main
js 545 lines 15.7 KB
Raw
1 #!/usr/bin/env node
2 /**
3 * Ralph Triage Script — Standalone CJS implementation
4 *
5 * ⚠️ SYNC NOTICE: This file ports triage logic from the SDK source:
6 * packages/squad-sdk/src/ralph/triage.ts
7 *
8 * Any changes to routing/triage logic MUST be applied to BOTH files.
9 * The SDK module is the canonical implementation; this script exists
10 * for zero-dependency use in GitHub Actions workflows.
11 *
12 * To verify parity: npm test -- test/ralph-triage.test.ts
13 */
14 'use strict';
15
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 });