ci-triage 0.3.0 → 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,8 @@ 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
+ import { startMcpServer } from './mcp-server.js';
13
14
  // --version support
14
15
  const _require = createRequire(import.meta.url);
15
16
  let pkgVersion = 'unknown';
@@ -26,10 +27,12 @@ function usage() {
26
27
  '',
27
28
  'Usage:',
28
29
  ' ci-triage owner/repo [limit] [--run <id>] [--md report.md] [--json]',
29
- ' 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]',
30
32
  ' ci-triage flakes owner/repo',
31
33
  ' ci-triage db prune [--days 90]',
32
34
  ' ci-triage db stats [owner/repo] [--json]',
35
+ ' ci-triage mcp',
33
36
  ' ci-triage resolve <owner/repo> <run-id> "<description>" [--commit <sha>]',
34
37
  ' ci-triage verify <owner/repo> <run-id> --clean-run <clean-run-id>',
35
38
  ' ci-triage history <owner/repo> [--json]',
@@ -38,9 +41,13 @@ function usage() {
38
41
  ' --run <id> Triage a specific run ID',
39
42
  ' --md <path> Write markdown report to file',
40
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',
41
46
  ' --provider <p> Force CI provider: github | gitlab | circleci',
42
- ' --llm Enable LLM root-cause analysis (requires OPENAI_API_KEY)',
47
+ ' --llm Enable LLM root-cause analysis (auto-on when OPENAI_API_KEY is set)',
48
+ ' --no-llm Disable LLM even when OPENAI_API_KEY is set (alias: CI_TRIAGE_NO_LLM=1)',
43
49
  ' --llm-model <model> Override LLM model (default: gpt-4.1-mini)',
50
+ ' --mcp Start MCP server (stdio transport) for agent tool use',
44
51
  ' --version Print version and exit',
45
52
  ].join('\n'));
46
53
  process.exit(1);
@@ -56,6 +63,52 @@ export function emitEmptyOutputWarning(totalFailures, llmRan) {
56
63
  process.stderr.write(EMPTY_OUTPUT_WARNING);
57
64
  }
58
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
+ }
59
112
  function buildRepoContextHint(repoContext, failures) {
60
113
  if (!repoContext)
61
114
  return null;
@@ -72,6 +125,10 @@ export function parseArgs(argv) {
72
125
  process.stdout.write(`ci-triage v${pkgVersion}\n`);
73
126
  process.exit(0);
74
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
+ }
75
132
  // subcommand: ci-triage flakes owner/repo
76
133
  if (args[0] === 'flakes') {
77
134
  const repo = args[1];
@@ -81,15 +138,32 @@ export function parseArgs(argv) {
81
138
  }
82
139
  return { repo, limit: 0, outputJson: false, subcommand: 'flakes' };
83
140
  }
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('--'));
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));
87
153
  if (repos.length === 0 ||
88
154
  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]');
155
+ console.error('Usage: ci-triage multi owner/repo1,owner/repo2 [--json] [--since 24h]');
90
156
  process.exit(1);
91
157
  }
92
- 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
+ };
93
167
  }
94
168
  // subcommand: ci-triage db prune [--days 90]
95
169
  if (args[0] === 'db' && args[1] === 'prune') {
@@ -156,7 +230,7 @@ export function parseArgs(argv) {
156
230
  if (!repo || !/^[A-Za-z0-9_./%-]+\/[A-Za-z0-9_./%-]+$/.test(repo)) {
157
231
  usage();
158
232
  }
159
- const positionalArgs = args.slice(1).filter((arg) => !arg.startsWith('--'));
233
+ const positionalArgs = positionalArgsExcludingOptionValues(args.slice(1));
160
234
  const parsedLimit = positionalArgs[0] ? Number(positionalArgs[0]) : 10;
161
235
  const limit = Math.min(Math.max(Number.isFinite(parsedLimit) ? parsedLimit : 10, 1), 100);
162
236
  const runIdx = args.indexOf('--run');
@@ -176,6 +250,8 @@ export function parseArgs(argv) {
176
250
  outputJson: args.includes('--json'),
177
251
  provider,
178
252
  llm: args.includes('--llm'),
253
+ noLlm: args.includes('--no-llm'),
254
+ mcp: args.includes('--mcp'),
179
255
  llmModel,
180
256
  };
181
257
  }
@@ -299,7 +375,15 @@ export async function triageRepo(options) {
299
375
  event: metadata.event,
300
376
  },
301
377
  });
302
- const llmEnabled = options.llm || !!process.env['OPENAI_API_KEY'];
378
+ if (run.createdAt && Number.isFinite(Date.parse(run.createdAt))) {
379
+ report.timestamp = run.createdAt;
380
+ }
381
+ const noLlm = options.noLlm || !!process.env['CI_TRIAGE_NO_LLM'];
382
+ const autoLlm = !noLlm && !!process.env['OPENAI_API_KEY'];
383
+ if (autoLlm && !options.llm && !noLlm) {
384
+ process.stderr.write('ℹ using LLM analysis (set CI_TRIAGE_NO_LLM=1 or pass --no-llm to disable)\n');
385
+ }
386
+ const llmEnabled = !noLlm && (options.llm || !!process.env['OPENAI_API_KEY']);
303
387
  if (llmEnabled) {
304
388
  const repoContext = await repoContextPromise;
305
389
  const failureEntries = classified.map((f) => ({
@@ -341,6 +425,7 @@ async function runMultiCommand(options) {
341
425
  repo,
342
426
  provider: options.provider,
343
427
  llm: options.llm,
428
+ noLlm: options.noLlm,
344
429
  llmModel: options.llmModel,
345
430
  });
346
431
  reports.push(report);
@@ -350,15 +435,21 @@ async function runMultiCommand(options) {
350
435
  console.error(`Failed to triage ${repo}: ${message}`);
351
436
  }
352
437
  }
353
- const patterns = detectSharedPatterns(reports);
438
+ const filteredReports = filterReportsSince(reports, options.sinceHours);
439
+ const patterns = detectSharedPatterns(filteredReports);
354
440
  if (options.outputJson) {
355
- process.stdout.write(buildMultiJson(reports, patterns));
441
+ process.stdout.write(buildMultiJson(filteredReports, patterns));
356
442
  return;
357
443
  }
358
- process.stdout.write(formatMultiSummary(reports, patterns));
444
+ process.stdout.write(formatMultiSummary(filteredReports, patterns));
359
445
  }
360
446
  async function main() {
361
447
  const options = parseArgs(process.argv);
448
+ // --mcp: start MCP server in stdio mode
449
+ if (options.mcp) {
450
+ await startMcpServer();
451
+ return;
452
+ }
362
453
  // Handle subcommands
363
454
  if (options.subcommand === 'flakes') {
364
455
  await runFlakesCommand(options.repo);
@@ -442,7 +533,12 @@ async function main() {
442
533
  },
443
534
  });
444
535
  // LLM analysis (gated)
445
- const llmEnabled = options.llm || !!process.env['OPENAI_API_KEY'];
536
+ const noLlm = options.noLlm || !!process.env['CI_TRIAGE_NO_LLM'];
537
+ const autoLlm = !noLlm && !!process.env['OPENAI_API_KEY'];
538
+ if (autoLlm && !options.llm && !noLlm) {
539
+ process.stderr.write('ℹ using LLM analysis (set CI_TRIAGE_NO_LLM=1 or pass --no-llm to disable)\n');
540
+ }
541
+ const llmEnabled = !noLlm && (options.llm || !!process.env['OPENAI_API_KEY']);
446
542
  if (llmEnabled) {
447
543
  const repoContext = await repoContextPromise;
448
544
  const failureEntries = classified.map((f) => ({
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
  }
package/dist/parser.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { infraParsers } from './parsers/index.js';
1
2
  const ANSI_REGEX = /\x1B\[[0-?]*[ -/]*[@-~]/g;
2
3
  const TIMESTAMP_PREFIX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*/;
3
4
  const MAX_LINES = 25_000;
@@ -10,7 +11,12 @@ const ERROR_LINE_REGEX = [
10
11
  /^FAILED\b.+$/,
11
12
  /^Caused by:\s+.+$/i,
12
13
  /\b(?:heap out of memory|permission denied|cannot find module|failed to start container|no such image)\b/i,
14
+ /\b(?:command not found|script returned exit code|the command exited with)\b/i,
13
15
  /\b(?:eacces|enoent|econnrefused|etimedout|timed out|rate limit(?:ed)?|too many requests)\b/i,
16
+ /\bcurl:\s*\(\d+\).*(?:4\d{2}|5\d{2})\b/i,
17
+ /\b(?:http(?:\/\d(?:\.\d)?)?\s+|status(?: code)?[:=]?\s*)(?:4\d{2}|5\d{2})\b/i,
18
+ /\b(?:pages?\s+(?:deploy|deployment).*(?:failed|error)|failed to create deployment)\b/i,
19
+ /\b(?:codeql|code[- ]scanning).*(?:workflow error|analysis failed|database .* failed|required permissions|init failed|analyze failed)\b/i,
14
20
  /\b(?:environment variable .* not set|missing env(?:ironment)? variable|undefined env)\b/i,
15
21
  /\b(?:npm audit|vulnerabilities|eslint|linting failed|error ts\d{4})\b/i,
16
22
  /\berror\s+TS\d{4}\b/i,
@@ -243,17 +249,16 @@ export function parseInfraFailures(rawLog) {
243
249
  continue;
244
250
  }
245
251
  const context = `${lines[i - 1] ?? ''}\n${line}\n${lines[i + 1] ?? ''}`;
246
- // Shell script/runtime errors from bash/sh.
247
- if (/^.+:\s*line\s+\d+:\s*.+$/i.test(line) ||
248
- /\bcommand not found\b/i.test(line) ||
249
- /\bpermission denied\b/i.test(line) ||
250
- /\bno such file or directory\b/i.test(line)) {
251
- failures.push(createInfraFailure(lines, i, line));
252
- continue;
253
- }
254
- // HTTP errors.
255
- if (/\bHTTP\s*(?:403|404|429|5\d{2})\b/i.test(line) || /\b(?:403 Forbidden|404 Not Found)\b/i.test(line)) {
256
- failures.push(createInfraFailure(lines, i, line));
252
+ const matchedByHandler = infraParsers
253
+ .map((handler) => handler({
254
+ line,
255
+ prevLine: lines[i - 1] ?? '',
256
+ nextLine: lines[i + 1] ?? '',
257
+ context,
258
+ }))
259
+ .find((value) => Boolean(value));
260
+ if (matchedByHandler) {
261
+ failures.push(createInfraFailure(lines, i, matchedByHandler));
257
262
  continue;
258
263
  }
259
264
  // GitHub Actions explicit step failure line.
@@ -272,14 +277,10 @@ export function parseInfraFailures(rawLog) {
272
277
  failures.push(createInfraFailure(lines, i, line));
273
278
  continue;
274
279
  }
275
- // GitHub Pages deploy failures.
276
- if (/\bError:\s*Get Pages site failed\b/i.test(line) ||
277
- (/\bHttpError:\s*Not Found\b/i.test(line) && /\bpages?\b/i.test(context))) {
278
- failures.push(createInfraFailure(lines, i, line));
279
- continue;
280
- }
281
- // CodeQL/configuration errors.
282
- if (/\bconfiguration error\b/i.test(line) && /\b(?:codeql|code[- ]scanning)\b/i.test(context)) {
280
+ // CodeQL/configuration/workflow errors.
281
+ if ((/\bconfiguration error\b/i.test(line) && /\b(?:codeql|code[- ]scanning)\b/i.test(context)) ||
282
+ (/\b(?:workflow error|analysis failed|database .* failed|required permissions|init failed|analyze failed|autobuild failed)\b/i.test(line) &&
283
+ /\b(?:codeql|code[- ]scanning|github\/codeql-action)\b/i.test(context))) {
283
284
  failures.push(createInfraFailure(lines, i, line));
284
285
  continue;
285
286
  }
@@ -0,0 +1,33 @@
1
+ const GITHUB_PAGES_PATTERNS = [
2
+ /\bError:\s*Get Pages site failed\b/i,
3
+ /\bfailed to create deployment\b/i,
4
+ /\bpages?\s+(?:deploy|deployment).*(?:failed|error)\b/i,
5
+ ];
6
+ const VERCEL_PATTERNS = [
7
+ /\bvercel\b.*\b(?:error|failed|failure)\b/i,
8
+ /\b(?:vercel|vc)\s+deploy\b.*\b(?:error|failed)\b/i,
9
+ /\b(?:project|team)\s+not found\b.*\bvercel\b/i,
10
+ /\bNo Output Directory named\b/i,
11
+ /\bCommand\s+\".+\"\s+exited with\s+[1-9]\d*\b/i,
12
+ ];
13
+ const CLOUDFLARE_PATTERNS = [
14
+ /\bcloudflare\b.*\b(?:error|failed|failure)\b/i,
15
+ /\bcloudflare pages\b.*\b(?:error|failed|failure)\b/i,
16
+ /\bwrangler\b.*\b(?:error|failed|failure)\b/i,
17
+ /\bA request to the Cloudflare API\b.*\bfailed\b/i,
18
+ ];
19
+ export const parseDeployPagesFailure = ({ line, context }) => {
20
+ if (GITHUB_PAGES_PATTERNS.some((pattern) => pattern.test(line))) {
21
+ return line.trim();
22
+ }
23
+ if (/\bHttpError:\s*Not Found\b/i.test(line) && /\b(?:pages?|deploy)\b/i.test(context)) {
24
+ return line.trim();
25
+ }
26
+ if (VERCEL_PATTERNS.some((pattern) => pattern.test(line))) {
27
+ return line.trim();
28
+ }
29
+ if (CLOUDFLARE_PATTERNS.some((pattern) => pattern.test(line))) {
30
+ return line.trim();
31
+ }
32
+ return null;
33
+ };
@@ -0,0 +1,13 @@
1
+ const HTTP_PATTERNS = [
2
+ /\bHTTP(?:\/\d(?:\.\d)?)?\s+(?:4\d{2}|5\d{2})\b/i,
3
+ /\b(?:status|status code)[:=]?\s*(?:4\d{2}|5\d{2})\b/i,
4
+ /\bcurl:\s*\(\d+\).*(?:4\d{2}|5\d{2})\b/i,
5
+ /\bfetch\b.*\b(?:HTTP|status|status code)[:=]?\s*(?:4\d{2}|5\d{2})\b/i,
6
+ /\b(?:4\d{2}|5\d{2})\s+(?:Bad Request|Unauthorized|Forbidden|Not Found|Too Many Requests|Internal Server Error|Bad Gateway|Service Unavailable|Gateway Timeout)\b/i,
7
+ ];
8
+ export const parseHttpError = ({ line }) => {
9
+ if (HTTP_PATTERNS.some((pattern) => pattern.test(line))) {
10
+ return line.trim();
11
+ }
12
+ return null;
13
+ };
@@ -0,0 +1,9 @@
1
+ import { parseDeployPagesFailure } from './deploy-pages-failure.js';
2
+ import { parseHttpError } from './http-error.js';
3
+ import { parseShellScriptFailure } from './shell-failure.js';
4
+ export const infraParsers = [
5
+ parseShellScriptFailure,
6
+ parseHttpError,
7
+ parseDeployPagesFailure,
8
+ ];
9
+ export { parseDeployPagesFailure, parseHttpError, parseShellScriptFailure };
@@ -0,0 +1,14 @@
1
+ const SHELL_PATTERNS = [
2
+ /^.+:\s*line\s+\d+:\s*.+$/i,
3
+ /\bcommand not found\b/i,
4
+ /\bpermission denied\b/i,
5
+ /\bno such file or directory\b/i,
6
+ /\b(?:script returned exit code|the command exited with)\s*([1-9]\d*)\b/i,
7
+ /\bexit code\s+([1-9]\d*)\b/i,
8
+ ];
9
+ export const parseShellScriptFailure = ({ line }) => {
10
+ if (SHELL_PATTERNS.some((pattern) => pattern.test(line))) {
11
+ return line.trim();
12
+ }
13
+ return null;
14
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -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.0",
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": {
@@ -41,7 +41,7 @@
41
41
  "homepage": "https://github.com/clankamode/ci-triage",
42
42
  "devDependencies": {
43
43
  "@types/better-sqlite3": "^7.6.13",
44
- "@types/node": "^22.13.10",
44
+ "@types/node": "^25.3.1",
45
45
  "typescript": "^5.9.2",
46
46
  "vitest": "^4.0.18"
47
47
  },