@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.
@@ -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;
@@ -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');
@@ -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
 
@@ -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 = `Path does not exist: ${path.resolve(targetPath)}`;
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
  }
@@ -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, getRepoPoolSize, getRepoResetScript, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
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 = await repoSettingsRepo.getLocalPath(repository);
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 poolSize = config ? getRepoPoolSize(config, repository) : 0;
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;
@@ -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
+ };