@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,295 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Bulk analysis configuration routes.
4
+ *
5
+ * The index page can open many PR tabs at once. Rather than placing a large
6
+ * analysis configuration in every PR URL, the browser stores it here and passes
7
+ * a short ID through the setup/review URL. Each PR tab then resolves the ID
8
+ * before starting auto-analysis.
9
+ */
10
+
11
+ const crypto = require('crypto');
12
+ const express = require('express');
13
+ const logger = require('../utils/logger');
14
+ const { VALID_TIERS } = require('../ai/prompts/config');
15
+ const { getAllProvidersInfo } = require('../ai');
16
+ const { normalizeCouncilConfig, validateCouncilConfig } = require('./councils');
17
+
18
+ const router = express.Router();
19
+
20
+ const configs = new Map();
21
+ const CONFIG_TTL_MS = 30 * 60 * 1000;
22
+ const MAX_CONFIGS = 1000;
23
+ const MAX_INSTRUCTIONS_LENGTH = 5000;
24
+ const VALID_TIER_SET = new Set(VALID_TIERS);
25
+ const VALID_CONFIG_TYPES = new Set(['council', 'advanced']);
26
+ const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
27
+
28
+ function pruneExpired(now = Date.now()) {
29
+ for (const [id, entry] of configs.entries()) {
30
+ if (entry.expiresAt <= now) {
31
+ configs.delete(id);
32
+ }
33
+ }
34
+ }
35
+
36
+ function enforceMaxConfigs() {
37
+ while (configs.size > MAX_CONFIGS) {
38
+ const oldestId = configs.keys().next().value;
39
+ if (!oldestId) return;
40
+ configs.delete(oldestId);
41
+ }
42
+ }
43
+
44
+ function isPlainObject(value) {
45
+ return !!value && typeof value === 'object' && !Array.isArray(value);
46
+ }
47
+
48
+ function validateString(value, field, { required = false, max = 200 } = {}) {
49
+ if (value == null) {
50
+ return required ? `${field} is required` : null;
51
+ }
52
+ if (typeof value !== 'string' || value.trim().length === 0 || value.length > max) {
53
+ return `${field} must be a non-empty string up to ${max} characters`;
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function validateCustomInstructions(value) {
59
+ if (value == null) return null;
60
+ if (typeof value !== 'string') return 'customInstructions must be a string';
61
+ if (value.length > MAX_INSTRUCTIONS_LENGTH) {
62
+ return `customInstructions exceed maximum length of ${MAX_INSTRUCTIONS_LENGTH} characters`;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function validateJsonShape(value, path = 'analysisConfig', depth = 0) {
68
+ if (depth > 20) return `${path} is too deeply nested`;
69
+ if (value == null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
70
+ return null;
71
+ }
72
+ if (Array.isArray(value)) {
73
+ if (value.length > 200) return `${path} has too many items`;
74
+ for (let i = 0; i < value.length; i++) {
75
+ const error = validateJsonShape(value[i], `${path}[${i}]`, depth + 1);
76
+ if (error) return error;
77
+ }
78
+ return null;
79
+ }
80
+ if (!isPlainObject(value)) return `${path} must contain only JSON values`;
81
+
82
+ const entries = Object.entries(value);
83
+ if (entries.length > 200) return `${path} has too many keys`;
84
+ for (const [key, child] of entries) {
85
+ if (FORBIDDEN_KEYS.has(key)) return `${path} contains forbidden key ${key}`;
86
+ const error = validateJsonShape(child, `${path}.${key}`, depth + 1);
87
+ if (error) return error;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function sanitizeExcludePrevious(value) {
93
+ if (value == null) return { error: null, value: undefined };
94
+ if (!isPlainObject(value)) return { error: 'excludePrevious must be an object' };
95
+ return {
96
+ error: null,
97
+ value: {
98
+ github: value.github === true,
99
+ feedback: value.feedback === true
100
+ }
101
+ };
102
+ }
103
+
104
+ function sanitizeEnabledLevels(value) {
105
+ if (value == null) return { error: null, value: undefined };
106
+ if (!Array.isArray(value)) return { error: 'enabledLevels must be an array' };
107
+
108
+ const levels = [];
109
+ for (const level of value) {
110
+ const number = Number(level);
111
+ if (![1, 2, 3].includes(number)) {
112
+ return { error: 'enabledLevels may only include levels 1, 2, and 3' };
113
+ }
114
+ if (!levels.includes(number)) levels.push(number);
115
+ }
116
+
117
+ if (levels.length === 0) return { error: 'enabledLevels must include at least one level' };
118
+ return { error: null, value: levels };
119
+ }
120
+
121
+ function sanitizeSingleConfig(config) {
122
+ let error = validateString(config.provider, 'provider', { required: true });
123
+ if (error) return { error };
124
+
125
+ error = validateString(config.model, 'model', { required: true });
126
+ if (error) return { error };
127
+
128
+ if (config.tier != null && (!VALID_TIER_SET.has(config.tier))) {
129
+ return { error: `tier must be one of ${VALID_TIERS.join(', ')}` };
130
+ }
131
+
132
+ // The modal builds two related fields: `instructions` carries the *effective*
133
+ // prompt (selected preset chips concatenated with the textarea) while
134
+ // `customInstructions` is the raw textarea only. Persist the effective prompt
135
+ // so bulk-launched analyses see the same prompt the modal showed the user —
136
+ // otherwise any chosen preset chips are silently dropped.
137
+ const effectiveInstructions = config.instructions || config.customInstructions;
138
+
139
+ error = validateCustomInstructions(effectiveInstructions);
140
+ if (error) return { error };
141
+
142
+ const enabledLevels = sanitizeEnabledLevels(config.enabledLevels);
143
+ if (enabledLevels.error) return { error: enabledLevels.error };
144
+
145
+ const excludePrevious = sanitizeExcludePrevious(config.excludePrevious);
146
+ if (excludePrevious.error) return { error: excludePrevious.error };
147
+
148
+ // Defense in depth: the bulk replay path forwards this stored pair straight to
149
+ // analysis with no client-side guard. If the model does not belong to the
150
+ // provider (a mismatched pair that slipped past the client resolver), fall back
151
+ // to the provider's own default rather than forwarding an invalid pair. Unknown
152
+ // providers (custom/unavailable, not in the registry) pass through unchanged.
153
+ let normalizedModel = config.model;
154
+ const providerInfo = getAllProvidersInfo().find(p => p.id === config.provider);
155
+ if (providerInfo && !providerInfo.models.some(m => m.id === config.model)) {
156
+ normalizedModel = providerInfo.defaultModel || config.model;
157
+ }
158
+
159
+ return {
160
+ error: null,
161
+ config: {
162
+ provider: config.provider,
163
+ model: normalizedModel,
164
+ tier: config.tier,
165
+ customInstructions: effectiveInstructions || null,
166
+ enabledLevels: enabledLevels.value,
167
+ skipLevel3: config.skipLevel3 === true,
168
+ noLevels: config.noLevels === true,
169
+ excludePrevious: excludePrevious.value
170
+ }
171
+ };
172
+ }
173
+
174
+ function sanitizeCouncilConfig(config) {
175
+ // configType selects which downstream validator runs, so reject unrecognized
176
+ // values rather than silently coercing them (which could route councilConfig
177
+ // through the wrong validator).
178
+ if (config.configType != null && !VALID_CONFIG_TYPES.has(config.configType)) {
179
+ return { error: `configType must be one of ${[...VALID_CONFIG_TYPES].join(', ')}` };
180
+ }
181
+ const configType = config.configType || 'advanced';
182
+
183
+ let error = validateString(config.councilId, 'councilId', { max: 128 });
184
+ if (error) return { error };
185
+
186
+ if (config.councilName != null) {
187
+ error = validateString(config.councilName, 'councilName', { max: 200 });
188
+ if (error) return { error };
189
+ }
190
+
191
+ error = validateCustomInstructions(config.customInstructions);
192
+ if (error) return { error };
193
+
194
+ const excludePrevious = sanitizeExcludePrevious(config.excludePrevious);
195
+ if (excludePrevious.error) return { error: excludePrevious.error };
196
+
197
+ if (!config.councilId && !config.councilConfig) {
198
+ return { error: 'Either councilId or councilConfig is required' };
199
+ }
200
+
201
+ let councilConfig;
202
+ if (config.councilConfig != null) {
203
+ const shapeError = validateJsonShape(config.councilConfig, 'councilConfig');
204
+ if (shapeError) return { error: shapeError };
205
+
206
+ councilConfig = normalizeCouncilConfig(config.councilConfig, configType);
207
+ const councilError = validateCouncilConfig(councilConfig, configType);
208
+ if (councilError) return { error: `Invalid council config: ${councilError}` };
209
+ }
210
+
211
+ return {
212
+ error: null,
213
+ config: {
214
+ isCouncil: true,
215
+ configType,
216
+ // When an inline snapshot is stored, drop councilId so the downstream
217
+ // analysis route is forced to use the exact modal-selected councilConfig
218
+ // rather than re-fetching (and possibly diverging from) the DB record.
219
+ councilId: councilConfig ? undefined : (config.councilId || undefined),
220
+ councilName: config.councilName || null,
221
+ councilConfig,
222
+ customInstructions: config.customInstructions || null,
223
+ excludePrevious: excludePrevious.value
224
+ }
225
+ };
226
+ }
227
+
228
+ function sanitizeAnalysisConfig(config) {
229
+ if (!isPlainObject(config)) {
230
+ return { error: 'analysisConfig object required' };
231
+ }
232
+
233
+ const shapeError = validateJsonShape(config);
234
+ if (shapeError) return { error: shapeError };
235
+
236
+ if (config.isCouncil === true) {
237
+ return sanitizeCouncilConfig(config);
238
+ }
239
+ return sanitizeSingleConfig(config);
240
+ }
241
+
242
+ router.post('/api/bulk-analysis-configs', (req, res) => {
243
+ try {
244
+ const result = sanitizeAnalysisConfig(req.body?.analysisConfig);
245
+ if (result.error) {
246
+ return res.status(400).json({ error: result.error });
247
+ }
248
+
249
+ pruneExpired();
250
+
251
+ const id = crypto.randomUUID();
252
+ configs.set(id, {
253
+ analysisConfig: result.config,
254
+ expiresAt: Date.now() + CONFIG_TTL_MS
255
+ });
256
+ enforceMaxConfigs();
257
+
258
+ res.json({ success: true, id, expiresInMs: CONFIG_TTL_MS });
259
+ } catch (error) {
260
+ logger.error('Failed to store bulk analysis config:', error);
261
+ res.status(500).json({ error: 'Failed to store bulk analysis config' });
262
+ }
263
+ });
264
+
265
+ router.get('/api/bulk-analysis-configs/:id', (req, res) => {
266
+ const { id } = req.params;
267
+ pruneExpired();
268
+
269
+ const entry = configs.get(id);
270
+ if (!entry) {
271
+ return res.status(404).json({ error: 'Bulk analysis config not found' });
272
+ }
273
+
274
+ res.json({
275
+ success: true,
276
+ analysisConfig: entry.analysisConfig
277
+ });
278
+ });
279
+
280
+ function _resetBulkAnalysisConfigs() {
281
+ configs.clear();
282
+ }
283
+
284
+ function _getBulkAnalysisConfig(id) {
285
+ const entry = configs.get(id);
286
+ return entry ? entry.analysisConfig : null;
287
+ }
288
+
289
+ module.exports = router;
290
+ module.exports._resetBulkAnalysisConfigs = _resetBulkAnalysisConfigs;
291
+ module.exports._getBulkAnalysisConfig = _getBulkAnalysisConfig;
292
+ module.exports._pruneExpired = pruneExpired;
293
+ module.exports._CONFIG_TTL_MS = CONFIG_TTL_MS;
294
+ module.exports._MAX_CONFIGS = MAX_CONFIGS;
295
+ module.exports._sanitizeAnalysisConfig = sanitizeAnalysisConfig;
@@ -23,11 +23,16 @@ const { normalizeRepository } = require('../utils/paths');
23
23
  const {
24
24
  isRunningViaNpx,
25
25
  getGitHubToken,
26
+ getDefaultProvider,
27
+ getDefaultModel,
28
+ resolveHostBinding,
29
+ resolveBindingRepositoryFromPR,
26
30
  getSummaryEnabled,
27
31
  getSummaryAutoGenerate,
28
32
  getTourEnabled,
29
33
  getTourAutoGenerate
30
34
  } = require('../config');
35
+ const { resolveRepoLinks } = require('../links/repo-links');
31
36
  const { version } = require('../../package.json');
32
37
  const semver = require('semver');
33
38
  const { getAllChatProviders, getAllCachedChatAvailability } = require('../chat/chat-providers');
@@ -70,10 +75,54 @@ router.get('/runtime-config.js', (req, res) => {
70
75
 
71
76
  /**
72
77
  * Get user configuration (for frontend use)
73
- * Returns safe-to-expose configuration values
78
+ * Returns safe-to-expose configuration values.
79
+ *
80
+ * GitHub token presence is reported with two distinct fields:
81
+ * - has_global_github_token: always present. True iff `getGitHubToken(config)`
82
+ * resolves a token from the top-level config / env (no repo context).
83
+ * - has_github_token: present ONLY when both ?owner and ?repo query
84
+ * parameters are supplied. True iff a token can be resolved for that
85
+ * specific repository via `resolveHostBinding(repo, config)` — this
86
+ * covers repo-scoped `token`, `token_command`, alt-host bindings, AND
87
+ * falls through to the global lookup. Callers that know which repo
88
+ * they're rendering should pass these params so that repo-scoped
89
+ * authentication is reflected accurately (e.g. for deciding whether
90
+ * to enable GitHub-comment dedup). When the params are absent, the
91
+ * repo-aware field is omitted entirely — there is no safe default
92
+ * that doesn't risk silently meaning the wrong thing.
93
+ */
94
+ /**
95
+ * Resolve a coherent default provider/model PAIR for the frontend.
96
+ *
97
+ * `default_provider` and `default_model` seed the bulk modal, auto-analyze, and
98
+ * the manual analyze dialog as a single selection, so they must belong together.
99
+ * An explicitly configured model wins (the user opted into it). When no model is
100
+ * configured, derive it from the selected provider's own default rather than the
101
+ * provider-agnostic global default — otherwise a provider-only override (e.g.
102
+ * `default_provider: 'gemini'`) would pair with an Anthropic model like 'opus'.
103
+ *
104
+ * @param {Object} config - Configuration object
105
+ * @returns {{ provider: string, model: string }}
74
106
  */
107
+ function resolveDefaultProviderModel(config) {
108
+ const provider = getDefaultProvider(config);
109
+ const providerInfo = getAllProvidersInfo().find(p => p.id === provider);
110
+ const explicitModel = config.default_model || config.model;
111
+ // Only honour an explicit model if it actually belongs to the selected provider.
112
+ // `DEFAULT_CONFIG.default_model` is always populated (e.g. 'opus'), so a
113
+ // provider-only override like `default_provider: 'gemini'` would otherwise inherit
114
+ // a foreign Anthropic model and return a mismatched pair. When the model does not
115
+ // belong to the provider, derive a coherent default from the provider itself.
116
+ const modelBelongs = explicitModel && providerInfo?.models?.some(m => m.id === explicitModel);
117
+ if (modelBelongs) {
118
+ return { provider, model: explicitModel };
119
+ }
120
+ return { provider, model: providerInfo?.defaultModel || getDefaultModel(config) };
121
+ }
122
+
75
123
  router.get('/api/config', (req, res) => {
76
124
  const config = req.app.get('config') || {};
125
+ const defaultPair = resolveDefaultProviderModel(config);
77
126
 
78
127
  // Build chat_providers array with availability
79
128
  const chatAvailability = getAllCachedChatAvailability();
@@ -81,13 +130,34 @@ router.get('/api/config', (req, res) => {
81
130
  id: p.id, name: p.name, type: p.type, available: chatAvailability[p.id]?.available || false
82
131
  }));
83
132
 
133
+ // Repo-aware token resolution (opt-in via ?owner & ?repo query params).
134
+ // Both must be non-empty strings; missing/partial params fall back to the
135
+ // global-only response shape.
136
+ const { owner, repo } = req.query;
137
+ const hasRepoContext = typeof owner === 'string' && owner.length > 0
138
+ && typeof repo === 'string' && repo.length > 0;
139
+
140
+ const hasGlobalGithubToken = Boolean(getGitHubToken(config));
141
+ let hasRepoGithubToken = null;
142
+ if (hasRepoContext) {
143
+ const repository = `${owner}/${repo}`;
144
+ // resolveHostBinding already falls through to top-level config when
145
+ // no repo-scoped token is configured, so this is a true union of
146
+ // "repo-scoped binding works" OR "global token works".
147
+ hasRepoGithubToken = Boolean(resolveHostBinding(repository, config).token);
148
+ }
149
+
84
150
  // Only return safe configuration values (not secrets like github_token)
85
151
  res.json({
86
152
  version,
87
153
  theme: config.theme || 'light',
88
- has_github_token: Boolean(getGitHubToken(config)),
154
+ has_global_github_token: hasGlobalGithubToken,
155
+ // Repo-aware field — only included when owner+repo were supplied.
156
+ ...(hasRepoContext ? { has_github_token: hasRepoGithubToken } : {}),
89
157
  comment_button_action: config.comment_button_action || 'submit',
90
158
  comment_format: config.comment_format || 'legacy',
159
+ default_provider: defaultPair.provider,
160
+ default_model: defaultPair.model,
91
161
  // Include npx detection for frontend command examples
92
162
  is_running_via_npx: isRunningViaNpx(),
93
163
  enable_chat: config.enable_chat !== false,
@@ -215,6 +285,35 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
215
285
  }
216
286
  });
217
287
 
288
+ /**
289
+ * Get repository-specific header link configuration.
290
+ * Reads `config.repos["owner/repo"].links` and returns:
291
+ * - external: { label, url_template, icon } | null
292
+ * - github: boolean (false means hide the default GitHub link)
293
+ * - graphite: boolean (false means hide the Graphite link)
294
+ *
295
+ * The icon SVG is sanitised server-side (script tags, on* handlers, and
296
+ * `javascript:` URLs stripped). The url_template is NOT substituted here —
297
+ * the frontend has the live PR/branch context, so it performs the
298
+ * whitelisted substitution at render time.
299
+ */
300
+ router.get('/api/repos/:owner/:repo/links', (req, res) => {
301
+ try {
302
+ const { owner, repo } = req.params;
303
+ const repository = normalizeRepository(owner, repo);
304
+ const config = req.app.get('config') || {};
305
+ // Resolve via bindingRepository so monorepo-style configs (one
306
+ // `repos[...]` entry serving many captured owner/repo via
307
+ // url_pattern) surface the right link config.
308
+ const bindingRepository = resolveBindingRepositoryFromPR(owner, repo, config);
309
+ const links = resolveRepoLinks(config, bindingRepository);
310
+ res.json({ repository, links });
311
+ } catch (error) {
312
+ logger.error('Error resolving repo links:', error);
313
+ res.status(500).json({ error: 'Failed to resolve repository links' });
314
+ }
315
+ });
316
+
218
317
  /**
219
318
  * Save repository-specific settings
220
319
  * Saves default_instructions, default_provider, and/or default_model for the repository
@@ -425,3 +524,4 @@ function _resetPendingUpdate() {
425
524
 
426
525
  module.exports = router;
427
526
  module.exports._resetPendingUpdate = _resetPendingUpdate;
527
+ module.exports._resolveDefaultProviderModel = resolveDefaultProviderModel;
@@ -34,8 +34,9 @@ const router = express.Router();
34
34
  * Default dependencies for the sync flow. Tests override these via the
35
35
  * `externalCommentsDeps` Express app setting (or by passing `_deps` to
36
36
  * `executeSync` directly). Credential resolution is delegated to the
37
- * adapter via `adapter.resolveCredentials(config)` — keeps the route
38
- * source-agnostic and lets each adapter name its own env var in errors.
37
+ * adapter via `adapter.resolveCredentials(config, repository)` — keeps the
38
+ * route source-agnostic, lets each adapter name its own env var in errors,
39
+ * and threads the repo through so per-repo alt-host bindings apply.
39
40
  */
40
41
  const defaults = {
41
42
  getAdapter
@@ -115,7 +116,7 @@ function isPRMode(review) {
115
116
  * @param {Object} params.config - Server config (for token lookup)
116
117
  * @param {Object} params.review - Validated review row
117
118
  * @param {string} params.source - Adapter source name (e.g. 'github')
118
- * @param {Object} [params._deps] - Test overrides for { GitHubClient, getGitHubToken, getAdapter }
119
+ * @param {Object} [params._deps] - Test overrides for { GitHubClient, getGitHubToken, getAdapter, resolveHostBinding, resolveBindingRepositoryFromPR }
119
120
  * @returns {Promise<{count: number, lostAnchors: number, syncedAt: string}>}
120
121
  */
121
122
  async function executeSync({ db, config, review, source, _deps }) {
@@ -124,12 +125,9 @@ async function executeSync({ db, config, review, source, _deps }) {
124
125
  // Look up adapter — throws on unknown sources, caught by the route.
125
126
  const adapter = deps.getAdapter(source);
126
127
 
127
- // Delegate credential resolution to the adapter so the route stays
128
- // source-agnostic and each adapter can name its own env var in errors.
129
- // The adapter throws (e.g. GitHubApiError 401) when credentials are
130
- // missing — the route's catch maps it to a 401 response.
131
- const { client } = adapter.resolveCredentials(config || {}, _deps);
132
-
128
+ // Parse owner/repo BEFORE resolving credentials: the repository drives
129
+ // binding-aware credential resolution (per-repo api_host/token for
130
+ // alt-host repos), so it must be validated first.
133
131
  const [owner, repo] = String(review.repository).split('/');
134
132
  if (!owner || !repo) {
135
133
  throw new BadRequestError(
@@ -137,6 +135,18 @@ async function executeSync({ db, config, review, source, _deps }) {
137
135
  );
138
136
  }
139
137
 
138
+ // Delegate credential resolution to the adapter so the route stays
139
+ // source-agnostic and each adapter can name its own env var in errors.
140
+ // Thread `review.repository` through so the adapter resolves the
141
+ // repo-scoped host binding (alt-host api_host + repo token) instead of
142
+ // always targeting api.github.com with the top-level github.com token.
143
+ // The adapter throws (e.g. GitHubApiError 401) when credentials are
144
+ // missing — the route's catch maps it to a 401 response.
145
+ // `isAltHost` reflects whether the resolved binding targets an alternate
146
+ // Git host. Alt-hosts don't implement GitHub's deprecated `position`
147
+ // field, so it drives line-based anchoring in `mapComment` below.
148
+ const { client, isAltHost } = adapter.resolveCredentials(config || {}, review.repository, _deps);
149
+
140
150
  const apiRows = await adapter.fetchComments({
141
151
  client,
142
152
  owner,
@@ -156,7 +166,7 @@ async function executeSync({ db, config, review, source, _deps }) {
156
166
  for (const apiRow of apiRows || []) {
157
167
  let mapped;
158
168
  try {
159
- mapped = adapter.mapComment(apiRow);
169
+ mapped = adapter.mapComment(apiRow, { isAltHost });
160
170
  } catch (mapError) {
161
171
  // A malformed row from the source shouldn't kill the whole sync — log
162
172
  // it and keep going. The adapter only throws for genuinely malformed
@@ -161,7 +161,9 @@ function registerCollection(def) {
161
161
  }
162
162
 
163
163
  const config = req.app.get('config');
164
- const githubToken = getGitHubToken(config);
164
+ // Cross-repo search on github.com — no per-repo binding applies here.
165
+ // Explicit `undefined` repository selects the no-repo (top-level) path.
166
+ const githubToken = getGitHubToken(config, undefined);
165
167
  if (!githubToken) {
166
168
  return res.status(401).json({ success: false, error: 'GitHub token not configured' });
167
169
  }