Setting the file. One moment.
Ralph Triage · {skill Name} · microsoft/waza · Skills Docs
ContentsBack to the top of the page function cleanCell
— line 253
This file
Number 12.37
Position 37 of 48
Type JavaScript
Size 15 KB
Lines 545 ralph-triage.js
JavaScript · 545 lines · 15 KB
16 const fs = require ( 'node:fs' );
17 const path = require ( 'node:path' );
18 const https = require ( 'node:https' );
19 const { execSync } = require ( 'node:child_process' );
20
21 function parseArgs ( argv ) {
22 let squadDir = '.squad' ;
23 let output = 'triage-results.json' ;
24
25 for ( let i = 0 ; i < argv. length ; i += 1 ) {
26 const arg = argv[i];
27 if (arg === '--squad-dir' ) {
28 squadDir = argv[i + 1 ];
29 i += 1 ;
30 continue ;
31 }
32 if (arg === '--output' ) {
33 output = argv[i + 1 ];
34 i += 1 ;
35 continue ;
36 }
37 if (arg === '--help' || arg === '-h' ) {
38 printUsage ();
39 process. exit ( 0 );
40 }
41 throw new Error ( `Unknown argument: ${ arg }` );
42 }
43
44 if ( ! squadDir) throw new Error ( '--squad-dir requires a value' );
45 if ( ! output) throw new Error ( '--output requires a value' );
46
47 return { squadDir, output };
48 }
49
50 function printUsage () {
51 console. log ( 'Usage: node .squad/templates/ralph-triage.js --squad-dir .squad --output triage-results.json' );
52 }
53
54 function normalizeEol ( content ) {
55 return content. replace ( / \r\n / g , ' \n ' ). replace ( / \r / g , ' \n ' );
56 }
57
58 function slugify ( text ) { return text. toLowerCase (). replace ( / [ ^ a-z0-9] + / g , '-' ). replace ( / ^ - | - $ / g , '' ); }
59
60 function parseRoutingRules ( routingMd ) {
61 const table = parseTableSection (routingMd, / ^ ## \s * work \s * type \s * (?:→ | ->) \s * agent \b / i );
62 if ( ! table) return [];
63
64 const workTypeIndex = findColumnIndex (table.headers, [ 'work type' , 'type' ]);
65 const agentIndex = findColumnIndex (table.headers, [ 'agent' , 'route to' , 'route' ]);
66 const examplesIndex = findColumnIndex (table.headers, [ 'examples' , 'example' ]);
67
68 if (workTypeIndex < 0 || agentIndex < 0 ) return [];
69
70 const rules = [];
71 for ( const row of table.rows) {
72 const workType = cleanCell (row[workTypeIndex] || '' );
73 const agentName = cleanCell (row[agentIndex] || '' );
74 const keywords = splitKeywords (examplesIndex >= 0 ? row[examplesIndex] : '' );
75 if ( ! workType || ! agentName) continue ;
76 rules. push ({ workType, agentName, keywords });
77 }
78
79 return rules;
80 }
81
82 function parseModuleOwnership ( routingMd ) {
83 const table = parseTableSection (routingMd, / ^ ## \s * module \s * ownership \b / i );
84 if ( ! table) return [];
85
86 const moduleIndex = findColumnIndex (table.headers, [ 'module' , 'path' ]);
87 const primaryIndex = findColumnIndex (table.headers, [ 'primary' ]);
88 const secondaryIndex = findColumnIndex (table.headers, [ 'secondary' ]);
89
90 if (moduleIndex < 0 || primaryIndex < 0 ) return [];
91
92 const modules = [];
93 for ( const row of table.rows) {
94 const modulePath = normalizeModulePath (row[moduleIndex] || '' );
95 const primary = cleanCell (row[primaryIndex] || '' );
96 const secondaryRaw = cleanCell (secondaryIndex >= 0 ? row[secondaryIndex] || '' : '' );
97 const secondary = normalizeOptionalOwner (secondaryRaw);
98
99 if ( ! modulePath || ! primary) continue ;
100 modules. push ({ modulePath, primary, secondary });
101 }
102
103 return modules;
104 }
105
106 function parseRoster ( teamMd ) {
107 const table =
108 parseTableSection (teamMd, / ^ ## \s * members \b / i ) ||
109 parseTableSection (teamMd, / ^ ## \s * team \s * roster \b / i );
110
111 if ( ! table) return [];
112
113 const nameIndex = findColumnIndex (table.headers, [ 'name' ]);
114 const roleIndex = findColumnIndex (table.headers, [ 'role' ]);
115 if (nameIndex < 0 || roleIndex < 0 ) return [];
116
117 const excluded = new Set ([ 'scribe' , 'ralph' ]);
118 const members = [];
119
120 for ( const row of table.rows) {
121 const name = cleanCell (row[nameIndex] || '' );
122 const role = cleanCell (row[roleIndex] || '' );
123 if ( ! name || ! role) continue ;
124 if (excluded. has (name. toLowerCase ())) continue ;
125
126 members. push ({
127 name,
128 role,
129 label: `squad:${ slugify ( name ) }` ,
130 });
131 }
132
133 return members;
134 }
135
136 function triageIssue ( issue , rules , modules , roster ) {
137 const issueText = `${ issue . title } \n ${ issue . body || ''}` . toLowerCase ();
138 const normalizedIssueText = normalizeTextForPathMatch (issueText);
139
140 const bestModule = findBestModuleMatch (normalizedIssueText, modules);
141 if (bestModule) {
142 const primaryMember = findMember (bestModule.primary, roster);
143 if (primaryMember) {
144 return {
145 agent: primaryMember,
146 reason: `Matched module path "${ bestModule . modulePath }" to primary owner "${ bestModule . primary }"` ,
147 source: 'module-ownership' ,
148 confidence: 'high' ,
149 };
150 }
151
152 if (bestModule.secondary) {
153 const secondaryMember = findMember (bestModule.secondary, roster);
154 if (secondaryMember) {
155 return {
156 agent: secondaryMember,
157 reason: `Matched module path "${ bestModule . modulePath }" to secondary owner "${ bestModule . secondary }"` ,
158 source: 'module-ownership' ,
159 confidence: 'medium' ,
160 };
161 }
162 }
163 }
164
165 const bestRule = findBestRuleMatch (issueText, rules);
166 if (bestRule) {
167 const agent = findMember (bestRule.rule.agentName, roster);
168 if (agent) {
169 return {
170 agent,
171 reason: `Matched routing keyword(s): ${ bestRule . matchedKeywords . join ( ', ' ) }` ,
172 source: 'routing-rule' ,
173 confidence: bestRule.matchedKeywords. length >= 2 ? 'high' : 'medium' ,
174 };
175 }
176 }
177
178 const roleMatch = findRoleKeywordMatch (issueText, roster);
179 if (roleMatch) {
180 return {
181 agent: roleMatch.agent,
182 reason: roleMatch.reason,
183 source: 'role-keyword' ,
184 confidence: 'medium' ,
185 };
186 }
187
188 const lead = findLeadFallback (roster);
189 if ( ! lead) return null ;
190
191 return {
192 agent: lead,
193 reason: 'No module, routing, or role keyword match — routed to Lead/Architect' ,
194 source: 'lead-fallback' ,
195 confidence: 'low' ,
196 };
197 }
198
199 function parseTableSection ( markdown , sectionHeader ) {
200 const lines = normalizeEol (markdown). split ( ' \n ' );
201 let inSection = false ;
202 const tableLines = [];
203
204 for ( const line of lines) {
205 const trimmed = line. trim ();
206 if ( ! inSection && sectionHeader. test (trimmed)) {
207 inSection = true ;
208 continue ;
209 }
210 if (inSection && / ^ ## \s + / . test (trimmed)) break ;
211 if (inSection && trimmed. startsWith ( '|' )) tableLines. push (trimmed);
212 }
213
214 if (tableLines. length === 0 ) return null ;
215
216 let headers = null ;
217 const rows = [];
218
219 for ( const line of tableLines) {
220 const cells = parseTableLine (line);
221 if (cells. length === 0 ) continue ;
222 if (cells. every (( cell ) => / ^ : ? - {2,} : ?$ / . test (cell))) continue ;
223
224 if ( ! headers) {
225 headers = cells;
226 continue ;
227 }
228
229 rows. push (cells);
230 }
231
232 if ( ! headers) return null ;
233 return { headers, rows };
234 }
235
236 function parseTableLine ( line ) {
237 return line
238 . replace ( / ^ \| / , '' )
239 . replace ( / \| $ / , '' )
240 . split ( '|' )
241 . map (( cell ) => cell. trim ());
242 }
243
244 function findColumnIndex ( headers , candidates ) {
245 const normalizedHeaders = headers. map (( header ) => cleanCell (header). toLowerCase ());
246 for ( const candidate of candidates) {
247 const index = normalizedHeaders. findIndex (( header ) => header. includes (candidate));
248 if (index >= 0 ) return index;
249 }
250 return - 1 ;
251 }
252
253 function cleanCell ( value ) {
254 return value
255 . replace ( /`/ g , '' )
256 . replace ( / \[ ( [ ^ \] ] + ) \]\( [ ^ )] + \) / g , '$1' )
257 . trim ();
258 }
259
260 function splitKeywords ( examplesCell ) {
261 if ( ! examplesCell) return [];
262 return examplesCell
263 . split ( ',' )
264 . map (( keyword ) => cleanCell (keyword))
265 . filter (( keyword ) => keyword. length > 0 );
266 }
267
268 function normalizeOptionalOwner ( owner ) {
269 if ( ! owner) return null ;
270 if ( / ^ [-—–] +$ / . test (owner)) return null ;
271 return owner;
272 }
273
274 function normalizeModulePath ( modulePath ) {
275 return cleanCell (modulePath). replace ( / \\ / g , '/' ). toLowerCase ();
276 }
277
278 function normalizeTextForPathMatch ( text ) {
279 return text. replace ( / \\ / g , '/' ). replace ( /`/ g , '' );
280 }
281
282 function normalizeName ( value ) {
283 return cleanCell (value)
284 . toLowerCase ()
285 . replace ( / [ ^ \w@\s-] / g , '' )
286 . replace ( / \s + / g , ' ' )
287 . trim ();
288 }
289
290 function findMember ( target , roster ) {
291 const normalizedTarget = normalizeName (target);
292 if ( ! normalizedTarget) return null ;
293
294 for ( const member of roster) {
295 if ( normalizeName (member.name) === normalizedTarget) return member;
296 }
297
298 for ( const member of roster) {
299 if ( normalizeName (member.role) === normalizedTarget) return member;
300 }
301
302 for ( const member of roster) {
303 const memberName = normalizeName (member.name);
304 if (normalizedTarget. includes (memberName) || memberName. includes (normalizedTarget)) {
305 return member;
306 }
307 }
308
309 for ( const member of roster) {
310 const memberRole = normalizeName (member.role);
311 if (normalizedTarget. includes (memberRole) || memberRole. includes (normalizedTarget)) {
312 return member;
313 }
314 }
315
316 return null ;
317 }
318
319 function findBestModuleMatch ( issueText , modules ) {
320 let best = null ;
321 let bestLength = - 1 ;
322
323 for ( const module of modules) {
324 const modulePath = normalizeModulePath ( module .modulePath);
325 if ( ! modulePath) continue ;
326 if ( ! issueText. includes (modulePath)) continue ;
327
328 if (modulePath. length > bestLength) {
329 best = module ;
330 bestLength = modulePath. length ;
331 }
332 }
333
334 return best;
335 }
336
337 function findBestRuleMatch ( issueText , rules ) {
338 let best = null ;
339 let bestScore = 0 ;
340
341 for ( const rule of rules) {
342 const matchedKeywords = rule.keywords
343 . map (( keyword ) => keyword. toLowerCase ())
344 . filter (( keyword ) => keyword. length > 0 && issueText. includes (keyword));
345
346 if (matchedKeywords. length === 0 ) continue ;
347
348 const score =
349 matchedKeywords. length * 100 + matchedKeywords. reduce (( sum , keyword ) => sum + keyword. length , 0 );
350 if (score > bestScore) {
351 best = { rule, matchedKeywords };
352 bestScore = score;
353 }
354 }
355
356 return best;
357 }
358
359 function findRoleKeywordMatch ( issueText , roster ) {
360 for ( const member of roster) {
361 const role = member.role. toLowerCase ();
362
363 if (
364 (role. includes ( 'frontend' ) || role. includes ( 'ui' )) &&
365 (issueText. includes ( 'ui' ) || issueText. includes ( 'frontend' ) || issueText. includes ( 'css' ))
366 ) {
367 return { agent: member, reason: 'Matched frontend/UI role keywords' };
368 }
369
370 if (
371 (role. includes ( 'backend' ) || role. includes ( 'api' ) || role. includes ( 'server' )) &&
372 (issueText. includes ( 'api' ) || issueText. includes ( 'backend' ) || issueText. includes ( 'database' ))
373 ) {
374 return { agent: member, reason: 'Matched backend/API role keywords' };
375 }
376
377 if (
378 (role. includes ( 'test' ) || role. includes ( 'qa' )) &&
379 (issueText. includes ( 'test' ) || issueText. includes ( 'bug' ) || issueText. includes ( 'fix' ))
380 ) {
381 return { agent: member, reason: 'Matched testing/QA role keywords' };
382 }
383 }
384
385 return null ;
386 }
387
388 function findLeadFallback ( roster ) {
389 return (
390 roster. find (( member ) => {
391 const role = member.role. toLowerCase ();
392 return role. includes ( 'lead' ) || role. includes ( 'architect' );
393 }) || null
394 );
395 }
396
397 function parseOwnerRepoFromRemote ( remoteUrl ) {
398 const sshMatch = remoteUrl. match ( / ^ git@ [ ^ :] + :( [ ^ /] + ) \/ ( . +? )(?: \. git) ?$ / );
399 if (sshMatch) return { owner: sshMatch[ 1 ], repo: sshMatch[ 2 ] };
400
401 if (remoteUrl. startsWith ( 'http://' ) || remoteUrl. startsWith ( 'https://' ) || remoteUrl. startsWith ( 'ssh://' )) {
402 const parsed = new URL (remoteUrl);
403 const parts = parsed.pathname. replace ( / ^ \/ + / , '' ). replace ( / \. git $ / , '' ). split ( '/' );
404 if (parts. length >= 2 ) {
405 return { owner: parts[ 0 ], repo: parts[ 1 ] };
406 }
407 }
408
409 throw new Error ( `Unable to parse owner/repo from remote URL: ${ remoteUrl }` );
410 }
411
412 function getOwnerRepoFromGit () {
413 const remoteUrl = execSync ( 'git remote get-url origin' , { encoding: 'utf8' }). trim ();
414 return parseOwnerRepoFromRemote (remoteUrl);
415 }
416
417 function githubRequestJson ( pathname , token ) {
418 return new Promise (( resolve , reject ) => {
419 const req = https. request (
420 {
421 hostname: 'api.github.com' ,
422 method: 'GET' ,
423 path: pathname,
424 headers: {
425 Accept: 'application/vnd.github+json' ,
426 Authorization: `Bearer ${ token }` ,
427 'User-Agent' : 'squad-ralph-triage' ,
428 'X-GitHub-Api-Version' : '2022-11-28' ,
429 },
430 },
431 ( res ) => {
432 let body = '' ;
433 res. setEncoding ( 'utf8' );
434 res. on ( 'data' , ( chunk ) => {
435 body += chunk;
436 });
437 res. on ( 'end' , () => {
438 if ((res.statusCode || 500 ) >= 400 ) {
439 reject ( new Error ( `GitHub API ${ res . statusCode }: ${ body }` ));
440 return ;
441 }
442 try {
443 resolve ( JSON . parse (body));
444 } catch (error) {
445 reject ( new Error ( `Failed to parse GitHub response: ${ error . message }` ));
446 }
447 });
448 },
449 );
450 req. on ( 'error' , reject);
451 req. end ();
452 });
453 }
454
455 async function fetchSquadIssues ( owner , repo , token ) {
456 const all = [];
457 let page = 1 ;
458 const perPage = 100 ;
459
460 for (;;) {
461 const query = new URLSearchParams ({
462 state: 'open' ,
463 labels: 'squad' ,
464 per_page: String (perPage),
465 page: String (page),
466 });
467 const issues = await githubRequestJson ( `/repos/${ owner }/${ repo }/issues?${ query . toString () }` , token);
468 if ( ! Array. isArray (issues) || issues. length === 0 ) break ;
469 all. push ( ... issues);
470 if (issues. length < perPage) break ;
471 page += 1 ;
472 }
473
474 return all;
475 }
476
477 function issueHasLabel ( issue , labelName ) {
478 const target = labelName. toLowerCase ();
479 return (issue.labels || []). some (( label ) => {
480 if ( ! label) return false ;
481 const name = typeof label === 'string' ? label : label.name;
482 return typeof name === 'string' && name. toLowerCase () === target;
483 });
484 }
485
486 function isUntriagedIssue ( issue , memberLabels ) {
487 if (issue.pull_request) return false ;
488 if ( ! issueHasLabel (issue, 'squad' )) return false ;
489 return ! memberLabels. some (( label ) => issueHasLabel (issue, label));
490 }
491
492 async function main () {
493 const args = parseArgs (process.argv. slice ( 2 ));
494 const token = process.env. GITHUB_TOKEN ;
495 if ( ! token) {
496 throw new Error ( 'GITHUB_TOKEN is required' );
497 }
498
499 const squadDir = path. resolve (process. cwd (), args.squadDir);
500 const teamMd = fs. readFileSync (path. join (squadDir, 'team.md' ), 'utf8' );
501 const routingMd = fs. readFileSync (path. join (squadDir, 'routing.md' ), 'utf8' );
502
503 const roster = parseRoster (teamMd);
504 const rules = parseRoutingRules (routingMd);
505 const modules = parseModuleOwnership (routingMd);
506
507 const { owner , repo } = getOwnerRepoFromGit ();
508 const openSquadIssues = await fetchSquadIssues (owner, repo, token);
509
510 const memberLabels = roster. map (( member ) => member.label);
511 const untriaged = openSquadIssues. filter (( issue ) => isUntriagedIssue (issue, memberLabels));
512
513 const results = [];
514 for ( const issue of untriaged) {
515 const decision = triageIssue (
516 {
517 number: issue.number,
518 title: issue.title || '' ,
519 body: issue.body || '' ,
520 labels: [],
521 },
522 rules,
523 modules,
524 roster,
525 );
526
527 if ( ! decision) continue ;
528 results. push ({
529 issueNumber: issue.number,
530 assignTo: decision.agent.name,
531 label: decision.agent.label,
532 reason: decision.reason,
533 source: decision.source,
534 });
535 }
536
537 const outputPath = path. resolve (process. cwd (), args.output);
538 fs. mkdirSync (path. dirname (outputPath), { recursive: true });
539 fs. writeFileSync (outputPath, `${ JSON . stringify ( results , null , 2 ) } \n ` , 'utf8' );
540 }
541
542 main (). catch (( error ) => {
543 console. error (error.message);
544 process. exit ( 1 );
545 });