gcal.py (71 lines of code) (raw):

# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. """A module with APIs to create Performance Triage reminders on Google Calendar. Expected usage: creds = auth_as_user() service = get_calendar_service(creds) send_triage_reminder(service, ...) To use this API, you must: 1. Create or reuse a Google Cloud Project with access to the Google Calendar API: we created the perf-triage-reminder project. 2. Download the credentials for that project to PATH_GCLOUD_PROJECT_SECRETS. Note: this is only used to fetch credentials locally and is not used in automation. If you are using this API in automation, you must also: 3. Run these APIs manually, locally before running it automation. You will be prompted to sign in to a Google account via a browser: preferably, you should use an account independent of a specific person (i.e. a service account): we created perf.triage.bot. 4. Add the cached secrets at PATH_CACHED_USER_SECRETS or ENV_CACHED_USER_SECRETS ^ to your automation environment. ## Resources shared across the performance team We have set up shared projects and accounts - this is a summary of them: - Performance Team calendar - perf.triage.bot Google Account: this is our service account that we use to run in GitHub Actions. It is a Google Account external to the Mozilla organization that has edit permissions to the Performance Team Google Calendar. Access is granted through 1Password Vault. - perf-triage-reminder Google Cloud Project: this project gives access to the Google Calendar APIs and enables sign in to get credentials. Access is granted through the performance team mailing lists - perf.triage.bot 1Password Vault: stores the credentials for ^. Access is granted to individuals because that's the only way it seems to work. WARNING: as folks leave Mozilla, we must make sure folks continue to have access to the 1Password Vault so we don't lose access to the Google Account. If we do, we'll have to create a new one and take time to set it up. ## Alternative implementations Google has a newer, safer form of authorization for server-to-server applications like this one: https://cloud.google.com/iam/docs/workload-identity-federation However, I didn't know if it'd work for this use case and I wanted to keep the implementation quick so I did what was familiar. Instead of creating a service account external to the Mozilla google organization, we could create a service account inside of it. However, it requires IT to grant the account the "Domain-Wide Delegation Authority" permission, which grants service accounts access to user data which is necessary to invite users to calendar events. However, they chose not to grant the permission. As we understand it, this would give the account slightly more access than the current set up (it can access internal-only Mozilla organization calendars) but it would be easier to keep track of and safely share the account. Instead of creating a service account, a Mozilla employee could use their credentials (e.g. on cron). However, this has a failure point when they leave and the credentials need to be updated. Instead of Google Calendar reminders, we could send Matrix reminders or emails but we didn't think this was as useful. """ from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build from googleapiclient.errors import HttpError import json import os import os.path import sys # List of scopes: https://developers.google.com/identity/protocols/oauth2/scopes#calendar # If these scopes are modified, users (including you) will need to delete the # PATH_CACHED_USER_SECRETS file. SCOPES = ["https://www.googleapis.com/auth/calendar.events"] # 'Performance Team' calendar ID. ID_CALENDAR = "mozilla.com_9bk5f2rqdeuip38jbeld84kpqc@group.calendar.google.com" # This file is downloaded from the Google Cloud API console for the project perf-triage-reminder. It # is used to enable the OAuth flow and track/rate-limit the requests made to the GCal API. PATH_GCLOUD_PROJECT_SECRETS = ".google-cloud-project-secrets.json" # This file is generated by the OAuth flow. The file is specific to the user account used to sign # in. The PATH_CACHED_USER_SECRETS = ".google-user-token.json" ENV_CACHED_USER_SECRETS = 'PERF_TRIAGE_BOT_CACHED_USER_SECRETS' IN_AUTOMATION = True if os.environ.get('CI') else False # CI is always true on GitHub Actions. DESCRIPTION = """{lead_sheriff} as Triage Sheriff #1, can you please take the lead to coordinate a date/time this week? For the latest guidelines, please see https://wiki.mozilla.org/Performance/Triage. -- Sent by the friendly scripts at https://github.com/mozilla/perf-triage/""" class CredentialException(Exception): pass def _fetch_cached_user_credentials(): log_prefix = 'Fetching cached user credentials from' env_secrets = os.environ.get(ENV_CACHED_USER_SECRETS) if env_secrets: print(f'{log_prefix} environment variable') creds = Credentials.from_authorized_user_info(json.loads(env_secrets), SCOPES) elif os.path.exists(PATH_CACHED_USER_SECRETS): print(f'{log_prefix} local file') creds = Credentials.from_authorized_user_file(PATH_CACHED_USER_SECRETS, SCOPES) else: print('Cached user credentials not found') creds = None return creds # via https://developers.google.com/calendar/api/quickstart/python def auth_as_user(): """Prompt the user to authorize access to their calendars via a web browser and returns credentials on success. If this is called a second time, the user will not be prompted because a cached version will be used. To call this API locally, you must get a PATH_GCLOUD_PROJECT_SECRETS: see the top-of-file comment. """ creds = _fetch_cached_user_credentials() if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: creds.refresh(Request()) elif IN_AUTOMATION: raise CredentialException(('Credentials unavailable for refresh. Credentials must be ' 'refetched manually and updated in secrets.')) else: flow = InstalledAppFlow.from_client_secrets_file(PATH_GCLOUD_PROJECT_SECRETS, SCOPES) creds = flow.run_local_server(port=0) if not IN_AUTOMATION: # Don't save secrets to disk. with open(PATH_CACHED_USER_SECRETS, "w") as token: token.write(creds.to_json()) return creds # via https://developers.google.com/calendar/api/quickstart/python def get_calendar_service(creds): """Returns the Google Calendar API object.""" try: return build("calendar", "v3", credentials=creds) except HttpError as error: print("Unable to fetch calendar service: {}".format(error), file=sys.stderr) raise error def send_triage_reminder(service, date, emails): """Adds a triage reminder event to the Performance Team Google Calendar.""" details = { "summary": "Reminder: perf triage rotation", "description": DESCRIPTION.format(lead_sheriff=emails[0]), "start": { "dateTime": "{}T17:00:00Z".format(date), # utc. }, "end": { "dateTime": "{}T17:30:00Z".format(date), # utc. }, "attendees": [{"email": email} for email in emails], # Not sure if this key actually works. "sendNotifications": True, # send an "Invitation: ..." email to attendees. } try: event = service.events().insert(calendarId=ID_CALENDAR, body=details).execute() print("Event created: {}".format(event.get("htmlLink"))) except HttpError as error: print("Error when creating event: {}".format(error), file=sys.stderr) raise error