ha-inlite

Home Assistant integration for in-lite
git clone https://git.stephank.nl/ha-inlite
Log | Files | Refs | README | LICENSE | ZIP

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 });