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.
Files changed (47) hide show
  1. package/.gitlab/duo/chat-rules.md +40 -0
  2. package/.gitlab/duo/mr-review-instructions.md +44 -0
  3. package/.gitlab-ci.yml +136 -0
  4. package/README.md +585 -57
  5. package/action.yml +21 -2
  6. package/dist/applicant-scorer.js +27 -105
  7. package/dist/cli.js +1045 -34
  8. package/dist/commands/apply-for-issue.js +396 -0
  9. package/dist/commands/bounty-hunter.js +441 -0
  10. package/dist/commands/contribute.js +245 -51
  11. package/dist/commands/drips.js +351 -0
  12. package/dist/commands/gitlab-issues.js +87 -0
  13. package/dist/commands/gitlab-mrs.js +163 -0
  14. package/dist/commands/gitlab-pipelines.js +95 -0
  15. package/dist/commands/prs.js +3 -3
  16. package/dist/core/github.js +24 -0
  17. package/dist/core/gitlab.js +233 -0
  18. package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
  19. package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
  20. package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
  21. package/dist/gitlab-agents/mr-review-agent.js +200 -0
  22. package/dist/gitlab-agents/reminder-agent.js +164 -0
  23. package/dist/grade-assignment.js +262 -0
  24. package/dist/remind-contributors.js +127 -0
  25. package/dist/review-and-merge.js +125 -0
  26. package/examples/gitpadi.yml +152 -0
  27. package/package.json +20 -4
  28. package/src/applicant-scorer.ts +33 -141
  29. package/src/cli.ts +1078 -34
  30. package/src/commands/apply-for-issue.ts +452 -0
  31. package/src/commands/bounty-hunter.ts +529 -0
  32. package/src/commands/contribute.ts +264 -50
  33. package/src/commands/drips.ts +408 -0
  34. package/src/commands/gitlab-issues.ts +87 -0
  35. package/src/commands/gitlab-mrs.ts +185 -0
  36. package/src/commands/gitlab-pipelines.ts +104 -0
  37. package/src/commands/prs.ts +3 -3
  38. package/src/core/github.ts +24 -0
  39. package/src/core/gitlab.ts +397 -0
  40. package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
  41. package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
  42. package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
  43. package/src/gitlab-agents/mr-review-agent.ts +231 -0
  44. package/src/gitlab-agents/reminder-agent.ts +203 -0
  45. package/src/grade-assignment.ts +283 -0
  46. package/src/remind-contributors.ts +159 -0
  47. 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
+ }
@@ -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 5 min)
61
+ // Step 2: Poll until all checks complete (max 15 min)
62
62
  let attempts = 0;
63
- const maxAttempts = 30;
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 5 min. Try again later.\n`));
114
+ console.log(chalk.yellow(` āš ļø Checks still running after 15 min. Try again later.\n`));
115
115
  return;
116
116
  }
117
117
  if (!allPassed) {
@@ -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); });