@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.
Files changed (91) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +371 -0
  3. package/bin/git-diff-lines +146 -0
  4. package/bin/pair-review.js +49 -0
  5. package/package.json +71 -0
  6. package/public/css/ai-summary-modal.css +183 -0
  7. package/public/css/pr.css +8698 -0
  8. package/public/css/repo-settings.css +891 -0
  9. package/public/css/styles.css +479 -0
  10. package/public/favicon.png +0 -0
  11. package/public/index.html +1104 -0
  12. package/public/js/components/AIPanel.js +1639 -0
  13. package/public/js/components/AISummaryModal.js +278 -0
  14. package/public/js/components/AnalysisConfigModal.js +684 -0
  15. package/public/js/components/ConfirmDialog.js +227 -0
  16. package/public/js/components/PreviewModal.js +344 -0
  17. package/public/js/components/ProgressModal.js +678 -0
  18. package/public/js/components/ReviewModal.js +531 -0
  19. package/public/js/components/SplitButton.js +382 -0
  20. package/public/js/components/StatusIndicator.js +265 -0
  21. package/public/js/components/SuggestionNavigator.js +489 -0
  22. package/public/js/components/Toast.js +166 -0
  23. package/public/js/local.js +1580 -0
  24. package/public/js/modules/analysis-history.js +940 -0
  25. package/public/js/modules/comment-manager.js +643 -0
  26. package/public/js/modules/diff-renderer.js +585 -0
  27. package/public/js/modules/file-comment-manager.js +1242 -0
  28. package/public/js/modules/gap-coordinates.js +190 -0
  29. package/public/js/modules/hunk-parser.js +358 -0
  30. package/public/js/modules/line-tracker.js +386 -0
  31. package/public/js/modules/panel-resizer.js +228 -0
  32. package/public/js/modules/storage-cleanup.js +36 -0
  33. package/public/js/modules/suggestion-manager.js +692 -0
  34. package/public/js/pr.js +3503 -0
  35. package/public/js/repo-settings.js +691 -0
  36. package/public/js/utils/file-order.js +87 -0
  37. package/public/js/utils/markdown.js +97 -0
  38. package/public/js/utils/suggestion-ui.js +55 -0
  39. package/public/js/utils/tier-icons.js +25 -0
  40. package/public/local.html +460 -0
  41. package/public/pr.html +329 -0
  42. package/public/repo-settings.html +243 -0
  43. package/src/ai/analyzer.js +2592 -0
  44. package/src/ai/claude-cli.js +153 -0
  45. package/src/ai/claude-provider.js +261 -0
  46. package/src/ai/codex-provider.js +361 -0
  47. package/src/ai/copilot-provider.js +345 -0
  48. package/src/ai/gemini-provider.js +375 -0
  49. package/src/ai/index.js +47 -0
  50. package/src/ai/prompts/baseline/_meta.json +14 -0
  51. package/src/ai/prompts/baseline/level1/balanced.js +239 -0
  52. package/src/ai/prompts/baseline/level1/fast.js +194 -0
  53. package/src/ai/prompts/baseline/level1/thorough.js +319 -0
  54. package/src/ai/prompts/baseline/level2/balanced.js +248 -0
  55. package/src/ai/prompts/baseline/level2/fast.js +201 -0
  56. package/src/ai/prompts/baseline/level2/thorough.js +367 -0
  57. package/src/ai/prompts/baseline/level3/balanced.js +280 -0
  58. package/src/ai/prompts/baseline/level3/fast.js +220 -0
  59. package/src/ai/prompts/baseline/level3/thorough.js +459 -0
  60. package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
  61. package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
  62. package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
  63. package/src/ai/prompts/config.js +52 -0
  64. package/src/ai/prompts/index.js +267 -0
  65. package/src/ai/prompts/shared/diff-instructions.js +50 -0
  66. package/src/ai/prompts/shared/output-schema.js +179 -0
  67. package/src/ai/prompts/shared/valid-files.js +37 -0
  68. package/src/ai/provider.js +260 -0
  69. package/src/config.js +139 -0
  70. package/src/database.js +2284 -0
  71. package/src/git/gitattributes.js +207 -0
  72. package/src/git/worktree.js +688 -0
  73. package/src/github/client.js +893 -0
  74. package/src/github/parser.js +247 -0
  75. package/src/local-review.js +691 -0
  76. package/src/main.js +987 -0
  77. package/src/routes/analysis.js +897 -0
  78. package/src/routes/comments.js +534 -0
  79. package/src/routes/config.js +250 -0
  80. package/src/routes/local.js +1728 -0
  81. package/src/routes/pr.js +1164 -0
  82. package/src/routes/shared.js +218 -0
  83. package/src/routes/worktrees.js +500 -0
  84. package/src/server.js +295 -0
  85. package/src/utils/diff-annotator.js +414 -0
  86. package/src/utils/instructions.js +33 -0
  87. package/src/utils/json-extractor.js +107 -0
  88. package/src/utils/line-validation.js +183 -0
  89. package/src/utils/logger.js +142 -0
  90. package/src/utils/paths.js +161 -0
  91. 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;