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.
@@ -1,37 +1,47 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- // Import built-in Node.js modules
4
3
  import path from 'node:path';
5
4
  import { fileURLToPath } from 'node:url';
6
5
  import https from 'node:https';
7
6
  import http from 'node:http';
8
7
 
9
- // Get __dirname equivalent for ES modules
10
8
  const __filename = fileURLToPath(import.meta.url);
11
9
  const __dirname = path.dirname(__filename);
12
10
 
13
- // Import npm dependencies
14
- import { Octokit } from '@octokit/rest';
15
11
  import fs from 'fs-extra';
16
12
  import yargs from 'yargs';
17
13
  import { hideBin } from 'yargs/helpers';
18
14
 
19
- // Get version from package.json or fallback
20
- let version = '0.1.0'; // Fallback version
21
-
15
+ import {
16
+ formatDate,
17
+ convertToJson as formattersConvertToJson,
18
+ generateMetadataMarkdown,
19
+ generateCommitsMarkdown,
20
+ generateFilesMarkdown,
21
+ } from './formatters.mjs';
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
+
33
+ let version = '0.1.0';
22
34
  try {
23
- const packagePath = path.join(__dirname, 'package.json');
24
- // Use node:fs for Deno compatibility (fs-extra has issues with Deno)
35
+ const packagePath = path.join(__dirname, '..', 'package.json');
25
36
  const { readFileSync, existsSync } = await import('node:fs');
26
37
  if (existsSync(packagePath)) {
27
38
  const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
28
39
  version = packageJson.version;
29
40
  }
30
41
  } catch (_error) {
31
- // Use fallback version if package.json can't be read
42
+ /* Use fallback version */
32
43
  }
33
44
 
34
- // Colors for console output
35
45
  const colors = {
36
46
  green: '\x1b[32m',
37
47
  yellow: '\x1b[33m',
@@ -44,55 +54,52 @@ const colors = {
44
54
  reset: '\x1b[0m',
45
55
  };
46
56
 
47
- // Verbose logging flag (set by CLI option)
48
57
  let verboseMode = false;
58
+ let silentMode = false;
49
59
 
50
- const log = (color, message) =>
51
- console.error(`${colors[color]}${message}${colors.reset}`);
60
+ const log = (color, message) => {
61
+ if (!silentMode) {
62
+ console.error(`${colors[color]}${message}${colors.reset}`);
63
+ }
64
+ };
52
65
 
53
66
  const verboseLog = (color, message) => {
54
- if (verboseMode) {
67
+ if (verboseMode && !silentMode) {
55
68
  log(color, message);
56
69
  }
57
70
  };
58
71
 
59
- // Helper function to check if gh CLI is installed
60
- async function isGhInstalled() {
61
- try {
62
- const { execSync } = await import('node:child_process');
63
- execSync('gh --version', { stdio: 'pipe' });
64
- return true;
65
- } catch (_error) {
66
- return false;
67
- }
68
- }
69
-
70
- // Helper function to get GitHub token from gh CLI if available
71
- async function getGhToken() {
72
- try {
73
- if (!(await isGhInstalled())) {
74
- return null;
75
- }
76
-
77
- const { execSync } = await import('node:child_process');
78
- const token = execSync('gh auth token', {
79
- encoding: 'utf8',
80
- stdio: 'pipe',
81
- }).trim();
82
- return token;
83
- } catch (_error) {
84
- return null;
85
- }
72
+ // Initialize loggers for backends module
73
+ setLoggers({ log, verboseLog });
74
+
75
+ /**
76
+ * Set logging mode for library usage
77
+ * @param {Object} options - Logging options
78
+ * @param {boolean} options.verbose - Enable verbose logging
79
+ * @param {boolean} options.silent - Disable all logging
80
+ */
81
+ export function setLoggingMode(options = {}) {
82
+ verboseMode = options.verbose || false;
83
+ silentMode = options.silent || false;
84
+ // Update loggers in backends module
85
+ setLoggers({ log, verboseLog });
86
86
  }
87
87
 
88
- // Parse PR URL to extract owner, repo, and PR number
89
- function parsePrUrl(url) {
90
- // Support multiple formats:
91
- // https://github.com/owner/repo/pull/123
92
- // owner/repo#123
93
- // owner/repo/123
94
-
95
- // Try full URL format
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;
95
+
96
+ /**
97
+ * Parse PR URL to extract owner, repo, and PR number
98
+ * @param {string} url - PR URL or shorthand (owner/repo#123, owner/repo/123, or full URL)
99
+ * @returns {{owner: string, repo: string, prNumber: number}|null} Parsed PR info or null
100
+ */
101
+ export function parsePrUrl(url) {
102
+ // Try full URL format (github.com/owner/repo/pull/123 or owner/repo#123 or owner/repo/123)
96
103
  const urlMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
97
104
  if (urlMatch) {
98
105
  return {
@@ -125,7 +132,6 @@ function parsePrUrl(url) {
125
132
  return null;
126
133
  }
127
134
 
128
- // Image magic bytes for validation
129
135
  const imageMagicBytes = {
130
136
  png: [0x89, 0x50, 0x4e, 0x47],
131
137
  jpg: [0xff, 0xd8, 0xff],
@@ -136,8 +142,13 @@ const imageMagicBytes = {
136
142
  svg: [0x3c, 0x3f, 0x78, 0x6d, 0x6c], // <?xml for SVG (though SVG can also start with <svg)
137
143
  };
138
144
 
139
- // Validate image by checking magic bytes
140
- function validateImageBuffer(buffer, url) {
145
+ /**
146
+ * Validate image by checking magic bytes
147
+ * @param {Buffer} buffer - Image buffer to validate
148
+ * @param {string} url - Original URL (for logging)
149
+ * @returns {{valid: boolean, format?: string, reason?: string}} Validation result
150
+ */
151
+ export function validateImageBuffer(buffer, url) {
141
152
  if (!buffer || buffer.length < 4) {
142
153
  return { valid: false, reason: 'Buffer too small' };
143
154
  }
@@ -185,8 +196,13 @@ function validateImageBuffer(buffer, url) {
185
196
  return { valid: true, format: 'unknown' };
186
197
  }
187
198
 
188
- // Get file extension from format or URL
189
- function getExtensionFromFormat(format, url) {
199
+ /**
200
+ * Get file extension from format or URL
201
+ * @param {string} format - Image format detected
202
+ * @param {string} url - Original URL
203
+ * @returns {string} File extension with leading dot
204
+ */
205
+ export function getExtensionFromFormat(format, url) {
190
206
  const formatExtensions = {
191
207
  png: '.png',
192
208
  jpg: '.jpg',
@@ -227,8 +243,14 @@ function getExtensionFromFormat(format, url) {
227
243
  return '.png'; // Default fallback
228
244
  }
229
245
 
230
- // Download a file with redirect support
231
- function downloadFile(url, token, maxRedirects = 5) {
246
+ /**
247
+ * Download a file with redirect support
248
+ * @param {string} url - URL to download
249
+ * @param {string} token - GitHub token for authenticated requests
250
+ * @param {number} maxRedirects - Maximum number of redirects to follow
251
+ * @returns {Promise<Buffer>} Downloaded file content
252
+ */
253
+ export function downloadFile(url, token, maxRedirects = 5) {
232
254
  return new Promise((resolve, reject) => {
233
255
  if (maxRedirects <= 0) {
234
256
  reject(new Error('Too many redirects'));
@@ -281,8 +303,12 @@ function downloadFile(url, token, maxRedirects = 5) {
281
303
  });
282
304
  }
283
305
 
284
- // Extract image URLs from markdown content
285
- function extractMarkdownImageUrls(content) {
306
+ /**
307
+ * Extract image URLs from markdown content
308
+ * @param {string} content - Markdown content to search
309
+ * @returns {Array<{url: string, alt: string}>} Array of image URLs with alt text
310
+ */
311
+ export function extractMarkdownImageUrls(content) {
286
312
  if (!content) {
287
313
  return [];
288
314
  }
@@ -305,8 +331,15 @@ function extractMarkdownImageUrls(content) {
305
331
  return urls;
306
332
  }
307
333
 
308
- // Download all images from content and update the markdown
309
- async function downloadImages(content, imagesDir, token, _prNumber) {
334
+ /**
335
+ * Download all images from content and update the markdown
336
+ * @param {string} content - Markdown content with image URLs
337
+ * @param {string} imagesDir - Directory to save images
338
+ * @param {string} token - GitHub token for authenticated requests
339
+ * @param {number} _prNumber - PR number (unused, kept for compatibility)
340
+ * @returns {Promise<{content: string, downloadedImages: Array}>} Updated content and downloaded images info
341
+ */
342
+ export async function downloadImages(content, imagesDir, token, _prNumber) {
310
343
  if (!content) {
311
344
  return { content, downloadedImages: [] };
312
345
  }
@@ -338,7 +371,7 @@ async function downloadImages(content, imagesDir, token, _prNumber) {
338
371
  const ext = getExtensionFromFormat(validation.format, url);
339
372
  const filename = `image-${imageCounter}${ext}`;
340
373
  const localPath = path.join(imagesDir, filename);
341
- const relativePath = `./${path.basename(imagesDir)}/${filename}`;
374
+ const relativePath = `./images/${filename}`;
342
375
 
343
376
  await fs.writeFile(localPath, buffer);
344
377
  downloadedImages.push({
@@ -362,87 +395,9 @@ async function downloadImages(content, imagesDir, token, _prNumber) {
362
395
  return { content: updatedContent, downloadedImages };
363
396
  }
364
397
 
365
- // Fetch pull request data from GitHub API
366
- async function fetchPullRequest(owner, repo, prNumber, token, options = {}) {
367
- try {
368
- log('blue', `🔍 Fetching pull request ${owner}/${repo}#${prNumber}...`);
369
-
370
- const octokit = new Octokit({
371
- auth: token,
372
- baseUrl: 'https://api.github.com',
373
- });
374
-
375
- // Fetch PR data
376
- const { data: pr } = await octokit.rest.pulls.get({
377
- owner,
378
- repo,
379
- pull_number: prNumber,
380
- });
381
-
382
- // Fetch PR files
383
- const { data: files } = await octokit.rest.pulls.listFiles({
384
- owner,
385
- repo,
386
- pull_number: prNumber,
387
- });
388
-
389
- // Fetch PR comments
390
- const { data: comments } = await octokit.rest.issues.listComments({
391
- owner,
392
- repo,
393
- issue_number: prNumber,
394
- });
395
-
396
- // Fetch PR review comments
397
- const { data: reviewComments } =
398
- await octokit.rest.pulls.listReviewComments({
399
- owner,
400
- repo,
401
- pull_number: prNumber,
402
- });
403
-
404
- // Fetch PR reviews (only if includeReviews is true)
405
- let reviews = [];
406
- if (options.includeReviews !== false) {
407
- const { data: reviewsData } = await octokit.rest.pulls.listReviews({
408
- owner,
409
- repo,
410
- pull_number: prNumber,
411
- });
412
- reviews = reviewsData;
413
- }
414
-
415
- // Fetch PR commits
416
- const { data: commits } = await octokit.rest.pulls.listCommits({
417
- owner,
418
- repo,
419
- pull_number: prNumber,
420
- });
421
-
422
- log('green', `✅ Successfully fetched PR data`);
423
-
424
- return {
425
- pr,
426
- files,
427
- comments,
428
- reviewComments,
429
- reviews,
430
- commits,
431
- };
432
- } catch (error) {
433
- if (error.status === 404) {
434
- log('red', `❌ Pull request not found: ${owner}/${repo}#${prNumber}`);
435
- } else if (error.status === 401) {
436
- log(
437
- 'red',
438
- `❌ Authentication failed. Please provide a valid GitHub token`
439
- );
440
- } else {
441
- log('red', `❌ Failed to fetch pull request: ${error.message}`);
442
- }
443
- process.exit(1);
444
- }
445
- }
398
+ // Alias for backwards compatibility
399
+ export const fetchPullRequest = (owner, repo, prNumber, token, options = {}) =>
400
+ loadPullRequest({ owner, repo, prNumber, token, ...options });
446
401
 
447
402
  // Process content and download images if enabled
448
403
  function processContent(
@@ -458,8 +413,17 @@ function processContent(
458
413
  return downloadImages(content, imagesDir, token, prNumber);
459
414
  }
460
415
 
461
- // Convert PR data to markdown
462
- async function convertToMarkdown(data, options = {}) {
416
+ /**
417
+ * Convert PR data to markdown format
418
+ * @param {Object} data - PR data from loadPullRequest
419
+ * @param {Object} options - Conversion options
420
+ * @param {boolean} options.downloadImagesFlag - Download embedded images (default: true)
421
+ * @param {string} options.imagesDir - Directory to save images
422
+ * @param {string} options.token - GitHub token for downloading images
423
+ * @param {number} options.prNumber - PR number
424
+ * @returns {Promise<{markdown: string, downloadedImages: Array}>} Markdown content and downloaded images
425
+ */
426
+ export async function convertToMarkdown(data, options = {}) {
463
427
  const { pr, files, comments, reviewComments, reviews, commits } = data;
464
428
  const {
465
429
  downloadImagesFlag = true,
@@ -486,125 +450,152 @@ async function convertToMarkdown(data, options = {}) {
486
450
  allDownloadedImages = [...allDownloadedImages, ...result.downloadedImages];
487
451
  }
488
452
 
489
- // Header
490
453
  markdown += `# ${pr.title}\n\n`;
491
454
 
492
- // Metadata
493
- markdown += `**Author:** @${pr.user.login}\n`;
494
- markdown += `**Created:** ${pr.created_at}\n`;
495
- markdown += `**State:** ${pr.state}\n`;
496
- markdown += `**Branch:** ${pr.head.ref} → ${pr.base.ref}\n`;
497
-
498
- // Labels
499
- if (pr.labels && pr.labels.length > 0) {
500
- markdown += `**Labels:** ${pr.labels.map((l) => l.name).join(', ')}\n`;
501
- }
455
+ markdown += generateMetadataMarkdown(pr);
456
+ markdown += '---\n\n';
502
457
 
503
- markdown += '\n## Description\n\n';
458
+ markdown += '## Description\n\n';
504
459
  markdown += prBody ? `${prBody}\n\n` : '_No description provided._\n\n';
505
460
 
506
461
  markdown += '---\n\n';
507
462
 
508
- // Comments
509
- if (comments.length > 0) {
510
- markdown += `## Comments\n\n`;
511
- for (const comment of comments) {
512
- let commentBody = comment.body || '';
513
- if (downloadImagesFlag && commentBody) {
514
- verboseLog(
515
- 'blue',
516
- `Processing images in comment by @${comment.user.login}...`
517
- );
518
- const result = await processContent(
519
- commentBody,
520
- imagesDir,
521
- token,
522
- prNumber,
523
- downloadImagesFlag
524
- );
525
- commentBody = result.content;
526
- allDownloadedImages = [
527
- ...allDownloadedImages,
528
- ...result.downloadedImages,
529
- ];
530
- }
463
+ markdown += '## Conversation\n\n';
531
464
 
532
- markdown += `### Comment by @${comment.user.login} (${comment.created_at})\n\n`;
533
- markdown += `${commentBody}\n\n`;
534
- markdown += '---\n\n';
465
+ const timelineEvents = [];
466
+
467
+ for (const comment of comments) {
468
+ timelineEvents.push({
469
+ type: 'comment',
470
+ timestamp: new Date(comment.created_at),
471
+ data: comment,
472
+ });
473
+ }
474
+
475
+ for (const review of reviews) {
476
+ if (review.submitted_at) {
477
+ timelineEvents.push({
478
+ type: 'review',
479
+ timestamp: new Date(review.submitted_at),
480
+ data: review,
481
+ });
535
482
  }
536
483
  }
537
484
 
538
- // Reviews
539
- if (reviews.length > 0) {
540
- markdown += `## Reviews\n\n`;
541
- for (const review of reviews) {
542
- let reviewBody = review.body || '';
543
- if (downloadImagesFlag && reviewBody) {
544
- verboseLog(
545
- 'blue',
546
- `Processing images in review by @${review.user.login}...`
547
- );
548
- const result = await processContent(
549
- reviewBody,
550
- imagesDir,
551
- token,
552
- prNumber,
553
- downloadImagesFlag
554
- );
555
- reviewBody = result.content;
556
- allDownloadedImages = [
557
- ...allDownloadedImages,
558
- ...result.downloadedImages,
559
- ];
560
- }
485
+ timelineEvents.sort((a, b) => a.timestamp - b.timestamp);
561
486
 
562
- markdown += `### Review by @${review.user.login} (${review.submitted_at})\n`;
563
- markdown += `**State:** ${review.state}\n\n`;
487
+ if (timelineEvents.length === 0) {
488
+ markdown += '_No comments or reviews._\n\n';
489
+ } else {
490
+ for (const event of timelineEvents) {
491
+ if (event.type === 'comment') {
492
+ const comment = event.data;
493
+ let commentBody = comment.body || '';
494
+ if (downloadImagesFlag && commentBody) {
495
+ verboseLog(
496
+ 'blue',
497
+ `Processing images in comment by @${comment.user.login}...`
498
+ );
499
+ const result = await processContent(
500
+ commentBody,
501
+ imagesDir,
502
+ token,
503
+ prNumber,
504
+ downloadImagesFlag
505
+ );
506
+ commentBody = result.content;
507
+ allDownloadedImages = [
508
+ ...allDownloadedImages,
509
+ ...result.downloadedImages,
510
+ ];
511
+ }
564
512
 
565
- if (reviewBody) {
566
- markdown += `${reviewBody}\n\n`;
567
- }
513
+ markdown += `### 💬 Comment by [@${comment.user.login}](https://github.com/${comment.user.login})\n`;
514
+ markdown += `*${formatDate(comment.created_at)}*\n\n`;
515
+ markdown += `${commentBody}\n\n`;
516
+ markdown += '---\n\n';
517
+ } else if (event.type === 'review') {
518
+ const review = event.data;
519
+ let reviewBody = review.body || '';
520
+ if (downloadImagesFlag && reviewBody) {
521
+ verboseLog(
522
+ 'blue',
523
+ `Processing images in review by @${review.user.login}...`
524
+ );
525
+ const result = await processContent(
526
+ reviewBody,
527
+ imagesDir,
528
+ token,
529
+ prNumber,
530
+ downloadImagesFlag
531
+ );
532
+ reviewBody = result.content;
533
+ allDownloadedImages = [
534
+ ...allDownloadedImages,
535
+ ...result.downloadedImages,
536
+ ];
537
+ }
568
538
 
569
- // Add review comments for this review
570
- const reviewReviewComments = reviewComments.filter(
571
- (rc) => rc.pull_request_review_id === review.id
572
- );
573
- if (reviewReviewComments.length > 0) {
574
- markdown += `#### Review Comments\n\n`;
575
- for (const rc of reviewReviewComments) {
576
- let rcBody = rc.body || '';
577
- if (downloadImagesFlag && rcBody) {
578
- const result = await processContent(
579
- rcBody,
580
- imagesDir,
581
- token,
582
- prNumber,
583
- downloadImagesFlag
584
- );
585
- rcBody = result.content;
586
- allDownloadedImages = [
587
- ...allDownloadedImages,
588
- ...result.downloadedImages,
589
- ];
590
- }
539
+ const stateEmoji =
540
+ review.state === 'APPROVED'
541
+ ? '✅'
542
+ : review.state === 'CHANGES_REQUESTED'
543
+ ? '❌'
544
+ : review.state === 'COMMENTED'
545
+ ? '💬'
546
+ : '📝';
547
+
548
+ markdown += `### ${stateEmoji} Review by [@${review.user.login}](https://github.com/${review.user.login})\n`;
549
+ markdown += `*${formatDate(review.submitted_at)}* — **${review.state}**\n\n`;
591
550
 
592
- const lineInfo = rc.line ? `:${rc.line}` : '';
593
- markdown += `**File:** ${rc.path}${lineInfo}\n`;
594
- markdown += `${rcBody}\n\n`;
551
+ if (reviewBody) {
552
+ markdown += `${reviewBody}\n\n`;
595
553
  }
596
- }
597
554
 
598
- markdown += '---\n\n';
555
+ // Add review comments for this review
556
+ const reviewReviewComments = reviewComments.filter(
557
+ (rc) => rc.pull_request_review_id === review.id
558
+ );
559
+ if (reviewReviewComments.length > 0) {
560
+ markdown += `#### Inline Comments\n\n`;
561
+ for (const rc of reviewReviewComments) {
562
+ let rcBody = rc.body || '';
563
+ if (downloadImagesFlag && rcBody) {
564
+ const result = await processContent(
565
+ rcBody,
566
+ imagesDir,
567
+ token,
568
+ prNumber,
569
+ downloadImagesFlag
570
+ );
571
+ rcBody = result.content;
572
+ allDownloadedImages = [
573
+ ...allDownloadedImages,
574
+ ...result.downloadedImages,
575
+ ];
576
+ }
577
+
578
+ const lineInfo = rc.line ? `:${rc.line}` : '';
579
+ markdown += `**\`${rc.path}${lineInfo}\`**\n\n`;
580
+ markdown += `${rcBody}\n\n`;
581
+ if (rc.diff_hunk) {
582
+ markdown += '```diff\n';
583
+ markdown += `${rc.diff_hunk}\n`;
584
+ markdown += '```\n\n';
585
+ }
586
+ }
587
+ }
588
+
589
+ markdown += '---\n\n';
590
+ }
599
591
  }
600
592
  }
601
593
 
602
- // Standalone review comments (not associated with a review)
603
594
  const standaloneReviewComments = reviewComments.filter(
604
595
  (rc) => !rc.pull_request_review_id
605
596
  );
606
597
  if (standaloneReviewComments.length > 0) {
607
- markdown += `## Review Comments\n\n`;
598
+ markdown += `## Inline Code Comments\n\n`;
608
599
  for (const comment of standaloneReviewComments) {
609
600
  let commentBody = comment.body || '';
610
601
  if (downloadImagesFlag && commentBody) {
@@ -622,191 +613,208 @@ async function convertToMarkdown(data, options = {}) {
622
613
  ];
623
614
  }
624
615
 
625
- markdown += `**@${comment.user.login}** commented on \`${comment.path}\``;
616
+ markdown += `### [@${comment.user.login}](https://github.com/${comment.user.login}) on \`${comment.path}\``;
626
617
  if (comment.line) {
627
618
  markdown += ` (line ${comment.line})`;
628
619
  }
629
- markdown += `:\n`;
630
- markdown += `*${comment.created_at}*\n\n`;
620
+ markdown += `\n`;
621
+ markdown += `*${formatDate(comment.created_at)}*\n\n`;
631
622
  markdown += `${commentBody}\n\n`;
632
623
  if (comment.diff_hunk) {
633
624
  markdown += '```diff\n';
634
625
  markdown += `${comment.diff_hunk}\n`;
635
626
  markdown += '```\n\n';
636
627
  }
628
+ markdown += '---\n\n';
637
629
  }
638
630
  }
639
631
 
640
- // Commits
641
- if (commits.length > 0) {
642
- markdown += `## Commits (${commits.length})\n\n`;
643
- for (const commit of commits) {
644
- const message = commit.commit.message.split('\n')[0]; // First line only
645
- const sha = commit.sha.substring(0, 7);
646
- markdown += `- [\`${sha}\`](${commit.html_url}) ${message} - @${commit.author?.login || 'unknown'}\n`;
647
- }
648
- markdown += '\n';
649
- }
632
+ markdown += generateCommitsMarkdown(commits);
650
633
 
651
- // Files changed
652
- if (files.length > 0) {
653
- markdown += `## Files Changed (${files.length})\n\n`;
654
- for (const file of files) {
655
- const statusIcon =
656
- file.status === 'added'
657
- ? '🆕'
658
- : file.status === 'removed'
659
- ? '🗑️'
660
- : file.status === 'modified'
661
- ? '✏️'
662
- : file.status === 'renamed'
663
- ? '📝'
664
- : '📄';
665
- markdown += `${statusIcon} **${file.filename}** (+${file.additions} -${file.deletions})\n`;
666
- if (file.status === 'renamed') {
667
- markdown += ` - Renamed from: \`${file.previous_filename}\`\n`;
668
- }
669
- }
670
- markdown += '\n';
671
- }
634
+ markdown += generateFilesMarkdown(files);
672
635
 
673
636
  return { markdown, downloadedImages: allDownloadedImages };
674
637
  }
675
638
 
676
- // Convert PR data to JSON format
677
- function convertToJson(data, downloadedImages = []) {
678
- const { pr, files, comments, reviewComments, reviews, commits } = data;
639
+ /**
640
+ * Convert PR data to JSON format
641
+ * @param {Object} data - PR data from loadPullRequest
642
+ * @param {Array} downloadedImages - Array of downloaded image info
643
+ * @returns {string} JSON string
644
+ */
645
+ export function convertToJson(data, downloadedImages = []) {
646
+ return formattersConvertToJson(data, downloadedImages);
647
+ }
648
+
649
+ /**
650
+ * Save PR data to a folder with all assets for offline viewing
651
+ * @param {Object} data - PR data from loadPullRequest
652
+ * @param {Object} options - Save options
653
+ * @param {string} options.outputDir - Output directory
654
+ * @param {string} options.format - Output format ('markdown' or 'json')
655
+ * @param {boolean} options.downloadImages - Download images (default: true)
656
+ * @param {string} options.token - GitHub token for downloading images
657
+ * @returns {Promise<{mdPath: string, jsonPath: string, imagesDir: string, downloadedImages: Array}>}
658
+ */
659
+ export async function savePullRequest(data, options = {}) {
660
+ const {
661
+ outputDir,
662
+ format = 'markdown',
663
+ downloadImages: downloadImagesFlag = true,
664
+ token = '',
665
+ } = options;
666
+
667
+ const prNumber = data.pr.number;
668
+ const prDir = path.join(outputDir, `pr-${prNumber}`);
669
+ const imagesDir = path.join(prDir, 'images');
670
+ const mdPath = path.join(prDir, `pr-${prNumber}.md`);
671
+ const jsonPath = path.join(prDir, `pr-${prNumber}.json`);
672
+
673
+ // Ensure directories exist
674
+ await fs.ensureDir(prDir);
675
+
676
+ let downloadedImages = [];
677
+
678
+ // Generate markdown
679
+ log('blue', `📝 Converting to ${format}...`);
680
+
681
+ const mdResult = await convertToMarkdown(data, {
682
+ downloadImagesFlag,
683
+ imagesDir,
684
+ token,
685
+ prNumber,
686
+ });
687
+ downloadedImages = mdResult.downloadedImages;
679
688
 
680
- return JSON.stringify(
681
- {
682
- pullRequest: {
683
- number: pr.number,
684
- title: pr.title,
685
- state: pr.state,
686
- url: pr.html_url,
687
- author: pr.user.login,
688
- createdAt: pr.created_at,
689
- updatedAt: pr.updated_at,
690
- mergedAt: pr.merged_at,
691
- closedAt: pr.closed_at,
692
- base: pr.base.ref,
693
- head: pr.head.ref,
694
- additions: pr.additions,
695
- deletions: pr.deletions,
696
- changedFiles: pr.changed_files,
697
- labels: pr.labels?.map((l) => l.name) || [],
698
- body: pr.body,
699
- },
700
- commits: commits.map((c) => ({
701
- sha: c.sha,
702
- message: c.commit.message,
703
- author: c.author?.login || 'unknown',
704
- url: c.html_url,
705
- })),
706
- files: files.map((f) => ({
707
- filename: f.filename,
708
- status: f.status,
709
- additions: f.additions,
710
- deletions: f.deletions,
711
- previousFilename: f.previous_filename,
712
- })),
713
- reviews: reviews.map((r) => ({
714
- id: r.id,
715
- author: r.user.login,
716
- state: r.state,
717
- body: r.body,
718
- submittedAt: r.submitted_at,
719
- })),
720
- reviewComments: reviewComments.map((c) => ({
721
- id: c.id,
722
- author: c.user.login,
723
- body: c.body,
724
- path: c.path,
725
- line: c.line,
726
- createdAt: c.created_at,
727
- diffHunk: c.diff_hunk,
728
- reviewId: c.pull_request_review_id,
729
- })),
730
- comments: comments.map((c) => ({
731
- id: c.id,
732
- author: c.user.login,
733
- body: c.body,
734
- createdAt: c.created_at,
735
- })),
736
- downloadedImages: downloadedImages.map((img) => ({
737
- originalUrl: img.originalUrl,
738
- localPath: img.relativePath,
739
- format: img.format,
740
- })),
741
- },
742
- null,
743
- 2
689
+ // Save markdown
690
+ await fs.writeFile(mdPath, mdResult.markdown, 'utf8');
691
+ log('green', `✅ Saved markdown to ${mdPath}`);
692
+
693
+ // Always save JSON as well for metadata
694
+ const jsonContent = convertToJson(data, downloadedImages);
695
+ await fs.writeFile(jsonPath, jsonContent, 'utf8');
696
+ log('green', `✅ Saved JSON metadata to ${jsonPath}`);
697
+
698
+ if (downloadedImages.length > 0) {
699
+ log(
700
+ 'green',
701
+ `📁 Downloaded ${downloadedImages.length} image(s) to ${imagesDir}`
702
+ );
703
+ }
704
+
705
+ return {
706
+ mdPath,
707
+ jsonPath,
708
+ imagesDir,
709
+ downloadedImages,
710
+ };
711
+ }
712
+
713
+ // CLI IMPLEMENTATION
714
+
715
+ // Only run CLI when executed directly, not when imported as a module
716
+ // Check if this module is the main entry point by comparing paths
717
+ function isRunningAsCli() {
718
+ // Get the actual script being run
719
+ const scriptArg = process.argv[1];
720
+ if (!scriptArg) {
721
+ return false;
722
+ }
723
+
724
+ // Normalize paths for comparison
725
+ const scriptPath = path.resolve(scriptArg);
726
+ const thisModulePath = path.resolve(__filename);
727
+
728
+ // Check if the script being run is this module
729
+ // This handles both direct execution and bun run
730
+ return (
731
+ scriptPath === thisModulePath ||
732
+ scriptPath.endsWith('gh-load-pull-request.mjs') ||
733
+ scriptPath.endsWith('gh-load-pull-request')
744
734
  );
745
735
  }
746
736
 
747
- // Configure CLI arguments
748
- const scriptName = path.basename(process.argv[1]);
749
- const argv = yargs(hideBin(process.argv))
750
- .scriptName(scriptName)
751
- .version(version)
752
- .usage('Usage: $0 <pr-url> [options]')
753
- .command(
754
- '$0 <pr>',
755
- 'Download a GitHub pull request and convert it to markdown',
756
- (yargs) => {
757
- yargs.positional('pr', {
758
- describe:
759
- 'Pull request URL or shorthand (e.g., https://github.com/owner/repo/pull/123 or owner/repo#123)',
760
- type: 'string',
761
- });
762
- }
763
- )
764
- .option('token', {
765
- alias: 't',
766
- type: 'string',
767
- describe: 'GitHub personal access token (optional for public PRs)',
768
- default: process.env.GITHUB_TOKEN,
769
- })
770
- .option('output', {
771
- alias: 'o',
772
- type: 'string',
773
- describe: 'Output directory (default: current directory)',
774
- })
775
- .option('download-images', {
776
- type: 'boolean',
777
- describe: 'Download embedded images',
778
- default: true,
779
- })
780
- .option('include-reviews', {
781
- type: 'boolean',
782
- describe: 'Include PR reviews',
783
- default: true,
784
- })
785
- .option('format', {
786
- type: 'string',
787
- describe: 'Output format: markdown, json',
788
- default: 'markdown',
789
- choices: ['markdown', 'json'],
790
- })
791
- .option('verbose', {
792
- alias: 'v',
793
- type: 'boolean',
794
- describe: 'Enable verbose logging',
795
- default: false,
796
- })
797
- .help('h')
798
- .alias('h', 'help')
799
- .example('$0 https://github.com/owner/repo/pull/123', 'Download PR #123')
800
- .example('$0 owner/repo#123', 'Download PR using shorthand format')
801
- .example('$0 owner/repo#123 -o ./output', 'Save to output directory')
802
- .example('$0 owner/repo#123 --format json', 'Output as JSON')
803
- .example('$0 owner/repo#123 --no-download-images', 'Skip image download')
804
- .example(
805
- '$0 https://github.com/owner/repo/pull/123 --token ghp_xxx',
806
- 'Download private PR'
807
- ).argv;
737
+ /**
738
+ * Parse CLI arguments
739
+ * @returns {Object} Parsed CLI arguments
740
+ */
741
+ function parseCliArgs() {
742
+ const scriptName = path.basename(process.argv[1] || 'gh-load-pull-request');
743
+ return yargs(hideBin(process.argv))
744
+ .scriptName(scriptName)
745
+ .version(version)
746
+ .usage('Usage: $0 <pr-url> [options]')
747
+ .command(
748
+ '$0 <pr>',
749
+ 'Download a GitHub pull request and convert it to markdown',
750
+ (yargs) => {
751
+ yargs.positional('pr', {
752
+ describe:
753
+ 'Pull request URL or shorthand (e.g., https://github.com/owner/repo/pull/123 or owner/repo#123)',
754
+ type: 'string',
755
+ });
756
+ }
757
+ )
758
+ .option('token', {
759
+ alias: 't',
760
+ type: 'string',
761
+ describe: 'GitHub personal access token (optional for public PRs)',
762
+ default: process.env.GITHUB_TOKEN,
763
+ })
764
+ .option('output', {
765
+ alias: 'o',
766
+ type: 'string',
767
+ describe: 'Output directory (creates pr-<number>/ subfolder)',
768
+ })
769
+ .option('download-images', {
770
+ type: 'boolean',
771
+ describe: 'Download embedded images',
772
+ default: true,
773
+ })
774
+ .option('include-reviews', {
775
+ type: 'boolean',
776
+ describe: 'Include PR reviews',
777
+ default: true,
778
+ })
779
+ .option('format', {
780
+ type: 'string',
781
+ describe: 'Output format: markdown, json',
782
+ default: 'markdown',
783
+ choices: ['markdown', 'json'],
784
+ })
785
+ .option('verbose', {
786
+ alias: 'v',
787
+ type: 'boolean',
788
+ describe: 'Enable verbose logging',
789
+ default: false,
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
+ })
801
+ .help('h')
802
+ .alias('h', 'help')
803
+ .example('$0 https://github.com/owner/repo/pull/123', 'Download PR #123')
804
+ .example('$0 owner/repo#123', 'Download PR using shorthand format')
805
+ .example('$0 owner/repo#123 -o ./output', 'Save to output directory')
806
+ .example('$0 owner/repo#123 --format json', 'Output as JSON')
807
+ .example('$0 owner/repo#123 --no-download-images', 'Skip image download')
808
+ .example(
809
+ '$0 https://github.com/owner/repo/pull/123 --token ghp_xxx',
810
+ 'Download private PR'
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;
814
+ }
808
815
 
809
816
  async function main() {
817
+ const argv = parseCliArgs();
810
818
  const {
811
819
  pr: prInput,
812
820
  token: tokenArg,
@@ -815,11 +823,22 @@ async function main() {
815
823
  'include-reviews': includeReviews,
816
824
  format,
817
825
  verbose,
826
+ 'force-api': forceApi,
827
+ 'force-gh': forceGh,
818
828
  } = argv;
819
829
 
820
830
  // Set verbose mode
821
831
  verboseMode = verbose;
822
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
+
823
842
  // Parse PR input first (before potentially slow gh CLI token fetch)
824
843
  const prInfo = parsePrUrl(prInput);
825
844
  if (!prInfo) {
@@ -833,91 +852,75 @@ async function main() {
833
852
 
834
853
  let token = tokenArg;
835
854
 
836
- // If no token provided, try to get it from gh CLI
837
- 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) {
838
857
  const ghToken = await getGhToken();
839
858
  if (ghToken) {
840
859
  token = ghToken;
841
- log('cyan', '🔑 Using GitHub token from gh CLI');
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
+ }
842
865
  }
843
866
  }
844
867
 
845
868
  const { owner, repo, prNumber } = prInfo;
846
869
 
847
- // Fetch PR data
848
- const data = await fetchPullRequest(owner, repo, prNumber, token, {
849
- includeReviews,
850
- });
851
-
852
- // Determine output paths
853
- const outputDir = output || process.cwd();
854
- const imagesDir = path.join(outputDir, `pr-${prNumber}-images`);
855
- const mdOutputPath = path.join(outputDir, `pr-${prNumber}.md`);
856
- const jsonOutputPath = path.join(outputDir, `pr-${prNumber}.json`);
857
-
858
- // Convert to appropriate format
859
- log('blue', `📝 Converting to ${format}...`);
860
-
861
- let outputContent;
862
- let downloadedImages = [];
870
+ try {
871
+ // Fetch PR data
872
+ const data = await loadPullRequest({
873
+ owner,
874
+ repo,
875
+ prNumber,
876
+ token,
877
+ includeReviews,
878
+ forceApi,
879
+ forceGh,
880
+ });
863
881
 
864
- if (format === 'json') {
865
- // For JSON, we might still want to download images
866
- if (downloadImagesFlag) {
867
- log('blue', '🖼️ Processing images...');
868
- // Process all content for images
869
- const allContent = [
870
- data.pr.body || '',
871
- ...data.comments.map((c) => c.body || ''),
872
- ...data.reviews.map((r) => r.body || ''),
873
- ...data.reviewComments.map((rc) => rc.body || ''),
874
- ].join('\n\n');
875
-
876
- const result = await downloadImages(
877
- allContent,
878
- imagesDir,
882
+ // Determine output paths
883
+ if (output) {
884
+ // Save to directory
885
+ await savePullRequest(data, {
886
+ outputDir: output,
887
+ format,
888
+ downloadImages: downloadImagesFlag,
879
889
  token,
880
- prNumber
881
- );
882
- downloadedImages = result.downloadedImages;
890
+ });
891
+ } else {
892
+ // Output to stdout
893
+ if (format === 'json') {
894
+ const jsonContent = convertToJson(data, []);
895
+ console.log(jsonContent);
896
+ } else {
897
+ const { markdown } = await convertToMarkdown(data, {
898
+ downloadImagesFlag: false, // Don't download images when outputting to stdout
899
+ imagesDir: '',
900
+ token: '',
901
+ prNumber,
902
+ });
903
+ console.log(markdown);
904
+ }
883
905
  }
884
- outputContent = convertToJson(data, downloadedImages);
885
- } else {
886
- // Markdown format with image processing
887
- const result = await convertToMarkdown(data, {
888
- downloadImagesFlag,
889
- imagesDir,
890
- token,
891
- prNumber,
892
- });
893
- outputContent = result.markdown;
894
- downloadedImages = result.downloadedImages;
895
- }
896
906
 
897
- // Output
898
- if (output) {
899
- await fs.ensureDir(outputDir);
900
- const outputPath = format === 'json' ? jsonOutputPath : mdOutputPath;
901
- await fs.writeFile(outputPath, outputContent, 'utf8');
902
- log('green', `✅ Saved to ${outputPath}`);
903
-
904
- if (downloadedImages.length > 0) {
905
- log(
906
- 'green',
907
- `📁 Downloaded ${downloadedImages.length} image(s) to ${imagesDir}`
908
- );
907
+ log('blue', '🎉 Done!');
908
+ } catch (error) {
909
+ log('red', `❌ ${error.message}`);
910
+ if (verboseMode) {
911
+ console.error(error.stack);
909
912
  }
910
- } else {
911
- console.log(outputContent);
913
+ process.exit(1);
912
914
  }
913
-
914
- log('blue', '🎉 Done!');
915
915
  }
916
916
 
917
- main().catch((error) => {
918
- log('red', `💥 Script failed: ${error.message}`);
919
- if (verboseMode) {
920
- console.error(error.stack);
921
- }
922
- process.exit(1);
923
- });
917
+ // Run CLI if this is the main module
918
+ if (isRunningAsCli()) {
919
+ main().catch((error) => {
920
+ log('red', `💥 Script failed: ${error.message}`);
921
+ if (verboseMode) {
922
+ console.error(error.stack);
923
+ }
924
+ process.exit(1);
925
+ });
926
+ }