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/CHANGELOG.md +42 -0
- package/README.md +98 -5
- package/action.yml +21 -1
- package/dist/action.js +78 -63
- package/dist/classifier.js +70 -0
- package/dist/flake-store.js +106 -1
- package/dist/index.js +347 -11
- package/dist/llm-analyzer.js +22 -3
- package/dist/multi.js +116 -0
- package/dist/parser.js +117 -0
- package/dist/parsers/deploy-pages-failure.js +33 -0
- package/dist/parsers/http-error.js +13 -0
- package/dist/parsers/index.js +9 -0
- package/dist/parsers/shell-failure.js +14 -0
- package/dist/parsers/types.js +1 -0
- package/dist/providers/index.js +10 -2
- package/dist/repo-context.js +97 -0
- package/dist/reporter.js +1 -0
- package/package.json +3 -2
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 {
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
}
|
package/dist/llm-analyzer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|