git-reviewer 2.3.1 → 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/package.json +1 -1
- package/public/app.js +88 -5
- 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/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
|
@@ -1102,7 +1102,7 @@ async function submitReview() {
|
|
|
1102
1102
|
throw new Error(data.error || 'Failed to submit review');
|
|
1103
1103
|
}
|
|
1104
1104
|
|
|
1105
|
-
showReviewModal(data
|
|
1105
|
+
showReviewModal(data);
|
|
1106
1106
|
showStatus(`Review submitted successfully! ${data.totalComments} comments`, 'success');
|
|
1107
1107
|
|
|
1108
1108
|
} catch (error) {
|
|
@@ -1110,9 +1110,38 @@ async function submitReview() {
|
|
|
1110
1110
|
}
|
|
1111
1111
|
}
|
|
1112
1112
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
+
|
|
1116
1145
|
const modal = document.createElement('div');
|
|
1117
1146
|
modal.className = 'review-modal';
|
|
1118
1147
|
modal.innerHTML = `
|
|
@@ -1122,11 +1151,12 @@ function showReviewModal(reviewContent, filename) {
|
|
|
1122
1151
|
<button class="close-modal" onclick="this.closest('.review-modal').remove()">×</button>
|
|
1123
1152
|
</div>
|
|
1124
1153
|
<div class="review-modal-body">
|
|
1154
|
+
${destination}
|
|
1125
1155
|
<pre class="review-text">${escapeHtml(reviewContent)}</pre>
|
|
1126
1156
|
</div>
|
|
1127
1157
|
<div class="review-modal-footer">
|
|
1128
1158
|
<button data-filename="${escapeHtml(filename)}" onclick="downloadReview(this.dataset.filename, this.closest('.review-modal').querySelector('.review-text').textContent)">
|
|
1129
|
-
Download
|
|
1159
|
+
Download
|
|
1130
1160
|
</button>
|
|
1131
1161
|
<button onclick="copyReviewToClipboard(this.closest('.review-modal').querySelector('.review-text').textContent)">
|
|
1132
1162
|
Copy to Clipboard
|
|
@@ -1594,6 +1624,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1594
1624
|
}
|
|
1595
1625
|
});
|
|
1596
1626
|
|
|
1627
|
+
restoreHowTo();
|
|
1628
|
+
|
|
1597
1629
|
for (const id of ['recentList', 'browseList']) {
|
|
1598
1630
|
const list = document.getElementById(id);
|
|
1599
1631
|
list.addEventListener('click', onPickerActivate);
|
|
@@ -1850,3 +1882,54 @@ function onPickerActivate(event) {
|
|
|
1850
1882
|
const into = event.target.closest('[data-into]');
|
|
1851
1883
|
if (into) loadBrowse(into.dataset.into);
|
|
1852
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
|
});
|