gitpadi 2.0.6 → 2.1.1

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 (45) 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 +1082 -36
  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/gitlab-issues.js +87 -0
  12. package/dist/commands/gitlab-mrs.js +163 -0
  13. package/dist/commands/gitlab-pipelines.js +95 -0
  14. package/dist/commands/prs.js +3 -3
  15. package/dist/core/github.js +28 -0
  16. package/dist/core/gitlab.js +233 -0
  17. package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
  18. package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
  19. package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
  20. package/dist/gitlab-agents/mr-review-agent.js +200 -0
  21. package/dist/gitlab-agents/reminder-agent.js +164 -0
  22. package/dist/grade-assignment.js +262 -0
  23. package/dist/remind-contributors.js +127 -0
  24. package/dist/review-and-merge.js +125 -0
  25. package/examples/gitpadi.yml +152 -0
  26. package/package.json +20 -4
  27. package/src/applicant-scorer.ts +33 -141
  28. package/src/cli.ts +1119 -35
  29. package/src/commands/apply-for-issue.ts +452 -0
  30. package/src/commands/bounty-hunter.ts +529 -0
  31. package/src/commands/contribute.ts +264 -50
  32. package/src/commands/gitlab-issues.ts +87 -0
  33. package/src/commands/gitlab-mrs.ts +185 -0
  34. package/src/commands/gitlab-pipelines.ts +104 -0
  35. package/src/commands/prs.ts +3 -3
  36. package/src/core/github.ts +29 -0
  37. package/src/core/gitlab.ts +397 -0
  38. package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
  39. package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
  40. package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
  41. package/src/gitlab-agents/mr-review-agent.ts +231 -0
  42. package/src/gitlab-agents/reminder-agent.ts +203 -0
  43. package/src/grade-assignment.ts +283 -0
  44. package/src/remind-contributors.ts +159 -0
  45. package/src/review-and-merge.ts +143 -0
@@ -0,0 +1,397 @@
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
+
6
+ import chalk from 'chalk';
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import os from 'node:os';
10
+ import { execFileSync } from 'child_process';
11
+
12
+ const CONFIG_DIR = path.join(os.homedir(), '.gitpadi');
13
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
14
+
15
+ // ── State ──────────────────────────────────────────────────────────────
16
+ let _token: string = '';
17
+ let _host: string = 'https://gitlab.com';
18
+ let _namespace: string = ''; // equivalent of "owner"
19
+ let _project: string = ''; // equivalent of "repo"
20
+
21
+ // ── Config (shared file with github.ts) ───────────────────────────────
22
+ interface GitPadiFullConfig {
23
+ token?: string;
24
+ owner?: string;
25
+ repo?: string;
26
+ gitlabToken?: string;
27
+ gitlabHost?: string;
28
+ gitlabNamespace?: string;
29
+ gitlabProject?: string;
30
+ platform?: 'github' | 'gitlab';
31
+ }
32
+
33
+ function loadFullConfig(): GitPadiFullConfig {
34
+ if (!fs.existsSync(CONFIG_FILE)) return {};
35
+ try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); } catch { return {}; }
36
+ }
37
+
38
+ function saveFullConfig(patch: Partial<GitPadiFullConfig>): void {
39
+ const current = loadFullConfig();
40
+ const updated = { ...current, ...patch };
41
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
42
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2));
43
+ }
44
+
45
+ // ── Local repo detection ───────────────────────────────────────────────
46
+ export function detectLocalGitLabRepo(): { namespace: string; project: string } | null {
47
+ try {
48
+ const remotes = execFileSync('git', ['remote', '-v'], { encoding: 'utf-8', stdio: 'pipe' });
49
+ // Match both gitlab.com/ns/proj and self-hosted instances
50
+ const match = remotes.match(/gitlab[^/]*[:/]([^/]+)\/([^/.]+)(?:\.git)?/);
51
+ if (match) return { namespace: match[1], project: match[2] };
52
+ } catch { /* not a git repo */ }
53
+ return null;
54
+ }
55
+
56
+ // ── Init ───────────────────────────────────────────────────────────────
57
+ export function initGitLab(token?: string, namespace?: string, project?: string, host?: string): void {
58
+ const config = loadFullConfig();
59
+ const detected = detectLocalGitLabRepo();
60
+
61
+ _token = token || process.env.GITLAB_TOKEN || process.env.GITPADI_GITLAB_TOKEN || config.gitlabToken || '';
62
+ _host = host || process.env.GITLAB_HOST || config.gitlabHost || 'https://gitlab.com';
63
+ _namespace = namespace || process.env.GITLAB_NAMESPACE || config.gitlabNamespace || detected?.namespace || '';
64
+ _project = project || process.env.GITLAB_PROJECT || config.gitlabProject || detected?.project || '';
65
+ }
66
+
67
+ export function getGitLabToken(): string { return _token; }
68
+ export function getGitLabHost(): string { return _host; }
69
+ export function getNamespace(): string { return _namespace; }
70
+ export function getProject(): string { return _project; }
71
+ export function getFullProject(): string { return `${_namespace}/${_project}`; }
72
+
73
+ export function setGitLabProject(namespace: string, project: string): void {
74
+ _namespace = namespace;
75
+ _project = project;
76
+ if (_token) saveFullConfig({ gitlabNamespace: namespace, gitlabProject: project });
77
+ }
78
+
79
+ export function saveGitLabConfig(token: string, host: string, namespace: string, project: string): void {
80
+ _token = token;
81
+ _host = host;
82
+ _namespace = namespace;
83
+ _project = project;
84
+ saveFullConfig({ gitlabToken: token, gitlabHost: host, gitlabNamespace: namespace, gitlabProject: project, platform: 'gitlab' });
85
+ }
86
+
87
+ export function savePlatformPreference(platform: 'github' | 'gitlab'): void {
88
+ saveFullConfig({ platform });
89
+ }
90
+
91
+ export function loadPlatformPreference(): 'github' | 'gitlab' | undefined {
92
+ return loadFullConfig().platform;
93
+ }
94
+
95
+ export function requireGitLabProject(): void {
96
+ if (!_namespace || !_project) {
97
+ console.error(chalk.red('\n❌ GitLab project not set.'));
98
+ console.error(chalk.dim(' Set GITLAB_NAMESPACE and GITLAB_PROJECT env vars, or run gitpadi and select a project.\n'));
99
+ process.exit(1);
100
+ }
101
+ }
102
+
103
+ // ── HTTP helpers ───────────────────────────────────────────────────────
104
+ function apiUrl(path: string): string {
105
+ const base = _host.replace(/\/$/, '');
106
+ return `${base}/api/v4${path}`;
107
+ }
108
+
109
+ async function glFetch<T>(method: string, path: string, body?: unknown): Promise<T> {
110
+ const res = await fetch(apiUrl(path), {
111
+ method,
112
+ headers: {
113
+ 'PRIVATE-TOKEN': _token,
114
+ 'Content-Type': 'application/json',
115
+ },
116
+ body: body ? JSON.stringify(body) : undefined,
117
+ });
118
+
119
+ if (!res.ok) {
120
+ const text = await res.text().catch(() => '');
121
+ throw Object.assign(new Error(`GitLab API ${method} ${path} → ${res.status}: ${text}`), { status: res.status });
122
+ }
123
+
124
+ const text = await res.text();
125
+ return text ? JSON.parse(text) as T : ({} as T);
126
+ }
127
+
128
+ const gl = {
129
+ get: <T>(path: string) => glFetch<T>('GET', path),
130
+ post: <T>(path: string, body: unknown) => glFetch<T>('POST', path, body),
131
+ put: <T>(path: string, body: unknown) => glFetch<T>('PUT', path, body),
132
+ delete: <T>(path: string) => glFetch<T>('DELETE', path),
133
+ };
134
+
135
+ // Encode "namespace/project" → "namespace%2Fproject" for URL path segments
136
+ function encodeProject(ns: string, proj: string): string {
137
+ return encodeURIComponent(`${ns}/${proj}`);
138
+ }
139
+
140
+ // ── Rate-limit retry ───────────────────────────────────────────────────
141
+ export async function withGitLabRetry<T>(fn: () => Promise<T>, maxRetries = 4): Promise<T> {
142
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
143
+ try {
144
+ return await fn();
145
+ } catch (e: any) {
146
+ const isRateLimit = e.status === 429 || (e.status === 403 && /rate limit/i.test(e.message || ''));
147
+ if (isRateLimit && attempt < maxRetries) {
148
+ const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
149
+ await new Promise(r => setTimeout(r, delay));
150
+ continue;
151
+ }
152
+ throw e;
153
+ }
154
+ }
155
+ throw new Error('withGitLabRetry: max retries exceeded');
156
+ }
157
+
158
+ // ── User ───────────────────────────────────────────────────────────────
159
+ export interface GitLabUser {
160
+ id: number;
161
+ username: string;
162
+ name: string;
163
+ avatar_url: string;
164
+ web_url: string;
165
+ created_at: string;
166
+ public_repos?: number;
167
+ }
168
+
169
+ export async function getAuthenticatedGitLabUser(): Promise<GitLabUser> {
170
+ return gl.get<GitLabUser>('/user');
171
+ }
172
+
173
+ // ── Project ────────────────────────────────────────────────────────────
174
+ export interface GitLabProject {
175
+ id: number;
176
+ name: string;
177
+ path_with_namespace: string;
178
+ description: string | null;
179
+ web_url: string;
180
+ default_branch: string;
181
+ visibility: string;
182
+ star_count: number;
183
+ forks_count: number;
184
+ open_issues_count: number;
185
+ permissions?: { project_access?: { access_level: number } };
186
+ }
187
+
188
+ export async function getGitLabProject(namespace: string, project: string): Promise<GitLabProject> {
189
+ return gl.get<GitLabProject>(`/projects/${encodeProject(namespace, project)}`);
190
+ }
191
+
192
+ export async function forkGitLabProject(namespace: string, project: string): Promise<GitLabProject> {
193
+ return gl.post<GitLabProject>(`/projects/${encodeProject(namespace, project)}/fork`, {});
194
+ }
195
+
196
+ // ── Issues ─────────────────────────────────────────────────────────────
197
+ export interface GitLabIssue {
198
+ id: number;
199
+ iid: number; // project-scoped issue number (equivalent to GitHub issue number)
200
+ title: string;
201
+ description: string | null;
202
+ state: 'opened' | 'closed';
203
+ author: { username: string };
204
+ assignees: Array<{ username: string }>;
205
+ labels: string[];
206
+ created_at: string;
207
+ updated_at: string;
208
+ web_url: string;
209
+ }
210
+
211
+ export async function listGitLabIssues(namespace: string, project: string, opts: {
212
+ state?: 'opened' | 'closed' | 'all';
213
+ per_page?: number;
214
+ labels?: string;
215
+ } = {}): Promise<GitLabIssue[]> {
216
+ const params = new URLSearchParams({
217
+ state: opts.state || 'opened',
218
+ per_page: String(opts.per_page || 50),
219
+ });
220
+ if (opts.labels) params.set('labels', opts.labels);
221
+ return gl.get<GitLabIssue[]>(`/projects/${encodeProject(namespace, project)}/issues?${params}`);
222
+ }
223
+
224
+ export async function createGitLabIssue(namespace: string, project: string, data: {
225
+ title: string;
226
+ description?: string;
227
+ labels?: string;
228
+ assignee_ids?: number[];
229
+ }): Promise<GitLabIssue> {
230
+ return gl.post<GitLabIssue>(`/projects/${encodeProject(namespace, project)}/issues`, data);
231
+ }
232
+
233
+ export async function updateGitLabIssue(namespace: string, project: string, iid: number, data: {
234
+ state_event?: 'close' | 'reopen';
235
+ title?: string;
236
+ description?: string;
237
+ }): Promise<GitLabIssue> {
238
+ return gl.put<GitLabIssue>(`/projects/${encodeProject(namespace, project)}/issues/${iid}`, data);
239
+ }
240
+
241
+ export async function createGitLabIssueNote(namespace: string, project: string, iid: number, body: string): Promise<void> {
242
+ await gl.post(`/projects/${encodeProject(namespace, project)}/issues/${iid}/notes`, { body });
243
+ }
244
+
245
+ // ── Merge Requests ─────────────────────────────────────────────────────
246
+ export interface GitLabMR {
247
+ id: number;
248
+ iid: number;
249
+ title: string;
250
+ description: string | null;
251
+ state: 'opened' | 'closed' | 'locked' | 'merged';
252
+ author: { username: string };
253
+ source_branch: string;
254
+ target_branch: string;
255
+ web_url: string;
256
+ sha: string;
257
+ draft: boolean;
258
+ has_conflicts: boolean;
259
+ created_at: string;
260
+ updated_at: string;
261
+ merge_status: string;
262
+ }
263
+
264
+ export async function listGitLabMRs(namespace: string, project: string, opts: {
265
+ state?: 'opened' | 'closed' | 'locked' | 'merged' | 'all';
266
+ per_page?: number;
267
+ } = {}): Promise<GitLabMR[]> {
268
+ const params = new URLSearchParams({
269
+ state: opts.state || 'opened',
270
+ per_page: String(opts.per_page || 50),
271
+ });
272
+ return gl.get<GitLabMR[]>(`/projects/${encodeProject(namespace, project)}/merge_requests?${params}`);
273
+ }
274
+
275
+ export async function getGitLabMR(namespace: string, project: string, iid: number): Promise<GitLabMR> {
276
+ return gl.get<GitLabMR>(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}`);
277
+ }
278
+
279
+ export async function createGitLabMR(namespace: string, project: string, data: {
280
+ title: string;
281
+ description?: string;
282
+ source_branch: string;
283
+ target_branch: string;
284
+ remove_source_branch?: boolean;
285
+ squash?: boolean;
286
+ }): Promise<GitLabMR> {
287
+ return gl.post<GitLabMR>(`/projects/${encodeProject(namespace, project)}/merge_requests`, {
288
+ remove_source_branch: true,
289
+ ...data,
290
+ });
291
+ }
292
+
293
+ export async function mergeGitLabMR(namespace: string, project: string, iid: number, opts: {
294
+ squash?: boolean;
295
+ message?: string;
296
+ } = {}): Promise<GitLabMR> {
297
+ return gl.put<GitLabMR>(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/merge`, {
298
+ squash: opts.squash ?? true,
299
+ squash_commit_message: opts.message,
300
+ });
301
+ }
302
+
303
+ export async function updateGitLabMR(namespace: string, project: string, iid: number, data: {
304
+ state_event?: 'close' | 'reopen';
305
+ title?: string;
306
+ }): Promise<GitLabMR> {
307
+ return gl.put<GitLabMR>(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}`, data);
308
+ }
309
+
310
+ export async function listGitLabMRNotes(namespace: string, project: string, iid: number): Promise<Array<{
311
+ id: number; body: string; author: { username: string }; created_at: string;
312
+ }>> {
313
+ return gl.get(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/notes?per_page=100`);
314
+ }
315
+
316
+ export async function createGitLabMRNote(namespace: string, project: string, iid: number, body: string): Promise<void> {
317
+ await gl.post(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/notes`, { body });
318
+ }
319
+
320
+ export async function updateGitLabMRNote(namespace: string, project: string, iid: number, noteId: number, body: string): Promise<void> {
321
+ await gl.put(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/notes/${noteId}`, { body });
322
+ }
323
+
324
+ export async function listGitLabMRChanges(namespace: string, project: string, iid: number): Promise<Array<{
325
+ old_path: string; new_path: string; new_file: boolean; deleted_file: boolean; diff: string;
326
+ }>> {
327
+ const data = await gl.get<{ changes: any[] }>(`/projects/${encodeProject(namespace, project)}/merge_requests/${iid}/changes`);
328
+ return data.changes || [];
329
+ }
330
+
331
+ // ── Pipelines ──────────────────────────────────────────────────────────
332
+ export interface GitLabPipeline {
333
+ id: number;
334
+ iid: number;
335
+ status: 'created' | 'waiting_for_resource' | 'preparing' | 'pending' | 'running' | 'success' | 'failed' | 'canceled' | 'skipped' | 'manual' | 'scheduled';
336
+ source: string;
337
+ ref: string;
338
+ sha: string;
339
+ web_url: string;
340
+ created_at: string;
341
+ updated_at: string;
342
+ }
343
+
344
+ export interface GitLabJob {
345
+ id: number;
346
+ name: string;
347
+ stage: string;
348
+ status: string;
349
+ web_url: string;
350
+ created_at: string;
351
+ started_at: string | null;
352
+ finished_at: string | null;
353
+ duration: number | null;
354
+ }
355
+
356
+ export async function listGitLabPipelines(namespace: string, project: string, opts: {
357
+ per_page?: number;
358
+ ref?: string;
359
+ status?: string;
360
+ } = {}): Promise<GitLabPipeline[]> {
361
+ const params = new URLSearchParams({ per_page: String(opts.per_page || 20) });
362
+ if (opts.ref) params.set('ref', opts.ref);
363
+ if (opts.status) params.set('status', opts.status);
364
+ return gl.get<GitLabPipeline[]>(`/projects/${encodeProject(namespace, project)}/pipelines?${params}`);
365
+ }
366
+
367
+ export async function getGitLabPipeline(namespace: string, project: string, pipelineId: number): Promise<GitLabPipeline> {
368
+ return gl.get<GitLabPipeline>(`/projects/${encodeProject(namespace, project)}/pipelines/${pipelineId}`);
369
+ }
370
+
371
+ export async function listGitLabPipelineJobs(namespace: string, project: string, pipelineId: number): Promise<GitLabJob[]> {
372
+ return gl.get<GitLabJob[]>(`/projects/${encodeProject(namespace, project)}/pipelines/${pipelineId}/jobs?per_page=100`);
373
+ }
374
+
375
+ export async function getGitLabJobLog(namespace: string, project: string, jobId: number): Promise<string> {
376
+ const res = await fetch(apiUrl(`/projects/${encodeProject(namespace, project)}/jobs/${jobId}/trace`), {
377
+ headers: { 'PRIVATE-TOKEN': _token },
378
+ });
379
+ if (!res.ok) throw new Error(`Could not fetch job log: ${res.status}`);
380
+ return res.text();
381
+ }
382
+
383
+ // ── MR CI status (for polling) ─────────────────────────────────────────
384
+ export async function getGitLabMRPipelineStatus(namespace: string, project: string, mrIid: number): Promise<{
385
+ status: string;
386
+ web_url: string;
387
+ } | null> {
388
+ try {
389
+ const pipelines = await gl.get<GitLabPipeline[]>(
390
+ `/projects/${encodeProject(namespace, project)}/merge_requests/${mrIid}/pipelines?per_page=1`
391
+ );
392
+ if (!pipelines.length) return null;
393
+ return { status: pipelines[0].status, web_url: pipelines[0].web_url };
394
+ } catch {
395
+ return null;
396
+ }
397
+ }
@@ -0,0 +1,201 @@
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
+
12
+ import Anthropic from '@anthropic-ai/sdk';
13
+
14
+ const GITLAB_TOKEN = process.env.GITLAB_TOKEN || '';
15
+ const GITLAB_HOST = (process.env.GITLAB_HOST || 'https://gitlab.com').replace(/\/$/, '');
16
+ const GATEWAY_TOKEN = process.env.AI_FLOW_AI_GATEWAY_TOKEN || '';
17
+
18
+ const anthropic = GATEWAY_TOKEN
19
+ ? new Anthropic({ apiKey: GATEWAY_TOKEN, baseURL: `${GITLAB_HOST}/api/v4/ai/llm/v1` })
20
+ : new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
21
+
22
+ const CI_RECOVERY_MARKER = '<!-- gitpadi-ci-recovery -->';
23
+
24
+ async function glFetch<T>(method: string, path: string, body?: unknown): Promise<T> {
25
+ const res = await fetch(`${GITLAB_HOST}/api/v4${path}`, {
26
+ method,
27
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN, 'Content-Type': 'application/json' },
28
+ body: body ? JSON.stringify(body) : undefined,
29
+ });
30
+ if (!res.ok) throw new Error(`GitLab ${method} ${path} → ${res.status}`);
31
+ return res.json() as Promise<T>;
32
+ }
33
+
34
+ async function getJobLog(projectId: number, jobId: number): Promise<string> {
35
+ const res = await fetch(`${GITLAB_HOST}/api/v4/projects/${projectId}/jobs/${jobId}/trace`, {
36
+ headers: { 'PRIVATE-TOKEN': GITLAB_TOKEN },
37
+ });
38
+ if (!res.ok) return 'Log unavailable';
39
+ const text = await res.text();
40
+ // Return last 3000 chars — most errors are at the end
41
+ return text.length > 3000 ? `...\n${text.slice(-3000)}` : text;
42
+ }
43
+
44
+ async function postOrUpdateMRNote(projectId: number, mrIid: number, body: string): Promise<void> {
45
+ const notes = await glFetch<Array<{ id: number; body: string }>>(
46
+ 'GET', `/projects/${projectId}/merge_requests/${mrIid}/notes?per_page=100`
47
+ );
48
+ const existing = notes.find(n => n.body?.includes(CI_RECOVERY_MARKER));
49
+ if (existing) {
50
+ await glFetch('PUT', `/projects/${projectId}/merge_requests/${mrIid}/notes/${existing.id}`, { body });
51
+ } else {
52
+ await glFetch('POST', `/projects/${projectId}/merge_requests/${mrIid}/notes`, { body });
53
+ }
54
+ }
55
+
56
+ async function main(): Promise<void> {
57
+ console.log('\n🤖 GitPadi CI Recovery Agent\n');
58
+
59
+ const contextRaw = process.env.AI_FLOW_CONTEXT || '{}';
60
+ let context: any;
61
+ try { context = JSON.parse(contextRaw); } catch { context = {}; }
62
+
63
+ const {
64
+ project_id,
65
+ project_path = '',
66
+ mr_iid,
67
+ pipeline_id,
68
+ title = '',
69
+ source_branch = '',
70
+ author = '',
71
+ failed_jobs = [],
72
+ } = context;
73
+
74
+ if (!mr_iid || !pipeline_id) {
75
+ console.log('⏭️ No MR/pipeline context — skipping.');
76
+ return;
77
+ }
78
+
79
+ console.log(` Project: ${project_path}`);
80
+ console.log(` MR: !${mr_iid} — ${title}`);
81
+ console.log(` Pipeline: #${pipeline_id}`);
82
+ console.log(` Failed: ${failed_jobs.length} job(s)\n`);
83
+
84
+ // Fetch all failed jobs if not in context
85
+ let jobs = failed_jobs;
86
+ if (!jobs.length) {
87
+ try {
88
+ const allJobs = await glFetch<Array<{ id: number; name: string; status: string; stage: string }>>(
89
+ 'GET', `/projects/${project_id}/pipelines/${pipeline_id}/jobs?per_page=100`
90
+ );
91
+ jobs = allJobs.filter((j: any) => j.status === 'failed');
92
+ } catch {
93
+ jobs = [];
94
+ }
95
+ }
96
+
97
+ if (!jobs.length) {
98
+ console.log(' No failed jobs found.');
99
+ return;
100
+ }
101
+
102
+ // Collect logs from failed jobs
103
+ console.log(' Fetching failure logs...');
104
+ const jobLogs: Array<{ name: string; stage: string; log: string }> = [];
105
+ for (const job of jobs.slice(0, 3)) { // max 3 jobs to avoid token overload
106
+ const log = await getJobLog(project_id, job.id);
107
+ jobLogs.push({ name: job.name, stage: job.stage || 'unknown', log });
108
+ console.log(` ✓ Fetched log for: ${job.name}`);
109
+ }
110
+
111
+ // Ask Claude to diagnose
112
+ const prompt = `You are GitPadi, an AI DevOps assistant integrated with GitLab.
113
+
114
+ A CI pipeline failed on this Merge Request:
115
+ - MR: !${mr_iid} — "${title}"
116
+ - Author: @${author}
117
+ - Branch: ${source_branch}
118
+
119
+ Failed job logs:
120
+ ${jobLogs.map(j => `\n### Job: ${j.name} (stage: ${j.stage})\n\`\`\`\n${j.log}\n\`\`\``).join('\n')}
121
+
122
+ Analyze these failures and provide:
123
+ 1. Root cause diagnosis for each failure
124
+ 2. Specific fix recommendations with code snippets where possible
125
+ 3. Priority order of fixes
126
+
127
+ Respond as JSON:
128
+ {
129
+ "failures": [
130
+ {
131
+ "job": "job name",
132
+ "rootCause": "explanation",
133
+ "errorType": "lint|test|build|type|other",
134
+ "fix": {
135
+ "description": "what to change",
136
+ "codeSnippet": "code if applicable, null otherwise",
137
+ "commands": ["shell commands if applicable"]
138
+ },
139
+ "confidence": "high|medium|low"
140
+ }
141
+ ],
142
+ "quickFix": "single most impactful thing to fix first",
143
+ "estimatedFixTime": "e.g. 5 minutes",
144
+ "summary": "overall diagnosis in 2-3 sentences"
145
+ }`;
146
+
147
+ let diagnosis: any;
148
+ try {
149
+ const response = await anthropic.messages.create({
150
+ model: 'claude-sonnet-4-20250514',
151
+ max_tokens: 2048,
152
+ messages: [{ role: 'user', content: prompt }],
153
+ });
154
+ const text = response.content[0].type === 'text' ? response.content[0].text : '';
155
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
156
+ if (!jsonMatch) throw new Error('No JSON');
157
+ diagnosis = JSON.parse(jsonMatch[0]);
158
+ } catch (e: any) {
159
+ console.error(`❌ Claude diagnosis failed: ${e.message}`);
160
+ process.exit(1);
161
+ }
162
+
163
+ // Build the recovery comment
164
+ let comment = `## 🤖 GitPadi — CI Failure Recovery\n\n`;
165
+ comment += `**Pipeline:** #${pipeline_id} | **Failed jobs:** ${jobs.length}\n\n`;
166
+
167
+ comment += `### Diagnosis\n\n${diagnosis.summary}\n\n`;
168
+
169
+ if (diagnosis.quickFix) {
170
+ comment += `> 🚀 **Quick Fix:** ${diagnosis.quickFix}\n`;
171
+ comment += `> ⏱️ **Estimated fix time:** ${diagnosis.estimatedFixTime || 'a few minutes'}\n\n`;
172
+ }
173
+
174
+ diagnosis.failures?.forEach((f: any, i: number) => {
175
+ const confIcon = f.confidence === 'high' ? '🔴' : f.confidence === 'medium' ? '🟡' : '🟢';
176
+ comment += `### ${i + 1}. \`${f.job}\`\n\n`;
177
+ comment += `**Root Cause:** ${f.rootCause}\n\n`;
178
+
179
+ if (f.fix) {
180
+ comment += `**Fix:** ${f.fix.description}\n\n`;
181
+ if (f.fix.codeSnippet) {
182
+ comment += `\`\`\`\n${f.fix.codeSnippet}\n\`\`\`\n\n`;
183
+ }
184
+ if (f.fix.commands?.length) {
185
+ comment += `Run:\n\`\`\`bash\n${f.fix.commands.join('\n')}\n\`\`\`\n\n`;
186
+ }
187
+ }
188
+
189
+ comment += `${confIcon} Confidence: ${f.confidence}\n\n`;
190
+ });
191
+
192
+ comment += `---\n`;
193
+ 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`;
194
+ comment += CI_RECOVERY_MARKER;
195
+
196
+ await postOrUpdateMRNote(project_id, mr_iid, comment);
197
+ console.log(`\n✅ Recovery guide posted to MR !${mr_iid}`);
198
+ console.log(` ${diagnosis.failures?.length || 0} failure(s) diagnosed`);
199
+ }
200
+
201
+ main().catch(e => { console.error('Fatal:', e.message); process.exit(1); });