@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.
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/references/level1-balanced.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level1-fast.md +7 -0
- package/plugin-code-critic/skills/analyze/references/level1-thorough.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level2-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level2-fast.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level2-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/level3-fast.md +8 -0
- package/plugin-code-critic/skills/analyze/references/level3-thorough.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-balanced.md +9 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-fast.md +5 -0
- package/plugin-code-critic/skills/analyze/references/orchestration-thorough.md +9 -0
- package/public/css/analysis-config.css +83 -0
- package/public/css/pr.css +191 -4
- package/public/index.html +20 -0
- package/public/js/components/AIPanel.js +1 -1
- package/public/js/components/AdvancedConfigTab.js +83 -8
- package/public/js/components/AnalysisConfigModal.js +155 -5
- package/public/js/components/ChatPanel.js +22 -5
- package/public/js/components/CouncilProgressModal.js +239 -22
- package/public/js/components/TimeoutSelect.js +2 -0
- package/public/js/components/VoiceCentricConfigTab.js +179 -12
- package/public/js/index.js +119 -1
- package/public/js/local.js +141 -47
- package/public/js/modules/suggestion-manager.js +2 -1
- package/public/js/pr.js +71 -12
- package/public/js/repo-settings.js +2 -2
- package/public/local.html +32 -11
- package/public/pr.html +2 -0
- package/src/ai/analyzer.js +371 -111
- package/src/ai/claude-provider.js +2 -0
- package/src/ai/codex-provider.js +1 -1
- package/src/ai/copilot-provider.js +2 -0
- package/src/ai/executable-provider.js +534 -0
- package/src/ai/gemini-provider.js +2 -0
- package/src/ai/index.js +9 -1
- package/src/ai/pi-provider.js +10 -8
- package/src/ai/prompts/baseline/consolidation/balanced.js +54 -2
- package/src/ai/prompts/baseline/consolidation/fast.js +31 -1
- package/src/ai/prompts/baseline/consolidation/thorough.js +46 -3
- package/src/ai/prompts/baseline/level1/balanced.js +12 -0
- package/src/ai/prompts/baseline/level1/fast.js +11 -0
- package/src/ai/prompts/baseline/level1/thorough.js +12 -0
- package/src/ai/prompts/baseline/level2/balanced.js +13 -0
- package/src/ai/prompts/baseline/level2/fast.js +12 -0
- package/src/ai/prompts/baseline/level2/thorough.js +13 -0
- package/src/ai/prompts/baseline/level3/balanced.js +13 -0
- package/src/ai/prompts/baseline/level3/fast.js +12 -0
- package/src/ai/prompts/baseline/level3/thorough.js +13 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +15 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +11 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +15 -0
- package/src/ai/prompts/render-for-skill.js +3 -0
- package/src/ai/prompts/shared/output-schema.js +8 -0
- package/src/ai/provider.js +89 -4
- package/src/chat/prompt-builder.js +17 -1
- package/src/chat/session-manager.js +32 -28
- package/src/config.js +15 -2
- package/src/database.js +59 -15
- package/src/git/base-branch.js +133 -52
- package/src/local-review.js +15 -9
- package/src/main.js +3 -2
- package/src/routes/analyses.js +34 -8
- package/src/routes/chat.js +15 -8
- package/src/routes/config.js +3 -120
- package/src/routes/councils.js +15 -6
- package/src/routes/executable-analysis.js +494 -0
- package/src/routes/local.js +160 -26
- package/src/routes/mcp.js +9 -4
- package/src/routes/pr.js +166 -29
- package/src/routes/reviews.js +31 -5
- package/src/routes/shared.js +72 -5
- package/src/routes/worktrees.js +4 -2
- package/src/utils/comment-formatter.js +28 -11
- package/src/utils/instructions.js +22 -8
- 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 };
|