gitpadi 2.0.7 → 2.1.2
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 +1045 -34
- 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/drips.js +351 -0
- 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 +24 -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 +1078 -34
- 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/drips.ts +408 -0
- 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 +24 -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
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
// commands/apply-for-issue.ts — Browse open issues and apply to work on them
|
|
2
|
+
//
|
|
3
|
+
// Like Drips/Algora/Gitcoin — but from your terminal.
|
|
4
|
+
// Finds "good first issue" / "help wanted" issues, shows them in a table,
|
|
5
|
+
// lets you pick one and post a polished application comment.
|
|
6
|
+
// Optionally forks + branches immediately after applying.
|
|
7
|
+
|
|
8
|
+
import inquirer from 'inquirer';
|
|
9
|
+
import chalk from 'chalk';
|
|
10
|
+
import ora from 'ora';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import { getOctokit, getOwner, getRepo, getAuthenticatedUser, forkRepo } from '../core/github.js';
|
|
13
|
+
import { execFileSync } from 'child_process';
|
|
14
|
+
|
|
15
|
+
const dim = chalk.dim;
|
|
16
|
+
const cyan = chalk.cyan;
|
|
17
|
+
const yellow = chalk.yellow;
|
|
18
|
+
const green = chalk.green;
|
|
19
|
+
const bold = chalk.bold;
|
|
20
|
+
const magenta = chalk.magenta;
|
|
21
|
+
|
|
22
|
+
// Labels that signal "open to external contributors"
|
|
23
|
+
const BOUNTY_LABELS = [
|
|
24
|
+
'good first issue',
|
|
25
|
+
'help wanted',
|
|
26
|
+
'bounty',
|
|
27
|
+
'hacktoberfest',
|
|
28
|
+
'up for grabs',
|
|
29
|
+
'first-timers-only',
|
|
30
|
+
'easy',
|
|
31
|
+
'beginner',
|
|
32
|
+
'contributions welcome',
|
|
33
|
+
'open to pr',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function truncate(text: string, max: number): string {
|
|
37
|
+
return text.length <= max ? text : text.slice(0, max - 1) + '…';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function labelStr(labels: any[]): string {
|
|
41
|
+
return labels
|
|
42
|
+
.map((l: any) => (typeof l === 'string' ? l : l.name || ''))
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map((l: string) => dim(`[${l}]`))
|
|
45
|
+
.join(' ');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function diffDays(dateStr: string): number {
|
|
49
|
+
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 86_400_000);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Main export ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export async function browseAndApply(targetOwner?: string, targetRepo?: string): Promise<void> {
|
|
55
|
+
const octokit = getOctokit();
|
|
56
|
+
let owner = targetOwner || getOwner();
|
|
57
|
+
let repo = targetRepo || getRepo();
|
|
58
|
+
|
|
59
|
+
// If no repo context, ask the user for a target
|
|
60
|
+
if (!owner || !repo) {
|
|
61
|
+
const { target } = await inquirer.prompt([{
|
|
62
|
+
type: 'input',
|
|
63
|
+
name: 'target',
|
|
64
|
+
message: bold('Enter repo or issue URL (owner/repo or github.com/…):'),
|
|
65
|
+
validate: (v: string) => v.trim().length > 0 || 'Required',
|
|
66
|
+
}]);
|
|
67
|
+
|
|
68
|
+
const parsed = parseTarget(target.trim());
|
|
69
|
+
owner = parsed.owner;
|
|
70
|
+
repo = parsed.repo;
|
|
71
|
+
|
|
72
|
+
// If they pasted a direct issue URL, skip the browse step
|
|
73
|
+
if (parsed.issue) {
|
|
74
|
+
return applyToIssue(owner, repo, parsed.issue);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log();
|
|
79
|
+
const spinner = ora(` Fetching open issues for ${cyan(`${owner}/${repo}`)}…`).start();
|
|
80
|
+
|
|
81
|
+
let issues: any[] = [];
|
|
82
|
+
try {
|
|
83
|
+
// First try bounty/help-wanted labels, fall back to all open issues
|
|
84
|
+
const { data } = await octokit.issues.listForRepo({
|
|
85
|
+
owner, repo,
|
|
86
|
+
state: 'open',
|
|
87
|
+
per_page: 100,
|
|
88
|
+
sort: 'updated',
|
|
89
|
+
direction: 'desc',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Filter out pull requests (GitHub returns them in issues endpoint)
|
|
93
|
+
const realIssues = data.filter((i: any) => !i.pull_request);
|
|
94
|
+
|
|
95
|
+
// Prefer issues with contributor-friendly labels
|
|
96
|
+
const prioritised = realIssues.filter((i: any) =>
|
|
97
|
+
i.labels.some((l: any) => BOUNTY_LABELS.includes((typeof l === 'string' ? l : l.name || '').toLowerCase()))
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
issues = prioritised.length > 0 ? prioritised : realIssues;
|
|
101
|
+
spinner.succeed(` Found ${issues.length} open issue(s)`);
|
|
102
|
+
} catch (e: any) {
|
|
103
|
+
spinner.fail(` Could not fetch issues: ${e.message}`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (issues.length === 0) {
|
|
108
|
+
console.log(yellow('\n No open issues found.'));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Show issue list
|
|
113
|
+
console.log();
|
|
114
|
+
const choices = issues.slice(0, 30).map((issue: any) => {
|
|
115
|
+
const age = diffDays(issue.updated_at);
|
|
116
|
+
const ageStr = age === 0 ? dim('today') : age === 1 ? dim('1d ago') : dim(`${age}d ago`);
|
|
117
|
+
const labels = labelStr(issue.labels);
|
|
118
|
+
const title = truncate(issue.title, 52);
|
|
119
|
+
return {
|
|
120
|
+
name: ` ${cyan(`#${issue.number}`)} ${bold(title)} ${labels} ${ageStr}`,
|
|
121
|
+
value: issue.number,
|
|
122
|
+
short: `#${issue.number} ${issue.title}`,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
(choices as any[]).push(
|
|
127
|
+
new inquirer.Separator(dim(' ──────────────────────────────────────')),
|
|
128
|
+
{ name: ` ${dim('🔍 Enter issue number manually')}`, value: '__manual__', short: 'manual' },
|
|
129
|
+
{ name: ` ${dim('⬅ Back')}`, value: '__back__', short: 'back' },
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const { issueNumber } = await inquirer.prompt([{
|
|
133
|
+
type: 'list',
|
|
134
|
+
name: 'issueNumber',
|
|
135
|
+
message: bold(`Choose an issue to apply for (${owner}/${repo}):`),
|
|
136
|
+
choices,
|
|
137
|
+
pageSize: 15,
|
|
138
|
+
}]);
|
|
139
|
+
|
|
140
|
+
if (issueNumber === '__back__') return;
|
|
141
|
+
|
|
142
|
+
if (issueNumber === '__manual__') {
|
|
143
|
+
const { num } = await inquirer.prompt([{
|
|
144
|
+
type: 'input',
|
|
145
|
+
name: 'num',
|
|
146
|
+
message: bold('Issue number:'),
|
|
147
|
+
validate: (v: string) => /^\d+$/.test(v.trim()) || 'Enter a number',
|
|
148
|
+
}]);
|
|
149
|
+
return applyToIssue(owner, repo, parseInt(num));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return applyToIssue(owner, repo, issueNumber);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Apply to a specific issue ────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export async function applyToIssue(owner: string, repo: string, issueNumber: number): Promise<void> {
|
|
158
|
+
const octokit = getOctokit();
|
|
159
|
+
const spinner = ora(` Loading issue #${issueNumber}…`).start();
|
|
160
|
+
|
|
161
|
+
let issue: any;
|
|
162
|
+
try {
|
|
163
|
+
const { data } = await octokit.issues.get({ owner, repo, issue_number: issueNumber });
|
|
164
|
+
issue = data;
|
|
165
|
+
spinner.succeed();
|
|
166
|
+
} catch (e: any) {
|
|
167
|
+
spinner.fail(` Issue not found: ${e.message}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Show the issue
|
|
172
|
+
console.log();
|
|
173
|
+
console.log(` ${cyan(bold(`#${issue.number}`))} — ${bold(issue.title)}`);
|
|
174
|
+
console.log(` ${dim('Labels:')} ${labelStr(issue.labels) || dim('none')}`);
|
|
175
|
+
if (issue.assignees?.length > 0) {
|
|
176
|
+
const names = issue.assignees.map((a: any) => `@${a.login}`).join(', ');
|
|
177
|
+
console.log(` ${yellow(`⚠ Already assigned to: ${names}`)}`);
|
|
178
|
+
}
|
|
179
|
+
console.log();
|
|
180
|
+
if (issue.body) {
|
|
181
|
+
const preview = issue.body.replace(/\r?\n/g, ' ').trim();
|
|
182
|
+
console.log(` ${dim(truncate(preview, 200))}`);
|
|
183
|
+
console.log();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check if already commented
|
|
187
|
+
const me = await getAuthenticatedUser();
|
|
188
|
+
const { data: existingComments } = await octokit.issues.listComments({
|
|
189
|
+
owner, repo, issue_number: issueNumber, per_page: 100,
|
|
190
|
+
});
|
|
191
|
+
const alreadyApplied = existingComments.some(
|
|
192
|
+
(c: any) => c.user?.login === me && /i('d| would| want| will)|assign.*me|let me|i can|i'm interested|claiming this/i.test(c.body || '')
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (alreadyApplied) {
|
|
196
|
+
console.log(yellow(` ⚠ You've already applied for this issue.\n`));
|
|
197
|
+
const { cont } = await inquirer.prompt([{
|
|
198
|
+
type: 'confirm',
|
|
199
|
+
name: 'cont',
|
|
200
|
+
message: 'Apply again anyway?',
|
|
201
|
+
default: false,
|
|
202
|
+
}]);
|
|
203
|
+
if (!cont) return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Pick application style
|
|
207
|
+
const { style } = await inquirer.prompt([{
|
|
208
|
+
type: 'list',
|
|
209
|
+
name: 'style',
|
|
210
|
+
message: bold('How would you like to apply?'),
|
|
211
|
+
choices: [
|
|
212
|
+
{ name: ` ${cyan('📝')} Write a custom message`, value: 'custom' },
|
|
213
|
+
{ name: ` ${green('⚡')} Quick apply (standard template)`, value: 'quick' },
|
|
214
|
+
{ name: ` ${magenta('🤖')} Detailed application (shows your skills)`, value: 'detailed' },
|
|
215
|
+
new inquirer.Separator(dim(' ─────────────────')),
|
|
216
|
+
{ name: ` ${dim('⬅ Cancel')}`, value: 'cancel' },
|
|
217
|
+
],
|
|
218
|
+
}]);
|
|
219
|
+
|
|
220
|
+
if (style === 'cancel') return;
|
|
221
|
+
|
|
222
|
+
let commentBody = '';
|
|
223
|
+
|
|
224
|
+
if (style === 'quick') {
|
|
225
|
+
commentBody = buildQuickApplication(issue.title);
|
|
226
|
+
} else if (style === 'detailed') {
|
|
227
|
+
commentBody = await buildDetailedApplication(me, owner, repo, issue);
|
|
228
|
+
} else {
|
|
229
|
+
// Custom message
|
|
230
|
+
const { msg } = await inquirer.prompt([{
|
|
231
|
+
type: 'input',
|
|
232
|
+
name: 'msg',
|
|
233
|
+
message: bold('Your application message:'),
|
|
234
|
+
validate: (v: string) => v.trim().length >= 10 || 'Please write at least 10 characters',
|
|
235
|
+
}]);
|
|
236
|
+
commentBody = buildCustomApplication(msg.trim(), issue.title);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Preview
|
|
240
|
+
console.log();
|
|
241
|
+
console.log(dim(' ── Preview ──────────────────────────────────'));
|
|
242
|
+
commentBody.split('\n').forEach(line => console.log(` ${dim(line)}`));
|
|
243
|
+
console.log(dim(' ─────────────────────────────────────────────'));
|
|
244
|
+
console.log();
|
|
245
|
+
|
|
246
|
+
const { confirm } = await inquirer.prompt([{
|
|
247
|
+
type: 'list',
|
|
248
|
+
name: 'confirm',
|
|
249
|
+
message: bold('Post this application?'),
|
|
250
|
+
choices: [
|
|
251
|
+
{ name: ` ${green('✅ Yes, post it')}`, value: 'yes' },
|
|
252
|
+
{ name: ` ${yellow('✏️ Edit message')}`, value: 'edit' },
|
|
253
|
+
{ name: ` ${dim('❌ Cancel')}`, value: 'no' },
|
|
254
|
+
],
|
|
255
|
+
}]);
|
|
256
|
+
|
|
257
|
+
if (confirm === 'no') return;
|
|
258
|
+
|
|
259
|
+
if (confirm === 'edit') {
|
|
260
|
+
const { edited } = await inquirer.prompt([{
|
|
261
|
+
type: 'editor',
|
|
262
|
+
name: 'edited',
|
|
263
|
+
message: 'Edit your application:',
|
|
264
|
+
default: commentBody,
|
|
265
|
+
}]);
|
|
266
|
+
commentBody = edited.trim();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const postSpinner = ora(' Posting application…').start();
|
|
270
|
+
try {
|
|
271
|
+
const { data: posted } = await octokit.issues.createComment({
|
|
272
|
+
owner, repo, issue_number: issueNumber, body: commentBody,
|
|
273
|
+
});
|
|
274
|
+
postSpinner.succeed(green(` Application posted! → ${posted.html_url}`));
|
|
275
|
+
} catch (e: any) {
|
|
276
|
+
postSpinner.fail(` Failed to post: ${e.message}`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Offer to fork + branch immediately
|
|
281
|
+
console.log();
|
|
282
|
+
const { forkNow } = await inquirer.prompt([{
|
|
283
|
+
type: 'confirm',
|
|
284
|
+
name: 'forkNow',
|
|
285
|
+
message: bold(`Fork ${owner}/${repo} and create a branch for this issue now?`),
|
|
286
|
+
default: true,
|
|
287
|
+
}]);
|
|
288
|
+
|
|
289
|
+
if (forkNow) {
|
|
290
|
+
await forkAndBranchForIssue(owner, repo, issueNumber, issue.title);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Application message builders ────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
function buildQuickApplication(issueTitle: string): string {
|
|
297
|
+
return `Hey! I'd like to work on this issue.
|
|
298
|
+
|
|
299
|
+
I'm available to start right away and will keep you updated on progress. Please assign this to me if you think I'm a good fit.
|
|
300
|
+
|
|
301
|
+
> Issue: *${issueTitle}*
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
*Sent via [GitPadi](https://github.com/Netwalls/contributor-agent) 🤖*`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildCustomApplication(message: string, issueTitle: string): string {
|
|
308
|
+
// Ensure it matches APPLICATION_PATTERNS so applicant-scorer fires
|
|
309
|
+
const opener = /i('d| would| want| will)|assign.*me|let me|i can|i'm interested|claiming this/i.test(message)
|
|
310
|
+
? ''
|
|
311
|
+
: "I'd like to work on this issue.\n\n";
|
|
312
|
+
|
|
313
|
+
return `${opener}${message}
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
*Sent via [GitPadi](https://github.com/Netwalls/contributor-agent) 🤖*`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function buildDetailedApplication(me: string, owner: string, repo: string, issue: any): Promise<string> {
|
|
320
|
+
const octokit = getOctokit();
|
|
321
|
+
const spinner = ora(' Fetching your GitHub profile…').start();
|
|
322
|
+
|
|
323
|
+
let userBio = '';
|
|
324
|
+
let topLanguages: string[] = [];
|
|
325
|
+
let mergedPRs = 0;
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
const { data: user } = await octokit.users.getByUsername({ username: me });
|
|
329
|
+
userBio = user.bio || '';
|
|
330
|
+
|
|
331
|
+
// Count merged PRs in this repo
|
|
332
|
+
const { data: prs } = await octokit.pulls.list({ owner, repo, state: 'closed', per_page: 100 });
|
|
333
|
+
mergedPRs = prs.filter((p: any) => p.user?.login === me && p.merged_at).length;
|
|
334
|
+
|
|
335
|
+
// Get top languages from user's repos
|
|
336
|
+
const { data: repos } = await octokit.repos.listForUser({ username: me, per_page: 20, sort: 'updated' });
|
|
337
|
+
const langCounts: Record<string, number> = {};
|
|
338
|
+
repos.forEach((r: any) => { if (r.language) langCounts[r.language] = (langCounts[r.language] || 0) + 1; });
|
|
339
|
+
topLanguages = Object.entries(langCounts).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l]) => l);
|
|
340
|
+
|
|
341
|
+
spinner.succeed();
|
|
342
|
+
} catch {
|
|
343
|
+
spinner.warn(' Could not fully load profile — using basic template');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const langLine = topLanguages.length > 0 ? `**Languages:** ${topLanguages.join(', ')}` : '';
|
|
347
|
+
const prLine = mergedPRs > 0 ? `**${mergedPRs} merged PR(s)** in this repo` : '';
|
|
348
|
+
const statsLines = [langLine, prLine].filter(Boolean).join(' · ');
|
|
349
|
+
|
|
350
|
+
const { approach } = await inquirer.prompt([{
|
|
351
|
+
type: 'input',
|
|
352
|
+
name: 'approach',
|
|
353
|
+
message: bold('Briefly describe your approach / why you want this issue:'),
|
|
354
|
+
validate: (v: string) => v.trim().length >= 10 || 'Please write at least 10 characters',
|
|
355
|
+
}]);
|
|
356
|
+
|
|
357
|
+
const { eta } = await inquirer.prompt([{
|
|
358
|
+
type: 'list',
|
|
359
|
+
name: 'eta',
|
|
360
|
+
message: bold('Expected time to complete:'),
|
|
361
|
+
choices: ['1–2 days', '3–5 days', '1 week', '2 weeks', 'Unsure — I\'ll update you'],
|
|
362
|
+
}]);
|
|
363
|
+
|
|
364
|
+
return `## Application — ${issue.title}
|
|
365
|
+
|
|
366
|
+
I'd like to work on this issue.
|
|
367
|
+
|
|
368
|
+
**Why I'm a good fit:**
|
|
369
|
+
${approach.trim()}
|
|
370
|
+
|
|
371
|
+
**My approach:**
|
|
372
|
+
- I'll start by reviewing the codebase around this issue
|
|
373
|
+
- Open a draft PR early so you can track progress
|
|
374
|
+
- Will ask questions here if anything is unclear
|
|
375
|
+
|
|
376
|
+
**Timeline:** ~${eta}
|
|
377
|
+
|
|
378
|
+
${statsLines ? `---\n${statsLines}` : ''}
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
*Sent via [GitPadi](https://github.com/Netwalls/contributor-agent) 🤖*`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Fork + branch helper ─────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
async function forkAndBranchForIssue(owner: string, repo: string, issueNumber: number, issueTitle: string): Promise<void> {
|
|
387
|
+
const octokit = getOctokit();
|
|
388
|
+
const me = await getAuthenticatedUser();
|
|
389
|
+
const spinner = ora(` Forking ${owner}/${repo}…`).start();
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
await forkRepo(owner, repo);
|
|
393
|
+
spinner.succeed(` Forked → ${me}/${repo}`);
|
|
394
|
+
} catch (e: any) {
|
|
395
|
+
if (/already exists/i.test(e.message)) {
|
|
396
|
+
spinner.info(` Fork already exists at ${me}/${repo}`);
|
|
397
|
+
} else {
|
|
398
|
+
spinner.fail(` Fork failed: ${e.message}`);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const { cloneDir } = await inquirer.prompt([{
|
|
404
|
+
type: 'input',
|
|
405
|
+
name: 'cloneDir',
|
|
406
|
+
message: bold('Where to clone?'),
|
|
407
|
+
default: `./${repo}`,
|
|
408
|
+
}]);
|
|
409
|
+
|
|
410
|
+
const dir = cloneDir.trim();
|
|
411
|
+
|
|
412
|
+
if (!fs.existsSync(dir)) {
|
|
413
|
+
const cloneSpinner = ora(` Cloning into ${dir}…`).start();
|
|
414
|
+
try {
|
|
415
|
+
execFileSync('git', ['clone', `https://github.com/${me}/${repo}.git`, dir], { stdio: 'pipe' });
|
|
416
|
+
cloneSpinner.succeed();
|
|
417
|
+
} catch (e: any) {
|
|
418
|
+
cloneSpinner.fail(` Clone failed: ${e.message}`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Add upstream
|
|
424
|
+
try {
|
|
425
|
+
execFileSync('git', ['remote', 'add', 'upstream', `https://github.com/${owner}/${repo}.git`], { cwd: dir, stdio: 'pipe' });
|
|
426
|
+
} catch { /* already exists */ }
|
|
427
|
+
|
|
428
|
+
// Create branch
|
|
429
|
+
const branchName = `fix/issue-${issueNumber}`;
|
|
430
|
+
try {
|
|
431
|
+
execFileSync('git', ['checkout', '-b', branchName], { cwd: dir, stdio: 'pipe' });
|
|
432
|
+
console.log(green(`\n ✅ Branch created: ${cyan(branchName)}`));
|
|
433
|
+
console.log(dim(` cd ${dir} && npx gitpadi`));
|
|
434
|
+
console.log();
|
|
435
|
+
} catch (e: any) {
|
|
436
|
+
console.log(yellow(` ⚠ Could not create branch: ${e.message}`));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ── URL parser ───────────────────────────────────────────────────────────────
|
|
441
|
+
|
|
442
|
+
function parseTarget(input: string): { owner: string; repo: string; issue?: number } {
|
|
443
|
+
const urlMatch = input.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?(?:issues\/(\d+))?(?:\?|$|\/)/);
|
|
444
|
+
if (urlMatch) {
|
|
445
|
+
return { owner: urlMatch[1], repo: urlMatch[2], issue: urlMatch[3] ? parseInt(urlMatch[3]) : undefined };
|
|
446
|
+
}
|
|
447
|
+
const parts = input.replace(/https?:\/\/github\.com\//, '').split('/');
|
|
448
|
+
if (parts.length >= 2) {
|
|
449
|
+
return { owner: parts[0], repo: parts[1], issue: parts[3] ? parseInt(parts[3]) : undefined };
|
|
450
|
+
}
|
|
451
|
+
throw new Error('Use a GitHub URL or "owner/repo" format.');
|
|
452
|
+
}
|