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/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,
|
|
@@ -200,3 +206,114 @@ export function parseFailures(rawLog) {
|
|
|
200
206
|
flush();
|
|
201
207
|
return dedupeFailures(failures);
|
|
202
208
|
}
|
|
209
|
+
function hasErrorContext(line) {
|
|
210
|
+
return /\b(?:error|failed|failure|denied|invalid|missing|not found|unable|cannot)\b/i.test(line);
|
|
211
|
+
}
|
|
212
|
+
function inferStepName(lines, index) {
|
|
213
|
+
for (let i = index; i >= 0 && i >= index - 40; i -= 1) {
|
|
214
|
+
const step = parseStepName(lines[i] ?? '');
|
|
215
|
+
if (step) {
|
|
216
|
+
return step;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return 'Unknown step';
|
|
220
|
+
}
|
|
221
|
+
function createInfraFailure(lines, index, error) {
|
|
222
|
+
const line = lines[index] ?? '';
|
|
223
|
+
const location = parseLocation(line);
|
|
224
|
+
const stack = [];
|
|
225
|
+
if (index + 1 < lines.length && isStackLine(lines[index + 1] ?? '')) {
|
|
226
|
+
stack.push((lines[index + 1] ?? '').trim());
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
stepName: inferStepName(lines, index),
|
|
230
|
+
error: error.trim(),
|
|
231
|
+
stack,
|
|
232
|
+
location,
|
|
233
|
+
rawLines: [line],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
export function parseInfraFailures(rawLog) {
|
|
237
|
+
if (!rawLog || rawLog.trim().length === 0) {
|
|
238
|
+
return [];
|
|
239
|
+
}
|
|
240
|
+
const cleanedLog = stripAnsiAndTimestamps(rawLog);
|
|
241
|
+
const lines = cleanedLog
|
|
242
|
+
.split(/\r?\n/)
|
|
243
|
+
.slice(0, MAX_LINES)
|
|
244
|
+
.map(sanitizeLine);
|
|
245
|
+
const failures = [];
|
|
246
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
247
|
+
const line = lines[i] ?? '';
|
|
248
|
+
if (!line) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const context = `${lines[i - 1] ?? ''}\n${line}\n${lines[i + 1] ?? ''}`;
|
|
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));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// GitHub Actions explicit step failure line.
|
|
265
|
+
const processExit = line.match(/\bError:\s*Process completed with exit code\s+(\d+)\b/i);
|
|
266
|
+
if (processExit && Number(processExit[1]) !== 0) {
|
|
267
|
+
failures.push(createInfraFailure(lines, i, line));
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
// Missing env/token in error context.
|
|
271
|
+
if (/\b(?:GITHUB_TOKEN|GH_TOKEN|GITLAB_TOKEN|CIRCLE_TOKEN)\b/.test(line) && hasErrorContext(line)) {
|
|
272
|
+
failures.push(createInfraFailure(lines, i, line));
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// Input/output file conflicts.
|
|
276
|
+
if (/\binput file is output file\b/i.test(line) || /\bis the same file\b/i.test(line)) {
|
|
277
|
+
failures.push(createInfraFailure(lines, i, line));
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
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))) {
|
|
284
|
+
failures.push(createInfraFailure(lines, i, line));
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// npm/node failures.
|
|
288
|
+
if (/\bnpm ERR!\b/i.test(line) || /\bCannot find module\b/i.test(line)) {
|
|
289
|
+
failures.push(createInfraFailure(lines, i, line));
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// Generic exit code failures not already captured.
|
|
293
|
+
const genericExit = line.match(/\bexit code\s+([1-9]\d*)\b/i);
|
|
294
|
+
if (genericExit) {
|
|
295
|
+
failures.push(createInfraFailure(lines, i, line));
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// Generic explicit error prefix catch for shell-like errors.
|
|
299
|
+
if (/^error:\s+.+$/i.test(line) && !/\bexit code\s+0\b/i.test(line)) {
|
|
300
|
+
failures.push(createInfraFailure(lines, i, line));
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return dedupeFailures(failures);
|
|
305
|
+
}
|
|
306
|
+
export function parseAllFailures(rawLog) {
|
|
307
|
+
const combined = [...parseFailures(rawLog), ...parseInfraFailures(rawLog)];
|
|
308
|
+
const seen = new Set();
|
|
309
|
+
const deduped = [];
|
|
310
|
+
for (const failure of combined) {
|
|
311
|
+
const key = failure.error.trim();
|
|
312
|
+
if (!key || seen.has(key)) {
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
seen.add(key);
|
|
316
|
+
deduped.push(failure);
|
|
317
|
+
}
|
|
318
|
+
return deduped;
|
|
319
|
+
}
|
|
@@ -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 {};
|
package/dist/providers/index.js
CHANGED
|
@@ -12,10 +12,18 @@ const providers = {
|
|
|
12
12
|
circleci: () => new CircleCiProvider(),
|
|
13
13
|
};
|
|
14
14
|
/**
|
|
15
|
-
* Detect CI provider by local repo markers.
|
|
16
|
-
*
|
|
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.
|
|
3
|
+
"version": "0.3.1",
|
|
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
|
],
|
|
@@ -40,7 +41,7 @@
|
|
|
40
41
|
"homepage": "https://github.com/clankamode/ci-triage",
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"@types/better-sqlite3": "^7.6.13",
|
|
43
|
-
"@types/node": "^
|
|
44
|
+
"@types/node": "^25.3.1",
|
|
44
45
|
"typescript": "^5.9.2",
|
|
45
46
|
"vitest": "^4.0.18"
|
|
46
47
|
},
|