git-reviewer 2.15.0 → 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 +66 -5
- package/bin/reviewer.js +256 -1
- package/lib/agent.js +12 -2
- package/lib/answerer.js +191 -0
- 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 +147 -7
- package/lib/comments.js +175 -10
- package/lib/presence.js +223 -0
- package/lib/store.js +284 -9
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +47 -0
- package/package.json +1 -1
- package/public/app.js +739 -107
- package/public/index.html +33 -0
- package/public/style.css +271 -0
- package/server.js +307 -34
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
|
|
|
@@ -345,6 +349,7 @@ rather than guess when an anchor has vanished.
|
|
|
345
349
|
"comments": [
|
|
346
350
|
{
|
|
347
351
|
"id": "src/auth.js:10",
|
|
352
|
+
"key": "Xq3vB9aK",
|
|
348
353
|
"file": "src/auth.js",
|
|
349
354
|
"line": 10,
|
|
350
355
|
"anchor": " if (scheme !== SCHEME || !token) return null;",
|
|
@@ -384,6 +389,54 @@ comment, finding the code a comment quotes rather than trusting its line
|
|
|
384
389
|
number. Where an agent cannot leave a command running, it asks you to start
|
|
385
390
|
`git reviewer` yourself and reads the result with `git reviewer export`.
|
|
386
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
|
+
|
|
387
440
|
## HTTP API
|
|
388
441
|
|
|
389
442
|
The UI is a client of this; nothing is hidden from you.
|
|
@@ -398,9 +451,13 @@ The UI is a client of this; nothing is hidden from you.
|
|
|
398
451
|
| `GET /api/alive` | Held open by the page; when the last one closes, a terminal run stops |
|
|
399
452
|
| `GET /api/file/:repoId/:path` | The file's diff, as structured lines |
|
|
400
453
|
| `GET /api/file-full/:repoId/:path` | Every line of the file, for context |
|
|
401
|
-
| `POST /api/save-comments` | Persist the current comments |
|
|
402
|
-
| `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 |
|
|
403
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 |
|
|
404
461
|
| `DELETE /api/cleanup/:repoId` | End the session; files on disk are untouched |
|
|
405
462
|
|
|
406
463
|
### POST request bodies
|
|
@@ -435,7 +492,11 @@ they have no file diff.
|
|
|
435
492
|
}
|
|
436
493
|
```
|
|
437
494
|
|
|
438
|
-
Each comment requires `file` (non-empty string), `line` (positive integer), and `text` (non-empty string). `lineContent`, `selectedText`, and `
|
|
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.
|
|
439
500
|
|
|
440
501
|
`POST /api/submit-review` renders the previously saved comments:
|
|
441
502
|
|
|
@@ -467,7 +528,7 @@ rather than a public issue.
|
|
|
467
528
|
|
|
468
529
|
```bash
|
|
469
530
|
npm install # 3 dependencies, no build step, ~6MB
|
|
470
|
-
npm test #
|
|
531
|
+
npm test # 500 tests
|
|
471
532
|
npm run test:watch
|
|
472
533
|
npm run test:coverage
|
|
473
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.
|
|
@@ -107,6 +116,244 @@ async function exportReview(options) {
|
|
|
107
116
|
);
|
|
108
117
|
}
|
|
109
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
|
+
|
|
110
357
|
/**
|
|
111
358
|
* Which repository the page should open.
|
|
112
359
|
*
|
|
@@ -165,6 +412,10 @@ async function main() {
|
|
|
165
412
|
if (options.command === 'export') {
|
|
166
413
|
return exportReview(options);
|
|
167
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);
|
|
168
419
|
|
|
169
420
|
const repoPath = await repositoryToOpen(options.repoPath);
|
|
170
421
|
|
|
@@ -200,7 +451,11 @@ async function main() {
|
|
|
200
451
|
|
|
201
452
|
let handedOff = false;
|
|
202
453
|
const stop = () => {
|
|
203
|
-
|
|
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
|
+
});
|
|
204
459
|
// Don't wait forever on a browser holding the connection open.
|
|
205
460
|
setTimeout(() => process.exit(0), 2000).unref();
|
|
206
461
|
};
|
package/lib/agent.js
CHANGED
|
@@ -31,12 +31,15 @@ const SCHEMA = 'code-review/v1';
|
|
|
31
31
|
/**
|
|
32
32
|
* @typedef {object} AgentComment
|
|
33
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
|
|
34
36
|
* @property {string} file repo-relative path
|
|
35
37
|
* @property {number} line line number when the comment was written
|
|
36
38
|
* @property {string|null} anchor the exact source line, for relocating the comment
|
|
37
39
|
* @property {string} [selection] the snippet the reviewer highlighted
|
|
38
40
|
* @property {string} body
|
|
39
|
-
* @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
|
|
40
43
|
*/
|
|
41
44
|
|
|
42
45
|
/**
|
|
@@ -166,6 +169,10 @@ function toAgentComment(comment, id) {
|
|
|
166
169
|
// Derived from file and line so it stays stable across exports, with a
|
|
167
170
|
// suffix where that is not enough on its own. See `assignIds`.
|
|
168
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 } : {}),
|
|
169
176
|
file: comment.file,
|
|
170
177
|
line: comment.line,
|
|
171
178
|
anchor: comment.lineContent ?? null,
|
|
@@ -178,6 +185,7 @@ function toAgentComment(comment, id) {
|
|
|
178
185
|
entry.followUps = comment.followUps.map(followUp => {
|
|
179
186
|
const item = { body: followUp.text };
|
|
180
187
|
if (followUp.timestamp) item.at = followUp.timestamp;
|
|
188
|
+
if (followUp.by) item.by = followUp.by;
|
|
181
189
|
return item;
|
|
182
190
|
});
|
|
183
191
|
}
|
|
@@ -322,8 +330,10 @@ function formatPrompt(document) {
|
|
|
322
330
|
|
|
323
331
|
if (comment.followUps?.length > 0) {
|
|
324
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.
|
|
325
335
|
comment.followUps.forEach((followUp, index) => {
|
|
326
|
-
lines.push(`${index + 1}. ${followUp.body}`);
|
|
336
|
+
lines.push(`${index + 1}. ${followUp.by ? `(${followUp.by}) ` : ''}${followUp.body}`);
|
|
327
337
|
});
|
|
328
338
|
lines.push('');
|
|
329
339
|
}
|
package/lib/answerer.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
|
|
5
|
+
const { updateSavedComments } = require('./store');
|
|
6
|
+
const { watchComments } = require('./watch');
|
|
7
|
+
const { announce, listAgents, watchAgents } = require('./presence');
|
|
8
|
+
const { mintKey } = require('./comments');
|
|
9
|
+
const { awaitingAnswer, newReviewerMessages, appendReply, lastAuthor, followUpIdentity } = require('./threads');
|
|
10
|
+
const { questionFor } = require('./claude-background');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Keep a review's threads answered by a background Claude Code session.
|
|
14
|
+
*
|
|
15
|
+
* Everything the reviewer says -- a new comment, a reply in a thread, from
|
|
16
|
+
* this tab or any other -- reaches the session through the comments file,
|
|
17
|
+
* and every answer goes back the same way, as a follow-up written `by:
|
|
18
|
+
* claude`. Nothing here talks to the page directly except the text of an
|
|
19
|
+
* answer still being written, which is not worth a file write per word.
|
|
20
|
+
*
|
|
21
|
+
* Threads already waiting when it connects are answered first, oldest first.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The reviewer's messages in a thread, by identity: the comment, and their follow-ups. */
|
|
25
|
+
function reviewerMessages(comment) {
|
|
26
|
+
return new Set([
|
|
27
|
+
`comment:${comment.key}`,
|
|
28
|
+
...(comment.followUps ?? []).filter(followUp => !followUp.by).map(followUpIdentity)
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class Answerer extends EventEmitter {
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} options
|
|
35
|
+
* @param {string} options.reviewsDir
|
|
36
|
+
* @param {string} options.repoPath
|
|
37
|
+
* @param {import('./claude-background').BackgroundSession} options.session
|
|
38
|
+
* @param {{id: string, title: string|null}} options.claudeSession the session being asked
|
|
39
|
+
* @param {string} [options.agent] the name answers are written under
|
|
40
|
+
*/
|
|
41
|
+
constructor({ reviewsDir, repoPath, session, claudeSession, agent = 'claude' }) {
|
|
42
|
+
super();
|
|
43
|
+
this.reviewsDir = reviewsDir;
|
|
44
|
+
this.repoPath = repoPath;
|
|
45
|
+
this.session = session;
|
|
46
|
+
this.claudeSession = claudeSession;
|
|
47
|
+
this.agent = agent;
|
|
48
|
+
|
|
49
|
+
/** @type {import('./comments').Comment[]|null} */
|
|
50
|
+
this.known = null;
|
|
51
|
+
/** @type {Array<{key: string, force: boolean}>} */
|
|
52
|
+
this.queue = [];
|
|
53
|
+
/** @type {{key: string, asked: Set<string>}|null} */
|
|
54
|
+
this.inFlight = null;
|
|
55
|
+
this.stopped = false;
|
|
56
|
+
|
|
57
|
+
this.presence = announce(reviewsDir, repoPath, {
|
|
58
|
+
kind: 'background',
|
|
59
|
+
agent,
|
|
60
|
+
session: claudeSession.id,
|
|
61
|
+
title: claudeSession.title
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
session.on('session', () => this.emit('status', this.status()));
|
|
65
|
+
session.on('working', ({ key }) => this.presence.update({ working: [key] }));
|
|
66
|
+
session.on('partial', ({ key, text }) => this.emit('partial', { key, text }));
|
|
67
|
+
session.on('answer', ({ key, text, session: answeredBy }) => this.onAnswer(key, text, answeredBy));
|
|
68
|
+
session.on('failed', ({ key, message }) => {
|
|
69
|
+
this.inFlight = null;
|
|
70
|
+
this.presence.update({ working: [] });
|
|
71
|
+
this.emit('failed', { key, message });
|
|
72
|
+
this.emit('status', this.status());
|
|
73
|
+
this.dispatch();
|
|
74
|
+
});
|
|
75
|
+
session.on('exit', ({ message, fatal }) => {
|
|
76
|
+
if (message) this.emit('failed', { key: null, message });
|
|
77
|
+
// Claude Code could not even be started: say so once and disconnect,
|
|
78
|
+
// rather than show "connected" while every question fails the same way.
|
|
79
|
+
if (fatal) this.stop().then(() => this.emit('stopped'));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.watcher = watchComments(reviewsDir, repoPath, state => this.onComments(state.comments));
|
|
83
|
+
// Threads held back while a live session was answering are this one's
|
|
84
|
+
// again when it leaves; nothing else would prompt another look.
|
|
85
|
+
this.agentsWatcher = watchAgents(reviewsDir, repoPath, () => this.dispatch());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {import('./comments').Comment[]} comments */
|
|
89
|
+
onComments(comments) {
|
|
90
|
+
if (this.stopped) return;
|
|
91
|
+
if (this.known === null) {
|
|
92
|
+
for (const comment of awaitingAnswer(comments)) this.enqueue(comment.key);
|
|
93
|
+
} else {
|
|
94
|
+
for (const { comment } of newReviewerMessages(this.known, comments)) this.enqueue(comment.key);
|
|
95
|
+
}
|
|
96
|
+
this.known = comments;
|
|
97
|
+
this.dispatch();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
enqueue(key, force = false) {
|
|
101
|
+
const queued = this.queue.find(item => item.key === key);
|
|
102
|
+
if (queued) {
|
|
103
|
+
queued.force ||= force;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
this.queue.push({ key, force });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async dispatch() {
|
|
110
|
+
if (this.stopped || this.inFlight || this.queue.length === 0 || this.known === null) return;
|
|
111
|
+
|
|
112
|
+
// An agent answering live, in the reviewer's own terminal, takes
|
|
113
|
+
// precedence. Two agents answering one thread is noise, and the live one
|
|
114
|
+
// is the one somebody started on purpose.
|
|
115
|
+
const live = (await listAgents(this.reviewsDir, this.repoPath).catch(() => []))
|
|
116
|
+
.some(agent => agent.kind === 'channel');
|
|
117
|
+
if (live || this.inFlight || this.stopped) return;
|
|
118
|
+
|
|
119
|
+
while (this.queue.length > 0) {
|
|
120
|
+
const { key, force } = this.queue.shift();
|
|
121
|
+
const comment = this.known.find(entry => entry.key === key);
|
|
122
|
+
// Gone, or answered by somebody else in the meantime.
|
|
123
|
+
if (!comment || (!force && lastAuthor(comment) !== null)) continue;
|
|
124
|
+
|
|
125
|
+
this.inFlight = { key, asked: reviewerMessages(comment) };
|
|
126
|
+
this.session.ask(key, questionFor(comment));
|
|
127
|
+
this.emit('status', this.status());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
this.emit('status', this.status());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async onAnswer(key, text, answeredBy) {
|
|
134
|
+
const asked = this.inFlight?.key === key ? this.inFlight.asked : new Set();
|
|
135
|
+
let askAgain = false;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
await updateSavedComments(this.reviewsDir, this.repoPath, current => {
|
|
139
|
+
const thread = current.comments.find(comment => comment.key === key);
|
|
140
|
+
if (!thread) return null; // deleted while it was being answered
|
|
141
|
+
// Something the reviewer added while the answer was being written
|
|
142
|
+
// was not part of the question, so the thread is asked again --
|
|
143
|
+
// with everything, this answer included -- once this one is posted.
|
|
144
|
+
askAgain = [...reviewerMessages(thread)].some(identity => !asked.has(identity));
|
|
145
|
+
return {
|
|
146
|
+
comments: appendReply(current.comments, key, {
|
|
147
|
+
key: mintKey(),
|
|
148
|
+
text,
|
|
149
|
+
timestamp: new Date().toISOString(),
|
|
150
|
+
by: this.agent,
|
|
151
|
+
...(answeredBy ? { session: answeredBy } : {})
|
|
152
|
+
})
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
this.emit('failed', { key, message: `Could not save the answer: ${error.message}` });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
this.inFlight = null;
|
|
160
|
+
await this.presence.update({ working: [] });
|
|
161
|
+
if (askAgain) this.enqueue(key, true);
|
|
162
|
+
// Said even when nothing else is queued: the page shows "answering…"
|
|
163
|
+
// until it hears this thread is no longer being worked on.
|
|
164
|
+
this.emit('status', this.status());
|
|
165
|
+
this.dispatch();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
status() {
|
|
169
|
+
return {
|
|
170
|
+
kind: 'background',
|
|
171
|
+
agent: this.agent,
|
|
172
|
+
session: this.claudeSession.id,
|
|
173
|
+
title: this.claudeSession.title,
|
|
174
|
+
answeringIn: this.session.answeringSession,
|
|
175
|
+
forked: this.session.fork,
|
|
176
|
+
working: this.inFlight?.key ?? null,
|
|
177
|
+
queued: this.queue.map(item => item.key)
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async stop() {
|
|
182
|
+
if (this.stopped) return;
|
|
183
|
+
this.stopped = true;
|
|
184
|
+
this.watcher.close();
|
|
185
|
+
this.agentsWatcher.close();
|
|
186
|
+
await this.session.stop();
|
|
187
|
+
await this.presence.close();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = { Answerer };
|