@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
@@ -37,7 +37,10 @@ router.get('/api/config', (req, res) => {
37
37
  theme: config.theme || 'light',
38
38
  comment_button_action: config.comment_button_action || 'submit',
39
39
  // Include npx detection for frontend command examples
40
- is_running_via_npx: isRunningViaNpx()
40
+ is_running_via_npx: isRunningViaNpx(),
41
+ enable_chat: config.enable_chat !== false,
42
+ chat_enable_shortcuts: config.chat?.enable_shortcuts !== false,
43
+ pi_available: getCachedAvailability('pi')?.available || false
41
44
  });
42
45
  });
43
46
 
@@ -47,7 +50,7 @@ router.get('/api/config', (req, res) => {
47
50
  */
48
51
  router.patch('/api/config', async (req, res) => {
49
52
  try {
50
- const { comment_button_action } = req.body;
53
+ const { comment_button_action, chat_enable_shortcuts } = req.body;
51
54
 
52
55
  // Validate comment_button_action if provided
53
56
  if (comment_button_action !== undefined) {
@@ -58,6 +61,14 @@ router.patch('/api/config', async (req, res) => {
58
61
  }
59
62
  }
60
63
 
64
+ if (chat_enable_shortcuts !== undefined) {
65
+ if (typeof chat_enable_shortcuts !== 'boolean') {
66
+ return res.status(400).json({
67
+ error: 'Invalid chat_enable_shortcuts. Must be a boolean'
68
+ });
69
+ }
70
+ }
71
+
61
72
  // Get current config
62
73
  const config = req.app.get('config') || {};
63
74
 
@@ -66,6 +77,11 @@ router.patch('/api/config', async (req, res) => {
66
77
  config.comment_button_action = comment_button_action;
67
78
  }
68
79
 
80
+ if (chat_enable_shortcuts !== undefined) {
81
+ if (!config.chat) config.chat = {};
82
+ config.chat.enable_shortcuts = chat_enable_shortcuts;
83
+ }
84
+
69
85
  // Save config to file
70
86
  const { saveConfig } = require('../config');
71
87
  await saveConfig(config);
@@ -77,7 +93,8 @@ router.patch('/api/config', async (req, res) => {
77
93
  success: true,
78
94
  config: {
79
95
  theme: config.theme || 'light',
80
- comment_button_action: config.comment_button_action || 'submit'
96
+ comment_button_action: config.comment_button_action || 'submit',
97
+ chat_enable_shortcuts: config.chat?.enable_shortcuts !== false
81
98
  }
82
99
  });
83
100
 
@@ -111,7 +128,8 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
111
128
  default_model: null,
112
129
  local_path: null,
113
130
  default_council_id: null,
114
- default_tab: null
131
+ default_tab: null,
132
+ default_chat_instructions: null
115
133
  });
116
134
  }
117
135
 
@@ -123,6 +141,7 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
123
141
  local_path: settings.local_path,
124
142
  default_council_id: settings.default_council_id,
125
143
  default_tab: settings.default_tab,
144
+ default_chat_instructions: settings.default_chat_instructions,
126
145
  created_at: settings.created_at,
127
146
  updated_at: settings.updated_at
128
147
  });
@@ -142,14 +161,14 @@ router.get('/api/repos/:owner/:repo/settings', async (req, res) => {
142
161
  router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
143
162
  try {
144
163
  const { owner, repo } = req.params;
145
- const { default_instructions, default_provider, default_model, local_path, default_council_id, default_tab } = req.body;
164
+ const { default_instructions, default_provider, default_model, local_path, default_council_id, default_tab, default_chat_instructions } = req.body;
146
165
  const repository = normalizeRepository(owner, repo);
147
166
  const db = req.app.get('db');
148
167
 
149
168
  // Validate that at least one setting is provided
150
- if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined && default_council_id === undefined && default_tab === undefined) {
169
+ if (default_instructions === undefined && default_provider === undefined && default_model === undefined && local_path === undefined && default_council_id === undefined && default_tab === undefined && default_chat_instructions === undefined) {
151
170
  return res.status(400).json({
152
- error: 'At least one setting (default_instructions, default_provider, default_model, local_path, default_council_id, or default_tab) must be provided'
171
+ error: 'At least one setting (default_instructions, default_provider, default_model, local_path, default_council_id, default_tab, or default_chat_instructions) must be provided'
153
172
  });
154
173
  }
155
174
 
@@ -160,7 +179,8 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
160
179
  default_model,
161
180
  local_path,
162
181
  default_council_id,
163
- default_tab
182
+ default_tab,
183
+ default_chat_instructions
164
184
  });
165
185
 
166
186
  logger.info(`Saved repo settings for ${repository}`);
@@ -175,6 +195,7 @@ router.post('/api/repos/:owner/:repo/settings', async (req, res) => {
175
195
  local_path: settings.local_path,
176
196
  default_council_id: settings.default_council_id,
177
197
  default_tab: settings.default_tab,
198
+ default_chat_instructions: settings.default_chat_instructions,
178
199
  updated_at: settings.updated_at
179
200
  }
180
201
  });
@@ -0,0 +1,274 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Context Files Routes
4
+ *
5
+ * Provides endpoints for managing context file ranges that pin specific
6
+ * line ranges from non-diff files into the diff panel for review.
7
+ *
8
+ * All endpoints live under /api/reviews/:reviewId/context-files
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const express = require('express');
14
+ const { ReviewRepository, ContextFileRepository, WorktreeRepository } = require('../database');
15
+ const logger = require('../utils/logger');
16
+ const { broadcastReviewEvent } = require('../sse/review-events');
17
+ const { getDiffFileList } = require('../utils/diff-file-list');
18
+
19
+ const router = express.Router();
20
+
21
+ /**
22
+ * Resolve the repository root directory for a review.
23
+ * Local reviews use local_path; PR reviews look up the worktree path.
24
+ * Returns null when the root cannot be determined (e.g. worktree not set up).
25
+ *
26
+ * @param {object} db - SQLite database handle
27
+ * @param {object} review - Review row from the database
28
+ * @returns {Promise<string|null>} Absolute path to the repo root, or null
29
+ */
30
+ async function resolveRepoRoot(db, review) {
31
+ // Local mode – the path is stored directly on the review record
32
+ if (review.local_path) {
33
+ return review.local_path;
34
+ }
35
+
36
+ // PR mode – look up the worktree record
37
+ if (review.pr_number && review.repository) {
38
+ const worktreeRepo = new WorktreeRepository(db);
39
+ const worktree = await worktreeRepo.findByPR(review.pr_number, review.repository);
40
+ if (worktree && worktree.path) {
41
+ return worktree.path;
42
+ }
43
+ }
44
+
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Middleware: validate that :reviewId exists in the reviews table.
50
+ * Attaches the review record to req.review for downstream handlers.
51
+ */
52
+ async function validateReviewId(req, res, next) {
53
+ try {
54
+ const reviewId = parseInt(req.params.reviewId, 10);
55
+
56
+ if (isNaN(reviewId) || reviewId <= 0) {
57
+ return res.status(400).json({ error: 'Invalid review ID' });
58
+ }
59
+
60
+ const db = req.app.get('db');
61
+ const reviewRepo = new ReviewRepository(db);
62
+ const review = await reviewRepo.getReview(reviewId);
63
+
64
+ if (!review) {
65
+ return res.status(404).json({ error: `Review #${reviewId} not found` });
66
+ }
67
+
68
+ req.review = review;
69
+ req.reviewId = reviewId;
70
+ next();
71
+ } catch (error) {
72
+ next(error);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * POST /api/reviews/:reviewId/context-files
78
+ * Add a context file range for a review.
79
+ * Body: { file, line_start, line_end, label? }
80
+ */
81
+ router.post('/api/reviews/:reviewId/context-files', validateReviewId, async (req, res) => {
82
+ try {
83
+ const { file, line_start, line_end, label } = req.body;
84
+
85
+ // Validate: file is required and non-empty string
86
+ if (!file || typeof file !== 'string' || file.trim().length === 0) {
87
+ return res.status(400).json({ error: 'file is required and must be a non-empty string' });
88
+ }
89
+
90
+ if (file.includes('..') || file.startsWith('/')) {
91
+ return res.status(400).json({ error: 'file must be a relative path without .. segments' });
92
+ }
93
+
94
+ // Validate: line_start and line_end are positive integers
95
+ const lineStart = parseInt(line_start, 10);
96
+ const lineEnd = parseInt(line_end, 10);
97
+
98
+ if (isNaN(lineStart) || lineStart <= 0) {
99
+ return res.status(400).json({ error: 'line_start must be a positive integer' });
100
+ }
101
+
102
+ if (isNaN(lineEnd) || lineEnd <= 0) {
103
+ return res.status(400).json({ error: 'line_end must be a positive integer' });
104
+ }
105
+
106
+ // Validate: line_end >= line_start
107
+ if (lineEnd < lineStart) {
108
+ return res.status(400).json({ error: 'line_end must be >= line_start' });
109
+ }
110
+
111
+ // Validate: max range of 500 lines
112
+ if (lineEnd - lineStart + 1 > 500) {
113
+ return res.status(400).json({ error: 'Range cannot exceed 500 lines' });
114
+ }
115
+
116
+ const db = req.app.get('db');
117
+
118
+ // Reject files that are already part of the review's diff
119
+ const diffFiles = await getDiffFileList(db, req.review);
120
+ if (diffFiles.includes(file.trim())) {
121
+ return res.status(400).json({
122
+ error: `Cannot add context file: '${file.trim()}' is already part of the diff`
123
+ });
124
+ }
125
+
126
+ // Validate that the file exists on disk when we can resolve the repo root
127
+ const repoRoot = await resolveRepoRoot(db, req.review);
128
+ if (repoRoot) {
129
+ const resolved = path.resolve(repoRoot, file.trim());
130
+ // Double-check the resolved path is still within the repo root (belt-and-suspenders with the .. check above)
131
+ if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) {
132
+ return res.status(400).json({ error: 'file must be a relative path without .. segments' });
133
+ }
134
+ if (!fs.existsSync(resolved)) {
135
+ return res.status(400).json({ error: 'File not found in repository' });
136
+ }
137
+ }
138
+
139
+ const contextFileRepo = new ContextFileRepository(db);
140
+
141
+ const record = await contextFileRepo.add(
142
+ req.reviewId,
143
+ file.trim(),
144
+ lineStart,
145
+ lineEnd,
146
+ label || null
147
+ );
148
+
149
+ res.status(201).json({ success: true, contextFile: record });
150
+ broadcastReviewEvent(req.reviewId, { type: 'review:context_files_changed' }, { sourceClientId: req.get('X-Client-Id') });
151
+ } catch (error) {
152
+ logger.error('Error adding context file:', error);
153
+ res.status(500).json({ error: 'Failed to add context file' });
154
+ }
155
+ });
156
+
157
+ /**
158
+ * GET /api/reviews/:reviewId/context-files
159
+ * List all context file ranges for a review.
160
+ */
161
+ router.get('/api/reviews/:reviewId/context-files', validateReviewId, async (req, res) => {
162
+ try {
163
+ const db = req.app.get('db');
164
+ const contextFileRepo = new ContextFileRepository(db);
165
+
166
+ const contextFiles = await contextFileRepo.getByReviewId(req.reviewId);
167
+
168
+ res.json({ success: true, contextFiles: contextFiles || [] });
169
+ } catch (error) {
170
+ logger.error('Error fetching context files:', error);
171
+ res.status(500).json({ error: 'Failed to fetch context files' });
172
+ }
173
+ });
174
+
175
+ /**
176
+ * PATCH /api/reviews/:reviewId/context-files/:id
177
+ * Update the line range of an existing context file entry.
178
+ * Body: { line_start, line_end }
179
+ */
180
+ router.patch('/api/reviews/:reviewId/context-files/:id', validateReviewId, async (req, res) => {
181
+ try {
182
+ const id = parseInt(req.params.id, 10);
183
+
184
+ if (isNaN(id) || id <= 0) {
185
+ return res.status(400).json({ error: 'Invalid context file ID' });
186
+ }
187
+
188
+ const { line_start, line_end } = req.body;
189
+
190
+ const lineStart = parseInt(line_start, 10);
191
+ const lineEnd = parseInt(line_end, 10);
192
+
193
+ if (isNaN(lineStart) || lineStart <= 0) {
194
+ return res.status(400).json({ error: 'line_start must be a positive integer' });
195
+ }
196
+
197
+ if (isNaN(lineEnd) || lineEnd <= 0) {
198
+ return res.status(400).json({ error: 'line_end must be a positive integer' });
199
+ }
200
+
201
+ if (lineEnd < lineStart) {
202
+ return res.status(400).json({ error: 'line_end must be >= line_start' });
203
+ }
204
+
205
+ if (lineEnd - lineStart + 1 > 500) {
206
+ return res.status(400).json({ error: 'Range cannot exceed 500 lines' });
207
+ }
208
+
209
+ const db = req.app.get('db');
210
+ const contextFileRepo = new ContextFileRepository(db);
211
+
212
+ const updated = await contextFileRepo.updateRange(id, lineStart, lineEnd);
213
+
214
+ if (!updated) {
215
+ return res.status(404).json({ error: 'Context file not found' });
216
+ }
217
+
218
+ res.json({ success: true });
219
+ broadcastReviewEvent(req.reviewId, { type: 'review:context_files_changed' }, { sourceClientId: req.get('X-Client-Id') });
220
+ } catch (error) {
221
+ logger.error('Error updating context file range:', error);
222
+ res.status(500).json({ error: 'Failed to update context file range' });
223
+ }
224
+ });
225
+
226
+ /**
227
+ * DELETE /api/reviews/:reviewId/context-files/:id
228
+ * Remove a single context file range by ID.
229
+ */
230
+ router.delete('/api/reviews/:reviewId/context-files/:id', validateReviewId, async (req, res) => {
231
+ try {
232
+ const id = parseInt(req.params.id, 10);
233
+
234
+ if (isNaN(id) || id <= 0) {
235
+ return res.status(400).json({ error: 'Invalid context file ID' });
236
+ }
237
+
238
+ const db = req.app.get('db');
239
+ const contextFileRepo = new ContextFileRepository(db);
240
+
241
+ const deleted = await contextFileRepo.remove(id, req.reviewId);
242
+
243
+ if (!deleted) {
244
+ return res.status(404).json({ error: 'Context file not found' });
245
+ }
246
+
247
+ res.json({ success: true, message: 'Context file removed' });
248
+ broadcastReviewEvent(req.reviewId, { type: 'review:context_files_changed' }, { sourceClientId: req.get('X-Client-Id') });
249
+ } catch (error) {
250
+ logger.error('Error removing context file:', error);
251
+ res.status(500).json({ error: 'Failed to remove context file' });
252
+ }
253
+ });
254
+
255
+ /**
256
+ * DELETE /api/reviews/:reviewId/context-files
257
+ * Remove all context file ranges for a review.
258
+ */
259
+ router.delete('/api/reviews/:reviewId/context-files', validateReviewId, async (req, res) => {
260
+ try {
261
+ const db = req.app.get('db');
262
+ const contextFileRepo = new ContextFileRepository(db);
263
+
264
+ const deletedCount = await contextFileRepo.removeAll(req.reviewId);
265
+
266
+ res.json({ success: true, deletedCount, message: `Removed ${deletedCount} context file${deletedCount !== 1 ? 's' : ''}` });
267
+ broadcastReviewEvent(req.reviewId, { type: 'review:context_files_changed' }, { sourceClientId: req.get('X-Client-Id') });
268
+ } catch (error) {
269
+ logger.error('Error removing all context files:', error);
270
+ res.status(500).json({ error: 'Failed to remove context files' });
271
+ }
272
+ });
273
+
274
+ module.exports = router;