|
88 | 88 | env: |
89 | 89 | GITHUB_TOKEN: ${{ github.token }} |
90 | 90 | UPSTREAM_PR: ${{ steps.send_pr.outputs.pr }} |
| 91 | + |
| 92 | + - name: Request CODEOWNERS reviewers on upstream PR |
| 93 | + if: steps.send_pr.outputs.pr != '' |
| 94 | + env: |
| 95 | + GH_TOKEN: ${{ steps.resolve_token.outputs.token }} |
| 96 | + PR: ${{ steps.send_pr.outputs.pr }} |
| 97 | + UPSTREAM: ${{ env.UPSTREAM_OWNER }}/${{ env.UPSTREAM_REPO }} |
| 98 | + run: | |
| 99 | + set -euo pipefail |
| 100 | + # System Python on the runner is PEP 668 externally-managed; use a venv. |
| 101 | + python3 -m venv /tmp/codeowners-venv |
| 102 | + /tmp/codeowners-venv/bin/pip install --quiet pathspec |
| 103 | + /tmp/codeowners-venv/bin/python <<'PY' |
| 104 | + import base64, json, os, subprocess, sys |
| 105 | + from pathspec import GitIgnoreSpec |
| 106 | +
|
| 107 | + pr = os.environ["PR"] |
| 108 | + upstream = os.environ["UPSTREAM"] |
| 109 | +
|
| 110 | + pr_json = subprocess.check_output( |
| 111 | + ["gh", "api", f"repos/{upstream}/pulls/{pr}"], text=True) |
| 112 | + pr_data = json.loads(pr_json) |
| 113 | + author = pr_data["user"]["login"] |
| 114 | + base_ref = pr_data["base"]["ref"] |
| 115 | +
|
| 116 | + # Authoritative CODEOWNERS is the one on the PR base branch. |
| 117 | + raw = subprocess.check_output( |
| 118 | + ["gh", "api", "--method", "GET", |
| 119 | + f"repos/{upstream}/contents/.github/CODEOWNERS", |
| 120 | + "-f", f"ref={base_ref}", "--jq", ".content"], text=True).strip() |
| 121 | + codeowners = base64.b64decode(raw).decode() |
| 122 | +
|
| 123 | + rules = [] |
| 124 | + for line in codeowners.splitlines(): |
| 125 | + line = line.split("#", 1)[0].strip() |
| 126 | + if not line: |
| 127 | + continue |
| 128 | + pattern, *rule_owners = line.split() |
| 129 | + rules.append((GitIgnoreSpec.from_lines([pattern]), rule_owners)) |
| 130 | +
|
| 131 | + # GitHub caps /pulls/{n}/files at 3000 even with --paginate; truly |
| 132 | + # enormous PRs will under-request owners for the overflow. |
| 133 | + files = subprocess.check_output( |
| 134 | + ["gh", "api", f"repos/{upstream}/pulls/{pr}/files", "--paginate", |
| 135 | + "--jq", ".[].filename"], text=True).splitlines() |
| 136 | +
|
| 137 | + owners = set() |
| 138 | + for path in files: |
| 139 | + matched = None |
| 140 | + for spec, rule_owners in rules: |
| 141 | + if spec.match_file(path): |
| 142 | + matched = rule_owners # last match wins |
| 143 | + if matched: |
| 144 | + owners.update(o.lstrip("@") for o in matched) |
| 145 | +
|
| 146 | + # CODEOWNERS lists teams as "org/slug"; the REST endpoint wants the |
| 147 | + # bare slug in team_reviewers. |
| 148 | + team_slugs = sorted(t.split("/", 1)[1] for t in owners if "/" in t) |
| 149 | + users = sorted(o for o in owners |
| 150 | + if "/" not in o and o.lower() != author.lower()) |
| 151 | +
|
| 152 | + if not (team_slugs or users): |
| 153 | + print("No CODEOWNERS-matched reviewers.") |
| 154 | + sys.exit(0) |
| 155 | +
|
| 156 | + # Use the REST POST endpoint directly: `gh pr edit --add-reviewer` |
| 157 | + # runs a GraphQL query that needs read:org, which our tokens don't |
| 158 | + # have. POST /pulls/{n}/requested_reviewers only writes, so the |
| 159 | + # existing repo / pull-requests:write scope is enough. Each array |
| 160 | + # is capped at 15 per call. |
| 161 | + def request(body): |
| 162 | + print("Requesting:", body) |
| 163 | + subprocess.run( |
| 164 | + ["gh", "api", "--method", "POST", |
| 165 | + f"repos/{upstream}/pulls/{pr}/requested_reviewers", |
| 166 | + "--input", "-"], |
| 167 | + input=json.dumps(body), text=True, check=True) |
| 168 | +
|
| 169 | + for i in range(0, len(team_slugs), 15): |
| 170 | + request({"team_reviewers": team_slugs[i:i + 15]}) |
| 171 | + for i in range(0, len(users), 15): |
| 172 | + request({"reviewers": users[i:i + 15]}) |
| 173 | + PY |
0 commit comments