@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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/references/level1-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level1-fast.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level1-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level2-fast.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-fast.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
- package/public/js/components/ChatPanel.js +1 -1
- package/src/ai/analyzer.js +5 -1
- package/src/ai/prompts/baseline/consolidation/balanced.js +9 -0
- package/src/ai/prompts/baseline/consolidation/fast.js +9 -0
- package/src/ai/prompts/baseline/consolidation/thorough.js +9 -0
- package/src/ai/prompts/baseline/level1/balanced.js +9 -0
- package/src/ai/prompts/baseline/level1/fast.js +9 -0
- package/src/ai/prompts/baseline/level1/thorough.js +9 -0
- package/src/ai/prompts/baseline/level2/balanced.js +9 -0
- package/src/ai/prompts/baseline/level2/fast.js +9 -0
- package/src/ai/prompts/baseline/level2/thorough.js +9 -0
- package/src/ai/prompts/baseline/level3/balanced.js +9 -0
- package/src/ai/prompts/baseline/level3/fast.js +9 -0
- package/src/ai/prompts/baseline/level3/thorough.js +9 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +9 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +9 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +9 -0
- package/src/ai/prompts/shared/output-schema.js +10 -1
- package/src/chat/prompt-builder.js +6 -1
- package/src/config.js +83 -1
- package/src/database.js +5 -1
- package/src/git/worktree.js +169 -23
- package/src/main.js +36 -148
- 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
|
-
(
|
|
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;
|
package/src/git/worktree.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
//
|
|
535
|
-
|
|
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
|
-
//
|
|
539
|
-
|
|
540
|
-
const port = await startServerWithPRContext(config, prInfo, flags);
|
|
495
|
+
// Async cleanup of stale worktrees (don't block startup)
|
|
496
|
+
cleanupStaleWorktreesAsync(config);
|
|
541
497
|
|
|
542
|
-
|
|
498
|
+
let url = `http://localhost:${port}/pr/${prInfo.owner}/${prInfo.repo}/${prInfo.number}`;
|
|
543
499
|
if (flags.ai) {
|
|
544
|
-
|
|
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
|
-
|
|
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);
|