ci-triage 0.1.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/LICENSE +21 -0
- package/README.md +144 -0
- package/action.yml +67 -0
- package/dist/action.js +104 -0
- package/dist/classifier.js +157 -0
- package/dist/comment.js +81 -0
- package/dist/fetcher.js +62 -0
- package/dist/flake-detector.js +107 -0
- package/dist/github.js +52 -0
- package/dist/history.js +133 -0
- package/dist/index.js +72 -0
- package/dist/index.test.js +6 -0
- package/dist/junit.js +94 -0
- package/dist/mcp-server.js +175 -0
- package/dist/parser.js +202 -0
- package/dist/reporter.js +115 -0
- package/dist/types.js +1 -0
- package/package.json +51 -0
package/dist/parser.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
const ANSI_REGEX = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
|
2
|
+
const TIMESTAMP_PREFIX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*/;
|
|
3
|
+
const MAX_LINES = 25_000;
|
|
4
|
+
const ERROR_LINE_REGEX = [
|
|
5
|
+
/^##\[error\](.+)$/i,
|
|
6
|
+
/^FATAL ERROR:\s+.+$/i,
|
|
7
|
+
/^(?:Error|TypeError|ReferenceError|SyntaxError|AssertionError|panic):\s+.+$/i,
|
|
8
|
+
/^npm ERR!\s+.+$/i,
|
|
9
|
+
/^FAIL\b.+$/,
|
|
10
|
+
/^FAILED\b.+$/,
|
|
11
|
+
/^Caused by:\s+.+$/i,
|
|
12
|
+
/\b(?:heap out of memory|permission denied|cannot find module|failed to start container|no such image)\b/i,
|
|
13
|
+
/\b(?:eacces|enoent|econnrefused|etimedout|timed out|rate limit(?:ed)?|too many requests)\b/i,
|
|
14
|
+
/\b(?:environment variable .* not set|missing env(?:ironment)? variable|undefined env)\b/i,
|
|
15
|
+
/\b(?:npm audit|vulnerabilities|eslint|linting failed|error ts\d{4})\b/i,
|
|
16
|
+
/\berror\s+TS\d{4}\b/i,
|
|
17
|
+
/^Process completed with exit code\s+\d+/i,
|
|
18
|
+
];
|
|
19
|
+
const STACK_LINE_REGEX = [
|
|
20
|
+
/^\s*at\s+.+$/,
|
|
21
|
+
/^\s*\.\.\.\s*\d+\s*more$/,
|
|
22
|
+
/^\s*File\s+".+",\s+line\s+\d+/,
|
|
23
|
+
];
|
|
24
|
+
function stripAnsiAndTimestamps(input) {
|
|
25
|
+
return input
|
|
26
|
+
.replace(ANSI_REGEX, '')
|
|
27
|
+
.split(/\r?\n/)
|
|
28
|
+
.map((line) => line.replace(TIMESTAMP_PREFIX, ''))
|
|
29
|
+
.join('\n');
|
|
30
|
+
}
|
|
31
|
+
function sanitizeLine(line) {
|
|
32
|
+
return line.replace(/[^\x09\x0A\x0D\x20-\x7E]/g, '').trimEnd();
|
|
33
|
+
}
|
|
34
|
+
/** Track last seen job/step from tab-delimited log lines */
|
|
35
|
+
let lastTabStep = null;
|
|
36
|
+
function parseStepName(line) {
|
|
37
|
+
const groupRun = line.match(/^##\[group\]Run\s+(.+)$/i);
|
|
38
|
+
if (groupRun) {
|
|
39
|
+
return groupRun[1].trim();
|
|
40
|
+
}
|
|
41
|
+
const groupStep = line.match(/^##\[group\](.+)$/i);
|
|
42
|
+
if (groupStep) {
|
|
43
|
+
return groupStep[1].trim();
|
|
44
|
+
}
|
|
45
|
+
const runLine = line.match(/^Run\s+(.+)$/);
|
|
46
|
+
if (runLine) {
|
|
47
|
+
return runLine[1].trim();
|
|
48
|
+
}
|
|
49
|
+
// GitHub Actions --log-failed format: "jobName\tstepName\ttimestamp message"
|
|
50
|
+
const tabParts = line.split('\t');
|
|
51
|
+
if (tabParts.length >= 3) {
|
|
52
|
+
const stepCandidate = tabParts[1].trim();
|
|
53
|
+
if (stepCandidate && stepCandidate !== lastTabStep) {
|
|
54
|
+
lastTabStep = stepCandidate;
|
|
55
|
+
return stepCandidate;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function parseLocation(line) {
|
|
61
|
+
const parenLocation = line.match(/\(([\w./\\-]+):(\d+):(\d+)\)/);
|
|
62
|
+
if (parenLocation) {
|
|
63
|
+
return {
|
|
64
|
+
file: parenLocation[1],
|
|
65
|
+
line: Number(parenLocation[2]),
|
|
66
|
+
column: Number(parenLocation[3]),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const fileLineCol = line.match(/(^|\s)([\w./\\-]+):(\d+):(\d+)(\s|$)/);
|
|
70
|
+
if (fileLineCol) {
|
|
71
|
+
return {
|
|
72
|
+
file: fileLineCol[2],
|
|
73
|
+
line: Number(fileLineCol[3]),
|
|
74
|
+
column: Number(fileLineCol[4]),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const fileLine = line.match(/(^|\s)([\w./\\-]+):(\d+)(\s|$)/);
|
|
78
|
+
if (fileLine) {
|
|
79
|
+
return {
|
|
80
|
+
file: fileLine[2],
|
|
81
|
+
line: Number(fileLine[3]),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const pythonStyle = line.match(/File\s+"([^"]+)",\s+line\s+(\d+)/);
|
|
85
|
+
if (pythonStyle) {
|
|
86
|
+
return {
|
|
87
|
+
file: pythonStyle[1],
|
|
88
|
+
line: Number(pythonStyle[2]),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
function isErrorStart(line) {
|
|
94
|
+
return ERROR_LINE_REGEX.some((re) => re.test(line));
|
|
95
|
+
}
|
|
96
|
+
function isStackLine(line) {
|
|
97
|
+
return STACK_LINE_REGEX.some((re) => re.test(line));
|
|
98
|
+
}
|
|
99
|
+
function toErrorMessage(line) {
|
|
100
|
+
const m = line.match(/^##\[error\](.+)$/i);
|
|
101
|
+
if (m) {
|
|
102
|
+
return m[1].trim();
|
|
103
|
+
}
|
|
104
|
+
return line.trim();
|
|
105
|
+
}
|
|
106
|
+
function dedupeFailures(failures) {
|
|
107
|
+
const seen = new Set();
|
|
108
|
+
const deduped = [];
|
|
109
|
+
for (const failure of failures) {
|
|
110
|
+
const key = `${failure.stepName}|${failure.error}|${failure.location?.file ?? ''}|${failure.location?.line ?? ''}`;
|
|
111
|
+
if (seen.has(key)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
seen.add(key);
|
|
115
|
+
deduped.push(failure);
|
|
116
|
+
}
|
|
117
|
+
return deduped;
|
|
118
|
+
}
|
|
119
|
+
export function parseFailures(rawLog) {
|
|
120
|
+
if (!rawLog || rawLog.trim().length === 0) {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
const cleanedLog = stripAnsiAndTimestamps(rawLog);
|
|
124
|
+
const lines = cleanedLog
|
|
125
|
+
.split(/\r?\n/)
|
|
126
|
+
.slice(0, MAX_LINES)
|
|
127
|
+
.map(sanitizeLine);
|
|
128
|
+
const failures = [];
|
|
129
|
+
let currentStep = 'Unknown step';
|
|
130
|
+
let active = null;
|
|
131
|
+
const flush = () => {
|
|
132
|
+
if (!active) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
active.error = active.error.trim();
|
|
136
|
+
if (active.error.length > 0) {
|
|
137
|
+
failures.push(active);
|
|
138
|
+
}
|
|
139
|
+
active = null;
|
|
140
|
+
};
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
if (!line) {
|
|
143
|
+
if (active) {
|
|
144
|
+
active.rawLines.push('');
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const step = parseStepName(line);
|
|
149
|
+
if (step) {
|
|
150
|
+
currentStep = step;
|
|
151
|
+
flush();
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (/^##\[endgroup\]/i.test(line)) {
|
|
155
|
+
flush();
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (isErrorStart(line)) {
|
|
159
|
+
flush();
|
|
160
|
+
active = {
|
|
161
|
+
stepName: currentStep,
|
|
162
|
+
error: toErrorMessage(line),
|
|
163
|
+
stack: [],
|
|
164
|
+
rawLines: [line],
|
|
165
|
+
};
|
|
166
|
+
const loc = parseLocation(line);
|
|
167
|
+
if (loc) {
|
|
168
|
+
active.location = loc;
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!active) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
active.rawLines.push(line);
|
|
176
|
+
if (isStackLine(line)) {
|
|
177
|
+
active.stack.push(line.trim());
|
|
178
|
+
if (!active.location) {
|
|
179
|
+
const loc = parseLocation(line);
|
|
180
|
+
if (loc) {
|
|
181
|
+
active.location = loc;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const continuation = /^\s+/.test(line) || /^\^+$/.test(line) || /^\.{3}/.test(line);
|
|
187
|
+
if (continuation || line.startsWith('> ') || line.startsWith('|')) {
|
|
188
|
+
active.error = `${active.error}\n${line.trim()}`;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const inlineLoc = parseLocation(line);
|
|
192
|
+
if (inlineLoc && !active.location) {
|
|
193
|
+
active.location = inlineLoc;
|
|
194
|
+
active.stack.push(line.trim());
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
// New unrelated line ends the active block.
|
|
198
|
+
flush();
|
|
199
|
+
}
|
|
200
|
+
flush();
|
|
201
|
+
return dedupeFailures(failures);
|
|
202
|
+
}
|
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
function makeFailureEntry(item) {
|
|
2
|
+
return {
|
|
3
|
+
type: item.classification.type,
|
|
4
|
+
file: item.location?.file,
|
|
5
|
+
line: item.location?.line,
|
|
6
|
+
error: item.error,
|
|
7
|
+
stack: item.stack.length > 0 ? item.stack.join('\n') : undefined,
|
|
8
|
+
flaky: {
|
|
9
|
+
is_flaky: false,
|
|
10
|
+
confidence: 0,
|
|
11
|
+
pass_rate_7d: 0,
|
|
12
|
+
last_5_runs: [],
|
|
13
|
+
},
|
|
14
|
+
severity: item.classification.severity,
|
|
15
|
+
category: item.classification.category,
|
|
16
|
+
suggested_fix: item.classification.suggestedFix,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function buildSteps(failures) {
|
|
20
|
+
const map = new Map();
|
|
21
|
+
for (const failure of failures) {
|
|
22
|
+
const key = failure.stepName || 'Unknown step';
|
|
23
|
+
const existing = map.get(key);
|
|
24
|
+
if (!existing) {
|
|
25
|
+
map.set(key, {
|
|
26
|
+
name: key,
|
|
27
|
+
status: 'failed',
|
|
28
|
+
log_lines: failure.rawLines.length,
|
|
29
|
+
failures: [makeFailureEntry(failure)],
|
|
30
|
+
});
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
existing.log_lines += failure.rawLines.length;
|
|
34
|
+
existing.failures.push(makeFailureEntry(failure));
|
|
35
|
+
}
|
|
36
|
+
return [...map.values()];
|
|
37
|
+
}
|
|
38
|
+
export function buildJsonReport(input) {
|
|
39
|
+
const steps = buildSteps(input.failures);
|
|
40
|
+
const categories = {};
|
|
41
|
+
for (const failure of input.failures) {
|
|
42
|
+
categories[failure.classification.category] = (categories[failure.classification.category] ?? 0) + 1;
|
|
43
|
+
}
|
|
44
|
+
const topCategory = Object.entries(categories).sort((a, b) => b[1] - a[1])[0]?.[0] ?? 'unknown';
|
|
45
|
+
return {
|
|
46
|
+
version: '1.0',
|
|
47
|
+
repo: input.repo,
|
|
48
|
+
run_id: input.run.databaseId,
|
|
49
|
+
run_url: input.run.url,
|
|
50
|
+
commit: input.metadata?.headSha ?? '',
|
|
51
|
+
branch: input.metadata?.headBranch ?? '',
|
|
52
|
+
pr: null,
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
status: input.run.conclusion === 'failure' ? 'failed' : 'success',
|
|
55
|
+
duration_ms: 0,
|
|
56
|
+
jobs: [
|
|
57
|
+
{
|
|
58
|
+
name: input.run.workflowName,
|
|
59
|
+
status: input.run.conclusion === 'failure' ? 'failed' : 'success',
|
|
60
|
+
steps,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
summary: {
|
|
64
|
+
total_failures: input.failures.length,
|
|
65
|
+
flaky_count: 0,
|
|
66
|
+
real_count: input.failures.length,
|
|
67
|
+
categories,
|
|
68
|
+
root_cause: topCategory === 'unknown' ? 'Unknown root cause from failed logs.' : `Most failures are ${topCategory}.`,
|
|
69
|
+
action: topCategory === 'timeout' || topCategory === 'network_error' ? 'retry' : 'fix',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function toJson(report) {
|
|
74
|
+
return `${JSON.stringify(report, null, 2)}\n`;
|
|
75
|
+
}
|
|
76
|
+
export function toMarkdown(report) {
|
|
77
|
+
const lines = [
|
|
78
|
+
`# CI Failure Triage (${report.repo})`,
|
|
79
|
+
'',
|
|
80
|
+
`- run: ${report.run_url}`,
|
|
81
|
+
`- workflow: ${report.jobs[0]?.name ?? 'Unknown'}`,
|
|
82
|
+
`- failures: ${report.summary.total_failures}`,
|
|
83
|
+
`- root cause: ${report.summary.root_cause}`,
|
|
84
|
+
'',
|
|
85
|
+
];
|
|
86
|
+
for (const step of report.jobs[0]?.steps ?? []) {
|
|
87
|
+
lines.push(`## Step: ${step.name}`);
|
|
88
|
+
for (const failure of step.failures) {
|
|
89
|
+
lines.push(`- category: ${failure.category}`);
|
|
90
|
+
lines.push(`- severity: ${failure.severity}`);
|
|
91
|
+
lines.push(`- error: ${failure.error}`);
|
|
92
|
+
if (failure.file) {
|
|
93
|
+
lines.push(`- location: ${failure.file}${failure.line ? `:${failure.line}` : ''}`);
|
|
94
|
+
}
|
|
95
|
+
lines.push(`- suggested fix: ${failure.suggested_fix}`);
|
|
96
|
+
lines.push('');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return lines.join('\n').trimEnd() + '\n';
|
|
100
|
+
}
|
|
101
|
+
export function toConsoleText(report) {
|
|
102
|
+
const lines = [
|
|
103
|
+
`${report.jobs[0]?.name ?? 'workflow'}: ${report.summary.total_failures} failure(s)`,
|
|
104
|
+
`run: ${report.run_url}`,
|
|
105
|
+
`root cause: ${report.summary.root_cause}`,
|
|
106
|
+
];
|
|
107
|
+
for (const step of report.jobs[0]?.steps ?? []) {
|
|
108
|
+
for (const failure of step.failures) {
|
|
109
|
+
lines.push(`- [${failure.severity}] ${failure.category} in ${step.name}`);
|
|
110
|
+
lines.push(` error: ${failure.error}`);
|
|
111
|
+
lines.push(` fix: ${failure.suggested_fix}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return `${lines.join('\n')}\n`;
|
|
115
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ci-triage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open-source CI failure triage for humans and agents — smart log parsing, flake detection, structured JSON, MCP server.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ci-triage": "dist/index.js",
|
|
8
|
+
"ci-triage-mcp": "dist/mcp-server.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"action.yml",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc -p tsconfig.json",
|
|
21
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
22
|
+
"test": "vitest run"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"ci",
|
|
26
|
+
"triage",
|
|
27
|
+
"github-actions",
|
|
28
|
+
"flaky-tests",
|
|
29
|
+
"mcp",
|
|
30
|
+
"coding-agent",
|
|
31
|
+
"devtools",
|
|
32
|
+
"ci-cd"
|
|
33
|
+
],
|
|
34
|
+
"author": "clankamode",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "https://github.com/clankamode/ci-triage.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/clankamode/ci-triage",
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.13.10",
|
|
43
|
+
"typescript": "^5.9.2",
|
|
44
|
+
"vitest": "^4.0.18"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
48
|
+
"fast-xml-parser": "^5.4.1",
|
|
49
|
+
"zod": "^4.3.6"
|
|
50
|
+
}
|
|
51
|
+
}
|