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 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 owner/repo2 owner/repo3 [--json]',
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 owner/repo2 [--json]
88
- if (args[0] === 'multi') {
89
- const repos = args.slice(1).filter((arg) => !arg.startsWith('--'));
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 owner/repo2 owner/repo3 [--json]');
155
+ console.error('Usage: ci-triage multi owner/repo1,owner/repo2 [--json] [--since 24h]');
93
156
  process.exit(1);
94
157
  }
95
- return { repo: repos[0], repos, limit: 0, outputJson: args.includes('--json'), subcommand: 'multi' };
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).filter((arg) => !arg.startsWith('--'));
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 patterns = detectSharedPatterns(reports);
438
+ const filteredReports = filterReportsSince(reports, options.sinceHours);
439
+ const patterns = detectSharedPatterns(filteredReports);
365
440
  if (options.outputJson) {
366
- process.stdout.write(buildMultiJson(reports, patterns));
441
+ process.stdout.write(buildMultiJson(filteredReports, patterns));
367
442
  return;
368
443
  }
369
- process.stdout.write(formatMultiSummary(reports, patterns));
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 patterns detected: none', '');
126
+ lines.push('Shared root causes detected: none', '');
80
127
  }
81
128
  else {
82
- lines.push('Shared patterns detected:');
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
- return `${JSON.stringify({ repos: reports, patterns }, null, 2)}\n`;
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
  }
@@ -35,6 +35,7 @@ export class CircleCiProvider {
35
35
  workflowName: 'CircleCI',
36
36
  conclusion: p.state === 'created' ? null : p.state,
37
37
  url: `https://app.circleci.com/pipelines/${repo}/${p.number}`,
38
+ createdAt: p.created_at,
38
39
  }));
39
40
  }
40
41
  async resolveRun(repo, runId) {
@@ -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) {
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ci-triage",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Open-source CI failure triage for humans and agents — smart log parsing, flake detection, structured JSON, MCP server.",
5
5
  "type": "module",
6
6
  "bin": {