@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@in-the-loop-labs/pair-review",
3
- "version": "3.0.4",
3
+ "version": "3.0.5",
4
4
  "description": "Your AI-powered code review partner - Close the feedback loop with AI coding agents",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-review",
3
- "version": "3.0.4",
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.4",
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 = wrapper.querySelector('.pr-description-popover');
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 = wrapper.querySelector('.pr-description-popover');
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
- wrapper.appendChild(popover);
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);
@@ -170,4 +170,55 @@ function tryDefaultBranch(repoPath, currentBranch, deps) {
170
170
  return null;
171
171
  }
172
172
 
173
- module.exports = { detectBaseBranch };
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 };
@@ -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 has commits ahead of the base branch.
72
- * Non-fatal: returns false on any error.
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
- async function checkBranchAvailable(localPath, branchName, scopeStart, config, repositoryName) {
78
+ function isBranchAvailable(branchName, scopeStart, localPath) {
75
79
  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
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 false;
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, and has commits ahead.
606
- const branchAvailable = Boolean(branchInfo) || await checkBranchAvailable(
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
- const config = req.app.get('config') || {};
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 = await checkBranchAvailable(
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 config = req.app.get('config') || {};
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
- const { detectBaseBranch } = require('../git/base-branch');
1599
- const config = req.app.get('config') || {};
1600
- const token = getGitHubToken(config);
1601
- const detection = await detectBaseBranch(localPath, currentBranch, {
1602
- repository: review.repository,
1603
- enableGraphite: config.enable_graphite === true,
1604
- _deps: token ? { getGitHubToken: () => token } : undefined
1605
- });
1606
- if (!detection) {
1607
- return res.status(400).json({ error: 'Could not detect base branch' });
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