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,58 @@ 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 *
5983 * 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)
84+ * maintainerApprovals >= 1 AND coreApprovals >= 2 → status: ready-to-merge (CODEOWNERS + min core reviews)
85+ * coreApprovals >= 1 → queue:maintainers (senior review present, needs more)
86+ * anyApproval >= 1 → queue:committers (has any approval, needs committer)
87+ * else → queue:junior-committer (no approvals yet)
6488 *
6589 * 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
90+ * total core reviews (maintainer or committer ). This prevents a single maintainer approval
6791 * + a soft approval from marking a PR as ready when branch protection requires 2+ core reviews.
6892 *
69- * @param {{ maintainerApproval: number, writeApproval: number, softApproval: number, anyApproval: number } } approvals
93+ * @param {{ maintainerApprovals: number, coreApprovals: number, softApprovals: number, anyApproval: number } } approvals
94+ * @param {boolean } ciFailing - If true, automatically demotes PR to queue:junior-committer
7095 * @returns {object } The correct QUEUE_LABELS entry
7196 */
72- function determineLabel ( approvals ) {
73- if ( approvals . maintainerApproval >= 1 && ( approvals . maintainerApproval + approvals . writeApproval ) >= 2 ) {
97+ function determineLabel ( approvals , ciFailing = false ) {
98+ if ( ciFailing ) {
99+ return QUEUE_LABELS . JUNIOR ;
100+ }
101+ if ( approvals . maintainerApprovals >= 1 && approvals . coreApprovals >= 2 ) {
74102 return QUEUE_LABELS . MERGE ;
75103 }
76- if ( approvals . writeApproval >= 1 || approvals . maintainerApproval >= 1 ) {
104+ if ( approvals . maintainerApprovals >= 1 ) {
105+ return QUEUE_LABELS . COMMITTERS ;
106+ }
107+ if ( approvals . coreApprovals >= 1 ) {
77108 return QUEUE_LABELS . MAINTAINERS ;
78109 }
79110 if ( approvals . anyApproval >= 1 ) {
@@ -106,44 +137,60 @@ async function syncLabel(github, owner, repo, pr, dryRun) {
106137 const prNumber = pr . number ;
107138 const currentLabels = ( pr . labels || [ ] ) . map ( ( l ) => l . name ) ;
108139
109- // Count approvals and determine the correct label
140+ // Count approvals and check CI status
110141 const approvals = await countApprovals ( github , owner , repo , prNumber ) ;
111- const correctLabel = determineLabel ( approvals ) ;
142+ const ciFailing = await hasCIFailures ( github , owner , repo , pr . head . sha ) ;
143+ const correctLabel = determineLabel ( approvals , ciFailing ) ;
112144
113145 console . log (
114- ` PR #${ prNumber } : maintainerApproval =${ approvals . maintainerApproval } , ` +
115- `writeApproval =${ approvals . writeApproval } , ` +
116- `softApproval =${ approvals . softApproval } , anyApproval=${ approvals . anyApproval } ` +
117- `→ ${ correctLabel . name } `
146+ ` PR #${ prNumber } : maintainerApprovals =${ approvals . maintainerApprovals } , ` +
147+ `coreApprovals =${ approvals . coreApprovals } , ` +
148+ `softApprovals =${ approvals . softApprovals } , anyApproval=${ approvals . anyApproval } , ` +
149+ `ciFailing= ${ ciFailing } → ${ correctLabel . name } `
118150 ) ;
119151
120152 // Determine which stale queue labels to remove
121153 const staleLabels = currentLabels . filter (
122154 ( name ) => ALL_QUEUE_LABEL_NAMES . includes ( name ) && name !== correctLabel . name
123155 ) ;
124156
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.` ) ;
157+ const isHuman = pr . user && pr . user . type !== 'Bot' ;
158+ const needsCommunityReview = isHuman && ! currentLabels . includes ( COMMUNITY_REVIEW . name ) ;
159+
160+ // Check if the correct labels are already present AND there are no stale labels to remove
161+ if ( currentLabels . includes ( correctLabel . name ) && staleLabels . length === 0 && ! needsCommunityReview ) {
162+ console . log ( ` ✓ Already has "${ correctLabel . name } "${ isHuman ? ` and "${ COMMUNITY_REVIEW . name } "` : '' } . No change needed.` ) ;
128163 return false ;
129164 }
130165
166+ const labelsToAdd = [ ] ;
167+ if ( ! currentLabels . includes ( correctLabel . name ) ) {
168+ labelsToAdd . push ( correctLabel . name ) ;
169+ }
170+ if ( needsCommunityReview ) {
171+ labelsToAdd . push ( COMMUNITY_REVIEW . name ) ;
172+ }
173+
131174 if ( dryRun ) {
132- console . log ( ` [DRY RUN] Would add "${ correctLabel . name } ".` ) ;
175+ if ( labelsToAdd . length > 0 ) {
176+ console . log ( ` [DRY RUN] Would add: ${ labelsToAdd . join ( ', ' ) } .` ) ;
177+ }
133178 if ( staleLabels . length > 0 ) {
134179 console . log ( ` [DRY RUN] Would remove: ${ staleLabels . join ( ', ' ) } .` ) ;
135180 }
136181 return true ;
137182 }
138183
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 } ".` ) ;
184+ // Step 1: ADD the correct labels FIRST (crash-safe: PR always has at least one label)
185+ if ( labelsToAdd . length > 0 ) {
186+ await github . rest . issues . addLabels ( {
187+ owner,
188+ repo,
189+ issue_number : prNumber ,
190+ labels : labelsToAdd ,
191+ } ) ;
192+ console . log ( ` + Added: ${ labelsToAdd . join ( ', ' ) } .` ) ;
193+ }
147194
148195 // Step 2: THEN remove stale queue labels one by one
149196 for ( const stale of staleLabels ) {
@@ -170,4 +217,4 @@ async function syncLabel(github, owner, repo, pr, dryRun) {
170217 return true ;
171218}
172219
173- module . exports = { ensureLabel, determineLabel, syncLabel } ;
220+ module . exports = { ensureLabel, determineLabel, syncLabel, hasCIFailures } ;
0 commit comments