ci-triage 0.1.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/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 +211 -0
- package/dist/index.js +485 -21
- package/dist/llm-analyzer.js +106 -0
- package/dist/multi.js +116 -0
- package/dist/parser.js +116 -0
- package/dist/providers/circleci.js +106 -0
- package/dist/providers/github.js +103 -0
- package/dist/providers/gitlab.js +96 -0
- package/dist/providers/index.js +44 -0
- package/dist/providers/types.js +2 -0
- package/dist/repo-context.js +97 -0
- package/dist/reporter.js +1 -0
- package/package.json +6 -2
package/dist/multi.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
function reportFailures(report) {
|
|
2
|
+
const failures = [];
|
|
3
|
+
for (const job of report.jobs) {
|
|
4
|
+
for (const step of job.steps ?? []) {
|
|
5
|
+
failures.push(...step.failures);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
return failures;
|
|
9
|
+
}
|
|
10
|
+
function normalizeError(error) {
|
|
11
|
+
return error.replace(/\s+/g, ' ').trim();
|
|
12
|
+
}
|
|
13
|
+
export function detectSharedPatterns(reports) {
|
|
14
|
+
const byFixAction = new Map();
|
|
15
|
+
const byCategory = new Map();
|
|
16
|
+
const byErrorSubstring = new Map();
|
|
17
|
+
for (const report of reports) {
|
|
18
|
+
const failures = reportFailures(report);
|
|
19
|
+
const repoFixActions = new Set();
|
|
20
|
+
const repoCategories = new Set();
|
|
21
|
+
const repoErrorSubstrings = new Set();
|
|
22
|
+
for (const failure of failures) {
|
|
23
|
+
repoFixActions.add(failure.fix_action ?? 'unknown');
|
|
24
|
+
repoCategories.add(failure.category);
|
|
25
|
+
const normalized = normalizeError(failure.error);
|
|
26
|
+
if (normalized.length > 20) {
|
|
27
|
+
repoErrorSubstrings.add(normalized.slice(0, 120));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
for (const action of repoFixActions) {
|
|
31
|
+
const repos = byFixAction.get(action) ?? new Set();
|
|
32
|
+
repos.add(report.repo);
|
|
33
|
+
byFixAction.set(action, repos);
|
|
34
|
+
}
|
|
35
|
+
for (const category of repoCategories) {
|
|
36
|
+
const repos = byCategory.get(category) ?? new Set();
|
|
37
|
+
repos.add(report.repo);
|
|
38
|
+
byCategory.set(category, repos);
|
|
39
|
+
}
|
|
40
|
+
for (const substring of repoErrorSubstrings) {
|
|
41
|
+
const repos = byErrorSubstring.get(substring) ?? new Set();
|
|
42
|
+
repos.add(report.repo);
|
|
43
|
+
byErrorSubstring.set(substring, repos);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const patterns = [];
|
|
47
|
+
for (const [fix_action, repos] of byFixAction) {
|
|
48
|
+
if (repos.size >= 2) {
|
|
49
|
+
patterns.push({
|
|
50
|
+
fix_action,
|
|
51
|
+
repos: [...repos].sort(),
|
|
52
|
+
description: `Common fix action across repos: ${fix_action}`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const [category, repos] of byCategory) {
|
|
57
|
+
if (repos.size >= 2) {
|
|
58
|
+
patterns.push({
|
|
59
|
+
category,
|
|
60
|
+
repos: [...repos].sort(),
|
|
61
|
+
description: `Common category across repos: ${category}`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const [error_substring, repos] of byErrorSubstring) {
|
|
66
|
+
if (repos.size >= 2) {
|
|
67
|
+
patterns.push({
|
|
68
|
+
error_substring,
|
|
69
|
+
repos: [...repos].sort(),
|
|
70
|
+
description: `Common error text across repos: ${error_substring}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return patterns;
|
|
75
|
+
}
|
|
76
|
+
export function formatMultiSummary(reports, patterns) {
|
|
77
|
+
const lines = [`Multi-repo triage: ${reports.length} repos`, ''];
|
|
78
|
+
if (patterns.length === 0) {
|
|
79
|
+
lines.push('Shared patterns detected: none', '');
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
lines.push('Shared patterns detected:');
|
|
83
|
+
for (const pattern of patterns) {
|
|
84
|
+
const repos = pattern.repos.map((repo) => repo.split('/').at(-1) ?? repo).join(', ');
|
|
85
|
+
if (pattern.fix_action) {
|
|
86
|
+
lines.push(` [${pattern.fix_action}] ${pattern.repos.length} repos affected: ${repos}`);
|
|
87
|
+
}
|
|
88
|
+
else if (pattern.category) {
|
|
89
|
+
lines.push(` [${pattern.category}] ${pattern.repos.length} repos affected: ${repos}`);
|
|
90
|
+
}
|
|
91
|
+
else if (pattern.error_substring) {
|
|
92
|
+
lines.push(` [error] ${pattern.repos.length} repos affected: ${repos}`);
|
|
93
|
+
}
|
|
94
|
+
lines.push(` -> ${pattern.description}`);
|
|
95
|
+
}
|
|
96
|
+
lines.push('');
|
|
97
|
+
}
|
|
98
|
+
lines.push('Per-repo:');
|
|
99
|
+
for (const report of reports) {
|
|
100
|
+
const failures = reportFailures(report);
|
|
101
|
+
const actionCounts = new Map();
|
|
102
|
+
for (const failure of failures) {
|
|
103
|
+
const action = failure.fix_action ?? 'unknown';
|
|
104
|
+
actionCounts.set(action, (actionCounts.get(action) ?? 0) + 1);
|
|
105
|
+
}
|
|
106
|
+
const actionSummary = [...actionCounts.entries()]
|
|
107
|
+
.sort((a, b) => b[1] - a[1])
|
|
108
|
+
.map(([action, count]) => `${action} x${count}`)
|
|
109
|
+
.join(', ');
|
|
110
|
+
lines.push(` ${report.repo} - ${failures.length} failures${actionSummary ? ` (${actionSummary})` : ''}`);
|
|
111
|
+
}
|
|
112
|
+
return `${lines.join('\n')}\n`;
|
|
113
|
+
}
|
|
114
|
+
export function buildMultiJson(reports, patterns) {
|
|
115
|
+
return `${JSON.stringify({ repos: reports, patterns }, null, 2)}\n`;
|
|
116
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const CIRCLE_API = 'https://circleci.com/api/v2';
|
|
2
|
+
function token() {
|
|
3
|
+
return process.env['CIRCLE_TOKEN'];
|
|
4
|
+
}
|
|
5
|
+
async function circleFetch(path) {
|
|
6
|
+
const tok = token();
|
|
7
|
+
if (!tok)
|
|
8
|
+
throw new Error('CIRCLE_TOKEN env var is required for CircleCI provider.');
|
|
9
|
+
const res = await fetch(`${CIRCLE_API}${path}`, {
|
|
10
|
+
headers: { 'Circle-Token': tok },
|
|
11
|
+
});
|
|
12
|
+
if (!res.ok)
|
|
13
|
+
throw new Error(`CircleCI API error ${res.status}: ${res.statusText} — ${path}`);
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
export class CircleCiProvider {
|
|
17
|
+
name = 'circleci';
|
|
18
|
+
async canHandle(_repo) {
|
|
19
|
+
if (!token()) {
|
|
20
|
+
console.error([
|
|
21
|
+
'Error: CircleCI provider requires CIRCLE_TOKEN environment variable.',
|
|
22
|
+
' Create a personal API token at: https://app.circleci.com/settings/user/tokens',
|
|
23
|
+
' Then set: export CIRCLE_TOKEN=<your-token>',
|
|
24
|
+
].join('\n'));
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
/** repo format: gh/orgname/reponame or bb/org/repo */
|
|
30
|
+
async listRuns(repo, limit) {
|
|
31
|
+
const data = await circleFetch(`/project/${repo}/pipeline?page-token=&per_page=${Math.min(limit, 50)}`);
|
|
32
|
+
return data.items.map((p) => ({
|
|
33
|
+
id: p.id,
|
|
34
|
+
displayTitle: `Pipeline #${p.number}`,
|
|
35
|
+
workflowName: 'CircleCI',
|
|
36
|
+
conclusion: p.state === 'created' ? null : p.state,
|
|
37
|
+
url: `https://app.circleci.com/pipelines/${repo}/${p.number}`,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
async resolveRun(repo, runId) {
|
|
41
|
+
const runs = await this.listRuns(repo, 20);
|
|
42
|
+
if (runId !== undefined) {
|
|
43
|
+
const run = runs.find((r) => String(r.id) === String(runId));
|
|
44
|
+
if (!run)
|
|
45
|
+
throw new Error(`Pipeline ${runId} not found for ${repo}.`);
|
|
46
|
+
return run;
|
|
47
|
+
}
|
|
48
|
+
const failed = runs.find((r) => r.conclusion === 'errored' || r.conclusion === 'failed');
|
|
49
|
+
if (!failed)
|
|
50
|
+
throw new Error(`No failed pipelines found for ${repo} in recent history.`);
|
|
51
|
+
return failed;
|
|
52
|
+
}
|
|
53
|
+
async fetchLogs(ref) {
|
|
54
|
+
if (!ref.runId)
|
|
55
|
+
throw new Error('runId required for CircleCI provider');
|
|
56
|
+
const byJob = {};
|
|
57
|
+
try {
|
|
58
|
+
// Get workflows for this pipeline
|
|
59
|
+
const wfData = await circleFetch(`/pipeline/${ref.runId}/workflow`);
|
|
60
|
+
for (const wf of wfData.items) {
|
|
61
|
+
if (wf.status !== 'failed')
|
|
62
|
+
continue;
|
|
63
|
+
const jobData = await circleFetch(`/workflow/${wf.id}/job`);
|
|
64
|
+
for (const job of jobData.items) {
|
|
65
|
+
if (job.status !== 'failed' || !job.job_number)
|
|
66
|
+
continue;
|
|
67
|
+
try {
|
|
68
|
+
const artifacts = await circleFetch(`/project/${ref.repo}/${job.job_number}/artifacts`);
|
|
69
|
+
for (const artifact of artifacts.items) {
|
|
70
|
+
if (artifact.path.endsWith('.log') || artifact.path.endsWith('output.txt')) {
|
|
71
|
+
const logRes = await fetch(artifact.url, {
|
|
72
|
+
headers: { 'Circle-Token': token() },
|
|
73
|
+
});
|
|
74
|
+
if (logRes.ok)
|
|
75
|
+
byJob[job.name] = await logRes.text();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// skip if artifact fetch fails
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// return empty on any error
|
|
87
|
+
}
|
|
88
|
+
return { combined: Object.values(byJob).join('\n'), byJob };
|
|
89
|
+
}
|
|
90
|
+
async fetchMetadata(ref) {
|
|
91
|
+
if (!ref.runId)
|
|
92
|
+
return { headSha: '', headBranch: '', event: '' };
|
|
93
|
+
try {
|
|
94
|
+
const pipeline = await circleFetch(`/pipeline/${ref.runId}`);
|
|
95
|
+
return {
|
|
96
|
+
headSha: pipeline.vcs?.revision ?? '',
|
|
97
|
+
headBranch: pipeline.vcs?.branch ?? '',
|
|
98
|
+
event: 'push',
|
|
99
|
+
url: `https://app.circleci.com/pipelines/${ref.repo}/${pipeline.number}`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { headSha: '', headBranch: '', event: '' };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
function runJson(args) {
|
|
3
|
+
const out = execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
4
|
+
return JSON.parse(out);
|
|
5
|
+
}
|
|
6
|
+
function runText(args) {
|
|
7
|
+
return execFileSync('gh', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
8
|
+
}
|
|
9
|
+
function checkGhAvailable() {
|
|
10
|
+
try {
|
|
11
|
+
execFileSync('gh', ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function checkGhAuth() {
|
|
19
|
+
try {
|
|
20
|
+
execFileSync('gh', ['auth', 'status'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class GitHubProvider {
|
|
28
|
+
name = 'github';
|
|
29
|
+
async canHandle(_repo) {
|
|
30
|
+
if (!checkGhAvailable()) {
|
|
31
|
+
console.error([
|
|
32
|
+
'Error: `gh` CLI is required for GitHub mode but was not found.',
|
|
33
|
+
' Install: https://cli.github.com/',
|
|
34
|
+
' macOS: brew install gh',
|
|
35
|
+
' After installing, run: gh auth login',
|
|
36
|
+
].join('\n'));
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (!checkGhAuth()) {
|
|
40
|
+
console.error([
|
|
41
|
+
'Error: `gh` CLI is installed but not authenticated.',
|
|
42
|
+
' Run: gh auth login',
|
|
43
|
+
' If you intended to use GitLab or CircleCI, pass --provider gitlab|circleci',
|
|
44
|
+
].join('\n'));
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
async listRuns(repo, limit) {
|
|
50
|
+
const runs = runJson([
|
|
51
|
+
'run', 'list', '--repo', repo, '--limit', String(limit),
|
|
52
|
+
'--json', 'databaseId,displayTitle,workflowName,conclusion,url',
|
|
53
|
+
]);
|
|
54
|
+
return runs.map((r) => ({
|
|
55
|
+
id: r.databaseId,
|
|
56
|
+
displayTitle: r.displayTitle,
|
|
57
|
+
workflowName: r.workflowName,
|
|
58
|
+
conclusion: r.conclusion,
|
|
59
|
+
url: r.url,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
async resolveRun(repo, runId) {
|
|
63
|
+
const runs = await this.listRuns(repo, 100);
|
|
64
|
+
if (runId !== undefined) {
|
|
65
|
+
const run = runs.find((r) => String(r.id) === String(runId));
|
|
66
|
+
if (!run)
|
|
67
|
+
throw new Error(`Run ${runId} not found in recent runs for ${repo}.`);
|
|
68
|
+
return run;
|
|
69
|
+
}
|
|
70
|
+
// Most recent failed run
|
|
71
|
+
const failed = runs.find((r) => r.conclusion === 'failure');
|
|
72
|
+
if (!failed)
|
|
73
|
+
throw new Error(`No failed runs found for ${repo} in recent history.`);
|
|
74
|
+
return failed;
|
|
75
|
+
}
|
|
76
|
+
async fetchLogs(ref) {
|
|
77
|
+
if (!ref.runId)
|
|
78
|
+
throw new Error('runId required for GitHub provider');
|
|
79
|
+
try {
|
|
80
|
+
const combined = runText([
|
|
81
|
+
'run', 'view', String(ref.runId), '--repo', ref.repo, '--log-failed',
|
|
82
|
+
]);
|
|
83
|
+
return { combined };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return { combined: '' };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async fetchMetadata(ref) {
|
|
90
|
+
if (!ref.runId)
|
|
91
|
+
return { headSha: '', headBranch: '', event: '' };
|
|
92
|
+
try {
|
|
93
|
+
const result = runJson([
|
|
94
|
+
'run', 'view', String(ref.runId), '--repo', ref.repo,
|
|
95
|
+
'--json', 'headSha,headBranch,event',
|
|
96
|
+
]);
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { headSha: '', headBranch: '', event: '' };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const GITLAB_API = 'https://gitlab.com/api/v4';
|
|
2
|
+
function token() {
|
|
3
|
+
return process.env['GITLAB_TOKEN'];
|
|
4
|
+
}
|
|
5
|
+
async function gitlabFetch(path) {
|
|
6
|
+
const tok = token();
|
|
7
|
+
if (!tok)
|
|
8
|
+
throw new Error('GITLAB_TOKEN env var is required for GitLab provider.');
|
|
9
|
+
const res = await fetch(`${GITLAB_API}${path}`, {
|
|
10
|
+
headers: { 'PRIVATE-TOKEN': tok },
|
|
11
|
+
});
|
|
12
|
+
if (!res.ok)
|
|
13
|
+
throw new Error(`GitLab API error ${res.status}: ${res.statusText} — ${path}`);
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
function encodedRepo(repo) {
|
|
17
|
+
// repo format: group/project — encode slashes for GitLab API
|
|
18
|
+
return encodeURIComponent(repo);
|
|
19
|
+
}
|
|
20
|
+
export class GitLabProvider {
|
|
21
|
+
name = 'gitlab';
|
|
22
|
+
async canHandle(_repo) {
|
|
23
|
+
if (!token()) {
|
|
24
|
+
console.error([
|
|
25
|
+
'Error: GitLab provider requires GITLAB_TOKEN environment variable.',
|
|
26
|
+
' Create a personal access token at: https://gitlab.com/-/profile/personal_access_tokens',
|
|
27
|
+
' Required scopes: read_api',
|
|
28
|
+
' Then set: export GITLAB_TOKEN=<your-token>',
|
|
29
|
+
].join('\n'));
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
async listRuns(repo, limit) {
|
|
35
|
+
const pipelines = await gitlabFetch(`/projects/${encodedRepo(repo)}/pipelines?per_page=${limit}`);
|
|
36
|
+
return pipelines.map((p) => ({
|
|
37
|
+
id: p.id,
|
|
38
|
+
displayTitle: `Pipeline #${p.id} (${p.ref})`,
|
|
39
|
+
workflowName: 'GitLab CI',
|
|
40
|
+
conclusion: p.status === 'success' ? 'success' : p.status === 'failed' ? 'failure' : p.status,
|
|
41
|
+
url: p.web_url,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
async resolveRun(repo, runId) {
|
|
45
|
+
if (runId !== undefined) {
|
|
46
|
+
const pipeline = await gitlabFetch(`/projects/${encodedRepo(repo)}/pipelines/${runId}`);
|
|
47
|
+
return {
|
|
48
|
+
id: pipeline.id,
|
|
49
|
+
displayTitle: `Pipeline #${pipeline.id} (${pipeline.ref})`,
|
|
50
|
+
workflowName: 'GitLab CI',
|
|
51
|
+
conclusion: pipeline.status === 'success' ? 'success' : pipeline.status === 'failed' ? 'failure' : pipeline.status,
|
|
52
|
+
url: pipeline.web_url,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const runs = await this.listRuns(repo, 20);
|
|
56
|
+
const failed = runs.find((r) => r.conclusion === 'failure');
|
|
57
|
+
if (!failed)
|
|
58
|
+
throw new Error(`No failed pipelines found for ${repo} in recent history.`);
|
|
59
|
+
return failed;
|
|
60
|
+
}
|
|
61
|
+
async fetchLogs(ref) {
|
|
62
|
+
if (!ref.runId)
|
|
63
|
+
throw new Error('runId required for GitLab provider');
|
|
64
|
+
const jobs = await gitlabFetch(`/projects/${encodedRepo(ref.repo)}/pipelines/${ref.runId}/jobs?scope[]=failed`);
|
|
65
|
+
const byJob = {};
|
|
66
|
+
for (const job of jobs) {
|
|
67
|
+
try {
|
|
68
|
+
const logRes = await fetch(`${GITLAB_API}/projects/${encodedRepo(ref.repo)}/jobs/${job.id}/trace`, { headers: { 'PRIVATE-TOKEN': token() } });
|
|
69
|
+
if (logRes.ok) {
|
|
70
|
+
byJob[job.name] = await logRes.text();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// skip job if log fetch fails
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const combined = Object.values(byJob).join('\n');
|
|
78
|
+
return { combined, byJob };
|
|
79
|
+
}
|
|
80
|
+
async fetchMetadata(ref) {
|
|
81
|
+
if (!ref.runId)
|
|
82
|
+
return { headSha: '', headBranch: '', event: '' };
|
|
83
|
+
try {
|
|
84
|
+
const pipeline = await gitlabFetch(`/projects/${encodedRepo(ref.repo)}/pipelines/${ref.runId}`);
|
|
85
|
+
return {
|
|
86
|
+
headSha: pipeline.sha,
|
|
87
|
+
headBranch: pipeline.ref,
|
|
88
|
+
event: 'push',
|
|
89
|
+
url: pipeline.web_url,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { headSha: '', headBranch: '', event: '' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { GitHubProvider } from './github.js';
|
|
4
|
+
import { GitLabProvider } from './gitlab.js';
|
|
5
|
+
import { CircleCiProvider } from './circleci.js';
|
|
6
|
+
export { GitHubProvider } from './github.js';
|
|
7
|
+
export { GitLabProvider } from './gitlab.js';
|
|
8
|
+
export { CircleCiProvider } from './circleci.js';
|
|
9
|
+
const providers = {
|
|
10
|
+
github: () => new GitHubProvider(),
|
|
11
|
+
gitlab: () => new GitLabProvider(),
|
|
12
|
+
circleci: () => new CircleCiProvider(),
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
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
|
+
*/
|
|
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
|
|
27
|
+
if (existsSync(join(cwd, '.gitlab-ci.yml')))
|
|
28
|
+
return 'gitlab';
|
|
29
|
+
if (existsSync(join(cwd, '.circleci')))
|
|
30
|
+
return 'circleci';
|
|
31
|
+
return 'github';
|
|
32
|
+
}
|
|
33
|
+
/** Get a provider instance by name, or auto-detect if not specified. */
|
|
34
|
+
export function getProvider(name) {
|
|
35
|
+
if (name) {
|
|
36
|
+
const factory = providers[name];
|
|
37
|
+
if (!factory) {
|
|
38
|
+
throw new Error(`Unknown provider: "${name}". Valid options: github, gitlab, circleci`);
|
|
39
|
+
}
|
|
40
|
+
return factory();
|
|
41
|
+
}
|
|
42
|
+
const detected = detectProvider();
|
|
43
|
+
return providers[detected]();
|
|
44
|
+
}
|
|
@@ -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
|
+
}
|