git-reviewer 2.14.1 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -4
- package/bin/reviewer.js +286 -7
- package/lib/agent.js +25 -5
- package/lib/answerer.js +191 -0
- package/lib/changes.js +83 -4
- package/lib/channel.js +286 -0
- package/lib/claude-background.js +316 -0
- package/lib/claude-command.js +90 -0
- package/lib/claude-sessions.js +229 -0
- package/lib/cli.js +197 -20
- package/lib/comments.js +175 -10
- package/lib/export.js +2 -2
- package/lib/presence.js +223 -0
- package/lib/review.js +3 -1
- package/lib/sessions.js +8 -4
- package/lib/store.js +290 -7
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +58 -0
- package/package.json +1 -1
- package/public/app.js +811 -119
- package/public/index.html +33 -0
- package/public/style.css +277 -0
- package/server.js +338 -43
package/server.js
CHANGED
|
@@ -12,16 +12,24 @@ const {
|
|
|
12
12
|
resolveRepoFile,
|
|
13
13
|
PathEscapeError
|
|
14
14
|
} = require('./lib/paths');
|
|
15
|
-
const {
|
|
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
|
-
const { buildReviewDocument } = require('./lib/agent');
|
|
20
|
-
const { readSavedComments, ReviewOwnershipError } = require('./lib/store');
|
|
20
|
+
const { buildReviewDocument, describeScope } = require('./lib/agent');
|
|
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
|
-
|
|
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. */
|
|
@@ -838,7 +907,10 @@ function createApp(options = {}) {
|
|
|
838
907
|
// Resolve to the root of the working tree before anything is keyed on
|
|
839
908
|
// this path. A subdirectory passes `checkIsRepo()` quite happily and
|
|
840
909
|
// then produces a file list whose diffs are all empty; see lib/repo.js.
|
|
841
|
-
const { base, head, commits: run, staged = false } = req.body ?? {};
|
|
910
|
+
const { base, head, commits: run, staged = false, untracked = true } = req.body ?? {};
|
|
911
|
+
if (typeof untracked !== 'boolean') {
|
|
912
|
+
return res.status(400).json({ error: 'The untracked option must be a boolean.' });
|
|
913
|
+
}
|
|
842
914
|
if (typeof staged !== 'boolean' || (staged && (base != null || head != null || run != null))) {
|
|
843
915
|
return res.status(400).json({ error: 'Choose staged changes or a commit comparison, not both.' });
|
|
844
916
|
}
|
|
@@ -887,12 +959,14 @@ function createApp(options = {}) {
|
|
|
887
959
|
mode = 'range';
|
|
888
960
|
message = describeRange(range, files.length);
|
|
889
961
|
} else {
|
|
890
|
-
files =
|
|
962
|
+
files = await collectWorkingTreeChanges(git, untracked);
|
|
891
963
|
mode = 'working';
|
|
892
|
-
message =
|
|
964
|
+
message = describeWorkingChanges(files, untracked);
|
|
893
965
|
|
|
894
966
|
// A clean tree has nothing to review, so fall back to the last commit.
|
|
895
|
-
|
|
967
|
+
// Hiding untracked files is an explicit working-tree scope. An empty
|
|
968
|
+
// tracked review must not silently become a review of committed work.
|
|
969
|
+
if (files.length === 0 && untracked) {
|
|
896
970
|
try {
|
|
897
971
|
const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
|
|
898
972
|
const commitFiles = await collectRangeChanges(git, lastCommit);
|
|
@@ -910,7 +984,10 @@ function createApp(options = {}) {
|
|
|
910
984
|
}
|
|
911
985
|
}
|
|
912
986
|
|
|
913
|
-
const repoId = sessions.create(root, mode, range);
|
|
987
|
+
const repoId = sessions.create(root, mode, range, untracked);
|
|
988
|
+
sessions.get(repoId).foldedDirectories = new Map(
|
|
989
|
+
files.filter(file => file.directory).map(file => [file.path, file.fileCount])
|
|
990
|
+
);
|
|
914
991
|
// stderr, not stdout. `reviewer | claude -p ...` puts the finished
|
|
915
992
|
// review on stdout, and anything else written there lands in the middle
|
|
916
993
|
// of it. Progress is for the person watching; stdout is for the pipe.
|
|
@@ -925,7 +1002,7 @@ function createApp(options = {}) {
|
|
|
925
1002
|
|
|
926
1003
|
// `repoPath` in the response is the resolved root, not what was asked
|
|
927
1004
|
// for, so the page can show which repository it actually opened.
|
|
928
|
-
res.json({ repoId, files, repoPath: root, mode, range, message });
|
|
1005
|
+
res.json({ repoId, files, repoPath: root, mode, range, message, untracked });
|
|
929
1006
|
} catch (error) {
|
|
930
1007
|
console.error('Load repo error:', error);
|
|
931
1008
|
res.status(500).json({ error: error.message });
|
|
@@ -946,6 +1023,10 @@ function createApp(options = {}) {
|
|
|
946
1023
|
// works because of statement order is not a containment check.
|
|
947
1024
|
resolveRepoFile(session.repoPath, filePath);
|
|
948
1025
|
|
|
1026
|
+
if (session.foldedDirectories?.has(filePath)) {
|
|
1027
|
+
return res.json({ filePath, directory: true, fileCount: session.foldedDirectories.get(filePath), binary: false, diffLines: [] });
|
|
1028
|
+
}
|
|
1029
|
+
|
|
949
1030
|
const git = gitFactory(session.repoPath);
|
|
950
1031
|
const diffText = await diffForFile(git, session, filePath);
|
|
951
1032
|
|
|
@@ -978,6 +1059,11 @@ function createApp(options = {}) {
|
|
|
978
1059
|
const session = requireSession(res, repoId);
|
|
979
1060
|
if (!session) return;
|
|
980
1061
|
|
|
1062
|
+
resolveRepoFile(session.repoPath, filePath);
|
|
1063
|
+
if (session.foldedDirectories?.has(filePath)) {
|
|
1064
|
+
return res.json({ filePath, directory: true, fileCount: session.foldedDirectories.get(filePath), lines: [] });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
981
1067
|
const content = await readFileForMode(session, filePath);
|
|
982
1068
|
res.json({ filePath, lines: content.split('\n') });
|
|
983
1069
|
} catch (error) {
|
|
@@ -992,13 +1078,30 @@ function createApp(options = {}) {
|
|
|
992
1078
|
}
|
|
993
1079
|
});
|
|
994
1080
|
|
|
995
|
-
/**
|
|
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
|
+
*/
|
|
996
1093
|
app.post('/api/save-comments', async (req, res) => {
|
|
997
1094
|
try {
|
|
998
1095
|
const { repoId, comments } = req.body ?? {};
|
|
999
1096
|
const session = requireSession(res, repoId);
|
|
1000
1097
|
if (!session) return;
|
|
1001
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
|
+
|
|
1002
1105
|
let normalized;
|
|
1003
1106
|
try {
|
|
1004
1107
|
normalized = normalizeComments(comments);
|
|
@@ -1006,26 +1109,47 @@ function createApp(options = {}) {
|
|
|
1006
1109
|
return res.status(400).json({ error: error.message });
|
|
1007
1110
|
}
|
|
1008
1111
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
//
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
+
}
|
|
1023
1127
|
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
+
});
|
|
1026
1142
|
|
|
1027
|
-
|
|
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);
|
|
1028
1149
|
} catch (error) {
|
|
1150
|
+
if (error instanceof ReviewOwnershipError) {
|
|
1151
|
+
return res.status(409).json({ error: error.message });
|
|
1152
|
+
}
|
|
1029
1153
|
console.error('Save comments error:', error);
|
|
1030
1154
|
res.status(500).json({ error: error.message });
|
|
1031
1155
|
}
|
|
@@ -1037,8 +1161,8 @@ function createApp(options = {}) {
|
|
|
1037
1161
|
const session = requireSession(res, req.params.repoId);
|
|
1038
1162
|
if (!session) return;
|
|
1039
1163
|
|
|
1040
|
-
const { comments } = await readSavedComments(reviewsDir, session.repoPath);
|
|
1041
|
-
res.json({ comments });
|
|
1164
|
+
const { comments, lastUpdated } = await readSavedComments(reviewsDir, session.repoPath);
|
|
1165
|
+
res.json({ comments, lastUpdated: lastUpdated ?? null });
|
|
1042
1166
|
} catch (error) {
|
|
1043
1167
|
if (error instanceof ReviewOwnershipError) {
|
|
1044
1168
|
return res.status(409).json({ error: error.message });
|
|
@@ -1066,7 +1190,6 @@ function createApp(options = {}) {
|
|
|
1066
1190
|
}
|
|
1067
1191
|
|
|
1068
1192
|
const generatedAt = new Date();
|
|
1069
|
-
const reviewContent = formatReview({ repoPath: session.repoPath, comments, generatedAt });
|
|
1070
1193
|
const filename = reviewFilename(session.repoPath, generatedAt);
|
|
1071
1194
|
|
|
1072
1195
|
// The commit is worth recording for anything acting on the review later:
|
|
@@ -1082,7 +1205,12 @@ function createApp(options = {}) {
|
|
|
1082
1205
|
head,
|
|
1083
1206
|
branch,
|
|
1084
1207
|
mode: session.mode,
|
|
1085
|
-
range: session.range
|
|
1208
|
+
range: session.range,
|
|
1209
|
+
untracked: session.untracked
|
|
1210
|
+
});
|
|
1211
|
+
const reviewContent = formatReview({
|
|
1212
|
+
repoPath: session.repoPath, comments, generatedAt,
|
|
1213
|
+
scope: document.untracked === false ? describeScope(document) : null
|
|
1086
1214
|
});
|
|
1087
1215
|
const documentFilename = filename.replace(/\.txt$/, '.json');
|
|
1088
1216
|
|
|
@@ -1137,6 +1265,170 @@ function createApp(options = {}) {
|
|
|
1137
1265
|
}
|
|
1138
1266
|
});
|
|
1139
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
|
+
|
|
1140
1432
|
/** Drop a session. Saved comments on disk are left alone. */
|
|
1141
1433
|
app.delete('/api/cleanup/:repoId', (req, res) => {
|
|
1142
1434
|
sessions.delete(req.params.repoId);
|
|
@@ -1175,6 +1467,9 @@ function startServer(options = {}) {
|
|
|
1175
1467
|
}
|
|
1176
1468
|
resolve(server);
|
|
1177
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(); });
|
|
1178
1473
|
server.on('error', reject);
|
|
1179
1474
|
});
|
|
1180
1475
|
}
|