@in-the-loop-labs/pair-review 3.3.3 → 3.3.4
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/public/css/pr.css +25 -2
- package/public/js/components/TimeoutSelect.js +4 -0
- package/public/js/local.js +4 -3
- package/public/js/modules/analysis-history.js +47 -22
- package/public/js/pr.js +65 -5
- package/src/ai/analyzer.js +48 -15
- package/src/ai/claude-provider.js +27 -15
- package/src/database.js +32 -6
- package/src/git/diff-flags.js +4 -2
- package/src/git/worktree-pool-lifecycle.js +12 -3
- package/src/git/worktree.js +172 -33
- package/src/main.js +2 -1
- package/src/routes/analyses.js +3 -1
- package/src/routes/pr.js +25 -27
- package/src/routes/reviews.js +19 -15
- package/src/setup/pr-setup.js +19 -6
- package/src/utils/diff-file-content.js +110 -0
package/src/git/diff-flags.js
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* - --no-ext-diff: Disable external diff drivers (overrides diff.external)
|
|
9
9
|
* - --src-prefix=a/ --dst-prefix=b/: Ensure consistent a/ b/ prefixes (overrides diff.noprefix / diff.mnemonicPrefix)
|
|
10
10
|
* - --no-relative: Ensure paths are repo-root-relative (overrides diff.relative)
|
|
11
|
+
* - --full-index: Persist full blob IDs so diff snapshots remain durable over time
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* String form for execSync / exec shell calls (e.g. `git diff ${GIT_DIFF_FLAGS} ...`).
|
|
15
16
|
*/
|
|
16
|
-
const GIT_DIFF_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --no-relative';
|
|
17
|
+
const GIT_DIFF_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --no-relative --full-index';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Array form for simple-git .diff() calls (full diff output including file content).
|
|
@@ -23,7 +24,8 @@ const GIT_DIFF_FLAGS_ARRAY = [
|
|
|
23
24
|
'--no-ext-diff',
|
|
24
25
|
'--src-prefix=a/',
|
|
25
26
|
'--dst-prefix=b/',
|
|
26
|
-
'--no-relative'
|
|
27
|
+
'--no-relative',
|
|
28
|
+
'--full-index'
|
|
27
29
|
];
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -248,6 +248,8 @@ class WorktreePoolLifecycle {
|
|
|
248
248
|
number: prInfo.prNumber,
|
|
249
249
|
}, prData, { remote: remoteName });
|
|
250
250
|
|
|
251
|
+
await worktreeManager.ensureBaseShaAvailable(git, prData, remoteName);
|
|
252
|
+
|
|
251
253
|
// Clean the working tree before switching PRs. Without this, untracked
|
|
252
254
|
// files (build artifacts, generated code) from the previous PR leak into
|
|
253
255
|
// the new checkout, and modified tracked files can cause checkout to fail.
|
|
@@ -257,7 +259,7 @@ class WorktreePoolLifecycle {
|
|
|
257
259
|
await git.clean('f', ['-d']);
|
|
258
260
|
|
|
259
261
|
// Checkout specific head SHA (stored SHA in restore mode, latest in fresh mode)
|
|
260
|
-
const targetSha =
|
|
262
|
+
const targetSha = worktreeManager.getPRHeadSha(prData);
|
|
261
263
|
if (targetSha) {
|
|
262
264
|
await git.checkout([targetSha]);
|
|
263
265
|
} else {
|
|
@@ -269,8 +271,8 @@ class WorktreePoolLifecycle {
|
|
|
269
271
|
logger.info(`Executing reset script: ${options.resetScript}`);
|
|
270
272
|
const headRef = prData.head?.ref || prData.head_branch || '';
|
|
271
273
|
const baseRef = prData.base?.ref || prData.base_branch || '';
|
|
272
|
-
const headSha =
|
|
273
|
-
const baseSha =
|
|
274
|
+
const headSha = worktreeManager.getPRHeadSha(prData);
|
|
275
|
+
const baseSha = worktreeManager.getPRBaseSha(prData);
|
|
274
276
|
|
|
275
277
|
const scriptEnv = {
|
|
276
278
|
BASE_BRANCH: baseRef,
|
|
@@ -341,6 +343,13 @@ class WorktreePoolLifecycle {
|
|
|
341
343
|
const git = this._simpleGit(poolEntry.path);
|
|
342
344
|
const currentHead = (await git.revparse(['HEAD'])).trim();
|
|
343
345
|
if (currentHead === targetSha) {
|
|
346
|
+
const worktreeManager = new this._GitWorktreeManager(this.db);
|
|
347
|
+
const remote = await worktreeManager.resolveRemoteForPR(git, prData, {
|
|
348
|
+
owner: prInfo.owner,
|
|
349
|
+
repo: prInfo.repo,
|
|
350
|
+
number: prInfo.prNumber,
|
|
351
|
+
});
|
|
352
|
+
await worktreeManager.ensureBaseShaAvailable(git, prData, remote);
|
|
344
353
|
logger.info(`Pool worktree ${poolEntry.id} already at target SHA ${targetSha.slice(0, 8)}, skipping refresh`);
|
|
345
354
|
await this._poolRepo.markInUse(poolEntry.id, prInfo.prNumber);
|
|
346
355
|
return { worktreePath: poolEntry.path, worktreeId: poolEntry.id };
|
package/src/git/worktree.js
CHANGED
|
@@ -10,6 +10,8 @@ const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = requi
|
|
|
10
10
|
const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./diff-flags');
|
|
11
11
|
const { spawn, execSync } = require('child_process');
|
|
12
12
|
|
|
13
|
+
const MISSING_COMMIT_ERROR_CODE = 'PAIR_REVIEW_MISSING_COMMIT';
|
|
14
|
+
|
|
13
15
|
/**
|
|
14
16
|
* Git worktree manager for handling PR branch checkouts and diffs
|
|
15
17
|
*/
|
|
@@ -201,6 +203,136 @@ class GitWorktreeManager {
|
|
|
201
203
|
return prData?.head?.sha || prData?.head_sha || '';
|
|
202
204
|
}
|
|
203
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Extract the PR base SHA from either REST or stored PR metadata.
|
|
208
|
+
* @param {Object|null} prData
|
|
209
|
+
* @returns {string}
|
|
210
|
+
*/
|
|
211
|
+
getPRBaseSha(prData) {
|
|
212
|
+
return prData?.base?.sha || prData?.base_sha || '';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Check whether the given commit object is already available locally.
|
|
217
|
+
* @param {Object} git - simple-git instance
|
|
218
|
+
* @param {string} sha
|
|
219
|
+
* @returns {Promise<boolean>}
|
|
220
|
+
*/
|
|
221
|
+
async hasCommitLocally(git, sha) {
|
|
222
|
+
if (!sha) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const objectType = (await git.raw(['cat-file', '-t', sha])).trim();
|
|
228
|
+
return objectType === 'commit';
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Ensure a specific commit object exists locally, fetching it directly when needed.
|
|
236
|
+
* @param {Object} git - simple-git instance
|
|
237
|
+
* @param {string} sha
|
|
238
|
+
* @param {string} remote
|
|
239
|
+
* @param {string} label
|
|
240
|
+
* @returns {Promise<void>}
|
|
241
|
+
*/
|
|
242
|
+
async ensureCommitAvailable(git, sha, remote, label = 'Commit') {
|
|
243
|
+
if (!sha) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (await this.hasCommitLocally(git, sha)) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let fetchError = null;
|
|
252
|
+
try {
|
|
253
|
+
await git.raw(['fetch', remote, sha]);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
fetchError = error;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (await this.hasCommitLocally(git, sha)) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (fetchError) {
|
|
263
|
+
const error = new Error(`${label} ${sha} is not available locally and fetch from ${remote} failed: ${fetchError.message}`);
|
|
264
|
+
error.code = MISSING_COMMIT_ERROR_CODE;
|
|
265
|
+
error.commitSha = sha;
|
|
266
|
+
error.commitLabel = label;
|
|
267
|
+
error.remote = remote;
|
|
268
|
+
error.cause = fetchError;
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const error = new Error(`${label} ${sha} is not available locally after fetch from ${remote}`);
|
|
273
|
+
error.code = MISSING_COMMIT_ERROR_CODE;
|
|
274
|
+
error.commitSha = sha;
|
|
275
|
+
error.commitLabel = label;
|
|
276
|
+
error.remote = remote;
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Ensure the PR base commit is available in the local repository before diffing.
|
|
282
|
+
* @param {Object} git - simple-git instance
|
|
283
|
+
* @param {Object|null} prData
|
|
284
|
+
* @param {string} remote
|
|
285
|
+
* @returns {Promise<void>}
|
|
286
|
+
*/
|
|
287
|
+
async ensureBaseShaAvailable(git, prData, remote) {
|
|
288
|
+
const baseSha = this.getPRBaseSha(prData);
|
|
289
|
+
if (!baseSha) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
console.log(`Ensuring base commit ${baseSha} is available...`);
|
|
294
|
+
await this.ensureCommitAvailable(git, baseSha, remote, 'Base SHA');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Fail with a targeted message when a required diff commit is missing locally.
|
|
299
|
+
* @param {Object} git - simple-git instance
|
|
300
|
+
* @param {string} sha
|
|
301
|
+
* @param {string} label
|
|
302
|
+
* @returns {Promise<void>}
|
|
303
|
+
*/
|
|
304
|
+
async assertCommitAvailableLocally(git, sha, label = 'Commit') {
|
|
305
|
+
if (!sha) {
|
|
306
|
+
throw new Error(`${label} is required but missing from PR data`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (await this.hasCommitLocally(git, sha)) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const error = new Error(`${label} ${sha} is not available locally. Refresh the worktree to fetch the missing commit before generating the diff.`);
|
|
314
|
+
error.code = MISSING_COMMIT_ERROR_CODE;
|
|
315
|
+
error.commitSha = sha;
|
|
316
|
+
error.commitLabel = label;
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Preserve machine-checkable error metadata when wrapping lower-level git failures.
|
|
322
|
+
* @param {string} prefix
|
|
323
|
+
* @param {Error} error
|
|
324
|
+
* @returns {Error}
|
|
325
|
+
*/
|
|
326
|
+
wrapError(prefix, error) {
|
|
327
|
+
const wrapped = new Error(`${prefix}: ${error.message}`);
|
|
328
|
+
if (error?.code) wrapped.code = error.code;
|
|
329
|
+
if (error?.commitSha) wrapped.commitSha = error.commitSha;
|
|
330
|
+
if (error?.commitLabel) wrapped.commitLabel = error.commitLabel;
|
|
331
|
+
if (error?.remote) wrapped.remote = error.remote;
|
|
332
|
+
if (error) wrapped.cause = error;
|
|
333
|
+
return wrapped;
|
|
334
|
+
}
|
|
335
|
+
|
|
204
336
|
/**
|
|
205
337
|
* Detect whether a fetch failed because the remote does not expose a PR ref.
|
|
206
338
|
* @param {Error} error
|
|
@@ -451,7 +583,7 @@ class GitWorktreeManager {
|
|
|
451
583
|
await this.cleanupWorktree(worktreePath);
|
|
452
584
|
|
|
453
585
|
// Create git instance for the source repository
|
|
454
|
-
const git =
|
|
586
|
+
const git = this._gitFor(repositoryPath);
|
|
455
587
|
|
|
456
588
|
// Resolve which remote points to the PR's base repository (handles forks)
|
|
457
589
|
const remote = await this.resolveRemoteForPR(git, prData, prInfo);
|
|
@@ -490,7 +622,7 @@ class GitWorktreeManager {
|
|
|
490
622
|
} else {
|
|
491
623
|
// Without checkout_script: use worktreeSourcePath as cwd if provided
|
|
492
624
|
// (to inherit sparse-checkout from existing worktree)
|
|
493
|
-
const worktreeAddGit = worktreeSourcePath ?
|
|
625
|
+
const worktreeAddGit = worktreeSourcePath ? this._gitFor(worktreeSourcePath) : git;
|
|
494
626
|
if (worktreeSourcePath) {
|
|
495
627
|
console.log(`Creating worktree at ${worktreePath} from ${prData.base_branch} (inheriting sparse-checkout from ${worktreeSourcePath})...`);
|
|
496
628
|
} else {
|
|
@@ -509,17 +641,10 @@ class GitWorktreeManager {
|
|
|
509
641
|
}
|
|
510
642
|
|
|
511
643
|
// Create git instance for the worktree
|
|
512
|
-
const worktreeGit =
|
|
513
|
-
|
|
644
|
+
const worktreeGit = this._gitFor(worktreePath);
|
|
645
|
+
|
|
514
646
|
// Ensure base SHA is available (in case base branch was force-pushed or rebased)
|
|
515
|
-
|
|
516
|
-
try {
|
|
517
|
-
// Try to fetch the specific base SHA if it's not already available
|
|
518
|
-
await worktreeGit.raw(['fetch', remote, prData.base_sha]);
|
|
519
|
-
} catch (fetchError) {
|
|
520
|
-
// If fetch fails, the SHA might already be available locally
|
|
521
|
-
console.log(`Base SHA fetch not needed or already available: ${fetchError.message}`);
|
|
522
|
-
}
|
|
647
|
+
await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
|
|
523
648
|
|
|
524
649
|
// Fetch the PR head using PR refs when available, with a branch/SHA fallback
|
|
525
650
|
console.log(`Fetching PR #${prInfo.number} head...`);
|
|
@@ -546,7 +671,7 @@ class GitWorktreeManager {
|
|
|
546
671
|
const scriptEnv = {
|
|
547
672
|
BASE_BRANCH: prData.base_branch,
|
|
548
673
|
HEAD_BRANCH: headBranch,
|
|
549
|
-
BASE_SHA: prData
|
|
674
|
+
BASE_SHA: this.getPRBaseSha(prData),
|
|
550
675
|
HEAD_SHA: this.getPRHeadSha(prData),
|
|
551
676
|
PR_NUMBER: String(prInfo.number),
|
|
552
677
|
WORKTREE_PATH: worktreePath
|
|
@@ -567,8 +692,8 @@ class GitWorktreeManager {
|
|
|
567
692
|
|
|
568
693
|
// Verify we're at the correct commit
|
|
569
694
|
const currentCommit = await worktreeGit.revparse(['HEAD']);
|
|
570
|
-
if (currentCommit.trim() !==
|
|
571
|
-
console.warn(`Warning: Expected commit ${
|
|
695
|
+
if (targetSha && currentCommit.trim() !== targetSha) {
|
|
696
|
+
console.warn(`Warning: Expected commit ${targetSha}, but got ${currentCommit.trim()}`);
|
|
572
697
|
}
|
|
573
698
|
|
|
574
699
|
// Store/update worktree record in database
|
|
@@ -598,7 +723,7 @@ class GitWorktreeManager {
|
|
|
598
723
|
console.error('Error during cleanup:', cleanupError);
|
|
599
724
|
}
|
|
600
725
|
|
|
601
|
-
throw
|
|
726
|
+
throw this.wrapError('Failed to create git worktree', error);
|
|
602
727
|
}
|
|
603
728
|
}
|
|
604
729
|
|
|
@@ -612,7 +737,7 @@ class GitWorktreeManager {
|
|
|
612
737
|
*/
|
|
613
738
|
async updateWorktree(owner, repo, number, prData) {
|
|
614
739
|
const prInfo = { owner, repo, number };
|
|
615
|
-
const headSha = prData
|
|
740
|
+
const headSha = this.getPRHeadSha(prData);
|
|
616
741
|
const worktreePath = await this.getWorktreePath(prInfo);
|
|
617
742
|
|
|
618
743
|
try {
|
|
@@ -625,7 +750,7 @@ class GitWorktreeManager {
|
|
|
625
750
|
console.log(`Updating worktree for PR #${number} at ${worktreePath}`);
|
|
626
751
|
|
|
627
752
|
// Create git instance for the worktree
|
|
628
|
-
const worktreeGit =
|
|
753
|
+
const worktreeGit = this._gitFor(worktreePath);
|
|
629
754
|
|
|
630
755
|
// Resolve which remote points to the PR's base repository (handles forks)
|
|
631
756
|
const remote = await this.resolveRemoteForPR(worktreeGit, prData, prInfo);
|
|
@@ -635,17 +760,19 @@ class GitWorktreeManager {
|
|
|
635
760
|
console.log(`Fetching latest changes from ${remote}...`);
|
|
636
761
|
await worktreeGit.fetch([remote, '--prune']);
|
|
637
762
|
|
|
763
|
+
await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
|
|
764
|
+
|
|
638
765
|
// Fetch the PR head using PR refs when available, with a branch/SHA fallback
|
|
639
766
|
console.log(`Fetching PR #${number} head...`);
|
|
640
767
|
const fetchedHead = await this.fetchPRHead(worktreeGit, prInfo, prData, { remote });
|
|
641
768
|
|
|
642
769
|
// Checkout to PR head commit
|
|
643
|
-
console.log(`Checking out to PR head
|
|
770
|
+
console.log(`Checking out to PR head ${headSha || fetchedHead.checkoutTarget}...`);
|
|
644
771
|
await worktreeGit.checkout([fetchedHead.checkoutTarget]);
|
|
645
772
|
|
|
646
773
|
// Verify we're at the correct commit
|
|
647
774
|
const currentCommit = await worktreeGit.revparse(['HEAD']);
|
|
648
|
-
if (currentCommit.trim() !== headSha) {
|
|
775
|
+
if (headSha && currentCommit.trim() !== headSha) {
|
|
649
776
|
console.warn(`Warning: Expected commit ${headSha}, but got ${currentCommit.trim()}`);
|
|
650
777
|
}
|
|
651
778
|
|
|
@@ -654,7 +781,7 @@ class GitWorktreeManager {
|
|
|
654
781
|
|
|
655
782
|
} catch (error) {
|
|
656
783
|
console.error('Error updating worktree:', error);
|
|
657
|
-
throw
|
|
784
|
+
throw this.wrapError('Failed to update git worktree', error);
|
|
658
785
|
}
|
|
659
786
|
}
|
|
660
787
|
|
|
@@ -666,16 +793,21 @@ class GitWorktreeManager {
|
|
|
666
793
|
*/
|
|
667
794
|
async generateUnifiedDiff(worktreePath, prData) {
|
|
668
795
|
try {
|
|
669
|
-
|
|
796
|
+
const git = this._gitFor(worktreePath);
|
|
797
|
+
const baseSha = this.getPRBaseSha(prData);
|
|
798
|
+
const headSha = this.getPRHeadSha(prData);
|
|
670
799
|
|
|
671
|
-
|
|
800
|
+
console.log(`Generating diff between ${baseSha} and ${headSha}...`);
|
|
801
|
+
|
|
802
|
+
await this.assertCommitAvailableLocally(git, baseSha, 'Base SHA');
|
|
803
|
+
await this.assertCommitAvailableLocally(git, headSha, 'Head SHA');
|
|
672
804
|
|
|
673
805
|
// Generate diff between base SHA and head SHA (not branch names)
|
|
674
806
|
// This ensures we compare the exact commits from the PR, even if the base branch has moved
|
|
675
807
|
// Defensive flags to normalize output regardless of user's git config
|
|
676
808
|
// (see src/git/diff-flags.js for rationale)
|
|
677
809
|
const diff = await git.diff([
|
|
678
|
-
`${
|
|
810
|
+
`${baseSha}...${headSha}`,
|
|
679
811
|
'--unified=3',
|
|
680
812
|
...GIT_DIFF_FLAGS_ARRAY
|
|
681
813
|
]);
|
|
@@ -684,7 +816,7 @@ class GitWorktreeManager {
|
|
|
684
816
|
|
|
685
817
|
} catch (error) {
|
|
686
818
|
console.error('Error generating diff:', error);
|
|
687
|
-
throw
|
|
819
|
+
throw this.wrapError('Failed to generate diff', error);
|
|
688
820
|
}
|
|
689
821
|
}
|
|
690
822
|
|
|
@@ -696,12 +828,17 @@ class GitWorktreeManager {
|
|
|
696
828
|
*/
|
|
697
829
|
async getChangedFiles(worktreePath, prData) {
|
|
698
830
|
try {
|
|
699
|
-
const git =
|
|
831
|
+
const git = this._gitFor(worktreePath);
|
|
832
|
+
const baseSha = this.getPRBaseSha(prData);
|
|
833
|
+
const headSha = this.getPRHeadSha(prData);
|
|
834
|
+
|
|
835
|
+
await this.assertCommitAvailableLocally(git, baseSha, 'Base SHA');
|
|
836
|
+
await this.assertCommitAvailableLocally(git, headSha, 'Head SHA');
|
|
700
837
|
|
|
701
838
|
// Get file changes with stats using base SHA and head SHA
|
|
702
839
|
// This ensures we get the exact files changed in the PR, even if the base branch has moved
|
|
703
840
|
const diffSummary = await git.diffSummary([
|
|
704
|
-
`${
|
|
841
|
+
`${baseSha}...${headSha}`,
|
|
705
842
|
...GIT_DIFF_SUMMARY_FLAGS_ARRAY
|
|
706
843
|
]);
|
|
707
844
|
|
|
@@ -728,7 +865,7 @@ class GitWorktreeManager {
|
|
|
728
865
|
|
|
729
866
|
} catch (error) {
|
|
730
867
|
console.error('Error getting changed files:', error);
|
|
731
|
-
throw
|
|
868
|
+
throw this.wrapError('Failed to get changed files', error);
|
|
732
869
|
}
|
|
733
870
|
}
|
|
734
871
|
|
|
@@ -779,7 +916,7 @@ class GitWorktreeManager {
|
|
|
779
916
|
*/
|
|
780
917
|
async resolveOwningRepo(worktreePath) {
|
|
781
918
|
try {
|
|
782
|
-
const git =
|
|
919
|
+
const git = this._gitFor(worktreePath);
|
|
783
920
|
const commonDir = (await git.raw(['rev-parse', '--git-common-dir'])).trim();
|
|
784
921
|
// commonDir is either a .git subdirectory (regular repos) or the bare repo root itself.
|
|
785
922
|
// Only strip the last component when it's actually a .git directory.
|
|
@@ -977,7 +1114,7 @@ class GitWorktreeManager {
|
|
|
977
1114
|
*/
|
|
978
1115
|
async hasLocalChanges(worktreePath) {
|
|
979
1116
|
try {
|
|
980
|
-
const git =
|
|
1117
|
+
const git = this._gitFor(worktreePath);
|
|
981
1118
|
const status = await git.raw(['status', '--porcelain']);
|
|
982
1119
|
return status.trim().length > 0;
|
|
983
1120
|
} catch (error) {
|
|
@@ -1007,11 +1144,13 @@ class GitWorktreeManager {
|
|
|
1007
1144
|
throw new Error(`Worktree has uncommitted changes. Please resolve manually at: ${worktreePath}`);
|
|
1008
1145
|
}
|
|
1009
1146
|
|
|
1010
|
-
const git =
|
|
1147
|
+
const git = this._gitFor(worktreePath);
|
|
1011
1148
|
|
|
1012
1149
|
// Resolve which remote points to the PR's base repository (handles forks)
|
|
1013
1150
|
const remote = await this.resolveRemoteForPR(git, prData, prInfo);
|
|
1014
1151
|
|
|
1152
|
+
await this.ensureBaseShaAvailable(git, prData, remote);
|
|
1153
|
+
|
|
1015
1154
|
// Fetch the latest PR head from remote
|
|
1016
1155
|
console.log(`Fetching PR #${prNumber} head from ${remote}...`);
|
|
1017
1156
|
const fetchedHead = await this.fetchPRHead(git, prInfo || { number: prNumber }, prData, { remote });
|
|
@@ -1035,7 +1174,7 @@ class GitWorktreeManager {
|
|
|
1035
1174
|
throw error;
|
|
1036
1175
|
}
|
|
1037
1176
|
console.error('Error refreshing worktree:', error);
|
|
1038
|
-
throw
|
|
1177
|
+
throw this.wrapError('Failed to refresh worktree', error);
|
|
1039
1178
|
}
|
|
1040
1179
|
}
|
|
1041
1180
|
|
|
@@ -1314,4 +1453,4 @@ class GitWorktreeManager {
|
|
|
1314
1453
|
}
|
|
1315
1454
|
}
|
|
1316
1455
|
|
|
1317
|
-
module.exports = { GitWorktreeManager };
|
|
1456
|
+
module.exports = { GitWorktreeManager, MISSING_COMMIT_ERROR_CODE };
|
package/src/main.js
CHANGED
|
@@ -115,7 +115,8 @@ OPTIONS:
|
|
|
115
115
|
The web UI also starts for the human reviewer.
|
|
116
116
|
--model <name> Override the AI model. Claude Code is the default provider.
|
|
117
117
|
Available models: opus, sonnet, haiku (Claude Code);
|
|
118
|
-
also: opus-4.5, opus-4.6-low, opus-4.6-medium, opus-4.6-1m
|
|
118
|
+
also: opus-4.5, opus-4.6-low, opus-4.6-medium, opus-4.6-1m,
|
|
119
|
+
opus-4.7-xhigh
|
|
119
120
|
or use provider-specific models with Gemini/Codex
|
|
120
121
|
--use-checkout Use current directory instead of creating worktree
|
|
121
122
|
(automatic in GitHub Actions)
|
package/src/routes/analyses.js
CHANGED
|
@@ -42,13 +42,14 @@ const router = express.Router();
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Enrich a raw analysis run record for API responses.
|
|
45
|
-
* Applies backward-compatible tier fallback and parses
|
|
45
|
+
* Applies backward-compatible tier fallback and parses JSON columns.
|
|
46
46
|
*/
|
|
47
47
|
function enrichRun(run) {
|
|
48
48
|
if (!run) return null;
|
|
49
49
|
return {
|
|
50
50
|
...run,
|
|
51
51
|
levels_config: run.levels_config ? JSON.parse(run.levels_config) : null,
|
|
52
|
+
level_outcomes: run.level_outcomes ? JSON.parse(run.level_outcomes) : null,
|
|
52
53
|
tier: run.tier ?? (run.provider && run.model ? getTierForModel(run.provider, run.model) : null)
|
|
53
54
|
};
|
|
54
55
|
}
|
|
@@ -625,6 +626,7 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
|
|
|
625
626
|
status: 'completed',
|
|
626
627
|
summary: result.summary,
|
|
627
628
|
totalSuggestions: result.suggestions.length,
|
|
629
|
+
...(result.levelOutcomes ? { levelOutcomes: result.levelOutcomes } : {}),
|
|
628
630
|
...runUpdateExtra
|
|
629
631
|
});
|
|
630
632
|
} catch (updateError) {
|
package/src/routes/pr.js
CHANGED
|
@@ -45,6 +45,7 @@ const {
|
|
|
45
45
|
registerProcess: registerProcessForCancellation
|
|
46
46
|
} = require('./shared');
|
|
47
47
|
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
48
|
+
const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
|
|
48
49
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
49
50
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
50
51
|
const { getProviderClass, createProvider } = require('../ai/provider');
|
|
@@ -961,44 +962,41 @@ router.get('/api/file-content-original/:fileName(*)', async (req, res) => {
|
|
|
961
962
|
});
|
|
962
963
|
}
|
|
963
964
|
|
|
964
|
-
//
|
|
965
|
-
//
|
|
965
|
+
// Prefer the exact blob from the cached diff snapshot. If that is unavailable,
|
|
966
|
+
// fall back to repo-wide base_sha and finally the worktree filesystem.
|
|
966
967
|
const repository = normalizeRepository(owner, repo);
|
|
967
968
|
const prRecord = await queryOne(db, `
|
|
968
969
|
SELECT pr_data FROM pr_metadata
|
|
969
970
|
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
970
971
|
`, [prNumber, repository]);
|
|
971
972
|
|
|
972
|
-
|
|
973
|
-
if (prRecord?.pr_data) {
|
|
974
|
-
|
|
975
|
-
const prData = JSON.parse(prRecord.pr_data);
|
|
976
|
-
baseSha = prData.base_sha;
|
|
977
|
-
} catch (parseError) {
|
|
978
|
-
console.warn('Could not parse pr_data for base_sha:', parseError.message);
|
|
979
|
-
}
|
|
973
|
+
const prData = safeParseJson(prRecord?.pr_data, null);
|
|
974
|
+
if (prRecord?.pr_data && !prData) {
|
|
975
|
+
logger.warn('Could not parse pr_data for file-content route');
|
|
980
976
|
}
|
|
981
977
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
if (
|
|
978
|
+
const contentSpecs = resolveOriginalFileContentSpecs(prData, fileName);
|
|
979
|
+
|
|
980
|
+
if (contentSpecs.length > 0) {
|
|
985
981
|
try {
|
|
986
982
|
const git = simpleGit(worktreePath);
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
983
|
+
for (const contentSpec of contentSpecs) {
|
|
984
|
+
try {
|
|
985
|
+
const content = await git.show([contentSpec.gitSpec]);
|
|
986
|
+
const lines = content.split('\n');
|
|
987
|
+
|
|
988
|
+
return res.json({
|
|
989
|
+
fileName,
|
|
990
|
+
lines,
|
|
991
|
+
totalLines: lines.length
|
|
992
|
+
});
|
|
993
|
+
} catch (gitError) {
|
|
994
|
+
// Fall through to the next git-based source before reading HEAD.
|
|
995
|
+
logger.debug(`Could not read file ${fileName} from ${contentSpec.source}: ${gitError.message}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
996
998
|
} catch (gitError) {
|
|
997
|
-
|
|
998
|
-
// - File might not exist at base_sha (new file)
|
|
999
|
-
// - Worktree might not be a valid git repo (test environment)
|
|
1000
|
-
// - Git command might fail for other reasons
|
|
1001
|
-
logger.debug(`Could not read file ${fileName} from base commit: ${gitError.message}, falling back to HEAD`);
|
|
999
|
+
logger.debug(`Could not initialize git for ${worktreePath}: ${gitError.message}`);
|
|
1002
1000
|
}
|
|
1003
1001
|
}
|
|
1004
1002
|
|
package/src/routes/reviews.js
CHANGED
|
@@ -21,6 +21,7 @@ const { GitWorktreeManager } = require('../git/worktree');
|
|
|
21
21
|
const { normalizeRepository } = require('../utils/paths');
|
|
22
22
|
const { resolveFormat, formatAdoptedComment: formatComment } = require('../utils/comment-formatter');
|
|
23
23
|
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
24
|
+
const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
|
|
24
25
|
|
|
25
26
|
const router = express.Router();
|
|
26
27
|
|
|
@@ -1012,32 +1013,35 @@ router.get('/api/reviews/:reviewId/file-content/:fileName(*)', validateReviewId,
|
|
|
1012
1013
|
return res.status(404).json({ error: 'Worktree not found for this PR. The PR may need to be reloaded.' });
|
|
1013
1014
|
}
|
|
1014
1015
|
|
|
1015
|
-
//
|
|
1016
|
+
// Prefer the exact blob from the cached diff snapshot. If that is unavailable,
|
|
1017
|
+
// fall back to repo-wide base_sha and finally the worktree filesystem.
|
|
1016
1018
|
const normalizedRepo = normalizeRepository(owner, repo);
|
|
1017
1019
|
const prRecord = await queryOne(db, `
|
|
1018
1020
|
SELECT pr_data FROM pr_metadata
|
|
1019
1021
|
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
1020
1022
|
`, [prNumber, normalizedRepo]);
|
|
1021
1023
|
|
|
1022
|
-
|
|
1023
|
-
if (prRecord?.pr_data) {
|
|
1024
|
-
|
|
1025
|
-
const prData = JSON.parse(prRecord.pr_data);
|
|
1026
|
-
baseSha = prData.base_sha;
|
|
1027
|
-
} catch (parseError) {
|
|
1028
|
-
logger.warn('Could not parse pr_data for base_sha:', parseError.message);
|
|
1029
|
-
}
|
|
1024
|
+
const prData = safeParseJson(prRecord?.pr_data, null);
|
|
1025
|
+
if (prRecord?.pr_data && !prData) {
|
|
1026
|
+
logger.warn('Could not parse pr_data for file-content route');
|
|
1030
1027
|
}
|
|
1031
1028
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1029
|
+
const contentSpecs = resolveOriginalFileContentSpecs(prData, fileName);
|
|
1030
|
+
|
|
1031
|
+
if (contentSpecs.length > 0) {
|
|
1034
1032
|
try {
|
|
1035
1033
|
const git = simpleGit(worktreePath);
|
|
1036
|
-
const
|
|
1037
|
-
|
|
1038
|
-
|
|
1034
|
+
for (const contentSpec of contentSpecs) {
|
|
1035
|
+
try {
|
|
1036
|
+
const content = await git.show([contentSpec.gitSpec]);
|
|
1037
|
+
const lines = content.split('\n');
|
|
1038
|
+
return res.json({ fileName, lines, totalLines: lines.length });
|
|
1039
|
+
} catch (gitError) {
|
|
1040
|
+
logger.debug(`Could not read file ${fileName} from ${contentSpec.source}: ${gitError.message}`);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1039
1043
|
} catch (gitError) {
|
|
1040
|
-
logger.debug(`Could not
|
|
1044
|
+
logger.debug(`Could not initialize git for ${worktreePath}: ${gitError.message}`);
|
|
1041
1045
|
}
|
|
1042
1046
|
}
|
|
1043
1047
|
|
package/src/setup/pr-setup.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
const { run, queryOne, WorktreeRepository, RepoSettingsRepository, ReviewRepository } = require('../database');
|
|
15
|
-
const { GitWorktreeManager } = require('../git/worktree');
|
|
15
|
+
const { GitWorktreeManager, MISSING_COMMIT_ERROR_CODE } = require('../git/worktree');
|
|
16
16
|
const { WorktreePoolLifecycle } = require('../git/worktree-pool-lifecycle');
|
|
17
17
|
const { GitHubClient } = require('../github/client');
|
|
18
18
|
const { normalizeRepository } = require('../utils/paths');
|
|
@@ -374,11 +374,24 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
|
|
|
374
374
|
* Used to trigger fallback from restore mode to fresh setup.
|
|
375
375
|
*/
|
|
376
376
|
function isShaNotFoundError(err) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
377
|
+
let current = err;
|
|
378
|
+
while (current) {
|
|
379
|
+
if (current.code === MISSING_COMMIT_ERROR_CODE) {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const msg = (current.message || '').toLowerCase();
|
|
384
|
+
if (msg.includes('did not match any') ||
|
|
385
|
+
msg.includes('not a valid object') ||
|
|
386
|
+
msg.includes('reference is not a tree') ||
|
|
387
|
+
msg.includes('bad object')) {
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
current = current.cause;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return false;
|
|
382
395
|
}
|
|
383
396
|
|
|
384
397
|
/**
|