@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.
Files changed (46) hide show
  1. package/package.json +15 -20
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  5. package/public/css/pr.css +603 -6
  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/local.js +6 -0
  10. package/public/js/modules/cancel-background-job.js +183 -0
  11. package/public/js/modules/hunk-summary-renderer.js +116 -0
  12. package/public/js/modules/storage-cleanup.js +16 -0
  13. package/public/js/modules/tour-renderer.js +725 -0
  14. package/public/js/pr.js +1276 -2
  15. package/public/js/utils/modal-detection.js +77 -0
  16. package/public/local.html +17 -0
  17. package/public/pr.html +17 -0
  18. package/src/ai/abort-signal-wiring.js +130 -0
  19. package/src/ai/background-queue.js +290 -0
  20. package/src/ai/claude-cli.js +1 -1
  21. package/src/ai/claude-provider.js +50 -7
  22. package/src/ai/codex-provider.js +28 -5
  23. package/src/ai/copilot-provider.js +22 -3
  24. package/src/ai/cursor-agent-provider.js +22 -6
  25. package/src/ai/executable-provider.js +4 -19
  26. package/src/ai/gemini-provider.js +22 -5
  27. package/src/ai/hunk-hashing.js +161 -0
  28. package/src/ai/index.js +2 -0
  29. package/src/ai/opencode-provider.js +21 -5
  30. package/src/ai/pi-provider.js +21 -5
  31. package/src/ai/prompts/hunk-summary.js +199 -0
  32. package/src/ai/prompts/tour.js +232 -0
  33. package/src/ai/provider.js +21 -1
  34. package/src/ai/summary-generator.js +469 -0
  35. package/src/ai/tour-generator.js +568 -0
  36. package/src/config.js +114 -0
  37. package/src/database.js +282 -1
  38. package/src/local-review.js +189 -169
  39. package/src/routes/config.js +16 -1
  40. package/src/routes/context-files.js +2 -29
  41. package/src/routes/local.js +311 -4
  42. package/src/routes/middleware/validate-review-id.js +53 -0
  43. package/src/routes/pr.js +259 -4
  44. package/src/routes/reviews.js +145 -29
  45. package/src/utils/diff-hunks.js +65 -0
  46. package/src/utils/json-extractor.js +5 -2
@@ -0,0 +1,469 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ const crypto = require('crypto');
4
+ const path = require('path');
5
+ const logger = require('../utils/logger');
6
+
7
+ const defaults = {
8
+ parseUnifiedDiffHunks: require('../utils/diff-hunks').parseUnifiedDiffHunks,
9
+ hashHunk: require('./hunk-hashing').hashHunk,
10
+ isTrivialHunk: require('./hunk-hashing').isTrivialHunk,
11
+ HunkSummaryRepository: require('../database').HunkSummaryRepository,
12
+ createProvider: require('./provider').createProvider,
13
+ resolveNonExecutableProviderId: require('./provider').resolveNonExecutableProviderId,
14
+ getSummaryProvider: require('../config').getSummaryProvider,
15
+ getSummaryModel: require('../config').getSummaryModel,
16
+ getSummaryEnabled: require('../config').getSummaryEnabled,
17
+ getSummaryAutoGenerate: require('../config').getSummaryAutoGenerate,
18
+ buildHunkSummaryPrompt: require('./prompts/hunk-summary').buildHunkSummaryPrompt,
19
+ extractJSON: require('../utils/json-extractor').extractJSON,
20
+ getGeneratedFilePatterns: require('../git/gitattributes').getGeneratedFilePatterns,
21
+ broadcastReviewEvent: require('../events/review-events').broadcastReviewEvent,
22
+ hashDiff: (diffText) => crypto.createHash('sha256').update(diffText).digest('hex').slice(0, 16),
23
+ backgroundQueue: null
24
+ };
25
+
26
+ /**
27
+ * Count '+' lines in parsed hunks to gate summary generation by added-line volume.
28
+ * Hunk-header lines are not '+'-prefixed by `parseUnifiedDiffHunks`, so this is safe.
29
+ * @param {Map<string, Array<{header: string, lines: string[]}>>} hunksByFile
30
+ * @returns {number}
31
+ */
32
+ function countAddedLines(hunksByFile) {
33
+ let total = 0;
34
+ for (const hunks of hunksByFile.values()) {
35
+ for (const hunk of hunks) {
36
+ for (const line of hunk.lines) {
37
+ if (line.startsWith('+') && !line.startsWith('+++')) total++;
38
+ }
39
+ }
40
+ }
41
+ return total;
42
+ }
43
+
44
+ /**
45
+ * Generate hunk summaries for a review's diff and persist + broadcast them.
46
+ * @param {Object} params
47
+ * @param {Object} params.db
48
+ * @param {Object} params.config
49
+ * @param {number} params.reviewId
50
+ * @param {string} params.diffText
51
+ * @param {string} params.worktreePath
52
+ * @param {Object} [params.reviewContext]
53
+ * @param {Object} [params._deps]
54
+ * @returns {Promise<{filesProcessed: number, hunksPersisted: number}>}
55
+ */
56
+ async function generateSummariesForReview({
57
+ db,
58
+ config,
59
+ reviewId,
60
+ diffText,
61
+ worktreePath,
62
+ reviewContext,
63
+ abortSignal,
64
+ _deps
65
+ }) {
66
+ const deps = { ...defaults, ..._deps };
67
+
68
+ // Helper: bail between per-file iterations once the user cancels.
69
+ // Summaries are written file-by-file, so a mid-loop abort can leave a
70
+ // subset of files persisted — that is intentional ("stop burning more
71
+ // tokens"). The check only prevents doing MORE work, not undoing
72
+ // already-persisted summaries.
73
+ const isAborted = () => abortSignal && abortSignal.aborted;
74
+
75
+ if (!diffText || !diffText.trim()) {
76
+ return { filesProcessed: 0, hunksPersisted: 0 };
77
+ }
78
+
79
+ const hunksByFile = deps.parseUnifiedDiffHunks(diffText);
80
+ if (!hunksByFile || hunksByFile.size === 0) {
81
+ return { filesProcessed: 0, hunksPersisted: 0 };
82
+ }
83
+
84
+ const summariesCfg = (config && config.summaries) || {};
85
+ const maxFiles = (summariesCfg.max_files != null) ? summariesCfg.max_files : 50;
86
+ if (hunksByFile.size > maxFiles) {
87
+ logger.info(
88
+ `Skipping hunk summaries for review ${reviewId}: ${hunksByFile.size} files exceeds summaries.max_files=${maxFiles}`
89
+ );
90
+ return { filesProcessed: 0, hunksPersisted: 0, oversized: true };
91
+ }
92
+
93
+ const maxLinesAdded = (summariesCfg.max_lines_added != null) ? summariesCfg.max_lines_added : 3000;
94
+ const linesAdded = countAddedLines(hunksByFile);
95
+ if (linesAdded > maxLinesAdded) {
96
+ logger.info(
97
+ `Skipping hunk summaries for review ${reviewId}: ${linesAdded} added lines exceeds summaries.max_lines_added=${maxLinesAdded}`
98
+ );
99
+ return { filesProcessed: 0, hunksPersisted: 0, oversized: true };
100
+ }
101
+
102
+ let isGeneratedFile = () => false;
103
+ try {
104
+ const parser = await deps.getGeneratedFilePatterns(worktreePath);
105
+ isGeneratedFile = (filePath) => parser.isGenerated(filePath);
106
+ } catch (err) {
107
+ logger.warn(`Failed to load .gitattributes for review ${reviewId}: ${err.message}`);
108
+ }
109
+
110
+ const preferredProviderId = deps.getSummaryProvider(config);
111
+ const providerId = deps.resolveNonExecutableProviderId(preferredProviderId);
112
+ if (!providerId) {
113
+ logger.info(
114
+ `Hunk summaries skipped for review ${reviewId}: no non-executable provider available`
115
+ );
116
+ return { filesProcessed: 0, hunksPersisted: 0 };
117
+ }
118
+
119
+ let provider;
120
+ let resolvedModel;
121
+ try {
122
+ const initialProvider = deps.createProvider(providerId);
123
+ const ProviderClass = initialProvider.constructor;
124
+ resolvedModel = deps.getSummaryModel(config, ProviderClass);
125
+ provider = deps.createProvider(providerId, resolvedModel);
126
+ } catch (err) {
127
+ logger.info(
128
+ `Hunk summaries skipped for review ${reviewId}: summary provider unavailable (${err.message})`
129
+ );
130
+ return { filesProcessed: 0, hunksPersisted: 0 };
131
+ }
132
+
133
+ const repo = new deps.HunkSummaryRepository(db);
134
+
135
+ const effectiveContext = { ...(reviewContext || {}) };
136
+ if (!effectiveContext.changedFiles) {
137
+ effectiveContext.changedFiles = Array.from(hunksByFile.keys());
138
+ }
139
+
140
+ let filesProcessed = 0;
141
+ let hunksPersisted = 0;
142
+
143
+ for (const [filePath, hunks] of hunksByFile.entries()) {
144
+ if (isAborted()) {
145
+ // Cancelled mid-loop: surface as an AbortError so the queue's
146
+ // broadcast carries `cancelled: true`. Any per-file work already
147
+ // persisted stays — see comment on `isAborted` declaration.
148
+ const err = new Error(`Hunk summaries for review ${reviewId} cancelled`);
149
+ err.name = 'AbortError';
150
+ err.isCancellation = true;
151
+ throw err;
152
+ }
153
+ // Use the basename for log readability — full repo-relative paths can be
154
+ // long enough to clutter each log line, and within a single review the
155
+ // basename is almost always unique enough to identify the file.
156
+ const summaryPrefix = `[Summary ${path.basename(filePath)}]`;
157
+ try {
158
+ const classified = hunks.map((hunk) => {
159
+ const content = [hunk.header, ...hunk.lines].join('\n');
160
+ const contentHash = deps.hashHunk(filePath, content);
161
+ const triviality = deps.isTrivialHunk(hunk, filePath, { isGeneratedFile });
162
+ return { hunk, contentHash, triviality };
163
+ });
164
+
165
+ const allHashes = classified.map((c) => c.contentHash);
166
+ const existingRows = await repo.getByHashes(reviewId, allHashes);
167
+ const existingHashes = new Set(existingRows.map((row) => row.content_hash));
168
+
169
+ const trivialRowsToPersist = [];
170
+ const missing = [];
171
+ for (const item of classified) {
172
+ if (existingHashes.has(item.contentHash)) continue;
173
+ if (item.triviality.trivial) {
174
+ trivialRowsToPersist.push({
175
+ review_id: reviewId,
176
+ file_path: filePath,
177
+ content_hash: item.contentHash,
178
+ summary_text: null,
179
+ trivial_reason: item.triviality.reason,
180
+ provider: null,
181
+ model: null
182
+ });
183
+ } else {
184
+ missing.push(item);
185
+ }
186
+ }
187
+
188
+ if (trivialRowsToPersist.length > 0) {
189
+ await repo.upsertMany(trivialRowsToPersist);
190
+ hunksPersisted += trivialRowsToPersist.length;
191
+ }
192
+
193
+ if (missing.length > 0) {
194
+ const prompt = deps.buildHunkSummaryPrompt({
195
+ filePath,
196
+ hunks: missing.map((m) => m.hunk),
197
+ prTitle: effectiveContext.prTitle,
198
+ prDescription: effectiveContext.prDescription,
199
+ changedFiles: effectiveContext.changedFiles,
200
+ cwd: worktreePath
201
+ });
202
+
203
+ let result;
204
+ try {
205
+ result = await provider.execute(prompt, {
206
+ cwd: worktreePath,
207
+ logPrefix: summaryPrefix,
208
+ abortSignal,
209
+ });
210
+ } catch (execErr) {
211
+ if (execErr && (execErr.name === 'AbortError' || execErr.isCancellation)) {
212
+ // Propagate cancellation up so the queue marks the broadcast as
213
+ // cancelled instead of "completed normally with no output".
214
+ throw execErr;
215
+ }
216
+ // (Intentional: see retry-on-reload note below — no sentinel row.)
217
+ logger.error(`${summaryPrefix} Hunk summary provider error for ${filePath}: ${execErr.message}`);
218
+ await broadcastFile(deps, repo, reviewId, filePath);
219
+ filesProcessed++;
220
+ continue;
221
+ }
222
+
223
+ let data;
224
+ let topLevelMalformed = false;
225
+ if (result && Array.isArray(result.summaries)) {
226
+ data = { summaries: result.summaries };
227
+ } else if (result && result.data && (result.parsed || result.success)) {
228
+ data = result.data;
229
+ } else {
230
+ const raw = (result && result.raw) || '';
231
+ const extracted = deps.extractJSON(raw, 'hunk-summary', summaryPrefix);
232
+ if (!extracted || !extracted.success) {
233
+ const errMsg = extracted && extracted.error ? extracted.error : 'unknown error';
234
+ logger.warn(`${summaryPrefix} Hunk summary JSON parse failed: ${errMsg}`);
235
+ topLevelMalformed = true;
236
+ } else {
237
+ data = extracted.data;
238
+ }
239
+ }
240
+
241
+ if (!topLevelMalformed && (!data || !Array.isArray(data.summaries))) {
242
+ logger.warn(`${summaryPrefix} Hunk summary response missing summaries[]`);
243
+ topLevelMalformed = true;
244
+ }
245
+
246
+ if (topLevelMalformed) {
247
+ // Asymmetry vs per-slot malformed handling, intentional:
248
+ // - Envelope malformed (this branch): no sentinel persisted; the
249
+ // hunks are eligible for re-enqueue on the next reload, the same
250
+ // as a provider exception (see `execErr` catch above). Truncated
251
+ // streams, stray markdown fences, and "model rambled past the
252
+ // JSON" are transient failures and should not lock out hunks.
253
+ // - Per-slot malformed (below): the model returned a valid envelope
254
+ // but a specific entry was missing/wrong-type. That IS persisted
255
+ // as a `model_malformed` sentinel because the model spoke
256
+ // coherently for the file but not for that slot — re-enqueueing
257
+ // would just produce the same bad output.
258
+ await broadcastFile(deps, repo, reviewId, filePath);
259
+ filesProcessed++;
260
+ continue;
261
+ }
262
+
263
+ // Per-hunk classification. For each slot in `missing`, choose the best
264
+ // outcome from the model's entries:
265
+ // - valid (non-empty string) > model_skipped (null) > model_malformed
266
+ // Slots the model never returned for fall through to model_malformed.
267
+ // No truncation: the prompt sets the length budget; we trust the model.
268
+ const VALID = 0;
269
+ const SKIPPED = 1;
270
+ const MALFORMED = 2;
271
+ const slotState = new Array(missing.length).fill(null);
272
+
273
+ const setSlot = (idx, kind, summary) => {
274
+ const current = slotState[idx];
275
+ if (!current || kind < current.kind) {
276
+ slotState[idx] = { kind, summary };
277
+ }
278
+ };
279
+
280
+ for (const item of data.summaries) {
281
+ if (!item || typeof item.index !== 'number') continue;
282
+ const idx = item.index - 1;
283
+ if (idx < 0 || idx >= missing.length) continue;
284
+
285
+ if (typeof item.summary === 'string' && item.summary.length > 0) {
286
+ setSlot(idx, VALID, item.summary);
287
+ } else if (item.summary === null) {
288
+ setSlot(idx, SKIPPED, null);
289
+ } else {
290
+ // undefined, empty string, wrong type
291
+ setSlot(idx, MALFORMED, null);
292
+ }
293
+ }
294
+
295
+ const rowsToPersist = [];
296
+ for (let i = 0; i < missing.length; i++) {
297
+ const state = slotState[i] || { kind: MALFORMED, summary: null };
298
+ if (state.kind === VALID) {
299
+ rowsToPersist.push({
300
+ review_id: reviewId,
301
+ file_path: filePath,
302
+ content_hash: missing[i].contentHash,
303
+ summary_text: state.summary,
304
+ trivial_reason: null,
305
+ provider: providerId,
306
+ model: resolvedModel
307
+ });
308
+ } else {
309
+ rowsToPersist.push({
310
+ review_id: reviewId,
311
+ file_path: filePath,
312
+ content_hash: missing[i].contentHash,
313
+ summary_text: null,
314
+ trivial_reason: state.kind === SKIPPED ? 'model_skipped' : 'model_malformed',
315
+ provider: providerId,
316
+ model: resolvedModel
317
+ });
318
+ }
319
+ }
320
+
321
+ if (rowsToPersist.length > 0) {
322
+ await repo.upsertMany(rowsToPersist);
323
+ hunksPersisted += rowsToPersist.length;
324
+ }
325
+ }
326
+
327
+ await broadcastFile(deps, repo, reviewId, filePath);
328
+ filesProcessed++;
329
+ } catch (fileErr) {
330
+ // AbortErrors must escape the file-level recover-and-continue path —
331
+ // otherwise we'd silently swallow a user cancel and move on to the
332
+ // next file, defeating the whole point of cancellation.
333
+ if (fileErr && (fileErr.name === 'AbortError' || fileErr.isCancellation)) {
334
+ throw fileErr;
335
+ }
336
+ logger.error(`${summaryPrefix} Hunk summary processing failed: ${fileErr.message}`);
337
+ await broadcastFile(deps, repo, reviewId, filePath);
338
+ filesProcessed++;
339
+ }
340
+ }
341
+
342
+ return { filesProcessed, hunksPersisted };
343
+ }
344
+
345
+ /**
346
+ * Fetch all summaries for a file and broadcast them on the review channel.
347
+ * @param {Object} deps
348
+ * @param {Object} repo
349
+ * @param {number} reviewId
350
+ * @param {string} filePath
351
+ * @returns {Promise<void>}
352
+ */
353
+ async function broadcastFile(deps, repo, reviewId, filePath) {
354
+ try {
355
+ const fileRowsRaw = await repo.getByReviewAndFile(reviewId, filePath);
356
+ const fileRows = fileRowsRaw.map((r) => ({
357
+ file_path: r.file_path,
358
+ content_hash: r.content_hash,
359
+ summary_text: r.summary_text,
360
+ trivial_reason: r.trivial_reason
361
+ }));
362
+ deps.broadcastReviewEvent(reviewId, {
363
+ type: 'review:hunk_summaries_ready',
364
+ filePath,
365
+ summaries: fileRows
366
+ });
367
+ } catch (err) {
368
+ logger.warn(`[Summary ${path.basename(filePath)}] broadcast failed: ${err.message}`);
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Gate the summary job and enqueue it on the background queue.
374
+ *
375
+ * `trigger` controls how the enabled/auto_generate config interacts with
376
+ * kickoff:
377
+ * - `'auto'` (default): requires `summaries.enabled && summaries.auto_generate`
378
+ * - `'manual'`: only requires `summaries.enabled` (user-initiated start)
379
+ *
380
+ * @param {Object} params
381
+ * @param {Object} params.db
382
+ * @param {Object} params.config
383
+ * @param {number} params.reviewId
384
+ * @param {string} params.diffText
385
+ * @param {string} params.worktreePath
386
+ * @param {Object} [params.reviewContext]
387
+ * @param {'auto'|'manual'} [params.trigger='auto']
388
+ * @param {Object} [params._deps]
389
+ * @returns {Promise<{filesProcessed: number, hunksPersisted: number}>|null}
390
+ */
391
+ function kickOffSummaryJob({
392
+ db,
393
+ config,
394
+ reviewId,
395
+ diffText,
396
+ worktreePath,
397
+ reviewContext,
398
+ trigger = 'auto',
399
+ _deps
400
+ }) {
401
+ const deps = { ...defaults, ...(_deps || {}) };
402
+
403
+ if (!deps.getSummaryEnabled(config)) {
404
+ return null;
405
+ }
406
+
407
+ if (!reviewId) {
408
+ logger.debug('kickOffSummaryJob skipped: missing reviewId');
409
+ return null;
410
+ }
411
+
412
+ const queue = deps.backgroundQueue || require('./background-queue').backgroundQueue;
413
+
414
+ // Cancel any in-flight summaries job for this review whose digest no longer
415
+ // matches the new one. This is load-bearing for correctness, not just cost:
416
+ // generateSummariesForReview persists content_hash-keyed rows without any
417
+ // staleness check at upsert time (unlike tour-generator), so a stale worker
418
+ // that escapes cancellation will persist summaries for hunks the user has
419
+ // already moved past. Calling this even when the new diff is empty (refresh
420
+ // or scope change that removed all changes is a valid terminal snapshot)
421
+ // ensures the old job stops burning tokens and writing stale rows.
422
+ const cancelActiveSummariesJob = (excludeJobType) => {
423
+ if (typeof queue.findActiveJobType !== 'function') return;
424
+ const activeJobType = queue.findActiveJobType(reviewId, 'summaries');
425
+ if (activeJobType && activeJobType !== excludeJobType && typeof queue.cancel === 'function') {
426
+ queue.cancel(reviewId, activeJobType);
427
+ }
428
+ };
429
+
430
+ if (!diffText || !worktreePath) {
431
+ const missing = [];
432
+ if (!diffText) missing.push('diffText');
433
+ if (!worktreePath) missing.push('worktreePath');
434
+ logger.debug(`kickOffSummaryJob skipped: missing ${missing.join(', ')}`);
435
+ cancelActiveSummariesJob();
436
+ return null;
437
+ }
438
+
439
+ const digest = deps.hashDiff(diffText);
440
+ const newJobType = `summaries:${digest}`;
441
+ cancelActiveSummariesJob(newJobType);
442
+
443
+ // auto_generate gate, applied here — AFTER cancellation (both the empty-diff
444
+ // branch above and the digest-mismatch cancel on this line) — so stale-job
445
+ // cancellation runs regardless of trigger. This is load-bearing: the
446
+ // summaries worker has no pre-upsert staleness guard, so an escaped stale
447
+ // worker would persist content_hash-keyed rows for hunks the user moved
448
+ // past. With `auto_generate` off, an auto-triggered kickoff still cancels
449
+ // that stale job; it simply declines to enqueue a replacement. Manual
450
+ // kickoffs always proceed.
451
+ if (trigger !== 'manual' && !deps.getSummaryAutoGenerate(config)) return null;
452
+
453
+ return queue.enqueue(reviewId, newJobType, (signal) =>
454
+ generateSummariesForReview({
455
+ db,
456
+ config,
457
+ reviewId,
458
+ diffText,
459
+ worktreePath,
460
+ reviewContext,
461
+ // BackgroundQueue invokes our thunk as `fn(signal)`. Pass it through
462
+ // so a user cancel reaches the per-file provider.execute call.
463
+ abortSignal: signal,
464
+ _deps
465
+ })
466
+ );
467
+ }
468
+
469
+ module.exports = { generateSummariesForReview, kickOffSummaryJob, countAddedLines };