git-reviewer 2.1.0 → 2.2.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 +62 -6
- package/package.json +1 -1
- package/public/app.js +15 -2
- package/public/style.css +16 -0
- package/server.js +40 -2
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
|

|
|
@@ -239,7 +239,7 @@ rather than a public issue.
|
|
|
239
239
|
|
|
240
240
|
```bash
|
|
241
241
|
npm install # 3 dependencies, no build step, ~6MB
|
|
242
|
-
npm test #
|
|
242
|
+
npm test # 206 tests
|
|
243
243
|
npm run test:watch
|
|
244
244
|
npm run test:coverage
|
|
245
245
|
```
|
package/lib/agent.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const path = require('path');
|
|
3
4
|
const { groupByFile } = require('./comments');
|
|
4
5
|
const { fence, repoSlug } = require('./review');
|
|
5
6
|
|
|
@@ -66,10 +67,43 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
|
|
|
66
67
|
comments: comments.length,
|
|
67
68
|
files: grouped.length
|
|
68
69
|
},
|
|
69
|
-
comments: grouped.flatMap(([, fileComments]) => fileComments
|
|
70
|
+
comments: assignIds(grouped.flatMap(([, fileComments]) => fileComments))
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Give every comment an id that is unique within the document.
|
|
76
|
+
*
|
|
77
|
+
* `<file>:<line>` was assumed to be unique because "the UI anchors one
|
|
78
|
+
* comment per line". It does not: in a diff, the removed line and the line
|
|
79
|
+
* that replaced it can carry the *same* number, one on each side, and
|
|
80
|
+
* commenting on both is two ordinary clicks. The export then contained two
|
|
81
|
+
* different comments under one id, and a consumer keyed by id — which is
|
|
82
|
+
* what an id is for — silently kept one of them. See #26.
|
|
83
|
+
*
|
|
84
|
+
* The first comment on a line keeps the bare `<file>:<line>`, so ids do not
|
|
85
|
+
* churn for the overwhelmingly common case and stay stable for anything
|
|
86
|
+
* already consuming them. Only a genuine collision gets a suffix.
|
|
87
|
+
*
|
|
88
|
+
* Stable across exports because the order is: `groupByFile` sorts by line
|
|
89
|
+
* with a stable sort, so two comments sharing a line keep the order they
|
|
90
|
+
* were saved in, and saving appends.
|
|
91
|
+
*
|
|
92
|
+
* @param {import('./comments').Comment[]} comments in document order
|
|
93
|
+
* @returns {AgentComment[]}
|
|
94
|
+
*/
|
|
95
|
+
function assignIds(comments) {
|
|
96
|
+
const seen = new Map();
|
|
97
|
+
|
|
98
|
+
return comments.map(comment => {
|
|
99
|
+
const base = `${comment.file}:${comment.line}`;
|
|
100
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
101
|
+
seen.set(base, n);
|
|
102
|
+
return toAgentComment(comment, n === 1 ? base : `${base}#${n}`);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
73
107
|
/**
|
|
74
108
|
* Select comments from an already-built document and keep its summary honest.
|
|
75
109
|
*
|
|
@@ -81,7 +115,8 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
|
|
|
81
115
|
function filterReviewDocument(document, { file = null } = {}) {
|
|
82
116
|
if (file === null) return document;
|
|
83
117
|
|
|
84
|
-
const
|
|
118
|
+
const wanted = normalizeReviewPath(file, document.repository.path);
|
|
119
|
+
const comments = document.comments.filter(comment => comment.file === wanted);
|
|
85
120
|
return {
|
|
86
121
|
...document,
|
|
87
122
|
summary: {
|
|
@@ -92,15 +127,36 @@ function filterReviewDocument(document, { file = null } = {}) {
|
|
|
92
127
|
};
|
|
93
128
|
}
|
|
94
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Turn a `--file` argument into the repo-relative, forward-slash form comments
|
|
132
|
+
* are stored in.
|
|
133
|
+
*
|
|
134
|
+
* Shells hand paths over as `./src/auth.js` on tab-completion, scripts often
|
|
135
|
+
* pass absolute paths, and Windows users type backslashes. Matching literally
|
|
136
|
+
* made every one of those miss and fail with the same error as a file that
|
|
137
|
+
* genuinely has no comments.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} file
|
|
140
|
+
* @param {string} repoPath
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function normalizeReviewPath(file, repoPath) {
|
|
144
|
+
let candidate = file;
|
|
145
|
+
if (path.isAbsolute(candidate)) candidate = path.relative(repoPath, candidate);
|
|
146
|
+
|
|
147
|
+
const normalized = path.posix.normalize(candidate.replace(/\\/g, '/'));
|
|
148
|
+
return normalized.replace(/^(\.\/)+/, '').replace(/\/$/, '');
|
|
149
|
+
}
|
|
150
|
+
|
|
95
151
|
/**
|
|
96
152
|
* @param {import('./comments').Comment} comment
|
|
97
153
|
* @returns {AgentComment}
|
|
98
154
|
*/
|
|
99
|
-
function toAgentComment(comment) {
|
|
155
|
+
function toAgentComment(comment, id) {
|
|
100
156
|
const entry = {
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
id
|
|
157
|
+
// Derived from file and line so it stays stable across exports, with a
|
|
158
|
+
// suffix where that is not enough on its own. See `assignIds`.
|
|
159
|
+
id,
|
|
104
160
|
file: comment.file,
|
|
105
161
|
line: comment.line,
|
|
106
162
|
anchor: comment.lineContent ?? null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-reviewer",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Review your coding agent's changes like a pull request \u2014 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
|
@@ -296,7 +296,7 @@ async function loadFile(filePath, index) {
|
|
|
296
296
|
updateCommentsSidebar();
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
displayCode(data.filePath, data.diffLines);
|
|
299
|
+
displayCode(data.filePath, data.diffLines, data.binary);
|
|
300
300
|
updateFullContextButton();
|
|
301
301
|
return Promise.resolve();
|
|
302
302
|
|
|
@@ -307,7 +307,7 @@ async function loadFile(filePath, index) {
|
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
// Display code with diff highlighting
|
|
310
|
-
function displayCode(filePath, diffLines) {
|
|
310
|
+
function displayCode(filePath, diffLines, binary) {
|
|
311
311
|
const codeSection = document.getElementById('codeSection');
|
|
312
312
|
const currentFileEl = document.getElementById('currentFile');
|
|
313
313
|
const codeViewer = document.getElementById('codeViewer');
|
|
@@ -315,6 +315,19 @@ function displayCode(filePath, diffLines) {
|
|
|
315
315
|
currentFileEl.textContent = filePath;
|
|
316
316
|
currentDiffLines = diffLines; // Store for reference when adding comments
|
|
317
317
|
|
|
318
|
+
// A binary file has no lines to show and none to comment on. Saying so is
|
|
319
|
+
// the whole feature: it used to be decoded as UTF-8 and rendered as
|
|
320
|
+
// mojibake, and a comment left on one of those lines exported an anchor
|
|
321
|
+
// that could never match the file again.
|
|
322
|
+
if (binary) {
|
|
323
|
+
codeSection.style.display = 'block';
|
|
324
|
+
codeViewer.innerHTML =
|
|
325
|
+
'<div class="binary-notice">Binary file — not shown.' +
|
|
326
|
+
'<span>git reports this file as binary, so there are no lines to ' +
|
|
327
|
+
'review. Comments need a line to anchor to.</span></div>';
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
318
331
|
const fileComments = comments.filter(c => c.file === filePath);
|
|
319
332
|
|
|
320
333
|
let html = '';
|
package/public/style.css
CHANGED
|
@@ -988,3 +988,19 @@ button:disabled {
|
|
|
988
988
|
color: #24292e;
|
|
989
989
|
border: 1px solid #e1e4e8;
|
|
990
990
|
}
|
|
991
|
+
|
|
992
|
+
/* A binary file has no lines to render; say so rather than showing a
|
|
993
|
+
lossy UTF-8 decode of its bytes. See #23. */
|
|
994
|
+
.binary-notice {
|
|
995
|
+
padding: 28px 24px;
|
|
996
|
+
color: #57606a;
|
|
997
|
+
font-size: 14px;
|
|
998
|
+
line-height: 1.6;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
.binary-notice span {
|
|
1002
|
+
display: block;
|
|
1003
|
+
margin-top: 6px;
|
|
1004
|
+
font-size: 13px;
|
|
1005
|
+
opacity: 0.8;
|
|
1006
|
+
}
|
package/server.js
CHANGED
|
@@ -149,6 +149,29 @@ function createApp(options = {}) {
|
|
|
149
149
|
* @param {string} filePath
|
|
150
150
|
* @returns {Promise<string>}
|
|
151
151
|
*/
|
|
152
|
+
/**
|
|
153
|
+
* Whether git considers this file binary.
|
|
154
|
+
*
|
|
155
|
+
* Asking git rather than sniffing the bytes ourselves, because git's
|
|
156
|
+
* answer is the one that produced the diff we are about to render, and a
|
|
157
|
+
* second opinion that disagreed would be worse than no opinion. It says
|
|
158
|
+
* so in the diff itself: `Binary files a/x and b/x differ` for the
|
|
159
|
+
* default format, `GIT binary patch` when binary patches are enabled.
|
|
160
|
+
*
|
|
161
|
+
* This matters beyond the mojibake. `lib/changes.js` already classifies
|
|
162
|
+
* these as `B` and the sidebar renders that letter, but the file endpoint
|
|
163
|
+
* never consulted it -- so a 2 KB PNG came back as ten `add` lines that
|
|
164
|
+
* were ~45% U+FFFD, and a comment left on one of them exported an
|
|
165
|
+
* `anchor` that can never match the file. docs/agent-format.md tells a
|
|
166
|
+
* consumer to locate comments by that anchor.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} diffText
|
|
169
|
+
* @returns {boolean}
|
|
170
|
+
*/
|
|
171
|
+
function isBinaryDiff(diffText) {
|
|
172
|
+
return /^Binary files .* differ$/m.test(diffText) || /^GIT binary patch$/m.test(diffText);
|
|
173
|
+
}
|
|
174
|
+
|
|
152
175
|
async function diffForFile(git, session, filePath) {
|
|
153
176
|
if (session.mode === 'lastCommit') {
|
|
154
177
|
return git.diff(['HEAD~1', 'HEAD', '--', filePath]);
|
|
@@ -221,11 +244,26 @@ function createApp(options = {}) {
|
|
|
221
244
|
const session = requireSession(res, repoId);
|
|
222
245
|
if (!session) return;
|
|
223
246
|
|
|
247
|
+
// Confine the path first, explicitly. It used to be confined as a
|
|
248
|
+
// side effect of `readFileForMode` calling `resolveRepoFile`, so
|
|
249
|
+
// moving the read below the binary check silently turned a 400 into a
|
|
250
|
+
// 500 and let the traversal reach git. A containment check that only
|
|
251
|
+
// works because of statement order is not a containment check.
|
|
252
|
+
resolveRepoFile(session.repoPath, filePath);
|
|
253
|
+
|
|
224
254
|
const git = gitFactory(session.repoPath);
|
|
225
|
-
const content = await readFileForMode(session, filePath);
|
|
226
255
|
const diffText = await diffForFile(git, session, filePath);
|
|
227
256
|
|
|
228
|
-
|
|
257
|
+
// Answered before the file is read: decoding a PNG as UTF-8 to then
|
|
258
|
+
// throw the result away is how the mojibake got served in the first
|
|
259
|
+
// place.
|
|
260
|
+
if (isBinaryDiff(diffText)) {
|
|
261
|
+
return res.json({ filePath, binary: true, diffLines: [] });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const content = await readFileForMode(session, filePath);
|
|
265
|
+
|
|
266
|
+
res.json({ filePath, binary: false, diffLines: parseDiff(diffText, content) });
|
|
229
267
|
} catch (error) {
|
|
230
268
|
if (error instanceof PathEscapeError) {
|
|
231
269
|
return res.status(400).json({ error: error.message });
|