Add second-line triage queue board automation (#61918)
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
This commit is contained in:
1 parent
e974c3b920
commit
543d3aaf43
3 files changed
+272
No files matched your search
@@ -19,6 +19,7 @@ on:
|
||||
- "Guild Stale Assignments"
|
||||
- "Guild Weekly Shipped"
|
||||
- "Guild New PR Notification"
|
||||
- "Issue Triage Queue Board"
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user