@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.
- package/README.md +77 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/review-requests/SKILL.md +4 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/SKILL.md +4 -3
- package/public/css/pr.css +1962 -114
- package/public/js/CONVENTIONS.md +16 -0
- package/public/js/components/AIPanel.js +66 -0
- package/public/js/components/AnalysisConfigModal.js +2 -2
- package/public/js/components/ChatPanel.js +2955 -0
- package/public/js/components/CouncilProgressModal.js +12 -16
- package/public/js/components/KeyboardShortcuts.js +3 -0
- package/public/js/components/PanelGroup.js +723 -0
- package/public/js/components/PreviewModal.js +3 -8
- package/public/js/index.js +8 -0
- package/public/js/local.js +17 -615
- package/public/js/modules/analysis-history.js +19 -68
- package/public/js/modules/comment-manager.js +103 -20
- package/public/js/modules/diff-context.js +176 -0
- package/public/js/modules/diff-renderer.js +30 -0
- package/public/js/modules/file-comment-manager.js +126 -105
- package/public/js/modules/file-list-merger.js +64 -0
- package/public/js/modules/panel-resizer.js +25 -6
- package/public/js/modules/suggestion-manager.js +40 -125
- package/public/js/pr.js +1009 -159
- package/public/js/repo-settings.js +36 -6
- package/public/js/utils/category-emoji.js +44 -0
- package/public/js/utils/time.js +32 -0
- package/public/local.html +107 -70
- package/public/pr.html +107 -70
- package/public/repo-settings.html +32 -0
- package/src/ai/analyzer.js +5 -1
- package/src/ai/copilot-provider.js +39 -9
- package/src/ai/cursor-agent-provider.js +45 -11
- package/src/ai/gemini-provider.js +17 -4
- package/src/ai/prompts/config.js +7 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +25 -37
- package/src/chat/CONVENTIONS.md +18 -0
- package/src/chat/pi-bridge.js +491 -0
- package/src/chat/prompt-builder.js +272 -0
- package/src/chat/session-manager.js +619 -0
- package/src/config.js +14 -0
- package/src/database.js +322 -15
- package/src/main.js +4 -17
- package/src/routes/analyses.js +721 -0
- package/src/routes/chat.js +655 -0
- package/src/routes/config.js +29 -8
- package/src/routes/context-files.js +274 -0
- package/src/routes/local.js +225 -1133
- package/src/routes/mcp.js +39 -30
- package/src/routes/pr.js +424 -58
- package/src/routes/reviews.js +1035 -0
- package/src/routes/shared.js +4 -29
- package/src/server.js +34 -12
- package/src/sse/review-events.js +46 -0
- package/src/utils/auto-context.js +88 -0
- package/src/utils/category-emoji.js +33 -0
- package/src/utils/diff-annotator.js +75 -1
- package/src/utils/diff-file-list.js +57 -0
- package/src/routes/analysis.js +0 -1600
- 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;
|