gitpadi 2.0.6 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.gitlab/duo/chat-rules.md +40 -0
  2. package/.gitlab/duo/mr-review-instructions.md +44 -0
  3. package/.gitlab-ci.yml +136 -0
  4. package/README.md +585 -57
  5. package/action.yml +21 -2
  6. package/dist/applicant-scorer.js +27 -105
  7. package/dist/cli.js +1082 -36
  8. package/dist/commands/apply-for-issue.js +396 -0
  9. package/dist/commands/bounty-hunter.js +441 -0
  10. package/dist/commands/contribute.js +245 -51
  11. package/dist/commands/gitlab-issues.js +87 -0
  12. package/dist/commands/gitlab-mrs.js +163 -0
  13. package/dist/commands/gitlab-pipelines.js +95 -0
  14. package/dist/commands/prs.js +3 -3
  15. package/dist/core/github.js +28 -0
  16. package/dist/core/gitlab.js +233 -0
  17. package/dist/gitlab-agents/ci-recovery-agent.js +173 -0
  18. package/dist/gitlab-agents/contributor-scoring-agent.js +159 -0
  19. package/dist/gitlab-agents/grade-assignment-agent.js +252 -0
  20. package/dist/gitlab-agents/mr-review-agent.js +200 -0
  21. package/dist/gitlab-agents/reminder-agent.js +164 -0
  22. package/dist/grade-assignment.js +262 -0
  23. package/dist/remind-contributors.js +127 -0
  24. package/dist/review-and-merge.js +125 -0
  25. package/examples/gitpadi.yml +152 -0
  26. package/package.json +20 -4
  27. package/src/applicant-scorer.ts +33 -141
  28. package/src/cli.ts +1119 -35
  29. package/src/commands/apply-for-issue.ts +452 -0
  30. package/src/commands/bounty-hunter.ts +529 -0
  31. package/src/commands/contribute.ts +264 -50
  32. package/src/commands/gitlab-issues.ts +87 -0
  33. package/src/commands/gitlab-mrs.ts +185 -0
  34. package/src/commands/gitlab-pipelines.ts +104 -0
  35. package/src/commands/prs.ts +3 -3
  36. package/src/core/github.ts +29 -0
  37. package/src/core/gitlab.ts +397 -0
  38. package/src/gitlab-agents/ci-recovery-agent.ts +201 -0
  39. package/src/gitlab-agents/contributor-scoring-agent.ts +196 -0
  40. package/src/gitlab-agents/grade-assignment-agent.ts +275 -0
  41. package/src/gitlab-agents/mr-review-agent.ts +231 -0
  42. package/src/gitlab-agents/reminder-agent.ts +203 -0
  43. package/src/grade-assignment.ts +283 -0
  44. package/src/remind-contributors.ts +159 -0
  45. package/src/review-and-merge.ts +143 -0
@@ -0,0 +1,529 @@
1
+ // commands/bounty-hunter.ts — Autonomous Bounty Hunter
2
+ //
3
+ // Searches Drips Wave and GrantFox for open issues, scores them against your
4
+ // GitHub profile, and automatically posts applications — no human input needed.
5
+ //
6
+ // How it works:
7
+ // Drips Wave → GitHub issues labeled "Stellar Wave" across 196+ orgs
8
+ // GrantFox → GitHub issues in org:GrantChain and known partner repos
9
+ //
10
+ // Both platforms sync from GitHub, so posting a matching application comment
11
+ // IS the application. Their bots pick it up automatically.
12
+ //
13
+ // Usage:
14
+ // gitpadi hunt
15
+ // gitpadi hunt --platform drips --max 5 --dry-run
16
+
17
+ import chalk from 'chalk';
18
+ import ora from 'ora';
19
+ import { getOctokit, getAuthenticatedUser } from '../core/github.js';
20
+
21
+ const dim = chalk.dim;
22
+ const cyan = chalk.cyan;
23
+ const green = chalk.green;
24
+ const yellow = chalk.yellow;
25
+ const bold = chalk.bold;
26
+ const magenta = chalk.magenta;
27
+ const red = chalk.red;
28
+
29
+ // ── Point values for Drips Wave complexity labels ────────────────────────────
30
+ const DRIPS_COMPLEXITY: Record<string, number> = {
31
+ 'small': 100, 'trivial': 100,
32
+ 'medium': 150,
33
+ 'large': 200, 'high': 200, 'complex': 200,
34
+ };
35
+
36
+ // Drips 3× multiplier repos (highest reward)
37
+ const DRIPS_3X_REPOS = new Set([
38
+ 'akkuea/akkuea',
39
+ 'tupui/soroban-versioning',
40
+ 'OFFER-HUB/offer-hub-monorepo',
41
+ 'safetrustcr/frontend-SafeTrust',
42
+ 'kindfi-org/kindfi',
43
+ 'Stellopay/stellopay-frontend',
44
+ 'Stellar-Rent/stellar-rent',
45
+ 'Stellopay/stellopay-core',
46
+ 'boundlessfi/bounties',
47
+ 'BuenDia-Builders/be-energy',
48
+ 'Trustless-Work/clonable-backoffice',
49
+ 'Trustless-Work/tokenization-private-credit-oss',
50
+ 'safetrustcr/backend-SafeTrust',
51
+ ]);
52
+
53
+ // GrantFox partner orgs on GitHub
54
+ const GRANTFOX_ORGS = ['GrantChain', 'Trustless-Work'];
55
+
56
+ // Labels that signal open contribution opportunity
57
+ const CONTRIBUTOR_LABELS = [
58
+ 'good first issue', 'help wanted', 'bounty', 'hacktoberfest',
59
+ 'up for grabs', 'stellar wave', 'grantfox',
60
+ 'contributions welcome', 'open to pr',
61
+ ];
62
+
63
+ export interface BountyIssue {
64
+ platform: 'drips' | 'grantfox';
65
+ owner: string;
66
+ repo: string;
67
+ number: number;
68
+ title: string;
69
+ body: string;
70
+ url: string;
71
+ labels: string[];
72
+ points: number; // Drips Wave points (0 if GrantFox)
73
+ multiplier: number; // 1x or 3x for Drips featured repos
74
+ effectivePoints: number;
75
+ complexity: string;
76
+ assignees: string[];
77
+ hasPr: boolean;
78
+ skillMatch: number; // 0–100 how well it matches user's languages
79
+ age: number; // days since last update
80
+ }
81
+
82
+ export interface HunterOptions {
83
+ platform: 'drips' | 'grantfox' | 'all';
84
+ maxApplications: number;
85
+ dryRun: boolean;
86
+ skills: string[]; // languages to filter by (empty = all)
87
+ minPoints: number; // minimum Drips points (0 = any)
88
+ skipAssigned: boolean; // skip issues already assigned
89
+ verbose: boolean;
90
+ }
91
+
92
+ const DEFAULT_OPTIONS: HunterOptions = {
93
+ platform: 'all',
94
+ maxApplications: 3,
95
+ dryRun: false,
96
+ skills: [],
97
+ minPoints: 0,
98
+ skipAssigned: true,
99
+ verbose: false,
100
+ };
101
+
102
+ // ── Search ───────────────────────────────────────────────────────────────────
103
+
104
+ async function searchDripsIssues(octokit: any, skills: string[]): Promise<BountyIssue[]> {
105
+ const issues: BountyIssue[] = [];
106
+
107
+ // Primary: label "Stellar Wave" — this is the official Drips Wave label
108
+ const queries = [
109
+ 'label:"Stellar Wave" state:open type:issue',
110
+ 'label:"stellar-wave" state:open type:issue',
111
+ ];
112
+
113
+ for (const q of queries) {
114
+ try {
115
+ const { data } = await octokit.search.issuesAndPullRequests({
116
+ q, per_page: 50, sort: 'updated', order: 'desc',
117
+ });
118
+
119
+ for (const item of data.items) {
120
+ if (item.pull_request) continue;
121
+
122
+ const [owner, repo] = (item.repository_url?.replace('https://api.github.com/repos/', '') || '/').split('/');
123
+ const fullRepo = `${owner}/${repo}`;
124
+ const labels = item.labels.map((l: any) => (l.name || '').toLowerCase());
125
+
126
+ // Parse complexity from labels
127
+ let complexity = 'medium';
128
+ let points = 150;
129
+ for (const [lbl, pts] of Object.entries(DRIPS_COMPLEXITY)) {
130
+ if (labels.some((l: string) => l.includes(lbl))) {
131
+ complexity = lbl;
132
+ points = pts;
133
+ break;
134
+ }
135
+ }
136
+
137
+ const multiplier = DRIPS_3X_REPOS.has(fullRepo) ? 3 : 1;
138
+
139
+ issues.push({
140
+ platform: 'drips',
141
+ owner, repo,
142
+ number: item.number,
143
+ title: item.title,
144
+ body: item.body || '',
145
+ url: item.html_url,
146
+ labels,
147
+ points,
148
+ multiplier,
149
+ effectivePoints: points * multiplier,
150
+ complexity,
151
+ assignees: item.assignees?.map((a: any) => a.login) || [],
152
+ hasPr: false,
153
+ skillMatch: calcSkillMatch(item.title + ' ' + (item.body || '') + ' ' + labels.join(' '), skills),
154
+ age: Math.floor((Date.now() - new Date(item.updated_at).getTime()) / 86_400_000),
155
+ });
156
+ }
157
+ } catch { /* rate limit or no results */ }
158
+ }
159
+
160
+ return dedupeIssues(issues);
161
+ }
162
+
163
+ async function searchGrantFoxIssues(octokit: any, skills: string[]): Promise<BountyIssue[]> {
164
+ const issues: BountyIssue[] = [];
165
+
166
+ const queries = [
167
+ // GrantFox own repositories
168
+ ...GRANTFOX_ORGS.map(org => `org:${org} state:open type:issue`),
169
+ // Issues tagged with contributor-friendly labels in known partner orgs
170
+ 'label:"good first issue" org:Trustless-Work state:open type:issue',
171
+ 'label:"help wanted" org:Trustless-Work state:open type:issue',
172
+ 'label:bounty state:open type:issue org:GrantChain',
173
+ ];
174
+
175
+ for (const q of queries) {
176
+ try {
177
+ const { data } = await octokit.search.issuesAndPullRequests({
178
+ q, per_page: 30, sort: 'updated', order: 'desc',
179
+ });
180
+
181
+ for (const item of data.items) {
182
+ if (item.pull_request) continue;
183
+
184
+ const [owner, repo] = (item.repository_url?.replace('https://api.github.com/repos/', '') || '/').split('/');
185
+ const labels = item.labels.map((l: any) => (l.name || '').toLowerCase());
186
+
187
+ issues.push({
188
+ platform: 'grantfox',
189
+ owner, repo,
190
+ number: item.number,
191
+ title: item.title,
192
+ body: item.body || '',
193
+ url: item.html_url,
194
+ labels,
195
+ points: 0,
196
+ multiplier: 1,
197
+ effectivePoints: 0,
198
+ complexity: 'unknown',
199
+ assignees: item.assignees?.map((a: any) => a.login) || [],
200
+ hasPr: false,
201
+ skillMatch: calcSkillMatch(item.title + ' ' + (item.body || '') + ' ' + labels.join(' '), skills),
202
+ age: Math.floor((Date.now() - new Date(item.updated_at).getTime()) / 86_400_000),
203
+ });
204
+ }
205
+ } catch { /* rate limit or no results */ }
206
+ }
207
+
208
+ return dedupeIssues(issues);
209
+ }
210
+
211
+ // ── Scoring helpers ──────────────────────────────────────────────────────────
212
+
213
+ function calcSkillMatch(text: string, skills: string[]): number {
214
+ if (skills.length === 0) return 50; // neutral
215
+ const lower = text.toLowerCase();
216
+ const hits = skills.filter(s => lower.includes(s.toLowerCase()));
217
+ return Math.round((hits.length / skills.length) * 100);
218
+ }
219
+
220
+ function scoreIssue(issue: BountyIssue): number {
221
+ let score = 0;
222
+ score += issue.skillMatch * 0.4; // 40% skill match
223
+ score += Math.min(issue.effectivePoints / 6, 33); // up to 33 pts for reward
224
+ score += issue.assignees.length === 0 ? 20 : 0; // unassigned bonus
225
+ score -= Math.min(issue.age / 10, 10); // penalise stale issues
226
+ return Math.round(Math.max(0, score));
227
+ }
228
+
229
+ function dedupeIssues(issues: BountyIssue[]): BountyIssue[] {
230
+ const seen = new Set<string>();
231
+ return issues.filter(i => {
232
+ const key = `${i.owner}/${i.repo}#${i.number}`;
233
+ if (seen.has(key)) return false;
234
+ seen.add(key);
235
+ return true;
236
+ });
237
+ }
238
+
239
+ // ── Application comment builders ─────────────────────────────────────────────
240
+
241
+ function buildDripsApplication(issue: BountyIssue, me: string): string {
242
+ const pts = issue.effectivePoints > 0
243
+ ? `\n\n> **Reward:** ${issue.points} pts${issue.multiplier > 1 ? ` × ${issue.multiplier}× = **${issue.effectivePoints} pts**` : ''} (${issue.complexity})`
244
+ : '';
245
+
246
+ return `Hey! I'd like to work on this issue.
247
+
248
+ I'm available to start right away, will open a draft PR early to show progress, and will keep you updated.${pts}
249
+
250
+ Please assign this to me if you think I'm a good fit — happy to answer any questions about my approach first.
251
+
252
+ ---
253
+ *Applied via [GitPadi](https://github.com/Netwalls/contributor-agent) Bounty Hunter 🤖*`;
254
+ }
255
+
256
+ function buildGrantFoxApplication(issue: BountyIssue, me: string): string {
257
+ return `Hi! I'd like to work on this issue.
258
+
259
+ I found this through GrantFox and I'm interested in contributing. I'll:
260
+ - Open a draft PR early so you can track my progress
261
+ - Ask questions here if anything needs clarification
262
+ - Aim to complete within 3–5 days
263
+
264
+ Please assign this to me if it's still open — I'm ready to start.
265
+
266
+ ---
267
+ *Applied via [GitPadi](https://github.com/Netwalls/contributor-agent) Bounty Hunter 🤖*`;
268
+ }
269
+
270
+ // ── Check if already applied ─────────────────────────────────────────────────
271
+
272
+ async function alreadyApplied(octokit: any, issue: BountyIssue, me: string): Promise<boolean> {
273
+ try {
274
+ const { data: comments } = await octokit.issues.listComments({
275
+ owner: issue.owner,
276
+ repo: issue.repo,
277
+ issue_number: issue.number,
278
+ per_page: 100,
279
+ });
280
+ return comments.some((c: any) =>
281
+ c.user?.login === me &&
282
+ /i('d| would| want| will)|assign.*me|let me|i can|i'm interested|claiming|GitPadi Bounty Hunter/i.test(c.body || '')
283
+ );
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ // ── Print helpers ────────────────────────────────────────────────────────────
290
+
291
+ function platformBadge(platform: 'drips' | 'grantfox'): string {
292
+ return platform === 'drips'
293
+ ? cyan('[Drips Wave]')
294
+ : magenta('[GrantFox] ');
295
+ }
296
+
297
+ function pointsBadge(issue: BountyIssue): string {
298
+ if (issue.effectivePoints === 0) return dim(' ');
299
+ if (issue.multiplier > 1) return yellow(`${issue.effectivePoints}pts★`);
300
+ return dim(`${issue.effectivePoints}pts `);
301
+ }
302
+
303
+ function printIssueRow(issue: BountyIssue, idx: number, status: string): void {
304
+ const num = dim(`${idx + 1}.`);
305
+ const badge = platformBadge(issue.platform);
306
+ const pts = pointsBadge(issue);
307
+ const assigned = issue.assignees.length > 0 ? yellow(' [assigned]') : '';
308
+ const title = issue.title.length > 55 ? issue.title.slice(0, 54) + '…' : issue.title;
309
+ console.log(` ${num} ${badge} ${pts} ${bold(title)}${assigned}`);
310
+ console.log(` ${dim(`${issue.owner}/${issue.repo}#${issue.number}`)} ${dim('→')} ${dim(issue.url)}`);
311
+ console.log(` ${status}`);
312
+ console.log();
313
+ }
314
+
315
+ // ── Main ─────────────────────────────────────────────────────────────────────
316
+
317
+ export async function runBountyHunter(opts: Partial<HunterOptions> = {}): Promise<void> {
318
+ const options: HunterOptions = { ...DEFAULT_OPTIONS, ...opts };
319
+ const octokit = getOctokit();
320
+
321
+ console.log();
322
+ console.log(bold(cyan(' 🎯 GitPadi Bounty Hunter')));
323
+ console.log(dim(` Platform: ${options.platform} | Max applications: ${options.maxApplications} | Dry-run: ${options.dryRun}`));
324
+ console.log();
325
+
326
+ // Get authenticated user + their languages
327
+ const spinner = ora(' Loading your GitHub profile…').start();
328
+ let me = '';
329
+ let myLanguages: string[] = options.skills;
330
+
331
+ try {
332
+ me = await getAuthenticatedUser();
333
+ if (myLanguages.length === 0) {
334
+ // Auto-detect user's top languages from their repos
335
+ const { data: myRepos } = await octokit.repos.listForAuthenticatedUser({
336
+ per_page: 50, sort: 'updated',
337
+ });
338
+ const langCounts: Record<string, number> = {};
339
+ myRepos.forEach((r: any) => {
340
+ if (r.language) langCounts[r.language] = (langCounts[r.language] || 0) + 1;
341
+ });
342
+ myLanguages = Object.entries(langCounts)
343
+ .sort((a, b) => b[1] - a[1])
344
+ .slice(0, 8)
345
+ .map(([l]) => l.toLowerCase());
346
+ }
347
+ spinner.succeed(` Logged in as @${me} | Skills: ${myLanguages.join(', ') || 'all'}`);
348
+ } catch (e: any) {
349
+ spinner.fail(` Could not load profile: ${e.message}`);
350
+ return;
351
+ }
352
+
353
+ // Search issues
354
+ const searches: Promise<BountyIssue[]>[] = [];
355
+
356
+ if (options.platform === 'drips' || options.platform === 'all') {
357
+ const s = ora(' Searching Drips Wave issues…').start();
358
+ searches.push(
359
+ searchDripsIssues(octokit, myLanguages)
360
+ .then(r => { s.succeed(` Drips Wave: ${r.length} issues found`); return r; })
361
+ .catch(e => { s.fail(` Drips search failed: ${e.message}`); return []; })
362
+ );
363
+ }
364
+
365
+ if (options.platform === 'grantfox' || options.platform === 'all') {
366
+ const s = ora(' Searching GrantFox issues…').start();
367
+ searches.push(
368
+ searchGrantFoxIssues(octokit, myLanguages)
369
+ .then(r => { s.succeed(` GrantFox: ${r.length} issues found`); return r; })
370
+ .catch(e => { s.fail(` GrantFox search failed: ${e.message}`); return []; })
371
+ );
372
+ }
373
+
374
+ const results = await Promise.all(searches);
375
+ let allIssues = dedupeIssues(results.flat());
376
+
377
+ // Filter
378
+ if (options.skipAssigned) {
379
+ allIssues = allIssues.filter(i => i.assignees.length === 0);
380
+ }
381
+ if (options.minPoints > 0) {
382
+ allIssues = allIssues.filter(i => i.platform === 'grantfox' || i.effectivePoints >= options.minPoints);
383
+ }
384
+ if (options.skills.length > 0) {
385
+ allIssues = allIssues.filter(i => i.skillMatch > 0);
386
+ }
387
+
388
+ // Rank
389
+ allIssues.sort((a, b) => scoreIssue(b) - scoreIssue(a));
390
+
391
+ if (allIssues.length === 0) {
392
+ console.log(yellow('\n No matching issues found. Try --platform all or remove skill filters.'));
393
+ return;
394
+ }
395
+
396
+ console.log();
397
+ console.log(bold(` Found ${allIssues.length} matching issue(s) — top picks:\n`));
398
+
399
+ // Show top issues
400
+ const TOP_DISPLAY = Math.min(allIssues.length, 10);
401
+ for (let i = 0; i < TOP_DISPLAY; i++) {
402
+ const issue = allIssues[i];
403
+ const rank = scoreIssue(issue);
404
+ const rankStr = rank >= 70 ? green(`match ${rank}%`) : rank >= 40 ? yellow(`match ${rank}%`) : dim(`match ${rank}%`);
405
+ printIssueRow(issue, i, rankStr);
406
+ }
407
+
408
+ if (allIssues.length > TOP_DISPLAY) {
409
+ console.log(dim(` … and ${allIssues.length - TOP_DISPLAY} more\n`));
410
+ }
411
+
412
+ // Select top N for application
413
+ const candidates = allIssues.slice(0, options.maxApplications);
414
+
415
+ if (options.dryRun) {
416
+ console.log(yellow(` [dry-run] Would apply to ${candidates.length} issue(s):`));
417
+ candidates.forEach((issue, i) => {
418
+ console.log(` ${i + 1}. ${issue.owner}/${issue.repo}#${issue.number} — ${issue.title}`);
419
+ });
420
+ console.log();
421
+ console.log(dim(' Remove --dry-run to actually post applications.'));
422
+ return;
423
+ }
424
+
425
+ // Apply
426
+ console.log(bold(`\n Applying to top ${candidates.length} issue(s)…\n`));
427
+ const results_: Array<{ issue: BountyIssue; status: 'applied' | 'skipped' | 'failed'; url?: string }> = [];
428
+
429
+ for (const issue of candidates) {
430
+ const label = ` ${platformBadge(issue.platform)} ${issue.owner}/${issue.repo}#${issue.number}`;
431
+ const applySpinner = ora(`${label}`).start();
432
+
433
+ // Check for existing application
434
+ const already = await alreadyApplied(octokit, issue, me);
435
+ if (already) {
436
+ applySpinner.warn(`${label} — already applied, skipping`);
437
+ results_.push({ issue, status: 'skipped' });
438
+ continue;
439
+ }
440
+
441
+ const body = issue.platform === 'drips'
442
+ ? buildDripsApplication(issue, me)
443
+ : buildGrantFoxApplication(issue, me);
444
+
445
+ try {
446
+ const { data: comment } = await octokit.issues.createComment({
447
+ owner: issue.owner,
448
+ repo: issue.repo,
449
+ issue_number: issue.number,
450
+ body,
451
+ });
452
+ applySpinner.succeed(`${label} — ${green('applied')} → ${dim(comment.html_url)}`);
453
+ results_.push({ issue, status: 'applied', url: comment.html_url });
454
+ } catch (e: any) {
455
+ applySpinner.fail(`${label} — ${red('failed:')} ${e.message}`);
456
+ results_.push({ issue, status: 'failed' });
457
+ }
458
+
459
+ // Small delay to avoid rate limiting
460
+ await new Promise(r => setTimeout(r, 800));
461
+ }
462
+
463
+ // Summary
464
+ const applied = results_.filter(r => r.status === 'applied');
465
+ const skipped = results_.filter(r => r.status === 'skipped');
466
+ const failed = results_.filter(r => r.status === 'failed');
467
+
468
+ console.log();
469
+ console.log(bold(' ── Summary ───────────────────────────────────────────'));
470
+ console.log(` ${green(`✅ Applied: ${applied.length}`)}`);
471
+ if (skipped.length) console.log(` ${yellow(`⏭ Skipped: ${skipped.length} (already applied)`)}`);
472
+ if (failed.length) console.log(` ${red(`❌ Failed: ${failed.length}`)}`);
473
+ console.log();
474
+
475
+ if (applied.length > 0) {
476
+ console.log(dim(' What happens next:'));
477
+ const hasDrips = applied.some(r => r.issue.platform === 'drips');
478
+ const hasGF = applied.some(r => r.issue.platform === 'grantfox');
479
+
480
+ if (hasDrips) {
481
+ console.log(dim(' • Drips Wave: submit a PR when your fix is ready. Points are awarded on merge.'));
482
+ }
483
+ if (hasGF) {
484
+ console.log(dim(' • GrantFox: maintainer will review your profile and assign if you\'re a good fit.'));
485
+ }
486
+ console.log(dim(' • Run `gitpadi` → Contributor → Sync with Upstream to stay current.'));
487
+ console.log();
488
+ }
489
+
490
+ // Offer to fork + branch for applied issues
491
+ if (applied.length > 0) {
492
+ console.log(bold(' Fork & branch for applied issues?'));
493
+ for (const r of applied) {
494
+ const { execFileSync } = await import('child_process');
495
+ const { forkRepo } = await import('../core/github.js');
496
+ const { existsSync } = await import('fs');
497
+
498
+ console.log(`\n ${cyan(`${r.issue.owner}/${r.issue.repo}#${r.issue.number}`)}`);
499
+ try {
500
+ const forkSpinner = ora(` Forking ${r.issue.owner}/${r.issue.repo}…`).start();
501
+ await forkRepo(r.issue.owner, r.issue.repo);
502
+ forkSpinner.succeed(` Forked → ${me}/${r.issue.repo}`);
503
+
504
+ const dir = `./${r.issue.repo}-${r.issue.number}`;
505
+ if (!existsSync(dir)) {
506
+ const cloneSpinner = ora(` Cloning into ${dir}…`).start();
507
+ execFileSync('git', ['clone', `https://github.com/${me}/${r.issue.repo}.git`, dir], { stdio: 'pipe' });
508
+ cloneSpinner.succeed();
509
+ }
510
+
511
+ try {
512
+ execFileSync('git', ['remote', 'add', 'upstream', `https://github.com/${r.issue.owner}/${r.issue.repo}.git`], { cwd: dir, stdio: 'pipe' });
513
+ } catch { /* already exists */ }
514
+
515
+ const branch = `fix/issue-${r.issue.number}`;
516
+ execFileSync('git', ['checkout', '-b', branch], { cwd: dir, stdio: 'pipe' });
517
+ console.log(green(` ✅ Branch ready: ${cyan(branch)}`));
518
+ console.log(dim(` cd ${dir} && npx gitpadi`));
519
+ } catch (e: any) {
520
+ if (/already exists/i.test(e.message)) {
521
+ console.log(yellow(` Fork already exists.`));
522
+ } else {
523
+ console.log(yellow(` Could not fork/clone: ${e.message}`));
524
+ }
525
+ }
526
+ }
527
+ console.log();
528
+ }
529
+ }