@in-the-loop-labs/pair-review 3.4.0 → 3.5.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 +24 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +762 -6
- package/public/js/components/AIPanel.js +486 -42
- package/public/js/components/ChatPanel.js +2002 -528
- package/public/js/index.js +43 -0
- package/public/js/modules/comment-minimizer.js +66 -20
- package/public/js/modules/external-comment-manager.js +870 -0
- package/public/js/pr.js +297 -20
- package/public/local.html +21 -5
- package/public/pr.html +31 -5
- package/src/chat/chat-providers.js +64 -12
- package/src/config.js +21 -17
- package/src/database.js +580 -2
- package/src/external/github-adapter.js +152 -0
- package/src/external/index.js +37 -0
- package/src/git/fetch-helpers.js +29 -0
- package/src/git/worktree-pool-lifecycle.js +16 -5
- package/src/git/worktree.js +9 -8
- package/src/github/client.js +77 -1
- package/src/local-review.js +3 -0
- package/src/main.js +6 -3
- package/src/routes/config.js +27 -0
- package/src/routes/external-comments.js +394 -0
- package/src/routes/local.js +7 -0
- package/src/routes/setup.js +7 -0
- package/src/routes/stack-analysis.js +1 -1
- package/src/server.js +9 -0
- package/src/setup/local-setup.js +5 -1
- package/src/setup/pr-setup.js +7 -4
- package/src/single-port.js +2 -0
- package/src/utils/local-path-input.js +44 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* External Comment Routes
|
|
4
|
+
*
|
|
5
|
+
* Endpoints for syncing and reading review comments from external systems
|
|
6
|
+
* (currently GitHub PR review comments; designed for GitLab/Linear/etc.).
|
|
7
|
+
* External comments are stored as a read-only mirror in the
|
|
8
|
+
* `external_comments` table — see ExternalCommentRepository.
|
|
9
|
+
*
|
|
10
|
+
* This file is shared between two implementation agents:
|
|
11
|
+
* --- SYNC ROUTES --- : POST /api/reviews/:reviewId/external-comments/sync
|
|
12
|
+
* --- FETCH ROUTES --- : GET /api/reviews/:reviewId/external-comments
|
|
13
|
+
*
|
|
14
|
+
* Canonical PR-mode predicate: `isPRMode(review)`. Use it from EVERY route
|
|
15
|
+
* in this file — sync and fetch must agree on what counts as a PR review,
|
|
16
|
+
* otherwise the two endpoints diverge on local-mode handling.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const express = require('express');
|
|
20
|
+
const {
|
|
21
|
+
ExternalCommentRepository,
|
|
22
|
+
ReviewRepository,
|
|
23
|
+
withTransaction
|
|
24
|
+
} = require('../database');
|
|
25
|
+
const { getAdapter } = require('../external');
|
|
26
|
+
const { GitHubApiError } = require('../github/client');
|
|
27
|
+
const logger = require('../utils/logger');
|
|
28
|
+
|
|
29
|
+
const router = express.Router();
|
|
30
|
+
|
|
31
|
+
// --- SYNC ROUTES ---
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default dependencies for the sync flow. Tests override these via the
|
|
35
|
+
* `externalCommentsDeps` Express app setting (or by passing `_deps` to
|
|
36
|
+
* `executeSync` directly). Credential resolution is delegated to the
|
|
37
|
+
* adapter via `adapter.resolveCredentials(config)` — keeps the route
|
|
38
|
+
* source-agnostic and lets each adapter name its own env var in errors.
|
|
39
|
+
*/
|
|
40
|
+
const defaults = {
|
|
41
|
+
getAdapter
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* In-flight sync registry keyed by `${reviewId}:${source}`.
|
|
46
|
+
*
|
|
47
|
+
* Page-load auto-sync and the manual "refresh external comments" button
|
|
48
|
+
* can race. When a sync is already running for a (reviewId, source) pair,
|
|
49
|
+
* a second caller awaits the same promise instead of making a duplicate
|
|
50
|
+
* GitHub round-trip. This also avoids two parent-resolution passes briefly
|
|
51
|
+
* interleaving (the hazard called out in the plan).
|
|
52
|
+
*
|
|
53
|
+
* Entries are removed in a `finally` so failures do not permanently block
|
|
54
|
+
* retries.
|
|
55
|
+
*
|
|
56
|
+
* @type {Map<string, Promise<{count: number, lostAnchors: number, syncedAt: string}>>}
|
|
57
|
+
*/
|
|
58
|
+
const inFlight = new Map();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Global write-phase serializer. The per-key `inFlight` map only dedupes
|
|
62
|
+
* matching (reviewId, source) pairs — two syncs for DIFFERENT reviews can
|
|
63
|
+
* still race their write phases on the same better-sqlite3 connection,
|
|
64
|
+
* which cannot nest BEGIN…COMMIT (throws "cannot start a transaction
|
|
65
|
+
* within a transaction"). We do all network I/O and mapping outside the
|
|
66
|
+
* transaction (cheap to interleave), then chain transactional writes
|
|
67
|
+
* through this single promise so only one BEGIN…COMMIT runs at a time.
|
|
68
|
+
*
|
|
69
|
+
* Per-db serialization would be cleaner if the route handled multiple DBs,
|
|
70
|
+
* but pair-review uses one SQLite file per process; a module-level chain
|
|
71
|
+
* is sufficient and avoids a per-db WeakMap dance.
|
|
72
|
+
*/
|
|
73
|
+
let writeChain = Promise.resolve();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Typed 400 error. Mirrors the GitHubApiError shape (name/message/status)
|
|
77
|
+
* so the route's catch ladder can fan-out by `instanceof` rather than
|
|
78
|
+
* string-sniff. Used for client-correctable problems (malformed inputs)
|
|
79
|
+
* that previously bubbled out as plain Error → 500.
|
|
80
|
+
*/
|
|
81
|
+
class BadRequestError extends Error {
|
|
82
|
+
constructor(message) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = 'BadRequestError';
|
|
85
|
+
this.status = 400;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Canonical PR-mode predicate. Both routes in this file (sync + fetch) must
|
|
91
|
+
* use this same check so the two endpoints agree on what a "PR review" is.
|
|
92
|
+
*
|
|
93
|
+
* A row is PR-mode iff:
|
|
94
|
+
* - it has a numeric `pr_number`
|
|
95
|
+
* - it has a non-empty `repository`
|
|
96
|
+
* - its `review_type` is not 'local' (default is 'pr')
|
|
97
|
+
* - it has no `local_path` (which would identify a local-mode review)
|
|
98
|
+
*/
|
|
99
|
+
function isPRMode(review) {
|
|
100
|
+
if (!review) return false;
|
|
101
|
+
if (review.review_type && review.review_type !== 'pr') return false;
|
|
102
|
+
if (review.local_path) return false;
|
|
103
|
+
if (!Number.isInteger(review.pr_number)) return false;
|
|
104
|
+
if (!review.repository) return false;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run a full sync for one (reviewId, source) pair. Idempotent. Throws
|
|
110
|
+
* domain errors (Error / GitHubApiError) — the route handler catches them
|
|
111
|
+
* and maps them to HTTP responses.
|
|
112
|
+
*
|
|
113
|
+
* @param {Object} params
|
|
114
|
+
* @param {Object} params.db - Database handle
|
|
115
|
+
* @param {Object} params.config - Server config (for token lookup)
|
|
116
|
+
* @param {Object} params.review - Validated review row
|
|
117
|
+
* @param {string} params.source - Adapter source name (e.g. 'github')
|
|
118
|
+
* @param {Object} [params._deps] - Test overrides for { GitHubClient, getGitHubToken, getAdapter }
|
|
119
|
+
* @returns {Promise<{count: number, lostAnchors: number, syncedAt: string}>}
|
|
120
|
+
*/
|
|
121
|
+
async function executeSync({ db, config, review, source, _deps }) {
|
|
122
|
+
const deps = { ...defaults, ..._deps };
|
|
123
|
+
|
|
124
|
+
// Look up adapter — throws on unknown sources, caught by the route.
|
|
125
|
+
const adapter = deps.getAdapter(source);
|
|
126
|
+
|
|
127
|
+
// Delegate credential resolution to the adapter so the route stays
|
|
128
|
+
// source-agnostic and each adapter can name its own env var in errors.
|
|
129
|
+
// The adapter throws (e.g. GitHubApiError 401) when credentials are
|
|
130
|
+
// missing — the route's catch maps it to a 401 response.
|
|
131
|
+
const { client } = adapter.resolveCredentials(config || {}, _deps);
|
|
132
|
+
|
|
133
|
+
const [owner, repo] = String(review.repository).split('/');
|
|
134
|
+
if (!owner || !repo) {
|
|
135
|
+
throw new BadRequestError(
|
|
136
|
+
`Invalid review.repository "${review.repository}"; expected "owner/repo"`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const apiRows = await adapter.fetchComments({
|
|
141
|
+
client,
|
|
142
|
+
owner,
|
|
143
|
+
repo,
|
|
144
|
+
pull_number: review.pr_number
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Map raw API rows and filter out "lost anchors" (BOTH current AND original
|
|
148
|
+
// position fields null — unrenderable). Counting them lets the UI tell the
|
|
149
|
+
// user why their visible count differs from GitHub's reported total.
|
|
150
|
+
// Track external_ids seen this sync so we can prune rows that upstream
|
|
151
|
+
// has removed (or that we no longer render because they lost anchors)
|
|
152
|
+
// inside the same transaction as the upserts.
|
|
153
|
+
let lostAnchors = 0;
|
|
154
|
+
const mappedRows = [];
|
|
155
|
+
const seenExternalIds = new Set();
|
|
156
|
+
for (const apiRow of apiRows || []) {
|
|
157
|
+
let mapped;
|
|
158
|
+
try {
|
|
159
|
+
mapped = adapter.mapComment(apiRow);
|
|
160
|
+
} catch (mapError) {
|
|
161
|
+
// A malformed row from the source shouldn't kill the whole sync — log
|
|
162
|
+
// it and keep going. The adapter only throws for genuinely malformed
|
|
163
|
+
// rows (e.g. missing required `path`).
|
|
164
|
+
logger.warn(`External comment adapter ${source} could not map row: ${mapError.message}`);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (mapped.line_end == null && mapped.original_line_end == null) {
|
|
169
|
+
lostAnchors++;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
mappedRows.push(mapped);
|
|
173
|
+
seenExternalIds.add(String(mapped.external_id));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const repository = new ExternalCommentRepository(db);
|
|
177
|
+
const syncedAt = new Date().toISOString();
|
|
178
|
+
|
|
179
|
+
// Write phase: upsert all rows, conditionally prune rows missing from
|
|
180
|
+
// this snapshot, then resolve parents. Wrapped in a single transaction
|
|
181
|
+
// so concurrent readers never see a partial mirror.
|
|
182
|
+
//
|
|
183
|
+
// Empty-snapshot prune is intentionally skipped (`seenExternalIds.size`
|
|
184
|
+
// gate below). A transient empty response from upstream (e.g. GitHub
|
|
185
|
+
// briefly returning [] while a PR is being reorganized) used to wipe the
|
|
186
|
+
// entire local mirror for (review_id, source). Skipping the prune turns
|
|
187
|
+
// that transient into a no-op; the non-empty case still prunes rows that
|
|
188
|
+
// upstream removed.
|
|
189
|
+
//
|
|
190
|
+
// We serialize the transactional write phase through a module-level
|
|
191
|
+
// promise chain because better-sqlite3 cannot nest BEGIN…COMMIT — two
|
|
192
|
+
// concurrent syncs for DIFFERENT reviews would otherwise collide here.
|
|
193
|
+
let deletedCount = 0;
|
|
194
|
+
const performWrites = async () => {
|
|
195
|
+
await withTransaction(db, async () => {
|
|
196
|
+
for (const mapped of mappedRows) {
|
|
197
|
+
await repository.upsert(review.id, source, mapped);
|
|
198
|
+
}
|
|
199
|
+
if (seenExternalIds.size > 0) {
|
|
200
|
+
deletedCount = await repository.deleteMissing(review.id, source, seenExternalIds);
|
|
201
|
+
}
|
|
202
|
+
await repository.resolveParents(review.id, source);
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Chain the current write phase onto whatever's already pending. The
|
|
207
|
+
// chain swallows errors at the join point so a failed sync doesn't
|
|
208
|
+
// permanently break the next caller's link in the chain — the *current*
|
|
209
|
+
// caller still observes its own failure via the `await` below.
|
|
210
|
+
const previous = writeChain;
|
|
211
|
+
const myWrite = previous.then(performWrites, performWrites);
|
|
212
|
+
writeChain = myWrite.catch(() => {});
|
|
213
|
+
await myWrite;
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
count: mappedRows.length,
|
|
217
|
+
lostAnchors,
|
|
218
|
+
deleted: deletedCount,
|
|
219
|
+
syncedAt
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Middleware: validate `:reviewId`, attach `req.review`.
|
|
225
|
+
*
|
|
226
|
+
* Mirrors the pattern in `routes/reviews.js` but lives here to keep the
|
|
227
|
+
* sync route self-contained. The fetch route below intentionally uses a
|
|
228
|
+
* different (older) shape because it predates this middleware.
|
|
229
|
+
*/
|
|
230
|
+
async function validateReviewId(req, res, next) {
|
|
231
|
+
try {
|
|
232
|
+
const reviewId = parseInt(req.params.reviewId, 10);
|
|
233
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
234
|
+
return res.status(400).json({ error: 'Invalid review ID' });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const db = req.app.get('db');
|
|
238
|
+
const reviewRepo = new ReviewRepository(db);
|
|
239
|
+
const review = await reviewRepo.getReview(reviewId);
|
|
240
|
+
|
|
241
|
+
if (!review) {
|
|
242
|
+
return res.status(404).json({ error: 'Review not found' });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
req.review = review;
|
|
246
|
+
req.reviewId = reviewId;
|
|
247
|
+
next();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
next(error);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* POST /api/reviews/:reviewId/external-comments/sync?source=github
|
|
255
|
+
*
|
|
256
|
+
* Fetches inline review comments from the external source and upserts them
|
|
257
|
+
* into the local mirror. Idempotent. Returns
|
|
258
|
+
* `{ count, lostAnchors, syncedAt }`. See module header for the
|
|
259
|
+
* concurrent-sync guard contract.
|
|
260
|
+
*/
|
|
261
|
+
router.post('/api/reviews/:reviewId/external-comments/sync', validateReviewId, async (req, res) => {
|
|
262
|
+
const source = (req.query.source || 'github').toString();
|
|
263
|
+
const review = req.review;
|
|
264
|
+
|
|
265
|
+
if (!isPRMode(review)) {
|
|
266
|
+
return res.status(400).json({ error: 'External comment sync requires a PR mode review' });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const db = req.app.get('db');
|
|
270
|
+
const config = req.app.get('config') || {};
|
|
271
|
+
const key = `${review.id}:${source}`;
|
|
272
|
+
|
|
273
|
+
// Tests inject dependency overrides via the app setting
|
|
274
|
+
// `externalCommentsDeps`. In production this is undefined and the module
|
|
275
|
+
// defaults win.
|
|
276
|
+
const _deps = req.app.get('externalCommentsDeps') || undefined;
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
let promise = inFlight.get(key);
|
|
280
|
+
if (!promise) {
|
|
281
|
+
promise = executeSync({ db, config, review, source, _deps })
|
|
282
|
+
.finally(() => {
|
|
283
|
+
// Remove the slot only after the promise settles — concurrent
|
|
284
|
+
// callers awaiting this entry must see the same outcome.
|
|
285
|
+
inFlight.delete(key);
|
|
286
|
+
});
|
|
287
|
+
inFlight.set(key, promise);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const result = await promise;
|
|
291
|
+
res.json(result);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
// Unknown source — the adapter dispatcher throws a plain Error.
|
|
294
|
+
if (error && typeof error.message === 'string' && error.message.startsWith('Unknown external comment source:')) {
|
|
295
|
+
logger.warn(`External comments sync rejected: ${error.message}`);
|
|
296
|
+
return res.status(400).json({ error: error.message });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Client-correctable problem (e.g. malformed review.repository).
|
|
300
|
+
// BadRequestError carries status=400 explicitly so we don't fall
|
|
301
|
+
// through to the catch-all 500.
|
|
302
|
+
if (error instanceof BadRequestError) {
|
|
303
|
+
logger.warn(`External comments sync rejected: ${error.message}`);
|
|
304
|
+
return res.status(error.status).json({ error: error.message });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (error instanceof GitHubApiError) {
|
|
308
|
+
logger.error(`External comments sync GitHub error (${error.status}): ${error.message}`);
|
|
309
|
+
|
|
310
|
+
// Single mapping path: trust GitHubApiError.message, which
|
|
311
|
+
// `handleApiError` already populates with the retry-after seconds on
|
|
312
|
+
// 429s and the auth/rate context on other failures. The previously
|
|
313
|
+
// separate 429 branch read `error.retryAfter`, which GitHubApiError
|
|
314
|
+
// doesn't carry — dead code that masked the real message.
|
|
315
|
+
if (error.status >= 400 && error.status < 600) {
|
|
316
|
+
return res.status(error.status).json({ error: error.message });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return res.status(500).json({ error: error.message });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
logger.error('External comments sync failed:', error);
|
|
323
|
+
res.status(500).json({ error: error.message || 'Failed to sync external comments' });
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// --- FETCH ROUTES ---
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* GET /api/reviews/:reviewId/external-comments?source=github
|
|
331
|
+
*
|
|
332
|
+
* Returns external comments persisted for a review, grouped into threads.
|
|
333
|
+
* Each thread is a root comment object with all original row fields plus a
|
|
334
|
+
* `replies` array of the same shape.
|
|
335
|
+
*
|
|
336
|
+
* Query params:
|
|
337
|
+
* - source: (optional) filter to one external source (e.g. 'github').
|
|
338
|
+
* If omitted, returns rows from all known sources.
|
|
339
|
+
* If provided but unknown, responds 400.
|
|
340
|
+
*
|
|
341
|
+
* Responses:
|
|
342
|
+
* - 200: { threads: Array<Thread> }
|
|
343
|
+
* - 400: unknown source
|
|
344
|
+
* - 404: review not found
|
|
345
|
+
* - 500: unexpected
|
|
346
|
+
*
|
|
347
|
+
* Local-mode reviews always return { threads: [] } — external comments
|
|
348
|
+
* are a PR-mode concept, but the endpoint is safe to call from local pages.
|
|
349
|
+
*/
|
|
350
|
+
router.get('/api/reviews/:reviewId/external-comments', validateReviewId, async (req, res) => {
|
|
351
|
+
try {
|
|
352
|
+
const reviewId = req.reviewId;
|
|
353
|
+
const review = req.review;
|
|
354
|
+
|
|
355
|
+
const source = req.query.source;
|
|
356
|
+
|
|
357
|
+
// If a source filter is provided, validate it against the adapter registry
|
|
358
|
+
// before touching the DB. Catches typos early with a meaningful message.
|
|
359
|
+
if (source !== undefined && source !== null && source !== '') {
|
|
360
|
+
try {
|
|
361
|
+
getAdapter(source);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
return res.status(400).json({ error: `Unknown external comment source: ${source}` });
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Non-PR reviews (local-mode, malformed rows) never have external
|
|
368
|
+
// comments. Return an empty thread list so the frontend can call this
|
|
369
|
+
// endpoint unconditionally. We use the canonical `isPRMode` predicate
|
|
370
|
+
// here so sync and fetch stay in lockstep on what counts as PR mode.
|
|
371
|
+
if (!isPRMode(review)) {
|
|
372
|
+
return res.json({ threads: [] });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const db = req.app.get('db');
|
|
376
|
+
const repo = new ExternalCommentRepository(db);
|
|
377
|
+
const listOptions = {};
|
|
378
|
+
if (source) {
|
|
379
|
+
listOptions.source = source;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const threads = await repo.listThreadsByReview(reviewId, listOptions);
|
|
383
|
+
|
|
384
|
+
res.json({ threads });
|
|
385
|
+
} catch (error) {
|
|
386
|
+
logger.error('Error fetching external comments:', error);
|
|
387
|
+
res.status(500).json({ error: 'Failed to fetch external comments' });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
module.exports = router;
|
|
392
|
+
module.exports.executeSync = executeSync;
|
|
393
|
+
// Exported for tests only — production code should not reach into this map.
|
|
394
|
+
module.exports._inFlight = inFlight;
|
package/src/routes/local.js
CHANGED
|
@@ -35,6 +35,7 @@ const { getProviderClass, createProvider } = require('../ai/provider');
|
|
|
35
35
|
const { getDefaultBranch, tryGraphiteState } = require('../git/base-branch');
|
|
36
36
|
const { CommentRepository } = require('../database');
|
|
37
37
|
const { runExecutableAnalysis, getChangedFiles } = require('./executable-analysis');
|
|
38
|
+
const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
|
|
38
39
|
const {
|
|
39
40
|
activeAnalyses,
|
|
40
41
|
localReviewDiffs,
|
|
@@ -380,6 +381,12 @@ router.post('/api/local/start', async (req, res) => {
|
|
|
380
381
|
});
|
|
381
382
|
}
|
|
382
383
|
|
|
384
|
+
try {
|
|
385
|
+
rejectUrlLikeLocalReviewPath(inputPath);
|
|
386
|
+
} catch (err) {
|
|
387
|
+
return res.status(400).json({ error: err.message });
|
|
388
|
+
}
|
|
389
|
+
|
|
383
390
|
// Required inline (not reusing top-level import) so that vi.spyOn()
|
|
384
391
|
// replacements on the module exports are visible at call time in integration tests.
|
|
385
392
|
const { findGitRoot, getHeadSha, getRepositoryName, getCurrentBranch } = require('../local-review');
|
package/src/routes/setup.js
CHANGED
|
@@ -18,6 +18,7 @@ const { setupLocalReview } = require('../setup/local-setup');
|
|
|
18
18
|
const { getGitHubToken, expandPath } = require('../config');
|
|
19
19
|
const { queryOne, ReviewRepository } = require('../database');
|
|
20
20
|
const { normalizeRepository } = require('../utils/paths');
|
|
21
|
+
const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
|
|
21
22
|
const logger = require('../utils/logger');
|
|
22
23
|
|
|
23
24
|
const router = express.Router();
|
|
@@ -186,6 +187,12 @@ router.post('/api/setup/local', async (req, res) => {
|
|
|
186
187
|
return res.status(400).json({ error: 'Missing required field: path' });
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
try {
|
|
191
|
+
rejectUrlLikeLocalReviewPath(rawPath);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
return res.status(400).json({ error: err.message });
|
|
194
|
+
}
|
|
195
|
+
|
|
189
196
|
const targetPath = expandPath(rawPath);
|
|
190
197
|
const db = req.app.get('db');
|
|
191
198
|
|
|
@@ -220,7 +220,7 @@ async function executeStackAnalysis(params) {
|
|
|
220
220
|
// 2. Bulk fetch all PR refs (runs against trigger worktree)
|
|
221
221
|
const refspecs = prNumbers.map(n => `+refs/pull/${n}/head:refs/remotes/origin/pr-${n}`);
|
|
222
222
|
try {
|
|
223
|
-
deps.execSync(`git fetch origin ${refspecs.join(' ')}`, {
|
|
223
|
+
deps.execSync(`git fetch --no-tags origin ${refspecs.join(' ')}`, {
|
|
224
224
|
cwd: triggerWorktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
225
225
|
timeout: 60000
|
|
226
226
|
});
|
package/src/server.js
CHANGED
|
@@ -349,6 +349,7 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
|
|
|
349
349
|
const contextFilesRoutes = require('./routes/context-files');
|
|
350
350
|
const githubCollectionsRoutes = require('./routes/github-collections');
|
|
351
351
|
const stackAnalysisRoutes = require('./routes/stack-analysis');
|
|
352
|
+
const externalCommentsRoutes = require('./routes/external-comments');
|
|
352
353
|
const { createSoundRouter } = require('./routes/sound');
|
|
353
354
|
|
|
354
355
|
// Initialize chat session manager
|
|
@@ -369,6 +370,14 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
|
|
|
369
370
|
app.use('/', mcpRoutes);
|
|
370
371
|
app.use('/', githubCollectionsRoutes);
|
|
371
372
|
app.use('/', stackAnalysisRoutes);
|
|
373
|
+
// External-comments routes (GitHub PR review-comment sync + fetch) are
|
|
374
|
+
// gated by the `external_comments` config flag. When disabled, the
|
|
375
|
+
// router is not mounted so any GET/POST against `/api/reviews/*/
|
|
376
|
+
// external-comments*` returns 404 — matching the frontend's intent that
|
|
377
|
+
// the feature simply doesn't exist for that user.
|
|
378
|
+
if (config.external_comments !== false) {
|
|
379
|
+
app.use('/', externalCommentsRoutes);
|
|
380
|
+
}
|
|
372
381
|
app.use('/', createSoundRouter());
|
|
373
382
|
app.use('/', prRoutes);
|
|
374
383
|
|
package/src/setup/local-setup.js
CHANGED
|
@@ -6,6 +6,7 @@ const { fireHooks, hasHooks } = require('../hooks/hook-runner');
|
|
|
6
6
|
const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('../hooks/payloads');
|
|
7
7
|
const { STOPS, DEFAULT_SCOPE, reviewScope } = require('../local-scope');
|
|
8
8
|
const logger = require('../utils/logger');
|
|
9
|
+
const { LOCAL_REVIEW_PATH_URL_ERROR, rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
|
|
9
10
|
const path = require('path');
|
|
10
11
|
const fs = require('fs').promises;
|
|
11
12
|
|
|
@@ -31,11 +32,14 @@ async function setupLocalReview({ db, targetPath, onProgress, config }) {
|
|
|
31
32
|
let resolvedPath;
|
|
32
33
|
try {
|
|
33
34
|
progress({ step: 'validate', status: 'running', message: 'Validating target path...' });
|
|
35
|
+
rejectUrlLikeLocalReviewPath(targetPath);
|
|
34
36
|
resolvedPath = path.resolve(targetPath);
|
|
35
37
|
await fs.access(resolvedPath);
|
|
36
38
|
progress({ step: 'validate', status: 'completed', message: `Path resolved to ${resolvedPath}` });
|
|
37
39
|
} catch (err) {
|
|
38
|
-
const message =
|
|
40
|
+
const message = err.message === LOCAL_REVIEW_PATH_URL_ERROR
|
|
41
|
+
? err.message
|
|
42
|
+
: `Path does not exist: ${path.resolve(targetPath)}`;
|
|
39
43
|
progress({ step: 'validate', status: 'error', message });
|
|
40
44
|
throw new Error(message);
|
|
41
45
|
}
|
package/src/setup/pr-setup.js
CHANGED
|
@@ -17,7 +17,7 @@ const { WorktreePoolLifecycle } = require('../git/worktree-pool-lifecycle');
|
|
|
17
17
|
const { GitHubClient } = require('../github/client');
|
|
18
18
|
const { normalizeRepository } = require('../utils/paths');
|
|
19
19
|
const { findMainGitRoot } = require('../local-review');
|
|
20
|
-
const { getConfigDir, getRepoPath, resolveRepoOptions,
|
|
20
|
+
const { getConfigDir, getRepoPath, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
|
|
21
21
|
const logger = require('../utils/logger');
|
|
22
22
|
const { fireReviewStartedHook } = require('../hooks/payloads');
|
|
23
23
|
const simpleGit = require('simple-git');
|
|
@@ -229,6 +229,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
|
|
|
229
229
|
const worktreeManager = new GitWorktreeManager(db);
|
|
230
230
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
231
231
|
const worktreeRepo = new WorktreeRepository(db);
|
|
232
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
232
233
|
|
|
233
234
|
let repositoryPath = null;
|
|
234
235
|
let worktreeSourcePath = null; // Path to use as cwd for `git worktree add` (may differ from repositoryPath)
|
|
@@ -288,7 +289,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
|
|
|
288
289
|
// ------------------------------------------------------------------
|
|
289
290
|
// Resolve monorepo worktree options (checkout_script, worktree_directory, worktree_name_template)
|
|
290
291
|
// ------------------------------------------------------------------
|
|
291
|
-
const resolved = config ? resolveRepoOptions(config, repository) : { checkoutScript: null, checkoutTimeout: DEFAULT_CHECKOUT_TIMEOUT_MS, worktreeConfig: null };
|
|
292
|
+
const resolved = config ? resolveRepoOptions(config, repository, repoSettings) : { checkoutScript: null, checkoutTimeout: DEFAULT_CHECKOUT_TIMEOUT_MS, worktreeConfig: null };
|
|
292
293
|
const { checkoutScript, checkoutTimeout, worktreeConfig } = resolved;
|
|
293
294
|
|
|
294
295
|
// When a checkout script is configured, null out worktreeSourcePath —
|
|
@@ -301,7 +302,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
|
|
|
301
302
|
// ------------------------------------------------------------------
|
|
302
303
|
// Tier 0: Check known local path from repo_settings
|
|
303
304
|
// ------------------------------------------------------------------
|
|
304
|
-
const knownPath =
|
|
305
|
+
const knownPath = repoSettings?.local_path || null;
|
|
305
306
|
|
|
306
307
|
if (!repositoryPath && knownPath && await worktreeManager.pathExists(knownPath)) {
|
|
307
308
|
try {
|
|
@@ -464,7 +465,9 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, o
|
|
|
464
465
|
// Step: worktree - Create git worktree for the PR
|
|
465
466
|
// ------------------------------------------------------------------
|
|
466
467
|
const prInfo = { owner, repo, number: prNumber };
|
|
467
|
-
const
|
|
468
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
469
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
470
|
+
const { poolSize } = resolvePoolConfig(config || {}, repository, repoSettings);
|
|
468
471
|
const resetScript = config ? getRepoResetScript(config, repository) : null;
|
|
469
472
|
|
|
470
473
|
let worktreePath;
|
package/src/single-port.js
CHANGED
|
@@ -4,6 +4,7 @@ const path = require('path');
|
|
|
4
4
|
const semver = require('semver');
|
|
5
5
|
const { PRArgumentParser } = require('./github/parser');
|
|
6
6
|
const logger = require('./utils/logger');
|
|
7
|
+
const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
|
|
7
8
|
const { version: packageVersion } = require('../package.json');
|
|
8
9
|
|
|
9
10
|
const HEALTH_TIMEOUT_MS = 2000;
|
|
@@ -158,6 +159,7 @@ async function attemptDelegation(config, flags, prArgs, _deps) {
|
|
|
158
159
|
// Determine mode and build URL
|
|
159
160
|
let url;
|
|
160
161
|
if (flags.local) {
|
|
162
|
+
rejectUrlLikeLocalReviewPath(flags.localPath);
|
|
161
163
|
const targetPath = path.resolve(flags.localPath || process.cwd());
|
|
162
164
|
url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze: flags.ai });
|
|
163
165
|
} else if (prArgs.length > 0) {
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const LOCAL_REVIEW_PATH_URL_ERROR = 'Local reviews require a filesystem path, not a URL. Pass GitHub or Graphite URLs as PR review inputs instead.';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Detect inputs that are URLs or remote-style Git URLs rather than filesystem paths.
|
|
7
|
+
* This intentionally checks only unambiguous URL forms so normal absolute,
|
|
8
|
+
* relative, tilde, and Windows paths continue to work.
|
|
9
|
+
*
|
|
10
|
+
* @param {unknown} input
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
function isUrlLikeLocalReviewPath(input) {
|
|
14
|
+
if (typeof input !== 'string') return false;
|
|
15
|
+
|
|
16
|
+
const value = input.trim();
|
|
17
|
+
if (!value) return false;
|
|
18
|
+
|
|
19
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return true;
|
|
20
|
+
if (/^(?:github\.com|app\.graphite\.(?:dev|com))\//i.test(value)) return true;
|
|
21
|
+
// Treat only a leading user@host:path token as SSH remote syntax; if a
|
|
22
|
+
// directory prefix contains @ and : it should remain a filesystem path.
|
|
23
|
+
if (/^[^@/\\\s]+@[^:/\\\s]+:[^\s]+$/.test(value)) return true;
|
|
24
|
+
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Throw a user-facing error when a local review path is actually a URL.
|
|
30
|
+
*
|
|
31
|
+
* @param {unknown} input
|
|
32
|
+
* @throws {Error}
|
|
33
|
+
*/
|
|
34
|
+
function rejectUrlLikeLocalReviewPath(input) {
|
|
35
|
+
if (isUrlLikeLocalReviewPath(input)) {
|
|
36
|
+
throw new Error(LOCAL_REVIEW_PATH_URL_ERROR);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
LOCAL_REVIEW_PATH_URL_ERROR,
|
|
42
|
+
isUrlLikeLocalReviewPath,
|
|
43
|
+
rejectUrlLikeLocalReviewPath
|
|
44
|
+
};
|