@@ -250,6 +250,184 @@ const KIND_TO_LABEL = {
250250 "provider-compatibility" : "provider-compatibility" ,
251251} ;
252252
253+ /**
254+ * Orthogonal product-area labels (additive beside kind/process labels).
255+ * Colors/descriptions are used when the workflow ensures labels exist.
256+ */
257+ const AREA_LABELS = {
258+ provider : {
259+ color : "1D76DB" ,
260+ description : "Provider adapters, OpenAI-compat presets, upstream API quirks" ,
261+ } ,
262+ "account-pool" : {
263+ color : "5319E7" ,
264+ description : "OAuth, credentials, Codex pool, quota, failover, plans" ,
265+ } ,
266+ catalog : {
267+ color : "006B75" ,
268+ description : "Model catalog, slugs, visibility, routed entries" ,
269+ } ,
270+ gui : {
271+ color : "D93F0B" ,
272+ description : "Dashboard, tray, settings UI" ,
273+ } ,
274+ cli : {
275+ color : "FBCA04" ,
276+ description : "CLI, config inject, packaging flags" ,
277+ } ,
278+ proxy : {
279+ color : "0E8A16" ,
280+ description : "HTTP proxy, routing, reverse-proxy / management auth" ,
281+ } ,
282+ platform : {
283+ color : "BFDADC" ,
284+ description : "OS/service/tray/ACL (Windows-heavy, not Windows-only)" ,
285+ } ,
286+ streaming : {
287+ color : "C5DEF5" ,
288+ description : "SSE, WebSocket, terminal stream frames" ,
289+ } ,
290+ tools : {
291+ color : "F9D0C4" ,
292+ description : "tool_calls, MCP, web-search / sidecar tools" ,
293+ } ,
294+ install : {
295+ color : "EDEDED" ,
296+ description : "Installation or packaging" ,
297+ } ,
298+ service : {
299+ color : "EDEDED" ,
300+ description : "Service lifecycle (WinSW/launchd/scheduler)" ,
301+ } ,
302+ } ;
303+
304+ /** Canonical Area dropdown text → area label(s). Keys are lowercased. */
305+ const AREA_FIELD_TO_LABELS = {
306+ cli : [ "cli" ] ,
307+ "proxy and routing" : [ "proxy" ] ,
308+ dashboard : [ "gui" ] ,
309+ "provider adapter" : [ "provider" ] ,
310+ "provider adapters" : [ "provider" ] ,
311+ "authentication and account pool" : [ "account-pool" ] ,
312+ "catalog / models" : [ "catalog" ] ,
313+ streaming : [ "streaming" ] ,
314+ "tools / mcp / web search" : [ "tools" ] ,
315+ "installation or packaging" : [ "install" ] ,
316+ "service lifecycle" : [ "service" ] ,
317+ "service lifecycle (config injection)" : [ "service" ] ,
318+ "platform (windows / macos / linux)" : [ "platform" ] ,
319+ // Do not map to kind label `documentation` — that collides with labelBasedKind
320+ // when a feature/bug form picks Area: Documentation. Docs form already seeds
321+ // the kind label; Area selection alone does not add an area tag.
322+ documentation : [ ] ,
323+ // No dedicated label; heuristics still run in detectAreaLabels.
324+ "multiple areas" : [ ] ,
325+ other : [ ] ,
326+ } ;
327+
328+ /** Body headings used for area heuristics (excludes Environment / OS metadata). */
329+ const AREA_HEURISTIC_BODY_HEADINGS = [
330+ "Summary" ,
331+ "Reproduction" ,
332+ "What are you trying to accomplish?" ,
333+ "What prevents this today?" ,
334+ "What should OpenCodex do?" ,
335+ "Example usage or interface" ,
336+ "Current behaviour" ,
337+ "Expected behaviour" ,
338+ "Minimal redacted request or reproduction" ,
339+ "What is wrong or missing?" ,
340+ "Documentation problem type" ,
341+ "Documentation location" ,
342+ ] ;
343+
344+ /**
345+ * Heuristic rules. `scope: "title"` avoids false hits from template Environment /
346+ * OS fields in the body; `scope: "full"` is for distinctive technical tokens.
347+ */
348+ const AREA_HEURISTICS = [
349+ {
350+ label : "account-pool" ,
351+ scope : "full" ,
352+ re : / \b ( o a u t h | r e a u t h | n e e d s r e a u t h | a c c o u n t p o o l | c o d e x .? a u t h | a u t o [ - ] ? s w i t c h | a c c o u n t f a i l o v e r | r e f r e s h t o k e n | p l a n _ t y p e | c h a t g p t [ - ] a c c o u n t | r e s e t c r e d i t ) \b / i,
353+ } ,
354+ {
355+ label : "account-pool" ,
356+ scope : "title" ,
357+ re : / \b ( q u o t a | f a i l o v e r | p o o l a c c o u n t | a c c o u n t s w i t c h ) \b / i,
358+ } ,
359+ {
360+ label : "catalog" ,
361+ scope : "full" ,
362+ re : / \b ( m o d e l c a t a l o g | o p e n c o d e x - c a t a l o g | m o d e l l i s t | m o d e l v i s i b i l i t y | v i r t u a l m o d e l | r o u t e d ( c a t a l o g | e n t r i e s | s l u g ) | m o d e l s l u g ) \b / i,
363+ } ,
364+ {
365+ label : "catalog" ,
366+ scope : "title" ,
367+ re : / \b c a t a l o g \b / i,
368+ } ,
369+ {
370+ label : "gui" ,
371+ scope : "title" ,
372+ re : / \b ( d a s h b o a r d | \b g u i \b | t r a y | s i d e b a r | s e t t i n g s ( p a g e | t a b | u i ) ) \b / i,
373+ } ,
374+ {
375+ label : "cli" ,
376+ scope : "title" ,
377+ re : / \b ( o c x \b | c o n f i g \. t o m l | c o n f i g i n j e c t ) \b / i,
378+ } ,
379+ {
380+ label : "proxy" ,
381+ scope : "full" ,
382+ re : / \b ( r e v e r s e [ - ] p r o x y | m a n a g e m e n t a p i | a d m i n [ - ] t o k e n | \/ a p i \/ \* | b i n d ( s ) ? t h e ( o l d ) ? p o r t ) \b / i,
383+ } ,
384+ {
385+ label : "proxy" ,
386+ scope : "title" ,
387+ re : / \b ( r e v e r s e [ - ] p r o x y | m a n a g e m e n t a p i | a d m i n [ - ] t o k e n ) \b / i,
388+ } ,
389+ {
390+ label : "platform" ,
391+ scope : "full" ,
392+ re : / \b ( w i n s w | l a u n c h d | s c h t a s k s | i c a c l s | w i n d o w s - l a t e s t | t r a y h o s t | s c h e d u l e r b a c k e n d ) \b / i,
393+ } ,
394+ {
395+ label : "platform" ,
396+ scope : "title" ,
397+ re : / \b ( \[ w i n d o w s \] | \[ m a c o s \] | w i n d o w s | m a c o s | d a r w i n | w i n 3 2 | w s l ) \b / i,
398+ } ,
399+ {
400+ label : "streaming" ,
401+ scope : "full" ,
402+ re : / \b ( s s e | w e b s o c k e t | \b w s \b | s t r e a m ( i n g ) ? \b .{ 0 , 40 } \b t r u n c a t \w * | s t r e a m ( i n g ) ? \b .{ 0 , 40 } \b t e r m i n a l \b | t e r m i n a l ( s s e ) ? f r a m e | w i t h o u t a t e r m i n a l ) \b / i,
403+ } ,
404+ {
405+ label : "tools" ,
406+ scope : "full" ,
407+ re : / \b ( t o o l _ c a l l s ? | t o o l [ - ] c a l l s ? | \b m c p \b | w e b [ - ] s e a r c h | t o o l [ - ] r e c a l l ) \b / i,
408+ } ,
409+ {
410+ label : "install" ,
411+ scope : "full" ,
412+ re : / \b ( n p m ( g l o b a l ) ? i n s t a l l | p a c k a g i n g | r e l e a s e a s s e t | n p x o c x ) \b / i,
413+ } ,
414+ {
415+ label : "service" ,
416+ scope : "full" ,
417+ re : / \b ( o c x s e r v i c e | w i n s w | s c h e d u l e r b a c k e n d | l a u n c h d s e r v i c e ) \b / i,
418+ } ,
419+ {
420+ label : "provider" ,
421+ scope : "full" ,
422+ re : / \b ( p r o v i d e r a d a p t e r | o p e n a i [ - ] c o m p a t i b l e | p r o v i d e r [ - ] c o m p a t | a d a p t e r q u i r k | b u i l t [ - ] i n p r o v i d e r | p r o v i d e r p r e s e t ) \b / i,
423+ } ,
424+ {
425+ label : "provider" ,
426+ scope : "title" ,
427+ re : / \b ( \[ p r o v i d e r \] | p r o v i d e r c o m p a t | o p e n a i [ - ] c o m p a t i b l e ) \b / i,
428+ } ,
429+ ] ;
430+
253431/**
254432 * Map a detected issue kind to its triage label. Returns null when unknown.
255433 */
@@ -258,6 +436,99 @@ function labelForKind(kind) {
258436 return KIND_TO_LABEL [ kind ] || null ;
259437}
260438
439+ /**
440+ * Map a template Area dropdown value to orthogonal area label names.
441+ * Returns [] for Other / Multiple areas / unknown / empty.
442+ *
443+ * @param {unknown } areaText
444+ * @returns {string[] }
445+ */
446+ function mapAreaFieldToLabels ( areaText ) {
447+ if ( typeof areaText !== "string" ) return [ ] ;
448+ const key = areaText . replace ( / \s + / g, " " ) . trim ( ) . toLowerCase ( ) ;
449+ if ( ! key ) return [ ] ;
450+ return AREA_FIELD_TO_LABELS [ key ] ? [ ...AREA_FIELD_TO_LABELS [ key ] ] : [ ] ;
451+ }
452+
453+ /**
454+ * Build heuristic text from title-relevant semantic sections only — never from
455+ * Operating system / Version / Checks metadata that every template includes.
456+ *
457+ * @param {string } body
458+ * @returns {string }
459+ */
460+ function bodyForAreaHeuristics ( body ) {
461+ if ( typeof body !== "string" || ! body . trim ( ) ) return "" ;
462+ const parts = [ ] ;
463+ for ( const heading of AREA_HEURISTIC_BODY_HEADINGS ) {
464+ const section = extractSection ( body , heading ) ;
465+ if ( section ) parts . push ( section ) ;
466+ }
467+ return parts . join ( "\n\n" ) ;
468+ }
469+
470+ /**
471+ * Conservative title/body heuristics for orthogonal area labels.
472+ *
473+ * @param {string } title
474+ * @param {string } body semantic body text (already filtered)
475+ * @returns {string[] }
476+ */
477+ function heuristicAreaLabels ( title , body ) {
478+ const titleText = title || "" ;
479+ const fullText = `${ titleText } \n${ body || "" } ` ;
480+ const seen = new Set ( ) ;
481+ const out = [ ] ;
482+ for ( const { label, re, scope } of AREA_HEURISTICS ) {
483+ const text = scope === "title" ? titleText : fullText ;
484+ if ( ! re . test ( text ) || seen . has ( label ) ) continue ;
485+ seen . add ( label ) ;
486+ out . push ( label ) ;
487+ }
488+ return out ;
489+ }
490+
491+ /**
492+ * Detect additive product-area labels from Area field, form defaults, and
493+ * title/body heuristics. Never invents per-provider labels.
494+ *
495+ * @param {{
496+ * title?: string,
497+ * body?: string,
498+ * labels?: string[],
499+ * heuristicBody?: string,
500+ * }} issue
501+ * `body` is the source form (for Area / provider headings).
502+ * `heuristicBody` may include English translation text for heuristics only.
503+ * @returns {string[] }
504+ */
505+ function detectAreaLabels ( issue ) {
506+ const title = typeof issue ?. title === "string" ? issue . title : "" ;
507+ const body = typeof issue ?. body === "string" ? issue . body : "" ;
508+ const labels = Array . isArray ( issue ?. labels ) ? issue . labels : [ ] ;
509+ const heuristicSource = typeof issue ?. heuristicBody === "string" ? issue . heuristicBody : body ;
510+
511+ const areaSection = extractSection ( body , "Area" ) ;
512+ const fromArea = mapAreaFieldToLabels ( areaSection ) ;
513+ const fromHeur = heuristicAreaLabels ( title , bodyForAreaHeuristics ( heuristicSource ) ) ;
514+ const fromForm = [ ] ;
515+ if ( labels . includes ( "provider-compatibility" ) ) fromForm . push ( "provider" ) ;
516+ // Provider-compat form uses this heading instead of Area.
517+ if ( extractSection ( body , "Provider or upstream service" ) !== null ) {
518+ fromForm . push ( "provider" ) ;
519+ }
520+
521+ const seen = new Set ( ) ;
522+ const out = [ ] ;
523+ for ( const label of [ ...fromArea , ...fromForm , ...fromHeur ] ) {
524+ if ( ! label || seen . has ( label ) ) continue ;
525+ if ( ! AREA_LABELS [ label ] ) continue ;
526+ seen . add ( label ) ;
527+ out . push ( label ) ;
528+ }
529+ return out ;
530+ }
531+
261532function countHeadings ( body , headings ) {
262533 let n = 0 ;
263534 for ( const h of headings ) {
@@ -952,6 +1223,12 @@ module.exports = {
9521223 hasConcreteDetail,
9531224 labelForKind,
9541225 KIND_TO_LABEL ,
1226+ AREA_LABELS ,
1227+ AREA_FIELD_TO_LABELS ,
1228+ mapAreaFieldToLabels,
1229+ bodyForAreaHeuristics,
1230+ heuristicAreaLabels,
1231+ detectAreaLabels,
9551232 hasSubstantialStructuredContent,
9561233 rejectsWorkflowDispatchPullRequest,
9571234 rejectsWorkflowDispatchNonDefaultBranch,
0 commit comments