codereviewr 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,140 @@
1
+ # codereview
2
+
3
+ A local code review tool for humans reviewing AI-generated changes. Point it at a git repo (or a directory of repos), browse diffs in your browser, leave inline comments, and generate structured markdown feedback you can paste back into your agent session.
4
+
5
+ ## Install
6
+
7
+ Requires [Bun](https://bun.sh) to build, runs on Node.js or Bun.
8
+
9
+ ```bash
10
+ # Clone and install globally
11
+ git clone https://github.com/jhaynie/codereview.git
12
+ cd codereview
13
+ bun install
14
+ bun run build
15
+ npm link
16
+ ```
17
+
18
+ Now `codereview` is available globally.
19
+
20
+ ## Usage
21
+
22
+ ```bash
23
+ # Review uncommitted changes in the current directory
24
+ codereview
25
+
26
+ # Review a specific repo
27
+ codereview /path/to/repo
28
+
29
+ # Review all changes on your branch vs main
30
+ codereview --base main
31
+
32
+ # Review a multi-repo worktree
33
+ codereview /path/to/worktree
34
+ ```
35
+
36
+ The tool opens a browser UI and exits automatically when you close the tab.
37
+
38
+ ### Options
39
+
40
+ | Flag | Description |
41
+ | ------------------- | --------------------------------------------------------------------------------------------------------------- |
42
+ | `-b, --base <ref>` | Diff against a base branch (e.g., `main`). Shows all committed + uncommitted changes since the branch diverged. |
43
+ | `-p, --port <port>` | Run the UI on a specific port. Default: random available port. |
44
+ | `-h, --help` | Show help. |
45
+
46
+ ### Multi-Repo Mode
47
+
48
+ If the target directory isn't a git repo but contains subdirectories that are, codereview automatically enters multi-repo mode. Files are grouped by repo in the sidebar, and each repo's branch is tracked independently.
49
+
50
+ ```
51
+ worktree/
52
+ api-server/.git
53
+ web-client/.git
54
+ shared-lib/.git
55
+ ```
56
+
57
+ ```bash
58
+ codereview /path/to/worktree --base main
59
+ ```
60
+
61
+ ## How It Works
62
+
63
+ 1. **Browse** -- Files are listed in the left sidebar with status icons (A/M/D/R). Use arrow keys to navigate or click.
64
+ 2. **Review** -- The center panel shows the diff with syntax-colored additions and deletions. Collapsed sections between hunks can be expanded 10 lines at a time.
65
+ 3. **Comment** -- Click the `+` icon on any line to open an inline comment editor. Press `Cmd+Enter` to save. Comments appear below their line with a yellow accent bar.
66
+ 4. **Track** -- A comment count pill in the toolbar shows how many comments you've made. Click it to open a slideover panel listing all comments grouped by file. Click any comment to jump to it.
67
+ 5. **Generate** -- Hit the "Generate" button to produce markdown. An editable preview lets you tweak the output before copying to clipboard.
68
+
69
+ ### Generated Output
70
+
71
+ The markdown is structured for pasting into an agent session:
72
+
73
+ ```
74
+ Verify each finding against the current code and only fix it if needed.
75
+
76
+ In `src/server.ts` around lines 42, this error case is unhandled
77
+
78
+ In `src/api/auth.ts` around lines 108, the token refresh logic has a race condition
79
+ ```
80
+
81
+ ## Keyboard Shortcuts
82
+
83
+ | Key | Context | Action |
84
+ | ----------- | ---------------------- | ---------------- |
85
+ | `Up/Down` | Sidebar | Navigate files |
86
+ | `Left` | Diff panel | Focus sidebar |
87
+ | `Right` | Sidebar | Focus diff panel |
88
+ | `+` click | Diff line | Add comment |
89
+ | `Cmd+Enter` | Comment editor | Save comment |
90
+ | `Esc` | Comment editor / modal | Cancel / close |
91
+
92
+ ## UI Features
93
+
94
+ - **Dark / Light / System theme** -- Toggle in the sidebar footer, persisted to localStorage
95
+ - **Draggable sidebar** -- Resize the file list, width persisted across sessions
96
+ - **Branch toggle** -- Switch between "dirty" (uncommitted only) and "vs main" (full branch diff) on the fly
97
+ - **Diff stats** -- Each file header shows `+N` / `-N` line counts
98
+ - **Expandable context** -- Hidden lines between hunks expand incrementally (10 at a time)
99
+
100
+ ### Default View
101
+
102
+ ![default](./images/defaultview.png)
103
+
104
+ ### Dark Mode
105
+
106
+ ![darkmode](./images/darkmode.png)
107
+
108
+ ### Leave Comments
109
+
110
+ ![comment](./images/comment.png)
111
+
112
+ ### Generate the Review Feedback
113
+
114
+ ![review](./images/review.png)
115
+
116
+ ### Supports Multi-Repo
117
+
118
+ ![multirepo](./images/multirepo.png)
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ # Install deps
124
+ bun install
125
+
126
+ # Build (bundles frontend + CLI)
127
+ bun run build
128
+
129
+ # Run from built output
130
+ node dist/cli.mjs /path/to/repo
131
+
132
+ # Or run source directly with Bun during development
133
+ bun src/cli.ts /path/to/repo
134
+ ```
135
+
136
+ Note: Running source directly with `bun src/cli.ts` requires Bun and won't serve the pre-built frontend. Use `bun run build` first, then `node dist/cli.mjs` for the full experience.
137
+
138
+ ## License
139
+
140
+ MIT
package/dist/cli.mjs ADDED
@@ -0,0 +1,457 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { parseArgs } from "node:util";
5
+ import { createServer } from "node:http";
6
+ import { readFile as readFile2, realpath } from "node:fs/promises";
7
+ import { resolve as resolve2, join as join2, dirname, extname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { exec } from "node:child_process";
10
+
11
+ // src/git.ts
12
+ import { execFile } from "node:child_process";
13
+ import { readFile, readdir } from "node:fs/promises";
14
+ import { join, resolve } from "node:path";
15
+ function git(args, cwd) {
16
+ return new Promise((resolve2, reject) => {
17
+ execFile("git", args, { cwd, maxBuffer: 50 * 1024 * 1024 }, (err, stdout) => {
18
+ if (err)
19
+ reject(err);
20
+ else
21
+ resolve2(stdout);
22
+ });
23
+ });
24
+ }
25
+ function gitSafe(args, cwd) {
26
+ return git(args, cwd).catch(() => "");
27
+ }
28
+ async function getRepoInfo(dir) {
29
+ let repo = "";
30
+ let branch = "";
31
+ try {
32
+ const remoteUrl = (await git(["config", "--get", "remote.origin.url"], dir)).trim();
33
+ const match = remoteUrl.match(/[:\/]([^\/]+\/[^\/]+?)(?:\.git)?$/);
34
+ repo = match ? match[1] : remoteUrl;
35
+ } catch {}
36
+ try {
37
+ branch = (await git(["rev-parse", "--abbrev-ref", "HEAD"], dir)).trim();
38
+ } catch {
39
+ branch = "unknown";
40
+ }
41
+ return { repo, branch };
42
+ }
43
+ async function isGitRepo(dir) {
44
+ try {
45
+ const toplevel = (await git(["rev-parse", "--show-toplevel"], dir)).trim();
46
+ return resolve(toplevel) === resolve(dir);
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+ async function getBranches(dir) {
52
+ try {
53
+ const output = (await git(["branch", "--format=%(refname:short)"], dir)).trim();
54
+ return output.split(`
55
+ `).filter(Boolean);
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+ async function getMergeBase(dir, baseRef) {
61
+ return (await git(["merge-base", baseRef, "HEAD"], dir)).trim();
62
+ }
63
+ async function getChangedFiles(dir, baseRef) {
64
+ const files = [];
65
+ if (baseRef) {
66
+ const mergeBase = await getMergeBase(dir, baseRef);
67
+ const diffOutput = await git(["diff", "--name-status", mergeBase], dir);
68
+ for (const line of diffOutput.split(`
69
+ `).filter(Boolean)) {
70
+ const parts = line.split("\t");
71
+ const statusCode = parts[0];
72
+ let fileStatus = "modified";
73
+ let oldPath;
74
+ let filePath = parts[1];
75
+ if (statusCode === "A") {
76
+ fileStatus = "added";
77
+ } else if (statusCode === "D") {
78
+ fileStatus = "deleted";
79
+ } else if (statusCode.startsWith("R")) {
80
+ fileStatus = "renamed";
81
+ oldPath = parts[1];
82
+ filePath = parts[2];
83
+ }
84
+ files.push({ path: filePath, status: fileStatus, oldPath });
85
+ }
86
+ const statusOutput = await gitSafe(["status", "--porcelain"], dir);
87
+ for (const line of statusOutput.split(`
88
+ `).filter(Boolean)) {
89
+ const status = line.substring(0, 2).trim();
90
+ if (status === "??") {
91
+ const path = line.substring(3);
92
+ if (!files.some((f) => f.path === path)) {
93
+ files.push({ path, status: "added" });
94
+ }
95
+ }
96
+ }
97
+ } else {
98
+ const statusOutput = await git(["status", "--porcelain"], dir);
99
+ for (const line of statusOutput.split(`
100
+ `).filter(Boolean)) {
101
+ const status = line.substring(0, 2).trim();
102
+ const path = line.substring(3);
103
+ let fileStatus = "modified";
104
+ let oldPath;
105
+ if (status === "A" || status === "??") {
106
+ fileStatus = "added";
107
+ } else if (status === "D") {
108
+ fileStatus = "deleted";
109
+ } else if (status.startsWith("R")) {
110
+ fileStatus = "renamed";
111
+ const parts = path.split(" -> ");
112
+ oldPath = parts[0];
113
+ }
114
+ files.push({
115
+ path: fileStatus === "renamed" ? path.split(" -> ")[1] : path,
116
+ status: fileStatus,
117
+ oldPath
118
+ });
119
+ }
120
+ }
121
+ return files;
122
+ }
123
+ async function readWorkingFile(dir, path) {
124
+ try {
125
+ return await readFile(join(dir, path), "utf-8");
126
+ } catch {
127
+ return "";
128
+ }
129
+ }
130
+ async function getFileDiff(dir, file, baseRef) {
131
+ let oldContent = "";
132
+ let newContent = "";
133
+ let hunks = [];
134
+ const oldRef = baseRef ? await getMergeBase(dir, baseRef) : "HEAD";
135
+ if (file.status === "added") {
136
+ try {
137
+ newContent = await git(["show", `:${file.path}`], dir);
138
+ } catch {
139
+ newContent = await readWorkingFile(dir, file.path);
140
+ }
141
+ oldContent = "";
142
+ } else if (file.status === "deleted") {
143
+ oldContent = await gitSafe(["show", `${oldRef}:${file.path}`], dir);
144
+ newContent = "";
145
+ } else {
146
+ oldContent = await gitSafe(["show", `${oldRef}:${file.path}`], dir);
147
+ newContent = await readWorkingFile(dir, file.path);
148
+ if (!newContent) {
149
+ try {
150
+ newContent = await git(["show", `:${file.path}`], dir);
151
+ } catch {
152
+ newContent = await gitSafe(["show", `HEAD:${file.path}`], dir);
153
+ }
154
+ }
155
+ }
156
+ if (file.status !== "added") {
157
+ try {
158
+ const diffResult = await git(["diff", oldRef, "--", file.path], dir);
159
+ hunks = parseDiffHunks(diffResult);
160
+ } catch {
161
+ hunks = [];
162
+ }
163
+ }
164
+ return {
165
+ path: file.path,
166
+ status: file.status,
167
+ oldPath: file.oldPath,
168
+ oldContent,
169
+ newContent,
170
+ hunks
171
+ };
172
+ }
173
+ async function getAllFileDiffs(dir, repo, baseRef) {
174
+ const files = await getChangedFiles(dir, baseRef);
175
+ const diffs = [];
176
+ for (const file of files) {
177
+ const diff = await getFileDiff(dir, file, baseRef);
178
+ if (repo)
179
+ diff.repo = repo;
180
+ diffs.push(diff);
181
+ }
182
+ return diffs;
183
+ }
184
+ async function discoverGitRepos(parentDir) {
185
+ const entries = await readdir(parentDir, { withFileTypes: true });
186
+ const repos = [];
187
+ for (const entry of entries) {
188
+ if (entry.isDirectory() && !entry.name.startsWith(".")) {
189
+ const subDir = join(parentDir, entry.name);
190
+ if (await isGitRepo(subDir)) {
191
+ repos.push(entry.name);
192
+ }
193
+ }
194
+ }
195
+ return repos.sort();
196
+ }
197
+ async function getMultiRepoDiffs(parentDir, repoNames, baseRef) {
198
+ const repos = [];
199
+ const allDiffs = [];
200
+ for (const name of repoNames) {
201
+ const repoDir = join(parentDir, name);
202
+ const info = await getRepoInfo(repoDir);
203
+ const diffs = await getAllFileDiffs(repoDir, name, baseRef);
204
+ if (diffs.length > 0) {
205
+ repos.push({ name, info });
206
+ allDiffs.push(...diffs);
207
+ }
208
+ }
209
+ return { repos, diffs: allDiffs };
210
+ }
211
+ function parseDiffHunks(diffOutput) {
212
+ const hunks = [];
213
+ const lines = diffOutput.split(`
214
+ `);
215
+ let currentHunk = null;
216
+ let currentContent = [];
217
+ for (const line of lines) {
218
+ const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
219
+ if (hunkMatch) {
220
+ if (currentHunk) {
221
+ currentHunk.content = currentContent.join(`
222
+ `);
223
+ hunks.push(currentHunk);
224
+ }
225
+ currentHunk = {
226
+ oldStart: parseInt(hunkMatch[1]),
227
+ oldLines: parseInt(hunkMatch[2] || "1"),
228
+ newStart: parseInt(hunkMatch[3]),
229
+ newLines: parseInt(hunkMatch[4] || "1"),
230
+ content: ""
231
+ };
232
+ currentContent = [line];
233
+ } else if (currentHunk && (line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") || line.startsWith("\\"))) {
234
+ currentContent.push(line);
235
+ }
236
+ }
237
+ if (currentHunk) {
238
+ currentHunk.content = currentContent.join(`
239
+ `);
240
+ hunks.push(currentHunk);
241
+ }
242
+ return hunks;
243
+ }
244
+
245
+ // src/cli.ts
246
+ var __dirname2 = dirname(fileURLToPath(import.meta.url));
247
+ var PUBLIC_DIR = join2(__dirname2, "public");
248
+ var MIME = {
249
+ ".html": "text/html; charset=utf-8",
250
+ ".js": "application/javascript; charset=utf-8",
251
+ ".css": "text/css; charset=utf-8",
252
+ ".json": "application/json; charset=utf-8",
253
+ ".svg": "image/svg+xml"
254
+ };
255
+ var state = {
256
+ diffs: [],
257
+ repos: [],
258
+ multiRepo: false,
259
+ repoNames: [],
260
+ workDir: process.cwd(),
261
+ baseRef: ""
262
+ };
263
+ async function loadDiffs(baseRef) {
264
+ const base = baseRef ?? state.baseRef;
265
+ state.baseRef = base;
266
+ if (state.multiRepo) {
267
+ const result = await getMultiRepoDiffs(state.workDir, state.repoNames, base || undefined);
268
+ state.repos = result.repos;
269
+ state.diffs = result.diffs;
270
+ } else {
271
+ const info = await getRepoInfo(state.workDir);
272
+ state.repos = [{ name: "", info }];
273
+ state.diffs = await getAllFileDiffs(state.workDir, undefined, base || undefined);
274
+ }
275
+ }
276
+ function jsonResponse(data, status = 200) {
277
+ return { status, headers: { "Content-Type": "application/json; charset=utf-8" }, body: JSON.stringify(data) };
278
+ }
279
+ async function readBody(req) {
280
+ const chunks = [];
281
+ for await (const chunk of req)
282
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
283
+ return Buffer.concat(chunks).toString("utf-8");
284
+ }
285
+ function generateMarkdown(comments) {
286
+ if (comments.length === 0)
287
+ return "";
288
+ const lines = ["Verify each finding against the current code and only fix it if needed.", ""];
289
+ for (const comment of comments) {
290
+ const filePath = comment.repo ? `${comment.repo}/${comment.filePath}` : comment.filePath;
291
+ lines.push(`In \`${filePath}\` around lines ${comment.lineNumber}, ${comment.content}`);
292
+ lines.push("");
293
+ }
294
+ return lines.join(`
295
+ `);
296
+ }
297
+ async function main() {
298
+ const { values, positionals } = parseArgs({
299
+ args: process.argv.slice(2),
300
+ options: {
301
+ help: { type: "boolean", short: "h" },
302
+ port: { type: "string", short: "p", default: "" },
303
+ base: { type: "string", short: "b", default: "" }
304
+ },
305
+ allowPositionals: true
306
+ });
307
+ if (values.help) {
308
+ console.log(`
309
+ codereview - A CLI tool for reviewing git changes
310
+
311
+ Usage: codereview [options] [directory]
312
+
313
+ Options:
314
+ -h, --help Show this help message
315
+ -p, --port Port to run the UI server (default: random available port)
316
+ -b, --base <ref> Diff against a base branch (e.g., main). Shows all changes on the branch.
317
+ Without this flag, only uncommitted changes are shown.
318
+
319
+ Arguments:
320
+ directory The git directory or parent of multiple git repos (default: current directory)
321
+ `);
322
+ process.exit(0);
323
+ }
324
+ const targetDir = await realpath(resolve2(positionals[0] || process.cwd()));
325
+ state.workDir = targetDir;
326
+ state.baseRef = values.base || "";
327
+ if (await isGitRepo(targetDir)) {
328
+ state.multiRepo = false;
329
+ state.repoNames = [];
330
+ } else {
331
+ const repoNames = await discoverGitRepos(targetDir);
332
+ if (repoNames.length === 0) {
333
+ console.error(`Error: ${targetDir} is not a git repository and contains no git repos`);
334
+ process.exit(1);
335
+ }
336
+ console.log(`Found ${repoNames.length} repo(s): ${repoNames.join(", ")}`);
337
+ state.multiRepo = true;
338
+ state.repoNames = repoNames;
339
+ }
340
+ console.log(`Loading git changes${state.baseRef ? ` (base: ${state.baseRef})` : ""}...`);
341
+ await loadDiffs();
342
+ if (state.diffs.length === 0) {
343
+ console.log("No changes to review.");
344
+ process.exit(0);
345
+ }
346
+ const repoCount = state.repos.length;
347
+ console.log(`Found ${state.diffs.length} file(s) with changes${repoCount > 1 ? ` across ${repoCount} repos` : ""}.`);
348
+ const activeClients = new Set;
349
+ let shutdownTimer = null;
350
+ const SHUTDOWN_GRACE_MS = 500;
351
+ function onClientConnect(res) {
352
+ activeClients.add(res);
353
+ if (shutdownTimer) {
354
+ clearTimeout(shutdownTimer);
355
+ shutdownTimer = null;
356
+ }
357
+ }
358
+ function onClientDisconnect(res) {
359
+ activeClients.delete(res);
360
+ if (activeClients.size === 0 && !shutdownTimer) {
361
+ shutdownTimer = setTimeout(() => {
362
+ console.log("All clients disconnected. Shutting down.");
363
+ process.exit(0);
364
+ }, SHUTDOWN_GRACE_MS);
365
+ }
366
+ }
367
+ async function handleRequest(req) {
368
+ const url = new URL(req.url || "/", "http://localhost");
369
+ const path = url.pathname;
370
+ const method = req.method || "GET";
371
+ if (path === "/api/info" && method === "GET") {
372
+ return jsonResponse({ multiRepo: state.multiRepo, repos: state.repos, baseRef: state.baseRef });
373
+ }
374
+ if (path === "/api/diffs" && method === "GET") {
375
+ return jsonResponse(state.diffs);
376
+ }
377
+ if (path === "/api/branches" && method === "GET") {
378
+ if (state.multiRepo) {
379
+ let common = null;
380
+ for (const name of state.repoNames) {
381
+ const branches = new Set(await getBranches(join2(state.workDir, name)));
382
+ if (common === null) {
383
+ common = branches;
384
+ } else {
385
+ for (const b of common) {
386
+ if (!branches.has(b))
387
+ common.delete(b);
388
+ }
389
+ }
390
+ }
391
+ return jsonResponse({ branches: Array.from(common || []).sort() });
392
+ }
393
+ return jsonResponse({ branches: await getBranches(state.workDir) });
394
+ }
395
+ if (path === "/api/reload" && method === "POST") {
396
+ const body = JSON.parse(await readBody(req));
397
+ try {
398
+ await loadDiffs(body.base ?? "");
399
+ return jsonResponse({ success: true, fileCount: state.diffs.length, baseRef: state.baseRef, repos: state.repos });
400
+ } catch (err) {
401
+ return jsonResponse({ success: false, error: err.message }, 400);
402
+ }
403
+ }
404
+ if (path === "/api/generate-markdown" && method === "POST") {
405
+ const body = JSON.parse(await readBody(req));
406
+ return jsonResponse({ markdown: generateMarkdown(body.comments) });
407
+ }
408
+ const filePath = path === "/" ? "/index.html" : path;
409
+ try {
410
+ const content = await readFile2(join2(PUBLIC_DIR, filePath));
411
+ const mime = MIME[extname(filePath)] || "application/octet-stream";
412
+ return { status: 200, headers: { "Content-Type": mime }, body: content };
413
+ } catch {
414
+ return { status: 404, headers: { "Content-Type": "text/plain" }, body: "Not Found" };
415
+ }
416
+ }
417
+ const requestedPort = values.port ? parseInt(values.port) : 0;
418
+ const server = createServer(async (req, res) => {
419
+ if (req.url === "/api/keepalive" && req.method === "GET") {
420
+ res.writeHead(200, {
421
+ "Content-Type": "text/event-stream",
422
+ "Cache-Control": "no-cache",
423
+ Connection: "keep-alive"
424
+ });
425
+ res.write(`data: connected
426
+
427
+ `);
428
+ onClientConnect(res);
429
+ req.on("close", () => onClientDisconnect(res));
430
+ return;
431
+ }
432
+ try {
433
+ const { status, headers, body } = await handleRequest(req);
434
+ res.writeHead(status, headers);
435
+ res.end(body);
436
+ } catch (err) {
437
+ res.writeHead(500, { "Content-Type": "text/plain" });
438
+ res.end(`Internal Server Error: ${err.message}`);
439
+ }
440
+ });
441
+ server.listen(requestedPort, () => {
442
+ const addr = server.address();
443
+ const port = typeof addr === "object" && addr ? addr.port : requestedPort;
444
+ console.log(`
445
+ Code review UI running at http://localhost:${port}`);
446
+ console.log(`Press Ctrl+C to stop.
447
+ `);
448
+ if (process.env.NO_OPEN !== "true") {
449
+ const opener = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : "start";
450
+ exec(`${opener} http://localhost:${port}`, () => {});
451
+ }
452
+ });
453
+ }
454
+ main().catch((err) => {
455
+ console.error("Error:", err.message);
456
+ process.exit(1);
457
+ });