git-reviewer 2.0.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/lib/review.js ADDED
@@ -0,0 +1,206 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const { groupByFile } = require('./comments');
8
+
9
+ /**
10
+ * Rendering a set of comments into the Markdown review file, and naming the
11
+ * files that hold a repository's review state.
12
+ */
13
+
14
+ /**
15
+ * Make a repository name safe to embed in a filename.
16
+ *
17
+ * A repository directory can be named anything the filesystem allows, and that
18
+ * name goes straight into a filename under `reviews/`. Anything outside a
19
+ * conservative set becomes `_` so the result stays a single, predictable path
20
+ * segment.
21
+ *
22
+ * @param {string} repoPath
23
+ * @returns {string}
24
+ */
25
+ function repoSlug(repoPath) {
26
+ const base = path.basename(String(repoPath ?? '').replace(/[/\\]+$/, ''));
27
+ const slug = base.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '');
28
+ return slug === '' ? 'repo' : slug;
29
+ }
30
+
31
+ /**
32
+ * Resolve a repository path to the one canonical form that identifies it.
33
+ *
34
+ * Two paths that name the same repository must produce the same answer, or the
35
+ * same review ends up under two names: `/work/api`, `/work/api/`, and a symlink
36
+ * pointing at either are one repository, not three. `realpath` is what settles
37
+ * the symlink case; it is allowed to fail, because naming a directory that does
38
+ * not exist yet is not an error here.
39
+ *
40
+ * @param {string} repoPath
41
+ * @returns {string}
42
+ */
43
+ function canonicalRepoPath(repoPath) {
44
+ const resolved = path.resolve(String(repoPath ?? ''));
45
+ try {
46
+ return fs.realpathSync(resolved);
47
+ } catch {
48
+ return resolved;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Identify a repository's review state uniquely.
54
+ *
55
+ * The readable slug alone is only the directory's basename, so two checkouts
56
+ * both called `api-service` name the same file and the second review silently
57
+ * destroys the first. The suffix is derived from the full canonical path, which
58
+ * is what actually distinguishes them. The slug is kept in front so the
59
+ * directory stays browsable by a human.
60
+ *
61
+ * @param {string} repoPath
62
+ * @returns {string}
63
+ */
64
+ function repoKey(repoPath) {
65
+ // Both halves come from the canonical path. Taking the readable half from
66
+ // the path as written would undo the work: a symlink named `api` pointing at
67
+ // a checkout named `api-service` would agree on the fingerprint and still
68
+ // land in a different file.
69
+ const canonical = canonicalRepoPath(repoPath);
70
+ const fingerprint = crypto.createHash('sha256').update(canonical).digest('hex').slice(0, 12);
71
+ return `${repoSlug(canonical)}-${fingerprint}`;
72
+ }
73
+
74
+ /**
75
+ * Name of the JSON file holding a repository's saved comments.
76
+ *
77
+ * This file is the source of truth for a review: the UI writes it on every
78
+ * edit and the submitted `.txt` is rendered from it.
79
+ *
80
+ * @param {string} repoPath
81
+ * @returns {string}
82
+ */
83
+ function commentsFilename(repoPath) {
84
+ return `.code-review-comments-${repoKey(repoPath)}.json`;
85
+ }
86
+
87
+ /**
88
+ * The pre-collision-fix name for a repository's saved comments.
89
+ *
90
+ * Reviews written before the path fingerprint existed live under this name.
91
+ * Nothing writes it any more; {@link module:lib/store} reads it once so an
92
+ * existing review is adopted rather than abandoned.
93
+ *
94
+ * @param {string} repoPath
95
+ * @returns {string}
96
+ */
97
+ function legacyCommentsFilename(repoPath) {
98
+ return `.code-review-comments-${repoSlug(repoPath)}.json`;
99
+ }
100
+
101
+ /**
102
+ * Name of a submitted review file.
103
+ *
104
+ * The timestamp is derived from the ISO form so the names sort chronologically
105
+ * and contain no characters that need quoting.
106
+ *
107
+ * @param {string} repoPath
108
+ * @param {Date} generatedAt
109
+ * @returns {string}
110
+ */
111
+ function reviewFilename(repoPath, generatedAt) {
112
+ const stamp = generatedAt.toISOString().replace(/[:.]/g, '-').replace('T', '_');
113
+ return `review_${repoKey(repoPath)}_${stamp}.txt`;
114
+ }
115
+
116
+ /**
117
+ * Render comments as the Markdown body of a review.
118
+ *
119
+ * Timestamps are written in ISO 8601 rather than a locale format: a review is
120
+ * a file that gets committed, pasted into a pull request, and read on another
121
+ * machine, so it must not change shape with the reader's locale or timezone.
122
+ *
123
+ * @param {object} args
124
+ * @param {string} args.repoPath repository the review covers
125
+ * @param {import('./comments').Comment[]} args.comments
126
+ * @param {Date} args.generatedAt
127
+ * @returns {string}
128
+ */
129
+ function formatReview({ repoPath, comments, generatedAt }) {
130
+ const sections = [
131
+ '# Code Review\n',
132
+ `Repository: ${repoPath}`,
133
+ `Generated: ${generatedAt.toISOString()}`,
134
+ `Total Comments: ${comments.length}\n`
135
+ ];
136
+
137
+ for (const [file, fileComments] of groupByFile(comments)) {
138
+ sections.push(`## ${file}\n`);
139
+ for (const comment of fileComments) {
140
+ sections.push(formatComment(comment));
141
+ }
142
+ }
143
+
144
+ return `${sections.join('\n')}`;
145
+ }
146
+
147
+ /**
148
+ * @param {import('./comments').Comment} comment
149
+ * @returns {string}
150
+ */
151
+ function formatComment(comment) {
152
+ const parts = [`**Line ${comment.line}:**`];
153
+
154
+ if (comment.lineContent) {
155
+ parts.push(fence(comment.lineContent));
156
+ }
157
+
158
+ if (comment.selectedText) {
159
+ parts.push('**Selected code:**', fence(comment.selectedText));
160
+ }
161
+
162
+ parts.push(comment.text);
163
+
164
+ if (comment.followUps?.length > 0) {
165
+ parts.push('', '**Follow-ups:**');
166
+ comment.followUps.forEach((followUp, index) => {
167
+ const timestamp = formatTimestamp(followUp.timestamp);
168
+ parts.push(` ${index + 1}. ${followUp.text}${timestamp ? ` (${timestamp})` : ''}`);
169
+ });
170
+ }
171
+
172
+ return `${parts.join('\n')}\n`;
173
+ }
174
+
175
+ /**
176
+ * Wrap text in a fenced code block, widening the fence if the text itself
177
+ * contains one.
178
+ *
179
+ * @param {string} text
180
+ * @returns {string}
181
+ */
182
+ function fence(text) {
183
+ const longest = String(text).match(/^`{3,}/gm)?.reduce((a, b) => (b.length > a.length ? b : a), '') ?? '';
184
+ const marker = '`'.repeat(Math.max(3, longest.length + 1));
185
+ return `${marker}\n${text}\n${marker}`;
186
+ }
187
+
188
+ /**
189
+ * @param {string|undefined} timestamp
190
+ * @returns {string} ISO 8601 form, or '' when absent or unparseable
191
+ */
192
+ function formatTimestamp(timestamp) {
193
+ if (!timestamp) return '';
194
+ const date = new Date(timestamp);
195
+ return Number.isNaN(date.getTime()) ? '' : date.toISOString();
196
+ }
197
+
198
+ module.exports = {
199
+ formatReview,
200
+ reviewFilename,
201
+ commentsFilename,
202
+ legacyCommentsFilename,
203
+ repoSlug,
204
+ repoKey,
205
+ canonicalRepoPath
206
+ };
@@ -0,0 +1,62 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ /**
6
+ * In-memory record of which repository each browser session is reviewing.
7
+ *
8
+ * Sessions live only as long as the process: the desktop app starts a fresh
9
+ * server per launch, and the web mode is a local single-user tool. Nothing
10
+ * here is persisted, so a restart simply asks the user to reopen the repo.
11
+ */
12
+
13
+ /**
14
+ * @typedef {'working'|'lastCommit'} ReviewMode
15
+ * `working` diffs the working tree against HEAD; `lastCommit` diffs HEAD
16
+ * against its parent, which is what a clean tree falls back to.
17
+ */
18
+
19
+ class SessionStore {
20
+ /**
21
+ * @param {() => string} [generateId] injectable for deterministic tests
22
+ */
23
+ constructor(generateId = () => crypto.randomBytes(8).toString('hex')) {
24
+ this.generateId = generateId;
25
+ /** @type {Map<string, {repoPath: string, mode: ReviewMode}>} */
26
+ this.sessions = new Map();
27
+ }
28
+
29
+ /**
30
+ * @param {string} repoPath absolute path of the opened repository
31
+ * @param {ReviewMode} mode
32
+ * @returns {string} the new session id
33
+ */
34
+ create(repoPath, mode) {
35
+ const repoId = this.generateId();
36
+ this.sessions.set(repoId, { repoPath, mode });
37
+ return repoId;
38
+ }
39
+
40
+ /**
41
+ * @param {string} repoId
42
+ * @returns {{repoPath: string, mode: ReviewMode}|undefined}
43
+ */
44
+ get(repoId) {
45
+ return this.sessions.get(repoId);
46
+ }
47
+
48
+ /**
49
+ * @param {string} repoId
50
+ * @returns {boolean} whether a session was removed
51
+ */
52
+ delete(repoId) {
53
+ return this.sessions.delete(repoId);
54
+ }
55
+
56
+ /** @returns {number} */
57
+ get size() {
58
+ return this.sessions.size;
59
+ }
60
+ }
61
+
62
+ module.exports = { SessionStore };
package/lib/store.js ADDED
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs').promises;
4
+ const path = require('path');
5
+
6
+ const { commentsFilename, legacyCommentsFilename, canonicalRepoPath } = require('./review');
7
+
8
+ /**
9
+ * Reading a repository's saved comments, whatever name they were written under.
10
+ *
11
+ * There is exactly one rule here: a review is served to the repository it was
12
+ * written for, or not at all. Comment files used to be named after the
13
+ * repository's basename alone, so two checkouts called `api-service` shared one
14
+ * file — the second review overwrote the first, and every reader afterwards
15
+ * handed one repository's comments out under the other's name. Filenames now
16
+ * carry a fingerprint of the full path, and this module is the single place
17
+ * that decides which file answers for a repository.
18
+ */
19
+
20
+ /** A comment file that belongs to a different repository than the one asking. */
21
+ class ReviewOwnershipError extends Error {
22
+ /**
23
+ * @param {string} requested repository being opened
24
+ * @param {string} owner repository the file records itself as belonging to
25
+ */
26
+ constructor(requested, owner) {
27
+ super(
28
+ `The saved review in this file was written for ${owner}, not ${requested}. ` +
29
+ 'Refusing to serve one repository\'s review under another\'s name.'
30
+ );
31
+ this.name = 'ReviewOwnershipError';
32
+ this.requested = requested;
33
+ this.owner = owner;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Whether a stored `repoPath` names the repository being opened.
39
+ *
40
+ * Files written before the fingerprint existed, and those written by tests,
41
+ * may carry no `repoPath` at all. An absent owner is not a mismatch — there is
42
+ * simply nothing to check — and the filename is doing the identifying.
43
+ *
44
+ * @param {string|undefined} stored
45
+ * @param {string} repoPath
46
+ * @returns {boolean}
47
+ */
48
+ function belongsTo(stored, repoPath) {
49
+ if (typeof stored !== 'string' || stored === '') return true;
50
+ return canonicalRepoPath(stored) === canonicalRepoPath(repoPath);
51
+ }
52
+
53
+ /**
54
+ * @param {string} file
55
+ * @returns {Promise<object|null>} parsed contents, or null when absent
56
+ */
57
+ async function readJson(file) {
58
+ try {
59
+ return JSON.parse(await fs.readFile(file, 'utf-8'));
60
+ } catch (error) {
61
+ if (error.code === 'ENOENT') return null;
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Read the comments saved for a repository.
68
+ *
69
+ * Looks under the current name first. Falling back to the legacy name is the
70
+ * migration: a review written before this change is adopted, but only when the
71
+ * file says it belongs to this repository — the whole point of the legacy name
72
+ * is that two repositories could share it, so adopting one blindly would
73
+ * reintroduce the bug it is here to fix.
74
+ *
75
+ * @param {string} reviewsDir
76
+ * @param {string} repoPath
77
+ * @returns {Promise<{comments: object[], adopted: boolean}>}
78
+ * @throws {ReviewOwnershipError} when the current file names another repository
79
+ */
80
+ async function readSavedComments(reviewsDir, repoPath) {
81
+ const current = path.join(reviewsDir, commentsFilename(repoPath));
82
+ const saved = await readJson(current);
83
+
84
+ if (saved !== null) {
85
+ // The fingerprint should already have kept these apart. Checking anyway is
86
+ // cheap, and the failure it guards against is silent corruption.
87
+ if (!belongsTo(saved.repoPath, repoPath)) {
88
+ throw new ReviewOwnershipError(repoPath, saved.repoPath);
89
+ }
90
+ return { comments: saved.comments ?? [], adopted: false };
91
+ }
92
+
93
+ const legacyName = legacyCommentsFilename(repoPath);
94
+ if (legacyName === commentsFilename(repoPath)) return { comments: [], adopted: false };
95
+
96
+ const legacy = await readJson(path.join(reviewsDir, legacyName));
97
+ if (legacy === null || !belongsTo(legacy.repoPath, repoPath)) {
98
+ return { comments: [], adopted: false };
99
+ }
100
+
101
+ // Move it under the new name so the next read is a plain hit and the old
102
+ // name stops shadowing another checkout. A read-only reviews directory is
103
+ // not a reason to withhold the review, so a failed rename is not fatal.
104
+ try {
105
+ await fs.rename(path.join(reviewsDir, legacyName), current);
106
+ } catch {
107
+ /* the comments are already in hand; renaming is housekeeping */
108
+ }
109
+
110
+ return { comments: legacy.comments ?? [], adopted: true };
111
+ }
112
+
113
+ module.exports = { readSavedComments, ReviewOwnershipError, belongsTo };
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "git-reviewer",
3
+ "version": "2.0.0",
4
+ "description": "Review local git changes in your browser with inline comments and threaded follow-ups \u2014 no branch, remote, or account required",
5
+ "author": "Dheeraj Jha",
6
+ "license": "MIT",
7
+ "homepage": "https://github.com/dheerajjha/reviewer#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/dheerajjha/reviewer.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/dheerajjha/reviewer/issues"
14
+ },
15
+ "keywords": [
16
+ "code-review",
17
+ "git",
18
+ "diff",
19
+ "review",
20
+ "cli",
21
+ "developer-tools"
22
+ ],
23
+ "engines": {
24
+ "node": ">=18.0.0"
25
+ },
26
+ "main": "server.js",
27
+ "bin": {
28
+ "reviewer": "bin/reviewer.js"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "lib/",
33
+ "public/",
34
+ "server.js"
35
+ ],
36
+ "scripts": {
37
+ "start": "node bin/reviewer.js",
38
+ "serve": "node server.js",
39
+ "test": "node --test",
40
+ "test:watch": "node --test --watch",
41
+ "test:coverage": "node --test --experimental-test-coverage"
42
+ },
43
+ "dependencies": {
44
+ "cors": "^2.8.5",
45
+ "express": "^4.18.2",
46
+ "simple-git": "^3.20.0"
47
+ }
48
+ }