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,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 } ;
0 commit comments