Add meta signals to the community PR board (#58635)

For the ease of finding something that fits the time that reviewers have
and for making it mechanically easier to prioritize the PRs from the
community champions, surface some meta information on the PR board (and
make it updatable). Meta information here means things like size and
whether there's an issue linked to the PR (and what is its type if there
is).

Release Notes:

- N/A
This commit is contained in:
Lena authored and GitHub committed 2026-06-05 10:16:52 +00:00
1 parent 21df82d5a4
commit 0db7bf3941
3 files changed
+335 -12

No files matched your search

+1 -1
View File
@@ -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:
@@ -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
+278 -11
View File
@@ -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}")