@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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
- package/src/utils/diff-hunks.js +65 -0
- 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
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
//
|
|
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
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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(
|
|
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,
|
|
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,
|
|
896
|
+
const { poolSize: resolvedPoolSize, poolFetchIntervalMinutes: _resolvedFetchInterval } = resolvePoolConfig(config, repositoryForBinding, repoSettings);
|
|
822
897
|
poolSize = resolvedPoolSize || 0;
|
|
823
|
-
resetScript = config ? getRepoResetScript(config,
|
|
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
|
-
|
|
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
|
|
package/src/routes/analyses.js
CHANGED
|
@@ -33,7 +33,8 @@ const {
|
|
|
33
33
|
killProcesses,
|
|
34
34
|
createProgressCallback
|
|
35
35
|
} = require('./shared');
|
|
36
|
-
const {
|
|
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
|
|
235
|
-
|
|
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
|
-
|
|
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;
|