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/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 `export` is the subcommand. Anywhere else it is an ordinary
118
- // path, so a directory that happens to be named `export` is still reviewable
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] === 'export') {
122
- options.command = 'export';
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
- if (!repoPath) return staged ? `${origin}/?staged=1` : origin;
268
-
269
- // Slashes are left as slashes. They are legal in a query value -- RFC 3986
270
- // has `query = *( pchar / "/" / "?" )` -- and encoding them turns the most
271
- // prominent line the command prints into %2F soup:
272
- //
273
- // before http://127.0.0.1:4500/?repo=%2FUsers%2Fyou%2Fwork%2Fapi
274
- // after http://127.0.0.1:4500/?repo=/Users/you/work/api
275
- //
276
- // Everything else stays encoded, including the characters that would
277
- // otherwise end the query and take the rest of the path with them.
278
- return `${origin}/?repo=${encodeURIComponent(repoPath).replace(/%2F/g, '/')}${staged ? '&staged=1' : ''}`;
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 (ids, collapsed state,
7
- * draft text). Only the fields below are written to disk, so a comment file
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(followUp => {
71
- const entry = { text: followUp.text };
72
- if (followUp.timestamp) entry.timestamp = followUp.timestamp;
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
- return comments.map((comment, index) => normalizeComment(comment, index));
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 = { normalizeComment, normalizeComments, groupByFile };
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