@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/setup/pr-setup.js
CHANGED
|
@@ -11,13 +11,14 @@
|
|
|
11
11
|
* - setupPRReview: full orchestrator that wires the above together
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
const { run, queryOne, WorktreeRepository, RepoSettingsRepository, ReviewRepository } = require('../database');
|
|
14
|
+
const { run, queryOne, WorktreeRepository, RepoSettingsRepository, ReviewRepository, PRMetadataRepository } = require('../database');
|
|
15
15
|
const { GitWorktreeManager, MISSING_COMMIT_ERROR_CODE } = require('../git/worktree');
|
|
16
16
|
const { WorktreePoolLifecycle } = require('../git/worktree-pool-lifecycle');
|
|
17
17
|
const { GitHubClient } = require('../github/client');
|
|
18
18
|
const { normalizeRepository } = require('../utils/paths');
|
|
19
19
|
const { findMainGitRoot } = require('../local-review');
|
|
20
|
-
const { getConfigDir, getRepoPath, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveHostBinding, resolveBindingRepositoryFromPR, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
|
|
20
|
+
const { getConfigDir, getRepoPath, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveHostBinding, resolveBindingRepositoryFromPR, getRepoConfig, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
|
|
21
|
+
const { storedHostToOption, isDualHostRepoConfig } = require('../utils/host-resolution');
|
|
21
22
|
const logger = require('../utils/logger');
|
|
22
23
|
const { fireReviewStartedHook } = require('../hooks/payloads');
|
|
23
24
|
const simpleGit = require('simple-git');
|
|
@@ -38,14 +39,74 @@ const path = require('path');
|
|
|
38
39
|
* @param {string} worktreePath - Worktree (or checkout) path
|
|
39
40
|
* @param {Object} [options] - Optional settings
|
|
40
41
|
* @param {boolean} [options.skipWorktreeRecord] - Skip creating a worktree DB record
|
|
42
|
+
* @param {string|null} [options.host] - Per-PR host binding to stamp. When
|
|
43
|
+
* omitted (`undefined`), the host column is left untouched on UPDATE and
|
|
44
|
+
* written as NULL on INSERT. `null` (github.com) or an api_host URL string is
|
|
45
|
+
* written on both the UPDATE and INSERT arms — this is the self-healing step
|
|
46
|
+
* that stamps the host actually used to fetch the PR.
|
|
41
47
|
*/
|
|
42
48
|
async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, options = {}) {
|
|
43
49
|
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
50
|
+
// `undefined` means "caller doesn't know the host" — don't disturb any stored
|
|
51
|
+
// value on UPDATE and default to NULL (github.com) on INSERT. `null` or a URL
|
|
52
|
+
// string is an explicit host the caller wants persisted on both arms.
|
|
53
|
+
const writeHost = options.host !== undefined;
|
|
54
|
+
const hostValue = options.host;
|
|
44
55
|
|
|
45
56
|
// Begin transaction for atomic database operations
|
|
46
57
|
await run(db, 'BEGIN TRANSACTION');
|
|
47
58
|
|
|
48
59
|
try {
|
|
60
|
+
// Look up any existing row FIRST — before any mutation — so the cross-host
|
|
61
|
+
// collision guard below can reject cleanly (no partial write to roll back).
|
|
62
|
+
const existingPR = await queryOne(db, `
|
|
63
|
+
SELECT id, host, pr_data FROM pr_metadata WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
64
|
+
`, [prInfo.number, repository]);
|
|
65
|
+
|
|
66
|
+
// Cross-host collision guard.
|
|
67
|
+
//
|
|
68
|
+
// INVARIANT: a given (repository, pr_number) identifies exactly ONE pull
|
|
69
|
+
// request across ALL hosts. This is the confirmed plan assumption
|
|
70
|
+
// (plans/per-pr-host-resolution.md, "Assumption (confirmed)") — PR numbers do
|
|
71
|
+
// NOT collide between github.com and an alt host for the same repo — and it
|
|
72
|
+
// is why durable identity stays keyed UNIQUE(pr_number, repository) with no
|
|
73
|
+
// host column and the /pr/:owner/:repo/:number URL scheme is unchanged.
|
|
74
|
+
//
|
|
75
|
+
// If that assumption is ever violated (two DIFFERENT PRs share a number on
|
|
76
|
+
// two hosts), the UPDATE arm below would silently overwrite the first PR's
|
|
77
|
+
// pr_data and re-point its reviews/worktrees at the second. Detect it: when
|
|
78
|
+
// this fetch's host differs from the stored row's host, the API id must
|
|
79
|
+
// match. Same id → same logical PR, the host difference is the intended
|
|
80
|
+
// self-heal relabel → proceed. Different id → genuine cross-host duplicate →
|
|
81
|
+
// refuse rather than corrupt the wrong PR. Unparseable stored id → proceed
|
|
82
|
+
// but warn (can't prove either way; nothing to compare against).
|
|
83
|
+
if (existingPR && writeHost && existingPR.host !== hostValue) {
|
|
84
|
+
const incomingId = prData.id ?? prData.node_id ?? null;
|
|
85
|
+
let storedId = null;
|
|
86
|
+
try {
|
|
87
|
+
const storedData = existingPR.pr_data ? JSON.parse(existingPR.pr_data) : null;
|
|
88
|
+
if (storedData) storedId = storedData.id ?? storedData.node_id ?? null;
|
|
89
|
+
} catch {
|
|
90
|
+
storedId = null; // unparseable → treated as "unknown" below
|
|
91
|
+
}
|
|
92
|
+
const storedHostLabel = existingPR.host === null ? 'github.com' : existingPR.host;
|
|
93
|
+
const incomingHostLabel = hostValue === null ? 'github.com' : hostValue;
|
|
94
|
+
if (storedId === null) {
|
|
95
|
+
logger.warn(
|
|
96
|
+
`storePRData: stored pr_metadata for ${repository} #${prInfo.number} has no parseable API id; ` +
|
|
97
|
+
`proceeding with host relabel (${storedHostLabel} -> ${incomingHostLabel})`
|
|
98
|
+
);
|
|
99
|
+
} else if (incomingId !== null && String(storedId) !== String(incomingId)) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Cross-host PR conflict for ${repository} #${prInfo.number}: a DIFFERENT pull request with this ` +
|
|
102
|
+
`number is already stored on ${storedHostLabel}, but this fetch came from ${incomingHostLabel}. ` +
|
|
103
|
+
`pair-review assumes pull request numbers do not collide across hosts for a repository ` +
|
|
104
|
+
`(see plans/per-pr-host-resolution.md); reviewing two different PRs that share number ` +
|
|
105
|
+
`#${prInfo.number} on different hosts is not supported.`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
49
110
|
// Store or update worktree record (skip when using --use-checkout,
|
|
50
111
|
// since the path is the user's working directory, not a managed worktree)
|
|
51
112
|
if (!options.skipWorktreeRecord) {
|
|
@@ -67,19 +128,16 @@ async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath,
|
|
|
67
128
|
fetched_at: new Date().toISOString()
|
|
68
129
|
};
|
|
69
130
|
|
|
70
|
-
// First check if PR metadata exists
|
|
71
|
-
const existingPR = await queryOne(db, `
|
|
72
|
-
SELECT id FROM pr_metadata WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
73
|
-
`, [prInfo.number, repository]);
|
|
74
|
-
|
|
75
131
|
const now = new Date().toISOString();
|
|
76
132
|
|
|
77
133
|
if (existingPR) {
|
|
78
|
-
// Update existing PR metadata (preserves ID)
|
|
134
|
+
// Update existing PR metadata (preserves ID). The host column is only
|
|
135
|
+
// touched when the caller supplies one, so a plain re-fetch that doesn't
|
|
136
|
+
// know the host leaves any previously stamped value intact.
|
|
79
137
|
await run(db, `
|
|
80
138
|
UPDATE pr_metadata
|
|
81
139
|
SET title = ?, description = ?, author = ?,
|
|
82
|
-
base_branch = ?, head_branch = ?, pr_data =
|
|
140
|
+
base_branch = ?, head_branch = ?, pr_data = ?${writeHost ? ', host = ?' : ''},
|
|
83
141
|
updated_at = CURRENT_TIMESTAMP, last_accessed_at = ?
|
|
84
142
|
WHERE id = ?
|
|
85
143
|
`, [
|
|
@@ -89,16 +147,18 @@ async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath,
|
|
|
89
147
|
prData.base_branch,
|
|
90
148
|
prData.head_branch,
|
|
91
149
|
JSON.stringify(extendedPRData),
|
|
150
|
+
...(writeHost ? [hostValue] : []),
|
|
92
151
|
now,
|
|
93
152
|
existingPR.id
|
|
94
153
|
]);
|
|
95
154
|
logger.info(`Updated existing PR metadata (ID: ${existingPR.id})`);
|
|
96
155
|
} else {
|
|
97
|
-
// Insert new PR metadata
|
|
156
|
+
// Insert new PR metadata. When no host is supplied the column is omitted
|
|
157
|
+
// and defaults to NULL (github.com).
|
|
98
158
|
const result = await run(db, `
|
|
99
159
|
INSERT INTO pr_metadata
|
|
100
|
-
(pr_number, repository, title, description, author, base_branch, head_branch, pr_data, last_accessed_at)
|
|
101
|
-
VALUES (?, ?, ?, ?, ?, ?, ?,
|
|
160
|
+
(pr_number, repository, title, description, author, base_branch, head_branch, pr_data${writeHost ? ', host' : ''}, last_accessed_at)
|
|
161
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?${writeHost ? ', ?' : ''}, ?)
|
|
102
162
|
`, [
|
|
103
163
|
prInfo.number,
|
|
104
164
|
repository,
|
|
@@ -108,6 +168,7 @@ async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath,
|
|
|
108
168
|
prData.base_branch,
|
|
109
169
|
prData.head_branch,
|
|
110
170
|
JSON.stringify(extendedPRData),
|
|
171
|
+
...(writeHost ? [hostValue] : []),
|
|
111
172
|
now
|
|
112
173
|
]);
|
|
113
174
|
logger.info(`Created new PR metadata (ID: ${result.lastID})`);
|
|
@@ -173,6 +234,122 @@ async function storePRData(db, prInfo, prData, diff, changedFiles, worktreePath,
|
|
|
173
234
|
}
|
|
174
235
|
}
|
|
175
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Resolve the per-PR host binding and fetch the PR, applying the host
|
|
239
|
+
* precedence used by every PR-setup entry point (web setup route + CLI):
|
|
240
|
+
*
|
|
241
|
+
* 1. explicit `host` (URL paste / request body): `undefined` = unknown,
|
|
242
|
+
* `null` = github.com, `'<url>'` = an alt host. When defined,
|
|
243
|
+
* `resolveHostBinding` validates it and throws on a stale/mismatched host.
|
|
244
|
+
* 2. stored `pr_metadata.host` (a row exists → `getPRHost !== undefined`).
|
|
245
|
+
* 3. ambiguity rule. For a DUAL repo (`api_host` + `exclusive: false`) whose
|
|
246
|
+
* host is still unknown, PROBE: fetch from the alt host first and, ONLY on
|
|
247
|
+
* a 404, fall back to github.com. Any non-404 error (auth, network, 5xx)
|
|
248
|
+
* fails loudly WITHOUT falling back — a silent fallback could fetch a
|
|
249
|
+
* same-numbered PR from the wrong system and stamp the wrong host.
|
|
250
|
+
* Exclusive alt-host and plain github repos resolve to a single binding
|
|
251
|
+
* with no probe (byte-identical to pre-dual behaviour).
|
|
252
|
+
*
|
|
253
|
+
* The chosen binding carries a `host` echo field (null for github.com, the
|
|
254
|
+
* api_host string for an alt host) that callers persist via `storePRData` — the
|
|
255
|
+
* self-healing step that stamps the host actually used.
|
|
256
|
+
*
|
|
257
|
+
* @param {Object} params
|
|
258
|
+
* @param {Object} params.db - Database instance (for the stored-host lookup)
|
|
259
|
+
* @param {Object} params.config - Application config
|
|
260
|
+
* @param {string} params.bindingRepository - `repos[...]` config-lookup key
|
|
261
|
+
* @param {string} params.owner
|
|
262
|
+
* @param {string} params.repo
|
|
263
|
+
* @param {number} params.prNumber
|
|
264
|
+
* @param {string|null|undefined} params.host - explicit host override (see above)
|
|
265
|
+
* @param {string} [params.githubToken] - fallback token when the chosen binding
|
|
266
|
+
* resolves none (legacy github.com behaviour)
|
|
267
|
+
* @param {Object} [params.deps] - `{ GitHubClient }` override for tests
|
|
268
|
+
* @returns {Promise<{ binding: Object, prData: Object, githubClient: Object }>}
|
|
269
|
+
*/
|
|
270
|
+
async function resolvePrHostBinding({ db, config, bindingRepository, owner, repo, prNumber, host, githubToken, deps = {} }) {
|
|
271
|
+
const Client = deps.GitHubClient || GitHubClient;
|
|
272
|
+
const repository = normalizeRepository(owner, repo);
|
|
273
|
+
const repoConfig = getRepoConfig(config, bindingRepository);
|
|
274
|
+
const configuredApiHost = (repoConfig && typeof repoConfig.api_host === 'string' && repoConfig.api_host)
|
|
275
|
+
? repoConfig.api_host
|
|
276
|
+
: null;
|
|
277
|
+
// A dual repo has an api_host but is not exclusive — its PRs may live on
|
|
278
|
+
// github.com OR the alt host, so an unknown host must be probed.
|
|
279
|
+
const isDual = isDualHostRepoConfig(repoConfig);
|
|
280
|
+
|
|
281
|
+
// Build the argument for `new Client(...)` from a resolved binding. A binding
|
|
282
|
+
// with a token is used as-is. A github-flavored binding (apiHost === null) with
|
|
283
|
+
// no token may fall back to the caller's github.com token (legacy behaviour).
|
|
284
|
+
// An ALT-flavored binding with no token must NOT fall back to `githubToken` —
|
|
285
|
+
// that would point Octokit at api.github.com while the caller records the
|
|
286
|
+
// result as the alt host (stamping a same-numbered github PR as alt). Guard on
|
|
287
|
+
// host flavor, not token truthiness: surface a clear missing-credential error.
|
|
288
|
+
const clientArgFor = (binding) => {
|
|
289
|
+
if (binding.token) return binding;
|
|
290
|
+
if (binding.apiHost === null) return githubToken;
|
|
291
|
+
throw new Error(
|
|
292
|
+
`No token configured for alt host ${binding.apiHost} (repo ${bindingRepository}). ` +
|
|
293
|
+
`Configure repos["${bindingRepository}"].token or token_command.`
|
|
294
|
+
);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// Apply host precedence to decide whether we know the host up front.
|
|
298
|
+
let effectiveHost;
|
|
299
|
+
let hostKnown = false;
|
|
300
|
+
if (host !== undefined) {
|
|
301
|
+
effectiveHost = host;
|
|
302
|
+
hostKnown = true;
|
|
303
|
+
} else {
|
|
304
|
+
const prMetadataRepo = new PRMetadataRepository(db);
|
|
305
|
+
const storedHost = await prMetadataRepo.getPRHost(repository, prNumber);
|
|
306
|
+
// storedHostToOption applies the legacy-NULL convention: `undefined` means
|
|
307
|
+
// "host unknown" (leave hostKnown false → ambiguity/probe path), while a
|
|
308
|
+
// returned option object pins the host.
|
|
309
|
+
const storedOption = storedHostToOption(config, bindingRepository, storedHost);
|
|
310
|
+
if (storedOption !== undefined) {
|
|
311
|
+
effectiveHost = storedOption.host;
|
|
312
|
+
hostKnown = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Fixed-binding path: host is known, OR the repo can only live on one host
|
|
317
|
+
// (plain github / exclusive alt) so the ambiguity rule is unambiguous.
|
|
318
|
+
if (hostKnown || !isDual) {
|
|
319
|
+
const binding = resolveHostBinding(bindingRepository, config, hostKnown ? { host: effectiveHost } : {});
|
|
320
|
+
const client = new Client(clientArgFor(binding));
|
|
321
|
+
const repoExists = await client.repositoryExists(owner, repo);
|
|
322
|
+
if (!repoExists) {
|
|
323
|
+
throw new Error(`Repository ${owner}/${repo} not found`);
|
|
324
|
+
}
|
|
325
|
+
const prData = await client.fetchPullRequest(owner, repo, prNumber);
|
|
326
|
+
return { binding, prData, githubClient: client };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Probe path: dual repo, host unknown. Try the alt host first. `clientArgFor`
|
|
330
|
+
// errors clearly if the alt binding has no token rather than silently probing
|
|
331
|
+
// github.com disguised as the alt host.
|
|
332
|
+
const altBinding = resolveHostBinding(bindingRepository, config, { host: configuredApiHost });
|
|
333
|
+
const altClient = new Client(clientArgFor(altBinding));
|
|
334
|
+
try {
|
|
335
|
+
const prData = await altClient.fetchPullRequest(owner, repo, prNumber);
|
|
336
|
+
logger.info(`PR #${prNumber} (${repository}) resolved to alt host ${configuredApiHost}`);
|
|
337
|
+
return { binding: altBinding, prData, githubClient: altClient };
|
|
338
|
+
} catch (altErr) {
|
|
339
|
+
if (altErr && altErr.status === 404) {
|
|
340
|
+
logger.info(`PR #${prNumber} (${repository}) not found on alt host ${configuredApiHost}; falling back to github.com`);
|
|
341
|
+
const githubBinding = resolveHostBinding(bindingRepository, config, { host: null });
|
|
342
|
+
const githubClient = new Client(clientArgFor(githubBinding));
|
|
343
|
+
const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
344
|
+
return { binding: githubBinding, prData, githubClient };
|
|
345
|
+
}
|
|
346
|
+
// Auth/network/5xx on the alt host — do NOT fall back to github.com.
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Failed to fetch PR #${prNumber} from alt host ${configuredApiHost}: ${altErr.message}`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
176
353
|
/**
|
|
177
354
|
* Register the known location of a GitHub repository in the database.
|
|
178
355
|
* This allows the web UI to find the repo without cloning when reviewing PRs.
|
|
@@ -419,30 +596,30 @@ function isShaNotFoundError(err) {
|
|
|
419
596
|
* @param {Object} [params.config] - Application config (for monorepo path lookup)
|
|
420
597
|
* @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [params.poolLifecycle] - Shared pool lifecycle instance (avoids creating a fresh singleton)
|
|
421
598
|
* @param {Object} [params.restoreMetadata] - Stored PR data for restore mode (skips GitHub fetch + diff)
|
|
599
|
+
* @param {string|null} [params.host] - Explicit per-PR host override (URL paste /
|
|
600
|
+
* dashboard row): `null` = github.com, an api_host URL string = that alt host,
|
|
601
|
+
* omitted (`undefined`) = unknown (derive from stored host / probe).
|
|
422
602
|
* @param {Function} [params.onProgress] - Optional progress callback
|
|
423
603
|
* @returns {Promise<{ reviewUrl: string, title: string }>}
|
|
424
604
|
*/
|
|
425
|
-
async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRepository: externalBindingRepository, config, onProgress, poolLifecycle: externalPoolLifecycle, restoreMetadata }) {
|
|
605
|
+
async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRepository: externalBindingRepository, config, host, onProgress, poolLifecycle: externalPoolLifecycle, restoreMetadata }) {
|
|
426
606
|
const repository = normalizeRepository(owner, repo);
|
|
427
607
|
const progress = onProgress || (() => {});
|
|
428
608
|
|
|
429
|
-
// Resolve the
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
// token shape if no config is available (legacy invocation path) or
|
|
434
|
-
// the binding resolved no token (callers in this case already
|
|
435
|
-
// pre-resolved it).
|
|
609
|
+
// Resolve the config-lookup key. Use `resolveBindingRepositoryFromPR` so
|
|
610
|
+
// monorepo-style configs (one `repos[...]` entry serving many captured
|
|
611
|
+
// owner/repo) find the right binding. Fall back to the PR identity when no
|
|
612
|
+
// config is available (legacy invocation path).
|
|
436
613
|
const bindingRepository = externalBindingRepository
|
|
437
614
|
|| (config ? resolveBindingRepositoryFromPR(owner, repo, config) : repository);
|
|
438
|
-
const setupBinding = config ? resolveHostBinding(bindingRepository, config) : null;
|
|
439
|
-
const clientArg = (setupBinding && setupBinding.token)
|
|
440
|
-
? setupBinding
|
|
441
|
-
: githubToken;
|
|
442
615
|
|
|
443
616
|
const isRestore = !!(restoreMetadata && restoreMetadata.head_sha);
|
|
444
617
|
let prData;
|
|
445
618
|
let githubClient = null;
|
|
619
|
+
// Host actually used to fetch — persisted by storePRData (self-healing). Left
|
|
620
|
+
// undefined for restore mode and the legacy (no-config) path so those flows
|
|
621
|
+
// leave any previously-stamped host untouched.
|
|
622
|
+
let stampHost;
|
|
446
623
|
|
|
447
624
|
if (isRestore) {
|
|
448
625
|
prData = restoreMetadata;
|
|
@@ -450,21 +627,33 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRe
|
|
|
450
627
|
progress({ step: 'fetch', status: 'completed', message: 'Using stored PR data.' });
|
|
451
628
|
} else {
|
|
452
629
|
// ------------------------------------------------------------------
|
|
453
|
-
// Step: verify -
|
|
630
|
+
// Step: verify + fetch - Resolve the per-PR host binding, verify access,
|
|
631
|
+
// and fetch PR data. `resolvePrHostBinding` applies host precedence
|
|
632
|
+
// (explicit host → stored host → ambiguity/probe) and, for a dual repo
|
|
633
|
+
// whose host is unknown, probes the alt host first (404 → github.com).
|
|
454
634
|
// ------------------------------------------------------------------
|
|
455
635
|
progress({ step: 'verify', status: 'running', message: 'Verifying repository access...' });
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
636
|
+
// Pair the 'fetch' step's completed event (below) with a running event and
|
|
637
|
+
// restore the spinner message; resolvePrHostBinding does the actual fetch.
|
|
638
|
+
progress({ step: 'fetch', status: 'running', message: 'Fetching pull request data from GitHub...' });
|
|
639
|
+
if (config) {
|
|
640
|
+
const resolved = await resolvePrHostBinding({
|
|
641
|
+
db, config, bindingRepository, owner, repo, prNumber, host, githubToken
|
|
642
|
+
});
|
|
643
|
+
githubClient = resolved.githubClient;
|
|
644
|
+
prData = resolved.prData;
|
|
645
|
+
stampHost = resolved.binding.host;
|
|
646
|
+
} else {
|
|
647
|
+
// Legacy path: no config, so bind directly with the supplied token and
|
|
648
|
+
// do not track a host.
|
|
649
|
+
githubClient = new GitHubClient(githubToken);
|
|
650
|
+
const repoExists = await githubClient.repositoryExists(owner, repo);
|
|
651
|
+
if (!repoExists) {
|
|
652
|
+
throw new Error(`Repository ${owner}/${repo} not found`);
|
|
653
|
+
}
|
|
654
|
+
prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
460
655
|
}
|
|
461
656
|
progress({ step: 'verify', status: 'completed', message: 'Repository access verified.' });
|
|
462
|
-
|
|
463
|
-
// ------------------------------------------------------------------
|
|
464
|
-
// Step: fetch - Fetch PR data from GitHub
|
|
465
|
-
// ------------------------------------------------------------------
|
|
466
|
-
progress({ step: 'fetch', status: 'running', message: 'Fetching pull request data from GitHub...' });
|
|
467
|
-
prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
468
657
|
progress({ step: 'fetch', status: 'completed', message: 'Pull request data fetched.' });
|
|
469
658
|
}
|
|
470
659
|
|
|
@@ -624,7 +813,9 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRe
|
|
|
624
813
|
// Step: store - Persist PR data and register repository location
|
|
625
814
|
// ------------------------------------------------------------------
|
|
626
815
|
progress({ step: 'store', status: 'running', message: 'Storing pull request data...' });
|
|
627
|
-
const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath
|
|
816
|
+
const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
|
|
817
|
+
host: stampHost
|
|
818
|
+
});
|
|
628
819
|
|
|
629
820
|
// Persist review→worktree mapping in DB for pool usage tracking
|
|
630
821
|
if (poolWorktreeId) {
|
|
@@ -662,8 +853,15 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRe
|
|
|
662
853
|
// fall back to a full fresh setup.
|
|
663
854
|
if (isRestore && isShaNotFoundError(err)) {
|
|
664
855
|
logger.warn(`Restore to stored SHA failed, falling back to fresh setup: ${err.message}`);
|
|
665
|
-
// Retry without restoreMetadata.
|
|
666
|
-
|
|
856
|
+
// Retry without restoreMetadata. Forward the explicit `host` and
|
|
857
|
+
// `bindingRepository` so the fresh fetch binds the SAME host the caller
|
|
858
|
+
// requested — re-deriving them here could bind a different host (probe a
|
|
859
|
+
// dual repo, or pick the wrong monorepo binding key).
|
|
860
|
+
return setupPRReview({
|
|
861
|
+
db, owner, repo, prNumber, githubToken,
|
|
862
|
+
bindingRepository: externalBindingRepository,
|
|
863
|
+
config, host, onProgress, poolLifecycle: externalPoolLifecycle
|
|
864
|
+
});
|
|
667
865
|
}
|
|
668
866
|
|
|
669
867
|
// Release the pool worktree so it doesn't stay permanently in_use.
|
|
@@ -684,4 +882,4 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRe
|
|
|
684
882
|
}
|
|
685
883
|
}
|
|
686
884
|
|
|
687
|
-
module.exports = { setupPRReview, storePRData, registerRepositoryLocation, findRepositoryPath, isShaNotFoundError };
|
|
885
|
+
module.exports = { setupPRReview, storePRData, resolvePrHostBinding, registerRepositoryLocation, findRepositoryPath, isShaNotFoundError };
|
package/src/setup/stack-setup.js
CHANGED
|
@@ -90,9 +90,14 @@ async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, b
|
|
|
90
90
|
// 5. Get changed files with stats
|
|
91
91
|
const changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
|
|
92
92
|
|
|
93
|
-
// 6. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records)
|
|
93
|
+
// 6. Store via storePRData (creates/updates pr_metadata, reviews, worktrees records).
|
|
94
|
+
// Stamp the host of the binding used to fetch this stack PR (null for
|
|
95
|
+
// github.com, the api_host string for an alt host) so per-PR host resolution
|
|
96
|
+
// self-heals across the stack. Omitted when only a bare token was supplied.
|
|
94
97
|
const prInfo = { owner, repo, number: prNumber };
|
|
95
|
-
const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath
|
|
98
|
+
const { isNewReview, reviewId } = await storePRData(db, prInfo, prData, diff, changedFiles, worktreePath, {
|
|
99
|
+
host: binding ? binding.host : undefined
|
|
100
|
+
});
|
|
96
101
|
|
|
97
102
|
logger.info(`Stack PR #${prNumber} setup complete (reviewId: ${reviewId}, new: ${isNewReview})`);
|
|
98
103
|
|
package/src/single-port.js
CHANGED
|
@@ -6,6 +6,7 @@ const { PRArgumentParser } = require('./github/parser');
|
|
|
6
6
|
const logger = require('./utils/logger');
|
|
7
7
|
const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
|
|
8
8
|
const { normalizeRepository } = require('./utils/paths');
|
|
9
|
+
const { isDualHostRepo, hostSetupParamValue } = require('./utils/host-resolution');
|
|
9
10
|
const { buildInteractiveAnalysisConfig } = require('./interactive-analysis-config');
|
|
10
11
|
const { version: packageVersion } = require('../package.json');
|
|
11
12
|
|
|
@@ -169,31 +170,69 @@ function storeAnalysisConfigRemote(port, analysisConfig, _deps) {
|
|
|
169
170
|
* the resolved provider/model or council snapshot PLUS custom instructions). When
|
|
170
171
|
* present it supersedes `councilId` — the id already carries the council selection,
|
|
171
172
|
* mirroring the cold-start precedence in handlePullRequest/handleLocalReview.
|
|
173
|
+
* @param {string} [context.host] - Setup `?host=` value (alt api_host string or the
|
|
174
|
+
* 'github' sentinel) so a pasted alt URL binds directly instead of re-probing.
|
|
175
|
+
* Already resolved by the caller via `hostSetupParamValue`; omitted when null.
|
|
176
|
+
* @param {string} [context.provider] - CLI provider override to carry to the running server
|
|
177
|
+
* @param {string} [context.model] - CLI model override to carry to the running server
|
|
178
|
+
* @param {string} [context.scope] - Local-mode only: the `--scope` range to carry
|
|
179
|
+
* to the delegated session (e.g. 'branch..untracked').
|
|
180
|
+
* @param {string} [context.base] - Local-mode only: the `--base` branch override.
|
|
172
181
|
* @returns {string} Full URL
|
|
173
182
|
*/
|
|
174
183
|
function buildDelegationUrl(port, mode, context = {}) {
|
|
175
184
|
const base = `http://localhost:${port}`;
|
|
185
|
+
|
|
186
|
+
// The analyze + provider/model override params are built with URLSearchParams
|
|
187
|
+
// so they join and URL-encode safely. The provider/model override rides the
|
|
188
|
+
// URL ONLY alongside analyze=true: the browser auto-analyze path is the sole
|
|
189
|
+
// consumer that reads and then strips them, so carrying them without analyze
|
|
190
|
+
// would leave a stuck ?provider= param that a refresh would replay.
|
|
191
|
+
const extra = new URLSearchParams();
|
|
192
|
+
if (context.analyze) {
|
|
193
|
+
extra.set('analyze', 'true');
|
|
194
|
+
if (context.provider) extra.set('provider', context.provider);
|
|
195
|
+
if (context.model) extra.set('model', context.model);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// analysisConfigId supersedes councilId — the stored id already encodes the
|
|
199
|
+
// council selection (plus provider/model + custom instructions), mirroring
|
|
200
|
+
// the cold-start precedence in handlePullRequest/handleLocalReview. These keep
|
|
201
|
+
// encodeURIComponent (percent-encoding, %20 for spaces) rather than the
|
|
202
|
+
// URLSearchParams form-encoding above, to preserve the exact wire format the
|
|
203
|
+
// running server already parses — matching the `path` param, which does the
|
|
204
|
+
// same for the same reason.
|
|
205
|
+
let configParam = '';
|
|
206
|
+
if (context.analysisConfigId) {
|
|
207
|
+
configParam = `analysisConfigId=${encodeURIComponent(context.analysisConfigId)}`;
|
|
208
|
+
} else if (context.councilId) {
|
|
209
|
+
configParam = `council=${encodeURIComponent(context.councilId)}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Join the URLSearchParams bundle with the percent-encoded config param.
|
|
213
|
+
const query = [extra.toString(), configParam].filter(Boolean).join('&');
|
|
214
|
+
|
|
176
215
|
if (mode === 'pr') {
|
|
177
216
|
let url = `${base}/pr/${context.owner}/${context.repo}/${context.number}`;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
if (query.length) url += `?${query.join('&')}`;
|
|
217
|
+
// Thread the pasted URL's host so setup binds it directly (cold-start parity).
|
|
218
|
+
// Host keeps encodeURIComponent (matching analysisConfigId/path) and appends
|
|
219
|
+
// after the shared analyze/provider/model + config bundle, so it rides
|
|
220
|
+
// alongside the provider/model override rather than replacing it.
|
|
221
|
+
const hostParam = context.host ? `host=${encodeURIComponent(context.host)}` : '';
|
|
222
|
+
const prQuery = [query, hostParam].filter(Boolean).join('&');
|
|
223
|
+
if (prQuery) url += `?${prQuery}`;
|
|
186
224
|
return url;
|
|
187
225
|
}
|
|
188
226
|
if (mode === 'local') {
|
|
189
|
-
// The `?path=` segment is always present, so
|
|
227
|
+
// The `?path=` segment is always present, so the intent bundle appends with `&`.
|
|
190
228
|
let url = `${base}/local?path=${encodeURIComponent(context.localPath)}`;
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
229
|
+
if (query) url += `&${query}`;
|
|
230
|
+
// Carry the diff-scope selection so a delegated `--local --scope/--base` run
|
|
231
|
+
// lands at the requested scope instead of the server's default. The receiving
|
|
232
|
+
// setup page relays these and the server re-validates them. These keep
|
|
233
|
+
// encodeURIComponent to match the `path` param's wire format.
|
|
234
|
+
if (context.scope) url += `&scope=${encodeURIComponent(context.scope)}`;
|
|
235
|
+
if (context.base) url += `&base=${encodeURIComponent(context.base)}`;
|
|
197
236
|
return url;
|
|
198
237
|
}
|
|
199
238
|
return `${base}/`;
|
|
@@ -281,18 +320,34 @@ async function attemptDelegation(config, flags, prArgs, _deps, options = {}) {
|
|
|
281
320
|
return storeAnalysisConfigRemote(port, analysisConfig, _deps);
|
|
282
321
|
};
|
|
283
322
|
|
|
284
|
-
// Determine mode and build URL
|
|
323
|
+
// Determine mode and build URL. Also carry any --provider/--model override on
|
|
324
|
+
// the URL: under single_port the delegated-to server is a DIFFERENT process
|
|
325
|
+
// whose env never received the flag, so the URL is the only channel that
|
|
326
|
+
// reaches it. buildDelegationUrl only emits provider/model alongside
|
|
327
|
+
// analyze=true (and analysisConfigId supersedes both when present).
|
|
285
328
|
let url;
|
|
286
329
|
if (flags.local) {
|
|
287
330
|
rejectUrlLikeLocalReviewPath(flags.localPath);
|
|
288
331
|
const targetPath = path.resolve(flags.localPath || process.cwd());
|
|
289
332
|
const analysisConfigId = await handoffAnalysisConfigId(options.localRepository || null);
|
|
290
|
-
url = buildDelegationUrl(port, 'local', {
|
|
333
|
+
url = buildDelegationUrl(port, 'local', {
|
|
334
|
+
localPath: targetPath, analyze, councilId, analysisConfigId,
|
|
335
|
+
provider: flags.provider, model: flags.model,
|
|
336
|
+
scope: flags.scope || null, base: flags.base || null
|
|
337
|
+
});
|
|
291
338
|
} else if (prArgs.length > 0) {
|
|
292
339
|
const prInfo = await parsePRArgsForDelegation(prArgs, config, _deps);
|
|
293
340
|
const repository = normalizeRepository(prInfo.owner, prInfo.repo);
|
|
294
341
|
const analysisConfigId = await handoffAnalysisConfigId(repository);
|
|
295
|
-
|
|
342
|
+
// Thread the parsed host so a pasted alt URL binds directly (no re-probe) —
|
|
343
|
+
// same mapping as the cold-start handlePullRequest path. bindingRepository
|
|
344
|
+
// (from a url_pattern match) is the config key that determines dual-ness.
|
|
345
|
+
const bindingRepository = prInfo.bindingRepository || repository;
|
|
346
|
+
const host = hostSetupParamValue(prInfo.host, isDualHostRepo(config, bindingRepository));
|
|
347
|
+
url = buildDelegationUrl(port, 'pr', {
|
|
348
|
+
...prInfo, analyze, councilId, analysisConfigId, host,
|
|
349
|
+
provider: flags.provider, model: flags.model
|
|
350
|
+
});
|
|
296
351
|
} else {
|
|
297
352
|
url = buildDelegationUrl(port, 'server');
|
|
298
353
|
}
|