@in-the-loop-labs/pair-review 3.6.0 → 3.7.1

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.
Files changed (67) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +17 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +89 -44
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ChatPanel.js +11 -1
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/SuggestionNavigator.js +55 -10
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +58 -8
  18. package/public/js/modules/suggestion-manager.js +25 -1
  19. package/public/js/modules/tour-renderer.js +45 -5
  20. package/public/js/pr.js +703 -171
  21. package/public/js/repo-links.js +328 -0
  22. package/public/js/utils/provider-model.js +88 -0
  23. package/public/js/utils/scroll-into-view.js +164 -0
  24. package/public/js/utils/storage-keys.js +50 -0
  25. package/public/local.html +10 -0
  26. package/public/pr.html +10 -0
  27. package/public/repo-settings.html +1 -0
  28. package/public/setup.html +2 -0
  29. package/src/ai/analyzer.js +125 -18
  30. package/src/ai/claude-provider.js +31 -3
  31. package/src/config.js +664 -10
  32. package/src/external/github-adapter.js +114 -25
  33. package/src/git/base-branch.js +11 -4
  34. package/src/github/client.js +482 -588
  35. package/src/github/errors.js +55 -0
  36. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  37. package/src/github/impl/graphql/pending-review.js +153 -0
  38. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  39. package/src/github/impl/graphql/stack-walker.js +210 -0
  40. package/src/github/impl/host/pending-review-comments.js +338 -0
  41. package/src/github/impl/rest/pending-review.js +251 -0
  42. package/src/github/impl/rest/review-lifecycle.js +226 -0
  43. package/src/github/impl/rest/stack-walker.js +309 -0
  44. package/src/github/operations/pending-review-comments.js +79 -0
  45. package/src/github/operations/pending-review.js +89 -0
  46. package/src/github/operations/review-lifecycle.js +126 -0
  47. package/src/github/operations/stack-walker.js +87 -0
  48. package/src/github/parser.js +230 -4
  49. package/src/github/stack-walker.js +14 -189
  50. package/src/links/repo-links.js +230 -0
  51. package/src/local-review.js +13 -4
  52. package/src/main.js +136 -32
  53. package/src/routes/analyses.js +30 -7
  54. package/src/routes/bulk-analysis-configs.js +295 -0
  55. package/src/routes/config.js +102 -2
  56. package/src/routes/external-comments.js +20 -10
  57. package/src/routes/github-collections.js +3 -1
  58. package/src/routes/local.js +101 -11
  59. package/src/routes/mcp.js +47 -4
  60. package/src/routes/pr.js +298 -68
  61. package/src/routes/setup.js +8 -3
  62. package/src/routes/stack-analysis.js +33 -9
  63. package/src/routes/worktrees.js +3 -2
  64. package/src/server.js +2 -0
  65. package/src/setup/pr-setup.js +37 -11
  66. package/src/setup/stack-setup.js +13 -3
  67. package/src/single-port.js +6 -3
@@ -497,10 +497,13 @@ router.post('/api/local/start', async (req, res) => {
497
497
  const digest = await computeScopedDigest(repoPath, scopeStart, scopeEnd);
498
498
 
499
499
  // Branch detection: when no uncommitted changes, check if branch has commits ahead
500
+ const { resolveHostBinding: _resolveHostBindingForBranch } = require('../config');
501
+ const branchBinding = repository ? _resolveHostBindingForBranch(repository, config) : null;
500
502
  const branchInfo = await detectAndBuildBranchInfo(repoPath, branch, {
501
503
  repository,
502
504
  diff,
503
- githubToken: getGitHubToken(config),
505
+ githubToken: branchBinding?.token || getGitHubToken(config),
506
+ hostBinding: branchBinding,
504
507
  enableGraphite: config.enable_graphite === true
505
508
  });
506
509
 
@@ -716,13 +719,18 @@ router.get('/api/local/:reviewId', async (req, res) => {
716
719
  && branchName && branchName !== 'HEAD' && branchName !== 'unknown'
717
720
  && repositoryName && repositoryName.includes('/')) {
718
721
  const bgConfig = req.app.get('config') || {};
719
- const bgToken = getGitHubToken(bgConfig);
722
+ const { resolveHostBinding: _resolveHostBinding } = require('../config');
723
+ const bgBinding = _resolveHostBinding(repositoryName, bgConfig);
724
+ const bgToken = bgBinding.token;
720
725
  const bgT0 = Date.now();
721
726
  const { detectBaseBranch } = require('../git/base-branch');
722
727
  detectBaseBranch(review.local_path, branchName, {
723
728
  repository: repositoryName,
724
729
  enableGraphite: bgConfig.enable_graphite === true,
725
- _deps: bgToken ? { getGitHubToken: () => bgToken } : undefined
730
+ _deps: bgToken ? {
731
+ getGitHubToken: () => bgToken,
732
+ getHostBinding: () => bgBinding
733
+ } : undefined
726
734
  }).then(detection => {
727
735
  if (detection && detection.baseBranch) {
728
736
  return reviewRepo.updateReview(reviewId, { local_base_branch: detection.baseBranch });
@@ -1450,7 +1458,9 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1450
1458
 
1451
1459
  const progressCallback = createProgressCallback(analysisId);
1452
1460
 
1453
- // Start analysis asynchronously (skipRunCreation since we created the record above; also passes changedFiles for local mode path validation, tier for prompt selection, and skipLevel3 flag)
1461
+ // Start analysis asynchronously (skipRunCreation since we created the record above; also passes changedFiles for local mode path validation, tier for prompt selection, and skipLevel3 flag).
1462
+ // Local mode has no associated GitHub PR, so githubClient is intentionally omitted —
1463
+ // the analyzer drops the GitHub dedup section when no client is supplied.
1454
1464
  analyzer.analyzeLevel1(reviewId, localPath, localMetadata, progressCallback, { globalInstructions, repoInstructions, requestInstructions }, changedFiles, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig, excludePrevious, serverPort: req.socket.localPort })
1455
1465
  .then(async result => {
1456
1466
  logger.section('Local Analysis Results');
@@ -1940,11 +1950,16 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
1940
1950
  } else {
1941
1951
  const { detectBaseBranch } = require('../git/base-branch');
1942
1952
  const config = req.app.get('config') || {};
1943
- const token = getGitHubToken(config);
1953
+ const { resolveHostBinding: _resolveHostBinding } = require('../config');
1954
+ const localBinding = _resolveHostBinding(review.repository, config);
1955
+ const token = localBinding.token;
1944
1956
  const detection = await detectBaseBranch(localPath, currentBranch, {
1945
1957
  repository: review.repository,
1946
1958
  enableGraphite: config.enable_graphite === true,
1947
- _deps: token ? { getGitHubToken: () => token } : undefined
1959
+ _deps: token ? {
1960
+ getGitHubToken: () => token,
1961
+ getHostBinding: () => localBinding
1962
+ } : undefined
1948
1963
  });
1949
1964
  if (!detection) {
1950
1965
  return res.status(400).json({ error: 'Could not detect base branch' });
@@ -2257,11 +2272,19 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2257
2272
  baseBranch: review.local_base_branch || null,
2258
2273
  });
2259
2274
 
2260
- // Generate and cache diff
2275
+ // Generate and cache diff. Hoist the result out of the try so we can also
2276
+ // persist it to `local_diffs` below (after reviewRepo is constructed) — the
2277
+ // council path previously cached the diff in-memory only, which left the
2278
+ // manual tour/summary buttons reporting a false "no-diff" after a restart.
2279
+ let councilDiff = null;
2280
+ let councilStats = null;
2281
+ let councilDigest = null;
2261
2282
  try {
2262
2283
  const diffResult = await generateScopedDiff(localPath, councilScopeStart, councilScopeEnd, review.local_base_branch);
2263
- const digest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
2264
- setLocalReviewDiff(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
2284
+ councilDigest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
2285
+ councilDiff = diffResult.diff;
2286
+ councilStats = diffResult.stats;
2287
+ setLocalReviewDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
2265
2288
  } catch (diffError) {
2266
2289
  logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
2267
2290
  }
@@ -2269,6 +2292,16 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2269
2292
  // Resolve instructions
2270
2293
  const repoSettingsRepo = new RepoSettingsRepository(db);
2271
2294
  const reviewRepo = new ReviewRepository(db);
2295
+
2296
+ // Durably persist the diff so it survives a restart and the manual
2297
+ // tour/summary buttons can find it (parity with the analysis-push path).
2298
+ if (councilDiff) {
2299
+ try {
2300
+ await reviewRepo.saveLocalDiff(reviewId, { diff: councilDiff, stats: councilStats, digest: councilDigest });
2301
+ } catch (saveError) {
2302
+ logger.warn(`Could not persist diff for local council review ${reviewId}: ${saveError.message}`);
2303
+ }
2304
+ }
2272
2305
  const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
2273
2306
  const repoInstructions = repoSettings?.default_instructions || null;
2274
2307
  const requestInstructions = rawInstructions?.trim() || null;
@@ -2286,6 +2319,8 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2286
2319
  const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
2287
2320
  buildCouncilProviderOverrides(localCouncilConfig, review.repository, repoSettings);
2288
2321
 
2322
+ // Local mode has no associated GitHub PR, so we do not pass a githubClient.
2323
+ // The analyzer drops the GitHub dedup section when no client is supplied.
2289
2324
  const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
2290
2325
  db,
2291
2326
  {
@@ -2380,10 +2415,65 @@ router.post('/api/local/:reviewId/jobs/:jobKey/start', async (req, res) => {
2380
2415
  return res.status(404).json({ error: `Local review #${reviewId} not found` });
2381
2416
  }
2382
2417
 
2383
- const localDiff = await reviewRepo.getLocalDiff(reviewId);
2384
- const diffText = localDiff ? (localDiff.diff || '') : '';
2385
2418
  const worktreePath = review.local_path || null;
2386
2419
 
2420
+ // Resolve the diff through the same chain the rest of this file uses, rather
2421
+ // than a DB-only read. Reviews created via the analysis-push, council, or MCP
2422
+ // paths may have a diff only in the in-memory cache (or nowhere yet), so a
2423
+ // DB-only read would falsely report "no-diff" for a review that clearly has
2424
+ // changes. Order: (1) in-memory cache, (2) persisted `local_diffs` row,
2425
+ // (3) regenerate from the live working tree (scope-aware) and persist.
2426
+ let diffText = getLocalReviewDiff(reviewId)?.diff || '';
2427
+
2428
+ if (!diffText) {
2429
+ const persistedDiff = await reviewRepo.getLocalDiff(reviewId);
2430
+ diffText = persistedDiff?.diff || '';
2431
+ }
2432
+
2433
+ if (!diffText && worktreePath) {
2434
+ // Regenerate from the current working tree and persist (in-memory + DB) so
2435
+ // the next read is fast and durable, and so pre-Fix-B reviews self-heal.
2436
+ // Mirrors the council diff block above: on error, log and leave it empty.
2437
+ try {
2438
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
2439
+ const hasBranch = includesBranch(scopeStart);
2440
+
2441
+ // Snapshot guard: mirror the HEAD invariant enforced by the refresh-diff
2442
+ // handler (see ~line 1702). For a non-branch review, the persisted diff is
2443
+ // pinned to `local_head_sha`. If HEAD has since moved, regenerating here
2444
+ // would silently re-snapshot the CURRENT worktree onto a row that still
2445
+ // claims the OLDER SHA — a data-consistency hole. So we only regenerate
2446
+ // when HEAD still matches; otherwise we leave diffText empty and let the
2447
+ // `{ started: false, reason: 'no-diff' }` response funnel the user through
2448
+ // the established refresh-diff / resolve-head-change flow. Branch-scoped
2449
+ // reviews persist across HEAD changes, so they always regenerate.
2450
+ let headPinned = true;
2451
+ if (!hasBranch && review.local_head_sha) {
2452
+ // Lazy require keeps getHeadSha stubbable via vi.spyOn in tests.
2453
+ const { getHeadSha } = require('../local-review');
2454
+ const currentHeadSha = await getHeadSha(worktreePath);
2455
+ if (currentHeadSha !== review.local_head_sha) {
2456
+ headPinned = false;
2457
+ logger.warn(`Skipping self-heal diff regen for local review ${reviewId} (${jobKey}): HEAD moved on non-branch review (recorded ${review.local_head_sha}, current ${currentHeadSha}) — funneling through resolve-head-change`);
2458
+ }
2459
+ }
2460
+
2461
+ if (headPinned) {
2462
+ const diffResult = await generateScopedDiff(worktreePath, scopeStart, scopeEnd, review.local_base_branch);
2463
+ diffText = diffResult.diff || '';
2464
+ if (diffText) {
2465
+ const digest = await computeScopedDigest(worktreePath, scopeStart, scopeEnd);
2466
+ setLocalReviewDiff(reviewId, { diff: diffText, stats: diffResult.stats, digest });
2467
+ await reviewRepo.saveLocalDiff(reviewId, { diff: diffText, stats: diffResult.stats, digest });
2468
+ }
2469
+ }
2470
+ } catch (regenError) {
2471
+ // A getHeadSha throw (e.g. missing worktree) lands here: leave diffText
2472
+ // empty so the no-diff response fires, matching prior behavior.
2473
+ logger.warn(`Could not regenerate diff for local review ${reviewId} manual ${jobKey} start: ${regenError.message}`);
2474
+ }
2475
+ }
2476
+
2387
2477
  if (!diffText || !worktreePath) {
2388
2478
  return res.json({ started: false, reason: 'no-diff' });
2389
2479
  }
package/src/routes/mcp.js CHANGED
@@ -11,19 +11,22 @@ const { getTierForModel } = require('../ai/provider');
11
11
  const { TIERS, TIER_ALIASES, resolveTier } = require('../ai/prompts/config');
12
12
  const { GitWorktreeManager } = require('../git/worktree');
13
13
  const path = require('path');
14
- const { getCurrentBranch } = require('../local-review');
14
+ const { getCurrentBranch, generateScopedDiff, computeScopedDigest } = require('../local-review');
15
+ const { reviewScope } = require('../local-scope');
15
16
  const { normalizeRepository } = require('../utils/paths');
16
17
  const logger = require('../utils/logger');
17
18
  const { broadcastReviewEvent } = require('../events/review-events');
18
19
  const {
19
20
  activeAnalyses,
20
21
  reviewToAnalysisId,
22
+ localReviewDiffs,
21
23
  determineCompletionInfo,
22
24
  broadcastProgress,
23
25
  createProgressCallback
24
26
  } = require('./shared');
25
27
  const { safeParseJson } = require('../utils/safe-parse-json');
26
- const { resolveLoadSkills } = require('../config');
28
+ const { resolveLoadSkills, resolveHostBinding } = require('../config');
29
+ const { GitHubClient } = require('../github/client');
27
30
 
28
31
  // All valid tier values: canonical tiers + aliases (for Zod enum validation)
29
32
  const ALL_TIER_VALUES = /** @type {[string, ...string[]]} */ ([...TIERS, ...Object.keys(TIER_ALIASES)]);
@@ -557,6 +560,32 @@ function createMCPServer(db, options = {}) {
557
560
 
558
561
  const review = await reviewRepo.getLocalReviewById(reviewId);
559
562
 
563
+ // Persist the diff so the web UI can display it and the manual
564
+ // tour/summary buttons work (including after a restart). The MCP path
565
+ // previously stored the diff only in analysis_runs, leaving no
566
+ // `local_diffs` row.
567
+ //
568
+ // Use the review's recorded scope — not the default-scope wrapper — so
569
+ // re-using a review that was moved to `staged` or `branch` scope does
570
+ // not clobber its durable row with a narrower default-scope patch. Set
571
+ // the in-memory cache alongside the DB row so the cache-first readers
572
+ // in routes/local.js don't keep serving a stale entry. Non-fatal:
573
+ // matches the analysis-push path.
574
+ try {
575
+ const { start: scopeStart, end: scopeEnd } = reviewScope(review);
576
+ const diffResult = await generateScopedDiff(
577
+ localPath,
578
+ scopeStart,
579
+ scopeEnd,
580
+ review.local_base_branch || null
581
+ );
582
+ const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
583
+ localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
584
+ await reviewRepo.saveLocalDiff(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
585
+ } catch (diffError) {
586
+ logger.warn(`Could not generate or persist diff for local review ${reviewId}: ${diffError.message}`);
587
+ }
588
+
560
589
  // Resolve provider and model
561
590
  const repoSettings = repository ? await repoSettingsRepo.getRepoSettings(repository) : null;
562
591
  const provider = process.env.PAIR_REVIEW_PROVIDER || repoSettings?.default_provider || config.default_provider || config.provider || 'claude';
@@ -640,7 +669,9 @@ function createMCPServer(db, options = {}) {
640
669
  headSha: localHeadSha
641
670
  });
642
671
 
643
- // Launch analysis asynchronously (skipRunCreation since we created the record above)
672
+ // Launch analysis asynchronously (skipRunCreation since we created the record above).
673
+ // Local mode has no associated GitHub PR, so githubClient is intentionally
674
+ // omitted — the analyzer drops the GitHub dedup section when no client is supplied.
644
675
  analyzer.analyzeLevel1(reviewId, localPath, localMetadata, progressCallback, { repoInstructions, requestInstructions }, changedFiles, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: args.skipLevel3, excludePrevious: args.excludePrevious, serverPort: (options.port || config.port || 7247) })
645
676
  .then(result => handleAnalysisCompletion(analysisId, runId, result, async (r) => {
646
677
  if (r.summary) {
@@ -762,6 +793,18 @@ function createMCPServer(db, options = {}) {
762
793
  const progressCallback = createProgressCallback(analysisId);
763
794
  const tier = resolveTier(args.tier);
764
795
 
796
+ // Build a GitHubClient so the analyzer can pre-fetch existing PR
797
+ // review comments when args.excludePrevious.github is set. Uses
798
+ // the repo's binding so alt-host repos route to the right host
799
+ // with the right token.
800
+ const prAnalysisBinding = resolveHostBinding(repository, config);
801
+ const prAnalysisGithubClient = prAnalysisBinding.token
802
+ ? new GitHubClient(prAnalysisBinding)
803
+ : undefined;
804
+ if (prAnalysisGithubClient) {
805
+ logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNumber} (mcp)`);
806
+ }
807
+
765
808
  logger.log('MCP', `Starting PR analysis: PR #${prNumber} in ${repository}, runId=${runId}`, 'magenta');
766
809
 
767
810
  // Create DB analysis_runs record just before launching so it's queryable for polling
@@ -779,7 +822,7 @@ function createMCPServer(db, options = {}) {
779
822
  });
780
823
 
781
824
  // Launch analysis asynchronously (skipRunCreation since we created the record above)
782
- analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: args.skipLevel3, excludePrevious: args.excludePrevious, serverPort: (options.port || config.port || 7247) })
825
+ analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: args.skipLevel3, excludePrevious: args.excludePrevious, serverPort: (options.port || config.port || 7247), githubClient: prAnalysisGithubClient })
783
826
  .then(result => handleAnalysisCompletion(analysisId, runId, result, async (r) => {
784
827
  try { await prMetadataRepo.updateLastAiRunId(prMetadata.id, r.runId); } catch (_) { /* ignore */ }
785
828
  if (r.summary) {