Skip to content

Commit

Permalink
Merge pull request #655 from ebmdatalab/iaindillingham/codespaces
Browse files Browse the repository at this point in the history
Identify Codespaces at risk of deletion
  • Loading branch information
iaindillingham authored Dec 11, 2024
2 parents c8d3755 + 83d7f5f commit a86974c
Show file tree
Hide file tree
Showing 5 changed files with 124 additions and 0 deletions.
19 changes: 19 additions & 0 deletions bennettbot/job_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,25 @@
},
],
},
"codespaces": {
"description": "Codespaces monitoring tools",
"jobs": {
"at_risk": {
"run_args_template": "python codespaces.py",
"report_stdout": True,
"report_format": "blocks",
},
},
"slack": [
{
"command": "at risk",
"help": "Shows the Codespaces that are at risk of being deleted",
"action": "schedule_job",
"job_type": "at_risk",
"delay_seconds": 0,
},
],
},
}
# fmt: on

Expand Down
1 change: 1 addition & 0 deletions dotenv-sample
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ SLACK_APP_USERNAME=changeme
GITHUB_WEBHOOK_SECRET=changeme
WEBHOOK_ORIGIN=http://changeme:1234
DATA_TEAM_GITHUB_API_TOKEN=changeme
CODESPACES_GITHUB_API_TOKEN=changeme
2 changes: 2 additions & 0 deletions scripts/local-setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ elif test "$SLACK_BOT_TOKEN" = "changeme" -o -z "$SLACK_BOT_TOKEN" -o "$FORCE_UP
SLACK_APP_TOKEN_BW_ID=3738619b-4e7d-4932-84b0-b1e700942908
SLACK_SIGNING_SECRET_BW_ID=946080f1-c50f-4edf-9773-b1e70095747a
DATA_TEAM_GITHUB_API_TOKEN_BW_ID=3c8ca5df-2fa1-49ac-afd6-b1e70092fd2a
CODESPACES_GITHUB_API_TOKEN_BW_ID=4d64924a-18c5-48a7-8962-b23e00c896ac
GCP_BW_ID=62511176-c5bd-4f9c-8de6-b1e700f570b1

# ensure we have latest passwords
Expand All @@ -82,6 +83,7 @@ elif test "$SLACK_BOT_TOKEN" = "changeme" -o -z "$SLACK_BOT_TOKEN" -o "$FORCE_UP
ensure_value SLACK_APP_TOKEN "$(bw get password $SLACK_APP_TOKEN_BW_ID)" "$ENV_FILE"
ensure_value SLACK_SIGNING_SECRET "$(bw get password $SLACK_SIGNING_SECRET_BW_ID)" "$ENV_FILE"
ensure_value DATA_TEAM_GITHUB_API_TOKEN "$(bw get password $DATA_TEAM_GITHUB_API_TOKEN_BW_ID)" "$ENV_FILE"
ensure_value CODESPACES_GITHUB_API_TOKEN "$(bw get password $CODESPACES_GITHUB_API_TOKEN_BW_ID)" "$ENV_FILE"

# create/update the GCP credentials file with the JSON retrieved from bitwarden
echo "Writing credentials to $GCP_CREDENTIALS_PATH"
Expand Down
Empty file.
102 changes: 102 additions & 0 deletions workspace/codespaces/codespaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""
Queries the GitHub REST API "List Codespaces for the organization" endpoint
and shows the Codespaces that are at risk of being deleted.
"""

import collections
import datetime
import json
import os

import requests

from workspace.utils import blocks


URL_PATTERN = "https://api.github.com/orgs/{org}/codespaces"
# The following can be a classic PAT with the admin:org scope or a fine-grained token
# with "Codespaces" repository permissions set to "read" and "Organization codespaces"
# organization permissions set to "read". For more information, see:
# https://docs.github.com/en/rest/codespaces/organizations?apiVersion=2022-11-28#list-codespaces-for-the-organization
TOKEN = os.environ["CODESPACES_GITHUB_API_TOKEN"]
HEADERS = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}


Codespace = collections.namedtuple(
"Codespace",
["last_used_days_ago", "owner", "name", "has_unpushed", "has_uncommitted"],
)


def fetch(url, key):
"""
Recursively fetch all records from the GitHub REST API endpoint given by `url`.
`key` is the property under which the records are stored.
"""

def set_bearer_auth(request):
request.headers["Authorization"] = f"Bearer {TOKEN}"
return request

def fetch_one_page(page_url):
response = requests.get(page_url, auth=set_bearer_auth, headers=HEADERS)
response.raise_for_status()
yield from response.json().get(key)
if "next" in response.links:
yield from fetch_one_page(response.links["next"]["url"])

yield from fetch_one_page(url)


def get_codespace(record):
now = datetime.datetime.now(datetime.timezone.utc)
last_used_at = datetime.datetime.fromisoformat(record["last_used_at"])
last_used_days_ago = (now - last_used_at).days
owner = record["owner"]["login"]
name = record["name"]
has_unpushed = record["git_status"]["has_unpushed_changes"] == "true"
has_uncommitted = record["git_status"]["has_uncommitted_changes"] == "true"
return Codespace(last_used_days_ago, owner, name, has_unpushed, has_uncommitted)


def is_at_risk(codespace, threshold_in_days):
is_dormant = codespace.last_used_days_ago >= threshold_in_days
return is_dormant and (codespace.has_unpushed or codespace.has_uncommitted)


def main():
org = "opensafely"
threshold_in_days = 20

records = fetch(URL_PATTERN.format(org=org), "codespaces")
codespaces = (get_codespace(rec) for rec in records)
at_risk_codespaces = sorted(
(cs for cs in codespaces if is_at_risk(cs, threshold_in_days)),
key=lambda cs: cs.last_used_days_ago,
reverse=True,
)

if at_risk_codespaces:
items = [
(
f"`{cs.owner}` has a Codespace that they last used {cs.last_used_days_ago} days ago "
f"({cs.name}).\n"
f"Unpushed changes: {'Yes' if cs.has_unpushed else 'No'} | "
f"Uncommitted changes: {'Yes' if cs.has_uncommitted else 'No'}\n\n"
)
for cs in at_risk_codespaces
]
body = f"The following `{org}` Codespaces are at risk of deletion.\n\n"
body += "".join(items)
else:
body = f"No `{org}` Codespaces are at risk of deletion :tada:"

header = "Codespaces Report"
return json.dumps(blocks.get_basic_header_and_text_blocks(header, body))


if __name__ == "__main__":
print(main())

0 comments on commit a86974c

Please sign in to comment.