@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
@@ -23,8 +23,9 @@ class CancellationError extends Error {
23
23
  // Store active analysis runs in memory for status tracking
24
24
  const activeAnalyses = new Map();
25
25
 
26
- // Store mapping of PR (owner/repo/number) to analysis ID for tracking
27
- const prToAnalysisId = new Map();
26
+ // Store mapping of integer reviewId to analysis UUID for tracking.
27
+ // Unified map: replaces the previous separate prToAnalysisId and localReviewToAnalysisId maps.
28
+ const reviewToAnalysisId = new Map();
28
29
 
29
30
  // Store SSE clients for real-time progress updates
30
31
  const progressClients = new Map();
@@ -37,9 +38,6 @@ const localReviewDiffs = new Map();
37
38
  // Maps analysisId -> Set of ChildProcess objects
38
39
  const activeProcesses = new Map();
39
40
 
40
- // Store mapping of local review key to analysis ID for tracking
41
- const localReviewToAnalysisId = new Map();
42
-
43
41
  // Store active review setup operations (concurrency guard)
44
42
  // Maps setupKey (e.g., "pr:owner/repo/123" or "local:/path") -> { setupId, promise }
45
43
  const activeSetups = new Map();
@@ -48,26 +46,6 @@ const activeSetups = new Map();
48
46
  // Maps setupId -> Set of response objects
49
47
  const setupProgressClients = new Map();
50
48
 
51
- /**
52
- * Generate a consistent PR key for mapping
53
- * @param {string} owner - Repository owner
54
- * @param {string} repo - Repository name
55
- * @param {number} prNumber - Pull request number
56
- * @returns {string} PR key in format "owner/repo/number"
57
- */
58
- function getPRKey(owner, repo, prNumber) {
59
- return `${owner}/${repo}/${prNumber}`;
60
- }
61
-
62
- /**
63
- * Generate a consistent key for local review mapping
64
- * @param {number} reviewId - Local review ID
65
- * @returns {string} Review key
66
- */
67
- function getLocalReviewKey(reviewId) {
68
- return `local/${reviewId}`;
69
- }
70
-
71
49
  /**
72
50
  * Get the model to use for AI analysis
73
51
  * Priority: CLI flag (PAIR_REVIEW_MODEL env var) > config.default_model > 'opus' default
@@ -447,15 +425,12 @@ function parseEnabledLevels(requestEnabledLevels, skipLevel3 = false) {
447
425
  module.exports = {
448
426
  CancellationError,
449
427
  activeAnalyses,
450
- prToAnalysisId,
451
- localReviewToAnalysisId,
428
+ reviewToAnalysisId,
452
429
  progressClients,
453
430
  localReviewDiffs,
454
431
  activeProcesses,
455
432
  activeSetups,
456
433
  setupProgressClients,
457
- getPRKey,
458
- getLocalReviewKey,
459
434
  getModel,
460
435
  determineCompletionInfo,
461
436
  broadcastProgress,
package/src/server.js CHANGED
@@ -9,6 +9,7 @@ const logger = require('./utils/logger');
9
9
 
10
10
  let db = null;
11
11
  let server = null;
12
+ let chatSessionManager = null;
12
13
 
13
14
  /**
14
15
  * Request logging middleware (disabled for cleaner output)
@@ -248,20 +249,29 @@ async function startServer(sharedDb = null) {
248
249
  // API routes - split into focused modules
249
250
  // Order matters: more specific routes must be mounted before general ones
250
251
  // to ensure proper route matching
251
- const analysisRoutes = require('./routes/analysis');
252
+ const analysisRoutes = require('./routes/analyses');
252
253
  const worktreesRoutes = require('./routes/worktrees');
253
- const commentsRoutes = require('./routes/comments');
254
+ const reviewsRoutes = require('./routes/reviews');
254
255
  const configRoutes = require('./routes/config');
255
256
  const prRoutes = require('./routes/pr');
256
257
  const localRoutes = require('./routes/local');
257
258
  const setupRoutes = require('./routes/setup');
258
259
  const mcpRoutes = require('./routes/mcp');
259
260
  const councilRoutes = require('./routes/councils');
261
+ const chatRoutes = require('./routes/chat');
262
+ const contextFilesRoutes = require('./routes/context-files');
263
+
264
+ // Initialize chat session manager
265
+ const ChatSessionManager = require('./chat/session-manager');
266
+ chatSessionManager = new ChatSessionManager(db);
267
+ app.chatSessionManager = chatSessionManager;
260
268
 
261
269
  // Mount specific routes first to ensure they match before general PR routes
270
+ app.use('/', chatRoutes);
262
271
  app.use('/', analysisRoutes);
263
272
  app.use('/', councilRoutes);
264
- app.use('/', commentsRoutes);
273
+ app.use('/', reviewsRoutes);
274
+ app.use('/', contextFilesRoutes);
265
275
  app.use('/', configRoutes);
266
276
  app.use('/', worktreesRoutes);
267
277
  app.use('/', localRoutes);
@@ -283,15 +293,18 @@ async function startServer(sharedDb = null) {
283
293
  // Find available port and start server
284
294
  const port = await findAvailablePort(app, config.port);
285
295
 
296
+ // Check provider availability before accepting requests so /api/config
297
+ // returns accurate pi_available on the very first request (avoids race
298
+ // condition where the frontend fetches config before the cache is populated)
299
+ const defaultProvider = config.default_provider || 'claude';
300
+ try {
301
+ await checkAllProviders(defaultProvider);
302
+ } catch (err) {
303
+ console.warn('Provider availability check failed:', err.message);
304
+ }
305
+
286
306
  server = app.listen(port, () => {
287
307
  console.log(`Server running on http://localhost:${port}`);
288
-
289
- // Check provider availability in background after server is listening
290
- // Use the configured default provider as priority (if set)
291
- const defaultProvider = config.default_provider || 'claude';
292
- checkAllProviders(defaultProvider).catch(err => {
293
- console.warn('Background provider availability check failed:', err.message);
294
- });
295
308
  });
296
309
 
297
310
  server.on('error', (error) => {
@@ -318,9 +331,18 @@ async function startServer(sharedDb = null) {
318
331
  /**
319
332
  * Graceful shutdown handler
320
333
  */
321
- function gracefulShutdown(signal) {
334
+ async function gracefulShutdown(signal) {
322
335
  console.log('\nServer shutting down...');
323
-
336
+
337
+ // Close all active chat sessions
338
+ if (chatSessionManager) {
339
+ try {
340
+ await chatSessionManager.closeAll();
341
+ } catch (error) {
342
+ console.error('Error closing chat sessions:', error.message);
343
+ }
344
+ }
345
+
324
346
  if (server) {
325
347
  server.close(() => {
326
348
  if (db) {
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Shared SSE client registry and review-scoped event broadcaster.
4
+ *
5
+ * All SSE connections (chat, analysis, etc.) share a single Set of
6
+ * Express response objects. broadcastReviewEvent sends review-level
7
+ * events (as opposed to session-level events handled in chat.js).
8
+ */
9
+
10
+ const logger = require('../utils/logger');
11
+
12
+ /**
13
+ * Connected SSE clients shared across all route modules.
14
+ * Each entry is an Express response object with an open SSE connection.
15
+ * @type {Set<import('express').Response>}
16
+ */
17
+ const sseClients = new Set();
18
+
19
+ /**
20
+ * Broadcast a review-scoped SSE event to all connected clients.
21
+ * Optionally includes a `sourceClientId` so the originating browser tab
22
+ * can recognise (and skip) its own echo.
23
+ *
24
+ * @param {number} reviewId - Review ID to include in the event
25
+ * @param {Object} payload - Event data (must include at minimum a `type` field)
26
+ * @param {Object} [options]
27
+ * @param {string} [options.sourceClientId] - Client ID of the tab that triggered the mutation
28
+ */
29
+ function broadcastReviewEvent(reviewId, payload, options = {}) {
30
+ const envelope = { ...payload, reviewId };
31
+ if (options.sourceClientId) {
32
+ envelope.sourceClientId = options.sourceClientId;
33
+ }
34
+ const data = JSON.stringify(envelope);
35
+ for (const client of sseClients) {
36
+ try {
37
+ client.write(`data: ${data}\n\n`);
38
+ } catch {
39
+ // Client disconnected — remove from set
40
+ sseClients.delete(client);
41
+ logger.debug('[ReviewEvents] Removed disconnected SSE client');
42
+ }
43
+ }
44
+ }
45
+
46
+ module.exports = { sseClients, broadcastReviewEvent };
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ const { getDiffFileList } = require('./diff-file-list');
3
+ const { ContextFileRepository } = require('../database');
4
+ const logger = require('./logger');
5
+
6
+ const LINE_PADDING = 10;
7
+ const FILE_COMMENT_DEFAULT_LINES = 50;
8
+ const MAX_RANGE = 500;
9
+
10
+ /**
11
+ * Ensure a context file entry exists for a comment that targets a file
12
+ * outside the review's diff. If the file IS in the diff, this is a no-op.
13
+ *
14
+ * @param {object} db - SQLite database handle
15
+ * @param {object} review - Review row from the database
16
+ * @param {object} opts
17
+ * @param {string} opts.file - File path the comment targets
18
+ * @param {number|null} opts.line_start - Start line (falsy for file-level comments)
19
+ * @param {number|null} opts.line_end - End line
20
+ * @returns {Promise<{created: boolean, expanded: boolean, contextFileId?: number}>}
21
+ */
22
+ async function ensureContextFileForComment(db, review, { file, line_start, line_end }) {
23
+ try {
24
+ // 1. If the file is already in the diff, nothing to do
25
+ const diffFiles = await getDiffFileList(db, review);
26
+ if (diffFiles.includes(file)) {
27
+ return { created: false, expanded: false };
28
+ }
29
+
30
+ // 2. Compute desired range
31
+ let desiredStart, desiredEnd;
32
+ if (line_start) {
33
+ desiredStart = Math.max(1, line_start - LINE_PADDING);
34
+ desiredEnd = (line_end ?? line_start) + LINE_PADDING;
35
+ } else {
36
+ desiredStart = 1;
37
+ desiredEnd = FILE_COMMENT_DEFAULT_LINES;
38
+ }
39
+
40
+ // 3. Clamp total range to MAX_RANGE
41
+ if (desiredEnd - desiredStart + 1 > MAX_RANGE) {
42
+ desiredEnd = desiredStart + MAX_RANGE - 1;
43
+ }
44
+
45
+ // 4. Look up existing context file entries for this file
46
+ const contextFileRepo = new ContextFileRepository(db);
47
+ const existing = await contextFileRepo.getByReviewIdAndFile(review.id, file);
48
+
49
+ if (existing.length > 0) {
50
+ // 5. Check if ANY existing entry already covers the desired range
51
+ const covering = existing.find(e => e.line_start <= desiredStart && e.line_end >= desiredEnd);
52
+ if (covering) {
53
+ return { created: false, expanded: false };
54
+ }
55
+
56
+ // 6. Find an entry that overlaps with the desired range
57
+ const overlapping = existing.find(e =>
58
+ e.line_start <= desiredEnd && e.line_end >= desiredStart
59
+ );
60
+
61
+ if (overlapping) {
62
+ // 7. Expand to the union of old and desired ranges
63
+ let newStart = Math.min(overlapping.line_start, desiredStart);
64
+ let newEnd = Math.max(overlapping.line_end, desiredEnd);
65
+
66
+ if (newEnd - newStart + 1 > MAX_RANGE) {
67
+ newEnd = newStart + MAX_RANGE - 1;
68
+ }
69
+
70
+ await contextFileRepo.updateRange(overlapping.id, newStart, newEnd);
71
+ return { created: false, expanded: true, contextFileId: overlapping.id };
72
+ }
73
+
74
+ // No overlapping entry — fall through to create a new one
75
+ }
76
+
77
+ // 8. No existing entry — create one
78
+ const inserted = await contextFileRepo.add(
79
+ review.id, file, desiredStart, desiredEnd, 'Auto-added for comment'
80
+ );
81
+ return { created: true, expanded: false, contextFileId: inserted.id };
82
+ } catch (err) {
83
+ logger.warn(`[AutoContext] Failed to ensure context file: ${err.message}`);
84
+ return { created: false, expanded: false };
85
+ }
86
+ }
87
+
88
+ module.exports = { ensureContextFileForComment };
@@ -0,0 +1,33 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ /**
3
+ * Canonical category-to-emoji mapping for AI suggestion types.
4
+ * Used by server-side code to format adopted comments.
5
+ *
6
+ * Canonical types from src/ai/prompts/shared/output-schema.js:
7
+ * bug|improvement|praise|suggestion|design|performance|security|code-style
8
+ */
9
+
10
+ const CATEGORY_EMOJI_MAP = {
11
+ 'bug': '\u{1F41B}', // bug
12
+ 'improvement': '\u{1F4A1}', // lightbulb
13
+ 'praise': '\u{2B50}', // star
14
+ 'suggestion': '\u{1F4AC}', // speech bubble
15
+ 'design': '\u{1F4D0}', // triangular ruler
16
+ 'performance': '\u{26A1}', // high voltage
17
+ 'security': '\u{1F512}', // lock
18
+ 'code-style': '\u{1F3A8}', // artist palette
19
+ 'style': '\u{1F3A8}' // artist palette (alias for code-style)
20
+ };
21
+
22
+ const DEFAULT_EMOJI = '\u{1F4AC}'; // speech bubble
23
+
24
+ /**
25
+ * Get emoji for a suggestion category
26
+ * @param {string} category - Category name
27
+ * @returns {string} Emoji character
28
+ */
29
+ function getEmoji(category) {
30
+ return CATEGORY_EMOJI_MAP[category] || DEFAULT_EMOJI;
31
+ }
32
+
33
+ module.exports = { CATEGORY_EMOJI_MAP, DEFAULT_EMOJI, getEmoji };
@@ -187,6 +187,7 @@ function annotateDiff(rawDiff) {
187
187
  }
188
188
 
189
189
  const lines = rawDiff.split('\n');
190
+ if (lines[lines.length - 1] === '') lines.pop();
190
191
  const output = [];
191
192
 
192
193
  let currentFile = {};
@@ -404,11 +405,84 @@ function parseAnnotatedDiff(annotatedDiff) {
404
405
  return files;
405
406
  }
406
407
 
408
+ /**
409
+ * Build a lookup of which file+side+line combinations appear in diff hunks.
410
+ * Used to determine whether a comment targets a line GitHub can render inline
411
+ * (inside a hunk) vs. one that must be submitted as file-level.
412
+ *
413
+ * @param {string} rawDiff - Raw unified diff from git
414
+ * @returns {{ isLineInDiff: (file: string, line: number, side?: string) => boolean }}
415
+ */
416
+ function buildDiffLineSet(rawDiff) {
417
+ if (!rawDiff || rawDiff.trim() === '') {
418
+ return { isLineInDiff: () => false };
419
+ }
420
+
421
+ const entries = new Set();
422
+ const diffLines = rawDiff.split('\n');
423
+ // Trim trailing empty element produced by split('\n') on newline-terminated input.
424
+ // Without this, the empty string matches the context-line branch and adds phantom
425
+ // entries for lines beyond the actual hunk boundary.
426
+ if (diffLines[diffLines.length - 1] === '') diffLines.pop();
427
+ let currentFile = {};
428
+ let oldLineNum = 0;
429
+ let newLineNum = 0;
430
+ let inHunk = false;
431
+
432
+ for (const line of diffLines) {
433
+ if (line.startsWith('diff --git')) {
434
+ currentFile = {};
435
+ inHunk = false;
436
+ parseFileHeader(line, currentFile);
437
+ continue;
438
+ }
439
+
440
+ if (parseFileHeader(line, currentFile)) {
441
+ continue;
442
+ }
443
+
444
+ const hunkInfo = parseHunkHeader(line);
445
+ if (hunkInfo) {
446
+ oldLineNum = hunkInfo.oldStart;
447
+ newLineNum = hunkInfo.newStart;
448
+ inHunk = true;
449
+ continue;
450
+ }
451
+
452
+ if (!inHunk) continue;
453
+
454
+ if (line.startsWith('\\ No newline')) continue;
455
+
456
+ const filePath = currentFile.newPath || currentFile.oldPath;
457
+ if (!filePath) continue;
458
+
459
+ if (line.startsWith('+')) {
460
+ entries.add(`${filePath}:RIGHT:${newLineNum}`);
461
+ newLineNum++;
462
+ } else if (line.startsWith('-')) {
463
+ entries.add(`${filePath}:LEFT:${oldLineNum}`);
464
+ oldLineNum++;
465
+ } else if (line.startsWith(' ') || line === '') {
466
+ entries.add(`${filePath}:LEFT:${oldLineNum}`);
467
+ entries.add(`${filePath}:RIGHT:${newLineNum}`);
468
+ oldLineNum++;
469
+ newLineNum++;
470
+ }
471
+ }
472
+
473
+ return {
474
+ isLineInDiff(file, lineNum, side = 'RIGHT') {
475
+ return entries.has(`${file}:${side}:${lineNum}`);
476
+ }
477
+ };
478
+ }
479
+
407
480
  module.exports = {
408
481
  annotateDiff,
409
482
  parseAnnotatedDiff,
410
483
  parseHunkHeader,
411
484
  formatLineNum,
412
485
  getLineMarker,
413
- getLineContent
486
+ getLineContent,
487
+ buildDiffLineSet
414
488
  };
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ const { promisify } = require('util');
3
+ const { exec } = require('child_process');
4
+ const { queryOne } = require('../database');
5
+
6
+ const execPromise = promisify(exec);
7
+
8
+ /**
9
+ * Return the list of file paths that belong to the review's diff.
10
+ * Works for both PR-mode and local-mode reviews.
11
+ *
12
+ * @param {object} db - SQLite database handle
13
+ * @param {object} review - Review row from the database
14
+ * @returns {Promise<string[]>} Array of relative file paths in the diff
15
+ */
16
+ async function getDiffFileList(db, review) {
17
+ // PR mode – pull from pr_metadata table
18
+ if (review.pr_number && review.repository) {
19
+ try {
20
+ const prRecord = await queryOne(db, `
21
+ SELECT pr_data FROM pr_metadata
22
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
23
+ `, [review.pr_number, review.repository]);
24
+
25
+ if (prRecord?.pr_data) {
26
+ const prData = JSON.parse(prRecord.pr_data);
27
+ return (prData.changed_files || []).map(f => f.file);
28
+ }
29
+ } catch {
30
+ // parse / query error – fall through to empty list
31
+ }
32
+ return [];
33
+ }
34
+
35
+ // Local mode – ask git for changed / untracked files
36
+ if (review.local_path) {
37
+ try {
38
+ const opts = { cwd: review.local_path };
39
+ const [{ stdout: unstaged }, { stdout: untracked }] = await Promise.all([
40
+ execPromise('git diff --name-only', opts),
41
+ execPromise('git ls-files --others --exclude-standard', opts),
42
+ ]);
43
+ const combined = `${unstaged}\n${untracked}`
44
+ .split('\n')
45
+ .map(l => l.trim())
46
+ .filter(Boolean);
47
+ return [...new Set(combined)];
48
+ } catch {
49
+ // git error – fall through to empty list
50
+ }
51
+ return [];
52
+ }
53
+
54
+ return [];
55
+ }
56
+
57
+ module.exports = { getDiffFileList };