@in-the-loop-labs/pair-review 3.5.2 → 3.7.0

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 (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
@@ -24,8 +24,10 @@ const { broadcastReviewEvent } = require('../events/review-events');
24
24
  const { fireHooks, hasHooks } = require('../hooks/hook-runner');
25
25
  const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
26
26
  const { mergeInstructions } = require('../utils/instructions');
27
- const { getGitHubToken, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
28
- const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = require('../local-review');
27
+ const { getGitHubToken, resolveLoadSkills, buildCouncilProviderOverrides, getSummaryEnabled, getTourEnabled } = require('../config');
28
+ const { backgroundQueue } = require('../ai/background-queue');
29
+ const localReview = require('../local-review');
30
+ const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = localReview;
29
31
  const { STOPS, isValidScope, normalizeScope, reviewScope, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
30
32
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
31
33
  const { getShaAbbrevLength } = require('../git/sha-abbrev');
@@ -36,6 +38,12 @@ const { getDefaultBranch, tryGraphiteState } = require('../git/base-branch');
36
38
  const { CommentRepository } = require('../database');
37
39
  const { runExecutableAnalysis, getChangedFiles } = require('./executable-analysis');
38
40
  const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
41
+ const reviewsRouter = require('./reviews');
42
+ const summaryGenerator = require('../ai/summary-generator');
43
+ const tourGenerator = require('../ai/tour-generator');
44
+ const { parseUnifiedDiffPatches } = require('../utils/diff-file-list');
45
+ const { parseHunks } = require('../utils/diff-hunks');
46
+ const { hashHunk } = require('../ai/hunk-hashing');
39
47
  const {
40
48
  activeAnalyses,
41
49
  localReviewDiffs,
@@ -489,10 +497,13 @@ router.post('/api/local/start', async (req, res) => {
489
497
  const digest = await computeScopedDigest(repoPath, scopeStart, scopeEnd);
490
498
 
491
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;
492
502
  const branchInfo = await detectAndBuildBranchInfo(repoPath, branch, {
493
503
  repository,
494
504
  diff,
495
- githubToken: getGitHubToken(config),
505
+ githubToken: branchBinding?.token || getGitHubToken(config),
506
+ hostBinding: branchBinding,
496
507
  enableGraphite: config.enable_graphite === true
497
508
  });
498
509
 
@@ -519,6 +530,30 @@ router.post('/api/local/start', async (req, res) => {
519
530
  }
520
531
  });
521
532
 
533
+ (async () => {
534
+ await summaryGenerator.kickOffSummaryJob({
535
+ db,
536
+ config,
537
+ reviewId: sessionId,
538
+ diffText: diff,
539
+ worktreePath: repoPath,
540
+ reviewContext: { prTitle: branch },
541
+ trigger: 'auto'
542
+ });
543
+ })().catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
544
+
545
+ (async () => {
546
+ await tourGenerator.kickOffTourJob({
547
+ db,
548
+ config,
549
+ reviewId: sessionId,
550
+ diffText: diff,
551
+ worktreePath: repoPath,
552
+ reviewContext: { prTitle: branch },
553
+ trigger: 'auto'
554
+ });
555
+ })().catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
556
+
522
557
  } catch (error) {
523
558
  logger.error(`Error starting local review: ${error.message}`);
524
559
  res.status(500).json({
@@ -684,13 +719,18 @@ router.get('/api/local/:reviewId', async (req, res) => {
684
719
  && branchName && branchName !== 'HEAD' && branchName !== 'unknown'
685
720
  && repositoryName && repositoryName.includes('/')) {
686
721
  const bgConfig = req.app.get('config') || {};
687
- const bgToken = getGitHubToken(bgConfig);
722
+ const { resolveHostBinding: _resolveHostBinding } = require('../config');
723
+ const bgBinding = _resolveHostBinding(repositoryName, bgConfig);
724
+ const bgToken = bgBinding.token;
688
725
  const bgT0 = Date.now();
689
726
  const { detectBaseBranch } = require('../git/base-branch');
690
727
  detectBaseBranch(review.local_path, branchName, {
691
728
  repository: repositoryName,
692
729
  enableGraphite: bgConfig.enable_graphite === true,
693
- _deps: bgToken ? { getGitHubToken: () => bgToken } : undefined
730
+ _deps: bgToken ? {
731
+ getGitHubToken: () => bgToken,
732
+ getHostBinding: () => bgBinding
733
+ } : undefined
694
734
  }).then(detection => {
695
735
  if (detection && detection.baseBranch) {
696
736
  return reviewRepo.updateReview(reviewId, { local_base_branch: detection.baseBranch });
@@ -719,6 +759,48 @@ router.get('/api/local/:reviewId', async (req, res) => {
719
759
  }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
720
760
  }
721
761
 
762
+ // Background: re-trigger hunk summary + tour generation on review load.
763
+ // Self-invoked so any rejection here cannot reach the outer try/catch
764
+ // and call res.status(500) on an already-flushed response.
765
+ (async () => {
766
+ let bgDiffText = getLocalReviewDiff(reviewId)?.diff;
767
+ if (!bgDiffText) {
768
+ const persistedDiff = await reviewRepo.getLocalDiff(reviewId);
769
+ bgDiffText = persistedDiff?.diff;
770
+ }
771
+ if (!bgDiffText) {
772
+ logger.debug(`Skipping background AI kickoff for review ${reviewId}: no diff available`);
773
+ return;
774
+ }
775
+ const reviewContext = { prTitle: review.name || branchName };
776
+ const results = await Promise.allSettled([
777
+ summaryGenerator.kickOffSummaryJob({
778
+ db,
779
+ config: localConfig,
780
+ reviewId,
781
+ diffText: bgDiffText,
782
+ worktreePath: review.local_path,
783
+ reviewContext,
784
+ trigger: 'auto'
785
+ }),
786
+ tourGenerator.kickOffTourJob({
787
+ db,
788
+ config: localConfig,
789
+ reviewId,
790
+ diffText: bgDiffText,
791
+ worktreePath: review.local_path,
792
+ reviewContext,
793
+ trigger: 'auto'
794
+ })
795
+ ]);
796
+ const labels = ['Hunk summary', 'Tour'];
797
+ results.forEach((r, i) => {
798
+ if (r.status === 'rejected') {
799
+ logger.warn(`${labels[i]} kickoff failed for review ${reviewId}: ${r.reason?.message || r.reason}`);
800
+ }
801
+ });
802
+ })().catch((err) => logger.warn(`Background AI kickoff failed for review ${reviewId}: ${err.message}`));
803
+
722
804
  } catch (error) {
723
805
  logger.error('Error fetching local review:', error.stack || error.message);
724
806
  res.status(500).json({
@@ -805,7 +887,11 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
805
887
 
806
888
  if ((hideWhitespace || baseBranchOverride) && review.local_path) {
807
889
  try {
808
- const wsResult = await generateScopedDiff(review.local_path, scopeStart, scopeEnd, baseBranch, { hideWhitespace });
890
+ // Call via the module namespace so tests can stub `generateScopedDiff`
891
+ // with `vi.spyOn(localReview, 'generateScopedDiff')`. The destructured
892
+ // top-level binding is captured at require time and would not honor a
893
+ // spy.
894
+ const wsResult = await localReview.generateScopedDiff(review.local_path, scopeStart, scopeEnd, baseBranch, { hideWhitespace });
809
895
  diffData = { diff: wsResult.diff, stats: wsResult.stats };
810
896
  } catch (wsError) {
811
897
  logger.warn(`Could not generate diff for review #${reviewId}: ${wsError.message}`);
@@ -854,6 +940,52 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
854
940
  }
855
941
  }
856
942
 
943
+ // Compute per-file hunk hashes for the hunk-summary feature.
944
+ //
945
+ // The frontend stamps these hashes onto rendered hunks BY INDEX
946
+ // (`hunkHashes[blockIndex]`), so the array MUST be aligned to the
947
+ // diff that was actually returned to the client. Two cases:
948
+ //
949
+ // 1. `?w=1`: `git diff -w` only DROPS whitespace-only hunks; it
950
+ // never rewrites kept hunks. The frontend renderPatch length
951
+ // guard catches the drop case (mismatch between canonical hash
952
+ // count and rendered block count) and bails. So for kept hunks
953
+ // the canonical hash still identifies the right rendered hunk
954
+ // AND matches the persisted summary key — fall back to the
955
+ // canonical diff here for hash computation.
956
+ //
957
+ // 2. `?base=<branch>`: regen produces a DIFFERENT diff against a
958
+ // different base. Hunk counts may match by coincidence, but the
959
+ // content can differ. Hashing the canonical diff would mount a
960
+ // summary onto an override hunk whose code it doesn't describe
961
+ // — silent and wrong. Hash the override diff instead so:
962
+ // - identical-content hunks (hash equals canonical) still
963
+ // match a persisted summary and mount correctly;
964
+ // - divergent-content hunks miss (hash mismatch) and stay
965
+ // unmounted — visibly missing rather than silently wrong.
966
+ let canonicalDiff = diffContent;
967
+ if (hideWhitespace && !baseBranchOverride) {
968
+ const cached = getLocalReviewDiff(reviewId);
969
+ if (cached?.diff) {
970
+ canonicalDiff = cached.diff;
971
+ } else {
972
+ const persisted = await reviewRepo.getLocalDiff(reviewId);
973
+ if (persisted?.diff) canonicalDiff = persisted.diff;
974
+ }
975
+ }
976
+ const hunkHashesByFile = {};
977
+ if (canonicalDiff) {
978
+ const filePatchMap = parseUnifiedDiffPatches(canonicalDiff);
979
+ for (const [filePath, filePatch] of filePatchMap.entries()) {
980
+ const hunks = parseHunks(filePatch);
981
+ if (hunks.length > 0) {
982
+ hunkHashesByFile[filePath] = hunks.map((h) =>
983
+ hashHunk(filePath, `${h.header}\n${h.lines.join('\n')}`)
984
+ );
985
+ }
986
+ }
987
+ }
988
+
857
989
  const diffElapsed = Date.now() - tEndpoint;
858
990
  if (diffElapsed > 200) {
859
991
  logger.debug(`[perf] diff#${reviewId} took ${diffElapsed}ms (threshold: 200ms)`);
@@ -861,6 +993,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
861
993
  res.json({
862
994
  diff: diffContent || '',
863
995
  generated_files: generatedFiles,
996
+ hunk_hashes_by_file: hunkHashesByFile,
864
997
  stats: {
865
998
  trackedChanges: stats?.trackedChanges || 0,
866
999
  untrackedFiles: stats?.untrackedFiles || 0,
@@ -1325,7 +1458,9 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1325
1458
 
1326
1459
  const progressCallback = createProgressCallback(analysisId);
1327
1460
 
1328
- // 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.
1329
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 })
1330
1465
  .then(async result => {
1331
1466
  logger.section('Local Analysis Results');
@@ -1605,6 +1740,25 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
1605
1740
  }
1606
1741
  });
1607
1742
 
1743
+ // Re-kick the summary and tour jobs against the fresh diff. Each kickoff
1744
+ // is dedup'd by digest (summaries) or hash (tour); a no-op when the
1745
+ // canonical diff is unchanged (e.g. user clicked refresh but nothing
1746
+ // upstream changed). When the digest IS new, the kickoffs auto-cancel
1747
+ // the stale in-flight job before enqueueing the fresh one — see
1748
+ // kickOffSummaryJob / kickOffTourJob.
1749
+ const config = req.app.get('config') || {};
1750
+ const reviewContext = { prTitle: branchName || review.local_head_branch || undefined };
1751
+ (async () => {
1752
+ await summaryGenerator.kickOffSummaryJob({
1753
+ db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
1754
+ });
1755
+ })().catch((err) => logger.warn(`Hunk summary job failed for review ${reviewId}: ${err.message}`));
1756
+ (async () => {
1757
+ await tourGenerator.kickOffTourJob({
1758
+ db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
1759
+ });
1760
+ })().catch((err) => logger.warn(`Tour job failed for review ${reviewId}: ${err.message}`));
1761
+
1608
1762
  } catch (error) {
1609
1763
  logger.error('Error refreshing local diff:', error);
1610
1764
  res.status(500).json({
@@ -1683,7 +1837,29 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1683
1837
  // branch-ahead commit, making the Branch scope stop selectable.
1684
1838
  const branchAvailable = isBranchAvailable(headBranch, scopeStart, localPath);
1685
1839
 
1686
- return res.json({ success: true, action: 'updated', branchAvailable });
1840
+ res.json({ success: true, action: 'updated', branchAvailable });
1841
+
1842
+ // Re-kick the summary and tour jobs against the freshly-recomputed diff.
1843
+ // The frontend's _resolveHeadChange path applies the refreshed diff in
1844
+ // place via GET /diff (which is read-only and does NOT enqueue), so
1845
+ // without an explicit kickoff here the in-flight stale job from the
1846
+ // previous HEAD would keep burning tokens against a now-stale diff.
1847
+ // Each kickoff is dedup'd by digest/hash; a no-op when the recomputed
1848
+ // diff matches. When the digest IS new, the kickoffs auto-cancel the
1849
+ // stale in-flight job before enqueueing the fresh one.
1850
+ const config = req.app.get('config') || {};
1851
+ const reviewContext = { prTitle: headBranch || review.local_head_branch || undefined };
1852
+ (async () => {
1853
+ await summaryGenerator.kickOffSummaryJob({
1854
+ db, config, reviewId, diffText: scopedResult.diff, worktreePath: localPath, reviewContext, trigger: 'auto'
1855
+ });
1856
+ })().catch((err) => logger.warn(`Hunk summary job failed for review ${reviewId}: ${err.message}`));
1857
+ (async () => {
1858
+ await tourGenerator.kickOffTourJob({
1859
+ db, config, reviewId, diffText: scopedResult.diff, worktreePath: localPath, reviewContext, trigger: 'auto'
1860
+ });
1861
+ })().catch((err) => logger.warn(`Tour job failed for review ${reviewId}: ${err.message}`));
1862
+ return;
1687
1863
  }
1688
1864
 
1689
1865
  // action === 'new-session'
@@ -1774,11 +1950,16 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
1774
1950
  } else {
1775
1951
  const { detectBaseBranch } = require('../git/base-branch');
1776
1952
  const config = req.app.get('config') || {};
1777
- const token = getGitHubToken(config);
1953
+ const { resolveHostBinding: _resolveHostBinding } = require('../config');
1954
+ const localBinding = _resolveHostBinding(review.repository, config);
1955
+ const token = localBinding.token;
1778
1956
  const detection = await detectBaseBranch(localPath, currentBranch, {
1779
1957
  repository: review.repository,
1780
1958
  enableGraphite: config.enable_graphite === true,
1781
- _deps: token ? { getGitHubToken: () => token } : undefined
1959
+ _deps: token ? {
1960
+ getGitHubToken: () => token,
1961
+ getHostBinding: () => localBinding
1962
+ } : undefined
1782
1963
  });
1783
1964
  if (!detection) {
1784
1965
  return res.status(400).json({ error: 'Could not detect base branch' });
@@ -1839,6 +2020,23 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
1839
2020
  }
1840
2021
  });
1841
2022
 
2023
+ // Re-kick the summary and tour jobs against the freshly-scoped diff.
2024
+ // Each kickoff is dedup'd by diff digest/hash; when the scope change
2025
+ // actually produces a different diff, the kickoffs auto-cancel the
2026
+ // stale in-flight job before enqueueing the fresh one.
2027
+ const config = req.app.get('config') || {};
2028
+ const reviewContext = { prTitle: currentBranch || review.local_head_branch || undefined };
2029
+ (async () => {
2030
+ await summaryGenerator.kickOffSummaryJob({
2031
+ db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
2032
+ });
2033
+ })().catch((err) => logger.warn(`Hunk summary job failed for review ${reviewId}: ${err.message}`));
2034
+ (async () => {
2035
+ await tourGenerator.kickOffTourJob({
2036
+ db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
2037
+ });
2038
+ })().catch((err) => logger.warn(`Tour job failed for review ${reviewId}: ${err.message}`));
2039
+
1842
2040
  } catch (error) {
1843
2041
  logger.error(`Error setting scope: ${error.message}`);
1844
2042
  res.status(500).json({ error: 'Failed to set scope: ' + error.message });
@@ -2074,11 +2272,19 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2074
2272
  baseBranch: review.local_base_branch || null,
2075
2273
  });
2076
2274
 
2077
- // 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;
2078
2282
  try {
2079
2283
  const diffResult = await generateScopedDiff(localPath, councilScopeStart, councilScopeEnd, review.local_base_branch);
2080
- const digest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
2081
- 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 });
2082
2288
  } catch (diffError) {
2083
2289
  logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
2084
2290
  }
@@ -2086,6 +2292,16 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2086
2292
  // Resolve instructions
2087
2293
  const repoSettingsRepo = new RepoSettingsRepository(db);
2088
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
+ }
2089
2305
  const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
2090
2306
  const repoInstructions = repoSettings?.default_instructions || null;
2091
2307
  const requestInstructions = rawInstructions?.trim() || null;
@@ -2103,6 +2319,8 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2103
2319
  const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
2104
2320
  buildCouncilProviderOverrides(localCouncilConfig, review.repository, repoSettings);
2105
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.
2106
2324
  const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
2107
2325
  db,
2108
2326
  {
@@ -2144,4 +2362,183 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2144
2362
  }
2145
2363
  });
2146
2364
 
2365
+ /**
2366
+ * POST /api/local/:reviewId/jobs/:jobKey/start
2367
+ *
2368
+ * Manually trigger a summary or tour generation job for this local review.
2369
+ * Used by the frontend when `auto_generate` is off and the user clicks the
2370
+ * toolbar button.
2371
+ *
2372
+ * Mirrors the server-side kickoff that runs on local review load, but passes
2373
+ * `trigger: 'manual'` so it bypasses the `auto_generate` gate (the `enabled`
2374
+ * gate still applies — disabled features return 409).
2375
+ *
2376
+ * Request:
2377
+ * - `jobKey` path param: `summary` or `tour`
2378
+ *
2379
+ * Responses:
2380
+ * - 200 `{ started: true, alreadyRunning: false }` — enqueued
2381
+ * - 200 `{ started: false, alreadyRunning: true }` — feature on but a job
2382
+ * is already in flight
2383
+ * (idempotent no-op)
2384
+ * - 200 `{ started: false, reason: 'no-diff' }` — diff is empty
2385
+ * - 400 `{ error: 'Invalid jobKey' }` — unknown jobKey
2386
+ * - 404 `{ error: '...' }` — review not found
2387
+ * - 409 `{ error: '... disabled' }` — feature disabled in config
2388
+ */
2389
+ const LOCAL_MANUAL_START_JOB_KEYS = new Set(['summary', 'tour']);
2390
+
2391
+ router.post('/api/local/:reviewId/jobs/:jobKey/start', async (req, res) => {
2392
+ try {
2393
+ const reviewId = parseInt(req.params.reviewId, 10);
2394
+ if (!Number.isInteger(reviewId) || reviewId <= 0) {
2395
+ return res.status(400).json({ error: 'Invalid review ID' });
2396
+ }
2397
+ const { jobKey } = req.params;
2398
+ if (!LOCAL_MANUAL_START_JOB_KEYS.has(jobKey)) {
2399
+ return res.status(400).json({ error: `Invalid jobKey "${jobKey}" (expected "summary" or "tour")` });
2400
+ }
2401
+
2402
+ const db = req.app.get('db');
2403
+ const config = req.app.get('config') || {};
2404
+
2405
+ if (jobKey === 'summary' && !getSummaryEnabled(config)) {
2406
+ return res.status(409).json({ error: 'Summaries feature is disabled in config' });
2407
+ }
2408
+ if (jobKey === 'tour' && !getTourEnabled(config)) {
2409
+ return res.status(409).json({ error: 'Tours feature is disabled in config' });
2410
+ }
2411
+
2412
+ const reviewRepo = new ReviewRepository(db);
2413
+ const review = await reviewRepo.getLocalReviewById(reviewId);
2414
+ if (!review) {
2415
+ return res.status(404).json({ error: `Local review #${reviewId} not found` });
2416
+ }
2417
+
2418
+ const worktreePath = review.local_path || null;
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
+
2477
+ if (!diffText || !worktreePath) {
2478
+ return res.json({ started: false, reason: 'no-diff' });
2479
+ }
2480
+
2481
+ const activeJobType = typeof backgroundQueue.findActiveJobType === 'function'
2482
+ ? backgroundQueue.findActiveJobType(reviewId, jobKey === 'summary' ? 'summaries' : 'tour')
2483
+ : null;
2484
+ if (activeJobType) {
2485
+ return res.json({ started: false, alreadyRunning: true });
2486
+ }
2487
+
2488
+ const reviewContext = {
2489
+ prTitle: review.name || review.local_head_branch || undefined
2490
+ };
2491
+
2492
+ if (jobKey === 'summary') {
2493
+ Promise.resolve(summaryGenerator.kickOffSummaryJob({
2494
+ db, config, reviewId, diffText, worktreePath, reviewContext, trigger: 'manual'
2495
+ })).catch((err) => logger.warn(`Manual hunk summary kickoff failed for review ${reviewId}: ${err.message}`));
2496
+ } else {
2497
+ Promise.resolve(tourGenerator.kickOffTourJob({
2498
+ db, config, reviewId, diffText, worktreePath, reviewContext, trigger: 'manual'
2499
+ })).catch((err) => logger.warn(`Manual tour kickoff failed for review ${reviewId}: ${err.message}`));
2500
+ }
2501
+
2502
+ return res.json({ started: true, alreadyRunning: false });
2503
+ } catch (error) {
2504
+ logger.error(`Error starting manual job for local review: ${error.message}`);
2505
+ res.status(500).json({ error: 'Failed to start job: ' + error.message });
2506
+ }
2507
+ });
2508
+
2509
+ /**
2510
+ * POST /api/local/:reviewId/jobs/:jobKey/cancel
2511
+ *
2512
+ * Local-mode wrapper around the shared cancel handler in reviews.js.
2513
+ * The unified `/api/reviews/:reviewId/jobs/:jobKey/cancel` already works
2514
+ * for local reviews (both modes share the `reviews` table), but exposing
2515
+ * it under both prefixes lets the frontend pick whichever helper matches
2516
+ * its current mode without a special case. See `handleJobCancel` in
2517
+ * `src/routes/reviews.js` for the canonical implementation.
2518
+ */
2519
+ router.post('/api/local/:reviewId/jobs/:jobKey/cancel', async (req, res) => {
2520
+ try {
2521
+ const reviewId = parseInt(req.params.reviewId, 10);
2522
+ if (isNaN(reviewId) || reviewId <= 0) {
2523
+ return res.status(400).json({ error: 'Invalid review ID' });
2524
+ }
2525
+ const db = req.app.get('db');
2526
+ // Same shape that validateReviewId attaches — we re-derive here because
2527
+ // local routes don't pass through that middleware by convention.
2528
+ const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ?', [reviewId]);
2529
+ if (!review) {
2530
+ return res.status(404).json({ error: `Review #${reviewId} not found` });
2531
+ }
2532
+ req.reviewId = reviewId;
2533
+ req.review = review;
2534
+ // await (not return) so any rejection from the delegated handler is
2535
+ // caught by the outer try/catch — Express 4 does not forward rejected
2536
+ // promises from async route handlers.
2537
+ await reviewsRouter.handleJobCancel(req, res);
2538
+ } catch (error) {
2539
+ logger.error(`Error cancelling background job for local review: ${error.message}`);
2540
+ res.status(500).json({ error: 'Failed to cancel background job' });
2541
+ }
2542
+ });
2543
+
2147
2544
  module.exports = router;
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) {