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