git-reviewer 2.14.1 → 2.16.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 +122 -4
- package/bin/reviewer.js +286 -7
- package/lib/agent.js +25 -5
- package/lib/answerer.js +191 -0
- package/lib/changes.js +83 -4
- package/lib/channel.js +286 -0
- package/lib/claude-background.js +316 -0
- package/lib/claude-command.js +90 -0
- package/lib/claude-sessions.js +229 -0
- package/lib/cli.js +197 -20
- package/lib/comments.js +175 -10
- package/lib/export.js +2 -2
- package/lib/presence.js +223 -0
- package/lib/review.js +3 -1
- package/lib/sessions.js +8 -4
- package/lib/store.js +290 -7
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +58 -0
- package/package.json +1 -1
- package/public/app.js +811 -119
- package/public/index.html +33 -0
- package/public/style.css +277 -0
- package/server.js +338 -43
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](test/)
|
|
7
7
|
[](package.json)
|
|
8
8
|
|
|
9
9
|
**English** · [简体中文](docs/README.zh-CN.md) · [日本語](docs/README.ja.md)
|
|
@@ -48,6 +48,10 @@ Nothing is installed into the project, there is nothing to sign in to, and
|
|
|
48
48
|
nothing leaves your machine. If you would rather not install it,
|
|
49
49
|
`npx git-reviewer` works the same way.
|
|
50
50
|
|
|
51
|
+
You can also ask while you read. Connect the Claude Code session that wrote the
|
|
52
|
+
code, and it answers each comment in its thread as you write it; see
|
|
53
|
+
[Ask the session that wrote the code](#ask-the-session-that-wrote-the-code).
|
|
54
|
+
|
|
51
55
|
When the review and the hand-off happen at different times, the two-step form
|
|
52
56
|
still works and is the one to reach for:
|
|
53
57
|
|
|
@@ -79,7 +83,9 @@ git reviewer export [repository] [--format json|prompt]
|
|
|
79
83
|
-p, --port <n> Port to listen on (default 4500; falls back to a free
|
|
80
84
|
port if that one is taken)
|
|
81
85
|
--no-open Print the URL instead of opening a browser
|
|
86
|
+
--no-intro Open the review without the first-run help
|
|
82
87
|
--staged Review only staged changes (HEAD vs index)
|
|
88
|
+
--no-untracked Review tracked working-tree changes only
|
|
83
89
|
-f, --format <f> Export format: json (default) or prompt
|
|
84
90
|
-h, --help Show help
|
|
85
91
|
-v, --version Show the version
|
|
@@ -103,6 +109,19 @@ changes. As with the default view, reload after changing what is staged. Saved c
|
|
|
103
109
|
are shared between views and may refer to code outside the current comparison;
|
|
104
110
|
check their anchors before applying them.
|
|
105
111
|
|
|
112
|
+
Wholly untracked directories with more than 200 files appear as one directory
|
|
113
|
+
row with a file count and no diff. Smaller directories stay reviewable file by
|
|
114
|
+
file. Tracked changes are always listed individually, and `.gitignore` still
|
|
115
|
+
applies. The scope note names any folded directories and their counts.
|
|
116
|
+
The headline counts individual files separately from folded folders and the
|
|
117
|
+
files inside them. Tracked changes appear first; reloading keeps your selection.
|
|
118
|
+
|
|
119
|
+
Use `git reviewer --no-untracked` to exclude all untracked files. If there are
|
|
120
|
+
no tracked working-tree changes, the view stays empty instead of loading the
|
|
121
|
+
last commit. Staged and commit comparisons keep their usual behavior.
|
|
122
|
+
Saved reviews and exports retain this tracked-only scope. The text review and
|
|
123
|
+
agent prompt explain the exclusion; older comments remain shared between views.
|
|
124
|
+
|
|
106
125
|
### When you are not standing in a repository
|
|
107
126
|
|
|
108
127
|
The page opens on a picker rather than on an empty box:
|
|
@@ -330,6 +349,7 @@ rather than guess when an anchor has vanished.
|
|
|
330
349
|
"comments": [
|
|
331
350
|
{
|
|
332
351
|
"id": "src/auth.js:10",
|
|
352
|
+
"key": "Xq3vB9aK",
|
|
333
353
|
"file": "src/auth.js",
|
|
334
354
|
"line": 10,
|
|
335
355
|
"anchor": " if (scheme !== SCHEME || !token) return null;",
|
|
@@ -369,6 +389,54 @@ comment, finding the code a comment quotes rather than trusting its line
|
|
|
369
389
|
number. Where an agent cannot leave a command running, it asks you to start
|
|
370
390
|
`git reviewer` yourself and reads the result with `git reviewer export`.
|
|
371
391
|
|
|
392
|
+
## Ask the session that wrote the code
|
|
393
|
+
|
|
394
|
+
The agent that wrote a change knows why it is the way it is: it read the code
|
|
395
|
+
around it, weighed the alternatives and chose one. reviewer can put your
|
|
396
|
+
comments to that session, and its answers appear in the thread, under the
|
|
397
|
+
comment, as they are written. You review in the browser and ask as you go:
|
|
398
|
+
"why throw here instead of repricing?" on one file, "which edge cases are not
|
|
399
|
+
tested?" on another, and each thread gets its answer from the agent that
|
|
400
|
+
wrote the code, in its own words.
|
|
401
|
+
|
|
402
|
+
**From the page.** Click **Ask Claude Code**, pick the session. Claude Code
|
|
403
|
+
keeps every session's transcript, and reviewer lists the ones that ran in
|
|
404
|
+
this repository. From then on every comment you write, and every reply you
|
|
405
|
+
add, goes to that session; threads that were already waiting go first. The
|
|
406
|
+
session is resumed in the background as a copy, so the original transcript
|
|
407
|
+
is left exactly as it was. The copy can read the repository and nothing else:
|
|
408
|
+
no edits, no shell, none of your MCP servers.
|
|
409
|
+
|
|
410
|
+
**Live, in your terminal.** To have the session itself answer, the one you
|
|
411
|
+
can watch and step into, connect it:
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
git reviewer connect # the most recent session that worked here
|
|
415
|
+
git reviewer connect --list # choose one
|
|
416
|
+
git reviewer connect --session 9fef2341
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
This resumes the session with reviewer attached as a Claude Code
|
|
420
|
+
[channel](https://code.claude.com/docs/en/channels). Every comment arrives in
|
|
421
|
+
the session as it is written, and it answers in the thread with the `reply`
|
|
422
|
+
tool, with your own permissions. Channels are a research preview, so Claude
|
|
423
|
+
Code asks you to confirm the development channel when it starts.
|
|
424
|
+
|
|
425
|
+
**Any agent.** Two commands are the whole protocol, so anything that can run a
|
|
426
|
+
command can take part:
|
|
427
|
+
|
|
428
|
+
```bash
|
|
429
|
+
git reviewer watch # one JSON line per comment waiting for an answer
|
|
430
|
+
git reviewer reply <key> "Because the spec says inclusive."
|
|
431
|
+
git reviewer reply <key> - < answer.md # or the answer on stdin
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Every comment has a stable `key`. It is in the export, and in each line `watch`
|
|
435
|
+
prints. Answers are stored as follow-ups with a `by` field naming who wrote
|
|
436
|
+
them, so an agent's reply is never mistaken for your own note. They export
|
|
437
|
+
labelled as such too, so a review handed over afterwards carries the whole
|
|
438
|
+
conversation.
|
|
439
|
+
|
|
372
440
|
## HTTP API
|
|
373
441
|
|
|
374
442
|
The UI is a client of this; nothing is hidden from you.
|
|
@@ -383,11 +451,61 @@ The UI is a client of this; nothing is hidden from you.
|
|
|
383
451
|
| `GET /api/alive` | Held open by the page; when the last one closes, a terminal run stops |
|
|
384
452
|
| `GET /api/file/:repoId/:path` | The file's diff, as structured lines |
|
|
385
453
|
| `GET /api/file-full/:repoId/:path` | Every line of the file, for context |
|
|
386
|
-
| `POST /api/save-comments` | Persist the current comments |
|
|
387
|
-
| `GET /api/load-comments/:repoId` | Restore saved comments |
|
|
454
|
+
| `POST /api/save-comments` | Persist the current comments. Send back the `lastUpdated` you loaded and the save is conditional: if the file has moved on since, it answers `409` with the current `comments` and `lastUpdated` instead of overwriting them |
|
|
455
|
+
| `GET /api/load-comments/:repoId` | Restore saved comments, with `lastUpdated`, the version token a conditional save sends back |
|
|
388
456
|
| `POST /api/submit-review` | Render the saved comments to a review file |
|
|
457
|
+
| `GET /api/events/:repoId` | Server-sent events for an open review: `comments` whenever the saved comments change, whoever changed them; `agents` as agents connect, leave or start on a thread; `partial` with an answer still being written |
|
|
458
|
+
| `GET /api/agent/sessions/:repoId` | The Claude Code sessions that ran in this repository, newest first |
|
|
459
|
+
| `POST /api/agent/connect` | `{repoId, sessionId, fork}`: have that session answer the review's comments in the background. `fork` defaults to `true`, a copy of the session |
|
|
460
|
+
| `POST /api/agent/disconnect` | `{repoId}`: stop the background session |
|
|
389
461
|
| `DELETE /api/cleanup/:repoId` | End the session; files on disk are untouched |
|
|
390
462
|
|
|
463
|
+
### POST request bodies
|
|
464
|
+
|
|
465
|
+
`POST /api/load-repo` opens a repository. `repoPath` is required; choose a comparison or staged changes:
|
|
466
|
+
|
|
467
|
+
```json
|
|
468
|
+
{"repoPath":"/home/you/work/api","base":"main","head":"feature"}
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
To review a commit range, use `"commits": {"from":"abc123","to":"def456"}` instead of `base`/`head`. To review staged changes, use `"staged": true` without comparison fields. `head` is optional when using `base`.
|
|
472
|
+
|
|
473
|
+
For tracked working-tree changes only, send `"untracked": false`. It defaults
|
|
474
|
+
to `true`. Folded directory entries carry `directory: true` and `fileCount`;
|
|
475
|
+
they have no file diff.
|
|
476
|
+
|
|
477
|
+
`POST /api/save-comments` persists comments for an open session:
|
|
478
|
+
|
|
479
|
+
```json
|
|
480
|
+
{
|
|
481
|
+
"repoId": "<repoId returned by load-repo>",
|
|
482
|
+
"comments": [
|
|
483
|
+
{
|
|
484
|
+
"file": "src/app.js",
|
|
485
|
+
"line": 12,
|
|
486
|
+
"lineContent": "const value = 1;",
|
|
487
|
+
"text": "Consider renaming this variable.",
|
|
488
|
+
"selectedText": "const value",
|
|
489
|
+
"followUps": [{"text":"Please also update its uses.","timestamp":"2026-01-01T00:00:00.000Z"}]
|
|
490
|
+
}
|
|
491
|
+
]
|
|
492
|
+
}
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Each comment requires `file` (non-empty string), `line` (positive integer), and `text` (non-empty string). `lineContent`, `selectedText`, `followUps` and `key` are optional. A comment sent without a `key` gets one derived from its content, so the same list posted twice keeps its identities. Follow-ups take `key`, `timestamp`, and `by` for a reply from someone other than the reviewer. Follow-ups already saved for a comment are kept even when a save leaves them out, so a stale save cannot erase an agent's answer.
|
|
496
|
+
|
|
497
|
+
Add `"lastUpdated"` with the value `load-comments` returned (`null` for a review
|
|
498
|
+
not saved yet) to make the save conditional, as the page does. Without it the
|
|
499
|
+
save overwrites whatever is on disk, as it always has.
|
|
500
|
+
|
|
501
|
+
`POST /api/submit-review` renders the previously saved comments:
|
|
502
|
+
|
|
503
|
+
```json
|
|
504
|
+
{"repoId":"<repoId returned by load-repo>"}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
The startup `--json` output does not include `repoId`: no session exists at startup. A client obtains its own `repoId` by calling `POST /api/load-repo`.
|
|
508
|
+
|
|
391
509
|
A `repoId` is a random per-session handle, not a path. Requests that resolve
|
|
392
510
|
outside the opened repository are refused, and a path that names nothing gets a
|
|
393
511
|
404.
|
|
@@ -410,7 +528,7 @@ rather than a public issue.
|
|
|
410
528
|
|
|
411
529
|
```bash
|
|
412
530
|
npm install # 3 dependencies, no build step, ~6MB
|
|
413
|
-
npm test #
|
|
531
|
+
npm test # 500 tests
|
|
414
532
|
npm run test:watch
|
|
415
533
|
npm run test:coverage
|
|
416
534
|
```
|
package/bin/reviewer.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require('fs').promises;
|
|
5
5
|
const path = require('path');
|
|
6
|
+
const { spawn } = require('child_process');
|
|
6
7
|
|
|
7
8
|
const { parseArgs, buildUrl, UsageError, usage, invokedAs } = require('../lib/cli');
|
|
8
9
|
const { homeRelative } = require('../lib/paths');
|
|
@@ -18,6 +19,14 @@ const {
|
|
|
18
19
|
} = require('../lib/export');
|
|
19
20
|
const { startServer, DEFAULT_PORT, DEFAULT_HOST } = require('../server');
|
|
20
21
|
const { version } = require('../package.json');
|
|
22
|
+
const { updateSavedComments } = require('../lib/store');
|
|
23
|
+
const { watchComments } = require('../lib/watch');
|
|
24
|
+
const { announce } = require('../lib/presence');
|
|
25
|
+
const { mintKey } = require('../lib/comments');
|
|
26
|
+
const { appendReply, awaitingAnswer, newReviewerMessages, describeThread, lastAuthor } = require('../lib/threads');
|
|
27
|
+
const { listClaudeSessions } = require('../lib/claude-sessions');
|
|
28
|
+
const { runChannel } = require('../lib/channel');
|
|
29
|
+
const { resolveClaudeCommand } = require('../lib/claude-command');
|
|
21
30
|
|
|
22
31
|
// What to call this command when it talks about itself: whatever the person
|
|
23
32
|
// typed, so a hint can be pasted back as-is. See invokedAs in lib/cli.js.
|
|
@@ -92,6 +101,9 @@ async function exportReview(options) {
|
|
|
92
101
|
error instanceof NoMatchingCommentsError ||
|
|
93
102
|
error instanceof ReviewOwnershipError
|
|
94
103
|
) {
|
|
104
|
+
if (repoPath === given && error instanceof NoReviewError && !(await repoRoot(given))) {
|
|
105
|
+
throw new UsageError(`${given} is not inside a git repository, and no review is saved for it.`);
|
|
106
|
+
}
|
|
95
107
|
throw new UsageError(error.message);
|
|
96
108
|
}
|
|
97
109
|
throw error;
|
|
@@ -104,6 +116,244 @@ async function exportReview(options) {
|
|
|
104
116
|
);
|
|
105
117
|
}
|
|
106
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The root of the repository a command is about, or a usage error.
|
|
121
|
+
*
|
|
122
|
+
* @param {string|null} given the repository named on the command line
|
|
123
|
+
* @returns {Promise<string>}
|
|
124
|
+
*/
|
|
125
|
+
async function requireRepository(given) {
|
|
126
|
+
const target = given ?? process.cwd();
|
|
127
|
+
const root = await repoRoot(target);
|
|
128
|
+
if (!root) throw new UsageError(`${target} is not inside a git repository.`);
|
|
129
|
+
return root;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** How a session is shown in a list: its name, or what it was first asked. */
|
|
133
|
+
function sessionLabel(session) {
|
|
134
|
+
return session.title ?? session.firstPrompt ?? '(no prompt)';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `reviewer connect`: open the Claude Code session that worked here, with
|
|
139
|
+
* reviewer attached as a channel.
|
|
140
|
+
*
|
|
141
|
+
* Every comment the reviewer writes then arrives in that session as it is
|
|
142
|
+
* written, and the session answers in the thread with the `reply` tool. This
|
|
143
|
+
* is the session in a terminal, live: the person can watch it work and step
|
|
144
|
+
* in. During the channels research preview Claude Code asks once, at
|
|
145
|
+
* startup, to confirm a development channel; that is expected.
|
|
146
|
+
*
|
|
147
|
+
* @param {import('../lib/cli').CliOptions} options
|
|
148
|
+
*/
|
|
149
|
+
async function connectCommand(options) {
|
|
150
|
+
const repoPath = await requireRepository(options.repoPath);
|
|
151
|
+
const sessions = await listClaudeSessions(repoPath, { limit: 50 });
|
|
152
|
+
|
|
153
|
+
if (options.list) {
|
|
154
|
+
if (sessions.length === 0) {
|
|
155
|
+
note(`No Claude Code session has worked in ${repoPath} yet.`);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
for (const session of sessions) {
|
|
159
|
+
process.stdout.write(`${session.id} ${session.updatedAt.slice(0, 16).replace('T', ' ')} ${sessionLabel(session)}\n`);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let chosen = null;
|
|
165
|
+
if (options.session) {
|
|
166
|
+
const matches = sessions.filter(session => session.id.startsWith(options.session));
|
|
167
|
+
if (matches.length !== 1) {
|
|
168
|
+
throw new UsageError(matches.length === 0
|
|
169
|
+
? `No Claude Code session ${options.session} has worked in ${repoPath}. ${NAME} connect --list shows them.`
|
|
170
|
+
: `${options.session} matches ${matches.length} sessions; give more of the id.`);
|
|
171
|
+
}
|
|
172
|
+
[chosen] = matches;
|
|
173
|
+
} else {
|
|
174
|
+
// The most recent session that is not one of reviewer's own copies:
|
|
175
|
+
// asking a copy made to answer an earlier review is asking second-hand.
|
|
176
|
+
chosen = sessions.find(session => !session.title?.startsWith('Review answers:')) ?? sessions[0] ?? null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const title = chosen ? sessionLabel(chosen) : null;
|
|
180
|
+
const mcpConfig = {
|
|
181
|
+
mcpServers: {
|
|
182
|
+
reviewer: {
|
|
183
|
+
command: process.execPath,
|
|
184
|
+
args: [path.resolve(__filename), 'channel', repoPath],
|
|
185
|
+
...(title ? { env: { REVIEWER_SESSION_TITLE: title } } : {})
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const args = [
|
|
190
|
+
...(chosen ? ['--resume', chosen.id] : []),
|
|
191
|
+
...(chosen && options.fork ? ['--fork-session'] : []),
|
|
192
|
+
'--mcp-config', JSON.stringify(mcpConfig),
|
|
193
|
+
'--dangerously-load-development-channels', 'server:reviewer'
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
note(chosen
|
|
197
|
+
? `\n Resuming Claude Code session ${chosen.id}${options.fork ? ' as a copy' : ''}\n ${title}\n`
|
|
198
|
+
: `\n No Claude Code session has worked in ${repoPath} yet; starting a new one.\n`);
|
|
199
|
+
note(' Claude Code will ask you to confirm the reviewer channel (it is a research preview).');
|
|
200
|
+
note(` Then every comment you write in reviewer arrives here, and the answers appear under them.\n`);
|
|
201
|
+
|
|
202
|
+
const claude = process.env.REVIEWER_CLAUDE || 'claude';
|
|
203
|
+
// A session whose directory has since been removed is resumed from the
|
|
204
|
+
// repository root rather than failing to spawn at all.
|
|
205
|
+
const cwd = chosen ? await fs.access(chosen.cwd).then(() => chosen.cwd, () => repoPath) : repoPath;
|
|
206
|
+
const child = spawn(resolveClaudeCommand(claude), args, { cwd, stdio: 'inherit' });
|
|
207
|
+
|
|
208
|
+
// Ctrl+C belongs to Claude Code while it runs -- it interrupts a turn, it
|
|
209
|
+
// does not quit -- and the terminal delivers it to both processes. This one
|
|
210
|
+
// only waits, so it must not die of the same keypress and leave Claude Code
|
|
211
|
+
// running on a terminal the shell has already taken back. A request to
|
|
212
|
+
// stop that comes from outside is passed on instead.
|
|
213
|
+
const ignore = () => {};
|
|
214
|
+
const forward = signal => child.kill(signal);
|
|
215
|
+
process.on('SIGINT', ignore);
|
|
216
|
+
process.on('SIGQUIT', ignore);
|
|
217
|
+
process.on('SIGTERM', forward);
|
|
218
|
+
process.on('SIGHUP', forward);
|
|
219
|
+
try {
|
|
220
|
+
await new Promise((resolve, reject) => {
|
|
221
|
+
child.on('error', error => reject(['ENOENT', 'EINVAL'].includes(error.code)
|
|
222
|
+
? new Error(`Could not run "${claude}". Is Claude Code installed and on your PATH? Set REVIEWER_CLAUDE to its path otherwise` +
|
|
223
|
+
`${process.platform === 'win32' ? ' (the full path of claude.exe)' : ''}.`)
|
|
224
|
+
: error));
|
|
225
|
+
child.on('exit', (code, signal) => {
|
|
226
|
+
process.exitCode = code ?? (signal ? 1 : 0);
|
|
227
|
+
resolve();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
} finally {
|
|
231
|
+
process.off('SIGINT', ignore);
|
|
232
|
+
process.off('SIGQUIT', ignore);
|
|
233
|
+
process.off('SIGTERM', forward);
|
|
234
|
+
process.off('SIGHUP', forward);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `reviewer channel`: the MCP server `connect` asks Claude Code to run.
|
|
240
|
+
*
|
|
241
|
+
* @param {import('../lib/cli').CliOptions} options
|
|
242
|
+
*/
|
|
243
|
+
async function channelCommand(options) {
|
|
244
|
+
const repoPath = await requireRepository(options.repoPath);
|
|
245
|
+
await runChannel({
|
|
246
|
+
repoPath,
|
|
247
|
+
reviewsDir: reviewsDir(),
|
|
248
|
+
version,
|
|
249
|
+
title: process.env.REVIEWER_SESSION_TITLE || null
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* `reviewer reply <key> <text>`: answer in a thread from the command line.
|
|
255
|
+
*
|
|
256
|
+
* For an agent following a review with `reviewer watch`, and anything else
|
|
257
|
+
* that can run a command. Inside Claude Code the reply is signed `claude`
|
|
258
|
+
* and carries the session's id; elsewhere it is from `agent` unless `--as`
|
|
259
|
+
* says who.
|
|
260
|
+
*
|
|
261
|
+
* @param {import('../lib/cli').CliOptions} options
|
|
262
|
+
*/
|
|
263
|
+
async function replyCommand(options) {
|
|
264
|
+
const repoPath = await requireRepository(options.repoPath);
|
|
265
|
+
if (options.textFromStdin) {
|
|
266
|
+
const chunks = [];
|
|
267
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
268
|
+
options.text = Buffer.concat(chunks).toString('utf-8').trim();
|
|
269
|
+
if (options.text === '') throw new UsageError('reply - read nothing from stdin');
|
|
270
|
+
}
|
|
271
|
+
const session = process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
272
|
+
const by = options.as ?? (session ? 'claude' : 'agent');
|
|
273
|
+
|
|
274
|
+
let thread = null;
|
|
275
|
+
await updateSavedComments(reviewsDir(), repoPath, current => {
|
|
276
|
+
thread = current.comments.find(comment => comment.key === options.key) ?? null;
|
|
277
|
+
if (!thread) return null;
|
|
278
|
+
return {
|
|
279
|
+
comments: appendReply(current.comments, options.key, {
|
|
280
|
+
key: mintKey(),
|
|
281
|
+
text: options.text,
|
|
282
|
+
timestamp: new Date().toISOString(),
|
|
283
|
+
by,
|
|
284
|
+
...(session ? { session } : {})
|
|
285
|
+
})
|
|
286
|
+
};
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// Loudly: a reply that went nowhere and exited 0 would look, to whatever
|
|
290
|
+
// ran it, exactly like one the reviewer can read.
|
|
291
|
+
if (!thread) {
|
|
292
|
+
console.error(`error: no comment with key ${options.key} in the review of ${repoPath}.`);
|
|
293
|
+
console.error(` ${NAME} export ${homeRelative(repoPath)} lists the comments and their keys.`);
|
|
294
|
+
process.exitCode = 1;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
note(`Replied on ${thread.file}:${thread.line} as ${by}.`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* `reviewer watch`: one JSON line per thing the reviewer says, as they say it.
|
|
302
|
+
*
|
|
303
|
+
* Threads already waiting for an answer come first, then each new comment
|
|
304
|
+
* and each reply the reviewer adds in a thread. Made for an agent that can
|
|
305
|
+
* follow a command's output -- Claude Code's Monitor tool turns each line
|
|
306
|
+
* into a message -- and answer with `reviewer reply`.
|
|
307
|
+
*
|
|
308
|
+
* @param {import('../lib/cli').CliOptions} options
|
|
309
|
+
*/
|
|
310
|
+
async function watchCommand(options) {
|
|
311
|
+
const repoPath = await requireRepository(options.repoPath);
|
|
312
|
+
const dir = reviewsDir();
|
|
313
|
+
const session = process.env.CLAUDE_CODE_SESSION_ID || null;
|
|
314
|
+
const presence = announce(dir, repoPath, { kind: 'watch', agent: session ? 'claude' : 'agent', session, title: null });
|
|
315
|
+
const working = new Set();
|
|
316
|
+
|
|
317
|
+
const emit = comment => {
|
|
318
|
+
const followUps = (comment.followUps ?? []).filter(followUp => !followUp.by);
|
|
319
|
+
const latest = followUps.length > 0 ? followUps[followUps.length - 1].text : comment.text;
|
|
320
|
+
process.stdout.write(`${JSON.stringify({
|
|
321
|
+
key: comment.key,
|
|
322
|
+
file: comment.file,
|
|
323
|
+
line: comment.line,
|
|
324
|
+
text: latest,
|
|
325
|
+
thread: describeThread(comment)
|
|
326
|
+
})}\n`);
|
|
327
|
+
working.add(comment.key);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
let known = null;
|
|
331
|
+
const watcher = watchComments(dir, repoPath, state => {
|
|
332
|
+
if (known === null) {
|
|
333
|
+
awaitingAnswer(state.comments).forEach(emit);
|
|
334
|
+
} else {
|
|
335
|
+
for (const { comment } of newReviewerMessages(known, state.comments)) emit(comment);
|
|
336
|
+
}
|
|
337
|
+
for (const key of working) {
|
|
338
|
+
const thread = state.comments.find(comment => comment.key === key);
|
|
339
|
+
if (!thread || lastAuthor(thread) !== null) working.delete(key);
|
|
340
|
+
}
|
|
341
|
+
presence.update({ working: [...working] });
|
|
342
|
+
known = state.comments;
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
note(`Watching the review of ${repoPath}. Reply with: ${NAME} reply <key> "<text>"`);
|
|
346
|
+
|
|
347
|
+
await new Promise(resolve => {
|
|
348
|
+
const finish = () => resolve();
|
|
349
|
+
process.once('SIGINT', finish);
|
|
350
|
+
process.once('SIGTERM', finish);
|
|
351
|
+
process.stdout.on('error', finish); // whatever was reading has gone
|
|
352
|
+
});
|
|
353
|
+
watcher.close();
|
|
354
|
+
await presence.close();
|
|
355
|
+
}
|
|
356
|
+
|
|
107
357
|
/**
|
|
108
358
|
* Which repository the page should open.
|
|
109
359
|
*
|
|
@@ -162,6 +412,10 @@ async function main() {
|
|
|
162
412
|
if (options.command === 'export') {
|
|
163
413
|
return exportReview(options);
|
|
164
414
|
}
|
|
415
|
+
if (options.command === 'connect') return connectCommand(options);
|
|
416
|
+
if (options.command === 'channel') return channelCommand(options);
|
|
417
|
+
if (options.command === 'reply') return replyCommand(options);
|
|
418
|
+
if (options.command === 'watch') return watchCommand(options);
|
|
165
419
|
|
|
166
420
|
const repoPath = await repositoryToOpen(options.repoPath);
|
|
167
421
|
|
|
@@ -197,7 +451,11 @@ async function main() {
|
|
|
197
451
|
|
|
198
452
|
let handedOff = false;
|
|
199
453
|
const stop = () => {
|
|
200
|
-
|
|
454
|
+
// A background Claude Code session answering comments stops with us:
|
|
455
|
+
// asked to straight away, and waited for before the process exits.
|
|
456
|
+
Promise.resolve(server.stopAgents?.()).catch(() => {}).then(() => {
|
|
457
|
+
server.close(() => process.exit(0));
|
|
458
|
+
});
|
|
201
459
|
// Don't wait forever on a browser holding the connection open.
|
|
202
460
|
setTimeout(() => process.exit(0), 2000).unref();
|
|
203
461
|
};
|
|
@@ -250,13 +508,34 @@ async function main() {
|
|
|
250
508
|
options.port ?? (Number(process.env.PORT) || DEFAULT_PORT),
|
|
251
509
|
{ onReviewSubmitted, handoff: handingOff, onIdle }
|
|
252
510
|
);
|
|
253
|
-
const
|
|
511
|
+
const port = server.address().port;
|
|
512
|
+
const url = buildUrl(
|
|
513
|
+
`http://${DEFAULT_HOST}:${port}`,
|
|
514
|
+
repoPath,
|
|
515
|
+
options.staged,
|
|
516
|
+
options.noIntro,
|
|
517
|
+
options.untracked
|
|
518
|
+
);
|
|
254
519
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
:
|
|
520
|
+
if (options.json) {
|
|
521
|
+
// One machine-readable line instead of the banner, so an agent can read
|
|
522
|
+
// the URL without a regex that breaks the day the banner is reworded.
|
|
523
|
+
// stderr, like everything else here: stdout is reserved for the review.
|
|
524
|
+
// No repoId: none exists yet. The page creates one via POST /api/load-repo.
|
|
525
|
+
note(JSON.stringify({
|
|
526
|
+
url,
|
|
527
|
+
port,
|
|
528
|
+
repository: repoPath,
|
|
529
|
+
pid: process.pid,
|
|
530
|
+
version
|
|
531
|
+
}));
|
|
532
|
+
} else {
|
|
533
|
+
note(`\n Code Reviewer ${url}`);
|
|
534
|
+
if (repoPath) note(` reviewing ${repoPath}`);
|
|
535
|
+
note(handingOff
|
|
536
|
+
? '\n Submit the review in the browser and it will be written here.\n'
|
|
537
|
+
: '\n Close the tab when you are done, or press Ctrl+C.\n');
|
|
538
|
+
}
|
|
260
539
|
|
|
261
540
|
if (options.open && !(await openInBrowser(url))) {
|
|
262
541
|
note(' Could not open a browser — open the URL above yourself.\n');
|
package/lib/agent.js
CHANGED
|
@@ -23,6 +23,7 @@ const SCHEMA = 'code-review/v1';
|
|
|
23
23
|
* @property {string} generatedAt ISO 8601
|
|
24
24
|
* @property {{path: string, name: string, head: string|null, branch: string|null}} repository
|
|
25
25
|
* @property {'working'|'staged'|'lastCommit'|'range'|null} mode
|
|
26
|
+
* @property {boolean} [untracked] false when the working comparison excluded untracked files
|
|
26
27
|
* @property {{comments: number, files: number}} summary
|
|
27
28
|
* @property {AgentComment[]} comments
|
|
28
29
|
*/
|
|
@@ -30,12 +31,15 @@ const SCHEMA = 'code-review/v1';
|
|
|
30
31
|
/**
|
|
31
32
|
* @typedef {object} AgentComment
|
|
32
33
|
* @property {string} id stable within a document
|
|
34
|
+
* @property {string} [key] stable across documents: the key the comment was
|
|
35
|
+
* saved with, which `reviewer reply` takes
|
|
33
36
|
* @property {string} file repo-relative path
|
|
34
37
|
* @property {number} line line number when the comment was written
|
|
35
38
|
* @property {string|null} anchor the exact source line, for relocating the comment
|
|
36
39
|
* @property {string} [selection] the snippet the reviewer highlighted
|
|
37
40
|
* @property {string} body
|
|
38
|
-
* @property {Array<{body: string, at?: string}>} [followUps]
|
|
41
|
+
* @property {Array<{body: string, at?: string, by?: string}>} [followUps]
|
|
42
|
+
* `by` names an agent that answered in the thread; absent means the reviewer
|
|
39
43
|
*/
|
|
40
44
|
|
|
41
45
|
/**
|
|
@@ -48,9 +52,10 @@ const SCHEMA = 'code-review/v1';
|
|
|
48
52
|
* @param {string|null} [args.head] the commit the review was written against
|
|
49
53
|
* @param {string|null} [args.branch]
|
|
50
54
|
* @param {string|null} [args.mode]
|
|
55
|
+
* @param {boolean} [args.untracked]
|
|
51
56
|
* @returns {ReviewDocument}
|
|
52
57
|
*/
|
|
53
|
-
function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null, range = null }) {
|
|
58
|
+
function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null, range = null, untracked }) {
|
|
54
59
|
const grouped = groupByFile(comments);
|
|
55
60
|
|
|
56
61
|
return {
|
|
@@ -63,6 +68,9 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
|
|
|
63
68
|
branch
|
|
64
69
|
},
|
|
65
70
|
mode,
|
|
71
|
+
// Older saves say nothing about untracked files. Only record an explicit
|
|
72
|
+
// exclusion in a working comparison; other scopes do not use this flag.
|
|
73
|
+
...(mode === 'working' && untracked === false ? { untracked: false } : {}),
|
|
66
74
|
// Omitted rather than null when there is no range, so `"range" in
|
|
67
75
|
// document` is a meaningful test -- the same rule the optional comment
|
|
68
76
|
// fields follow. A `working` review has no range and never will.
|
|
@@ -161,6 +169,10 @@ function toAgentComment(comment, id) {
|
|
|
161
169
|
// Derived from file and line so it stays stable across exports, with a
|
|
162
170
|
// suffix where that is not enough on its own. See `assignIds`.
|
|
163
171
|
id,
|
|
172
|
+
// The saved key survives the code moving and the comment being edited,
|
|
173
|
+
// which `id` does not. Added beside it rather than replacing it, so
|
|
174
|
+
// nothing reading `id` today has to change.
|
|
175
|
+
...(comment.key ? { key: comment.key } : {}),
|
|
164
176
|
file: comment.file,
|
|
165
177
|
line: comment.line,
|
|
166
178
|
anchor: comment.lineContent ?? null,
|
|
@@ -173,6 +185,7 @@ function toAgentComment(comment, id) {
|
|
|
173
185
|
entry.followUps = comment.followUps.map(followUp => {
|
|
174
186
|
const item = { body: followUp.text };
|
|
175
187
|
if (followUp.timestamp) item.at = followUp.timestamp;
|
|
188
|
+
if (followUp.by) item.by = followUp.by;
|
|
176
189
|
return item;
|
|
177
190
|
});
|
|
178
191
|
}
|
|
@@ -198,10 +211,15 @@ function toAgentComment(comment, id) {
|
|
|
198
211
|
* @param {ReviewDocument} document
|
|
199
212
|
* @returns {string|null}
|
|
200
213
|
*/
|
|
201
|
-
function describeScope({ repository, mode, range }) {
|
|
214
|
+
function describeScope({ repository, mode, range, untracked }) {
|
|
202
215
|
const at = repository.head ? ` (${repository.head})` : '';
|
|
203
216
|
|
|
204
217
|
if (mode === 'working') {
|
|
218
|
+
if (untracked === false) {
|
|
219
|
+
return `Comparison: tracked changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}. ` +
|
|
220
|
+
'Untracked files are excluded from this comparison. Saved comments are shared between review views ' +
|
|
221
|
+
'and may refer to changes outside this comparison; check their anchors before applying them.';
|
|
222
|
+
}
|
|
205
223
|
return `Reviewed: uncommitted changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}.`;
|
|
206
224
|
}
|
|
207
225
|
|
|
@@ -312,8 +330,10 @@ function formatPrompt(document) {
|
|
|
312
330
|
|
|
313
331
|
if (comment.followUps?.length > 0) {
|
|
314
332
|
lines.push('Follow-ups:', '');
|
|
333
|
+
// An agent's answer is labelled as one. Unlabelled, "I'd keep it
|
|
334
|
+
// inclusive" would read as the reviewer's own instruction.
|
|
315
335
|
comment.followUps.forEach((followUp, index) => {
|
|
316
|
-
lines.push(`${index + 1}. ${followUp.body}`);
|
|
336
|
+
lines.push(`${index + 1}. ${followUp.by ? `(${followUp.by}) ` : ''}${followUp.body}`);
|
|
317
337
|
});
|
|
318
338
|
lines.push('');
|
|
319
339
|
}
|
|
@@ -322,4 +342,4 @@ function formatPrompt(document) {
|
|
|
322
342
|
return `${lines.join('\n')}`;
|
|
323
343
|
}
|
|
324
344
|
|
|
325
|
-
module.exports = { buildReviewDocument, filterReviewDocument, formatPrompt, SCHEMA };
|
|
345
|
+
module.exports = { buildReviewDocument, filterReviewDocument, formatPrompt, describeScope, SCHEMA };
|