git-reviewer 2.15.0 → 2.17.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 +93 -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/search.js +319 -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 +1118 -107
- package/public/index.html +80 -0
- package/public/style.css +383 -0
- package/server.js +431 -34
package/server.js
CHANGED
|
@@ -14,14 +14,25 @@ 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');
|
|
33
|
+
const {
|
|
34
|
+
buildMatcher, searchDiffs, grepRepository, definesName, summarizeReferences, SearchError, SCOPES, MAX_RESULTS
|
|
35
|
+
} = require('./lib/search');
|
|
25
36
|
|
|
26
37
|
const DEFAULT_PORT = 4500;
|
|
27
38
|
const DEFAULT_HOST = '127.0.0.1';
|
|
@@ -60,6 +71,15 @@ function loopbackHostOnly(req, res, next) {
|
|
|
60
71
|
}
|
|
61
72
|
|
|
62
73
|
|
|
74
|
+
/**
|
|
75
|
+
* An agent's presence record, as the page is told about it.
|
|
76
|
+
*
|
|
77
|
+
* @param {import('./lib/presence').AgentPresence} agent
|
|
78
|
+
*/
|
|
79
|
+
function describeAgent({ id, kind, agent, session, title, startedAt, working }) {
|
|
80
|
+
return { id, kind, agent, session, title, startedAt, working };
|
|
81
|
+
}
|
|
82
|
+
|
|
63
83
|
/** A path inside the repository that names nothing, on disk or at HEAD. */
|
|
64
84
|
class FileNotFoundError extends Error {
|
|
65
85
|
/** @param {string} filePath */
|
|
@@ -95,6 +115,10 @@ class FileNotFoundError extends Error {
|
|
|
95
115
|
* leaves before deciding it is not coming back.
|
|
96
116
|
* @param {boolean} [options.loopbackOnly] refuse any request whose `Host` is
|
|
97
117
|
* not a loopback name. `startServer` turns it on when it binds to loopback.
|
|
118
|
+
* @param {object} [options.claude] how Claude Code is found and run, for
|
|
119
|
+
* answering comments from a session: `command` (default `claude`, or
|
|
120
|
+
* `REVIEWER_CLAUDE`), `configDir` (where its sessions are kept) and
|
|
121
|
+
* `spawn`, all injectable for tests.
|
|
98
122
|
* @returns {import('express').Express}
|
|
99
123
|
*/
|
|
100
124
|
// How far back the range picker offers to go. Long enough to cover the work
|
|
@@ -122,8 +146,10 @@ function createApp(options = {}) {
|
|
|
122
146
|
handoff = false,
|
|
123
147
|
onIdle = null,
|
|
124
148
|
idleGraceMs = 5000,
|
|
125
|
-
loopbackOnly = false
|
|
149
|
+
loopbackOnly = false,
|
|
150
|
+
claude = {}
|
|
126
151
|
} = options;
|
|
152
|
+
const claudeCommand = claude.command ?? process.env.REVIEWER_CLAUDE ?? 'claude';
|
|
127
153
|
|
|
128
154
|
const app = express();
|
|
129
155
|
|
|
@@ -136,6 +162,38 @@ function createApp(options = {}) {
|
|
|
136
162
|
app.locals.sessions = sessions;
|
|
137
163
|
app.locals.reviewsDir = reviewsDir;
|
|
138
164
|
|
|
165
|
+
// Background Claude Code sessions answering comments, one per repository
|
|
166
|
+
// at most, and the feed their answers-in-progress go out on. An answer is
|
|
167
|
+
// only written to the review once it is finished; until then the words
|
|
168
|
+
// arriving are sent to the page directly, not written to disk word by word.
|
|
169
|
+
/** @type {Map<string, Answerer>} */
|
|
170
|
+
const answerers = new Map();
|
|
171
|
+
const live = new EventEmitter();
|
|
172
|
+
live.setMaxListeners(0);
|
|
173
|
+
|
|
174
|
+
// Connecting and disconnecting a repository's background session happen
|
|
175
|
+
// one at a time. Two connects racing would each stop the session they
|
|
176
|
+
// found -- none -- and both start one, and the first would be left
|
|
177
|
+
// running with nothing to stop it.
|
|
178
|
+
/** @type {Map<string, Promise<void>>} */
|
|
179
|
+
const agentLocks = new Map();
|
|
180
|
+
const withAgentLock = (repoPath, task) => {
|
|
181
|
+
const previous = agentLocks.get(repoPath) ?? Promise.resolve();
|
|
182
|
+
const run = previous.then(task, task);
|
|
183
|
+
const tail = run.then(() => {}, () => {});
|
|
184
|
+
agentLocks.set(repoPath, tail);
|
|
185
|
+
tail.then(() => {
|
|
186
|
+
if (agentLocks.get(repoPath) === tail) agentLocks.delete(repoPath);
|
|
187
|
+
});
|
|
188
|
+
return run;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
app.locals.stopAgents = async () => {
|
|
192
|
+
const running = [...answerers.values()];
|
|
193
|
+
answerers.clear();
|
|
194
|
+
await Promise.all(running.map(answerer => answerer.stop()));
|
|
195
|
+
};
|
|
196
|
+
|
|
139
197
|
// How many tabs are currently holding `/api/alive` open, and the timer that
|
|
140
198
|
// starts when that reaches zero. See the route for why it is a timer.
|
|
141
199
|
let watching = 0;
|
|
@@ -756,22 +814,23 @@ function createApp(options = {}) {
|
|
|
756
814
|
* after having been above it, so `reviewer` whose browser never launched
|
|
757
815
|
* keeps serving rather than exiting into an empty terminal.
|
|
758
816
|
*/
|
|
759
|
-
|
|
817
|
+
/**
|
|
818
|
+
* Count a tab as watching for as long as `res` stays open.
|
|
819
|
+
*
|
|
820
|
+
* Shared by `/api/alive` and `/api/events`: a tab holds exactly one of
|
|
821
|
+
* them, the first before a repository is open and the second after.
|
|
822
|
+
* Browsers allow six connections to one host, and a tab holding two would
|
|
823
|
+
* leave three tabs nothing to save with.
|
|
824
|
+
*
|
|
825
|
+
* @param {import('express').Response} res
|
|
826
|
+
*/
|
|
827
|
+
function countWatcher(res) {
|
|
760
828
|
if (idleTimer) {
|
|
761
829
|
clearTimeout(idleTimer);
|
|
762
830
|
idleTimer = null;
|
|
763
831
|
}
|
|
764
832
|
watching += 1;
|
|
765
833
|
|
|
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
834
|
res.on('close', () => {
|
|
776
835
|
watching -= 1;
|
|
777
836
|
if (watching > 0 || !onIdle) return;
|
|
@@ -785,6 +844,19 @@ function createApp(options = {}) {
|
|
|
785
844
|
// waiting out the grace period before the process could exit.
|
|
786
845
|
idleTimer.unref();
|
|
787
846
|
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
app.get('/api/alive', (req, res) => {
|
|
850
|
+
countWatcher(res);
|
|
851
|
+
|
|
852
|
+
res.set({
|
|
853
|
+
'Content-Type': 'text/event-stream',
|
|
854
|
+
'Cache-Control': 'no-cache',
|
|
855
|
+
Connection: 'keep-alive'
|
|
856
|
+
});
|
|
857
|
+
// A comment frame, which EventSource ignores. Sending it now is what
|
|
858
|
+
// flushes the headers, so the client is connected rather than pending.
|
|
859
|
+
res.write(': watching\n\n');
|
|
788
860
|
});
|
|
789
861
|
|
|
790
862
|
/** List the directories inside one, so a repository can be found by looking. */
|
|
@@ -919,6 +991,11 @@ function createApp(options = {}) {
|
|
|
919
991
|
sessions.get(repoId).foldedDirectories = new Map(
|
|
920
992
|
files.filter(file => file.directory).map(file => [file.path, file.fileCount])
|
|
921
993
|
);
|
|
994
|
+
// What a search of the review looks through: the files it shows, less
|
|
995
|
+
// folded directories and binaries, which have no lines to match.
|
|
996
|
+
sessions.get(repoId).files = files
|
|
997
|
+
.filter(file => !file.directory && file.status !== 'B')
|
|
998
|
+
.map(file => file.path);
|
|
922
999
|
// stderr, not stdout. `reviewer | claude -p ...` puts the finished
|
|
923
1000
|
// review on stdout, and anything else written there lands in the middle
|
|
924
1001
|
// of it. Progress is for the person watching; stdout is for the pipe.
|
|
@@ -1009,13 +1086,30 @@ function createApp(options = {}) {
|
|
|
1009
1086
|
}
|
|
1010
1087
|
});
|
|
1011
1088
|
|
|
1012
|
-
/**
|
|
1089
|
+
/**
|
|
1090
|
+
* Persist the current comments. Called on every edit.
|
|
1091
|
+
*
|
|
1092
|
+
* A caller that sends the `lastUpdated` it loaded saves conditionally
|
|
1093
|
+
* (#107): if the file has moved on since -- another tab, `reviewer
|
|
1094
|
+
* resolve`, an agent -- the save is refused with 409 and the current
|
|
1095
|
+
* comments and stamp ride along, so the caller can merge and retry
|
|
1096
|
+
* instead of silently overwriting what it never saw. A caller that
|
|
1097
|
+
* sends no `lastUpdated` at all keeps the old unconditional behaviour:
|
|
1098
|
+
* scripts written against the original route are not tabs racing each
|
|
1099
|
+
* other, and breaking them would not protect anyone.
|
|
1100
|
+
*/
|
|
1013
1101
|
app.post('/api/save-comments', async (req, res) => {
|
|
1014
1102
|
try {
|
|
1015
1103
|
const { repoId, comments } = req.body ?? {};
|
|
1016
1104
|
const session = requireSession(res, repoId);
|
|
1017
1105
|
if (!session) return;
|
|
1018
1106
|
|
|
1107
|
+
const conditional = Object.prototype.hasOwnProperty.call(req.body ?? {}, 'lastUpdated');
|
|
1108
|
+
const expected = req.body?.lastUpdated ?? null;
|
|
1109
|
+
if (conditional && expected !== null && typeof expected !== 'string') {
|
|
1110
|
+
return res.status(400).json({ error: 'lastUpdated must be a string or null' });
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1019
1113
|
let normalized;
|
|
1020
1114
|
try {
|
|
1021
1115
|
normalized = normalizeComments(comments);
|
|
@@ -1023,27 +1117,47 @@ function createApp(options = {}) {
|
|
|
1023
1117
|
return res.status(400).json({ error: error.message });
|
|
1024
1118
|
}
|
|
1025
1119
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
//
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1120
|
+
// Comments posted without keys -- older pages, scripts -- get the key
|
|
1121
|
+
// their content derives, so re-posting the same list keeps each
|
|
1122
|
+
// comment's identity, and the follow-ups below can find their thread.
|
|
1123
|
+
const incoming = withKeys(normalized);
|
|
1124
|
+
|
|
1125
|
+
let refusal = null;
|
|
1126
|
+
const saved = await updateSavedComments(reviewsDir, session.repoPath, current => {
|
|
1127
|
+
if (conditional && (current.lastUpdated ?? null) !== expected) {
|
|
1128
|
+
refusal = {
|
|
1129
|
+
error: 'Comments have changed since they were loaded. Reload, merge and retry.',
|
|
1130
|
+
comments: current.comments,
|
|
1131
|
+
lastUpdated: current.lastUpdated ?? null
|
|
1132
|
+
};
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1041
1135
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1136
|
+
// `mode` is on the envelope rather than on each comment: it describes
|
|
1137
|
+
// the review, not any one remark in it, and it is what lets `reviewer
|
|
1138
|
+
// export` say what was compared. Without it an exported review could
|
|
1139
|
+
// only report where HEAD is, which says nothing about whether the
|
|
1140
|
+
// comments are about committed work or uncommitted work.
|
|
1141
|
+
//
|
|
1142
|
+
// Follow-ups already on disk are kept for every comment the save
|
|
1143
|
+
// still holds: an agent may have answered in a thread since this tab
|
|
1144
|
+
// last heard from the server, and a whole-list save must not erase it.
|
|
1145
|
+
return {
|
|
1146
|
+
comments: keepFollowUpsFrom(incoming, current.comments),
|
|
1147
|
+
scope: { mode: session.mode, range: session.range, untracked: session.untracked }
|
|
1148
|
+
};
|
|
1149
|
+
});
|
|
1044
1150
|
|
|
1045
|
-
|
|
1151
|
+
const outcome = refusal
|
|
1152
|
+
? { status: 409, body: refusal }
|
|
1153
|
+
// What was written, which can be more than what was sent -- the
|
|
1154
|
+
// follow-ups kept from disk -- so the page can show it at once.
|
|
1155
|
+
: { status: 200, body: { message: 'Comments saved successfully', lastUpdated: saved.lastUpdated, comments: saved.comments } };
|
|
1156
|
+
res.status(outcome.status).json(outcome.body);
|
|
1046
1157
|
} catch (error) {
|
|
1158
|
+
if (error instanceof ReviewOwnershipError) {
|
|
1159
|
+
return res.status(409).json({ error: error.message });
|
|
1160
|
+
}
|
|
1047
1161
|
console.error('Save comments error:', error);
|
|
1048
1162
|
res.status(500).json({ error: error.message });
|
|
1049
1163
|
}
|
|
@@ -1055,8 +1169,8 @@ function createApp(options = {}) {
|
|
|
1055
1169
|
const session = requireSession(res, req.params.repoId);
|
|
1056
1170
|
if (!session) return;
|
|
1057
1171
|
|
|
1058
|
-
const { comments } = await readSavedComments(reviewsDir, session.repoPath);
|
|
1059
|
-
res.json({ comments });
|
|
1172
|
+
const { comments, lastUpdated } = await readSavedComments(reviewsDir, session.repoPath);
|
|
1173
|
+
res.json({ comments, lastUpdated: lastUpdated ?? null });
|
|
1060
1174
|
} catch (error) {
|
|
1061
1175
|
if (error instanceof ReviewOwnershipError) {
|
|
1062
1176
|
return res.status(409).json({ error: error.message });
|
|
@@ -1159,6 +1273,286 @@ function createApp(options = {}) {
|
|
|
1159
1273
|
}
|
|
1160
1274
|
});
|
|
1161
1275
|
|
|
1276
|
+
/**
|
|
1277
|
+
* The live feed for one open review.
|
|
1278
|
+
*
|
|
1279
|
+
* `comments` whenever the saved comments change -- whoever changed them:
|
|
1280
|
+
* another tab, an agent answering in a thread, `reviewer reply` -- and once
|
|
1281
|
+
* on connecting; `agents` whenever an agent connects, leaves, or starts on
|
|
1282
|
+
* a thread; and, while a background session here is answering, `partial`
|
|
1283
|
+
* with the answer as far as it has got. The page applies `comments` the way
|
|
1284
|
+
* it applies a refused save, so nothing it is holding is lost.
|
|
1285
|
+
*/
|
|
1286
|
+
app.get('/api/events/:repoId', (req, res) => {
|
|
1287
|
+
const session = requireSession(res, req.params.repoId);
|
|
1288
|
+
if (!session) return;
|
|
1289
|
+
|
|
1290
|
+
// The page's one long-lived connection once a repository is open, so it
|
|
1291
|
+
// is also how the server knows the tab is still there (see /api/alive).
|
|
1292
|
+
countWatcher(res);
|
|
1293
|
+
|
|
1294
|
+
res.set({
|
|
1295
|
+
'Content-Type': 'text/event-stream',
|
|
1296
|
+
'Cache-Control': 'no-cache',
|
|
1297
|
+
Connection: 'keep-alive'
|
|
1298
|
+
});
|
|
1299
|
+
res.write(': events\n\n');
|
|
1300
|
+
|
|
1301
|
+
const send = (event, data) => {
|
|
1302
|
+
if (!res.writableEnded) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
1303
|
+
};
|
|
1304
|
+
|
|
1305
|
+
const comments = watchComments(reviewsDir, session.repoPath, state => {
|
|
1306
|
+
send('comments', { comments: state.comments, lastUpdated: state.lastUpdated ?? null });
|
|
1307
|
+
}, { onError: error => console.error('Review events:', error.message) });
|
|
1308
|
+
const agents = watchAgents(reviewsDir, session.repoPath, list => {
|
|
1309
|
+
send('agents', { agents: list.map(describeAgent) });
|
|
1310
|
+
});
|
|
1311
|
+
const onLive = (repoPath, event, data) => {
|
|
1312
|
+
if (repoPath === session.repoPath) send(event, data);
|
|
1313
|
+
};
|
|
1314
|
+
live.on('event', onLive);
|
|
1315
|
+
send('answerer', answerers.get(session.repoPath)?.status() ?? null);
|
|
1316
|
+
|
|
1317
|
+
// Proxies close a connection that has been quiet for a minute or two.
|
|
1318
|
+
const keepAlive = setInterval(() => res.write(': ping\n\n'), 20000);
|
|
1319
|
+
keepAlive.unref();
|
|
1320
|
+
|
|
1321
|
+
res.on('close', () => {
|
|
1322
|
+
comments.close();
|
|
1323
|
+
agents.close();
|
|
1324
|
+
live.off('event', onLive);
|
|
1325
|
+
clearInterval(keepAlive);
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* The Claude Code sessions that worked in this repository, newest first,
|
|
1331
|
+
* to choose the one that should answer -- normally the one that wrote the
|
|
1332
|
+
* code under review.
|
|
1333
|
+
*/
|
|
1334
|
+
app.get('/api/agent/sessions/:repoId', async (req, res) => {
|
|
1335
|
+
const session = requireSession(res, req.params.repoId);
|
|
1336
|
+
if (!session) return;
|
|
1337
|
+
try {
|
|
1338
|
+
const sessions = await listClaudeSessions(session.repoPath, { configDir: claude.configDir });
|
|
1339
|
+
res.json({ sessions, answerer: answerers.get(session.repoPath)?.status() ?? null });
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
console.error('Session list error:', error);
|
|
1342
|
+
res.status(500).json({ error: error.message });
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Have a Claude Code session answer this review's comments.
|
|
1348
|
+
*
|
|
1349
|
+
* Resumes the session in the background -- as a copy unless `fork` is
|
|
1350
|
+
* false -- and puts every thread waiting for an answer to it, then every
|
|
1351
|
+
* comment and reply the reviewer writes from now on. Only a session that
|
|
1352
|
+
* worked in this repository can be named: the id goes on a command line,
|
|
1353
|
+
* and the list is the whitelist.
|
|
1354
|
+
*/
|
|
1355
|
+
app.post('/api/agent/connect', async (req, res) => {
|
|
1356
|
+
try {
|
|
1357
|
+
const { repoId, sessionId, fork = true } = req.body ?? {};
|
|
1358
|
+
const session = requireSession(res, repoId);
|
|
1359
|
+
if (!session) return;
|
|
1360
|
+
if (typeof fork !== 'boolean') {
|
|
1361
|
+
return res.status(400).json({ error: 'fork must be true or false' });
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const found = await findClaudeSession(session.repoPath, sessionId, { configDir: claude.configDir });
|
|
1365
|
+
if (!found) {
|
|
1366
|
+
return res.status(404).json({ error: 'No Claude Code session with that id has worked in this repository.' });
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
const answerer = await withAgentLock(session.repoPath, async () => {
|
|
1370
|
+
await answerers.get(session.repoPath)?.stop();
|
|
1371
|
+
answerers.delete(session.repoPath);
|
|
1372
|
+
return startAnswerer(session, found, fork);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
console.error(`Answering comments from Claude Code session ${found.id}${fork ? ' (in a copy)' : ''}`);
|
|
1376
|
+
live.emit('event', session.repoPath, 'answerer', answerer.status());
|
|
1377
|
+
res.json({ answerer: answerer.status() });
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
console.error('Agent connect error:', error);
|
|
1380
|
+
res.status(500).json({ error: error.message });
|
|
1381
|
+
}
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* Start a background session answering a repository's comments.
|
|
1386
|
+
*
|
|
1387
|
+
* @param {{repoPath: string}} session
|
|
1388
|
+
* @param {import('./lib/claude-sessions').ClaudeSession} found
|
|
1389
|
+
* @param {boolean} fork
|
|
1390
|
+
* @returns {Promise<Answerer>}
|
|
1391
|
+
*/
|
|
1392
|
+
async function startAnswerer(session, found, fork) {
|
|
1393
|
+
const about = found.title ?? found.firstPrompt ?? found.id;
|
|
1394
|
+
// Claude Code finds a session by the directory it ran in. If that has
|
|
1395
|
+
// since been removed, the repository root is the next best place to
|
|
1396
|
+
// run from, rather than a spawn error that reads like a missing binary.
|
|
1397
|
+
const cwd = await fs.access(found.cwd).then(() => found.cwd, () => session.repoPath);
|
|
1398
|
+
const background = new BackgroundSession({
|
|
1399
|
+
sessionId: found.id,
|
|
1400
|
+
cwd,
|
|
1401
|
+
fork,
|
|
1402
|
+
name: fork ? `Review answers: ${about}`.slice(0, 80) : null,
|
|
1403
|
+
claude: claude.spawn ? claudeCommand : resolveClaudeCommand(claudeCommand),
|
|
1404
|
+
spawn: claude.spawn
|
|
1405
|
+
});
|
|
1406
|
+
const answerer = new Answerer({
|
|
1407
|
+
reviewsDir,
|
|
1408
|
+
repoPath: session.repoPath,
|
|
1409
|
+
session: background,
|
|
1410
|
+
claudeSession: { id: found.id, title: about }
|
|
1411
|
+
});
|
|
1412
|
+
answerer.on('partial', data => live.emit('event', session.repoPath, 'partial', data));
|
|
1413
|
+
answerer.on('failed', data => live.emit('event', session.repoPath, 'agent-error', data));
|
|
1414
|
+
answerer.on('status', status => live.emit('event', session.repoPath, 'answerer', status));
|
|
1415
|
+
answerer.on('stopped', () => {
|
|
1416
|
+
if (answerers.get(session.repoPath) === answerer) answerers.delete(session.repoPath);
|
|
1417
|
+
live.emit('event', session.repoPath, 'answerer', null);
|
|
1418
|
+
});
|
|
1419
|
+
answerers.set(session.repoPath, answerer);
|
|
1420
|
+
background.start();
|
|
1421
|
+
return answerer;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/** Stop the background session answering this review, if there is one. */
|
|
1425
|
+
app.post('/api/agent/disconnect', async (req, res) => {
|
|
1426
|
+
const { repoId } = req.body ?? {};
|
|
1427
|
+
const session = requireSession(res, repoId);
|
|
1428
|
+
if (!session) return;
|
|
1429
|
+
|
|
1430
|
+
const answerer = await withAgentLock(session.repoPath, async () => {
|
|
1431
|
+
const running = answerers.get(session.repoPath);
|
|
1432
|
+
answerers.delete(session.repoPath);
|
|
1433
|
+
await running?.stop();
|
|
1434
|
+
return running;
|
|
1435
|
+
});
|
|
1436
|
+
live.emit('event', session.repoPath, 'answerer', null);
|
|
1437
|
+
res.json({ disconnected: Boolean(answerer) });
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
// A review's diffs are built once and searched as the reviewer types; this
|
|
1441
|
+
// long is long enough for a burst of typing and short enough to notice an
|
|
1442
|
+
// edit made since.
|
|
1443
|
+
const SEARCH_CACHE_MS = 15000;
|
|
1444
|
+
|
|
1445
|
+
/**
|
|
1446
|
+
* Every changed file's diff, as the page draws it, for searching.
|
|
1447
|
+
*
|
|
1448
|
+
* @param {object} session
|
|
1449
|
+
* @returns {Promise<Array<{path: string, lines: object[]}>>}
|
|
1450
|
+
*/
|
|
1451
|
+
async function diffsForSearch(session) {
|
|
1452
|
+
const cached = session.searchCache;
|
|
1453
|
+
if (cached && Date.now() - cached.at < SEARCH_CACHE_MS) return cached.files;
|
|
1454
|
+
|
|
1455
|
+
const git = gitFactory(session.repoPath);
|
|
1456
|
+
const paths = session.files ?? [];
|
|
1457
|
+
const files = [];
|
|
1458
|
+
// A few at a time: a review of two hundred files should not start two
|
|
1459
|
+
// hundred git processes at once.
|
|
1460
|
+
for (let i = 0; i < paths.length; i += 8) {
|
|
1461
|
+
const batch = await Promise.all(paths.slice(i, i + 8).map(async filePath => {
|
|
1462
|
+
try {
|
|
1463
|
+
const diffText = await diffForFile(git, session, filePath);
|
|
1464
|
+
if (isBinaryDiff(diffText)) return null;
|
|
1465
|
+
const content = await readFileForMode(session, filePath);
|
|
1466
|
+
return { path: filePath, lines: parseDiff(diffText, content) };
|
|
1467
|
+
} catch {
|
|
1468
|
+
return null; // gone since the review was opened
|
|
1469
|
+
}
|
|
1470
|
+
}));
|
|
1471
|
+
files.push(...batch.filter(Boolean));
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
session.searchCache = { at: Date.now(), files };
|
|
1475
|
+
return files;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Search the review: every changed file at once, and with `scope=repo` the
|
|
1480
|
+
* rest of the repository at the tree under review.
|
|
1481
|
+
*
|
|
1482
|
+
* `q` is the text; `mode` is contains, word, prefix, suffix or regex;
|
|
1483
|
+
* `case=1` matches case; `scope` is changes (lines the change added or
|
|
1484
|
+
* deleted), files (every line of the changed files) or repo. `symbol=1`
|
|
1485
|
+
* is a references search: whole word, case-sensitive, and each result is
|
|
1486
|
+
* marked as a definition or a use. Results say which side of the change
|
|
1487
|
+
* they are on, and `diffIndex` is the line's position in the diff the
|
|
1488
|
+
* page draws for that file.
|
|
1489
|
+
*/
|
|
1490
|
+
app.get('/api/search/:repoId', async (req, res) => {
|
|
1491
|
+
const session = requireSession(res, req.params.repoId);
|
|
1492
|
+
if (!session) return;
|
|
1493
|
+
|
|
1494
|
+
const pick = name => (typeof req.query[name] === 'string' ? req.query[name] : '');
|
|
1495
|
+
const query = pick('q');
|
|
1496
|
+
const symbol = pick('symbol') === '1';
|
|
1497
|
+
const scope = pick('scope') || 'files';
|
|
1498
|
+
const match = { mode: symbol ? 'word' : (pick('mode') || 'contains'), caseSensitive: symbol || pick('case') === '1' };
|
|
1499
|
+
if (!SCOPES.includes(scope)) {
|
|
1500
|
+
return res.status(400).json({ error: `Unknown scope "${scope}".` });
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
let matcher;
|
|
1504
|
+
try {
|
|
1505
|
+
matcher = buildMatcher(query, match);
|
|
1506
|
+
} catch (error) {
|
|
1507
|
+
if (error instanceof SearchError) return res.status(400).json({ error: error.message });
|
|
1508
|
+
throw error;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
try {
|
|
1512
|
+
const started = Date.now();
|
|
1513
|
+
const files = await diffsForSearch(session);
|
|
1514
|
+
const found = searchDiffs(files, matcher, { changedOnly: scope === 'changes' });
|
|
1515
|
+
let { results, truncated } = found;
|
|
1516
|
+
|
|
1517
|
+
if (scope === 'repo' && results.length < MAX_RESULTS) {
|
|
1518
|
+
const elsewhere = await grepRepository({
|
|
1519
|
+
repoPath: session.repoPath,
|
|
1520
|
+
session,
|
|
1521
|
+
query,
|
|
1522
|
+
match,
|
|
1523
|
+
matcher,
|
|
1524
|
+
exclude: new Set(files.map(file => file.path)),
|
|
1525
|
+
limit: MAX_RESULTS - results.length
|
|
1526
|
+
});
|
|
1527
|
+
results = results.concat(elsewhere.results);
|
|
1528
|
+
truncated = truncated || elsewhere.truncated;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
let summary = null;
|
|
1532
|
+
if (symbol) {
|
|
1533
|
+
for (const result of results) result.role = definesName(result.text, query) ? 'definition' : 'reference';
|
|
1534
|
+
summary = summarizeReferences(results);
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
res.json({
|
|
1538
|
+
query,
|
|
1539
|
+
scope,
|
|
1540
|
+
mode: match.mode,
|
|
1541
|
+
caseSensitive: match.caseSensitive,
|
|
1542
|
+
symbol,
|
|
1543
|
+
results,
|
|
1544
|
+
truncated,
|
|
1545
|
+
files: new Set(results.map(result => result.file)).size,
|
|
1546
|
+
searched: files.length,
|
|
1547
|
+
ms: Date.now() - started,
|
|
1548
|
+
summary
|
|
1549
|
+
});
|
|
1550
|
+
} catch (error) {
|
|
1551
|
+
console.error('Search error:', error);
|
|
1552
|
+
res.status(500).json({ error: error.message });
|
|
1553
|
+
}
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1162
1556
|
/** Drop a session. Saved comments on disk are left alone. */
|
|
1163
1557
|
app.delete('/api/cleanup/:repoId', (req, res) => {
|
|
1164
1558
|
sessions.delete(req.params.repoId);
|
|
@@ -1197,6 +1591,9 @@ function startServer(options = {}) {
|
|
|
1197
1591
|
}
|
|
1198
1592
|
resolve(server);
|
|
1199
1593
|
});
|
|
1594
|
+
// A background session must not outlive the server that started it.
|
|
1595
|
+
server.stopAgents = app.locals.stopAgents;
|
|
1596
|
+
server.on('close', () => { app.locals.stopAgents(); });
|
|
1200
1597
|
server.on('error', reject);
|
|
1201
1598
|
});
|
|
1202
1599
|
}
|