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/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 };
|
package/lib/comments.js
ADDED
|
@@ -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 };
|