@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/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/styles.css +14 -2
- package/public/index.html +1 -0
- package/public/js/components/UpdateBanner.js +143 -0
- package/public/js/index.js +43 -0
- package/public/js/modules/diff-renderer.js +3 -0
- package/public/local.html +2 -0
- package/public/pr.html +2 -0
- package/public/setup.html +1 -0
- package/src/config.js +35 -21
- package/src/database.js +14 -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/local-review.js +3 -0
- package/src/main.js +39 -23
- package/src/mcp-stdio.js +7 -0
- package/src/routes/config.js +55 -1
- 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 +51 -9
- package/src/setup/local-setup.js +5 -1
- package/src/setup/pr-setup.js +7 -4
- package/src/single-port.js +193 -0
- package/src/utils/local-path-input.js +44 -0
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
|
-
//
|
|
436
|
-
|
|
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
|
|
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
|
|
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
|
|
package/src/routes/config.js
CHANGED
|
@@ -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;
|
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
|
@@ -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({
|
|
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 =
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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 };
|
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;
|
|
@@ -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
|
+
};
|