@in-the-loop-labs/pair-review 3.0.5 → 3.1.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 (79) hide show
  1. package/package.json +2 -1
  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/references/level1-balanced.md +8 -0
  5. package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
  6. package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
  7. package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
  8. package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
  9. package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
  10. package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
  11. package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
  12. package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
  13. package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
  14. package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
  15. package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
  16. package/public/css/analysis-config.css +83 -0
  17. package/public/css/pr.css +191 -4
  18. package/public/index.html +20 -0
  19. package/public/js/components/AIPanel.js +1 -1
  20. package/public/js/components/AdvancedConfigTab.js +83 -8
  21. package/public/js/components/AnalysisConfigModal.js +155 -5
  22. package/public/js/components/ChatPanel.js +22 -5
  23. package/public/js/components/CouncilProgressModal.js +239 -22
  24. package/public/js/components/TimeoutSelect.js +2 -0
  25. package/public/js/components/VoiceCentricConfigTab.js +179 -12
  26. package/public/js/index.js +119 -1
  27. package/public/js/local.js +141 -47
  28. package/public/js/modules/suggestion-manager.js +2 -1
  29. package/public/js/pr.js +71 -12
  30. package/public/js/repo-settings.js +2 -2
  31. package/public/local.html +32 -11
  32. package/public/pr.html +2 -0
  33. package/src/ai/analyzer.js +371 -111
  34. package/src/ai/claude-provider.js +2 -0
  35. package/src/ai/codex-provider.js +1 -1
  36. package/src/ai/copilot-provider.js +2 -0
  37. package/src/ai/executable-provider.js +534 -0
  38. package/src/ai/gemini-provider.js +2 -0
  39. package/src/ai/index.js +9 -1
  40. package/src/ai/pi-provider.js +10 -8
  41. package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
  42. package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
  43. package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
  44. package/src/ai/prompts/baseline/level1/balanced.js +12 -0
  45. package/src/ai/prompts/baseline/level1/fast.js +11 -0
  46. package/src/ai/prompts/baseline/level1/thorough.js +12 -0
  47. package/src/ai/prompts/baseline/level2/balanced.js +13 -0
  48. package/src/ai/prompts/baseline/level2/fast.js +12 -0
  49. package/src/ai/prompts/baseline/level2/thorough.js +13 -0
  50. package/src/ai/prompts/baseline/level3/balanced.js +13 -0
  51. package/src/ai/prompts/baseline/level3/fast.js +12 -0
  52. package/src/ai/prompts/baseline/level3/thorough.js +13 -0
  53. package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
  54. package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
  55. package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
  56. package/src/ai/prompts/render-for-skill.js +3 -0
  57. package/src/ai/prompts/shared/output-schema.js +8 -0
  58. package/src/ai/provider.js +89 -4
  59. package/src/chat/prompt-builder.js +17 -1
  60. package/src/chat/session-manager.js +32 -28
  61. package/src/config.js +15 -2
  62. package/src/database.js +59 -15
  63. package/src/git/base-branch.js +133 -52
  64. package/src/local-review.js +15 -9
  65. package/src/main.js +3 -2
  66. package/src/routes/analyses.js +34 -8
  67. package/src/routes/chat.js +15 -8
  68. package/src/routes/config.js +3 -120
  69. package/src/routes/councils.js +15 -6
  70. package/src/routes/executable-analysis.js +494 -0
  71. package/src/routes/local.js +160 -26
  72. package/src/routes/mcp.js +9 -4
  73. package/src/routes/pr.js +166 -29
  74. package/src/routes/reviews.js +31 -5
  75. package/src/routes/shared.js +72 -5
  76. package/src/routes/worktrees.js +4 -2
  77. package/src/utils/comment-formatter.js +28 -11
  78. package/src/utils/instructions.js +22 -8
  79. package/src/utils/logger.js +20 -10
@@ -0,0 +1,494 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared lifecycle for executable provider analysis
4
+ *
5
+ * Both local and PR analysis routes follow the same steps when running an
6
+ * executable (external-tool) provider. This module captures that shared
7
+ * lifecycle so neither route duplicates it.
8
+ *
9
+ * Mode-specific behaviour is injected via the `callbacks` parameter:
10
+ * - buildContext(review, extra) – returns the full executableContext object
11
+ * `extra` contains { selectedModel, requestInstructions }
12
+ * - buildHookPayload(review, extra) – returns the mode-specific hook payload fields
13
+ * - onSuccess(db, runId, result) – persists mode-specific artefacts on success
14
+ * - logLabel – short string for log messages (e.g. "PR #42")
15
+ */
16
+
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const { exec } = require('child_process');
20
+ const { promisify } = require('util');
21
+ const execPromise = promisify(exec);
22
+ const fsPromises = require('fs').promises;
23
+ const logger = require('../utils/logger');
24
+ const { createProvider } = require('../ai/provider');
25
+ const { AnalysisRunRepository, CommentRepository } = require('../database');
26
+ const { fireHooks, hasHooks } = require('../hooks/hook-runner');
27
+ const { buildAnalysisStartedPayload, buildAnalysisCompletedPayload, getCachedUser } = require('../hooks/payloads');
28
+ const { normalizePath, resolveRenamedFile } = require('../utils/paths');
29
+ const { buildFileLineCountMap, validateSuggestionLineNumbers } = require('../utils/line-validation');
30
+ const { GIT_DIFF_FLAGS } = require('../git/diff-flags');
31
+ const { generateScopedDiff, findMergeBase } = require('../local-review');
32
+ const { scopeIncludes } = require('../local-scope');
33
+
34
+ /**
35
+ * Generate a diff for the executable provider and write it to a file.
36
+ *
37
+ * PR mode: uses baseSha...headSha (three-dot merge-base diff).
38
+ * Local mode: scope-aware diff via generateScopedDiff.
39
+ *
40
+ * Uses GIT_DIFF_FLAGS for normalized output and git-default context (3 lines).
41
+ * Additional flags can be passed via diffArgs (from provider config.diff_args).
42
+ *
43
+ * @param {string} cwd - Working directory (repo path)
44
+ * @param {Object} context - Executable context
45
+ * @param {string|null} context.baseSha - Base SHA (PR mode)
46
+ * @param {string|null} context.headSha - Head SHA (PR mode)
47
+ * @param {string|null} context.scopeStart - Scope start stop (local mode)
48
+ * @param {string|null} context.scopeEnd - Scope end stop (local mode)
49
+ * @param {string|null} context.baseBranch - Base branch (local mode, for merge-base)
50
+ * @param {string[]} diffArgs - Extra git diff flags from provider config
51
+ * @param {string} outputPath - Path to write the diff file
52
+ * @returns {Promise<string>} The diff content
53
+ */
54
+ async function generateDiffForExecutable(cwd, context, diffArgs, outputPath) {
55
+ let diff;
56
+ const extraFlags = diffArgs.length > 0 ? ' ' + diffArgs.join(' ') : '';
57
+
58
+ if (context.baseSha && context.headSha) {
59
+ // PR mode: straightforward base...head diff
60
+ const { stdout } = await execPromise(
61
+ `git diff ${GIT_DIFF_FLAGS}${extraFlags} ${context.baseSha}...${context.headSha}`,
62
+ { cwd, maxBuffer: 50 * 1024 * 1024 }
63
+ );
64
+ diff = stdout;
65
+ } else if (context.scopeStart && context.scopeEnd) {
66
+ // Local mode: scope-aware diff generation
67
+ // Note: diffArgs are passed as extraArgs to generateScopedDiff, which handles
68
+ // appending them to the git diff command internally (extraFlags is not used here).
69
+ const result = await generateScopedDiff(
70
+ cwd,
71
+ context.scopeStart,
72
+ context.scopeEnd,
73
+ context.baseBranch || null,
74
+ { contextLines: 3, extraArgs: diffArgs }
75
+ );
76
+ diff = result.diff;
77
+ } else {
78
+ // Fallback: simple working-tree diff
79
+ const { stdout } = await execPromise(
80
+ `git diff ${GIT_DIFF_FLAGS}${extraFlags}`,
81
+ { cwd, maxBuffer: 50 * 1024 * 1024 }
82
+ );
83
+ diff = stdout;
84
+ }
85
+
86
+ await fsPromises.writeFile(outputPath, diff || '', 'utf-8');
87
+ return diff;
88
+ }
89
+
90
+ /**
91
+ * Get the list of changed files from git for suggestion validation.
92
+ * PR mode uses base...head diff.
93
+ * Local mode is scope-aware: only includes files from the scope stops
94
+ * (branch, staged, unstaged, untracked) that were included in the diff.
95
+ * @param {string} cwd - Working directory
96
+ * @param {Object} context - Executable context with baseSha/headSha or scope fields
97
+ * @returns {Promise<string[]>} Changed file paths
98
+ */
99
+ async function getChangedFiles(cwd, context) {
100
+ try {
101
+ if (context.baseSha && context.headSha) {
102
+ const { stdout } = await execPromise(
103
+ `git diff ${GIT_DIFF_FLAGS} ${context.baseSha}...${context.headSha} --name-only`,
104
+ { cwd }
105
+ );
106
+ return stdout.trim().split('\n').filter(f => f.length > 0);
107
+ }
108
+
109
+ // Local mode: scope-aware file list
110
+ const { scopeStart, scopeEnd, baseBranch } = context;
111
+ const commands = [];
112
+
113
+ if (scopeStart && scopeEnd) {
114
+ const hasBranch = scopeIncludes(scopeStart, scopeEnd, 'branch');
115
+ const hasStaged = scopeIncludes(scopeStart, scopeEnd, 'staged');
116
+ const hasUnstaged = scopeIncludes(scopeStart, scopeEnd, 'unstaged');
117
+ const hasUntracked = scopeIncludes(scopeStart, scopeEnd, 'untracked');
118
+
119
+ if (hasBranch && baseBranch) {
120
+ const mergeBase = await findMergeBase(cwd, baseBranch);
121
+ commands.push(
122
+ execPromise(`git diff ${GIT_DIFF_FLAGS} ${mergeBase}..HEAD --name-only`, { cwd }).then(r => r.stdout)
123
+ );
124
+ }
125
+ if (hasStaged) {
126
+ commands.push(
127
+ execPromise(`git diff ${GIT_DIFF_FLAGS} --cached --name-only`, { cwd }).then(r => r.stdout)
128
+ );
129
+ }
130
+ if (hasUnstaged) {
131
+ commands.push(
132
+ execPromise(`git diff ${GIT_DIFF_FLAGS} --name-only`, { cwd }).then(r => r.stdout)
133
+ );
134
+ }
135
+ if (hasUntracked) {
136
+ commands.push(
137
+ execPromise('git ls-files --others --exclude-standard', { cwd }).then(r => r.stdout)
138
+ );
139
+ }
140
+ } else {
141
+ // Fallback: no scope info — include unstaged + untracked + staged
142
+ commands.push(
143
+ execPromise(`git diff ${GIT_DIFF_FLAGS} --name-only`, { cwd }).then(r => r.stdout),
144
+ execPromise('git ls-files --others --exclude-standard', { cwd }).then(r => r.stdout),
145
+ execPromise(`git diff ${GIT_DIFF_FLAGS} --cached --name-only`, { cwd }).then(r => r.stdout)
146
+ );
147
+ }
148
+
149
+ const results = await Promise.all(commands);
150
+ const all = results
151
+ .flatMap(output => output.trim().split('\n'))
152
+ .filter(f => f.length > 0);
153
+ return [...new Set(all)];
154
+ } catch (error) {
155
+ logger.warn(`Could not get changed files list: ${error.message}`);
156
+ return [];
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Validate suggestions: filter by file path and clamp invalid line numbers.
162
+ * Mirrors Analyzer.validateAndFinalizeSuggestions as a standalone function.
163
+ * @param {Array} suggestions - Suggestion objects from the executable provider
164
+ * @param {string[]} validFiles - Changed file paths from the diff
165
+ * @param {Map<string, number>} fileLineCountMap - File path → line count
166
+ * @returns {Array} Validated suggestions
167
+ */
168
+ function validateSuggestions(suggestions, validFiles, fileLineCountMap) {
169
+ if (!suggestions || suggestions.length === 0) return [];
170
+ const inputCount = suggestions.length;
171
+
172
+ // File path validation
173
+ let filtered = suggestions;
174
+ if (validFiles && validFiles.length > 0) {
175
+ const normalizedValid = new Set(validFiles.map(p => normalizePath(resolveRenamedFile(p))));
176
+ filtered = suggestions.filter(s => {
177
+ const norm = normalizePath(resolveRenamedFile(s.file));
178
+ if (normalizedValid.has(norm)) return true;
179
+ logger.warn(`[Validation] Discarded suggestion with invalid path: "${s.file}" (${s.type} - ${s.title})`);
180
+ return false;
181
+ });
182
+ if (filtered.length < inputCount) {
183
+ logger.info(`[Validation] File path filter: ${inputCount} → ${filtered.length} suggestions`);
184
+ }
185
+ } else {
186
+ logger.warn('[Validation] No valid paths available, skipping path filtering');
187
+ }
188
+
189
+ // Line number validation
190
+ const lineResult = validateSuggestionLineNumbers(filtered, fileLineCountMap, { convertToFileLevel: true });
191
+ if (lineResult.converted.length > 0) {
192
+ logger.warn(`[Validation] Converted ${lineResult.converted.length} suggestions to file-level (invalid line numbers)`);
193
+ }
194
+
195
+ const final = [...lineResult.valid, ...lineResult.converted];
196
+ logger.info(`[Validation] Final: ${final.length} suggestions from ${inputCount} input`);
197
+ return final;
198
+ }
199
+
200
+ /**
201
+ * Run the full executable-provider analysis lifecycle.
202
+ *
203
+ * @param {object} req Express request (used for app-level refs)
204
+ * @param {object} res Express response (JSON reply sent early)
205
+ * @param {object} params Common parameters
206
+ * @param {string} params.reviewId
207
+ * @param {object} params.review
208
+ * @param {string} params.selectedProvider
209
+ * @param {string} params.selectedModel
210
+ * @param {string|null} params.repoInstructions
211
+ * @param {string|null} params.requestInstructions
212
+ * @param {string} params.runId
213
+ * @param {string} params.analysisId
214
+ * @param {string} params.repository
215
+ * @param {string} params.reviewType 'local' | 'pr'
216
+ * @param {string} params.headSha SHA to record on the analysis run
217
+ * @param {object} [params.extraInitialStatus] Extra fields merged into the initial progress status
218
+ * @param {object} shared Shared state / helpers from the route module
219
+ * @param {Map} shared.activeAnalyses
220
+ * @param {Map} shared.reviewToAnalysisId
221
+ * @param {Function} shared.broadcastProgress
222
+ * @param {Function} shared.broadcastReviewEvent
223
+ * @param {Function} shared.registerProcessForCancellation
224
+ * @param {object} callbacks Mode-specific behaviour
225
+ * @param {Function} callbacks.buildContext (review, { selectedModel, requestInstructions }) => executableContext object
226
+ * @param {Function} callbacks.buildHookPayload (review, extra) => object merged into hook payload
227
+ * @param {Function} callbacks.onSuccess (db, runId, { suggestions, summary }) => Promise
228
+ * @param {string} callbacks.logLabel e.g. "Review #5" or "PR #42"
229
+ */
230
+ async function runExecutableAnalysis(req, res, params, shared, callbacks) {
231
+ const {
232
+ reviewId, review, selectedProvider, selectedModel,
233
+ repoInstructions, requestInstructions,
234
+ runId, analysisId, repository, reviewType, headSha,
235
+ extraInitialStatus
236
+ } = params;
237
+
238
+ const {
239
+ activeAnalyses, reviewToAnalysisId,
240
+ broadcastProgress, broadcastReviewEvent,
241
+ registerProcessForCancellation
242
+ } = shared;
243
+
244
+ const { buildContext, buildHookPayload, onSuccess, logLabel } = callbacks;
245
+
246
+ const db = req.app.get('db');
247
+ const analysisRunRepo = new AnalysisRunRepository(db);
248
+ const commentRepo = new CommentRepository(db);
249
+
250
+ // 1. Create analysis run record
251
+ try {
252
+ await analysisRunRepo.create({
253
+ id: runId,
254
+ reviewId,
255
+ provider: selectedProvider,
256
+ model: selectedModel,
257
+ tier: 'thorough',
258
+ repoInstructions,
259
+ requestInstructions,
260
+ headSha: headSha || null,
261
+ configType: 'single',
262
+ levelsConfig: null
263
+ });
264
+ } catch (error) {
265
+ logger.error('Failed to create analysis run record:', error);
266
+ return res.status(500).json({ error: 'Failed to initialize analysis tracking' });
267
+ }
268
+
269
+ // 2. Set up progress tracking
270
+ const initialStatus = {
271
+ id: analysisId,
272
+ runId,
273
+ reviewId,
274
+ ...extraInitialStatus,
275
+ repository,
276
+ reviewType,
277
+ status: 'running',
278
+ // TODO: derive from provider capabilities once level-based progress is supported for executable providers
279
+ noLevels: true,
280
+ startedAt: new Date().toISOString(),
281
+ progress: 'Running external analysis tool...',
282
+ levels: {},
283
+ filesAnalyzed: 0,
284
+ filesRemaining: 0
285
+ };
286
+ activeAnalyses.set(analysisId, initialStatus);
287
+ reviewToAnalysisId.set(reviewId, analysisId);
288
+
289
+ broadcastProgress(analysisId, initialStatus);
290
+ broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
291
+
292
+ // 3. Fire analysis.started hook
293
+ const analysisHookConfig = req.app.get('config') || {};
294
+ const hookPayloadFields = buildHookPayload(review, {});
295
+ if (hasHooks('analysis.started', analysisHookConfig)) {
296
+ getCachedUser(analysisHookConfig).then(user => {
297
+ fireHooks('analysis.started', buildAnalysisStartedPayload({
298
+ reviewId, analysisId, provider: selectedProvider, model: selectedModel,
299
+ ...hookPayloadFields,
300
+ user,
301
+ }), analysisHookConfig);
302
+ }).catch(() => {});
303
+ }
304
+
305
+ // 4. Respond immediately — analysis runs async
306
+ res.json({
307
+ analysisId,
308
+ runId,
309
+ status: 'running',
310
+ message: 'Executable provider analysis started'
311
+ });
312
+
313
+ // 5. Run the executable provider asynchronously
314
+ (async () => {
315
+ const tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'pair-review-exec-'));
316
+ try {
317
+ const provider = createProvider(selectedProvider, selectedModel);
318
+
319
+ const executableContext = {
320
+ ...buildContext(review, { selectedModel, requestInstructions }),
321
+ outputDir: tmpDir,
322
+ // Use resolved CLI model (cli_model || id) instead of raw model ID; null suppresses model
323
+ model: provider.resolvedModel !== undefined ? provider.resolvedModel : (provider.model || null)
324
+ };
325
+ const cwd = executableContext.cwd || process.cwd();
326
+
327
+ // Only generate a diff file when the provider's context_args maps diff_path to a CLI flag
328
+ if (provider.contextArgs?.diff_path) {
329
+ const diffPath = path.join(tmpDir, 'review.diff');
330
+ try {
331
+ await generateDiffForExecutable(cwd, executableContext, provider.diffArgs || [], diffPath);
332
+ executableContext.diffPath = diffPath;
333
+ } catch (diffError) {
334
+ logger.warn(`Failed to generate diff for executable: ${diffError.message} — continuing without diff`);
335
+ }
336
+ }
337
+
338
+ logger.section(`Executable Provider Analysis - ${logLabel}`);
339
+ logger.log('API', `Provider: ${selectedProvider}`, 'cyan');
340
+ logger.log('API', `Model: ${selectedModel}`, 'cyan');
341
+ logger.log('API', `Working dir: ${cwd}`, 'magenta');
342
+ logger.log('API', `Output dir: ${tmpDir}`, 'magenta');
343
+ if (executableContext.diffPath) {
344
+ logger.log('API', `Diff file: ${executableContext.diffPath}`, 'magenta');
345
+ }
346
+
347
+ // Throttled stream event handler — avoids flooding WebSocket
348
+ let lastBroadcastTime = 0;
349
+ const THROTTLE_MS = 300;
350
+ const onStreamEvent = (event) => {
351
+ const status = activeAnalyses.get(analysisId);
352
+ if (!status) return;
353
+ status.levels = { exec: { status: 'running', streamEvent: event } };
354
+ const now = Date.now();
355
+ if (now - lastBroadcastTime >= THROTTLE_MS) {
356
+ lastBroadcastTime = now;
357
+ broadcastProgress(analysisId, status);
358
+ }
359
+ };
360
+
361
+ const result = await provider.execute(null, {
362
+ executableContext,
363
+ cwd,
364
+ timeout: provider.timeout || 600000,
365
+ analysisId,
366
+ registerProcess: (id, proc) => registerProcessForCancellation(id, proc),
367
+ onStreamEvent
368
+ });
369
+
370
+ if (!result?.success || !result?.data) {
371
+ throw new Error('Executable provider returned no data');
372
+ }
373
+
374
+ const rawSuggestions = result.data.suggestions || [];
375
+ const summary = result.data.summary || '';
376
+
377
+ // Validate suggestions against the diff (file paths + line numbers)
378
+ const validFiles = await getChangedFiles(cwd, executableContext);
379
+ const fileLineCountMap = validFiles.length > 0
380
+ ? await buildFileLineCountMap(cwd, validFiles)
381
+ : new Map();
382
+ const suggestions = validateSuggestions(rawSuggestions, validFiles, fileLineCountMap);
383
+
384
+ // Store validated suggestions
385
+ await commentRepo.bulkInsertAISuggestions(reviewId, runId, suggestions, null);
386
+
387
+ // Update run to completed
388
+ await analysisRunRepo.update(runId, {
389
+ status: 'completed',
390
+ summary,
391
+ totalSuggestions: suggestions.length,
392
+ completedAt: new Date().toISOString()
393
+ });
394
+
395
+ // Mode-specific success handling
396
+ await onSuccess(db, runId, { suggestions, summary });
397
+
398
+ logger.success(`Executable analysis complete for ${logLabel}: ${suggestions.length} suggestions`);
399
+
400
+ // Update progress tracking
401
+ const completedStatus = {
402
+ ...activeAnalyses.get(analysisId),
403
+ status: 'completed',
404
+ completedAt: new Date().toISOString(),
405
+ progress: `Analysis complete: ${suggestions.length} suggestions found`,
406
+ suggestionsCount: suggestions.length
407
+ };
408
+ activeAnalyses.set(analysisId, completedStatus);
409
+ broadcastProgress(analysisId, completedStatus);
410
+ broadcastReviewEvent(reviewId, { type: 'review:analysis_completed' });
411
+
412
+ // Fire analysis.completed hook
413
+ if (hasHooks('analysis.completed', analysisHookConfig)) {
414
+ getCachedUser(analysisHookConfig).then(user => {
415
+ fireHooks('analysis.completed', buildAnalysisCompletedPayload({
416
+ reviewId, analysisId, provider: selectedProvider, model: selectedModel,
417
+ status: 'success', totalSuggestions: suggestions.length,
418
+ ...hookPayloadFields,
419
+ user,
420
+ }), analysisHookConfig);
421
+ }).catch(() => {});
422
+ }
423
+ } catch (error) {
424
+ if (error.isCancellation) {
425
+ logger.info(`Executable analysis cancelled for ${logLabel}`);
426
+ // Status is already set to 'cancelled' by the cancel endpoint
427
+ if (hasHooks('analysis.completed', analysisHookConfig)) {
428
+ getCachedUser(analysisHookConfig).then(user => {
429
+ fireHooks('analysis.completed', buildAnalysisCompletedPayload({
430
+ reviewId, analysisId, provider: selectedProvider, model: selectedModel,
431
+ status: 'cancelled', totalSuggestions: 0,
432
+ ...hookPayloadFields,
433
+ user,
434
+ }), analysisHookConfig);
435
+ }).catch(() => {});
436
+ }
437
+ return;
438
+ }
439
+
440
+ logger.error(`Executable analysis failed for ${logLabel}: ${error.message}`);
441
+
442
+ // Update run to failed
443
+ try {
444
+ await analysisRunRepo.update(runId, {
445
+ status: 'failed',
446
+ completedAt: new Date().toISOString()
447
+ });
448
+ } catch (e) {
449
+ logger.warn(`Failed to update run status: ${e.message}`);
450
+ }
451
+
452
+ const failedStatus = {
453
+ ...activeAnalyses.get(analysisId),
454
+ status: 'failed',
455
+ completedAt: new Date().toISOString(),
456
+ error: error.message,
457
+ progress: 'Analysis failed'
458
+ };
459
+ activeAnalyses.set(analysisId, failedStatus);
460
+ broadcastProgress(analysisId, failedStatus);
461
+
462
+ if (hasHooks('analysis.completed', analysisHookConfig)) {
463
+ getCachedUser(analysisHookConfig).then(user => {
464
+ fireHooks('analysis.completed', buildAnalysisCompletedPayload({
465
+ reviewId, analysisId, provider: selectedProvider, model: selectedModel,
466
+ status: 'failed', totalSuggestions: 0,
467
+ ...hookPayloadFields,
468
+ user,
469
+ }), analysisHookConfig);
470
+ }).catch(() => {});
471
+ }
472
+ } finally {
473
+ // Clean up review-to-analysis mapping (allow new analyses for this review).
474
+ // Do NOT delete activeAnalyses entry — leave it with terminal status so
475
+ // clients can poll for final results via HTTP (matches local.js/pr.js).
476
+ reviewToAnalysisId.delete(reviewId);
477
+
478
+ // Clean up temp directory (keep in debug mode for inspection)
479
+ if (tmpDir) {
480
+ if (logger.isStreamDebugEnabled()) {
481
+ logger.info(`Keeping executable output dir for debug: ${tmpDir}`);
482
+ } else {
483
+ try {
484
+ await fsPromises.rm(tmpDir, { recursive: true, force: true });
485
+ } catch (e) {
486
+ logger.debug(`Failed to clean up temp dir ${tmpDir}: ${e.message}`);
487
+ }
488
+ }
489
+ }
490
+ }
491
+ })();
492
+ }
493
+
494
+ module.exports = { runExecutableAnalysis, generateDiffForExecutable, getChangedFiles };