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,163 @@
1
+ // commands/gitlab-mrs.ts — GitLab Merge Request 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, listGitLabMRs, getGitLabMR, mergeGitLabMR, updateGitLabMR, listGitLabMRChanges, getGitLabMRPipelineStatus, withGitLabRetry, } from '../core/gitlab.js';
6
+ export async function listMRs(opts = {}) {
7
+ requireGitLabProject();
8
+ const spinner = ora(`Fetching MRs from ${chalk.cyan(getFullProject())}...`).start();
9
+ try {
10
+ const state = (['opened', 'closed', 'merged', 'all'].includes(opts.state || '')
11
+ ? opts.state : 'opened');
12
+ const mrs = await withGitLabRetry(() => listGitLabMRs(getNamespace(), getProject(), { state, per_page: opts.limit || 50 }));
13
+ spinner.stop();
14
+ if (!mrs.length) {
15
+ console.log(chalk.yellow('\n No merge requests found.\n'));
16
+ return;
17
+ }
18
+ const table = new Table({
19
+ head: ['#', 'Title', 'Author', 'Branch', 'State'].map(h => chalk.cyan(h)),
20
+ style: { head: [], border: [] },
21
+ });
22
+ mrs.forEach(mr => {
23
+ const stateLabel = mr.state === 'opened' ? chalk.green('open')
24
+ : mr.state === 'merged' ? chalk.magenta('merged') : chalk.red(mr.state);
25
+ table.push([
26
+ `!${mr.iid}`,
27
+ mr.title.substring(0, 50),
28
+ `@${mr.author.username}`,
29
+ mr.source_branch.substring(0, 25),
30
+ stateLabel,
31
+ ]);
32
+ });
33
+ console.log(`\n${chalk.bold(`🔀 Merge Requests — ${getFullProject()}`)} (${mrs.length})\n`);
34
+ console.log(table.toString());
35
+ console.log('');
36
+ }
37
+ catch (e) {
38
+ spinner.fail(e.message);
39
+ }
40
+ }
41
+ export async function mergeMR(iid, opts = {}) {
42
+ requireGitLabProject();
43
+ if (opts.force) {
44
+ const spinner = ora(`Force merging MR !${iid}...`).start();
45
+ try {
46
+ const result = await withGitLabRetry(() => mergeGitLabMR(getNamespace(), getProject(), iid, { squash: opts.squash ?? true, message: opts.message }));
47
+ spinner.succeed(`Force merged MR ${chalk.green(`!${iid}`)} ${chalk.yellow('(CI skipped)')}`);
48
+ console.log(chalk.dim(` SHA: ${result.sha}\n`));
49
+ }
50
+ catch (e) {
51
+ spinner.fail(e.message);
52
+ }
53
+ return;
54
+ }
55
+ const spinner = ora(`Checking CI status for MR !${iid}...`).start();
56
+ try {
57
+ // Poll up to 15 min (90 × 10s)
58
+ let attempts = 0;
59
+ const maxAttempts = 90;
60
+ let ciStatus = null;
61
+ while (attempts < maxAttempts) {
62
+ const pipeline = await withGitLabRetry(() => getGitLabMRPipelineStatus(getNamespace(), getProject(), iid));
63
+ if (!pipeline) {
64
+ spinner.info(chalk.dim('No CI pipeline found — proceeding without check.'));
65
+ break;
66
+ }
67
+ ciStatus = pipeline.status;
68
+ if (['success', 'failed', 'canceled', 'skipped'].includes(ciStatus))
69
+ break;
70
+ spinner.text = chalk.dim(`Pipeline ${ciStatus}... (${attempts * 10}s elapsed)`);
71
+ await new Promise(r => setTimeout(r, 10000));
72
+ attempts++;
73
+ }
74
+ spinner.stop();
75
+ if (ciStatus === 'failed' || ciStatus === 'canceled') {
76
+ console.log(chalk.red(` ❌ Pipeline ${ciStatus} — merge blocked. Fix the pipeline and retry.\n`));
77
+ return;
78
+ }
79
+ if (attempts >= maxAttempts) {
80
+ console.log(chalk.yellow(` ⚠️ Pipeline still running after 15 min. Try again later.\n`));
81
+ return;
82
+ }
83
+ if (ciStatus === 'success') {
84
+ console.log(chalk.green(` ✅ Pipeline passed!\n`));
85
+ }
86
+ const mergeSpinner = ora(`Merging MR !${iid}...`).start();
87
+ const result = await withGitLabRetry(() => mergeGitLabMR(getNamespace(), getProject(), iid, { squash: opts.squash ?? true, message: opts.message }));
88
+ mergeSpinner.succeed(`Merged MR ${chalk.green(`!${iid}`)}`);
89
+ console.log(chalk.dim(` SHA: ${result.sha}\n`));
90
+ }
91
+ catch (e) {
92
+ spinner.fail(e.message);
93
+ }
94
+ }
95
+ export async function closeMR(iid) {
96
+ requireGitLabProject();
97
+ const spinner = ora(`Closing MR !${iid}...`).start();
98
+ try {
99
+ await withGitLabRetry(() => updateGitLabMR(getNamespace(), getProject(), iid, { state_event: 'close' }));
100
+ spinner.succeed(`Closed MR ${chalk.red(`!${iid}`)}`);
101
+ }
102
+ catch (e) {
103
+ spinner.fail(e.message);
104
+ }
105
+ }
106
+ export async function reviewMR(iid) {
107
+ requireGitLabProject();
108
+ const spinner = ora(`Reviewing MR !${iid}...`).start();
109
+ try {
110
+ const mr = await withGitLabRetry(() => getGitLabMR(getNamespace(), getProject(), iid));
111
+ const changes = await withGitLabRetry(() => listGitLabMRChanges(getNamespace(), getProject(), iid));
112
+ spinner.stop();
113
+ console.log(`\n${chalk.bold(`🔍 MR Review — !${iid}: ${mr.title}`)}`);
114
+ console.log(chalk.dim(` Author: @${mr.author.username} Branch: ${mr.source_branch} → ${mr.target_branch}\n`));
115
+ const checks = [];
116
+ // Linked issues
117
+ const linked = mr.description?.match(/(fix(es|ed)?|clos(e[sd]?)|resolv(e[sd]?))\s+#\d+/gi) || [];
118
+ checks.push({ name: 'Linked Issues', icon: linked.length ? '✅' : '⚠️', detail: linked.length ? linked.join(', ') : 'None found — use "Fixes #N"' });
119
+ // Size
120
+ const totalLines = changes.reduce((sum, f) => {
121
+ const adds = (f.diff.match(/^\+/gm) || []).length;
122
+ const dels = (f.diff.match(/^-/gm) || []).length;
123
+ return sum + adds + dels;
124
+ }, 0);
125
+ checks.push({ name: 'MR Size', icon: totalLines > 1000 ? '❌' : totalLines > 500 ? '⚠️' : '✅', detail: `~${totalLines} lines changed (${changes.length} files)` });
126
+ // Tests
127
+ const srcFiles = changes.filter(f => !f.new_path.includes('test') && !f.new_path.includes('spec') && /\.(ts|rs|js|py)$/.test(f.new_path));
128
+ const testFiles = changes.filter(f => f.new_path.includes('test') || f.new_path.includes('spec'));
129
+ checks.push({ name: 'Tests', icon: srcFiles.length > 0 && testFiles.length === 0 ? '⚠️' : '✅', detail: `${testFiles.length} test file(s), ${srcFiles.length} source file(s)` });
130
+ // Sensitive files
131
+ const sensitive = changes.filter(f => /(\.env|secret|credential|password|\.key|\.pem)/i.test(f.new_path));
132
+ checks.push({ name: 'Security', icon: sensitive.length ? '❌' : '✅', detail: sensitive.length ? `Flagged: ${sensitive.map(f => f.new_path).join(', ')}` : 'Clean' });
133
+ // Draft
134
+ checks.push({ name: 'Draft Status', icon: mr.draft ? '⚠️' : '✅', detail: mr.draft ? 'MR is a draft — not ready to merge' : 'Ready for review' });
135
+ checks.forEach(c => console.log(` ${c.icon} ${chalk.bold(c.name)}: ${c.detail}`));
136
+ console.log('');
137
+ }
138
+ catch (e) {
139
+ spinner.fail(e.message);
140
+ }
141
+ }
142
+ export async function diffMR(iid) {
143
+ requireGitLabProject();
144
+ const spinner = ora(`Fetching diff for MR !${iid}...`).start();
145
+ try {
146
+ const changes = await withGitLabRetry(() => listGitLabMRChanges(getNamespace(), getProject(), iid));
147
+ spinner.stop();
148
+ console.log(`\n${chalk.bold(`📄 MR !${iid} — Changed Files`)} (${changes.length})\n`);
149
+ const table = new Table({
150
+ head: ['File', 'Status'].map(h => chalk.cyan(h)),
151
+ style: { head: [], border: [] },
152
+ });
153
+ changes.forEach(f => {
154
+ const status = f.new_file ? chalk.green('added') : f.deleted_file ? chalk.red('removed') : chalk.yellow('modified');
155
+ table.push([f.new_path.substring(0, 70), status]);
156
+ });
157
+ console.log(table.toString());
158
+ console.log('');
159
+ }
160
+ catch (e) {
161
+ spinner.fail(e.message);
162
+ }
163
+ }
@@ -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) {
@@ -94,6 +94,34 @@ export async function getRepoDetails(owner, repo) {
94
94
  const { data } = await getOctokit().repos.get({ owner, repo });
95
95
  return data;
96
96
  }
97
+ export async function getRepoPermissions(owner, repo) {
98
+ const data = await getRepoDetails(owner, repo);
99
+ return data.permissions || { admin: false, push: false, pull: true };
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
+ }
97
125
  export async function getLatestCheckRuns(owner, repo, ref) {
98
126
  const octokit = getOctokit();
99
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
+ }