@in-the-loop-labs/pair-review 2.6.3 → 3.0.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 (150) hide show
  1. package/.pi/extensions/task/index.ts +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/LICENSE +201 -674
  4. package/README.md +2 -2
  5. package/bin/pair-review.js +1 -1
  6. package/package.json +2 -2
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin-code-critic/.claude-plugin/plugin.json +2 -2
  9. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
  10. package/public/css/ai-summary-modal.css +1 -1
  11. package/public/css/pr.css +194 -0
  12. package/public/index.html +168 -3
  13. package/public/js/components/AIPanel.js +17 -3
  14. package/public/js/components/AISummaryModal.js +1 -1
  15. package/public/js/components/AdvancedConfigTab.js +1 -1
  16. package/public/js/components/AnalysisConfigModal.js +1 -1
  17. package/public/js/components/ChatPanel.js +42 -7
  18. package/public/js/components/ConfirmDialog.js +22 -3
  19. package/public/js/components/CouncilProgressModal.js +14 -1
  20. package/public/js/components/DiffOptionsDropdown.js +411 -24
  21. package/public/js/components/EmojiPicker.js +1 -1
  22. package/public/js/components/KeyboardShortcuts.js +1 -1
  23. package/public/js/components/PanelGroup.js +1 -1
  24. package/public/js/components/PreviewModal.js +1 -1
  25. package/public/js/components/ReviewModal.js +1 -1
  26. package/public/js/components/SplitButton.js +1 -1
  27. package/public/js/components/StatusIndicator.js +1 -1
  28. package/public/js/components/SuggestionNavigator.js +13 -6
  29. package/public/js/components/TabTitle.js +96 -0
  30. package/public/js/components/TextInputDialog.js +1 -1
  31. package/public/js/components/TimeoutSelect.js +1 -1
  32. package/public/js/components/Toast.js +7 -1
  33. package/public/js/components/VoiceCentricConfigTab.js +1 -1
  34. package/public/js/index.js +649 -44
  35. package/public/js/local.js +570 -77
  36. package/public/js/modules/analysis-history.js +4 -3
  37. package/public/js/modules/comment-manager.js +6 -1
  38. package/public/js/modules/comment-minimizer.js +304 -0
  39. package/public/js/modules/diff-context.js +1 -1
  40. package/public/js/modules/diff-renderer.js +1 -1
  41. package/public/js/modules/file-comment-manager.js +1 -1
  42. package/public/js/modules/file-list-merger.js +1 -1
  43. package/public/js/modules/gap-coordinates.js +1 -1
  44. package/public/js/modules/hunk-parser.js +1 -1
  45. package/public/js/modules/line-tracker.js +1 -1
  46. package/public/js/modules/panel-resizer.js +1 -1
  47. package/public/js/modules/storage-cleanup.js +1 -1
  48. package/public/js/modules/suggestion-manager.js +1 -1
  49. package/public/js/pr.js +83 -7
  50. package/public/js/repo-settings.js +1 -1
  51. package/public/js/utils/category-emoji.js +1 -1
  52. package/public/js/utils/file-order.js +1 -1
  53. package/public/js/utils/markdown.js +1 -1
  54. package/public/js/utils/suggestion-ui.js +1 -1
  55. package/public/js/utils/tier-icons.js +1 -1
  56. package/public/js/utils/time.js +1 -1
  57. package/public/js/ws-client.js +1 -1
  58. package/public/local.html +14 -0
  59. package/public/pr.html +3 -0
  60. package/public/setup.html +1 -1
  61. package/src/ai/analyzer.js +18 -12
  62. package/src/ai/claude-cli.js +1 -1
  63. package/src/ai/claude-provider.js +1 -1
  64. package/src/ai/codex-provider.js +1 -1
  65. package/src/ai/copilot-provider.js +1 -1
  66. package/src/ai/cursor-agent-provider.js +1 -1
  67. package/src/ai/gemini-provider.js +1 -1
  68. package/src/ai/index.js +1 -1
  69. package/src/ai/opencode-provider.js +1 -1
  70. package/src/ai/pi-provider.js +1 -1
  71. package/src/ai/prompts/baseline/consolidation/balanced.js +1 -1
  72. package/src/ai/prompts/baseline/consolidation/fast.js +1 -1
  73. package/src/ai/prompts/baseline/consolidation/thorough.js +1 -1
  74. package/src/ai/prompts/baseline/level1/balanced.js +1 -1
  75. package/src/ai/prompts/baseline/level1/fast.js +1 -1
  76. package/src/ai/prompts/baseline/level1/thorough.js +1 -1
  77. package/src/ai/prompts/baseline/level2/balanced.js +1 -1
  78. package/src/ai/prompts/baseline/level2/fast.js +1 -1
  79. package/src/ai/prompts/baseline/level2/thorough.js +1 -1
  80. package/src/ai/prompts/baseline/level3/balanced.js +1 -1
  81. package/src/ai/prompts/baseline/level3/fast.js +1 -1
  82. package/src/ai/prompts/baseline/level3/thorough.js +1 -1
  83. package/src/ai/prompts/baseline/orchestration/balanced.js +1 -1
  84. package/src/ai/prompts/baseline/orchestration/fast.js +1 -1
  85. package/src/ai/prompts/baseline/orchestration/thorough.js +1 -1
  86. package/src/ai/prompts/config.js +1 -1
  87. package/src/ai/prompts/index.js +1 -1
  88. package/src/ai/prompts/line-number-guidance.js +1 -1
  89. package/src/ai/prompts/render-for-skill.js +1 -1
  90. package/src/ai/prompts/shared/diff-instructions.js +1 -1
  91. package/src/ai/prompts/shared/output-schema.js +1 -1
  92. package/src/ai/prompts/shared/valid-files.js +1 -1
  93. package/src/ai/prompts/sparse-checkout-guidance.js +1 -1
  94. package/src/ai/provider-availability.js +1 -1
  95. package/src/ai/provider.js +1 -1
  96. package/src/ai/stream-parser.js +1 -1
  97. package/src/chat/acp-bridge.js +1 -1
  98. package/src/chat/api-reference.js +1 -1
  99. package/src/chat/chat-providers.js +1 -1
  100. package/src/chat/claude-code-bridge.js +1 -1
  101. package/src/chat/codex-bridge.js +1 -1
  102. package/src/chat/pi-bridge.js +1 -1
  103. package/src/chat/prompt-builder.js +1 -1
  104. package/src/chat/session-manager.js +1 -1
  105. package/src/config.js +3 -1
  106. package/src/database.js +591 -40
  107. package/src/events/review-events.js +1 -1
  108. package/src/git/base-branch.js +173 -0
  109. package/src/git/gitattributes.js +1 -1
  110. package/src/git/sha-abbrev.js +35 -0
  111. package/src/git/worktree.js +1 -1
  112. package/src/github/client.js +33 -2
  113. package/src/github/parser.js +1 -1
  114. package/src/hooks/hook-runner.js +100 -0
  115. package/src/hooks/payloads.js +212 -0
  116. package/src/local-review.js +469 -130
  117. package/src/local-scope.js +58 -0
  118. package/src/main.js +56 -5
  119. package/src/mcp-stdio.js +1 -1
  120. package/src/protocol-handler.js +1 -1
  121. package/src/routes/analyses.js +74 -11
  122. package/src/routes/chat.js +34 -1
  123. package/src/routes/config.js +2 -1
  124. package/src/routes/context-files.js +1 -1
  125. package/src/routes/councils.js +1 -1
  126. package/src/routes/github-collections.js +1 -1
  127. package/src/routes/local.js +735 -69
  128. package/src/routes/mcp.js +21 -11
  129. package/src/routes/pr.js +91 -13
  130. package/src/routes/reviews.js +1 -1
  131. package/src/routes/setup.js +2 -1
  132. package/src/routes/shared.js +1 -1
  133. package/src/routes/worktrees.js +213 -149
  134. package/src/server.js +31 -1
  135. package/src/setup/local-setup.js +47 -6
  136. package/src/setup/pr-setup.js +29 -6
  137. package/src/utils/auto-context.js +1 -1
  138. package/src/utils/category-emoji.js +1 -1
  139. package/src/utils/comment-formatter.js +1 -1
  140. package/src/utils/diff-annotator.js +1 -1
  141. package/src/utils/diff-file-list.js +1 -1
  142. package/src/utils/instructions.js +1 -1
  143. package/src/utils/json-extractor.js +1 -1
  144. package/src/utils/line-validation.js +1 -1
  145. package/src/utils/logger.js +1 -1
  146. package/src/utils/paths.js +1 -1
  147. package/src/utils/safe-parse-json.js +1 -1
  148. package/src/utils/stats-calculator.js +1 -1
  149. package/src/ws/index.js +1 -1
  150. package/src/ws/server.js +1 -1
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * Local Review Routes
4
4
  *
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  const express = require('express');
16
+ const { execSync } = require('child_process');
16
17
  const path = require('path');
17
18
  const fs = require('fs').promises;
18
19
  const { queryOne, run, ReviewRepository, RepoSettingsRepository, AnalysisRunRepository, CouncilRepository } = require('../database');
@@ -20,9 +21,14 @@ const Analyzer = require('../ai/analyzer');
20
21
  const { v4: uuidv4 } = require('uuid');
21
22
  const logger = require('../utils/logger');
22
23
  const { broadcastReviewEvent } = require('../events/review-events');
24
+ const { fireHooks, hasHooks } = require('../hooks/hook-runner');
25
+ const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
23
26
  const { mergeInstructions } = require('../utils/instructions');
24
- const { generateLocalDiff, computeLocalDiffDigest } = require('../local-review');
27
+ const { getGitHubToken } = require('../config');
28
+ const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = require('../local-review');
29
+ const { STOPS, isValidScope, scopeIncludes, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
25
30
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
31
+ const { getShaAbbrevLength } = require('../git/sha-abbrev');
26
32
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
27
33
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
28
34
  const {
@@ -60,6 +66,22 @@ function deleteLocalReviewDiff(reviewId) {
60
66
  localReviewDiffs.delete(toIntKey(reviewId));
61
67
  }
62
68
 
69
+ /**
70
+ * Delete a local review session and its in-memory diff cache.
71
+ * Shared by both single-delete and bulk-delete routes.
72
+ *
73
+ * @param {ReviewRepository} reviewRepo - Repository instance
74
+ * @param {number} id - Review ID
75
+ * @returns {boolean} true if deleted, false if not found
76
+ */
77
+ async function deleteLocalReviewFull(reviewRepo, id) {
78
+ const deleted = await reviewRepo.deleteLocalSession(id);
79
+ if (deleted) {
80
+ deleteLocalReviewDiff(id);
81
+ }
82
+ return deleted;
83
+ }
84
+
63
85
  /**
64
86
  * Open native OS directory picker dialog and return the selected path.
65
87
  * Uses osascript on macOS, zenity/kdialog on Linux, PowerShell on Windows.
@@ -165,9 +187,19 @@ router.get('/api/local/sessions', async (req, res) => {
165
187
  const reviewRepo = new ReviewRepository(db);
166
188
  const { sessions, hasMore } = await reviewRepo.listLocalSessions({ limit, before });
167
189
 
190
+ // Compute SHA abbreviation length per unique repo path
191
+ const abbrevCache = new Map();
192
+ const enrichedSessions = sessions.map(session => {
193
+ if (!session.local_path) return session;
194
+ if (!abbrevCache.has(session.local_path)) {
195
+ abbrevCache.set(session.local_path, getShaAbbrevLength(session.local_path));
196
+ }
197
+ return { ...session, sha_abbrev_length: abbrevCache.get(session.local_path) };
198
+ });
199
+
168
200
  res.json({
169
201
  success: true,
170
- sessions,
202
+ sessions: enrichedSessions,
171
203
  hasMore
172
204
  });
173
205
 
@@ -179,6 +211,74 @@ router.get('/api/local/sessions', async (req, res) => {
179
211
  }
180
212
  });
181
213
 
214
+ /**
215
+ * Bulk delete local review sessions.
216
+ * Accepts { ids: number[] } in request body. Max 50 IDs per request.
217
+ * Must be registered BEFORE /:reviewId param routes.
218
+ * Only deletes DB records — does NOT remove files on disk.
219
+ */
220
+ router.post('/api/local/sessions/bulk-delete', async (req, res) => {
221
+ try {
222
+ const { ids } = req.body || {};
223
+
224
+ if (!Array.isArray(ids) || ids.length === 0) {
225
+ return res.status(400).json({
226
+ success: false,
227
+ error: 'Request body must contain a non-empty "ids" array'
228
+ });
229
+ }
230
+
231
+ if (ids.length > 50) {
232
+ return res.status(400).json({
233
+ success: false,
234
+ error: 'Maximum 50 IDs per request'
235
+ });
236
+ }
237
+
238
+ const parsedIds = ids.map(id => parseInt(id, 10));
239
+ if (parsedIds.some(id => isNaN(id) || id <= 0)) {
240
+ return res.status(400).json({
241
+ success: false,
242
+ error: 'All IDs must be positive integers'
243
+ });
244
+ }
245
+
246
+ const db = req.app.get('db');
247
+ const reviewRepo = new ReviewRepository(db);
248
+ let deleted = 0;
249
+ const errors = [];
250
+
251
+ for (const id of parsedIds) {
252
+ try {
253
+ const result = await deleteLocalReviewFull(reviewRepo, id);
254
+ if (result) {
255
+ deleted++;
256
+ } else {
257
+ errors.push({ id, error: `Local review #${id} not found` });
258
+ }
259
+ } catch (err) {
260
+ errors.push({ id, error: err.message });
261
+ }
262
+ }
263
+
264
+ if (deleted > 0) logger.success(`Bulk deleted ${deleted} local review session(s)`);
265
+
266
+ res.json({
267
+ success: deleted > 0 || errors.length === 0,
268
+ deleted,
269
+ failed: errors.length,
270
+ errors
271
+ });
272
+
273
+ } catch (error) {
274
+ logger.error(`Error in bulk delete local sessions: ${error.message}`);
275
+ res.status(500).json({
276
+ success: false,
277
+ error: 'Failed to process bulk delete'
278
+ });
279
+ }
280
+ });
281
+
182
282
  /**
183
283
  * Delete a local review session
184
284
  * Must be registered BEFORE /:reviewId param routes
@@ -196,7 +296,7 @@ router.delete('/api/local/sessions/:reviewId', async (req, res) => {
196
296
 
197
297
  const db = req.app.get('db');
198
298
  const reviewRepo = new ReviewRepository(db);
199
- const deleted = await reviewRepo.deleteLocalSession(reviewId);
299
+ const deleted = await deleteLocalReviewFull(reviewRepo, reviewId);
200
300
 
201
301
  if (!deleted) {
202
302
  return res.status(404).json({
@@ -204,9 +304,6 @@ router.delete('/api/local/sessions/:reviewId', async (req, res) => {
204
304
  });
205
305
  }
206
306
 
207
- // Clean up in-memory diff cache to avoid stale data
208
- deleteLocalReviewDiff(reviewId);
209
-
210
307
  logger.success(`Deleted local review session #${reviewId}`);
211
308
 
212
309
  res.json({
@@ -275,21 +372,79 @@ router.post('/api/local/start', async (req, res) => {
275
372
  // Create or resume session
276
373
  const db = req.app.get('db');
277
374
  const reviewRepo = new ReviewRepository(db);
278
- const sessionId = await reviewRepo.upsertLocalReview({
279
- localPath: repoPath,
280
- localHeadSha: headSha,
281
- repository
282
- });
283
375
 
284
- // Generate diff
376
+ let sessionId;
377
+ // Try exact match (path + sha + branch)
378
+ let existing = await reviewRepo.getLocalReview(repoPath, headSha, branch);
379
+
380
+ // Adopt legacy sessions that predate branch tracking
381
+ if (!existing) {
382
+ const legacy = await reviewRepo.getLocalReviewByPathAndSha(repoPath, headSha);
383
+ if (legacy && legacy.local_head_branch === null) {
384
+ existing = legacy;
385
+ }
386
+ }
387
+
388
+ // Check for branch-scope session (persists across HEAD changes)
389
+ if (!existing) {
390
+ const branchSession = await reviewRepo.getLocalBranchScopeReview(repoPath, branch);
391
+ if (branchSession) existing = branchSession;
392
+ }
393
+
394
+ if (existing) {
395
+ sessionId = existing.id;
396
+ if (existing.local_head_sha !== headSha) {
397
+ await reviewRepo.updateLocalHeadSha(sessionId, headSha);
398
+ }
399
+ if (existing.local_head_branch === null) {
400
+ await reviewRepo.updateReview(sessionId, { local_head_branch: branch });
401
+ }
402
+ } else {
403
+ sessionId = await reviewRepo.upsertLocalReview({
404
+ localPath: repoPath,
405
+ localHeadSha: headSha,
406
+ repository,
407
+ scopeStart: DEFAULT_SCOPE.start,
408
+ scopeEnd: DEFAULT_SCOPE.end,
409
+ localHeadBranch: branch
410
+ });
411
+ }
412
+
413
+ // Fire review hook (non-blocking)
414
+ const config = req.app.get('config') || {};
415
+ // Generate diff using default scope
285
416
  logger.log('API', `Starting local review for ${repoPath}`, 'cyan');
286
- const { diff, stats } = await generateLocalDiff(repoPath);
417
+ const scopeStart = existing?.local_scope_start || DEFAULT_SCOPE.start;
418
+ const scopeEnd = existing?.local_scope_end || DEFAULT_SCOPE.end;
419
+
420
+ // Fire review hook (non-blocking, after scope is resolved)
421
+ const hookEvent = existing ? 'review.loaded' : 'review.started';
422
+ if (hasHooks(hookEvent, config)) {
423
+ getCachedUser(config).then(user => {
424
+ const builder = existing ? buildReviewLoadedPayload : buildReviewStartedPayload;
425
+ const si = STOPS.indexOf(scopeStart);
426
+ const ei = STOPS.indexOf(scopeEnd);
427
+ const scope = STOPS.slice(si, ei + 1);
428
+ const payload = builder({ reviewId: sessionId, mode: 'local', localContext: { path: repoPath, branch, headSha, scope }, user });
429
+ fireHooks(hookEvent, payload, config);
430
+ }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
431
+ }
432
+ const baseBranch = existing?.local_base_branch || null;
433
+ const { diff, stats } = await generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
287
434
 
288
435
  // Compute digest for staleness detection
289
- const digest = await computeLocalDiffDigest(repoPath);
436
+ const digest = await computeScopedDigest(repoPath, scopeStart, scopeEnd);
437
+
438
+ // Branch detection: when no uncommitted changes, check if branch has commits ahead
439
+ const branchInfo = await detectAndBuildBranchInfo(repoPath, branch, {
440
+ repository,
441
+ diff,
442
+ githubToken: getGitHubToken(config),
443
+ enableGraphite: config.enable_graphite === true
444
+ });
290
445
 
291
446
  // Persist to in-memory Map
292
- setLocalReviewDiff(sessionId, { diff, stats, digest });
447
+ setLocalReviewDiff(sessionId, { diff, stats, digest, branchInfo });
293
448
 
294
449
  // Persist to database
295
450
  await reviewRepo.saveLocalDiff(sessionId, { diff, stats, digest });
@@ -302,6 +457,7 @@ router.post('/api/local/start', async (req, res) => {
302
457
  sessionId,
303
458
  repository,
304
459
  branch,
460
+ branchInfo,
305
461
  stats: {
306
462
  trackedChanges: stats.trackedChanges || 0,
307
463
  untrackedFiles: stats.untrackedFiles || 0,
@@ -376,6 +532,73 @@ router.get('/api/local/:reviewId', async (req, res) => {
376
532
  }
377
533
  }
378
534
 
535
+ // Build scope info for the response
536
+ const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
537
+ const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
538
+ const baseBranch = review.local_base_branch || null;
539
+
540
+ // When scope does NOT include branch, check for branch detection info
541
+ // Frontend uses this to suggest expanding scope to include branch
542
+ let branchInfo = null;
543
+ const cachedDiff = getLocalReviewDiff(reviewId);
544
+ if (!includesBranch(scopeStart) && cachedDiff?.branchInfo) {
545
+ branchInfo = cachedDiff.branchInfo;
546
+ } else if (!includesBranch(scopeStart) && !cachedDiff && review.local_path) {
547
+ // No cache (web UI started session) — run detection on-demand
548
+ const config = req.app.get('config') || {};
549
+ branchInfo = await detectAndBuildBranchInfo(review.local_path, branchName, {
550
+ repository: repositoryName,
551
+ githubToken: getGitHubToken(config),
552
+ enableGraphite: config.enable_graphite === true
553
+ });
554
+ }
555
+
556
+ // Check repo settings for auto_branch_review preference
557
+ let autoBranchReview = 0;
558
+ if (branchInfo && repositoryName && repositoryName.includes('/')) {
559
+ try {
560
+ const repoSettingsRepo = new RepoSettingsRepository(db);
561
+ const repoSettings = await repoSettingsRepo.getRepoSettings(repositoryName);
562
+ if (repoSettings) {
563
+ autoBranchReview = repoSettings.auto_branch_review || 0;
564
+ }
565
+ } catch {
566
+ // Non-fatal
567
+ }
568
+ }
569
+
570
+ // If auto_branch_review is -1 (never), suppress branchInfo
571
+ if (autoBranchReview === -1) {
572
+ branchInfo = null;
573
+ }
574
+
575
+ // Determine if Branch stop should be selectable in the scope range selector.
576
+ // This is independent of branchInfo (which guards on no uncommitted changes).
577
+ // Branch is available when: not detached HEAD, not on default branch, and has commits ahead.
578
+ let branchAvailable = includesBranch(scopeStart) || Boolean(branchInfo);
579
+ if (!branchAvailable && branchName && branchName !== 'HEAD' && branchName !== 'unknown' && review.local_path) {
580
+ try {
581
+ const { getBranchCommitCount } = require('../local-review');
582
+ const { detectBaseBranch } = require('../git/base-branch');
583
+ const config = req.app.get('config') || {};
584
+ const depsOverride = getGitHubToken(config) ? { getGitHubToken: () => getGitHubToken(config) } : undefined;
585
+ const detection = await detectBaseBranch(review.local_path, branchName, {
586
+ repository: repositoryName,
587
+ enableGraphite: config.enable_graphite === true,
588
+ _deps: depsOverride
589
+ });
590
+ if (detection) {
591
+ const commitCount = await getBranchCommitCount(review.local_path, detection.baseBranch);
592
+ branchAvailable = commitCount > 0;
593
+ }
594
+ } catch {
595
+ // Non-fatal — branch stop stays disabled
596
+ }
597
+ }
598
+
599
+ // Compute SHA abbreviation length from the repo's git config
600
+ const shaAbbrevLength = getShaAbbrevLength(review.local_path);
601
+
379
602
  res.json({
380
603
  id: review.id,
381
604
  localPath: review.local_path,
@@ -385,10 +608,35 @@ router.get('/api/local/:reviewId', async (req, res) => {
385
608
  reviewType: 'local',
386
609
  status: review.status,
387
610
  name: review.name || null,
611
+ localMode: review.local_mode || 'uncommitted',
612
+ scopeStart,
613
+ scopeEnd,
614
+ baseBranch,
615
+ branchInfo,
616
+ branchAvailable,
617
+ shaAbbrevLength,
388
618
  createdAt: review.created_at,
389
619
  updatedAt: review.updated_at
390
620
  });
391
621
 
622
+ // Fire review.loaded hook (session already exists to be fetched by ID)
623
+ const hookConfig = req.app.get('config') || {};
624
+ if (hasHooks('review.loaded', hookConfig)) {
625
+ getCachedUser(hookConfig).then(user => {
626
+ const hookScopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
627
+ const hookScopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
628
+ const si = STOPS.indexOf(hookScopeStart);
629
+ const ei = STOPS.indexOf(hookScopeEnd);
630
+ const scope = STOPS.slice(si, ei + 1);
631
+ const payload = buildReviewLoadedPayload({
632
+ reviewId: review.id, mode: 'local',
633
+ localContext: { path: review.local_path, branch: branchName, headSha: review.local_head_sha, scope },
634
+ user,
635
+ });
636
+ fireHooks('review.loaded', payload, hookConfig);
637
+ }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
638
+ }
639
+
392
640
  } catch (error) {
393
641
  logger.error('Error fetching local review:', error.stack || error.message);
394
642
  res.status(500).json({
@@ -467,11 +715,13 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
467
715
 
468
716
  // When ?w=1, regenerate the diff with whitespace changes hidden (transient view, not cached)
469
717
  const hideWhitespace = req.query.w === '1';
718
+ const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
719
+ const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
470
720
  let diffData;
471
721
 
472
722
  if (hideWhitespace && review.local_path) {
473
723
  try {
474
- const wsResult = await generateLocalDiff(review.local_path, { hideWhitespace: true });
724
+ const wsResult = await generateScopedDiff(review.local_path, scopeStart, scopeEnd, review.local_base_branch, { hideWhitespace: true });
475
725
  diffData = { diff: wsResult.diff, stats: wsResult.stats };
476
726
  } catch (wsError) {
477
727
  logger.warn(`Could not generate whitespace-filtered diff for review #${reviewId}: ${wsError.message}`);
@@ -572,6 +822,42 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
572
822
  });
573
823
  }
574
824
 
825
+ const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
826
+ const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
827
+
828
+ // Always check HEAD SHA for supplementary fields
829
+ let headShaChanged = false;
830
+ let previousHeadSha = review.local_head_sha || null;
831
+ let currentHeadSha = null;
832
+
833
+ try {
834
+ const { getHeadSha } = require('../local-review');
835
+ currentHeadSha = await getHeadSha(localPath);
836
+ headShaChanged = !!(previousHeadSha && currentHeadSha && currentHeadSha !== previousHeadSha);
837
+ } catch (error) {
838
+ // If branch is in scope, HEAD SHA failure is fatal (existing behavior)
839
+ if (includesBranch(scopeStart)) {
840
+ return res.json({
841
+ isStale: true,
842
+ headShaChanged,
843
+ previousHeadSha,
844
+ currentHeadSha: null,
845
+ error: `Could not check HEAD SHA: ${error.message}`
846
+ });
847
+ }
848
+ // Otherwise, just continue with digest check
849
+ }
850
+
851
+ // When branch is in scope and HEAD changed, early return (existing behavior)
852
+ if (includesBranch(scopeStart) && headShaChanged) {
853
+ return res.json({
854
+ isStale: true,
855
+ headShaChanged,
856
+ previousHeadSha,
857
+ currentHeadSha
858
+ });
859
+ }
860
+
575
861
  // Get stored diff data (in-memory first, then fall back to DB)
576
862
  let storedDiffData = getLocalReviewDiff(reviewId);
577
863
  if (!storedDiffData) {
@@ -584,6 +870,9 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
584
870
  } else {
585
871
  return res.json({
586
872
  isStale: null,
873
+ headShaChanged,
874
+ previousHeadSha,
875
+ currentHeadSha,
587
876
  error: 'No stored diff data found'
588
877
  });
589
878
  }
@@ -595,17 +884,23 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
595
884
  // Assume stale to be safe and prompt user to refresh
596
885
  return res.json({
597
886
  isStale: true,
887
+ headShaChanged,
888
+ previousHeadSha,
889
+ currentHeadSha,
598
890
  error: 'No baseline digest - please refresh to enable staleness detection'
599
891
  });
600
892
  }
601
893
 
602
894
  // Compute current digest to compare against baseline
603
- const currentDigest = await computeLocalDiffDigest(localPath);
895
+ const currentDigest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
604
896
 
605
897
  // If current digest computation failed, assume stale to be safe
606
898
  if (!currentDigest) {
607
899
  return res.json({
608
900
  isStale: true,
901
+ headShaChanged,
902
+ previousHeadSha,
903
+ currentHeadSha,
609
904
  error: 'Could not compute current digest - refresh recommended'
610
905
  });
611
906
  }
@@ -615,13 +910,19 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
615
910
  res.json({
616
911
  isStale,
617
912
  storedDigest: storedDiffData.digest,
618
- currentDigest
913
+ currentDigest,
914
+ headShaChanged,
915
+ previousHeadSha,
916
+ currentHeadSha
619
917
  });
620
918
 
621
919
  } catch (error) {
622
920
  logger.warn(`Error checking local review staleness: ${error.message}`);
623
921
  res.json({
624
922
  isStale: null,
923
+ headShaChanged: false,
924
+ previousHeadSha: null,
925
+ currentHeadSha: null,
625
926
  error: error.message
626
927
  });
627
928
  }
@@ -715,6 +1016,10 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
715
1016
  const runId = uuidv4();
716
1017
  const analysisId = runId;
717
1018
 
1019
+ // Extract scope early — needed for both analysis run creation and diff generation
1020
+ const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
1021
+ const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
1022
+
718
1023
  // Create DB analysis_runs record immediately so it's queryable for polling
719
1024
  const analysisRunRepo = new AnalysisRunRepository(db);
720
1025
  const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
@@ -730,7 +1035,9 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
730
1035
  requestInstructions,
731
1036
  headSha: review.local_head_sha || null,
732
1037
  configType: 'single',
733
- levelsConfig
1038
+ levelsConfig,
1039
+ scopeStart,
1040
+ scopeEnd
734
1041
  });
735
1042
  } catch (error) {
736
1043
  logger.error('Failed to create analysis run record:', error);
@@ -764,26 +1071,54 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
764
1071
  // Broadcast initial status
765
1072
  broadcastProgress(analysisId, initialStatus);
766
1073
  broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
1074
+ const analysisHookConfig = req.app.get('config') || {};
1075
+ if (hasHooks('analysis.started', analysisHookConfig)) {
1076
+ getCachedUser(analysisHookConfig).then(user => {
1077
+ fireHooks('analysis.started', buildAnalysisStartedPayload({
1078
+ reviewId, analysisId, provider: selectedProvider, model: selectedModel,
1079
+ mode: 'local',
1080
+ localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
1081
+ user,
1082
+ }), analysisHookConfig);
1083
+ }).catch(() => {});
1084
+ }
767
1085
 
768
1086
  // Create analyzer instance with provider and model
769
1087
  const analyzer = new Analyzer(db, selectedModel, selectedProvider);
770
1088
 
771
1089
  // Build local review metadata for the analyzer
772
1090
  // The analyzer uses base_sha and head_sha for git diff commands
773
- // For local review, we use HEAD as both since we're diffing working directory
1091
+ // When branch is in scope, base_sha is the merge-base; otherwise, HEAD
1092
+ const hasBranch = includesBranch(scopeStart);
1093
+ let analysisBaseSha = review.local_head_sha;
1094
+ if (hasBranch && review.local_base_branch) {
1095
+ try {
1096
+ analysisBaseSha = await findMergeBase(localPath, review.local_base_branch);
1097
+ } catch {
1098
+ // Fall back to HEAD
1099
+ }
1100
+ }
774
1101
  const localMetadata = {
775
1102
  id: reviewId,
776
- repository: review.repository, // Include repository for context display
777
- title: `Local changes in ${repository}`,
778
- description: `Reviewing uncommitted changes in ${localPath}`,
779
- base_sha: review.local_head_sha, // HEAD commit
780
- head_sha: review.local_head_sha, // HEAD commit (diff is against working directory)
1103
+ repository: review.repository,
1104
+ title: hasBranch
1105
+ ? `Branch changes: ${review.local_base_branch}..HEAD`
1106
+ : `Local changes in ${repository}`,
1107
+ description: hasBranch
1108
+ ? `Reviewing committed changes on branch against ${review.local_base_branch}`
1109
+ : `Reviewing uncommitted changes in ${localPath}`,
1110
+ base_sha: analysisBaseSha,
1111
+ head_sha: review.local_head_sha,
781
1112
  reviewType: 'local'
782
1113
  };
783
1114
 
784
1115
  // Get changed files for local mode path validation
785
- // This is critical for local mode since git diff HEAD...HEAD returns nothing
786
- const changedFiles = await analyzer.getLocalChangedFiles(localPath);
1116
+ // When branch is in scope, pass null so analyzer falls through to getChangedFilesList
1117
+ // which correctly uses git diff base_sha...head_sha --name-only
1118
+ const hasStaged = scopeIncludes(scopeStart, scopeEnd, 'staged');
1119
+ const changedFiles = hasBranch
1120
+ ? null
1121
+ : await analyzer.getLocalChangedFiles(localPath, { includeStaged: hasStaged });
787
1122
 
788
1123
  // Log analysis start
789
1124
  logger.section(`Local AI Analysis Request - Review #${reviewId}`);
@@ -793,7 +1128,7 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
793
1128
  logger.log('API', `Provider: ${selectedProvider}`, 'cyan');
794
1129
  logger.log('API', `Model: ${selectedModel}`, 'cyan');
795
1130
  logger.log('API', `Tier: ${tier}`, 'cyan');
796
- logger.log('API', `Changed files: ${changedFiles.length}`, 'cyan');
1131
+ logger.log('API', `Changed files: ${changedFiles ? changedFiles.length : '(branch mode)'}`, 'cyan');
797
1132
  if (combinedInstructions) {
798
1133
  logger.log('API', `Custom instructions: ${combinedInstructions.length} chars`, 'cyan');
799
1134
  }
@@ -865,6 +1200,21 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
865
1200
  // Broadcast completion status
866
1201
  broadcastProgress(analysisId, completedStatus);
867
1202
  broadcastReviewEvent(reviewId, { type: 'review:analysis_completed' });
1203
+
1204
+ // Fire analysis.completed hook
1205
+ const hookConfig = req.app.get('config') || {};
1206
+ if (hasHooks('analysis.completed', hookConfig)) {
1207
+ getCachedUser(hookConfig).then(user => {
1208
+ fireHooks('analysis.completed', buildAnalysisCompletedPayload({
1209
+ reviewId, analysisId, provider: selectedProvider, model: selectedModel,
1210
+ status: 'success',
1211
+ totalSuggestions: completionInfo.totalSuggestions,
1212
+ mode: 'local',
1213
+ localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
1214
+ user,
1215
+ }), hookConfig);
1216
+ }).catch(() => {});
1217
+ }
868
1218
  })
869
1219
  .catch(error => {
870
1220
  const currentStatus = activeAnalyses.get(analysisId);
@@ -877,6 +1227,18 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
877
1227
  if (error.isCancellation) {
878
1228
  logger.info(`Local analysis cancelled for review #${reviewId}`);
879
1229
  // Status is already set to 'cancelled' by the cancel endpoint
1230
+ const cancelConfig = req.app.get('config') || {};
1231
+ if (hasHooks('analysis.completed', cancelConfig)) {
1232
+ getCachedUser(cancelConfig).then(user => {
1233
+ fireHooks('analysis.completed', buildAnalysisCompletedPayload({
1234
+ reviewId, analysisId, provider: selectedProvider, model: selectedModel,
1235
+ status: 'cancelled', totalSuggestions: 0,
1236
+ mode: 'local',
1237
+ localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
1238
+ user,
1239
+ }), cancelConfig);
1240
+ }).catch(() => {});
1241
+ }
880
1242
  return;
881
1243
  }
882
1244
 
@@ -902,6 +1264,19 @@ router.post('/api/local/:reviewId/analyses', async (req, res) => {
902
1264
 
903
1265
  // Broadcast failure status
904
1266
  broadcastProgress(analysisId, failedStatus);
1267
+
1268
+ const failConfig = req.app.get('config') || {};
1269
+ if (hasHooks('analysis.completed', failConfig)) {
1270
+ getCachedUser(failConfig).then(user => {
1271
+ fireHooks('analysis.completed', buildAnalysisCompletedPayload({
1272
+ reviewId, analysisId, provider: selectedProvider, model: selectedModel,
1273
+ status: 'failed', totalSuggestions: 0,
1274
+ mode: 'local',
1275
+ localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
1276
+ user,
1277
+ }), failConfig);
1278
+ }).catch(() => {});
1279
+ }
905
1280
  })
906
1281
  .finally(() => {
907
1282
  // Clean up review to analysis ID mapping (unified map)
@@ -964,64 +1339,66 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
964
1339
 
965
1340
  // Check if HEAD has changed
966
1341
  const { getHeadSha } = require('../local-review');
1342
+ const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
1343
+ const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
1344
+ const hasBranch = includesBranch(scopeStart);
967
1345
  let currentHeadSha;
968
- let sessionChanged = false;
969
- let newSessionId = null;
1346
+ let headShaChanged = false;
970
1347
 
971
1348
  try {
972
1349
  currentHeadSha = await getHeadSha(localPath);
973
1350
 
974
1351
  if (originalHeadSha && currentHeadSha !== originalHeadSha) {
975
- sessionChanged = true;
976
- logger.log('API', `HEAD changed: ${originalHeadSha.substring(0, 7)} -> ${currentHeadSha.substring(0, 7)}`, 'yellow');
977
-
978
- // Check if a session already exists for the new HEAD
979
- const existingSession = await reviewRepo.getLocalReview(localPath, currentHeadSha);
980
- if (existingSession) {
981
- newSessionId = existingSession.id;
982
- logger.log('API', `Existing session found for new HEAD: ${newSessionId}`, 'cyan');
983
- } else {
984
- // Create a new session for the new HEAD
985
- const { getRepositoryName } = require('../local-review');
986
- const repository = await getRepositoryName(localPath);
987
- newSessionId = await reviewRepo.upsertLocalReview({
988
- localPath: localPath,
989
- localHeadSha: currentHeadSha,
990
- repository
991
- });
992
- logger.log('API', `Created new session for new HEAD: ${newSessionId}`, 'cyan');
1352
+ headShaChanged = true;
1353
+ const abbrevLen = getShaAbbrevLength(localPath);
1354
+ logger.log('API', `HEAD changed: ${originalHeadSha.substring(0, abbrevLen)} -> ${currentHeadSha.substring(0, abbrevLen)}`, 'yellow');
1355
+
1356
+ if (hasBranch) {
1357
+ // Branch scope: session persists across HEAD changes — just update the SHA
1358
+ await reviewRepo.updateLocalHeadSha(reviewId, currentHeadSha);
1359
+ logger.log('API', `Updated HEAD SHA on branch-scope session ${reviewId}`, 'cyan');
993
1360
  }
1361
+ // Non-branch scope: defer decision to frontend via resolve-head-change endpoint
994
1362
  }
995
1363
  } catch (headError) {
996
1364
  logger.warn(`Could not check HEAD SHA: ${headError.message}`);
997
1365
  }
998
1366
 
999
- // Regenerate the diff from the working directory
1000
- const { diff, stats } = await generateLocalDiff(localPath);
1001
-
1002
- // Compute fresh digest for the new diff
1003
- const digest = await computeLocalDiffDigest(localPath);
1367
+ // Non-branch HEAD change: skip diff computation entirely — the old diff is
1368
+ // preserved until the user decides (via resolve-head-change) what to do.
1369
+ // The resolve-head-change endpoint will recompute the diff for whichever
1370
+ // action the user picks (update or new-session).
1371
+ if (headShaChanged && !hasBranch) {
1372
+ return res.json({
1373
+ success: true,
1374
+ message: 'HEAD changed — awaiting user decision',
1375
+ headShaChanged,
1376
+ previousHeadSha: originalHeadSha,
1377
+ currentHeadSha: currentHeadSha || null,
1378
+ stats: {}
1379
+ });
1380
+ }
1004
1381
 
1005
- // Update the stored diff data for the appropriate session
1006
- const targetSessionId = sessionChanged ? newSessionId : reviewId;
1007
- setLocalReviewDiff(targetSessionId, { diff, stats, digest });
1382
+ const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
1383
+ const diff = scopedResult.diff;
1384
+ const stats = scopedResult.stats;
1385
+ const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
1008
1386
 
1009
- // Persist diff to database for future session recovery
1387
+ setLocalReviewDiff(reviewId, { diff, stats, digest });
1010
1388
  try {
1011
- await reviewRepo.saveLocalDiff(targetSessionId, { diff, stats, digest });
1389
+ await reviewRepo.saveLocalDiff(reviewId, { diff, stats, digest });
1012
1390
  } catch (persistError) {
1013
1391
  logger.warn(`Could not persist diff to database: ${persistError.message}`);
1014
1392
  }
1015
1393
 
1016
- logger.success(`Diff refreshed: ${stats.unstagedChanges} unstaged, ${stats.untrackedFiles} untracked${stats.stagedChanges > 0 ? ` (${stats.stagedChanges} staged excluded)` : ''}`);
1394
+ logger.success(`Diff refreshed (scope ${scopeStart}–${scopeEnd}): ${stats.trackedChanges || 0} file(s)`);
1017
1395
 
1018
1396
  res.json({
1019
1397
  success: true,
1020
1398
  message: 'Diff refreshed successfully',
1021
- sessionChanged,
1022
- newSessionId: sessionChanged ? newSessionId : null,
1023
- newHeadSha: sessionChanged ? currentHeadSha : null,
1024
- originalHeadSha: originalHeadSha,
1399
+ headShaChanged,
1400
+ previousHeadSha: originalHeadSha,
1401
+ currentHeadSha: currentHeadSha || null,
1025
1402
  stats: {
1026
1403
  trackedChanges: stats.trackedChanges || 0,
1027
1404
  untrackedFiles: stats.untrackedFiles || 0,
@@ -1038,6 +1415,272 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
1038
1415
  }
1039
1416
  });
1040
1417
 
1418
+ /**
1419
+ * Resolve a HEAD SHA change on a non-branch-scoped review.
1420
+ * Called by the frontend after the user chooses how to handle a detected HEAD change.
1421
+ *
1422
+ * action: 'update' — keep the current session, update its SHA, recompute diff
1423
+ * action: 'new-session' — create a fresh session for the new HEAD, return its ID
1424
+ */
1425
+ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1426
+ try {
1427
+ const reviewId = parseInt(req.params.reviewId);
1428
+ if (isNaN(reviewId) || reviewId <= 0) {
1429
+ return res.status(400).json({ error: 'Invalid review ID' });
1430
+ }
1431
+
1432
+ const { action, newHeadSha } = req.body || {};
1433
+ if (!action || !['update', 'new-session'].includes(action)) {
1434
+ return res.status(400).json({ error: 'action must be "update" or "new-session"' });
1435
+ }
1436
+ if (!newHeadSha || typeof newHeadSha !== 'string') {
1437
+ return res.status(400).json({ error: 'newHeadSha is required' });
1438
+ }
1439
+
1440
+ const db = req.app.get('db');
1441
+ const reviewRepo = new ReviewRepository(db);
1442
+ const review = await reviewRepo.getLocalReviewById(reviewId);
1443
+ if (!review) {
1444
+ return res.status(404).json({ error: `Local review #${reviewId} not found` });
1445
+ }
1446
+
1447
+ const localPath = review.local_path;
1448
+ if (!localPath) {
1449
+ return res.status(400).json({ error: 'Local review is missing path information' });
1450
+ }
1451
+
1452
+ const scopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
1453
+ const scopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
1454
+
1455
+ if (action === 'update') {
1456
+ // Check for UNIQUE conflict before updating
1457
+ const headBranch = review.local_head_branch || null;
1458
+ const conflict = await reviewRepo.getLocalReview(localPath, newHeadSha, headBranch);
1459
+ if (conflict && conflict.id !== reviewId) {
1460
+ logger.log('API', `UNIQUE conflict: session #${conflict.id} already exists for this HEAD`, 'yellow');
1461
+ return res.json({ success: true, action: 'redirect', sessionId: conflict.id });
1462
+ }
1463
+
1464
+ // Update SHA
1465
+ await reviewRepo.updateLocalHeadSha(reviewId, newHeadSha);
1466
+ logger.log('API', `Updated HEAD SHA on session ${reviewId}`, 'cyan');
1467
+
1468
+ // Recompute and persist diff
1469
+ const scopedResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
1470
+ const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
1471
+ setLocalReviewDiff(reviewId, { diff: scopedResult.diff, stats: scopedResult.stats, digest });
1472
+ try {
1473
+ await reviewRepo.saveLocalDiff(reviewId, { diff: scopedResult.diff, stats: scopedResult.stats, digest });
1474
+ } catch (persistError) {
1475
+ logger.warn(`Could not persist diff to database: ${persistError.message}`);
1476
+ }
1477
+
1478
+ return res.json({ success: true, action: 'updated' });
1479
+ }
1480
+
1481
+ // action === 'new-session'
1482
+ let branch;
1483
+ try { branch = await getCurrentBranch(localPath); } catch (_) { /* non-fatal */ }
1484
+ const repository = await getRepositoryName(localPath);
1485
+
1486
+ // Check for an existing session at the new HEAD
1487
+ const existing = await reviewRepo.findLocalReview(localPath, newHeadSha, branch);
1488
+ if (existing) {
1489
+ logger.log('API', `Existing session found for new HEAD: ${existing.id}`, 'cyan');
1490
+ return res.json({ success: true, action: 'new-session', newSessionId: existing.id });
1491
+ }
1492
+
1493
+ const newSessionId = await reviewRepo.upsertLocalReview({
1494
+ localPath,
1495
+ localHeadSha: newHeadSha,
1496
+ repository,
1497
+ scopeStart,
1498
+ scopeEnd,
1499
+ localHeadBranch: branch
1500
+ });
1501
+ logger.log('API', `Created new session for new HEAD: ${newSessionId}`, 'cyan');
1502
+
1503
+ // Compute and persist diff so the new session is immediately usable
1504
+ const newScopeResult = await generateScopedDiff(localPath, scopeStart, scopeEnd, review.local_base_branch);
1505
+ const newDigest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
1506
+ setLocalReviewDiff(newSessionId, { diff: newScopeResult.diff, stats: newScopeResult.stats, digest: newDigest });
1507
+ try {
1508
+ await reviewRepo.saveLocalDiff(newSessionId, { diff: newScopeResult.diff, stats: newScopeResult.stats, digest: newDigest });
1509
+ } catch (persistError) {
1510
+ logger.warn(`Could not persist diff for new session: ${persistError.message}`);
1511
+ }
1512
+
1513
+ return res.json({ success: true, action: 'new-session', newSessionId });
1514
+
1515
+ } catch (error) {
1516
+ logger.error('Error resolving head change:', error);
1517
+ res.status(500).json({ error: 'Failed to resolve head change: ' + error.message });
1518
+ }
1519
+ });
1520
+
1521
+ /**
1522
+ * Set the scope range for a local review.
1523
+ * Validates scope, detects baseBranch if needed, regenerates diff.
1524
+ */
1525
+ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
1526
+ try {
1527
+ const reviewId = parseInt(req.params.reviewId);
1528
+
1529
+ if (isNaN(reviewId) || reviewId <= 0) {
1530
+ return res.status(400).json({ error: 'Invalid review ID' });
1531
+ }
1532
+
1533
+ const { scopeStart, scopeEnd, baseBranch: requestBaseBranch } = req.body || {};
1534
+
1535
+ if (!scopeStart || !scopeEnd) {
1536
+ return res.status(400).json({ error: 'scopeStart and scopeEnd are required' });
1537
+ }
1538
+
1539
+ if (!isValidScope(scopeStart, scopeEnd)) {
1540
+ return res.status(400).json({ error: `Invalid scope range: ${scopeStart}–${scopeEnd}` });
1541
+ }
1542
+
1543
+ const db = req.app.get('db');
1544
+ const reviewRepo = new ReviewRepository(db);
1545
+ const review = await reviewRepo.getLocalReviewById(reviewId);
1546
+
1547
+ if (!review) {
1548
+ return res.status(404).json({ error: `Local review #${reviewId} not found` });
1549
+ }
1550
+
1551
+ const localPath = review.local_path;
1552
+ if (!localPath) {
1553
+ return res.status(400).json({ error: 'Local review is missing path information' });
1554
+ }
1555
+
1556
+ // When branch is in scope, resolve baseBranch and current branch
1557
+ let baseBranch = requestBaseBranch || null;
1558
+ let currentBranch = null;
1559
+ if (includesBranch(scopeStart)) {
1560
+ currentBranch = await getCurrentBranch(localPath);
1561
+ if (!baseBranch) {
1562
+ const { detectBaseBranch } = require('../git/base-branch');
1563
+ const config = req.app.get('config') || {};
1564
+ const token = getGitHubToken(config);
1565
+ const detection = await detectBaseBranch(localPath, currentBranch, {
1566
+ repository: review.repository,
1567
+ enableGraphite: config.enable_graphite === true,
1568
+ _deps: token ? { getGitHubToken: () => token } : undefined
1569
+ });
1570
+ if (!detection) {
1571
+ return res.status(400).json({ error: 'Could not detect base branch' });
1572
+ }
1573
+ baseBranch = detection.baseBranch;
1574
+ }
1575
+
1576
+ // Validate branch name to prevent shell injection
1577
+ if (!/^[\w.\-/]+$/.test(baseBranch)) {
1578
+ return res.status(400).json({ error: 'Invalid branch name' });
1579
+ }
1580
+ }
1581
+
1582
+ logger.log('API', `Setting scope on review #${reviewId}: ${scopeStart}–${scopeEnd}${baseBranch ? ` (base: ${baseBranch})` : ''}`, 'cyan');
1583
+
1584
+ // Generate diff for the new scope
1585
+ const { diff, stats, mergeBaseSha } = await generateScopedDiff(localPath, scopeStart, scopeEnd, baseBranch);
1586
+
1587
+ // Get the HEAD SHA for staleness tracking
1588
+ const { getHeadSha } = require('../local-review');
1589
+ const headSha = await getHeadSha(localPath);
1590
+
1591
+ // Update the review record with new scope (headBranch stored on branch scope, cleared otherwise)
1592
+ await reviewRepo.updateLocalScope(reviewId, scopeStart, scopeEnd, baseBranch, currentBranch);
1593
+ await reviewRepo.updateLocalHeadSha(reviewId, headSha);
1594
+
1595
+ // Auto-name review from first commit subject when branch is newly in scope
1596
+ const oldScopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
1597
+ if (!review.name && includesBranch(scopeStart) && !includesBranch(oldScopeStart) && baseBranch) {
1598
+ const firstSubject = await getFirstCommitSubject(localPath, baseBranch);
1599
+ if (firstSubject) {
1600
+ await reviewRepo.updateReview(reviewId, { name: firstSubject.slice(0, 200) });
1601
+ }
1602
+ }
1603
+
1604
+ // Compute digest
1605
+ const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
1606
+
1607
+ // Store diff in cache and DB
1608
+ setLocalReviewDiff(reviewId, { diff, stats, digest });
1609
+ await reviewRepo.saveLocalDiff(reviewId, { diff, stats, digest });
1610
+
1611
+ logger.success(`Review #${reviewId} scope set to ${scopeStart}–${scopeEnd}: ${stats.trackedChanges || 0} file(s) changed`);
1612
+
1613
+ res.json({
1614
+ success: true,
1615
+ scopeStart,
1616
+ scopeEnd,
1617
+ localMode: includesBranch(scopeStart) ? 'branch' : 'uncommitted',
1618
+ baseBranch,
1619
+ mergeBaseSha,
1620
+ stats: {
1621
+ trackedChanges: stats.trackedChanges || 0,
1622
+ untrackedFiles: stats.untrackedFiles || 0,
1623
+ stagedChanges: stats.stagedChanges || 0,
1624
+ unstagedChanges: stats.unstagedChanges || 0
1625
+ }
1626
+ });
1627
+
1628
+ } catch (error) {
1629
+ logger.error(`Error setting scope: ${error.message}`);
1630
+ res.status(500).json({ error: 'Failed to set scope: ' + error.message });
1631
+ }
1632
+ });
1633
+
1634
+ /**
1635
+ * Save "don't ask again" preference for branch review
1636
+ */
1637
+ router.post('/api/local/:reviewId/branch-review-preference', async (req, res) => {
1638
+ try {
1639
+ const reviewId = parseInt(req.params.reviewId);
1640
+
1641
+ if (isNaN(reviewId) || reviewId <= 0) {
1642
+ return res.status(400).json({ error: 'Invalid review ID' });
1643
+ }
1644
+
1645
+ const { preference } = req.body || {};
1646
+ if (![0, 1, -1].includes(preference)) {
1647
+ return res.status(400).json({ error: 'Invalid preference value. Must be 0, 1, or -1.' });
1648
+ }
1649
+
1650
+ const db = req.app.get('db');
1651
+ const reviewRepo = new ReviewRepository(db);
1652
+ const review = await reviewRepo.getLocalReviewById(reviewId);
1653
+
1654
+ if (!review) {
1655
+ return res.status(404).json({ error: `Local review #${reviewId} not found` });
1656
+ }
1657
+
1658
+ const repository = review.repository;
1659
+ if (!repository || !repository.includes('/')) {
1660
+ return res.status(400).json({ error: 'Cannot save preference: no repository identified' });
1661
+ }
1662
+
1663
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1664
+ const existing = await repoSettingsRepo.getRepoSettings(repository);
1665
+
1666
+ if (existing) {
1667
+ await run(db, `
1668
+ UPDATE repo_settings SET auto_branch_review = ?, updated_at = ? WHERE repository = ? COLLATE NOCASE
1669
+ `, [preference, new Date().toISOString(), repository]);
1670
+ } else {
1671
+ await run(db, `
1672
+ INSERT INTO repo_settings (repository, auto_branch_review, created_at, updated_at) VALUES (?, ?, ?, ?)
1673
+ `, [repository, preference, new Date().toISOString(), new Date().toISOString()]);
1674
+ }
1675
+
1676
+ res.json({ success: true, preference });
1677
+
1678
+ } catch (error) {
1679
+ logger.error(`Error saving branch review preference: ${error.message}`);
1680
+ res.status(500).json({ error: 'Failed to save preference' });
1681
+ }
1682
+ });
1683
+
1041
1684
  /**
1042
1685
  * Get review settings for a local review
1043
1686
  * Returns the custom_instructions from the review record
@@ -1180,21 +1823,39 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
1180
1823
 
1181
1824
  const localPath = review.local_path;
1182
1825
 
1826
+ const councilScopeStart = review.local_scope_start || DEFAULT_SCOPE.start;
1827
+ const councilScopeEnd = review.local_scope_end || DEFAULT_SCOPE.end;
1828
+ const councilHasBranch = includesBranch(councilScopeStart);
1829
+
1830
+ // Compute merge-base when branch is in scope
1831
+ let analysisBaseSha = review.local_head_sha;
1832
+ if (councilHasBranch && review.local_base_branch) {
1833
+ try {
1834
+ analysisBaseSha = await findMergeBase(localPath, review.local_base_branch);
1835
+ } catch {
1836
+ // Fall back to HEAD
1837
+ }
1838
+ }
1839
+
1183
1840
  const prMetadata = {
1184
1841
  reviewType: 'local',
1185
1842
  repository: review.repository,
1186
- title: review.name || 'Local changes',
1843
+ title: review.name || (councilHasBranch ? `Branch changes: ${review.local_base_branch}..HEAD` : 'Local changes'),
1187
1844
  description: '',
1845
+ base_sha: analysisBaseSha,
1188
1846
  head_sha: review.local_head_sha
1189
1847
  };
1190
1848
 
1191
1849
  const analyzer = new Analyzer(db, 'council', 'council');
1192
- const changedFiles = await analyzer.getLocalChangedFiles(localPath);
1850
+ const councilHasStaged = scopeIncludes(councilScopeStart, councilScopeEnd, 'staged');
1851
+ const changedFiles = councilHasBranch
1852
+ ? null
1853
+ : await analyzer.getLocalChangedFiles(localPath, { includeStaged: councilHasStaged });
1193
1854
 
1194
1855
  // Generate and cache diff
1195
1856
  try {
1196
- const diffResult = await generateLocalDiff(localPath);
1197
- const digest = await computeLocalDiffDigest(localPath);
1857
+ const diffResult = await generateScopedDiff(localPath, councilScopeStart, councilScopeEnd, review.local_base_branch);
1858
+ const digest = await computeScopedDigest(localPath, councilScopeStart, councilScopeEnd);
1198
1859
  setLocalReviewDiff(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
1199
1860
  } catch (diffError) {
1200
1861
  logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
@@ -1226,7 +1887,12 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
1226
1887
  headSha: review.local_head_sha,
1227
1888
  logLabel: `local review #${reviewId}`,
1228
1889
  initialStatusExtra: { reviewId, reviewType: 'local' },
1229
- runUpdateExtra: { filesAnalyzed: changedFiles.length }
1890
+ config: req.app.get('config') || {},
1891
+ hookContext: {
1892
+ mode: 'local',
1893
+ localContext: { path: localPath, branch: review.local_head_branch, headSha: review.local_head_sha },
1894
+ },
1895
+ runUpdateExtra: { filesAnalyzed: changedFiles ? changedFiles.length : 0 }
1230
1896
  },
1231
1897
  councilConfig,
1232
1898
  councilId,