git-reviewer 2.12.0 → 2.13.1
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 +61 -27
- package/bin/reviewer.js +8 -4
- package/lib/agent.js +11 -0
- package/lib/cli.js +48 -16
- package/man/git-reviewer.1 +80 -0
- package/package.json +7 -3
- package/public/app.js +384 -19
- package/public/index.html +25 -2
- package/public/style.css +144 -0
- package/server.js +183 -12
package/public/style.css
CHANGED
|
@@ -2072,3 +2072,147 @@ button:disabled {
|
|
|
2072
2072
|
letter-spacing: 0.04em;
|
|
2073
2073
|
color: #6a737d;
|
|
2074
2074
|
}
|
|
2075
|
+
|
|
2076
|
+
/* ---------------------------------------------------------------------------
|
|
2077
|
+
* Commits: the ones a review is made of, above the files they touch.
|
|
2078
|
+
* ------------------------------------------------------------------------ */
|
|
2079
|
+
|
|
2080
|
+
.commits-section {
|
|
2081
|
+
border-bottom: 1px solid #e1e4e8;
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
.commits-hint {
|
|
2085
|
+
margin: 0;
|
|
2086
|
+
padding: 0 20px 8px;
|
|
2087
|
+
font-size: 11px;
|
|
2088
|
+
color: #6a737d;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
.commits-hint kbd {
|
|
2092
|
+
font-size: 10px;
|
|
2093
|
+
padding: 0 4px;
|
|
2094
|
+
border: 1px solid #d1d5da;
|
|
2095
|
+
border-bottom-width: 2px;
|
|
2096
|
+
border-radius: 3px;
|
|
2097
|
+
background: #fafbfc;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/* A bounded height, so a branch of forty commits does not push the files it
|
|
2101
|
+
* touches off the bottom of the sidebar. */
|
|
2102
|
+
.commits-list {
|
|
2103
|
+
list-style: none;
|
|
2104
|
+
margin: 0;
|
|
2105
|
+
padding: 0 8px 8px;
|
|
2106
|
+
max-height: 34vh;
|
|
2107
|
+
overflow-y: auto;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
.commit-item {
|
|
2111
|
+
display: flex;
|
|
2112
|
+
align-items: baseline;
|
|
2113
|
+
gap: 8px;
|
|
2114
|
+
padding: 6px 10px;
|
|
2115
|
+
margin: 2px 0;
|
|
2116
|
+
border-radius: 6px;
|
|
2117
|
+
border-left: 3px solid transparent;
|
|
2118
|
+
font-size: 12px;
|
|
2119
|
+
cursor: pointer;
|
|
2120
|
+
user-select: none;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
.commit-item:hover {
|
|
2124
|
+
background: #f3f4f6;
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
.commit-item:focus-visible {
|
|
2128
|
+
outline: 2px solid #0366d6;
|
|
2129
|
+
outline-offset: -2px;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
.commit-item-selected {
|
|
2133
|
+
background: #f1f8ff;
|
|
2134
|
+
border-left-color: #0366d6;
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
.commit-sha {
|
|
2138
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
2139
|
+
font-size: 11px;
|
|
2140
|
+
color: #0366d6;
|
|
2141
|
+
flex-shrink: 0;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
.commit-subject {
|
|
2145
|
+
flex: 1;
|
|
2146
|
+
min-width: 0;
|
|
2147
|
+
overflow: hidden;
|
|
2148
|
+
text-overflow: ellipsis;
|
|
2149
|
+
white-space: nowrap;
|
|
2150
|
+
color: #24292e;
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
.commit-when {
|
|
2154
|
+
flex-shrink: 0;
|
|
2155
|
+
font-size: 11px;
|
|
2156
|
+
color: #6a737d;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
.commit-merge {
|
|
2160
|
+
flex-shrink: 0;
|
|
2161
|
+
font-size: 10px;
|
|
2162
|
+
font-weight: 600;
|
|
2163
|
+
text-transform: uppercase;
|
|
2164
|
+
color: #6f42c1;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
.commits-truncated {
|
|
2168
|
+
margin: 0;
|
|
2169
|
+
padding: 0 20px 8px;
|
|
2170
|
+
font-size: 11px;
|
|
2171
|
+
color: #6a737d;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
/* Stepping one commit at a time, from the scope bar. */
|
|
2175
|
+
.commit-nav {
|
|
2176
|
+
display: flex;
|
|
2177
|
+
align-items: center;
|
|
2178
|
+
gap: 4px;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
.commit-nav .scope-action {
|
|
2182
|
+
padding: 5px 10px;
|
|
2183
|
+
font-size: 15px;
|
|
2184
|
+
line-height: 1;
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2187
|
+
.commit-nav .scope-action:disabled {
|
|
2188
|
+
opacity: 0.4;
|
|
2189
|
+
cursor: default;
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
.commit-nav .scope-action:disabled:hover {
|
|
2193
|
+
background: #fff;
|
|
2194
|
+
border-color: #d1d5da;
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
.commit-position {
|
|
2198
|
+
min-width: 0;
|
|
2199
|
+
font-size: 12px;
|
|
2200
|
+
color: #586069;
|
|
2201
|
+
white-space: nowrap;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
.commit-position:empty {
|
|
2205
|
+
display: none;
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
/* A commit message keeps its own line breaks; it is prose someone wrote in
|
|
2209
|
+
* paragraphs, and reflowing it into one line loses its lists. */
|
|
2210
|
+
.scope-note-message {
|
|
2211
|
+
white-space: pre-line;
|
|
2212
|
+
font-size: 13px;
|
|
2213
|
+
color: #24292e;
|
|
2214
|
+
padding-left: 10px;
|
|
2215
|
+
border-left: 3px solid #d1d5da;
|
|
2216
|
+
max-height: 8.5em;
|
|
2217
|
+
overflow-y: auto;
|
|
2218
|
+
}
|
package/server.js
CHANGED
|
@@ -73,6 +73,11 @@ const COMMIT_LIST_LIMIT = 30;
|
|
|
73
73
|
// cut are the least recently touched and are still reachable by typing.
|
|
74
74
|
const REF_LIST_LIMIT = 40;
|
|
75
75
|
|
|
76
|
+
// How many commits the commits list shows. A branch of more than this is a
|
|
77
|
+
// review nobody is doing commit by commit; the list says it is truncated
|
|
78
|
+
// rather than pretending the rest are not there.
|
|
79
|
+
const LOG_LIMIT = 200;
|
|
80
|
+
|
|
76
81
|
function createApp(options = {}) {
|
|
77
82
|
const {
|
|
78
83
|
reviewsDir = resolveReviewsDir(),
|
|
@@ -321,6 +326,90 @@ function createApp(options = {}) {
|
|
|
321
326
|
};
|
|
322
327
|
}
|
|
323
328
|
|
|
329
|
+
/**
|
|
330
|
+
* The range for reviewing commits themselves: one, or an unbroken run.
|
|
331
|
+
*
|
|
332
|
+
* Different from comparing two refs. `from` and `to` are both *in* the
|
|
333
|
+
* review -- clicking one commit means that commit's changes, and
|
|
334
|
+
* shift-clicking a second means everything from the first through the
|
|
335
|
+
* second -- so the diff starts at the parent of `from`, not at `from`.
|
|
336
|
+
* That is what GitHub does when you pick commits inside a pull request, and
|
|
337
|
+
* what "review commit 3 of 5" has to mean.
|
|
338
|
+
*
|
|
339
|
+
* `from` has to come before `to` on one line of history. Two commits on
|
|
340
|
+
* different branches have no run between them; that is a comparison, and
|
|
341
|
+
* the compare pickers exist for it.
|
|
342
|
+
*
|
|
343
|
+
* @throws {Error} with a message meant for the page
|
|
344
|
+
*/
|
|
345
|
+
async function resolveCommitRun(git, fromRef, toRef) {
|
|
346
|
+
for (const [label, ref] of [['first commit', fromRef], ['last commit', toRef]]) {
|
|
347
|
+
if (!looksLikeRef(ref)) throw new Error(`That ${label} does not look like a commit.`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const resolve = async ref => {
|
|
351
|
+
try {
|
|
352
|
+
return (await git.revparse([`${ref}^{commit}`])).trim();
|
|
353
|
+
} catch {
|
|
354
|
+
throw new Error(`No commit called "${ref}" in this repository.`);
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
const first = await resolve(fromRef);
|
|
358
|
+
const last = await resolve(toRef);
|
|
359
|
+
|
|
360
|
+
// Ancestry through output, not exit code: `merge-base --is-ancestor`
|
|
361
|
+
// answers only by exiting 1, and simple-git resolves a silent non-zero
|
|
362
|
+
// exit -- so it reported every pair as ordered, including main before a
|
|
363
|
+
// branch that main was not an ancestor of. The merge base of an ancestor
|
|
364
|
+
// and its descendant is the ancestor, and that is checkable.
|
|
365
|
+
const mergeBase = (await git.raw(['merge-base', first, last]).catch(() => '')).trim();
|
|
366
|
+
if (mergeBase !== first) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
`${first.slice(0, 7)} does not come before ${last.slice(0, 7)} on one line of history, ` +
|
|
369
|
+
'so there is no run of commits between them. Use Compare to diff two unrelated points.'
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// A root commit has no parent. Its "before" is the empty tree, which is
|
|
374
|
+
// also how `git show` presents a root commit: every file added.
|
|
375
|
+
let from;
|
|
376
|
+
let hasParent = true;
|
|
377
|
+
try {
|
|
378
|
+
from = (await git.revparse([`${first}^`])).trim();
|
|
379
|
+
} catch {
|
|
380
|
+
hasParent = false;
|
|
381
|
+
from = (await git.raw(['hash-object', '-t', 'tree', '/dev/null'])).trim();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const count = Number((await git.raw(['rev-list', '--count', hasParent ? `${from}..${last}` : last, '--'])).trim());
|
|
385
|
+
const subject = first === last
|
|
386
|
+
? (await git.raw(['log', '-1', '--format=%s', last, '--']).catch(() => '')).trim()
|
|
387
|
+
: null;
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
kind: 'commits',
|
|
391
|
+
first,
|
|
392
|
+
last,
|
|
393
|
+
base: from,
|
|
394
|
+
head: last,
|
|
395
|
+
from,
|
|
396
|
+
commits: Number.isFinite(count) ? count : 1,
|
|
397
|
+
behind: 0,
|
|
398
|
+
baseName: first.slice(0, 7),
|
|
399
|
+
headName: last.slice(0, 7),
|
|
400
|
+
...(subject ? { subject } : {})
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** What to say about a commit or a run of them once loaded. */
|
|
405
|
+
function describeCommitRun(range, fileCount) {
|
|
406
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
407
|
+
const files = fileCount === 0 ? 'no files changed' : plural(fileCount, 'changed file');
|
|
408
|
+
return range.first === range.last
|
|
409
|
+
? `Commit ${range.headName}${range.subject ? `: ${range.subject}` : ''} — ${files}`
|
|
410
|
+
: `${plural(range.commits, 'commit')}, ${range.baseName} through ${range.headName} — ${files}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
324
413
|
/**
|
|
325
414
|
* The changed files of a range, with the status git gives each one.
|
|
326
415
|
*
|
|
@@ -382,9 +471,9 @@ function createApp(options = {}) {
|
|
|
382
471
|
* @param {import('simple-git').SimpleGit} git
|
|
383
472
|
* @param {number} limit
|
|
384
473
|
*/
|
|
385
|
-
async function recentCommits(git, limit) {
|
|
474
|
+
async function recentCommits(git, limit, head = 'HEAD') {
|
|
386
475
|
const raw = await git.raw([
|
|
387
|
-
'log', `--max-count=${limit}`, '--format=%H%x00%h%x00%s%x00%cr'
|
|
476
|
+
'log', `--max-count=${limit}`, '--format=%H%x00%h%x00%s%x00%cr', head, '--'
|
|
388
477
|
]);
|
|
389
478
|
|
|
390
479
|
return raw
|
|
@@ -396,13 +485,6 @@ function createApp(options = {}) {
|
|
|
396
485
|
});
|
|
397
486
|
}
|
|
398
487
|
|
|
399
|
-
/**
|
|
400
|
-
* The commits of an open repository, newest first.
|
|
401
|
-
*
|
|
402
|
-
* Behind a session id like the file endpoints rather than taking a path:
|
|
403
|
-
* this reads history out of a repository, and the session is what says
|
|
404
|
-
* which repository the caller has already been granted.
|
|
405
|
-
*/
|
|
406
488
|
/**
|
|
407
489
|
* Everything the compare pickers offer: branches, tags and recent commits.
|
|
408
490
|
*
|
|
@@ -445,11 +527,18 @@ function createApp(options = {}) {
|
|
|
445
527
|
};
|
|
446
528
|
|
|
447
529
|
const current = await git.revparse(['--abbrev-ref', 'HEAD']).then(b => b.trim()).catch(() => null);
|
|
530
|
+
|
|
531
|
+
// The commits offered are the history of whatever is being compared, not
|
|
532
|
+
// always of the checkout: comparing main against feature/billing from a
|
|
533
|
+
// checkout of feature/auth should offer billing's commits to pick from.
|
|
534
|
+
const requested = typeof req.query.head === 'string' ? req.query.head.trim() : '';
|
|
535
|
+
const head = requested && looksLikeRef(requested) ? requested : 'HEAD';
|
|
536
|
+
|
|
448
537
|
const [branches, remotes, tags, commits] = await Promise.all([
|
|
449
538
|
refList('refs/heads', REF_LIST_LIMIT),
|
|
450
539
|
refList('refs/remotes', REF_LIST_LIMIT),
|
|
451
540
|
refList('refs/tags', REF_LIST_LIMIT),
|
|
452
|
-
recentCommits(git, COMMIT_LIST_LIMIT).catch(() => [])
|
|
541
|
+
recentCommits(git, COMMIT_LIST_LIMIT, head).catch(() => [])
|
|
453
542
|
]);
|
|
454
543
|
|
|
455
544
|
res.json({
|
|
@@ -463,6 +552,77 @@ function createApp(options = {}) {
|
|
|
463
552
|
});
|
|
464
553
|
});
|
|
465
554
|
|
|
555
|
+
/**
|
|
556
|
+
* The commits a review can be narrowed to, one at a time or as a run.
|
|
557
|
+
*
|
|
558
|
+
* With a base: the commits the comparison is made of -- what `base..head`
|
|
559
|
+
* has -- oldest first, because a branch reads as a story in the order it
|
|
560
|
+
* was written, and stepping through it commit by commit is the point.
|
|
561
|
+
* Without one: the recent history of `head`, newest first, the way `git
|
|
562
|
+
* log` reads, because there is no story there, only "how far back".
|
|
563
|
+
*
|
|
564
|
+
* Each commit carries its full message. The subject is what fits in a
|
|
565
|
+
* list, but the body is often where the author explains the change, and a
|
|
566
|
+
* reviewer looking at one commit should not have to leave the tool to read
|
|
567
|
+
* why it was made.
|
|
568
|
+
*
|
|
569
|
+
* Records are split on %x1e rather than on newlines, because a commit body
|
|
570
|
+
* is made of newlines.
|
|
571
|
+
*/
|
|
572
|
+
app.get('/api/log/:repoId', async (req, res) => {
|
|
573
|
+
const session = requireSession(res, req.params.repoId);
|
|
574
|
+
if (!session) return;
|
|
575
|
+
|
|
576
|
+
const git = gitFactory(session.repoPath);
|
|
577
|
+
const pick = name => (typeof req.query[name] === 'string' ? req.query[name].trim() : '');
|
|
578
|
+
const head = pick('head') || 'HEAD';
|
|
579
|
+
const base = pick('base');
|
|
580
|
+
|
|
581
|
+
for (const ref of base ? [base, head] : [head]) {
|
|
582
|
+
if (!looksLikeRef(ref)) {
|
|
583
|
+
return res.status(400).json({ error: 'That does not look like a commit or branch.' });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
const range = base ? `${base}..${head}` : head;
|
|
589
|
+
const total = Number((await git.raw(['rev-list', '--count', range, '--'])).trim()) || 0;
|
|
590
|
+
const raw = await git.raw([
|
|
591
|
+
'log', `--max-count=${LOG_LIMIT}`, ...(base ? ['--reverse'] : []),
|
|
592
|
+
'--format=%H%x00%h%x00%s%x00%an%x00%cr%x00%P%x00%b%x1e', range, '--'
|
|
593
|
+
]);
|
|
594
|
+
|
|
595
|
+
const commits = raw.split('\u001e').map(record => record.replace(/^\n/, '')).filter(Boolean).map(record => {
|
|
596
|
+
const [sha, short, subject, author, when, parents, body] = record.split('\u0000');
|
|
597
|
+
return {
|
|
598
|
+
sha, short, subject, author, when,
|
|
599
|
+
body: (body ?? '').trim(),
|
|
600
|
+
// A merge is shown against its first parent when reviewed alone --
|
|
601
|
+
// what it brought in -- and the list says it is one, because that
|
|
602
|
+
// diff can be surprisingly large.
|
|
603
|
+
merge: (parents ?? '').trim().split(/\s+/).filter(Boolean).length > 1
|
|
604
|
+
};
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
res.json({
|
|
608
|
+
order: base ? 'oldest-first' : 'newest-first',
|
|
609
|
+
total,
|
|
610
|
+
truncated: total > commits.length,
|
|
611
|
+
commits
|
|
612
|
+
});
|
|
613
|
+
} catch {
|
|
614
|
+
res.status(400).json({ error: `Could not read the history of "${base ? `${base}..${head}` : head}".` });
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* The commits of an open repository, newest first. Kept for 2.11 callers;
|
|
620
|
+
* the page itself uses /api/refs and /api/log.
|
|
621
|
+
*
|
|
622
|
+
* Behind a session id like the file endpoints rather than taking a path:
|
|
623
|
+
* this reads history out of a repository, and the session is what says
|
|
624
|
+
* which repository the caller has already been granted.
|
|
625
|
+
*/
|
|
466
626
|
app.get('/api/commits/:repoId', async (req, res) => {
|
|
467
627
|
try {
|
|
468
628
|
const session = requireSession(res, req.params.repoId);
|
|
@@ -608,7 +768,7 @@ function createApp(options = {}) {
|
|
|
608
768
|
// Resolve to the root of the working tree before anything is keyed on
|
|
609
769
|
// this path. A subdirectory passes `checkIsRepo()` quite happily and
|
|
610
770
|
// then produces a file list whose diffs are all empty; see lib/repo.js.
|
|
611
|
-
const { base, head } = req.body ?? {};
|
|
771
|
+
const { base, head, commits: run } = req.body ?? {};
|
|
612
772
|
const root = await repoRoot(repoPath, gitFactory);
|
|
613
773
|
if (!root) {
|
|
614
774
|
return res.status(400).json({ error: 'Not a valid git repository' });
|
|
@@ -621,7 +781,18 @@ function createApp(options = {}) {
|
|
|
621
781
|
let range = null;
|
|
622
782
|
let message;
|
|
623
783
|
|
|
624
|
-
if (
|
|
784
|
+
if (run && typeof run === 'object') {
|
|
785
|
+
// Commits themselves, one or a run: see resolveCommitRun.
|
|
786
|
+
try {
|
|
787
|
+
range = await resolveCommitRun(git, `${run.from ?? ''}`.trim(), `${run.to ?? run.from ?? ''}`.trim());
|
|
788
|
+
} catch (error) {
|
|
789
|
+
return res.status(400).json({ error: error.message });
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
files = await collectRangeChanges(git, range);
|
|
793
|
+
mode = 'range';
|
|
794
|
+
message = describeCommitRun(range, files.length);
|
|
795
|
+
} else if (base !== undefined && base !== null && `${base}`.trim() !== '') {
|
|
625
796
|
// An explicit range. Nothing falls back to anything here: someone who
|
|
626
797
|
// asked for x1..x3 and is silently shown their working tree instead
|
|
627
798
|
// has been told a lie about what they are reviewing.
|