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.
- package/.gitlab/duo/chat-rules.md +40 -0
- package/.gitlab/duo/mr-review-instructions.md +44 -0
- package/.gitlab-ci.yml +136 -0
- package/README.md +585 -57
- package/action.yml +21 -2
- package/dist/applicant-scorer.js +27 -105
- package/dist/cli.js +1082 -36
- package/dist/commands/apply-for-issue.js +396 -0
- package/dist/commands/bounty-hunter.js +441 -0
- package/dist/commands/contribute.js +245 -51
- package/dist/commands/gitlab-issues.js +87 -0
- package/dist/commands/gitlab-mrs.js +163 -0
- package/dist/commands/gitlab-pipelines.js +95 -0
- package/dist/commands/prs.js +3 -3
- package/dist/core/github.js +28 -0
- package/dist/core/gitlab.js +233 -0
- package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
- package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
- package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
- package/dist/gitlab-agents/mr-review-agent.js +200 -0
- package/dist/gitlab-agents/reminder-agent.js +164 -0
- package/dist/grade-assignment.js +262 -0
- package/dist/remind-contributors.js +127 -0
- package/dist/review-and-merge.js +125 -0
- package/examples/gitpadi.yml +152 -0
- package/package.json +20 -4
- package/src/applicant-scorer.ts +33 -141
- package/src/cli.ts +1119 -35
- package/src/commands/apply-for-issue.ts +452 -0
- package/src/commands/bounty-hunter.ts +529 -0
- package/src/commands/contribute.ts +264 -50
- package/src/commands/gitlab-issues.ts +87 -0
- package/src/commands/gitlab-mrs.ts +185 -0
- package/src/commands/gitlab-pipelines.ts +104 -0
- package/src/commands/prs.ts +3 -3
- package/src/core/github.ts +29 -0
- package/src/core/gitlab.ts +397 -0
- package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
- package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
- package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
- package/src/gitlab-agents/mr-review-agent.ts +231 -0
- package/src/gitlab-agents/reminder-agent.ts +203 -0
- package/src/grade-assignment.ts +283 -0
- package/src/remind-contributors.ts +159 -0
- 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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
152
|
-
|
|
178
|
+
execFileSync('git', ['clone', cloneUrl, cloneDir], { stdio: 'inherit' });
|
|
179
|
+
console.log(green(`\n ✔ Cloned successfully`));
|
|
153
180
|
} catch (e: any) {
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
+
git(['checkout', '-b', branchName], { stdio: 'ignore' });
|
|
166
195
|
} catch {
|
|
167
|
-
|
|
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 =
|
|
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
|
-
|
|
234
|
+
git(['fetch', 'upstream'], { stdio: 'pipe' });
|
|
202
235
|
let upstreamBranch = 'main';
|
|
203
236
|
try {
|
|
204
|
-
|
|
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 =
|
|
261
|
+
const currentBranch = git(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
229
262
|
|
|
230
263
|
// Fetch origin (our fork) with the new synced data
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
326
|
+
// Stage and Commit
|
|
300
327
|
spinner.text = 'Staging and committing changes...';
|
|
301
328
|
|
|
302
329
|
try {
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
333
|
+
// Use execFileSync to avoid shell injection on commit message
|
|
334
|
+
execFileSync('git', ['commit', '-m', commitMsg], { stdio: 'pipe' });
|
|
308
335
|
}
|
|
309
|
-
} catch
|
|
310
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|