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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 clankamode
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# ci-triage
|
|
2
|
+
|
|
3
|
+
Open-source CI failure triage for humans and agents. Parses CI logs, classifies failures, detects flaky tests, and outputs structured JSON โ with an MCP server for coding agents.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
When CI fails, everyone does the same dance: open the run, scroll logs, squint at errors. Coding agents have it worse โ they can't scroll. **ci-triage** gives both humans and agents structured, queryable failure data.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- ๐ **Smart log parsing** โ extracts errors, file:line references, stack traces from GitHub Actions logs
|
|
12
|
+
- ๐ท๏ธ **Failure classification** โ 16 categories with severity levels and confidence scores
|
|
13
|
+
- ๐งช **Flake detection** โ compares against run history to distinguish flaky from real failures
|
|
14
|
+
- ๐ **JUnit XML support** โ parses standard test result files
|
|
15
|
+
- ๐ **Structured JSON output** โ agent-consumable schema with full failure context
|
|
16
|
+
- ๐ค **MCP server** โ coding agents (Codex, Claude Code) can query failures programmatically
|
|
17
|
+
- ๐ง **GitHub Action** โ drop into any workflow with PR comments and artifacts
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
### CLI
|
|
22
|
+
```bash
|
|
23
|
+
# Install
|
|
24
|
+
npm install -g ci-triage
|
|
25
|
+
|
|
26
|
+
# Triage the most recent failed run
|
|
27
|
+
ci-triage owner/repo
|
|
28
|
+
|
|
29
|
+
# JSON output (for agents)
|
|
30
|
+
ci-triage owner/repo --json
|
|
31
|
+
|
|
32
|
+
# Triage a specific run
|
|
33
|
+
ci-triage owner/repo --run 12345
|
|
34
|
+
|
|
35
|
+
# Save markdown report
|
|
36
|
+
ci-triage owner/repo --md triage.md
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### GitHub Action
|
|
40
|
+
```yaml
|
|
41
|
+
- uses: clankamode/ci-triage@v1
|
|
42
|
+
with:
|
|
43
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
44
|
+
flake-detect: true
|
|
45
|
+
comment: true
|
|
46
|
+
json-artifact: true
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### MCP Server (for coding agents)
|
|
50
|
+
|
|
51
|
+
**Codex CLI** (`~/.codex/config.toml`):
|
|
52
|
+
```toml
|
|
53
|
+
[mcp-servers.ci-triage]
|
|
54
|
+
command = "npx"
|
|
55
|
+
args = ["-y", "ci-triage", "--mcp"]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Claude Code**:
|
|
59
|
+
```bash
|
|
60
|
+
claude mcp add ci-triage npx -y ci-triage --mcp
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Tools exposed:**
|
|
64
|
+
| Tool | Description |
|
|
65
|
+
|------|-------------|
|
|
66
|
+
| `triage_run` | Full triage report for a CI run |
|
|
67
|
+
| `list_failures` | Recent failed runs summary |
|
|
68
|
+
| `is_flaky` | Flake history for a specific test |
|
|
69
|
+
| `suggest_fix` | Fix suggestions for failures |
|
|
70
|
+
|
|
71
|
+
## Output Schema
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"version": "1.0",
|
|
76
|
+
"repo": "owner/repo",
|
|
77
|
+
"run_id": 12345,
|
|
78
|
+
"run_url": "https://github.com/...",
|
|
79
|
+
"commit": "abc1234",
|
|
80
|
+
"branch": "feat/something",
|
|
81
|
+
"status": "failed",
|
|
82
|
+
"jobs": [{
|
|
83
|
+
"name": "test",
|
|
84
|
+
"status": "failed",
|
|
85
|
+
"steps": [{
|
|
86
|
+
"name": "Run tests",
|
|
87
|
+
"failures": [{
|
|
88
|
+
"category": "assertion_error",
|
|
89
|
+
"severity": "medium",
|
|
90
|
+
"error": "Expected 42 but received undefined",
|
|
91
|
+
"file": "src/foo.test.ts",
|
|
92
|
+
"line": 42,
|
|
93
|
+
"flaky": { "is_flaky": true, "confidence": 0.92 },
|
|
94
|
+
"suggested_fix": "Check null handling in Foo.validate()"
|
|
95
|
+
}]
|
|
96
|
+
}]
|
|
97
|
+
}],
|
|
98
|
+
"summary": {
|
|
99
|
+
"total_failures": 1,
|
|
100
|
+
"flaky_count": 1,
|
|
101
|
+
"real_count": 0,
|
|
102
|
+
"root_cause": "Flaky assertion in Foo.validate"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Failure Categories
|
|
108
|
+
|
|
109
|
+
| Category | Severity | Description |
|
|
110
|
+
|----------|----------|-------------|
|
|
111
|
+
| `oom` | critical | Out of memory / heap exhaustion |
|
|
112
|
+
| `port_conflict` | high | Address already in use |
|
|
113
|
+
| `permission_error` | high | EACCES / permission denied |
|
|
114
|
+
| `module_not_found` | high | Missing dependency or import |
|
|
115
|
+
| `assertion_error` | medium | Test failure / assertion mismatch |
|
|
116
|
+
| `docker_error` | high | Container / image failure |
|
|
117
|
+
| `network_error` | medium | Connection refused / timeout |
|
|
118
|
+
| `missing_env` | high | Missing environment variable |
|
|
119
|
+
| `missing_file` | medium | File or artifact not found |
|
|
120
|
+
| `rate_limited` | medium | HTTP 429 / rate limiting |
|
|
121
|
+
| `missing_lockfile` | high | Lockfile not committed |
|
|
122
|
+
| `dependency_security` | high | Vulnerable dependency |
|
|
123
|
+
| `timeout` | medium | Step or job timeout |
|
|
124
|
+
| `lint_error` | low | Linter / formatter failure |
|
|
125
|
+
| `type_error` | high | TypeScript / compilation error |
|
|
126
|
+
| `unknown` | low | Unrecognized (manual triage) |
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
git clone https://github.com/clankamode/ci-triage
|
|
132
|
+
cd ci-triage
|
|
133
|
+
npm install
|
|
134
|
+
npm run build
|
|
135
|
+
npm test
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT
|
|
141
|
+
|
|
142
|
+
## Credits
|
|
143
|
+
|
|
144
|
+
Built by [Clanka](https://clankamode.github.io) โก โ an autonomous engineer.
|
package/action.yml
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
name: "CI Triage"
|
|
2
|
+
description: "Automated CI failure triage with flake detection"
|
|
3
|
+
inputs:
|
|
4
|
+
token:
|
|
5
|
+
description: "GitHub token for API access"
|
|
6
|
+
required: true
|
|
7
|
+
flake-detect:
|
|
8
|
+
description: "Enable flake detection"
|
|
9
|
+
required: false
|
|
10
|
+
default: "true"
|
|
11
|
+
comment:
|
|
12
|
+
description: "Post or update a PR comment"
|
|
13
|
+
required: false
|
|
14
|
+
default: "true"
|
|
15
|
+
json-artifact:
|
|
16
|
+
description: "Upload JSON report as artifact"
|
|
17
|
+
required: false
|
|
18
|
+
default: "true"
|
|
19
|
+
history-depth:
|
|
20
|
+
description: "Number of historical runs to inspect"
|
|
21
|
+
required: false
|
|
22
|
+
default: "20"
|
|
23
|
+
outputs:
|
|
24
|
+
report-json:
|
|
25
|
+
description: "Raw triage report JSON string"
|
|
26
|
+
value: ${{ steps.triage.outputs.report-json }}
|
|
27
|
+
status:
|
|
28
|
+
description: "Triage status (pass/fail/flaky)"
|
|
29
|
+
value: ${{ steps.triage.outputs.status }}
|
|
30
|
+
failure-count:
|
|
31
|
+
description: "Total number of failures"
|
|
32
|
+
value: ${{ steps.triage.outputs.failure-count }}
|
|
33
|
+
runs:
|
|
34
|
+
using: "composite"
|
|
35
|
+
steps:
|
|
36
|
+
- name: Setup Node.js
|
|
37
|
+
uses: actions/setup-node@v4
|
|
38
|
+
with:
|
|
39
|
+
node-version: "20"
|
|
40
|
+
|
|
41
|
+
- name: Install dependencies
|
|
42
|
+
shell: bash
|
|
43
|
+
run: npm ci
|
|
44
|
+
|
|
45
|
+
- name: Build
|
|
46
|
+
shell: bash
|
|
47
|
+
run: npm run build
|
|
48
|
+
|
|
49
|
+
- name: Run triage
|
|
50
|
+
id: triage
|
|
51
|
+
shell: bash
|
|
52
|
+
env:
|
|
53
|
+
INPUT_TOKEN: ${{ inputs.token }}
|
|
54
|
+
GH_TOKEN: ${{ inputs.token }}
|
|
55
|
+
GITHUB_TOKEN: ${{ inputs.token }}
|
|
56
|
+
CI_TRIAGE_FLAKE_DETECT: ${{ inputs.flake-detect }}
|
|
57
|
+
CI_TRIAGE_COMMENT: ${{ inputs.comment }}
|
|
58
|
+
CI_TRIAGE_JSON_ARTIFACT: ${{ inputs.json-artifact }}
|
|
59
|
+
CI_TRIAGE_HISTORY_DEPTH: ${{ inputs.history-depth }}
|
|
60
|
+
run: node dist/action.js
|
|
61
|
+
|
|
62
|
+
- name: Upload triage JSON artifact
|
|
63
|
+
if: ${{ inputs.json-artifact == 'true' && steps.triage.outputs.artifact-path != '' }}
|
|
64
|
+
uses: actions/upload-artifact@v4
|
|
65
|
+
with:
|
|
66
|
+
name: ci-triage-report
|
|
67
|
+
path: ${{ steps.triage.outputs.artifact-path }}
|
package/dist/action.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { appendFileSync } from 'node:fs';
|
|
3
|
+
import { formatComment } from './comment.js';
|
|
4
|
+
import { getPRForRun, postOrUpdateComment, uploadArtifact } from './github.js';
|
|
5
|
+
function parseBool(value, defaultValue) {
|
|
6
|
+
if (!value)
|
|
7
|
+
return defaultValue;
|
|
8
|
+
return value.toLowerCase() === 'true';
|
|
9
|
+
}
|
|
10
|
+
function flattenFailures(job) {
|
|
11
|
+
if (job.failures)
|
|
12
|
+
return job.failures;
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const step of job.steps ?? []) {
|
|
15
|
+
for (const failure of step.failures ?? []) {
|
|
16
|
+
out.push(failure);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function asReport(raw, repo, runId) {
|
|
22
|
+
if (raw && typeof raw === 'object' && 'summary' in raw && 'jobs' in raw && 'status' in raw) {
|
|
23
|
+
return raw;
|
|
24
|
+
}
|
|
25
|
+
const rows = Array.isArray(raw) ? raw : [];
|
|
26
|
+
const jobs = rows.map((row, idx) => ({
|
|
27
|
+
name: row.workflowName ?? `failed-job-${idx + 1}`,
|
|
28
|
+
status: 'failed',
|
|
29
|
+
failures: [
|
|
30
|
+
{
|
|
31
|
+
test_name: row.displayTitle ?? 'Unknown failure',
|
|
32
|
+
error: row.cause ?? 'Unknown cause',
|
|
33
|
+
suggested_fix: row.fix,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}));
|
|
37
|
+
const total = jobs.reduce((acc, job) => acc + flattenFailures(job).length, 0);
|
|
38
|
+
return {
|
|
39
|
+
version: '1.0',
|
|
40
|
+
repo,
|
|
41
|
+
run_id: runId,
|
|
42
|
+
timestamp: new Date().toISOString(),
|
|
43
|
+
status: total > 0 ? 'fail' : 'pass',
|
|
44
|
+
jobs,
|
|
45
|
+
summary: {
|
|
46
|
+
total_failures: total,
|
|
47
|
+
flaky_count: 0,
|
|
48
|
+
real_count: total,
|
|
49
|
+
categories: {},
|
|
50
|
+
root_cause: total > 0 ? 'See failed job sections for causes.' : 'No failures detected.',
|
|
51
|
+
action: total > 0 ? 'fix' : 'none',
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function main() {
|
|
56
|
+
const token = process.env.INPUT_TOKEN;
|
|
57
|
+
const repo = process.env.GITHUB_REPOSITORY;
|
|
58
|
+
const runIdRaw = process.env.GITHUB_RUN_ID;
|
|
59
|
+
const outputPath = process.env.GITHUB_OUTPUT;
|
|
60
|
+
if (!token)
|
|
61
|
+
throw new Error('Missing required input: token');
|
|
62
|
+
if (!repo)
|
|
63
|
+
throw new Error('Missing GITHUB_REPOSITORY');
|
|
64
|
+
if (!outputPath)
|
|
65
|
+
throw new Error('Missing GITHUB_OUTPUT');
|
|
66
|
+
const flakeDetect = parseBool(process.env.CI_TRIAGE_FLAKE_DETECT, true);
|
|
67
|
+
const shouldComment = parseBool(process.env.CI_TRIAGE_COMMENT, true);
|
|
68
|
+
const shouldUploadArtifact = parseBool(process.env.CI_TRIAGE_JSON_ARTIFACT, true);
|
|
69
|
+
const historyDepth = Number(process.env.CI_TRIAGE_HISTORY_DEPTH ?? '20');
|
|
70
|
+
const runId = runIdRaw ? Number(runIdRaw) : undefined;
|
|
71
|
+
const triageArgs = ['dist/index.js', repo, '--json'];
|
|
72
|
+
if (flakeDetect) {
|
|
73
|
+
triageArgs.push('--flake-detect', '--history', String(historyDepth));
|
|
74
|
+
}
|
|
75
|
+
const rawJson = execFileSync('node', triageArgs, {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
env: {
|
|
78
|
+
...process.env,
|
|
79
|
+
GH_TOKEN: token,
|
|
80
|
+
GITHUB_TOKEN: token,
|
|
81
|
+
},
|
|
82
|
+
}).trim();
|
|
83
|
+
const rawReport = rawJson ? JSON.parse(rawJson) : [];
|
|
84
|
+
const report = asReport(rawReport, repo, runId);
|
|
85
|
+
const reportJson = JSON.stringify(report);
|
|
86
|
+
const failureCount = String(report.summary.total_failures);
|
|
87
|
+
const status = report.status === 'failed' ? 'fail' : report.status;
|
|
88
|
+
let artifactPath = '';
|
|
89
|
+
if (shouldUploadArtifact) {
|
|
90
|
+
artifactPath = uploadArtifact('ci-triage-report', JSON.stringify(report, null, 2));
|
|
91
|
+
}
|
|
92
|
+
if (shouldComment && runId !== undefined) {
|
|
93
|
+
const prNumber = getPRForRun(repo, runId, token);
|
|
94
|
+
if (prNumber !== null) {
|
|
95
|
+
const body = formatComment(report);
|
|
96
|
+
postOrUpdateComment(repo, prNumber, body, token);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
appendFileSync(outputPath, `report-json<<__CI_TRIAGE__\n${reportJson}\n__CI_TRIAGE__\n`);
|
|
100
|
+
appendFileSync(outputPath, `status=${status}\n`);
|
|
101
|
+
appendFileSync(outputPath, `failure-count=${failureCount}\n`);
|
|
102
|
+
appendFileSync(outputPath, `artifact-path=${artifactPath}\n`);
|
|
103
|
+
}
|
|
104
|
+
main();
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
const RULES = [
|
|
2
|
+
{
|
|
3
|
+
category: 'oom',
|
|
4
|
+
severity: 'critical',
|
|
5
|
+
type: 'infra_resource',
|
|
6
|
+
cause: 'Out of memory (OOM/heap)',
|
|
7
|
+
suggestedFix: 'Increase runner memory, reduce parallelism, or tune heap settings.',
|
|
8
|
+
patterns: [/\b(?:heap out of memory|out of memory|oom\b|killed process \d+)\b/i],
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
category: 'port_conflict',
|
|
12
|
+
severity: 'high',
|
|
13
|
+
type: 'runtime_error',
|
|
14
|
+
cause: 'Port already in use',
|
|
15
|
+
suggestedFix: 'Use dynamic ports and ensure tests/services close listeners during teardown.',
|
|
16
|
+
patterns: [/\b(?:eaddrinuse|address already in use|port \d+.*in use)\b/i],
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
category: 'permission_error',
|
|
20
|
+
severity: 'high',
|
|
21
|
+
type: 'permission_error',
|
|
22
|
+
cause: 'Permission denied (EACCES)',
|
|
23
|
+
suggestedFix: 'Fix file permissions/ownership and confirm required executable bits are set.',
|
|
24
|
+
patterns: [/\b(?:eacces|permission denied|operation not permitted)\b/i],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
category: 'module_not_found',
|
|
28
|
+
severity: 'high',
|
|
29
|
+
type: 'build_error',
|
|
30
|
+
cause: 'Module not found',
|
|
31
|
+
suggestedFix: 'Install missing dependency and verify import paths/casing and lockfile sync.',
|
|
32
|
+
patterns: [/\b(?:cannot find module|module not found|err_module_not_found)\b/i],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
category: 'assertion_error',
|
|
36
|
+
severity: 'medium',
|
|
37
|
+
type: 'test_assertion',
|
|
38
|
+
cause: 'Test failure/assertion error',
|
|
39
|
+
suggestedFix: 'Inspect failing expectations and fix logic or stabilize flaky assertions.',
|
|
40
|
+
patterns: [/\b(?:assertionerror|assertion failed|expected .* but received|\bFAIL\b.*\.(?:test|spec)\.)\b/i],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
category: 'docker_error',
|
|
44
|
+
severity: 'high',
|
|
45
|
+
type: 'infra_container',
|
|
46
|
+
cause: 'Docker/container failure',
|
|
47
|
+
suggestedFix: 'Check image pull/startup logs and container service health in CI.',
|
|
48
|
+
patterns: [/\b(?:docker|containerd|failed to start container|no such image)\b/i],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
category: 'network_error',
|
|
52
|
+
severity: 'medium',
|
|
53
|
+
type: 'network_error',
|
|
54
|
+
cause: 'Network/connectivity error',
|
|
55
|
+
suggestedFix: 'Retry transient calls, validate endpoints, and tune network timeouts.',
|
|
56
|
+
patterns: [/\b(?:econnrefused|etimedout|network error|connection refused|getaddrinfo enotfound)\b/i],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
category: 'missing_env',
|
|
60
|
+
severity: 'high',
|
|
61
|
+
type: 'configuration_error',
|
|
62
|
+
cause: 'Missing environment variable',
|
|
63
|
+
suggestedFix: 'Set required CI secrets/variables and validate configuration at startup.',
|
|
64
|
+
patterns: [/\b(?:missing env(?:ironment)? variable|undefined env|environment variable .* not set)\b/i],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
category: 'missing_file',
|
|
68
|
+
severity: 'medium',
|
|
69
|
+
type: 'build_error',
|
|
70
|
+
cause: 'Missing file/build artifact',
|
|
71
|
+
suggestedFix: 'Verify artifact generation and ensure inter-job paths are correct.',
|
|
72
|
+
patterns: [/\b(?:no such file or directory|file not found|artifact not found|enoent)\b/i],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
category: 'rate_limited',
|
|
76
|
+
severity: 'medium',
|
|
77
|
+
type: 'external_service',
|
|
78
|
+
cause: 'Rate limited (429)',
|
|
79
|
+
suggestedFix: 'Add backoff/retries and reduce burst traffic to external APIs.',
|
|
80
|
+
patterns: [/\b(?:rate limit(?:ed)?|http\s*429|too many requests)\b/i],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
category: 'missing_lockfile',
|
|
84
|
+
severity: 'high',
|
|
85
|
+
type: 'build_error',
|
|
86
|
+
cause: 'Missing dependency lockfile',
|
|
87
|
+
suggestedFix: 'Commit lockfile and rerun CI.',
|
|
88
|
+
patterns: [/\b(?:lock file is not found|package-lock\.json.*not found|yarn\.lock.*not found|pnpm-lock\.yaml.*not found)\b/i],
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
category: 'dependency_security',
|
|
92
|
+
severity: 'high',
|
|
93
|
+
type: 'security_gate',
|
|
94
|
+
cause: 'Dependency security gate failure',
|
|
95
|
+
suggestedFix: 'Upgrade vulnerable dependencies or adjust policy gates where appropriate.',
|
|
96
|
+
patterns: [/\b(?:npm audit|found [1-9]\d* vulnerabilit(?:y|ies)|dependency check failed|security audit failed)\b/i],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
category: 'timeout',
|
|
100
|
+
severity: 'medium',
|
|
101
|
+
type: 'timeout_error',
|
|
102
|
+
cause: 'Timeout/flaky infrastructure',
|
|
103
|
+
suggestedFix: 'Increase timeout, split long-running steps, or add targeted retries.',
|
|
104
|
+
patterns: [/\b(?:timed out|timeout exceeded|operation timed out|deadline exceeded)\b/i],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
category: 'lint_error',
|
|
108
|
+
severity: 'low',
|
|
109
|
+
type: 'quality_gate',
|
|
110
|
+
cause: 'Lint/format failure',
|
|
111
|
+
suggestedFix: 'Run local linter/formatter and apply fixes.',
|
|
112
|
+
patterns: [/\b(?:eslint|prettier|lint(?:ing)? failed|style check failed)\b/i],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
category: 'type_error',
|
|
116
|
+
severity: 'high',
|
|
117
|
+
type: 'compile_error',
|
|
118
|
+
cause: 'TypeScript/build break',
|
|
119
|
+
suggestedFix: 'Run build locally and fix type or module resolution issues.',
|
|
120
|
+
patterns: [/\b(?:ts\d{4}:|error\s+ts\d{4}|type error|tsc\b.*error|compilation failed)\b/i],
|
|
121
|
+
},
|
|
122
|
+
];
|
|
123
|
+
function confidenceFromMatch(failure, patternHits) {
|
|
124
|
+
let score = 0.45;
|
|
125
|
+
score += Math.min(0.35, patternHits * 0.2);
|
|
126
|
+
if (failure.location) {
|
|
127
|
+
score += 0.1;
|
|
128
|
+
}
|
|
129
|
+
if (failure.stack.length > 0) {
|
|
130
|
+
score += 0.1;
|
|
131
|
+
}
|
|
132
|
+
return Number(Math.min(0.99, score).toFixed(2));
|
|
133
|
+
}
|
|
134
|
+
export function classify(failure) {
|
|
135
|
+
const haystack = [failure.error, ...failure.stack, ...failure.rawLines].join('\n');
|
|
136
|
+
for (const rule of RULES) {
|
|
137
|
+
const hits = rule.patterns.filter((pattern) => pattern.test(haystack)).length;
|
|
138
|
+
if (hits > 0) {
|
|
139
|
+
return {
|
|
140
|
+
category: rule.category,
|
|
141
|
+
severity: rule.severity,
|
|
142
|
+
confidence: confidenceFromMatch(failure, hits),
|
|
143
|
+
cause: rule.cause,
|
|
144
|
+
suggestedFix: rule.suggestedFix,
|
|
145
|
+
type: rule.type,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
category: 'unknown',
|
|
151
|
+
severity: 'low',
|
|
152
|
+
confidence: 0.35,
|
|
153
|
+
cause: 'Unknown (manual triage needed)',
|
|
154
|
+
suggestedFix: 'Open failed logs and inspect the first failing step in detail.',
|
|
155
|
+
type: 'unknown_failure',
|
|
156
|
+
};
|
|
157
|
+
}
|
package/dist/comment.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const CI_TRIAGE_MARKER = '<!-- ci-triage-comment -->';
|
|
2
|
+
function statusEmoji(status) {
|
|
3
|
+
if (status === 'pass')
|
|
4
|
+
return 'โ
';
|
|
5
|
+
if (status === 'flaky')
|
|
6
|
+
return 'โ ๏ธ';
|
|
7
|
+
return 'โ';
|
|
8
|
+
}
|
|
9
|
+
function normalizeStatus(status) {
|
|
10
|
+
if (status === 'pass')
|
|
11
|
+
return 'pass';
|
|
12
|
+
if (status === 'flaky')
|
|
13
|
+
return 'flaky';
|
|
14
|
+
return 'fail';
|
|
15
|
+
}
|
|
16
|
+
function flattenFailures(job) {
|
|
17
|
+
if (job.failures && job.failures.length > 0) {
|
|
18
|
+
return job.failures;
|
|
19
|
+
}
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const step of job.steps ?? []) {
|
|
22
|
+
for (const failure of step.failures ?? []) {
|
|
23
|
+
out.push(failure);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
function formatFileLink(report, failure) {
|
|
29
|
+
if (!failure.file)
|
|
30
|
+
return '_unknown location_';
|
|
31
|
+
const line = failure.line ?? 1;
|
|
32
|
+
if (report.commit) {
|
|
33
|
+
const url = `https://github.com/${report.repo}/blob/${report.commit}/${failure.file}#L${line}`;
|
|
34
|
+
return `[${failure.file}:${line}](${url})`;
|
|
35
|
+
}
|
|
36
|
+
return `\`${failure.file}:${line}\``;
|
|
37
|
+
}
|
|
38
|
+
function formatFailure(report, failure) {
|
|
39
|
+
const testName = failure.test_name ?? '_unnamed test_';
|
|
40
|
+
const error = failure.error ?? '_no error message_';
|
|
41
|
+
const location = formatFileLink(report, failure);
|
|
42
|
+
const flakyBadge = failure.flaky?.is_flaky ? ' `flaky`' : '';
|
|
43
|
+
const fix = failure.suggested_fix ? `\n- Fix: ${failure.suggested_fix}` : '';
|
|
44
|
+
return `- **${testName}**${flakyBadge}\n- Error: ${error}\n- Location: ${location}${fix}`;
|
|
45
|
+
}
|
|
46
|
+
export function formatComment(report) {
|
|
47
|
+
const status = normalizeStatus(report.status);
|
|
48
|
+
const emoji = statusEmoji(status);
|
|
49
|
+
const total = report.summary.total_failures;
|
|
50
|
+
const flaky = report.summary.flaky_count;
|
|
51
|
+
const real = report.summary.real_count;
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push(CI_TRIAGE_MARKER);
|
|
54
|
+
lines.push(`${emoji} **CI Triage: ${status.toUpperCase()}**`);
|
|
55
|
+
lines.push('');
|
|
56
|
+
lines.push(`Failures: **${total}** | Flaky: **${flaky}** | Real: **${real}**`);
|
|
57
|
+
if (status === 'pass') {
|
|
58
|
+
lines.push('');
|
|
59
|
+
lines.push('No failing jobs were detected in this run.');
|
|
60
|
+
}
|
|
61
|
+
for (const job of report.jobs.filter((j) => j.status === 'failed' || j.status === 'fail')) {
|
|
62
|
+
const failures = flattenFailures(job);
|
|
63
|
+
lines.push('');
|
|
64
|
+
lines.push(`<details>`);
|
|
65
|
+
lines.push(`<summary><strong>${job.name}</strong> (${failures.length} failures)</summary>`);
|
|
66
|
+
lines.push('');
|
|
67
|
+
if (failures.length === 0) {
|
|
68
|
+
lines.push('- No parsed failure details were found for this job.');
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
for (const failure of failures) {
|
|
72
|
+
lines.push(formatFailure(report, failure));
|
|
73
|
+
lines.push('');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
lines.push(`</details>`);
|
|
77
|
+
}
|
|
78
|
+
lines.push('');
|
|
79
|
+
lines.push('[Generated by ci-triage](https://github.com/clankamode/ci-triage)');
|
|
80
|
+
return lines.join('\n').trim();
|
|
81
|
+
}
|
package/dist/fetcher.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
function runJson(args) {
|
|
3
|
+
const out = execFileSync('gh', args, { encoding: 'utf8' });
|
|
4
|
+
return JSON.parse(out);
|
|
5
|
+
}
|
|
6
|
+
export function fetchRuns(repo, limit) {
|
|
7
|
+
return runJson([
|
|
8
|
+
'run',
|
|
9
|
+
'list',
|
|
10
|
+
'--repo',
|
|
11
|
+
repo,
|
|
12
|
+
'--limit',
|
|
13
|
+
String(limit),
|
|
14
|
+
'--json',
|
|
15
|
+
'databaseId,displayTitle,workflowName,conclusion,url',
|
|
16
|
+
]);
|
|
17
|
+
}
|
|
18
|
+
export function fetchRunById(repo, runId) {
|
|
19
|
+
const runs = runJson([
|
|
20
|
+
'run',
|
|
21
|
+
'list',
|
|
22
|
+
'--repo',
|
|
23
|
+
repo,
|
|
24
|
+
'--limit',
|
|
25
|
+
'100',
|
|
26
|
+
'--json',
|
|
27
|
+
'databaseId,displayTitle,workflowName,conclusion,url',
|
|
28
|
+
]);
|
|
29
|
+
const run = runs.find((r) => r.databaseId === runId);
|
|
30
|
+
if (!run) {
|
|
31
|
+
throw new Error(`Run ${runId} not found in recent runs.`);
|
|
32
|
+
}
|
|
33
|
+
return run;
|
|
34
|
+
}
|
|
35
|
+
export function fetchRunMetadata(repo, runId) {
|
|
36
|
+
try {
|
|
37
|
+
const result = runJson([
|
|
38
|
+
'run',
|
|
39
|
+
'view',
|
|
40
|
+
String(runId),
|
|
41
|
+
'--repo',
|
|
42
|
+
repo,
|
|
43
|
+
'--json',
|
|
44
|
+
'headSha,headBranch,event',
|
|
45
|
+
]);
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return { headSha: '', headBranch: '', event: '' };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function fetchFailedLog(repo, runId) {
|
|
53
|
+
try {
|
|
54
|
+
return execFileSync('gh', ['run', 'view', String(runId), '--repo', repo, '--log-failed'], {
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
}
|