@in-the-loop-labs/pair-review 1.0.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/LICENSE +674 -0
- package/README.md +371 -0
- package/bin/git-diff-lines +146 -0
- package/bin/pair-review.js +49 -0
- package/package.json +71 -0
- package/public/css/ai-summary-modal.css +183 -0
- package/public/css/pr.css +8698 -0
- package/public/css/repo-settings.css +891 -0
- package/public/css/styles.css +479 -0
- package/public/favicon.png +0 -0
- package/public/index.html +1104 -0
- package/public/js/components/AIPanel.js +1639 -0
- package/public/js/components/AISummaryModal.js +278 -0
- package/public/js/components/AnalysisConfigModal.js +684 -0
- package/public/js/components/ConfirmDialog.js +227 -0
- package/public/js/components/PreviewModal.js +344 -0
- package/public/js/components/ProgressModal.js +678 -0
- package/public/js/components/ReviewModal.js +531 -0
- package/public/js/components/SplitButton.js +382 -0
- package/public/js/components/StatusIndicator.js +265 -0
- package/public/js/components/SuggestionNavigator.js +489 -0
- package/public/js/components/Toast.js +166 -0
- package/public/js/local.js +1580 -0
- package/public/js/modules/analysis-history.js +940 -0
- package/public/js/modules/comment-manager.js +643 -0
- package/public/js/modules/diff-renderer.js +585 -0
- package/public/js/modules/file-comment-manager.js +1242 -0
- package/public/js/modules/gap-coordinates.js +190 -0
- package/public/js/modules/hunk-parser.js +358 -0
- package/public/js/modules/line-tracker.js +386 -0
- package/public/js/modules/panel-resizer.js +228 -0
- package/public/js/modules/storage-cleanup.js +36 -0
- package/public/js/modules/suggestion-manager.js +692 -0
- package/public/js/pr.js +3503 -0
- package/public/js/repo-settings.js +691 -0
- package/public/js/utils/file-order.js +87 -0
- package/public/js/utils/markdown.js +97 -0
- package/public/js/utils/suggestion-ui.js +55 -0
- package/public/js/utils/tier-icons.js +25 -0
- package/public/local.html +460 -0
- package/public/pr.html +329 -0
- package/public/repo-settings.html +243 -0
- package/src/ai/analyzer.js +2592 -0
- package/src/ai/claude-cli.js +153 -0
- package/src/ai/claude-provider.js +261 -0
- package/src/ai/codex-provider.js +361 -0
- package/src/ai/copilot-provider.js +345 -0
- package/src/ai/gemini-provider.js +375 -0
- package/src/ai/index.js +47 -0
- package/src/ai/prompts/baseline/_meta.json +14 -0
- package/src/ai/prompts/baseline/level1/balanced.js +239 -0
- package/src/ai/prompts/baseline/level1/fast.js +194 -0
- package/src/ai/prompts/baseline/level1/thorough.js +319 -0
- package/src/ai/prompts/baseline/level2/balanced.js +248 -0
- package/src/ai/prompts/baseline/level2/fast.js +201 -0
- package/src/ai/prompts/baseline/level2/thorough.js +367 -0
- package/src/ai/prompts/baseline/level3/balanced.js +280 -0
- package/src/ai/prompts/baseline/level3/fast.js +220 -0
- package/src/ai/prompts/baseline/level3/thorough.js +459 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
- package/src/ai/prompts/config.js +52 -0
- package/src/ai/prompts/index.js +267 -0
- package/src/ai/prompts/shared/diff-instructions.js +50 -0
- package/src/ai/prompts/shared/output-schema.js +179 -0
- package/src/ai/prompts/shared/valid-files.js +37 -0
- package/src/ai/provider.js +260 -0
- package/src/config.js +139 -0
- package/src/database.js +2284 -0
- package/src/git/gitattributes.js +207 -0
- package/src/git/worktree.js +688 -0
- package/src/github/client.js +893 -0
- package/src/github/parser.js +247 -0
- package/src/local-review.js +691 -0
- package/src/main.js +987 -0
- package/src/routes/analysis.js +897 -0
- package/src/routes/comments.js +534 -0
- package/src/routes/config.js +250 -0
- package/src/routes/local.js +1728 -0
- package/src/routes/pr.js +1164 -0
- package/src/routes/shared.js +218 -0
- package/src/routes/worktrees.js +500 -0
- package/src/server.js +295 -0
- package/src/utils/diff-annotator.js +414 -0
- package/src/utils/instructions.js +33 -0
- package/src/utils/json-extractor.js +107 -0
- package/src/utils/line-validation.js +183 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/paths.js +161 -0
- package/src/utils/stats-calculator.js +86 -0
|
@@ -0,0 +1,1728 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Local Review Routes
|
|
4
|
+
*
|
|
5
|
+
* Handles all local review-related endpoints:
|
|
6
|
+
* - Get local review metadata
|
|
7
|
+
* - Get local diff
|
|
8
|
+
* - Trigger AI analysis (Level 1, 2, 3)
|
|
9
|
+
* - Get AI suggestions
|
|
10
|
+
* - User comment CRUD operations
|
|
11
|
+
*
|
|
12
|
+
* Note: No submit-review endpoint - GitHub submission is disabled in local mode.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const express = require('express');
|
|
16
|
+
const { query, queryOne, run, ReviewRepository, RepoSettingsRepository, CommentRepository, AnalysisRunRepository } = require('../database');
|
|
17
|
+
const Analyzer = require('../ai/analyzer');
|
|
18
|
+
const { v4: uuidv4 } = require('uuid');
|
|
19
|
+
const logger = require('../utils/logger');
|
|
20
|
+
const { mergeInstructions } = require('../utils/instructions');
|
|
21
|
+
const { calculateStats, getStatsQuery } = require('../utils/stats-calculator');
|
|
22
|
+
const { generateLocalDiff, computeLocalDiffDigest } = require('../local-review');
|
|
23
|
+
const {
|
|
24
|
+
activeAnalyses,
|
|
25
|
+
progressClients,
|
|
26
|
+
localReviewDiffs,
|
|
27
|
+
getModel,
|
|
28
|
+
determineCompletionInfo,
|
|
29
|
+
broadcastProgress,
|
|
30
|
+
CancellationError
|
|
31
|
+
} = require('./shared');
|
|
32
|
+
|
|
33
|
+
const router = express.Router();
|
|
34
|
+
|
|
35
|
+
// Store mapping of local review ID to analysis ID for tracking
|
|
36
|
+
const localReviewToAnalysisId = new Map();
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generate a consistent key for local review mapping
|
|
40
|
+
* @param {number} reviewId - Local review ID
|
|
41
|
+
* @returns {string} Review key
|
|
42
|
+
*/
|
|
43
|
+
function getLocalReviewKey(reviewId) {
|
|
44
|
+
return `local/${reviewId}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get local review metadata
|
|
49
|
+
*/
|
|
50
|
+
router.get('/api/local/:reviewId', async (req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
53
|
+
|
|
54
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
55
|
+
return res.status(400).json({
|
|
56
|
+
error: 'Invalid review ID'
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const db = req.app.get('db');
|
|
61
|
+
const reviewRepo = new ReviewRepository(db);
|
|
62
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
63
|
+
|
|
64
|
+
if (!review) {
|
|
65
|
+
return res.status(404).json({
|
|
66
|
+
error: `Local review #${reviewId} not found`
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// If the stored repository name doesn't look like owner/repo format,
|
|
71
|
+
// try to get a fresh one from git remote for display purposes only.
|
|
72
|
+
// Note: GET requests are read-only - no database writes here.
|
|
73
|
+
// Repository name updates happen during session creation or refresh.
|
|
74
|
+
let repositoryName = review.repository;
|
|
75
|
+
if (repositoryName && !repositoryName.includes('/') && review.local_path) {
|
|
76
|
+
try {
|
|
77
|
+
const { getRepositoryName } = require('../local-review');
|
|
78
|
+
const freshRepoName = await getRepositoryName(review.local_path);
|
|
79
|
+
if (freshRepoName && freshRepoName.includes('/')) {
|
|
80
|
+
repositoryName = freshRepoName;
|
|
81
|
+
// Just use the fresh name for this response - don't write to DB in GET
|
|
82
|
+
logger.log('API', `Using fresh repository name from git remote: ${freshRepoName}`, 'cyan');
|
|
83
|
+
}
|
|
84
|
+
} catch (repoError) {
|
|
85
|
+
// Keep the original name if we can't get a better one
|
|
86
|
+
logger.warn(`Could not refresh repository name: ${repoError.message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
res.json({
|
|
91
|
+
id: review.id,
|
|
92
|
+
localPath: review.local_path,
|
|
93
|
+
localHeadSha: review.local_head_sha,
|
|
94
|
+
repository: repositoryName,
|
|
95
|
+
branch: process.env.PAIR_REVIEW_BRANCH || 'unknown',
|
|
96
|
+
reviewType: 'local',
|
|
97
|
+
status: review.status,
|
|
98
|
+
createdAt: review.created_at,
|
|
99
|
+
updatedAt: review.updated_at
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error('Error fetching local review:', error);
|
|
104
|
+
res.status(500).json({
|
|
105
|
+
error: 'Failed to fetch local review'
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get local diff
|
|
112
|
+
*/
|
|
113
|
+
router.get('/api/local/:reviewId/diff', async (req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
116
|
+
|
|
117
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
118
|
+
return res.status(400).json({
|
|
119
|
+
error: 'Invalid review ID'
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Verify the review exists
|
|
124
|
+
const db = req.app.get('db');
|
|
125
|
+
const reviewRepo = new ReviewRepository(db);
|
|
126
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
127
|
+
|
|
128
|
+
if (!review) {
|
|
129
|
+
return res.status(404).json({
|
|
130
|
+
error: `Local review #${reviewId} not found`
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Get diff from module-level storage
|
|
135
|
+
const diffData = localReviewDiffs.get(reviewId) || { diff: '', stats: {} };
|
|
136
|
+
const { diff: diffContent, stats } = diffData;
|
|
137
|
+
|
|
138
|
+
res.json({
|
|
139
|
+
diff: diffContent || '',
|
|
140
|
+
stats: {
|
|
141
|
+
trackedChanges: stats?.trackedChanges || 0,
|
|
142
|
+
untrackedFiles: stats?.untrackedFiles || 0,
|
|
143
|
+
stagedChanges: stats?.stagedChanges || 0,
|
|
144
|
+
unstagedChanges: stats?.unstagedChanges || 0
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error('Error fetching local diff:', error);
|
|
150
|
+
res.status(500).json({
|
|
151
|
+
error: 'Failed to fetch local diff'
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check if local review diff is stale (working directory has changed since diff was captured)
|
|
158
|
+
* Uses a digest of the diff content for accurate change detection
|
|
159
|
+
*/
|
|
160
|
+
router.get('/api/local/:reviewId/check-stale', async (req, res) => {
|
|
161
|
+
try {
|
|
162
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
163
|
+
|
|
164
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
165
|
+
return res.status(400).json({
|
|
166
|
+
error: 'Invalid review ID'
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const db = req.app.get('db');
|
|
171
|
+
const reviewRepo = new ReviewRepository(db);
|
|
172
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
173
|
+
|
|
174
|
+
if (!review) {
|
|
175
|
+
return res.json({
|
|
176
|
+
isStale: null,
|
|
177
|
+
error: 'Local review not found'
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const localPath = review.local_path;
|
|
182
|
+
if (!localPath) {
|
|
183
|
+
return res.json({
|
|
184
|
+
isStale: null,
|
|
185
|
+
error: 'Local review missing path'
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Get stored diff data
|
|
190
|
+
const storedDiffData = localReviewDiffs.get(reviewId);
|
|
191
|
+
if (!storedDiffData) {
|
|
192
|
+
return res.json({
|
|
193
|
+
isStale: null,
|
|
194
|
+
error: 'No stored diff data found'
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Check if baseline digest exists (must be computed at diff-capture time)
|
|
199
|
+
if (!storedDiffData.digest) {
|
|
200
|
+
// No baseline digest - session may predate staleness detection feature
|
|
201
|
+
// Assume stale to be safe and prompt user to refresh
|
|
202
|
+
return res.json({
|
|
203
|
+
isStale: true,
|
|
204
|
+
error: 'No baseline digest - please refresh to enable staleness detection'
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Compute current digest to compare against baseline
|
|
209
|
+
const currentDigest = await computeLocalDiffDigest(localPath);
|
|
210
|
+
|
|
211
|
+
// If current digest computation failed, assume stale to be safe
|
|
212
|
+
if (!currentDigest) {
|
|
213
|
+
return res.json({
|
|
214
|
+
isStale: true,
|
|
215
|
+
error: 'Could not compute current digest - refresh recommended'
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const isStale = storedDiffData.digest !== currentDigest;
|
|
220
|
+
|
|
221
|
+
res.json({
|
|
222
|
+
isStale,
|
|
223
|
+
storedDigest: storedDiffData.digest,
|
|
224
|
+
currentDigest
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
} catch (error) {
|
|
228
|
+
logger.warn(`Error checking local review staleness: ${error.message}`);
|
|
229
|
+
res.json({
|
|
230
|
+
isStale: null,
|
|
231
|
+
error: error.message
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Start Level 1 AI analysis for local review
|
|
238
|
+
*/
|
|
239
|
+
router.post('/api/local/:reviewId/analyze', async (req, res) => {
|
|
240
|
+
try {
|
|
241
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
242
|
+
|
|
243
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
244
|
+
return res.status(400).json({
|
|
245
|
+
error: 'Invalid review ID'
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Extract optional provider, model, tier and customInstructions from request body
|
|
250
|
+
const { provider: requestProvider, model: requestModel, tier: requestTier, customInstructions: rawInstructions } = req.body || {};
|
|
251
|
+
|
|
252
|
+
// Trim and validate custom instructions
|
|
253
|
+
const MAX_INSTRUCTIONS_LENGTH = 5000;
|
|
254
|
+
let requestInstructions = rawInstructions?.trim() || null;
|
|
255
|
+
if (requestInstructions && requestInstructions.length > MAX_INSTRUCTIONS_LENGTH) {
|
|
256
|
+
return res.status(400).json({
|
|
257
|
+
error: `Custom instructions exceed maximum length of ${MAX_INSTRUCTIONS_LENGTH} characters`
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const db = req.app.get('db');
|
|
262
|
+
const reviewRepo = new ReviewRepository(db);
|
|
263
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
264
|
+
|
|
265
|
+
if (!review) {
|
|
266
|
+
return res.status(404).json({
|
|
267
|
+
error: `Local review #${reviewId} not found`
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const localPath = review.local_path;
|
|
272
|
+
const repository = review.repository;
|
|
273
|
+
|
|
274
|
+
// Fetch repo settings for default instructions
|
|
275
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
276
|
+
const repoSettings = repository ? await repoSettingsRepo.getRepoSettings(repository) : null;
|
|
277
|
+
|
|
278
|
+
// Determine provider: request body > repo settings > config > default ('claude')
|
|
279
|
+
let selectedProvider;
|
|
280
|
+
if (requestProvider) {
|
|
281
|
+
selectedProvider = requestProvider;
|
|
282
|
+
} else if (repoSettings && repoSettings.default_provider) {
|
|
283
|
+
selectedProvider = repoSettings.default_provider;
|
|
284
|
+
} else {
|
|
285
|
+
const config = req.app.get('config') || {};
|
|
286
|
+
selectedProvider = config.provider || 'claude';
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Determine model: request body > repo settings > config/CLI > default
|
|
290
|
+
let selectedModel;
|
|
291
|
+
if (requestModel) {
|
|
292
|
+
selectedModel = requestModel;
|
|
293
|
+
} else if (repoSettings && repoSettings.default_model) {
|
|
294
|
+
selectedModel = repoSettings.default_model;
|
|
295
|
+
} else {
|
|
296
|
+
selectedModel = getModel(req);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Get repo instructions from settings
|
|
300
|
+
const repoInstructions = repoSettings?.default_instructions || null;
|
|
301
|
+
// Merge for logging purposes (analyzer will also merge internally)
|
|
302
|
+
const combinedInstructions = mergeInstructions(repoInstructions, requestInstructions);
|
|
303
|
+
|
|
304
|
+
// Save custom instructions to the review record
|
|
305
|
+
// Only update when requestInstructions has a value - updateReview would accept
|
|
306
|
+
// null/undefined but we only want to persist actual user-provided instructions
|
|
307
|
+
if (requestInstructions) {
|
|
308
|
+
await reviewRepo.updateReview(reviewId, {
|
|
309
|
+
customInstructions: requestInstructions
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Create analysis ID
|
|
314
|
+
const analysisId = uuidv4();
|
|
315
|
+
|
|
316
|
+
// Store analysis status with separate tracking for each level
|
|
317
|
+
const initialStatus = {
|
|
318
|
+
id: analysisId,
|
|
319
|
+
reviewId,
|
|
320
|
+
repository: repository,
|
|
321
|
+
reviewType: 'local',
|
|
322
|
+
status: 'running',
|
|
323
|
+
startedAt: new Date().toISOString(),
|
|
324
|
+
progress: 'Starting analysis...',
|
|
325
|
+
levels: {
|
|
326
|
+
1: { status: 'running', progress: 'Starting...' },
|
|
327
|
+
2: { status: 'running', progress: 'Starting...' },
|
|
328
|
+
3: { status: 'running', progress: 'Starting...' },
|
|
329
|
+
4: { status: 'pending', progress: 'Pending' }
|
|
330
|
+
},
|
|
331
|
+
filesAnalyzed: 0,
|
|
332
|
+
filesRemaining: 0
|
|
333
|
+
};
|
|
334
|
+
activeAnalyses.set(analysisId, initialStatus);
|
|
335
|
+
|
|
336
|
+
// Store local review to analysis ID mapping
|
|
337
|
+
const reviewKey = getLocalReviewKey(reviewId);
|
|
338
|
+
localReviewToAnalysisId.set(reviewKey, analysisId);
|
|
339
|
+
|
|
340
|
+
// Broadcast initial status
|
|
341
|
+
broadcastProgress(analysisId, initialStatus);
|
|
342
|
+
|
|
343
|
+
// Create analyzer instance with provider and model
|
|
344
|
+
const analyzer = new Analyzer(db, selectedModel, selectedProvider);
|
|
345
|
+
|
|
346
|
+
// Build local review metadata for the analyzer
|
|
347
|
+
// The analyzer uses base_sha and head_sha for git diff commands
|
|
348
|
+
// For local review, we use HEAD as both since we're diffing working directory
|
|
349
|
+
const localMetadata = {
|
|
350
|
+
id: reviewId,
|
|
351
|
+
repository: review.repository, // Include repository for context display
|
|
352
|
+
title: `Local changes in ${repository}`,
|
|
353
|
+
description: `Reviewing uncommitted changes in ${localPath}`,
|
|
354
|
+
base_sha: review.local_head_sha, // HEAD commit
|
|
355
|
+
head_sha: review.local_head_sha, // HEAD commit (diff is against working directory)
|
|
356
|
+
reviewType: 'local'
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// Get changed files for local mode path validation
|
|
360
|
+
// This is critical for local mode since git diff HEAD...HEAD returns nothing
|
|
361
|
+
const changedFiles = await analyzer.getLocalChangedFiles(localPath);
|
|
362
|
+
|
|
363
|
+
// Log analysis start
|
|
364
|
+
logger.section(`Local AI Analysis Request - Review #${reviewId}`);
|
|
365
|
+
logger.log('API', `Repository: ${repository}`, 'magenta');
|
|
366
|
+
logger.log('API', `Local path: ${localPath}`, 'magenta');
|
|
367
|
+
logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
|
|
368
|
+
logger.log('API', `Provider: ${selectedProvider}`, 'cyan');
|
|
369
|
+
logger.log('API', `Model: ${selectedModel}`, 'cyan');
|
|
370
|
+
// Determine tier: request body > default ('balanced')
|
|
371
|
+
const tier = requestTier || 'balanced';
|
|
372
|
+
logger.log('API', `Tier: ${tier}`, 'cyan');
|
|
373
|
+
logger.log('API', `Changed files: ${changedFiles.length}`, 'cyan');
|
|
374
|
+
if (combinedInstructions) {
|
|
375
|
+
logger.log('API', `Custom instructions: ${combinedInstructions.length} chars`, 'cyan');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Create progress callback function that tracks each level separately
|
|
379
|
+
const progressCallback = (progressUpdate) => {
|
|
380
|
+
const currentStatus = activeAnalyses.get(analysisId);
|
|
381
|
+
if (!currentStatus) return;
|
|
382
|
+
|
|
383
|
+
const level = progressUpdate.level;
|
|
384
|
+
|
|
385
|
+
// Update the specific level's status
|
|
386
|
+
if (level && level >= 1 && level <= 3) {
|
|
387
|
+
currentStatus.levels[level] = {
|
|
388
|
+
status: progressUpdate.status || 'running',
|
|
389
|
+
progress: progressUpdate.progress || 'In progress...'
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Handle orchestration as level 4
|
|
394
|
+
if (level === 'orchestration') {
|
|
395
|
+
currentStatus.levels[4] = {
|
|
396
|
+
status: progressUpdate.status || 'running',
|
|
397
|
+
progress: progressUpdate.progress || 'Finalizing results...'
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Update overall progress message if provided
|
|
402
|
+
if (progressUpdate.progress && !level) {
|
|
403
|
+
currentStatus.progress = progressUpdate.progress;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
activeAnalyses.set(analysisId, currentStatus);
|
|
407
|
+
broadcastProgress(analysisId, currentStatus);
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// Start analysis asynchronously (pass changedFiles for local mode path validation)
|
|
411
|
+
// Pass analysisId for process tracking/cancellation
|
|
412
|
+
// Pass separate instructions for storage, analyzer will merge them for prompts
|
|
413
|
+
// Pass tier for prompt selection
|
|
414
|
+
analyzer.analyzeLevel1(reviewId, localPath, localMetadata, progressCallback, { repoInstructions, requestInstructions }, changedFiles, { analysisId, tier })
|
|
415
|
+
.then(async result => {
|
|
416
|
+
logger.section('Local Analysis Results');
|
|
417
|
+
logger.success(`Analysis complete for local review #${reviewId}`);
|
|
418
|
+
logger.success(`Found ${result.suggestions.length} suggestions`);
|
|
419
|
+
|
|
420
|
+
// Save summary to review record (reuse reviewRepo from handler start)
|
|
421
|
+
if (result.summary) {
|
|
422
|
+
try {
|
|
423
|
+
await reviewRepo.updateSummary(reviewId, result.summary);
|
|
424
|
+
logger.info(`Saved analysis summary to review record`);
|
|
425
|
+
logger.section('Analysis Summary');
|
|
426
|
+
logger.info(result.summary);
|
|
427
|
+
} catch (summaryError) {
|
|
428
|
+
logger.warn(`Failed to save analysis summary: ${summaryError.message}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Determine completion status
|
|
433
|
+
const completionInfo = determineCompletionInfo(result);
|
|
434
|
+
|
|
435
|
+
const currentStatus = activeAnalyses.get(analysisId);
|
|
436
|
+
if (!currentStatus) {
|
|
437
|
+
console.warn('Analysis already completed or removed:', analysisId);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Mark all completed levels as completed
|
|
442
|
+
for (let i = 1; i <= completionInfo.completedLevel; i++) {
|
|
443
|
+
currentStatus.levels[i] = {
|
|
444
|
+
status: 'completed',
|
|
445
|
+
progress: `Level ${i} complete`
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Mark orchestration (level 4) as completed
|
|
450
|
+
currentStatus.levels[4] = {
|
|
451
|
+
status: 'completed',
|
|
452
|
+
progress: 'Results finalized'
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const completedStatus = {
|
|
456
|
+
...currentStatus,
|
|
457
|
+
status: 'completed',
|
|
458
|
+
level: completionInfo.completedLevel,
|
|
459
|
+
completedLevel: completionInfo.completedLevel,
|
|
460
|
+
completedAt: new Date().toISOString(),
|
|
461
|
+
result,
|
|
462
|
+
progress: completionInfo.progressMessage,
|
|
463
|
+
suggestionsCount: completionInfo.totalSuggestions,
|
|
464
|
+
filesAnalyzed: currentStatus?.filesAnalyzed || 0,
|
|
465
|
+
filesRemaining: 0
|
|
466
|
+
};
|
|
467
|
+
activeAnalyses.set(analysisId, completedStatus);
|
|
468
|
+
|
|
469
|
+
// Broadcast completion status
|
|
470
|
+
broadcastProgress(analysisId, completedStatus);
|
|
471
|
+
})
|
|
472
|
+
.catch(error => {
|
|
473
|
+
const currentStatus = activeAnalyses.get(analysisId);
|
|
474
|
+
if (!currentStatus) {
|
|
475
|
+
console.warn('Analysis status not found during error handling:', analysisId);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Handle cancellation gracefully - don't log as error
|
|
480
|
+
if (error.isCancellation) {
|
|
481
|
+
logger.info(`Local analysis cancelled for review #${reviewId}`);
|
|
482
|
+
// Status is already set to 'cancelled' by the cancel endpoint
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
logger.error(`Local analysis failed for review #${reviewId}: ${error.message}`);
|
|
487
|
+
|
|
488
|
+
// Mark all levels as failed
|
|
489
|
+
for (let i = 1; i <= 4; i++) {
|
|
490
|
+
currentStatus.levels[i] = {
|
|
491
|
+
status: 'failed',
|
|
492
|
+
progress: 'Failed'
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const failedStatus = {
|
|
497
|
+
...currentStatus,
|
|
498
|
+
status: 'failed',
|
|
499
|
+
level: 1,
|
|
500
|
+
completedAt: new Date().toISOString(),
|
|
501
|
+
error: error.message,
|
|
502
|
+
progress: 'Analysis failed'
|
|
503
|
+
};
|
|
504
|
+
activeAnalyses.set(analysisId, failedStatus);
|
|
505
|
+
|
|
506
|
+
// Broadcast failure status
|
|
507
|
+
broadcastProgress(analysisId, failedStatus);
|
|
508
|
+
})
|
|
509
|
+
.finally(() => {
|
|
510
|
+
// Clean up local review to analysis ID mapping
|
|
511
|
+
const reviewKey = getLocalReviewKey(reviewId);
|
|
512
|
+
localReviewToAnalysisId.delete(reviewKey);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// Return analysis ID immediately
|
|
516
|
+
res.json({
|
|
517
|
+
analysisId,
|
|
518
|
+
status: 'started',
|
|
519
|
+
message: 'AI analysis started in background'
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
} catch (error) {
|
|
523
|
+
console.error('Error starting local AI analysis:', error);
|
|
524
|
+
res.status(500).json({
|
|
525
|
+
error: 'Failed to start AI analysis'
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Get AI suggestions for a local review
|
|
532
|
+
*/
|
|
533
|
+
router.get('/api/local/:reviewId/suggestions', async (req, res) => {
|
|
534
|
+
try {
|
|
535
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
536
|
+
|
|
537
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
538
|
+
return res.status(400).json({
|
|
539
|
+
error: 'Invalid review ID'
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const db = req.app.get('db');
|
|
544
|
+
|
|
545
|
+
// Verify review exists
|
|
546
|
+
const reviewRepo = new ReviewRepository(db);
|
|
547
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
548
|
+
|
|
549
|
+
if (!review) {
|
|
550
|
+
return res.status(404).json({
|
|
551
|
+
error: `Local review #${reviewId} not found`
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Parse levels query parameter (e.g., ?levels=final,1,2)
|
|
556
|
+
// Default to 'final' (orchestrated suggestions only) if not specified
|
|
557
|
+
const levelsParam = req.query.levels || 'final';
|
|
558
|
+
const requestedLevels = levelsParam.split(',').map(l => l.trim());
|
|
559
|
+
|
|
560
|
+
// Parse optional runId query parameter to fetch suggestions from a specific analysis run
|
|
561
|
+
// If not provided, defaults to the latest run
|
|
562
|
+
const runIdParam = req.query.runId;
|
|
563
|
+
|
|
564
|
+
// Build level filter clause
|
|
565
|
+
const levelConditions = [];
|
|
566
|
+
requestedLevels.forEach(level => {
|
|
567
|
+
if (level === 'final') {
|
|
568
|
+
levelConditions.push('ai_level IS NULL');
|
|
569
|
+
} else if (['1', '2', '3'].includes(level)) {
|
|
570
|
+
levelConditions.push(`ai_level = ${parseInt(level)}`);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// If no valid levels specified, default to final
|
|
575
|
+
const levelFilter = levelConditions.length > 0
|
|
576
|
+
? `(${levelConditions.join(' OR ')})`
|
|
577
|
+
: 'ai_level IS NULL';
|
|
578
|
+
|
|
579
|
+
// Build the run ID filter clause
|
|
580
|
+
// If a specific runId is provided, use it directly; otherwise use subquery for latest
|
|
581
|
+
let runIdFilter;
|
|
582
|
+
let queryParams;
|
|
583
|
+
if (runIdParam) {
|
|
584
|
+
runIdFilter = 'ai_run_id = ?';
|
|
585
|
+
queryParams = [reviewId, runIdParam];
|
|
586
|
+
} else {
|
|
587
|
+
// Get AI suggestions from the comments table
|
|
588
|
+
// For local reviews, review_id stores the review ID
|
|
589
|
+
// Only return suggestions from the latest analysis run (ai_run_id)
|
|
590
|
+
// This preserves history while showing only the most recent results
|
|
591
|
+
//
|
|
592
|
+
// Note: If no AI suggestions exist (subquery returns NULL), the ai_run_id = NULL
|
|
593
|
+
// comparison returns no rows. This is intentional - we only show suggestions
|
|
594
|
+
// when there's a matching analysis run.
|
|
595
|
+
//
|
|
596
|
+
// Note: reviewId is passed twice because SQLite requires separate parameters
|
|
597
|
+
// for the outer WHERE clause and the subquery. A CTE could consolidate this but
|
|
598
|
+
// adds complexity without meaningful benefit here.
|
|
599
|
+
runIdFilter = `ai_run_id = (
|
|
600
|
+
SELECT ai_run_id FROM comments
|
|
601
|
+
WHERE review_id = ? AND source = 'ai' AND ai_run_id IS NOT NULL
|
|
602
|
+
ORDER BY created_at DESC
|
|
603
|
+
LIMIT 1
|
|
604
|
+
)`;
|
|
605
|
+
queryParams = [reviewId, reviewId];
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const suggestions = await query(db, `
|
|
609
|
+
SELECT
|
|
610
|
+
id,
|
|
611
|
+
source,
|
|
612
|
+
author,
|
|
613
|
+
ai_run_id,
|
|
614
|
+
ai_level,
|
|
615
|
+
ai_confidence,
|
|
616
|
+
file,
|
|
617
|
+
line_start,
|
|
618
|
+
line_end,
|
|
619
|
+
side,
|
|
620
|
+
type,
|
|
621
|
+
title,
|
|
622
|
+
body,
|
|
623
|
+
status,
|
|
624
|
+
is_file_level,
|
|
625
|
+
created_at,
|
|
626
|
+
updated_at
|
|
627
|
+
FROM comments
|
|
628
|
+
WHERE review_id = ?
|
|
629
|
+
AND source = 'ai'
|
|
630
|
+
AND ${levelFilter}
|
|
631
|
+
AND status IN ('active', 'dismissed', 'adopted')
|
|
632
|
+
AND ${runIdFilter}
|
|
633
|
+
ORDER BY
|
|
634
|
+
CASE
|
|
635
|
+
WHEN ai_level IS NULL THEN 0
|
|
636
|
+
WHEN ai_level = 1 THEN 1
|
|
637
|
+
WHEN ai_level = 2 THEN 2
|
|
638
|
+
WHEN ai_level = 3 THEN 3
|
|
639
|
+
ELSE 4
|
|
640
|
+
END,
|
|
641
|
+
is_file_level DESC,
|
|
642
|
+
file,
|
|
643
|
+
line_start
|
|
644
|
+
`, queryParams);
|
|
645
|
+
|
|
646
|
+
res.json({ suggestions });
|
|
647
|
+
|
|
648
|
+
} catch (error) {
|
|
649
|
+
console.error('Error fetching local review suggestions:', error);
|
|
650
|
+
res.status(500).json({
|
|
651
|
+
error: 'Failed to fetch AI suggestions'
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Get user comments for a local review
|
|
658
|
+
* Uses CommentRepository.getUserComments() for consistency with PR mode
|
|
659
|
+
*/
|
|
660
|
+
router.get('/api/local/:reviewId/user-comments', async (req, res) => {
|
|
661
|
+
try {
|
|
662
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
663
|
+
|
|
664
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
665
|
+
return res.status(400).json({
|
|
666
|
+
error: 'Invalid review ID'
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const db = req.app.get('db');
|
|
671
|
+
|
|
672
|
+
// Verify review exists
|
|
673
|
+
const reviewRepo = new ReviewRepository(db);
|
|
674
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
675
|
+
|
|
676
|
+
if (!review) {
|
|
677
|
+
return res.json({
|
|
678
|
+
success: true,
|
|
679
|
+
comments: []
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Use CommentRepository for consistency with PR mode
|
|
684
|
+
// This ensures both modes use the same query logic and include the same columns
|
|
685
|
+
const commentRepo = new CommentRepository(db);
|
|
686
|
+
const { includeDismissed } = req.query;
|
|
687
|
+
const comments = await commentRepo.getUserComments(reviewId, {
|
|
688
|
+
includeDismissed: includeDismissed === 'true'
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
res.json({
|
|
692
|
+
success: true,
|
|
693
|
+
comments: comments || []
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
} catch (error) {
|
|
697
|
+
console.error('Error fetching local review user comments:', error);
|
|
698
|
+
res.status(500).json({
|
|
699
|
+
error: 'Failed to fetch user comments'
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Add user comment to a local review
|
|
706
|
+
*/
|
|
707
|
+
router.post('/api/local/:reviewId/user-comments', async (req, res) => {
|
|
708
|
+
try {
|
|
709
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
710
|
+
|
|
711
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
712
|
+
return res.status(400).json({
|
|
713
|
+
error: 'Invalid review ID'
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const { file, line_start, line_end, diff_position, side, body, parent_id, type, title } = req.body;
|
|
718
|
+
|
|
719
|
+
if (!file || !line_start || !body) {
|
|
720
|
+
return res.status(400).json({
|
|
721
|
+
error: 'Missing required fields: file, line_start, body'
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const db = req.app.get('db');
|
|
726
|
+
|
|
727
|
+
// Verify review exists
|
|
728
|
+
const reviewRepo = new ReviewRepository(db);
|
|
729
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
730
|
+
|
|
731
|
+
if (!review) {
|
|
732
|
+
return res.status(404).json({
|
|
733
|
+
error: 'Local review not found'
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Create line-level comment using repository
|
|
738
|
+
const commentRepo = new CommentRepository(db);
|
|
739
|
+
const commentId = await commentRepo.createLineComment({
|
|
740
|
+
review_id: reviewId,
|
|
741
|
+
file,
|
|
742
|
+
line_start,
|
|
743
|
+
line_end,
|
|
744
|
+
diff_position,
|
|
745
|
+
side,
|
|
746
|
+
body,
|
|
747
|
+
parent_id,
|
|
748
|
+
type,
|
|
749
|
+
title
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
res.json({
|
|
753
|
+
success: true,
|
|
754
|
+
commentId,
|
|
755
|
+
message: 'Comment saved successfully'
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
} catch (error) {
|
|
759
|
+
console.error('Error creating local review user comment:', error);
|
|
760
|
+
res.status(500).json({
|
|
761
|
+
error: error.message || 'Failed to create comment'
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Create file-level user comment for a local review
|
|
768
|
+
* File-level comments are about an entire file, not tied to specific lines
|
|
769
|
+
*/
|
|
770
|
+
router.post('/api/local/:reviewId/file-comment', async (req, res) => {
|
|
771
|
+
try {
|
|
772
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
773
|
+
|
|
774
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
775
|
+
return res.status(400).json({
|
|
776
|
+
error: 'Invalid review ID'
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const { file, body, parent_id, type, title } = req.body;
|
|
781
|
+
|
|
782
|
+
if (!file || !body) {
|
|
783
|
+
return res.status(400).json({
|
|
784
|
+
error: 'Missing required fields: file, body'
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Validate body is not just whitespace
|
|
789
|
+
const trimmedBody = body.trim();
|
|
790
|
+
if (trimmedBody.length === 0) {
|
|
791
|
+
return res.status(400).json({
|
|
792
|
+
error: 'Comment body cannot be empty or whitespace only'
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const db = req.app.get('db');
|
|
797
|
+
|
|
798
|
+
// Verify review exists
|
|
799
|
+
const reviewRepo = new ReviewRepository(db);
|
|
800
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
801
|
+
|
|
802
|
+
if (!review) {
|
|
803
|
+
return res.status(404).json({
|
|
804
|
+
error: 'Local review not found'
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Create file-level comment using repository
|
|
809
|
+
const commentRepo = new CommentRepository(db);
|
|
810
|
+
const commentId = await commentRepo.createFileComment({
|
|
811
|
+
review_id: reviewId,
|
|
812
|
+
file,
|
|
813
|
+
body: trimmedBody,
|
|
814
|
+
type,
|
|
815
|
+
title,
|
|
816
|
+
parent_id
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
res.json({
|
|
820
|
+
success: true,
|
|
821
|
+
commentId,
|
|
822
|
+
message: 'File-level comment saved successfully'
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
} catch (error) {
|
|
826
|
+
console.error('Error creating file-level comment:', error);
|
|
827
|
+
res.status(500).json({
|
|
828
|
+
error: error.message || 'Failed to create file-level comment'
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Update file-level comment in a local review
|
|
835
|
+
*/
|
|
836
|
+
router.put('/api/local/:reviewId/file-comment/:commentId', async (req, res) => {
|
|
837
|
+
try {
|
|
838
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
839
|
+
const commentId = parseInt(req.params.commentId);
|
|
840
|
+
|
|
841
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
842
|
+
return res.status(400).json({
|
|
843
|
+
error: 'Invalid review ID'
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
if (isNaN(commentId) || commentId <= 0) {
|
|
848
|
+
return res.status(400).json({
|
|
849
|
+
error: 'Invalid comment ID'
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const { body } = req.body;
|
|
854
|
+
|
|
855
|
+
if (!body || !body.trim()) {
|
|
856
|
+
return res.status(400).json({
|
|
857
|
+
error: 'Comment body cannot be empty'
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const db = req.app.get('db');
|
|
862
|
+
|
|
863
|
+
// Verify the comment exists, belongs to this review, and is a file-level comment
|
|
864
|
+
const comment = await queryOne(db, `
|
|
865
|
+
SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user' AND is_file_level = 1
|
|
866
|
+
`, [commentId, reviewId]);
|
|
867
|
+
|
|
868
|
+
if (!comment) {
|
|
869
|
+
return res.status(404).json({
|
|
870
|
+
error: 'File-level comment not found'
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Update comment
|
|
875
|
+
await run(db, `
|
|
876
|
+
UPDATE comments
|
|
877
|
+
SET body = ?, updated_at = CURRENT_TIMESTAMP
|
|
878
|
+
WHERE id = ?
|
|
879
|
+
`, [body.trim(), commentId]);
|
|
880
|
+
|
|
881
|
+
res.json({
|
|
882
|
+
success: true,
|
|
883
|
+
message: 'File-level comment updated successfully'
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
} catch (error) {
|
|
887
|
+
console.error('Error updating file-level comment:', error);
|
|
888
|
+
res.status(500).json({
|
|
889
|
+
error: 'Failed to update comment'
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Delete file-level comment from a local review
|
|
896
|
+
*/
|
|
897
|
+
router.delete('/api/local/:reviewId/file-comment/:commentId', async (req, res) => {
|
|
898
|
+
try {
|
|
899
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
900
|
+
const commentId = parseInt(req.params.commentId);
|
|
901
|
+
|
|
902
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
903
|
+
return res.status(400).json({
|
|
904
|
+
error: 'Invalid review ID'
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (isNaN(commentId) || commentId <= 0) {
|
|
909
|
+
return res.status(400).json({
|
|
910
|
+
error: 'Invalid comment ID'
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const db = req.app.get('db');
|
|
915
|
+
|
|
916
|
+
// Verify the comment exists, belongs to this review, and is a file-level comment
|
|
917
|
+
const comment = await queryOne(db, `
|
|
918
|
+
SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user' AND is_file_level = 1
|
|
919
|
+
`, [commentId, reviewId]);
|
|
920
|
+
|
|
921
|
+
if (!comment) {
|
|
922
|
+
return res.status(404).json({
|
|
923
|
+
error: 'File-level comment not found'
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Use CommentRepository to delete (also dismisses parent AI suggestion if applicable)
|
|
928
|
+
const commentRepo = new CommentRepository(db);
|
|
929
|
+
const result = await commentRepo.deleteComment(commentId);
|
|
930
|
+
|
|
931
|
+
res.json({
|
|
932
|
+
success: true,
|
|
933
|
+
message: 'File-level comment deleted successfully',
|
|
934
|
+
dismissedSuggestionId: result.dismissedSuggestionId
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
} catch (error) {
|
|
938
|
+
console.error('Error deleting file-level comment:', error);
|
|
939
|
+
res.status(500).json({
|
|
940
|
+
error: 'Failed to delete comment'
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* Update AI suggestion status for a local review
|
|
947
|
+
* Sets status to 'adopted', 'dismissed', or 'active' (restored)
|
|
948
|
+
*/
|
|
949
|
+
router.post('/api/local/:reviewId/ai-suggestion/:suggestionId/status', async (req, res) => {
|
|
950
|
+
try {
|
|
951
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
952
|
+
const suggestionId = parseInt(req.params.suggestionId);
|
|
953
|
+
const { status } = req.body;
|
|
954
|
+
|
|
955
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
956
|
+
return res.status(400).json({
|
|
957
|
+
error: 'Invalid review ID'
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (isNaN(suggestionId) || suggestionId <= 0) {
|
|
962
|
+
return res.status(400).json({
|
|
963
|
+
error: 'Invalid suggestion ID'
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (!['adopted', 'dismissed', 'active'].includes(status)) {
|
|
968
|
+
return res.status(400).json({
|
|
969
|
+
error: 'Invalid status. Must be "adopted", "dismissed", or "active"'
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const db = req.app.get('db');
|
|
974
|
+
const commentRepo = new CommentRepository(db);
|
|
975
|
+
|
|
976
|
+
// Get the suggestion and verify it belongs to this review
|
|
977
|
+
const suggestion = await commentRepo.getComment(suggestionId, 'ai');
|
|
978
|
+
|
|
979
|
+
if (!suggestion) {
|
|
980
|
+
return res.status(404).json({
|
|
981
|
+
error: 'AI suggestion not found'
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
if (suggestion.review_id !== reviewId) {
|
|
986
|
+
return res.status(403).json({
|
|
987
|
+
error: 'Suggestion does not belong to this review'
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// Update suggestion status using repository
|
|
992
|
+
await commentRepo.updateSuggestionStatus(suggestionId, status);
|
|
993
|
+
|
|
994
|
+
res.json({
|
|
995
|
+
success: true,
|
|
996
|
+
status
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
} catch (error) {
|
|
1000
|
+
console.error('Error updating AI suggestion status:', error);
|
|
1001
|
+
res.status(500).json({
|
|
1002
|
+
error: error.message || 'Failed to update suggestion status'
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Get a single user comment from a local review
|
|
1009
|
+
*/
|
|
1010
|
+
router.get('/api/local/:reviewId/user-comments/:commentId', async (req, res) => {
|
|
1011
|
+
try {
|
|
1012
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1013
|
+
const commentId = parseInt(req.params.commentId);
|
|
1014
|
+
|
|
1015
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1016
|
+
return res.status(400).json({
|
|
1017
|
+
error: 'Invalid review ID'
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
if (isNaN(commentId) || commentId <= 0) {
|
|
1022
|
+
return res.status(400).json({
|
|
1023
|
+
error: 'Invalid comment ID'
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
const db = req.app.get('db');
|
|
1028
|
+
|
|
1029
|
+
// Get the comment and verify it belongs to this review
|
|
1030
|
+
const comment = await queryOne(db, `
|
|
1031
|
+
SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
|
|
1032
|
+
`, [commentId, reviewId]);
|
|
1033
|
+
|
|
1034
|
+
if (!comment) {
|
|
1035
|
+
return res.status(404).json({
|
|
1036
|
+
error: 'User comment not found'
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
res.json({
|
|
1041
|
+
id: comment.id,
|
|
1042
|
+
file: comment.file,
|
|
1043
|
+
line_start: comment.line_start,
|
|
1044
|
+
line_end: comment.line_end,
|
|
1045
|
+
body: comment.body,
|
|
1046
|
+
type: comment.type,
|
|
1047
|
+
title: comment.title,
|
|
1048
|
+
status: comment.status,
|
|
1049
|
+
created_at: comment.created_at,
|
|
1050
|
+
updated_at: comment.updated_at
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
console.error('Error fetching local review user comment:', error);
|
|
1055
|
+
res.status(500).json({
|
|
1056
|
+
error: 'Failed to fetch comment'
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Update user comment in a local review
|
|
1063
|
+
*/
|
|
1064
|
+
router.put('/api/local/:reviewId/user-comments/:commentId', async (req, res) => {
|
|
1065
|
+
try {
|
|
1066
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1067
|
+
const commentId = parseInt(req.params.commentId);
|
|
1068
|
+
|
|
1069
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1070
|
+
return res.status(400).json({
|
|
1071
|
+
error: 'Invalid review ID'
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (isNaN(commentId) || commentId <= 0) {
|
|
1076
|
+
return res.status(400).json({
|
|
1077
|
+
error: 'Invalid comment ID'
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
const { body } = req.body;
|
|
1082
|
+
|
|
1083
|
+
if (!body || !body.trim()) {
|
|
1084
|
+
return res.status(400).json({
|
|
1085
|
+
error: 'Comment body cannot be empty'
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const db = req.app.get('db');
|
|
1090
|
+
|
|
1091
|
+
// Verify the comment exists and belongs to this review
|
|
1092
|
+
const comment = await queryOne(db, `
|
|
1093
|
+
SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
|
|
1094
|
+
`, [commentId, reviewId]);
|
|
1095
|
+
|
|
1096
|
+
if (!comment) {
|
|
1097
|
+
return res.status(404).json({
|
|
1098
|
+
error: 'User comment not found'
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Update comment
|
|
1103
|
+
await run(db, `
|
|
1104
|
+
UPDATE comments
|
|
1105
|
+
SET body = ?, updated_at = CURRENT_TIMESTAMP
|
|
1106
|
+
WHERE id = ?
|
|
1107
|
+
`, [body.trim(), commentId]);
|
|
1108
|
+
|
|
1109
|
+
res.json({
|
|
1110
|
+
success: true,
|
|
1111
|
+
message: 'Comment updated successfully'
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
console.error('Error updating local review user comment:', error);
|
|
1116
|
+
res.status(500).json({
|
|
1117
|
+
error: 'Failed to update comment'
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Bulk delete all user comments for a local review
|
|
1124
|
+
* Also dismisses any AI suggestions that were parents of the deleted comments.
|
|
1125
|
+
*/
|
|
1126
|
+
router.delete('/api/local/:reviewId/user-comments', async (req, res) => {
|
|
1127
|
+
try {
|
|
1128
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1129
|
+
|
|
1130
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1131
|
+
return res.status(400).json({
|
|
1132
|
+
error: 'Invalid review ID'
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const db = req.app.get('db');
|
|
1137
|
+
|
|
1138
|
+
// Verify review exists
|
|
1139
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1140
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
1141
|
+
|
|
1142
|
+
if (!review) {
|
|
1143
|
+
return res.status(404).json({
|
|
1144
|
+
error: `Local review #${reviewId} not found`
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// Begin transaction to ensure atomicity
|
|
1149
|
+
await run(db, 'BEGIN TRANSACTION');
|
|
1150
|
+
|
|
1151
|
+
try {
|
|
1152
|
+
// Bulk delete using repository (also dismisses parent AI suggestions)
|
|
1153
|
+
const commentRepo = new CommentRepository(db);
|
|
1154
|
+
const result = await commentRepo.bulkDeleteComments(reviewId);
|
|
1155
|
+
|
|
1156
|
+
// Commit transaction
|
|
1157
|
+
await run(db, 'COMMIT');
|
|
1158
|
+
|
|
1159
|
+
res.json({
|
|
1160
|
+
success: true,
|
|
1161
|
+
deletedCount: result.deletedCount,
|
|
1162
|
+
dismissedSuggestionIds: result.dismissedSuggestionIds,
|
|
1163
|
+
message: `Deleted ${result.deletedCount} user comment${result.deletedCount !== 1 ? 's' : ''}`
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
} catch (transactionError) {
|
|
1167
|
+
// Rollback transaction on error
|
|
1168
|
+
await run(db, 'ROLLBACK');
|
|
1169
|
+
throw transactionError;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
console.error('Error deleting all local review user comments:', error);
|
|
1174
|
+
res.status(500).json({
|
|
1175
|
+
error: 'Failed to delete comments'
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
/**
|
|
1181
|
+
* Delete user comment from a local review
|
|
1182
|
+
* If the comment was adopted from an AI suggestion, the parent suggestion
|
|
1183
|
+
* is automatically transitioned to 'dismissed' state.
|
|
1184
|
+
*/
|
|
1185
|
+
router.delete('/api/local/:reviewId/user-comments/:commentId', async (req, res) => {
|
|
1186
|
+
try {
|
|
1187
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1188
|
+
const commentId = parseInt(req.params.commentId);
|
|
1189
|
+
|
|
1190
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1191
|
+
return res.status(400).json({
|
|
1192
|
+
error: 'Invalid review ID'
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (isNaN(commentId) || commentId <= 0) {
|
|
1197
|
+
return res.status(400).json({
|
|
1198
|
+
error: 'Invalid comment ID'
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
const db = req.app.get('db');
|
|
1203
|
+
|
|
1204
|
+
// Verify the comment exists and belongs to this review
|
|
1205
|
+
const comment = await queryOne(db, `
|
|
1206
|
+
SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
|
|
1207
|
+
`, [commentId, reviewId]);
|
|
1208
|
+
|
|
1209
|
+
if (!comment) {
|
|
1210
|
+
return res.status(404).json({
|
|
1211
|
+
error: 'User comment not found'
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Use CommentRepository to delete (also dismisses parent AI suggestion if applicable)
|
|
1216
|
+
const commentRepo = new CommentRepository(db);
|
|
1217
|
+
const result = await commentRepo.deleteComment(commentId);
|
|
1218
|
+
|
|
1219
|
+
res.json({
|
|
1220
|
+
success: true,
|
|
1221
|
+
message: 'Comment deleted successfully',
|
|
1222
|
+
dismissedSuggestionId: result.dismissedSuggestionId
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
console.error('Error deleting local review user comment:', error);
|
|
1227
|
+
res.status(500).json({
|
|
1228
|
+
error: 'Failed to delete comment'
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Restore a dismissed user comment in a local review
|
|
1235
|
+
* Sets status from 'inactive' back to 'active'
|
|
1236
|
+
*/
|
|
1237
|
+
router.put('/api/local/:reviewId/user-comments/:commentId/restore', async (req, res) => {
|
|
1238
|
+
try {
|
|
1239
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1240
|
+
const commentId = parseInt(req.params.commentId);
|
|
1241
|
+
|
|
1242
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1243
|
+
return res.status(400).json({
|
|
1244
|
+
error: 'Invalid review ID'
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (isNaN(commentId) || commentId <= 0) {
|
|
1249
|
+
return res.status(400).json({
|
|
1250
|
+
error: 'Invalid comment ID'
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const db = req.app.get('db');
|
|
1255
|
+
|
|
1256
|
+
// Verify the comment exists and belongs to this review
|
|
1257
|
+
const comment = await queryOne(db, `
|
|
1258
|
+
SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
|
|
1259
|
+
`, [commentId, reviewId]);
|
|
1260
|
+
|
|
1261
|
+
if (!comment) {
|
|
1262
|
+
return res.status(404).json({
|
|
1263
|
+
error: 'User comment not found'
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (comment.status !== 'inactive') {
|
|
1268
|
+
return res.status(400).json({
|
|
1269
|
+
error: 'Comment is not dismissed'
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Restore the comment using CommentRepository
|
|
1274
|
+
const commentRepo = new CommentRepository(db);
|
|
1275
|
+
await commentRepo.restoreComment(commentId);
|
|
1276
|
+
|
|
1277
|
+
// Get the restored comment to return
|
|
1278
|
+
const restoredComment = await commentRepo.getComment(commentId, 'user');
|
|
1279
|
+
|
|
1280
|
+
res.json({
|
|
1281
|
+
success: true,
|
|
1282
|
+
message: 'Comment restored successfully',
|
|
1283
|
+
comment: restoredComment
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
console.error('Error restoring local review user comment:', error);
|
|
1288
|
+
res.status(500).json({
|
|
1289
|
+
error: error.message || 'Failed to restore comment'
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Check if analysis is running for a local review
|
|
1296
|
+
*/
|
|
1297
|
+
router.get('/api/local/:reviewId/analysis-status', async (req, res) => {
|
|
1298
|
+
try {
|
|
1299
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1300
|
+
|
|
1301
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1302
|
+
return res.status(400).json({
|
|
1303
|
+
error: 'Invalid review ID'
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
const reviewKey = getLocalReviewKey(reviewId);
|
|
1308
|
+
const analysisId = localReviewToAnalysisId.get(reviewKey);
|
|
1309
|
+
|
|
1310
|
+
if (!analysisId) {
|
|
1311
|
+
return res.json({
|
|
1312
|
+
running: false,
|
|
1313
|
+
analysisId: null,
|
|
1314
|
+
status: null
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const analysis = activeAnalyses.get(analysisId);
|
|
1319
|
+
|
|
1320
|
+
if (!analysis) {
|
|
1321
|
+
// Clean up stale mapping
|
|
1322
|
+
localReviewToAnalysisId.delete(reviewKey);
|
|
1323
|
+
return res.json({
|
|
1324
|
+
running: false,
|
|
1325
|
+
analysisId: null,
|
|
1326
|
+
status: null
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
res.json({
|
|
1331
|
+
running: true,
|
|
1332
|
+
analysisId,
|
|
1333
|
+
status: analysis
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
} catch (error) {
|
|
1337
|
+
console.error('Error checking local review analysis status:', error);
|
|
1338
|
+
res.status(500).json({
|
|
1339
|
+
error: 'Failed to check analysis status'
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Check if a local review has existing AI suggestions
|
|
1346
|
+
*/
|
|
1347
|
+
router.get('/api/local/:reviewId/has-ai-suggestions', async (req, res) => {
|
|
1348
|
+
try {
|
|
1349
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1350
|
+
const { runId } = req.query;
|
|
1351
|
+
|
|
1352
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1353
|
+
return res.status(400).json({
|
|
1354
|
+
error: 'Invalid review ID'
|
|
1355
|
+
});
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const db = req.app.get('db');
|
|
1359
|
+
|
|
1360
|
+
// Verify review exists
|
|
1361
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1362
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
1363
|
+
|
|
1364
|
+
if (!review) {
|
|
1365
|
+
return res.status(404).json({
|
|
1366
|
+
error: `Local review #${reviewId} not found`
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// Check if any AI suggestions exist for this review
|
|
1371
|
+
const result = await queryOne(db, `
|
|
1372
|
+
SELECT EXISTS(
|
|
1373
|
+
SELECT 1 FROM comments
|
|
1374
|
+
WHERE review_id = ? AND source = 'ai'
|
|
1375
|
+
) as has_suggestions
|
|
1376
|
+
`, [reviewId]);
|
|
1377
|
+
|
|
1378
|
+
const hasSuggestions = result?.has_suggestions === 1;
|
|
1379
|
+
|
|
1380
|
+
// Check if any analysis has been run using analysis_runs table
|
|
1381
|
+
let analysisHasRun = hasSuggestions;
|
|
1382
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
1383
|
+
let selectedRun = null;
|
|
1384
|
+
try {
|
|
1385
|
+
// If runId is provided, fetch that specific run; otherwise get the latest
|
|
1386
|
+
if (runId) {
|
|
1387
|
+
selectedRun = await analysisRunRepo.getById(runId);
|
|
1388
|
+
} else {
|
|
1389
|
+
selectedRun = await analysisRunRepo.getLatestByReviewId(reviewId);
|
|
1390
|
+
}
|
|
1391
|
+
analysisHasRun = !!(selectedRun || hasSuggestions);
|
|
1392
|
+
} catch (e) {
|
|
1393
|
+
// Log the error at debug level before falling back
|
|
1394
|
+
logger.debug('analysis_runs query failed in local mode, falling back to hasSuggestions:', e.message);
|
|
1395
|
+
// Fall back to using hasSuggestions if analysis_runs table doesn't exist
|
|
1396
|
+
analysisHasRun = hasSuggestions;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Get AI summary from the selected analysis run if available, otherwise fall back to review summary
|
|
1400
|
+
const summary = selectedRun?.summary || review?.summary || null;
|
|
1401
|
+
|
|
1402
|
+
// Get stats for AI suggestions (issues/suggestions/praise for final level only)
|
|
1403
|
+
// Filter by runId if provided, otherwise use the latest analysis run
|
|
1404
|
+
let stats = { issues: 0, suggestions: 0, praise: 0 };
|
|
1405
|
+
if (hasSuggestions) {
|
|
1406
|
+
try {
|
|
1407
|
+
const statsQuery = getStatsQuery(runId);
|
|
1408
|
+
const statsResult = await query(db, statsQuery.query, statsQuery.params(reviewId));
|
|
1409
|
+
stats = calculateStats(statsResult);
|
|
1410
|
+
} catch (e) {
|
|
1411
|
+
console.warn('Error fetching AI suggestion stats:', e);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
res.json({
|
|
1416
|
+
hasSuggestions: hasSuggestions,
|
|
1417
|
+
analysisHasRun: analysisHasRun,
|
|
1418
|
+
summary: summary,
|
|
1419
|
+
stats: stats
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
console.error('Error checking for AI suggestions:', error);
|
|
1424
|
+
res.status(500).json({
|
|
1425
|
+
error: 'Failed to check for AI suggestions'
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
/**
|
|
1431
|
+
* Server-Sent Events endpoint for local review AI analysis progress
|
|
1432
|
+
*/
|
|
1433
|
+
router.get('/api/local/:reviewId/ai-suggestions/status', (req, res) => {
|
|
1434
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1435
|
+
|
|
1436
|
+
// Find the analysis ID for this local review
|
|
1437
|
+
const reviewKey = getLocalReviewKey(reviewId);
|
|
1438
|
+
const analysisId = localReviewToAnalysisId.get(reviewKey);
|
|
1439
|
+
|
|
1440
|
+
// Set up SSE headers
|
|
1441
|
+
res.writeHead(200, {
|
|
1442
|
+
'Content-Type': 'text/event-stream',
|
|
1443
|
+
'Cache-Control': 'no-cache',
|
|
1444
|
+
'Connection': 'keep-alive',
|
|
1445
|
+
'Access-Control-Allow-Origin': '*',
|
|
1446
|
+
'Access-Control-Allow-Headers': 'Cache-Control'
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
// Send initial connection message
|
|
1450
|
+
res.write('data: {"type":"connected","message":"Connected to progress stream"}\n\n');
|
|
1451
|
+
|
|
1452
|
+
// If we have an analysis ID, use it; otherwise use a placeholder
|
|
1453
|
+
const trackingId = analysisId || `local-${reviewId}`;
|
|
1454
|
+
|
|
1455
|
+
// Store client for this analysis
|
|
1456
|
+
if (!progressClients.has(trackingId)) {
|
|
1457
|
+
progressClients.set(trackingId, new Set());
|
|
1458
|
+
}
|
|
1459
|
+
progressClients.get(trackingId).add(res);
|
|
1460
|
+
|
|
1461
|
+
// Send current status if analysis exists
|
|
1462
|
+
if (analysisId) {
|
|
1463
|
+
const currentStatus = activeAnalyses.get(analysisId);
|
|
1464
|
+
if (currentStatus) {
|
|
1465
|
+
res.write(`data: ${JSON.stringify({
|
|
1466
|
+
type: 'progress',
|
|
1467
|
+
...currentStatus
|
|
1468
|
+
})}\n\n`);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Handle client disconnect
|
|
1473
|
+
req.on('close', () => {
|
|
1474
|
+
const clients = progressClients.get(trackingId);
|
|
1475
|
+
if (clients) {
|
|
1476
|
+
clients.delete(res);
|
|
1477
|
+
if (clients.size === 0) {
|
|
1478
|
+
progressClients.delete(trackingId);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
req.on('error', () => {
|
|
1484
|
+
const clients = progressClients.get(trackingId);
|
|
1485
|
+
if (clients) {
|
|
1486
|
+
clients.delete(res);
|
|
1487
|
+
if (clients.size === 0) {
|
|
1488
|
+
progressClients.delete(trackingId);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
});
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1494
|
+
/**
|
|
1495
|
+
* Refresh the diff for a local review
|
|
1496
|
+
* Regenerates the diff from the current state of the working directory
|
|
1497
|
+
* Returns sessionChanged flag if HEAD has changed since the session was created
|
|
1498
|
+
*/
|
|
1499
|
+
router.post('/api/local/:reviewId/refresh', async (req, res) => {
|
|
1500
|
+
try {
|
|
1501
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1502
|
+
|
|
1503
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1504
|
+
return res.status(400).json({
|
|
1505
|
+
error: 'Invalid review ID'
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const db = req.app.get('db');
|
|
1510
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1511
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
1512
|
+
|
|
1513
|
+
if (!review) {
|
|
1514
|
+
return res.status(404).json({
|
|
1515
|
+
error: `Local review #${reviewId} not found`
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const localPath = review.local_path;
|
|
1520
|
+
const originalHeadSha = review.local_head_sha;
|
|
1521
|
+
|
|
1522
|
+
if (!localPath) {
|
|
1523
|
+
return res.status(400).json({
|
|
1524
|
+
error: 'Local review is missing path information'
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
logger.log('API', `Refreshing diff for local review #${reviewId}`, 'cyan');
|
|
1529
|
+
logger.log('API', `Local path: ${localPath}`, 'magenta');
|
|
1530
|
+
|
|
1531
|
+
// Check if HEAD has changed
|
|
1532
|
+
const { getHeadSha } = require('../local-review');
|
|
1533
|
+
let currentHeadSha;
|
|
1534
|
+
let sessionChanged = false;
|
|
1535
|
+
let newSessionId = null;
|
|
1536
|
+
|
|
1537
|
+
try {
|
|
1538
|
+
currentHeadSha = await getHeadSha(localPath);
|
|
1539
|
+
|
|
1540
|
+
if (originalHeadSha && currentHeadSha !== originalHeadSha) {
|
|
1541
|
+
sessionChanged = true;
|
|
1542
|
+
logger.log('API', `HEAD changed: ${originalHeadSha.substring(0, 7)} -> ${currentHeadSha.substring(0, 7)}`, 'yellow');
|
|
1543
|
+
|
|
1544
|
+
// Check if a session already exists for the new HEAD
|
|
1545
|
+
const existingSession = await reviewRepo.getLocalReview(localPath, currentHeadSha);
|
|
1546
|
+
if (existingSession) {
|
|
1547
|
+
newSessionId = existingSession.id;
|
|
1548
|
+
logger.log('API', `Existing session found for new HEAD: ${newSessionId}`, 'cyan');
|
|
1549
|
+
} else {
|
|
1550
|
+
// Create a new session for the new HEAD
|
|
1551
|
+
const { getRepositoryName } = require('../local-review');
|
|
1552
|
+
const repository = await getRepositoryName(localPath);
|
|
1553
|
+
newSessionId = await reviewRepo.upsertLocalReview({
|
|
1554
|
+
localPath: localPath,
|
|
1555
|
+
localHeadSha: currentHeadSha,
|
|
1556
|
+
repository
|
|
1557
|
+
});
|
|
1558
|
+
logger.log('API', `Created new session for new HEAD: ${newSessionId}`, 'cyan');
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
} catch (headError) {
|
|
1562
|
+
logger.warn(`Could not check HEAD SHA: ${headError.message}`);
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// Regenerate the diff from the working directory
|
|
1566
|
+
const { diff, stats } = await generateLocalDiff(localPath);
|
|
1567
|
+
|
|
1568
|
+
// Compute fresh digest for the new diff
|
|
1569
|
+
const digest = await computeLocalDiffDigest(localPath);
|
|
1570
|
+
|
|
1571
|
+
// Update the stored diff data for the appropriate session
|
|
1572
|
+
const targetSessionId = sessionChanged ? newSessionId : reviewId;
|
|
1573
|
+
localReviewDiffs.set(targetSessionId, { diff, stats, digest });
|
|
1574
|
+
|
|
1575
|
+
logger.success(`Diff refreshed: ${stats.unstagedChanges} unstaged, ${stats.untrackedFiles} untracked${stats.stagedChanges > 0 ? ` (${stats.stagedChanges} staged excluded)` : ''}`);
|
|
1576
|
+
|
|
1577
|
+
res.json({
|
|
1578
|
+
success: true,
|
|
1579
|
+
message: 'Diff refreshed successfully',
|
|
1580
|
+
sessionChanged,
|
|
1581
|
+
newSessionId: sessionChanged ? newSessionId : null,
|
|
1582
|
+
newHeadSha: sessionChanged ? currentHeadSha : null,
|
|
1583
|
+
originalHeadSha: originalHeadSha,
|
|
1584
|
+
stats: {
|
|
1585
|
+
trackedChanges: stats.trackedChanges || 0,
|
|
1586
|
+
untrackedFiles: stats.untrackedFiles || 0,
|
|
1587
|
+
stagedChanges: stats.stagedChanges || 0,
|
|
1588
|
+
unstagedChanges: stats.unstagedChanges || 0
|
|
1589
|
+
}
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
} catch (error) {
|
|
1593
|
+
console.error('Error refreshing local diff:', error);
|
|
1594
|
+
res.status(500).json({
|
|
1595
|
+
error: 'Failed to refresh diff: ' + error.message
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
});
|
|
1599
|
+
|
|
1600
|
+
/**
|
|
1601
|
+
* Get review settings for a local review
|
|
1602
|
+
* Returns the custom_instructions from the review record
|
|
1603
|
+
*/
|
|
1604
|
+
router.get('/api/local/:reviewId/review-settings', async (req, res) => {
|
|
1605
|
+
try {
|
|
1606
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1607
|
+
|
|
1608
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1609
|
+
return res.status(400).json({
|
|
1610
|
+
error: 'Invalid review ID'
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
const db = req.app.get('db');
|
|
1615
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1616
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
1617
|
+
|
|
1618
|
+
if (!review) {
|
|
1619
|
+
return res.json({
|
|
1620
|
+
custom_instructions: null
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
res.json({
|
|
1625
|
+
custom_instructions: review.custom_instructions || null
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
console.error('Error fetching local review settings:', error);
|
|
1630
|
+
res.status(500).json({
|
|
1631
|
+
error: 'Failed to fetch review settings'
|
|
1632
|
+
});
|
|
1633
|
+
}
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* Save review settings for a local review
|
|
1638
|
+
* Saves the custom_instructions to the review record
|
|
1639
|
+
*/
|
|
1640
|
+
router.post('/api/local/:reviewId/review-settings', async (req, res) => {
|
|
1641
|
+
try {
|
|
1642
|
+
const reviewId = parseInt(req.params.reviewId);
|
|
1643
|
+
|
|
1644
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1645
|
+
return res.status(400).json({
|
|
1646
|
+
error: 'Invalid review ID'
|
|
1647
|
+
});
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
const { custom_instructions } = req.body;
|
|
1651
|
+
|
|
1652
|
+
const db = req.app.get('db');
|
|
1653
|
+
const reviewRepo = new ReviewRepository(db);
|
|
1654
|
+
const review = await reviewRepo.getLocalReviewById(reviewId);
|
|
1655
|
+
|
|
1656
|
+
if (!review) {
|
|
1657
|
+
return res.status(404).json({
|
|
1658
|
+
error: `Local review #${reviewId} not found`
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// Update the review with custom instructions
|
|
1663
|
+
await reviewRepo.updateReview(reviewId, {
|
|
1664
|
+
customInstructions: custom_instructions || null
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
res.json({
|
|
1668
|
+
success: true,
|
|
1669
|
+
custom_instructions: custom_instructions || null
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
} catch (error) {
|
|
1673
|
+
console.error('Error saving local review settings:', error);
|
|
1674
|
+
res.status(500).json({
|
|
1675
|
+
error: 'Failed to save review settings'
|
|
1676
|
+
});
|
|
1677
|
+
}
|
|
1678
|
+
});
|
|
1679
|
+
|
|
1680
|
+
/**
|
|
1681
|
+
* Get all analysis runs for a local review
|
|
1682
|
+
*/
|
|
1683
|
+
router.get('/api/local/:reviewId/analysis-runs', async (req, res) => {
|
|
1684
|
+
try {
|
|
1685
|
+
const reviewId = parseInt(req.params.reviewId, 10);
|
|
1686
|
+
|
|
1687
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1688
|
+
return res.status(400).json({ error: 'Invalid review ID' });
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
const db = req.app.get('db');
|
|
1692
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
1693
|
+
const runs = await analysisRunRepo.getByReviewId(reviewId);
|
|
1694
|
+
|
|
1695
|
+
res.json({ runs });
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
console.error('Error fetching analysis runs:', error);
|
|
1698
|
+
res.status(500).json({ error: 'Failed to fetch analysis runs' });
|
|
1699
|
+
}
|
|
1700
|
+
});
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* Get the most recent analysis run for a local review
|
|
1704
|
+
*/
|
|
1705
|
+
router.get('/api/local/:reviewId/analysis-runs/latest', async (req, res) => {
|
|
1706
|
+
try {
|
|
1707
|
+
const reviewId = parseInt(req.params.reviewId, 10);
|
|
1708
|
+
|
|
1709
|
+
if (isNaN(reviewId) || reviewId <= 0) {
|
|
1710
|
+
return res.status(400).json({ error: 'Invalid review ID' });
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
const db = req.app.get('db');
|
|
1714
|
+
const analysisRunRepo = new AnalysisRunRepository(db);
|
|
1715
|
+
const run = await analysisRunRepo.getLatestByReviewId(reviewId);
|
|
1716
|
+
|
|
1717
|
+
if (!run) {
|
|
1718
|
+
return res.status(404).json({ error: 'No analysis runs found' });
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
res.json({ run });
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
console.error('Error fetching latest analysis run:', error);
|
|
1724
|
+
res.status(500).json({ error: 'Failed to fetch latest analysis run' });
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
module.exports = router;
|