@in-the-loop-labs/pair-review 3.3.7 → 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/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');
@@ -20,6 +22,7 @@ const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./git/di
20
22
  const { getEmoji: getCategoryEmoji } = require('./utils/category-emoji');
21
23
  const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({default: open}) => open(...args));
22
24
  const { registerProtocolHandler, unregisterProtocolHandler } = require('./protocol-handler');
25
+ const { attemptDelegation } = require('./single-port');
23
26
 
24
27
  let db = null;
25
28
 
@@ -287,6 +290,7 @@ function parseArgs(args) {
287
290
  flags.local = true;
288
291
  // Next argument is optional path (if not starting with -)
289
292
  if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
293
+ rejectUrlLikeLocalReviewPath(args[i + 1]);
290
294
  flags.localPath = args[i + 1];
291
295
  i++; // Skip next argument since we consumed it
292
296
  }
@@ -432,26 +436,8 @@ AI PROVIDERS:
432
436
  showWelcomeMessage();
433
437
  }
434
438
 
435
- // Initialize database
436
- console.log('Initializing database...');
437
- db = await initializeDatabase(resolveDbName(config));
438
-
439
- // Migrate existing worktrees to database (if any)
440
- const path = require('path');
441
- const worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
442
- const migrationResult = await migrateExistingWorktrees(db, worktreeBaseDir);
443
- if (migrationResult.migrated > 0) {
444
- console.log(`Migrated ${migrationResult.migrated} existing worktrees to database`);
445
- }
446
- if (migrationResult.errors.length > 0) {
447
- console.warn('Some worktrees could not be migrated:', migrationResult.errors);
448
- }
449
-
450
- // Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
451
- const poolLifecycle = new WorktreePoolLifecycle(db, config);
452
- await poolLifecycle.resetAndRehydrate();
453
-
454
- // Parse command line arguments including flags
439
+ // Parse command line arguments including flags (before DB init so
440
+ // single-port delegation can skip DB entirely)
455
441
  const { prArgs, flags } = parseArgs(args);
456
442
 
457
443
  // Apply debug_stream from config if not already enabled by CLI flag
@@ -474,6 +460,36 @@ AI PROVIDERS:
474
460
  // server, so we must also apply here.
475
461
  applyConfigOverrides(config);
476
462
 
463
+ // Single-port delegation: if a pair-review server is already running on the
464
+ // configured port, delegate to it (open browser URL) and exit immediately.
465
+ // Skipped for: headless modes (no browser), single_port: false (dev mode).
466
+ if (config.single_port !== false && !flags.aiReview && !flags.aiDraft) {
467
+ const delegated = await attemptDelegation(config, flags, prArgs);
468
+ if (delegated) {
469
+ process.exit(0);
470
+ }
471
+ // Not delegated — no server running, proceed to start one
472
+ }
473
+
474
+ // Initialize database
475
+ console.log('Initializing database...');
476
+ db = await initializeDatabase(resolveDbName(config));
477
+
478
+ // Migrate existing worktrees to database (if any)
479
+ const path = require('path');
480
+ const worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
481
+ const migrationResult = await migrateExistingWorktrees(db, worktreeBaseDir);
482
+ if (migrationResult.migrated > 0) {
483
+ console.log(`Migrated ${migrationResult.migrated} existing worktrees to database`);
484
+ }
485
+ if (migrationResult.errors.length > 0) {
486
+ console.warn('Some worktrees could not be migrated:', migrationResult.errors);
487
+ }
488
+
489
+ // Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
490
+ const poolLifecycle = new WorktreePoolLifecycle(db, config);
491
+ await poolLifecycle.resetAndRehydrate();
492
+
477
493
  // Check for local mode (review uncommitted local changes)
478
494
  if (flags.local) {
479
495
  // Resolve localPath, defaulting to cwd if not provided
@@ -710,7 +726,7 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
710
726
 
711
727
  // Ensure we have the base SHA available (fetch if needed)
712
728
  try {
713
- await git.fetch(['origin', prData.base_sha]);
729
+ await fetchNoTags(git, ['origin', prData.base_sha]);
714
730
  } catch (fetchError) {
715
731
  // Fetch by SHA may fail (not all servers support it); verify SHA is available locally
716
732
  try {
@@ -1212,7 +1228,7 @@ function startPoolBackgroundFetches(db, config) {
1212
1228
  const git = simpleGit(entry.path, { timeout: { block: 300000 } });
1213
1229
  const remotes = await git.getRemotes();
1214
1230
  const remote = remotes.find(r => r.name === 'origin') || remotes[0];
1215
- if (remote) await git.fetch([remote.name, '--prune']);
1231
+ if (remote) await fetchNoTags(git, ['--prune', remote.name]);
1216
1232
  await poolRepo.updateLastFetched(entry.id);
1217
1233
  // Refresh the lease so the stale guard only needs to outlive
1218
1234
  // a single stalled fetch, not the entire serial loop.
@@ -1281,4 +1297,4 @@ if (require.main === module) {
1281
1297
  main();
1282
1298
  }
1283
1299
 
1284
- module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
1300
+ module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
package/src/mcp-stdio.js CHANGED
@@ -45,6 +45,13 @@ async function startMCPStdio() {
45
45
  console.error(`[MCP] Warning: failed to load config, using defaults: ${err.message}`);
46
46
  }
47
47
 
48
+ // MCP mode needs its own Express server for stdio↔HTTP bridging and cannot
49
+ // delegate to a running pair-review instance (the stdio transport owns this
50
+ // process). Force auto-port selection to avoid EADDRINUSE when a regular
51
+ // pair-review server is already running on config.port.
52
+ // startServer (src/server.js) reads this env var and flips config.single_port.
53
+ process.env.PAIR_REVIEW_SINGLE_PORT = 'false';
54
+
48
55
  const db = await initializeDatabase(resolveDbName(config));
49
56
  const port = await startServer(db);
50
57
 
@@ -22,11 +22,19 @@ const {
22
22
  const { normalizeRepository } = require('../utils/paths');
23
23
  const { isRunningViaNpx, getGitHubToken } = require('../config');
24
24
  const { version } = require('../../package.json');
25
+ const semver = require('semver');
25
26
  const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
26
27
  const logger = require('../utils/logger');
27
28
 
28
29
  const router = express.Router();
29
30
 
31
+ // Module-level state: the most recent version we've been told about that's
32
+ // newer than the running server. Plain string, not an object. `null` means
33
+ // nothing is pending. Reset on process restart — which is fine because a
34
+ // restart either IS the update (running version is now newer) or loses no
35
+ // information (the next notifier will re-populate it).
36
+ let pendingUpdateVersion = null;
37
+
30
38
  /**
31
39
  * Get user configuration (for frontend use)
32
40
  * Returns safe-to-expose configuration values
@@ -71,10 +79,46 @@ router.get('/api/config', (req, res) => {
71
79
  icon: config.share.icon || null,
72
80
  label: config.share.label || null,
73
81
  description: config.share.description || null
74
- } : null
82
+ } : null,
83
+ pending_update: pendingUpdateVersion
75
84
  });
76
85
  });
77
86
 
87
+ /**
88
+ * Notify the running server that a newer version is available.
89
+ * Called by a newer CLI invocation delegating to this server.
90
+ * Stores state so browser tabs can pick it up via GET /api/config.
91
+ *
92
+ * Suppression is version-based, not time-based: a POST is accepted only
93
+ * when the incoming version is strictly newer than both the running version
94
+ * and any currently-pending version. This means `pendingUpdateVersion`
95
+ * monotonically increases for the life of the process.
96
+ */
97
+ router.post('/api/notify-update', (req, res) => {
98
+ const incomingVersion = req.body?.version;
99
+ if (!incomingVersion || !semver.valid(incomingVersion)) {
100
+ return res.status(400).json({ error: 'Invalid version' });
101
+ }
102
+
103
+ if (!semver.gt(incomingVersion, version)) {
104
+ return res.json({ ok: true, notified: false, reason: 'not_newer' });
105
+ }
106
+
107
+ // Suppress unless the incoming version is STRICTLY newer than what's
108
+ // already pending. Handles three cases at once:
109
+ // - incoming == pending → suppressed (nothing new)
110
+ // - incoming > pending → accepted (genuinely newer, falls through)
111
+ // - incoming < pending → suppressed (downgrade — user already knows)
112
+ if (pendingUpdateVersion && !semver.gt(incomingVersion, pendingUpdateVersion)) {
113
+ return res.json({ ok: true, notified: false, reason: 'not_newer_than_pending' });
114
+ }
115
+
116
+ pendingUpdateVersion = incomingVersion;
117
+ logger.info(`New version available: ${incomingVersion} (running ${version})`);
118
+
119
+ res.json({ ok: true, notified: true });
120
+ });
121
+
78
122
  /**
79
123
  * Get repository-specific settings
80
124
  * Returns default_instructions, default_provider, and default_model for the repository
@@ -328,4 +372,14 @@ router.post('/api/providers/refresh-availability', async (req, res) => {
328
372
  }
329
373
  });
330
374
 
375
+ /**
376
+ * Test-only helper: reset the in-memory pending-update state.
377
+ * Not exported from index — intended for use by integration tests that
378
+ * share the same module instance and need isolation between cases.
379
+ */
380
+ function _resetPendingUpdate() {
381
+ pendingUpdateVersion = null;
382
+ }
383
+
331
384
  module.exports = router;
385
+ module.exports._resetPendingUpdate = _resetPendingUpdate;
@@ -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
@@ -14,6 +14,19 @@ let db = null;
14
14
  let server = null;
15
15
  let chatSessionManager = null;
16
16
 
17
+ /**
18
+ * Apply env var overrides to config after loadConfig().
19
+ * Currently handles PAIR_REVIEW_SINGLE_PORT — a bridge for callers that
20
+ * need to force multi-port mode (e.g. mcp-stdio.js). Matches the
21
+ * PAIR_REVIEW_YOLO bridge pattern.
22
+ */
23
+ function applyEnvOverrides(config) {
24
+ if (process.env.PAIR_REVIEW_SINGLE_PORT === 'false') {
25
+ config.single_port = false;
26
+ }
27
+ return config;
28
+ }
29
+
17
30
  /**
18
31
  * Request logging middleware (disabled for cleaner output)
19
32
  */
@@ -93,6 +106,7 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
93
106
  try {
94
107
  // Load configuration
95
108
  const { config } = await loadConfig();
109
+ applyEnvOverrides(config);
96
110
 
97
111
  // Apply provider configuration overrides (custom models, commands, etc.)
98
112
  applyConfigOverrides(config);
@@ -294,9 +308,14 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
294
308
  res.sendFile(path.join(__dirname, '..', 'public', 'local.html'));
295
309
  });
296
310
 
297
- // Health check endpoint
311
+ // Health check endpoint (also used by single-port detection)
298
312
  app.get('/health', (req, res) => {
299
- res.json({ status: 'ok', timestamp: new Date().toISOString() });
313
+ res.json({
314
+ status: 'ok',
315
+ service: 'pair-review',
316
+ version: require('../package.json').version,
317
+ timestamp: new Date().toISOString()
318
+ });
300
319
  });
301
320
 
302
321
  // Store database instance, GitHub token, and config for routes
@@ -365,7 +384,9 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
365
384
  });
366
385
 
367
386
  // Find available port and start server
368
- const port = await findAvailablePort(app, config.port);
387
+ const port = config.single_port !== false
388
+ ? config.port // single-port mode: use exact port, fail if unavailable
389
+ : await findAvailablePort(app, config.port);
369
390
 
370
391
  // Check provider availability before accepting requests so /api/config
371
392
  // returns accurate pi_available on the very first request (avoids race
@@ -381,14 +402,35 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
381
402
  console.warn('Provider availability check failed:', err.message);
382
403
  }
383
404
 
384
- server = app.listen(port, () => {
385
- console.log(`Server running on http://localhost:${port}`);
386
- attachWebSocket(server, db, poolLifecycle);
405
+ await new Promise((resolve, reject) => {
406
+ server = app.listen(port, () => {
407
+ console.log(`Server running on http://localhost:${port}`);
408
+ attachWebSocket(server, db, poolLifecycle);
409
+ resolve();
410
+ });
411
+
412
+ // .once instead of .on: this handler detaches after firing exactly once,
413
+ // so the post-startup handler below doesn't double-handle EADDRINUSE/EACCES
414
+ // during the initial bind.
415
+ server.once('error', (error) => {
416
+ if (error.code === 'EADDRINUSE' && config.single_port !== false) {
417
+ reject(new Error(
418
+ `Port ${port} is already in use. A pair-review server may already be running, ` +
419
+ `or another service is using this port. ` +
420
+ `Set "single_port": false in ~/.pair-review/config.json to use automatic port selection.`
421
+ ));
422
+ } else {
423
+ reject(error);
424
+ }
425
+ });
387
426
  });
388
427
 
428
+ // Post-startup error handler. Express middleware handles request-level errors,
429
+ // so this only fires for lower-level issues like accept-loop failures (EMFILE,
430
+ // ENFILE from file descriptor exhaustion). Log but do NOT process.exit — the
431
+ // old code did that and it was too aggressive for transient errors.
389
432
  server.on('error', (error) => {
390
- console.error('Server error:', error);
391
- process.exit(1);
433
+ console.error('Server error after startup:', error);
392
434
  });
393
435
 
394
436
  // Return the actual port the server started on
@@ -468,4 +510,4 @@ if (require.main === module) {
468
510
  startServer();
469
511
  }
470
512
 
471
- module.exports = { startServer };
513
+ module.exports = { startServer, applyEnvOverrides };
@@ -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;
@@ -0,0 +1,193 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const http = require('http');
3
+ const path = require('path');
4
+ const semver = require('semver');
5
+ const { PRArgumentParser } = require('./github/parser');
6
+ const logger = require('./utils/logger');
7
+ const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
8
+ const { version: packageVersion } = require('../package.json');
9
+
10
+ const HEALTH_TIMEOUT_MS = 2000;
11
+
12
+ // Default dependencies (overridable for testing)
13
+ const defaults = {
14
+ httpGet: http.get,
15
+ httpRequest: http.request,
16
+ logger,
17
+ open: (...args) => process.env.PAIR_REVIEW_NO_OPEN
18
+ ? Promise.resolve()
19
+ : import('open').then(({ default: open }) => open(...args)),
20
+ PRArgumentParser
21
+ };
22
+
23
+ /**
24
+ * Check if a pair-review server is already running on the given port.
25
+ * @param {number} port
26
+ * @param {object} [_deps] - Dependency overrides for testing
27
+ * @returns {Promise<{running: boolean, isPairReview?: boolean, version?: string}>}
28
+ */
29
+ function detectRunningServer(port, _deps) {
30
+ const deps = { ...defaults, ..._deps };
31
+ return new Promise((resolve) => {
32
+ const req = deps.httpGet(`http://localhost:${port}/health`, { timeout: HEALTH_TIMEOUT_MS }, (res) => {
33
+ let data = '';
34
+ res.on('data', (chunk) => { data += chunk; });
35
+ res.on('end', () => {
36
+ try {
37
+ const body = JSON.parse(data);
38
+ if (body.service === 'pair-review') {
39
+ resolve({ running: true, isPairReview: true, version: body.version || null });
40
+ } else {
41
+ resolve({ running: true, isPairReview: false });
42
+ }
43
+ } catch {
44
+ resolve({ running: true, isPairReview: false });
45
+ }
46
+ });
47
+ });
48
+
49
+ req.on('error', () => {
50
+ resolve({ running: false });
51
+ });
52
+
53
+ req.on('timeout', () => {
54
+ req.destroy();
55
+ resolve({ running: false });
56
+ });
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Notify the running server that a newer version is available.
62
+ * Fire-and-forget — does not block on response.
63
+ * @param {number} port
64
+ * @param {string} currentVersion - Version of the current CLI invocation
65
+ * @param {object} [_deps] - Dependency overrides for testing
66
+ */
67
+ function notifyVersion(port, currentVersion, _deps) {
68
+ const deps = { ...defaults, ..._deps };
69
+ const payload = JSON.stringify({ version: currentVersion });
70
+ const req = deps.httpRequest({
71
+ hostname: 'localhost',
72
+ port,
73
+ path: '/api/notify-update',
74
+ method: 'POST',
75
+ headers: {
76
+ 'Content-Type': 'application/json',
77
+ 'Content-Length': Buffer.byteLength(payload)
78
+ },
79
+ timeout: HEALTH_TIMEOUT_MS
80
+ }, () => { /* ignore response */ });
81
+
82
+ req.on('error', () => { /* fire and forget */ });
83
+ req.on('timeout', () => { req.destroy(); });
84
+ req.write(payload);
85
+ req.end();
86
+ }
87
+
88
+ /**
89
+ * Build the URL to delegate to an existing server.
90
+ * @param {number} port
91
+ * @param {'pr'|'local'|'server'} mode
92
+ * @param {object} context
93
+ * @param {string} [context.owner]
94
+ * @param {string} [context.repo]
95
+ * @param {number} [context.number]
96
+ * @param {string} [context.localPath]
97
+ * @param {boolean} [context.analyze] - Whether to trigger auto-analysis
98
+ * @returns {string} Full URL
99
+ */
100
+ function buildDelegationUrl(port, mode, context = {}) {
101
+ const base = `http://localhost:${port}`;
102
+ if (mode === 'pr') {
103
+ let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
104
+ if (context.analyze) url += '?analyze=true';
105
+ return url;
106
+ }
107
+ if (mode === 'local') {
108
+ let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
109
+ if (context.analyze) url += '&analyze=true';
110
+ return url;
111
+ }
112
+ return `${base}/`;
113
+ }
114
+
115
+ /**
116
+ * Parse PR arguments for URL construction without starting a server.
117
+ * Reuses PRArgumentParser — synchronous for URLs, async for bare numbers.
118
+ * @param {string[]} prArgs - Raw CLI PR arguments
119
+ * @param {object} [_deps] - Dependency overrides for testing
120
+ * @returns {Promise<{owner: string, repo: string, number: number}>}
121
+ */
122
+ async function parsePRArgsForDelegation(prArgs, _deps) {
123
+ const deps = { ...defaults, ..._deps };
124
+ const parser = new deps.PRArgumentParser();
125
+ return parser.parsePRArguments(prArgs);
126
+ }
127
+
128
+ /**
129
+ * Attempt single-port delegation. Returns true if delegation happened (caller should exit).
130
+ * Returns false if no running server was found (caller should start normally).
131
+ * Throws if port is occupied by a non-pair-review service.
132
+ *
133
+ * @param {object} config - Loaded config
134
+ * @param {object} flags - Parsed CLI flags
135
+ * @param {string[]} prArgs - PR arguments from CLI
136
+ * @param {object} [_deps] - Dependency overrides for testing
137
+ * @returns {Promise<boolean>} true if delegated, false if should start fresh
138
+ */
139
+ async function attemptDelegation(config, flags, prArgs, _deps) {
140
+ const deps = { ...defaults, ..._deps };
141
+ const port = config.port;
142
+
143
+ const result = await detectRunningServer(port, _deps);
144
+
145
+ if (result.running && !result.isPairReview) {
146
+ throw new Error(
147
+ `Port ${port} is in use by another service. ` +
148
+ `Either stop that service, or set a different port in ~/.pair-review/config.json`
149
+ );
150
+ }
151
+
152
+ if (!result.running) {
153
+ return false;
154
+ }
155
+
156
+ // Server is running — delegate to it
157
+ deps.logger.info(`Existing pair-review server detected on port ${port} (v${result.version})`);
158
+
159
+ // Determine mode and build URL
160
+ let url;
161
+ if (flags.local) {
162
+ rejectUrlLikeLocalReviewPath(flags.localPath);
163
+ const targetPath = path.resolve(flags.localPath || process.cwd());
164
+ url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze: flags.ai });
165
+ } else if (prArgs.length > 0) {
166
+ const prInfo = await parsePRArgsForDelegation(prArgs, _deps);
167
+ url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze: flags.ai });
168
+ } else {
169
+ url = buildDelegationUrl(port, 'server');
170
+ }
171
+
172
+ // Notify running server of newer version if applicable
173
+ if (result.version && semver.valid(packageVersion) && semver.valid(result.version)) {
174
+ if (semver.gt(packageVersion, result.version)) {
175
+ deps.logger.info(`Notifying server of newer version: ${packageVersion} > ${result.version}`);
176
+ notifyVersion(port, packageVersion, _deps);
177
+ }
178
+ }
179
+
180
+ // Open browser and exit
181
+ deps.logger.info(`Delegating to running server: ${url}`);
182
+ await deps.open(url);
183
+ return true;
184
+ }
185
+
186
+ module.exports = {
187
+ detectRunningServer,
188
+ notifyVersion,
189
+ buildDelegationUrl,
190
+ parsePRArgsForDelegation,
191
+ attemptDelegation,
192
+ HEALTH_TIMEOUT_MS
193
+ };