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/lib/search.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Finding text across a review: every changed file at once, the way an IDE's
|
|
7
|
+
* find navigator searches a project rather than the open file.
|
|
8
|
+
*
|
|
9
|
+
* The question it is built for is the one a reviewer cannot answer from one
|
|
10
|
+
* file: "this function is new here -- where is it called, and where was it
|
|
11
|
+
* called before this change?" So every result says which side of the change
|
|
12
|
+
* it is on: a line this change added, one it deleted, unchanged code inside a
|
|
13
|
+
* changed file, or code in the rest of the repository the change did not
|
|
14
|
+
* touch. A references search goes one step further and tells definitions from
|
|
15
|
+
* uses, and says when a definition changed but its callers did not.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// What counts as part of an identifier, for whole-word matching. `$` is in
|
|
19
|
+
// it because JavaScript identifiers use it.
|
|
20
|
+
const IDENT = 'A-Za-z0-9_$';
|
|
21
|
+
const MODES = ['contains', 'word', 'prefix', 'suffix', 'regex'];
|
|
22
|
+
const SCOPES = ['changes', 'files', 'repo'];
|
|
23
|
+
|
|
24
|
+
const MAX_RESULTS = 2000;
|
|
25
|
+
const MAX_PER_FILE = 300;
|
|
26
|
+
const MAX_LINE = 2000;
|
|
27
|
+
const MAX_RANGES = 20;
|
|
28
|
+
|
|
29
|
+
/** A search that cannot run as asked; the message is for the person who typed it. */
|
|
30
|
+
class SearchError extends Error {
|
|
31
|
+
constructor(message) {
|
|
32
|
+
super(message);
|
|
33
|
+
this.name = 'SearchError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const escapeRegExp = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The matcher for a query, as a global regular expression.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} query
|
|
43
|
+
* @param {object} [options]
|
|
44
|
+
* @param {string} [options.mode] contains, word, prefix, suffix or regex
|
|
45
|
+
* @param {boolean} [options.caseSensitive]
|
|
46
|
+
* @returns {RegExp}
|
|
47
|
+
* @throws {SearchError}
|
|
48
|
+
*/
|
|
49
|
+
function buildMatcher(query, { mode = 'contains', caseSensitive = false } = {}) {
|
|
50
|
+
if (typeof query !== 'string' || query.trim() === '') throw new SearchError('Type something to search for.');
|
|
51
|
+
if (query.length > 500) throw new SearchError('That search is longer than 500 characters.');
|
|
52
|
+
if (!MODES.includes(mode)) throw new SearchError(`Unknown match mode "${mode}".`);
|
|
53
|
+
|
|
54
|
+
const literal = escapeRegExp(query);
|
|
55
|
+
const source = {
|
|
56
|
+
contains: literal,
|
|
57
|
+
word: `(?<![${IDENT}])${literal}(?![${IDENT}])`,
|
|
58
|
+
prefix: `(?<![${IDENT}])${literal}`,
|
|
59
|
+
suffix: `${literal}(?![${IDENT}])`,
|
|
60
|
+
regex: query
|
|
61
|
+
}[mode];
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
return new RegExp(source, caseSensitive ? 'g' : 'gi');
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw new SearchError(`That is not a valid regular expression: ${error.message.replace(/^Invalid regular expression: /, '')}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Where a matcher matches on one line, as [start, end) pairs, so the page can
|
|
72
|
+
* mark exactly what matched. Very long lines -- minified bundles -- are
|
|
73
|
+
* searched only as far as MAX_LINE characters.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} text
|
|
76
|
+
* @param {RegExp} matcher a global RegExp from buildMatcher
|
|
77
|
+
* @returns {Array<[number, number]>}
|
|
78
|
+
*/
|
|
79
|
+
function matchRanges(text, matcher) {
|
|
80
|
+
const line = text.length > MAX_LINE ? text.slice(0, MAX_LINE) : text;
|
|
81
|
+
const ranges = [];
|
|
82
|
+
matcher.lastIndex = 0;
|
|
83
|
+
let match;
|
|
84
|
+
while ((match = matcher.exec(line)) !== null) {
|
|
85
|
+
if (match[0].length === 0) {
|
|
86
|
+
matcher.lastIndex += 1; // an empty regex match would never advance
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
ranges.push([match.index, match.index + match[0].length]);
|
|
90
|
+
if (ranges.length >= MAX_RANGES) break;
|
|
91
|
+
}
|
|
92
|
+
return ranges;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether a line defines `name`, as far as one line can show it, in the
|
|
97
|
+
* languages people most often review: a function, class, method, type or
|
|
98
|
+
* variable declared under that name. A heuristic, and labelled as one in the
|
|
99
|
+
* page: a references search sorts by it, never hides by it.
|
|
100
|
+
*
|
|
101
|
+
* @param {string} text
|
|
102
|
+
* @param {string} name
|
|
103
|
+
* @returns {boolean}
|
|
104
|
+
*/
|
|
105
|
+
function definesName(text, name) {
|
|
106
|
+
const n = escapeRegExp(name);
|
|
107
|
+
const end = `(?![${IDENT}])`;
|
|
108
|
+
const declared = new RegExp(
|
|
109
|
+
`(?:^|[^${IDENT}.])(?:function\\*?|def|class|interface|type|struct|enum|trait|fn|func|const|let|var|val|module)\\s+${n}${end}`
|
|
110
|
+
);
|
|
111
|
+
const assigned = new RegExp(
|
|
112
|
+
`(?:^|[\\s{,;(])${n}\\s*[:=]\\s*(?:async\\s+)?(?:function\\b|\\([^)]*\\)\\s*=>|[A-Za-z_$][\\w$]*\\s*=>)`
|
|
113
|
+
);
|
|
114
|
+
const method = new RegExp(
|
|
115
|
+
`^\\s*(?:(?:public|private|protected|static|async|override|export|default|get|set)\\s+)*\\*?${n}\\s*\\([^)]*\\)\\s*(?::\\s*[^{]+)?\\{\\s*$`
|
|
116
|
+
);
|
|
117
|
+
return declared.test(text) || assigned.test(text) || method.test(text);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The side of the change a diff line is on.
|
|
122
|
+
*
|
|
123
|
+
* @param {'add'|'delete'|'unchanged'} type
|
|
124
|
+
* @returns {'added'|'deleted'|'context'}
|
|
125
|
+
*/
|
|
126
|
+
function sideOf(type) {
|
|
127
|
+
return type === 'add' ? 'added' : type === 'delete' ? 'deleted' : 'context';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Search the changed files of a review.
|
|
132
|
+
*
|
|
133
|
+
* @param {Array<{path: string, lines: Array<{type: string, oldLine: number|null, newLine: number|null, content: string}>}>} files
|
|
134
|
+
* each changed file's diff, as the page draws it: every line of the file,
|
|
135
|
+
* with what was added and deleted marked
|
|
136
|
+
* @param {RegExp} matcher
|
|
137
|
+
* @param {object} [options]
|
|
138
|
+
* @param {boolean} [options.changedOnly] only lines the change added or deleted
|
|
139
|
+
* @returns {{results: object[], truncated: boolean}}
|
|
140
|
+
*/
|
|
141
|
+
function searchDiffs(files, matcher, { changedOnly = false } = {}) {
|
|
142
|
+
const results = [];
|
|
143
|
+
let truncated = false;
|
|
144
|
+
|
|
145
|
+
for (const file of files) {
|
|
146
|
+
let inFile = 0;
|
|
147
|
+
for (let index = 0; index < file.lines.length; index++) {
|
|
148
|
+
const line = file.lines[index];
|
|
149
|
+
if (changedOnly && line.type !== 'add' && line.type !== 'delete') continue;
|
|
150
|
+
const ranges = matchRanges(line.content, matcher);
|
|
151
|
+
if (ranges.length === 0) continue;
|
|
152
|
+
|
|
153
|
+
if (results.length >= MAX_RESULTS || inFile >= MAX_PER_FILE) {
|
|
154
|
+
truncated = true;
|
|
155
|
+
if (results.length >= MAX_RESULTS) return { results, truncated };
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
results.push({
|
|
159
|
+
file: file.path,
|
|
160
|
+
side: sideOf(line.type),
|
|
161
|
+
line: line.newLine ?? line.oldLine,
|
|
162
|
+
oldLine: line.oldLine,
|
|
163
|
+
newLine: line.newLine,
|
|
164
|
+
diffIndex: index,
|
|
165
|
+
text: line.content.slice(0, MAX_LINE),
|
|
166
|
+
ranges
|
|
167
|
+
});
|
|
168
|
+
inFile += 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { results, truncated };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The git grep arguments for a query, as close to the matcher as git's own
|
|
177
|
+
* dialect allows. Whatever git finds is filtered through the real matcher
|
|
178
|
+
* afterwards, so git only has to be no narrower than it.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} query
|
|
181
|
+
* @param {{mode: string, caseSensitive: boolean}} options
|
|
182
|
+
* @returns {string[]}
|
|
183
|
+
*/
|
|
184
|
+
function grepPattern(query, { mode, caseSensitive }) {
|
|
185
|
+
const literal = query.replace(/[.^$*+?()[\]{}|\\]/g, '\\$&');
|
|
186
|
+
const args = caseSensitive ? [] : ['-i'];
|
|
187
|
+
if (mode === 'contains' || mode === 'word' || mode === 'prefix' || mode === 'suffix') {
|
|
188
|
+
// A fixed-string search for the text is never narrower than any of
|
|
189
|
+
// these, and git does it fastest; the boundaries are checked in JS.
|
|
190
|
+
return [...args, '-F', '-e', query];
|
|
191
|
+
}
|
|
192
|
+
return [...args, '-E', '-e', mode === 'regex' ? query : literal];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Lines matching the query in the repository's files, on the reviewed side.
|
|
197
|
+
*
|
|
198
|
+
* Runs `git grep` against the exact tree under review -- the working tree, the
|
|
199
|
+
* index, or the head commit of a comparison -- and stops reading once it has
|
|
200
|
+
* enough, so a match in every file of a large repository costs the first
|
|
201
|
+
* MAX_RESULTS lines and no more.
|
|
202
|
+
*
|
|
203
|
+
* @param {object} options
|
|
204
|
+
* @param {string} options.repoPath
|
|
205
|
+
* @param {{mode: string, range: {head: string}|null}} options.session
|
|
206
|
+
* @param {string} options.query
|
|
207
|
+
* @param {{mode: string, caseSensitive: boolean}} options.match
|
|
208
|
+
* @param {RegExp} options.matcher
|
|
209
|
+
* @param {Set<string>} options.exclude paths already searched through their diff
|
|
210
|
+
* @param {number} [options.limit]
|
|
211
|
+
* @returns {Promise<{results: object[], truncated: boolean}>}
|
|
212
|
+
*/
|
|
213
|
+
function grepRepository({ repoPath, session, query, match, matcher, exclude, limit = MAX_RESULTS }) {
|
|
214
|
+
const where = session.mode === 'staged' ? ['--cached']
|
|
215
|
+
: session.range ? [session.range.head]
|
|
216
|
+
: [];
|
|
217
|
+
const rev = session.range && session.mode !== 'staged' ? session.range.head : null;
|
|
218
|
+
const args = ['grep', '-z', '-n', '-I', '--no-color', ...grepPattern(query, match), ...where, '--'];
|
|
219
|
+
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
const child = spawn('git', args, { cwd: repoPath, windowsHide: true });
|
|
222
|
+
const results = [];
|
|
223
|
+
const perFile = new Map();
|
|
224
|
+
let buffered = '';
|
|
225
|
+
let truncated = false;
|
|
226
|
+
let done = false;
|
|
227
|
+
|
|
228
|
+
const finish = () => {
|
|
229
|
+
if (done) return;
|
|
230
|
+
done = true;
|
|
231
|
+
resolve({ results, truncated });
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
child.stdout.setEncoding('utf-8');
|
|
235
|
+
child.stdout.on('data', chunk => {
|
|
236
|
+
if (done) return;
|
|
237
|
+
buffered += chunk;
|
|
238
|
+
let newline;
|
|
239
|
+
while ((newline = buffered.indexOf('\n')) >= 0) {
|
|
240
|
+
const record = buffered.slice(0, newline);
|
|
241
|
+
buffered = buffered.slice(newline + 1);
|
|
242
|
+
const [location, lineNumber, ...rest] = record.split('\0');
|
|
243
|
+
if (lineNumber === undefined) continue;
|
|
244
|
+
const file = rev && location.startsWith(`${rev}:`) ? location.slice(rev.length + 1) : location;
|
|
245
|
+
if (exclude.has(file)) continue;
|
|
246
|
+
const text = rest.join('\0');
|
|
247
|
+
const ranges = matchRanges(text, matcher);
|
|
248
|
+
if (ranges.length === 0) continue;
|
|
249
|
+
|
|
250
|
+
const count = perFile.get(file) ?? 0;
|
|
251
|
+
if (count >= MAX_PER_FILE) {
|
|
252
|
+
truncated = true;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
perFile.set(file, count + 1);
|
|
256
|
+
results.push({
|
|
257
|
+
file,
|
|
258
|
+
side: 'untouched',
|
|
259
|
+
line: Number(lineNumber),
|
|
260
|
+
oldLine: null,
|
|
261
|
+
newLine: Number(lineNumber),
|
|
262
|
+
diffIndex: null,
|
|
263
|
+
text: text.slice(0, MAX_LINE),
|
|
264
|
+
ranges
|
|
265
|
+
});
|
|
266
|
+
if (results.length >= limit) {
|
|
267
|
+
truncated = true;
|
|
268
|
+
child.kill();
|
|
269
|
+
finish();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
child.on('error', error => {
|
|
275
|
+
if (!done) {
|
|
276
|
+
done = true;
|
|
277
|
+
reject(error);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
// git grep exits 1 when nothing matched, which is an answer, not a failure.
|
|
281
|
+
child.on('close', finish);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* What a references search found, in the terms a reviewer asks it.
|
|
287
|
+
*
|
|
288
|
+
* @param {object[]} results with `role` set
|
|
289
|
+
* @returns {{definitions: number, added: number, removed: number, unchanged: number, warning: string|null}}
|
|
290
|
+
*/
|
|
291
|
+
function summarizeReferences(results) {
|
|
292
|
+
const uses = results.filter(result => result.role === 'reference');
|
|
293
|
+
const definitions = results.filter(result => result.role === 'definition');
|
|
294
|
+
const summary = {
|
|
295
|
+
definitions: definitions.length,
|
|
296
|
+
added: uses.filter(result => result.side === 'added').length,
|
|
297
|
+
removed: uses.filter(result => result.side === 'deleted').length,
|
|
298
|
+
unchanged: uses.filter(result => result.side === 'context' || result.side === 'untouched').length,
|
|
299
|
+
warning: null
|
|
300
|
+
};
|
|
301
|
+
const definitionChanged = definitions.some(result => result.side === 'added' || result.side === 'deleted');
|
|
302
|
+
if (definitionChanged && summary.unchanged > 0) {
|
|
303
|
+
summary.warning = `The definition changed, and ${summary.unchanged} ${summary.unchanged === 1 ? 'use was' : 'uses were'} left as they were. Check that they still fit.`;
|
|
304
|
+
}
|
|
305
|
+
return summary;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
buildMatcher,
|
|
310
|
+
matchRanges,
|
|
311
|
+
definesName,
|
|
312
|
+
searchDiffs,
|
|
313
|
+
grepRepository,
|
|
314
|
+
summarizeReferences,
|
|
315
|
+
SearchError,
|
|
316
|
+
MODES,
|
|
317
|
+
SCOPES,
|
|
318
|
+
MAX_RESULTS
|
|
319
|
+
};
|
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
|
+
};
|