Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-27T23:29:25.722Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

github-community-pr-board.py

817 lines · 28.3 KB · python
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
Served at tenant.openagents/omega Member data and write actions are omitted.