@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
@@ -4,7 +4,7 @@ const { promisify } = require('util');
4
4
  const crypto = require('crypto');
5
5
  const path = require('path');
6
6
  const fs = require('fs').promises;
7
- const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken } = require('./config');
7
+ const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken, resolveHostBinding } = require('./config');
8
8
  const logger = require('./utils/logger');
9
9
  const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
10
10
  const { fireHooks, hasHooks } = require('./hooks/hook-runner');
@@ -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,186 @@ 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
+ // Resolve binding so alt-host repos look up PRs on the right host.
795
+ const branchBinding = repository ? resolveHostBinding(repository, config) : null;
796
+ branchInfo = await module.exports.detectAndBuildBranchInfo(repoPath, branch, {
797
+ repository,
798
+ diff,
799
+ untrackedFiles,
800
+ githubToken: branchBinding?.token || getGitHubToken(config),
801
+ hostBinding: branchBinding,
802
+ enableGraphite: config.enable_graphite === true
803
+ });
804
+ if (branchInfo) {
805
+ console.log(`\nNo uncommitted changes, but branch has ${branchInfo.commitCount} commit(s) ahead of ${branchInfo.baseBranch}.`);
806
+ console.log('The UI will offer to review branch changes.');
807
+ }
808
+ }
809
+
810
+ if (!diff && !branchInfo) {
811
+ console.log('\nNo changes detected in current scope. The UI will open anyway - you can change scope or make changes and refresh.');
812
+ } else if (diff) {
813
+ console.log(`Found ${stats.trackedChanges || 0} file(s) changed`);
814
+ if (stats.untrackedFiles > 0) {
815
+ console.log(` - ${stats.untrackedFiles} untracked file(s)`);
816
+ }
817
+ if (stats.stagedChanges > 0 && !scopeIncludes(scopeStart, scopeEnd, 'staged')) {
818
+ console.log(` - ${stats.stagedChanges} staged file(s) (outside current scope)`);
819
+ }
820
+ }
821
+
822
+ process.env.PAIR_REVIEW_LOCAL = 'true';
823
+ process.env.PAIR_REVIEW_LOCAL_PATH = repoPath;
824
+ process.env.PAIR_REVIEW_LOCAL_ID = String(sessionId);
825
+ process.env.PAIR_REVIEW_REPOSITORY = repository;
826
+ process.env.PAIR_REVIEW_BRANCH = branch;
827
+ process.env.PAIR_REVIEW_LOCAL_HEAD_SHA = headSha;
828
+
829
+ const digest = await module.exports.computeScopedDigest(repoPath, scopeStart, scopeEnd);
830
+
831
+ localReviewDiffs.set(sessionId, { diff, stats, digest, branchInfo });
832
+
833
+ try {
834
+ await reviewRepo.saveLocalDiff(sessionId, { diff, stats, digest });
835
+ } catch (persistError) {
836
+ logger.warn(`Could not persist diff to database: ${persistError.message}`);
837
+ }
838
+
839
+ summaryGenerator.kickOffSummaryJob({
840
+ db,
841
+ config,
842
+ reviewId: sessionId,
843
+ diffText: diff,
844
+ worktreePath: repoPath,
845
+ reviewContext: { prTitle: branch },
846
+ trigger: 'auto'
847
+ })?.catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
848
+
849
+ tourGenerator.kickOffTourJob({
850
+ db,
851
+ config,
852
+ reviewId: sessionId,
853
+ diffText: diff,
854
+ worktreePath: repoPath,
855
+ reviewContext: { prTitle: branch },
856
+ trigger: 'auto'
857
+ })?.catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
858
+
859
+ return {
860
+ sessionId,
861
+ repoPath,
862
+ branch,
863
+ repository,
864
+ headSha,
865
+ diff,
866
+ stats,
867
+ digest,
868
+ branchInfo,
869
+ scopeStart,
870
+ scopeEnd,
871
+ baseBranch
872
+ };
873
+ }
874
+
693
875
  /**
694
876
  * Handle local review mode
695
877
  * @param {string} targetPath - Target path to review (file or directory)
@@ -698,201 +880,43 @@ async function generateLocalDiff(repoPath, options = {}) {
698
880
  */
699
881
  async function handleLocalReview(targetPath, flags = {}) {
700
882
  let db = null;
883
+ let setupComplete = false;
701
884
 
702
885
  try {
703
886
  rejectUrlLikeLocalReviewPath(targetPath);
704
887
 
705
- // Resolve target path
706
888
  const resolvedPath = path.resolve(targetPath || process.cwd());
707
889
 
708
- // Validate path exists
709
890
  try {
710
891
  await fs.access(resolvedPath);
711
892
  } catch {
712
893
  throw new Error(`Path does not exist: ${resolvedPath}`);
713
894
  }
714
895
 
715
- // Find git repository root
716
896
  console.log(`Finding git repository root from ${resolvedPath}...`);
717
- const repoPath = await findGitRoot(resolvedPath);
897
+ const repoPath = await module.exports.findGitRoot(resolvedPath);
718
898
  console.log(`Found git repository at: ${repoPath}`);
719
899
 
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
900
  const { config, isFirstRun } = await loadConfig();
734
901
 
735
- // Show welcome message on first run
736
902
  if (isFirstRun) {
737
903
  showWelcomeMessage();
738
904
  }
739
905
 
740
- // Initialize database
741
906
  console.log('Initializing database...');
742
907
  db = await initializeDatabase(resolveDbName(config));
743
908
 
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
- }
909
+ const session = await setupLocalReviewSession({ db, config, repoPath, flags });
910
+ setupComplete = true;
809
911
 
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
- }
884
-
885
- // Set model override if provided
886
912
  if (flags.model) {
887
913
  process.env.PAIR_REVIEW_MODEL = flags.model;
888
914
  }
889
915
 
890
- // Start server
891
916
  console.log('Starting server...');
892
917
  const port = await startServer(db);
893
918
 
894
- // Open browser to local review view
895
- let url = `http://localhost:${port}/local/${sessionId}`;
919
+ let url = `http://localhost:${port}/local/${session.sessionId}`;
896
920
  if (flags.ai) {
897
921
  url += '?analyze=true';
898
922
  }
@@ -900,17 +924,15 @@ async function handleLocalReview(targetPath, flags = {}) {
900
924
  await open(url);
901
925
 
902
926
  console.log(`\nLocal review session started.`);
903
- console.log(`Repository: ${repoPath}`);
904
- console.log(`Branch: ${branch}`);
905
- console.log(`Session ID: ${sessionId}\n`);
927
+ console.log(`Repository: ${session.repoPath}`);
928
+ console.log(`Branch: ${session.branch}`);
929
+ console.log(`Session ID: ${session.sessionId}\n`);
906
930
 
907
931
  } catch (error) {
908
- // Close database on error
909
- if (db) {
932
+ if (db && !setupComplete) {
910
933
  db.close();
911
934
  }
912
935
 
913
- // Provide cleaner error messages for common issues
914
936
  if (error.message.includes('does not exist')) {
915
937
  console.error(`\n[ERROR] ${error.message}\n`);
916
938
  } else if (error.message.includes('Not a git repository')) {
@@ -1019,11 +1041,12 @@ async function getFirstCommitSubject(repoPath, baseBranch) {
1019
1041
  * @param {string} [options.diff] - The uncommitted diff content (empty = eligible)
1020
1042
  * @param {Array} [options.untrackedFiles] - Untracked files array (empty = eligible)
1021
1043
  * @param {string} [options.githubToken] - Resolved GitHub token for PR lookup
1044
+ * @param {Object} [options.hostBinding] - Resolved host binding (apiHost/token/features) for alt-host PR lookup
1022
1045
  * @param {boolean} [options.enableGraphite] - When true, try Graphite CLI for parent branch
1023
1046
  * @returns {Promise<{baseBranch: string, commitCount: number, source: string, prNumber?: number}|null>}
1024
1047
  */
1025
1048
  async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1026
- const { repository, diff, untrackedFiles, githubToken, enableGraphite } = options;
1049
+ const { repository, diff, untrackedFiles, githubToken, hostBinding, enableGraphite } = options;
1027
1050
 
1028
1051
  // Guard: detached HEAD, has uncommitted changes, or has untracked files
1029
1052
  if (branch === 'HEAD') return null;
@@ -1032,7 +1055,12 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1032
1055
 
1033
1056
  try {
1034
1057
  const { detectBaseBranch } = require('./git/base-branch');
1035
- const depsOverride = githubToken ? { getGitHubToken: () => githubToken } : undefined;
1058
+ const depsOverride = githubToken || hostBinding
1059
+ ? {
1060
+ getGitHubToken: () => githubToken || '',
1061
+ getHostBinding: () => hostBinding || null
1062
+ }
1063
+ : undefined;
1036
1064
  const detection = await detectBaseBranch(repoPath, branch, {
1037
1065
  repository,
1038
1066
  enableGraphite,
@@ -1057,6 +1085,7 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1057
1085
 
1058
1086
  module.exports = {
1059
1087
  handleLocalReview,
1088
+ setupLocalReviewSession,
1060
1089
  findGitRoot,
1061
1090
  findMainGitRoot,
1062
1091
  getHeadSha,