gitpadi 2.1.8 ā 2.2.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.
- package/dist/cli.js +22 -0
- package/dist/commands/prs.js +107 -0
- package/dist/conflict-checker.js +103 -0
- package/package.json +1 -1
- package/src/cli.ts +19 -0
- package/src/commands/prs.ts +95 -0
- package/src/conflict-checker.ts +126 -0
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import * as applyForIssue from './commands/apply-for-issue.js';
|
|
|
24
24
|
import { runBountyHunter } from './commands/bounty-hunter.js';
|
|
25
25
|
import { dripsMenu } from './commands/drips.js';
|
|
26
26
|
import { remindContributors } from './remind-contributors.js';
|
|
27
|
+
import { checkConflicts } from './conflict-checker.js';
|
|
27
28
|
import * as gitlabIssues from './commands/gitlab-issues.js';
|
|
28
29
|
import * as gitlabMRs from './commands/gitlab-mrs.js';
|
|
29
30
|
import * as gitlabPipelines from './commands/gitlab-pipelines.js';
|
|
@@ -1077,6 +1078,7 @@ async function maintainerMenu() {
|
|
|
1077
1078
|
{ name: `${red('š')} ${bold('Releases')} ${dim('ā create, list, tag releases')}`, value: 'releases' },
|
|
1078
1079
|
{ name: `${green('šÆ')} ${bold('Bounty Hunter')} ${dim('ā auto-apply to Drips Wave & GrantFox')}`, value: 'hunt' },
|
|
1079
1080
|
{ name: `${cyan('š')} ${bold('Remind Contributors')} ${dim('ā warn assignees with no PR (12h)')}`, value: 'remind' },
|
|
1081
|
+
{ name: `${red('āļø')} ${bold('Conflict Checker')} ${dim('ā warn PRs with merge conflicts')}`, value: 'conflicts' },
|
|
1080
1082
|
new inquirer.Separator(dim(' āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā')),
|
|
1081
1083
|
{ name: `${dim('āļø')} ${dim('Switch Repo')}`, value: 'switch' },
|
|
1082
1084
|
{ name: `${dim('ā¬
')} ${dim('Back to Mode Selector')}`, value: 'back' },
|
|
@@ -1104,6 +1106,8 @@ async function maintainerMenu() {
|
|
|
1104
1106
|
await runBountyHunter({ platform: 'all', maxApplications: 3, dryRun: false });
|
|
1105
1107
|
else if (category === 'remind')
|
|
1106
1108
|
await safeMenu(remindContributors);
|
|
1109
|
+
else if (category === 'conflicts')
|
|
1110
|
+
await safeMenu(checkConflicts);
|
|
1107
1111
|
else if (category === 'review-merge') {
|
|
1108
1112
|
await ensureTargetRepo();
|
|
1109
1113
|
const { prNum } = await ask([{ type: 'input', name: 'prNum', message: cyan('PR number to review:') }]);
|
|
@@ -1547,6 +1551,8 @@ async function prMenu() {
|
|
|
1547
1551
|
{ name: ` ${green('āø')} List pull requests`, value: 'list' },
|
|
1548
1552
|
{ name: ` ${green('āø')} Merge PR ${dim('(checks CI first)')}`, value: 'merge' },
|
|
1549
1553
|
{ name: ` ${yellow('āø')} Force merge ${dim('(skip CI checks)')}`, value: 'force-merge' },
|
|
1554
|
+
{ name: ` ${green('āø')} Merge ALL open PRs ${dim('(checks CI per PR)')}`, value: 'merge-all' },
|
|
1555
|
+
{ name: ` ${yellow('āø')} Force merge ALL open PRs ${dim('(skip CI)')}`, value: 'force-merge-all' },
|
|
1550
1556
|
{ name: ` ${red('āø')} Close PR`, value: 'close' },
|
|
1551
1557
|
{ name: ` ${yellow('āø')} Auto-review PR`, value: 'review' },
|
|
1552
1558
|
{ name: ` ${green('āø')} Approve PR`, value: 'approve' },
|
|
@@ -1562,6 +1568,20 @@ async function prMenu() {
|
|
|
1562
1568
|
]);
|
|
1563
1569
|
await prs.listPRs({ state: a.state, limit: parseInt(a.limit) });
|
|
1564
1570
|
}
|
|
1571
|
+
else if (action === 'merge-all' || action === 'force-merge-all') {
|
|
1572
|
+
const a = await ask([
|
|
1573
|
+
{
|
|
1574
|
+
type: 'list', name: 'method', message: 'Merge method:', choices: [
|
|
1575
|
+
{ name: `${green('squash')} ${dim('ā clean single commit')}`, value: 'squash' },
|
|
1576
|
+
{ name: `${cyan('merge')} ${dim('ā preserve all commits')}`, value: 'merge' },
|
|
1577
|
+
{ name: `${yellow('rebase')} ${dim('ā linear history')}`, value: 'rebase' },
|
|
1578
|
+
]
|
|
1579
|
+
},
|
|
1580
|
+
{ type: 'confirm', name: 'confirm', message: action === 'force-merge-all' ? yellow('ā ļø Force merge ALL open PRs (skip CI)?') : red('ā ļø Merge ALL open PRs?'), default: false },
|
|
1581
|
+
]);
|
|
1582
|
+
if (a.confirm)
|
|
1583
|
+
await prs.mergeAllPRs({ method: a.method, force: action === 'force-merge-all' });
|
|
1584
|
+
}
|
|
1565
1585
|
else if (action === 'merge' || action === 'force-merge') {
|
|
1566
1586
|
const a = await ask([
|
|
1567
1587
|
{ type: 'input', name: 'n', message: yellow('PR #') + dim(' (q=back):') },
|
|
@@ -1805,6 +1825,8 @@ function setupCommander() {
|
|
|
1805
1825
|
.action(async (o) => { await prs.listPRs({ state: o.state, limit: parseInt(o.limit) }); });
|
|
1806
1826
|
p.command('merge <n>').option('-m, --method <m>', '', 'squash').option('--message <msg>').option('-f, --force', 'Skip CI checks')
|
|
1807
1827
|
.action(async (n, o) => { await prs.mergePR(parseInt(n), o); });
|
|
1828
|
+
p.command('merge-all').option('-m, --method <m>', '', 'squash').option('-f, --force', 'Skip CI checks')
|
|
1829
|
+
.action(async (o) => { await prs.mergeAllPRs(o); });
|
|
1808
1830
|
p.command('close <n>').action(async (n) => { await prs.closePR(parseInt(n)); });
|
|
1809
1831
|
p.command('review <n>').action(async (n) => { await prs.reviewPR(parseInt(n)); });
|
|
1810
1832
|
p.command('approve <n>').action(async (n) => { await prs.approvePR(parseInt(n)); });
|
package/dist/commands/prs.js
CHANGED
|
@@ -142,6 +142,113 @@ export async function mergePR(number, opts) {
|
|
|
142
142
|
console.error(chalk.red(` ā ${e.message}`));
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
|
+
export async function mergeAllPRs(opts) {
|
|
146
|
+
requireRepo();
|
|
147
|
+
const octokit = getOctokit();
|
|
148
|
+
const method = (opts.method || 'squash');
|
|
149
|
+
const spinner = ora(`Fetching open PRs from ${chalk.cyan(getFullRepo())}...`).start();
|
|
150
|
+
let openPRs = [];
|
|
151
|
+
try {
|
|
152
|
+
const { data } = await octokit.pulls.list({ owner: getOwner(), repo: getRepo(), state: 'open', per_page: 100 });
|
|
153
|
+
openPRs = data;
|
|
154
|
+
spinner.stop();
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
spinner.fail(e.message);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (openPRs.length === 0) {
|
|
161
|
+
console.log(chalk.yellow('\n No open PRs found.\n'));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
console.log(`\n${chalk.bold(`š Merge All Open PRs ā ${getFullRepo()}`)} (${openPRs.length} PRs)\n`);
|
|
165
|
+
const results = [];
|
|
166
|
+
for (const pr of openPRs) {
|
|
167
|
+
console.log(chalk.dim(` āāā PR #${pr.number}: ${pr.title.substring(0, 60)} āāā`));
|
|
168
|
+
try {
|
|
169
|
+
if (opts.force) {
|
|
170
|
+
const s = ora(` Force merging #${pr.number}...`).start();
|
|
171
|
+
const { data } = await octokit.pulls.merge({
|
|
172
|
+
owner: getOwner(), repo: getRepo(), pull_number: pr.number,
|
|
173
|
+
merge_method: method,
|
|
174
|
+
});
|
|
175
|
+
if (data.merged) {
|
|
176
|
+
s.succeed(` Merged #${pr.number} ${chalk.yellow('(CI skipped)')}`);
|
|
177
|
+
results.push({ number: pr.number, title: pr.title, status: 'merged' });
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
s.fail(` Could not merge #${pr.number}: ${data.message}`);
|
|
181
|
+
results.push({ number: pr.number, title: pr.title, status: 'failed', reason: data.message });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// Check CI
|
|
186
|
+
const s = ora(` Checking CI for #${pr.number}...`).start();
|
|
187
|
+
const sha = pr.head.sha;
|
|
188
|
+
let attempts = 0;
|
|
189
|
+
let allComplete = false;
|
|
190
|
+
let ciChecks = [];
|
|
191
|
+
while (!allComplete && attempts < 90) {
|
|
192
|
+
const { data: checkRuns } = await octokit.checks.listForRef({ owner: getOwner(), repo: getRepo(), ref: sha, per_page: 100 });
|
|
193
|
+
const { data: statusData } = await octokit.repos.getCombinedStatusForRef({ owner: getOwner(), repo: getRepo(), ref: sha });
|
|
194
|
+
ciChecks = checkRuns.check_runs.map((c) => ({ name: c.name, status: c.status, conclusion: c.conclusion }));
|
|
195
|
+
statusData.statuses.forEach((st) => {
|
|
196
|
+
if (!ciChecks.find((c) => c.name === st.context))
|
|
197
|
+
ciChecks.push({ name: st.context, status: st.state === 'pending' ? 'in_progress' : 'completed', conclusion: st.state === 'pending' ? null : st.state });
|
|
198
|
+
});
|
|
199
|
+
if (ciChecks.length === 0)
|
|
200
|
+
break;
|
|
201
|
+
allComplete = ciChecks.every((c) => c.status === 'completed');
|
|
202
|
+
if (!allComplete) {
|
|
203
|
+
s.text = chalk.dim(` Waiting for CI on #${pr.number}... (${attempts * 10}s)`);
|
|
204
|
+
await new Promise((r) => setTimeout(r, 10000));
|
|
205
|
+
attempts++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
s.stop();
|
|
209
|
+
if (!allComplete) {
|
|
210
|
+
console.log(chalk.yellow(` ā ļø CI still running for #${pr.number} after 15 min ā skipping.\n`));
|
|
211
|
+
results.push({ number: pr.number, title: pr.title, status: 'skipped', reason: 'CI timeout' });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const allPassed = ciChecks.length === 0 || ciChecks.every((c) => c.conclusion === 'success' || c.conclusion === 'neutral' || c.conclusion === 'skipped');
|
|
215
|
+
if (!allPassed) {
|
|
216
|
+
console.log(chalk.red(` ā CI failed for #${pr.number} ā skipping.\n`));
|
|
217
|
+
results.push({ number: pr.number, title: pr.title, status: 'skipped', reason: 'CI failed' });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const ms = ora(` Merging #${pr.number}...`).start();
|
|
221
|
+
const { data } = await octokit.pulls.merge({ owner: getOwner(), repo: getRepo(), pull_number: pr.number, merge_method: method });
|
|
222
|
+
if (data.merged) {
|
|
223
|
+
ms.succeed(` Merged #${pr.number}`);
|
|
224
|
+
results.push({ number: pr.number, title: pr.title, status: 'merged' });
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
ms.fail(` Could not merge #${pr.number}: ${data.message}`);
|
|
228
|
+
results.push({ number: pr.number, title: pr.title, status: 'failed', reason: data.message });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
console.error(chalk.red(` ā #${pr.number}: ${e.message}`));
|
|
234
|
+
results.push({ number: pr.number, title: pr.title, status: 'failed', reason: e.message });
|
|
235
|
+
}
|
|
236
|
+
console.log('');
|
|
237
|
+
}
|
|
238
|
+
// Summary table
|
|
239
|
+
const table = new Table({
|
|
240
|
+
head: ['#', 'Title', 'Result'].map((h) => chalk.cyan(h)),
|
|
241
|
+
style: { head: [], border: [] },
|
|
242
|
+
});
|
|
243
|
+
results.forEach((r) => {
|
|
244
|
+
const icon = r.status === 'merged' ? chalk.green('ā
merged') : r.status === 'skipped' ? chalk.yellow(`āļø skipped${r.reason ? ` (${r.reason})` : ''}`) : chalk.red(`ā failed${r.reason ? ` (${r.reason})` : ''}`);
|
|
245
|
+
table.push([`#${r.number}`, r.title.substring(0, 50), icon]);
|
|
246
|
+
});
|
|
247
|
+
const merged = results.filter((r) => r.status === 'merged').length;
|
|
248
|
+
console.log(`${chalk.bold('š Summary')}\n`);
|
|
249
|
+
console.log(table.toString());
|
|
250
|
+
console.log(`\n ${chalk.green(`${merged} merged`)}, ${chalk.yellow(`${results.filter((r) => r.status === 'skipped').length} skipped`)}, ${chalk.red(`${results.filter((r) => r.status === 'failed').length} failed`)}\n`);
|
|
251
|
+
}
|
|
145
252
|
export async function closePR(number) {
|
|
146
253
|
requireRepo();
|
|
147
254
|
const spinner = ora(`Closing PR #${number}...`).start();
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { initGitHub, getOctokit, getOwner, getRepo } from './core/github.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
// Signature marker to prevent posting duplicate conflict warnings on the same PR
|
|
4
|
+
const SIG_CONFLICT = '<!-- gitpadi-conflict-warning -->';
|
|
5
|
+
export async function checkConflicts() {
|
|
6
|
+
console.log(chalk.bold('\nāļø GitPadi Conflict Checker\n'));
|
|
7
|
+
try {
|
|
8
|
+
initGitHub();
|
|
9
|
+
const octokit = getOctokit();
|
|
10
|
+
const owner = getOwner();
|
|
11
|
+
const repo = getRepo();
|
|
12
|
+
if (!owner || !repo) {
|
|
13
|
+
console.error(chalk.red('ā Owner or Repo not found in environment/config.'));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
console.log(chalk.dim(`š Scanning open PRs in ${owner}/${repo} for merge conflicts...\n`));
|
|
17
|
+
// Fetch all open PRs
|
|
18
|
+
const { data: prs } = await octokit.pulls.list({
|
|
19
|
+
owner, repo, state: 'open', per_page: 100,
|
|
20
|
+
});
|
|
21
|
+
if (prs.length === 0) {
|
|
22
|
+
console.log(chalk.green('ā
No open PRs found.'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
let conflictCount = 0;
|
|
26
|
+
let skippedCount = 0;
|
|
27
|
+
let cleanCount = 0;
|
|
28
|
+
for (const pr of prs) {
|
|
29
|
+
const prNumber = pr.number;
|
|
30
|
+
const author = pr.user?.login ?? 'unknown';
|
|
31
|
+
process.stdout.write(chalk.cyan(` āø PR #${prNumber}: "${pr.title.substring(0, 50)}" (@${author}) ā `));
|
|
32
|
+
// Fetch the full PR to get the mergeable field
|
|
33
|
+
// GitHub computes mergeability lazily; we may need to retry once
|
|
34
|
+
let fullPr = (await octokit.pulls.get({ owner, repo, pull_number: prNumber })).data;
|
|
35
|
+
// If mergeability is still being computed (null), wait briefly and retry
|
|
36
|
+
if (fullPr.mergeable === null) {
|
|
37
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
38
|
+
fullPr = (await octokit.pulls.get({ owner, repo, pull_number: prNumber })).data;
|
|
39
|
+
}
|
|
40
|
+
const hasConflict = fullPr.mergeable === false && fullPr.mergeable_state === 'dirty';
|
|
41
|
+
if (!hasConflict) {
|
|
42
|
+
process.stdout.write(chalk.green('ā
No conflict\n'));
|
|
43
|
+
cleanCount++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
// Check if we already posted a conflict warning on this PR
|
|
47
|
+
const { data: comments } = await octokit.issues.listComments({
|
|
48
|
+
owner, repo, issue_number: prNumber,
|
|
49
|
+
});
|
|
50
|
+
const alreadyWarned = comments.some((c) => c.body?.includes(SIG_CONFLICT));
|
|
51
|
+
if (alreadyWarned) {
|
|
52
|
+
process.stdout.write(chalk.dim('ā© Already warned\n'));
|
|
53
|
+
skippedCount++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// Post the conflict warning comment
|
|
57
|
+
const conflictingFiles = fullPr.mergeable_state === 'dirty'
|
|
58
|
+
? '\n\nTo see which files are conflicting, open the PR on GitHub and click **"Resolve conflicts"**.'
|
|
59
|
+
: '';
|
|
60
|
+
const message = [
|
|
61
|
+
`ā ļø **Merge Conflict Detected** ā Hi @${author}!`,
|
|
62
|
+
``,
|
|
63
|
+
`Your branch **\`${fullPr.head.ref}\`** has conflicts with the base branch **\`${fullPr.base.ref}\`** that must be resolved before this PR can be merged.`,
|
|
64
|
+
``,
|
|
65
|
+
`**How to fix it:**`,
|
|
66
|
+
`\`\`\`bash`,
|
|
67
|
+
`# 1. Pull the latest base branch`,
|
|
68
|
+
`git checkout ${fullPr.base.ref}`,
|
|
69
|
+
`git pull origin ${fullPr.base.ref}`,
|
|
70
|
+
``,
|
|
71
|
+
`# 2. Switch to your branch and merge/rebase`,
|
|
72
|
+
`git checkout ${fullPr.head.ref}`,
|
|
73
|
+
`git merge ${fullPr.base.ref}`,
|
|
74
|
+
``,
|
|
75
|
+
`# 3. Resolve the conflicts, then commit and push`,
|
|
76
|
+
`git add .`,
|
|
77
|
+
`git commit -m "fix: resolve merge conflicts"`,
|
|
78
|
+
`git push origin ${fullPr.head.ref}`,
|
|
79
|
+
`\`\`\``,
|
|
80
|
+
conflictingFiles,
|
|
81
|
+
``,
|
|
82
|
+
`If you need help resolving conflicts, check out the [GitHub guide on resolving conflicts](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/resolving-a-merge-conflict-using-the-command-line).`,
|
|
83
|
+
``,
|
|
84
|
+
`${SIG_CONFLICT}`,
|
|
85
|
+
].join('\n');
|
|
86
|
+
await octokit.issues.createComment({
|
|
87
|
+
owner, repo, issue_number: prNumber, body: message,
|
|
88
|
+
});
|
|
89
|
+
process.stdout.write(chalk.red('š“ Conflict ā warning posted\n'));
|
|
90
|
+
conflictCount++;
|
|
91
|
+
}
|
|
92
|
+
console.log(chalk.bold(`\n⨠Conflict scan complete.`));
|
|
93
|
+
console.log(chalk.dim(` š Conflicts warned: ${conflictCount} | Already warned: ${skippedCount} | Clean: ${cleanCount}\n`));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
console.error(chalk.red(`\nā Error during conflict scan: ${error.message}`));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Standalone entry point (npx tsx src/conflict-checker.ts)
|
|
101
|
+
if (process.argv[1]?.endsWith('conflict-checker.ts') || process.argv[1]?.endsWith('conflict-checker.js')) {
|
|
102
|
+
checkConflicts();
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gitpadi",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "GitPadi ā AI-powered GitHub & GitLab management CLI. Fork repos, manage issues & PRs, score contributors, grade assignments, and automate everything. Powered by Anthropic Claude via GitLab Duo Agent Platform.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.ts
CHANGED
|
@@ -32,6 +32,7 @@ import * as applyForIssue from './commands/apply-for-issue.js';
|
|
|
32
32
|
import { runBountyHunter } from './commands/bounty-hunter.js';
|
|
33
33
|
import { dripsMenu } from './commands/drips.js';
|
|
34
34
|
import { remindContributors } from './remind-contributors.js';
|
|
35
|
+
import { checkConflicts } from './conflict-checker.js';
|
|
35
36
|
import * as gitlabIssues from './commands/gitlab-issues.js';
|
|
36
37
|
import * as gitlabMRs from './commands/gitlab-mrs.js';
|
|
37
38
|
import * as gitlabPipelines from './commands/gitlab-pipelines.js';
|
|
@@ -1122,6 +1123,7 @@ async function maintainerMenu() {
|
|
|
1122
1123
|
{ name: `${red('š')} ${bold('Releases')} ${dim('ā create, list, tag releases')}`, value: 'releases' },
|
|
1123
1124
|
{ name: `${green('šÆ')} ${bold('Bounty Hunter')} ${dim('ā auto-apply to Drips Wave & GrantFox')}`, value: 'hunt' },
|
|
1124
1125
|
{ name: `${cyan('š')} ${bold('Remind Contributors')} ${dim('ā warn assignees with no PR (12h)')}`, value: 'remind' },
|
|
1126
|
+
{ name: `${red('āļø')} ${bold('Conflict Checker')} ${dim('ā warn PRs with merge conflicts')}`, value: 'conflicts' },
|
|
1125
1127
|
new inquirer.Separator(dim(' āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā')),
|
|
1126
1128
|
{ name: `${dim('āļø')} ${dim('Switch Repo')}`, value: 'switch' },
|
|
1127
1129
|
{ name: `${dim('ā¬
')} ${dim('Back to Mode Selector')}`, value: 'back' },
|
|
@@ -1144,6 +1146,7 @@ async function maintainerMenu() {
|
|
|
1144
1146
|
else if (category === 'releases') await safeMenu(releaseMenu);
|
|
1145
1147
|
else if (category === 'hunt') await runBountyHunter({ platform: 'all', maxApplications: 3, dryRun: false });
|
|
1146
1148
|
else if (category === 'remind') await safeMenu(remindContributors);
|
|
1149
|
+
else if (category === 'conflicts') await safeMenu(checkConflicts);
|
|
1147
1150
|
else if (category === 'review-merge') {
|
|
1148
1151
|
await ensureTargetRepo();
|
|
1149
1152
|
const { prNum } = await ask([{ type: 'input', name: 'prNum', message: cyan('PR number to review:') }]);
|
|
@@ -1615,6 +1618,8 @@ async function prMenu() {
|
|
|
1615
1618
|
{ name: ` ${green('āø')} List pull requests`, value: 'list' },
|
|
1616
1619
|
{ name: ` ${green('āø')} Merge PR ${dim('(checks CI first)')}`, value: 'merge' },
|
|
1617
1620
|
{ name: ` ${yellow('āø')} Force merge ${dim('(skip CI checks)')}`, value: 'force-merge' },
|
|
1621
|
+
{ name: ` ${green('āø')} Merge ALL open PRs ${dim('(checks CI per PR)')}`, value: 'merge-all' },
|
|
1622
|
+
{ name: ` ${yellow('āø')} Force merge ALL open PRs ${dim('(skip CI)')}`, value: 'force-merge-all' },
|
|
1618
1623
|
{ name: ` ${red('āø')} Close PR`, value: 'close' },
|
|
1619
1624
|
{ name: ` ${yellow('āø')} Auto-review PR`, value: 'review' },
|
|
1620
1625
|
{ name: ` ${green('āø')} Approve PR`, value: 'approve' },
|
|
@@ -1630,6 +1635,18 @@ async function prMenu() {
|
|
|
1630
1635
|
{ type: 'input', name: 'limit', message: dim('Max results:'), default: '50' },
|
|
1631
1636
|
]);
|
|
1632
1637
|
await prs.listPRs({ state: a.state, limit: parseInt(a.limit) });
|
|
1638
|
+
} else if (action === 'merge-all' || action === 'force-merge-all') {
|
|
1639
|
+
const a = await ask([
|
|
1640
|
+
{
|
|
1641
|
+
type: 'list', name: 'method', message: 'Merge method:', choices: [
|
|
1642
|
+
{ name: `${green('squash')} ${dim('ā clean single commit')}`, value: 'squash' },
|
|
1643
|
+
{ name: `${cyan('merge')} ${dim('ā preserve all commits')}`, value: 'merge' },
|
|
1644
|
+
{ name: `${yellow('rebase')} ${dim('ā linear history')}`, value: 'rebase' },
|
|
1645
|
+
]
|
|
1646
|
+
},
|
|
1647
|
+
{ type: 'confirm', name: 'confirm', message: action === 'force-merge-all' ? yellow('ā ļø Force merge ALL open PRs (skip CI)?') : red('ā ļø Merge ALL open PRs?'), default: false },
|
|
1648
|
+
]);
|
|
1649
|
+
if (a.confirm) await prs.mergeAllPRs({ method: a.method, force: action === 'force-merge-all' });
|
|
1633
1650
|
} else if (action === 'merge' || action === 'force-merge') {
|
|
1634
1651
|
const a = await ask([
|
|
1635
1652
|
{ type: 'input', name: 'n', message: yellow('PR #') + dim(' (q=back):') },
|
|
@@ -1869,6 +1886,8 @@ function setupCommander(): Command {
|
|
|
1869
1886
|
.action(async (o) => { await prs.listPRs({ state: o.state, limit: parseInt(o.limit) }); });
|
|
1870
1887
|
p.command('merge <n>').option('-m, --method <m>', '', 'squash').option('--message <msg>').option('-f, --force', 'Skip CI checks')
|
|
1871
1888
|
.action(async (n, o) => { await prs.mergePR(parseInt(n), o); });
|
|
1889
|
+
p.command('merge-all').option('-m, --method <m>', '', 'squash').option('-f, --force', 'Skip CI checks')
|
|
1890
|
+
.action(async (o) => { await prs.mergeAllPRs(o); });
|
|
1872
1891
|
p.command('close <n>').action(async (n) => { await prs.closePR(parseInt(n)); });
|
|
1873
1892
|
p.command('review <n>').action(async (n) => { await prs.reviewPR(parseInt(n)); });
|
|
1874
1893
|
p.command('approve <n>').action(async (n) => { await prs.approvePR(parseInt(n)); });
|
package/src/commands/prs.ts
CHANGED
|
@@ -152,6 +152,101 @@ export async function mergePR(number: number, opts: { method?: string; message?:
|
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
|
|
155
|
+
export async function mergeAllPRs(opts: { method?: string; force?: boolean }) {
|
|
156
|
+
requireRepo();
|
|
157
|
+
const octokit = getOctokit();
|
|
158
|
+
const method = (opts.method || 'squash') as 'merge' | 'squash' | 'rebase';
|
|
159
|
+
|
|
160
|
+
const spinner = ora(`Fetching open PRs from ${chalk.cyan(getFullRepo())}...`).start();
|
|
161
|
+
let openPRs: any[] = [];
|
|
162
|
+
try {
|
|
163
|
+
const { data } = await octokit.pulls.list({ owner: getOwner(), repo: getRepo(), state: 'open', per_page: 100 });
|
|
164
|
+
openPRs = data;
|
|
165
|
+
spinner.stop();
|
|
166
|
+
} catch (e: any) { spinner.fail(e.message); return; }
|
|
167
|
+
|
|
168
|
+
if (openPRs.length === 0) { console.log(chalk.yellow('\n No open PRs found.\n')); return; }
|
|
169
|
+
|
|
170
|
+
console.log(`\n${chalk.bold(`š Merge All Open PRs ā ${getFullRepo()}`)} (${openPRs.length} PRs)\n`);
|
|
171
|
+
|
|
172
|
+
const results: Array<{ number: number; title: string; status: 'merged' | 'failed' | 'skipped'; reason?: string }> = [];
|
|
173
|
+
|
|
174
|
+
for (const pr of openPRs) {
|
|
175
|
+
console.log(chalk.dim(` āāā PR #${pr.number}: ${pr.title.substring(0, 60)} āāā`));
|
|
176
|
+
try {
|
|
177
|
+
if (opts.force) {
|
|
178
|
+
const s = ora(` Force merging #${pr.number}...`).start();
|
|
179
|
+
const { data } = await octokit.pulls.merge({
|
|
180
|
+
owner: getOwner(), repo: getRepo(), pull_number: pr.number,
|
|
181
|
+
merge_method: method,
|
|
182
|
+
});
|
|
183
|
+
if (data.merged) { s.succeed(` Merged #${pr.number} ${chalk.yellow('(CI skipped)')}`); results.push({ number: pr.number, title: pr.title, status: 'merged' }); }
|
|
184
|
+
else { s.fail(` Could not merge #${pr.number}: ${data.message}`); results.push({ number: pr.number, title: pr.title, status: 'failed', reason: data.message }); }
|
|
185
|
+
} else {
|
|
186
|
+
// Check CI
|
|
187
|
+
const s = ora(` Checking CI for #${pr.number}...`).start();
|
|
188
|
+
const sha = pr.head.sha;
|
|
189
|
+
let attempts = 0;
|
|
190
|
+
let allComplete = false;
|
|
191
|
+
let ciChecks: Array<{ name: string; status: string; conclusion: string | null }> = [];
|
|
192
|
+
|
|
193
|
+
while (!allComplete && attempts < 90) {
|
|
194
|
+
const { data: checkRuns } = await octokit.checks.listForRef({ owner: getOwner(), repo: getRepo(), ref: sha, per_page: 100 });
|
|
195
|
+
const { data: statusData } = await octokit.repos.getCombinedStatusForRef({ owner: getOwner(), repo: getRepo(), ref: sha });
|
|
196
|
+
ciChecks = checkRuns.check_runs.map((c) => ({ name: c.name, status: c.status, conclusion: c.conclusion }));
|
|
197
|
+
statusData.statuses.forEach((st) => {
|
|
198
|
+
if (!ciChecks.find((c) => c.name === st.context))
|
|
199
|
+
ciChecks.push({ name: st.context, status: st.state === 'pending' ? 'in_progress' : 'completed', conclusion: st.state === 'pending' ? null : st.state });
|
|
200
|
+
});
|
|
201
|
+
if (ciChecks.length === 0) break;
|
|
202
|
+
allComplete = ciChecks.every((c) => c.status === 'completed');
|
|
203
|
+
if (!allComplete) { s.text = chalk.dim(` Waiting for CI on #${pr.number}... (${attempts * 10}s)`); await new Promise((r) => setTimeout(r, 10000)); attempts++; }
|
|
204
|
+
}
|
|
205
|
+
s.stop();
|
|
206
|
+
|
|
207
|
+
if (!allComplete) {
|
|
208
|
+
console.log(chalk.yellow(` ā ļø CI still running for #${pr.number} after 15 min ā skipping.\n`));
|
|
209
|
+
results.push({ number: pr.number, title: pr.title, status: 'skipped', reason: 'CI timeout' });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const allPassed = ciChecks.length === 0 || ciChecks.every((c) =>
|
|
214
|
+
c.conclusion === 'success' || c.conclusion === 'neutral' || c.conclusion === 'skipped');
|
|
215
|
+
|
|
216
|
+
if (!allPassed) {
|
|
217
|
+
console.log(chalk.red(` ā CI failed for #${pr.number} ā skipping.\n`));
|
|
218
|
+
results.push({ number: pr.number, title: pr.title, status: 'skipped', reason: 'CI failed' });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ms = ora(` Merging #${pr.number}...`).start();
|
|
223
|
+
const { data } = await octokit.pulls.merge({ owner: getOwner(), repo: getRepo(), pull_number: pr.number, merge_method: method });
|
|
224
|
+
if (data.merged) { ms.succeed(` Merged #${pr.number}`); results.push({ number: pr.number, title: pr.title, status: 'merged' }); }
|
|
225
|
+
else { ms.fail(` Could not merge #${pr.number}: ${data.message}`); results.push({ number: pr.number, title: pr.title, status: 'failed', reason: data.message }); }
|
|
226
|
+
}
|
|
227
|
+
} catch (e: any) {
|
|
228
|
+
console.error(chalk.red(` ā #${pr.number}: ${e.message}`));
|
|
229
|
+
results.push({ number: pr.number, title: pr.title, status: 'failed', reason: e.message });
|
|
230
|
+
}
|
|
231
|
+
console.log('');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Summary table
|
|
235
|
+
const table = new Table({
|
|
236
|
+
head: ['#', 'Title', 'Result'].map((h) => chalk.cyan(h)),
|
|
237
|
+
style: { head: [], border: [] },
|
|
238
|
+
});
|
|
239
|
+
results.forEach((r) => {
|
|
240
|
+
const icon = r.status === 'merged' ? chalk.green('ā
merged') : r.status === 'skipped' ? chalk.yellow(`āļø skipped${r.reason ? ` (${r.reason})` : ''}`) : chalk.red(`ā failed${r.reason ? ` (${r.reason})` : ''}`);
|
|
241
|
+
table.push([`#${r.number}`, r.title.substring(0, 50), icon]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const merged = results.filter((r) => r.status === 'merged').length;
|
|
245
|
+
console.log(`${chalk.bold('š Summary')}\n`);
|
|
246
|
+
console.log(table.toString());
|
|
247
|
+
console.log(`\n ${chalk.green(`${merged} merged`)}, ${chalk.yellow(`${results.filter((r) => r.status === 'skipped').length} skipped`)}, ${chalk.red(`${results.filter((r) => r.status === 'failed').length} failed`)}\n`);
|
|
248
|
+
}
|
|
249
|
+
|
|
155
250
|
export async function closePR(number: number) {
|
|
156
251
|
requireRepo();
|
|
157
252
|
const spinner = ora(`Closing PR #${number}...`).start();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { initGitHub, getOctokit, getOwner, getRepo } from './core/github.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
// Signature marker to prevent posting duplicate conflict warnings on the same PR
|
|
5
|
+
const SIG_CONFLICT = '<!-- gitpadi-conflict-warning -->';
|
|
6
|
+
|
|
7
|
+
export async function checkConflicts() {
|
|
8
|
+
console.log(chalk.bold('\nāļø GitPadi Conflict Checker\n'));
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
initGitHub();
|
|
12
|
+
const octokit = getOctokit();
|
|
13
|
+
const owner = getOwner();
|
|
14
|
+
const repo = getRepo();
|
|
15
|
+
|
|
16
|
+
if (!owner || !repo) {
|
|
17
|
+
console.error(chalk.red('ā Owner or Repo not found in environment/config.'));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
console.log(chalk.dim(`š Scanning open PRs in ${owner}/${repo} for merge conflicts...\n`));
|
|
22
|
+
|
|
23
|
+
// Fetch all open PRs
|
|
24
|
+
const { data: prs } = await octokit.pulls.list({
|
|
25
|
+
owner, repo, state: 'open', per_page: 100,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (prs.length === 0) {
|
|
29
|
+
console.log(chalk.green('ā
No open PRs found.'));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let conflictCount = 0;
|
|
34
|
+
let skippedCount = 0;
|
|
35
|
+
let cleanCount = 0;
|
|
36
|
+
|
|
37
|
+
for (const pr of prs) {
|
|
38
|
+
const prNumber = pr.number;
|
|
39
|
+
const author = pr.user?.login ?? 'unknown';
|
|
40
|
+
|
|
41
|
+
process.stdout.write(chalk.cyan(` āø PR #${prNumber}: "${pr.title.substring(0, 50)}" (@${author}) ā `));
|
|
42
|
+
|
|
43
|
+
// Fetch the full PR to get the mergeable field
|
|
44
|
+
// GitHub computes mergeability lazily; we may need to retry once
|
|
45
|
+
let fullPr = (await octokit.pulls.get({ owner, repo, pull_number: prNumber })).data;
|
|
46
|
+
|
|
47
|
+
// If mergeability is still being computed (null), wait briefly and retry
|
|
48
|
+
if (fullPr.mergeable === null) {
|
|
49
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
50
|
+
fullPr = (await octokit.pulls.get({ owner, repo, pull_number: prNumber })).data;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const hasConflict = fullPr.mergeable === false && fullPr.mergeable_state === 'dirty';
|
|
54
|
+
|
|
55
|
+
if (!hasConflict) {
|
|
56
|
+
process.stdout.write(chalk.green('ā
No conflict\n'));
|
|
57
|
+
cleanCount++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check if we already posted a conflict warning on this PR
|
|
62
|
+
const { data: comments } = await octokit.issues.listComments({
|
|
63
|
+
owner, repo, issue_number: prNumber,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const alreadyWarned = comments.some((c) => c.body?.includes(SIG_CONFLICT));
|
|
67
|
+
|
|
68
|
+
if (alreadyWarned) {
|
|
69
|
+
process.stdout.write(chalk.dim('ā© Already warned\n'));
|
|
70
|
+
skippedCount++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Post the conflict warning comment
|
|
75
|
+
const conflictingFiles = fullPr.mergeable_state === 'dirty'
|
|
76
|
+
? '\n\nTo see which files are conflicting, open the PR on GitHub and click **"Resolve conflicts"**.'
|
|
77
|
+
: '';
|
|
78
|
+
|
|
79
|
+
const message = [
|
|
80
|
+
`ā ļø **Merge Conflict Detected** ā Hi @${author}!`,
|
|
81
|
+
``,
|
|
82
|
+
`Your branch **\`${fullPr.head.ref}\`** has conflicts with the base branch **\`${fullPr.base.ref}\`** that must be resolved before this PR can be merged.`,
|
|
83
|
+
``,
|
|
84
|
+
`**How to fix it:**`,
|
|
85
|
+
`\`\`\`bash`,
|
|
86
|
+
`# 1. Pull the latest base branch`,
|
|
87
|
+
`git checkout ${fullPr.base.ref}`,
|
|
88
|
+
`git pull origin ${fullPr.base.ref}`,
|
|
89
|
+
``,
|
|
90
|
+
`# 2. Switch to your branch and merge/rebase`,
|
|
91
|
+
`git checkout ${fullPr.head.ref}`,
|
|
92
|
+
`git merge ${fullPr.base.ref}`,
|
|
93
|
+
``,
|
|
94
|
+
`# 3. Resolve the conflicts, then commit and push`,
|
|
95
|
+
`git add .`,
|
|
96
|
+
`git commit -m "fix: resolve merge conflicts"`,
|
|
97
|
+
`git push origin ${fullPr.head.ref}`,
|
|
98
|
+
`\`\`\``,
|
|
99
|
+
conflictingFiles,
|
|
100
|
+
``,
|
|
101
|
+
`If you need help resolving conflicts, check out the [GitHub guide on resolving conflicts](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/addressing-merge-conflicts/resolving-a-merge-conflict-using-the-command-line).`,
|
|
102
|
+
``,
|
|
103
|
+
`${SIG_CONFLICT}`,
|
|
104
|
+
].join('\n');
|
|
105
|
+
|
|
106
|
+
await octokit.issues.createComment({
|
|
107
|
+
owner, repo, issue_number: prNumber, body: message,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
process.stdout.write(chalk.red('š“ Conflict ā warning posted\n'));
|
|
111
|
+
conflictCount++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
console.log(chalk.bold(`\n⨠Conflict scan complete.`));
|
|
115
|
+
console.log(chalk.dim(` š Conflicts warned: ${conflictCount} | Already warned: ${skippedCount} | Clean: ${cleanCount}\n`));
|
|
116
|
+
|
|
117
|
+
} catch (error: any) {
|
|
118
|
+
console.error(chalk.red(`\nā Error during conflict scan: ${error.message}`));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Standalone entry point (npx tsx src/conflict-checker.ts)
|
|
124
|
+
if (process.argv[1]?.endsWith('conflict-checker.ts') || process.argv[1]?.endsWith('conflict-checker.js')) {
|
|
125
|
+
checkConflicts();
|
|
126
|
+
}
|