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/lib/store.js
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs').promises;
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
5
6
|
|
|
6
7
|
const { commentsFilename, legacyCommentsFilename, canonicalRepoPath } = require('./review');
|
|
8
|
+
const { withKeys } = require('./comments');
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Reading a repository's saved comments, whatever name they were written under.
|
|
@@ -74,10 +76,15 @@ async function readJson(file) {
|
|
|
74
76
|
*
|
|
75
77
|
* @param {string} reviewsDir
|
|
76
78
|
* @param {string} repoPath
|
|
77
|
-
* @returns {Promise<{comments: object[], mode: string|null, range: object|null, untracked?: boolean, adopted: boolean}>}
|
|
79
|
+
* @returns {Promise<{comments: object[], mode: string|null, range: object|null, untracked?: boolean, adopted: boolean, lastUpdated: string|null}>}
|
|
78
80
|
* `mode` is null for a file written before it was recorded -- there is
|
|
79
81
|
* nothing on disk that says what was compared, and guessing would be
|
|
80
|
-
* worse than admitting it.
|
|
82
|
+
* worse than admitting it. `lastUpdated` is null when the file carries
|
|
83
|
+
* no stamp (or there is no file yet): it is the version token a caller
|
|
84
|
+
* sends back on save so the server can tell a current writer from a
|
|
85
|
+
* stale one (#107). Every comment and follow-up comes back with a `key`;
|
|
86
|
+
* one saved before keys existed gets the key derived from its content (see
|
|
87
|
+
* `withKeys`), the same on every read.
|
|
81
88
|
* @throws {ReviewOwnershipError} when the current file names another repository
|
|
82
89
|
*/
|
|
83
90
|
async function readSavedComments(reviewsDir, repoPath) {
|
|
@@ -91,18 +98,19 @@ async function readSavedComments(reviewsDir, repoPath) {
|
|
|
91
98
|
throw new ReviewOwnershipError(repoPath, saved.repoPath);
|
|
92
99
|
}
|
|
93
100
|
return {
|
|
94
|
-
comments: saved.comments ?? [], mode: saved.mode ?? null, range: saved.range ?? null,
|
|
101
|
+
comments: withKeys(saved.comments ?? []), mode: saved.mode ?? null, range: saved.range ?? null,
|
|
95
102
|
...(saved.mode === 'working' && saved.untracked === false ? { untracked: false } : {}),
|
|
96
|
-
adopted: false
|
|
103
|
+
adopted: false,
|
|
104
|
+
lastUpdated: saved.lastUpdated ?? null
|
|
97
105
|
};
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
const legacyName = legacyCommentsFilename(repoPath);
|
|
101
|
-
if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, range: null, adopted: false };
|
|
109
|
+
if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, range: null, adopted: false, lastUpdated: null };
|
|
102
110
|
|
|
103
111
|
const legacy = await readJson(path.join(reviewsDir, legacyName));
|
|
104
112
|
if (legacy === null || !belongsTo(legacy.repoPath, repoPath)) {
|
|
105
|
-
return { comments: [], mode: null, range: null, adopted: false };
|
|
113
|
+
return { comments: [], mode: null, range: null, adopted: false, lastUpdated: null };
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
// Move it under the new name so the next read is a plain hit and the old
|
|
@@ -115,10 +123,277 @@ async function readSavedComments(reviewsDir, repoPath) {
|
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
return {
|
|
118
|
-
comments: legacy.comments ?? [], mode: legacy.mode ?? null, range: legacy.range ?? null,
|
|
126
|
+
comments: withKeys(legacy.comments ?? []), mode: legacy.mode ?? null, range: legacy.range ?? null,
|
|
119
127
|
...(legacy.mode === 'working' && legacy.untracked === false ? { untracked: false } : {}),
|
|
120
|
-
adopted: true
|
|
128
|
+
adopted: true,
|
|
129
|
+
lastUpdated: legacy.lastUpdated ?? null
|
|
121
130
|
};
|
|
122
131
|
}
|
|
123
132
|
|
|
124
|
-
|
|
133
|
+
/**
|
|
134
|
+
* The `lastUpdated` stamp for a save that follows `previous`.
|
|
135
|
+
*
|
|
136
|
+
* Stamps are the version token for conditional saves (#107), so a new stamp
|
|
137
|
+
* must differ from the one it replaces even when two saves land inside the
|
|
138
|
+
* same millisecond -- otherwise a stale tab would look current by accident.
|
|
139
|
+
*
|
|
140
|
+
* @param {string|null} previous stamp currently on disk, if any
|
|
141
|
+
* @returns {string} ISO 8601 stamp strictly after `previous`
|
|
142
|
+
*/
|
|
143
|
+
function nextLastUpdated(previous) {
|
|
144
|
+
const parsed = previous ? Date.parse(previous) : NaN;
|
|
145
|
+
const stamp = Number.isNaN(parsed) ? Date.now() : Math.max(Date.now(), parsed + 1);
|
|
146
|
+
return new Date(stamp).toISOString();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// A lock older than this belongs to a writer that died holding it. Reading
|
|
150
|
+
// and writing one small JSON file takes milliseconds; ten seconds is not a
|
|
151
|
+
// writer that is merely slow. A lock whose writer is provably gone is not
|
|
152
|
+
// waited on at all.
|
|
153
|
+
const LOCK_STALE_MS = 10000;
|
|
154
|
+
const LOCK_RETRY_MS = 15;
|
|
155
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
156
|
+
|
|
157
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether a process is running.
|
|
161
|
+
*
|
|
162
|
+
* @param {number} pid
|
|
163
|
+
* @returns {boolean}
|
|
164
|
+
*/
|
|
165
|
+
function isAlive(pid) {
|
|
166
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
167
|
+
try {
|
|
168
|
+
process.kill(pid, 0);
|
|
169
|
+
return true;
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// EPERM: it exists, it just is not ours to signal.
|
|
172
|
+
return error.code === 'EPERM';
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Whether creating a lock failed only because somebody else holds it.
|
|
178
|
+
*
|
|
179
|
+
* EEXIST everywhere. On Windows, also EPERM and EACCES: a file another
|
|
180
|
+
* process has just deleted lingers as "delete pending" while anyone still
|
|
181
|
+
* has it open, and creating one by the same name is refused until it is
|
|
182
|
+
* gone -- the lock is being released, not withheld. CI caught it: two
|
|
183
|
+
* writers in separate processes, and one of them died of EPERM.
|
|
184
|
+
*
|
|
185
|
+
* @param {NodeJS.ErrnoException} error
|
|
186
|
+
*/
|
|
187
|
+
function lockIsBusy(error) {
|
|
188
|
+
if (error.code === 'EEXIST') return true;
|
|
189
|
+
return process.platform === 'win32' && (error.code === 'EPERM' || error.code === 'EACCES');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether a lock has been abandoned: the process that wrote it has gone, or
|
|
194
|
+
* it has been held far longer than any write takes.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} lock
|
|
197
|
+
* @returns {Promise<boolean>}
|
|
198
|
+
*/
|
|
199
|
+
async function isAbandoned(lock) {
|
|
200
|
+
const [content, stat] = await Promise.all([
|
|
201
|
+
fs.readFile(lock, 'utf-8').catch(() => null),
|
|
202
|
+
fs.stat(lock).catch(() => null)
|
|
203
|
+
]);
|
|
204
|
+
if (content === null || !stat) return false; // released meanwhile: just try again
|
|
205
|
+
const owner = Number.parseInt(content, 10);
|
|
206
|
+
if (Number.isInteger(owner) && owner > 0 && !isAlive(owner)) return true;
|
|
207
|
+
return Date.now() - stat.mtimeMs > LOCK_STALE_MS;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Remove a lock whose writer died holding it, and say whether it was removed.
|
|
212
|
+
*
|
|
213
|
+
* Two waiters can both find the same lock abandoned. If each simply removed
|
|
214
|
+
* it, the second would remove the lock the first had just taken, and both
|
|
215
|
+
* would write at once -- the lost reply the lock exists to prevent. So
|
|
216
|
+
* breaking a lock is done under a lock of its own, and the one holding it
|
|
217
|
+
* looks again before removing anything. Nothing but a breaker removes a lock
|
|
218
|
+
* that is not its own, and there is one breaker at a time.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} lock
|
|
221
|
+
* @returns {Promise<boolean>}
|
|
222
|
+
*/
|
|
223
|
+
async function breakAbandonedLock(lock) {
|
|
224
|
+
if (!(await isAbandoned(lock))) return false;
|
|
225
|
+
|
|
226
|
+
const breaker = `${lock}.break`;
|
|
227
|
+
try {
|
|
228
|
+
await (await fs.open(breaker, 'wx')).close();
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (!lockIsBusy(error)) throw error;
|
|
231
|
+
// Somebody else is breaking it. One who died doing so is cleared by age.
|
|
232
|
+
const stat = await fs.stat(breaker).catch(() => null);
|
|
233
|
+
if (stat && Date.now() - stat.mtimeMs > LOCK_STALE_MS) await fs.rm(breaker, { force: true });
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
if (!(await isAbandoned(lock))) return false;
|
|
239
|
+
await fs.rm(lock, { force: true });
|
|
240
|
+
return true;
|
|
241
|
+
} finally {
|
|
242
|
+
await fs.rm(breaker, { force: true });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Run `task` holding the lock on `file`, against every process on the machine.
|
|
248
|
+
*
|
|
249
|
+
* The page is no longer the only writer. An agent answering in a thread
|
|
250
|
+
* writes the same file from another process -- `reviewer channel`, run by
|
|
251
|
+
* Claude Code -- and a read-modify-write from each, interleaved, loses one of
|
|
252
|
+
* them. The lock is a sibling file created with `wx`, which the operating
|
|
253
|
+
* system creates atomically or refuses, on every platform Node runs on, and
|
|
254
|
+
* which holds the writer's pid so a lock left by a process that died can be
|
|
255
|
+
* recognised as such.
|
|
256
|
+
*
|
|
257
|
+
* @template T
|
|
258
|
+
* @param {string} file
|
|
259
|
+
* @param {() => Promise<T>} task
|
|
260
|
+
* @returns {Promise<T>}
|
|
261
|
+
*/
|
|
262
|
+
async function withFileLock(file, task) {
|
|
263
|
+
const lock = `${file}.lock`;
|
|
264
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
265
|
+
|
|
266
|
+
for (;;) {
|
|
267
|
+
try {
|
|
268
|
+
const handle = await fs.open(lock, 'wx');
|
|
269
|
+
try {
|
|
270
|
+
await handle.writeFile(`${process.pid}\n`);
|
|
271
|
+
} finally {
|
|
272
|
+
await handle.close();
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (!lockIsBusy(error)) throw error;
|
|
277
|
+
if (error.code === 'EEXIST' && await breakAbandonedLock(lock)) continue;
|
|
278
|
+
if (Date.now() > deadline) {
|
|
279
|
+
if (error.code !== 'EEXIST') throw error;
|
|
280
|
+
throw new Error(`Timed out waiting for ${path.basename(lock)}; if no reviewer is running, delete it.`);
|
|
281
|
+
}
|
|
282
|
+
await sleep(LOCK_RETRY_MS);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
return await task();
|
|
288
|
+
} finally {
|
|
289
|
+
await fs.rm(lock, { force: true });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Replace `file` in one step, so no reader ever sees half of it.
|
|
295
|
+
*
|
|
296
|
+
* A reader in another process -- an agent watching the review -- would
|
|
297
|
+
* otherwise be able to catch the file truncated mid-write and parse nothing.
|
|
298
|
+
* Windows refuses to rename over a file another process has open for a
|
|
299
|
+
* moment, so that is retried briefly rather than failing the save.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} file
|
|
302
|
+
* @param {string} content
|
|
303
|
+
*/
|
|
304
|
+
async function writeFileAtomic(file, content) {
|
|
305
|
+
const temporary = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
306
|
+
await fs.writeFile(temporary, content);
|
|
307
|
+
for (let attempt = 0; ; attempt++) {
|
|
308
|
+
try {
|
|
309
|
+
await fs.rename(temporary, file);
|
|
310
|
+
return;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (attempt < 20 && ['EPERM', 'EACCES', 'EBUSY'].includes(error.code)) {
|
|
313
|
+
await sleep(10);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
await fs.rm(temporary, { force: true });
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Writes from this process to one file go one at a time, so they queue here
|
|
323
|
+
// instead of polling for the lock against each other.
|
|
324
|
+
/** @type {Map<string, Promise<void>>} */
|
|
325
|
+
const queues = new Map();
|
|
326
|
+
|
|
327
|
+
function serialize(file, task) {
|
|
328
|
+
const previous = queues.get(file) ?? Promise.resolve();
|
|
329
|
+
const run = previous.then(task, task);
|
|
330
|
+
const tail = run.then(() => {}, () => {});
|
|
331
|
+
queues.set(file, tail);
|
|
332
|
+
tail.then(() => {
|
|
333
|
+
if (queues.get(file) === tail) queues.delete(file);
|
|
334
|
+
});
|
|
335
|
+
return run;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @typedef {object} SavedReview
|
|
340
|
+
* @property {import('./comments').Comment[]} comments keyed
|
|
341
|
+
* @property {string|null} mode
|
|
342
|
+
* @property {object|null} range
|
|
343
|
+
* @property {boolean} [untracked]
|
|
344
|
+
* @property {string|null} lastUpdated
|
|
345
|
+
*/
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Change a repository's saved comments, safely against every other writer.
|
|
349
|
+
*
|
|
350
|
+
* Takes the lock, reads what is on disk now, and hands it to `update`, which
|
|
351
|
+
* returns the comments to write -- or null to write nothing, which is how a
|
|
352
|
+
* conditional save turns itself down. What it returns is written in one step
|
|
353
|
+
* with a `lastUpdated` strictly after the one it replaced. The scope fields
|
|
354
|
+
* (`mode`, `range`, `untracked`) are kept from disk unless `update` supplies
|
|
355
|
+
* them: a reply in a thread does not change what the review is of.
|
|
356
|
+
*
|
|
357
|
+
* @param {string} reviewsDir
|
|
358
|
+
* @param {string} repoPath
|
|
359
|
+
* @param {(current: SavedReview) => ({comments: object[], scope?: {mode: string|null, range: object|null, untracked?: boolean}}|null|Promise<object|null>)} update
|
|
360
|
+
* @returns {Promise<{written: boolean, comments: object[], lastUpdated: string|null}>}
|
|
361
|
+
*/
|
|
362
|
+
async function updateSavedComments(reviewsDir, repoPath, update) {
|
|
363
|
+
const file = path.join(reviewsDir, commentsFilename(repoPath));
|
|
364
|
+
|
|
365
|
+
return serialize(file, async () => {
|
|
366
|
+
await fs.mkdir(reviewsDir, { recursive: true });
|
|
367
|
+
|
|
368
|
+
return withFileLock(file, async () => {
|
|
369
|
+
const current = await readSavedComments(reviewsDir, repoPath);
|
|
370
|
+
const change = await update(current);
|
|
371
|
+
if (!change) return { written: false, comments: current.comments, lastUpdated: current.lastUpdated };
|
|
372
|
+
|
|
373
|
+
const scope = change.scope ?? current;
|
|
374
|
+
const lastUpdated = nextLastUpdated(current.lastUpdated ?? null);
|
|
375
|
+
const data = {
|
|
376
|
+
repoPath,
|
|
377
|
+
lastUpdated,
|
|
378
|
+
mode: scope.mode ?? null,
|
|
379
|
+
range: scope.range ?? null,
|
|
380
|
+
...(scope.mode === 'working' && scope.untracked === false ? { untracked: false } : {}),
|
|
381
|
+
comments: change.comments
|
|
382
|
+
};
|
|
383
|
+
await writeFileAtomic(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
384
|
+
|
|
385
|
+
return { written: true, comments: change.comments, lastUpdated };
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
module.exports = {
|
|
391
|
+
readSavedComments,
|
|
392
|
+
updateSavedComments,
|
|
393
|
+
nextLastUpdated,
|
|
394
|
+
withFileLock,
|
|
395
|
+
writeFileAtomic,
|
|
396
|
+
isAlive,
|
|
397
|
+
ReviewOwnershipError,
|
|
398
|
+
belongsTo
|
|
399
|
+
};
|
package/lib/threads.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A review comment as a conversation: the reviewer's comment, then follow-ups
|
|
5
|
+
* from either side. An agent connected to a review answers in these threads,
|
|
6
|
+
* so this is where "what is still waiting for an answer" and "what is new
|
|
7
|
+
* since I last looked" are decided -- once, for the page, the channel and the
|
|
8
|
+
* background session alike.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Who wrote the last word in a thread: null for the reviewer, otherwise the
|
|
13
|
+
* `by` of the last follow-up.
|
|
14
|
+
*
|
|
15
|
+
* @param {import('./comments').Comment} comment
|
|
16
|
+
* @returns {string|null}
|
|
17
|
+
*/
|
|
18
|
+
function lastAuthor(comment) {
|
|
19
|
+
const followUps = comment.followUps ?? [];
|
|
20
|
+
return followUps.length === 0 ? null : (followUps[followUps.length - 1].by ?? null);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Threads whose last word is the reviewer's: what an agent has yet to answer.
|
|
25
|
+
*
|
|
26
|
+
* @template {import('./comments').Comment} T
|
|
27
|
+
* @param {T[]} comments
|
|
28
|
+
* @returns {T[]}
|
|
29
|
+
*/
|
|
30
|
+
function awaitingAnswer(comments) {
|
|
31
|
+
return comments.filter(comment => lastAuthor(comment) === null);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The identity a follow-up is merged by: its key, or what it said and when. */
|
|
35
|
+
function followUpIdentity(followUp) {
|
|
36
|
+
return followUp.key ?? JSON.stringify([followUp.timestamp ?? null, followUp.text, followUp.by ?? null]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every follow-up in either list, once.
|
|
41
|
+
*
|
|
42
|
+
* Follow-ups are only ever added -- nothing in the page or the API removes
|
|
43
|
+
* one -- so a follow-up present on either side is one somebody wrote, and
|
|
44
|
+
* the union is the only merge that cannot lose an answer. `ours` keeps its
|
|
45
|
+
* order; follow-ups only `theirs` holds are placed by timestamp.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('./comments').FollowUp[]} [ours]
|
|
48
|
+
* @param {import('./comments').FollowUp[]} [theirs]
|
|
49
|
+
* @returns {import('./comments').FollowUp[]}
|
|
50
|
+
*/
|
|
51
|
+
function unionFollowUps(ours = [], theirs = []) {
|
|
52
|
+
const merged = [...ours];
|
|
53
|
+
const seen = new Set(ours.map(followUpIdentity));
|
|
54
|
+
|
|
55
|
+
for (const followUp of theirs) {
|
|
56
|
+
const identity = followUpIdentity(followUp);
|
|
57
|
+
if (seen.has(identity)) continue;
|
|
58
|
+
seen.add(identity);
|
|
59
|
+
|
|
60
|
+
// After the last entry that is not later than it, so an answer that was
|
|
61
|
+
// written while this side was adding its own lands where it happened.
|
|
62
|
+
const at = followUp.timestamp ?? '';
|
|
63
|
+
let index = merged.length;
|
|
64
|
+
if (at) {
|
|
65
|
+
while (index > 0 && (merged[index - 1].timestamp ?? '') > at) index--;
|
|
66
|
+
}
|
|
67
|
+
merged.splice(index, 0, followUp);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return merged;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Carry over follow-ups a save never saw.
|
|
75
|
+
*
|
|
76
|
+
* A tab saves the whole list it holds. An agent may have answered in a thread
|
|
77
|
+
* after that tab last heard from the server; saving the tab's copy of the
|
|
78
|
+
* thread as it stands would erase the answer. For every comment present both
|
|
79
|
+
* in the save and on disk, the follow-ups on disk are kept alongside the
|
|
80
|
+
* tab's.
|
|
81
|
+
*
|
|
82
|
+
* @param {import('./comments').Comment[]} incoming keyed comments being saved
|
|
83
|
+
* @param {import('./comments').Comment[]} current keyed comments on disk
|
|
84
|
+
* @returns {import('./comments').Comment[]}
|
|
85
|
+
*/
|
|
86
|
+
function keepFollowUpsFrom(incoming, current) {
|
|
87
|
+
const onDisk = new Map(current.map(comment => [comment.key, comment]));
|
|
88
|
+
|
|
89
|
+
return incoming.map(comment => {
|
|
90
|
+
const stored = onDisk.get(comment.key);
|
|
91
|
+
if (!stored?.followUps?.length) return comment;
|
|
92
|
+
|
|
93
|
+
const followUps = unionFollowUps(comment.followUps, stored.followUps);
|
|
94
|
+
return followUps.length === (comment.followUps?.length ?? 0) ? comment : { ...comment, followUps };
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* What the reviewer has said in `next` that was not in `prev`: new comments,
|
|
100
|
+
* and new follow-ups the reviewer wrote. Each comes back once, keyed, so an
|
|
101
|
+
* agent can be told about it exactly once.
|
|
102
|
+
*
|
|
103
|
+
* @param {import('./comments').Comment[]} prev keyed
|
|
104
|
+
* @param {import('./comments').Comment[]} next keyed
|
|
105
|
+
* @returns {Array<{comment: import('./comments').Comment, followUp: import('./comments').FollowUp|null}>}
|
|
106
|
+
*/
|
|
107
|
+
function newReviewerMessages(prev, next) {
|
|
108
|
+
const before = new Map(prev.map(comment => [comment.key, comment]));
|
|
109
|
+
const messages = [];
|
|
110
|
+
|
|
111
|
+
for (const comment of next) {
|
|
112
|
+
const old = before.get(comment.key);
|
|
113
|
+
if (!old) {
|
|
114
|
+
messages.push({ comment, followUp: null });
|
|
115
|
+
}
|
|
116
|
+
const known = new Set((old?.followUps ?? []).map(followUpIdentity));
|
|
117
|
+
for (const followUp of comment.followUps ?? []) {
|
|
118
|
+
if (followUp.by || known.has(followUpIdentity(followUp))) continue;
|
|
119
|
+
// A brand-new comment arriving with follow-ups already on it is one
|
|
120
|
+
// message, not several: the agent is told about the thread once.
|
|
121
|
+
if (!old) continue;
|
|
122
|
+
messages.push({ comment, followUp });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return messages;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The thread an agent is being asked to answer, as plain text.
|
|
131
|
+
*
|
|
132
|
+
* Shared by every way an agent can be connected, so a background session and
|
|
133
|
+
* a live one are asked the same question in the same words.
|
|
134
|
+
*
|
|
135
|
+
* @param {import('./comments').Comment} comment
|
|
136
|
+
* @returns {string}
|
|
137
|
+
*/
|
|
138
|
+
function describeThread(comment) {
|
|
139
|
+
const lines = [`${comment.file}, line ${comment.line}`];
|
|
140
|
+
if (comment.lineContent !== undefined && comment.lineContent !== null && comment.lineContent !== '') {
|
|
141
|
+
lines.push(`Line: ${comment.lineContent.trim()}`);
|
|
142
|
+
}
|
|
143
|
+
if (comment.selectedText) {
|
|
144
|
+
lines.push(`Selected: ${comment.selectedText.trim()}`);
|
|
145
|
+
}
|
|
146
|
+
lines.push('', `Reviewer: ${comment.text}`);
|
|
147
|
+
for (const followUp of comment.followUps ?? []) {
|
|
148
|
+
lines.push(`${followUp.by ? `${followUp.by} (you)` : 'Reviewer'}: ${followUp.text}`);
|
|
149
|
+
}
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Append an agent's answer to a thread.
|
|
155
|
+
*
|
|
156
|
+
* @param {import('./comments').Comment[]} comments keyed
|
|
157
|
+
* @param {string} key comment to answer
|
|
158
|
+
* @param {import('./comments').FollowUp} reply
|
|
159
|
+
* @returns {import('./comments').Comment[]|null} the new list, or null when no comment has that key
|
|
160
|
+
*/
|
|
161
|
+
function appendReply(comments, key, reply) {
|
|
162
|
+
let found = false;
|
|
163
|
+
const updated = comments.map(comment => {
|
|
164
|
+
if (comment.key !== key) return comment;
|
|
165
|
+
found = true;
|
|
166
|
+
return { ...comment, followUps: [...(comment.followUps ?? []), reply] };
|
|
167
|
+
});
|
|
168
|
+
return found ? updated : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
lastAuthor,
|
|
173
|
+
awaitingAnswer,
|
|
174
|
+
unionFollowUps,
|
|
175
|
+
keepFollowUpsFrom,
|
|
176
|
+
newReviewerMessages,
|
|
177
|
+
describeThread,
|
|
178
|
+
appendReply,
|
|
179
|
+
followUpIdentity
|
|
180
|
+
};
|
package/lib/watch.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const { readSavedComments } = require('./store');
|
|
7
|
+
const { commentsFilename } = require('./review');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hearing about a review changing, whoever changed it.
|
|
11
|
+
*
|
|
12
|
+
* The comments file is the source of truth and, now that agents answer in
|
|
13
|
+
* threads, it has writers other than the page: `reviewer channel` running
|
|
14
|
+
* inside a Claude Code session, `reviewer reply`, a background session the
|
|
15
|
+
* server started. Each of them writes the file; everyone interested watches
|
|
16
|
+
* it. That keeps the writers from needing to know who is listening, or where.
|
|
17
|
+
*
|
|
18
|
+
* `fs.watch` gives the change within milliseconds where the platform supports
|
|
19
|
+
* it. A slow poll runs alongside, because there are filesystems where
|
|
20
|
+
* `fs.watch` is silently deaf -- network mounts, some containers -- and a
|
|
21
|
+
* reply that never shows up is worse than one that shows up a second late.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEBOUNCE_MS = 25;
|
|
25
|
+
const POLL_MS = 1500;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Watch one repository's saved comments.
|
|
29
|
+
*
|
|
30
|
+
* `onChange` is called with what was read whenever `lastUpdated` moves, and
|
|
31
|
+
* once at the start with the state as it stands, so a caller never has to
|
|
32
|
+
* read separately and race the first change.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} reviewsDir
|
|
35
|
+
* @param {string} repoPath
|
|
36
|
+
* @param {(state: import('./store').SavedReview) => void} onChange
|
|
37
|
+
* @param {object} [options]
|
|
38
|
+
* @param {number} [options.pollMs]
|
|
39
|
+
* @param {(error: Error) => void} [options.onError] a file that cannot be read or parsed; the watch carries on
|
|
40
|
+
* @returns {{close: () => void, ready: Promise<void>}}
|
|
41
|
+
*/
|
|
42
|
+
function watchComments(reviewsDir, repoPath, onChange, { pollMs = POLL_MS, onError = () => {} } = {}) {
|
|
43
|
+
const filename = commentsFilename(repoPath);
|
|
44
|
+
const file = path.join(reviewsDir, filename);
|
|
45
|
+
|
|
46
|
+
let closed = false;
|
|
47
|
+
let lastSeen;
|
|
48
|
+
let lastStat = null;
|
|
49
|
+
let timer = null;
|
|
50
|
+
let reading = null;
|
|
51
|
+
let again = false;
|
|
52
|
+
|
|
53
|
+
const check = async () => {
|
|
54
|
+
if (closed) return;
|
|
55
|
+
if (reading) {
|
|
56
|
+
again = true;
|
|
57
|
+
return reading;
|
|
58
|
+
}
|
|
59
|
+
reading = (async () => {
|
|
60
|
+
try {
|
|
61
|
+
const state = await readSavedComments(reviewsDir, repoPath);
|
|
62
|
+
if (closed) return;
|
|
63
|
+
if (state.lastUpdated !== lastSeen) {
|
|
64
|
+
lastSeen = state.lastUpdated;
|
|
65
|
+
onChange(state);
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// A file caught mid-replace by a writer that is not atomic, or one
|
|
69
|
+
// hand-edited into invalid JSON. Say so and keep watching: the next
|
|
70
|
+
// write will be readable.
|
|
71
|
+
if (!closed) onError(error);
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
try {
|
|
75
|
+
await reading;
|
|
76
|
+
} finally {
|
|
77
|
+
reading = null;
|
|
78
|
+
if (again && !closed) {
|
|
79
|
+
again = false;
|
|
80
|
+
check();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const schedule = () => {
|
|
86
|
+
if (closed) return;
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
timer = setTimeout(check, DEBOUNCE_MS);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
let watcher = null;
|
|
92
|
+
try {
|
|
93
|
+
fs.mkdirSync(reviewsDir, { recursive: true });
|
|
94
|
+
// The directory, not the file: an atomic write replaces the file, and a
|
|
95
|
+
// watch on the old one would go quiet after the first save.
|
|
96
|
+
watcher = fs.watch(reviewsDir, (event, name) => {
|
|
97
|
+
if (!name || name === filename) schedule();
|
|
98
|
+
});
|
|
99
|
+
watcher.on('error', () => {
|
|
100
|
+
watcher?.close();
|
|
101
|
+
watcher = null;
|
|
102
|
+
});
|
|
103
|
+
} catch {
|
|
104
|
+
watcher = null; // the poll below still covers it
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const poll = setInterval(() => {
|
|
108
|
+
fs.stat(file, (error, stat) => {
|
|
109
|
+
const signature = error ? null : `${stat.mtimeMs}:${stat.size}`;
|
|
110
|
+
if (signature !== lastStat) {
|
|
111
|
+
lastStat = signature;
|
|
112
|
+
check();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}, pollMs);
|
|
116
|
+
poll.unref();
|
|
117
|
+
|
|
118
|
+
const ready = check();
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
ready,
|
|
122
|
+
close() {
|
|
123
|
+
closed = true;
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
clearInterval(poll);
|
|
126
|
+
watcher?.close();
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { watchComments };
|
package/man/git-reviewer.1
CHANGED
|
@@ -17,6 +17,23 @@ git-reviewer, reviewer \- review local git changes in your browser, and hand the
|
|
|
17
17
|
.IR json | prompt ]
|
|
18
18
|
.RB [ \-\-file
|
|
19
19
|
.IR path ]
|
|
20
|
+
.br
|
|
21
|
+
.B git reviewer connect
|
|
22
|
+
.RI [ repository ]
|
|
23
|
+
.RB [ \-\-session
|
|
24
|
+
.IR id ]
|
|
25
|
+
.RB [ \-\-fork ]
|
|
26
|
+
.RB [ \-\-list ]
|
|
27
|
+
.br
|
|
28
|
+
.B git reviewer reply
|
|
29
|
+
.I key text
|
|
30
|
+
.RB [ \-\-repo
|
|
31
|
+
.IR path ]
|
|
32
|
+
.RB [ \-\-as
|
|
33
|
+
.IR name ]
|
|
34
|
+
.br
|
|
35
|
+
.B git reviewer watch
|
|
36
|
+
.RI [ repository ]
|
|
20
37
|
.SH DESCRIPTION
|
|
21
38
|
Opens the uncommitted changes of a git repository, or its last commit on a
|
|
22
39
|
clean tree, as a pull-request style review in your browser: inline comments
|
|
@@ -44,6 +61,36 @@ is the same program under a shorter name.
|
|
|
44
61
|
.B export
|
|
45
62
|
Print the saved review of a repository to stdout, as JSON or as a prompt for
|
|
46
63
|
a coding agent.
|
|
64
|
+
.TP
|
|
65
|
+
.B connect
|
|
66
|
+
Resume the Claude Code session that worked in the repository, with reviewer
|
|
67
|
+
attached as a channel: every comment written in the review is sent to the
|
|
68
|
+
session as it is written, and the session answers in the thread.
|
|
69
|
+
.B \-\-list
|
|
70
|
+
shows the sessions;
|
|
71
|
+
.B \-\-session
|
|
72
|
+
picks one by id or id prefix;
|
|
73
|
+
.B \-\-fork
|
|
74
|
+
resumes a copy. Claude Code asks once, at startup, to confirm the channel.
|
|
75
|
+
.TP
|
|
76
|
+
.B reply
|
|
77
|
+
Post a reply in a comment thread, as an agent. Inside Claude Code it is
|
|
78
|
+
signed
|
|
79
|
+
.BR claude ,
|
|
80
|
+
elsewhere
|
|
81
|
+
.B agent
|
|
82
|
+
unless
|
|
83
|
+
.B \-\-as
|
|
84
|
+
names who it is from. Exits 1 if no comment has that key.
|
|
85
|
+
.TP
|
|
86
|
+
.B watch
|
|
87
|
+
Print each comment waiting for an answer, then each new comment or reply
|
|
88
|
+
from the reviewer, as one JSON line, until interrupted.
|
|
89
|
+
.TP
|
|
90
|
+
.B channel
|
|
91
|
+
The MCP server
|
|
92
|
+
.B connect
|
|
93
|
+
asks Claude Code to run, speaking JSON-RPC over stdio. Not run by hand.
|
|
47
94
|
.SH OPTIONS
|
|
48
95
|
.TP
|
|
49
96
|
.BR \-p ", " \-\-port " \fIn\fR"
|