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
@@ -1,7 +1,7 @@
1
1
  // commands/contribute.ts — Contributor workflow for GitPadi
2
2
  import chalk from 'chalk';
3
3
  import ora from 'ora';
4
- import { execSync } from 'child_process';
4
+ import { execFileSync } from 'child_process';
5
5
  import * as fs from 'fs';
6
6
  import boxen from 'boxen';
7
7
  import { getOctokit, getOwner, getRepo, forkRepo, getAuthenticatedUser, getLatestCheckRuns, setRepo } from '../core/github.js';
@@ -19,7 +19,7 @@ function parseTarget(input) {
19
19
  if (match) {
20
20
  return {
21
21
  owner: match[1],
22
- repo: match[2],
22
+ repo: match[2].replace(/\.git$/, ''),
23
23
  issue: match[4] ? parseInt(match[4]) : undefined
24
24
  };
25
25
  }
@@ -29,6 +29,14 @@ function parseTarget(input) {
29
29
  }
30
30
  throw new Error('Invalid target. Use a GitHub URL or "owner/repo" format.');
31
31
  }
32
+ /** Safe git wrapper — uses execFileSync to avoid shell injection */
33
+ function git(args, opts = {}) {
34
+ return execFileSync('git', args, {
35
+ encoding: 'utf-8',
36
+ stdio: opts.stdio ?? 'pipe',
37
+ cwd: opts.cwd,
38
+ });
39
+ }
32
40
  /**
33
41
  * Phase 2: Fork & Clone Workflow (The "Start" Command)
34
42
  */
@@ -41,6 +49,8 @@ export async function forkAndClone(target) {
41
49
  myUser = await getAuthenticatedUser();
42
50
  const forkFullName = await forkRepo(owner, repo);
43
51
  spinner.succeed(`Forked to ${green(forkFullName)}`);
52
+ // Give GitHub a moment to prepare the fork
53
+ await new Promise(r => setTimeout(r, 2000));
44
54
  }
45
55
  catch (e) {
46
56
  spinner.fail(e.message);
@@ -66,7 +76,7 @@ export async function forkAndClone(target) {
66
76
  // Check if it's already a valid git clone of this repo
67
77
  let isValidClone = false;
68
78
  try {
69
- const remoteUrl = execSync('git remote get-url origin', { cwd: cloneDir, encoding: 'utf-8', stdio: 'pipe' }).trim();
79
+ const remoteUrl = git(['remote', 'get-url', 'origin'], { cwd: cloneDir }).trim();
70
80
  if (remoteUrl.includes(repo))
71
81
  isValidClone = true;
72
82
  }
@@ -78,35 +88,52 @@ export async function forkAndClone(target) {
78
88
  setRepo(owner, repo);
79
89
  // Ensure upstream remote exists
80
90
  try {
81
- execSync(`git remote add upstream https://github.com/${owner}/${repo}.git`, { stdio: 'pipe' });
91
+ git(['remote', 'add', 'upstream', `https://github.com/${owner}/${repo}.git`], { stdio: 'ignore' });
82
92
  }
83
93
  catch { /* exists */ }
84
94
  // 1. Detect default branch
85
95
  let defaultBranch = 'main';
86
96
  try {
87
- execSync('git rev-parse upstream/main', { stdio: 'pipe' });
97
+ git(['rev-parse', 'upstream/main'], { stdio: 'ignore' });
88
98
  }
89
99
  catch {
90
100
  defaultBranch = 'master';
91
101
  }
92
102
  // 2. Checkout default branch before syncing
93
103
  try {
94
- execSync(`git checkout ${defaultBranch}`, { stdio: 'pipe' });
104
+ const current = git(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
105
+ if (current !== defaultBranch) {
106
+ git(['checkout', defaultBranch], { stdio: 'ignore' });
107
+ }
95
108
  }
96
109
  catch {
97
- // If it fails, maybe it's not even a valid default branch locally, but we'll try to sync anyway
110
+ // Ignore checkout failures for default branch
98
111
  }
99
112
  await syncBranch();
100
113
  // 3. Create or switch to issue branch
101
114
  const branchName = issue ? `fix/issue-${issue}` : null;
102
115
  if (branchName) {
103
- try {
104
- execSync(`git checkout -b ${branchName}`, { stdio: 'pipe' });
105
- console.log(green(` ✔ Created branch ${branchName}`));
106
- }
107
- catch {
108
- execSync(`git checkout ${branchName}`, { stdio: 'pipe' });
109
- console.log(green(` ✔ Switched to branch ${branchName}`));
116
+ const current = git(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
117
+ if (current !== branchName) {
118
+ try {
119
+ git(['checkout', '-b', branchName], { stdio: 'ignore' });
120
+ console.log(green(` ✔ Created branch ${branchName}`));
121
+ }
122
+ catch {
123
+ try {
124
+ git(['checkout', branchName], { stdio: 'ignore' });
125
+ console.log(green(` ✔ Switched to branch ${branchName}`));
126
+ }
127
+ catch (e) {
128
+ const nowBranch = git(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
129
+ if (nowBranch === branchName) {
130
+ console.log(green(` ✔ On branch ${branchName} (warning: some git hooks may have failed)`));
131
+ }
132
+ else {
133
+ console.warn(yellow(` ⚠️ Warning: Could not switch to branch ${branchName}: ${e.message}`));
134
+ }
135
+ }
136
+ }
110
137
  }
111
138
  }
112
139
  console.log(green('\n✨ Workspace ready!'));
@@ -121,26 +148,32 @@ export async function forkAndClone(target) {
121
148
  }]);
122
149
  cloneDir = path.resolve(resolvedParent, newDir);
123
150
  }
124
- // 4. Clone
125
- const cloneSpinner = ora(`Cloning your fork...`).start();
151
+ // 4. Clone (use execFileSync — no shell interpolation)
126
152
  const cloneUrl = `https://github.com/${myUser}/${repo}.git`;
153
+ console.log(dim(` ▸ Cloning from ${cloneUrl}...`));
127
154
  try {
128
- execSync(`git clone ${cloneUrl} ${cloneDir}`, { stdio: 'pipe' });
129
- cloneSpinner.succeed(`Cloned into ${green(cloneDir)}`);
155
+ execFileSync('git', ['clone', cloneUrl, cloneDir], { stdio: 'inherit' });
156
+ console.log(green(`\n ✔ Cloned successfully`));
130
157
  }
131
158
  catch (e) {
132
- cloneSpinner.fail(`Clone failed: ${e.message}`);
159
+ console.error(chalk.red(`\n ❌ Clone failed. Please check your internet connection and GitHub permissions.`));
133
160
  return;
134
161
  }
162
+ ora().succeed(`Setup complete for ${green(cloneDir)}`);
135
163
  // 5. Setup Remotes & Branch
136
164
  process.chdir(cloneDir);
137
- execSync(`git remote add upstream https://github.com/${owner}/${repo}.git`, { stdio: 'pipe' });
165
+ git(['remote', 'add', 'upstream', `https://github.com/${owner}/${repo}.git`], { stdio: 'ignore' });
138
166
  const branchName = issue ? `fix/issue-${issue}` : `contrib-${Math.floor(Date.now() / 1000)}`;
139
167
  try {
140
- execSync(`git checkout -b ${branchName}`, { stdio: 'pipe' });
168
+ git(['checkout', '-b', branchName], { stdio: 'ignore' });
141
169
  }
142
170
  catch {
143
- execSync(`git checkout ${branchName}`, { stdio: 'pipe' });
171
+ try {
172
+ git(['checkout', branchName], { stdio: 'ignore' });
173
+ }
174
+ catch {
175
+ // Ignore hook failures
176
+ }
144
177
  }
145
178
  // Update local GitPadi state
146
179
  setRepo(owner, repo);
@@ -161,15 +194,15 @@ export async function forkAndClone(target) {
161
194
  export async function syncBranch() {
162
195
  try {
163
196
  // Check if we are in a git repo and if upstream exists
164
- const remotes = execSync('git remote', { encoding: 'utf-8' });
197
+ const remotes = git(['remote']);
165
198
  if (!remotes.includes('upstream'))
166
199
  return;
167
200
  const spinner = ora('Syncing fork...').start();
168
201
  // 1. Detect upstream default branch (main or master)
169
- execSync('git fetch upstream', { stdio: 'pipe' });
202
+ git(['fetch', 'upstream'], { stdio: 'pipe' });
170
203
  let upstreamBranch = 'main';
171
204
  try {
172
- execSync('git rev-parse upstream/main', { stdio: 'pipe' });
205
+ git(['rev-parse', 'upstream/main'], { stdio: 'pipe' });
173
206
  }
174
207
  catch {
175
208
  upstreamBranch = 'master';
@@ -193,14 +226,14 @@ export async function syncBranch() {
193
226
  }
194
227
  // 3. Pull synced changes locally
195
228
  const pullSpinner = ora('Pulling latest changes...').start();
196
- const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
229
+ const currentBranch = git(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
197
230
  // Fetch origin (our fork) with the new synced data
198
- execSync('git fetch origin', { stdio: 'pipe' });
199
- execSync('git fetch upstream', { stdio: 'pipe' });
231
+ git(['fetch', 'origin'], { stdio: 'pipe' });
232
+ git(['fetch', 'upstream'], { stdio: 'pipe' });
200
233
  // If we're on main/master, just pull
201
234
  if (currentBranch === upstreamBranch) {
202
235
  try {
203
- execSync(`git pull origin ${upstreamBranch} --no-edit`, { stdio: 'pipe' });
236
+ git(['pull', 'origin', upstreamBranch, '--no-edit'], { stdio: 'pipe' });
204
237
  pullSpinner.succeed(green(`Pulled latest ${upstreamBranch} ✓`));
205
238
  }
206
239
  catch {
@@ -211,7 +244,7 @@ export async function syncBranch() {
211
244
  // We're on a feature branch — merge upstream into it
212
245
  pullSpinner.text = `Merging upstream/${upstreamBranch} into ${currentBranch}...`;
213
246
  try {
214
- execSync(`git merge upstream/${upstreamBranch} --no-edit`, { stdio: 'pipe' });
247
+ git(['merge', `upstream/${upstreamBranch}`, '--no-edit'], { stdio: 'pipe' });
215
248
  pullSpinner.succeed(green(`Merged upstream/${upstreamBranch} into ${cyan(currentBranch)} ✓`));
216
249
  }
217
250
  catch {
@@ -234,7 +267,7 @@ export async function submitPR(opts) {
234
267
  try {
235
268
  const owner = getOwner();
236
269
  const repo = getRepo();
237
- const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
270
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
238
271
  // Auto-infer issue from branch name (e.g. fix/issue-303)
239
272
  let linkedIssue = opts.issue;
240
273
  if (!linkedIssue) {
@@ -243,46 +276,39 @@ export async function submitPR(opts) {
243
276
  linkedIssue = parseInt(match[1]);
244
277
  }
245
278
  const octokit = getOctokit();
246
- // 2. Fetch Issue Details for better PR Metadata
279
+ // Fetch Issue Details for better PR Metadata
247
280
  let issueTitle = '';
248
281
  if (linkedIssue) {
249
282
  spinner.text = `Fetching details for issue #${linkedIssue}...`;
250
283
  try {
251
- const { data: issueData } = await octokit.issues.get({
252
- owner,
253
- repo,
254
- issue_number: linkedIssue,
255
- });
284
+ const { data: issueData } = await octokit.issues.get({ owner, repo, issue_number: linkedIssue });
256
285
  issueTitle = issueData.title;
257
286
  }
258
- catch (e) {
259
- // Silently fallback if issue doesn't exist or no access
260
- }
287
+ catch { /* Silently fallback */ }
261
288
  }
262
289
  const prTitle = opts.title || (issueTitle ? `fix: ${issueTitle} (#${linkedIssue})` : 'Automated contribution via GitPadi');
263
290
  const commitMsg = opts.message || prTitle;
264
- // 1. Stage and Commit
291
+ // Stage and Commit
265
292
  spinner.text = 'Staging and committing changes...';
266
293
  try {
267
- execSync('git add .', { stdio: 'pipe' });
268
- // Check if there are changes to commit
269
- const status = execSync('git status --porcelain', { encoding: 'utf-8' });
294
+ git(['add', '.'], { stdio: 'pipe' });
295
+ const status = git(['status', '--porcelain']);
270
296
  if (status.trim()) {
271
- execSync(`git commit -m "${commitMsg.replace(/"/g, '\\"')}"`, { stdio: 'pipe' });
297
+ // Use execFileSync to avoid shell injection on commit message
298
+ execFileSync('git', ['commit', '-m', commitMsg], { stdio: 'pipe' });
272
299
  }
273
300
  }
274
- catch (e) {
275
- // If commit fails (e.g. no changes), we might still want to push if there are unpushed commits
276
- dim(' (Note: No new changes to commit or commit failed)');
301
+ catch {
302
+ // No new changes to commit or commit failed continue to push anyway
277
303
  }
278
304
  spinner.text = 'Pushing to your fork...';
279
- execSync(`git push origin ${branch}`, { stdio: 'pipe' });
305
+ git(['push', 'origin', branch], { stdio: 'pipe' });
280
306
  spinner.text = 'Creating Pull Request...';
281
307
  const body = opts.body || (linkedIssue ? `Fixes #${linkedIssue}` : 'Automated PR via GitPadi');
282
308
  // Detect base branch
283
309
  let baseBranch = 'main';
284
310
  try {
285
- execSync('git rev-parse origin/main', { stdio: 'pipe' });
311
+ git(['rev-parse', 'origin/main'], { stdio: 'pipe' });
286
312
  }
287
313
  catch {
288
314
  baseBranch = 'master';
@@ -309,7 +335,7 @@ export async function viewLogs() {
309
335
  try {
310
336
  const owner = getOwner();
311
337
  const repo = getRepo();
312
- const sha = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
338
+ const sha = git(['rev-parse', 'HEAD']).trim();
313
339
  const { checkRuns, combinedState } = await getLatestCheckRuns(owner, repo, sha);
314
340
  spinner.stop();
315
341
  if (checkRuns.length === 0) {
@@ -330,3 +356,171 @@ export async function viewLogs() {
330
356
  spinner.fail(e.message);
331
357
  }
332
358
  }
359
+ /**
360
+ * Phase 8: Fix & Re-push (CI Failure Recovery)
361
+ * Shows failed checks, waits for user to fix, then amends commit and force-pushes.
362
+ */
363
+ export async function fixAndRepush() {
364
+ const inquirer = (await import('inquirer')).default;
365
+ // 1. Show current CI status
366
+ console.log(yellow('\n 🔧 Fix & Re-push Workflow\n'));
367
+ await viewLogs();
368
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
369
+ console.log(dim(` 📌 Current branch: ${cyan(branch)}\n`));
370
+ // 2. Wait for user to fix
371
+ const { ready } = await inquirer.prompt([{
372
+ type: 'list',
373
+ name: 'ready',
374
+ message: yellow('Have you fixed the CI issues?'),
375
+ choices: [
376
+ { name: `${green('✅')} Yes, stage & re-push my changes`, value: 'yes' },
377
+ { name: `${yellow('📝')} Yes, but let me write a new commit message`, value: 'new-msg' },
378
+ { name: `${dim('⬅')} Cancel`, value: 'cancel' },
379
+ ]
380
+ }]);
381
+ if (ready === 'cancel')
382
+ return;
383
+ const spinner = ora('Staging changes...').start();
384
+ try {
385
+ // 3. Stage everything
386
+ git(['add', '.'], { stdio: 'ignore' });
387
+ const status = git(['status', '--porcelain']);
388
+ if (!status.trim()) {
389
+ spinner.warn(yellow('No changes detected. Make sure you saved your files.'));
390
+ return;
391
+ }
392
+ if (ready === 'new-msg') {
393
+ spinner.stop();
394
+ const { msg } = await inquirer.prompt([{
395
+ type: 'input',
396
+ name: 'msg',
397
+ message: 'Commit message:',
398
+ default: 'fix: address CI failures'
399
+ }]);
400
+ execFileSync('git', ['commit', '-m', msg], { stdio: 'ignore' });
401
+ spinner.start('Pushing...');
402
+ }
403
+ else {
404
+ // Amend the previous commit (cleaner history)
405
+ spinner.text = 'Amending previous commit...';
406
+ git(['commit', '--amend', '--no-edit'], { stdio: 'ignore' });
407
+ }
408
+ // 4. Force push
409
+ spinner.text = 'Force-pushing to origin...';
410
+ git(['push', 'origin', branch, '--force-with-lease'], { stdio: 'ignore' });
411
+ spinner.succeed(green('Changes pushed! CI will re-run automatically.'));
412
+ // 5. Wait a moment and re-check
413
+ console.log(dim('\n ⏳ Waiting 10s for CI to pick up the new commit...\n'));
414
+ await new Promise(r => setTimeout(r, 10000));
415
+ await viewLogs();
416
+ }
417
+ catch (e) {
418
+ spinner.fail(chalk.red(`Re-push failed: ${e.message}`));
419
+ }
420
+ }
421
+ /**
422
+ * Phase 12: Reply to Maintainer Comments
423
+ * Fetches PR review comments and lets the contributor reply interactively.
424
+ */
425
+ export async function replyToComments() {
426
+ const inquirer = (await import('inquirer')).default;
427
+ const spinner = ora('Fetching PR comments...').start();
428
+ try {
429
+ const owner = getOwner();
430
+ const repo = getRepo();
431
+ const octokit = getOctokit();
432
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
433
+ const myUser = await getAuthenticatedUser();
434
+ // Find our open PR for this branch
435
+ const { data: prs } = await octokit.pulls.list({
436
+ owner, repo, state: 'open', head: `${myUser}:${branch}`
437
+ });
438
+ if (prs.length === 0) {
439
+ spinner.fail('No open PR found for this branch.');
440
+ return;
441
+ }
442
+ const pr = prs[0];
443
+ spinner.text = `Fetching comments for PR #${pr.number}...`;
444
+ // Get issue comments (general PR comments)
445
+ const { data: issueComments } = await octokit.issues.listComments({
446
+ owner, repo, issue_number: pr.number
447
+ });
448
+ // Get review comments (inline code comments)
449
+ const { data: reviewComments } = await octokit.pulls.listReviewComments({
450
+ owner, repo, pull_number: pr.number
451
+ });
452
+ // Filter to show only comments from others (not our own)
453
+ const otherComments = [
454
+ ...issueComments.filter(c => c.user?.login !== myUser).map(c => ({
455
+ id: c.id,
456
+ type: 'issue',
457
+ user: c.user?.login || 'unknown',
458
+ body: c.body || '',
459
+ created: c.created_at,
460
+ file: null,
461
+ })),
462
+ ...reviewComments.filter(c => c.user?.login !== myUser).map(c => ({
463
+ id: c.id,
464
+ type: 'review',
465
+ user: c.user?.login || 'unknown',
466
+ body: c.body || '',
467
+ created: c.created_at,
468
+ file: c.path,
469
+ })),
470
+ ].sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
471
+ spinner.stop();
472
+ if (otherComments.length === 0) {
473
+ console.log(green('\n ✅ No comments from maintainers to reply to.\n'));
474
+ return;
475
+ }
476
+ console.log(chalk.bold(`\n 💬 ${otherComments.length} comment(s) on PR #${pr.number}: "${pr.title}"\n`));
477
+ // Show each comment and offer to reply
478
+ for (const comment of otherComments) {
479
+ const fileInfo = comment.file ? dim(` (${comment.file})`) : '';
480
+ console.log(` ${cyan(`@${comment.user}`)}${fileInfo} — ${dim(new Date(comment.created).toLocaleDateString())}`);
481
+ console.log(` ${white(comment.body.substring(0, 200))}${comment.body.length > 200 ? dim('...') : ''}\n`);
482
+ const { action } = await inquirer.prompt([{
483
+ type: 'list',
484
+ name: 'action',
485
+ message: `Reply to @${comment.user}?`,
486
+ choices: [
487
+ { name: `${green('💬')} Write a reply`, value: 'reply' },
488
+ { name: `${dim('⏩')} Skip`, value: 'skip' },
489
+ { name: `${dim('⬅')} Done reviewing`, value: 'done' },
490
+ ]
491
+ }]);
492
+ if (action === 'done')
493
+ break;
494
+ if (action === 'skip')
495
+ continue;
496
+ const { reply } = await inquirer.prompt([{
497
+ type: 'input',
498
+ name: 'reply',
499
+ message: 'Your reply:'
500
+ }]);
501
+ if (reply && reply.trim()) {
502
+ const replySpinner = ora('Posting reply...').start();
503
+ if (comment.type === 'review') {
504
+ await octokit.pulls.createReplyForReviewComment({
505
+ owner, repo,
506
+ pull_number: pr.number,
507
+ comment_id: comment.id,
508
+ body: reply,
509
+ });
510
+ }
511
+ else {
512
+ await octokit.issues.createComment({
513
+ owner, repo,
514
+ issue_number: pr.number,
515
+ body: `> ${comment.body.split('\n')[0]}\n\n${reply}`,
516
+ });
517
+ }
518
+ replySpinner.succeed(green('Reply posted!'));
519
+ }
520
+ }
521
+ console.log('');
522
+ }
523
+ catch (e) {
524
+ spinner.fail(e.message);
525
+ }
526
+ }
@@ -0,0 +1,87 @@
1
+ // commands/gitlab-issues.ts — GitLab issue 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, listGitLabIssues, createGitLabIssue, updateGitLabIssue, createGitLabIssueNote, withGitLabRetry, } from '../core/gitlab.js';
6
+ export async function listIssues(opts = {}) {
7
+ requireGitLabProject();
8
+ const spinner = ora(`Fetching issues from ${chalk.cyan(getFullProject())}...`).start();
9
+ try {
10
+ const state = (opts.state === 'closed' ? 'closed' : opts.state === 'all' ? 'all' : 'opened');
11
+ const issues = await withGitLabRetry(() => listGitLabIssues(getNamespace(), getProject(), { state, per_page: opts.limit || 50 }));
12
+ spinner.stop();
13
+ if (!issues.length) {
14
+ console.log(chalk.yellow('\n No issues found.\n'));
15
+ return;
16
+ }
17
+ const table = new Table({
18
+ head: ['#', 'Title', 'Author', 'State', 'Labels'].map(h => chalk.cyan(h)),
19
+ style: { head: [], border: [] },
20
+ });
21
+ issues.forEach(issue => {
22
+ const stateLabel = issue.state === 'opened' ? chalk.green('open') : chalk.red('closed');
23
+ table.push([
24
+ `#${issue.iid}`,
25
+ issue.title.substring(0, 50),
26
+ `@${issue.author.username}`,
27
+ stateLabel,
28
+ issue.labels.slice(0, 3).join(', ') || '-',
29
+ ]);
30
+ });
31
+ console.log(`\n${chalk.bold(`📋 Issues — ${getFullProject()}`)} (${issues.length})\n`);
32
+ console.log(table.toString());
33
+ console.log('');
34
+ }
35
+ catch (e) {
36
+ spinner.fail(e.message);
37
+ }
38
+ }
39
+ export async function createIssue(opts) {
40
+ requireGitLabProject();
41
+ const spinner = ora('Creating issue...').start();
42
+ try {
43
+ const issue = await withGitLabRetry(() => createGitLabIssue(getNamespace(), getProject(), {
44
+ title: opts.title,
45
+ description: opts.description,
46
+ labels: opts.labels,
47
+ }));
48
+ spinner.succeed(`Created issue ${chalk.green(`#${issue.iid}`)}: ${issue.title}`);
49
+ console.log(chalk.dim(` ${issue.web_url}\n`));
50
+ }
51
+ catch (e) {
52
+ spinner.fail(e.message);
53
+ }
54
+ }
55
+ export async function closeIssue(iid) {
56
+ requireGitLabProject();
57
+ const spinner = ora(`Closing issue #${iid}...`).start();
58
+ try {
59
+ await withGitLabRetry(() => updateGitLabIssue(getNamespace(), getProject(), iid, { state_event: 'close' }));
60
+ spinner.succeed(`Closed issue ${chalk.red(`#${iid}`)}`);
61
+ }
62
+ catch (e) {
63
+ spinner.fail(e.message);
64
+ }
65
+ }
66
+ export async function reopenIssue(iid) {
67
+ requireGitLabProject();
68
+ const spinner = ora(`Reopening issue #${iid}...`).start();
69
+ try {
70
+ await withGitLabRetry(() => updateGitLabIssue(getNamespace(), getProject(), iid, { state_event: 'reopen' }));
71
+ spinner.succeed(`Reopened issue ${chalk.green(`#${iid}`)}`);
72
+ }
73
+ catch (e) {
74
+ spinner.fail(e.message);
75
+ }
76
+ }
77
+ export async function commentOnIssue(iid, body) {
78
+ requireGitLabProject();
79
+ const spinner = ora(`Posting comment on issue #${iid}...`).start();
80
+ try {
81
+ await withGitLabRetry(() => createGitLabIssueNote(getNamespace(), getProject(), iid, body));
82
+ spinner.succeed(`Comment posted on issue #${iid}`);
83
+ }
84
+ catch (e) {
85
+ spinner.fail(e.message);
86
+ }
87
+ }