ci-triage 0.2.0 → 0.3.0

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/parser.js CHANGED
@@ -200,3 +200,119 @@ export function parseFailures(rawLog) {
200
200
  flush();
201
201
  return dedupeFailures(failures);
202
202
  }
203
+ function hasErrorContext(line) {
204
+ return /\b(?:error|failed|failure|denied|invalid|missing|not found|unable|cannot)\b/i.test(line);
205
+ }
206
+ function inferStepName(lines, index) {
207
+ for (let i = index; i >= 0 && i >= index - 40; i -= 1) {
208
+ const step = parseStepName(lines[i] ?? '');
209
+ if (step) {
210
+ return step;
211
+ }
212
+ }
213
+ return 'Unknown step';
214
+ }
215
+ function createInfraFailure(lines, index, error) {
216
+ const line = lines[index] ?? '';
217
+ const location = parseLocation(line);
218
+ const stack = [];
219
+ if (index + 1 < lines.length && isStackLine(lines[index + 1] ?? '')) {
220
+ stack.push((lines[index + 1] ?? '').trim());
221
+ }
222
+ return {
223
+ stepName: inferStepName(lines, index),
224
+ error: error.trim(),
225
+ stack,
226
+ location,
227
+ rawLines: [line],
228
+ };
229
+ }
230
+ export function parseInfraFailures(rawLog) {
231
+ if (!rawLog || rawLog.trim().length === 0) {
232
+ return [];
233
+ }
234
+ const cleanedLog = stripAnsiAndTimestamps(rawLog);
235
+ const lines = cleanedLog
236
+ .split(/\r?\n/)
237
+ .slice(0, MAX_LINES)
238
+ .map(sanitizeLine);
239
+ const failures = [];
240
+ for (let i = 0; i < lines.length; i += 1) {
241
+ const line = lines[i] ?? '';
242
+ if (!line) {
243
+ continue;
244
+ }
245
+ 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));
257
+ continue;
258
+ }
259
+ // GitHub Actions explicit step failure line.
260
+ const processExit = line.match(/\bError:\s*Process completed with exit code\s+(\d+)\b/i);
261
+ if (processExit && Number(processExit[1]) !== 0) {
262
+ failures.push(createInfraFailure(lines, i, line));
263
+ continue;
264
+ }
265
+ // Missing env/token in error context.
266
+ if (/\b(?:GITHUB_TOKEN|GH_TOKEN|GITLAB_TOKEN|CIRCLE_TOKEN)\b/.test(line) && hasErrorContext(line)) {
267
+ failures.push(createInfraFailure(lines, i, line));
268
+ continue;
269
+ }
270
+ // Input/output file conflicts.
271
+ if (/\binput file is output file\b/i.test(line) || /\bis the same file\b/i.test(line)) {
272
+ failures.push(createInfraFailure(lines, i, line));
273
+ continue;
274
+ }
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)) {
283
+ failures.push(createInfraFailure(lines, i, line));
284
+ continue;
285
+ }
286
+ // npm/node failures.
287
+ if (/\bnpm ERR!\b/i.test(line) || /\bCannot find module\b/i.test(line)) {
288
+ failures.push(createInfraFailure(lines, i, line));
289
+ continue;
290
+ }
291
+ // Generic exit code failures not already captured.
292
+ const genericExit = line.match(/\bexit code\s+([1-9]\d*)\b/i);
293
+ if (genericExit) {
294
+ failures.push(createInfraFailure(lines, i, line));
295
+ continue;
296
+ }
297
+ // Generic explicit error prefix catch for shell-like errors.
298
+ if (/^error:\s+.+$/i.test(line) && !/\bexit code\s+0\b/i.test(line)) {
299
+ failures.push(createInfraFailure(lines, i, line));
300
+ continue;
301
+ }
302
+ }
303
+ return dedupeFailures(failures);
304
+ }
305
+ export function parseAllFailures(rawLog) {
306
+ const combined = [...parseFailures(rawLog), ...parseInfraFailures(rawLog)];
307
+ const seen = new Set();
308
+ const deduped = [];
309
+ for (const failure of combined) {
310
+ const key = failure.error.trim();
311
+ if (!key || seen.has(key)) {
312
+ continue;
313
+ }
314
+ seen.add(key);
315
+ deduped.push(failure);
316
+ }
317
+ return deduped;
318
+ }
@@ -12,10 +12,18 @@ const providers = {
12
12
  circleci: () => new CircleCiProvider(),
13
13
  };
14
14
  /**
15
- * Detect CI provider by local repo markers.
16
- * Checks cwd and common project root indicators.
15
+ * Detect CI provider by environment variables first, then local repo markers.
16
+ * Env vars take priority so detection works in temp checkout dirs (e.g. /tmp).
17
17
  */
18
18
  export function detectProvider(cwd = process.cwd()) {
19
+ // Environment-variable detection (reliable in CI; works from any cwd)
20
+ if (process.env['GITLAB_CI'])
21
+ return 'gitlab';
22
+ if (process.env['CIRCLECI'])
23
+ return 'circleci';
24
+ if (process.env['GITHUB_ACTIONS'])
25
+ return 'github';
26
+ // Fallback: filesystem markers in the working directory
19
27
  if (existsSync(join(cwd, '.gitlab-ci.yml')))
20
28
  return 'gitlab';
21
29
  if (existsSync(join(cwd, '.circleci')))
@@ -0,0 +1,97 @@
1
+ import { execFile } from 'node:child_process';
2
+ function runGh(args) {
3
+ return new Promise((resolve, reject) => {
4
+ execFile('gh', args, { encoding: 'utf8' }, (error, stdout, stderr) => {
5
+ if (error) {
6
+ const err = error;
7
+ err.stdout = stdout ?? '';
8
+ err.stderr = stderr ?? '';
9
+ reject(err);
10
+ return;
11
+ }
12
+ resolve({ stdout: stdout ?? '', stderr: stderr ?? '' });
13
+ });
14
+ });
15
+ }
16
+ function parseScopes(statusOutput) {
17
+ const line = statusOutput
18
+ .split('\n')
19
+ .map((l) => l.trim())
20
+ .find((l) => /^token scopes:/i.test(l));
21
+ if (!line)
22
+ return [];
23
+ const raw = line.replace(/^token scopes:\s*/i, '').replace(/'/g, '').trim();
24
+ if (!raw || /^none$/i.test(raw))
25
+ return [];
26
+ return raw
27
+ .split(',')
28
+ .map((s) => s.trim())
29
+ .filter(Boolean);
30
+ }
31
+ function normalizePlan(planName) {
32
+ const normalized = (planName ?? '').toLowerCase();
33
+ if (normalized.includes('enterprise'))
34
+ return 'enterprise';
35
+ if (normalized.includes('team'))
36
+ return 'team';
37
+ if (normalized.includes('pro'))
38
+ return 'pro';
39
+ if (normalized.includes('free'))
40
+ return 'free';
41
+ return undefined;
42
+ }
43
+ export async function fetchRepoContext(repo) {
44
+ let repoInfo;
45
+ try {
46
+ const { stdout } = await runGh(['api', `repos/${repo}`]);
47
+ repoInfo = JSON.parse(stdout);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ const context = {
53
+ repo,
54
+ private: repoInfo.private ?? false,
55
+ visibility: repoInfo.visibility ?? (repoInfo.private ? 'private' : 'public'),
56
+ hasPages: repoInfo.has_pages ?? false,
57
+ pagesEnabled: false,
58
+ defaultBranch: repoInfo.default_branch ?? '',
59
+ workflowFiles: [],
60
+ hasCodeScanning: false,
61
+ tokenScopes: [],
62
+ plan: normalizePlan(repoInfo.owner?.plan?.name ?? repoInfo.plan?.name),
63
+ };
64
+ try {
65
+ const { stdout } = await runGh(['api', `repos/${repo}/contents/.github/workflows`]);
66
+ const workflows = JSON.parse(stdout);
67
+ const files = Array.isArray(workflows) ? workflows : [workflows];
68
+ context.workflowFiles = files
69
+ .map((f) => f?.name ?? '')
70
+ .filter((name) => /\.ya?ml$/i.test(name));
71
+ }
72
+ catch {
73
+ context.workflowFiles = [];
74
+ }
75
+ try {
76
+ await runGh(['api', `repos/${repo}/pages`]);
77
+ context.pagesEnabled = true;
78
+ }
79
+ catch {
80
+ context.pagesEnabled = false;
81
+ }
82
+ try {
83
+ await runGh(['api', `repos/${repo}/code-scanning/alerts`, '-f', 'per_page=1']);
84
+ context.hasCodeScanning = true;
85
+ }
86
+ catch {
87
+ context.hasCodeScanning = false;
88
+ }
89
+ try {
90
+ const { stdout, stderr } = await runGh(['auth', 'status']);
91
+ context.tokenScopes = parseScopes(`${stdout}\n${stderr}`);
92
+ }
93
+ catch {
94
+ context.tokenScopes = [];
95
+ }
96
+ return context;
97
+ }
package/dist/reporter.js CHANGED
@@ -14,6 +14,7 @@ function makeFailureEntry(item) {
14
14
  severity: item.classification.severity,
15
15
  category: item.classification.category,
16
16
  suggested_fix: item.classification.suggestedFix,
17
+ fix_action: item.classification.fixAction,
17
18
  };
18
19
  }
19
20
  function buildSteps(failures) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ci-triage",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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": {
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "action.yml",
13
+ "CHANGELOG.md",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],