@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.
- package/README.md +92 -17
- package/package.json +6 -9
- 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/components/AdvancedConfigTab.js +1 -1
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/index.js +87 -10
- package/public/js/local.js +20 -10
- package/public/js/pr.js +67 -39
- package/public/js/repo-links.js +11 -3
- package/public/js/utils/analyze-params.js +69 -0
- package/public/js/utils/provider-model.js +67 -2
- 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/ai/analyzer.js +4 -4
- package/src/ai/antigravity-provider.js +594 -0
- package/src/ai/index.js +1 -1
- package/src/ai/opencode-provider.js +1 -1
- package/src/ai/provider.js +5 -5
- package/src/ai/stream-parser.js +1 -52
- package/src/chat/acp-bridge.js +1 -1
- package/src/chat/chat-providers.js +1 -9
- package/src/config.js +153 -31
- package/src/database.js +128 -4
- 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 +203 -37
- package/src/routes/config.js +49 -14
- 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/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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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' });
|
package/src/routes/local.js
CHANGED
|
@@ -49,7 +49,7 @@ const {
|
|
|
49
49
|
activeAnalyses,
|
|
50
50
|
localReviewDiffs,
|
|
51
51
|
reviewToAnalysisId,
|
|
52
|
-
|
|
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
|
-
//
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
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
|
}
|
package/src/routes/setup.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|
-
|
|
72
|
-
|
|
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
|
}
|