@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
package/src/ai/stream-parser.js
CHANGED
|
@@ -212,56 +212,6 @@ function parseCodexLine(line, options = {}) {
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
/**
|
|
216
|
-
* Parse a single Gemini stream-json JSONL line into a normalized event.
|
|
217
|
-
* Returns null if the line should not be emitted.
|
|
218
|
-
*
|
|
219
|
-
* Gemini stream-json event types:
|
|
220
|
-
* - message (role: "assistant") → assistant_text
|
|
221
|
-
* - tool_use (tool_name, parameters) → tool_use
|
|
222
|
-
* - init, message (role: "user"), tool_result, result → filtered out
|
|
223
|
-
*
|
|
224
|
-
* @param {string} line - A single JSONL line from Gemini stdout
|
|
225
|
-
* @param {Object} [options] - Parse options
|
|
226
|
-
* @param {string} [options.cwd] - Working directory to strip from file paths
|
|
227
|
-
* @returns {{ type: string, text: string, timestamp: number } | null}
|
|
228
|
-
*/
|
|
229
|
-
function parseGeminiLine(line, options = {}) {
|
|
230
|
-
if (!line || !line.trim()) return null;
|
|
231
|
-
|
|
232
|
-
const { cwd } = options;
|
|
233
|
-
|
|
234
|
-
try {
|
|
235
|
-
const event = JSON.parse(line);
|
|
236
|
-
const eventType = event.type;
|
|
237
|
-
|
|
238
|
-
if (eventType === 'message' && event.role === 'assistant' && event.content && event.content.trim()) {
|
|
239
|
-
return {
|
|
240
|
-
type: 'assistant_text',
|
|
241
|
-
text: truncateSnippet(event.content),
|
|
242
|
-
timestamp: Date.now()
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (eventType === 'tool_use') {
|
|
247
|
-
const toolName = event.tool_name || 'unknown';
|
|
248
|
-
const detail = extractToolDetail(event.parameters, cwd);
|
|
249
|
-
const text = detail ? `${toolName}: ${detail}` : toolName;
|
|
250
|
-
return {
|
|
251
|
-
type: 'tool_use',
|
|
252
|
-
text: truncateSnippet(text),
|
|
253
|
-
timestamp: Date.now()
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// init, user messages, tool_result, result — never emit
|
|
258
|
-
return null;
|
|
259
|
-
} catch {
|
|
260
|
-
// Best-effort side channel — silently ignore non-JSON or malformed lines.
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
215
|
/**
|
|
266
216
|
* Parse a single OpenCode JSONL line into a normalized event.
|
|
267
217
|
* Returns null if the line should not be emitted.
|
|
@@ -323,7 +273,7 @@ function parseOpenCodeLine(line, options = {}) {
|
|
|
323
273
|
*/
|
|
324
274
|
class StreamParser {
|
|
325
275
|
/**
|
|
326
|
-
* @param {Function} parseLine - Provider-specific line parser (e.g. parseClaudeLine,
|
|
276
|
+
* @param {Function} parseLine - Provider-specific line parser (e.g. parseClaudeLine, parseCodexLine)
|
|
327
277
|
* @param {Function} onEvent - Callback receiving normalized events { type, text, timestamp }
|
|
328
278
|
* @param {Object} [options] - Options passed through to parseLine (e.g. { cwd })
|
|
329
279
|
*/
|
|
@@ -636,7 +586,6 @@ module.exports = {
|
|
|
636
586
|
extractToolDetail,
|
|
637
587
|
parseClaudeLine,
|
|
638
588
|
parseCodexLine,
|
|
639
|
-
parseGeminiLine,
|
|
640
589
|
parseOpenCodeLine,
|
|
641
590
|
parseCursorAgentLine,
|
|
642
591
|
parsePiLine,
|
package/src/chat/acp-bridge.js
CHANGED
|
@@ -82,7 +82,7 @@ class AcpBridge extends EventEmitter {
|
|
|
82
82
|
const args = [...this.acpArgs];
|
|
83
83
|
const useShell = this.useShell;
|
|
84
84
|
|
|
85
|
-
// For multi-word commands (e.g. "devx
|
|
85
|
+
// For multi-word commands (e.g. "devx claude"), use shell mode
|
|
86
86
|
const spawnCmd = useShell ? `${command} ${args.join(' ')}` : command;
|
|
87
87
|
const spawnArgs = useShell ? [] : args;
|
|
88
88
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Chat Provider Registry
|
|
4
4
|
*
|
|
5
|
-
* Defines named chat providers (Pi, Copilot,
|
|
5
|
+
* Defines named chat providers (Pi, Copilot, OpenCode, Claude, Codex, Cursor) with their
|
|
6
6
|
* default commands/args, config overrides, and availability checks.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -32,14 +32,6 @@ const CHAT_PROVIDERS = {
|
|
|
32
32
|
args: ['--acp', '--stdio'],
|
|
33
33
|
env: {},
|
|
34
34
|
},
|
|
35
|
-
'gemini-acp': {
|
|
36
|
-
id: 'gemini-acp',
|
|
37
|
-
name: 'Gemini (ACP)',
|
|
38
|
-
type: 'acp',
|
|
39
|
-
command: 'gemini',
|
|
40
|
-
args: ['--experimental-acp'],
|
|
41
|
-
env: {},
|
|
42
|
-
},
|
|
43
35
|
'opencode-acp': {
|
|
44
36
|
id: 'opencode-acp',
|
|
45
37
|
name: 'OpenCode (ACP)',
|
package/src/config.js
CHANGED
|
@@ -66,8 +66,8 @@ const DEFAULT_CONFIG = {
|
|
|
66
66
|
port: 7247,
|
|
67
67
|
single_port: true, // When true, reuse a single server on the configured port; new invocations delegate to the running server
|
|
68
68
|
theme: "light",
|
|
69
|
-
default_provider: "claude", // AI provider: 'claude', '
|
|
70
|
-
default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-
|
|
69
|
+
default_provider: "claude", // AI provider: 'claude', 'antigravity', 'codex', 'copilot', 'opencode', 'cursor-agent', 'pi'
|
|
70
|
+
default_model: "opus", // Model within the provider (e.g., 'opus' for Claude, 'gemini-3.1-pro-low' for Antigravity)
|
|
71
71
|
tours: {
|
|
72
72
|
enabled: false, // When true, the guided-tour feature is available (toolbar button visible, etc.)
|
|
73
73
|
auto_generate: true, // When true, a tour generation job is kicked off automatically on review load
|
|
@@ -89,7 +89,7 @@ const DEFAULT_CONFIG = {
|
|
|
89
89
|
db_name: "", // Custom database filename (default: database.db). Useful for per-worktree isolation.
|
|
90
90
|
yolo: false, // When true, skips fine-grained AI provider permission setup (equivalent to --yolo CLI flag)
|
|
91
91
|
enable_chat: true, // When true, enables the chat panel feature (uses chat_provider)
|
|
92
|
-
chat_provider: "pi", // Chat provider: 'pi', 'copilot-acp', '
|
|
92
|
+
chat_provider: "pi", // Chat provider: 'pi', 'copilot-acp', 'opencode-acp', 'cursor-acp', 'codex'
|
|
93
93
|
comment_format: "legacy", // Comment format preset or custom template for adopted suggestions
|
|
94
94
|
chat: { enable_shortcuts: true, enter_to_send: true }, // Chat panel settings (enable_shortcuts: show action shortcut buttons, enter_to_send: Enter sends message instead of newline)
|
|
95
95
|
providers: {}, // Custom AI analysis provider configurations (overrides built-in defaults)
|
|
@@ -534,26 +534,61 @@ function _resolveFeatures(apiHost, explicit) {
|
|
|
534
534
|
return out;
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Whether a repo config describes an *exclusive* alt-host repo — one whose
|
|
539
|
+
* every PR lives on the configured `api_host` and which has no github.com
|
|
540
|
+
* presence. True iff `api_host` is a non-empty string AND `exclusive` is not
|
|
541
|
+
* explicitly `false`. Omitting `exclusive` therefore preserves today's
|
|
542
|
+
* behaviour (an `api_host` repo is alt-host-only). Setting `exclusive: false`
|
|
543
|
+
* marks a *dual* repo whose PRs may live on github.com OR the alt host.
|
|
544
|
+
*
|
|
545
|
+
* @param {Object|null|undefined} repoConfig - A single `repos[...]` entry
|
|
546
|
+
* @returns {boolean}
|
|
547
|
+
*/
|
|
548
|
+
function isExclusiveAltHost(repoConfig) {
|
|
549
|
+
if (!repoConfig || typeof repoConfig !== 'object') return false;
|
|
550
|
+
const apiHost = typeof repoConfig.api_host === 'string' && repoConfig.api_host;
|
|
551
|
+
if (!apiHost) return false;
|
|
552
|
+
return repoConfig.exclusive !== false;
|
|
553
|
+
}
|
|
554
|
+
|
|
537
555
|
/**
|
|
538
556
|
* Resolves the host binding for a given repository. The binding describes
|
|
539
557
|
* which API host pair-review should talk to, the token to authenticate
|
|
540
558
|
* with, and per-area dispatch flags.
|
|
541
559
|
*
|
|
542
|
-
*
|
|
560
|
+
* The optional `options.host` selects the binding *flavor* for repos that can
|
|
561
|
+
* live on more than one host (dual repos, `exclusive: false`):
|
|
562
|
+
* - `undefined` (or no options) — legacy + ambiguity rule: an EXCLUSIVE
|
|
563
|
+
* alt-host repo binds to its alt host (today's behaviour); a DUAL repo or
|
|
564
|
+
* a plain github.com repo binds to github.com.
|
|
565
|
+
* - `null` — force a github.com binding. For an EXCLUSIVE alt-host repo this
|
|
566
|
+
* is a caller bug (that repo has no github.com presence) and throws.
|
|
567
|
+
* - `'<url>'` — force an alt-host binding; the string must equal the repo's
|
|
568
|
+
* configured `api_host`, otherwise the stored host no longer matches
|
|
569
|
+
* config and this throws.
|
|
570
|
+
*
|
|
571
|
+
* Token resolution priority for a github.com (github-flavored) binding of a
|
|
572
|
+
* plain repo:
|
|
543
573
|
* 1. GITHUB_TOKEN environment variable
|
|
544
574
|
* 2. repo-level `token`
|
|
545
575
|
* 3. repo-level `token_command` (cached per (repo, command))
|
|
546
576
|
* 4. top-level `github_token`
|
|
547
577
|
* 5. top-level `github_token_command` (cached per (repo, command))
|
|
548
578
|
*
|
|
549
|
-
* For
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
* the
|
|
553
|
-
*
|
|
554
|
-
* lookup returns an empty token so the caller can surface a clear
|
|
579
|
+
* For an alt-host binding, the github.com top-level credentials are NOT used —
|
|
580
|
+
* `GITHUB_TOKEN`, `config.github_token`, and `config.github_token_command` are
|
|
581
|
+
* all github.com-only and would be the wrong token for an alt-host endpoint.
|
|
582
|
+
* Only the repo-scoped `token` / `token_command` keys are consulted; missing
|
|
583
|
+
* those, the lookup returns an empty token so the caller can surface a clear
|
|
555
584
|
* "missing credential" error.
|
|
556
585
|
*
|
|
586
|
+
* For the github.com binding of a DUAL repo, the reverse holds: the repo-scoped
|
|
587
|
+
* `token` / `token_command` are alt-host credentials and are NOT used — only
|
|
588
|
+
* the top-level github.com chain (env → `github_token` → `github_token_command`)
|
|
589
|
+
* is consulted. The repo's explicit `features` block (written for the alt host)
|
|
590
|
+
* also does not apply to its github.com binding.
|
|
591
|
+
*
|
|
557
592
|
* Refreshable sources (`repo:token_command`, `config:github_token_command`)
|
|
558
593
|
* additionally carry a `refresh` closure on the returned binding. Calling
|
|
559
594
|
* `refresh()` busts the cached token for that exact source, re-runs the
|
|
@@ -564,58 +599,101 @@ function _resolveFeatures(apiHost, explicit) {
|
|
|
564
599
|
*
|
|
565
600
|
* @param {string|null|undefined} repository - "owner/repo" identifier, or null/undefined for no-repo fallback
|
|
566
601
|
* @param {Object} config - Configuration object from loadConfig()
|
|
567
|
-
* @
|
|
602
|
+
* @param {{ host?: string|null }} [options] - Per-PR host override (see above)
|
|
603
|
+
* @returns {{ apiHost: string|null, host: string|null, token: string, features: Object, source: string, refresh: (function(): string)|null }}
|
|
568
604
|
*/
|
|
569
|
-
function resolveHostBinding(repository, config) {
|
|
605
|
+
function resolveHostBinding(repository, config, options = {}) {
|
|
570
606
|
const safeConfig = config || {};
|
|
571
607
|
const repoConfig = repository ? getRepoConfig(safeConfig, repository) : null;
|
|
572
|
-
const
|
|
608
|
+
const configuredApiHost = (repoConfig && typeof repoConfig.api_host === 'string' && repoConfig.api_host)
|
|
573
609
|
? repoConfig.api_host
|
|
574
610
|
: null;
|
|
575
|
-
const
|
|
611
|
+
const requestedHost = options ? options.host : undefined;
|
|
612
|
+
const exclusive = isExclusiveAltHost(repoConfig);
|
|
613
|
+
|
|
614
|
+
// Decide the binding flavor from the requested host + repo config.
|
|
615
|
+
// `apiHost` null → github.com binding; non-null → alt-host binding.
|
|
616
|
+
let apiHost;
|
|
617
|
+
if (requestedHost === undefined) {
|
|
618
|
+
// Ambiguity rule: exclusive alt-host repo → alt binding (legacy);
|
|
619
|
+
// dual repo and plain github repo → github binding.
|
|
620
|
+
apiHost = exclusive ? configuredApiHost : null;
|
|
621
|
+
} else if (requestedHost === null) {
|
|
622
|
+
if (exclusive) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
`resolveHostBinding: repository "${repository}" is an exclusive alt-host repo (api_host "${configuredApiHost}") and has no github.com presence, but a github.com binding was requested (host=null). This is a caller bug; pass the api_host string, or set "exclusive": false on the repo config.`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
apiHost = null;
|
|
628
|
+
} else {
|
|
629
|
+
// A specific alt host was requested; it must match this repo's config.
|
|
630
|
+
if (requestedHost !== configuredApiHost) {
|
|
631
|
+
throw new Error(
|
|
632
|
+
`resolveHostBinding: requested host "${requestedHost}" for repository "${repository}" does not match its configured api_host (${configuredApiHost === null ? 'none' : `"${configuredApiHost}"`}). The stored host no longer matches config — re-open the PR from a URL to re-resolve its host.`
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
apiHost = configuredApiHost;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Binding flavor booleans:
|
|
639
|
+
// - alt binding uses ONLY repo-scoped credentials + the repo's features.
|
|
640
|
+
// - github binding uses ONLY the top-level github.com chain.
|
|
641
|
+
// - the github binding of a DUAL repo must skip the repo-scoped
|
|
642
|
+
// credentials/features (they belong to the alt host).
|
|
643
|
+
const isAltBinding = apiHost !== null;
|
|
644
|
+
const isDualGithubBinding = !isAltBinding && configuredApiHost !== null;
|
|
645
|
+
const useRepoScopedToken = !isDualGithubBinding;
|
|
646
|
+
const useGithubChain = !isAltBinding;
|
|
647
|
+
|
|
648
|
+
// A dual repo's explicit `features` block was authored for its alt host, so
|
|
649
|
+
// it does not apply to the github.com binding; use plain github defaults.
|
|
650
|
+
const explicitFeatures = isDualGithubBinding ? undefined : repoConfig?.features;
|
|
651
|
+
const features = _resolveFeatures(apiHost, explicitFeatures);
|
|
576
652
|
|
|
577
653
|
// Token resolution
|
|
578
654
|
let token = '';
|
|
579
655
|
let source = 'none';
|
|
580
656
|
|
|
581
|
-
// 1. GITHUB_TOKEN env var, only for github.com
|
|
582
|
-
if (
|
|
657
|
+
// 1. GITHUB_TOKEN env var, only for a github.com binding
|
|
658
|
+
if (useGithubChain && process.env.GITHUB_TOKEN) {
|
|
583
659
|
token = process.env.GITHUB_TOKEN;
|
|
584
660
|
source = 'env:GITHUB_TOKEN';
|
|
585
661
|
logger.debug('Using GitHub token from GITHUB_TOKEN environment variable');
|
|
586
|
-
return { apiHost, token, features, source, refresh: null };
|
|
662
|
+
return { apiHost, host: apiHost, token, features, source, refresh: null };
|
|
587
663
|
}
|
|
588
664
|
|
|
589
|
-
// 2. Repo-level literal token
|
|
590
|
-
|
|
665
|
+
// 2. Repo-level literal token (alt-host credential; not used for a dual
|
|
666
|
+
// repo's github.com binding)
|
|
667
|
+
if (useRepoScopedToken && repoConfig && typeof repoConfig.token === 'string' && repoConfig.token) {
|
|
591
668
|
token = repoConfig.token;
|
|
592
669
|
source = 'repo:token';
|
|
593
670
|
logger.debug(`Using token from repos[${repository}].token`);
|
|
594
|
-
return { apiHost, token, features, source, refresh: null };
|
|
671
|
+
return { apiHost, host: apiHost, token, features, source, refresh: null };
|
|
595
672
|
}
|
|
596
673
|
|
|
597
674
|
// 3. Repo-level token_command
|
|
598
|
-
if (repoConfig && typeof repoConfig.token_command === 'string' && repoConfig.token_command) {
|
|
675
|
+
if (useRepoScopedToken && repoConfig && typeof repoConfig.token_command === 'string' && repoConfig.token_command) {
|
|
599
676
|
const result = _runTokenCommand(repoConfig.token_command, repository, 'repo:token_command');
|
|
600
677
|
if (result) {
|
|
601
678
|
return {
|
|
602
679
|
apiHost,
|
|
680
|
+
host: apiHost,
|
|
603
681
|
token: result,
|
|
604
682
|
features,
|
|
605
683
|
source: 'repo:token_command',
|
|
606
|
-
refresh: _makeRefresh(repository, safeConfig, 'repo:token_command')
|
|
684
|
+
refresh: _makeRefresh(repository, safeConfig, 'repo:token_command', options)
|
|
607
685
|
};
|
|
608
686
|
}
|
|
609
687
|
}
|
|
610
688
|
|
|
611
|
-
// 4. Top-level github_token. Only consulted for github.com
|
|
689
|
+
// 4. Top-level github_token. Only consulted for a github.com binding —
|
|
612
690
|
// the top-level token is a github.com credential and would fail
|
|
613
691
|
// authentication when sent to an alt-host.
|
|
614
|
-
if (
|
|
692
|
+
if (useGithubChain && typeof safeConfig.github_token === 'string' && safeConfig.github_token) {
|
|
615
693
|
token = safeConfig.github_token;
|
|
616
694
|
source = 'config:github_token';
|
|
617
695
|
logger.debug('Using GitHub token from config.github_token');
|
|
618
|
-
return { apiHost, token, features, source, refresh: null };
|
|
696
|
+
return { apiHost, host: apiHost, token, features, source, refresh: null };
|
|
619
697
|
}
|
|
620
698
|
|
|
621
699
|
// 5. Top-level github_token_command. Like step 4, github.com-only.
|
|
@@ -623,25 +701,26 @@ function resolveHostBinding(repository, config) {
|
|
|
623
701
|
// command only — keying on repository would re-invoke the (often
|
|
624
702
|
// slow) command per repo per session. Repo-level `token_command`
|
|
625
703
|
// above keeps its per-(repo, command) cache key.
|
|
626
|
-
if (
|
|
704
|
+
if (useGithubChain && typeof safeConfig.github_token_command === 'string' && safeConfig.github_token_command) {
|
|
627
705
|
const result = _runTokenCommand(safeConfig.github_token_command, null, 'config:github_token_command');
|
|
628
706
|
if (result) {
|
|
629
707
|
return {
|
|
630
708
|
apiHost,
|
|
709
|
+
host: apiHost,
|
|
631
710
|
token: result,
|
|
632
711
|
features,
|
|
633
712
|
source: 'config:github_token_command',
|
|
634
|
-
refresh: _makeRefresh(repository, safeConfig, 'config:github_token_command')
|
|
713
|
+
refresh: _makeRefresh(repository, safeConfig, 'config:github_token_command', options)
|
|
635
714
|
};
|
|
636
715
|
}
|
|
637
716
|
}
|
|
638
717
|
|
|
639
|
-
if (
|
|
718
|
+
if (isAltBinding && repository) {
|
|
640
719
|
logger.debug(`No repo-scoped token resolved for alt-host repo ${repository} (${apiHost}); github.com top-level credentials are not used for alt-hosts`);
|
|
641
720
|
} else {
|
|
642
721
|
logger.debug('No token resolved for host binding');
|
|
643
722
|
}
|
|
644
|
-
return { apiHost, token: '', features, source: 'none', refresh: null };
|
|
723
|
+
return { apiHost, host: apiHost, token: '', features, source: 'none', refresh: null };
|
|
645
724
|
}
|
|
646
725
|
|
|
647
726
|
/**
|
|
@@ -656,14 +735,15 @@ function resolveHostBinding(repository, config) {
|
|
|
656
735
|
* @param {string|null|undefined} repository - "owner/repo" identifier as supplied to resolveHostBinding
|
|
657
736
|
* @param {Object} config - Configuration object from loadConfig()
|
|
658
737
|
* @param {('repo:token_command'|'config:github_token_command')} source - The refreshable source backing the binding
|
|
738
|
+
* @param {{ host?: string|null }} [options] - The same host override the binding was resolved with, so the refresh re-resolves the SAME flavor (a two-arg re-resolve would apply the ambiguity rule and could pick the wrong host for a dual repo)
|
|
659
739
|
* @returns {function(): string} - Closure resolving to the fresh token (empty string on failure)
|
|
660
740
|
*/
|
|
661
|
-
function _makeRefresh(repository, config, source) {
|
|
741
|
+
function _makeRefresh(repository, config, source, options = {}) {
|
|
662
742
|
return function refresh() {
|
|
663
743
|
invalidateTokenCache(repository, config, source);
|
|
664
744
|
// Re-resolve after invalidation so _runTokenCommand re-executes the
|
|
665
745
|
// command. Returns '' if the command now fails or yields nothing.
|
|
666
|
-
return resolveHostBinding(repository, config).token;
|
|
746
|
+
return resolveHostBinding(repository, config, options).token;
|
|
667
747
|
};
|
|
668
748
|
}
|
|
669
749
|
|
|
@@ -795,6 +875,22 @@ function validateRepoConfig(config) {
|
|
|
795
875
|
const apiHost = (typeof repoEntry.api_host === 'string' && repoEntry.api_host) ? repoEntry.api_host : null;
|
|
796
876
|
const features = (repoEntry.features && typeof repoEntry.features === 'object') ? repoEntry.features : {};
|
|
797
877
|
|
|
878
|
+
// `exclusive` marks whether an alt-host repo's PRs live ONLY on its
|
|
879
|
+
// `api_host` (default) or may also live on github.com (`exclusive: false`,
|
|
880
|
+
// a dual repo). It is meaningless without `api_host`.
|
|
881
|
+
if (repoEntry.exclusive !== undefined && repoEntry.exclusive !== null) {
|
|
882
|
+
if (typeof repoEntry.exclusive !== 'boolean') {
|
|
883
|
+
throw new Error(
|
|
884
|
+
`Invalid pair-review config: repos["${repoKey}"].exclusive must be a boolean.`
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
if (!apiHost) {
|
|
888
|
+
throw new Error(
|
|
889
|
+
`Invalid pair-review config: repos["${repoKey}"].exclusive is only valid when api_host is set.`
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
798
894
|
for (const [area, value] of Object.entries(features)) {
|
|
799
895
|
// Endpoint-override sub-keys (e.g. `pending_review_comments_endpoint`)
|
|
800
896
|
// are validated separately below. Reject anything that ends in
|
|
@@ -910,6 +1006,31 @@ function validateRepoConfig(config) {
|
|
|
910
1006
|
`Invalid pair-review config: repos["${repoKey}"].url_pattern is not a valid regular expression: ${err.message}`
|
|
911
1007
|
);
|
|
912
1008
|
}
|
|
1009
|
+
|
|
1010
|
+
// An `api_host`-bearing pattern must never match a canonical github.com /
|
|
1011
|
+
// Graphite URL — doing so pre-pins a github PR to the alt host and bypasses
|
|
1012
|
+
// the setup probe (a silent, durable wrong binding). Warn (do NOT throw —
|
|
1013
|
+
// we must not break existing configs; parsePRUrl also guards this at
|
|
1014
|
+
// runtime by discarding such matches) when the pattern is over-broad.
|
|
1015
|
+
if (typeof repoEntry.api_host === 'string' && repoEntry.api_host) {
|
|
1016
|
+
try {
|
|
1017
|
+
const rx = new RegExp(repoEntry.url_pattern);
|
|
1018
|
+
const canaries = [
|
|
1019
|
+
'https://github.com/o/r/pull/1',
|
|
1020
|
+
'https://app.graphite.dev/github/pr/o/r/1',
|
|
1021
|
+
'https://app.graphite.com/github/o/r/pull/1'
|
|
1022
|
+
];
|
|
1023
|
+
if (canaries.some((u) => rx.test(u))) {
|
|
1024
|
+
logger.warn(
|
|
1025
|
+
`repos["${repoKey}"].url_pattern also matches canonical github.com / Graphite URLs; ` +
|
|
1026
|
+
`anchor it (e.g. start with "^https://<your-alt-host>/") so it never pre-pins a github.com ` +
|
|
1027
|
+
`PR to the alt host. pair-review will still route such URLs to github.com.`
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
} catch {
|
|
1031
|
+
// Invalid regex already reported above; nothing more to check.
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
913
1034
|
}
|
|
914
1035
|
|
|
915
1036
|
// Optional escape-hatch regex used by parseRepositoryFromURL to match
|
|
@@ -1512,6 +1633,7 @@ module.exports = {
|
|
|
1512
1633
|
validatePort,
|
|
1513
1634
|
getGitHubToken,
|
|
1514
1635
|
resolveHostBinding,
|
|
1636
|
+
isExclusiveAltHost,
|
|
1515
1637
|
invalidateTokenCache,
|
|
1516
1638
|
validateRepoConfig,
|
|
1517
1639
|
matchRepoByUrl,
|
package/src/database.js
CHANGED
|
@@ -21,7 +21,7 @@ function getDbPath() {
|
|
|
21
21
|
/**
|
|
22
22
|
* Current schema version - increment this when adding new migrations
|
|
23
23
|
*/
|
|
24
|
-
const CURRENT_SCHEMA_VERSION =
|
|
24
|
+
const CURRENT_SCHEMA_VERSION = 51;
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Database schema SQL statements
|
|
@@ -123,6 +123,7 @@ const SCHEMA_SQL = {
|
|
|
123
123
|
pr_data TEXT,
|
|
124
124
|
last_ai_run_id TEXT,
|
|
125
125
|
last_accessed_at TEXT,
|
|
126
|
+
host TEXT,
|
|
126
127
|
UNIQUE(pr_number, repository)
|
|
127
128
|
)
|
|
128
129
|
`,
|
|
@@ -312,6 +313,7 @@ const SCHEMA_SQL = {
|
|
|
312
313
|
html_url TEXT,
|
|
313
314
|
state TEXT DEFAULT 'open',
|
|
314
315
|
collection TEXT NOT NULL,
|
|
316
|
+
host TEXT,
|
|
315
317
|
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
316
318
|
)
|
|
317
319
|
`,
|
|
@@ -349,6 +351,7 @@ const SCHEMA_SQL = {
|
|
|
349
351
|
diff_position INTEGER,
|
|
350
352
|
commit_sha TEXT,
|
|
351
353
|
is_outdated INTEGER NOT NULL DEFAULT 0,
|
|
354
|
+
is_file_level INTEGER NOT NULL DEFAULT 0,
|
|
352
355
|
original_line_start INTEGER,
|
|
353
356
|
original_line_end INTEGER,
|
|
354
357
|
original_commit_sha TEXT,
|
|
@@ -397,8 +400,14 @@ const INDEX_SQL = [
|
|
|
397
400
|
'CREATE INDEX IF NOT EXISTS idx_context_files_review ON context_files(review_id)',
|
|
398
401
|
// Hunk summaries indexes
|
|
399
402
|
'CREATE INDEX IF NOT EXISTS idx_hunk_summaries_review ON hunk_summaries(review_id)',
|
|
400
|
-
// GitHub PR cache indexes
|
|
401
|
-
|
|
403
|
+
// GitHub PR cache indexes. `host` is part of the unique key so a dual-host
|
|
404
|
+
// repo can cache the same (collection, owner, repo, number) from github.com
|
|
405
|
+
// (host NULL) AND its alt host (host = api_host URL) without colliding —
|
|
406
|
+
// independently-numbered PRs across systems overlap on small numbers. SQLite
|
|
407
|
+
// treats NULLs as distinct in unique indexes, so two NULL-host rows would not
|
|
408
|
+
// collide, but the collections refresh DELETEs the collection before
|
|
409
|
+
// re-inserting, so duplicate NULL-host rows can't accumulate. See migration 51.
|
|
410
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number, host)',
|
|
402
411
|
// Worktree pool indexes
|
|
403
412
|
'CREATE INDEX IF NOT EXISTS idx_worktree_pool_repo ON worktree_pool(repository)',
|
|
404
413
|
'CREATE INDEX IF NOT EXISTS idx_worktree_pool_status ON worktree_pool(repository, status)',
|
|
@@ -1305,6 +1314,9 @@ const MIGRATIONS = {
|
|
|
1305
1314
|
fetched_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
1306
1315
|
)
|
|
1307
1316
|
`);
|
|
1317
|
+
// Historical shape: no `host` column exists at v26 (added in migration
|
|
1318
|
+
// 50). Migration 51 widens this index to include `host`. Do NOT add
|
|
1319
|
+
// `host` here — it would reference a column that doesn't exist yet.
|
|
1308
1320
|
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)');
|
|
1309
1321
|
console.log(' Created github_pr_cache table');
|
|
1310
1322
|
} else {
|
|
@@ -2159,6 +2171,72 @@ const MIGRATIONS = {
|
|
|
2159
2171
|
console.log(' Table tours already exists');
|
|
2160
2172
|
}
|
|
2161
2173
|
console.log('Migration to schema version 48 complete');
|
|
2174
|
+
},
|
|
2175
|
+
|
|
2176
|
+
// Migration to version 49: Add is_file_level flag to external_comments so
|
|
2177
|
+
// file-level review comments (GitHub subject_type='file') can be rendered in
|
|
2178
|
+
// the per-file comments zone instead of as a bogus line-1 annotation.
|
|
2179
|
+
// Idempotent single-column ALTER guarded by columnExists — safe to re-run.
|
|
2180
|
+
// A single DDL statement needs no transaction wrapper.
|
|
2181
|
+
49: (db) => {
|
|
2182
|
+
console.log('Running migration to schema version 49: Add is_file_level to external_comments...');
|
|
2183
|
+
if (!tableExists(db, 'external_comments')) {
|
|
2184
|
+
// Table not created yet (older instance below version 45). Migration 45
|
|
2185
|
+
// creates it with the current base schema; nothing to alter here.
|
|
2186
|
+
console.log(' external_comments table not present; nothing to alter');
|
|
2187
|
+
} else if (!columnExists(db, 'external_comments', 'is_file_level')) {
|
|
2188
|
+
db.exec('ALTER TABLE external_comments ADD COLUMN is_file_level INTEGER NOT NULL DEFAULT 0');
|
|
2189
|
+
console.log(' Added is_file_level column to external_comments');
|
|
2190
|
+
} else {
|
|
2191
|
+
console.log(' Column is_file_level already exists');
|
|
2192
|
+
}
|
|
2193
|
+
console.log('Migration to schema version 49 complete');
|
|
2194
|
+
},
|
|
2195
|
+
|
|
2196
|
+
// Migration to version 50: Add host column to pr_metadata and github_pr_cache
|
|
2197
|
+
// for per-PR host resolution (dual GitHub + alt-host repos). NULL means
|
|
2198
|
+
// github.com; otherwise the configured api_host URL string. No backfill — the
|
|
2199
|
+
// NULL-means-github fallback makes existing rows resolve identically, and rows
|
|
2200
|
+
// are re-stamped on next fetch. Each ALTER is guarded by columnExists so the
|
|
2201
|
+
// migration is idempotent; single DDL statements need no transaction wrapper.
|
|
2202
|
+
50: (db) => {
|
|
2203
|
+
console.log('Running migration to schema version 50: Add host to pr_metadata and github_pr_cache...');
|
|
2204
|
+
if (tableExists(db, 'pr_metadata') && !columnExists(db, 'pr_metadata', 'host')) {
|
|
2205
|
+
db.exec('ALTER TABLE pr_metadata ADD COLUMN host TEXT');
|
|
2206
|
+
console.log(' Added host column to pr_metadata');
|
|
2207
|
+
} else {
|
|
2208
|
+
console.log(' Column host already exists on pr_metadata (or table absent)');
|
|
2209
|
+
}
|
|
2210
|
+
if (tableExists(db, 'github_pr_cache') && !columnExists(db, 'github_pr_cache', 'host')) {
|
|
2211
|
+
db.exec('ALTER TABLE github_pr_cache ADD COLUMN host TEXT');
|
|
2212
|
+
console.log(' Added host column to github_pr_cache');
|
|
2213
|
+
} else {
|
|
2214
|
+
console.log(' Column host already exists on github_pr_cache (or table absent)');
|
|
2215
|
+
}
|
|
2216
|
+
console.log('Migration to schema version 50 complete');
|
|
2217
|
+
},
|
|
2218
|
+
|
|
2219
|
+
// Migration to version 51: widen idx_github_pr_cache_unique to include host.
|
|
2220
|
+
// The collections refresh inserts github rows (host NULL) and alt-host rows
|
|
2221
|
+
// (host = api_host URL) under the same collection in one transaction. For a
|
|
2222
|
+
// dual-host repo whose PRs are numbered independently per system, a shared
|
|
2223
|
+
// (collection, owner, repo, number) would violate the old 4-column unique
|
|
2224
|
+
// index — and the single throw rolls back the DELETE plus BOTH insert loops,
|
|
2225
|
+
// so the user sees zero PRs for that collection. Adding host to the key lets
|
|
2226
|
+
// the two systems' same-numbered PRs coexist. The new index is strictly wider
|
|
2227
|
+
// than the old one, so existing data can never violate it. Idempotent:
|
|
2228
|
+
// DROP IF EXISTS then CREATE IF NOT EXISTS yields the same shape on re-run and
|
|
2229
|
+
// is safe on both a v50 DB (old 4-column index) and a fresh DB.
|
|
2230
|
+
51: (db) => {
|
|
2231
|
+
console.log('Running migration to schema version 51: widen idx_github_pr_cache_unique to include host...');
|
|
2232
|
+
if (tableExists(db, 'github_pr_cache')) {
|
|
2233
|
+
db.exec('DROP INDEX IF EXISTS idx_github_pr_cache_unique');
|
|
2234
|
+
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number, host)');
|
|
2235
|
+
console.log(' Rebuilt idx_github_pr_cache_unique with host in the key');
|
|
2236
|
+
} else {
|
|
2237
|
+
console.log(' Table github_pr_cache absent; nothing to reindex');
|
|
2238
|
+
}
|
|
2239
|
+
console.log('Migration to schema version 51 complete');
|
|
2162
2240
|
}
|
|
2163
2241
|
};
|
|
2164
2242
|
|
|
@@ -4974,6 +5052,52 @@ class PRMetadataRepository {
|
|
|
4974
5052
|
};
|
|
4975
5053
|
}
|
|
4976
5054
|
|
|
5055
|
+
/**
|
|
5056
|
+
* Get the stored host binding for a PR.
|
|
5057
|
+
*
|
|
5058
|
+
* Distinguishes "no row" from "row says github". Used by per-PR host
|
|
5059
|
+
* resolution (dual GitHub + alt-host repos) to decide which system a PR
|
|
5060
|
+
* lives on before building a binding.
|
|
5061
|
+
*
|
|
5062
|
+
* @param {string} repository - Repository in owner/repo format
|
|
5063
|
+
* @param {number} prNumber - Pull request number
|
|
5064
|
+
* @returns {Promise<string|null|undefined>} The stored api_host URL string,
|
|
5065
|
+
* `null` when the row exists but host is unset (github.com), or `undefined`
|
|
5066
|
+
* when no pr_metadata row exists for the PR.
|
|
5067
|
+
*/
|
|
5068
|
+
async getPRHost(repository, prNumber) {
|
|
5069
|
+
const row = await queryOne(this.db, `
|
|
5070
|
+
SELECT host FROM pr_metadata
|
|
5071
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
5072
|
+
`, [prNumber, repository]);
|
|
5073
|
+
|
|
5074
|
+
if (!row) return undefined;
|
|
5075
|
+
return row.host;
|
|
5076
|
+
}
|
|
5077
|
+
|
|
5078
|
+
/**
|
|
5079
|
+
* Update ONLY the host binding for a PR's metadata row.
|
|
5080
|
+
*
|
|
5081
|
+
* Used to persist an explicit host correction (e.g. a `?host` query param on
|
|
5082
|
+
* the /pr fast path) without re-running full setup. Leaves every other column
|
|
5083
|
+
* untouched.
|
|
5084
|
+
*
|
|
5085
|
+
* @param {string} repository - Repository in owner/repo format
|
|
5086
|
+
* @param {number} prNumber - Pull request number
|
|
5087
|
+
* @param {string|null} host - api_host URL string, or null for github.com
|
|
5088
|
+
* @returns {Promise<boolean>} True if a matching row was updated, false when
|
|
5089
|
+
* no pr_metadata row exists for the PR.
|
|
5090
|
+
*/
|
|
5091
|
+
async updatePRHost(repository, prNumber, host) {
|
|
5092
|
+
const result = await run(this.db, `
|
|
5093
|
+
UPDATE pr_metadata
|
|
5094
|
+
SET host = ?, updated_at = CURRENT_TIMESTAMP
|
|
5095
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
5096
|
+
`, [host, prNumber, repository]);
|
|
5097
|
+
|
|
5098
|
+
return result.changes > 0;
|
|
5099
|
+
}
|
|
5100
|
+
|
|
4977
5101
|
/**
|
|
4978
5102
|
* Update the last_ai_run_id for a PR metadata record
|
|
4979
5103
|
* @param {number} id - PR metadata record ID
|
|
@@ -5007,7 +5131,7 @@ class AnalysisRunRepository {
|
|
|
5007
5131
|
* @param {Object} runInfo - Run information
|
|
5008
5132
|
* @param {string} runInfo.id - Unique run ID (UUID)
|
|
5009
5133
|
* @param {number} runInfo.reviewId - Review ID (references reviews.id, works for both PR and local modes)
|
|
5010
|
-
* @param {string} [runInfo.provider] - AI provider (claude,
|
|
5134
|
+
* @param {string} [runInfo.provider] - AI provider (claude, antigravity, etc.)
|
|
5011
5135
|
* @param {string} [runInfo.model] - AI model name
|
|
5012
5136
|
* @param {string} [runInfo.customInstructions] - Merged custom instructions (kept for backward compatibility)
|
|
5013
5137
|
* @param {string} [runInfo.globalInstructions] - Global instructions from ~/.pair-review/global-instructions.md
|