gitpadi 2.0.7 ā 2.1.2
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/.gitlab/duo/chat-rules.md +40 -0
- package/.gitlab/duo/mr-review-instructions.md +44 -0
- package/.gitlab-ci.yml +136 -0
- package/README.md +585 -57
- package/action.yml +21 -2
- package/dist/applicant-scorer.js +27 -105
- package/dist/cli.js +1045 -34
- package/dist/commands/apply-for-issue.js +396 -0
- package/dist/commands/bounty-hunter.js +441 -0
- package/dist/commands/contribute.js +245 -51
- package/dist/commands/drips.js +351 -0
- package/dist/commands/gitlab-issues.js +87 -0
- package/dist/commands/gitlab-mrs.js +163 -0
- package/dist/commands/gitlab-pipelines.js +95 -0
- package/dist/commands/prs.js +3 -3
- package/dist/core/github.js +24 -0
- package/dist/core/gitlab.js +233 -0
- package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
- package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
- package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
- package/dist/gitlab-agents/mr-review-agent.js +200 -0
- package/dist/gitlab-agents/reminder-agent.js +164 -0
- package/dist/grade-assignment.js +262 -0
- package/dist/remind-contributors.js +127 -0
- package/dist/review-and-merge.js +125 -0
- package/examples/gitpadi.yml +152 -0
- package/package.json +20 -4
- package/src/applicant-scorer.ts +33 -141
- package/src/cli.ts +1078 -34
- package/src/commands/apply-for-issue.ts +452 -0
- package/src/commands/bounty-hunter.ts +529 -0
- package/src/commands/contribute.ts +264 -50
- package/src/commands/drips.ts +408 -0
- package/src/commands/gitlab-issues.ts +87 -0
- package/src/commands/gitlab-mrs.ts +185 -0
- package/src/commands/gitlab-pipelines.ts +104 -0
- package/src/commands/prs.ts +3 -3
- package/src/core/github.ts +24 -0
- package/src/core/gitlab.ts +397 -0
- package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
- package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
- package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
- package/src/gitlab-agents/mr-review-agent.ts +231 -0
- package/src/gitlab-agents/reminder-agent.ts +203 -0
- package/src/grade-assignment.ts +283 -0
- package/src/remind-contributors.ts +159 -0
- package/src/review-and-merge.ts +143 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// commands/gitlab-pipelines.ts ā GitLab CI/CD pipeline management for GitPadi
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import Table from 'cli-table3';
|
|
5
|
+
import { getNamespace, getProject, getFullProject, requireGitLabProject, listGitLabPipelines, getGitLabPipeline, listGitLabPipelineJobs, getGitLabJobLog, withGitLabRetry, } from '../core/gitlab.js';
|
|
6
|
+
function pipelineStatusColor(status) {
|
|
7
|
+
switch (status) {
|
|
8
|
+
case 'success': return chalk.green(status);
|
|
9
|
+
case 'failed': return chalk.red(status);
|
|
10
|
+
case 'running': return chalk.yellow(status);
|
|
11
|
+
case 'canceled': return chalk.dim(status);
|
|
12
|
+
case 'pending': return chalk.blue(status);
|
|
13
|
+
default: return chalk.dim(status);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function listPipelines(opts = {}) {
|
|
17
|
+
requireGitLabProject();
|
|
18
|
+
const spinner = ora(`Fetching pipelines from ${chalk.cyan(getFullProject())}...`).start();
|
|
19
|
+
try {
|
|
20
|
+
const pipelines = await withGitLabRetry(() => listGitLabPipelines(getNamespace(), getProject(), { per_page: opts.limit || 20, ref: opts.ref }));
|
|
21
|
+
spinner.stop();
|
|
22
|
+
if (!pipelines.length) {
|
|
23
|
+
console.log(chalk.yellow('\n No pipelines found.\n'));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const table = new Table({
|
|
27
|
+
head: ['ID', 'Status', 'Ref', 'SHA', 'Created'].map(h => chalk.cyan(h)),
|
|
28
|
+
style: { head: [], border: [] },
|
|
29
|
+
});
|
|
30
|
+
pipelines.forEach(p => {
|
|
31
|
+
table.push([
|
|
32
|
+
`#${p.id}`,
|
|
33
|
+
pipelineStatusColor(p.status),
|
|
34
|
+
p.ref.substring(0, 25),
|
|
35
|
+
p.sha.substring(0, 8),
|
|
36
|
+
new Date(p.created_at).toLocaleDateString(),
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
console.log(`\n${chalk.bold(`š§ Pipelines ā ${getFullProject()}`)} (${pipelines.length})\n`);
|
|
40
|
+
console.log(table.toString());
|
|
41
|
+
console.log('');
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
spinner.fail(e.message);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function viewPipelineJobs(pipelineId) {
|
|
48
|
+
requireGitLabProject();
|
|
49
|
+
const spinner = ora(`Fetching jobs for pipeline #${pipelineId}...`).start();
|
|
50
|
+
try {
|
|
51
|
+
const [pipeline, jobs] = await Promise.all([
|
|
52
|
+
withGitLabRetry(() => getGitLabPipeline(getNamespace(), getProject(), pipelineId)),
|
|
53
|
+
withGitLabRetry(() => listGitLabPipelineJobs(getNamespace(), getProject(), pipelineId)),
|
|
54
|
+
]);
|
|
55
|
+
spinner.stop();
|
|
56
|
+
console.log(`\n${chalk.bold(`š§ Pipeline #${pipelineId}`)} ā ${pipelineStatusColor(pipeline.status)} ā ${pipeline.ref}\n`);
|
|
57
|
+
const table = new Table({
|
|
58
|
+
head: ['Job', 'Stage', 'Status', 'Duration'].map(h => chalk.cyan(h)),
|
|
59
|
+
style: { head: [], border: [] },
|
|
60
|
+
});
|
|
61
|
+
jobs.forEach(job => {
|
|
62
|
+
const duration = job.duration ? `${Math.round(job.duration)}s` : '-';
|
|
63
|
+
table.push([job.name, job.stage, pipelineStatusColor(job.status), duration]);
|
|
64
|
+
});
|
|
65
|
+
console.log(table.toString());
|
|
66
|
+
console.log('');
|
|
67
|
+
const failed = jobs.filter(j => j.status === 'failed');
|
|
68
|
+
if (failed.length) {
|
|
69
|
+
console.log(chalk.red(` ā ${failed.length} failed job(s): ${failed.map(j => j.name).join(', ')}\n`));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
spinner.fail(e.message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function viewJobLog(jobId) {
|
|
77
|
+
requireGitLabProject();
|
|
78
|
+
const spinner = ora(`Fetching log for job #${jobId}...`).start();
|
|
79
|
+
try {
|
|
80
|
+
const log = await withGitLabRetry(() => getGitLabJobLog(getNamespace(), getProject(), jobId));
|
|
81
|
+
spinner.stop();
|
|
82
|
+
console.log(`\n${chalk.bold(`š Job #${jobId} Log`)}\n`);
|
|
83
|
+
// Show last 100 lines to avoid overwhelming the terminal
|
|
84
|
+
const lines = log.split('\n');
|
|
85
|
+
const tail = lines.slice(-100).join('\n');
|
|
86
|
+
if (lines.length > 100) {
|
|
87
|
+
console.log(chalk.dim(` ... (showing last 100 of ${lines.length} lines)\n`));
|
|
88
|
+
}
|
|
89
|
+
console.log(chalk.dim(tail));
|
|
90
|
+
console.log('');
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
spinner.fail(e.message);
|
|
94
|
+
}
|
|
95
|
+
}
|
package/dist/commands/prs.js
CHANGED
|
@@ -58,9 +58,9 @@ export async function mergePR(number, opts) {
|
|
|
58
58
|
const spinner = ora(`Checking CI status for PR #${number}...`).start();
|
|
59
59
|
const { data: pr } = await octokit.pulls.get({ owner: getOwner(), repo: getRepo(), pull_number: number });
|
|
60
60
|
const sha = pr.head.sha;
|
|
61
|
-
// Step 2: Poll until all checks complete (max
|
|
61
|
+
// Step 2: Poll until all checks complete (max 15 min)
|
|
62
62
|
let attempts = 0;
|
|
63
|
-
const maxAttempts =
|
|
63
|
+
const maxAttempts = 90;
|
|
64
64
|
let allComplete = false;
|
|
65
65
|
let ciChecks = [];
|
|
66
66
|
while (!allComplete && attempts < maxAttempts) {
|
|
@@ -111,7 +111,7 @@ export async function mergePR(number, opts) {
|
|
|
111
111
|
console.log(ciTable.toString());
|
|
112
112
|
console.log('');
|
|
113
113
|
if (!allComplete) {
|
|
114
|
-
console.log(chalk.yellow(` ā ļø Checks still running after
|
|
114
|
+
console.log(chalk.yellow(` ā ļø Checks still running after 15 min. Try again later.\n`));
|
|
115
115
|
return;
|
|
116
116
|
}
|
|
117
117
|
if (!allPassed) {
|
package/dist/core/github.js
CHANGED
|
@@ -98,6 +98,30 @@ export async function getRepoPermissions(owner, repo) {
|
|
|
98
98
|
const data = await getRepoDetails(owner, repo);
|
|
99
99
|
return data.permissions || { admin: false, push: false, pull: true };
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Retries an async operation on GitHub rate-limit (429) or secondary rate-limit (403)
|
|
103
|
+
* with exponential backoff. maxRetries defaults to 4 (covers ~30s total wait).
|
|
104
|
+
*/
|
|
105
|
+
export async function withRetry(fn, maxRetries = 4) {
|
|
106
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
107
|
+
try {
|
|
108
|
+
return await fn();
|
|
109
|
+
}
|
|
110
|
+
catch (e) {
|
|
111
|
+
const isRateLimit = e.status === 429 || (e.status === 403 && /rate limit/i.test(e.message || ''));
|
|
112
|
+
if (isRateLimit && attempt < maxRetries) {
|
|
113
|
+
// Honour Retry-After header when present, else exponential backoff
|
|
114
|
+
const retryAfter = parseInt(e.response?.headers?.['retry-after'] || '0', 10);
|
|
115
|
+
const delay = retryAfter > 0 ? retryAfter * 1000 : Math.pow(2, attempt) * 1000 + Math.random() * 500;
|
|
116
|
+
await new Promise(r => setTimeout(r, delay));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Unreachable ā satisfies TS return type
|
|
123
|
+
throw new Error('withRetry: max retries exceeded');
|
|
124
|
+
}
|
|
101
125
|
export async function getLatestCheckRuns(owner, repo, ref) {
|
|
102
126
|
const octokit = getOctokit();
|
|
103
127
|
const { data: checks } = await octokit.checks.listForRef({ owner, repo, ref });
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// core/gitlab.ts ā GitLab REST API client for GitPadi
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the interface of github.ts so CLI commands can be
|
|
4
|
+
// written once and routed to either platform.
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import { execFileSync } from 'child_process';
|
|
10
|
+
const CONFIG_DIR = path.join(os.homedir(), '.gitpadi');
|
|
11
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
12
|
+
// āā State āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
13
|
+
let _token = '';
|
|
14
|
+
let _host = 'https://gitlab.com';
|
|
15
|
+
let _namespace = ''; // equivalent of "owner"
|
|
16
|
+
let _project = ''; // equivalent of "repo"
|
|
17
|
+
function loadFullConfig() {
|
|
18
|
+
if (!fs.existsSync(CONFIG_FILE))
|
|
19
|
+
return {};
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function saveFullConfig(patch) {
|
|
28
|
+
const current = loadFullConfig();
|
|
29
|
+
const updated = { ...current, ...patch };
|
|
30
|
+
if (!fs.existsSync(CONFIG_DIR))
|
|
31
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
32
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2));
|
|
33
|
+
}
|
|
34
|
+
// āā Local repo detection āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
35
|
+
export function detectLocalGitLabRepo() {
|
|
36
|
+
try {
|
|
37
|
+
const remotes = execFileSync('git', ['remote', '-v'], { encoding: 'utf-8', stdio: 'pipe' });
|
|
38
|
+
// Match both gitlab.com/ns/proj and self-hosted instances
|
|
39
|
+
const match = remotes.match(/gitlab[^/]*[:/]([^/]+)\/([^/.]+)(?:\.git)?/);
|
|
40
|
+
if (match)
|
|
41
|
+
return { namespace: match[1], project: match[2] };
|
|
42
|
+
}
|
|
43
|
+
catch { /* not a git repo */ }
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
// āā Init āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
47
|
+
export function initGitLab(token, namespace, project, host) {
|
|
48
|
+
const config = loadFullConfig();
|
|
49
|
+
const detected = detectLocalGitLabRepo();
|
|
50
|
+
_token = token || process.env.GITLAB_TOKEN || process.env.GITPADI_GITLAB_TOKEN || config.gitlabToken || '';
|
|
51
|
+
_host = host || process.env.GITLAB_HOST || config.gitlabHost || 'https://gitlab.com';
|
|
52
|
+
_namespace = namespace || process.env.GITLAB_NAMESPACE || config.gitlabNamespace || detected?.namespace || '';
|
|
53
|
+
_project = project || process.env.GITLAB_PROJECT || config.gitlabProject || detected?.project || '';
|
|
54
|
+
}
|
|
55
|
+
export function getGitLabToken() { return _token; }
|
|
56
|
+
export function getGitLabHost() { return _host; }
|
|
57
|
+
export function getNamespace() { return _namespace; }
|
|
58
|
+
export function getProject() { return _project; }
|
|
59
|
+
export function getFullProject() { return `${_namespace}/${_project}`; }
|
|
60
|
+
export function setGitLabProject(namespace, project) {
|
|
61
|
+
_namespace = namespace;
|
|
62
|
+
_project = project;
|
|
63
|
+
if (_token)
|
|
64
|
+
saveFullConfig({ gitlabNamespace: namespace, gitlabProject: project });
|
|
65
|
+
}
|
|
66
|
+
export function saveGitLabConfig(token, host, namespace, project) {
|
|
67
|
+
_token = token;
|
|
68
|
+
_host = host;
|
|
69
|
+
_namespace = namespace;
|
|
70
|
+
_project = project;
|
|
71
|
+
saveFullConfig({ gitlabToken: token, gitlabHost: host, gitlabNamespace: namespace, gitlabProject: project, platform: 'gitlab' });
|
|
72
|
+
}
|
|
73
|
+
export function savePlatformPreference(platform) {
|
|
74
|
+
saveFullConfig({ platform });
|
|
75
|
+
}
|
|
76
|
+
export function loadPlatformPreference() {
|
|
77
|
+
return loadFullConfig().platform;
|
|
78
|
+
}
|
|
79
|
+
export function requireGitLabProject() {
|
|
80
|
+
if (!_namespace || !_project) {
|
|
81
|
+
console.error(chalk.red('\nā GitLab project not set.'));
|
|
82
|
+
console.error(chalk.dim(' Set GITLAB_NAMESPACE and GITLAB_PROJECT env vars, or run gitpadi and select a project.\n'));
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// āā HTTP helpers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
87
|
+
function apiUrl(path) {
|
|
88
|
+
const base = _host.replace(/\/$/, '');
|
|
89
|
+
return `${base}/api/v4${path}`;
|
|
90
|
+
}
|
|
91
|
+
async function glFetch(method, path, body) {
|
|
92
|
+
const res = await fetch(apiUrl(path), {
|
|
93
|
+
method,
|
|
94
|
+
headers: {
|
|
95
|
+
'PRIVATE-TOKEN': _token,
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
99
|
+
});
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const text = await res.text().catch(() => '');
|
|
102
|
+
throw Object.assign(new Error(`GitLab API ${method} ${path} ā ${res.status}: ${text}`), { status: res.status });
|
|
103
|
+
}
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
return text ? JSON.parse(text) : {};
|
|
106
|
+
}
|
|
107
|
+
const gl = {
|
|
108
|
+
get: (path) => glFetch('GET', path),
|
|
109
|
+
post: (path, body) => glFetch('POST', path, body),
|
|
110
|
+
put: (path, body) => glFetch('PUT', path, body),
|
|
111
|
+
delete: (path) => glFetch('DELETE', path),
|
|
112
|
+
};
|
|
113
|
+
// Encode "namespace/project" ā "namespace%2Fproject" for URL path segments
|
|
114
|
+
function encodeProject(ns, proj) {
|
|
115
|
+
return encodeURIComponent(`${ns}/${proj}`);
|
|
116
|
+
}
|
|
117
|
+
// āā Rate-limit retry āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
118
|
+
export async function withGitLabRetry(fn, maxRetries = 4) {
|
|
119
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
120
|
+
try {
|
|
121
|
+
return await fn();
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
const isRateLimit = e.status === 429 || (e.status === 403 && /rate limit/i.test(e.message || ''));
|
|
125
|
+
if (isRateLimit && attempt < maxRetries) {
|
|
126
|
+
const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
|
|
127
|
+
await new Promise(r => setTimeout(r, delay));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
throw new Error('withGitLabRetry: max retries exceeded');
|
|
134
|
+
}
|
|
135
|
+
export async function getAuthenticatedGitLabUser() {
|
|
136
|
+
return gl.get('/user');
|
|
137
|
+
}
|
|
138
|
+
export async function getGitLabProject(namespace, project) {
|
|
139
|
+
return gl.get(`/projects/${encodeProject(namespace, project)}`);
|
|
140
|
+
}
|
|
141
|
+
export async function forkGitLabProject(namespace, project) {
|
|
142
|
+
return gl.post(`/projects/${encodeProject(namespace, project)}/fork`, {});
|
|
143
|
+
}
|
|
144
|
+
export async function listGitLabIssues(namespace, project, opts = {}) {
|
|
145
|
+
const params = new URLSearchParams({
|
|
146
|
+
state: opts.state || 'opened',
|
|
147
|
+
per_page: String(opts.per_page || 50),
|
|
148
|
+
});
|
|
149
|
+
if (opts.labels)
|
|
150
|
+
params.set('labels', opts.labels);
|
|
151
|
+
return gl.get(`/projects/${encodeProject(namespace, project)}/issues?${params}`);
|
|
152
|
+
}
|
|
153
|
+
export async function createGitLabIssue(namespace, project, data) {
|
|
154
|
+
return gl.post(`/projects/${encodeProject(namespace, project)}/issues`, data);
|
|
155
|
+
}
|
|
156
|
+
export async function updateGitLabIssue(namespace, project, iid, data) {
|
|
157
|
+
return gl.put(`/projects/${encodeProject(namespace, project)}/issues/${iid}`, data);
|
|
158
|
+
}
|
|
159
|
+
export async function createGitLabIssueNote(namespace, project, iid, body) {
|
|
160
|
+
await gl.post(`/projects/${encodeProject(namespace, project)}/issues/${iid}/notes`, { body });
|
|
161
|
+
}
|
|
162
|
+
export async function listGitLabMRs(namespace, project, opts = {}) {
|
|
163
|
+
const params = new URLSearchParams({
|
|
164
|
+
state: opts.state || 'opened',
|
|
165
|
+
per_page: String(opts.per_page || 50),
|
|
166
|
+
});
|
|
167
|
+
return gl.get(`/projects/${encodeProject(namespace, project)}/merge_requests?${params}`);
|
|
168
|
+
}
|
|
169
|
+
export async function getGitLabMR(namespace, project, iid) {
|
|
170
|
+
return gl.get(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}`);
|
|
171
|
+
}
|
|
172
|
+
export async function createGitLabMR(namespace, project, data) {
|
|
173
|
+
return gl.post(`/projects/${encodeProject(namespace, project)}/merge_requests`, {
|
|
174
|
+
remove_source_branch: true,
|
|
175
|
+
...data,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
export async function mergeGitLabMR(namespace, project, iid, opts = {}) {
|
|
179
|
+
return gl.put(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/merge`, {
|
|
180
|
+
squash: opts.squash ?? true,
|
|
181
|
+
squash_commit_message: opts.message,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
export async function updateGitLabMR(namespace, project, iid, data) {
|
|
185
|
+
return gl.put(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}`, data);
|
|
186
|
+
}
|
|
187
|
+
export async function listGitLabMRNotes(namespace, project, iid) {
|
|
188
|
+
return gl.get(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/notes?per_page=100`);
|
|
189
|
+
}
|
|
190
|
+
export async function createGitLabMRNote(namespace, project, iid, body) {
|
|
191
|
+
await gl.post(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/notes`, { body });
|
|
192
|
+
}
|
|
193
|
+
export async function updateGitLabMRNote(namespace, project, iid, noteId, body) {
|
|
194
|
+
await gl.put(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/notes/${noteId}`, { body });
|
|
195
|
+
}
|
|
196
|
+
export async function listGitLabMRChanges(namespace, project, iid) {
|
|
197
|
+
const data = await gl.get(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/changes`);
|
|
198
|
+
return data.changes || [];
|
|
199
|
+
}
|
|
200
|
+
export async function listGitLabPipelines(namespace, project, opts = {}) {
|
|
201
|
+
const params = new URLSearchParams({ per_page: String(opts.per_page || 20) });
|
|
202
|
+
if (opts.ref)
|
|
203
|
+
params.set('ref', opts.ref);
|
|
204
|
+
if (opts.status)
|
|
205
|
+
params.set('status', opts.status);
|
|
206
|
+
return gl.get(`/projects/${encodeProject(namespace, project)}/pipelines?${params}`);
|
|
207
|
+
}
|
|
208
|
+
export async function getGitLabPipeline(namespace, project, pipelineId) {
|
|
209
|
+
return gl.get(`/projects/${encodeProject(namespace, project)}/pipelines/${pipelineId}`);
|
|
210
|
+
}
|
|
211
|
+
export async function listGitLabPipelineJobs(namespace, project, pipelineId) {
|
|
212
|
+
return gl.get(`/projects/${encodeProject(namespace, project)}/pipelines/${pipelineId}/jobs?per_page=100`);
|
|
213
|
+
}
|
|
214
|
+
export async function getGitLabJobLog(namespace, project, jobId) {
|
|
215
|
+
const res = await fetch(apiUrl(`/projects/${encodeProject(namespace, project)}/jobs/${jobId}/trace`), {
|
|
216
|
+
headers: { 'PRIVATE-TOKEN': _token },
|
|
217
|
+
});
|
|
218
|
+
if (!res.ok)
|
|
219
|
+
throw new Error(`Could not fetch job log: ${res.status}`);
|
|
220
|
+
return res.text();
|
|
221
|
+
}
|
|
222
|
+
// āā MR CI status (for polling) āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
223
|
+
export async function getGitLabMRPipelineStatus(namespace, project, mrIid) {
|
|
224
|
+
try {
|
|
225
|
+
const pipelines = await gl.get(`/projects/${encodeProject(namespace, project)}/merge_requests/${mrIid}/pipelines?per_page=1`);
|
|
226
|
+
if (!pipelines.length)
|
|
227
|
+
return null;
|
|
228
|
+
return { status: pipelines[0].status, web_url: pipelines[0].web_url };
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// gitlab-agents/ci-recovery-agent.ts
|
|
3
|
+
//
|
|
4
|
+
// GitLab Duo External Agent ā AI-powered CI Failure Recovery
|
|
5
|
+
//
|
|
6
|
+
// Triggered when a pipeline fails on an MR.
|
|
7
|
+
// Claude reads the full failure logs, identifies root causes, and:
|
|
8
|
+
// 1. Posts a diagnosis comment on the MR explaining what failed and why
|
|
9
|
+
// 2. Suggests specific fixes with code snippets
|
|
10
|
+
// 3. (Optional) Creates a fix commit if AUTO_FIX=true
|
|
11
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
12
|
+
const GITLAB_TOKEN = process.env.GITLAB_TOKEN || '';
|
|
13
|
+
const GITLAB_HOST = (process.env.GITLAB_HOST || 'https://gitlab.com').replace(/\/$/, '');
|
|
14
|
+
const GATEWAY_TOKEN = process.env.AI_FLOW_AI_GATEWAY_TOKEN || '';
|
|
15
|
+
const anthropic = GATEWAY_TOKEN
|
|
16
|
+
? new Anthropic({ apiKey: GATEWAY_TOKEN, baseURL: `${GITLAB_HOST}/api/v4/ai/llm/v1` })
|
|
17
|
+
: new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
18
|
+
const CI_RECOVERY_MARKER = '<!-- gitpadi-ci-recovery -->';
|
|
19
|
+
async function glFetch(method, path, body) {
|
|
20
|
+
const res = await fetch(`${GITLAB_HOST}/api/v4${path}`, {
|
|
21
|
+
method,
|
|
22
|
+
headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN, 'Content-Type': 'application/json' },
|
|
23
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok)
|
|
26
|
+
throw new Error(`GitLab ${method} ${path} ā ${res.status}`);
|
|
27
|
+
return res.json();
|
|
28
|
+
}
|
|
29
|
+
async function getJobLog(projectId, jobId) {
|
|
30
|
+
const res = await fetch(`${GITLAB_HOST}/api/v4/projects/${projectId}/jobs/${jobId}/trace`, {
|
|
31
|
+
headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN },
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok)
|
|
34
|
+
return 'Log unavailable';
|
|
35
|
+
const text = await res.text();
|
|
36
|
+
// Return last 3000 chars ā most errors are at the end
|
|
37
|
+
return text.length > 3000 ? `...\n${text.slice(-3000)}` : text;
|
|
38
|
+
}
|
|
39
|
+
async function postOrUpdateMRNote(projectId, mrIid, body) {
|
|
40
|
+
const notes = await glFetch('GET', `/projects/${projectId}/merge_requests/${mrIid}/notes?per_page=100`);
|
|
41
|
+
const existing = notes.find(n => n.body?.includes(CI_RECOVERY_MARKER));
|
|
42
|
+
if (existing) {
|
|
43
|
+
await glFetch('PUT', `/projects/${projectId}/merge_requests/${mrIid}/notes/${existing.id}`, { body });
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
await glFetch('POST', `/projects/${projectId}/merge_requests/${mrIid}/notes`, { body });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function main() {
|
|
50
|
+
console.log('\nš¤ GitPadi CI Recovery Agent\n');
|
|
51
|
+
const contextRaw = process.env.AI_FLOW_CONTEXT || '{}';
|
|
52
|
+
let context;
|
|
53
|
+
try {
|
|
54
|
+
context = JSON.parse(contextRaw);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
context = {};
|
|
58
|
+
}
|
|
59
|
+
const { project_id, project_path = '', mr_iid, pipeline_id, title = '', source_branch = '', author = '', failed_jobs = [], } = context;
|
|
60
|
+
if (!mr_iid || !pipeline_id) {
|
|
61
|
+
console.log('āļø No MR/pipeline context ā skipping.');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
console.log(` Project: ${project_path}`);
|
|
65
|
+
console.log(` MR: !${mr_iid} ā ${title}`);
|
|
66
|
+
console.log(` Pipeline: #${pipeline_id}`);
|
|
67
|
+
console.log(` Failed: ${failed_jobs.length} job(s)\n`);
|
|
68
|
+
// Fetch all failed jobs if not in context
|
|
69
|
+
let jobs = failed_jobs;
|
|
70
|
+
if (!jobs.length) {
|
|
71
|
+
try {
|
|
72
|
+
const allJobs = await glFetch('GET', `/projects/${project_id}/pipelines/${pipeline_id}/jobs?per_page=100`);
|
|
73
|
+
jobs = allJobs.filter((j) => j.status === 'failed');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
jobs = [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!jobs.length) {
|
|
80
|
+
console.log(' No failed jobs found.');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Collect logs from failed jobs
|
|
84
|
+
console.log(' Fetching failure logs...');
|
|
85
|
+
const jobLogs = [];
|
|
86
|
+
for (const job of jobs.slice(0, 3)) { // max 3 jobs to avoid token overload
|
|
87
|
+
const log = await getJobLog(project_id, job.id);
|
|
88
|
+
jobLogs.push({ name: job.name, stage: job.stage || 'unknown', log });
|
|
89
|
+
console.log(` ā Fetched log for: ${job.name}`);
|
|
90
|
+
}
|
|
91
|
+
// Ask Claude to diagnose
|
|
92
|
+
const prompt = `You are GitPadi, an AI DevOps assistant integrated with GitLab.
|
|
93
|
+
|
|
94
|
+
A CI pipeline failed on this Merge Request:
|
|
95
|
+
- MR: !${mr_iid} ā "${title}"
|
|
96
|
+
- Author: @${author}
|
|
97
|
+
- Branch: ${source_branch}
|
|
98
|
+
|
|
99
|
+
Failed job logs:
|
|
100
|
+
${jobLogs.map(j => `\n### Job: ${j.name} (stage: ${j.stage})\n\`\`\`\n${j.log}\n\`\`\``).join('\n')}
|
|
101
|
+
|
|
102
|
+
Analyze these failures and provide:
|
|
103
|
+
1. Root cause diagnosis for each failure
|
|
104
|
+
2. Specific fix recommendations with code snippets where possible
|
|
105
|
+
3. Priority order of fixes
|
|
106
|
+
|
|
107
|
+
Respond as JSON:
|
|
108
|
+
{
|
|
109
|
+
"failures": [
|
|
110
|
+
{
|
|
111
|
+
"job": "job name",
|
|
112
|
+
"rootCause": "explanation",
|
|
113
|
+
"errorType": "lint|test|build|type|other",
|
|
114
|
+
"fix": {
|
|
115
|
+
"description": "what to change",
|
|
116
|
+
"codeSnippet": "code if applicable, null otherwise",
|
|
117
|
+
"commands": ["shell commands if applicable"]
|
|
118
|
+
},
|
|
119
|
+
"confidence": "high|medium|low"
|
|
120
|
+
}
|
|
121
|
+
],
|
|
122
|
+
"quickFix": "single most impactful thing to fix first",
|
|
123
|
+
"estimatedFixTime": "e.g. 5 minutes",
|
|
124
|
+
"summary": "overall diagnosis in 2-3 sentences"
|
|
125
|
+
}`;
|
|
126
|
+
let diagnosis;
|
|
127
|
+
try {
|
|
128
|
+
const response = await anthropic.messages.create({
|
|
129
|
+
model: 'claude-sonnet-4-20250514',
|
|
130
|
+
max_tokens: 2048,
|
|
131
|
+
messages: [{ role: 'user', content: prompt }],
|
|
132
|
+
});
|
|
133
|
+
const text = response.content[0].type === 'text' ? response.content[0].text : '';
|
|
134
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
135
|
+
if (!jsonMatch)
|
|
136
|
+
throw new Error('No JSON');
|
|
137
|
+
diagnosis = JSON.parse(jsonMatch[0]);
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
console.error(`ā Claude diagnosis failed: ${e.message}`);
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
// Build the recovery comment
|
|
144
|
+
let comment = `## š¤ GitPadi ā CI Failure Recovery\n\n`;
|
|
145
|
+
comment += `**Pipeline:** #${pipeline_id} | **Failed jobs:** ${jobs.length}\n\n`;
|
|
146
|
+
comment += `### Diagnosis\n\n${diagnosis.summary}\n\n`;
|
|
147
|
+
if (diagnosis.quickFix) {
|
|
148
|
+
comment += `> š **Quick Fix:** ${diagnosis.quickFix}\n`;
|
|
149
|
+
comment += `> ā±ļø **Estimated fix time:** ${diagnosis.estimatedFixTime || 'a few minutes'}\n\n`;
|
|
150
|
+
}
|
|
151
|
+
diagnosis.failures?.forEach((f, i) => {
|
|
152
|
+
const confIcon = f.confidence === 'high' ? 'š“' : f.confidence === 'medium' ? 'š”' : 'š¢';
|
|
153
|
+
comment += `### ${i + 1}. \`${f.job}\`\n\n`;
|
|
154
|
+
comment += `**Root Cause:** ${f.rootCause}\n\n`;
|
|
155
|
+
if (f.fix) {
|
|
156
|
+
comment += `**Fix:** ${f.fix.description}\n\n`;
|
|
157
|
+
if (f.fix.codeSnippet) {
|
|
158
|
+
comment += `\`\`\`\n${f.fix.codeSnippet}\n\`\`\`\n\n`;
|
|
159
|
+
}
|
|
160
|
+
if (f.fix.commands?.length) {
|
|
161
|
+
comment += `Run:\n\`\`\`bash\n${f.fix.commands.join('\n')}\n\`\`\`\n\n`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
comment += `${confIcon} Confidence: ${f.confidence}\n\n`;
|
|
165
|
+
});
|
|
166
|
+
comment += `---\n`;
|
|
167
|
+
comment += `_š¤ Diagnosed by [GitPadi](https://github.com/Netwalls/contributor-agent) using Claude ā [Fix & Re-push](https://github.com/Netwalls/contributor-agent#fix--re-push) when ready_\n\n`;
|
|
168
|
+
comment += CI_RECOVERY_MARKER;
|
|
169
|
+
await postOrUpdateMRNote(project_id, mr_iid, comment);
|
|
170
|
+
console.log(`\nā
Recovery guide posted to MR !${mr_iid}`);
|
|
171
|
+
console.log(` ${diagnosis.failures?.length || 0} failure(s) diagnosed`);
|
|
172
|
+
}
|
|
173
|
+
main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });
|