@in-the-loop-labs/pair-review 3.5.1 → 3.6.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 (48) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +603 -6
  5. package/public/index.html +90 -0
  6. package/public/js/components/ChatPanel.js +163 -3
  7. package/public/js/components/KeyboardShortcuts.js +10 -26
  8. package/public/js/components/TourBar.js +248 -0
  9. package/public/js/index.js +298 -25
  10. package/public/js/local.js +6 -0
  11. package/public/js/modules/cancel-background-job.js +183 -0
  12. package/public/js/modules/hunk-summary-renderer.js +116 -0
  13. package/public/js/modules/storage-cleanup.js +16 -0
  14. package/public/js/modules/tour-renderer.js +725 -0
  15. package/public/js/pr.js +1276 -2
  16. package/public/js/utils/modal-detection.js +77 -0
  17. package/public/local.html +17 -0
  18. package/public/pr.html +17 -0
  19. package/src/ai/abort-signal-wiring.js +130 -0
  20. package/src/ai/background-queue.js +290 -0
  21. package/src/ai/claude-cli.js +1 -1
  22. package/src/ai/claude-provider.js +50 -7
  23. package/src/ai/codex-provider.js +28 -5
  24. package/src/ai/copilot-provider.js +22 -3
  25. package/src/ai/cursor-agent-provider.js +22 -6
  26. package/src/ai/executable-provider.js +4 -19
  27. package/src/ai/gemini-provider.js +22 -5
  28. package/src/ai/hunk-hashing.js +161 -0
  29. package/src/ai/index.js +2 -0
  30. package/src/ai/opencode-provider.js +21 -5
  31. package/src/ai/pi-provider.js +21 -5
  32. package/src/ai/prompts/hunk-summary.js +199 -0
  33. package/src/ai/prompts/tour.js +232 -0
  34. package/src/ai/provider.js +21 -1
  35. package/src/ai/summary-generator.js +469 -0
  36. package/src/ai/tour-generator.js +568 -0
  37. package/src/config.js +114 -0
  38. package/src/database.js +282 -1
  39. package/src/local-review.js +189 -169
  40. package/src/routes/config.js +16 -1
  41. package/src/routes/context-files.js +2 -29
  42. package/src/routes/github-collections.js +168 -90
  43. package/src/routes/local.js +311 -4
  44. package/src/routes/middleware/validate-review-id.js +53 -0
  45. package/src/routes/pr.js +259 -4
  46. package/src/routes/reviews.js +145 -29
  47. package/src/utils/diff-hunks.js +65 -0
  48. package/src/utils/json-extractor.js +5 -2
package/src/routes/pr.js CHANGED
@@ -25,7 +25,8 @@ const Analyzer = require('../ai/analyzer');
25
25
  const { v4: uuidv4 } = require('uuid');
26
26
  const fs = require('fs').promises;
27
27
  const path = require('path');
28
- const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch } = require('../config');
28
+ const { getGitHubToken, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch, getSummaryEnabled, getTourEnabled } = require('../config');
29
+ const { backgroundQueue } = require('../ai/background-queue');
29
30
  const logger = require('../utils/logger');
30
31
  const { buildDiffLineSet } = require('../utils/diff-annotator');
31
32
  const { broadcastReviewEvent } = require('../events/review-events');
@@ -34,6 +35,8 @@ const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStarte
34
35
  const simpleGit = require('simple-git');
35
36
  const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('../git/diff-flags');
36
37
  const { walkPRStack, DEFAULT_TRUNK_BRANCHES } = require('../github/stack-walker');
38
+ const summaryGenerator = require('../ai/summary-generator');
39
+ const tourGenerator = require('../ai/tour-generator');
37
40
  const {
38
41
  activeAnalyses,
39
42
  reviewToAnalysisId,
@@ -45,7 +48,9 @@ const {
45
48
  registerProcess: registerProcessForCancellation
46
49
  } = require('./shared');
47
50
  const { safeParseJson } = require('../utils/safe-parse-json');
48
- const { mergeChangedFilesWithDiff } = require('../utils/diff-file-list');
51
+ const { mergeChangedFilesWithDiff, parseUnifiedDiffPatches } = require('../utils/diff-file-list');
52
+ const { parseHunks } = require('../utils/diff-hunks');
53
+ const { hashHunk } = require('../ai/hunk-hashing');
49
54
  const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
50
55
  const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
51
56
  const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
@@ -56,6 +61,57 @@ const analysesRouter = require('./analyses');
56
61
  const { worktreeLock } = require('../git/worktree-lock');
57
62
  const router = express.Router();
58
63
 
64
+ /**
65
+ * Compute per-file hunk hashes from a canonical (unfiltered) unified diff.
66
+ * Returns a Map<filePath, string[]> where the array is parallel to the order
67
+ * `parseHunks(filePatch)` returns hunks. The frontend's `parseDiffIntoBlocks`
68
+ * walks hunks in the same order, so `hunk_hashes[i]` matches `block[i]`.
69
+ *
70
+ * This is computed from the canonical (non-whitespace-filtered) diff so that
71
+ * the resulting hashes always match the keys persisted in `hunk_summaries`.
72
+ * Even when the rendered patch is `?w=1` (whitespace-filtered), the hashes
73
+ * stay aligned to the canonical hunks.
74
+ *
75
+ * @param {string} canonicalDiff - Full unified diff (NOT whitespace-filtered)
76
+ * @returns {Map<string, string[]>}
77
+ */
78
+ function computeHunkHashesFromDiff(canonicalDiff) {
79
+ const result = new Map();
80
+ if (!canonicalDiff) return result;
81
+ const filePatchMap = parseUnifiedDiffPatches(canonicalDiff);
82
+ for (const [filePath, filePatch] of filePatchMap.entries()) {
83
+ const hunks = parseHunks(filePatch);
84
+ const hashes = hunks.map((h) => hashHunk(filePath, `${h.header}\n${h.lines.join('\n')}`));
85
+ result.set(filePath, hashes);
86
+ }
87
+ return result;
88
+ }
89
+
90
+ /**
91
+ * Decorate a `changed_files` array with a parallel `hunk_hashes` array per
92
+ * file. Hashes come from the canonical diff so they remain stable across
93
+ * whitespace-filtered renders.
94
+ *
95
+ * @param {Array<object>} changedFiles
96
+ * @param {string} canonicalDiff
97
+ * @returns {Array<object>} New array; inputs are not mutated.
98
+ */
99
+ function attachHunkHashes(changedFiles, canonicalDiff) {
100
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) return changedFiles;
101
+ const hashes = computeHunkHashesFromDiff(canonicalDiff);
102
+ return changedFiles.map((file) => {
103
+ if (typeof file === 'string') return file;
104
+ const filePath = file?.file;
105
+ if (!filePath) return file;
106
+ const fileHashes = hashes.get(filePath);
107
+ if (!fileHashes) return file;
108
+ return { ...file, hunk_hashes: fileHashes };
109
+ });
110
+ }
111
+
112
+ module.exports._computeHunkHashesFromDiff = computeHunkHashesFromDiff;
113
+ module.exports._attachHunkHashes = attachHunkHashes;
114
+
59
115
  /**
60
116
  * Sync pending draft review from GitHub with local database
61
117
  *
@@ -254,8 +310,13 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
254
310
  }
255
311
  }
256
312
 
257
- // Prepare response
258
- const changedFiles = mergeChangedFilesWithDiff(extendedData.changed_files || [], extendedData.diff || '');
313
+ // Prepare response. Hunk hashes are computed from the canonical diff so
314
+ // they remain stable across whitespace-filtered renders (the rendered
315
+ // patch may be filtered, but the persisted hash keys are not).
316
+ const changedFiles = attachHunkHashes(
317
+ mergeChangedFilesWithDiff(extendedData.changed_files || [], extendedData.diff || ''),
318
+ extendedData.diff || ''
319
+ );
259
320
 
260
321
  // Use review.id instead of prMetadata.id to avoid ID collision with local mode
261
322
  const response = {
@@ -314,6 +375,40 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
314
375
  }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
315
376
  }
316
377
 
378
+ (async () => {
379
+ const reviewContext = {
380
+ prTitle: prMetadata.title,
381
+ prDescription: prMetadata.description,
382
+ changedFiles: changedFiles.map((f) => (typeof f === 'string' ? f : (f.filename || f.file || f.path))).filter(Boolean)
383
+ };
384
+ const results = await Promise.allSettled([
385
+ summaryGenerator.kickOffSummaryJob({
386
+ db,
387
+ config,
388
+ reviewId: review.id,
389
+ diffText: extendedData.diff,
390
+ worktreePath: extendedData.worktree_path,
391
+ reviewContext,
392
+ trigger: 'auto'
393
+ }),
394
+ tourGenerator.kickOffTourJob({
395
+ db,
396
+ config,
397
+ reviewId: review.id,
398
+ diffText: extendedData.diff,
399
+ worktreePath: extendedData.worktree_path,
400
+ reviewContext,
401
+ trigger: 'auto'
402
+ })
403
+ ]);
404
+ const labels = ['Hunk summary', 'Tour'];
405
+ results.forEach((r, i) => {
406
+ if (r.status === 'rejected') {
407
+ logger.warn(`${labels[i]} kickoff failed for review ${review.id}: ${r.reason?.message || r.reason}`);
408
+ }
409
+ });
410
+ })().catch((err) => logger.warn(`Background AI kickoff failed for review ${review.id}: ${err.message}`));
411
+
317
412
  } catch (error) {
318
413
  console.error('Error fetching PR data:', error);
319
414
  res.status(500).json({
@@ -506,6 +601,35 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
506
601
 
507
602
  res.json(response);
508
603
 
604
+ // Re-kick the summary and tour jobs against the freshly-refreshed diff.
605
+ // The frontend's refreshPR() calls this POST then GETs /diff (which is a
606
+ // read-only endpoint and does NOT enqueue), so without an explicit
607
+ // kickoff here the in-flight stale job would keep burning tokens until
608
+ // it completes. Each kickoff is dedup'd by diff digest/hash; when the
609
+ // diff actually changed (new PR HEAD), the kickoffs auto-cancel the
610
+ // stale in-flight job before enqueueing the fresh one.
611
+ (async () => {
612
+ const reviewContext = {
613
+ prTitle: prMetadata.title,
614
+ prDescription: prMetadata.description,
615
+ changedFiles: (changedFiles || []).map((f) => (typeof f === 'string' ? f : (f.filename || f.file || f.path))).filter(Boolean)
616
+ };
617
+ const results = await Promise.allSettled([
618
+ summaryGenerator.kickOffSummaryJob({
619
+ db, config, reviewId: review.id, diffText: extendedData.diff, worktreePath: extendedData.worktree_path, reviewContext, trigger: 'auto'
620
+ }),
621
+ tourGenerator.kickOffTourJob({
622
+ db, config, reviewId: review.id, diffText: extendedData.diff, worktreePath: extendedData.worktree_path, reviewContext, trigger: 'auto'
623
+ })
624
+ ]);
625
+ const labels = ['Hunk summary', 'Tour'];
626
+ results.forEach((r, i) => {
627
+ if (r.status === 'rejected') {
628
+ logger.warn(`${labels[i]} kickoff failed for review ${review.id} on refresh: ${r.reason?.message || r.reason}`);
629
+ }
630
+ });
631
+ })().catch((err) => logger.warn(`Background AI kickoff failed for review ${review.id} on refresh: ${err.message}`));
632
+
509
633
  } catch (error) {
510
634
  logger.error('Error refreshing PR:', error);
511
635
  res.status(500).json({
@@ -514,6 +638,122 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
514
638
  }
515
639
  });
516
640
 
641
+ /**
642
+ * POST /api/pr/:owner/:repo/:number/jobs/:jobKey/start
643
+ *
644
+ * Manually trigger a summary or tour generation job for this PR. Used by the
645
+ * frontend when `auto_generate` is off and the user clicks the toolbar button.
646
+ *
647
+ * Mirrors the server-side kickoff that runs on PR load, but passes
648
+ * `trigger: 'manual'` so it bypasses the `auto_generate` gate (the `enabled`
649
+ * gate still applies — disabled features return 409).
650
+ *
651
+ * Request:
652
+ * - `jobKey` path param: `summary` or `tour`
653
+ *
654
+ * Responses:
655
+ * - 200 `{ started: true, alreadyRunning: false }` — enqueued
656
+ * - 200 `{ started: false, alreadyRunning: true }` — feature on but a job
657
+ * is already in flight
658
+ * (idempotent no-op)
659
+ * - 200 `{ started: false, reason: 'no-diff' }` — diff is empty
660
+ * - 400 `{ error: 'Invalid jobKey' }` — unknown jobKey
661
+ * - 404 `{ error: '...' }` — PR not found
662
+ * - 409 `{ error: '... disabled' }` — feature disabled in config
663
+ */
664
+ const MANUAL_START_JOB_KEYS = new Set(['summary', 'tour']);
665
+
666
+ router.post('/api/pr/:owner/:repo/:number/jobs/:jobKey/start', async (req, res) => {
667
+ try {
668
+ const { owner, repo, number, jobKey } = req.params;
669
+ const prNumber = parseInt(number, 10);
670
+
671
+ if (!Number.isInteger(prNumber) || prNumber <= 0) {
672
+ return res.status(400).json({ error: 'Invalid pull request number' });
673
+ }
674
+ if (!MANUAL_START_JOB_KEYS.has(jobKey)) {
675
+ return res.status(400).json({ error: `Invalid jobKey "${jobKey}" (expected "summary" or "tour")` });
676
+ }
677
+
678
+ const repository = normalizeRepository(owner, repo);
679
+ const db = req.app.get('db');
680
+ const config = req.app.get('config') || {};
681
+
682
+ // Enforce the feature-enabled gate at the HTTP boundary so the frontend
683
+ // gets a clean 409 instead of a silent no-op from the generator.
684
+ if (jobKey === 'summary' && !getSummaryEnabled(config)) {
685
+ return res.status(409).json({ error: 'Summaries feature is disabled in config' });
686
+ }
687
+ if (jobKey === 'tour' && !getTourEnabled(config)) {
688
+ return res.status(409).json({ error: 'Tours feature is disabled in config' });
689
+ }
690
+
691
+ const prMetadata = await queryOne(db, `
692
+ SELECT id, pr_number, repository, title, description, pr_data
693
+ FROM pr_metadata
694
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
695
+ `, [prNumber, repository]);
696
+
697
+ if (!prMetadata) {
698
+ return res.status(404).json({
699
+ error: `Pull request #${prNumber} not found in repository ${repository}`
700
+ });
701
+ }
702
+
703
+ const reviewRepo = new ReviewRepository(db);
704
+ const { review } = await reviewRepo.getOrCreate({ prNumber, repository });
705
+
706
+ let extendedData = {};
707
+ try {
708
+ extendedData = prMetadata.pr_data ? JSON.parse(prMetadata.pr_data) : {};
709
+ } catch (parseError) {
710
+ logger.warn(`Could not parse pr_data for PR #${prNumber}: ${parseError.message}`);
711
+ }
712
+
713
+ const diffText = extendedData.diff || '';
714
+ const worktreePath = extendedData.worktree_path || null;
715
+
716
+ if (!diffText || !worktreePath) {
717
+ return res.json({ started: false, reason: 'no-diff' });
718
+ }
719
+
720
+ // Idempotency: if a job is already in flight for this review/job-type,
721
+ // don't double-start. The frontend already has the in-flight event stream.
722
+ const activeJobType = typeof backgroundQueue.findActiveJobType === 'function'
723
+ ? backgroundQueue.findActiveJobType(review.id, jobKey === 'summary' ? 'summaries' : 'tour')
724
+ : null;
725
+ if (activeJobType) {
726
+ return res.json({ started: false, alreadyRunning: true });
727
+ }
728
+
729
+ const reviewContext = {
730
+ prTitle: prMetadata.title,
731
+ prDescription: prMetadata.description,
732
+ changedFiles: (extendedData.changed_files || [])
733
+ .map((f) => (typeof f === 'string' ? f : (f.filename || f.file || f.path)))
734
+ .filter(Boolean)
735
+ };
736
+
737
+ // Kick off in the background — return the start status immediately so
738
+ // the frontend can switch the button to its generating state. Errors
739
+ // are logged but the HTTP response is already sent.
740
+ if (jobKey === 'summary') {
741
+ Promise.resolve(summaryGenerator.kickOffSummaryJob({
742
+ db, config, reviewId: review.id, diffText, worktreePath, reviewContext, trigger: 'manual'
743
+ })).catch((err) => logger.warn(`Manual hunk summary kickoff failed for review ${review.id}: ${err.message}`));
744
+ } else {
745
+ Promise.resolve(tourGenerator.kickOffTourJob({
746
+ db, config, reviewId: review.id, diffText, worktreePath, reviewContext, trigger: 'manual'
747
+ })).catch((err) => logger.warn(`Manual tour kickoff failed for review ${review.id}: ${err.message}`));
748
+ }
749
+
750
+ return res.json({ started: true, alreadyRunning: false });
751
+ } catch (error) {
752
+ logger.error('Error starting manual job:', error);
753
+ res.status(500).json({ error: 'Failed to start job: ' + error.message });
754
+ }
755
+ });
756
+
517
757
  /**
518
758
  * Check if PR data is stale (remote has newer commits)
519
759
  */
@@ -826,6 +1066,21 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
826
1066
  }));
827
1067
  }
828
1068
 
1069
+ // Hunk hashes MUST come from the canonical (unfiltered) diff. When
1070
+ // hideWhitespace is on the rendered `diffContent` is the filtered diff,
1071
+ // which would produce hashes that diverge from the keys persisted in
1072
+ // `hunk_summaries`. We deliberately do NOT fall back to `diffContent`:
1073
+ // if `prData.diff` is missing, fail closed and emit no hashes so the
1074
+ // frontend skips anchoring rather than anchoring to misaligned hunks.
1075
+ if (prData.diff) {
1076
+ changedFiles = attachHunkHashes(changedFiles, prData.diff);
1077
+ } else {
1078
+ logger.warn(
1079
+ `[hunk-hash] PR #${prNumber} ${repository}: no canonical prData.diff; ` +
1080
+ 'omitting hunk_hashes (summaries will not anchor for this response).'
1081
+ );
1082
+ }
1083
+
829
1084
  // When diff was regenerated (whitespace), compute aggregate stats from
830
1085
  // the regenerated changedFiles instead of using stale cached values from prData.
831
1086
  const additions = hideWhitespace
@@ -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;
@@ -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' };