@in-the-loop-labs/pair-review 3.0.4 → 3.0.6

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.6",
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.6",
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.6",
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,52 @@ 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 using only
175
+ * local refs (no network I/O).
176
+ *
177
+ * Priority:
178
+ * 1. `git symbolic-ref refs/remotes/origin/HEAD` — reads the local ref
179
+ * that `git clone` sets automatically.
180
+ * 2. Check whether `refs/heads/main` or `refs/heads/master` exist locally.
181
+ *
182
+ * @param {string} localPath - Absolute path to the repository
183
+ * @param {Object} [_deps] - Dependency overrides for testing
184
+ * @returns {string|null} Default branch name, or null if it cannot be determined
185
+ */
186
+ function getDefaultBranch(localPath, _deps) {
187
+ if (!localPath) return null;
188
+ const deps = { ...defaults, ..._deps };
189
+
190
+ // Try symbolic-ref (set by git clone)
191
+ try {
192
+ const ref = deps.execSync('git symbolic-ref refs/remotes/origin/HEAD', {
193
+ cwd: localPath,
194
+ encoding: 'utf8',
195
+ stdio: ['pipe', 'pipe', 'pipe'],
196
+ }).trim();
197
+ // ref looks like "refs/remotes/origin/main"
198
+ const branch = ref.replace(/^refs\/remotes\/origin\//, '');
199
+ if (branch && branch !== ref) return branch;
200
+ } catch {
201
+ // origin/HEAD not set — fall through to local check
202
+ }
203
+
204
+ // Fallback: check if main or master exists locally
205
+ for (const candidate of ['main', 'master']) {
206
+ try {
207
+ deps.execSync(`git rev-parse --verify refs/heads/${candidate}`, {
208
+ cwd: localPath,
209
+ encoding: 'utf8',
210
+ stdio: ['pipe', 'pipe', 'pipe'],
211
+ });
212
+ return candidate;
213
+ } catch {
214
+ // Branch doesn't exist
215
+ }
216
+ }
217
+
218
+ return null;
219
+ }
220
+
221
+ module.exports = { detectBaseBranch, getDefaultBranch };
@@ -31,6 +31,9 @@ const { getGeneratedFilePatterns } = require('../git/gitattributes');
31
31
  const { getShaAbbrevLength } = require('../git/sha-abbrev');
32
32
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
33
33
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
34
+ const { getProviderClass, createProvider } = require('../ai/provider');
35
+ const { getDefaultBranch } = require('../git/base-branch');
36
+ const { CommentRepository } = require('../database');
34
37
  const {
35
38
  activeAnalyses,
36
39
  localReviewDiffs,
@@ -68,30 +71,23 @@ function deleteLocalReviewDiff(reviewId) {
68
71
 
69
72
  /**
70
73
  * 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.
74
+ * Returns true when the current branch is a non-default, non-detached branch,
75
+ * or when the scope already includes branch.
76
+ *
77
+ * @param {string} branchName - Current branch name
78
+ * @param {string} scopeStart - Current scope start stop
79
+ * @param {string} localPath - Absolute path to the repository (used to detect the actual default branch)
73
80
  */
74
- async function checkBranchAvailable(localPath, branchName, scopeStart, config, repositoryName) {
81
+ function isBranchAvailable(branchName, scopeStart, localPath) {
75
82
  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
83
+ if (!branchName || branchName === 'HEAD' || branchName === 'unknown') return false;
84
+
85
+ // Detect the default branch using only local refs (no network).
86
+ const defaultBranch = getDefaultBranch(localPath);
87
+ if (defaultBranch) {
88
+ return branchName !== defaultBranch;
93
89
  }
94
- return false;
90
+ return branchName !== 'main' && branchName !== 'master';
95
91
  }
96
92
 
97
93
  /**
@@ -506,6 +502,7 @@ router.post('/api/local/start', async (req, res) => {
506
502
  * Get local review metadata
507
503
  */
508
504
  router.get('/api/local/:reviewId', async (req, res) => {
505
+ const tEndpoint = Date.now();
509
506
  try {
510
507
  const reviewId = parseInt(req.params.reviewId);
511
508
 
@@ -566,19 +563,14 @@ router.get('/api/local/:reviewId', async (req, res) => {
566
563
  const baseBranch = review.local_base_branch || null;
567
564
 
568
565
  // When scope does NOT include branch, check for branch detection info
569
- // Frontend uses this to suggest expanding scope to include branch
566
+ // Frontend uses this to suggest expanding scope to include branch.
567
+ // Only use already-cached results here — never block the response on
568
+ // GitHub API calls. Background detection (after res.json) will populate
569
+ // the cache for subsequent requests.
570
570
  let branchInfo = null;
571
571
  const cachedDiff = getLocalReviewDiff(reviewId);
572
572
  if (!includesBranch(scopeStart) && cachedDiff?.branchInfo) {
573
573
  branchInfo = cachedDiff.branchInfo;
574
- } else if (!includesBranch(scopeStart) && !cachedDiff && review.local_path) {
575
- // No cache (web UI started session) — run detection on-demand
576
- const config = req.app.get('config') || {};
577
- branchInfo = await detectAndBuildBranchInfo(review.local_path, branchName, {
578
- repository: repositoryName,
579
- githubToken: getGitHubToken(config),
580
- enableGraphite: config.enable_graphite === true
581
- });
582
574
  }
583
575
 
584
576
  // Check repo settings for auto_branch_review preference
@@ -602,13 +594,15 @@ router.get('/api/local/:reviewId', async (req, res) => {
602
594
 
603
595
  // Determine if Branch stop should be selectable in the scope range selector.
604
596
  // 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
- );
597
+ // Branch is available when: not detached HEAD, not on default branch.
598
+ const branchAvailable = Boolean(branchInfo) || isBranchAvailable(branchName, scopeStart, review.local_path);
609
599
 
610
600
  // Compute SHA abbreviation length from the repo's git config
611
601
  const shaAbbrevLength = getShaAbbrevLength(review.local_path);
602
+ const metadataElapsed = Date.now() - tEndpoint;
603
+ if (metadataElapsed > 200) {
604
+ logger.debug(`[perf] metadata#${reviewId} took ${metadataElapsed}ms (threshold: 200ms)`);
605
+ }
612
606
 
613
607
  res.json({
614
608
  id: review.id,
@@ -630,6 +624,29 @@ router.get('/api/local/:reviewId', async (req, res) => {
630
624
  updatedAt: review.updated_at
631
625
  });
632
626
 
627
+ // Background: pre-cache base branch detection so set-scope is fast later
628
+ if (!includesBranch(scopeStart) && !review.local_base_branch
629
+ && branchName && branchName !== 'HEAD' && branchName !== 'unknown'
630
+ && repositoryName && repositoryName.includes('/')) {
631
+ const bgConfig = req.app.get('config') || {};
632
+ const bgToken = getGitHubToken(bgConfig);
633
+ const bgT0 = Date.now();
634
+ const { detectBaseBranch } = require('../git/base-branch');
635
+ detectBaseBranch(review.local_path, branchName, {
636
+ repository: repositoryName,
637
+ enableGraphite: bgConfig.enable_graphite === true,
638
+ _deps: bgToken ? { getGitHubToken: () => bgToken } : undefined
639
+ }).then(detection => {
640
+ if (detection && detection.baseBranch) {
641
+ return reviewRepo.updateReview(reviewId, { local_base_branch: detection.baseBranch });
642
+ }
643
+ }).then(() => {
644
+ logger.debug(`[perf] metadata#${reviewId} background-detectBaseBranch: ${Date.now() - bgT0}ms`);
645
+ }).catch(err => {
646
+ logger.warn(`Background base branch detection failed: ${err.message}`);
647
+ });
648
+ }
649
+
633
650
  // Fire review.loaded hook (session already exists to be fetched by ID)
634
651
  const hookConfig = req.app.get('config') || {};
635
652
  if (hasHooks('review.loaded', hookConfig)) {
@@ -704,6 +721,7 @@ router.patch('/api/local/:reviewId/name', async (req, res) => {
704
721
  * Get local diff
705
722
  */
706
723
  router.get('/api/local/:reviewId/diff', async (req, res) => {
724
+ const tEndpoint = Date.now();
707
725
  try {
708
726
  const reviewId = parseInt(req.params.reviewId);
709
727
 
@@ -781,6 +799,10 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
781
799
  }
782
800
  }
783
801
 
802
+ const diffElapsed = Date.now() - tEndpoint;
803
+ if (diffElapsed > 200) {
804
+ logger.debug(`[perf] diff#${reviewId} took ${diffElapsed}ms (threshold: 200ms)`);
805
+ }
784
806
  res.json({
785
807
  diff: diffContent || '',
786
808
  generated_files: generatedFiles,
@@ -805,6 +827,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
805
827
  * Uses a digest of the diff content for accurate change detection
806
828
  */
807
829
  router.get('/api/local/:reviewId/check-stale', async (req, res) => {
830
+ const tEndpoint = Date.now();
808
831
  try {
809
832
  const reviewId = parseInt(req.params.reviewId);
810
833
 
@@ -861,6 +884,10 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
861
884
 
862
885
  // When branch is in scope and HEAD changed, early return (existing behavior)
863
886
  if (includesBranch(scopeStart) && headShaChanged) {
887
+ const staleEarlyElapsed = Date.now() - tEndpoint;
888
+ if (staleEarlyElapsed > 200) {
889
+ logger.debug(`[perf] check-stale#${reviewId} took ${staleEarlyElapsed}ms (threshold: 200ms)`);
890
+ }
864
891
  return res.json({
865
892
  isStale: true,
866
893
  headShaChanged,
@@ -918,6 +945,10 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
918
945
 
919
946
  const isStale = storedDiffData.digest !== currentDigest;
920
947
 
948
+ const staleElapsed = Date.now() - tEndpoint;
949
+ if (staleElapsed > 200) {
950
+ logger.debug(`[perf] check-stale#${reviewId} took ${staleElapsed}ms (threshold: 200ms)`);
951
+ }
921
952
  res.json({
922
953
  isStale,
923
954
  storedDigest: storedDiffData.digest,
@@ -1377,12 +1408,10 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
1377
1408
 
1378
1409
  // Recompute branchAvailable so the frontend can update the scope selector
1379
1410
  // (e.g. after a commit creates the first branch-ahead commit).
1380
- const config = req.app.get('config') || {};
1411
+ // Lazy require to ensure testability via vi.spyOn on the module exports.
1381
1412
  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
- );
1413
+ try { branchName = await require('../local-review').getCurrentBranch(localPath); } catch (_) { branchName = review.local_head_branch || null; }
1414
+ const branchAvailable = isBranchAvailable(branchName, scopeStart, localPath);
1386
1415
 
1387
1416
  // Non-branch HEAD change: skip diff computation entirely — the old diff is
1388
1417
  // preserved until the user decides (via resolve-head-change) what to do.
@@ -1491,8 +1520,8 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1491
1520
 
1492
1521
  // Persist SHA and branch together in a single write so SQLite only
1493
1522
  // 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');
1523
+ await reviewRepo.updateReview(reviewId, { local_head_sha: newHeadSha, local_head_branch: headBranch, local_base_branch: null });
1524
+ logger.log('API', `Updated HEAD SHA and branch on session ${reviewId} (cleared cached base branch)`, 'cyan');
1496
1525
 
1497
1526
  // Recompute and persist diff
1498
1527
  const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
@@ -1506,10 +1535,7 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1506
1535
 
1507
1536
  // Recompute branchAvailable — the commit may have created the first
1508
1537
  // 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
- );
1538
+ const branchAvailable = isBranchAvailable(headBranch, scopeStart, localPath);
1513
1539
 
1514
1540
  return res.json({ success: true, action: 'updated', branchAvailable });
1515
1541
  }
@@ -1593,20 +1619,26 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
1593
1619
  let baseBranch = requestBaseBranch || null;
1594
1620
  let currentBranch = null;
1595
1621
  if (includesBranch(scopeStart)) {
1596
- currentBranch = await getCurrentBranch(localPath);
1622
+ currentBranch = await require('../local-review').getCurrentBranch(localPath);
1597
1623
  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' });
1624
+ // Use cached base branch from background detection if available
1625
+ if (review.local_base_branch && review.local_head_branch === currentBranch) {
1626
+ baseBranch = review.local_base_branch;
1627
+ logger.debug(`[perf] set-scope#${reviewId} using cached base branch: ${baseBranch}`);
1628
+ } else {
1629
+ const { detectBaseBranch } = require('../git/base-branch');
1630
+ const config = req.app.get('config') || {};
1631
+ const token = getGitHubToken(config);
1632
+ const detection = await detectBaseBranch(localPath, currentBranch, {
1633
+ repository: review.repository,
1634
+ enableGraphite: config.enable_graphite === true,
1635
+ _deps: token ? { getGitHubToken: () => token } : undefined
1636
+ });
1637
+ if (!detection) {
1638
+ return res.status(400).json({ error: 'Could not detect base branch' });
1639
+ }
1640
+ baseBranch = detection.baseBranch;
1608
1641
  }
1609
- baseBranch = detection.baseBranch;
1610
1642
  }
1611
1643
 
1612
1644
  // Validate branch name to prevent shell injection