@in-the-loop-labs/pair-review 1.4.3 → 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/config.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const express = require('express');
|
|
13
|
-
const { RepoSettingsRepository, ReviewRepository } = require('../database');
|
|
13
|
+
const { RepoSettingsRepository, ReviewRepository, queryOne } = require('../database');
|
|
14
14
|
const {
|
|
15
15
|
getAllProvidersInfo,
|
|
16
16
|
testProviderAvailability,
|
|
@@ -82,7 +82,7 @@ router.patch('/api/config', async (req, res) => {
|
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
} catch (error) {
|
|
85
|
-
|
|
85
|
+
logger.error('Error updating config:', error);
|
|
86
86
|
res.status(500).json({
|
|
87
87
|
error: 'Failed to update configuration'
|
|
88
88
|
});
|
|
@@ -109,7 +109,9 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
109
109
|
default_instructions: null,
|
|
110
110
|
default_provider: null,
|
|
111
111
|
default_model: null,
|
|
112
|
-
local_path: null
|
|
112
|
+
local_path: null,
|
|
113
|
+
default_council_id: null,
|
|
114
|
+
default_tab: null
|
|
113
115
|
});
|
|
114
116
|
}
|
|
115
117
|
|
|
@@ -119,12 +121,14 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
119
121
|
default_provider: settings.default_provider,
|
|
120
122
|
default_model: settings.default_model,
|
|
121
123
|
local_path: settings.local_path,
|
|
124
|
+
default_council_id: settings.default_council_id,
|
|
125
|
+
default_tab: settings.default_tab,
|
|
122
126
|
created_at: settings.created_at,
|
|
123
127
|
updated_at: settings.updated_at
|
|
124
128
|
});
|
|
125
129
|
|
|
126
130
|
} catch (error) {
|
|
127
|
-
|
|
131
|
+
logger.error('Error fetching repo settings:', error);
|
|
128
132
|
res.status(500).json({
|
|
129
133
|
error: 'Failed to fetch repository settings'
|
|
130
134
|
});
|
|
@@ -138,14 +142,14 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
138
142
|
router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
139
143
|
try {
|
|
140
144
|
const { owner, repo } = req.params;
|
|
141
|
-
const { default_instructions, default_provider, default_model, local_path } = req.body;
|
|
145
|
+
const { default_instructions, default_provider, default_model, local_path, default_council_id, default_tab } = req.body;
|
|
142
146
|
const repository = normalizeRepository(owner, repo);
|
|
143
147
|
const db = req.app.get('db');
|
|
144
148
|
|
|
145
149
|
// Validate that at least one setting is provided
|
|
146
|
-
if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined) {
|
|
150
|
+
if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined && default_council_id === undefined && default_tab === undefined) {
|
|
147
151
|
return res.status(400).json({
|
|
148
|
-
error: 'At least one setting (default_instructions, default_provider, default_model, or
|
|
152
|
+
error: 'At least one setting (default_instructions, default_provider, default_model, local_path, default_council_id, or default_tab) must be provided'
|
|
149
153
|
});
|
|
150
154
|
}
|
|
151
155
|
|
|
@@ -154,7 +158,9 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
154
158
|
default_instructions,
|
|
155
159
|
default_provider,
|
|
156
160
|
default_model,
|
|
157
|
-
local_path
|
|
161
|
+
local_path,
|
|
162
|
+
default_council_id,
|
|
163
|
+
default_tab
|
|
158
164
|
});
|
|
159
165
|
|
|
160
166
|
logger.info(`Saved repo settings for ${repository}`);
|
|
@@ -167,12 +173,14 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
|
|
|
167
173
|
default_provider: settings.default_provider,
|
|
168
174
|
default_model: settings.default_model,
|
|
169
175
|
local_path: settings.local_path,
|
|
176
|
+
default_council_id: settings.default_council_id,
|
|
177
|
+
default_tab: settings.default_tab,
|
|
170
178
|
updated_at: settings.updated_at
|
|
171
179
|
}
|
|
172
180
|
});
|
|
173
181
|
|
|
174
182
|
} catch (error) {
|
|
175
|
-
|
|
183
|
+
logger.error('Error saving repo settings:', error);
|
|
176
184
|
res.status(500).json({
|
|
177
185
|
error: 'Failed to save repository settings'
|
|
178
186
|
});
|
|
@@ -202,16 +210,29 @@ router.get('/api/pr/:owner/:repo/:number/review-settings', async (req, res) => {
|
|
|
202
210
|
|
|
203
211
|
if (!review) {
|
|
204
212
|
return res.json({
|
|
205
|
-
custom_instructions: null
|
|
213
|
+
custom_instructions: null,
|
|
214
|
+
last_council_id: null
|
|
206
215
|
});
|
|
207
216
|
}
|
|
208
217
|
|
|
218
|
+
// Find the last council used for this review
|
|
219
|
+
let last_council_id = null;
|
|
220
|
+
const lastCouncilRun = await queryOne(db, `
|
|
221
|
+
SELECT model FROM analysis_runs
|
|
222
|
+
WHERE review_id = ? AND provider = 'council' AND model != 'inline-config'
|
|
223
|
+
ORDER BY started_at DESC LIMIT 1
|
|
224
|
+
`, [review.id]);
|
|
225
|
+
if (lastCouncilRun) {
|
|
226
|
+
last_council_id = lastCouncilRun.model;
|
|
227
|
+
}
|
|
228
|
+
|
|
209
229
|
res.json({
|
|
210
|
-
custom_instructions: review.custom_instructions || null
|
|
230
|
+
custom_instructions: review.custom_instructions || null,
|
|
231
|
+
last_council_id
|
|
211
232
|
});
|
|
212
233
|
|
|
213
234
|
} catch (error) {
|
|
214
|
-
|
|
235
|
+
logger.error('Error fetching review settings:', error);
|
|
215
236
|
res.status(500).json({
|
|
216
237
|
error: 'Failed to fetch review settings'
|
|
217
238
|
});
|
|
@@ -238,7 +259,7 @@ router.get('/api/providers', (req, res) => {
|
|
|
238
259
|
checkInProgress: isCheckInProgress()
|
|
239
260
|
});
|
|
240
261
|
} catch (error) {
|
|
241
|
-
|
|
262
|
+
logger.error('Error fetching providers:', error);
|
|
242
263
|
res.status(500).json({
|
|
243
264
|
error: 'Failed to fetch AI providers'
|
|
244
265
|
});
|
|
@@ -261,7 +282,7 @@ router.get('/api/providers/:providerId/test', async (req, res) => {
|
|
|
261
282
|
installInstructions: result.installInstructions || null
|
|
262
283
|
});
|
|
263
284
|
} catch (error) {
|
|
264
|
-
|
|
285
|
+
logger.error('Error testing provider:', error);
|
|
265
286
|
res.status(500).json({
|
|
266
287
|
error: 'Failed to test provider availability'
|
|
267
288
|
});
|
|
@@ -298,7 +319,7 @@ router.post('/api/providers/refresh-availability', async (req, res) => {
|
|
|
298
319
|
checkInProgress: true
|
|
299
320
|
});
|
|
300
321
|
} catch (error) {
|
|
301
|
-
|
|
322
|
+
logger.error('Error refreshing provider availability:', error);
|
|
302
323
|
res.status(500).json({
|
|
303
324
|
error: 'Failed to refresh provider availability'
|
|
304
325
|
});
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Council Routes
|
|
4
|
+
*
|
|
5
|
+
* CRUD endpoints for managing Review Council configurations.
|
|
6
|
+
* Councils define multi-voice, multi-provider analysis configurations
|
|
7
|
+
* that run in parallel and consolidate results.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const express = require('express');
|
|
11
|
+
const { v4: uuidv4 } = require('uuid');
|
|
12
|
+
const logger = require('../utils/logger');
|
|
13
|
+
const { CouncilRepository } = require('../database');
|
|
14
|
+
|
|
15
|
+
const router = express.Router();
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalize a council config to match the expected shape for its type.
|
|
19
|
+
*
|
|
20
|
+
* When type is 'council' (voice-centric) but the config is in the levels-based
|
|
21
|
+
* (advanced) format — e.g. from a previously saved council or a migration — this
|
|
22
|
+
* extracts the voices and converts the levels to booleans so it passes validation.
|
|
23
|
+
*
|
|
24
|
+
* When type is anything else, or the config already matches, returns the config
|
|
25
|
+
* as-is.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} config - Council configuration
|
|
28
|
+
* @param {string} [type] - The council type ('council' or 'advanced')
|
|
29
|
+
* @returns {Object} Normalized config (may be the original object if no changes needed)
|
|
30
|
+
*/
|
|
31
|
+
function normalizeCouncilConfig(config, type) {
|
|
32
|
+
if (!config || typeof config !== 'object' || type !== 'council') {
|
|
33
|
+
return config;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// If it already has a voices array, it's already in voice-centric format
|
|
37
|
+
if (Array.isArray(config.voices) && config.voices.length > 0) {
|
|
38
|
+
return config;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if levels are in the advanced format (objects with enabled/voices)
|
|
42
|
+
if (!config.levels || typeof config.levels !== 'object') {
|
|
43
|
+
return config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hasAdvancedLevels = Object.values(config.levels).some(
|
|
47
|
+
val => typeof val === 'object' && val !== null && 'enabled' in val
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (!hasAdvancedLevels) {
|
|
51
|
+
return config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Convert from advanced (levels-based) to voice-centric format
|
|
55
|
+
const normalizedVoices = [];
|
|
56
|
+
const seenVoices = new Set();
|
|
57
|
+
const normalizedLevels = {};
|
|
58
|
+
|
|
59
|
+
for (const [key, levelConfig] of Object.entries(config.levels)) {
|
|
60
|
+
if (typeof levelConfig === 'object' && levelConfig !== null) {
|
|
61
|
+
normalizedLevels[key] = levelConfig.enabled !== false;
|
|
62
|
+
if (levelConfig.enabled !== false && Array.isArray(levelConfig.voices)) {
|
|
63
|
+
for (const v of levelConfig.voices) {
|
|
64
|
+
const voiceSig = JSON.stringify(v, Object.keys(v).sort());
|
|
65
|
+
if (!seenVoices.has(voiceSig)) {
|
|
66
|
+
seenVoices.add(voiceSig);
|
|
67
|
+
normalizedVoices.push(v);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else {
|
|
72
|
+
// Already boolean — keep as-is
|
|
73
|
+
normalizedLevels[key] = levelConfig !== false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Destructure out orchestration so it does not leak into the normalized output
|
|
78
|
+
const { orchestration, ...rest } = config;
|
|
79
|
+
return {
|
|
80
|
+
...rest,
|
|
81
|
+
voices: normalizedVoices,
|
|
82
|
+
levels: normalizedLevels,
|
|
83
|
+
consolidation: config.consolidation || orchestration || undefined
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validate a council config object
|
|
89
|
+
* @param {Object} config - Council configuration
|
|
90
|
+
* @param {string} [type] - The council type ('council' or 'advanced'), provided as a sibling field from req.body
|
|
91
|
+
* @returns {string|null} Error message or null if valid
|
|
92
|
+
*/
|
|
93
|
+
function validateCouncilConfig(config, type) {
|
|
94
|
+
if (!config || typeof config !== 'object') {
|
|
95
|
+
return 'config must be an object';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Dispatch based on explicit type parameter (from req.body.type, not config.type)
|
|
99
|
+
if (type === 'council') {
|
|
100
|
+
return validateCouncilFormat(config);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Legacy configs (no type) and type === 'advanced' use level-centric format
|
|
104
|
+
return validateAdvancedFormat(config);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Validate the voice-centric council format (type: 'council')
|
|
109
|
+
* @param {Object} config
|
|
110
|
+
* @returns {string|null} Error message or null if valid
|
|
111
|
+
*/
|
|
112
|
+
function validateCouncilFormat(config) {
|
|
113
|
+
// Validate voices array
|
|
114
|
+
if (!Array.isArray(config.voices) || config.voices.length === 0) {
|
|
115
|
+
return 'config.voices must be a non-empty array';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const [i, voice] of config.voices.entries()) {
|
|
119
|
+
if (!voice.provider) {
|
|
120
|
+
return `voices[${i}].provider is required`;
|
|
121
|
+
}
|
|
122
|
+
if (!voice.model) {
|
|
123
|
+
return `voices[${i}].model is required`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Validate levels
|
|
128
|
+
if (!config.levels || typeof config.levels !== 'object') {
|
|
129
|
+
return 'config.levels is required and must be an object';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const validLevels = ['1', '2', '3'];
|
|
133
|
+
const hasEnabled = Object.entries(config.levels).some(([key, val]) =>
|
|
134
|
+
validLevels.includes(key) && val === true
|
|
135
|
+
);
|
|
136
|
+
if (!hasEnabled) {
|
|
137
|
+
return 'At least one level (1, 2, or 3) must be enabled';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Validate consolidation (optional)
|
|
141
|
+
if (config.consolidation) {
|
|
142
|
+
if (!config.consolidation.provider || !config.consolidation.model) {
|
|
143
|
+
return 'consolidation.provider and consolidation.model are required when consolidation is specified';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validate the level-centric advanced format (type: 'advanced' or legacy no-type)
|
|
152
|
+
* @param {Object} config
|
|
153
|
+
* @returns {string|null} Error message or null if valid
|
|
154
|
+
*/
|
|
155
|
+
function validateAdvancedFormat(config) {
|
|
156
|
+
// Validate levels
|
|
157
|
+
if (!config.levels || typeof config.levels !== 'object') {
|
|
158
|
+
return 'config.levels is required and must be an object';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const validLevels = ['1', '2', '3'];
|
|
162
|
+
for (const [levelKey, level] of Object.entries(config.levels)) {
|
|
163
|
+
if (!validLevels.includes(levelKey)) {
|
|
164
|
+
return `Invalid level key: "${levelKey}". Valid keys: ${validLevels.join(', ')}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (typeof level.enabled !== 'boolean') {
|
|
168
|
+
return `levels.${levelKey}.enabled must be a boolean`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (level.enabled) {
|
|
172
|
+
if (!Array.isArray(level.voices) || level.voices.length === 0) {
|
|
173
|
+
return `levels.${levelKey}.voices must be a non-empty array when enabled`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const [i, voice] of level.voices.entries()) {
|
|
177
|
+
if (!voice.provider) {
|
|
178
|
+
return `levels.${levelKey}.voices[${i}].provider is required`;
|
|
179
|
+
}
|
|
180
|
+
if (!voice.model) {
|
|
181
|
+
return `levels.${levelKey}.voices[${i}].model is required`;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Ensure at least one level is enabled with voices
|
|
188
|
+
const hasEnabledLevel = Object.values(config.levels).some(l => l.enabled);
|
|
189
|
+
if (!hasEnabledLevel) {
|
|
190
|
+
return 'At least one level must be enabled';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Validate orchestration (optional — defaults will be applied at runtime)
|
|
194
|
+
if (config.orchestration) {
|
|
195
|
+
if (!config.orchestration.provider || !config.orchestration.model) {
|
|
196
|
+
return 'orchestration.provider and orchestration.model are required when orchestration is specified';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* GET /api/councils — List all saved councils
|
|
205
|
+
*/
|
|
206
|
+
router.get('/api/councils', async (req, res) => {
|
|
207
|
+
try {
|
|
208
|
+
const db = req.app.get('db');
|
|
209
|
+
const councilRepo = new CouncilRepository(db);
|
|
210
|
+
const councils = await councilRepo.list();
|
|
211
|
+
|
|
212
|
+
res.json({ councils });
|
|
213
|
+
} catch (error) {
|
|
214
|
+
logger.error('Error listing councils:', error);
|
|
215
|
+
res.status(500).json({ error: 'Failed to list councils' });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* GET /api/councils/:id — Get a specific council
|
|
221
|
+
*/
|
|
222
|
+
router.get('/api/councils/:id', async (req, res) => {
|
|
223
|
+
try {
|
|
224
|
+
const { id } = req.params;
|
|
225
|
+
const db = req.app.get('db');
|
|
226
|
+
const councilRepo = new CouncilRepository(db);
|
|
227
|
+
const council = await councilRepo.getById(id);
|
|
228
|
+
|
|
229
|
+
if (!council) {
|
|
230
|
+
return res.status(404).json({ error: 'Council not found' });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
res.json({ council });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
logger.error('Error fetching council:', error);
|
|
236
|
+
res.status(500).json({ error: 'Failed to fetch council' });
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* POST /api/councils — Create a new council
|
|
242
|
+
*/
|
|
243
|
+
router.post('/api/councils', async (req, res) => {
|
|
244
|
+
try {
|
|
245
|
+
const { name, config, type } = req.body || {};
|
|
246
|
+
|
|
247
|
+
if (!name || !name.trim()) {
|
|
248
|
+
return res.status(400).json({ error: 'name is required' });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!config) {
|
|
252
|
+
return res.status(400).json({ error: 'config is required' });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const effectiveType = type || 'advanced';
|
|
256
|
+
const validationError = validateCouncilConfig(config, effectiveType);
|
|
257
|
+
if (validationError) {
|
|
258
|
+
return res.status(400).json({ error: validationError });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const db = req.app.get('db');
|
|
262
|
+
const councilRepo = new CouncilRepository(db);
|
|
263
|
+
const id = uuidv4();
|
|
264
|
+
const council = await councilRepo.create({ id, name: name.trim(), config, type: effectiveType });
|
|
265
|
+
|
|
266
|
+
res.status(201).json({ council });
|
|
267
|
+
} catch (error) {
|
|
268
|
+
logger.error('Error creating council:', error);
|
|
269
|
+
res.status(500).json({ error: 'Failed to create council' });
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* PUT /api/councils/:id — Update a council
|
|
275
|
+
*/
|
|
276
|
+
router.put('/api/councils/:id', async (req, res) => {
|
|
277
|
+
try {
|
|
278
|
+
const { id } = req.params;
|
|
279
|
+
const { name, config, type } = req.body || {};
|
|
280
|
+
|
|
281
|
+
const db = req.app.get('db');
|
|
282
|
+
const councilRepo = new CouncilRepository(db);
|
|
283
|
+
|
|
284
|
+
// Verify council exists
|
|
285
|
+
const existing = await councilRepo.getById(id);
|
|
286
|
+
if (!existing) {
|
|
287
|
+
return res.status(404).json({ error: 'Council not found' });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Validate config if provided
|
|
291
|
+
if (config) {
|
|
292
|
+
// A PUT might update config without changing type, so use the effective type:
|
|
293
|
+
// prefer the explicitly provided type, fall back to the existing record's type
|
|
294
|
+
const effectiveType = type !== undefined ? type : existing.type;
|
|
295
|
+
const validationError = validateCouncilConfig(config, effectiveType);
|
|
296
|
+
if (validationError) {
|
|
297
|
+
return res.status(400).json({ error: validationError });
|
|
298
|
+
}
|
|
299
|
+
} else if (type !== undefined && type !== existing.type) {
|
|
300
|
+
// Type is changing without a new config — validate existing config against the new type
|
|
301
|
+
const validationError = validateCouncilConfig(existing.config, type);
|
|
302
|
+
if (validationError) {
|
|
303
|
+
return res.status(400).json({ error: `Existing config is incompatible with type '${type}': ${validationError}` });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const updates = {};
|
|
308
|
+
if (name !== undefined) {
|
|
309
|
+
const trimmed = name.trim();
|
|
310
|
+
if (!trimmed) {
|
|
311
|
+
return res.status(400).json({ error: 'name cannot be empty' });
|
|
312
|
+
}
|
|
313
|
+
updates.name = trimmed;
|
|
314
|
+
}
|
|
315
|
+
if (config !== undefined) updates.config = config;
|
|
316
|
+
if (type !== undefined) updates.type = type;
|
|
317
|
+
|
|
318
|
+
await councilRepo.update(id, updates);
|
|
319
|
+
const updated = await councilRepo.getById(id);
|
|
320
|
+
|
|
321
|
+
res.json({ council: updated });
|
|
322
|
+
} catch (error) {
|
|
323
|
+
logger.error('Error updating council:', error);
|
|
324
|
+
res.status(500).json({ error: 'Failed to update council' });
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* DELETE /api/councils/:id — Delete a council
|
|
330
|
+
*/
|
|
331
|
+
router.delete('/api/councils/:id', async (req, res) => {
|
|
332
|
+
try {
|
|
333
|
+
const { id } = req.params;
|
|
334
|
+
const db = req.app.get('db');
|
|
335
|
+
const councilRepo = new CouncilRepository(db);
|
|
336
|
+
|
|
337
|
+
const existed = await councilRepo.delete(id);
|
|
338
|
+
if (!existed) {
|
|
339
|
+
return res.status(404).json({ error: 'Council not found' });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
res.json({ success: true });
|
|
343
|
+
} catch (error) {
|
|
344
|
+
logger.error('Error deleting council:', error);
|
|
345
|
+
res.status(500).json({ error: 'Failed to delete council' });
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
module.exports = router;
|
|
350
|
+
module.exports.validateCouncilConfig = validateCouncilConfig;
|
|
351
|
+
module.exports.normalizeCouncilConfig = normalizeCouncilConfig;
|
package/src/routes/local.js
CHANGED
|
@@ -33,7 +33,8 @@ const {
|
|
|
33
33
|
determineCompletionInfo,
|
|
34
34
|
broadcastProgress,
|
|
35
35
|
CancellationError,
|
|
36
|
-
createProgressCallback
|
|
36
|
+
createProgressCallback,
|
|
37
|
+
parseEnabledLevels
|
|
37
38
|
} = require('./shared');
|
|
38
39
|
|
|
39
40
|
const router = express.Router();
|
|
@@ -603,7 +604,7 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
|
|
|
603
604
|
}
|
|
604
605
|
|
|
605
606
|
// Extract optional provider, model, tier, customInstructions and skipLevel3 from request body
|
|
606
|
-
const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3 } = req.body || {};
|
|
607
|
+
const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions, skipLevel3: requestSkipLevel3, enabledLevels: requestEnabledLevels } = req.body || {};
|
|
607
608
|
|
|
608
609
|
// Trim and validate custom instructions
|
|
609
610
|
const MAX_INSTRUCTIONS_LENGTH = 5000;
|
|
@@ -680,6 +681,7 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
|
|
|
680
681
|
|
|
681
682
|
// Create DB analysis_runs record immediately so it's queryable for polling
|
|
682
683
|
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
684
|
+
const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
|
|
683
685
|
try {
|
|
684
686
|
await analysisRunRepo.create({
|
|
685
687
|
id: runId,
|
|
@@ -688,7 +690,9 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
|
|
|
688
690
|
model: selectedModel,
|
|
689
691
|
repoInstructions,
|
|
690
692
|
requestInstructions,
|
|
691
|
-
headSha: review.local_head_sha || null
|
|
693
|
+
headSha: review.local_head_sha || null,
|
|
694
|
+
configType: 'single',
|
|
695
|
+
levelsConfig
|
|
692
696
|
});
|
|
693
697
|
} catch (error) {
|
|
694
698
|
logger.error('Failed to create analysis run record:', error);
|
|
@@ -705,9 +709,9 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
|
|
|
705
709
|
startedAt: new Date().toISOString(),
|
|
706
710
|
progress: 'Starting analysis...',
|
|
707
711
|
levels: {
|
|
708
|
-
1: { status: 'running', progress: 'Starting...' },
|
|
709
|
-
2: { status: 'running', progress: 'Starting...' },
|
|
710
|
-
3:
|
|
712
|
+
1: levelsConfig[1] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
713
|
+
2: levelsConfig[2] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
714
|
+
3: levelsConfig[3] ? { status: 'running', progress: 'Starting...' } : { status: 'skipped', progress: 'Skipped' },
|
|
711
715
|
4: { status: 'pending', progress: 'Pending' }
|
|
712
716
|
},
|
|
713
717
|
filesAnalyzed: 0,
|
|
@@ -760,7 +764,7 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
|
|
|
760
764
|
const progressCallback = createProgressCallback(analysisId);
|
|
761
765
|
|
|
762
766
|
// Start analysis asynchronously (skipRunCreation since we created the record above; also passes changedFiles for local mode path validation, tier for prompt selection, and skipLevel3 flag)
|
|
763
|
-
analyzer.analyzeLevel1(reviewId, localPath, localMetadata, progressCallback, { repoInstructions, requestInstructions }, changedFiles, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3 })
|
|
767
|
+
analyzer.analyzeLevel1(reviewId, localPath, localMetadata, progressCallback, { repoInstructions, requestInstructions }, changedFiles, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig })
|
|
764
768
|
.then(async result => {
|
|
765
769
|
logger.section('Local Analysis Results');
|
|
766
770
|
logger.success(`Analysis complete for local review #${reviewId}`);
|
|
@@ -979,6 +983,7 @@ router.get('/api/local/:reviewId/suggestions', async (req, res) => {
|
|
|
979
983
|
AND source = 'ai'
|
|
980
984
|
AND ${levelFilter}
|
|
981
985
|
AND status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')
|
|
986
|
+
AND (is_raw = 0 OR is_raw IS NULL)
|
|
982
987
|
AND ${runIdFilter}
|
|
983
988
|
ORDER BY
|
|
984
989
|
CASE
|
|
@@ -1741,10 +1746,11 @@ router.get('/api/local/:reviewId/has-ai-suggestions', async (req, res) => {
|
|
|
1741
1746
|
}
|
|
1742
1747
|
|
|
1743
1748
|
// Check if any AI suggestions exist for this review
|
|
1749
|
+
// Exclude raw council voice suggestions (is_raw=1) — only count final/consolidated suggestions
|
|
1744
1750
|
const result = await queryOne(db, `
|
|
1745
1751
|
SELECT EXISTS(
|
|
1746
1752
|
SELECT 1 FROM comments
|
|
1747
|
-
WHERE review_id = ? AND source = 'ai'
|
|
1753
|
+
WHERE review_id = ? AND source = 'ai' AND (is_raw = 0 OR is_raw IS NULL)
|
|
1748
1754
|
) as has_suggestions
|
|
1749
1755
|
`, [reviewId]);
|
|
1750
1756
|
|
|
@@ -1997,12 +2003,25 @@ router.get('/api/local/:reviewId/review-settings', async (req, res) => {
|
|
|
1997
2003
|
|
|
1998
2004
|
if (!review) {
|
|
1999
2005
|
return res.json({
|
|
2000
|
-
custom_instructions: null
|
|
2006
|
+
custom_instructions: null,
|
|
2007
|
+
last_council_id: null
|
|
2001
2008
|
});
|
|
2002
2009
|
}
|
|
2003
2010
|
|
|
2011
|
+
// Find the last council used for this review
|
|
2012
|
+
let last_council_id = null;
|
|
2013
|
+
const lastCouncilRun = await queryOne(db, `
|
|
2014
|
+
SELECT model FROM analysis_runs
|
|
2015
|
+
WHERE review_id = ? AND provider = 'council' AND model != 'inline-config'
|
|
2016
|
+
ORDER BY started_at DESC LIMIT 1
|
|
2017
|
+
`, [review.id]);
|
|
2018
|
+
if (lastCouncilRun) {
|
|
2019
|
+
last_council_id = lastCouncilRun.model;
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2004
2022
|
res.json({
|
|
2005
|
-
custom_instructions: review.custom_instructions || null
|
|
2023
|
+
custom_instructions: review.custom_instructions || null,
|
|
2024
|
+
last_council_id
|
|
2006
2025
|
});
|
|
2007
2026
|
|
|
2008
2027
|
} catch (error) {
|
|
@@ -2072,7 +2091,10 @@ router.get('/api/local/:reviewId/analysis-runs', async (req, res) => {
|
|
|
2072
2091
|
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
2073
2092
|
const runs = await analysisRunRepo.getByReviewId(reviewId);
|
|
2074
2093
|
|
|
2075
|
-
res.json({ runs
|
|
2094
|
+
res.json({ runs: runs.map(r => ({
|
|
2095
|
+
...r,
|
|
2096
|
+
levels_config: r.levels_config ? JSON.parse(r.levels_config) : null
|
|
2097
|
+
})) });
|
|
2076
2098
|
} catch (error) {
|
|
2077
2099
|
logger.error('Error fetching analysis runs:', error);
|
|
2078
2100
|
res.status(500).json({ error: 'Failed to fetch analysis runs' });
|
package/src/routes/mcp.js
CHANGED
|
@@ -273,6 +273,8 @@ function createMCPServer(db, options = {}) {
|
|
|
273
273
|
...reviewLookupSchema,
|
|
274
274
|
limit: z.number().int().positive().optional()
|
|
275
275
|
.describe('Maximum number of runs to return (most recent first). Use limit=1 to poll for the latest run.'),
|
|
276
|
+
includeChildRuns: z.boolean().optional()
|
|
277
|
+
.describe('Include child reviewer runs from council analyses. Defaults to false (only top-level runs).'),
|
|
276
278
|
},
|
|
277
279
|
async (args) => {
|
|
278
280
|
const { review, error } = await resolveReview(args, db);
|
|
@@ -281,7 +283,12 @@ function createMCPServer(db, options = {}) {
|
|
|
281
283
|
}
|
|
282
284
|
|
|
283
285
|
const runRepo = new AnalysisRunRepository(db);
|
|
284
|
-
|
|
286
|
+
let runs = await runRepo.getByReviewId(review.id, { limit: args.limit });
|
|
287
|
+
|
|
288
|
+
// By default, exclude child runs (they're an internal implementation detail)
|
|
289
|
+
if (!args.includeChildRuns) {
|
|
290
|
+
runs = runs.filter(r => !r.parent_run_id);
|
|
291
|
+
}
|
|
285
292
|
|
|
286
293
|
return {
|
|
287
294
|
content: [{
|
|
@@ -368,7 +375,7 @@ function createMCPServer(db, options = {}) {
|
|
|
368
375
|
|
|
369
376
|
// Build parameterized WHERE conditions
|
|
370
377
|
const params = [runId];
|
|
371
|
-
const conditions = ["ai_run_id = ?", "source = 'ai'", 'ai_level IS NULL'];
|
|
378
|
+
const conditions = ["ai_run_id = ?", "source = 'ai'", 'ai_level IS NULL', '(is_raw = 0 OR is_raw IS NULL)'];
|
|
372
379
|
|
|
373
380
|
if (args.status) {
|
|
374
381
|
conditions.push('status = ?');
|
package/src/routes/setup.js
CHANGED
|
@@ -91,7 +91,9 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
91
91
|
return res.json({ setupId: existing.setupId });
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
// Check if we already have data for this PR in the database
|
|
94
|
+
// Check if we already have data AND a worktree for this PR in the database.
|
|
95
|
+
// When a user deletes a worktree, PR metadata is preserved but the worktree
|
|
96
|
+
// record is removed. We must re-run setup to recreate the worktree.
|
|
95
97
|
const repository = normalizeRepository(owner, repo);
|
|
96
98
|
const existingPR = await queryOne(
|
|
97
99
|
db,
|
|
@@ -99,7 +101,15 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
99
101
|
[prNumber, repository]
|
|
100
102
|
);
|
|
101
103
|
if (existingPR) {
|
|
102
|
-
|
|
104
|
+
const worktree = await queryOne(
|
|
105
|
+
db,
|
|
106
|
+
'SELECT id FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE',
|
|
107
|
+
[prNumber, repository]
|
|
108
|
+
);
|
|
109
|
+
if (worktree) {
|
|
110
|
+
return res.json({ existing: true, reviewUrl: `/pr/${owner}/${repo}/${prNumber}` });
|
|
111
|
+
}
|
|
112
|
+
logger.info(`PR metadata exists but worktree missing for ${repository} #${prNumber}, re-running setup`);
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
// Start the async setup
|