Skip to content

Commit

Permalink
Automatically schedule an incident retro (#442)
Browse files Browse the repository at this point in the history
* Adding changes for calendar functionality

* Adding unit tests for google calendar

* Linting and formatting

* Removing a comment

* Adding a comment

* Adding the sre bot email to the github action
  • Loading branch information
sylviamclaughlin authored Apr 5, 2024
1 parent b00e6c3 commit 3a7ac0c
Show file tree
Hide file tree
Showing 8 changed files with 975 additions and 3 deletions.
3 changes: 2 additions & 1 deletion .github/workflows/ci_code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,5 @@ jobs:
NOTIFY_TEST_KEY: ${{ secrets.NOTIFY_TEST_KEY }}
NOTIFY_SRE_USER_NAME: ${{ secrets.NOTIFY_SRE_USER_NAME }}
NOTIFY_SRE_CLIENT_SECRET: ${{ secrets.NOTIFY_SRE_CLIENT_SECRET }}
NOTIFY_OPS_CHANNEL_ID: ${{ secrets.NOTIFY_OPS_CHANNEL_ID }}
NOTIFY_OPS_CHANNEL_ID: ${{ secrets.NOTIFY_OPS_CHANNEL_ID }}
SRE_BOT_EMAIL: ${{ secrets.SRE_BOT_EMAIL }}
1 change: 1 addition & 0 deletions app/integrations/google_drive.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"https://www.googleapis.com/auth/drive.file",
"https://www.googleapis.com/auth/docs",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/calendar",
]


Expand Down
163 changes: 163 additions & 0 deletions app/integrations/google_workspace/google_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import os
import logging
from datetime import datetime, timedelta

import pytz
import json

from integrations.google_workspace.google_service import (
get_google_service,
handle_google_api_errors,
)

# Get the email for the SRE bot
SRE_BOT_EMAIL = os.environ.get("SRE_BOT_EMAIL")

# If modifying these scopes, delete the file token.json.
SCOPES = ["https://www.googleapis.com/auth/calendar"]


# Schedule a calendar event by finding the first available slot in the next 60 days that all participants are free in and book the event
@handle_google_api_errors
def schedule_event(event_details):
# initialize the google service
service = get_google_service(
"calendar", "v3", delegated_user_email=SRE_BOT_EMAIL, scopes=SCOPES
)

# Define the time range for the query
now = datetime.utcnow()
time_min = now.isoformat() + "Z" # 'Z' indicates UTC time
time_max = (now + timedelta(days=60)).isoformat() + "Z"

# Construct the items array
items = []
emails = json.loads(event_details).get("emails")
incident_name = json.loads(event_details).get("topic")
for email in emails:
email = email.strip()
items.append({"id": email})

# Construct the request body
freebusy_query = {
"timeMin": time_min,
"timeMax": time_max,
"items": items,
}

# Execute the query to find all the busy times for all the participants
freebusy_result = service.freebusy().query(body=freebusy_query).execute()

# return the first available slot to book the event
first_available_start, first_available_end = find_first_available_slot(
freebusy_result
)

# If there are no available slots, return None
if first_available_start is None and first_available_end is None:
logging.info("No available slots found")
return None

# Crete the event in everyone's calendar
event_link = book_calendar_event(
service, first_available_start, first_available_end, emails, incident_name
)

return event_link


# Create a calendar event in everyone's calendar
def book_calendar_event(service, start, end, emails, incident_name):
# Build the attendees array
attendees = []
for email in emails:
attendees.append({"email": email.strip()})

# Create the event
event = {
"summary": "Retro " + incident_name,
"description": "This is a retro meeting to discuss incident: " + incident_name,
"start": {"dateTime": start.isoformat()},
"end": {"dateTime": end.isoformat()},
"attendees": attendees,
"conferenceData": {
"createRequest": {
"requestId": f"{start.timestamp()}", # Unique ID per event to avoid collisions
"conferenceSolutionKey": {
"type": "hangoutsMeet" # This automatically generates a Google Meet link
},
}
},
"reminders": {
"useDefault": False,
"overrides": [
{"method": "popup", "minutes": 10},
],
},
}

# call the google calendar API to create the event and send an email to all attendees
event = (
service.events()
.insert(
calendarId="primary", body=event, conferenceDataVersion=1, sendUpdates="all"
)
.execute()
)

# Return the link to the calendar event
return event.get("htmlLink")


# Function to use the freebusy response to find the first available spot in the next 60 days. We look for a 30 minute windows, 3
# days in the future, ignoring weekends
def find_first_available_slot(
freebusy_response, duration_minutes=30, days_in_future=3, search_days_limit=60
):
# EST timezone
est = pytz.timezone("US/Eastern")

# Combine all busy times into a single list and sort them
busy_times = []
for calendar in freebusy_response["calendars"].values():
for busy_period in calendar["busy"]:
start = (
datetime.strptime(busy_period["start"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC)
.astimezone(est)
)
end = (
datetime.strptime(busy_period["end"], "%Y-%m-%dT%H:%M:%SZ")
.replace(tzinfo=pytz.UTC)
.astimezone(est)
)
busy_times.append((start, end))
busy_times.sort(key=lambda x: x[0])

for day_offset in range(days_in_future, days_in_future + search_days_limit):
# Calculate the start and end times of the search window for the current day
search_date = datetime.now(tz=est) + timedelta(days=day_offset)

# Check if the day is Saturday (5) or Sunday (6) and skip it
if search_date.weekday() in [5, 6]:
continue

search_start = search_date.replace(
hour=13, minute=0, second=0, microsecond=0
) # 1 PM EST
search_end = search_date.replace(
hour=15, minute=0, second=0, microsecond=0
) # 3 PM EST

# Attempt to find an available slot within this day's search window
for current_time in (
search_start + timedelta(minutes=i) for i in range(0, 121, duration_minutes)
):
slot_end = current_time + timedelta(minutes=duration_minutes)
if all(
slot_end <= start or current_time >= end for start, end in busy_times
):
if slot_end <= search_end:
return current_time, slot_end

return None, None # No available slot found after searching the limit
3 changes: 3 additions & 0 deletions app/modules/incident/incident.py
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,9 @@ def submit(ack, view, say, body, client, logger):
text = "Run `/sre incident close` to update the status of the incident document and incident spreadsheet to closed and to archive the channel"
say(text=text, channel=channel_id)

text = "Run `/sre incident schedule` to let the SRE bot schedule a Retro Google calendar meeting for all participants."
say(text=text, channel=channel_id)


def generate_success_modal(body, channel_id, channel_name):
locale = body["view"]["blocks"][0]["elements"][0]["value"]
Expand Down
Loading

0 comments on commit 3a7ac0c

Please sign in to comment.