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
|
@@ -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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
129
|
-
|
|
155
|
+
execFileSync('git', ['clone', cloneUrl, cloneDir], { stdio: 'inherit' });
|
|
156
|
+
console.log(green(`\n ✔ Cloned successfully`));
|
|
130
157
|
}
|
|
131
158
|
catch (e) {
|
|
132
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
+
git(['checkout', '-b', branchName], { stdio: 'ignore' });
|
|
141
169
|
}
|
|
142
170
|
catch {
|
|
143
|
-
|
|
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 =
|
|
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
|
-
|
|
202
|
+
git(['fetch', 'upstream'], { stdio: 'pipe' });
|
|
170
203
|
let upstreamBranch = 'main';
|
|
171
204
|
try {
|
|
172
|
-
|
|
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 =
|
|
229
|
+
const currentBranch = git(['rev-parse', '--abbrev-ref', 'HEAD']).trim();
|
|
197
230
|
// Fetch origin (our fork) with the new synced data
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
291
|
+
// Stage and Commit
|
|
265
292
|
spinner.text = 'Staging and committing changes...';
|
|
266
293
|
try {
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
297
|
+
// Use execFileSync to avoid shell injection on commit message
|
|
298
|
+
execFileSync('git', ['commit', '-m', commitMsg], { stdio: 'pipe' });
|
|
272
299
|
}
|
|
273
300
|
}
|
|
274
|
-
catch
|
|
275
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|