@in-the-loop-labs/pair-review 3.4.0 → 3.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
4
4
  "description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "code-critic",
3
- "version": "3.4.0",
3
+ "version": "3.4.1",
4
4
  "description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
5
5
  "author": {
6
6
  "name": "in-the-loop-labs",
@@ -133,6 +133,20 @@
133
133
  return div.innerHTML;
134
134
  }
135
135
 
136
+ 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.';
137
+
138
+ function isUrlLikeLocalReviewPath(value) {
139
+ if (!value || typeof value !== 'string') return false;
140
+ const trimmed = value.trim();
141
+ if (!trimmed) return false;
142
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return true;
143
+ if (/^(?:github\.com|app\.graphite\.(?:dev|com))\//i.test(trimmed)) return true;
144
+ // Keep this aligned with src/utils/local-path-input.js: only a leading
145
+ // user@host:path token is treated as an SSH-style remote.
146
+ if (/^[^@/\\\s]+@[^:/\\\s]+:[^\s]+$/.test(trimmed)) return true;
147
+ return false;
148
+ }
149
+
136
150
  /**
137
151
  * Set loading state for a tab's form
138
152
  * @param {string} tab - 'pr' or 'local'
@@ -710,6 +724,12 @@
710
724
  return;
711
725
  }
712
726
 
727
+ if (isUrlLikeLocalReviewPath(pathValue)) {
728
+ showError('local', LOCAL_REVIEW_PATH_URL_ERROR);
729
+ input.focus();
730
+ return;
731
+ }
732
+
713
733
  // Navigate to the setup page which shows step-by-step progress
714
734
  // The /local?path= route serves setup.html which handles the full setup flow
715
735
  let href = '/local?path=' + encodeURIComponent(pathValue);
@@ -717,6 +737,21 @@
717
737
  window.location.href = href;
718
738
  }
719
739
 
740
+ function handleLocalPathInput(event) {
741
+ const input = event && event.target ? event.target : document.getElementById('local-path-input');
742
+ const errorEl = document.getElementById('start-review-error-local');
743
+ if (!input || !errorEl) return;
744
+
745
+ if (isUrlLikeLocalReviewPath(input.value)) {
746
+ showError('local', LOCAL_REVIEW_PATH_URL_ERROR);
747
+ return;
748
+ }
749
+
750
+ if (errorEl.textContent === LOCAL_REVIEW_PATH_URL_ERROR) {
751
+ errorEl.classList.remove('visible', 'info');
752
+ }
753
+ }
754
+
720
755
  // ─── Browse Directory ──────────────────────────────────────────────────────
721
756
 
722
757
  /**
@@ -746,6 +781,9 @@
746
781
 
747
782
  if (!data.cancelled && data.path) {
748
783
  input.value = data.path;
784
+ // Setting .value in JavaScript does not fire an input event, so run the
785
+ // same handler used for typing to clear any stale URL-specific error.
786
+ handleLocalPathInput({ target: input });
749
787
  input.focus();
750
788
  }
751
789
 
@@ -1873,6 +1911,11 @@
1873
1911
  localForm.addEventListener('submit', handleStartLocal);
1874
1912
  }
1875
1913
 
1914
+ const localPathInput = document.getElementById('local-path-input');
1915
+ if (localPathInput) {
1916
+ localPathInput.addEventListener('input', handleLocalPathInput);
1917
+ }
1918
+
1876
1919
  // Set up browse button handler
1877
1920
  const browseBtn = document.getElementById('browse-local-btn');
1878
1921
  if (browseBtn) {
package/src/config.js CHANGED
@@ -222,19 +222,19 @@ async function loadConfig() {
222
222
  }
223
223
  }
224
224
 
225
- // Normalize legacy monorepos key into repos (monorepos values are overridden by repos)
226
- if (mergedConfig.monorepos) {
227
- mergedConfig.repos = deepMerge(mergedConfig.monorepos, mergedConfig.repos);
228
- }
229
-
230
- // Normalize repo keys to lowercase to match the database's COLLATE NOCASE identity
231
- if (mergedConfig.repos) {
232
- const normalized = {};
233
- for (const [key, value] of Object.entries(mergedConfig.repos)) {
234
- normalized[key.toLowerCase()] = value;
225
+ // Normalize legacy monorepos into one canonical repos map. Lowercase both
226
+ // sides before merging so JS object identity matches DB COLLATE NOCASE.
227
+ const lowercaseKeys = (obj) => {
228
+ const out = {};
229
+ for (const [key, value] of Object.entries(obj || {})) {
230
+ out[key.toLowerCase()] = value;
235
231
  }
236
- mergedConfig.repos = normalized;
237
- }
232
+ return out;
233
+ };
234
+ const lowerMonorepos = lowercaseKeys(mergedConfig.monorepos);
235
+ const lowerRepos = lowercaseKeys(mergedConfig.repos);
236
+ mergedConfig.repos = deepMerge(lowerMonorepos, lowerRepos);
237
+ delete mergedConfig.monorepos;
238
238
 
239
239
  // PORT env var overrides all config layers (used by Preview and similar harnesses)
240
240
  if (process.env.PORT) {
@@ -424,12 +424,15 @@ function expandPath(p) {
424
424
  * @returns {object|null}
425
425
  */
426
426
  function getRepoConfig(config, repository) {
427
+ const key = String(repository).toLowerCase();
427
428
  const reposSection = config.repos || {};
428
- const entry = reposSection[repository];
429
- if (entry) return entry;
429
+ const repoEntry = reposSection[key] || reposSection[repository] || Object.entries(reposSection)
430
+ .find(([repoName]) => repoName.toLowerCase() === key)?.[1];
431
+ if (repoEntry) return repoEntry;
430
432
 
431
433
  const legacySection = config.monorepos || {};
432
- return legacySection[repository] || null;
434
+ return legacySection[key] || legacySection[repository] || Object.entries(legacySection)
435
+ .find(([repoName]) => repoName.toLowerCase() === key)?.[1] || null;
433
436
  }
434
437
 
435
438
  /**
@@ -784,4 +787,4 @@ module.exports = {
784
787
  shouldSkipUpdateNotifier,
785
788
  _resetTokenCache,
786
789
  DEFAULT_CHECKOUT_TIMEOUT_MS
787
- };
790
+ };
package/src/database.js CHANGED
@@ -3001,6 +3001,20 @@ class RepoSettingsRepository {
3001
3001
  await run(this.db, `UPDATE repo_settings SET pool_fetch_finished_at = ? WHERE repository = ?`, [now, repository]);
3002
3002
  }
3003
3003
 
3004
+ /**
3005
+ * List repositories with pool settings stored in the database.
3006
+ * Includes rows with a fetch interval only so callers can resolve complete
3007
+ * pool configuration with file fallback through resolvePoolConfig().
3008
+ * @returns {Promise<Array<{repository: string, pool_size: number|null, pool_fetch_interval_minutes: number|null}>>}
3009
+ */
3010
+ async findPoolConfiguredRepoSettings() {
3011
+ return await query(this.db, `
3012
+ SELECT repository, pool_size, pool_fetch_interval_minutes
3013
+ FROM repo_settings
3014
+ WHERE pool_size IS NOT NULL OR pool_fetch_interval_minutes IS NOT NULL
3015
+ `);
3016
+ }
3017
+
3004
3018
  /**
3005
3019
  * Delete settings for a repository
3006
3020
  * @param {string} repository - Repository in owner/repo format
@@ -0,0 +1,29 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Fetch from a remote without auto-following tags reachable from the fetched
5
+ * commits. Large monorepos can have very large tag namespaces, and pair-review
6
+ * only needs commits/refs for review setup.
7
+ * @param {Object} git - simple-git instance
8
+ * @param {string[]} args - Arguments after `git fetch --no-tags`
9
+ * @returns {Promise<*>}
10
+ */
11
+ async function fetchNoTags(git, args) {
12
+ return git.fetch(['--no-tags', ...args]);
13
+ }
14
+
15
+ /**
16
+ * Raw `git fetch --no-tags` wrapper for fetch forms not exposed cleanly by
17
+ * simple-git helpers.
18
+ * @param {Object} git - simple-git instance
19
+ * @param {string[]} args - Arguments after `git fetch --no-tags`
20
+ * @returns {Promise<*>}
21
+ */
22
+ async function rawFetchNoTags(git, args) {
23
+ return git.raw(['fetch', '--no-tags', ...args]);
24
+ }
25
+
26
+ module.exports = {
27
+ fetchNoTags,
28
+ rawFetchNoTags,
29
+ };
@@ -3,11 +3,11 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const logger = require('../utils/logger');
6
- const { WorktreePoolRepository, WorktreeRepository, generateWorktreeId } = require('../database');
6
+ const { WorktreePoolRepository, WorktreeRepository, RepoSettingsRepository, generateWorktreeId } = require('../database');
7
7
  const { GitWorktreeManager } = require('./worktree');
8
8
  const { WorktreePoolUsageTracker } = require('./worktree-pool-usage');
9
9
  const { normalizeRepository } = require('../utils/paths');
10
- const { getRepoPoolSize } = require('../config');
10
+ const { resolvePoolConfig } = require('../config');
11
11
 
12
12
  /**
13
13
  * Consolidates the worktree pool state machine: absorbs WorktreePoolManager
@@ -25,6 +25,7 @@ class WorktreePoolLifecycle {
25
25
  const defaults = {
26
26
  poolRepo: new WorktreePoolRepository(db),
27
27
  worktreeRepo: new WorktreeRepository(db),
28
+ repoSettingsRepo: new RepoSettingsRepository(db),
28
29
  usageTracker: new WorktreePoolUsageTracker(),
29
30
  fs: fs,
30
31
  simpleGit: require('simple-git'),
@@ -36,6 +37,7 @@ class WorktreePoolLifecycle {
36
37
  this.config = config;
37
38
  this._poolRepo = deps.poolRepo;
38
39
  this._worktreeRepo = deps.worktreeRepo;
40
+ this._repoSettingsRepo = deps.repoSettingsRepo;
39
41
  this._usageTracker = deps.usageTracker;
40
42
  this._fs = deps.fs;
41
43
  this._simpleGit = deps.simpleGit;
@@ -628,11 +630,20 @@ class WorktreePoolLifecycle {
628
630
  * @private
629
631
  */
630
632
  async _adoptExistingWorktrees() {
631
- const repos = this.config.repos || {};
633
+ const config = this.config || {};
634
+ const repoSettingsRows = await this._repoSettingsRepo.findPoolConfiguredRepoSettings();
635
+ const settingsByRepo = new Map(
636
+ repoSettingsRows.map(row => [String(row.repository).toLowerCase(), row])
637
+ );
638
+ const repoNames = new Set(Object.keys(config.repos || {}));
639
+ for (const row of repoSettingsRows) {
640
+ repoNames.add(String(row.repository).toLowerCase());
641
+ }
632
642
  const adoptedInUse = [];
633
643
 
634
- for (const repoName of Object.keys(repos)) {
635
- const poolSize = getRepoPoolSize(this.config, repoName);
644
+ for (const repoName of repoNames) {
645
+ const repoSettings = settingsByRepo.get(String(repoName).toLowerCase()) || null;
646
+ const { poolSize } = resolvePoolConfig(config, repoName, repoSettings);
636
647
  if (!poolSize) continue;
637
648
 
638
649
  // Count existing pool entries for this repo
@@ -8,6 +8,7 @@ const { WorktreeRepository, generateWorktreeId } = require('../database');
8
8
  const { getGeneratedFilePatterns } = require('./gitattributes');
9
9
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
10
10
  const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./diff-flags');
11
+ const { fetchNoTags, rawFetchNoTags } = require('./fetch-helpers');
11
12
  const { spawn, execSync } = require('child_process');
12
13
 
13
14
  const MISSING_COMMIT_ERROR_CODE = 'PAIR_REVIEW_MISSING_COMMIT';
@@ -250,7 +251,7 @@ class GitWorktreeManager {
250
251
 
251
252
  let fetchError = null;
252
253
  try {
253
- await git.raw(['fetch', remote, sha]);
254
+ await rawFetchNoTags(git, [remote, sha]);
254
255
  } catch (error) {
255
256
  fetchError = error;
256
257
  }
@@ -370,7 +371,7 @@ class GitWorktreeManager {
370
371
  const prTrackingRef = `refs/remotes/${baseRemote}/pr-${prNumber}`;
371
372
 
372
373
  try {
373
- await git.fetch([baseRemote, `+refs/pull/${prNumber}/head:${prTrackingRef}`]);
374
+ await fetchNoTags(git, [baseRemote, `+refs/pull/${prNumber}/head:${prTrackingRef}`]);
374
375
  return {
375
376
  remote: baseRemote,
376
377
  trackingRef: prTrackingRef,
@@ -387,7 +388,7 @@ class GitWorktreeManager {
387
388
  throw prRefError;
388
389
  }
389
390
 
390
- await git.raw(['fetch', baseRemote, headSha]);
391
+ await rawFetchNoTags(git, [baseRemote, headSha]);
391
392
  return {
392
393
  remote: baseRemote,
393
394
  trackingRef: null,
@@ -591,13 +592,13 @@ class GitWorktreeManager {
591
592
  // Fetch only the specific base branch we need, with error handling for ref conflicts
592
593
  console.log(`Fetching base branch ${prData.base_branch} from ${remote}...`);
593
594
  try {
594
- await git.fetch([remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
595
+ await fetchNoTags(git, [remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
595
596
  } catch (fetchError) {
596
597
  // If fetch fails due to ref conflicts, try alternative approaches
597
598
  console.log(`Standard fetch failed, trying alternative: ${fetchError.message}`);
598
599
  try {
599
600
  // Try fetching with force flag to overwrite conflicting refs
600
- await git.raw(['fetch', remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`, '--force']);
601
+ await rawFetchNoTags(git, ['--force', remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
601
602
  } catch (altFetchError) {
602
603
  console.warn(`Could not fetch base branch ${prData.base_branch}, will try to use existing ref`);
603
604
  // Continue anyway - the branch might already be available locally
@@ -658,7 +659,7 @@ class GitWorktreeManager {
658
659
  if (headBranch) {
659
660
  try {
660
661
  console.log(`Fetching head branch ${headBranch}...`);
661
- await worktreeGit.fetch([remote, `+refs/heads/${headBranch}:refs/remotes/${remote}/${headBranch}`]);
662
+ await fetchNoTags(worktreeGit, [remote, `+refs/heads/${headBranch}:refs/remotes/${remote}/${headBranch}`]);
662
663
  // Create/update a local branch pointing to the fetched ref so tooling can reference it by name
663
664
  await worktreeGit.branch(['-f', headBranch, `${remote}/${headBranch}`]);
664
665
  } catch (branchFetchError) {
@@ -769,14 +770,14 @@ class GitWorktreeManager {
769
770
  // This mirrors the targeted fetch used in createWorktreeForPR.
770
771
  if (prData?.base_branch) {
771
772
  try {
772
- await worktreeGit.fetch([remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
773
+ await fetchNoTags(worktreeGit, [remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
773
774
  } catch (fetchError) {
774
775
  console.warn(`Targeted base-branch fetch failed, will rely on existing refs: ${fetchError.message}`);
775
776
  }
776
777
  }
777
778
  } else {
778
779
  console.log(`Fetching latest changes from ${remote}...`);
779
- await worktreeGit.fetch([remote, '--prune']);
780
+ await fetchNoTags(worktreeGit, ['--prune', remote]);
780
781
  }
781
782
 
782
783
  await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const fs = require('fs').promises;
7
7
  const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken } = require('./config');
8
8
  const logger = require('./utils/logger');
9
+ const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
9
10
  const { fireHooks, hasHooks } = require('./hooks/hook-runner');
10
11
  const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
11
12
 
@@ -699,6 +700,8 @@ async function handleLocalReview(targetPath, flags = {}) {
699
700
  let db = null;
700
701
 
701
702
  try {
703
+ rejectUrlLikeLocalReviewPath(targetPath);
704
+
702
705
  // Resolve target path
703
706
  const resolvedPath = path.resolve(targetPath || process.cwd());
704
707
 
package/src/main.js CHANGED
@@ -5,6 +5,7 @@ const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, Work
5
5
  const { PRArgumentParser } = require('./github/parser');
6
6
  const { GitHubClient } = require('./github/client');
7
7
  const { GitWorktreeManager } = require('./git/worktree');
8
+ const { fetchNoTags } = require('./git/fetch-helpers');
8
9
  const { WorktreePoolLifecycle } = require('./git/worktree-pool-lifecycle');
9
10
  const { startServer } = require('./server');
10
11
  const Analyzer = require('./ai/analyzer');
@@ -13,6 +14,7 @@ const { handleLocalReview, findMainGitRoot } = require('./local-review');
13
14
  const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
14
15
  const { fireReviewStartedHook } = require('./hooks/payloads');
15
16
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
17
+ const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
16
18
  const logger = require('./utils/logger');
17
19
  const simpleGit = require('simple-git');
18
20
  const { getGeneratedFilePatterns } = require('./git/gitattributes');
@@ -288,6 +290,7 @@ function parseArgs(args) {
288
290
  flags.local = true;
289
291
  // Next argument is optional path (if not starting with -)
290
292
  if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
293
+ rejectUrlLikeLocalReviewPath(args[i + 1]);
291
294
  flags.localPath = args[i + 1];
292
295
  i++; // Skip next argument since we consumed it
293
296
  }
@@ -723,7 +726,7 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
723
726
 
724
727
  // Ensure we have the base SHA available (fetch if needed)
725
728
  try {
726
- await git.fetch(['origin', prData.base_sha]);
729
+ await fetchNoTags(git, ['origin', prData.base_sha]);
727
730
  } catch (fetchError) {
728
731
  // Fetch by SHA may fail (not all servers support it); verify SHA is available locally
729
732
  try {
@@ -1225,7 +1228,7 @@ function startPoolBackgroundFetches(db, config) {
1225
1228
  const git = simpleGit(entry.path, { timeout: { block: 300000 } });
1226
1229
  const remotes = await git.getRemotes();
1227
1230
  const remote = remotes.find(r => r.name === 'origin') || remotes[0];
1228
- if (remote) await git.fetch([remote.name, '--prune']);
1231
+ if (remote) await fetchNoTags(git, ['--prune', remote.name]);
1229
1232
  await poolRepo.updateLastFetched(entry.id);
1230
1233
  // Refresh the lease so the stale guard only needs to outlive
1231
1234
  // a single stalled fetch, not the entire serial loop.
@@ -1294,4 +1297,4 @@ if (require.main === module) {
1294
1297
  main();
1295
1298
  }
1296
1299
 
1297
- module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
1300
+ module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
@@ -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
  });
@@ -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
+ };