gh-load-pull-request 0.5.0 → 0.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/package.json +1 -1
- package/src/formatters.mjs +245 -0
- package/src/gh-load-pull-request.mjs +451 -377
package/package.json
CHANGED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatters for gh-load-pull-request
|
|
3
|
+
* Contains functions for converting PR data to markdown and JSON formats
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format a date string for display
|
|
8
|
+
* @param {string} dateStr - ISO date string
|
|
9
|
+
* @returns {string} Formatted date string
|
|
10
|
+
*/
|
|
11
|
+
export function formatDate(dateStr) {
|
|
12
|
+
if (!dateStr) {
|
|
13
|
+
return '';
|
|
14
|
+
}
|
|
15
|
+
const date = new Date(dateStr);
|
|
16
|
+
return date
|
|
17
|
+
.toISOString()
|
|
18
|
+
.replace('T', ' ')
|
|
19
|
+
.replace(/\.\d+Z$/, ' UTC');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert PR data to JSON format
|
|
24
|
+
* @param {Object} data - PR data from loadPullRequest
|
|
25
|
+
* @param {Array} downloadedImages - Array of downloaded image info
|
|
26
|
+
* @returns {string} JSON string
|
|
27
|
+
*/
|
|
28
|
+
export function convertToJson(data, downloadedImages = []) {
|
|
29
|
+
const { pr, files, comments, reviewComments, reviews, commits } = data;
|
|
30
|
+
|
|
31
|
+
return JSON.stringify(
|
|
32
|
+
{
|
|
33
|
+
pullRequest: {
|
|
34
|
+
number: pr.number,
|
|
35
|
+
title: pr.title,
|
|
36
|
+
state: pr.state,
|
|
37
|
+
draft: pr.draft,
|
|
38
|
+
merged: pr.merged,
|
|
39
|
+
url: pr.html_url,
|
|
40
|
+
author: {
|
|
41
|
+
login: pr.user.login,
|
|
42
|
+
url: `https://github.com/${pr.user.login}`,
|
|
43
|
+
},
|
|
44
|
+
createdAt: pr.created_at,
|
|
45
|
+
updatedAt: pr.updated_at,
|
|
46
|
+
mergedAt: pr.merged_at,
|
|
47
|
+
closedAt: pr.closed_at,
|
|
48
|
+
mergedBy: pr.merged_by
|
|
49
|
+
? {
|
|
50
|
+
login: pr.merged_by.login,
|
|
51
|
+
url: `https://github.com/${pr.merged_by.login}`,
|
|
52
|
+
}
|
|
53
|
+
: null,
|
|
54
|
+
base: {
|
|
55
|
+
ref: pr.base.ref,
|
|
56
|
+
sha: pr.base.sha,
|
|
57
|
+
},
|
|
58
|
+
head: {
|
|
59
|
+
ref: pr.head.ref,
|
|
60
|
+
sha: pr.head.sha,
|
|
61
|
+
},
|
|
62
|
+
additions: pr.additions,
|
|
63
|
+
deletions: pr.deletions,
|
|
64
|
+
changedFiles: pr.changed_files,
|
|
65
|
+
labels: pr.labels?.map((l) => ({ name: l.name, color: l.color })) || [],
|
|
66
|
+
assignees:
|
|
67
|
+
pr.assignees?.map((a) => ({
|
|
68
|
+
login: a.login,
|
|
69
|
+
url: `https://github.com/${a.login}`,
|
|
70
|
+
})) || [],
|
|
71
|
+
requestedReviewers:
|
|
72
|
+
pr.requested_reviewers?.map((r) => ({
|
|
73
|
+
login: r.login,
|
|
74
|
+
url: `https://github.com/${r.login}`,
|
|
75
|
+
})) || [],
|
|
76
|
+
milestone: pr.milestone
|
|
77
|
+
? { title: pr.milestone.title, number: pr.milestone.number }
|
|
78
|
+
: null,
|
|
79
|
+
body: pr.body,
|
|
80
|
+
},
|
|
81
|
+
commits: commits.map((c) => ({
|
|
82
|
+
sha: c.sha,
|
|
83
|
+
message: c.commit.message,
|
|
84
|
+
author: c.author?.login || c.commit.author?.name || 'unknown',
|
|
85
|
+
url: c.html_url,
|
|
86
|
+
date: c.commit.author?.date,
|
|
87
|
+
})),
|
|
88
|
+
files: files.map((f) => ({
|
|
89
|
+
filename: f.filename,
|
|
90
|
+
status: f.status,
|
|
91
|
+
additions: f.additions,
|
|
92
|
+
deletions: f.deletions,
|
|
93
|
+
previousFilename: f.previous_filename,
|
|
94
|
+
patch: f.patch,
|
|
95
|
+
})),
|
|
96
|
+
reviews: reviews.map((r) => ({
|
|
97
|
+
id: r.id,
|
|
98
|
+
author: r.user.login,
|
|
99
|
+
state: r.state,
|
|
100
|
+
body: r.body,
|
|
101
|
+
submittedAt: r.submitted_at,
|
|
102
|
+
})),
|
|
103
|
+
reviewComments: reviewComments.map((c) => ({
|
|
104
|
+
id: c.id,
|
|
105
|
+
author: c.user.login,
|
|
106
|
+
body: c.body,
|
|
107
|
+
path: c.path,
|
|
108
|
+
line: c.line,
|
|
109
|
+
createdAt: c.created_at,
|
|
110
|
+
diffHunk: c.diff_hunk,
|
|
111
|
+
reviewId: c.pull_request_review_id,
|
|
112
|
+
})),
|
|
113
|
+
comments: comments.map((c) => ({
|
|
114
|
+
id: c.id,
|
|
115
|
+
author: c.user.login,
|
|
116
|
+
body: c.body,
|
|
117
|
+
createdAt: c.created_at,
|
|
118
|
+
})),
|
|
119
|
+
downloadedImages: downloadedImages.map((img) => ({
|
|
120
|
+
originalUrl: img.originalUrl,
|
|
121
|
+
localPath: img.relativePath,
|
|
122
|
+
format: img.format,
|
|
123
|
+
})),
|
|
124
|
+
},
|
|
125
|
+
null,
|
|
126
|
+
2
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Generate markdown for metadata section
|
|
132
|
+
* @param {Object} pr - Pull request data
|
|
133
|
+
* @returns {string} Markdown content
|
|
134
|
+
*/
|
|
135
|
+
export function generateMetadataMarkdown(pr) {
|
|
136
|
+
let markdown = `## Metadata\n\n`;
|
|
137
|
+
|
|
138
|
+
markdown += `| Field | Value |\n`;
|
|
139
|
+
markdown += `|-------|-------|\n`;
|
|
140
|
+
markdown += `| **Number** | #${pr.number} |\n`;
|
|
141
|
+
markdown += `| **URL** | ${pr.html_url} |\n`;
|
|
142
|
+
markdown += `| **Author** | [@${pr.user.login}](https://github.com/${pr.user.login}) |\n`;
|
|
143
|
+
markdown += `| **State** | ${pr.state}${pr.merged ? ' (merged)' : pr.draft ? ' (draft)' : ''} |\n`;
|
|
144
|
+
markdown += `| **Created** | ${formatDate(pr.created_at)} |\n`;
|
|
145
|
+
markdown += `| **Updated** | ${formatDate(pr.updated_at)} |\n`;
|
|
146
|
+
|
|
147
|
+
if (pr.merged_at) {
|
|
148
|
+
markdown += `| **Merged** | ${formatDate(pr.merged_at)} |\n`;
|
|
149
|
+
if (pr.merged_by) {
|
|
150
|
+
markdown += `| **Merged by** | [@${pr.merged_by.login}](https://github.com/${pr.merged_by.login}) |\n`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (pr.closed_at && !pr.merged_at) {
|
|
154
|
+
markdown += `| **Closed** | ${formatDate(pr.closed_at)} |\n`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
markdown += `| **Base** | \`${pr.base.ref}\` |\n`;
|
|
158
|
+
markdown += `| **Head** | \`${pr.head.ref}\` |\n`;
|
|
159
|
+
markdown += `| **Additions** | +${pr.additions} |\n`;
|
|
160
|
+
markdown += `| **Deletions** | -${pr.deletions} |\n`;
|
|
161
|
+
markdown += `| **Changed Files** | ${pr.changed_files} |\n`;
|
|
162
|
+
markdown += '\n';
|
|
163
|
+
|
|
164
|
+
if (pr.labels && pr.labels.length > 0) {
|
|
165
|
+
markdown += `**Labels:** ${pr.labels.map((l) => `\`${l.name}\``).join(', ')}\n\n`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (pr.assignees && pr.assignees.length > 0) {
|
|
169
|
+
markdown += `**Assignees:** ${pr.assignees.map((a) => `[@${a.login}](https://github.com/${a.login})`).join(', ')}\n\n`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (pr.requested_reviewers && pr.requested_reviewers.length > 0) {
|
|
173
|
+
markdown += `**Requested Reviewers:** ${pr.requested_reviewers.map((r) => `[@${r.login}](https://github.com/${r.login})`).join(', ')}\n\n`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (pr.milestone) {
|
|
177
|
+
markdown += `**Milestone:** ${pr.milestone.title}\n\n`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return markdown;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Generate markdown for commits section
|
|
185
|
+
* @param {Array} commits - Array of commit data
|
|
186
|
+
* @returns {string} Markdown content
|
|
187
|
+
*/
|
|
188
|
+
export function generateCommitsMarkdown(commits) {
|
|
189
|
+
if (commits.length === 0) {
|
|
190
|
+
return '';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let markdown = `## Commits (${commits.length})\n\n`;
|
|
194
|
+
|
|
195
|
+
for (const commit of commits) {
|
|
196
|
+
const message = commit.commit.message.split('\n')[0];
|
|
197
|
+
const sha = commit.sha.substring(0, 7);
|
|
198
|
+
const author =
|
|
199
|
+
commit.author?.login || commit.commit.author?.name || 'unknown';
|
|
200
|
+
const authorLink = commit.author
|
|
201
|
+
? `[@${author}](https://github.com/${author})`
|
|
202
|
+
: author;
|
|
203
|
+
markdown += `- [\`${sha}\`](${commit.html_url}) ${message} — ${authorLink}\n`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
markdown += '\n';
|
|
207
|
+
return markdown;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Generate markdown for files changed section
|
|
212
|
+
* @param {Array} files - Array of file data
|
|
213
|
+
* @returns {string} Markdown content
|
|
214
|
+
*/
|
|
215
|
+
export function generateFilesMarkdown(files) {
|
|
216
|
+
if (files.length === 0) {
|
|
217
|
+
return '';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
let markdown = `## Files Changed (${files.length})\n\n`;
|
|
221
|
+
markdown += `| Status | File | Changes |\n`;
|
|
222
|
+
markdown += `|--------|------|--------:|\n`;
|
|
223
|
+
|
|
224
|
+
for (const file of files) {
|
|
225
|
+
const statusIcon =
|
|
226
|
+
file.status === 'added'
|
|
227
|
+
? '🆕 Added'
|
|
228
|
+
: file.status === 'removed'
|
|
229
|
+
? '🗑️ Removed'
|
|
230
|
+
: file.status === 'modified'
|
|
231
|
+
? '✏️ Modified'
|
|
232
|
+
: file.status === 'renamed'
|
|
233
|
+
? '📝 Renamed'
|
|
234
|
+
: `📄 ${file.status}`;
|
|
235
|
+
const changes = `+${file.additions} -${file.deletions}`;
|
|
236
|
+
let filename = file.filename;
|
|
237
|
+
if (file.status === 'renamed' && file.previous_filename) {
|
|
238
|
+
filename = `${file.previous_filename} → ${file.filename}`;
|
|
239
|
+
}
|
|
240
|
+
markdown += `| ${statusIcon} | \`${filename}\` | ${changes} |\n`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
markdown += '\n';
|
|
244
|
+
return markdown;
|
|
245
|
+
}
|
|
@@ -1,37 +1,38 @@
|
|
|
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
11
|
import { Octokit } from '@octokit/rest';
|
|
15
12
|
import fs from 'fs-extra';
|
|
16
13
|
import yargs from 'yargs';
|
|
17
14
|
import { hideBin } from 'yargs/helpers';
|
|
18
15
|
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
import {
|
|
17
|
+
formatDate,
|
|
18
|
+
convertToJson as formattersConvertToJson,
|
|
19
|
+
generateMetadataMarkdown,
|
|
20
|
+
generateCommitsMarkdown,
|
|
21
|
+
generateFilesMarkdown,
|
|
22
|
+
} from './formatters.mjs';
|
|
21
23
|
|
|
24
|
+
let version = '0.1.0';
|
|
22
25
|
try {
|
|
23
|
-
const packagePath = path.join(__dirname, 'package.json');
|
|
24
|
-
// Use node:fs for Deno compatibility (fs-extra has issues with Deno)
|
|
26
|
+
const packagePath = path.join(__dirname, '..', 'package.json');
|
|
25
27
|
const { readFileSync, existsSync } = await import('node:fs');
|
|
26
28
|
if (existsSync(packagePath)) {
|
|
27
29
|
const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
28
30
|
version = packageJson.version;
|
|
29
31
|
}
|
|
30
32
|
} catch (_error) {
|
|
31
|
-
|
|
33
|
+
/* Use fallback version */
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
// Colors for console output
|
|
35
36
|
const colors = {
|
|
36
37
|
green: '\x1b[32m',
|
|
37
38
|
yellow: '\x1b[33m',
|
|
@@ -44,19 +45,32 @@ const colors = {
|
|
|
44
45
|
reset: '\x1b[0m',
|
|
45
46
|
};
|
|
46
47
|
|
|
47
|
-
// Verbose logging flag (set by CLI option)
|
|
48
48
|
let verboseMode = false;
|
|
49
|
+
let silentMode = false;
|
|
49
50
|
|
|
50
|
-
const log = (color, message) =>
|
|
51
|
-
|
|
51
|
+
const log = (color, message) => {
|
|
52
|
+
if (!silentMode) {
|
|
53
|
+
console.error(`${colors[color]}${message}${colors.reset}`);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
52
56
|
|
|
53
57
|
const verboseLog = (color, message) => {
|
|
54
|
-
if (verboseMode) {
|
|
58
|
+
if (verboseMode && !silentMode) {
|
|
55
59
|
log(color, message);
|
|
56
60
|
}
|
|
57
61
|
};
|
|
58
62
|
|
|
59
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Set logging mode for library usage
|
|
65
|
+
* @param {Object} options - Logging options
|
|
66
|
+
* @param {boolean} options.verbose - Enable verbose logging
|
|
67
|
+
* @param {boolean} options.silent - Disable all logging
|
|
68
|
+
*/
|
|
69
|
+
export function setLoggingMode(options = {}) {
|
|
70
|
+
verboseMode = options.verbose || false;
|
|
71
|
+
silentMode = options.silent || false;
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
async function isGhInstalled() {
|
|
61
75
|
try {
|
|
62
76
|
const { execSync } = await import('node:child_process');
|
|
@@ -67,8 +81,11 @@ async function isGhInstalled() {
|
|
|
67
81
|
}
|
|
68
82
|
}
|
|
69
83
|
|
|
70
|
-
|
|
71
|
-
|
|
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() {
|
|
72
89
|
try {
|
|
73
90
|
if (!(await isGhInstalled())) {
|
|
74
91
|
return null;
|
|
@@ -85,14 +102,13 @@ async function getGhToken() {
|
|
|
85
102
|
}
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// Try full URL format
|
|
105
|
+
/**
|
|
106
|
+
* Parse PR URL to extract owner, repo, and PR number
|
|
107
|
+
* @param {string} url - PR URL or shorthand (owner/repo#123, owner/repo/123, or full URL)
|
|
108
|
+
* @returns {{owner: string, repo: string, prNumber: number}|null} Parsed PR info or null
|
|
109
|
+
*/
|
|
110
|
+
export function parsePrUrl(url) {
|
|
111
|
+
// Try full URL format (github.com/owner/repo/pull/123 or owner/repo#123 or owner/repo/123)
|
|
96
112
|
const urlMatch = url.match(/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
|
|
97
113
|
if (urlMatch) {
|
|
98
114
|
return {
|
|
@@ -125,7 +141,6 @@ function parsePrUrl(url) {
|
|
|
125
141
|
return null;
|
|
126
142
|
}
|
|
127
143
|
|
|
128
|
-
// Image magic bytes for validation
|
|
129
144
|
const imageMagicBytes = {
|
|
130
145
|
png: [0x89, 0x50, 0x4e, 0x47],
|
|
131
146
|
jpg: [0xff, 0xd8, 0xff],
|
|
@@ -136,8 +151,13 @@ const imageMagicBytes = {
|
|
|
136
151
|
svg: [0x3c, 0x3f, 0x78, 0x6d, 0x6c], // <?xml for SVG (though SVG can also start with <svg)
|
|
137
152
|
};
|
|
138
153
|
|
|
139
|
-
|
|
140
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Validate image by checking magic bytes
|
|
156
|
+
* @param {Buffer} buffer - Image buffer to validate
|
|
157
|
+
* @param {string} url - Original URL (for logging)
|
|
158
|
+
* @returns {{valid: boolean, format?: string, reason?: string}} Validation result
|
|
159
|
+
*/
|
|
160
|
+
export function validateImageBuffer(buffer, url) {
|
|
141
161
|
if (!buffer || buffer.length < 4) {
|
|
142
162
|
return { valid: false, reason: 'Buffer too small' };
|
|
143
163
|
}
|
|
@@ -185,8 +205,13 @@ function validateImageBuffer(buffer, url) {
|
|
|
185
205
|
return { valid: true, format: 'unknown' };
|
|
186
206
|
}
|
|
187
207
|
|
|
188
|
-
|
|
189
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Get file extension from format or URL
|
|
210
|
+
* @param {string} format - Image format detected
|
|
211
|
+
* @param {string} url - Original URL
|
|
212
|
+
* @returns {string} File extension with leading dot
|
|
213
|
+
*/
|
|
214
|
+
export function getExtensionFromFormat(format, url) {
|
|
190
215
|
const formatExtensions = {
|
|
191
216
|
png: '.png',
|
|
192
217
|
jpg: '.jpg',
|
|
@@ -227,8 +252,14 @@ function getExtensionFromFormat(format, url) {
|
|
|
227
252
|
return '.png'; // Default fallback
|
|
228
253
|
}
|
|
229
254
|
|
|
230
|
-
|
|
231
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Download a file with redirect support
|
|
257
|
+
* @param {string} url - URL to download
|
|
258
|
+
* @param {string} token - GitHub token for authenticated requests
|
|
259
|
+
* @param {number} maxRedirects - Maximum number of redirects to follow
|
|
260
|
+
* @returns {Promise<Buffer>} Downloaded file content
|
|
261
|
+
*/
|
|
262
|
+
export function downloadFile(url, token, maxRedirects = 5) {
|
|
232
263
|
return new Promise((resolve, reject) => {
|
|
233
264
|
if (maxRedirects <= 0) {
|
|
234
265
|
reject(new Error('Too many redirects'));
|
|
@@ -281,8 +312,12 @@ function downloadFile(url, token, maxRedirects = 5) {
|
|
|
281
312
|
});
|
|
282
313
|
}
|
|
283
314
|
|
|
284
|
-
|
|
285
|
-
|
|
315
|
+
/**
|
|
316
|
+
* Extract image URLs from markdown content
|
|
317
|
+
* @param {string} content - Markdown content to search
|
|
318
|
+
* @returns {Array<{url: string, alt: string}>} Array of image URLs with alt text
|
|
319
|
+
*/
|
|
320
|
+
export function extractMarkdownImageUrls(content) {
|
|
286
321
|
if (!content) {
|
|
287
322
|
return [];
|
|
288
323
|
}
|
|
@@ -305,8 +340,15 @@ function extractMarkdownImageUrls(content) {
|
|
|
305
340
|
return urls;
|
|
306
341
|
}
|
|
307
342
|
|
|
308
|
-
|
|
309
|
-
|
|
343
|
+
/**
|
|
344
|
+
* Download all images from content and update the markdown
|
|
345
|
+
* @param {string} content - Markdown content with image URLs
|
|
346
|
+
* @param {string} imagesDir - Directory to save images
|
|
347
|
+
* @param {string} token - GitHub token for authenticated requests
|
|
348
|
+
* @param {number} _prNumber - PR number (unused, kept for compatibility)
|
|
349
|
+
* @returns {Promise<{content: string, downloadedImages: Array}>} Updated content and downloaded images info
|
|
350
|
+
*/
|
|
351
|
+
export async function downloadImages(content, imagesDir, token, _prNumber) {
|
|
310
352
|
if (!content) {
|
|
311
353
|
return { content, downloadedImages: [] };
|
|
312
354
|
}
|
|
@@ -338,7 +380,7 @@ async function downloadImages(content, imagesDir, token, _prNumber) {
|
|
|
338
380
|
const ext = getExtensionFromFormat(validation.format, url);
|
|
339
381
|
const filename = `image-${imageCounter}${ext}`;
|
|
340
382
|
const localPath = path.join(imagesDir, filename);
|
|
341
|
-
const relativePath =
|
|
383
|
+
const relativePath = `./images/${filename}`;
|
|
342
384
|
|
|
343
385
|
await fs.writeFile(localPath, buffer);
|
|
344
386
|
downloadedImages.push({
|
|
@@ -362,8 +404,19 @@ async function downloadImages(content, imagesDir, token, _prNumber) {
|
|
|
362
404
|
return { content: updatedContent, downloadedImages };
|
|
363
405
|
}
|
|
364
406
|
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
|
|
367
420
|
try {
|
|
368
421
|
log('blue', `🔍 Fetching pull request ${owner}/${repo}#${prNumber}...`);
|
|
369
422
|
|
|
@@ -386,14 +439,14 @@ async function fetchPullRequest(owner, repo, prNumber, token, options = {}) {
|
|
|
386
439
|
pull_number: prNumber,
|
|
387
440
|
});
|
|
388
441
|
|
|
389
|
-
// Fetch PR comments
|
|
442
|
+
// Fetch PR comments (issue comments)
|
|
390
443
|
const { data: comments } = await octokit.rest.issues.listComments({
|
|
391
444
|
owner,
|
|
392
445
|
repo,
|
|
393
446
|
issue_number: prNumber,
|
|
394
447
|
});
|
|
395
448
|
|
|
396
|
-
// Fetch PR review comments
|
|
449
|
+
// Fetch PR review comments (inline code comments)
|
|
397
450
|
const { data: reviewComments } =
|
|
398
451
|
await octokit.rest.pulls.listReviewComments({
|
|
399
452
|
owner,
|
|
@@ -401,9 +454,9 @@ async function fetchPullRequest(owner, repo, prNumber, token, options = {}) {
|
|
|
401
454
|
pull_number: prNumber,
|
|
402
455
|
});
|
|
403
456
|
|
|
404
|
-
// Fetch PR reviews
|
|
457
|
+
// Fetch PR reviews
|
|
405
458
|
let reviews = [];
|
|
406
|
-
if (
|
|
459
|
+
if (includeReviews) {
|
|
407
460
|
const { data: reviewsData } = await octokit.rest.pulls.listReviews({
|
|
408
461
|
owner,
|
|
409
462
|
repo,
|
|
@@ -431,19 +484,21 @@ async function fetchPullRequest(owner, repo, prNumber, token, options = {}) {
|
|
|
431
484
|
};
|
|
432
485
|
} catch (error) {
|
|
433
486
|
if (error.status === 404) {
|
|
434
|
-
|
|
487
|
+
throw new Error(`Pull request not found: ${owner}/${repo}#${prNumber}`);
|
|
435
488
|
} else if (error.status === 401) {
|
|
436
|
-
|
|
437
|
-
'
|
|
438
|
-
`❌ Authentication failed. Please provide a valid GitHub token`
|
|
489
|
+
throw new Error(
|
|
490
|
+
'Authentication failed. Please provide a valid GitHub token'
|
|
439
491
|
);
|
|
440
492
|
} else {
|
|
441
|
-
|
|
493
|
+
throw new Error(`Failed to fetch pull request: ${error.message}`);
|
|
442
494
|
}
|
|
443
|
-
process.exit(1);
|
|
444
495
|
}
|
|
445
496
|
}
|
|
446
497
|
|
|
498
|
+
// Alias for backwards compatibility
|
|
499
|
+
export const fetchPullRequest = (owner, repo, prNumber, token, options = {}) =>
|
|
500
|
+
loadPullRequest({ owner, repo, prNumber, token, ...options });
|
|
501
|
+
|
|
447
502
|
// Process content and download images if enabled
|
|
448
503
|
function processContent(
|
|
449
504
|
content,
|
|
@@ -458,8 +513,17 @@ function processContent(
|
|
|
458
513
|
return downloadImages(content, imagesDir, token, prNumber);
|
|
459
514
|
}
|
|
460
515
|
|
|
461
|
-
|
|
462
|
-
|
|
516
|
+
/**
|
|
517
|
+
* Convert PR data to markdown format
|
|
518
|
+
* @param {Object} data - PR data from loadPullRequest
|
|
519
|
+
* @param {Object} options - Conversion options
|
|
520
|
+
* @param {boolean} options.downloadImagesFlag - Download embedded images (default: true)
|
|
521
|
+
* @param {string} options.imagesDir - Directory to save images
|
|
522
|
+
* @param {string} options.token - GitHub token for downloading images
|
|
523
|
+
* @param {number} options.prNumber - PR number
|
|
524
|
+
* @returns {Promise<{markdown: string, downloadedImages: Array}>} Markdown content and downloaded images
|
|
525
|
+
*/
|
|
526
|
+
export async function convertToMarkdown(data, options = {}) {
|
|
463
527
|
const { pr, files, comments, reviewComments, reviews, commits } = data;
|
|
464
528
|
const {
|
|
465
529
|
downloadImagesFlag = true,
|
|
@@ -486,125 +550,152 @@ async function convertToMarkdown(data, options = {}) {
|
|
|
486
550
|
allDownloadedImages = [...allDownloadedImages, ...result.downloadedImages];
|
|
487
551
|
}
|
|
488
552
|
|
|
489
|
-
// Header
|
|
490
553
|
markdown += `# ${pr.title}\n\n`;
|
|
491
554
|
|
|
492
|
-
|
|
493
|
-
markdown +=
|
|
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
|
-
}
|
|
555
|
+
markdown += generateMetadataMarkdown(pr);
|
|
556
|
+
markdown += '---\n\n';
|
|
502
557
|
|
|
503
|
-
markdown += '
|
|
558
|
+
markdown += '## Description\n\n';
|
|
504
559
|
markdown += prBody ? `${prBody}\n\n` : '_No description provided._\n\n';
|
|
505
560
|
|
|
506
561
|
markdown += '---\n\n';
|
|
507
562
|
|
|
508
|
-
|
|
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
|
-
}
|
|
563
|
+
markdown += '## Conversation\n\n';
|
|
531
564
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
565
|
+
const timelineEvents = [];
|
|
566
|
+
|
|
567
|
+
for (const comment of comments) {
|
|
568
|
+
timelineEvents.push({
|
|
569
|
+
type: 'comment',
|
|
570
|
+
timestamp: new Date(comment.created_at),
|
|
571
|
+
data: comment,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const review of reviews) {
|
|
576
|
+
if (review.submitted_at) {
|
|
577
|
+
timelineEvents.push({
|
|
578
|
+
type: 'review',
|
|
579
|
+
timestamp: new Date(review.submitted_at),
|
|
580
|
+
data: review,
|
|
581
|
+
});
|
|
535
582
|
}
|
|
536
583
|
}
|
|
537
584
|
|
|
538
|
-
|
|
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
|
-
}
|
|
585
|
+
timelineEvents.sort((a, b) => a.timestamp - b.timestamp);
|
|
561
586
|
|
|
562
|
-
|
|
563
|
-
|
|
587
|
+
if (timelineEvents.length === 0) {
|
|
588
|
+
markdown += '_No comments or reviews._\n\n';
|
|
589
|
+
} else {
|
|
590
|
+
for (const event of timelineEvents) {
|
|
591
|
+
if (event.type === 'comment') {
|
|
592
|
+
const comment = event.data;
|
|
593
|
+
let commentBody = comment.body || '';
|
|
594
|
+
if (downloadImagesFlag && commentBody) {
|
|
595
|
+
verboseLog(
|
|
596
|
+
'blue',
|
|
597
|
+
`Processing images in comment by @${comment.user.login}...`
|
|
598
|
+
);
|
|
599
|
+
const result = await processContent(
|
|
600
|
+
commentBody,
|
|
601
|
+
imagesDir,
|
|
602
|
+
token,
|
|
603
|
+
prNumber,
|
|
604
|
+
downloadImagesFlag
|
|
605
|
+
);
|
|
606
|
+
commentBody = result.content;
|
|
607
|
+
allDownloadedImages = [
|
|
608
|
+
...allDownloadedImages,
|
|
609
|
+
...result.downloadedImages,
|
|
610
|
+
];
|
|
611
|
+
}
|
|
564
612
|
|
|
565
|
-
|
|
566
|
-
markdown +=
|
|
567
|
-
|
|
613
|
+
markdown += `### 💬 Comment by [@${comment.user.login}](https://github.com/${comment.user.login})\n`;
|
|
614
|
+
markdown += `*${formatDate(comment.created_at)}*\n\n`;
|
|
615
|
+
markdown += `${commentBody}\n\n`;
|
|
616
|
+
markdown += '---\n\n';
|
|
617
|
+
} else if (event.type === 'review') {
|
|
618
|
+
const review = event.data;
|
|
619
|
+
let reviewBody = review.body || '';
|
|
620
|
+
if (downloadImagesFlag && reviewBody) {
|
|
621
|
+
verboseLog(
|
|
622
|
+
'blue',
|
|
623
|
+
`Processing images in review by @${review.user.login}...`
|
|
624
|
+
);
|
|
625
|
+
const result = await processContent(
|
|
626
|
+
reviewBody,
|
|
627
|
+
imagesDir,
|
|
628
|
+
token,
|
|
629
|
+
prNumber,
|
|
630
|
+
downloadImagesFlag
|
|
631
|
+
);
|
|
632
|
+
reviewBody = result.content;
|
|
633
|
+
allDownloadedImages = [
|
|
634
|
+
...allDownloadedImages,
|
|
635
|
+
...result.downloadedImages,
|
|
636
|
+
];
|
|
637
|
+
}
|
|
568
638
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
imagesDir,
|
|
581
|
-
token,
|
|
582
|
-
prNumber,
|
|
583
|
-
downloadImagesFlag
|
|
584
|
-
);
|
|
585
|
-
rcBody = result.content;
|
|
586
|
-
allDownloadedImages = [
|
|
587
|
-
...allDownloadedImages,
|
|
588
|
-
...result.downloadedImages,
|
|
589
|
-
];
|
|
590
|
-
}
|
|
639
|
+
const stateEmoji =
|
|
640
|
+
review.state === 'APPROVED'
|
|
641
|
+
? '✅'
|
|
642
|
+
: review.state === 'CHANGES_REQUESTED'
|
|
643
|
+
? '❌'
|
|
644
|
+
: review.state === 'COMMENTED'
|
|
645
|
+
? '💬'
|
|
646
|
+
: '📝';
|
|
647
|
+
|
|
648
|
+
markdown += `### ${stateEmoji} Review by [@${review.user.login}](https://github.com/${review.user.login})\n`;
|
|
649
|
+
markdown += `*${formatDate(review.submitted_at)}* — **${review.state}**\n\n`;
|
|
591
650
|
|
|
592
|
-
|
|
593
|
-
markdown +=
|
|
594
|
-
markdown += `${rcBody}\n\n`;
|
|
651
|
+
if (reviewBody) {
|
|
652
|
+
markdown += `${reviewBody}\n\n`;
|
|
595
653
|
}
|
|
596
|
-
}
|
|
597
654
|
|
|
598
|
-
|
|
655
|
+
// Add review comments for this review
|
|
656
|
+
const reviewReviewComments = reviewComments.filter(
|
|
657
|
+
(rc) => rc.pull_request_review_id === review.id
|
|
658
|
+
);
|
|
659
|
+
if (reviewReviewComments.length > 0) {
|
|
660
|
+
markdown += `#### Inline Comments\n\n`;
|
|
661
|
+
for (const rc of reviewReviewComments) {
|
|
662
|
+
let rcBody = rc.body || '';
|
|
663
|
+
if (downloadImagesFlag && rcBody) {
|
|
664
|
+
const result = await processContent(
|
|
665
|
+
rcBody,
|
|
666
|
+
imagesDir,
|
|
667
|
+
token,
|
|
668
|
+
prNumber,
|
|
669
|
+
downloadImagesFlag
|
|
670
|
+
);
|
|
671
|
+
rcBody = result.content;
|
|
672
|
+
allDownloadedImages = [
|
|
673
|
+
...allDownloadedImages,
|
|
674
|
+
...result.downloadedImages,
|
|
675
|
+
];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const lineInfo = rc.line ? `:${rc.line}` : '';
|
|
679
|
+
markdown += `**\`${rc.path}${lineInfo}\`**\n\n`;
|
|
680
|
+
markdown += `${rcBody}\n\n`;
|
|
681
|
+
if (rc.diff_hunk) {
|
|
682
|
+
markdown += '```diff\n';
|
|
683
|
+
markdown += `${rc.diff_hunk}\n`;
|
|
684
|
+
markdown += '```\n\n';
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
markdown += '---\n\n';
|
|
690
|
+
}
|
|
599
691
|
}
|
|
600
692
|
}
|
|
601
693
|
|
|
602
|
-
// Standalone review comments (not associated with a review)
|
|
603
694
|
const standaloneReviewComments = reviewComments.filter(
|
|
604
695
|
(rc) => !rc.pull_request_review_id
|
|
605
696
|
);
|
|
606
697
|
if (standaloneReviewComments.length > 0) {
|
|
607
|
-
markdown += `##
|
|
698
|
+
markdown += `## Inline Code Comments\n\n`;
|
|
608
699
|
for (const comment of standaloneReviewComments) {
|
|
609
700
|
let commentBody = comment.body || '';
|
|
610
701
|
if (downloadImagesFlag && commentBody) {
|
|
@@ -622,191 +713,196 @@ async function convertToMarkdown(data, options = {}) {
|
|
|
622
713
|
];
|
|
623
714
|
}
|
|
624
715
|
|
|
625
|
-
markdown +=
|
|
716
|
+
markdown += `### [@${comment.user.login}](https://github.com/${comment.user.login}) on \`${comment.path}\``;
|
|
626
717
|
if (comment.line) {
|
|
627
718
|
markdown += ` (line ${comment.line})`;
|
|
628
719
|
}
|
|
629
|
-
markdown +=
|
|
630
|
-
markdown += `*${comment.created_at}*\n\n`;
|
|
720
|
+
markdown += `\n`;
|
|
721
|
+
markdown += `*${formatDate(comment.created_at)}*\n\n`;
|
|
631
722
|
markdown += `${commentBody}\n\n`;
|
|
632
723
|
if (comment.diff_hunk) {
|
|
633
724
|
markdown += '```diff\n';
|
|
634
725
|
markdown += `${comment.diff_hunk}\n`;
|
|
635
726
|
markdown += '```\n\n';
|
|
636
727
|
}
|
|
728
|
+
markdown += '---\n\n';
|
|
637
729
|
}
|
|
638
730
|
}
|
|
639
731
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
}
|
|
650
|
-
|
|
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
|
-
}
|
|
732
|
+
markdown += generateCommitsMarkdown(commits);
|
|
733
|
+
|
|
734
|
+
markdown += generateFilesMarkdown(files);
|
|
672
735
|
|
|
673
736
|
return { markdown, downloadedImages: allDownloadedImages };
|
|
674
737
|
}
|
|
675
738
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
739
|
+
/**
|
|
740
|
+
* Convert PR data to JSON format
|
|
741
|
+
* @param {Object} data - PR data from loadPullRequest
|
|
742
|
+
* @param {Array} downloadedImages - Array of downloaded image info
|
|
743
|
+
* @returns {string} JSON string
|
|
744
|
+
*/
|
|
745
|
+
export function convertToJson(data, downloadedImages = []) {
|
|
746
|
+
return formattersConvertToJson(data, downloadedImages);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Save PR data to a folder with all assets for offline viewing
|
|
751
|
+
* @param {Object} data - PR data from loadPullRequest
|
|
752
|
+
* @param {Object} options - Save options
|
|
753
|
+
* @param {string} options.outputDir - Output directory
|
|
754
|
+
* @param {string} options.format - Output format ('markdown' or 'json')
|
|
755
|
+
* @param {boolean} options.downloadImages - Download images (default: true)
|
|
756
|
+
* @param {string} options.token - GitHub token for downloading images
|
|
757
|
+
* @returns {Promise<{mdPath: string, jsonPath: string, imagesDir: string, downloadedImages: Array}>}
|
|
758
|
+
*/
|
|
759
|
+
export async function savePullRequest(data, options = {}) {
|
|
760
|
+
const {
|
|
761
|
+
outputDir,
|
|
762
|
+
format = 'markdown',
|
|
763
|
+
downloadImages: downloadImagesFlag = true,
|
|
764
|
+
token = '',
|
|
765
|
+
} = options;
|
|
766
|
+
|
|
767
|
+
const prNumber = data.pr.number;
|
|
768
|
+
const prDir = path.join(outputDir, `pr-${prNumber}`);
|
|
769
|
+
const imagesDir = path.join(prDir, 'images');
|
|
770
|
+
const mdPath = path.join(prDir, `pr-${prNumber}.md`);
|
|
771
|
+
const jsonPath = path.join(prDir, `pr-${prNumber}.json`);
|
|
772
|
+
|
|
773
|
+
// Ensure directories exist
|
|
774
|
+
await fs.ensureDir(prDir);
|
|
775
|
+
|
|
776
|
+
let downloadedImages = [];
|
|
777
|
+
|
|
778
|
+
// Generate markdown
|
|
779
|
+
log('blue', `📝 Converting to ${format}...`);
|
|
780
|
+
|
|
781
|
+
const mdResult = await convertToMarkdown(data, {
|
|
782
|
+
downloadImagesFlag,
|
|
783
|
+
imagesDir,
|
|
784
|
+
token,
|
|
785
|
+
prNumber,
|
|
786
|
+
});
|
|
787
|
+
downloadedImages = mdResult.downloadedImages;
|
|
788
|
+
|
|
789
|
+
// Save markdown
|
|
790
|
+
await fs.writeFile(mdPath, mdResult.markdown, 'utf8');
|
|
791
|
+
log('green', `✅ Saved markdown to ${mdPath}`);
|
|
792
|
+
|
|
793
|
+
// Always save JSON as well for metadata
|
|
794
|
+
const jsonContent = convertToJson(data, downloadedImages);
|
|
795
|
+
await fs.writeFile(jsonPath, jsonContent, 'utf8');
|
|
796
|
+
log('green', `✅ Saved JSON metadata to ${jsonPath}`);
|
|
679
797
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
|
798
|
+
if (downloadedImages.length > 0) {
|
|
799
|
+
log(
|
|
800
|
+
'green',
|
|
801
|
+
`📁 Downloaded ${downloadedImages.length} image(s) to ${imagesDir}`
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
mdPath,
|
|
807
|
+
jsonPath,
|
|
808
|
+
imagesDir,
|
|
809
|
+
downloadedImages,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// CLI IMPLEMENTATION
|
|
814
|
+
|
|
815
|
+
// Only run CLI when executed directly, not when imported as a module
|
|
816
|
+
// Check if this module is the main entry point by comparing paths
|
|
817
|
+
function isRunningAsCli() {
|
|
818
|
+
// Get the actual script being run
|
|
819
|
+
const scriptArg = process.argv[1];
|
|
820
|
+
if (!scriptArg) {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Normalize paths for comparison
|
|
825
|
+
const scriptPath = path.resolve(scriptArg);
|
|
826
|
+
const thisModulePath = path.resolve(__filename);
|
|
827
|
+
|
|
828
|
+
// Check if the script being run is this module
|
|
829
|
+
// This handles both direct execution and bun run
|
|
830
|
+
return (
|
|
831
|
+
scriptPath === thisModulePath ||
|
|
832
|
+
scriptPath.endsWith('gh-load-pull-request.mjs') ||
|
|
833
|
+
scriptPath.endsWith('gh-load-pull-request')
|
|
744
834
|
);
|
|
745
835
|
}
|
|
746
836
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
.
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
(
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
'$0
|
|
806
|
-
'
|
|
807
|
-
|
|
837
|
+
/**
|
|
838
|
+
* Parse CLI arguments
|
|
839
|
+
* @returns {Object} Parsed CLI arguments
|
|
840
|
+
*/
|
|
841
|
+
function parseCliArgs() {
|
|
842
|
+
const scriptName = path.basename(process.argv[1] || 'gh-load-pull-request');
|
|
843
|
+
return yargs(hideBin(process.argv))
|
|
844
|
+
.scriptName(scriptName)
|
|
845
|
+
.version(version)
|
|
846
|
+
.usage('Usage: $0 <pr-url> [options]')
|
|
847
|
+
.command(
|
|
848
|
+
'$0 <pr>',
|
|
849
|
+
'Download a GitHub pull request and convert it to markdown',
|
|
850
|
+
(yargs) => {
|
|
851
|
+
yargs.positional('pr', {
|
|
852
|
+
describe:
|
|
853
|
+
'Pull request URL or shorthand (e.g., https://github.com/owner/repo/pull/123 or owner/repo#123)',
|
|
854
|
+
type: 'string',
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
)
|
|
858
|
+
.option('token', {
|
|
859
|
+
alias: 't',
|
|
860
|
+
type: 'string',
|
|
861
|
+
describe: 'GitHub personal access token (optional for public PRs)',
|
|
862
|
+
default: process.env.GITHUB_TOKEN,
|
|
863
|
+
})
|
|
864
|
+
.option('output', {
|
|
865
|
+
alias: 'o',
|
|
866
|
+
type: 'string',
|
|
867
|
+
describe: 'Output directory (creates pr-<number>/ subfolder)',
|
|
868
|
+
})
|
|
869
|
+
.option('download-images', {
|
|
870
|
+
type: 'boolean',
|
|
871
|
+
describe: 'Download embedded images',
|
|
872
|
+
default: true,
|
|
873
|
+
})
|
|
874
|
+
.option('include-reviews', {
|
|
875
|
+
type: 'boolean',
|
|
876
|
+
describe: 'Include PR reviews',
|
|
877
|
+
default: true,
|
|
878
|
+
})
|
|
879
|
+
.option('format', {
|
|
880
|
+
type: 'string',
|
|
881
|
+
describe: 'Output format: markdown, json',
|
|
882
|
+
default: 'markdown',
|
|
883
|
+
choices: ['markdown', 'json'],
|
|
884
|
+
})
|
|
885
|
+
.option('verbose', {
|
|
886
|
+
alias: 'v',
|
|
887
|
+
type: 'boolean',
|
|
888
|
+
describe: 'Enable verbose logging',
|
|
889
|
+
default: false,
|
|
890
|
+
})
|
|
891
|
+
.help('h')
|
|
892
|
+
.alias('h', 'help')
|
|
893
|
+
.example('$0 https://github.com/owner/repo/pull/123', 'Download PR #123')
|
|
894
|
+
.example('$0 owner/repo#123', 'Download PR using shorthand format')
|
|
895
|
+
.example('$0 owner/repo#123 -o ./output', 'Save to output directory')
|
|
896
|
+
.example('$0 owner/repo#123 --format json', 'Output as JSON')
|
|
897
|
+
.example('$0 owner/repo#123 --no-download-images', 'Skip image download')
|
|
898
|
+
.example(
|
|
899
|
+
'$0 https://github.com/owner/repo/pull/123 --token ghp_xxx',
|
|
900
|
+
'Download private PR'
|
|
901
|
+
).argv;
|
|
902
|
+
}
|
|
808
903
|
|
|
809
904
|
async function main() {
|
|
905
|
+
const argv = parseCliArgs();
|
|
810
906
|
const {
|
|
811
907
|
pr: prInput,
|
|
812
908
|
token: tokenArg,
|
|
@@ -844,80 +940,58 @@ async function main() {
|
|
|
844
940
|
|
|
845
941
|
const { owner, repo, prNumber } = prInfo;
|
|
846
942
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
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 = [];
|
|
863
|
-
|
|
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,
|
|
879
|
-
token,
|
|
880
|
-
prNumber
|
|
881
|
-
);
|
|
882
|
-
downloadedImages = result.downloadedImages;
|
|
883
|
-
}
|
|
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,
|
|
943
|
+
try {
|
|
944
|
+
// Fetch PR data
|
|
945
|
+
const data = await loadPullRequest({
|
|
946
|
+
owner,
|
|
947
|
+
repo,
|
|
891
948
|
prNumber,
|
|
949
|
+
token,
|
|
950
|
+
includeReviews,
|
|
892
951
|
});
|
|
893
|
-
outputContent = result.markdown;
|
|
894
|
-
downloadedImages = result.downloadedImages;
|
|
895
|
-
}
|
|
896
952
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
953
|
+
// Determine output paths
|
|
954
|
+
if (output) {
|
|
955
|
+
// Save to directory
|
|
956
|
+
await savePullRequest(data, {
|
|
957
|
+
outputDir: output,
|
|
958
|
+
format,
|
|
959
|
+
downloadImages: downloadImagesFlag,
|
|
960
|
+
token,
|
|
961
|
+
});
|
|
962
|
+
} else {
|
|
963
|
+
// Output to stdout
|
|
964
|
+
if (format === 'json') {
|
|
965
|
+
const jsonContent = convertToJson(data, []);
|
|
966
|
+
console.log(jsonContent);
|
|
967
|
+
} else {
|
|
968
|
+
const { markdown } = await convertToMarkdown(data, {
|
|
969
|
+
downloadImagesFlag: false, // Don't download images when outputting to stdout
|
|
970
|
+
imagesDir: '',
|
|
971
|
+
token: '',
|
|
972
|
+
prNumber,
|
|
973
|
+
});
|
|
974
|
+
console.log(markdown);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
903
977
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
);
|
|
978
|
+
log('blue', '🎉 Done!');
|
|
979
|
+
} catch (error) {
|
|
980
|
+
log('red', `❌ ${error.message}`);
|
|
981
|
+
if (verboseMode) {
|
|
982
|
+
console.error(error.stack);
|
|
909
983
|
}
|
|
910
|
-
|
|
911
|
-
console.log(outputContent);
|
|
984
|
+
process.exit(1);
|
|
912
985
|
}
|
|
913
|
-
|
|
914
|
-
log('blue', '🎉 Done!');
|
|
915
986
|
}
|
|
916
987
|
|
|
917
|
-
main
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
}
|
|
988
|
+
// Run CLI if this is the main module
|
|
989
|
+
if (isRunningAsCli()) {
|
|
990
|
+
main().catch((error) => {
|
|
991
|
+
log('red', `💥 Script failed: ${error.message}`);
|
|
992
|
+
if (verboseMode) {
|
|
993
|
+
console.error(error.stack);
|
|
994
|
+
}
|
|
995
|
+
process.exit(1);
|
|
996
|
+
});
|
|
997
|
+
}
|