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 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
+ }
@@ -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
+ }
@@ -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
+ }