git-reviewer 2.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/LICENSE +21 -0
- package/README.md +282 -0
- package/bin/reviewer.js +146 -0
- package/lib/agent.js +182 -0
- package/lib/browser.js +67 -0
- package/lib/changes.js +84 -0
- package/lib/cli.js +234 -0
- package/lib/comments.js +98 -0
- package/lib/diff.js +92 -0
- package/lib/paths.js +52 -0
- package/lib/review.js +206 -0
- package/lib/sessions.js +62 -0
- package/lib/store.js +113 -0
- package/package.json +48 -0
- package/public/app.js +1439 -0
- package/public/favicon.svg +7 -0
- package/public/index.html +66 -0
- package/public/style.css +990 -0
- package/server.js +421 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dheeraj Jha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# reviewer
|
|
2
|
+
|
|
3
|
+
[](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](package.json)
|
|
6
|
+
[](test/)
|
|
7
|
+
[](package.json)
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
Review code before it becomes a pull request. `reviewer` opens any local git
|
|
12
|
+
repository in your browser, shows what changed, and lets you leave inline
|
|
13
|
+
comments with threaded follow-ups — against your working directory, without a
|
|
14
|
+
branch, a remote, a push, or an account. Comments persist between sessions and
|
|
15
|
+
survive the code moving underneath them, so you can review, edit, and come back
|
|
16
|
+
tomorrow to a review that still points at the right lines. When you are done it
|
|
17
|
+
writes a plain Markdown file you can paste anywhere.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd ~/work/my-app
|
|
21
|
+
npx git-reviewer .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That starts a local server and opens your browser on the repository you named.
|
|
25
|
+
There is nothing to install into the project, nothing to sign in to, and
|
|
26
|
+
nothing leaves your machine.
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
Click a line number, write the note, keep going. Replies thread under the
|
|
31
|
+
comment they answer, and everything is saved as you type.
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
reviewer [repository] [options]
|
|
37
|
+
reviewer export [repository] [--format json|prompt]
|
|
38
|
+
|
|
39
|
+
repository Path to a git repository (default: the current directory)
|
|
40
|
+
|
|
41
|
+
-p, --port <n> Port to listen on (default 4500; falls back to a free
|
|
42
|
+
port if that one is taken)
|
|
43
|
+
--no-open Print the URL instead of opening a browser
|
|
44
|
+
-f, --format <f> Export format: json (default) or prompt
|
|
45
|
+
-h, --help Show help
|
|
46
|
+
-v, --version Show the version
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
reviewer # review the repository you are standing in
|
|
51
|
+
reviewer ~/work/api # review another one
|
|
52
|
+
reviewer . --no-open # print the URL, open it yourself
|
|
53
|
+
reviewer . --port 8080 # somewhere other than 4500
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Two at once is fine — the second one finds its own port.
|
|
57
|
+
|
|
58
|
+
To install it as a command rather than running it through `npx`:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npm install -g git-reviewer
|
|
62
|
+
reviewer .
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The package is `git-reviewer` because `reviewer` and `code-reviewer` were both
|
|
66
|
+
already taken on npm. The command it installs is still `reviewer` -- the
|
|
67
|
+
package name is a distribution detail, the command is the product.
|
|
68
|
+
|
|
69
|
+
From a clone, if you would rather not install anything:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
git clone https://github.com/dheerajjha/reviewer.git
|
|
73
|
+
cd reviewer && npm install && npm link
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## What it shows you
|
|
77
|
+
|
|
78
|
+
`reviewer` picks what to review based on the state of the repository, so there
|
|
79
|
+
is nothing to configure:
|
|
80
|
+
|
|
81
|
+
| Repository state | What you review |
|
|
82
|
+
|---|---|
|
|
83
|
+
| Uncommitted changes present | Your working directory against `HEAD` |
|
|
84
|
+
| Working directory clean | The last commit against its parent |
|
|
85
|
+
| No commits yet | Every tracked and untracked file, as wholly new |
|
|
86
|
+
|
|
87
|
+
Modified, added, deleted, renamed, and binary files are all listed, each marked
|
|
88
|
+
with its git status letter. Deleted lines are commentable too — the most useful
|
|
89
|
+
review note is often about the code someone removed. A file deleted outright is
|
|
90
|
+
shown in full, read back out of `HEAD`:
|
|
91
|
+
|
|
92
|
+

|
|
93
|
+
|
|
94
|
+
## Reviewing
|
|
95
|
+
|
|
96
|
+
| Action | How |
|
|
97
|
+
|---|---|
|
|
98
|
+
| Comment on a line | Click the line number |
|
|
99
|
+
| Comment on a snippet | Hold `Cmd/Ctrl`, select code, then click the line number |
|
|
100
|
+
| Reply to a comment | Click **Reply** on it |
|
|
101
|
+
| Edit or delete | Hover the comment |
|
|
102
|
+
| Finish the review | Click **Submit Review** |
|
|
103
|
+
|
|
104
|
+
| Shortcut | Action |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `Cmd/Ctrl+Enter` | Save the comment being written |
|
|
107
|
+
| `Escape` | Cancel input |
|
|
108
|
+
| `↑` / `↓` | Move between files |
|
|
109
|
+
|
|
110
|
+
## What it writes
|
|
111
|
+
|
|
112
|
+
Two files, both under `reviews/`, both plain text you can read without this
|
|
113
|
+
app:
|
|
114
|
+
|
|
115
|
+
- **`.code-review-comments-<repo>.json`** — the live state of the review. It is
|
|
116
|
+
written on every edit and is the source of truth; reopening the same
|
|
117
|
+
repository restores exactly what is in it.
|
|
118
|
+
- **`review_<repo>_<timestamp>.txt`** — a submitted review, rendered from that
|
|
119
|
+
JSON as Markdown, grouped by file and ordered by line:
|
|
120
|
+
|
|
121
|
+
````markdown
|
|
122
|
+
# Code Review
|
|
123
|
+
|
|
124
|
+
Repository: /work/my-app
|
|
125
|
+
Generated: 2026-08-10T12:30:45.678Z
|
|
126
|
+
Total Comments: 2
|
|
127
|
+
|
|
128
|
+
## src/auth.js
|
|
129
|
+
|
|
130
|
+
**Line 42:**
|
|
131
|
+
```
|
|
132
|
+
const token = req.headers.authorization;
|
|
133
|
+
```
|
|
134
|
+
This trusts the header without checking the scheme.
|
|
135
|
+
|
|
136
|
+
**Follow-ups:**
|
|
137
|
+
1. Still open after the rebase (2026-08-10T14:02:11.000Z)
|
|
138
|
+
````
|
|
139
|
+
|
|
140
|
+
Timestamps are ISO 8601 rather than a locale format on purpose: a review gets
|
|
141
|
+
committed, pasted into a pull request, and read on someone else's machine, so
|
|
142
|
+
its shape should not depend on the reader.
|
|
143
|
+
|
|
144
|
+
Submitting shows you exactly what was written, ready to download or copy:
|
|
145
|
+
|
|
146
|
+

|
|
147
|
+
|
|
148
|
+
## Handing the review to a coding agent
|
|
149
|
+
|
|
150
|
+
A review is only half the work; applying it is the other half. `reviewer
|
|
151
|
+
export` prints the review you saved — no server, no submit step — either as
|
|
152
|
+
JSON or as instructions ready to pipe:
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
reviewer export . --format prompt | claude -p "Apply this review to the repo."
|
|
156
|
+
reviewer export . | jq '.comments[].file'
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Every comment carries an `anchor`: the exact text of the line it was left on.
|
|
160
|
+
Line numbers go stale the moment an agent makes its first edit, because
|
|
161
|
+
everything below shifts — so the anchor is what lets a comment still be found.
|
|
162
|
+
The prompt format says so to the agent explicitly, and tells it to report
|
|
163
|
+
rather than guess when an anchor has vanished.
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{
|
|
167
|
+
"schema": "code-review/v1",
|
|
168
|
+
"repository": { "path": "/work/api-service", "head": "abe3b37", "branch": "main" },
|
|
169
|
+
"summary": { "comments": 2, "files": 1 },
|
|
170
|
+
"comments": [
|
|
171
|
+
{
|
|
172
|
+
"id": "src/auth.js:10",
|
|
173
|
+
"file": "src/auth.js",
|
|
174
|
+
"line": 10,
|
|
175
|
+
"anchor": " if (scheme !== SCHEME || !token) return null;",
|
|
176
|
+
"body": "Reject an empty token too.",
|
|
177
|
+
"followUps": [{ "body": "Still open after the rebase", "at": "2026-08-10T14:00:00.000Z" }]
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Submitting in the UI writes this alongside the `.txt`. The full schema is in
|
|
184
|
+
[docs/agent-format.md](docs/agent-format.md).
|
|
185
|
+
|
|
186
|
+
## HTTP API
|
|
187
|
+
|
|
188
|
+
The UI is a client of this; nothing is hidden from you.
|
|
189
|
+
|
|
190
|
+
| Endpoint | Purpose |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `GET /api/health` | Liveness, plus the number of open sessions |
|
|
193
|
+
| `POST /api/load-repo` | Open a repository; returns a `repoId` and the changed files |
|
|
194
|
+
| `GET /api/file/:repoId/:path` | The file's diff, as structured lines |
|
|
195
|
+
| `GET /api/file-full/:repoId/:path` | Every line of the file, for context |
|
|
196
|
+
| `POST /api/save-comments` | Persist the current comments |
|
|
197
|
+
| `GET /api/load-comments/:repoId` | Restore saved comments |
|
|
198
|
+
| `POST /api/submit-review` | Render the saved comments to a review file |
|
|
199
|
+
| `DELETE /api/cleanup/:repoId` | End the session; files on disk are untouched |
|
|
200
|
+
|
|
201
|
+
A `repoId` is a random per-session handle, not a path. Requests that resolve
|
|
202
|
+
outside the opened repository are refused, and a path that names nothing gets a
|
|
203
|
+
404.
|
|
204
|
+
|
|
205
|
+
`npm run serve` starts the server on its own, without opening a browser.
|
|
206
|
+
|
|
207
|
+
## Security
|
|
208
|
+
|
|
209
|
+
The server binds to `127.0.0.1`. It reads any file in the repository you
|
|
210
|
+
opened, so reaching it should require being on the machine running it — set
|
|
211
|
+
`HOST=0.0.0.0` only if you mean it. Every path that reaches the filesystem is
|
|
212
|
+
resolved through a check that it lands inside that repository, so a request for
|
|
213
|
+
`..%2f..%2fetc%2fpasswd` is refused rather than served.
|
|
214
|
+
|
|
215
|
+
If you find a way past that, please report it through
|
|
216
|
+
[security advisories](https://github.com/dheerajjha/reviewer/security/advisories/new)
|
|
217
|
+
rather than a public issue.
|
|
218
|
+
|
|
219
|
+
## Development
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
npm install # 3 dependencies, no build step, ~6MB
|
|
223
|
+
npm test # 163 tests
|
|
224
|
+
npm run test:watch
|
|
225
|
+
npm run test:coverage
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
bin/reviewer.js the command: parse args, start server, open browser
|
|
230
|
+
server.js Express app factory and routes
|
|
231
|
+
lib/
|
|
232
|
+
cli.js argument parsing and URL building
|
|
233
|
+
browser.js opening a URL on each platform
|
|
234
|
+
diff.js unified diff -> structured lines
|
|
235
|
+
paths.js confines request paths to the repository
|
|
236
|
+
changes.js git status and commit summaries -> changed files
|
|
237
|
+
comments.js the persisted comment shape
|
|
238
|
+
review.js rendering and naming review files
|
|
239
|
+
sessions.js repoId -> repository
|
|
240
|
+
public/ UI: index.html, style.css, app.js
|
|
241
|
+
test/ Node test runner; HTTP tests drive real git repositories
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
The request handlers hold no logic worth testing — it lives in `lib/`, and
|
|
245
|
+
`server.js` exports `createApp()` so a test can point an instance at a
|
|
246
|
+
temporary directory. The HTTP tests do not stub git: each one builds a real
|
|
247
|
+
repository in a temp directory and runs real `git` against it, because diff
|
|
248
|
+
parsing is exactly where a stub would be wrong in the same way the code is.
|
|
249
|
+
|
|
250
|
+
Tests run on Linux and macOS across Node 18, 20, and 22. See
|
|
251
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
252
|
+
|
|
253
|
+
## Tech
|
|
254
|
+
|
|
255
|
+
Express, [simple-git](https://github.com/steveukx/git-js), and vanilla
|
|
256
|
+
JavaScript in the browser. Three runtime dependencies, no dev dependencies, no
|
|
257
|
+
build step for the frontend, no test framework — the tests use the one built
|
|
258
|
+
into Node.
|
|
259
|
+
|
|
260
|
+
Version 2.0 dropped the Electron wrapper. It added a few hundred megabytes and
|
|
261
|
+
a per-platform build pipeline to put a browser engine around a page your
|
|
262
|
+
browser already renders. See [CHANGELOG.md](CHANGELOG.md).
|
|
263
|
+
|
|
264
|
+
## Contributing
|
|
265
|
+
|
|
266
|
+
There is a list of [open issues](https://github.com/dheerajjha/reviewer/issues),
|
|
267
|
+
including some tagged
|
|
268
|
+
[good first issue](https://github.com/dheerajjha/reviewer/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
|
269
|
+
The ones most likely to change how the tool feels:
|
|
270
|
+
|
|
271
|
+
- [#1](https://github.com/dheerajjha/reviewer/issues/1) — reviews are stored inside the install rather than with the repository
|
|
272
|
+
- [#2](https://github.com/dheerajjha/reviewer/issues/2) — give comments a state, so a review can be worked through and marked off
|
|
273
|
+
- [#3](https://github.com/dheerajjha/reviewer/issues/3) — report whether each anchor still matches the file
|
|
274
|
+
- [#4](https://github.com/dheerajjha/reviewer/issues/4) — expose the review over MCP, so an agent works through it interactively
|
|
275
|
+
- [#6](https://github.com/dheerajjha/reviewer/issues/6) — review a commit range, not just the working tree
|
|
276
|
+
|
|
277
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) covers the setup and what a good change looks
|
|
278
|
+
like here.
|
|
279
|
+
|
|
280
|
+
## License
|
|
281
|
+
|
|
282
|
+
[MIT](LICENSE) © Dheeraj Jha
|
package/bin/reviewer.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('fs').promises;
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const simpleGit = require('simple-git');
|
|
8
|
+
|
|
9
|
+
const { parseArgs, buildUrl, UsageError, USAGE } = require('../lib/cli');
|
|
10
|
+
const { openInBrowser } = require('../lib/browser');
|
|
11
|
+
const { buildReviewDocument, filterReviewDocument, formatPrompt } = require('../lib/agent');
|
|
12
|
+
const { readSavedComments, ReviewOwnershipError } = require('../lib/store');
|
|
13
|
+
const { startServer, DEFAULT_PORT, DEFAULT_HOST, REVIEWS_DIR } = require('../server');
|
|
14
|
+
const { version } = require('../package.json');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `reviewer` command: start the local server and open it in a browser.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Listen on `port`, falling back to any free port if it is taken.
|
|
22
|
+
*
|
|
23
|
+
* Someone reviewing two repositories at once should get a second window, not
|
|
24
|
+
* an EADDRINUSE stack trace.
|
|
25
|
+
*
|
|
26
|
+
* @param {number} port
|
|
27
|
+
* @returns {Promise<import('http').Server>}
|
|
28
|
+
*/
|
|
29
|
+
async function listen(port) {
|
|
30
|
+
try {
|
|
31
|
+
return await startServer({ port, host: DEFAULT_HOST, silent: true });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error.code !== 'EADDRINUSE') throw error;
|
|
34
|
+
|
|
35
|
+
console.log(`Port ${port} is in use, picking another.`);
|
|
36
|
+
return startServer({ port: 0, host: DEFAULT_HOST, silent: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Print the saved review of a repository to stdout.
|
|
42
|
+
*
|
|
43
|
+
* Reads the same JSON the UI writes on every edit, so a review can be handed
|
|
44
|
+
* to another tool without submitting it or keeping the server running.
|
|
45
|
+
*
|
|
46
|
+
* @param {import('../lib/cli').CliOptions} options
|
|
47
|
+
*/
|
|
48
|
+
async function exportReview(options) {
|
|
49
|
+
const repoPath = options.repoPath ?? process.cwd();
|
|
50
|
+
|
|
51
|
+
let comments;
|
|
52
|
+
try {
|
|
53
|
+
({ comments } = await readSavedComments(REVIEWS_DIR, repoPath));
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (error instanceof ReviewOwnershipError) throw new UsageError(error.message);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (comments.length === 0) {
|
|
60
|
+
throw new UsageError(`No saved review for ${repoPath}. Review it first, then export.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Recording the commit lets a consumer tell whether the tree has moved on
|
|
64
|
+
// since the review was written. Neither is fatal if git will not say.
|
|
65
|
+
const git = simpleGit(repoPath);
|
|
66
|
+
const head = await git.revparse(['HEAD']).then(sha => sha.trim()).catch(() => null);
|
|
67
|
+
const branch = await git.revparse(['--abbrev-ref', 'HEAD']).then(name => name.trim()).catch(() => null);
|
|
68
|
+
|
|
69
|
+
const document = filterReviewDocument(
|
|
70
|
+
buildReviewDocument({
|
|
71
|
+
repoPath,
|
|
72
|
+
comments,
|
|
73
|
+
generatedAt: new Date(),
|
|
74
|
+
head,
|
|
75
|
+
branch
|
|
76
|
+
}),
|
|
77
|
+
{ file: options.file }
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
if (document.comments.length === 0) {
|
|
81
|
+
throw new UsageError(`The saved review for ${repoPath} has no comments for ${options.file}.`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
process.stdout.write(
|
|
85
|
+
options.format === 'prompt'
|
|
86
|
+
? formatPrompt(document)
|
|
87
|
+
: `${JSON.stringify(document, null, 2)}\n`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function main() {
|
|
92
|
+
const options = parseArgs(process.argv.slice(2));
|
|
93
|
+
|
|
94
|
+
if (options.help) {
|
|
95
|
+
process.stdout.write(USAGE);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (options.version) {
|
|
100
|
+
console.log(version);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (options.repoPath) {
|
|
105
|
+
try {
|
|
106
|
+
await fs.access(options.repoPath);
|
|
107
|
+
} catch {
|
|
108
|
+
throw new UsageError(`No such directory: ${options.repoPath}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (options.command === 'export') {
|
|
113
|
+
return exportReview(options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const server = await listen(options.port ?? (Number(process.env.PORT) || DEFAULT_PORT));
|
|
117
|
+
const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, options.repoPath);
|
|
118
|
+
|
|
119
|
+
console.log(`\n Code Reviewer ${url}`);
|
|
120
|
+
if (options.repoPath) console.log(` reviewing ${options.repoPath}`);
|
|
121
|
+
console.log('\n Press Ctrl+C to stop.\n');
|
|
122
|
+
|
|
123
|
+
if (options.open && !(await openInBrowser(url))) {
|
|
124
|
+
console.log(' Could not open a browser — open the URL above yourself.\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stop = () => {
|
|
128
|
+
server.close(() => process.exit(0));
|
|
129
|
+
// Don't wait forever on a browser holding the connection open.
|
|
130
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
process.on('SIGINT', stop);
|
|
134
|
+
process.on('SIGTERM', stop);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
main().catch(error => {
|
|
138
|
+
if (error instanceof UsageError) {
|
|
139
|
+
console.error(`error: ${error.message}\n`);
|
|
140
|
+
process.stderr.write(USAGE);
|
|
141
|
+
process.exit(2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.error(error);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
});
|
package/lib/agent.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { groupByFile } = require('./comments');
|
|
4
|
+
const { repoSlug } = require('./review');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The machine-readable form of a review.
|
|
8
|
+
*
|
|
9
|
+
* The `.txt` review is written for a person to read. A coding agent asked to
|
|
10
|
+
* act on a review needs something else: a stable schema, and above all an
|
|
11
|
+
* *anchor* — the exact text of the line a comment was left on. Line numbers
|
|
12
|
+
* are the first thing to go stale, because the agent's own first edit shifts
|
|
13
|
+
* every line below it. The anchor is what lets a comment still be found after
|
|
14
|
+
* that.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const SCHEMA = 'code-review/v1';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} ReviewDocument
|
|
21
|
+
* @property {string} schema
|
|
22
|
+
* @property {string} generatedAt ISO 8601
|
|
23
|
+
* @property {{path: string, name: string, head: string|null, branch: string|null}} repository
|
|
24
|
+
* @property {'working'|'lastCommit'|null} mode
|
|
25
|
+
* @property {{comments: number, files: number}} summary
|
|
26
|
+
* @property {AgentComment[]} comments
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {object} AgentComment
|
|
31
|
+
* @property {string} id stable within a document
|
|
32
|
+
* @property {string} file repo-relative path
|
|
33
|
+
* @property {number} line line number when the comment was written
|
|
34
|
+
* @property {string|null} anchor the exact source line, for relocating the comment
|
|
35
|
+
* @property {string} [selection] the snippet the reviewer highlighted
|
|
36
|
+
* @property {string} body
|
|
37
|
+
* @property {Array<{body: string, at?: string}>} [followUps]
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the machine-readable review document.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} args
|
|
44
|
+
* @param {string} args.repoPath
|
|
45
|
+
* @param {import('./comments').Comment[]} args.comments
|
|
46
|
+
* @param {Date} args.generatedAt
|
|
47
|
+
* @param {string|null} [args.head] the commit the review was written against
|
|
48
|
+
* @param {string|null} [args.branch]
|
|
49
|
+
* @param {string|null} [args.mode]
|
|
50
|
+
* @returns {ReviewDocument}
|
|
51
|
+
*/
|
|
52
|
+
function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null }) {
|
|
53
|
+
const grouped = groupByFile(comments);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
schema: SCHEMA,
|
|
57
|
+
generatedAt: generatedAt.toISOString(),
|
|
58
|
+
repository: {
|
|
59
|
+
path: repoPath,
|
|
60
|
+
name: repoSlug(repoPath),
|
|
61
|
+
head,
|
|
62
|
+
branch
|
|
63
|
+
},
|
|
64
|
+
mode,
|
|
65
|
+
summary: {
|
|
66
|
+
comments: comments.length,
|
|
67
|
+
files: grouped.length
|
|
68
|
+
},
|
|
69
|
+
comments: grouped.flatMap(([, fileComments]) => fileComments.map(toAgentComment))
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Select comments from an already-built document and keep its summary honest.
|
|
75
|
+
*
|
|
76
|
+
* @param {ReviewDocument} document
|
|
77
|
+
* @param {object} filters
|
|
78
|
+
* @param {string|null} [filters.file]
|
|
79
|
+
* @returns {ReviewDocument}
|
|
80
|
+
*/
|
|
81
|
+
function filterReviewDocument(document, { file = null } = {}) {
|
|
82
|
+
if (file === null) return document;
|
|
83
|
+
|
|
84
|
+
const comments = document.comments.filter(comment => comment.file === file);
|
|
85
|
+
return {
|
|
86
|
+
...document,
|
|
87
|
+
summary: {
|
|
88
|
+
comments: comments.length,
|
|
89
|
+
files: new Set(comments.map(comment => comment.file)).size
|
|
90
|
+
},
|
|
91
|
+
comments
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {import('./comments').Comment} comment
|
|
97
|
+
* @returns {AgentComment}
|
|
98
|
+
*/
|
|
99
|
+
function toAgentComment(comment) {
|
|
100
|
+
const entry = {
|
|
101
|
+
// File and line identify a comment uniquely — the UI anchors one comment
|
|
102
|
+
// per line — and staying derived keeps ids stable across exports.
|
|
103
|
+
id: `${comment.file}:${comment.line}`,
|
|
104
|
+
file: comment.file,
|
|
105
|
+
line: comment.line,
|
|
106
|
+
anchor: comment.lineContent ?? null,
|
|
107
|
+
body: comment.text
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
if (comment.selectedText) entry.selection = comment.selectedText;
|
|
111
|
+
|
|
112
|
+
if (comment.followUps?.length > 0) {
|
|
113
|
+
entry.followUps = comment.followUps.map(followUp => {
|
|
114
|
+
const item = { body: followUp.text };
|
|
115
|
+
if (followUp.timestamp) item.at = followUp.timestamp;
|
|
116
|
+
return item;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return entry;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Render a review as instructions for a coding agent.
|
|
125
|
+
*
|
|
126
|
+
* This is the format meant to be piped straight into a tool like Claude Code.
|
|
127
|
+
* It leads with what to do and how to locate each comment, because an agent
|
|
128
|
+
* reads top-down and the instruction has to arrive before the data it governs.
|
|
129
|
+
*
|
|
130
|
+
* @param {ReviewDocument} document
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
function formatPrompt(document) {
|
|
134
|
+
const { repository, summary } = document;
|
|
135
|
+
const lines = [
|
|
136
|
+
'# Code review to address',
|
|
137
|
+
'',
|
|
138
|
+
`Repository: ${repository.path}`,
|
|
139
|
+
repository.head ? `Reviewed at commit: ${repository.head}` : null,
|
|
140
|
+
`${summary.comments} comment(s) across ${summary.files} file(s), written ${document.generatedAt}.`,
|
|
141
|
+
'',
|
|
142
|
+
'Work through every comment below and make the change it asks for, or, if',
|
|
143
|
+
'you disagree, say why and leave the code alone. Comments are grouped by',
|
|
144
|
+
'file and ordered by line.',
|
|
145
|
+
'',
|
|
146
|
+
'Line numbers are from when the review was written, so treat them as a',
|
|
147
|
+
'hint: locate each comment by its `anchor` line instead, since your own',
|
|
148
|
+
'edits will shift everything below them. If an anchor no longer appears in',
|
|
149
|
+
'the file, say so rather than guessing at the intended location.',
|
|
150
|
+
''
|
|
151
|
+
].filter(line => line !== null);
|
|
152
|
+
|
|
153
|
+
let currentFile = null;
|
|
154
|
+
for (const comment of document.comments) {
|
|
155
|
+
if (comment.file !== currentFile) {
|
|
156
|
+
currentFile = comment.file;
|
|
157
|
+
lines.push(`## ${currentFile}`, '');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
lines.push(`### ${comment.id}`, '');
|
|
161
|
+
if (comment.anchor !== null) {
|
|
162
|
+
lines.push('Anchor line:', '', '```', comment.anchor, '```', '');
|
|
163
|
+
}
|
|
164
|
+
if (comment.selection) {
|
|
165
|
+
lines.push('Selected code:', '', '```', comment.selection, '```', '');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
lines.push(comment.body, '');
|
|
169
|
+
|
|
170
|
+
if (comment.followUps?.length > 0) {
|
|
171
|
+
lines.push('Follow-ups:', '');
|
|
172
|
+
comment.followUps.forEach((followUp, index) => {
|
|
173
|
+
lines.push(`${index + 1}. ${followUp.body}`);
|
|
174
|
+
});
|
|
175
|
+
lines.push('');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return `${lines.join('\n')}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { buildReviewDocument, filterReviewDocument, formatPrompt, SCHEMA };
|
package/lib/browser.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const childProcess = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Opening a URL in the user's default browser.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The command that opens a URL on this platform.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} platform a `process.platform` value
|
|
13
|
+
* @param {string} url
|
|
14
|
+
* @returns {{command: string, args: string[]}}
|
|
15
|
+
*/
|
|
16
|
+
function openCommand(platform, url) {
|
|
17
|
+
switch (platform) {
|
|
18
|
+
case 'darwin':
|
|
19
|
+
return { command: 'open', args: [url] };
|
|
20
|
+
case 'win32':
|
|
21
|
+
// `start` is a cmd builtin, not an executable. Its first quoted argument
|
|
22
|
+
// is taken as the window title, so an empty one has to come first or the
|
|
23
|
+
// URL itself would be swallowed as the title.
|
|
24
|
+
return { command: 'cmd', args: ['/c', 'start', '', url] };
|
|
25
|
+
default:
|
|
26
|
+
return { command: 'xdg-open', args: [url] };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Open a URL in the default browser.
|
|
32
|
+
*
|
|
33
|
+
* Failure here is not worth interrupting anyone over — a headless machine has
|
|
34
|
+
* no browser to open, and the caller has already printed the URL — so this
|
|
35
|
+
* resolves either way and reports what happened.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} url
|
|
38
|
+
* @param {object} [options]
|
|
39
|
+
* @param {string} [options.platform] defaults to the running platform
|
|
40
|
+
* @param {typeof childProcess.spawn} [options.spawn] injectable for tests
|
|
41
|
+
* @returns {Promise<boolean>} whether the opener was launched
|
|
42
|
+
*/
|
|
43
|
+
function openInBrowser(url, options = {}) {
|
|
44
|
+
const { platform = process.platform, spawn = childProcess.spawn } = options;
|
|
45
|
+
const { command, args } = openCommand(platform, url);
|
|
46
|
+
|
|
47
|
+
return new Promise(resolve => {
|
|
48
|
+
try {
|
|
49
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
50
|
+
|
|
51
|
+
// A missing opener fails asynchronously — spawn does not throw for it —
|
|
52
|
+
// so success has to wait for the 'spawn' event rather than assume it.
|
|
53
|
+
// Reporting success either way would make the caller's fallback message
|
|
54
|
+
// unreachable on exactly the machines that need it.
|
|
55
|
+
child.on('error', () => resolve(false));
|
|
56
|
+
child.on('spawn', () => {
|
|
57
|
+
// Let the opener outlive this process rather than hold the event loop.
|
|
58
|
+
child.unref();
|
|
59
|
+
resolve(true);
|
|
60
|
+
});
|
|
61
|
+
} catch {
|
|
62
|
+
resolve(false);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { openInBrowser, openCommand };
|