@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.
- package/LICENSE +21 -21
- package/README.md +144 -44
- package/bin/cli.js +189 -182
- package/package.json +39 -39
- package/src/commands/evolve.js +316 -316
- package/src/commands/fix.js +447 -447
- package/src/commands/init.js +298 -298
- package/src/commands/reset.js +61 -61
- package/src/commands/retry.js +190 -190
- package/src/commands/run.js +958 -958
- package/src/commands/skip.js +62 -62
- package/src/commands/status.js +95 -95
- package/src/commands/usage.js +361 -361
- package/src/utils/automator.js +279 -279
- package/src/utils/checkpoint.js +246 -246
- package/src/utils/detect-prd.js +137 -137
- package/src/utils/git.js +388 -388
- package/src/utils/github.js +486 -486
- package/src/utils/json.js +220 -220
- package/src/utils/logger.js +41 -41
- package/src/utils/prompt.js +49 -49
- package/src/utils/provider.js +770 -769
- package/src/utils/self-heal.js +330 -330
- package/src/utils/shell-bootstrap.js +404 -0
- package/src/utils/update-check.js +103 -103
package/src/utils/github.js
CHANGED
|
@@ -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
|
+
}
|