github-community-pr-board.py

  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}")