@in-the-loop-labs/pair-review 1.6.2 → 2.0.1
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 +77 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +4 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
- package/public/css/pr.css +1962 -114
- package/public/js/CONVENTIONS.md +16 -0
- package/public/js/components/AIPanel.js +66 -0
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +2955 -0
- package/public/js/components/CouncilProgressModal.js +12 -16
- package/public/js/components/KeyboardShortcuts.js +3 -0
- package/public/js/components/PanelGroup.js +723 -0
- package/public/js/components/PreviewModal.js +3 -8
- package/public/js/index.js +8 -0
- package/public/js/local.js +17 -615
- package/public/js/modules/analysis-history.js +19 -68
- package/public/js/modules/comment-manager.js +103 -20
- package/public/js/modules/diff-context.js +176 -0
- package/public/js/modules/diff-renderer.js +30 -0
- package/public/js/modules/file-comment-manager.js +126 -105
- package/public/js/modules/file-list-merger.js +64 -0
- package/public/js/modules/panel-resizer.js +25 -6
- package/public/js/modules/suggestion-manager.js +40 -125
- package/public/js/pr.js +1009 -159
- package/public/js/repo-settings.js +36 -6
- package/public/js/utils/category-emoji.js +44 -0
- package/public/js/utils/time.js +32 -0
- package/public/local.html +107 -70
- package/public/pr.html +107 -70
- package/public/repo-settings.html +32 -0
- package/src/ai/analyzer.js +5 -1
- package/src/ai/copilot-provider.js +39 -9
- package/src/ai/cursor-agent-provider.js +45 -11
- package/src/ai/gemini-provider.js +17 -4
- package/src/ai/prompts/config.js +7 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +25 -37
- package/src/chat/CONVENTIONS.md +18 -0
- package/src/chat/pi-bridge.js +491 -0
- package/src/chat/prompt-builder.js +272 -0
- package/src/chat/session-manager.js +619 -0
- package/src/config.js +14 -0
- package/src/database.js +322 -15
- package/src/main.js +4 -17
- package/src/routes/analyses.js +721 -0
- package/src/routes/chat.js +655 -0
- package/src/routes/config.js +29 -8
- package/src/routes/context-files.js +274 -0
- package/src/routes/local.js +225 -1133
- package/src/routes/mcp.js +39 -30
- package/src/routes/pr.js +424 -58
- package/src/routes/reviews.js +1035 -0
- package/src/routes/shared.js +4 -29
- package/src/server.js +34 -12
- package/src/sse/review-events.js +46 -0
- package/src/utils/auto-context.js +88 -0
- package/src/utils/category-emoji.js +33 -0
- package/src/utils/diff-annotator.js +75 -1
- package/src/utils/diff-file-list.js +57 -0
- package/src/routes/analysis.js +0 -1600
- package/src/routes/comments.js +0 -534
package/src/routes/analysis.js
DELETED
|
@@ -1,1600 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
-
/**
|
|
3
|
-
* AI Analysis Routes
|
|
4
|
-
*
|
|
5
|
-
* Handles all AI analysis-related endpoints:
|
|
6
|
-
* - Triggering analysis (Level 1, 2, 3)
|
|
7
|
-
* - Getting analysis status
|
|
8
|
-
* - Checking for AI suggestions
|
|
9
|
-
* - Fetching AI suggestions
|
|
10
|
-
* - SSE progress streaming
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
const express = require('express');
|
|
14
|
-
const { query, queryOne, withTransaction, RepoSettingsRepository, ReviewRepository, CommentRepository, AnalysisRunRepository, PRMetadataRepository, CouncilRepository } = require('../database');
|
|
15
|
-
const { GitWorktreeManager } = require('../git/worktree');
|
|
16
|
-
const Analyzer = require('../ai/analyzer');
|
|
17
|
-
const { v4: uuidv4 } = require('uuid');
|
|
18
|
-
const logger = require('../utils/logger');
|
|
19
|
-
const { mergeInstructions } = require('../utils/instructions');
|
|
20
|
-
const { calculateStats, getStatsQuery } = require('../utils/stats-calculator');
|
|
21
|
-
const path = require('path');
|
|
22
|
-
const { normalizeRepository } = require('../utils/paths');
|
|
23
|
-
const {
|
|
24
|
-
activeAnalyses,
|
|
25
|
-
prToAnalysisId,
|
|
26
|
-
localReviewToAnalysisId,
|
|
27
|
-
progressClients,
|
|
28
|
-
localReviewDiffs,
|
|
29
|
-
getPRKey,
|
|
30
|
-
getLocalReviewKey,
|
|
31
|
-
getModel,
|
|
32
|
-
determineCompletionInfo,
|
|
33
|
-
broadcastProgress,
|
|
34
|
-
killProcesses,
|
|
35
|
-
isAnalysisCancelled,
|
|
36
|
-
CancellationError,
|
|
37
|
-
createProgressCallback,
|
|
38
|
-
parseEnabledLevels
|
|
39
|
-
} = require('./shared');
|
|
40
|
-
const { generateLocalDiff, computeLocalDiffDigest } = require('../local-review');
|
|
41
|
-
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
42
|
-
|
|
43
|
-
const router = express.Router();
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Trigger AI analysis for a PR (Level 1)
|
|
47
|
-
*/
|
|
48
|
-
router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
|
|
49
|
-
try {
|
|
50
|
-
const { owner, repo, pr } = req.params;
|
|
51
|
-
const prNumber = parseInt(pr);
|
|
52
|
-
|
|
53
|
-
// Extract optional provider, model, tier, customInstructions and skipLevel3 from request body
|
|
54
|
-
const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels } = req.body || {};
|
|
55
|
-
|
|
56
|
-
// Trim and validate custom instructions
|
|
57
|
-
const MAX_INSTRUCTIONS_LENGTH = 5000;
|
|
58
|
-
let requestInstructions = rawInstructions?.trim() || null;
|
|
59
|
-
if (requestInstructions && requestInstructions.length > MAX_INSTRUCTIONS_LENGTH) {
|
|
60
|
-
return res.status(400).json({
|
|
61
|
-
error: `Custom instructions exceed maximum length of ${MAX_INSTRUCTIONS_LENGTH} characters`
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Validate tier
|
|
66
|
-
const VALID_TIERS = ['fast', 'balanced', 'thorough', 'free', 'standard', 'premium'];
|
|
67
|
-
if (requestTier && !VALID_TIERS.includes(requestTier)) {
|
|
68
|
-
return res.status(400).json({
|
|
69
|
-
error: `Invalid tier: "${requestTier}". Valid tiers: ${VALID_TIERS.join(', ')}`
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (isNaN(prNumber) || prNumber <= 0) {
|
|
74
|
-
return res.status(400).json({
|
|
75
|
-
error: 'Invalid pull request number'
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const repository = normalizeRepository(owner, repo);
|
|
80
|
-
|
|
81
|
-
// Check if PR exists in database
|
|
82
|
-
const db = req.app.get('db');
|
|
83
|
-
// Create repositories once for reuse throughout the handler
|
|
84
|
-
const reviewRepo = new ReviewRepository(db);
|
|
85
|
-
const prMetadataRepo = new PRMetadataRepository(db);
|
|
86
|
-
const prMetadata = await prMetadataRepo.getByPR(prNumber, repository);
|
|
87
|
-
|
|
88
|
-
if (!prMetadata) {
|
|
89
|
-
return res.status(404).json({
|
|
90
|
-
error: `Pull request #${prNumber} not found. Please load the PR first.`
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Get worktree path
|
|
95
|
-
const worktreeManager = new GitWorktreeManager(db);
|
|
96
|
-
const worktreePath = await worktreeManager.getWorktreePath({ owner, repo, number: prNumber });
|
|
97
|
-
|
|
98
|
-
// Check if worktree exists
|
|
99
|
-
if (!await worktreeManager.worktreeExists({ owner, repo, number: prNumber })) {
|
|
100
|
-
return res.status(404).json({
|
|
101
|
-
error: 'Worktree not found for this PR. Please reload the PR.'
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Fetch repo settings and save custom instructions in a transaction
|
|
106
|
-
// This ensures consistency between reading settings and updating the review record
|
|
107
|
-
const { provider, model, repoInstructions, combinedInstructions } = await withTransaction(db, async () => {
|
|
108
|
-
// Fetch repo settings for default instructions, provider, and model
|
|
109
|
-
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
110
|
-
const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
111
|
-
|
|
112
|
-
// Determine provider: request body > repo settings > config > default ('claude')
|
|
113
|
-
let selectedProvider;
|
|
114
|
-
if (requestProvider) {
|
|
115
|
-
selectedProvider = requestProvider;
|
|
116
|
-
} else if (fetchedRepoSettings && fetchedRepoSettings.default_provider) {
|
|
117
|
-
selectedProvider = fetchedRepoSettings.default_provider;
|
|
118
|
-
} else {
|
|
119
|
-
const config = req.app.get('config') || {};
|
|
120
|
-
selectedProvider = config.default_provider || config.provider || 'claude';
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Determine model: request body > repo settings > config/CLI > default
|
|
124
|
-
let selectedModel;
|
|
125
|
-
if (requestModel) {
|
|
126
|
-
selectedModel = requestModel;
|
|
127
|
-
} else if (fetchedRepoSettings && fetchedRepoSettings.default_model) {
|
|
128
|
-
selectedModel = fetchedRepoSettings.default_model;
|
|
129
|
-
} else {
|
|
130
|
-
selectedModel = getModel(req);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Get repo instructions from settings
|
|
134
|
-
const fetchedRepoInstructions = fetchedRepoSettings?.default_instructions || null;
|
|
135
|
-
// Merge for logging purposes (analyzer will also merge internally)
|
|
136
|
-
const mergedInstructions = mergeInstructions(fetchedRepoInstructions, requestInstructions);
|
|
137
|
-
|
|
138
|
-
// Save custom instructions to the review record using upsert
|
|
139
|
-
// Uses reviewRepo created at the start of the handler
|
|
140
|
-
if (requestInstructions) {
|
|
141
|
-
await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
provider: selectedProvider,
|
|
146
|
-
model: selectedModel,
|
|
147
|
-
repoInstructions: fetchedRepoInstructions,
|
|
148
|
-
combinedInstructions: mergedInstructions
|
|
149
|
-
};
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// Create unified run/analysis ID
|
|
153
|
-
const runId = uuidv4();
|
|
154
|
-
const analysisId = runId;
|
|
155
|
-
|
|
156
|
-
// Get or create a review record for this PR
|
|
157
|
-
// The review.id is passed to the analyzer so comments use review.id, not prMetadata.id
|
|
158
|
-
// This avoids ID collision with local mode where comments also use reviews.id
|
|
159
|
-
const review = await reviewRepo.getOrCreate({ prNumber, repository });
|
|
160
|
-
|
|
161
|
-
// Create DB analysis_runs record immediately so it's queryable for polling
|
|
162
|
-
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
163
|
-
const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
|
|
164
|
-
await analysisRunRepo.create({
|
|
165
|
-
id: runId,
|
|
166
|
-
reviewId: review.id,
|
|
167
|
-
provider,
|
|
168
|
-
model,
|
|
169
|
-
repoInstructions,
|
|
170
|
-
requestInstructions,
|
|
171
|
-
headSha: prMetadata.head_sha || null,
|
|
172
|
-
configType: 'single',
|
|
173
|
-
levelsConfig
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
// Store analysis status with separate tracking for each level
|
|
177
|
-
const initialStatus = {
|
|
178
|
-
id: analysisId,
|
|
179
|
-
runId,
|
|
180
|
-
prNumber,
|
|
181
|
-
repository,
|
|
182
|
-
status: 'running',
|
|
183
|
-
startedAt: new Date().toISOString(),
|
|
184
|
-
progress: 'Starting analysis...',
|
|
185
|
-
// Track each level separately for parallel execution
|
|
186
|
-
levels: {
|
|
187
|
-
1: levelsConfig[1] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
188
|
-
2: levelsConfig[2] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
189
|
-
3: levelsConfig[3] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
190
|
-
4: { status: 'pending', progress: 'Pending' }
|
|
191
|
-
},
|
|
192
|
-
filesAnalyzed: 0,
|
|
193
|
-
filesRemaining: 0
|
|
194
|
-
};
|
|
195
|
-
activeAnalyses.set(analysisId, initialStatus);
|
|
196
|
-
|
|
197
|
-
// Store PR to analysis ID mapping
|
|
198
|
-
const prKey = getPRKey(owner, repo, prNumber);
|
|
199
|
-
prToAnalysisId.set(prKey, analysisId);
|
|
200
|
-
|
|
201
|
-
// Broadcast initial status
|
|
202
|
-
broadcastProgress(analysisId, initialStatus);
|
|
203
|
-
|
|
204
|
-
// Create analyzer instance with provider and model
|
|
205
|
-
const analyzer = new Analyzer(req.app.get('db'), model, provider);
|
|
206
|
-
|
|
207
|
-
// Log analysis start with colorful output
|
|
208
|
-
logger.section(`AI Analysis Request - PR #${prNumber}`);
|
|
209
|
-
logger.log('API', `Repository: ${repository}`, 'magenta');
|
|
210
|
-
logger.log('API', `Worktree: ${worktreePath}`, 'magenta');
|
|
211
|
-
logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
|
|
212
|
-
logger.log('API', `Review ID: ${review.id}`, 'magenta');
|
|
213
|
-
logger.log('API', `Provider: ${provider}`, 'cyan');
|
|
214
|
-
logger.log('API', `Model: ${model}`, 'cyan');
|
|
215
|
-
// Determine tier: request body > default ('balanced')
|
|
216
|
-
const tier = requestTier || 'balanced';
|
|
217
|
-
logger.log('API', `Tier: ${tier}`, 'cyan');
|
|
218
|
-
if (combinedInstructions) {
|
|
219
|
-
logger.log('API', `Custom instructions: ${combinedInstructions.length} chars`, 'cyan');
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
const progressCallback = createProgressCallback(analysisId);
|
|
223
|
-
|
|
224
|
-
// Start analysis asynchronously (skipRunCreation since we created the record above; tier for prompt selection, skipLevel3 flag)
|
|
225
|
-
analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig })
|
|
226
|
-
.then(async result => {
|
|
227
|
-
logger.section('Analysis Results');
|
|
228
|
-
logger.success(`Analysis complete for PR #${prNumber}`);
|
|
229
|
-
logger.success(`Found ${result.suggestions.length} suggestions:`);
|
|
230
|
-
|
|
231
|
-
// Update pr_metadata with the last AI run ID (tracks that analysis was run)
|
|
232
|
-
try {
|
|
233
|
-
await prMetadataRepo.updateLastAiRunId(prMetadata.id, result.runId);
|
|
234
|
-
logger.info(`Updated pr_metadata with last_ai_run_id: ${result.runId}`);
|
|
235
|
-
} catch (updateError) {
|
|
236
|
-
logger.warn(`Failed to update pr_metadata with last_ai_run_id: ${updateError.message}`);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Save summary to review record (reuse reviewRepo from handler start)
|
|
240
|
-
if (result.summary) {
|
|
241
|
-
try {
|
|
242
|
-
await reviewRepo.upsertSummary(prNumber, repository, result.summary);
|
|
243
|
-
logger.info(`Saved analysis summary to review record`);
|
|
244
|
-
logger.section('Analysis Summary');
|
|
245
|
-
logger.info(result.summary);
|
|
246
|
-
} catch (summaryError) {
|
|
247
|
-
logger.warn(`Failed to save analysis summary: ${summaryError.message}`);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
result.suggestions.forEach(s => {
|
|
251
|
-
const icon = s.type === 'bug' ? '🐛' :
|
|
252
|
-
s.type === 'praise' ? '⭐' :
|
|
253
|
-
s.type === 'improvement' ? '💡' :
|
|
254
|
-
s.type === 'security' ? '🔒' :
|
|
255
|
-
s.type === 'performance' ? '⚡' :
|
|
256
|
-
s.type === 'design' ? '📐' :
|
|
257
|
-
s.type === 'suggestion' ? '💬' :
|
|
258
|
-
s.type === 'code-style' || s.type === 'style' ? '🧹' : '📝';
|
|
259
|
-
logger.log('Result', `${icon} ${s.type}: ${s.title} (${s.file}:${s.line_start})`, 'green');
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
// Determine completion status using extracted helper function
|
|
263
|
-
const completionInfo = determineCompletionInfo(result);
|
|
264
|
-
|
|
265
|
-
const currentStatus = activeAnalyses.get(analysisId);
|
|
266
|
-
if (!currentStatus) {
|
|
267
|
-
logger.warn('Analysis already completed or removed:', analysisId);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// Check if analysis was cancelled while running
|
|
272
|
-
if (currentStatus.status === 'cancelled') {
|
|
273
|
-
logger.info(`Analysis ${analysisId} was cancelled, skipping completion update`);
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Mark all completed levels as completed
|
|
278
|
-
for (let i = 1; i <= completionInfo.completedLevel; i++) {
|
|
279
|
-
currentStatus.levels[i] = {
|
|
280
|
-
status: 'completed',
|
|
281
|
-
progress: `Level ${i} complete`
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Mark orchestration (level 4) as completed
|
|
286
|
-
currentStatus.levels[4] = {
|
|
287
|
-
status: 'completed',
|
|
288
|
-
progress: 'Results finalized'
|
|
289
|
-
};
|
|
290
|
-
|
|
291
|
-
const completedStatus = {
|
|
292
|
-
...currentStatus,
|
|
293
|
-
status: 'completed',
|
|
294
|
-
level: completionInfo.completedLevel,
|
|
295
|
-
completedLevel: completionInfo.completedLevel,
|
|
296
|
-
completedAt: new Date().toISOString(),
|
|
297
|
-
result,
|
|
298
|
-
progress: completionInfo.progressMessage,
|
|
299
|
-
suggestionsCount: completionInfo.totalSuggestions,
|
|
300
|
-
filesAnalyzed: currentStatus?.filesAnalyzed || 0,
|
|
301
|
-
filesRemaining: 0,
|
|
302
|
-
currentFile: currentStatus?.totalFiles || 0,
|
|
303
|
-
totalFiles: currentStatus?.totalFiles || 0
|
|
304
|
-
};
|
|
305
|
-
activeAnalyses.set(analysisId, completedStatus);
|
|
306
|
-
|
|
307
|
-
// Broadcast completion status
|
|
308
|
-
broadcastProgress(analysisId, completedStatus);
|
|
309
|
-
})
|
|
310
|
-
.catch(error => {
|
|
311
|
-
const currentStatus = activeAnalyses.get(analysisId);
|
|
312
|
-
if (!currentStatus) {
|
|
313
|
-
logger.warn('Analysis status not found during error handling:', analysisId);
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Handle cancellation gracefully - don't log as error
|
|
318
|
-
if (error.isCancellation) {
|
|
319
|
-
logger.info(`Analysis cancelled for PR #${prNumber}`);
|
|
320
|
-
// Status is already set to 'cancelled' by the cancel endpoint
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
logger.error(`Analysis failed for PR #${prNumber}: ${error.message}`);
|
|
325
|
-
|
|
326
|
-
// Mark all levels as failed
|
|
327
|
-
for (let i = 1; i <= 4; i++) {
|
|
328
|
-
currentStatus.levels[i] = {
|
|
329
|
-
status: 'failed',
|
|
330
|
-
progress: 'Failed'
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const failedStatus = {
|
|
335
|
-
...currentStatus,
|
|
336
|
-
status: 'failed',
|
|
337
|
-
level: 1,
|
|
338
|
-
completedAt: new Date().toISOString(),
|
|
339
|
-
error: error.message,
|
|
340
|
-
progress: 'Analysis failed'
|
|
341
|
-
};
|
|
342
|
-
activeAnalyses.set(analysisId, failedStatus);
|
|
343
|
-
|
|
344
|
-
// Broadcast failure status
|
|
345
|
-
broadcastProgress(analysisId, failedStatus);
|
|
346
|
-
})
|
|
347
|
-
.finally(() => {
|
|
348
|
-
// Clean up PR to analysis ID mapping (always runs regardless of success/failure)
|
|
349
|
-
const prKey = getPRKey(owner, repo, prNumber);
|
|
350
|
-
prToAnalysisId.delete(prKey);
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
// Return analysis ID immediately (runId added for unified ID)
|
|
354
|
-
res.json({
|
|
355
|
-
analysisId,
|
|
356
|
-
runId,
|
|
357
|
-
status: 'started',
|
|
358
|
-
message: 'AI analysis started in background'
|
|
359
|
-
});
|
|
360
|
-
|
|
361
|
-
} catch (error) {
|
|
362
|
-
logger.error('Error starting AI analysis:', error);
|
|
363
|
-
res.status(500).json({
|
|
364
|
-
error: 'Failed to start AI analysis'
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
* Get AI analysis status
|
|
371
|
-
*/
|
|
372
|
-
router.get('/api/analyze/status/:id', async (req, res) => {
|
|
373
|
-
try {
|
|
374
|
-
const { id } = req.params;
|
|
375
|
-
|
|
376
|
-
const analysis = activeAnalyses.get(id);
|
|
377
|
-
|
|
378
|
-
if (!analysis) {
|
|
379
|
-
return res.status(404).json({
|
|
380
|
-
error: 'Analysis not found'
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
res.json(analysis);
|
|
385
|
-
|
|
386
|
-
} catch (error) {
|
|
387
|
-
logger.error('Error fetching analysis status:', error);
|
|
388
|
-
res.status(500).json({
|
|
389
|
-
error: 'Failed to fetch analysis status'
|
|
390
|
-
});
|
|
391
|
-
}
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* Cancel an active AI analysis
|
|
396
|
-
*/
|
|
397
|
-
router.post('/api/analyze/cancel/:id', async (req, res) => {
|
|
398
|
-
try {
|
|
399
|
-
const { id } = req.params;
|
|
400
|
-
|
|
401
|
-
const analysis = activeAnalyses.get(id);
|
|
402
|
-
|
|
403
|
-
if (!analysis) {
|
|
404
|
-
return res.status(404).json({
|
|
405
|
-
error: 'Analysis not found'
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// Check if already completed/failed/cancelled
|
|
410
|
-
if (['completed', 'failed', 'cancelled'].includes(analysis.status)) {
|
|
411
|
-
return res.json({
|
|
412
|
-
success: true,
|
|
413
|
-
message: `Analysis already ${analysis.status}`,
|
|
414
|
-
status: analysis.status
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
logger.section(`Cancelling Analysis: ${id}`);
|
|
419
|
-
// Log context based on review type (PR mode vs local mode)
|
|
420
|
-
if (analysis.reviewType === 'local') {
|
|
421
|
-
logger.log('API', `Local review #${analysis.reviewId} in ${analysis.repository}`, 'yellow');
|
|
422
|
-
} else {
|
|
423
|
-
logger.log('API', `PR #${analysis.prNumber} in ${analysis.repository}`, 'yellow');
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// Kill all running child processes for this analysis
|
|
427
|
-
const killedCount = killProcesses(id);
|
|
428
|
-
logger.info(`Killed ${killedCount} running process(es)`);
|
|
429
|
-
|
|
430
|
-
// Update database record to cancelled
|
|
431
|
-
if (analysis.runId) {
|
|
432
|
-
try {
|
|
433
|
-
const db = req.app.get('db');
|
|
434
|
-
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
435
|
-
await analysisRunRepo.update(analysis.runId, { status: 'cancelled' });
|
|
436
|
-
logger.info(`Updated analysis_run DB record to cancelled: ${analysis.runId}`);
|
|
437
|
-
} catch (dbError) {
|
|
438
|
-
logger.warn(`Failed to update analysis_run DB record: ${dbError.message}`);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Update analysis status to cancelled
|
|
443
|
-
const cancelledStatus = {
|
|
444
|
-
...analysis,
|
|
445
|
-
status: 'cancelled',
|
|
446
|
-
cancelledAt: new Date().toISOString(),
|
|
447
|
-
progress: 'Analysis cancelled by user',
|
|
448
|
-
levels: {
|
|
449
|
-
...analysis.levels,
|
|
450
|
-
// Mark any running levels as cancelled
|
|
451
|
-
// Note: Level 4 represents orchestration (the synthesis phase after levels 1-3)
|
|
452
|
-
1: analysis.levels?.[1]?.status === 'running'
|
|
453
|
-
? { status: 'cancelled', progress: 'Cancelled' }
|
|
454
|
-
: analysis.levels?.[1],
|
|
455
|
-
2: analysis.levels?.[2]?.status === 'running'
|
|
456
|
-
? { status: 'cancelled', progress: 'Cancelled' }
|
|
457
|
-
: analysis.levels?.[2],
|
|
458
|
-
3: analysis.levels?.[3]?.status === 'running'
|
|
459
|
-
? { status: 'cancelled', progress: 'Cancelled' }
|
|
460
|
-
: analysis.levels?.[3],
|
|
461
|
-
4: analysis.levels?.[4]?.status === 'running'
|
|
462
|
-
? { status: 'cancelled', progress: 'Cancelled' }
|
|
463
|
-
: analysis.levels?.[4]
|
|
464
|
-
}
|
|
465
|
-
};
|
|
466
|
-
|
|
467
|
-
activeAnalyses.set(id, cancelledStatus);
|
|
468
|
-
|
|
469
|
-
// Broadcast cancelled status to SSE clients
|
|
470
|
-
broadcastProgress(id, cancelledStatus);
|
|
471
|
-
|
|
472
|
-
// Clean up PR to analysis ID mapping (PR mode only)
|
|
473
|
-
// Local mode cleanup is handled in the local.js analyze endpoint's .finally() block
|
|
474
|
-
if (analysis.reviewType !== 'local' && analysis.repository && analysis.prNumber) {
|
|
475
|
-
const [owner, repo] = analysis.repository.split('/');
|
|
476
|
-
const prKey = getPRKey(owner, repo, analysis.prNumber);
|
|
477
|
-
prToAnalysisId.delete(prKey);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
logger.success(`Analysis ${id} cancelled successfully`);
|
|
481
|
-
|
|
482
|
-
res.json({
|
|
483
|
-
success: true,
|
|
484
|
-
message: 'Analysis cancelled',
|
|
485
|
-
processesKilled: killedCount,
|
|
486
|
-
status: 'cancelled'
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
} catch (error) {
|
|
490
|
-
logger.error(`Error cancelling analysis: ${error.message}`);
|
|
491
|
-
res.status(500).json({
|
|
492
|
-
error: 'Failed to cancel analysis'
|
|
493
|
-
});
|
|
494
|
-
}
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
/**
|
|
498
|
-
* Check if analysis is running for a specific PR
|
|
499
|
-
*/
|
|
500
|
-
router.get('/api/pr/:owner/:repo/:number/analysis-status', async (req, res) => {
|
|
501
|
-
try {
|
|
502
|
-
const { owner, repo, number } = req.params;
|
|
503
|
-
const prKey = getPRKey(owner, repo, number);
|
|
504
|
-
|
|
505
|
-
const analysisId = prToAnalysisId.get(prKey);
|
|
506
|
-
|
|
507
|
-
if (analysisId) {
|
|
508
|
-
const analysis = activeAnalyses.get(analysisId);
|
|
509
|
-
|
|
510
|
-
if (analysis) {
|
|
511
|
-
return res.json({
|
|
512
|
-
running: true,
|
|
513
|
-
analysisId,
|
|
514
|
-
status: analysis
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Clean up stale mapping
|
|
519
|
-
prToAnalysisId.delete(prKey);
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// Fall back to database — an analysis may have been started externally (e.g. via MCP)
|
|
523
|
-
const db = req.app.get('db');
|
|
524
|
-
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
525
|
-
const repository = `${owner}/${repo}`;
|
|
526
|
-
const review = await queryOne(db, 'SELECT id FROM reviews WHERE repository = ? AND pr_number = ?', [repository, number]);
|
|
527
|
-
|
|
528
|
-
if (review) {
|
|
529
|
-
const latestRun = await analysisRunRepo.getLatestByReviewId(review.id);
|
|
530
|
-
|
|
531
|
-
if (latestRun && latestRun.status === 'running') {
|
|
532
|
-
return res.json({
|
|
533
|
-
running: true,
|
|
534
|
-
analysisId: latestRun.id,
|
|
535
|
-
status: {
|
|
536
|
-
id: latestRun.id,
|
|
537
|
-
prNumber: parseInt(number),
|
|
538
|
-
repository: `${owner}/${repo}`,
|
|
539
|
-
status: 'running',
|
|
540
|
-
startedAt: latestRun.started_at,
|
|
541
|
-
progress: 'Analysis in progress...',
|
|
542
|
-
levels: {
|
|
543
|
-
1: { status: 'running', progress: 'Running...' },
|
|
544
|
-
2: { status: 'running', progress: 'Running...' },
|
|
545
|
-
3: { status: 'running', progress: 'Running...' },
|
|
546
|
-
4: { status: 'pending', progress: 'Pending' }
|
|
547
|
-
},
|
|
548
|
-
filesAnalyzed: latestRun.files_analyzed || 0,
|
|
549
|
-
filesRemaining: 0
|
|
550
|
-
}
|
|
551
|
-
});
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
res.json({
|
|
556
|
-
running: false,
|
|
557
|
-
analysisId: null,
|
|
558
|
-
status: null
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
} catch (error) {
|
|
562
|
-
logger.error('Error checking PR analysis status:', error);
|
|
563
|
-
res.status(500).json({
|
|
564
|
-
error: 'Failed to check analysis status'
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Check if a PR has existing AI suggestions
|
|
571
|
-
* Also returns whether AI analysis has ever been run (even if no suggestions were found)
|
|
572
|
-
*/
|
|
573
|
-
router.get('/api/pr/:owner/:repo/:number/has-ai-suggestions', async (req, res) => {
|
|
574
|
-
try {
|
|
575
|
-
const { owner, repo, number } = req.params;
|
|
576
|
-
const { runId } = req.query;
|
|
577
|
-
const prNumber = parseInt(number);
|
|
578
|
-
|
|
579
|
-
if (isNaN(prNumber) || prNumber <= 0) {
|
|
580
|
-
return res.status(400).json({
|
|
581
|
-
error: 'Invalid pull request number'
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const repository = normalizeRepository(owner, repo);
|
|
586
|
-
const db = req.app.get('db');
|
|
587
|
-
|
|
588
|
-
// Get PR metadata to verify PR exists and get last_ai_run_id
|
|
589
|
-
const prMetadata = await queryOne(db, `
|
|
590
|
-
SELECT id FROM pr_metadata
|
|
591
|
-
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
592
|
-
`, [prNumber, repository]);
|
|
593
|
-
|
|
594
|
-
if (!prMetadata) {
|
|
595
|
-
return res.status(404).json({
|
|
596
|
-
error: `Pull request #${prNumber} not found`
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// Get review record for this PR (don't create one - GET should not have side effects)
|
|
601
|
-
// Comments are associated with review.id to avoid ID collision with local mode
|
|
602
|
-
const reviewRepo = new ReviewRepository(db);
|
|
603
|
-
const review = await reviewRepo.getReviewByPR(prNumber, repository);
|
|
604
|
-
|
|
605
|
-
// If no review exists, no analysis has been run
|
|
606
|
-
if (!review) {
|
|
607
|
-
return res.json({
|
|
608
|
-
hasSuggestions: false,
|
|
609
|
-
analysisHasRun: false,
|
|
610
|
-
summary: null,
|
|
611
|
-
stats: { issues: 0, suggestions: 0, praise: 0 }
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Check if any AI suggestions exist for this PR using review.id
|
|
616
|
-
// Exclude raw council voice suggestions (is_raw=1) — only count final/consolidated suggestions
|
|
617
|
-
const result = await queryOne(db, `
|
|
618
|
-
SELECT EXISTS(
|
|
619
|
-
SELECT 1 FROM comments
|
|
620
|
-
WHERE review_id = ? AND source = 'ai' AND (is_raw = 0 OR is_raw IS NULL)
|
|
621
|
-
) as has_suggestions
|
|
622
|
-
`, [review.id]);
|
|
623
|
-
|
|
624
|
-
const hasSuggestions = result?.has_suggestions === 1;
|
|
625
|
-
|
|
626
|
-
// Check if any analysis has been run by looking for analysis_runs records
|
|
627
|
-
// Falls back to checking pr_metadata.last_ai_run_id for backwards compatibility
|
|
628
|
-
let analysisHasRun = hasSuggestions;
|
|
629
|
-
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
630
|
-
let selectedRun = null;
|
|
631
|
-
try {
|
|
632
|
-
// If runId is provided, fetch that specific run; otherwise get the latest
|
|
633
|
-
if (runId) {
|
|
634
|
-
selectedRun = await analysisRunRepo.getById(runId);
|
|
635
|
-
} else {
|
|
636
|
-
selectedRun = await analysisRunRepo.getLatestByReviewId(review.id);
|
|
637
|
-
}
|
|
638
|
-
// Analysis has been run if there's an analysis_run record OR if there are any AI suggestions
|
|
639
|
-
analysisHasRun = !!(selectedRun || hasSuggestions);
|
|
640
|
-
} catch (e) {
|
|
641
|
-
// Log the error at debug level before attempting fallback
|
|
642
|
-
logger.debug('analysis_runs query failed, falling back to pr_metadata:', e.message);
|
|
643
|
-
// If analysis_runs table doesn't exist yet, fall back to pr_metadata.last_ai_run_id
|
|
644
|
-
try {
|
|
645
|
-
const runCheck = await queryOne(db, `
|
|
646
|
-
SELECT last_ai_run_id FROM pr_metadata
|
|
647
|
-
WHERE id = ?
|
|
648
|
-
`, [prMetadata.id]);
|
|
649
|
-
analysisHasRun = !!(runCheck?.last_ai_run_id || hasSuggestions);
|
|
650
|
-
} catch (fallbackError) {
|
|
651
|
-
logger.debug('pr_metadata fallback also failed:', fallbackError.message);
|
|
652
|
-
// Fall back to using hasSuggestions if both fail
|
|
653
|
-
analysisHasRun = hasSuggestions;
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Get AI summary from the selected analysis run if available, otherwise fall back to review summary
|
|
658
|
-
const summary = selectedRun?.summary || review?.summary || null;
|
|
659
|
-
|
|
660
|
-
// Get stats for AI suggestions (issues/suggestions/praise for final level only)
|
|
661
|
-
// Filter by runId if provided, otherwise use the latest analysis run
|
|
662
|
-
let stats = { issues: 0, suggestions: 0, praise: 0 };
|
|
663
|
-
if (hasSuggestions) {
|
|
664
|
-
try {
|
|
665
|
-
const statsQuery = getStatsQuery(runId);
|
|
666
|
-
const statsResult = await query(db, statsQuery.query, statsQuery.params(review.id));
|
|
667
|
-
stats = calculateStats(statsResult);
|
|
668
|
-
} catch (e) {
|
|
669
|
-
logger.warn('Error fetching AI suggestion stats:', e);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
res.json({
|
|
674
|
-
hasSuggestions: hasSuggestions,
|
|
675
|
-
analysisHasRun: analysisHasRun,
|
|
676
|
-
summary: summary,
|
|
677
|
-
stats: stats
|
|
678
|
-
});
|
|
679
|
-
} catch (error) {
|
|
680
|
-
logger.error('Error checking for AI suggestions:', error);
|
|
681
|
-
res.status(500).json({
|
|
682
|
-
error: 'Failed to check for AI suggestions'
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
});
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* Get AI suggestions for a PR (compatibility endpoint with owner/repo/number)
|
|
689
|
-
*/
|
|
690
|
-
router.get('/api/pr/:owner/:repo/:number/ai-suggestions', async (req, res) => {
|
|
691
|
-
try {
|
|
692
|
-
const { owner, repo, number } = req.params;
|
|
693
|
-
const prNumber = parseInt(number);
|
|
694
|
-
|
|
695
|
-
if (isNaN(prNumber) || prNumber <= 0) {
|
|
696
|
-
return res.status(400).json({
|
|
697
|
-
error: 'Invalid pull request number'
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
const repository = normalizeRepository(owner, repo);
|
|
702
|
-
const db = req.app.get('db');
|
|
703
|
-
|
|
704
|
-
// Get PR metadata to verify PR exists
|
|
705
|
-
const prMetadata = await queryOne(db, `
|
|
706
|
-
SELECT id FROM pr_metadata
|
|
707
|
-
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
708
|
-
`, [prNumber, repository]);
|
|
709
|
-
|
|
710
|
-
if (!prMetadata) {
|
|
711
|
-
return res.status(404).json({
|
|
712
|
-
error: `Pull request #${prNumber} not found`
|
|
713
|
-
});
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
// Get review record for this PR (don't create one - GET should not have side effects)
|
|
717
|
-
// Comments are associated with review.id to avoid ID collision with local mode
|
|
718
|
-
const reviewRepo = new ReviewRepository(db);
|
|
719
|
-
const review = await reviewRepo.getReviewByPR(prNumber, repository);
|
|
720
|
-
|
|
721
|
-
// If no review exists, return empty suggestions
|
|
722
|
-
if (!review) {
|
|
723
|
-
return res.json({ suggestions: [] });
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
// Parse levels query parameter (e.g., ?levels=final,1,2)
|
|
727
|
-
// Default to 'final' (orchestrated suggestions only) if not specified
|
|
728
|
-
const levelsParam = req.query.levels || 'final';
|
|
729
|
-
const requestedLevels = levelsParam.split(',').map(l => l.trim());
|
|
730
|
-
|
|
731
|
-
// Parse optional runId query parameter to fetch suggestions from a specific analysis run
|
|
732
|
-
// If not provided, defaults to the latest run
|
|
733
|
-
const runIdParam = req.query.runId;
|
|
734
|
-
|
|
735
|
-
// Build level filter clause
|
|
736
|
-
const levelConditions = [];
|
|
737
|
-
requestedLevels.forEach(level => {
|
|
738
|
-
if (level === 'final') {
|
|
739
|
-
levelConditions.push('ai_level IS NULL');
|
|
740
|
-
} else if (['1', '2', '3'].includes(level)) {
|
|
741
|
-
levelConditions.push(`ai_level = ${parseInt(level)}`);
|
|
742
|
-
}
|
|
743
|
-
});
|
|
744
|
-
|
|
745
|
-
// If no valid levels specified, default to final
|
|
746
|
-
const levelFilter = levelConditions.length > 0
|
|
747
|
-
? `(${levelConditions.join(' OR ')})`
|
|
748
|
-
: 'ai_level IS NULL';
|
|
749
|
-
|
|
750
|
-
// Build the run ID filter clause
|
|
751
|
-
// If a specific runId is provided, use it directly; otherwise use subquery for latest
|
|
752
|
-
let runIdFilter;
|
|
753
|
-
let queryParams;
|
|
754
|
-
if (runIdParam) {
|
|
755
|
-
runIdFilter = 'ai_run_id = ?';
|
|
756
|
-
queryParams = [review.id, runIdParam];
|
|
757
|
-
} else {
|
|
758
|
-
// Get AI suggestions from the comments table
|
|
759
|
-
// Only return suggestions from the latest analysis run (ai_run_id)
|
|
760
|
-
// This preserves history while showing only the most recent results
|
|
761
|
-
//
|
|
762
|
-
// Note: If no AI suggestions exist (subquery returns NULL), the ai_run_id = NULL
|
|
763
|
-
// comparison returns no rows. This is intentional - we only show suggestions
|
|
764
|
-
// when there's a matching analysis run.
|
|
765
|
-
//
|
|
766
|
-
// Note: review.id is passed twice because SQLite requires separate parameters
|
|
767
|
-
// for the outer WHERE clause and the subquery. A CTE could consolidate this but
|
|
768
|
-
// adds complexity without meaningful benefit here.
|
|
769
|
-
runIdFilter = `ai_run_id = (
|
|
770
|
-
SELECT ai_run_id FROM comments
|
|
771
|
-
WHERE review_id = ? AND source = 'ai' AND ai_run_id IS NOT NULL
|
|
772
|
-
ORDER BY created_at DESC
|
|
773
|
-
LIMIT 1
|
|
774
|
-
)`;
|
|
775
|
-
queryParams = [review.id, review.id];
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
const rows = await query(db, `
|
|
779
|
-
SELECT
|
|
780
|
-
id,
|
|
781
|
-
source,
|
|
782
|
-
author,
|
|
783
|
-
ai_run_id,
|
|
784
|
-
ai_level,
|
|
785
|
-
ai_confidence,
|
|
786
|
-
file,
|
|
787
|
-
line_start,
|
|
788
|
-
line_end,
|
|
789
|
-
side,
|
|
790
|
-
type,
|
|
791
|
-
title,
|
|
792
|
-
body,
|
|
793
|
-
reasoning,
|
|
794
|
-
status,
|
|
795
|
-
is_file_level,
|
|
796
|
-
created_at,
|
|
797
|
-
updated_at
|
|
798
|
-
FROM comments
|
|
799
|
-
WHERE review_id = ?
|
|
800
|
-
AND source = 'ai'
|
|
801
|
-
AND ${levelFilter}
|
|
802
|
-
AND status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')
|
|
803
|
-
AND (is_raw = 0 OR is_raw IS NULL)
|
|
804
|
-
AND ${runIdFilter}
|
|
805
|
-
ORDER BY
|
|
806
|
-
CASE
|
|
807
|
-
WHEN ai_level IS NULL THEN 0
|
|
808
|
-
WHEN ai_level = 1 THEN 1
|
|
809
|
-
WHEN ai_level = 2 THEN 2
|
|
810
|
-
WHEN ai_level = 3 THEN 3
|
|
811
|
-
ELSE 4
|
|
812
|
-
END,
|
|
813
|
-
is_file_level DESC,
|
|
814
|
-
file,
|
|
815
|
-
line_start
|
|
816
|
-
`, queryParams);
|
|
817
|
-
|
|
818
|
-
const suggestions = rows.map(row => ({
|
|
819
|
-
...row,
|
|
820
|
-
reasoning: row.reasoning ? JSON.parse(row.reasoning) : null
|
|
821
|
-
}));
|
|
822
|
-
|
|
823
|
-
res.json({ suggestions });
|
|
824
|
-
|
|
825
|
-
} catch (error) {
|
|
826
|
-
logger.error('Error fetching AI suggestions:', error);
|
|
827
|
-
res.status(500).json({
|
|
828
|
-
error: 'Failed to fetch AI suggestions'
|
|
829
|
-
});
|
|
830
|
-
}
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
/**
|
|
834
|
-
* Server-Sent Events endpoint for AI analysis progress
|
|
835
|
-
*/
|
|
836
|
-
router.get('/api/pr/:id/ai-suggestions/status', (req, res) => {
|
|
837
|
-
const analysisId = req.params.id;
|
|
838
|
-
|
|
839
|
-
// Set up SSE headers
|
|
840
|
-
res.writeHead(200, {
|
|
841
|
-
'Content-Type': 'text/event-stream',
|
|
842
|
-
'Cache-Control': 'no-cache',
|
|
843
|
-
'Connection': 'keep-alive',
|
|
844
|
-
'Access-Control-Allow-Origin': '*',
|
|
845
|
-
'Access-Control-Allow-Headers': 'Cache-Control'
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
// Send initial connection message
|
|
849
|
-
res.write('data: {"type":"connected","message":"Connected to progress stream"}\n\n');
|
|
850
|
-
|
|
851
|
-
// Store client for this analysis
|
|
852
|
-
if (!progressClients.has(analysisId)) {
|
|
853
|
-
progressClients.set(analysisId, new Set());
|
|
854
|
-
}
|
|
855
|
-
progressClients.get(analysisId).add(res);
|
|
856
|
-
|
|
857
|
-
// Send current status if analysis exists
|
|
858
|
-
const currentStatus = activeAnalyses.get(analysisId);
|
|
859
|
-
if (currentStatus) {
|
|
860
|
-
res.write(`data: ${JSON.stringify({
|
|
861
|
-
type: 'progress',
|
|
862
|
-
...currentStatus
|
|
863
|
-
})}\n\n`);
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// Handle client disconnect
|
|
867
|
-
req.on('close', () => {
|
|
868
|
-
const clients = progressClients.get(analysisId);
|
|
869
|
-
if (clients) {
|
|
870
|
-
clients.delete(res);
|
|
871
|
-
if (clients.size === 0) {
|
|
872
|
-
progressClients.delete(analysisId);
|
|
873
|
-
}
|
|
874
|
-
}
|
|
875
|
-
});
|
|
876
|
-
|
|
877
|
-
req.on('error', () => {
|
|
878
|
-
const clients = progressClients.get(analysisId);
|
|
879
|
-
if (clients) {
|
|
880
|
-
clients.delete(res);
|
|
881
|
-
if (clients.size === 0) {
|
|
882
|
-
progressClients.delete(analysisId);
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
});
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
/**
|
|
889
|
-
* Get all analysis runs for a review
|
|
890
|
-
* Works for both PR mode (owner/repo/pr) and local mode (reviewId)
|
|
891
|
-
*/
|
|
892
|
-
router.get('/api/analysis-runs/:reviewId', async (req, res) => {
|
|
893
|
-
try {
|
|
894
|
-
const { reviewId } = req.params;
|
|
895
|
-
const db = req.app.get('db');
|
|
896
|
-
|
|
897
|
-
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
898
|
-
const runs = await analysisRunRepo.getByReviewId(parseInt(reviewId, 10));
|
|
899
|
-
|
|
900
|
-
res.json({ runs: runs.map(r => ({
|
|
901
|
-
...r,
|
|
902
|
-
levels_config: r.levels_config ? JSON.parse(r.levels_config) : null
|
|
903
|
-
})) });
|
|
904
|
-
} catch (error) {
|
|
905
|
-
logger.error('Error fetching analysis runs:', error);
|
|
906
|
-
res.status(500).json({ error: 'Failed to fetch analysis runs' });
|
|
907
|
-
}
|
|
908
|
-
});
|
|
909
|
-
|
|
910
|
-
/**
|
|
911
|
-
* Get the most recent analysis run for a review
|
|
912
|
-
*/
|
|
913
|
-
router.get('/api/analysis-runs/:reviewId/latest', async (req, res) => {
|
|
914
|
-
try {
|
|
915
|
-
const { reviewId } = req.params;
|
|
916
|
-
const db = req.app.get('db');
|
|
917
|
-
|
|
918
|
-
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
919
|
-
const run = await analysisRunRepo.getLatestByReviewId(parseInt(reviewId, 10));
|
|
920
|
-
|
|
921
|
-
if (!run) {
|
|
922
|
-
return res.status(404).json({ error: 'No analysis runs found' });
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
res.json({ run });
|
|
926
|
-
} catch (error) {
|
|
927
|
-
logger.error('Error fetching latest analysis run:', error);
|
|
928
|
-
res.status(500).json({ error: 'Failed to fetch latest analysis run' });
|
|
929
|
-
}
|
|
930
|
-
});
|
|
931
|
-
|
|
932
|
-
/**
|
|
933
|
-
* Get a specific analysis run by ID
|
|
934
|
-
*/
|
|
935
|
-
router.get('/api/analysis-run/:runId', async (req, res) => {
|
|
936
|
-
try {
|
|
937
|
-
const { runId } = req.params;
|
|
938
|
-
const db = req.app.get('db');
|
|
939
|
-
|
|
940
|
-
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
941
|
-
const run = await analysisRunRepo.getById(runId);
|
|
942
|
-
|
|
943
|
-
if (!run) {
|
|
944
|
-
return res.status(404).json({ error: 'Analysis run not found' });
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
res.json({ run });
|
|
948
|
-
} catch (error) {
|
|
949
|
-
logger.error('Error fetching analysis run:', error);
|
|
950
|
-
res.status(500).json({ error: 'Failed to fetch analysis run' });
|
|
951
|
-
}
|
|
952
|
-
});
|
|
953
|
-
|
|
954
|
-
/**
|
|
955
|
-
* Import externally-produced analysis results
|
|
956
|
-
*
|
|
957
|
-
* Accepts suggestions generated outside pair-review (e.g. by a coding agent's
|
|
958
|
-
* analyze skill) and stores them as a completed analysis run so they appear
|
|
959
|
-
* inline in the web UI.
|
|
960
|
-
*/
|
|
961
|
-
router.post('/api/analysis-results', async (req, res) => {
|
|
962
|
-
try {
|
|
963
|
-
const {
|
|
964
|
-
path: localPath,
|
|
965
|
-
headSha,
|
|
966
|
-
repo,
|
|
967
|
-
prNumber,
|
|
968
|
-
provider = null,
|
|
969
|
-
model = null,
|
|
970
|
-
summary = null,
|
|
971
|
-
suggestions = [],
|
|
972
|
-
fileLevelSuggestions = []
|
|
973
|
-
} = req.body || {};
|
|
974
|
-
|
|
975
|
-
// --- Validate identification pair ---
|
|
976
|
-
const hasLocal = localPath && headSha;
|
|
977
|
-
const hasPR = repo && prNumber != null;
|
|
978
|
-
|
|
979
|
-
if (!hasLocal && !hasPR) {
|
|
980
|
-
return res.status(400).json({
|
|
981
|
-
error: 'Must provide either (path + headSha) for local mode or (repo + prNumber) for PR mode'
|
|
982
|
-
});
|
|
983
|
-
}
|
|
984
|
-
if (hasLocal && hasPR) {
|
|
985
|
-
return res.status(400).json({
|
|
986
|
-
error: 'Provide only one identification pair: (path + headSha) or (repo + prNumber), not both'
|
|
987
|
-
});
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
// --- Validate suggestions ---
|
|
991
|
-
if (!Array.isArray(suggestions)) {
|
|
992
|
-
return res.status(400).json({ error: 'suggestions must be an array' });
|
|
993
|
-
}
|
|
994
|
-
if (!Array.isArray(fileLevelSuggestions)) {
|
|
995
|
-
return res.status(400).json({ error: 'fileLevelSuggestions must be an array' });
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
const REQUIRED_SUGGESTION_FIELDS = ['file', 'type', 'title', 'description'];
|
|
999
|
-
for (const [idx, s] of suggestions.entries()) {
|
|
1000
|
-
for (const field of REQUIRED_SUGGESTION_FIELDS) {
|
|
1001
|
-
if (!s[field]) {
|
|
1002
|
-
return res.status(400).json({
|
|
1003
|
-
error: `suggestions[${idx}] missing required field: ${field}`
|
|
1004
|
-
});
|
|
1005
|
-
}
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
for (const [idx, s] of fileLevelSuggestions.entries()) {
|
|
1009
|
-
for (const field of REQUIRED_SUGGESTION_FIELDS) {
|
|
1010
|
-
if (!s[field]) {
|
|
1011
|
-
return res.status(400).json({
|
|
1012
|
-
error: `fileLevelSuggestions[${idx}] missing required field: ${field}`
|
|
1013
|
-
});
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
const db = req.app.get('db');
|
|
1019
|
-
const reviewRepo = new ReviewRepository(db);
|
|
1020
|
-
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
1021
|
-
|
|
1022
|
-
// --- Resolve review ---
|
|
1023
|
-
let reviewId;
|
|
1024
|
-
if (hasLocal) {
|
|
1025
|
-
// Local mode: derive repository name from the directory basename
|
|
1026
|
-
const repository = path.basename(localPath) || 'local';
|
|
1027
|
-
reviewId = await reviewRepo.upsertLocalReview({
|
|
1028
|
-
localPath,
|
|
1029
|
-
localHeadSha: headSha,
|
|
1030
|
-
repository
|
|
1031
|
-
});
|
|
1032
|
-
|
|
1033
|
-
// Generate and store diff so the web UI can display it
|
|
1034
|
-
// This is a git operation, not a DB operation, so it runs outside the transaction
|
|
1035
|
-
try {
|
|
1036
|
-
const diffResult = await generateLocalDiff(localPath);
|
|
1037
|
-
const digest = await computeLocalDiffDigest(localPath);
|
|
1038
|
-
localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
|
|
1039
|
-
} catch (diffError) {
|
|
1040
|
-
// Non-fatal: the review is still usable, just without diff data
|
|
1041
|
-
logger.warn(`Could not generate diff for local review ${reviewId}: ${diffError.message}`);
|
|
1042
|
-
}
|
|
1043
|
-
} else {
|
|
1044
|
-
const repoParts = repo.split('/');
|
|
1045
|
-
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
|
|
1046
|
-
return res.status(400).json({ error: 'repo must be in format owner/repo' });
|
|
1047
|
-
}
|
|
1048
|
-
const parsedPR = parseInt(prNumber, 10);
|
|
1049
|
-
if (isNaN(parsedPR) || parsedPR <= 0) {
|
|
1050
|
-
return res.status(400).json({ error: 'Invalid pull request number' });
|
|
1051
|
-
}
|
|
1052
|
-
const repository = normalizeRepository(repoParts[0], repoParts[1]);
|
|
1053
|
-
const review = await reviewRepo.getOrCreate({
|
|
1054
|
-
prNumber: parsedPR,
|
|
1055
|
-
repository
|
|
1056
|
-
});
|
|
1057
|
-
reviewId = review.id;
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
// --- Create completed analysis run, insert suggestions, update stats ---
|
|
1061
|
-
const runId = uuidv4();
|
|
1062
|
-
const allSuggestions = [
|
|
1063
|
-
...suggestions.map(s => ({ ...s, is_file_level: false })),
|
|
1064
|
-
...fileLevelSuggestions.map(s => ({ ...s, is_file_level: true }))
|
|
1065
|
-
];
|
|
1066
|
-
const totalSuggestions = allSuggestions.length;
|
|
1067
|
-
const filesAnalyzed = new Set(allSuggestions.map(s => s.file)).size;
|
|
1068
|
-
|
|
1069
|
-
const commentRepo = new CommentRepository(db);
|
|
1070
|
-
|
|
1071
|
-
await withTransaction(db, async () => {
|
|
1072
|
-
await analysisRunRepo.create({
|
|
1073
|
-
id: runId,
|
|
1074
|
-
reviewId,
|
|
1075
|
-
provider,
|
|
1076
|
-
model,
|
|
1077
|
-
headSha: headSha || null,
|
|
1078
|
-
status: 'completed'
|
|
1079
|
-
});
|
|
1080
|
-
|
|
1081
|
-
await commentRepo.bulkInsertAISuggestions(reviewId, runId, allSuggestions);
|
|
1082
|
-
|
|
1083
|
-
await analysisRunRepo.update(runId, {
|
|
1084
|
-
summary,
|
|
1085
|
-
totalSuggestions,
|
|
1086
|
-
filesAnalyzed
|
|
1087
|
-
});
|
|
1088
|
-
});
|
|
1089
|
-
|
|
1090
|
-
// --- Broadcast SSE completion event (after transaction completes) ---
|
|
1091
|
-
const completionEvent = {
|
|
1092
|
-
id: runId,
|
|
1093
|
-
status: 'completed',
|
|
1094
|
-
completedAt: new Date().toISOString(),
|
|
1095
|
-
progress: `Analysis complete — ${totalSuggestions} suggestion${totalSuggestions !== 1 ? 's' : ''}`,
|
|
1096
|
-
suggestionsCount: totalSuggestions,
|
|
1097
|
-
filesAnalyzed,
|
|
1098
|
-
levels: {
|
|
1099
|
-
1: { status: 'completed', progress: 'Complete' },
|
|
1100
|
-
2: { status: 'completed', progress: 'Complete' },
|
|
1101
|
-
3: { status: 'completed', progress: 'Complete' },
|
|
1102
|
-
4: { status: 'completed', progress: 'Complete' }
|
|
1103
|
-
}
|
|
1104
|
-
};
|
|
1105
|
-
broadcastProgress(runId, completionEvent);
|
|
1106
|
-
|
|
1107
|
-
// Broadcast on the review-level key so the frontend auto-refreshes.
|
|
1108
|
-
// Local mode SSE clients register under `local-${reviewId}`;
|
|
1109
|
-
// PR mode SSE clients register under `review-${reviewId}`.
|
|
1110
|
-
const reviewKey = hasLocal ? `local-${reviewId}` : `review-${reviewId}`;
|
|
1111
|
-
broadcastProgress(reviewKey, { ...completionEvent, source: 'external' });
|
|
1112
|
-
|
|
1113
|
-
logger.success(`Imported ${totalSuggestions} external analysis suggestions (run ${runId})`);
|
|
1114
|
-
|
|
1115
|
-
res.status(201).json({
|
|
1116
|
-
runId,
|
|
1117
|
-
reviewId,
|
|
1118
|
-
totalSuggestions,
|
|
1119
|
-
status: 'completed'
|
|
1120
|
-
});
|
|
1121
|
-
} catch (error) {
|
|
1122
|
-
logger.error('Error importing analysis results:', error);
|
|
1123
|
-
res.status(500).json({ error: 'Failed to import analysis results' });
|
|
1124
|
-
}
|
|
1125
|
-
});
|
|
1126
|
-
|
|
1127
|
-
/**
|
|
1128
|
-
* Launch a council analysis, shared by both PR and local mode.
|
|
1129
|
-
*
|
|
1130
|
-
* This helper encapsulates all the common logic: council config resolution/validation,
|
|
1131
|
-
* analysis run record creation, progress tracking setup, async analyzer invocation,
|
|
1132
|
-
* completion/failure status broadcasting, and tracking map cleanup.
|
|
1133
|
-
*
|
|
1134
|
-
* Mode-specific differences are injected via the `modeContext` parameter.
|
|
1135
|
-
*
|
|
1136
|
-
* @param {Object} db - Database handle
|
|
1137
|
-
* @param {Object} modeContext - Mode-specific values:
|
|
1138
|
-
* @param {number} modeContext.reviewId - Review record ID
|
|
1139
|
-
* @param {string} modeContext.worktreePath - Path to the worktree or local directory
|
|
1140
|
-
* @param {Object} modeContext.prMetadata - PR metadata (from DB for PR mode, synthetic for local mode)
|
|
1141
|
-
* @param {Array|null} modeContext.changedFiles - Changed files list (null for PR mode)
|
|
1142
|
-
* @param {string} modeContext.repository - Repository identifier (e.g., "owner/repo")
|
|
1143
|
-
* @param {string} modeContext.headSha - HEAD SHA for the analysis run record
|
|
1144
|
-
* @param {Object} modeContext.trackingMap - Map instance for tracking (prToAnalysisId or localReviewToAnalysisId)
|
|
1145
|
-
* @param {string} modeContext.trackingKey - Key for the tracking map (getPRKey() or getLocalReviewKey())
|
|
1146
|
-
* @param {string} modeContext.logLabel - Human-readable label for logs (e.g., "PR #42" or "local review #7")
|
|
1147
|
-
* @param {Object} [modeContext.initialStatusExtra] - Extra fields merged into the initial status object
|
|
1148
|
-
* @param {Array<string>} [modeContext.extraBroadcastKeys] - Additional SSE keys to broadcast completion to
|
|
1149
|
-
* @param {Function} [modeContext.onSuccess] - Optional async callback invoked after successful completion
|
|
1150
|
-
* with signature (result, analysisRunRepo, runId). Used for mode-specific side effects like saving
|
|
1151
|
-
* summary to the review record.
|
|
1152
|
-
* @param {Object} [modeContext.runUpdateExtra] - Extra fields merged into the analysisRunRepo.update call on success
|
|
1153
|
-
* @param {Object} councilConfig - Validated council configuration
|
|
1154
|
-
* @param {string} councilId - Council ID (for the model field in analysis_runs), or null for inline config
|
|
1155
|
-
* @param {Object} instructions - { repoInstructions, requestInstructions }
|
|
1156
|
-
* @returns {{ analysisId: string, runId: string }} IDs for the caller to return in the response
|
|
1157
|
-
*/
|
|
1158
|
-
/**
|
|
1159
|
-
* Check if a level is enabled in a council config, handling both formats:
|
|
1160
|
-
* - Voice-centric: levels values are booleans (e.g. { '1': true })
|
|
1161
|
-
* - Advanced: levels values are objects (e.g. { '1': { enabled: true, voices: [...] } })
|
|
1162
|
-
*/
|
|
1163
|
-
function isLevelEnabled(councilConfig, levelKey) {
|
|
1164
|
-
const val = councilConfig.levels?.[levelKey];
|
|
1165
|
-
if (typeof val === 'boolean') return val;
|
|
1166
|
-
return val?.enabled === true;
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId, instructions, configType = 'advanced') {
|
|
1170
|
-
const {
|
|
1171
|
-
reviewId,
|
|
1172
|
-
worktreePath,
|
|
1173
|
-
prMetadata,
|
|
1174
|
-
changedFiles,
|
|
1175
|
-
repository,
|
|
1176
|
-
headSha,
|
|
1177
|
-
trackingMap,
|
|
1178
|
-
trackingKey,
|
|
1179
|
-
logLabel,
|
|
1180
|
-
initialStatusExtra,
|
|
1181
|
-
extraBroadcastKeys,
|
|
1182
|
-
onSuccess,
|
|
1183
|
-
runUpdateExtra
|
|
1184
|
-
} = modeContext;
|
|
1185
|
-
|
|
1186
|
-
const { repoInstructions, requestInstructions } = instructions;
|
|
1187
|
-
|
|
1188
|
-
// Determine if voice-centric mode
|
|
1189
|
-
const isVoiceCentric = configType === 'council';
|
|
1190
|
-
|
|
1191
|
-
// Create run/analysis ID
|
|
1192
|
-
const runId = uuidv4();
|
|
1193
|
-
const analysisId = runId;
|
|
1194
|
-
|
|
1195
|
-
// Compute levelsConfig for the run record
|
|
1196
|
-
let levelsConfig = null;
|
|
1197
|
-
if (isVoiceCentric && councilConfig.levels) {
|
|
1198
|
-
levelsConfig = councilConfig.levels;
|
|
1199
|
-
} else if (councilConfig.levels) {
|
|
1200
|
-
levelsConfig = {};
|
|
1201
|
-
for (const [key, val] of Object.entries(councilConfig.levels)) {
|
|
1202
|
-
levelsConfig[key] = val?.enabled !== false;
|
|
1203
|
-
}
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
// Create DB analysis_runs record
|
|
1207
|
-
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
1208
|
-
await analysisRunRepo.create({
|
|
1209
|
-
id: runId,
|
|
1210
|
-
reviewId,
|
|
1211
|
-
provider: 'council',
|
|
1212
|
-
model: councilId || 'inline-config',
|
|
1213
|
-
repoInstructions,
|
|
1214
|
-
requestInstructions,
|
|
1215
|
-
headSha: headSha || null,
|
|
1216
|
-
configType,
|
|
1217
|
-
levelsConfig
|
|
1218
|
-
});
|
|
1219
|
-
|
|
1220
|
-
// Touch council MRU timestamp (if using a saved council, not inline-config)
|
|
1221
|
-
if (councilId) {
|
|
1222
|
-
const councilRepo = new CouncilRepository(db);
|
|
1223
|
-
councilRepo.touchLastUsedAt(councilId).catch(err => {
|
|
1224
|
-
logger.warn(`Failed to update council last_used_at: ${err.message}`);
|
|
1225
|
-
});
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
// Setup progress tracking
|
|
1229
|
-
const initialStatus = {
|
|
1230
|
-
id: analysisId,
|
|
1231
|
-
repository,
|
|
1232
|
-
status: 'running',
|
|
1233
|
-
startedAt: new Date().toISOString(),
|
|
1234
|
-
progress: 'Starting council analysis...',
|
|
1235
|
-
levels: {
|
|
1236
|
-
1: isLevelEnabled(councilConfig, '1') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
1237
|
-
2: isLevelEnabled(councilConfig, '2') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
1238
|
-
3: isLevelEnabled(councilConfig, '3') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
1239
|
-
4: { status: 'pending', progress: 'Pending' }
|
|
1240
|
-
},
|
|
1241
|
-
isCouncil: true,
|
|
1242
|
-
councilConfig,
|
|
1243
|
-
configType,
|
|
1244
|
-
filesAnalyzed: 0,
|
|
1245
|
-
filesRemaining: 0,
|
|
1246
|
-
...initialStatusExtra
|
|
1247
|
-
};
|
|
1248
|
-
activeAnalyses.set(analysisId, initialStatus);
|
|
1249
|
-
|
|
1250
|
-
// Store tracking map entry
|
|
1251
|
-
trackingMap.set(trackingKey, analysisId);
|
|
1252
|
-
|
|
1253
|
-
broadcastProgress(analysisId, initialStatus);
|
|
1254
|
-
|
|
1255
|
-
// Create analyzer (provider/model don't matter for council — voices have their own)
|
|
1256
|
-
const analyzer = new Analyzer(db, 'council', 'council');
|
|
1257
|
-
|
|
1258
|
-
logger.section(`Council Analysis Request (${configType}) - ${logLabel}`);
|
|
1259
|
-
logger.log('API', `Repository: ${repository}`, 'magenta');
|
|
1260
|
-
logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
|
|
1261
|
-
logger.log('API', `Config type: ${configType}`, 'magenta');
|
|
1262
|
-
|
|
1263
|
-
const progressCallback = createProgressCallback(analysisId);
|
|
1264
|
-
|
|
1265
|
-
// Route to voice-centric or level-centric council based on configType
|
|
1266
|
-
const reviewContext = {
|
|
1267
|
-
reviewId,
|
|
1268
|
-
worktreePath,
|
|
1269
|
-
prMetadata,
|
|
1270
|
-
changedFiles,
|
|
1271
|
-
instructions: { repoInstructions, requestInstructions }
|
|
1272
|
-
};
|
|
1273
|
-
|
|
1274
|
-
const analysisPromise = isVoiceCentric
|
|
1275
|
-
? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback })
|
|
1276
|
-
: analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback });
|
|
1277
|
-
|
|
1278
|
-
analysisPromise
|
|
1279
|
-
.then(async result => {
|
|
1280
|
-
logger.success(`Council analysis complete for ${logLabel}: ${result.suggestions.length} suggestions`);
|
|
1281
|
-
|
|
1282
|
-
// Update analysis run record
|
|
1283
|
-
try {
|
|
1284
|
-
await analysisRunRepo.update(runId, {
|
|
1285
|
-
status: 'completed',
|
|
1286
|
-
summary: result.summary,
|
|
1287
|
-
totalSuggestions: result.suggestions.length,
|
|
1288
|
-
...runUpdateExtra
|
|
1289
|
-
});
|
|
1290
|
-
} catch (updateError) {
|
|
1291
|
-
logger.warn(`Failed to update analysis_run: ${updateError.message}`);
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
// Mode-specific success callback (e.g., saving summary to review)
|
|
1295
|
-
if (onSuccess) {
|
|
1296
|
-
try {
|
|
1297
|
-
await onSuccess(result, analysisRunRepo, runId);
|
|
1298
|
-
} catch (callbackError) {
|
|
1299
|
-
logger.warn(`Council onSuccess callback failed: ${callbackError.message}`);
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// Use robust fallback: if activeAnalyses entry was removed (e.g. by cancel), use empty object
|
|
1304
|
-
const currentStatus = activeAnalyses.get(analysisId);
|
|
1305
|
-
if (!currentStatus) return;
|
|
1306
|
-
|
|
1307
|
-
const completedStatus = {
|
|
1308
|
-
...currentStatus,
|
|
1309
|
-
status: 'completed',
|
|
1310
|
-
completedAt: new Date().toISOString(),
|
|
1311
|
-
progress: `Council analysis complete — ${result.suggestions.length} suggestions`,
|
|
1312
|
-
suggestionsCount: result.suggestions.length,
|
|
1313
|
-
levels: {
|
|
1314
|
-
...currentStatus.levels,
|
|
1315
|
-
4: { status: 'completed', progress: 'Results finalized' }
|
|
1316
|
-
}
|
|
1317
|
-
};
|
|
1318
|
-
// Mark all enabled levels as completed
|
|
1319
|
-
for (const levelKey of ['1', '2', '3']) {
|
|
1320
|
-
if (currentStatus.levels?.[levelKey]?.status === 'running') {
|
|
1321
|
-
completedStatus.levels[levelKey] = { status: 'completed', progress: 'Complete' };
|
|
1322
|
-
}
|
|
1323
|
-
}
|
|
1324
|
-
activeAnalyses.set(analysisId, completedStatus);
|
|
1325
|
-
broadcastProgress(analysisId, completedStatus);
|
|
1326
|
-
|
|
1327
|
-
// Broadcast to additional SSE keys (e.g., local mode broadcasts to `local-${reviewId}`)
|
|
1328
|
-
if (extraBroadcastKeys) {
|
|
1329
|
-
for (const key of extraBroadcastKeys) {
|
|
1330
|
-
broadcastProgress(key, { ...completedStatus, source: 'council' });
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
})
|
|
1334
|
-
.catch(error => {
|
|
1335
|
-
if (error.isCancellation) {
|
|
1336
|
-
logger.info(`Council analysis cancelled for ${logLabel}`);
|
|
1337
|
-
return;
|
|
1338
|
-
}
|
|
1339
|
-
logger.error(`Council analysis failed for ${logLabel}: ${error.message}`);
|
|
1340
|
-
|
|
1341
|
-
// Use robust fallback: if activeAnalyses entry was removed, use empty object
|
|
1342
|
-
// This prevents orphaned DB records in "running" status
|
|
1343
|
-
const failedStatus = {
|
|
1344
|
-
...(activeAnalyses.get(analysisId) || {}),
|
|
1345
|
-
status: 'failed',
|
|
1346
|
-
completedAt: new Date().toISOString(),
|
|
1347
|
-
error: error.message,
|
|
1348
|
-
progress: 'Council analysis failed'
|
|
1349
|
-
};
|
|
1350
|
-
activeAnalyses.set(analysisId, failedStatus);
|
|
1351
|
-
broadcastProgress(analysisId, failedStatus);
|
|
1352
|
-
|
|
1353
|
-
// Update analysis run record
|
|
1354
|
-
analysisRunRepo.update(runId, { status: 'failed' }).catch(() => {});
|
|
1355
|
-
})
|
|
1356
|
-
.finally(() => {
|
|
1357
|
-
// Clean up tracking map entry (always runs regardless of success/failure)
|
|
1358
|
-
trackingMap.delete(trackingKey);
|
|
1359
|
-
});
|
|
1360
|
-
|
|
1361
|
-
return { analysisId, runId };
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
/**
|
|
1365
|
-
* Trigger council analysis for a PR
|
|
1366
|
-
* Uses multiple voices/providers across configurable levels
|
|
1367
|
-
*/
|
|
1368
|
-
router.post('/api/analyze/council/:owner/:repo/:pr', async (req, res) => {
|
|
1369
|
-
try {
|
|
1370
|
-
const { owner, repo, pr } = req.params;
|
|
1371
|
-
const prNumber = parseInt(pr);
|
|
1372
|
-
const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
|
|
1373
|
-
|
|
1374
|
-
if (isNaN(prNumber) || prNumber <= 0) {
|
|
1375
|
-
return res.status(400).json({ error: 'Invalid pull request number' });
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
if (!councilId && !inlineConfig) {
|
|
1379
|
-
return res.status(400).json({ error: 'Either councilId or councilConfig is required' });
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
const repository = normalizeRepository(owner, repo);
|
|
1383
|
-
const db = req.app.get('db');
|
|
1384
|
-
|
|
1385
|
-
// Resolve council config and determine config type
|
|
1386
|
-
// Priority: request body configType > saved council type > default 'advanced'
|
|
1387
|
-
let councilConfig;
|
|
1388
|
-
let configType;
|
|
1389
|
-
if (councilId) {
|
|
1390
|
-
const councilRepo = new CouncilRepository(db);
|
|
1391
|
-
const council = await councilRepo.getById(councilId);
|
|
1392
|
-
if (!council) {
|
|
1393
|
-
return res.status(404).json({ error: 'Council not found' });
|
|
1394
|
-
}
|
|
1395
|
-
councilConfig = council.config;
|
|
1396
|
-
configType = requestConfigType || council.type || 'advanced';
|
|
1397
|
-
} else {
|
|
1398
|
-
councilConfig = inlineConfig;
|
|
1399
|
-
configType = requestConfigType || 'advanced';
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
// Normalize config to voice-centric format if needed (handles DB-stored legacy formats)
|
|
1403
|
-
councilConfig = normalizeCouncilConfig(councilConfig, configType);
|
|
1404
|
-
|
|
1405
|
-
// Validate council config (saved configs are validated on save; inline configs need runtime validation)
|
|
1406
|
-
const configError = validateCouncilConfig(councilConfig, configType);
|
|
1407
|
-
if (configError) {
|
|
1408
|
-
return res.status(400).json({ error: `Invalid council config: ${configError}` });
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
// Get PR metadata
|
|
1412
|
-
const prMetadataRepo = new PRMetadataRepository(db);
|
|
1413
|
-
const prMetadata = await prMetadataRepo.getByPR(prNumber, repository);
|
|
1414
|
-
if (!prMetadata) {
|
|
1415
|
-
return res.status(404).json({ error: `Pull request #${prNumber} not found. Please load the PR first.` });
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
// Get worktree path
|
|
1419
|
-
const worktreeManager = new GitWorktreeManager(db);
|
|
1420
|
-
const worktreePath = await worktreeManager.getWorktreePath({ owner, repo, number: prNumber });
|
|
1421
|
-
if (!await worktreeManager.worktreeExists({ owner, repo, number: prNumber })) {
|
|
1422
|
-
return res.status(404).json({ error: 'Worktree not found for this PR. Please reload the PR.' });
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
// Resolve instructions
|
|
1426
|
-
const reviewRepo = new ReviewRepository(db);
|
|
1427
|
-
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1428
|
-
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
1429
|
-
const repoInstructions = repoSettings?.default_instructions || null;
|
|
1430
|
-
const requestInstructions = rawInstructions?.trim() || null;
|
|
1431
|
-
|
|
1432
|
-
// Get or create review record
|
|
1433
|
-
const review = await reviewRepo.getOrCreate({ prNumber, repository });
|
|
1434
|
-
|
|
1435
|
-
// Save custom instructions to the review record (same as single-model endpoint)
|
|
1436
|
-
if (requestInstructions) {
|
|
1437
|
-
await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
const { analysisId, runId } = await launchCouncilAnalysis(
|
|
1441
|
-
db,
|
|
1442
|
-
{
|
|
1443
|
-
reviewId: review.id,
|
|
1444
|
-
worktreePath,
|
|
1445
|
-
prMetadata,
|
|
1446
|
-
changedFiles: null,
|
|
1447
|
-
repository,
|
|
1448
|
-
headSha: prMetadata.head_sha,
|
|
1449
|
-
trackingMap: prToAnalysisId,
|
|
1450
|
-
trackingKey: getPRKey(owner, repo, prNumber),
|
|
1451
|
-
logLabel: `PR #${prNumber}`,
|
|
1452
|
-
initialStatusExtra: { prNumber },
|
|
1453
|
-
extraBroadcastKeys: null,
|
|
1454
|
-
onSuccess: async (result) => {
|
|
1455
|
-
if (result.summary) {
|
|
1456
|
-
await reviewRepo.upsertSummary(prNumber, repository, result.summary);
|
|
1457
|
-
}
|
|
1458
|
-
}
|
|
1459
|
-
},
|
|
1460
|
-
councilConfig,
|
|
1461
|
-
councilId,
|
|
1462
|
-
{ repoInstructions, requestInstructions },
|
|
1463
|
-
configType
|
|
1464
|
-
);
|
|
1465
|
-
|
|
1466
|
-
res.json({
|
|
1467
|
-
analysisId,
|
|
1468
|
-
runId,
|
|
1469
|
-
status: 'started',
|
|
1470
|
-
message: 'Council analysis started in background',
|
|
1471
|
-
isCouncil: true
|
|
1472
|
-
});
|
|
1473
|
-
} catch (error) {
|
|
1474
|
-
logger.error('Error starting council analysis:', error);
|
|
1475
|
-
res.status(500).json({ error: 'Failed to start council analysis' });
|
|
1476
|
-
}
|
|
1477
|
-
});
|
|
1478
|
-
|
|
1479
|
-
/**
|
|
1480
|
-
* Trigger council analysis for a local review
|
|
1481
|
-
*/
|
|
1482
|
-
router.post('/api/local/:reviewId/analyze/council', async (req, res) => {
|
|
1483
|
-
try {
|
|
1484
|
-
const { reviewId } = req.params;
|
|
1485
|
-
const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
|
|
1486
|
-
|
|
1487
|
-
if (!councilId && !inlineConfig) {
|
|
1488
|
-
return res.status(400).json({ error: 'Either councilId or councilConfig is required' });
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
const db = req.app.get('db');
|
|
1492
|
-
|
|
1493
|
-
// Get review record
|
|
1494
|
-
const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ? AND review_type = ?', [reviewId, 'local']);
|
|
1495
|
-
if (!review) {
|
|
1496
|
-
return res.status(404).json({ error: 'Local review not found' });
|
|
1497
|
-
}
|
|
1498
|
-
|
|
1499
|
-
// Resolve council config and determine config type
|
|
1500
|
-
// Priority: request body configType > saved council type > default 'advanced'
|
|
1501
|
-
let councilConfig;
|
|
1502
|
-
let configType;
|
|
1503
|
-
if (councilId) {
|
|
1504
|
-
const councilRepo = new CouncilRepository(db);
|
|
1505
|
-
const council = await councilRepo.getById(councilId);
|
|
1506
|
-
if (!council) {
|
|
1507
|
-
return res.status(404).json({ error: 'Council not found' });
|
|
1508
|
-
}
|
|
1509
|
-
councilConfig = council.config;
|
|
1510
|
-
configType = requestConfigType || council.type || 'advanced';
|
|
1511
|
-
} else {
|
|
1512
|
-
councilConfig = inlineConfig;
|
|
1513
|
-
configType = requestConfigType || 'advanced';
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
// Normalize config to voice-centric format if needed (handles DB-stored legacy formats)
|
|
1517
|
-
councilConfig = normalizeCouncilConfig(councilConfig, configType);
|
|
1518
|
-
|
|
1519
|
-
// Validate council config (saved configs are validated on save; inline configs need runtime validation)
|
|
1520
|
-
const configError = validateCouncilConfig(councilConfig, configType);
|
|
1521
|
-
if (configError) {
|
|
1522
|
-
return res.status(400).json({ error: `Invalid council config: ${configError}` });
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
const localPath = review.local_path;
|
|
1526
|
-
|
|
1527
|
-
// Build metadata for local mode
|
|
1528
|
-
const prMetadata = {
|
|
1529
|
-
reviewType: 'local',
|
|
1530
|
-
repository: review.repository,
|
|
1531
|
-
title: review.name || 'Local changes',
|
|
1532
|
-
description: '',
|
|
1533
|
-
head_sha: review.local_head_sha
|
|
1534
|
-
};
|
|
1535
|
-
|
|
1536
|
-
// Get changed files
|
|
1537
|
-
const analyzer = new Analyzer(db, 'council', 'council');
|
|
1538
|
-
const changedFiles = await analyzer.getLocalChangedFiles(localPath);
|
|
1539
|
-
|
|
1540
|
-
// Generate and cache diff so the web UI can display it (same as regular analysis endpoint)
|
|
1541
|
-
try {
|
|
1542
|
-
const diffResult = await generateLocalDiff(localPath);
|
|
1543
|
-
const digest = await computeLocalDiffDigest(localPath);
|
|
1544
|
-
localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
|
|
1545
|
-
} catch (diffError) {
|
|
1546
|
-
logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
// Resolve instructions
|
|
1550
|
-
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1551
|
-
const reviewRepo = new ReviewRepository(db);
|
|
1552
|
-
const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
|
|
1553
|
-
const repoInstructions = repoSettings?.default_instructions || null;
|
|
1554
|
-
const requestInstructions = rawInstructions?.trim() || null;
|
|
1555
|
-
|
|
1556
|
-
const parsedReviewId = parseInt(reviewId, 10);
|
|
1557
|
-
|
|
1558
|
-
// Save custom instructions to the review record (same as single-model endpoint)
|
|
1559
|
-
if (requestInstructions) {
|
|
1560
|
-
await reviewRepo.updateReview(parsedReviewId, {
|
|
1561
|
-
customInstructions: requestInstructions
|
|
1562
|
-
});
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
const { analysisId, runId } = await launchCouncilAnalysis(
|
|
1566
|
-
db,
|
|
1567
|
-
{
|
|
1568
|
-
reviewId: parsedReviewId,
|
|
1569
|
-
worktreePath: localPath,
|
|
1570
|
-
prMetadata,
|
|
1571
|
-
changedFiles,
|
|
1572
|
-
repository: review.repository,
|
|
1573
|
-
headSha: review.local_head_sha,
|
|
1574
|
-
trackingMap: localReviewToAnalysisId,
|
|
1575
|
-
trackingKey: getLocalReviewKey(reviewId),
|
|
1576
|
-
logLabel: `local review #${reviewId}`,
|
|
1577
|
-
initialStatusExtra: { reviewId: parsedReviewId, reviewType: 'local' },
|
|
1578
|
-
extraBroadcastKeys: [`local-${reviewId}`],
|
|
1579
|
-
runUpdateExtra: { filesAnalyzed: changedFiles.length }
|
|
1580
|
-
},
|
|
1581
|
-
councilConfig,
|
|
1582
|
-
councilId,
|
|
1583
|
-
{ repoInstructions, requestInstructions },
|
|
1584
|
-
configType
|
|
1585
|
-
);
|
|
1586
|
-
|
|
1587
|
-
res.json({
|
|
1588
|
-
analysisId,
|
|
1589
|
-
runId,
|
|
1590
|
-
status: 'started',
|
|
1591
|
-
message: 'Council analysis started in background',
|
|
1592
|
-
isCouncil: true
|
|
1593
|
-
});
|
|
1594
|
-
} catch (error) {
|
|
1595
|
-
logger.error('Error starting local council analysis:', error);
|
|
1596
|
-
res.status(500).json({ error: 'Failed to start council analysis' });
|
|
1597
|
-
}
|
|
1598
|
-
});
|
|
1599
|
-
|
|
1600
|
-
module.exports = router;
|