git-reviewer 2.1.0 → 2.3.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/README.md +44 -12
- package/bin/reviewer.js +33 -3
- package/lib/agent.js +62 -6
- package/lib/browse.js +129 -0
- package/lib/cli.js +17 -3
- package/lib/paths.js +25 -0
- package/lib/recents.js +123 -0
- package/lib/repo.js +53 -0
- package/package.json +4 -3
- package/public/app.js +458 -62
- package/public/index.html +60 -6
- package/public/style.css +333 -2
- package/server.js +118 -7
package/README.md
CHANGED
|
@@ -3,11 +3,9 @@
|
|
|
3
3
|
[](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
|
-
[](test/)
|
|
7
7
|
[](package.json)
|
|
8
8
|
|
|
9
|
-

|
|
10
|
-
|
|
11
9
|
When an agent writes the code, reading it becomes the bottleneck — and the tool
|
|
12
10
|
built for reading a diff, the pull request, wants a branch, a remote and a push
|
|
13
11
|
you are not ready to make yet.
|
|
@@ -18,13 +16,21 @@ mean, with no branch, no remote, no push and no account. Then it hands the
|
|
|
18
16
|
marked-up review back to the agent to act on.
|
|
19
17
|
|
|
20
18
|
```bash
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
npm install -g git-reviewer
|
|
20
|
+
|
|
21
|
+
cd ~/your-project
|
|
22
|
+
reviewer # read the diff, comment on it
|
|
23
|
+
reviewer export . --format prompt | claude -p "Apply this review."
|
|
23
24
|
```
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+

|
|
27
|
+
|
|
28
|
+
*The whole loop, unedited and in real time — [full resolution](docs/demo.mp4).*
|
|
29
|
+
|
|
30
|
+
`reviewer` starts a local server and opens your browser on the repository you
|
|
31
|
+
are standing in. Nothing is installed into the project, there is nothing to
|
|
32
|
+
sign in to, and nothing leaves your machine. If you would rather not install
|
|
33
|
+
it, `npx git-reviewer` works the same way.
|
|
28
34
|
|
|
29
35
|
Comments persist between sessions and survive the code moving underneath them —
|
|
30
36
|
including the agent's own edits, which shift every line below the first change —
|
|
@@ -43,7 +49,8 @@ comment they answer, and everything is saved as you type.
|
|
|
43
49
|
reviewer [repository] [options]
|
|
44
50
|
reviewer export [repository] [--format json|prompt]
|
|
45
51
|
|
|
46
|
-
repository
|
|
52
|
+
repository A git repository, or any directory inside one
|
|
53
|
+
(default: the current directory)
|
|
47
54
|
|
|
48
55
|
-p, --port <n> Port to listen on (default 4500; falls back to a free
|
|
49
56
|
port if that one is taken)
|
|
@@ -62,6 +69,25 @@ reviewer . --port 8080 # somewhere other than 4500
|
|
|
62
69
|
|
|
63
70
|
Two at once is fine — the second one finds its own port.
|
|
64
71
|
|
|
72
|
+
### When you are not standing in a repository
|
|
73
|
+
|
|
74
|
+
The page opens on a picker rather than on an empty box:
|
|
75
|
+
|
|
76
|
+
- **Recent** — repositories you have opened before, newest first, with how many
|
|
77
|
+
comments are saved against each. One click reopens one.
|
|
78
|
+
- **Browse** — walk the filesystem from your home directory. Repositories are
|
|
79
|
+
marked, so you can see where to stop; a folder row walks into it, and the
|
|
80
|
+
button beside a marked one opens it.
|
|
81
|
+
|
|
82
|
+
Only directory names are listed, never file names, and the two endpoints behind
|
|
83
|
+
this refuse a request a browser says came from another origin.
|
|
84
|
+
|
|
85
|
+
The header leads with the repository you have open; click it to switch. Beside
|
|
86
|
+
it is a small keyboard icon that reveals a path box, for when the path is
|
|
87
|
+
already on your clipboard and a navigator is the slow way round.
|
|
88
|
+
|
|
89
|
+

|
|
90
|
+
|
|
65
91
|
To install it as a command rather than running it through `npx`:
|
|
66
92
|
|
|
67
93
|
```bash
|
|
@@ -70,8 +96,14 @@ reviewer .
|
|
|
70
96
|
```
|
|
71
97
|
|
|
72
98
|
The package is `git-reviewer` because `reviewer` and `code-reviewer` were both
|
|
73
|
-
already taken on npm.
|
|
74
|
-
|
|
99
|
+
already taken on npm. It installs the command under both names, so whichever
|
|
100
|
+
one you reach for is the one that is there:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
reviewer # the command
|
|
104
|
+
git-reviewer # the package name, if that is what you typed
|
|
105
|
+
git reviewer # git dispatches to it like any other subcommand
|
|
106
|
+
```
|
|
75
107
|
|
|
76
108
|
From a clone, if you would rather not install anything:
|
|
77
109
|
|
|
@@ -239,7 +271,7 @@ rather than a public issue.
|
|
|
239
271
|
|
|
240
272
|
```bash
|
|
241
273
|
npm install # 3 dependencies, no build step, ~6MB
|
|
242
|
-
npm test #
|
|
274
|
+
npm test # 261 tests
|
|
243
275
|
npm run test:watch
|
|
244
276
|
npm run test:coverage
|
|
245
277
|
```
|
package/bin/reviewer.js
CHANGED
|
@@ -9,6 +9,7 @@ const { openInBrowser } = require('../lib/browser');
|
|
|
9
9
|
const { formatPrompt } = require('../lib/agent');
|
|
10
10
|
const { ReviewOwnershipError } = require('../lib/store');
|
|
11
11
|
const { reviewsDir, adoptLegacyReviews } = require('../lib/paths');
|
|
12
|
+
const { repoRoot } = require('../lib/repo');
|
|
12
13
|
const {
|
|
13
14
|
loadReviewDocument,
|
|
14
15
|
NoReviewError,
|
|
@@ -50,7 +51,13 @@ async function listen(port) {
|
|
|
50
51
|
* @param {import('../lib/cli').CliOptions} options
|
|
51
52
|
*/
|
|
52
53
|
async function exportReview(options) {
|
|
53
|
-
|
|
54
|
+
// Saved reviews are filed under the repository root, so exporting from a
|
|
55
|
+
// subdirectory has to look there too — otherwise the browser saves a review
|
|
56
|
+
// one directory up and this reports that none exists. Falling back to the
|
|
57
|
+
// path as given keeps the case `lib/export.js` deliberately supports: a
|
|
58
|
+
// review whose repository is no longer a repository is still exportable.
|
|
59
|
+
const given = options.repoPath ?? process.cwd();
|
|
60
|
+
const repoPath = (await repoRoot(given)) ?? given;
|
|
54
61
|
|
|
55
62
|
let document;
|
|
56
63
|
try {
|
|
@@ -76,6 +83,27 @@ async function exportReview(options) {
|
|
|
76
83
|
);
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Which repository the page should open.
|
|
88
|
+
*
|
|
89
|
+
* `reviewer` with no argument reviews the repository you are standing in.
|
|
90
|
+
* `--help` has promised that since the first release and the code never did
|
|
91
|
+
* it, so the page opened on an empty path box instead.
|
|
92
|
+
*
|
|
93
|
+
* Naming a directory and defaulting to one are answered differently on
|
|
94
|
+
* purpose. Name one, and you are told when it is not a repository, because
|
|
95
|
+
* you asked about it. Nobody asked for the current directory in particular —
|
|
96
|
+
* so when that is not inside a repository the page opens on its picker
|
|
97
|
+
* exactly as it always has, rather than greeting you with an error.
|
|
98
|
+
*
|
|
99
|
+
* @param {string|null} given the repository named on the command line
|
|
100
|
+
* @returns {Promise<string|null>} repository to open, or null for the picker
|
|
101
|
+
*/
|
|
102
|
+
async function repositoryToOpen(given) {
|
|
103
|
+
if (given) return (await repoRoot(given)) ?? given;
|
|
104
|
+
return repoRoot(process.cwd());
|
|
105
|
+
}
|
|
106
|
+
|
|
79
107
|
async function main() {
|
|
80
108
|
const options = parseArgs(process.argv.slice(2));
|
|
81
109
|
|
|
@@ -114,11 +142,13 @@ async function main() {
|
|
|
114
142
|
return exportReview(options);
|
|
115
143
|
}
|
|
116
144
|
|
|
145
|
+
const repoPath = await repositoryToOpen(options.repoPath);
|
|
146
|
+
|
|
117
147
|
const server = await listen(options.port ?? (Number(process.env.PORT) || DEFAULT_PORT));
|
|
118
|
-
const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`,
|
|
148
|
+
const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, repoPath);
|
|
119
149
|
|
|
120
150
|
console.log(`\n Code Reviewer ${url}`);
|
|
121
|
-
if (
|
|
151
|
+
if (repoPath) console.log(` reviewing ${repoPath}`);
|
|
122
152
|
console.log('\n Press Ctrl+C to stop.\n');
|
|
123
153
|
|
|
124
154
|
if (options.open && !(await openInBrowser(url))) {
|
package/lib/agent.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const path = require('path');
|
|
3
4
|
const { groupByFile } = require('./comments');
|
|
4
5
|
const { fence, repoSlug } = require('./review');
|
|
5
6
|
|
|
@@ -66,10 +67,43 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
|
|
|
66
67
|
comments: comments.length,
|
|
67
68
|
files: grouped.length
|
|
68
69
|
},
|
|
69
|
-
comments: grouped.flatMap(([, fileComments]) => fileComments
|
|
70
|
+
comments: assignIds(grouped.flatMap(([, fileComments]) => fileComments))
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Give every comment an id that is unique within the document.
|
|
76
|
+
*
|
|
77
|
+
* `<file>:<line>` was assumed to be unique because "the UI anchors one
|
|
78
|
+
* comment per line". It does not: in a diff, the removed line and the line
|
|
79
|
+
* that replaced it can carry the *same* number, one on each side, and
|
|
80
|
+
* commenting on both is two ordinary clicks. The export then contained two
|
|
81
|
+
* different comments under one id, and a consumer keyed by id — which is
|
|
82
|
+
* what an id is for — silently kept one of them. See #26.
|
|
83
|
+
*
|
|
84
|
+
* The first comment on a line keeps the bare `<file>:<line>`, so ids do not
|
|
85
|
+
* churn for the overwhelmingly common case and stay stable for anything
|
|
86
|
+
* already consuming them. Only a genuine collision gets a suffix.
|
|
87
|
+
*
|
|
88
|
+
* Stable across exports because the order is: `groupByFile` sorts by line
|
|
89
|
+
* with a stable sort, so two comments sharing a line keep the order they
|
|
90
|
+
* were saved in, and saving appends.
|
|
91
|
+
*
|
|
92
|
+
* @param {import('./comments').Comment[]} comments in document order
|
|
93
|
+
* @returns {AgentComment[]}
|
|
94
|
+
*/
|
|
95
|
+
function assignIds(comments) {
|
|
96
|
+
const seen = new Map();
|
|
97
|
+
|
|
98
|
+
return comments.map(comment => {
|
|
99
|
+
const base = `${comment.file}:${comment.line}`;
|
|
100
|
+
const n = (seen.get(base) ?? 0) + 1;
|
|
101
|
+
seen.set(base, n);
|
|
102
|
+
return toAgentComment(comment, n === 1 ? base : `${base}#${n}`);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
73
107
|
/**
|
|
74
108
|
* Select comments from an already-built document and keep its summary honest.
|
|
75
109
|
*
|
|
@@ -81,7 +115,8 @@ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, bra
|
|
|
81
115
|
function filterReviewDocument(document, { file = null } = {}) {
|
|
82
116
|
if (file === null) return document;
|
|
83
117
|
|
|
84
|
-
const
|
|
118
|
+
const wanted = normalizeReviewPath(file, document.repository.path);
|
|
119
|
+
const comments = document.comments.filter(comment => comment.file === wanted);
|
|
85
120
|
return {
|
|
86
121
|
...document,
|
|
87
122
|
summary: {
|
|
@@ -92,15 +127,36 @@ function filterReviewDocument(document, { file = null } = {}) {
|
|
|
92
127
|
};
|
|
93
128
|
}
|
|
94
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Turn a `--file` argument into the repo-relative, forward-slash form comments
|
|
132
|
+
* are stored in.
|
|
133
|
+
*
|
|
134
|
+
* Shells hand paths over as `./src/auth.js` on tab-completion, scripts often
|
|
135
|
+
* pass absolute paths, and Windows users type backslashes. Matching literally
|
|
136
|
+
* made every one of those miss and fail with the same error as a file that
|
|
137
|
+
* genuinely has no comments.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} file
|
|
140
|
+
* @param {string} repoPath
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function normalizeReviewPath(file, repoPath) {
|
|
144
|
+
let candidate = file;
|
|
145
|
+
if (path.isAbsolute(candidate)) candidate = path.relative(repoPath, candidate);
|
|
146
|
+
|
|
147
|
+
const normalized = path.posix.normalize(candidate.replace(/\\/g, '/'));
|
|
148
|
+
return normalized.replace(/^(\.\/)+/, '').replace(/\/$/, '');
|
|
149
|
+
}
|
|
150
|
+
|
|
95
151
|
/**
|
|
96
152
|
* @param {import('./comments').Comment} comment
|
|
97
153
|
* @returns {AgentComment}
|
|
98
154
|
*/
|
|
99
|
-
function toAgentComment(comment) {
|
|
155
|
+
function toAgentComment(comment, id) {
|
|
100
156
|
const entry = {
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
id
|
|
157
|
+
// Derived from file and line so it stays stable across exports, with a
|
|
158
|
+
// suffix where that is not enough on its own. See `assignIds`.
|
|
159
|
+
id,
|
|
104
160
|
file: comment.file,
|
|
105
161
|
line: comment.line,
|
|
106
162
|
anchor: comment.lineContent ?? null,
|
package/lib/browse.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const { homeRelative } = require('./paths');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Listing directories, so a repository can be found by looking rather than by
|
|
11
|
+
* being typed from memory.
|
|
12
|
+
*
|
|
13
|
+
* Only directories are reported. A picker picks a folder, so file names are
|
|
14
|
+
* not the question — and not answering it keeps this from being a way to read
|
|
15
|
+
* the shape of someone's disk when all it needs to do is navigate.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Directories that are never worth offering.
|
|
20
|
+
*
|
|
21
|
+
* `.git` is a repository's own plumbing: listing it invites clicking into it,
|
|
22
|
+
* and what is inside is not reviewable. `node_modules` is thousands of
|
|
23
|
+
* directories nobody is looking for.
|
|
24
|
+
*/
|
|
25
|
+
const NEVER_LIST = new Set(['.git', 'node_modules']);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Is this directory the root of a working tree?
|
|
29
|
+
*
|
|
30
|
+
* Decided by the presence of `.git` rather than by asking git, because this
|
|
31
|
+
* runs once per row and a subprocess per row would make a home directory take
|
|
32
|
+
* seconds to list. `.git` is a directory in an ordinary clone and a *file* in
|
|
33
|
+
* a worktree or a submodule, so existence is the question and type is not.
|
|
34
|
+
*
|
|
35
|
+
* This is a hint for the UI. `lib/repo.js` stays the authority on what gets
|
|
36
|
+
* opened, so a wrong guess here costs a click, not a wrong review.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} dir
|
|
39
|
+
* @returns {Promise<boolean>}
|
|
40
|
+
*/
|
|
41
|
+
async function isRepository(dir) {
|
|
42
|
+
try {
|
|
43
|
+
await fs.stat(path.join(dir, '.git'));
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve one directory entry, following symlinks.
|
|
52
|
+
*
|
|
53
|
+
* `withFileTypes` reports a symlink as a symlink, not as what it points at,
|
|
54
|
+
* and a home directory with `~/code -> /Volumes/work/code` in it is ordinary.
|
|
55
|
+
* Following it costs a stat; not following it hides the projects.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} parent
|
|
58
|
+
* @param {import('fs').Dirent} entry
|
|
59
|
+
* @returns {Promise<{name: string, path: string, isRepository: boolean}|null>}
|
|
60
|
+
* null when the entry is not a directory, or cannot be read at all
|
|
61
|
+
*/
|
|
62
|
+
async function describe(parent, entry) {
|
|
63
|
+
if (NEVER_LIST.has(entry.name)) return null;
|
|
64
|
+
|
|
65
|
+
const full = path.join(parent, entry.name);
|
|
66
|
+
|
|
67
|
+
if (!entry.isDirectory()) {
|
|
68
|
+
if (!entry.isSymbolicLink()) return null;
|
|
69
|
+
try {
|
|
70
|
+
if (!(await fs.stat(full)).isDirectory()) return null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null; // dangling symlink
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const repository = await isRepository(full);
|
|
77
|
+
|
|
78
|
+
// Hidden directories are noise in a home directory -- `.cache`, `.npm`,
|
|
79
|
+
// `.Trash` -- but `~/.dotfiles` is one of the most commonly reviewed
|
|
80
|
+
// repositories there is. Hide the hidden ones, except the ones that are
|
|
81
|
+
// repositories, which is the only reason to be looking in here.
|
|
82
|
+
if (entry.name.startsWith('.') && !repository) return null;
|
|
83
|
+
|
|
84
|
+
return { name: entry.name, path: full, isRepository: repository };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* List the directories inside `target`.
|
|
89
|
+
*
|
|
90
|
+
* An entry that cannot be read is skipped rather than failing the listing: one
|
|
91
|
+
* unreadable directory in a home folder should not make the folder unbrowsable.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} [target] directory to list; the home directory by default
|
|
94
|
+
* @returns {Promise<{path: string, display: string, parent: string|null,
|
|
95
|
+
* isRepository: boolean,
|
|
96
|
+
* entries: Array<{name: string, path: string, isRepository: boolean}>}>}
|
|
97
|
+
* @throws an fs error with `code` ENOENT, ENOTDIR or EACCES for the caller to map
|
|
98
|
+
*/
|
|
99
|
+
async function browse(target) {
|
|
100
|
+
const asked = typeof target === 'string' ? target.trim() : '';
|
|
101
|
+
const dir = path.resolve(asked === '' ? os.homedir() : asked);
|
|
102
|
+
|
|
103
|
+
const stats = await fs.stat(dir);
|
|
104
|
+
if (!stats.isDirectory()) {
|
|
105
|
+
throw Object.assign(new Error(`Not a directory: ${dir}`), { code: 'ENOTDIR' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const described = await Promise.all(
|
|
109
|
+
(await fs.readdir(dir, { withFileTypes: true })).map(entry => describe(dir, entry))
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const parent = path.dirname(dir);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
path: dir,
|
|
116
|
+
// The same directory, written the way a person writes it. `path` stays
|
|
117
|
+
// absolute because that is what gets opened; this is what gets read.
|
|
118
|
+
display: homeRelative(dir),
|
|
119
|
+
// At the filesystem root, dirname answers with the root again. There is
|
|
120
|
+
// nowhere further up, and a parent row that goes nowhere is a bug report.
|
|
121
|
+
parent: parent === dir ? null : parent,
|
|
122
|
+
isRepository: await isRepository(dir),
|
|
123
|
+
entries: described
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { browse, isRepository, NEVER_LIST };
|
package/lib/cli.js
CHANGED
|
@@ -15,9 +15,13 @@ Usage:
|
|
|
15
15
|
reviewer [repository] [options]
|
|
16
16
|
reviewer export [repository] [--format json|prompt] [--file <path>]
|
|
17
17
|
|
|
18
|
+
Installed under both names: reviewer and git-reviewer. The second name also
|
|
19
|
+
makes it reachable as a git subcommand: git reviewer
|
|
20
|
+
|
|
18
21
|
Arguments:
|
|
19
|
-
repository Path to a git repository
|
|
20
|
-
directory. It is opened
|
|
22
|
+
repository Path to a git repository, or to any directory inside one.
|
|
23
|
+
Defaults to the current directory. It is opened
|
|
24
|
+
automatically once the page loads.
|
|
21
25
|
|
|
22
26
|
Commands:
|
|
23
27
|
export Print the saved review of a repository to stdout, for a
|
|
@@ -228,7 +232,17 @@ function parseFile(value, flag) {
|
|
|
228
232
|
*/
|
|
229
233
|
function buildUrl(origin, repoPath) {
|
|
230
234
|
if (!repoPath) return origin;
|
|
231
|
-
|
|
235
|
+
|
|
236
|
+
// Slashes are left as slashes. They are legal in a query value -- RFC 3986
|
|
237
|
+
// has `query = *( pchar / "/" / "?" )` -- and encoding them turns the most
|
|
238
|
+
// prominent line the command prints into %2F soup:
|
|
239
|
+
//
|
|
240
|
+
// before http://127.0.0.1:4500/?repo=%2FUsers%2Fyou%2Fwork%2Fapi
|
|
241
|
+
// after http://127.0.0.1:4500/?repo=/Users/you/work/api
|
|
242
|
+
//
|
|
243
|
+
// Everything else stays encoded, including the characters that would
|
|
244
|
+
// otherwise end the query and take the rest of the path with them.
|
|
245
|
+
return `${origin}/?repo=${encodeURIComponent(repoPath).replace(/%2F/g, '/')}`;
|
|
232
246
|
}
|
|
233
247
|
|
|
234
248
|
module.exports = { parseArgs, buildUrl, UsageError, USAGE, FORMATS };
|
package/lib/paths.js
CHANGED
|
@@ -155,12 +155,37 @@ function resolveRepoFile(repoPath, filePath) {
|
|
|
155
155
|
return resolved;
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* A path as a person would write it, with the home directory as `~`.
|
|
160
|
+
*
|
|
161
|
+
* For display only — nothing resolves a `~` back, and nothing should. An
|
|
162
|
+
* absolute path is the right thing to store, to compare and to hand to git;
|
|
163
|
+
* it is the wrong thing to put in a list of projects, where the first forty
|
|
164
|
+
* characters are the same on every row and the part that identifies the row
|
|
165
|
+
* is the part that gets cut off.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} target
|
|
168
|
+
* @param {string} [home]
|
|
169
|
+
* @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
function homeRelative(target, home = os.homedir()) {
|
|
172
|
+
const resolved = path.resolve(String(target ?? ''));
|
|
173
|
+
|
|
174
|
+
if (resolved === home) return '~';
|
|
175
|
+
if (home && resolved.startsWith(home + path.sep)) {
|
|
176
|
+
return `~${resolved.slice(home.length)}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return resolved;
|
|
180
|
+
}
|
|
181
|
+
|
|
158
182
|
module.exports = {
|
|
159
183
|
reviewsDir,
|
|
160
184
|
userDataDir,
|
|
161
185
|
adoptLegacyReviews,
|
|
162
186
|
LEGACY_REVIEWS_DIR,
|
|
163
187
|
resolveRepoFile,
|
|
188
|
+
homeRelative,
|
|
164
189
|
PathEscapeError
|
|
165
190
|
};
|
|
166
191
|
|
package/lib/recents.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs').promises;
|
|
4
|
+
const pathModule = require('path');
|
|
5
|
+
|
|
6
|
+
const { canonicalRepoPath, repoSlug } = require('./review');
|
|
7
|
+
const { homeRelative } = require('./paths');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The repositories this machine has opened, newest first.
|
|
11
|
+
*
|
|
12
|
+
* `listReviews` already answers a related question — which repositories have a
|
|
13
|
+
* saved review — but that is not the same list. A repository you opened,
|
|
14
|
+
* read and left without commenting on has no review and is exactly the one you
|
|
15
|
+
* want offered back to you next time.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Filename inside the data directory. Not a review, so `listReviews` skips it. */
|
|
19
|
+
const RECENTS_FILE = 'recent-projects.json';
|
|
20
|
+
|
|
21
|
+
/** How many to keep. Long enough to cover what you are working on, short enough to read. */
|
|
22
|
+
const LIMIT = 10;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} dataDir
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
function recentsFile(dataDir) {
|
|
29
|
+
return pathModule.join(dataDir, RECENTS_FILE);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read the recorded list.
|
|
34
|
+
*
|
|
35
|
+
* Answers an empty list for every failure — absent, unreadable, not JSON, or
|
|
36
|
+
* JSON of the wrong shape. A damaged recents file is a lost convenience, and
|
|
37
|
+
* it must not be allowed to become a failure to open a repository.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} dataDir
|
|
40
|
+
* @returns {Promise<Array<{path: string, openedAt: string}>>} newest first
|
|
41
|
+
*/
|
|
42
|
+
async function readRecents(dataDir) {
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(await fs.readFile(recentsFile(dataDir), 'utf-8'));
|
|
46
|
+
} catch {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!Array.isArray(parsed?.projects)) return [];
|
|
51
|
+
|
|
52
|
+
return parsed.projects.filter(
|
|
53
|
+
project =>
|
|
54
|
+
typeof project?.path === 'string' &&
|
|
55
|
+
project.path !== '' &&
|
|
56
|
+
typeof project.openedAt === 'string'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Record a repository as just opened.
|
|
62
|
+
*
|
|
63
|
+
* Written to a temporary file and renamed, so two `reviewer` processes racing
|
|
64
|
+
* cannot leave a half-written list behind. The loser of the race loses one
|
|
65
|
+
* entry, which is the right thing to lose.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} dataDir
|
|
68
|
+
* @param {string} repoPath
|
|
69
|
+
* @param {Date} [now]
|
|
70
|
+
* @returns {Promise<Array<{path: string, openedAt: string}>>} the new list
|
|
71
|
+
*/
|
|
72
|
+
async function recordRecent(dataDir, repoPath, now = new Date()) {
|
|
73
|
+
const canonical = canonicalRepoPath(repoPath);
|
|
74
|
+
const existing = await readRecents(dataDir);
|
|
75
|
+
|
|
76
|
+
const projects = [
|
|
77
|
+
{ path: canonical, openedAt: now.toISOString() },
|
|
78
|
+
...existing.filter(project => canonicalRepoPath(project.path) !== canonical)
|
|
79
|
+
].slice(0, LIMIT);
|
|
80
|
+
|
|
81
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
82
|
+
|
|
83
|
+
const temporary = `${recentsFile(dataDir)}.${process.pid}.tmp`;
|
|
84
|
+
await fs.writeFile(temporary, `${JSON.stringify({ version: 1, projects }, null, 2)}\n`);
|
|
85
|
+
await fs.rename(temporary, recentsFile(dataDir));
|
|
86
|
+
|
|
87
|
+
return projects;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The recent list, as the picker needs to draw it.
|
|
92
|
+
*
|
|
93
|
+
* A repository that has been moved or deleted is reported rather than dropped:
|
|
94
|
+
* seeing it greyed out with the path you remember is a better answer than a
|
|
95
|
+
* list that quietly gets shorter.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} dataDir
|
|
98
|
+
* @param {Map<string, number>} [commentCounts] repository path -> saved comments
|
|
99
|
+
* @returns {Promise<Array<{path: string, name: string, location: string,
|
|
100
|
+
* openedAt: string, exists: boolean, comments: number}>>}
|
|
101
|
+
*/
|
|
102
|
+
async function describeRecents(dataDir, commentCounts = new Map()) {
|
|
103
|
+
const projects = await readRecents(dataDir);
|
|
104
|
+
|
|
105
|
+
return Promise.all(
|
|
106
|
+
projects.map(async project => ({
|
|
107
|
+
path: project.path,
|
|
108
|
+
name: repoSlug(project.path),
|
|
109
|
+
// Where it lives, rather than all of where it lives. The name is on the
|
|
110
|
+
// row already, and two checkouts are told apart by the directory holding
|
|
111
|
+
// them, not by the forty characters they have in common.
|
|
112
|
+
location: homeRelative(pathModule.dirname(project.path)),
|
|
113
|
+
openedAt: project.openedAt,
|
|
114
|
+
exists: await fs
|
|
115
|
+
.stat(project.path)
|
|
116
|
+
.then(stats => stats.isDirectory())
|
|
117
|
+
.catch(() => false),
|
|
118
|
+
comments: commentCounts.get(canonicalRepoPath(project.path)) ?? 0
|
|
119
|
+
}))
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { readRecents, recordRecent, describeRecents, RECENTS_FILE, LIMIT };
|
package/lib/repo.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const simpleGit = require('simple-git');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Finding the repository a directory belongs to.
|
|
9
|
+
*
|
|
10
|
+
* Everything downstream of this — the session, the saved review's filename,
|
|
11
|
+
* every diff — is keyed on one path, and that path has to be the root of the
|
|
12
|
+
* working tree rather than wherever the person happened to be standing. Two
|
|
13
|
+
* things go wrong otherwise, and neither announces itself:
|
|
14
|
+
*
|
|
15
|
+
* - `git status --porcelain` reports paths relative to the root, but a
|
|
16
|
+
* pathspec is resolved relative to the process's directory. Ask a git
|
|
17
|
+
* rooted at `repo/src` for `git diff -- src/deep/file.txt` and you get an
|
|
18
|
+
* empty string back, not an error. The file is listed, the diff is blank,
|
|
19
|
+
* and nothing says why.
|
|
20
|
+
* - reviews are filed under a fingerprint of the repository path, so a
|
|
21
|
+
* review written from the root is invisible from a subdirectory. The
|
|
22
|
+
* browser saves it, `reviewer export` run one directory down reports that
|
|
23
|
+
* no review exists, and the loop in the README quietly stops working.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The root of the working tree containing `dir`.
|
|
28
|
+
*
|
|
29
|
+
* Answers `null` rather than raising for every way this can fail — not a
|
|
30
|
+
* repository, a bare repository with no working tree, git not installed, the
|
|
31
|
+
* directory gone. Callers decide what an absent repository means; for some it
|
|
32
|
+
* is an error and for others it is simply a directory they will use as given.
|
|
33
|
+
*
|
|
34
|
+
* The `simpleGit()` call is inside the `try` on purpose. It throws
|
|
35
|
+
* *synchronously, at construction* when the directory does not exist, so a
|
|
36
|
+
* `.catch()` on the returned promise never runs — the same trap documented at
|
|
37
|
+
* `readGitPosition` in `lib/export.js`.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} dir any directory inside the repository
|
|
40
|
+
* @param {(dir: string) => import('simple-git').SimpleGit} [gitFactory]
|
|
41
|
+
* @returns {Promise<string|null>} absolute path to the root, or null
|
|
42
|
+
*/
|
|
43
|
+
async function repoRoot(dir, gitFactory = simpleGit) {
|
|
44
|
+
try {
|
|
45
|
+
const top = await gitFactory(dir).revparse(['--show-toplevel']);
|
|
46
|
+
const trimmed = String(top).trim();
|
|
47
|
+
return trimmed === '' ? null : path.resolve(trimmed);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { repoRoot };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-reviewer",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Review your coding agent's changes like a pull request
|
|
3
|
+
"version": "2.3.0",
|
|
4
|
+
"description": "Review your coding agent's changes like a pull request — locally, with inline comments and threaded follow-ups, then hand the marked-up review back to the agent",
|
|
5
5
|
"author": "Dheeraj Jha",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"homepage": "https://github.com/dheerajjha/reviewer#readme",
|
|
@@ -31,7 +31,8 @@
|
|
|
31
31
|
},
|
|
32
32
|
"main": "server.js",
|
|
33
33
|
"bin": {
|
|
34
|
-
"reviewer": "bin/reviewer.js"
|
|
34
|
+
"reviewer": "bin/reviewer.js",
|
|
35
|
+
"git-reviewer": "bin/reviewer.js"
|
|
35
36
|
},
|
|
36
37
|
"files": [
|
|
37
38
|
"bin/",
|