@in-the-loop-labs/pair-review 3.5.1 → 3.6.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 (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +603 -6
  5. package/public/index.html +90 -0
  6. package/public/js/components/ChatPanel.js +163 -3
  7. package/public/js/components/KeyboardShortcuts.js +10 -26
  8. package/public/js/components/TourBar.js +248 -0
  9. package/public/js/index.js +298 -25
  10. package/public/js/local.js +6 -0
  11. package/public/js/modules/cancel-background-job.js +183 -0
  12. package/public/js/modules/hunk-summary-renderer.js +116 -0
  13. package/public/js/modules/storage-cleanup.js +16 -0
  14. package/public/js/modules/tour-renderer.js +725 -0
  15. package/public/js/pr.js +1276 -2
  16. package/public/js/utils/modal-detection.js +77 -0
  17. package/public/local.html +17 -0
  18. package/public/pr.html +17 -0
  19. package/src/ai/abort-signal-wiring.js +130 -0
  20. package/src/ai/background-queue.js +290 -0
  21. package/src/ai/claude-cli.js +1 -1
  22. package/src/ai/claude-provider.js +50 -7
  23. package/src/ai/codex-provider.js +28 -5
  24. package/src/ai/copilot-provider.js +22 -3
  25. package/src/ai/cursor-agent-provider.js +22 -6
  26. package/src/ai/executable-provider.js +4 -19
  27. package/src/ai/gemini-provider.js +22 -5
  28. package/src/ai/hunk-hashing.js +161 -0
  29. package/src/ai/index.js +2 -0
  30. package/src/ai/opencode-provider.js +21 -5
  31. package/src/ai/pi-provider.js +21 -5
  32. package/src/ai/prompts/hunk-summary.js +199 -0
  33. package/src/ai/prompts/tour.js +232 -0
  34. package/src/ai/provider.js +21 -1
  35. package/src/ai/summary-generator.js +469 -0
  36. package/src/ai/tour-generator.js +568 -0
  37. package/src/config.js +114 -0
  38. package/src/database.js +282 -1
  39. package/src/local-review.js +189 -169
  40. package/src/routes/config.js +16 -1
  41. package/src/routes/context-files.js +2 -29
  42. package/src/routes/github-collections.js +168 -90
  43. package/src/routes/local.js +311 -4
  44. package/src/routes/middleware/validate-review-id.js +53 -0
  45. package/src/routes/pr.js +259 -4
  46. package/src/routes/reviews.js +145 -29
  47. package/src/utils/diff-hunks.js +65 -0
  48. package/src/utils/json-extractor.js +5 -2
@@ -15,6 +15,8 @@ const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewS
15
15
  const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
16
16
  const { startServer } = require('./server');
17
17
  const { localReviewDiffs } = require('./routes/shared');
18
+ const summaryGenerator = require('./ai/summary-generator');
19
+ const tourGenerator = require('./ai/tour-generator');
18
20
  const { getShaAbbrevLength } = require('./git/sha-abbrev');
19
21
  const { GIT_DIFF_FLAGS, GIT_DIFF_FLAGS_ARRAY } = require('./git/diff-flags');
20
22
  const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({ default: open }) => open(...args));
@@ -690,6 +692,183 @@ async function generateLocalDiff(repoPath, options = {}) {
690
692
  };
691
693
  }
692
694
 
695
+ /**
696
+ * Set up a local review session: resolve git state, persist the diff,
697
+ * and enqueue the background summary job. Caller is responsible for
698
+ * starting the server and opening the browser.
699
+ *
700
+ * @param {Object} params
701
+ * @param {Object} params.db - Database instance
702
+ * @param {Object} params.config - Loaded config
703
+ * @param {string} params.repoPath - Path to the working tree (already validated)
704
+ * @param {Object} [params.flags] - CLI flags / options
705
+ * @returns {Promise<{sessionId: number, repoPath: string, branch: string, repository: string, headSha: string, diff: string, stats: Object, digest: string|null, branchInfo: Object|null, scopeStart: string, scopeEnd: string, baseBranch: string|null}>}
706
+ */
707
+ async function setupLocalReviewSession({ db, config, repoPath, flags = {} }) {
708
+ const headSha = await module.exports.getHeadSha(repoPath);
709
+ console.log(`HEAD SHA: ${headSha}`);
710
+
711
+ const branch = await module.exports.getCurrentBranch(repoPath);
712
+ console.log(`Current branch: ${branch}`);
713
+
714
+ const reviewId = generateLocalReviewId(repoPath, headSha);
715
+ console.log(`Local review ID: ${reviewId}`);
716
+
717
+ const reviewRepo = new ReviewRepository(db);
718
+ const repository = await module.exports.getRepositoryName(repoPath);
719
+ console.log(`Repository: ${repository}`);
720
+
721
+ if (repository.includes('/')) {
722
+ try {
723
+ const mainRepoRoot = await module.exports.findMainGitRoot(repoPath);
724
+ const repoSettingsRepo = new RepoSettingsRepository(db);
725
+ await repoSettingsRepo.setLocalPath(repository, mainRepoRoot);
726
+ console.log(`Registered repository location: ${mainRepoRoot}`);
727
+ } catch (error) {
728
+ console.warn(`Could not register repository location: ${error.message}`);
729
+ }
730
+ }
731
+
732
+ console.log('Checking for existing review session...');
733
+ let existingReview = await reviewRepo.getLocalReview(repoPath, headSha, branch);
734
+
735
+ if (!existingReview) {
736
+ const legacy = await reviewRepo.getLocalReviewByPathAndSha(repoPath, headSha);
737
+ if (legacy && legacy.local_head_branch === null) {
738
+ existingReview = legacy;
739
+ }
740
+ }
741
+
742
+ if (!existingReview) {
743
+ const branchSession = await reviewRepo.getLocalBranchReview(repoPath, branch);
744
+ if (branchSession) {
745
+ existingReview = branchSession;
746
+ }
747
+ }
748
+
749
+ let sessionId;
750
+ if (existingReview) {
751
+ sessionId = existingReview.id;
752
+ if (existingReview.local_head_sha !== headSha) {
753
+ await reviewRepo.updateLocalHeadSha(sessionId, headSha);
754
+ const abbrevLen = getShaAbbrevLength(repoPath);
755
+ console.log(`Updated HEAD SHA on session ${sessionId}: ${existingReview.local_head_sha.substring(0, abbrevLen)} -> ${headSha.substring(0, abbrevLen)}`);
756
+ }
757
+ if (existingReview.local_head_branch === null) {
758
+ await reviewRepo.updateReview(sessionId, { local_head_branch: branch });
759
+ console.log(`Backfilled branch on session ${sessionId}: ${branch}`);
760
+ }
761
+ console.log(`Resuming existing review session (ID: ${existingReview.id})`);
762
+ } else {
763
+ console.log('Creating new review session...');
764
+ sessionId = await reviewRepo.upsertLocalReview({
765
+ localPath: repoPath,
766
+ localHeadSha: headSha,
767
+ repository,
768
+ localHeadBranch: branch
769
+ });
770
+ console.log(`Created new review session (ID: ${sessionId})`);
771
+ }
772
+
773
+ const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
774
+
775
+ const hookEvent = existingReview ? 'review.loaded' : 'review.started';
776
+ if (hasHooks(hookEvent, config)) {
777
+ getCachedUser(config).then(user => {
778
+ const builder = existingReview ? buildReviewLoadedPayload : buildReviewStartedPayload;
779
+ const si = STOPS.indexOf(scopeStart);
780
+ const ei = STOPS.indexOf(scopeEnd);
781
+ const scope = STOPS.slice(si, ei + 1);
782
+ const payload = builder({ reviewId: sessionId, mode: 'local', localContext: { path: repoPath, branch, headSha, scope }, user });
783
+ fireHooks(hookEvent, payload, config);
784
+ }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
785
+ }
786
+ const baseBranch = existingReview?.local_base_branch || null;
787
+
788
+ console.log(`Generating diff for scope: ${scopeLabel(scopeStart, scopeEnd)}...`);
789
+ const { diff, stats } = await module.exports.generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
790
+
791
+ let branchInfo = null;
792
+ if (!includesBranch(scopeStart)) {
793
+ const untrackedFiles = await getUntrackedFiles(repoPath);
794
+ branchInfo = await module.exports.detectAndBuildBranchInfo(repoPath, branch, {
795
+ repository,
796
+ diff,
797
+ untrackedFiles,
798
+ githubToken: getGitHubToken(config),
799
+ enableGraphite: config.enable_graphite === true
800
+ });
801
+ if (branchInfo) {
802
+ console.log(`\nNo uncommitted changes, but branch has ${branchInfo.commitCount} commit(s) ahead of ${branchInfo.baseBranch}.`);
803
+ console.log('The UI will offer to review branch changes.');
804
+ }
805
+ }
806
+
807
+ if (!diff && !branchInfo) {
808
+ console.log('\nNo changes detected in current scope. The UI will open anyway - you can change scope or make changes and refresh.');
809
+ } else if (diff) {
810
+ console.log(`Found ${stats.trackedChanges || 0} file(s) changed`);
811
+ if (stats.untrackedFiles > 0) {
812
+ console.log(` - ${stats.untrackedFiles} untracked file(s)`);
813
+ }
814
+ if (stats.stagedChanges > 0 && !scopeIncludes(scopeStart, scopeEnd, 'staged')) {
815
+ console.log(` - ${stats.stagedChanges} staged file(s) (outside current scope)`);
816
+ }
817
+ }
818
+
819
+ process.env.PAIR_REVIEW_LOCAL = 'true';
820
+ process.env.PAIR_REVIEW_LOCAL_PATH = repoPath;
821
+ process.env.PAIR_REVIEW_LOCAL_ID = String(sessionId);
822
+ process.env.PAIR_REVIEW_REPOSITORY = repository;
823
+ process.env.PAIR_REVIEW_BRANCH = branch;
824
+ process.env.PAIR_REVIEW_LOCAL_HEAD_SHA = headSha;
825
+
826
+ const digest = await module.exports.computeScopedDigest(repoPath, scopeStart, scopeEnd);
827
+
828
+ localReviewDiffs.set(sessionId, { diff, stats, digest, branchInfo });
829
+
830
+ try {
831
+ await reviewRepo.saveLocalDiff(sessionId, { diff, stats, digest });
832
+ } catch (persistError) {
833
+ logger.warn(`Could not persist diff to database: ${persistError.message}`);
834
+ }
835
+
836
+ summaryGenerator.kickOffSummaryJob({
837
+ db,
838
+ config,
839
+ reviewId: sessionId,
840
+ diffText: diff,
841
+ worktreePath: repoPath,
842
+ reviewContext: { prTitle: branch },
843
+ trigger: 'auto'
844
+ })?.catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
845
+
846
+ tourGenerator.kickOffTourJob({
847
+ db,
848
+ config,
849
+ reviewId: sessionId,
850
+ diffText: diff,
851
+ worktreePath: repoPath,
852
+ reviewContext: { prTitle: branch },
853
+ trigger: 'auto'
854
+ })?.catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
855
+
856
+ return {
857
+ sessionId,
858
+ repoPath,
859
+ branch,
860
+ repository,
861
+ headSha,
862
+ diff,
863
+ stats,
864
+ digest,
865
+ branchInfo,
866
+ scopeStart,
867
+ scopeEnd,
868
+ baseBranch
869
+ };
870
+ }
871
+
693
872
  /**
694
873
  * Handle local review mode
695
874
  * @param {string} targetPath - Target path to review (file or directory)
@@ -698,201 +877,43 @@ async function generateLocalDiff(repoPath, options = {}) {
698
877
  */
699
878
  async function handleLocalReview(targetPath, flags = {}) {
700
879
  let db = null;
880
+ let setupComplete = false;
701
881
 
702
882
  try {
703
883
  rejectUrlLikeLocalReviewPath(targetPath);
704
884
 
705
- // Resolve target path
706
885
  const resolvedPath = path.resolve(targetPath || process.cwd());
707
886
 
708
- // Validate path exists
709
887
  try {
710
888
  await fs.access(resolvedPath);
711
889
  } catch {
712
890
  throw new Error(`Path does not exist: ${resolvedPath}`);
713
891
  }
714
892
 
715
- // Find git repository root
716
893
  console.log(`Finding git repository root from ${resolvedPath}...`);
717
- const repoPath = await findGitRoot(resolvedPath);
894
+ const repoPath = await module.exports.findGitRoot(resolvedPath);
718
895
  console.log(`Found git repository at: ${repoPath}`);
719
896
 
720
- // Get HEAD SHA for session identity
721
- const headSha = await getHeadSha(repoPath);
722
- console.log(`HEAD SHA: ${headSha}`);
723
-
724
- // Get current branch
725
- const branch = await getCurrentBranch(repoPath);
726
- console.log(`Current branch: ${branch}`);
727
-
728
- // Generate local review ID
729
- const reviewId = generateLocalReviewId(repoPath, headSha);
730
- console.log(`Local review ID: ${reviewId}`);
731
-
732
- // Load configuration
733
897
  const { config, isFirstRun } = await loadConfig();
734
898
 
735
- // Show welcome message on first run
736
899
  if (isFirstRun) {
737
900
  showWelcomeMessage();
738
901
  }
739
902
 
740
- // Initialize database
741
903
  console.log('Initializing database...');
742
904
  db = await initializeDatabase(resolveDbName(config));
743
905
 
744
- // Check for existing session or create new one
745
- const reviewRepo = new ReviewRepository(db);
746
- const repository = await getRepositoryName(repoPath);
747
- console.log(`Repository: ${repository}`);
748
-
749
- // If this is a GitHub repository (has owner/repo format), register the local path
750
- // This enables future web UI sessions to find this repository without cloning
751
- // Use findMainGitRoot to resolve worktrees to their parent repo
752
- if (repository.includes('/')) {
753
- try {
754
- const mainRepoRoot = await findMainGitRoot(repoPath);
755
- const repoSettingsRepo = new RepoSettingsRepository(db);
756
- await repoSettingsRepo.setLocalPath(repository, mainRepoRoot);
757
- console.log(`Registered repository location: ${mainRepoRoot}`);
758
- } catch (error) {
759
- // Non-fatal: registration failure shouldn't block the review
760
- console.warn(`Could not register repository location: ${error.message}`);
761
- }
762
- }
763
-
764
- console.log('Checking for existing review session...');
765
- let existingReview = await reviewRepo.getLocalReview(repoPath, headSha, branch);
766
-
767
- if (!existingReview) {
768
- // Adopt legacy sessions that predate branch tracking (local_head_branch is NULL)
769
- const legacy = await reviewRepo.getLocalReviewByPathAndSha(repoPath, headSha);
770
- if (legacy && legacy.local_head_branch === null) {
771
- existingReview = legacy;
772
- }
773
- }
774
-
775
- if (!existingReview) {
776
- // Check for existing branch-scope session on this path
777
- // (branch scope sessions persist across HEAD changes)
778
- const branchSession = await reviewRepo.getLocalBranchReview(repoPath, branch);
779
- if (branchSession) {
780
- existingReview = branchSession;
781
- }
782
- }
783
-
784
- let sessionId;
785
- if (existingReview) {
786
- sessionId = existingReview.id;
787
- // Update HEAD SHA if it changed (branch mode: new commits on same branch)
788
- if (existingReview.local_head_sha !== headSha) {
789
- await reviewRepo.updateLocalHeadSha(sessionId, headSha);
790
- const abbrevLen = getShaAbbrevLength(repoPath);
791
- console.log(`Updated HEAD SHA on session ${sessionId}: ${existingReview.local_head_sha.substring(0, abbrevLen)} -> ${headSha.substring(0, abbrevLen)}`);
792
- }
793
- // Backfill branch on legacy sessions
794
- if (existingReview.local_head_branch === null) {
795
- await reviewRepo.updateReview(sessionId, { local_head_branch: branch });
796
- console.log(`Backfilled branch on session ${sessionId}: ${branch}`);
797
- }
798
- console.log(`Resuming existing review session (ID: ${existingReview.id})`);
799
- } else {
800
- console.log('Creating new review session...');
801
- sessionId = await reviewRepo.upsertLocalReview({
802
- localPath: repoPath,
803
- localHeadSha: headSha,
804
- repository,
805
- localHeadBranch: branch
806
- });
807
- console.log(`Created new review session (ID: ${sessionId})`);
808
- }
809
-
810
- // Read scope from session (or use defaults for new sessions)
811
- // Use reviewScope() to normalize legacy scopes that may not include 'unstaged'
812
- const { start: scopeStart, end: scopeEnd } = existingReview ? reviewScope(existingReview) : DEFAULT_SCOPE;
813
-
814
- // Fire review hook (non-blocking)
815
- const hookEvent = existingReview ? 'review.loaded' : 'review.started';
816
- if (hasHooks(hookEvent, config)) {
817
- getCachedUser(config).then(user => {
818
- const builder = existingReview ? buildReviewLoadedPayload : buildReviewStartedPayload;
819
- const si = STOPS.indexOf(scopeStart);
820
- const ei = STOPS.indexOf(scopeEnd);
821
- const scope = STOPS.slice(si, ei + 1);
822
- const payload = builder({ reviewId: sessionId, mode: 'local', localContext: { path: repoPath, branch, headSha, scope }, user });
823
- fireHooks(hookEvent, payload, config);
824
- }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
825
- }
826
- const baseBranch = existingReview?.local_base_branch || null;
827
-
828
- // Generate diff using session's actual scope
829
- console.log(`Generating diff for scope: ${scopeLabel(scopeStart, scopeEnd)}...`);
830
- const { diff, stats } = await generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
831
-
832
- // Branch detection: when scope does NOT include branch and no uncommitted changes,
833
- // check if branch has commits ahead (frontend uses this to suggest expanding scope)
834
- let branchInfo = null;
835
- if (!includesBranch(scopeStart)) {
836
- const untrackedFiles = await getUntrackedFiles(repoPath);
837
- branchInfo = await detectAndBuildBranchInfo(repoPath, branch, {
838
- repository,
839
- diff,
840
- untrackedFiles,
841
- githubToken: getGitHubToken(config),
842
- enableGraphite: config.enable_graphite === true
843
- });
844
- if (branchInfo) {
845
- console.log(`\nNo uncommitted changes, but branch has ${branchInfo.commitCount} commit(s) ahead of ${branchInfo.baseBranch}.`);
846
- console.log('The UI will offer to review branch changes.');
847
- }
848
- }
849
-
850
- if (!diff && !branchInfo) {
851
- console.log('\nNo changes detected in current scope. The UI will open anyway - you can change scope or make changes and refresh.');
852
- } else if (diff) {
853
- console.log(`Found ${stats.trackedChanges || 0} file(s) changed`);
854
- if (stats.untrackedFiles > 0) {
855
- console.log(` - ${stats.untrackedFiles} untracked file(s)`);
856
- }
857
- if (stats.stagedChanges > 0 && !scopeIncludes(scopeStart, scopeEnd, 'staged')) {
858
- console.log(` - ${stats.stagedChanges} staged file(s) (outside current scope)`);
859
- }
860
- }
861
-
862
- // Set environment variables for local mode (metadata only, not large data)
863
- process.env.PAIR_REVIEW_LOCAL = 'true';
864
- process.env.PAIR_REVIEW_LOCAL_PATH = repoPath;
865
- process.env.PAIR_REVIEW_LOCAL_ID = String(sessionId);
866
- process.env.PAIR_REVIEW_REPOSITORY = repository;
867
- process.env.PAIR_REVIEW_BRANCH = branch;
868
- process.env.PAIR_REVIEW_LOCAL_HEAD_SHA = headSha;
869
-
870
- // Compute baseline digest NOW for accurate staleness detection later
871
- // This must be done at diff-capture time, not lazily at check time
872
- const digest = await computeScopedDigest(repoPath, scopeStart, scopeEnd);
873
-
874
- // Store diff data in module-level Map (avoids process.env size limits and security concerns)
875
- localReviewDiffs.set(sessionId, { diff, stats, digest, branchInfo });
876
-
877
- // Persist diff to database so past sessions remain viewable without the server running
878
- try {
879
- const reviewRepo = new ReviewRepository(db);
880
- await reviewRepo.saveLocalDiff(sessionId, { diff, stats, digest });
881
- } catch (persistError) {
882
- logger.warn(`Could not persist diff to database: ${persistError.message}`);
883
- }
906
+ const session = await setupLocalReviewSession({ db, config, repoPath, flags });
907
+ setupComplete = true;
884
908
 
885
- // Set model override if provided
886
909
  if (flags.model) {
887
910
  process.env.PAIR_REVIEW_MODEL = flags.model;
888
911
  }
889
912
 
890
- // Start server
891
913
  console.log('Starting server...');
892
914
  const port = await startServer(db);
893
915
 
894
- // Open browser to local review view
895
- let url = `http://localhost:${port}/local/${sessionId}`;
916
+ let url = `http://localhost:${port}/local/${session.sessionId}`;
896
917
  if (flags.ai) {
897
918
  url += '?analyze=true';
898
919
  }
@@ -900,17 +921,15 @@ async function handleLocalReview(targetPath, flags = {}) {
900
921
  await open(url);
901
922
 
902
923
  console.log(`\nLocal review session started.`);
903
- console.log(`Repository: ${repoPath}`);
904
- console.log(`Branch: ${branch}`);
905
- console.log(`Session ID: ${sessionId}\n`);
924
+ console.log(`Repository: ${session.repoPath}`);
925
+ console.log(`Branch: ${session.branch}`);
926
+ console.log(`Session ID: ${session.sessionId}\n`);
906
927
 
907
928
  } catch (error) {
908
- // Close database on error
909
- if (db) {
929
+ if (db && !setupComplete) {
910
930
  db.close();
911
931
  }
912
932
 
913
- // Provide cleaner error messages for common issues
914
933
  if (error.message.includes('does not exist')) {
915
934
  console.error(`\n[ERROR] ${error.message}\n`);
916
935
  } else if (error.message.includes('Not a git repository')) {
@@ -1057,6 +1076,7 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1057
1076
 
1058
1077
  module.exports = {
1059
1078
  handleLocalReview,
1079
+ setupLocalReviewSession,
1060
1080
  findGitRoot,
1061
1081
  findMainGitRoot,
1062
1082
  getHeadSha,
@@ -20,7 +20,14 @@ const {
20
20
  isCheckInProgress
21
21
  } = require('../ai');
22
22
  const { normalizeRepository } = require('../utils/paths');
23
- const { isRunningViaNpx, getGitHubToken } = require('../config');
23
+ const {
24
+ isRunningViaNpx,
25
+ getGitHubToken,
26
+ getSummaryEnabled,
27
+ getSummaryAutoGenerate,
28
+ getTourEnabled,
29
+ getTourAutoGenerate
30
+ } = require('../config');
24
31
  const { version } = require('../../package.json');
25
32
  const semver = require('semver');
26
33
  const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
@@ -93,6 +100,14 @@ router.get('/api/config', (req, res) => {
93
100
  enable_graphite: config.enable_graphite === true,
94
101
  external_comments: config.external_comments !== false,
95
102
  chat_spinner: config.chat_spinner || 'dots',
103
+ summaries: {
104
+ enabled: getSummaryEnabled(config),
105
+ auto_generate: getSummaryAutoGenerate(config)
106
+ },
107
+ tours: {
108
+ enabled: getTourEnabled(config),
109
+ auto_generate: getTourAutoGenerate(config)
110
+ },
96
111
  // Share configuration for external review viewers.
97
112
  // - url: The base URL of the external share site
98
113
  // - method: Plumbed through for future use (e.g., POST-based share flows).
@@ -11,10 +11,11 @@
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
13
  const express = require('express');
14
- const { ReviewRepository, ContextFileRepository, WorktreeRepository } = require('../database');
14
+ const { ContextFileRepository, WorktreeRepository } = require('../database');
15
15
  const logger = require('../utils/logger');
16
16
  const { broadcastReviewEvent } = require('../events/review-events');
17
17
  const { getDiffFileList } = require('../utils/diff-file-list');
18
+ const validateReviewId = require('./middleware/validate-review-id');
18
19
 
19
20
  const router = express.Router();
20
21
 
@@ -45,34 +46,6 @@ async function resolveRepoRoot(db, review) {
45
46
  return null;
46
47
  }
47
48
 
48
- /**
49
- * Middleware: validate that :reviewId exists in the reviews table.
50
- * Attaches the review record to req.review for downstream handlers.
51
- */
52
- async function validateReviewId(req, res, next) {
53
- try {
54
- const reviewId = parseInt(req.params.reviewId, 10);
55
-
56
- if (isNaN(reviewId) || reviewId <= 0) {
57
- return res.status(400).json({ error: 'Invalid review ID' });
58
- }
59
-
60
- const db = req.app.get('db');
61
- const reviewRepo = new ReviewRepository(db);
62
- const review = await reviewRepo.getReview(reviewId);
63
-
64
- if (!review) {
65
- return res.status(404).json({ error: `Review #${reviewId} not found` });
66
- }
67
-
68
- req.review = review;
69
- req.reviewId = reviewId;
70
- next();
71
- } catch (error) {
72
- next(error);
73
- }
74
- }
75
-
76
49
  /**
77
50
  * POST /api/reviews/:reviewId/context-files
78
51
  * Add a context file range for a review.