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 +35 -17
- package/bin/reviewer.js +34 -33
- package/lib/agent.js +8 -3
- package/lib/export.js +205 -0
- package/lib/paths.js +123 -2
- package/lib/review.js +3 -0
- package/package.json +9 -3
- package/server.js +14 -5
package/README.md
CHANGED
|
@@ -3,27 +3,34 @@
|
|
|
3
3
|
[](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](test/)
|
|
7
7
|
[](package.json)
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|

|
|
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
|
|
113
|
-
|
|
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 #
|
|
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 {
|
|
12
|
-
const {
|
|
13
|
-
const {
|
|
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
|
|
55
|
+
let document;
|
|
52
56
|
try {
|
|
53
|
-
|
|
57
|
+
document = await loadReviewDocument(reviewsDir(), repoPath, { file: options.file });
|
|
54
58
|
} catch (error) {
|
|
55
|
-
|
|
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:', '',
|
|
167
|
+
lines.push('Anchor line:', '', fence(comment.anchor), '');
|
|
163
168
|
}
|
|
164
169
|
if (comment.selection) {
|
|
165
|
-
lines.push('Selected code:', '',
|
|
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
|
-
*
|
|
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 = {
|
|
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.
|
|
4
|
-
"description": "Review
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
+
});
|