gh-load-pull-request 0.5.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 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 GitHub personal access token (optional for public PRs)
77
- -o, --output Output file path (default: stdout)
78
- -h, --help Show help
79
- --version Show version number
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 react-pr-28000.md
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-load-pull-request",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Download GitHub pull request and convert it to markdown",
5
5
  "type": "module",
6
6
  "main": "src/gh-load-pull-request.mjs",
@@ -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
+ }