@review-my-code/rmcode 0.1.0-alpha.1 → 0.1.2
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 +29 -0
- package/dist/cli.js +887 -254
- package/package.json +8 -3
package/dist/cli.js
CHANGED
|
@@ -2,11 +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
|
-
const
|
|
11
|
+
const APP_URL = process.env.RMC_APP_URL || "https://review-my-code.com";
|
|
12
|
+
const VERSION = "0.1.2";
|
|
13
|
+
const FREE_PLAN_CREDITS_PER_MONTH = 30;
|
|
14
|
+
const REQUEST_TIMEOUT_MS = 290_000;
|
|
15
|
+
const POLL_TIMEOUT_MS = 10 * 60_000;
|
|
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;
|
|
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";
|
|
10
32
|
// ── Colors (no dependencies) ──────────────────────────────────────────
|
|
11
33
|
const c = {
|
|
12
34
|
reset: "\x1b[0m",
|
|
@@ -15,15 +37,12 @@ const c = {
|
|
|
15
37
|
red: "\x1b[31m",
|
|
16
38
|
green: "\x1b[32m",
|
|
17
39
|
yellow: "\x1b[33m",
|
|
18
|
-
blue: "\x1b[34m",
|
|
19
|
-
magenta: "\x1b[35m",
|
|
20
40
|
cyan: "\x1b[36m",
|
|
21
41
|
gray: "\x1b[90m",
|
|
22
42
|
white: "\x1b[97m",
|
|
23
43
|
bgRed: "\x1b[41m",
|
|
24
44
|
bgYellow: "\x1b[43m",
|
|
25
45
|
bgGreen: "\x1b[42m",
|
|
26
|
-
bgBlue: "\x1b[44m",
|
|
27
46
|
};
|
|
28
47
|
const isCI = process.env.CI === "true";
|
|
29
48
|
const noColor = process.env.NO_COLOR === "1" || !process.stdout.isTTY;
|
|
@@ -32,6 +51,19 @@ function style(text, ...codes) {
|
|
|
32
51
|
return text;
|
|
33
52
|
return codes.join("") + text + c.reset;
|
|
34
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
|
+
}
|
|
35
67
|
// ── Severity styling ──────────────────────────────────────────────────
|
|
36
68
|
function severityBadge(severity) {
|
|
37
69
|
const s = severity.toLowerCase();
|
|
@@ -44,118 +76,526 @@ function severityBadge(severity) {
|
|
|
44
76
|
return style(` ${severity.toUpperCase()} `, c.dim);
|
|
45
77
|
}
|
|
46
78
|
// ── Git helpers ───────────────────────────────────────────────────────
|
|
47
|
-
function
|
|
79
|
+
function git(args) {
|
|
80
|
+
return (0, child_process_1.execFileSync)("git", args, {
|
|
81
|
+
encoding: "utf-8",
|
|
82
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
83
|
+
}).trim();
|
|
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
|
+
}
|
|
92
|
+
function isGitRepo() {
|
|
48
93
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return diff.trim() || null;
|
|
94
|
+
git(["rev-parse", "--is-inside-work-tree"]);
|
|
95
|
+
return true;
|
|
52
96
|
}
|
|
53
97
|
catch {
|
|
54
|
-
return
|
|
98
|
+
return false;
|
|
55
99
|
}
|
|
56
100
|
}
|
|
57
|
-
function
|
|
101
|
+
function gitWorktreeRoot() {
|
|
102
|
+
return git(["rev-parse", "--show-toplevel"]);
|
|
103
|
+
}
|
|
104
|
+
function mergeBase() {
|
|
105
|
+
const refs = [];
|
|
58
106
|
try {
|
|
59
|
-
|
|
107
|
+
refs.push(git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]));
|
|
60
108
|
}
|
|
61
109
|
catch {
|
|
62
|
-
|
|
110
|
+
// Remote HEAD is not available.
|
|
63
111
|
}
|
|
112
|
+
for (const ref of [
|
|
113
|
+
...refs,
|
|
114
|
+
"origin/main",
|
|
115
|
+
"origin/master",
|
|
116
|
+
"main",
|
|
117
|
+
"master",
|
|
118
|
+
]) {
|
|
119
|
+
try {
|
|
120
|
+
git(["rev-parse", "--verify", ref]);
|
|
121
|
+
return git(["merge-base", ref, "HEAD"]);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// Try the next common default branch ref.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
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.");
|
|
64
128
|
}
|
|
65
|
-
function
|
|
129
|
+
function headCommit() {
|
|
66
130
|
try {
|
|
67
|
-
return (
|
|
131
|
+
return git(["rev-parse", "--verify", "HEAD"]);
|
|
68
132
|
}
|
|
69
133
|
catch {
|
|
70
|
-
return
|
|
134
|
+
return undefined;
|
|
71
135
|
}
|
|
72
136
|
}
|
|
73
|
-
function
|
|
137
|
+
function diffFromMergeBase() {
|
|
138
|
+
const base = mergeBase();
|
|
139
|
+
const diff = git(["diff", `${base}...HEAD`]);
|
|
140
|
+
const files = git(["diff", `${base}...HEAD`, "--name-only"])
|
|
141
|
+
.split("\n")
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
return { diff, files, baseRef: base };
|
|
144
|
+
}
|
|
145
|
+
function diffStaged() {
|
|
146
|
+
const diff = git(["diff", "--cached"]);
|
|
147
|
+
const files = git(["diff", "--cached", "--name-only"])
|
|
148
|
+
.split("\n")
|
|
149
|
+
.filter(Boolean);
|
|
150
|
+
return { diff, files, baseRef: headCommit() };
|
|
151
|
+
}
|
|
152
|
+
function diffUnstaged() {
|
|
153
|
+
const diff = git(["diff"]);
|
|
154
|
+
const files = git(["diff", "--name-only"]).split("\n").filter(Boolean);
|
|
155
|
+
return { diff, files, baseRef: headCommit() };
|
|
156
|
+
}
|
|
157
|
+
function diffAll() {
|
|
158
|
+
const root = gitWorktreeRoot();
|
|
159
|
+
const trackedDiff = git(["diff", "HEAD"]);
|
|
160
|
+
const trackedFiles = git(["diff", "HEAD", "--name-only"])
|
|
161
|
+
.split("\n")
|
|
162
|
+
.filter(Boolean);
|
|
163
|
+
const untrackedFiles = git(["ls-files", "--others", "--exclude-standard"])
|
|
164
|
+
.split("\n")
|
|
165
|
+
.filter(Boolean);
|
|
166
|
+
const untrackedDiffs = untrackedFiles
|
|
167
|
+
.map((file) => pseudoDiffForNewFile(file, root))
|
|
168
|
+
.filter(Boolean);
|
|
169
|
+
const diff = [trackedDiff, ...untrackedDiffs].filter(Boolean).join("\n\n");
|
|
170
|
+
const files = [...trackedFiles, ...untrackedFiles];
|
|
171
|
+
return { diff, files, baseRef: headCommit() };
|
|
172
|
+
}
|
|
173
|
+
function pseudoDiffForNewFile(file, worktreeRoot) {
|
|
74
174
|
try {
|
|
75
|
-
(0,
|
|
76
|
-
|
|
175
|
+
if ((0, path_1.isAbsolute)(file))
|
|
176
|
+
return "";
|
|
177
|
+
const stat = (0, fs_1.lstatSync)(file);
|
|
178
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
179
|
+
return "";
|
|
180
|
+
if (stat.size > MAX_UNTRACKED_FILE_BYTES)
|
|
181
|
+
return "";
|
|
182
|
+
const realRoot = (0, fs_1.realpathSync)(worktreeRoot);
|
|
183
|
+
const realFile = (0, fs_1.realpathSync)(file);
|
|
184
|
+
const rel = (0, path_1.relative)(realRoot, realFile);
|
|
185
|
+
if (rel.startsWith("..") || (0, path_1.isAbsolute)(rel))
|
|
186
|
+
return "";
|
|
187
|
+
const contents = (0, fs_1.readFileSync)(realFile, "utf-8");
|
|
188
|
+
if (contents.includes("\0"))
|
|
189
|
+
return "";
|
|
190
|
+
const added = contents
|
|
191
|
+
.split("\n")
|
|
192
|
+
.map((line) => `+${line}`)
|
|
193
|
+
.join("\n");
|
|
194
|
+
return `diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${contents.split("\n").length} @@\n${added}`;
|
|
77
195
|
}
|
|
78
196
|
catch {
|
|
79
|
-
return
|
|
197
|
+
return "";
|
|
80
198
|
}
|
|
81
199
|
}
|
|
82
200
|
function gitRepoName() {
|
|
83
201
|
try {
|
|
84
|
-
const remote = (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}).trim();
|
|
88
|
-
const httpsMatch = remote.match(/github\.com\/([^/]+\/[^/.]+)/);
|
|
89
|
-
if (httpsMatch)
|
|
90
|
-
return httpsMatch[1];
|
|
91
|
-
const sshMatch = remote.match(/github\.com:([^/]+\/[^/.]+)/);
|
|
92
|
-
if (sshMatch)
|
|
93
|
-
return sshMatch[1];
|
|
94
|
-
return null;
|
|
202
|
+
const remote = git(["remote", "get-url", "origin"]);
|
|
203
|
+
const m = remote.match(/github\.com[:/]([^/]+\/[^/.]+)/);
|
|
204
|
+
return m ? m[1] : null;
|
|
95
205
|
}
|
|
96
206
|
catch {
|
|
97
207
|
return null;
|
|
98
208
|
}
|
|
99
209
|
}
|
|
210
|
+
function currentBranch() {
|
|
211
|
+
try {
|
|
212
|
+
return git(["branch", "--show-current"]);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return "unknown";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function filesFromDiff(diff) {
|
|
219
|
+
const files = new Set();
|
|
220
|
+
const re = /^diff --git a\/.+ b\/(.+)$/gm;
|
|
221
|
+
let match;
|
|
222
|
+
while ((match = re.exec(diff)) !== null) {
|
|
223
|
+
files.add(match[1]);
|
|
224
|
+
}
|
|
225
|
+
return [...files];
|
|
226
|
+
}
|
|
227
|
+
function looksLikeUnifiedDiff(value) {
|
|
228
|
+
return value.startsWith("diff --git ") || /\n@@ -\d/.test(value);
|
|
229
|
+
}
|
|
230
|
+
function hasPipedStdin() {
|
|
231
|
+
if (process.stdin.isTTY)
|
|
232
|
+
return false;
|
|
233
|
+
try {
|
|
234
|
+
const stat = (0, fs_1.fstatSync)(0);
|
|
235
|
+
return stat.isFIFO() || stat.isFile() || stat.isSocket();
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
100
241
|
const API_KEY = process.env.RMC_API_KEY || null;
|
|
101
|
-
async function
|
|
242
|
+
async function fetchReviewJson(url, init) {
|
|
243
|
+
const controller = new AbortController();
|
|
244
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
245
|
+
try {
|
|
246
|
+
const resp = await fetch(url, { ...init, signal: controller.signal });
|
|
247
|
+
const text = await resp.text();
|
|
248
|
+
let body;
|
|
249
|
+
try {
|
|
250
|
+
body = text ? JSON.parse(text) : { success: false };
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
body = {
|
|
254
|
+
success: false,
|
|
255
|
+
error: `RMCode API returned a non-JSON response (${resp.status}).`,
|
|
256
|
+
code: "RMC-5000",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (!resp.ok && body.success !== false) {
|
|
260
|
+
body = {
|
|
261
|
+
...body,
|
|
262
|
+
success: false,
|
|
263
|
+
error: body.error || `RMCode API request failed (${resp.status}).`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
return { status: resp.status, body };
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
const message = error instanceof Error && error.name === "AbortError"
|
|
270
|
+
? "RMCode API request timed out."
|
|
271
|
+
: error instanceof Error
|
|
272
|
+
? error.message
|
|
273
|
+
: "RMCode API request failed.";
|
|
274
|
+
return {
|
|
275
|
+
status: 0,
|
|
276
|
+
body: { success: false, error: message, code: "RMC-5000" },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
finally {
|
|
280
|
+
clearTimeout(timeout);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function callAuthenticatedAPI(code, language, type, files, bundle, progress) {
|
|
102
284
|
const repo = gitRepoName();
|
|
103
|
-
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`, {
|
|
104
295
|
method: "POST",
|
|
105
296
|
headers: {
|
|
106
297
|
"Content-Type": "application/json",
|
|
107
298
|
"X-API-Key": API_KEY,
|
|
108
299
|
},
|
|
109
|
-
body: JSON.stringify(
|
|
300
|
+
body: JSON.stringify(payload),
|
|
110
301
|
});
|
|
111
|
-
|
|
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
|
+
}
|
|
320
|
+
if (status === 202 && body.data?.accessToken) {
|
|
321
|
+
progress?.update("Reviewing (queued)");
|
|
322
|
+
return pollForResults(`${API_URL}/api/reviews/cli/${body.data.accessToken}`, body.meta, { "X-API-Key": API_KEY }, progress);
|
|
323
|
+
}
|
|
324
|
+
return body;
|
|
112
325
|
}
|
|
113
|
-
async function callAnonymousAPI(code, language) {
|
|
326
|
+
async function callAnonymousAPI(code, language, type, files, progress) {
|
|
114
327
|
const repo = gitRepoName();
|
|
115
|
-
const
|
|
328
|
+
const { status, body } = await fetchReviewJson(`${API_URL}/api/reviews/anonymous`, {
|
|
116
329
|
method: "POST",
|
|
117
330
|
headers: { "Content-Type": "application/json" },
|
|
118
|
-
body: JSON.stringify({ type
|
|
331
|
+
body: JSON.stringify({ type, value: code, language, files, repo }),
|
|
119
332
|
});
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return pollForResults(result.data.accessToken, result.meta);
|
|
333
|
+
if (status === 202 && body.data?.accessToken) {
|
|
334
|
+
progress?.update("Reviewing (queued)");
|
|
335
|
+
return pollForResults(`${API_URL}/api/reviews/anonymous/${body.data.accessToken}`, body.meta, undefined, progress);
|
|
124
336
|
}
|
|
125
|
-
return
|
|
337
|
+
return body;
|
|
126
338
|
}
|
|
127
|
-
async function pollForResults(
|
|
128
|
-
const deadline = Date.now() +
|
|
339
|
+
async function pollForResults(url, initialMeta, headers, progress) {
|
|
340
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
341
|
+
let interval = POLL_INITIAL_INTERVAL_MS;
|
|
342
|
+
let reviewing = false;
|
|
129
343
|
while (Date.now() < deadline) {
|
|
130
|
-
await new Promise((r) => setTimeout(r,
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
344
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
345
|
+
interval = Math.min(interval * POLL_BACKOFF_FACTOR, POLL_MAX_INTERVAL_MS);
|
|
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
|
+
}
|
|
351
|
+
if (status >= 400 || body.success === false) {
|
|
352
|
+
if (initialMeta && !body.meta)
|
|
353
|
+
body.meta = initialMeta;
|
|
354
|
+
return body;
|
|
355
|
+
}
|
|
356
|
+
if (body.data?.status === "completed" || body.data?.status === "failed") {
|
|
357
|
+
if (initialMeta && !body.meta)
|
|
358
|
+
body.meta = initialMeta;
|
|
359
|
+
return body;
|
|
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
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { success: false, error: "Review timed out after 10 minutes." };
|
|
370
|
+
}
|
|
371
|
+
function callAPI(code, language, type = "snippet", files = [], bundle, progress) {
|
|
372
|
+
if (API_KEY)
|
|
373
|
+
return callAuthenticatedAPI(code, language, type, files, bundle, progress);
|
|
374
|
+
return Promise.resolve({
|
|
375
|
+
success: false,
|
|
376
|
+
error: "API key required. Run `rmcode login`, then set RMC_API_KEY before sending code for review.",
|
|
377
|
+
code: "RMC-1002",
|
|
378
|
+
meta: {
|
|
379
|
+
plan: null,
|
|
380
|
+
reviewer: "scout",
|
|
381
|
+
message: "Anonymous CLI reviews are disabled so your credits stay tied to your RMCode account.",
|
|
382
|
+
},
|
|
383
|
+
});
|
|
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}).`);
|
|
137
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);
|
|
138
439
|
}
|
|
139
|
-
|
|
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;
|
|
140
463
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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;
|
|
144
519
|
}
|
|
145
|
-
|
|
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;
|
|
146
564
|
}
|
|
147
565
|
// ── Language detection ────────────────────────────────────────────────
|
|
148
566
|
const EXT_TO_LANG = {
|
|
149
|
-
".ts": "typescript",
|
|
150
|
-
".
|
|
151
|
-
".
|
|
152
|
-
".
|
|
153
|
-
".
|
|
154
|
-
".
|
|
155
|
-
".
|
|
156
|
-
".
|
|
157
|
-
".
|
|
158
|
-
".
|
|
567
|
+
".ts": "typescript",
|
|
568
|
+
".tsx": "typescript",
|
|
569
|
+
".js": "javascript",
|
|
570
|
+
".jsx": "javascript",
|
|
571
|
+
".py": "python",
|
|
572
|
+
".java": "java",
|
|
573
|
+
".go": "go",
|
|
574
|
+
".rs": "rust",
|
|
575
|
+
".c": "c",
|
|
576
|
+
".cpp": "cpp",
|
|
577
|
+
".h": "c",
|
|
578
|
+
".cs": "csharp",
|
|
579
|
+
".rb": "ruby",
|
|
580
|
+
".php": "php",
|
|
581
|
+
".swift": "swift",
|
|
582
|
+
".kt": "kotlin",
|
|
583
|
+
".scala": "scala",
|
|
584
|
+
".lua": "lua",
|
|
585
|
+
".dart": "dart",
|
|
586
|
+
".v": "verilog",
|
|
587
|
+
".sv": "verilog",
|
|
588
|
+
".vhd": "vhdl",
|
|
589
|
+
".sh": "bash",
|
|
590
|
+
".zig": "zig",
|
|
591
|
+
".sol": "solidity",
|
|
592
|
+
".jl": "julia",
|
|
593
|
+
".r": "r",
|
|
594
|
+
".ex": "elixir",
|
|
595
|
+
".erl": "erlang",
|
|
596
|
+
".hs": "haskell",
|
|
597
|
+
".ml": "ocaml",
|
|
598
|
+
".fs": "fsharp",
|
|
159
599
|
};
|
|
160
600
|
function detectLanguage(files) {
|
|
161
601
|
for (const f of files) {
|
|
@@ -166,35 +606,100 @@ function detectLanguage(files) {
|
|
|
166
606
|
return "javascript";
|
|
167
607
|
}
|
|
168
608
|
// ── Output formatting ─────────────────────────────────────────────────
|
|
169
|
-
function
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return;
|
|
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);
|
|
173
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) {
|
|
174
654
|
if (findings.length === 0) {
|
|
175
655
|
console.log(`\n ${style("No issues found.", c.green, c.bold)} Your code looks good.\n`);
|
|
656
|
+
console.log(`${verdictFooter(0, startedAt)}\n`);
|
|
176
657
|
return;
|
|
177
658
|
}
|
|
178
659
|
const counts = {};
|
|
179
|
-
for (const f of findings)
|
|
660
|
+
for (const f of findings)
|
|
180
661
|
counts[f.severity] = (counts[f.severity] || 0) + 1;
|
|
181
|
-
|
|
182
|
-
|
|
662
|
+
const summary = Object.entries(counts)
|
|
663
|
+
.map(([k, v]) => `${v} ${k}`)
|
|
664
|
+
.join(" · ");
|
|
183
665
|
console.log(`\n ${style(`${findings.length} finding${findings.length !== 1 ? "s" : ""}`, c.bold, c.white)} · ${summary}\n`);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
677
|
+
const location = f.filePath
|
|
678
|
+
? `${style(f.filePath, c.cyan)}${f.line ? `:${f.line}` : ""}`
|
|
679
|
+
: "";
|
|
680
|
+
console.log(` ${style(num, c.bold, c.white)} ${bar} ${severityBadge(f.severity)} ${style(f.title, c.bold, c.white)}`);
|
|
187
681
|
if (location)
|
|
188
|
-
console.log(` ${style("at", c.dim)} ${location}`);
|
|
189
|
-
|
|
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
|
+
}
|
|
190
686
|
if (f.codeContext) {
|
|
191
|
-
|
|
687
|
+
for (const line of f.codeContext.split("\n")) {
|
|
688
|
+
console.log(` ${pad} ${bar} ${style(line, c.dim)}`);
|
|
689
|
+
}
|
|
192
690
|
}
|
|
193
691
|
if (f.suggestedCode) {
|
|
194
|
-
|
|
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
|
+
}
|
|
195
697
|
}
|
|
196
|
-
|
|
197
|
-
|
|
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`);
|
|
198
703
|
}
|
|
199
704
|
function printMeta(meta) {
|
|
200
705
|
if (!meta)
|
|
@@ -203,108 +708,108 @@ function printMeta(meta) {
|
|
|
203
708
|
if (meta.reviewer)
|
|
204
709
|
parts.push(meta.reviewer.charAt(0).toUpperCase() + meta.reviewer.slice(1));
|
|
205
710
|
if (meta.plan) {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
else {
|
|
211
|
-
parts.push(planLabel);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (meta.quotaUsed) {
|
|
215
|
-
parts.push(`Free review (${meta.quotaUsed} today)`);
|
|
711
|
+
const label = meta.plan.charAt(0).toUpperCase() + meta.plan.slice(1);
|
|
712
|
+
parts.push(meta.creditsRemaining != null
|
|
713
|
+
? `${label} (${meta.creditsRemaining} cr left)`
|
|
714
|
+
: label);
|
|
216
715
|
}
|
|
217
|
-
if (parts.length > 0)
|
|
716
|
+
if (parts.length > 0)
|
|
218
717
|
console.log(` ${style(parts.join(" · "), c.dim)}`);
|
|
219
|
-
|
|
220
|
-
if (meta.message) {
|
|
718
|
+
if (meta.message)
|
|
221
719
|
console.log(` ${style(meta.message, c.yellow)}`);
|
|
222
|
-
}
|
|
223
720
|
}
|
|
224
|
-
|
|
721
|
+
function printJsonResponse(result) {
|
|
722
|
+
console.log(JSON.stringify(result, null, 2));
|
|
723
|
+
}
|
|
724
|
+
function hasBlockingFindings(result) {
|
|
725
|
+
const findings = result.data?.findings?.findings || [];
|
|
726
|
+
return (findings.length > 0 || result.data?.findings?.verdict === "request_changes");
|
|
727
|
+
}
|
|
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
|
+
}
|
|
225
736
|
function createSpinner(msg) {
|
|
226
737
|
if (noColor || isCI) {
|
|
738
|
+
// Plain fallback: print each phase transition once, no timer spam.
|
|
739
|
+
let lastMsg = msg;
|
|
227
740
|
process.stderr.write(` ${msg}...\n`);
|
|
228
|
-
return {
|
|
229
|
-
|
|
741
|
+
return {
|
|
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) {
|
|
752
|
+
if (final)
|
|
753
|
+
process.stderr.write(` ${final}\n`);
|
|
754
|
+
},
|
|
755
|
+
};
|
|
230
756
|
}
|
|
231
757
|
const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
758
|
+
const startedAt = Date.now();
|
|
759
|
+
let currentMsg = msg;
|
|
232
760
|
let i = 0;
|
|
233
761
|
const interval = setInterval(() => {
|
|
234
|
-
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`);
|
|
235
763
|
}, 80);
|
|
236
764
|
return {
|
|
765
|
+
update(nextMsg) {
|
|
766
|
+
currentMsg = nextMsg;
|
|
767
|
+
},
|
|
768
|
+
note(line) {
|
|
769
|
+
process.stderr.write(`\r\x1b[K${line}\n`);
|
|
770
|
+
},
|
|
237
771
|
stop(finalMsg) {
|
|
238
772
|
clearInterval(interval);
|
|
239
|
-
process.stderr.write(`\r ${style("✓", c.green)} ${finalMsg ||
|
|
773
|
+
process.stderr.write(`\r ${style("✓", c.green)} ${finalMsg || currentMsg}\x1b[K\n`);
|
|
240
774
|
},
|
|
241
775
|
};
|
|
242
776
|
}
|
|
243
|
-
// ──
|
|
244
|
-
async function
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const lang = detectLanguage(files);
|
|
260
|
-
const spinner = createSpinner(`Reviewing ${files.length} file${files.length !== 1 ? "s" : ""} (${lang})`);
|
|
777
|
+
// ── Review runner ─────────────────────────────────────────────────────
|
|
778
|
+
async function runReview(content, files, label, jsonMode, failOnFindings, type = "diff", language, baseRef) {
|
|
779
|
+
const startedAt = Date.now();
|
|
780
|
+
const lang = language || detectLanguage(files);
|
|
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);
|
|
261
793
|
try {
|
|
262
|
-
const result = await callAPI(
|
|
263
|
-
spinner.stop(`Reviewed ${
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
if (result.
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if (!jsonMode)
|
|
272
|
-
printMeta(result.meta);
|
|
273
|
-
if (result.data?.findings?.findings) {
|
|
274
|
-
printFindings(result.data.findings.findings, jsonMode);
|
|
794
|
+
const result = await callAPI(content, lang, type, files, bundle, spinner);
|
|
795
|
+
spinner.stop(`Reviewed ${label}`);
|
|
796
|
+
if (jsonMode) {
|
|
797
|
+
printJsonResponse(result);
|
|
798
|
+
if (!result.success)
|
|
799
|
+
process.exit(1);
|
|
800
|
+
if (failOnFindings && hasBlockingFindings(result))
|
|
801
|
+
process.exit(2);
|
|
802
|
+
return;
|
|
275
803
|
}
|
|
276
|
-
}
|
|
277
|
-
catch (err) {
|
|
278
|
-
spinner.stop("Review failed");
|
|
279
|
-
console.error(style(` ${err.message}`, c.red));
|
|
280
|
-
process.exit(1);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
async function reviewFile(filePath, jsonMode) {
|
|
284
|
-
const resolved = (0, path_1.resolve)(filePath);
|
|
285
|
-
if (!(0, fs_1.existsSync)(resolved)) {
|
|
286
|
-
console.error(style(` File not found: ${filePath}`, c.red));
|
|
287
|
-
process.exit(1);
|
|
288
|
-
}
|
|
289
|
-
const content = (0, fs_1.readFileSync)(resolved, "utf-8");
|
|
290
|
-
const ext = (0, path_1.extname)(filePath).toLowerCase();
|
|
291
|
-
const lang = EXT_TO_LANG[ext] || "javascript";
|
|
292
|
-
const spinner = createSpinner(`Reviewing ${(0, path_1.basename)(filePath)} (${lang})`);
|
|
293
|
-
try {
|
|
294
|
-
const result = await callAPI(content, lang, "file", [filePath]);
|
|
295
|
-
spinner.stop(`Reviewed ${(0, path_1.basename)(filePath)}`);
|
|
296
804
|
if (!result.success) {
|
|
297
805
|
console.error(style(` ${result.error || "Review failed."}`, c.red));
|
|
298
|
-
if (result.meta?.message)
|
|
806
|
+
if (result.meta?.message)
|
|
299
807
|
console.log(` ${style(result.meta.message, c.yellow)}`);
|
|
300
|
-
}
|
|
301
808
|
process.exit(1);
|
|
302
809
|
}
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
printFindings(result.data.findings.findings, jsonMode);
|
|
307
|
-
}
|
|
810
|
+
if (result.data?.findings?.findings)
|
|
811
|
+
printFindings(result.data.findings.findings, startedAt);
|
|
812
|
+
printMeta(result.meta);
|
|
308
813
|
}
|
|
309
814
|
catch (err) {
|
|
310
815
|
spinner.stop("Review failed");
|
|
@@ -312,81 +817,74 @@ async function reviewFile(filePath, jsonMode) {
|
|
|
312
817
|
process.exit(1);
|
|
313
818
|
}
|
|
314
819
|
}
|
|
315
|
-
|
|
316
|
-
const rl = (0, readline_1.createInterface)({ input: process.stdin });
|
|
317
|
-
const lines = [];
|
|
318
|
-
for await (const line of rl)
|
|
319
|
-
lines.push(line);
|
|
320
|
-
const code = lines.join("\n");
|
|
321
|
-
if (!code.trim()) {
|
|
322
|
-
console.error(style(" No input received on stdin.", c.red));
|
|
323
|
-
process.exit(1);
|
|
324
|
-
}
|
|
325
|
-
const spinner = createSpinner(`Reviewing stdin (${lang})`);
|
|
326
|
-
try {
|
|
327
|
-
const result = await callAPI(code, lang, "snippet", []);
|
|
328
|
-
spinner.stop("Reviewed stdin");
|
|
329
|
-
if (!result.success) {
|
|
330
|
-
console.error(style(` ${result.error || "Review failed."}`, c.red));
|
|
331
|
-
if (result.meta?.message) {
|
|
332
|
-
console.log(` ${style(result.meta.message, c.yellow)}`);
|
|
333
|
-
}
|
|
334
|
-
process.exit(1);
|
|
335
|
-
}
|
|
336
|
-
if (!jsonMode)
|
|
337
|
-
printMeta(result.meta);
|
|
338
|
-
if (result.data?.findings?.findings) {
|
|
339
|
-
printFindings(result.data.findings.findings, jsonMode);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
catch (err) {
|
|
343
|
-
spinner.stop("Review failed");
|
|
344
|
-
console.error(style(` ${err.message}`, c.red));
|
|
345
|
-
process.exit(1);
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
// ── Help & Version ────────────────────────────────────────────────────
|
|
820
|
+
// ── Help ──────────────────────────────────────────────────────────────
|
|
349
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`;
|
|
350
825
|
console.log(`
|
|
351
|
-
|
|
826
|
+
${header}
|
|
352
827
|
|
|
353
|
-
${style("
|
|
828
|
+
${style("COMMANDS", c.bold)}
|
|
354
829
|
|
|
355
|
-
${style("rmcode", c.cyan)} Review
|
|
356
|
-
${style("rmcode --staged", c.cyan)} Review staged changes
|
|
357
|
-
${style("rmcode
|
|
358
|
-
${style("
|
|
830
|
+
${style("rmcode", c.cyan)} Review changes from merge base (default)
|
|
831
|
+
${style("rmcode --staged", c.cyan)} Review staged changes only
|
|
832
|
+
${style("rmcode --unstaged", c.cyan)} Review unstaged changes only
|
|
833
|
+
${style("rmcode --all", c.cyan)} Review all uncommitted changes (staged + unstaged)
|
|
834
|
+
${style("rmcode <file>", c.cyan)} Review a single local file
|
|
359
835
|
${style("rmcode login", c.cyan)} Set up your API key
|
|
360
|
-
${style("rmcode install", c.cyan)} Install the GitHub App for PR reviews
|
|
836
|
+
${style("rmcode install", c.cyan)} Install the GitHub App for automatic PR reviews
|
|
837
|
+
${style("rmcode help", c.cyan)} Show this help
|
|
361
838
|
|
|
362
839
|
${style("OPTIONS", c.bold)}
|
|
363
840
|
|
|
364
|
-
--
|
|
365
|
-
--
|
|
366
|
-
--
|
|
367
|
-
--lang <language> Language hint for stdin input
|
|
841
|
+
--json Output the full API response as JSON (for CI/agents)
|
|
842
|
+
--fail-on-findings Exit 2 when JSON output contains findings
|
|
843
|
+
--lang <language> Language hint for stdin input
|
|
368
844
|
--help, -h Show this help
|
|
369
845
|
--version, -v Show version
|
|
370
846
|
|
|
847
|
+
${style("MODES", c.bold)}
|
|
848
|
+
|
|
849
|
+
${style("Default", c.white)} Diff from merge base to HEAD — what your PR would contain
|
|
850
|
+
${style("--staged", c.white)} Only changes in the staging area (git add)
|
|
851
|
+
${style("--unstaged", c.white)} Only working directory changes not yet staged
|
|
852
|
+
${style("--all", c.white)} Everything not yet committed, including untracked text files
|
|
853
|
+
${style("file", c.white)} A single local file
|
|
854
|
+
${style("stdin", c.white)} Pipe any diff or code: ${style("git diff main | rmcode", c.dim)}
|
|
855
|
+
|
|
371
856
|
${style("EXAMPLES", c.bold)}
|
|
372
857
|
|
|
373
|
-
${style("# Review your
|
|
858
|
+
${style("# Review your branch before opening a PR", c.dim)}
|
|
859
|
+
rmcode
|
|
860
|
+
|
|
861
|
+
${style("# Review only what you're about to commit", c.dim)}
|
|
374
862
|
rmcode --staged
|
|
375
863
|
|
|
376
|
-
${style("#
|
|
377
|
-
rmcode --
|
|
864
|
+
${style("# Review a specific range", c.dim)}
|
|
865
|
+
git diff abc123..def456 | rmcode --lang typescript
|
|
378
866
|
|
|
379
|
-
${style("# Review
|
|
380
|
-
rmcode src/auth
|
|
867
|
+
${style("# Review one file", c.dim)}
|
|
868
|
+
rmcode src/auth.ts
|
|
381
869
|
|
|
382
|
-
${style("#
|
|
383
|
-
|
|
870
|
+
${style("# JSON output for CI pipeline", c.dim)}
|
|
871
|
+
rmcode --json
|
|
384
872
|
|
|
385
|
-
${style(
|
|
873
|
+
${style(`# Set up authenticated reviews (${FREE_PLAN_CREDITS_PER_MONTH} credits/month free)`, c.dim)}
|
|
386
874
|
rmcode login
|
|
387
875
|
|
|
388
|
-
|
|
389
|
-
|
|
876
|
+
${style("ENVIRONMENT", c.bold)}
|
|
877
|
+
|
|
878
|
+
RMC_API_KEY API key for authenticated reviews (get one: rmcode login)
|
|
879
|
+
RMC_API_URL API endpoint (default: https://review-my-code.com)
|
|
880
|
+
RMC_APP_URL App URL for login (default: https://review-my-code.com)
|
|
881
|
+
|
|
882
|
+
${style("PRIVACY", c.bold)}
|
|
883
|
+
|
|
884
|
+
CLI reviews run locally to collect your diff/file/stdin content, then send that
|
|
885
|
+
review content to RMCode's backend and model providers for analysis. For
|
|
886
|
+
automatic PR reviews, install the GitHub App; that path uses the repository's
|
|
887
|
+
GitHub Actions runner to produce review context.
|
|
390
888
|
|
|
391
889
|
${style("https://review-my-code.com", c.dim)}
|
|
392
890
|
`);
|
|
@@ -394,28 +892,59 @@ function printHelp() {
|
|
|
394
892
|
// ── Version check ─────────────────────────────────────────────────────
|
|
395
893
|
async function checkForUpdate() {
|
|
396
894
|
try {
|
|
397
|
-
const resp = await fetch("https://registry.npmjs.org/rmcode/latest", {
|
|
895
|
+
const resp = await fetch("https://registry.npmjs.org/@review-my-code/rmcode/latest", {
|
|
398
896
|
signal: AbortSignal.timeout(3000),
|
|
399
897
|
});
|
|
400
898
|
if (!resp.ok)
|
|
401
899
|
return;
|
|
402
|
-
const data = await resp.json();
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
console.log(style(
|
|
406
|
-
console.log(style(` Run: npm install -g rmcode\n`, c.dim));
|
|
900
|
+
const data = (await resp.json());
|
|
901
|
+
if (data.version && data.version !== VERSION) {
|
|
902
|
+
console.log(style(`\n Update available: ${VERSION} → ${data.version}`, c.yellow));
|
|
903
|
+
console.log(style(` Run: npm install -g @review-my-code/rmcode\n`, c.dim));
|
|
407
904
|
}
|
|
408
905
|
}
|
|
409
906
|
catch {
|
|
410
|
-
|
|
907
|
+
/* silent */
|
|
411
908
|
}
|
|
412
909
|
}
|
|
910
|
+
function parseArgs(args) {
|
|
911
|
+
const flags = new Set();
|
|
912
|
+
const options = {};
|
|
913
|
+
const positional = [];
|
|
914
|
+
for (let i = 0; i < args.length; i++) {
|
|
915
|
+
const arg = args[i];
|
|
916
|
+
if (arg === "--lang") {
|
|
917
|
+
options.lang = args[i + 1];
|
|
918
|
+
i++;
|
|
919
|
+
}
|
|
920
|
+
else if (arg.startsWith("-")) {
|
|
921
|
+
flags.add(arg);
|
|
922
|
+
}
|
|
923
|
+
else {
|
|
924
|
+
positional.push(arg);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return { flags, options, positional };
|
|
928
|
+
}
|
|
929
|
+
function unknownFlags(flags) {
|
|
930
|
+
const known = new Set([
|
|
931
|
+
"--all",
|
|
932
|
+
"--fail-on-findings",
|
|
933
|
+
"--help",
|
|
934
|
+
"-h",
|
|
935
|
+
"--json",
|
|
936
|
+
"--staged",
|
|
937
|
+
"--unstaged",
|
|
938
|
+
"--version",
|
|
939
|
+
"-v",
|
|
940
|
+
]);
|
|
941
|
+
return [...flags].filter((flag) => !known.has(flag));
|
|
942
|
+
}
|
|
413
943
|
// ── Main ──────────────────────────────────────────────────────────────
|
|
414
944
|
async function main() {
|
|
415
945
|
const args = process.argv.slice(2);
|
|
416
|
-
const flags
|
|
417
|
-
|
|
418
|
-
if (flags.has("--help") || flags.has("-h")) {
|
|
946
|
+
const { flags, options, positional } = parseArgs(args);
|
|
947
|
+
if (flags.has("--help") || flags.has("-h") || positional[0] === "help") {
|
|
419
948
|
printHelp();
|
|
420
949
|
return;
|
|
421
950
|
}
|
|
@@ -423,65 +952,169 @@ async function main() {
|
|
|
423
952
|
console.log(`rmcode v${VERSION}`);
|
|
424
953
|
return;
|
|
425
954
|
}
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
955
|
+
const invalidFlags = unknownFlags(flags);
|
|
956
|
+
if (invalidFlags.length > 0) {
|
|
957
|
+
console.error(style(`\n Unknown option: ${invalidFlags[0]}`, c.red));
|
|
958
|
+
console.error(style(` Run: rmcode help\n`, c.dim));
|
|
959
|
+
process.exit(1);
|
|
960
|
+
}
|
|
429
961
|
if (positional[0] === "install") {
|
|
430
962
|
console.log(`\n ${style("Install the GitHub App for automatic PR reviews:", c.bold)}\n`);
|
|
431
963
|
console.log(` ${style("https://github.com/apps/rmcode-ai", c.cyan, c.bold)}\n`);
|
|
432
|
-
console.log(` ${style("
|
|
964
|
+
console.log(` ${style("Install from the same GitHub account linked in RMCode so PR reviews share your plan credits.", c.dim)}`);
|
|
965
|
+
console.log(` ${style("The app uses a GitHub Actions workflow in your repo for context extraction.", c.dim)}`);
|
|
966
|
+
console.log(` ${style("If that workflow cannot be created or dispatched, the PR check fails until setup is fixed.", c.dim)}\n`);
|
|
433
967
|
return;
|
|
434
968
|
}
|
|
435
|
-
// rmc login
|
|
436
969
|
if (positional[0] === "login") {
|
|
437
|
-
const url = `${
|
|
970
|
+
const url = `${APP_URL}/api-keys`;
|
|
438
971
|
console.log(`\n ${style("Opening your browser to create an API key...", c.bold)}\n`);
|
|
439
972
|
try {
|
|
440
973
|
const openCmd = process.platform === "darwin"
|
|
441
974
|
? "open"
|
|
442
975
|
: process.platform === "win32"
|
|
443
|
-
? "
|
|
976
|
+
? "cmd"
|
|
444
977
|
: "xdg-open";
|
|
445
|
-
|
|
978
|
+
const openArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
979
|
+
(0, child_process_1.execFileSync)(openCmd, openArgs, { stdio: "ignore" });
|
|
446
980
|
}
|
|
447
981
|
catch {
|
|
448
|
-
console.log(` ${style("Could not open browser. Visit
|
|
982
|
+
console.log(` ${style("Could not open browser. Visit:", c.yellow)}`);
|
|
449
983
|
}
|
|
450
984
|
console.log(` ${style(url, c.cyan, c.bold)}\n`);
|
|
451
985
|
console.log(` ${style("Then set your API key:", c.dim)}`);
|
|
452
|
-
console.log(` ${style("export RMC_API_KEY=
|
|
986
|
+
console.log(` ${style("export RMC_API_KEY=rmc_...", c.green)}\n`);
|
|
453
987
|
return;
|
|
454
988
|
}
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
console.
|
|
459
|
-
|
|
989
|
+
const jsonMode = flags.has("--json");
|
|
990
|
+
const failOnFindings = flags.has("--fail-on-findings");
|
|
991
|
+
if (positional.length > 1) {
|
|
992
|
+
console.error(style(`\n Unknown command: ${positional[0]}`, c.red));
|
|
993
|
+
console.error(style(` Run: rmcode help\n`, c.dim));
|
|
994
|
+
process.exit(1);
|
|
460
995
|
}
|
|
461
|
-
|
|
462
|
-
//
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
return;
|
|
996
|
+
// Brand header for review runs — stderr so any stdout consumers stay
|
|
997
|
+
// safe; hidden in --json, non-TTY, NO_COLOR, and CI.
|
|
998
|
+
if (!jsonMode && !noColor && !isCI) {
|
|
999
|
+
process.stderr.write(`\n${owlHeader()}\n\n`);
|
|
466
1000
|
}
|
|
467
1001
|
// Piped stdin
|
|
468
|
-
if (
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
|
|
1002
|
+
if (hasPipedStdin()) {
|
|
1003
|
+
const lang = options.lang || "javascript";
|
|
1004
|
+
const rl = (0, readline_1.createInterface)({ input: process.stdin });
|
|
1005
|
+
const lines = [];
|
|
1006
|
+
for await (const line of rl)
|
|
1007
|
+
lines.push(line);
|
|
1008
|
+
const code = lines.join("\n");
|
|
1009
|
+
if (!code.trim()) {
|
|
1010
|
+
console.error(style(" No input received on stdin.", c.red));
|
|
1011
|
+
process.exit(1);
|
|
1012
|
+
}
|
|
1013
|
+
const startedAt = Date.now();
|
|
1014
|
+
const spinner = createSpinner(`Uploading review request (stdin, ${lang})`);
|
|
1015
|
+
try {
|
|
1016
|
+
const stdinType = looksLikeUnifiedDiff(code) ? "diff" : "snippet";
|
|
1017
|
+
const stdinFiles = stdinType === "diff" ? filesFromDiff(code) : [];
|
|
1018
|
+
const result = await callAPI(code, lang, stdinType, stdinFiles, null, spinner);
|
|
1019
|
+
spinner.stop("Reviewed stdin");
|
|
1020
|
+
if (jsonMode) {
|
|
1021
|
+
printJsonResponse(result);
|
|
1022
|
+
if (!result.success)
|
|
1023
|
+
process.exit(1);
|
|
1024
|
+
if (failOnFindings && hasBlockingFindings(result))
|
|
1025
|
+
process.exit(2);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (!result.success) {
|
|
1029
|
+
console.error(style(` ${result.error || "Review failed."}`, c.red));
|
|
1030
|
+
process.exit(1);
|
|
1031
|
+
}
|
|
1032
|
+
if (result.data?.findings?.findings)
|
|
1033
|
+
printFindings(result.data.findings.findings, startedAt);
|
|
1034
|
+
printMeta(result.meta);
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
spinner.stop("Review failed");
|
|
1038
|
+
console.error(style(` ${err.message}`, c.red));
|
|
1039
|
+
process.exit(1);
|
|
1040
|
+
}
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if (positional.length === 1) {
|
|
1044
|
+
const file = positional[0];
|
|
1045
|
+
let code;
|
|
1046
|
+
try {
|
|
1047
|
+
code = (0, fs_1.readFileSync)(file, "utf-8");
|
|
1048
|
+
}
|
|
1049
|
+
catch (err) {
|
|
1050
|
+
console.error(style(` Could not read ${file}: ${err.message}`, c.red));
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
if (!code.trim()) {
|
|
1054
|
+
console.error(style(` ${file} is empty.`, c.yellow));
|
|
1055
|
+
process.exit(0);
|
|
1056
|
+
}
|
|
1057
|
+
await runReview(code, [file], file, jsonMode, failOnFindings, "file", detectLanguage([file]));
|
|
1058
|
+
if (!jsonMode)
|
|
1059
|
+
await checkForUpdate();
|
|
472
1060
|
return;
|
|
473
1061
|
}
|
|
474
|
-
//
|
|
475
|
-
|
|
1062
|
+
// Git modes
|
|
1063
|
+
if (!isGitRepo()) {
|
|
1064
|
+
console.error(style(" Not a git repository. Run from inside a git repo.", c.red));
|
|
1065
|
+
process.exit(1);
|
|
1066
|
+
}
|
|
1067
|
+
const branch = currentBranch();
|
|
1068
|
+
let diff;
|
|
1069
|
+
let files;
|
|
1070
|
+
let baseRef;
|
|
1071
|
+
let label;
|
|
1072
|
+
if (flags.has("--staged")) {
|
|
1073
|
+
({ diff, files, baseRef } = diffStaged());
|
|
1074
|
+
label = "staged changes";
|
|
1075
|
+
if (!diff) {
|
|
1076
|
+
console.error(style(" No staged changes. Stage files with: git add <files>", c.yellow));
|
|
1077
|
+
process.exit(0);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
else if (flags.has("--unstaged")) {
|
|
1081
|
+
({ diff, files, baseRef } = diffUnstaged());
|
|
1082
|
+
label = "unstaged changes";
|
|
1083
|
+
if (!diff) {
|
|
1084
|
+
console.error(style(" No unstaged changes.", c.yellow));
|
|
1085
|
+
process.exit(0);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
else if (flags.has("--all")) {
|
|
1089
|
+
({ diff, files, baseRef } = diffAll());
|
|
1090
|
+
label = "all uncommitted changes";
|
|
1091
|
+
if (!diff) {
|
|
1092
|
+
console.error(style(" No uncommitted changes.", c.yellow));
|
|
1093
|
+
process.exit(0);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
else {
|
|
1097
|
+
({ diff, files, baseRef } = diffFromMergeBase());
|
|
1098
|
+
label = `branch ${style(branch, c.cyan)} vs base`;
|
|
1099
|
+
if (!diff) {
|
|
1100
|
+
console.error(style(" No changes from merge base. Your branch matches main.", c.yellow));
|
|
1101
|
+
process.exit(0);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
await runReview(diff, files, label, jsonMode, failOnFindings, "diff", undefined, baseRef);
|
|
476
1105
|
if (!jsonMode && !API_KEY) {
|
|
477
|
-
console.log(style(
|
|
1106
|
+
console.log(style(` Get ${FREE_PLAN_CREDITS_PER_MONTH} credits/month free: rmcode login`, c.dim));
|
|
478
1107
|
console.log(style(" Auto-review every PR: rmcode install", c.dim));
|
|
479
1108
|
console.log();
|
|
480
1109
|
}
|
|
481
|
-
|
|
482
|
-
|
|
1110
|
+
if (!jsonMode)
|
|
1111
|
+
await checkForUpdate();
|
|
483
1112
|
}
|
|
484
|
-
main()
|
|
1113
|
+
main()
|
|
1114
|
+
.then(() => {
|
|
1115
|
+
process.exit(process.exitCode ?? 0);
|
|
1116
|
+
})
|
|
1117
|
+
.catch((err) => {
|
|
485
1118
|
console.error(style(` Fatal: ${err.message}`, c.red));
|
|
486
1119
|
process.exit(1);
|
|
487
1120
|
});
|