mirror of
https://github.com/zed-industries/zed.git
synced 2026-08-03 12:14:51 +00:00
GitHub issue search can't express the one filter we want here: "created at least one business day ago." That's the whole reason the project board #\87 exists — since we can't save it as a search, we keep a project's membership in sync to stand in for it. So: an hourly workflow runs script/github-triage-queue-board.py, which adds the open issues that fall in the eligibility window to project #\87 and removes the ones that have aged out. Also wires the workflow into the community automation failure Slack alert, so if it breaks we hear about it. Release Notes: - N/A
209 lines
7.6 KiB
Python
209 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Maintain a GitHub project as a search filter for second-line issue triage.
|
|
|
|
GitHub issue search can't express "created at least one business day ago", so
|
|
this script keeps the project's membership equal to the open issues created in
|
|
that window, adding issues that have become eligible and removing the ones that
|
|
have aged out.
|
|
|
|
Requires:
|
|
requests (pip install requests)
|
|
|
|
Usage (called by the workflow, not directly):
|
|
python github-triage-queue-board.py
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
import urllib.parse
|
|
from datetime import datetime, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import requests
|
|
|
|
RETRYABLE_STATUS_CODES = {502, 503, 504}
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAY_SECONDS = 5
|
|
|
|
GITHUB_API_URL = "https://api.github.com"
|
|
REPO_OWNER = "zed-industries"
|
|
REPO_NAME = "zed"
|
|
# The eligibility window's boundaries are calendar days in this timezone. GitHub
|
|
# search interprets bare dates as UTC, so the window is pinned to a fixed zone to
|
|
# stay aligned with a consistent business day regardless of where the job runs.
|
|
# ZoneInfo (not a fixed offset) so daylight saving is handled automatically.
|
|
BUSINESS_DAY_TZ = ZoneInfo("America/Los_Angeles")
|
|
|
|
|
|
def github_graphql(query, variables):
|
|
for attempt in range(MAX_RETRIES + 1):
|
|
response = requests.post(
|
|
f"{GITHUB_API_URL}/graphql",
|
|
headers=GITHUB_HEADERS,
|
|
json={"query": query, "variables": variables},
|
|
timeout=30,
|
|
)
|
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
print(
|
|
f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..."
|
|
)
|
|
time.sleep(RETRY_DELAY_SECONDS)
|
|
continue
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
if "errors" in result:
|
|
raise RuntimeError(f"GraphQL error: {result['errors']}")
|
|
return result["data"]
|
|
raise RuntimeError("github_graphql: retry loop exited without return")
|
|
|
|
|
|
def github_rest_get(path):
|
|
for attempt in range(MAX_RETRIES + 1):
|
|
response = requests.get(
|
|
f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS, timeout=30
|
|
)
|
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
print(
|
|
f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..."
|
|
)
|
|
time.sleep(RETRY_DELAY_SECONDS)
|
|
continue
|
|
response.raise_for_status()
|
|
return response.json()
|
|
raise RuntimeError("github_rest_get: retry loop exited without return")
|
|
|
|
|
|
def business_day_end(day):
|
|
# Latest instant of a calendar day in the business-day timezone.
|
|
return datetime(day.year, day.month, day.day, 23, 59, 59, tzinfo=BUSINESS_DAY_TZ)
|
|
|
|
|
|
def eligibility_window(today):
|
|
# Returns the (start, end) instants bounding the window. `start` is exclusive
|
|
# and `end` inclusive (see eligible_issues); both are day-ends so the whole
|
|
# from-day is excluded and the whole to-day included. Returning full
|
|
# timestamps rather than bare dates keeps GitHub search, which reads bare
|
|
# dates as UTC, from skewing the window by up to a day at the UTC boundary.
|
|
weekday = today.isoweekday() # Monday = 1
|
|
# Nothing advances over the weekend, so compute as of the preceding Friday.
|
|
if weekday >= 6:
|
|
today -= timedelta(days=weekday - 5)
|
|
weekday = 5
|
|
one_biz_day_ago = today - timedelta(days=3 if weekday == 1 else 1)
|
|
this_monday = today - timedelta(days=weekday - 1)
|
|
eligible_from = this_monday - timedelta(days=4)
|
|
return business_day_end(eligible_from), business_day_end(one_biz_day_ago)
|
|
|
|
|
|
def eligible_issues(window_start, window_end):
|
|
# created:> excludes window_start; created:<= includes window_end.
|
|
query = (
|
|
f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open "
|
|
f"created:>{window_start.isoformat()} created:<={window_end.isoformat()}"
|
|
)
|
|
issues = {}
|
|
page = 1
|
|
while True:
|
|
data = github_rest_get(
|
|
f"search/issues?q={urllib.parse.quote(query)}&per_page=100&page={page}"
|
|
)
|
|
for item in data["items"]:
|
|
issues[item["node_id"]] = item["number"]
|
|
if len(data["items"]) < 100 or page >= 10:
|
|
return issues
|
|
page += 1
|
|
|
|
|
|
def fetch_project_id(project_number):
|
|
data = github_graphql(
|
|
"""
|
|
query($owner: String!, $number: Int!) {
|
|
organization(login: $owner) { projectV2(number: $number) { id } }
|
|
}
|
|
""",
|
|
{"owner": REPO_OWNER, "number": project_number},
|
|
)
|
|
project = data["organization"]["projectV2"]
|
|
if not project:
|
|
raise RuntimeError(f"project #{project_number} not found")
|
|
return project["id"]
|
|
|
|
|
|
def project_items(project_id):
|
|
# Yields (item_id, content_id, number) for each issue on the project.
|
|
# content_id is the issue's global node id, which is the same value the REST
|
|
# search API returns as node_id, so it can be compared directly against the
|
|
# keys of eligible_issues().
|
|
cursor = None
|
|
while True:
|
|
data = github_graphql(
|
|
"""
|
|
query($project: ID!, $cursor: String) {
|
|
node(id: $project) {
|
|
... on ProjectV2 {
|
|
items(first: 100, after: $cursor) {
|
|
pageInfo { hasNextPage endCursor }
|
|
nodes { id content { ... on Issue { id number } } }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
{"project": project_id, "cursor": cursor},
|
|
)
|
|
items = data["node"]["items"]
|
|
for item in items["nodes"]:
|
|
content = item["content"] or {}
|
|
if "id" in content: # skip drafts / PRs (no Issue content)
|
|
yield item["id"], content["id"], content["number"]
|
|
if not items["pageInfo"]["hasNextPage"]:
|
|
return
|
|
cursor = items["pageInfo"]["endCursor"]
|
|
|
|
|
|
def add_to_project(project_id, content_id):
|
|
github_graphql(
|
|
"mutation($p: ID!, $c: ID!) { addProjectV2ItemById(input: {projectId: $p, contentId: $c}) { item { id } } }",
|
|
{"p": project_id, "c": content_id},
|
|
)
|
|
|
|
|
|
def remove_from_project(project_id, item_id):
|
|
github_graphql(
|
|
"mutation($p: ID!, $i: ID!) { deleteProjectV2Item(input: {projectId: $p, itemId: $i}) { deletedItemId } }",
|
|
{"p": project_id, "i": item_id},
|
|
)
|
|
|
|
|
|
def sync_project(project_id, dry_run=False):
|
|
today = datetime.now(BUSINESS_DAY_TZ).date()
|
|
window_start, window_end = eligibility_window(today)
|
|
print(f"window: created >{window_start.isoformat()} <={window_end.isoformat()}")
|
|
|
|
eligible = eligible_issues(window_start, window_end)
|
|
current = list(project_items(project_id))
|
|
on_board = {content_id for _, content_id, _ in current}
|
|
|
|
for content_id, number in eligible.items():
|
|
if content_id not in on_board:
|
|
print(f"+ #{number}")
|
|
if not dry_run:
|
|
add_to_project(project_id, content_id)
|
|
|
|
for item_id, content_id, number in current:
|
|
if content_id not in eligible:
|
|
print(f"- #{number}")
|
|
if not dry_run:
|
|
remove_from_project(project_id, item_id)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
GITHUB_HEADERS = {
|
|
"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
project_number = int(os.environ["PROJECT_NUMBER"])
|
|
project_id = fetch_project_id(project_number)
|
|
sync_project(project_id, os.environ.get("DRY_RUN") == "true")
|