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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dheeraj Jha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,282 @@
1
+ # reviewer
2
+
3
+ [![CI](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/dheerajjha/reviewer/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+ [![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)
6
+ [![Tests](https://img.shields.io/badge/tests-163-brightgreen.svg)](test/)
7
+ [![Dependencies](https://img.shields.io/badge/dependencies-3-brightgreen.svg)](package.json)
8
+
9
+ ![Plain grey code lines on the left resolving into colored diff stripes, with threaded comment markers attached in the right margin](docs/banner.jpg)
10
+
11
+ Review code before it becomes a pull request. `reviewer` opens any local git
12
+ repository in your browser, shows what changed, and lets you leave inline
13
+ comments with threaded follow-ups — against your working directory, without a
14
+ branch, a remote, a push, or an account. Comments persist between sessions and
15
+ survive the code moving underneath them, so you can review, edit, and come back
16
+ tomorrow to a review that still points at the right lines. When you are done it
17
+ writes a plain Markdown file you can paste anywhere.
18
+
19
+ ```bash
20
+ cd ~/work/my-app
21
+ npx git-reviewer .
22
+ ```
23
+
24
+ That starts a local server and opens your browser on the repository you named.
25
+ There is nothing to install into the project, nothing to sign in to, and
26
+ nothing leaves your machine.
27
+
28
+ ![The app reviewing a modified file: changed files on the left, a colour-coded diff in the middle with an inline comment and a threaded follow-up attached to line 10, and a comments sidebar on the right](docs/screenshot-comments.png)
29
+
30
+ Click a line number, write the note, keep going. Replies thread under the
31
+ comment they answer, and everything is saved as you type.
32
+
33
+ ## Usage
34
+
35
+ ```
36
+ reviewer [repository] [options]
37
+ reviewer export [repository] [--format json|prompt]
38
+
39
+ repository Path to a git repository (default: the current directory)
40
+
41
+ -p, --port <n> Port to listen on (default 4500; falls back to a free
42
+ port if that one is taken)
43
+ --no-open Print the URL instead of opening a browser
44
+ -f, --format <f> Export format: json (default) or prompt
45
+ -h, --help Show help
46
+ -v, --version Show the version
47
+ ```
48
+
49
+ ```bash
50
+ reviewer # review the repository you are standing in
51
+ reviewer ~/work/api # review another one
52
+ reviewer . --no-open # print the URL, open it yourself
53
+ reviewer . --port 8080 # somewhere other than 4500
54
+ ```
55
+
56
+ Two at once is fine — the second one finds its own port.
57
+
58
+ To install it as a command rather than running it through `npx`:
59
+
60
+ ```bash
61
+ npm install -g git-reviewer
62
+ reviewer .
63
+ ```
64
+
65
+ The package is `git-reviewer` because `reviewer` and `code-reviewer` were both
66
+ already taken on npm. The command it installs is still `reviewer` -- the
67
+ package name is a distribution detail, the command is the product.
68
+
69
+ From a clone, if you would rather not install anything:
70
+
71
+ ```bash
72
+ git clone https://github.com/dheerajjha/reviewer.git
73
+ cd reviewer && npm install && npm link
74
+ ```
75
+
76
+ ## What it shows you
77
+
78
+ `reviewer` picks what to review based on the state of the repository, so there
79
+ is nothing to configure:
80
+
81
+ | Repository state | What you review |
82
+ |---|---|
83
+ | Uncommitted changes present | Your working directory against `HEAD` |
84
+ | Working directory clean | The last commit against its parent |
85
+ | No commits yet | Every tracked and untracked file, as wholly new |
86
+
87
+ Modified, added, deleted, renamed, and binary files are all listed, each marked
88
+ with its git status letter. Deleted lines are commentable too — the most useful
89
+ review note is often about the code someone removed. A file deleted outright is
90
+ shown in full, read back out of `HEAD`:
91
+
92
+ ![The app showing a deleted file, every line rendered as a removal, recovered from HEAD because the file is no longer on disk](docs/screenshot-deleted.png)
93
+
94
+ ## Reviewing
95
+
96
+ | Action | How |
97
+ |---|---|
98
+ | Comment on a line | Click the line number |
99
+ | Comment on a snippet | Hold `Cmd/Ctrl`, select code, then click the line number |
100
+ | Reply to a comment | Click **Reply** on it |
101
+ | Edit or delete | Hover the comment |
102
+ | Finish the review | Click **Submit Review** |
103
+
104
+ | Shortcut | Action |
105
+ |---|---|
106
+ | `Cmd/Ctrl+Enter` | Save the comment being written |
107
+ | `Escape` | Cancel input |
108
+ | `↑` / `↓` | Move between files |
109
+
110
+ ## What it writes
111
+
112
+ Two files, both under `reviews/`, both plain text you can read without this
113
+ app:
114
+
115
+ - **`.code-review-comments-<repo>.json`** — the live state of the review. It is
116
+ written on every edit and is the source of truth; reopening the same
117
+ repository restores exactly what is in it.
118
+ - **`review_<repo>_<timestamp>.txt`** — a submitted review, rendered from that
119
+ JSON as Markdown, grouped by file and ordered by line:
120
+
121
+ ````markdown
122
+ # Code Review
123
+
124
+ Repository: /work/my-app
125
+ Generated: 2026-08-10T12:30:45.678Z
126
+ Total Comments: 2
127
+
128
+ ## src/auth.js
129
+
130
+ **Line 42:**
131
+ ```
132
+ const token = req.headers.authorization;
133
+ ```
134
+ This trusts the header without checking the scheme.
135
+
136
+ **Follow-ups:**
137
+ 1. Still open after the rebase (2026-08-10T14:02:11.000Z)
138
+ ````
139
+
140
+ Timestamps are ISO 8601 rather than a locale format on purpose: a review gets
141
+ committed, pasted into a pull request, and read on someone else's machine, so
142
+ its shape should not depend on the reader.
143
+
144
+ Submitting shows you exactly what was written, ready to download or copy:
145
+
146
+ ![The Review Submitted dialog showing the rendered Markdown review, with a download button naming the file review_api-service_2026-08-10_17-40-48-989Z.txt](docs/screenshot-review.png)
147
+
148
+ ## Handing the review to a coding agent
149
+
150
+ A review is only half the work; applying it is the other half. `reviewer
151
+ export` prints the review you saved — no server, no submit step — either as
152
+ JSON or as instructions ready to pipe:
153
+
154
+ ```bash
155
+ reviewer export . --format prompt | claude -p "Apply this review to the repo."
156
+ reviewer export . | jq '.comments[].file'
157
+ ```
158
+
159
+ Every comment carries an `anchor`: the exact text of the line it was left on.
160
+ Line numbers go stale the moment an agent makes its first edit, because
161
+ everything below shifts — so the anchor is what lets a comment still be found.
162
+ The prompt format says so to the agent explicitly, and tells it to report
163
+ rather than guess when an anchor has vanished.
164
+
165
+ ```json
166
+ {
167
+ "schema": "code-review/v1",
168
+ "repository": { "path": "/work/api-service", "head": "abe3b37", "branch": "main" },
169
+ "summary": { "comments": 2, "files": 1 },
170
+ "comments": [
171
+ {
172
+ "id": "src/auth.js:10",
173
+ "file": "src/auth.js",
174
+ "line": 10,
175
+ "anchor": " if (scheme !== SCHEME || !token) return null;",
176
+ "body": "Reject an empty token too.",
177
+ "followUps": [{ "body": "Still open after the rebase", "at": "2026-08-10T14:00:00.000Z" }]
178
+ }
179
+ ]
180
+ }
181
+ ```
182
+
183
+ Submitting in the UI writes this alongside the `.txt`. The full schema is in
184
+ [docs/agent-format.md](docs/agent-format.md).
185
+
186
+ ## HTTP API
187
+
188
+ The UI is a client of this; nothing is hidden from you.
189
+
190
+ | Endpoint | Purpose |
191
+ |---|---|
192
+ | `GET /api/health` | Liveness, plus the number of open sessions |
193
+ | `POST /api/load-repo` | Open a repository; returns a `repoId` and the changed files |
194
+ | `GET /api/file/:repoId/:path` | The file's diff, as structured lines |
195
+ | `GET /api/file-full/:repoId/:path` | Every line of the file, for context |
196
+ | `POST /api/save-comments` | Persist the current comments |
197
+ | `GET /api/load-comments/:repoId` | Restore saved comments |
198
+ | `POST /api/submit-review` | Render the saved comments to a review file |
199
+ | `DELETE /api/cleanup/:repoId` | End the session; files on disk are untouched |
200
+
201
+ A `repoId` is a random per-session handle, not a path. Requests that resolve
202
+ outside the opened repository are refused, and a path that names nothing gets a
203
+ 404.
204
+
205
+ `npm run serve` starts the server on its own, without opening a browser.
206
+
207
+ ## Security
208
+
209
+ The server binds to `127.0.0.1`. It reads any file in the repository you
210
+ opened, so reaching it should require being on the machine running it — set
211
+ `HOST=0.0.0.0` only if you mean it. Every path that reaches the filesystem is
212
+ resolved through a check that it lands inside that repository, so a request for
213
+ `..%2f..%2fetc%2fpasswd` is refused rather than served.
214
+
215
+ If you find a way past that, please report it through
216
+ [security advisories](https://github.com/dheerajjha/reviewer/security/advisories/new)
217
+ rather than a public issue.
218
+
219
+ ## Development
220
+
221
+ ```bash
222
+ npm install # 3 dependencies, no build step, ~6MB
223
+ npm test # 163 tests
224
+ npm run test:watch
225
+ npm run test:coverage
226
+ ```
227
+
228
+ ```
229
+ bin/reviewer.js the command: parse args, start server, open browser
230
+ server.js Express app factory and routes
231
+ lib/
232
+ cli.js argument parsing and URL building
233
+ browser.js opening a URL on each platform
234
+ diff.js unified diff -> structured lines
235
+ paths.js confines request paths to the repository
236
+ changes.js git status and commit summaries -> changed files
237
+ comments.js the persisted comment shape
238
+ review.js rendering and naming review files
239
+ sessions.js repoId -> repository
240
+ public/ UI: index.html, style.css, app.js
241
+ test/ Node test runner; HTTP tests drive real git repositories
242
+ ```
243
+
244
+ The request handlers hold no logic worth testing — it lives in `lib/`, and
245
+ `server.js` exports `createApp()` so a test can point an instance at a
246
+ temporary directory. The HTTP tests do not stub git: each one builds a real
247
+ repository in a temp directory and runs real `git` against it, because diff
248
+ parsing is exactly where a stub would be wrong in the same way the code is.
249
+
250
+ Tests run on Linux and macOS across Node 18, 20, and 22. See
251
+ [CONTRIBUTING.md](CONTRIBUTING.md).
252
+
253
+ ## Tech
254
+
255
+ Express, [simple-git](https://github.com/steveukx/git-js), and vanilla
256
+ JavaScript in the browser. Three runtime dependencies, no dev dependencies, no
257
+ build step for the frontend, no test framework — the tests use the one built
258
+ into Node.
259
+
260
+ Version 2.0 dropped the Electron wrapper. It added a few hundred megabytes and
261
+ a per-platform build pipeline to put a browser engine around a page your
262
+ browser already renders. See [CHANGELOG.md](CHANGELOG.md).
263
+
264
+ ## Contributing
265
+
266
+ There is a list of [open issues](https://github.com/dheerajjha/reviewer/issues),
267
+ including some tagged
268
+ [good first issue](https://github.com/dheerajjha/reviewer/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
269
+ The ones most likely to change how the tool feels:
270
+
271
+ - [#1](https://github.com/dheerajjha/reviewer/issues/1) — reviews are stored inside the install rather than with the repository
272
+ - [#2](https://github.com/dheerajjha/reviewer/issues/2) — give comments a state, so a review can be worked through and marked off
273
+ - [#3](https://github.com/dheerajjha/reviewer/issues/3) — report whether each anchor still matches the file
274
+ - [#4](https://github.com/dheerajjha/reviewer/issues/4) — expose the review over MCP, so an agent works through it interactively
275
+ - [#6](https://github.com/dheerajjha/reviewer/issues/6) — review a commit range, not just the working tree
276
+
277
+ [CONTRIBUTING.md](CONTRIBUTING.md) covers the setup and what a good change looks
278
+ like here.
279
+
280
+ ## License
281
+
282
+ [MIT](LICENSE) © Dheeraj Jha
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs').promises;
5
+ const path = require('path');
6
+
7
+ const simpleGit = require('simple-git');
8
+
9
+ const { parseArgs, buildUrl, UsageError, USAGE } = require('../lib/cli');
10
+ const { openInBrowser } = require('../lib/browser');
11
+ const { buildReviewDocument, filterReviewDocument, formatPrompt } = require('../lib/agent');
12
+ const { readSavedComments, ReviewOwnershipError } = require('../lib/store');
13
+ const { startServer, DEFAULT_PORT, DEFAULT_HOST, REVIEWS_DIR } = require('../server');
14
+ const { version } = require('../package.json');
15
+
16
+ /**
17
+ * The `reviewer` command: start the local server and open it in a browser.
18
+ */
19
+
20
+ /**
21
+ * Listen on `port`, falling back to any free port if it is taken.
22
+ *
23
+ * Someone reviewing two repositories at once should get a second window, not
24
+ * an EADDRINUSE stack trace.
25
+ *
26
+ * @param {number} port
27
+ * @returns {Promise<import('http').Server>}
28
+ */
29
+ async function listen(port) {
30
+ try {
31
+ return await startServer({ port, host: DEFAULT_HOST, silent: true });
32
+ } catch (error) {
33
+ if (error.code !== 'EADDRINUSE') throw error;
34
+
35
+ console.log(`Port ${port} is in use, picking another.`);
36
+ return startServer({ port: 0, host: DEFAULT_HOST, silent: true });
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Print the saved review of a repository to stdout.
42
+ *
43
+ * Reads the same JSON the UI writes on every edit, so a review can be handed
44
+ * to another tool without submitting it or keeping the server running.
45
+ *
46
+ * @param {import('../lib/cli').CliOptions} options
47
+ */
48
+ async function exportReview(options) {
49
+ const repoPath = options.repoPath ?? process.cwd();
50
+
51
+ let comments;
52
+ try {
53
+ ({ comments } = await readSavedComments(REVIEWS_DIR, repoPath));
54
+ } catch (error) {
55
+ if (error instanceof ReviewOwnershipError) throw new UsageError(error.message);
56
+ throw error;
57
+ }
58
+
59
+ if (comments.length === 0) {
60
+ throw new UsageError(`No saved review for ${repoPath}. Review it first, then export.`);
61
+ }
62
+
63
+ // Recording the commit lets a consumer tell whether the tree has moved on
64
+ // since the review was written. Neither is fatal if git will not say.
65
+ const git = simpleGit(repoPath);
66
+ const head = await git.revparse(['HEAD']).then(sha => sha.trim()).catch(() => null);
67
+ const branch = await git.revparse(['--abbrev-ref', 'HEAD']).then(name => name.trim()).catch(() => null);
68
+
69
+ const document = filterReviewDocument(
70
+ buildReviewDocument({
71
+ repoPath,
72
+ comments,
73
+ generatedAt: new Date(),
74
+ head,
75
+ branch
76
+ }),
77
+ { file: options.file }
78
+ );
79
+
80
+ if (document.comments.length === 0) {
81
+ throw new UsageError(`The saved review for ${repoPath} has no comments for ${options.file}.`);
82
+ }
83
+
84
+ process.stdout.write(
85
+ options.format === 'prompt'
86
+ ? formatPrompt(document)
87
+ : `${JSON.stringify(document, null, 2)}\n`
88
+ );
89
+ }
90
+
91
+ async function main() {
92
+ const options = parseArgs(process.argv.slice(2));
93
+
94
+ if (options.help) {
95
+ process.stdout.write(USAGE);
96
+ return;
97
+ }
98
+
99
+ if (options.version) {
100
+ console.log(version);
101
+ return;
102
+ }
103
+
104
+ if (options.repoPath) {
105
+ try {
106
+ await fs.access(options.repoPath);
107
+ } catch {
108
+ throw new UsageError(`No such directory: ${options.repoPath}`);
109
+ }
110
+ }
111
+
112
+ if (options.command === 'export') {
113
+ return exportReview(options);
114
+ }
115
+
116
+ const server = await listen(options.port ?? (Number(process.env.PORT) || DEFAULT_PORT));
117
+ const url = buildUrl(`http://${DEFAULT_HOST}:${server.address().port}`, options.repoPath);
118
+
119
+ console.log(`\n Code Reviewer ${url}`);
120
+ if (options.repoPath) console.log(` reviewing ${options.repoPath}`);
121
+ console.log('\n Press Ctrl+C to stop.\n');
122
+
123
+ if (options.open && !(await openInBrowser(url))) {
124
+ console.log(' Could not open a browser — open the URL above yourself.\n');
125
+ }
126
+
127
+ const stop = () => {
128
+ server.close(() => process.exit(0));
129
+ // Don't wait forever on a browser holding the connection open.
130
+ setTimeout(() => process.exit(0), 2000).unref();
131
+ };
132
+
133
+ process.on('SIGINT', stop);
134
+ process.on('SIGTERM', stop);
135
+ }
136
+
137
+ main().catch(error => {
138
+ if (error instanceof UsageError) {
139
+ console.error(`error: ${error.message}\n`);
140
+ process.stderr.write(USAGE);
141
+ process.exit(2);
142
+ }
143
+
144
+ console.error(error);
145
+ process.exit(1);
146
+ });
package/lib/agent.js ADDED
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ const { groupByFile } = require('./comments');
4
+ const { repoSlug } = require('./review');
5
+
6
+ /**
7
+ * The machine-readable form of a review.
8
+ *
9
+ * The `.txt` review is written for a person to read. A coding agent asked to
10
+ * act on a review needs something else: a stable schema, and above all an
11
+ * *anchor* — the exact text of the line a comment was left on. Line numbers
12
+ * are the first thing to go stale, because the agent's own first edit shifts
13
+ * every line below it. The anchor is what lets a comment still be found after
14
+ * that.
15
+ */
16
+
17
+ const SCHEMA = 'code-review/v1';
18
+
19
+ /**
20
+ * @typedef {object} ReviewDocument
21
+ * @property {string} schema
22
+ * @property {string} generatedAt ISO 8601
23
+ * @property {{path: string, name: string, head: string|null, branch: string|null}} repository
24
+ * @property {'working'|'lastCommit'|null} mode
25
+ * @property {{comments: number, files: number}} summary
26
+ * @property {AgentComment[]} comments
27
+ */
28
+
29
+ /**
30
+ * @typedef {object} AgentComment
31
+ * @property {string} id stable within a document
32
+ * @property {string} file repo-relative path
33
+ * @property {number} line line number when the comment was written
34
+ * @property {string|null} anchor the exact source line, for relocating the comment
35
+ * @property {string} [selection] the snippet the reviewer highlighted
36
+ * @property {string} body
37
+ * @property {Array<{body: string, at?: string}>} [followUps]
38
+ */
39
+
40
+ /**
41
+ * Build the machine-readable review document.
42
+ *
43
+ * @param {object} args
44
+ * @param {string} args.repoPath
45
+ * @param {import('./comments').Comment[]} args.comments
46
+ * @param {Date} args.generatedAt
47
+ * @param {string|null} [args.head] the commit the review was written against
48
+ * @param {string|null} [args.branch]
49
+ * @param {string|null} [args.mode]
50
+ * @returns {ReviewDocument}
51
+ */
52
+ function buildReviewDocument({ repoPath, comments, generatedAt, head = null, branch = null, mode = null }) {
53
+ const grouped = groupByFile(comments);
54
+
55
+ return {
56
+ schema: SCHEMA,
57
+ generatedAt: generatedAt.toISOString(),
58
+ repository: {
59
+ path: repoPath,
60
+ name: repoSlug(repoPath),
61
+ head,
62
+ branch
63
+ },
64
+ mode,
65
+ summary: {
66
+ comments: comments.length,
67
+ files: grouped.length
68
+ },
69
+ comments: grouped.flatMap(([, fileComments]) => fileComments.map(toAgentComment))
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Select comments from an already-built document and keep its summary honest.
75
+ *
76
+ * @param {ReviewDocument} document
77
+ * @param {object} filters
78
+ * @param {string|null} [filters.file]
79
+ * @returns {ReviewDocument}
80
+ */
81
+ function filterReviewDocument(document, { file = null } = {}) {
82
+ if (file === null) return document;
83
+
84
+ const comments = document.comments.filter(comment => comment.file === file);
85
+ return {
86
+ ...document,
87
+ summary: {
88
+ comments: comments.length,
89
+ files: new Set(comments.map(comment => comment.file)).size
90
+ },
91
+ comments
92
+ };
93
+ }
94
+
95
+ /**
96
+ * @param {import('./comments').Comment} comment
97
+ * @returns {AgentComment}
98
+ */
99
+ function toAgentComment(comment) {
100
+ const entry = {
101
+ // File and line identify a comment uniquely — the UI anchors one comment
102
+ // per line — and staying derived keeps ids stable across exports.
103
+ id: `${comment.file}:${comment.line}`,
104
+ file: comment.file,
105
+ line: comment.line,
106
+ anchor: comment.lineContent ?? null,
107
+ body: comment.text
108
+ };
109
+
110
+ if (comment.selectedText) entry.selection = comment.selectedText;
111
+
112
+ if (comment.followUps?.length > 0) {
113
+ entry.followUps = comment.followUps.map(followUp => {
114
+ const item = { body: followUp.text };
115
+ if (followUp.timestamp) item.at = followUp.timestamp;
116
+ return item;
117
+ });
118
+ }
119
+
120
+ return entry;
121
+ }
122
+
123
+ /**
124
+ * Render a review as instructions for a coding agent.
125
+ *
126
+ * This is the format meant to be piped straight into a tool like Claude Code.
127
+ * It leads with what to do and how to locate each comment, because an agent
128
+ * reads top-down and the instruction has to arrive before the data it governs.
129
+ *
130
+ * @param {ReviewDocument} document
131
+ * @returns {string}
132
+ */
133
+ function formatPrompt(document) {
134
+ const { repository, summary } = document;
135
+ const lines = [
136
+ '# Code review to address',
137
+ '',
138
+ `Repository: ${repository.path}`,
139
+ repository.head ? `Reviewed at commit: ${repository.head}` : null,
140
+ `${summary.comments} comment(s) across ${summary.files} file(s), written ${document.generatedAt}.`,
141
+ '',
142
+ 'Work through every comment below and make the change it asks for, or, if',
143
+ 'you disagree, say why and leave the code alone. Comments are grouped by',
144
+ 'file and ordered by line.',
145
+ '',
146
+ 'Line numbers are from when the review was written, so treat them as a',
147
+ 'hint: locate each comment by its `anchor` line instead, since your own',
148
+ 'edits will shift everything below them. If an anchor no longer appears in',
149
+ 'the file, say so rather than guessing at the intended location.',
150
+ ''
151
+ ].filter(line => line !== null);
152
+
153
+ let currentFile = null;
154
+ for (const comment of document.comments) {
155
+ if (comment.file !== currentFile) {
156
+ currentFile = comment.file;
157
+ lines.push(`## ${currentFile}`, '');
158
+ }
159
+
160
+ lines.push(`### ${comment.id}`, '');
161
+ if (comment.anchor !== null) {
162
+ lines.push('Anchor line:', '', '```', comment.anchor, '```', '');
163
+ }
164
+ if (comment.selection) {
165
+ lines.push('Selected code:', '', '```', comment.selection, '```', '');
166
+ }
167
+
168
+ lines.push(comment.body, '');
169
+
170
+ if (comment.followUps?.length > 0) {
171
+ lines.push('Follow-ups:', '');
172
+ comment.followUps.forEach((followUp, index) => {
173
+ lines.push(`${index + 1}. ${followUp.body}`);
174
+ });
175
+ lines.push('');
176
+ }
177
+ }
178
+
179
+ return `${lines.join('\n')}`;
180
+ }
181
+
182
+ module.exports = { buildReviewDocument, filterReviewDocument, formatPrompt, SCHEMA };
package/lib/browser.js ADDED
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const childProcess = require('child_process');
4
+
5
+ /**
6
+ * Opening a URL in the user's default browser.
7
+ */
8
+
9
+ /**
10
+ * The command that opens a URL on this platform.
11
+ *
12
+ * @param {string} platform a `process.platform` value
13
+ * @param {string} url
14
+ * @returns {{command: string, args: string[]}}
15
+ */
16
+ function openCommand(platform, url) {
17
+ switch (platform) {
18
+ case 'darwin':
19
+ return { command: 'open', args: [url] };
20
+ case 'win32':
21
+ // `start` is a cmd builtin, not an executable. Its first quoted argument
22
+ // is taken as the window title, so an empty one has to come first or the
23
+ // URL itself would be swallowed as the title.
24
+ return { command: 'cmd', args: ['/c', 'start', '', url] };
25
+ default:
26
+ return { command: 'xdg-open', args: [url] };
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Open a URL in the default browser.
32
+ *
33
+ * Failure here is not worth interrupting anyone over — a headless machine has
34
+ * no browser to open, and the caller has already printed the URL — so this
35
+ * resolves either way and reports what happened.
36
+ *
37
+ * @param {string} url
38
+ * @param {object} [options]
39
+ * @param {string} [options.platform] defaults to the running platform
40
+ * @param {typeof childProcess.spawn} [options.spawn] injectable for tests
41
+ * @returns {Promise<boolean>} whether the opener was launched
42
+ */
43
+ function openInBrowser(url, options = {}) {
44
+ const { platform = process.platform, spawn = childProcess.spawn } = options;
45
+ const { command, args } = openCommand(platform, url);
46
+
47
+ return new Promise(resolve => {
48
+ try {
49
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
50
+
51
+ // A missing opener fails asynchronously — spawn does not throw for it —
52
+ // so success has to wait for the 'spawn' event rather than assume it.
53
+ // Reporting success either way would make the caller's fallback message
54
+ // unreachable on exactly the machines that need it.
55
+ child.on('error', () => resolve(false));
56
+ child.on('spawn', () => {
57
+ // Let the opener outlive this process rather than hold the event loop.
58
+ child.unref();
59
+ resolve(true);
60
+ });
61
+ } catch {
62
+ resolve(false);
63
+ }
64
+ });
65
+ }
66
+
67
+ module.exports = { openInBrowser, openCommand };