@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
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* AI Analysis Routes (shared, ID-based endpoints)
|
|
4
|
+
*
|
|
5
|
+
* Provides endpoints that work across both PR mode and Local mode:
|
|
6
|
+
* - GET /api/analyses/runs — list analysis runs for a review
|
|
7
|
+
* - GET /api/analyses/runs/latest — get most recent run for a review
|
|
8
|
+
* - GET /api/analyses/runs/:runId — get a specific run
|
|
9
|
+
* - POST /api/analyses/results — import external analysis results
|
|
10
|
+
* - GET /api/analyses/:id/status — get in-memory analysis status
|
|
11
|
+
* - POST /api/analyses/:id/cancel — cancel an active analysis
|
|
12
|
+
* - GET /api/analyses/:id/progress — SSE progress stream (unified)
|
|
13
|
+
*
|
|
14
|
+
* Routes that are PR-specific or local-specific live in pr.js and local.js
|
|
15
|
+
* respectively (e.g., starting an analysis).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const express = require('express');
|
|
19
|
+
const { queryOne, withTransaction, ReviewRepository, CommentRepository, AnalysisRunRepository, CouncilRepository } = require('../database');
|
|
20
|
+
const Analyzer = require('../ai/analyzer');
|
|
21
|
+
const { getTierForModel } = require('../ai/provider');
|
|
22
|
+
const { v4: uuidv4 } = require('uuid');
|
|
23
|
+
const logger = require('../utils/logger');
|
|
24
|
+
const { broadcastReviewEvent } = require('../sse/review-events');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const { normalizeRepository } = require('../utils/paths');
|
|
27
|
+
const {
|
|
28
|
+
activeAnalyses,
|
|
29
|
+
reviewToAnalysisId,
|
|
30
|
+
progressClients,
|
|
31
|
+
localReviewDiffs,
|
|
32
|
+
broadcastProgress,
|
|
33
|
+
killProcesses,
|
|
34
|
+
createProgressCallback
|
|
35
|
+
} = require('./shared');
|
|
36
|
+
const { generateLocalDiff, computeLocalDiffDigest } = require('../local-review');
|
|
37
|
+
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
38
|
+
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
39
|
+
|
|
40
|
+
const router = express.Router();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Enrich a raw analysis run record for API responses.
|
|
44
|
+
* Applies backward-compatible tier fallback and parses levels_config JSON.
|
|
45
|
+
*/
|
|
46
|
+
function enrichRun(run) {
|
|
47
|
+
if (!run) return null;
|
|
48
|
+
return {
|
|
49
|
+
...run,
|
|
50
|
+
levels_config: run.levels_config ? JSON.parse(run.levels_config) : null,
|
|
51
|
+
tier: run.tier ?? (run.provider && run.model ? getTierForModel(run.provider, run.model) : null)
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ==========================================================================
|
|
56
|
+
// Static path routes — registered BEFORE :id param routes to avoid clashes
|
|
57
|
+
// ==========================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Get all analysis runs for a review
|
|
61
|
+
* Query param: reviewId (integer)
|
|
62
|
+
*/
|
|
63
|
+
router.get('/api/analyses/runs', async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
const reviewId = parseInt(req.query.reviewId, 10);
|
|
66
|
+
|
|
67
|
+
if (!reviewId || isNaN(reviewId) || reviewId <= 0) {
|
|
68
|
+
return res.status(400).json({ error: 'Missing or invalid reviewId query parameter' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const db = req.app.get('db');
|
|
72
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
73
|
+
const runs = await analysisRunRepo.getByReviewId(reviewId);
|
|
74
|
+
|
|
75
|
+
res.json({ runs: runs.map(enrichRun) });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
logger.error('Error fetching analysis runs:', error);
|
|
78
|
+
res.status(500).json({ error: 'Failed to fetch analysis runs' });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get the most recent analysis run for a review
|
|
84
|
+
* Query param: reviewId (integer)
|
|
85
|
+
*/
|
|
86
|
+
router.get('/api/analyses/runs/latest', async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const reviewId = parseInt(req.query.reviewId, 10);
|
|
89
|
+
|
|
90
|
+
if (!reviewId || isNaN(reviewId) || reviewId <= 0) {
|
|
91
|
+
return res.status(400).json({ error: 'Missing or invalid reviewId query parameter' });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const db = req.app.get('db');
|
|
95
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
96
|
+
const run = await analysisRunRepo.getLatestByReviewId(reviewId);
|
|
97
|
+
|
|
98
|
+
if (!run) {
|
|
99
|
+
return res.status(404).json({ error: 'No analysis runs found' });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
res.json({ run: enrichRun(run) });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
logger.error('Error fetching latest analysis run:', error);
|
|
105
|
+
res.status(500).json({ error: 'Failed to fetch latest analysis run' });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get a specific analysis run by ID
|
|
111
|
+
*/
|
|
112
|
+
router.get('/api/analyses/runs/:runId', async (req, res) => {
|
|
113
|
+
try {
|
|
114
|
+
const { runId } = req.params;
|
|
115
|
+
const db = req.app.get('db');
|
|
116
|
+
|
|
117
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
118
|
+
const run = await analysisRunRepo.getById(runId);
|
|
119
|
+
|
|
120
|
+
if (!run) {
|
|
121
|
+
return res.status(404).json({ error: 'Analysis run not found' });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
res.json({ run: enrichRun(run) });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
logger.error('Error fetching analysis run:', error);
|
|
127
|
+
res.status(500).json({ error: 'Failed to fetch analysis run' });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Import externally-produced analysis results
|
|
133
|
+
*
|
|
134
|
+
* Accepts suggestions generated outside pair-review (e.g. by a coding agent's
|
|
135
|
+
* analyze skill) and stores them as a completed analysis run so they appear
|
|
136
|
+
* inline in the web UI.
|
|
137
|
+
*/
|
|
138
|
+
router.post('/api/analyses/results', async (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
const {
|
|
141
|
+
path: localPath,
|
|
142
|
+
headSha,
|
|
143
|
+
repo,
|
|
144
|
+
prNumber,
|
|
145
|
+
provider = null,
|
|
146
|
+
model = null,
|
|
147
|
+
summary = null,
|
|
148
|
+
suggestions = [],
|
|
149
|
+
fileLevelSuggestions = [],
|
|
150
|
+
tier = null
|
|
151
|
+
} = req.body || {};
|
|
152
|
+
|
|
153
|
+
// --- Validate tier ---
|
|
154
|
+
let resolvedTier = tier;
|
|
155
|
+
if (tier != null) {
|
|
156
|
+
if (!VALID_TIERS.includes(tier)) {
|
|
157
|
+
return res.status(400).json({
|
|
158
|
+
error: `Invalid tier: "${tier}". Valid tiers: ${VALID_TIERS.join(', ')}`
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
resolvedTier = resolveTier(tier);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// --- Validate identification pair ---
|
|
165
|
+
const hasLocal = localPath && headSha;
|
|
166
|
+
const hasPR = repo && prNumber != null;
|
|
167
|
+
|
|
168
|
+
if (!hasLocal && !hasPR) {
|
|
169
|
+
return res.status(400).json({
|
|
170
|
+
error: 'Must provide either (path + headSha) for local mode or (repo + prNumber) for PR mode'
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (hasLocal && hasPR) {
|
|
174
|
+
return res.status(400).json({
|
|
175
|
+
error: 'Provide only one identification pair: (path + headSha) or (repo + prNumber), not both'
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- Validate suggestions ---
|
|
180
|
+
if (!Array.isArray(suggestions)) {
|
|
181
|
+
return res.status(400).json({ error: 'suggestions must be an array' });
|
|
182
|
+
}
|
|
183
|
+
if (!Array.isArray(fileLevelSuggestions)) {
|
|
184
|
+
return res.status(400).json({ error: 'fileLevelSuggestions must be an array' });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const REQUIRED_SUGGESTION_FIELDS = ['file', 'type', 'title', 'description'];
|
|
188
|
+
for (const [idx, s] of suggestions.entries()) {
|
|
189
|
+
for (const field of REQUIRED_SUGGESTION_FIELDS) {
|
|
190
|
+
if (!s[field]) {
|
|
191
|
+
return res.status(400).json({
|
|
192
|
+
error: `suggestions[${idx}] missing required field: ${field}`
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const [idx, s] of fileLevelSuggestions.entries()) {
|
|
198
|
+
for (const field of REQUIRED_SUGGESTION_FIELDS) {
|
|
199
|
+
if (!s[field]) {
|
|
200
|
+
return res.status(400).json({
|
|
201
|
+
error: `fileLevelSuggestions[${idx}] missing required field: ${field}`
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const db = req.app.get('db');
|
|
208
|
+
const reviewRepo = new ReviewRepository(db);
|
|
209
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
210
|
+
|
|
211
|
+
// --- Resolve review ---
|
|
212
|
+
let reviewId;
|
|
213
|
+
if (hasLocal) {
|
|
214
|
+
// Local mode: derive repository name from the directory basename
|
|
215
|
+
const repository = path.basename(localPath) || 'local';
|
|
216
|
+
reviewId = await reviewRepo.upsertLocalReview({
|
|
217
|
+
localPath,
|
|
218
|
+
localHeadSha: headSha,
|
|
219
|
+
repository
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// Generate and store diff so the web UI can display it
|
|
223
|
+
try {
|
|
224
|
+
const diffResult = await generateLocalDiff(localPath);
|
|
225
|
+
const digest = await computeLocalDiffDigest(localPath);
|
|
226
|
+
localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
|
|
227
|
+
} catch (diffError) {
|
|
228
|
+
logger.warn(`Could not generate diff for local review ${reviewId}: ${diffError.message}`);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
const repoParts = repo.split('/');
|
|
232
|
+
if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) {
|
|
233
|
+
return res.status(400).json({ error: 'repo must be in format owner/repo' });
|
|
234
|
+
}
|
|
235
|
+
const parsedPR = parseInt(prNumber, 10);
|
|
236
|
+
if (isNaN(parsedPR) || parsedPR <= 0) {
|
|
237
|
+
return res.status(400).json({ error: 'Invalid pull request number' });
|
|
238
|
+
}
|
|
239
|
+
const repository = normalizeRepository(repoParts[0], repoParts[1]);
|
|
240
|
+
const review = await reviewRepo.getOrCreate({
|
|
241
|
+
prNumber: parsedPR,
|
|
242
|
+
repository
|
|
243
|
+
});
|
|
244
|
+
reviewId = review.id;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- Create completed analysis run, insert suggestions, update stats ---
|
|
248
|
+
const runId = uuidv4();
|
|
249
|
+
const allSuggestions = [
|
|
250
|
+
...suggestions.map(s => ({ ...s, is_file_level: false })),
|
|
251
|
+
...fileLevelSuggestions.map(s => ({ ...s, is_file_level: true }))
|
|
252
|
+
];
|
|
253
|
+
const totalSuggestions = allSuggestions.length;
|
|
254
|
+
const filesAnalyzed = new Set(allSuggestions.map(s => s.file)).size;
|
|
255
|
+
|
|
256
|
+
const commentRepo = new CommentRepository(db);
|
|
257
|
+
|
|
258
|
+
await withTransaction(db, async () => {
|
|
259
|
+
await analysisRunRepo.create({
|
|
260
|
+
id: runId,
|
|
261
|
+
reviewId,
|
|
262
|
+
provider,
|
|
263
|
+
model,
|
|
264
|
+
tier: resolvedTier,
|
|
265
|
+
headSha: headSha || null,
|
|
266
|
+
status: 'completed'
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
await commentRepo.bulkInsertAISuggestions(reviewId, runId, allSuggestions);
|
|
270
|
+
|
|
271
|
+
await analysisRunRepo.update(runId, {
|
|
272
|
+
summary,
|
|
273
|
+
totalSuggestions,
|
|
274
|
+
filesAnalyzed
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// --- Broadcast SSE completion event (after transaction completes) ---
|
|
279
|
+
const completionEvent = {
|
|
280
|
+
id: runId,
|
|
281
|
+
status: 'completed',
|
|
282
|
+
completedAt: new Date().toISOString(),
|
|
283
|
+
progress: `Analysis complete — ${totalSuggestions} suggestion${totalSuggestions !== 1 ? 's' : ''}`,
|
|
284
|
+
suggestionsCount: totalSuggestions,
|
|
285
|
+
filesAnalyzed,
|
|
286
|
+
levels: {
|
|
287
|
+
1: { status: 'completed', progress: 'Complete' },
|
|
288
|
+
2: { status: 'completed', progress: 'Complete' },
|
|
289
|
+
3: { status: 'completed', progress: 'Complete' },
|
|
290
|
+
4: { status: 'completed', progress: 'Complete' }
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
broadcastProgress(runId, completionEvent);
|
|
294
|
+
|
|
295
|
+
// Broadcast on the review-level key so the frontend auto-refreshes.
|
|
296
|
+
broadcastProgress(`review-${reviewId}`, { ...completionEvent, source: 'external' });
|
|
297
|
+
|
|
298
|
+
broadcastReviewEvent(reviewId, { type: 'review:analysis_completed' });
|
|
299
|
+
|
|
300
|
+
logger.success(`Imported ${totalSuggestions} external analysis suggestions (run ${runId})`);
|
|
301
|
+
|
|
302
|
+
res.status(201).json({
|
|
303
|
+
runId,
|
|
304
|
+
reviewId,
|
|
305
|
+
totalSuggestions,
|
|
306
|
+
status: 'completed'
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
logger.error('Error importing analysis results:', error);
|
|
310
|
+
res.status(500).json({ error: 'Failed to import analysis results' });
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ==========================================================================
|
|
315
|
+
// Parameterised :id routes — registered AFTER static paths
|
|
316
|
+
// ==========================================================================
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get AI analysis status
|
|
320
|
+
*/
|
|
321
|
+
router.get('/api/analyses/:id/status', async (req, res) => {
|
|
322
|
+
try {
|
|
323
|
+
const { id } = req.params;
|
|
324
|
+
|
|
325
|
+
const analysis = activeAnalyses.get(id);
|
|
326
|
+
|
|
327
|
+
if (!analysis) {
|
|
328
|
+
return res.status(404).json({
|
|
329
|
+
error: 'Analysis not found'
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
res.json(analysis);
|
|
334
|
+
|
|
335
|
+
} catch (error) {
|
|
336
|
+
logger.error('Error fetching analysis status:', error);
|
|
337
|
+
res.status(500).json({
|
|
338
|
+
error: 'Failed to fetch analysis status'
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Cancel an active AI analysis
|
|
345
|
+
*/
|
|
346
|
+
router.post('/api/analyses/:id/cancel', async (req, res) => {
|
|
347
|
+
try {
|
|
348
|
+
const { id } = req.params;
|
|
349
|
+
|
|
350
|
+
const analysis = activeAnalyses.get(id);
|
|
351
|
+
|
|
352
|
+
if (!analysis) {
|
|
353
|
+
return res.status(404).json({
|
|
354
|
+
error: 'Analysis not found'
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Check if already completed/failed/cancelled
|
|
359
|
+
if (['completed', 'failed', 'cancelled'].includes(analysis.status)) {
|
|
360
|
+
return res.json({
|
|
361
|
+
success: true,
|
|
362
|
+
message: `Analysis already ${analysis.status}`,
|
|
363
|
+
status: analysis.status
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
logger.section(`Cancelling Analysis: ${id}`);
|
|
368
|
+
// Log context based on review type (PR mode vs local mode)
|
|
369
|
+
if (analysis.reviewType === 'local') {
|
|
370
|
+
logger.log('API', `Local review #${analysis.reviewId} in ${analysis.repository}`, 'yellow');
|
|
371
|
+
} else {
|
|
372
|
+
logger.log('API', `PR #${analysis.prNumber} in ${analysis.repository}`, 'yellow');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Kill all running child processes for this analysis
|
|
376
|
+
const killedCount = killProcesses(id);
|
|
377
|
+
logger.info(`Killed ${killedCount} running process(es)`);
|
|
378
|
+
|
|
379
|
+
// Update database record to cancelled
|
|
380
|
+
if (analysis.runId) {
|
|
381
|
+
try {
|
|
382
|
+
const db = req.app.get('db');
|
|
383
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
384
|
+
await analysisRunRepo.update(analysis.runId, { status: 'cancelled' });
|
|
385
|
+
logger.info(`Updated analysis_run DB record to cancelled: ${analysis.runId}`);
|
|
386
|
+
} catch (dbError) {
|
|
387
|
+
logger.warn(`Failed to update analysis_run DB record: ${dbError.message}`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Update analysis status to cancelled
|
|
392
|
+
const cancelledStatus = {
|
|
393
|
+
...analysis,
|
|
394
|
+
status: 'cancelled',
|
|
395
|
+
cancelledAt: new Date().toISOString(),
|
|
396
|
+
progress: 'Analysis cancelled by user',
|
|
397
|
+
levels: {
|
|
398
|
+
...analysis.levels,
|
|
399
|
+
1: analysis.levels?.[1]?.status === 'running'
|
|
400
|
+
? { status: 'cancelled', progress: 'Cancelled' }
|
|
401
|
+
: analysis.levels?.[1],
|
|
402
|
+
2: analysis.levels?.[2]?.status === 'running'
|
|
403
|
+
? { status: 'cancelled', progress: 'Cancelled' }
|
|
404
|
+
: analysis.levels?.[2],
|
|
405
|
+
3: analysis.levels?.[3]?.status === 'running'
|
|
406
|
+
? { status: 'cancelled', progress: 'Cancelled' }
|
|
407
|
+
: analysis.levels?.[3],
|
|
408
|
+
4: analysis.levels?.[4]?.status === 'running'
|
|
409
|
+
? { status: 'cancelled', progress: 'Cancelled' }
|
|
410
|
+
: analysis.levels?.[4]
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
activeAnalyses.set(id, cancelledStatus);
|
|
415
|
+
|
|
416
|
+
// Broadcast cancelled status to SSE clients
|
|
417
|
+
broadcastProgress(id, cancelledStatus);
|
|
418
|
+
|
|
419
|
+
// Clean up review to analysis ID mapping
|
|
420
|
+
if (analysis.reviewId) {
|
|
421
|
+
reviewToAnalysisId.delete(analysis.reviewId);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
logger.success(`Analysis ${id} cancelled successfully`);
|
|
425
|
+
|
|
426
|
+
res.json({
|
|
427
|
+
success: true,
|
|
428
|
+
message: 'Analysis cancelled',
|
|
429
|
+
processesKilled: killedCount,
|
|
430
|
+
status: 'cancelled'
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
} catch (error) {
|
|
434
|
+
logger.error(`Error cancelling analysis: ${error.message}`);
|
|
435
|
+
res.status(500).json({
|
|
436
|
+
error: 'Failed to cancel analysis'
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Server-Sent Events endpoint for AI analysis progress (unified)
|
|
443
|
+
*
|
|
444
|
+
* Clients connect with an analysis UUID. The handler also registers
|
|
445
|
+
* the client under `review-${reviewId}` when the analysis status
|
|
446
|
+
* carries a reviewId, so that external result broadcasts reach them.
|
|
447
|
+
*/
|
|
448
|
+
router.get('/api/analyses/:id/progress', (req, res) => {
|
|
449
|
+
const analysisId = req.params.id;
|
|
450
|
+
|
|
451
|
+
// Set up SSE headers
|
|
452
|
+
res.writeHead(200, {
|
|
453
|
+
'Content-Type': 'text/event-stream',
|
|
454
|
+
'Cache-Control': 'no-cache',
|
|
455
|
+
'Connection': 'keep-alive',
|
|
456
|
+
'Access-Control-Allow-Origin': '*',
|
|
457
|
+
'Access-Control-Allow-Headers': 'Cache-Control'
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// Send initial connection message
|
|
461
|
+
res.write('data: {"type":"connected","message":"Connected to progress stream"}\n\n');
|
|
462
|
+
|
|
463
|
+
// Store client for this analysis
|
|
464
|
+
if (!progressClients.has(analysisId)) {
|
|
465
|
+
progressClients.set(analysisId, new Set());
|
|
466
|
+
}
|
|
467
|
+
progressClients.get(analysisId).add(res);
|
|
468
|
+
|
|
469
|
+
// Also register under the review-level key so external broadcasts reach this client
|
|
470
|
+
const currentStatus = activeAnalyses.get(analysisId);
|
|
471
|
+
const reviewId = currentStatus?.reviewId;
|
|
472
|
+
const reviewKey = reviewId ? `review-${reviewId}` : null;
|
|
473
|
+
if (reviewKey) {
|
|
474
|
+
if (!progressClients.has(reviewKey)) {
|
|
475
|
+
progressClients.set(reviewKey, new Set());
|
|
476
|
+
}
|
|
477
|
+
progressClients.get(reviewKey).add(res);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Send current status if analysis exists
|
|
481
|
+
if (currentStatus) {
|
|
482
|
+
res.write(`data: ${JSON.stringify({
|
|
483
|
+
type: 'progress',
|
|
484
|
+
...currentStatus
|
|
485
|
+
})}\n\n`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Handle client disconnect
|
|
489
|
+
const cleanup = () => {
|
|
490
|
+
const clients = progressClients.get(analysisId);
|
|
491
|
+
if (clients) {
|
|
492
|
+
clients.delete(res);
|
|
493
|
+
if (clients.size === 0) {
|
|
494
|
+
progressClients.delete(analysisId);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (reviewKey) {
|
|
498
|
+
const reviewClients = progressClients.get(reviewKey);
|
|
499
|
+
if (reviewClients) {
|
|
500
|
+
reviewClients.delete(res);
|
|
501
|
+
if (reviewClients.size === 0) {
|
|
502
|
+
progressClients.delete(reviewKey);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
req.on('close', cleanup);
|
|
509
|
+
req.on('error', cleanup);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// ==========================================================================
|
|
513
|
+
// Shared helper: launch council analysis
|
|
514
|
+
// ==========================================================================
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Launch a council analysis, shared by both PR and local mode.
|
|
518
|
+
*
|
|
519
|
+
* This helper encapsulates all the common logic: council config resolution/validation,
|
|
520
|
+
* analysis run record creation, progress tracking setup, async analyzer invocation,
|
|
521
|
+
* completion/failure status broadcasting, and tracking map cleanup.
|
|
522
|
+
*
|
|
523
|
+
* @param {Object} db - Database handle
|
|
524
|
+
* @param {Object} modeContext - Mode-specific values
|
|
525
|
+
* @param {Object} councilConfig - Validated council configuration
|
|
526
|
+
* @param {string} councilId - Council ID (for the model field in analysis_runs), or null for inline config
|
|
527
|
+
* @param {Object} instructions - { repoInstructions, requestInstructions }
|
|
528
|
+
* @param {string} [configType='advanced'] - Config type
|
|
529
|
+
* @returns {{ analysisId: string, runId: string }}
|
|
530
|
+
*/
|
|
531
|
+
function isLevelEnabled(councilConfig, levelKey) {
|
|
532
|
+
const val = councilConfig.levels?.[levelKey];
|
|
533
|
+
if (typeof val === 'boolean') return val;
|
|
534
|
+
return val?.enabled === true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId, instructions, configType = 'advanced') {
|
|
538
|
+
const {
|
|
539
|
+
reviewId,
|
|
540
|
+
worktreePath,
|
|
541
|
+
prMetadata,
|
|
542
|
+
changedFiles,
|
|
543
|
+
repository,
|
|
544
|
+
headSha,
|
|
545
|
+
logLabel,
|
|
546
|
+
initialStatusExtra,
|
|
547
|
+
extraBroadcastKeys,
|
|
548
|
+
onSuccess,
|
|
549
|
+
runUpdateExtra
|
|
550
|
+
} = modeContext;
|
|
551
|
+
|
|
552
|
+
const { repoInstructions, requestInstructions } = instructions;
|
|
553
|
+
|
|
554
|
+
const isVoiceCentric = configType === 'council';
|
|
555
|
+
|
|
556
|
+
const runId = uuidv4();
|
|
557
|
+
const analysisId = runId;
|
|
558
|
+
|
|
559
|
+
let levelsConfig = null;
|
|
560
|
+
if (isVoiceCentric && councilConfig.levels) {
|
|
561
|
+
levelsConfig = councilConfig.levels;
|
|
562
|
+
} else if (councilConfig.levels) {
|
|
563
|
+
levelsConfig = {};
|
|
564
|
+
for (const [key, val] of Object.entries(councilConfig.levels)) {
|
|
565
|
+
levelsConfig[key] = val?.enabled !== false;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
570
|
+
await analysisRunRepo.create({
|
|
571
|
+
id: runId,
|
|
572
|
+
reviewId,
|
|
573
|
+
provider: 'council',
|
|
574
|
+
model: councilId || 'inline-config',
|
|
575
|
+
tier: null,
|
|
576
|
+
repoInstructions,
|
|
577
|
+
requestInstructions,
|
|
578
|
+
headSha: headSha || null,
|
|
579
|
+
configType,
|
|
580
|
+
levelsConfig
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
if (councilId) {
|
|
584
|
+
const councilRepo = new CouncilRepository(db);
|
|
585
|
+
councilRepo.touchLastUsedAt(councilId).catch(err => {
|
|
586
|
+
logger.warn(`Failed to update council last_used_at: ${err.message}`);
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const initialStatus = {
|
|
591
|
+
id: analysisId,
|
|
592
|
+
reviewId,
|
|
593
|
+
repository,
|
|
594
|
+
status: 'running',
|
|
595
|
+
startedAt: new Date().toISOString(),
|
|
596
|
+
progress: 'Starting council analysis...',
|
|
597
|
+
levels: {
|
|
598
|
+
1: isLevelEnabled(councilConfig, '1') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
599
|
+
2: isLevelEnabled(councilConfig, '2') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
600
|
+
3: isLevelEnabled(councilConfig, '3') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
601
|
+
4: { status: 'pending', progress: 'Pending' }
|
|
602
|
+
},
|
|
603
|
+
isCouncil: true,
|
|
604
|
+
councilConfig,
|
|
605
|
+
configType,
|
|
606
|
+
filesAnalyzed: 0,
|
|
607
|
+
filesRemaining: 0,
|
|
608
|
+
...initialStatusExtra
|
|
609
|
+
};
|
|
610
|
+
activeAnalyses.set(analysisId, initialStatus);
|
|
611
|
+
|
|
612
|
+
// Store unified tracking map entry (integer reviewId -> analysis UUID)
|
|
613
|
+
reviewToAnalysisId.set(reviewId, analysisId);
|
|
614
|
+
|
|
615
|
+
broadcastProgress(analysisId, initialStatus);
|
|
616
|
+
broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
|
|
617
|
+
|
|
618
|
+
const analyzer = new Analyzer(db, 'council', 'council');
|
|
619
|
+
|
|
620
|
+
logger.section(`Council Analysis Request (${configType}) - ${logLabel}`);
|
|
621
|
+
logger.log('API', `Repository: ${repository}`, 'magenta');
|
|
622
|
+
logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
|
|
623
|
+
logger.log('API', `Config type: ${configType}`, 'magenta');
|
|
624
|
+
|
|
625
|
+
const progressCallback = createProgressCallback(analysisId);
|
|
626
|
+
|
|
627
|
+
const reviewContext = {
|
|
628
|
+
reviewId,
|
|
629
|
+
worktreePath,
|
|
630
|
+
prMetadata,
|
|
631
|
+
changedFiles,
|
|
632
|
+
instructions: { repoInstructions, requestInstructions }
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const analysisPromise = isVoiceCentric
|
|
636
|
+
? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback })
|
|
637
|
+
: analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback });
|
|
638
|
+
|
|
639
|
+
analysisPromise
|
|
640
|
+
.then(async result => {
|
|
641
|
+
logger.success(`Council analysis complete for ${logLabel}: ${result.suggestions.length} suggestions`);
|
|
642
|
+
|
|
643
|
+
try {
|
|
644
|
+
await analysisRunRepo.update(runId, {
|
|
645
|
+
status: 'completed',
|
|
646
|
+
summary: result.summary,
|
|
647
|
+
totalSuggestions: result.suggestions.length,
|
|
648
|
+
...runUpdateExtra
|
|
649
|
+
});
|
|
650
|
+
} catch (updateError) {
|
|
651
|
+
logger.warn(`Failed to update analysis_run: ${updateError.message}`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (onSuccess) {
|
|
655
|
+
try {
|
|
656
|
+
await onSuccess(result, analysisRunRepo, runId);
|
|
657
|
+
} catch (callbackError) {
|
|
658
|
+
logger.warn(`Council onSuccess callback failed: ${callbackError.message}`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const currentStatus = activeAnalyses.get(analysisId);
|
|
663
|
+
if (!currentStatus) return;
|
|
664
|
+
|
|
665
|
+
const completedStatus = {
|
|
666
|
+
...currentStatus,
|
|
667
|
+
status: 'completed',
|
|
668
|
+
completedAt: new Date().toISOString(),
|
|
669
|
+
progress: `Council analysis complete — ${result.suggestions.length} suggestions`,
|
|
670
|
+
suggestionsCount: result.suggestions.length,
|
|
671
|
+
levels: {
|
|
672
|
+
...currentStatus.levels,
|
|
673
|
+
4: { status: 'completed', progress: 'Results finalized' }
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
for (const levelKey of ['1', '2', '3']) {
|
|
677
|
+
if (currentStatus.levels?.[levelKey]?.status === 'running') {
|
|
678
|
+
completedStatus.levels[levelKey] = { status: 'completed', progress: 'Complete' };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
activeAnalyses.set(analysisId, completedStatus);
|
|
682
|
+
broadcastProgress(analysisId, completedStatus);
|
|
683
|
+
broadcastReviewEvent(initialStatus.reviewId, { type: 'review:analysis_completed' });
|
|
684
|
+
|
|
685
|
+
if (extraBroadcastKeys) {
|
|
686
|
+
for (const key of extraBroadcastKeys) {
|
|
687
|
+
broadcastProgress(key, { ...completedStatus, source: 'council' });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
})
|
|
691
|
+
.catch(error => {
|
|
692
|
+
if (error.isCancellation) {
|
|
693
|
+
logger.info(`Council analysis cancelled for ${logLabel}`);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
logger.error(`Council analysis failed for ${logLabel}: ${error.message}`);
|
|
697
|
+
|
|
698
|
+
const failedStatus = {
|
|
699
|
+
...(activeAnalyses.get(analysisId) || {}),
|
|
700
|
+
status: 'failed',
|
|
701
|
+
completedAt: new Date().toISOString(),
|
|
702
|
+
error: error.message,
|
|
703
|
+
progress: 'Council analysis failed'
|
|
704
|
+
};
|
|
705
|
+
activeAnalyses.set(analysisId, failedStatus);
|
|
706
|
+
broadcastProgress(analysisId, failedStatus);
|
|
707
|
+
|
|
708
|
+
analysisRunRepo.update(runId, { status: 'failed' }).catch(() => {});
|
|
709
|
+
})
|
|
710
|
+
.finally(() => {
|
|
711
|
+
// Clean up unified tracking map entry
|
|
712
|
+
reviewToAnalysisId.delete(reviewId);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
return { analysisId, runId };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Export the helper for pr.js and local.js to use
|
|
719
|
+
router.launchCouncilAnalysis = launchCouncilAnalysis;
|
|
720
|
+
|
|
721
|
+
module.exports = router;
|