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 +140 -0
- package/dist/cli.mjs +457 -0
- package/dist/public/app.js +166 -0
- package/dist/public/index.html +13 -0
- package/dist/public/styles.css +1154 -0
- package/package.json +27 -0
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
|
+

|
|
103
|
+
|
|
104
|
+
### Dark Mode
|
|
105
|
+
|
|
106
|
+

|
|
107
|
+
|
|
108
|
+
### Leave Comments
|
|
109
|
+
|
|
110
|
+

|
|
111
|
+
|
|
112
|
+
### Generate the Review Feedback
|
|
113
|
+
|
|
114
|
+

|
|
115
|
+
|
|
116
|
+
### Supports Multi-Repo
|
|
117
|
+
|
|
118
|
+

|
|
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
|
+
});
|