@in-the-loop-labs/pair-review 2.0.3 → 2.1.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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/skills/analyze/references/level1-balanced.md +9 -0
  5. package/plugin-code-critic/skills/analyze/references/level1-fast.md +9 -0
  6. package/plugin-code-critic/skills/analyze/references/level1-thorough.md +9 -0
  7. package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
  8. package/plugin-code-critic/skills/analyze/references/level2-fast.md +9 -0
  9. package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
  10. package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
  11. package/plugin-code-critic/skills/analyze/references/level3-fast.md +9 -0
  12. package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
  13. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
  14. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +9 -0
  15. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
  16. package/public/js/components/ChatPanel.js +1 -1
  17. package/src/ai/analyzer.js +5 -1
  18. package/src/ai/prompts/baseline/consolidation/balanced.js +9 -0
  19. package/src/ai/prompts/baseline/consolidation/fast.js +9 -0
  20. package/src/ai/prompts/baseline/consolidation/thorough.js +9 -0
  21. package/src/ai/prompts/baseline/level1/balanced.js +9 -0
  22. package/src/ai/prompts/baseline/level1/fast.js +9 -0
  23. package/src/ai/prompts/baseline/level1/thorough.js +9 -0
  24. package/src/ai/prompts/baseline/level2/balanced.js +9 -0
  25. package/src/ai/prompts/baseline/level2/fast.js +9 -0
  26. package/src/ai/prompts/baseline/level2/thorough.js +9 -0
  27. package/src/ai/prompts/baseline/level3/balanced.js +9 -0
  28. package/src/ai/prompts/baseline/level3/fast.js +9 -0
  29. package/src/ai/prompts/baseline/level3/thorough.js +9 -0
  30. package/src/ai/prompts/baseline/orchestration/balanced.js +9 -0
  31. package/src/ai/prompts/baseline/orchestration/fast.js +9 -0
  32. package/src/ai/prompts/baseline/orchestration/thorough.js +9 -0
  33. package/src/ai/prompts/shared/output-schema.js +10 -1
  34. package/src/chat/prompt-builder.js +6 -1
  35. package/src/config.js +83 -1
  36. package/src/database.js +5 -1
  37. package/src/git/worktree.js +169 -23
  38. package/src/main.js +36 -148
  39. package/src/setup/pr-setup.js +27 -7
package/src/config.js CHANGED
@@ -5,6 +5,7 @@ const os = require('os');
5
5
  const logger = require('./utils/logger');
6
6
 
7
7
  const CONFIG_DIR = path.join(os.homedir(), '.pair-review');
8
+ const DEFAULT_CHECKOUT_TIMEOUT_MS = 300000;
8
9
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
10
  const CONFIG_EXAMPLE_FILE = path.join(CONFIG_DIR, 'config.example.json');
10
11
  const PACKAGE_ROOT = path.join(__dirname, '..');
@@ -316,6 +317,81 @@ function getMonorepoPath(config, repository) {
316
317
  return null;
317
318
  }
318
319
 
320
+ /**
321
+ * Gets the configured checkout script for a monorepo repository
322
+ * @param {Object} config - Configuration object from loadConfig()
323
+ * @param {string} repository - Repository in "owner/repo" format
324
+ * @returns {string|null} - Checkout script path or null if not configured
325
+ */
326
+ function getMonorepoCheckoutScript(config, repository) {
327
+ const monorepoConfig = config.monorepos?.[repository];
328
+ return monorepoConfig?.checkout_script || null;
329
+ }
330
+
331
+ /**
332
+ * Gets the configured worktree directory for a monorepo repository
333
+ * @param {Object} config - Configuration object from loadConfig()
334
+ * @param {string} repository - Repository in "owner/repo" format
335
+ * @returns {string|null} - Expanded worktree directory path or null if not configured
336
+ */
337
+ function getMonorepoWorktreeDirectory(config, repository) {
338
+ const monorepoConfig = config.monorepos?.[repository];
339
+ if (monorepoConfig?.worktree_directory) {
340
+ return expandPath(monorepoConfig.worktree_directory);
341
+ }
342
+ return null;
343
+ }
344
+
345
+ /**
346
+ * Gets the configured worktree name template for a monorepo repository
347
+ * @param {Object} config - Configuration object from loadConfig()
348
+ * @param {string} repository - Repository in "owner/repo" format
349
+ * @returns {string|null} - Template string or null if not configured
350
+ */
351
+ function getMonorepoWorktreeNameTemplate(config, repository) {
352
+ const monorepoConfig = config.monorepos?.[repository];
353
+ return monorepoConfig?.worktree_name_template || null;
354
+ }
355
+
356
+ /**
357
+ * Gets the configured checkout script timeout for a monorepo repository
358
+ * @param {Object} config - Configuration object from loadConfig()
359
+ * @param {string} repository - Repository in "owner/repo" format
360
+ * @returns {number} - Timeout in milliseconds (default: 300000 = 5 minutes)
361
+ */
362
+ function getMonorepoCheckoutTimeout(config, repository) {
363
+ const monorepoConfig = config.monorepos?.[repository];
364
+ if (monorepoConfig?.checkout_timeout_seconds > 0) {
365
+ return monorepoConfig.checkout_timeout_seconds * 1000;
366
+ }
367
+ return DEFAULT_CHECKOUT_TIMEOUT_MS; // 5 minutes default
368
+ }
369
+
370
+ /**
371
+ * Resolves all monorepo worktree options for a repository into a single object.
372
+ * Composite helper that combines the individual getters into the shape expected
373
+ * by GitWorktreeManager and createWorktreeForPR.
374
+ *
375
+ * @param {Object} config - Configuration object from loadConfig()
376
+ * @param {string} repository - Repository in "owner/repo" format
377
+ * @returns {{ checkoutScript: string|null, checkoutTimeout: number, worktreeConfig: Object|null }}
378
+ */
379
+ function resolveMonorepoOptions(config, repository) {
380
+ const checkoutScript = getMonorepoCheckoutScript(config, repository);
381
+ const checkoutTimeout = getMonorepoCheckoutTimeout(config, repository);
382
+ const worktreeDirectory = getMonorepoWorktreeDirectory(config, repository);
383
+ const nameTemplate = getMonorepoWorktreeNameTemplate(config, repository);
384
+
385
+ let worktreeConfig = null;
386
+ if (worktreeDirectory || nameTemplate) {
387
+ worktreeConfig = {};
388
+ if (worktreeDirectory) worktreeConfig.worktreeBaseDir = worktreeDirectory;
389
+ if (nameTemplate) worktreeConfig.nameTemplate = nameTemplate;
390
+ }
391
+
392
+ return { checkoutScript, checkoutTimeout, worktreeConfig };
393
+ }
394
+
319
395
  /**
320
396
  * Resolves the database filename to use.
321
397
  * Priority:
@@ -358,6 +434,12 @@ module.exports = {
358
434
  showWelcomeMessage,
359
435
  expandPath,
360
436
  getMonorepoPath,
437
+ getMonorepoCheckoutScript,
438
+ getMonorepoWorktreeDirectory,
439
+ getMonorepoWorktreeNameTemplate,
440
+ getMonorepoCheckoutTimeout,
441
+ resolveMonorepoOptions,
361
442
  resolveDbName,
362
- warnIfDevModeWithoutDbName
443
+ warnIfDevModeWithoutDbName,
444
+ DEFAULT_CHECKOUT_TIMEOUT_MS
363
445
  };
package/src/database.js CHANGED
@@ -2193,8 +2193,12 @@ class CommentRepository {
2193
2193
  }
2194
2194
 
2195
2195
  for (const suggestion of normalized) {
2196
+ const suggestionText = suggestion.suggestion;
2197
+ const hasSuggestionBlock = suggestionText?.trimStart().startsWith('```suggestion');
2196
2198
  const body = suggestion.description +
2197
- (suggestion.suggestion ? '\n\n**Suggestion:** ' + suggestion.suggestion : '');
2199
+ (suggestionText
2200
+ ? (hasSuggestionBlock ? '\n\n' + suggestionText : '\n\n**Suggestion:** ' + suggestionText)
2201
+ : '');
2198
2202
 
2199
2203
  // File-level suggestions have is_file_level=true or have null line_start
2200
2204
  const isFileLevel = suggestion.is_file_level === true || suggestion.line_start === null ? 1 : 0;
@@ -3,10 +3,11 @@ const simpleGit = require('simple-git');
3
3
  const path = require('path');
4
4
  const fs = require('fs').promises;
5
5
  const os = require('os');
6
- const { getConfigDir } = require('../config');
6
+ const { getConfigDir, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
7
7
  const { WorktreeRepository, generateWorktreeId } = require('../database');
8
8
  const { getGeneratedFilePatterns } = require('./gitattributes');
9
9
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
10
+ const { spawn, execSync } = require('child_process');
10
11
 
11
12
  /**
12
13
  * Git worktree manager for handling PR branch checkouts and diffs
@@ -15,13 +16,43 @@ class GitWorktreeManager {
15
16
  /**
16
17
  * Create a new GitWorktreeManager instance
17
18
  * @param {sqlite3.Database} [db] - Optional database instance for worktree tracking
19
+ * @param {Object} [options] - Optional settings
20
+ * @param {string} [options.worktreeBaseDir] - Custom base directory for worktrees
21
+ * @param {string} [options.nameTemplate] - Template for worktree directory names
22
+ * Supported variables: {id}, {pr_number}, {repo}, {owner}
23
+ * Default: '{id}' (preserves current behavior)
18
24
  */
19
- constructor(db = null) {
20
- this.worktreeBaseDir = path.join(getConfigDir(), 'worktrees');
25
+ constructor(db = null, options = {}) {
26
+ this.worktreeBaseDir = options.worktreeBaseDir || path.join(getConfigDir(), 'worktrees');
27
+ this.nameTemplate = options.nameTemplate || '{id}';
21
28
  this.db = db;
22
29
  this.worktreeRepo = db ? new WorktreeRepository(db) : null;
23
30
  }
24
31
 
32
+ /**
33
+ * Apply the name template to generate a worktree directory name
34
+ * @param {Object} context - Template context variables
35
+ * @param {string} context.id - Random worktree ID
36
+ * @param {number} [context.prNumber] - PR number
37
+ * @param {string} [context.repo] - Repository name
38
+ * @param {string} [context.owner] - Repository owner
39
+ * @returns {string} Resolved directory name
40
+ */
41
+ applyNameTemplate(context) {
42
+ let name = this.nameTemplate;
43
+ name = name.replace(/\{id\}/g, context.id);
44
+ if (context.prNumber !== undefined) {
45
+ name = name.replace(/\{pr_number\}/g, String(context.prNumber));
46
+ }
47
+ if (context.repo) {
48
+ name = name.replace(/\{repo\}/g, context.repo);
49
+ }
50
+ if (context.owner) {
51
+ name = name.replace(/\{owner\}/g, context.owner);
52
+ }
53
+ return name;
54
+ }
55
+
25
56
  /**
26
57
  * Resolve which git remote points to the given repository URLs.
27
58
  * Compares normalized URLs against all configured remotes. If no match is
@@ -129,6 +160,65 @@ class GitWorktreeManager {
129
160
  return this.resolveRemoteForRepo(git, cloneUrl, sshUrl);
130
161
  }
131
162
 
163
+ /**
164
+ * Execute a user-provided checkout script in the worktree.
165
+ * The script receives PR context as environment variables and is responsible
166
+ * for configuring sparse-checkout (or any other worktree setup).
167
+ *
168
+ * @param {string} script - Script path or command to execute
169
+ * @param {string} worktreePath - Path to the worktree (used as cwd)
170
+ * @param {Object} env - Environment variables to pass (BASE_BRANCH, HEAD_BRANCH, etc.)
171
+ * @param {number} [timeout=DEFAULT_CHECKOUT_TIMEOUT_MS] - Timeout in milliseconds (default: 5 minutes)
172
+ * @returns {Promise<{stdout: string, stderr: string}>} Script output
173
+ */
174
+ async executeCheckoutScript(script, worktreePath, env, timeout = DEFAULT_CHECKOUT_TIMEOUT_MS) {
175
+ return new Promise((resolve, reject) => {
176
+ const child = spawn(script, [], {
177
+ cwd: worktreePath,
178
+ shell: true,
179
+ env: { ...process.env, ...env },
180
+ stdio: ['ignore', 'pipe', 'pipe']
181
+ });
182
+
183
+ let stdout = '';
184
+ let stderr = '';
185
+
186
+ child.stdout.on('data', (data) => {
187
+ const chunk = data.toString();
188
+ stdout += chunk;
189
+ process.stdout.write(chunk);
190
+ });
191
+ child.stderr.on('data', (data) => {
192
+ const chunk = data.toString();
193
+ stderr += chunk;
194
+ process.stderr.write(chunk);
195
+ });
196
+
197
+ const timer = setTimeout(() => {
198
+ child.kill('SIGTERM');
199
+ reject(new Error(`Checkout script timed out after ${timeout}ms.\nstdout: ${stdout}\nstderr: ${stderr}`));
200
+ }, timeout);
201
+
202
+ child.on('error', (err) => {
203
+ clearTimeout(timer);
204
+ if (err.code === 'ENOENT') {
205
+ reject(new Error(`Checkout script not found: ${script}`));
206
+ } else {
207
+ reject(new Error(`Checkout script failed to start: ${err.message}`));
208
+ }
209
+ });
210
+
211
+ child.on('close', (code) => {
212
+ clearTimeout(timer);
213
+ if (code === 0) {
214
+ resolve({ stdout, stderr });
215
+ } else {
216
+ reject(new Error(`Checkout script exited with code ${code}.\nstdout: ${stdout}\nstderr: ${stderr}`));
217
+ }
218
+ });
219
+ });
220
+ }
221
+
132
222
  /**
133
223
  * Create a git worktree for a PR and checkout to the PR head commit
134
224
  * @param {Object} prInfo - PR information { owner, repo, number }
@@ -137,10 +227,14 @@ class GitWorktreeManager {
137
227
  * @param {Object} [options] - Optional settings
138
228
  * @param {string} [options.worktreeSourcePath] - Path to use as cwd for git worktree add
139
229
  * (to inherit sparse-checkout from an existing worktree). Falls back to repositoryPath.
230
+ * @param {string} [options.checkoutScript] - Path to a script that configures sparse-checkout in the worktree.
231
+ * When set, worktree is created with --no-checkout from the main git root (no sparse-checkout inheritance),
232
+ * and the script is executed before checkout with PR context as environment variables.
233
+ * @param {number} [options.checkoutTimeout] - Timeout in ms for checkout script (default: 300000 = 5 minutes)
140
234
  * @returns {Promise<string>} Path to created worktree
141
235
  */
142
236
  async createWorktreeForPR(prInfo, prData, repositoryPath, options = {}) {
143
- const { worktreeSourcePath } = options;
237
+ const { worktreeSourcePath, checkoutScript, checkoutTimeout } = options;
144
238
  // Check if worktree already exists in DB
145
239
  const repository = normalizeRepository(prInfo.owner, prInfo.repo);
146
240
  let worktreePath;
@@ -157,7 +251,7 @@ class GitWorktreeManager {
157
251
  // Check if the directory still exists on disk
158
252
  const directoryExists = await this.pathExists(worktreePath);
159
253
 
160
- if (directoryExists) {
254
+ if (directoryExists && await this.isValidGitWorktree(worktreePath)) {
161
255
  // Try to reuse existing worktree by refreshing it
162
256
  console.log(`Found existing worktree for PR #${prInfo.number} at ${worktreePath}`);
163
257
  try {
@@ -170,6 +264,8 @@ class GitWorktreeManager {
170
264
  // For other errors, log and fall through to recreate
171
265
  console.log(`Could not refresh existing worktree, will recreate: ${refreshError.message}`);
172
266
  }
267
+ } else if (directoryExists) {
268
+ console.log(`Worktree directory at ${worktreePath} is not a valid git worktree, will recreate`);
173
269
  } else {
174
270
  console.log(`Worktree directory no longer exists at ${worktreePath}, will recreate`);
175
271
  }
@@ -207,9 +303,15 @@ class GitWorktreeManager {
207
303
  }
208
304
  }
209
305
 
210
- // Generate new random ID for worktree directory
306
+ // Generate new random ID for worktree directory and apply name template
211
307
  const worktreeId = generateWorktreeId();
212
- worktreePath = path.join(this.worktreeBaseDir, worktreeId);
308
+ const worktreeDirName = this.applyNameTemplate({
309
+ id: worktreeId,
310
+ prNumber: prInfo.number,
311
+ repo: prInfo.repo,
312
+ owner: prInfo.owner
313
+ });
314
+ worktreePath = path.join(this.worktreeBaseDir, worktreeDirName);
213
315
  }
214
316
 
215
317
  try {
@@ -243,23 +345,39 @@ class GitWorktreeManager {
243
345
  }
244
346
  }
245
347
 
246
- // Create worktree and checkout to base branch
247
- // Use worktreeSourcePath as cwd if provided (to inherit sparse-checkout from existing worktree)
248
- const worktreeAddGit = worktreeSourcePath ? simpleGit(worktreeSourcePath) : git;
249
- if (worktreeSourcePath) {
250
- console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (inheriting sparse-checkout from ${worktreeSourcePath})...`);
348
+ // Create worktree strategy depends on whether a checkout script is configured
349
+ if (checkoutScript) {
350
+ // With checkout_script: create worktree with --no-checkout from main git root.
351
+ // The script will configure sparse-checkout before files are populated.
352
+ console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (--no-checkout, script will configure sparse-checkout)...`);
353
+ try {
354
+ await git.raw(['worktree', 'add', '--no-checkout', worktreePath, `${remote}/${prData.base_branch}`]);
355
+ } catch (worktreeError) {
356
+ if (worktreeError.message.includes('already registered')) {
357
+ console.log('Worktree already registered, trying with --force...');
358
+ await git.raw(['worktree', 'add', '--force', '--no-checkout', worktreePath, `${remote}/${prData.base_branch}`]);
359
+ } else {
360
+ throw worktreeError;
361
+ }
362
+ }
251
363
  } else {
252
- console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch}...`);
253
- }
254
- try {
255
- await worktreeAddGit.raw(['worktree', 'add', worktreePath, `${remote}/${prData.base_branch}`]);
256
- } catch (worktreeError) {
257
- // If worktree creation fails due to existing registration, try with --force
258
- if (worktreeError.message.includes('already registered')) {
259
- console.log('Worktree already registered, trying with --force...');
260
- await worktreeAddGit.raw(['worktree', 'add', '--force', worktreePath, `${remote}/${prData.base_branch}`]);
364
+ // Without checkout_script: use worktreeSourcePath as cwd if provided
365
+ // (to inherit sparse-checkout from existing worktree)
366
+ const worktreeAddGit = worktreeSourcePath ? simpleGit(worktreeSourcePath) : git;
367
+ if (worktreeSourcePath) {
368
+ console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (inheriting sparse-checkout from ${worktreeSourcePath})...`);
261
369
  } else {
262
- throw worktreeError;
370
+ console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch}...`);
371
+ }
372
+ try {
373
+ await worktreeAddGit.raw(['worktree', 'add', worktreePath, `${remote}/${prData.base_branch}`]);
374
+ } catch (worktreeError) {
375
+ if (worktreeError.message.includes('already registered')) {
376
+ console.log('Worktree already registered, trying with --force...');
377
+ await worktreeAddGit.raw(['worktree', 'add', '--force', worktreePath, `${remote}/${prData.base_branch}`]);
378
+ } else {
379
+ throw worktreeError;
380
+ }
263
381
  }
264
382
  }
265
383
 
@@ -280,6 +398,35 @@ class GitWorktreeManager {
280
398
  console.log(`Fetching PR #${prInfo.number} head...`);
281
399
  await worktreeGit.fetch([remote, `+refs/pull/${prInfo.number}/head:refs/remotes/${remote}/pr-${prInfo.number}`]);
282
400
 
401
+ // Execute checkout script if configured (before checkout so sparse-checkout is set up)
402
+ if (checkoutScript) {
403
+ // Fetch the actual head branch by name (for checkout scripts that expect branch refs)
404
+ // This may fail for fork PRs where the branch is in a different repo - that's okay
405
+ if (prData.head_branch) {
406
+ try {
407
+ console.log(`Fetching head branch ${prData.head_branch}...`);
408
+ await worktreeGit.fetch([remote, `+refs/heads/${prData.head_branch}:refs/remotes/${remote}/${prData.head_branch}`]);
409
+ // Create/update a local branch pointing to the fetched ref so tooling can reference it by name
410
+ await worktreeGit.branch(['-f', prData.head_branch, `${remote}/${prData.head_branch}`]);
411
+ } catch (branchFetchError) {
412
+ // Expected for fork PRs - the branch exists in the fork, not the base repo
413
+ console.log(`Could not fetch head branch (may be from a fork): ${branchFetchError.message}`);
414
+ }
415
+ }
416
+
417
+ console.log(`Executing checkout script: ${checkoutScript}`);
418
+ const scriptEnv = {
419
+ BASE_BRANCH: prData.base_branch,
420
+ HEAD_BRANCH: prData.head_branch,
421
+ BASE_SHA: prData.base_sha,
422
+ HEAD_SHA: prData.head_sha,
423
+ PR_NUMBER: String(prInfo.number),
424
+ WORKTREE_PATH: worktreePath
425
+ };
426
+ await this.executeCheckoutScript(checkoutScript, worktreePath, scriptEnv, checkoutTimeout);
427
+ console.log('Checkout script completed successfully');
428
+ }
429
+
283
430
  // Checkout to PR head commit
284
431
  console.log(`Checking out to PR head commit ${prData.head_sha}...`);
285
432
  await worktreeGit.checkout([`${remote}/pr-${prInfo.number}`]);
@@ -596,7 +743,6 @@ class GitWorktreeManager {
596
743
  await fs.rm(dirPath, { recursive: true, force: true });
597
744
  } catch (error) {
598
745
  // Fallback for older Node.js versions
599
- const { execSync } = require('child_process');
600
746
  if (process.platform === 'win32') {
601
747
  execSync(`rmdir /s /q "${dirPath}"`, { stdio: 'ignore' });
602
748
  } else {
package/src/main.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: GPL-3.0-or-later
2
2
  const fs = require('fs');
3
- const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName } = require('./config');
3
+ const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName, resolveMonorepoOptions } = require('./config');
4
4
  const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository } = require('./database');
5
5
  const { PRArgumentParser } = require('./github/parser');
6
6
  const { GitHubClient } = require('./github/client');
@@ -466,8 +466,6 @@ AI PROVIDERS:
466
466
  * @param {Object} flags - Parsed command line flags
467
467
  */
468
468
  async function handlePullRequest(args, config, db, flags = {}) {
469
- let prInfo = null; // Declare prInfo outside try block for error handling
470
-
471
469
  try {
472
470
  // Get GitHub token (env var takes precedence over config)
473
471
  const githubToken = getGitHubToken(config);
@@ -477,136 +475,36 @@ async function handlePullRequest(args, config, db, flags = {}) {
477
475
 
478
476
  // Parse PR arguments
479
477
  const parser = new PRArgumentParser();
480
- prInfo = await parser.parsePRArguments(args);
481
-
482
- console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo}`);
483
-
484
- // Create GitHub client and verify repository access
485
- const githubClient = new GitHubClient(githubToken);
486
- const repoExists = await githubClient.repositoryExists(prInfo.owner, prInfo.repo);
487
- if (!repoExists) {
488
- throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
489
- }
490
-
491
- // Fetch PR data from GitHub
492
- console.log('Fetching pull request data from GitHub...');
493
- const prData = await githubClient.fetchPullRequest(prInfo.owner, prInfo.repo, prInfo.number);
478
+ const prInfo = await parser.parsePRArguments(args);
494
479
 
495
- // Determine repository path: only use cwd if it matches the target repo
480
+ // Register cwd as known repo path if it matches the target repo
496
481
  const currentDir = parser.getCurrentDirectory();
497
482
  const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
498
-
499
- let repositoryPath;
500
483
  if (isMatchingRepo) {
501
- // Current directory is a checkout of the target repository
502
- repositoryPath = currentDir;
503
- // Register the known repository location for future web UI usage
504
484
  await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
505
- } else {
506
- // Current directory is not the target repository - find or clone it
507
- console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
508
- const repository = normalizeRepository(prInfo.owner, prInfo.repo);
509
- const result = await findRepositoryPath({
510
- db,
511
- owner: prInfo.owner,
512
- repo: prInfo.repo,
513
- repository,
514
- prNumber: prInfo.number,
515
- onProgress: (progress) => {
516
- if (progress.message) {
517
- console.log(progress.message);
518
- }
519
- }
520
- });
521
- repositoryPath = result.repositoryPath;
522
485
  }
523
486
 
524
- // Setup git worktree
525
- console.log('Setting up git worktree...');
526
- const worktreeManager = new GitWorktreeManager(db);
527
- const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath);
528
-
529
- // Generate unified diff
530
- console.log('Generating unified diff...');
531
- const diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
532
- const changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
487
+ // Set model override if provided via CLI flag
488
+ if (flags.model) {
489
+ process.env.PAIR_REVIEW_MODEL = flags.model;
490
+ }
533
491
 
534
- // Store PR data in database
535
- console.log('Storing pull request data...');
536
- await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath);
492
+ // Start server and open browser to setup page
493
+ const port = await startServer(db);
537
494
 
538
- // Start server with PR context
539
- console.log('Starting server...');
540
- const port = await startServerWithPRContext(config, prInfo, flags);
495
+ // Async cleanup of stale worktrees (don't block startup)
496
+ cleanupStaleWorktreesAsync(config);
541
497
 
542
- // Trigger AI analysis server-side if --ai flag is present
498
+ let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
543
499
  if (flags.ai) {
544
- console.log('Starting AI analysis...');
545
-
546
- // Wait for server to be ready with retry logic
547
- const maxRetries = 5;
548
- const retryDelay = 200; // ms
549
- let lastError;
550
-
551
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
552
- try {
553
- // Add small delay to ensure server is fully initialized
554
- if (attempt > 1) {
555
- await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));
556
- }
557
-
558
- const response = await fetch(`http://localhost:${port}/api/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}/analyses`, {
559
- method: 'POST',
560
- headers: { 'Content-Type': 'application/json' }
561
- });
562
-
563
- if (response.ok) {
564
- const result = await response.json();
565
- console.log(`AI analysis started (ID: ${result.analysisId})`);
566
- break; // Success, exit retry loop
567
- } else {
568
- lastError = `Server responded with ${response.status}: ${await response.text()}`;
569
- if (attempt === maxRetries) {
570
- console.warn('Failed to start AI analysis:', lastError);
571
- }
572
- }
573
- } catch (error) {
574
- lastError = error.message;
575
- if (attempt === maxRetries) {
576
- console.warn('Could not start AI analysis after', maxRetries, 'attempts:', lastError);
577
- }
578
- }
579
- }
500
+ url += '?analyze=true';
580
501
  }
581
502
 
582
- // Open browser to PR view
583
- const url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
584
-
585
503
  console.log(`Opening browser to: ${url}`);
586
504
  await open(url);
587
505
 
588
506
  } catch (error) {
589
- // Provide cleaner error messages for common issues
590
- if (error.message && error.message.includes('not found in repository')) {
591
- if (prInfo) {
592
- console.error(`\n❌ Pull request #${prInfo.number} does not exist in ${prInfo.owner}/${prInfo.repo}`);
593
- } else {
594
- console.error(`\n❌ ${error.message}`);
595
- }
596
- console.error('Please check the PR number and try again.\n');
597
- } else if (error.message && error.message.includes('authentication failed')) {
598
- console.error('\n❌ GitHub authentication failed');
599
- console.error('Please check your token in ~/.pair-review/config.json\n');
600
- } else if (error.message && error.message.includes('Repository') && error.message.includes('not found')) {
601
- console.error(`\n❌ ${error.message}`);
602
- console.error('Please check the repository name and your access permissions.\n');
603
- } else if (error.message && error.message.includes('Network error')) {
604
- console.error('\n❌ Network connection error');
605
- console.error('Please check your internet connection and try again.\n');
606
- } else {
607
- // For other errors, show a clean message without stack trace
608
- console.error(`\n❌ Error: ${error.message}\n`);
609
- }
507
+ console.error(`\n❌ Error: ${error.message}\n`);
610
508
  process.exit(1);
611
509
  }
612
510
  }
@@ -627,36 +525,6 @@ async function startServerOnly(config) {
627
525
  await open(url);
628
526
  }
629
527
 
630
- /**
631
- * Start server with PR context
632
- * @param {Object} config - Application configuration
633
- * @param {Object} prInfo - PR information
634
- * @param {Object} flags - Command line flags
635
- * @returns {Promise<number>} Server port
636
- */
637
- async function startServerWithPRContext(config, prInfo, flags = {}) {
638
- // Set environment variable for PR context
639
- process.env.PAIR_REVIEW_PR = JSON.stringify(prInfo);
640
-
641
- // Set environment variable for auto-AI flag
642
- if (flags.ai) {
643
- process.env.PAIR_REVIEW_AUTO_AI = 'true';
644
- }
645
-
646
- // Set environment variable for model override (CLI takes priority)
647
- if (flags.model) {
648
- process.env.PAIR_REVIEW_MODEL = flags.model;
649
- }
650
-
651
- const { startServer } = require('./server');
652
- const actualPort = await startServer(db);
653
-
654
- // Async cleanup of stale worktrees (don't block startup)
655
- cleanupStaleWorktreesAsync(config);
656
-
657
- // Return the actual port the server started on
658
- return actualPort;
659
- }
660
528
 
661
529
  /**
662
530
  * Format AI suggestion with emoji and category prefix
@@ -784,10 +652,21 @@ async function performHeadlessReview(args, config, db, flags, options) {
784
652
  const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
785
653
 
786
654
  let repositoryPath;
655
+ let worktreeSourcePath;
656
+ let checkoutScript;
657
+ let checkoutTimeout;
658
+ let worktreeConfig = null;
787
659
  if (isMatchingRepo) {
788
660
  // Current directory is a checkout of the target repository
789
661
  repositoryPath = currentDir;
790
662
  await registerRepositoryLocation(db, currentDir, prInfo.owner, prInfo.repo);
663
+
664
+ // Resolve monorepo config options (checkout_script, worktree_directory, worktree_name_template)
665
+ // even when running from inside the target repo, so they are not silently ignored.
666
+ const resolved = resolveMonorepoOptions(config, repository);
667
+ checkoutScript = resolved.checkoutScript;
668
+ checkoutTimeout = resolved.checkoutTimeout;
669
+ worktreeConfig = resolved.worktreeConfig;
791
670
  } else {
792
671
  // Current directory is not the target repository - find or clone it
793
672
  console.log(`Current directory is not a checkout of ${prInfo.owner}/${prInfo.repo}, locating repository...`);
@@ -797,6 +676,7 @@ async function performHeadlessReview(args, config, db, flags, options) {
797
676
  repo: prInfo.repo,
798
677
  repository,
799
678
  prNumber: prInfo.number,
679
+ config,
800
680
  onProgress: (progress) => {
801
681
  if (progress.message) {
802
682
  console.log(progress.message);
@@ -804,11 +684,19 @@ async function performHeadlessReview(args, config, db, flags, options) {
804
684
  }
805
685
  });
806
686
  repositoryPath = result.repositoryPath;
687
+ worktreeSourcePath = result.worktreeSourcePath;
688
+ checkoutScript = result.checkoutScript;
689
+ checkoutTimeout = result.checkoutTimeout;
690
+ worktreeConfig = result.worktreeConfig;
807
691
  }
808
692
 
809
693
  console.log('Setting up git worktree...');
810
- const worktreeManager = new GitWorktreeManager(db);
811
- worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath);
694
+ const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
695
+ worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath, {
696
+ worktreeSourcePath,
697
+ checkoutScript,
698
+ checkoutTimeout
699
+ });
812
700
 
813
701
  console.log('Generating unified diff...');
814
702
  diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);