@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
@@ -24,8 +24,10 @@ const { broadcastReviewEvent } = require('../events/review-events');
24
24
  const { fireHooks, hasHooks } = require('../hooks/hook-runner');
25
25
  const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
26
26
  const { mergeInstructions } = require('../utils/instructions');
27
- const { getGitHubToken, resolveLoadSkills, buildCouncilProviderOverrides } = require('../config');
28
- const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = require('../local-review');
27
+ const { getGitHubToken, resolveLoadSkills, buildCouncilProviderOverrides, getSummaryEnabled, getTourEnabled } = require('../config');
28
+ const { backgroundQueue } = require('../ai/background-queue');
29
+ const localReview = require('../local-review');
30
+ const { generateScopedDiff, computeScopedDigest, getBranchCommitCount, getFirstCommitSubject, detectAndBuildBranchInfo, findMergeBase, getCurrentBranch, getRepositoryName } = localReview;
29
31
  const { STOPS, isValidScope, normalizeScope, reviewScope, includesBranch, DEFAULT_SCOPE } = require('../local-scope');
30
32
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
31
33
  const { getShaAbbrevLength } = require('../git/sha-abbrev');
@@ -36,6 +38,12 @@ const { getDefaultBranch, tryGraphiteState } = require('../git/base-branch');
36
38
  const { CommentRepository } = require('../database');
37
39
  const { runExecutableAnalysis, getChangedFiles } = require('./executable-analysis');
38
40
  const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
41
+ const reviewsRouter = require('./reviews');
42
+ const summaryGenerator = require('../ai/summary-generator');
43
+ const tourGenerator = require('../ai/tour-generator');
44
+ const { parseUnifiedDiffPatches } = require('../utils/diff-file-list');
45
+ const { parseHunks } = require('../utils/diff-hunks');
46
+ const { hashHunk } = require('../ai/hunk-hashing');
39
47
  const {
40
48
  activeAnalyses,
41
49
  localReviewDiffs,
@@ -519,6 +527,30 @@ router.post('/api/local/start', async (req, res) => {
519
527
  }
520
528
  });
521
529
 
530
+ (async () => {
531
+ await summaryGenerator.kickOffSummaryJob({
532
+ db,
533
+ config,
534
+ reviewId: sessionId,
535
+ diffText: diff,
536
+ worktreePath: repoPath,
537
+ reviewContext: { prTitle: branch },
538
+ trigger: 'auto'
539
+ });
540
+ })().catch((err) => logger.warn(`Hunk summary job failed for review ${sessionId}: ${err.message}`));
541
+
542
+ (async () => {
543
+ await tourGenerator.kickOffTourJob({
544
+ db,
545
+ config,
546
+ reviewId: sessionId,
547
+ diffText: diff,
548
+ worktreePath: repoPath,
549
+ reviewContext: { prTitle: branch },
550
+ trigger: 'auto'
551
+ });
552
+ })().catch((err) => logger.warn(`Tour job failed for review ${sessionId}: ${err.message}`));
553
+
522
554
  } catch (error) {
523
555
  logger.error(`Error starting local review: ${error.message}`);
524
556
  res.status(500).json({
@@ -719,6 +751,48 @@ router.get('/api/local/:reviewId', async (req, res) => {
719
751
  }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
720
752
  }
721
753
 
754
+ // Background: re-trigger hunk summary + tour generation on review load.
755
+ // Self-invoked so any rejection here cannot reach the outer try/catch
756
+ // and call res.status(500) on an already-flushed response.
757
+ (async () => {
758
+ let bgDiffText = getLocalReviewDiff(reviewId)?.diff;
759
+ if (!bgDiffText) {
760
+ const persistedDiff = await reviewRepo.getLocalDiff(reviewId);
761
+ bgDiffText = persistedDiff?.diff;
762
+ }
763
+ if (!bgDiffText) {
764
+ logger.debug(`Skipping background AI kickoff for review ${reviewId}: no diff available`);
765
+ return;
766
+ }
767
+ const reviewContext = { prTitle: review.name || branchName };
768
+ const results = await Promise.allSettled([
769
+ summaryGenerator.kickOffSummaryJob({
770
+ db,
771
+ config: localConfig,
772
+ reviewId,
773
+ diffText: bgDiffText,
774
+ worktreePath: review.local_path,
775
+ reviewContext,
776
+ trigger: 'auto'
777
+ }),
778
+ tourGenerator.kickOffTourJob({
779
+ db,
780
+ config: localConfig,
781
+ reviewId,
782
+ diffText: bgDiffText,
783
+ worktreePath: review.local_path,
784
+ reviewContext,
785
+ trigger: 'auto'
786
+ })
787
+ ]);
788
+ const labels = ['Hunk summary', 'Tour'];
789
+ results.forEach((r, i) => {
790
+ if (r.status === 'rejected') {
791
+ logger.warn(`${labels[i]} kickoff failed for review ${reviewId}: ${r.reason?.message || r.reason}`);
792
+ }
793
+ });
794
+ })().catch((err) => logger.warn(`Background AI kickoff failed for review ${reviewId}: ${err.message}`));
795
+
722
796
  } catch (error) {
723
797
  logger.error('Error fetching local review:', error.stack || error.message);
724
798
  res.status(500).json({
@@ -805,7 +879,11 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
805
879
 
806
880
  if ((hideWhitespace || baseBranchOverride) && review.local_path) {
807
881
  try {
808
- const wsResult = await generateScopedDiff(review.local_path, scopeStart, scopeEnd, baseBranch, { hideWhitespace });
882
+ // Call via the module namespace so tests can stub `generateScopedDiff`
883
+ // with `vi.spyOn(localReview, 'generateScopedDiff')`. The destructured
884
+ // top-level binding is captured at require time and would not honor a
885
+ // spy.
886
+ const wsResult = await localReview.generateScopedDiff(review.local_path, scopeStart, scopeEnd, baseBranch, { hideWhitespace });
809
887
  diffData = { diff: wsResult.diff, stats: wsResult.stats };
810
888
  } catch (wsError) {
811
889
  logger.warn(`Could not generate diff for review #${reviewId}: ${wsError.message}`);
@@ -854,6 +932,52 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
854
932
  }
855
933
  }
856
934
 
935
+ // Compute per-file hunk hashes for the hunk-summary feature.
936
+ //
937
+ // The frontend stamps these hashes onto rendered hunks BY INDEX
938
+ // (`hunkHashes[blockIndex]`), so the array MUST be aligned to the
939
+ // diff that was actually returned to the client. Two cases:
940
+ //
941
+ // 1. `?w=1`: `git diff -w` only DROPS whitespace-only hunks; it
942
+ // never rewrites kept hunks. The frontend renderPatch length
943
+ // guard catches the drop case (mismatch between canonical hash
944
+ // count and rendered block count) and bails. So for kept hunks
945
+ // the canonical hash still identifies the right rendered hunk
946
+ // AND matches the persisted summary key — fall back to the
947
+ // canonical diff here for hash computation.
948
+ //
949
+ // 2. `?base=<branch>`: regen produces a DIFFERENT diff against a
950
+ // different base. Hunk counts may match by coincidence, but the
951
+ // content can differ. Hashing the canonical diff would mount a
952
+ // summary onto an override hunk whose code it doesn't describe
953
+ // — silent and wrong. Hash the override diff instead so:
954
+ // - identical-content hunks (hash equals canonical) still
955
+ // match a persisted summary and mount correctly;
956
+ // - divergent-content hunks miss (hash mismatch) and stay
957
+ // unmounted — visibly missing rather than silently wrong.
958
+ let canonicalDiff = diffContent;
959
+ if (hideWhitespace && !baseBranchOverride) {
960
+ const cached = getLocalReviewDiff(reviewId);
961
+ if (cached?.diff) {
962
+ canonicalDiff = cached.diff;
963
+ } else {
964
+ const persisted = await reviewRepo.getLocalDiff(reviewId);
965
+ if (persisted?.diff) canonicalDiff = persisted.diff;
966
+ }
967
+ }
968
+ const hunkHashesByFile = {};
969
+ if (canonicalDiff) {
970
+ const filePatchMap = parseUnifiedDiffPatches(canonicalDiff);
971
+ for (const [filePath, filePatch] of filePatchMap.entries()) {
972
+ const hunks = parseHunks(filePatch);
973
+ if (hunks.length > 0) {
974
+ hunkHashesByFile[filePath] = hunks.map((h) =>
975
+ hashHunk(filePath, `${h.header}\n${h.lines.join('\n')}`)
976
+ );
977
+ }
978
+ }
979
+ }
980
+
857
981
  const diffElapsed = Date.now() - tEndpoint;
858
982
  if (diffElapsed > 200) {
859
983
  logger.debug(`[perf] diff#${reviewId} took ${diffElapsed}ms (threshold: 200ms)`);
@@ -861,6 +985,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
861
985
  res.json({
862
986
  diff: diffContent || '',
863
987
  generated_files: generatedFiles,
988
+ hunk_hashes_by_file: hunkHashesByFile,
864
989
  stats: {
865
990
  trackedChanges: stats?.trackedChanges || 0,
866
991
  untrackedFiles: stats?.untrackedFiles || 0,
@@ -1605,6 +1730,25 @@ router.post('/api/local/:reviewId/refresh', async (req, res) => {
1605
1730
  }
1606
1731
  });
1607
1732
 
1733
+ // Re-kick the summary and tour jobs against the fresh diff. Each kickoff
1734
+ // is dedup'd by digest (summaries) or hash (tour); a no-op when the
1735
+ // canonical diff is unchanged (e.g. user clicked refresh but nothing
1736
+ // upstream changed). When the digest IS new, the kickoffs auto-cancel
1737
+ // the stale in-flight job before enqueueing the fresh one — see
1738
+ // kickOffSummaryJob / kickOffTourJob.
1739
+ const config = req.app.get('config') || {};
1740
+ const reviewContext = { prTitle: branchName || review.local_head_branch || undefined };
1741
+ (async () => {
1742
+ await summaryGenerator.kickOffSummaryJob({
1743
+ db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
1744
+ });
1745
+ })().catch((err) => logger.warn(`Hunk summary job failed for review ${reviewId}: ${err.message}`));
1746
+ (async () => {
1747
+ await tourGenerator.kickOffTourJob({
1748
+ db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
1749
+ });
1750
+ })().catch((err) => logger.warn(`Tour job failed for review ${reviewId}: ${err.message}`));
1751
+
1608
1752
  } catch (error) {
1609
1753
  logger.error('Error refreshing local diff:', error);
1610
1754
  res.status(500).json({
@@ -1683,7 +1827,29 @@ router.post('/api/local/:reviewId/resolve-head-change', async (req, res) => {
1683
1827
  // branch-ahead commit, making the Branch scope stop selectable.
1684
1828
  const branchAvailable = isBranchAvailable(headBranch, scopeStart, localPath);
1685
1829
 
1686
- return res.json({ success: true, action: 'updated', branchAvailable });
1830
+ res.json({ success: true, action: 'updated', branchAvailable });
1831
+
1832
+ // Re-kick the summary and tour jobs against the freshly-recomputed diff.
1833
+ // The frontend's _resolveHeadChange path applies the refreshed diff in
1834
+ // place via GET /diff (which is read-only and does NOT enqueue), so
1835
+ // without an explicit kickoff here the in-flight stale job from the
1836
+ // previous HEAD would keep burning tokens against a now-stale diff.
1837
+ // Each kickoff is dedup'd by digest/hash; a no-op when the recomputed
1838
+ // diff matches. When the digest IS new, the kickoffs auto-cancel the
1839
+ // stale in-flight job before enqueueing the fresh one.
1840
+ const config = req.app.get('config') || {};
1841
+ const reviewContext = { prTitle: headBranch || review.local_head_branch || undefined };
1842
+ (async () => {
1843
+ await summaryGenerator.kickOffSummaryJob({
1844
+ db, config, reviewId, diffText: scopedResult.diff, worktreePath: localPath, reviewContext, trigger: 'auto'
1845
+ });
1846
+ })().catch((err) => logger.warn(`Hunk summary job failed for review ${reviewId}: ${err.message}`));
1847
+ (async () => {
1848
+ await tourGenerator.kickOffTourJob({
1849
+ db, config, reviewId, diffText: scopedResult.diff, worktreePath: localPath, reviewContext, trigger: 'auto'
1850
+ });
1851
+ })().catch((err) => logger.warn(`Tour job failed for review ${reviewId}: ${err.message}`));
1852
+ return;
1687
1853
  }
1688
1854
 
1689
1855
  // action === 'new-session'
@@ -1839,6 +2005,23 @@ router.post('/api/local/:reviewId/set-scope', async (req, res) => {
1839
2005
  }
1840
2006
  });
1841
2007
 
2008
+ // Re-kick the summary and tour jobs against the freshly-scoped diff.
2009
+ // Each kickoff is dedup'd by diff digest/hash; when the scope change
2010
+ // actually produces a different diff, the kickoffs auto-cancel the
2011
+ // stale in-flight job before enqueueing the fresh one.
2012
+ const config = req.app.get('config') || {};
2013
+ const reviewContext = { prTitle: currentBranch || review.local_head_branch || undefined };
2014
+ (async () => {
2015
+ await summaryGenerator.kickOffSummaryJob({
2016
+ db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
2017
+ });
2018
+ })().catch((err) => logger.warn(`Hunk summary job failed for review ${reviewId}: ${err.message}`));
2019
+ (async () => {
2020
+ await tourGenerator.kickOffTourJob({
2021
+ db, config, reviewId, diffText: diff, worktreePath: localPath, reviewContext, trigger: 'auto'
2022
+ });
2023
+ })().catch((err) => logger.warn(`Tour job failed for review ${reviewId}: ${err.message}`));
2024
+
1842
2025
  } catch (error) {
1843
2026
  logger.error(`Error setting scope: ${error.message}`);
1844
2027
  res.status(500).json({ error: 'Failed to set scope: ' + error.message });
@@ -2144,4 +2327,128 @@ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
2144
2327
  }
2145
2328
  });
2146
2329
 
2330
+ /**
2331
+ * POST /api/local/:reviewId/jobs/:jobKey/start
2332
+ *
2333
+ * Manually trigger a summary or tour generation job for this local review.
2334
+ * Used by the frontend when `auto_generate` is off and the user clicks the
2335
+ * toolbar button.
2336
+ *
2337
+ * Mirrors the server-side kickoff that runs on local review load, but passes
2338
+ * `trigger: 'manual'` so it bypasses the `auto_generate` gate (the `enabled`
2339
+ * gate still applies — disabled features return 409).
2340
+ *
2341
+ * Request:
2342
+ * - `jobKey` path param: `summary` or `tour`
2343
+ *
2344
+ * Responses:
2345
+ * - 200 `{ started: true, alreadyRunning: false }` — enqueued
2346
+ * - 200 `{ started: false, alreadyRunning: true }` — feature on but a job
2347
+ * is already in flight
2348
+ * (idempotent no-op)
2349
+ * - 200 `{ started: false, reason: 'no-diff' }` — diff is empty
2350
+ * - 400 `{ error: 'Invalid jobKey' }` — unknown jobKey
2351
+ * - 404 `{ error: '...' }` — review not found
2352
+ * - 409 `{ error: '... disabled' }` — feature disabled in config
2353
+ */
2354
+ const LOCAL_MANUAL_START_JOB_KEYS = new Set(['summary', 'tour']);
2355
+
2356
+ router.post('/api/local/:reviewId/jobs/:jobKey/start', async (req, res) => {
2357
+ try {
2358
+ const reviewId = parseInt(req.params.reviewId, 10);
2359
+ if (!Number.isInteger(reviewId) || reviewId <= 0) {
2360
+ return res.status(400).json({ error: 'Invalid review ID' });
2361
+ }
2362
+ const { jobKey } = req.params;
2363
+ if (!LOCAL_MANUAL_START_JOB_KEYS.has(jobKey)) {
2364
+ return res.status(400).json({ error: `Invalid jobKey "${jobKey}" (expected "summary" or "tour")` });
2365
+ }
2366
+
2367
+ const db = req.app.get('db');
2368
+ const config = req.app.get('config') || {};
2369
+
2370
+ if (jobKey === 'summary' && !getSummaryEnabled(config)) {
2371
+ return res.status(409).json({ error: 'Summaries feature is disabled in config' });
2372
+ }
2373
+ if (jobKey === 'tour' && !getTourEnabled(config)) {
2374
+ return res.status(409).json({ error: 'Tours feature is disabled in config' });
2375
+ }
2376
+
2377
+ const reviewRepo = new ReviewRepository(db);
2378
+ const review = await reviewRepo.getLocalReviewById(reviewId);
2379
+ if (!review) {
2380
+ return res.status(404).json({ error: `Local review #${reviewId} not found` });
2381
+ }
2382
+
2383
+ const localDiff = await reviewRepo.getLocalDiff(reviewId);
2384
+ const diffText = localDiff ? (localDiff.diff || '') : '';
2385
+ const worktreePath = review.local_path || null;
2386
+
2387
+ if (!diffText || !worktreePath) {
2388
+ return res.json({ started: false, reason: 'no-diff' });
2389
+ }
2390
+
2391
+ const activeJobType = typeof backgroundQueue.findActiveJobType === 'function'
2392
+ ? backgroundQueue.findActiveJobType(reviewId, jobKey === 'summary' ? 'summaries' : 'tour')
2393
+ : null;
2394
+ if (activeJobType) {
2395
+ return res.json({ started: false, alreadyRunning: true });
2396
+ }
2397
+
2398
+ const reviewContext = {
2399
+ prTitle: review.name || review.local_head_branch || undefined
2400
+ };
2401
+
2402
+ if (jobKey === 'summary') {
2403
+ Promise.resolve(summaryGenerator.kickOffSummaryJob({
2404
+ db, config, reviewId, diffText, worktreePath, reviewContext, trigger: 'manual'
2405
+ })).catch((err) => logger.warn(`Manual hunk summary kickoff failed for review ${reviewId}: ${err.message}`));
2406
+ } else {
2407
+ Promise.resolve(tourGenerator.kickOffTourJob({
2408
+ db, config, reviewId, diffText, worktreePath, reviewContext, trigger: 'manual'
2409
+ })).catch((err) => logger.warn(`Manual tour kickoff failed for review ${reviewId}: ${err.message}`));
2410
+ }
2411
+
2412
+ return res.json({ started: true, alreadyRunning: false });
2413
+ } catch (error) {
2414
+ logger.error(`Error starting manual job for local review: ${error.message}`);
2415
+ res.status(500).json({ error: 'Failed to start job: ' + error.message });
2416
+ }
2417
+ });
2418
+
2419
+ /**
2420
+ * POST /api/local/:reviewId/jobs/:jobKey/cancel
2421
+ *
2422
+ * Local-mode wrapper around the shared cancel handler in reviews.js.
2423
+ * The unified `/api/reviews/:reviewId/jobs/:jobKey/cancel` already works
2424
+ * for local reviews (both modes share the `reviews` table), but exposing
2425
+ * it under both prefixes lets the frontend pick whichever helper matches
2426
+ * its current mode without a special case. See `handleJobCancel` in
2427
+ * `src/routes/reviews.js` for the canonical implementation.
2428
+ */
2429
+ router.post('/api/local/:reviewId/jobs/:jobKey/cancel', async (req, res) => {
2430
+ try {
2431
+ const reviewId = parseInt(req.params.reviewId, 10);
2432
+ if (isNaN(reviewId) || reviewId <= 0) {
2433
+ return res.status(400).json({ error: 'Invalid review ID' });
2434
+ }
2435
+ const db = req.app.get('db');
2436
+ // Same shape that validateReviewId attaches — we re-derive here because
2437
+ // local routes don't pass through that middleware by convention.
2438
+ const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ?', [reviewId]);
2439
+ if (!review) {
2440
+ return res.status(404).json({ error: `Review #${reviewId} not found` });
2441
+ }
2442
+ req.reviewId = reviewId;
2443
+ req.review = review;
2444
+ // await (not return) so any rejection from the delegated handler is
2445
+ // caught by the outer try/catch — Express 4 does not forward rejected
2446
+ // promises from async route handlers.
2447
+ await reviewsRouter.handleJobCancel(req, res);
2448
+ } catch (error) {
2449
+ logger.error(`Error cancelling background job for local review: ${error.message}`);
2450
+ res.status(500).json({ error: 'Failed to cancel background job' });
2451
+ }
2452
+ });
2453
+
2147
2454
  module.exports = router;
@@ -0,0 +1,53 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared middleware for /api/reviews/:reviewId/* routes.
4
+ *
5
+ * Validates that the :reviewId path parameter is a positive integer that
6
+ * corresponds to an existing review. On success it attaches:
7
+ * - req.reviewId — parsed integer reviewId
8
+ * - req.review — the full review row from the DB
9
+ *
10
+ * On failure it short-circuits with:
11
+ * - 400 when :reviewId is not a positive integer
12
+ * - 404 when no matching review exists
13
+ *
14
+ * This is the canonical implementation; route modules should import this
15
+ * rather than re-defining their own copy.
16
+ */
17
+
18
+ const { ReviewRepository } = require('../../database');
19
+
20
+ /**
21
+ * Express middleware: validate that :reviewId exists in the reviews table.
22
+ * Attaches the review record to req.review and the parsed id to req.reviewId.
23
+ *
24
+ * @param {import('express').Request} req
25
+ * @param {import('express').Response} res
26
+ * @param {import('express').NextFunction} next
27
+ */
28
+ async function validateReviewId(req, res, next) {
29
+ try {
30
+ const reviewId = parseInt(req.params.reviewId, 10);
31
+
32
+ if (isNaN(reviewId) || reviewId <= 0) {
33
+ return res.status(400).json({ error: 'Invalid review ID' });
34
+ }
35
+
36
+ const db = req.app.get('db');
37
+ const reviewRepo = new ReviewRepository(db);
38
+ const review = await reviewRepo.getReview(reviewId);
39
+
40
+ if (!review) {
41
+ return res.status(404).json({ error: `Review #${reviewId} not found` });
42
+ }
43
+
44
+ req.review = review;
45
+ req.reviewId = reviewId;
46
+ next();
47
+ } catch (error) {
48
+ next(error);
49
+ }
50
+ }
51
+
52
+ module.exports = validateReviewId;
53
+ module.exports.validateReviewId = validateReviewId;