Skip to repository content817 lines · 28.3 KB · python
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T00:30:33.835Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
github-community-pr-board.py
1#!/usr/bin/env python3
2"""
3Route community PRs to the correct review track on a GitHub Project board,
4and surface signal fields (Size, Issue Linked, Contributor, Upvotes) that the
5board's views can filter and sort on.
6
7Reads the event payload dispatched by the GitHub Actions workflow and:
8- On `labeled`: adds the PR to the board (idempotent), sets the Track
9 field to the most specific matching track, and recomputes signal fields.
10- On `unlabeled`: re-resolves Track from remaining labels, or clears it
11 if no area/platform labels remain (PR stays on the board for visibility).
12 Signals are recomputed if the PR is on the board.
13- On `edited`: recomputes signals if the PR is on the board (a body edit
14 can change which issues the PR closes).
15- On `assigned`: if the assignee is a staff team member, sets Status to
16 "In Progress (us)".
17- On `review_requested`: if current Status is "In Progress (author)",
18 flips it to "In Progress (us)" — the author is explicitly asking for
19 re-review.
20- On `issue_comment.created`: if the commenter is the PR author and
21 current Status is "In Progress (author)", flips it to
22 "In Progress (us)" — the author is likely signaling they're done.
23- On `workflow_dispatch` with a PR number: re-resolves Track and
24 recomputes signals for a manually specified PR.
25- When run with REFRESH_ALL=1: walks every open PR on the board and
26 recomputes its signals. Used by the scheduled refresh workflow as a
27 belt-and-suspenders pass against missed webhook events (notably
28 intentionally-unsubscribed `synchronize` events that change Size, and
29 sidebar edits to linked issues that don't fire a PR webhook).
30
31Review-based status changes (approved → "In Progress (us)", changes
32requested → "In Progress (author)") are handled by built-in board
33automations, not this script.
34
35Signal fields are written best-effort: if a field or option is missing
36from the project, the script logs and skips it rather than failing, so
37new fields can be rolled out in the project UI independently of deploys.
38
39Requires:
40 requests (pip install requests)
41
42Usage (called by the workflow, not directly):
43 python github-community-pr-board.py
44"""
45
46import json
47import os
48import sys
49import time
50from pathlib import Path
51
52import requests
53
54RETRYABLE_STATUS_CODES = {502, 503, 504}
55MAX_RETRIES = 3
56RETRY_DELAY_SECONDS = 5
57
58GITHUB_API_URL = "https://api.github.com"
59REPO_OWNER = "zed-industries"
60REPO_NAME = "zed"
61STAFF_TEAM_SLUG = "staff"
62
63SKIP_LABELS = {"staff", "bot"}
64
65
66STATUS_IN_PROGRESS_US = "In Progress (us)"
67STATUS_IN_PROGRESS_AUTHOR = (
68 "In Progress (author)" # set by built-in board automation, read by this script
69)
70
71MAPPING_PATH = Path(__file__).parent / "community-pr-track-mapping.json"
72
73
74def sync_track_from_labels(pr, project_number):
75 """Sync the PR's Track field on the board with its current labels.
76
77 Returns (project, project_item) if the PR ends up on (or stays on)
78 the board, so callers can do further per-item work without a second
79 project/item lookup. Returns (None, None) otherwise.
80 """
81 pr_labels = {label["name"] for label in pr.get("labels", [])}
82 track_name = resolve_track(pr_labels, load_mapping())
83
84 project = github_fetch_project(project_number)
85 project_item = github_find_project_item(project["id"], pr["node_id"])
86
87 if not track_name:
88 if project_item:
89 github_clear_field(project, project_item, "Track")
90 print(f"No track matched, cleared Track on PR #{pr['number']}")
91 return project, project_item
92 print(
93 f"No track matched for labels on PR #{pr['number']}, not on board, nothing to do"
94 )
95 return None, None
96
97 print(f"Resolved track: {track_name}")
98
99 if not project_item:
100 project_item = github_add_to_project(project["id"], pr["node_id"])
101 github_set_project_field(project, project_item, "Track", track_name)
102 return project, project_item
103
104
105def set_progress_status_on_assignment(pr, assignee_login, project_number):
106 """Set Status to 'In Progress (us)' when a staff member self-assigns."""
107 if not github_is_staff_member(assignee_login):
108 print(f"Assignee '{assignee_login}' is not a staff member, skipping")
109 return
110
111 project = github_fetch_project(project_number)
112 item_id = github_find_project_item(project["id"], pr["node_id"])
113 if not item_id:
114 print(f"PR #{pr['number']} not on board, skipping assignment status update")
115 return
116
117 github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
118
119
120def return_to_reviewer(pr, project_number, reason):
121 """Flip Status from 'In Progress (author)' to 'In Progress (us)'.
122
123 Called when the author signals they're ready for re-review, either
124 by re-requesting review or by commenting on the PR.
125 """
126 project = github_fetch_project(project_number)
127 item_id = github_find_project_item(project["id"], pr["node_id"])
128 if not item_id:
129 print(f"PR #{pr['number']} not on board, skipping")
130 return
131
132 current_status = github_get_field_value(item_id, "Status")
133 if current_status == STATUS_IN_PROGRESS_AUTHOR:
134 print(
135 f"{reason}, flipping status from '{current_status}' to '{STATUS_IN_PROGRESS_US}'"
136 )
137 github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
138 else:
139 print(f"Current status is '{current_status}', not flipping ({reason})")
140
141
142def compute_size_bucket(total_changes):
143 """Return the size bucket label for a PR's total (additions + deletions).
144
145 Size is computed from raw additions + deletions; we don't try to strip
146 generated files.
147 """
148 buckets = [(30, "XS"), (150, "S"), (400, "M"), (1000, "L")]
149 for threshold, name in buckets:
150 if total_changes < threshold:
151 return name
152 return "XL"
153
154
155def compute_contributor(pr_labels):
156 """Return the Contributor value derived from the PR's labels.
157
158 The auto-labeler is responsible for applying `guild`, `community champion`
159 and `first contribution` based on the author's membership/history; anything
160 else on the board is treated as a returning contributor (PRs from staff/bots
161 don't reach this function because they're filtered upstream).
162
163 Guild takes precedence over Champion because current-cohort guild members
164 are frequently also community champions, and their guild status is the more
165 relevant signal while the cohort is active.
166 """
167 if "guild" in pr_labels:
168 return "Guild"
169 if "community champion" in pr_labels:
170 return "Champion"
171 if "first contribution" in pr_labels:
172 return "First PR"
173 return "Returning"
174
175
176def recompute_signals(pr, project, project_item):
177 """Recompute Size, Contributor, Issue Linked, and Upvotes on a board item.
178
179 Best-effort: missing fields/options are logged and skipped so the
180 project's field set can be rolled out independently.
181 """
182 pr_labels = {label["name"] for label in pr.get("labels", [])}
183 total_changes = pr.get("additions", 0) + pr.get("deletions", 0)
184 author_login = (pr.get("user") or {}).get("login")
185
186 set_field_optional(project, project_item, "Size", compute_size_bucket(total_changes))
187 set_field_optional(project, project_item, "Contributor", compute_contributor(pr_labels))
188 set_field_optional(project, project_item, "Issue Linked", github_pr_issue_type(pr["node_id"]))
189 set_number_field_optional(project, project_item, "Upvotes", github_pr_upvotes(pr["node_id"], author_login))
190
191
192def refresh_signals_if_on_board(pr, project_number):
193 """Recompute signals for a PR only if it's already on the board."""
194 project = github_fetch_project(project_number)
195 project_item = github_find_project_item(project["id"], pr["node_id"])
196 if not project_item:
197 print(f"PR #{pr['number']} not on board, skipping signal refresh")
198 return
199 recompute_signals(pr, project, project_item)
200
201
202def refresh_all_board_items(project_number):
203 """Walk every open PR on the board and recompute its signals.
204
205 Backstop for the daily refresh workflow. Individual PR failures are
206 logged and don't abort the rest of the run.
207 """
208 project = github_fetch_project(project_number)
209 processed = skipped = errors = 0
210 for item in github_list_project_items(project["id"]):
211 if item.get("isArchived"):
212 skipped += 1
213 continue
214 content = item.get("content") or {}
215 if content.get("__typename") != "PullRequest":
216 skipped += 1
217 continue
218 if content.get("state") != "OPEN" or content.get("isDraft"):
219 skipped += 1
220 continue
221 label_names = [n["name"] for n in content["labels"]["nodes"]]
222 if set(label_names) & SKIP_LABELS:
223 skipped += 1
224 continue
225 pr = {
226 "number": content["number"],
227 "node_id": content["id"],
228 "labels": [{"name": n} for n in label_names],
229 "additions": content["additions"],
230 "deletions": content["deletions"],
231 "user": {"login": (content.get("author") or {}).get("login")},
232 }
233 print(f"--- Refreshing PR #{pr['number']} ---")
234 try:
235 recompute_signals(pr, project, item["id"])
236 processed += 1
237 except Exception as exc:
238 print(f"Failed to refresh PR #{pr['number']}: {exc}")
239 errors += 1
240 print(
241 f"Refresh complete: {processed} processed, {skipped} skipped, {errors} errors"
242 )
243
244
245def load_mapping(path=MAPPING_PATH):
246 """Load the Track-to-labels mapping from the JSON file."""
247 with open(path) as f:
248 data = json.load(f)
249 return data["tracks"]
250
251
252def resolve_track(pr_labels, tracks):
253 """Return the name of the most specific track matching the PR's labels.
254
255 Tracks are checked in order; the first match wins (most specific first).
256 """
257 for track in tracks:
258 if pr_labels & set(track["labels"]):
259 return track["name"]
260 return None
261
262
263def github_graphql(query, variables):
264 """Execute a GitHub GraphQL query. Retries on transient server errors."""
265 for attempt in range(MAX_RETRIES + 1):
266 response = requests.post(
267 f"{GITHUB_API_URL}/graphql",
268 headers=GITHUB_HEADERS,
269 json={"query": query, "variables": variables},
270 )
271 if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
272 print(
273 f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..."
274 )
275 time.sleep(RETRY_DELAY_SECONDS)
276 continue
277 response.raise_for_status()
278 result = response.json()
279 if "errors" in result:
280 raise RuntimeError(f"GraphQL error: {result['errors']}")
281 return result["data"]
282 raise RuntimeError("github_graphql: retry loop exited without return")
283
284
285def github_rest_get(path):
286 """GET from the GitHub REST API. Retries on transient server errors."""
287 for attempt in range(MAX_RETRIES + 1):
288 response = requests.get(f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS)
289 if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
290 print(
291 f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..."
292 )
293 time.sleep(RETRY_DELAY_SECONDS)
294 continue
295 response.raise_for_status()
296 return response.json()
297 raise RuntimeError("github_rest_get: retry loop exited without return")
298
299
300def github_is_staff_member(username):
301 """Check if a user is a member of the staff team."""
302 try:
303 response = requests.get(
304 f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members/{username}",
305 headers=GITHUB_HEADERS,
306 )
307 if response.status_code == 204:
308 return True
309 if response.status_code == 404:
310 return False
311 print(
312 f"Warning: unexpected status {response.status_code} checking staff membership for '{username}'"
313 )
314 return False
315 except requests.RequestException as exc:
316 print(f"Warning: failed to check staff membership for '{username}': {exc}")
317 return False
318
319
320def github_fetch_pr(pr_number):
321 """Fetch a PR by number via the REST API."""
322 return github_rest_get(f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}")
323
324
325def github_fetch_project(project_number):
326 """Fetch a GitHub project board's metadata including fields and their options."""
327 data = github_graphql(
328 """
329 query($owner: String!, $number: Int!) {
330 organization(login: $owner) {
331 projectV2(number: $number) {
332 id
333 fields(first: 50) {
334 nodes {
335 ... on ProjectV2Field {
336 id
337 name
338 dataType
339 }
340 ... on ProjectV2SingleSelectField {
341 id
342 name
343 options { id name }
344 }
345 }
346 }
347 }
348 }
349 }
350 """,
351 {"owner": REPO_OWNER, "number": project_number},
352 )
353 return data["organization"]["projectV2"]
354
355
356def github_add_to_project(project_id, content_node_id):
357 """Add a PR to the project board. Returns the new project item ID."""
358 data = github_graphql(
359 """
360 mutation($projectId: ID!, $contentId: ID!) {
361 addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
362 item { id }
363 }
364 }
365 """,
366 {"projectId": project_id, "contentId": content_node_id},
367 )
368 item_id = data["addProjectV2ItemById"]["item"]["id"]
369 print(f"Added PR to board (item: {item_id})")
370 return item_id
371
372
373def github_list_project_items(project_id):
374 """Yield every item on a project board, paginating as needed.
375
376 Each item includes `isArchived` so callers can cheaply filter archived
377 items out without an extra round-trip per item.
378 """
379 cursor = None
380 while True:
381 data = github_graphql(
382 """
383 query($projectId: ID!, $cursor: String) {
384 node(id: $projectId) {
385 ... on ProjectV2 {
386 items(first: 100, after: $cursor) {
387 pageInfo { hasNextPage endCursor }
388 nodes {
389 id
390 isArchived
391 content {
392 __typename
393 ... on PullRequest {
394 id
395 number
396 state
397 isDraft
398 additions
399 deletions
400 author { login }
401 labels(first: 50) { nodes { name } }
402 }
403 }
404 }
405 }
406 }
407 }
408 }
409 """,
410 {"projectId": project_id, "cursor": cursor},
411 )
412 page = data["node"]["items"]
413 for item in page["nodes"]:
414 yield item
415 if not page["pageInfo"]["hasNextPage"]:
416 return
417 cursor = page["pageInfo"]["endCursor"]
418
419
420def github_pr_upvotes(pr_node_id, author_login):
421 """Return the count of unique positive reactors on the PR.
422
423 Counts users (not bots) who left at least one of THUMBS_UP, HEART,
424 HOORAY, or ROCKET on the PR itself. Each user is counted once even if
425 they reacted with multiple positive emojis. The PR author is excluded
426 so self-reactions don't inflate the count.
427
428 Caps at 100 reactors per emoji — above that the exact count stops
429 mattering for ranking purposes.
430 """
431 data = github_graphql(
432 """
433 query($prId: ID!) {
434 node(id: $prId) {
435 ... on PullRequest {
436 reactionGroups {
437 content
438 reactors(first: 100) {
439 nodes {
440 __typename
441 ... on User { login }
442 }
443 }
444 }
445 }
446 }
447 }
448 """,
449 {"prId": pr_node_id},
450 )
451 positive_contents = {"THUMBS_UP", "HEART", "HOORAY", "ROCKET"}
452 reactors = set()
453 for group in data["node"]["reactionGroups"] or []:
454 if group["content"] not in positive_contents:
455 continue
456 for node in group["reactors"]["nodes"]:
457 if node.get("__typename") != "User":
458 continue
459 login = node.get("login")
460 if login and login != author_login:
461 reactors.add(login)
462 return len(reactors)
463
464
465def github_pr_issue_type(pr_node_id):
466 """Return the Issue Linked field value for a PR.
467
468 Reads `closingIssuesReferences` (authoritative source for what GitHub
469 will close on merge, covers both `Closes #N` keywords and Development
470 sidebar links) and maps the linked issues' types to one of: 'Crash',
471 'Bug', 'Feature', 'Docs', or 'No issue'.
472
473 When a PR closes multiple issues with different types, returns the
474 most urgent one by the priority Crash > Bug > Feature > Docs.
475 """
476 data = github_graphql(
477 """
478 query($prId: ID!) {
479 node(id: $prId) {
480 ... on PullRequest {
481 closingIssuesReferences(first: 20) {
482 totalCount
483 nodes { issueType { name } }
484 }
485 }
486 }
487 }
488 """,
489 {"prId": pr_node_id},
490 )
491 refs = data["node"]["closingIssuesReferences"]
492 if refs["totalCount"] == 0:
493 return "No issue"
494 type_names = {
495 n["issueType"]["name"] for n in refs["nodes"] if n.get("issueType")
496 }
497 for priority in ("Crash", "Bug", "Feature", "Docs"):
498 if priority in type_names:
499 return priority
500 # Org-wide guardrails should ensure every issue has a recognized type,
501 # so reaching this branch means something slipped through. Log it and
502 # fall back so the PR doesn't carry stale data.
503 print(
504 f"Warning: PR has {refs['totalCount']} linked issue(s) but none with "
505 f"a recognized type (saw: {type_names or 'no type set'})"
506 )
507 return "No issue"
508
509
510def github_find_project_item(project_id, content_node_id):
511 """Find a PR's item ID on the project board, or None if not present.
512
513 Uses a read-only query so it won't add the PR as a side effect.
514 """
515 data = github_graphql(
516 """
517 query($contentId: ID!) {
518 node(id: $contentId) {
519 ... on PullRequest {
520 projectItems(first: 50) {
521 nodes {
522 id
523 project { id }
524 }
525 }
526 }
527 }
528 }
529 """,
530 {"contentId": content_node_id},
531 )
532 for item in data["node"]["projectItems"]["nodes"]:
533 if item["project"]["id"] == project_id:
534 return item["id"]
535 return None
536
537
538def github_set_project_field(project, item_id, field_name, option_name):
539 """Set a single-select field on a project item."""
540 field_id = None
541 option_id = None
542 for field in project["fields"]["nodes"]:
543 if field.get("name") == field_name:
544 field_id = field["id"]
545 for option in field.get("options", []):
546 if option["name"] == option_name:
547 option_id = option["id"]
548 break
549 break
550
551 if not field_id:
552 available = [f["name"] for f in project["fields"]["nodes"] if "name" in f]
553 raise RuntimeError(
554 f"Field '{field_name}' not found on project. Available: {available}"
555 )
556 if not option_id:
557 available = [
558 opt["name"]
559 for f in project["fields"]["nodes"]
560 if f.get("name") == field_name
561 for opt in f.get("options", [])
562 ]
563 raise RuntimeError(
564 f"Option '{option_name}' not found in field '{field_name}'. "
565 f"Available: {available}"
566 )
567
568 github_graphql(
569 """
570 mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
571 updateProjectV2ItemFieldValue(input: {
572 projectId: $projectId
573 itemId: $itemId
574 fieldId: $fieldId
575 value: { singleSelectOptionId: $optionId }
576 }) {
577 projectV2Item { id }
578 }
579 }
580 """,
581 {
582 "projectId": project["id"],
583 "itemId": item_id,
584 "fieldId": field_id,
585 "optionId": option_id,
586 },
587 )
588 print(f"Set '{field_name}' to '{option_name}'")
589
590
591def set_field_optional(project, item_id, field_name, option_name):
592 """Set a single-select field, logging and skipping if the field or
593 option doesn't exist on the project yet.
594
595 Used for signal fields so they can be added in the project UI
596 independently of script deploys without crashing the workflow.
597 """
598 field = None
599 for f in project["fields"]["nodes"]:
600 if f.get("name") == field_name:
601 field = f
602 break
603 if not field:
604 print(f"Field '{field_name}' not on project, skipping")
605 return
606 option_id = None
607 for opt in field.get("options", []):
608 if opt["name"] == option_name:
609 option_id = opt["id"]
610 break
611 if not option_id:
612 available = [opt["name"] for opt in field.get("options", [])]
613 print(
614 f"Option '{option_name}' not in field '{field_name}' "
615 f"(available: {available}), skipping"
616 )
617 return
618 github_graphql(
619 """
620 mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
621 updateProjectV2ItemFieldValue(input: {
622 projectId: $projectId
623 itemId: $itemId
624 fieldId: $fieldId
625 value: { singleSelectOptionId: $optionId }
626 }) {
627 projectV2Item { id }
628 }
629 }
630 """,
631 {
632 "projectId": project["id"],
633 "itemId": item_id,
634 "fieldId": field["id"],
635 "optionId": option_id,
636 },
637 )
638 print(f"Set '{field_name}' to '{option_name}'")
639
640
641def set_number_field_optional(project, item_id, field_name, value):
642 """Set a number field, logging and skipping if the field doesn't exist
643 on the project yet. Mirror of `set_field_optional` for non-select fields.
644 """
645 field = None
646 for f in project["fields"]["nodes"]:
647 if f.get("name") == field_name:
648 field = f
649 break
650 if not field:
651 print(f"Field '{field_name}' not on project, skipping")
652 return
653 github_graphql(
654 """
655 mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: Float!) {
656 updateProjectV2ItemFieldValue(input: {
657 projectId: $projectId
658 itemId: $itemId
659 fieldId: $fieldId
660 value: { number: $value }
661 }) {
662 projectV2Item { id }
663 }
664 }
665 """,
666 {
667 "projectId": project["id"],
668 "itemId": item_id,
669 "fieldId": field["id"],
670 "value": value,
671 },
672 )
673 print(f"Set '{field_name}' to {value}")
674
675
676def github_clear_field(project, item_id, field_name):
677 """Clear a single-select field on a project item."""
678 field_id = None
679 for field in project["fields"]["nodes"]:
680 if field.get("name") == field_name:
681 field_id = field["id"]
682 break
683
684 if not field_id:
685 available = [f["name"] for f in project["fields"]["nodes"] if "name" in f]
686 raise RuntimeError(
687 f"Field '{field_name}' not found on project. Available: {available}"
688 )
689
690 github_graphql(
691 """
692 mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) {
693 clearProjectV2ItemFieldValue(input: {
694 projectId: $projectId
695 itemId: $itemId
696 fieldId: $fieldId
697 }) {
698 projectV2Item { id }
699 }
700 }
701 """,
702 {
703 "projectId": project["id"],
704 "itemId": item_id,
705 "fieldId": field_id,
706 },
707 )
708 print(f"Cleared '{field_name}'")
709
710
711def github_get_field_value(item_id, field_name):
712 """Read the current value of a single-select field on a project item."""
713 data = github_graphql(
714 """
715 query($itemId: ID!) {
716 node(id: $itemId) {
717 ... on ProjectV2Item {
718 fieldValues(first: 20) {
719 nodes {
720 ... on ProjectV2ItemFieldSingleSelectValue {
721 field { ... on ProjectV2SingleSelectField { name } }
722 name
723 }
724 }
725 }
726 }
727 }
728 }
729 """,
730 {"itemId": item_id},
731 )
732 for field_value in data["node"]["fieldValues"]["nodes"]:
733 if field_value.get("field", {}).get("name") == field_name:
734 return field_value.get("name")
735 return None
736
737
738if __name__ == "__main__":
739 GITHUB_HEADERS = {
740 "Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
741 "Accept": "application/vnd.github+json",
742 "X-GitHub-Api-Version": "2022-11-28",
743 }
744
745 project_number = int(os.environ["PROJECT_NUMBER"])
746
747 if os.environ.get("REFRESH_ALL") == "1":
748 refresh_all_board_items(project_number)
749 sys.exit(0)
750
751 manual_pr_number = os.environ.get("MANUAL_PR_NUMBER")
752
753 if manual_pr_number:
754 pr = github_fetch_pr(manual_pr_number)
755 action = "labeled"
756 event = {}
757 print(f"Manual dispatch for PR #{manual_pr_number}")
758 else:
759 event_name = os.environ["GITHUB_EVENT_NAME"]
760 with open(os.environ["GITHUB_EVENT_PATH"]) as f:
761 event = json.load(f)
762
763 if event_name in ("pull_request", "pull_request_target"):
764 pr = event["pull_request"]
765 action = event["action"]
766 elif event_name == "issue_comment":
767 issue = event["issue"]
768 if "pull_request" not in issue:
769 print("Comment is on an issue, not a PR, skipping")
770 sys.exit(0)
771 commenter = event["comment"]["user"]["login"]
772 pr_author = issue["user"]["login"]
773 if commenter != pr_author:
774 print(
775 f"Commenter '{commenter}' is not PR author '{pr_author}', skipping"
776 )
777 sys.exit(0)
778 pr = github_fetch_pr(issue["number"])
779 action = "author_commented"
780 else:
781 print(f"Unexpected event: {event_name}")
782 sys.exit(0)
783
784 pr_labels = {label["name"] for label in pr.get("labels", [])}
785 if pr_labels & SKIP_LABELS:
786 print(f"Skipping PR #{pr['number']} (has {pr_labels & SKIP_LABELS} label)")
787 sys.exit(0)
788
789 if pr.get("draft"):
790 print(f"Skipping draft PR #{pr['number']}")
791 sys.exit(0)
792
793 if pr.get("state") != "open":
794 print(f"Skipping PR #{pr['number']} (state: {pr.get('state')})")
795 sys.exit(0)
796
797 print(f"Processing PR #{pr['number']}: action={action}")
798
799 if action in ("labeled", "unlabeled"):
800 project, project_item = sync_track_from_labels(pr, project_number)
801 if project_item:
802 recompute_signals(pr, project, project_item)
803 elif action == "assigned":
804 assignee_login = event.get("assignee", {}).get("login")
805 if not assignee_login:
806 print("No assignee login in event payload, skipping")
807 else:
808 set_progress_status_on_assignment(pr, assignee_login, project_number)
809 elif action == "review_requested":
810 return_to_reviewer(pr, project_number, "Author re-requested review")
811 elif action == "author_commented":
812 return_to_reviewer(pr, project_number, "Author commented on PR")
813 elif action == "edited":
814 refresh_signals_if_on_board(pr, project_number)
815 else:
816 print(f"Ignoring action: {action}")
817