@review-my-code/rmcode 0.1.1 → 0.1.3
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/dist/cli.js +548 -61
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,17 +2,33 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
5
6
|
const fs_1 = require("fs");
|
|
7
|
+
const os_1 = require("os");
|
|
6
8
|
const path_1 = require("path");
|
|
7
9
|
const readline_1 = require("readline");
|
|
8
10
|
const API_URL = process.env.RMC_API_URL || "https://review-my-code.com";
|
|
9
11
|
const APP_URL = process.env.RMC_APP_URL || "https://review-my-code.com";
|
|
10
|
-
const VERSION = "0.1.
|
|
12
|
+
const VERSION = "0.1.3";
|
|
11
13
|
const FREE_PLAN_CREDITS_PER_MONTH = 30;
|
|
12
14
|
const REQUEST_TIMEOUT_MS = 290_000;
|
|
13
15
|
const POLL_TIMEOUT_MS = 10 * 60_000;
|
|
14
|
-
|
|
16
|
+
// Backed-off polling keeps long reviews well under the backend's per-IP
|
|
17
|
+
// rate limit (100 requests / 15 min).
|
|
18
|
+
const POLL_INITIAL_INTERVAL_MS = 3_000;
|
|
19
|
+
const POLL_MAX_INTERVAL_MS = 15_000;
|
|
20
|
+
const POLL_BACKOFF_FACTOR = 1.5;
|
|
15
21
|
const MAX_UNTRACKED_FILE_BYTES = 512 * 1024;
|
|
22
|
+
const EXTRACTOR_TIMEOUT_MS = 120_000;
|
|
23
|
+
const EXTRACTOR_DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
24
|
+
const EXTRACTOR_INFO_TIMEOUT_MS = 15_000;
|
|
25
|
+
// Mirrors the GitHub App workflow template's "Install extractor runtime
|
|
26
|
+
// dependencies" step and REPOMAP_MAX_CHARS env.
|
|
27
|
+
const EXTRACTOR_RUNTIME_DEPS = [
|
|
28
|
+
"web-tree-sitter@0.20.8",
|
|
29
|
+
"tree-sitter-wasms@0.1.13",
|
|
30
|
+
];
|
|
31
|
+
const EXTRACTOR_REPOMAP_MAX_CHARS = "80000";
|
|
16
32
|
// ── Colors (no dependencies) ──────────────────────────────────────────
|
|
17
33
|
const c = {
|
|
18
34
|
reset: "\x1b[0m",
|
|
@@ -35,6 +51,19 @@ function style(text, ...codes) {
|
|
|
35
51
|
return text;
|
|
36
52
|
return codes.join("") + text + c.reset;
|
|
37
53
|
}
|
|
54
|
+
// ── Owl header ────────────────────────────────────────────────────────
|
|
55
|
+
//
|
|
56
|
+
// Brand header shown once at the start of review runs (stderr, so piped
|
|
57
|
+
// stdout stays parseable) and in --help. Hidden in --json mode, non-TTY,
|
|
58
|
+
// NO_COLOR, and CI via the noColor/isCI guards.
|
|
59
|
+
function owlHeader() {
|
|
60
|
+
const owl = (part) => style(part, c.dim, c.gray);
|
|
61
|
+
return [
|
|
62
|
+
` ${owl("{o,o}")}`,
|
|
63
|
+
` ${owl("/)__)")} ${style("rmcode", c.bold, c.white)} ${style(`v${VERSION} — AI code review`, c.dim)}`,
|
|
64
|
+
` ${owl('" "')} ${style("─────────────────────────────────", c.dim)}`,
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
38
67
|
// ── Severity styling ──────────────────────────────────────────────────
|
|
39
68
|
function severityBadge(severity) {
|
|
40
69
|
const s = severity.toLowerCase();
|
|
@@ -53,6 +82,13 @@ function git(args) {
|
|
|
53
82
|
maxBuffer: 10 * 1024 * 1024,
|
|
54
83
|
}).trim();
|
|
55
84
|
}
|
|
85
|
+
function gitQuiet(args) {
|
|
86
|
+
return (0, child_process_1.execFileSync)("git", args, {
|
|
87
|
+
encoding: "utf-8",
|
|
88
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
89
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
90
|
+
}).trim();
|
|
91
|
+
}
|
|
56
92
|
function isGitRepo() {
|
|
57
93
|
try {
|
|
58
94
|
git(["rev-parse", "--is-inside-work-tree"]);
|
|
@@ -90,25 +126,33 @@ function mergeBase() {
|
|
|
90
126
|
}
|
|
91
127
|
throw new Error("Could not determine a base branch. Set an upstream branch, or run rmcode --staged, rmcode --all, or pipe an explicit git diff.");
|
|
92
128
|
}
|
|
129
|
+
function headCommit() {
|
|
130
|
+
try {
|
|
131
|
+
return git(["rev-parse", "--verify", "HEAD"]);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
93
137
|
function diffFromMergeBase() {
|
|
94
138
|
const base = mergeBase();
|
|
95
139
|
const diff = git(["diff", `${base}...HEAD`]);
|
|
96
140
|
const files = git(["diff", `${base}...HEAD`, "--name-only"])
|
|
97
141
|
.split("\n")
|
|
98
142
|
.filter(Boolean);
|
|
99
|
-
return { diff, files };
|
|
143
|
+
return { diff, files, baseRef: base };
|
|
100
144
|
}
|
|
101
145
|
function diffStaged() {
|
|
102
146
|
const diff = git(["diff", "--cached"]);
|
|
103
147
|
const files = git(["diff", "--cached", "--name-only"])
|
|
104
148
|
.split("\n")
|
|
105
149
|
.filter(Boolean);
|
|
106
|
-
return { diff, files };
|
|
150
|
+
return { diff, files, baseRef: headCommit() };
|
|
107
151
|
}
|
|
108
152
|
function diffUnstaged() {
|
|
109
153
|
const diff = git(["diff"]);
|
|
110
154
|
const files = git(["diff", "--name-only"]).split("\n").filter(Boolean);
|
|
111
|
-
return { diff, files };
|
|
155
|
+
return { diff, files, baseRef: headCommit() };
|
|
112
156
|
}
|
|
113
157
|
function diffAll() {
|
|
114
158
|
const root = gitWorktreeRoot();
|
|
@@ -124,7 +168,7 @@ function diffAll() {
|
|
|
124
168
|
.filter(Boolean);
|
|
125
169
|
const diff = [trackedDiff, ...untrackedDiffs].filter(Boolean).join("\n\n");
|
|
126
170
|
const files = [...trackedFiles, ...untrackedFiles];
|
|
127
|
-
return { diff, files };
|
|
171
|
+
return { diff, files, baseRef: headCommit() };
|
|
128
172
|
}
|
|
129
173
|
function pseudoDiffForNewFile(file, worktreeRoot) {
|
|
130
174
|
try {
|
|
@@ -236,22 +280,50 @@ async function fetchReviewJson(url, init) {
|
|
|
236
280
|
clearTimeout(timeout);
|
|
237
281
|
}
|
|
238
282
|
}
|
|
239
|
-
async function callAuthenticatedAPI(code, language, type, files) {
|
|
283
|
+
async function callAuthenticatedAPI(code, language, type, files, bundle, progress) {
|
|
240
284
|
const repo = gitRepoName();
|
|
241
|
-
const
|
|
285
|
+
const payload = {
|
|
286
|
+
type,
|
|
287
|
+
value: code,
|
|
288
|
+
language,
|
|
289
|
+
files,
|
|
290
|
+
repo,
|
|
291
|
+
};
|
|
292
|
+
if (bundle)
|
|
293
|
+
payload.bundle = bundle;
|
|
294
|
+
let { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/cli`, {
|
|
242
295
|
method: "POST",
|
|
243
296
|
headers: {
|
|
244
297
|
"Content-Type": "application/json",
|
|
245
298
|
"X-API-Key": API_KEY,
|
|
246
299
|
},
|
|
247
|
-
body: JSON.stringify(
|
|
300
|
+
body: JSON.stringify(payload),
|
|
248
301
|
});
|
|
302
|
+
// If the API rejects the attached context bundle (too large or invalid),
|
|
303
|
+
// degrade to the legacy diff-only request instead of failing the review.
|
|
304
|
+
if (bundle && (status === 413 || status === 400)) {
|
|
305
|
+
const notice = style(" Repository context was rejected by the API — reviewing without it.", c.dim);
|
|
306
|
+
if (progress)
|
|
307
|
+
progress.note(notice);
|
|
308
|
+
else
|
|
309
|
+
console.error(notice);
|
|
310
|
+
delete payload.bundle;
|
|
311
|
+
({ status, body } = await fetchReviewJson(`${API_URL}/api/reviews/cli`, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: {
|
|
314
|
+
"Content-Type": "application/json",
|
|
315
|
+
"X-API-Key": API_KEY,
|
|
316
|
+
},
|
|
317
|
+
body: JSON.stringify(payload),
|
|
318
|
+
}));
|
|
319
|
+
}
|
|
249
320
|
if (status === 202 && body.data?.accessToken) {
|
|
250
|
-
|
|
321
|
+
progress?.update("Reviewing (queued)");
|
|
322
|
+
return pollForResults(`${API_URL}/api/reviews/cli/${body.data.accessToken}`, body.meta, { "X-API-Key": API_KEY }, progress);
|
|
251
323
|
}
|
|
252
324
|
return body;
|
|
253
325
|
}
|
|
254
|
-
async function callAnonymousAPI(code, language, type, files) {
|
|
326
|
+
async function callAnonymousAPI(code, language, type, files, progress) {
|
|
255
327
|
const repo = gitRepoName();
|
|
256
328
|
const { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/anonymous`, {
|
|
257
329
|
method: "POST",
|
|
@@ -259,15 +331,23 @@ async function callAnonymousAPI(code, language, type, files) {
|
|
|
259
331
|
body: JSON.stringify({ type, value: code, language, files, repo }),
|
|
260
332
|
});
|
|
261
333
|
if (status === 202 && body.data?.accessToken) {
|
|
262
|
-
|
|
334
|
+
progress?.update("Reviewing (queued)");
|
|
335
|
+
return pollForResults(`${API_URL}/api/reviews/anonymous/${body.data.accessToken}`, body.meta, undefined, progress);
|
|
263
336
|
}
|
|
264
337
|
return body;
|
|
265
338
|
}
|
|
266
|
-
async function pollForResults(url, initialMeta, headers) {
|
|
339
|
+
async function pollForResults(url, initialMeta, headers, progress) {
|
|
267
340
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
341
|
+
let interval = POLL_INITIAL_INTERVAL_MS;
|
|
342
|
+
let reviewing = false;
|
|
268
343
|
while (Date.now() < deadline) {
|
|
269
|
-
await new Promise((r) => setTimeout(r,
|
|
344
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
345
|
+
interval = Math.min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL_MS);
|
|
270
346
|
const { status, body } = await fetchReviewJson(url, { headers });
|
|
347
|
+
if (status === 429 || body.error?.includes("Too many requests")) {
|
|
348
|
+
// Rate limiting is transient — retry at the backed-off interval.
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
271
351
|
if (status >= 400 || body.success === false) {
|
|
272
352
|
if (initialMeta && !body.meta)
|
|
273
353
|
body.meta = initialMeta;
|
|
@@ -278,12 +358,19 @@ async function pollForResults(url, initialMeta, headers) {
|
|
|
278
358
|
body.meta = initialMeta;
|
|
279
359
|
return body;
|
|
280
360
|
}
|
|
361
|
+
// The poll body carries no real queue state ("pending" covers both
|
|
362
|
+
// queued and running), so flip to the active phase once the first
|
|
363
|
+
// poll answers — unless the API explicitly reports "queued".
|
|
364
|
+
if (progress && !reviewing && body.data?.status !== "queued") {
|
|
365
|
+
reviewing = true;
|
|
366
|
+
progress.update("Reviewing");
|
|
367
|
+
}
|
|
281
368
|
}
|
|
282
369
|
return { success: false, error: "Review timed out after 10 minutes." };
|
|
283
370
|
}
|
|
284
|
-
function callAPI(code, language, type = "snippet", files = []) {
|
|
371
|
+
function callAPI(code, language, type = "snippet", files = [], bundle, progress) {
|
|
285
372
|
if (API_KEY)
|
|
286
|
-
return callAuthenticatedAPI(code, language, type, files);
|
|
373
|
+
return callAuthenticatedAPI(code, language, type, files, bundle, progress);
|
|
287
374
|
return Promise.resolve({
|
|
288
375
|
success: false,
|
|
289
376
|
error: "API key required. Run `rmcode login`, then set RMC_API_KEY before sending code for review.",
|
|
@@ -295,6 +382,186 @@ function callAPI(code, language, type = "snippet", files = []) {
|
|
|
295
382
|
},
|
|
296
383
|
});
|
|
297
384
|
}
|
|
385
|
+
class ExtractorIntegrityError extends Error {
|
|
386
|
+
}
|
|
387
|
+
function sha256Hex(data) {
|
|
388
|
+
return (0, crypto_1.createHash)("sha256").update(data).digest("hex");
|
|
389
|
+
}
|
|
390
|
+
async function fetchExtractorInfo() {
|
|
391
|
+
try {
|
|
392
|
+
const resp = await fetch(`${API_URL}/api/reviews/cli/extractor`, {
|
|
393
|
+
headers: { "X-API-Key": API_KEY },
|
|
394
|
+
signal: AbortSignal.timeout(EXTRACTOR_INFO_TIMEOUT_MS),
|
|
395
|
+
});
|
|
396
|
+
if (!resp.ok)
|
|
397
|
+
return null;
|
|
398
|
+
const body = (await resp.json());
|
|
399
|
+
const data = body.data;
|
|
400
|
+
if (!data?.version || !data.sha256 || !data.url)
|
|
401
|
+
return null;
|
|
402
|
+
return {
|
|
403
|
+
version: data.version,
|
|
404
|
+
sha256: data.sha256.toLowerCase(),
|
|
405
|
+
url: data.url,
|
|
406
|
+
tier: data.tier,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Download (or reuse a cached copy of) the pinned extractor and install its
|
|
415
|
+
* runtime dependencies next to it, mirroring the GitHub App workflow.
|
|
416
|
+
* Throws ExtractorIntegrityError on checksum mismatch — never run an
|
|
417
|
+
* artifact that fails verification.
|
|
418
|
+
*/
|
|
419
|
+
async function ensureExtractor(info) {
|
|
420
|
+
const cacheDir = (0, path_1.join)((0, os_1.tmpdir)(), "rmcode-extractor", info.version);
|
|
421
|
+
(0, fs_1.mkdirSync)(cacheDir, { recursive: true });
|
|
422
|
+
const extractorPath = (0, path_1.join)(cacheDir, "extractor.mjs");
|
|
423
|
+
let verified = false;
|
|
424
|
+
if ((0, fs_1.existsSync)(extractorPath)) {
|
|
425
|
+
verified = sha256Hex((0, fs_1.readFileSync)(extractorPath)) === info.sha256;
|
|
426
|
+
}
|
|
427
|
+
if (!verified) {
|
|
428
|
+
const resp = await fetch(info.url, {
|
|
429
|
+
signal: AbortSignal.timeout(EXTRACTOR_DOWNLOAD_TIMEOUT_MS),
|
|
430
|
+
});
|
|
431
|
+
if (!resp.ok) {
|
|
432
|
+
throw new Error(`Extractor download failed (${resp.status}).`);
|
|
433
|
+
}
|
|
434
|
+
const data = Buffer.from(await resp.arrayBuffer());
|
|
435
|
+
if (sha256Hex(data) !== info.sha256) {
|
|
436
|
+
throw new ExtractorIntegrityError(`Extractor checksum mismatch for ${info.url} — refusing to run it. Expected sha256 ${info.sha256}.`);
|
|
437
|
+
}
|
|
438
|
+
(0, fs_1.writeFileSync)(extractorPath, data);
|
|
439
|
+
}
|
|
440
|
+
if (!(0, fs_1.existsSync)((0, path_1.join)(cacheDir, "node_modules", "web-tree-sitter"))) {
|
|
441
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
442
|
+
const init = (0, child_process_1.spawnSync)(npm, ["init", "-y"], {
|
|
443
|
+
cwd: cacheDir,
|
|
444
|
+
stdio: "ignore",
|
|
445
|
+
timeout: 30_000,
|
|
446
|
+
});
|
|
447
|
+
if (init.status !== 0) {
|
|
448
|
+
throw new Error("Could not initialize extractor dependency directory.");
|
|
449
|
+
}
|
|
450
|
+
const install = (0, child_process_1.spawnSync)(npm, [
|
|
451
|
+
"install",
|
|
452
|
+
"--no-save",
|
|
453
|
+
"--legacy-peer-deps",
|
|
454
|
+
"--no-audit",
|
|
455
|
+
"--no-fund",
|
|
456
|
+
...EXTRACTOR_RUNTIME_DEPS,
|
|
457
|
+
], { cwd: cacheDir, stdio: "ignore", timeout: EXTRACTOR_TIMEOUT_MS });
|
|
458
|
+
if (install.status !== 0) {
|
|
459
|
+
throw new Error("Could not install extractor runtime dependencies.");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return extractorPath;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Run the pinned extractor against a temporary detached worktree at the
|
|
466
|
+
* diff's base state and return the ContextBundle, or null when extraction
|
|
467
|
+
* is unavailable. Throws ExtractorIntegrityError on checksum mismatch.
|
|
468
|
+
*/
|
|
469
|
+
async function extractContextBundle(diff, baseRef) {
|
|
470
|
+
const info = await fetchExtractorInfo();
|
|
471
|
+
if (!info)
|
|
472
|
+
return null;
|
|
473
|
+
const extractorPath = await ensureExtractor(info);
|
|
474
|
+
const runDir = (0, fs_1.mkdtempSync)((0, path_1.join)((0, os_1.tmpdir)(), "rmcode-run-"));
|
|
475
|
+
const diffPath = (0, path_1.join)(runDir, "pr.diff");
|
|
476
|
+
const bundlePath = (0, path_1.join)(runDir, "bundle.json");
|
|
477
|
+
const baseDir = (0, path_1.join)(runDir, "base");
|
|
478
|
+
let worktreeAdded = false;
|
|
479
|
+
try {
|
|
480
|
+
try {
|
|
481
|
+
gitQuiet(["worktree", "add", "--detach", baseDir, baseRef]);
|
|
482
|
+
worktreeAdded = true;
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// Without a base-state worktree the extractor would see the wrong
|
|
486
|
+
// tree (e.g. uncommitted or post-PR files), so degrade to diff-only
|
|
487
|
+
// instead of extracting from the live checkout.
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
(0, fs_1.writeFileSync)(diffPath, diff);
|
|
491
|
+
const result = (0, child_process_1.spawnSync)(process.execPath, [
|
|
492
|
+
extractorPath,
|
|
493
|
+
"--pr=0",
|
|
494
|
+
`--diff-file=${diffPath}`,
|
|
495
|
+
`--output-bundle=${bundlePath}`,
|
|
496
|
+
`--source-repo=${process.env.RMC_SOURCE_REPO || gitRepoName() || "local/repo"}`,
|
|
497
|
+
`--tier=${info.tier || "free"}`,
|
|
498
|
+
], {
|
|
499
|
+
cwd: baseDir,
|
|
500
|
+
stdio: "ignore",
|
|
501
|
+
timeout: EXTRACTOR_TIMEOUT_MS,
|
|
502
|
+
env: {
|
|
503
|
+
...process.env,
|
|
504
|
+
REPOMAP_MAX_CHARS: EXTRACTOR_REPOMAP_MAX_CHARS,
|
|
505
|
+
RMCODE_EXTRACTOR_VERSION: info.version,
|
|
506
|
+
RMCODE_EXTRACTOR_SHA256: info.sha256,
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
if (result.status !== 0 || !(0, fs_1.existsSync)(bundlePath))
|
|
510
|
+
return null;
|
|
511
|
+
const bundle = JSON.parse((0, fs_1.readFileSync)(bundlePath, "utf-8"));
|
|
512
|
+
if (typeof bundle.diff !== "string" ||
|
|
513
|
+
typeof bundle.repomapContext !== "string" ||
|
|
514
|
+
typeof bundle.enrichedContext !== "string" ||
|
|
515
|
+
!Array.isArray(bundle.changedFiles)) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
return bundle;
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
finally {
|
|
524
|
+
if (worktreeAdded) {
|
|
525
|
+
try {
|
|
526
|
+
gitQuiet(["worktree", "remove", "--force", baseDir]);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
(0, fs_1.rmSync)(baseDir, { recursive: true, force: true });
|
|
530
|
+
try {
|
|
531
|
+
gitQuiet(["worktree", "prune"]);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
// Stale worktree metadata only; git prunes it on its own later.
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
(0, fs_1.rmSync)(runDir, { recursive: true, force: true });
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Best-effort context extraction for git-based diff reviews. Returns null
|
|
543
|
+
* (after printing a one-line notice) when the review must run diff-only.
|
|
544
|
+
* Reports progress on the shared review spinner so the run renders as one
|
|
545
|
+
* phase-aware line. Exits the process on extractor checksum mismatch.
|
|
546
|
+
*/
|
|
547
|
+
async function maybeExtractContext(diff, spinner, baseRef) {
|
|
548
|
+
if (!API_KEY || !baseRef || !isGitRepo())
|
|
549
|
+
return null;
|
|
550
|
+
try {
|
|
551
|
+
const bundle = await extractContextBundle(diff, baseRef);
|
|
552
|
+
if (bundle)
|
|
553
|
+
return bundle;
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
if (err instanceof ExtractorIntegrityError) {
|
|
557
|
+
spinner.stop("Context extraction aborted");
|
|
558
|
+
console.error(style(` ${err.message}`, c.red));
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
spinner.note(style(" Running review without repository context.", c.dim));
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
298
565
|
// ── Language detection ────────────────────────────────────────────────
|
|
299
566
|
const EXT_TO_LANG = {
|
|
300
567
|
".ts": "typescript",
|
|
@@ -339,9 +606,54 @@ function detectLanguage(files) {
|
|
|
339
606
|
return "javascript";
|
|
340
607
|
}
|
|
341
608
|
// ── Output formatting ─────────────────────────────────────────────────
|
|
342
|
-
function
|
|
609
|
+
function severityRank(severity) {
|
|
610
|
+
const s = severity.toLowerCase();
|
|
611
|
+
if (s === "critical" || s === "security")
|
|
612
|
+
return 0;
|
|
613
|
+
if (s === "bug" || s === "high")
|
|
614
|
+
return 1;
|
|
615
|
+
if (s === "perf" || s === "medium")
|
|
616
|
+
return 2;
|
|
617
|
+
return 3;
|
|
618
|
+
}
|
|
619
|
+
function wrapText(text, width) {
|
|
620
|
+
const lines = [];
|
|
621
|
+
for (const paragraph of text.split("\n")) {
|
|
622
|
+
if (!paragraph.trim()) {
|
|
623
|
+
lines.push("");
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
let line = "";
|
|
627
|
+
for (const word of paragraph.split(/\s+/)) {
|
|
628
|
+
if (!line)
|
|
629
|
+
line = word;
|
|
630
|
+
else if (line.length + 1 + word.length <= width)
|
|
631
|
+
line += ` ${word}`;
|
|
632
|
+
else {
|
|
633
|
+
lines.push(line);
|
|
634
|
+
line = word;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (line)
|
|
638
|
+
lines.push(line);
|
|
639
|
+
}
|
|
640
|
+
return lines;
|
|
641
|
+
}
|
|
642
|
+
function verdictFooter(findingCount, startedAt) {
|
|
643
|
+
const rule = noColor ? "--" : style("──", c.dim);
|
|
644
|
+
const mark = findingCount > 0 ? style("✗", c.red, c.bold) : style("✓", c.green, c.bold);
|
|
645
|
+
const verdict = findingCount > 0
|
|
646
|
+
? `changes requested · ${findingCount} finding${findingCount !== 1 ? "s" : ""}`
|
|
647
|
+
: "approved · no blocking findings";
|
|
648
|
+
const elapsed = startedAt != null
|
|
649
|
+
? ` · ${formatElapsed(Date.now() - startedAt, true)}`
|
|
650
|
+
: "";
|
|
651
|
+
return ` ${rule} ${mark} ${verdict}${elapsed} ${rule}`;
|
|
652
|
+
}
|
|
653
|
+
function printFindings(findings, startedAt) {
|
|
343
654
|
if (findings.length === 0) {
|
|
344
655
|
console.log(`\n ${style("No issues found.", c.green, c.bold)} Your code looks good.\n`);
|
|
656
|
+
console.log(`${verdictFooter(0, startedAt)}\n`);
|
|
345
657
|
return;
|
|
346
658
|
}
|
|
347
659
|
const counts = {};
|
|
@@ -351,20 +663,43 @@ function printFindings(findings) {
|
|
|
351
663
|
.map(([k, v]) => `${v} ${k}`)
|
|
352
664
|
.join(" · ");
|
|
353
665
|
console.log(`\n ${style(`${findings.length} finding${findings.length !== 1 ? "s" : ""}`, c.bold, c.white)} · ${summary}\n`);
|
|
354
|
-
|
|
666
|
+
const sorted = [...findings].sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
|
|
667
|
+
const numWidth = String(sorted.length).length;
|
|
668
|
+
// Each body line is indented by " " + number column + " " + gutter bar
|
|
669
|
+
// + " " = numWidth + 5 chars. Clamp to a sane floor so narrow terminals
|
|
670
|
+
// never produce a negative width for wrap/repeat.
|
|
671
|
+
const wrapWidth = Math.max(20, Math.min(process.stdout.columns || 80, 100) - (numWidth + 5));
|
|
672
|
+
const pad = " ".repeat(numWidth);
|
|
673
|
+
const bar = noColor ? "|" : style("▌", c.dim);
|
|
674
|
+
const fixPad = " ".repeat("fix: ".length);
|
|
675
|
+
sorted.forEach((f, idx) => {
|
|
676
|
+
const num = String(idx + 1).padStart(numWidth);
|
|
355
677
|
const location = f.filePath
|
|
356
678
|
? `${style(f.filePath, c.cyan)}${f.line ? `:${f.line}` : ""}`
|
|
357
679
|
: "";
|
|
358
|
-
console.log(` ${severityBadge(f.severity)} ${style(f.title, c.bold, c.white)}`);
|
|
680
|
+
console.log(` ${style(num, c.bold, c.white)} ${bar} ${severityBadge(f.severity)} ${style(f.title, c.bold, c.white)}`);
|
|
359
681
|
if (location)
|
|
360
|
-
console.log(` ${style("at", c.dim)} ${location}`);
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (f.
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
682
|
+
console.log(` ${pad} ${bar} ${style("at", c.dim)} ${location}`);
|
|
683
|
+
for (const line of wrapText(f.explanation, wrapWidth)) {
|
|
684
|
+
console.log(` ${pad} ${bar} ${style(line, c.gray)}`);
|
|
685
|
+
}
|
|
686
|
+
if (f.codeContext) {
|
|
687
|
+
for (const line of f.codeContext.split("\n")) {
|
|
688
|
+
console.log(` ${pad} ${bar} ${style(line, c.dim)}`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (f.suggestedCode) {
|
|
692
|
+
const [first, ...rest] = f.suggestedCode.split("\n");
|
|
693
|
+
console.log(` ${pad} ${bar} ${style("fix:", c.green)} ${style(first, c.green)}`);
|
|
694
|
+
for (const line of rest) {
|
|
695
|
+
console.log(` ${pad} ${bar} ${fixPad}${style(line, c.green)}`);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (idx < sorted.length - 1) {
|
|
699
|
+
console.log(` ${noColor ? "-".repeat(wrapWidth) : style("─".repeat(wrapWidth), c.dim)}`);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
console.log(`\n${verdictFooter(sorted.length, startedAt)}\n`);
|
|
368
703
|
}
|
|
369
704
|
function printMeta(meta) {
|
|
370
705
|
if (!meta)
|
|
@@ -390,35 +725,73 @@ function hasBlockingFindings(result) {
|
|
|
390
725
|
const findings = result.data?.findings?.findings || [];
|
|
391
726
|
return (findings.length > 0 || result.data?.findings?.verdict === "request_changes");
|
|
392
727
|
}
|
|
393
|
-
|
|
728
|
+
function formatElapsed(ms, compact = false) {
|
|
729
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
730
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
731
|
+
const seconds = String(totalSeconds % 60).padStart(2, "0");
|
|
732
|
+
if (compact)
|
|
733
|
+
return minutes > 0 ? `${minutes}m${seconds}s` : `${totalSeconds % 60}s`;
|
|
734
|
+
return `${minutes}m ${seconds}s`;
|
|
735
|
+
}
|
|
394
736
|
function createSpinner(msg) {
|
|
395
737
|
if (noColor || isCI) {
|
|
738
|
+
// Plain fallback: print each phase transition once, no timer spam.
|
|
739
|
+
let lastMsg = msg;
|
|
396
740
|
process.stderr.write(` ${msg}...\n`);
|
|
397
741
|
return {
|
|
398
|
-
|
|
742
|
+
update(nextMsg) {
|
|
743
|
+
if (nextMsg === lastMsg)
|
|
744
|
+
return;
|
|
745
|
+
lastMsg = nextMsg;
|
|
746
|
+
process.stderr.write(` ${nextMsg}...\n`);
|
|
747
|
+
},
|
|
748
|
+
note(line) {
|
|
749
|
+
process.stderr.write(`${line}\n`);
|
|
750
|
+
},
|
|
751
|
+
stop(final) {
|
|
399
752
|
if (final)
|
|
400
753
|
process.stderr.write(` ${final}\n`);
|
|
401
754
|
},
|
|
402
755
|
};
|
|
403
756
|
}
|
|
404
757
|
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
758
|
+
const startedAt = Date.now();
|
|
759
|
+
let currentMsg = msg;
|
|
405
760
|
let i = 0;
|
|
406
761
|
const interval = setInterval(() => {
|
|
407
|
-
process.stderr.write(`\r ${style(frames[i++ % frames.length], c.green)} ${
|
|
762
|
+
process.stderr.write(`\r ${style(frames[i++ % frames.length], c.green)} ${currentMsg}… ${style(formatElapsed(Date.now() - startedAt), c.dim)}\x1b[K`);
|
|
408
763
|
}, 80);
|
|
409
764
|
return {
|
|
765
|
+
update(nextMsg) {
|
|
766
|
+
currentMsg = nextMsg;
|
|
767
|
+
},
|
|
768
|
+
note(line) {
|
|
769
|
+
process.stderr.write(`\r\x1b[K${line}\n`);
|
|
770
|
+
},
|
|
410
771
|
stop(finalMsg) {
|
|
411
772
|
clearInterval(interval);
|
|
412
|
-
process.stderr.write(`\r ${style("✓", c.green)} ${finalMsg ||
|
|
773
|
+
process.stderr.write(`\r ${style("✓", c.green)} ${finalMsg || currentMsg}\x1b[K\n`);
|
|
413
774
|
},
|
|
414
775
|
};
|
|
415
776
|
}
|
|
416
777
|
// ── Review runner ─────────────────────────────────────────────────────
|
|
417
|
-
async function runReview(content, files, label, jsonMode, failOnFindings, type = "diff", language) {
|
|
778
|
+
async function runReview(content, files, label, jsonMode, failOnFindings, type = "diff", language, baseRef) {
|
|
779
|
+
const startedAt = Date.now();
|
|
418
780
|
const lang = language || detectLanguage(files);
|
|
419
|
-
const
|
|
781
|
+
const uploadMsg = `Uploading review request (${files.length} file${files.length !== 1 ? "s" : ""}, ${lang})`;
|
|
782
|
+
// Reach parity with the GitHub App path: run the pinned extractor against
|
|
783
|
+
// the diff's base state so the review sees the same repository context.
|
|
784
|
+
// One spinner carries the whole run through its phases. The guard here
|
|
785
|
+
// mirrors maybeExtractContext's own early return.
|
|
786
|
+
const willExtract = type === "diff" && Boolean(API_KEY) && Boolean(baseRef) && isGitRepo();
|
|
787
|
+
const spinner = createSpinner(willExtract ? "Extracting repository context" : uploadMsg);
|
|
788
|
+
const bundle = willExtract
|
|
789
|
+
? await maybeExtractContext(content, spinner, baseRef)
|
|
790
|
+
: null;
|
|
791
|
+
if (willExtract)
|
|
792
|
+
spinner.update(uploadMsg);
|
|
420
793
|
try {
|
|
421
|
-
const result = await callAPI(content, lang, type, files);
|
|
794
|
+
const result = await callAPI(content, lang, type, files, bundle, spinner);
|
|
422
795
|
spinner.stop(`Reviewed ${label}`);
|
|
423
796
|
if (jsonMode) {
|
|
424
797
|
printJsonResponse(result);
|
|
@@ -434,9 +807,9 @@ async function runReview(content, files, label, jsonMode, failOnFindings, type =
|
|
|
434
807
|
console.log(` ${style(result.meta.message, c.yellow)}`);
|
|
435
808
|
process.exit(1);
|
|
436
809
|
}
|
|
437
|
-
printMeta(result.meta);
|
|
438
810
|
if (result.data?.findings?.findings)
|
|
439
|
-
printFindings(result.data.findings.findings);
|
|
811
|
+
printFindings(result.data.findings.findings, startedAt);
|
|
812
|
+
printMeta(result.meta);
|
|
440
813
|
}
|
|
441
814
|
catch (err) {
|
|
442
815
|
spinner.stop("Review failed");
|
|
@@ -446,8 +819,11 @@ async function runReview(content, files, label, jsonMode, failOnFindings, type =
|
|
|
446
819
|
}
|
|
447
820
|
// ── Help ──────────────────────────────────────────────────────────────
|
|
448
821
|
function printHelp() {
|
|
822
|
+
const header = !noColor && !isCI
|
|
823
|
+
? owlHeader()
|
|
824
|
+
: ` ${style("rmcode", c.bold, c.green)} ${style(`v${VERSION}`, c.dim)} — AI code review from your terminal`;
|
|
449
825
|
console.log(`
|
|
450
|
-
|
|
826
|
+
${header}
|
|
451
827
|
|
|
452
828
|
${style("COMMANDS", c.bold)}
|
|
453
829
|
|
|
@@ -458,6 +834,7 @@ function printHelp() {
|
|
|
458
834
|
${style("rmcode <file>", c.cyan)} Review a single local file
|
|
459
835
|
${style("rmcode login", c.cyan)} Set up your API key
|
|
460
836
|
${style("rmcode install", c.cyan)} Install the GitHub App for automatic PR reviews
|
|
837
|
+
${style("rmcode update", c.cyan)} Update rmcode to the latest version
|
|
461
838
|
${style("rmcode help", c.cyan)} Show this help
|
|
462
839
|
|
|
463
840
|
${style("OPTIONS", c.bold)}
|
|
@@ -513,18 +890,120 @@ function printHelp() {
|
|
|
513
890
|
${style("https://review-my-code.com", c.dim)}
|
|
514
891
|
`);
|
|
515
892
|
}
|
|
516
|
-
// ──
|
|
517
|
-
|
|
893
|
+
// ── Self-update ───────────────────────────────────────────────────────
|
|
894
|
+
const PACKAGE_NAME = "@review-my-code/rmcode";
|
|
895
|
+
const REGISTRY_LATEST_URL = "https://registry.npmjs.org/@review-my-code%2Frmcode/latest";
|
|
896
|
+
/** Resolved real path of the running entry script, for install detection. */
|
|
897
|
+
function entryScriptPath() {
|
|
898
|
+
const binPath = process.argv[1] || "";
|
|
518
899
|
try {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
900
|
+
return (0, fs_1.realpathSync)(binPath);
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
return binPath;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function isNpxInvocation(binPath) {
|
|
907
|
+
// npm's exec cache lives under .../_npx/<hash>/; older layouts use /npx/.
|
|
908
|
+
return /[\\/]_?npx[\\/]/.test(binPath);
|
|
909
|
+
}
|
|
910
|
+
function updateCommandFor(binPath) {
|
|
911
|
+
const ext = process.platform === "win32" ? ".cmd" : "";
|
|
912
|
+
if (/[\\/]pnpm[\\/]/.test(binPath))
|
|
913
|
+
return { cmd: `pnpm${ext}`, args: ["add", "-g", `${PACKAGE_NAME}@latest`] };
|
|
914
|
+
if (/[\\/]bun[\\/]/.test(binPath))
|
|
915
|
+
return { cmd: "bun", args: ["add", "-g", `${PACKAGE_NAME}@latest`] };
|
|
916
|
+
return {
|
|
917
|
+
cmd: `npm${ext}`,
|
|
918
|
+
args: ["install", "-g", `${PACKAGE_NAME}@latest`],
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
async function runUpdate() {
|
|
922
|
+
const binPath = entryScriptPath();
|
|
923
|
+
if (isNpxInvocation(binPath)) {
|
|
924
|
+
console.log(`\n ${style("Running via npx — already using the latest published version each run.", c.dim)}\n`);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
const { cmd, args } = updateCommandFor(binPath);
|
|
928
|
+
console.log(`\n ${style(`Updating ${PACKAGE_NAME} via ${cmd}...`, c.bold)}\n`);
|
|
929
|
+
const result = (0, child_process_1.spawnSync)(cmd, args, { stdio: "inherit" });
|
|
930
|
+
if (result.status === 0) {
|
|
931
|
+
const latest = await fetchLatestVersion().catch(() => null);
|
|
932
|
+
console.log(`\n ${style("✓", c.green)} ${latest
|
|
933
|
+
? `Updated to v${latest}.`
|
|
934
|
+
: "Updated. Run rmcode --version to confirm."}\n`);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
console.error(style(`\n Update failed. Run it manually:`, c.red));
|
|
938
|
+
console.error(` ${style([cmd, ...args].join(" "), c.cyan, c.bold)}\n`);
|
|
939
|
+
process.exit(1);
|
|
940
|
+
}
|
|
941
|
+
// ── Update nudge ──────────────────────────────────────────────────────
|
|
942
|
+
//
|
|
943
|
+
// After a successful human-mode review, print at most one dim line when a
|
|
944
|
+
// newer version is published. The registry is queried at most once per
|
|
945
|
+
// 24h (cached in ~/.config/rmcode/update-check.json) and only after the
|
|
946
|
+
// review output has been printed. Any failure here is silently swallowed
|
|
947
|
+
// — the nudge must never affect review output or exit codes.
|
|
948
|
+
const UPDATE_CHECK_TIMEOUT_MS = 1_500;
|
|
949
|
+
const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
|
|
950
|
+
function updateCachePath() {
|
|
951
|
+
return (0, path_1.join)((0, os_1.homedir)(), ".config", "rmcode", "update-check.json");
|
|
952
|
+
}
|
|
953
|
+
async function fetchLatestVersion() {
|
|
954
|
+
const resp = await fetch(REGISTRY_LATEST_URL, {
|
|
955
|
+
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
|
|
956
|
+
});
|
|
957
|
+
if (!resp.ok)
|
|
958
|
+
return null;
|
|
959
|
+
const data = (await resp.json());
|
|
960
|
+
return typeof data.version === "string" ? data.version : null;
|
|
961
|
+
}
|
|
962
|
+
/** Latest published version, from the daily cache when fresh. */
|
|
963
|
+
async function latestPublishedVersion() {
|
|
964
|
+
const cachePath = updateCachePath();
|
|
965
|
+
try {
|
|
966
|
+
const cached = JSON.parse((0, fs_1.readFileSync)(cachePath, "utf-8"));
|
|
967
|
+
if (typeof cached.latest === "string" &&
|
|
968
|
+
typeof cached.checkedAt === "number" &&
|
|
969
|
+
Date.now() - cached.checkedAt < UPDATE_CHECK_TTL_MS) {
|
|
970
|
+
return cached.latest;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
catch {
|
|
974
|
+
// Missing or unreadable cache — fall through to a fresh fetch.
|
|
975
|
+
}
|
|
976
|
+
const latest = await fetchLatestVersion();
|
|
977
|
+
if (!latest)
|
|
978
|
+
return null;
|
|
979
|
+
try {
|
|
980
|
+
(0, fs_1.mkdirSync)((0, path_1.join)((0, os_1.homedir)(), ".config", "rmcode"), { recursive: true });
|
|
981
|
+
(0, fs_1.writeFileSync)(cachePath, JSON.stringify({ latest, checkedAt: Date.now() }));
|
|
982
|
+
}
|
|
983
|
+
catch {
|
|
984
|
+
// Cache writes are best-effort.
|
|
985
|
+
}
|
|
986
|
+
return latest;
|
|
987
|
+
}
|
|
988
|
+
/** Hand-rolled x.y.z compare: 1 if a > b, -1 if a < b, 0 if equal. */
|
|
989
|
+
function compareVersions(a, b) {
|
|
990
|
+
const pa = a.split(".").map((n) => parseInt(n, 10));
|
|
991
|
+
const pb = b.split(".").map((n) => parseInt(n, 10));
|
|
992
|
+
for (let i = 0; i < 3; i++) {
|
|
993
|
+
const da = pa[i] || 0;
|
|
994
|
+
const db = pb[i] || 0;
|
|
995
|
+
if (da !== db)
|
|
996
|
+
return da > db ? 1 : -1;
|
|
997
|
+
}
|
|
998
|
+
return 0;
|
|
999
|
+
}
|
|
1000
|
+
async function maybeShowUpdateNudge() {
|
|
1001
|
+
if (noColor || isCI)
|
|
1002
|
+
return;
|
|
1003
|
+
try {
|
|
1004
|
+
const latest = await latestPublishedVersion();
|
|
1005
|
+
if (latest && compareVersions(latest, VERSION) > 0) {
|
|
1006
|
+
console.log(style(` Update available ${VERSION} → ${latest} · run rmcode update`, c.dim));
|
|
528
1007
|
}
|
|
529
1008
|
}
|
|
530
1009
|
catch {
|
|
@@ -582,6 +1061,10 @@ async function main() {
|
|
|
582
1061
|
console.error(style(` Run: rmcode help\n`, c.dim));
|
|
583
1062
|
process.exit(1);
|
|
584
1063
|
}
|
|
1064
|
+
if (positional[0] === "update") {
|
|
1065
|
+
await runUpdate();
|
|
1066
|
+
return;
|
|
1067
|
+
}
|
|
585
1068
|
if (positional[0] === "install") {
|
|
586
1069
|
console.log(`\n ${style("Install the GitHub App for automatic PR reviews:", c.bold)}\n`);
|
|
587
1070
|
console.log(` ${style("https://github.com/apps/rmcode-ai", c.cyan, c.bold)}\n`);
|
|
@@ -617,8 +1100,10 @@ async function main() {
|
|
|
617
1100
|
console.error(style(` Run: rmcode help\n`, c.dim));
|
|
618
1101
|
process.exit(1);
|
|
619
1102
|
}
|
|
620
|
-
|
|
621
|
-
|
|
1103
|
+
// Brand header for review runs — stderr so any stdout consumers stay
|
|
1104
|
+
// safe; hidden in --json, non-TTY, NO_COLOR, and CI.
|
|
1105
|
+
if (!jsonMode && !noColor && !isCI) {
|
|
1106
|
+
process.stderr.write(`\n${owlHeader()}\n\n`);
|
|
622
1107
|
}
|
|
623
1108
|
// Piped stdin
|
|
624
1109
|
if (hasPipedStdin()) {
|
|
@@ -632,11 +1117,12 @@ async function main() {
|
|
|
632
1117
|
console.error(style(" No input received on stdin.", c.red));
|
|
633
1118
|
process.exit(1);
|
|
634
1119
|
}
|
|
635
|
-
const
|
|
1120
|
+
const startedAt = Date.now();
|
|
1121
|
+
const spinner = createSpinner(`Uploading review request (stdin, ${lang})`);
|
|
636
1122
|
try {
|
|
637
1123
|
const stdinType = looksLikeUnifiedDiff(code) ? "diff" : "snippet";
|
|
638
1124
|
const stdinFiles = stdinType === "diff" ? filesFromDiff(code) : [];
|
|
639
|
-
const result = await callAPI(code, lang, stdinType, stdinFiles);
|
|
1125
|
+
const result = await callAPI(code, lang, stdinType, stdinFiles, null, spinner);
|
|
640
1126
|
spinner.stop("Reviewed stdin");
|
|
641
1127
|
if (jsonMode) {
|
|
642
1128
|
printJsonResponse(result);
|
|
@@ -650,9 +1136,9 @@ async function main() {
|
|
|
650
1136
|
console.error(style(` ${result.error || "Review failed."}`, c.red));
|
|
651
1137
|
process.exit(1);
|
|
652
1138
|
}
|
|
653
|
-
printMeta(result.meta);
|
|
654
1139
|
if (result.data?.findings?.findings)
|
|
655
|
-
printFindings(result.data.findings.findings);
|
|
1140
|
+
printFindings(result.data.findings.findings, startedAt);
|
|
1141
|
+
printMeta(result.meta);
|
|
656
1142
|
}
|
|
657
1143
|
catch (err) {
|
|
658
1144
|
spinner.stop("Review failed");
|
|
@@ -677,7 +1163,7 @@ async function main() {
|
|
|
677
1163
|
}
|
|
678
1164
|
await runReview(code, [file], file, jsonMode, failOnFindings, "file", detectLanguage([file]));
|
|
679
1165
|
if (!jsonMode)
|
|
680
|
-
await
|
|
1166
|
+
await maybeShowUpdateNudge();
|
|
681
1167
|
return;
|
|
682
1168
|
}
|
|
683
1169
|
// Git modes
|
|
@@ -688,9 +1174,10 @@ async function main() {
|
|
|
688
1174
|
const branch = currentBranch();
|
|
689
1175
|
let diff;
|
|
690
1176
|
let files;
|
|
1177
|
+
let baseRef;
|
|
691
1178
|
let label;
|
|
692
1179
|
if (flags.has("--staged")) {
|
|
693
|
-
({ diff, files } = diffStaged());
|
|
1180
|
+
({ diff, files, baseRef } = diffStaged());
|
|
694
1181
|
label = "staged changes";
|
|
695
1182
|
if (!diff) {
|
|
696
1183
|
console.error(style(" No staged changes. Stage files with: git add <files>", c.yellow));
|
|
@@ -698,7 +1185,7 @@ async function main() {
|
|
|
698
1185
|
}
|
|
699
1186
|
}
|
|
700
1187
|
else if (flags.has("--unstaged")) {
|
|
701
|
-
({ diff, files } = diffUnstaged());
|
|
1188
|
+
({ diff, files, baseRef } = diffUnstaged());
|
|
702
1189
|
label = "unstaged changes";
|
|
703
1190
|
if (!diff) {
|
|
704
1191
|
console.error(style(" No unstaged changes.", c.yellow));
|
|
@@ -706,7 +1193,7 @@ async function main() {
|
|
|
706
1193
|
}
|
|
707
1194
|
}
|
|
708
1195
|
else if (flags.has("--all")) {
|
|
709
|
-
({ diff, files } = diffAll());
|
|
1196
|
+
({ diff, files, baseRef } = diffAll());
|
|
710
1197
|
label = "all uncommitted changes";
|
|
711
1198
|
if (!diff) {
|
|
712
1199
|
console.error(style(" No uncommitted changes.", c.yellow));
|
|
@@ -714,21 +1201,21 @@ async function main() {
|
|
|
714
1201
|
}
|
|
715
1202
|
}
|
|
716
1203
|
else {
|
|
717
|
-
({ diff, files } = diffFromMergeBase());
|
|
1204
|
+
({ diff, files, baseRef } = diffFromMergeBase());
|
|
718
1205
|
label = `branch ${style(branch, c.cyan)} vs base`;
|
|
719
1206
|
if (!diff) {
|
|
720
1207
|
console.error(style(" No changes from merge base. Your branch matches main.", c.yellow));
|
|
721
1208
|
process.exit(0);
|
|
722
1209
|
}
|
|
723
1210
|
}
|
|
724
|
-
await runReview(diff, files, label, jsonMode, failOnFindings);
|
|
1211
|
+
await runReview(diff, files, label, jsonMode, failOnFindings, "diff", undefined, baseRef);
|
|
725
1212
|
if (!jsonMode && !API_KEY) {
|
|
726
1213
|
console.log(style(` Get ${FREE_PLAN_CREDITS_PER_MONTH} credits/month free: rmcode login`, c.dim));
|
|
727
1214
|
console.log(style(" Auto-review every PR: rmcode install", c.dim));
|
|
728
1215
|
console.log();
|
|
729
1216
|
}
|
|
730
1217
|
if (!jsonMode)
|
|
731
|
-
await
|
|
1218
|
+
await maybeShowUpdateNudge();
|
|
732
1219
|
}
|
|
733
1220
|
main()
|
|
734
1221
|
.then(() => {
|
package/package.json
CHANGED