git-reviewer 2.9.1 → 2.11.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 +31 -2
- package/lib/agent.js +15 -2
- package/lib/export.js +2 -2
- package/lib/sessions.js +19 -6
- package/lib/store.js +5 -5
- package/package.json +1 -1
- package/public/app.js +148 -2
- package/public/index.html +22 -0
- package/public/style.css +87 -0
- package/server.js +192 -21
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
|
|
@@ -141,6 +141,35 @@ is nothing to configure:
|
|
|
141
141
|
| Working directory clean | The last commit against its parent |
|
|
142
142
|
| No commits yet | Every tracked and untracked file, as wholly new |
|
|
143
143
|
|
|
144
|
+
### Reviewing further back than the last commit
|
|
145
|
+
|
|
146
|
+
Those defaults cover the common case and nothing else needs setting up, but a
|
|
147
|
+
branch you have been working on for a day is several commits deep, and the
|
|
148
|
+
default scope only ever shows you the most recent one.
|
|
149
|
+
|
|
150
|
+
**Changes since** in the header takes any earlier commit and reloads the review
|
|
151
|
+
as a single combined diff up to `HEAD`. Pick one from the dropdown — recent
|
|
152
|
+
commits by subject line and when they landed, so you do not have to go and find
|
|
153
|
+
a SHA — or type a branch, a tag, or something like `HEAD~5` in the box beside
|
|
154
|
+
it for anything the list does not reach.
|
|
155
|
+
|
|
156
|
+
The commit the range starts from is not itself included: "changes since x1"
|
|
157
|
+
means everything after x1, the same as `x1..HEAD`.
|
|
158
|
+
|
|
159
|
+
The bar keeps saying how many commits the range covers, and it keeps saying it
|
|
160
|
+
when there are no files:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
Comparing 86b28f11 → 27668114 — 2 commits, shown as one combined diff
|
|
164
|
+
2 commits, and no net change between the two ends — every change in them was
|
|
165
|
+
undone again inside the range.
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
That is a real answer, not an empty screen. A file added in one commit and
|
|
169
|
+
deleted in a later one is correctly absent from a diff of the two ends, and
|
|
170
|
+
"no net change across 2 commits" is a different statement from "nothing to
|
|
171
|
+
review".
|
|
172
|
+
|
|
144
173
|
Modified, added, deleted, renamed, and binary files are all listed, each marked
|
|
145
174
|
with its git status letter. Deleted lines are commentable too — the most useful
|
|
146
175
|
review note is often about the code someone removed. A file deleted outright is
|
|
@@ -294,7 +323,7 @@ rather than a public issue.
|
|
|
294
323
|
|
|
295
324
|
```bash
|
|
296
325
|
npm install # 3 dependencies, no build step, ~6MB
|
|
297
|
-
npm test #
|
|
326
|
+
npm test # 314 tests
|
|
298
327
|
npm run test:watch
|
|
299
328
|
npm run test:coverage
|
|
300
329
|
```
|
package/lib/agent.js
CHANGED
|
@@ -50,7 +50,7 @@ const SCHEMA = 'code-review/v1';
|
|
|
50
50
|
* @param {string|null} [args.mode]
|
|
51
51
|
* @returns {ReviewDocument}
|
|
52
52
|
*/
|
|
53
|
-
function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null }) {
|
|
53
|
+
function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null, range = null }) {
|
|
54
54
|
const grouped = groupByFile(comments);
|
|
55
55
|
|
|
56
56
|
return {
|
|
@@ -63,6 +63,10 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
|
|
|
63
63
|
branch
|
|
64
64
|
},
|
|
65
65
|
mode,
|
|
66
|
+
// Omitted rather than null when there is no range, so `"range" in
|
|
67
|
+
// document` is a meaningful test -- the same rule the optional comment
|
|
68
|
+
// fields follow. A `working` review has no range and never will.
|
|
69
|
+
...(range ? { range } : {}),
|
|
66
70
|
summary: {
|
|
67
71
|
comments: comments.length,
|
|
68
72
|
files: grouped.length
|
|
@@ -194,13 +198,22 @@ function toAgentComment(comment, id) {
|
|
|
194
198
|
* @param {ReviewDocument} document
|
|
195
199
|
* @returns {string|null}
|
|
196
200
|
*/
|
|
197
|
-
function describeScope({ repository, mode }) {
|
|
201
|
+
function describeScope({ repository, mode, range }) {
|
|
198
202
|
const at = repository.head ? ` (${repository.head})` : '';
|
|
199
203
|
|
|
200
204
|
if (mode === 'working') {
|
|
201
205
|
return `Reviewed: uncommitted changes in the working tree, against ${repository.branch ?? 'HEAD'}${at}.`;
|
|
202
206
|
}
|
|
203
207
|
|
|
208
|
+
if (mode === 'range' && range) {
|
|
209
|
+
// Both ends, and the count, because the count is the only thing that
|
|
210
|
+
// distinguishes "one commit of work" from "nine" once the diff has been
|
|
211
|
+
// flattened -- and an agent reading a flattened diff of nine commits
|
|
212
|
+
// should know that is what it is holding.
|
|
213
|
+
const commits = `${range.commits} commit${range.commits === 1 ? '' : 's'}`;
|
|
214
|
+
return `Reviewed: everything between ${range.base} and ${range.head} (${commits}), as a single combined diff.`;
|
|
215
|
+
}
|
|
216
|
+
|
|
204
217
|
if (mode === 'lastCommit') {
|
|
205
218
|
return repository.head
|
|
206
219
|
? `Reviewed: commit ${repository.head}, against its parent.`
|
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, mode } = await readSavedComments(reviewsDir, repoPath);
|
|
103
|
+
const { comments, mode, range } = 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, mode }),
|
|
124
|
+
buildReviewDocument({ repoPath, comments, generatedAt: new Date(), head, branch, mode, range }),
|
|
125
125
|
{ file }
|
|
126
126
|
);
|
|
127
127
|
|
package/lib/sessions.js
CHANGED
|
@@ -11,9 +11,21 @@ const crypto = require('crypto');
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* @typedef {'working'|'lastCommit'} ReviewMode
|
|
14
|
+
* @typedef {'working'|'lastCommit'|'range'} ReviewMode
|
|
15
15
|
* `working` diffs the working tree against HEAD; `lastCommit` diffs HEAD
|
|
16
|
-
* against its parent, which is what a clean tree falls back to
|
|
16
|
+
* against its parent, which is what a clean tree falls back to; `range`
|
|
17
|
+
* diffs two refs the reviewer chose.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} ReviewRange
|
|
22
|
+
* @property {string} base ref the review is measured from, already resolved
|
|
23
|
+
* @property {string} head ref the review is measured to, already resolved
|
|
24
|
+
* @property {number} commits how many commits separate them
|
|
25
|
+
*
|
|
26
|
+
* Present for every commit-based review, including `lastCommit`, which is
|
|
27
|
+
* just the range `HEAD~1..HEAD` under a name. `working` has no range: its
|
|
28
|
+
* head is the working tree, which is not a ref.
|
|
17
29
|
*/
|
|
18
30
|
|
|
19
31
|
class SessionStore {
|
|
@@ -22,24 +34,25 @@ class SessionStore {
|
|
|
22
34
|
*/
|
|
23
35
|
constructor(generateId = () => crypto.randomBytes(8).toString('hex')) {
|
|
24
36
|
this.generateId = generateId;
|
|
25
|
-
/** @type {Map<string, {repoPath: string, mode: ReviewMode}>} */
|
|
37
|
+
/** @type {Map<string, {repoPath: string, mode: ReviewMode, range: ReviewRange|null}>} */
|
|
26
38
|
this.sessions = new Map();
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
/**
|
|
30
42
|
* @param {string} repoPath absolute path of the opened repository
|
|
31
43
|
* @param {ReviewMode} mode
|
|
44
|
+
* @param {ReviewRange|null} [range] the two refs, for a commit-based review
|
|
32
45
|
* @returns {string} the new session id
|
|
33
46
|
*/
|
|
34
|
-
create(repoPath, mode) {
|
|
47
|
+
create(repoPath, mode, range = null) {
|
|
35
48
|
const repoId = this.generateId();
|
|
36
|
-
this.sessions.set(repoId, { repoPath, mode });
|
|
49
|
+
this.sessions.set(repoId, { repoPath, mode, range });
|
|
37
50
|
return repoId;
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
/**
|
|
41
54
|
* @param {string} repoId
|
|
42
|
-
* @returns {{repoPath: string, mode: ReviewMode}|undefined}
|
|
55
|
+
* @returns {{repoPath: string, mode: ReviewMode, range: ReviewRange|null}|undefined}
|
|
43
56
|
*/
|
|
44
57
|
get(repoId) {
|
|
45
58
|
return this.sessions.get(repoId);
|
package/lib/store.js
CHANGED
|
@@ -74,7 +74,7 @@ async function readJson(file) {
|
|
|
74
74
|
*
|
|
75
75
|
* @param {string} reviewsDir
|
|
76
76
|
* @param {string} repoPath
|
|
77
|
-
* @returns {Promise<{comments: object[], mode: string|null, adopted: boolean}>}
|
|
77
|
+
* @returns {Promise<{comments: object[], mode: string|null, range: object|null, adopted: boolean}>}
|
|
78
78
|
* `mode` is null for a file written before it was recorded -- there is
|
|
79
79
|
* nothing on disk that says what was compared, and guessing would be
|
|
80
80
|
* worse than admitting it.
|
|
@@ -90,15 +90,15 @@ async function readSavedComments(reviewsDir, repoPath) {
|
|
|
90
90
|
if (!belongsTo(saved.repoPath, repoPath)) {
|
|
91
91
|
throw new ReviewOwnershipError(repoPath, saved.repoPath);
|
|
92
92
|
}
|
|
93
|
-
return { comments: saved.comments ?? [], mode: saved.mode ?? null, adopted: false };
|
|
93
|
+
return { comments: saved.comments ?? [], mode: saved.mode ?? null, range: saved.range ?? null, adopted: false };
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const legacyName = legacyCommentsFilename(repoPath);
|
|
97
|
-
if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, adopted: false };
|
|
97
|
+
if (legacyName === commentsFilename(repoPath)) return { comments: [], mode: null, range: null, adopted: false };
|
|
98
98
|
|
|
99
99
|
const legacy = await readJson(path.join(reviewsDir, legacyName));
|
|
100
100
|
if (legacy === null || !belongsTo(legacy.repoPath, repoPath)) {
|
|
101
|
-
return { comments: [], mode: null, adopted: false };
|
|
101
|
+
return { comments: [], mode: null, range: null, adopted: false };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// Move it under the new name so the next read is a plain hit and the old
|
|
@@ -110,7 +110,7 @@ async function readSavedComments(reviewsDir, repoPath) {
|
|
|
110
110
|
/* the comments are already in hand; renaming is housekeeping */
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
return { comments: legacy.comments ?? [], mode: legacy.mode ?? null, adopted: true };
|
|
113
|
+
return { comments: legacy.comments ?? [], mode: legacy.mode ?? null, range: legacy.range ?? null, adopted: true };
|
|
114
114
|
}
|
|
115
115
|
|
|
116
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.11.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
|
@@ -132,7 +132,129 @@ function calculateSimilarity(str1, str2) {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
// Load local repository
|
|
135
|
-
|
|
135
|
+
/** Short form of a commit sha, for reading rather than for copying. */
|
|
136
|
+
function shortSha(sha) {
|
|
137
|
+
return typeof sha === 'string' ? sha.slice(0, 8) : '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Say what the review is of, above the files.
|
|
142
|
+
*
|
|
143
|
+
* The empty-range case is the reason this exists. `git diff x3 x5` over work
|
|
144
|
+
* that was added and then removed is correctly empty, and a file list showing
|
|
145
|
+
* nothing is indistinguishable from a repository with nothing to review. The
|
|
146
|
+
* commit count stays on screen so that an empty list reads as "9 commits, no
|
|
147
|
+
* net change" rather than as "nothing happened".
|
|
148
|
+
*
|
|
149
|
+
* @param {{mode: string, range: ?{base: string, head: string, commits: number}}} data
|
|
150
|
+
*/
|
|
151
|
+
function renderScope(data) {
|
|
152
|
+
const bar = document.getElementById('scopeBar');
|
|
153
|
+
const label = document.getElementById('scopeLabel');
|
|
154
|
+
const reset = document.getElementById('scopeResetBtn');
|
|
155
|
+
|
|
156
|
+
bar.classList.remove('hidden');
|
|
157
|
+
reset.classList.toggle('hidden', data.mode !== 'range');
|
|
158
|
+
|
|
159
|
+
const select = document.getElementById('compareSelect');
|
|
160
|
+
|
|
161
|
+
if (data.mode === 'range' && data.range) {
|
|
162
|
+
const { base, head, commits } = data.range;
|
|
163
|
+
label.textContent =
|
|
164
|
+
`Comparing ${shortSha(base)} \u2192 ${shortSha(head)} \u2014 ` +
|
|
165
|
+
`${commits} commit${commits === 1 ? '' : 's'}, shown as one combined diff`;
|
|
166
|
+
// Only if the base is one of the offered commits. A range opened by
|
|
167
|
+
// typing `HEAD~5` or a branch name has no matching option, and leaving
|
|
168
|
+
// the picker on its placeholder is honest -- the scope bar above already
|
|
169
|
+
// says exactly what is being compared.
|
|
170
|
+
select.value = [...select.options].some(option => option.value === base) ? base : '';
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
label.textContent = data.mode === 'lastCommit'
|
|
175
|
+
? 'Reviewing the last commit'
|
|
176
|
+
: 'Reviewing uncommitted changes';
|
|
177
|
+
select.value = '';
|
|
178
|
+
document.getElementById('compareBase').value = '';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Fill the commit picker for the open repository.
|
|
183
|
+
*
|
|
184
|
+
* The newest commit is deliberately not offered. "Changes since HEAD" is
|
|
185
|
+
* either nothing or the working tree depending on how you squint, and both
|
|
186
|
+
* of those are already what "Back to latest" gives you -- so listing it
|
|
187
|
+
* would be offering a choice that cannot do anything useful.
|
|
188
|
+
*
|
|
189
|
+
* A failure here is silent on purpose: the text box next to it still works,
|
|
190
|
+
* and a repository whose history will not list is not a reason to refuse to
|
|
191
|
+
* review it.
|
|
192
|
+
*/
|
|
193
|
+
async function loadCommitOptions(repoId) {
|
|
194
|
+
const select = document.getElementById('compareSelect');
|
|
195
|
+
|
|
196
|
+
try {
|
|
197
|
+
const response = await fetch(`${API_BASE}/commits/${repoId}`);
|
|
198
|
+
if (!response.ok) return;
|
|
199
|
+
|
|
200
|
+
const { commits } = await response.json();
|
|
201
|
+
const options = commits.slice(1);
|
|
202
|
+
|
|
203
|
+
select.innerHTML = '';
|
|
204
|
+
if (options.length === 0) {
|
|
205
|
+
select.appendChild(new Option('No earlier commits', ''));
|
|
206
|
+
select.disabled = true;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
select.disabled = false;
|
|
211
|
+
select.appendChild(new Option('Pick a commit…', ''));
|
|
212
|
+
for (const commit of options) {
|
|
213
|
+
// The subject is what a person recognises; the sha is there to
|
|
214
|
+
// disambiguate two commits with the same message, which is common
|
|
215
|
+
// enough in a branch that has been rebased.
|
|
216
|
+
select.appendChild(new Option(`${commit.short} · ${commit.subject} · ${commit.when}`, commit.sha));
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
/* the text box beside it is the fallback */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Compare from whatever was chosen in the picker. */
|
|
224
|
+
function compareFromSelection() {
|
|
225
|
+
const base = document.getElementById('compareSelect').value;
|
|
226
|
+
if (!base) return;
|
|
227
|
+
|
|
228
|
+
document.getElementById('compareBase').value = '';
|
|
229
|
+
loadRepo({ base });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Reload the repository as a range starting at whatever was typed. */
|
|
233
|
+
function compareFrom() {
|
|
234
|
+
const base = document.getElementById('compareBase').value.trim();
|
|
235
|
+
|
|
236
|
+
if (!base) {
|
|
237
|
+
showStatus('Enter a commit, tag or branch to compare from', 'error');
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
loadRepo({ base });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Back to the default scope: uncommitted changes, or the last commit. */
|
|
245
|
+
function resetScope() {
|
|
246
|
+
document.getElementById('compareBase').value = '';
|
|
247
|
+
document.getElementById('compareSelect').value = '';
|
|
248
|
+
loadRepo();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Load a repository, optionally as a range between two commits.
|
|
253
|
+
*
|
|
254
|
+
* @param {{base?: string}} [scope] omit for the default: uncommitted changes,
|
|
255
|
+
* falling back to the last commit on a clean tree.
|
|
256
|
+
*/
|
|
257
|
+
async function loadRepo(scope = {}) {
|
|
136
258
|
const repoPath = document.getElementById('repoPath').value.trim();
|
|
137
259
|
|
|
138
260
|
if (!repoPath) {
|
|
@@ -147,7 +269,7 @@ async function loadRepo() {
|
|
|
147
269
|
const response = await fetch(`${API_BASE}/load-repo`, {
|
|
148
270
|
method: 'POST',
|
|
149
271
|
headers: { 'Content-Type': 'application/json' },
|
|
150
|
-
body: JSON.stringify({ repoPath })
|
|
272
|
+
body: JSON.stringify(scope.base ? { repoPath, base: scope.base } : { repoPath })
|
|
151
273
|
});
|
|
152
274
|
|
|
153
275
|
const data = await response.json();
|
|
@@ -167,7 +289,25 @@ async function loadRepo() {
|
|
|
167
289
|
setRepoButton(data.repoPath);
|
|
168
290
|
}
|
|
169
291
|
|
|
292
|
+
// Before renderScope, which needs the options present to be able to
|
|
293
|
+
// select the current base among them.
|
|
294
|
+
await loadCommitOptions(data.repoId);
|
|
295
|
+
renderScope(data);
|
|
296
|
+
|
|
170
297
|
if (data.files.length === 0) {
|
|
298
|
+
// A range with no files is a real answer, not a failure: every change
|
|
299
|
+
// in it was undone again before the far end. The server says so in
|
|
300
|
+
// `message`, and the scope bar above is still showing how many commits
|
|
301
|
+
// that covers -- so this is information, not an error, and it must not
|
|
302
|
+
// be dressed as one.
|
|
303
|
+
if (data.mode === 'range') {
|
|
304
|
+
showStatus(data.message, 'success');
|
|
305
|
+
hidePicker();
|
|
306
|
+
displayFiles([]);
|
|
307
|
+
document.getElementById('loadBtn').disabled = false;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
171
311
|
showStatus('No uncommitted changes found in the repository', 'error');
|
|
172
312
|
document.getElementById('loadBtn').disabled = false;
|
|
173
313
|
return;
|
|
@@ -1749,6 +1889,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1749
1889
|
}
|
|
1750
1890
|
});
|
|
1751
1891
|
|
|
1892
|
+
document.getElementById('compareBase').addEventListener('keypress', (e) => {
|
|
1893
|
+
if (e.key === 'Enter') {
|
|
1894
|
+
compareFrom();
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1752
1898
|
// Initialize resize handle
|
|
1753
1899
|
initResizeHandle();
|
|
1754
1900
|
|
package/public/index.html
CHANGED
|
@@ -54,6 +54,28 @@
|
|
|
54
54
|
</div>
|
|
55
55
|
</div>
|
|
56
56
|
<div id="status" class="status"></div>
|
|
57
|
+
|
|
58
|
+
<div id="scopeBar" class="scope-bar hidden">
|
|
59
|
+
<span id="scopeLabel" class="scope-label"></span>
|
|
60
|
+
<div class="scope-compare">
|
|
61
|
+
<!-- "since", not "from": `base..HEAD` does not include base's own
|
|
62
|
+
changes, and a picker that says "compare from x1" invites the
|
|
63
|
+
reading that x1 is in the diff. -->
|
|
64
|
+
<label for="compareSelect">Changes since</label>
|
|
65
|
+
<select id="compareSelect" onchange="compareFromSelection()" aria-label="Review everything after this commit">
|
|
66
|
+
<option value="">Pick a commit…</option>
|
|
67
|
+
</select>
|
|
68
|
+
<input
|
|
69
|
+
type="text"
|
|
70
|
+
id="compareBase"
|
|
71
|
+
placeholder="or a branch, tag or ref"
|
|
72
|
+
spellcheck="false"
|
|
73
|
+
autocomplete="off"
|
|
74
|
+
value="">
|
|
75
|
+
<button id="compareBtn" onclick="compareFrom()">Compare</button>
|
|
76
|
+
<button id="scopeResetBtn" class="scope-reset hidden" onclick="resetScope()">Back to latest</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
57
79
|
</header>
|
|
58
80
|
|
|
59
81
|
<div class="main-container">
|
package/public/style.css
CHANGED
|
@@ -1877,3 +1877,90 @@ button:disabled {
|
|
|
1877
1877
|
color: #7a5d00;
|
|
1878
1878
|
text-align: center;
|
|
1879
1879
|
}
|
|
1880
|
+
|
|
1881
|
+
/* What the review is of, and how to change it.
|
|
1882
|
+
*
|
|
1883
|
+
* Its own row under the status line rather than beside the repository
|
|
1884
|
+
* controls: choosing a repository and choosing which of its commits to look
|
|
1885
|
+
* at are two different decisions, and crowding them made the header read as
|
|
1886
|
+
* one long form. */
|
|
1887
|
+
.scope-bar {
|
|
1888
|
+
display: flex;
|
|
1889
|
+
align-items: center;
|
|
1890
|
+
justify-content: space-between;
|
|
1891
|
+
flex-wrap: wrap;
|
|
1892
|
+
gap: 8px 16px;
|
|
1893
|
+
margin-top: 8px;
|
|
1894
|
+
padding: 8px 12px;
|
|
1895
|
+
border-radius: 6px;
|
|
1896
|
+
background: #f3f4f6;
|
|
1897
|
+
border-left: 3px solid #6a737d;
|
|
1898
|
+
font-size: 13px;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
/* The scope reads as a sentence rather than a field label, because the case
|
|
1902
|
+
* that matters most is "9 commits, and no net change" — which has to be
|
|
1903
|
+
* legible as an explanation, not as a value. */
|
|
1904
|
+
.scope-label {
|
|
1905
|
+
color: #24292e;
|
|
1906
|
+
min-width: 0;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
.scope-compare {
|
|
1910
|
+
display: flex;
|
|
1911
|
+
align-items: center;
|
|
1912
|
+
gap: 8px;
|
|
1913
|
+
flex-shrink: 0;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
.scope-compare label {
|
|
1917
|
+
color: #6a737d;
|
|
1918
|
+
white-space: nowrap;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
.scope-compare input {
|
|
1922
|
+
width: 190px;
|
|
1923
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1924
|
+
font-size: 12px;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
.scope-reset {
|
|
1928
|
+
background: transparent;
|
|
1929
|
+
border: 1px solid #d1d5da;
|
|
1930
|
+
color: #24292e;
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
@media (max-width: 720px) {
|
|
1934
|
+
.scope-compare input {
|
|
1935
|
+
width: 120px;
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/* The picker carries the common case; the text box beside it carries
|
|
1940
|
+
* everything else — a branch, a tag, `HEAD~5`, a sha from somewhere else.
|
|
1941
|
+
* Both stay visible rather than one hiding behind a mode, because a control
|
|
1942
|
+
* you cannot see is a feature people believe is missing. */
|
|
1943
|
+
.scope-compare select {
|
|
1944
|
+
max-width: 320px;
|
|
1945
|
+
font-size: 12px;
|
|
1946
|
+
padding: 4px 6px;
|
|
1947
|
+
border: 1px solid #d1d5da;
|
|
1948
|
+
border-radius: 6px;
|
|
1949
|
+
background: #fff;
|
|
1950
|
+
color: #24292e;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
.scope-compare select:disabled {
|
|
1954
|
+
color: #6a737d;
|
|
1955
|
+
background: #f3f4f6;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
@media (max-width: 720px) {
|
|
1959
|
+
.scope-compare {
|
|
1960
|
+
flex-wrap: wrap;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
.scope-compare select {
|
|
1964
|
+
max-width: 100%;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
package/server.js
CHANGED
|
@@ -62,6 +62,12 @@ class FileNotFoundError extends Error {
|
|
|
62
62
|
* leaves before deciding it is not coming back.
|
|
63
63
|
* @returns {import('express').Express}
|
|
64
64
|
*/
|
|
65
|
+
// How far back the range picker offers to go. Long enough to cover the work
|
|
66
|
+
// someone is plausibly still reviewing, short enough that the list is a list
|
|
67
|
+
// rather than the whole history -- anything older is still reachable by
|
|
68
|
+
// typing a ref, which is what the text box is for.
|
|
69
|
+
const COMMIT_LIST_LIMIT = 30;
|
|
70
|
+
|
|
65
71
|
function createApp(options = {}) {
|
|
66
72
|
const {
|
|
67
73
|
reviewsDir = resolveReviewsDir(),
|
|
@@ -121,9 +127,9 @@ function createApp(options = {}) {
|
|
|
121
127
|
const git = gitFactory(session.repoPath);
|
|
122
128
|
const absolutePath = resolveRepoFile(session.repoPath, filePath);
|
|
123
129
|
|
|
124
|
-
if (session.
|
|
130
|
+
if (session.range) {
|
|
125
131
|
try {
|
|
126
|
-
return await git.show([
|
|
132
|
+
return await git.show([`${session.range.head}:${filePath}`]);
|
|
127
133
|
} catch {
|
|
128
134
|
return await fs.readFile(absolutePath, 'utf-8').catch(() => '');
|
|
129
135
|
}
|
|
@@ -198,8 +204,8 @@ function createApp(options = {}) {
|
|
|
198
204
|
}
|
|
199
205
|
|
|
200
206
|
async function diffForFile(git, session, filePath) {
|
|
201
|
-
if (session.
|
|
202
|
-
return git.diff([
|
|
207
|
+
if (session.range) {
|
|
208
|
+
return git.diff([session.range.base, session.range.head, '--', filePath]);
|
|
203
209
|
}
|
|
204
210
|
if (!(await hasCommits(git))) {
|
|
205
211
|
return '';
|
|
@@ -207,6 +213,144 @@ function createApp(options = {}) {
|
|
|
207
213
|
return git.diff(['HEAD', '--', filePath]);
|
|
208
214
|
}
|
|
209
215
|
|
|
216
|
+
/**
|
|
217
|
+
* A ref safe to hand to git as an argument.
|
|
218
|
+
*
|
|
219
|
+
* simple-git passes arguments as an array rather than through a shell, so
|
|
220
|
+
* there is no quoting to get wrong -- but a value beginning with `-` is
|
|
221
|
+
* still read by git as an option rather than as a ref, and
|
|
222
|
+
* `--output=/somewhere` is a real thing to hand `git diff`. Refs do not
|
|
223
|
+
* begin with a dash, so refusing one costs nothing.
|
|
224
|
+
*
|
|
225
|
+
* @param {string} ref
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
function looksLikeRef(ref) {
|
|
229
|
+
return ref.length > 0 && ref.length <= 255 && !ref.startsWith('-');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Turn two refs into the range a review is of.
|
|
234
|
+
*
|
|
235
|
+
* Both are resolved to commit SHAs here, once, and the session carries
|
|
236
|
+
* those rather than the names. A branch that moves under a review in
|
|
237
|
+
* progress would otherwise change what the review is of halfway through,
|
|
238
|
+
* and the comments already written would silently be about a diff that no
|
|
239
|
+
* longer exists.
|
|
240
|
+
*
|
|
241
|
+
* @param {import('simple-git').SimpleGit} git
|
|
242
|
+
* @param {string} base
|
|
243
|
+
* @param {string} head
|
|
244
|
+
* @returns {Promise<import('./lib/sessions').ReviewRange>}
|
|
245
|
+
* @throws {Error} with a message meant for the page, when a ref will not resolve
|
|
246
|
+
*/
|
|
247
|
+
async function resolveRange(git, base, head) {
|
|
248
|
+
for (const [label, ref] of [['base', base], ['head', head]]) {
|
|
249
|
+
if (!looksLikeRef(ref)) {
|
|
250
|
+
throw new Error(`That ${label} does not look like a commit or branch.`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const resolve = async (label, ref) => {
|
|
255
|
+
try {
|
|
256
|
+
return (await git.revparse([`${ref}^{commit}`])).trim();
|
|
257
|
+
} catch {
|
|
258
|
+
throw new Error(`No commit or branch called "${ref}" in this repository.`);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const baseSha = await resolve('base', base);
|
|
263
|
+
const headSha = await resolve('head', head);
|
|
264
|
+
|
|
265
|
+
// `base..head` counts what head has and base does not, which is the
|
|
266
|
+
// number a reviewer means by "how many commits am I looking at". It is
|
|
267
|
+
// not symmetric, and it is not the same as the file count -- a range
|
|
268
|
+
// whose changes cancel out has commits and no files, which is the whole
|
|
269
|
+
// reason this number is reported.
|
|
270
|
+
const commits = Number(
|
|
271
|
+
(await git.raw(['rev-list', '--count', `${baseSha}..${headSha}`])).trim()
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
return { base: baseSha, head: headSha, commits: Number.isFinite(commits) ? commits : 0 };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* What to tell the reviewer about a range they just opened.
|
|
279
|
+
*
|
|
280
|
+
* The empty case is the one that matters. `git diff x3 x5` over a file
|
|
281
|
+
* added in x4 and deleted in x5 is correctly empty, and an interface that
|
|
282
|
+
* renders that as "no changes" is saying something false about two commits
|
|
283
|
+
* of real work. So the commit count leads, and the absence of files is
|
|
284
|
+
* described rather than left as a blank screen.
|
|
285
|
+
*
|
|
286
|
+
* @param {import('./lib/sessions').ReviewRange} range
|
|
287
|
+
* @param {number} fileCount
|
|
288
|
+
* @returns {string}
|
|
289
|
+
*/
|
|
290
|
+
function describeRange(range, fileCount) {
|
|
291
|
+
const commits = `${range.commits} commit${range.commits === 1 ? '' : 's'}`;
|
|
292
|
+
|
|
293
|
+
if (fileCount === 0) {
|
|
294
|
+
return range.commits === 0
|
|
295
|
+
? 'Those two commits are the same, so there is nothing between them.'
|
|
296
|
+
: `${commits}, and no net change between the two ends — every change in them was undone again inside the range.`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return `${commits}, ${fileCount} changed file${fileCount === 1 ? '' : 's'} between the two ends`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Recent commits, so a range can be picked instead of typed.
|
|
304
|
+
*
|
|
305
|
+
* Nobody knows the SHA of the commit they want to review back to, and
|
|
306
|
+
* asking for one is asking the reviewer to go and run `git log` in another
|
|
307
|
+
* window to use a tool whose whole point is not having to.
|
|
308
|
+
*
|
|
309
|
+
* Each entry is what a person recognises a commit by -- the subject line
|
|
310
|
+
* and when it landed -- with the SHA carried alongside rather than being
|
|
311
|
+
* the thing they have to read.
|
|
312
|
+
*
|
|
313
|
+
* `%x00` separates the fields because a commit subject can contain
|
|
314
|
+
* anything a person can type, including whatever delimiter looked safe.
|
|
315
|
+
*
|
|
316
|
+
* @param {import('simple-git').SimpleGit} git
|
|
317
|
+
* @param {number} limit
|
|
318
|
+
*/
|
|
319
|
+
async function recentCommits(git, limit) {
|
|
320
|
+
const raw = await git.raw([
|
|
321
|
+
'log', `--max-count=${limit}`, '--format=%H%x00%h%x00%s%x00%cr'
|
|
322
|
+
]);
|
|
323
|
+
|
|
324
|
+
return raw
|
|
325
|
+
.split('\n')
|
|
326
|
+
.filter(line => line.trim() !== '')
|
|
327
|
+
.map(line => {
|
|
328
|
+
const [sha, short, subject, when] = line.split('\u0000');
|
|
329
|
+
return { sha, short, subject, when };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The commits of an open repository, newest first.
|
|
335
|
+
*
|
|
336
|
+
* Behind a session id like the file endpoints rather than taking a path:
|
|
337
|
+
* this reads history out of a repository, and the session is what says
|
|
338
|
+
* which repository the caller has already been granted.
|
|
339
|
+
*/
|
|
340
|
+
app.get('/api/commits/:repoId', async (req, res) => {
|
|
341
|
+
try {
|
|
342
|
+
const session = requireSession(res, req.params.repoId);
|
|
343
|
+
if (!session) return;
|
|
344
|
+
|
|
345
|
+
const commits = await recentCommits(gitFactory(session.repoPath), COMMIT_LIST_LIMIT);
|
|
346
|
+
res.json({ commits });
|
|
347
|
+
} catch (error) {
|
|
348
|
+
// A repository with no commits is not an error, it is an empty list.
|
|
349
|
+
console.error('Commit list error:', error.message);
|
|
350
|
+
res.json({ commits: [] });
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
210
354
|
/** Liveness probe, and a count of how many repositories are open. */
|
|
211
355
|
app.get('/api/health', (req, res) => {
|
|
212
356
|
res.json({ status: 'ok', sessions: sessions.size });
|
|
@@ -338,6 +482,7 @@ function createApp(options = {}) {
|
|
|
338
482
|
// Resolve to the root of the working tree before anything is keyed on
|
|
339
483
|
// this path. A subdirectory passes `checkIsRepo()` quite happily and
|
|
340
484
|
// then produces a file list whose diffs are all empty; see lib/repo.js.
|
|
485
|
+
const { base, head } = req.body ?? {};
|
|
341
486
|
const root = await repoRoot(repoPath, gitFactory);
|
|
342
487
|
if (!root) {
|
|
343
488
|
return res.status(400).json({ error: 'Not a valid git repository' });
|
|
@@ -345,27 +490,51 @@ function createApp(options = {}) {
|
|
|
345
490
|
|
|
346
491
|
const git = gitFactory(root);
|
|
347
492
|
|
|
348
|
-
let files
|
|
349
|
-
let mode
|
|
350
|
-
let
|
|
493
|
+
let files;
|
|
494
|
+
let mode;
|
|
495
|
+
let range = null;
|
|
496
|
+
let message;
|
|
351
497
|
|
|
352
|
-
|
|
353
|
-
|
|
498
|
+
if (base !== undefined && base !== null && `${base}`.trim() !== '') {
|
|
499
|
+
// An explicit range. Nothing falls back to anything here: someone who
|
|
500
|
+
// asked for x1..x3 and is silently shown their working tree instead
|
|
501
|
+
// has been told a lie about what they are reviewing.
|
|
354
502
|
try {
|
|
355
|
-
|
|
356
|
-
if (commitFiles.length > 0) {
|
|
357
|
-
files = commitFiles;
|
|
358
|
-
mode = 'lastCommit';
|
|
359
|
-
message = `No working directory changes. Loaded last commit with ${files.length} changed file(s)`;
|
|
360
|
-
}
|
|
503
|
+
range = await resolveRange(git, `${base}`.trim(), `${head ?? 'HEAD'}`.trim());
|
|
361
504
|
} catch (error) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
505
|
+
return res.status(400).json({ error: error.message });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
files = collectCommitChanges(await git.diffSummary([range.base, range.head]));
|
|
509
|
+
mode = 'range';
|
|
510
|
+
message = describeRange(range, files.length);
|
|
511
|
+
} else {
|
|
512
|
+
files = collectWorkingChanges(await git.status());
|
|
513
|
+
mode = 'working';
|
|
514
|
+
message = `Found ${files.length} changed file(s)`;
|
|
515
|
+
|
|
516
|
+
// A clean tree has nothing to review, so fall back to the last commit.
|
|
517
|
+
if (files.length === 0) {
|
|
518
|
+
try {
|
|
519
|
+
const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
|
|
520
|
+
const commitFiles = collectCommitChanges(
|
|
521
|
+
await git.diffSummary([lastCommit.base, lastCommit.head])
|
|
522
|
+
);
|
|
523
|
+
if (commitFiles.length > 0) {
|
|
524
|
+
files = commitFiles;
|
|
525
|
+
mode = 'lastCommit';
|
|
526
|
+
range = lastCommit;
|
|
527
|
+
message = `No working directory changes. Loaded last commit with ${files.length} changed file(s)`;
|
|
528
|
+
}
|
|
529
|
+
} catch (error) {
|
|
530
|
+
// A repository with no commits, or only one, has no parent to diff
|
|
531
|
+
// against. An empty file list is the correct answer there.
|
|
532
|
+
console.error('Error loading last commit:', error.message);
|
|
533
|
+
}
|
|
365
534
|
}
|
|
366
535
|
}
|
|
367
536
|
|
|
368
|
-
const repoId = sessions.create(root, mode);
|
|
537
|
+
const repoId = sessions.create(root, mode, range);
|
|
369
538
|
// stderr, not stdout. `reviewer | claude -p ...` puts the finished
|
|
370
539
|
// review on stdout, and anything else written there lands in the middle
|
|
371
540
|
// of it. Progress is for the person watching; stdout is for the pipe.
|
|
@@ -380,7 +549,7 @@ function createApp(options = {}) {
|
|
|
380
549
|
|
|
381
550
|
// `repoPath` in the response is the resolved root, not what was asked
|
|
382
551
|
// for, so the page can show which repository it actually opened.
|
|
383
|
-
res.json({ repoId, files, repoPath: root, mode, message });
|
|
552
|
+
res.json({ repoId, files, repoPath: root, mode, range, message });
|
|
384
553
|
} catch (error) {
|
|
385
554
|
console.error('Load repo error:', error);
|
|
386
555
|
res.status(500).json({ error: error.message });
|
|
@@ -472,6 +641,7 @@ function createApp(options = {}) {
|
|
|
472
641
|
repoPath: session.repoPath,
|
|
473
642
|
lastUpdated: new Date().toISOString(),
|
|
474
643
|
mode: session.mode,
|
|
644
|
+
range: session.range,
|
|
475
645
|
comments: normalized
|
|
476
646
|
};
|
|
477
647
|
|
|
@@ -535,7 +705,8 @@ function createApp(options = {}) {
|
|
|
535
705
|
generatedAt,
|
|
536
706
|
head,
|
|
537
707
|
branch,
|
|
538
|
-
mode: session.mode
|
|
708
|
+
mode: session.mode,
|
|
709
|
+
range: session.range
|
|
539
710
|
});
|
|
540
711
|
const documentFilename = filename.replace(/\.txt$/, '.json');
|
|
541
712
|
|