@in-the-loop-labs/pair-review 4.0.0 → 4.1.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 (43) hide show
  1. package/README.md +82 -7
  2. package/package.json +2 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +3 -4
  5. package/plugin-pair-loop/.claude-plugin/plugin.json +19 -0
  6. package/plugin-pair-loop/skills/loop/SKILL.md +233 -0
  7. package/public/js/index.js +87 -10
  8. package/public/js/local.js +19 -9
  9. package/public/js/pr.js +64 -36
  10. package/public/js/repo-links.js +11 -3
  11. package/public/js/utils/analyze-params.js +69 -0
  12. package/public/js/utils/provider-model.js +66 -1
  13. package/public/js/vendor/pierre-diffs-worker.js +16158 -0
  14. package/public/js/vendor/pierre-diffs.js +1880 -0
  15. package/public/local.html +1 -0
  16. package/public/pr.html +1 -0
  17. package/public/setup.html +35 -16
  18. package/src/ai/codex-provider.js +59 -11
  19. package/src/config.js +150 -28
  20. package/src/database.js +127 -3
  21. package/src/external/github-adapter.js +18 -3
  22. package/src/github/client.js +37 -0
  23. package/src/github/parser.js +41 -7
  24. package/src/interactive-analysis-config.js +2 -2
  25. package/src/links/repo-links.js +66 -28
  26. package/src/local-review.js +134 -5
  27. package/src/local-scope.js +38 -0
  28. package/src/main.js +199 -33
  29. package/src/routes/config.js +47 -12
  30. package/src/routes/external-comments.js +13 -1
  31. package/src/routes/github-collections.js +175 -13
  32. package/src/routes/local.js +11 -20
  33. package/src/routes/pr.js +63 -36
  34. package/src/routes/setup.js +63 -8
  35. package/src/routes/shared.js +85 -0
  36. package/src/routes/stack-analysis.js +39 -3
  37. package/src/server.js +74 -3
  38. package/src/setup/local-setup.js +23 -7
  39. package/src/setup/pr-setup.js +237 -39
  40. package/src/setup/stack-setup.js +7 -2
  41. package/src/single-port.js +73 -18
  42. package/src/utils/host-resolution.js +157 -0
  43. package/plugin-code-critic/skills/loop/SKILL.md +0 -373
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 = 48;
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
- 'CREATE UNIQUE INDEX IF NOT EXISTS idx_github_pr_cache_unique ON github_pr_cache(collection, owner, repo, number)',
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
@@ -24,6 +24,7 @@ const {
24
24
  resolveHostBinding,
25
25
  resolveBindingRepositoryFromPR
26
26
  } = require('../config');
27
+ const { storedHostToOption } = require('../utils/host-resolution');
27
28
 
28
29
  const name = 'github';
29
30
 
@@ -63,14 +64,26 @@ const credentialEnvVar = 'GITHUB_TOKEN';
63
64
  * diff-relative `position` field, so `mapComment` must anchor by `line`
64
65
  * instead — see its docstring.
65
66
  *
67
+ * Per-PR host: for a DUAL repo (github + alt-host) the binding depends on which
68
+ * system the PR actually lives on. The caller passes the PR's stored
69
+ * `pr_metadata.host` via `options.storedHost` (undefined | null | api_host URL);
70
+ * this method applies the legacy-NULL convention (`storedHostToOption`) and
71
+ * threads the resulting `{ host }` into `resolveHostBinding`, so a dual repo's
72
+ * alt-hosted PR resolves to the alt binding (and the line-based anchoring path)
73
+ * instead of always hitting api.github.com two-arg. When `options.storedHost`
74
+ * is omitted the two-arg ambiguity rule applies, unchanged.
75
+ *
66
76
  * @param {Object} config - Server config (see `loadConfig()`)
67
77
  * @param {string} [repository] - "owner/repo" identifier for binding-aware resolution
68
78
  * @param {Object} [_deps] - Test overrides for
69
79
  * { GitHubClient, getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR }
80
+ * @param {Object} [options] - Runtime resolution inputs
81
+ * @param {string|null|undefined} [options.storedHost] - The PR's stored
82
+ * pr_metadata.host (undefined = no row / unknown, null = github.com, URL = alt host)
70
83
  * @returns {{ client: Object, isAltHost: boolean }}
71
84
  * @throws {GitHubApiError} with status 401 when no token is configured
72
85
  */
73
- function resolveCredentials(config, repository, _deps) {
86
+ function resolveCredentials(config, repository, _deps, options = {}) {
74
87
  const deps = {
75
88
  GitHubClient,
76
89
  getGitHubToken,
@@ -82,10 +95,12 @@ function resolveCredentials(config, repository, _deps) {
82
95
 
83
96
  if (repository) {
84
97
  // Binding-aware path. Mirrors resolveBindingForRequest in routes/pr.js:
85
- // resolve the PR identity to a binding key, then to a host binding.
98
+ // resolve the PR identity to a binding key, then to a host binding
99
+ // pinned to the PR's stored host for dual repos.
86
100
  const [owner, repo] = String(repository).split('/');
87
101
  const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, safeConfig);
88
- const binding = deps.resolveHostBinding(bindingRepository, safeConfig);
102
+ const hostOption = storedHostToOption(safeConfig, bindingRepository, options.storedHost);
103
+ const binding = deps.resolveHostBinding(bindingRepository, safeConfig, hostOption || {});
89
104
  if (!binding || !binding.token) {
90
105
  throw new GitHubApiError(
91
106
  `GitHub token not configured for ${repository}. Set ${credentialEnvVar}, add github_token to ~/.pair-review/config.json, or configure repos["${bindingRepository}"].token / token_command (required for alt-host repos)`,
@@ -1137,6 +1137,43 @@ class GitHubClient {
1137
1137
  });
1138
1138
  }
1139
1139
 
1140
+ /**
1141
+ * List open pull requests for a single repository via the REST API.
1142
+ *
1143
+ * Used by the dashboard collections sweep against alt-hosts, which speak a
1144
+ * REST subset and generally have no Search API. The returned rows carry the
1145
+ * classification fields (`author`, `requested_reviewers`, `requested_teams`)
1146
+ * so the caller can bucket each PR into a collection locally, plus the same
1147
+ * display fields `searchPullRequests` returns so both paths cache uniformly.
1148
+ *
1149
+ * @param {string} owner - Repository owner
1150
+ * @param {string} repo - Repository name
1151
+ * @returns {Promise<Array<{owner: string, repo: string, number: number, title: string, author: string|null, updated_at: string, html_url: string, state: string, requested_reviewers: string[], requested_teams: string[]}>>}
1152
+ */
1153
+ async listOpenPullRequests(owner, repo) {
1154
+ const pulls = await this.octokit.paginate(
1155
+ this.octokit.rest.pulls.list,
1156
+ { owner, repo, state: 'open', per_page: 100 }
1157
+ );
1158
+
1159
+ return pulls.map(pr => ({
1160
+ owner,
1161
+ repo,
1162
+ number: pr.number,
1163
+ title: pr.title,
1164
+ author: pr.user?.login || null,
1165
+ updated_at: pr.updated_at,
1166
+ html_url: pr.html_url,
1167
+ state: pr.state,
1168
+ requested_reviewers: Array.isArray(pr.requested_reviewers)
1169
+ ? pr.requested_reviewers.map(r => r && r.login).filter(Boolean)
1170
+ : [],
1171
+ requested_teams: Array.isArray(pr.requested_teams)
1172
+ ? pr.requested_teams.map(t => t && t.slug).filter(Boolean)
1173
+ : []
1174
+ }));
1175
+ }
1176
+
1140
1177
  /**
1141
1178
  * Get the authenticated user's information.
1142
1179
  * @returns {Promise<{login: string, name: string, avatar_url: string}>}
@@ -90,11 +90,20 @@ class PRArgumentParser {
90
90
  if (!owner || !repo || typeof number !== 'number' || isNaN(number) || number <= 0) {
91
91
  return null;
92
92
  }
93
+ // Carry the matched repo's `api_host` so downstream setup binds the PR to
94
+ // the alternate host without probing. A `url_pattern` match on a repo with
95
+ // no `api_host` (a plain github.com entry) resolves to `host: null`.
96
+ const matchedApiHost = (match.repoConfig
97
+ && typeof match.repoConfig.api_host === 'string'
98
+ && match.repoConfig.api_host)
99
+ ? match.repoConfig.api_host
100
+ : null;
93
101
  return {
94
102
  owner,
95
103
  repo,
96
104
  number,
97
- bindingRepository: match.bindingRepository
105
+ bindingRepository: match.bindingRepository,
106
+ host: matchedApiHost
98
107
  };
99
108
  }
100
109
 
@@ -114,16 +123,30 @@ class PRArgumentParser {
114
123
  return null;
115
124
  }
116
125
 
126
+ // Clean up the URL - trim whitespace
127
+ const trimmedUrl = url.trim();
128
+
117
129
  // Try config-driven URL pattern matching first. This handles
118
130
  // alternate-host URLs and lets host-specific repos override the
119
131
  // built-in github.com path if they choose.
120
- const configMatch = this._matchUrlPatternFromConfig(url.trim());
132
+ const configMatch = this._matchUrlPatternFromConfig(trimmedUrl);
121
133
  if (configMatch) {
122
- return configMatch;
134
+ // INVARIANT: an `api_host`-bearing `url_pattern` must NEVER bind a
135
+ // canonical github.com / Graphite URL. An over-broad or unanchored pattern
136
+ // can match one and pre-pin it to the alt host (an explicit host bypasses
137
+ // the setup probe → a silent, durable wrong binding). When the config
138
+ // match carries an alt host but the URL is a canonical github/graphite URL,
139
+ // discard the match and fall through to the built-in parsers (host: null).
140
+ const sansProtocol = trimmedUrl.replace(/^https?:\/\//i, '');
141
+ const isCanonicalHostUrl = sansProtocol.startsWith('github.com/')
142
+ || sansProtocol.startsWith('app.graphite.dev/')
143
+ || sansProtocol.startsWith('app.graphite.com/');
144
+ if (!(configMatch.host && isCanonicalHostUrl)) {
145
+ return configMatch;
146
+ }
123
147
  }
124
148
 
125
- // Clean up the URL - trim whitespace
126
- let normalizedUrl = url.trim();
149
+ let normalizedUrl = trimmedUrl;
127
150
 
128
151
  // Add https:// if no protocol is present
129
152
  if (normalizedUrl.startsWith('github.com')) {
@@ -222,7 +245,9 @@ class PRArgumentParser {
222
245
  * @param {string} repo - Repository name
223
246
  * @param {string} numberStr - PR number as string
224
247
  * @param {string} source - Source name for error messages ('GitHub' or 'Graphite')
225
- * @returns {Object} Validated PR info { owner, repo, number }
248
+ * @returns {Object} Validated PR info `{ owner, repo, number, host? }`. `host`
249
+ * is `null` for github.com/Graphite URLs (definitively github); it is OMITTED
250
+ * (undefined) for the pair-review:// scheme, which carries no host in its path.
226
251
  * @private
227
252
  */
228
253
  _createPRInfo(owner, repo, numberStr, source) {
@@ -237,7 +262,16 @@ class PRArgumentParser {
237
262
  throw new Error(`Invalid ${source} URL format. Expected: ${exampleUrl}`);
238
263
  }
239
264
 
240
- return { owner, repo, number };
265
+ // A github.com or Graphite URL unambiguously identifies a github.com PR, so
266
+ // the host is explicitly null (not "unknown"). The pair-review:// scheme
267
+ // carries NO host in its path and is not github-only, so leave `host`
268
+ // undefined — it flows into the probe/derive path like a bare number,
269
+ // instead of self-healing (dual repo) or throwing (exclusive alt) on github.
270
+ const result = { owner, repo, number };
271
+ if (source !== 'pair-review://') {
272
+ result.host = null;
273
+ }
274
+ return result;
241
275
  }
242
276
 
243
277
  /**
@@ -93,7 +93,7 @@ async function resolveCliInstructions(flags) {
93
93
  * @param {Object} params
94
94
  * @param {Object} params.db - Database instance
95
95
  * @param {Object} params.config - Loaded global config
96
- * @param {Object} params.flags - Parsed CLI flags (council/model/instructions)
96
+ * @param {Object} params.flags - Parsed CLI flags (council/provider/model/instructions)
97
97
  * @param {string} params.repository - owner/repo (for repo-default resolution)
98
98
  * @returns {Promise<Object|null>} The analysisConfig object, or null when no instructions.
99
99
  */
@@ -104,7 +104,7 @@ async function buildInteractiveAnalysisConfig({ db, config, flags, repository })
104
104
  const reviewConfig = await resolveReviewConfig(
105
105
  db,
106
106
  repository,
107
- { council: flags.council, model: flags.model },
107
+ { council: flags.council, provider: flags.provider, model: flags.model },
108
108
  config
109
109
  );
110
110
 
@@ -31,6 +31,7 @@
31
31
  */
32
32
 
33
33
  const { getRepoConfig } = require('../config');
34
+ const { isDualHostRepoConfig } = require('../utils/host-resolution');
34
35
  const logger = require('../utils/logger');
35
36
 
36
37
  // Whitelist of allowed `{placeholder}` names. Anything else in the template
@@ -158,13 +159,28 @@ function sanitizeSvgIcon(svg) {
158
159
  * malformed), a warning is logged and `icon` becomes `null` — the link
159
160
  * is still rendered, just without a custom icon.
160
161
  *
162
+ * **Per-PR host awareness (dual-host repos only).** `host` is the PR's
163
+ * resolved host: `null` = github.com, an `api_host` URL string = the alt
164
+ * host, `undefined` = unknown / not applicable. It only affects a
165
+ * *dual-host* repo (`api_host` + `exclusive: false`):
166
+ * - `host === null` (github-hosted PR) → keep the GitHub/Graphite links
167
+ * (subject to any explicit `links.github/graphite: false`) and hide the
168
+ * alt-host external link.
169
+ * - `host === '<url>'` (alt-hosted PR) → keep the external link and hide
170
+ * the GitHub/Graphite links.
171
+ * Exclusive alt-host repos and plain github repos ignore `host` entirely, so
172
+ * existing two-arg callers (and `host === undefined`) render byte-identically
173
+ * to before this parameter existed.
174
+ *
161
175
  * @param {Object} config
162
176
  * @param {string} repository Canonical `owner/repo` identifier
177
+ * @param {string|null} [host] PR's resolved host: null=github, url=alt,
178
+ * undefined=unknown/not applicable
163
179
  * @returns {{ external: { label: string, url_template: string, icon: string|null }|null,
164
180
  * github: boolean,
165
181
  * graphite: boolean }}
166
182
  */
167
- function resolveRepoLinks(config, repository) {
183
+ function resolveRepoLinks(config, repository, host = undefined) {
168
184
  const result = { external: null, github: true, graphite: true };
169
185
  if (!config || !repository) return result;
170
186
 
@@ -172,33 +188,47 @@ function resolveRepoLinks(config, repository) {
172
188
  if (!repoConfig || typeof repoConfig !== 'object') return result;
173
189
 
174
190
  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
- );
191
+ if (links && typeof links === 'object') {
192
+ if (links.github === false) result.github = false;
193
+ if (links.graphite === false) result.graphite = false;
194
+
195
+ const ext = links.external;
196
+ if (ext && typeof ext === 'object'
197
+ && typeof ext.label === 'string' && ext.label
198
+ && typeof ext.url_template === 'string'
199
+ && ext.url_template.startsWith('https://')) {
200
+ let icon = null;
201
+ if (ext.icon !== undefined && ext.icon !== null && ext.icon !== '') {
202
+ icon = sanitizeSvgIcon(ext.icon);
203
+ if (icon === null) {
204
+ logger.warn(
205
+ `Dropping links.external.icon for "${repository}" — failed sanitisation.`
206
+ );
207
+ }
192
208
  }
209
+ result.external = {
210
+ // Optional host display name (e.g. "Meteorite"). When absent, the
211
+ // field is null and consumers fall back to "GitHub" via resolveHostName.
212
+ name: (typeof ext.name === 'string' && ext.name) ? ext.name : null,
213
+ label: ext.label,
214
+ url_template: ext.url_template,
215
+ icon
216
+ };
217
+ }
218
+ }
219
+
220
+ // Per-PR host awareness for dual-host repos. A `host` of `undefined`
221
+ // (omitted arg, unknown host) leaves the repo-level result untouched, so
222
+ // non-dual repos and legacy two-arg callers are unaffected.
223
+ if (host !== undefined && isDualHostRepoConfig(repoConfig)) {
224
+ if (host === null) {
225
+ // github-hosted PR: the alt-host external link does not apply.
226
+ result.external = null;
227
+ } else {
228
+ // alt-hosted PR: hide the GitHub/Graphite links, keep the external one.
229
+ result.github = false;
230
+ result.graphite = false;
193
231
  }
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
232
  }
203
233
 
204
234
  return result;
@@ -212,12 +242,20 @@ function resolveRepoLinks(config, repository) {
212
242
  * `"GitHub"`. This is the server-side counterpart to the frontend
213
243
  * `window.RepoLinks.hostName()` accessor.
214
244
  *
245
+ * Host-aware for dual-host repos: a github-hosted PR (`host === null`)
246
+ * reports "GitHub" even when an external name is configured, because
247
+ * `resolveRepoLinks` clears the external link for that host. See
248
+ * `resolveRepoLinks` for the `host` semantics. Non-dual repos and two-arg
249
+ * callers behave exactly as before.
250
+ *
215
251
  * @param {Object} config
216
252
  * @param {string} repository Canonical `owner/repo` identifier
253
+ * @param {string|null} [host] PR's resolved host: null=github, url=alt,
254
+ * undefined=unknown/not applicable
217
255
  * @returns {string} The configured host name, or "GitHub" by default
218
256
  */
219
- function resolveHostName(config, repository) {
220
- const links = resolveRepoLinks(config, repository);
257
+ function resolveHostName(config, repository, host = undefined) {
258
+ const links = resolveRepoLinks(config, repository, host);
221
259
  return (links.external && links.external.name) ? links.external.name : 'GitHub';
222
260
  }
223
261