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