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
package/src/cli.ts
CHANGED
|
@@ -10,12 +10,17 @@ import inquirer from 'inquirer';
|
|
|
10
10
|
import gradient from 'gradient-string';
|
|
11
11
|
import figlet from 'figlet';
|
|
12
12
|
import os from 'node:os';
|
|
13
|
-
import {
|
|
13
|
+
import { execFileSync } from 'child_process';
|
|
14
14
|
|
|
15
15
|
import boxen from 'boxen';
|
|
16
16
|
import { createSpinner } from 'nanospinner';
|
|
17
17
|
import { Octokit } from '@octokit/rest';
|
|
18
|
-
import { initGitHub, setRepo, getOwner, getRepo, getOctokit, loadConfig, saveConfig, getToken, getAuthenticatedUser } from './core/github.js';
|
|
18
|
+
import { initGitHub, setRepo, getOwner, getRepo, getOctokit, loadConfig, saveConfig, getToken, getAuthenticatedUser, getRepoPermissions } from './core/github.js';
|
|
19
|
+
import {
|
|
20
|
+
initGitLab, getGitLabToken, getNamespace, getProject, getFullProject,
|
|
21
|
+
saveGitLabConfig, savePlatformPreference, loadPlatformPreference,
|
|
22
|
+
getAuthenticatedGitLabUser, getGitLabProject, setGitLabProject,
|
|
23
|
+
} from './core/gitlab.js';
|
|
19
24
|
|
|
20
25
|
import * as issues from './commands/issues.js';
|
|
21
26
|
import * as prs from './commands/prs.js';
|
|
@@ -23,8 +28,15 @@ import * as repos from './commands/repos.js';
|
|
|
23
28
|
import * as contributors from './commands/contributors.js';
|
|
24
29
|
import * as releases from './commands/releases.js';
|
|
25
30
|
import * as contribute from './commands/contribute.js';
|
|
31
|
+
import * as applyForIssue from './commands/apply-for-issue.js';
|
|
32
|
+
import { runBountyHunter } from './commands/bounty-hunter.js';
|
|
33
|
+
import * as gitlabIssues from './commands/gitlab-issues.js';
|
|
34
|
+
import * as gitlabMRs from './commands/gitlab-mrs.js';
|
|
35
|
+
import * as gitlabPipelines from './commands/gitlab-pipelines.js';
|
|
26
36
|
|
|
27
|
-
const VERSION = '2.
|
|
37
|
+
const VERSION = '2.1.1';
|
|
38
|
+
let targetConfirmed = false;
|
|
39
|
+
let gitlabProjectConfirmed = false;
|
|
28
40
|
|
|
29
41
|
// ── Styling ────────────────────────────────────────────────────────────
|
|
30
42
|
const cyber = gradient(['#ff00ff', '#00ffff', '#ff00ff']);
|
|
@@ -114,7 +126,7 @@ async function bootSequence() {
|
|
|
114
126
|
const bootSteps = [
|
|
115
127
|
'▸ Initializing GitPadi engine',
|
|
116
128
|
'▸ Loading command modules',
|
|
117
|
-
'▸
|
|
129
|
+
'▸ Connecting to GitHub & GitLab',
|
|
118
130
|
'▸ Systems online',
|
|
119
131
|
];
|
|
120
132
|
|
|
@@ -149,6 +161,19 @@ async function ensureAuthenticated() {
|
|
|
149
161
|
|
|
150
162
|
console.log(neon(' ⚡ Authentication — let\'s connect you to GitHub.\n'));
|
|
151
163
|
|
|
164
|
+
console.log(dim(' ┌─ How to get your GitHub Token ──────────────────────────────┐'));
|
|
165
|
+
console.log(dim(' │'));
|
|
166
|
+
console.log(dim(' │ 1. Open: ') + cyan('https://github.com/settings/tokens'));
|
|
167
|
+
console.log(dim(' │ 2. Click ') + bold('"Generate new token (classic)"'));
|
|
168
|
+
console.log(dim(' │ 3. Give it a name (e.g. "gitpadi")'));
|
|
169
|
+
console.log(dim(' │ 4. Select scopes: ') + yellow('repo') + dim(' (and ') + yellow('read:org') + dim(' for org repos)'));
|
|
170
|
+
console.log(dim(' │ 5. Click "Generate token" and copy it'));
|
|
171
|
+
console.log(dim(' │'));
|
|
172
|
+
console.log(dim(' │ The token will look like: ') + dim('ghp_xxxxxxxxxxxxxxxxxxxx'));
|
|
173
|
+
console.log(dim(' │'));
|
|
174
|
+
console.log(dim(' └─────────────────────────────────────────────────────────────┘'));
|
|
175
|
+
console.log('');
|
|
176
|
+
|
|
152
177
|
const { t } = await inquirer.prompt([{
|
|
153
178
|
type: 'password',
|
|
154
179
|
name: 't',
|
|
@@ -183,7 +208,18 @@ async function ensureTargetRepo(force = false) {
|
|
|
183
208
|
let repo = getRepo();
|
|
184
209
|
|
|
185
210
|
if (!force && owner && repo) {
|
|
186
|
-
return;
|
|
211
|
+
if (targetConfirmed) return;
|
|
212
|
+
|
|
213
|
+
const { confirm } = await inquirer.prompt([{
|
|
214
|
+
type: 'confirm',
|
|
215
|
+
name: 'confirm',
|
|
216
|
+
message: cyan('🎯 Targeting ') + bold(`${owner}/${repo}`) + cyan('. Correct?'),
|
|
217
|
+
default: true
|
|
218
|
+
}]);
|
|
219
|
+
if (confirm) {
|
|
220
|
+
targetConfirmed = true;
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
187
223
|
}
|
|
188
224
|
|
|
189
225
|
console.log(neon('\n 📦 Project Targeting — which repo are we working on?\n'));
|
|
@@ -240,38 +276,706 @@ async function ensureTargetRepo(force = false) {
|
|
|
240
276
|
|
|
241
277
|
setRepo(targetOwner, repo as string);
|
|
242
278
|
saveConfig({ token: getToken(), owner: targetOwner, repo: repo as string });
|
|
279
|
+
targetConfirmed = true;
|
|
280
|
+
|
|
281
|
+
// 4. Permission Check (for Maintainer safety)
|
|
282
|
+
const checkSpin = createSpinner(dim('Checking maintainer permissions...')).start();
|
|
283
|
+
try {
|
|
284
|
+
const perms = await getRepoPermissions(targetOwner, repo as string);
|
|
285
|
+
if (!perms.push && !perms.admin) {
|
|
286
|
+
checkSpin.warn({ text: yellow(`Limited Access → ${cyan(`${targetOwner}/${repo}`)}`) });
|
|
287
|
+
console.log(boxen(
|
|
288
|
+
chalk.yellow(`🛡️ Maintainer Mode Warning\n\n`) +
|
|
289
|
+
chalk.dim(`You do not have push/admin permissions for this repository.\n`) +
|
|
290
|
+
chalk.dim(`Most maintainer actions (bulk issues, merging PRs) will fail.`),
|
|
291
|
+
{ padding: 1, borderColor: 'yellow', borderStyle: 'round' }
|
|
292
|
+
));
|
|
293
|
+
|
|
294
|
+
const { proceed } = await inquirer.prompt([{
|
|
295
|
+
type: 'confirm',
|
|
296
|
+
name: 'proceed',
|
|
297
|
+
message: 'Continue anyway?',
|
|
298
|
+
default: false
|
|
299
|
+
}]);
|
|
300
|
+
|
|
301
|
+
if (!proceed) {
|
|
302
|
+
targetConfirmed = false;
|
|
303
|
+
throw new BackToMenu();
|
|
304
|
+
}
|
|
305
|
+
} else {
|
|
306
|
+
checkSpin.success({ text: green(`Permissions verified for ${cyan(`${targetOwner}/${repo}`)}`) });
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
checkSpin.warn({ text: yellow('Could not verify permissions (API limit or private)') });
|
|
310
|
+
}
|
|
243
311
|
|
|
244
312
|
console.log(green(`\n ✅ Locked in → ${cyan(`${targetOwner}/${repo}`)}\n`));
|
|
245
313
|
}
|
|
246
314
|
|
|
315
|
+
// ── GitLab Authentication ───────────────────────────────────────────────
|
|
316
|
+
async function ensureGitLabAuthenticated() {
|
|
317
|
+
initGitLab();
|
|
318
|
+
let token = getGitLabToken();
|
|
319
|
+
|
|
320
|
+
if (token) {
|
|
321
|
+
try {
|
|
322
|
+
await getAuthenticatedGitLabUser();
|
|
323
|
+
return; // token valid
|
|
324
|
+
} catch {
|
|
325
|
+
console.log(red(' ❌ Saved GitLab session invalid. Re-authenticating...'));
|
|
326
|
+
token = '';
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
console.log(neon('\n ⚡ GitLab Authentication — connect your account.\n'));
|
|
331
|
+
|
|
332
|
+
console.log(dim(' ┌─ How to get your GitLab Personal Access Token ──────────────┐'));
|
|
333
|
+
console.log(dim(' │'));
|
|
334
|
+
console.log(dim(' │ 1. Open: ') + cyan('https://gitlab.com/-/user_settings/personal_access_tokens'));
|
|
335
|
+
console.log(dim(' │ 2. Click ') + bold('"Add new token"'));
|
|
336
|
+
console.log(dim(' │ 3. Give it a name (e.g. "gitpadi") and set an expiry date'));
|
|
337
|
+
console.log(dim(' │ 4. Select scopes: ') + yellow('api') + dim(' (covers read/write access)'));
|
|
338
|
+
console.log(dim(' │ 5. Click "Create personal access token" and copy it'));
|
|
339
|
+
console.log(dim(' │'));
|
|
340
|
+
console.log(dim(' │ The token will look like: ') + dim('glpat-xxxxxxxxxxxxxxxxxxxx'));
|
|
341
|
+
console.log(dim(' │ For self-hosted GitLab, use your own host in the next step.'));
|
|
342
|
+
console.log(dim(' │'));
|
|
343
|
+
console.log(dim(' └─────────────────────────────────────────────────────────────┘'));
|
|
344
|
+
console.log('');
|
|
345
|
+
|
|
346
|
+
const { host } = await inquirer.prompt([{
|
|
347
|
+
type: 'input',
|
|
348
|
+
name: 'host',
|
|
349
|
+
message: cyan('🌐 GitLab host:'),
|
|
350
|
+
default: 'https://gitlab.com',
|
|
351
|
+
}]);
|
|
352
|
+
|
|
353
|
+
const { t } = await inquirer.prompt([{
|
|
354
|
+
type: 'password',
|
|
355
|
+
name: 't',
|
|
356
|
+
message: magenta('🔑 GitLab Personal Access Token') + dim(' (glpat-xxx):'),
|
|
357
|
+
mask: '•',
|
|
358
|
+
validate: async (v: string) => {
|
|
359
|
+
if (!v.startsWith('glpat-') && !v.startsWith('gl-')) {
|
|
360
|
+
return 'Token should start with glpat-';
|
|
361
|
+
}
|
|
362
|
+
const spinner = createSpinner(dim('Validating token...')).start();
|
|
363
|
+
try {
|
|
364
|
+
// Temp init to validate
|
|
365
|
+
const tmpRes = await fetch(`${host.replace(/\/$/, '')}/api/v4/user`, {
|
|
366
|
+
headers: { 'PRIVATE-TOKEN': v },
|
|
367
|
+
});
|
|
368
|
+
if (!tmpRes.ok) throw new Error('Invalid');
|
|
369
|
+
spinner.success({ text: green('Token valid!') });
|
|
370
|
+
return true;
|
|
371
|
+
} catch {
|
|
372
|
+
spinner.error({ text: red('Invalid token — GitLab rejected it.') });
|
|
373
|
+
return 'Invalid token';
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
}]);
|
|
377
|
+
|
|
378
|
+
saveGitLabConfig(t, host, '', '');
|
|
379
|
+
initGitLab(t, '', '', host);
|
|
380
|
+
const user = await getAuthenticatedGitLabUser();
|
|
381
|
+
console.log(green(`\n ✅ Authenticated as @${user.username}\n`));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Prompts user to select a GitLab project (namespace/project)
|
|
386
|
+
*/
|
|
387
|
+
async function ensureGitLabProject(force = false) {
|
|
388
|
+
let ns = getNamespace();
|
|
389
|
+
let proj = getProject();
|
|
390
|
+
|
|
391
|
+
if (!force && ns && proj) {
|
|
392
|
+
if (gitlabProjectConfirmed) return;
|
|
393
|
+
const { confirm } = await inquirer.prompt([{
|
|
394
|
+
type: 'confirm',
|
|
395
|
+
name: 'confirm',
|
|
396
|
+
message: cyan('🎯 Targeting ') + bold(`${ns}/${proj}`) + cyan('. Correct?'),
|
|
397
|
+
default: true,
|
|
398
|
+
}]);
|
|
399
|
+
if (confirm) { gitlabProjectConfirmed = true; return; }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
console.log(neon('\n 📦 GitLab Project — which project are we working on?\n'));
|
|
403
|
+
|
|
404
|
+
const user = await getAuthenticatedGitLabUser();
|
|
405
|
+
|
|
406
|
+
const { targetNs } = await inquirer.prompt([{
|
|
407
|
+
type: 'input',
|
|
408
|
+
name: 'targetNs',
|
|
409
|
+
message: cyan('👤 Namespace (user or group):'),
|
|
410
|
+
default: ns || user.username,
|
|
411
|
+
validate: (v: string) => v.length > 0 || 'Required',
|
|
412
|
+
}]);
|
|
413
|
+
|
|
414
|
+
const { targetProj } = await inquirer.prompt([{
|
|
415
|
+
type: 'input',
|
|
416
|
+
name: 'targetProj',
|
|
417
|
+
message: cyan('📦 Project path:'),
|
|
418
|
+
default: proj || '',
|
|
419
|
+
validate: (v: string) => v.length > 0 || 'Required',
|
|
420
|
+
}]);
|
|
421
|
+
|
|
422
|
+
// Verify project exists
|
|
423
|
+
const spin = createSpinner(dim(`Verifying ${targetNs}/${targetProj}...`)).start();
|
|
424
|
+
try {
|
|
425
|
+
const project = await getGitLabProject(targetNs, targetProj);
|
|
426
|
+
spin.success({ text: green(`Found: ${project.path_with_namespace}`) });
|
|
427
|
+
} catch {
|
|
428
|
+
spin.warn({ text: yellow('Could not verify project — proceeding anyway.') });
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
setGitLabProject(targetNs, targetProj);
|
|
432
|
+
gitlabProjectConfirmed = true;
|
|
433
|
+
console.log(green(`\n ✅ Locked in → ${cyan(`${targetNs}/${targetProj}`)}\n`));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ── GitLab Maintainer Menu ─────────────────────────────────────────────
|
|
437
|
+
async function gitlabMaintainerMenu() {
|
|
438
|
+
while (true) {
|
|
439
|
+
line('═');
|
|
440
|
+
console.log(gradient(['#FC6D26', '#E24329'])(' 🦊 GITPADI GITLAB MAINTAINER'));
|
|
441
|
+
console.log(dim(' Manage issues, merge requests, and pipelines'));
|
|
442
|
+
line('═');
|
|
443
|
+
console.log('');
|
|
444
|
+
|
|
445
|
+
const { category } = await inquirer.prompt([{
|
|
446
|
+
type: 'list',
|
|
447
|
+
name: 'category',
|
|
448
|
+
message: bold('Select operation:'),
|
|
449
|
+
choices: [
|
|
450
|
+
{ name: `${magenta('📋')} ${bold('Issues')} ${dim('— create, close, comment')}`, value: 'issues' },
|
|
451
|
+
{ name: `${cyan('🔀')} ${bold('Merge Requests')} ${dim('— list, merge, review, diff')}`, value: 'mrs' },
|
|
452
|
+
{ name: `${yellow('🔧')} ${bold('Pipelines')} ${dim('— list, view jobs, read logs')}`, value: 'pipelines' },
|
|
453
|
+
new inquirer.Separator(dim(' ─────────────────────────────')),
|
|
454
|
+
{ name: `${dim('⚙️')} ${dim('Switch Project')}`, value: 'switch' },
|
|
455
|
+
{ name: `${dim('⬅')} ${dim('Back to Mode Selector')}`, value: 'back' },
|
|
456
|
+
],
|
|
457
|
+
loop: false,
|
|
458
|
+
}]);
|
|
459
|
+
|
|
460
|
+
if (category === 'back') break;
|
|
461
|
+
|
|
462
|
+
if (category === 'switch') {
|
|
463
|
+
await ensureGitLabProject(true);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (category === 'issues') await safeMenu(gitlabIssueMenu);
|
|
468
|
+
else if (category === 'mrs') await safeMenu(gitlabMRMenu);
|
|
469
|
+
else if (category === 'pipelines') await safeMenu(gitlabPipelineMenu);
|
|
470
|
+
|
|
471
|
+
console.log('');
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function gitlabIssueMenu() {
|
|
476
|
+
const { action } = await inquirer.prompt([{
|
|
477
|
+
type: 'list', name: 'action', message: magenta('📋 Issue Operation:'),
|
|
478
|
+
choices: [
|
|
479
|
+
{ name: ` ${dim('⬅ Back')}`, value: 'back' },
|
|
480
|
+
new inquirer.Separator(dim(' ─────────────────────────────')),
|
|
481
|
+
{ name: ` ${green('▸')} List issues`, value: 'list' },
|
|
482
|
+
{ name: ` ${green('▸')} Create issue`, value: 'create' },
|
|
483
|
+
{ name: ` ${red('▸')} Close issue`, value: 'close' },
|
|
484
|
+
{ name: ` ${green('▸')} Reopen issue`, value: 'reopen' },
|
|
485
|
+
{ name: ` ${cyan('▸')} Comment on issue`, value: 'comment' },
|
|
486
|
+
],
|
|
487
|
+
}]);
|
|
488
|
+
|
|
489
|
+
if (action === 'back') return;
|
|
490
|
+
|
|
491
|
+
if (action === 'list') {
|
|
492
|
+
const a = await ask([
|
|
493
|
+
{ type: 'list', name: 'state', message: 'State:', choices: ['opened', 'closed', 'all'], default: 'opened' },
|
|
494
|
+
{ type: 'input', name: 'limit', message: dim('Max results:'), default: '50' },
|
|
495
|
+
]);
|
|
496
|
+
await gitlabIssues.listIssues({ state: a.state, limit: parseInt(a.limit) });
|
|
497
|
+
} else if (action === 'create') {
|
|
498
|
+
const a = await ask([
|
|
499
|
+
{ type: 'input', name: 'title', message: yellow('Issue title'), validate: (v: string) => v.length > 0 || 'Required' },
|
|
500
|
+
{ type: 'input', name: 'description', message: dim('Description (optional):'), default: '' },
|
|
501
|
+
{ type: 'input', name: 'labels', message: dim('Labels (comma-separated):'), default: '' },
|
|
502
|
+
]);
|
|
503
|
+
await gitlabIssues.createIssue({ title: a.title, description: a.description || undefined, labels: a.labels || undefined });
|
|
504
|
+
} else if (action === 'close' || action === 'reopen') {
|
|
505
|
+
const { n } = await ask([{ type: 'input', name: 'n', message: yellow('Issue #') + dim(' (q=back):') }]);
|
|
506
|
+
if (!n || isNaN(Number(n))) return;
|
|
507
|
+
if (action === 'close') await gitlabIssues.closeIssue(parseInt(n));
|
|
508
|
+
else await gitlabIssues.reopenIssue(parseInt(n));
|
|
509
|
+
} else if (action === 'comment') {
|
|
510
|
+
const a = await ask([
|
|
511
|
+
{ type: 'input', name: 'n', message: yellow('Issue #') + dim(' (q=back):') },
|
|
512
|
+
{ type: 'input', name: 'body', message: cyan('Comment:') },
|
|
513
|
+
]);
|
|
514
|
+
if (!a.n || isNaN(Number(a.n))) return;
|
|
515
|
+
await gitlabIssues.commentOnIssue(parseInt(a.n), a.body);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function gitlabMRMenu() {
|
|
520
|
+
const { action } = await inquirer.prompt([{
|
|
521
|
+
type: 'list', name: 'action', message: cyan('🔀 Merge Request Operation:'),
|
|
522
|
+
choices: [
|
|
523
|
+
{ name: ` ${dim('⬅ Back')}`, value: 'back' },
|
|
524
|
+
new inquirer.Separator(dim(' ─────────────────────────────')),
|
|
525
|
+
{ name: ` ${green('▸')} List merge requests`, value: 'list' },
|
|
526
|
+
{ name: ` ${green('▸')} Merge MR ${dim('(checks pipeline first)')}`, value: 'merge' },
|
|
527
|
+
{ name: ` ${yellow('▸')} Force merge ${dim('(skip pipeline)')}`, value: 'force-merge' },
|
|
528
|
+
{ name: ` ${red('▸')} Close MR`, value: 'close' },
|
|
529
|
+
{ name: ` ${yellow('▸')} Review MR`, value: 'review' },
|
|
530
|
+
{ name: ` ${cyan('▸')} View diff`, value: 'diff' },
|
|
531
|
+
],
|
|
532
|
+
}]);
|
|
533
|
+
|
|
534
|
+
if (action === 'back') return;
|
|
535
|
+
|
|
536
|
+
if (action === 'list') {
|
|
537
|
+
const a = await ask([
|
|
538
|
+
{ type: 'list', name: 'state', message: 'State:', choices: ['opened', 'closed', 'merged', 'all'], default: 'opened' },
|
|
539
|
+
{ type: 'input', name: 'limit', message: dim('Max results:'), default: '50' },
|
|
540
|
+
]);
|
|
541
|
+
await gitlabMRs.listMRs({ state: a.state, limit: parseInt(a.limit) });
|
|
542
|
+
} else if (action === 'merge' || action === 'force-merge') {
|
|
543
|
+
const a = await ask([
|
|
544
|
+
{ type: 'input', name: 'n', message: yellow('MR !') + dim('(q=back):') },
|
|
545
|
+
{ type: 'confirm', name: 'squash', message: 'Squash commits?', default: true },
|
|
546
|
+
{ type: 'confirm', name: 'confirm', message: action === 'force-merge' ? yellow('⚠️ Force merge (skip pipeline)?') : red('⚠️ Merge this MR?'), default: false },
|
|
547
|
+
]);
|
|
548
|
+
if (a.confirm) await gitlabMRs.mergeMR(parseInt(a.n), { squash: a.squash, force: action === 'force-merge' });
|
|
549
|
+
} else {
|
|
550
|
+
const { n } = await ask([{ type: 'input', name: 'n', message: yellow('MR !') + dim('(q=back):') }]);
|
|
551
|
+
if (!n || isNaN(Number(n))) return;
|
|
552
|
+
if (action === 'close') await gitlabMRs.closeMR(parseInt(n));
|
|
553
|
+
else if (action === 'review') await gitlabMRs.reviewMR(parseInt(n));
|
|
554
|
+
else await gitlabMRs.diffMR(parseInt(n));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function gitlabPipelineMenu() {
|
|
559
|
+
const { action } = await inquirer.prompt([{
|
|
560
|
+
type: 'list', name: 'action', message: yellow('🔧 Pipeline Operation:'),
|
|
561
|
+
choices: [
|
|
562
|
+
{ name: ` ${dim('⬅ Back')}`, value: 'back' },
|
|
563
|
+
new inquirer.Separator(dim(' ─────────────────────────────')),
|
|
564
|
+
{ name: ` ${green('▸')} List pipelines`, value: 'list' },
|
|
565
|
+
{ name: ` ${cyan('▸')} View pipeline jobs`, value: 'jobs' },
|
|
566
|
+
{ name: ` ${cyan('▸')} View job log`, value: 'log' },
|
|
567
|
+
],
|
|
568
|
+
}]);
|
|
569
|
+
|
|
570
|
+
if (action === 'back') return;
|
|
571
|
+
|
|
572
|
+
if (action === 'list') {
|
|
573
|
+
const a = await ask([
|
|
574
|
+
{ type: 'input', name: 'ref', message: dim('Branch/ref (blank for all):'), default: '' },
|
|
575
|
+
{ type: 'input', name: 'limit', message: dim('Max results:'), default: '20' },
|
|
576
|
+
]);
|
|
577
|
+
await gitlabPipelines.listPipelines({ ref: a.ref || undefined, limit: parseInt(a.limit) });
|
|
578
|
+
} else if (action === 'jobs') {
|
|
579
|
+
const { n } = await ask([{ type: 'input', name: 'n', message: yellow('Pipeline ID:') + dim(' (q=back):') }]);
|
|
580
|
+
if (!n || isNaN(Number(n))) return;
|
|
581
|
+
await gitlabPipelines.viewPipelineJobs(parseInt(n));
|
|
582
|
+
} else if (action === 'log') {
|
|
583
|
+
const { n } = await ask([{ type: 'input', name: 'n', message: yellow('Job ID:') + dim(' (q=back):') }]);
|
|
584
|
+
if (!n || isNaN(Number(n))) return;
|
|
585
|
+
await gitlabPipelines.viewJobLog(parseInt(n));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ── Platform Selector ──────────────────────────────────────────────────
|
|
590
|
+
async function selectPlatform(): Promise<'github' | 'gitlab'> {
|
|
591
|
+
const saved = loadPlatformPreference();
|
|
592
|
+
if (saved) {
|
|
593
|
+
const { keepPlatform } = await inquirer.prompt([{
|
|
594
|
+
type: 'confirm',
|
|
595
|
+
name: 'keepPlatform',
|
|
596
|
+
message: `${dim('Last session:')} ${saved === 'github' ? cyan('GitHub') : gradient(['#FC6D26', '#E24329'])('GitLab')} — continue with this?`,
|
|
597
|
+
default: true,
|
|
598
|
+
}]);
|
|
599
|
+
if (keepPlatform) return saved;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const { platform } = await inquirer.prompt([{
|
|
603
|
+
type: 'list',
|
|
604
|
+
name: 'platform',
|
|
605
|
+
message: bold('Choose your platform:'),
|
|
606
|
+
choices: [
|
|
607
|
+
{ name: `${cyan('🐙')} ${bold('GitHub')} ${dim('— github.com')}`, value: 'github' },
|
|
608
|
+
{ name: `${gradient(['#FC6D26', '#E24329'])('🦊')} ${bold('GitLab')} ${dim('— gitlab.com or self-hosted')}`, value: 'gitlab' },
|
|
609
|
+
],
|
|
610
|
+
loop: false,
|
|
611
|
+
}]);
|
|
612
|
+
|
|
613
|
+
savePlatformPreference(platform);
|
|
614
|
+
return platform;
|
|
615
|
+
}
|
|
616
|
+
|
|
247
617
|
// ── Mode Selector ──────────────────────────────────────────────────────
|
|
248
618
|
async function mainMenu() {
|
|
619
|
+
const platform = await selectPlatform();
|
|
620
|
+
|
|
621
|
+
// Authenticate for selected platform
|
|
622
|
+
if (platform === 'gitlab') {
|
|
623
|
+
await ensureGitLabAuthenticated();
|
|
624
|
+
}
|
|
625
|
+
|
|
249
626
|
while (true) {
|
|
250
627
|
line('═');
|
|
251
|
-
|
|
628
|
+
const platformLabel = platform === 'github'
|
|
629
|
+
? cyan('GitHub')
|
|
630
|
+
: gradient(['#FC6D26', '#E24329'])('GitLab');
|
|
631
|
+
console.log(cyber(` ⟨ GITPADI MODE SELECTOR ⟩`) + dim(` [${platformLabel}]`));
|
|
252
632
|
console.log(dim(' Select your workflow persona to continue'));
|
|
253
633
|
line('═');
|
|
254
634
|
console.log('');
|
|
255
635
|
|
|
256
|
-
|
|
636
|
+
if (platform === 'github') {
|
|
637
|
+
const { mode } = await inquirer.prompt([{
|
|
638
|
+
type: 'list',
|
|
639
|
+
name: 'mode',
|
|
640
|
+
message: bold('Choose your path:'),
|
|
641
|
+
choices: [
|
|
642
|
+
{ name: `${cyan('✨')} ${bold('Contributor Mode')} ${dim('— fork, clone, sync, submit PRs')}`, value: 'contributor' },
|
|
643
|
+
{ name: `${magenta('🛠️')} ${bold('Maintainer Mode')} ${dim('— manage issues, PRs, contributors')}`, value: 'maintainer' },
|
|
644
|
+
{ name: `${yellow('🏫')} ${bold('Organization/School')} ${dim('— assignments, grading, leaderboard')}`, value: 'org' },
|
|
645
|
+
new inquirer.Separator(dim(' ─────────────────────────────')),
|
|
646
|
+
{ name: `${dim('🔄')} ${dim('Switch Platform')}`, value: 'switch' },
|
|
647
|
+
{ name: `${dim('👋')} ${dim('Exit')}`, value: 'exit' },
|
|
648
|
+
],
|
|
649
|
+
loop: false,
|
|
650
|
+
}]);
|
|
651
|
+
|
|
652
|
+
if (mode === 'exit') break;
|
|
653
|
+
if (mode === 'switch') { savePlatformPreference('gitlab'); return mainMenu(); }
|
|
654
|
+
if (mode === 'contributor') await safeMenu(contributorMenu);
|
|
655
|
+
else if (mode === 'maintainer') {
|
|
656
|
+
await ensureTargetRepo();
|
|
657
|
+
await safeMenu(maintainerMenu);
|
|
658
|
+
} else if (mode === 'org') {
|
|
659
|
+
await ensureTargetRepo();
|
|
660
|
+
await safeMenu(orgMenu);
|
|
661
|
+
}
|
|
662
|
+
} else {
|
|
663
|
+
// GitLab mode
|
|
664
|
+
const { mode } = await inquirer.prompt([{
|
|
665
|
+
type: 'list',
|
|
666
|
+
name: 'mode',
|
|
667
|
+
message: bold('Choose your path:'),
|
|
668
|
+
choices: [
|
|
669
|
+
{ name: `${gradient(['#FC6D26', '#E24329'])('🛠️')} ${bold('GitLab Maintainer')} ${dim('— issues, MRs, pipelines')}`, value: 'gl-maintainer' },
|
|
670
|
+
new inquirer.Separator(dim(' ─────────────────────────────')),
|
|
671
|
+
{ name: `${dim('🔄')} ${dim('Switch Platform')}`, value: 'switch' },
|
|
672
|
+
{ name: `${dim('👋')} ${dim('Exit')}`, value: 'exit' },
|
|
673
|
+
],
|
|
674
|
+
loop: false,
|
|
675
|
+
}]);
|
|
676
|
+
|
|
677
|
+
if (mode === 'exit') break;
|
|
678
|
+
if (mode === 'switch') { savePlatformPreference('github'); return mainMenu(); }
|
|
679
|
+
if (mode === 'gl-maintainer') {
|
|
680
|
+
await ensureGitLabProject();
|
|
681
|
+
await safeMenu(gitlabMaintainerMenu);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// ── Organization / School Menu ─────────────────────────────────────────
|
|
688
|
+
async function orgMenu() {
|
|
689
|
+
while (true) {
|
|
690
|
+
line('═');
|
|
691
|
+
console.log(yellow(' 🏫 GITPADI ORGANIZATION / SCHOOL'));
|
|
692
|
+
console.log(dim(' Create assignments, grade PRs, track student performance'));
|
|
693
|
+
line('═');
|
|
694
|
+
console.log('');
|
|
695
|
+
|
|
696
|
+
const { action } = await inquirer.prompt([{
|
|
257
697
|
type: 'list',
|
|
258
|
-
name: '
|
|
259
|
-
message: bold('
|
|
698
|
+
name: 'action',
|
|
699
|
+
message: bold('Select operation:'),
|
|
260
700
|
choices: [
|
|
261
|
-
{ name: `${
|
|
262
|
-
{ name: `${
|
|
701
|
+
{ name: `${yellow('📝')} ${bold('Create Assignment')} ${dim('— post a new assignment as an issue')}`, value: 'create' },
|
|
702
|
+
{ name: `${green('📊')} ${bold('Grade a PR')} ${dim('— score a student submission')}`, value: 'grade' },
|
|
703
|
+
{ name: `${cyan('🏆')} ${bold('Cohort Leaderboard')} ${dim('— rank all students by score')}`, value: 'leaderboard' },
|
|
263
704
|
new inquirer.Separator(dim(' ─────────────────────────────')),
|
|
264
|
-
{ name: `${dim('
|
|
705
|
+
{ name: `${dim('⚙️')} ${dim('Switch Repo')}`, value: 'switch' },
|
|
706
|
+
{ name: `${dim('⬅')} ${dim('Back to Mode Selector')}`, value: 'back' },
|
|
265
707
|
],
|
|
266
708
|
loop: false,
|
|
267
709
|
}]);
|
|
268
710
|
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
await
|
|
711
|
+
if (action === 'back') break;
|
|
712
|
+
|
|
713
|
+
if (action === 'switch') {
|
|
714
|
+
await ensureTargetRepo(true);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ── Create Assignment ──────────────────────────────────────────
|
|
719
|
+
if (action === 'create') {
|
|
720
|
+
const answers = await ask([
|
|
721
|
+
{ type: 'input', name: 'title', message: yellow('Assignment title:') },
|
|
722
|
+
{ type: 'input', name: 'description', message: yellow('Description (what students should build):') },
|
|
723
|
+
{ type: 'input', name: 'deadline', message: yellow('Deadline (e.g. "March 15, 2026"):'), default: '' },
|
|
724
|
+
{ type: 'input', name: 'files', message: yellow('Expected files/folders (comma-separated):'), default: '' },
|
|
725
|
+
{ type: 'input', name: 'labels', message: yellow('Labels (space-separated):'), default: 'assignment' },
|
|
726
|
+
]);
|
|
727
|
+
|
|
728
|
+
if (!answers.title) continue;
|
|
729
|
+
|
|
730
|
+
const ora = (await import('ora')).default;
|
|
731
|
+
const spinner = ora('Creating assignment...').start();
|
|
732
|
+
try {
|
|
733
|
+
const octokit = getOctokit();
|
|
734
|
+
const owner = getOwner();
|
|
735
|
+
const repo = getRepo();
|
|
736
|
+
|
|
737
|
+
let body = `## 📝 ${answers.title}\n\n`;
|
|
738
|
+
body += `${answers.description}\n\n`;
|
|
739
|
+
if (answers.deadline) body += `**Deadline:** ${answers.deadline}\n\n`;
|
|
740
|
+
if (answers.files) body += `**Expected files:** ${answers.files}\n\n`;
|
|
741
|
+
body += `---\n\n`;
|
|
742
|
+
body += `### Submission Instructions\n\n`;
|
|
743
|
+
body += `1. Fork this repository\n`;
|
|
744
|
+
body += `2. Create a branch named \`assignment-<issue-number>\` (e.g. \`assignment-5\`)\n`;
|
|
745
|
+
body += `3. Complete the assignment\n`;
|
|
746
|
+
body += `4. Open a Pull Request with \`Fixes #<issue-number>\` in the description\n`;
|
|
747
|
+
body += `5. GitPadi will automatically grade your submission\n\n`;
|
|
748
|
+
body += `### Grading Criteria\n\n`;
|
|
749
|
+
body += `| Criteria | Points |\n|----------|--------|\n`;
|
|
750
|
+
body += `| CI Passing | 25 |\n`;
|
|
751
|
+
body += `| Assignment Relevance | 25 |\n`;
|
|
752
|
+
body += `| Test Coverage | 20 |\n`;
|
|
753
|
+
body += `| Code Quality | 15 |\n`;
|
|
754
|
+
body += `| Submission Format | 15 |\n`;
|
|
755
|
+
body += `| **Total** | **100** |\n\n`;
|
|
756
|
+
body += `**Pass threshold:** 40/100 (Grade C or above)\n`;
|
|
757
|
+
|
|
758
|
+
const labels = answers.labels.split(/\s+/).filter((l: string) => l);
|
|
759
|
+
|
|
760
|
+
const { data: issue } = await octokit.issues.create({
|
|
761
|
+
owner, repo,
|
|
762
|
+
title: `📝 ${answers.title}`,
|
|
763
|
+
body,
|
|
764
|
+
labels,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
spinner.succeed(green(`Assignment created: #${issue.number} — ${issue.html_url}`));
|
|
768
|
+
} catch (e: any) {
|
|
769
|
+
spinner.fail(e.message);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ── Grade a PR ─────────────────────────────────────────────────
|
|
774
|
+
else if (action === 'grade') {
|
|
775
|
+
const { prNum } = await ask([{ type: 'input', name: 'prNum', message: cyan('PR number to grade:') }]);
|
|
776
|
+
if (!prNum || isNaN(Number(prNum))) continue;
|
|
777
|
+
|
|
778
|
+
const ora = (await import('ora')).default;
|
|
779
|
+
const spinner = ora('Grading submission...').start();
|
|
780
|
+
try {
|
|
781
|
+
const octokit = getOctokit();
|
|
782
|
+
const owner = getOwner();
|
|
783
|
+
const repo = getRepo();
|
|
784
|
+
const prNumber = parseInt(prNum);
|
|
785
|
+
|
|
786
|
+
const { data: pr } = await octokit.pulls.get({ owner, repo, pull_number: prNumber });
|
|
787
|
+
const student = pr.user?.login || 'unknown';
|
|
788
|
+
const branchName = pr.head.ref;
|
|
789
|
+
const prBody = pr.body || '';
|
|
790
|
+
|
|
791
|
+
// Detect assignment
|
|
792
|
+
const bodyMatch = prBody.match(/(?:fixes|closes|resolves|assignment)\s*#(\d+)/i);
|
|
793
|
+
const branchMatch = branchName.match(/(?:assignment|hw|task|fix\/issue)-(\d+)/i);
|
|
794
|
+
const assignmentNum = bodyMatch ? parseInt(bodyMatch[1]) : branchMatch ? parseInt(branchMatch[1]) : null;
|
|
795
|
+
|
|
796
|
+
if (!assignmentNum) {
|
|
797
|
+
spinner.fail('No assignment issue linked. Student should use `Fixes #N` or branch `assignment-N`.');
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Fetch assignment
|
|
802
|
+
const { data: issue } = await octokit.issues.get({ owner, repo, issue_number: assignmentNum });
|
|
803
|
+
const { data: files } = await octokit.pulls.listFiles({ owner, repo, pull_number: prNumber });
|
|
804
|
+
const totalChanges = files.reduce((sum: number, f: any) => sum + f.additions + f.deletions, 0);
|
|
805
|
+
|
|
806
|
+
// CI
|
|
807
|
+
const sha = pr.head.sha;
|
|
808
|
+
const { data: ciChecks } = await octokit.checks.listForRef({ owner, repo, ref: sha });
|
|
809
|
+
const { data: statuses } = await octokit.repos.getCombinedStatusForRef({ owner, repo, ref: sha });
|
|
810
|
+
const ciPassed = statuses.state === 'success' || (ciChecks.total_count > 0 && ciChecks.check_runs.every((c: any) => c.conclusion === 'success'));
|
|
811
|
+
const ciFailed = statuses.state === 'failure' || ciChecks.check_runs.some((c: any) => c.conclusion === 'failure');
|
|
812
|
+
|
|
813
|
+
// Quick score
|
|
814
|
+
let score = 0;
|
|
815
|
+
if (ciPassed) score += 25;
|
|
816
|
+
score += 15; // base relevance
|
|
817
|
+
const testFiles = files.filter((f: any) => f.filename.includes('test') || f.filename.includes('spec'));
|
|
818
|
+
if (testFiles.length > 0) score += 20; else score += 5;
|
|
819
|
+
if (totalChanges < 500) score += 15; else score += 5;
|
|
820
|
+
const hasRef = /(?:fixes|closes|resolves)\s+#\d+/i.test(prBody);
|
|
821
|
+
if (hasRef && prBody.length > 20) score += 15; else score += 5;
|
|
822
|
+
|
|
823
|
+
const letter = score >= 80 ? 'A' : score >= 60 ? 'B' : score >= 40 ? 'C' : score >= 20 ? 'D' : 'F';
|
|
824
|
+
const emoji = score >= 80 ? '🟢' : score >= 60 ? '🔵' : score >= 40 ? '🟡' : score >= 20 ? '🟠' : '🔴';
|
|
825
|
+
const passed = score >= 40;
|
|
826
|
+
|
|
827
|
+
spinner.stop();
|
|
828
|
+
|
|
829
|
+
console.log(bold(`\n 📊 Grade for @${student} — PR #${prNumber}`));
|
|
830
|
+
console.log(` Assignment: ${issue.title} (#${assignmentNum})`);
|
|
831
|
+
console.log(` Changes: ${totalChanges} lines across ${files.length} files`);
|
|
832
|
+
console.log(` CI: ${ciPassed ? green('✅ Passed') : ciFailed ? red('❌ Failed') : yellow('⏳ Pending')}`);
|
|
833
|
+
console.log(` Tests: ${testFiles.length > 0 ? green(`${testFiles.length} file(s)`) : red('None')}`);
|
|
834
|
+
console.log(bold(`\n Score: ${score}/100 — Grade ${letter} ${emoji}\n`));
|
|
835
|
+
|
|
836
|
+
// Post grade
|
|
837
|
+
const gradeBody = `## ${emoji} GitPadi Grade — PR #${prNumber}\n\n**Student:** @${student}\n**Assignment:** ${issue.title} (#${assignmentNum})\n**Score:** ${score}/100\n**Grade:** ${letter}\n\n${passed ? '> ✅ **Passed.**' : '> ❌ **Did not pass.** Please fix and re-submit.'}\n\n---\n_Graded by [GitPadi](https://github.com/Netwalls/contributor-agent) 📝_\n\n<!-- gitpadi-grade -->`;
|
|
838
|
+
await octokit.issues.createComment({ owner, repo, issue_number: prNumber, body: gradeBody });
|
|
839
|
+
console.log(green(' ✅ Grade posted to PR.'));
|
|
840
|
+
|
|
841
|
+
if (passed && ciPassed) {
|
|
842
|
+
const { merge } = await inquirer.prompt([{
|
|
843
|
+
type: 'list', name: 'merge', message: green('Merge this PR?'),
|
|
844
|
+
choices: [
|
|
845
|
+
{ name: `${green('✅')} Yes, squash & merge`, value: 'yes' },
|
|
846
|
+
{ name: `${dim('⬅')} No`, value: 'no' },
|
|
847
|
+
]
|
|
848
|
+
}]);
|
|
849
|
+
if (merge === 'yes') {
|
|
850
|
+
const mergeSpinner = ora('Merging...').start();
|
|
851
|
+
await octokit.pulls.merge({
|
|
852
|
+
owner, repo, pull_number: prNumber,
|
|
853
|
+
merge_method: 'squash',
|
|
854
|
+
commit_title: `[Grade ${letter}] ${pr.title} (#${prNumber})`,
|
|
855
|
+
});
|
|
856
|
+
mergeSpinner.succeed(green(`PR #${prNumber} merged!`));
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
} catch (e: any) {
|
|
860
|
+
spinner.fail(e.message);
|
|
861
|
+
}
|
|
273
862
|
}
|
|
274
|
-
|
|
863
|
+
|
|
864
|
+
// ── Cohort Leaderboard ─────────────────────────────────────────
|
|
865
|
+
else if (action === 'leaderboard') {
|
|
866
|
+
const ora = (await import('ora')).default;
|
|
867
|
+
const Table = (await import('cli-table3')).default;
|
|
868
|
+
const spinner = ora('Building cohort leaderboard...').start();
|
|
869
|
+
|
|
870
|
+
try {
|
|
871
|
+
const octokit = getOctokit();
|
|
872
|
+
const owner = getOwner();
|
|
873
|
+
const repo = getRepo();
|
|
874
|
+
|
|
875
|
+
// Find all PRs with grade comments
|
|
876
|
+
const { data: allPRs } = await octokit.pulls.list({ owner, repo, state: 'all', per_page: 100 });
|
|
877
|
+
|
|
878
|
+
const studentScores: Record<string, { scores: number[]; grades: string[]; prs: number[] }> = {};
|
|
879
|
+
|
|
880
|
+
for (const pr of allPRs) {
|
|
881
|
+
const { data: comments } = await octokit.issues.listComments({ owner, repo, issue_number: pr.number });
|
|
882
|
+
const gradeComment = comments.find(c => c.body?.includes('<!-- gitpadi-grade -->'));
|
|
883
|
+
|
|
884
|
+
if (gradeComment && gradeComment.body) {
|
|
885
|
+
const student = pr.user?.login || 'unknown';
|
|
886
|
+
const scoreMatch = gradeComment.body.match(/\*\*Score:\*\*\s*(\d+)/);
|
|
887
|
+
const gradeMatch = gradeComment.body.match(/\*\*Grade:\*\*\s*(\w)/);
|
|
888
|
+
|
|
889
|
+
if (scoreMatch) {
|
|
890
|
+
if (!studentScores[student]) studentScores[student] = { scores: [], grades: [], prs: [] };
|
|
891
|
+
studentScores[student].scores.push(parseInt(scoreMatch[1]));
|
|
892
|
+
studentScores[student].grades.push(gradeMatch ? gradeMatch[1] : '?');
|
|
893
|
+
studentScores[student].prs.push(pr.number);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
spinner.stop();
|
|
899
|
+
|
|
900
|
+
const students = Object.entries(studentScores)
|
|
901
|
+
.map(([name, data]) => ({
|
|
902
|
+
name,
|
|
903
|
+
avg: Math.round(data.scores.reduce((a, b) => a + b, 0) / data.scores.length),
|
|
904
|
+
total: data.scores.reduce((a, b) => a + b, 0),
|
|
905
|
+
assignments: data.scores.length,
|
|
906
|
+
grades: data.grades.join(', '),
|
|
907
|
+
}))
|
|
908
|
+
.sort((a, b) => b.avg - a.avg);
|
|
909
|
+
|
|
910
|
+
if (students.length === 0) {
|
|
911
|
+
console.log(yellow('\n No graded submissions found yet.\n'));
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const table = new Table({
|
|
916
|
+
head: ['Rank', 'Student', 'Avg Score', 'Assignments', 'Grades', 'Total Points'].map(h => cyan(h)),
|
|
917
|
+
style: { head: [], border: [] },
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
students.forEach((s, i) => {
|
|
921
|
+
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
|
|
922
|
+
const avgColor = s.avg >= 80 ? green : s.avg >= 60 ? cyan : s.avg >= 40 ? yellow : red;
|
|
923
|
+
table.push([
|
|
924
|
+
medal,
|
|
925
|
+
bold(`@${s.name}`),
|
|
926
|
+
avgColor(`${s.avg}/100`),
|
|
927
|
+
`${s.assignments}`,
|
|
928
|
+
s.grades,
|
|
929
|
+
`${s.total}`,
|
|
930
|
+
]);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
console.log(bold(`\n 🏆 COHORT LEADERBOARD — ${owner}/${repo}\n`));
|
|
934
|
+
console.log(table.toString());
|
|
935
|
+
console.log(green(`\n 👑 Top student: @${students[0].name} (${students[0].avg}/100 avg)\n`));
|
|
936
|
+
|
|
937
|
+
// Offer to post leaderboard as an issue comment
|
|
938
|
+
const { post } = await inquirer.prompt([{
|
|
939
|
+
type: 'list', name: 'post',
|
|
940
|
+
message: 'Post leaderboard to a pinned issue?',
|
|
941
|
+
choices: [
|
|
942
|
+
{ name: `${green('✅')} Yes`, value: 'yes' },
|
|
943
|
+
{ name: `${dim('⬅')} No`, value: 'no' },
|
|
944
|
+
]
|
|
945
|
+
}]);
|
|
946
|
+
|
|
947
|
+
if (post === 'yes') {
|
|
948
|
+
let leaderBody = `## 🏆 Cohort Leaderboard\n\n`;
|
|
949
|
+
leaderBody += `_Last updated: ${new Date().toLocaleDateString()}_\n\n`;
|
|
950
|
+
leaderBody += `| Rank | Student | Avg Score | Assignments | Grades |\n|------|---------|-----------|-------------|--------|\n`;
|
|
951
|
+
students.forEach((s, i) => {
|
|
952
|
+
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}`;
|
|
953
|
+
leaderBody += `| ${medal} | @${s.name} | ${s.avg}/100 | ${s.assignments} | ${s.grades} |\n`;
|
|
954
|
+
});
|
|
955
|
+
leaderBody += `\n👑 **Top student:** @${students[0].name}\n\n<!-- gitpadi-leaderboard -->`;
|
|
956
|
+
|
|
957
|
+
// Find or create leaderboard issue
|
|
958
|
+
const { data: issues } = await octokit.issues.listForRepo({ owner, repo, state: 'open', labels: 'leaderboard', per_page: 5 });
|
|
959
|
+
const existing = issues[0];
|
|
960
|
+
|
|
961
|
+
if (existing) {
|
|
962
|
+
await octokit.issues.update({ owner, repo, issue_number: existing.number, body: leaderBody });
|
|
963
|
+
console.log(green(` ✅ Leaderboard updated: ${existing.html_url}`));
|
|
964
|
+
} else {
|
|
965
|
+
const { data: newIssue } = await octokit.issues.create({
|
|
966
|
+
owner, repo, title: '🏆 Cohort Leaderboard', body: leaderBody, labels: ['leaderboard'],
|
|
967
|
+
});
|
|
968
|
+
// Pin it
|
|
969
|
+
try { await octokit.issues.update({ owner, repo, issue_number: newIssue.number }); } catch { }
|
|
970
|
+
console.log(green(` ✅ Leaderboard created: ${newIssue.html_url}`));
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
} catch (e: any) {
|
|
974
|
+
spinner.fail(e.message);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
console.log('');
|
|
275
979
|
}
|
|
276
980
|
}
|
|
277
981
|
|
|
@@ -283,29 +987,50 @@ async function contributorMenu() {
|
|
|
283
987
|
console.log(dim(' Automating forking, syncing, and PR delivery'));
|
|
284
988
|
line('═');
|
|
285
989
|
|
|
286
|
-
// Auto-check for updates if in a repo
|
|
287
|
-
if (getOwner() && getRepo()) {
|
|
288
|
-
await contribute.syncBranch();
|
|
289
|
-
}
|
|
290
|
-
|
|
291
990
|
const { action } = await inquirer.prompt([{
|
|
292
991
|
type: 'list',
|
|
293
992
|
name: 'action',
|
|
294
993
|
message: bold('Contributor Action:'),
|
|
295
994
|
choices: [
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
{ name: `${
|
|
299
|
-
{ name: `${
|
|
300
|
-
|
|
995
|
+
// ── Bounty Discovery ──────────────────────────────────
|
|
996
|
+
new inquirer.Separator(dim(' ── Bounty Discovery ─────────────────────')),
|
|
997
|
+
{ name: `${green('🎯')} ${bold('Bounty Hunter')} ${dim('— auto-apply Drips Wave & GrantFox')}`, value: 'hunt' },
|
|
998
|
+
{ name: `${yellow('🙋')} ${bold('Apply for Issue')} ${dim('— browse a repo & request to work')}`, value: 'apply' },
|
|
999
|
+
{ name: `${cyan('🔍')} ${bold('My Applications')} ${dim('— see issues you applied for')}`, value: 'my-apps' },
|
|
1000
|
+
// ── Contribution Flow ─────────────────────────────────
|
|
1001
|
+
new inquirer.Separator(dim(' ── Contribution Flow ────────────────────')),
|
|
1002
|
+
{ name: `${cyan('🚀')} ${bold('Start Contribution')} ${dim('— fork, clone & branch from URL')}`, value: 'start' },
|
|
1003
|
+
{ name: `${green('🔄')} ${bold('Sync with Upstream')} ${dim('— pull latest from original repo')}`, value: 'sync' },
|
|
1004
|
+
{ name: `${magenta('🚀')} ${bold('Submit PR')} ${dim('— stage, commit, push & open PR')}`, value: 'submit' },
|
|
1005
|
+
{ name: `${red('🔧')} ${bold('Fix & Re-push')} ${dim('— fix CI failures & force-push')}`, value: 'fix' },
|
|
1006
|
+
{ name: `${cyan('💬')} ${bold('Reply to Comments')} ${dim('— respond to reviewer feedback')}`, value: 'reply' },
|
|
1007
|
+
// ── Visibility ────────────────────────────────────────
|
|
1008
|
+
new inquirer.Separator(dim(' ── Visibility ───────────────────────────')),
|
|
1009
|
+
{ name: `${yellow('📋')} ${bold('View Action Logs')} ${dim('— check CI status on latest commit')}`, value: 'logs' },
|
|
1010
|
+
{ name: `${green('📝')} ${bold('My Open PRs')} ${dim('— list your open PRs in this repo')}`, value: 'my-prs' },
|
|
1011
|
+
{ name: `${magenta('📊')} ${bold('My Score')} ${dim('— see your contributor tier & breakdown')}`, value: 'my-score' },
|
|
1012
|
+
// ── Back ──────────────────────────────────────────────
|
|
1013
|
+
new inquirer.Separator(dim(' ─────────────────────────────────────────')),
|
|
301
1014
|
{ name: `${dim('⬅')} ${dim('Back to Mode Selector')}`, value: 'back' },
|
|
302
1015
|
],
|
|
303
1016
|
loop: false,
|
|
1017
|
+
pageSize: 18,
|
|
304
1018
|
}]);
|
|
305
1019
|
|
|
306
1020
|
if (action === 'back') break;
|
|
307
1021
|
|
|
308
|
-
if (action === '
|
|
1022
|
+
if (action === 'hunt') {
|
|
1023
|
+
await runBountyHunter({ platform: 'all', maxApplications: 3, dryRun: false });
|
|
1024
|
+
} else if (action === 'apply') {
|
|
1025
|
+
await applyForIssue.browseAndApply();
|
|
1026
|
+
} else if (action === 'my-apps') {
|
|
1027
|
+
await myApplicationsMenu();
|
|
1028
|
+
} else if (action === 'my-prs') {
|
|
1029
|
+
await ensureTargetRepo();
|
|
1030
|
+
await myOpenPRsMenu();
|
|
1031
|
+
} else if (action === 'my-score') {
|
|
1032
|
+
await myScoreMenu();
|
|
1033
|
+
} else if (action === 'start') {
|
|
309
1034
|
const { url } = await ask([{ type: 'input', name: 'url', message: 'Enter Repo or Issue URL:' }]);
|
|
310
1035
|
await contribute.forkAndClone(url);
|
|
311
1036
|
} else {
|
|
@@ -316,8 +1041,12 @@ async function contributorMenu() {
|
|
|
316
1041
|
await contribute.syncBranch();
|
|
317
1042
|
} else if (action === 'logs') {
|
|
318
1043
|
await contribute.viewLogs();
|
|
1044
|
+
} else if (action === 'fix') {
|
|
1045
|
+
await contribute.fixAndRepush();
|
|
1046
|
+
} else if (action === 'reply') {
|
|
1047
|
+
await contribute.replyToComments();
|
|
319
1048
|
} else if (action === 'submit') {
|
|
320
|
-
const branch =
|
|
1049
|
+
const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf-8' }).trim();
|
|
321
1050
|
const match = branch.match(/issue-(\d+)/);
|
|
322
1051
|
const detectedIssue = match ? parseInt(match[1]) : undefined;
|
|
323
1052
|
|
|
@@ -380,16 +1109,19 @@ async function maintainerMenu() {
|
|
|
380
1109
|
name: 'category',
|
|
381
1110
|
message: bold('Select operation:'),
|
|
382
1111
|
choices: [
|
|
383
|
-
{ name: `${magenta('📋')} ${bold('Issues')}
|
|
384
|
-
{ name: `${cyan('🔀')} ${bold('Pull Requests')}
|
|
385
|
-
{ name: `${green('
|
|
386
|
-
{ name: `${yellow('🏆')} ${bold('Contributors')}
|
|
387
|
-
{ name: `${
|
|
388
|
-
|
|
1112
|
+
{ name: `${magenta('📋')} ${bold('Issues')} ${dim('— create, close, assign, bulk-create')}`, value: 'issues' },
|
|
1113
|
+
{ name: `${cyan('🔀')} ${bold('Pull Requests')} ${dim('— list, merge, review, approve, diff')}`, value: 'prs' },
|
|
1114
|
+
{ name: `${green('✅')} ${bold('Review & Merge')} ${dim('— review a PR, check CI, squash merge')}`, value: 'review-merge' },
|
|
1115
|
+
{ name: `${yellow('🏆')} ${bold('Contributors')} ${dim('— score applicants, rank, auto-assign')}`, value: 'contributors' },
|
|
1116
|
+
{ name: `${green('📦')} ${bold('Repositories')} ${dim('— create, delete, clone, topics, info')}`, value: 'repos' },
|
|
1117
|
+
{ name: `${red('🚀')} ${bold('Releases')} ${dim('— create, list, tag releases')}`, value: 'releases' },
|
|
1118
|
+
{ name: `${green('🎯')} ${bold('Bounty Hunter')} ${dim('— auto-apply to Drips Wave & GrantFox')}`, value: 'hunt' },
|
|
1119
|
+
new inquirer.Separator(dim(' ─────────────────────────────────────────')),
|
|
389
1120
|
{ name: `${dim('⚙️')} ${dim('Switch Repo')}`, value: 'switch' },
|
|
390
1121
|
{ name: `${dim('⬅')} ${dim('Back to Mode Selector')}`, value: 'back' },
|
|
391
1122
|
],
|
|
392
1123
|
loop: false,
|
|
1124
|
+
pageSize: 12,
|
|
393
1125
|
}]);
|
|
394
1126
|
|
|
395
1127
|
if (category === 'back') break;
|
|
@@ -404,11 +1136,342 @@ async function maintainerMenu() {
|
|
|
404
1136
|
else if (category === 'repos') await safeMenu(repoMenu);
|
|
405
1137
|
else if (category === 'contributors') await safeMenu(contributorScoringMenu);
|
|
406
1138
|
else if (category === 'releases') await safeMenu(releaseMenu);
|
|
1139
|
+
else if (category === 'hunt') await runBountyHunter({ platform: 'all', maxApplications: 3, dryRun: false });
|
|
1140
|
+
else if (category === 'review-merge') {
|
|
1141
|
+
await ensureTargetRepo();
|
|
1142
|
+
const { prNum } = await ask([{ type: 'input', name: 'prNum', message: cyan('PR number to review:') }]);
|
|
1143
|
+
if (!prNum || isNaN(Number(prNum))) continue;
|
|
1144
|
+
|
|
1145
|
+
const ora = (await import('ora')).default;
|
|
1146
|
+
const spinner = ora('Reviewing PR...').start();
|
|
1147
|
+
try {
|
|
1148
|
+
const octokit = getOctokit();
|
|
1149
|
+
const owner = getOwner();
|
|
1150
|
+
const repo = getRepo();
|
|
1151
|
+
const prNumber = parseInt(prNum);
|
|
1152
|
+
|
|
1153
|
+
const { data: pr } = await octokit.pulls.get({ owner, repo, pull_number: prNumber });
|
|
1154
|
+
const { data: files } = await octokit.pulls.listFiles({ owner, repo, pull_number: prNumber });
|
|
1155
|
+
const totalChanges = files.reduce((sum: number, f: any) => sum + f.additions + f.deletions, 0);
|
|
1156
|
+
|
|
1157
|
+
// CI Check
|
|
1158
|
+
const sha = pr.head.sha;
|
|
1159
|
+
const { data: ciChecks } = await octokit.checks.listForRef({ owner, repo, ref: sha });
|
|
1160
|
+
const { data: statuses } = await octokit.repos.getCombinedStatusForRef({ owner, repo, ref: sha });
|
|
1161
|
+
|
|
1162
|
+
const ciPassed = statuses.state === 'success' || (ciChecks.total_count > 0 && ciChecks.check_runs.every((c: any) => c.conclusion === 'success'));
|
|
1163
|
+
const ciFailed = statuses.state === 'failure' || ciChecks.check_runs.some((c: any) => c.conclusion === 'failure');
|
|
1164
|
+
|
|
1165
|
+
spinner.stop();
|
|
1166
|
+
|
|
1167
|
+
console.log(bold(`\n 📋 PR #${prNumber}: "${pr.title}"\n`));
|
|
1168
|
+
console.log(` ${dim('Author:')} @${pr.user?.login}`);
|
|
1169
|
+
console.log(` ${dim('Changes:')} ${totalChanges} lines across ${files.length} files`);
|
|
1170
|
+
console.log(` ${dim('CI:')} ${ciPassed ? green('✅ Passed') : ciFailed ? red('❌ Failed') : yellow('⏳ Pending')}\n`);
|
|
1171
|
+
|
|
1172
|
+
if (ciFailed) {
|
|
1173
|
+
// Notify contributor
|
|
1174
|
+
const failMsg = `❌ **CI Failed.** Please fix the failing checks and re-push.\n\nUse \`npx gitpadi\` → \`Fix & Re-push\` for a guided workflow.\n\n---\n_Review by [GitPadi](https://github.com/Netwalls/contributor-agent) 🤖_`;
|
|
1175
|
+
await octokit.issues.createComment({ owner, repo, issue_number: prNumber, body: failMsg });
|
|
1176
|
+
console.log(red(' ❌ CI failed. Contributor notified.'));
|
|
1177
|
+
} else if (ciPassed) {
|
|
1178
|
+
const { mergeAction } = await inquirer.prompt([{
|
|
1179
|
+
type: 'list', name: 'mergeAction', message: green('CI passed! Merge this PR?'),
|
|
1180
|
+
choices: [
|
|
1181
|
+
{ name: `${green('✅')} Squash & Merge`, value: 'squash' },
|
|
1182
|
+
{ name: `${cyan('🔀')} Merge Commit`, value: 'merge' },
|
|
1183
|
+
{ name: `${dim('⬅')} Skip`, value: 'skip' },
|
|
1184
|
+
]
|
|
1185
|
+
}]);
|
|
1186
|
+
|
|
1187
|
+
if (mergeAction !== 'skip') {
|
|
1188
|
+
const mergeSpinner = ora('Merging...').start();
|
|
1189
|
+
await octokit.pulls.merge({
|
|
1190
|
+
owner, repo, pull_number: prNumber,
|
|
1191
|
+
merge_method: mergeAction as 'squash' | 'merge',
|
|
1192
|
+
commit_title: `${pr.title} (#${prNumber})`,
|
|
1193
|
+
});
|
|
1194
|
+
mergeSpinner.succeed(green(`PR #${prNumber} merged successfully! 🎉`));
|
|
1195
|
+
}
|
|
1196
|
+
} else {
|
|
1197
|
+
console.log(yellow(' ⏳ CI is still running. Try again later.'));
|
|
1198
|
+
}
|
|
1199
|
+
} catch (e: any) {
|
|
1200
|
+
spinner.fail(e.message);
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
407
1203
|
|
|
408
1204
|
console.log('');
|
|
409
1205
|
}
|
|
410
1206
|
}
|
|
411
1207
|
|
|
1208
|
+
// ── CI Actions Menu ────────────────────────────────────────────────────
|
|
1209
|
+
// Triggers GitHub Actions workflow_dispatch events from the terminal.
|
|
1210
|
+
// This makes npx gitpadi the primary interface for all CI operations.
|
|
1211
|
+
async function ciActionsMenu() {
|
|
1212
|
+
const WORKFLOW_FILE = 'gitpadi.yml';
|
|
1213
|
+
const owner = getOwner();
|
|
1214
|
+
const repo = getRepo();
|
|
1215
|
+
const octokit = getOctokit();
|
|
1216
|
+
|
|
1217
|
+
// Detect default branch
|
|
1218
|
+
let ref = 'main';
|
|
1219
|
+
try {
|
|
1220
|
+
const { data: repoData } = await octokit.repos.get({ owner, repo });
|
|
1221
|
+
ref = repoData.default_branch || 'main';
|
|
1222
|
+
} catch { /* use main */ }
|
|
1223
|
+
|
|
1224
|
+
while (true) {
|
|
1225
|
+
line('─');
|
|
1226
|
+
console.log(green(` 🤖 CI ACTIONS — ${owner}/${repo}`));
|
|
1227
|
+
console.log(dim(` Triggers .github/workflows/${WORKFLOW_FILE} via GitHub Actions API`));
|
|
1228
|
+
line('─');
|
|
1229
|
+
|
|
1230
|
+
const { action } = await inquirer.prompt([{
|
|
1231
|
+
type: 'list',
|
|
1232
|
+
name: 'action',
|
|
1233
|
+
message: bold('Select CI action to trigger:'),
|
|
1234
|
+
choices: [
|
|
1235
|
+
{ name: `${green('✅')} ${bold('Review & Merge PR')} ${dim('— review + auto-merge a specific PR')}`, value: 'review-and-merge' },
|
|
1236
|
+
{ name: `${magenta('📊')} ${bold('Grade Assignment PR')} ${dim('— grade a student submission PR')}`, value: 'grade-assignment' },
|
|
1237
|
+
{ name: `${yellow('🏆')} ${bold('Score Applicants')} ${dim('— score contributors on an issue')}`, value: 'score-applicant' },
|
|
1238
|
+
{ name: `${cyan('🔔')} ${bold('Remind Contributors')} ${dim('— send escalating reminders now')}`, value: 'remind-contributors' },
|
|
1239
|
+
{ name: `${green('📋')} ${bold('Create Issues from File')} ${dim('— bulk-create from JSON/MD file')}`, value: 'create-issues' },
|
|
1240
|
+
new inquirer.Separator(dim(' ─────────────────────────────────────────')),
|
|
1241
|
+
{ name: `${dim('⬅')} ${dim('Back')}`, value: 'back' },
|
|
1242
|
+
],
|
|
1243
|
+
loop: false,
|
|
1244
|
+
}]);
|
|
1245
|
+
|
|
1246
|
+
if (action === 'back') break;
|
|
1247
|
+
|
|
1248
|
+
let inputs: Record<string, string> = { action };
|
|
1249
|
+
|
|
1250
|
+
if (action === 'review-and-merge') {
|
|
1251
|
+
const { prNum } = await ask([{ type: 'input', name: 'prNum', message: cyan('PR number:'), validate: (v: string) => /^\d+$/.test(v) || 'Enter a number' }]);
|
|
1252
|
+
inputs.pr_number = prNum;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (action === 'create-issues') {
|
|
1256
|
+
const { file } = await ask([{ type: 'input', name: 'file', message: cyan('Issues file path:'), default: 'issues.json' }]);
|
|
1257
|
+
inputs.issues_file = file;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
await triggerWorkflow(action, inputs, ref, WORKFLOW_FILE);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
async function triggerWorkflow(action: string, extraInputs: Record<string, string> = {}, ref?: string, workflowFile = 'gitpadi.yml') {
|
|
1265
|
+
const owner = getOwner();
|
|
1266
|
+
const repo = getRepo();
|
|
1267
|
+
const octokit = getOctokit();
|
|
1268
|
+
const ora = (await import('ora')).default;
|
|
1269
|
+
|
|
1270
|
+
if (!ref) {
|
|
1271
|
+
try {
|
|
1272
|
+
const { data } = await octokit.repos.get({ owner, repo });
|
|
1273
|
+
ref = data.default_branch || 'main';
|
|
1274
|
+
} catch { ref = 'main'; }
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const spinner = ora(` Triggering ${bold(action)} on ${cyan(`${owner}/${repo}`)}…`).start();
|
|
1278
|
+
try {
|
|
1279
|
+
await octokit.actions.createWorkflowDispatch({
|
|
1280
|
+
owner, repo,
|
|
1281
|
+
workflow_id: workflowFile,
|
|
1282
|
+
ref,
|
|
1283
|
+
inputs: { action, ...extraInputs },
|
|
1284
|
+
});
|
|
1285
|
+
spinner.succeed(` ✅ ${bold(action)} triggered on branch ${cyan(ref)}`);
|
|
1286
|
+
console.log(dim(` Monitor at: https://github.com/${owner}/${repo}/actions\n`));
|
|
1287
|
+
} catch (e: any) {
|
|
1288
|
+
spinner.fail(` Failed to trigger workflow: ${e.message}`);
|
|
1289
|
+
console.log(dim(` Make sure ${workflowFile} exists in .github/workflows/ and has workflow_dispatch enabled.\n`));
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// ── My Score Menu ──────────────────────────────────────────────────────
|
|
1294
|
+
async function myScoreMenu() {
|
|
1295
|
+
const ora = (await import('ora')).default;
|
|
1296
|
+
const { fetchProfile, scoreApplicant, TIER_EMOJI } = await import('./core/scorer.js');
|
|
1297
|
+
|
|
1298
|
+
const spinner = ora(' Fetching your GitHub profile…').start();
|
|
1299
|
+
try {
|
|
1300
|
+
const me = await getAuthenticatedUser();
|
|
1301
|
+
const profile = await fetchProfile(me, '');
|
|
1302
|
+
const scored = scoreApplicant(profile, []);
|
|
1303
|
+
spinner.succeed(` @${me} ${TIER_EMOJI[scored.tier]} Tier ${scored.tier} ${scored.score}/100`);
|
|
1304
|
+
|
|
1305
|
+
console.log();
|
|
1306
|
+
console.log(bold(' 📊 Score Breakdown'));
|
|
1307
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
1308
|
+
console.log(` Account Maturity ${magenta(`${scored.breakdown.accountMaturity}/15`)}`);
|
|
1309
|
+
console.log(` Repo Experience ${cyan(`${scored.breakdown.repoExperience}/30`)}`);
|
|
1310
|
+
console.log(` GitHub Presence ${green(`${scored.breakdown.githubPresence}/15`)}`);
|
|
1311
|
+
console.log(` Activity Level ${yellow(`${scored.breakdown.activityLevel}/15`)}`);
|
|
1312
|
+
console.log(` Language Relevance ${magenta(`${scored.breakdown.languageRelevance}/10`)}`);
|
|
1313
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
1314
|
+
console.log(` ${bold('Total')} ${bold(`${scored.score}/100`)} ${TIER_EMOJI[scored.tier]} ${bold(`Tier ${scored.tier}`)}`);
|
|
1315
|
+
console.log();
|
|
1316
|
+
|
|
1317
|
+
const tierMsg: Record<string, string> = {
|
|
1318
|
+
S: green('Exceptional — you qualify for auto-assign on Drips/GrantFox issues.'),
|
|
1319
|
+
A: green('Strong — most maintainers will assign you on application.'),
|
|
1320
|
+
B: yellow('Decent — you\'ll get considered. Keep contributing to improve.'),
|
|
1321
|
+
C: yellow('Building up — contribute more to boost your score.'),
|
|
1322
|
+
D: red('Early stage — focus on getting PRs merged to level up.'),
|
|
1323
|
+
};
|
|
1324
|
+
console.log(` ${tierMsg[scored.tier]}`);
|
|
1325
|
+
console.log();
|
|
1326
|
+
} catch (e: any) {
|
|
1327
|
+
spinner.fail(` Could not load profile: ${e.message}`);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// ── My Applications Menu ───────────────────────────────────────────────
|
|
1332
|
+
async function myApplicationsMenu() {
|
|
1333
|
+
const ora = (await import('ora')).default;
|
|
1334
|
+
const { APPLICATION_PATTERNS } = await import('./core/scorer.js');
|
|
1335
|
+
const octokit = getOctokit();
|
|
1336
|
+
|
|
1337
|
+
const { target } = await inquirer.prompt([{
|
|
1338
|
+
type: 'input',
|
|
1339
|
+
name: 'target',
|
|
1340
|
+
message: bold('Repo to check (owner/repo or leave blank for current):'),
|
|
1341
|
+
default: getOwner() && getRepo() ? `${getOwner()}/${getRepo()}` : '',
|
|
1342
|
+
}]);
|
|
1343
|
+
|
|
1344
|
+
const [owner, repo] = (target || `${getOwner()}/${getRepo()}`).split('/');
|
|
1345
|
+
if (!owner || !repo) { console.log(yellow(' No repo selected.')); return; }
|
|
1346
|
+
|
|
1347
|
+
const spinner = ora(` Fetching your applications in ${cyan(`${owner}/${repo}`)}…`).start();
|
|
1348
|
+
const me = await getAuthenticatedUser();
|
|
1349
|
+
|
|
1350
|
+
try {
|
|
1351
|
+
// Search issues where you commented with an application-style comment
|
|
1352
|
+
const { data: comments } = await octokit.search.issuesAndPullRequests({
|
|
1353
|
+
q: `commenter:${me} type:issue state:open repo:${owner}/${repo}`,
|
|
1354
|
+
per_page: 50, sort: 'updated',
|
|
1355
|
+
});
|
|
1356
|
+
|
|
1357
|
+
const applied: Array<{ number: number; title: string; url: string; assignees: string[]; status: string }> = [];
|
|
1358
|
+
|
|
1359
|
+
for (const item of comments.items) {
|
|
1360
|
+
const { data: issueComments } = await octokit.issues.listComments({
|
|
1361
|
+
owner, repo, issue_number: item.number, per_page: 100,
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
const myComments = issueComments.filter((c: any) =>
|
|
1365
|
+
c.user?.login === me &&
|
|
1366
|
+
APPLICATION_PATTERNS.some(p => p.test(c.body || ''))
|
|
1367
|
+
);
|
|
1368
|
+
|
|
1369
|
+
if (myComments.length > 0) {
|
|
1370
|
+
const assignees: string[] = (item as any).assignees?.map((a: any) => a.login) || [];
|
|
1371
|
+
const isAssignedToMe = assignees.includes(me);
|
|
1372
|
+
applied.push({
|
|
1373
|
+
number: item.number,
|
|
1374
|
+
title: item.title,
|
|
1375
|
+
url: item.html_url,
|
|
1376
|
+
assignees,
|
|
1377
|
+
status: isAssignedToMe ? green('✅ Assigned to you') : assignees.length > 0 ? yellow(`⚠ Assigned to @${assignees[0]}`) : cyan('⏳ Pending'),
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
spinner.succeed(` ${applied.length} application(s) found in ${owner}/${repo}`);
|
|
1383
|
+
console.log();
|
|
1384
|
+
|
|
1385
|
+
if (applied.length === 0) {
|
|
1386
|
+
console.log(dim(' You haven\'t applied for any open issues in this repo yet.'));
|
|
1387
|
+
console.log(dim(' Use "Bounty Hunter" or "Apply for Issue" to get started.'));
|
|
1388
|
+
} else {
|
|
1389
|
+
applied.forEach(a => {
|
|
1390
|
+
console.log(` ${cyan(`#${a.number}`)} ${bold(a.title)}`);
|
|
1391
|
+
console.log(` ${a.status} ${dim(a.url)}`);
|
|
1392
|
+
console.log();
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
} catch (e: any) {
|
|
1396
|
+
spinner.fail(e.message);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// ── My Open PRs Menu ───────────────────────────────────────────────────
|
|
1401
|
+
async function myOpenPRsMenu() {
|
|
1402
|
+
const ora = (await import('ora')).default;
|
|
1403
|
+
const octokit = getOctokit();
|
|
1404
|
+
const owner = getOwner();
|
|
1405
|
+
const repo = getRepo();
|
|
1406
|
+
const me = await getAuthenticatedUser();
|
|
1407
|
+
|
|
1408
|
+
const spinner = ora(` Fetching your open PRs in ${cyan(`${owner}/${repo}`)}…`).start();
|
|
1409
|
+
try {
|
|
1410
|
+
const { data: prs } = await octokit.pulls.list({ owner, repo, state: 'open', per_page: 50 });
|
|
1411
|
+
const mine = prs.filter((p: any) => p.user?.login === me);
|
|
1412
|
+
spinner.succeed(` ${mine.length} open PR(s) as @${me}`);
|
|
1413
|
+
console.log();
|
|
1414
|
+
|
|
1415
|
+
if (mine.length === 0) {
|
|
1416
|
+
console.log(dim(' No open PRs. Use "Submit PR" to open one.'));
|
|
1417
|
+
} else {
|
|
1418
|
+
mine.forEach((p: any) => {
|
|
1419
|
+
const age = Math.floor((Date.now() - new Date(p.updated_at).getTime()) / 86_400_000);
|
|
1420
|
+
const draft = p.draft ? yellow(' [draft]') : '';
|
|
1421
|
+
console.log(` ${cyan(`#${p.number}`)}${draft} ${bold(p.title)}`);
|
|
1422
|
+
console.log(` ${dim(`branch: ${p.head.ref}`)} ${dim(`updated ${age}d ago`)} ${dim(p.html_url)}`);
|
|
1423
|
+
console.log();
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
} catch (e: any) {
|
|
1427
|
+
spinner.fail(e.message);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// ── Repo Stats Menu ────────────────────────────────────────────────────
|
|
1432
|
+
async function repoStatsMenu() {
|
|
1433
|
+
const ora = (await import('ora')).default;
|
|
1434
|
+
const octokit = getOctokit();
|
|
1435
|
+
const owner = getOwner();
|
|
1436
|
+
const repo = getRepo();
|
|
1437
|
+
|
|
1438
|
+
const spinner = ora(` Loading stats for ${cyan(`${owner}/${repo}`)}…`).start();
|
|
1439
|
+
try {
|
|
1440
|
+
const [repoData, openIssues, openPRs, closedPRs, contributors] = await Promise.all([
|
|
1441
|
+
octokit.repos.get({ owner, repo }),
|
|
1442
|
+
octokit.issues.listForRepo({ owner, repo, state: 'open', per_page: 1 }),
|
|
1443
|
+
octokit.pulls.list({ owner, repo, state: 'open', per_page: 1 }),
|
|
1444
|
+
octokit.pulls.list({ owner, repo, state: 'closed', per_page: 50 }),
|
|
1445
|
+
octokit.repos.listContributors({ owner, repo, per_page: 10 }),
|
|
1446
|
+
]);
|
|
1447
|
+
|
|
1448
|
+
const merged = closedPRs.data.filter((p: any) => p.merged_at).length;
|
|
1449
|
+
const mergeRate = closedPRs.data.length > 0
|
|
1450
|
+
? Math.round((merged / closedPRs.data.length) * 100) : 0;
|
|
1451
|
+
|
|
1452
|
+
spinner.succeed();
|
|
1453
|
+
console.log();
|
|
1454
|
+
console.log(bold(` 📊 ${owner}/${repo}`));
|
|
1455
|
+
console.log(` ${'─'.repeat(42)}`);
|
|
1456
|
+
console.log(` ⭐ Stars ${yellow(String(repoData.data.stargazers_count))}`);
|
|
1457
|
+
console.log(` 🍴 Forks ${cyan(String(repoData.data.forks_count))}`);
|
|
1458
|
+
console.log(` 📋 Open Issues ${magenta(String(repoData.data.open_issues_count))}`);
|
|
1459
|
+
console.log(` 🔀 Open PRs ${cyan(String(openPRs.data.length ? '1+' : '0'))}`);
|
|
1460
|
+
console.log(` ✅ Merge Rate ${mergeRate >= 70 ? green(`${mergeRate}%`) : yellow(`${mergeRate}%`)} (last 50 closed PRs)`);
|
|
1461
|
+
console.log(` 🌿 Default Branch ${dim(repoData.data.default_branch)}`);
|
|
1462
|
+
console.log(` 📝 Language ${dim(repoData.data.language || 'mixed')}`);
|
|
1463
|
+
console.log();
|
|
1464
|
+
console.log(` Top Contributors:`);
|
|
1465
|
+
contributors.data.slice(0, 5).forEach((c: any, i: number) => {
|
|
1466
|
+
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : ' ';
|
|
1467
|
+
console.log(` ${medal} @${c.login} ${dim(`${c.contributions} commits`)}`);
|
|
1468
|
+
});
|
|
1469
|
+
console.log();
|
|
1470
|
+
} catch (e: any) {
|
|
1471
|
+
spinner.fail(e.message);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
|
|
412
1475
|
// ── Issue Menu ─────────────────────────────────────────────────────────
|
|
413
1476
|
async function issueMenu() {
|
|
414
1477
|
const { action } = await inquirer.prompt([{
|
|
@@ -846,6 +1909,27 @@ function setupCommander(): Command {
|
|
|
846
1909
|
});
|
|
847
1910
|
program.command('logs').description('📋 View Action logs').action(async () => { await contribute.viewLogs(); });
|
|
848
1911
|
|
|
1912
|
+
program.command('hunt')
|
|
1913
|
+
.description('🎯 Bounty Hunter — auto-apply to Drips Wave & GrantFox issues')
|
|
1914
|
+
.option('-p, --platform <p>', 'Platform: drips | grantfox | all (default: all)', 'all')
|
|
1915
|
+
.option('-m, --max <n>', 'Max applications to post (default: 3)', '3')
|
|
1916
|
+
.option('-s, --skills <langs>', 'Comma-separated languages to filter by (e.g. typescript,rust)', '')
|
|
1917
|
+
.option('--min-points <n>', 'Minimum Drips points (e.g. 150)', '0')
|
|
1918
|
+
.option('--dry-run', 'Show what would be applied for without posting', false)
|
|
1919
|
+
.option('--include-assigned', 'Include already-assigned issues', false)
|
|
1920
|
+
.action(async (o) => {
|
|
1921
|
+
const platform = (['drips', 'grantfox', 'all'].includes(o.platform) ? o.platform : 'all') as 'drips' | 'grantfox' | 'all';
|
|
1922
|
+
await runBountyHunter({
|
|
1923
|
+
platform,
|
|
1924
|
+
maxApplications: parseInt(o.max) || 3,
|
|
1925
|
+
skills: o.skills ? o.skills.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
|
1926
|
+
minPoints: parseInt(o.minPoints) || 0,
|
|
1927
|
+
dryRun: !!o.dryRun,
|
|
1928
|
+
skipAssigned: !o.includeAssigned,
|
|
1929
|
+
verbose: false,
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1932
|
+
|
|
849
1933
|
return program;
|
|
850
1934
|
}
|
|
851
1935
|
|