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