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,104 @@
1
+ // commands/gitlab-pipelines.ts — GitLab CI/CD pipeline management for GitPadi
2
+
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import Table from 'cli-table3';
6
+ import {
7
+ getNamespace, getProject, getFullProject, requireGitLabProject,
8
+ listGitLabPipelines, getGitLabPipeline, listGitLabPipelineJobs, getGitLabJobLog,
9
+ withGitLabRetry,
10
+ } from '../core/gitlab.js';
11
+
12
+ function pipelineStatusColor(status: string): string {
13
+ switch (status) {
14
+ case 'success': return chalk.green(status);
15
+ case 'failed': return chalk.red(status);
16
+ case 'running': return chalk.yellow(status);
17
+ case 'canceled': return chalk.dim(status);
18
+ case 'pending': return chalk.blue(status);
19
+ default: return chalk.dim(status);
20
+ }
21
+ }
22
+
23
+ export async function listPipelines(opts: { ref?: string; limit?: number } = {}) {
24
+ requireGitLabProject();
25
+ const spinner = ora(`Fetching pipelines from ${chalk.cyan(getFullProject())}...`).start();
26
+ try {
27
+ const pipelines = await withGitLabRetry(() =>
28
+ listGitLabPipelines(getNamespace(), getProject(), { per_page: opts.limit || 20, ref: opts.ref })
29
+ );
30
+ spinner.stop();
31
+
32
+ if (!pipelines.length) { console.log(chalk.yellow('\n No pipelines found.\n')); return; }
33
+
34
+ const table = new Table({
35
+ head: ['ID', 'Status', 'Ref', 'SHA', 'Created'].map(h => chalk.cyan(h)),
36
+ style: { head: [], border: [] },
37
+ });
38
+
39
+ pipelines.forEach(p => {
40
+ table.push([
41
+ `#${p.id}`,
42
+ pipelineStatusColor(p.status),
43
+ p.ref.substring(0, 25),
44
+ p.sha.substring(0, 8),
45
+ new Date(p.created_at).toLocaleDateString(),
46
+ ]);
47
+ });
48
+
49
+ console.log(`\n${chalk.bold(`🔧 Pipelines — ${getFullProject()}`)} (${pipelines.length})\n`);
50
+ console.log(table.toString());
51
+ console.log('');
52
+ } catch (e: any) { spinner.fail(e.message); }
53
+ }
54
+
55
+ export async function viewPipelineJobs(pipelineId: number) {
56
+ requireGitLabProject();
57
+ const spinner = ora(`Fetching jobs for pipeline #${pipelineId}...`).start();
58
+ try {
59
+ const [pipeline, jobs] = await Promise.all([
60
+ withGitLabRetry(() => getGitLabPipeline(getNamespace(), getProject(), pipelineId)),
61
+ withGitLabRetry(() => listGitLabPipelineJobs(getNamespace(), getProject(), pipelineId)),
62
+ ]);
63
+ spinner.stop();
64
+
65
+ console.log(`\n${chalk.bold(`🔧 Pipeline #${pipelineId}`)} — ${pipelineStatusColor(pipeline.status)} — ${pipeline.ref}\n`);
66
+
67
+ const table = new Table({
68
+ head: ['Job', 'Stage', 'Status', 'Duration'].map(h => chalk.cyan(h)),
69
+ style: { head: [], border: [] },
70
+ });
71
+
72
+ jobs.forEach(job => {
73
+ const duration = job.duration ? `${Math.round(job.duration)}s` : '-';
74
+ table.push([job.name, job.stage, pipelineStatusColor(job.status), duration]);
75
+ });
76
+
77
+ console.log(table.toString());
78
+ console.log('');
79
+
80
+ const failed = jobs.filter(j => j.status === 'failed');
81
+ if (failed.length) {
82
+ console.log(chalk.red(` ❌ ${failed.length} failed job(s): ${failed.map(j => j.name).join(', ')}\n`));
83
+ }
84
+ } catch (e: any) { spinner.fail(e.message); }
85
+ }
86
+
87
+ export async function viewJobLog(jobId: number) {
88
+ requireGitLabProject();
89
+ const spinner = ora(`Fetching log for job #${jobId}...`).start();
90
+ try {
91
+ const log = await withGitLabRetry(() => getGitLabJobLog(getNamespace(), getProject(), jobId));
92
+ spinner.stop();
93
+
94
+ console.log(`\n${chalk.bold(`📋 Job #${jobId} Log`)}\n`);
95
+ // Show last 100 lines to avoid overwhelming the terminal
96
+ const lines = log.split('\n');
97
+ const tail = lines.slice(-100).join('\n');
98
+ if (lines.length > 100) {
99
+ console.log(chalk.dim(` ... (showing last 100 of ${lines.length} lines)\n`));
100
+ }
101
+ console.log(chalk.dim(tail));
102
+ console.log('');
103
+ } catch (e: any) { spinner.fail(e.message); }
104
+ }
@@ -60,9 +60,9 @@ export async function mergePR(number: number, opts: { method?: string; message?:
60
60
  const { data: pr } = await octokit.pulls.get({ owner: getOwner(), repo: getRepo(), pull_number: number });
61
61
  const sha = pr.head.sha;
62
62
 
63
- // Step 2: Poll until all checks complete (max 5 min)
63
+ // Step 2: Poll until all checks complete (max 15 min)
64
64
  let attempts = 0;
65
- const maxAttempts = 30;
65
+ const maxAttempts = 90;
66
66
  let allComplete = false;
67
67
  let ciChecks: Array<{ name: string; status: string; conclusion: string | null }> = [];
68
68
 
@@ -122,7 +122,7 @@ export async function mergePR(number: number, opts: { method?: string; message?:
122
122
  console.log('');
123
123
 
124
124
  if (!allComplete) {
125
- console.log(chalk.yellow(` ⚠️ Checks still running after 5 min. Try again later.\n`));
125
+ console.log(chalk.yellow(` ⚠️ Checks still running after 15 min. Try again later.\n`));
126
126
  return;
127
127
  }
128
128
  if (!allPassed) {
@@ -119,6 +119,30 @@ export async function getRepoPermissions(owner: string, repo: string) {
119
119
  return data.permissions || { admin: false, push: false, pull: true };
120
120
  }
121
121
 
122
+ /**
123
+ * Retries an async operation on GitHub rate-limit (429) or secondary rate-limit (403)
124
+ * with exponential backoff. maxRetries defaults to 4 (covers ~30s total wait).
125
+ */
126
+ export async function withRetry<T>(fn: () => Promise<T>, maxRetries = 4): Promise<T> {
127
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
128
+ try {
129
+ return await fn();
130
+ } catch (e: any) {
131
+ const isRateLimit = e.status === 429 || (e.status === 403 && /rate limit/i.test(e.message || ''));
132
+ if (isRateLimit && attempt < maxRetries) {
133
+ // Honour Retry-After header when present, else exponential backoff
134
+ const retryAfter = parseInt(e.response?.headers?.['retry-after'] || '0', 10);
135
+ const delay = retryAfter > 0 ? retryAfter * 1000 : Math.pow(2, attempt) * 1000 + Math.random() * 500;
136
+ await new Promise(r => setTimeout(r, delay));
137
+ continue;
138
+ }
139
+ throw e;
140
+ }
141
+ }
142
+ // Unreachable — satisfies TS return type
143
+ throw new Error('withRetry: max retries exceeded');
144
+ }
145
+
122
146
  export async function getLatestCheckRuns(owner: string, repo: string, ref: string) {
123
147
  const octokit = getOctokit();
124
148
  const { data: checks } = await octokit.checks.listForRef({ owner, repo, ref });
@@ -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
+ }