@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.
Files changed (63) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1962 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
@@ -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;