ci-triage 0.2.0 → 0.3.1

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,16 @@
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';
13
+ import { startMcpServer } from './mcp-server.js';
10
14
  // --version support
11
15
  const _require = createRequire(import.meta.url);
12
16
  let pkgVersion = 'unknown';
@@ -23,20 +27,48 @@ function usage() {
23
27
  '',
24
28
  'Usage:',
25
29
  ' ci-triage owner/repo [limit] [--run <id>] [--md report.md] [--json]',
30
+ ' ci-triage multi owner/repo1 owner/repo2 owner/repo3 [--json]',
26
31
  ' ci-triage flakes owner/repo',
32
+ ' ci-triage db prune [--days 90]',
33
+ ' ci-triage db stats [owner/repo] [--json]',
34
+ ' ci-triage resolve <owner/repo> <run-id> "<description>" [--commit <sha>]',
35
+ ' ci-triage verify <owner/repo> <run-id> --clean-run <clean-run-id>',
36
+ ' ci-triage history <owner/repo> [--json]',
27
37
  '',
28
38
  'Options:',
29
39
  ' --run <id> Triage a specific run ID',
30
40
  ' --md <path> Write markdown report to file',
31
41
  ' --json Output JSON instead of human text',
32
42
  ' --provider <p> Force CI provider: github | gitlab | circleci',
33
- ' --llm Enable LLM root-cause analysis (requires OPENAI_API_KEY)',
43
+ ' --llm Enable LLM root-cause analysis (auto-on when OPENAI_API_KEY is set)',
44
+ ' --no-llm Disable LLM even when OPENAI_API_KEY is set (alias: CI_TRIAGE_NO_LLM=1)',
34
45
  ' --llm-model <model> Override LLM model (default: gpt-4.1-mini)',
46
+ ' --mcp Start MCP server (stdio transport) for agent tool use',
35
47
  ' --version Print version and exit',
36
48
  ].join('\n'));
37
49
  process.exit(1);
38
50
  }
39
- function parseArgs(argv) {
51
+ export const EMPTY_OUTPUT_WARNING = 'Warning: No structured failures detected. The log may contain infra/script failures\n' +
52
+ "that the heuristic parser doesn't recognize. Re-run with OPENAI_API_KEY set or\n" +
53
+ 'pass --llm for root-cause analysis.\n';
54
+ export function shouldWarnOnEmptyOutput(totalFailures, llmRan) {
55
+ return totalFailures === 0 && !llmRan;
56
+ }
57
+ export function emitEmptyOutputWarning(totalFailures, llmRan) {
58
+ if (shouldWarnOnEmptyOutput(totalFailures, llmRan)) {
59
+ process.stderr.write(EMPTY_OUTPUT_WARNING);
60
+ }
61
+ }
62
+ function buildRepoContextHint(repoContext, failures) {
63
+ if (!repoContext)
64
+ return null;
65
+ const pagesFailure = failures.some((f) => /pages|deploy-pages|github pages/i.test(`${f.stepName} ${f.error}`));
66
+ if (pagesFailure && repoContext.private && !repoContext.pagesEnabled) {
67
+ return '(private repo, Pages not enabled)';
68
+ }
69
+ return null;
70
+ }
71
+ export function parseArgs(argv) {
40
72
  const args = argv.slice(2);
41
73
  // --version
42
74
  if (args.includes('--version') || args.includes('-v')) {
@@ -52,6 +84,77 @@ function parseArgs(argv) {
52
84
  }
53
85
  return { repo, limit: 0, outputJson: false, subcommand: 'flakes' };
54
86
  }
87
+ // subcommand: ci-triage multi owner/repo1 owner/repo2 [--json]
88
+ if (args[0] === 'multi') {
89
+ const repos = args.slice(1).filter((arg) => !arg.startsWith('--'));
90
+ if (repos.length === 0 ||
91
+ repos.some((repo) => !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(repo))) {
92
+ console.error('Usage: ci-triage multi owner/repo1 owner/repo2 owner/repo3 [--json]');
93
+ process.exit(1);
94
+ }
95
+ return { repo: repos[0], repos, limit: 0, outputJson: args.includes('--json'), subcommand: 'multi' };
96
+ }
97
+ // subcommand: ci-triage db prune [--days 90]
98
+ if (args[0] === 'db' && args[1] === 'prune') {
99
+ const daysIdx = args.indexOf('--days');
100
+ const days = daysIdx >= 0 ? Number(args[daysIdx + 1]) : 90;
101
+ return {
102
+ repo: '',
103
+ limit: 0,
104
+ outputJson: args.includes('--json'),
105
+ subcommand: 'db-prune',
106
+ dbPruneDays: isFinite(days) ? days : 90,
107
+ };
108
+ }
109
+ // subcommand: ci-triage db stats [owner/repo]
110
+ if (args[0] === 'db' && args[1] === 'stats') {
111
+ const repo = args[2];
112
+ if (repo && !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(repo)) {
113
+ console.error('Usage: ci-triage db stats [owner/repo]');
114
+ process.exit(1);
115
+ }
116
+ return {
117
+ repo: '',
118
+ limit: 0,
119
+ outputJson: args.includes('--json'),
120
+ subcommand: 'db-stats',
121
+ dbStatRepo: repo,
122
+ };
123
+ }
124
+ // subcommand: ci-triage resolve <owner/repo> <run-id> "<description>" [--commit <sha>]
125
+ if (args[0] === 'resolve') {
126
+ const repo = args[1];
127
+ const resolveRunId = args[2];
128
+ const resolveDescription = args[3];
129
+ if (!repo || !resolveRunId || !resolveDescription) {
130
+ console.error('Usage: ci-triage resolve owner/repo run-id "description" [--commit <sha>]');
131
+ process.exit(1);
132
+ }
133
+ const commitIdx = args.indexOf('--commit');
134
+ const resolveCommit = commitIdx >= 0 ? args[commitIdx + 1] : undefined;
135
+ return { repo, limit: 0, outputJson: false, subcommand: 'resolve', resolveRunId, resolveDescription, resolveCommit };
136
+ }
137
+ // subcommand: ci-triage verify <owner/repo> <run-id> --clean-run <clean-run-id>
138
+ if (args[0] === 'verify') {
139
+ const repo = args[1];
140
+ const verifyRunId = args[2];
141
+ const cleanRunIdx = args.indexOf('--clean-run');
142
+ const cleanRunId = cleanRunIdx >= 0 ? args[cleanRunIdx + 1] : undefined;
143
+ if (!repo || !verifyRunId || !cleanRunId) {
144
+ console.error('Usage: ci-triage verify owner/repo run-id --clean-run clean-run-id');
145
+ process.exit(1);
146
+ }
147
+ return { repo, limit: 0, outputJson: false, subcommand: 'verify', verifyRunId, cleanRunId };
148
+ }
149
+ // subcommand: ci-triage history <owner/repo> [--json]
150
+ if (args[0] === 'history') {
151
+ const historyRepo = args[1];
152
+ if (!historyRepo || !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(historyRepo)) {
153
+ console.error('Usage: ci-triage history owner/repo [--json]');
154
+ process.exit(1);
155
+ }
156
+ return { repo: '', limit: 0, outputJson: args.includes('--json'), subcommand: 'history', historyRepo };
157
+ }
55
158
  const repo = args[0];
56
159
  if (!repo || !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(repo)) {
57
160
  usage();
@@ -76,6 +179,8 @@ function parseArgs(argv) {
76
179
  outputJson: args.includes('--json'),
77
180
  provider,
78
181
  llm: args.includes('--llm'),
182
+ noLlm: args.includes('--no-llm'),
183
+ mcp: args.includes('--mcp'),
79
184
  llmModel,
80
185
  };
81
186
  }
@@ -96,16 +201,215 @@ async function runFlakesCommand(repo) {
96
201
  }
97
202
  console.log(`\nTotal flaky tests: ${flakes.length}`);
98
203
  }
204
+ async function runDbStatsCommand(repo, outputJson) {
205
+ const stats = getDbStats(repo);
206
+ if (outputJson) {
207
+ process.stdout.write(`${JSON.stringify(stats, null, 2)}\n`);
208
+ return;
209
+ }
210
+ const rows = [
211
+ ['scope', repo ?? 'all repos'],
212
+ ['run_count', String(stats.run_count)],
213
+ ['test_result_count', String(stats.test_result_count)],
214
+ ['flaky_test_count', String(stats.flaky_test_count)],
215
+ ['db_path', stats.db_path],
216
+ ['db_size_bytes', String(stats.db_size_bytes)],
217
+ ];
218
+ const keyWidth = Math.max(...rows.map(([k]) => k.length));
219
+ const valueWidth = Math.max(...rows.map(([, v]) => v.length));
220
+ const border = `+${'-'.repeat(keyWidth + 2)}+${'-'.repeat(valueWidth + 2)}+`;
221
+ console.log(border);
222
+ console.log(`| ${'metric'.padEnd(keyWidth)} | ${'value'.padEnd(valueWidth)} |`);
223
+ console.log(border);
224
+ for (const [key, value] of rows) {
225
+ console.log(`| ${key.padEnd(keyWidth)} | ${value.padEnd(valueWidth)} |`);
226
+ }
227
+ console.log(border);
228
+ }
229
+ function runResolveCommand(repo, runId, description, commit) {
230
+ markResolved(repo, runId, 'manual', description, commit);
231
+ console.log(`✓ Marked run ${runId} as resolved: ${description}`);
232
+ }
233
+ function runVerifyCommand(repo, runId, cleanRunId) {
234
+ markVerified(repo, runId, cleanRunId);
235
+ console.log(`✓ Verified fix: run ${runId} resolved by clean run ${cleanRunId}`);
236
+ }
237
+ function runHistoryCommand(repo, outputJson) {
238
+ const records = getRemediations(repo);
239
+ if (outputJson) {
240
+ process.stdout.write(`${JSON.stringify(records, null, 2)}\n`);
241
+ return;
242
+ }
243
+ if (records.length === 0) {
244
+ console.log(`No remediation history found for ${repo}.`);
245
+ return;
246
+ }
247
+ const cols = {
248
+ run_id: Math.max(6, ...records.map((r) => r.run_id.length)),
249
+ fix_action: Math.max(10, ...records.map((r) => r.fix_action.length)),
250
+ description: Math.max(11, ...records.map((r) => r.description.length)),
251
+ resolved_at: 10,
252
+ verified: 8,
253
+ };
254
+ const sep = `+-${'-'.repeat(cols.run_id)}-+-${'-'.repeat(cols.fix_action)}-+-${'-'.repeat(cols.description)}-+-${'-'.repeat(cols.resolved_at)}-+-${'-'.repeat(cols.verified)}-+`;
255
+ 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)} |`;
256
+ console.log(sep);
257
+ console.log(header);
258
+ console.log(sep);
259
+ for (const r of records) {
260
+ const desc = r.description.length > cols.description ? r.description.slice(0, cols.description - 1) + '…' : r.description;
261
+ 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)} |`;
262
+ console.log(line);
263
+ }
264
+ console.log(sep);
265
+ console.log(`\nTotal: ${records.length} remediation(s)`);
266
+ }
267
+ export async function triageRepo(options) {
268
+ const providerName = options.provider ?? detectProvider();
269
+ const provider = getProvider(providerName);
270
+ const repoContextPromise = fetchRepoContext(options.repo).catch(() => null);
271
+ const canHandle = await provider.canHandle(options.repo);
272
+ if (!canHandle) {
273
+ throw new Error(`Provider ${providerName} cannot handle repo ${options.repo}`);
274
+ }
275
+ const run = await provider.resolveRun(options.repo, options.runId);
276
+ if (!run) {
277
+ throw new Error('No failed runs found.');
278
+ }
279
+ const runRef = { provider: providerName, repo: options.repo, runId: run.id };
280
+ const [logBundle, metadata] = await Promise.all([
281
+ provider.fetchLogs(runRef),
282
+ provider.fetchMetadata(runRef),
283
+ ]);
284
+ const rawLog = logBundle.combined;
285
+ const failures = parseAllFailures(rawLog);
286
+ const classified = failures.map((failure) => ({
287
+ ...failure,
288
+ classification: classify(failure),
289
+ }));
290
+ const runInfo = {
291
+ databaseId: Number(run.id) || 0,
292
+ displayTitle: run.displayTitle,
293
+ workflowName: run.workflowName,
294
+ conclusion: run.conclusion,
295
+ url: run.url,
296
+ };
297
+ const report = buildJsonReport({
298
+ repo: options.repo,
299
+ run: runInfo,
300
+ failures: classified,
301
+ metadata: {
302
+ headSha: metadata.headSha,
303
+ headBranch: metadata.headBranch,
304
+ event: metadata.event,
305
+ },
306
+ });
307
+ const noLlm = options.noLlm || !!process.env['CI_TRIAGE_NO_LLM'];
308
+ const autoLlm = !noLlm && !!process.env['OPENAI_API_KEY'];
309
+ if (autoLlm && !options.llm && !noLlm) {
310
+ process.stderr.write('ℹ using LLM analysis (set CI_TRIAGE_NO_LLM=1 or pass --no-llm to disable)\n');
311
+ }
312
+ const llmEnabled = !noLlm && (options.llm || !!process.env['OPENAI_API_KEY']);
313
+ if (llmEnabled) {
314
+ const repoContext = await repoContextPromise;
315
+ const failureEntries = classified.map((f) => ({
316
+ type: f.classification?.type ?? 'unknown',
317
+ error: f.error,
318
+ stack: f.stack?.join('\n'),
319
+ category: f.classification?.category ?? 'unknown',
320
+ severity: f.classification?.severity ?? 'low',
321
+ suggested_fix: f.classification?.suggestedFix ?? '',
322
+ fix_action: f.classification?.fixAction ?? 'unknown',
323
+ flaky: { is_flaky: false, confidence: 0, pass_rate_7d: 1, last_5_runs: [] },
324
+ }));
325
+ const analysis = await analyzeLlm(failureEntries, rawLog, {
326
+ model: options.llmModel,
327
+ enabled: llmEnabled,
328
+ repoContext,
329
+ });
330
+ report.analysis = analysis;
331
+ }
332
+ try {
333
+ const testMap = {};
334
+ for (const f of classified) {
335
+ if (f.stepName)
336
+ testMap[f.stepName] = 'fail';
337
+ }
338
+ persistRun(options.repo, String(run.id), new Date().toISOString(), testMap);
339
+ }
340
+ catch {
341
+ // non-fatal
342
+ }
343
+ return report;
344
+ }
345
+ async function runMultiCommand(options) {
346
+ const reports = [];
347
+ const repos = options.repos ?? [];
348
+ for (const repo of repos) {
349
+ try {
350
+ const report = await triageRepo({
351
+ repo,
352
+ provider: options.provider,
353
+ llm: options.llm,
354
+ noLlm: options.noLlm,
355
+ llmModel: options.llmModel,
356
+ });
357
+ reports.push(report);
358
+ }
359
+ catch (err) {
360
+ const message = err instanceof Error ? err.message : String(err);
361
+ console.error(`Failed to triage ${repo}: ${message}`);
362
+ }
363
+ }
364
+ const patterns = detectSharedPatterns(reports);
365
+ if (options.outputJson) {
366
+ process.stdout.write(buildMultiJson(reports, patterns));
367
+ return;
368
+ }
369
+ process.stdout.write(formatMultiSummary(reports, patterns));
370
+ }
99
371
  async function main() {
100
372
  const options = parseArgs(process.argv);
373
+ // --mcp: start MCP server in stdio mode
374
+ if (options.mcp) {
375
+ await startMcpServer();
376
+ return;
377
+ }
101
378
  // Handle subcommands
102
379
  if (options.subcommand === 'flakes') {
103
380
  await runFlakesCommand(options.repo);
104
381
  return;
105
382
  }
383
+ if (options.subcommand === 'db-prune') {
384
+ const days = options.dbPruneDays ?? 90;
385
+ const deleted = pruneRuns(days);
386
+ console.log(`Pruned ${deleted} run(s) older than ${days} days from ~/.ci-triage/flake.db`);
387
+ return;
388
+ }
389
+ if (options.subcommand === 'db-stats') {
390
+ await runDbStatsCommand(options.dbStatRepo, options.outputJson);
391
+ return;
392
+ }
393
+ if (options.subcommand === 'resolve') {
394
+ runResolveCommand(options.repo, options.resolveRunId, options.resolveDescription, options.resolveCommit);
395
+ return;
396
+ }
397
+ if (options.subcommand === 'verify') {
398
+ runVerifyCommand(options.repo, options.verifyRunId, options.cleanRunId);
399
+ return;
400
+ }
401
+ if (options.subcommand === 'history') {
402
+ runHistoryCommand(options.historyRepo, options.outputJson);
403
+ return;
404
+ }
405
+ if (options.subcommand === 'multi') {
406
+ await runMultiCommand(options);
407
+ return;
408
+ }
106
409
  // Resolve provider
107
410
  const providerName = options.provider ?? detectProvider();
108
411
  const provider = getProvider(providerName);
412
+ const repoContextPromise = fetchRepoContext(options.repo).catch(() => null);
109
413
  const canHandle = await provider.canHandle(options.repo);
110
414
  if (!canHandle) {
111
415
  process.exit(1);
@@ -130,7 +434,7 @@ async function main() {
130
434
  provider.fetchMetadata(runRef),
131
435
  ]);
132
436
  const rawLog = logBundle.combined;
133
- const failures = parseFailures(rawLog);
437
+ const failures = parseAllFailures(rawLog);
134
438
  const classified = failures.map((failure) => ({
135
439
  ...failure,
136
440
  classification: classify(failure),
@@ -154,8 +458,14 @@ async function main() {
154
458
  },
155
459
  });
156
460
  // LLM analysis (gated)
157
- const llmEnabled = options.llm || !!process.env['OPENAI_API_KEY'];
461
+ const noLlm = options.noLlm || !!process.env['CI_TRIAGE_NO_LLM'];
462
+ const autoLlm = !noLlm && !!process.env['OPENAI_API_KEY'];
463
+ if (autoLlm && !options.llm && !noLlm) {
464
+ process.stderr.write('ℹ using LLM analysis (set CI_TRIAGE_NO_LLM=1 or pass --no-llm to disable)\n');
465
+ }
466
+ const llmEnabled = !noLlm && (options.llm || !!process.env['OPENAI_API_KEY']);
158
467
  if (llmEnabled) {
468
+ const repoContext = await repoContextPromise;
159
469
  const failureEntries = classified.map((f) => ({
160
470
  type: f.classification?.type ?? 'unknown',
161
471
  error: f.error,
@@ -167,7 +477,8 @@ async function main() {
167
477
  }));
168
478
  const analysis = await analyzeLlm(failureEntries, rawLog, {
169
479
  model: options.llmModel,
170
- enabled: true,
480
+ enabled: llmEnabled,
481
+ repoContext,
171
482
  });
172
483
  report.analysis = analysis;
173
484
  }
@@ -183,11 +494,32 @@ async function main() {
183
494
  catch {
184
495
  // non-fatal
185
496
  }
497
+ // Surface remediation status (best-effort)
498
+ try {
499
+ const remediations = getRemediations(options.repo);
500
+ const currentRunStr = String(run.id);
501
+ const currentResolved = remediations.find((r) => r.run_id === currentRunStr);
502
+ if (currentResolved) {
503
+ console.log(`✓ This run was previously marked as resolved`);
504
+ }
505
+ const unresolved = remediations.filter((r) => r.run_id !== currentRunStr && !r.is_verified);
506
+ if (unresolved.length > 0) {
507
+ console.log(`⚠ ${unresolved.length} previously unresolved run(s) for this repo`);
508
+ }
509
+ }
510
+ catch {
511
+ // non-fatal
512
+ }
186
513
  if (options.outputJson) {
187
514
  process.stdout.write(toJson(report));
188
515
  }
189
516
  else {
190
517
  process.stdout.write(toConsoleText(report));
518
+ const repoContext = await repoContextPromise;
519
+ const repoHint = buildRepoContextHint(repoContext, classified.map((f) => ({ stepName: f.stepName, error: f.error })));
520
+ if (repoHint) {
521
+ console.log(repoHint);
522
+ }
191
523
  // Print LLM analysis summary if available
192
524
  if (report.analysis?.mode === 'llm') {
193
525
  console.log('\n── LLM Root-Cause Analysis ─────────────────────────────');
@@ -208,6 +540,8 @@ async function main() {
208
540
  console.log('─────────────────────────────────────────────────────────\n');
209
541
  }
210
542
  }
543
+ const llmRan = report.analysis?.mode === 'llm';
544
+ emitEmptyOutputWarning(report.summary.total_failures, llmRan);
211
545
  if (options.markdownPath) {
212
546
  writeFileSync(options.markdownPath, toMarkdown(report), 'utf8');
213
547
  if (!options.outputJson) {
@@ -215,7 +549,9 @@ async function main() {
215
549
  }
216
550
  }
217
551
  }
218
- main().catch((err) => {
219
- console.error(err instanceof Error ? err.message : String(err));
220
- process.exit(1);
221
- });
552
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
553
+ main().catch((err) => {
554
+ console.error(err instanceof Error ? err.message : String(err));
555
+ process.exit(1);
556
+ });
557
+ }
@@ -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
+ }