git-reviewer 2.14.0 → 2.14.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 +30 -2
- package/lib/export.js +1 -1
- package/lib/repo.js +1 -1
- package/package.json +2 -2
- package/server.js +76 -20
package/README.md
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
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
|
+
**English** · [简体中文](docs/README.zh-CN.md) · [日本語](docs/README.ja.md)
|
|
10
|
+
|
|
9
11
|
When an agent writes the code, reading it becomes the bottleneck — and the tool
|
|
10
12
|
built for reading a diff, the pull request, wants a branch, a remote and a push
|
|
11
13
|
you are not ready to make yet.
|
|
@@ -341,6 +343,32 @@ rather than guess when an anchor has vanished.
|
|
|
341
343
|
Submitting in the UI writes this alongside the `.txt`. The full schema is in
|
|
342
344
|
[docs/agent-format.md](docs/agent-format.md).
|
|
343
345
|
|
|
346
|
+
## Use it from inside your agent
|
|
347
|
+
|
|
348
|
+
Installed as a plugin, the agent opens the review itself, waits while you read
|
|
349
|
+
and comment, and works through your comments the moment you press **Submit
|
|
350
|
+
Review**. You never leave the conversation to start it.
|
|
351
|
+
|
|
352
|
+
```
|
|
353
|
+
/plugin install git-reviewer --marketplace dheerajjha/reviewer
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
That is Claude Code; then ask to review the changes, or run
|
|
357
|
+
`/git-reviewer:review`. The same skill installs elsewhere:
|
|
358
|
+
|
|
359
|
+
| Agent | Install |
|
|
360
|
+
|---|---|
|
|
361
|
+
| Codex CLI | `codex plugin marketplace add dheerajjha/reviewer`, then `codex plugin add git-reviewer@git-reviewer` |
|
|
362
|
+
| Cursor | **Customize** → **From GitHub Repository** → `dheerajjha/reviewer` |
|
|
363
|
+
| Copilot CLI | `copilot plugin marketplace add dheerajjha/reviewer`, then `copilot plugin install git-reviewer@git-reviewer` |
|
|
364
|
+
| Anything else | `npx skills add dheerajjha/reviewer` |
|
|
365
|
+
|
|
366
|
+
The skill is plain instructions, [skills/review/SKILL.md](skills/review/SKILL.md):
|
|
367
|
+
start `git reviewer` in the background, wait for the submit, then apply every
|
|
368
|
+
comment, finding the code a comment quotes rather than trusting its line
|
|
369
|
+
number. Where an agent cannot leave a command running, it asks you to start
|
|
370
|
+
`git reviewer` yourself and reads the result with `git reviewer export`.
|
|
371
|
+
|
|
344
372
|
## HTTP API
|
|
345
373
|
|
|
346
374
|
The UI is a client of this; nothing is hidden from you.
|
|
@@ -382,7 +410,7 @@ rather than a public issue.
|
|
|
382
410
|
|
|
383
411
|
```bash
|
|
384
412
|
npm install # 3 dependencies, no build step, ~6MB
|
|
385
|
-
npm test #
|
|
413
|
+
npm test # 379 tests
|
|
386
414
|
npm run test:watch
|
|
387
415
|
npm run test:coverage
|
|
388
416
|
```
|
package/lib/export.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs').promises;
|
|
4
4
|
const path = require('path');
|
|
5
5
|
|
|
6
|
-
const simpleGit = require('simple-git');
|
|
6
|
+
const { simpleGit } = require('simple-git');
|
|
7
7
|
|
|
8
8
|
const { buildReviewDocument, filterReviewDocument } = require('./agent');
|
|
9
9
|
const { readSavedComments, belongsTo } = require('./store');
|
package/lib/repo.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-reviewer",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.1",
|
|
4
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",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"cors": "^2.8.5",
|
|
53
53
|
"express": "^4.18.2",
|
|
54
|
-
"simple-git": "^
|
|
54
|
+
"simple-git": "^4.0.2"
|
|
55
55
|
},
|
|
56
56
|
"man": [
|
|
57
57
|
"./man/git-reviewer.1"
|
package/server.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const express = require('express');
|
|
4
4
|
const cors = require('cors');
|
|
5
|
-
const simpleGit = require('simple-git');
|
|
5
|
+
const { simpleGit } = require('simple-git');
|
|
6
6
|
const fs = require('fs').promises;
|
|
7
7
|
const path = require('path');
|
|
8
8
|
|
|
@@ -26,6 +26,39 @@ const { listReviews } = require('./lib/export');
|
|
|
26
26
|
const DEFAULT_PORT = 4500;
|
|
27
27
|
const DEFAULT_HOST = '127.0.0.1';
|
|
28
28
|
|
|
29
|
+
// Addresses that only this machine can reach. Bound to one of these, the
|
|
30
|
+
// server installs `loopbackHostOnly`.
|
|
31
|
+
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
32
|
+
|
|
33
|
+
// The names a browser puts in `Host` for a server it reached on loopback.
|
|
34
|
+
// An IPv6 address keeps its brackets there.
|
|
35
|
+
const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '[::1]']);
|
|
36
|
+
|
|
37
|
+
/** The host name in a `Host` header, without the port. */
|
|
38
|
+
function hostnameOf(header) {
|
|
39
|
+
const value = String(header || '').toLowerCase();
|
|
40
|
+
if (value.startsWith('[')) return value.slice(0, value.indexOf(']') + 1);
|
|
41
|
+
return value.split(':')[0];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Refuse a request addressed to this server by any name but a loopback one.
|
|
46
|
+
*
|
|
47
|
+
* That is DNS rebinding: a page on the attacker's own domain whose name is
|
|
48
|
+
* re-pointed at 127.0.0.1 after it loads. The browser then treats this server
|
|
49
|
+
* as that site, so `Origin` and `Host` agree and `sameOriginOnly` lets the
|
|
50
|
+
* request through. A server reachable only on loopback is only ever honestly
|
|
51
|
+
* addressed by a loopback name. Bound anywhere else (`HOST=0.0.0.0`), its
|
|
52
|
+
* owner has chosen to be reached by other names, and this is not installed.
|
|
53
|
+
*/
|
|
54
|
+
function loopbackHostOnly(req, res, next) {
|
|
55
|
+
if (!LOOPBACK_HOSTNAMES.has(hostnameOf(req.headers.host))) {
|
|
56
|
+
return res.status(403).json({ error: 'Not available under this host name' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
next();
|
|
60
|
+
}
|
|
61
|
+
|
|
29
62
|
|
|
30
63
|
/** A path inside the repository that names nothing, on disk or at HEAD. */
|
|
31
64
|
class FileNotFoundError extends Error {
|
|
@@ -60,6 +93,8 @@ class FileNotFoundError extends Error {
|
|
|
60
93
|
* stop. Omitted means never: a server with no one to tell keeps serving.
|
|
61
94
|
* @param {number} [options.idleGraceMs] how long to wait after the last tab
|
|
62
95
|
* leaves before deciding it is not coming back.
|
|
96
|
+
* @param {boolean} [options.loopbackOnly] refuse any request whose `Host` is
|
|
97
|
+
* not a loopback name. `startServer` turns it on when it binds to loopback.
|
|
63
98
|
* @returns {import('express').Express}
|
|
64
99
|
*/
|
|
65
100
|
// How far back the range picker offers to go. Long enough to cover the work
|
|
@@ -86,12 +121,15 @@ function createApp(options = {}) {
|
|
|
86
121
|
onReviewSubmitted = null,
|
|
87
122
|
handoff = false,
|
|
88
123
|
onIdle = null,
|
|
89
|
-
idleGraceMs = 5000
|
|
124
|
+
idleGraceMs = 5000,
|
|
125
|
+
loopbackOnly = false
|
|
90
126
|
} = options;
|
|
91
127
|
|
|
92
128
|
const app = express();
|
|
93
129
|
|
|
130
|
+
if (loopbackOnly) app.use(loopbackHostOnly);
|
|
94
131
|
app.use(cors());
|
|
132
|
+
app.use('/api', sameOriginOnly);
|
|
95
133
|
app.use(express.json({ limit: '10mb' }));
|
|
96
134
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
97
135
|
|
|
@@ -660,20 +698,37 @@ function createApp(options = {}) {
|
|
|
660
698
|
/**
|
|
661
699
|
* Refuse a request a browser has marked as coming from somewhere else.
|
|
662
700
|
*
|
|
663
|
-
* `
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
701
|
+
* Every `/api` route goes through this (#66). Until 2.14.1 only the routes
|
|
702
|
+
* that enumerate directories did, on the reasoning that the rest answer
|
|
703
|
+
* about a repository whose path the caller already had. A path is easy to
|
|
704
|
+
* guess, though, and `cors()` is open, so a page on another site could read
|
|
705
|
+
* any file in a guessed repository. Worse, it could save a comment into the
|
|
706
|
+
* review, which `reviewer export` then hands to the coding agent as an
|
|
707
|
+
* instruction.
|
|
708
|
+
*
|
|
709
|
+
* A browser sends `Origin` on every cross-origin request that could do
|
|
710
|
+
* either, and leaves it out of the page's own same-origin GETs, so the page
|
|
711
|
+
* keeps working. A client that is not a browser sends no `Origin` and gets
|
|
712
|
+
* through: it could read these files without asking this server anyway.
|
|
713
|
+
* Only the host is compared, not the scheme, so the page still works behind
|
|
714
|
+
* a proxy that terminates TLS.
|
|
715
|
+
*
|
|
716
|
+
* This cannot catch DNS rebinding, where `Origin` and `Host` both name the
|
|
717
|
+
* attacker's domain. `loopbackHostOnly` does.
|
|
671
718
|
*/
|
|
672
719
|
function sameOriginOnly(req, res, next) {
|
|
673
720
|
const origin = req.get('origin');
|
|
674
721
|
|
|
675
|
-
if (origin
|
|
676
|
-
|
|
722
|
+
if (origin) {
|
|
723
|
+
let originHost = null;
|
|
724
|
+
try {
|
|
725
|
+
originHost = new URL(origin).host;
|
|
726
|
+
} catch {
|
|
727
|
+
// `Origin: null` (a sandboxed frame, a file:// page) names no host.
|
|
728
|
+
}
|
|
729
|
+
if (originHost !== req.headers.host) {
|
|
730
|
+
return res.status(403).json({ error: 'Not available to another origin' });
|
|
731
|
+
}
|
|
677
732
|
}
|
|
678
733
|
|
|
679
734
|
next();
|
|
@@ -686,9 +741,9 @@ function createApp(options = {}) {
|
|
|
686
741
|
* The connection *is* the signal. An open page holds this response open and
|
|
687
742
|
* closing the page closes it, so there is no heartbeat interval to tune, and
|
|
688
743
|
* two tabs are two connections rather than one flag that either close
|
|
689
|
-
* clears.
|
|
690
|
-
*
|
|
691
|
-
*
|
|
744
|
+
* clears. Like every `/api` route it goes through `sameOriginOnly`, which
|
|
745
|
+
* matters here too: without it a page on another site could hold this open
|
|
746
|
+
* and keep alive a server its owner had finished with.
|
|
692
747
|
*
|
|
693
748
|
* Nothing happens at the moment a watcher leaves. A reload closes and
|
|
694
749
|
* reopens within milliseconds, and a dropped connection reopens when
|
|
@@ -701,7 +756,7 @@ function createApp(options = {}) {
|
|
|
701
756
|
* after having been above it, so `reviewer` whose browser never launched
|
|
702
757
|
* keeps serving rather than exiting into an empty terminal.
|
|
703
758
|
*/
|
|
704
|
-
app.get('/api/alive',
|
|
759
|
+
app.get('/api/alive', (req, res) => {
|
|
705
760
|
if (idleTimer) {
|
|
706
761
|
clearTimeout(idleTimer);
|
|
707
762
|
idleTimer = null;
|
|
@@ -733,7 +788,7 @@ function createApp(options = {}) {
|
|
|
733
788
|
});
|
|
734
789
|
|
|
735
790
|
/** List the directories inside one, so a repository can be found by looking. */
|
|
736
|
-
app.get('/api/browse',
|
|
791
|
+
app.get('/api/browse', async (req, res) => {
|
|
737
792
|
try {
|
|
738
793
|
res.json(await browse(req.query.path));
|
|
739
794
|
} catch (error) {
|
|
@@ -750,7 +805,7 @@ function createApp(options = {}) {
|
|
|
750
805
|
});
|
|
751
806
|
|
|
752
807
|
/** Repositories opened before now, newest first. */
|
|
753
|
-
app.get('/api/recent',
|
|
808
|
+
app.get('/api/recent', async (req, res) => {
|
|
754
809
|
try {
|
|
755
810
|
// Annotated with what is saved against each, because "3 comments" is
|
|
756
811
|
// what tells you which of two checkouts you were in the middle of.
|
|
@@ -1109,8 +1164,9 @@ function startServer(options = {}) {
|
|
|
1109
1164
|
...appOptions
|
|
1110
1165
|
} = options;
|
|
1111
1166
|
// Everything else in `options` -- reviewsDir, sessions, git, handoff and
|
|
1112
|
-
// onReviewSubmitted -- goes through to the app.
|
|
1113
|
-
|
|
1167
|
+
// onReviewSubmitted -- goes through to the app. Bound to loopback, the app
|
|
1168
|
+
// also refuses any Host but a loopback name (see `loopbackHostOnly`).
|
|
1169
|
+
const app = createApp({ loopbackOnly: LOOPBACK_ADDRESSES.has(host), ...appOptions });
|
|
1114
1170
|
|
|
1115
1171
|
return new Promise((resolve, reject) => {
|
|
1116
1172
|
const server = app.listen(port, host, () => {
|