@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
package/src/main.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const fs = require('fs');
3
- const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
3
+ const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
4
4
  const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository } = require('./database');
5
5
  const { PRArgumentParser } = require('./github/parser');
6
6
  const { GitHubClient } = require('./github/client');
@@ -26,6 +26,47 @@ const { attemptDelegation } = require('./single-port');
26
26
 
27
27
  let db = null;
28
28
 
29
+ /**
30
+ * Build a user-facing "missing token" error message tailored to the
31
+ * resolved host binding.
32
+ *
33
+ * For github.com bindings (`apiHost === null`), suggest the legacy
34
+ * options: `GITHUB_TOKEN` env var, `config.github_token`, or
35
+ * `config.github_token_command`. For alt-host bindings, suppress those
36
+ * suggestions (the github.com top-level credentials are intentionally
37
+ * not used for alt-hosts) and point exclusively at the per-repo
38
+ * `token` / `token_command` keys under the resolved bindingRepository.
39
+ *
40
+ * @param {Object} params
41
+ * @param {string} params.owner
42
+ * @param {string} params.repo
43
+ * @param {string} params.bindingRepository - The `repos[...]` config key
44
+ * that the binding lookup was performed against. May differ from
45
+ * `<owner>/<repo>` for monorepo-style configs (see Fix #8).
46
+ * @param {string|null} params.apiHost - Resolved api_host (null = github.com)
47
+ * @returns {string}
48
+ */
49
+ function buildMissingTokenError({ owner, repo, bindingRepository, apiHost }) {
50
+ if (apiHost) {
51
+ return (
52
+ `GitHub token not found for alt-host repo ${owner}/${repo} (${apiHost}). ` +
53
+ `GITHUB_TOKEN and top-level github_token/github_token_command are github.com-only ` +
54
+ `and are not used for alt-hosts. Configure ` +
55
+ `\`config.repos["${bindingRepository}"].token\` or ` +
56
+ `\`config.repos["${bindingRepository}"].token_command\` ` +
57
+ `(e.g., "althost-cli auth token"). Run: npx pair-review --configure`
58
+ );
59
+ }
60
+ return (
61
+ `GitHub token not found for ${owner}/${repo}. ` +
62
+ `Set GITHUB_TOKEN env var, or configure a token at ` +
63
+ `\`config.repos["${bindingRepository}"].token\` or ` +
64
+ `\`config.repos["${bindingRepository}"].token_command\`, or set ` +
65
+ `top-level \`github_token\` / \`github_token_command\` ` +
66
+ `(e.g., "gh auth token"). Run: npx pair-review --configure`
67
+ );
68
+ }
69
+
29
70
  /**
30
71
  * Detect PR information from GitHub Actions environment variables.
31
72
  * Returns null if not in GitHub Actions or PR info cannot be determined.
@@ -573,16 +614,33 @@ AI PROVIDERS:
573
614
  */
574
615
  async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = null) {
575
616
  try {
576
- // Get GitHub token (env var takes precedence over config)
577
- const githubToken = getGitHubToken(config);
578
- if (!githubToken) {
579
- throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
580
- }
581
-
582
- // Parse PR arguments
583
- const parser = new PRArgumentParser();
617
+ // Parse PR arguments FIRST pass config so url_pattern matching for
618
+ // alternate hosts can resolve pasted URLs to the canonical
619
+ // owner/repo before falling back to GitHub/Graphite parsers.
620
+ // We must know the target repo before resolving its token, so that
621
+ // repo-scoped tokens, repo-scoped token_command entries, and alt-host
622
+ // configurations are honored. A no-repository token preflight only
623
+ // sees env + top-level credentials and would reject valid configs.
624
+ const parser = new PRArgumentParser(config);
584
625
  const prInfo = await parser.parsePRArguments(args);
585
626
 
627
+ // Resolve token via repo-aware host binding so alt-host and
628
+ // repo-scoped credentials are respected. When a per-repo
629
+ // `url_pattern` matched, prefer its `bindingRepository` — that's the
630
+ // matched `repos[...]` config key, which may differ from the
631
+ // captured `${owner}/${repo}` for monorepo-style patterns.
632
+ const repositoryForBinding = prInfo.bindingRepository
633
+ || normalizeRepository(prInfo.owner, prInfo.repo);
634
+ const binding = resolveHostBinding(repositoryForBinding, config);
635
+ if (!binding.token) {
636
+ throw new Error(buildMissingTokenError({
637
+ owner: prInfo.owner,
638
+ repo: prInfo.repo,
639
+ bindingRepository: repositoryForBinding,
640
+ apiHost: binding.apiHost
641
+ }));
642
+ }
643
+
586
644
  // Register cwd as known repo path if it matches the target repo
587
645
  const currentDir = parser.getCurrentDirectory();
588
646
  const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
@@ -677,20 +735,33 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
677
735
  let poolLifecycle = null;
678
736
 
679
737
  try {
680
- // Get GitHub token (env var takes precedence over config)
681
- const githubToken = getGitHubToken(config);
682
- if (!githubToken) {
683
- throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
684
- }
685
-
686
- // Parse PR arguments
687
- const parser = new PRArgumentParser();
738
+ // Parse PR arguments pass config so url_pattern matching for
739
+ // alternate hosts can resolve pasted URLs to the canonical
740
+ // owner/repo before falling back to GitHub/Graphite parsers.
741
+ const parser = new PRArgumentParser(config);
688
742
  prInfo = await parser.parsePRArguments(args);
689
743
 
744
+ // Resolve host binding for the target repo (handles alt-host).
745
+ // Prefer `bindingRepository` (from url_pattern match) over the raw
746
+ // `${owner}/${repo}` since they can differ when one config entry
747
+ // serves many monorepo-shaped URLs.
748
+ const repositoryForBinding = prInfo.bindingRepository
749
+ || normalizeRepository(prInfo.owner, prInfo.repo);
750
+ const headlessBinding = resolveHostBinding(repositoryForBinding, config);
751
+ if (!headlessBinding.token) {
752
+ throw new Error(buildMissingTokenError({
753
+ owner: prInfo.owner,
754
+ repo: prInfo.repo,
755
+ bindingRepository: repositoryForBinding,
756
+ apiHost: headlessBinding.apiHost
757
+ }));
758
+ }
759
+ const githubToken = headlessBinding.token;
760
+
690
761
  console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in ${options.modeLabel}`);
691
762
 
692
763
  // Create GitHub client and verify repository access
693
- const githubClient = new GitHubClient(githubToken);
764
+ const githubClient = new GitHubClient(headlessBinding);
694
765
  const repoExists = await githubClient.repositoryExists(prInfo.owner, prInfo.repo);
695
766
  if (!repoExists) {
696
767
  throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
@@ -786,9 +857,10 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
786
857
 
787
858
  // Resolve monorepo config options (checkout_script, worktree_directory, worktree_name_template)
788
859
  // even when running from inside the target repo, so they are not silently ignored.
860
+ // Config lookups must use the binding key (matched `repos[...]` entry), not the PR identity.
789
861
  const repoSettingsRepo = new RepoSettingsRepository(db);
790
862
  const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
791
- const resolved = resolveRepoOptions(config, repository, repoSettings);
863
+ const resolved = resolveRepoOptions(config, repositoryForBinding, repoSettings);
792
864
  checkoutScript = resolved.checkoutScript;
793
865
  checkoutTimeout = resolved.checkoutTimeout;
794
866
  worktreeConfig = resolved.worktreeConfig;
@@ -802,8 +874,10 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
802
874
  owner: prInfo.owner,
803
875
  repo: prInfo.repo,
804
876
  repository,
877
+ bindingRepository: repositoryForBinding,
805
878
  prNumber: prInfo.number,
806
879
  config,
880
+ cloneUrl: prData?.repository?.clone_url,
807
881
  onProgress: (progress) => {
808
882
  if (progress.message) {
809
883
  console.log(progress.message);
@@ -816,11 +890,12 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
816
890
  checkoutTimeout = result.checkoutTimeout;
817
891
  worktreeConfig = result.worktreeConfig;
818
892
  // findRepositoryPath doesn't return pool config; resolve from DB + file config
893
+ // (binding key, not PR identity).
819
894
  const repoSettingsRepo = new RepoSettingsRepository(db);
820
895
  const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
821
- const { poolSize: resolvedPoolSize, poolFetchIntervalMinutes: _resolvedFetchInterval } = resolvePoolConfig(config, repository, repoSettings);
896
+ const { poolSize: resolvedPoolSize, poolFetchIntervalMinutes: _resolvedFetchInterval } = resolvePoolConfig(config, repositoryForBinding, repoSettings);
822
897
  poolSize = resolvedPoolSize || 0;
823
- resetScript = config ? getRepoResetScript(config, repository) : null;
898
+ resetScript = config ? getRepoResetScript(config, repositoryForBinding) : null;
824
899
  }
825
900
 
826
901
  const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
@@ -906,8 +981,11 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
906
981
 
907
982
  let analysisSummary = null;
908
983
  try {
909
- // Pass all instruction levels to ensure they're captured in the analysis run
910
- const analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { globalInstructions, repoInstructions });
984
+ // Pass all instruction levels to ensure they're captured in the analysis run.
985
+ // githubClient is forwarded so the analyzer can pre-fetch existing PR review
986
+ // comments when callers opt in via excludePrevious.github.
987
+ logger.debug(`analyzer githubClient wired for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}`);
988
+ const analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { globalInstructions, repoInstructions }, null, { githubClient });
911
989
  analysisSummary = analysisResult.summary;
912
990
  console.log('AI analysis completed successfully');
913
991
  } catch (analysisError) {
@@ -992,24 +1070,49 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
992
1070
  // Submit review to GitHub via GraphQL (same path as web UI)
993
1071
  console.log(`Submitting review with ${githubComments.length} comments...`);
994
1072
 
995
- const prNodeId = storedPRData.node_id;
996
- if (!prNodeId) {
997
- throw new Error(`PR node_id not available for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}. Cannot submit review without GraphQL node ID.`);
998
- }
999
-
1000
1073
  // Check for existing pending draft (GitHub only allows one per user per PR)
1001
1074
  const existingDraft = await githubClient.getPendingReviewForUser(
1002
1075
  prInfo.owner, prInfo.repo, prInfo.number
1003
1076
  );
1004
1077
 
1078
+ // GraphQL PR node id is only required when we'll be creating a NEW
1079
+ // GraphQL review (no existing draft to reuse) OR adding GraphQL
1080
+ // review comments. REST and host-impl configurations address the
1081
+ // PR by (owner, repo, prNumber) + numeric review id and ignore
1082
+ // prNodeId; reusing an existing GraphQL draft also doesn't need
1083
+ // the PR node id because the review node id is sufficient.
1084
+ const willCreateNewGraphQLReview =
1085
+ headlessBinding.features.review_lifecycle === 'graphql' && !existingDraft;
1086
+ const willAddGraphQLComments =
1087
+ githubComments.length > 0 && headlessBinding.features.pending_review_comments === 'graphql';
1088
+ const needsGraphQLNodeId = willCreateNewGraphQLReview || willAddGraphQLComments;
1089
+
1090
+ if (needsGraphQLNodeId && !storedPRData.node_id) {
1091
+ throw new Error(
1092
+ `GraphQL PR node id required for ${prInfo.owner}/${prInfo.repo}#${prInfo.number} ` +
1093
+ `(features.review_lifecycle = "${headlessBinding.features.review_lifecycle}", ` +
1094
+ `pending_review_comments = "${headlessBinding.features.pending_review_comments}"). ` +
1095
+ `PR record is missing node_id — refresh the PR data and try again.`
1096
+ );
1097
+ }
1098
+
1099
+ const prNodeId = storedPRData.node_id ?? null;
1100
+
1101
+ const submitPrContext = {
1102
+ owner: prInfo.owner,
1103
+ repo: prInfo.repo,
1104
+ prNumber: prInfo.number,
1105
+ reviewId: existingDraft?.databaseId
1106
+ };
1107
+
1005
1108
  let githubReview;
1006
1109
  if (options.reviewEvent === 'DRAFT') {
1007
1110
  githubReview = await githubClient.createDraftReviewGraphQL(
1008
- prNodeId, reviewBody, githubComments, existingDraft?.id
1111
+ prNodeId, reviewBody, githubComments, existingDraft?.id, submitPrContext
1009
1112
  );
1010
1113
  } else {
1011
1114
  githubReview = await githubClient.createReviewGraphQL(
1012
- prNodeId, options.reviewEvent, reviewBody, githubComments, existingDraft?.id
1115
+ prNodeId, options.reviewEvent, reviewBody, githubComments, existingDraft?.id, submitPrContext
1013
1116
  );
1014
1117
  }
1015
1118
 
@@ -33,7 +33,8 @@ const {
33
33
  killProcesses,
34
34
  createProgressCallback
35
35
  } = require('./shared');
36
- const { generateLocalDiff, computeLocalDiffDigest, getCurrentBranch } = require('../local-review');
36
+ const { generateScopedDiff, computeScopedDigest, getCurrentBranch } = require('../local-review');
37
+ const { reviewScope } = require('../local-scope');
37
38
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
38
39
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
39
40
 
@@ -229,13 +230,34 @@ router.post('/api/analyses/results', async (req, res) => {
229
230
  });
230
231
  }
231
232
 
232
- // Generate and store diff so the web UI can display it
233
+ // Generate and store diff so the web UI can display it. Persist to the
234
+ // `local_diffs` table as well as the in-memory cache so the diff survives
235
+ // a restart and the manual tour/summary buttons never falsely report
236
+ // "no-diff" for a review created via this analysis-push path.
237
+ //
238
+ // Use the review's recorded scope (mirroring the council path) — NOT the
239
+ // default-scope `generateLocalDiff` wrapper — so that re-using an existing
240
+ // review whose scope is `staged` or `branch` does not clobber its durable
241
+ // `local_diffs` row with a narrower default-scope patch. For brand-new
242
+ // reviews the upsert above created a default-scope row, so either lookup
243
+ // yields the correct scope; reviewScope() falls back to the default scope
244
+ // when the columns are unset.
245
+ const reviewForScope = existingReview || await reviewRepo.getLocalReviewById(reviewId);
246
+ const { start: scopeStart, end: scopeEnd } = reviewScope(reviewForScope);
233
247
  try {
234
- const diffResult = await generateLocalDiff(localPath);
235
- const digest = await computeLocalDiffDigest(localPath);
248
+ const diffResult = await generateScopedDiff(
249
+ localPath,
250
+ scopeStart,
251
+ scopeEnd,
252
+ reviewForScope.local_base_branch || null
253
+ );
254
+ const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
236
255
  localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
256
+ await reviewRepo.saveLocalDiff(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
237
257
  } catch (diffError) {
238
- logger.warn(`Could not generate diff for local review ${reviewId}: ${diffError.message}`);
258
+ // Covers both diff generation AND the durable saveLocalDiff write, so the
259
+ // message names both failure modes rather than only generation.
260
+ logger.warn(`Could not generate or persist diff for local review ${reviewId}: ${diffError.message}`);
239
261
  }
240
262
  } else {
241
263
  const repoParts = repo.split('/');
@@ -495,6 +517,7 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
495
517
  config: modeConfig,
496
518
  excludePrevious,
497
519
  serverPort,
520
+ githubClient,
498
521
  providerOverrides = {},
499
522
  providerOverridesMap = null,
500
523
  hookContext = {},
@@ -606,8 +629,8 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
606
629
  };
607
630
 
608
631
  analysisPromise = isVoiceCentric
609
- ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort })
610
- : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort });
632
+ ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort, githubClient })
633
+ : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort, githubClient });
611
634
  } catch (setupError) {
612
635
  // Synchronous setup failure — clean up the analysis hold immediately
613
636
  reviewToAnalysisId.delete(reviewId);
@@ -0,0 +1,295 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Bulk analysis configuration routes.
4
+ *
5
+ * The index page can open many PR tabs at once. Rather than placing a large
6
+ * analysis configuration in every PR URL, the browser stores it here and passes
7
+ * a short ID through the setup/review URL. Each PR tab then resolves the ID
8
+ * before starting auto-analysis.
9
+ */
10
+
11
+ const crypto = require('crypto');
12
+ const express = require('express');
13
+ const logger = require('../utils/logger');
14
+ const { VALID_TIERS } = require('../ai/prompts/config');
15
+ const { getAllProvidersInfo } = require('../ai');
16
+ const { normalizeCouncilConfig, validateCouncilConfig } = require('./councils');
17
+
18
+ const router = express.Router();
19
+
20
+ const configs = new Map();
21
+ const CONFIG_TTL_MS = 30 * 60 * 1000;
22
+ const MAX_CONFIGS = 1000;
23
+ const MAX_INSTRUCTIONS_LENGTH = 5000;
24
+ const VALID_TIER_SET = new Set(VALID_TIERS);
25
+ const VALID_CONFIG_TYPES = new Set(['council', 'advanced']);
26
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
27
+
28
+ function pruneExpired(now = Date.now()) {
29
+ for (const [id, entry] of configs.entries()) {
30
+ if (entry.expiresAt <= now) {
31
+ configs.delete(id);
32
+ }
33
+ }
34
+ }
35
+
36
+ function enforceMaxConfigs() {
37
+ while (configs.size > MAX_CONFIGS) {
38
+ const oldestId = configs.keys().next().value;
39
+ if (!oldestId) return;
40
+ configs.delete(oldestId);
41
+ }
42
+ }
43
+
44
+ function isPlainObject(value) {
45
+ return !!value && typeof value === 'object' && !Array.isArray(value);
46
+ }
47
+
48
+ function validateString(value, field, { required = false, max = 200 } = {}) {
49
+ if (value == null) {
50
+ return required ? `${field} is required` : null;
51
+ }
52
+ if (typeof value !== 'string' || value.trim().length === 0 || value.length > max) {
53
+ return `${field} must be a non-empty string up to ${max} characters`;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function validateCustomInstructions(value) {
59
+ if (value == null) return null;
60
+ if (typeof value !== 'string') return 'customInstructions must be a string';
61
+ if (value.length > MAX_INSTRUCTIONS_LENGTH) {
62
+ return `customInstructions exceed maximum length of ${MAX_INSTRUCTIONS_LENGTH} characters`;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function validateJsonShape(value, path = 'analysisConfig', depth = 0) {
68
+ if (depth > 20) return `${path} is too deeply nested`;
69
+ if (value == null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
70
+ return null;
71
+ }
72
+ if (Array.isArray(value)) {
73
+ if (value.length > 200) return `${path} has too many items`;
74
+ for (let i = 0; i < value.length; i++) {
75
+ const error = validateJsonShape(value[i], `${path}[${i}]`, depth + 1);
76
+ if (error) return error;
77
+ }
78
+ return null;
79
+ }
80
+ if (!isPlainObject(value)) return `${path} must contain only JSON values`;
81
+
82
+ const entries = Object.entries(value);
83
+ if (entries.length > 200) return `${path} has too many keys`;
84
+ for (const [key, child] of entries) {
85
+ if (FORBIDDEN_KEYS.has(key)) return `${path} contains forbidden key ${key}`;
86
+ const error = validateJsonShape(child, `${path}.${key}`, depth + 1);
87
+ if (error) return error;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function sanitizeExcludePrevious(value) {
93
+ if (value == null) return { error: null, value: undefined };
94
+ if (!isPlainObject(value)) return { error: 'excludePrevious must be an object' };
95
+ return {
96
+ error: null,
97
+ value: {
98
+ github: value.github === true,
99
+ feedback: value.feedback === true
100
+ }
101
+ };
102
+ }
103
+
104
+ function sanitizeEnabledLevels(value) {
105
+ if (value == null) return { error: null, value: undefined };
106
+ if (!Array.isArray(value)) return { error: 'enabledLevels must be an array' };
107
+
108
+ const levels = [];
109
+ for (const level of value) {
110
+ const number = Number(level);
111
+ if (![1, 2, 3].includes(number)) {
112
+ return { error: 'enabledLevels may only include levels 1, 2, and 3' };
113
+ }
114
+ if (!levels.includes(number)) levels.push(number);
115
+ }
116
+
117
+ if (levels.length === 0) return { error: 'enabledLevels must include at least one level' };
118
+ return { error: null, value: levels };
119
+ }
120
+
121
+ function sanitizeSingleConfig(config) {
122
+ let error = validateString(config.provider, 'provider', { required: true });
123
+ if (error) return { error };
124
+
125
+ error = validateString(config.model, 'model', { required: true });
126
+ if (error) return { error };
127
+
128
+ if (config.tier != null && (!VALID_TIER_SET.has(config.tier))) {
129
+ return { error: `tier must be one of ${VALID_TIERS.join(', ')}` };
130
+ }
131
+
132
+ // The modal builds two related fields: `instructions` carries the *effective*
133
+ // prompt (selected preset chips concatenated with the textarea) while
134
+ // `customInstructions` is the raw textarea only. Persist the effective prompt
135
+ // so bulk-launched analyses see the same prompt the modal showed the user —
136
+ // otherwise any chosen preset chips are silently dropped.
137
+ const effectiveInstructions = config.instructions || config.customInstructions;
138
+
139
+ error = validateCustomInstructions(effectiveInstructions);
140
+ if (error) return { error };
141
+
142
+ const enabledLevels = sanitizeEnabledLevels(config.enabledLevels);
143
+ if (enabledLevels.error) return { error: enabledLevels.error };
144
+
145
+ const excludePrevious = sanitizeExcludePrevious(config.excludePrevious);
146
+ if (excludePrevious.error) return { error: excludePrevious.error };
147
+
148
+ // Defense in depth: the bulk replay path forwards this stored pair straight to
149
+ // analysis with no client-side guard. If the model does not belong to the
150
+ // provider (a mismatched pair that slipped past the client resolver), fall back
151
+ // to the provider's own default rather than forwarding an invalid pair. Unknown
152
+ // providers (custom/unavailable, not in the registry) pass through unchanged.
153
+ let normalizedModel = config.model;
154
+ const providerInfo = getAllProvidersInfo().find(p => p.id === config.provider);
155
+ if (providerInfo && !providerInfo.models.some(m => m.id === config.model)) {
156
+ normalizedModel = providerInfo.defaultModel || config.model;
157
+ }
158
+
159
+ return {
160
+ error: null,
161
+ config: {
162
+ provider: config.provider,
163
+ model: normalizedModel,
164
+ tier: config.tier,
165
+ customInstructions: effectiveInstructions || null,
166
+ enabledLevels: enabledLevels.value,
167
+ skipLevel3: config.skipLevel3 === true,
168
+ noLevels: config.noLevels === true,
169
+ excludePrevious: excludePrevious.value
170
+ }
171
+ };
172
+ }
173
+
174
+ function sanitizeCouncilConfig(config) {
175
+ // configType selects which downstream validator runs, so reject unrecognized
176
+ // values rather than silently coercing them (which could route councilConfig
177
+ // through the wrong validator).
178
+ if (config.configType != null && !VALID_CONFIG_TYPES.has(config.configType)) {
179
+ return { error: `configType must be one of ${[...VALID_CONFIG_TYPES].join(', ')}` };
180
+ }
181
+ const configType = config.configType || 'advanced';
182
+
183
+ let error = validateString(config.councilId, 'councilId', { max: 128 });
184
+ if (error) return { error };
185
+
186
+ if (config.councilName != null) {
187
+ error = validateString(config.councilName, 'councilName', { max: 200 });
188
+ if (error) return { error };
189
+ }
190
+
191
+ error = validateCustomInstructions(config.customInstructions);
192
+ if (error) return { error };
193
+
194
+ const excludePrevious = sanitizeExcludePrevious(config.excludePrevious);
195
+ if (excludePrevious.error) return { error: excludePrevious.error };
196
+
197
+ if (!config.councilId && !config.councilConfig) {
198
+ return { error: 'Either councilId or councilConfig is required' };
199
+ }
200
+
201
+ let councilConfig;
202
+ if (config.councilConfig != null) {
203
+ const shapeError = validateJsonShape(config.councilConfig, 'councilConfig');
204
+ if (shapeError) return { error: shapeError };
205
+
206
+ councilConfig = normalizeCouncilConfig(config.councilConfig, configType);
207
+ const councilError = validateCouncilConfig(councilConfig, configType);
208
+ if (councilError) return { error: `Invalid council config: ${councilError}` };
209
+ }
210
+
211
+ return {
212
+ error: null,
213
+ config: {
214
+ isCouncil: true,
215
+ configType,
216
+ // When an inline snapshot is stored, drop councilId so the downstream
217
+ // analysis route is forced to use the exact modal-selected councilConfig
218
+ // rather than re-fetching (and possibly diverging from) the DB record.
219
+ councilId: councilConfig ? undefined : (config.councilId || undefined),
220
+ councilName: config.councilName || null,
221
+ councilConfig,
222
+ customInstructions: config.customInstructions || null,
223
+ excludePrevious: excludePrevious.value
224
+ }
225
+ };
226
+ }
227
+
228
+ function sanitizeAnalysisConfig(config) {
229
+ if (!isPlainObject(config)) {
230
+ return { error: 'analysisConfig object required' };
231
+ }
232
+
233
+ const shapeError = validateJsonShape(config);
234
+ if (shapeError) return { error: shapeError };
235
+
236
+ if (config.isCouncil === true) {
237
+ return sanitizeCouncilConfig(config);
238
+ }
239
+ return sanitizeSingleConfig(config);
240
+ }
241
+
242
+ router.post('/api/bulk-analysis-configs', (req, res) => {
243
+ try {
244
+ const result = sanitizeAnalysisConfig(req.body?.analysisConfig);
245
+ if (result.error) {
246
+ return res.status(400).json({ error: result.error });
247
+ }
248
+
249
+ pruneExpired();
250
+
251
+ const id = crypto.randomUUID();
252
+ configs.set(id, {
253
+ analysisConfig: result.config,
254
+ expiresAt: Date.now() + CONFIG_TTL_MS
255
+ });
256
+ enforceMaxConfigs();
257
+
258
+ res.json({ success: true, id, expiresInMs: CONFIG_TTL_MS });
259
+ } catch (error) {
260
+ logger.error('Failed to store bulk analysis config:', error);
261
+ res.status(500).json({ error: 'Failed to store bulk analysis config' });
262
+ }
263
+ });
264
+
265
+ router.get('/api/bulk-analysis-configs/:id', (req, res) => {
266
+ const { id } = req.params;
267
+ pruneExpired();
268
+
269
+ const entry = configs.get(id);
270
+ if (!entry) {
271
+ return res.status(404).json({ error: 'Bulk analysis config not found' });
272
+ }
273
+
274
+ res.json({
275
+ success: true,
276
+ analysisConfig: entry.analysisConfig
277
+ });
278
+ });
279
+
280
+ function _resetBulkAnalysisConfigs() {
281
+ configs.clear();
282
+ }
283
+
284
+ function _getBulkAnalysisConfig(id) {
285
+ const entry = configs.get(id);
286
+ return entry ? entry.analysisConfig : null;
287
+ }
288
+
289
+ module.exports = router;
290
+ module.exports._resetBulkAnalysisConfigs = _resetBulkAnalysisConfigs;
291
+ module.exports._getBulkAnalysisConfig = _getBulkAnalysisConfig;
292
+ module.exports._pruneExpired = pruneExpired;
293
+ module.exports._CONFIG_TTL_MS = CONFIG_TTL_MS;
294
+ module.exports._MAX_CONFIGS = MAX_CONFIGS;
295
+ module.exports._sanitizeAnalysisConfig = sanitizeAnalysisConfig;