@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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/dist/cli.js +380 -21
  3. 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.5";
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
- const m = remote.match(/github\.com[:/]([^/]+\/[^/.]+)/);
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
- gitQuiet(["worktree", "add", "--detach", baseDir, baseRef]);
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
- gitQuiet(["worktree", "remove", "--force", baseDir]);
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
- gitQuiet(["worktree", "prune"]);
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" && Boolean(API_KEY) && Boolean(baseRef) && isGitRepo();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@review-my-code/rmcode",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "AI code review from your terminal. Catches logic errors, null risks, security holes, and broken error handling.",
5
5
  "keywords": [
6
6
  "code-review",