git-reviewer 2.8.1 → 2.9.1
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 +2 -2
- package/bin/reviewer.js +18 -2
- package/lib/agent.js +35 -1
- package/lib/export.js +2 -2
- package/lib/store.js +8 -5
- package/package.json +1 -1
- package/public/app.js +24 -0
- package/server.js +72 -1
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
|
|
@@ -294,7 +294,7 @@ rather than a public issue.
|
|
|
294
294
|
|
|
295
295
|
```bash
|
|
296
296
|
npm install # 3 dependencies, no build step, ~6MB
|
|
297
|
-
npm test #
|
|
297
|
+
npm test # 299 tests
|
|
298
298
|
npm run test:watch
|
|
299
299
|
npm run test:coverage
|
|
300
300
|
```
|
package/bin/reviewer.js
CHANGED
|
@@ -226,9 +226,25 @@ async function main() {
|
|
|
226
226
|
stop();
|
|
227
227
|
};
|
|
228
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The last tab closed and did not come back.
|
|
231
|
+
*
|
|
232
|
+
* Only on a terminal. In the piped form, submitting is what hands the
|
|
233
|
+
* review over, and a closed tab is not a submitted review -- wiring this to
|
|
234
|
+
* the handoff would send a half-written review to something that starts
|
|
235
|
+
* editing files, which is a worse outcome by a wide margin than the Ctrl+C
|
|
236
|
+
* this saves.
|
|
237
|
+
*/
|
|
238
|
+
const onIdle = handingOff
|
|
239
|
+
? null
|
|
240
|
+
: () => {
|
|
241
|
+
note('\n Browser closed. Stopping.\n');
|
|
242
|
+
stop();
|
|
243
|
+
};
|
|
244
|
+
|
|
229
245
|
const server = await listen(
|
|
230
246
|
options.port ?? (Number(process.env.PORT) || DEFAULT_PORT),
|
|
231
|
-
{ onReviewSubmitted, handoff: handingOff }
|
|
247
|
+
{ onReviewSubmitted, handoff: handingOff, onIdle }
|
|
232
248
|
);
|
|
233
249
|
const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath);
|
|
234
250
|
|
|
@@ -236,7 +252,7 @@ async function main() {
|
|
|
236
252
|
if (repoPath) note(` reviewing ${repoPath}`);
|
|
237
253
|
note(handingOff
|
|
238
254
|
? '\n Submit the review in the browser and it will be written here.\n'
|
|
239
|
-
: '\n
|
|
255
|
+
: '\n Close the tab when you are done, or press Ctrl+C.\n');
|
|
240
256
|
|
|
241
257
|
if (options.open && !(await openInBrowser(url))) {
|
|
242
258
|
note(' Could not open a browser — open the URL above yourself.\n');
|
package/lib/agent.js
CHANGED
|
@@ -176,6 +176,40 @@ function toAgentComment(comment, id) {
|
|
|
176
176
|
return entry;
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* What this review was of, in one line.
|
|
181
|
+
*
|
|
182
|
+
* An agent applying a review needs to know whether it is looking at
|
|
183
|
+
* uncommitted work or at a commit that is already in history -- "make this
|
|
184
|
+
* change" means a different thing in each. Both used to print
|
|
185
|
+
* `Reviewed at commit: <sha>`, which in `working` mode is not merely vague:
|
|
186
|
+
* the review is of changes that are *not* in that commit, and saying they
|
|
187
|
+
* are is the kind of authoritative-looking line this project exists to
|
|
188
|
+
* avoid.
|
|
189
|
+
*
|
|
190
|
+
* A null mode is a review exported from a file written before the mode was
|
|
191
|
+
* recorded. Reporting only where HEAD is, exactly as it always did, is the
|
|
192
|
+
* honest answer there -- nothing on disk says what was compared.
|
|
193
|
+
*
|
|
194
|
+
* @param {ReviewDocument} document
|
|
195
|
+
* @returns {string|null}
|
|
196
|
+
*/
|
|
197
|
+
function describeScope({ repository, mode }) {
|
|
198
|
+
const at = repository.head ? ` (${repository.head})` : '';
|
|
199
|
+
|
|
200
|
+
if (mode === 'working') {
|
|
201
|
+
return `Reviewed: uncommitted changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}.`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (mode === 'lastCommit') {
|
|
205
|
+
return repository.head
|
|
206
|
+
? `Reviewed: commit ${repository.head}, against its parent.`
|
|
207
|
+
: 'Reviewed: the most recent commit, against its parent.';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return repository.head ? `Reviewed at commit: ${repository.head}` : null;
|
|
211
|
+
}
|
|
212
|
+
|
|
179
213
|
/**
|
|
180
214
|
* Render a review as instructions for a coding agent.
|
|
181
215
|
*
|
|
@@ -192,7 +226,7 @@ function formatPrompt(document) {
|
|
|
192
226
|
'# Code review to address',
|
|
193
227
|
'',
|
|
194
228
|
`Repository: ${repository.path}`,
|
|
195
|
-
|
|
229
|
+
describeScope(document),
|
|
196
230
|
`${summary.comments} comment(s) across ${summary.files} file(s), written ${document.generatedAt}.`,
|
|
197
231
|
'',
|
|
198
232
|
'Work through every comment below and make the change it asks for, or, if',
|
package/lib/export.js
CHANGED
|
@@ -100,7 +100,7 @@ async function readGitPosition(repoPath) {
|
|
|
100
100
|
* records a different repository than the one asked for
|
|
101
101
|
*/
|
|
102
102
|
async function loadReviewDocument(reviewsDir, repoPath, { file = null } = {}) {
|
|
103
|
-
const { comments } = await readSavedComments(reviewsDir, repoPath);
|
|
103
|
+
const { comments, mode } = await readSavedComments(reviewsDir, repoPath);
|
|
104
104
|
|
|
105
105
|
if (comments.length === 0) throw new NoReviewError(repoPath);
|
|
106
106
|
|
|
@@ -121,7 +121,7 @@ async function loadReviewDocument(reviewsDir, repoPath, { file = null } = {}) {
|
|
|
121
121
|
// same object reference when no filter is given. `test/agent.test.js`
|
|
122
122
|
// asserts that identity with `assert.equal`.
|
|
123
123
|
const document = filterReviewDocument(
|
|
124
|
-
buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch }),
|
|
124
|
+
buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch, mode }),
|
|
125
125
|
{ file }
|
|
126
126
|
);
|
|
127
127
|
|
package/lib/store.js
CHANGED
|
@@ -74,7 +74,10 @@ async function readJson(file) {
|
|
|
74
74
|
*
|
|
75
75
|
* @param {string} reviewsDir
|
|
76
76
|
* @param {string} repoPath
|
|
77
|
-
* @returns {Promise<{comments: object[], adopted: boolean}>}
|
|
77
|
+
* @returns {Promise<{comments: object[], mode: string|null, adopted: boolean}>}
|
|
78
|
+
* `mode` is null for a file written before it was recorded -- there is
|
|
79
|
+
* nothing on disk that says what was compared, and guessing would be
|
|
80
|
+
* worse than admitting it.
|
|
78
81
|
* @throws {ReviewOwnershipError} when the current file names another repository
|
|
79
82
|
*/
|
|
80
83
|
async function readSavedComments(reviewsDir, repoPath) {
|
|
@@ -87,15 +90,15 @@ async function readSavedComments(reviewsDir, repoPath) {
|
|
|
87
90
|
if (!belongsTo(saved.repoPath, repoPath)) {
|
|
88
91
|
throw new ReviewOwnershipError(repoPath, saved.repoPath);
|
|
89
92
|
}
|
|
90
|
-
return { comments: saved.comments ?? [], adopted: false };
|
|
93
|
+
return { comments: saved.comments ?? [], mode: saved.mode ?? null, adopted: false };
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
const legacyName = legacyCommentsFilename(repoPath);
|
|
94
|
-
if (legacyName === commentsFilename(repoPath)) return { comments: [], adopted: false };
|
|
97
|
+
if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, adopted: false };
|
|
95
98
|
|
|
96
99
|
const legacy = await readJson(path.join(reviewsDir, legacyName));
|
|
97
100
|
if (legacy === null || !belongsTo(legacy.repoPath, repoPath)) {
|
|
98
|
-
return { comments: [], adopted: false };
|
|
101
|
+
return { comments: [], mode: null, adopted: false };
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
// Move it under the new name so the next read is a plain hit and the old
|
|
@@ -107,7 +110,7 @@ async function readSavedComments(reviewsDir, repoPath) {
|
|
|
107
110
|
/* the comments are already in hand; renaming is housekeeping */
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
return { comments: legacy.comments ?? [], adopted: true };
|
|
113
|
+
return { comments: legacy.comments ?? [], mode: legacy.mode ?? null, adopted: true };
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
module.exports = { readSavedComments, ReviewOwnershipError, belongsTo };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-reviewer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.1",
|
|
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
|
@@ -1718,7 +1718,31 @@ function getKeyboardShortcut(key, targetTagName, modifiers = {}) {
|
|
|
1718
1718
|
}
|
|
1719
1719
|
|
|
1720
1720
|
// Allow Enter key to load repo
|
|
1721
|
+
/**
|
|
1722
|
+
* Tell the server this tab exists, by holding a connection open for as long
|
|
1723
|
+
* as it does.
|
|
1724
|
+
*
|
|
1725
|
+
* There is nothing to read from this stream. The point is the socket: the
|
|
1726
|
+
* browser closes it when the tab closes, which is the only notification of a
|
|
1727
|
+
* closed tab that arrives whether you quit the browser, kill the window or
|
|
1728
|
+
* lose the machine. `beforeunload` does not fire for all of those.
|
|
1729
|
+
*
|
|
1730
|
+
* EventSource reconnects on its own after a drop, which is what makes the
|
|
1731
|
+
* server's grace period work -- a flaky connection comes back inside it and
|
|
1732
|
+
* nothing is stopped.
|
|
1733
|
+
*/
|
|
1734
|
+
function watchFromThisTab() {
|
|
1735
|
+
try {
|
|
1736
|
+
new EventSource(`${API_BASE}/alive`);
|
|
1737
|
+
} catch {
|
|
1738
|
+
// Older browsers, or a page opened from a file:// URL. Losing this costs
|
|
1739
|
+
// an automatic exit, not a review, so there is nothing to report.
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1721
1743
|
document.addEventListener('DOMContentLoaded', () => {
|
|
1744
|
+
watchFromThisTab();
|
|
1745
|
+
|
|
1722
1746
|
document.getElementById('repoPath').addEventListener('keypress', (e) => {
|
|
1723
1747
|
if (e.key === 'Enter') {
|
|
1724
1748
|
loadRepo();
|
package/server.js
CHANGED
|
@@ -55,6 +55,11 @@ class FileNotFoundError extends Error {
|
|
|
55
55
|
* @param {boolean} [options.handoff] whether the caller is going to do
|
|
56
56
|
* something with a submitted review. Reported to the page so it can say
|
|
57
57
|
* where the review went instead of only offering a download.
|
|
58
|
+
* @param {() => void} [options.onIdle] called once the last browser tab has
|
|
59
|
+
* been gone for `idleGraceMs`, so the process that started the server can
|
|
60
|
+
* stop. Omitted means never: a server with no one to tell keeps serving.
|
|
61
|
+
* @param {number} [options.idleGraceMs] how long to wait after the last tab
|
|
62
|
+
* leaves before deciding it is not coming back.
|
|
58
63
|
* @returns {import('express').Express}
|
|
59
64
|
*/
|
|
60
65
|
function createApp(options = {}) {
|
|
@@ -63,7 +68,9 @@ function createApp(options = {}) {
|
|
|
63
68
|
sessions = new SessionStore(),
|
|
64
69
|
git: gitFactory = simpleGit,
|
|
65
70
|
onReviewSubmitted = null,
|
|
66
|
-
handoff = false
|
|
71
|
+
handoff = false,
|
|
72
|
+
onIdle = null,
|
|
73
|
+
idleGraceMs = 5000
|
|
67
74
|
} = options;
|
|
68
75
|
|
|
69
76
|
const app = express();
|
|
@@ -75,6 +82,11 @@ function createApp(options = {}) {
|
|
|
75
82
|
app.locals.sessions = sessions;
|
|
76
83
|
app.locals.reviewsDir = reviewsDir;
|
|
77
84
|
|
|
85
|
+
// How many tabs are currently holding `/api/alive` open, and the timer that
|
|
86
|
+
// starts when that reaches zero. See the route for why it is a timer.
|
|
87
|
+
let watching = 0;
|
|
88
|
+
let idleTimer = null;
|
|
89
|
+
|
|
78
90
|
/**
|
|
79
91
|
* Look up the session for a request, or answer 400.
|
|
80
92
|
*
|
|
@@ -222,6 +234,59 @@ function createApp(options = {}) {
|
|
|
222
234
|
next();
|
|
223
235
|
}
|
|
224
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Held open for as long as a tab is watching, so the server can tell when
|
|
239
|
+
* the last one has gone.
|
|
240
|
+
*
|
|
241
|
+
* The connection *is* the signal. An open page holds this response open and
|
|
242
|
+
* closing the page closes it, so there is no heartbeat interval to tune, and
|
|
243
|
+
* two tabs are two connections rather than one flag that either close
|
|
244
|
+
* clears. `sameOriginOnly` is here for the same reason it is on the two
|
|
245
|
+
* below: without it a page on another site could hold this open and keep a
|
|
246
|
+
* server alive that its owner had finished with.
|
|
247
|
+
*
|
|
248
|
+
* Nothing happens at the moment a watcher leaves. A reload closes and
|
|
249
|
+
* reopens within milliseconds, and a dropped connection reopens when
|
|
250
|
+
* EventSource retries -- at the instant it happens neither is
|
|
251
|
+
* distinguishable from someone closing the tab for good. So the last one
|
|
252
|
+
* leaving starts a timer and the next to arrive cancels it. Guessing wrong
|
|
253
|
+
* ends a review somebody is in the middle of, which is worth five seconds.
|
|
254
|
+
*
|
|
255
|
+
* A server nobody ever opened is not idle. `watching` only falls to zero
|
|
256
|
+
* after having been above it, so `reviewer` whose browser never launched
|
|
257
|
+
* keeps serving rather than exiting into an empty terminal.
|
|
258
|
+
*/
|
|
259
|
+
app.get('/api/alive', sameOriginOnly, (req, res) => {
|
|
260
|
+
if (idleTimer) {
|
|
261
|
+
clearTimeout(idleTimer);
|
|
262
|
+
idleTimer = null;
|
|
263
|
+
}
|
|
264
|
+
watching += 1;
|
|
265
|
+
|
|
266
|
+
res.set({
|
|
267
|
+
'Content-Type': 'text/event-stream',
|
|
268
|
+
'Cache-Control': 'no-cache',
|
|
269
|
+
Connection: 'keep-alive'
|
|
270
|
+
});
|
|
271
|
+
// A comment frame, which EventSource ignores. Sending it now is what
|
|
272
|
+
// flushes the headers, so the client is connected rather than pending.
|
|
273
|
+
res.write(': watching\n\n');
|
|
274
|
+
|
|
275
|
+
res.on('close', () => {
|
|
276
|
+
watching -= 1;
|
|
277
|
+
if (watching > 0 || !onIdle) return;
|
|
278
|
+
|
|
279
|
+
idleTimer = setTimeout(() => {
|
|
280
|
+
idleTimer = null;
|
|
281
|
+
if (watching === 0) onIdle();
|
|
282
|
+
}, idleGraceMs);
|
|
283
|
+
// The listening socket is what holds the process open; this timer
|
|
284
|
+
// should not be able to on its own, or a closed server would sit
|
|
285
|
+
// waiting out the grace period before the process could exit.
|
|
286
|
+
idleTimer.unref();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
225
290
|
/** List the directories inside one, so a repository can be found by looking. */
|
|
226
291
|
app.get('/api/browse', sameOriginOnly, async (req, res) => {
|
|
227
292
|
try {
|
|
@@ -398,9 +463,15 @@ function createApp(options = {}) {
|
|
|
398
463
|
|
|
399
464
|
await fs.mkdir(reviewsDir, { recursive: true });
|
|
400
465
|
|
|
466
|
+
// `mode` is on the envelope rather than on each comment: it describes
|
|
467
|
+
// the review, not any one remark in it, and it is what lets `reviewer
|
|
468
|
+
// export` say what was compared. Without it an exported review could
|
|
469
|
+
// only report where HEAD is, which says nothing about whether the
|
|
470
|
+
// comments are about committed work or uncommitted work.
|
|
401
471
|
const data = {
|
|
402
472
|
repoPath: session.repoPath,
|
|
403
473
|
lastUpdated: new Date().toISOString(),
|
|
474
|
+
mode: session.mode,
|
|
404
475
|
comments: normalized
|
|
405
476
|
};
|
|
406
477
|
|