@in-the-loop-labs/pair-review 1.6.2 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +77 -4
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/skills/review-requests/SKILL.md +4 -1
  5. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  6. package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
  7. package/public/css/pr.css +1962 -114
  8. package/public/js/CONVENTIONS.md +16 -0
  9. package/public/js/components/AIPanel.js +66 -0
  10. package/public/js/components/AnalysisConfigModal.js +2 -2
  11. package/public/js/components/ChatPanel.js +2955 -0
  12. package/public/js/components/CouncilProgressModal.js +12 -16
  13. package/public/js/components/KeyboardShortcuts.js +3 -0
  14. package/public/js/components/PanelGroup.js +723 -0
  15. package/public/js/components/PreviewModal.js +3 -8
  16. package/public/js/index.js +8 -0
  17. package/public/js/local.js +17 -615
  18. package/public/js/modules/analysis-history.js +19 -68
  19. package/public/js/modules/comment-manager.js +103 -20
  20. package/public/js/modules/diff-context.js +176 -0
  21. package/public/js/modules/diff-renderer.js +30 -0
  22. package/public/js/modules/file-comment-manager.js +126 -105
  23. package/public/js/modules/file-list-merger.js +64 -0
  24. package/public/js/modules/panel-resizer.js +25 -6
  25. package/public/js/modules/suggestion-manager.js +40 -125
  26. package/public/js/pr.js +1009 -159
  27. package/public/js/repo-settings.js +36 -6
  28. package/public/js/utils/category-emoji.js +44 -0
  29. package/public/js/utils/time.js +32 -0
  30. package/public/local.html +107 -70
  31. package/public/pr.html +107 -70
  32. package/public/repo-settings.html +32 -0
  33. package/src/ai/analyzer.js +5 -1
  34. package/src/ai/copilot-provider.js +39 -9
  35. package/src/ai/cursor-agent-provider.js +45 -11
  36. package/src/ai/gemini-provider.js +17 -4
  37. package/src/ai/prompts/config.js +7 -1
  38. package/src/ai/provider-availability.js +1 -1
  39. package/src/ai/provider.js +25 -37
  40. package/src/chat/CONVENTIONS.md +18 -0
  41. package/src/chat/pi-bridge.js +491 -0
  42. package/src/chat/prompt-builder.js +272 -0
  43. package/src/chat/session-manager.js +619 -0
  44. package/src/config.js +14 -0
  45. package/src/database.js +322 -15
  46. package/src/main.js +4 -17
  47. package/src/routes/analyses.js +721 -0
  48. package/src/routes/chat.js +655 -0
  49. package/src/routes/config.js +29 -8
  50. package/src/routes/context-files.js +274 -0
  51. package/src/routes/local.js +225 -1133
  52. package/src/routes/mcp.js +39 -30
  53. package/src/routes/pr.js +424 -58
  54. package/src/routes/reviews.js +1035 -0
  55. package/src/routes/shared.js +4 -29
  56. package/src/server.js +34 -12
  57. package/src/sse/review-events.js +46 -0
  58. package/src/utils/auto-context.js +88 -0
  59. package/src/utils/category-emoji.js +33 -0
  60. package/src/utils/diff-annotator.js +75 -1
  61. package/src/utils/diff-file-list.js +57 -0
  62. package/src/routes/analysis.js +0 -1600
  63. package/src/routes/comments.js +0 -534
@@ -0,0 +1,1035 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Unified Review Comment Routes
4
+ *
5
+ * Provides a single set of comment CRUD endpoints under /api/reviews/:reviewId/comments
6
+ * that work for both PR mode and Local mode. This replaces the previously separate
7
+ * comment routes in comments.js (PR mode) and local.js (Local mode).
8
+ */
9
+
10
+ const express = require('express');
11
+ const { query, queryOne, run, withTransaction, CommentRepository, ReviewRepository, AnalysisRunRepository } = require('../database');
12
+ const { calculateStats, getStatsQuery } = require('../utils/stats-calculator');
13
+ const { activeAnalyses, reviewToAnalysisId } = require('./shared');
14
+ const logger = require('../utils/logger');
15
+ const { broadcastReviewEvent } = require('../sse/review-events');
16
+ const { ensureContextFileForComment } = require('../utils/auto-context');
17
+ const path = require('path');
18
+ const fs = require('fs').promises;
19
+ const simpleGit = require('simple-git');
20
+ const { GitWorktreeManager } = require('../git/worktree');
21
+ const { normalizeRepository } = require('../utils/paths');
22
+ const { getEmoji: getCategoryEmoji } = require('../utils/category-emoji');
23
+
24
+ const router = express.Router();
25
+
26
+ /**
27
+ * Format adopted comment text with emoji and category prefix.
28
+ * Mirrors the frontend formatAdoptedComment() in SuggestionManager / FileCommentManager.
29
+ * @param {string} text - Comment text
30
+ * @param {string} category - Category name (e.g., 'bug', 'code-style')
31
+ * @returns {string} Formatted text with emoji prefix
32
+ */
33
+ function formatAdoptedComment(text, category) {
34
+ if (!category) {
35
+ return text;
36
+ }
37
+ const emoji = getCategoryEmoji(category);
38
+ // Properly capitalize hyphenated categories (e.g., "code-style" -> "Code Style")
39
+ const capitalizedCategory = category
40
+ .split('-')
41
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
42
+ .join(' ');
43
+ return `${emoji} **${capitalizedCategory}**: ${text}`;
44
+ }
45
+
46
+ /**
47
+ * Middleware: validate that :reviewId exists in the reviews table.
48
+ * Attaches the review record to req.review for downstream handlers.
49
+ */
50
+ async function validateReviewId(req, res, next) {
51
+ try {
52
+ const reviewId = parseInt(req.params.reviewId, 10);
53
+
54
+ if (isNaN(reviewId) || reviewId <= 0) {
55
+ return res.status(400).json({ error: 'Invalid review ID' });
56
+ }
57
+
58
+ const db = req.app.get('db');
59
+ const reviewRepo = new ReviewRepository(db);
60
+ const review = await reviewRepo.getReview(reviewId);
61
+
62
+ if (!review) {
63
+ return res.status(404).json({ error: `Review #${reviewId} not found` });
64
+ }
65
+
66
+ req.review = review;
67
+ req.reviewId = reviewId;
68
+ next();
69
+ } catch (error) {
70
+ next(error);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * GET /api/reviews/:reviewId/comments
76
+ * Get all comments for a review.
77
+ * Query params:
78
+ * - includeDismissed: if 'true', includes dismissed (inactive) comments
79
+ */
80
+ router.get('/api/reviews/:reviewId/comments', validateReviewId, async (req, res) => {
81
+ try {
82
+ const { includeDismissed } = req.query;
83
+ const db = req.app.get('db');
84
+ const commentRepo = new CommentRepository(db);
85
+
86
+ const comments = await commentRepo.getUserComments(req.reviewId, {
87
+ includeDismissed: includeDismissed === 'true'
88
+ });
89
+
90
+ res.json({
91
+ success: true,
92
+ comments: comments || []
93
+ });
94
+ } catch (error) {
95
+ logger.error('Error fetching comments:', error);
96
+ res.status(500).json({ error: 'Failed to fetch comments' });
97
+ }
98
+ });
99
+
100
+ /**
101
+ * POST /api/reviews/:reviewId/comments
102
+ * Create a new comment. If line_start is present, creates a line-level comment;
103
+ * otherwise creates a file-level comment.
104
+ */
105
+ router.post('/api/reviews/:reviewId/comments', validateReviewId, async (req, res) => {
106
+ try {
107
+ const { file, line_start, line_end, diff_position, side, commit_sha, body, parent_id, type, title } = req.body;
108
+
109
+ if (!file || !body) {
110
+ return res.status(400).json({
111
+ error: 'Missing required fields: file, body'
112
+ });
113
+ }
114
+
115
+ // Validate body is not just whitespace
116
+ const trimmedBody = body.trim();
117
+ if (trimmedBody.length === 0) {
118
+ return res.status(400).json({
119
+ error: 'Comment body cannot be empty or whitespace only'
120
+ });
121
+ }
122
+
123
+ const db = req.app.get('db');
124
+ const commentRepo = new CommentRepository(db);
125
+
126
+ let commentId;
127
+
128
+ if (line_start) {
129
+ // Line-level comment
130
+ commentId = await commentRepo.createLineComment({
131
+ review_id: req.reviewId,
132
+ file,
133
+ line_start,
134
+ line_end,
135
+ diff_position,
136
+ side,
137
+ commit_sha,
138
+ body: trimmedBody,
139
+ parent_id,
140
+ type,
141
+ title
142
+ });
143
+ } else {
144
+ // File-level comment
145
+ commentId = await commentRepo.createFileComment({
146
+ review_id: req.reviewId,
147
+ file,
148
+ body: trimmedBody,
149
+ commit_sha,
150
+ type,
151
+ title,
152
+ parent_id
153
+ });
154
+ }
155
+
156
+ res.json({
157
+ success: true,
158
+ commentId,
159
+ message: line_start ? 'Comment saved successfully' : 'File-level comment saved successfully'
160
+ });
161
+ broadcastReviewEvent(req.reviewId, { type: 'review:comments_changed' }, { sourceClientId: req.get('X-Client-Id') });
162
+
163
+ // Fire-and-forget: auto-add context file for comments on files outside the diff
164
+ try {
165
+ const result = await ensureContextFileForComment(db, req.review, { file, line_start, line_end });
166
+ if (result.created || result.expanded) {
167
+ broadcastReviewEvent(req.reviewId, { type: 'review:context_files_changed' });
168
+ }
169
+ } catch (err) {
170
+ logger.warn(`[AutoContext] Failed: ${err.message}`);
171
+ }
172
+ } catch (error) {
173
+ logger.error('Error creating comment:', error);
174
+ res.status(500).json({
175
+ error: error.message || 'Failed to create comment'
176
+ });
177
+ }
178
+ });
179
+
180
+ /**
181
+ * GET /api/reviews/:reviewId/comments/:id
182
+ * Get a single comment, verifying it belongs to the review.
183
+ */
184
+ router.get('/api/reviews/:reviewId/comments/:id', validateReviewId, async (req, res) => {
185
+ try {
186
+ const { id } = req.params;
187
+ const db = req.app.get('db');
188
+ const commentRepo = new CommentRepository(db);
189
+
190
+ const comment = await commentRepo.getComment(id, 'user');
191
+
192
+ if (!comment) {
193
+ return res.status(404).json({ error: 'User comment not found' });
194
+ }
195
+
196
+ if (comment.review_id !== req.reviewId) {
197
+ return res.status(404).json({ error: 'User comment not found' });
198
+ }
199
+
200
+ res.json(comment);
201
+ } catch (error) {
202
+ logger.error('Error fetching comment:', error);
203
+ res.status(500).json({
204
+ error: error.message || 'Failed to fetch comment'
205
+ });
206
+ }
207
+ });
208
+
209
+ /**
210
+ * PUT /api/reviews/:reviewId/comments/:id
211
+ * Update a comment, verifying it belongs to the review.
212
+ */
213
+ router.put('/api/reviews/:reviewId/comments/:id', validateReviewId, async (req, res) => {
214
+ try {
215
+ const { id } = req.params;
216
+ const { body } = req.body;
217
+
218
+ if (!body || !body.trim()) {
219
+ return res.status(400).json({
220
+ error: 'Comment body cannot be empty'
221
+ });
222
+ }
223
+
224
+ const db = req.app.get('db');
225
+
226
+ // Verify the comment exists and belongs to this review
227
+ const comment = await queryOne(db, `
228
+ SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
229
+ `, [id, req.reviewId]);
230
+
231
+ if (!comment) {
232
+ return res.status(404).json({ error: 'User comment not found' });
233
+ }
234
+
235
+ const commentRepo = new CommentRepository(db);
236
+ await commentRepo.updateComment(id, body);
237
+
238
+ res.json({
239
+ success: true,
240
+ message: 'Comment updated successfully'
241
+ });
242
+ broadcastReviewEvent(req.reviewId, { type: 'review:comments_changed' }, { sourceClientId: req.get('X-Client-Id') });
243
+ } catch (error) {
244
+ logger.error('Error updating comment:', error);
245
+
246
+ if (error.message && error.message.includes('not found')) {
247
+ return res.status(404).json({ error: error.message });
248
+ }
249
+
250
+ res.status(500).json({
251
+ error: error.message || 'Failed to update comment'
252
+ });
253
+ }
254
+ });
255
+
256
+ /**
257
+ * DELETE /api/reviews/:reviewId/comments/:id
258
+ * Soft-delete a comment, verifying it belongs to the review.
259
+ * If the comment was adopted from an AI suggestion, the parent suggestion
260
+ * is automatically transitioned to 'dismissed' state.
261
+ */
262
+ router.delete('/api/reviews/:reviewId/comments/:id', validateReviewId, async (req, res) => {
263
+ try {
264
+ const { id } = req.params;
265
+ const db = req.app.get('db');
266
+
267
+ // Verify the comment exists and belongs to this review
268
+ const comment = await queryOne(db, `
269
+ SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
270
+ `, [id, req.reviewId]);
271
+
272
+ if (!comment) {
273
+ return res.status(404).json({ error: 'User comment not found' });
274
+ }
275
+
276
+ const commentRepo = new CommentRepository(db);
277
+ const result = await commentRepo.deleteComment(id);
278
+
279
+ res.json({
280
+ success: true,
281
+ message: 'Comment deleted successfully',
282
+ dismissedSuggestionId: result.dismissedSuggestionId
283
+ });
284
+ broadcastReviewEvent(req.reviewId, { type: 'review:comments_changed' }, { sourceClientId: req.get('X-Client-Id') });
285
+ if (result.dismissedSuggestionId) {
286
+ broadcastReviewEvent(req.reviewId, { type: 'review:suggestions_changed' }, { sourceClientId: req.get('X-Client-Id') });
287
+ }
288
+ } catch (error) {
289
+ logger.error('Error deleting comment:', error);
290
+
291
+ if (error.message && error.message.includes('not found')) {
292
+ return res.status(404).json({ error: error.message });
293
+ }
294
+
295
+ res.status(500).json({
296
+ error: error.message || 'Failed to delete comment'
297
+ });
298
+ }
299
+ });
300
+
301
+ /**
302
+ * PUT /api/reviews/:reviewId/comments/:id/restore
303
+ * Restore a dismissed (inactive) comment, verifying it belongs to the review.
304
+ */
305
+ router.put('/api/reviews/:reviewId/comments/:id/restore', validateReviewId, async (req, res) => {
306
+ try {
307
+ const { id } = req.params;
308
+ const commentId = parseInt(id, 10);
309
+
310
+ if (isNaN(commentId)) {
311
+ return res.status(400).json({ error: 'Invalid comment ID' });
312
+ }
313
+
314
+ const db = req.app.get('db');
315
+
316
+ // Verify the comment exists and belongs to this review
317
+ const comment = await queryOne(db, `
318
+ SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
319
+ `, [commentId, req.reviewId]);
320
+
321
+ if (!comment) {
322
+ return res.status(404).json({ error: 'User comment not found' });
323
+ }
324
+
325
+ if (comment.status !== 'inactive') {
326
+ return res.status(400).json({ error: 'Comment is not dismissed' });
327
+ }
328
+
329
+ const commentRepo = new CommentRepository(db);
330
+ await commentRepo.restoreComment(commentId);
331
+
332
+ // Get the restored comment to return
333
+ const restoredComment = await commentRepo.getComment(commentId, 'user');
334
+
335
+ res.json({
336
+ success: true,
337
+ message: 'Comment restored successfully',
338
+ comment: restoredComment
339
+ });
340
+ broadcastReviewEvent(req.reviewId, { type: 'review:comments_changed' }, { sourceClientId: req.get('X-Client-Id') });
341
+ } catch (error) {
342
+ logger.error('Error restoring comment:', error);
343
+
344
+ if (error.message && error.message.includes('not found')) {
345
+ return res.status(404).json({ error: error.message });
346
+ }
347
+
348
+ if (error.message && error.message.includes('not dismissed')) {
349
+ return res.status(400).json({ error: error.message });
350
+ }
351
+
352
+ res.status(500).json({
353
+ error: error.message || 'Failed to restore comment'
354
+ });
355
+ }
356
+ });
357
+
358
+ /**
359
+ * DELETE /api/reviews/:reviewId/comments
360
+ * Bulk delete all user comments for a review.
361
+ * Also dismisses any AI suggestions that were parents of the deleted comments.
362
+ */
363
+ router.delete('/api/reviews/:reviewId/comments', validateReviewId, async (req, res) => {
364
+ try {
365
+ const db = req.app.get('db');
366
+
367
+ // Begin transaction to ensure atomicity
368
+ await run(db, 'BEGIN TRANSACTION');
369
+
370
+ try {
371
+ const commentRepo = new CommentRepository(db);
372
+ const result = await commentRepo.bulkDeleteComments(req.reviewId);
373
+
374
+ await run(db, 'COMMIT');
375
+
376
+ res.json({
377
+ success: true,
378
+ deletedCount: result.deletedCount,
379
+ dismissedSuggestionIds: result.dismissedSuggestionIds,
380
+ message: `Deleted ${result.deletedCount} user comment${result.deletedCount !== 1 ? 's' : ''}`
381
+ });
382
+ broadcastReviewEvent(req.reviewId, { type: 'review:comments_changed' }, { sourceClientId: req.get('X-Client-Id') });
383
+ if (result.dismissedSuggestionIds.length > 0) {
384
+ broadcastReviewEvent(req.reviewId, { type: 'review:suggestions_changed' }, { sourceClientId: req.get('X-Client-Id') });
385
+ }
386
+ } catch (transactionError) {
387
+ await run(db, 'ROLLBACK');
388
+ throw transactionError;
389
+ }
390
+ } catch (error) {
391
+ logger.error('Error deleting comments:', error);
392
+ res.status(500).json({
393
+ error: error.message || 'Failed to delete comments'
394
+ });
395
+ }
396
+ });
397
+
398
+ // ==========================================================================
399
+ // AI Suggestion Routes
400
+ // ==========================================================================
401
+
402
+ /**
403
+ * GET /api/reviews/:reviewId/suggestions/check
404
+ * Check whether AI suggestions exist for a review and return summary stats.
405
+ * Query params:
406
+ * - runId: specific analysis run ID. Default: latest run
407
+ */
408
+ router.get('/api/reviews/:reviewId/suggestions/check', validateReviewId, async (req, res) => {
409
+ try {
410
+ const { runId } = req.query;
411
+ const db = req.app.get('db');
412
+ const reviewId = req.reviewId;
413
+
414
+ // Check if any AI suggestions exist for this review
415
+ // Exclude raw council voice suggestions (is_raw=1) — only count final/consolidated suggestions
416
+ const result = await queryOne(db, `
417
+ SELECT EXISTS(
418
+ SELECT 1 FROM comments
419
+ WHERE review_id = ? AND source = 'ai' AND (is_raw = 0 OR is_raw IS NULL)
420
+ ) as has_suggestions
421
+ `, [reviewId]);
422
+
423
+ const hasSuggestions = result?.has_suggestions === 1;
424
+
425
+ // Check if any analysis has been run using analysis_runs table
426
+ let analysisHasRun = hasSuggestions;
427
+ const analysisRunRepo = new AnalysisRunRepository(db);
428
+ let selectedRun = null;
429
+ try {
430
+ // If runId is provided, fetch that specific run; otherwise get the latest
431
+ if (runId) {
432
+ selectedRun = await analysisRunRepo.getById(runId);
433
+ } else {
434
+ selectedRun = await analysisRunRepo.getLatestByReviewId(reviewId);
435
+ }
436
+ analysisHasRun = !!(selectedRun || hasSuggestions);
437
+ } catch (e) {
438
+ logger.debug('analysis_runs query failed, falling back to hasSuggestions:', e.message);
439
+ analysisHasRun = hasSuggestions;
440
+ }
441
+
442
+ // Get AI summary from the selected analysis run if available, otherwise fall back to review summary
443
+ const summary = selectedRun?.summary || req.review?.summary || null;
444
+
445
+ // Get stats for AI suggestions (issues/suggestions/praise for final level only)
446
+ // Filter by runId if provided, otherwise use the latest analysis run
447
+ let stats = { issues: 0, suggestions: 0, praise: 0 };
448
+ if (hasSuggestions) {
449
+ try {
450
+ const statsQuery = getStatsQuery(runId);
451
+ const statsResult = await query(db, statsQuery.query, statsQuery.params(reviewId));
452
+ stats = calculateStats(statsResult);
453
+ } catch (e) {
454
+ logger.warn('Error fetching AI suggestion stats:', e);
455
+ }
456
+ }
457
+
458
+ res.json({
459
+ hasSuggestions: hasSuggestions,
460
+ analysisHasRun: analysisHasRun,
461
+ summary: summary,
462
+ stats: stats
463
+ });
464
+ } catch (error) {
465
+ logger.error('Error checking for AI suggestions:', error);
466
+ res.status(500).json({
467
+ error: 'Failed to check for AI suggestions'
468
+ });
469
+ }
470
+ });
471
+
472
+ /**
473
+ * GET /api/reviews/:reviewId/suggestions
474
+ * Get AI suggestions for a review.
475
+ * Query params:
476
+ * - levels: comma-separated list of levels (e.g., 'final,1,2'). Default: 'final'
477
+ * - runId: specific analysis run ID. Default: latest run
478
+ */
479
+ router.get('/api/reviews/:reviewId/suggestions', validateReviewId, async (req, res) => {
480
+ try {
481
+ const db = req.app.get('db');
482
+ const reviewId = req.reviewId;
483
+
484
+ // Parse levels query parameter (e.g., ?levels=final,1,2)
485
+ // Default to 'final' (orchestrated suggestions only) if not specified
486
+ const levelsParam = req.query.levels || 'final';
487
+ const requestedLevels = levelsParam.split(',').map(l => l.trim());
488
+
489
+ // Parse optional runId query parameter to fetch suggestions from a specific analysis run
490
+ // If not provided, defaults to the latest run
491
+ const runIdParam = req.query.runId;
492
+
493
+ // Build level filter clause
494
+ const levelConditions = [];
495
+ requestedLevels.forEach(level => {
496
+ if (level === 'final') {
497
+ levelConditions.push('ai_level IS NULL');
498
+ } else if (['1', '2', '3'].includes(level)) {
499
+ levelConditions.push(`ai_level = ${parseInt(level)}`);
500
+ }
501
+ });
502
+
503
+ // If no valid levels specified, default to final
504
+ const levelFilter = levelConditions.length > 0
505
+ ? `(${levelConditions.join(' OR ')})`
506
+ : 'ai_level IS NULL';
507
+
508
+ // Build the run ID filter clause
509
+ // If a specific runId is provided, use it directly; otherwise use subquery for latest
510
+ let runIdFilter;
511
+ let queryParams;
512
+ if (runIdParam) {
513
+ runIdFilter = 'ai_run_id = ?';
514
+ queryParams = [reviewId, runIdParam];
515
+ } else {
516
+ // Get AI suggestions from the comments table
517
+ // Only return suggestions from the latest analysis run (ai_run_id)
518
+ // This preserves history while showing only the most recent results
519
+ //
520
+ // Note: If no AI suggestions exist (subquery returns NULL), the ai_run_id = NULL
521
+ // comparison returns no rows. This is intentional - we only show suggestions
522
+ // when there's a matching analysis run.
523
+ //
524
+ // Note: reviewId is passed twice because SQLite requires separate parameters
525
+ // for the outer WHERE clause and the subquery. A CTE could consolidate this but
526
+ // adds complexity without meaningful benefit here.
527
+ runIdFilter = `ai_run_id = (
528
+ SELECT ai_run_id FROM comments
529
+ WHERE review_id = ? AND source = 'ai' AND ai_run_id IS NOT NULL
530
+ ORDER BY created_at DESC
531
+ LIMIT 1
532
+ )`;
533
+ queryParams = [reviewId, reviewId];
534
+ }
535
+
536
+ const rows = await query(db, `
537
+ SELECT
538
+ id,
539
+ source,
540
+ author,
541
+ ai_run_id,
542
+ ai_level,
543
+ ai_confidence,
544
+ file,
545
+ line_start,
546
+ line_end,
547
+ side,
548
+ type,
549
+ title,
550
+ body,
551
+ reasoning,
552
+ status,
553
+ is_file_level,
554
+ created_at,
555
+ updated_at
556
+ FROM comments
557
+ WHERE review_id = ?
558
+ AND source = 'ai'
559
+ AND ${levelFilter}
560
+ AND status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')
561
+ AND (is_raw = 0 OR is_raw IS NULL)
562
+ AND ${runIdFilter}
563
+ ORDER BY
564
+ CASE
565
+ WHEN ai_level IS NULL THEN 0
566
+ WHEN ai_level = 1 THEN 1
567
+ WHEN ai_level = 2 THEN 2
568
+ WHEN ai_level = 3 THEN 3
569
+ ELSE 4
570
+ END,
571
+ is_file_level DESC,
572
+ file,
573
+ line_start
574
+ `, queryParams);
575
+
576
+ const suggestions = rows.map(row => ({
577
+ ...row,
578
+ reasoning: row.reasoning ? JSON.parse(row.reasoning) : null
579
+ }));
580
+
581
+ res.json({ suggestions });
582
+
583
+ } catch (error) {
584
+ logger.error('Error fetching AI suggestions:', error);
585
+ res.status(500).json({
586
+ error: 'Failed to fetch AI suggestions'
587
+ });
588
+ }
589
+ });
590
+
591
+ /**
592
+ * POST /api/reviews/:reviewId/suggestions/:id/status
593
+ * Update AI suggestion status (dismiss/restore).
594
+ * Note: "adopted" is not allowed here — use the /edit endpoint instead.
595
+ */
596
+ router.post('/api/reviews/:reviewId/suggestions/:id/status', validateReviewId, async (req, res) => {
597
+ try {
598
+ const { id } = req.params;
599
+ const { status } = req.body;
600
+
601
+ if (!['dismissed', 'active'].includes(status)) {
602
+ if (status === 'adopted') {
603
+ return res.status(400).json({
604
+ error: 'Cannot set status to \'adopted\' directly. Use POST /suggestions/:id/adopt for adopt-as-is or POST /suggestions/:id/edit for adopt-with-edits.'
605
+ });
606
+ }
607
+ return res.status(400).json({
608
+ error: 'Invalid status. Must be "dismissed" or "active"'
609
+ });
610
+ }
611
+
612
+ const db = req.app.get('db');
613
+ const commentRepo = new CommentRepository(db);
614
+
615
+ // Get the suggestion
616
+ const suggestion = await commentRepo.getComment(id, 'ai');
617
+
618
+ if (!suggestion) {
619
+ return res.status(404).json({
620
+ error: 'AI suggestion not found'
621
+ });
622
+ }
623
+
624
+ // Verify suggestion belongs to this review
625
+ if (suggestion.review_id !== req.reviewId) {
626
+ return res.status(403).json({
627
+ error: 'Suggestion does not belong to this review'
628
+ });
629
+ }
630
+
631
+ // Update suggestion status using repository
632
+ await commentRepo.updateSuggestionStatus(id, status);
633
+
634
+ res.json({
635
+ success: true,
636
+ status
637
+ });
638
+ broadcastReviewEvent(req.reviewId, { type: 'review:suggestions_changed' }, { sourceClientId: req.get('X-Client-Id') });
639
+
640
+ } catch (error) {
641
+ logger.error('Error updating suggestion status:', error);
642
+ res.status(500).json({
643
+ error: error.message || 'Failed to update suggestion status'
644
+ });
645
+ }
646
+ });
647
+
648
+ /**
649
+ * POST /api/reviews/:reviewId/suggestions/:id/adopt
650
+ * Adopt an AI suggestion as-is as a user comment.
651
+ *
652
+ * Atomically:
653
+ * 1. Reads the suggestion from the DB
654
+ * 2. Creates a user comment from the suggestion's body/type/title (with category prefix)
655
+ * 3. Sets parent_id linkage on the new comment
656
+ * 4. Sets suggestion status to 'adopted' in the DB
657
+ * 5. Returns the new userCommentId
658
+ *
659
+ * Why this exists: adoption must create a linked user comment via parent_id,
660
+ * which is why raw status-setting via POST /suggestions/:id/status cannot do it.
661
+ * Use this endpoint for adopt-as-is; use POST /suggestions/:id/edit for adopt-with-edits.
662
+ */
663
+ router.post('/api/reviews/:reviewId/suggestions/:id/adopt', validateReviewId, async (req, res) => {
664
+ try {
665
+ const { id } = req.params;
666
+ const db = req.app.get('db');
667
+ const commentRepo = new CommentRepository(db);
668
+
669
+ // Get the suggestion to validate it exists
670
+ const suggestion = await commentRepo.getComment(id, 'ai');
671
+
672
+ if (!suggestion) {
673
+ return res.status(404).json({
674
+ error: 'AI suggestion not found'
675
+ });
676
+ }
677
+
678
+ // Verify suggestion belongs to this review
679
+ if (suggestion.review_id !== req.reviewId) {
680
+ return res.status(403).json({
681
+ error: 'Suggestion does not belong to this review'
682
+ });
683
+ }
684
+
685
+ // Only active suggestions can be adopted
686
+ if (suggestion.status !== 'active') {
687
+ return res.status(400).json({
688
+ error: suggestion.status === 'adopted'
689
+ ? 'Suggestion has already been adopted'
690
+ : `Cannot adopt suggestion with status '${suggestion.status}'. Restore it to active first.`
691
+ });
692
+ }
693
+
694
+ // Format the body with category prefix (mirrors frontend formatAdoptedComment)
695
+ const formattedBody = formatAdoptedComment(suggestion.body, suggestion.type);
696
+
697
+ // Atomically adopt: create user comment and update suggestion status in one transaction
698
+ const userCommentId = await withTransaction(db, async () => {
699
+ const ucId = await commentRepo.adoptSuggestion(id, formattedBody);
700
+ await commentRepo.updateSuggestionStatus(id, 'adopted', ucId);
701
+ return ucId;
702
+ });
703
+
704
+ res.json({
705
+ success: true,
706
+ userCommentId,
707
+ message: 'Suggestion adopted as user comment'
708
+ });
709
+ broadcastReviewEvent(req.reviewId, { type: 'review:suggestions_changed' }, { sourceClientId: req.get('X-Client-Id') });
710
+ broadcastReviewEvent(req.reviewId, { type: 'review:comments_changed' }, { sourceClientId: req.get('X-Client-Id') });
711
+
712
+ } catch (error) {
713
+ logger.error('Error adopting suggestion:', error);
714
+ res.status(500).json({
715
+ error: error.message || 'Failed to adopt suggestion'
716
+ });
717
+ }
718
+ });
719
+
720
+ /**
721
+ * POST /api/reviews/:reviewId/suggestions/:id/edit
722
+ * Edit AI suggestion and adopt as user comment.
723
+ */
724
+ router.post('/api/reviews/:reviewId/suggestions/:id/edit', validateReviewId, async (req, res) => {
725
+ try {
726
+ const { id } = req.params;
727
+ const { editedText, action } = req.body;
728
+
729
+ if (action !== 'adopt_edited') {
730
+ return res.status(400).json({
731
+ error: 'Invalid action. Must be "adopt_edited"'
732
+ });
733
+ }
734
+
735
+ if (!editedText || !editedText.trim()) {
736
+ return res.status(400).json({
737
+ error: 'Edited text cannot be empty'
738
+ });
739
+ }
740
+
741
+ const db = req.app.get('db');
742
+ const commentRepo = new CommentRepository(db);
743
+
744
+ // Get the suggestion to validate it exists
745
+ const suggestion = await commentRepo.getComment(id, 'ai');
746
+
747
+ if (!suggestion) {
748
+ return res.status(404).json({
749
+ error: 'AI suggestion not found'
750
+ });
751
+ }
752
+
753
+ // Verify suggestion belongs to this review
754
+ if (suggestion.review_id !== req.reviewId) {
755
+ return res.status(403).json({
756
+ error: 'Suggestion does not belong to this review'
757
+ });
758
+ }
759
+
760
+ // Atomically adopt: create user comment and update suggestion status in one transaction
761
+ const userCommentId = await withTransaction(db, async () => {
762
+ const ucId = await commentRepo.adoptSuggestion(id, editedText);
763
+ await commentRepo.updateSuggestionStatus(id, 'adopted', ucId);
764
+ return ucId;
765
+ });
766
+
767
+ res.json({
768
+ success: true,
769
+ userCommentId,
770
+ message: 'Suggestion edited and adopted as user comment'
771
+ });
772
+ broadcastReviewEvent(req.reviewId, { type: 'review:suggestions_changed' }, { sourceClientId: req.get('X-Client-Id') });
773
+ broadcastReviewEvent(req.reviewId, { type: 'review:comments_changed' }, { sourceClientId: req.get('X-Client-Id') });
774
+
775
+ } catch (error) {
776
+ logger.error('Error editing suggestion:', error);
777
+ res.status(500).json({
778
+ error: error.message || 'Failed to edit suggestion'
779
+ });
780
+ }
781
+ });
782
+
783
+ // ==========================================================================
784
+ // Analysis Status Route
785
+ // ==========================================================================
786
+
787
+ /**
788
+ * GET /api/reviews/:reviewId/analyses/status
789
+ * Check if an analysis is running for a given review.
790
+ * Replaces both:
791
+ * - GET /api/pr/:owner/:repo/:number/analysis-status
792
+ * - GET /api/local/:reviewId/analysis-status
793
+ */
794
+ router.get('/api/reviews/:reviewId/analyses/status', validateReviewId, async (req, res) => {
795
+ try {
796
+ const reviewId = req.reviewId;
797
+
798
+ // 1. Check unified in-memory map
799
+ const analysisId = reviewToAnalysisId.get(reviewId);
800
+
801
+ if (analysisId) {
802
+ const analysis = activeAnalyses.get(analysisId);
803
+
804
+ if (analysis) {
805
+ return res.json({
806
+ running: true,
807
+ analysisId,
808
+ status: analysis
809
+ });
810
+ }
811
+
812
+ // Clean up stale mapping
813
+ reviewToAnalysisId.delete(reviewId);
814
+ }
815
+
816
+ // 2. Fall back to database — an analysis may have been started externally (e.g. via MCP)
817
+ const db = req.app.get('db');
818
+ const analysisRunRepo = new AnalysisRunRepository(db);
819
+ const latestRun = await analysisRunRepo.getLatestByReviewId(reviewId);
820
+
821
+ if (latestRun && latestRun.status === 'running') {
822
+ return res.json({
823
+ running: true,
824
+ analysisId: latestRun.id,
825
+ status: {
826
+ id: latestRun.id,
827
+ reviewId,
828
+ status: 'running',
829
+ startedAt: latestRun.started_at,
830
+ progress: 'Analysis in progress...',
831
+ levels: {
832
+ 1: { status: 'running', progress: 'Running...' },
833
+ 2: { status: 'running', progress: 'Running...' },
834
+ 3: { status: 'running', progress: 'Running...' },
835
+ 4: { status: 'pending', progress: 'Pending' }
836
+ },
837
+ filesAnalyzed: latestRun.files_analyzed || 0,
838
+ filesRemaining: 0
839
+ }
840
+ });
841
+ }
842
+
843
+ // 3. Not running
844
+ res.json({
845
+ running: false,
846
+ analysisId: null,
847
+ status: null
848
+ });
849
+
850
+ } catch (error) {
851
+ logger.error('Error checking review analysis status:', error);
852
+ res.status(500).json({
853
+ error: 'Failed to check analysis status'
854
+ });
855
+ }
856
+ });
857
+
858
+ // ==========================================================================
859
+ // Hunk Expansion Route
860
+ // ==========================================================================
861
+
862
+ /**
863
+ * POST /api/reviews/:reviewId/expand-hunk
864
+ * Broadcast a request to expand a hidden hunk in the diff view.
865
+ * This is a transient UI command — no database writes.
866
+ *
867
+ * Body: { file, line_start, line_end, side? }
868
+ * - file: (string, required) path of the file whose hunk to expand
869
+ * - line_start: (integer, required) first line to reveal (>= 1)
870
+ * - line_end: (integer, required) last line to reveal (>= line_start)
871
+ * - side: ('left' | 'right', optional, default 'right')
872
+ */
873
+ router.post('/api/reviews/:reviewId/expand-hunk', validateReviewId, async (req, res) => {
874
+ try {
875
+ const { file, line_start, line_end, side } = req.body;
876
+
877
+ // --- validation ---
878
+ if (!file || typeof file !== 'string') {
879
+ return res.status(400).json({ error: 'Missing or invalid required field: file' });
880
+ }
881
+
882
+ if (!Number.isInteger(line_start) || line_start < 1) {
883
+ return res.status(400).json({ error: 'Missing or invalid required field: line_start (must be a positive integer)' });
884
+ }
885
+
886
+ if (!Number.isInteger(line_end) || line_end < line_start) {
887
+ return res.status(400).json({ error: 'Missing or invalid required field: line_end (must be an integer >= line_start)' });
888
+ }
889
+
890
+ const resolvedSide = side || 'right';
891
+ if (!['left', 'right'].includes(resolvedSide)) {
892
+ return res.status(400).json({ error: 'Invalid value for side: must be "left" or "right"' });
893
+ }
894
+
895
+ // --- broadcast ---
896
+ broadcastReviewEvent(req.reviewId, {
897
+ type: 'review:expand_hunk',
898
+ file,
899
+ line_start,
900
+ line_end,
901
+ side: resolvedSide
902
+ });
903
+
904
+ res.json({ success: true });
905
+ } catch (error) {
906
+ logger.error('Error broadcasting expand-hunk event:', error);
907
+ res.status(500).json({ error: 'Failed to broadcast expand-hunk event' });
908
+ }
909
+ });
910
+
911
+ /**
912
+ * GET /api/reviews/:reviewId/file-content/:fileName(*)
913
+ * Fetch file content for context expansion and context files.
914
+ * Replaces the legacy /api/file-content-original/ endpoint by using
915
+ * the review record to determine local vs PR mode.
916
+ */
917
+ router.get('/api/reviews/:reviewId/file-content/:fileName(*)', validateReviewId, async (req, res) => {
918
+ try {
919
+ const fileName = decodeURIComponent(req.params.fileName);
920
+ const review = req.review;
921
+ const db = req.app.get('db');
922
+
923
+ // Local mode: use local_path + local_head_sha
924
+ if (review.review_type === 'local' || review.local_path) {
925
+ const localPath = review.local_path;
926
+ if (!localPath) {
927
+ return res.status(404).json({ error: 'Local review missing path' });
928
+ }
929
+
930
+ const localHeadSha = review.local_head_sha;
931
+
932
+ // Try git show for HEAD version (correct line numbers for diff)
933
+ if (localHeadSha) {
934
+ try {
935
+ const git = simpleGit(localPath);
936
+ const content = await git.show([`${localHeadSha}:${fileName}`]);
937
+ const lines = content.split('\n');
938
+ return res.json({ fileName, lines, totalLines: lines.length });
939
+ } catch (gitError) {
940
+ logger.debug(`Could not read file ${fileName} from HEAD: ${gitError.message}, falling back to working directory`);
941
+ }
942
+ }
943
+
944
+ // Fallback: read from filesystem
945
+ const filePath = path.join(localPath, fileName);
946
+ try {
947
+ const realFilePath = await fs.realpath(filePath);
948
+ const realLocalPath = await fs.realpath(localPath);
949
+ if (!realFilePath.startsWith(realLocalPath + path.sep) && realFilePath !== realLocalPath) {
950
+ return res.status(403).json({ error: 'Access denied: path outside repository' });
951
+ }
952
+ const content = await fs.readFile(realFilePath, 'utf8');
953
+ const lines = content.split('\n');
954
+ return res.json({ fileName, lines, totalLines: lines.length });
955
+ } catch (fileError) {
956
+ if (fileError.code === 'ENOENT') {
957
+ return res.status(404).json({ error: 'File not found in local repository' });
958
+ } else if (fileError.code === 'EISDIR') {
959
+ return res.status(400).json({ error: 'Path is a directory, not a file' });
960
+ }
961
+ throw fileError;
962
+ }
963
+ }
964
+
965
+ // PR mode: use pr_number + repository to find worktree
966
+ const prNumber = review.pr_number;
967
+ const repository = review.repository;
968
+
969
+ if (!prNumber || !repository) {
970
+ return res.status(400).json({ error: 'Review missing PR metadata' });
971
+ }
972
+
973
+ const [owner, repo] = repository.split('/');
974
+ const worktreeManager = new GitWorktreeManager(db);
975
+ const worktreePath = await worktreeManager.getWorktreePath({ owner, repo, number: prNumber });
976
+
977
+ if (!await worktreeManager.worktreeExists({ owner, repo, number: prNumber })) {
978
+ return res.status(404).json({ error: 'Worktree not found for this PR. The PR may need to be reloaded.' });
979
+ }
980
+
981
+ // Get base_sha from stored PR data
982
+ const normalizedRepo = normalizeRepository(owner, repo);
983
+ const prRecord = await queryOne(db, `
984
+ SELECT pr_data FROM pr_metadata
985
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
986
+ `, [prNumber, normalizedRepo]);
987
+
988
+ let baseSha = null;
989
+ if (prRecord?.pr_data) {
990
+ try {
991
+ const prData = JSON.parse(prRecord.pr_data);
992
+ baseSha = prData.base_sha;
993
+ } catch (parseError) {
994
+ logger.warn('Could not parse pr_data for base_sha:', parseError.message);
995
+ }
996
+ }
997
+
998
+ // Try git show for BASE version (correct line numbers for diff)
999
+ if (baseSha) {
1000
+ try {
1001
+ const git = simpleGit(worktreePath);
1002
+ const content = await git.show([`${baseSha}:${fileName}`]);
1003
+ const lines = content.split('\n');
1004
+ return res.json({ fileName, lines, totalLines: lines.length });
1005
+ } catch (gitError) {
1006
+ logger.debug(`Could not read file ${fileName} from base commit: ${gitError.message}, falling back to HEAD`);
1007
+ }
1008
+ }
1009
+
1010
+ // Fallback: read from filesystem
1011
+ const filePath = path.join(worktreePath, fileName);
1012
+ try {
1013
+ const realFilePath = await fs.realpath(filePath);
1014
+ const realWorktreePath = await fs.realpath(worktreePath);
1015
+ if (!realFilePath.startsWith(realWorktreePath + path.sep) && realFilePath !== realWorktreePath) {
1016
+ return res.status(403).json({ error: 'Access denied: path outside repository' });
1017
+ }
1018
+ const content = await fs.readFile(realFilePath, 'utf8');
1019
+ const lines = content.split('\n');
1020
+ return res.json({ fileName, lines, totalLines: lines.length });
1021
+ } catch (fileError) {
1022
+ if (fileError.code === 'ENOENT') {
1023
+ return res.status(404).json({ error: 'File not found in worktree' });
1024
+ } else if (fileError.code === 'EISDIR') {
1025
+ return res.status(400).json({ error: 'Path is a directory, not a file' });
1026
+ }
1027
+ throw fileError;
1028
+ }
1029
+ } catch (error) {
1030
+ logger.error('Error retrieving file content:', error);
1031
+ res.status(500).json({ error: 'Internal server error while retrieving file content' });
1032
+ }
1033
+ });
1034
+
1035
+ module.exports = router;