gitpadi 2.0.0

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.
@@ -0,0 +1,114 @@
1
+ // commands/contributors.ts — Contributor management commands for GitPadi
2
+
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import Table from 'cli-table3';
6
+ import { getOctokit, getOwner, getRepo, getFullRepo, requireRepo } from '../core/github.js';
7
+ import { fetchProfile, scoreApplicant, TIER_EMOJI, type ScoredApplicant } from '../core/scorer.js';
8
+
9
+ export async function scoreUser(username: string) {
10
+ requireRepo();
11
+ const spinner = ora(`Scoring @${username}...`).start();
12
+
13
+ try {
14
+ const profile = await fetchProfile(username);
15
+ const scored = scoreApplicant(profile);
16
+ spinner.stop();
17
+
18
+ console.log(`\n${chalk.bold(`🏆 Score: @${username}`)} — ${TIER_EMOJI[scored.tier]} Tier ${scored.tier} (${scored.score}/100)\n`);
19
+
20
+ const table = new Table({ style: { head: [], border: [] } });
21
+ table.push(
22
+ { [chalk.cyan('🏛️ Account Maturity')]: `${scored.breakdown.accountMaturity}/15` },
23
+ { [chalk.cyan('🔧 Repo Experience')]: `${scored.breakdown.repoExperience}/30` },
24
+ { [chalk.cyan('🌐 GitHub Presence')]: `${scored.breakdown.githubPresence}/15` },
25
+ { [chalk.cyan('⚡ Activity Level')]: `${scored.breakdown.activityLevel}/15` },
26
+ { [chalk.cyan('📝 Application')]: `${scored.breakdown.applicationQuality}/15` },
27
+ { [chalk.cyan('💻 Languages')]: `${scored.breakdown.languageRelevance}/10` },
28
+ { [chalk.bold('Total')]: chalk.bold(`${scored.score}/100`) },
29
+ );
30
+
31
+ console.log(table.toString());
32
+ console.log(`\n ${chalk.dim('Account age:')} ${Math.round(scored.accountAge / 30)} months`);
33
+ console.log(` ${chalk.dim('Public repos:')} ${scored.publicRepos}`);
34
+ console.log(` ${chalk.dim('Followers:')} ${scored.followers}`);
35
+ console.log(` ${chalk.dim('Languages:')} ${scored.relevantLanguages.slice(0, 8).join(', ') || 'None detected'}`);
36
+ console.log(` ${chalk.dim('Repo PRs:')} ${scored.prsMerged} merged, ${scored.prsOpen} open`);
37
+ console.log('');
38
+ } catch (e: any) { spinner.fail(e.message); }
39
+ }
40
+
41
+ export async function rankApplicants(issueNumber: number) {
42
+ requireRepo();
43
+ const octokit = getOctokit();
44
+ const spinner = ora(`Finding applicants for #${issueNumber}...`).start();
45
+
46
+ try {
47
+ const { data: issue } = await octokit.issues.get({ owner: getOwner(), repo: getRepo(), issue_number: issueNumber });
48
+ const { data: comments } = await octokit.issues.listComments({ owner: getOwner(), repo: getRepo(), issue_number: issueNumber, per_page: 100 });
49
+ const labels = issue.labels.map((l) => typeof l === 'string' ? l : l.name || '').filter(Boolean);
50
+
51
+ const { isApplicationComment } = await import('../core/scorer.js');
52
+ const apps = comments.filter((c) => c.body && isApplicationComment(c.body) && c.user?.login !== 'github-actions[bot]');
53
+ const byUser = new Map<string, string>();
54
+ apps.forEach((c) => { if (c.user?.login) byUser.set(c.user.login, c.body || ''); });
55
+
56
+ if (byUser.size === 0) { spinner.warn('No applicants found.'); return; }
57
+
58
+ spinner.text = `Scoring ${byUser.size} applicant(s)...`;
59
+ const scored: ScoredApplicant[] = [];
60
+ for (const [user, body] of byUser) {
61
+ const profile = await fetchProfile(user, body);
62
+ scored.push(scoreApplicant(profile, labels));
63
+ }
64
+
65
+ scored.sort((a, b) => b.score - a.score);
66
+ spinner.stop();
67
+
68
+ const table = new Table({
69
+ head: ['Rank', 'User', 'Tier', 'Score', 'PRs', 'Activity', 'Languages'].map((h) => chalk.cyan(h)),
70
+ style: { head: [], border: [] },
71
+ });
72
+
73
+ scored.forEach((s, i) => {
74
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
75
+ table.push([medal, `@${s.username}`, `${TIER_EMOJI[s.tier]} ${s.tier}`, `${s.score}/100`, `${s.prsMerged}m/${s.prsOpen}o`, `${s.totalContributions}`, s.relevantLanguages.slice(0, 3).join(', ') || '-']);
76
+ });
77
+
78
+ console.log(`\n${chalk.bold(`🏆 Applicant Rankings — Issue #${issueNumber}`)}\n`);
79
+ console.log(table.toString());
80
+
81
+ if (scored.length >= 2) {
82
+ const gap = scored[0].score - scored[1].score;
83
+ if (gap >= 20) console.log(chalk.green(`\n → Clear winner: @${scored[0].username} (+${gap} pts)`));
84
+ else if (gap >= 5) console.log(chalk.green(`\n → Top pick: @${scored[0].username} (+${gap} pts)`));
85
+ else console.log(chalk.yellow(`\n → Close match — review manually`));
86
+ }
87
+ console.log('');
88
+ } catch (e: any) { spinner.fail(e.message); }
89
+ }
90
+
91
+ export async function listContributors(opts: { limit?: number }) {
92
+ requireRepo();
93
+ const spinner = ora('Fetching contributors...').start();
94
+
95
+ try {
96
+ const { data } = await getOctokit().repos.listContributors({
97
+ owner: getOwner(), repo: getRepo(), per_page: opts.limit || 25,
98
+ });
99
+ spinner.stop();
100
+
101
+ const table = new Table({
102
+ head: ['#', 'User', 'Contributions'].map((h) => chalk.cyan(h)),
103
+ style: { head: [], border: [] },
104
+ });
105
+
106
+ data.forEach((c, i) => {
107
+ table.push([`${i + 1}.`, `@${c.login || '?'}`, `${c.contributions}`]);
108
+ });
109
+
110
+ console.log(`\n${chalk.bold(`👥 Contributors — ${getFullRepo()}`)} (${data.length})\n`);
111
+ console.log(table.toString());
112
+ console.log('');
113
+ } catch (e: any) { spinner.fail(e.message); }
114
+ }
@@ -0,0 +1,267 @@
1
+ // commands/issues.ts — Issue management commands for GitPadi
2
+
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import Table from 'cli-table3';
6
+ import { getOctokit, getOwner, getRepo, getFullRepo, requireRepo } from '../core/github.js';
7
+ import { fetchProfile, scoreApplicant, isApplicationComment, TIER_EMOJI, type ScoredApplicant } from '../core/scorer.js';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+
11
+ export async function listIssues(opts: { state?: string; labels?: string; limit?: number }) {
12
+ requireRepo();
13
+ const octokit = getOctokit();
14
+ const spinner = ora(`Fetching issues from ${chalk.cyan(getFullRepo())}...`).start();
15
+
16
+ try {
17
+ const { data: issues } = await octokit.issues.listForRepo({
18
+ owner: getOwner(), repo: getRepo(),
19
+ state: (opts.state as 'open' | 'closed' | 'all') || 'open',
20
+ labels: opts.labels || undefined,
21
+ per_page: opts.limit || 25,
22
+ });
23
+
24
+ // Filter out PRs (GitHub API returns PRs in issues endpoint)
25
+ const realIssues = issues.filter((i) => !i.pull_request);
26
+ spinner.stop();
27
+
28
+ if (realIssues.length === 0) {
29
+ console.log(chalk.yellow('\n No issues found.\n'));
30
+ return;
31
+ }
32
+
33
+ const table = new Table({
34
+ head: ['#', 'Title', 'Labels', 'Assignee', 'State'].map((h) => chalk.cyan(h)),
35
+ style: { head: [], border: [] },
36
+ });
37
+
38
+ realIssues.forEach((i) => {
39
+ const labels = i.labels.map((l) => typeof l === 'string' ? l : l.name || '').join(', ');
40
+ const assignee = i.assignee?.login || chalk.dim('unassigned');
41
+ const state = i.state === 'open' ? chalk.green('open') : chalk.red('closed');
42
+ table.push([`#${i.number}`, i.title.substring(0, 60), labels.substring(0, 30), assignee, state]);
43
+ });
44
+
45
+ console.log(`\n${chalk.bold(`📋 Issues — ${getFullRepo()}`)} (${realIssues.length})\n`);
46
+ console.log(table.toString());
47
+ console.log('');
48
+ } catch (e: any) {
49
+ spinner.fail(`Failed: ${e.message}`);
50
+ }
51
+ }
52
+
53
+ export async function createIssue(opts: { title: string; body?: string; labels?: string; milestone?: string }) {
54
+ requireRepo();
55
+ const octokit = getOctokit();
56
+ const spinner = ora('Creating issue...').start();
57
+
58
+ try {
59
+ const { data } = await octokit.issues.create({
60
+ owner: getOwner(), repo: getRepo(),
61
+ title: opts.title,
62
+ body: opts.body || '',
63
+ labels: opts.labels ? opts.labels.split(',').map((l) => l.trim()) : undefined,
64
+ });
65
+
66
+ spinner.succeed(`Created issue ${chalk.green(`#${data.number}`)}: ${data.title}`);
67
+ console.log(chalk.dim(` → ${data.html_url}\n`));
68
+ } catch (e: any) {
69
+ spinner.fail(`Failed: ${e.message}`);
70
+ }
71
+ }
72
+
73
+ export async function createIssuesFromFile(filePath: string, opts: { dryRun?: boolean; start?: number; end?: number }) {
74
+ requireRepo();
75
+ const resolved = path.resolve(filePath);
76
+ if (!fs.existsSync(resolved)) {
77
+ console.error(chalk.red(`\n❌ File not found: ${resolved}\n`));
78
+ return;
79
+ }
80
+
81
+ const config = JSON.parse(fs.readFileSync(resolved, 'utf-8'));
82
+ const issues = config.issues || [];
83
+ const start = opts.start || 1;
84
+ const end = opts.end || 999;
85
+ const filtered = issues.filter((i: any) => i.number >= start && i.number <= end);
86
+
87
+ console.log(`\n${chalk.bold('📋 GitPadi Issue Creator')}`);
88
+ console.log(chalk.dim(` Repo: ${getFullRepo()}`));
89
+ console.log(chalk.dim(` File: ${filePath}`));
90
+ console.log(chalk.dim(` Range: #${start}-#${end} (${filtered.length} issues)`));
91
+ console.log(chalk.dim(` Mode: ${opts.dryRun ? 'DRY RUN' : 'LIVE'}\n`));
92
+
93
+ if (opts.dryRun) {
94
+ filtered.forEach((i: any) => {
95
+ console.log(` ${chalk.dim(`#${String(i.number).padStart(2, '0')}`)} ${i.title}`);
96
+ console.log(chalk.dim(` [${i.labels.join(', ')}]`));
97
+ });
98
+ console.log(chalk.green(`\n✅ Dry run: ${filtered.length} issues would be created.\n`));
99
+ return;
100
+ }
101
+
102
+ const octokit = getOctokit();
103
+ let created = 0, failed = 0;
104
+
105
+ // Create labels if defined
106
+ if (config.labels) {
107
+ const spinner = ora('Setting up labels...').start();
108
+ try {
109
+ for (const [name, color] of Object.entries(config.labels)) {
110
+ try { await octokit.issues.createLabel({ owner: getOwner(), repo: getRepo(), name, color: color as string }); } catch { /* exists */ }
111
+ }
112
+ spinner.succeed('Labels ready');
113
+ } catch { spinner.warn('Labels skipped (permission)'); }
114
+ }
115
+
116
+ for (const issue of filtered) {
117
+ try {
118
+ const { data } = await octokit.issues.create({
119
+ owner: getOwner(), repo: getRepo(), title: issue.title, body: issue.body, labels: issue.labels,
120
+ });
121
+ created++;
122
+ console.log(` ${chalk.green('✅')} #${String(issue.number).padStart(2, '0')} → GitHub #${data.number}`);
123
+ await new Promise((r) => setTimeout(r, 1200));
124
+ } catch (e: any) {
125
+ failed++;
126
+ console.log(` ${chalk.red('❌')} #${String(issue.number).padStart(2, '0')}: ${e.message}`);
127
+ }
128
+ }
129
+
130
+ console.log(`\n ${chalk.green(`✅ ${created}`)} created ${chalk.red(`❌ ${failed}`)} failed\n`);
131
+ }
132
+
133
+ export async function closeIssue(number: number) {
134
+ requireRepo();
135
+ const spinner = ora(`Closing issue #${number}...`).start();
136
+ try {
137
+ await getOctokit().issues.update({ owner: getOwner(), repo: getRepo(), issue_number: number, state: 'closed' });
138
+ spinner.succeed(`Closed issue ${chalk.red(`#${number}`)}`);
139
+ } catch (e: any) { spinner.fail(e.message); }
140
+ }
141
+
142
+ export async function reopenIssue(number: number) {
143
+ requireRepo();
144
+ const spinner = ora(`Reopening issue #${number}...`).start();
145
+ try {
146
+ await getOctokit().issues.update({ owner: getOwner(), repo: getRepo(), issue_number: number, state: 'open' });
147
+ spinner.succeed(`Reopened issue ${chalk.green(`#${number}`)}`);
148
+ } catch (e: any) { spinner.fail(e.message); }
149
+ }
150
+
151
+ export async function deleteIssue(number: number) {
152
+ requireRepo();
153
+ const spinner = ora(`Deleting issue #${number}...`).start();
154
+ try {
155
+ // GitHub doesn't have a direct delete API for issues — close + lock as workaround
156
+ const octokit = getOctokit();
157
+ await octokit.issues.update({ owner: getOwner(), repo: getRepo(), issue_number: number, state: 'closed' });
158
+ await octokit.issues.lock({ owner: getOwner(), repo: getRepo(), issue_number: number, lock_reason: 'off-topic' });
159
+ spinner.succeed(`Closed & locked issue ${chalk.red(`#${number}`)} ${chalk.dim('(GitHub API does not support true deletion)')}`);
160
+ } catch (e: any) { spinner.fail(e.message); }
161
+ }
162
+
163
+ export async function assignIssue(number: number, assignees: string[]) {
164
+ requireRepo();
165
+ const spinner = ora(`Assigning #${number} to ${assignees.join(', ')}...`).start();
166
+ try {
167
+ await getOctokit().issues.addAssignees({ owner: getOwner(), repo: getRepo(), issue_number: number, assignees });
168
+ spinner.succeed(`Assigned ${chalk.cyan(assignees.join(', '))} to #${number}`);
169
+ } catch (e: any) { spinner.fail(e.message); }
170
+ }
171
+
172
+ export async function assignBest(number: number) {
173
+ requireRepo();
174
+ const octokit = getOctokit();
175
+ const spinner = ora(`Analyzing applicants for #${number}...`).start();
176
+
177
+ try {
178
+ const { data: comments } = await octokit.issues.listComments({
179
+ owner: getOwner(), repo: getRepo(), issue_number: number, per_page: 100,
180
+ });
181
+
182
+ const { data: issue } = await octokit.issues.get({ owner: getOwner(), repo: getRepo(), issue_number: number });
183
+ const labels = issue.labels.map((l) => typeof l === 'string' ? l : l.name || '').filter(Boolean);
184
+
185
+ const apps = comments.filter((c) => c.body && isApplicationComment(c.body) && c.user?.login !== 'github-actions[bot]');
186
+ const byUser = new Map<string, typeof apps[0]>();
187
+ apps.forEach((c) => { if (c.user?.login) byUser.set(c.user.login, c); });
188
+
189
+ if (byUser.size === 0) {
190
+ spinner.warn('No applicants found for this issue.');
191
+ return;
192
+ }
193
+
194
+ spinner.text = `Scoring ${byUser.size} applicant(s)...`;
195
+ const scored: ScoredApplicant[] = [];
196
+ for (const [user, comment] of byUser) {
197
+ const profile = await fetchProfile(user, comment.body || '');
198
+ scored.push(scoreApplicant(profile, labels));
199
+ }
200
+
201
+ scored.sort((a, b) => b.score - a.score);
202
+ spinner.stop();
203
+
204
+ const table = new Table({
205
+ head: ['Rank', 'User', 'Tier', 'Score', 'Merged PRs', 'Languages'].map((h) => chalk.cyan(h)),
206
+ style: { head: [], border: [] },
207
+ });
208
+
209
+ scored.forEach((s, i) => {
210
+ const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
211
+ table.push([medal, `@${s.username}`, `${TIER_EMOJI[s.tier]} ${s.tier}`, `${s.score}/100`, String(s.prsMerged), s.relevantLanguages.slice(0, 3).join(', ') || '-']);
212
+ });
213
+
214
+ console.log(`\n${chalk.bold(`🏆 Applicant Rankings — Issue #${number}`)}\n`);
215
+ console.log(table.toString());
216
+
217
+ const best = scored[0];
218
+ console.log(`\n ${chalk.green('→')} Best candidate: ${chalk.bold(`@${best.username}`)} (${TIER_EMOJI[best.tier]} Tier ${best.tier}, ${best.score}/100)`);
219
+
220
+ // Auto-assign the best
221
+ const assignSpinner = ora(`Assigning @${best.username}...`).start();
222
+ await octokit.issues.addAssignees({ owner: getOwner(), repo: getRepo(), issue_number: number, assignees: [best.username] });
223
+ assignSpinner.succeed(`Assigned ${chalk.cyan(`@${best.username}`)} to #${number}`);
224
+ console.log('');
225
+ } catch (e: any) {
226
+ spinner.fail(e.message);
227
+ }
228
+ }
229
+
230
+ export async function searchIssues(query: string) {
231
+ requireRepo();
232
+ const spinner = ora('Searching...').start();
233
+ try {
234
+ const { data } = await getOctokit().search.issuesAndPullRequests({
235
+ q: `repo:${getOwner()}/${getRepo()} is:issue ${query}`,
236
+ per_page: 20,
237
+ });
238
+ spinner.stop();
239
+
240
+ if (data.total_count === 0) {
241
+ console.log(chalk.yellow(`\n No issues matching "${query}"\n`));
242
+ return;
243
+ }
244
+
245
+ const table = new Table({
246
+ head: ['#', 'Title', 'State'].map((h) => chalk.cyan(h)),
247
+ style: { head: [], border: [] },
248
+ });
249
+
250
+ data.items.forEach((i) => {
251
+ table.push([`#${i.number}`, i.title.substring(0, 65), i.state === 'open' ? chalk.green('open') : chalk.red('closed')]);
252
+ });
253
+
254
+ console.log(`\n${chalk.bold(`🔍 Search: "${query}"`)} (${data.total_count} results)\n`);
255
+ console.log(table.toString());
256
+ console.log('');
257
+ } catch (e: any) { spinner.fail(e.message); }
258
+ }
259
+
260
+ export async function labelIssue(number: number, labels: string[]) {
261
+ requireRepo();
262
+ const spinner = ora(`Adding labels to #${number}...`).start();
263
+ try {
264
+ await getOctokit().issues.addLabels({ owner: getOwner(), repo: getRepo(), issue_number: number, labels });
265
+ spinner.succeed(`Added ${chalk.cyan(labels.join(', '))} to #${number}`);
266
+ } catch (e: any) { spinner.fail(e.message); }
267
+ }
@@ -0,0 +1,243 @@
1
+ // commands/prs.ts — Pull Request management commands for GitPadi
2
+
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import Table from 'cli-table3';
6
+ import { getOctokit, getOwner, getRepo, getFullRepo, requireRepo } from '../core/github.js';
7
+
8
+ export async function listPRs(opts: { state?: string; limit?: number }) {
9
+ requireRepo();
10
+ const spinner = ora(`Fetching PRs from ${chalk.cyan(getFullRepo())}...`).start();
11
+
12
+ try {
13
+ const { data: prs } = await getOctokit().pulls.list({
14
+ owner: getOwner(), repo: getRepo(),
15
+ state: (opts.state as 'open' | 'closed' | 'all') || 'open',
16
+ per_page: opts.limit || 25,
17
+ });
18
+
19
+ spinner.stop();
20
+ if (prs.length === 0) { console.log(chalk.yellow('\n No pull requests found.\n')); return; }
21
+
22
+ const table = new Table({
23
+ head: ['#', 'Title', 'Author', 'Branch', 'State'].map((h) => chalk.cyan(h)),
24
+ style: { head: [], border: [] },
25
+ });
26
+
27
+ prs.forEach((pr) => {
28
+ const state = pr.state === 'open' ? chalk.green('open') : pr.merged_at ? chalk.magenta('merged') : chalk.red('closed');
29
+ table.push([`#${pr.number}`, pr.title.substring(0, 50), `@${pr.user?.login || '?'}`, pr.head.ref.substring(0, 25), state]);
30
+ });
31
+
32
+ console.log(`\n${chalk.bold(`🔀 Pull Requests — ${getFullRepo()}`)} (${prs.length})\n`);
33
+ console.log(table.toString());
34
+ console.log('');
35
+ } catch (e: any) { spinner.fail(e.message); }
36
+ }
37
+
38
+ export async function mergePR(number: number, opts: { method?: string; message?: string; force?: boolean }) {
39
+ requireRepo();
40
+ const octokit = getOctokit();
41
+ const method = (opts.method || 'squash') as 'merge' | 'squash' | 'rebase';
42
+
43
+ try {
44
+ // Force merge — skip CI
45
+ if (opts.force) {
46
+ const spinner = ora(`Force merging PR #${number} via ${method}...`).start();
47
+ const { data } = await octokit.pulls.merge({
48
+ owner: getOwner(), repo: getRepo(), pull_number: number,
49
+ merge_method: method, commit_message: opts.message,
50
+ });
51
+ if (data.merged) {
52
+ spinner.succeed(`Force merged PR ${chalk.green(`#${number}`)} via ${chalk.cyan(method)} ${chalk.yellow('(CI skipped)')}`);
53
+ console.log(chalk.dim(` SHA: ${data.sha}\n`));
54
+ } else { spinner.fail(`Could not merge: ${data.message}`); }
55
+ return;
56
+ }
57
+
58
+ // Step 1: Get PR head SHA
59
+ const spinner = ora(`Checking CI status for PR #${number}...`).start();
60
+ const { data: pr } = await octokit.pulls.get({ owner: getOwner(), repo: getRepo(), pull_number: number });
61
+ const sha = pr.head.sha;
62
+
63
+ // Step 2: Poll until all checks complete (max 5 min)
64
+ let attempts = 0;
65
+ const maxAttempts = 30;
66
+ let allComplete = false;
67
+ let ciChecks: Array<{ name: string; status: string; conclusion: string | null }> = [];
68
+
69
+ while (!allComplete && attempts < maxAttempts) {
70
+ const { data: checkRuns } = await octokit.checks.listForRef({
71
+ owner: getOwner(), repo: getRepo(), ref: sha, per_page: 100,
72
+ });
73
+ const { data: statusData } = await octokit.repos.getCombinedStatusForRef({
74
+ owner: getOwner(), repo: getRepo(), ref: sha,
75
+ });
76
+
77
+ ciChecks = checkRuns.check_runs.map((c) => ({
78
+ name: c.name, status: c.status, conclusion: c.conclusion,
79
+ }));
80
+ statusData.statuses.forEach((s) => {
81
+ if (!ciChecks.find((c) => c.name === s.context)) {
82
+ ciChecks.push({ name: s.context, status: s.state === 'pending' ? 'in_progress' : 'completed', conclusion: s.state === 'pending' ? null : s.state });
83
+ }
84
+ });
85
+
86
+ if (ciChecks.length === 0) break;
87
+
88
+ allComplete = ciChecks.every((c) => c.status === 'completed');
89
+ if (!allComplete) {
90
+ const running = ciChecks.filter((c) => c.status !== 'completed').length;
91
+ spinner.text = chalk.dim(`Waiting for ${running} check(s)... (${attempts * 10}s)`);
92
+ await new Promise((r) => setTimeout(r, 10000));
93
+ attempts++;
94
+ }
95
+ }
96
+ spinner.stop();
97
+
98
+ // Step 3: Show CI results
99
+ if (ciChecks.length > 0) {
100
+ console.log(`\n${chalk.bold(`🔍 CI Status — PR #${number}`)}\n`);
101
+
102
+ const ciTable = new Table({
103
+ head: ['Check', 'Result'].map((h) => chalk.cyan(h)),
104
+ style: { head: [], border: [] },
105
+ });
106
+
107
+ let allPassed = true;
108
+ ciChecks.forEach((c) => {
109
+ const result = c.conclusion === 'success' ? chalk.green('✅ pass')
110
+ : c.conclusion === 'failure' ? chalk.red('❌ fail')
111
+ : c.conclusion === 'neutral' || c.conclusion === 'skipped' ? chalk.dim('⏭️ skip')
112
+ : c.status !== 'completed' ? chalk.yellow('⏳ running')
113
+ : chalk.red('❌ ' + (c.conclusion || 'unknown'));
114
+
115
+ if (c.status === 'completed' && c.conclusion !== 'success' && c.conclusion !== 'neutral' && c.conclusion !== 'skipped') {
116
+ allPassed = false;
117
+ }
118
+ ciTable.push([c.name, result]);
119
+ });
120
+
121
+ console.log(ciTable.toString());
122
+ console.log('');
123
+
124
+ if (!allComplete) {
125
+ console.log(chalk.yellow(` ⚠️ Checks still running after 5 min. Try again later.\n`));
126
+ return;
127
+ }
128
+ if (!allPassed) {
129
+ console.log(chalk.red(` ❌ CI failed — merge blocked. Fix the checks and retry.\n`));
130
+ return;
131
+ }
132
+ console.log(chalk.green(` ✅ All checks passed!\n`));
133
+ } else {
134
+ console.log(chalk.dim(`\n ℹ️ No CI checks found — proceeding.\n`));
135
+ }
136
+
137
+ // Step 4: Merge
138
+ const mergeSpinner = ora(`Merging PR #${number} via ${method}...`).start();
139
+ const { data } = await octokit.pulls.merge({
140
+ owner: getOwner(), repo: getRepo(), pull_number: number,
141
+ merge_method: method,
142
+ commit_message: opts.message,
143
+ });
144
+
145
+ if (data.merged) {
146
+ mergeSpinner.succeed(`Merged PR ${chalk.green(`#${number}`)} via ${chalk.cyan(method)}`);
147
+ console.log(chalk.dim(` SHA: ${data.sha}\n`));
148
+ } else {
149
+ mergeSpinner.fail(`PR #${number} could not be merged: ${data.message}`);
150
+ }
151
+ } catch (e: any) { console.error(chalk.red(` ❌ ${e.message}`)); }
152
+ }
153
+
154
+
155
+ export async function closePR(number: number) {
156
+ requireRepo();
157
+ const spinner = ora(`Closing PR #${number}...`).start();
158
+ try {
159
+ await getOctokit().pulls.update({ owner: getOwner(), repo: getRepo(), pull_number: number, state: 'closed' });
160
+ spinner.succeed(`Closed PR ${chalk.red(`#${number}`)}`);
161
+ } catch (e: any) { spinner.fail(e.message); }
162
+ }
163
+
164
+ export async function reviewPR(number: number) {
165
+ requireRepo();
166
+ const octokit = getOctokit();
167
+ const spinner = ora(`Reviewing PR #${number}...`).start();
168
+
169
+ try {
170
+ const { data: pr } = await octokit.pulls.get({ owner: getOwner(), repo: getRepo(), pull_number: number });
171
+ const { data: files } = await octokit.pulls.listFiles({ owner: getOwner(), repo: getRepo(), pull_number: number, per_page: 100 });
172
+ const { data: commits } = await octokit.pulls.listCommits({ owner: getOwner(), repo: getRepo(), pull_number: number, per_page: 100 });
173
+ spinner.stop();
174
+
175
+ console.log(`\n${chalk.bold(`🔍 PR Review — #${number}: ${pr.title}`)}`);
176
+ console.log(chalk.dim(` Author: @${pr.user?.login} Branch: ${pr.head.ref} → ${pr.base.ref}\n`));
177
+
178
+ const checks: Array<{ name: string; icon: string; detail: string }> = [];
179
+
180
+ // Linked issues
181
+ const linked = pr.body?.match(/(fix(es|ed)?|clos(e[sd]?)|resolv(e[sd]?))\s+#\d+/gi) || [];
182
+ checks.push({ name: 'Linked Issues', icon: linked.length > 0 ? '✅' : '⚠️', detail: linked.length > 0 ? linked.join(', ') : 'None found — use "Fixes #N"' });
183
+
184
+ // Size
185
+ const total = files.reduce((s, f) => s + f.additions + f.deletions, 0);
186
+ checks.push({ name: 'PR Size', icon: total > 1000 ? '❌' : total > 500 ? '⚠️' : '✅', detail: `${total} lines changed (${files.length} files)` });
187
+
188
+ // Tests
189
+ const srcFiles = files.filter((f) => !f.filename.includes('test') && !f.filename.includes('spec') && /\.(ts|rs|js)$/.test(f.filename));
190
+ const testFiles = files.filter((f) => f.filename.includes('test') || f.filename.includes('spec'));
191
+ checks.push({ name: 'Tests', icon: srcFiles.length > 0 && testFiles.length === 0 ? '⚠️' : '✅', detail: `${testFiles.length} test file(s), ${srcFiles.length} source file(s)` });
192
+
193
+ // Commits
194
+ const conventionalRegex = /^(feat|fix|docs|style|refactor|test|chore|ci|perf|build|revert)(\(.+\))?!?:\s/;
195
+ const bad = commits.filter((c) => !conventionalRegex.test(c.commit.message));
196
+ checks.push({ name: 'Commits', icon: bad.length > 0 ? '⚠️' : '✅', detail: bad.length > 0 ? `${bad.length} non-conventional` : 'All conventional' });
197
+
198
+ // Sensitive files
199
+ const sensitive = files.filter((f) => /(\.env|secret|credential|password|\.key|\.pem)/i.test(f.filename));
200
+ checks.push({ name: 'Security', icon: sensitive.length > 0 ? '❌' : '✅', detail: sensitive.length > 0 ? `Flagged: ${sensitive.map((f) => f.filename).join(', ')}` : 'Clean' });
201
+
202
+ checks.forEach((c) => console.log(` ${c.icon} ${chalk.bold(c.name)}: ${c.detail}`));
203
+ console.log('');
204
+ } catch (e: any) { spinner.fail(e.message); }
205
+ }
206
+
207
+ export async function approvePR(number: number) {
208
+ requireRepo();
209
+ const spinner = ora(`Approving PR #${number}...`).start();
210
+ try {
211
+ await getOctokit().pulls.createReview({
212
+ owner: getOwner(), repo: getRepo(), pull_number: number,
213
+ event: 'APPROVE', body: '✅ Approved via GitPadi',
214
+ });
215
+ spinner.succeed(`Approved PR ${chalk.green(`#${number}`)}`);
216
+ } catch (e: any) { spinner.fail(e.message); }
217
+ }
218
+
219
+ export async function diffPR(number: number) {
220
+ requireRepo();
221
+ const spinner = ora(`Fetching diff for PR #${number}...`).start();
222
+ try {
223
+ const { data: files } = await getOctokit().pulls.listFiles({
224
+ owner: getOwner(), repo: getRepo(), pull_number: number, per_page: 100,
225
+ });
226
+ spinner.stop();
227
+
228
+ console.log(`\n${chalk.bold(`📄 PR #${number} — Changed Files`)} (${files.length})\n`);
229
+
230
+ const table = new Table({
231
+ head: ['File', '+', '-', 'Status'].map((h) => chalk.cyan(h)),
232
+ style: { head: [], border: [] },
233
+ });
234
+
235
+ files.forEach((f) => {
236
+ const statusColor = f.status === 'added' ? chalk.green : f.status === 'removed' ? chalk.red : chalk.yellow;
237
+ table.push([f.filename.substring(0, 60), chalk.green(`+${f.additions}`), chalk.red(`-${f.deletions}`), statusColor(f.status)]);
238
+ });
239
+
240
+ console.log(table.toString());
241
+ console.log('');
242
+ } catch (e: any) { spinner.fail(e.message); }
243
+ }
@@ -0,0 +1,54 @@
1
+ // commands/releases.ts — Release management commands for GitPadi
2
+
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import Table from 'cli-table3';
6
+ import { getOctokit, getOwner, getRepo, getFullRepo, requireRepo } from '../core/github.js';
7
+
8
+ export async function createRelease(tag: string, opts: { name?: string; body?: string; draft?: boolean; prerelease?: boolean; generate?: boolean }) {
9
+ requireRepo();
10
+ const spinner = ora(`Creating release ${chalk.cyan(tag)}...`).start();
11
+
12
+ try {
13
+ const { data } = await getOctokit().repos.createRelease({
14
+ owner: getOwner(), repo: getRepo(),
15
+ tag_name: tag,
16
+ name: opts.name || tag,
17
+ body: opts.body || '',
18
+ draft: opts.draft || false,
19
+ prerelease: opts.prerelease || false,
20
+ generate_release_notes: opts.generate !== false,
21
+ });
22
+
23
+ spinner.succeed(`Created release ${chalk.green(data.tag_name)}`);
24
+ console.log(chalk.dim(` → ${data.html_url}\n`));
25
+ } catch (e: any) { spinner.fail(e.message); }
26
+ }
27
+
28
+ export async function listReleases(opts: { limit?: number }) {
29
+ requireRepo();
30
+ const spinner = ora('Fetching releases...').start();
31
+
32
+ try {
33
+ const { data } = await getOctokit().repos.listReleases({
34
+ owner: getOwner(), repo: getRepo(), per_page: opts.limit || 10,
35
+ });
36
+ spinner.stop();
37
+
38
+ if (data.length === 0) { console.log(chalk.yellow('\n No releases found.\n')); return; }
39
+
40
+ const table = new Table({
41
+ head: ['Tag', 'Name', 'Date', 'Type'].map((h) => chalk.cyan(h)),
42
+ style: { head: [], border: [] },
43
+ });
44
+
45
+ data.forEach((r) => {
46
+ const type = r.draft ? chalk.yellow('draft') : r.prerelease ? chalk.magenta('pre-release') : chalk.green('release');
47
+ table.push([r.tag_name, (r.name || '-').substring(0, 40), new Date(r.published_at || r.created_at).toLocaleDateString(), type]);
48
+ });
49
+
50
+ console.log(`\n${chalk.bold(`🚀 Releases — ${getFullRepo()}`)} (${data.length})\n`);
51
+ console.log(table.toString());
52
+ console.log('');
53
+ } catch (e: any) { spinner.fail(e.message); }
54
+ }