@in-the-loop-labs/pair-review 1.4.4 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +54 -0
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/pr.css +1081 -54
- package/public/css/repo-settings.css +452 -140
- package/public/js/components/AdvancedConfigTab.js +1364 -0
- package/public/js/components/AnalysisConfigModal.js +488 -112
- package/public/js/components/CouncilProgressModal.js +1416 -0
- package/public/js/components/TextInputDialog.js +231 -0
- package/public/js/components/TimeoutSelect.js +367 -0
- package/public/js/components/VoiceCentricConfigTab.js +1334 -0
- package/public/js/local.js +162 -83
- package/public/js/modules/analysis-history.js +185 -11
- package/public/js/modules/comment-manager.js +13 -0
- package/public/js/modules/file-comment-manager.js +28 -0
- package/public/js/pr.js +233 -115
- package/public/js/repo-settings.js +575 -106
- package/public/local.html +11 -1
- package/public/pr.html +6 -1
- package/public/repo-settings.html +28 -21
- package/public/setup.html +8 -2
- package/src/ai/analyzer.js +1262 -111
- package/src/ai/claude-cli.js +2 -2
- package/src/ai/claude-provider.js +6 -6
- package/src/ai/codex-provider.js +6 -6
- package/src/ai/copilot-provider.js +3 -3
- package/src/ai/cursor-agent-provider.js +6 -6
- package/src/ai/gemini-provider.js +6 -6
- package/src/ai/opencode-provider.js +6 -6
- package/src/ai/pi-provider.js +6 -6
- package/src/ai/prompts/baseline/consolidation/balanced.js +208 -0
- package/src/ai/prompts/baseline/consolidation/fast.js +175 -0
- package/src/ai/prompts/baseline/consolidation/thorough.js +283 -0
- package/src/ai/prompts/config.js +1 -1
- package/src/ai/prompts/index.js +26 -2
- package/src/ai/provider.js +4 -2
- package/src/database.js +417 -14
- package/src/main.js +1 -1
- package/src/routes/analysis.js +495 -10
- package/src/routes/config.js +36 -15
- package/src/routes/councils.js +351 -0
- package/src/routes/local.js +33 -11
- package/src/routes/mcp.js +9 -2
- package/src/routes/setup.js +12 -2
- package/src/routes/shared.js +126 -13
- package/src/server.js +34 -4
- package/src/utils/stats-calculator.js +2 -0
package/src/routes/analysis.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
const express = require('express');
|
|
14
|
-
const { query, queryOne, withTransaction, RepoSettingsRepository, ReviewRepository, CommentRepository, AnalysisRunRepository, PRMetadataRepository } = require('../database');
|
|
14
|
+
const { query, queryOne, withTransaction, RepoSettingsRepository, ReviewRepository, CommentRepository, AnalysisRunRepository, PRMetadataRepository, CouncilRepository } = require('../database');
|
|
15
15
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
16
16
|
const Analyzer = require('../ai/analyzer');
|
|
17
17
|
const { v4: uuidv4 } = require('uuid');
|
|
@@ -23,18 +23,22 @@ const { normalizeRepository } = require('../utils/paths');
|
|
|
23
23
|
const {
|
|
24
24
|
activeAnalyses,
|
|
25
25
|
prToAnalysisId,
|
|
26
|
+
localReviewToAnalysisId,
|
|
26
27
|
progressClients,
|
|
27
28
|
localReviewDiffs,
|
|
28
29
|
getPRKey,
|
|
30
|
+
getLocalReviewKey,
|
|
29
31
|
getModel,
|
|
30
32
|
determineCompletionInfo,
|
|
31
33
|
broadcastProgress,
|
|
32
34
|
killProcesses,
|
|
33
35
|
isAnalysisCancelled,
|
|
34
36
|
CancellationError,
|
|
35
|
-
createProgressCallback
|
|
37
|
+
createProgressCallback,
|
|
38
|
+
parseEnabledLevels
|
|
36
39
|
} = require('./shared');
|
|
37
40
|
const { generateLocalDiff, computeLocalDiffDigest } = require('../local-review');
|
|
41
|
+
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
38
42
|
|
|
39
43
|
const router = express.Router();
|
|
40
44
|
|
|
@@ -47,7 +51,7 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
|
|
|
47
51
|
const prNumber = parseInt(pr);
|
|
48
52
|
|
|
49
53
|
// Extract optional provider, model, tier, customInstructions and skipLevel3 from request body
|
|
50
|
-
const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3 } = req.body || {};
|
|
54
|
+
const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels } = req.body || {};
|
|
51
55
|
|
|
52
56
|
// Trim and validate custom instructions
|
|
53
57
|
const MAX_INSTRUCTIONS_LENGTH = 5000;
|
|
@@ -156,6 +160,7 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
|
|
|
156
160
|
|
|
157
161
|
// Create DB analysis_runs record immediately so it's queryable for polling
|
|
158
162
|
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
163
|
+
const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
|
|
159
164
|
await analysisRunRepo.create({
|
|
160
165
|
id: runId,
|
|
161
166
|
reviewId: review.id,
|
|
@@ -163,7 +168,9 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
|
|
|
163
168
|
model,
|
|
164
169
|
repoInstructions,
|
|
165
170
|
requestInstructions,
|
|
166
|
-
headSha: prMetadata.head_sha || null
|
|
171
|
+
headSha: prMetadata.head_sha || null,
|
|
172
|
+
configType: 'single',
|
|
173
|
+
levelsConfig
|
|
167
174
|
});
|
|
168
175
|
|
|
169
176
|
// Store analysis status with separate tracking for each level
|
|
@@ -176,9 +183,9 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
|
|
|
176
183
|
progress: 'Starting analysis...',
|
|
177
184
|
// Track each level separately for parallel execution
|
|
178
185
|
levels: {
|
|
179
|
-
1: { status: 'running', progress: 'Starting...' },
|
|
180
|
-
2: { status: 'running', progress: 'Starting...' },
|
|
181
|
-
3:
|
|
186
|
+
1: levelsConfig[1] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
187
|
+
2: levelsConfig[2] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
188
|
+
3: levelsConfig[3] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
182
189
|
4: { status: 'pending', progress: 'Pending' }
|
|
183
190
|
},
|
|
184
191
|
filesAnalyzed: 0,
|
|
@@ -214,7 +221,7 @@ router.post('/api/analyze/:owner/:repo/:pr', async (req, res) => {
|
|
|
214
221
|
const progressCallback = createProgressCallback(analysisId);
|
|
215
222
|
|
|
216
223
|
// Start analysis asynchronously (skipRunCreation since we created the record above; tier for prompt selection, skipLevel3 flag)
|
|
217
|
-
analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3 })
|
|
224
|
+
analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig })
|
|
218
225
|
.then(async result => {
|
|
219
226
|
logger.section('Analysis Results');
|
|
220
227
|
logger.success(`Analysis complete for PR #${prNumber}`);
|
|
@@ -587,10 +594,11 @@ router.get('/api/pr/:owner/:repo/:number/has-ai-suggestions', async (req, res) =
|
|
|
587
594
|
}
|
|
588
595
|
|
|
589
596
|
// Check if any AI suggestions exist for this PR using review.id
|
|
597
|
+
// Exclude raw council voice suggestions (is_raw=1) — only count final/consolidated suggestions
|
|
590
598
|
const result = await queryOne(db, `
|
|
591
599
|
SELECT EXISTS(
|
|
592
600
|
SELECT 1 FROM comments
|
|
593
|
-
WHERE review_id = ? AND source = 'ai'
|
|
601
|
+
WHERE review_id = ? AND source = 'ai' AND (is_raw = 0 OR is_raw IS NULL)
|
|
594
602
|
) as has_suggestions
|
|
595
603
|
`, [review.id]);
|
|
596
604
|
|
|
@@ -772,6 +780,7 @@ router.get('/api/pr/:owner/:repo/:number/ai-suggestions', async (req, res) => {
|
|
|
772
780
|
AND source = 'ai'
|
|
773
781
|
AND ${levelFilter}
|
|
774
782
|
AND status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')
|
|
783
|
+
AND (is_raw = 0 OR is_raw IS NULL)
|
|
775
784
|
AND ${runIdFilter}
|
|
776
785
|
ORDER BY
|
|
777
786
|
CASE
|
|
@@ -863,7 +872,10 @@ router.get('/api/analysis-runs/:reviewId', async (req, res) => {
|
|
|
863
872
|
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
864
873
|
const runs = await analysisRunRepo.getByReviewId(parseInt(reviewId, 10));
|
|
865
874
|
|
|
866
|
-
res.json({ runs
|
|
875
|
+
res.json({ runs: runs.map(r => ({
|
|
876
|
+
...r,
|
|
877
|
+
levels_config: r.levels_config ? JSON.parse(r.levels_config) : null
|
|
878
|
+
})) });
|
|
867
879
|
} catch (error) {
|
|
868
880
|
logger.error('Error fetching analysis runs:', error);
|
|
869
881
|
res.status(500).json({ error: 'Failed to fetch analysis runs' });
|
|
@@ -1087,4 +1099,477 @@ router.post('/api/analysis-results', async (req, res) => {
|
|
|
1087
1099
|
}
|
|
1088
1100
|
});
|
|
1089
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Launch a council analysis, shared by both PR and local mode.
|
|
1104
|
+
*
|
|
1105
|
+
* This helper encapsulates all the common logic: council config resolution/validation,
|
|
1106
|
+
* analysis run record creation, progress tracking setup, async analyzer invocation,
|
|
1107
|
+
* completion/failure status broadcasting, and tracking map cleanup.
|
|
1108
|
+
*
|
|
1109
|
+
* Mode-specific differences are injected via the `modeContext` parameter.
|
|
1110
|
+
*
|
|
1111
|
+
* @param {Object} db - Database handle
|
|
1112
|
+
* @param {Object} modeContext - Mode-specific values:
|
|
1113
|
+
* @param {number} modeContext.reviewId - Review record ID
|
|
1114
|
+
* @param {string} modeContext.worktreePath - Path to the worktree or local directory
|
|
1115
|
+
* @param {Object} modeContext.prMetadata - PR metadata (from DB for PR mode, synthetic for local mode)
|
|
1116
|
+
* @param {Array|null} modeContext.changedFiles - Changed files list (null for PR mode)
|
|
1117
|
+
* @param {string} modeContext.repository - Repository identifier (e.g., "owner/repo")
|
|
1118
|
+
* @param {string} modeContext.headSha - HEAD SHA for the analysis run record
|
|
1119
|
+
* @param {Object} modeContext.trackingMap - Map instance for tracking (prToAnalysisId or localReviewToAnalysisId)
|
|
1120
|
+
* @param {string} modeContext.trackingKey - Key for the tracking map (getPRKey() or getLocalReviewKey())
|
|
1121
|
+
* @param {string} modeContext.logLabel - Human-readable label for logs (e.g., "PR #42" or "local review #7")
|
|
1122
|
+
* @param {Object} [modeContext.initialStatusExtra] - Extra fields merged into the initial status object
|
|
1123
|
+
* @param {Array<string>} [modeContext.extraBroadcastKeys] - Additional SSE keys to broadcast completion to
|
|
1124
|
+
* @param {Function} [modeContext.onSuccess] - Optional async callback invoked after successful completion
|
|
1125
|
+
* with signature (result, analysisRunRepo, runId). Used for mode-specific side effects like saving
|
|
1126
|
+
* summary to the review record.
|
|
1127
|
+
* @param {Object} [modeContext.runUpdateExtra] - Extra fields merged into the analysisRunRepo.update call on success
|
|
1128
|
+
* @param {Object} councilConfig - Validated council configuration
|
|
1129
|
+
* @param {string} councilId - Council ID (for the model field in analysis_runs), or null for inline config
|
|
1130
|
+
* @param {Object} instructions - { repoInstructions, requestInstructions }
|
|
1131
|
+
* @returns {{ analysisId: string, runId: string }} IDs for the caller to return in the response
|
|
1132
|
+
*/
|
|
1133
|
+
/**
|
|
1134
|
+
* Check if a level is enabled in a council config, handling both formats:
|
|
1135
|
+
* - Voice-centric: levels values are booleans (e.g. { '1': true })
|
|
1136
|
+
* - Advanced: levels values are objects (e.g. { '1': { enabled: true, voices: [...] } })
|
|
1137
|
+
*/
|
|
1138
|
+
function isLevelEnabled(councilConfig, levelKey) {
|
|
1139
|
+
const val = councilConfig.levels?.[levelKey];
|
|
1140
|
+
if (typeof val === 'boolean') return val;
|
|
1141
|
+
return val?.enabled === true;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId, instructions, configType = 'advanced') {
|
|
1145
|
+
const {
|
|
1146
|
+
reviewId,
|
|
1147
|
+
worktreePath,
|
|
1148
|
+
prMetadata,
|
|
1149
|
+
changedFiles,
|
|
1150
|
+
repository,
|
|
1151
|
+
headSha,
|
|
1152
|
+
trackingMap,
|
|
1153
|
+
trackingKey,
|
|
1154
|
+
logLabel,
|
|
1155
|
+
initialStatusExtra,
|
|
1156
|
+
extraBroadcastKeys,
|
|
1157
|
+
onSuccess,
|
|
1158
|
+
runUpdateExtra
|
|
1159
|
+
} = modeContext;
|
|
1160
|
+
|
|
1161
|
+
const { repoInstructions, requestInstructions } = instructions;
|
|
1162
|
+
|
|
1163
|
+
// Determine if voice-centric mode
|
|
1164
|
+
const isVoiceCentric = configType === 'council';
|
|
1165
|
+
|
|
1166
|
+
// Create run/analysis ID
|
|
1167
|
+
const runId = uuidv4();
|
|
1168
|
+
const analysisId = runId;
|
|
1169
|
+
|
|
1170
|
+
// Compute levelsConfig for the run record
|
|
1171
|
+
let levelsConfig = null;
|
|
1172
|
+
if (isVoiceCentric && councilConfig.levels) {
|
|
1173
|
+
levelsConfig = councilConfig.levels;
|
|
1174
|
+
} else if (councilConfig.levels) {
|
|
1175
|
+
levelsConfig = {};
|
|
1176
|
+
for (const [key, val] of Object.entries(councilConfig.levels)) {
|
|
1177
|
+
levelsConfig[key] = val?.enabled !== false;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// Create DB analysis_runs record
|
|
1182
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
1183
|
+
await analysisRunRepo.create({
|
|
1184
|
+
id: runId,
|
|
1185
|
+
reviewId,
|
|
1186
|
+
provider: 'council',
|
|
1187
|
+
model: councilId || 'inline-config',
|
|
1188
|
+
repoInstructions,
|
|
1189
|
+
requestInstructions,
|
|
1190
|
+
headSha: headSha || null,
|
|
1191
|
+
configType,
|
|
1192
|
+
levelsConfig
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// Touch council MRU timestamp (if using a saved council, not inline-config)
|
|
1196
|
+
if (councilId) {
|
|
1197
|
+
const councilRepo = new CouncilRepository(db);
|
|
1198
|
+
councilRepo.touchLastUsedAt(councilId).catch(err => {
|
|
1199
|
+
logger.warn(`Failed to update council last_used_at: ${err.message}`);
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Setup progress tracking
|
|
1204
|
+
const initialStatus = {
|
|
1205
|
+
id: analysisId,
|
|
1206
|
+
repository,
|
|
1207
|
+
status: 'running',
|
|
1208
|
+
startedAt: new Date().toISOString(),
|
|
1209
|
+
progress: 'Starting council analysis...',
|
|
1210
|
+
levels: {
|
|
1211
|
+
1: isLevelEnabled(councilConfig, '1') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
1212
|
+
2: isLevelEnabled(councilConfig, '2') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
1213
|
+
3: isLevelEnabled(councilConfig, '3') ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
1214
|
+
4: { status: 'pending', progress: 'Pending' }
|
|
1215
|
+
},
|
|
1216
|
+
isCouncil: true,
|
|
1217
|
+
councilConfig,
|
|
1218
|
+
configType,
|
|
1219
|
+
filesAnalyzed: 0,
|
|
1220
|
+
filesRemaining: 0,
|
|
1221
|
+
...initialStatusExtra
|
|
1222
|
+
};
|
|
1223
|
+
activeAnalyses.set(analysisId, initialStatus);
|
|
1224
|
+
|
|
1225
|
+
// Store tracking map entry
|
|
1226
|
+
trackingMap.set(trackingKey, analysisId);
|
|
1227
|
+
|
|
1228
|
+
broadcastProgress(analysisId, initialStatus);
|
|
1229
|
+
|
|
1230
|
+
// Create analyzer (provider/model don't matter for council — voices have their own)
|
|
1231
|
+
const analyzer = new Analyzer(db, 'council', 'council');
|
|
1232
|
+
|
|
1233
|
+
logger.section(`Council Analysis Request (${configType}) - ${logLabel}`);
|
|
1234
|
+
logger.log('API', `Repository: ${repository}`, 'magenta');
|
|
1235
|
+
logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
|
|
1236
|
+
logger.log('API', `Config type: ${configType}`, 'magenta');
|
|
1237
|
+
|
|
1238
|
+
const progressCallback = createProgressCallback(analysisId);
|
|
1239
|
+
|
|
1240
|
+
// Route to voice-centric or level-centric council based on configType
|
|
1241
|
+
const reviewContext = {
|
|
1242
|
+
reviewId,
|
|
1243
|
+
worktreePath,
|
|
1244
|
+
prMetadata,
|
|
1245
|
+
changedFiles,
|
|
1246
|
+
instructions: { repoInstructions, requestInstructions }
|
|
1247
|
+
};
|
|
1248
|
+
|
|
1249
|
+
const analysisPromise = isVoiceCentric
|
|
1250
|
+
? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback })
|
|
1251
|
+
: analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback });
|
|
1252
|
+
|
|
1253
|
+
analysisPromise
|
|
1254
|
+
.then(async result => {
|
|
1255
|
+
logger.success(`Council analysis complete for ${logLabel}: ${result.suggestions.length} suggestions`);
|
|
1256
|
+
|
|
1257
|
+
// Update analysis run record
|
|
1258
|
+
try {
|
|
1259
|
+
await analysisRunRepo.update(runId, {
|
|
1260
|
+
status: 'completed',
|
|
1261
|
+
summary: result.summary,
|
|
1262
|
+
totalSuggestions: result.suggestions.length,
|
|
1263
|
+
...runUpdateExtra
|
|
1264
|
+
});
|
|
1265
|
+
} catch (updateError) {
|
|
1266
|
+
logger.warn(`Failed to update analysis_run: ${updateError.message}`);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
// Mode-specific success callback (e.g., saving summary to review)
|
|
1270
|
+
if (onSuccess) {
|
|
1271
|
+
try {
|
|
1272
|
+
await onSuccess(result, analysisRunRepo, runId);
|
|
1273
|
+
} catch (callbackError) {
|
|
1274
|
+
logger.warn(`Council onSuccess callback failed: ${callbackError.message}`);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Use robust fallback: if activeAnalyses entry was removed (e.g. by cancel), use empty object
|
|
1279
|
+
const currentStatus = activeAnalyses.get(analysisId);
|
|
1280
|
+
if (!currentStatus) return;
|
|
1281
|
+
|
|
1282
|
+
const completedStatus = {
|
|
1283
|
+
...currentStatus,
|
|
1284
|
+
status: 'completed',
|
|
1285
|
+
completedAt: new Date().toISOString(),
|
|
1286
|
+
progress: `Council analysis complete — ${result.suggestions.length} suggestions`,
|
|
1287
|
+
suggestionsCount: result.suggestions.length,
|
|
1288
|
+
levels: {
|
|
1289
|
+
...currentStatus.levels,
|
|
1290
|
+
4: { status: 'completed', progress: 'Results finalized' }
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
// Mark all enabled levels as completed
|
|
1294
|
+
for (const levelKey of ['1', '2', '3']) {
|
|
1295
|
+
if (currentStatus.levels?.[levelKey]?.status === 'running') {
|
|
1296
|
+
completedStatus.levels[levelKey] = { status: 'completed', progress: 'Complete' };
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
activeAnalyses.set(analysisId, completedStatus);
|
|
1300
|
+
broadcastProgress(analysisId, completedStatus);
|
|
1301
|
+
|
|
1302
|
+
// Broadcast to additional SSE keys (e.g., local mode broadcasts to `local-${reviewId}`)
|
|
1303
|
+
if (extraBroadcastKeys) {
|
|
1304
|
+
for (const key of extraBroadcastKeys) {
|
|
1305
|
+
broadcastProgress(key, { ...completedStatus, source: 'council' });
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
})
|
|
1309
|
+
.catch(error => {
|
|
1310
|
+
if (error.isCancellation) {
|
|
1311
|
+
logger.info(`Council analysis cancelled for ${logLabel}`);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
logger.error(`Council analysis failed for ${logLabel}: ${error.message}`);
|
|
1315
|
+
|
|
1316
|
+
// Use robust fallback: if activeAnalyses entry was removed, use empty object
|
|
1317
|
+
// This prevents orphaned DB records in "running" status
|
|
1318
|
+
const failedStatus = {
|
|
1319
|
+
...(activeAnalyses.get(analysisId) || {}),
|
|
1320
|
+
status: 'failed',
|
|
1321
|
+
completedAt: new Date().toISOString(),
|
|
1322
|
+
error: error.message,
|
|
1323
|
+
progress: 'Council analysis failed'
|
|
1324
|
+
};
|
|
1325
|
+
activeAnalyses.set(analysisId, failedStatus);
|
|
1326
|
+
broadcastProgress(analysisId, failedStatus);
|
|
1327
|
+
|
|
1328
|
+
// Update analysis run record
|
|
1329
|
+
analysisRunRepo.update(runId, { status: 'failed' }).catch(() => {});
|
|
1330
|
+
})
|
|
1331
|
+
.finally(() => {
|
|
1332
|
+
// Clean up tracking map entry (always runs regardless of success/failure)
|
|
1333
|
+
trackingMap.delete(trackingKey);
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
return { analysisId, runId };
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
1340
|
+
* Trigger council analysis for a PR
|
|
1341
|
+
* Uses multiple voices/providers across configurable levels
|
|
1342
|
+
*/
|
|
1343
|
+
router.post('/api/analyze/council/:owner/:repo/:pr', async (req, res) => {
|
|
1344
|
+
try {
|
|
1345
|
+
const { owner, repo, pr } = req.params;
|
|
1346
|
+
const prNumber = parseInt(pr);
|
|
1347
|
+
const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
|
|
1348
|
+
|
|
1349
|
+
if (isNaN(prNumber) || prNumber <= 0) {
|
|
1350
|
+
return res.status(400).json({ error: 'Invalid pull request number' });
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
if (!councilId && !inlineConfig) {
|
|
1354
|
+
return res.status(400).json({ error: 'Either councilId or councilConfig is required' });
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const repository = normalizeRepository(owner, repo);
|
|
1358
|
+
const db = req.app.get('db');
|
|
1359
|
+
|
|
1360
|
+
// Resolve council config and determine config type
|
|
1361
|
+
// Priority: request body configType > saved council type > default 'advanced'
|
|
1362
|
+
let councilConfig;
|
|
1363
|
+
let configType;
|
|
1364
|
+
if (councilId) {
|
|
1365
|
+
const councilRepo = new CouncilRepository(db);
|
|
1366
|
+
const council = await councilRepo.getById(councilId);
|
|
1367
|
+
if (!council) {
|
|
1368
|
+
return res.status(404).json({ error: 'Council not found' });
|
|
1369
|
+
}
|
|
1370
|
+
councilConfig = council.config;
|
|
1371
|
+
configType = requestConfigType || council.type || 'advanced';
|
|
1372
|
+
} else {
|
|
1373
|
+
councilConfig = inlineConfig;
|
|
1374
|
+
configType = requestConfigType || 'advanced';
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// Normalize config to voice-centric format if needed (handles DB-stored legacy formats)
|
|
1378
|
+
councilConfig = normalizeCouncilConfig(councilConfig, configType);
|
|
1379
|
+
|
|
1380
|
+
// Validate council config (saved configs are validated on save; inline configs need runtime validation)
|
|
1381
|
+
const configError = validateCouncilConfig(councilConfig, configType);
|
|
1382
|
+
if (configError) {
|
|
1383
|
+
return res.status(400).json({ error: `Invalid council config: ${configError}` });
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// Get PR metadata
|
|
1387
|
+
const prMetadataRepo = new PRMetadataRepository(db);
|
|
1388
|
+
const prMetadata = await prMetadataRepo.getByPR(prNumber, repository);
|
|
1389
|
+
if (!prMetadata) {
|
|
1390
|
+
return res.status(404).json({ error: `Pull request #${prNumber} not found. Please load the PR first.` });
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Get worktree path
|
|
1394
|
+
const worktreeManager = new GitWorktreeManager(db);
|
|
1395
|
+
const worktreePath = await worktreeManager.getWorktreePath({ owner, repo, number: prNumber });
|
|
1396
|
+
if (!await worktreeManager.worktreeExists({ owner, repo, number: prNumber })) {
|
|
1397
|
+
return res.status(404).json({ error: 'Worktree not found for this PR. Please reload the PR.' });
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Resolve instructions
|
|
1401
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1402
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1403
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
1404
|
+
const repoInstructions = repoSettings?.default_instructions || null;
|
|
1405
|
+
const requestInstructions = rawInstructions?.trim() || null;
|
|
1406
|
+
|
|
1407
|
+
// Get or create review record
|
|
1408
|
+
const review = await reviewRepo.getOrCreate({ prNumber, repository });
|
|
1409
|
+
|
|
1410
|
+
// Save custom instructions to the review record (same as single-model endpoint)
|
|
1411
|
+
if (requestInstructions) {
|
|
1412
|
+
await reviewRepo.upsertCustomInstructions(prNumber, repository, requestInstructions);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
const { analysisId, runId } = await launchCouncilAnalysis(
|
|
1416
|
+
db,
|
|
1417
|
+
{
|
|
1418
|
+
reviewId: review.id,
|
|
1419
|
+
worktreePath,
|
|
1420
|
+
prMetadata,
|
|
1421
|
+
changedFiles: null,
|
|
1422
|
+
repository,
|
|
1423
|
+
headSha: prMetadata.head_sha,
|
|
1424
|
+
trackingMap: prToAnalysisId,
|
|
1425
|
+
trackingKey: getPRKey(owner, repo, prNumber),
|
|
1426
|
+
logLabel: `PR #${prNumber}`,
|
|
1427
|
+
initialStatusExtra: { prNumber },
|
|
1428
|
+
extraBroadcastKeys: null,
|
|
1429
|
+
onSuccess: async (result) => {
|
|
1430
|
+
if (result.summary) {
|
|
1431
|
+
await reviewRepo.upsertSummary(prNumber, repository, result.summary);
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
},
|
|
1435
|
+
councilConfig,
|
|
1436
|
+
councilId,
|
|
1437
|
+
{ repoInstructions, requestInstructions },
|
|
1438
|
+
configType
|
|
1439
|
+
);
|
|
1440
|
+
|
|
1441
|
+
res.json({
|
|
1442
|
+
analysisId,
|
|
1443
|
+
runId,
|
|
1444
|
+
status: 'started',
|
|
1445
|
+
message: 'Council analysis started in background',
|
|
1446
|
+
isCouncil: true
|
|
1447
|
+
});
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
logger.error('Error starting council analysis:', error);
|
|
1450
|
+
res.status(500).json({ error: 'Failed to start council analysis' });
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* Trigger council analysis for a local review
|
|
1456
|
+
*/
|
|
1457
|
+
router.post('/api/local/:reviewId/analyze/council', async (req, res) => {
|
|
1458
|
+
try {
|
|
1459
|
+
const { reviewId } = req.params;
|
|
1460
|
+
const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
|
|
1461
|
+
|
|
1462
|
+
if (!councilId && !inlineConfig) {
|
|
1463
|
+
return res.status(400).json({ error: 'Either councilId or councilConfig is required' });
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const db = req.app.get('db');
|
|
1467
|
+
|
|
1468
|
+
// Get review record
|
|
1469
|
+
const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ? AND review_type = ?', [reviewId, 'local']);
|
|
1470
|
+
if (!review) {
|
|
1471
|
+
return res.status(404).json({ error: 'Local review not found' });
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// Resolve council config and determine config type
|
|
1475
|
+
// Priority: request body configType > saved council type > default 'advanced'
|
|
1476
|
+
let councilConfig;
|
|
1477
|
+
let configType;
|
|
1478
|
+
if (councilId) {
|
|
1479
|
+
const councilRepo = new CouncilRepository(db);
|
|
1480
|
+
const council = await councilRepo.getById(councilId);
|
|
1481
|
+
if (!council) {
|
|
1482
|
+
return res.status(404).json({ error: 'Council not found' });
|
|
1483
|
+
}
|
|
1484
|
+
councilConfig = council.config;
|
|
1485
|
+
configType = requestConfigType || council.type || 'advanced';
|
|
1486
|
+
} else {
|
|
1487
|
+
councilConfig = inlineConfig;
|
|
1488
|
+
configType = requestConfigType || 'advanced';
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// Normalize config to voice-centric format if needed (handles DB-stored legacy formats)
|
|
1492
|
+
councilConfig = normalizeCouncilConfig(councilConfig, configType);
|
|
1493
|
+
|
|
1494
|
+
// Validate council config (saved configs are validated on save; inline configs need runtime validation)
|
|
1495
|
+
const configError = validateCouncilConfig(councilConfig, configType);
|
|
1496
|
+
if (configError) {
|
|
1497
|
+
return res.status(400).json({ error: `Invalid council config: ${configError}` });
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
const localPath = review.local_path;
|
|
1501
|
+
|
|
1502
|
+
// Build metadata for local mode
|
|
1503
|
+
const prMetadata = {
|
|
1504
|
+
reviewType: 'local',
|
|
1505
|
+
repository: review.repository,
|
|
1506
|
+
title: review.name || 'Local changes',
|
|
1507
|
+
description: '',
|
|
1508
|
+
head_sha: review.local_head_sha
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
// Get changed files
|
|
1512
|
+
const analyzer = new Analyzer(db, 'council', 'council');
|
|
1513
|
+
const changedFiles = await analyzer.getLocalChangedFiles(localPath);
|
|
1514
|
+
|
|
1515
|
+
// Generate and cache diff so the web UI can display it (same as regular analysis endpoint)
|
|
1516
|
+
try {
|
|
1517
|
+
const diffResult = await generateLocalDiff(localPath);
|
|
1518
|
+
const digest = await computeLocalDiffDigest(localPath);
|
|
1519
|
+
localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
|
|
1520
|
+
} catch (diffError) {
|
|
1521
|
+
logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
// Resolve instructions
|
|
1525
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1526
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1527
|
+
const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
|
|
1528
|
+
const repoInstructions = repoSettings?.default_instructions || null;
|
|
1529
|
+
const requestInstructions = rawInstructions?.trim() || null;
|
|
1530
|
+
|
|
1531
|
+
const parsedReviewId = parseInt(reviewId, 10);
|
|
1532
|
+
|
|
1533
|
+
// Save custom instructions to the review record (same as single-model endpoint)
|
|
1534
|
+
if (requestInstructions) {
|
|
1535
|
+
await reviewRepo.updateReview(parsedReviewId, {
|
|
1536
|
+
customInstructions: requestInstructions
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
const { analysisId, runId } = await launchCouncilAnalysis(
|
|
1541
|
+
db,
|
|
1542
|
+
{
|
|
1543
|
+
reviewId: parsedReviewId,
|
|
1544
|
+
worktreePath: localPath,
|
|
1545
|
+
prMetadata,
|
|
1546
|
+
changedFiles,
|
|
1547
|
+
repository: review.repository,
|
|
1548
|
+
headSha: review.local_head_sha,
|
|
1549
|
+
trackingMap: localReviewToAnalysisId,
|
|
1550
|
+
trackingKey: getLocalReviewKey(reviewId),
|
|
1551
|
+
logLabel: `local review #${reviewId}`,
|
|
1552
|
+
initialStatusExtra: { reviewId: parsedReviewId, reviewType: 'local' },
|
|
1553
|
+
extraBroadcastKeys: [`local-${reviewId}`],
|
|
1554
|
+
runUpdateExtra: { filesAnalyzed: changedFiles.length }
|
|
1555
|
+
},
|
|
1556
|
+
councilConfig,
|
|
1557
|
+
councilId,
|
|
1558
|
+
{ repoInstructions, requestInstructions },
|
|
1559
|
+
configType
|
|
1560
|
+
);
|
|
1561
|
+
|
|
1562
|
+
res.json({
|
|
1563
|
+
analysisId,
|
|
1564
|
+
runId,
|
|
1565
|
+
status: 'started',
|
|
1566
|
+
message: 'Council analysis started in background',
|
|
1567
|
+
isCouncil: true
|
|
1568
|
+
});
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
logger.error('Error starting local council analysis:', error);
|
|
1571
|
+
res.status(500).json({ error: 'Failed to start council analysis' });
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
|
|
1090
1575
|
module.exports = router;
|