gh-load-pull-request 0.6.0 → 0.7.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/README.md +49 -5
- package/package.json +1 -1
- package/src/backends.mjs +495 -0
- package/src/gh-load-pull-request.mjs +55 -126
package/README.md
CHANGED
|
@@ -73,10 +73,45 @@ chmod +x gh-load-pull-request.mjs
|
|
|
73
73
|
Usage: gh-load-pull-request <pr-url> [options]
|
|
74
74
|
|
|
75
75
|
Options:
|
|
76
|
-
-t, --token
|
|
77
|
-
-o, --output
|
|
78
|
-
|
|
79
|
-
--
|
|
76
|
+
-t, --token GitHub personal access token (optional for public PRs)
|
|
77
|
+
-o, --output Output directory (creates pr-<number>/ subfolder)
|
|
78
|
+
--format Output format: markdown, json (default: markdown)
|
|
79
|
+
--download-images Download embedded images (default: true)
|
|
80
|
+
--include-reviews Include PR reviews (default: true)
|
|
81
|
+
--force-api Force using GitHub API instead of gh CLI
|
|
82
|
+
--force-gh Force using gh CLI, fail if not available
|
|
83
|
+
-v, --verbose Enable verbose logging
|
|
84
|
+
-h, --help Show help
|
|
85
|
+
--version Show version number
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Backend Modes
|
|
89
|
+
|
|
90
|
+
The tool supports two backend modes for fetching PR data:
|
|
91
|
+
|
|
92
|
+
### 1. gh CLI Mode (Default)
|
|
93
|
+
|
|
94
|
+
By default, the tool uses the [GitHub CLI](https://cli.github.com/) (`gh`) to fetch PR data. This is the recommended mode as it:
|
|
95
|
+
|
|
96
|
+
- Uses your existing `gh` authentication
|
|
97
|
+
- Doesn't require managing tokens separately
|
|
98
|
+
- Works seamlessly with GitHub Enterprise
|
|
99
|
+
|
|
100
|
+
### 2. API Mode (Fallback)
|
|
101
|
+
|
|
102
|
+
If `gh` CLI is not available or not authenticated, the tool automatically falls back to using the GitHub REST API via Octokit. You can also force this mode with `--force-api`.
|
|
103
|
+
|
|
104
|
+
### Controlling Backend Mode
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Use default mode (gh CLI with API fallback)
|
|
108
|
+
gh-load-pull-request owner/repo#123
|
|
109
|
+
|
|
110
|
+
# Force gh CLI mode (fails if gh is not available)
|
|
111
|
+
gh-load-pull-request owner/repo#123 --force-gh
|
|
112
|
+
|
|
113
|
+
# Force API mode (useful for testing or when gh has issues)
|
|
114
|
+
gh-load-pull-request owner/repo#123 --force-api
|
|
80
115
|
```
|
|
81
116
|
|
|
82
117
|
## Input Formats
|
|
@@ -152,7 +187,7 @@ gh-load-pull-request https://github.com/facebook/react/pull/28000
|
|
|
152
187
|
gh-load-pull-request facebook/react#28000
|
|
153
188
|
|
|
154
189
|
# Save to file
|
|
155
|
-
gh-load-pull-request facebook/react#28000 -o
|
|
190
|
+
gh-load-pull-request facebook/react#28000 -o ./output
|
|
156
191
|
|
|
157
192
|
# Download private PR using gh CLI auth
|
|
158
193
|
gh-load-pull-request myorg/private-repo#42
|
|
@@ -160,6 +195,15 @@ gh-load-pull-request myorg/private-repo#42
|
|
|
160
195
|
# Download with explicit token
|
|
161
196
|
gh-load-pull-request myorg/repo#123 --token ghp_your_token_here
|
|
162
197
|
|
|
198
|
+
# Force using GitHub API instead of gh CLI
|
|
199
|
+
gh-load-pull-request owner/repo#123 --force-api
|
|
200
|
+
|
|
201
|
+
# Output as JSON
|
|
202
|
+
gh-load-pull-request owner/repo#123 --format json
|
|
203
|
+
|
|
204
|
+
# Verbose mode for debugging
|
|
205
|
+
gh-load-pull-request owner/repo#123 -v
|
|
206
|
+
|
|
163
207
|
# Pipe to other tools (e.g., AI for review)
|
|
164
208
|
gh-load-pull-request owner/repo#123 | claude-analyze
|
|
165
209
|
```
|
package/package.json
CHANGED
package/src/backends.mjs
ADDED
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend implementations for gh-load-pull-request
|
|
3
|
+
* Contains functions for fetching PR data via gh CLI and GitHub API
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Octokit } from '@octokit/rest';
|
|
7
|
+
|
|
8
|
+
let verboseLog = () => {};
|
|
9
|
+
let log = () => {};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Set logging functions from parent module
|
|
13
|
+
* @param {Object} loggers - Object with log and verboseLog functions
|
|
14
|
+
*/
|
|
15
|
+
export function setLoggers(loggers) {
|
|
16
|
+
log = loggers.log || (() => {});
|
|
17
|
+
verboseLog = loggers.verboseLog || (() => {});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if gh CLI is installed and available
|
|
22
|
+
* @returns {Promise<boolean>} True if gh is installed
|
|
23
|
+
*/
|
|
24
|
+
export async function isGhInstalled() {
|
|
25
|
+
try {
|
|
26
|
+
const { execSync } = await import('node:child_process');
|
|
27
|
+
execSync('gh --version', { stdio: 'pipe' });
|
|
28
|
+
return true;
|
|
29
|
+
} catch (_error) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if gh CLI is authenticated
|
|
36
|
+
* @returns {Promise<boolean>} True if gh is authenticated
|
|
37
|
+
*/
|
|
38
|
+
export async function isGhAuthenticated() {
|
|
39
|
+
try {
|
|
40
|
+
const { execSync } = await import('node:child_process');
|
|
41
|
+
execSync('gh auth status', { stdio: 'pipe' });
|
|
42
|
+
return true;
|
|
43
|
+
} catch (_error) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get GitHub token from gh CLI if available
|
|
50
|
+
* @returns {Promise<string|null>} GitHub token or null
|
|
51
|
+
*/
|
|
52
|
+
export async function getGhToken() {
|
|
53
|
+
try {
|
|
54
|
+
if (!(await isGhInstalled())) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { execSync } = await import('node:child_process');
|
|
59
|
+
const token = execSync('gh auth token', {
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
stdio: 'pipe',
|
|
62
|
+
}).trim();
|
|
63
|
+
return token;
|
|
64
|
+
} catch (_error) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Transform gh CLI JSON output to match the expected PR data format
|
|
71
|
+
* @param {Object} ghData - Data from gh pr view --json
|
|
72
|
+
* @param {string} owner - Repository owner
|
|
73
|
+
* @param {string} repo - Repository name
|
|
74
|
+
* @returns {Object} Transformed PR data matching Octokit format
|
|
75
|
+
*/
|
|
76
|
+
function transformGhPrData(ghData, owner, repo) {
|
|
77
|
+
// Transform PR object to match Octokit format
|
|
78
|
+
const pr = {
|
|
79
|
+
number: ghData.number,
|
|
80
|
+
title: ghData.title,
|
|
81
|
+
state: ghData.state.toLowerCase(),
|
|
82
|
+
draft: ghData.isDraft,
|
|
83
|
+
merged: ghData.state === 'MERGED',
|
|
84
|
+
html_url: ghData.url,
|
|
85
|
+
user: {
|
|
86
|
+
login: ghData.author.login,
|
|
87
|
+
},
|
|
88
|
+
created_at: ghData.createdAt,
|
|
89
|
+
updated_at: ghData.updatedAt,
|
|
90
|
+
merged_at: ghData.mergedAt,
|
|
91
|
+
closed_at: ghData.closedAt,
|
|
92
|
+
merged_by: ghData.mergedBy ? { login: ghData.mergedBy.login } : null,
|
|
93
|
+
base: {
|
|
94
|
+
ref: ghData.baseRefName,
|
|
95
|
+
sha: ghData.baseRefOid,
|
|
96
|
+
},
|
|
97
|
+
head: {
|
|
98
|
+
ref: ghData.headRefName,
|
|
99
|
+
sha: ghData.headRefOid,
|
|
100
|
+
},
|
|
101
|
+
additions: ghData.additions,
|
|
102
|
+
deletions: ghData.deletions,
|
|
103
|
+
changed_files: ghData.changedFiles,
|
|
104
|
+
labels:
|
|
105
|
+
ghData.labels?.map((l) => ({ name: l.name, color: l.color || '' })) || [],
|
|
106
|
+
assignees: ghData.assignees?.map((a) => ({ login: a.login })) || [],
|
|
107
|
+
requested_reviewers:
|
|
108
|
+
ghData.reviewRequests?.map((r) => ({ login: r.login })) || [],
|
|
109
|
+
milestone: ghData.milestone
|
|
110
|
+
? { title: ghData.milestone.title, number: ghData.milestone.number }
|
|
111
|
+
: null,
|
|
112
|
+
body: ghData.body,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// Transform files
|
|
116
|
+
const files = (ghData.files || []).map((f) => ({
|
|
117
|
+
filename: f.path,
|
|
118
|
+
status:
|
|
119
|
+
f.additions > 0 && f.deletions === 0
|
|
120
|
+
? 'added'
|
|
121
|
+
: f.additions === 0 && f.deletions > 0
|
|
122
|
+
? 'removed'
|
|
123
|
+
: 'modified',
|
|
124
|
+
additions: f.additions,
|
|
125
|
+
deletions: f.deletions,
|
|
126
|
+
previous_filename: null, // gh CLI doesn't provide this in the same way
|
|
127
|
+
patch: '', // gh CLI doesn't include patch in pr view
|
|
128
|
+
}));
|
|
129
|
+
|
|
130
|
+
// Transform commits
|
|
131
|
+
const commits = (ghData.commits || []).map((c) => ({
|
|
132
|
+
sha: c.oid,
|
|
133
|
+
commit: {
|
|
134
|
+
message: `${c.messageHeadline}\n\n${c.messageBody || ''}`.trim(),
|
|
135
|
+
author: {
|
|
136
|
+
name: c.authors?.[0]?.name || 'unknown',
|
|
137
|
+
date: c.authoredDate,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
html_url: `https://github.com/${owner}/${repo}/commit/${c.oid}`,
|
|
141
|
+
author: c.authors?.[0]?.login ? { login: c.authors[0].login } : null,
|
|
142
|
+
}));
|
|
143
|
+
|
|
144
|
+
// Transform issue comments
|
|
145
|
+
const comments = (ghData.comments || []).map((c) => ({
|
|
146
|
+
id: c.id,
|
|
147
|
+
user: { login: c.author?.login || 'unknown' },
|
|
148
|
+
body: c.body,
|
|
149
|
+
created_at: c.createdAt,
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
// Transform reviews
|
|
153
|
+
const reviews = (ghData.reviews || []).map((r) => ({
|
|
154
|
+
id: r.id,
|
|
155
|
+
user: { login: r.author?.login || 'unknown' },
|
|
156
|
+
state: r.state,
|
|
157
|
+
body: r.body,
|
|
158
|
+
submitted_at: r.submittedAt,
|
|
159
|
+
}));
|
|
160
|
+
|
|
161
|
+
// Review comments need to be fetched separately via gh api
|
|
162
|
+
// as gh pr view doesn't include them in a usable format
|
|
163
|
+
const reviewComments = [];
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
pr,
|
|
167
|
+
files,
|
|
168
|
+
comments,
|
|
169
|
+
reviewComments,
|
|
170
|
+
reviews,
|
|
171
|
+
commits,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Fetch pull request data using gh CLI
|
|
177
|
+
* @param {Object} options - Options for fetching PR
|
|
178
|
+
* @param {string} options.owner - Repository owner
|
|
179
|
+
* @param {string} options.repo - Repository name
|
|
180
|
+
* @param {number} options.prNumber - Pull request number
|
|
181
|
+
* @param {boolean} options.includeReviews - Include PR reviews (default: true)
|
|
182
|
+
* @returns {Promise<Object>} PR data object with pr, files, comments, reviewComments, reviews, commits
|
|
183
|
+
*/
|
|
184
|
+
export async function loadPullRequestWithGh(options) {
|
|
185
|
+
const { owner, repo, prNumber, includeReviews = true } = options;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
log(
|
|
189
|
+
'blue',
|
|
190
|
+
`🔍 Fetching pull request ${owner}/${repo}#${prNumber} using gh CLI...`
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const { execSync } = await import('node:child_process');
|
|
194
|
+
|
|
195
|
+
// Build the list of JSON fields to fetch
|
|
196
|
+
const jsonFields = [
|
|
197
|
+
'number',
|
|
198
|
+
'title',
|
|
199
|
+
'state',
|
|
200
|
+
'isDraft',
|
|
201
|
+
'body',
|
|
202
|
+
'url',
|
|
203
|
+
'author',
|
|
204
|
+
'createdAt',
|
|
205
|
+
'updatedAt',
|
|
206
|
+
'mergedAt',
|
|
207
|
+
'closedAt',
|
|
208
|
+
'mergedBy',
|
|
209
|
+
'baseRefName',
|
|
210
|
+
'baseRefOid',
|
|
211
|
+
'headRefName',
|
|
212
|
+
'headRefOid',
|
|
213
|
+
'additions',
|
|
214
|
+
'deletions',
|
|
215
|
+
'changedFiles',
|
|
216
|
+
'labels',
|
|
217
|
+
'assignees',
|
|
218
|
+
'reviewRequests',
|
|
219
|
+
'milestone',
|
|
220
|
+
'files',
|
|
221
|
+
'commits',
|
|
222
|
+
'comments',
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
if (includeReviews) {
|
|
226
|
+
jsonFields.push('reviews');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fetch PR data using gh pr view
|
|
230
|
+
const ghCommand = `gh pr view ${prNumber} --repo ${owner}/${repo} --json ${jsonFields.join(',')}`;
|
|
231
|
+
verboseLog('dim', ` Running: ${ghCommand}`);
|
|
232
|
+
|
|
233
|
+
const ghOutput = execSync(ghCommand, {
|
|
234
|
+
encoding: 'utf8',
|
|
235
|
+
stdio: 'pipe',
|
|
236
|
+
timeout: 60000,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const ghData = JSON.parse(ghOutput);
|
|
240
|
+
|
|
241
|
+
// Fetch review comments separately via gh api
|
|
242
|
+
let reviewComments = [];
|
|
243
|
+
try {
|
|
244
|
+
const reviewCommentsCommand = `gh api repos/${owner}/${repo}/pulls/${prNumber}/comments`;
|
|
245
|
+
verboseLog('dim', ` Running: ${reviewCommentsCommand}`);
|
|
246
|
+
|
|
247
|
+
const reviewCommentsOutput = execSync(reviewCommentsCommand, {
|
|
248
|
+
encoding: 'utf8',
|
|
249
|
+
stdio: 'pipe',
|
|
250
|
+
timeout: 30000,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const rawReviewComments = JSON.parse(reviewCommentsOutput);
|
|
254
|
+
reviewComments = rawReviewComments.map((c) => ({
|
|
255
|
+
id: c.id,
|
|
256
|
+
user: { login: c.user?.login || 'unknown' },
|
|
257
|
+
body: c.body,
|
|
258
|
+
path: c.path,
|
|
259
|
+
line: c.line,
|
|
260
|
+
created_at: c.created_at,
|
|
261
|
+
diff_hunk: c.diff_hunk,
|
|
262
|
+
pull_request_review_id: c.pull_request_review_id,
|
|
263
|
+
}));
|
|
264
|
+
} catch (reviewError) {
|
|
265
|
+
verboseLog(
|
|
266
|
+
'yellow',
|
|
267
|
+
` ⚠️ Could not fetch review comments: ${reviewError.message}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Transform gh data to expected format
|
|
272
|
+
const transformedData = transformGhPrData(ghData, owner, repo);
|
|
273
|
+
transformedData.reviewComments = reviewComments;
|
|
274
|
+
|
|
275
|
+
log('green', `✅ Successfully fetched PR data using gh CLI`);
|
|
276
|
+
|
|
277
|
+
return transformedData;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
const errorMessage = error.message || String(error);
|
|
280
|
+
if (
|
|
281
|
+
errorMessage.includes('not found') ||
|
|
282
|
+
errorMessage.includes('Could not resolve')
|
|
283
|
+
) {
|
|
284
|
+
throw new Error(`Pull request not found: ${owner}/${repo}#${prNumber}`);
|
|
285
|
+
} else if (errorMessage.includes('auth') || errorMessage.includes('401')) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
'Authentication failed. Please run "gh auth login" to authenticate'
|
|
288
|
+
);
|
|
289
|
+
} else {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`Failed to fetch pull request via gh CLI: ${errorMessage}`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Fetch pull request data using GitHub REST API via Octokit
|
|
299
|
+
* @param {Object} options - Options for fetching PR
|
|
300
|
+
* @param {string} options.owner - Repository owner
|
|
301
|
+
* @param {string} options.repo - Repository name
|
|
302
|
+
* @param {number} options.prNumber - Pull request number
|
|
303
|
+
* @param {string} options.token - GitHub token (optional for public repos)
|
|
304
|
+
* @param {boolean} options.includeReviews - Include PR reviews (default: true)
|
|
305
|
+
* @returns {Promise<Object>} PR data object with pr, files, comments, reviewComments, reviews, commits
|
|
306
|
+
*/
|
|
307
|
+
export async function loadPullRequestWithApi(options) {
|
|
308
|
+
const { owner, repo, prNumber, token, includeReviews = true } = options;
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
log(
|
|
312
|
+
'blue',
|
|
313
|
+
`🔍 Fetching pull request ${owner}/${repo}#${prNumber} using API...`
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const octokit = new Octokit({
|
|
317
|
+
auth: token,
|
|
318
|
+
baseUrl: 'https://api.github.com',
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Fetch PR data
|
|
322
|
+
const { data: pr } = await octokit.rest.pulls.get({
|
|
323
|
+
owner,
|
|
324
|
+
repo,
|
|
325
|
+
pull_number: prNumber,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Fetch PR files
|
|
329
|
+
const { data: files } = await octokit.rest.pulls.listFiles({
|
|
330
|
+
owner,
|
|
331
|
+
repo,
|
|
332
|
+
pull_number: prNumber,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Fetch PR comments (issue comments)
|
|
336
|
+
const { data: comments } = await octokit.rest.issues.listComments({
|
|
337
|
+
owner,
|
|
338
|
+
repo,
|
|
339
|
+
issue_number: prNumber,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// Fetch PR review comments (inline code comments)
|
|
343
|
+
const { data: reviewComments } =
|
|
344
|
+
await octokit.rest.pulls.listReviewComments({
|
|
345
|
+
owner,
|
|
346
|
+
repo,
|
|
347
|
+
pull_number: prNumber,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Fetch PR reviews
|
|
351
|
+
let reviews = [];
|
|
352
|
+
if (includeReviews) {
|
|
353
|
+
const { data: reviewsData } = await octokit.rest.pulls.listReviews({
|
|
354
|
+
owner,
|
|
355
|
+
repo,
|
|
356
|
+
pull_number: prNumber,
|
|
357
|
+
});
|
|
358
|
+
reviews = reviewsData;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Fetch PR commits
|
|
362
|
+
const { data: commits } = await octokit.rest.pulls.listCommits({
|
|
363
|
+
owner,
|
|
364
|
+
repo,
|
|
365
|
+
pull_number: prNumber,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
log('green', `✅ Successfully fetched PR data using API`);
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
pr,
|
|
372
|
+
files,
|
|
373
|
+
comments,
|
|
374
|
+
reviewComments,
|
|
375
|
+
reviews,
|
|
376
|
+
commits,
|
|
377
|
+
};
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (error.status === 404) {
|
|
380
|
+
throw new Error(`Pull request not found: ${owner}/${repo}#${prNumber}`);
|
|
381
|
+
} else if (error.status === 401) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
'Authentication failed. Please provide a valid GitHub token'
|
|
384
|
+
);
|
|
385
|
+
} else {
|
|
386
|
+
throw new Error(`Failed to fetch pull request via API: ${error.message}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Fetch pull request data from GitHub
|
|
393
|
+
* By default uses gh CLI if available, falls back to API otherwise.
|
|
394
|
+
* @param {Object} options - Options for fetching PR
|
|
395
|
+
* @param {string} options.owner - Repository owner
|
|
396
|
+
* @param {string} options.repo - Repository name
|
|
397
|
+
* @param {number} options.prNumber - Pull request number
|
|
398
|
+
* @param {string} options.token - GitHub token (optional for public repos, used for API fallback)
|
|
399
|
+
* @param {boolean} options.includeReviews - Include PR reviews (default: true)
|
|
400
|
+
* @param {boolean} options.forceApi - Force using API instead of gh CLI (default: false)
|
|
401
|
+
* @param {boolean} options.forceGh - Force using gh CLI, fail if not available (default: false)
|
|
402
|
+
* @returns {Promise<Object>} PR data object with pr, files, comments, reviewComments, reviews, commits
|
|
403
|
+
*/
|
|
404
|
+
export async function loadPullRequest(options) {
|
|
405
|
+
const {
|
|
406
|
+
owner,
|
|
407
|
+
repo,
|
|
408
|
+
prNumber,
|
|
409
|
+
token,
|
|
410
|
+
includeReviews = true,
|
|
411
|
+
forceApi = false,
|
|
412
|
+
forceGh = false,
|
|
413
|
+
} = options;
|
|
414
|
+
|
|
415
|
+
// If force API mode, use API directly
|
|
416
|
+
if (forceApi) {
|
|
417
|
+
verboseLog('cyan', '🔧 Using API mode (forced)');
|
|
418
|
+
return loadPullRequestWithApi({
|
|
419
|
+
owner,
|
|
420
|
+
repo,
|
|
421
|
+
prNumber,
|
|
422
|
+
token,
|
|
423
|
+
includeReviews,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Check if gh CLI is available
|
|
428
|
+
const ghInstalled = await isGhInstalled();
|
|
429
|
+
|
|
430
|
+
// If force gh mode but gh is not available, throw error
|
|
431
|
+
if (forceGh && !ghInstalled) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
'gh CLI is required but not installed. Please install GitHub CLI: https://cli.github.com/'
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// If gh is available, try using it first
|
|
438
|
+
if (ghInstalled) {
|
|
439
|
+
// Check if authenticated
|
|
440
|
+
const ghAuth = await isGhAuthenticated();
|
|
441
|
+
if (!ghAuth) {
|
|
442
|
+
verboseLog(
|
|
443
|
+
'yellow',
|
|
444
|
+
'⚠️ gh CLI is not authenticated, falling back to API'
|
|
445
|
+
);
|
|
446
|
+
if (forceGh) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
'gh CLI is not authenticated. Please run "gh auth login"'
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
return loadPullRequestWithApi({
|
|
452
|
+
owner,
|
|
453
|
+
repo,
|
|
454
|
+
prNumber,
|
|
455
|
+
token,
|
|
456
|
+
includeReviews,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
return await loadPullRequestWithGh({
|
|
462
|
+
owner,
|
|
463
|
+
repo,
|
|
464
|
+
prNumber,
|
|
465
|
+
includeReviews,
|
|
466
|
+
});
|
|
467
|
+
} catch (ghError) {
|
|
468
|
+
// If gh fails and we're not forcing gh, fall back to API
|
|
469
|
+
if (!forceGh) {
|
|
470
|
+
verboseLog(
|
|
471
|
+
'yellow',
|
|
472
|
+
`⚠️ gh CLI failed: ${ghError.message}, falling back to API`
|
|
473
|
+
);
|
|
474
|
+
return loadPullRequestWithApi({
|
|
475
|
+
owner,
|
|
476
|
+
repo,
|
|
477
|
+
prNumber,
|
|
478
|
+
token,
|
|
479
|
+
includeReviews,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
throw ghError;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// gh not available, use API
|
|
487
|
+
verboseLog('cyan', '🔧 gh CLI not available, using API');
|
|
488
|
+
return loadPullRequestWithApi({
|
|
489
|
+
owner,
|
|
490
|
+
repo,
|
|
491
|
+
prNumber,
|
|
492
|
+
token,
|
|
493
|
+
includeReviews,
|
|
494
|
+
});
|
|
495
|
+
}
|
|
@@ -8,7 +8,6 @@ import http from 'node:http';
|
|
|
8
8
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
9
|
const __dirname = path.dirname(__filename);
|
|
10
10
|
|
|
11
|
-
import { Octokit } from '@octokit/rest';
|
|
12
11
|
import fs from 'fs-extra';
|
|
13
12
|
import yargs from 'yargs';
|
|
14
13
|
import { hideBin } from 'yargs/helpers';
|
|
@@ -21,6 +20,16 @@ import {
|
|
|
21
20
|
generateFilesMarkdown,
|
|
22
21
|
} from './formatters.mjs';
|
|
23
22
|
|
|
23
|
+
import {
|
|
24
|
+
isGhInstalled as backendsIsGhInstalled,
|
|
25
|
+
isGhAuthenticated as backendsIsGhAuthenticated,
|
|
26
|
+
getGhToken as backendsGetGhToken,
|
|
27
|
+
loadPullRequest as backendsLoadPullRequest,
|
|
28
|
+
loadPullRequestWithGh as backendsLoadPullRequestWithGh,
|
|
29
|
+
loadPullRequestWithApi as backendsLoadPullRequestWithApi,
|
|
30
|
+
setLoggers,
|
|
31
|
+
} from './backends.mjs';
|
|
32
|
+
|
|
24
33
|
let version = '0.1.0';
|
|
25
34
|
try {
|
|
26
35
|
const packagePath = path.join(__dirname, '..', 'package.json');
|
|
@@ -60,6 +69,9 @@ const verboseLog = (color, message) => {
|
|
|
60
69
|
}
|
|
61
70
|
};
|
|
62
71
|
|
|
72
|
+
// Initialize loggers for backends module
|
|
73
|
+
setLoggers({ log, verboseLog });
|
|
74
|
+
|
|
63
75
|
/**
|
|
64
76
|
* Set logging mode for library usage
|
|
65
77
|
* @param {Object} options - Logging options
|
|
@@ -69,38 +81,17 @@ const verboseLog = (color, message) => {
|
|
|
69
81
|
export function setLoggingMode(options = {}) {
|
|
70
82
|
verboseMode = options.verbose || false;
|
|
71
83
|
silentMode = options.silent || false;
|
|
84
|
+
// Update loggers in backends module
|
|
85
|
+
setLoggers({ log, verboseLog });
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Get GitHub token from gh CLI if available
|
|
86
|
-
* @returns {Promise<string|null>} GitHub token or null
|
|
87
|
-
*/
|
|
88
|
-
export async function getGhToken() {
|
|
89
|
-
try {
|
|
90
|
-
if (!(await isGhInstalled())) {
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const { execSync } = await import('node:child_process');
|
|
95
|
-
const token = execSync('gh auth token', {
|
|
96
|
-
encoding: 'utf8',
|
|
97
|
-
stdio: 'pipe',
|
|
98
|
-
}).trim();
|
|
99
|
-
return token;
|
|
100
|
-
} catch (_error) {
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
88
|
+
// Re-export backend functions
|
|
89
|
+
export const isGhInstalled = backendsIsGhInstalled;
|
|
90
|
+
export const isGhAuthenticated = backendsIsGhAuthenticated;
|
|
91
|
+
export const getGhToken = backendsGetGhToken;
|
|
92
|
+
export const loadPullRequest = backendsLoadPullRequest;
|
|
93
|
+
export const loadPullRequestWithGh = backendsLoadPullRequestWithGh;
|
|
94
|
+
export const loadPullRequestWithApi = backendsLoadPullRequestWithApi;
|
|
104
95
|
|
|
105
96
|
/**
|
|
106
97
|
* Parse PR URL to extract owner, repo, and PR number
|
|
@@ -404,97 +395,6 @@ export async function downloadImages(content, imagesDir, token, _prNumber) {
|
|
|
404
395
|
return { content: updatedContent, downloadedImages };
|
|
405
396
|
}
|
|
406
397
|
|
|
407
|
-
/**
|
|
408
|
-
* Fetch pull request data from GitHub API
|
|
409
|
-
* @param {Object} options - Options for fetching PR
|
|
410
|
-
* @param {string} options.owner - Repository owner
|
|
411
|
-
* @param {string} options.repo - Repository name
|
|
412
|
-
* @param {number} options.prNumber - Pull request number
|
|
413
|
-
* @param {string} options.token - GitHub token (optional for public repos)
|
|
414
|
-
* @param {boolean} options.includeReviews - Include PR reviews (default: true)
|
|
415
|
-
* @returns {Promise<Object>} PR data object with pr, files, comments, reviewComments, reviews, commits
|
|
416
|
-
*/
|
|
417
|
-
export async function loadPullRequest(options) {
|
|
418
|
-
const { owner, repo, prNumber, token, includeReviews = true } = options;
|
|
419
|
-
|
|
420
|
-
try {
|
|
421
|
-
log('blue', `🔍 Fetching pull request ${owner}/${repo}#${prNumber}...`);
|
|
422
|
-
|
|
423
|
-
const octokit = new Octokit({
|
|
424
|
-
auth: token,
|
|
425
|
-
baseUrl: 'https://api.github.com',
|
|
426
|
-
});
|
|
427
|
-
|
|
428
|
-
// Fetch PR data
|
|
429
|
-
const { data: pr } = await octokit.rest.pulls.get({
|
|
430
|
-
owner,
|
|
431
|
-
repo,
|
|
432
|
-
pull_number: prNumber,
|
|
433
|
-
});
|
|
434
|
-
|
|
435
|
-
// Fetch PR files
|
|
436
|
-
const { data: files } = await octokit.rest.pulls.listFiles({
|
|
437
|
-
owner,
|
|
438
|
-
repo,
|
|
439
|
-
pull_number: prNumber,
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
// Fetch PR comments (issue comments)
|
|
443
|
-
const { data: comments } = await octokit.rest.issues.listComments({
|
|
444
|
-
owner,
|
|
445
|
-
repo,
|
|
446
|
-
issue_number: prNumber,
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
// Fetch PR review comments (inline code comments)
|
|
450
|
-
const { data: reviewComments } =
|
|
451
|
-
await octokit.rest.pulls.listReviewComments({
|
|
452
|
-
owner,
|
|
453
|
-
repo,
|
|
454
|
-
pull_number: prNumber,
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
// Fetch PR reviews
|
|
458
|
-
let reviews = [];
|
|
459
|
-
if (includeReviews) {
|
|
460
|
-
const { data: reviewsData } = await octokit.rest.pulls.listReviews({
|
|
461
|
-
owner,
|
|
462
|
-
repo,
|
|
463
|
-
pull_number: prNumber,
|
|
464
|
-
});
|
|
465
|
-
reviews = reviewsData;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// Fetch PR commits
|
|
469
|
-
const { data: commits } = await octokit.rest.pulls.listCommits({
|
|
470
|
-
owner,
|
|
471
|
-
repo,
|
|
472
|
-
pull_number: prNumber,
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
log('green', `✅ Successfully fetched PR data`);
|
|
476
|
-
|
|
477
|
-
return {
|
|
478
|
-
pr,
|
|
479
|
-
files,
|
|
480
|
-
comments,
|
|
481
|
-
reviewComments,
|
|
482
|
-
reviews,
|
|
483
|
-
commits,
|
|
484
|
-
};
|
|
485
|
-
} catch (error) {
|
|
486
|
-
if (error.status === 404) {
|
|
487
|
-
throw new Error(`Pull request not found: ${owner}/${repo}#${prNumber}`);
|
|
488
|
-
} else if (error.status === 401) {
|
|
489
|
-
throw new Error(
|
|
490
|
-
'Authentication failed. Please provide a valid GitHub token'
|
|
491
|
-
);
|
|
492
|
-
} else {
|
|
493
|
-
throw new Error(`Failed to fetch pull request: ${error.message}`);
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
398
|
// Alias for backwards compatibility
|
|
499
399
|
export const fetchPullRequest = (owner, repo, prNumber, token, options = {}) =>
|
|
500
400
|
loadPullRequest({ owner, repo, prNumber, token, ...options });
|
|
@@ -888,6 +788,16 @@ function parseCliArgs() {
|
|
|
888
788
|
describe: 'Enable verbose logging',
|
|
889
789
|
default: false,
|
|
890
790
|
})
|
|
791
|
+
.option('force-api', {
|
|
792
|
+
type: 'boolean',
|
|
793
|
+
describe: 'Force using GitHub API instead of gh CLI',
|
|
794
|
+
default: false,
|
|
795
|
+
})
|
|
796
|
+
.option('force-gh', {
|
|
797
|
+
type: 'boolean',
|
|
798
|
+
describe: 'Force using gh CLI, fail if not available',
|
|
799
|
+
default: false,
|
|
800
|
+
})
|
|
891
801
|
.help('h')
|
|
892
802
|
.alias('h', 'help')
|
|
893
803
|
.example('$0 https://github.com/owner/repo/pull/123', 'Download PR #123')
|
|
@@ -898,7 +808,9 @@ function parseCliArgs() {
|
|
|
898
808
|
.example(
|
|
899
809
|
'$0 https://github.com/owner/repo/pull/123 --token ghp_xxx',
|
|
900
810
|
'Download private PR'
|
|
901
|
-
)
|
|
811
|
+
)
|
|
812
|
+
.example('$0 owner/repo#123 --force-api', 'Force using GitHub API')
|
|
813
|
+
.example('$0 owner/repo#123 --force-gh', 'Force using gh CLI').argv;
|
|
902
814
|
}
|
|
903
815
|
|
|
904
816
|
async function main() {
|
|
@@ -911,11 +823,22 @@ async function main() {
|
|
|
911
823
|
'include-reviews': includeReviews,
|
|
912
824
|
format,
|
|
913
825
|
verbose,
|
|
826
|
+
'force-api': forceApi,
|
|
827
|
+
'force-gh': forceGh,
|
|
914
828
|
} = argv;
|
|
915
829
|
|
|
916
830
|
// Set verbose mode
|
|
917
831
|
verboseMode = verbose;
|
|
918
832
|
|
|
833
|
+
// Validate mutually exclusive flags
|
|
834
|
+
if (forceApi && forceGh) {
|
|
835
|
+
log(
|
|
836
|
+
'red',
|
|
837
|
+
'❌ Cannot use both --force-api and --force-gh at the same time'
|
|
838
|
+
);
|
|
839
|
+
process.exit(1);
|
|
840
|
+
}
|
|
841
|
+
|
|
919
842
|
// Parse PR input first (before potentially slow gh CLI token fetch)
|
|
920
843
|
const prInfo = parsePrUrl(prInput);
|
|
921
844
|
if (!prInfo) {
|
|
@@ -929,12 +852,16 @@ async function main() {
|
|
|
929
852
|
|
|
930
853
|
let token = tokenArg;
|
|
931
854
|
|
|
932
|
-
// If no token provided, try to get
|
|
933
|
-
if (!token || token === undefined) {
|
|
855
|
+
// If force-api mode or no token provided, try to get token from gh CLI for API fallback
|
|
856
|
+
if (forceApi || !token || token === undefined) {
|
|
934
857
|
const ghToken = await getGhToken();
|
|
935
858
|
if (ghToken) {
|
|
936
859
|
token = ghToken;
|
|
937
|
-
|
|
860
|
+
if (forceApi) {
|
|
861
|
+
log('cyan', '🔑 Using GitHub token from gh CLI for API mode');
|
|
862
|
+
} else {
|
|
863
|
+
verboseLog('cyan', '🔑 Got GitHub token from gh CLI for fallback');
|
|
864
|
+
}
|
|
938
865
|
}
|
|
939
866
|
}
|
|
940
867
|
|
|
@@ -948,6 +875,8 @@ async function main() {
|
|
|
948
875
|
prNumber,
|
|
949
876
|
token,
|
|
950
877
|
includeReviews,
|
|
878
|
+
forceApi,
|
|
879
|
+
forceGh,
|
|
951
880
|
});
|
|
952
881
|
|
|
953
882
|
// Determine output paths
|