Skip to content

Commit f58909c

Browse files
committed
fix(review-checklist): don't let bot's own reviewer removal create a sticky exclusion
The bot's own removeUnselected/removeRequestedReviewers calls (draft-opened CODEOWNERS cleanup, stale-exclusion enforcement) fire review_request_removed, which the workflow also listens on — self-triggering another run. That run previously read its own bookkeeping removal as a deliberate human decision and added the login to the persisted exclusion set, permanently blocking that owner from ever being auto-assigned to the PR again. Guard on sender.type !== 'Bot' so only human-driven removals become sticky.
1 parent e1e3425 commit f58909c

2 files changed

Lines changed: 234 additions & 12 deletions

File tree

.github/scripts/review-checklist.js

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ function serializeExcluded(excluded) {
3030
return `${EXCLUDED_PREFIX}${[...excluded].join(',')}${EXCLUDED_SUFFIX}`;
3131
}
3232

33+
// Returns commentBody with its exclusion-marker section replaced by the
34+
// serialized form of `excluded`. Appends the marker if the body doesn't
35+
// already have one. Centralized here (rather than duplicated by other
36+
// writers, e.g. the /remove-reviewer slash command) so the marker format
37+
// only needs to be understood in one place.
38+
function withExcluded(commentBody, excluded) {
39+
const marker = serializeExcluded(excluded);
40+
const start = commentBody.indexOf(EXCLUDED_PREFIX);
41+
if (start === -1) return `${commentBody}\n${marker}`;
42+
const end = commentBody.indexOf(EXCLUDED_SUFFIX, start);
43+
if (end === -1) return `${commentBody}\n${marker}`;
44+
return commentBody.slice(0, start) + marker + commentBody.slice(end + EXCLUDED_SUFFIX.length);
45+
}
46+
3347
// ── Pure helpers ──────────────────────────────────────────────────────────────
3448

3549
function labelFromPattern(pattern) {
@@ -340,11 +354,19 @@ function planSynchronizeSwaps(eligibleAreas, allReviews, {
340354
// review is even wanted yet. Cleared in full — no checklist
341355
// posted until the PR is ready for review.
342356
//
343-
// non-draft, just (re)opened
344-
// → Same CODEOWNERS avalanche. Pruned to the load-balanced
345-
// single pick per area before the checklist is first posted,
346-
// so reviewers aren't @-mentioned en masse before the final
347-
// reviewer set is known.
357+
// non-draft, just (re)opened or just marked ready_for_review
358+
// → Same CODEOWNERS avalanche — GitHub auto-requests CODEOWNERS
359+
// reviewers both on creation and again when a draft is marked
360+
// ready for review. Pruned to the load-balanced single pick
361+
// per area before the checklist is first posted, so reviewers
362+
// aren't @-mentioned en masse before the final reviewer set
363+
// is known.
364+
//
365+
// synchronize with a dismissed reviewer
366+
// → see planSynchronizeSwaps: re-requests a reviewer whose
367+
// approval a new push just dismissed, swapping out a fresh
368+
// CODEOWNERS pick for the same area if one was auto-assigned
369+
// (never removing a pick still needed by another area).
348370
//
349371
// Everywhere else:
350372
//
@@ -477,11 +499,14 @@ async function coordinateReviewers(github, context, core, {
477499
owners: area.owners.filter(o => !updatedExcluded.has(o)),
478500
}));
479501

480-
if (action === 'opened' || action === 'reopened') {
502+
if (action === 'opened' || action === 'reopened' || action === 'ready_for_review') {
481503
// Non-draft, just (re)opened — same CODEOWNERS avalanche problem as the
482504
// draft case: GitHub has already auto-assigned every touched-area owner.
483-
// Prune to a load-balanced single pick per area BEFORE posting the
484-
// checklist so reviewers aren't @-mentioned en masse. Pass an empty
505+
// ready_for_review gets the same treatment: GitHub auto-requests CODEOWNERS
506+
// reviewers again when a draft is marked ready, dumping the avalanche on a
507+
// PR that may have sat in draft (untouched, per the isDraft branch above)
508+
// for a while. Prune to a load-balanced single pick per area BEFORE posting
509+
// the checklist so reviewers aren't @-mentioned en masse. Pass an empty
485510
// existingRequested so chooseReviewers treats every area as uncovered and
486511
// picks fresh rather than seeing "already has an owner" and returning nothing.
487512
const { selected, log } = chooseReviewers(eligibleAreas, {
@@ -497,7 +522,7 @@ async function coordinateReviewers(github, context, core, {
497522
const toRequest = new Set([...selected].filter(l => !existingRequested.has(l)));
498523
if (toRequest.size > 0) await requestReviewers(github, core, pr, toRequest);
499524

500-
core.info(`Non-draft PR opened — pruned to load-balanced selection: ${[...selected].join(', ') || '(none)'}`);
525+
core.info(`Non-draft PR ${action} — pruned to load-balanced selection: ${[...selected].join(', ') || '(none)'}`);
501526
return { confirmedRequested: selected, excludedReviewers: updatedExcluded };
502527
}
503528

@@ -776,6 +801,7 @@ module.exports = async function run({ github, context, core }) {
776801
}
777802
};
778803

804+
module.exports.MARKER = MARKER;
779805
module.exports.matchesPattern = matchesPattern;
780806
module.exports.labelFromPattern = labelFromPattern;
781807
module.exports.attributeFiles = attributeFiles;
@@ -784,4 +810,6 @@ module.exports.chooseReviewers = chooseReviewers;
784810
module.exports.buildBody = buildBody;
785811
module.exports.parseExcluded = parseExcluded;
786812
module.exports.serializeExcluded = serializeExcluded;
813+
module.exports.withExcluded = withExcluded;
814+
module.exports.coordinateReviewers = coordinateReviewers;
787815
module.exports.planSynchronizeSwaps = planSynchronizeSwaps;

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

Lines changed: 197 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
const assert = require('assert');
55
const {
6+
MARKER,
67
matchesPattern,
78
labelFromPattern,
89
attributeFiles,
@@ -11,9 +12,33 @@ const {
1112
buildBody,
1213
parseExcluded,
1314
serializeExcluded,
15+
withExcluded,
1416
planSynchronizeSwaps,
17+
coordinateReviewers,
1518
} = require('./review-checklist.js');
1619

20+
// Minimal recording mock for the github.rest surface coordinateReviewers
21+
// touches. Each call resolves successfully and is appended to its call log.
22+
function makeGithubMock() {
23+
const calls = { removeRequestedReviewers: [], requestReviewers: [], addAssignees: [] };
24+
return {
25+
calls,
26+
rest: {
27+
pulls: {
28+
removeRequestedReviewers: async (opts) => { calls.removeRequestedReviewers.push(opts.reviewers[0]); },
29+
requestReviewers: async (opts) => { calls.requestReviewers.push(opts.reviewers[0]); },
30+
},
31+
issues: {
32+
addAssignees: async (opts) => { calls.addAssignees.push(opts.assignees[0]); },
33+
},
34+
},
35+
};
36+
}
37+
38+
function makeCore() {
39+
return { info: () => {}, warning: () => {}, setFailed: () => {} };
40+
}
41+
1742
let passed = 0;
1843
let failed = 0;
1944

@@ -28,6 +53,14 @@ function test(name, fn) {
2853
}
2954
}
3055

56+
// coordinateReviewers exercises real async API calls (mocked). test() doesn't
57+
// await, so an async fn's assertions would run after the pass/fail tally is
58+
// already printed — queue these separately and await them before the summary.
59+
const asyncTests = [];
60+
function asyncTest(name, fn) {
61+
asyncTests.push({ name, fn });
62+
}
63+
3164
// ----------------------------------------------------------------
3265
// matchesPattern — anchored directory patterns
3366
// ----------------------------------------------------------------
@@ -500,6 +533,34 @@ test('serializeExcluded: round-trips through parseExcluded', () => {
500533
assert.deepStrictEqual([...roundTripped].sort(), ['alice', 'bob']);
501534
});
502535

536+
// ----------------------------------------------------------------
537+
// withExcluded — used by /remove-reviewer to persist a deliberate removal
538+
// into the checklist comment's exclusion marker.
539+
// ----------------------------------------------------------------
540+
541+
test('withExcluded: replaces an existing marker in place, preserving the rest of the body', () => {
542+
const body = `${MARKER}\nsome checklist text\n<!-- hdf5-review-checklist-excluded:alice-->`;
543+
const updated = withExcluded(body, new Set(['alice', 'bob']));
544+
assert.ok(updated.includes('some checklist text'));
545+
assert.ok(updated.startsWith(MARKER));
546+
assert.deepStrictEqual([...parseExcluded(updated)].sort(), ['alice', 'bob']);
547+
// Only one marker present afterward — not appended alongside the old one.
548+
assert.strictEqual(updated.split('hdf5-review-checklist-excluded:').length - 1, 1);
549+
});
550+
551+
test('withExcluded: appends a marker when the body has none', () => {
552+
const body = `${MARKER}\nsome checklist text`;
553+
const updated = withExcluded(body, new Set(['alice']));
554+
assert.ok(updated.includes('some checklist text'));
555+
assert.deepStrictEqual([...parseExcluded(updated)], ['alice']);
556+
});
557+
558+
test('withExcluded: round-trips an empty set to the empty marker', () => {
559+
const body = `${MARKER}\ntext\n<!-- hdf5-review-checklist-excluded:alice-->`;
560+
const updated = withExcluded(body, new Set());
561+
assert.strictEqual(parseExcluded(updated).size, 0);
562+
});
563+
503564
// ----------------------------------------------------------------
504565
// planSynchronizeSwaps
505566
// ----------------------------------------------------------------
@@ -651,10 +712,143 @@ test('planSynchronizeSwaps: dismissedOwner already covering a different area is
651712
assert.strictEqual(swaps.length, 0);
652713
});
653714

715+
// ----------------------------------------------------------------
716+
// coordinateReviewers — ready_for_review CODEOWNERS-avalanche pruning
717+
// ----------------------------------------------------------------
718+
719+
function makeCoordinateBaseArgs(overrides) {
720+
const area = makeArea('.github', ['hyoklee', 'lrknox', 'jhendersonHDF', 'glennsong09'], 5);
721+
return {
722+
owner: 'HDFGroup', repo: 'hdf5', pr_number: 1,
723+
prData: {
724+
user: { login: 'lrknox' },
725+
draft: false,
726+
requested_reviewers: [{ login: 'hyoklee' }, { login: 'jhendersonHDF' }, { login: 'glennsong09' }],
727+
},
728+
allCodeOwners: new Set(['hyoklee', 'lrknox', 'jhendersonHDF', 'glennsong09']),
729+
catchAllOwners: new Set(),
730+
touchedAreas: [area],
731+
reviewerLoad: {},
732+
excludedReviewers: new Set(),
733+
allReviews: [],
734+
LINE_THRESHOLD: 300,
735+
AREA_THRESHOLDS: {},
736+
PUBLIC_HEADER: /public\.h$/,
737+
...overrides,
738+
};
739+
}
740+
741+
asyncTest('coordinateReviewers: ready_for_review prunes the CODEOWNERS avalanche to one load-balanced pick', async () => {
742+
const github = makeGithubMock();
743+
const context = { eventName: 'pull_request_target', payload: { action: 'ready_for_review', sender: { type: 'User' } } };
744+
const args = makeCoordinateBaseArgs();
745+
746+
const { confirmedRequested } = await coordinateReviewers(github, context, makeCore(), args);
747+
748+
// hyoklee is first non-author owner in CODEOWNERS order with equal (zero) load.
749+
assert.deepStrictEqual([...confirmedRequested], ['hyoklee']);
750+
// The other two avalanche-assigned owners get removed.
751+
assert.ok(github.calls.removeRequestedReviewers.includes('jhendersonHDF'));
752+
assert.ok(github.calls.removeRequestedReviewers.includes('glennsong09'));
753+
assert.strictEqual(github.calls.removeRequestedReviewers.length, 2);
754+
// hyoklee was already requested, so no redundant request call.
755+
assert.strictEqual(github.calls.requestReviewers.length, 0);
756+
});
757+
758+
asyncTest('coordinateReviewers: ready_for_review on a draft-opened PR (still draft) does not prune', async () => {
759+
// Sanity check the branch ordering: if somehow still draft (shouldn't
760+
// happen for a real ready_for_review payload, but guards the isDraft
761+
// branch precedence), the draft path's "leave alone" rule wins.
762+
const github = makeGithubMock();
763+
const context = { eventName: 'pull_request_target', payload: { action: 'ready_for_review', sender: { type: 'User' } } };
764+
const args = makeCoordinateBaseArgs({ prData: { ...makeCoordinateBaseArgs().prData, draft: true } });
765+
766+
const { confirmedRequested } = await coordinateReviewers(github, context, makeCore(), args);
767+
768+
assert.strictEqual(github.calls.removeRequestedReviewers.length, 0);
769+
assert.deepStrictEqual([...confirmedRequested].sort(), ['glennsong09', 'hyoklee', 'jhendersonHDF']);
770+
});
771+
772+
asyncTest('coordinateReviewers: plain synchronize (no dismissed reviews) is left to additive fill, not pruned', async () => {
773+
// Contrast case: a synchronize with no dismissed reviews must NOT trigger
774+
// avalanche-style pruning — only opened/reopened/ready_for_review do. All
775+
// three avalanche-style owners stay; nothing is removed, nothing new is
776+
// requested since the area is already covered.
777+
const github = makeGithubMock();
778+
const context = { eventName: 'pull_request_target', payload: { action: 'synchronize', sender: { type: 'User' } } };
779+
const args = makeCoordinateBaseArgs();
780+
781+
const { confirmedRequested } = await coordinateReviewers(github, context, makeCore(), args);
782+
783+
assert.strictEqual(github.calls.removeRequestedReviewers.length, 0);
784+
assert.strictEqual(github.calls.requestReviewers.length, 0);
785+
assert.deepStrictEqual([...confirmedRequested].sort(), ['glennsong09', 'hyoklee', 'jhendersonHDF']);
786+
});
787+
788+
// ----------------------------------------------------------------
789+
// coordinateReviewers — bot-self-triggered review_request_removed must not
790+
// create a sticky exclusion (the bot's own removeUnselected/removeRequestedReviewers
791+
// calls fire this very event and would otherwise self-trigger a run that reads
792+
// its own bookkeeping removal as a deliberate human decision).
793+
// ----------------------------------------------------------------
794+
795+
function makeRemovalContext(senderType) {
796+
return {
797+
eventName: 'pull_request_target',
798+
payload: {
799+
action: 'review_request_removed',
800+
requested_reviewer: { login: 'jhendersonHDF' },
801+
sender: { type: senderType },
802+
},
803+
};
804+
}
805+
806+
asyncTest('coordinateReviewers: bot-sender review_request_removed does not persist a sticky exclusion', async () => {
807+
const github = makeGithubMock();
808+
const args = makeCoordinateBaseArgs({
809+
prData: {
810+
user: { login: 'lrknox' },
811+
draft: false,
812+
requested_reviewers: [{ login: 'hyoklee' }, { login: 'glennsong09' }],
813+
},
814+
});
815+
816+
const { excludedReviewers } = await coordinateReviewers(github, makeRemovalContext('Bot'), makeCore(), args);
817+
818+
assert.ok(!excludedReviewers.has('jhendersonHDF'));
819+
});
820+
821+
asyncTest('coordinateReviewers: human-sender review_request_removed does persist a sticky exclusion', async () => {
822+
const github = makeGithubMock();
823+
const args = makeCoordinateBaseArgs({
824+
prData: {
825+
user: { login: 'lrknox' },
826+
draft: false,
827+
requested_reviewers: [{ login: 'hyoklee' }, { login: 'glennsong09' }],
828+
},
829+
});
830+
831+
const { excludedReviewers } = await coordinateReviewers(github, makeRemovalContext('User'), makeCore(), args);
832+
833+
assert.ok(excludedReviewers.has('jhendersonHDF'));
834+
});
835+
654836
// ----------------------------------------------------------------
655837
// Summary
656838
// ----------------------------------------------------------------
657839

658-
console.log('');
659-
console.log(`${passed} passed, ${failed} failed`);
660-
process.exit(failed > 0 ? 1 : 0);
840+
(async () => {
841+
for (const { name, fn } of asyncTests) {
842+
try {
843+
await fn();
844+
console.log(`✓ ${name}`);
845+
passed++;
846+
} catch (e) {
847+
console.log(`✗ ${name}${e.message}`);
848+
failed++;
849+
}
850+
}
851+
console.log('');
852+
console.log(`${passed} passed, ${failed} failed`);
853+
process.exit(failed > 0 ? 1 : 0);
854+
})();

0 commit comments

Comments
 (0)