@in-the-loop-labs/pair-review 3.5.2 → 3.7.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.
Files changed (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
package/src/routes/pr.js CHANGED
@@ -17,6 +17,7 @@ const express = require('express');
17
17
  const { query, queryOne, run, withTransaction, WorktreeRepository, ReviewRepository, GitHubReviewRepository, RepoSettingsRepository, AnalysisRunRepository, PRMetadataRepository, CouncilRepository } = require('../database');
18
18
  const { GitWorktreeManager } = require('../git/worktree');
19
19
  const { GitHubClient } = require('../github/client');
20
+ const { PRArgumentParser } = require('../github/parser');
20
21
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
21
22
  const { getShaAbbrevLength, DEFAULT_SHA_ABBREV_LENGTH } = require('../git/sha-abbrev');
22
23
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
@@ -25,7 +26,9 @@ const Analyzer = require('../ai/analyzer');
25
26
  const { v4: uuidv4 } = require('uuid');
26
27
  const fs = require('fs').promises;
27
28
  const path = require('path');
28
- const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch } = require('../config');
29
+ const { resolveHostBinding, resolveBindingRepositoryFromPR, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch, getSummaryEnabled, getTourEnabled } = require('../config');
30
+ const { resolveHostName } = require('../links/repo-links');
31
+ const { backgroundQueue } = require('../ai/background-queue');
29
32
  const logger = require('../utils/logger');
30
33
  const { buildDiffLineSet } = require('../utils/diff-annotator');
31
34
  const { broadcastReviewEvent } = require('../events/review-events');
@@ -34,6 +37,8 @@ const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStarte
34
37
  const simpleGit = require('simple-git');
35
38
  const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('../git/diff-flags');
36
39
  const { walkPRStack, DEFAULT_TRUNK_BRANCHES } = require('../github/stack-walker');
40
+ const summaryGenerator = require('../ai/summary-generator');
41
+ const tourGenerator = require('../ai/tour-generator');
37
42
  const {
38
43
  activeAnalyses,
39
44
  reviewToAnalysisId,
@@ -45,7 +50,9 @@ const {
45
50
  registerProcess: registerProcessForCancellation
46
51
  } = require('./shared');
47
52
  const { safeParseJson } = require('../utils/safe-parse-json');
48
- const { mergeChangedFilesWithDiff } = require('../utils/diff-file-list');
53
+ const { mergeChangedFilesWithDiff, parseUnifiedDiffPatches } = require('../utils/diff-file-list');
54
+ const { parseHunks } = require('../utils/diff-hunks');
55
+ const { hashHunk } = require('../ai/hunk-hashing');
49
56
  const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
50
57
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
51
58
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
@@ -56,6 +63,108 @@ const analysesRouter = require('./analyses');
56
63
  const { worktreeLock } = require('../git/worktree-lock');
57
64
  const router = express.Router();
58
65
 
66
+ /**
67
+ * Compute per-file hunk hashes from a canonical (unfiltered) unified diff.
68
+ * Returns a Map<filePath, string[]> where the array is parallel to the order
69
+ * `parseHunks(filePatch)` returns hunks. The frontend's `parseDiffIntoBlocks`
70
+ * walks hunks in the same order, so `hunk_hashes[i]` matches `block[i]`.
71
+ *
72
+ * This is computed from the canonical (non-whitespace-filtered) diff so that
73
+ * the resulting hashes always match the keys persisted in `hunk_summaries`.
74
+ * Even when the rendered patch is `?w=1` (whitespace-filtered), the hashes
75
+ * stay aligned to the canonical hunks.
76
+ *
77
+ * @param {string} canonicalDiff - Full unified diff (NOT whitespace-filtered)
78
+ * @returns {Map<string, string[]>}
79
+ */
80
+ function computeHunkHashesFromDiff(canonicalDiff) {
81
+ const result = new Map();
82
+ if (!canonicalDiff) return result;
83
+ const filePatchMap = parseUnifiedDiffPatches(canonicalDiff);
84
+ for (const [filePath, filePatch] of filePatchMap.entries()) {
85
+ const hunks = parseHunks(filePatch);
86
+ const hashes = hunks.map((h) => hashHunk(filePath, `${h.header}\n${h.lines.join('\n')}`));
87
+ result.set(filePath, hashes);
88
+ }
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * Decorate a `changed_files` array with a parallel `hunk_hashes` array per
94
+ * file. Hashes come from the canonical diff so they remain stable across
95
+ * whitespace-filtered renders.
96
+ *
97
+ * @param {Array<object>} changedFiles
98
+ * @param {string} canonicalDiff
99
+ * @returns {Array<object>} New array; inputs are not mutated.
100
+ */
101
+ function attachHunkHashes(changedFiles, canonicalDiff) {
102
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) return changedFiles;
103
+ const hashes = computeHunkHashesFromDiff(canonicalDiff);
104
+ return changedFiles.map((file) => {
105
+ if (typeof file === 'string') return file;
106
+ const filePath = file?.file;
107
+ if (!filePath) return file;
108
+ const fileHashes = hashes.get(filePath);
109
+ if (!fileHashes) return file;
110
+ return { ...file, hunk_hashes: fileHashes };
111
+ });
112
+ }
113
+
114
+ module.exports._computeHunkHashesFromDiff = computeHunkHashesFromDiff;
115
+ module.exports._attachHunkHashes = attachHunkHashes;
116
+
117
+ /**
118
+ * Resolve the host binding for a PR route, with alt-host safety checks.
119
+ *
120
+ * For an alt-host-configured repo we MUST NOT silently fall back to the
121
+ * server-startup github.com token cached at `req.app.get('githubToken')` —
122
+ * pointing that token at the alt-host would either auth-fail or, worse,
123
+ * leak it to a third party. When the binding resolves no token for an
124
+ * alt-host repo, throw a clear configuration error.
125
+ *
126
+ * For github.com repos (no `apiHost`) we preserve the pre-existing
127
+ * fallback to `req.app.get('githubToken')` so the GET endpoints that
128
+ * never touched per-repo config keep working.
129
+ *
130
+ * @param {import('express').Request} req
131
+ * @param {string} repository - "owner/repo" identifier
132
+ * @returns {{ binding: {apiHost: string|null, token: string, features: Object, source: string}, token: string }|null}
133
+ * `null` when no token can be resolved AND the repo is github.com (so
134
+ * callers can fall through to optional behaviour). Throws for alt-host
135
+ * misconfiguration.
136
+ */
137
+ function resolveBindingForRequest(req, repository) {
138
+ const config = req.app.get('config') || {};
139
+ // `repository` here is the PR-identity `${owner}/${repo}`. For
140
+ // monorepo-style configs the binding-key in `config.repos` can differ;
141
+ // resolve it via `resolveBindingRepositoryFromPR` before looking up
142
+ // the host binding so per-repo tokens/api_host/features apply.
143
+ const [owner, repo] = String(repository).split('/');
144
+ const bindingRepository = resolveBindingRepositoryFromPR(owner, repo, config);
145
+ const binding = resolveHostBinding(bindingRepository, config);
146
+ if (binding.token) {
147
+ return { binding, token: binding.token, bindingRepository };
148
+ }
149
+ // No token from repo or top-level config — for github.com fall back to
150
+ // the server-startup cached token (legacy behaviour); for alt-host we
151
+ // refuse to use that token because it's for github.com.
152
+ if (binding.apiHost) {
153
+ throw new Error(
154
+ `No GitHub token configured for alt-host repo ${repository} (${binding.apiHost}). Configure repos["${bindingRepository}"].token or token_command.`
155
+ );
156
+ }
157
+ const fallback = req.app.get('githubToken');
158
+ if (fallback) {
159
+ return {
160
+ binding: { ...binding, token: fallback, source: 'app:githubToken' },
161
+ token: fallback,
162
+ bindingRepository
163
+ };
164
+ }
165
+ return null;
166
+ }
167
+
59
168
  /**
60
169
  * Sync pending draft review from GitHub with local database
61
170
  *
@@ -71,15 +180,24 @@ const router = express.Router();
71
180
  * @param {number} reviewId - The local review ID
72
181
  * @param {Object} githubPendingReview - The pending review data from GitHub GraphQL API
73
182
  * @param {GitHubClient} [githubClient] - Optional GitHub client for querying old review states
183
+ * @param {Object} [prContext] - `{ owner, repo, prNumber }` — required for REST mode of `getReviewById`
74
184
  * @returns {Promise<Object>} The synced pending draft record with comments_count
75
185
  */
76
- async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPendingReview, githubClient = null) {
186
+ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPendingReview, githubClient = null, prContext = null) {
77
187
  // Find all our pending records for this review
78
188
  const existingPendingRecords = await githubReviewRepo.findPendingByReviewId(reviewId);
79
189
 
80
- // Check if this GitHub draft matches any of our records by node_id
81
- const matchingRecord = existingPendingRecords.find(
82
- r => r.github_node_id === githubPendingReview.id
190
+ // Check if this GitHub draft matches any of our records. Match on
191
+ // either the GraphQL node id OR the stringified numeric databaseId —
192
+ // alt-host REST responses may not surface a node_id consistently, so
193
+ // a numeric-id-only record is the only identifier we have to anchor
194
+ // a draft against an existing local record.
195
+ const githubDbIdStr = (githubPendingReview.databaseId !== undefined && githubPendingReview.databaseId !== null)
196
+ ? String(githubPendingReview.databaseId)
197
+ : null;
198
+ const matchingRecord = existingPendingRecords.find(r =>
199
+ (r.github_node_id && r.github_node_id === githubPendingReview.id) ||
200
+ (githubDbIdStr !== null && r.github_review_id === githubDbIdStr)
83
201
  );
84
202
 
85
203
  let pendingDraft;
@@ -99,9 +217,20 @@ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPend
99
217
  let actualState = 'dismissed'; // Default if we can't determine
100
218
  let githubReviewData = null;
101
219
 
102
- if (githubClient && oldRecord.github_node_id) {
220
+ // On the GraphQL path the node id is the canonical identifier; on
221
+ // the REST path the numeric id (`github_review_id`) is the only
222
+ // value we may have. Run the lookup whenever we have either —
223
+ // otherwise mark the record as dismissed without querying.
224
+ const oldLookupId = oldRecord.github_node_id || oldRecord.github_review_id;
225
+ if (githubClient && (oldRecord.github_node_id || oldRecord.github_review_id)) {
103
226
  try {
104
- githubReviewData = await githubClient.getReviewById(oldRecord.github_node_id);
227
+ // prContext carries the REST review id when available. The
228
+ // GraphQL path ignores it. The github_review_id column holds
229
+ // the numeric REST id we received when the draft was created.
230
+ const reviewPrContext = prContext
231
+ ? { ...prContext, reviewId: oldRecord.github_review_id }
232
+ : null;
233
+ githubReviewData = await githubClient.getReviewById(oldLookupId, reviewPrContext);
105
234
 
106
235
  if (githubReviewData) {
107
236
  // Map GitHub state to our local state
@@ -116,15 +245,15 @@ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPend
116
245
  // APPROVED, CHANGES_REQUESTED, COMMENTED all mean it was submitted
117
246
  actualState = 'submitted';
118
247
  }
119
- logger.debug(`Old review ${oldRecord.github_node_id} actual state from GitHub: ${githubReviewData.state} -> ${actualState}`);
248
+ logger.debug(`Old review ${oldLookupId} actual state from GitHub: ${githubReviewData.state} -> ${actualState}`);
120
249
  } else {
121
250
  // Review not found on GitHub - treat as dismissed
122
- logger.debug(`Old review ${oldRecord.github_node_id} not found on GitHub, marking as dismissed`);
251
+ logger.debug(`Old review ${oldLookupId} not found on GitHub, marking as dismissed`);
123
252
  actualState = 'dismissed';
124
253
  }
125
254
  } catch (error) {
126
255
  // On error, default to dismissed (most likely scenario)
127
- logger.warn(`Error querying GitHub for old review ${oldRecord.github_node_id}: ${error.message}, marking as dismissed`);
256
+ logger.warn(`Error querying GitHub for old review ${oldLookupId}: ${error.message}, marking as dismissed`);
128
257
  actualState = 'dismissed';
129
258
  }
130
259
  }
@@ -211,18 +340,30 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
211
340
  // Check for pending GitHub draft
212
341
  let pendingDraft = null;
213
342
  {
214
- const config = req.app.get('config');
215
- const githubToken = getGitHubToken(config || {}) || req.app.get('githubToken');
343
+ let resolved = null;
344
+ try {
345
+ resolved = resolveBindingForRequest(req, repository);
346
+ } catch (configErr) {
347
+ // Alt-host repo with no token configured — surface the message
348
+ // but don't fail the GET (draft info is supplementary).
349
+ logger.warn(configErr.message);
350
+ }
216
351
 
217
- if (githubToken) {
352
+ if (resolved) {
218
353
  try {
219
- const githubClient = new GitHubClient(githubToken);
354
+ const githubClient = new GitHubClient(resolved.binding);
220
355
  const githubReviewRepo = new GitHubReviewRepository(db);
221
356
 
222
357
  const githubPendingReview = await githubClient.getPendingReviewForUser(repoOwner, repoName, prNumber);
223
358
 
224
359
  if (githubPendingReview) {
225
- pendingDraft = await syncPendingDraftFromGitHub(githubReviewRepo, review.id, githubPendingReview, githubClient);
360
+ pendingDraft = await syncPendingDraftFromGitHub(
361
+ githubReviewRepo,
362
+ review.id,
363
+ githubPendingReview,
364
+ githubClient,
365
+ { owner: repoOwner, repo: repoName, prNumber }
366
+ );
226
367
  }
227
368
  } catch (githubError) {
228
369
  // Log the error but don't fail the request - draft info is supplementary
@@ -239,11 +380,15 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
239
380
  // Detect PR stack via GitHub GraphQL chain-walking
240
381
  let stackData = null;
241
382
  {
242
- const stackConfig = req.app.get('config') || {};
243
- const githubToken = getGitHubToken(stackConfig) || req.app.get('githubToken');
244
- if (githubToken) {
383
+ let resolved = null;
384
+ try {
385
+ resolved = resolveBindingForRequest(req, repository);
386
+ } catch (configErr) {
387
+ logger.warn(configErr.message);
388
+ }
389
+ if (resolved) {
245
390
  try {
246
- const ghClient = new GitHubClient(githubToken);
391
+ const ghClient = new GitHubClient(resolved.binding);
247
392
  const defaultBranch = extendedData.repository?.default_branch;
248
393
  stackData = await walkPRStack(ghClient, repoOwner, repoName, prNumber, {
249
394
  defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
@@ -254,8 +399,13 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
254
399
  }
255
400
  }
256
401
 
257
- // Prepare response
258
- const changedFiles = mergeChangedFilesWithDiff(extendedData.changed_files || [], extendedData.diff || '');
402
+ // Prepare response. Hunk hashes are computed from the canonical diff so
403
+ // they remain stable across whitespace-filtered renders (the rendered
404
+ // patch may be filtered, but the persisted hash keys are not).
405
+ const changedFiles = attachHunkHashes(
406
+ mergeChangedFilesWithDiff(extendedData.changed_files || [], extendedData.diff || ''),
407
+ extendedData.diff || ''
408
+ );
259
409
 
260
410
  // Use review.id instead of prMetadata.id to avoid ID collision with local mode
261
411
  const response = {
@@ -314,6 +464,40 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
314
464
  }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
315
465
  }
316
466
 
467
+ (async () => {
468
+ const reviewContext = {
469
+ prTitle: prMetadata.title,
470
+ prDescription: prMetadata.description,
471
+ changedFiles: changedFiles.map((f) => (typeof f === 'string' ? f : (f.filename || f.file || f.path))).filter(Boolean)
472
+ };
473
+ const results = await Promise.allSettled([
474
+ summaryGenerator.kickOffSummaryJob({
475
+ db,
476
+ config,
477
+ reviewId: review.id,
478
+ diffText: extendedData.diff,
479
+ worktreePath: extendedData.worktree_path,
480
+ reviewContext,
481
+ trigger: 'auto'
482
+ }),
483
+ tourGenerator.kickOffTourJob({
484
+ db,
485
+ config,
486
+ reviewId: review.id,
487
+ diffText: extendedData.diff,
488
+ worktreePath: extendedData.worktree_path,
489
+ reviewContext,
490
+ trigger: 'auto'
491
+ })
492
+ ]);
493
+ const labels = ['Hunk summary', 'Tour'];
494
+ results.forEach((r, i) => {
495
+ if (r.status === 'rejected') {
496
+ logger.warn(`${labels[i]} kickoff failed for review ${review.id}: ${r.reason?.message || r.reason}`);
497
+ }
498
+ });
499
+ })().catch((err) => logger.warn(`Background AI kickoff failed for review ${review.id}: ${err.message}`));
500
+
317
501
  } catch (error) {
318
502
  console.error('Error fetching PR data:', error);
319
503
  res.status(500).json({
@@ -354,10 +538,16 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
354
538
  });
355
539
  }
356
540
 
357
- // Fetch fresh PR data from GitHub
358
- const githubToken = getGitHubToken(config);
359
- if (!githubToken) {
360
- return res.status(401).json({ error: 'GitHub token not configured' });
541
+ // Resolve host binding for this repo (validates alt-host token presence).
542
+ let binding;
543
+ try {
544
+ const resolved = resolveBindingForRequest(req, repository);
545
+ if (!resolved) {
546
+ return res.status(401).json({ error: 'GitHub token not configured' });
547
+ }
548
+ binding = resolved.binding;
549
+ } catch (configErr) {
550
+ return res.status(500).json({ error: configErr.message });
361
551
  }
362
552
 
363
553
  // Check if worktree is locked before modifying it
@@ -373,7 +563,7 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
373
563
  }
374
564
  }
375
565
 
376
- const githubClient = new GitHubClient(githubToken);
566
+ const githubClient = new GitHubClient(binding);
377
567
  const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
378
568
 
379
569
  // Update worktree with latest changes
@@ -460,10 +650,15 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
460
650
  // Refresh stack data via GitHub GraphQL
461
651
  let stackData = null;
462
652
  {
463
- const githubToken = getGitHubToken(config) || req.app.get('githubToken');
464
- if (githubToken) {
653
+ let resolved = null;
654
+ try {
655
+ resolved = resolveBindingForRequest(req, repository);
656
+ } catch (configErr) {
657
+ logger.warn(configErr.message);
658
+ }
659
+ if (resolved) {
465
660
  try {
466
- const ghClient = new GitHubClient(githubToken);
661
+ const ghClient = new GitHubClient(resolved.binding);
467
662
  const defaultBranch = prData.repository?.default_branch;
468
663
  stackData = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
469
664
  defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
@@ -506,6 +701,35 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
506
701
 
507
702
  res.json(response);
508
703
 
704
+ // Re-kick the summary and tour jobs against the freshly-refreshed diff.
705
+ // The frontend's refreshPR() calls this POST then GETs /diff (which is a
706
+ // read-only endpoint and does NOT enqueue), so without an explicit
707
+ // kickoff here the in-flight stale job would keep burning tokens until
708
+ // it completes. Each kickoff is dedup'd by diff digest/hash; when the
709
+ // diff actually changed (new PR HEAD), the kickoffs auto-cancel the
710
+ // stale in-flight job before enqueueing the fresh one.
711
+ (async () => {
712
+ const reviewContext = {
713
+ prTitle: prMetadata.title,
714
+ prDescription: prMetadata.description,
715
+ changedFiles: (changedFiles || []).map((f) => (typeof f === 'string' ? f : (f.filename || f.file || f.path))).filter(Boolean)
716
+ };
717
+ const results = await Promise.allSettled([
718
+ summaryGenerator.kickOffSummaryJob({
719
+ db, config, reviewId: review.id, diffText: extendedData.diff, worktreePath: extendedData.worktree_path, reviewContext, trigger: 'auto'
720
+ }),
721
+ tourGenerator.kickOffTourJob({
722
+ db, config, reviewId: review.id, diffText: extendedData.diff, worktreePath: extendedData.worktree_path, reviewContext, trigger: 'auto'
723
+ })
724
+ ]);
725
+ const labels = ['Hunk summary', 'Tour'];
726
+ results.forEach((r, i) => {
727
+ if (r.status === 'rejected') {
728
+ logger.warn(`${labels[i]} kickoff failed for review ${review.id} on refresh: ${r.reason?.message || r.reason}`);
729
+ }
730
+ });
731
+ })().catch((err) => logger.warn(`Background AI kickoff failed for review ${review.id} on refresh: ${err.message}`));
732
+
509
733
  } catch (error) {
510
734
  logger.error('Error refreshing PR:', error);
511
735
  res.status(500).json({
@@ -514,6 +738,127 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
514
738
  }
515
739
  });
516
740
 
741
+ /**
742
+ * POST /api/pr/:owner/:repo/:number/jobs/:jobKey/start
743
+ *
744
+ * Manually trigger a summary or tour generation job for this PR. Used by the
745
+ * frontend when `auto_generate` is off and the user clicks the toolbar button.
746
+ *
747
+ * Mirrors the server-side kickoff that runs on PR load, but passes
748
+ * `trigger: 'manual'` so it bypasses the `auto_generate` gate (the `enabled`
749
+ * gate still applies — disabled features return 409).
750
+ *
751
+ * Request:
752
+ * - `jobKey` path param: `summary` or `tour`
753
+ *
754
+ * Responses:
755
+ * - 200 `{ started: true, alreadyRunning: false }` — enqueued
756
+ * - 200 `{ started: false, alreadyRunning: true }` — feature on but a job
757
+ * is already in flight
758
+ * (idempotent no-op)
759
+ * - 200 `{ started: false, reason: 'no-diff' }` — diff is empty
760
+ * - 400 `{ error: 'Invalid jobKey' }` — unknown jobKey
761
+ * - 404 `{ error: '...' }` — PR not found
762
+ * - 409 `{ error: '... disabled' }` — feature disabled in config
763
+ */
764
+ const MANUAL_START_JOB_KEYS = new Set(['summary', 'tour']);
765
+
766
+ router.post('/api/pr/:owner/:repo/:number/jobs/:jobKey/start', async (req, res) => {
767
+ try {
768
+ const { owner, repo, number, jobKey } = req.params;
769
+ const prNumber = parseInt(number, 10);
770
+
771
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
772
+ return res.status(400).json({ error: 'Invalid pull request number' });
773
+ }
774
+ if (!MANUAL_START_JOB_KEYS.has(jobKey)) {
775
+ return res.status(400).json({ error: `Invalid jobKey "${jobKey}" (expected "summary" or "tour")` });
776
+ }
777
+
778
+ const repository = normalizeRepository(owner, repo);
779
+ const db = req.app.get('db');
780
+ const config = req.app.get('config') || {};
781
+
782
+ // Enforce the feature-enabled gate at the HTTP boundary so the frontend
783
+ // gets a clean 409 instead of a silent no-op from the generator.
784
+ if (jobKey === 'summary' && !getSummaryEnabled(config)) {
785
+ return res.status(409).json({ error: 'Summaries feature is disabled in config' });
786
+ }
787
+ if (jobKey === 'tour' && !getTourEnabled(config)) {
788
+ return res.status(409).json({ error: 'Tours feature is disabled in config' });
789
+ }
790
+
791
+ const prMetadata = await queryOne(db, `
792
+ SELECT id, pr_number, repository, title, description, pr_data
793
+ FROM pr_metadata
794
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
795
+ `, [prNumber, repository]);
796
+
797
+ if (!prMetadata) {
798
+ return res.status(404).json({
799
+ error: `Pull request #${prNumber} not found in repository ${repository}`
800
+ });
801
+ }
802
+
803
+ const reviewRepo = new ReviewRepository(db);
804
+ const { review } = await reviewRepo.getOrCreate({ prNumber, repository });
805
+
806
+ let extendedData = {};
807
+ try {
808
+ extendedData = prMetadata.pr_data ? JSON.parse(prMetadata.pr_data) : {};
809
+ } catch (parseError) {
810
+ logger.warn(`Could not parse pr_data for PR #${prNumber}: ${parseError.message}`);
811
+ }
812
+
813
+ const diffText = extendedData.diff || '';
814
+ const worktreePath = extendedData.worktree_path || null;
815
+
816
+ // Unlike local mode, a PR's diff is always persisted in `pr_data` at PR-load
817
+ // time — there is no in-memory cache or working-tree regeneration to fall
818
+ // back on. So an empty diff here genuinely means `pr_data` has no diff, and
819
+ // this `no-diff` cannot be a false negative (no parity fix needed; see the
820
+ // local manual-start handler in local.js for the self-healing variant).
821
+ if (!diffText || !worktreePath) {
822
+ return res.json({ started: false, reason: 'no-diff' });
823
+ }
824
+
825
+ // Idempotency: if a job is already in flight for this review/job-type,
826
+ // don't double-start. The frontend already has the in-flight event stream.
827
+ const activeJobType = typeof backgroundQueue.findActiveJobType === 'function'
828
+ ? backgroundQueue.findActiveJobType(review.id, jobKey === 'summary' ? 'summaries' : 'tour')
829
+ : null;
830
+ if (activeJobType) {
831
+ return res.json({ started: false, alreadyRunning: true });
832
+ }
833
+
834
+ const reviewContext = {
835
+ prTitle: prMetadata.title,
836
+ prDescription: prMetadata.description,
837
+ changedFiles: (extendedData.changed_files || [])
838
+ .map((f) => (typeof f === 'string' ? f : (f.filename || f.file || f.path)))
839
+ .filter(Boolean)
840
+ };
841
+
842
+ // Kick off in the background — return the start status immediately so
843
+ // the frontend can switch the button to its generating state. Errors
844
+ // are logged but the HTTP response is already sent.
845
+ if (jobKey === 'summary') {
846
+ Promise.resolve(summaryGenerator.kickOffSummaryJob({
847
+ db, config, reviewId: review.id, diffText, worktreePath, reviewContext, trigger: 'manual'
848
+ })).catch((err) => logger.warn(`Manual hunk summary kickoff failed for review ${review.id}: ${err.message}`));
849
+ } else {
850
+ Promise.resolve(tourGenerator.kickOffTourJob({
851
+ db, config, reviewId: review.id, diffText, worktreePath, reviewContext, trigger: 'manual'
852
+ })).catch((err) => logger.warn(`Manual tour kickoff failed for review ${review.id}: ${err.message}`));
853
+ }
854
+
855
+ return res.json({ started: true, alreadyRunning: false });
856
+ } catch (error) {
857
+ logger.error('Error starting manual job:', error);
858
+ res.status(500).json({ error: 'Failed to start job: ' + error.message });
859
+ }
860
+ });
861
+
517
862
  /**
518
863
  * Check if PR data is stale (remote has newer commits)
519
864
  */
@@ -567,11 +912,17 @@ router.get('/api/pr/:owner/:repo/:number/check-stale', async (req, res) => {
567
912
  }
568
913
 
569
914
  // Fetch current PR from GitHub
570
- const githubToken = getGitHubToken(config);
571
- if (!githubToken) {
572
- return res.json({ isStale: null, error: 'GitHub token not configured' });
915
+ let binding;
916
+ try {
917
+ const resolved = resolveBindingForRequest(req, repository);
918
+ if (!resolved) {
919
+ return res.json({ isStale: null, error: 'GitHub token not configured' });
920
+ }
921
+ binding = resolved.binding;
922
+ } catch (configErr) {
923
+ return res.json({ isStale: null, error: configErr.message });
573
924
  }
574
- const githubClient = new GitHubClient(githubToken);
925
+ const githubClient = new GitHubClient(binding);
575
926
  const remotePrData = await githubClient.fetchPullRequest(owner, repo, prNumber);
576
927
 
577
928
  const remoteHeadSha = remotePrData.head_sha;
@@ -633,14 +984,20 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
633
984
  }
634
985
 
635
986
  // Initialize GitHub client and check for pending drafts on GitHub
636
- const githubToken = getGitHubToken(config) || req.app.get('githubToken');
637
- if (!githubToken) {
638
- return res.status(500).json({
639
- error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
640
- });
987
+ let binding;
988
+ try {
989
+ const resolved = resolveBindingForRequest(req, repository);
990
+ if (!resolved) {
991
+ return res.status(500).json({
992
+ error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
993
+ });
994
+ }
995
+ binding = resolved.binding;
996
+ } catch (configErr) {
997
+ return res.status(500).json({ error: configErr.message });
641
998
  }
642
999
 
643
- const githubClient = new GitHubClient(githubToken);
1000
+ const githubClient = new GitHubClient(binding);
644
1001
  const githubReviewRepo = new GitHubReviewRepository(db);
645
1002
 
646
1003
  // Fetch pending review from GitHub
@@ -649,7 +1006,13 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
649
1006
  const githubPendingReview = await githubClient.getPendingReviewForUser(owner, repo, prNumber);
650
1007
 
651
1008
  if (githubPendingReview) {
652
- pendingDraft = await syncPendingDraftFromGitHub(githubReviewRepo, review.id, githubPendingReview, githubClient);
1009
+ pendingDraft = await syncPendingDraftFromGitHub(
1010
+ githubReviewRepo,
1011
+ review.id,
1012
+ githubPendingReview,
1013
+ githubClient,
1014
+ { owner, repo, prNumber }
1015
+ );
653
1016
  }
654
1017
  } catch (githubError) {
655
1018
  // Log the error but don't fail the request - return local data only
@@ -826,6 +1189,21 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
826
1189
  }));
827
1190
  }
828
1191
 
1192
+ // Hunk hashes MUST come from the canonical (unfiltered) diff. When
1193
+ // hideWhitespace is on the rendered `diffContent` is the filtered diff,
1194
+ // which would produce hashes that diverge from the keys persisted in
1195
+ // `hunk_summaries`. We deliberately do NOT fall back to `diffContent`:
1196
+ // if `prData.diff` is missing, fail closed and emit no hashes so the
1197
+ // frontend skips anchoring rather than anchoring to misaligned hunks.
1198
+ if (prData.diff) {
1199
+ changedFiles = attachHunkHashes(changedFiles, prData.diff);
1200
+ } else {
1201
+ logger.warn(
1202
+ `[hunk-hash] PR #${prNumber} ${repository}: no canonical prData.diff; ` +
1203
+ 'omitting hunk_hashes (summaries will not anchor for this response).'
1204
+ );
1205
+ }
1206
+
829
1207
  // When diff was regenerated (whitespace), compute aggregate stats from
830
1208
  // the regenerated changedFiles instead of using stale cached values from prData.
831
1209
  const additions = hideWhitespace
@@ -1085,16 +1463,22 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1085
1463
  const repository = normalizeRepository(owner, repo);
1086
1464
  const db = req.app.get('db');
1087
1465
 
1088
- // Get GitHub token from app context (set during app initialization)
1089
- const githubToken = req.app.get('githubToken');
1090
- if (!githubToken) {
1091
- return res.status(500).json({
1092
- error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
1093
- });
1466
+ // Resolve host binding (repo-aware: alt-host repos require their own token).
1467
+ let binding;
1468
+ try {
1469
+ const resolved = resolveBindingForRequest(req, repository);
1470
+ if (!resolved) {
1471
+ return res.status(500).json({
1472
+ error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
1473
+ });
1474
+ }
1475
+ binding = resolved.binding;
1476
+ } catch (configErr) {
1477
+ return res.status(500).json({ error: configErr.message });
1094
1478
  }
1095
1479
 
1096
1480
  // Initialize GitHub client
1097
- const githubClient = new GitHubClient(githubToken);
1481
+ const githubClient = new GitHubClient(binding);
1098
1482
 
1099
1483
  // Get PR metadata and worktree path
1100
1484
  const prMetadata = await queryOne(db, `
@@ -1150,13 +1534,6 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1150
1534
  //
1151
1535
  // We check whether the comment's target line actually appears in a diff hunk
1152
1536
  // rather than relying on diff_position (which may not be set by all sources).
1153
- const prNodeId = prData.node_id;
1154
- if (!prNodeId) {
1155
- return res.status(400).json({
1156
- error: 'PR node_id not available. Please refresh the PR data and try again.'
1157
- });
1158
- }
1159
-
1160
1537
  const diffLineSet = buildDiffLineSet(diffContent);
1161
1538
 
1162
1539
  const graphqlComments = comments.map(comment => {
@@ -1226,10 +1603,63 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1226
1603
  // GitHub only allows one pending review per user per PR
1227
1604
  const existingDraft = await githubClient.getPendingReviewForUser(owner, repo, prNumber);
1228
1605
 
1606
+ // GraphQL PR node id is only required when the dispatcher actually
1607
+ // routes to a GraphQL implementation AND we'll be creating a brand
1608
+ // new review (not reusing the existing draft) OR adding GraphQL
1609
+ // review comments. REST review-lifecycle and host pending-review
1610
+ // -comments address the PR by (owner, repo, prNumber) + numeric
1611
+ // review id and ignore prNodeId; reusing an existing GraphQL draft
1612
+ // also doesn't need the PR node id because the review node id is
1613
+ // sufficient. Compute this after fetching existingDraft so the
1614
+ // requirement is narrowed correctly.
1615
+ const willCreateNewGraphQLReview =
1616
+ binding.features.review_lifecycle === 'graphql' && !existingDraft;
1617
+ const willAddGraphQLComments =
1618
+ graphqlComments.length > 0 && binding.features.pending_review_comments === 'graphql';
1619
+ const needsGraphQLNodeId = willCreateNewGraphQLReview || willAddGraphQLComments;
1620
+
1621
+ if (needsGraphQLNodeId && !prData.node_id) {
1622
+ return res.status(400).json({
1623
+ error:
1624
+ `GraphQL PR node id required for ${repository}#${prNumber} ` +
1625
+ `(features.review_lifecycle = "${binding.features.review_lifecycle}", ` +
1626
+ `pending_review_comments = "${binding.features.pending_review_comments}"). ` +
1627
+ `PR record is missing node_id — refresh the PR data and try again.`
1628
+ });
1629
+ }
1630
+
1631
+ const prNodeId = prData.node_id ?? null;
1632
+
1633
+ // The PR head SHA is required by the host pending-review-comments path
1634
+ // (GitHub-compatible alt-hosts validate each inline comment like
1635
+ // `pulls.createReviewComment`, which mandates `commit_id`). The GraphQL
1636
+ // path on github.com ignores it (the pending review pins the commit
1637
+ // implicitly), so threading it through is harmless there.
1638
+ // `prData.head_sha` is the canonical source (merged from the cached PR
1639
+ // JSON); `prMetadata.head_sha` is a defensive fallback for callers whose
1640
+ // record exposes it as a column. If neither is present, proceed without
1641
+ // it but warn loudly so the resulting 422 is diagnosable.
1642
+ const headSha = prData.head_sha || prMetadata.head_sha || null;
1643
+ if (!headSha) {
1644
+ logger.warn(
1645
+ `Submit review for ${repository}#${prNumber}: PR head SHA is missing ` +
1646
+ `(prData.head_sha and prMetadata.head_sha both absent). Host inline-comment ` +
1647
+ `posting will likely fail with a 422 missing commit_id error.`
1648
+ );
1649
+ }
1650
+
1651
+ const submitPrContext = {
1652
+ owner,
1653
+ repo,
1654
+ prNumber,
1655
+ reviewId: existingDraft?.databaseId,
1656
+ headSha
1657
+ };
1658
+
1229
1659
  if (event === 'DRAFT') {
1230
1660
  // Delegate to createDraftReviewGraphQL (handles both new and existing drafts)
1231
1661
  githubReview = await githubClient.createDraftReviewGraphQL(
1232
- prNodeId, body || '', graphqlComments, existingDraft?.id
1662
+ prNodeId, body || '', graphqlComments, existingDraft?.id, submitPrContext
1233
1663
  );
1234
1664
  // When adding to an existing draft, use the existing URL and include prior comments in total count
1235
1665
  if (existingDraft) {
@@ -1238,7 +1668,9 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1238
1668
  }
1239
1669
  } else {
1240
1670
  // For non-drafts, create/use review, add comments, and submit
1241
- githubReview = await githubClient.createReviewGraphQL(prNodeId, event, body || '', graphqlComments, existingDraft?.id);
1671
+ githubReview = await githubClient.createReviewGraphQL(
1672
+ prNodeId, event, body || '', graphqlComments, existingDraft?.id, submitPrContext
1673
+ );
1242
1674
  }
1243
1675
 
1244
1676
  // ID storage strategy:
@@ -1311,10 +1743,15 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1311
1743
  // Commit transaction
1312
1744
  await run(db, 'COMMIT');
1313
1745
 
1314
- // Send success response after all database operations complete
1746
+ // Send success response after all database operations complete.
1747
+ // Use the configured remote-host display name (e.g. "Meteorite")
1748
+ // instead of the literal "GitHub". Resolve via the binding repository
1749
+ // so monorepo url_pattern configs map to the right repos[...] entry.
1750
+ const cfg = req.app.get('config') || {};
1751
+ const hostName = resolveHostName(cfg, resolveBindingRepositoryFromPR(owner, repo, cfg));
1315
1752
  res.json({
1316
1753
  success: true,
1317
- message: `${event === 'DRAFT' ? 'Draft review created' : 'Review submitted'} successfully ${event === 'DRAFT' ? 'on' : 'to'} GitHub`,
1754
+ message: `${event === 'DRAFT' ? 'Draft review created' : 'Review submitted'} successfully ${event === 'DRAFT' ? 'on' : 'to'} ${hostName}`,
1318
1755
  github_url: githubReview.html_url,
1319
1756
  comments_submitted: githubReview.comments_count,
1320
1757
  event: event,
@@ -1489,11 +1926,12 @@ router.get('/api/pr/health', (req, res) => {
1489
1926
 
1490
1927
  /**
1491
1928
  * Parse a PR URL and extract owner, repo, and PR number
1492
- * Supports GitHub and Graphite URLs (with or without protocol)
1929
+ * Supports GitHub and Graphite URLs (with or without protocol), plus
1930
+ * any per-repo `url_pattern` regexes configured in pair-review config.
1493
1931
  */
1494
1932
  router.post('/api/parse-pr-url', (req, res) => {
1495
- const { PRArgumentParser } = require('../github/parser');
1496
- const parser = new PRArgumentParser();
1933
+ const config = req.app.get('config') || null;
1934
+ const parser = new PRArgumentParser(config);
1497
1935
 
1498
1936
  const { url } = req.body;
1499
1937
 
@@ -1666,6 +2104,24 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1666
2104
  const appConfig = req.app.get('config') || {};
1667
2105
  const globalInstructions = appConfig.globalInstructions || null;
1668
2106
 
2107
+ // Build a GitHubClient so the analyzer can pre-fetch existing PR review
2108
+ // comments when the request opts in via excludePrevious.github. If no token
2109
+ // is available we pass undefined and the analyzer silently omits the GitHub
2110
+ // dedup section. For alt-host repos we use the repo-bound token and host;
2111
+ // for github.com repos we fall back to the server-startup cached token.
2112
+ let analyzerGithubClient;
2113
+ try {
2114
+ const resolved = resolveBindingForRequest(req, repository);
2115
+ analyzerGithubClient = resolved ? new GitHubClient(resolved.binding) : undefined;
2116
+ } catch (configErr) {
2117
+ // Alt-host misconfiguration — skip dedup pre-fetch with a clear log.
2118
+ logger.warn(`Skipping GitHub dedup pre-fetch: ${configErr.message}`);
2119
+ analyzerGithubClient = undefined;
2120
+ }
2121
+ if (analyzerGithubClient) {
2122
+ logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNumber}`);
2123
+ }
2124
+
1669
2125
  const { provider, model, repoInstructions, combinedInstructions, repoSettings: fetchedRepoSettings } = await withTransaction(db, async () => {
1670
2126
  const repoSettingsRepo = new RepoSettingsRepository(db);
1671
2127
  const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
@@ -1821,7 +2277,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1821
2277
 
1822
2278
  const progressCallback = createProgressCallback(analysisId);
1823
2279
 
1824
- analysisPromise = analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { globalInstructions, repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig, excludePrevious, serverPort: req.socket.localPort });
2280
+ analysisPromise = analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { globalInstructions, repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig, excludePrevious, serverPort: req.socket.localPort, githubClient: analyzerGithubClient });
1825
2281
  } catch (setupError) {
1826
2282
  // Synchronous setup failure — clean up the analysis hold immediately
1827
2283
  reviewToAnalysisId.delete(review.id);
@@ -2067,6 +2523,19 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
2067
2523
  const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
2068
2524
  buildCouncilProviderOverrides(prCouncilConfig, repository, repoSettings);
2069
2525
 
2526
+ // Build a GitHubClient for analyzer-side dedup pre-fetch (PR mode only).
2527
+ let councilGithubClient;
2528
+ try {
2529
+ const resolved = resolveBindingForRequest(req, repository);
2530
+ councilGithubClient = resolved ? new GitHubClient(resolved.binding) : undefined;
2531
+ } catch (configErr) {
2532
+ logger.warn(`Skipping GitHub dedup pre-fetch (council): ${configErr.message}`);
2533
+ councilGithubClient = undefined;
2534
+ }
2535
+ if (councilGithubClient) {
2536
+ logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNumber} (council)`);
2537
+ }
2538
+
2070
2539
  const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
2071
2540
  db,
2072
2541
  {
@@ -2081,6 +2550,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
2081
2550
  config: prCouncilConfig,
2082
2551
  excludePrevious,
2083
2552
  serverPort: req.socket.localPort,
2553
+ githubClient: councilGithubClient,
2084
2554
  poolLifecycle: req.app.get('poolLifecycle'),
2085
2555
  providerOverrides: councilProviderOverrides,
2086
2556
  providerOverridesMap: councilProviderOverridesMap,
@@ -2172,13 +2642,14 @@ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
2172
2642
  deletions: f.deletions ?? 0
2173
2643
  }));
2174
2644
 
2175
- // Get the authenticated user (who is sharing)
2645
+ // Get the authenticated user (who is sharing).
2646
+ // Use the repo's binding so authentication targets the right host —
2647
+ // an alt-host's `getAuthenticatedUser` resolves the user on that host.
2176
2648
  let sharedBy = null;
2177
2649
  try {
2178
- const config = req.app.get('config') || {};
2179
- const githubToken = getGitHubToken(config);
2180
- if (githubToken) {
2181
- const githubClient = new GitHubClient(githubToken);
2650
+ const resolved = resolveBindingForRequest(req, repository);
2651
+ if (resolved) {
2652
+ const githubClient = new GitHubClient(resolved.binding);
2182
2653
  const user = await githubClient.getAuthenticatedUser();
2183
2654
  sharedBy = user.login;
2184
2655
  }
@@ -2308,8 +2779,15 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
2308
2779
  const db = req.app.get('db');
2309
2780
  const config = req.app.get('config') || {};
2310
2781
 
2311
- const githubToken = getGitHubToken(config) || req.app.get('githubToken');
2312
- if (!githubToken) {
2782
+ let binding;
2783
+ try {
2784
+ const resolved = resolveBindingForRequest(req, repository);
2785
+ if (!resolved) {
2786
+ return res.json({ stack: [] });
2787
+ }
2788
+ binding = resolved.binding;
2789
+ } catch (configErr) {
2790
+ logger.warn(configErr.message);
2313
2791
  return res.json({ stack: [] });
2314
2792
  }
2315
2793
 
@@ -2321,7 +2799,7 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
2321
2799
  const parsedPrData = prMetadataRow?.pr_data ? JSON.parse(prMetadataRow.pr_data) : {};
2322
2800
  const defaultBranch = parsedPrData.repository?.default_branch;
2323
2801
 
2324
- const ghClient = new GitHubClient(githubToken);
2802
+ const ghClient = new GitHubClient(binding);
2325
2803
  let stack;
2326
2804
  try {
2327
2805
  stack = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
@@ -2381,3 +2859,10 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
2381
2859
  });
2382
2860
 
2383
2861
  module.exports = router;
2862
+ // Exported for tests so behavioural changes to the GitHub pending-draft
2863
+ // sync logic (e.g. Fix #10: matching by numeric id when node_id is
2864
+ // absent) can be exercised directly without spinning up the route.
2865
+ module.exports._internals = {
2866
+ syncPendingDraftFromGitHub,
2867
+ resolveBindingForRequest
2868
+ };