From 543d3aaf4300ba557992b85101fdfc579b7867c3 Mon Sep 17 00:00:00 2001 From: Lena <241371603+zelenenka@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:12:54 +0200 Subject: [PATCH] Add second-line triage queue board automation (#61918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...ck_notify_community_automation_failure.yml | 1 + .github/workflows/triage_queue_board.yml | 62 ++++++ script/github-triage-queue-board.py | 209 ++++++++++++++++++ 3 files changed, 272 insertions(+) create mode 100644 .github/workflows/triage_queue_board.yml create mode 100644 script/github-triage-queue-board.py diff --git a/.github/workflows/slack_notify_community_automation_failure.yml b/.github/workflows/slack_notify_community_automation_failure.yml index 71e490cd02..10fc16fe16 100644 --- a/.github/workflows/slack_notify_community_automation_failure.yml +++ b/.github/workflows/slack_notify_community_automation_failure.yml @@ -19,6 +19,7 @@ on: - "Guild Stale Assignments" - "Guild Weekly Shipped" - "Guild New PR Notification" + - "Issue Triage Queue Board" types: [completed] permissions: diff --git a/.github/workflows/triage_queue_board.yml b/.github/workflows/triage_queue_board.yml new file mode 100644 index 0000000000..db80b4a22b --- /dev/null +++ b/.github/workflows/triage_queue_board.yml @@ -0,0 +1,62 @@ +# Sync GitHub project #87 to the current second-line triage shift's issues + +name: Issue Triage Queue Board + +on: + schedule: + - cron: "5 * * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Log the diff without mutating the board" + type: boolean + default: false + +permissions: {} + +concurrency: + group: triage-queue-board + cancel-in-progress: true + +jobs: + sync: + name: Sync triage queue board + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 10 + permissions: + contents: read + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + repositories: zed + permission-issues: read + permission-organization-projects: write + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: script/github-triage-queue-board.py + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Sync + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "87" + DRY_RUN: ${{ inputs.dry_run }} + run: python script/github-triage-queue-board.py diff --git a/script/github-triage-queue-board.py b/script/github-triage-queue-board.py new file mode 100644 index 0000000000..4eb1c5e354 --- /dev/null +++ b/script/github-triage-queue-board.py @@ -0,0 +1,209 @@ +#!/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")