From dead22978d9040ea61d5c514bea600a3f39da8e3 Mon Sep 17 00:00:00 2001 From: Matt Liberty Date: Wed, 13 May 2026 20:45:34 +0000 Subject: [PATCH 1/3] ci: request CODEOWNERS reviewers on staging-sync PRs GitHub's CODEOWNERS auto-request silently skips PRs opened from an org-owned fork (the staging mirror), even though it fires normally for user forks. The sync workflow's PRs were never getting owners assigned, so branch protection blocked merges with no reviewers in flight. Add a post-send_pr step that parses CODEOWNERS from the PR base branch, matches changed files, and explicitly requests the resulting teams and users via `gh pr edit --add-reviewer`. Chunked at 15 to respect the request-reviewers endpoint cap. Signed-off-by: Matt Liberty --- .../github-actions-on-label-create.yml | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/.github/workflows/github-actions-on-label-create.yml b/.github/workflows/github-actions-on-label-create.yml index 6b809642b11..ace9bb81fee 100644 --- a/.github/workflows/github-actions-on-label-create.yml +++ b/.github/workflows/github-actions-on-label-create.yml @@ -88,3 +88,74 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} UPSTREAM_PR: ${{ steps.send_pr.outputs.pr }} + + - name: Request CODEOWNERS reviewers on upstream PR + if: steps.send_pr.outputs.pr != '' + env: + GH_TOKEN: ${{ steps.resolve_token.outputs.token }} + PR: ${{ steps.send_pr.outputs.pr }} + UPSTREAM: ${{ env.UPSTREAM_OWNER }}/${{ env.UPSTREAM_REPO }} + run: | + set -euo pipefail + python3 -m pip install --quiet pathspec + python3 <<'PY' + import base64, json, os, subprocess, sys + from pathspec import GitIgnoreSpec + + pr = os.environ["PR"] + upstream = os.environ["UPSTREAM"] + + pr_json = subprocess.check_output( + ["gh", "api", f"repos/{upstream}/pulls/{pr}"], text=True) + pr_data = json.loads(pr_json) + author = pr_data["user"]["login"] + base_ref = pr_data["base"]["ref"] + + # Authoritative CODEOWNERS is the one on the PR base branch. + raw = subprocess.check_output( + ["gh", "api", "--method", "GET", + f"repos/{upstream}/contents/.github/CODEOWNERS", + "-f", f"ref={base_ref}", "--jq", ".content"], text=True).strip() + codeowners = base64.b64decode(raw).decode() + + rules = [] + for line in codeowners.splitlines(): + line = line.split("#", 1)[0].strip() + if not line: + continue + pattern, *rule_owners = line.split() + rules.append((GitIgnoreSpec.from_lines([pattern]), rule_owners)) + + # GitHub caps /pulls/{n}/files at 3000 even with --paginate; truly + # enormous PRs will under-request owners for the overflow. + files = subprocess.check_output( + ["gh", "api", f"repos/{upstream}/pulls/{pr}/files", "--paginate", + "--jq", ".[].filename"], text=True).splitlines() + + owners = set() + for path in files: + matched = None + for spec, rule_owners in rules: + if spec.match_file(path): + matched = rule_owners # last match wins + if matched: + owners.update(o.lstrip("@") for o in matched) + + teams = sorted(o for o in owners if "/" in o) + users = sorted(o for o in owners + if "/" not in o and o.lower() != author.lower()) + reviewers = teams + users + + if not reviewers: + print("No CODEOWNERS-matched reviewers.") + sys.exit(0) + + # GitHub's request-reviewers endpoint caps each array at 15; chunk so + # PRs that match many owner teams don't get 422'd. + for i in range(0, len(reviewers), 15): + batch = reviewers[i:i + 15] + print("Requesting:", batch) + subprocess.check_call( + ["gh", "pr", "edit", pr, "--repo", upstream, + "--add-reviewer", ",".join(batch)]) + PY From 2baff417f8c579f2d159ddc039177558e2d75a82 Mon Sep 17 00:00:00 2001 From: Matt Liberty Date: Wed, 13 May 2026 20:54:33 +0000 Subject: [PATCH 2/3] ci: install pathspec into a venv on the runner System Python on ubuntu-latest is PEP 668 externally-managed, so the bare `pip install pathspec` failed the new CODEOWNERS-request step with `externally-managed-environment`. Create a venv under /tmp and install into it instead. Signed-off-by: Matt Liberty --- .github/workflows/github-actions-on-label-create.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/github-actions-on-label-create.yml b/.github/workflows/github-actions-on-label-create.yml index ace9bb81fee..98c0db6b5a8 100644 --- a/.github/workflows/github-actions-on-label-create.yml +++ b/.github/workflows/github-actions-on-label-create.yml @@ -97,8 +97,10 @@ jobs: UPSTREAM: ${{ env.UPSTREAM_OWNER }}/${{ env.UPSTREAM_REPO }} run: | set -euo pipefail - python3 -m pip install --quiet pathspec - python3 <<'PY' + # System Python on the runner is PEP 668 externally-managed; use a venv. + python3 -m venv /tmp/codeowners-venv + /tmp/codeowners-venv/bin/pip install --quiet pathspec + /tmp/codeowners-venv/bin/python <<'PY' import base64, json, os, subprocess, sys from pathspec import GitIgnoreSpec From 6cdf8837e3aec93d0e7bb80c0a83fcf502d13cca Mon Sep 17 00:00:00 2001 From: Matt Liberty Date: Wed, 13 May 2026 21:10:25 +0000 Subject: [PATCH 3/3] ci: post reviewers via REST instead of `gh pr edit` `gh pr edit --add-reviewer` runs a GraphQL query that touches team login/name/slug fields, all of which require read:org. Our tokens have repo+workflow but not read:org, so the step fails with "Your token has not been granted the required scopes." Switch to POST /repos/{owner}/{repo}/pulls/{n}/requested_reviewers, which only writes and works with the existing pull-requests:write scope. Send team_reviewers (bare slug, not org/slug) and reviewers in separate arrays, each chunked to 15. Signed-off-by: Matt Liberty --- .../github-actions-on-label-create.yml | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/github-actions-on-label-create.yml b/.github/workflows/github-actions-on-label-create.yml index 98c0db6b5a8..f52d1b69608 100644 --- a/.github/workflows/github-actions-on-label-create.yml +++ b/.github/workflows/github-actions-on-label-create.yml @@ -143,21 +143,31 @@ jobs: if matched: owners.update(o.lstrip("@") for o in matched) - teams = sorted(o for o in owners if "/" in o) + # CODEOWNERS lists teams as "org/slug"; the REST endpoint wants the + # bare slug in team_reviewers. + team_slugs = sorted(t.split("/", 1)[1] for t in owners if "/" in t) users = sorted(o for o in owners if "/" not in o and o.lower() != author.lower()) - reviewers = teams + users - if not reviewers: + if not (team_slugs or users): print("No CODEOWNERS-matched reviewers.") sys.exit(0) - # GitHub's request-reviewers endpoint caps each array at 15; chunk so - # PRs that match many owner teams don't get 422'd. - for i in range(0, len(reviewers), 15): - batch = reviewers[i:i + 15] - print("Requesting:", batch) - subprocess.check_call( - ["gh", "pr", "edit", pr, "--repo", upstream, - "--add-reviewer", ",".join(batch)]) + # Use the REST POST endpoint directly: `gh pr edit --add-reviewer` + # runs a GraphQL query that needs read:org, which our tokens don't + # have. POST /pulls/{n}/requested_reviewers only writes, so the + # existing repo / pull-requests:write scope is enough. Each array + # is capped at 15 per call. + def request(body): + print("Requesting:", body) + subprocess.run( + ["gh", "api", "--method", "POST", + f"repos/{upstream}/pulls/{pr}/requested_reviewers", + "--input", "-"], + input=json.dumps(body), text=True, check=True) + + for i in range(0, len(team_slugs), 15): + request({"team_reviewers": team_slugs[i:i + 15]}) + for i in range(0, len(users), 15): + request({"reviewers": users[i:i + 15]}) PY