coderev-cli 1.0.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/src/github.js ADDED
@@ -0,0 +1,365 @@
1
+ const https = require('https');
2
+
3
+ /**
4
+ * Resolve the GitHub token with fallback priority:
5
+ * 1. Explicit --github-token argument
6
+ * 2. GITHUB_TOKEN env var
7
+ * 3. config.github.token from .coderevrc.json
8
+ */
9
+ function resolveToken(cliToken, config) {
10
+ if (cliToken) return cliToken;
11
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
12
+ // Check config.github.token (from .coderevrc.json)
13
+ try {
14
+ if (config && config.github && config.github.token) return config.github.token;
15
+ } catch {}
16
+ return null;
17
+ }
18
+
19
+ /**
20
+ * Parse a GitHub PR URL or owner/repo#pr shorthand into parts.
21
+ * Supported formats:
22
+ * - owner/repo#42
23
+ * - https://github.com/owner/repo/pull/42
24
+ * - https://api.github.com/repos/owner/repo/pulls/42
25
+ * - 42 (local repo, requires --repo or gh detection)
26
+ */
27
+ function parsePrRef(ref) {
28
+ // owner/repo#42
29
+ const shorthand = ref.match(/^([\w.-]+)\/([\w.-]+)#(\d+)$/);
30
+ if (shorthand) {
31
+ return { owner: shorthand[1], repo: shorthand[2], pr: parseInt(shorthand[3], 10) };
32
+ }
33
+
34
+ // Full GitHub URL
35
+ const fullUrl = ref.match(/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/);
36
+ if (fullUrl) {
37
+ return { owner: fullUrl[1], repo: fullUrl[2], pr: parseInt(fullUrl[3], 10) };
38
+ }
39
+
40
+ // API URL
41
+ const apiUrl = ref.match(/api\.github\.com\/repos\/([\w.-]+)\/([\w.-]+)\/pulls\/(\d+)/);
42
+ if (apiUrl) {
43
+ return { owner: apiUrl[1], repo: apiUrl[2], pr: parseInt(apiUrl[3], 10) };
44
+ }
45
+
46
+ // Just a number: assume local repo context
47
+ const justNumber = ref.match(/^(\d+)$/);
48
+ if (justNumber) {
49
+ return { owner: null, repo: null, pr: parseInt(justNumber[1], 10) };
50
+ }
51
+
52
+ throw new Error(
53
+ `Invalid PR reference: "${ref}". Use formats like:\n` +
54
+ ` coderev review --pr owner/repo#42\n` +
55
+ ` coderev review --pr https://github.com/owner/repo/pull/42\n` +
56
+ ` coderev review --pr 42 (current repo via gh)`
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Fetch a pull request diff from GitHub.
62
+ * @param {object} ref - { owner, repo, pr }
63
+ * @param {string} token - GitHub personal access token (optional, for private repos)
64
+ * @returns {Promise<string>} The diff text
65
+ */
66
+ function fetchPrDiff(ref, token) {
67
+ const { owner, repo, pr } = ref;
68
+ return new Promise((resolve, reject) => {
69
+ const options = {
70
+ hostname: 'api.github.com',
71
+ path: `/repos/${owner}/${repo}/pulls/${pr}`,
72
+ headers: {
73
+ 'User-Agent': 'coderev-agent',
74
+ 'Accept': 'application/vnd.github.v3.diff',
75
+ },
76
+ };
77
+
78
+ if (token) {
79
+ options.headers['Authorization'] = `token ${token}`;
80
+ }
81
+
82
+ https.get(options, (res) => {
83
+ // Handle redirects (GitHub may redirect to a different endpoint)
84
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
85
+ https.get(res.headers.location, { headers: options.headers }, (res2) => {
86
+ let body = '';
87
+ res2.on('data', (chunk) => (body += chunk));
88
+ res2.on('end', () => {
89
+ if (res2.statusCode === 200) resolve(body);
90
+ else reject(new Error(`GitHub API returned status ${res2.statusCode}: ${body.slice(0, 200)}`));
91
+ });
92
+ }).on('error', reject);
93
+ return;
94
+ }
95
+
96
+ let body = '';
97
+ res.on('data', (chunk) => (body += chunk));
98
+ res.on('end', () => {
99
+ if (res.statusCode === 200) {
100
+ resolve(body);
101
+ } else if (res.statusCode === 404) {
102
+ reject(new Error(`PR not found: ${owner}/${repo}#${pr}. Is the repo private? Use --github-token.`));
103
+ } else if (res.statusCode === 401 || res.statusCode === 403) {
104
+ reject(new Error(`GitHub API access denied (${res.statusCode}). Try setting GITHUB_TOKEN.`));
105
+ } else {
106
+ reject(new Error(`GitHub API returned status ${res.statusCode}: ${body.slice(0, 200)}`));
107
+ }
108
+ });
109
+ }).on('error', reject);
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Post a review comment on a PR (single top-level comment).
115
+ * @param {object} ref - { owner, repo, pr }
116
+ * @param {string} body - Comment body (Markdown)
117
+ * @param {string} token - GitHub personal access token
118
+ * @returns {Promise<object>} GitHub API response
119
+ */
120
+ function postPrComment(ref, body, token) {
121
+ const { owner, repo, pr } = ref;
122
+ return new Promise((resolve, reject) => {
123
+ const postData = JSON.stringify({ body });
124
+
125
+ const options = {
126
+ hostname: 'api.github.com',
127
+ path: `/repos/${owner}/${repo}/issues/${pr}/comments`,
128
+ method: 'POST',
129
+ headers: {
130
+ 'User-Agent': 'coderev-agent',
131
+ 'Accept': 'application/vnd.github.v3+json',
132
+ 'Authorization': `token ${token}`,
133
+ 'Content-Type': 'application/json',
134
+ 'Content-Length': Buffer.byteLength(postData),
135
+ },
136
+ };
137
+
138
+ const req = https.request(options, (res) => {
139
+ let body = '';
140
+ res.on('data', (chunk) => (body += chunk));
141
+ res.on('end', () => {
142
+ if (res.statusCode === 201) {
143
+ resolve(JSON.parse(body));
144
+ } else {
145
+ reject(new Error(`Failed to post comment (${res.statusCode}): ${body.slice(0, 200)}`));
146
+ }
147
+ });
148
+ });
149
+
150
+ req.on('error', reject);
151
+ req.write(postData);
152
+ req.end();
153
+ });
154
+ }
155
+
156
+ /**
157
+ * Detect current GitHub repo info using `gh` CLI.
158
+ * @returns {{ owner: string, repo: string } | null}
159
+ */
160
+ function detectRepoFromGh() {
161
+ try {
162
+ const { execSync } = require('child_process');
163
+ const output = execSync('gh repo view --json owner,name 2>nul', {
164
+ encoding: 'utf-8',
165
+ timeout: 5000,
166
+ });
167
+ const parsed = JSON.parse(output);
168
+ if (parsed.owner && parsed.name) {
169
+ return {
170
+ owner: typeof parsed.owner === 'object' ? parsed.owner.login : parsed.owner,
171
+ repo: parsed.name,
172
+ };
173
+ }
174
+ return null;
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Get local GitHub remote origin info from git.
182
+ * @param {string} [repoPath] - Path to git repo (defaults to cwd)
183
+ * @returns {{ owner: string, repo: string } | null}
184
+ */
185
+ function detectRepoFromGit(repoPath) {
186
+ try {
187
+ const { execSync } = require('child_process');
188
+ const remote = execSync('git config --get remote.origin.url', {
189
+ cwd: repoPath || process.cwd(),
190
+ encoding: 'utf-8',
191
+ timeout: 5000,
192
+ }).trim();
193
+ // Supports: git@github.com:owner/repo.git, https://github.com/owner/repo, etc.
194
+ const match = remote.match(/(?:(?:git@|https:\/\/)github\.com[\/:])([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
195
+ if (match) {
196
+ return { owner: match[1], repo: match[2] };
197
+ }
198
+ return null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Resolve a partial PR ref into a full { owner, repo, pr }.
206
+ * If owner/repo is missing, tries gh CLI then git remote.
207
+ */
208
+ function resolvePrRef(ref, repoPath) {
209
+ const parsed = parsePrRef(ref);
210
+
211
+ if (parsed.owner && parsed.repo) {
212
+ return parsed; // Already fully qualified
213
+ }
214
+
215
+ // Try gh CLI first, then git remote
216
+ let repoInfo = detectRepoFromGh();
217
+ if (!repoInfo) repoInfo = detectRepoFromGit(repoPath);
218
+
219
+ if (!repoInfo) {
220
+ throw new Error(
221
+ `Could not detect current repo. Use full format like owner/repo#${parsed.pr}, ` +
222
+ `or install GitHub CLI (gh) and authenticate.`
223
+ );
224
+ }
225
+
226
+ return { owner: repoInfo.owner, repo: repoInfo.repo, pr: parsed.pr };
227
+ }
228
+
229
+ /**
230
+ * Fetch PR file list with patch info (for line-level comments).
231
+ * @param {object} ref - { owner, repo, pr }
232
+ * @param {string} token - GitHub token
233
+ * @returns {Promise<Array>} Array of { filename, patch, sha }
234
+ */
235
+ function fetchPrFiles(ref, token) {
236
+ const { owner, repo, pr } = ref;
237
+ return new Promise((resolve, reject) => {
238
+ const options = {
239
+ hostname: 'api.github.com',
240
+ path: `/repos/${owner}/${repo}/pulls/${pr}/files`,
241
+ headers: {
242
+ 'User-Agent': 'coderev-agent',
243
+ 'Accept': 'application/vnd.github.v3+json',
244
+ },
245
+ };
246
+ if (token) options.headers['Authorization'] = `token ${token}`;
247
+
248
+ https.get(options, (res) => {
249
+ let body = '';
250
+ res.on('data', (chunk) => (body += chunk));
251
+ res.on('end', () => {
252
+ if (res.statusCode === 200) {
253
+ try { resolve(JSON.parse(body)); }
254
+ catch { reject(new Error('Failed to parse PR files response')); }
255
+ } else {
256
+ reject(new Error(`GitHub API error ${res.statusCode}: ${body.slice(0, 200)}`));
257
+ }
258
+ });
259
+ }).on('error', reject);
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Post inline review comments on a PR via the Pull Request Review API.
265
+ * @param {object} ref - { owner, repo, pr }
266
+ * @param {string} commitId - SHA of the head commit
267
+ * @param {Array} comments - Array of { path, line, body, side: 'LEFT'|'RIGHT' }
268
+ * @param {string} token - GitHub token
269
+ * @returns {Promise<object>} GitHub API response
270
+ */
271
+ function postInlineComments(ref, commitId, comments, token) {
272
+ const { owner, repo, pr } = ref;
273
+ return new Promise((resolve, reject) => {
274
+ const body = {
275
+ commit_id: commitId,
276
+ event: 'COMMENT',
277
+ body: '## coderev review\n\n' + `Found ${comments.length} issue(s):`,
278
+ comments: comments.map(c => ({
279
+ path: c.path,
280
+ line: c.line,
281
+ side: c.side || 'RIGHT',
282
+ body: `**${c.type.toUpperCase()}**${c.severity ? ' [' + c.severity + ']' : ''}: ${c.message}${c.suggestion ? '\n\n> Suggestion: ' + c.suggestion : ''}`,
283
+ })),
284
+ };
285
+
286
+ const postData = JSON.stringify(body);
287
+ const options = {
288
+ hostname: 'api.github.com',
289
+ path: `/repos/${owner}/${repo}/pulls/${pr}/reviews`,
290
+ method: 'POST',
291
+ headers: {
292
+ 'User-Agent': 'coderev-agent',
293
+ 'Accept': 'application/vnd.github.v3+json',
294
+ 'Authorization': `token ${token}`,
295
+ 'Content-Type': 'application/json',
296
+ 'Content-Length': Buffer.byteLength(postData),
297
+ },
298
+ };
299
+
300
+ const req = https.request(options, (res) => {
301
+ let rb = '';
302
+ res.on('data', (c) => (rb += c));
303
+ res.on('end', () => {
304
+ if (res.statusCode === 200) {
305
+ try { resolve(JSON.parse(rb)); }
306
+ catch { resolve(rb); }
307
+ } else {
308
+ reject(new Error(`Failed to post review (${res.statusCode}): ${rb.slice(0, 300)}`));
309
+ }
310
+ });
311
+ });
312
+ req.on('error', reject);
313
+ req.write(postData);
314
+ req.end();
315
+ });
316
+ }
317
+
318
+ /**
319
+ * List all open pull requests for a repository.
320
+ * @param {object} ref - { owner, repo }
321
+ * @param {string} token - GitHub token
322
+ * @param {object} [options] - { state: 'open'|'closed'|'all', limit: number }
323
+ * @returns {Promise<Array>} Array of { number, title, head, base, url }
324
+ */
325
+ function listPullRequests(ref, token, options = {}) {
326
+ const { owner, repo } = ref;
327
+ const state = options.state || 'open';
328
+ const limit = options.limit || 50;
329
+ return new Promise((resolve, reject) => {
330
+ const opts = {
331
+ hostname: 'api.github.com',
332
+ path: `/repos/${owner}/${repo}/pulls?state=${state}&per_page=${limit}&sort=updated&direction=desc`,
333
+ headers: {
334
+ 'User-Agent': 'coderev-agent',
335
+ 'Accept': 'application/vnd.github.v3+json',
336
+ },
337
+ };
338
+ if (token) opts.headers['Authorization'] = `token ${token}`;
339
+
340
+ https.get(opts, (res) => {
341
+ let body = '';
342
+ res.on('data', (c) => (body += c));
343
+ res.on('end', () => {
344
+ if (res.statusCode === 200) {
345
+ try {
346
+ const prs = JSON.parse(body);
347
+ resolve(prs.map(p => ({
348
+ number: p.number,
349
+ title: p.title,
350
+ head: p.head.ref,
351
+ base: p.base.ref,
352
+ url: p.html_url,
353
+ draft: p.draft || false,
354
+ updatedAt: p.updated_at,
355
+ })));
356
+ } catch { reject(new Error('Failed to parse PR list')); }
357
+ } else {
358
+ reject(new Error(`GitHub API error ${res.statusCode}: ${body.slice(0, 200)}`));
359
+ }
360
+ });
361
+ }).on('error', reject);
362
+ });
363
+ }
364
+
365
+ module.exports = { parsePrRef, fetchPrDiff, postPrComment, resolvePrRef, detectRepoFromGh, detectRepoFromGit, resolveToken, fetchPrFiles, postInlineComments, listPullRequests };
package/src/gitlab.js ADDED
@@ -0,0 +1,144 @@
1
+ const https = require('https');
2
+ const URL = require('url');
3
+
4
+ /**
5
+ * Parse a GitLab MR reference.
6
+ * Supported formats:
7
+ * - owner/repo!42
8
+ * - https://gitlab.com/owner/repo/-/merge_requests/42
9
+ * - https://gitlab.example.com/owner/repo/-/merge_requests/42
10
+ * - 42 (requires --repo with git remote)
11
+ */
12
+ function parseMrRef(ref) {
13
+ // owner/repo!42
14
+ const shorthand = ref.match(/^([\w.-]+)\/([\w.-]+)!(\d+)$/);
15
+ if (shorthand) {
16
+ return { host: 'gitlab.com', owner: shorthand[1], repo: shorthand[2], mr: parseInt(shorthand[3], 10), protocol: 'https:' };
17
+ }
18
+
19
+ // Full GitLab URL
20
+ const fullUrl = ref.match(/^(https?:\/\/[\w.-]+)\/([\w.-]+)\/([\w.-]+)\/-\/merge_requests\/(\d+)/);
21
+ if (fullUrl) {
22
+ return { host: fullUrl[1].replace(/^https?:\/\//, ''), owner: fullUrl[2], repo: fullUrl[3], mr: parseInt(fullUrl[4], 10), protocol: fullUrl[1].startsWith('https') ? 'https:' : 'http:' };
23
+ }
24
+
25
+ // Just a number: try to detect from git remote
26
+ const justNumber = ref.match(/^(\d+)$/);
27
+ if (justNumber) {
28
+ return { host: null, owner: null, repo: null, mr: parseInt(justNumber[1], 10) };
29
+ }
30
+
31
+ throw new Error(
32
+ `Invalid MR reference: "${ref}". Use formats like:\n` +
33
+ ` coderev review --gl owner/repo!42\n` +
34
+ ` coderev review --gl https://gitlab.com/owner/repo/-/merge_requests/42`
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Resolve MR ref, detecting host/owner/repo from git remote if needed.
40
+ */
41
+ function resolveMrRef(ref, repoPath) {
42
+ const parsed = parseMrRef(ref);
43
+
44
+ if (parsed.host && parsed.owner && parsed.repo) {
45
+ return parsed;
46
+ }
47
+
48
+ // Try git remote
49
+ try {
50
+ const { execSync } = require('child_process');
51
+ const remote = execSync('git config --get remote.origin.url', {
52
+ cwd: repoPath || process.cwd(),
53
+ encoding: 'utf-8',
54
+ timeout: 5000,
55
+ }).trim();
56
+
57
+ const gitlabMatch = remote.match(/git@([\w.-]+):([\w.-]+)\/([\w.-]+?)(?:\.git)?$/) ||
58
+ remote.match(/https?:\/\/([\w.-]+)\/([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
59
+ if (gitlabMatch) {
60
+ const protocol = remote.startsWith('https') ? 'https:' : 'http:';
61
+ return { host: gitlabMatch[1], owner: gitlabMatch[2], repo: gitlabMatch[3], mr: parsed.mr, protocol };
62
+ }
63
+ } catch {}
64
+
65
+ throw new Error(
66
+ `Could not detect GitLab repo. Use full format like owner/repo!${parsed.mr}.`
67
+ );
68
+ }
69
+
70
+ /**
71
+ * Fetch a merge request diff from GitLab.
72
+ */
73
+ function fetchMrDiff(ref, token) {
74
+ const { host, owner, repo, mr, protocol } = ref;
75
+ const apiHost = host === 'gitlab.com' ? 'gitlab.com' : host;
76
+ return new Promise((resolve, reject) => {
77
+ const options = {
78
+ hostname: apiHost,
79
+ path: `/api/v4/projects/${encodeURIComponent(owner + '/' + repo)}/merge_requests/${mr}`,
80
+ headers: {
81
+ 'User-Agent': 'coderev-agent',
82
+ 'Accept': 'text/plain, application/json',
83
+ },
84
+ };
85
+
86
+ if (token) options.headers['Authorization'] = `Bearer ${token}`;
87
+
88
+ https.get({ hostname: apiHost, path: options.path + '.diff', headers: options.headers, protocol: protocol || 'https:' }, (res) => {
89
+ let body = '';
90
+ res.on('data', (chunk) => (body += chunk));
91
+ res.on('end', () => {
92
+ if (res.statusCode === 200) {
93
+ resolve(body);
94
+ } else if (res.statusCode === 404) {
95
+ reject(new Error(`MR not found: ${owner}/${repo}!${mr}. Use --gitlab-token for private repos.`));
96
+ } else {
97
+ reject(new Error(`GitLab API error (${res.statusCode}): ${body.slice(0, 200)}`));
98
+ }
99
+ });
100
+ }).on('error', reject);
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Post a comment on a GitLab MR.
106
+ */
107
+ function postMrComment(ref, body, token) {
108
+ const { host, owner, repo, mr, protocol } = ref;
109
+ const apiHost = host === 'gitlab.com' ? 'gitlab.com' : host;
110
+ return new Promise((resolve, reject) => {
111
+ const postData = JSON.stringify({ body });
112
+
113
+ const options = {
114
+ hostname: apiHost,
115
+ path: `/api/v4/projects/${encodeURIComponent(owner + '/' + repo)}/merge_requests/${mr}/notes`,
116
+ method: 'POST',
117
+ headers: {
118
+ 'User-Agent': 'coderev-agent',
119
+ 'Accept': 'application/json',
120
+ 'Authorization': `Bearer ${token}`,
121
+ 'Content-Type': 'application/json',
122
+ 'Content-Length': Buffer.byteLength(postData),
123
+ },
124
+ };
125
+
126
+ const req = https.request(Object.assign({}, options, { protocol: protocol || 'https:' }), (res) => {
127
+ let rb = '';
128
+ res.on('data', (c) => (rb += c));
129
+ res.on('end', () => {
130
+ if (res.statusCode === 201) {
131
+ try { resolve(JSON.parse(rb)); }
132
+ catch { resolve(rb); }
133
+ } else {
134
+ reject(new Error(`Failed to post comment (${res.statusCode}): ${rb.slice(0, 200)}`));
135
+ }
136
+ });
137
+ });
138
+ req.on('error', reject);
139
+ req.write(postData);
140
+ req.end();
141
+ });
142
+ }
143
+
144
+ module.exports = { parseMrRef, resolveMrRef, fetchMrDiff, postMrComment };
package/src/index.js ADDED
@@ -0,0 +1,8 @@
1
+ const { reviewDiff } = require('./reviewer');
2
+ const { loadConfig, getApiKey } = require('./config');
3
+
4
+ module.exports = {
5
+ reviewDiff,
6
+ loadConfig,
7
+ getApiKey,
8
+ };