Skip to content

Commit 34eb7b5

Browse files
committed
fix: Harden review-sync pipeline and resolve reviewer feedback
Signed-off-by: darshit2308 <darshit2308@gmail.com>
1 parent fd669c5 commit 34eb7b5

10 files changed

Lines changed: 247 additions & 89 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: 84 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,64 @@ 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+
* Returns true if any check run conclusion is 'failure' or 'timed_out'.
59+
*/
60+
async function hasCIFailures(github, owner, repo, sha) {
61+
try {
62+
const { data } = await github.rest.checks.listForRef({
63+
owner,
64+
repo,
65+
ref: sha,
66+
filter: 'latest'
67+
});
68+
return data.check_runs.some(
69+
run => run.conclusion === 'failure' || run.conclusion === 'timed_out'
70+
);
71+
} catch (error) {
72+
// Fail securely: do not assume CI is passing if we cannot verify it.
73+
// Throwing ensures this PR skips sync and the workflow registers an error.
74+
const message = error instanceof Error ? error.message : String(error);
75+
console.error(` ✗ Failed to fetch CI checks for ${sha}: ${message}`);
76+
throw error;
77+
}
78+
}
79+
5680
/**
5781
* Determine the correct queue label for a PR based on approval counts.
5882
*
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)
83+
* Phase 1 logic (5-stage pipeline):
84+
* ciFailing → queue:junior-committer (CI broken, block promotion)
85+
* maintainerApprovals >= 1 AND coreApprovals >= 2 → status: ready-to-merge (CODEOWNERS + min core reviews)
86+
* maintainerApprovals >= 1 (but coreApprovals < 2) → queue:committers (maintainer already in, need committer next)
87+
* coreApprovals >= 1 (no maintainer yet) → queue:maintainers (committer reviewed, needs maintainer)
88+
* anyApproval >= 1 → queue:committers (has soft approval, needs committer)
89+
* else → queue:junior-committer (no approvals yet)
6490
*
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
91+
* Note: When a maintainer "jumps in early" before triage/committers, the PR should
92+
* route to queue:committers (not queue:maintainers) to attract a committer review
93+
* rather than a second maintainer. This prevents wasting maintainer bandwidth.
94+
*
95+
* status: ready-to-merge requires BOTH a maintainer approval AND at least 2
96+
* total core reviews (maintainer or committer). This prevents a single maintainer approval
6797
* + a soft approval from marking a PR as ready when branch protection requires 2+ core reviews.
6898
*
69-
* @param {{ maintainerApproval: number, writeApproval: number, softApproval: number, anyApproval: number }} approvals
99+
* @param {{ maintainerApprovals: number, coreApprovals: number, softApprovals: number, anyApproval: number }} approvals
100+
* @param {boolean} ciFailing - If true, automatically demotes PR to queue:junior-committer
70101
* @returns {object} The correct QUEUE_LABELS entry
71102
*/
72-
function determineLabel(approvals) {
73-
if (approvals.maintainerApproval >= 1 && (approvals.maintainerApproval + approvals.writeApproval) >= 2) {
103+
function determineLabel(approvals, ciFailing = false) {
104+
if (ciFailing) {
105+
return QUEUE_LABELS.JUNIOR;
106+
}
107+
if (approvals.maintainerApprovals >= 1 && approvals.coreApprovals >= 2) {
74108
return QUEUE_LABELS.MERGE;
75109
}
76-
if (approvals.writeApproval >= 1 || approvals.maintainerApproval >= 1) {
110+
if (approvals.maintainerApprovals >= 1) {
111+
return QUEUE_LABELS.COMMITTERS;
112+
}
113+
if (approvals.coreApprovals >= 1) {
77114
return QUEUE_LABELS.MAINTAINERS;
78115
}
79116
if (approvals.anyApproval >= 1) {
@@ -106,44 +143,60 @@ async function syncLabel(github, owner, repo, pr, dryRun) {
106143
const prNumber = pr.number;
107144
const currentLabels = (pr.labels || []).map((l) => l.name);
108145

109-
// Count approvals and determine the correct label
146+
// Count approvals and check CI status
110147
const approvals = await countApprovals(github, owner, repo, prNumber);
111-
const correctLabel = determineLabel(approvals);
148+
const ciFailing = await hasCIFailures(github, owner, repo, pr.head.sha);
149+
const correctLabel = determineLabel(approvals, ciFailing);
112150

113151
console.log(
114-
` PR #${prNumber}: maintainerApproval=${approvals.maintainerApproval}, ` +
115-
`writeApproval=${approvals.writeApproval}, ` +
116-
`softApproval=${approvals.softApproval}, anyApproval=${approvals.anyApproval} ` +
117-
`→ ${correctLabel.name}`
152+
` PR #${prNumber}: maintainerApprovals=${approvals.maintainerApprovals}, ` +
153+
`coreApprovals=${approvals.coreApprovals}, ` +
154+
`softApprovals=${approvals.softApprovals}, anyApproval=${approvals.anyApproval}, ` +
155+
`ciFailing=${ciFailing} ${correctLabel.name}`
118156
);
119157

120158
// Determine which stale queue labels to remove
121159
const staleLabels = currentLabels.filter(
122160
(name) => ALL_QUEUE_LABEL_NAMES.includes(name) && name !== correctLabel.name
123161
);
124162

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.`);
163+
const isHuman = pr.user && pr.user.type !== 'Bot';
164+
const needsCommunityReview = isHuman && !currentLabels.includes(COMMUNITY_REVIEW.name);
165+
166+
// Check if the correct labels are already present AND there are no stale labels to remove
167+
if (currentLabels.includes(correctLabel.name) && staleLabels.length === 0 && !needsCommunityReview) {
168+
console.log(` ✓ Already has "${correctLabel.name}"${isHuman ? ` and "${COMMUNITY_REVIEW.name}"` : ''}. No change needed.`);
128169
return false;
129170
}
130171

172+
const labelsToAdd = [];
173+
if (!currentLabels.includes(correctLabel.name)) {
174+
labelsToAdd.push(correctLabel.name);
175+
}
176+
if (needsCommunityReview) {
177+
labelsToAdd.push(COMMUNITY_REVIEW.name);
178+
}
179+
131180
if (dryRun) {
132-
console.log(` [DRY RUN] Would add "${correctLabel.name}".`);
181+
if (labelsToAdd.length > 0) {
182+
console.log(` [DRY RUN] Would add: ${labelsToAdd.join(', ')}.`);
183+
}
133184
if (staleLabels.length > 0) {
134185
console.log(` [DRY RUN] Would remove: ${staleLabels.join(', ')}.`);
135186
}
136187
return true;
137188
}
138189

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}".`);
190+
// Step 1: ADD the correct labels FIRST (crash-safe: PR always has at least one label)
191+
if (labelsToAdd.length > 0) {
192+
await github.rest.issues.addLabels({
193+
owner,
194+
repo,
195+
issue_number: prNumber,
196+
labels: labelsToAdd,
197+
});
198+
console.log(` + Added: ${labelsToAdd.join(', ')}.`);
199+
}
147200

148201
// Step 2: THEN remove stale queue labels one by one
149202
for (const stale of staleLabels) {
@@ -170,4 +223,4 @@ async function syncLabel(github, owner, repo, pr, dryRun) {
170223
return true;
171224
}
172225

173-
module.exports = { ensureLabel, determineLabel, syncLabel };
226+
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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "review-sync",
3+
"version": "1.0.0",
4+
"description": "Automated PR review queue label sync scripts and tests",
5+
"main": "index.js",
6+
"scripts": {
7+
"test": "node tests/test-labels.js && node tests/test-permissions.js && node tests/test-reviews.js"
8+
}
9+
}

0 commit comments

Comments
 (0)