@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/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
|
-
|
|
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
|
package/src/routes/reviews.js
CHANGED
|
@@ -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' };
|