@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
@@ -8,11 +8,12 @@
8
8
  */
9
9
 
10
10
  const express = require('express');
11
- const { query, queryOne, run, withTransaction, CommentRepository, ReviewRepository, AnalysisRunRepository } = require('../database');
11
+ const { query, queryOne, run, withTransaction, CommentRepository, ReviewRepository, AnalysisRunRepository, HunkSummaryRepository, TourRepository } = require('../database');
12
12
  const { calculateStats, getStatsQuery } = require('../utils/stats-calculator');
13
13
  const { activeAnalyses, reviewToAnalysisId } = require('./shared');
14
14
  const logger = require('../utils/logger');
15
15
  const { broadcastReviewEvent } = require('../events/review-events');
16
+ const { backgroundQueue } = require('../ai/background-queue');
16
17
  const { ensureContextFileForComment } = require('../utils/auto-context');
17
18
  const path = require('path');
18
19
  const fs = require('fs').promises;
@@ -22,37 +23,10 @@ const { normalizeRepository } = require('../utils/paths');
22
23
  const { resolveFormat, formatAdoptedComment: formatComment } = require('../utils/comment-formatter');
23
24
  const { safeParseJson } = require('../utils/safe-parse-json');
24
25
  const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
26
+ const validateReviewId = require('./middleware/validate-review-id');
25
27
 
26
28
  const router = express.Router();
27
29
 
28
- /**
29
- * Middleware: validate that :reviewId exists in the reviews table.
30
- * Attaches the review record to req.review for downstream handlers.
31
- */
32
- async function validateReviewId(req, res, next) {
33
- try {
34
- const reviewId = parseInt(req.params.reviewId, 10);
35
-
36
- if (isNaN(reviewId) || reviewId <= 0) {
37
- return res.status(400).json({ error: 'Invalid review ID' });
38
- }
39
-
40
- const db = req.app.get('db');
41
- const reviewRepo = new ReviewRepository(db);
42
- const review = await reviewRepo.getReview(reviewId);
43
-
44
- if (!review) {
45
- return res.status(404).json({ error: `Review #${reviewId} not found` });
46
- }
47
-
48
- req.review = review;
49
- req.reviewId = reviewId;
50
- next();
51
- } catch (error) {
52
- next(error);
53
- }
54
- }
55
-
56
30
  /**
57
31
  * GET /api/reviews/:reviewId/comments
58
32
  * Get all comments for a review.
@@ -1070,4 +1044,146 @@ router.get('/api/reviews/:reviewId/file-content/:fileName(*)', validateReviewId,
1070
1044
  }
1071
1045
  });
1072
1046
 
1047
+ // ==========================================================================
1048
+ // Hunk Summaries Route
1049
+ // ==========================================================================
1050
+
1051
+ /**
1052
+ * GET /api/reviews/:reviewId/hunk-summaries
1053
+ * Get all hunk summaries for a review (PR or Local).
1054
+ * Returns trivial-marker rows alongside generated summaries; the frontend filters.
1055
+ */
1056
+ router.get('/api/reviews/:reviewId/hunk-summaries', validateReviewId, async (req, res) => {
1057
+ try {
1058
+ const db = req.app.get('db');
1059
+ const repo = new HunkSummaryRepository(db);
1060
+ const rows = await repo.getByReview(req.reviewId);
1061
+ // `generating` reflects whether the background queue is still working
1062
+ // on this review's summaries; the frontend uses it to show a "generating"
1063
+ // pulse on the toolbar toggle until `review:background_job_finished`
1064
+ // fires for jobType=`summaries:*`.
1065
+ const generating = backgroundQueue.hasActiveForReview(req.reviewId, 'summaries');
1066
+ res.json({
1067
+ summaries: rows.map((row) => ({
1068
+ file_path: row.file_path,
1069
+ content_hash: row.content_hash,
1070
+ summary_text: row.summary_text,
1071
+ trivial_reason: row.trivial_reason
1072
+ })),
1073
+ generating
1074
+ });
1075
+ } catch (error) {
1076
+ logger.error('Error fetching hunk summaries:', error);
1077
+ res.status(500).json({ error: 'Failed to fetch hunk summaries' });
1078
+ }
1079
+ });
1080
+
1081
+ // ==========================================================================
1082
+ // Tour Route
1083
+ // ==========================================================================
1084
+
1085
+ /**
1086
+ * GET /api/reviews/:reviewId/tour
1087
+ * Get the persisted guided tour for a review (PR or Local).
1088
+ * Returns `{tour: null}` when no tour has been generated yet, otherwise
1089
+ * returns `{tour: {stops, diff_hash, stale, generating, provider, model, created_at}}`.
1090
+ *
1091
+ * `stale` is true when a `tour` job is currently in flight for this review,
1092
+ * meaning the persisted tour may be about to be replaced. `generating` is
1093
+ * true when there is no persisted tour yet but a job is in flight.
1094
+ */
1095
+ router.get('/api/reviews/:reviewId/tour', validateReviewId, async (req, res) => {
1096
+ try {
1097
+ const db = req.app.get('db');
1098
+ const repo = new TourRepository(db);
1099
+ const row = await repo.get(req.reviewId);
1100
+ const generating = backgroundQueue.hasActiveForReview(req.reviewId, 'tour');
1101
+
1102
+ if (!row) {
1103
+ return res.json({ tour: null, generating });
1104
+ }
1105
+
1106
+ let stops;
1107
+ try {
1108
+ stops = JSON.parse(row.stops);
1109
+ } catch (err) {
1110
+ logger.warn(`Failed to parse tour.stops for review ${req.reviewId}: ${err.message}`);
1111
+ return res.status(500).json({ error: 'Tour data corrupt' });
1112
+ }
1113
+
1114
+ res.json({
1115
+ tour: {
1116
+ stops,
1117
+ diff_hash: row.diff_hash,
1118
+ stale: generating,
1119
+ provider: row.provider,
1120
+ model: row.model,
1121
+ created_at: row.created_at
1122
+ },
1123
+ generating
1124
+ });
1125
+ } catch (error) {
1126
+ logger.error('Error fetching tour:', error);
1127
+ res.status(500).json({ error: 'Failed to fetch tour' });
1128
+ }
1129
+ });
1130
+
1131
+ // ==========================================================================
1132
+ // Background Job Cancellation
1133
+ // ==========================================================================
1134
+
1135
+ // Only these prefixes are user-cancellable. We deliberately do NOT accept
1136
+ // arbitrary jobKeys — that would let the UI cancel internal jobs we don't
1137
+ // want to be cancellable from the toolbar (e.g. future scheduling work).
1138
+ const CANCELLABLE_JOB_PREFIXES = new Set(['tour', 'summaries']);
1139
+
1140
+ /**
1141
+ * POST /api/reviews/:reviewId/jobs/:jobKey/cancel
1142
+ *
1143
+ * Cancel an in-flight background job (tour or summaries) for this review.
1144
+ * Aborts the per-job `AbortSignal`, which kills the upstream CLI child
1145
+ * process so we stop burning tokens immediately.
1146
+ *
1147
+ * Works for BOTH Local mode (`/local/:reviewId`) and PR mode (`/pr/...`)
1148
+ * because both modes write into the same `reviews` table and dispatch
1149
+ * jobs through the same `backgroundQueue` keyed by reviewId. The
1150
+ * separate `/api/local/...` cancel route below shares this handler so
1151
+ * the contract stays in one place.
1152
+ *
1153
+ * Request:
1154
+ * - `jobKey` path param: bare prefix (`tour` | `summaries`) or full
1155
+ * job key suffix (`summaries:<digest>`). Bare prefix cancels ALL
1156
+ * matching variants — what the toolbar actually wants.
1157
+ *
1158
+ * Responses:
1159
+ * - 200 `{ cancelled: true, count: N }` - aborted N job(s)
1160
+ * - 404 `{ cancelled: false }` - nothing in flight
1161
+ * - 400 - invalid jobKey
1162
+ */
1163
+ async function handleJobCancel(req, res) {
1164
+ const rawKey = String(req.params.jobKey || '').trim();
1165
+ // Strip whitespace; reject if empty, contains slashes/control chars, or
1166
+ // does not start with an allow-listed prefix. We deliberately do NOT
1167
+ // accept arbitrary keys — see CANCELLABLE_JOB_PREFIXES comment.
1168
+ if (!rawKey || /[/\\\s]/.test(rawKey)) {
1169
+ return res.status(400).json({ error: 'Invalid jobKey' });
1170
+ }
1171
+ const prefix = rawKey.includes(':') ? rawKey.slice(0, rawKey.indexOf(':')) : rawKey;
1172
+ if (!CANCELLABLE_JOB_PREFIXES.has(prefix)) {
1173
+ return res.status(400).json({ error: `jobKey "${prefix}" is not cancellable` });
1174
+ }
1175
+
1176
+ const { cancelled } = backgroundQueue.cancel(req.reviewId, rawKey);
1177
+ if (cancelled === 0) {
1178
+ logger.info(`Cancel request for ${req.reviewId}:${rawKey} matched no in-flight job`);
1179
+ return res.status(404).json({ cancelled: false });
1180
+ }
1181
+ logger.info(`Cancelled ${cancelled} background job(s) for ${req.reviewId}:${rawKey}`);
1182
+ res.json({ cancelled: true, count: cancelled });
1183
+ }
1184
+
1185
+ router.post('/api/reviews/:reviewId/jobs/:jobKey/cancel', validateReviewId, handleJobCancel);
1186
+
1073
1187
  module.exports = router;
1188
+ module.exports.handleJobCancel = handleJobCancel;
1189
+ module.exports.CANCELLABLE_JOB_PREFIXES = CANCELLABLE_JOB_PREFIXES;
@@ -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,
@@ -19,7 +19,7 @@ const { normalizeRepository } = require('../utils/paths');
19
19
  const { mergeInstructions } = require('../utils/instructions');
20
20
  const { GitWorktreeManager } = require('../git/worktree');
21
21
  const { GitHubClient } = require('../github/client');
22
- const { getGitHubToken, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
22
+ const { getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
23
23
  const { setupStackPR } = require('../setup/stack-setup');
24
24
  const Analyzer = require('../ai/analyzer');
25
25
  const { getProviderClass, createProvider } = require('../ai/provider');
@@ -164,6 +164,8 @@ const defaults = {
164
164
  GitWorktreeManager,
165
165
  GitHubClient,
166
166
  getGitHubToken,
167
+ resolveHostBinding,
168
+ resolveBindingRepositoryFromPR,
167
169
  setupStackPR,
168
170
  Analyzer,
169
171
  getProviderClass,
@@ -229,10 +231,17 @@ async function executeStackAnalysis(params) {
229
231
  }
230
232
 
231
233
  // 3. Fetch all PR data from GitHub in parallel
232
- const githubToken = deps.getGitHubToken(config);
234
+ // Use the per-repo binding so alt-host stack analyses target the right host.
235
+ // The PR identity (`${owner}/${repo}`) is used for DB rows and worktree
236
+ // identity. For host-binding lookups we MUST use the config-binding key,
237
+ // which differs from the PR identity for monorepo-style `url_pattern`
238
+ // configs (one `repos[...]` entry serves many captured owner/repo pairs).
239
+ const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, config);
240
+ const stackBinding = deps.resolveHostBinding(bindingRepository, config);
241
+ const githubToken = stackBinding.token;
233
242
  const prDataMap = new Map();
234
243
  if (githubToken) {
235
- const githubClient = new deps.GitHubClient(githubToken);
244
+ const githubClient = new deps.GitHubClient(stackBinding);
236
245
  const fetchResults = await Promise.allSettled(
237
246
  prNumbers.map(async (prNum) => {
238
247
  const prData = await githubClient.fetchPullRequest(owner, repo, prNum);
@@ -294,10 +303,10 @@ async function executeStackAnalysis(params) {
294
303
  };
295
304
 
296
305
  return analyzeStackPR(deps, db, config, {
297
- owner, repo, repository, prNum,
306
+ owner, repo, repository, bindingRepository, prNum,
298
307
  worktreePath: worktreePathMap.get(prNum),
299
308
  analysisConfig, stackAnalysisId, state,
300
- githubToken, prData: prDataMap.get(prNum),
309
+ githubToken, binding: stackBinding, prData: prDataMap.get(prNum),
301
310
  onAnalysisIdReady
302
311
  }).then(result => {
303
312
  state.prStatuses.set(prNum, {
@@ -335,15 +344,25 @@ async function executeStackAnalysis(params) {
335
344
  * Called in parallel for all PRs.
336
345
  */
337
346
  async function analyzeStackPR(deps, db, config, {
338
- owner, repo, repository, prNum, worktreePath,
339
- analysisConfig, stackAnalysisId, state, githubToken, prData,
347
+ owner, repo, repository, bindingRepository, prNum, worktreePath,
348
+ analysisConfig, stackAnalysisId, state, githubToken, binding, prData,
340
349
  onAnalysisIdReady
341
350
  }) {
351
+ // Build a GitHubClient for analyzer-side dedup pre-fetch. The stack
352
+ // analysis flow is PR mode only — local mode does not enter this path.
353
+ // Prefer the host-aware binding (alt-host capable) and fall back to the
354
+ // bare token for legacy callers.
355
+ const clientArg = binding || githubToken;
356
+ const stackGithubClient = clientArg ? new deps.GitHubClient(clientArg) : undefined;
357
+ if (stackGithubClient) {
358
+ logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNum} (stack)`);
359
+ }
342
360
  // 1. Setup PR (generates diff, stores metadata)
343
361
  const worktreeManager = new deps.GitWorktreeManager(db);
344
362
  await deps.setupStackPR({
345
363
  db, owner, repo, prNumber: prNum,
346
- githubToken, worktreePath, worktreeManager, prData
364
+ githubToken, binding, bindingRepository,
365
+ worktreePath, worktreeManager, prData
347
366
  });
348
367
 
349
368
  // 2. Fetch prMetadata from DB
@@ -381,6 +400,7 @@ async function analyzeStackPR(deps, db, config, {
381
400
  reviewId, worktreePath, prMetadata, prNum, owner, repo, repository,
382
401
  globalInstructions, repoInstructions, requestInstructions,
383
402
  councilId, rawCouncilConfig, configType, onAnalysisIdReady,
403
+ githubClient: stackGithubClient,
384
404
  providerOverrides: councilProviderOverrides,
385
405
  providerOverridesMap: councilProviderOverridesMap
386
406
  });
@@ -408,6 +428,7 @@ async function analyzeStackPR(deps, db, config, {
408
428
  selectedProvider, selectedModel,
409
429
  globalInstructions, repoInstructions, requestInstructions,
410
430
  reqTier, reqEnabledLevels, onAnalysisIdReady,
431
+ githubClient: stackGithubClient,
411
432
  providerOverrides
412
433
  });
413
434
  }
@@ -428,6 +449,7 @@ async function launchStackSingleAnalysis(deps, db, config, {
428
449
  selectedProvider, selectedModel,
429
450
  globalInstructions, repoInstructions, requestInstructions,
430
451
  reqTier, reqEnabledLevels, onAnalysisIdReady,
452
+ githubClient,
431
453
  providerOverrides = {}
432
454
  }) {
433
455
  const runId = uuidv4();
@@ -485,7 +507,7 @@ async function launchStackSingleAnalysis(deps, db, config, {
485
507
  reviewId, worktreePath, prMetadata, progressCallback,
486
508
  { globalInstructions, repoInstructions, requestInstructions },
487
509
  null,
488
- { analysisId, runId, skipRunCreation: true, tier, enabledLevels: levelsConfig }
510
+ { analysisId, runId, skipRunCreation: true, tier, enabledLevels: levelsConfig, githubClient }
489
511
  );
490
512
 
491
513
  const completionInfo = determineCompletionInfo(result);
@@ -552,6 +574,7 @@ async function launchStackCouncilAnalysis(deps, db, config, {
552
574
  reviewId, worktreePath, prMetadata, prNum, owner, repo, repository,
553
575
  globalInstructions, repoInstructions, requestInstructions,
554
576
  councilId, rawCouncilConfig, configType, onAnalysisIdReady,
577
+ githubClient,
555
578
  providerOverrides = {},
556
579
  providerOverridesMap = null
557
580
  }) {
@@ -595,6 +618,7 @@ async function launchStackCouncilAnalysis(deps, db, config, {
595
618
  logLabel: `Stack PR #${prNum}`,
596
619
  initialStatusExtra: { prNumber: prNum, reviewType: 'pr' },
597
620
  config,
621
+ githubClient,
598
622
  providerOverrides,
599
623
  providerOverridesMap,
600
624
  hookContext: {
@@ -47,9 +47,10 @@ router.post('/api/worktrees/create', async (req, res) => {
47
47
  const db = req.app.get('db');
48
48
  const config = req.app.get('config');
49
49
 
50
- // Validate GitHub token
50
+ // Validate GitHub token. Pass the owner/repo so alt-host repos
51
+ // resolve their own per-repo token.
51
52
  const { getGitHubToken } = require('../config');
52
- const githubToken = getGitHubToken(config);
53
+ const githubToken = getGitHubToken(config, `${owner}/${repo}`);
53
54
  if (!githubToken) {
54
55
  return res.status(500).json({
55
56
  success: false,
package/src/server.js CHANGED
@@ -348,6 +348,7 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
348
348
  const chatRoutes = require('./routes/chat');
349
349
  const contextFilesRoutes = require('./routes/context-files');
350
350
  const githubCollectionsRoutes = require('./routes/github-collections');
351
+ const bulkAnalysisConfigsRoutes = require('./routes/bulk-analysis-configs');
351
352
  const stackAnalysisRoutes = require('./routes/stack-analysis');
352
353
  const externalCommentsRoutes = require('./routes/external-comments');
353
354
  const { createSoundRouter } = require('./routes/sound');
@@ -369,6 +370,7 @@ async function startServer(sharedDb = null, sharedPoolLifecycle = null) {
369
370
  app.use('/', setupRoutes);
370
371
  app.use('/', mcpRoutes);
371
372
  app.use('/', githubCollectionsRoutes);
373
+ app.use('/', bulkAnalysisConfigsRoutes);
372
374
  app.use('/', stackAnalysisRoutes);
373
375
  // External-comments routes (GitHub PR review-comment sync + fetch) are
374
376
  // gated by the `external_comments` config flag. When disabled, the
@@ -17,7 +17,7 @@ const { WorktreePoolLifecycle } = require('../git/worktree-pool-lifecycle');
17
17
  const { GitHubClient } = require('../github/client');
18
18
  const { normalizeRepository } = require('../utils/paths');
19
19
  const { findMainGitRoot } = require('../local-review');
20
- const { getConfigDir, getRepoPath, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
20
+ const { getConfigDir, getRepoPath, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveHostBinding, resolveBindingRepositoryFromPR, DEFAULT_CHECKOUT_TIMEOUT_MS } = require('../config');
21
21
  const logger = require('../utils/logger');
22
22
  const { fireReviewStartedHook } = require('../hooks/payloads');
23
23
  const simpleGit = require('simple-git');
@@ -213,9 +213,11 @@ async function registerRepositoryLocation(db, currentDir, owner, repo) {
213
213
  * @param {Object} params.db - Database instance
214
214
  * @param {string} params.owner - Repository owner
215
215
  * @param {string} params.repo - Repository name
216
- * @param {string} params.repository - Normalized "owner/repo" string
216
+ * @param {string} params.repository - Normalized "owner/repo" PR identity (used for DB lookups: worktrees, repo_settings)
217
+ * @param {string} [params.bindingRepository] - `repos[...]` config-lookup key; defaults to `repository`. Differs for monorepo url_pattern configs.
217
218
  * @param {number} params.prNumber - PR number (used for worktree lookup)
218
219
  * @param {Object} [params.config] - Application config (used for monorepo path lookup)
220
+ * @param {string} [params.cloneUrl] - Alt-host clone URL from `prData.repository.clone_url`; falls back to github.com when omitted.
219
221
  * @param {Function} [params.onProgress] - Optional progress callback
220
222
  * @returns {Promise<{ repositoryPath: string, knownPath: string|null, worktreeSourcePath: string|null, checkoutScript: string|null, checkoutTimeout: number, worktreeConfig: Object|null }>}
221
223
  * - repositoryPath: the main git root (bare repo or .git parent)
@@ -225,7 +227,10 @@ async function registerRepositoryLocation(db, currentDir, owner, repo) {
225
227
  * - checkoutTimeout: timeout in ms for checkout script (default: 300000 = 5 minutes)
226
228
  * - worktreeConfig: { worktreeBaseDir, nameTemplate } if configured, null otherwise
227
229
  */
228
- async function findRepositoryPath({ db, owner, repo, repository, prNumber, config, onProgress }) {
230
+ async function findRepositoryPath({ db, owner, repo, repository, bindingRepository, prNumber, config, cloneUrl, onProgress }) {
231
+ // `repository` is the PR identity (DB key). `bindingRepository` is the
232
+ // `repos[...]` config-lookup key — they differ for monorepo url_pattern configs.
233
+ const configKey = bindingRepository || repository;
229
234
  const worktreeManager = new GitWorktreeManager(db);
230
235
  const repoSettingsRepo = new RepoSettingsRepository(db);
231
236
  const worktreeRepo = new WorktreeRepository(db);
@@ -237,7 +242,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
237
242
  // ------------------------------------------------------------------
238
243
  // Tier -1: Explicit monorepo configuration (highest priority)
239
244
  // ------------------------------------------------------------------
240
- const monorepoPath = config ? getRepoPath(config, repository) : null;
245
+ const monorepoPath = config ? getRepoPath(config, configKey) : null;
241
246
 
242
247
  if (monorepoPath) {
243
248
  // The configured path might be a worktree or a regular/bare repo.
@@ -289,7 +294,7 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
289
294
  // ------------------------------------------------------------------
290
295
  // Resolve monorepo worktree options (checkout_script, worktree_directory, worktree_name_template)
291
296
  // ------------------------------------------------------------------
292
- const resolved = config ? resolveRepoOptions(config, repository, repoSettings) : { checkoutScript: null, checkoutTimeout: DEFAULT_CHECKOUT_TIMEOUT_MS, worktreeConfig: null };
297
+ const resolved = config ? resolveRepoOptions(config, configKey, repoSettings) : { checkoutScript: null, checkoutTimeout: DEFAULT_CHECKOUT_TIMEOUT_MS, worktreeConfig: null };
293
298
  const { checkoutScript, checkoutTimeout, worktreeConfig } = resolved;
294
299
 
295
300
  // When a checkout script is configured, null out worktreeSourcePath —
@@ -357,8 +362,10 @@ async function findRepositoryPath({ db, owner, repo, repository, prNumber, confi
357
362
  await fs.mkdir(path.dirname(cachedRepoPath), { recursive: true });
358
363
 
359
364
  const git = simpleGit();
360
- const cloneUrl = `https://github.com/${owner}/${repo}.git`;
361
- await git.clone(cloneUrl, cachedRepoPath, ['--filter=blob:none', '--no-checkout']);
365
+ // Honor alt-host clone URL when callers thread it through; older
366
+ // restore snapshots / pre-alt-host callers fall back to github.com.
367
+ const resolvedCloneUrl = cloneUrl || `https://github.com/${owner}/${repo}.git`;
368
+ await git.clone(resolvedCloneUrl, cachedRepoPath, ['--filter=blob:none', '--no-checkout']);
362
369
  repositoryPath = cachedRepoPath;
363
370
  if (onProgress) {
364
371
  onProgress({ step: 'repo', status: 'running', message: `Repository cloned to ${cachedRepoPath}` });
@@ -408,16 +415,31 @@ function isShaNotFoundError(err) {
408
415
  * @param {string} params.repo - Repository name
409
416
  * @param {number} params.prNumber - Pull request number
410
417
  * @param {string} params.githubToken - GitHub PAT
418
+ * @param {string} [params.bindingRepository] - `repos[...]` config-lookup key; resolved internally when omitted
411
419
  * @param {Object} [params.config] - Application config (for monorepo path lookup)
412
420
  * @param {import('../git/worktree-pool-lifecycle').WorktreePoolLifecycle} [params.poolLifecycle] - Shared pool lifecycle instance (avoids creating a fresh singleton)
413
421
  * @param {Object} [params.restoreMetadata] - Stored PR data for restore mode (skips GitHub fetch + diff)
414
422
  * @param {Function} [params.onProgress] - Optional progress callback
415
423
  * @returns {Promise<{ reviewUrl: string, title: string }>}
416
424
  */
417
- async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, onProgress, poolLifecycle: externalPoolLifecycle, restoreMetadata }) {
425
+ async function setupPRReview({ db, owner, repo, prNumber, githubToken, bindingRepository: externalBindingRepository, config, onProgress, poolLifecycle: externalPoolLifecycle, restoreMetadata }) {
418
426
  const repository = normalizeRepository(owner, repo);
419
427
  const progress = onProgress || (() => {});
420
428
 
429
+ // Resolve the per-repo host binding so alt-host setups talk to the
430
+ // configured `api_host`. Use `resolveBindingRepositoryFromPR` so
431
+ // monorepo-style configs (one `repos[...]` entry serving many
432
+ // captured owner/repo) find the right binding. Fall back to the bare
433
+ // token shape if no config is available (legacy invocation path) or
434
+ // the binding resolved no token (callers in this case already
435
+ // pre-resolved it).
436
+ const bindingRepository = externalBindingRepository
437
+ || (config ? resolveBindingRepositoryFromPR(owner, repo, config) : repository);
438
+ const setupBinding = config ? resolveHostBinding(bindingRepository, config) : null;
439
+ const clientArg = (setupBinding && setupBinding.token)
440
+ ? setupBinding
441
+ : githubToken;
442
+
421
443
  const isRestore = !!(restoreMetadata && restoreMetadata.head_sha);
422
444
  let prData;
423
445
  let githubClient = null;
@@ -431,7 +453,7 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, o
431
453
  // Step: verify - Verify repository access
432
454
  // ------------------------------------------------------------------
433
455
  progress({ step: 'verify', status: 'running', message: 'Verifying repository access...' });
434
- githubClient = new GitHubClient(githubToken);
456
+ githubClient = new GitHubClient(clientArg);
435
457
  const repoExists = await githubClient.repositoryExists(owner, repo);
436
458
  if (!repoExists) {
437
459
  throw new Error(`Repository ${owner}/${repo} not found`);
@@ -455,8 +477,10 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, o
455
477
  owner,
456
478
  repo,
457
479
  repository,
480
+ bindingRepository,
458
481
  prNumber,
459
482
  config,
483
+ cloneUrl: prData?.repository?.clone_url,
460
484
  onProgress: progress
461
485
  });
462
486
  progress({ step: 'repo', status: 'completed', message: `Repository located at ${repositoryPath}` });
@@ -467,8 +491,10 @@ async function setupPRReview({ db, owner, repo, prNumber, githubToken, config, o
467
491
  const prInfo = { owner, repo, number: prNumber };
468
492
  const repoSettingsRepo = new RepoSettingsRepository(db);
469
493
  const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
470
- const { poolSize } = resolvePoolConfig(config || {}, repository, repoSettings);
471
- const resetScript = config ? getRepoResetScript(config, repository) : null;
494
+ // Pool/reset settings live under the config-lookup key (`bindingRepository`),
495
+ // not the PR identity, so monorepo `repos[...]` entries are honored.
496
+ const { poolSize } = resolvePoolConfig(config || {}, bindingRepository, repoSettings);
497
+ const resetScript = config ? getRepoResetScript(config, bindingRepository) : null;
472
498
 
473
499
  let worktreePath;
474
500
  let worktreeManager;
@@ -23,17 +23,27 @@ const logger = require('../utils/logger');
23
23
  * @param {string} params.owner - Repository owner
24
24
  * @param {string} params.repo - Repository name
25
25
  * @param {number} params.prNumber - Pull request number
26
- * @param {string} params.githubToken - GitHub personal access token
26
+ * @param {string} [params.githubToken] - GitHub personal access token (legacy). Prefer `binding`.
27
+ * @param {Object} [params.binding] - Resolved host binding (`resolveHostBinding(bindingRepository, config)`).
28
+ * Required for alt-host repos so the right API host is used.
29
+ * @param {string} [params.bindingRepository] - `repos[...]` config-lookup key for this PR. Differs from
30
+ * `${owner}/${repo}` for monorepo `url_pattern` configs. Surfaced for downstream
31
+ * per-repo lookups (worktree pool, reset script) that key off the config entry.
27
32
  * @param {string} params.worktreePath - Path to the per-PR worktree
28
33
  * @param {import('../git/worktree').GitWorktreeManager} params.worktreeManager - Worktree manager instance
29
34
  * @param {Object} [params.prData] - Pre-fetched PR data from GitHub (skips API call when provided)
30
35
  * @returns {Promise<{ reviewId: number, prMetadata: Object, prData: Object, isNew: boolean }>}
31
36
  */
32
- async function setupStackPR({ db, owner, repo, prNumber, githubToken, worktreePath, worktreeManager, prData: prefetchedPRData }) {
37
+ async function setupStackPR({ db, owner, repo, prNumber, githubToken, binding, bindingRepository, worktreePath, worktreeManager, prData: prefetchedPRData }) {
38
+ // `bindingRepository` is accepted so callers (e.g. `executeStackAnalysis`)
39
+ // can thread the resolved config-binding key through to any downstream
40
+ // per-repo lookups added in this function. Currently unused inside this
41
+ // function — `storePRData` keys off the PR identity.
42
+ void bindingRepository;
33
43
  logger.info(`Setting up stack PR #${prNumber} for ${owner}/${repo}`);
34
44
 
35
45
  // 1. Fetch PR data from GitHub (or use pre-fetched data)
36
- const githubClient = new GitHubClient(githubToken);
46
+ const githubClient = new GitHubClient(binding || githubToken);
37
47
  let prData;
38
48
  if (prefetchedPRData) {
39
49
  prData = prefetchedPRData;
@@ -116,12 +116,15 @@ function buildDelegationUrl(port, mode, context = {}) {
116
116
  * Parse PR arguments for URL construction without starting a server.
117
117
  * Reuses PRArgumentParser — synchronous for URLs, async for bare numbers.
118
118
  * @param {string[]} prArgs - Raw CLI PR arguments
119
+ * @param {object} [config] - Pair-review config, passed to the parser so
120
+ * that per-repo `url_pattern` regexes are tried before the built-in
121
+ * GitHub/Graphite parsers. Pass null to disable config-driven matching.
119
122
  * @param {object} [_deps] - Dependency overrides for testing
120
123
  * @returns {Promise<{owner: string, repo: string, number: number}>}
121
124
  */
122
- async function parsePRArgsForDelegation(prArgs, _deps) {
125
+ async function parsePRArgsForDelegation(prArgs, config = null, _deps) {
123
126
  const deps = { ...defaults, ..._deps };
124
- const parser = new deps.PRArgumentParser();
127
+ const parser = new deps.PRArgumentParser(config);
125
128
  return parser.parsePRArguments(prArgs);
126
129
  }
127
130
 
@@ -163,7 +166,7 @@ async function attemptDelegation(config, flags, prArgs, _deps) {
163
166
  const targetPath = path.resolve(flags.localPath || process.cwd());
164
167
  url = buildDelegationUrl(port, 'local', { localPath: targetPath, analyze: flags.ai });
165
168
  } else if (prArgs.length > 0) {
166
- const prInfo = await parsePRArgsForDelegation(prArgs, _deps);
169
+ const prInfo = await parsePRArgsForDelegation(prArgs, config, _deps);
167
170
  url = buildDelegationUrl(port, 'pr', { ...prInfo, analyze: flags.ai });
168
171
  } else {
169
172
  url = buildDelegationUrl(port, 'server');
@@ -0,0 +1,65 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const { parseUnifiedDiffPatches } = require('./diff-file-list');
4
+
5
+ /**
6
+ * @typedef {Object} Hunk
7
+ * @property {string} header - Hunk header line, e.g. "@@ -10,5 +10,7 @@".
8
+ * @property {string[]} lines - Diff lines including their leading marker
9
+ * ('+', '-', ' ', or the literal '\' marker).
10
+ */
11
+
12
+ /**
13
+ * Split a single file's patch text into per-hunk structures.
14
+ * @param {string} filePatch - Patch text for one file (with or without diff header).
15
+ * @returns {Hunk[]} Array of hunks; empty when the patch contains no `@@` lines.
16
+ */
17
+ function parseHunks(filePatch) {
18
+ if (!filePatch) return [];
19
+
20
+ const lines = filePatch.split('\n');
21
+ const hunks = [];
22
+ let current = null;
23
+
24
+ for (const line of lines) {
25
+ if (line.startsWith('@@')) {
26
+ if (current) hunks.push(current);
27
+ current = { header: line, lines: [] };
28
+ continue;
29
+ }
30
+ if (current) {
31
+ current.lines.push(line);
32
+ }
33
+ }
34
+
35
+ if (current) hunks.push(current);
36
+
37
+ for (const hunk of hunks) {
38
+ while (hunk.lines.length > 0 && hunk.lines[hunk.lines.length - 1] === '') {
39
+ hunk.lines.pop();
40
+ }
41
+ }
42
+
43
+ return hunks;
44
+ }
45
+
46
+ /**
47
+ * Parse a full unified diff into a Map of file path -> hunks.
48
+ * @param {string} diffText - Full unified diff text spanning many files.
49
+ * @returns {Map<string, Hunk[]>} Map keyed by the new path (or old path for deletions).
50
+ */
51
+ function parseUnifiedDiffHunks(diffText) {
52
+ const result = new Map();
53
+ if (!diffText) return result;
54
+
55
+ const patches = parseUnifiedDiffPatches(diffText);
56
+ for (const [filePath, patch] of patches.entries()) {
57
+ const hunks = parseHunks(patch);
58
+ if (hunks.length === 0) continue;
59
+ result.set(filePath, hunks);
60
+ }
61
+
62
+ return result;
63
+ }
64
+
65
+ module.exports = { parseHunks, parseUnifiedDiffHunks };
@@ -15,10 +15,13 @@ const logger = require('./logger');
15
15
  *
16
16
  * @param {string} response - Raw response text (may include preamble/postamble prose)
17
17
  * @param {string|number} level - Level identifier for logging (e.g., 1, 2, 3, 'orchestration', 'unknown')
18
+ * @param {string} [logPrefix] - Custom log prefix to use instead of `[Level <level>]`.
19
+ * Used by callers (e.g., summary generation, council mode) that have a more
20
+ * meaningful identifier than a numeric analysis level.
18
21
  * @returns {Object} Extraction result with success flag and data/error
19
22
  */
20
- function extractJSON(response, level = 'unknown') {
21
- const levelPrefix = `[Level ${level}]`;
23
+ function extractJSON(response, level = 'unknown', logPrefix) {
24
+ const levelPrefix = logPrefix || `[Level ${level}]`;
22
25
 
23
26
  if (!response || !response.trim()) {
24
27
  return { success: false, error: 'Empty response' };