git-reviewer 2.0.0 → 2.2.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 +70 -9
- package/lib/export.js +205 -0
- package/lib/paths.js +123 -2
- package/lib/review.js +3 -0
- package/package.json +9 -3
- package/public/app.js +15 -2
- package/public/style.css +16 -0
- package/server.js +54 -7
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 # 206 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,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const path = require('path');
|
|
3
4
|
const { groupByFile } = require('./comments');
|
|
4
|
-
const { repoSlug } = require('./review');
|
|
5
|
+
const { fence, repoSlug } = require('./review');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The machine-readable form of a review.
|
|
@@ -66,10 +67,43 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
|
|
|
66
67
|
comments: comments.length,
|
|
67
68
|
files: grouped.length
|
|
68
69
|
},
|
|
69
|
-
comments: grouped.flatMap(([, fileComments]) => fileComments
|
|
70
|
+
comments: assignIds(grouped.flatMap(([, fileComments]) => fileComments))
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Give every comment an id that is unique within the document.
|
|
76
|
+
*
|
|
77
|
+
* `<file>:<line>` was assumed to be unique because "the UI anchors one
|
|
78
|
+
* comment per line". It does not: in a diff, the removed line and the line
|
|
79
|
+
* that replaced it can carry the *same* number, one on each side, and
|
|
80
|
+
* commenting on both is two ordinary clicks. The export then contained two
|
|
81
|
+
* different comments under one id, and a consumer keyed by id — which is
|
|
82
|
+
* what an id is for — silently kept one of them. See #26.
|
|
83
|
+
*
|
|
84
|
+
* The first comment on a line keeps the bare `<file>:<line>`, so ids do not
|
|
85
|
+
* churn for the overwhelmingly common case and stay stable for anything
|
|
86
|
+
* already consuming them. Only a genuine collision gets a suffix.
|
|
87
|
+
*
|
|
88
|
+
* Stable across exports because the order is: `groupByFile` sorts by line
|
|
89
|
+
* with a stable sort, so two comments sharing a line keep the order they
|
|
90
|
+
* were saved in, and saving appends.
|
|
91
|
+
*
|
|
92
|
+
* @param {import('./comments').Comment[]} comments in document order
|
|
93
|
+
* @returns {AgentComment[]}
|
|
94
|
+
*/
|
|
95
|
+
function assignIds(comments) {
|
|
96
|
+
const seen = new Map();
|
|
97
|
+
|
|
98
|
+
return comments.map(comment => {
|
|
99
|
+
const base = `${comment.file}:${comment.line}`;
|
|
100
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
101
|
+
seen.set(base, n);
|
|
102
|
+
return toAgentComment(comment, n === 1 ? base : `${base}#${n}`);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
73
107
|
/**
|
|
74
108
|
* Select comments from an already-built document and keep its summary honest.
|
|
75
109
|
*
|
|
@@ -81,7 +115,8 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
|
|
|
81
115
|
function filterReviewDocument(document, { file = null } = {}) {
|
|
82
116
|
if (file === null) return document;
|
|
83
117
|
|
|
84
|
-
const
|
|
118
|
+
const wanted = normalizeReviewPath(file, document.repository.path);
|
|
119
|
+
const comments = document.comments.filter(comment => comment.file === wanted);
|
|
85
120
|
return {
|
|
86
121
|
...document,
|
|
87
122
|
summary: {
|
|
@@ -92,15 +127,36 @@ function filterReviewDocument(document, { file = null } = {}) {
|
|
|
92
127
|
};
|
|
93
128
|
}
|
|
94
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Turn a `--file` argument into the repo-relative, forward-slash form comments
|
|
132
|
+
* are stored in.
|
|
133
|
+
*
|
|
134
|
+
* Shells hand paths over as `./src/auth.js` on tab-completion, scripts often
|
|
135
|
+
* pass absolute paths, and Windows users type backslashes. Matching literally
|
|
136
|
+
* made every one of those miss and fail with the same error as a file that
|
|
137
|
+
* genuinely has no comments.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} file
|
|
140
|
+
* @param {string} repoPath
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function normalizeReviewPath(file, repoPath) {
|
|
144
|
+
let candidate = file;
|
|
145
|
+
if (path.isAbsolute(candidate)) candidate = path.relative(repoPath, candidate);
|
|
146
|
+
|
|
147
|
+
const normalized = path.posix.normalize(candidate.replace(/\\/g, '/'));
|
|
148
|
+
return normalized.replace(/^(\.\/)+/, '').replace(/\/$/, '');
|
|
149
|
+
}
|
|
150
|
+
|
|
95
151
|
/**
|
|
96
152
|
* @param {import('./comments').Comment} comment
|
|
97
153
|
* @returns {AgentComment}
|
|
98
154
|
*/
|
|
99
|
-
function toAgentComment(comment) {
|
|
155
|
+
function toAgentComment(comment, id) {
|
|
100
156
|
const entry = {
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
id
|
|
157
|
+
// Derived from file and line so it stays stable across exports, with a
|
|
158
|
+
// suffix where that is not enough on its own. See `assignIds`.
|
|
159
|
+
id,
|
|
104
160
|
file: comment.file,
|
|
105
161
|
line: comment.line,
|
|
106
162
|
anchor: comment.lineContent ?? null,
|
|
@@ -158,11 +214,16 @@ function formatPrompt(document) {
|
|
|
158
214
|
}
|
|
159
215
|
|
|
160
216
|
lines.push(`### ${comment.id}`, '');
|
|
217
|
+
// Fenced with `fence`, not a literal ```, because the anchor is arbitrary
|
|
218
|
+
// source and in a Markdown file it is routinely a fence itself. A
|
|
219
|
+
// same-length marker closes the block on the anchor's own line, and the
|
|
220
|
+
// comment body below it — the instruction the agent is meant to act on —
|
|
221
|
+
// becomes code, taking the next comment's heading with it. See #22.
|
|
161
222
|
if (comment.anchor !== null) {
|
|
162
|
-
lines.push('Anchor line:', '',
|
|
223
|
+
lines.push('Anchor line:', '', fence(comment.anchor), '');
|
|
163
224
|
}
|
|
164
225
|
if (comment.selection) {
|
|
165
|
-
lines.push('Selected code:', '',
|
|
226
|
+
lines.push('Selected code:', '', fence(comment.selection), '');
|
|
166
227
|
}
|
|
167
228
|
|
|
168
229
|
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.2.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/public/app.js
CHANGED
|
@@ -296,7 +296,7 @@ async function loadFile(filePath, index) {
|
|
|
296
296
|
updateCommentsSidebar();
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
displayCode(data.filePath, data.diffLines);
|
|
299
|
+
displayCode(data.filePath, data.diffLines, data.binary);
|
|
300
300
|
updateFullContextButton();
|
|
301
301
|
return Promise.resolve();
|
|
302
302
|
|
|
@@ -307,7 +307,7 @@ async function loadFile(filePath, index) {
|
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
// Display code with diff highlighting
|
|
310
|
-
function displayCode(filePath, diffLines) {
|
|
310
|
+
function displayCode(filePath, diffLines, binary) {
|
|
311
311
|
const codeSection = document.getElementById('codeSection');
|
|
312
312
|
const currentFileEl = document.getElementById('currentFile');
|
|
313
313
|
const codeViewer = document.getElementById('codeViewer');
|
|
@@ -315,6 +315,19 @@ function displayCode(filePath, diffLines) {
|
|
|
315
315
|
currentFileEl.textContent = filePath;
|
|
316
316
|
currentDiffLines = diffLines; // Store for reference when adding comments
|
|
317
317
|
|
|
318
|
+
// A binary file has no lines to show and none to comment on. Saying so is
|
|
319
|
+
// the whole feature: it used to be decoded as UTF-8 and rendered as
|
|
320
|
+
// mojibake, and a comment left on one of those lines exported an anchor
|
|
321
|
+
// that could never match the file again.
|
|
322
|
+
if (binary) {
|
|
323
|
+
codeSection.style.display = 'block';
|
|
324
|
+
codeViewer.innerHTML =
|
|
325
|
+
'<div class="binary-notice">Binary file — not shown.' +
|
|
326
|
+
'<span>git reports this file as binary, so there are no lines to ' +
|
|
327
|
+
'review. Comments need a line to anchor to.</span></div>';
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
318
331
|
const fileComments = comments.filter(c => c.file === filePath);
|
|
319
332
|
|
|
320
333
|
let html = '';
|
package/public/style.css
CHANGED
|
@@ -988,3 +988,19 @@ button:disabled {
|
|
|
988
988
|
color: #24292e;
|
|
989
989
|
border: 1px solid #e1e4e8;
|
|
990
990
|
}
|
|
991
|
+
|
|
992
|
+
/* A binary file has no lines to render; say so rather than showing a
|
|
993
|
+
lossy UTF-8 decode of its bytes. See #23. */
|
|
994
|
+
.binary-notice {
|
|
995
|
+
padding: 28px 24px;
|
|
996
|
+
color: #57606a;
|
|
997
|
+
font-size: 14px;
|
|
998
|
+
line-height: 1.6;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
.binary-notice span {
|
|
1002
|
+
display: block;
|
|
1003
|
+
margin-top: 6px;
|
|
1004
|
+
font-size: 13px;
|
|
1005
|
+
opacity: 0.8;
|
|
1006
|
+
}
|
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;
|
|
@@ -147,6 +149,29 @@ function createApp(options = {}) {
|
|
|
147
149
|
* @param {string} filePath
|
|
148
150
|
* @returns {Promise<string>}
|
|
149
151
|
*/
|
|
152
|
+
/**
|
|
153
|
+
* Whether git considers this file binary.
|
|
154
|
+
*
|
|
155
|
+
* Asking git rather than sniffing the bytes ourselves, because git's
|
|
156
|
+
* answer is the one that produced the diff we are about to render, and a
|
|
157
|
+
* second opinion that disagreed would be worse than no opinion. It says
|
|
158
|
+
* so in the diff itself: `Binary files a/x and b/x differ` for the
|
|
159
|
+
* default format, `GIT binary patch` when binary patches are enabled.
|
|
160
|
+
*
|
|
161
|
+
* This matters beyond the mojibake. `lib/changes.js` already classifies
|
|
162
|
+
* these as `B` and the sidebar renders that letter, but the file endpoint
|
|
163
|
+
* never consulted it -- so a 2 KB PNG came back as ten `add` lines that
|
|
164
|
+
* were ~45% U+FFFD, and a comment left on one of them exported an
|
|
165
|
+
* `anchor` that can never match the file. docs/agent-format.md tells a
|
|
166
|
+
* consumer to locate comments by that anchor.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} diffText
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
function isBinaryDiff(diffText) {
|
|
172
|
+
return /^Binary files .* differ$/m.test(diffText) || /^GIT binary patch$/m.test(diffText);
|
|
173
|
+
}
|
|
174
|
+
|
|
150
175
|
async function diffForFile(git, session, filePath) {
|
|
151
176
|
if (session.mode === 'lastCommit') {
|
|
152
177
|
return git.diff(['HEAD~1', 'HEAD', '--', filePath]);
|
|
@@ -219,11 +244,26 @@ function createApp(options = {}) {
|
|
|
219
244
|
const session = requireSession(res, repoId);
|
|
220
245
|
if (!session) return;
|
|
221
246
|
|
|
247
|
+
// Confine the path first, explicitly. It used to be confined as a
|
|
248
|
+
// side effect of `readFileForMode` calling `resolveRepoFile`, so
|
|
249
|
+
// moving the read below the binary check silently turned a 400 into a
|
|
250
|
+
// 500 and let the traversal reach git. A containment check that only
|
|
251
|
+
// works because of statement order is not a containment check.
|
|
252
|
+
resolveRepoFile(session.repoPath, filePath);
|
|
253
|
+
|
|
222
254
|
const git = gitFactory(session.repoPath);
|
|
223
|
-
const content = await readFileForMode(session, filePath);
|
|
224
255
|
const diffText = await diffForFile(git, session, filePath);
|
|
225
256
|
|
|
226
|
-
|
|
257
|
+
// Answered before the file is read: decoding a PNG as UTF-8 to then
|
|
258
|
+
// throw the result away is how the mojibake got served in the first
|
|
259
|
+
// place.
|
|
260
|
+
if (isBinaryDiff(diffText)) {
|
|
261
|
+
return res.json({ filePath, binary: true, diffLines: [] });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const content = await readFileForMode(session, filePath);
|
|
265
|
+
|
|
266
|
+
res.json({ filePath, binary: false, diffLines: parseDiff(diffText, content) });
|
|
227
267
|
} catch (error) {
|
|
228
268
|
if (error instanceof PathEscapeError) {
|
|
229
269
|
return res.status(400).json({ error: error.message });
|
|
@@ -418,4 +458,11 @@ if (require.main === module) {
|
|
|
418
458
|
});
|
|
419
459
|
}
|
|
420
460
|
|
|
421
|
-
module.exports = { createApp, startServer, DEFAULT_PORT, DEFAULT_HOST
|
|
461
|
+
module.exports = { createApp, startServer, DEFAULT_PORT, DEFAULT_HOST };
|
|
462
|
+
|
|
463
|
+
// Re-exported as a getter so existing callers keep working and still see
|
|
464
|
+
// REVIEWER_DATA_DIR. See lib/paths.js.
|
|
465
|
+
Object.defineProperty(module.exports, 'REVIEWS_DIR', {
|
|
466
|
+
get: resolveReviewsDir,
|
|
467
|
+
enumerable: true
|
|
468
|
+
});
|