diff --git a/.github/workflows/community_pr_board.yml b/.github/workflows/community_pr_board.yml index 03bb938173..d155cf8275 100644 --- a/.github/workflows/community_pr_board.yml +++ b/.github/workflows/community_pr_board.yml @@ -13,7 +13,7 @@ name: Community PR Board on: pull_request_target: - types: [labeled, unlabeled, assigned, review_requested] + types: [labeled, unlabeled, assigned, review_requested, edited] issue_comment: types: [created] workflow_dispatch: diff --git a/.github/workflows/community_pr_board_refresh.yml b/.github/workflows/community_pr_board_refresh.yml new file mode 100644 index 0000000000..1d77638c82 --- /dev/null +++ b/.github/workflows/community_pr_board_refresh.yml @@ -0,0 +1,56 @@ +# Community PR Board — daily meta information refresh +# +# Walks every open PR on the community board and recomputes its signal +# fields. Backstop for changes that don't reach the event-driven workflow, +# either because the relevant webhook isn't subscribed or doesn't fire at all. + +name: PR Board Meta Fields Refresh + +on: + schedule: + - cron: "0 9 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: community-pr-board-refresh + cancel-in-progress: true + +jobs: + refresh: + if: github.repository == 'zed-industries/zed' + runs-on: namespace-profile-2x4-ubuntu-2404 + timeout-minutes: 15 + + steps: + - name: Generate app token + id: app-token + uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v3.0.0 + with: + app-id: ${{ secrets.ZED_COMMUNITY_BOT_APP_ID }} + private-key: ${{ secrets.ZED_COMMUNITY_BOT_PRIVATE_KEY }} + owner: zed-industries + + - name: Checkout repository + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + script/github-community-pr-board.py + sparse-checkout-cone-mode: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install requests + + - name: Refresh all board items + env: + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_NUMBER: "85" + REFRESH_ALL: "1" + run: python script/github-community-pr-board.py diff --git a/script/github-community-pr-board.py b/script/github-community-pr-board.py index c89beec42a..b4879d9cf6 100644 --- a/script/github-community-pr-board.py +++ b/script/github-community-pr-board.py @@ -1,12 +1,17 @@ #!/usr/bin/env python3 """ -Route community PRs to the correct review track on a GitHub Project board. +Route community PRs to the correct review track on a GitHub Project board, +and surface signal fields (Size, Issue Linked, Contributor) that the +board's views can filter and sort on. Reads the event payload dispatched by the GitHub Actions workflow and: -- On `labeled`: adds the PR to the board (idempotent) and sets the Track - field to the most specific matching track. +- On `labeled`: adds the PR to the board (idempotent), sets the Track + field to the most specific matching track, and recomputes signal fields. - On `unlabeled`: re-resolves Track from remaining labels, or clears it if no area/platform labels remain (PR stays on the board for visibility). + Signals are recomputed if the PR is on the board. +- On `edited`: recomputes signals if the PR is on the board (a body edit + can change which issues the PR closes). - On `assigned`: if the assignee is a staff team member, sets Status to "In Progress (us)". - On `review_requested`: if current Status is "In Progress (author)", @@ -15,12 +20,22 @@ Reads the event payload dispatched by the GitHub Actions workflow and: - On `issue_comment.created`: if the commenter is the PR author and current Status is "In Progress (author)", flips it to "In Progress (us)" — the author is likely signaling they're done. -- On `workflow_dispatch`: re-resolves Track for a manually specified PR. +- On `workflow_dispatch` with a PR number: re-resolves Track and + recomputes signals for a manually specified PR. +- When run with REFRESH_ALL=1: walks every open PR on the board and + recomputes its signals. Used by the scheduled refresh workflow as a + belt-and-suspenders pass against missed webhook events (notably + intentionally-unsubscribed `synchronize` events that change Size, and + sidebar edits to linked issues that don't fire a PR webhook). Review-based status changes (approved → "In Progress (us)", changes requested → "In Progress (author)") are handled by built-in board automations, not this script. +Signal fields are written best-effort: if a field or option is missing +from the project, the script logs and skips it rather than failing, so +new fields can be rolled out in the project UI independently of deploys. + Requires: requests (pip install requests) @@ -57,7 +72,12 @@ MAPPING_PATH = Path(__file__).parent / "community-pr-track-mapping.json" def sync_track_from_labels(pr, project_number): - """Sync the PR's Track field on the board with its current labels.""" + """Sync the PR's Track field on the board with its current labels. + + Returns (project, project_item) if the PR ends up on (or stays on) + the board, so callers can do further per-item work without a second + project/item lookup. Returns (None, None) otherwise. + """ pr_labels = {label["name"] for label in pr.get("labels", [])} track_name = resolve_track(pr_labels, load_mapping()) @@ -68,17 +88,18 @@ def sync_track_from_labels(pr, project_number): if project_item: github_clear_field(project, project_item, "Track") print(f"No track matched, cleared Track on PR #{pr['number']}") - else: - print( - f"No track matched for labels on PR #{pr['number']}, not on board, nothing to do" - ) - return + return project, project_item + print( + f"No track matched for labels on PR #{pr['number']}, not on board, nothing to do" + ) + return None, None 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) + return project, project_item def set_progress_status_on_assignment(pr, assignee_login, project_number): @@ -118,6 +139,100 @@ def return_to_reviewer(pr, project_number, reason): print(f"Current status is '{current_status}', not flipping ({reason})") +def compute_size_bucket(total_changes): + """Return the size bucket label for a PR's total (additions + deletions). + + Size is computed from raw additions + deletions; we don't try to strip + generated files. + """ + buckets = [(30, "XS"), (150, "S"), (400, "M"), (1000, "L")] + for threshold, name in buckets: + if total_changes < threshold: + return name + return "XL" + + +def compute_contributor(pr_labels): + """Return the Contributor value derived from the PR's labels. + + The auto-labeler is responsible for applying `community champion` and + `first contribution` based on the author's history; anything else on + the board is treated as a returning contributor (PRs from staff/bots + don't reach this function because they're filtered upstream). + """ + if "community champion" in pr_labels: + return "Champion" + if "first contribution" in pr_labels: + return "First PR" + return "Returning" + + +def recompute_signals(pr, project, project_item): + """Recompute Size, Contributor, and Issue Linked on a board item. + + Best-effort: missing fields/options are logged and skipped so the + project's field set can be rolled out independently. + """ + pr_labels = {label["name"] for label in pr.get("labels", [])} + total_changes = pr.get("additions", 0) + pr.get("deletions", 0) + + set_field_optional(project, project_item, "Size", compute_size_bucket(total_changes)) + set_field_optional(project, project_item, "Contributor", compute_contributor(pr_labels)) + set_field_optional(project, project_item, "Issue Linked", github_pr_issue_type(pr["node_id"])) + + +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_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") + return + recompute_signals(pr, project, project_item) + + +def refresh_all_board_items(project_number): + """Walk every open PR on the board and recompute its signals. + + 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) + processed = skipped = errors = 0 + for item in github_list_project_items(project["id"]): + if item.get("isArchived"): + skipped += 1 + continue + content = item.get("content") or {} + if content.get("__typename") != "PullRequest": + skipped += 1 + continue + if content.get("state") != "OPEN" or content.get("isDraft"): + skipped += 1 + continue + label_names = [n["name"] for n in content["labels"]["nodes"]] + if set(label_names) & SKIP_LABELS: + skipped += 1 + continue + pr = { + "number": content["number"], + "node_id": content["id"], + "labels": [{"name": n} for n in label_names], + "additions": content["additions"], + "deletions": content["deletions"], + } + print(f"--- Refreshing PR #{pr['number']} ---") + try: + recompute_signals(pr, project, item["id"]) + processed += 1 + except Exception as exc: + print(f"Failed to refresh PR #{pr['number']}: {exc}") + errors += 1 + print( + f"Refresh complete: {processed} processed, {skipped} skipped, {errors} errors" + ) + + def load_mapping(path=MAPPING_PATH): """Load the Track-to-labels mapping from the JSON file.""" with open(path) as f: @@ -155,6 +270,7 @@ def github_graphql(query, variables): 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): @@ -169,6 +285,7 @@ def github_rest_get(path): continue response.raise_for_status() return response.json() + raise RuntimeError("github_rest_get: retry loop exited without return") def github_is_staff_member(username): @@ -239,6 +356,97 @@ def github_add_to_project(project_id, content_node_id): return item_id +def github_list_project_items(project_id): + """Yield every item on a project board, paginating as needed. + + Each item includes `isArchived` so callers can cheaply filter archived + items out without an extra round-trip per item. + """ + cursor = None + while True: + data = github_graphql( + """ + query($projectId: ID!, $cursor: String) { + node(id: $projectId) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isArchived + content { + __typename + ... on PullRequest { + id + number + state + isDraft + additions + deletions + labels(first: 50) { nodes { name } } + } + } + } + } + } + } + } + """, + {"projectId": project_id, "cursor": cursor}, + ) + page = data["node"]["items"] + for item in page["nodes"]: + yield item + if not page["pageInfo"]["hasNextPage"]: + return + cursor = page["pageInfo"]["endCursor"] + + +def github_pr_issue_type(pr_node_id): + """Return the Issue Linked field value for a PR. + + Reads `closingIssuesReferences` (authoritative source for what GitHub + will close on merge, covers both `Closes #N` keywords and Development + sidebar links) and maps the linked issues' types to one of: 'Crash', + 'Bug', 'Feature', 'Docs', or 'No issue'. + + When a PR closes multiple issues with different types, returns the + most urgent one by the priority Crash > Bug > Feature > Docs. + """ + data = github_graphql( + """ + query($prId: ID!) { + node(id: $prId) { + ... on PullRequest { + closingIssuesReferences(first: 20) { + totalCount + nodes { issueType { name } } + } + } + } + } + """, + {"prId": pr_node_id}, + ) + refs = data["node"]["closingIssuesReferences"] + if refs["totalCount"] == 0: + return "No issue" + type_names = { + n["issueType"]["name"] for n in refs["nodes"] if n.get("issueType") + } + for priority in ("Crash", "Bug", "Feature", "Docs"): + if priority in type_names: + return priority + # Org-wide guardrails should ensure every issue has a recognized type, + # so reaching this branch means something slipped through. Log it and + # fall back so the PR doesn't carry stale data. + print( + f"Warning: PR has {refs['totalCount']} linked issue(s) but none with " + f"a recognized type (saw: {type_names or 'no type set'})" + ) + return "No issue" + + def github_find_project_item(project_id, content_node_id): """Find a PR's item ID on the project board, or None if not present. @@ -320,6 +528,56 @@ def github_set_project_field(project, item_id, field_name, option_name): 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 + option doesn't exist on the project yet. + + Used for signal fields so they can be added in the project UI + independently of script deploys without crashing the workflow. + """ + field = None + for f in project["fields"]["nodes"]: + if f.get("name") == field_name: + field = f + break + if not field: + print(f"Field '{field_name}' not on project, skipping") + return + option_id = None + for opt in field.get("options", []): + if opt["name"] == option_name: + option_id = opt["id"] + break + if not option_id: + available = [opt["name"] for opt in field.get("options", [])] + print( + f"Option '{option_name}' not in field '{field_name}' " + f"(available: {available}), skipping" + ) + return + 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 github_clear_field(project, item_id, field_name): """Clear a single-select field on a project item.""" field_id = None @@ -390,6 +648,11 @@ if __name__ == "__main__": } project_number = int(os.environ["PROJECT_NUMBER"]) + + if os.environ.get("REFRESH_ALL") == "1": + refresh_all_board_items(project_number) + sys.exit(0) + manual_pr_number = os.environ.get("MANUAL_PR_NUMBER") if manual_pr_number: @@ -435,7 +698,9 @@ if __name__ == "__main__": print(f"Processing PR #{pr['number']}: action={action}") if action in ("labeled", "unlabeled"): - sync_track_from_labels(pr, project_number) + project, project_item = sync_track_from_labels(pr, project_number) + if project_item: + recompute_signals(pr, project, project_item) elif action == "assigned": assignee_login = event.get("assignee", {}).get("login") if not assignee_login: @@ -446,5 +711,7 @@ if __name__ == "__main__": return_to_reviewer(pr, project_number, "Author re-requested review") elif action == "author_commented": return_to_reviewer(pr, project_number, "Author commented on PR") + elif action == "edited": + refresh_signals_if_on_board(pr, project_number) else: print(f"Ignoring action: {action}")