Skip to content

Commit 65fc219

Browse files
authored
review-checklist: enforce one load-balanced reviewer and fix checklist display (#6439)
* review-checklist: show all requested owners per area in checklist buildBody was using area.owners.find() — picking the first CODEOWNERS-listed owner in the requested set. This broke reviewer swaps (removing @A and adding @b would show @C, the next CODEOWNERS entry, not @b) and also failed to reflect GitHub's CODEOWNERS auto-assignment, which requests all owners. Switch to area.owners.filter() so every requested owner for an area is mentioned in that row. Approval logic is unchanged: any owner approval still checks the box. * review-checklist: enforce one load-balanced reviewer per area GitHub's CODEOWNERS auto-assignment requests all owners of touched files when a PR opens, before the workflow runs. The script was then seeing them already assigned and skipping its own selection entirely. On opened/reopened: select one load-balanced reviewer per area (ignoring GitHub's pre-assigned set), remove any auto-assigned CODEOWNERS not in the selection, then add the chosen reviewer. Only code owners are removed — manually-added non-owner reviewers are left untouched. On synchronize: keep existing assignments (reviewer may have already started). confirmedRequested now starts empty and is populated only with the script's selection, so the checklist only mentions owners that were explicitly chosen. * review-checklist: retry reviewer cleanup to handle GitHub auto-assign race GitHub's CODEOWNERS auto-assignment can fire after the workflow starts, re-adding extra reviewers after we remove them. Add a 15-second wait followed by a second cleanup pass on opened/reopened events. Extract the removal loop into enforceSelection() so both passes share the same logic. * zizmor: suppress template-injection for PR #6356 workflow files Add zizmor_config.yml to ignore template-injection findings in the setup-jextract action and maven/ctest workflow files introduced in PR #6356, where inputs are caller-controlled but not user-controlled. * review-checklist: don't re-assign reviewers on synchronize On synchronize, chooseReviewers saw no owner assigned (because the reviewer was manually removed) and re-added one via requestReviewers, overriding the manual removal. Reviewer assignment now only happens on opened/reopened. Synchronize only updates the checklist display based on whoever is currently assigned — manual removals are respected. * zizmor: skip upload-sarif failure on fork PRs * zizmor: move config out of workflows dir to fix GitHub Actions parse error GitHub Actions parses all .yml files in .github/workflows/ as workflow files; zizmor_config.yml there caused "unexpected value 'rules'" because rules: is not valid workflow syntax. Moved to .github/zizmor.yml and updated the --config path in zizmor.yml accordingly. * review-checklist: retry on transient 401 from GitHub API GitHub's API intermittently returns 401 on write operations (issues.addAssignees, issues.createComment) even when the token has Issues: write and PullRequests: write — read-only calls succeed in the same run. The github-script action excludes 401 from retries by default. Removing 401 from retry-exempt-status-codes and setting retries: 3 handles these transient failures with exponential backoff. * review-checklist: fix checklist mention when non-requested owner approves The mention in each checklist row was derived from the approver (if any), so when a different area owner happened to approve first, the mention changed from the assigned reviewer to the approver. Fix by decoupling sign-off detection from display: signedOff now uses .some() so any owner's approval checks the box, while the mention always shows the confirmed-requested reviewer(s) via .filter(). Also shows multiple reviewers when one is manually added alongside the load-balanced selection. * review-checklist: show approver name when signed off, requested reviewer(s) when pending When an area is signed off, replace the mention with the approver so the checklist shows who actually reviewed it (which may differ from whoever was load-balanced as the requested reviewer). When pending, show all confirmed-requested reviewers — the one load-balanced pick normally, but two if a reviewer was manually added alongside it. * zizmor: remove config file and --config flag The ignored files (ctest.yml, maven-deploy.yml, maven-staging.yml, setup-jextract/action.yml) had template-injection findings on inputs.* references, which are not attacker-controllable. Rather than suppressing them via a config file, let all findings surface in the Security tab and address them individually if needed.
1 parent 64036c6 commit 65fc219

4 files changed

Lines changed: 89 additions & 17 deletions

File tree

.github/scripts/review-checklist.js

Lines changed: 71 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,15 @@ function chooseReviewers(touchedAreas, {
159159
function buildBody(touchedAreas, approvedUsers, confirmedRequested) {
160160
const rowData = touchedAreas.map(area => {
161161
const approver = area.owners.find(o => approvedUsers.has(o));
162-
const assigned = approver ?? area.owners.find(o => confirmedRequested.has(o));
163162
const signedOff = !!approver;
164163
const box = signedOff ? 'x' : ' ';
165164
const tick = signedOff ? ' ✅' : '';
166-
const mention = assigned ? ` — @${assigned}` : '';
165+
// Signed off: show who approved. Pending: show all confirmed-requested reviewers
166+
// (may be > 1 if a reviewer was manually added alongside the load-balanced pick).
167+
const requested = area.owners.filter(o => confirmedRequested.has(o));
168+
const mention = approver
169+
? ` — @${approver}`
170+
: requested.length > 0 ? ` — ${requested.map(o => `@${o}`).join(', ')}` : '';
167171
return { text: `- [${box}] **${area.label}**${tick}${mention}`, signedOff };
168172
});
169173

@@ -357,10 +361,10 @@ module.exports = async function run({ github, context, core }) {
357361
prData.requested_reviewers.map(r => r.login).filter(Boolean)
358362
);
359363

360-
// confirmedRequested tracks what was actually successfully requested
361-
// (used in buildBody so the checklist never shows an owner whose
362-
// requestReviewers call silently failed).
363-
let confirmedRequested = new Set(existingRequested);
364+
// confirmedRequested tracks who is actually assigned for checklist display.
365+
// Starts empty — populated below only with the load-balanced selection so
366+
// the checklist never shows owners that were not chosen by the script.
367+
let confirmedRequested = new Set();
364368

365369
// ----------------------------------------------------------------
366370
// 6. Auto-assign reviewers (pull_request events only, not reviews).
@@ -400,30 +404,80 @@ module.exports = async function run({ github, context, core }) {
400404
core.warning(`Could not fetch open PRs for load balancing; falling back to CODEOWNERS order: ${e.message}`);
401405
}
402406

407+
const isNewPR = context.payload.action === 'opened' || context.payload.action === 'reopened';
408+
409+
// On open/reopen: ignore existing assignments so GitHub's CODEOWNERS
410+
// auto-assignment doesn't suppress our load-balanced selection.
411+
// On synchronize: respect existing assignments (reviewer may have already started).
403412
const { selected, log } = chooseReviewers(touchedAreas, {
404413
prAuthor,
405-
existingRequested,
414+
existingRequested: isNewPR ? new Set() : existingRequested,
406415
reviewerLoad,
407416
LINE_THRESHOLD,
408417
AREA_THRESHOLDS,
409418
PUBLIC_HEADER,
410419
});
411420
for (const msg of log) core.info(msg);
412421

413-
// Request one at a time so a single invalid login cannot block the rest.
414-
// Only add to confirmedRequested on success — the checklist must not show
415-
// an owner whose request call failed.
416-
for (const reviewer of selected) {
422+
// Helper: remove CODEOWNERS auto-assigned reviewers not in our selection.
423+
// Only removes code owners — leaves manually-added non-owner reviewers untouched.
424+
async function enforceSelection(currentRequested) {
425+
for (const reviewer of currentRequested) {
426+
if (allCodeOwners.has(reviewer) && !selected.has(reviewer)) {
427+
try {
428+
await github.rest.pulls.removeRequestedReviewers({
429+
owner, repo, pull_number: pr_number, reviewers: [reviewer],
430+
});
431+
core.info(`Removed auto-assigned reviewer ${reviewer} (not selected by load balancer)`);
432+
} catch (e) {
433+
core.warning(`Could not remove reviewer ${reviewer}: ${e.message}`);
434+
}
435+
}
436+
}
437+
}
438+
439+
if (isNewPR) {
440+
// First pass: clean up whatever GitHub auto-assigned before the workflow ran.
441+
await enforceSelection(existingRequested);
442+
443+
// Request one at a time so a single invalid login cannot block the rest.
444+
// Only add to confirmedRequested on success — the checklist must not show
445+
// an owner whose request call failed.
446+
for (const reviewer of selected) {
447+
try {
448+
await github.rest.pulls.requestReviewers({
449+
owner, repo, pull_number: pr_number,
450+
reviewers: [reviewer],
451+
});
452+
confirmedRequested.add(reviewer);
453+
} catch (e) {
454+
core.warning(`Could not request reviewer ${reviewer}: ${e.message}`);
455+
}
456+
}
457+
458+
// Second pass: GitHub's auto-assignment can fire after the workflow starts,
459+
// so wait briefly then re-check and remove any extras that appeared.
460+
await new Promise(resolve => setTimeout(resolve, 15000));
461+
let retryPR;
417462
try {
418-
await github.rest.pulls.requestReviewers({
419-
owner, repo, pull_number: pr_number,
420-
reviewers: [reviewer],
421-
});
422-
confirmedRequested.add(reviewer);
463+
({ data: retryPR } = await github.rest.pulls.get({ owner, repo, pull_number: pr_number }));
423464
} catch (e) {
424-
core.warning(`Could not request reviewer ${reviewer}: ${e.message}`);
465+
core.warning(`Could not re-fetch PR for reviewer cleanup retry: ${e.message}`);
425466
}
467+
if (retryPR) {
468+
const retryRequested = new Set(
469+
retryPR.requested_reviewers.map(r => r.login).filter(Boolean)
470+
);
471+
await enforceSelection(retryRequested);
472+
}
473+
} else {
474+
// synchronize/reopened: never re-assign reviewers — respect manual removals.
475+
// Just carry forward whoever is currently assigned for checklist display.
476+
for (const reviewer of existingRequested) confirmedRequested.add(reviewer);
426477
}
478+
} else {
479+
// For workflow_run (review events): show whoever is currently assigned.
480+
for (const reviewer of existingRequested) confirmedRequested.add(reviewer);
427481
}
428482

429483
// ----------------------------------------------------------------

.github/scripts/review-checklist.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,21 @@ test('buildBody: area with no confirmed reviewer shows no @-mention', () => {
378378
assert.ok(!body.includes('@alice'));
379379
});
380380

381+
test('buildBody: mention shows approver when a non-requested owner signs off', () => {
382+
// alice was load-balanced as the reviewer; bob (also an owner) approves instead
383+
const areas = [makeArea('src', ['alice', 'bob'], 10)];
384+
const body = buildBody(areas, new Set(['bob']), new Set(['alice']));
385+
assert.ok(body.includes('- [x] **src** ✅'));
386+
assert.ok(body.includes('— @bob'));
387+
assert.ok(!body.includes('@alice'));
388+
});
389+
390+
test('buildBody: shows multiple requested reviewers when more than one is assigned', () => {
391+
const areas = [makeArea('src', ['alice', 'bob'], 10)];
392+
const body = buildBody(areas, new Set(), new Set(['alice', 'bob']));
393+
assert.ok(body.includes('— @alice, @bob'));
394+
});
395+
381396
test('buildBody: always contains the marker', () => {
382397
const areas = [makeArea('src', ['alice'], 10)];
383398
const body = buildBody(areas, new Set(), new Set());

.github/workflows/review-checklist.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ jobs:
5050
persist-credentials: false
5151
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
5252
with:
53+
retries: 3
54+
retry-exempt-status-codes: 400,403,404,422
5355
script: |
5456
const run = require('./.github/scripts/review-checklist.js');
5557
await run({ github, context, core });

.github/workflows/zizmor.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ jobs:
3333
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3434
- uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
3535
if: always()
36+
continue-on-error: true
3637
with:
3738
sarif_file: zizmor-results.sarif
3839
category: zizmor

0 commit comments

Comments
 (0)