git-reviewer 2.0.0 → 2.1.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 CHANGED
@@ -3,27 +3,34 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-163-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-196-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  ![Plain grey code lines on the left resolving into colored diff stripes, with threaded comment markers attached in the right margin](docs/banner.jpg)
10
10
 
11
- Review code before it becomes a pull request. `reviewer` opens any local git
12
- repository in your browser, shows what changed, and lets you leave inline
13
- comments with threaded follow-ups — against your working directory, without a
14
- branch, a remote, a push, or an account. Comments persist between sessions and
15
- survive the code moving underneath them, so you can review, edit, and come back
16
- tomorrow to a review that still points at the right lines. When you are done it
17
- writes a plain Markdown file you can paste anywhere.
11
+ When an agent writes the code, reading it becomes the bottleneck — and the tool
12
+ built for reading a diff, the pull request, wants a branch, a remote and a push
13
+ you are not ready to make yet.
14
+
15
+ `reviewer` gives you the pull-request view of your working directory instead:
16
+ inline comments with threaded follow-ups, each attached to the exact line you
17
+ mean, with no branch, no remote, no push and no account. Then it hands the
18
+ marked-up review back to the agent to act on.
18
19
 
19
20
  ```bash
20
- cd ~/work/my-app
21
- npx git-reviewer .
21
+ npx git-reviewer . # read the diff, leave comments
22
+ npx git-reviewer export . --format prompt | claude -p "Apply this review."
22
23
  ```
23
24
 
24
- That starts a local server and opens your browser on the repository you named.
25
- There is nothing to install into the project, nothing to sign in to, and
26
- nothing leaves your machine.
25
+ The first command starts a local server and opens your browser on the
26
+ repository you named. Nothing is installed into the project, there is nothing
27
+ to sign in to, and nothing leaves your machine.
28
+
29
+ Comments persist between sessions and survive the code moving underneath them —
30
+ including the agent's own edits, which shift every line below the first change —
31
+ so a review you wrote yesterday still points at the right lines today. When you
32
+ would rather read the review than pipe it, it also writes a plain Markdown file
33
+ you can paste anywhere.
27
34
 
28
35
  ![The app reviewing a modified file: changed files on the left, a colour-coded diff in the middle with an inline comment and a threaded follow-up attached to line 10, and a comments sidebar on the right](docs/screenshot-comments.png)
29
36
 
@@ -109,8 +116,20 @@ shown in full, read back out of `HEAD`:
109
116
 
110
117
  ## What it writes
111
118
 
112
- Two files, both under `reviews/`, both plain text you can read without this
113
- app:
119
+ Two files, both plain text you can read without this app, both in a per-user
120
+ data directory outside the install:
121
+
122
+ | platform | location |
123
+ | --- | --- |
124
+ | macOS | `~/Library/Application Support/git-reviewer` |
125
+ | Linux, BSD | `$XDG_DATA_HOME/git-reviewer`, else `~/.local/share/git-reviewer` |
126
+ | Windows | `%LOCALAPPDATA%\git-reviewer` |
127
+
128
+ Set `REVIEWER_DATA_DIR` to put them anywhere else. They are deliberately *not*
129
+ kept inside the installed package: that directory belongs to npm, which deletes
130
+ it on upgrade and on uninstall, and `npx` puts it in a cache that is pruned
131
+ without warning. A review you wrote by hand is the most expensive thing this
132
+ tool holds.
114
133
 
115
134
  - **`.code-review-comments-<repo>.json`** — the live state of the review. It is
116
135
  written on every edit and is the source of truth; reopening the same
@@ -220,7 +239,7 @@ rather than a public issue.
220
239
 
221
240
  ```bash
222
241
  npm install # 3 dependencies, no build step, ~6MB
223
- npm test # 163 tests
242
+ npm test # 196 tests
224
243
  npm run test:watch
225
244
  npm run test:coverage
226
245
  ```
@@ -268,7 +287,6 @@ including some tagged
268
287
  [good first issue](https://github.com/dheerajjha/reviewer/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
269
288
  The ones most likely to change how the tool feels:
270
289
 
271
- - [#1](https://github.com/dheerajjha/reviewer/issues/1) — reviews are stored inside the install rather than with the repository
272
290
  - [#2](https://github.com/dheerajjha/reviewer/issues/2) — give comments a state, so a review can be worked through and marked off
273
291
  - [#3](https://github.com/dheerajjha/reviewer/issues/3) — report whether each anchor still matches the file
274
292
  - [#4](https://github.com/dheerajjha/reviewer/issues/4) — expose the review over MCP, so an agent works through it interactively
package/bin/reviewer.js CHANGED
@@ -4,13 +4,17 @@
4
4
  const fs = require('fs').promises;
5
5
  const path = require('path');
6
6
 
7
- const simpleGit = require('simple-git');
8
-
9
7
  const { parseArgs, buildUrl, UsageError, USAGE } = require('../lib/cli');
10
8
  const { openInBrowser } = require('../lib/browser');
11
- const { buildReviewDocument, filterReviewDocument, formatPrompt } = require('../lib/agent');
12
- const { readSavedComments, ReviewOwnershipError } = require('../lib/store');
13
- const { startServer, DEFAULT_PORT, DEFAULT_HOST, REVIEWS_DIR } = require('../server');
9
+ const { formatPrompt } = require('../lib/agent');
10
+ const { ReviewOwnershipError } = require('../lib/store');
11
+ const { reviewsDir, adoptLegacyReviews } = require('../lib/paths');
12
+ const {
13
+ loadReviewDocument,
14
+ NoReviewError,
15
+ NoMatchingCommentsError
16
+ } = require('../lib/export');
17
+ const { startServer, DEFAULT_PORT, DEFAULT_HOST } = require('../server');
14
18
  const { version } = require('../package.json');
15
19
 
16
20
  /**
@@ -48,39 +52,23 @@ async function listen(port) {
48
52
  async function exportReview(options) {
49
53
  const repoPath = options.repoPath ?? process.cwd();
50
54
 
51
- let comments;
55
+ let document;
52
56
  try {
53
- ({ comments } = await readSavedComments(REVIEWS_DIR, repoPath));
57
+ document = await loadReviewDocument(reviewsDir(), repoPath, { file: options.file });
54
58
  } catch (error) {
55
- if (error instanceof ReviewOwnershipError) throw new UsageError(error.message);
59
+ // `lib/export` raises domain errors so that an MCP tool or an agent
60
+ // handover can answer them its own way. The terminal wants all three as
61
+ // the same usage error, with the same wording it has always printed.
62
+ if (
63
+ error instanceof NoReviewError ||
64
+ error instanceof NoMatchingCommentsError ||
65
+ error instanceof ReviewOwnershipError
66
+ ) {
67
+ throw new UsageError(error.message);
68
+ }
56
69
  throw error;
57
70
  }
58
71
 
59
- if (comments.length === 0) {
60
- throw new UsageError(`No saved review for ${repoPath}. Review it first, then export.`);
61
- }
62
-
63
- // Recording the commit lets a consumer tell whether the tree has moved on
64
- // since the review was written. Neither is fatal if git will not say.
65
- const git = simpleGit(repoPath);
66
- const head = await git.revparse(['HEAD']).then(sha => sha.trim()).catch(() => null);
67
- const branch = await git.revparse(['--abbrev-ref', 'HEAD']).then(name => name.trim()).catch(() => null);
68
-
69
- const document = filterReviewDocument(
70
- buildReviewDocument({
71
- repoPath,
72
- comments,
73
- generatedAt: new Date(),
74
- head,
75
- branch
76
- }),
77
- { file: options.file }
78
- );
79
-
80
- if (document.comments.length === 0) {
81
- throw new UsageError(`The saved review for ${repoPath} has no comments for ${options.file}.`);
82
- }
83
-
84
72
  process.stdout.write(
85
73
  options.format === 'prompt'
86
74
  ? formatPrompt(document)
@@ -91,6 +79,19 @@ async function exportReview(options) {
91
79
  async function main() {
92
80
  const options = parseArgs(process.argv.slice(2));
93
81
 
82
+ // Reviews written by a version that kept them inside the package directory
83
+ // are moved out before anything reads them. Once, synchronously, so no read
84
+ // can start against a half-populated directory. See #33.
85
+ const adopted = adoptLegacyReviews();
86
+ if (adopted > 0) {
87
+ console.log(
88
+ ` Copied ${adopted} saved review${adopted === 1 ? '' : 's'} out of the ` +
89
+ `install directory to\n ${reviewsDir()}\n so that upgrading or ` +
90
+ 'reinstalling this package can no longer delete them.\n' +
91
+ ' The originals are left where they were.\n'
92
+ );
93
+ }
94
+
94
95
  if (options.help) {
95
96
  process.stdout.write(USAGE);
96
97
  return;
package/lib/agent.js CHANGED
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { groupByFile } = require('./comments');
4
- const { repoSlug } = require('./review');
4
+ const { fence, repoSlug } = require('./review');
5
5
 
6
6
  /**
7
7
  * The machine-readable form of a review.
@@ -158,11 +158,16 @@ function formatPrompt(document) {
158
158
  }
159
159
 
160
160
  lines.push(`### ${comment.id}`, '');
161
+ // Fenced with `fence`, not a literal ```, because the anchor is arbitrary
162
+ // source and in a Markdown file it is routinely a fence itself. A
163
+ // same-length marker closes the block on the anchor's own line, and the
164
+ // comment body below it — the instruction the agent is meant to act on —
165
+ // becomes code, taking the next comment's heading with it. See #22.
161
166
  if (comment.anchor !== null) {
162
- lines.push('Anchor line:', '', '```', comment.anchor, '```', '');
167
+ lines.push('Anchor line:', '', fence(comment.anchor), '');
163
168
  }
164
169
  if (comment.selection) {
165
- lines.push('Selected code:', '', '```', comment.selection, '```', '');
170
+ lines.push('Selected code:', '', fence(comment.selection), '');
166
171
  }
167
172
 
168
173
  lines.push(comment.body, '');
package/lib/export.js ADDED
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs').promises;
4
+ const path = require('path');
5
+
6
+ const simpleGit = require('simple-git');
7
+
8
+ const { buildReviewDocument, filterReviewDocument } = require('./agent');
9
+ const { readSavedComments, belongsTo } = require('./store');
10
+ const { commentsFilename, canonicalRepoPath, repoSlug } = require('./review');
11
+
12
+ /**
13
+ * Producing a `code-review/v1` document from a repository on disk.
14
+ *
15
+ * This chain — read the saved comments, build the document, filter it — used
16
+ * to be assembled exactly once, inside a local un-exported function in
17
+ * `bin/reviewer.js`. That was fine while the CLI was the only consumer. It
18
+ * stops being fine the moment there is a second one, because the second
19
+ * consumer cannot call it and has to rebuild it, and then the two copies
20
+ * drift in the way that matters most: quietly, and only in the edge cases.
21
+ *
22
+ * The HTTP server reaches the same document by a third route entirely
23
+ * (`POST /api/submit-review`), which is session-bound and writes two files to
24
+ * disk as a side effect of being asked for a document. That one is left alone
25
+ * here; this module is what anything new should call.
26
+ *
27
+ * Nothing in here knows about the CLI. The three conditions the CLI reports
28
+ * as usage errors are raised as domain errors and translated at the boundary,
29
+ * so an MCP tool or an agent handover can answer them its own way rather than
30
+ * inheriting a message written for a terminal.
31
+ */
32
+
33
+ /** No review has been saved for this repository. */
34
+ class NoReviewError extends Error {
35
+ /** @param {string} repoPath */
36
+ constructor(repoPath) {
37
+ super(`No saved review for ${repoPath}. Review it first, then export.`);
38
+ this.name = 'NoReviewError';
39
+ this.repoPath = repoPath;
40
+ }
41
+ }
42
+
43
+ /** A review exists, but the requested filter selected none of it. */
44
+ class NoMatchingCommentsError extends Error {
45
+ /**
46
+ * @param {string} repoPath
47
+ * @param {string} file the filter that matched nothing
48
+ */
49
+ constructor(repoPath, file) {
50
+ super(`The saved review for ${repoPath} has no comments for ${file}.`);
51
+ this.name = 'NoMatchingCommentsError';
52
+ this.repoPath = repoPath;
53
+ this.file = file;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Where the working tree is, as far as git will say.
59
+ *
60
+ * Every failure here is answered with nulls rather than raised. A review is
61
+ * still a review when git cannot be reached, and this has to hold for three
62
+ * separate failures that do not look alike:
63
+ *
64
+ * - the directory is not a repository -> revparse rejects
65
+ * - git is not installed -> revparse rejects
66
+ * - the directory does not exist at all -> `simpleGit()` throws
67
+ * *synchronously, at construction*, which is why the constructor is
68
+ * inside the try and not above it. The CLI never hit this because
69
+ * `main()` does an `fs.access` check first; a library has no such
70
+ * guarantee, and an agent asking about a repository that has since been
71
+ * moved or deleted would otherwise get a crash instead of an answer.
72
+ *
73
+ * @param {string} repoPath
74
+ * @returns {Promise<{head: string|null, branch: string|null}>}
75
+ */
76
+ async function readGitPosition(repoPath) {
77
+ try {
78
+ const git = simpleGit(repoPath);
79
+ const [head, branch] = await Promise.all([
80
+ git.revparse(['HEAD']).then(sha => sha.trim()).catch(() => null),
81
+ git.revparse(['--abbrev-ref', 'HEAD']).then(name => name.trim()).catch(() => null)
82
+ ]);
83
+ return { head, branch };
84
+ } catch {
85
+ return { head: null, branch: null };
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Load a repository's saved review as a `code-review/v1` document.
91
+ *
92
+ * @param {string} reviewsDir directory holding the saved comment files
93
+ * @param {string} repoPath repository to export
94
+ * @param {object} [options]
95
+ * @param {string|null} [options.file] restrict to one repo-relative path
96
+ * @returns {Promise<import('./agent').ReviewDocument>}
97
+ * @throws {NoReviewError} when nothing has been saved for this repository
98
+ * @throws {NoMatchingCommentsError} when `file` selects no comments
99
+ * @throws {import('./store').ReviewOwnershipError} when the file on disk
100
+ * records a different repository than the one asked for
101
+ */
102
+ async function loadReviewDocument(reviewsDir, repoPath, { file = null } = {}) {
103
+ const { comments } = await readSavedComments(reviewsDir, repoPath);
104
+
105
+ if (comments.length === 0) throw new NoReviewError(repoPath);
106
+
107
+ // Recording the commit lets a consumer tell whether the tree has moved on
108
+ // since the review was written. Neither is fatal if git will not say.
109
+ //
110
+ // Caveat worth knowing before you build on this: `head` is read live, here,
111
+ // at export time -- it is not what HEAD was when the review was written. So
112
+ // docs/agent-format.md telling consumers to compare it against current HEAD
113
+ // to detect staleness cannot work, because it is always current HEAD. That
114
+ // is a real contract bug (#32); it is reproduced rather than fixed here so
115
+ // this stays the pure lift it claims to be, and so the fix lands where it
116
+ // can change the stored format deliberately.
117
+ const { head, branch } = await readGitPosition(repoPath);
118
+
119
+ // Build then filter, in that order, and do not tidy this into one step:
120
+ // `filterReviewDocument` recomputes `summary` honestly and returns the very
121
+ // same object reference when no filter is given. `test/agent.test.js`
122
+ // asserts that identity with `assert.equal`.
123
+ const document = filterReviewDocument(
124
+ buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch }),
125
+ { file }
126
+ );
127
+
128
+ if (document.comments.length === 0) throw new NoMatchingCommentsError(repoPath, file);
129
+
130
+ return document;
131
+ }
132
+
133
+ /**
134
+ * Every repository with a saved review in `reviewsDir`.
135
+ *
136
+ * Nothing in this codebase enumerated `reviews/` before: there was no way to
137
+ * ask "which repositories have reviews?", only to ask about one you already
138
+ * knew the path of. Any agent-facing surface needs discovery first.
139
+ *
140
+ * Entries are reported from what each file records about itself, with the
141
+ * same ownership rule `readSavedComments` enforces: a file whose recorded
142
+ * repository disagrees with its own filename is corrupt and is skipped rather
143
+ * than listed under a name it does not own. Unreadable and unparseable files
144
+ * are skipped for the same reason — a listing that throws because one file in
145
+ * the directory is damaged is useless.
146
+ *
147
+ * A missing `reviewsDir` is an empty list, not an error: nobody has reviewed
148
+ * anything yet.
149
+ *
150
+ * @param {string} reviewsDir
151
+ * @returns {Promise<Array<{repoPath: string, name: string, comments: number, updatedAt: string}>>}
152
+ * newest first
153
+ */
154
+ async function listReviews(reviewsDir) {
155
+ let names;
156
+ try {
157
+ names = await fs.readdir(reviewsDir);
158
+ } catch (error) {
159
+ if (error.code === 'ENOENT') return [];
160
+ throw error;
161
+ }
162
+
163
+ const reviews = [];
164
+
165
+ for (const name of names) {
166
+ if (!name.startsWith('.code-review-comments-') || !name.endsWith('.json')) continue;
167
+
168
+ const file = path.join(reviewsDir, name);
169
+
170
+ let saved;
171
+ let stat;
172
+ try {
173
+ [saved, stat] = await Promise.all([
174
+ fs.readFile(file, 'utf-8').then(JSON.parse),
175
+ fs.stat(file)
176
+ ]);
177
+ } catch {
178
+ continue; // unreadable or not JSON; a damaged file is not a listing failure
179
+ }
180
+
181
+ const repoPath = saved?.repoPath;
182
+ if (typeof repoPath !== 'string' || repoPath === '') continue;
183
+
184
+ // The filename carries a fingerprint of the canonical path. If the file
185
+ // records a repository that would not produce this filename, the two
186
+ // disagree about who it belongs to, and store.js would refuse to serve
187
+ // it. Listing it anyway would advertise a review that cannot be opened.
188
+ if (commentsFilename(repoPath) !== name) continue;
189
+ if (!belongsTo(repoPath, repoPath)) continue;
190
+
191
+ const comments = Array.isArray(saved.comments) ? saved.comments.length : 0;
192
+ if (comments === 0) continue; // an emptied review is not a review
193
+
194
+ reviews.push({
195
+ repoPath: canonicalRepoPath(repoPath),
196
+ name: repoSlug(repoPath),
197
+ comments,
198
+ updatedAt: stat.mtime.toISOString()
199
+ });
200
+ }
201
+
202
+ return reviews.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
203
+ }
204
+
205
+ module.exports = { loadReviewDocument, listReviews, NoReviewError, NoMatchingCommentsError };
package/lib/paths.js CHANGED
@@ -1,11 +1,117 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const os = require('os');
3
5
  const path = require('path');
4
6
 
5
7
  /**
6
- * Confining request-supplied paths to the repository being reviewed.
8
+ * Where review state lives, and confining request-supplied paths to the
9
+ * repository being reviewed.
7
10
  */
8
11
 
12
+ /** The directory reviews were written to before #33. Inside the package. */
13
+ const LEGACY_REVIEWS_DIR = path.join(__dirname, '..', 'reviews');
14
+
15
+ /**
16
+ * The per-user directory this tool may keep state in.
17
+ *
18
+ * Reviews used to be written to `<package>/reviews`, which put user data
19
+ * inside a directory npm owns and reuses. Both of the things npm does to that
20
+ * directory destroy the data:
21
+ *
22
+ * npm uninstall git-reviewer -> the reviews go with it
23
+ * npm i git-reviewer@<newer> -> npm replaces the package directory
24
+ *
25
+ * and `npx git-reviewer .` -- the command the README leads with -- writes into
26
+ * the npx cache, which is pruned on npm's schedule rather than the user's.
27
+ *
28
+ * A review someone wrote by hand is the most expensive thing this tool holds.
29
+ * It does not belong anywhere a package manager is entitled to delete.
30
+ *
31
+ * @returns {string}
32
+ */
33
+ function userDataDir() {
34
+ const home = os.homedir();
35
+
36
+ if (process.platform === 'darwin') {
37
+ return path.join(home, 'Library', 'Application Support');
38
+ }
39
+
40
+ if (process.platform === 'win32') {
41
+ return (
42
+ process.env.LOCALAPPDATA ||
43
+ process.env.APPDATA ||
44
+ path.join(home, 'AppData', 'Local')
45
+ );
46
+ }
47
+
48
+ return process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
49
+ }
50
+
51
+ /**
52
+ * Where comment state and submitted reviews are written.
53
+ *
54
+ * Resolved on every call rather than frozen at import, so that setting
55
+ * `REVIEWER_DATA_DIR` always takes effect. That is not only a convenience:
56
+ * the test suite spawns the real CLI, and a value captured at import time
57
+ * would have those tests writing into the developer's actual review
58
+ * directory.
59
+ *
60
+ * @returns {string}
61
+ */
62
+ function reviewsDir() {
63
+ const override = process.env.REVIEWER_DATA_DIR;
64
+ if (override) return path.resolve(override);
65
+ return path.join(userDataDir(), 'git-reviewer');
66
+ }
67
+
68
+ /**
69
+ * Move reviews written by an older version out of the package directory.
70
+ *
71
+ * Same shape, and the same reasoning, as the legacy-filename adoption in
72
+ * `lib/store.js`: a review someone wrote is not ours to abandon because we
73
+ * changed our minds about where it belongs.
74
+ *
75
+ * Copy rather than rename: the source may be read-only, on another device, or
76
+ * an npx cache we do not own, and none of those is a reason to fail. Existing
77
+ * files at the destination always win -- a second run must not overwrite newer
78
+ * work with the stale copy it already migrated.
79
+ *
80
+ * Synchronous on purpose. It runs once, before anything can read the
81
+ * directory, and an async version would let a read start against a directory
82
+ * that is still half-populated.
83
+ *
84
+ * @param {string} [target] defaults to the resolved reviews directory
85
+ * @returns {number} how many files were adopted
86
+ */
87
+ function adoptLegacyReviews(target = reviewsDir()) {
88
+ if (path.resolve(target) === path.resolve(LEGACY_REVIEWS_DIR)) return 0;
89
+
90
+ let names;
91
+ try {
92
+ names = fs.readdirSync(LEGACY_REVIEWS_DIR);
93
+ } catch {
94
+ return 0; // nothing written by an older version
95
+ }
96
+
97
+ let adopted = 0;
98
+ for (const name of names) {
99
+ const from = path.join(LEGACY_REVIEWS_DIR, name);
100
+ const to = path.join(target, name);
101
+ try {
102
+ if (!fs.statSync(from).isFile()) continue;
103
+ if (fs.existsSync(to)) continue;
104
+ fs.mkdirSync(target, { recursive: true });
105
+ fs.copyFileSync(from, to);
106
+ adopted += 1;
107
+ } catch {
108
+ /* one unreadable file is not a reason to abandon the rest */
109
+ }
110
+ }
111
+
112
+ return adopted;
113
+ }
114
+
9
115
  class PathEscapeError extends Error {
10
116
  /** @param {string} requested the offending path, as received */
11
117
  constructor(requested) {
@@ -49,4 +155,19 @@ function resolveRepoFile(repoPath, filePath) {
49
155
  return resolved;
50
156
  }
51
157
 
52
- module.exports = { resolveRepoFile, PathEscapeError };
158
+ module.exports = {
159
+ reviewsDir,
160
+ userDataDir,
161
+ adoptLegacyReviews,
162
+ LEGACY_REVIEWS_DIR,
163
+ resolveRepoFile,
164
+ PathEscapeError
165
+ };
166
+
167
+ // Kept so `require('./lib/paths').REVIEWS_DIR` and the server's re-export
168
+ // keep working. A getter, not a value, so it honours REVIEWER_DATA_DIR at
169
+ // the moment it is read.
170
+ Object.defineProperty(module.exports, 'REVIEWS_DIR', {
171
+ get: reviewsDir,
172
+ enumerable: true
173
+ });
package/lib/review.js CHANGED
@@ -179,6 +179,8 @@ function formatComment(comment) {
179
179
  * @param {string} text
180
180
  * @returns {string}
181
181
  */
182
+ // Exported for lib/agent.js: the prompt format has the same problem this
183
+ // solves, and one rule for choosing a marker is better than two.
182
184
  function fence(text) {
183
185
  const longest = String(text).match(/^`{3,}/gm)?.reduce((a, b) => (b.length > a.length ? b : a), '') ?? '';
184
186
  const marker = '`'.repeat(Math.max(3, longest.length + 1));
@@ -197,6 +199,7 @@ function formatTimestamp(timestamp) {
197
199
 
198
200
  module.exports = {
199
201
  formatReview,
202
+ fence,
200
203
  reviewFilename,
201
204
  commentsFilename,
202
205
  legacyCommentsFilename,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.0.0",
4
- "description": "Review local git changes in your browser with inline comments and threaded follow-ups \u2014 no branch, remote, or account required",
3
+ "version": "2.1.0",
4
+ "description": "Review your coding agent's changes like a pull request \u2014 locally, with inline comments and threaded follow-ups, then hand the marked-up review back to the agent",
5
5
  "author": "Dheeraj Jha",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/dheerajjha/reviewer#readme",
@@ -14,11 +14,17 @@
14
14
  },
15
15
  "keywords": [
16
16
  "code-review",
17
+ "code-review-tool",
17
18
  "git",
18
19
  "diff",
19
20
  "review",
20
21
  "cli",
21
- "developer-tools"
22
+ "developer-tools",
23
+ "ai",
24
+ "agent",
25
+ "coding-agent",
26
+ "llm",
27
+ "local-first"
22
28
  ],
23
29
  "engines": {
24
30
  "node": ">=18.0.0"
package/server.js CHANGED
@@ -7,7 +7,11 @@ const fs = require('fs').promises;
7
7
  const path = require('path');
8
8
 
9
9
  const { parseDiff } = require('./lib/diff');
10
- const { resolveRepoFile, PathEscapeError } = require('./lib/paths');
10
+ const {
11
+ reviewsDir: resolveReviewsDir,
12
+ resolveRepoFile,
13
+ PathEscapeError
14
+ } = require('./lib/paths');
11
15
  const { collectWorkingChanges, collectCommitChanges } = require('./lib/changes');
12
16
  const { SessionStore } = require('./lib/sessions');
13
17
  const { normalizeComments } = require('./lib/comments');
@@ -18,8 +22,6 @@ const { readSavedComments, ReviewOwnershipError } = require('./lib/store');
18
22
  const DEFAULT_PORT = 4500;
19
23
  const DEFAULT_HOST = '127.0.0.1';
20
24
 
21
- /** Where comment state and submitted reviews are written. */
22
- const REVIEWS_DIR = path.join(__dirname, 'reviews');
23
25
 
24
26
  /** A path inside the repository that names nothing, on disk or at HEAD. */
25
27
  class FileNotFoundError extends Error {
@@ -46,7 +48,7 @@ class FileNotFoundError extends Error {
46
48
  */
47
49
  function createApp(options = {}) {
48
50
  const {
49
- reviewsDir = REVIEWS_DIR,
51
+ reviewsDir = resolveReviewsDir(),
50
52
  sessions = new SessionStore(),
51
53
  git: gitFactory = simpleGit
52
54
  } = options;
@@ -418,4 +420,11 @@ if (require.main === module) {
418
420
  });
419
421
  }
420
422
 
421
- module.exports = { createApp, startServer, DEFAULT_PORT, DEFAULT_HOST, REVIEWS_DIR };
423
+ module.exports = { createApp, startServer, DEFAULT_PORT, DEFAULT_HOST };
424
+
425
+ // Re-exported as a getter so existing callers keep working and still see
426
+ // REVIEWER_DATA_DIR. See lib/paths.js.
427
+ Object.defineProperty(module.exports, 'REVIEWS_DIR', {
428
+ get: resolveReviewsDir,
429
+ enumerable: true
430
+ });