55 */
66
77module . exports = async ( { github, context, core } ) => {
8- const rawLabels = process . env . LABELS_OUTPUT ;
9- core . info ( `Raw labels JSON: ${ rawLabels } ` ) ;
10- let parsedLabels ;
11- try {
12- // First, try to parse the raw output as JSON.
13- parsedLabels = JSON . parse ( rawLabels ) ;
14- } catch ( jsonError ) {
15- // If that fails, check for a markdown code block.
16- core . warning (
17- `Direct JSON parsing failed: ${ jsonError . message } . Trying to extract from a markdown block.` ,
18- ) ;
19- const jsonMatch = rawLabels . match ( / ` ` ` j s o n \s * ( [ \s \S ] * ?) \s * ` ` ` / ) ;
20- if ( jsonMatch && jsonMatch [ 1 ] ) {
21- try {
22- parsedLabels = JSON . parse ( jsonMatch [ 1 ] . trim ( ) ) ;
23- } catch ( markdownError ) {
24- core . setFailed (
25- `Failed to parse JSON even after extracting from markdown block: ${ markdownError . message } \nRaw output: ${ rawLabels } ` ,
26- ) ;
27- return ;
8+ const extractJson = ( raw ) => {
9+ if ( ! raw || raw === '[]' || raw === '' ) return [ ] ;
10+ try {
11+ // First, try to parse the raw output as JSON.
12+ return JSON . parse ( raw ) ;
13+ } catch {
14+ // If that fails, check for a markdown code block.
15+ core . info (
16+ 'Direct JSON parsing failed. Trying to extract from a markdown block.' ,
17+ ) ;
18+ const jsonMatch = raw . match ( / ` ` ` j s o n \s * ( [ \s \S ] * ?) \s * ` ` ` / ) ;
19+ if ( jsonMatch && jsonMatch [ 1 ] ) {
20+ try {
21+ return JSON . parse ( jsonMatch [ 1 ] . trim ( ) ) ;
22+ } catch ( markdownError ) {
23+ core . warning (
24+ `Failed to parse extracted JSON from markdown block: ${ markdownError . message } ` ,
25+ ) ;
26+ }
2827 }
29- } else {
30- // If no markdown block, try to find a raw JSON array in the output.
31- // The CLI may include debug/log lines (e.g. telemetry init, YOLO mode)
32- // before the actual JSON response.
33- const jsonArrayMatch = rawLabels . match (
28+
29+ // Try to find a raw JSON array in the output.
30+ const jsonArrayMatch = raw . match (
3431 / \[ \s * \{ \s * " i s s u e _ n u m b e r " [ \s \S ] * \} \s * \] / ,
3532 ) ;
3633 if ( jsonArrayMatch ) {
3734 try {
38- parsedLabels = JSON . parse ( jsonArrayMatch [ 0 ] ) ;
39- } catch ( extractError ) {
40- // It's possible the regex matched from a `[STARTUP]` log all the way to the end
41- // of the JSON array. We need to be more aggressive and find the FIRST `[ { "issue_number"`
42- core . warning (
43- `Strict array match failed: ${ extractError . message } . Attempting to clean leading noisy brackets.` ,
44- ) ;
45- const fallbackMatch = rawLabels . match (
46- / ( \[ \s * \{ \s * " i s s u e _ n u m b e r " [ \s \S ] * ) / ,
47- ) ;
35+ return JSON . parse ( jsonArrayMatch [ 0 ] ) ;
36+ } catch {
37+ const fallbackMatch = raw . match ( / ( \[ \s * \{ \s * " i s s u e _ n u m b e r " [ \s \S ] * ) / ) ;
4838 if ( fallbackMatch ) {
4939 try {
50- // We might have grabbed trailing noise too, so we find the last closing bracket
5140 const cleaned = fallbackMatch [ 0 ] . substring (
5241 0 ,
5342 fallbackMatch [ 0 ] . lastIndexOf ( ']' ) + 1 ,
5443 ) ;
55- parsedLabels = JSON . parse ( cleaned ) ;
44+ return JSON . parse ( cleaned ) ;
5645 } catch ( fallbackError ) {
57- core . setFailed (
58- `Found JSON-like content but failed to parse : ${ fallbackError . message } \nRaw output: ${ rawLabels } ` ,
46+ core . warning (
47+ `Failed to parse extracted JSON using fallback regex : ${ fallbackError . message } ` ,
5948 ) ;
60- return ;
6149 }
62- } else {
63- core . setFailed (
64- `Found JSON-like content but failed to parse: ${ extractError . message } \nRaw output: ${ rawLabels } ` ,
65- ) ;
66- return ;
6750 }
6851 }
69- } else {
70- core . setFailed (
71- `Output is not valid JSON and does not contain extractable JSON.\nRaw output: ${ rawLabels } ` ,
72- ) ;
73- return ;
7452 }
7553 }
76- }
77- core . info ( `Parsed labels JSON: ${ JSON . stringify ( parsedLabels ) } ` ) ;
54+ core . warning ( 'No valid JSON could be extracted from input.' ) ;
55+ return [ ] ;
56+ } ;
7857
79- for ( const entry of parsedLabels ) {
80- const issueNumber = entry . issue_number ;
81- if ( ! issueNumber ) {
82- core . info (
83- `Skipping entry with no issue number: ${ JSON . stringify ( entry ) } ` ,
84- ) ;
85- continue ;
58+ // Collect all outputs from environment variables
59+ // Prioritize EFFORT results over STANDARD results by processing Effort FIRST
60+ // so that its labels appear first in the merged arrays (and thus win in mutually exclusive logic)
61+ const effortRaw = process . env . LABELS_OUTPUT_EFFORT ;
62+ const standardRaw = process . env . LABELS_OUTPUT_STANDARD ;
63+ const genericRaw = process . env . LABELS_OUTPUT ;
64+
65+ const resultsByIssue = new Map ( ) ;
66+
67+ const processResults = ( results , _sourceName ) => {
68+ for ( const entry of results ) {
69+ const issueNumber = entry . issue_number ;
70+ if ( ! issueNumber ) continue ;
71+
72+ if ( ! resultsByIssue . has ( issueNumber ) ) {
73+ resultsByIssue . set ( issueNumber , {
74+ issue_number : issueNumber ,
75+ labels_to_add : [ ...( entry . labels_to_add || [ ] ) ] ,
76+ labels_to_remove : [ ...( entry . labels_to_remove || [ ] ) ] ,
77+ explanation : entry . explanation || '' ,
78+ effort_analysis : entry . effort_analysis || '' ,
79+ } ) ;
80+ } else {
81+ const existing = resultsByIssue . get ( issueNumber ) ;
82+ // Combine labels
83+ existing . labels_to_add = [
84+ ...new Set ( [
85+ ...existing . labels_to_add ,
86+ ...( entry . labels_to_add || [ ] ) ,
87+ ] ) ,
88+ ] ;
89+ existing . labels_to_remove = [
90+ ...new Set ( [
91+ ...existing . labels_to_remove ,
92+ ...( entry . labels_to_remove || [ ] ) ,
93+ ] ) ,
94+ ] ;
95+
96+ // Combine explanations (if different)
97+ if (
98+ entry . explanation &&
99+ ! existing . explanation . includes ( entry . explanation )
100+ ) {
101+ existing . explanation = existing . explanation
102+ ? `${ existing . explanation } \n\n${ entry . explanation } `
103+ : entry . explanation ;
104+ }
105+
106+ // Take effort analysis if present
107+ if ( entry . effort_analysis && ! existing . effort_analysis ) {
108+ existing . effort_analysis = entry . effort_analysis ;
109+ }
110+ }
86111 }
112+ } ;
113+
114+ // Order matters: Effort first so its labels win in conflict resolution
115+ processResults ( extractJson ( effortRaw ) , 'EFFORT' ) ;
116+ processResults ( extractJson ( standardRaw ) , 'STANDARD' ) ;
117+ processResults ( extractJson ( genericRaw ) , 'GENERIC' ) ;
87118
119+ const finalResults = Array . from ( resultsByIssue . values ( ) ) ;
120+ core . info ( `Aggregated triage results for ${ finalResults . length } issues.` ) ;
121+
122+ for ( const entry of finalResults ) {
123+ const issueNumber = entry . issue_number ;
88124 let labelsToAdd = entry . labels_to_add || [ ] ;
89125 let labelsToRemove = entry . labels_to_remove || [ ] ;
90126 let existingLabels = [ ] ;
@@ -131,84 +167,65 @@ module.exports = async ({ github, context, core }) => {
131167 labelsToAdd . includes ( 'status/manual-triage' ) ||
132168 existingLabels . includes ( 'status/manual-triage' )
133169 ) {
134- // If the AI flagged it for manual triage, remove bot-triaged if it exists
135170 labelsToRemove . push ( 'status/bot-triaged' ) ;
136- // Ensure we don't accidentally try to add bot-triaged if the AI returned it
137171 labelsToAdd = labelsToAdd . filter ( ( l ) => l !== 'status/bot-triaged' ) ;
138172 } else {
139- // Standard successful bot triage
140173 labelsToAdd . push ( 'status/bot-triaged' ) ;
141174 }
142175
143- // Deduplicate arrays
144- labelsToAdd = [ ...new Set ( labelsToAdd ) ] ;
145- labelsToRemove = [ ...new Set ( labelsToRemove ) ] ;
176+ // Resolve internal conflicts (e.g., adding P1 and P2)
177+ // We already resolved these by putting Effort first in the combined list
146178
147- // Fetch existing labels to auto-resolve conflicts
148- const hasNewArea = labelsToAdd . some ( ( l ) => l . startsWith ( 'area/' ) ) ;
149- if ( hasNewArea ) {
150- const existingAreas = existingLabels . filter ( ( l ) => l . startsWith ( 'area/' ) ) ;
151- labelsToRemove . push ( ...existingAreas ) ;
152- }
153-
154- const hasNewPriority = labelsToAdd . some ( ( l ) => l . startsWith ( 'priority/' ) ) ;
155- if ( hasNewPriority ) {
156- const existingPriorities = existingLabels . filter ( ( l ) =>
157- l . startsWith ( 'priority/' ) ,
179+ // Resolve external conflicts with existing labels
180+ if ( labelsToAdd . some ( ( l ) => l . startsWith ( 'area/' ) ) ) {
181+ labelsToRemove . push (
182+ ...existingLabels . filter ( ( l ) => l . startsWith ( 'area/' ) ) ,
158183 ) ;
159- labelsToRemove . push ( ...existingPriorities ) ;
160- }
161-
162- const hasNewKind = labelsToAdd . some ( ( l ) => l . startsWith ( 'kind/' ) ) ;
163- if ( hasNewKind ) {
164- const existingKinds = existingLabels . filter ( ( l ) => l . startsWith ( 'kind/' ) ) ;
165- labelsToRemove . push ( ...existingKinds ) ;
166184 }
167-
168- // Enforce mutually exclusive area labels
169- const areaLabelsToAdd = labelsToAdd . filter ( ( l ) => l . startsWith ( 'area/' ) ) ;
170- if ( areaLabelsToAdd . length > 1 ) {
171- core . warning (
172- `Issue #${ issueNumber } has multiple area labels to add: ${ areaLabelsToAdd . join ( ', ' ) } . Keeping only the first one.` ,
185+ if ( labelsToAdd . some ( ( l ) => l . startsWith ( 'priority/' ) ) ) {
186+ labelsToRemove . push (
187+ ...existingLabels . filter ( ( l ) => l . startsWith ( 'priority/' ) ) ,
173188 ) ;
174- const firstArea = areaLabelsToAdd [ 0 ] ;
175- labelsToAdd = labelsToAdd . filter (
176- ( l ) => ! l . startsWith ( 'area/' ) || l === firstArea ,
189+ }
190+ if ( labelsToAdd . some ( ( l ) => l . startsWith ( 'kind/' ) ) ) {
191+ labelsToRemove . push (
192+ ...existingLabels . filter ( ( l ) => l . startsWith ( 'kind/' ) ) ,
177193 ) ;
178194 }
179195
180- // Enforce mutually exclusive priority labels
181- const priorityLabelsToAdd = labelsToAdd . filter ( ( l ) =>
182- l . startsWith ( 'priority/' ) ,
183- ) ;
184- if ( priorityLabelsToAdd . length > 1 ) {
185- core . warning (
186- `Issue #${ issueNumber } has multiple priority labels to add: ${ priorityLabelsToAdd . join ( ', ' ) } . Keeping only the first one.` ,
187- ) ;
188- const firstPriority = priorityLabelsToAdd [ 0 ] ;
189- labelsToAdd = labelsToAdd . filter (
190- ( l ) => ! l . startsWith ( 'priority/' ) || l === firstPriority ,
191- ) ;
196+ // Enforce mutual exclusivity in the TO-ADD list (Architect wins)
197+ const exclusivePrefixes = [ 'area/' , 'priority/' , 'kind/' ] ;
198+ for ( const prefix of exclusivePrefixes ) {
199+ const filtered = labelsToAdd . filter ( ( l ) => l . startsWith ( prefix ) ) ;
200+ if ( filtered . length > 1 ) {
201+ const winner = filtered [ 0 ] ; // First one wins
202+ core . info (
203+ `Issue #${ issueNumber } has multiple ${ prefix } labels suggested. Keeping "${ winner } " and discarding others.` ,
204+ ) ;
205+ labelsToAdd = labelsToAdd . filter (
206+ ( l ) => ! l . startsWith ( prefix ) || l === winner ,
207+ ) ;
208+ }
192209 }
193210
194- // Re-deduplicate and filter out labels we are trying to add,
195- // and filter out labels that are already present or absent to avoid unnecessary API calls
211+ // Final deduplication and cleanup
196212 labelsToRemove = [ ...new Set ( labelsToRemove ) ] . filter (
197213 ( l ) => ! labelsToAdd . includes ( l ) && existingLabels . includes ( l ) ,
198214 ) ;
199- labelsToAdd = labelsToAdd . filter ( ( l ) => ! existingLabels . includes ( l ) ) ;
215+ labelsToAdd = [ ...new Set ( labelsToAdd ) ] . filter (
216+ ( l ) => ! existingLabels . includes ( l ) ,
217+ ) ;
200218
219+ // Batch label operations
201220 if ( labelsToAdd . length > 0 ) {
202221 await github . rest . issues . addLabels ( {
203222 owner : context . repo . owner ,
204223 repo : context . repo . repo ,
205224 issue_number : issueNumber ,
206225 labels : labelsToAdd ,
207226 } ) ;
208-
209- const explanation = entry . explanation ? ` - ${ entry . explanation } ` : '' ;
210227 core . info (
211- `Successfully added labels for #${ issueNumber } : ${ labelsToAdd . join ( ', ' ) } ${ explanation } ` ,
228+ `Successfully added labels for #${ issueNumber } : ${ labelsToAdd . join ( ', ' ) } ` ,
212229 ) ;
213230 }
214231
@@ -222,32 +239,26 @@ module.exports = async ({ github, context, core }) => {
222239 name : label ,
223240 } ) ;
224241 } catch ( e ) {
225- if ( e . status !== 404 ) {
242+ if ( e . status !== 404 )
226243 core . warning (
227244 `Failed to remove label ${ label } from #${ issueNumber } : ${ e . message } ` ,
228245 ) ;
229- }
230246 }
231247 }
232248 core . info (
233249 `Successfully removed labels for #${ issueNumber } : ${ labelsToRemove . join ( ', ' ) } ` ,
234250 ) ;
235251 }
236252
237- // Restrictive Commenting Policy:
238- // - Silence standard triage (Area/Kind/Priority) to avoid spam.
239- // - Only comment if status/need-information is added (to explain what is missing).
240- // - Only comment if effort_analysis is present (deep technical dive).
253+ // Post comment if needed
241254 const needsInfoAdded =
242255 labelsToAdd . includes ( 'status/need-information' ) &&
243256 ! existingLabels . includes ( 'status/need-information' ) ;
244257 const hasEffortAnalysis = ! ! entry . effort_analysis ;
245258
246259 if ( needsInfoAdded || hasEffortAnalysis ) {
247260 let commentBody = '' ;
248- if ( needsInfoAdded && entry . explanation ) {
249- commentBody += entry . explanation ;
250- }
261+ if ( needsInfoAdded && entry . explanation ) commentBody += entry . explanation ;
251262 if ( hasEffortAnalysis ) {
252263 if ( commentBody ) commentBody += '\n\n' ;
253264 commentBody += `**Effort Analysis:**\n${ entry . effort_analysis } ` ;
@@ -260,19 +271,8 @@ module.exports = async ({ github, context, core }) => {
260271 issue_number : issueNumber ,
261272 body : commentBody ,
262273 } ) ;
263- core . info (
264- `Posted required comment (need-info or effort) for #${ issueNumber } ` ,
265- ) ;
274+ core . info ( `Posted required comment for #${ issueNumber } ` ) ;
266275 }
267276 }
268-
269- if (
270- ( ! entry . labels_to_add || entry . labels_to_add . length === 0 ) &&
271- ( ! entry . labels_to_remove || entry . labels_to_remove . length === 0 )
272- ) {
273- core . info (
274- `No labels to add or remove for #${ issueNumber } , leaving as is` ,
275- ) ;
276- }
277277 }
278278} ;
0 commit comments