ci-triage 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { writeFileSync } from 'node:fs';
3
3
  import { createRequire } from 'node:module';
4
+ import { pathToFileURL } from 'node:url';
4
5
  import { classify } from './classifier.js';
5
- import { parseFailures } from './parser.js';
6
+ import { parseAllFailures } from './parser.js';
6
7
  import { buildJsonReport, toConsoleText, toJson, toMarkdown } from './reporter.js';
7
8
  import { getProvider, detectProvider } from './providers/index.js';
8
- import { getFlakes, persistRun } from './flake-store.js';
9
+ import { getDbStats, getFlakes, getRemediations, markResolved, markVerified, persistRun, pruneRuns } from './flake-store.js';
9
10
  import { analyzeLlm } from './llm-analyzer.js';
11
+ import { fetchRepoContext } from './repo-context.js';
12
+ import { buildMultiJson, detectSharedPatterns, formatMultiSummary } from './multi.js';
10
13
  // --version support
11
14
  const _require = createRequire(import.meta.url);
12
15
  let pkgVersion = 'unknown';
@@ -23,7 +26,13 @@ function usage() {
23
26
  '',
24
27
  'Usage:',
25
28
  ' ci-triage owner/repo [limit] [--run <id>] [--md report.md] [--json]',
29
+ ' ci-triage multi owner/repo1 owner/repo2 owner/repo3 [--json]',
26
30
  ' ci-triage flakes owner/repo',
31
+ ' ci-triage db prune [--days 90]',
32
+ ' ci-triage db stats [owner/repo] [--json]',
33
+ ' ci-triage resolve <owner/repo> <run-id> "<description>" [--commit <sha>]',
34
+ ' ci-triage verify <owner/repo> <run-id> --clean-run <clean-run-id>',
35
+ ' ci-triage history <owner/repo> [--json]',
27
36
  '',
28
37
  'Options:',
29
38
  ' --run <id> Triage a specific run ID',
@@ -36,7 +45,27 @@ function usage() {
36
45
  ].join('\n'));
37
46
  process.exit(1);
38
47
  }
39
- function parseArgs(argv) {
48
+ export const EMPTY_OUTPUT_WARNING = 'Warning: No structured failures detected. The log may contain infra/script failures\n' +
49
+ "that the heuristic parser doesn't recognize. Re-run with OPENAI_API_KEY set or\n" +
50
+ 'pass --llm for root-cause analysis.\n';
51
+ export function shouldWarnOnEmptyOutput(totalFailures, llmRan) {
52
+ return totalFailures === 0 && !llmRan;
53
+ }
54
+ export function emitEmptyOutputWarning(totalFailures, llmRan) {
55
+ if (shouldWarnOnEmptyOutput(totalFailures, llmRan)) {
56
+ process.stderr.write(EMPTY_OUTPUT_WARNING);
57
+ }
58
+ }
59
+ function buildRepoContextHint(repoContext, failures) {
60
+ if (!repoContext)
61
+ return null;
62
+ const pagesFailure = failures.some((f) => /pages|deploy-pages|github pages/i.test(`${f.stepName} ${f.error}`));
63
+ if (pagesFailure && repoContext.private && !repoContext.pagesEnabled) {
64
+ return '(private repo, Pages not enabled)';
65
+ }
66
+ return null;
67
+ }
68
+ export function parseArgs(argv) {
40
69
  const args = argv.slice(2);
41
70
  // --version
42
71
  if (args.includes('--version') || args.includes('-v')) {
@@ -52,6 +81,77 @@ function parseArgs(argv) {
52
81
  }
53
82
  return { repo, limit: 0, outputJson: false, subcommand: 'flakes' };
54
83
  }
84
+ // subcommand: ci-triage multi owner/repo1 owner/repo2 [--json]
85
+ if (args[0] === 'multi') {
86
+ const repos = args.slice(1).filter((arg) => !arg.startsWith('--'));
87
+ if (repos.length === 0 ||
88
+ repos.some((repo) => !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(repo))) {
89
+ console.error('Usage: ci-triage multi owner/repo1 owner/repo2 owner/repo3 [--json]');
90
+ process.exit(1);
91
+ }
92
+ return { repo: repos[0], repos, limit: 0, outputJson: args.includes('--json'), subcommand: 'multi' };
93
+ }
94
+ // subcommand: ci-triage db prune [--days 90]
95
+ if (args[0] === 'db' && args[1] === 'prune') {
96
+ const daysIdx = args.indexOf('--days');
97
+ const days = daysIdx >= 0 ? Number(args[daysIdx + 1]) : 90;
98
+ return {
99
+ repo: '',
100
+ limit: 0,
101
+ outputJson: args.includes('--json'),
102
+ subcommand: 'db-prune',
103
+ dbPruneDays: isFinite(days) ? days : 90,
104
+ };
105
+ }
106
+ // subcommand: ci-triage db stats [owner/repo]
107
+ if (args[0] === 'db' && args[1] === 'stats') {
108
+ const repo = args[2];
109
+ if (repo && !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(repo)) {
110
+ console.error('Usage: ci-triage db stats [owner/repo]');
111
+ process.exit(1);
112
+ }
113
+ return {
114
+ repo: '',
115
+ limit: 0,
116
+ outputJson: args.includes('--json'),
117
+ subcommand: 'db-stats',
118
+ dbStatRepo: repo,
119
+ };
120
+ }
121
+ // subcommand: ci-triage resolve <owner/repo> <run-id> "<description>" [--commit <sha>]
122
+ if (args[0] === 'resolve') {
123
+ const repo = args[1];
124
+ const resolveRunId = args[2];
125
+ const resolveDescription = args[3];
126
+ if (!repo || !resolveRunId || !resolveDescription) {
127
+ console.error('Usage: ci-triage resolve owner/repo run-id "description" [--commit <sha>]');
128
+ process.exit(1);
129
+ }
130
+ const commitIdx = args.indexOf('--commit');
131
+ const resolveCommit = commitIdx >= 0 ? args[commitIdx + 1] : undefined;
132
+ return { repo, limit: 0, outputJson: false, subcommand: 'resolve', resolveRunId, resolveDescription, resolveCommit };
133
+ }
134
+ // subcommand: ci-triage verify <owner/repo> <run-id> --clean-run <clean-run-id>
135
+ if (args[0] === 'verify') {
136
+ const repo = args[1];
137
+ const verifyRunId = args[2];
138
+ const cleanRunIdx = args.indexOf('--clean-run');
139
+ const cleanRunId = cleanRunIdx >= 0 ? args[cleanRunIdx + 1] : undefined;
140
+ if (!repo || !verifyRunId || !cleanRunId) {
141
+ console.error('Usage: ci-triage verify owner/repo run-id --clean-run clean-run-id');
142
+ process.exit(1);
143
+ }
144
+ return { repo, limit: 0, outputJson: false, subcommand: 'verify', verifyRunId, cleanRunId };
145
+ }
146
+ // subcommand: ci-triage history <owner/repo> [--json]
147
+ if (args[0] === 'history') {
148
+ const historyRepo = args[1];
149
+ if (!historyRepo || !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(historyRepo)) {
150
+ console.error('Usage: ci-triage history owner/repo [--json]');
151
+ process.exit(1);
152
+ }
153
+ return { repo: '', limit: 0, outputJson: args.includes('--json'), subcommand: 'history', historyRepo };
154
+ }
55
155
  const repo = args[0];
56
156
  if (!repo || !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(repo)) {
57
157
  usage();
@@ -96,6 +196,167 @@ async function runFlakesCommand(repo) {
96
196
  }
97
197
  console.log(`\nTotal flaky tests: ${flakes.length}`);
98
198
  }
199
+ async function runDbStatsCommand(repo, outputJson) {
200
+ const stats = getDbStats(repo);
201
+ if (outputJson) {
202
+ process.stdout.write(`${JSON.stringify(stats, null, 2)}\n`);
203
+ return;
204
+ }
205
+ const rows = [
206
+ ['scope', repo ?? 'all repos'],
207
+ ['run_count', String(stats.run_count)],
208
+ ['test_result_count', String(stats.test_result_count)],
209
+ ['flaky_test_count', String(stats.flaky_test_count)],
210
+ ['db_path', stats.db_path],
211
+ ['db_size_bytes', String(stats.db_size_bytes)],
212
+ ];
213
+ const keyWidth = Math.max(...rows.map(([k]) => k.length));
214
+ const valueWidth = Math.max(...rows.map(([, v]) => v.length));
215
+ const border = `+${'-'.repeat(keyWidth + 2)}+${'-'.repeat(valueWidth + 2)}+`;
216
+ console.log(border);
217
+ console.log(`| ${'metric'.padEnd(keyWidth)} | ${'value'.padEnd(valueWidth)} |`);
218
+ console.log(border);
219
+ for (const [key, value] of rows) {
220
+ console.log(`| ${key.padEnd(keyWidth)} | ${value.padEnd(valueWidth)} |`);
221
+ }
222
+ console.log(border);
223
+ }
224
+ function runResolveCommand(repo, runId, description, commit) {
225
+ markResolved(repo, runId, 'manual', description, commit);
226
+ console.log(`✓ Marked run ${runId} as resolved: ${description}`);
227
+ }
228
+ function runVerifyCommand(repo, runId, cleanRunId) {
229
+ markVerified(repo, runId, cleanRunId);
230
+ console.log(`✓ Verified fix: run ${runId} resolved by clean run ${cleanRunId}`);
231
+ }
232
+ function runHistoryCommand(repo, outputJson) {
233
+ const records = getRemediations(repo);
234
+ if (outputJson) {
235
+ process.stdout.write(`${JSON.stringify(records, null, 2)}\n`);
236
+ return;
237
+ }
238
+ if (records.length === 0) {
239
+ console.log(`No remediation history found for ${repo}.`);
240
+ return;
241
+ }
242
+ const cols = {
243
+ run_id: Math.max(6, ...records.map((r) => r.run_id.length)),
244
+ fix_action: Math.max(10, ...records.map((r) => r.fix_action.length)),
245
+ description: Math.max(11, ...records.map((r) => r.description.length)),
246
+ resolved_at: 10,
247
+ verified: 8,
248
+ };
249
+ const sep = `+-${'-'.repeat(cols.run_id)}-+-${'-'.repeat(cols.fix_action)}-+-${'-'.repeat(cols.description)}-+-${'-'.repeat(cols.resolved_at)}-+-${'-'.repeat(cols.verified)}-+`;
250
+ const header = `| ${'run_id'.padEnd(cols.run_id)} | ${'fix_action'.padEnd(cols.fix_action)} | ${'description'.padEnd(cols.description)} | ${'resolved_at'.padEnd(cols.resolved_at)} | ${'verified?'.padEnd(cols.verified)} |`;
251
+ console.log(sep);
252
+ console.log(header);
253
+ console.log(sep);
254
+ for (const r of records) {
255
+ const desc = r.description.length > cols.description ? r.description.slice(0, cols.description - 1) + '…' : r.description;
256
+ const line = `| ${r.run_id.padEnd(cols.run_id)} | ${r.fix_action.padEnd(cols.fix_action)} | ${desc.padEnd(cols.description)} | ${r.resolved_at.slice(0, 10).padEnd(cols.resolved_at)} | ${(r.is_verified ? 'yes' : 'no').padEnd(cols.verified)} |`;
257
+ console.log(line);
258
+ }
259
+ console.log(sep);
260
+ console.log(`\nTotal: ${records.length} remediation(s)`);
261
+ }
262
+ export async function triageRepo(options) {
263
+ const providerName = options.provider ?? detectProvider();
264
+ const provider = getProvider(providerName);
265
+ const repoContextPromise = fetchRepoContext(options.repo).catch(() => null);
266
+ const canHandle = await provider.canHandle(options.repo);
267
+ if (!canHandle) {
268
+ throw new Error(`Provider ${providerName} cannot handle repo ${options.repo}`);
269
+ }
270
+ const run = await provider.resolveRun(options.repo, options.runId);
271
+ if (!run) {
272
+ throw new Error('No failed runs found.');
273
+ }
274
+ const runRef = { provider: providerName, repo: options.repo, runId: run.id };
275
+ const [logBundle, metadata] = await Promise.all([
276
+ provider.fetchLogs(runRef),
277
+ provider.fetchMetadata(runRef),
278
+ ]);
279
+ const rawLog = logBundle.combined;
280
+ const failures = parseAllFailures(rawLog);
281
+ const classified = failures.map((failure) => ({
282
+ ...failure,
283
+ classification: classify(failure),
284
+ }));
285
+ const runInfo = {
286
+ databaseId: Number(run.id) || 0,
287
+ displayTitle: run.displayTitle,
288
+ workflowName: run.workflowName,
289
+ conclusion: run.conclusion,
290
+ url: run.url,
291
+ };
292
+ const report = buildJsonReport({
293
+ repo: options.repo,
294
+ run: runInfo,
295
+ failures: classified,
296
+ metadata: {
297
+ headSha: metadata.headSha,
298
+ headBranch: metadata.headBranch,
299
+ event: metadata.event,
300
+ },
301
+ });
302
+ const llmEnabled = options.llm || !!process.env['OPENAI_API_KEY'];
303
+ if (llmEnabled) {
304
+ const repoContext = await repoContextPromise;
305
+ const failureEntries = classified.map((f) => ({
306
+ type: f.classification?.type ?? 'unknown',
307
+ error: f.error,
308
+ stack: f.stack?.join('\n'),
309
+ category: f.classification?.category ?? 'unknown',
310
+ severity: f.classification?.severity ?? 'low',
311
+ suggested_fix: f.classification?.suggestedFix ?? '',
312
+ fix_action: f.classification?.fixAction ?? 'unknown',
313
+ flaky: { is_flaky: false, confidence: 0, pass_rate_7d: 1, last_5_runs: [] },
314
+ }));
315
+ const analysis = await analyzeLlm(failureEntries, rawLog, {
316
+ model: options.llmModel,
317
+ enabled: llmEnabled,
318
+ repoContext,
319
+ });
320
+ report.analysis = analysis;
321
+ }
322
+ try {
323
+ const testMap = {};
324
+ for (const f of classified) {
325
+ if (f.stepName)
326
+ testMap[f.stepName] = 'fail';
327
+ }
328
+ persistRun(options.repo, String(run.id), new Date().toISOString(), testMap);
329
+ }
330
+ catch {
331
+ // non-fatal
332
+ }
333
+ return report;
334
+ }
335
+ async function runMultiCommand(options) {
336
+ const reports = [];
337
+ const repos = options.repos ?? [];
338
+ for (const repo of repos) {
339
+ try {
340
+ const report = await triageRepo({
341
+ repo,
342
+ provider: options.provider,
343
+ llm: options.llm,
344
+ llmModel: options.llmModel,
345
+ });
346
+ reports.push(report);
347
+ }
348
+ catch (err) {
349
+ const message = err instanceof Error ? err.message : String(err);
350
+ console.error(`Failed to triage ${repo}: ${message}`);
351
+ }
352
+ }
353
+ const patterns = detectSharedPatterns(reports);
354
+ if (options.outputJson) {
355
+ process.stdout.write(buildMultiJson(reports, patterns));
356
+ return;
357
+ }
358
+ process.stdout.write(formatMultiSummary(reports, patterns));
359
+ }
99
360
  async function main() {
100
361
  const options = parseArgs(process.argv);
101
362
  // Handle subcommands
@@ -103,9 +364,36 @@ async function main() {
103
364
  await runFlakesCommand(options.repo);
104
365
  return;
105
366
  }
367
+ if (options.subcommand === 'db-prune') {
368
+ const days = options.dbPruneDays ?? 90;
369
+ const deleted = pruneRuns(days);
370
+ console.log(`Pruned ${deleted} run(s) older than ${days} days from ~/.ci-triage/flake.db`);
371
+ return;
372
+ }
373
+ if (options.subcommand === 'db-stats') {
374
+ await runDbStatsCommand(options.dbStatRepo, options.outputJson);
375
+ return;
376
+ }
377
+ if (options.subcommand === 'resolve') {
378
+ runResolveCommand(options.repo, options.resolveRunId, options.resolveDescription, options.resolveCommit);
379
+ return;
380
+ }
381
+ if (options.subcommand === 'verify') {
382
+ runVerifyCommand(options.repo, options.verifyRunId, options.cleanRunId);
383
+ return;
384
+ }
385
+ if (options.subcommand === 'history') {
386
+ runHistoryCommand(options.historyRepo, options.outputJson);
387
+ return;
388
+ }
389
+ if (options.subcommand === 'multi') {
390
+ await runMultiCommand(options);
391
+ return;
392
+ }
106
393
  // Resolve provider
107
394
  const providerName = options.provider ?? detectProvider();
108
395
  const provider = getProvider(providerName);
396
+ const repoContextPromise = fetchRepoContext(options.repo).catch(() => null);
109
397
  const canHandle = await provider.canHandle(options.repo);
110
398
  if (!canHandle) {
111
399
  process.exit(1);
@@ -130,7 +418,7 @@ async function main() {
130
418
  provider.fetchMetadata(runRef),
131
419
  ]);
132
420
  const rawLog = logBundle.combined;
133
- const failures = parseFailures(rawLog);
421
+ const failures = parseAllFailures(rawLog);
134
422
  const classified = failures.map((failure) => ({
135
423
  ...failure,
136
424
  classification: classify(failure),
@@ -156,6 +444,7 @@ async function main() {
156
444
  // LLM analysis (gated)
157
445
  const llmEnabled = options.llm || !!process.env['OPENAI_API_KEY'];
158
446
  if (llmEnabled) {
447
+ const repoContext = await repoContextPromise;
159
448
  const failureEntries = classified.map((f) => ({
160
449
  type: f.classification?.type ?? 'unknown',
161
450
  error: f.error,
@@ -167,7 +456,8 @@ async function main() {
167
456
  }));
168
457
  const analysis = await analyzeLlm(failureEntries, rawLog, {
169
458
  model: options.llmModel,
170
- enabled: true,
459
+ enabled: llmEnabled,
460
+ repoContext,
171
461
  });
172
462
  report.analysis = analysis;
173
463
  }
@@ -183,11 +473,32 @@ async function main() {
183
473
  catch {
184
474
  // non-fatal
185
475
  }
476
+ // Surface remediation status (best-effort)
477
+ try {
478
+ const remediations = getRemediations(options.repo);
479
+ const currentRunStr = String(run.id);
480
+ const currentResolved = remediations.find((r) => r.run_id === currentRunStr);
481
+ if (currentResolved) {
482
+ console.log(`✓ This run was previously marked as resolved`);
483
+ }
484
+ const unresolved = remediations.filter((r) => r.run_id !== currentRunStr && !r.is_verified);
485
+ if (unresolved.length > 0) {
486
+ console.log(`⚠ ${unresolved.length} previously unresolved run(s) for this repo`);
487
+ }
488
+ }
489
+ catch {
490
+ // non-fatal
491
+ }
186
492
  if (options.outputJson) {
187
493
  process.stdout.write(toJson(report));
188
494
  }
189
495
  else {
190
496
  process.stdout.write(toConsoleText(report));
497
+ const repoContext = await repoContextPromise;
498
+ const repoHint = buildRepoContextHint(repoContext, classified.map((f) => ({ stepName: f.stepName, error: f.error })));
499
+ if (repoHint) {
500
+ console.log(repoHint);
501
+ }
191
502
  // Print LLM analysis summary if available
192
503
  if (report.analysis?.mode === 'llm') {
193
504
  console.log('\n── LLM Root-Cause Analysis ─────────────────────────────');
@@ -208,6 +519,8 @@ async function main() {
208
519
  console.log('─────────────────────────────────────────────────────────\n');
209
520
  }
210
521
  }
522
+ const llmRan = report.analysis?.mode === 'llm';
523
+ emitEmptyOutputWarning(report.summary.total_failures, llmRan);
211
524
  if (options.markdownPath) {
212
525
  writeFileSync(options.markdownPath, toMarkdown(report), 'utf8');
213
526
  if (!options.outputJson) {
@@ -215,7 +528,9 @@ async function main() {
215
528
  }
216
529
  }
217
530
  }
218
- main().catch((err) => {
219
- console.error(err instanceof Error ? err.message : String(err));
220
- process.exit(1);
221
- });
531
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
532
+ main().catch((err) => {
533
+ console.error(err instanceof Error ? err.message : String(err));
534
+ process.exit(1);
535
+ });
536
+ }
@@ -10,13 +10,32 @@ function estimateCost(model, inputTokens, outputTokens) {
10
10
  const pricing = PRICING_PER_1M[model] ?? { input: 0.40, output: 1.60 };
11
11
  return (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output;
12
12
  }
13
- function buildPrompt(failures, logExcerpt) {
13
+ function formatRepoContext(context) {
14
+ const visibilityLine = `- Visibility: ${context.visibility}${context.plan ? ` (${context.plan} plan)` : ''}`;
15
+ const pagesLine = `- GitHub Pages: ${context.pagesEnabled ? 'enabled' : 'not enabled'}`;
16
+ const scanningLine = `- Code scanning: ${context.hasCodeScanning ? 'enabled' : 'not enabled'}`;
17
+ const workflowsLine = `- Workflow files: ${context.workflowFiles.length ? context.workflowFiles.join(', ') : 'none found'}`;
18
+ const scopesLine = `- Token scopes: ${context.tokenScopes.length ? context.tokenScopes.join(', ') : 'none detected'}`;
19
+ return [
20
+ 'Repository context:',
21
+ visibilityLine,
22
+ pagesLine,
23
+ scanningLine,
24
+ workflowsLine,
25
+ scopesLine,
26
+ '',
27
+ 'Given this context, consider whether workflow files should be removed rather than fixed.',
28
+ '',
29
+ ].join('\n');
30
+ }
31
+ export function buildPrompt(failures, logExcerpt, repoContext = null) {
14
32
  const failureSummary = failures
15
33
  .slice(0, 5)
16
34
  .map((f) => `- [${f.category}] ${f.error}${f.stack ? `\n Stack: ${f.stack.slice(0, 300)}` : ''}`)
17
35
  .join('\n');
18
36
  const logSnippet = logExcerpt.slice(-3000); // last 3k chars of log
19
- return `You are a CI failure analyst. Given the following CI failures and log excerpt, provide:
37
+ const contextPrefix = repoContext ? `${formatRepoContext(repoContext)}\n` : '';
38
+ return `${contextPrefix}You are a CI failure analyst. Given the following CI failures and log excerpt, provide:
20
39
  1. A concise root cause (1-2 sentences)
21
40
  2. Up to 3 specific fix suggestions
22
41
 
@@ -40,7 +59,7 @@ export async function analyzeLlm(failures, logExcerpt, options = {}) {
40
59
  }
41
60
  const client = new OpenAI({ apiKey: process.env['OPENAI_API_KEY'] });
42
61
  try {
43
- const prompt = buildPrompt(failures, logExcerpt);
62
+ const prompt = buildPrompt(failures, logExcerpt, options.repoContext ?? null);
44
63
  const response = await client.responses.create({
45
64
  model,
46
65
  input: prompt,
package/dist/multi.js ADDED
@@ -0,0 +1,116 @@
1
+ function reportFailures(report) {
2
+ const failures = [];
3
+ for (const job of report.jobs) {
4
+ for (const step of job.steps ?? []) {
5
+ failures.push(...step.failures);
6
+ }
7
+ }
8
+ return failures;
9
+ }
10
+ function normalizeError(error) {
11
+ return error.replace(/\s+/g, ' ').trim();
12
+ }
13
+ export function detectSharedPatterns(reports) {
14
+ const byFixAction = new Map();
15
+ const byCategory = new Map();
16
+ const byErrorSubstring = new Map();
17
+ for (const report of reports) {
18
+ const failures = reportFailures(report);
19
+ const repoFixActions = new Set();
20
+ const repoCategories = new Set();
21
+ const repoErrorSubstrings = new Set();
22
+ for (const failure of failures) {
23
+ repoFixActions.add(failure.fix_action ?? 'unknown');
24
+ repoCategories.add(failure.category);
25
+ const normalized = normalizeError(failure.error);
26
+ if (normalized.length > 20) {
27
+ repoErrorSubstrings.add(normalized.slice(0, 120));
28
+ }
29
+ }
30
+ for (const action of repoFixActions) {
31
+ const repos = byFixAction.get(action) ?? new Set();
32
+ repos.add(report.repo);
33
+ byFixAction.set(action, repos);
34
+ }
35
+ for (const category of repoCategories) {
36
+ const repos = byCategory.get(category) ?? new Set();
37
+ repos.add(report.repo);
38
+ byCategory.set(category, repos);
39
+ }
40
+ for (const substring of repoErrorSubstrings) {
41
+ const repos = byErrorSubstring.get(substring) ?? new Set();
42
+ repos.add(report.repo);
43
+ byErrorSubstring.set(substring, repos);
44
+ }
45
+ }
46
+ const patterns = [];
47
+ for (const [fix_action, repos] of byFixAction) {
48
+ if (repos.size >= 2) {
49
+ patterns.push({
50
+ fix_action,
51
+ repos: [...repos].sort(),
52
+ description: `Common fix action across repos: ${fix_action}`,
53
+ });
54
+ }
55
+ }
56
+ for (const [category, repos] of byCategory) {
57
+ if (repos.size >= 2) {
58
+ patterns.push({
59
+ category,
60
+ repos: [...repos].sort(),
61
+ description: `Common category across repos: ${category}`,
62
+ });
63
+ }
64
+ }
65
+ for (const [error_substring, repos] of byErrorSubstring) {
66
+ if (repos.size >= 2) {
67
+ patterns.push({
68
+ error_substring,
69
+ repos: [...repos].sort(),
70
+ description: `Common error text across repos: ${error_substring}`,
71
+ });
72
+ }
73
+ }
74
+ return patterns;
75
+ }
76
+ export function formatMultiSummary(reports, patterns) {
77
+ const lines = [`Multi-repo triage: ${reports.length} repos`, ''];
78
+ if (patterns.length === 0) {
79
+ lines.push('Shared patterns detected: none', '');
80
+ }
81
+ else {
82
+ lines.push('Shared patterns detected:');
83
+ for (const pattern of patterns) {
84
+ const repos = pattern.repos.map((repo) => repo.split('/').at(-1) ?? repo).join(', ');
85
+ if (pattern.fix_action) {
86
+ lines.push(` [${pattern.fix_action}] ${pattern.repos.length} repos affected: ${repos}`);
87
+ }
88
+ else if (pattern.category) {
89
+ lines.push(` [${pattern.category}] ${pattern.repos.length} repos affected: ${repos}`);
90
+ }
91
+ else if (pattern.error_substring) {
92
+ lines.push(` [error] ${pattern.repos.length} repos affected: ${repos}`);
93
+ }
94
+ lines.push(` -> ${pattern.description}`);
95
+ }
96
+ lines.push('');
97
+ }
98
+ lines.push('Per-repo:');
99
+ for (const report of reports) {
100
+ const failures = reportFailures(report);
101
+ const actionCounts = new Map();
102
+ for (const failure of failures) {
103
+ const action = failure.fix_action ?? 'unknown';
104
+ actionCounts.set(action, (actionCounts.get(action) ?? 0) + 1);
105
+ }
106
+ const actionSummary = [...actionCounts.entries()]
107
+ .sort((a, b) => b[1] - a[1])
108
+ .map(([action, count]) => `${action} x${count}`)
109
+ .join(', ');
110
+ lines.push(` ${report.repo} - ${failures.length} failures${actionSummary ? ` (${actionSummary})` : ''}`);
111
+ }
112
+ return `${lines.join('\n')}\n`;
113
+ }
114
+ export function buildMultiJson(reports, patterns) {
115
+ return `${JSON.stringify({ repos: reports, patterns }, null, 2)}\n`;
116
+ }