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 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)); });
@@ -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.1.8",
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)); });
@@ -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
+ }