git-reviewer 2.3.0 → 2.4.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 +27 -8
- package/bin/reviewer.js +79 -17
- package/lib/cli.js +11 -0
- package/lib/comments.js +24 -3
- package/package.json +1 -1
- package/public/app.js +110 -10
- package/public/index.html +22 -0
- package/public/style.css +129 -0
- package/server.js +41 -4
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
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
|
When an agent writes the code, reading it becomes the bottleneck — and the tool
|
|
@@ -19,18 +19,34 @@ marked-up review back to the agent to act on.
|
|
|
19
19
|
npm install -g git-reviewer
|
|
20
20
|
|
|
21
21
|
cd ~/your-project
|
|
22
|
-
reviewer
|
|
23
|
-
reviewer export . --format prompt | claude -p "Apply this review."
|
|
22
|
+
reviewer | claude -p "Apply this review."
|
|
24
23
|
```
|
|
25
24
|
|
|
25
|
+
That is the whole loop. `reviewer` opens the review in your browser; when you
|
|
26
|
+
press **Submit Review**, the review is written to stdout and the command
|
|
27
|
+
exits — so whatever is on the other end of the pipe receives it and starts
|
|
28
|
+
work. Nothing to copy, no second command, no file to go and find.
|
|
29
|
+
|
|
30
|
+
Without a pipe, `reviewer` is just a review tool: it tells you where the
|
|
31
|
+
review was saved and keeps serving, because you may have more to read.
|
|
32
|
+
Everything it says to you goes to stderr either way, so only the review is
|
|
33
|
+
ever on stdout.
|
|
34
|
+
|
|
26
35
|

|
|
27
36
|
|
|
28
37
|
*The whole loop, unedited and in real time — [full resolution](docs/demo.mp4).*
|
|
29
38
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
39
|
+
Nothing is installed into the project, there is nothing to sign in to, and
|
|
40
|
+
nothing leaves your machine. If you would rather not install it,
|
|
41
|
+
`npx git-reviewer` works the same way.
|
|
42
|
+
|
|
43
|
+
The recording above shows the two-step form, which still works and is the one
|
|
44
|
+
to reach for when the review and the hand-off happen at different times:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
reviewer # read the diff, comment on it
|
|
48
|
+
reviewer export . --format prompt | claude -p "Apply this review."
|
|
49
|
+
```
|
|
34
50
|
|
|
35
51
|
Comments persist between sessions and survive the code moving underneath them —
|
|
36
52
|
including the agent's own edits, which shift every line below the first change —
|
|
@@ -146,6 +162,9 @@ shown in full, read back out of `HEAD`:
|
|
|
146
162
|
| `Escape` | Cancel input |
|
|
147
163
|
| `↑` / `↓` | Move between files |
|
|
148
164
|
|
|
165
|
+
None of that is guessable, so the review pane says it too, in a **How to
|
|
166
|
+
comment** strip above the diff. Collapse it once and it stays collapsed.
|
|
167
|
+
|
|
149
168
|
## What it writes
|
|
150
169
|
|
|
151
170
|
Two files, both plain text you can read without this app, both in a per-user
|
|
@@ -271,7 +290,7 @@ rather than a public issue.
|
|
|
271
290
|
|
|
272
291
|
```bash
|
|
273
292
|
npm install # 3 dependencies, no build step, ~6MB
|
|
274
|
-
npm test #
|
|
293
|
+
npm test # 270 tests
|
|
275
294
|
npm run test:watch
|
|
276
295
|
npm run test:coverage
|
|
277
296
|
```
|
package/bin/reviewer.js
CHANGED
|
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
|
|
7
7
|
const { parseArgs, buildUrl, UsageError, USAGE } = require('../lib/cli');
|
|
8
|
+
const { homeRelative } = require('../lib/paths');
|
|
8
9
|
const { openInBrowser } = require('../lib/browser');
|
|
9
10
|
const { formatPrompt } = require('../lib/agent');
|
|
10
11
|
const { ReviewOwnershipError } = require('../lib/store');
|
|
@@ -22,6 +23,21 @@ const { version } = require('../package.json');
|
|
|
22
23
|
* The `reviewer` command: start the local server and open it in a browser.
|
|
23
24
|
*/
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Everything this command says to the person running it.
|
|
28
|
+
*
|
|
29
|
+
* stderr, not stdout, and that is the whole design. stdout carries the
|
|
30
|
+
* finished review so it can be piped into a coding agent; a banner or a
|
|
31
|
+
* progress line written there lands in the middle of it. Both streams go to
|
|
32
|
+
* the same terminal when nothing is piping, so this is invisible until it
|
|
33
|
+
* matters.
|
|
34
|
+
*
|
|
35
|
+
* @param {string} [line]
|
|
36
|
+
*/
|
|
37
|
+
function note(line = '') {
|
|
38
|
+
process.stderr.write(`${line}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/**
|
|
26
42
|
* Listen on `port`, falling back to any free port if it is taken.
|
|
27
43
|
*
|
|
@@ -29,16 +45,17 @@ const { version } = require('../package.json');
|
|
|
29
45
|
* an EADDRINUSE stack trace.
|
|
30
46
|
*
|
|
31
47
|
* @param {number} port
|
|
48
|
+
* @param {object} [options] passed through to the server
|
|
32
49
|
* @returns {Promise<import('http').Server>}
|
|
33
50
|
*/
|
|
34
|
-
async function listen(port) {
|
|
51
|
+
async function listen(port, options = {}) {
|
|
35
52
|
try {
|
|
36
|
-
return await startServer({ port, host: DEFAULT_HOST, silent: true });
|
|
53
|
+
return await startServer({ ...options, port, host: DEFAULT_HOST, silent: true });
|
|
37
54
|
} catch (error) {
|
|
38
55
|
if (error.code !== 'EADDRINUSE') throw error;
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
return startServer({ port: 0, host: DEFAULT_HOST, silent: true });
|
|
57
|
+
note(`Port ${port} is in use, picking another.`);
|
|
58
|
+
return startServer({ ...options, port: 0, host: DEFAULT_HOST, silent: true });
|
|
42
59
|
}
|
|
43
60
|
}
|
|
44
61
|
|
|
@@ -112,11 +129,11 @@ async function main() {
|
|
|
112
129
|
// can start against a half-populated directory. See #33.
|
|
113
130
|
const adopted = adoptLegacyReviews();
|
|
114
131
|
if (adopted > 0) {
|
|
115
|
-
|
|
132
|
+
note(
|
|
116
133
|
` Copied ${adopted} saved review${adopted === 1 ? '' : 's'} out of the ` +
|
|
117
134
|
`install directory to\n ${reviewsDir()}\n so that upgrading or ` +
|
|
118
135
|
'reinstalling this package can no longer delete them.\n' +
|
|
119
|
-
' The originals are left where they were
|
|
136
|
+
' The originals are left where they were.'
|
|
120
137
|
);
|
|
121
138
|
}
|
|
122
139
|
|
|
@@ -144,23 +161,68 @@ async function main() {
|
|
|
144
161
|
|
|
145
162
|
const repoPath = await repositoryToOpen(options.repoPath);
|
|
146
163
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
164
|
+
// Is anything reading our stdout? If so, the review goes there when it is
|
|
165
|
+
// submitted and the command finishes -- which is what turns the whole loop
|
|
166
|
+
// into one line:
|
|
167
|
+
//
|
|
168
|
+
// reviewer | claude -p "Apply this review."
|
|
169
|
+
//
|
|
170
|
+
// On a terminal there is nothing waiting, so submitting is not the end of
|
|
171
|
+
// anything: you may have more to review. It says where the review went and
|
|
172
|
+
// what to run next, and keeps serving.
|
|
173
|
+
const handingOff = !process.stdout.isTTY;
|
|
174
|
+
|
|
175
|
+
let handedOff = false;
|
|
158
176
|
const stop = () => {
|
|
159
177
|
server.close(() => process.exit(0));
|
|
160
178
|
// Don't wait forever on a browser holding the connection open.
|
|
161
179
|
setTimeout(() => process.exit(0), 2000).unref();
|
|
162
180
|
};
|
|
163
181
|
|
|
182
|
+
/**
|
|
183
|
+
* A review has just been written. Say so, or hand it over.
|
|
184
|
+
*
|
|
185
|
+
* @param {{document: object, reviewPath: string}} submitted
|
|
186
|
+
*/
|
|
187
|
+
const onReviewSubmitted = ({ document, reviewPath }) => {
|
|
188
|
+
const { comments, files } = document.summary;
|
|
189
|
+
const count = `${comments} comment${comments === 1 ? '' : 's'} on ` +
|
|
190
|
+
`${files} file${files === 1 ? '' : 's'}`;
|
|
191
|
+
|
|
192
|
+
if (!handingOff) {
|
|
193
|
+
note(`\n Review saved ${homeRelative(reviewPath)}`);
|
|
194
|
+
note(` ${count}\n`);
|
|
195
|
+
note(' Hand it to an agent with:');
|
|
196
|
+
note(` reviewer export ${repoPath ? homeRelative(repoPath) : '.'} --format prompt | claude -p "Apply this review."\n`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Once is enough: the first submitted review is what the pipe is waiting
|
|
201
|
+
// for, and a second would arrive after the process has gone.
|
|
202
|
+
if (handedOff) return;
|
|
203
|
+
handedOff = true;
|
|
204
|
+
|
|
205
|
+
note(`\n ${count} — handing the review back to your terminal.\n`);
|
|
206
|
+
process.stdout.write(formatPrompt(document));
|
|
207
|
+
stop();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const server = await listen(
|
|
211
|
+
options.port ?? (Number(process.env.PORT) || DEFAULT_PORT),
|
|
212
|
+
{ onReviewSubmitted, handoff: handingOff }
|
|
213
|
+
);
|
|
214
|
+
const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath);
|
|
215
|
+
|
|
216
|
+
note(`\n Code Reviewer ${url}`);
|
|
217
|
+
if (repoPath) note(` reviewing ${repoPath}`);
|
|
218
|
+
note(handingOff
|
|
219
|
+
? '\n Submit the review in the browser and it will be written here.\n'
|
|
220
|
+
: '\n Press Ctrl+C to stop.\n');
|
|
221
|
+
|
|
222
|
+
if (options.open && !(await openInBrowser(url))) {
|
|
223
|
+
note(' Could not open a browser — open the URL above yourself.\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
164
226
|
process.on('SIGINT', stop);
|
|
165
227
|
process.on('SIGTERM', stop);
|
|
166
228
|
}
|
package/lib/cli.js
CHANGED
|
@@ -18,6 +18,15 @@ Usage:
|
|
|
18
18
|
Installed under both names: reviewer and git-reviewer. The second name also
|
|
19
19
|
makes it reachable as a git subcommand: git reviewer
|
|
20
20
|
|
|
21
|
+
Handing a review to an agent:
|
|
22
|
+
reviewer | claude -p "Apply this review."
|
|
23
|
+
|
|
24
|
+
With something reading its output, reviewer writes the finished review there
|
|
25
|
+
the moment you submit it in the browser, and exits. On a terminal there is
|
|
26
|
+
nothing waiting, so it says where the review was saved and keeps serving.
|
|
27
|
+
Everything it tells you goes to stderr either way, so only the review is
|
|
28
|
+
ever on stdout.
|
|
29
|
+
|
|
21
30
|
Arguments:
|
|
22
31
|
repository Path to a git repository, or to any directory inside one.
|
|
23
32
|
Defaults to the current directory. It is opened
|
|
@@ -38,6 +47,8 @@ Options:
|
|
|
38
47
|
|
|
39
48
|
Examples:
|
|
40
49
|
reviewer review the repository you are standing in
|
|
50
|
+
reviewer | claude -p "Apply this review."
|
|
51
|
+
review it, then hand it straight to an agent
|
|
41
52
|
reviewer ~/work/api review another repository
|
|
42
53
|
reviewer . --no-open print the URL, open it yourself
|
|
43
54
|
reviewer export . print the review as JSON
|
package/lib/comments.js
CHANGED
|
@@ -30,10 +30,31 @@
|
|
|
30
30
|
* Optional fields are omitted rather than stored as `null` so that an absent
|
|
31
31
|
* selection and an empty one are not two different states on disk.
|
|
32
32
|
*
|
|
33
|
+
* A comment missing its file, line or text is rejected here rather than stored:
|
|
34
|
+
* `JSON.stringify` would silently drop the missing fields, and the record would
|
|
35
|
+
* only surface later, in `reviewer export`, as a comment on a file called
|
|
36
|
+
* `undefined` for an agent to act on.
|
|
37
|
+
*
|
|
33
38
|
* @param {object} comment
|
|
39
|
+
* @param {number} index position in the batch, for the error message
|
|
34
40
|
* @returns {Comment}
|
|
41
|
+
* @throws {TypeError} when a required field is missing or malformed
|
|
35
42
|
*/
|
|
36
|
-
function normalizeComment(comment) {
|
|
43
|
+
function normalizeComment(comment, index) {
|
|
44
|
+
const where = `comments[${index}]`;
|
|
45
|
+
if (comment === null || typeof comment !== 'object' || Array.isArray(comment)) {
|
|
46
|
+
throw new TypeError(`${where} must be an object`);
|
|
47
|
+
}
|
|
48
|
+
if (typeof comment.file !== 'string' || comment.file.trim() === '') {
|
|
49
|
+
throw new TypeError(`${where}.file must be a non-empty string`);
|
|
50
|
+
}
|
|
51
|
+
if (!Number.isInteger(comment.line) || comment.line < 1) {
|
|
52
|
+
throw new TypeError(`${where}.line must be a positive integer`);
|
|
53
|
+
}
|
|
54
|
+
if (typeof comment.text !== 'string' || comment.text.trim() === '') {
|
|
55
|
+
throw new TypeError(`${where}.text must be a non-empty string`);
|
|
56
|
+
}
|
|
57
|
+
|
|
37
58
|
const normalized = {
|
|
38
59
|
file: comment.file,
|
|
39
60
|
line: comment.line,
|
|
@@ -61,13 +82,13 @@ function normalizeComment(comment) {
|
|
|
61
82
|
*
|
|
62
83
|
* @param {object[]} comments
|
|
63
84
|
* @returns {Comment[]}
|
|
64
|
-
* @throws {TypeError} when `comments` is not an array
|
|
85
|
+
* @throws {TypeError} when `comments` is not an array or a comment is malformed
|
|
65
86
|
*/
|
|
66
87
|
function normalizeComments(comments) {
|
|
67
88
|
if (!Array.isArray(comments)) {
|
|
68
89
|
throw new TypeError('comments must be an array');
|
|
69
90
|
}
|
|
70
|
-
return comments.map(normalizeComment);
|
|
91
|
+
return comments.map((comment, index) => normalizeComment(comment, index));
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-reviewer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Review your coding agent's changes like a pull request — 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",
|
package/public/app.js
CHANGED
|
@@ -180,8 +180,10 @@ async function loadRepo() {
|
|
|
180
180
|
// Load saved comments
|
|
181
181
|
const savedComments = await loadCommentsFromBackend();
|
|
182
182
|
if (savedComments.length > 0) {
|
|
183
|
-
//
|
|
184
|
-
|
|
183
|
+
// These went into `window.savedComments` as well, as a second copy for
|
|
184
|
+
// the matcher to read. Nothing reads it now, and leaving a stale copy of
|
|
185
|
+
// the review lying around is how the resurrection happened in the first
|
|
186
|
+
// place -- so there is one list, and it is this one.
|
|
185
187
|
|
|
186
188
|
// Add all saved comments to the comments array immediately
|
|
187
189
|
comments = savedComments.map(c => ({
|
|
@@ -308,9 +310,20 @@ async function loadFile(filePath, index) {
|
|
|
308
310
|
throw new Error(data.error || 'Failed to load file');
|
|
309
311
|
}
|
|
310
312
|
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
// Give this file's comments their position in the diff just rendered.
|
|
314
|
+
//
|
|
315
|
+
// The source here used to be `window.savedComments`, a snapshot taken when
|
|
316
|
+
// the repository was opened and never updated again. Everything else works
|
|
317
|
+
// from `comments`, the live list -- so a comment you deleted was still in
|
|
318
|
+
// the snapshot, was not found in the live list, and the branch below added
|
|
319
|
+
// it straight back. It vanished from disk and reappeared on screen, and
|
|
320
|
+
// the next save wrote the resurrected copy back over the deletion. Editing
|
|
321
|
+
// one did the same thing by a different route: the text no longer matched,
|
|
322
|
+
// so the pre-edit copy was added alongside the edited one (#52).
|
|
323
|
+
//
|
|
324
|
+
// `comments` is what exists. A comment that is not in it does not exist.
|
|
325
|
+
if (comments.length > 0) {
|
|
326
|
+
const matchedComments = matchCommentsToDiff(comments, filePath, data.diffLines);
|
|
314
327
|
|
|
315
328
|
// Update existing comments in the array with matched diff indices
|
|
316
329
|
matchedComments.forEach(mc => {
|
|
@@ -330,6 +343,10 @@ async function loadFile(filePath, index) {
|
|
|
330
343
|
existing.followUps = mc.followUps;
|
|
331
344
|
}
|
|
332
345
|
} else {
|
|
346
|
+
// Unreachable now that the matcher is fed the live list: everything
|
|
347
|
+
// it returns came from there. Kept as a guard rather than removed,
|
|
348
|
+
// because silently dropping a comment is the worse failure of the
|
|
349
|
+
// two, and this is the line that would do it.
|
|
333
350
|
// Add new comment if it doesn't exist
|
|
334
351
|
comments.push({
|
|
335
352
|
file: mc.file,
|
|
@@ -1085,7 +1102,7 @@ async function submitReview() {
|
|
|
1085
1102
|
throw new Error(data.error || 'Failed to submit review');
|
|
1086
1103
|
}
|
|
1087
1104
|
|
|
1088
|
-
showReviewModal(data
|
|
1105
|
+
showReviewModal(data);
|
|
1089
1106
|
showStatus(`Review submitted successfully! ${data.totalComments} comments`, 'success');
|
|
1090
1107
|
|
|
1091
1108
|
} catch (error) {
|
|
@@ -1093,9 +1110,38 @@ async function submitReview() {
|
|
|
1093
1110
|
}
|
|
1094
1111
|
}
|
|
1095
1112
|
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1113
|
+
/**
|
|
1114
|
+
* What happened when you pressed Submit.
|
|
1115
|
+
*
|
|
1116
|
+
* This used to offer a download and nothing else, which is a strange thing to
|
|
1117
|
+
* put in front of somebody who started this from a terminal: the review is
|
|
1118
|
+
* already a file, and they are more likely to want to know where it is than
|
|
1119
|
+
* to want a second copy of it in ~/Downloads.
|
|
1120
|
+
*
|
|
1121
|
+
* So it says where the review was written, and when something is waiting on
|
|
1122
|
+
* the other end of a pipe it says that instead — because in that case the
|
|
1123
|
+
* answer to "what do I do now" is "nothing, it has already gone".
|
|
1124
|
+
*
|
|
1125
|
+
* @param {{reviewContent: string, filename: string, reviewPath?: string,
|
|
1126
|
+
* handoff?: boolean, totalComments?: number}} result
|
|
1127
|
+
*/
|
|
1128
|
+
function showReviewModal(result) {
|
|
1129
|
+
const { reviewContent, filename, reviewPath, handoff, totalComments } = result;
|
|
1130
|
+
|
|
1131
|
+
const count = totalComments === undefined
|
|
1132
|
+
? ''
|
|
1133
|
+
: `${totalComments} comment${totalComments === 1 ? '' : 's'} · `;
|
|
1134
|
+
|
|
1135
|
+
const destination = handoff
|
|
1136
|
+
? `<div class="review-destination handed-off">
|
|
1137
|
+
<strong>Handed back to your terminal.</strong>
|
|
1138
|
+
<span>${escapeHtml(count)}The command you ran has printed this review and exited. You can close this window.</span>
|
|
1139
|
+
</div>`
|
|
1140
|
+
: `<div class="review-destination">
|
|
1141
|
+
<strong>Saved</strong>
|
|
1142
|
+
<span class="review-path" title="${escapeHtml(reviewPath ?? '')}">${escapeHtml(reviewPath ?? filename)}</span>
|
|
1143
|
+
</div>`;
|
|
1144
|
+
|
|
1099
1145
|
const modal = document.createElement('div');
|
|
1100
1146
|
modal.className = 'review-modal';
|
|
1101
1147
|
modal.innerHTML = `
|
|
@@ -1105,11 +1151,12 @@ function showReviewModal(reviewContent, filename) {
|
|
|
1105
1151
|
<button class="close-modal" onclick="this.closest('.review-modal').remove()">×</button>
|
|
1106
1152
|
</div>
|
|
1107
1153
|
<div class="review-modal-body">
|
|
1154
|
+
${destination}
|
|
1108
1155
|
<pre class="review-text">${escapeHtml(reviewContent)}</pre>
|
|
1109
1156
|
</div>
|
|
1110
1157
|
<div class="review-modal-footer">
|
|
1111
1158
|
<button data-filename="${escapeHtml(filename)}" onclick="downloadReview(this.dataset.filename, this.closest('.review-modal').querySelector('.review-text').textContent)">
|
|
1112
|
-
Download
|
|
1159
|
+
Download
|
|
1113
1160
|
</button>
|
|
1114
1161
|
<button onclick="copyReviewToClipboard(this.closest('.review-modal').querySelector('.review-text').textContent)">
|
|
1115
1162
|
Copy to Clipboard
|
|
@@ -1577,6 +1624,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1577
1624
|
}
|
|
1578
1625
|
});
|
|
1579
1626
|
|
|
1627
|
+
restoreHowTo();
|
|
1628
|
+
|
|
1580
1629
|
for (const id of ['recentList', 'browseList']) {
|
|
1581
1630
|
const list = document.getElementById(id);
|
|
1582
1631
|
list.addEventListener('click', onPickerActivate);
|
|
@@ -1833,3 +1882,54 @@ function onPickerActivate(event) {
|
|
|
1833
1882
|
const into = event.target.closest('[data-into]');
|
|
1834
1883
|
if (into) loadBrowse(into.dataset.into);
|
|
1835
1884
|
}
|
|
1885
|
+
|
|
1886
|
+
|
|
1887
|
+
/* How to comment
|
|
1888
|
+
* ---------------------------------------------------------------------------
|
|
1889
|
+
* Open until it is dismissed, then remembered as dismissed. A hint that cannot
|
|
1890
|
+
* be turned off becomes furniture, and a hint that has to be found is not one.
|
|
1891
|
+
*/
|
|
1892
|
+
|
|
1893
|
+
/** Where the dismissal is remembered. Per browser; it is a convenience, not state. */
|
|
1894
|
+
const HOW_TO_KEY = 'reviewer.howTo.collapsed';
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* @returns {boolean} whether it has been collapsed before
|
|
1898
|
+
*/
|
|
1899
|
+
function howToWasCollapsed() {
|
|
1900
|
+
try {
|
|
1901
|
+
return window.localStorage.getItem(HOW_TO_KEY) === 'true';
|
|
1902
|
+
} catch {
|
|
1903
|
+
// A private window, or site data blocked. Showing the hint again is a far
|
|
1904
|
+
// better failure than not rendering the panel at all.
|
|
1905
|
+
return false;
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
/**
|
|
1910
|
+
* @param {boolean} collapsed
|
|
1911
|
+
*/
|
|
1912
|
+
function rememberHowTo(collapsed) {
|
|
1913
|
+
try {
|
|
1914
|
+
window.localStorage.setItem(HOW_TO_KEY, String(collapsed));
|
|
1915
|
+
} catch {
|
|
1916
|
+
// Nothing to do. It reopens next time, which is the harmless direction.
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
function toggleHowTo() {
|
|
1921
|
+
const panel = document.getElementById('howTo');
|
|
1922
|
+
const collapsed = panel.classList.toggle('collapsed');
|
|
1923
|
+
|
|
1924
|
+
document.getElementById('howToToggle').setAttribute('aria-expanded', String(!collapsed));
|
|
1925
|
+
rememberHowTo(collapsed);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function restoreHowTo() {
|
|
1929
|
+
const panel = document.getElementById('howTo');
|
|
1930
|
+
if (!panel) return;
|
|
1931
|
+
|
|
1932
|
+
const collapsed = howToWasCollapsed();
|
|
1933
|
+
panel.classList.toggle('collapsed', collapsed);
|
|
1934
|
+
document.getElementById('howToToggle').setAttribute('aria-expanded', String(!collapsed));
|
|
1935
|
+
}
|
package/public/index.html
CHANGED
|
@@ -101,6 +101,28 @@
|
|
|
101
101
|
Full Context
|
|
102
102
|
</button>
|
|
103
103
|
</div>
|
|
104
|
+
<section id="howTo" class="how-to">
|
|
105
|
+
<button
|
|
106
|
+
id="howToToggle"
|
|
107
|
+
class="how-to-toggle"
|
|
108
|
+
onclick="toggleHowTo()"
|
|
109
|
+
aria-expanded="true"
|
|
110
|
+
aria-controls="howToBody">
|
|
111
|
+
<span class="how-to-chevron" aria-hidden="true">▾</span>
|
|
112
|
+
How to comment
|
|
113
|
+
</button>
|
|
114
|
+
<div id="howToBody" class="how-to-body">
|
|
115
|
+
<p><strong>Click a line number</strong> in the left column to comment on that line.
|
|
116
|
+
Removed lines work too — the most useful note is often about deleted code.</p>
|
|
117
|
+
<p><strong>Hold <kbd>⌘</kbd>/<kbd>Ctrl</kbd> and drag</strong> to select part of a line,
|
|
118
|
+
then click the line number. The selection is quoted in the comment.</p>
|
|
119
|
+
<p><kbd>⌘</kbd>/<kbd>Ctrl</kbd> + <kbd>Enter</kbd> saves ·
|
|
120
|
+
<kbd>Esc</kbd> cancels ·
|
|
121
|
+
<kbd>↑</kbd> <kbd>↓</kbd> move between files</p>
|
|
122
|
+
<p>Everything saves as you type. <strong>Submit Review</strong> when you are done.</p>
|
|
123
|
+
</div>
|
|
124
|
+
</section>
|
|
125
|
+
|
|
104
126
|
<div id="codeViewer" class="code-viewer"></div>
|
|
105
127
|
</main>
|
|
106
128
|
|
package/public/style.css
CHANGED
|
@@ -1319,3 +1319,132 @@ button:disabled {
|
|
|
1319
1319
|
max-height: calc(100vh - 112px);
|
|
1320
1320
|
overflow-y: auto;
|
|
1321
1321
|
}
|
|
1322
|
+
|
|
1323
|
+
/* Where the review went
|
|
1324
|
+
*
|
|
1325
|
+
* The modal used to offer a download and nothing else, which is an odd thing
|
|
1326
|
+
* to put in front of somebody who started this from a terminal. The review is
|
|
1327
|
+
* already a file; knowing where is more useful than a second copy of it.
|
|
1328
|
+
*/
|
|
1329
|
+
.review-destination {
|
|
1330
|
+
display: flex;
|
|
1331
|
+
align-items: baseline;
|
|
1332
|
+
gap: 10px;
|
|
1333
|
+
margin-bottom: 14px;
|
|
1334
|
+
padding: 10px 12px;
|
|
1335
|
+
border: 1px solid #e1e4e8;
|
|
1336
|
+
border-radius: 6px;
|
|
1337
|
+
background: #f6f8fa;
|
|
1338
|
+
font-size: 13px;
|
|
1339
|
+
color: #24292e;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
.review-destination.handed-off {
|
|
1343
|
+
border-color: #b7e0c2;
|
|
1344
|
+
background: #eefcf1;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
.review-destination strong {
|
|
1348
|
+
flex: none;
|
|
1349
|
+
font-weight: 600;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
.review-destination span {
|
|
1353
|
+
min-width: 0;
|
|
1354
|
+
color: #57606a;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
.review-destination .review-path {
|
|
1358
|
+
/* Wrapped rather than truncated. There is room in a modal, and every way of
|
|
1359
|
+
* shortening a path hides the half somebody wanted: cut the front and the
|
|
1360
|
+
* directory goes, cut the back and the filename goes. */
|
|
1361
|
+
word-break: break-all;
|
|
1362
|
+
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
|
1363
|
+
font-size: 12px;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
.review-destination {
|
|
1367
|
+
align-items: flex-start;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/* How to comment
|
|
1371
|
+
*
|
|
1372
|
+
* Nothing on screen said that the column of line numbers was clickable, or
|
|
1373
|
+
* that holding Cmd and dragging selects a snippet to quote. Both are obvious
|
|
1374
|
+
* the second time and invisible the first, which is exactly what a hint is
|
|
1375
|
+
* for -- and exactly why it has to be dismissible rather than permanent.
|
|
1376
|
+
*/
|
|
1377
|
+
.how-to {
|
|
1378
|
+
flex: none;
|
|
1379
|
+
border-bottom: 1px solid #e1e4e8;
|
|
1380
|
+
background: #f6f8fa;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
.how-to-toggle {
|
|
1384
|
+
display: flex;
|
|
1385
|
+
align-items: center;
|
|
1386
|
+
gap: 6px;
|
|
1387
|
+
width: 100%;
|
|
1388
|
+
padding: 8px 20px;
|
|
1389
|
+
border: none;
|
|
1390
|
+
background: transparent;
|
|
1391
|
+
font: inherit;
|
|
1392
|
+
font-size: 12px;
|
|
1393
|
+
font-weight: 600;
|
|
1394
|
+
color: #57606a;
|
|
1395
|
+
text-align: left;
|
|
1396
|
+
cursor: pointer;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
.how-to-toggle:hover {
|
|
1400
|
+
color: #24292e;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
.how-to-chevron {
|
|
1404
|
+
display: inline-block;
|
|
1405
|
+
font-size: 10px;
|
|
1406
|
+
transition: transform 0.15s ease;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
.how-to.collapsed .how-to-chevron {
|
|
1410
|
+
transform: rotate(-90deg);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
.how-to.collapsed .how-to-body {
|
|
1414
|
+
display: none;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
.how-to-body {
|
|
1418
|
+
padding: 0 20px 12px 20px;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
.how-to-body p {
|
|
1422
|
+
margin: 0 0 6px 0;
|
|
1423
|
+
font-size: 12.5px;
|
|
1424
|
+
line-height: 1.5;
|
|
1425
|
+
color: #57606a;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
.how-to-body p:last-child {
|
|
1429
|
+
margin-bottom: 0;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
.how-to-body strong {
|
|
1433
|
+
color: #24292e;
|
|
1434
|
+
font-weight: 600;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
.how-to-body kbd {
|
|
1438
|
+
display: inline-block;
|
|
1439
|
+
min-width: 18px;
|
|
1440
|
+
padding: 1px 5px;
|
|
1441
|
+
border: 1px solid #d1d5da;
|
|
1442
|
+
border-bottom-width: 2px;
|
|
1443
|
+
border-radius: 4px;
|
|
1444
|
+
background: white;
|
|
1445
|
+
font-family: inherit;
|
|
1446
|
+
font-size: 11px;
|
|
1447
|
+
line-height: 1.4;
|
|
1448
|
+
color: #24292e;
|
|
1449
|
+
text-align: center;
|
|
1450
|
+
}
|
package/server.js
CHANGED
|
@@ -48,13 +48,22 @@ class FileNotFoundError extends Error {
|
|
|
48
48
|
* @param {string} [options.reviewsDir] where comment and review files are written
|
|
49
49
|
* @param {SessionStore} [options.sessions]
|
|
50
50
|
* @param {(path: string) => import('simple-git').SimpleGit} [options.git] git factory, injectable for tests
|
|
51
|
+
* @param {(review: {document: object, reviewPath: string, documentPath: string,
|
|
52
|
+
* comments: object[]}) => void} [options.onReviewSubmitted] called after a
|
|
53
|
+
* review is written, so the process that started the server can act on it.
|
|
54
|
+
* This is what lets `reviewer` hand the finished review back to the terminal.
|
|
55
|
+
* @param {boolean} [options.handoff] whether the caller is going to do
|
|
56
|
+
* something with a submitted review. Reported to the page so it can say
|
|
57
|
+
* where the review went instead of only offering a download.
|
|
51
58
|
* @returns {import('express').Express}
|
|
52
59
|
*/
|
|
53
60
|
function createApp(options = {}) {
|
|
54
61
|
const {
|
|
55
62
|
reviewsDir = resolveReviewsDir(),
|
|
56
63
|
sessions = new SessionStore(),
|
|
57
|
-
git: gitFactory = simpleGit
|
|
64
|
+
git: gitFactory = simpleGit,
|
|
65
|
+
onReviewSubmitted = null,
|
|
66
|
+
handoff = false
|
|
58
67
|
} = options;
|
|
59
68
|
|
|
60
69
|
const app = express();
|
|
@@ -292,7 +301,10 @@ function createApp(options = {}) {
|
|
|
292
301
|
}
|
|
293
302
|
|
|
294
303
|
const repoId = sessions.create(root, mode);
|
|
295
|
-
|
|
304
|
+
// stderr, not stdout. `reviewer | claude -p ...` puts the finished
|
|
305
|
+
// review on stdout, and anything else written there lands in the middle
|
|
306
|
+
// of it. Progress is for the person watching; stdout is for the pipe.
|
|
307
|
+
console.error(`Loaded repository: ${root} (mode: ${mode})`);
|
|
296
308
|
|
|
297
309
|
try {
|
|
298
310
|
await recordRecent(reviewsDir, root);
|
|
@@ -465,16 +477,39 @@ function createApp(options = {}) {
|
|
|
465
477
|
`${JSON.stringify(document, null, 2)}\n`
|
|
466
478
|
);
|
|
467
479
|
|
|
468
|
-
console.
|
|
480
|
+
console.error(`Review generated: ${filename} (${comments.length} comments)`);
|
|
481
|
+
|
|
482
|
+
const reviewPath = path.join(reviewsDir, filename);
|
|
469
483
|
|
|
470
484
|
res.json({
|
|
471
485
|
message: 'Review submitted successfully',
|
|
472
486
|
reviewContent,
|
|
473
487
|
filename,
|
|
474
488
|
documentFilename,
|
|
489
|
+
reviewPath,
|
|
490
|
+
// The page has no other way to know whether anything is waiting for
|
|
491
|
+
// this on the other side, and "download it" is the wrong thing to say
|
|
492
|
+
// to someone whose terminal is about to be handed the same review.
|
|
493
|
+
handoff,
|
|
475
494
|
review: document,
|
|
476
495
|
totalComments: comments.length
|
|
477
496
|
});
|
|
497
|
+
|
|
498
|
+
// After the response, so a slow or throwing consumer cannot make the
|
|
499
|
+
// browser think the submit failed. The review is already on disk by now
|
|
500
|
+
// and the caller is told where.
|
|
501
|
+
if (onReviewSubmitted) {
|
|
502
|
+
try {
|
|
503
|
+
onReviewSubmitted({
|
|
504
|
+
document,
|
|
505
|
+
reviewPath,
|
|
506
|
+
documentPath: path.join(reviewsDir, documentFilename),
|
|
507
|
+
comments
|
|
508
|
+
});
|
|
509
|
+
} catch (error) {
|
|
510
|
+
console.error('Review handoff failed:', error.message);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
478
513
|
} catch (error) {
|
|
479
514
|
if (error instanceof ReviewOwnershipError) {
|
|
480
515
|
return res.status(409).json({ error: error.message });
|
|
@@ -510,12 +545,14 @@ function startServer(options = {}) {
|
|
|
510
545
|
silent = false,
|
|
511
546
|
...appOptions
|
|
512
547
|
} = options;
|
|
548
|
+
// Everything else in `options` -- reviewsDir, sessions, git, handoff and
|
|
549
|
+
// onReviewSubmitted -- goes through to the app.
|
|
513
550
|
const app = createApp(appOptions);
|
|
514
551
|
|
|
515
552
|
return new Promise((resolve, reject) => {
|
|
516
553
|
const server = app.listen(port, host, () => {
|
|
517
554
|
if (!silent) {
|
|
518
|
-
console.
|
|
555
|
+
console.error(`Code Reviewer server running on http://${host}:${server.address().port}`);
|
|
519
556
|
}
|
|
520
557
|
resolve(server);
|
|
521
558
|
});
|