@in-the-loop-labs/pair-review 3.0.4 → 3.0.5
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 +1 -5
- package/public/js/pr.js +12 -6
- package/src/database.js +5 -0
- package/src/git/base-branch.js +52 -1
- package/src/routes/local.js +83 -48
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pair-review",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.5",
|
|
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.5",
|
|
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/css/pr.css
CHANGED
|
@@ -6000,12 +6000,8 @@ body::before {
|
|
|
6000
6000
|
color: var(--color-accent, #2f81f7);
|
|
6001
6001
|
}
|
|
6002
6002
|
|
|
6003
|
-
/* PR description popover */
|
|
6003
|
+
/* PR description popover - appended to document.body with fixed positioning */
|
|
6004
6004
|
.pr-description-popover {
|
|
6005
|
-
position: absolute;
|
|
6006
|
-
top: calc(100% + 8px);
|
|
6007
|
-
left: 50%;
|
|
6008
|
-
transform: translateX(-50%);
|
|
6009
6005
|
z-index: 1000;
|
|
6010
6006
|
width: min(500px, 90vw);
|
|
6011
6007
|
background: var(--color-bg-primary, #0d1117);
|
package/public/js/pr.js
CHANGED
|
@@ -970,11 +970,8 @@ class PRManager {
|
|
|
970
970
|
const toggle = document.getElementById('pr-description-toggle');
|
|
971
971
|
if (!toggle) return;
|
|
972
972
|
|
|
973
|
-
const wrapper = toggle.closest('.pr-title-wrapper');
|
|
974
|
-
if (!wrapper) return;
|
|
975
|
-
|
|
976
973
|
const closePopover = () => {
|
|
977
|
-
const existing =
|
|
974
|
+
const existing = document.querySelector('.pr-description-popover');
|
|
978
975
|
if (existing) existing.remove();
|
|
979
976
|
toggle.classList.remove('active');
|
|
980
977
|
toggle.setAttribute('aria-expanded', 'false');
|
|
@@ -982,7 +979,7 @@ class PRManager {
|
|
|
982
979
|
|
|
983
980
|
toggle.addEventListener('click', (e) => {
|
|
984
981
|
e.stopPropagation();
|
|
985
|
-
const existing =
|
|
982
|
+
const existing = document.querySelector('.pr-description-popover');
|
|
986
983
|
if (existing) {
|
|
987
984
|
closePopover();
|
|
988
985
|
return;
|
|
@@ -1017,7 +1014,16 @@ class PRManager {
|
|
|
1017
1014
|
|
|
1018
1015
|
popover.append(arrow, header, content);
|
|
1019
1016
|
|
|
1020
|
-
|
|
1017
|
+
// Position relative to the toggle button
|
|
1018
|
+
const rect = toggle.getBoundingClientRect();
|
|
1019
|
+
popover.style.position = 'fixed';
|
|
1020
|
+
popover.style.top = `${rect.bottom + 8}px`;
|
|
1021
|
+
popover.style.left = `${rect.left + rect.width / 2}px`;
|
|
1022
|
+
popover.style.transform = 'translateX(-50%)';
|
|
1023
|
+
|
|
1024
|
+
// Append to document.body to escape overflow:hidden on .header-center
|
|
1025
|
+
document.body.appendChild(popover);
|
|
1026
|
+
|
|
1021
1027
|
toggle.classList.add('active');
|
|
1022
1028
|
toggle.setAttribute('aria-expanded', 'true');
|
|
1023
1029
|
|
package/src/database.js
CHANGED
|
@@ -2755,6 +2755,11 @@ class ReviewRepository {
|
|
|
2755
2755
|
params.push(updates.name);
|
|
2756
2756
|
}
|
|
2757
2757
|
|
|
2758
|
+
if (updates.local_base_branch !== undefined) {
|
|
2759
|
+
setClauses.push('local_base_branch = ?');
|
|
2760
|
+
params.push(updates.local_base_branch);
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2758
2763
|
if (updates.local_head_branch !== undefined) {
|
|
2759
2764
|
setClauses.push('local_head_branch = ?');
|
|
2760
2765
|
params.push(updates.local_head_branch);
|
package/src/git/base-branch.js
CHANGED
|
@@ -170,4 +170,55 @@ function tryDefaultBranch(repoPath, currentBranch, deps) {
|
|
|
170
170
|
return null;
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Synchronously detect the default branch for a repository.
|
|
175
|
+
*
|
|
176
|
+
* Uses the same logic as tryDefaultBranch but returns just the branch name
|
|
177
|
+
* (or null). Suitable for call sites that need a quick, synchronous answer
|
|
178
|
+
* without the full detectBaseBranch priority chain.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} repoPath - Absolute path to the repository
|
|
181
|
+
* @param {Object} [_deps] - Dependency overrides for testing
|
|
182
|
+
* @returns {string|null} Default branch name, or null if it cannot be determined
|
|
183
|
+
*/
|
|
184
|
+
function getDefaultBranch(repoPath, _deps) {
|
|
185
|
+
const deps = { ...defaults, ..._deps };
|
|
186
|
+
|
|
187
|
+
// Try `git remote show origin`
|
|
188
|
+
try {
|
|
189
|
+
const output = deps.execSync('git remote show origin', {
|
|
190
|
+
cwd: repoPath,
|
|
191
|
+
encoding: 'utf8',
|
|
192
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
193
|
+
timeout: 5000
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const match = output.match(/HEAD branch:\s*(.+)/);
|
|
197
|
+
if (match) {
|
|
198
|
+
const branch = match[1].trim();
|
|
199
|
+
if (branch && branch !== '(unknown)') {
|
|
200
|
+
return branch;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// No remote or network issue — try local refs
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Fallback: check if main or master exists locally
|
|
208
|
+
for (const candidate of ['main', 'master']) {
|
|
209
|
+
try {
|
|
210
|
+
deps.execSync(`git rev-parse --verify ${candidate}`, {
|
|
211
|
+
cwd: repoPath,
|
|
212
|
+
encoding: 'utf8',
|
|
213
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
214
|
+
});
|
|
215
|
+
return candidate;
|
|
216
|
+
} catch {
|
|
217
|
+
// Branch doesn't exist
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = { detectBaseBranch, getDefaultBranch };
|
package/src/routes/local.js
CHANGED
|
@@ -68,30 +68,24 @@ function deleteLocalReviewDiff(reviewId) {
|
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
70
|
* Check whether branch scope should be selectable in the scope range selector.
|
|
71
|
-
* Returns true when the branch
|
|
72
|
-
*
|
|
71
|
+
* Returns true when the current branch is a non-default, non-detached branch,
|
|
72
|
+
* or when the scope already includes branch.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} branchName - Current branch name
|
|
75
|
+
* @param {string} scopeStart - Current scope start stop
|
|
76
|
+
* @param {string} localPath - Absolute path to the repository (used to detect the actual default branch)
|
|
73
77
|
*/
|
|
74
|
-
|
|
78
|
+
function isBranchAvailable(branchName, scopeStart, localPath) {
|
|
75
79
|
if (includesBranch(scopeStart)) return true;
|
|
76
|
-
if (!branchName || branchName === 'HEAD' || branchName === 'unknown'
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
80
|
+
if (!branchName || branchName === 'HEAD' || branchName === 'unknown') return false;
|
|
81
|
+
|
|
82
|
+
const { getDefaultBranch } = require('../git/base-branch');
|
|
83
|
+
const defaultBranch = localPath ? getDefaultBranch(localPath) : null;
|
|
84
|
+
// If detection fails, fall back to checking main/master
|
|
85
|
+
if (defaultBranch) {
|
|
86
|
+
return branchName !== defaultBranch;
|
|
93
87
|
}
|
|
94
|
-
return
|
|
88
|
+
return branchName !== 'main' && branchName !== 'master';
|
|
95
89
|
}
|
|
96
90
|
|
|
97
91
|
/**
|
|
@@ -506,6 +500,7 @@ router.post('/api/local/start', async (req, res) => {
|
|
|
506
500
|
* Get local review metadata
|
|
507
501
|
*/
|
|
508
502
|
router.get('/api/local/:reviewId', async (req, res) => {
|
|
503
|
+
const tEndpoint = Date.now();
|
|
509
504
|
try {
|
|
510
505
|
const reviewId = parseInt(req.params.reviewId);
|
|
511
506
|
|
|
@@ -602,13 +597,15 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
602
597
|
|
|
603
598
|
// Determine if Branch stop should be selectable in the scope range selector.
|
|
604
599
|
// This is independent of branchInfo (which guards on no uncommitted changes).
|
|
605
|
-
// Branch is available when: not detached HEAD, not on default branch
|
|
606
|
-
const branchAvailable = Boolean(branchInfo) ||
|
|
607
|
-
review.local_path, branchName, scopeStart, req.app.get('config') || {}, repositoryName
|
|
608
|
-
);
|
|
600
|
+
// Branch is available when: not detached HEAD, not on default branch.
|
|
601
|
+
const branchAvailable = Boolean(branchInfo) || isBranchAvailable(branchName, scopeStart, review.local_path);
|
|
609
602
|
|
|
610
603
|
// Compute SHA abbreviation length from the repo's git config
|
|
611
604
|
const shaAbbrevLength = getShaAbbrevLength(review.local_path);
|
|
605
|
+
const metadataElapsed = Date.now() - tEndpoint;
|
|
606
|
+
if (metadataElapsed > 200) {
|
|
607
|
+
logger.debug(`[perf] metadata#${reviewId} took ${metadataElapsed}ms (threshold: 200ms)`);
|
|
608
|
+
}
|
|
612
609
|
|
|
613
610
|
res.json({
|
|
614
611
|
id: review.id,
|
|
@@ -630,6 +627,29 @@ router.get('/api/local/:reviewId', async (req, res) => {
|
|
|
630
627
|
updatedAt: review.updated_at
|
|
631
628
|
});
|
|
632
629
|
|
|
630
|
+
// Background: pre-cache base branch detection so set-scope is fast later
|
|
631
|
+
if (!includesBranch(scopeStart) && !review.local_base_branch
|
|
632
|
+
&& branchName && branchName !== 'HEAD' && branchName !== 'unknown'
|
|
633
|
+
&& repositoryName && repositoryName.includes('/')) {
|
|
634
|
+
const bgConfig = req.app.get('config') || {};
|
|
635
|
+
const bgToken = getGitHubToken(bgConfig);
|
|
636
|
+
const bgT0 = Date.now();
|
|
637
|
+
const { detectBaseBranch } = require('../git/base-branch');
|
|
638
|
+
detectBaseBranch(review.local_path, branchName, {
|
|
639
|
+
repository: repositoryName,
|
|
640
|
+
enableGraphite: bgConfig.enable_graphite === true,
|
|
641
|
+
_deps: bgToken ? { getGitHubToken: () => bgToken } : undefined
|
|
642
|
+
}).then(detection => {
|
|
643
|
+
if (detection && detection.baseBranch) {
|
|
644
|
+
return reviewRepo.updateReview(reviewId, { local_base_branch: detection.baseBranch });
|
|
645
|
+
}
|
|
646
|
+
}).then(() => {
|
|
647
|
+
logger.debug(`[perf] metadata#${reviewId} background-detectBaseBranch: ${Date.now() - bgT0}ms`);
|
|
648
|
+
}).catch(err => {
|
|
649
|
+
logger.warn(`Background base branch detection failed: ${err.message}`);
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
|
|
633
653
|
// Fire review.loaded hook (session already exists to be fetched by ID)
|
|
634
654
|
const hookConfig = req.app.get('config') || {};
|
|
635
655
|
if (hasHooks('review.loaded', hookConfig)) {
|
|
@@ -704,6 +724,7 @@ router.patch('/api/local/:reviewId/name', async (req, res) => {
|
|
|
704
724
|
* Get local diff
|
|
705
725
|
*/
|
|
706
726
|
router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
727
|
+
const tEndpoint = Date.now();
|
|
707
728
|
try {
|
|
708
729
|
const reviewId = parseInt(req.params.reviewId);
|
|
709
730
|
|
|
@@ -781,6 +802,10 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
781
802
|
}
|
|
782
803
|
}
|
|
783
804
|
|
|
805
|
+
const diffElapsed = Date.now() - tEndpoint;
|
|
806
|
+
if (diffElapsed > 200) {
|
|
807
|
+
logger.debug(`[perf] diff#${reviewId} took ${diffElapsed}ms (threshold: 200ms)`);
|
|
808
|
+
}
|
|
784
809
|
res.json({
|
|
785
810
|
diff: diffContent || '',
|
|
786
811
|
generated_files: generatedFiles,
|
|
@@ -805,6 +830,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
|
805
830
|
* Uses a digest of the diff content for accurate change detection
|
|
806
831
|
*/
|
|
807
832
|
router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
833
|
+
const tEndpoint = Date.now();
|
|
808
834
|
try {
|
|
809
835
|
const reviewId = parseInt(req.params.reviewId);
|
|
810
836
|
|
|
@@ -861,6 +887,10 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
|
861
887
|
|
|
862
888
|
// When branch is in scope and HEAD changed, early return (existing behavior)
|
|
863
889
|
if (includesBranch(scopeStart) && headShaChanged) {
|
|
890
|
+
const staleEarlyElapsed = Date.now() - tEndpoint;
|
|
891
|
+
if (staleEarlyElapsed > 200) {
|
|
892
|
+
logger.debug(`[perf] check-stale#${reviewId} took ${staleEarlyElapsed}ms (threshold: 200ms)`);
|
|
893
|
+
}
|
|
864
894
|
return res.json({
|
|
865
895
|
isStale: true,
|
|
866
896
|
headShaChanged,
|
|
@@ -918,6 +948,10 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
|
918
948
|
|
|
919
949
|
const isStale = storedDiffData.digest !== currentDigest;
|
|
920
950
|
|
|
951
|
+
const staleElapsed = Date.now() - tEndpoint;
|
|
952
|
+
if (staleElapsed > 200) {
|
|
953
|
+
logger.debug(`[perf] check-stale#${reviewId} took ${staleElapsed}ms (threshold: 200ms)`);
|
|
954
|
+
}
|
|
921
955
|
res.json({
|
|
922
956
|
isStale,
|
|
923
957
|
storedDigest: storedDiffData.digest,
|
|
@@ -1377,12 +1411,10 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
|
1377
1411
|
|
|
1378
1412
|
// Recompute branchAvailable so the frontend can update the scope selector
|
|
1379
1413
|
// (e.g. after a commit creates the first branch-ahead commit).
|
|
1380
|
-
|
|
1414
|
+
// Lazy require to ensure testability via vi.spyOn on the module exports.
|
|
1381
1415
|
let branchName;
|
|
1382
|
-
try { branchName = await getCurrentBranch(localPath); } catch (_) { branchName = review.local_head_branch || null; }
|
|
1383
|
-
const branchAvailable =
|
|
1384
|
-
localPath, branchName, scopeStart, config, review.repository
|
|
1385
|
-
);
|
|
1416
|
+
try { branchName = await require('../local-review').getCurrentBranch(localPath); } catch (_) { branchName = review.local_head_branch || null; }
|
|
1417
|
+
const branchAvailable = isBranchAvailable(branchName, scopeStart, localPath);
|
|
1386
1418
|
|
|
1387
1419
|
// Non-branch HEAD change: skip diff computation entirely — the old diff is
|
|
1388
1420
|
// preserved until the user decides (via resolve-head-change) what to do.
|
|
@@ -1491,8 +1523,8 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
|
|
|
1491
1523
|
|
|
1492
1524
|
// Persist SHA and branch together in a single write so SQLite only
|
|
1493
1525
|
// 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');
|
|
1526
|
+
await reviewRepo.updateReview(reviewId, { local_head_sha: newHeadSha, local_head_branch: headBranch, local_base_branch: null });
|
|
1527
|
+
logger.log('API', `Updated HEAD SHA and branch on session ${reviewId} (cleared cached base branch)`, 'cyan');
|
|
1496
1528
|
|
|
1497
1529
|
// Recompute and persist diff
|
|
1498
1530
|
const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
|
|
@@ -1506,10 +1538,7 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
|
|
|
1506
1538
|
|
|
1507
1539
|
// Recompute branchAvailable — the commit may have created the first
|
|
1508
1540
|
// branch-ahead commit, making the Branch scope stop selectable.
|
|
1509
|
-
const
|
|
1510
|
-
const branchAvailable = await checkBranchAvailable(
|
|
1511
|
-
localPath, headBranch, scopeStart, config, review.repository
|
|
1512
|
-
);
|
|
1541
|
+
const branchAvailable = isBranchAvailable(headBranch, scopeStart, localPath);
|
|
1513
1542
|
|
|
1514
1543
|
return res.json({ success: true, action: 'updated', branchAvailable });
|
|
1515
1544
|
}
|
|
@@ -1593,20 +1622,26 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
|
|
|
1593
1622
|
let baseBranch = requestBaseBranch || null;
|
|
1594
1623
|
let currentBranch = null;
|
|
1595
1624
|
if (includesBranch(scopeStart)) {
|
|
1596
|
-
currentBranch = await getCurrentBranch(localPath);
|
|
1625
|
+
currentBranch = await require('../local-review').getCurrentBranch(localPath);
|
|
1597
1626
|
if (!baseBranch) {
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1627
|
+
// Use cached base branch from background detection if available
|
|
1628
|
+
if (review.local_base_branch && review.local_head_branch === currentBranch) {
|
|
1629
|
+
baseBranch = review.local_base_branch;
|
|
1630
|
+
logger.debug(`[perf] set-scope#${reviewId} using cached base branch: ${baseBranch}`);
|
|
1631
|
+
} else {
|
|
1632
|
+
const { detectBaseBranch } = require('../git/base-branch');
|
|
1633
|
+
const config = req.app.get('config') || {};
|
|
1634
|
+
const token = getGitHubToken(config);
|
|
1635
|
+
const detection = await detectBaseBranch(localPath, currentBranch, {
|
|
1636
|
+
repository: review.repository,
|
|
1637
|
+
enableGraphite: config.enable_graphite === true,
|
|
1638
|
+
_deps: token ? { getGitHubToken: () => token } : undefined
|
|
1639
|
+
});
|
|
1640
|
+
if (!detection) {
|
|
1641
|
+
return res.status(400).json({ error: 'Could not detect base branch' });
|
|
1642
|
+
}
|
|
1643
|
+
baseBranch = detection.baseBranch;
|
|
1608
1644
|
}
|
|
1609
|
-
baseBranch = detection.baseBranch;
|
|
1610
1645
|
}
|
|
1611
1646
|
|
|
1612
1647
|
// Validate branch name to prevent shell injection
|