git-reviewer 2.15.0 → 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.
@@ -0,0 +1,229 @@
1
+ 'use strict';
2
+
3
+ const fsp = require('fs').promises;
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const { canonicalRepoPath } = require('./review');
8
+
9
+ /**
10
+ * Finding the Claude Code sessions that worked in a repository.
11
+ *
12
+ * The session worth asking about a change is the one that made it: it read
13
+ * the code, chose the approach and remembers why. Claude Code keeps each
14
+ * session's transcript as a JSON-lines file under its config directory,
15
+ * filed by the working directory it ran in, and that is what is read here --
16
+ * only the first and last few kilobytes of each, for its working directory,
17
+ * its name and the first thing it was asked.
18
+ */
19
+
20
+ // Enough of the head for the first prompt and the working directory, and of
21
+ // the tail for a name given later in the session, without reading a
22
+ // transcript that can run to tens of megabytes.
23
+ const SLICE_BYTES = 64 * 1024;
24
+ const PROMPT_CHARS = 240;
25
+
26
+ /** Where Claude Code keeps its state: `CLAUDE_CONFIG_DIR`, or `~/.claude`. */
27
+ function claudeConfigDir(env = process.env) {
28
+ return env.CLAUDE_CONFIG_DIR ? path.resolve(env.CLAUDE_CONFIG_DIR) : path.join(os.homedir(), '.claude');
29
+ }
30
+
31
+ /**
32
+ * The directory name Claude Code files a working directory's sessions under:
33
+ * the path with every character that is not a letter or a digit turned into
34
+ * `-`. `/home/you/work/api` becomes `-home-you-work-api`.
35
+ *
36
+ * @param {string} dir
37
+ */
38
+ function projectDirName(dir) {
39
+ return dir.replace(/[^A-Za-z0-9]/g, '-');
40
+ }
41
+
42
+ /** A session id as Claude Code writes them: a UUID. */
43
+ const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
+
45
+ /**
46
+ * @param {string} file
47
+ * @param {number} start
48
+ * @param {number} length
49
+ * @returns {Promise<string>}
50
+ */
51
+ async function readSlice(file, start, length) {
52
+ const handle = await fsp.open(file, 'r');
53
+ try {
54
+ const buffer = Buffer.alloc(length);
55
+ const { bytesRead } = await handle.read(buffer, 0, length, start);
56
+ return buffer.subarray(0, bytesRead).toString('utf-8');
57
+ } finally {
58
+ await handle.close();
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The records in a slice of a transcript. A slice cut mid-line yields a
64
+ * fragment at either end, which does not parse and is skipped.
65
+ *
66
+ * @param {string} text
67
+ * @returns {object[]}
68
+ */
69
+ function records(text) {
70
+ const parsed = [];
71
+ for (const line of text.split('\n')) {
72
+ if (!line.startsWith('{')) continue;
73
+ try {
74
+ parsed.push(JSON.parse(line));
75
+ } catch {
76
+ /* a fragment */
77
+ }
78
+ }
79
+ return parsed;
80
+ }
81
+
82
+ /**
83
+ * What a person typed, out of a transcript's user message -- not a tool
84
+ * result, and not the wrappers Claude Code puts around slash commands and
85
+ * reminders, which all open with a tag.
86
+ *
87
+ * @param {unknown} content
88
+ * @returns {string|null}
89
+ */
90
+ function promptText(content) {
91
+ let text = null;
92
+ if (typeof content === 'string') {
93
+ text = content;
94
+ } else if (Array.isArray(content)) {
95
+ text = content.find(block => block?.type === 'text' && typeof block.text === 'string')?.text ?? null;
96
+ }
97
+ if (!text) return null;
98
+ text = text.trim();
99
+ if (text === '' || text.startsWith('<') || text.startsWith('Caveat:')) return null;
100
+ const flat = text.replace(/\s+/g, ' ');
101
+ return flat.length > PROMPT_CHARS ? `${flat.slice(0, PROMPT_CHARS - 1)}…` : flat;
102
+ }
103
+
104
+ /**
105
+ * @typedef {object} ClaudeSession
106
+ * @property {string} id
107
+ * @property {string} cwd the directory it ran in
108
+ * @property {string|null} title the name it was given, or Claude Code's summary of it
109
+ * @property {string|null} firstPrompt
110
+ * @property {string|null} branch git branch when it started
111
+ * @property {string} updatedAt when it last wrote anything
112
+ */
113
+
114
+ /**
115
+ * Read what a transcript says about itself.
116
+ *
117
+ * @param {string} file
118
+ * @returns {Promise<ClaudeSession|null>} null for a file that is not a session transcript
119
+ */
120
+ async function describeSession(file) {
121
+ const id = path.basename(file, '.jsonl');
122
+ if (!SESSION_ID_PATTERN.test(id)) return null;
123
+
124
+ const stat = await fsp.stat(file);
125
+ const head = await readSlice(file, 0, SLICE_BYTES);
126
+ const tail = stat.size > SLICE_BYTES ? await readSlice(file, stat.size - SLICE_BYTES, SLICE_BYTES) : '';
127
+
128
+ let cwd = null;
129
+ let branch = null;
130
+ let firstPrompt = null;
131
+ let title = null;
132
+ let summary = null;
133
+
134
+ for (const record of [...records(head), ...records(tail)]) {
135
+ if (typeof record.cwd === 'string') cwd ??= record.cwd;
136
+ if (typeof record.gitBranch === 'string' && record.gitBranch !== '') branch ??= record.gitBranch;
137
+ if (!firstPrompt && record.type === 'user' && !record.isMeta && !record.isSidechain) {
138
+ firstPrompt = promptText(record.message?.content);
139
+ }
140
+ // Later records win: a session renamed halfway through goes by its new name.
141
+ if (record.type === 'custom-title' && typeof record.customTitle === 'string') title = record.customTitle;
142
+ if (record.type === 'summary' && typeof record.summary === 'string') summary = record.summary;
143
+ }
144
+
145
+ if (!cwd) return null;
146
+ return { id, cwd, title: title ?? summary ?? null, firstPrompt, branch, updatedAt: stat.mtime.toISOString() };
147
+ }
148
+
149
+ /** @param {string} child @param {string} parent */
150
+ function isInside(child, parent) {
151
+ const relative = path.relative(parent, child);
152
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
153
+ }
154
+
155
+ /**
156
+ * Claude Code sessions that ran in a repository or any directory inside it,
157
+ * most recently active first.
158
+ *
159
+ * @param {string} repoPath
160
+ * @param {object} [options]
161
+ * @param {string} [options.configDir]
162
+ * @param {number} [options.limit]
163
+ * @returns {Promise<ClaudeSession[]>}
164
+ */
165
+ async function listClaudeSessions(repoPath, { configDir = claudeConfigDir(), limit = 20 } = {}) {
166
+ const projects = path.join(configDir, 'projects');
167
+ const root = canonicalRepoPath(repoPath);
168
+ // The encoding loses information -- `/a/b-c` and `/a/b/c` share a name --
169
+ // so a matching directory is only a candidate. Each transcript's own
170
+ // working directory is what decides.
171
+ const prefixes = [...new Set([projectDirName(root), projectDirName(path.resolve(repoPath))])];
172
+
173
+ let dirs;
174
+ try {
175
+ dirs = (await fsp.readdir(projects, { withFileTypes: true }))
176
+ .filter(entry => entry.isDirectory() && prefixes.some(prefix => entry.name.startsWith(prefix)))
177
+ .map(entry => path.join(projects, entry.name));
178
+ } catch (error) {
179
+ if (error.code === 'ENOENT') return [];
180
+ throw error;
181
+ }
182
+
183
+ const candidates = [];
184
+ for (const dir of dirs) {
185
+ for (const entry of await fsp.readdir(dir, { withFileTypes: true }).catch(() => [])) {
186
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
187
+ const file = path.join(dir, entry.name);
188
+ const stat = await fsp.stat(file).catch(() => null);
189
+ if (stat) candidates.push({ file, mtimeMs: stat.mtimeMs });
190
+ }
191
+ }
192
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
193
+
194
+ const sessions = [];
195
+ for (const { file } of candidates) {
196
+ if (sessions.length >= limit) break;
197
+ const session = await describeSession(file).catch(() => null);
198
+ if (!session || !(session.firstPrompt || session.title)) continue;
199
+ if (!isInside(canonicalRepoPath(session.cwd), root)) continue;
200
+ sessions.push(session);
201
+ }
202
+ return sessions;
203
+ }
204
+
205
+ /**
206
+ * One session of a repository, by id -- or null, which is the answer for an
207
+ * id that is malformed, unknown, or belongs to another repository.
208
+ *
209
+ * @param {string} repoPath
210
+ * @param {string} id
211
+ * @param {object} [options]
212
+ * @param {string} [options.configDir]
213
+ * @returns {Promise<ClaudeSession|null>}
214
+ */
215
+ async function findClaudeSession(repoPath, id, { configDir = claudeConfigDir() } = {}) {
216
+ if (typeof id !== 'string' || !SESSION_ID_PATTERN.test(id)) return null;
217
+ const sessions = await listClaudeSessions(repoPath, { configDir, limit: Infinity });
218
+ return sessions.find(session => session.id === id) ?? null;
219
+ }
220
+
221
+ module.exports = {
222
+ listClaudeSessions,
223
+ findClaudeSession,
224
+ describeSession,
225
+ claudeConfigDir,
226
+ projectDirName,
227
+ promptText,
228
+ SESSION_ID_PATTERN
229
+ };
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,6 +44,17 @@ 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
@@ -49,6 +66,12 @@ Options:
49
66
  --json Print one JSON line on stderr at startup instead of the banner
50
67
  -f, --format <f> Export format: json (default) or prompt
51
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)
52
75
  -h, --help Show this help
53
76
  -v, --version Show the version
54
77
 
@@ -67,12 +90,17 @@ Examples:
67
90
  only the comments on one file
68
91
  ${name} export . --format prompt | claude -p "apply this review"
69
92
  hand over a review written earlier
93
+ ${name} connect
94
+ have the session that wrote the code answer your comments
70
95
  `;
71
96
  }
72
97
 
73
98
  /** Formats `reviewer export` can print. */
74
99
  const FORMATS = ['json', 'prompt'];
75
100
 
101
+ /** Words that, first on the command line, name a subcommand. */
102
+ const COMMANDS = ['export', 'connect', 'reply', 'watch', 'channel'];
103
+
76
104
  class UsageError extends Error {
77
105
  /** @param {string} message */
78
106
  constructor(message) {
@@ -83,7 +111,7 @@ class UsageError extends Error {
83
111
 
84
112
  /**
85
113
  * @typedef {object} CliOptions
86
- * @property {'serve'|'export'} command
114
+ * @property {'serve'|'export'|'connect'|'reply'|'watch'|'channel'} command
87
115
  * @property {string|null} repoPath absolute path, or null when not given
88
116
  * @property {number|null} port explicit port, or null to use the default
89
117
  * @property {boolean} open whether to launch a browser
@@ -93,6 +121,13 @@ class UsageError extends Error {
93
121
  * @property {boolean} json whether to print the startup info as one JSON line
94
122
  * @property {'json'|'prompt'} format for `export`
95
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
96
131
  * @property {boolean} help
97
132
  * @property {boolean} version
98
133
  */
@@ -118,23 +153,48 @@ function parseArgs(argv, cwd = process.cwd()) {
118
153
  json: false,
119
154
  format: 'json',
120
155
  file: null,
156
+ session: null,
157
+ fork: false,
158
+ list: false,
159
+ key: null,
160
+ text: null,
161
+ textFromStdin: false,
162
+ as: null,
121
163
  help: false,
122
164
  version: false
123
165
  };
124
166
  let positional = null;
167
+ const positionals = [];
168
+ let replyRepo = null;
125
169
 
126
- // A leading `export` is the subcommand. Anywhere else it is an ordinary
127
- // path, so a directory that happens to be named `export` is still reviewable
128
- // 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`.
129
173
  let rest = argv;
130
- if (argv[0] === 'export') {
131
- options.command = 'export';
174
+ if (COMMANDS.includes(argv[0])) {
175
+ options.command = argv[0];
132
176
  rest = argv.slice(1);
133
177
  }
134
178
 
135
179
  for (let i = 0; i < rest.length; i++) {
136
180
  const arg = rest[i];
137
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
+
138
198
  switch (arg) {
139
199
  case '-h':
140
200
  case '--help':
@@ -188,6 +248,37 @@ function parseArgs(argv, cwd = process.cwd()) {
188
248
  break;
189
249
  }
190
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
+
191
282
  default: {
192
283
  // Support --port=4500 as well as --port 4500.
193
284
  const inlinePort = arg.match(/^--port=(.*)$/);
@@ -208,10 +299,30 @@ function parseArgs(argv, cwd = process.cwd()) {
208
299
  break;
209
300
  }
210
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
+
211
315
  if (arg.startsWith('-') && arg !== '-') {
212
316
  throw new UsageError(`Unknown option: ${arg}`);
213
317
  }
214
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
+
215
326
  if (positional !== null) {
216
327
  throw new UsageError(`Unexpected argument: ${arg}`);
217
328
  }
@@ -225,6 +336,29 @@ function parseArgs(argv, cwd = process.cwd()) {
225
336
  options.repoPath = path.resolve(cwd, positional);
226
337
  }
227
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
+
228
362
  if (options.file !== null && options.command !== 'export') {
229
363
  throw new UsageError('--file is only valid with reviewer export');
230
364
  }
@@ -242,6 +376,12 @@ function parseArgs(argv, cwd = process.cwd()) {
242
376
  if (options.json && options.command !== 'serve') {
243
377
  throw new UsageError('--json is only valid when serving a review');
244
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
+ }
245
385
  return options;
246
386
  }
247
387
 
@@ -337,4 +477,4 @@ function invokedAs(env, argv1) {
337
477
 
338
478
  const USAGE = usage();
339
479
 
340
- module.exports = { parseArgs, buildUrl, UsageError, USAGE, FORMATS, usage, invokedAs };
480
+ module.exports = { parseArgs, buildUrl, UsageError, USAGE, FORMATS, COMMANDS, usage, invokedAs };