@in-the-loop-labs/pair-review 3.5.2 → 3.7.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/README.md +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- 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/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- 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 +778 -10
- package/src/database.js +282 -1
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const logger = require('../utils/logger');
|
|
5
|
+
const {
|
|
6
|
+
TOUR_PERSIST_MIN_STOPS,
|
|
7
|
+
TOUR_MAX_STOPS,
|
|
8
|
+
TOUR_TITLE_MAX,
|
|
9
|
+
TOUR_DESCRIPTION_MAX
|
|
10
|
+
} = require('./prompts/tour');
|
|
11
|
+
|
|
12
|
+
const TOUR_LOG_PREFIX = '[Tour]';
|
|
13
|
+
const SCRIPT_NAME = 'git-diff-lines';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Tracks the most recently requested diff hash per review so that an in-flight
|
|
17
|
+
* tour generation can detect it has been superseded by a newer kickoff and
|
|
18
|
+
* skip persistence. Cleared when the latest hash successfully persists.
|
|
19
|
+
*
|
|
20
|
+
* Exported (module.exports) so tests can reset state between runs.
|
|
21
|
+
*/
|
|
22
|
+
const latestRequestedDiffHash = new Map();
|
|
23
|
+
|
|
24
|
+
const defaults = {
|
|
25
|
+
TourRepository: require('../database').TourRepository,
|
|
26
|
+
createProvider: require('./provider').createProvider,
|
|
27
|
+
resolveNonExecutableProviderId: require('./provider').resolveNonExecutableProviderId,
|
|
28
|
+
getTourProvider: require('../config').getTourProvider,
|
|
29
|
+
getTourModel: require('../config').getTourModel,
|
|
30
|
+
getTourEnabled: require('../config').getTourEnabled,
|
|
31
|
+
getTourAutoGenerate: require('../config').getTourAutoGenerate,
|
|
32
|
+
buildTourPrompt: require('./prompts/tour').buildTourPrompt,
|
|
33
|
+
extractJSON: require('../utils/json-extractor').extractJSON,
|
|
34
|
+
broadcastReviewEvent: require('../events/review-events').broadcastReviewEvent,
|
|
35
|
+
parseUnifiedDiffHunks: require('../utils/diff-hunks').parseUnifiedDiffHunks,
|
|
36
|
+
hashDiff: (diffText) => crypto.createHash('sha256').update(diffText).digest('hex').slice(0, 16),
|
|
37
|
+
backgroundQueue: null,
|
|
38
|
+
// Indirection so tests can swap the worker thunk and observe scheduling.
|
|
39
|
+
generateTourForReview: null
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the bare-name annotated-diff command string passed to the prompt.
|
|
44
|
+
* Bare command name (not absolute path) so provider tool allow-lists match.
|
|
45
|
+
* See analyzer.js `_buildScriptCommand`.
|
|
46
|
+
* @param {string|null} worktreePath
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function buildScriptCommand(worktreePath) {
|
|
50
|
+
if (!worktreePath) return SCRIPT_NAME;
|
|
51
|
+
return `${SCRIPT_NAME} --cwd "${worktreePath}"`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a unified-diff hunk header (`@@ -a,b +c,d @@`).
|
|
56
|
+
* @param {string} header
|
|
57
|
+
* @returns {{oldStart: number, oldLen: number, newStart: number, newLen: number}|null}
|
|
58
|
+
*/
|
|
59
|
+
function parseHunkHeader(header) {
|
|
60
|
+
const m = header && header.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
|
|
61
|
+
if (!m) return null;
|
|
62
|
+
return {
|
|
63
|
+
oldStart: parseInt(m[1], 10),
|
|
64
|
+
oldLen: m[2] != null ? parseInt(m[2], 10) : 1,
|
|
65
|
+
newStart: parseInt(m[3], 10),
|
|
66
|
+
newLen: m[4] != null ? parseInt(m[4], 10) : 1
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build sets of changed line numbers per file per side from parsed hunks.
|
|
72
|
+
* @param {Map<string, Array<{header: string, lines: string[]}>>} hunksByFile
|
|
73
|
+
* @returns {{left: Map<string, Set<number>>, right: Map<string, Set<number>>}}
|
|
74
|
+
*/
|
|
75
|
+
function buildChangedLineIndex(hunksByFile) {
|
|
76
|
+
const left = new Map();
|
|
77
|
+
const right = new Map();
|
|
78
|
+
for (const [filePath, hunks] of hunksByFile.entries()) {
|
|
79
|
+
const leftSet = new Set();
|
|
80
|
+
const rightSet = new Set();
|
|
81
|
+
for (const hunk of hunks) {
|
|
82
|
+
const head = parseHunkHeader(hunk.header);
|
|
83
|
+
if (!head) continue;
|
|
84
|
+
let oldLine = head.oldStart;
|
|
85
|
+
let newLine = head.newStart;
|
|
86
|
+
for (const raw of hunk.lines) {
|
|
87
|
+
if (raw.startsWith('\\')) continue;
|
|
88
|
+
const marker = raw[0];
|
|
89
|
+
if (marker === '+') {
|
|
90
|
+
rightSet.add(newLine);
|
|
91
|
+
newLine++;
|
|
92
|
+
} else if (marker === '-') {
|
|
93
|
+
leftSet.add(oldLine);
|
|
94
|
+
oldLine++;
|
|
95
|
+
} else {
|
|
96
|
+
oldLine++;
|
|
97
|
+
newLine++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
left.set(filePath, leftSet);
|
|
102
|
+
right.set(filePath, rightSet);
|
|
103
|
+
}
|
|
104
|
+
return { left, right };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rangeIntersectsSet(start, end, set) {
|
|
108
|
+
if (!set || set.size === 0) return false;
|
|
109
|
+
for (let i = start; i <= end; i++) {
|
|
110
|
+
if (set.has(i)) return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Validate and normalize a single tour stop. Returns the cleaned stop or null.
|
|
117
|
+
* @param {unknown} stop
|
|
118
|
+
* @param {Object} ctx - { hunksByFile, changedLines, worktreePath }
|
|
119
|
+
* @returns {Promise<Object|null>}
|
|
120
|
+
*/
|
|
121
|
+
async function validateStop(stop, ctx) {
|
|
122
|
+
if (!stop || typeof stop !== 'object') return null;
|
|
123
|
+
|
|
124
|
+
const filePath = typeof stop.file_path === 'string' ? stop.file_path : null;
|
|
125
|
+
const title = typeof stop.title === 'string' ? stop.title.trim() : '';
|
|
126
|
+
const description = typeof stop.description === 'string' ? stop.description.trim() : '';
|
|
127
|
+
if (!filePath || !title || !description) return null;
|
|
128
|
+
|
|
129
|
+
const ls = Number(stop.line_start);
|
|
130
|
+
const le = Number(stop.line_end);
|
|
131
|
+
if (!Number.isInteger(ls) || ls < 1) return null;
|
|
132
|
+
if (!Number.isInteger(le) || le < ls) return null;
|
|
133
|
+
|
|
134
|
+
let normSide = typeof stop.side === 'string' ? stop.side.trim().toUpperCase() : 'RIGHT';
|
|
135
|
+
if (normSide !== 'LEFT' && normSide !== 'RIGHT') normSide = 'RIGHT';
|
|
136
|
+
|
|
137
|
+
// Context stops reference lines outside the rendered diff. The frontend
|
|
138
|
+
// renderer cannot anchor to rows that aren't in the DOM, so dropping them
|
|
139
|
+
// here keeps tours pointed only at lines a user can actually navigate to.
|
|
140
|
+
// Gap-expansion is a separate feature; see plans/semantic-hunk-summaries-and-tours.md.
|
|
141
|
+
if (stop.is_context === true) {
|
|
142
|
+
logger.info(
|
|
143
|
+
`${TOUR_LOG_PREFIX} dropping context stop ${filePath}:${ls}-${le} — gap expansion not yet supported in renderer`
|
|
144
|
+
);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!ctx.hunksByFile.has(filePath)) {
|
|
149
|
+
logger.warn(`${TOUR_LOG_PREFIX} dropping changed-file stop for file outside diff: ${filePath}`);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const lineSet = (normSide === 'LEFT' ? ctx.changedLines.left : ctx.changedLines.right).get(filePath);
|
|
153
|
+
if (!rangeIntersectsSet(ls, le, lineSet)) {
|
|
154
|
+
logger.warn(
|
|
155
|
+
`${TOUR_LOG_PREFIX} dropping changed-file stop ${filePath}:${ls}-${le} (${normSide}) — does not intersect changed lines`
|
|
156
|
+
);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
file_path: filePath,
|
|
162
|
+
side: normSide,
|
|
163
|
+
line_start: ls,
|
|
164
|
+
line_end: le,
|
|
165
|
+
title: title.slice(0, TOUR_TITLE_MAX),
|
|
166
|
+
description: description.slice(0, TOUR_DESCRIPTION_MAX)
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Generate a guided tour for a review and persist + broadcast it.
|
|
172
|
+
* @param {Object} params
|
|
173
|
+
* @param {Object} params.db
|
|
174
|
+
* @param {Object} params.config
|
|
175
|
+
* @param {number} params.reviewId
|
|
176
|
+
* @param {string} params.diffText - Full unified diff for the review snapshot.
|
|
177
|
+
* @param {string} params.worktreePath
|
|
178
|
+
* @param {Object} [params.reviewContext]
|
|
179
|
+
* @param {string} [params.diffHash] - Precomputed hash of `diffText`. When
|
|
180
|
+
* provided (e.g. from `kickOffTourJob`), used directly instead of being
|
|
181
|
+
* recomputed via `deps.hashDiff`. This makes the producer/consumer
|
|
182
|
+
* relationship between the kickoff and worker explicit rather than
|
|
183
|
+
* relying on the implicit invariant that `hashDiff` is deterministic
|
|
184
|
+
* across calls.
|
|
185
|
+
* @param {Object} [params._deps]
|
|
186
|
+
* @returns {Promise<{generated: boolean, stops: number, reason?: string}>}
|
|
187
|
+
*/
|
|
188
|
+
async function generateTourForReview({
|
|
189
|
+
db,
|
|
190
|
+
config,
|
|
191
|
+
reviewId,
|
|
192
|
+
diffText,
|
|
193
|
+
worktreePath,
|
|
194
|
+
reviewContext,
|
|
195
|
+
diffHash: providedDiffHash,
|
|
196
|
+
abortSignal,
|
|
197
|
+
_deps
|
|
198
|
+
}) {
|
|
199
|
+
const deps = { ...defaults, ..._deps };
|
|
200
|
+
|
|
201
|
+
// Helper: convert "aborted between awaits" into a rejected promise so we
|
|
202
|
+
// never persist partial work after a user cancel. Throwing an AbortError
|
|
203
|
+
// also tells the BackgroundQueue this completed via cancellation, which
|
|
204
|
+
// it surfaces in the broadcast event.
|
|
205
|
+
const throwIfAborted = () => {
|
|
206
|
+
if (abortSignal && abortSignal.aborted) {
|
|
207
|
+
const err = new Error(`${TOUR_LOG_PREFIX} review ${reviewId}: cancelled`);
|
|
208
|
+
err.name = 'AbortError';
|
|
209
|
+
err.isCancellation = true;
|
|
210
|
+
throw err;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (!diffText || !diffText.trim()) {
|
|
215
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: no diff text; skipping`);
|
|
216
|
+
return { generated: false, stops: 0, reason: 'no_diff' };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const hunksByFile = deps.parseUnifiedDiffHunks(diffText);
|
|
220
|
+
if (!hunksByFile || hunksByFile.size === 0) {
|
|
221
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: diff parsed to zero files; skipping`);
|
|
222
|
+
return { generated: false, stops: 0, reason: 'empty_diff' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Use the precomputed hash from the caller (kickOffTourJob) when
|
|
226
|
+
// available — that's the value stamped on `latestRequestedDiffHash`, so
|
|
227
|
+
// sharing it removes any "hashDiff must be deterministic" hidden
|
|
228
|
+
// contract between the two functions. Fall back to recomputing for
|
|
229
|
+
// direct callers (tests, ad-hoc invocations).
|
|
230
|
+
const diffHash = (typeof providedDiffHash === 'string' && providedDiffHash)
|
|
231
|
+
? providedDiffHash
|
|
232
|
+
: deps.hashDiff(diffText);
|
|
233
|
+
|
|
234
|
+
const tourRepo = new deps.TourRepository(db);
|
|
235
|
+
const existing = await tourRepo.get(reviewId);
|
|
236
|
+
if (existing && existing.diff_hash === diffHash) {
|
|
237
|
+
logger.debug(`${TOUR_LOG_PREFIX} review ${reviewId}: cached tour matches diff_hash; skipping`);
|
|
238
|
+
deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
|
|
239
|
+
return { generated: false, stops: 0, reason: 'cached' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Skip the expensive provider call if a newer kickoff has already
|
|
243
|
+
// superseded this one before we even started exploring.
|
|
244
|
+
const latestBeforeProvider = latestRequestedDiffHash.get(reviewId);
|
|
245
|
+
if (latestBeforeProvider !== undefined && latestBeforeProvider !== diffHash) {
|
|
246
|
+
logger.debug(
|
|
247
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: superseded before provider call (have ${latestBeforeProvider}, this ${diffHash}); skipping`
|
|
248
|
+
);
|
|
249
|
+
return { generated: false, stops: 0, superseded: true, reason: 'superseded' };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const preferredProviderId = deps.getTourProvider(config);
|
|
253
|
+
const providerId = deps.resolveNonExecutableProviderId(preferredProviderId);
|
|
254
|
+
if (!providerId) {
|
|
255
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: no agentic provider available; skipping`);
|
|
256
|
+
return { generated: false, stops: 0, reason: 'no_provider' };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let provider;
|
|
260
|
+
let resolvedModel;
|
|
261
|
+
try {
|
|
262
|
+
const initial = deps.createProvider(providerId);
|
|
263
|
+
const ProviderClass = initial.constructor;
|
|
264
|
+
resolvedModel = deps.getTourModel(config, ProviderClass);
|
|
265
|
+
provider = deps.createProvider(providerId, resolvedModel);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: provider unavailable (${err.message}); skipping`);
|
|
268
|
+
return { generated: false, stops: 0, reason: 'provider_error' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const ctx = reviewContext || {};
|
|
272
|
+
const prompt = deps.buildTourPrompt({
|
|
273
|
+
prTitle: ctx.prTitle,
|
|
274
|
+
prDescription: ctx.prDescription,
|
|
275
|
+
scriptCommand: buildScriptCommand(worktreePath),
|
|
276
|
+
changedFiles: Array.from(hunksByFile.keys()),
|
|
277
|
+
worktreePath
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
throwIfAborted();
|
|
281
|
+
let result;
|
|
282
|
+
try {
|
|
283
|
+
result = await provider.execute(prompt, {
|
|
284
|
+
cwd: worktreePath,
|
|
285
|
+
logPrefix: TOUR_LOG_PREFIX,
|
|
286
|
+
abortSignal,
|
|
287
|
+
});
|
|
288
|
+
} catch (execErr) {
|
|
289
|
+
if (execErr && (execErr.name === 'AbortError' || execErr.isCancellation)) {
|
|
290
|
+
logger.info(`${TOUR_LOG_PREFIX} review ${reviewId}: cancelled during provider call`);
|
|
291
|
+
throw execErr;
|
|
292
|
+
}
|
|
293
|
+
logger.error(`${TOUR_LOG_PREFIX} review ${reviewId}: provider error: ${execErr.message}`);
|
|
294
|
+
return { generated: false, stops: 0, reason: 'provider_throw' };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let data;
|
|
298
|
+
if (result && Array.isArray(result.stops)) {
|
|
299
|
+
data = { stops: result.stops };
|
|
300
|
+
} else if (result && result.data && (result.parsed || result.success)) {
|
|
301
|
+
data = result.data;
|
|
302
|
+
} else {
|
|
303
|
+
const raw = (result && result.raw) || '';
|
|
304
|
+
const extracted = deps.extractJSON(raw, 'tour', TOUR_LOG_PREFIX);
|
|
305
|
+
if (!extracted || !extracted.success) {
|
|
306
|
+
const errMsg = extracted && extracted.error ? extracted.error : 'unknown error';
|
|
307
|
+
logger.warn(`${TOUR_LOG_PREFIX} review ${reviewId}: JSON parse failed: ${errMsg}`);
|
|
308
|
+
return { generated: false, stops: 0, reason: 'malformed' };
|
|
309
|
+
}
|
|
310
|
+
data = extracted.data;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!data || !Array.isArray(data.stops)) {
|
|
314
|
+
logger.warn(`${TOUR_LOG_PREFIX} review ${reviewId}: response missing stops[]`);
|
|
315
|
+
return { generated: false, stops: 0, reason: 'malformed' };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const changedLines = buildChangedLineIndex(hunksByFile);
|
|
319
|
+
const validationCtx = {
|
|
320
|
+
hunksByFile,
|
|
321
|
+
changedLines,
|
|
322
|
+
worktreePath
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const validated = [];
|
|
326
|
+
for (const stop of data.stops) {
|
|
327
|
+
if (validated.length >= TOUR_MAX_STOPS) break;
|
|
328
|
+
const cleaned = await validateStop(stop, validationCtx);
|
|
329
|
+
if (!cleaned) continue;
|
|
330
|
+
|
|
331
|
+
// Drop stops that overlap an already-accepted stop on the same
|
|
332
|
+
// (file_path, side). Two ranges [a,b] and [c,d] overlap iff a <= d && c <= b.
|
|
333
|
+
const overlaps = validated.some((accepted) => (
|
|
334
|
+
accepted.file_path === cleaned.file_path
|
|
335
|
+
&& accepted.side === cleaned.side
|
|
336
|
+
&& cleaned.line_start <= accepted.line_end
|
|
337
|
+
&& accepted.line_start <= cleaned.line_end
|
|
338
|
+
));
|
|
339
|
+
if (overlaps) {
|
|
340
|
+
logger.debug(
|
|
341
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: dropping overlapping stop ${cleaned.file_path}:${cleaned.line_start}-${cleaned.line_end} (${cleaned.side})`
|
|
342
|
+
);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
validated.push(cleaned);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (validated.length < TOUR_PERSIST_MIN_STOPS) {
|
|
350
|
+
logger.info(
|
|
351
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: ${validated.length} valid stops after filtering; below persist threshold (${TOUR_PERSIST_MIN_STOPS}) — not tour-worthy`
|
|
352
|
+
);
|
|
353
|
+
return { generated: false, stops: 0, reason: 'not_tour_worthy' };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
throwIfAborted();
|
|
357
|
+
// Last-chance superseded check: another kickoff with a different diff
|
|
358
|
+
// arrived while we were exploring/validating. Skip the write so we don't
|
|
359
|
+
// overwrite a tour that's about to be regenerated for a newer diff.
|
|
360
|
+
const latestBeforeUpsert = latestRequestedDiffHash.get(reviewId);
|
|
361
|
+
if (latestBeforeUpsert !== undefined && latestBeforeUpsert !== diffHash) {
|
|
362
|
+
logger.debug(
|
|
363
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: superseded before upsert (have ${latestBeforeUpsert}, this ${diffHash}); skipping persist`
|
|
364
|
+
);
|
|
365
|
+
return { generated: false, stops: 0, superseded: true, reason: 'superseded' };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
await tourRepo.upsert({
|
|
369
|
+
review_id: reviewId,
|
|
370
|
+
stops: JSON.stringify(validated),
|
|
371
|
+
diff_hash: diffHash,
|
|
372
|
+
provider: providerId,
|
|
373
|
+
model: resolvedModel
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// Intentionally do NOT clear `latestRequestedDiffHash` on success. A
|
|
377
|
+
// predecessor worker whose cancel was lost (e.g., the provider's HTTP call
|
|
378
|
+
// didn't honor AbortSignal) may still be poised to reach its pre-upsert
|
|
379
|
+
// check. If we deleted our entry, that predecessor would see `undefined`,
|
|
380
|
+
// pass its `latestBeforeUpsert !== undefined && ...` guard, and overwrite
|
|
381
|
+
// our fresh row with a tour for the now-stale diff. Leaving the entry set
|
|
382
|
+
// to our hash makes the predecessor's hash mismatch and skip. The map
|
|
383
|
+
// grows by at most one entry per review until the next kickoff overwrites.
|
|
384
|
+
deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
|
|
385
|
+
|
|
386
|
+
logger.info(
|
|
387
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: persisted tour with ${validated.length} stops (diff_hash=${diffHash})`
|
|
388
|
+
);
|
|
389
|
+
return { generated: true, stops: validated.length };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Gate the tour job and enqueue it on the background queue.
|
|
394
|
+
*
|
|
395
|
+
* Dedup via the queue's `(reviewId, 'tour')` key — concurrent kickoffs share
|
|
396
|
+
* a single execution. Staleness is checked inside the generator itself via
|
|
397
|
+
* `diff_hash` comparison.
|
|
398
|
+
*
|
|
399
|
+
* `trigger` controls how the enabled/auto_generate config interacts with
|
|
400
|
+
* kickoff:
|
|
401
|
+
* - `'auto'` (default): requires `tours.enabled && tours.auto_generate`
|
|
402
|
+
* - `'manual'`: only requires `tours.enabled` (user-initiated start)
|
|
403
|
+
*
|
|
404
|
+
* @param {Object} params
|
|
405
|
+
* @param {Object} params.db
|
|
406
|
+
* @param {Object} params.config
|
|
407
|
+
* @param {number} params.reviewId
|
|
408
|
+
* @param {string} params.diffText
|
|
409
|
+
* @param {string} params.worktreePath
|
|
410
|
+
* @param {Object} [params.reviewContext]
|
|
411
|
+
* @param {'auto'|'manual'} [params.trigger='auto']
|
|
412
|
+
* @param {Object} [params._deps]
|
|
413
|
+
* @returns {Promise<Object>|null}
|
|
414
|
+
*/
|
|
415
|
+
async function kickOffTourJob({
|
|
416
|
+
db,
|
|
417
|
+
config,
|
|
418
|
+
reviewId,
|
|
419
|
+
diffText,
|
|
420
|
+
worktreePath,
|
|
421
|
+
reviewContext,
|
|
422
|
+
trigger = 'auto',
|
|
423
|
+
_deps
|
|
424
|
+
}) {
|
|
425
|
+
const deps = { ...defaults, ...(_deps || {}) };
|
|
426
|
+
|
|
427
|
+
if (!deps.getTourEnabled(config)) return null;
|
|
428
|
+
|
|
429
|
+
if (!reviewId) {
|
|
430
|
+
logger.debug('kickOffTourJob skipped: missing reviewId');
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const queue = deps.backgroundQueue || require('./background-queue').backgroundQueue;
|
|
435
|
+
|
|
436
|
+
// Cancel any in-flight tour job whose diff hash no longer matches. This is
|
|
437
|
+
// load-bearing for both cost (stops a stale provider call from burning
|
|
438
|
+
// tokens) and correctness — the in-generator superseded check relies on
|
|
439
|
+
// `latestRequestedDiffHash` reflecting the current desired snapshot. Calling
|
|
440
|
+
// this even when the new diff is empty (a valid terminal snapshot after a
|
|
441
|
+
// refresh or scope change) keeps the old worker from observing a stale,
|
|
442
|
+
// matching hash and persisting a tour the user has moved past.
|
|
443
|
+
const cancelActiveTourJob = () => {
|
|
444
|
+
if (
|
|
445
|
+
typeof queue.findActiveJobType === 'function' &&
|
|
446
|
+
queue.findActiveJobType(reviewId, 'tour') &&
|
|
447
|
+
typeof queue.cancel === 'function'
|
|
448
|
+
) {
|
|
449
|
+
queue.cancel(reviewId, 'tour');
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
if (!diffText || !worktreePath) {
|
|
454
|
+
const missing = [];
|
|
455
|
+
if (!diffText) missing.push('diffText');
|
|
456
|
+
if (!worktreePath) missing.push('worktreePath');
|
|
457
|
+
logger.debug(`kickOffTourJob skipped: missing ${missing.join(', ')}`);
|
|
458
|
+
// Stamp a sentinel so any in-flight worker's pre-upsert check sees a
|
|
459
|
+
// different value than its own hash and bails. The sentinel is intentionally
|
|
460
|
+
// non-hashlike so a real diff can never collide with it.
|
|
461
|
+
//
|
|
462
|
+
// Run cleanup unconditionally (not gated on a prior in-process hash). The
|
|
463
|
+
// map is in-memory: after a server restart it's empty, but a persisted
|
|
464
|
+
// row from a pre-restart session can still exist. Without unconditional
|
|
465
|
+
// cleanup, the first post-restart empty-diff transition would leave a
|
|
466
|
+
// stale row that GET /api/reviews/:id/tour serves verbatim (no diff_hash
|
|
467
|
+
// check), pointing the UI at stops no longer in the diff. `deleteByReview`
|
|
468
|
+
// is idempotent; the `changes > 0` guard below suppresses the broadcast
|
|
469
|
+
// on a fresh review that never had a tour.
|
|
470
|
+
latestRequestedDiffHash.set(reviewId, '__empty__');
|
|
471
|
+
cancelActiveTourJob();
|
|
472
|
+
try {
|
|
473
|
+
const repo = new deps.TourRepository(db);
|
|
474
|
+
const result = await repo.deleteByReview(reviewId);
|
|
475
|
+
if (result && result.changes > 0) {
|
|
476
|
+
deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
|
|
477
|
+
}
|
|
478
|
+
} catch (err) {
|
|
479
|
+
logger.warn(
|
|
480
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: failed to delete stale tour row on empty-diff cleanup: ${err.message}`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Stamp the latest requested diff hash BEFORE enqueueing so that an
|
|
487
|
+
// in-flight job (potentially started by a previous kickoff with an older
|
|
488
|
+
// diff) can observe it and decide to skip persistence.
|
|
489
|
+
const diffHash = deps.hashDiff(diffText);
|
|
490
|
+
const previousHash = latestRequestedDiffHash.get(reviewId);
|
|
491
|
+
latestRequestedDiffHash.set(reviewId, diffHash);
|
|
492
|
+
|
|
493
|
+
// Belt-and-suspenders: if a tour job is in flight with a different diff
|
|
494
|
+
// hash, cancel it now. The worker's staleness check at persistence time
|
|
495
|
+
// would discard its output anyway (the suspenders); cancelling stops the
|
|
496
|
+
// upstream provider call from burning more tokens (the belt). The order
|
|
497
|
+
// matters: the hash above is already stamped, so any not-yet-cancelled
|
|
498
|
+
// worker observing the map sees the new hash and skips persistence.
|
|
499
|
+
if (previousHash && previousHash !== diffHash) {
|
|
500
|
+
cancelActiveTourJob();
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// auto_generate gate, applied here — AFTER cancellation, the empty-diff
|
|
504
|
+
// cleanup, and the latestRequestedDiffHash stamp above — so all of that
|
|
505
|
+
// stale-state hygiene runs regardless of trigger. With `auto_generate`
|
|
506
|
+
// off, an auto-triggered kickoff (e.g. a refresh/scope-change re-invoke
|
|
507
|
+
// after a manual start) still cancels the in-flight job against the old
|
|
508
|
+
// diff and stamps the new hash; it simply declines to enqueue a
|
|
509
|
+
// replacement. Manual kickoffs always proceed.
|
|
510
|
+
//
|
|
511
|
+
// Before declining, reconcile the persisted row against the new diff. GET
|
|
512
|
+
// /api/reviews/:id/tour serves the row verbatim (no diff_hash check) and
|
|
513
|
+
// the frontend treats any non-empty stops as ready, so a stale row would
|
|
514
|
+
// map the old tour onto the new diff AND block the manual-generate click
|
|
515
|
+
// path (stops aren't empty). Compare against the PERSISTED diff_hash, not
|
|
516
|
+
// `previousHash`: `latestRequestedDiffHash` is empty after a server
|
|
517
|
+
// restart, so a previousHash-based guard would let a pre-restart stale
|
|
518
|
+
// row slip through. Same shape as the empty-diff branch above.
|
|
519
|
+
if (trigger !== 'manual' && !deps.getTourAutoGenerate(config)) {
|
|
520
|
+
try {
|
|
521
|
+
const repo = new deps.TourRepository(db);
|
|
522
|
+
const row = await repo.get(reviewId);
|
|
523
|
+
if (row && row.diff_hash !== diffHash) {
|
|
524
|
+
const result = await repo.deleteByReview(reviewId);
|
|
525
|
+
if (result && result.changes > 0) {
|
|
526
|
+
deps.broadcastReviewEvent(reviewId, { type: 'review:tour_ready' });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} catch (err) {
|
|
530
|
+
logger.warn(
|
|
531
|
+
`${TOUR_LOG_PREFIX} review ${reviewId}: failed to delete stale tour row on auto_generate=false gate: ${err.message}`
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const worker = deps.generateTourForReview || generateTourForReview;
|
|
538
|
+
return queue.enqueue(reviewId, 'tour', (signal) =>
|
|
539
|
+
worker({
|
|
540
|
+
db,
|
|
541
|
+
config,
|
|
542
|
+
reviewId,
|
|
543
|
+
diffText,
|
|
544
|
+
worktreePath,
|
|
545
|
+
reviewContext,
|
|
546
|
+
// Thread the hash we already computed through to the worker rather
|
|
547
|
+
// than relying on the (implicit) invariant that hashDiff produces
|
|
548
|
+
// the same output for the same diffText in both call sites.
|
|
549
|
+
diffHash,
|
|
550
|
+
// The BackgroundQueue calls our thunk as `fn(signal)`; pass it on so
|
|
551
|
+
// a user-initiated cancel reaches the upstream provider call.
|
|
552
|
+
abortSignal: signal,
|
|
553
|
+
_deps
|
|
554
|
+
})
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
module.exports = {
|
|
559
|
+
generateTourForReview,
|
|
560
|
+
kickOffTourJob,
|
|
561
|
+
// Exported for tests
|
|
562
|
+
parseHunkHeader,
|
|
563
|
+
buildChangedLineIndex,
|
|
564
|
+
buildScriptCommand,
|
|
565
|
+
validateStop,
|
|
566
|
+
latestRequestedDiffHash,
|
|
567
|
+
resetLatestRequestedDiffHash: () => latestRequestedDiffHash.clear()
|
|
568
|
+
};
|