@review-my-code/rmcode 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/cli.js +380 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,14 +16,22 @@ Set `RMC_API_KEY` before running a review; anonymous CLI reviews are disabled.
|
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
rmcode # review branch diff from merge base
|
|
19
|
+
rmcode --pr 123 # review GitHub PR #123 for this repo
|
|
20
|
+
rmcode --pr https://github.com/owner/repo/pull/123
|
|
19
21
|
rmcode --staged # review staged changes
|
|
20
22
|
rmcode --unstaged # review unstaged changes
|
|
21
23
|
rmcode --all # review all uncommitted changes, including safe text files
|
|
22
24
|
rmcode src/auth.ts # review one file
|
|
23
25
|
git diff main | rmcode # review a custom diff; language is detected from filenames
|
|
24
26
|
rmcode --json --fail-on-findings
|
|
27
|
+
rmcode cache clear # clear reusable PR checkout cache
|
|
25
28
|
rmcode update # update to the latest CLI
|
|
26
29
|
```
|
|
27
30
|
|
|
28
31
|
Review commands check for the latest published CLI before uploading code. If a
|
|
29
32
|
newer version is available, run `rmcode update` and retry.
|
|
33
|
+
|
|
34
|
+
`rmcode --pr` fetches PR metadata and diff from GitHub, caches a reusable base
|
|
35
|
+
checkout under `~/.cache/rmcode/pr-repos`, and runs the same pinned context
|
|
36
|
+
extractor used for local diff reviews. Use `GH_TOKEN`, `GITHUB_TOKEN`, or
|
|
37
|
+
`gh auth login` for private repositories.
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,7 @@ const path_1 = require("path");
|
|
|
9
9
|
const readline_1 = require("readline");
|
|
10
10
|
const API_URL = process.env.RMC_API_URL || "https://review-my-code.com";
|
|
11
11
|
const APP_URL = process.env.RMC_APP_URL || "https://review-my-code.com";
|
|
12
|
-
const VERSION = "0.1.
|
|
12
|
+
const VERSION = "0.1.7";
|
|
13
13
|
const FREE_PLAN_CREDITS_PER_MONTH = 30;
|
|
14
14
|
const REQUEST_TIMEOUT_MS = 290_000;
|
|
15
15
|
const POLL_TIMEOUT_MS = 10 * 60_000;
|
|
@@ -22,6 +22,7 @@ const MAX_UNTRACKED_FILE_BYTES = 512 * 1024;
|
|
|
22
22
|
const EXTRACTOR_TIMEOUT_MS = 120_000;
|
|
23
23
|
const EXTRACTOR_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
24
24
|
const EXTRACTOR_INFO_TIMEOUT_MS = 15_000;
|
|
25
|
+
const GITHUB_API_URL = "https://api.github.com";
|
|
25
26
|
// Mirrors the GitHub App workflow template's "Install extractor runtime
|
|
26
27
|
// dependencies" step and REPOMAP_MAX_CHARS env.
|
|
27
28
|
const EXTRACTOR_RUNTIME_DEPS = [
|
|
@@ -89,6 +90,69 @@ function gitQuiet(args) {
|
|
|
89
90
|
stdio: ["ignore", "pipe", "ignore"],
|
|
90
91
|
}).trim();
|
|
91
92
|
}
|
|
93
|
+
function gitQuietIn(cwd, args) {
|
|
94
|
+
return (0, child_process_1.execFileSync)("git", ["-C", cwd, ...args], {
|
|
95
|
+
encoding: "utf-8",
|
|
96
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
97
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
98
|
+
}).trim();
|
|
99
|
+
}
|
|
100
|
+
function runQuietAsync(cmd, args, options = {}) {
|
|
101
|
+
return new Promise((resolve, reject) => {
|
|
102
|
+
let settled = false;
|
|
103
|
+
let stdout = "";
|
|
104
|
+
let stderr = "";
|
|
105
|
+
const child = (0, child_process_1.spawn)(cmd, args, {
|
|
106
|
+
cwd: options.cwd,
|
|
107
|
+
env: options.env ? { ...process.env, ...options.env } : process.env,
|
|
108
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
109
|
+
});
|
|
110
|
+
const timer = options.timeoutMs && options.timeoutMs > 0
|
|
111
|
+
? setTimeout(() => {
|
|
112
|
+
child.kill("SIGTERM");
|
|
113
|
+
finish(new Error(`${cmd} ${args.join(" ")} timed out after ${formatElapsed(options.timeoutMs)}.`));
|
|
114
|
+
}, options.timeoutMs)
|
|
115
|
+
: null;
|
|
116
|
+
function finish(err, output = "") {
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
if (timer)
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
if (err)
|
|
123
|
+
reject(err);
|
|
124
|
+
else
|
|
125
|
+
resolve(output.trim());
|
|
126
|
+
}
|
|
127
|
+
child.stdout.setEncoding("utf8");
|
|
128
|
+
child.stderr.setEncoding("utf8");
|
|
129
|
+
child.stdout.on("data", (chunk) => {
|
|
130
|
+
stdout += chunk;
|
|
131
|
+
});
|
|
132
|
+
child.stderr.on("data", (chunk) => {
|
|
133
|
+
stderr += chunk;
|
|
134
|
+
});
|
|
135
|
+
child.on("error", (err) => finish(err));
|
|
136
|
+
child.on("close", (code, signal) => {
|
|
137
|
+
if (code === 0) {
|
|
138
|
+
finish(undefined, stdout);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
finish(new Error(`${cmd} ${args.join(" ")} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}. ${stderr.slice(0, 240).trim()}`.trim()));
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function gitQuietInAsync(cwd, args) {
|
|
146
|
+
return runQuietAsync("git", ["-C", cwd, ...args], {
|
|
147
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function gitQuietInAsyncWithEnv(cwd, args, env) {
|
|
151
|
+
return runQuietAsync("git", ["-C", cwd, ...args], {
|
|
152
|
+
env,
|
|
153
|
+
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
92
156
|
function isGitRepo() {
|
|
93
157
|
try {
|
|
94
158
|
git(["rev-parse", "--is-inside-work-tree"]);
|
|
@@ -200,13 +264,217 @@ function pseudoDiffForNewFile(file, worktreeRoot) {
|
|
|
200
264
|
function gitRepoName() {
|
|
201
265
|
try {
|
|
202
266
|
const remote = gitQuiet(["remote", "get-url", "origin"]);
|
|
203
|
-
|
|
204
|
-
return m ? m[1] : null;
|
|
267
|
+
return ownerRepoFromRemoteUrl(remote);
|
|
205
268
|
}
|
|
206
269
|
catch {
|
|
207
270
|
return null;
|
|
208
271
|
}
|
|
209
272
|
}
|
|
273
|
+
function ownerRepoFromRemoteUrl(remote) {
|
|
274
|
+
const m = remote.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/i);
|
|
275
|
+
return m ? m[1].replace(/\.git$/i, "") : null;
|
|
276
|
+
}
|
|
277
|
+
function parsePrReference(value) {
|
|
278
|
+
const trimmed = value.trim();
|
|
279
|
+
const numeric = /^\d+$/.test(trimmed)
|
|
280
|
+
? Number.parseInt(trimmed, 10)
|
|
281
|
+
: undefined;
|
|
282
|
+
if (numeric != null && numeric > 0) {
|
|
283
|
+
const ownerRepo = gitRepoName();
|
|
284
|
+
if (!ownerRepo) {
|
|
285
|
+
throw new Error("Could not infer owner/repo from origin. Use: rmcode --pr https://github.com/owner/repo/pull/123");
|
|
286
|
+
}
|
|
287
|
+
return { ownerRepo, number: numeric };
|
|
288
|
+
}
|
|
289
|
+
const match = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)(?:[/?#].*)?$/i);
|
|
290
|
+
if (!match) {
|
|
291
|
+
throw new Error("Invalid PR reference. Use `rmcode --pr 123` or `rmcode --pr https://github.com/owner/repo/pull/123`.");
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
ownerRepo: match[1].replace(/\.git$/i, ""),
|
|
295
|
+
number: Number.parseInt(match[2], 10),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
let cachedGithubToken;
|
|
299
|
+
function githubToken() {
|
|
300
|
+
if (cachedGithubToken !== undefined)
|
|
301
|
+
return cachedGithubToken || undefined;
|
|
302
|
+
const envToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
|
303
|
+
if (envToken?.trim()) {
|
|
304
|
+
cachedGithubToken = envToken.trim();
|
|
305
|
+
return cachedGithubToken;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const token = (0, child_process_1.execFileSync)("gh", ["auth", "token"], {
|
|
309
|
+
encoding: "utf-8",
|
|
310
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
311
|
+
timeout: 5_000,
|
|
312
|
+
}).trim();
|
|
313
|
+
cachedGithubToken = token || null;
|
|
314
|
+
return cachedGithubToken || undefined;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
cachedGithubToken = null;
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function githubGitAuthEnv(cloneUrl) {
|
|
322
|
+
if (!/^https:\/\/github\.com\//i.test(cloneUrl))
|
|
323
|
+
return undefined;
|
|
324
|
+
const token = githubToken();
|
|
325
|
+
if (!token)
|
|
326
|
+
return undefined;
|
|
327
|
+
const basicToken = Buffer.from(`x-access-token:${token}`).toString("base64");
|
|
328
|
+
return {
|
|
329
|
+
GIT_CONFIG_COUNT: "1",
|
|
330
|
+
GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader",
|
|
331
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${basicToken}`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
function githubHeaders(accept) {
|
|
335
|
+
const headers = {
|
|
336
|
+
Accept: accept,
|
|
337
|
+
"User-Agent": `rmcode/${VERSION}`,
|
|
338
|
+
};
|
|
339
|
+
const token = githubToken();
|
|
340
|
+
if (token)
|
|
341
|
+
headers.Authorization = `Bearer ${token}`;
|
|
342
|
+
return headers;
|
|
343
|
+
}
|
|
344
|
+
async function fetchGithubJson(path) {
|
|
345
|
+
const resp = await fetch(`${GITHUB_API_URL}${path}`, {
|
|
346
|
+
headers: githubHeaders("application/vnd.github+json"),
|
|
347
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
348
|
+
});
|
|
349
|
+
if (!resp.ok) {
|
|
350
|
+
const body = await resp.text().catch(() => "");
|
|
351
|
+
throw new Error(`GitHub API request failed (${resp.status}). ${body.includes("Not Found")
|
|
352
|
+
? "Check the PR URL and GitHub authentication."
|
|
353
|
+
: body.slice(0, 180)}`.trim());
|
|
354
|
+
}
|
|
355
|
+
return (await resp.json());
|
|
356
|
+
}
|
|
357
|
+
async function fetchGithubText(path, accept) {
|
|
358
|
+
const resp = await fetch(`${GITHUB_API_URL}${path}`, {
|
|
359
|
+
headers: githubHeaders(accept),
|
|
360
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
361
|
+
});
|
|
362
|
+
if (!resp.ok) {
|
|
363
|
+
throw new Error(`GitHub diff request failed (${resp.status}).`);
|
|
364
|
+
}
|
|
365
|
+
return resp.text();
|
|
366
|
+
}
|
|
367
|
+
async function fetchPrMetadata(ref) {
|
|
368
|
+
const pr = await fetchGithubJson(`/repos/${ref.ownerRepo}/pulls/${ref.number}`);
|
|
369
|
+
const baseRepo = pr.base?.repo?.full_name;
|
|
370
|
+
const cloneUrl = pr.base?.repo?.clone_url;
|
|
371
|
+
const baseSha = pr.base?.sha;
|
|
372
|
+
const baseRef = pr.base?.ref;
|
|
373
|
+
const headSha = pr.head?.sha;
|
|
374
|
+
if (!baseRepo || !cloneUrl || !baseSha || !baseRef || !headSha) {
|
|
375
|
+
throw new Error("GitHub PR metadata is missing base/head information.");
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
number: pr.number,
|
|
379
|
+
title: pr.title || `PR #${pr.number}`,
|
|
380
|
+
htmlUrl: pr.html_url || `https://github.com/${ref.ownerRepo}/pull/${ref.number}`,
|
|
381
|
+
baseRef,
|
|
382
|
+
baseSha,
|
|
383
|
+
baseRepo,
|
|
384
|
+
cloneUrl,
|
|
385
|
+
headSha,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
async function fetchPrDiff(ref) {
|
|
389
|
+
return fetchGithubText(`/repos/${ref.ownerRepo}/pulls/${ref.number}`, "application/vnd.github.v3.diff");
|
|
390
|
+
}
|
|
391
|
+
function rmcodeCacheRoot() {
|
|
392
|
+
return process.env.RMC_CACHE_DIR || (0, path_1.join)((0, os_1.homedir)(), ".cache", "rmcode");
|
|
393
|
+
}
|
|
394
|
+
function prCacheRoot() {
|
|
395
|
+
return process.env.RMC_PR_CACHE_DIR || (0, path_1.join)(rmcodeCacheRoot(), "pr-repos");
|
|
396
|
+
}
|
|
397
|
+
function safeCacheName(ownerRepo) {
|
|
398
|
+
return ownerRepo.toLowerCase().replace(/[^a-z0-9._-]+/g, "__");
|
|
399
|
+
}
|
|
400
|
+
async function ensurePrCacheDir(metadata) {
|
|
401
|
+
const root = prCacheRoot();
|
|
402
|
+
(0, fs_1.mkdirSync)(root, { recursive: true });
|
|
403
|
+
const cacheDir = (0, path_1.join)(root, safeCacheName(metadata.baseRepo));
|
|
404
|
+
const gitAuthEnv = githubGitAuthEnv(metadata.cloneUrl);
|
|
405
|
+
if (!(0, fs_1.existsSync)((0, path_1.join)(cacheDir, ".git"))) {
|
|
406
|
+
if ((0, fs_1.existsSync)(cacheDir))
|
|
407
|
+
(0, fs_1.rmSync)(cacheDir, { recursive: true, force: true });
|
|
408
|
+
try {
|
|
409
|
+
await runQuietAsync("git", ["clone", "--no-checkout", metadata.cloneUrl, cacheDir], { env: gitAuthEnv, timeoutMs: REQUEST_TIMEOUT_MS });
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
throw new Error(`Could not clone ${metadata.baseRepo} into rmcode cache. Make sure GitHub CLI is authenticated with access to the repository. ${err.message}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
try {
|
|
417
|
+
await gitQuietInAsync(cacheDir, [
|
|
418
|
+
"remote",
|
|
419
|
+
"set-url",
|
|
420
|
+
"origin",
|
|
421
|
+
metadata.cloneUrl,
|
|
422
|
+
]);
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// Keep the existing remote; the next fetch will report any real issue.
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
await gitQuietInAsyncWithEnv(cacheDir, [
|
|
430
|
+
"fetch",
|
|
431
|
+
"--no-tags",
|
|
432
|
+
"origin",
|
|
433
|
+
metadata.baseSha,
|
|
434
|
+
], gitAuthEnv);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
await gitQuietInAsyncWithEnv(cacheDir, [
|
|
438
|
+
"fetch",
|
|
439
|
+
"--no-tags",
|
|
440
|
+
"origin",
|
|
441
|
+
metadata.baseRef,
|
|
442
|
+
], gitAuthEnv);
|
|
443
|
+
}
|
|
444
|
+
try {
|
|
445
|
+
await gitQuietInAsync(cacheDir, [
|
|
446
|
+
"rev-parse",
|
|
447
|
+
"--verify",
|
|
448
|
+
metadata.baseSha,
|
|
449
|
+
]);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
throw new Error(`Could not fetch PR base commit ${metadata.baseSha} into the rmcode cache.`);
|
|
453
|
+
}
|
|
454
|
+
return cacheDir;
|
|
455
|
+
}
|
|
456
|
+
async function diffFromPullRequest(value, progress) {
|
|
457
|
+
const ref = typeof value === "string" ? parsePrReference(value) : value;
|
|
458
|
+
const [metadata, diff] = await Promise.all([
|
|
459
|
+
fetchPrMetadata(ref),
|
|
460
|
+
fetchPrDiff(ref),
|
|
461
|
+
]);
|
|
462
|
+
if (!diff.trim()) {
|
|
463
|
+
throw new Error(`GitHub returned an empty diff for ${metadata.htmlUrl}.`);
|
|
464
|
+
}
|
|
465
|
+
progress?.update(`Preparing PR cache for ${metadata.baseRepo}`);
|
|
466
|
+
const cacheDir = await ensurePrCacheDir(metadata);
|
|
467
|
+
return {
|
|
468
|
+
diff,
|
|
469
|
+
files: filesFromDiff(diff),
|
|
470
|
+
baseRef: metadata.baseSha,
|
|
471
|
+
metadata,
|
|
472
|
+
cacheDir,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function clearPrCache() {
|
|
476
|
+
(0, fs_1.rmSync)(prCacheRoot(), { recursive: true, force: true });
|
|
477
|
+
}
|
|
210
478
|
function currentBranch() {
|
|
211
479
|
try {
|
|
212
480
|
return git(["branch", "--show-current"]);
|
|
@@ -310,8 +578,8 @@ async function fetchReviewJson(url, init) {
|
|
|
310
578
|
clearTimeout(timeout);
|
|
311
579
|
}
|
|
312
580
|
}
|
|
313
|
-
async function callAuthenticatedAPI(code, language, type, files, bundle, progress) {
|
|
314
|
-
const repo = gitRepoName();
|
|
581
|
+
async function callAuthenticatedAPI(code, language, type, files, bundle, progress, repoOverride) {
|
|
582
|
+
const repo = repoOverride || gitRepoName();
|
|
315
583
|
const payload = {
|
|
316
584
|
type,
|
|
317
585
|
value: code,
|
|
@@ -353,8 +621,8 @@ async function callAuthenticatedAPI(code, language, type, files, bundle, progres
|
|
|
353
621
|
}
|
|
354
622
|
return body;
|
|
355
623
|
}
|
|
356
|
-
async function callAnonymousAPI(code, language, type, files, progress) {
|
|
357
|
-
const repo = gitRepoName();
|
|
624
|
+
async function callAnonymousAPI(code, language, type, files, progress, repoOverride) {
|
|
625
|
+
const repo = repoOverride || gitRepoName();
|
|
358
626
|
const { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/anonymous`, {
|
|
359
627
|
method: "POST",
|
|
360
628
|
headers: { "Content-Type": "application/json" },
|
|
@@ -398,9 +666,9 @@ async function pollForResults(url, initialMeta, headers, progress) {
|
|
|
398
666
|
}
|
|
399
667
|
return { success: false, error: "Review timed out after 10 minutes." };
|
|
400
668
|
}
|
|
401
|
-
function callAPI(code, language, type = "snippet", files = [], bundle, progress) {
|
|
669
|
+
function callAPI(code, language, type = "snippet", files = [], bundle, progress, repoOverride) {
|
|
402
670
|
if (API_KEY)
|
|
403
|
-
return callAuthenticatedAPI(code, language, type, files, bundle, progress);
|
|
671
|
+
return callAuthenticatedAPI(code, language, type, files, bundle, progress, repoOverride);
|
|
404
672
|
return Promise.resolve({
|
|
405
673
|
success: false,
|
|
406
674
|
error: "API key required. Run `rmcode login`, then set RMC_API_KEY before sending code for review.",
|
|
@@ -496,7 +764,7 @@ async function ensureExtractor(info) {
|
|
|
496
764
|
* diff's base state and return the ContextBundle, or null when extraction
|
|
497
765
|
* is unavailable. Throws ExtractorIntegrityError on checksum mismatch.
|
|
498
766
|
*/
|
|
499
|
-
async function extractContextBundle(diff, baseRef, contextOptions = {}) {
|
|
767
|
+
async function extractContextBundle(diff, baseRef, contextOptions = {}, repoRoot) {
|
|
500
768
|
const info = await fetchExtractorInfo();
|
|
501
769
|
if (!info)
|
|
502
770
|
return null;
|
|
@@ -508,7 +776,12 @@ async function extractContextBundle(diff, baseRef, contextOptions = {}) {
|
|
|
508
776
|
let worktreeAdded = false;
|
|
509
777
|
try {
|
|
510
778
|
try {
|
|
511
|
-
|
|
779
|
+
if (repoRoot) {
|
|
780
|
+
gitQuietIn(repoRoot, ["worktree", "add", "--detach", baseDir, baseRef]);
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
gitQuiet(["worktree", "add", "--detach", baseDir, baseRef]);
|
|
784
|
+
}
|
|
512
785
|
worktreeAdded = true;
|
|
513
786
|
}
|
|
514
787
|
catch {
|
|
@@ -554,12 +827,22 @@ async function extractContextBundle(diff, baseRef, contextOptions = {}) {
|
|
|
554
827
|
finally {
|
|
555
828
|
if (worktreeAdded) {
|
|
556
829
|
try {
|
|
557
|
-
|
|
830
|
+
if (repoRoot) {
|
|
831
|
+
gitQuietIn(repoRoot, ["worktree", "remove", "--force", baseDir]);
|
|
832
|
+
}
|
|
833
|
+
else {
|
|
834
|
+
gitQuiet(["worktree", "remove", "--force", baseDir]);
|
|
835
|
+
}
|
|
558
836
|
}
|
|
559
837
|
catch {
|
|
560
838
|
(0, fs_1.rmSync)(baseDir, { recursive: true, force: true });
|
|
561
839
|
try {
|
|
562
|
-
|
|
840
|
+
if (repoRoot) {
|
|
841
|
+
gitQuietIn(repoRoot, ["worktree", "prune"]);
|
|
842
|
+
}
|
|
843
|
+
else {
|
|
844
|
+
gitQuiet(["worktree", "prune"]);
|
|
845
|
+
}
|
|
563
846
|
}
|
|
564
847
|
catch {
|
|
565
848
|
// Stale worktree metadata only; git prunes it on its own later.
|
|
@@ -575,11 +858,11 @@ async function extractContextBundle(diff, baseRef, contextOptions = {}) {
|
|
|
575
858
|
* Reports progress on the shared review spinner so the run renders as one
|
|
576
859
|
* phase-aware line. Exits the process on extractor checksum mismatch.
|
|
577
860
|
*/
|
|
578
|
-
async function maybeExtractContext(diff, spinner, baseRef, contextOptions = {}) {
|
|
579
|
-
if (!API_KEY || !baseRef || !isGitRepo())
|
|
861
|
+
async function maybeExtractContext(diff, spinner, baseRef, contextOptions = {}, repoRoot) {
|
|
862
|
+
if (!API_KEY || !baseRef || (!repoRoot && !isGitRepo()))
|
|
580
863
|
return null;
|
|
581
864
|
try {
|
|
582
|
-
const bundle = await extractContextBundle(diff, baseRef, contextOptions);
|
|
865
|
+
const bundle = await extractContextBundle(diff, baseRef, contextOptions, repoRoot);
|
|
583
866
|
if (bundle)
|
|
584
867
|
return bundle;
|
|
585
868
|
}
|
|
@@ -806,7 +1089,7 @@ function createSpinner(msg) {
|
|
|
806
1089
|
};
|
|
807
1090
|
}
|
|
808
1091
|
// ── Review runner ─────────────────────────────────────────────────────
|
|
809
|
-
async function runReview(content, files, label, jsonMode, failOnFindings, type = "diff", language, baseRef, contextOptions = {}) {
|
|
1092
|
+
async function runReview(content, files, label, jsonMode, failOnFindings, type = "diff", language, baseRef, contextOptions = {}, contextRepoRoot, repoOverride) {
|
|
810
1093
|
const startedAt = Date.now();
|
|
811
1094
|
const lang = language || detectLanguage(files);
|
|
812
1095
|
const uploadMsg = `Uploading review request (${files.length} file${files.length !== 1 ? "s" : ""}, ${lang})`;
|
|
@@ -814,15 +1097,18 @@ async function runReview(content, files, label, jsonMode, failOnFindings, type =
|
|
|
814
1097
|
// the diff's base state so the review sees the same repository context.
|
|
815
1098
|
// One spinner carries the whole run through its phases. The guard here
|
|
816
1099
|
// mirrors maybeExtractContext's own early return.
|
|
817
|
-
const willExtract = type === "diff" &&
|
|
1100
|
+
const willExtract = type === "diff" &&
|
|
1101
|
+
Boolean(API_KEY) &&
|
|
1102
|
+
Boolean(baseRef) &&
|
|
1103
|
+
(Boolean(contextRepoRoot) || isGitRepo());
|
|
818
1104
|
const spinner = createSpinner(willExtract ? "Extracting repository context" : uploadMsg);
|
|
819
1105
|
const bundle = willExtract
|
|
820
|
-
? await maybeExtractContext(content, spinner, baseRef, contextOptions)
|
|
1106
|
+
? await maybeExtractContext(content, spinner, baseRef, contextOptions, contextRepoRoot)
|
|
821
1107
|
: null;
|
|
822
1108
|
if (willExtract)
|
|
823
1109
|
spinner.update(uploadMsg);
|
|
824
1110
|
try {
|
|
825
|
-
const result = await callAPI(content, lang, type, files, bundle, spinner);
|
|
1111
|
+
const result = await callAPI(content, lang, type, files, bundle, spinner, repoOverride);
|
|
826
1112
|
spinner.stop(`Reviewed ${label}`);
|
|
827
1113
|
if (jsonMode) {
|
|
828
1114
|
printJsonResponse(result);
|
|
@@ -848,6 +1134,38 @@ async function runReview(content, files, label, jsonMode, failOnFindings, type =
|
|
|
848
1134
|
process.exit(1);
|
|
849
1135
|
}
|
|
850
1136
|
}
|
|
1137
|
+
async function runPullRequestReview(prValue, jsonMode, failOnFindings, options) {
|
|
1138
|
+
if (!API_KEY) {
|
|
1139
|
+
console.error(style(" API key required. Run `rmcode login`, then set RMC_API_KEY before reviewing a PR.", c.red));
|
|
1140
|
+
process.exit(1);
|
|
1141
|
+
}
|
|
1142
|
+
let ref;
|
|
1143
|
+
try {
|
|
1144
|
+
ref = parsePrReference(prValue);
|
|
1145
|
+
}
|
|
1146
|
+
catch (err) {
|
|
1147
|
+
console.error(style(` ${err.message}`, c.red));
|
|
1148
|
+
process.exit(1);
|
|
1149
|
+
}
|
|
1150
|
+
const spinner = createSpinner(`Fetching GitHub PR ${prValue}`);
|
|
1151
|
+
try {
|
|
1152
|
+
const source = await diffFromPullRequest(ref, spinner);
|
|
1153
|
+
spinner.stop(`Fetched ${source.metadata.baseRepo}#${source.metadata.number}`);
|
|
1154
|
+
const contextOptions = {
|
|
1155
|
+
prTitle: firstNonEmpty(options.title, process.env.RMC_PR_TITLE, source.metadata.title),
|
|
1156
|
+
prNumber: source.metadata.number,
|
|
1157
|
+
sourceRepo: firstNonEmpty(options.sourceRepo, process.env.RMC_SOURCE_REPO, source.metadata.baseRepo),
|
|
1158
|
+
};
|
|
1159
|
+
await runReview(source.diff, source.files, `${source.metadata.baseRepo}#${source.metadata.number}`, jsonMode, failOnFindings, "diff", undefined, source.baseRef, contextOptions, source.cacheDir, source.metadata.baseRepo);
|
|
1160
|
+
if (!jsonMode)
|
|
1161
|
+
await maybeShowUpdateNudge();
|
|
1162
|
+
}
|
|
1163
|
+
catch (err) {
|
|
1164
|
+
spinner.stop("PR fetch failed");
|
|
1165
|
+
console.error(style(` ${err.message}`, c.red));
|
|
1166
|
+
process.exit(1);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
851
1169
|
// ── Help ──────────────────────────────────────────────────────────────
|
|
852
1170
|
function printHelp() {
|
|
853
1171
|
const header = !noColor && !isCI
|
|
@@ -859,10 +1177,13 @@ ${header}
|
|
|
859
1177
|
${style("COMMANDS", c.bold)}
|
|
860
1178
|
|
|
861
1179
|
${style("rmcode", c.cyan)} Review changes from merge base (default)
|
|
1180
|
+
${style("rmcode --pr 123", c.cyan)} Review GitHub PR #123 for this repo
|
|
1181
|
+
${style("rmcode --pr <url>", c.cyan)} Review a specific GitHub PR URL
|
|
862
1182
|
${style("rmcode --staged", c.cyan)} Review staged changes only
|
|
863
1183
|
${style("rmcode --unstaged", c.cyan)} Review unstaged changes only
|
|
864
1184
|
${style("rmcode --all", c.cyan)} Review all uncommitted changes (staged + unstaged)
|
|
865
1185
|
${style("rmcode <file>", c.cyan)} Review a single local file
|
|
1186
|
+
${style("rmcode cache clear", c.cyan)} Clear cached PR checkouts
|
|
866
1187
|
${style("rmcode login", c.cyan)} Set up your API key
|
|
867
1188
|
${style("rmcode install", c.cyan)} Install the GitHub App for automatic PR reviews
|
|
868
1189
|
${style("rmcode update", c.cyan)} Update rmcode to the latest version
|
|
@@ -872,6 +1193,7 @@ ${header}
|
|
|
872
1193
|
|
|
873
1194
|
--json Output the full API response as JSON (for CI/agents)
|
|
874
1195
|
--fail-on-findings Exit 2 when JSON output contains findings
|
|
1196
|
+
--pr <number|url> Review a GitHub PR by number or URL
|
|
875
1197
|
--lang <language> Optional language hint for raw stdin snippets
|
|
876
1198
|
--title <text> Optional PR title hint for repository-context reviews
|
|
877
1199
|
--pr-number <n> Optional PR number hint for CI/benchmark parity
|
|
@@ -882,6 +1204,7 @@ ${header}
|
|
|
882
1204
|
${style("MODES", c.bold)}
|
|
883
1205
|
|
|
884
1206
|
${style("Default", c.white)} Diff from merge base to HEAD — what your PR would contain
|
|
1207
|
+
${style("--pr", c.white)} Fetch a GitHub PR diff and cached base checkout for context extraction
|
|
885
1208
|
${style("--staged", c.white)} Only changes in the staging area (git add)
|
|
886
1209
|
${style("--unstaged", c.white)} Only working directory changes not yet staged
|
|
887
1210
|
${style("--all", c.white)} Everything not yet committed, including untracked text files
|
|
@@ -899,6 +1222,10 @@ ${header}
|
|
|
899
1222
|
${style("# Review a specific range", c.dim)}
|
|
900
1223
|
git diff abc123..def456 | rmcode
|
|
901
1224
|
|
|
1225
|
+
${style("# Review a GitHub PR without checking it out locally", c.dim)}
|
|
1226
|
+
rmcode --pr 123
|
|
1227
|
+
rmcode --pr https://github.com/owner/repo/pull/123
|
|
1228
|
+
|
|
902
1229
|
${style("# Review one file", c.dim)}
|
|
903
1230
|
rmcode src/auth.ts
|
|
904
1231
|
|
|
@@ -911,6 +1238,7 @@ ${header}
|
|
|
911
1238
|
${style("ENVIRONMENT", c.bold)}
|
|
912
1239
|
|
|
913
1240
|
RMC_API_KEY API key for authenticated reviews (get one: rmcode login)
|
|
1241
|
+
RMC_PR_CACHE_DIR Optional directory for reusable GitHub PR cache clones
|
|
914
1242
|
RMC_PR_TITLE Optional PR title hint for repository-context reviews
|
|
915
1243
|
RMC_PR_NUMBER Optional PR number hint for CI/benchmark parity
|
|
916
1244
|
RMC_SOURCE_REPO Optional owner/repo hint when no origin remote exists
|
|
@@ -1082,6 +1410,7 @@ function parseArgs(args) {
|
|
|
1082
1410
|
const positional = [];
|
|
1083
1411
|
const optionAliases = {
|
|
1084
1412
|
"--lang": "lang",
|
|
1413
|
+
"--pr": "pr",
|
|
1085
1414
|
"--title": "title",
|
|
1086
1415
|
"--pr-title": "title",
|
|
1087
1416
|
"--pr-number": "prNumber",
|
|
@@ -1096,10 +1425,13 @@ function parseArgs(args) {
|
|
|
1096
1425
|
if (eq > 0) {
|
|
1097
1426
|
options[optionName] = arg.slice(eq + 1);
|
|
1098
1427
|
}
|
|
1099
|
-
else {
|
|
1428
|
+
else if (args[i + 1] && !args[i + 1].startsWith("-")) {
|
|
1100
1429
|
options[optionName] = args[i + 1];
|
|
1101
1430
|
i++;
|
|
1102
1431
|
}
|
|
1432
|
+
else {
|
|
1433
|
+
options[optionName] = undefined;
|
|
1434
|
+
}
|
|
1103
1435
|
}
|
|
1104
1436
|
else if (arg.startsWith("-")) {
|
|
1105
1437
|
flags.add(arg);
|
|
@@ -1146,6 +1478,15 @@ async function main() {
|
|
|
1146
1478
|
await runUpdate();
|
|
1147
1479
|
return;
|
|
1148
1480
|
}
|
|
1481
|
+
if (positional[0] === "cache") {
|
|
1482
|
+
if (positional[1] === "clear" && positional.length === 2) {
|
|
1483
|
+
clearPrCache();
|
|
1484
|
+
console.log(`\n ${style("✓", c.green)} Cleared rmcode PR cache.\n`);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
console.error(style(`\n Unknown cache command. Run: rmcode cache clear\n`, c.red));
|
|
1488
|
+
process.exit(1);
|
|
1489
|
+
}
|
|
1149
1490
|
if (positional[0] === "install") {
|
|
1150
1491
|
console.log(`\n ${style("Install the GitHub App for automatic PR reviews:", c.bold)}\n`);
|
|
1151
1492
|
console.log(` ${style("https://github.com/apps/rmcode-ai", c.cyan, c.bold)}\n`);
|
|
@@ -1177,6 +1518,7 @@ async function main() {
|
|
|
1177
1518
|
const jsonMode = flags.has("--json");
|
|
1178
1519
|
const failOnFindings = flags.has("--fail-on-findings");
|
|
1179
1520
|
const contextOptions = contextOptionsFromCli(options);
|
|
1521
|
+
const hasPrOption = Object.prototype.hasOwnProperty.call(options, "pr");
|
|
1180
1522
|
if (positional.length > 1) {
|
|
1181
1523
|
console.error(style(`\n Unknown command: ${positional[0]}`, c.red));
|
|
1182
1524
|
console.error(style(` Run: rmcode help\n`, c.dim));
|
|
@@ -1188,6 +1530,23 @@ async function main() {
|
|
|
1188
1530
|
if (!jsonMode && !noColor && !isCI) {
|
|
1189
1531
|
process.stderr.write(`\n${owlHeader()}\n\n`);
|
|
1190
1532
|
}
|
|
1533
|
+
if (hasPrOption) {
|
|
1534
|
+
const prValue = options.pr?.trim();
|
|
1535
|
+
if (!prValue) {
|
|
1536
|
+
console.error(style("\n Missing value for --pr.\n", c.red));
|
|
1537
|
+
process.exit(1);
|
|
1538
|
+
}
|
|
1539
|
+
if (positional.length > 0) {
|
|
1540
|
+
console.error(style("\n --pr cannot be combined with a file path or command.\n", c.red));
|
|
1541
|
+
process.exit(1);
|
|
1542
|
+
}
|
|
1543
|
+
if (flags.has("--staged") || flags.has("--unstaged") || flags.has("--all")) {
|
|
1544
|
+
console.error(style("\n --pr cannot be combined with --staged, --unstaged, or --all.\n", c.red));
|
|
1545
|
+
process.exit(1);
|
|
1546
|
+
}
|
|
1547
|
+
await runPullRequestReview(prValue, jsonMode, failOnFindings, options);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1191
1550
|
// Piped stdin
|
|
1192
1551
|
if (hasPipedStdin()) {
|
|
1193
1552
|
const rl = (0, readline_1.createInterface)({ input: process.stdin });
|
package/package.json
CHANGED