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/lib/cli.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
5
|
+
const { KEY_PATTERN, AUTHOR_PATTERN } = require('./comments');
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Command-line parsing for the `reviewer` command.
|
|
7
9
|
*
|
|
@@ -15,6 +17,10 @@ function usage(name = 'git reviewer') {
|
|
|
15
17
|
Usage:
|
|
16
18
|
${name} [repository] [options]
|
|
17
19
|
${name} export [repository] [--format json|prompt] [--file <path>]
|
|
20
|
+
${name} connect [repository] [--session <id>] [--fork] [--list]
|
|
21
|
+
${name} reply <key> <text | -> [--repo <path>] [--as <name>]
|
|
22
|
+
${name} watch [repository]
|
|
23
|
+
${name} channel [repository]
|
|
18
24
|
|
|
19
25
|
One program, two names. git reviewer is the name the package is installed as
|
|
20
26
|
-- git runs any git-<name> on your PATH as a subcommand -- and reviewer is the
|
|
@@ -38,14 +44,34 @@ Arguments:
|
|
|
38
44
|
Commands:
|
|
39
45
|
export Print the saved review of a repository to stdout, for a
|
|
40
46
|
coding agent or another tool to consume.
|
|
47
|
+
connect Resume the Claude Code session that worked in this
|
|
48
|
+
repository with reviewer attached: every comment you
|
|
49
|
+
write is sent to it, and it answers in the thread.
|
|
50
|
+
--list shows the sessions; --session picks one.
|
|
51
|
+
reply Post a reply in a comment thread, as an agent: the
|
|
52
|
+
page shows it under the comment straight away. Use -
|
|
53
|
+
as the text to read it from stdin.
|
|
54
|
+
watch Print each new comment or reply from the reviewer as
|
|
55
|
+
one JSON line, for an agent to follow and answer.
|
|
56
|
+
channel The MCP server Claude Code runs for connect. Not
|
|
57
|
+
something to run by hand.
|
|
41
58
|
|
|
42
59
|
Options:
|
|
43
60
|
-p, --port <n> Port to listen on (default 4500; falls back to a free
|
|
44
61
|
port if that one is taken)
|
|
45
62
|
--no-open Print the URL instead of opening a browser
|
|
63
|
+
--no-intro Open the review without the first-run help
|
|
64
|
+
--no-untracked Exclude untracked files from working directory reviews
|
|
46
65
|
--staged Review only staged changes (HEAD vs index)
|
|
66
|
+
--json Print one JSON line on stderr at startup instead of the banner
|
|
47
67
|
-f, --format <f> Export format: json (default) or prompt
|
|
48
68
|
--file <path> Export comments for one repo-relative file
|
|
69
|
+
--session <id> connect: the Claude Code session to resume
|
|
70
|
+
--fork connect: answer in a copy of the session
|
|
71
|
+
--list connect: list this repository's sessions
|
|
72
|
+
--repo <path> reply: the repository (default: the current one)
|
|
73
|
+
--as <name> reply: who the reply is from (default: claude inside
|
|
74
|
+
Claude Code, agent elsewhere)
|
|
49
75
|
-h, --help Show this help
|
|
50
76
|
-v, --version Show the version
|
|
51
77
|
|
|
@@ -64,12 +90,17 @@ Examples:
|
|
|
64
90
|
only the comments on one file
|
|
65
91
|
${name} export . --format prompt | claude -p "apply this review"
|
|
66
92
|
hand over a review written earlier
|
|
93
|
+
${name} connect
|
|
94
|
+
have the session that wrote the code answer your comments
|
|
67
95
|
`;
|
|
68
96
|
}
|
|
69
97
|
|
|
70
98
|
/** Formats `reviewer export` can print. */
|
|
71
99
|
const FORMATS = ['json', 'prompt'];
|
|
72
100
|
|
|
101
|
+
/** Words that, first on the command line, name a subcommand. */
|
|
102
|
+
const COMMANDS = ['export', 'connect', 'reply', 'watch', 'channel'];
|
|
103
|
+
|
|
73
104
|
class UsageError extends Error {
|
|
74
105
|
/** @param {string} message */
|
|
75
106
|
constructor(message) {
|
|
@@ -80,13 +111,23 @@ class UsageError extends Error {
|
|
|
80
111
|
|
|
81
112
|
/**
|
|
82
113
|
* @typedef {object} CliOptions
|
|
83
|
-
* @property {'serve'|'export'} command
|
|
114
|
+
* @property {'serve'|'export'|'connect'|'reply'|'watch'|'channel'} command
|
|
84
115
|
* @property {string|null} repoPath absolute path, or null when not given
|
|
85
116
|
* @property {number|null} port explicit port, or null to use the default
|
|
86
117
|
* @property {boolean} open whether to launch a browser
|
|
118
|
+
* @property {boolean} noIntro skip the first-run help dialog
|
|
87
119
|
* @property {boolean} staged whether to review only the index
|
|
120
|
+
* @property {boolean} untracked whether to include untracked files
|
|
121
|
+
* @property {boolean} json whether to print the startup info as one JSON line
|
|
88
122
|
* @property {'json'|'prompt'} format for `export`
|
|
89
123
|
* @property {string|null} file repo-relative file to export, or all files
|
|
124
|
+
* @property {string|null} session `connect`: Claude Code session id
|
|
125
|
+
* @property {boolean} fork `connect`: resume a copy of the session
|
|
126
|
+
* @property {boolean} list `connect`: list sessions instead of connecting
|
|
127
|
+
* @property {string|null} key `reply`: the comment to reply to
|
|
128
|
+
* @property {string|null} text `reply`: what to say
|
|
129
|
+
* @property {boolean} textFromStdin `reply`: the text is to be read from stdin
|
|
130
|
+
* @property {string|null} as `reply`: who it is from
|
|
90
131
|
* @property {boolean} help
|
|
91
132
|
* @property {boolean} version
|
|
92
133
|
*/
|
|
@@ -106,26 +147,54 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
106
147
|
repoPath: null,
|
|
107
148
|
port: null,
|
|
108
149
|
open: true,
|
|
150
|
+
noIntro: false,
|
|
109
151
|
staged: false,
|
|
152
|
+
untracked: true,
|
|
153
|
+
json: false,
|
|
110
154
|
format: 'json',
|
|
111
155
|
file: null,
|
|
156
|
+
session: null,
|
|
157
|
+
fork: false,
|
|
158
|
+
list: false,
|
|
159
|
+
key: null,
|
|
160
|
+
text: null,
|
|
161
|
+
textFromStdin: false,
|
|
162
|
+
as: null,
|
|
112
163
|
help: false,
|
|
113
164
|
version: false
|
|
114
165
|
};
|
|
115
166
|
let positional = null;
|
|
167
|
+
const positionals = [];
|
|
168
|
+
let replyRepo = null;
|
|
116
169
|
|
|
117
|
-
// A leading
|
|
118
|
-
// path, so a directory that happens to be named `export` is still
|
|
119
|
-
// as `reviewer ./export`.
|
|
170
|
+
// A leading command word is the subcommand. Anywhere else it is an
|
|
171
|
+
// ordinary path, so a directory that happens to be named `export` is still
|
|
172
|
+
// reviewable as `reviewer ./export`.
|
|
120
173
|
let rest = argv;
|
|
121
|
-
if (argv[0]
|
|
122
|
-
options.command =
|
|
174
|
+
if (COMMANDS.includes(argv[0])) {
|
|
175
|
+
options.command = argv[0];
|
|
123
176
|
rest = argv.slice(1);
|
|
124
177
|
}
|
|
125
178
|
|
|
126
179
|
for (let i = 0; i < rest.length; i++) {
|
|
127
180
|
const arg = rest[i];
|
|
128
181
|
|
|
182
|
+
// A reply is prose, and prose starts with "- " as often as anything:
|
|
183
|
+
// a Markdown list. After the key, a word that is not one of reply's own
|
|
184
|
+
// options is part of the reply, and `--` makes everything after it so.
|
|
185
|
+
if (options.command === 'reply') {
|
|
186
|
+
if (arg === '--') {
|
|
187
|
+
positionals.push(...rest.slice(i + 1));
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
const ownOption = ['--repo', '--as', '-h', '--help', '-v', '--version'].includes(arg) ||
|
|
191
|
+
/^--(repo|as)=/.test(arg);
|
|
192
|
+
if (positionals.length > 0 && !ownOption) {
|
|
193
|
+
positionals.push(arg);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
129
198
|
switch (arg) {
|
|
130
199
|
case '-h':
|
|
131
200
|
case '--help':
|
|
@@ -141,10 +210,22 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
141
210
|
options.open = false;
|
|
142
211
|
break;
|
|
143
212
|
|
|
213
|
+
case '--no-intro':
|
|
214
|
+
options.noIntro = true;
|
|
215
|
+
break;
|
|
216
|
+
|
|
144
217
|
case '--staged':
|
|
145
218
|
options.staged = true;
|
|
146
219
|
break;
|
|
147
220
|
|
|
221
|
+
case '--no-untracked':
|
|
222
|
+
options.untracked = false;
|
|
223
|
+
break;
|
|
224
|
+
|
|
225
|
+
case '--json':
|
|
226
|
+
options.json = true;
|
|
227
|
+
break;
|
|
228
|
+
|
|
148
229
|
case '-p':
|
|
149
230
|
case '--port': {
|
|
150
231
|
const value = rest[++i];
|
|
@@ -167,6 +248,37 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
167
248
|
break;
|
|
168
249
|
}
|
|
169
250
|
|
|
251
|
+
case '--session': {
|
|
252
|
+
const value = rest[++i];
|
|
253
|
+
if (value === undefined || value.startsWith('-')) throw new UsageError('--session needs a session id');
|
|
254
|
+
options.session = value;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case '--fork':
|
|
259
|
+
options.fork = true;
|
|
260
|
+
break;
|
|
261
|
+
|
|
262
|
+
case '--list':
|
|
263
|
+
options.list = true;
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case '--repo': {
|
|
267
|
+
const value = rest[++i];
|
|
268
|
+
if (value === undefined || value.startsWith('-')) throw new UsageError('--repo needs a repository path');
|
|
269
|
+
replyRepo = value;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case '--as': {
|
|
274
|
+
const value = rest[++i];
|
|
275
|
+
if (value === undefined || !AUTHOR_PATTERN.test(value)) {
|
|
276
|
+
throw new UsageError('--as needs a short name, such as claude');
|
|
277
|
+
}
|
|
278
|
+
options.as = value;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
170
282
|
default: {
|
|
171
283
|
// Support --port=4500 as well as --port 4500.
|
|
172
284
|
const inlinePort = arg.match(/^--port=(.*)$/);
|
|
@@ -187,10 +299,30 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
187
299
|
break;
|
|
188
300
|
}
|
|
189
301
|
|
|
302
|
+
const inlineRepo = arg.match(/^--repo=(.+)$/);
|
|
303
|
+
if (inlineRepo) {
|
|
304
|
+
replyRepo = inlineRepo[1];
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const inlineAs = arg.match(/^--as=(.*)$/);
|
|
309
|
+
if (inlineAs) {
|
|
310
|
+
if (!AUTHOR_PATTERN.test(inlineAs[1])) throw new UsageError('--as needs a short name, such as claude');
|
|
311
|
+
options.as = inlineAs[1];
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
|
|
190
315
|
if (arg.startsWith('-') && arg !== '-') {
|
|
191
316
|
throw new UsageError(`Unknown option: ${arg}`);
|
|
192
317
|
}
|
|
193
318
|
|
|
319
|
+
// `reply <key> <text...>` takes words, not a repository: the reply
|
|
320
|
+
// is everything after the key, so it need not be quoted.
|
|
321
|
+
if (options.command === 'reply') {
|
|
322
|
+
positionals.push(arg);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
|
|
194
326
|
if (positional !== null) {
|
|
195
327
|
throw new UsageError(`Unexpected argument: ${arg}`);
|
|
196
328
|
}
|
|
@@ -204,6 +336,29 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
204
336
|
options.repoPath = path.resolve(cwd, positional);
|
|
205
337
|
}
|
|
206
338
|
|
|
339
|
+
if (options.command === 'reply') {
|
|
340
|
+
if (!options.help && !options.version) {
|
|
341
|
+
const [key, ...words] = positionals;
|
|
342
|
+
if (!key || !KEY_PATTERN.test(key)) throw new UsageError('reply needs the key of the comment to reply to');
|
|
343
|
+
const text = words.join(' ').trim();
|
|
344
|
+
if (text === '') throw new UsageError('reply needs the text of the reply, or - to read it from stdin');
|
|
345
|
+
options.key = key;
|
|
346
|
+
// `-` alone: the reply is on stdin, which suits an answer of several
|
|
347
|
+
// lines and quotes that a shell would otherwise have to survive.
|
|
348
|
+
options.text = text === '-' ? null : text;
|
|
349
|
+
options.textFromStdin = text === '-';
|
|
350
|
+
}
|
|
351
|
+
if (replyRepo !== null) options.repoPath = path.resolve(cwd, replyRepo);
|
|
352
|
+
} else if (replyRepo !== null) {
|
|
353
|
+
throw new UsageError('--repo is only valid with reviewer reply; name the repository directly');
|
|
354
|
+
}
|
|
355
|
+
if (options.as !== null && options.command !== 'reply') {
|
|
356
|
+
throw new UsageError('--as is only valid with reviewer reply');
|
|
357
|
+
}
|
|
358
|
+
for (const [flag, set] of [['--session', options.session !== null], ['--fork', options.fork], ['--list', options.list]]) {
|
|
359
|
+
if (set && options.command !== 'connect') throw new UsageError(`${flag} is only valid with reviewer connect`);
|
|
360
|
+
}
|
|
361
|
+
|
|
207
362
|
if (options.file !== null && options.command !== 'export') {
|
|
208
363
|
throw new UsageError('--file is only valid with reviewer export');
|
|
209
364
|
}
|
|
@@ -211,7 +366,22 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
211
366
|
if (options.staged && options.command !== 'serve') {
|
|
212
367
|
throw new UsageError('--staged is only valid when serving a review');
|
|
213
368
|
}
|
|
369
|
+
if (options.noIntro && options.command !== 'serve') {
|
|
370
|
+
throw new UsageError('--no-intro is only valid when serving a review');
|
|
371
|
+
}
|
|
372
|
+
if (!options.untracked && options.command !== 'serve') {
|
|
373
|
+
throw new UsageError('--no-untracked is only valid when serving a review');
|
|
374
|
+
}
|
|
214
375
|
|
|
376
|
+
if (options.json && options.command !== 'serve') {
|
|
377
|
+
throw new UsageError('--json is only valid when serving a review');
|
|
378
|
+
}
|
|
379
|
+
if (options.port !== null && options.command !== 'serve') {
|
|
380
|
+
throw new UsageError('--port is only valid when serving a review');
|
|
381
|
+
}
|
|
382
|
+
if (!options.open && options.command !== 'serve') {
|
|
383
|
+
throw new UsageError('--no-open is only valid when serving a review');
|
|
384
|
+
}
|
|
215
385
|
return options;
|
|
216
386
|
}
|
|
217
387
|
|
|
@@ -261,21 +431,28 @@ function parseFile(value, flag) {
|
|
|
261
431
|
* @param {string} origin e.g. http://127.0.0.1:4500
|
|
262
432
|
* @param {string|null} repoPath
|
|
263
433
|
* @param {boolean} [staged]
|
|
434
|
+
* @param {boolean} [noIntro] add intro=0 so a fresh browser skips the first-run help
|
|
435
|
+
* @param {boolean} [untracked] include untracked files in working directory reviews
|
|
264
436
|
* @returns {string}
|
|
265
437
|
*/
|
|
266
|
-
function buildUrl(origin, repoPath, staged = false) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
438
|
+
function buildUrl(origin, repoPath, staged = false, noIntro = false, untracked = true) {
|
|
439
|
+
const params = [];
|
|
440
|
+
if (repoPath) {
|
|
441
|
+
// Slashes are left as slashes. They are legal in a query value -- RFC 3986
|
|
442
|
+
// has `query = *( pchar / "/" / "?" )` -- and encoding them turns the most
|
|
443
|
+
// prominent line the command prints into %2F soup:
|
|
444
|
+
//
|
|
445
|
+
// before http://127.0.0.1:4500/?repo=%2FUsers%2Fyou%2Fwork%2Fapi
|
|
446
|
+
// after http://127.0.0.1:4500/?repo=/Users/you/work/api
|
|
447
|
+
//
|
|
448
|
+
// Everything else stays encoded, including the characters that would
|
|
449
|
+
// otherwise end the query and take the rest of the path with them.
|
|
450
|
+
params.push(`repo=${encodeURIComponent(repoPath).replace(/%2F/g, '/')}`);
|
|
451
|
+
}
|
|
452
|
+
if (staged) params.push('staged=1');
|
|
453
|
+
if (noIntro) params.push('intro=0');
|
|
454
|
+
if (!untracked) params.push('untracked=0');
|
|
455
|
+
return params.length === 0 ? origin : `${origin}/?${params.join('&')}`;
|
|
279
456
|
}
|
|
280
457
|
|
|
281
458
|
/**
|
|
@@ -300,4 +477,4 @@ function invokedAs(env, argv1) {
|
|
|
300
477
|
|
|
301
478
|
const USAGE = usage();
|
|
302
479
|
|
|
303
|
-
module.exports = { parseArgs, buildUrl, UsageError, USAGE, FORMATS, usage, invokedAs };
|
|
480
|
+
module.exports = { parseArgs, buildUrl, UsageError, USAGE, FORMATS, COMMANDS, usage, invokedAs };
|
package/lib/comments.js
CHANGED
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* The stored shape of a review comment.
|
|
5
7
|
*
|
|
6
|
-
* The client keeps extra bookkeeping on each comment (
|
|
7
|
-
* draft text). Only the fields below are written to disk, so a comment
|
|
8
|
-
* stays readable and stable across UI changes.
|
|
8
|
+
* The client keeps extra bookkeeping on each comment (diff positions, match
|
|
9
|
+
* state, draft text). Only the fields below are written to disk, so a comment
|
|
10
|
+
* file stays readable and stable across UI changes.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* @typedef {object} FollowUp
|
|
15
|
+
* @property {string} [key] stable identity, minted when it is written
|
|
13
16
|
* @property {string} text
|
|
14
17
|
* @property {string} [timestamp] ISO 8601
|
|
18
|
+
* @property {string} [by] who wrote it, when it was not the reviewer: an
|
|
19
|
+
* agent answering in the thread, say `claude`. Absent means the reviewer,
|
|
20
|
+
* which is what every follow-up written before this field existed was.
|
|
21
|
+
* @property {string} [session] the agent session that wrote it, when known
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* @typedef {object} Comment
|
|
26
|
+
* @property {string} [key] stable identity, minted when the comment is created
|
|
19
27
|
* @property {string} file repo-relative path
|
|
20
28
|
* @property {number} line 1-based line number
|
|
21
29
|
* @property {string} [lineContent] the line the comment is anchored to
|
|
@@ -24,6 +32,136 @@
|
|
|
24
32
|
* @property {FollowUp[]} [followUps]
|
|
25
33
|
*/
|
|
26
34
|
|
|
35
|
+
/**
|
|
36
|
+
* What a key looks like: short, and safe in a URL, a filename and an
|
|
37
|
+
* unquoted shell argument, because `reviewer reply <key>` is typed.
|
|
38
|
+
*/
|
|
39
|
+
const KEY_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
40
|
+
|
|
41
|
+
/** A name in `by`: something a person would recognise in a thread. */
|
|
42
|
+
const AUTHOR_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,39}$/;
|
|
43
|
+
|
|
44
|
+
/** A session reference in `session`: an id, not prose. */
|
|
45
|
+
const SESSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
46
|
+
|
|
47
|
+
/** @param {unknown} value */
|
|
48
|
+
function isKey(value) {
|
|
49
|
+
return typeof value === 'string' && KEY_PATTERN.test(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A new random key: eight characters, 48 bits from `crypto`.
|
|
54
|
+
*
|
|
55
|
+
* Short on purpose. The comment file is meant to stay readable, the key is
|
|
56
|
+
* typed on a command line, and two collisions in one review would need tens
|
|
57
|
+
* of millions of comments.
|
|
58
|
+
*
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
function mintKey() {
|
|
62
|
+
return crypto.randomBytes(6).toString('base64url');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The key a comment written without one has always had.
|
|
67
|
+
*
|
|
68
|
+
* Derived rather than random so that every reader -- a tab, an agent, `reviewer
|
|
69
|
+
* export` -- names the same comment the same way without anyone having to
|
|
70
|
+
* write the file first. The parts are what identified a comment before keys
|
|
71
|
+
* existed, plus how many earlier entries shared them, so two identical
|
|
72
|
+
* comments on one line still get two keys.
|
|
73
|
+
*
|
|
74
|
+
* @param {string[]} parts
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
function derivedKey(parts) {
|
|
78
|
+
return crypto.createHash('sha256').update(parts.join('\u0000')).digest('base64url').slice(0, 8);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Give every comment and follow-up a key, keeping the ones they already have.
|
|
83
|
+
*
|
|
84
|
+
* Comments saved before keys existed are read through here, and so is
|
|
85
|
+
* anything posted by a client that does not send keys. Both get the derived
|
|
86
|
+
* key, so the same comment comes back with the same key on every read, and a
|
|
87
|
+
* script that re-posts the same list keeps its comments' identities rather
|
|
88
|
+
* than turning each save into a delete and an add.
|
|
89
|
+
*
|
|
90
|
+
* @template {Comment} T
|
|
91
|
+
* @param {T[]} comments
|
|
92
|
+
* @returns {T[]} new objects; the input is left alone
|
|
93
|
+
*/
|
|
94
|
+
function withKeys(comments) {
|
|
95
|
+
const occurrences = new Map();
|
|
96
|
+
const nth = identity => {
|
|
97
|
+
const n = occurrences.get(identity) ?? 0;
|
|
98
|
+
occurrences.set(identity, n + 1);
|
|
99
|
+
return String(n);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return comments.map(comment => {
|
|
103
|
+
const key = isKey(comment.key)
|
|
104
|
+
? comment.key
|
|
105
|
+
: derivedKey(['comment', comment.file, String(comment.line), comment.text,
|
|
106
|
+
nth(JSON.stringify([comment.file, comment.line, comment.text]))]);
|
|
107
|
+
|
|
108
|
+
const keyed = { ...comment, key };
|
|
109
|
+
if (Array.isArray(comment.followUps)) {
|
|
110
|
+
keyed.followUps = comment.followUps.map(followUp => (isKey(followUp?.key)
|
|
111
|
+
? followUp
|
|
112
|
+
: {
|
|
113
|
+
...followUp,
|
|
114
|
+
key: derivedKey(['follow-up', key, followUp?.timestamp ?? '', followUp?.text ?? '',
|
|
115
|
+
followUp?.by ?? '', nth(JSON.stringify([key, followUp?.timestamp, followUp?.text, followUp?.by]))])
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
return keyed;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reduce a follow-up to the fields that get persisted.
|
|
124
|
+
*
|
|
125
|
+
* @param {object} followUp
|
|
126
|
+
* @param {string} where position, for the error message
|
|
127
|
+
* @returns {FollowUp}
|
|
128
|
+
* @throws {TypeError} when it is malformed
|
|
129
|
+
*/
|
|
130
|
+
function normalizeFollowUp(followUp, where) {
|
|
131
|
+
if (followUp === null || typeof followUp !== 'object' || Array.isArray(followUp)) {
|
|
132
|
+
throw new TypeError(`${where} must be an object`);
|
|
133
|
+
}
|
|
134
|
+
if (typeof followUp.text !== 'string' || followUp.text.trim() === '') {
|
|
135
|
+
throw new TypeError(`${where}.text must be a non-empty string`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @type {FollowUp} */
|
|
139
|
+
const entry = {};
|
|
140
|
+
if (followUp.key !== undefined) {
|
|
141
|
+
if (!isKey(followUp.key)) throw new TypeError(`${where}.key must be 1-64 letters, digits, _ or -`);
|
|
142
|
+
entry.key = followUp.key;
|
|
143
|
+
}
|
|
144
|
+
entry.text = followUp.text;
|
|
145
|
+
if (followUp.timestamp) entry.timestamp = followUp.timestamp;
|
|
146
|
+
|
|
147
|
+
// Empty and absent are the same thing here -- the reviewer -- so only a
|
|
148
|
+
// real name is stored, and the file never says `"by": ""`.
|
|
149
|
+
if (followUp.by !== undefined && followUp.by !== null && followUp.by !== '') {
|
|
150
|
+
if (typeof followUp.by !== 'string' || !AUTHOR_PATTERN.test(followUp.by)) {
|
|
151
|
+
throw new TypeError(`${where}.by must be a short name, such as "claude"`);
|
|
152
|
+
}
|
|
153
|
+
entry.by = followUp.by;
|
|
154
|
+
}
|
|
155
|
+
if (followUp.session !== undefined && followUp.session !== null && followUp.session !== '') {
|
|
156
|
+
if (typeof followUp.session !== 'string' || !SESSION_PATTERN.test(followUp.session)) {
|
|
157
|
+
throw new TypeError(`${where}.session must be a session id`);
|
|
158
|
+
}
|
|
159
|
+
entry.session = followUp.session;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return entry;
|
|
163
|
+
}
|
|
164
|
+
|
|
27
165
|
/**
|
|
28
166
|
* Reduce a client comment to the fields that get persisted.
|
|
29
167
|
*
|
|
@@ -54,8 +192,12 @@ function normalizeComment(comment, index) {
|
|
|
54
192
|
if (typeof comment.text !== 'string' || comment.text.trim() === '') {
|
|
55
193
|
throw new TypeError(`${where}.text must be a non-empty string`);
|
|
56
194
|
}
|
|
195
|
+
if (comment.key !== undefined && !isKey(comment.key)) {
|
|
196
|
+
throw new TypeError(`${where}.key must be 1-64 letters, digits, _ or -`);
|
|
197
|
+
}
|
|
57
198
|
|
|
58
199
|
const normalized = {
|
|
200
|
+
...(comment.key !== undefined ? { key: comment.key } : {}),
|
|
59
201
|
file: comment.file,
|
|
60
202
|
line: comment.line,
|
|
61
203
|
lineContent: comment.lineContent,
|
|
@@ -67,11 +209,9 @@ function normalizeComment(comment, index) {
|
|
|
67
209
|
}
|
|
68
210
|
|
|
69
211
|
if (Array.isArray(comment.followUps) && comment.followUps.length > 0) {
|
|
70
|
-
normalized.followUps = comment.followUps.map(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return entry;
|
|
74
|
-
});
|
|
212
|
+
normalized.followUps = comment.followUps.map(
|
|
213
|
+
(followUp, i) => normalizeFollowUp(followUp, `${where}.followUps[${i}]`)
|
|
214
|
+
);
|
|
75
215
|
}
|
|
76
216
|
|
|
77
217
|
return normalized;
|
|
@@ -80,6 +220,10 @@ function normalizeComment(comment, index) {
|
|
|
80
220
|
/**
|
|
81
221
|
* Normalize a batch of comments.
|
|
82
222
|
*
|
|
223
|
+
* Two comments claiming one key are refused: a key is how a reply finds its
|
|
224
|
+
* thread, and a reply that could land on either of two comments is the
|
|
225
|
+
* confidently-wrong outcome keys exist to rule out.
|
|
226
|
+
*
|
|
83
227
|
* @param {object[]} comments
|
|
84
228
|
* @returns {Comment[]}
|
|
85
229
|
* @throws {TypeError} when `comments` is not an array or a comment is malformed
|
|
@@ -88,7 +232,18 @@ function normalizeComments(comments) {
|
|
|
88
232
|
if (!Array.isArray(comments)) {
|
|
89
233
|
throw new TypeError('comments must be an array');
|
|
90
234
|
}
|
|
91
|
-
|
|
235
|
+
const normalized = comments.map((comment, index) => normalizeComment(comment, index));
|
|
236
|
+
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
normalized.forEach((comment, index) => {
|
|
239
|
+
if (comment.key === undefined) return;
|
|
240
|
+
if (seen.has(comment.key)) {
|
|
241
|
+
throw new TypeError(`comments[${index}].key "${comment.key}" is used by another comment`);
|
|
242
|
+
}
|
|
243
|
+
seen.add(comment.key);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
return normalized;
|
|
92
247
|
}
|
|
93
248
|
|
|
94
249
|
/**
|
|
@@ -116,4 +271,14 @@ function groupByFile(comments) {
|
|
|
116
271
|
]);
|
|
117
272
|
}
|
|
118
273
|
|
|
119
|
-
module.exports = {
|
|
274
|
+
module.exports = {
|
|
275
|
+
normalizeComment,
|
|
276
|
+
normalizeComments,
|
|
277
|
+
normalizeFollowUp,
|
|
278
|
+
groupByFile,
|
|
279
|
+
withKeys,
|
|
280
|
+
mintKey,
|
|
281
|
+
isKey,
|
|
282
|
+
KEY_PATTERN,
|
|
283
|
+
AUTHOR_PATTERN
|
|
284
|
+
};
|
package/lib/export.js
CHANGED
|
@@ -100,7 +100,7 @@ async function readGitPosition(repoPath) {
|
|
|
100
100
|
* records a different repository than the one asked for
|
|
101
101
|
*/
|
|
102
102
|
async function loadReviewDocument(reviewsDir, repoPath, { file = null } = {}) {
|
|
103
|
-
const { comments, mode, range } = await readSavedComments(reviewsDir, repoPath);
|
|
103
|
+
const { comments, mode, range, untracked } = await readSavedComments(reviewsDir, repoPath);
|
|
104
104
|
|
|
105
105
|
if (comments.length === 0) throw new NoReviewError(repoPath);
|
|
106
106
|
|
|
@@ -121,7 +121,7 @@ async function loadReviewDocument(reviewsDir, repoPath, { file = null } = {}) {
|
|
|
121
121
|
// same object reference when no filter is given. `test/agent.test.js`
|
|
122
122
|
// asserts that identity with `assert.equal`.
|
|
123
123
|
const document = filterReviewDocument(
|
|
124
|
-
buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch, mode, range }),
|
|
124
|
+
buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch, mode, range, untracked }),
|
|
125
125
|
{ file }
|
|
126
126
|
);
|
|
127
127
|
|