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/LICENSE +21 -0
- package/README.md +282 -0
- package/bin/reviewer.js +146 -0
- package/lib/agent.js +182 -0
- package/lib/browser.js +67 -0
- package/lib/changes.js +84 -0
- package/lib/cli.js +234 -0
- package/lib/comments.js +98 -0
- package/lib/diff.js +92 -0
- package/lib/paths.js +52 -0
- package/lib/review.js +206 -0
- package/lib/sessions.js +62 -0
- package/lib/store.js +113 -0
- package/package.json +48 -0
- package/public/app.js +1439 -0
- package/public/favicon.svg +7 -0
- package/public/index.html +66 -0
- package/public/style.css +990 -0
- package/server.js +421 -0
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
|
+
};
|
package/lib/sessions.js
ADDED
|
@@ -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
|
+
}
|