1#!/usr/bin/env python3
2"""
3Route community PRs to the correct review track on a GitHub Project board.
4
5Reads the event payload dispatched by the GitHub Actions workflow and:
6- On `labeled`: adds the PR to the board (idempotent) and sets the Track
7 field to the most specific matching track.
8- On `unlabeled`: re-resolves Track from remaining labels, or clears it
9 if no area/platform labels remain (PR stays on the board for visibility).
10- On `assigned`: if the assignee is a staff team member, sets Status to
11 "In Progress (us)".
12- On `review_requested`: if current Status is "In Progress (author)",
13 flips it to "In Progress (us)" — the author is explicitly asking for
14 re-review.
15- On `issue_comment.created`: if the commenter is the PR author and
16 current Status is "In Progress (author)", flips it to
17 "In Progress (us)" — the author is likely signaling they're done.
18- On `workflow_dispatch`: re-resolves Track for a manually specified PR.
19
20Review-based status changes (approved → "In Progress (us)", changes
21requested → "In Progress (author)") are handled by built-in board
22automations, not this script.
23
24Requires:
25 requests (pip install requests)
26
27Usage (called by the workflow, not directly):
28 python github-community-pr-board.py
29"""
30
31import json
32import os
33import sys
34import time
35from pathlib import Path
36
37import requests
38
39RETRYABLE_STATUS_CODES = {502, 503, 504}
40MAX_RETRIES = 3
41RETRY_DELAY_SECONDS = 5
42
43GITHUB_API_URL = "https://api.github.com"
44REPO_OWNER = "zed-industries"
45REPO_NAME = "zed"
46STAFF_TEAM_SLUG = "staff"
47
48SKIP_LABELS = {"staff", "bot"}
49
50
51STATUS_IN_PROGRESS_US = "In Progress (us)"
52STATUS_IN_PROGRESS_AUTHOR = (
53 "In Progress (author)" # set by built-in board automation, read by this script
54)
55
56MAPPING_PATH = Path(__file__).parent / "community-pr-track-mapping.json"
57
58
59def sync_track_from_labels(pr, project_number):
60 """Sync the PR's Track field on the board with its current labels."""
61 pr_labels = {label["name"] for label in pr.get("labels", [])}
62 track_name = resolve_track(pr_labels, load_mapping())
63
64 project = github_fetch_project(project_number)
65 project_item = github_find_project_item(project["id"], pr["node_id"])
66
67 if not track_name:
68 if project_item:
69 github_clear_field(project, project_item, "Track")
70 print(f"No track matched, cleared Track on PR #{pr['number']}")
71 else:
72 print(
73 f"No track matched for labels on PR #{pr['number']}, not on board, nothing to do"
74 )
75 return
76
77 print(f"Resolved track: {track_name}")
78
79 if not project_item:
80 project_item = github_add_to_project(project["id"], pr["node_id"])
81 github_set_project_field(project, project_item, "Track", track_name)
82
83
84def set_progress_status_on_assignment(pr, assignee_login, project_number):
85 """Set Status to 'In Progress (us)' when a staff member self-assigns."""
86 if not github_is_staff_member(assignee_login):
87 print(f"Assignee '{assignee_login}' is not a staff member, skipping")
88 return
89
90 project = github_fetch_project(project_number)
91 item_id = github_find_project_item(project["id"], pr["node_id"])
92 if not item_id:
93 print(f"PR #{pr['number']} not on board, skipping assignment status update")
94 return
95
96 github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
97
98
99def return_to_reviewer(pr, project_number, reason):
100 """Flip Status from 'In Progress (author)' to 'In Progress (us)'.
101
102 Called when the author signals they're ready for re-review, either
103 by re-requesting review or by commenting on the PR.
104 """
105 project = github_fetch_project(project_number)
106 item_id = github_find_project_item(project["id"], pr["node_id"])
107 if not item_id:
108 print(f"PR #{pr['number']} not on board, skipping")
109 return
110
111 current_status = github_get_field_value(item_id, "Status")
112 if current_status == STATUS_IN_PROGRESS_AUTHOR:
113 print(
114 f"{reason}, flipping status from '{current_status}' to '{STATUS_IN_PROGRESS_US}'"
115 )
116 github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
117 else:
118 print(f"Current status is '{current_status}', not flipping ({reason})")
119
120
121def load_mapping(path=MAPPING_PATH):
122 """Load the Track-to-labels mapping from the JSON file."""
123 with open(path) as f:
124 data = json.load(f)
125 return data["tracks"]
126
127
128def resolve_track(pr_labels, tracks):
129 """Return the name of the most specific track matching the PR's labels.
130
131 Tracks are checked in order; the first match wins (most specific first).
132 """
133 for track in tracks:
134 if pr_labels & set(track["labels"]):
135 return track["name"]
136 return None
137
138
139def github_graphql(query, variables):
140 """Execute a GitHub GraphQL query. Retries on transient server errors."""
141 for attempt in range(MAX_RETRIES + 1):
142 response = requests.post(
143 f"{GITHUB_API_URL}/graphql",
144 headers=GITHUB_HEADERS,
145 json={"query": query, "variables": variables},
146 )
147 if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
148 print(
149 f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..."
150 )
151 time.sleep(RETRY_DELAY_SECONDS)
152 continue
153 response.raise_for_status()
154 result = response.json()
155 if "errors" in result:
156 raise RuntimeError(f"GraphQL error: {result['errors']}")
157 return result["data"]
158
159
160def github_rest_get(path):
161 """GET from the GitHub REST API. Retries on transient server errors."""
162 for attempt in range(MAX_RETRIES + 1):
163 response = requests.get(f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS)
164 if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
165 print(
166 f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..."
167 )
168 time.sleep(RETRY_DELAY_SECONDS)
169 continue
170 response.raise_for_status()
171 return response.json()
172
173
174def github_is_staff_member(username):
175 """Check if a user is a member of the staff team."""
176 try:
177 response = requests.get(
178 f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members/{username}",
179 headers=GITHUB_HEADERS,
180 )
181 if response.status_code == 204:
182 return True
183 if response.status_code == 404:
184 return False
185 print(
186 f"Warning: unexpected status {response.status_code} checking staff membership for '{username}'"
187 )
188 return False
189 except requests.RequestException as exc:
190 print(f"Warning: failed to check staff membership for '{username}': {exc}")
191 return False
192
193
194def github_fetch_pr(pr_number):
195 """Fetch a PR by number via the REST API."""
196 return github_rest_get(f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}")
197
198
199def github_fetch_project(project_number):
200 """Fetch a GitHub project board's metadata including fields and their options."""
201 data = github_graphql(
202 """
203 query($owner: String!, $number: Int!) {
204 organization(login: $owner) {
205 projectV2(number: $number) {
206 id
207 fields(first: 50) {
208 nodes {
209 ... on ProjectV2SingleSelectField {
210 id
211 name
212 options { id name }
213 }
214 }
215 }
216 }
217 }
218 }
219 """,
220 {"owner": REPO_OWNER, "number": project_number},
221 )
222 return data["organization"]["projectV2"]
223
224
225def github_add_to_project(project_id, content_node_id):
226 """Add a PR to the project board. Returns the new project item ID."""
227 data = github_graphql(
228 """
229 mutation($projectId: ID!, $contentId: ID!) {
230 addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
231 item { id }
232 }
233 }
234 """,
235 {"projectId": project_id, "contentId": content_node_id},
236 )
237 item_id = data["addProjectV2ItemById"]["item"]["id"]
238 print(f"Added PR to board (item: {item_id})")
239 return item_id
240
241
242def github_find_project_item(project_id, content_node_id):
243 """Find a PR's item ID on the project board, or None if not present.
244
245 Uses a read-only query so it won't add the PR as a side effect.
246 """
247 data = github_graphql(
248 """
249 query($contentId: ID!) {
250 node(id: $contentId) {
251 ... on PullRequest {
252 projectItems(first: 50) {
253 nodes {
254 id
255 project { id }
256 }
257 }
258 }
259 }
260 }
261 """,
262 {"contentId": content_node_id},
263 )
264 for item in data["node"]["projectItems"]["nodes"]:
265 if item["project"]["id"] == project_id:
266 return item["id"]
267 return None
268
269
270def github_set_project_field(project, item_id, field_name, option_name):
271 """Set a single-select field on a project item."""
272 field_id = None
273 option_id = None
274 for field in project["fields"]["nodes"]:
275 if field.get("name") == field_name:
276 field_id = field["id"]
277 for option in field.get("options", []):
278 if option["name"] == option_name:
279 option_id = option["id"]
280 break
281 break
282
283 if not field_id:
284 available = [f["name"] for f in project["fields"]["nodes"] if "name" in f]
285 raise RuntimeError(
286 f"Field '{field_name}' not found on project. Available: {available}"
287 )
288 if not option_id:
289 available = [
290 opt["name"]
291 for f in project["fields"]["nodes"]
292 if f.get("name") == field_name
293 for opt in f.get("options", [])
294 ]
295 raise RuntimeError(
296 f"Option '{option_name}' not found in field '{field_name}'. "
297 f"Available: {available}"
298 )
299
300 github_graphql(
301 """
302 mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
303 updateProjectV2ItemFieldValue(input: {
304 projectId: $projectId
305 itemId: $itemId
306 fieldId: $fieldId
307 value: { singleSelectOptionId: $optionId }
308 }) {
309 projectV2Item { id }
310 }
311 }
312 """,
313 {
314 "projectId": project["id"],
315 "itemId": item_id,
316 "fieldId": field_id,
317 "optionId": option_id,
318 },
319 )
320 print(f"Set '{field_name}' to '{option_name}'")
321
322
323def github_clear_field(project, item_id, field_name):
324 """Clear a single-select field on a project item."""
325 field_id = None
326 for field in project["fields"]["nodes"]:
327 if field.get("name") == field_name:
328 field_id = field["id"]
329 break
330
331 if not field_id:
332 available = [f["name"] for f in project["fields"]["nodes"] if "name" in f]
333 raise RuntimeError(
334 f"Field '{field_name}' not found on project. Available: {available}"
335 )
336
337 github_graphql(
338 """
339 mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) {
340 clearProjectV2ItemFieldValue(input: {
341 projectId: $projectId
342 itemId: $itemId
343 fieldId: $fieldId
344 }) {
345 projectV2Item { id }
346 }
347 }
348 """,
349 {
350 "projectId": project["id"],
351 "itemId": item_id,
352 "fieldId": field_id,
353 },
354 )
355 print(f"Cleared '{field_name}'")
356
357
358def github_get_field_value(item_id, field_name):
359 """Read the current value of a single-select field on a project item."""
360 data = github_graphql(
361 """
362 query($itemId: ID!) {
363 node(id: $itemId) {
364 ... on ProjectV2Item {
365 fieldValues(first: 20) {
366 nodes {
367 ... on ProjectV2ItemFieldSingleSelectValue {
368 field { ... on ProjectV2SingleSelectField { name } }
369 name
370 }
371 }
372 }
373 }
374 }
375 }
376 """,
377 {"itemId": item_id},
378 )
379 for field_value in data["node"]["fieldValues"]["nodes"]:
380 if field_value.get("field", {}).get("name") == field_name:
381 return field_value.get("name")
382 return None
383
384
385if __name__ == "__main__":
386 GITHUB_HEADERS = {
387 "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
388 "Accept": "application/vnd.github+json",
389 "X-GitHub-Api-Version": "2022-11-28",
390 }
391
392 project_number = int(os.environ["PROJECT_NUMBER"])
393 manual_pr_number = os.environ.get("MANUAL_PR_NUMBER")
394
395 if manual_pr_number:
396 pr = github_fetch_pr(manual_pr_number)
397 action = "labeled"
398 event = {}
399 print(f"Manual dispatch for PR #{manual_pr_number}")
400 else:
401 event_name = os.environ["GITHUB_EVENT_NAME"]
402 with open(os.environ["GITHUB_EVENT_PATH"]) as f:
403 event = json.load(f)
404
405 if event_name in ("pull_request", "pull_request_target"):
406 pr = event["pull_request"]
407 action = event["action"]
408 elif event_name == "issue_comment":
409 issue = event["issue"]
410 if "pull_request" not in issue:
411 print("Comment is on an issue, not a PR, skipping")
412 sys.exit(0)
413 commenter = event["comment"]["user"]["login"]
414 pr_author = issue["user"]["login"]
415 if commenter != pr_author:
416 print(
417 f"Commenter '{commenter}' is not PR author '{pr_author}', skipping"
418 )
419 sys.exit(0)
420 pr = github_fetch_pr(issue["number"])
421 action = "author_commented"
422 else:
423 print(f"Unexpected event: {event_name}")
424 sys.exit(0)
425
426 pr_labels = {label["name"] for label in pr.get("labels", [])}
427 if pr_labels & SKIP_LABELS:
428 print(f"Skipping PR #{pr['number']} (has {pr_labels & SKIP_LABELS} label)")
429 sys.exit(0)
430
431 if pr.get("draft"):
432 print(f"Skipping draft PR #{pr['number']}")
433 sys.exit(0)
434
435 print(f"Processing PR #{pr['number']}: action={action}")
436
437 if action in ("labeled", "unlabeled"):
438 sync_track_from_labels(pr, project_number)
439 elif action == "assigned":
440 assignee_login = event.get("assignee", {}).get("login")
441 if not assignee_login:
442 print("No assignee login in event payload, skipping")
443 else:
444 set_progress_status_on_assignment(pr, assignee_login, project_number)
445 elif action == "review_requested":
446 return_to_reviewer(pr, project_number, "Author re-requested review")
447 elif action == "author_commented":
448 return_to_reviewer(pr, project_number, "Author commented on PR")
449 else:
450 print(f"Ignoring action: {action}")