@in-the-loop-labs/pair-review 3.6.0 → 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 (63) 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 +0 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +39 -23
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ReviewModal.js +135 -13
  13. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  14. package/public/js/index.js +175 -16
  15. package/public/js/local.js +58 -8
  16. package/public/js/modules/suggestion-manager.js +25 -1
  17. package/public/js/modules/tour-renderer.js +33 -3
  18. package/public/js/pr.js +653 -157
  19. package/public/js/repo-links.js +328 -0
  20. package/public/js/utils/provider-model.js +88 -0
  21. package/public/js/utils/storage-keys.js +50 -0
  22. package/public/local.html +7 -0
  23. package/public/pr.html +7 -0
  24. package/public/repo-settings.html +1 -0
  25. package/public/setup.html +2 -0
  26. package/src/ai/analyzer.js +125 -18
  27. package/src/config.js +664 -10
  28. package/src/external/github-adapter.js +114 -25
  29. package/src/git/base-branch.js +11 -4
  30. package/src/github/client.js +482 -588
  31. package/src/github/errors.js +55 -0
  32. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  33. package/src/github/impl/graphql/pending-review.js +153 -0
  34. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  35. package/src/github/impl/graphql/stack-walker.js +210 -0
  36. package/src/github/impl/host/pending-review-comments.js +338 -0
  37. package/src/github/impl/rest/pending-review.js +251 -0
  38. package/src/github/impl/rest/review-lifecycle.js +226 -0
  39. package/src/github/impl/rest/stack-walker.js +309 -0
  40. package/src/github/operations/pending-review-comments.js +79 -0
  41. package/src/github/operations/pending-review.js +89 -0
  42. package/src/github/operations/review-lifecycle.js +126 -0
  43. package/src/github/operations/stack-walker.js +87 -0
  44. package/src/github/parser.js +230 -4
  45. package/src/github/stack-walker.js +14 -189
  46. package/src/links/repo-links.js +230 -0
  47. package/src/local-review.js +13 -4
  48. package/src/main.js +133 -30
  49. package/src/routes/analyses.js +30 -7
  50. package/src/routes/bulk-analysis-configs.js +295 -0
  51. package/src/routes/config.js +102 -2
  52. package/src/routes/external-comments.js +20 -10
  53. package/src/routes/github-collections.js +3 -1
  54. package/src/routes/local.js +101 -11
  55. package/src/routes/mcp.js +47 -4
  56. package/src/routes/pr.js +298 -68
  57. package/src/routes/setup.js +8 -3
  58. package/src/routes/stack-analysis.js +33 -9
  59. package/src/routes/worktrees.js +3 -2
  60. package/src/server.js +2 -0
  61. package/src/setup/pr-setup.js +37 -11
  62. package/src/setup/stack-setup.js +13 -3
  63. package/src/single-port.js +6 -3
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,8 @@ 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, getSummaryEnabled, getTourEnabled } = require('../config');
29
+ const { resolveHostBinding, resolveBindingRepositoryFromPR, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch, getSummaryEnabled, getTourEnabled } = require('../config');
30
+ const { resolveHostName } = require('../links/repo-links');
29
31
  const { backgroundQueue } = require('../ai/background-queue');
30
32
  const logger = require('../utils/logger');
31
33
  const { buildDiffLineSet } = require('../utils/diff-annotator');
@@ -112,6 +114,57 @@ function attachHunkHashes(changedFiles, canonicalDiff) {
112
114
  module.exports._computeHunkHashesFromDiff = computeHunkHashesFromDiff;
113
115
  module.exports._attachHunkHashes = attachHunkHashes;
114
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
+
115
168
  /**
116
169
  * Sync pending draft review from GitHub with local database
117
170
  *
@@ -127,15 +180,24 @@ module.exports._attachHunkHashes = attachHunkHashes;
127
180
  * @param {number} reviewId - The local review ID
128
181
  * @param {Object} githubPendingReview - The pending review data from GitHub GraphQL API
129
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`
130
184
  * @returns {Promise<Object>} The synced pending draft record with comments_count
131
185
  */
132
- async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPendingReview, githubClient = null) {
186
+ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPendingReview, githubClient = null, prContext = null) {
133
187
  // Find all our pending records for this review
134
188
  const existingPendingRecords = await githubReviewRepo.findPendingByReviewId(reviewId);
135
189
 
136
- // Check if this GitHub draft matches any of our records by node_id
137
- const matchingRecord = existingPendingRecords.find(
138
- 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)
139
201
  );
140
202
 
141
203
  let pendingDraft;
@@ -155,9 +217,20 @@ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPend
155
217
  let actualState = 'dismissed'; // Default if we can't determine
156
218
  let githubReviewData = null;
157
219
 
158
- 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)) {
159
226
  try {
160
- 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);
161
234
 
162
235
  if (githubReviewData) {
163
236
  // Map GitHub state to our local state
@@ -172,15 +245,15 @@ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPend
172
245
  // APPROVED, CHANGES_REQUESTED, COMMENTED all mean it was submitted
173
246
  actualState = 'submitted';
174
247
  }
175
- 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}`);
176
249
  } else {
177
250
  // Review not found on GitHub - treat as dismissed
178
- 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`);
179
252
  actualState = 'dismissed';
180
253
  }
181
254
  } catch (error) {
182
255
  // On error, default to dismissed (most likely scenario)
183
- 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`);
184
257
  actualState = 'dismissed';
185
258
  }
186
259
  }
@@ -267,18 +340,30 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
267
340
  // Check for pending GitHub draft
268
341
  let pendingDraft = null;
269
342
  {
270
- const config = req.app.get('config');
271
- 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
+ }
272
351
 
273
- if (githubToken) {
352
+ if (resolved) {
274
353
  try {
275
- const githubClient = new GitHubClient(githubToken);
354
+ const githubClient = new GitHubClient(resolved.binding);
276
355
  const githubReviewRepo = new GitHubReviewRepository(db);
277
356
 
278
357
  const githubPendingReview = await githubClient.getPendingReviewForUser(repoOwner, repoName, prNumber);
279
358
 
280
359
  if (githubPendingReview) {
281
- 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
+ );
282
367
  }
283
368
  } catch (githubError) {
284
369
  // Log the error but don't fail the request - draft info is supplementary
@@ -295,11 +380,15 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
295
380
  // Detect PR stack via GitHub GraphQL chain-walking
296
381
  let stackData = null;
297
382
  {
298
- const stackConfig = req.app.get('config') || {};
299
- const githubToken = getGitHubToken(stackConfig) || req.app.get('githubToken');
300
- 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) {
301
390
  try {
302
- const ghClient = new GitHubClient(githubToken);
391
+ const ghClient = new GitHubClient(resolved.binding);
303
392
  const defaultBranch = extendedData.repository?.default_branch;
304
393
  stackData = await walkPRStack(ghClient, repoOwner, repoName, prNumber, {
305
394
  defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
@@ -449,10 +538,16 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
449
538
  });
450
539
  }
451
540
 
452
- // Fetch fresh PR data from GitHub
453
- const githubToken = getGitHubToken(config);
454
- if (!githubToken) {
455
- 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 });
456
551
  }
457
552
 
458
553
  // Check if worktree is locked before modifying it
@@ -468,7 +563,7 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
468
563
  }
469
564
  }
470
565
 
471
- const githubClient = new GitHubClient(githubToken);
566
+ const githubClient = new GitHubClient(binding);
472
567
  const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
473
568
 
474
569
  // Update worktree with latest changes
@@ -555,10 +650,15 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
555
650
  // Refresh stack data via GitHub GraphQL
556
651
  let stackData = null;
557
652
  {
558
- const githubToken = getGitHubToken(config) || req.app.get('githubToken');
559
- 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) {
560
660
  try {
561
- const ghClient = new GitHubClient(githubToken);
661
+ const ghClient = new GitHubClient(resolved.binding);
562
662
  const defaultBranch = prData.repository?.default_branch;
563
663
  stackData = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
564
664
  defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
@@ -713,6 +813,11 @@ router.post('/api/pr/:owner/:repo/:number/jobs/:jobKey/start', async (req, res)
713
813
  const diffText = extendedData.diff || '';
714
814
  const worktreePath = extendedData.worktree_path || null;
715
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).
716
821
  if (!diffText || !worktreePath) {
717
822
  return res.json({ started: false, reason: 'no-diff' });
718
823
  }
@@ -807,11 +912,17 @@ router.get('/api/pr/:owner/:repo/:number/check-stale', async (req, res) => {
807
912
  }
808
913
 
809
914
  // Fetch current PR from GitHub
810
- const githubToken = getGitHubToken(config);
811
- if (!githubToken) {
812
- 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 });
813
924
  }
814
- const githubClient = new GitHubClient(githubToken);
925
+ const githubClient = new GitHubClient(binding);
815
926
  const remotePrData = await githubClient.fetchPullRequest(owner, repo, prNumber);
816
927
 
817
928
  const remoteHeadSha = remotePrData.head_sha;
@@ -873,14 +984,20 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
873
984
  }
874
985
 
875
986
  // Initialize GitHub client and check for pending drafts on GitHub
876
- const githubToken = getGitHubToken(config) || req.app.get('githubToken');
877
- if (!githubToken) {
878
- return res.status(500).json({
879
- error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
880
- });
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 });
881
998
  }
882
999
 
883
- const githubClient = new GitHubClient(githubToken);
1000
+ const githubClient = new GitHubClient(binding);
884
1001
  const githubReviewRepo = new GitHubReviewRepository(db);
885
1002
 
886
1003
  // Fetch pending review from GitHub
@@ -889,7 +1006,13 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
889
1006
  const githubPendingReview = await githubClient.getPendingReviewForUser(owner, repo, prNumber);
890
1007
 
891
1008
  if (githubPendingReview) {
892
- 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
+ );
893
1016
  }
894
1017
  } catch (githubError) {
895
1018
  // Log the error but don't fail the request - return local data only
@@ -1340,16 +1463,22 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1340
1463
  const repository = normalizeRepository(owner, repo);
1341
1464
  const db = req.app.get('db');
1342
1465
 
1343
- // Get GitHub token from app context (set during app initialization)
1344
- const githubToken = req.app.get('githubToken');
1345
- if (!githubToken) {
1346
- return res.status(500).json({
1347
- error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
1348
- });
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 });
1349
1478
  }
1350
1479
 
1351
1480
  // Initialize GitHub client
1352
- const githubClient = new GitHubClient(githubToken);
1481
+ const githubClient = new GitHubClient(binding);
1353
1482
 
1354
1483
  // Get PR metadata and worktree path
1355
1484
  const prMetadata = await queryOne(db, `
@@ -1405,13 +1534,6 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1405
1534
  //
1406
1535
  // We check whether the comment's target line actually appears in a diff hunk
1407
1536
  // rather than relying on diff_position (which may not be set by all sources).
1408
- const prNodeId = prData.node_id;
1409
- if (!prNodeId) {
1410
- return res.status(400).json({
1411
- error: 'PR node_id not available. Please refresh the PR data and try again.'
1412
- });
1413
- }
1414
-
1415
1537
  const diffLineSet = buildDiffLineSet(diffContent);
1416
1538
 
1417
1539
  const graphqlComments = comments.map(comment => {
@@ -1481,10 +1603,63 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1481
1603
  // GitHub only allows one pending review per user per PR
1482
1604
  const existingDraft = await githubClient.getPendingReviewForUser(owner, repo, prNumber);
1483
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
+
1484
1659
  if (event === 'DRAFT') {
1485
1660
  // Delegate to createDraftReviewGraphQL (handles both new and existing drafts)
1486
1661
  githubReview = await githubClient.createDraftReviewGraphQL(
1487
- prNodeId, body || '', graphqlComments, existingDraft?.id
1662
+ prNodeId, body || '', graphqlComments, existingDraft?.id, submitPrContext
1488
1663
  );
1489
1664
  // When adding to an existing draft, use the existing URL and include prior comments in total count
1490
1665
  if (existingDraft) {
@@ -1493,7 +1668,9 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1493
1668
  }
1494
1669
  } else {
1495
1670
  // For non-drafts, create/use review, add comments, and submit
1496
- githubReview = await githubClient.createReviewGraphQL(prNodeId, event, body || '', graphqlComments, existingDraft?.id);
1671
+ githubReview = await githubClient.createReviewGraphQL(
1672
+ prNodeId, event, body || '', graphqlComments, existingDraft?.id, submitPrContext
1673
+ );
1497
1674
  }
1498
1675
 
1499
1676
  // ID storage strategy:
@@ -1566,10 +1743,15 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
1566
1743
  // Commit transaction
1567
1744
  await run(db, 'COMMIT');
1568
1745
 
1569
- // 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));
1570
1752
  res.json({
1571
1753
  success: true,
1572
- 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}`,
1573
1755
  github_url: githubReview.html_url,
1574
1756
  comments_submitted: githubReview.comments_count,
1575
1757
  event: event,
@@ -1744,11 +1926,12 @@ router.get('/api/pr/health', (req, res) => {
1744
1926
 
1745
1927
  /**
1746
1928
  * Parse a PR URL and extract owner, repo, and PR number
1747
- * 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.
1748
1931
  */
1749
1932
  router.post('/api/parse-pr-url', (req, res) => {
1750
- const { PRArgumentParser } = require('../github/parser');
1751
- const parser = new PRArgumentParser();
1933
+ const config = req.app.get('config') || null;
1934
+ const parser = new PRArgumentParser(config);
1752
1935
 
1753
1936
  const { url } = req.body;
1754
1937
 
@@ -1921,6 +2104,24 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
1921
2104
  const appConfig = req.app.get('config') || {};
1922
2105
  const globalInstructions = appConfig.globalInstructions || null;
1923
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
+
1924
2125
  const { provider, model, repoInstructions, combinedInstructions, repoSettings: fetchedRepoSettings } = await withTransaction(db, async () => {
1925
2126
  const repoSettingsRepo = new RepoSettingsRepository(db);
1926
2127
  const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
@@ -2076,7 +2277,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
2076
2277
 
2077
2278
  const progressCallback = createProgressCallback(analysisId);
2078
2279
 
2079
- 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 });
2080
2281
  } catch (setupError) {
2081
2282
  // Synchronous setup failure — clean up the analysis hold immediately
2082
2283
  reviewToAnalysisId.delete(review.id);
@@ -2322,6 +2523,19 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
2322
2523
  const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
2323
2524
  buildCouncilProviderOverrides(prCouncilConfig, repository, repoSettings);
2324
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
+
2325
2539
  const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
2326
2540
  db,
2327
2541
  {
@@ -2336,6 +2550,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
2336
2550
  config: prCouncilConfig,
2337
2551
  excludePrevious,
2338
2552
  serverPort: req.socket.localPort,
2553
+ githubClient: councilGithubClient,
2339
2554
  poolLifecycle: req.app.get('poolLifecycle'),
2340
2555
  providerOverrides: councilProviderOverrides,
2341
2556
  providerOverridesMap: councilProviderOverridesMap,
@@ -2427,13 +2642,14 @@ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
2427
2642
  deletions: f.deletions ?? 0
2428
2643
  }));
2429
2644
 
2430
- // 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.
2431
2648
  let sharedBy = null;
2432
2649
  try {
2433
- const config = req.app.get('config') || {};
2434
- const githubToken = getGitHubToken(config);
2435
- if (githubToken) {
2436
- const githubClient = new GitHubClient(githubToken);
2650
+ const resolved = resolveBindingForRequest(req, repository);
2651
+ if (resolved) {
2652
+ const githubClient = new GitHubClient(resolved.binding);
2437
2653
  const user = await githubClient.getAuthenticatedUser();
2438
2654
  sharedBy = user.login;
2439
2655
  }
@@ -2563,8 +2779,15 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
2563
2779
  const db = req.app.get('db');
2564
2780
  const config = req.app.get('config') || {};
2565
2781
 
2566
- const githubToken = getGitHubToken(config) || req.app.get('githubToken');
2567
- 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);
2568
2791
  return res.json({ stack: [] });
2569
2792
  }
2570
2793
 
@@ -2576,7 +2799,7 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
2576
2799
  const parsedPrData = prMetadataRow?.pr_data ? JSON.parse(prMetadataRow.pr_data) : {};
2577
2800
  const defaultBranch = parsedPrData.repository?.default_branch;
2578
2801
 
2579
- const ghClient = new GitHubClient(githubToken);
2802
+ const ghClient = new GitHubClient(binding);
2580
2803
  let stack;
2581
2804
  try {
2582
2805
  stack = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
@@ -2636,3 +2859,10 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
2636
2859
  });
2637
2860
 
2638
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
+ };
@@ -15,7 +15,7 @@ const crypto = require('crypto');
15
15
  const { activeSetups, broadcastSetupProgress } = require('./shared');
16
16
  const { setupPRReview } = require('../setup/pr-setup');
17
17
  const { setupLocalReview } = require('../setup/local-setup');
18
- const { getGitHubToken, expandPath } = require('../config');
18
+ const { getGitHubToken, expandPath, resolveBindingRepositoryFromPR } = require('../config');
19
19
  const { queryOne, ReviewRepository } = require('../database');
20
20
  const { normalizeRepository } = require('../utils/paths');
21
21
  const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
@@ -63,8 +63,12 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
63
63
  const db = req.app.get('db');
64
64
  const config = req.app.get('config');
65
65
 
66
- // GitHub token is required for PR setup
67
- const githubToken = getGitHubToken(config);
66
+ // GitHub token is required for PR setup. Resolve the binding key
67
+ // first so monorepo-style `repos[...]` entries (matched via
68
+ // `url_pattern` named captures) supply their per-repo token even when
69
+ // the captured owner/repo differs from the config key.
70
+ const repositoryForToken = resolveBindingRepositoryFromPR(owner, repo, config);
71
+ const githubToken = getGitHubToken(config, repositoryForToken);
68
72
  if (!githubToken) {
69
73
  return res.status(401).json({ error: 'GitHub token not configured' });
70
74
  }
@@ -143,6 +147,7 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
143
147
  repo,
144
148
  prNumber,
145
149
  githubToken,
150
+ bindingRepository: repositoryForToken,
146
151
  config,
147
152
  poolLifecycle: req.app.get('poolLifecycle'),
148
153
  restoreMetadata,