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 CHANGED
@@ -3,7 +3,7 @@
3
3
  [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
5
  [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
- [![Tests](https://img.shields.io/badge/tests-196-brightgreen.svg)](test/)
6
+ [![Tests](https://img.shields.io/badge/tests-206-brightgreen.svg)](test/)
7
7
  [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
8
 
9
9
  ![Plain grey code lines on the left resolving into colored diff stripes, with threaded comment markers attached in the right margin](docs/banner.jpg)
@@ -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 # 196 tests
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.map(toAgentComment))
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 comments = document.comments.filter(comment => comment.file === file);
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
- // File and line identify a comment uniquely — the UI anchors one comment
102
- // per line — and staying derived keeps ids stable across exports.
103
- id: `${comment.file}:${comment.line}`,
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.1.0",
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 &mdash; 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
- res.json({ filePath, diffLines: parseDiff(diffText, content) });
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 });