Skip to content

Commit dbc0517

Browse files
authored
fix(review): route reviewers without requiring write access (#5257)
Signed-off-by: Jim Meyer <jimeyer@nvidia.com>
1 parent 0381a68 commit dbc0517

3 files changed

Lines changed: 32 additions & 39 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Every pull request must be approved by someone listed in [MAINTAINERS.md](MAINTA
7272

7373
Pull requests that touch the enforcement machinery or reviewer routing — anything under `.github/workflows/`, `.github/actions/`, `MAINTAINERS.md`, `REVIEWERS.md`, `.github/CODEOWNERS`, or the enforcement and reviewer-routing scripts in `tasks/scripts/` — additionally require a code owner's approval, listed in [.github/CODEOWNERS](.github/CODEOWNERS). The status check reads its inputs from `main`, but a workflow triggered by a review runs the pull request's own copy of the workflow file, so GitHub's native code owner requirement is what keeps a change from disabling the gate that would have blocked it.
7474

75-
Use exactly one `area:` label and any relevant `topic:` labels on your pull request. The [reviewer routing workflow](.github/workflows/review-readiness.yml) requests all matching members of [REVIEWERS.md](REVIEWERS.md) with repository write access, excluding the author, when a PR opens ready for review, leaves draft status, or gains or loses an area/topic label. Manual reviewer choices and people who have commented or reviewed are preserved. Propose roster membership changes through a reviewed PR.
75+
Use exactly one `area:` label and any relevant `topic:` labels on your pull request. The [reviewer routing workflow](.github/workflows/review-readiness.yml) requests all matching members of [REVIEWERS.md](REVIEWERS.md), excluding the author, when a PR opens ready for review, leaves draft status, or gains or loses an area/topic label. Reviewers do not need repository write access to receive requests or review PRs. Manual reviewer choices and people who have commented or reviewed are preserved. Propose roster membership changes through a reviewed PR.
7676

7777
If your pull request has been idle, ask for a reviewer in the pull request or in the CNCF Slack channel rather than waiting.
7878

‎tasks/scripts/check_review_readiness.py‎

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"""Reconcile PR reviewers from the merged REVIEWERS.md and current PR metadata.
1111
1212
Preview with --repo OWNER/REPO --pr NUMBER; add --apply to update requests.
13-
Request all matching members with write access, excluding the author. Preserve
13+
Request all matching roster members, excluding the author. Preserve
1414
manual choices, unknown request provenance, and anyone who commented or reviewed.
1515
The parser and planning helpers are importable. No state is stored between runs.
1616
"""
@@ -194,15 +194,6 @@ def _load_reviewers(repository: str, number: int, pr: dict) -> dict[str, set[str
194194
}
195195

196196

197-
def _eligible_reviewers(repository: str, logins: set[str]) -> set[str]:
198-
eligible = set()
199-
for login in sorted(logins):
200-
value = _api(f"repos/{repository}/collaborators/{login}/permission")
201-
if value.get("permission") in {"write", "maintain", "admin"}:
202-
eligible.add(login)
203-
return eligible
204-
205-
206197
def _routing_key(pr: dict):
207198
return (
208199
pr["state"],
@@ -228,7 +219,7 @@ def check_review_readiness(
228219
if pr["state"] != "open" or pr["draft"]:
229220
return None
230221
selected = select_reviewers(categories, {label["name"] for label in pr["labels"]})
231-
selected = _eligible_reviewers(repository, selected - {pr["user"]["login"].lower()})
222+
selected -= {pr["user"]["login"].lower()}
232223
plan = plan_review_requests(selected, **_load_reviewers(repository, number, pr))
233224
if apply:
234225
endpoint = f"{pull}/requested_reviewers"

‎tasks/scripts/check_review_readiness_test.py‎

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -227,17 +227,6 @@ def test_loads_all_engagement_and_timeline_feeds(self):
227227
self.assertTrue(all(call.kwargs["pages"] for call in api.call_args_list))
228228
self.assertIn("/timeline?per_page=100", api.call_args.args[0])
229229

230-
def test_only_current_write_maintain_admin_permissions_are_eligible(self):
231-
responses = [
232-
{"permission": role}
233-
for role in ("admin", "maintain", "read", "triage", "write")
234-
]
235-
with mock.patch.object(routing, "_api", side_effect=responses):
236-
self.assertEqual(
237-
routing._eligible_reviewers(REPO, {"a", "b", "c", "d", "e"}),
238-
{"a", "b", "e"},
239-
)
240-
241230

242231
class ReconciliationTests(unittest.TestCase):
243232
def run_routing(self, categories, *, snapshots, responses, apply=True):
@@ -246,31 +235,48 @@ def run_routing(self, categories, *, snapshots, responses, apply=True):
246235
mock.patch.object(
247236
routing, "_load_reviewers", side_effect=snapshots
248237
) as load,
249-
mock.patch.object(
250-
routing, "_eligible_reviewers", side_effect=lambda _repo, logins: logins
251-
) as eligible,
252238
):
253239
plan = routing.check_review_readiness(categories, REPO, 42, apply=apply)
254-
return plan, api, load, eligible
240+
return plan, api, load
255241

256242
def test_initial_assignment_excludes_author_without_extra_refresh(self):
257-
plan, api, load, eligible = self.run_routing(
243+
plan, api, load = self.run_routing(
258244
CATEGORIES,
259245
snapshots=[reviewers()],
260246
responses=[pr("area:runtime", "topic:policy", author="ALICE"), None],
261247
)
262248
self.assertEqual(plan, routing.ReviewPlan(("bob", "carol"), ()))
263-
eligible.assert_called_once_with(REPO, {"bob", "carol"})
264249
load.assert_called_once()
265250
self.assertEqual(api.call_count, 2)
266251
self.assertEqual(
267252
api.call_args.kwargs,
268253
{"method": "POST", "data": {"reviewers": ["bob", "carol"]}},
269254
)
270255

256+
def test_requests_roster_members_without_permission_lookups(self):
257+
members = {"read-member", "triage-member", "outside-collaborator"}
258+
categories = {"area:runtime": members}
259+
plan, api, _ = self.run_routing(
260+
categories,
261+
snapshots=[reviewers()],
262+
responses=[pr("area:runtime"), None],
263+
)
264+
self.assertEqual(plan, routing.ReviewPlan(tuple(sorted(members)), ()))
265+
self.assertEqual(
266+
api.call_args_list,
267+
[
268+
mock.call(PULL),
269+
mock.call(
270+
f"{PULL}/requested_reviewers",
271+
method="POST",
272+
data={"reviewers": sorted(members)},
273+
),
274+
],
275+
)
276+
271277
def test_empty_area_does_not_block_topic_reviewers(self):
272278
categories = {"area:runtime": set(), "topic:policy": {"carol"}}
273-
plan, _, _, _ = self.run_routing(
279+
plan, _, _ = self.run_routing(
274280
categories,
275281
snapshots=[reviewers()],
276282
responses=[pr("area:runtime", "topic:policy")],
@@ -281,16 +287,15 @@ def test_empty_area_does_not_block_topic_reviewers(self):
281287
def test_draft_and_closed_prs_skip_all_routing(self):
282288
for value in (pr(draft=True), pr(state="closed")):
283289
with self.subTest(value=value):
284-
plan, api, load, eligible = self.run_routing(
290+
plan, api, load = self.run_routing(
285291
CATEGORIES, snapshots=[], responses=[value]
286292
)
287293
self.assertIsNone(plan)
288294
api.assert_called_once_with(PULL)
289295
load.assert_not_called()
290-
eligible.assert_not_called()
291296

292297
def test_preview_makes_no_changes(self):
293-
plan, api, _, _ = self.run_routing(
298+
plan, api, _ = self.run_routing(
294299
CATEGORIES,
295300
snapshots=[reviewers()],
296301
responses=[pr("area:build")],
@@ -310,7 +315,7 @@ def test_refresh_before_removal_protects_new_manual_choices_and_engagement(self)
310315
manual=("carol",),
311316
),
312317
]
313-
plan, api, _, _ = self.run_routing(
318+
plan, api, _ = self.run_routing(
314319
CATEGORIES, snapshots=snapshots, responses=[before, None, after, None]
315320
)
316321
self.assertEqual(plan, routing.ReviewPlan(("dave",), ("alice",)))
@@ -338,9 +343,6 @@ def test_changed_routing_or_draft_status_stops_removals(self):
338343
"_load_reviewers",
339344
return_value=reviewers(requested=("alice", "dave")),
340345
),
341-
mock.patch.object(
342-
routing, "_eligible_reviewers", return_value={"dave"}
343-
),
344346
):
345347
with self.assertRaisesRegex(ValueError, "routing changed"):
346348
routing.check_review_readiness(CATEGORIES, REPO, 42, apply=True)
@@ -356,7 +358,7 @@ def test_new_commit_and_unrelated_label_do_not_interrupt_routing(self):
356358
after = pr("area:build", "kind:bug", requested=("alice", "dave"))
357359
before["head"], after["head"] = {"sha": "old"}, {"sha": "new"}
358360
snapshots = [reviewers(requested=("alice", "dave"))] * 2
359-
plan, _, _, _ = self.run_routing(
361+
plan, _, _ = self.run_routing(
360362
CATEGORIES, snapshots=snapshots, responses=[before, after, None]
361363
)
362364
self.assertEqual(plan.remove, ("alice",))
@@ -374,7 +376,7 @@ def test_retry_after_partial_failure_uses_updated_github_metadata(self):
374376
snapshots=snapshots,
375377
responses=[before, None, after, subprocess.CalledProcessError(1, "gh")],
376378
)
377-
plan, api, _, _ = self.run_routing(
379+
plan, api, _ = self.run_routing(
378380
CATEGORIES, snapshots=[snapshots[1]] * 2, responses=[after, after, None]
379381
)
380382
self.assertEqual(plan, routing.ReviewPlan((), ("alice",)))

0 commit comments

Comments
 (0)