@in-the-loop-labs/pair-review 4.0.0 → 4.1.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 +82 -7
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +3 -4
- package/plugin-pair-loop/.claude-plugin/plugin.json +19 -0
- package/plugin-pair-loop/skills/loop/SKILL.md +233 -0
- package/public/js/index.js +87 -10
- package/public/js/local.js +19 -9
- package/public/js/pr.js +64 -36
- package/public/js/repo-links.js +11 -3
- package/public/js/utils/analyze-params.js +69 -0
- package/public/js/utils/provider-model.js +66 -1
- package/public/js/vendor/pierre-diffs-worker.js +16158 -0
- package/public/js/vendor/pierre-diffs.js +1880 -0
- package/public/local.html +1 -0
- package/public/pr.html +1 -0
- package/public/setup.html +35 -16
- package/src/config.js +150 -28
- package/src/database.js +127 -3
- package/src/external/github-adapter.js +18 -3
- package/src/github/client.js +37 -0
- package/src/github/parser.js +41 -7
- package/src/interactive-analysis-config.js +2 -2
- package/src/links/repo-links.js +66 -28
- package/src/local-review.js +134 -5
- package/src/local-scope.js +38 -0
- package/src/main.js +199 -33
- package/src/routes/config.js +47 -12
- package/src/routes/external-comments.js +13 -1
- package/src/routes/github-collections.js +175 -13
- package/src/routes/local.js +11 -20
- package/src/routes/pr.js +63 -36
- package/src/routes/setup.js +63 -8
- package/src/routes/shared.js +85 -0
- package/src/routes/stack-analysis.js +39 -3
- package/src/server.js +74 -3
- package/src/setup/local-setup.js +23 -7
- package/src/setup/pr-setup.js +237 -39
- package/src/setup/stack-setup.js +7 -2
- package/src/single-port.js +73 -18
- package/src/utils/host-resolution.js +157 -0
- package/plugin-code-critic/skills/loop/SKILL.md +0 -373
package/src/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const { v4: uuidv4 } = require('uuid');
|
|
4
|
-
const { loadConfig, getConfigDir,
|
|
4
|
+
const { loadConfig, getConfigDir, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills, buildCouncilProviderOverrides } = require('./config');
|
|
5
5
|
const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository, CouncilRepository, CommentRepository, AnalysisRunRepository } = require('./database');
|
|
6
6
|
const { PRArgumentParser } = require('./github/parser');
|
|
7
7
|
const { GitHubClient } = require('./github/client');
|
|
@@ -20,8 +20,9 @@ const { resolveReviewConfig } = require('./review-config');
|
|
|
20
20
|
const { redirectConsoleToStderr } = require('./mcp-stdio');
|
|
21
21
|
const { getChangedFiles } = require('./routes/executable-analysis');
|
|
22
22
|
const { safeParseJson } = require('./utils/safe-parse-json');
|
|
23
|
-
const { includesBranch } = require('./local-scope');
|
|
24
|
-
const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
|
|
23
|
+
const { includesBranch, parseScopeArg, VALID_SCOPE_RANGES } = require('./local-scope');
|
|
24
|
+
const { storePRData, resolvePrHostBinding, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
|
|
25
|
+
const { isDualHostRepo, resolvePreflightBinding, hostSetupParamValue } = require('./utils/host-resolution');
|
|
25
26
|
const { fireReviewStartedHook } = require('./hooks/payloads');
|
|
26
27
|
const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
|
|
27
28
|
const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
|
|
@@ -190,6 +191,21 @@ OPTIONS:
|
|
|
190
191
|
-h, --help Show this help message and exit
|
|
191
192
|
-l, --local [path] Review local uncommitted changes
|
|
192
193
|
Optional path defaults to current directory
|
|
194
|
+
--scope <start>..<end> (Local mode only) Set the diff scope for the review.
|
|
195
|
+
Stops, in order: branch, staged, unstaged, untracked.
|
|
196
|
+
The range must include 'unstaged'. Valid ranges:
|
|
197
|
+
branch..unstaged committed+staged+unstaged vs merge-base
|
|
198
|
+
branch..untracked everything vs merge-base (incl. untracked)
|
|
199
|
+
staged..unstaged staged + unstaged vs HEAD
|
|
200
|
+
staged..untracked staged + unstaged + untracked vs HEAD
|
|
201
|
+
unstaged..unstaged only unstaged working-tree changes
|
|
202
|
+
unstaged..untracked unstaged + untracked (the default)
|
|
203
|
+
Requires --local; cannot be used with a PR. The chosen
|
|
204
|
+
scope is persisted so the web UI opens with it too.
|
|
205
|
+
--base <branch> (Local mode only) Override base-branch detection for a
|
|
206
|
+
branch-relative scope. Requires --scope starting at
|
|
207
|
+
'branch' (e.g. --scope branch..untracked). Defaults to
|
|
208
|
+
the persisted, then auto-detected, base branch.
|
|
193
209
|
--mcp Start as an MCP stdio server for AI coding agents.
|
|
194
210
|
The web UI also starts for the human reviewer.
|
|
195
211
|
--model <name> Override the AI model. Claude Code is the default provider.
|
|
@@ -200,6 +216,13 @@ OPTIONS:
|
|
|
200
216
|
sonnet-5-high, sonnet-4.6
|
|
201
217
|
(opus is Opus 4.8 XHigh, the default)
|
|
202
218
|
or use provider-specific models with Antigravity/Codex
|
|
219
|
+
--provider <name> Override the AI provider for headless modes
|
|
220
|
+
(--ai-draft / --ai-review). Defaults to the
|
|
221
|
+
repo/app default provider (claude). Pair with
|
|
222
|
+
--model when the model belongs to a non-default
|
|
223
|
+
provider (e.g. --provider codex --model gpt-5.5).
|
|
224
|
+
Available: claude, antigravity, codex, copilot,
|
|
225
|
+
opencode, cursor-agent, pi
|
|
203
226
|
--council <handle> Run analysis with a saved council (multi-voice). Handle is a
|
|
204
227
|
council name, name-slug, or id (prefix). See --list-councils.
|
|
205
228
|
--list-councils List saved councils (handles to use with --council) and exit
|
|
@@ -216,6 +239,9 @@ EXAMPLES:
|
|
|
216
239
|
pair-review 123 # Review PR #123 in current repo
|
|
217
240
|
pair-review https://github.com/owner/repo/pull/456
|
|
218
241
|
pair-review --local # Review uncommitted local changes
|
|
242
|
+
pair-review --local --scope branch..untracked # Review whole branch vs merge-base
|
|
243
|
+
pair-review --local --scope staged..unstaged # Review staged + unstaged changes
|
|
244
|
+
pair-review --local --scope branch..untracked --base develop # Branch scope vs develop
|
|
219
245
|
pair-review 123 --ai # Auto-run AI analysis
|
|
220
246
|
pair-review --ai-review # CI mode: auto-detect PR, submit review
|
|
221
247
|
pair-review 123 --ai-draft --council security-review # Headless council draft
|
|
@@ -232,6 +258,7 @@ ENVIRONMENT VARIABLES:
|
|
|
232
258
|
PAIR_REVIEW_ANTIGRAVITY_CMD Custom command to invoke Antigravity CLI (default: agy)
|
|
233
259
|
PAIR_REVIEW_CODEX_CMD Custom command to invoke Codex CLI (default: codex)
|
|
234
260
|
PAIR_REVIEW_MODEL Override the AI model (same as --model flag, default: opus)
|
|
261
|
+
PAIR_REVIEW_PROVIDER Override the AI provider (same as --provider flag, default: claude)
|
|
235
262
|
|
|
236
263
|
CONFIGURATION:
|
|
237
264
|
Config file: ~/.pair-review/config.json
|
|
@@ -395,6 +422,7 @@ const KNOWN_FLAGS = new Set([
|
|
|
395
422
|
'-l', '--local',
|
|
396
423
|
'--mcp',
|
|
397
424
|
'--model',
|
|
425
|
+
'--provider',
|
|
398
426
|
'--council',
|
|
399
427
|
'--list-councils',
|
|
400
428
|
'--register',
|
|
@@ -469,6 +497,14 @@ function parseArgs(args) {
|
|
|
469
497
|
} else {
|
|
470
498
|
throw new Error('--model flag requires a model name (e.g., --model sonnet)');
|
|
471
499
|
}
|
|
500
|
+
} else if (arg === '--provider') {
|
|
501
|
+
// Next argument is the provider name
|
|
502
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
503
|
+
flags.provider = args[i + 1];
|
|
504
|
+
i++; // Skip next argument since we consumed it
|
|
505
|
+
} else {
|
|
506
|
+
throw new Error('--provider flag requires a provider name (e.g., --provider codex)');
|
|
507
|
+
}
|
|
472
508
|
} else if (arg === '--council') {
|
|
473
509
|
// Next argument is the council handle (name, name-slug, or id prefix)
|
|
474
510
|
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
@@ -477,6 +513,25 @@ function parseArgs(args) {
|
|
|
477
513
|
} else {
|
|
478
514
|
throw new Error('--council flag requires a council handle (e.g., --council my-council)');
|
|
479
515
|
}
|
|
516
|
+
} else if (arg === '--scope') {
|
|
517
|
+
// Next argument is a scope range like `unstaged..untracked`. A range
|
|
518
|
+
// won't legitimately start with '-', so apply the same missing-value
|
|
519
|
+
// guard as --model. Range validity is checked later in main().
|
|
520
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
521
|
+
flags.scope = args[i + 1];
|
|
522
|
+
i++; // Skip next argument since we consumed it
|
|
523
|
+
} else {
|
|
524
|
+
throw new Error('--scope flag requires a range value (e.g., --scope unstaged..untracked)');
|
|
525
|
+
}
|
|
526
|
+
} else if (arg === '--base') {
|
|
527
|
+
// Next argument is a base branch name. A branch name won't legitimately
|
|
528
|
+
// start with '-', so apply the same guard as --model.
|
|
529
|
+
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
|
530
|
+
flags.base = args[i + 1];
|
|
531
|
+
i++; // Skip next argument since we consumed it
|
|
532
|
+
} else {
|
|
533
|
+
throw new Error('--base flag requires a branch name (e.g., --base main)');
|
|
534
|
+
}
|
|
480
535
|
} else if (arg === '--list-councils') {
|
|
481
536
|
flags.listCouncils = true;
|
|
482
537
|
} else if (arg === '-l' || arg === '--local') {
|
|
@@ -515,6 +570,32 @@ function parseArgs(args) {
|
|
|
515
570
|
return { prArgs, flags };
|
|
516
571
|
}
|
|
517
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Fold the PAIR_REVIEW_PROVIDER / PAIR_REVIEW_MODEL env vars into parsed flags.
|
|
575
|
+
*
|
|
576
|
+
* `--provider`/`--model` and their env-var twins are two channels for the same
|
|
577
|
+
* override, but only the flags are threaded through single-port delegation
|
|
578
|
+
* (`buildDelegationUrl` forwards `flags.provider`/`flags.model`). Without this
|
|
579
|
+
* fold, an env-only invocation (`PAIR_REVIEW_PROVIDER=codex pair-review 123
|
|
580
|
+
* --ai`) delegates with an empty `flags.provider` and the override is silently
|
|
581
|
+
* dropped at the process boundary. Normalizing env→flags once here gives every
|
|
582
|
+
* downstream reader — delegation, the flags→env mirror, headless resolvers — a
|
|
583
|
+
* single effective value. Explicit flags always win over env.
|
|
584
|
+
*
|
|
585
|
+
* @param {object} flags - Parsed CLI flags (mutated in place)
|
|
586
|
+
* @param {object} [env] - Environment source (defaults to process.env; injectable for tests)
|
|
587
|
+
* @returns {object} the same `flags`, for chaining
|
|
588
|
+
*/
|
|
589
|
+
function normalizeProviderModelFlags(flags, env = process.env) {
|
|
590
|
+
if (!flags.provider && env.PAIR_REVIEW_PROVIDER) {
|
|
591
|
+
flags.provider = env.PAIR_REVIEW_PROVIDER;
|
|
592
|
+
}
|
|
593
|
+
if (!flags.model && env.PAIR_REVIEW_MODEL) {
|
|
594
|
+
flags.model = env.PAIR_REVIEW_MODEL;
|
|
595
|
+
}
|
|
596
|
+
return flags;
|
|
597
|
+
}
|
|
598
|
+
|
|
518
599
|
/**
|
|
519
600
|
* Main application entry point
|
|
520
601
|
*/
|
|
@@ -586,6 +667,7 @@ ENVIRONMENT VARIABLES:
|
|
|
586
667
|
PAIR_REVIEW_ANTIGRAVITY_CMD Custom Antigravity CLI command (default: agy)
|
|
587
668
|
PAIR_REVIEW_CODEX_CMD Custom Codex CLI command (default: codex)
|
|
588
669
|
PAIR_REVIEW_MODEL Default AI model (e.g., opus, sonnet, haiku)
|
|
670
|
+
PAIR_REVIEW_PROVIDER Default AI provider (e.g., claude, antigravity, codex)
|
|
589
671
|
PAIR_REVIEW_DB_NAME Custom database filename (overrides config)
|
|
590
672
|
|
|
591
673
|
LOCAL CONFIG:
|
|
@@ -656,6 +738,10 @@ AI PROVIDERS:
|
|
|
656
738
|
// single-port delegation can skip DB entirely)
|
|
657
739
|
const { prArgs, flags } = parseArgs(args);
|
|
658
740
|
|
|
741
|
+
// Normalize env→flags before delegation so an env-only provider/model
|
|
742
|
+
// override rides the single-port delegation URL to the running server.
|
|
743
|
+
normalizeProviderModelFlags(flags);
|
|
744
|
+
|
|
659
745
|
// Headless-mode flag validation (fail fast, before any DB/server work).
|
|
660
746
|
// --instructions and --instructions-file are mutually exclusive.
|
|
661
747
|
if (flags.instructions && flags.instructionsFile) {
|
|
@@ -669,6 +755,39 @@ AI PROVIDERS:
|
|
|
669
755
|
if (flags.headless && prArgs.length === 0 && !flags.local) {
|
|
670
756
|
throw new Error('--headless flag requires a pull request number/URL or --local to run analysis.');
|
|
671
757
|
}
|
|
758
|
+
// --scope and --base are local-review diff controls. They require --local
|
|
759
|
+
// and cannot be combined with a PR positional. They do NOT imply --local
|
|
760
|
+
// (explicit flags only — no magic mode switching).
|
|
761
|
+
if (flags.scope || flags.base) {
|
|
762
|
+
if (prArgs.length > 0) {
|
|
763
|
+
throw new Error('--scope/--base apply to local reviews only; they cannot be combined with a pull request number/URL.');
|
|
764
|
+
}
|
|
765
|
+
if (!flags.local) {
|
|
766
|
+
throw new Error('--scope/--base require --local (they set the diff scope for a local review).');
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
// Validate the --scope range against the six accepted local-review scopes.
|
|
770
|
+
let parsedScopeFlag = null;
|
|
771
|
+
if (flags.scope) {
|
|
772
|
+
parsedScopeFlag = parseScopeArg(flags.scope);
|
|
773
|
+
if (!parsedScopeFlag) {
|
|
774
|
+
throw new Error(
|
|
775
|
+
`Invalid --scope value "${flags.scope}". Valid ranges are: ${VALID_SCOPE_RANGES.join(', ')}. ` +
|
|
776
|
+
"The range must be two stops joined by '..' and must include 'unstaged'."
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// --base overrides base-branch detection and only applies to a
|
|
781
|
+
// branch-relative scope (one that starts at 'branch').
|
|
782
|
+
if (flags.base) {
|
|
783
|
+
if (!parsedScopeFlag || parsedScopeFlag.start !== 'branch') {
|
|
784
|
+
throw new Error("--base requires --scope with a branch-relative range (starting at 'branch', e.g. --scope branch..untracked).");
|
|
785
|
+
}
|
|
786
|
+
// Same validation the set-scope route uses to prevent shell injection.
|
|
787
|
+
if (!/^[\w.\-/]+$/.test(flags.base)) {
|
|
788
|
+
throw new Error(`Invalid --base branch name "${flags.base}".`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
672
791
|
// --instructions[-file] only takes effect in a mode that actually runs
|
|
673
792
|
// analysis. Plain interactive mode never auto-analyzes, so reject the
|
|
674
793
|
// orphaned case with a clear message rather than silently dropping the text
|
|
@@ -767,6 +886,11 @@ AI PROVIDERS:
|
|
|
767
886
|
// Skipped for: headless modes (no browser), single_port: false (dev mode).
|
|
768
887
|
// --headless never delegates/opens a browser — it runs analysis locally and
|
|
769
888
|
// reports to stdout/stderr.
|
|
889
|
+
//
|
|
890
|
+
// --scope/--base ARE delegated: attemptDelegation carries them on the local
|
|
891
|
+
// URL (buildDelegationUrl), the setup page relays them, and the setup route
|
|
892
|
+
// re-validates and applies them — so a scoped --local while a server is
|
|
893
|
+
// running lands at the requested scope instead of crashing on the busy port.
|
|
770
894
|
if (config.single_port !== false && !flags.aiReview && !flags.aiDraft && !flags.headless) {
|
|
771
895
|
// Carry --instructions across delegation. When an analyzing mode
|
|
772
896
|
// (--ai/--council) ALSO supplies per-run instructions and a server is
|
|
@@ -954,7 +1078,12 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
|
|
|
954
1078
|
// captured `${owner}/${repo}` for monorepo-style patterns.
|
|
955
1079
|
const repositoryForBinding = prInfo.bindingRepository
|
|
956
1080
|
|| normalizeRepository(prInfo.owner, prInfo.repo);
|
|
957
|
-
|
|
1081
|
+
// A pasted URL tells us the host up front (alt-host `url_pattern` match →
|
|
1082
|
+
// api_host string; github/graphite URL → null), so preflight the token
|
|
1083
|
+
// against that host. A bare number leaves host undefined → ambiguity rule,
|
|
1084
|
+
// but a dual repo with only an alt token is still usable (the setup probe
|
|
1085
|
+
// tries the alt host first) — resolvePreflightBinding tolerates that.
|
|
1086
|
+
const binding = resolvePreflightBinding(repositoryForBinding, config, prInfo.host);
|
|
958
1087
|
if (!binding.token) {
|
|
959
1088
|
throw new Error(buildMissingTokenError({
|
|
960
1089
|
owner: prInfo.owner,
|
|
@@ -977,6 +1106,13 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
|
|
|
977
1106
|
process.env.PAIR_REVIEW_MODEL = flags.model;
|
|
978
1107
|
}
|
|
979
1108
|
|
|
1109
|
+
// Set provider override if provided via CLI flag. Mirrored into the env
|
|
1110
|
+
// var so the web/UI analysis paths (which read PAIR_REVIEW_PROVIDER via
|
|
1111
|
+
// getProvider()) honor it too, matching how --model works.
|
|
1112
|
+
if (flags.provider) {
|
|
1113
|
+
process.env.PAIR_REVIEW_PROVIDER = flags.provider;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
980
1116
|
// Resolve the council handle FAIL-FAST before starting the server, so a bad
|
|
981
1117
|
// handle surfaces as a clean error (caught below) instead of after the
|
|
982
1118
|
// browser has opened.
|
|
@@ -1011,6 +1147,15 @@ async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = n
|
|
|
1011
1147
|
url += `&council=${councilSelection.id}`;
|
|
1012
1148
|
}
|
|
1013
1149
|
|
|
1150
|
+
// Thread the pasted URL's host to setup so it binds directly instead of
|
|
1151
|
+
// probing (shared mapping — see hostSetupParamValue): an alt-host string
|
|
1152
|
+
// forwards the api_host; a github URL on a DUAL repo forwards the 'github'
|
|
1153
|
+
// sentinel; bare numbers / plain / exclusive repos omit it.
|
|
1154
|
+
const hostParam = hostSetupParamValue(prInfo.host, isDualHostRepo(config, repositoryForBinding));
|
|
1155
|
+
if (hostParam) {
|
|
1156
|
+
url += (url.includes('?') ? '&' : '?') + `host=${encodeURIComponent(hostParam)}`;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1014
1159
|
console.log(`Opening browser to: ${url}`);
|
|
1015
1160
|
await open(url);
|
|
1016
1161
|
|
|
@@ -1107,29 +1252,34 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
1107
1252
|
// serves many monorepo-shaped URLs.
|
|
1108
1253
|
const repositoryForBinding = prInfo.bindingRepository
|
|
1109
1254
|
|| normalizeRepository(prInfo.owner, prInfo.repo);
|
|
1110
|
-
|
|
1111
|
-
|
|
1255
|
+
// Preflight the token so a missing credential fails fast before any network
|
|
1256
|
+
// work. Tolerates a dual repo with only an alt token when the host is
|
|
1257
|
+
// unknown (the setup probe tries the alt host first).
|
|
1258
|
+
const preflightBinding = resolvePreflightBinding(repositoryForBinding, config, prInfo.host);
|
|
1259
|
+
if (!preflightBinding.token) {
|
|
1112
1260
|
throw new Error(buildMissingTokenError({
|
|
1113
1261
|
owner: prInfo.owner,
|
|
1114
1262
|
repo: prInfo.repo,
|
|
1115
1263
|
bindingRepository: repositoryForBinding,
|
|
1116
|
-
apiHost:
|
|
1264
|
+
apiHost: preflightBinding.apiHost
|
|
1117
1265
|
}));
|
|
1118
1266
|
}
|
|
1119
|
-
const githubToken = headlessBinding.token;
|
|
1120
1267
|
|
|
1121
1268
|
console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in ${options.modeLabel}`);
|
|
1122
1269
|
|
|
1123
|
-
//
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
if (!repoExists) {
|
|
1127
|
-
throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
// Fetch PR data from GitHub
|
|
1270
|
+
// Resolve the per-PR host binding and fetch the PR. Applies host precedence
|
|
1271
|
+
// (URL-paste host → stored host → ambiguity/probe for dual repos) and
|
|
1272
|
+
// returns the chosen binding so its host is stamped by storePRData below.
|
|
1131
1273
|
console.log('Fetching pull request data from GitHub...');
|
|
1132
|
-
const prData = await
|
|
1274
|
+
const { binding: headlessBinding, prData, githubClient } = await resolvePrHostBinding({
|
|
1275
|
+
db, config, bindingRepository: repositoryForBinding,
|
|
1276
|
+
owner: prInfo.owner, repo: prInfo.repo, prNumber: prInfo.number,
|
|
1277
|
+
// Only forward a github.com token as the github fallback; if the preflight
|
|
1278
|
+
// resolved an ALT binding (dual repo, alt-only token), there is no github
|
|
1279
|
+
// fallback token, so pass '' rather than sending the alt token to github.com.
|
|
1280
|
+
host: prInfo.host, githubToken: preflightBinding.apiHost === null ? preflightBinding.token : ''
|
|
1281
|
+
});
|
|
1282
|
+
const githubToken = headlessBinding.token || preflightBinding.token;
|
|
1133
1283
|
|
|
1134
1284
|
let worktreePath;
|
|
1135
1285
|
let diff;
|
|
@@ -1290,7 +1440,8 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
1290
1440
|
// Store PR data in database
|
|
1291
1441
|
console.log('Storing pull request data...');
|
|
1292
1442
|
const { isNewReview, reviewId: storedReviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
|
|
1293
|
-
skipWorktreeRecord: !!flags.useCheckout
|
|
1443
|
+
skipWorktreeRecord: !!flags.useCheckout,
|
|
1444
|
+
host: headlessBinding.host
|
|
1294
1445
|
});
|
|
1295
1446
|
|
|
1296
1447
|
// Persist review→worktree mapping in DB for pool usage tracking
|
|
@@ -1340,10 +1491,13 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
1340
1491
|
// The early councilSelection above already fail-fast validated an explicit
|
|
1341
1492
|
// --council; resolveReviewConfig re-resolves the same handle (cheap in-memory
|
|
1342
1493
|
// match) and unifies single/council selection with the headless path.
|
|
1494
|
+
// --provider (flags.provider) rides in as an explicit single-model pick; the
|
|
1495
|
+
// resolver's ladder (explicit › PAIR_REVIEW_PROVIDER › repo › config › 'claude')
|
|
1496
|
+
// delivers the CLI override that commit 662e introduced.
|
|
1343
1497
|
const reviewConfig = await resolveReviewConfig(
|
|
1344
1498
|
db,
|
|
1345
1499
|
repository,
|
|
1346
|
-
{ council: flags.council, model: flags.model },
|
|
1500
|
+
{ council: flags.council, provider: flags.provider, model: flags.model },
|
|
1347
1501
|
config
|
|
1348
1502
|
);
|
|
1349
1503
|
|
|
@@ -1736,26 +1890,30 @@ async function preparePrHeadless(db, config, flags, prArgs, externalPoolLifecycl
|
|
|
1736
1890
|
|
|
1737
1891
|
const repositoryForBinding = prInfo.bindingRepository
|
|
1738
1892
|
|| normalizeRepository(prInfo.owner, prInfo.repo);
|
|
1739
|
-
|
|
1740
|
-
|
|
1893
|
+
// Preflight the token so a missing credential fails fast before any network
|
|
1894
|
+
// work. Tolerates a dual repo with only an alt token when the host is unknown
|
|
1895
|
+
// (the setup probe tries the alt host first).
|
|
1896
|
+
const preflightBinding = resolvePreflightBinding(repositoryForBinding, config, prInfo.host);
|
|
1897
|
+
if (!preflightBinding.token) {
|
|
1741
1898
|
throw new Error(buildMissingTokenError({
|
|
1742
1899
|
owner: prInfo.owner,
|
|
1743
1900
|
repo: prInfo.repo,
|
|
1744
1901
|
bindingRepository: repositoryForBinding,
|
|
1745
|
-
apiHost:
|
|
1902
|
+
apiHost: preflightBinding.apiHost
|
|
1746
1903
|
}));
|
|
1747
1904
|
}
|
|
1748
1905
|
|
|
1749
1906
|
console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in headless mode`);
|
|
1750
1907
|
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
if (!repoExists) {
|
|
1754
|
-
throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1908
|
+
// Resolve the per-PR host binding and fetch (host precedence: URL-paste host
|
|
1909
|
+
// → stored host → ambiguity/probe for dual repos).
|
|
1757
1910
|
console.log('Fetching pull request data from GitHub...');
|
|
1758
|
-
const prData = await
|
|
1911
|
+
const { binding: headlessBinding, prData, githubClient } = await resolvePrHostBinding({
|
|
1912
|
+
db, config, bindingRepository: repositoryForBinding,
|
|
1913
|
+
owner: prInfo.owner, repo: prInfo.repo, prNumber: prInfo.number,
|
|
1914
|
+
// Only forward a github.com token as the github fallback (see performHeadlessReview).
|
|
1915
|
+
host: prInfo.host, githubToken: preflightBinding.apiHost === null ? preflightBinding.token : ''
|
|
1916
|
+
});
|
|
1759
1917
|
|
|
1760
1918
|
let worktreePath;
|
|
1761
1919
|
let diff;
|
|
@@ -1903,7 +2061,8 @@ async function preparePrHeadless(db, config, flags, prArgs, externalPoolLifecycl
|
|
|
1903
2061
|
|
|
1904
2062
|
console.log('Storing pull request data...');
|
|
1905
2063
|
const { isNewReview, reviewId: storedReviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
|
|
1906
|
-
skipWorktreeRecord: !!flags.useCheckout
|
|
2064
|
+
skipWorktreeRecord: !!flags.useCheckout,
|
|
2065
|
+
host: headlessBinding.host
|
|
1907
2066
|
});
|
|
1908
2067
|
|
|
1909
2068
|
if (poolWorktreeId && poolLifecycle) {
|
|
@@ -1934,10 +2093,17 @@ async function preparePrHeadless(db, config, flags, prArgs, externalPoolLifecycl
|
|
|
1934
2093
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1935
2094
|
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
1936
2095
|
|
|
2096
|
+
// Thread --provider (flags.provider) through as an explicit single-model pick,
|
|
2097
|
+
// exactly as the PR-submit path at the top of the file and the local headless
|
|
2098
|
+
// path do. handleHeadlessAnalysis dispatches BEFORE the local/PR split (and thus
|
|
2099
|
+
// before handlePullRequest's PAIR_REVIEW_PROVIDER env mirror), so omitting it
|
|
2100
|
+
// here would let `pair-review <pr> --headless --provider <p>` silently fall
|
|
2101
|
+
// through to the default provider (or, with a repo default council, switch to
|
|
2102
|
+
// council mode) — while --model in the same invocation was honored.
|
|
1937
2103
|
const reviewConfig = await resolveReviewConfig(
|
|
1938
2104
|
db,
|
|
1939
2105
|
repository,
|
|
1940
|
-
{ council: flags.council, model: flags.model },
|
|
2106
|
+
{ council: flags.council, provider: flags.provider, model: flags.model },
|
|
1941
2107
|
config
|
|
1942
2108
|
);
|
|
1943
2109
|
|
|
@@ -2113,7 +2279,7 @@ async function handleHeadlessAnalysis(prArgs, config, db, flags, poolLifecycle)
|
|
|
2113
2279
|
const reviewConfig = await resolveReviewConfig(
|
|
2114
2280
|
db,
|
|
2115
2281
|
repository,
|
|
2116
|
-
{ council: flags.council, model: flags.model },
|
|
2282
|
+
{ council: flags.council, provider: flags.provider, model: flags.model },
|
|
2117
2283
|
config
|
|
2118
2284
|
);
|
|
2119
2285
|
const cliProvider = reviewConfig.type === 'single' ? reviewConfig.provider : null;
|
|
@@ -2503,4 +2669,4 @@ if (require.main === module) {
|
|
|
2503
2669
|
main();
|
|
2504
2670
|
}
|
|
2505
2671
|
|
|
2506
|
-
module.exports = { main, parseArgs, detectPRFromGitHubEnvironment, printCouncilList, runHeadlessAnalysis, recordEmptyScopeRun, buildHeadlessJson, buildHeadlessErrorJson, resolveCliInstructions };
|
|
2672
|
+
module.exports = { main, parseArgs, normalizeProviderModelFlags, detectPRFromGitHubEnvironment, printCouncilList, handleHeadlessAnalysis, runHeadlessAnalysis, recordEmptyScopeRun, buildHeadlessJson, buildHeadlessErrorJson, resolveCliInstructions };
|
package/src/routes/config.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const express = require('express');
|
|
13
|
-
const { RepoSettingsRepository, ReviewRepository, queryOne } = require('../database');
|
|
13
|
+
const { RepoSettingsRepository, ReviewRepository, PRMetadataRepository, queryOne } = require('../database');
|
|
14
14
|
const {
|
|
15
15
|
getAllProvidersInfo,
|
|
16
16
|
testProviderAvailability,
|
|
@@ -21,12 +21,12 @@ const {
|
|
|
21
21
|
modelMatches
|
|
22
22
|
} = require('../ai');
|
|
23
23
|
const { normalizeRepository } = require('../utils/paths');
|
|
24
|
+
const { resolvePreflightBinding } = require('../utils/host-resolution');
|
|
24
25
|
const {
|
|
25
26
|
isRunningViaNpx,
|
|
26
27
|
getGitHubToken,
|
|
27
28
|
getDefaultProvider,
|
|
28
29
|
getDefaultModel,
|
|
29
|
-
resolveHostBinding,
|
|
30
30
|
resolveBindingRepositoryFromPR,
|
|
31
31
|
getSummaryEnabled,
|
|
32
32
|
getSummaryAutoGenerate,
|
|
@@ -82,10 +82,11 @@ router.get('/runtime-config.js', (req, res) => {
|
|
|
82
82
|
* - has_global_github_token: always present. True iff `getGitHubToken(config)`
|
|
83
83
|
* resolves a token from the top-level config / env (no repo context).
|
|
84
84
|
* - has_github_token: present ONLY when both ?owner and ?repo query
|
|
85
|
-
* parameters are supplied. True iff
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
85
|
+
* parameters are supplied. True iff ANY usable binding for that specific
|
|
86
|
+
* repository resolves a token (`resolvePreflightBinding`) — this covers
|
|
87
|
+
* repo-scoped `token`, `token_command`, alt-host bindings, a DUAL repo whose
|
|
88
|
+
* only credential is the alt-host token, AND falls through to the global
|
|
89
|
+
* lookup. Callers that know which repo
|
|
89
90
|
* they're rendering should pass these params so that repo-scoped
|
|
90
91
|
* authentication is reflected accurately (e.g. for deciding whether
|
|
91
92
|
* to enable GitHub-comment dedup). When the params are absent, the
|
|
@@ -146,10 +147,14 @@ router.get('/api/config', (req, res) => {
|
|
|
146
147
|
let hasRepoGithubToken = null;
|
|
147
148
|
if (hasRepoContext) {
|
|
148
149
|
const repository = `${owner}/${repo}`;
|
|
149
|
-
//
|
|
150
|
-
// no repo-scoped token is configured, so
|
|
151
|
-
// "repo-scoped binding works" OR "global token
|
|
152
|
-
|
|
150
|
+
// "Has any usable binding" semantics. resolveHostBinding already falls
|
|
151
|
+
// through to top-level config when no repo-scoped token is configured, so
|
|
152
|
+
// the no-host lookup unions "repo-scoped binding works" OR "global token
|
|
153
|
+
// works". But for a DUAL repo the no-host lookup uses the github ambiguity
|
|
154
|
+
// binding, which misses a repo whose only credential is the alt-host token —
|
|
155
|
+
// resolvePreflightBinding additionally tries the alt candidate, so an
|
|
156
|
+
// alt-only dual repo reports true (matching what setup/probe will do).
|
|
157
|
+
hasRepoGithubToken = Boolean(resolvePreflightBinding(repository, config, undefined).token);
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
// Only return safe configuration values (not secrets like github_token)
|
|
@@ -163,6 +168,16 @@ router.get('/api/config', (req, res) => {
|
|
|
163
168
|
comment_format: config.comment_format || 'legacy',
|
|
164
169
|
default_provider: defaultPair.provider,
|
|
165
170
|
default_model: defaultPair.model,
|
|
171
|
+
// CLI/env override (PAIR_REVIEW_PROVIDER / PAIR_REVIEW_MODEL, set by
|
|
172
|
+
// `--provider` / `--model`). Surfaced as a DEDICATED signal — not folded
|
|
173
|
+
// into default_provider/default_model above — because every frontend seed
|
|
174
|
+
// site resolves `resolveProviderModelPair([repoSettings, appConfig])` with
|
|
175
|
+
// repo settings FIRST. Folding the override into appConfig would still lose
|
|
176
|
+
// to a repo's saved default, violating the documented `CLI/env > repo
|
|
177
|
+
// settings` contract. The frontend prepends this override ahead of
|
|
178
|
+
// repoSettings via buildProviderModelScopes(). null when unset.
|
|
179
|
+
provider_override: process.env.PAIR_REVIEW_PROVIDER || null,
|
|
180
|
+
model_override: process.env.PAIR_REVIEW_MODEL || null,
|
|
166
181
|
// Include npx detection for frontend command examples
|
|
167
182
|
is_running_via_npx: isRunningViaNpx(),
|
|
168
183
|
enable_chat: config.enable_chat !== false,
|
|
@@ -301,8 +316,14 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
301
316
|
* `javascript:` URLs stripped). The url_template is NOT substituted here —
|
|
302
317
|
* the frontend has the live PR/branch context, so it performs the
|
|
303
318
|
* whitelisted substitution at render time.
|
|
319
|
+
*
|
|
320
|
+
* Optional `?number=<n>` query: the PR number the header is being rendered
|
|
321
|
+
* for. For a dual-host repo (`api_host` + `exclusive: false`) the link set
|
|
322
|
+
* depends on which system the PR lives on, so we look up the PR's stored host
|
|
323
|
+
* and resolve links against it. Omitted (Local mode, plain/exclusive repos)
|
|
324
|
+
* or an unknown PR → repo-level defaults, byte-identical to before.
|
|
304
325
|
*/
|
|
305
|
-
router.get('/api/repos/:owner/:repo/links', (req, res) => {
|
|
326
|
+
router.get('/api/repos/:owner/:repo/links', async (req, res) => {
|
|
306
327
|
try {
|
|
307
328
|
const { owner, repo } = req.params;
|
|
308
329
|
const repository = normalizeRepository(owner, repo);
|
|
@@ -311,7 +332,21 @@ router.get('/api/repos/:owner/:repo/links', (req, res) => {
|
|
|
311
332
|
// `repos[...]` entry serving many captured owner/repo via
|
|
312
333
|
// url_pattern) surface the right link config.
|
|
313
334
|
const bindingRepository = resolveBindingRepositoryFromPR(owner, repo, config);
|
|
314
|
-
|
|
335
|
+
|
|
336
|
+
// Per-PR host awareness: when the caller names a PR, look up its stored
|
|
337
|
+
// host so a dual-host repo shows the correct link set (github links for a
|
|
338
|
+
// github-hosted PR, the external link for an alt-hosted one). A missing
|
|
339
|
+
// row leaves `host` undefined, which resolveRepoLinks treats as the
|
|
340
|
+
// repo-level default — so non-dual repos are unaffected regardless.
|
|
341
|
+
let host;
|
|
342
|
+
const prNumber = parseInt(req.query.number, 10);
|
|
343
|
+
if (Number.isInteger(prNumber) && prNumber > 0) {
|
|
344
|
+
const prMetadataRepo = new PRMetadataRepository(req.app.get('db'));
|
|
345
|
+
const storedHost = await prMetadataRepo.getPRHost(repository, prNumber);
|
|
346
|
+
if (storedHost !== undefined) host = storedHost;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const links = resolveRepoLinks(config, bindingRepository, host);
|
|
315
350
|
res.json({ repository, links });
|
|
316
351
|
} catch (error) {
|
|
317
352
|
logger.error('Error resolving repo links:', error);
|
|
@@ -20,6 +20,7 @@ const express = require('express');
|
|
|
20
20
|
const {
|
|
21
21
|
ExternalCommentRepository,
|
|
22
22
|
ReviewRepository,
|
|
23
|
+
PRMetadataRepository,
|
|
23
24
|
withTransaction
|
|
24
25
|
} = require('../database');
|
|
25
26
|
const { getAdapter } = require('../external');
|
|
@@ -135,6 +136,17 @@ async function executeSync({ db, config, review, source, _deps }) {
|
|
|
135
136
|
);
|
|
136
137
|
}
|
|
137
138
|
|
|
139
|
+
// Look up the PR's stored host so a DUAL repo's alt-hosted PR binds to the
|
|
140
|
+
// alt host (and its line-based anchoring path) rather than api.github.com.
|
|
141
|
+
// Pass the raw stored value through to the adapter, which applies the
|
|
142
|
+
// legacy-NULL convention against its resolved binding key. `undefined`
|
|
143
|
+
// (no row / unknown) preserves the two-arg ambiguity behaviour.
|
|
144
|
+
let storedHost;
|
|
145
|
+
if (Number.isInteger(review.pr_number)) {
|
|
146
|
+
const prMetadataRepo = new PRMetadataRepository(db);
|
|
147
|
+
storedHost = await prMetadataRepo.getPRHost(review.repository, review.pr_number);
|
|
148
|
+
}
|
|
149
|
+
|
|
138
150
|
// Delegate credential resolution to the adapter so the route stays
|
|
139
151
|
// source-agnostic and each adapter can name its own env var in errors.
|
|
140
152
|
// Thread `review.repository` through so the adapter resolves the
|
|
@@ -145,7 +157,7 @@ async function executeSync({ db, config, review, source, _deps }) {
|
|
|
145
157
|
// `isAltHost` reflects whether the resolved binding targets an alternate
|
|
146
158
|
// Git host. Alt-hosts don't implement GitHub's deprecated `position`
|
|
147
159
|
// field, so it drives line-based anchoring in `mapComment` below.
|
|
148
|
-
const { client, isAltHost } = adapter.resolveCredentials(config || {}, review.repository, _deps);
|
|
160
|
+
const { client, isAltHost } = adapter.resolveCredentials(config || {}, review.repository, _deps, { storedHost });
|
|
149
161
|
|
|
150
162
|
const apiRows = await adapter.fetchComments({
|
|
151
163
|
client,
|