git-reviewer 2.15.0 → 2.17.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/lib/threads.js ADDED
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * A review comment as a conversation: the reviewer's comment, then follow-ups
5
+ * from either side. An agent connected to a review answers in these threads,
6
+ * so this is where "what is still waiting for an answer" and "what is new
7
+ * since I last looked" are decided -- once, for the page, the channel and the
8
+ * background session alike.
9
+ */
10
+
11
+ /**
12
+ * Who wrote the last word in a thread: null for the reviewer, otherwise the
13
+ * `by` of the last follow-up.
14
+ *
15
+ * @param {import('./comments').Comment} comment
16
+ * @returns {string|null}
17
+ */
18
+ function lastAuthor(comment) {
19
+ const followUps = comment.followUps ?? [];
20
+ return followUps.length === 0 ? null : (followUps[followUps.length - 1].by ?? null);
21
+ }
22
+
23
+ /**
24
+ * Threads whose last word is the reviewer's: what an agent has yet to answer.
25
+ *
26
+ * @template {import('./comments').Comment} T
27
+ * @param {T[]} comments
28
+ * @returns {T[]}
29
+ */
30
+ function awaitingAnswer(comments) {
31
+ return comments.filter(comment => lastAuthor(comment) === null);
32
+ }
33
+
34
+ /** The identity a follow-up is merged by: its key, or what it said and when. */
35
+ function followUpIdentity(followUp) {
36
+ return followUp.key ?? JSON.stringify([followUp.timestamp ?? null, followUp.text, followUp.by ?? null]);
37
+ }
38
+
39
+ /**
40
+ * Every follow-up in either list, once.
41
+ *
42
+ * Follow-ups are only ever added -- nothing in the page or the API removes
43
+ * one -- so a follow-up present on either side is one somebody wrote, and
44
+ * the union is the only merge that cannot lose an answer. `ours` keeps its
45
+ * order; follow-ups only `theirs` holds are placed by timestamp.
46
+ *
47
+ * @param {import('./comments').FollowUp[]} [ours]
48
+ * @param {import('./comments').FollowUp[]} [theirs]
49
+ * @returns {import('./comments').FollowUp[]}
50
+ */
51
+ function unionFollowUps(ours = [], theirs = []) {
52
+ const merged = [...ours];
53
+ const seen = new Set(ours.map(followUpIdentity));
54
+
55
+ for (const followUp of theirs) {
56
+ const identity = followUpIdentity(followUp);
57
+ if (seen.has(identity)) continue;
58
+ seen.add(identity);
59
+
60
+ // After the last entry that is not later than it, so an answer that was
61
+ // written while this side was adding its own lands where it happened.
62
+ const at = followUp.timestamp ?? '';
63
+ let index = merged.length;
64
+ if (at) {
65
+ while (index > 0 && (merged[index - 1].timestamp ?? '') > at) index--;
66
+ }
67
+ merged.splice(index, 0, followUp);
68
+ }
69
+
70
+ return merged;
71
+ }
72
+
73
+ /**
74
+ * Carry over follow-ups a save never saw.
75
+ *
76
+ * A tab saves the whole list it holds. An agent may have answered in a thread
77
+ * after that tab last heard from the server; saving the tab's copy of the
78
+ * thread as it stands would erase the answer. For every comment present both
79
+ * in the save and on disk, the follow-ups on disk are kept alongside the
80
+ * tab's.
81
+ *
82
+ * @param {import('./comments').Comment[]} incoming keyed comments being saved
83
+ * @param {import('./comments').Comment[]} current keyed comments on disk
84
+ * @returns {import('./comments').Comment[]}
85
+ */
86
+ function keepFollowUpsFrom(incoming, current) {
87
+ const onDisk = new Map(current.map(comment => [comment.key, comment]));
88
+
89
+ return incoming.map(comment => {
90
+ const stored = onDisk.get(comment.key);
91
+ if (!stored?.followUps?.length) return comment;
92
+
93
+ const followUps = unionFollowUps(comment.followUps, stored.followUps);
94
+ return followUps.length === (comment.followUps?.length ?? 0) ? comment : { ...comment, followUps };
95
+ });
96
+ }
97
+
98
+ /**
99
+ * What the reviewer has said in `next` that was not in `prev`: new comments,
100
+ * and new follow-ups the reviewer wrote. Each comes back once, keyed, so an
101
+ * agent can be told about it exactly once.
102
+ *
103
+ * @param {import('./comments').Comment[]} prev keyed
104
+ * @param {import('./comments').Comment[]} next keyed
105
+ * @returns {Array<{comment: import('./comments').Comment, followUp: import('./comments').FollowUp|null}>}
106
+ */
107
+ function newReviewerMessages(prev, next) {
108
+ const before = new Map(prev.map(comment => [comment.key, comment]));
109
+ const messages = [];
110
+
111
+ for (const comment of next) {
112
+ const old = before.get(comment.key);
113
+ if (!old) {
114
+ messages.push({ comment, followUp: null });
115
+ }
116
+ const known = new Set((old?.followUps ?? []).map(followUpIdentity));
117
+ for (const followUp of comment.followUps ?? []) {
118
+ if (followUp.by || known.has(followUpIdentity(followUp))) continue;
119
+ // A brand-new comment arriving with follow-ups already on it is one
120
+ // message, not several: the agent is told about the thread once.
121
+ if (!old) continue;
122
+ messages.push({ comment, followUp });
123
+ }
124
+ }
125
+
126
+ return messages;
127
+ }
128
+
129
+ /**
130
+ * The thread an agent is being asked to answer, as plain text.
131
+ *
132
+ * Shared by every way an agent can be connected, so a background session and
133
+ * a live one are asked the same question in the same words.
134
+ *
135
+ * @param {import('./comments').Comment} comment
136
+ * @returns {string}
137
+ */
138
+ function describeThread(comment) {
139
+ const lines = [`${comment.file}, line ${comment.line}`];
140
+ if (comment.lineContent !== undefined && comment.lineContent !== null && comment.lineContent !== '') {
141
+ lines.push(`Line: ${comment.lineContent.trim()}`);
142
+ }
143
+ if (comment.selectedText) {
144
+ lines.push(`Selected: ${comment.selectedText.trim()}`);
145
+ }
146
+ lines.push('', `Reviewer: ${comment.text}`);
147
+ for (const followUp of comment.followUps ?? []) {
148
+ lines.push(`${followUp.by ? `${followUp.by} (you)` : 'Reviewer'}: ${followUp.text}`);
149
+ }
150
+ return lines.join('\n');
151
+ }
152
+
153
+ /**
154
+ * Append an agent's answer to a thread.
155
+ *
156
+ * @param {import('./comments').Comment[]} comments keyed
157
+ * @param {string} key comment to answer
158
+ * @param {import('./comments').FollowUp} reply
159
+ * @returns {import('./comments').Comment[]|null} the new list, or null when no comment has that key
160
+ */
161
+ function appendReply(comments, key, reply) {
162
+ let found = false;
163
+ const updated = comments.map(comment => {
164
+ if (comment.key !== key) return comment;
165
+ found = true;
166
+ return { ...comment, followUps: [...(comment.followUps ?? []), reply] };
167
+ });
168
+ return found ? updated : null;
169
+ }
170
+
171
+ module.exports = {
172
+ lastAuthor,
173
+ awaitingAnswer,
174
+ unionFollowUps,
175
+ keepFollowUpsFrom,
176
+ newReviewerMessages,
177
+ describeThread,
178
+ appendReply,
179
+ followUpIdentity
180
+ };
package/lib/watch.js ADDED
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { readSavedComments } = require('./store');
7
+ const { commentsFilename } = require('./review');
8
+
9
+ /**
10
+ * Hearing about a review changing, whoever changed it.
11
+ *
12
+ * The comments file is the source of truth and, now that agents answer in
13
+ * threads, it has writers other than the page: `reviewer channel` running
14
+ * inside a Claude Code session, `reviewer reply`, a background session the
15
+ * server started. Each of them writes the file; everyone interested watches
16
+ * it. That keeps the writers from needing to know who is listening, or where.
17
+ *
18
+ * `fs.watch` gives the change within milliseconds where the platform supports
19
+ * it. A slow poll runs alongside, because there are filesystems where
20
+ * `fs.watch` is silently deaf -- network mounts, some containers -- and a
21
+ * reply that never shows up is worse than one that shows up a second late.
22
+ */
23
+
24
+ const DEBOUNCE_MS = 25;
25
+ const POLL_MS = 1500;
26
+
27
+ /**
28
+ * Watch one repository's saved comments.
29
+ *
30
+ * `onChange` is called with what was read whenever `lastUpdated` moves, and
31
+ * once at the start with the state as it stands, so a caller never has to
32
+ * read separately and race the first change.
33
+ *
34
+ * @param {string} reviewsDir
35
+ * @param {string} repoPath
36
+ * @param {(state: import('./store').SavedReview) => void} onChange
37
+ * @param {object} [options]
38
+ * @param {number} [options.pollMs]
39
+ * @param {(error: Error) => void} [options.onError] a file that cannot be read or parsed; the watch carries on
40
+ * @returns {{close: () => void, ready: Promise<void>}}
41
+ */
42
+ function watchComments(reviewsDir, repoPath, onChange, { pollMs = POLL_MS, onError = () => {} } = {}) {
43
+ const filename = commentsFilename(repoPath);
44
+ const file = path.join(reviewsDir, filename);
45
+
46
+ let closed = false;
47
+ let lastSeen;
48
+ let lastStat = null;
49
+ let timer = null;
50
+ let reading = null;
51
+ let again = false;
52
+
53
+ const check = async () => {
54
+ if (closed) return;
55
+ if (reading) {
56
+ again = true;
57
+ return reading;
58
+ }
59
+ reading = (async () => {
60
+ try {
61
+ const state = await readSavedComments(reviewsDir, repoPath);
62
+ if (closed) return;
63
+ if (state.lastUpdated !== lastSeen) {
64
+ lastSeen = state.lastUpdated;
65
+ onChange(state);
66
+ }
67
+ } catch (error) {
68
+ // A file caught mid-replace by a writer that is not atomic, or one
69
+ // hand-edited into invalid JSON. Say so and keep watching: the next
70
+ // write will be readable.
71
+ if (!closed) onError(error);
72
+ }
73
+ })();
74
+ try {
75
+ await reading;
76
+ } finally {
77
+ reading = null;
78
+ if (again && !closed) {
79
+ again = false;
80
+ check();
81
+ }
82
+ }
83
+ };
84
+
85
+ const schedule = () => {
86
+ if (closed) return;
87
+ clearTimeout(timer);
88
+ timer = setTimeout(check, DEBOUNCE_MS);
89
+ };
90
+
91
+ let watcher = null;
92
+ try {
93
+ fs.mkdirSync(reviewsDir, { recursive: true });
94
+ // The directory, not the file: an atomic write replaces the file, and a
95
+ // watch on the old one would go quiet after the first save.
96
+ watcher = fs.watch(reviewsDir, (event, name) => {
97
+ if (!name || name === filename) schedule();
98
+ });
99
+ watcher.on('error', () => {
100
+ watcher?.close();
101
+ watcher = null;
102
+ });
103
+ } catch {
104
+ watcher = null; // the poll below still covers it
105
+ }
106
+
107
+ const poll = setInterval(() => {
108
+ fs.stat(file, (error, stat) => {
109
+ const signature = error ? null : `${stat.mtimeMs}:${stat.size}`;
110
+ if (signature !== lastStat) {
111
+ lastStat = signature;
112
+ check();
113
+ }
114
+ });
115
+ }, pollMs);
116
+ poll.unref();
117
+
118
+ const ready = check();
119
+
120
+ return {
121
+ ready,
122
+ close() {
123
+ closed = true;
124
+ clearTimeout(timer);
125
+ clearInterval(poll);
126
+ watcher?.close();
127
+ }
128
+ };
129
+ }
130
+
131
+ module.exports = { watchComments };
@@ -17,6 +17,23 @@ git-reviewer, reviewer \- review local git changes in your browser, and hand the
17
17
  .IR json | prompt ]
18
18
  .RB [ \-\-file
19
19
  .IR path ]
20
+ .br
21
+ .B git reviewer connect
22
+ .RI [ repository ]
23
+ .RB [ \-\-session
24
+ .IR id ]
25
+ .RB [ \-\-fork ]
26
+ .RB [ \-\-list ]
27
+ .br
28
+ .B git reviewer reply
29
+ .I key text
30
+ .RB [ \-\-repo
31
+ .IR path ]
32
+ .RB [ \-\-as
33
+ .IR name ]
34
+ .br
35
+ .B git reviewer watch
36
+ .RI [ repository ]
20
37
  .SH DESCRIPTION
21
38
  Opens the uncommitted changes of a git repository, or its last commit on a
22
39
  clean tree, as a pull-request style review in your browser: inline comments
@@ -44,6 +61,36 @@ is the same program under a shorter name.
44
61
  .B export
45
62
  Print the saved review of a repository to stdout, as JSON or as a prompt for
46
63
  a coding agent.
64
+ .TP
65
+ .B connect
66
+ Resume the Claude Code session that worked in the repository, with reviewer
67
+ attached as a channel: every comment written in the review is sent to the
68
+ session as it is written, and the session answers in the thread.
69
+ .B \-\-list
70
+ shows the sessions;
71
+ .B \-\-session
72
+ picks one by id or id prefix;
73
+ .B \-\-fork
74
+ resumes a copy. Claude Code asks once, at startup, to confirm the channel.
75
+ .TP
76
+ .B reply
77
+ Post a reply in a comment thread, as an agent. Inside Claude Code it is
78
+ signed
79
+ .BR claude ,
80
+ elsewhere
81
+ .B agent
82
+ unless
83
+ .B \-\-as
84
+ names who it is from. Exits 1 if no comment has that key.
85
+ .TP
86
+ .B watch
87
+ Print each comment waiting for an answer, then each new comment or reply
88
+ from the reviewer, as one JSON line, until interrupted.
89
+ .TP
90
+ .B channel
91
+ The MCP server
92
+ .B connect
93
+ asks Claude Code to run, speaking JSON-RPC over stdio. Not run by hand.
47
94
  .SH OPTIONS
48
95
  .TP
49
96
  .BR \-p ", " \-\-port " \fIn\fR"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-reviewer",
3
- "version": "2.15.0",
3
+ "version": "2.17.0",
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",