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/server.js CHANGED
@@ -14,14 +14,22 @@ const {
14
14
  } = require('./lib/paths');
15
15
  const { collectWorkingTreeChanges, describeWorkingChanges, collectStagedChanges, collectCommitChanges, parseNameStatus } = require('./lib/changes');
16
16
  const { SessionStore } = require('./lib/sessions');
17
- const { normalizeComments } = require('./lib/comments');
17
+ const { normalizeComments, withKeys } = require('./lib/comments');
18
+ const { keepFollowUpsFrom } = require('./lib/threads');
18
19
  const { formatReview, reviewFilename, commentsFilename } = require('./lib/review');
19
20
  const { buildReviewDocument, describeScope } = require('./lib/agent');
20
- const { readSavedComments, ReviewOwnershipError } = require('./lib/store');
21
+ const { readSavedComments, updateSavedComments, ReviewOwnershipError } = require('./lib/store');
21
22
  const { repoRoot } = require('./lib/repo');
22
23
  const { browse } = require('./lib/browse');
23
24
  const { recordRecent, describeRecents } = require('./lib/recents');
24
25
  const { listReviews } = require('./lib/export');
26
+ const { EventEmitter } = require('events');
27
+ const { watchComments } = require('./lib/watch');
28
+ const { watchAgents } = require('./lib/presence');
29
+ const { listClaudeSessions, findClaudeSession } = require('./lib/claude-sessions');
30
+ const { BackgroundSession } = require('./lib/claude-background');
31
+ const { Answerer } = require('./lib/answerer');
32
+ const { resolveClaudeCommand } = require('./lib/claude-command');
25
33
 
26
34
  const DEFAULT_PORT = 4500;
27
35
  const DEFAULT_HOST = '127.0.0.1';
@@ -60,6 +68,15 @@ function loopbackHostOnly(req, res, next) {
60
68
  }
61
69
 
62
70
 
71
+ /**
72
+ * An agent's presence record, as the page is told about it.
73
+ *
74
+ * @param {import('./lib/presence').AgentPresence} agent
75
+ */
76
+ function describeAgent({ id, kind, agent, session, title, startedAt, working }) {
77
+ return { id, kind, agent, session, title, startedAt, working };
78
+ }
79
+
63
80
  /** A path inside the repository that names nothing, on disk or at HEAD. */
64
81
  class FileNotFoundError extends Error {
65
82
  /** @param {string} filePath */
@@ -95,6 +112,10 @@ class FileNotFoundError extends Error {
95
112
  * leaves before deciding it is not coming back.
96
113
  * @param {boolean} [options.loopbackOnly] refuse any request whose `Host` is
97
114
  * not a loopback name. `startServer` turns it on when it binds to loopback.
115
+ * @param {object} [options.claude] how Claude Code is found and run, for
116
+ * answering comments from a session: `command` (default `claude`, or
117
+ * `REVIEWER_CLAUDE`), `configDir` (where its sessions are kept) and
118
+ * `spawn`, all injectable for tests.
98
119
  * @returns {import('express').Express}
99
120
  */
100
121
  // How far back the range picker offers to go. Long enough to cover the work
@@ -122,8 +143,10 @@ function createApp(options = {}) {
122
143
  handoff = false,
123
144
  onIdle = null,
124
145
  idleGraceMs = 5000,
125
- loopbackOnly = false
146
+ loopbackOnly = false,
147
+ claude = {}
126
148
  } = options;
149
+ const claudeCommand = claude.command ?? process.env.REVIEWER_CLAUDE ?? 'claude';
127
150
 
128
151
  const app = express();
129
152
 
@@ -136,6 +159,38 @@ function createApp(options = {}) {
136
159
  app.locals.sessions = sessions;
137
160
  app.locals.reviewsDir = reviewsDir;
138
161
 
162
+ // Background Claude Code sessions answering comments, one per repository
163
+ // at most, and the feed their answers-in-progress go out on. An answer is
164
+ // only written to the review once it is finished; until then the words
165
+ // arriving are sent to the page directly, not written to disk word by word.
166
+ /** @type {Map<string, Answerer>} */
167
+ const answerers = new Map();
168
+ const live = new EventEmitter();
169
+ live.setMaxListeners(0);
170
+
171
+ // Connecting and disconnecting a repository's background session happen
172
+ // one at a time. Two connects racing would each stop the session they
173
+ // found -- none -- and both start one, and the first would be left
174
+ // running with nothing to stop it.
175
+ /** @type {Map<string, Promise<void>>} */
176
+ const agentLocks = new Map();
177
+ const withAgentLock = (repoPath, task) => {
178
+ const previous = agentLocks.get(repoPath) ?? Promise.resolve();
179
+ const run = previous.then(task, task);
180
+ const tail = run.then(() => {}, () => {});
181
+ agentLocks.set(repoPath, tail);
182
+ tail.then(() => {
183
+ if (agentLocks.get(repoPath) === tail) agentLocks.delete(repoPath);
184
+ });
185
+ return run;
186
+ };
187
+
188
+ app.locals.stopAgents = async () => {
189
+ const running = [...answerers.values()];
190
+ answerers.clear();
191
+ await Promise.all(running.map(answerer => answerer.stop()));
192
+ };
193
+
139
194
  // How many tabs are currently holding `/api/alive` open, and the timer that
140
195
  // starts when that reaches zero. See the route for why it is a timer.
141
196
  let watching = 0;
@@ -756,22 +811,23 @@ function createApp(options = {}) {
756
811
  * after having been above it, so `reviewer` whose browser never launched
757
812
  * keeps serving rather than exiting into an empty terminal.
758
813
  */
759
- app.get('/api/alive', (req, res) => {
814
+ /**
815
+ * Count a tab as watching for as long as `res` stays open.
816
+ *
817
+ * Shared by `/api/alive` and `/api/events`: a tab holds exactly one of
818
+ * them, the first before a repository is open and the second after.
819
+ * Browsers allow six connections to one host, and a tab holding two would
820
+ * leave three tabs nothing to save with.
821
+ *
822
+ * @param {import('express').Response} res
823
+ */
824
+ function countWatcher(res) {
760
825
  if (idleTimer) {
761
826
  clearTimeout(idleTimer);
762
827
  idleTimer = null;
763
828
  }
764
829
  watching += 1;
765
830
 
766
- res.set({
767
- 'Content-Type': 'text/event-stream',
768
- 'Cache-Control': 'no-cache',
769
- Connection: 'keep-alive'
770
- });
771
- // A comment frame, which EventSource ignores. Sending it now is what
772
- // flushes the headers, so the client is connected rather than pending.
773
- res.write(': watching\n\n');
774
-
775
831
  res.on('close', () => {
776
832
  watching -= 1;
777
833
  if (watching > 0 || !onIdle) return;
@@ -785,6 +841,19 @@ function createApp(options = {}) {
785
841
  // waiting out the grace period before the process could exit.
786
842
  idleTimer.unref();
787
843
  });
844
+ }
845
+
846
+ app.get('/api/alive', (req, res) => {
847
+ countWatcher(res);
848
+
849
+ res.set({
850
+ 'Content-Type': 'text/event-stream',
851
+ 'Cache-Control': 'no-cache',
852
+ Connection: 'keep-alive'
853
+ });
854
+ // A comment frame, which EventSource ignores. Sending it now is what
855
+ // flushes the headers, so the client is connected rather than pending.
856
+ res.write(': watching\n\n');
788
857
  });
789
858
 
790
859
  /** List the directories inside one, so a repository can be found by looking. */
@@ -1009,13 +1078,30 @@ function createApp(options = {}) {
1009
1078
  }
1010
1079
  });
1011
1080
 
1012
- /** Persist the current comments. Called on every edit. */
1081
+ /**
1082
+ * Persist the current comments. Called on every edit.
1083
+ *
1084
+ * A caller that sends the `lastUpdated` it loaded saves conditionally
1085
+ * (#107): if the file has moved on since -- another tab, `reviewer
1086
+ * resolve`, an agent -- the save is refused with 409 and the current
1087
+ * comments and stamp ride along, so the caller can merge and retry
1088
+ * instead of silently overwriting what it never saw. A caller that
1089
+ * sends no `lastUpdated` at all keeps the old unconditional behaviour:
1090
+ * scripts written against the original route are not tabs racing each
1091
+ * other, and breaking them would not protect anyone.
1092
+ */
1013
1093
  app.post('/api/save-comments', async (req, res) => {
1014
1094
  try {
1015
1095
  const { repoId, comments } = req.body ?? {};
1016
1096
  const session = requireSession(res, repoId);
1017
1097
  if (!session) return;
1018
1098
 
1099
+ const conditional = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'lastUpdated');
1100
+ const expected = req.body?.lastUpdated ?? null;
1101
+ if (conditional && expected !== null && typeof expected !== 'string') {
1102
+ return res.status(400).json({ error: 'lastUpdated must be a string or null' });
1103
+ }
1104
+
1019
1105
  let normalized;
1020
1106
  try {
1021
1107
  normalized = normalizeComments(comments);
@@ -1023,27 +1109,47 @@ function createApp(options = {}) {
1023
1109
  return res.status(400).json({ error: error.message });
1024
1110
  }
1025
1111
 
1026
- await fs.mkdir(reviewsDir, { recursive: true });
1027
-
1028
- // `mode` is on the envelope rather than on each comment: it describes
1029
- // the review, not any one remark in it, and it is what lets `reviewer
1030
- // export` say what was compared. Without it an exported review could
1031
- // only report where HEAD is, which says nothing about whether the
1032
- // comments are about committed work or uncommitted work.
1033
- const data = {
1034
- repoPath: session.repoPath,
1035
- lastUpdated: new Date().toISOString(),
1036
- mode: session.mode,
1037
- range: session.range,
1038
- ...(session.untracked === false ? { untracked: false } : {}),
1039
- comments: normalized
1040
- };
1112
+ // Comments posted without keys -- older pages, scripts -- get the key
1113
+ // their content derives, so re-posting the same list keeps each
1114
+ // comment's identity, and the follow-ups below can find their thread.
1115
+ const incoming = withKeys(normalized);
1116
+
1117
+ let refusal = null;
1118
+ const saved = await updateSavedComments(reviewsDir, session.repoPath, current => {
1119
+ if (conditional && (current.lastUpdated ?? null) !== expected) {
1120
+ refusal = {
1121
+ error: 'Comments have changed since they were loaded. Reload, merge and retry.',
1122
+ comments: current.comments,
1123
+ lastUpdated: current.lastUpdated ?? null
1124
+ };
1125
+ return null;
1126
+ }
1041
1127
 
1042
- const target = path.join(reviewsDir, commentsFilename(session.repoPath));
1043
- await fs.writeFile(target, `${JSON.stringify(data, null, 2)}\n`);
1128
+ // `mode` is on the envelope rather than on each comment: it describes
1129
+ // the review, not any one remark in it, and it is what lets `reviewer
1130
+ // export` say what was compared. Without it an exported review could
1131
+ // only report where HEAD is, which says nothing about whether the
1132
+ // comments are about committed work or uncommitted work.
1133
+ //
1134
+ // Follow-ups already on disk are kept for every comment the save
1135
+ // still holds: an agent may have answered in a thread since this tab
1136
+ // last heard from the server, and a whole-list save must not erase it.
1137
+ return {
1138
+ comments: keepFollowUpsFrom(incoming, current.comments),
1139
+ scope: { mode: session.mode, range: session.range, untracked: session.untracked }
1140
+ };
1141
+ });
1044
1142
 
1045
- res.json({ message: 'Comments saved successfully' });
1143
+ const outcome = refusal
1144
+ ? { status: 409, body: refusal }
1145
+ // What was written, which can be more than what was sent -- the
1146
+ // follow-ups kept from disk -- so the page can show it at once.
1147
+ : { status: 200, body: { message: 'Comments saved successfully', lastUpdated: saved.lastUpdated, comments: saved.comments } };
1148
+ res.status(outcome.status).json(outcome.body);
1046
1149
  } catch (error) {
1150
+ if (error instanceof ReviewOwnershipError) {
1151
+ return res.status(409).json({ error: error.message });
1152
+ }
1047
1153
  console.error('Save comments error:', error);
1048
1154
  res.status(500).json({ error: error.message });
1049
1155
  }
@@ -1055,8 +1161,8 @@ function createApp(options = {}) {
1055
1161
  const session = requireSession(res, req.params.repoId);
1056
1162
  if (!session) return;
1057
1163
 
1058
- const { comments } = await readSavedComments(reviewsDir, session.repoPath);
1059
- res.json({ comments });
1164
+ const { comments, lastUpdated } = await readSavedComments(reviewsDir, session.repoPath);
1165
+ res.json({ comments, lastUpdated: lastUpdated ?? null });
1060
1166
  } catch (error) {
1061
1167
  if (error instanceof ReviewOwnershipError) {
1062
1168
  return res.status(409).json({ error: error.message });
@@ -1159,6 +1265,170 @@ function createApp(options = {}) {
1159
1265
  }
1160
1266
  });
1161
1267
 
1268
+ /**
1269
+ * The live feed for one open review.
1270
+ *
1271
+ * `comments` whenever the saved comments change -- whoever changed them:
1272
+ * another tab, an agent answering in a thread, `reviewer reply` -- and once
1273
+ * on connecting; `agents` whenever an agent connects, leaves, or starts on
1274
+ * a thread; and, while a background session here is answering, `partial`
1275
+ * with the answer as far as it has got. The page applies `comments` the way
1276
+ * it applies a refused save, so nothing it is holding is lost.
1277
+ */
1278
+ app.get('/api/events/:repoId', (req, res) => {
1279
+ const session = requireSession(res, req.params.repoId);
1280
+ if (!session) return;
1281
+
1282
+ // The page's one long-lived connection once a repository is open, so it
1283
+ // is also how the server knows the tab is still there (see /api/alive).
1284
+ countWatcher(res);
1285
+
1286
+ res.set({
1287
+ 'Content-Type': 'text/event-stream',
1288
+ 'Cache-Control': 'no-cache',
1289
+ Connection: 'keep-alive'
1290
+ });
1291
+ res.write(': events\n\n');
1292
+
1293
+ const send = (event, data) => {
1294
+ if (!res.writableEnded) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
1295
+ };
1296
+
1297
+ const comments = watchComments(reviewsDir, session.repoPath, state => {
1298
+ send('comments', { comments: state.comments, lastUpdated: state.lastUpdated ?? null });
1299
+ }, { onError: error => console.error('Review events:', error.message) });
1300
+ const agents = watchAgents(reviewsDir, session.repoPath, list => {
1301
+ send('agents', { agents: list.map(describeAgent) });
1302
+ });
1303
+ const onLive = (repoPath, event, data) => {
1304
+ if (repoPath === session.repoPath) send(event, data);
1305
+ };
1306
+ live.on('event', onLive);
1307
+ send('answerer', answerers.get(session.repoPath)?.status() ?? null);
1308
+
1309
+ // Proxies close a connection that has been quiet for a minute or two.
1310
+ const keepAlive = setInterval(() => res.write(': ping\n\n'), 20000);
1311
+ keepAlive.unref();
1312
+
1313
+ res.on('close', () => {
1314
+ comments.close();
1315
+ agents.close();
1316
+ live.off('event', onLive);
1317
+ clearInterval(keepAlive);
1318
+ });
1319
+ });
1320
+
1321
+ /**
1322
+ * The Claude Code sessions that worked in this repository, newest first,
1323
+ * to choose the one that should answer -- normally the one that wrote the
1324
+ * code under review.
1325
+ */
1326
+ app.get('/api/agent/sessions/:repoId', async (req, res) => {
1327
+ const session = requireSession(res, req.params.repoId);
1328
+ if (!session) return;
1329
+ try {
1330
+ const sessions = await listClaudeSessions(session.repoPath, { configDir: claude.configDir });
1331
+ res.json({ sessions, answerer: answerers.get(session.repoPath)?.status() ?? null });
1332
+ } catch (error) {
1333
+ console.error('Session list error:', error);
1334
+ res.status(500).json({ error: error.message });
1335
+ }
1336
+ });
1337
+
1338
+ /**
1339
+ * Have a Claude Code session answer this review's comments.
1340
+ *
1341
+ * Resumes the session in the background -- as a copy unless `fork` is
1342
+ * false -- and puts every thread waiting for an answer to it, then every
1343
+ * comment and reply the reviewer writes from now on. Only a session that
1344
+ * worked in this repository can be named: the id goes on a command line,
1345
+ * and the list is the whitelist.
1346
+ */
1347
+ app.post('/api/agent/connect', async (req, res) => {
1348
+ try {
1349
+ const { repoId, sessionId, fork = true } = req.body ?? {};
1350
+ const session = requireSession(res, repoId);
1351
+ if (!session) return;
1352
+ if (typeof fork !== 'boolean') {
1353
+ return res.status(400).json({ error: 'fork must be true or false' });
1354
+ }
1355
+
1356
+ const found = await findClaudeSession(session.repoPath, sessionId, { configDir: claude.configDir });
1357
+ if (!found) {
1358
+ return res.status(404).json({ error: 'No Claude Code session with that id has worked in this repository.' });
1359
+ }
1360
+
1361
+ const answerer = await withAgentLock(session.repoPath, async () => {
1362
+ await answerers.get(session.repoPath)?.stop();
1363
+ answerers.delete(session.repoPath);
1364
+ return startAnswerer(session, found, fork);
1365
+ });
1366
+
1367
+ console.error(`Answering comments from Claude Code session ${found.id}${fork ? ' (in a copy)' : ''}`);
1368
+ live.emit('event', session.repoPath, 'answerer', answerer.status());
1369
+ res.json({ answerer: answerer.status() });
1370
+ } catch (error) {
1371
+ console.error('Agent connect error:', error);
1372
+ res.status(500).json({ error: error.message });
1373
+ }
1374
+ });
1375
+
1376
+ /**
1377
+ * Start a background session answering a repository's comments.
1378
+ *
1379
+ * @param {{repoPath: string}} session
1380
+ * @param {import('./lib/claude-sessions').ClaudeSession} found
1381
+ * @param {boolean} fork
1382
+ * @returns {Promise<Answerer>}
1383
+ */
1384
+ async function startAnswerer(session, found, fork) {
1385
+ const about = found.title ?? found.firstPrompt ?? found.id;
1386
+ // Claude Code finds a session by the directory it ran in. If that has
1387
+ // since been removed, the repository root is the next best place to
1388
+ // run from, rather than a spawn error that reads like a missing binary.
1389
+ const cwd = await fs.access(found.cwd).then(() => found.cwd, () => session.repoPath);
1390
+ const background = new BackgroundSession({
1391
+ sessionId: found.id,
1392
+ cwd,
1393
+ fork,
1394
+ name: fork ? `Review answers: ${about}`.slice(0, 80) : null,
1395
+ claude: claude.spawn ? claudeCommand : resolveClaudeCommand(claudeCommand),
1396
+ spawn: claude.spawn
1397
+ });
1398
+ const answerer = new Answerer({
1399
+ reviewsDir,
1400
+ repoPath: session.repoPath,
1401
+ session: background,
1402
+ claudeSession: { id: found.id, title: about }
1403
+ });
1404
+ answerer.on('partial', data => live.emit('event', session.repoPath, 'partial', data));
1405
+ answerer.on('failed', data => live.emit('event', session.repoPath, 'agent-error', data));
1406
+ answerer.on('status', status => live.emit('event', session.repoPath, 'answerer', status));
1407
+ answerer.on('stopped', () => {
1408
+ if (answerers.get(session.repoPath) === answerer) answerers.delete(session.repoPath);
1409
+ live.emit('event', session.repoPath, 'answerer', null);
1410
+ });
1411
+ answerers.set(session.repoPath, answerer);
1412
+ background.start();
1413
+ return answerer;
1414
+ }
1415
+
1416
+ /** Stop the background session answering this review, if there is one. */
1417
+ app.post('/api/agent/disconnect', async (req, res) => {
1418
+ const { repoId } = req.body ?? {};
1419
+ const session = requireSession(res, repoId);
1420
+ if (!session) return;
1421
+
1422
+ const answerer = await withAgentLock(session.repoPath, async () => {
1423
+ const running = answerers.get(session.repoPath);
1424
+ answerers.delete(session.repoPath);
1425
+ await running?.stop();
1426
+ return running;
1427
+ });
1428
+ live.emit('event', session.repoPath, 'answerer', null);
1429
+ res.json({ disconnected: Boolean(answerer) });
1430
+ });
1431
+
1162
1432
  /** Drop a session. Saved comments on disk are left alone. */
1163
1433
  app.delete('/api/cleanup/:repoId', (req, res) => {
1164
1434
  sessions.delete(req.params.repoId);
@@ -1197,6 +1467,9 @@ function startServer(options = {}) {
1197
1467
  }
1198
1468
  resolve(server);
1199
1469
  });
1470
+ // A background session must not outlive the server that started it.
1471
+ server.stopAgents = app.locals.stopAgents;
1472
+ server.on('close', () => { app.locals.stopAgents(); });
1200
1473
  server.on('error', reject);
1201
1474
  });
1202
1475
  }