Skip to content

Commit e19f3cc

Browse files
authored
fix: Harden review-sync pipeline and resolve reviewer feedback (#2262)
Signed-off-by: darshit2308 <darshit2308@gmail.com>
1 parent fd669c5 commit e19f3cc

10 files changed

Lines changed: 270 additions & 90 deletions

File tree

.github/scripts/review-sync/helpers/constants.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,20 @@ const QUEUE_LABELS = {
3535
MERGE: {
3636
name: 'status: ready-to-merge',
3737
color: '0e8a16',
38-
description: 'PR has 1+ maintainer and 2+ total approvals, ready to merge',
38+
description: 'PR has 1+ maintainer and 2+ core (committer/maintainer) approvals, ready to merge',
3939
},
4040
};
4141

4242
/** All queue label names, used for cleanup operations. */
4343
const ALL_QUEUE_LABEL_NAMES = Object.values(QUEUE_LABELS).map((l) => l.name);
4444

45-
module.exports = { RATE_LIMIT_FLOOR, QUEUE_LABELS, ALL_QUEUE_LABEL_NAMES };
45+
/**
46+
* The permanent community review label appended to all open PRs.
47+
*/
48+
const COMMUNITY_REVIEW = {
49+
name: 'open to community review',
50+
color: '008672',
51+
description: 'PR is open for community review and feedback',
52+
};
53+
54+
module.exports = { RATE_LIMIT_FLOOR, QUEUE_LABELS, ALL_QUEUE_LABEL_NAMES, COMMUNITY_REVIEW };

.github/scripts/review-sync/helpers/labels.js

Lines changed: 102 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// - syncLabel() adds the correct label FIRST, then removes stale ones
1111
// (crash-safe: PR never has zero queue labels)
1212

13-
const { QUEUE_LABELS, ALL_QUEUE_LABEL_NAMES } = require('./constants');
13+
const { QUEUE_LABELS, ALL_QUEUE_LABEL_NAMES, COMMUNITY_REVIEW } = require('./constants');
1414
const { countApprovals } = require('./permissions');
1515

1616
/**
@@ -53,27 +53,82 @@ async function ensureLabel(github, owner, repo, label, dryRun) {
5353
}
5454
}
5555

56+
/**
57+
* Check if the latest CI runs for a given commit have any failures.
58+
*
59+
* We intentionally treat the following as failures:
60+
* - 'failure', 'timed_out' (explicit test failures)
61+
* - 'startup_failure' (e.g., invalid workflow YAML)
62+
* - 'action_required' (e.g., waiting for maintainer approval for first-time contributors)
63+
*
64+
* We intentionally EXCLUDE 'cancelled':
65+
* When a developer pushes a new commit, GitHub automatically cancels the currently
66+
* running workflows. If we treated 'cancelled' as a failure, every re-push would
67+
* instantly demote the PR to queue:junior-committer, frustrating contributors.
68+
*
69+
* @returns {boolean} true if any check run conclusion is a blocking failure.
70+
*/
71+
async function hasCIFailures(github, owner, repo, sha) {
72+
try {
73+
// We MUST use paginate, otherwise it silently truncates at 30 runs.
74+
// Matrix builds often exceed 30 checks.
75+
const checkRuns = await github.paginate(github.rest.checks.listForRef, {
76+
owner,
77+
repo,
78+
ref: sha,
79+
filter: 'latest'
80+
});
81+
82+
return checkRuns.some(
83+
run =>
84+
run.conclusion === 'failure' ||
85+
run.conclusion === 'timed_out' ||
86+
run.conclusion === 'startup_failure' ||
87+
run.conclusion === 'action_required'
88+
);
89+
} catch (error) {
90+
// Fail securely: do not assume CI is passing if we cannot verify it.
91+
// Throwing ensures this PR skips sync and the workflow registers an error.
92+
const message = error instanceof Error ? error.message : String(error);
93+
console.error(` ✗ Failed to fetch CI checks for ${sha}: ${message}`);
94+
throw error;
95+
}
96+
}
97+
5698
/**
5799
* Determine the correct queue label for a PR based on approval counts.
58100
*
59-
* Phase 1 logic (4-stage pipeline):
60-
* maintainerApproval >= 1 AND (maintainerApproval + writeApproval) >= 2 → status: ready-to-merge (CODEOWNERS + min core reviews)
61-
* writeApproval >= 1 OR maintainerApproval >= 1 → queue:maintainers (senior review present, needs more)
62-
* anyApproval >= 1 → queue:committers (has any approval, needs committer)
63-
* else → queue:junior-committer (no approvals yet)
101+
* Phase 1 logic (5-stage pipeline):
102+
* ciFailing → queue:junior-committer (CI broken, block promotion)
103+
* maintainerApprovals >= 1 AND coreApprovals >= 2 → status: ready-to-merge (CODEOWNERS + min core reviews)
104+
* maintainerApprovals >= 1 (but coreApprovals < 2) → queue:committers (maintainer already in, need committer next)
105+
* coreApprovals >= 1 (no maintainer yet) → queue:maintainers (committer reviewed, needs maintainer)
106+
* anyApproval >= 1 → queue:committers (has soft approval, needs committer)
107+
* else → queue:junior-committer (no approvals yet)
64108
*
65-
* Note: status: ready-to-merge requires BOTH a maintainer approval AND at least 2
66-
* total core reviews (maintainer or write). This prevents a single maintainer approval
109+
* Note: When a maintainer "jumps in early" before triage/committers, the PR should
110+
* route to queue:committers (not queue:maintainers) to attract a committer review
111+
* rather than a second maintainer. This prevents wasting maintainer bandwidth.
112+
*
113+
* status: ready-to-merge requires BOTH a maintainer approval AND at least 2
114+
* total core reviews (maintainer or committer). This prevents a single maintainer approval
67115
* + a soft approval from marking a PR as ready when branch protection requires 2+ core reviews.
68116
*
69-
* @param {{ maintainerApproval: number, writeApproval: number, softApproval: number, anyApproval: number }} approvals
117+
* @param {{ maintainerApprovals: number, coreApprovals: number, softApprovals: number, anyApproval: number }} approvals
118+
* @param {boolean} ciFailing - If true, automatically demotes PR to queue:junior-committer
70119
* @returns {object} The correct QUEUE_LABELS entry
71120
*/
72-
function determineLabel(approvals) {
73-
if (approvals.maintainerApproval >= 1 && (approvals.maintainerApproval + approvals.writeApproval) >= 2) {
121+
function determineLabel(approvals, ciFailing = false) {
122+
if (ciFailing) {
123+
return QUEUE_LABELS.JUNIOR;
124+
}
125+
if (approvals.maintainerApprovals >= 1 && approvals.coreApprovals >= 2) {
74126
return QUEUE_LABELS.MERGE;
75127
}
76-
if (approvals.writeApproval >= 1 || approvals.maintainerApproval >= 1) {
128+
if (approvals.maintainerApprovals >= 1) {
129+
return QUEUE_LABELS.COMMITTERS;
130+
}
131+
if (approvals.coreApprovals >= 1) {
77132
return QUEUE_LABELS.MAINTAINERS;
78133
}
79134
if (approvals.anyApproval >= 1) {
@@ -106,44 +161,60 @@ async function syncLabel(github, owner, repo, pr, dryRun) {
106161
const prNumber = pr.number;
107162
const currentLabels = (pr.labels || []).map((l) => l.name);
108163

109-
// Count approvals and determine the correct label
164+
// Count approvals and check CI status
110165
const approvals = await countApprovals(github, owner, repo, prNumber);
111-
const correctLabel = determineLabel(approvals);
166+
const ciFailing = await hasCIFailures(github, owner, repo, pr.head.sha);
167+
const correctLabel = determineLabel(approvals, ciFailing);
112168

113169
console.log(
114-
` PR #${prNumber}: maintainerApproval=${approvals.maintainerApproval}, ` +
115-
`writeApproval=${approvals.writeApproval}, ` +
116-
`softApproval=${approvals.softApproval}, anyApproval=${approvals.anyApproval} ` +
117-
`→ ${correctLabel.name}`
170+
` PR #${prNumber}: maintainerApprovals=${approvals.maintainerApprovals}, ` +
171+
`coreApprovals=${approvals.coreApprovals}, ` +
172+
`softApprovals=${approvals.softApprovals}, anyApproval=${approvals.anyApproval}, ` +
173+
`ciFailing=${ciFailing} ${correctLabel.name}`
118174
);
119175

120176
// Determine which stale queue labels to remove
121177
const staleLabels = currentLabels.filter(
122178
(name) => ALL_QUEUE_LABEL_NAMES.includes(name) && name !== correctLabel.name
123179
);
124180

125-
// Check if the correct label is already present AND there are no stale labels to remove
126-
if (currentLabels.includes(correctLabel.name) && staleLabels.length === 0) {
127-
console.log(` ✓ Already has "${correctLabel.name}". No change needed.`);
181+
const isHuman = pr.user && pr.user.type !== 'Bot';
182+
const needsCommunityReview = isHuman && !currentLabels.includes(COMMUNITY_REVIEW.name);
183+
184+
// Check if the correct labels are already present AND there are no stale labels to remove
185+
if (currentLabels.includes(correctLabel.name) && staleLabels.length === 0 && !needsCommunityReview) {
186+
console.log(` ✓ Already has "${correctLabel.name}"${isHuman ? ` and "${COMMUNITY_REVIEW.name}"` : ''}. No change needed.`);
128187
return false;
129188
}
130189

190+
const labelsToAdd = [];
191+
if (!currentLabels.includes(correctLabel.name)) {
192+
labelsToAdd.push(correctLabel.name);
193+
}
194+
if (needsCommunityReview) {
195+
labelsToAdd.push(COMMUNITY_REVIEW.name);
196+
}
197+
131198
if (dryRun) {
132-
console.log(` [DRY RUN] Would add "${correctLabel.name}".`);
199+
if (labelsToAdd.length > 0) {
200+
console.log(` [DRY RUN] Would add: ${labelsToAdd.join(', ')}.`);
201+
}
133202
if (staleLabels.length > 0) {
134203
console.log(` [DRY RUN] Would remove: ${staleLabels.join(', ')}.`);
135204
}
136205
return true;
137206
}
138207

139-
// Step 1: ADD the correct label FIRST (crash-safe: PR always has at least one label)
140-
await github.rest.issues.addLabels({
141-
owner,
142-
repo,
143-
issue_number: prNumber,
144-
labels: [correctLabel.name],
145-
});
146-
console.log(` + Added "${correctLabel.name}".`);
208+
// Step 1: ADD the correct labels FIRST (crash-safe: PR always has at least one label)
209+
if (labelsToAdd.length > 0) {
210+
await github.rest.issues.addLabels({
211+
owner,
212+
repo,
213+
issue_number: prNumber,
214+
labels: labelsToAdd,
215+
});
216+
console.log(` + Added: ${labelsToAdd.join(', ')}.`);
217+
}
147218

148219
// Step 2: THEN remove stale queue labels one by one
149220
for (const stale of staleLabels) {
@@ -170,4 +241,4 @@ async function syncLabel(github, owner, repo, pr, dryRun) {
170241
return true;
171242
}
172243

173-
module.exports = { ensureLabel, determineLabel, syncLabel };
244+
module.exports = { ensureLabel, determineLabel, syncLabel, hasCIFailures };

.github/scripts/review-sync/helpers/permissions.js

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,21 @@
1111
//
1212
// We MUST use role_name to distinguish maintainers from committers.
1313
// If we used permission, Sophie (Maintain role per MAINTAINERS.md) would
14-
// appear as 'write', and maintainerApproval would always be 0.
14+
// appear as 'write', and maintainerApprovals would always be 0.
1515
// PRs would be permanently stuck at queue:maintainers.
1616
//
1717
// Phase 3 will replace this with team membership checks
1818
// (getMembershipForUserInOrg) for full accuracy.
1919

2020
const { getLatestReviewStates } = require('./reviews');
2121

22+
const permissionCache = new Map();
23+
2224
/**
2325
* Check the repository role for a given user.
2426
*
2527
* Uses role_name (not legacy permission) to correctly detect the maintain role.
28+
* Results are cached in memory to avoid redundant API calls during the cron run.
2629
*
2730
* @param {object} github - Octokit instance
2831
* @param {string} owner - Repository owner
@@ -31,6 +34,12 @@ const { getLatestReviewStates } = require('./reviews');
3134
* @returns {string} 'admin' | 'maintain' | 'write' | 'triage' | 'read' | 'none'
3235
*/
3336
async function getPermissionLevel(github, owner, repo, username) {
37+
const cacheKey = `${owner}/${repo}/${username}`;
38+
39+
if (permissionCache.has(cacheKey)) {
40+
return permissionCache.get(cacheKey);
41+
}
42+
3443
try {
3544
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
3645
owner,
@@ -40,10 +49,13 @@ async function getPermissionLevel(github, owner, repo, username) {
4049
// CRITICAL: Use role_name, NOT permission.
4150
// The legacy 'permission' field maps maintain → write, triage → read.
4251
// role_name correctly returns: admin | maintain | write | triage | read
43-
return data.role_name || data.permission || 'none';
52+
const role = data.role_name || data.permission || 'none';
53+
permissionCache.set(cacheKey, role);
54+
return role;
4455
} catch (error) {
4556
if (error.status === 404) {
4657
// External contributor — not a collaborator
58+
permissionCache.set(cacheKey, 'none');
4759
return 'none';
4860
}
4961
// Log unexpected errors but don't crash the run
@@ -57,44 +69,49 @@ async function getPermissionLevel(github, owner, repo, username) {
5769
* Count approvals on a PR, split by permission level.
5870
*
5971
* Returns three counters:
60-
* - maintainerApproval: admin or maintain (maps to CODEOWNERS maintainer teams)
61-
* - writeApproval: write (committers)
62-
* - softApproval: triage, read, none, external contributors
72+
* - maintainerApprovals: admin or maintain (maps to CODEOWNERS maintainer teams)
73+
* - coreApprovals: write (committers) and maintainers
74+
* - softApprovals: triage, read, none, external contributors
6375
*
6476
* @param {object} github - Octokit instance
6577
* @param {string} owner - Repository owner
6678
* @param {string} repo - Repository name
6779
* @param {number} prNumber - Pull request number
68-
* @returns {{ maintainerApproval: number, writeApproval: number, softApproval: number, anyApproval: number }}
80+
* @param {{ maintainerApprovals: number, coreApprovals: number, softApprovals: number, anyApproval: number }}
6981
*/
7082
async function countApprovals(github, owner, repo, prNumber) {
7183
const latestStates = await getLatestReviewStates(github, owner, repo, prNumber);
7284

73-
let maintainerApproval = 0;
74-
let writeApproval = 0;
75-
let softApproval = 0;
85+
let maintainerApprovals = 0; // only maintainers
86+
let coreApprovals = 0; // anyone with write permission
87+
let softApprovals = 0;
7688

7789
for (const [username, state] of latestStates) {
7890
if (state !== 'APPROVED') continue;
7991

8092
const role = await getPermissionLevel(github, owner, repo, username);
8193

8294
if (role === 'admin' || role === 'maintain') {
83-
maintainerApproval++;
95+
maintainerApprovals++;
96+
coreApprovals++; // Maintainers also count as core
8497
} else if (role === 'write') {
85-
writeApproval++;
98+
coreApprovals++; // Committers count as core
8699
} else {
87100
// triage, read, none, or any unexpected value → soft approval
88-
softApproval++;
101+
softApprovals++;
89102
}
90103
}
91104

92105
return {
93-
maintainerApproval,
94-
writeApproval,
95-
softApproval,
96-
anyApproval: maintainerApproval + writeApproval + softApproval,
106+
maintainerApprovals,
107+
coreApprovals,
108+
softApprovals,
109+
anyApproval: coreApprovals + softApprovals,
97110
};
98111
}
99112

100-
module.exports = { getPermissionLevel, countApprovals };
113+
function clearPermissionCache() {
114+
permissionCache.clear();
115+
}
116+
117+
module.exports = { getPermissionLevel, countApprovals, clearPermissionCache };

.github/scripts/review-sync/index.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
// Phase 1 of 4 — label sync only.
1515

1616
const helpers = require('./helpers');
17-
const { QUEUE_LABELS, RATE_LIMIT_FLOOR } = helpers.constants;
17+
const { QUEUE_LABELS, RATE_LIMIT_FLOOR, COMMUNITY_REVIEW } = helpers.constants;
1818
const { ensureLabel, syncLabel } = helpers.labels;
1919

2020
module.exports = async ({ github, context, core }) => {
@@ -55,11 +55,12 @@ module.exports = async ({ github, context, core }) => {
5555
return;
5656
}
5757

58-
// ── 3. Ensure queue labels exist ─────────────────────────────────────────
59-
console.log('\n--- Ensuring Queue Labels Exist ---');
58+
// ── 3. Ensure labels exist ───────────────────────────────────────────────
59+
console.log('\n--- Ensuring Labels Exist ---');
6060
for (const label of Object.values(QUEUE_LABELS)) {
6161
await ensureLabel(github, owner, repo, label, dryRun);
6262
}
63+
await ensureLabel(github, owner, repo, COMMUNITY_REVIEW, dryRun);
6364

6465
// ── 4. Sync label on each PR ─────────────────────────────────────────────
6566
console.log('\n--- Syncing Labels ---');
@@ -90,6 +91,6 @@ module.exports = async ({ github, context, core }) => {
9091
console.log(` Errors: ${errors}`);
9192

9293
if (errors > 0) {
93-
process.exitCode = 1;
94+
core.setFailed(`Review sync completed with ${errors} error(s). Check logs above.`);
9495
}
9596
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"name": "review-sync",
3+
"version": "1.0.0",
4+
"description": "Automated PR review queue label sync scripts and tests",
5+
"private": true,
6+
"main": "index.js",
7+
"scripts": {
8+
"test": "node tests/test-labels.js && node tests/test-permissions.js && node tests/test-reviews.js"
9+
}
10+
}

0 commit comments

Comments
 (0)