@jojonax/codex-copilot 1.5.5 → 1.6.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.
@@ -1,486 +1,486 @@
1
- /**
2
- * GitHub operations module - PR and Review management via gh CLI
3
- */
4
-
5
- import { execSync } from 'child_process';
6
- import { log } from './logger.js';
7
-
8
- function gh(cmd, cwd) {
9
- return execSync(`gh ${cmd}`, {
10
- cwd,
11
- encoding: 'utf-8',
12
- stdio: ['pipe', 'pipe', 'pipe'],
13
- timeout: 30000, // 30s timeout to prevent hanging on network issues
14
- }).trim();
15
- }
16
-
17
- function ghSafe(cmd, cwd) {
18
- try {
19
- return { ok: true, output: gh(cmd, cwd) };
20
- } catch (err) {
21
- return { ok: false, output: err.stderr || err.message };
22
- }
23
- }
24
-
25
- function ghJSON(cmd, cwd) {
26
- const output = gh(cmd, cwd);
27
- if (!output) return null;
28
- try {
29
- return JSON.parse(output);
30
- } catch {
31
- return null;
32
- }
33
- }
34
-
35
- function ghJSONSafe(cmd, cwd) {
36
- try {
37
- return ghJSON(cmd, cwd);
38
- } catch {
39
- return null;
40
- }
41
- }
42
-
43
- function shellEscape(str) {
44
- return `'${str.replace(/'/g, "'\\''")}'`
45
- }
46
-
47
- /**
48
- * Check if gh CLI is authenticated
49
- */
50
- export function checkGhAuth() {
51
- try {
52
- execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe' });
53
- return true;
54
- } catch {
55
- return false;
56
- }
57
- }
58
-
59
- /**
60
- * Ensure the base branch exists on remote.
61
- * If not, push it first.
62
- */
63
- export function ensureRemoteBranch(cwd, branch) {
64
- try {
65
- const result = execSync(
66
- `git ls-remote --heads origin ${branch}`,
67
- { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
68
- ).trim();
69
- if (!result) {
70
- // Branch doesn't exist on remote, push it
71
- log.info(`Base branch '${branch}' not found on remote, pushing...`);
72
- execSync(`git push origin ${branch}`, {
73
- cwd,
74
- encoding: 'utf-8',
75
- stdio: ['pipe', 'pipe', 'pipe'],
76
- });
77
- log.info(`Base branch '${branch}' pushed to remote`);
78
- }
79
- return true;
80
- } catch (err) {
81
- log.warn(`Failed to verify remote branch '${branch}': ${err.message}`);
82
- return false;
83
- }
84
- }
85
-
86
- /**
87
- * Check if there are commits between base and head branches
88
- */
89
- export function hasCommitsBetween(cwd, base, head) {
90
- try {
91
- const result = execSync(
92
- `git log ${base}..${head} --oneline`,
93
- { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
94
- ).trim();
95
- return result.length > 0;
96
- } catch {
97
- // If comparison fails (e.g., branches diverged), assume there are commits
98
- return true;
99
- }
100
- }
101
-
102
- /**
103
- * Find an existing PR for a given head branch
104
- * @returns {{ number: number, url: string } | null}
105
- */
106
- export function findExistingPR(cwd, head) {
107
- try {
108
- const prs = ghJSON(
109
- `pr list --head ${shellEscape(head)} --json number,url --limit 1`,
110
- cwd
111
- );
112
- if (prs && prs.length > 0) {
113
- return { number: prs[0].number, url: prs[0].url || '' };
114
- }
115
- return null;
116
- } catch {
117
- return null;
118
- }
119
- }
120
-
121
- /**
122
- * Create a pull request with pre-checks and auto-recovery
123
- */
124
- export function createPR(cwd, { title, body, base = 'main', head }) {
125
- // Pre-check: ensure base branch exists on remote
126
- ensureRemoteBranch(cwd, base);
127
-
128
- const output = gh(
129
- `pr create --title ${shellEscape(title)} --body ${shellEscape(body)} --base ${shellEscape(base)} --head ${shellEscape(head)}`,
130
- cwd
131
- );
132
- // Extract PR URL and number from output
133
- const urlMatch = output.match(/https:\/\/github\.com\/.+\/pull\/(\d+)/);
134
- if (urlMatch) {
135
- return { url: urlMatch[0], number: parseInt(urlMatch[1]) };
136
- }
137
- // May already exist
138
- const existingMatch = output.match(/already exists.+\/pull\/(\d+)/);
139
- if (existingMatch) {
140
- return { url: output, number: parseInt(existingMatch[1]) };
141
- }
142
- throw new Error(`Failed to create PR: ${output}`);
143
- }
144
-
145
- /**
146
- * Create PR with full auto-recovery: pre-checks → create → fallback to find existing
147
- * @returns {{ number: number, url: string }}
148
- */
149
- export function createPRWithRecovery(cwd, { title, body, base = 'main', head }) {
150
- // Step 1: Try to create the PR
151
- try {
152
- return createPR(cwd, { title, body, base, head });
153
- } catch (err) {
154
- log.warn(`PR creation failed: ${err.message}`);
155
- }
156
-
157
- // Step 2: Auto-find existing PR for this branch
158
- log.info('Searching for existing PR...');
159
- const existing = findExistingPR(cwd, head);
160
- if (existing) {
161
- log.info(`Found existing PR #${existing.number}`);
162
- return existing;
163
- }
164
-
165
- // Step 3: Check if there are actually commits to create a PR for
166
- if (!hasCommitsBetween(cwd, base, head)) {
167
- log.warn('No commits between base and head branch');
168
- log.info('This may be a new repo or empty branch. Attempting to push base branch and retry...');
169
-
170
- // Ensure both branches are pushed
171
- ensureRemoteBranch(cwd, base);
172
- ensureRemoteBranch(cwd, head);
173
-
174
- // Retry PR creation
175
- try {
176
- return createPR(cwd, { title, body, base, head });
177
- } catch (retryErr) {
178
- log.warn(`PR retry also failed: ${retryErr.message}`);
179
- }
180
-
181
- // Last resort: try to find PR again
182
- const retryExisting = findExistingPR(cwd, head);
183
- if (retryExisting) {
184
- log.info(`Found existing PR #${retryExisting.number}`);
185
- return retryExisting;
186
- }
187
- }
188
-
189
- // Step 4: If all else fails, throw with actionable message
190
- throw new Error(
191
- `Cannot create or find PR for branch '${head}'. ` +
192
- `Possible causes: base branch '${base}' may not exist on remote, ` +
193
- `or there are no commits between '${base}' and '${head}'. ` +
194
- `Try: git push origin ${base} && git push origin ${head}`
195
- );
196
- }
197
-
198
- /**
199
- * Get PR review list
200
- */
201
- export function getReviews(cwd, prNumber) {
202
- try {
203
- const num = validatePRNumber(prNumber);
204
- return ghJSON(`api repos/{owner}/{repo}/pulls/${num}/reviews`, cwd) || [];
205
- } catch {
206
- return [];
207
- }
208
- }
209
-
210
- /**
211
- * Get PR review comments
212
- */
213
- export function getReviewComments(cwd, prNumber) {
214
- try {
215
- const num = validatePRNumber(prNumber);
216
- return ghJSON(`api repos/{owner}/{repo}/pulls/${num}/comments`, cwd) || [];
217
- } catch {
218
- return [];
219
- }
220
- }
221
-
222
- /**
223
- * Get PR issue comments (including bot comments)
224
- */
225
- export function getIssueComments(cwd, prNumber) {
226
- try {
227
- const num = validatePRNumber(prNumber);
228
- return ghJSON(`api repos/{owner}/{repo}/issues/${num}/comments`, cwd) || [];
229
- } catch {
230
- return [];
231
- }
232
- }
233
-
234
- /**
235
- * Check the latest review state
236
- * @returns {'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'PENDING' | null}
237
- */
238
- export function getLatestReviewState(cwd, prNumber) {
239
- const reviews = getReviews(cwd, prNumber);
240
- if (!reviews || reviews.length === 0) return null;
241
-
242
- // Filter out PENDING and DISMISSED
243
- const active = reviews.filter(r => r.state !== 'PENDING' && r.state !== 'DISMISSED');
244
- if (active.length === 0) return null;
245
-
246
- return active[active.length - 1].state;
247
- }
248
-
249
- /**
250
- * Merge a pull request
251
- */
252
- export function mergePR(cwd, prNumber, method = 'squash') {
253
- const num = validatePRNumber(prNumber);
254
- const validMethods = ['squash', 'merge', 'rebase'];
255
- if (!validMethods.includes(method)) {
256
- throw new Error(`Invalid merge method: ${method}`);
257
- }
258
- gh(`pr merge ${num} --${method} --delete-branch`, cwd);
259
- }
260
-
261
- function validatePRNumber(prNumber) {
262
- const num = parseInt(prNumber, 10);
263
- if (isNaN(num) || num <= 0) throw new Error(`Invalid PR number: ${prNumber}`);
264
- return num;
265
- }
266
-
267
- /**
268
- * Collect all review feedback as structured text.
269
- * Returns raw feedback — classification is done by AI provider.
270
- */
271
- export function collectReviewFeedback(cwd, prNumber) {
272
- const reviews = getReviews(cwd, prNumber);
273
- const comments = getReviewComments(cwd, prNumber);
274
- const issueComments = getIssueComments(cwd, prNumber);
275
-
276
- let feedback = '';
277
-
278
- // Review body text (skip APPROVED — already handled by state check)
279
- for (const r of reviews) {
280
- if (r.body && r.body.trim() && r.state !== 'APPROVED') {
281
- feedback += `### Review (${r.state})\n${r.body}\n\n`;
282
- }
283
- }
284
-
285
- // Inline code comments (comments on specific diff lines)
286
- for (const c of comments) {
287
- feedback += `### ${c.path}:L${c.line || c.original_line}\n${c.body}\n\n`;
288
- }
289
-
290
- // Bot comments
291
- for (const c of issueComments) {
292
- if (c.user?.type === 'Bot' || c.user?.login?.includes('bot')) {
293
- feedback += `### Bot Review (${c.user.login})\n${c.body}\n\n`;
294
- }
295
- }
296
-
297
- return feedback.trim();
298
- }
299
-
300
- /**
301
- * Check if the current repo is private
302
- * @returns {boolean} true if private, false if public or unknown
303
- */
304
- export function isPrivateRepo(cwd) {
305
- try {
306
- const result = ghJSON('repo view --json isPrivate', cwd);
307
- return result?.isPrivate === true;
308
- } catch {
309
- return false; // Default to public behavior if detection fails
310
- }
311
- }
312
-
313
- /**
314
- * Request bots to re-review the PR.
315
- * Auto-detects which review bots have previously commented on the PR
316
- * and triggers only those. Falls back to all known bot triggers.
317
- */
318
- export function requestReReview(cwd, prNumber) {
319
- // Known review bots and their re-review trigger commands
320
- const REVIEW_BOTS = [
321
- { login: 'gemini-code-assist', trigger: '/gemini review' },
322
- { login: 'coderabbitai', trigger: '@coderabbitai review' },
323
- { login: 'sourcery-ai', trigger: '@sourcery-ai review' },
324
- { login: 'deepsource', trigger: '@deepsource review' },
325
- { login: 'sweep-ai', trigger: 'sweep: review' },
326
- { login: 'openai-codex', trigger: '@codex review' },
327
- { login: 'claude', trigger: '@claude review' },
328
- { login: 'copilot', trigger: '@copilot review' },
329
- ];
330
-
331
- try {
332
- const num = validatePRNumber(prNumber);
333
-
334
- // Detect which bots have previously interacted with this PR
335
- const reviews = getReviews(cwd, num);
336
- const comments = getIssueComments(cwd, num);
337
- const reviewComments = getReviewComments(cwd, num);
338
-
339
- const allLogins = new Set();
340
- for (const r of reviews) if (r.user?.login) allLogins.add(r.user.login);
341
- for (const c of comments) if (c.user?.login) allLogins.add(c.user.login);
342
- for (const c of reviewComments) if (c.user?.login) allLogins.add(c.user.login);
343
-
344
- // Find matching bots that have been active on this PR
345
- const activeBots = REVIEW_BOTS.filter(bot =>
346
- [...allLogins].some(login => login.includes(bot.login))
347
- );
348
-
349
- // Determine which triggers to fire
350
- const triggers = activeBots.length > 0
351
- ? activeBots.filter(b => b.trigger).map(b => b.trigger)
352
- : REVIEW_BOTS.filter(b => b.trigger).map(b => b.trigger).slice(0, 1); // fallback: try gemini
353
-
354
- // Fire triggers as separate comments
355
- let triggered = 0;
356
- for (const trigger of triggers) {
357
- try {
358
- gh(`pr comment ${num} --body "${trigger}"`, cwd);
359
- triggered++;
360
- } catch { /* individual trigger failure is non-critical */ }
361
- }
362
-
363
- // Also try to request re-review from previous human reviewers via GitHub API
364
- try {
365
- const prReviews = ghJSON(`pr view ${num} --json reviews --jq '.reviews[].author.login'`, cwd);
366
- if (prReviews && typeof prReviews === 'string') {
367
- const logins = [...new Set(prReviews.trim().split('\n').filter(l => l && !l.includes('[bot]')))];
368
- if (logins.length > 0) {
369
- const owner = execSync('gh repo view --json owner --jq .owner.login', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
370
- const repo = execSync('gh repo view --json name --jq .name', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
371
- execSync(`gh api repos/${owner}/${repo}/pulls/${num}/requested_reviewers -f "reviewers[]=${logins[0]}" -X POST`, {
372
- cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
373
- });
374
- }
375
- }
376
- } catch {
377
- // Non-critical: human re-review request failed
378
- }
379
-
380
- return triggered > 0;
381
- } catch {
382
- return false;
383
- }
384
- }
385
-
386
- /**
387
- * C4: Verify GitHub CLI authentication is valid.
388
- * Checks both auth status and token expiry.
389
- * @returns {{ ok: boolean, user: string|null, error: string|null }}
390
- */
391
- export function ensureAuth(cwd) {
392
- try {
393
- const output = execSync('gh auth status 2>&1', {
394
- cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000,
395
- });
396
- const userMatch = output.match(/Logged in to .+ as (\S+)/);
397
- return { ok: true, user: userMatch?.[1] || 'unknown', error: null };
398
- } catch (err) {
399
- const msg = err.stderr || err.message || '';
400
- if (msg.includes('not logged in') || msg.includes('authentication')) {
401
- return { ok: false, user: null, error: 'Not authenticated. Run: gh auth login' };
402
- }
403
- if (msg.includes('token') && msg.includes('expir')) {
404
- return { ok: false, user: null, error: 'Token expired. Run: gh auth refresh' };
405
- }
406
- return { ok: false, user: null, error: `Auth check failed: ${msg.substring(0, 100)}` };
407
- }
408
- }
409
-
410
- /**
411
- * C2/C5: Check network connectivity to the git remote.
412
- * Verifies that we can reach the origin remote.
413
- * @returns {{ ok: boolean, error: string|null }}
414
- */
415
- export function checkConnectivity(cwd) {
416
- try {
417
- execSync('git ls-remote --exit-code origin HEAD', {
418
- cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000,
419
- });
420
- return { ok: true, error: null };
421
- } catch (err) {
422
- const msg = err.stderr || err.message || '';
423
- if (msg.includes('Could not resolve host') || msg.includes('Network is unreachable')) {
424
- return { ok: false, error: 'Network unreachable — check internet connection' };
425
- }
426
- if (msg.includes('Permission denied') || msg.includes('Authentication failed')) {
427
- return { ok: false, error: 'Authentication failed — check SSH keys or GH token' };
428
- }
429
- if (msg.includes('Repository not found')) {
430
- return { ok: false, error: 'Remote repository not found — verify origin URL' };
431
- }
432
- // Timeout or other error
433
- return { ok: false, error: `Cannot reach remote: ${msg.substring(0, 100)}` };
434
- }
435
- }
436
-
437
- export const github = {
438
- checkGhAuth, createPR, createPRWithRecovery, findExistingPR,
439
- ensureRemoteBranch, hasCommitsBetween,
440
- getReviews, getReviewComments, getIssueComments,
441
- getLatestReviewState, mergePR, collectReviewFeedback,
442
- isPrivateRepo, requestReReview, closePR, deleteBranch, getPRState,
443
- ensureAuth, checkConnectivity,
444
- };
445
-
446
- /**
447
- * Close a pull request without merging
448
- */
449
- export function closePR(cwd, prNumber) {
450
- try {
451
- const num = validatePRNumber(prNumber);
452
- gh(`pr close ${num}`, cwd);
453
- return true;
454
- } catch {
455
- return false;
456
- }
457
- }
458
-
459
- /**
460
- * Delete a remote branch
461
- */
462
- export function deleteBranch(cwd, branch) {
463
- try {
464
- execSync(`git push origin --delete ${branch}`, {
465
- cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
466
- });
467
- return true;
468
- } catch {
469
- return false;
470
- }
471
- }
472
-
473
- /**
474
- * Get the state of a PR: 'open', 'closed', or 'merged'
475
- */
476
- export function getPRState(cwd, prNumber) {
477
- try {
478
- const num = validatePRNumber(prNumber);
479
- const output = gh(`pr view ${num} --json state,mergedAt`, cwd);
480
- const data = JSON.parse(output);
481
- if (data.mergedAt) return 'merged';
482
- return (data.state || 'OPEN').toLowerCase();
483
- } catch {
484
- return 'unknown';
485
- }
486
- }
1
+ /**
2
+ * GitHub operations module - PR and Review management via gh CLI
3
+ */
4
+
5
+ import { execSync } from 'child_process';
6
+ import { log } from './logger.js';
7
+
8
+ function gh(cmd, cwd) {
9
+ return execSync(`gh ${cmd}`, {
10
+ cwd,
11
+ encoding: 'utf-8',
12
+ stdio: ['pipe', 'pipe', 'pipe'],
13
+ timeout: 30000, // 30s timeout to prevent hanging on network issues
14
+ }).trim();
15
+ }
16
+
17
+ function ghSafe(cmd, cwd) {
18
+ try {
19
+ return { ok: true, output: gh(cmd, cwd) };
20
+ } catch (err) {
21
+ return { ok: false, output: err.stderr || err.message };
22
+ }
23
+ }
24
+
25
+ function ghJSON(cmd, cwd) {
26
+ const output = gh(cmd, cwd);
27
+ if (!output) return null;
28
+ try {
29
+ return JSON.parse(output);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function ghJSONSafe(cmd, cwd) {
36
+ try {
37
+ return ghJSON(cmd, cwd);
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ function shellEscape(str) {
44
+ return `'${str.replace(/'/g, "'\\''")}'`
45
+ }
46
+
47
+ /**
48
+ * Check if gh CLI is authenticated
49
+ */
50
+ export function checkGhAuth() {
51
+ try {
52
+ execSync('gh auth status', { encoding: 'utf-8', stdio: 'pipe' });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Ensure the base branch exists on remote.
61
+ * If not, push it first.
62
+ */
63
+ export function ensureRemoteBranch(cwd, branch) {
64
+ try {
65
+ const result = execSync(
66
+ `git ls-remote --heads origin ${branch}`,
67
+ { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
68
+ ).trim();
69
+ if (!result) {
70
+ // Branch doesn't exist on remote, push it
71
+ log.info(`Base branch '${branch}' not found on remote, pushing...`);
72
+ execSync(`git push origin ${branch}`, {
73
+ cwd,
74
+ encoding: 'utf-8',
75
+ stdio: ['pipe', 'pipe', 'pipe'],
76
+ });
77
+ log.info(`Base branch '${branch}' pushed to remote`);
78
+ }
79
+ return true;
80
+ } catch (err) {
81
+ log.warn(`Failed to verify remote branch '${branch}': ${err.message}`);
82
+ return false;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Check if there are commits between base and head branches
88
+ */
89
+ export function hasCommitsBetween(cwd, base, head) {
90
+ try {
91
+ const result = execSync(
92
+ `git log ${base}..${head} --oneline`,
93
+ { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
94
+ ).trim();
95
+ return result.length > 0;
96
+ } catch {
97
+ // If comparison fails (e.g., branches diverged), assume there are commits
98
+ return true;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Find an existing PR for a given head branch
104
+ * @returns {{ number: number, url: string } | null}
105
+ */
106
+ export function findExistingPR(cwd, head) {
107
+ try {
108
+ const prs = ghJSON(
109
+ `pr list --head ${shellEscape(head)} --json number,url --limit 1`,
110
+ cwd
111
+ );
112
+ if (prs && prs.length > 0) {
113
+ return { number: prs[0].number, url: prs[0].url || '' };
114
+ }
115
+ return null;
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Create a pull request with pre-checks and auto-recovery
123
+ */
124
+ export function createPR(cwd, { title, body, base = 'main', head }) {
125
+ // Pre-check: ensure base branch exists on remote
126
+ ensureRemoteBranch(cwd, base);
127
+
128
+ const output = gh(
129
+ `pr create --title ${shellEscape(title)} --body ${shellEscape(body)} --base ${shellEscape(base)} --head ${shellEscape(head)}`,
130
+ cwd
131
+ );
132
+ // Extract PR URL and number from output
133
+ const urlMatch = output.match(/https:\/\/github\.com\/.+\/pull\/(\d+)/);
134
+ if (urlMatch) {
135
+ return { url: urlMatch[0], number: parseInt(urlMatch[1]) };
136
+ }
137
+ // May already exist
138
+ const existingMatch = output.match(/already exists.+\/pull\/(\d+)/);
139
+ if (existingMatch) {
140
+ return { url: output, number: parseInt(existingMatch[1]) };
141
+ }
142
+ throw new Error(`Failed to create PR: ${output}`);
143
+ }
144
+
145
+ /**
146
+ * Create PR with full auto-recovery: pre-checks → create → fallback to find existing
147
+ * @returns {{ number: number, url: string }}
148
+ */
149
+ export function createPRWithRecovery(cwd, { title, body, base = 'main', head }) {
150
+ // Step 1: Try to create the PR
151
+ try {
152
+ return createPR(cwd, { title, body, base, head });
153
+ } catch (err) {
154
+ log.warn(`PR creation failed: ${err.message}`);
155
+ }
156
+
157
+ // Step 2: Auto-find existing PR for this branch
158
+ log.info('Searching for existing PR...');
159
+ const existing = findExistingPR(cwd, head);
160
+ if (existing) {
161
+ log.info(`Found existing PR #${existing.number}`);
162
+ return existing;
163
+ }
164
+
165
+ // Step 3: Check if there are actually commits to create a PR for
166
+ if (!hasCommitsBetween(cwd, base, head)) {
167
+ log.warn('No commits between base and head branch');
168
+ log.info('This may be a new repo or empty branch. Attempting to push base branch and retry...');
169
+
170
+ // Ensure both branches are pushed
171
+ ensureRemoteBranch(cwd, base);
172
+ ensureRemoteBranch(cwd, head);
173
+
174
+ // Retry PR creation
175
+ try {
176
+ return createPR(cwd, { title, body, base, head });
177
+ } catch (retryErr) {
178
+ log.warn(`PR retry also failed: ${retryErr.message}`);
179
+ }
180
+
181
+ // Last resort: try to find PR again
182
+ const retryExisting = findExistingPR(cwd, head);
183
+ if (retryExisting) {
184
+ log.info(`Found existing PR #${retryExisting.number}`);
185
+ return retryExisting;
186
+ }
187
+ }
188
+
189
+ // Step 4: If all else fails, throw with actionable message
190
+ throw new Error(
191
+ `Cannot create or find PR for branch '${head}'. ` +
192
+ `Possible causes: base branch '${base}' may not exist on remote, ` +
193
+ `or there are no commits between '${base}' and '${head}'. ` +
194
+ `Try: git push origin ${base} && git push origin ${head}`
195
+ );
196
+ }
197
+
198
+ /**
199
+ * Get PR review list
200
+ */
201
+ export function getReviews(cwd, prNumber) {
202
+ try {
203
+ const num = validatePRNumber(prNumber);
204
+ return ghJSON(`api repos/{owner}/{repo}/pulls/${num}/reviews`, cwd) || [];
205
+ } catch {
206
+ return [];
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Get PR review comments
212
+ */
213
+ export function getReviewComments(cwd, prNumber) {
214
+ try {
215
+ const num = validatePRNumber(prNumber);
216
+ return ghJSON(`api repos/{owner}/{repo}/pulls/${num}/comments`, cwd) || [];
217
+ } catch {
218
+ return [];
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Get PR issue comments (including bot comments)
224
+ */
225
+ export function getIssueComments(cwd, prNumber) {
226
+ try {
227
+ const num = validatePRNumber(prNumber);
228
+ return ghJSON(`api repos/{owner}/{repo}/issues/${num}/comments`, cwd) || [];
229
+ } catch {
230
+ return [];
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Check the latest review state
236
+ * @returns {'APPROVED' | 'CHANGES_REQUESTED' | 'COMMENTED' | 'PENDING' | null}
237
+ */
238
+ export function getLatestReviewState(cwd, prNumber) {
239
+ const reviews = getReviews(cwd, prNumber);
240
+ if (!reviews || reviews.length === 0) return null;
241
+
242
+ // Filter out PENDING and DISMISSED
243
+ const active = reviews.filter(r => r.state !== 'PENDING' && r.state !== 'DISMISSED');
244
+ if (active.length === 0) return null;
245
+
246
+ return active[active.length - 1].state;
247
+ }
248
+
249
+ /**
250
+ * Merge a pull request
251
+ */
252
+ export function mergePR(cwd, prNumber, method = 'squash') {
253
+ const num = validatePRNumber(prNumber);
254
+ const validMethods = ['squash', 'merge', 'rebase'];
255
+ if (!validMethods.includes(method)) {
256
+ throw new Error(`Invalid merge method: ${method}`);
257
+ }
258
+ gh(`pr merge ${num} --${method} --delete-branch`, cwd);
259
+ }
260
+
261
+ function validatePRNumber(prNumber) {
262
+ const num = parseInt(prNumber, 10);
263
+ if (isNaN(num) || num <= 0) throw new Error(`Invalid PR number: ${prNumber}`);
264
+ return num;
265
+ }
266
+
267
+ /**
268
+ * Collect all review feedback as structured text.
269
+ * Returns raw feedback — classification is done by AI provider.
270
+ */
271
+ export function collectReviewFeedback(cwd, prNumber) {
272
+ const reviews = getReviews(cwd, prNumber);
273
+ const comments = getReviewComments(cwd, prNumber);
274
+ const issueComments = getIssueComments(cwd, prNumber);
275
+
276
+ let feedback = '';
277
+
278
+ // Review body text (skip APPROVED — already handled by state check)
279
+ for (const r of reviews) {
280
+ if (r.body && r.body.trim() && r.state !== 'APPROVED') {
281
+ feedback += `### Review (${r.state})\n${r.body}\n\n`;
282
+ }
283
+ }
284
+
285
+ // Inline code comments (comments on specific diff lines)
286
+ for (const c of comments) {
287
+ feedback += `### ${c.path}:L${c.line || c.original_line}\n${c.body}\n\n`;
288
+ }
289
+
290
+ // Bot comments
291
+ for (const c of issueComments) {
292
+ if (c.user?.type === 'Bot' || c.user?.login?.includes('bot')) {
293
+ feedback += `### Bot Review (${c.user.login})\n${c.body}\n\n`;
294
+ }
295
+ }
296
+
297
+ return feedback.trim();
298
+ }
299
+
300
+ /**
301
+ * Check if the current repo is private
302
+ * @returns {boolean} true if private, false if public or unknown
303
+ */
304
+ export function isPrivateRepo(cwd) {
305
+ try {
306
+ const result = ghJSON('repo view --json isPrivate', cwd);
307
+ return result?.isPrivate === true;
308
+ } catch {
309
+ return false; // Default to public behavior if detection fails
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Request bots to re-review the PR.
315
+ * Auto-detects which review bots have previously commented on the PR
316
+ * and triggers only those. Falls back to all known bot triggers.
317
+ */
318
+ export function requestReReview(cwd, prNumber) {
319
+ // Known review bots and their re-review trigger commands
320
+ const REVIEW_BOTS = [
321
+ { login: 'gemini-code-assist', trigger: '/gemini review' },
322
+ { login: 'coderabbitai', trigger: '@coderabbitai review' },
323
+ { login: 'sourcery-ai', trigger: '@sourcery-ai review' },
324
+ { login: 'deepsource', trigger: '@deepsource review' },
325
+ { login: 'sweep-ai', trigger: 'sweep: review' },
326
+ { login: 'openai-codex', trigger: '@codex review' },
327
+ { login: 'claude', trigger: '@claude review' },
328
+ { login: 'copilot', trigger: '@copilot review' },
329
+ ];
330
+
331
+ try {
332
+ const num = validatePRNumber(prNumber);
333
+
334
+ // Detect which bots have previously interacted with this PR
335
+ const reviews = getReviews(cwd, num);
336
+ const comments = getIssueComments(cwd, num);
337
+ const reviewComments = getReviewComments(cwd, num);
338
+
339
+ const allLogins = new Set();
340
+ for (const r of reviews) if (r.user?.login) allLogins.add(r.user.login);
341
+ for (const c of comments) if (c.user?.login) allLogins.add(c.user.login);
342
+ for (const c of reviewComments) if (c.user?.login) allLogins.add(c.user.login);
343
+
344
+ // Find matching bots that have been active on this PR
345
+ const activeBots = REVIEW_BOTS.filter(bot =>
346
+ [...allLogins].some(login => login.includes(bot.login))
347
+ );
348
+
349
+ // Determine which triggers to fire
350
+ const triggers = activeBots.length > 0
351
+ ? activeBots.filter(b => b.trigger).map(b => b.trigger)
352
+ : REVIEW_BOTS.filter(b => b.trigger).map(b => b.trigger).slice(0, 1); // fallback: try gemini
353
+
354
+ // Fire triggers as separate comments
355
+ let triggered = 0;
356
+ for (const trigger of triggers) {
357
+ try {
358
+ gh(`pr comment ${num} --body "${trigger}"`, cwd);
359
+ triggered++;
360
+ } catch { /* individual trigger failure is non-critical */ }
361
+ }
362
+
363
+ // Also try to request re-review from previous human reviewers via GitHub API
364
+ try {
365
+ const prReviews = ghJSON(`pr view ${num} --json reviews --jq '.reviews[].author.login'`, cwd);
366
+ if (prReviews && typeof prReviews === 'string') {
367
+ const logins = [...new Set(prReviews.trim().split('\n').filter(l => l && !l.includes('[bot]')))];
368
+ if (logins.length > 0) {
369
+ const owner = execSync('gh repo view --json owner --jq .owner.login', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
370
+ const repo = execSync('gh repo view --json name --jq .name', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
371
+ execSync(`gh api repos/${owner}/${repo}/pulls/${num}/requested_reviewers -f "reviewers[]=${logins[0]}" -X POST`, {
372
+ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
373
+ });
374
+ }
375
+ }
376
+ } catch {
377
+ // Non-critical: human re-review request failed
378
+ }
379
+
380
+ return triggered > 0;
381
+ } catch {
382
+ return false;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * C4: Verify GitHub CLI authentication is valid.
388
+ * Checks both auth status and token expiry.
389
+ * @returns {{ ok: boolean, user: string|null, error: string|null }}
390
+ */
391
+ export function ensureAuth(cwd) {
392
+ try {
393
+ const output = execSync('gh auth status 2>&1', {
394
+ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 10000,
395
+ });
396
+ const userMatch = output.match(/Logged in to .+ as (\S+)/);
397
+ return { ok: true, user: userMatch?.[1] || 'unknown', error: null };
398
+ } catch (err) {
399
+ const msg = err.stderr || err.message || '';
400
+ if (msg.includes('not logged in') || msg.includes('authentication')) {
401
+ return { ok: false, user: null, error: 'Not authenticated. Run: gh auth login' };
402
+ }
403
+ if (msg.includes('token') && msg.includes('expir')) {
404
+ return { ok: false, user: null, error: 'Token expired. Run: gh auth refresh' };
405
+ }
406
+ return { ok: false, user: null, error: `Auth check failed: ${msg.substring(0, 100)}` };
407
+ }
408
+ }
409
+
410
+ /**
411
+ * C2/C5: Check network connectivity to the git remote.
412
+ * Verifies that we can reach the origin remote.
413
+ * @returns {{ ok: boolean, error: string|null }}
414
+ */
415
+ export function checkConnectivity(cwd) {
416
+ try {
417
+ execSync('git ls-remote --exit-code origin HEAD', {
418
+ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 15000,
419
+ });
420
+ return { ok: true, error: null };
421
+ } catch (err) {
422
+ const msg = err.stderr || err.message || '';
423
+ if (msg.includes('Could not resolve host') || msg.includes('Network is unreachable')) {
424
+ return { ok: false, error: 'Network unreachable — check internet connection' };
425
+ }
426
+ if (msg.includes('Permission denied') || msg.includes('Authentication failed')) {
427
+ return { ok: false, error: 'Authentication failed — check SSH keys or GH token' };
428
+ }
429
+ if (msg.includes('Repository not found')) {
430
+ return { ok: false, error: 'Remote repository not found — verify origin URL' };
431
+ }
432
+ // Timeout or other error
433
+ return { ok: false, error: `Cannot reach remote: ${msg.substring(0, 100)}` };
434
+ }
435
+ }
436
+
437
+ export const github = {
438
+ checkGhAuth, createPR, createPRWithRecovery, findExistingPR,
439
+ ensureRemoteBranch, hasCommitsBetween,
440
+ getReviews, getReviewComments, getIssueComments,
441
+ getLatestReviewState, mergePR, collectReviewFeedback,
442
+ isPrivateRepo, requestReReview, closePR, deleteBranch, getPRState,
443
+ ensureAuth, checkConnectivity,
444
+ };
445
+
446
+ /**
447
+ * Close a pull request without merging
448
+ */
449
+ export function closePR(cwd, prNumber) {
450
+ try {
451
+ const num = validatePRNumber(prNumber);
452
+ gh(`pr close ${num}`, cwd);
453
+ return true;
454
+ } catch {
455
+ return false;
456
+ }
457
+ }
458
+
459
+ /**
460
+ * Delete a remote branch
461
+ */
462
+ export function deleteBranch(cwd, branch) {
463
+ try {
464
+ execSync(`git push origin --delete ${branch}`, {
465
+ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
466
+ });
467
+ return true;
468
+ } catch {
469
+ return false;
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Get the state of a PR: 'open', 'closed', or 'merged'
475
+ */
476
+ export function getPRState(cwd, prNumber) {
477
+ try {
478
+ const num = validatePRNumber(prNumber);
479
+ const output = gh(`pr view ${num} --json state,mergedAt`, cwd);
480
+ const data = JSON.parse(output);
481
+ if (data.mergedAt) return 'merged';
482
+ return (data.state || 'OPEN').toLowerCase();
483
+ } catch {
484
+ return 'unknown';
485
+ }
486
+ }