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' ) ;
1414const { 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 } ;
0 commit comments