@in-the-loop-labs/pair-review 3.6.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +17 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +89 -44
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ChatPanel.js +11 -1
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/SuggestionNavigator.js +55 -10
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +58 -8
  18. package/public/js/modules/suggestion-manager.js +25 -1
  19. package/public/js/modules/tour-renderer.js +45 -5
  20. package/public/js/pr.js +703 -171
  21. package/public/js/repo-links.js +328 -0
  22. package/public/js/utils/provider-model.js +88 -0
  23. package/public/js/utils/scroll-into-view.js +164 -0
  24. package/public/js/utils/storage-keys.js +50 -0
  25. package/public/local.html +10 -0
  26. package/public/pr.html +10 -0
  27. package/public/repo-settings.html +1 -0
  28. package/public/setup.html +2 -0
  29. package/src/ai/analyzer.js +125 -18
  30. package/src/ai/claude-provider.js +31 -3
  31. package/src/config.js +664 -10
  32. package/src/external/github-adapter.js +114 -25
  33. package/src/git/base-branch.js +11 -4
  34. package/src/github/client.js +482 -588
  35. package/src/github/errors.js +55 -0
  36. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  37. package/src/github/impl/graphql/pending-review.js +153 -0
  38. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  39. package/src/github/impl/graphql/stack-walker.js +210 -0
  40. package/src/github/impl/host/pending-review-comments.js +338 -0
  41. package/src/github/impl/rest/pending-review.js +251 -0
  42. package/src/github/impl/rest/review-lifecycle.js +226 -0
  43. package/src/github/impl/rest/stack-walker.js +309 -0
  44. package/src/github/operations/pending-review-comments.js +79 -0
  45. package/src/github/operations/pending-review.js +89 -0
  46. package/src/github/operations/review-lifecycle.js +126 -0
  47. package/src/github/operations/stack-walker.js +87 -0
  48. package/src/github/parser.js +230 -4
  49. package/src/github/stack-walker.js +14 -189
  50. package/src/links/repo-links.js +230 -0
  51. package/src/local-review.js +13 -4
  52. package/src/main.js +136 -32
  53. package/src/routes/analyses.js +30 -7
  54. package/src/routes/bulk-analysis-configs.js +295 -0
  55. package/src/routes/config.js +102 -2
  56. package/src/routes/external-comments.js +20 -10
  57. package/src/routes/github-collections.js +3 -1
  58. package/src/routes/local.js +101 -11
  59. package/src/routes/mcp.js +47 -4
  60. package/src/routes/pr.js +298 -68
  61. package/src/routes/setup.js +8 -3
  62. package/src/routes/stack-analysis.js +33 -9
  63. package/src/routes/worktrees.js +3 -2
  64. package/src/server.js +2 -0
  65. package/src/setup/pr-setup.js +37 -11
  66. package/src/setup/stack-setup.js +13 -3
  67. package/src/single-port.js +6 -3
@@ -0,0 +1,230 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Repo Links Resolver
4
+ *
5
+ * Resolves per-repo header link configuration. Configuration shape:
6
+ *
7
+ * "links": {
8
+ * "external": { "label": "...", "url_template": "https://...", "icon": "<svg ...>...</svg>" },
9
+ * "github": false, // hide default GitHub link
10
+ * "graphite": false // hide Graphite stack link
11
+ * }
12
+ *
13
+ * Public API:
14
+ *
15
+ * substituteUrlTemplate(template, context)
16
+ * — Replace whitelisted placeholders `{owner}`, `{repo}`, `{number}`,
17
+ * `{branch}`, `{base_branch}`, `{head_sha}` with URL-encoded values
18
+ * from `context`. Returns `null` if the resulting URL does not start
19
+ * with `https://` or if the template is malformed.
20
+ *
21
+ * sanitizeSvgIcon(svg)
22
+ * — Strip `<script>` tags, on* event-handler attributes, and
23
+ * `javascript:` URLs. Returns the sanitised SVG string, or null if
24
+ * the value is not a string that looks like SVG markup.
25
+ *
26
+ * resolveRepoLinks(config, repository)
27
+ * — Returns `{ external, github, graphite }` for the given
28
+ * `owner/repo`. The booleans are normalised: any non-false value (or
29
+ * absence) becomes `true`. `external` is `null` unless valid, with
30
+ * the icon already sanitised.
31
+ */
32
+
33
+ const { getRepoConfig } = require('../config');
34
+ const logger = require('../utils/logger');
35
+
36
+ // Whitelist of allowed `{placeholder}` names. Anything else in the template
37
+ // is left unsubstituted (so a malformed template surfaces visually rather
38
+ // than silently producing the wrong URL).
39
+ const ALLOWED_PLACEHOLDERS = new Set([
40
+ 'owner', 'repo', 'number', 'branch', 'base_branch', 'head_sha'
41
+ ]);
42
+
43
+ // Matches HTML on* event-handler attributes regardless of quoting style.
44
+ // Used to strip dangerous attributes from user-supplied SVG icons.
45
+ const ON_HANDLER_RE = /\s+on[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/g;
46
+
47
+ // Matches a complete `<script ...>...</script>` block (including the open
48
+ // and close tags) with case-insensitive, dot-matches-newline semantics so
49
+ // payload bodies that span lines are removed in one pass.
50
+ const SCRIPT_BLOCK_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
51
+
52
+ // Matches standalone <script ...> open tags missing a closing tag — strip
53
+ // them as well so a half-broken payload can't slip through.
54
+ const SCRIPT_OPEN_RE = /<script\b[^>]*>/gi;
55
+
56
+ // Matches values that begin with `javascript:` (anchor href, attribute
57
+ // values). Captures the leading quote so we can preserve attribute syntax.
58
+ const JS_URL_RE = /(["'=])\s*javascript:[^"'\s>]*/gi;
59
+
60
+ /**
61
+ * Substitute whitelisted `{placeholder}` tokens in a URL template.
62
+ *
63
+ * Behaviour:
64
+ * - Only `{owner}`, `{repo}`, `{number}`, `{branch}`, `{base_branch}`,
65
+ * and `{head_sha}` are substituted. Other tokens are left as-is so
66
+ * misconfigurations are visible.
67
+ * - Each substituted value is run through `encodeURIComponent()` to
68
+ * prevent injection of additional path segments or query params.
69
+ * - The result is rejected (returns `null`) unless it starts with
70
+ * `https://`, the same invariant `validateRepoConfig` enforces at
71
+ * startup. This is defence-in-depth: a template like
72
+ * `https://{owner}.example/foo` is technically `https://` at config
73
+ * time but could be subverted at substitution time if `{owner}` were
74
+ * unescaped — `encodeURIComponent` protects against that.
75
+ *
76
+ * @param {string} template
77
+ * @param {Object} context
78
+ * @param {string} [context.owner]
79
+ * @param {string} [context.repo]
80
+ * @param {number|string} [context.number]
81
+ * @param {string} [context.branch]
82
+ * @param {string} [context.base_branch]
83
+ * @param {string} [context.head_sha]
84
+ * @returns {string|null} Substituted URL, or null if it fails validation
85
+ */
86
+ function substituteUrlTemplate(template, context) {
87
+ if (typeof template !== 'string' || !template) return null;
88
+ const ctx = context || {};
89
+
90
+ const substituted = template.replace(/\{([a-zA-Z_]+)\}/g, (match, name) => {
91
+ if (!ALLOWED_PLACEHOLDERS.has(name)) return match;
92
+ const value = ctx[name];
93
+ if (value === undefined || value === null || value === '') return match;
94
+ return encodeURIComponent(String(value));
95
+ });
96
+
97
+ if (!substituted.startsWith('https://')) return null;
98
+ // If any unsubstituted whitelisted placeholders remain, the URL is
99
+ // incomplete — reject rather than producing a broken link.
100
+ for (const placeholder of ALLOWED_PLACEHOLDERS) {
101
+ if (substituted.includes(`{${placeholder}}`)) return null;
102
+ }
103
+ return substituted;
104
+ }
105
+
106
+ /**
107
+ * Strip dangerous content from user-supplied SVG markup.
108
+ *
109
+ * Removes:
110
+ * - `<script>...</script>` blocks (paired or unpaired)
111
+ * - All `on*=` event-handler attributes
112
+ * - Attribute values starting with `javascript:` (sets them to empty)
113
+ *
114
+ * This is the bare-minimum sanitisation called out in the plan. The
115
+ * frontend additionally re-parses the SVG via DOMParser before injecting
116
+ * it into the DOM, so any script-like content that slips through gets a
117
+ * second filter at insertion time.
118
+ *
119
+ * @param {string} svg
120
+ * @returns {string|null} Sanitised SVG, or null if the input is not a
121
+ * string that looks like SVG markup.
122
+ */
123
+ function sanitizeSvgIcon(svg) {
124
+ if (typeof svg !== 'string' || !svg.trim()) return null;
125
+ // Sanity check: the value should at least look like SVG markup. If it
126
+ // doesn't, refuse — `<svg ...>` is the only contract.
127
+ if (!/<svg\b/i.test(svg)) return null;
128
+
129
+ let cleaned = svg
130
+ .replace(SCRIPT_BLOCK_RE, '')
131
+ .replace(SCRIPT_OPEN_RE, '')
132
+ .replace(ON_HANDLER_RE, '')
133
+ // Replace dangerous javascript: URLs with empty strings, preserving
134
+ // the surrounding quote character so the attribute remains parseable.
135
+ .replace(JS_URL_RE, (_match, lead) => `${lead}`);
136
+
137
+ // Final guard: if any of the dangerous patterns survived (e.g.
138
+ // mismatched encoding tricks), reject the whole icon.
139
+ if (/<script\b/i.test(cleaned)) return null;
140
+ if (/\son[a-zA-Z]+\s*=/i.test(cleaned)) return null;
141
+ if (/javascript:/i.test(cleaned)) return null;
142
+
143
+ return cleaned;
144
+ }
145
+
146
+ /**
147
+ * Resolve link configuration for a repo. Reads `repos[owner/repo].links`
148
+ * and produces a normalised object the frontend can act on without
149
+ * further interpretation.
150
+ *
151
+ * `links.github` and `links.graphite` are booleans that gate the default
152
+ * built-in links. Anything that isn't an explicit `false` (including
153
+ * `undefined` and `true`) leaves the link enabled — preserving current
154
+ * behaviour for repos that omit the `links` block entirely.
155
+ *
156
+ * For the external link, the icon is sanitised here, server-side. If
157
+ * sanitisation strips the icon entirely (e.g. the input was hostile or
158
+ * malformed), a warning is logged and `icon` becomes `null` — the link
159
+ * is still rendered, just without a custom icon.
160
+ *
161
+ * @param {Object} config
162
+ * @param {string} repository Canonical `owner/repo` identifier
163
+ * @returns {{ external: { label: string, url_template: string, icon: string|null }|null,
164
+ * github: boolean,
165
+ * graphite: boolean }}
166
+ */
167
+ function resolveRepoLinks(config, repository) {
168
+ const result = { external: null, github: true, graphite: true };
169
+ if (!config || !repository) return result;
170
+
171
+ const repoConfig = getRepoConfig(config, repository);
172
+ if (!repoConfig || typeof repoConfig !== 'object') return result;
173
+
174
+ const links = repoConfig.links;
175
+ if (!links || typeof links !== 'object') return result;
176
+
177
+ if (links.github === false) result.github = false;
178
+ if (links.graphite === false) result.graphite = false;
179
+
180
+ const ext = links.external;
181
+ if (ext && typeof ext === 'object'
182
+ && typeof ext.label === 'string' && ext.label
183
+ && typeof ext.url_template === 'string'
184
+ && ext.url_template.startsWith('https://')) {
185
+ let icon = null;
186
+ if (ext.icon !== undefined && ext.icon !== null && ext.icon !== '') {
187
+ icon = sanitizeSvgIcon(ext.icon);
188
+ if (icon === null) {
189
+ logger.warn(
190
+ `Dropping links.external.icon for "${repository}" — failed sanitisation.`
191
+ );
192
+ }
193
+ }
194
+ result.external = {
195
+ // Optional host display name (e.g. "Meteorite"). When absent, the
196
+ // field is null and consumers fall back to "GitHub" via resolveHostName.
197
+ name: (typeof ext.name === 'string' && ext.name) ? ext.name : null,
198
+ label: ext.label,
199
+ url_template: ext.url_template,
200
+ icon
201
+ };
202
+ }
203
+
204
+ return result;
205
+ }
206
+
207
+ /**
208
+ * Resolve the display name of the remote code host for a repo, for use in
209
+ * user-facing text in place of the literal "GitHub".
210
+ *
211
+ * Returns `repos[owner/repo].links.external.name` when configured, otherwise
212
+ * `"GitHub"`. This is the server-side counterpart to the frontend
213
+ * `window.RepoLinks.hostName()` accessor.
214
+ *
215
+ * @param {Object} config
216
+ * @param {string} repository Canonical `owner/repo` identifier
217
+ * @returns {string} The configured host name, or "GitHub" by default
218
+ */
219
+ function resolveHostName(config, repository) {
220
+ const links = resolveRepoLinks(config, repository);
221
+ return (links.external && links.external.name) ? links.external.name : 'GitHub';
222
+ }
223
+
224
+ module.exports = {
225
+ substituteUrlTemplate,
226
+ sanitizeSvgIcon,
227
+ resolveRepoLinks,
228
+ resolveHostName,
229
+ ALLOWED_PLACEHOLDERS,
230
+ };
@@ -4,7 +4,7 @@ const { promisify } = require('util');
4
4
  const crypto = require('crypto');
5
5
  const path = require('path');
6
6
  const fs = require('fs').promises;
7
- const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken } = require('./config');
7
+ const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken, resolveHostBinding } = require('./config');
8
8
  const logger = require('./utils/logger');
9
9
  const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
10
10
  const { fireHooks, hasHooks } = require('./hooks/hook-runner');
@@ -791,11 +791,14 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {} }) {
791
791
  let branchInfo = null;
792
792
  if (!includesBranch(scopeStart)) {
793
793
  const untrackedFiles = await getUntrackedFiles(repoPath);
794
+ // Resolve binding so alt-host repos look up PRs on the right host.
795
+ const branchBinding = repository ? resolveHostBinding(repository, config) : null;
794
796
  branchInfo = await module.exports.detectAndBuildBranchInfo(repoPath, branch, {
795
797
  repository,
796
798
  diff,
797
799
  untrackedFiles,
798
- githubToken: getGitHubToken(config),
800
+ githubToken: branchBinding?.token || getGitHubToken(config),
801
+ hostBinding: branchBinding,
799
802
  enableGraphite: config.enable_graphite === true
800
803
  });
801
804
  if (branchInfo) {
@@ -1038,11 +1041,12 @@ async function getFirstCommitSubject(repoPath, baseBranch) {
1038
1041
  * @param {string} [options.diff] - The uncommitted diff content (empty = eligible)
1039
1042
  * @param {Array} [options.untrackedFiles] - Untracked files array (empty = eligible)
1040
1043
  * @param {string} [options.githubToken] - Resolved GitHub token for PR lookup
1044
+ * @param {Object} [options.hostBinding] - Resolved host binding (apiHost/token/features) for alt-host PR lookup
1041
1045
  * @param {boolean} [options.enableGraphite] - When true, try Graphite CLI for parent branch
1042
1046
  * @returns {Promise<{baseBranch: string, commitCount: number, source: string, prNumber?: number}|null>}
1043
1047
  */
1044
1048
  async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1045
- const { repository, diff, untrackedFiles, githubToken, enableGraphite } = options;
1049
+ const { repository, diff, untrackedFiles, githubToken, hostBinding, enableGraphite } = options;
1046
1050
 
1047
1051
  // Guard: detached HEAD, has uncommitted changes, or has untracked files
1048
1052
  if (branch === 'HEAD') return null;
@@ -1051,7 +1055,12 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
1051
1055
 
1052
1056
  try {
1053
1057
  const { detectBaseBranch } = require('./git/base-branch');
1054
- const depsOverride = githubToken ? { getGitHubToken: () => githubToken } : undefined;
1058
+ const depsOverride = githubToken || hostBinding
1059
+ ? {
1060
+ getGitHubToken: () => githubToken || '',
1061
+ getHostBinding: () => hostBinding || null
1062
+ }
1063
+ : undefined;
1055
1064
  const detection = await detectBaseBranch(repoPath, branch, {
1056
1065
  repository,
1057
1066
  enableGraphite,
package/src/main.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const fs = require('fs');
3
- const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
3
+ const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
4
4
  const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository } = require('./database');
5
5
  const { PRArgumentParser } = require('./github/parser');
6
6
  const { GitHubClient } = require('./github/client');
@@ -26,6 +26,47 @@ const { attemptDelegation } = require('./single-port');
26
26
 
27
27
  let db = null;
28
28
 
29
+ /**
30
+ * Build a user-facing "missing token" error message tailored to the
31
+ * resolved host binding.
32
+ *
33
+ * For github.com bindings (`apiHost === null`), suggest the legacy
34
+ * options: `GITHUB_TOKEN` env var, `config.github_token`, or
35
+ * `config.github_token_command`. For alt-host bindings, suppress those
36
+ * suggestions (the github.com top-level credentials are intentionally
37
+ * not used for alt-hosts) and point exclusively at the per-repo
38
+ * `token` / `token_command` keys under the resolved bindingRepository.
39
+ *
40
+ * @param {Object} params
41
+ * @param {string} params.owner
42
+ * @param {string} params.repo
43
+ * @param {string} params.bindingRepository - The `repos[...]` config key
44
+ * that the binding lookup was performed against. May differ from
45
+ * `<owner>/<repo>` for monorepo-style configs (see Fix #8).
46
+ * @param {string|null} params.apiHost - Resolved api_host (null = github.com)
47
+ * @returns {string}
48
+ */
49
+ function buildMissingTokenError({ owner, repo, bindingRepository, apiHost }) {
50
+ if (apiHost) {
51
+ return (
52
+ `GitHub token not found for alt-host repo ${owner}/${repo} (${apiHost}). ` +
53
+ `GITHUB_TOKEN and top-level github_token/github_token_command are github.com-only ` +
54
+ `and are not used for alt-hosts. Configure ` +
55
+ `\`config.repos["${bindingRepository}"].token\` or ` +
56
+ `\`config.repos["${bindingRepository}"].token_command\` ` +
57
+ `(e.g., "althost-cli auth token"). Run: npx pair-review --configure`
58
+ );
59
+ }
60
+ return (
61
+ `GitHub token not found for ${owner}/${repo}. ` +
62
+ `Set GITHUB_TOKEN env var, or configure a token at ` +
63
+ `\`config.repos["${bindingRepository}"].token\` or ` +
64
+ `\`config.repos["${bindingRepository}"].token_command\`, or set ` +
65
+ `top-level \`github_token\` / \`github_token_command\` ` +
66
+ `(e.g., "gh auth token"). Run: npx pair-review --configure`
67
+ );
68
+ }
69
+
29
70
  /**
30
71
  * Detect PR information from GitHub Actions environment variables.
31
72
  * Returns null if not in GitHub Actions or PR info cannot be determined.
@@ -118,8 +159,9 @@ OPTIONS:
118
159
  The web UI also starts for the human reviewer.
119
160
  --model <name> Override the AI model. Claude Code is the default provider.
120
161
  Available models: opus, sonnet, haiku (Claude Code);
121
- also: opus-4.8-xhigh, opus-4.8-high, opus-4.7-xhigh,
122
- opus-4.7-high, opus-4.6-high, opus-4.6-1m, sonnet-4.6
162
+ also: fable-5-xhigh, fable-5-high, opus-4.8-xhigh,
163
+ opus-4.8-high, opus-4.7-xhigh, opus-4.7-high,
164
+ opus-4.6-high, opus-4.6-1m, sonnet-4.6
123
165
  (opus is Opus 4.7 XHigh, the default)
124
166
  or use provider-specific models with Gemini/Codex
125
167
  --use-checkout Use current directory instead of creating worktree
@@ -573,16 +615,33 @@ AI PROVIDERS:
573
615
  */
574
616
  async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = null) {
575
617
  try {
576
- // Get GitHub token (env var takes precedence over config)
577
- const githubToken = getGitHubToken(config);
578
- if (!githubToken) {
579
- throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
580
- }
581
-
582
- // Parse PR arguments
583
- const parser = new PRArgumentParser();
618
+ // Parse PR arguments FIRST pass config so url_pattern matching for
619
+ // alternate hosts can resolve pasted URLs to the canonical
620
+ // owner/repo before falling back to GitHub/Graphite parsers.
621
+ // We must know the target repo before resolving its token, so that
622
+ // repo-scoped tokens, repo-scoped token_command entries, and alt-host
623
+ // configurations are honored. A no-repository token preflight only
624
+ // sees env + top-level credentials and would reject valid configs.
625
+ const parser = new PRArgumentParser(config);
584
626
  const prInfo = await parser.parsePRArguments(args);
585
627
 
628
+ // Resolve token via repo-aware host binding so alt-host and
629
+ // repo-scoped credentials are respected. When a per-repo
630
+ // `url_pattern` matched, prefer its `bindingRepository` — that's the
631
+ // matched `repos[...]` config key, which may differ from the
632
+ // captured `${owner}/${repo}` for monorepo-style patterns.
633
+ const repositoryForBinding = prInfo.bindingRepository
634
+ || normalizeRepository(prInfo.owner, prInfo.repo);
635
+ const binding = resolveHostBinding(repositoryForBinding, config);
636
+ if (!binding.token) {
637
+ throw new Error(buildMissingTokenError({
638
+ owner: prInfo.owner,
639
+ repo: prInfo.repo,
640
+ bindingRepository: repositoryForBinding,
641
+ apiHost: binding.apiHost
642
+ }));
643
+ }
644
+
586
645
  // Register cwd as known repo path if it matches the target repo
587
646
  const currentDir = parser.getCurrentDirectory();
588
647
  const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
@@ -677,20 +736,33 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
677
736
  let poolLifecycle = null;
678
737
 
679
738
  try {
680
- // Get GitHub token (env var takes precedence over config)
681
- const githubToken = getGitHubToken(config);
682
- if (!githubToken) {
683
- throw new Error('GitHub token not found. Set GITHUB_TOKEN env var, add github_token to config, or set github_token_command (e.g., "gh auth token"). Run: npx pair-review --configure');
684
- }
685
-
686
- // Parse PR arguments
687
- const parser = new PRArgumentParser();
739
+ // Parse PR arguments pass config so url_pattern matching for
740
+ // alternate hosts can resolve pasted URLs to the canonical
741
+ // owner/repo before falling back to GitHub/Graphite parsers.
742
+ const parser = new PRArgumentParser(config);
688
743
  prInfo = await parser.parsePRArguments(args);
689
744
 
745
+ // Resolve host binding for the target repo (handles alt-host).
746
+ // Prefer `bindingRepository` (from url_pattern match) over the raw
747
+ // `${owner}/${repo}` since they can differ when one config entry
748
+ // serves many monorepo-shaped URLs.
749
+ const repositoryForBinding = prInfo.bindingRepository
750
+ || normalizeRepository(prInfo.owner, prInfo.repo);
751
+ const headlessBinding = resolveHostBinding(repositoryForBinding, config);
752
+ if (!headlessBinding.token) {
753
+ throw new Error(buildMissingTokenError({
754
+ owner: prInfo.owner,
755
+ repo: prInfo.repo,
756
+ bindingRepository: repositoryForBinding,
757
+ apiHost: headlessBinding.apiHost
758
+ }));
759
+ }
760
+ const githubToken = headlessBinding.token;
761
+
690
762
  console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in ${options.modeLabel}`);
691
763
 
692
764
  // Create GitHub client and verify repository access
693
- const githubClient = new GitHubClient(githubToken);
765
+ const githubClient = new GitHubClient(headlessBinding);
694
766
  const repoExists = await githubClient.repositoryExists(prInfo.owner, prInfo.repo);
695
767
  if (!repoExists) {
696
768
  throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
@@ -786,9 +858,10 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
786
858
 
787
859
  // Resolve monorepo config options (checkout_script, worktree_directory, worktree_name_template)
788
860
  // even when running from inside the target repo, so they are not silently ignored.
861
+ // Config lookups must use the binding key (matched `repos[...]` entry), not the PR identity.
789
862
  const repoSettingsRepo = new RepoSettingsRepository(db);
790
863
  const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
791
- const resolved = resolveRepoOptions(config, repository, repoSettings);
864
+ const resolved = resolveRepoOptions(config, repositoryForBinding, repoSettings);
792
865
  checkoutScript = resolved.checkoutScript;
793
866
  checkoutTimeout = resolved.checkoutTimeout;
794
867
  worktreeConfig = resolved.worktreeConfig;
@@ -802,8 +875,10 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
802
875
  owner: prInfo.owner,
803
876
  repo: prInfo.repo,
804
877
  repository,
878
+ bindingRepository: repositoryForBinding,
805
879
  prNumber: prInfo.number,
806
880
  config,
881
+ cloneUrl: prData?.repository?.clone_url,
807
882
  onProgress: (progress) => {
808
883
  if (progress.message) {
809
884
  console.log(progress.message);
@@ -816,11 +891,12 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
816
891
  checkoutTimeout = result.checkoutTimeout;
817
892
  worktreeConfig = result.worktreeConfig;
818
893
  // findRepositoryPath doesn't return pool config; resolve from DB + file config
894
+ // (binding key, not PR identity).
819
895
  const repoSettingsRepo = new RepoSettingsRepository(db);
820
896
  const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
821
- const { poolSize: resolvedPoolSize, poolFetchIntervalMinutes: _resolvedFetchInterval } = resolvePoolConfig(config, repository, repoSettings);
897
+ const { poolSize: resolvedPoolSize, poolFetchIntervalMinutes: _resolvedFetchInterval } = resolvePoolConfig(config, repositoryForBinding, repoSettings);
822
898
  poolSize = resolvedPoolSize || 0;
823
- resetScript = config ? getRepoResetScript(config, repository) : null;
899
+ resetScript = config ? getRepoResetScript(config, repositoryForBinding) : null;
824
900
  }
825
901
 
826
902
  const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
@@ -906,8 +982,11 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
906
982
 
907
983
  let analysisSummary = null;
908
984
  try {
909
- // Pass all instruction levels to ensure they're captured in the analysis run
910
- const analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { globalInstructions, repoInstructions });
985
+ // Pass all instruction levels to ensure they're captured in the analysis run.
986
+ // githubClient is forwarded so the analyzer can pre-fetch existing PR review
987
+ // comments when callers opt in via excludePrevious.github.
988
+ logger.debug(`analyzer githubClient wired for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}`);
989
+ const analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { globalInstructions, repoInstructions }, null, { githubClient });
911
990
  analysisSummary = analysisResult.summary;
912
991
  console.log('AI analysis completed successfully');
913
992
  } catch (analysisError) {
@@ -992,24 +1071,49 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
992
1071
  // Submit review to GitHub via GraphQL (same path as web UI)
993
1072
  console.log(`Submitting review with ${githubComments.length} comments...`);
994
1073
 
995
- const prNodeId = storedPRData.node_id;
996
- if (!prNodeId) {
997
- throw new Error(`PR node_id not available for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}. Cannot submit review without GraphQL node ID.`);
998
- }
999
-
1000
1074
  // Check for existing pending draft (GitHub only allows one per user per PR)
1001
1075
  const existingDraft = await githubClient.getPendingReviewForUser(
1002
1076
  prInfo.owner, prInfo.repo, prInfo.number
1003
1077
  );
1004
1078
 
1079
+ // GraphQL PR node id is only required when we'll be creating a NEW
1080
+ // GraphQL review (no existing draft to reuse) OR adding GraphQL
1081
+ // review comments. REST and host-impl configurations address the
1082
+ // PR by (owner, repo, prNumber) + numeric review id and ignore
1083
+ // prNodeId; reusing an existing GraphQL draft also doesn't need
1084
+ // the PR node id because the review node id is sufficient.
1085
+ const willCreateNewGraphQLReview =
1086
+ headlessBinding.features.review_lifecycle === 'graphql' && !existingDraft;
1087
+ const willAddGraphQLComments =
1088
+ githubComments.length > 0 && headlessBinding.features.pending_review_comments === 'graphql';
1089
+ const needsGraphQLNodeId = willCreateNewGraphQLReview || willAddGraphQLComments;
1090
+
1091
+ if (needsGraphQLNodeId && !storedPRData.node_id) {
1092
+ throw new Error(
1093
+ `GraphQL PR node id required for ${prInfo.owner}/${prInfo.repo}#${prInfo.number} ` +
1094
+ `(features.review_lifecycle = "${headlessBinding.features.review_lifecycle}", ` +
1095
+ `pending_review_comments = "${headlessBinding.features.pending_review_comments}"). ` +
1096
+ `PR record is missing node_id — refresh the PR data and try again.`
1097
+ );
1098
+ }
1099
+
1100
+ const prNodeId = storedPRData.node_id ?? null;
1101
+
1102
+ const submitPrContext = {
1103
+ owner: prInfo.owner,
1104
+ repo: prInfo.repo,
1105
+ prNumber: prInfo.number,
1106
+ reviewId: existingDraft?.databaseId
1107
+ };
1108
+
1005
1109
  let githubReview;
1006
1110
  if (options.reviewEvent === 'DRAFT') {
1007
1111
  githubReview = await githubClient.createDraftReviewGraphQL(
1008
- prNodeId, reviewBody, githubComments, existingDraft?.id
1112
+ prNodeId, reviewBody, githubComments, existingDraft?.id, submitPrContext
1009
1113
  );
1010
1114
  } else {
1011
1115
  githubReview = await githubClient.createReviewGraphQL(
1012
- prNodeId, options.reviewEvent, reviewBody, githubComments, existingDraft?.id
1116
+ prNodeId, options.reviewEvent, reviewBody, githubComments, existingDraft?.id, submitPrContext
1013
1117
  );
1014
1118
  }
1015
1119
 
@@ -33,7 +33,8 @@ const {
33
33
  killProcesses,
34
34
  createProgressCallback
35
35
  } = require('./shared');
36
- const { generateLocalDiff, computeLocalDiffDigest, getCurrentBranch } = require('../local-review');
36
+ const { generateScopedDiff, computeScopedDigest, getCurrentBranch } = require('../local-review');
37
+ const { reviewScope } = require('../local-scope');
37
38
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
38
39
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
39
40
 
@@ -229,13 +230,34 @@ router.post('/api/analyses/results', async (req, res) => {
229
230
  });
230
231
  }
231
232
 
232
- // Generate and store diff so the web UI can display it
233
+ // Generate and store diff so the web UI can display it. Persist to the
234
+ // `local_diffs` table as well as the in-memory cache so the diff survives
235
+ // a restart and the manual tour/summary buttons never falsely report
236
+ // "no-diff" for a review created via this analysis-push path.
237
+ //
238
+ // Use the review's recorded scope (mirroring the council path) — NOT the
239
+ // default-scope `generateLocalDiff` wrapper — so that re-using an existing
240
+ // review whose scope is `staged` or `branch` does not clobber its durable
241
+ // `local_diffs` row with a narrower default-scope patch. For brand-new
242
+ // reviews the upsert above created a default-scope row, so either lookup
243
+ // yields the correct scope; reviewScope() falls back to the default scope
244
+ // when the columns are unset.
245
+ const reviewForScope = existingReview || await reviewRepo.getLocalReviewById(reviewId);
246
+ const { start: scopeStart, end: scopeEnd } = reviewScope(reviewForScope);
233
247
  try {
234
- const diffResult = await generateLocalDiff(localPath);
235
- const digest = await computeLocalDiffDigest(localPath);
248
+ const diffResult = await generateScopedDiff(
249
+ localPath,
250
+ scopeStart,
251
+ scopeEnd,
252
+ reviewForScope.local_base_branch || null
253
+ );
254
+ const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
236
255
  localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
256
+ await reviewRepo.saveLocalDiff(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
237
257
  } catch (diffError) {
238
- logger.warn(`Could not generate diff for local review ${reviewId}: ${diffError.message}`);
258
+ // Covers both diff generation AND the durable saveLocalDiff write, so the
259
+ // message names both failure modes rather than only generation.
260
+ logger.warn(`Could not generate or persist diff for local review ${reviewId}: ${diffError.message}`);
239
261
  }
240
262
  } else {
241
263
  const repoParts = repo.split('/');
@@ -495,6 +517,7 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
495
517
  config: modeConfig,
496
518
  excludePrevious,
497
519
  serverPort,
520
+ githubClient,
498
521
  providerOverrides = {},
499
522
  providerOverridesMap = null,
500
523
  hookContext = {},
@@ -606,8 +629,8 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
606
629
  };
607
630
 
608
631
  analysisPromise = isVoiceCentric
609
- ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort })
610
- : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort });
632
+ ? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort, githubClient })
633
+ : analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort, githubClient });
611
634
  } catch (setupError) {
612
635
  // Synchronous setup failure — clean up the analysis hold immediately
613
636
  reviewToAnalysisId.delete(reviewId);