Skip to content

Commit

Permalink
Identify Codespaces at risk of deletion
Browse files Browse the repository at this point in the history
Adds a `codespaces at risk` command to identify researchers whose
Codespaces are at risk of deletion. The `org` and `threshold_in_days`
variables aren't parametrised, but could be should different groups of
users have different requirements.

Closes opensafely-core/research-template-docker#70
  • Loading branch information
iaindillingham committed Dec 5, 2024
1 parent cf3b992 commit 291e65e
Show file tree
Hide file tree
Showing 3 changed files with 112 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
Empty file.
93 changes: 93 additions & 0 deletions workspace/codespaces/codespaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import collections
import datetime
import json
import os

import requests

from workspace.utils import blocks


URL_PATTERN = "https://api.github.com/orgs/{org}/codespaces"
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_codespaces_for_org(org):
now = datetime.datetime.now(datetime.timezone.utc)
records = fetch(URL_PATTERN.format(org=org), "codespaces")
for record in records:
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"
yield Codespace(last_used_days_ago, owner, name, has_unpushed, has_uncommitted)


def is_at_risk(codespace, threshold_in_days):
return codespace.last_used_days_ago >= threshold_in_days


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

codespaces = get_codespaces_for_org(org)
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 291e65e

Please sign in to comment.