git-reviewer 2.9.1 → 2.10.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 +2 -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 +86 -2
- package/public/index.html +16 -0
- package/public/style.css +58 -0
- package/server.js +134 -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
|
|
@@ -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 # 310 tests
|
|
298
298
|
npm run test:watch
|
|
299
299
|
npm run test:coverage
|
|
300
300
|
```
|
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.10.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,70 @@ 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
|
+
if (data.mode === 'range' && data.range) {
|
|
160
|
+
const { base, head, commits } = data.range;
|
|
161
|
+
label.textContent =
|
|
162
|
+
`Comparing ${shortSha(base)} \u2192 ${shortSha(head)} \u2014 ` +
|
|
163
|
+
`${commits} commit${commits === 1 ? '' : 's'}, shown as one combined diff`;
|
|
164
|
+
document.getElementById('compareBase').value = shortSha(base);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
label.textContent = data.mode === 'lastCommit'
|
|
169
|
+
? 'Reviewing the last commit'
|
|
170
|
+
: 'Reviewing uncommitted changes';
|
|
171
|
+
document.getElementById('compareBase').value = '';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Reload the repository as a range starting at whatever was typed. */
|
|
175
|
+
function compareFrom() {
|
|
176
|
+
const base = document.getElementById('compareBase').value.trim();
|
|
177
|
+
|
|
178
|
+
if (!base) {
|
|
179
|
+
showStatus('Enter a commit, tag or branch to compare from', 'error');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
loadRepo({ base });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Back to the default scope: uncommitted changes, or the last commit. */
|
|
187
|
+
function resetScope() {
|
|
188
|
+
document.getElementById('compareBase').value = '';
|
|
189
|
+
loadRepo();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Load a repository, optionally as a range between two commits.
|
|
194
|
+
*
|
|
195
|
+
* @param {{base?: string}} [scope] omit for the default: uncommitted changes,
|
|
196
|
+
* falling back to the last commit on a clean tree.
|
|
197
|
+
*/
|
|
198
|
+
async function loadRepo(scope = {}) {
|
|
136
199
|
const repoPath = document.getElementById('repoPath').value.trim();
|
|
137
200
|
|
|
138
201
|
if (!repoPath) {
|
|
@@ -147,7 +210,7 @@ async function loadRepo() {
|
|
|
147
210
|
const response = await fetch(`${API_BASE}/load-repo`, {
|
|
148
211
|
method: 'POST',
|
|
149
212
|
headers: { 'Content-Type': 'application/json' },
|
|
150
|
-
body: JSON.stringify({ repoPath })
|
|
213
|
+
body: JSON.stringify(scope.base ? { repoPath, base: scope.base } : { repoPath })
|
|
151
214
|
});
|
|
152
215
|
|
|
153
216
|
const data = await response.json();
|
|
@@ -167,7 +230,22 @@ async function loadRepo() {
|
|
|
167
230
|
setRepoButton(data.repoPath);
|
|
168
231
|
}
|
|
169
232
|
|
|
233
|
+
renderScope(data);
|
|
234
|
+
|
|
170
235
|
if (data.files.length === 0) {
|
|
236
|
+
// A range with no files is a real answer, not a failure: every change
|
|
237
|
+
// in it was undone again before the far end. The server says so in
|
|
238
|
+
// `message`, and the scope bar above is still showing how many commits
|
|
239
|
+
// that covers -- so this is information, not an error, and it must not
|
|
240
|
+
// be dressed as one.
|
|
241
|
+
if (data.mode === 'range') {
|
|
242
|
+
showStatus(data.message, 'success');
|
|
243
|
+
hidePicker();
|
|
244
|
+
displayFiles([]);
|
|
245
|
+
document.getElementById('loadBtn').disabled = false;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
171
249
|
showStatus('No uncommitted changes found in the repository', 'error');
|
|
172
250
|
document.getElementById('loadBtn').disabled = false;
|
|
173
251
|
return;
|
|
@@ -1749,6 +1827,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
1749
1827
|
}
|
|
1750
1828
|
});
|
|
1751
1829
|
|
|
1830
|
+
document.getElementById('compareBase').addEventListener('keypress', (e) => {
|
|
1831
|
+
if (e.key === 'Enter') {
|
|
1832
|
+
compareFrom();
|
|
1833
|
+
}
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1752
1836
|
// Initialize resize handle
|
|
1753
1837
|
initResizeHandle();
|
|
1754
1838
|
|
package/public/index.html
CHANGED
|
@@ -54,6 +54,22 @@
|
|
|
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
|
+
<label for="compareBase">Compare from</label>
|
|
62
|
+
<input
|
|
63
|
+
type="text"
|
|
64
|
+
id="compareBase"
|
|
65
|
+
placeholder="commit, tag or branch"
|
|
66
|
+
spellcheck="false"
|
|
67
|
+
autocomplete="off"
|
|
68
|
+
value="">
|
|
69
|
+
<button id="compareBtn" onclick="compareFrom()">Compare</button>
|
|
70
|
+
<button id="scopeResetBtn" class="scope-reset hidden" onclick="resetScope()">Back to latest</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
57
73
|
</header>
|
|
58
74
|
|
|
59
75
|
<div class="main-container">
|
package/public/style.css
CHANGED
|
@@ -1877,3 +1877,61 @@ 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
|
+
}
|
package/server.js
CHANGED
|
@@ -121,9 +121,9 @@ function createApp(options = {}) {
|
|
|
121
121
|
const git = gitFactory(session.repoPath);
|
|
122
122
|
const absolutePath = resolveRepoFile(session.repoPath, filePath);
|
|
123
123
|
|
|
124
|
-
if (session.
|
|
124
|
+
if (session.range) {
|
|
125
125
|
try {
|
|
126
|
-
return await git.show([
|
|
126
|
+
return await git.show([`${session.range.head}:${filePath}`]);
|
|
127
127
|
} catch {
|
|
128
128
|
return await fs.readFile(absolutePath, 'utf-8').catch(() => '');
|
|
129
129
|
}
|
|
@@ -198,8 +198,8 @@ function createApp(options = {}) {
|
|
|
198
198
|
}
|
|
199
199
|
|
|
200
200
|
async function diffForFile(git, session, filePath) {
|
|
201
|
-
if (session.
|
|
202
|
-
return git.diff([
|
|
201
|
+
if (session.range) {
|
|
202
|
+
return git.diff([session.range.base, session.range.head, '--', filePath]);
|
|
203
203
|
}
|
|
204
204
|
if (!(await hasCommits(git))) {
|
|
205
205
|
return '';
|
|
@@ -207,6 +207,92 @@ function createApp(options = {}) {
|
|
|
207
207
|
return git.diff(['HEAD', '--', filePath]);
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/**
|
|
211
|
+
* A ref safe to hand to git as an argument.
|
|
212
|
+
*
|
|
213
|
+
* simple-git passes arguments as an array rather than through a shell, so
|
|
214
|
+
* there is no quoting to get wrong -- but a value beginning with `-` is
|
|
215
|
+
* still read by git as an option rather than as a ref, and
|
|
216
|
+
* `--output=/somewhere` is a real thing to hand `git diff`. Refs do not
|
|
217
|
+
* begin with a dash, so refusing one costs nothing.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} ref
|
|
220
|
+
* @returns {boolean}
|
|
221
|
+
*/
|
|
222
|
+
function looksLikeRef(ref) {
|
|
223
|
+
return ref.length > 0 && ref.length <= 255 && !ref.startsWith('-');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Turn two refs into the range a review is of.
|
|
228
|
+
*
|
|
229
|
+
* Both are resolved to commit SHAs here, once, and the session carries
|
|
230
|
+
* those rather than the names. A branch that moves under a review in
|
|
231
|
+
* progress would otherwise change what the review is of halfway through,
|
|
232
|
+
* and the comments already written would silently be about a diff that no
|
|
233
|
+
* longer exists.
|
|
234
|
+
*
|
|
235
|
+
* @param {import('simple-git').SimpleGit} git
|
|
236
|
+
* @param {string} base
|
|
237
|
+
* @param {string} head
|
|
238
|
+
* @returns {Promise<import('./lib/sessions').ReviewRange>}
|
|
239
|
+
* @throws {Error} with a message meant for the page, when a ref will not resolve
|
|
240
|
+
*/
|
|
241
|
+
async function resolveRange(git, base, head) {
|
|
242
|
+
for (const [label, ref] of [['base', base], ['head', head]]) {
|
|
243
|
+
if (!looksLikeRef(ref)) {
|
|
244
|
+
throw new Error(`That ${label} does not look like a commit or branch.`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const resolve = async (label, ref) => {
|
|
249
|
+
try {
|
|
250
|
+
return (await git.revparse([`${ref}^{commit}`])).trim();
|
|
251
|
+
} catch {
|
|
252
|
+
throw new Error(`No commit or branch called "${ref}" in this repository.`);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const baseSha = await resolve('base', base);
|
|
257
|
+
const headSha = await resolve('head', head);
|
|
258
|
+
|
|
259
|
+
// `base..head` counts what head has and base does not, which is the
|
|
260
|
+
// number a reviewer means by "how many commits am I looking at". It is
|
|
261
|
+
// not symmetric, and it is not the same as the file count -- a range
|
|
262
|
+
// whose changes cancel out has commits and no files, which is the whole
|
|
263
|
+
// reason this number is reported.
|
|
264
|
+
const commits = Number(
|
|
265
|
+
(await git.raw(['rev-list', '--count', `${baseSha}..${headSha}`])).trim()
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
return { base: baseSha, head: headSha, commits: Number.isFinite(commits) ? commits : 0 };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* What to tell the reviewer about a range they just opened.
|
|
273
|
+
*
|
|
274
|
+
* The empty case is the one that matters. `git diff x3 x5` over a file
|
|
275
|
+
* added in x4 and deleted in x5 is correctly empty, and an interface that
|
|
276
|
+
* renders that as "no changes" is saying something false about two commits
|
|
277
|
+
* of real work. So the commit count leads, and the absence of files is
|
|
278
|
+
* described rather than left as a blank screen.
|
|
279
|
+
*
|
|
280
|
+
* @param {import('./lib/sessions').ReviewRange} range
|
|
281
|
+
* @param {number} fileCount
|
|
282
|
+
* @returns {string}
|
|
283
|
+
*/
|
|
284
|
+
function describeRange(range, fileCount) {
|
|
285
|
+
const commits = `${range.commits} commit${range.commits === 1 ? '' : 's'}`;
|
|
286
|
+
|
|
287
|
+
if (fileCount === 0) {
|
|
288
|
+
return range.commits === 0
|
|
289
|
+
? 'Those two commits are the same, so there is nothing between them.'
|
|
290
|
+
: `${commits}, and no net change between the two ends — every change in them was undone again inside the range.`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return `${commits}, ${fileCount} changed file${fileCount === 1 ? '' : 's'} between the two ends`;
|
|
294
|
+
}
|
|
295
|
+
|
|
210
296
|
/** Liveness probe, and a count of how many repositories are open. */
|
|
211
297
|
app.get('/api/health', (req, res) => {
|
|
212
298
|
res.json({ status: 'ok', sessions: sessions.size });
|
|
@@ -338,6 +424,7 @@ function createApp(options = {}) {
|
|
|
338
424
|
// Resolve to the root of the working tree before anything is keyed on
|
|
339
425
|
// this path. A subdirectory passes `checkIsRepo()` quite happily and
|
|
340
426
|
// then produces a file list whose diffs are all empty; see lib/repo.js.
|
|
427
|
+
const { base, head } = req.body ?? {};
|
|
341
428
|
const root = await repoRoot(repoPath, gitFactory);
|
|
342
429
|
if (!root) {
|
|
343
430
|
return res.status(400).json({ error: 'Not a valid git repository' });
|
|
@@ -345,27 +432,51 @@ function createApp(options = {}) {
|
|
|
345
432
|
|
|
346
433
|
const git = gitFactory(root);
|
|
347
434
|
|
|
348
|
-
let files
|
|
349
|
-
let mode
|
|
350
|
-
let
|
|
435
|
+
let files;
|
|
436
|
+
let mode;
|
|
437
|
+
let range = null;
|
|
438
|
+
let message;
|
|
351
439
|
|
|
352
|
-
|
|
353
|
-
|
|
440
|
+
if (base !== undefined && base !== null && `${base}`.trim() !== '') {
|
|
441
|
+
// An explicit range. Nothing falls back to anything here: someone who
|
|
442
|
+
// asked for x1..x3 and is silently shown their working tree instead
|
|
443
|
+
// has been told a lie about what they are reviewing.
|
|
354
444
|
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
|
-
}
|
|
445
|
+
range = await resolveRange(git, `${base}`.trim(), `${head ?? 'HEAD'}`.trim());
|
|
361
446
|
} catch (error) {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
447
|
+
return res.status(400).json({ error: error.message });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
files = collectCommitChanges(await git.diffSummary([range.base, range.head]));
|
|
451
|
+
mode = 'range';
|
|
452
|
+
message = describeRange(range, files.length);
|
|
453
|
+
} else {
|
|
454
|
+
files = collectWorkingChanges(await git.status());
|
|
455
|
+
mode = 'working';
|
|
456
|
+
message = `Found ${files.length} changed file(s)`;
|
|
457
|
+
|
|
458
|
+
// A clean tree has nothing to review, so fall back to the last commit.
|
|
459
|
+
if (files.length === 0) {
|
|
460
|
+
try {
|
|
461
|
+
const lastCommit = await resolveRange(git, 'HEAD~1', 'HEAD');
|
|
462
|
+
const commitFiles = collectCommitChanges(
|
|
463
|
+
await git.diffSummary([lastCommit.base, lastCommit.head])
|
|
464
|
+
);
|
|
465
|
+
if (commitFiles.length > 0) {
|
|
466
|
+
files = commitFiles;
|
|
467
|
+
mode = 'lastCommit';
|
|
468
|
+
range = lastCommit;
|
|
469
|
+
message = `No working directory changes. Loaded last commit with ${files.length} changed file(s)`;
|
|
470
|
+
}
|
|
471
|
+
} catch (error) {
|
|
472
|
+
// A repository with no commits, or only one, has no parent to diff
|
|
473
|
+
// against. An empty file list is the correct answer there.
|
|
474
|
+
console.error('Error loading last commit:', error.message);
|
|
475
|
+
}
|
|
365
476
|
}
|
|
366
477
|
}
|
|
367
478
|
|
|
368
|
-
const repoId = sessions.create(root, mode);
|
|
479
|
+
const repoId = sessions.create(root, mode, range);
|
|
369
480
|
// stderr, not stdout. `reviewer | claude -p ...` puts the finished
|
|
370
481
|
// review on stdout, and anything else written there lands in the middle
|
|
371
482
|
// of it. Progress is for the person watching; stdout is for the pipe.
|
|
@@ -380,7 +491,7 @@ function createApp(options = {}) {
|
|
|
380
491
|
|
|
381
492
|
// `repoPath` in the response is the resolved root, not what was asked
|
|
382
493
|
// for, so the page can show which repository it actually opened.
|
|
383
|
-
res.json({ repoId, files, repoPath: root, mode, message });
|
|
494
|
+
res.json({ repoId, files, repoPath: root, mode, range, message });
|
|
384
495
|
} catch (error) {
|
|
385
496
|
console.error('Load repo error:', error);
|
|
386
497
|
res.status(500).json({ error: error.message });
|
|
@@ -472,6 +583,7 @@ function createApp(options = {}) {
|
|
|
472
583
|
repoPath: session.repoPath,
|
|
473
584
|
lastUpdated: new Date().toISOString(),
|
|
474
585
|
mode: session.mode,
|
|
586
|
+
range: session.range,
|
|
475
587
|
comments: normalized
|
|
476
588
|
};
|
|
477
589
|
|
|
@@ -535,7 +647,8 @@ function createApp(options = {}) {
|
|
|
535
647
|
generatedAt,
|
|
536
648
|
head,
|
|
537
649
|
branch,
|
|
538
|
-
mode: session.mode
|
|
650
|
+
mode: session.mode,
|
|
651
|
+
range: session.range
|
|
539
652
|
});
|
|
540
653
|
const documentFilename = filename.replace(/\.txt$/, '.json');
|
|
541
654
|
|