@in-the-loop-labs/pair-review 3.9.1 → 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.
Files changed (53) hide show
  1. package/README.md +92 -17
  2. package/package.json +6 -9
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +3 -4
  5. package/plugin-pair-loop/.claude-plugin/plugin.json +19 -0
  6. package/plugin-pair-loop/skills/loop/SKILL.md +233 -0
  7. package/public/js/components/AdvancedConfigTab.js +1 -1
  8. package/public/js/components/AnalysisConfigModal.js +2 -2
  9. package/public/js/index.js +87 -10
  10. package/public/js/local.js +20 -10
  11. package/public/js/pr.js +67 -39
  12. package/public/js/repo-links.js +11 -3
  13. package/public/js/utils/analyze-params.js +69 -0
  14. package/public/js/utils/provider-model.js +67 -2
  15. package/public/js/vendor/pierre-diffs-worker.js +16158 -0
  16. package/public/js/vendor/pierre-diffs.js +1880 -0
  17. package/public/local.html +1 -0
  18. package/public/pr.html +1 -0
  19. package/public/setup.html +35 -16
  20. package/src/ai/analyzer.js +4 -4
  21. package/src/ai/antigravity-provider.js +594 -0
  22. package/src/ai/index.js +1 -1
  23. package/src/ai/opencode-provider.js +1 -1
  24. package/src/ai/provider.js +5 -5
  25. package/src/ai/stream-parser.js +1 -52
  26. package/src/chat/acp-bridge.js +1 -1
  27. package/src/chat/chat-providers.js +1 -9
  28. package/src/config.js +153 -31
  29. package/src/database.js +128 -4
  30. package/src/external/github-adapter.js +18 -3
  31. package/src/github/client.js +37 -0
  32. package/src/github/parser.js +41 -7
  33. package/src/interactive-analysis-config.js +2 -2
  34. package/src/links/repo-links.js +66 -28
  35. package/src/local-review.js +134 -5
  36. package/src/local-scope.js +38 -0
  37. package/src/main.js +203 -37
  38. package/src/routes/config.js +49 -14
  39. package/src/routes/external-comments.js +13 -1
  40. package/src/routes/github-collections.js +175 -13
  41. package/src/routes/local.js +11 -20
  42. package/src/routes/pr.js +63 -36
  43. package/src/routes/setup.js +63 -8
  44. package/src/routes/shared.js +85 -0
  45. package/src/routes/stack-analysis.js +39 -3
  46. package/src/server.js +74 -3
  47. package/src/setup/local-setup.js +23 -7
  48. package/src/setup/pr-setup.js +237 -39
  49. package/src/setup/stack-setup.js +7 -2
  50. package/src/single-port.js +73 -18
  51. package/src/utils/host-resolution.js +157 -0
  52. package/plugin-code-critic/skills/loop/SKILL.md +0 -373
  53. package/src/ai/gemini-provider.js +0 -752
@@ -16,12 +16,15 @@
16
16
  const express = require('express');
17
17
  const { query, run, withTransaction } = require('../database');
18
18
  const { GitHubClient } = require('../github/client');
19
- const { getGitHubToken } = require('../config');
19
+ const { getGitHubToken, resolveHostBinding } = require('../config');
20
20
  const logger = require('../utils/logger');
21
21
 
22
22
  const router = express.Router();
23
23
 
24
- const SELECT_COLUMNS = 'owner, repo, number, title, author, updated_at, html_url, state, fetched_at';
24
+ // `host` is additive: NULL for github.com rows, the repo's `api_host` URL for
25
+ // alt-host rows. Both GET and the refresh re-read echo it so the frontend can
26
+ // open a PR against the system it actually lives on without re-probing.
27
+ const SELECT_COLUMNS = 'owner, repo, number, title, author, updated_at, html_url, state, fetched_at, host';
25
28
 
26
29
  // Valid `org/team` slug: two non-empty segments of GitHub-allowed characters.
27
30
  // Used to guard against query injection when interpolating the team into the
@@ -40,11 +43,18 @@ const TEAM_SLUG_PATTERN = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
40
43
  * namespaced cache entry for a query whose results are identical to the
41
44
  * unfiltered view.
42
45
  */
46
+ // Each definition also carries a `classifyAlt(pr, login, team)` predicate that
47
+ // buckets a REST-listed alt-host PR into this collection. Alt-hosts generally
48
+ // have no Search API, so the collection semantics that `buildQuery` expresses
49
+ // as a github.com search string are re-expressed here as a local predicate
50
+ // over the fields `client.listOpenPullRequests` returns. `pr.requested_teams`
51
+ // holds team *slugs*; `team`, when set, is a validated `org/team` slug.
43
52
  const COLLECTIONS = [
44
53
  {
45
54
  name: 'review-requests',
46
55
  label: 'review requests',
47
- buildQuery: (login) => `is:pr is:open archived:false user-review-requested:${login}`
56
+ buildQuery: (login) => `is:pr is:open archived:false user-review-requested:${login}`,
57
+ classifyAlt: (pr, login) => pr.requested_reviewers.includes(login)
48
58
  },
49
59
  {
50
60
  name: 'team-reviews',
@@ -63,15 +73,128 @@ const COLLECTIONS = [
63
73
  return `is:pr is:open archived:false team-review-requested:${team}`;
64
74
  }
65
75
  return `is:pr is:open archived:false review-requested:${login} -user-review-requested:${login}`;
76
+ },
77
+ // A specific `team` filters to PRs requesting that team's slug (the part
78
+ // after the slash, since REST `requested_teams` carries bare slugs, not
79
+ // `org/team`); the all-teams view keeps the same "exclude direct requests"
80
+ // rule as the github.com search so a directly-requested PR shows under
81
+ // review-requests, not here.
82
+ classifyAlt: (pr, login, team) => {
83
+ if (pr.requested_teams.length === 0) return false;
84
+ if (team) {
85
+ const slug = team.slice(team.indexOf('/') + 1);
86
+ return pr.requested_teams.includes(slug);
87
+ }
88
+ return !pr.requested_reviewers.includes(login);
66
89
  }
67
90
  },
68
91
  {
69
92
  name: 'my-prs',
70
93
  label: 'your pull requests',
71
- buildQuery: (login) => `is:pr is:open archived:false author:${login}`
94
+ buildQuery: (login) => `is:pr is:open archived:false author:${login}`,
95
+ classifyAlt: (pr, login) => pr.author === login
72
96
  }
73
97
  ];
74
98
 
99
+ /**
100
+ * Derive an `owner/repo` pair from a `config.repos` key. Alt-host config
101
+ * entries are keyed by the canonical `owner/repo`; monorepo-style entries can
102
+ * be keyed by something else and matched to PRs via `url_pattern`, but the
103
+ * collections sweep needs a concrete owner/repo to call `pulls.list`, so those
104
+ * keys are skipped rather than guessed at.
105
+ * @param {string} repoKey - A `config.repos` key.
106
+ * @returns {{ owner: string, repo: string }|null} Null when not owner/repo shaped.
107
+ */
108
+ function ownerRepoFromKey(repoKey) {
109
+ if (typeof repoKey !== 'string') return null;
110
+ const parts = repoKey.split('/');
111
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return null;
112
+ return { owner: parts[0], repo: parts[1] };
113
+ }
114
+
115
+ /**
116
+ * Sweep every alt-host repo in config for open PRs belonging to `collection`,
117
+ * classifying each with `def.classifyAlt`. Best-effort per host: a failure for
118
+ * one repo is captured in the returned `hosts` array and never thrown, so the
119
+ * github.com results already gathered by the caller are never lost. Rows are
120
+ * stamped with the repo's `api_host` string for the `host` column.
121
+ *
122
+ * `credentialedRepoCount` counts alt-host repos that resolved a non-empty
123
+ * token (i.e. could authenticate), regardless of whether the subsequent fetch
124
+ * then succeeded. The refresh route uses it to decide whether ANY source can
125
+ * authenticate before returning 401 on an install with no github.com token.
126
+ *
127
+ * @param {Object} config - Server config (from loadConfig()).
128
+ * @param {Object} def - The collection definition (needs `classifyAlt`).
129
+ * @param {string|null} team - Validated `org/team` filter, or null.
130
+ * @returns {Promise<{ rows: Array<Object>, hosts: Array<{host: string, repo: string, ok: boolean, error?: string}>, credentialedRepoCount: number }>}
131
+ */
132
+ async function sweepAltHosts(config, def, team) {
133
+ const rows = [];
134
+ const hosts = [];
135
+ let credentialedRepoCount = 0;
136
+ const repos = (config && config.repos) || {};
137
+ if (typeof def.classifyAlt !== 'function') {
138
+ return { rows, hosts, credentialedRepoCount };
139
+ }
140
+
141
+ // One `GET /user` per (host, token) per refresh — multiple repos can share a
142
+ // host, and a login lookup per repo would be wasteful. Keyed by token too so
143
+ // distinct credentials on the same host resolve their own identity.
144
+ const loginCache = new Map();
145
+
146
+ for (const [repoKey, repoEntry] of Object.entries(repos)) {
147
+ if (!repoEntry || typeof repoEntry !== 'object') continue;
148
+ const apiHost = (typeof repoEntry.api_host === 'string' && repoEntry.api_host) ? repoEntry.api_host : null;
149
+ if (!apiHost) continue;
150
+
151
+ const ownerRepo = ownerRepoFromKey(repoKey);
152
+ if (!ownerRepo) {
153
+ logger.warn(`Collections: skipping alt-host repo "${repoKey}" (${apiHost}) — key is not an owner/repo pair, cannot derive a repo for pulls.list`);
154
+ hosts.push({ host: apiHost, repo: repoKey, ok: false, error: 'config key is not an owner/repo pair' });
155
+ continue;
156
+ }
157
+
158
+ try {
159
+ const binding = resolveHostBinding(repoKey, config, { host: apiHost });
160
+
161
+ // A repo with `api_host` but no repo-scoped token yields an empty-token
162
+ // binding. Probing anyway would 401 once per collection per refresh and
163
+ // spam error logs; surface it as a per-host status instead (debug, not
164
+ // error — it's a config gap, not a runtime failure).
165
+ if (!binding.token) {
166
+ logger.debug(`Collections: skipping alt-host repo "${repoKey}" (${apiHost}) — no repo-scoped credentials configured`);
167
+ hosts.push({ host: apiHost, repo: repoKey, ok: false, error: 'no credentials configured' });
168
+ continue;
169
+ }
170
+ credentialedRepoCount++;
171
+
172
+ const client = new GitHubClient(binding);
173
+
174
+ const loginKey = `${apiHost}\u0000${binding.token}`;
175
+ let login = loginCache.get(loginKey);
176
+ if (login === undefined) {
177
+ const user = await client.getAuthenticatedUser();
178
+ login = user.login;
179
+ loginCache.set(loginKey, login);
180
+ }
181
+
182
+ const prs = await client.listOpenPullRequests(ownerRepo.owner, ownerRepo.repo);
183
+ for (const pr of prs) {
184
+ if (def.classifyAlt(pr, login, team)) {
185
+ rows.push({ ...pr, host: apiHost });
186
+ }
187
+ }
188
+ hosts.push({ host: apiHost, repo: repoKey, ok: true });
189
+ } catch (error) {
190
+ logger.error(`Collections: alt-host refresh failed for ${repoKey} (${apiHost}): ${error.message}`);
191
+ hosts.push({ host: apiHost, repo: repoKey, ok: false, error: error.message });
192
+ }
193
+ }
194
+
195
+ return { rows, hosts, credentialedRepoCount };
196
+ }
197
+
75
198
  /**
76
199
  * Derive the cache storage key for a collection, namespacing by team so a
77
200
  * filtered view never clobbers the all-teams cache. Both the GET (read) and
@@ -161,17 +284,40 @@ function registerCollection(def) {
161
284
  }
162
285
 
163
286
  const config = req.app.get('config');
287
+ const db = req.app.get('db');
288
+
289
+ // The dashboard has two independent PR sources: the github.com search
290
+ // (top-level token) and the per-repo alt-host sweep. Treat them
291
+ // independently so an alt-host-only install (repo-scoped alt credentials,
292
+ // NO global github token) can still refresh. A missing github token skips
293
+ // ONLY the github branch; a per-source status is recorded instead.
164
294
  // Cross-repo search on github.com — no per-repo binding applies here.
165
295
  // Explicit `undefined` repository selects the no-repo (top-level) path.
166
296
  const githubToken = getGitHubToken(config, undefined);
167
- if (!githubToken) {
168
- return res.status(401).json({ success: false, error: 'GitHub token not configured' });
297
+ const sourceStatuses = [];
298
+ let prs = [];
299
+ if (githubToken) {
300
+ const client = new GitHubClient(githubToken);
301
+ const user = await client.getAuthenticatedUser();
302
+ prs = await client.searchPullRequests(buildQuery(user.login, { team }));
303
+ } else {
304
+ // host:null, repo:null identifies the github.com cross-repo source
305
+ // (mirrors the NULL-host = github.com convention used for cache rows).
306
+ sourceStatuses.push({ host: null, repo: null, ok: false, error: 'no github.com token configured' });
169
307
  }
170
308
 
171
- const db = req.app.get('db');
172
- const client = new GitHubClient(githubToken);
173
- const user = await client.getAuthenticatedUser();
174
- const prs = await client.searchPullRequests(buildQuery(user.login, { team }));
309
+ // Alt-host repos (both exclusive and dual) have no Search API, so sweep
310
+ // them via REST and classify locally. Best-effort: a failing host is
311
+ // reported in `hosts` and never aborts the github.com rows below. Done
312
+ // BEFORE the transaction so a slow alt host doesn't hold a write lock.
313
+ const { rows: altRows, hosts, credentialedRepoCount } = await sweepAltHosts(config, def, team);
314
+
315
+ // Only 401 when NO source can authenticate at all: no github token AND no
316
+ // alt-host repo with credentials. If either can authenticate, proceed and
317
+ // report the unavailable source in the response.
318
+ if (!githubToken && credentialedRepoCount === 0) {
319
+ return res.status(401).json({ success: false, error: 'GitHub token not configured' });
320
+ }
175
321
 
176
322
  // Namespace the cache so a filtered view never clobbers the all-teams
177
323
  // cache. Every distinct team string the user tries creates its own cached
@@ -179,18 +325,34 @@ function registerCollection(def) {
179
325
  // home-page feature.
180
326
  const key = cacheKey(name, team);
181
327
  await withTransaction(db, async () => {
328
+ // DELETE-then-INSERT clears prior github AND alt rows for this key, so a
329
+ // retry re-derives the whole view rather than duplicating. github rows
330
+ // stamp host NULL; alt rows carry their api_host string.
182
331
  await run(db, 'DELETE FROM github_pr_cache WHERE collection = ?', [key]);
183
332
  for (const pr of prs) {
184
333
  await run(db,
185
- 'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
186
- [pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, key]
334
+ 'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection, host) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
335
+ [pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, key, null]
336
+ );
337
+ }
338
+ for (const pr of altRows) {
339
+ await run(db,
340
+ 'INSERT INTO github_pr_cache (owner, repo, number, title, author, updated_at, html_url, state, collection, host) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
341
+ [pr.owner, pr.repo, pr.number, pr.title, pr.author, pr.updated_at, pr.html_url, pr.state, key, pr.host]
187
342
  );
188
343
  }
189
344
  });
190
345
 
191
346
  const rows = await getCachedRows(db, key);
192
347
  const fetchedAt = rows.length > 0 ? rows[0].fetched_at : null;
193
- res.json({ success: true, prs: rows, fetched_at: fetchedAt });
348
+ // `hosts` is additive; omit it entirely when every source is healthy (no
349
+ // alt-host repos and a working github token) so the github-only happy-path
350
+ // response shape is byte-identical to before. A skipped github source or
351
+ // any alt-host status makes it appear.
352
+ const allStatuses = sourceStatuses.concat(hosts);
353
+ const payload = { success: true, prs: rows, fetched_at: fetchedAt };
354
+ if (allStatuses.length > 0) payload.hosts = allStatuses;
355
+ res.json(payload);
194
356
  } catch (error) {
195
357
  if (error.status === 401 || error.status === 403) {
196
358
  return res.status(401).json({ success: false, error: 'GitHub token is invalid or expired' });
@@ -49,7 +49,7 @@ const {
49
49
  activeAnalyses,
50
50
  localReviewDiffs,
51
51
  reviewToAnalysisId,
52
- getModel,
52
+ resolveProviderModel,
53
53
  determineCompletionInfo,
54
54
  broadcastProgress,
55
55
  CancellationError,
@@ -1412,6 +1412,9 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1412
1412
  // wins and falls through to the single-provider path unchanged below.
1413
1413
  // For repos with no default_council_id the resolver returns type:'single' and
1414
1414
  // we fall through, so single-provider behavior is byte-identical to before.
1415
+ // (A CLI --provider override arrives here as a populated requestProvider —
1416
+ // the frontend forces the single-provider path when an override is active —
1417
+ // so this council branch is correctly skipped for delegated overrides.)
1415
1418
  if (!requestProvider && !requestModel) {
1416
1419
  const reviewConfig = await resolveReviewConfig(
1417
1420
  db,
@@ -1439,25 +1442,13 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
1439
1442
  }
1440
1443
  }
1441
1444
 
1442
- // Determine provider: request body > repo settings > config > default ('claude')
1443
- let selectedProvider;
1444
- if (requestProvider) {
1445
- selectedProvider = requestProvider;
1446
- } else if (repoSettings && repoSettings.default_provider) {
1447
- selectedProvider = repoSettings.default_provider;
1448
- } else {
1449
- selectedProvider = appConfig.default_provider || appConfig.provider || 'claude';
1450
- }
1451
-
1452
- // Determine model: request body > repo settings > config/CLI > default
1453
- let selectedModel;
1454
- if (requestModel) {
1455
- selectedModel = requestModel;
1456
- } else if (repoSettings && repoSettings.default_model) {
1457
- selectedModel = repoSettings.default_model;
1458
- } else {
1459
- selectedModel = getModel(req);
1460
- }
1445
+ // Resolve provider/model: request body > env/CLI > repo settings > config/legacy > default.
1446
+ // Shared with the PR route (src/routes/pr.js) so both paths resolve identically.
1447
+ const { provider: selectedProvider, model: selectedModel } = resolveProviderModel(req, {
1448
+ requestProvider,
1449
+ requestModel,
1450
+ repoSettings
1451
+ });
1461
1452
 
1462
1453
  // Get repo instructions from settings
1463
1454
  const repoInstructions = repoSettings?.default_instructions || null;
package/src/routes/pr.js CHANGED
@@ -27,6 +27,7 @@ const { v4: uuidv4 } = require('uuid');
27
27
  const fs = require('fs').promises;
28
28
  const path = require('path');
29
29
  const { resolveHostBinding, resolveBindingRepositoryFromPR, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch, getSummaryEnabled, getTourEnabled } = require('../config');
30
+ const { storedHostToOption, isDualHostRepo } = require('../utils/host-resolution');
30
31
  const { resolveHostName } = require('../links/repo-links');
31
32
  const { backgroundQueue } = require('../ai/background-queue');
32
33
  const logger = require('../utils/logger');
@@ -42,7 +43,7 @@ const tourGenerator = require('../ai/tour-generator');
42
43
  const {
43
44
  activeAnalyses,
44
45
  reviewToAnalysisId,
45
- getModel,
46
+ resolveProviderModel,
46
47
  determineCompletionInfo,
47
48
  broadcastProgress,
48
49
  createProgressCallback,
@@ -130,12 +131,12 @@ module.exports._attachHunkHashes = attachHunkHashes;
130
131
  *
131
132
  * @param {import('express').Request} req
132
133
  * @param {string} repository - "owner/repo" identifier
133
- * @returns {{ binding: {apiHost: string|null, token: string, features: Object, source: string}, token: string }|null}
134
+ * @returns {Promise<{ binding: {apiHost: string|null, host: string|null, token: string, features: Object, source: string}, token: string, bindingRepository: string }|null>}
134
135
  * `null` when no token can be resolved AND the repo is github.com (so
135
136
  * callers can fall through to optional behaviour). Throws for alt-host
136
- * misconfiguration.
137
+ * misconfiguration and for a stale stored host that no longer matches config.
137
138
  */
138
- function resolveBindingForRequest(req, repository) {
139
+ async function resolveBindingForRequest(req, repository) {
139
140
  const config = req.app.get('config') || {};
140
141
  // `repository` here is the PR-identity `${owner}/${repo}`. For
141
142
  // monorepo-style configs the binding-key in `config.repos` can differ;
@@ -143,7 +144,23 @@ function resolveBindingForRequest(req, repository) {
143
144
  // the host binding so per-repo tokens/api_host/features apply.
144
145
  const [owner, repo] = String(repository).split('/');
145
146
  const bindingRepository = resolveBindingRepositoryFromPR(owner, repo, config);
146
- const binding = resolveHostBinding(bindingRepository, config);
147
+
148
+ // PR-aware host resolution. Every caller is an `/:owner/:repo/:number` route,
149
+ // so a per-PR host may already be stored. When a pr_metadata row exists, bind
150
+ // to its stored host (github for NULL, the api_host string for an alt host);
151
+ // when no row exists, fall back to the ambiguity rule (two-arg resolve). A
152
+ // stale stored host (config changed) makes resolveHostBinding throw — that
153
+ // surfaces to the route's error handler as a clear failure rather than a
154
+ // silent bind to a dead host.
155
+ const prNumber = parseInt(req.params && req.params.number, 10);
156
+ let hostOptions;
157
+ if (Number.isInteger(prNumber) && prNumber > 0) {
158
+ const prMetadataRepo = new PRMetadataRepository(req.app.get('db'));
159
+ const storedHost = await prMetadataRepo.getPRHost(repository, prNumber);
160
+ // Apply the legacy-NULL back-compat convention (see storedHostToOption).
161
+ hostOptions = storedHostToOption(config, bindingRepository, storedHost);
162
+ }
163
+ const binding = resolveHostBinding(bindingRepository, config, hostOptions || {});
147
164
  if (binding.token) {
148
165
  return { binding, token: binding.token, bindingRepository };
149
166
  }
@@ -343,7 +360,7 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
343
360
  {
344
361
  let resolved = null;
345
362
  try {
346
- resolved = resolveBindingForRequest(req, repository);
363
+ resolved = await resolveBindingForRequest(req, repository);
347
364
  } catch (configErr) {
348
365
  // Alt-host repo with no token configured — surface the message
349
366
  // but don't fail the GET (draft info is supplementary).
@@ -383,7 +400,7 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
383
400
  {
384
401
  let resolved = null;
385
402
  try {
386
- resolved = resolveBindingForRequest(req, repository);
403
+ resolved = await resolveBindingForRequest(req, repository);
387
404
  } catch (configErr) {
388
405
  logger.warn(configErr.message);
389
406
  }
@@ -542,7 +559,7 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
542
559
  // Resolve host binding for this repo (validates alt-host token presence).
543
560
  let binding;
544
561
  try {
545
- const resolved = resolveBindingForRequest(req, repository);
562
+ const resolved = await resolveBindingForRequest(req, repository);
546
563
  if (!resolved) {
547
564
  return res.status(401).json({ error: 'GitHub token not configured' });
548
565
  }
@@ -653,7 +670,7 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
653
670
  {
654
671
  let resolved = null;
655
672
  try {
656
- resolved = resolveBindingForRequest(req, repository);
673
+ resolved = await resolveBindingForRequest(req, repository);
657
674
  } catch (configErr) {
658
675
  logger.warn(configErr.message);
659
676
  }
@@ -915,7 +932,7 @@ router.get('/api/pr/:owner/:repo/:number/check-stale', async (req, res) => {
915
932
  // Fetch current PR from GitHub
916
933
  let binding;
917
934
  try {
918
- const resolved = resolveBindingForRequest(req, repository);
935
+ const resolved = await resolveBindingForRequest(req, repository);
919
936
  if (!resolved) {
920
937
  return res.json({ isStale: null, error: 'GitHub token not configured' });
921
938
  }
@@ -987,7 +1004,7 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
987
1004
  // Initialize GitHub client and check for pending drafts on GitHub
988
1005
  let binding;
989
1006
  try {
990
- const resolved = resolveBindingForRequest(req, repository);
1007
+ const resolved = await resolveBindingForRequest(req, repository);
991
1008
  if (!resolved) {
992
1009
  return res.status(500).json({
993
1010
  error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
@@ -1467,7 +1484,7 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1467
1484
  // Resolve host binding (repo-aware: alt-host repos require their own token).
1468
1485
  let binding;
1469
1486
  try {
1470
- const resolved = resolveBindingForRequest(req, repository);
1487
+ const resolved = await resolveBindingForRequest(req, repository);
1471
1488
  if (!resolved) {
1472
1489
  return res.status(500).json({
1473
1490
  error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
@@ -1747,9 +1764,12 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1747
1764
  // Send success response after all database operations complete.
1748
1765
  // Use the configured remote-host display name (e.g. "Meteorite")
1749
1766
  // instead of the literal "GitHub". Resolve via the binding repository
1750
- // so monorepo url_pattern configs map to the right repos[...] entry.
1767
+ // so monorepo url_pattern configs map to the right repos[...] entry, and
1768
+ // pass the PR's resolved host (`binding.host`: null=github, api_host
1769
+ // string=alt) so a dual-host repo names the system this PR was submitted
1770
+ // to rather than its repo-level default.
1751
1771
  const cfg = req.app.get('config') || {};
1752
- const hostName = resolveHostName(cfg, resolveBindingRepositoryFromPR(owner, repo, cfg));
1772
+ const hostName = resolveHostName(cfg, resolveBindingRepositoryFromPR(owner, repo, cfg), binding.host);
1753
1773
  res.json({
1754
1774
  success: true,
1755
1775
  message: `${event === 'DRAFT' ? 'Draft review created' : 'Review submitted'} successfully ${event === 'DRAFT' ? 'on' : 'to'} ${hostName}`,
@@ -1946,11 +1966,28 @@ router.post('/api/parse-pr-url', (req, res) => {
1946
1966
  const result = parser.parsePRUrl(url);
1947
1967
 
1948
1968
  if (result) {
1969
+ // Report whether the resolved repo is DUAL (github + alt-host). A github URL
1970
+ // (host === null) for a dual repo must be forwarded to setup as an explicit
1971
+ // github pick (the "github" sentinel) so it does not probe alt-first; for a
1972
+ // plain or exclusive repo the client omits host (no probe / avoids the
1973
+ // exclusive-null throw). Resolve the binding key first (url_pattern match
1974
+ // supplies it; otherwise derive from owner/repo).
1975
+ const safeConfig = config || {};
1976
+ const bindingRepository = result.bindingRepository
1977
+ || resolveBindingRepositoryFromPR(result.owner, result.repo, safeConfig);
1949
1978
  return res.json({
1950
1979
  valid: true,
1951
1980
  owner: result.owner,
1952
1981
  repo: result.repo,
1953
- prNumber: result.number
1982
+ prNumber: result.number,
1983
+ // `host` tells the setup call which system the PR lives on: `null` =
1984
+ // github.com (a github/graphite URL), an api_host URL string = that alt
1985
+ // host (a `url_pattern` match), or absent when the parser could not tell.
1986
+ // `bindingRepository` is the matched `repos[...]` config key (may differ
1987
+ // from owner/repo for monorepo-style url_pattern configs).
1988
+ host: result.host,
1989
+ bindingRepository: result.bindingRepository,
1990
+ isDualHost: isDualHostRepo(safeConfig, bindingRepository)
1954
1991
  });
1955
1992
  }
1956
1993
 
@@ -2067,7 +2104,7 @@ async function launchPrCouncilAnalysis(req, {
2067
2104
  // Build a GitHubClient for analyzer-side dedup pre-fetch (PR mode only).
2068
2105
  let councilGithubClient;
2069
2106
  try {
2070
- const resolved = resolveBindingForRequest(req, repository);
2107
+ const resolved = await resolveBindingForRequest(req, repository);
2071
2108
  councilGithubClient = resolved ? new GitHubClient(resolved.binding) : undefined;
2072
2109
  } catch (configErr) {
2073
2110
  logger.warn(`Skipping GitHub dedup pre-fetch (council): ${configErr.message}`);
@@ -2190,7 +2227,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
2190
2227
  // for github.com repos we fall back to the server-startup cached token.
2191
2228
  let analyzerGithubClient;
2192
2229
  try {
2193
- const resolved = resolveBindingForRequest(req, repository);
2230
+ const resolved = await resolveBindingForRequest(req, repository);
2194
2231
  analyzerGithubClient = resolved ? new GitHubClient(resolved.binding) : undefined;
2195
2232
  } catch (configErr) {
2196
2233
  // Alt-host misconfiguration — skip dedup pre-fetch with a clear log.
@@ -2205,23 +2242,13 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
2205
2242
  const repoSettingsRepo = new RepoSettingsRepository(db);
2206
2243
  const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
2207
2244
 
2208
- let selectedProvider;
2209
- if (requestProvider) {
2210
- selectedProvider = requestProvider;
2211
- } else if (fetchedRepoSettings && fetchedRepoSettings.default_provider) {
2212
- selectedProvider = fetchedRepoSettings.default_provider;
2213
- } else {
2214
- selectedProvider = appConfig.default_provider || appConfig.provider || 'claude';
2215
- }
2216
-
2217
- let selectedModel;
2218
- if (requestModel) {
2219
- selectedModel = requestModel;
2220
- } else if (fetchedRepoSettings && fetchedRepoSettings.default_model) {
2221
- selectedModel = fetchedRepoSettings.default_model;
2222
- } else {
2223
- selectedModel = getModel(req);
2224
- }
2245
+ // Resolve provider/model: request body > env/CLI > repo settings > config/legacy > default.
2246
+ // Shared with the local route (src/routes/local.js) so both paths resolve identically.
2247
+ const { provider: selectedProvider, model: selectedModel } = resolveProviderModel(req, {
2248
+ requestProvider,
2249
+ requestModel,
2250
+ repoSettings: fetchedRepoSettings
2251
+ });
2225
2252
 
2226
2253
  const fetchedRepoInstructions = fetchedRepoSettings?.default_instructions || null;
2227
2254
  const mergedInstructions = mergeInstructions({ globalInstructions, repoInstructions: fetchedRepoInstructions, requestInstructions });
@@ -2709,7 +2736,7 @@ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
2709
2736
  // an alt-host's `getAuthenticatedUser` resolves the user on that host.
2710
2737
  let sharedBy = null;
2711
2738
  try {
2712
- const resolved = resolveBindingForRequest(req, repository);
2739
+ const resolved = await resolveBindingForRequest(req, repository);
2713
2740
  if (resolved) {
2714
2741
  const githubClient = new GitHubClient(resolved.binding);
2715
2742
  const user = await githubClient.getAuthenticatedUser();
@@ -2843,7 +2870,7 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
2843
2870
 
2844
2871
  let binding;
2845
2872
  try {
2846
- const resolved = resolveBindingForRequest(req, repository);
2873
+ const resolved = await resolveBindingForRequest(req, repository);
2847
2874
  if (!resolved) {
2848
2875
  return res.json({ stack: [] });
2849
2876
  }
@@ -15,10 +15,12 @@ const crypto = require('crypto');
15
15
  const { activeSetups, broadcastSetupProgress } = require('./shared');
16
16
  const { setupPRReview } = require('../setup/pr-setup');
17
17
  const { setupLocalReview } = require('../setup/local-setup');
18
- const { getGitHubToken, expandPath, resolveBindingRepositoryFromPR } = require('../config');
18
+ const { expandPath, resolveBindingRepositoryFromPR } = require('../config');
19
+ const { resolvePreflightBinding } = require('../utils/host-resolution');
19
20
  const { queryOne, ReviewRepository } = require('../database');
20
21
  const { normalizeRepository } = require('../utils/paths');
21
22
  const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
23
+ const { parseScopeArg, VALID_SCOPE_RANGES } = require('../local-scope');
22
24
  const logger = require('../utils/logger');
23
25
 
24
26
  const router = express.Router();
@@ -60,18 +62,38 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
60
62
  return res.status(400).json({ error: 'Invalid owner, repo, or PR number' });
61
63
  }
62
64
 
65
+ // Optional per-PR host override. Contract: `null` = github.com, an api_host
66
+ // URL string = that alt host, absent/undefined = unknown (server derives via
67
+ // stored host or a probe). A dashboard row (alt-host PR) and a URL paste both
68
+ // send this so setup binds to the right system without probing. Reject other
69
+ // shapes; the "must match the repo's api_host" check is enforced downstream
70
+ // by resolveHostBinding (a mismatch surfaces as a setup error).
71
+ const bodyHost = req.body ? req.body.host : undefined;
72
+ if (bodyHost !== undefined && bodyHost !== null && typeof bodyHost !== 'string') {
73
+ return res.status(400).json({ error: 'Invalid host: expected null or an api_host URL string' });
74
+ }
75
+
63
76
  const db = req.app.get('db');
64
77
  const config = req.app.get('config');
65
78
 
66
- // GitHub token is required for PR setup. Resolve the binding key
67
- // first so monorepo-style `repos[...]` entries (matched via
68
- // `url_pattern` named captures) supply their per-repo token even when
69
- // the captured owner/repo differs from the config key.
79
+ // GitHub token is required for PR setup. Resolve the binding key first so
80
+ // monorepo-style `repos[...]` entries (matched via `url_pattern` named
81
+ // captures) supply their per-repo token even when the captured owner/repo
82
+ // differs from the config key.
70
83
  const repositoryForToken = resolveBindingRepositoryFromPR(owner, repo, config);
71
- const githubToken = getGitHubToken(config, repositoryForToken);
72
- if (!githubToken) {
84
+ // Preflight the credential. resolvePreflightBinding gates on ANY usable
85
+ // binding — including a dual repo's alt-only token or a token pinned by an
86
+ // explicit body host — so an alt-host setup isn't falsely 401'd.
87
+ const preflightBinding = resolvePreflightBinding(repositoryForToken, config, bodyHost);
88
+ if (!preflightBinding.token) {
73
89
  return res.status(401).json({ error: 'GitHub token not configured' });
74
90
  }
91
+ // Split the two roles: the gate above accepts an alt token, but only a
92
+ // github.com token may be forwarded downstream as the github FALLBACK.
93
+ // resolvePrHostBinding can drop `githubToken` into a github.com client on the
94
+ // 404 probe fallback (clientArgFor's github-flavored branch), so an alt token
95
+ // must NEVER reach it — the CLI (main.js) applies the identical guard.
96
+ const githubToken = preflightBinding.apiHost === null ? preflightBinding.token : '';
75
97
 
76
98
  // Concurrency guard: if a setup is already running for this PR, return its ID
77
99
  const setupKey = `pr:${owner}/${repo}/${prNumber}`;
@@ -149,6 +171,7 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
149
171
  githubToken,
150
172
  bindingRepository: repositoryForToken,
151
173
  config,
174
+ host: bodyHost,
152
175
  poolLifecycle: req.app.get('poolLifecycle'),
153
176
  restoreMetadata,
154
177
  onProgress: (progress) => {
@@ -186,7 +209,7 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
186
209
  */
187
210
  router.post('/api/setup/local', async (req, res) => {
188
211
  try {
189
- const { path: rawPath } = req.body;
212
+ const { path: rawPath, scope: rawScope, base: rawBase } = req.body;
190
213
 
191
214
  if (!rawPath) {
192
215
  return res.status(400).json({ error: 'Missing required field: path' });
@@ -198,6 +221,37 @@ router.post('/api/setup/local', async (req, res) => {
198
221
  return res.status(400).json({ error: err.message });
199
222
  }
200
223
 
224
+ // Re-validate scope/base server-side — NEVER trust the delegated URL. Mirrors
225
+ // the CLI checks in main() so a delegated launch is held to the same contract.
226
+ let flags = {};
227
+ if (rawScope !== undefined && rawScope !== null && rawScope !== '') {
228
+ const parsed = parseScopeArg(rawScope);
229
+ if (!parsed) {
230
+ return res.status(400).json({
231
+ error: `Invalid scope value "${rawScope}". Valid ranges are: ${VALID_SCOPE_RANGES.join(', ')}. ` +
232
+ "The range must be two stops joined by '..' and must include 'unstaged'."
233
+ });
234
+ }
235
+ flags.scope = rawScope;
236
+ if (rawBase !== undefined && rawBase !== null && rawBase !== '') {
237
+ // --base only applies to a branch-relative scope, and must be a safe branch name.
238
+ if (parsed.start !== 'branch') {
239
+ return res.status(400).json({
240
+ error: "base requires a branch-relative scope (starting at 'branch', e.g. branch..untracked)."
241
+ });
242
+ }
243
+ if (!/^[\w.\-/]+$/.test(rawBase)) {
244
+ return res.status(400).json({ error: `Invalid base branch name "${rawBase}".` });
245
+ }
246
+ flags.base = rawBase;
247
+ }
248
+ } else if (rawBase !== undefined && rawBase !== null && rawBase !== '') {
249
+ // base without scope is meaningless (mirrors main()'s "base requires branch scope").
250
+ return res.status(400).json({
251
+ error: "base requires a branch-relative scope (starting at 'branch', e.g. branch..untracked)."
252
+ });
253
+ }
254
+
201
255
  const targetPath = expandPath(rawPath);
202
256
  const db = req.app.get('db');
203
257
 
@@ -216,6 +270,7 @@ router.post('/api/setup/local', async (req, res) => {
216
270
  db,
217
271
  targetPath,
218
272
  config: req.app.get('config') || {},
273
+ flags,
219
274
  onProgress: (progress) => {
220
275
  sendSetupEvent(setupId, 'step', progress);
221
276
  }