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/changes.js ADDED
@@ -0,0 +1,84 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Turning git state into the flat `{ path, status }` list the file sidebar
5
+ * renders.
6
+ *
7
+ * Status letters follow git's own vocabulary: `A` added, `M` modified,
8
+ * `D` deleted, `R` renamed, `B` binary.
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} ChangedFile
13
+ * @property {string} path repo-relative path
14
+ * @property {'A'|'M'|'D'|'R'|'B'} status
15
+ */
16
+
17
+ /**
18
+ * Collect working-directory changes from a `simple-git` status result.
19
+ *
20
+ * A single path can be reported by more than one bucket — a file that is
21
+ * staged and then edited again shows up as both created and modified — so the
22
+ * first status wins and later duplicates are dropped. Buckets are ordered
23
+ * most- to least-specific for that reason.
24
+ *
25
+ * @param {import('simple-git').StatusResult} status
26
+ * @returns {ChangedFile[]}
27
+ */
28
+ function collectWorkingChanges(status) {
29
+ const buckets = [
30
+ [status?.renamed ?? [], 'R'],
31
+ [status?.deleted ?? [], 'D'],
32
+ [status?.created ?? [], 'A'],
33
+ [status?.not_added ?? [], 'A'],
34
+ [status?.modified ?? [], 'M']
35
+ ];
36
+
37
+ const seen = new Map();
38
+
39
+ for (const [files, letter] of buckets) {
40
+ for (const entry of files) {
41
+ // `renamed` holds `{ from, to }`; every other bucket holds plain strings.
42
+ const filePath = typeof entry === 'string' ? entry : entry?.to;
43
+ if (!filePath || seen.has(filePath)) continue;
44
+ seen.set(filePath, { path: filePath, status: letter });
45
+ }
46
+ }
47
+
48
+ return [...seen.values()];
49
+ }
50
+
51
+ /**
52
+ * Derive a status letter for one file of a commit diff summary.
53
+ *
54
+ * `simple-git` reports binary files without insertion/deletion counts, and a
55
+ * commit that only adds lines to a file is an addition of that file only when
56
+ * it deletes none.
57
+ *
58
+ * @param {object} file entry from `git.diffSummary()`
59
+ * @returns {'A'|'M'|'D'|'B'}
60
+ */
61
+ function classifyCommitFile(file) {
62
+ if (file?.binary) return 'B';
63
+
64
+ const insertions = file?.insertions ?? 0;
65
+ const deletions = file?.deletions ?? 0;
66
+
67
+ if (insertions > 0 && deletions > 0) return 'M';
68
+ if (insertions > 0) return 'A';
69
+ if (deletions > 0) return 'D';
70
+ return 'M';
71
+ }
72
+
73
+ /**
74
+ * Collect the files touched by a commit.
75
+ *
76
+ * @param {import('simple-git').DiffResult} diffSummary
77
+ * @returns {ChangedFile[]}
78
+ */
79
+ function collectCommitChanges(diffSummary) {
80
+ const files = diffSummary?.files ?? [];
81
+ return files.map(file => ({ path: file.file, status: classifyCommitFile(file) }));
82
+ }
83
+
84
+ module.exports = { collectWorkingChanges, collectCommitChanges, classifyCommitFile };
package/lib/cli.js ADDED
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ /**
6
+ * Command-line parsing for the `reviewer` command.
7
+ *
8
+ * Kept separate from the command itself so the argument handling can be tested
9
+ * without starting a server or opening a browser.
10
+ */
11
+
12
+ const USAGE = `reviewer — review local git changes in your browser
13
+
14
+ Usage:
15
+ reviewer [repository] [options]
16
+ reviewer export [repository] [--format json|prompt] [--file <path>]
17
+
18
+ Arguments:
19
+ repository Path to a git repository. Defaults to the current
20
+ directory. It is opened automatically once the page loads.
21
+
22
+ Commands:
23
+ export Print the saved review of a repository to stdout, for a
24
+ coding agent or another tool to consume.
25
+
26
+ Options:
27
+ -p, --port <n> Port to listen on (default 4500; falls back to a free
28
+ port if that one is taken)
29
+ --no-open Print the URL instead of opening a browser
30
+ -f, --format <f> Export format: json (default) or prompt
31
+ --file <path> Export comments for one repo-relative file
32
+ -h, --help Show this help
33
+ -v, --version Show the version
34
+
35
+ Examples:
36
+ reviewer review the repository you are standing in
37
+ reviewer ~/work/api review another repository
38
+ reviewer . --no-open print the URL, open it yourself
39
+ reviewer export . print the review as JSON
40
+ reviewer export . --file src/auth.js
41
+ reviewer export . --format prompt | claude -p "apply this review"
42
+ `;
43
+
44
+ /** Formats `reviewer export` can print. */
45
+ const FORMATS = ['json', 'prompt'];
46
+
47
+ class UsageError extends Error {
48
+ /** @param {string} message */
49
+ constructor(message) {
50
+ super(message);
51
+ this.name = 'UsageError';
52
+ }
53
+ }
54
+
55
+ /**
56
+ * @typedef {object} CliOptions
57
+ * @property {'serve'|'export'} command
58
+ * @property {string|null} repoPath absolute path, or null when not given
59
+ * @property {number|null} port explicit port, or null to use the default
60
+ * @property {boolean} open whether to launch a browser
61
+ * @property {'json'|'prompt'} format for `export`
62
+ * @property {string|null} file repo-relative file to export, or all files
63
+ * @property {boolean} help
64
+ * @property {boolean} version
65
+ */
66
+
67
+ /**
68
+ * Parse `reviewer` arguments.
69
+ *
70
+ * @param {string[]} argv arguments after the node binary and script
71
+ * @param {string} [cwd] base for resolving a relative repository path
72
+ * @returns {CliOptions}
73
+ * @throws {UsageError} on an unknown flag, a missing value, or a bad port
74
+ */
75
+ function parseArgs(argv, cwd = process.cwd()) {
76
+ /** @type {CliOptions} */
77
+ const options = {
78
+ command: 'serve',
79
+ repoPath: null,
80
+ port: null,
81
+ open: true,
82
+ format: 'json',
83
+ file: null,
84
+ help: false,
85
+ version: false
86
+ };
87
+ let positional = null;
88
+
89
+ // A leading `export` is the subcommand. Anywhere else it is an ordinary
90
+ // path, so a directory that happens to be named `export` is still reviewable
91
+ // as `reviewer ./export`.
92
+ let rest = argv;
93
+ if (argv[0] === 'export') {
94
+ options.command = 'export';
95
+ rest = argv.slice(1);
96
+ }
97
+
98
+ for (let i = 0; i < rest.length; i++) {
99
+ const arg = rest[i];
100
+
101
+ switch (arg) {
102
+ case '-h':
103
+ case '--help':
104
+ options.help = true;
105
+ break;
106
+
107
+ case '-v':
108
+ case '--version':
109
+ options.version = true;
110
+ break;
111
+
112
+ case '--no-open':
113
+ options.open = false;
114
+ break;
115
+
116
+ case '-p':
117
+ case '--port': {
118
+ const value = rest[++i];
119
+ if (value === undefined) throw new UsageError(`${arg} needs a port number`);
120
+ options.port = parsePort(value, arg);
121
+ break;
122
+ }
123
+
124
+ case '-f':
125
+ case '--format': {
126
+ const value = rest[++i];
127
+ if (value === undefined) throw new UsageError(`${arg} needs a format`);
128
+ options.format = parseFormat(value, arg);
129
+ break;
130
+ }
131
+
132
+ case '--file': {
133
+ const value = rest[++i];
134
+ options.file = parseFile(value, '--file');
135
+ break;
136
+ }
137
+
138
+ default: {
139
+ // Support --port=4500 as well as --port 4500.
140
+ const inlinePort = arg.match(/^--port=(.*)$/);
141
+ if (inlinePort) {
142
+ options.port = parsePort(inlinePort[1], '--port');
143
+ break;
144
+ }
145
+
146
+ const inlineFormat = arg.match(/^--format=(.*)$/);
147
+ if (inlineFormat) {
148
+ options.format = parseFormat(inlineFormat[1], '--format');
149
+ break;
150
+ }
151
+
152
+ const inlineFile = arg.match(/^--file=(.*)$/);
153
+ if (inlineFile) {
154
+ options.file = parseFile(inlineFile[1], '--file');
155
+ break;
156
+ }
157
+
158
+ if (arg.startsWith('-') && arg !== '-') {
159
+ throw new UsageError(`Unknown option: ${arg}`);
160
+ }
161
+
162
+ if (positional !== null) {
163
+ throw new UsageError(`Unexpected argument: ${arg}`);
164
+ }
165
+
166
+ positional = arg;
167
+ }
168
+ }
169
+ }
170
+
171
+ if (positional !== null) {
172
+ options.repoPath = path.resolve(cwd, positional);
173
+ }
174
+
175
+ if (options.file !== null && options.command !== 'export') {
176
+ throw new UsageError('--file is only valid with reviewer export');
177
+ }
178
+
179
+ return options;
180
+ }
181
+
182
+ /**
183
+ * @param {string} value
184
+ * @param {string} flag the flag being parsed, for the error message
185
+ * @returns {number}
186
+ */
187
+ function parsePort(value, flag) {
188
+ // Number() rather than parseInt() so "80x" is rejected instead of read as 80
189
+ // — but Number('') is 0, and an empty value is a mistake, not a request for
190
+ // any free port.
191
+ const port = value.trim() === '' ? NaN : Number(value);
192
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
193
+ throw new UsageError(`${flag} must be a number between 0 and 65535, got "${value}"`);
194
+ }
195
+ return port;
196
+ }
197
+
198
+ /**
199
+ * @param {string} value
200
+ * @param {string} flag the flag being parsed, for the error message
201
+ * @returns {'json'|'prompt'}
202
+ */
203
+ function parseFormat(value, flag) {
204
+ if (!FORMATS.includes(value)) {
205
+ throw new UsageError(`${flag} must be one of ${FORMATS.join(', ')}, got "${value}"`);
206
+ }
207
+ return value;
208
+ }
209
+
210
+ /**
211
+ * @param {string|undefined} value
212
+ * @param {string} flag the flag being parsed, for the error message
213
+ * @returns {string}
214
+ */
215
+ function parseFile(value, flag) {
216
+ if (value === undefined || value.trim() === '' || value.startsWith('-')) {
217
+ throw new UsageError(`${flag} needs a repo-relative file path`);
218
+ }
219
+ return value;
220
+ }
221
+
222
+ /**
223
+ * Build the URL to open, asking the page to load a repository if one was named.
224
+ *
225
+ * @param {string} origin e.g. http://127.0.0.1:4500
226
+ * @param {string|null} repoPath
227
+ * @returns {string}
228
+ */
229
+ function buildUrl(origin, repoPath) {
230
+ if (!repoPath) return origin;
231
+ return `${origin}/?repo=${encodeURIComponent(repoPath)}`;
232
+ }
233
+
234
+ module.exports = { parseArgs, buildUrl, UsageError, USAGE, FORMATS };
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The stored shape of a review comment.
5
+ *
6
+ * The client keeps extra bookkeeping on each comment (ids, collapsed state,
7
+ * draft text). Only the fields below are written to disk, so a comment file
8
+ * stays readable and stable across UI changes.
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} FollowUp
13
+ * @property {string} text
14
+ * @property {string} [timestamp] ISO 8601
15
+ */
16
+
17
+ /**
18
+ * @typedef {object} Comment
19
+ * @property {string} file repo-relative path
20
+ * @property {number} line 1-based line number
21
+ * @property {string} [lineContent] the line the comment is anchored to
22
+ * @property {string} text
23
+ * @property {string} [selectedText] code the reviewer highlighted
24
+ * @property {FollowUp[]} [followUps]
25
+ */
26
+
27
+ /**
28
+ * Reduce a client comment to the fields that get persisted.
29
+ *
30
+ * Optional fields are omitted rather than stored as `null` so that an absent
31
+ * selection and an empty one are not two different states on disk.
32
+ *
33
+ * @param {object} comment
34
+ * @returns {Comment}
35
+ */
36
+ function normalizeComment(comment) {
37
+ const normalized = {
38
+ file: comment.file,
39
+ line: comment.line,
40
+ lineContent: comment.lineContent,
41
+ text: comment.text
42
+ };
43
+
44
+ if (comment.selectedText) {
45
+ normalized.selectedText = comment.selectedText;
46
+ }
47
+
48
+ if (Array.isArray(comment.followUps) && comment.followUps.length > 0) {
49
+ normalized.followUps = comment.followUps.map(followUp => {
50
+ const entry = { text: followUp.text };
51
+ if (followUp.timestamp) entry.timestamp = followUp.timestamp;
52
+ return entry;
53
+ });
54
+ }
55
+
56
+ return normalized;
57
+ }
58
+
59
+ /**
60
+ * Normalize a batch of comments.
61
+ *
62
+ * @param {object[]} comments
63
+ * @returns {Comment[]}
64
+ * @throws {TypeError} when `comments` is not an array
65
+ */
66
+ function normalizeComments(comments) {
67
+ if (!Array.isArray(comments)) {
68
+ throw new TypeError('comments must be an array');
69
+ }
70
+ return comments.map(normalizeComment);
71
+ }
72
+
73
+ /**
74
+ * Group comments by file, each group sorted by line number.
75
+ *
76
+ * Insertion order of the files is preserved so a review reads in the order the
77
+ * reviewer worked through the changes.
78
+ *
79
+ * @param {Comment[]} comments
80
+ * @returns {Array<[string, Comment[]]>}
81
+ */
82
+ function groupByFile(comments) {
83
+ /** @type {Map<string, Comment[]>} */
84
+ const groups = new Map();
85
+
86
+ for (const comment of comments) {
87
+ const group = groups.get(comment.file);
88
+ if (group) group.push(comment);
89
+ else groups.set(comment.file, [comment]);
90
+ }
91
+
92
+ return [...groups.entries()].map(([file, group]) => [
93
+ file,
94
+ [...group].sort((a, b) => a.line - b.line)
95
+ ]);
96
+ }
97
+
98
+ module.exports = { normalizeComment, normalizeComments, groupByFile };
package/lib/diff.js ADDED
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Parsing of unified diff text into the line model the UI renders.
5
+ *
6
+ * The parser is deliberately tolerant: `git diff` output for a single file is
7
+ * the common case, but the same text can arrive with multiple `diff --git`
8
+ * sections (a caller that forgot to scope the diff to one path), with no hunks
9
+ * at all (an untracked file has nothing to diff against), or truncated.
10
+ */
11
+
12
+ const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
13
+
14
+ /**
15
+ * @typedef {object} DiffLine
16
+ * @property {number|null} oldLine 1-based line number before the change
17
+ * @property {number|null} newLine 1-based line number after the change
18
+ * @property {'add'|'delete'|'unchanged'} type
19
+ * @property {string} content the line without its diff marker
20
+ */
21
+
22
+ /**
23
+ * Parse unified diff text into structured lines.
24
+ *
25
+ * When the diff contains no hunks the file is treated as wholly new and every
26
+ * line of `currentContent` is reported as an addition — that is what an
27
+ * untracked file looks like to the UI.
28
+ *
29
+ * @param {string} diffText unified diff, may be empty
30
+ * @param {string} currentContent full text of the file as it stands now
31
+ * @returns {DiffLine[]}
32
+ */
33
+ function parseDiff(diffText, currentContent) {
34
+ const lines = [];
35
+ const diffLines = String(diffText ?? '').split('\n');
36
+
37
+ let oldLine = 0;
38
+ let newLine = 0;
39
+ let inHunk = false;
40
+
41
+ for (const line of diffLines) {
42
+ // A new file section ends the previous one. Without this the `---`/`+++`
43
+ // headers of the second file would be read as content of the first.
44
+ if (line.startsWith('diff --git ')) {
45
+ inHunk = false;
46
+ continue;
47
+ }
48
+
49
+ if (line.startsWith('@@')) {
50
+ const match = line.match(HUNK_HEADER);
51
+ if (match) {
52
+ oldLine = parseInt(match[1], 10);
53
+ newLine = parseInt(match[2], 10);
54
+ inHunk = true;
55
+ }
56
+ continue;
57
+ }
58
+
59
+ if (!inHunk) continue;
60
+
61
+ if (line.startsWith('-')) {
62
+ lines.push({ oldLine: oldLine++, newLine: null, type: 'delete', content: line.slice(1) });
63
+ } else if (line.startsWith('+')) {
64
+ lines.push({ oldLine: null, newLine: newLine++, type: 'add', content: line.slice(1) });
65
+ } else if (line.startsWith(' ')) {
66
+ lines.push({ oldLine: oldLine++, newLine: newLine++, type: 'unchanged', content: line.slice(1) });
67
+ }
68
+ // Anything else inside a hunk ("") carries no
69
+ // line of its own and is dropped.
70
+ }
71
+
72
+ if (lines.length > 0) return lines;
73
+
74
+ return wholeFileAsAdditions(currentContent);
75
+ }
76
+
77
+ /**
78
+ * Represent a file with no diff as one addition per line.
79
+ *
80
+ * @param {string} currentContent
81
+ * @returns {DiffLine[]}
82
+ */
83
+ function wholeFileAsAdditions(currentContent) {
84
+ const content = String(currentContent ?? '');
85
+ if (content === '') return [];
86
+
87
+ return content
88
+ .split('\n')
89
+ .map((text, i) => ({ oldLine: null, newLine: i + 1, type: 'add', content: text }));
90
+ }
91
+
92
+ module.exports = { parseDiff, wholeFileAsAdditions, HUNK_HEADER };
package/lib/paths.js ADDED
@@ -0,0 +1,52 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ /**
6
+ * Confining request-supplied paths to the repository being reviewed.
7
+ */
8
+
9
+ class PathEscapeError extends Error {
10
+ /** @param {string} requested the offending path, as received */
11
+ constructor(requested) {
12
+ super('Path escapes the repository');
13
+ this.name = 'PathEscapeError';
14
+ this.requested = requested;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Resolve a repo-relative path to an absolute one, refusing anything that
20
+ * lands outside the repository.
21
+ *
22
+ * The file endpoints take their path straight from the URL, so without this a
23
+ * request for `../../../../etc/passwd` would be served happily. Absolute paths
24
+ * are refused for the same reason: only files inside the opened repository are
25
+ * in scope for a review.
26
+ *
27
+ * @param {string} repoPath absolute path of the repository
28
+ * @param {string} filePath path relative to `repoPath`
29
+ * @returns {string} absolute path inside `repoPath`
30
+ * @throws {PathEscapeError} when the result would sit outside `repoPath`
31
+ */
32
+ function resolveRepoFile(repoPath, filePath) {
33
+ const requested = String(filePath ?? '');
34
+
35
+ if (requested === '' || path.isAbsolute(requested)) {
36
+ throw new PathEscapeError(requested);
37
+ }
38
+
39
+ const root = path.resolve(repoPath);
40
+ const resolved = path.resolve(root, requested);
41
+
42
+ // `relative` is '' for the root itself and starts with '..' for anything
43
+ // above it. Comparing prefixes instead would accept a sibling `repo-evil`.
44
+ const relative = path.relative(root, resolved);
45
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
46
+ throw new PathEscapeError(requested);
47
+ }
48
+
49
+ return resolved;
50
+ }
51
+
52
+ module.exports = { resolveRepoFile, PathEscapeError };