@in-the-loop-labs/pair-review 3.5.2 → 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.
- package/package.json +15 -20
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/pr.css +603 -6
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/TourBar.js +248 -0
- package/public/js/local.js +6 -0
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/tour-renderer.js +725 -0
- package/public/js/pr.js +1276 -2
- package/public/js/utils/modal-detection.js +77 -0
- package/public/local.html +17 -0
- package/public/pr.html +17 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +114 -0
- package/src/database.js +282 -1
- package/src/local-review.js +189 -169
- package/src/routes/config.js +16 -1
- package/src/routes/context-files.js +2 -29
- package/src/routes/local.js +311 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +259 -4
- package/src/routes/reviews.js +145 -29
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
package/src/routes/local.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|