@in-the-loop-labs/pair-review 3.3.6 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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/AIPanel.js +3 -14
- package/public/js/components/UpdateBanner.js +143 -0
- package/public/js/modules/diff-renderer.js +103 -7
- package/public/js/modules/file-comment-manager.js +34 -13
- package/public/js/modules/suggestion-manager.js +22 -6
- package/public/js/pr.js +3 -3
- package/public/local.html +2 -0
- package/public/pr.html +2 -0
- package/public/setup.html +1 -0
- package/src/config.js +29 -6
- package/src/git/diff-flags.js +5 -1
- package/src/git/worktree.js +23 -4
- package/src/local-review.js +26 -10
- package/src/main.js +33 -20
- package/src/mcp-stdio.js +7 -0
- package/src/routes/config.js +55 -1
- package/src/routes/pr.js +26 -10
- package/src/server.js +51 -9
- package/src/single-port.js +191 -0
- package/src/utils/diff-file-list.js +111 -2
- package/src/utils/paths.js +4 -0
package/src/config.js
CHANGED
|
@@ -19,6 +19,7 @@ const DEFAULT_CONFIG = {
|
|
|
19
19
|
github_token: "",
|
|
20
20
|
github_token_command: "gh auth token", // Shell command whose stdout is used as the GitHub token
|
|
21
21
|
port: 7247,
|
|
22
|
+
single_port: true, // When true, reuse a single server on the configured port; new invocations delegate to the running server
|
|
22
23
|
theme: "light",
|
|
23
24
|
default_provider: "claude", // AI provider: 'claude', 'gemini', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
|
|
24
25
|
default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-2.5-pro' for Gemini)
|
|
@@ -128,7 +129,7 @@ async function copyExampleConfig() {
|
|
|
128
129
|
try {
|
|
129
130
|
await fs.access(sourceExample);
|
|
130
131
|
await fs.copyFile(sourceExample, CONFIG_EXAMPLE_FILE);
|
|
131
|
-
|
|
132
|
+
logger.info(`Copied config.example.json to: ${CONFIG_EXAMPLE_FILE}`);
|
|
132
133
|
return true;
|
|
133
134
|
} catch (error) {
|
|
134
135
|
if (error.code === 'ENOENT') {
|
|
@@ -154,13 +155,13 @@ async function ensureConfigDir() {
|
|
|
154
155
|
if (error.code === 'ENOENT') {
|
|
155
156
|
try {
|
|
156
157
|
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
157
|
-
|
|
158
|
+
logger.info(`Created config directory: ${CONFIG_DIR}`);
|
|
158
159
|
// Copy example config to new directory
|
|
159
160
|
await copyExampleConfig();
|
|
160
161
|
return true; // Directory was newly created
|
|
161
162
|
} catch (mkdirError) {
|
|
162
163
|
if (mkdirError.code === 'EACCES' || mkdirError.code === 'EPERM') {
|
|
163
|
-
|
|
164
|
+
logger.error(`Cannot create configuration directory at ~/.pair-review/`);
|
|
164
165
|
process.exit(1);
|
|
165
166
|
}
|
|
166
167
|
throw mkdirError;
|
|
@@ -211,7 +212,7 @@ async function loadConfig() {
|
|
|
211
212
|
// Optional files or managed-config-present: skip silently
|
|
212
213
|
} else if (error instanceof SyntaxError) {
|
|
213
214
|
if (source.required) {
|
|
214
|
-
|
|
215
|
+
logger.error(`Invalid configuration file at ~/.pair-review/config.json`);
|
|
215
216
|
process.exit(1);
|
|
216
217
|
}
|
|
217
218
|
logger.warn(`Malformed config at ${source.label}, skipping`);
|
|
@@ -235,9 +236,19 @@ async function loadConfig() {
|
|
|
235
236
|
mergedConfig.repos = normalized;
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
// PORT env var overrides all config layers (used by Preview and similar harnesses)
|
|
240
|
+
if (process.env.PORT) {
|
|
241
|
+
const envPort = Number(process.env.PORT);
|
|
242
|
+
if (!validatePort(envPort)) {
|
|
243
|
+
logger.error(`Invalid PORT env var "${process.env.PORT}" (must be an integer between 1024 and 65535)`);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
mergedConfig.port = envPort;
|
|
247
|
+
}
|
|
248
|
+
|
|
238
249
|
// Validate port
|
|
239
250
|
if (!validatePort(mergedConfig.port)) {
|
|
240
|
-
|
|
251
|
+
logger.error(`Invalid port number ${mergedConfig.port}`);
|
|
241
252
|
process.exit(1);
|
|
242
253
|
}
|
|
243
254
|
|
|
@@ -269,7 +280,7 @@ async function saveConfig(config) {
|
|
|
269
280
|
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
270
281
|
} catch (error) {
|
|
271
282
|
if (error.code === 'EACCES' || error.code === 'EPERM') {
|
|
272
|
-
|
|
283
|
+
logger.error(`Cannot create configuration directory at ~/.pair-review/`);
|
|
273
284
|
process.exit(1);
|
|
274
285
|
}
|
|
275
286
|
throw error;
|
|
@@ -517,6 +528,17 @@ function getRepoResetScript(config, repository) {
|
|
|
517
528
|
return repoConfig?.reset_script || null;
|
|
518
529
|
}
|
|
519
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Gets whether updateWorktree should skip the bulk `git fetch <remote> --prune`
|
|
533
|
+
* @param {Object} config - Configuration object from loadConfig()
|
|
534
|
+
* @param {string} repository - Repository in "owner/repo" format
|
|
535
|
+
* @returns {boolean} - true if the bulk fetch should be skipped (default: false)
|
|
536
|
+
*/
|
|
537
|
+
function getRepoSkipBulkFetch(config, repository) {
|
|
538
|
+
const repoConfig = getRepoConfig(config, repository);
|
|
539
|
+
return repoConfig?.skip_bulk_fetch === true;
|
|
540
|
+
}
|
|
541
|
+
|
|
520
542
|
/**
|
|
521
543
|
* Gets the configured pool size for a repository from file config only.
|
|
522
544
|
* Prefer resolvePoolConfig() when DB repo_settings are available.
|
|
@@ -750,6 +772,7 @@ module.exports = {
|
|
|
750
772
|
getRepoCheckoutTimeout,
|
|
751
773
|
resolveRepoOptions,
|
|
752
774
|
getRepoResetScript,
|
|
775
|
+
getRepoSkipBulkFetch,
|
|
753
776
|
getRepoPoolSize,
|
|
754
777
|
getRepoPoolFetchInterval,
|
|
755
778
|
getRepoLoadSkills,
|
package/src/git/diff-flags.js
CHANGED
|
@@ -30,12 +30,16 @@ const GIT_DIFF_FLAGS_ARRAY = [
|
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* Array form for simple-git .diffSummary() calls.
|
|
33
|
+
* Use --numstat so simple-git parses machine-readable output with exact file paths.
|
|
34
|
+
* The default --stat output is display-oriented and may abbreviate long paths,
|
|
35
|
+
* which breaks downstream matching for generated route files and similar cases.
|
|
33
36
|
* Omits --src-prefix/--dst-prefix since diffSummary doesn't output file content with prefixes.
|
|
34
37
|
*/
|
|
35
38
|
const GIT_DIFF_SUMMARY_FLAGS_ARRAY = [
|
|
36
39
|
'--no-color',
|
|
37
40
|
'--no-ext-diff',
|
|
38
|
-
'--no-relative'
|
|
41
|
+
'--no-relative',
|
|
42
|
+
'--numstat'
|
|
39
43
|
];
|
|
40
44
|
|
|
41
45
|
module.exports = {
|
package/src/git/worktree.js
CHANGED
|
@@ -733,9 +733,11 @@ class GitWorktreeManager {
|
|
|
733
733
|
* @param {string} repo - Repository name
|
|
734
734
|
* @param {number} number - PR number
|
|
735
735
|
* @param {Object} prData - PR data from GitHub API (for remote resolution)
|
|
736
|
+
* @param {Object} [options]
|
|
737
|
+
* @param {boolean} [options.skipBulkFetch=false] - Skip the bulk `git fetch <remote> --prune`; targeted base-SHA and PR-head fetches still run
|
|
736
738
|
* @returns {Promise<string>} Path to updated worktree
|
|
737
739
|
*/
|
|
738
|
-
async updateWorktree(owner, repo, number, prData) {
|
|
740
|
+
async updateWorktree(owner, repo, number, prData, options = {}) {
|
|
739
741
|
const prInfo = { owner, repo, number };
|
|
740
742
|
const headSha = this.getPRHeadSha(prData);
|
|
741
743
|
const worktreePath = await this.getWorktreePath(prInfo);
|
|
@@ -756,9 +758,26 @@ class GitWorktreeManager {
|
|
|
756
758
|
const remote = await this.resolveRemoteForPR(worktreeGit, prData, prInfo);
|
|
757
759
|
|
|
758
760
|
// Fetch the latest from the resolved remote (--prune removes stale
|
|
759
|
-
// tracking refs that would otherwise block fetch on ref hierarchy conflicts)
|
|
760
|
-
|
|
761
|
-
|
|
761
|
+
// tracking refs that would otherwise block fetch on ref hierarchy conflicts).
|
|
762
|
+
// Opt out via skip_bulk_fetch on very large monorepos where this is too slow;
|
|
763
|
+
// the targeted base-SHA and PR-head ref fetches below still run.
|
|
764
|
+
if (options.skipBulkFetch) {
|
|
765
|
+
console.log(`Skipping bulk fetch from ${remote} (skip_bulk_fetch enabled)`);
|
|
766
|
+
// Still fetch only the PR's base branch so ensureBaseShaAvailable does not
|
|
767
|
+
// have to fall back to `git fetch <remote> <sha>`, which some Git servers
|
|
768
|
+
// and mirrors reject (they require uploadpack.allowReachableSHA1InWant).
|
|
769
|
+
// This mirrors the targeted fetch used in createWorktreeForPR.
|
|
770
|
+
if (prData?.base_branch) {
|
|
771
|
+
try {
|
|
772
|
+
await worktreeGit.fetch([remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
|
|
773
|
+
} catch (fetchError) {
|
|
774
|
+
console.warn(`Targeted base-branch fetch failed, will rely on existing refs: ${fetchError.message}`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
console.log(`Fetching latest changes from ${remote}...`);
|
|
779
|
+
await worktreeGit.fetch([remote, '--prune']);
|
|
780
|
+
}
|
|
762
781
|
|
|
763
782
|
await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
|
|
764
783
|
|
package/src/local-review.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
const { execSync, exec } = require('child_process');
|
|
2
|
+
const { execSync, exec, execFileSync } = require('child_process');
|
|
3
3
|
const { promisify } = require('util');
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const path = require('path');
|
|
@@ -15,7 +15,7 @@ const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require
|
|
|
15
15
|
const { startServer } = require('./server');
|
|
16
16
|
const { localReviewDiffs } = require('./routes/shared');
|
|
17
17
|
const { getShaAbbrevLength } = require('./git/sha-abbrev');
|
|
18
|
-
const { GIT_DIFF_FLAGS } = require('./git/diff-flags');
|
|
18
|
+
const { GIT_DIFF_FLAGS, GIT_DIFF_FLAGS_ARRAY } = require('./git/diff-flags');
|
|
19
19
|
const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({ default: open }) => open(...args));
|
|
20
20
|
|
|
21
21
|
// Design note: This module uses execSync for git commands despite async function signatures.
|
|
@@ -393,12 +393,23 @@ async function findMergeBase(repoPath, baseBranch) {
|
|
|
393
393
|
* Generate diff output for untracked files using git diff --no-index.
|
|
394
394
|
* @param {string} repoPath - Path to the git repository
|
|
395
395
|
* @param {Array} untrackedFiles - Array from getUntrackedFiles()
|
|
396
|
-
* @param {
|
|
397
|
-
* @param {
|
|
398
|
-
* @param {
|
|
396
|
+
* @param {Object} [options]
|
|
397
|
+
* @param {boolean} [options.hideWhitespace=false] - Whether to pass -w
|
|
398
|
+
* @param {number} [options.contextLines=25] - Number of unified context lines
|
|
399
|
+
* @param {string[]} [options.extraArgs=[]] - Additional git diff flags
|
|
399
400
|
* @returns {string} Combined diff text for untracked files
|
|
400
401
|
*/
|
|
401
|
-
function generateUntrackedDiffs(repoPath, untrackedFiles,
|
|
402
|
+
function generateUntrackedDiffs(repoPath, untrackedFiles, options = {}) {
|
|
403
|
+
const diffArgs = [
|
|
404
|
+
'diff',
|
|
405
|
+
'--no-index',
|
|
406
|
+
...GIT_DIFF_FLAGS_ARRAY,
|
|
407
|
+
`--unified=${options.contextLines ?? 25}`,
|
|
408
|
+
...(options.extraArgs || []),
|
|
409
|
+
...(options.hideWhitespace ? ['-w'] : []),
|
|
410
|
+
'--',
|
|
411
|
+
'/dev/null'
|
|
412
|
+
];
|
|
402
413
|
let diff = '';
|
|
403
414
|
for (const untracked of untrackedFiles) {
|
|
404
415
|
if (!untracked.skipped) {
|
|
@@ -406,16 +417,21 @@ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag, contextFlag = '
|
|
|
406
417
|
const filePath = path.join(repoPath, untracked.file);
|
|
407
418
|
let fileDiff;
|
|
408
419
|
try {
|
|
409
|
-
fileDiff =
|
|
420
|
+
fileDiff = execFileSync('git', [...diffArgs, filePath], {
|
|
410
421
|
cwd: repoPath,
|
|
411
422
|
encoding: 'utf8',
|
|
412
423
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
413
424
|
maxBuffer: 10 * 1024 * 1024
|
|
414
425
|
});
|
|
415
426
|
} catch (diffError) {
|
|
427
|
+
const diffStdout = typeof diffError?.stdout === 'string'
|
|
428
|
+
? diffError.stdout
|
|
429
|
+
: Buffer.isBuffer(diffError?.stdout)
|
|
430
|
+
? diffError.stdout.toString('utf8')
|
|
431
|
+
: null;
|
|
416
432
|
if (diffError && typeof diffError === 'object' &&
|
|
417
|
-
diffError.status === GIT_DIFF_HAS_DIFFERENCES &&
|
|
418
|
-
fileDiff =
|
|
433
|
+
diffError.status === GIT_DIFF_HAS_DIFFERENCES && diffStdout !== null) {
|
|
434
|
+
fileDiff = diffStdout;
|
|
419
435
|
} else {
|
|
420
436
|
throw diffError;
|
|
421
437
|
}
|
|
@@ -552,7 +568,7 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
|
|
|
552
568
|
const untrackedFiles = await getUntrackedFiles(repoPath);
|
|
553
569
|
stats.untrackedFiles = untrackedFiles.length;
|
|
554
570
|
|
|
555
|
-
const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles,
|
|
571
|
+
const untrackedDiff = generateUntrackedDiffs(repoPath, untrackedFiles, options);
|
|
556
572
|
if (untrackedDiff) {
|
|
557
573
|
if (diff) diff += '\n';
|
|
558
574
|
diff += untrackedDiff;
|
package/src/main.js
CHANGED
|
@@ -20,6 +20,7 @@ const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./git/di
|
|
|
20
20
|
const { getEmoji: getCategoryEmoji } = require('./utils/category-emoji');
|
|
21
21
|
const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({default: open}) => open(...args));
|
|
22
22
|
const { registerProtocolHandler, unregisterProtocolHandler } = require('./protocol-handler');
|
|
23
|
+
const { attemptDelegation } = require('./single-port');
|
|
23
24
|
|
|
24
25
|
let db = null;
|
|
25
26
|
|
|
@@ -432,26 +433,8 @@ AI PROVIDERS:
|
|
|
432
433
|
showWelcomeMessage();
|
|
433
434
|
}
|
|
434
435
|
|
|
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
|
|
436
|
+
// Parse command line arguments including flags (before DB init so
|
|
437
|
+
// single-port delegation can skip DB entirely)
|
|
455
438
|
const { prArgs, flags } = parseArgs(args);
|
|
456
439
|
|
|
457
440
|
// Apply debug_stream from config if not already enabled by CLI flag
|
|
@@ -474,6 +457,36 @@ AI PROVIDERS:
|
|
|
474
457
|
// server, so we must also apply here.
|
|
475
458
|
applyConfigOverrides(config);
|
|
476
459
|
|
|
460
|
+
// Single-port delegation: if a pair-review server is already running on the
|
|
461
|
+
// configured port, delegate to it (open browser URL) and exit immediately.
|
|
462
|
+
// Skipped for: headless modes (no browser), single_port: false (dev mode).
|
|
463
|
+
if (config.single_port !== false && !flags.aiReview && !flags.aiDraft) {
|
|
464
|
+
const delegated = await attemptDelegation(config, flags, prArgs);
|
|
465
|
+
if (delegated) {
|
|
466
|
+
process.exit(0);
|
|
467
|
+
}
|
|
468
|
+
// Not delegated — no server running, proceed to start one
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Initialize database
|
|
472
|
+
console.log('Initializing database...');
|
|
473
|
+
db = await initializeDatabase(resolveDbName(config));
|
|
474
|
+
|
|
475
|
+
// Migrate existing worktrees to database (if any)
|
|
476
|
+
const path = require('path');
|
|
477
|
+
const worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
|
|
478
|
+
const migrationResult = await migrateExistingWorktrees(db, worktreeBaseDir);
|
|
479
|
+
if (migrationResult.migrated > 0) {
|
|
480
|
+
console.log(`Migrated ${migrationResult.migrated} existing worktrees to database`);
|
|
481
|
+
}
|
|
482
|
+
if (migrationResult.errors.length > 0) {
|
|
483
|
+
console.warn('Some worktrees could not be migrated:', migrationResult.errors);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Reset stale pool entries, wire idle callbacks, and rehydrate preserved entries
|
|
487
|
+
const poolLifecycle = new WorktreePoolLifecycle(db, config);
|
|
488
|
+
await poolLifecycle.resetAndRehydrate();
|
|
489
|
+
|
|
477
490
|
// Check for local mode (review uncommitted local changes)
|
|
478
491
|
if (flags.local) {
|
|
479
492
|
// Resolve localPath, defaulting to cwd if not provided
|
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/pr.js
CHANGED
|
@@ -25,7 +25,7 @@ const Analyzer = require('../ai/analyzer');
|
|
|
25
25
|
const { v4: uuidv4 } = require('uuid');
|
|
26
26
|
const fs = require('fs').promises;
|
|
27
27
|
const path = require('path');
|
|
28
|
-
const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
|
|
28
|
+
const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch } = require('../config');
|
|
29
29
|
const logger = require('../utils/logger');
|
|
30
30
|
const { buildDiffLineSet } = require('../utils/diff-annotator');
|
|
31
31
|
const { broadcastReviewEvent } = require('../events/review-events');
|
|
@@ -45,6 +45,7 @@ const {
|
|
|
45
45
|
registerProcess: registerProcessForCancellation
|
|
46
46
|
} = require('./shared');
|
|
47
47
|
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
48
|
+
const { mergeChangedFilesWithDiff } = require('../utils/diff-file-list');
|
|
48
49
|
const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
|
|
49
50
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
50
51
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
@@ -254,6 +255,8 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
// Prepare response
|
|
258
|
+
const changedFiles = mergeChangedFilesWithDiff(extendedData.changed_files || [], extendedData.diff || '');
|
|
259
|
+
|
|
257
260
|
// Use review.id instead of prMetadata.id to avoid ID collision with local mode
|
|
258
261
|
const response = {
|
|
259
262
|
success: true,
|
|
@@ -274,8 +277,8 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
274
277
|
shaAbbrevLength,
|
|
275
278
|
created_at: prMetadata.created_at,
|
|
276
279
|
updated_at: prMetadata.updated_at,
|
|
277
|
-
file_changes:
|
|
278
|
-
changed_files:
|
|
280
|
+
file_changes: changedFiles.length,
|
|
281
|
+
changed_files: changedFiles,
|
|
279
282
|
additions: extendedData.additions || 0,
|
|
280
283
|
deletions: extendedData.deletions || 0,
|
|
281
284
|
diff_content: extendedData.diff || '',
|
|
@@ -375,7 +378,13 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
375
378
|
|
|
376
379
|
// Update worktree with latest changes
|
|
377
380
|
const worktreeManager = new GitWorktreeManager(db);
|
|
378
|
-
const worktreePath = await worktreeManager.updateWorktree(
|
|
381
|
+
const worktreePath = await worktreeManager.updateWorktree(
|
|
382
|
+
owner,
|
|
383
|
+
repo,
|
|
384
|
+
prNumber,
|
|
385
|
+
prData,
|
|
386
|
+
{ skipBulkFetch: getRepoSkipBulkFetch(config, repository) }
|
|
387
|
+
);
|
|
379
388
|
|
|
380
389
|
// Generate fresh diff and get changed files
|
|
381
390
|
const diffPrData = {
|
|
@@ -759,6 +768,8 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
759
768
|
let diffContent = prData.diff || '';
|
|
760
769
|
let changedFiles = prData.changed_files || [];
|
|
761
770
|
|
|
771
|
+
let gitattributes = null;
|
|
772
|
+
|
|
762
773
|
if (hideWhitespace && worktreeRecord && worktreeRecord.path) {
|
|
763
774
|
try {
|
|
764
775
|
const worktreePath = worktreeRecord.path;
|
|
@@ -774,7 +785,7 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
774
785
|
|
|
775
786
|
const summaryArgs = [`${baseSha}...${headSha}`, ...GIT_DIFF_SUMMARY_FLAGS_ARRAY, '-w'];
|
|
776
787
|
const diffSummary = await git.diffSummary(summaryArgs);
|
|
777
|
-
|
|
788
|
+
gitattributes = await getGeneratedFilePatterns(worktreePath);
|
|
778
789
|
changedFiles = diffSummary.files.map(file => {
|
|
779
790
|
const resolvedFile = resolveRenamedFile(file.file);
|
|
780
791
|
const isRenamed = resolvedFile !== file.file;
|
|
@@ -799,17 +810,22 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
799
810
|
} else if (worktreeRecord && worktreeRecord.path) {
|
|
800
811
|
// Add generated flag to changed files based on .gitattributes
|
|
801
812
|
try {
|
|
802
|
-
|
|
803
|
-
changedFiles = changedFiles.map(file => ({
|
|
804
|
-
...file,
|
|
805
|
-
generated: gitattributes.isGenerated(file.file)
|
|
806
|
-
}));
|
|
813
|
+
gitattributes = await getGeneratedFilePatterns(worktreeRecord.path);
|
|
807
814
|
} catch (error) {
|
|
808
815
|
logger.warn(`Could not load .gitattributes: ${error.message}`);
|
|
809
816
|
// Continue without generated flags
|
|
810
817
|
}
|
|
811
818
|
}
|
|
812
819
|
|
|
820
|
+
changedFiles = mergeChangedFilesWithDiff(changedFiles, diffContent);
|
|
821
|
+
|
|
822
|
+
if (gitattributes) {
|
|
823
|
+
changedFiles = changedFiles.map(file => ({
|
|
824
|
+
...file,
|
|
825
|
+
generated: gitattributes.isGenerated(file.file)
|
|
826
|
+
}));
|
|
827
|
+
}
|
|
828
|
+
|
|
813
829
|
// When diff was regenerated (whitespace), compute aggregate stats from
|
|
814
830
|
// the regenerated changedFiles instead of using stale cached values from prData.
|
|
815
831
|
const additions = hideWhitespace
|
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 };
|