@in-the-loop-labs/pair-review 3.0.0 → 3.0.2
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/js/local.js +13 -3
- package/src/ai/analyzer.js +10 -2
- package/src/database.js +6 -0
- package/src/local-review.js +17 -9
- package/src/routes/local.js +62 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "pair-review app integration — Open PRs and local changes in the pair-review web UI, run server-side AI analysis, and address review feedback. Requires the pair-review MCP server.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "code-critic",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
4
4
|
"description": "AI-powered code review analysis — Run three-level AI analysis and implement-review-fix loops directly in your coding agent. Works standalone, no server required.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "in-the-loop-labs",
|
package/public/js/local.js
CHANGED
|
@@ -674,6 +674,10 @@ class LocalManager {
|
|
|
674
674
|
// User cancelled — keep old diff, early return
|
|
675
675
|
return;
|
|
676
676
|
}
|
|
677
|
+
// resolved is the response object — merge branchAvailable into result
|
|
678
|
+
if (resolved.branchAvailable !== undefined) {
|
|
679
|
+
result.branchAvailable = resolved.branchAvailable;
|
|
680
|
+
}
|
|
677
681
|
}
|
|
678
682
|
// Branch scope: backend already updated SHA and persisted diff — fall through
|
|
679
683
|
}
|
|
@@ -702,7 +706,7 @@ class LocalManager {
|
|
|
702
706
|
/**
|
|
703
707
|
* Handle a non-branch-scope HEAD SHA change.
|
|
704
708
|
* Shows a 3-option dialog (or auto-updates in silent mode).
|
|
705
|
-
* @returns {
|
|
709
|
+
* @returns {Object|false} The response data object if the session was updated in-place (caller should apply diff),
|
|
706
710
|
* false if cancelled or redirecting away (caller should skip _applyRefreshedDiff)
|
|
707
711
|
*/
|
|
708
712
|
async _resolveHeadChange(result, opts) {
|
|
@@ -771,8 +775,9 @@ class LocalManager {
|
|
|
771
775
|
return false; // navigating away — caller must not fire _applyRefreshedDiff
|
|
772
776
|
}
|
|
773
777
|
|
|
774
|
-
// action === 'updated' — session SHA + diff updated, continue to reload
|
|
775
|
-
|
|
778
|
+
// action === 'updated' — session SHA + diff updated, continue to reload.
|
|
779
|
+
// Return the response data so the caller can extract branchAvailable, etc.
|
|
780
|
+
return data;
|
|
776
781
|
}
|
|
777
782
|
|
|
778
783
|
/**
|
|
@@ -806,6 +811,11 @@ class LocalManager {
|
|
|
806
811
|
// refresh must call unconditionally since the manager won't re-fire its callback.
|
|
807
812
|
await manager.loadAISuggestions(null, manager.selectedRunId);
|
|
808
813
|
|
|
814
|
+
// Update branchAvailable on the scope selector if the backend sent an updated value
|
|
815
|
+
if (result.branchAvailable !== undefined && manager.diffOptionsDropdown) {
|
|
816
|
+
manager.diffOptionsDropdown.branchAvailable = result.branchAvailable;
|
|
817
|
+
}
|
|
818
|
+
|
|
809
819
|
// Clear stale state after successful refresh
|
|
810
820
|
manager._hideStaleBadge();
|
|
811
821
|
manager._stalenessPromise = null;
|
package/src/ai/analyzer.js
CHANGED
|
@@ -27,6 +27,14 @@ const { buildSparseCheckoutGuidance } = require('./prompts/sparse-checkout-guida
|
|
|
27
27
|
/** Minimum total suggestion count across all voices before consolidation is applied */
|
|
28
28
|
const COUNCIL_CONSOLIDATION_THRESHOLD = 8;
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Common git diff flags used across all diff operations.
|
|
32
|
+
* - --no-color: Disable color output (guards against color.diff=always in user config)
|
|
33
|
+
* - --no-ext-diff: Disable external diff drivers
|
|
34
|
+
* - --src-prefix/--dst-prefix: Ensure consistent a/ b/ prefixes (overrides user's diff.noprefix)
|
|
35
|
+
*/
|
|
36
|
+
const GIT_DIFF_COMMON_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/';
|
|
37
|
+
|
|
30
38
|
/**
|
|
31
39
|
* Build a human-readable display label for a council voice/reviewer.
|
|
32
40
|
* Uses 1-based index so logs read "Reviewer 1", "Reviewer 2", etc.
|
|
@@ -989,10 +997,10 @@ ${prMetadata.description || '(No description provided)'}
|
|
|
989
997
|
const isLocal = prMetadata.reviewType === 'local';
|
|
990
998
|
if (isLocal) {
|
|
991
999
|
// For local mode, diff against HEAD to see working directory changes
|
|
992
|
-
return suffix ? `git diff
|
|
1000
|
+
return suffix ? `git diff ${GIT_DIFF_COMMON_FLAGS} HEAD ${suffix}` : `git diff ${GIT_DIFF_COMMON_FLAGS} HEAD`;
|
|
993
1001
|
}
|
|
994
1002
|
// For PR mode, diff between base and head commits
|
|
995
|
-
const baseCmd = `git diff
|
|
1003
|
+
const baseCmd = `git diff ${GIT_DIFF_COMMON_FLAGS} ${prMetadata.base_sha}...${prMetadata.head_sha}`;
|
|
996
1004
|
return suffix ? `${baseCmd} ${suffix}` : baseCmd;
|
|
997
1005
|
}
|
|
998
1006
|
|
package/src/database.js
CHANGED
|
@@ -2717,6 +2717,7 @@ class ReviewRepository {
|
|
|
2717
2717
|
* @param {Object} [updates.reviewData] - Additional review data (will be JSON stringified)
|
|
2718
2718
|
* @param {string} [updates.customInstructions] - Custom instructions used for AI analysis
|
|
2719
2719
|
* @param {string} [updates.summary] - AI analysis summary
|
|
2720
|
+
* @param {string} [updates.local_head_sha] - Local HEAD SHA
|
|
2720
2721
|
* @param {Date|string} [updates.submittedAt] - Submission timestamp
|
|
2721
2722
|
* @returns {Promise<boolean>} True if record was updated
|
|
2722
2723
|
*/
|
|
@@ -2759,6 +2760,11 @@ class ReviewRepository {
|
|
|
2759
2760
|
params.push(updates.local_head_branch);
|
|
2760
2761
|
}
|
|
2761
2762
|
|
|
2763
|
+
if (updates.local_head_sha !== undefined) {
|
|
2764
|
+
setClauses.push('local_head_sha = ?');
|
|
2765
|
+
params.push(updates.local_head_sha);
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2762
2768
|
if (updates.submittedAt !== undefined) {
|
|
2763
2769
|
setClauses.push('submitted_at = ?');
|
|
2764
2770
|
const submittedAt = updates.submittedAt instanceof Date
|
package/src/local-review.js
CHANGED
|
@@ -33,6 +33,14 @@ const MAX_FILE_SIZE = 1024 * 1024;
|
|
|
33
33
|
*/
|
|
34
34
|
const GIT_DIFF_HAS_DIFFERENCES = 1;
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Common git diff flags used across all diff operations.
|
|
38
|
+
* - --no-color: Disable color output for consistent parsing
|
|
39
|
+
* - --no-ext-diff: Disable external diff drivers
|
|
40
|
+
* - --src-prefix/--dst-prefix: Ensure consistent a/ b/ prefixes (overrides user's diff.noprefix)
|
|
41
|
+
*/
|
|
42
|
+
const GIT_DIFF_COMMON_FLAGS = '--no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/';
|
|
43
|
+
|
|
36
44
|
/**
|
|
37
45
|
* Find the main git repository root, resolving through worktrees.
|
|
38
46
|
* For regular repos, returns the repo root.
|
|
@@ -401,7 +409,7 @@ function generateUntrackedDiffs(repoPath, untrackedFiles, wFlag) {
|
|
|
401
409
|
const filePath = path.join(repoPath, untracked.file);
|
|
402
410
|
let fileDiff;
|
|
403
411
|
try {
|
|
404
|
-
fileDiff = execSync(`git diff --no-index
|
|
412
|
+
fileDiff = execSync(`git diff --no-index ${GIT_DIFF_COMMON_FLAGS}${wFlag} -- /dev/null "${filePath}"`, {
|
|
405
413
|
cwd: repoPath,
|
|
406
414
|
encoding: 'utf8',
|
|
407
415
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -478,37 +486,37 @@ async function generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch, op
|
|
|
478
486
|
try {
|
|
479
487
|
if (hasBranch && !hasStaged && !hasUnstaged) {
|
|
480
488
|
// Branch only → committed changes since merge-base
|
|
481
|
-
diff = execSync(`git diff ${mergeBaseSha}..HEAD
|
|
489
|
+
diff = execSync(`git diff ${mergeBaseSha}..HEAD ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
|
|
482
490
|
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
483
491
|
maxBuffer: 50 * 1024 * 1024
|
|
484
492
|
});
|
|
485
493
|
} else if (hasBranch && hasStaged && !hasUnstaged) {
|
|
486
494
|
// Branch–Staged → staged changes relative to merge-base
|
|
487
|
-
diff = execSync(`git diff --cached ${mergeBaseSha}
|
|
495
|
+
diff = execSync(`git diff --cached ${mergeBaseSha} ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
|
|
488
496
|
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
489
497
|
maxBuffer: 50 * 1024 * 1024
|
|
490
498
|
});
|
|
491
499
|
} else if (hasBranch && hasUnstaged) {
|
|
492
500
|
// Branch–Unstaged (or Branch–Untracked) → working tree vs merge-base
|
|
493
|
-
diff = execSync(`git diff ${mergeBaseSha}
|
|
501
|
+
diff = execSync(`git diff ${mergeBaseSha} ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
|
|
494
502
|
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
495
503
|
maxBuffer: 50 * 1024 * 1024
|
|
496
504
|
});
|
|
497
505
|
} else if (hasStaged && !hasUnstaged) {
|
|
498
506
|
// Staged only → cached changes
|
|
499
|
-
diff = execSync(`git diff --cached
|
|
507
|
+
diff = execSync(`git diff --cached ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
|
|
500
508
|
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
501
509
|
maxBuffer: 50 * 1024 * 1024
|
|
502
510
|
});
|
|
503
511
|
} else if (hasStaged && hasUnstaged) {
|
|
504
512
|
// Staged–Unstaged (or Staged–Untracked) → all changes vs HEAD
|
|
505
|
-
diff = execSync(`git diff HEAD
|
|
513
|
+
diff = execSync(`git diff HEAD ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
|
|
506
514
|
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
507
515
|
maxBuffer: 50 * 1024 * 1024
|
|
508
516
|
});
|
|
509
517
|
} else if (hasUnstaged) {
|
|
510
518
|
// Unstaged only or Unstaged–Untracked → working tree changes
|
|
511
|
-
diff = execSync(`git diff
|
|
519
|
+
diff = execSync(`git diff ${GIT_DIFF_COMMON_FLAGS} --unified=25${wFlag}`, {
|
|
512
520
|
cwd: repoPath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
|
|
513
521
|
maxBuffer: 50 * 1024 * 1024
|
|
514
522
|
});
|
|
@@ -590,7 +598,7 @@ async function computeScopedDigest(repoPath, scopeStart, scopeEnd) {
|
|
|
590
598
|
// Staged in scope → cached diff content
|
|
591
599
|
if (scopeIncludes(scopeStart, scopeEnd, 'staged')) {
|
|
592
600
|
try {
|
|
593
|
-
const result = await execAsync(
|
|
601
|
+
const result = await execAsync(`git diff --cached ${GIT_DIFF_COMMON_FLAGS}`, {
|
|
594
602
|
cwd: repoPath, encoding: 'utf8', maxBuffer: 50 * 1024 * 1024
|
|
595
603
|
});
|
|
596
604
|
parts.push('STAGED:' + result.stdout);
|
|
@@ -602,7 +610,7 @@ async function computeScopedDigest(repoPath, scopeStart, scopeEnd) {
|
|
|
602
610
|
// Unstaged in scope → working tree diff
|
|
603
611
|
if (scopeIncludes(scopeStart, scopeEnd, 'unstaged')) {
|
|
604
612
|
try {
|
|
605
|
-
const result = await execAsync(
|
|
613
|
+
const result = await execAsync(`git diff ${GIT_DIFF_COMMON_FLAGS}`, {
|
|
606
614
|
cwd: repoPath, encoding: 'utf8', maxBuffer: 50 * 1024 * 1024
|
|
607
615
|
});
|
|
608
616
|
parts.push('UNSTAGED:' + result.stdout);
|
package/src/routes/local.js
CHANGED
|
@@ -66,6 +66,34 @@ function deleteLocalReviewDiff(reviewId) {
|
|
|
66
66
|
localReviewDiffs.delete(toIntKey(reviewId));
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Check whether branch scope should be selectable in the scope range selector.
|
|
71
|
+
* Returns true when the branch has commits ahead of the base branch.
|
|
72
|
+
* Non-fatal: returns false on any error.
|
|
73
|
+
*/
|
|
74
|
+
async function checkBranchAvailable(localPath, branchName, scopeStart, config, repositoryName) {
|
|
75
|
+
if (includesBranch(scopeStart)) return true;
|
|
76
|
+
if (!branchName || branchName === 'HEAD' || branchName === 'unknown' || !localPath) return false;
|
|
77
|
+
try {
|
|
78
|
+
const baseBranch = require('../git/base-branch');
|
|
79
|
+
const depsOverride = getGitHubToken(config) ? { getGitHubToken: () => getGitHubToken(config) } : undefined;
|
|
80
|
+
const detection = await baseBranch.detectBaseBranch(localPath, branchName, {
|
|
81
|
+
repository: repositoryName,
|
|
82
|
+
enableGraphite: config.enable_graphite === true,
|
|
83
|
+
_deps: depsOverride
|
|
84
|
+
});
|
|
85
|
+
if (detection) {
|
|
86
|
+
// Lazy require to ensure testability via vi.spyOn on the module exports
|
|
87
|
+
const localReview = require('../local-review');
|
|
88
|
+
const commitCount = await localReview.getBranchCommitCount(localPath, detection.baseBranch);
|
|
89
|
+
return commitCount > 0;
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Non-fatal — branch stop stays disabled
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
69
97
|
/**
|
|
70
98
|
* Delete a local review session and its in-memory diff cache.
|
|
71
99
|
* Shared by both single-delete and bulk-delete routes.
|
|
@@ -575,26 +603,9 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
575
603
|
// Determine if Branch stop should be selectable in the scope range selector.
|
|
576
604
|
// This is independent of branchInfo (which guards on no uncommitted changes).
|
|
577
605
|
// Branch is available when: not detached HEAD, not on default branch, and has commits ahead.
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const { getBranchCommitCount } = require('../local-review');
|
|
582
|
-
const { detectBaseBranch } = require('../git/base-branch');
|
|
583
|
-
const config = req.app.get('config') || {};
|
|
584
|
-
const depsOverride = getGitHubToken(config) ? { getGitHubToken: () => getGitHubToken(config) } : undefined;
|
|
585
|
-
const detection = await detectBaseBranch(review.local_path, branchName, {
|
|
586
|
-
repository: repositoryName,
|
|
587
|
-
enableGraphite: config.enable_graphite === true,
|
|
588
|
-
_deps: depsOverride
|
|
589
|
-
});
|
|
590
|
-
if (detection) {
|
|
591
|
-
const commitCount = await getBranchCommitCount(review.local_path, detection.baseBranch);
|
|
592
|
-
branchAvailable = commitCount > 0;
|
|
593
|
-
}
|
|
594
|
-
} catch {
|
|
595
|
-
// Non-fatal — branch stop stays disabled
|
|
596
|
-
}
|
|
597
|
-
}
|
|
606
|
+
const branchAvailable = Boolean(branchInfo) || await checkBranchAvailable(
|
|
607
|
+
review.local_path, branchName, scopeStart, req.app.get('config') || {}, repositoryName
|
|
608
|
+
);
|
|
598
609
|
|
|
599
610
|
// Compute SHA abbreviation length from the repo's git config
|
|
600
611
|
const shaAbbrevLength = getShaAbbrevLength(review.local_path);
|
|
@@ -1364,6 +1375,15 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
|
1364
1375
|
logger.warn(`Could not check HEAD SHA: ${headError.message}`);
|
|
1365
1376
|
}
|
|
1366
1377
|
|
|
1378
|
+
// Recompute branchAvailable so the frontend can update the scope selector
|
|
1379
|
+
// (e.g. after a commit creates the first branch-ahead commit).
|
|
1380
|
+
const config = req.app.get('config') || {};
|
|
1381
|
+
let branchName;
|
|
1382
|
+
try { branchName = await getCurrentBranch(localPath); } catch (_) { branchName = review.local_head_branch || null; }
|
|
1383
|
+
const branchAvailable = await checkBranchAvailable(
|
|
1384
|
+
localPath, branchName, scopeStart, config, review.repository
|
|
1385
|
+
);
|
|
1386
|
+
|
|
1367
1387
|
// Non-branch HEAD change: skip diff computation entirely — the old diff is
|
|
1368
1388
|
// preserved until the user decides (via resolve-head-change) what to do.
|
|
1369
1389
|
// The resolve-head-change endpoint will recompute the diff for whichever
|
|
@@ -1373,6 +1393,7 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
|
1373
1393
|
success: true,
|
|
1374
1394
|
message: 'HEAD changed — awaiting user decision',
|
|
1375
1395
|
headShaChanged,
|
|
1396
|
+
branchAvailable,
|
|
1376
1397
|
previousHeadSha: originalHeadSha,
|
|
1377
1398
|
currentHeadSha: currentHeadSha || null,
|
|
1378
1399
|
stats: {}
|
|
@@ -1397,6 +1418,7 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
|
1397
1418
|
success: true,
|
|
1398
1419
|
message: 'Diff refreshed successfully',
|
|
1399
1420
|
headShaChanged,
|
|
1421
|
+
branchAvailable,
|
|
1400
1422
|
previousHeadSha: originalHeadSha,
|
|
1401
1423
|
currentHeadSha: currentHeadSha || null,
|
|
1402
1424
|
stats: {
|
|
@@ -1453,17 +1475,24 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
|
|
|
1453
1475
|
const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
|
|
1454
1476
|
|
|
1455
1477
|
if (action === 'update') {
|
|
1456
|
-
//
|
|
1457
|
-
|
|
1478
|
+
// Read live branch — may differ from stored value after a checkout.
|
|
1479
|
+
// Lazy require to ensure testability via vi.spyOn on the module exports.
|
|
1480
|
+
let headBranch;
|
|
1481
|
+
try { headBranch = await require('../local-review').getCurrentBranch(localPath); } catch (_) { headBranch = review.local_head_branch || null; }
|
|
1482
|
+
|
|
1483
|
+
// Check for UNIQUE conflict before any mutation.
|
|
1484
|
+
// Use the live branch + new SHA so the conflict check targets the
|
|
1485
|
+
// final identity tuple (localPath, newHeadSha, headBranch).
|
|
1458
1486
|
const conflict = await reviewRepo.getLocalReview(localPath, newHeadSha, headBranch);
|
|
1459
1487
|
if (conflict && conflict.id !== reviewId) {
|
|
1460
1488
|
logger.log('API', `UNIQUE conflict: session #${conflict.id} already exists for this HEAD`, 'yellow');
|
|
1461
1489
|
return res.json({ success: true, action: 'redirect', sessionId: conflict.id });
|
|
1462
1490
|
}
|
|
1463
1491
|
|
|
1464
|
-
//
|
|
1465
|
-
|
|
1466
|
-
|
|
1492
|
+
// Persist SHA and branch together in a single write so SQLite only
|
|
1493
|
+
// ever sees the final identity tuple — no transient intermediate state.
|
|
1494
|
+
await reviewRepo.updateReview(reviewId, { local_head_sha: newHeadSha, local_head_branch: headBranch });
|
|
1495
|
+
logger.log('API', `Updated HEAD SHA and branch on session ${reviewId}`, 'cyan');
|
|
1467
1496
|
|
|
1468
1497
|
// Recompute and persist diff
|
|
1469
1498
|
const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
|
|
@@ -1475,7 +1504,14 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
|
|
|
1475
1504
|
logger.warn(`Could not persist diff to database: ${persistError.message}`);
|
|
1476
1505
|
}
|
|
1477
1506
|
|
|
1478
|
-
|
|
1507
|
+
// Recompute branchAvailable — the commit may have created the first
|
|
1508
|
+
// branch-ahead commit, making the Branch scope stop selectable.
|
|
1509
|
+
const config = req.app.get('config') || {};
|
|
1510
|
+
const branchAvailable = await checkBranchAvailable(
|
|
1511
|
+
localPath, headBranch, scopeStart, config, review.repository
|
|
1512
|
+
);
|
|
1513
|
+
|
|
1514
|
+
return res.json({ success: true, action: 'updated', branchAvailable });
|
|
1479
1515
|
}
|
|
1480
1516
|
|
|
1481
1517
|
// action === 'new-session'
|