ci-triage 0.3.1 → 0.3.2
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 +86 -11
- package/dist/multi.js +56 -17
- package/dist/providers/circleci.js +1 -0
- package/dist/providers/github.js +2 -1
- package/dist/providers/gitlab.js +2 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { getProvider, detectProvider } from './providers/index.js';
|
|
|
9
9
|
import { getDbStats, getFlakes, getRemediations, markResolved, markVerified, persistRun, pruneRuns } from './flake-store.js';
|
|
10
10
|
import { analyzeLlm } from './llm-analyzer.js';
|
|
11
11
|
import { fetchRepoContext } from './repo-context.js';
|
|
12
|
-
import { buildMultiJson, detectSharedPatterns, formatMultiSummary } from './multi.js';
|
|
12
|
+
import { buildMultiJson, detectSharedPatterns, filterReportsSince, formatMultiSummary } from './multi.js';
|
|
13
13
|
import { startMcpServer } from './mcp-server.js';
|
|
14
14
|
// --version support
|
|
15
15
|
const _require = createRequire(import.meta.url);
|
|
@@ -27,10 +27,12 @@ function usage() {
|
|
|
27
27
|
'',
|
|
28
28
|
'Usage:',
|
|
29
29
|
' ci-triage owner/repo [limit] [--run <id>] [--md report.md] [--json]',
|
|
30
|
-
' ci-triage multi owner/repo1
|
|
30
|
+
' ci-triage multi owner/repo1,owner/repo2 [--json] [--since 24h]',
|
|
31
|
+
' ci-triage --multi owner/repo1,owner/repo2 [--json] [--since 24h]',
|
|
31
32
|
' ci-triage flakes owner/repo',
|
|
32
33
|
' ci-triage db prune [--days 90]',
|
|
33
34
|
' ci-triage db stats [owner/repo] [--json]',
|
|
35
|
+
' ci-triage mcp',
|
|
34
36
|
' ci-triage resolve <owner/repo> <run-id> "<description>" [--commit <sha>]',
|
|
35
37
|
' ci-triage verify <owner/repo> <run-id> --clean-run <clean-run-id>',
|
|
36
38
|
' ci-triage history <owner/repo> [--json]',
|
|
@@ -39,6 +41,8 @@ function usage() {
|
|
|
39
41
|
' --run <id> Triage a specific run ID',
|
|
40
42
|
' --md <path> Write markdown report to file',
|
|
41
43
|
' --json Output JSON instead of human text',
|
|
44
|
+
' --multi Run cross-repo triage mode',
|
|
45
|
+
' --since <duration> Multi mode time filter, e.g. 24h, 7d, 30m',
|
|
42
46
|
' --provider <p> Force CI provider: github | gitlab | circleci',
|
|
43
47
|
' --llm Enable LLM root-cause analysis (auto-on when OPENAI_API_KEY is set)',
|
|
44
48
|
' --no-llm Disable LLM even when OPENAI_API_KEY is set (alias: CI_TRIAGE_NO_LLM=1)',
|
|
@@ -59,6 +63,52 @@ export function emitEmptyOutputWarning(totalFailures, llmRan) {
|
|
|
59
63
|
process.stderr.write(EMPTY_OUTPUT_WARNING);
|
|
60
64
|
}
|
|
61
65
|
}
|
|
66
|
+
function positionalArgsExcludingOptionValues(args) {
|
|
67
|
+
const optionsWithValues = new Set([
|
|
68
|
+
'--run',
|
|
69
|
+
'--md',
|
|
70
|
+
'--provider',
|
|
71
|
+
'--llm-model',
|
|
72
|
+
'--days',
|
|
73
|
+
'--commit',
|
|
74
|
+
'--clean-run',
|
|
75
|
+
'--since',
|
|
76
|
+
]);
|
|
77
|
+
const positional = [];
|
|
78
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
79
|
+
const token = args[i];
|
|
80
|
+
if (optionsWithValues.has(token)) {
|
|
81
|
+
i += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (token.startsWith('--'))
|
|
85
|
+
continue;
|
|
86
|
+
positional.push(token);
|
|
87
|
+
}
|
|
88
|
+
return positional;
|
|
89
|
+
}
|
|
90
|
+
function parseSinceHours(raw) {
|
|
91
|
+
if (!raw)
|
|
92
|
+
return undefined;
|
|
93
|
+
const match = raw.match(/^(\d+)([mhd])$/i);
|
|
94
|
+
if (!match)
|
|
95
|
+
return undefined;
|
|
96
|
+
const value = Number(match[1]);
|
|
97
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
98
|
+
return undefined;
|
|
99
|
+
const unit = match[2].toLowerCase();
|
|
100
|
+
if (unit === 'm')
|
|
101
|
+
return value / 60;
|
|
102
|
+
if (unit === 'h')
|
|
103
|
+
return value;
|
|
104
|
+
return value * 24;
|
|
105
|
+
}
|
|
106
|
+
function parseMultiRepos(args) {
|
|
107
|
+
return args
|
|
108
|
+
.flatMap((arg) => arg.split(','))
|
|
109
|
+
.map((repo) => repo.trim())
|
|
110
|
+
.filter((repo) => repo.length > 0);
|
|
111
|
+
}
|
|
62
112
|
function buildRepoContextHint(repoContext, failures) {
|
|
63
113
|
if (!repoContext)
|
|
64
114
|
return null;
|
|
@@ -75,6 +125,10 @@ export function parseArgs(argv) {
|
|
|
75
125
|
process.stdout.write(`ci-triage v${pkgVersion}\n`);
|
|
76
126
|
process.exit(0);
|
|
77
127
|
}
|
|
128
|
+
// subcommand/flag: ci-triage mcp OR ci-triage --mcp
|
|
129
|
+
if (args[0] === 'mcp' || args.includes('--mcp')) {
|
|
130
|
+
return { repo: '', limit: 0, outputJson: false, mcp: true, subcommand: 'mcp' };
|
|
131
|
+
}
|
|
78
132
|
// subcommand: ci-triage flakes owner/repo
|
|
79
133
|
if (args[0] === 'flakes') {
|
|
80
134
|
const repo = args[1];
|
|
@@ -84,15 +138,32 @@ export function parseArgs(argv) {
|
|
|
84
138
|
}
|
|
85
139
|
return { repo, limit: 0, outputJson: false, subcommand: 'flakes' };
|
|
86
140
|
}
|
|
87
|
-
// subcommand: ci-triage multi owner/repo1
|
|
88
|
-
|
|
89
|
-
|
|
141
|
+
// subcommand/flag: ci-triage multi owner/repo1,owner/repo2 [--json] [--since 24h]
|
|
142
|
+
// or: ci-triage --multi owner/repo1,owner/repo2 [--json] [--since 24h]
|
|
143
|
+
if (args[0] === 'multi' || args.includes('--multi')) {
|
|
144
|
+
const sinceIdx = args.indexOf('--since');
|
|
145
|
+
const sinceRaw = sinceIdx >= 0 ? args[sinceIdx + 1] : undefined;
|
|
146
|
+
const sinceHours = parseSinceHours(sinceRaw);
|
|
147
|
+
if (sinceIdx >= 0 && sinceHours === undefined) {
|
|
148
|
+
console.error('Usage: --since <duration>, e.g. 24h, 7d, 30m');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
151
|
+
const multiArgs = args[0] === 'multi' ? args.slice(1) : args;
|
|
152
|
+
const repos = parseMultiRepos(positionalArgsExcludingOptionValues(multiArgs));
|
|
90
153
|
if (repos.length === 0 ||
|
|
91
154
|
repos.some((repo) => !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(repo))) {
|
|
92
|
-
console.error('Usage: ci-triage multi owner/repo1
|
|
155
|
+
console.error('Usage: ci-triage multi owner/repo1,owner/repo2 [--json] [--since 24h]');
|
|
93
156
|
process.exit(1);
|
|
94
157
|
}
|
|
95
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
repo: repos[0],
|
|
160
|
+
repos,
|
|
161
|
+
multi: true,
|
|
162
|
+
sinceHours,
|
|
163
|
+
limit: 0,
|
|
164
|
+
outputJson: args.includes('--json'),
|
|
165
|
+
subcommand: 'multi',
|
|
166
|
+
};
|
|
96
167
|
}
|
|
97
168
|
// subcommand: ci-triage db prune [--days 90]
|
|
98
169
|
if (args[0] === 'db' && args[1] === 'prune') {
|
|
@@ -159,7 +230,7 @@ export function parseArgs(argv) {
|
|
|
159
230
|
if (!repo || !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(repo)) {
|
|
160
231
|
usage();
|
|
161
232
|
}
|
|
162
|
-
const positionalArgs = args.slice(1)
|
|
233
|
+
const positionalArgs = positionalArgsExcludingOptionValues(args.slice(1));
|
|
163
234
|
const parsedLimit = positionalArgs[0] ? Number(positionalArgs[0]) : 10;
|
|
164
235
|
const limit = Math.min(Math.max(Number.isFinite(parsedLimit) ? parsedLimit : 10, 1), 100);
|
|
165
236
|
const runIdx = args.indexOf('--run');
|
|
@@ -304,6 +375,9 @@ export async function triageRepo(options) {
|
|
|
304
375
|
event: metadata.event,
|
|
305
376
|
},
|
|
306
377
|
});
|
|
378
|
+
if (run.createdAt && Number.isFinite(Date.parse(run.createdAt))) {
|
|
379
|
+
report.timestamp = run.createdAt;
|
|
380
|
+
}
|
|
307
381
|
const noLlm = options.noLlm || !!process.env['CI_TRIAGE_NO_LLM'];
|
|
308
382
|
const autoLlm = !noLlm && !!process.env['OPENAI_API_KEY'];
|
|
309
383
|
if (autoLlm && !options.llm && !noLlm) {
|
|
@@ -361,12 +435,13 @@ async function runMultiCommand(options) {
|
|
|
361
435
|
console.error(`Failed to triage ${repo}: ${message}`);
|
|
362
436
|
}
|
|
363
437
|
}
|
|
364
|
-
const
|
|
438
|
+
const filteredReports = filterReportsSince(reports, options.sinceHours);
|
|
439
|
+
const patterns = detectSharedPatterns(filteredReports);
|
|
365
440
|
if (options.outputJson) {
|
|
366
|
-
process.stdout.write(buildMultiJson(
|
|
441
|
+
process.stdout.write(buildMultiJson(filteredReports, patterns));
|
|
367
442
|
return;
|
|
368
443
|
}
|
|
369
|
-
process.stdout.write(formatMultiSummary(
|
|
444
|
+
process.stdout.write(formatMultiSummary(filteredReports, patterns));
|
|
370
445
|
}
|
|
371
446
|
async function main() {
|
|
372
447
|
const options = parseArgs(process.argv);
|
package/dist/multi.js
CHANGED
|
@@ -10,6 +10,47 @@ function reportFailures(report) {
|
|
|
10
10
|
function normalizeError(error) {
|
|
11
11
|
return error.replace(/\s+/g, ' ').trim();
|
|
12
12
|
}
|
|
13
|
+
function toEpochMs(value) {
|
|
14
|
+
if (!value)
|
|
15
|
+
return 0;
|
|
16
|
+
const ts = Date.parse(value);
|
|
17
|
+
return Number.isFinite(ts) ? ts : 0;
|
|
18
|
+
}
|
|
19
|
+
export function filterReportsSince(reports, sinceHours, nowMs = Date.now()) {
|
|
20
|
+
if (sinceHours === undefined)
|
|
21
|
+
return reports;
|
|
22
|
+
const cutoffMs = nowMs - sinceHours * 60 * 60 * 1000;
|
|
23
|
+
return reports.filter((report) => toEpochMs(report.timestamp) >= cutoffMs);
|
|
24
|
+
}
|
|
25
|
+
export function rankReportsBySeverity(reports) {
|
|
26
|
+
const weights = {
|
|
27
|
+
critical: 4,
|
|
28
|
+
high: 3,
|
|
29
|
+
medium: 2,
|
|
30
|
+
low: 1,
|
|
31
|
+
};
|
|
32
|
+
return reports
|
|
33
|
+
.map((report) => {
|
|
34
|
+
const failures = reportFailures(report);
|
|
35
|
+
const severity_counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
36
|
+
let severity_score = 0;
|
|
37
|
+
for (const failure of failures) {
|
|
38
|
+
const sev = failure.severity;
|
|
39
|
+
if (sev in weights) {
|
|
40
|
+
severity_counts[sev] += 1;
|
|
41
|
+
severity_score += weights[sev];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
repo: report.repo,
|
|
46
|
+
severity_score,
|
|
47
|
+
total_failures: failures.length,
|
|
48
|
+
severity_counts,
|
|
49
|
+
root_cause: report.summary.root_cause,
|
|
50
|
+
};
|
|
51
|
+
})
|
|
52
|
+
.sort((a, b) => b.severity_score - a.severity_score || b.total_failures - a.total_failures || a.repo.localeCompare(b.repo));
|
|
53
|
+
}
|
|
13
54
|
export function detectSharedPatterns(reports) {
|
|
14
55
|
const byFixAction = new Map();
|
|
15
56
|
const byCategory = new Map();
|
|
@@ -74,12 +115,18 @@ export function detectSharedPatterns(reports) {
|
|
|
74
115
|
return patterns;
|
|
75
116
|
}
|
|
76
117
|
export function formatMultiSummary(reports, patterns) {
|
|
118
|
+
const ranked = rankReportsBySeverity(reports);
|
|
77
119
|
const lines = [`Multi-repo triage: ${reports.length} repos`, ''];
|
|
120
|
+
lines.push('Ranked by failure severity:');
|
|
121
|
+
for (const rank of ranked) {
|
|
122
|
+
lines.push(` ${rank.repo} - score ${rank.severity_score} (${rank.total_failures} failures, root cause: ${rank.root_cause})`);
|
|
123
|
+
}
|
|
124
|
+
lines.push('');
|
|
78
125
|
if (patterns.length === 0) {
|
|
79
|
-
lines.push('Shared
|
|
126
|
+
lines.push('Shared root causes detected: none', '');
|
|
80
127
|
}
|
|
81
128
|
else {
|
|
82
|
-
lines.push('Shared
|
|
129
|
+
lines.push('Shared root causes detected:');
|
|
83
130
|
for (const pattern of patterns) {
|
|
84
131
|
const repos = pattern.repos.map((repo) => repo.split('/').at(-1) ?? repo).join(', ');
|
|
85
132
|
if (pattern.fix_action) {
|
|
@@ -95,22 +142,14 @@ export function formatMultiSummary(reports, patterns) {
|
|
|
95
142
|
}
|
|
96
143
|
lines.push('');
|
|
97
144
|
}
|
|
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
145
|
return `${lines.join('\n')}\n`;
|
|
113
146
|
}
|
|
114
147
|
export function buildMultiJson(reports, patterns) {
|
|
115
|
-
|
|
148
|
+
const ranked = rankReportsBySeverity(reports);
|
|
149
|
+
return `${JSON.stringify({
|
|
150
|
+
repos: reports,
|
|
151
|
+
ranked_repos: ranked,
|
|
152
|
+
shared_root_causes: patterns,
|
|
153
|
+
patterns,
|
|
154
|
+
}, null, 2)}\n`;
|
|
116
155
|
}
|
package/dist/providers/github.js
CHANGED
|
@@ -49,7 +49,7 @@ export class GitHubProvider {
|
|
|
49
49
|
async listRuns(repo, limit) {
|
|
50
50
|
const runs = runJson([
|
|
51
51
|
'run', 'list', '--repo', repo, '--limit', String(limit),
|
|
52
|
-
'--json', 'databaseId,displayTitle,workflowName,conclusion,url',
|
|
52
|
+
'--json', 'databaseId,displayTitle,workflowName,conclusion,url,createdAt',
|
|
53
53
|
]);
|
|
54
54
|
return runs.map((r) => ({
|
|
55
55
|
id: r.databaseId,
|
|
@@ -57,6 +57,7 @@ export class GitHubProvider {
|
|
|
57
57
|
workflowName: r.workflowName,
|
|
58
58
|
conclusion: r.conclusion,
|
|
59
59
|
url: r.url,
|
|
60
|
+
createdAt: r.createdAt,
|
|
60
61
|
}));
|
|
61
62
|
}
|
|
62
63
|
async resolveRun(repo, runId) {
|
package/dist/providers/gitlab.js
CHANGED
|
@@ -39,6 +39,7 @@ export class GitLabProvider {
|
|
|
39
39
|
workflowName: 'GitLab CI',
|
|
40
40
|
conclusion: p.status === 'success' ? 'success' : p.status === 'failed' ? 'failure' : p.status,
|
|
41
41
|
url: p.web_url,
|
|
42
|
+
createdAt: p.created_at,
|
|
42
43
|
}));
|
|
43
44
|
}
|
|
44
45
|
async resolveRun(repo, runId) {
|
|
@@ -50,6 +51,7 @@ export class GitLabProvider {
|
|
|
50
51
|
workflowName: 'GitLab CI',
|
|
51
52
|
conclusion: pipeline.status === 'success' ? 'success' : pipeline.status === 'failed' ? 'failure' : pipeline.status,
|
|
52
53
|
url: pipeline.web_url,
|
|
54
|
+
createdAt: pipeline.created_at,
|
|
53
55
|
};
|
|
54
56
|
}
|
|
55
57
|
const runs = await this.listRuns(repo, 20);
|