Consolidate GitHub helpers for community scripts (#63198)

It's been fun for a while to have the same function calling GitHub API
in every single script, but we're going to have fun in different ways
now.

This one's for you, @MrSubidubi! 🥂 

## Testing

I've ran the things that had `--dry-run` locally and nothing blew up,
but the ultimate smoke test will be carried out in production.

---

Release Notes:

- N/A
This commit is contained in:
Lena 2026-09-04 11:40:05 +00:00 • committed by GitHub
parent 52af3dd60c
commit a7bc7d7736
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 338 additions and 520 deletions

View file

@ -32,12 +32,11 @@ import json
import os
import re
import sys
import time
from datetime import datetime, timedelta
import requests
from github_helpers import github_graphql, github_rest_api, post_github_comment
GITHUB_API = "https://api.github.com"
REPO_OWNER = "zed-industries"
REPO_NAME = "zed"
TRACKING_ISSUE_NUMBER = 46355
@ -54,70 +53,26 @@ STOPWORDS = {
"the", "this", "when", "while", "with", "won't", "work", "working", "zed",
}
# HTTP statuses we'll retry on for GET requests
TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504}
def log(message):
"""Print to stderr so it doesn't interfere with JSON output on stdout."""
print(message, file=sys.stderr)
def github_api_get(path, params=None):
"""Fetch JSON from the GitHub API, retrying transient failures. Raises on non-2xx status."""
url = f"{GITHUB_API}/{path.lstrip('/')}"
for attempt in range(3):
try:
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or (
isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES
)
if not transient or attempt == 2:
raise
wait = 2 ** attempt
log(f" Transient GitHub API error ({e}); retrying in {wait}s")
time.sleep(wait)
def github_search_issues(query, per_page=50, sort=None):
"""Search issues, using GitHub's relevance ordering unless a sort is specified."""
params = {"q": query, "per_page": per_page}
if sort:
params.update({"sort": sort, "order": "desc"})
return github_api_get("/search/issues", params).get("items", [])
def github_api_graphql(query, variables=None):
"""Run a GraphQL query against the GitHub API, retrying transient failures. """
url = f"{GITHUB_API}/graphql"
for attempt in range(3):
try:
response = requests.post(
url, headers=GITHUB_HEADERS, json={"query": query, "variables": variables or {}}
)
response.raise_for_status()
data = response.json()
if "errors" in data:
raise ValueError(f"GraphQL errors: {json.dumps(data['errors'])[:300]}")
return data["data"]
except requests.RequestException as e:
transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or (
isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES
)
if not transient or attempt == 2:
raise
wait = 2 ** attempt
log(f" Transient GitHub GraphQL error ({e}); retrying in {wait}s")
time.sleep(wait)
return github_rest_api("GET", "search/issues", params=params).get("items", [])
def check_team_membership(org, team_slug, username):
"""Check if user is an active member of a team."""
try:
data = github_api_get(f"/orgs/{org}/teams/{team_slug}/memberships/{username}")
data = github_rest_api(
"GET", f"orgs/{org}/teams/{team_slug}/memberships/{username}"
)
return data.get("state") == "active"
except requests.HTTPError as e:
if e.response.status_code == 404:
@ -125,13 +80,6 @@ def check_team_membership(org, team_slug, username):
raise
def post_comment(issue_number: int, body):
url = f"{GITHUB_API.rstrip('/')}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
response = requests.post(url, headers=GITHUB_HEADERS, json={"body": body})
response.raise_for_status()
log(f" Posted comment on #{issue_number}")
def format_candidate_reference(match):
candidate = match["candidate"]
if candidate["kind"] == "discussion":
@ -288,7 +236,9 @@ def fetch_issue(issue_number: int):
"""Fetch issue from GitHub and return as a dict."""
log(f"Fetching issue #{issue_number}")
issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}")
issue_data = github_rest_api(
"GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}"
)
issue = {
"number": issue_number,
"title": issue_data["title"],
@ -320,8 +270,9 @@ def fetch_area_labels():
labels = []
page = 1
while page_labels := github_api_get(
f"/repos/{REPO_OWNER}/{REPO_NAME}/labels",
while page_labels := github_rest_api(
"GET",
f"repos/{REPO_OWNER}/{REPO_NAME}/labels",
params={"per_page": 100, "page": page},
):
labels.extend(page_labels)
@ -481,7 +432,9 @@ def parse_duplicate_magnets():
"""
log(f"Parsing duplicate magnets from #{TRACKING_ISSUE_NUMBER}")
issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{TRACKING_ISSUE_NUMBER}")
issue_data = github_rest_api(
"GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{TRACKING_ISSUE_NUMBER}"
)
body = issue_data.get("body") or ""
# parse the issue body
@ -528,7 +481,9 @@ def enrich_magnets(magnets):
"""Fetch details for magnets and normalize them as candidates."""
log(f" Fetching details for {len(magnets)} magnets")
for magnet in magnets:
data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{magnet['number']}")
data = github_rest_api(
"GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{magnet['number']}"
)
magnet.update({
"key": f"issue:{magnet['number']}",
"kind": "issue",
@ -743,8 +698,9 @@ def enrich_popular_candidate_comments(candidates):
):
continue
try:
comments = github_api_get(
f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{candidate['number']}/comments",
comments = github_rest_api(
"GET",
f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{candidate['number']}/comments",
params={"per_page": 100},
)
except requests.RequestException as error:
@ -795,7 +751,7 @@ def search_discussions(issue, detected_areas, search_queries, max_searches=6):
for search_type, query in queries[:max_searches]:
log(f" Discussion search ({search_type}): {query}")
try:
data = github_api_graphql(gql, {"q": query})
data = github_graphql(gql, {"q": query})
for result_rank, node in enumerate(data["search"]["nodes"]):
if not node:
continue
@ -1322,22 +1278,12 @@ if __name__ == "__main__":
parser.add_argument("--dry-run", action="store_true", help="Skip posting comment, just log what would be posted")
args = parser.parse_args()
github_token = os.environ.get("GITHUB_TOKEN")
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
if not github_token:
log("Error: GITHUB_TOKEN not set")
sys.exit(1)
if not anthropic_key:
log("Error: ANTHROPIC_API_KEY not set")
sys.exit(1)
GITHUB_HEADERS = {
"Authorization": f"Bearer {github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
issue = fetch_issue(args.issue_number)
if should_skip(issue):
print(json.dumps({"skipped": True}))
@ -1376,7 +1322,10 @@ if __name__ == "__main__":
else:
log("Posting comment")
try:
post_comment(issue["number"], comment_body)
post_github_comment(
REPO_OWNER, REPO_NAME, issue["number"], comment_body
)
log(f" Posted comment on #{issue['number']}")
commented = True
except requests.RequestException as e:
log(f" Failed to post comment: {e}")

View file

@ -46,16 +46,17 @@ Usage (called by the workflow, not directly):
import json
import os
import sys
import time
from pathlib import Path
import requests
from github_helpers import (
add_github_project_item,
fetch_github_project,
github_graphql,
github_rest_api,
set_github_project_field,
)
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"
STAFF_TEAM_SLUG = "staff"
@ -81,7 +82,7 @@ def sync_track_from_labels(pr, project_number):
pr_labels = {label["name"] for label in pr.get("labels", [])}
track_name = resolve_track(pr_labels, load_mapping())
project = github_fetch_project(project_number)
project = fetch_github_project(project_number)
project_item = github_find_project_item(project["id"], pr["node_id"])
if not track_name:
@ -97,8 +98,8 @@ def sync_track_from_labels(pr, project_number):
print(f"Resolved track: {track_name}")
if not project_item:
project_item = github_add_to_project(project["id"], pr["node_id"])
github_set_project_field(project, project_item, "Track", track_name)
project_item = add_github_project_item(project["id"], pr["node_id"])
set_github_project_field(project, project_item, "Track", track_name)
return project, project_item
@ -108,13 +109,13 @@ def set_progress_status_on_assignment(pr, assignee_login, project_number):
print(f"Assignee '{assignee_login}' is not a staff member, skipping")
return
project = github_fetch_project(project_number)
project = fetch_github_project(project_number)
item_id = github_find_project_item(project["id"], pr["node_id"])
if not item_id:
print(f"PR #{pr['number']} not on board, skipping assignment status update")
return
github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
set_github_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
def return_to_reviewer(pr, project_number, reason):
@ -123,7 +124,7 @@ def return_to_reviewer(pr, project_number, reason):
Called when the author signals they're ready for re-review, either
by re-requesting review or by commenting on the PR.
"""
project = github_fetch_project(project_number)
project = fetch_github_project(project_number)
item_id = github_find_project_item(project["id"], pr["node_id"])
if not item_id:
print(f"PR #{pr['number']} not on board, skipping")
@ -134,7 +135,7 @@ def return_to_reviewer(pr, project_number, reason):
print(
f"{reason}, flipping status from '{current_status}' to '{STATUS_IN_PROGRESS_US}'"
)
github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
set_github_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
else:
print(f"Current status is '{current_status}', not flipping ({reason})")
@ -193,7 +194,7 @@ def recompute_signals(pr, project, project_item):
def refresh_signals_if_on_board(pr, project_number):
"""Recompute signals for a PR only if it's already on the board."""
project = github_fetch_project(project_number)
project = fetch_github_project(project_number)
project_item = github_find_project_item(project["id"], pr["node_id"])
if not project_item:
print(f"PR #{pr['number']} not on board, skipping signal refresh")
@ -207,7 +208,7 @@ def refresh_all_board_items(project_number):
Backstop for the daily refresh workflow. Individual PR failures are
logged and don't abort the rest of the run.
"""
project = github_fetch_project(project_number)
project = fetch_github_project(project_number)
processed = skipped = errors = 0
for item in github_list_project_items(project["id"]):
if item.get("isArchived"):
@ -262,115 +263,28 @@ def resolve_track(pr_labels, tracks):
return None
def github_graphql(query, variables):
"""Execute a GitHub GraphQL query. Retries on transient server errors."""
for attempt in range(MAX_RETRIES + 1):
response = requests.post(
f"{GITHUB_API_URL}/graphql",
headers=GITHUB_HEADERS,
json={"query": query, "variables": variables},
)
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):
"""GET from the GitHub REST API. Retries on transient server errors."""
for attempt in range(MAX_RETRIES + 1):
response = requests.get(f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS)
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 github_is_staff_member(username):
"""Check if a user is a member of the staff team."""
try:
response = requests.get(
f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members/{username}",
headers=GITHUB_HEADERS,
github_rest_api(
"GET", f"orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members/{username}"
)
if response.status_code == 204:
return True
if response.status_code == 404:
return True
except requests.HTTPError as error:
if error.response.status_code == 404:
return False
print(
f"Warning: unexpected status {response.status_code} checking staff membership for '{username}'"
)
print(f"Warning: failed to check staff membership for '{username}': {error}")
return False
except requests.RequestException as exc:
print(f"Warning: failed to check staff membership for '{username}': {exc}")
except requests.RequestException as error:
print(f"Warning: failed to check staff membership for '{username}': {error}")
return False
def github_fetch_pr(pr_number):
"""Fetch a PR by number via the REST API."""
return github_rest_get(f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}")
return github_rest_api("GET", f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}")
def github_fetch_project(project_number):
"""Fetch a GitHub project board's metadata including fields and their options."""
data = github_graphql(
"""
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
fields(first: 50) {
nodes {
... on ProjectV2Field {
id
name
dataType
}
... on ProjectV2SingleSelectField {
id
name
options { id name }
}
}
}
}
}
}
""",
{"owner": REPO_OWNER, "number": project_number},
)
return data["organization"]["projectV2"]
def github_add_to_project(project_id, content_node_id):
"""Add a PR to the project board. Returns the new project item ID."""
data = github_graphql(
"""
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item { id }
}
}
""",
{"projectId": project_id, "contentId": content_node_id},
)
item_id = data["addProjectV2ItemById"]["item"]["id"]
print(f"Added PR to board (item: {item_id})")
return item_id
def github_list_project_items(project_id):
"""Yield every item on a project board, paginating as needed.
@ -563,58 +477,6 @@ def github_find_project_item(project_id, content_node_id):
return None
def github_set_project_field(project, item_id, field_name, option_name):
"""Set a single-select field on a project item."""
field_id = None
option_id = None
for field in project["fields"]["nodes"]:
if field.get("name") == field_name:
field_id = field["id"]
for option in field.get("options", []):
if option["name"] == option_name:
option_id = option["id"]
break
break
if not field_id:
available = [f["name"] for f in project["fields"]["nodes"] if "name" in f]
raise RuntimeError(
f"Field '{field_name}' not found on project. Available: {available}"
)
if not option_id:
available = [
opt["name"]
for f in project["fields"]["nodes"]
if f.get("name") == field_name
for opt in f.get("options", [])
]
raise RuntimeError(
f"Option '{option_name}' not found in field '{field_name}'. "
f"Available: {available}"
)
github_graphql(
"""
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}
""",
{
"projectId": project["id"],
"itemId": item_id,
"fieldId": field_id,
"optionId": option_id,
},
)
print(f"Set '{field_name}' to '{option_name}'")
def set_field_optional(project, item_id, field_name, option_name):
"""Set a single-select field, logging and skipping if the field or
@ -764,11 +626,6 @@ def github_get_field_value(item_id, field_name):
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"])

View file

@ -29,18 +29,20 @@ import html
import json
import os
import random
import time
import urllib.parse
from datetime import datetime, timedelta, timezone
from functools import lru_cache
import requests
from github_helpers import (
fetch_github_project,
github_graphql,
github_rest_api,
github_rest_get_paginated,
post_github_comment,
set_github_project_field,
)
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"
# Cohort members are outside collaborators on the repo holding this custom
@ -95,71 +97,23 @@ ZEDGAR_QUIPS = [
]
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:
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_request(method, path, body=None):
url = f"{GITHUB_API_URL}/{path}"
for attempt in range(MAX_RETRIES + 1):
response = requests.request(
method, url, headers=GITHUB_HEADERS, json=body, timeout=30
)
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
time.sleep(RETRY_DELAY_SECONDS)
continue
response.raise_for_status()
if response.status_code == 204 or not response.content:
return None
return response.json()
raise RuntimeError("github_rest_request: retry loop exited without return")
def github_rest_get_paginated(path):
results = []
page = 1
while True:
separator = "&" if "?" in path else "?"
batch = github_rest_request("GET", f"{path}{separator}per_page=100&page={page}")
if not batch:
break
results.extend(batch)
if len(batch) < 100:
break
page += 1
return results
@lru_cache(maxsize=None)
def is_guild_member(username):
response = requests.get(
f"{GITHUB_API_URL}/repos/{REPO_OWNER}/{REPO_NAME}/collaborators/{username}/permission",
headers=GITHUB_HEADERS,
timeout=30,
)
# 404 means the user isn't a collaborator on the repo at all.
if response.status_code == 404:
return False
response.raise_for_status()
try:
response = github_rest_api(
"GET",
f"repos/{REPO_OWNER}/{REPO_NAME}/collaborators/{username}/permission",
)
except requests.HTTPError as error:
# 404 means the user isn't a collaborator on the repo at all.
if error.response.status_code == 404:
return False
raise
# role_name is the effective (highest) role for the user. For a cohort of
# outside collaborators whose only grant is this custom role, that is the
# custom role's name; built-in roles come back lowercased and won't match.
role_name = response.json().get("role_name") or ""
role_name = response.get("role_name") or ""
return role_name.lower() == GUILD_ROLE_NAME.lower()
@ -231,30 +185,6 @@ def pr_closing_issues(pr_node_id):
return (node.get("closingIssuesReferences") or {}).get("nodes", [])
def fetch_project(project_number):
data = github_graphql(
"""
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
fields(first: 50) {
nodes {
... on ProjectV2Field { id name dataType }
... on ProjectV2SingleSelectField { id name options { id name } }
}
}
}
}
}
""",
{"owner": REPO_OWNER, "number": project_number},
)
project = data["organization"]["projectV2"]
if not project:
raise RuntimeError(f"Project #{project_number} not found in {REPO_OWNER}")
return project
def find_project_item(project_id, content_node_id):
# Includes each item's single-select values so callers can read Status
@ -291,34 +221,6 @@ def find_project_item(project_id, content_node_id):
return None
def set_project_field(project, item_id, field_name, option_name):
field = next(
(f for f in project["fields"]["nodes"] if f.get("name") == field_name), None
)
if not field:
raise RuntimeError(f"Field '{field_name}' not found on board")
option_id = next(
(o["id"] for o in field.get("options", []) if o["name"] == option_name), None
)
if not option_id:
raise RuntimeError(f"Option '{option_name}' not found in field '{field_name}'")
github_graphql(
"""
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId, itemId: $itemId, fieldId: $fieldId,
value: { singleSelectOptionId: $optionId }
}) { projectV2Item { id } }
}
""",
{
"projectId": project["id"],
"itemId": item_id,
"fieldId": field["id"],
"optionId": option_id,
},
)
def list_project_issues(project_id):
cursor = None
@ -365,17 +267,11 @@ def list_project_issues(project_id):
cursor = page["pageInfo"]["endCursor"]
def post_comment(issue_number, body):
github_rest_request(
"POST", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments", {"body": body}
)
def remove_assignees(issue_number, assignees):
github_rest_request(
github_rest_api(
"DELETE",
f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees",
{"assignees": assignees},
body={"assignees": assignees},
)
@ -441,7 +337,7 @@ def handle_assignment(issue, assignee_login, project_number):
if not is_guild_member(assignee_login):
return
project = fetch_project(project_number)
project = fetch_github_project(project_number)
item = find_project_item(project["id"], issue["node_id"])
if not item:
send_slack(
@ -452,7 +348,7 @@ def handle_assignment(issue, assignee_login, project_number):
)
return
set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS)
set_github_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS)
def handle_unassignment(issue, removed_login, sender, project_number):
@ -462,7 +358,7 @@ def handle_unassignment(issue, removed_login, sender, project_number):
if not is_guild_member(removed_login):
return
project = fetch_project(project_number)
project = fetch_github_project(project_number)
item = find_project_item(project["id"], issue["node_id"])
if not item or item_status(item) != STATUS_IN_PROGRESS:
return
@ -476,7 +372,7 @@ def handle_unassignment(issue, removed_login, sender, project_number):
status = STATUS_FOR_TYPE.get((issue.get("type") or {}).get("name") or "")
link = slack_link(issue["html_url"], issue["title"])
if status:
set_project_field(project, item["id"], STATUS_FIELD, status)
set_github_project_field(project, item["id"], STATUS_FIELD, status)
send_slack(
f"@{removed_login} {who} In Progress issue #{issue['number']} "
f"{link}. I moved it back to *{status}*, "
@ -509,11 +405,11 @@ def set_linked_issues_in_progress(pull_request, project_number):
]
if not open_issues:
return
project = fetch_project(project_number)
project = fetch_github_project(project_number)
for issue in open_issues:
item = find_project_item(project["id"], issue["id"])
if item:
set_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS)
set_github_project_field(project, item["id"], STATUS_FIELD, STATUS_IN_PROGRESS)
def warn_about_extra_open_prs(pull_request, author):
@ -521,7 +417,7 @@ def warn_about_extra_open_prs(pull_request, author):
f"repo:{REPO_OWNER}/{REPO_NAME} is:pr is:open "
f"author:{author} created:>={COHORT_START_DATE}"
)
result = github_rest_request(
result = github_rest_api(
"GET", f"search/issues?q={urllib.parse.quote(query)}&per_page=100"
)
others = [
@ -589,7 +485,7 @@ def run_event(project_number):
def run_stale(project_number):
project = fetch_project(project_number)
project = fetch_github_project(project_number)
checked_in = cleared = 0
for item in list_project_issues(project["id"]):
@ -638,7 +534,9 @@ def run_stale(project_number):
# they have stayed quiet for the check-in window.
if (NOW - last_activity).days < CHECK_IN_AFTER_DAYS:
continue
post_comment(
post_github_comment(
REPO_OWNER,
REPO_NAME,
issue_number,
CHECK_IN_BODY.format(
marker=CHECK_IN_MARKER,
@ -658,7 +556,7 @@ def run_stale(project_number):
status = STATUS_FOR_TYPE.get((content.get("issueType") or {}).get("name") or "")
link = slack_link(content["url"], content["title"])
if status:
set_project_field(project, item["id"], STATUS_FIELD, status)
set_github_project_field(project, item["id"], STATUS_FIELD, status)
send_slack(
f"Issue #{issue_number} {link} went quiet with @{assignee} "
"(no open PR, no reply to my check-in), so I cleared the assignment and "
@ -678,7 +576,7 @@ def run_stale(project_number):
def run_weekly(project_number):
project = fetch_project(project_number)
project = fetch_github_project(project_number)
cutoff = NOW - timedelta(days=SHIPPED_WINDOW_DAYS)
shipped = []
@ -705,11 +603,6 @@ def run_weekly(project_number):
if __name__ == "__main__":
GITHUB_HEADERS = {
"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
NOW = datetime.now(timezone.utc)

View file

@ -24,13 +24,11 @@ import functools
import os
import re
import sys
import time
from datetime import datetime, timezone
import requests
from github_helpers import add_github_project_item, github_graphql, github_rest_api
GITHUB_API = "https://api.github.com"
GRAPHQL_URL = "https://api.github.com/graphql"
REPO_OWNER = "zed-industries"
REPO_NAME = "zed"
STAFF_TEAM_SLUG = "staff"
@ -48,8 +46,7 @@ BOT_START_DATE = "2026-02-18"
NEEDS_TRIAGE_LABEL = "state:needs triage"
DEFAULT_PROJECT_NUMBER = 76
VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"}
# HTTP statuses we'll retry on for GET requests
TRANSIENT_HTTP_STATUSES = {429, 500, 502, 503, 504}
# Add a new tuple when you deploy a new version of the bot that you want to
# keep track of (e.g. the prompt gets a rewrite or the model gets swapped).
# Newest first, please. The datetime is for the deployment time (merge to main).
@ -74,37 +71,19 @@ def bot_version_for_time(date_string):
return BOT_VERSION_TIMELINE[-1][0]
def github_api_get(path, params=None):
"""Fetch JSON from the GitHub REST API, retrying transient failures. Raises on non-2xx status."""
url = f"{GITHUB_API}/{path.lstrip('/')}"
for attempt in range(3):
try:
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
response.raise_for_status()
return response.json()
except requests.RequestException as e:
transient = isinstance(e, (requests.ConnectionError, requests.Timeout)) or (
isinstance(e, requests.HTTPError) and e.response.status_code in TRANSIENT_HTTP_STATUSES
)
if not transient or attempt == 2:
raise
wait = 2 ** attempt
print(f" Transient GitHub API error ({e}); retrying in {wait}s")
time.sleep(wait)
def github_search_issues(query):
"""Search issues, returning most recently created first."""
# not handling pagination on purpose: the oldest issues are on the board already
params = {"q": query, "sort": "created", "order": "desc", "per_page": 100}
return github_api_get("/search/issues", params).get("items", [])
return github_rest_api("GET", "search/issues", params=params).get("items", [])
def is_staff_member(username):
"""Check if user is an active member of the staff team."""
try:
data = github_api_get(
f"/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/memberships/{username}"
data = github_rest_api(
"GET",
f"orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/memberships/{username}",
)
return data.get("state") == "active"
except requests.HTTPError as error:
@ -114,7 +93,9 @@ def is_staff_member(username):
def fetch_issue(issue_number):
data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}")
data = github_rest_api(
"GET", f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}"
)
return {
"number": issue_number,
"node_id": data["node_id"],
@ -139,9 +120,11 @@ def get_bot_comment_with_time(issue_number):
Recognizes user-facing issue and Discussion alerts as well as triage-only
comments. Returns {"body": str, "created_at": str} if found, else None.
"""
comments_path = f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
comments_path = f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
page = 1
while comments := github_api_get(comments_path, {"per_page": 100, "page": page}):
while comments := github_rest_api(
"GET", comments_path, params={"per_page": 100, "page": page}
):
for comment in comments:
author = (comment.get("user") or {}).get("login", "")
body = comment.get("body", "")
@ -156,22 +139,6 @@ def parse_suggested_issues(comment_body):
return [int(match) for match in re.findall(r"^- #(\d+)", comment_body, re.MULTILINE)]
def github_api_graphql(query, variables=None, partial_errors_ok=False):
"""Execute a GitHub GraphQL query. Raises on errors unless partial_errors_ok is set."""
response = requests.post(
GRAPHQL_URL,
headers=GITHUB_HEADERS,
json={"query": query, "variables": variables or {}},
)
response.raise_for_status()
data = response.json()
if "errors" in data:
if not partial_errors_ok or "data" not in data:
raise RuntimeError(f"GraphQL errors: {data['errors']}")
print(f" GraphQL partial errors (ignored): {data['errors']}")
return data["data"]
def find_canonical_among(duplicate_number, candidates):
"""Check if any candidate issue has duplicate_number marked as a duplicate.
@ -187,7 +154,7 @@ def find_canonical_among(duplicate_number, candidates):
# candidate issue numbers are baked into the query body via field aliases
# (GraphQL doesn't let you parametrize alias names), so $numbers isn't needed.
data = github_api_graphql(
data = github_graphql(
"""
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
@ -219,7 +186,7 @@ def find_canonical_among(duplicate_number, candidates):
@functools.lru_cache
def get_project_config():
"""Fetch the project board's ID, field IDs, and option IDs."""
data = github_api_graphql(
data = github_graphql(
"""
query($org: String!, $number: Int!) {
organization(login: $org) {
@ -260,7 +227,7 @@ def find_project_item(issue_node_id):
Returns the project item ID if found, or None.
"""
data = github_api_graphql(
data = github_graphql(
"query($id: ID!) { node(id: $id) { ... on Issue { projectItems(first: 20) { nodes { id project { number } } } } } }",
{"id": issue_node_id},
)
@ -273,17 +240,7 @@ def find_project_item(issue_node_id):
def add_project_item(issue_node_id):
"""Add an issue to the project board. Returns the new item ID."""
config = get_project_config()
data = github_api_graphql(
"""
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item { id }
}
}
""",
{"projectId": config["project_id"], "contentId": issue_node_id},
)
return data["addProjectV2ItemById"]["item"]["id"]
return add_github_project_item(config["project_id"], issue_node_id)
def set_field_value(item_id, field_name, value):
@ -305,7 +262,7 @@ def set_field_value(item_id, field_name, value):
# text field
field_value = {"text": str(value)}
github_api_graphql(
github_graphql(
"""
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {
updateProjectV2ItemFieldValue(input: {
@ -543,11 +500,6 @@ if __name__ == "__main__":
args = parser.parse_args()
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
if not GITHUB_TOKEN:
print("Error: GITHUB_TOKEN environment variable is required")
sys.exit(1)
raw_project_number = os.environ.get("PROJECT_NUMBER", "")
if raw_project_number:
try:
@ -558,11 +510,6 @@ if __name__ == "__main__":
else:
PROJECT_NUMBER = DEFAULT_PROJECT_NUMBER
GITHUB_HEADERS = {
"Authorization": f"token {GITHUB_TOKEN}",
"Accept": "application/vnd.github+json",
}
if args.command == "classify-closed":
classify_closed(args.issue_number, args.closer_login, args.state_reason)
elif args.command == "classify-open":

View file

@ -15,18 +15,12 @@ Usage (called by the workflow, not directly):
"""
import os
import time
import urllib.parse
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import requests
from github_helpers import add_github_project_item, github_graphql, github_rest_api
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
@ -36,44 +30,6 @@ REPO_NAME = "zed"
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 day_start(day):
# First instant of a calendar day in the business-day timezone.
return datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=BUSINESS_DAY_TZ)
@ -108,8 +64,8 @@ def eligible_issues(window_start, window_end):
issues = {}
page = 1
while True:
data = github_rest_get(
f"search/issues?q={urllib.parse.quote(query)}&per_page=100&page={page}"
data = github_rest_api(
"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"]
@ -165,13 +121,6 @@ def project_items(project_id):
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 } }",
@ -192,7 +141,7 @@ def sync_project(project_id, dry_run=False):
if content_id not in on_board:
print(f"+ #{number}")
if not dry_run:
add_to_project(project_id, content_id)
add_github_project_item(project_id, content_id)
for item_id, content_id, number in current:
if content_id not in eligible:
@ -202,11 +151,6 @@ def sync_project(project_id, dry_run=False):
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")

214
script/github_helpers.py Normal file
View file

@ -0,0 +1,214 @@
import os
import sys
import time
import requests
GITHUB_API_URL = "https://api.github.com"
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
MAX_ATTEMPTS = 4
REQUEST_TIMEOUT_SECONDS = 30
def _github_headers():
token = os.environ.get("GITHUB_TOKEN")
if not token:
raise RuntimeError("GITHUB_TOKEN environment variable is required")
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def _retry_delay(response, attempt):
retry_after = response.headers.get("Retry-After")
if retry_after:
try:
return max(float(retry_after), 0)
except ValueError:
pass
return 2**attempt
def _github_request(method, path, *, params=None, body=None, retry=True):
url = f"{GITHUB_API_URL}/{path}"
max_attempts = MAX_ATTEMPTS if retry else 1
for attempt in range(max_attempts):
try:
response = requests.request(
method,
url,
headers=_github_headers(),
params=params,
json=body,
timeout=REQUEST_TIMEOUT_SECONDS,
)
except (requests.ConnectionError, requests.Timeout) as error:
if attempt == max_attempts - 1:
raise
delay = 2**attempt
print(
f"Transient GitHub API error ({error}); retrying in {delay}s",
file=sys.stderr,
)
time.sleep(delay)
continue
if (
response.status_code in RETRYABLE_STATUS_CODES
and attempt < max_attempts - 1
):
delay = _retry_delay(response, attempt)
print(
f"GitHub API returned {response.status_code}; retrying in {delay}s",
file=sys.stderr,
)
time.sleep(delay)
continue
response.raise_for_status()
return response
raise RuntimeError("GitHub API retry loop exited without returning")
def github_rest_api(method, path, params=None, body=None):
# Retrying writes after an ambiguous timeout can duplicate comments or mutations.
retry = method.upper() in {"GET", "HEAD", "OPTIONS"}
response = _github_request(
method, path, params=params, body=body, retry=retry
)
if response.status_code == 204 or not response.content:
return {}
return response.json()
def github_rest_get_paginated(path):
results = []
page = 1
while True:
batch = github_rest_api(
"GET", path, params={"per_page": 100, "page": page}
)
if not batch:
return results
results.extend(batch)
if len(batch) < 100:
return results
page += 1
def post_github_comment(owner, repository, issue_number, body):
github_rest_api(
"POST",
f"repos/{owner}/{repository}/issues/{issue_number}/comments",
body={"body": body},
)
def github_graphql(query, variables=None, *, partial_errors_ok=False):
# GraphQL uses POST for reads and writes, so classify the operation before retrying.
retry = not query.lstrip().startswith("mutation")
response = _github_request(
"POST",
"graphql",
body={"query": query, "variables": variables or {}},
retry=retry,
)
result = response.json()
if "errors" in result:
if not partial_errors_ok or "data" not in result:
raise RuntimeError(f"GraphQL errors: {result['errors']}")
print(f"GraphQL partial errors (ignored): {result['errors']}", file=sys.stderr)
return result["data"]
def fetch_github_project(project_number, owner="zed-industries"):
data = github_graphql(
"""
query($owner: String!, $number: Int!) {
organization(login: $owner) {
projectV2(number: $number) {
id
fields(first: 50) {
nodes {
... on ProjectV2Field { id name dataType }
... on ProjectV2SingleSelectField { id name options { id name } }
}
}
}
}
}
""",
{"owner": owner, "number": project_number},
)
project = data["organization"]["projectV2"]
if not project:
raise RuntimeError(f"Project #{project_number} not found in {owner}")
return project
def add_github_project_item(project_id, content_id):
data = github_graphql(
"""
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item { id }
}
}
""",
{"projectId": project_id, "contentId": content_id},
)
return data["addProjectV2ItemById"]["item"]["id"]
def set_github_project_field(project, item_id, field_name, option_name):
field = next(
(field for field in project["fields"]["nodes"] if field.get("name") == field_name),
None,
)
if not field:
available = [
field["name"] for field in project["fields"]["nodes"] if "name" in field
]
raise RuntimeError(
f"Field '{field_name}' not found on project. Available: {available}"
)
option_id = next(
(
option["id"]
for option in field.get("options", [])
if option["name"] == option_name
),
None,
)
if not option_id:
available = [option["name"] for option in field.get("options", [])]
raise RuntimeError(
f"Option '{option_name}' not found in field '{field_name}'. "
f"Available: {available}"
)
github_graphql(
"""
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}
""",
{
"projectId": project["id"],
"itemId": item_id,
"fieldId": field["id"],
"optionId": option_id,
},
)
print(f"Set '{field_name}' to '{option_name}'")