@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
@@ -15,21 +15,21 @@
15
15
  const express = require('express');
16
16
  const path = require('path');
17
17
  const fs = require('fs').promises;
18
- const { query, queryOne, run, ReviewRepository, RepoSettingsRepository, CommentRepository, AnalysisRunRepository } = require('../database');
18
+ const { queryOne, run, ReviewRepository, RepoSettingsRepository, AnalysisRunRepository, CouncilRepository } = require('../database');
19
19
  const Analyzer = require('../ai/analyzer');
20
20
  const { v4: uuidv4 } = require('uuid');
21
21
  const logger = require('../utils/logger');
22
+ const { broadcastReviewEvent } = require('../sse/review-events');
22
23
  const { mergeInstructions } = require('../utils/instructions');
23
- const { calculateStats, getStatsQuery } = require('../utils/stats-calculator');
24
24
  const { generateLocalDiff, computeLocalDiffDigest } = require('../local-review');
25
25
  const { getGeneratedFilePatterns } = require('../git/gitattributes');
26
+ const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
27
+ const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
26
28
  const {
27
29
  activeAnalyses,
28
- progressClients,
29
30
  localReviewDiffs,
30
- localReviewToAnalysisId,
31
+ reviewToAnalysisId,
31
32
  getModel,
32
- getLocalReviewKey,
33
33
  determineCompletionInfo,
34
34
  broadcastProgress,
35
35
  CancellationError,
@@ -39,6 +39,27 @@ const {
39
39
 
40
40
  const router = express.Router();
41
41
 
42
+ // ---------------------------------------------------------------------------
43
+ // Helpers – type-safe wrappers around localReviewDiffs Map
44
+ // JavaScript Maps use strict equality for keys. reviewId values arrive from
45
+ // req.params as strings, but every other code path stores them as integers.
46
+ // These helpers coerce once so callers never hit a string/int mismatch.
47
+ // ---------------------------------------------------------------------------
48
+ function toIntKey(reviewId) {
49
+ const key = typeof reviewId === 'number' ? reviewId : parseInt(reviewId, 10);
50
+ if (isNaN(key)) throw new Error(`Invalid reviewId for diff cache: ${reviewId}`);
51
+ return key;
52
+ }
53
+ function getLocalReviewDiff(reviewId) {
54
+ return localReviewDiffs.get(toIntKey(reviewId));
55
+ }
56
+ function setLocalReviewDiff(reviewId, value) {
57
+ localReviewDiffs.set(toIntKey(reviewId), value);
58
+ }
59
+ function deleteLocalReviewDiff(reviewId) {
60
+ localReviewDiffs.delete(toIntKey(reviewId));
61
+ }
62
+
42
63
  /**
43
64
  * Open native OS directory picker dialog and return the selected path.
44
65
  * Uses osascript on macOS, zenity/kdialog on Linux, PowerShell on Windows.
@@ -184,7 +205,7 @@ router.delete('/api/local/sessions/:reviewId', async (req, res) => {
184
205
  }
185
206
 
186
207
  // Clean up in-memory diff cache to avoid stale data
187
- localReviewDiffs.delete(reviewId);
208
+ deleteLocalReviewDiff(reviewId);
188
209
 
189
210
  logger.success(`Deleted local review session #${reviewId}`);
190
211
 
@@ -268,7 +289,7 @@ router.post('/api/local/start', async (req, res) => {
268
289
  const digest = await computeLocalDiffDigest(repoPath);
269
290
 
270
291
  // Persist to in-memory Map
271
- localReviewDiffs.set(sessionId, { diff, stats, digest });
292
+ setLocalReviewDiff(sessionId, { diff, stats, digest });
272
293
 
273
294
  // Persist to database
274
295
  await reviewRepo.saveLocalDiff(sessionId, { diff, stats, digest });
@@ -445,7 +466,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
445
466
  }
446
467
 
447
468
  // Get diff from module-level storage, falling back to database
448
- let diffData = localReviewDiffs.get(reviewId);
469
+ let diffData = getLocalReviewDiff(reviewId);
449
470
 
450
471
  if (!diffData) {
451
472
  // Try loading from database
@@ -453,7 +474,7 @@ router.get('/api/local/:reviewId/diff', async (req, res) => {
453
474
  if (persistedDiff) {
454
475
  diffData = persistedDiff;
455
476
  // Cache-warm the in-memory Map
456
- localReviewDiffs.set(reviewId, diffData);
477
+ setLocalReviewDiff(reviewId, diffData);
457
478
  logger.log('API', `Loaded persisted diff from DB for review #${reviewId}`, 'cyan');
458
479
  } else {
459
480
  diffData = { diff: '', stats: {} };
@@ -536,13 +557,13 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
536
557
  }
537
558
 
538
559
  // Get stored diff data (in-memory first, then fall back to DB)
539
- let storedDiffData = localReviewDiffs.get(reviewId);
560
+ let storedDiffData = getLocalReviewDiff(reviewId);
540
561
  if (!storedDiffData) {
541
562
  const persistedDiff = await reviewRepo.getLocalDiff(reviewId);
542
563
  if (persistedDiff) {
543
564
  storedDiffData = persistedDiff;
544
565
  // Cache-warm the in-memory Map
545
- localReviewDiffs.set(reviewId, storedDiffData);
566
+ setLocalReviewDiff(reviewId, storedDiffData);
546
567
  logger.log('API', `Loaded persisted diff from DB for staleness check on review #${reviewId}`, 'cyan');
547
568
  } else {
548
569
  return res.json({
@@ -593,7 +614,7 @@ router.get('/api/local/:reviewId/check-stale', async (req, res) => {
593
614
  /**
594
615
  * Start Level 1 AI analysis for local review
595
616
  */
596
- router.post('/api/local/:reviewId/analyze', async (req, res) => {
617
+ router.post('/api/local/:reviewId/analyses', async (req, res) => {
597
618
  try {
598
619
  const reviewId = parseInt(req.params.reviewId);
599
620
 
@@ -616,7 +637,6 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
616
637
  }
617
638
 
618
639
  // Validate tier
619
- const VALID_TIERS = ['fast', 'balanced', 'thorough', 'free', 'standard', 'premium'];
620
640
  if (requestTier && !VALID_TIERS.includes(requestTier)) {
621
641
  return res.status(400).json({
622
642
  error: `Invalid tier: "${requestTier}". Valid tiers: ${VALID_TIERS.join(', ')}`
@@ -682,12 +702,14 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
682
702
  // Create DB analysis_runs record immediately so it's queryable for polling
683
703
  const analysisRunRepo = new AnalysisRunRepository(db);
684
704
  const levelsConfig = parseEnabledLevels(requestEnabledLevels, requestSkipLevel3);
705
+ const tier = requestTier ? resolveTier(requestTier) : 'balanced';
685
706
  try {
686
707
  await analysisRunRepo.create({
687
708
  id: runId,
688
709
  reviewId,
689
710
  provider: selectedProvider,
690
711
  model: selectedModel,
712
+ tier,
691
713
  repoInstructions,
692
714
  requestInstructions,
693
715
  headSha: review.local_head_sha || null,
@@ -720,12 +742,12 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
720
742
  };
721
743
  activeAnalyses.set(analysisId, initialStatus);
722
744
 
723
- // Store local review to analysis ID mapping
724
- const reviewKey = getLocalReviewKey(reviewId);
725
- localReviewToAnalysisId.set(reviewKey, analysisId);
745
+ // Store review to analysis ID mapping (unified map)
746
+ reviewToAnalysisId.set(reviewId, analysisId);
726
747
 
727
748
  // Broadcast initial status
728
749
  broadcastProgress(analysisId, initialStatus);
750
+ broadcastReviewEvent(reviewId, { type: 'review:analysis_started', analysisId });
729
751
 
730
752
  // Create analyzer instance with provider and model
731
753
  const analyzer = new Analyzer(db, selectedModel, selectedProvider);
@@ -754,8 +776,6 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
754
776
  logger.log('API', `Analysis ID: ${analysisId}`, 'magenta');
755
777
  logger.log('API', `Provider: ${selectedProvider}`, 'cyan');
756
778
  logger.log('API', `Model: ${selectedModel}`, 'cyan');
757
- // Determine tier: request body > default ('balanced')
758
- const tier = requestTier || 'balanced';
759
779
  logger.log('API', `Tier: ${tier}`, 'cyan');
760
780
  logger.log('API', `Changed files: ${changedFiles.length}`, 'cyan');
761
781
  if (combinedInstructions) {
@@ -828,6 +848,7 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
828
848
 
829
849
  // Broadcast completion status
830
850
  broadcastProgress(analysisId, completedStatus);
851
+ broadcastReviewEvent(reviewId, { type: 'review:analysis_completed' });
831
852
  })
832
853
  .catch(error => {
833
854
  const currentStatus = activeAnalyses.get(analysisId);
@@ -867,9 +888,8 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
867
888
  broadcastProgress(analysisId, failedStatus);
868
889
  })
869
890
  .finally(() => {
870
- // Clean up local review to analysis ID mapping
871
- const reviewKey = getLocalReviewKey(reviewId);
872
- localReviewToAnalysisId.delete(reviewKey);
891
+ // Clean up review to analysis ID mapping (unified map)
892
+ reviewToAnalysisId.delete(reviewId);
873
893
  });
874
894
 
875
895
  // Return analysis ID immediately (runId added for unified ID)
@@ -888,10 +908,13 @@ router.post('/api/local/:reviewId/analyze', async (req, res) => {
888
908
  }
889
909
  });
890
910
 
911
+
891
912
  /**
892
- * Get AI suggestions for a local review
913
+ * Refresh the diff for a local review
914
+ * Regenerates the diff from the current state of the working directory
915
+ * Returns sessionChanged flag if HEAD has changed since the session was created
893
916
  */
894
- router.get('/api/local/:reviewId/suggestions', async (req, res) => {
917
+ router.post('/api/local/:reviewId/refresh', async (req, res) => {
895
918
  try {
896
919
  const reviewId = parseInt(req.params.reviewId);
897
920
 
@@ -902,8 +925,6 @@ router.get('/api/local/:reviewId/suggestions', async (req, res) => {
902
925
  }
903
926
 
904
927
  const db = req.app.get('db');
905
-
906
- // Verify review exists
907
928
  const reviewRepo = new ReviewRepository(db);
908
929
  const review = await reviewRepo.getLocalReviewById(reviewId);
909
930
 
@@ -913,166 +934,99 @@ router.get('/api/local/:reviewId/suggestions', async (req, res) => {
913
934
  });
914
935
  }
915
936
 
916
- // Parse levels query parameter (e.g., ?levels=final,1,2)
917
- // Default to 'final' (orchestrated suggestions only) if not specified
918
- const levelsParam = req.query.levels || 'final';
919
- const requestedLevels = levelsParam.split(',').map(l => l.trim());
920
-
921
- // Parse optional runId query parameter to fetch suggestions from a specific analysis run
922
- // If not provided, defaults to the latest run
923
- const runIdParam = req.query.runId;
924
-
925
- // Build level filter clause
926
- const levelConditions = [];
927
- requestedLevels.forEach(level => {
928
- if (level === 'final') {
929
- levelConditions.push('ai_level IS NULL');
930
- } else if (['1', '2', '3'].includes(level)) {
931
- levelConditions.push(`ai_level = ${parseInt(level)}`);
932
- }
933
- });
937
+ const localPath = review.local_path;
938
+ const originalHeadSha = review.local_head_sha;
934
939
 
935
- // If no valid levels specified, default to final
936
- const levelFilter = levelConditions.length > 0
937
- ? `(${levelConditions.join(' OR ')})`
938
- : 'ai_level IS NULL';
939
-
940
- // Build the run ID filter clause
941
- // If a specific runId is provided, use it directly; otherwise use subquery for latest
942
- let runIdFilter;
943
- let queryParams;
944
- if (runIdParam) {
945
- runIdFilter = 'ai_run_id = ?';
946
- queryParams = [reviewId, runIdParam];
947
- } else {
948
- // Get AI suggestions from the comments table
949
- // For local reviews, review_id stores the review ID
950
- // Only return suggestions from the latest analysis run (ai_run_id)
951
- // This preserves history while showing only the most recent results
952
- //
953
- // Note: If no AI suggestions exist (subquery returns NULL), the ai_run_id = NULL
954
- // comparison returns no rows. This is intentional - we only show suggestions
955
- // when there's a matching analysis run.
956
- //
957
- // Note: reviewId is passed twice because SQLite requires separate parameters
958
- // for the outer WHERE clause and the subquery. A CTE could consolidate this but
959
- // adds complexity without meaningful benefit here.
960
- runIdFilter = `ai_run_id = (
961
- SELECT ai_run_id FROM comments
962
- WHERE review_id = ? AND source = 'ai' AND ai_run_id IS NOT NULL
963
- ORDER BY created_at DESC
964
- LIMIT 1
965
- )`;
966
- queryParams = [reviewId, reviewId];
940
+ if (!localPath) {
941
+ return res.status(400).json({
942
+ error: 'Local review is missing path information'
943
+ });
967
944
  }
968
945
 
969
- const rows = await query(db, `
970
- SELECT
971
- id,
972
- source,
973
- author,
974
- ai_run_id,
975
- ai_level,
976
- ai_confidence,
977
- file,
978
- line_start,
979
- line_end,
980
- side,
981
- type,
982
- title,
983
- body,
984
- reasoning,
985
- status,
986
- is_file_level,
987
- created_at,
988
- updated_at
989
- FROM comments
990
- WHERE review_id = ?
991
- AND source = 'ai'
992
- AND ${levelFilter}
993
- AND status IN ('active', 'dismissed', 'adopted', 'draft', 'submitted')
994
- AND (is_raw = 0 OR is_raw IS NULL)
995
- AND ${runIdFilter}
996
- ORDER BY
997
- CASE
998
- WHEN ai_level IS NULL THEN 0
999
- WHEN ai_level = 1 THEN 1
1000
- WHEN ai_level = 2 THEN 2
1001
- WHEN ai_level = 3 THEN 3
1002
- ELSE 4
1003
- END,
1004
- is_file_level DESC,
1005
- file,
1006
- line_start
1007
- `, queryParams);
1008
-
1009
- const suggestions = rows.map(row => ({
1010
- ...row,
1011
- reasoning: row.reasoning ? JSON.parse(row.reasoning) : null
1012
- }));
1013
-
1014
- res.json({ suggestions });
946
+ logger.log('API', `Refreshing diff for local review #${reviewId}`, 'cyan');
947
+ logger.log('API', `Local path: ${localPath}`, 'magenta');
1015
948
 
1016
- } catch (error) {
1017
- logger.error('Error fetching local review suggestions:', error);
1018
- res.status(500).json({
1019
- error: 'Failed to fetch AI suggestions'
1020
- });
1021
- }
1022
- });
949
+ // Check if HEAD has changed
950
+ const { getHeadSha } = require('../local-review');
951
+ let currentHeadSha;
952
+ let sessionChanged = false;
953
+ let newSessionId = null;
1023
954
 
1024
- /**
1025
- * Get user comments for a local review
1026
- * Uses CommentRepository.getUserComments() for consistency with PR mode
1027
- */
1028
- router.get('/api/local/:reviewId/user-comments', async (req, res) => {
1029
- try {
1030
- const reviewId = parseInt(req.params.reviewId);
955
+ try {
956
+ currentHeadSha = await getHeadSha(localPath);
1031
957
 
1032
- if (isNaN(reviewId) || reviewId <= 0) {
1033
- return res.status(400).json({
1034
- error: 'Invalid review ID'
1035
- });
958
+ if (originalHeadSha && currentHeadSha !== originalHeadSha) {
959
+ sessionChanged = true;
960
+ logger.log('API', `HEAD changed: ${originalHeadSha.substring(0, 7)} -> ${currentHeadSha.substring(0, 7)}`, 'yellow');
961
+
962
+ // Check if a session already exists for the new HEAD
963
+ const existingSession = await reviewRepo.getLocalReview(localPath, currentHeadSha);
964
+ if (existingSession) {
965
+ newSessionId = existingSession.id;
966
+ logger.log('API', `Existing session found for new HEAD: ${newSessionId}`, 'cyan');
967
+ } else {
968
+ // Create a new session for the new HEAD
969
+ const { getRepositoryName } = require('../local-review');
970
+ const repository = await getRepositoryName(localPath);
971
+ newSessionId = await reviewRepo.upsertLocalReview({
972
+ localPath: localPath,
973
+ localHeadSha: currentHeadSha,
974
+ repository
975
+ });
976
+ logger.log('API', `Created new session for new HEAD: ${newSessionId}`, 'cyan');
977
+ }
978
+ }
979
+ } catch (headError) {
980
+ logger.warn(`Could not check HEAD SHA: ${headError.message}`);
1036
981
  }
1037
982
 
1038
- const db = req.app.get('db');
983
+ // Regenerate the diff from the working directory
984
+ const { diff, stats } = await generateLocalDiff(localPath);
1039
985
 
1040
- // Verify review exists
1041
- const reviewRepo = new ReviewRepository(db);
1042
- const review = await reviewRepo.getLocalReviewById(reviewId);
986
+ // Compute fresh digest for the new diff
987
+ const digest = await computeLocalDiffDigest(localPath);
1043
988
 
1044
- if (!review) {
1045
- return res.json({
1046
- success: true,
1047
- comments: []
1048
- });
989
+ // Update the stored diff data for the appropriate session
990
+ const targetSessionId = sessionChanged ? newSessionId : reviewId;
991
+ setLocalReviewDiff(targetSessionId, { diff, stats, digest });
992
+
993
+ // Persist diff to database for future session recovery
994
+ try {
995
+ await reviewRepo.saveLocalDiff(targetSessionId, { diff, stats, digest });
996
+ } catch (persistError) {
997
+ logger.warn(`Could not persist diff to database: ${persistError.message}`);
1049
998
  }
1050
999
 
1051
- // Use CommentRepository for consistency with PR mode
1052
- // This ensures both modes use the same query logic and include the same columns
1053
- const commentRepo = new CommentRepository(db);
1054
- const { includeDismissed } = req.query;
1055
- const comments = await commentRepo.getUserComments(reviewId, {
1056
- includeDismissed: includeDismissed === 'true'
1057
- });
1000
+ logger.success(`Diff refreshed: ${stats.unstagedChanges} unstaged, ${stats.untrackedFiles} untracked${stats.stagedChanges > 0 ? ` (${stats.stagedChanges} staged excluded)` : ''}`);
1058
1001
 
1059
1002
  res.json({
1060
1003
  success: true,
1061
- comments: comments || []
1004
+ message: 'Diff refreshed successfully',
1005
+ sessionChanged,
1006
+ newSessionId: sessionChanged ? newSessionId : null,
1007
+ newHeadSha: sessionChanged ? currentHeadSha : null,
1008
+ originalHeadSha: originalHeadSha,
1009
+ stats: {
1010
+ trackedChanges: stats.trackedChanges || 0,
1011
+ untrackedFiles: stats.untrackedFiles || 0,
1012
+ stagedChanges: stats.stagedChanges || 0,
1013
+ unstagedChanges: stats.unstagedChanges || 0
1014
+ }
1062
1015
  });
1063
1016
 
1064
1017
  } catch (error) {
1065
- logger.error('Error fetching local review user comments:', error);
1018
+ logger.error('Error refreshing local diff:', error);
1066
1019
  res.status(500).json({
1067
- error: 'Failed to fetch user comments'
1020
+ error: 'Failed to refresh diff: ' + error.message
1068
1021
  });
1069
1022
  }
1070
1023
  });
1071
1024
 
1072
1025
  /**
1073
- * Add user comment to a local review
1026
+ * Get review settings for a local review
1027
+ * Returns the custom_instructions from the review record
1074
1028
  */
1075
- router.post('/api/local/:reviewId/user-comments', async (req, res) => {
1029
+ router.get('/api/local/:reviewId/review-settings', async (req, res) => {
1076
1030
  try {
1077
1031
  const reviewId = parseInt(req.params.reviewId);
1078
1032
 
@@ -1082,60 +1036,46 @@ router.post('/api/local/:reviewId/user-comments', async (req, res) => {
1082
1036
  });
1083
1037
  }
1084
1038
 
1085
- const { file, line_start, line_end, diff_position, side, body, parent_id, type, title } = req.body;
1086
-
1087
- if (!file || !line_start || !body) {
1088
- return res.status(400).json({
1089
- error: 'Missing required fields: file, line_start, body'
1090
- });
1091
- }
1092
-
1093
1039
  const db = req.app.get('db');
1094
-
1095
- // Verify review exists
1096
1040
  const reviewRepo = new ReviewRepository(db);
1097
1041
  const review = await reviewRepo.getLocalReviewById(reviewId);
1098
1042
 
1099
1043
  if (!review) {
1100
- return res.status(404).json({
1101
- error: 'Local review not found'
1044
+ return res.json({
1045
+ custom_instructions: null,
1046
+ last_council_id: null
1102
1047
  });
1103
1048
  }
1104
1049
 
1105
- // Create line-level comment using repository
1106
- const commentRepo = new CommentRepository(db);
1107
- const commentId = await commentRepo.createLineComment({
1108
- review_id: reviewId,
1109
- file,
1110
- line_start,
1111
- line_end,
1112
- diff_position,
1113
- side,
1114
- body,
1115
- parent_id,
1116
- type,
1117
- title
1118
- });
1050
+ // Find the last council used for this review
1051
+ let last_council_id = null;
1052
+ const lastCouncilRun = await queryOne(db, `
1053
+ SELECT model FROM analysis_runs
1054
+ WHERE review_id = ? AND provider = 'council' AND model != 'inline-config'
1055
+ ORDER BY started_at DESC LIMIT 1
1056
+ `, [review.id]);
1057
+ if (lastCouncilRun) {
1058
+ last_council_id = lastCouncilRun.model;
1059
+ }
1119
1060
 
1120
1061
  res.json({
1121
- success: true,
1122
- commentId,
1123
- message: 'Comment saved successfully'
1062
+ custom_instructions: review.custom_instructions || null,
1063
+ last_council_id
1124
1064
  });
1125
1065
 
1126
1066
  } catch (error) {
1127
- logger.error('Error creating local review user comment:', error);
1067
+ logger.error('Error fetching local review settings:', error);
1128
1068
  res.status(500).json({
1129
- error: error.message || 'Failed to create comment'
1069
+ error: 'Failed to fetch review settings'
1130
1070
  });
1131
1071
  }
1132
1072
  });
1133
1073
 
1134
1074
  /**
1135
- * Create file-level user comment for a local review
1136
- * File-level comments are about an entire file, not tied to specific lines
1075
+ * Save review settings for a local review
1076
+ * Saves the custom_instructions to the review record
1137
1077
  */
1138
- router.post('/api/local/:reviewId/file-comment', async (req, res) => {
1078
+ router.post('/api/local/:reviewId/review-settings', async (req, res) => {
1139
1079
  try {
1140
1080
  const reviewId = parseInt(req.params.reviewId);
1141
1081
 
@@ -1145,998 +1085,150 @@ router.post('/api/local/:reviewId/file-comment', async (req, res) => {
1145
1085
  });
1146
1086
  }
1147
1087
 
1148
- const { file, body, parent_id, type, title } = req.body;
1149
-
1150
- if (!file || !body) {
1151
- return res.status(400).json({
1152
- error: 'Missing required fields: file, body'
1153
- });
1154
- }
1155
-
1156
- // Validate body is not just whitespace
1157
- const trimmedBody = body.trim();
1158
- if (trimmedBody.length === 0) {
1159
- return res.status(400).json({
1160
- error: 'Comment body cannot be empty or whitespace only'
1161
- });
1162
- }
1088
+ const { custom_instructions } = req.body;
1163
1089
 
1164
1090
  const db = req.app.get('db');
1165
-
1166
- // Verify review exists
1167
1091
  const reviewRepo = new ReviewRepository(db);
1168
1092
  const review = await reviewRepo.getLocalReviewById(reviewId);
1169
1093
 
1170
1094
  if (!review) {
1171
1095
  return res.status(404).json({
1172
- error: 'Local review not found'
1096
+ error: `Local review #${reviewId} not found`
1173
1097
  });
1174
1098
  }
1175
1099
 
1176
- // Create file-level comment using repository
1177
- const commentRepo = new CommentRepository(db);
1178
- const commentId = await commentRepo.createFileComment({
1179
- review_id: reviewId,
1180
- file,
1181
- body: trimmedBody,
1182
- type,
1183
- title,
1184
- parent_id
1100
+ // Update the review with custom instructions
1101
+ await reviewRepo.updateReview(reviewId, {
1102
+ customInstructions: custom_instructions || null
1185
1103
  });
1186
1104
 
1187
1105
  res.json({
1188
1106
  success: true,
1189
- commentId,
1190
- message: 'File-level comment saved successfully'
1107
+ custom_instructions: custom_instructions || null
1191
1108
  });
1192
1109
 
1193
1110
  } catch (error) {
1194
- logger.error('Error creating file-level comment:', error);
1111
+ logger.error('Error saving local review settings:', error);
1195
1112
  res.status(500).json({
1196
- error: error.message || 'Failed to create file-level comment'
1113
+ error: 'Failed to save review settings'
1197
1114
  });
1198
1115
  }
1199
1116
  });
1200
1117
 
1201
1118
  /**
1202
- * Update file-level comment in a local review
1119
+ * Trigger council analysis for a local review
1203
1120
  */
1204
- router.put('/api/local/:reviewId/file-comment/:commentId', async (req, res) => {
1121
+ router.post('/api/local/:reviewId/analyses/council', async (req, res) => {
1205
1122
  try {
1206
- const reviewId = parseInt(req.params.reviewId);
1207
- const commentId = parseInt(req.params.commentId);
1123
+ const reviewId = parseInt(req.params.reviewId, 10);
1124
+ const { councilId, councilConfig: inlineConfig, customInstructions: rawInstructions, configType: requestConfigType } = req.body || {};
1208
1125
 
1209
1126
  if (isNaN(reviewId) || reviewId <= 0) {
1210
- return res.status(400).json({
1211
- error: 'Invalid review ID'
1212
- });
1213
- }
1214
-
1215
- if (isNaN(commentId) || commentId <= 0) {
1216
- return res.status(400).json({
1217
- error: 'Invalid comment ID'
1218
- });
1127
+ return res.status(400).json({ error: 'Invalid review ID' });
1219
1128
  }
1220
1129
 
1221
- const { body } = req.body;
1222
-
1223
- if (!body || !body.trim()) {
1224
- return res.status(400).json({
1225
- error: 'Comment body cannot be empty'
1226
- });
1130
+ if (!councilId && !inlineConfig) {
1131
+ return res.status(400).json({ error: 'Either councilId or councilConfig is required' });
1227
1132
  }
1228
1133
 
1229
1134
  const db = req.app.get('db');
1230
1135
 
1231
- // Verify the comment exists, belongs to this review, and is a file-level comment
1232
- const comment = await queryOne(db, `
1233
- SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user' AND is_file_level = 1
1234
- `, [commentId, reviewId]);
1235
-
1236
- if (!comment) {
1237
- return res.status(404).json({
1238
- error: 'File-level comment not found'
1239
- });
1240
- }
1241
-
1242
- // Update comment
1243
- await run(db, `
1244
- UPDATE comments
1245
- SET body = ?, updated_at = CURRENT_TIMESTAMP
1246
- WHERE id = ?
1247
- `, [body.trim(), commentId]);
1248
-
1249
- res.json({
1250
- success: true,
1251
- message: 'File-level comment updated successfully'
1252
- });
1253
-
1254
- } catch (error) {
1255
- logger.error('Error updating file-level comment:', error);
1256
- res.status(500).json({
1257
- error: 'Failed to update comment'
1258
- });
1259
- }
1260
- });
1261
-
1262
- /**
1263
- * Delete file-level comment from a local review
1264
- */
1265
- router.delete('/api/local/:reviewId/file-comment/:commentId', async (req, res) => {
1266
- try {
1267
- const reviewId = parseInt(req.params.reviewId);
1268
- const commentId = parseInt(req.params.commentId);
1269
-
1270
- if (isNaN(reviewId) || reviewId <= 0) {
1271
- return res.status(400).json({
1272
- error: 'Invalid review ID'
1273
- });
1136
+ // Get review record
1137
+ const review = await queryOne(db, 'SELECT * FROM reviews WHERE id = ? AND review_type = ?', [reviewId, 'local']);
1138
+ if (!review) {
1139
+ return res.status(404).json({ error: 'Local review not found' });
1274
1140
  }
1275
1141
 
1276
- if (isNaN(commentId) || commentId <= 0) {
1277
- return res.status(400).json({
1278
- error: 'Invalid comment ID'
1279
- });
1142
+ // Resolve council config and determine config type
1143
+ let councilConfig;
1144
+ let configType;
1145
+ if (councilId) {
1146
+ const councilRepo = new CouncilRepository(db);
1147
+ const council = await councilRepo.getById(councilId);
1148
+ if (!council) {
1149
+ return res.status(404).json({ error: 'Council not found' });
1150
+ }
1151
+ councilConfig = council.config;
1152
+ configType = requestConfigType || council.type || 'advanced';
1153
+ } else {
1154
+ councilConfig = inlineConfig;
1155
+ configType = requestConfigType || 'advanced';
1280
1156
  }
1281
1157
 
1282
- const db = req.app.get('db');
1283
-
1284
- // Verify the comment exists, belongs to this review, and is a file-level comment
1285
- const comment = await queryOne(db, `
1286
- SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user' AND is_file_level = 1
1287
- `, [commentId, reviewId]);
1158
+ councilConfig = normalizeCouncilConfig(councilConfig, configType);
1288
1159
 
1289
- if (!comment) {
1290
- return res.status(404).json({
1291
- error: 'File-level comment not found'
1292
- });
1160
+ const configError = validateCouncilConfig(councilConfig, configType);
1161
+ if (configError) {
1162
+ return res.status(400).json({ error: `Invalid council config: ${configError}` });
1293
1163
  }
1294
1164
 
1295
- // Use CommentRepository to delete (also dismisses parent AI suggestion if applicable)
1296
- const commentRepo = new CommentRepository(db);
1297
- const result = await commentRepo.deleteComment(commentId);
1298
-
1299
- res.json({
1300
- success: true,
1301
- message: 'File-level comment deleted successfully',
1302
- dismissedSuggestionId: result.dismissedSuggestionId
1303
- });
1304
-
1305
- } catch (error) {
1306
- logger.error('Error deleting file-level comment:', error);
1307
- res.status(500).json({
1308
- error: 'Failed to delete comment'
1309
- });
1310
- }
1311
- });
1312
-
1313
- /**
1314
- * Update AI suggestion status for a local review
1315
- * Sets status to 'adopted', 'dismissed', or 'active' (restored)
1316
- */
1317
- router.post('/api/local/:reviewId/ai-suggestion/:suggestionId/status', async (req, res) => {
1318
- try {
1319
- const reviewId = parseInt(req.params.reviewId);
1320
- const suggestionId = parseInt(req.params.suggestionId);
1321
- const { status } = req.body;
1165
+ const localPath = review.local_path;
1322
1166
 
1323
- if (isNaN(reviewId) || reviewId <= 0) {
1324
- return res.status(400).json({
1325
- error: 'Invalid review ID'
1326
- });
1327
- }
1167
+ const prMetadata = {
1168
+ reviewType: 'local',
1169
+ repository: review.repository,
1170
+ title: review.name || 'Local changes',
1171
+ description: '',
1172
+ head_sha: review.local_head_sha
1173
+ };
1328
1174
 
1329
- if (isNaN(suggestionId) || suggestionId <= 0) {
1330
- return res.status(400).json({
1331
- error: 'Invalid suggestion ID'
1332
- });
1333
- }
1175
+ const analyzer = new Analyzer(db, 'council', 'council');
1176
+ const changedFiles = await analyzer.getLocalChangedFiles(localPath);
1334
1177
 
1335
- if (!['adopted', 'dismissed', 'active'].includes(status)) {
1336
- return res.status(400).json({
1337
- error: 'Invalid status. Must be "adopted", "dismissed", or "active"'
1338
- });
1178
+ // Generate and cache diff
1179
+ try {
1180
+ const diffResult = await generateLocalDiff(localPath);
1181
+ const digest = await computeLocalDiffDigest(localPath);
1182
+ setLocalReviewDiff(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
1183
+ } catch (diffError) {
1184
+ logger.warn(`Could not generate diff for local council review ${reviewId}: ${diffError.message}`);
1339
1185
  }
1340
1186
 
1341
- const db = req.app.get('db');
1342
- const commentRepo = new CommentRepository(db);
1343
-
1344
- // Get the suggestion and verify it belongs to this review
1345
- const suggestion = await commentRepo.getComment(suggestionId, 'ai');
1346
-
1347
- if (!suggestion) {
1348
- return res.status(404).json({
1349
- error: 'AI suggestion not found'
1350
- });
1351
- }
1187
+ // Resolve instructions
1188
+ const repoSettingsRepo = new RepoSettingsRepository(db);
1189
+ const reviewRepo = new ReviewRepository(db);
1190
+ const repoSettings = await repoSettingsRepo.getRepoSettings(review.repository);
1191
+ const repoInstructions = repoSettings?.default_instructions || null;
1192
+ const requestInstructions = rawInstructions?.trim() || null;
1352
1193
 
1353
- if (suggestion.review_id !== reviewId) {
1354
- return res.status(403).json({
1355
- error: 'Suggestion does not belong to this review'
1194
+ if (requestInstructions) {
1195
+ await reviewRepo.updateReview(reviewId, {
1196
+ customInstructions: requestInstructions
1356
1197
  });
1357
1198
  }
1358
1199
 
1359
- // Update suggestion status using repository
1360
- await commentRepo.updateSuggestionStatus(suggestionId, status);
1200
+ // Import launchCouncilAnalysis from analyses.js
1201
+ const analysesRouter = require('./analyses');
1202
+ const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
1203
+ db,
1204
+ {
1205
+ reviewId,
1206
+ worktreePath: localPath,
1207
+ prMetadata,
1208
+ changedFiles,
1209
+ repository: review.repository,
1210
+ headSha: review.local_head_sha,
1211
+ logLabel: `local review #${reviewId}`,
1212
+ initialStatusExtra: { reviewId, reviewType: 'local' },
1213
+ extraBroadcastKeys: [`review-${reviewId}`],
1214
+ runUpdateExtra: { filesAnalyzed: changedFiles.length }
1215
+ },
1216
+ councilConfig,
1217
+ councilId,
1218
+ { repoInstructions, requestInstructions },
1219
+ configType
1220
+ );
1361
1221
 
1362
1222
  res.json({
1363
- success: true,
1364
- status
1365
- });
1366
-
1367
- } catch (error) {
1368
- logger.error('Error updating AI suggestion status:', error);
1369
- res.status(500).json({
1370
- error: error.message || 'Failed to update suggestion status'
1223
+ analysisId,
1224
+ runId,
1225
+ status: 'started',
1226
+ message: 'Council analysis started in background',
1227
+ isCouncil: true
1371
1228
  });
1372
- }
1373
- });
1374
-
1375
- /**
1376
- * Get a single user comment from a local review
1377
- */
1378
- router.get('/api/local/:reviewId/user-comments/:commentId', async (req, res) => {
1379
- try {
1380
- const reviewId = parseInt(req.params.reviewId);
1381
- const commentId = parseInt(req.params.commentId);
1382
-
1383
- if (isNaN(reviewId) || reviewId <= 0) {
1384
- return res.status(400).json({
1385
- error: 'Invalid review ID'
1386
- });
1387
- }
1388
-
1389
- if (isNaN(commentId) || commentId <= 0) {
1390
- return res.status(400).json({
1391
- error: 'Invalid comment ID'
1392
- });
1393
- }
1394
-
1395
- const db = req.app.get('db');
1396
-
1397
- // Get the comment and verify it belongs to this review
1398
- const comment = await queryOne(db, `
1399
- SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
1400
- `, [commentId, reviewId]);
1401
-
1402
- if (!comment) {
1403
- return res.status(404).json({
1404
- error: 'User comment not found'
1405
- });
1406
- }
1407
-
1408
- res.json({
1409
- id: comment.id,
1410
- file: comment.file,
1411
- line_start: comment.line_start,
1412
- line_end: comment.line_end,
1413
- body: comment.body,
1414
- type: comment.type,
1415
- title: comment.title,
1416
- status: comment.status,
1417
- created_at: comment.created_at,
1418
- updated_at: comment.updated_at
1419
- });
1420
-
1421
- } catch (error) {
1422
- logger.error('Error fetching local review user comment:', error);
1423
- res.status(500).json({
1424
- error: 'Failed to fetch comment'
1425
- });
1426
- }
1427
- });
1428
-
1429
- /**
1430
- * Update user comment in a local review
1431
- */
1432
- router.put('/api/local/:reviewId/user-comments/:commentId', async (req, res) => {
1433
- try {
1434
- const reviewId = parseInt(req.params.reviewId);
1435
- const commentId = parseInt(req.params.commentId);
1436
-
1437
- if (isNaN(reviewId) || reviewId <= 0) {
1438
- return res.status(400).json({
1439
- error: 'Invalid review ID'
1440
- });
1441
- }
1442
-
1443
- if (isNaN(commentId) || commentId <= 0) {
1444
- return res.status(400).json({
1445
- error: 'Invalid comment ID'
1446
- });
1447
- }
1448
-
1449
- const { body } = req.body;
1450
-
1451
- if (!body || !body.trim()) {
1452
- return res.status(400).json({
1453
- error: 'Comment body cannot be empty'
1454
- });
1455
- }
1456
-
1457
- const db = req.app.get('db');
1458
-
1459
- // Verify the comment exists and belongs to this review
1460
- const comment = await queryOne(db, `
1461
- SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
1462
- `, [commentId, reviewId]);
1463
-
1464
- if (!comment) {
1465
- return res.status(404).json({
1466
- error: 'User comment not found'
1467
- });
1468
- }
1469
-
1470
- // Update comment
1471
- await run(db, `
1472
- UPDATE comments
1473
- SET body = ?, updated_at = CURRENT_TIMESTAMP
1474
- WHERE id = ?
1475
- `, [body.trim(), commentId]);
1476
-
1477
- res.json({
1478
- success: true,
1479
- message: 'Comment updated successfully'
1480
- });
1481
-
1482
- } catch (error) {
1483
- logger.error('Error updating local review user comment:', error);
1484
- res.status(500).json({
1485
- error: 'Failed to update comment'
1486
- });
1487
- }
1488
- });
1489
-
1490
- /**
1491
- * Bulk delete all user comments for a local review
1492
- * Also dismisses any AI suggestions that were parents of the deleted comments.
1493
- */
1494
- router.delete('/api/local/:reviewId/user-comments', async (req, res) => {
1495
- try {
1496
- const reviewId = parseInt(req.params.reviewId);
1497
-
1498
- if (isNaN(reviewId) || reviewId <= 0) {
1499
- return res.status(400).json({
1500
- error: 'Invalid review ID'
1501
- });
1502
- }
1503
-
1504
- const db = req.app.get('db');
1505
-
1506
- // Verify review exists
1507
- const reviewRepo = new ReviewRepository(db);
1508
- const review = await reviewRepo.getLocalReviewById(reviewId);
1509
-
1510
- if (!review) {
1511
- return res.status(404).json({
1512
- error: `Local review #${reviewId} not found`
1513
- });
1514
- }
1515
-
1516
- // Begin transaction to ensure atomicity
1517
- await run(db, 'BEGIN TRANSACTION');
1518
-
1519
- try {
1520
- // Bulk delete using repository (also dismisses parent AI suggestions)
1521
- const commentRepo = new CommentRepository(db);
1522
- const result = await commentRepo.bulkDeleteComments(reviewId);
1523
-
1524
- // Commit transaction
1525
- await run(db, 'COMMIT');
1526
-
1527
- res.json({
1528
- success: true,
1529
- deletedCount: result.deletedCount,
1530
- dismissedSuggestionIds: result.dismissedSuggestionIds,
1531
- message: `Deleted ${result.deletedCount} user comment${result.deletedCount !== 1 ? 's' : ''}`
1532
- });
1533
-
1534
- } catch (transactionError) {
1535
- // Rollback transaction on error
1536
- await run(db, 'ROLLBACK');
1537
- throw transactionError;
1538
- }
1539
-
1540
- } catch (error) {
1541
- logger.error('Error deleting all local review user comments:', error);
1542
- res.status(500).json({
1543
- error: 'Failed to delete comments'
1544
- });
1545
- }
1546
- });
1547
-
1548
- /**
1549
- * Delete user comment from a local review
1550
- * If the comment was adopted from an AI suggestion, the parent suggestion
1551
- * is automatically transitioned to 'dismissed' state.
1552
- */
1553
- router.delete('/api/local/:reviewId/user-comments/:commentId', async (req, res) => {
1554
- try {
1555
- const reviewId = parseInt(req.params.reviewId);
1556
- const commentId = parseInt(req.params.commentId);
1557
-
1558
- if (isNaN(reviewId) || reviewId <= 0) {
1559
- return res.status(400).json({
1560
- error: 'Invalid review ID'
1561
- });
1562
- }
1563
-
1564
- if (isNaN(commentId) || commentId <= 0) {
1565
- return res.status(400).json({
1566
- error: 'Invalid comment ID'
1567
- });
1568
- }
1569
-
1570
- const db = req.app.get('db');
1571
-
1572
- // Verify the comment exists and belongs to this review
1573
- const comment = await queryOne(db, `
1574
- SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
1575
- `, [commentId, reviewId]);
1576
-
1577
- if (!comment) {
1578
- return res.status(404).json({
1579
- error: 'User comment not found'
1580
- });
1581
- }
1582
-
1583
- // Use CommentRepository to delete (also dismisses parent AI suggestion if applicable)
1584
- const commentRepo = new CommentRepository(db);
1585
- const result = await commentRepo.deleteComment(commentId);
1586
-
1587
- res.json({
1588
- success: true,
1589
- message: 'Comment deleted successfully',
1590
- dismissedSuggestionId: result.dismissedSuggestionId
1591
- });
1592
-
1593
- } catch (error) {
1594
- logger.error('Error deleting local review user comment:', error);
1595
- res.status(500).json({
1596
- error: 'Failed to delete comment'
1597
- });
1598
- }
1599
- });
1600
-
1601
- /**
1602
- * Restore a dismissed user comment in a local review
1603
- * Sets status from 'inactive' back to 'active'
1604
- */
1605
- router.put('/api/local/:reviewId/user-comments/:commentId/restore', async (req, res) => {
1606
- try {
1607
- const reviewId = parseInt(req.params.reviewId);
1608
- const commentId = parseInt(req.params.commentId);
1609
-
1610
- if (isNaN(reviewId) || reviewId <= 0) {
1611
- return res.status(400).json({
1612
- error: 'Invalid review ID'
1613
- });
1614
- }
1615
-
1616
- if (isNaN(commentId) || commentId <= 0) {
1617
- return res.status(400).json({
1618
- error: 'Invalid comment ID'
1619
- });
1620
- }
1621
-
1622
- const db = req.app.get('db');
1623
-
1624
- // Verify the comment exists and belongs to this review
1625
- const comment = await queryOne(db, `
1626
- SELECT * FROM comments WHERE id = ? AND review_id = ? AND source = 'user'
1627
- `, [commentId, reviewId]);
1628
-
1629
- if (!comment) {
1630
- return res.status(404).json({
1631
- error: 'User comment not found'
1632
- });
1633
- }
1634
-
1635
- if (comment.status !== 'inactive') {
1636
- return res.status(400).json({
1637
- error: 'Comment is not dismissed'
1638
- });
1639
- }
1640
-
1641
- // Restore the comment using CommentRepository
1642
- const commentRepo = new CommentRepository(db);
1643
- await commentRepo.restoreComment(commentId);
1644
-
1645
- // Get the restored comment to return
1646
- const restoredComment = await commentRepo.getComment(commentId, 'user');
1647
-
1648
- res.json({
1649
- success: true,
1650
- message: 'Comment restored successfully',
1651
- comment: restoredComment
1652
- });
1653
-
1654
- } catch (error) {
1655
- logger.error('Error restoring local review user comment:', error);
1656
- res.status(500).json({
1657
- error: error.message || 'Failed to restore comment'
1658
- });
1659
- }
1660
- });
1661
-
1662
- /**
1663
- * Check if analysis is running for a local review
1664
- */
1665
- router.get('/api/local/:reviewId/analysis-status', async (req, res) => {
1666
- try {
1667
- const reviewId = parseInt(req.params.reviewId);
1668
-
1669
- if (isNaN(reviewId) || reviewId <= 0) {
1670
- return res.status(400).json({
1671
- error: 'Invalid review ID'
1672
- });
1673
- }
1674
-
1675
- const reviewKey = getLocalReviewKey(reviewId);
1676
- const analysisId = localReviewToAnalysisId.get(reviewKey);
1677
-
1678
- if (analysisId) {
1679
- const analysis = activeAnalyses.get(analysisId);
1680
-
1681
- if (analysis) {
1682
- return res.json({
1683
- running: true,
1684
- analysisId,
1685
- status: analysis
1686
- });
1687
- }
1688
-
1689
- // Clean up stale mapping
1690
- localReviewToAnalysisId.delete(reviewKey);
1691
- }
1692
-
1693
- // Fall back to database — an analysis may have been started externally (e.g. via MCP)
1694
- const db = req.app.get('db');
1695
- const analysisRunRepo = new AnalysisRunRepository(db);
1696
- const latestRun = await analysisRunRepo.getLatestByReviewId(reviewId);
1697
-
1698
- if (latestRun && latestRun.status === 'running') {
1699
- return res.json({
1700
- running: true,
1701
- analysisId: latestRun.id,
1702
- status: {
1703
- id: latestRun.id,
1704
- reviewId,
1705
- reviewType: 'local',
1706
- status: 'running',
1707
- startedAt: latestRun.started_at,
1708
- progress: 'Analysis in progress...',
1709
- levels: {
1710
- 1: { status: 'running', progress: 'Running...' },
1711
- 2: { status: 'running', progress: 'Running...' },
1712
- 3: { status: 'running', progress: 'Running...' },
1713
- 4: { status: 'pending', progress: 'Pending' }
1714
- },
1715
- filesAnalyzed: latestRun.files_analyzed || 0,
1716
- filesRemaining: 0
1717
- }
1718
- });
1719
- }
1720
-
1721
- res.json({
1722
- running: false,
1723
- analysisId: null,
1724
- status: null
1725
- });
1726
-
1727
- } catch (error) {
1728
- logger.error('Error checking local review analysis status:', error);
1729
- res.status(500).json({
1730
- error: 'Failed to check analysis status'
1731
- });
1732
- }
1733
- });
1734
-
1735
- /**
1736
- * Check if a local review has existing AI suggestions
1737
- */
1738
- router.get('/api/local/:reviewId/has-ai-suggestions', async (req, res) => {
1739
- try {
1740
- const reviewId = parseInt(req.params.reviewId);
1741
- const { runId } = req.query;
1742
-
1743
- if (isNaN(reviewId) || reviewId <= 0) {
1744
- return res.status(400).json({
1745
- error: 'Invalid review ID'
1746
- });
1747
- }
1748
-
1749
- const db = req.app.get('db');
1750
-
1751
- // Verify review exists
1752
- const reviewRepo = new ReviewRepository(db);
1753
- const review = await reviewRepo.getLocalReviewById(reviewId);
1754
-
1755
- if (!review) {
1756
- return res.status(404).json({
1757
- error: `Local review #${reviewId} not found`
1758
- });
1759
- }
1760
-
1761
- // Check if any AI suggestions exist for this review
1762
- // Exclude raw council voice suggestions (is_raw=1) — only count final/consolidated suggestions
1763
- const result = await queryOne(db, `
1764
- SELECT EXISTS(
1765
- SELECT 1 FROM comments
1766
- WHERE review_id = ? AND source = 'ai' AND (is_raw = 0 OR is_raw IS NULL)
1767
- ) as has_suggestions
1768
- `, [reviewId]);
1769
-
1770
- const hasSuggestions = result?.has_suggestions === 1;
1771
-
1772
- // Check if any analysis has been run using analysis_runs table
1773
- let analysisHasRun = hasSuggestions;
1774
- const analysisRunRepo = new AnalysisRunRepository(db);
1775
- let selectedRun = null;
1776
- try {
1777
- // If runId is provided, fetch that specific run; otherwise get the latest
1778
- if (runId) {
1779
- selectedRun = await analysisRunRepo.getById(runId);
1780
- } else {
1781
- selectedRun = await analysisRunRepo.getLatestByReviewId(reviewId);
1782
- }
1783
- analysisHasRun = !!(selectedRun || hasSuggestions);
1784
- } catch (e) {
1785
- // Log the error at debug level before falling back
1786
- logger.debug('analysis_runs query failed in local mode, falling back to hasSuggestions:', e.message);
1787
- // Fall back to using hasSuggestions if analysis_runs table doesn't exist
1788
- analysisHasRun = hasSuggestions;
1789
- }
1790
-
1791
- // Get AI summary from the selected analysis run if available, otherwise fall back to review summary
1792
- const summary = selectedRun?.summary || review?.summary || null;
1793
-
1794
- // Get stats for AI suggestions (issues/suggestions/praise for final level only)
1795
- // Filter by runId if provided, otherwise use the latest analysis run
1796
- let stats = { issues: 0, suggestions: 0, praise: 0 };
1797
- if (hasSuggestions) {
1798
- try {
1799
- const statsQuery = getStatsQuery(runId);
1800
- const statsResult = await query(db, statsQuery.query, statsQuery.params(reviewId));
1801
- stats = calculateStats(statsResult);
1802
- } catch (e) {
1803
- logger.warn('Error fetching AI suggestion stats:', e);
1804
- }
1805
- }
1806
-
1807
- res.json({
1808
- hasSuggestions: hasSuggestions,
1809
- analysisHasRun: analysisHasRun,
1810
- summary: summary,
1811
- stats: stats
1812
- });
1813
-
1814
- } catch (error) {
1815
- logger.error('Error checking for AI suggestions:', error);
1816
- res.status(500).json({
1817
- error: 'Failed to check for AI suggestions'
1818
- });
1819
- }
1820
- });
1821
-
1822
- /**
1823
- * Server-Sent Events endpoint for local review AI analysis progress
1824
- */
1825
- router.get('/api/local/:reviewId/ai-suggestions/status', (req, res) => {
1826
- const reviewId = parseInt(req.params.reviewId);
1827
-
1828
- // Find the analysis ID for this local review
1829
- const reviewKey = getLocalReviewKey(reviewId);
1830
- const analysisId = localReviewToAnalysisId.get(reviewKey);
1831
-
1832
- // Set up SSE headers
1833
- res.writeHead(200, {
1834
- 'Content-Type': 'text/event-stream',
1835
- 'Cache-Control': 'no-cache',
1836
- 'Connection': 'keep-alive',
1837
- 'Access-Control-Allow-Origin': '*',
1838
- 'Access-Control-Allow-Headers': 'Cache-Control'
1839
- });
1840
-
1841
- // Send initial connection message
1842
- res.write('data: {"type":"connected","message":"Connected to progress stream"}\n\n');
1843
-
1844
- // If we have an analysis ID, use it; otherwise use a placeholder
1845
- const trackingId = analysisId || `local-${reviewId}`;
1846
-
1847
- // Store client for this analysis
1848
- if (!progressClients.has(trackingId)) {
1849
- progressClients.set(trackingId, new Set());
1850
- }
1851
- progressClients.get(trackingId).add(res);
1852
-
1853
- // Send current status if analysis exists
1854
- if (analysisId) {
1855
- const currentStatus = activeAnalyses.get(analysisId);
1856
- if (currentStatus) {
1857
- res.write(`data: ${JSON.stringify({
1858
- type: 'progress',
1859
- ...currentStatus
1860
- })}\n\n`);
1861
- }
1862
- }
1863
-
1864
- // Handle client disconnect
1865
- req.on('close', () => {
1866
- const clients = progressClients.get(trackingId);
1867
- if (clients) {
1868
- clients.delete(res);
1869
- if (clients.size === 0) {
1870
- progressClients.delete(trackingId);
1871
- }
1872
- }
1873
- });
1874
-
1875
- req.on('error', () => {
1876
- const clients = progressClients.get(trackingId);
1877
- if (clients) {
1878
- clients.delete(res);
1879
- if (clients.size === 0) {
1880
- progressClients.delete(trackingId);
1881
- }
1882
- }
1883
- });
1884
- });
1885
-
1886
- /**
1887
- * Refresh the diff for a local review
1888
- * Regenerates the diff from the current state of the working directory
1889
- * Returns sessionChanged flag if HEAD has changed since the session was created
1890
- */
1891
- router.post('/api/local/:reviewId/refresh', async (req, res) => {
1892
- try {
1893
- const reviewId = parseInt(req.params.reviewId);
1894
-
1895
- if (isNaN(reviewId) || reviewId <= 0) {
1896
- return res.status(400).json({
1897
- error: 'Invalid review ID'
1898
- });
1899
- }
1900
-
1901
- const db = req.app.get('db');
1902
- const reviewRepo = new ReviewRepository(db);
1903
- const review = await reviewRepo.getLocalReviewById(reviewId);
1904
-
1905
- if (!review) {
1906
- return res.status(404).json({
1907
- error: `Local review #${reviewId} not found`
1908
- });
1909
- }
1910
-
1911
- const localPath = review.local_path;
1912
- const originalHeadSha = review.local_head_sha;
1913
-
1914
- if (!localPath) {
1915
- return res.status(400).json({
1916
- error: 'Local review is missing path information'
1917
- });
1918
- }
1919
-
1920
- logger.log('API', `Refreshing diff for local review #${reviewId}`, 'cyan');
1921
- logger.log('API', `Local path: ${localPath}`, 'magenta');
1922
-
1923
- // Check if HEAD has changed
1924
- const { getHeadSha } = require('../local-review');
1925
- let currentHeadSha;
1926
- let sessionChanged = false;
1927
- let newSessionId = null;
1928
-
1929
- try {
1930
- currentHeadSha = await getHeadSha(localPath);
1931
-
1932
- if (originalHeadSha && currentHeadSha !== originalHeadSha) {
1933
- sessionChanged = true;
1934
- logger.log('API', `HEAD changed: ${originalHeadSha.substring(0, 7)} -> ${currentHeadSha.substring(0, 7)}`, 'yellow');
1935
-
1936
- // Check if a session already exists for the new HEAD
1937
- const existingSession = await reviewRepo.getLocalReview(localPath, currentHeadSha);
1938
- if (existingSession) {
1939
- newSessionId = existingSession.id;
1940
- logger.log('API', `Existing session found for new HEAD: ${newSessionId}`, 'cyan');
1941
- } else {
1942
- // Create a new session for the new HEAD
1943
- const { getRepositoryName } = require('../local-review');
1944
- const repository = await getRepositoryName(localPath);
1945
- newSessionId = await reviewRepo.upsertLocalReview({
1946
- localPath: localPath,
1947
- localHeadSha: currentHeadSha,
1948
- repository
1949
- });
1950
- logger.log('API', `Created new session for new HEAD: ${newSessionId}`, 'cyan');
1951
- }
1952
- }
1953
- } catch (headError) {
1954
- logger.warn(`Could not check HEAD SHA: ${headError.message}`);
1955
- }
1956
-
1957
- // Regenerate the diff from the working directory
1958
- const { diff, stats } = await generateLocalDiff(localPath);
1959
-
1960
- // Compute fresh digest for the new diff
1961
- const digest = await computeLocalDiffDigest(localPath);
1962
-
1963
- // Update the stored diff data for the appropriate session
1964
- const targetSessionId = sessionChanged ? newSessionId : reviewId;
1965
- localReviewDiffs.set(targetSessionId, { diff, stats, digest });
1966
-
1967
- // Persist diff to database for future session recovery
1968
- try {
1969
- await reviewRepo.saveLocalDiff(targetSessionId, { diff, stats, digest });
1970
- } catch (persistError) {
1971
- logger.warn(`Could not persist diff to database: ${persistError.message}`);
1972
- }
1973
-
1974
- logger.success(`Diff refreshed: ${stats.unstagedChanges} unstaged, ${stats.untrackedFiles} untracked${stats.stagedChanges > 0 ? ` (${stats.stagedChanges} staged excluded)` : ''}`);
1975
-
1976
- res.json({
1977
- success: true,
1978
- message: 'Diff refreshed successfully',
1979
- sessionChanged,
1980
- newSessionId: sessionChanged ? newSessionId : null,
1981
- newHeadSha: sessionChanged ? currentHeadSha : null,
1982
- originalHeadSha: originalHeadSha,
1983
- stats: {
1984
- trackedChanges: stats.trackedChanges || 0,
1985
- untrackedFiles: stats.untrackedFiles || 0,
1986
- stagedChanges: stats.stagedChanges || 0,
1987
- unstagedChanges: stats.unstagedChanges || 0
1988
- }
1989
- });
1990
-
1991
- } catch (error) {
1992
- logger.error('Error refreshing local diff:', error);
1993
- res.status(500).json({
1994
- error: 'Failed to refresh diff: ' + error.message
1995
- });
1996
- }
1997
- });
1998
-
1999
- /**
2000
- * Get review settings for a local review
2001
- * Returns the custom_instructions from the review record
2002
- */
2003
- router.get('/api/local/:reviewId/review-settings', async (req, res) => {
2004
- try {
2005
- const reviewId = parseInt(req.params.reviewId);
2006
-
2007
- if (isNaN(reviewId) || reviewId <= 0) {
2008
- return res.status(400).json({
2009
- error: 'Invalid review ID'
2010
- });
2011
- }
2012
-
2013
- const db = req.app.get('db');
2014
- const reviewRepo = new ReviewRepository(db);
2015
- const review = await reviewRepo.getLocalReviewById(reviewId);
2016
-
2017
- if (!review) {
2018
- return res.json({
2019
- custom_instructions: null,
2020
- last_council_id: null
2021
- });
2022
- }
2023
-
2024
- // Find the last council used for this review
2025
- let last_council_id = null;
2026
- const lastCouncilRun = await queryOne(db, `
2027
- SELECT model FROM analysis_runs
2028
- WHERE review_id = ? AND provider = 'council' AND model != 'inline-config'
2029
- ORDER BY started_at DESC LIMIT 1
2030
- `, [review.id]);
2031
- if (lastCouncilRun) {
2032
- last_council_id = lastCouncilRun.model;
2033
- }
2034
-
2035
- res.json({
2036
- custom_instructions: review.custom_instructions || null,
2037
- last_council_id
2038
- });
2039
-
2040
- } catch (error) {
2041
- logger.error('Error fetching local review settings:', error);
2042
- res.status(500).json({
2043
- error: 'Failed to fetch review settings'
2044
- });
2045
- }
2046
- });
2047
-
2048
- /**
2049
- * Save review settings for a local review
2050
- * Saves the custom_instructions to the review record
2051
- */
2052
- router.post('/api/local/:reviewId/review-settings', async (req, res) => {
2053
- try {
2054
- const reviewId = parseInt(req.params.reviewId);
2055
-
2056
- if (isNaN(reviewId) || reviewId <= 0) {
2057
- return res.status(400).json({
2058
- error: 'Invalid review ID'
2059
- });
2060
- }
2061
-
2062
- const { custom_instructions } = req.body;
2063
-
2064
- const db = req.app.get('db');
2065
- const reviewRepo = new ReviewRepository(db);
2066
- const review = await reviewRepo.getLocalReviewById(reviewId);
2067
-
2068
- if (!review) {
2069
- return res.status(404).json({
2070
- error: `Local review #${reviewId} not found`
2071
- });
2072
- }
2073
-
2074
- // Update the review with custom instructions
2075
- await reviewRepo.updateReview(reviewId, {
2076
- customInstructions: custom_instructions || null
2077
- });
2078
-
2079
- res.json({
2080
- success: true,
2081
- custom_instructions: custom_instructions || null
2082
- });
2083
-
2084
- } catch (error) {
2085
- logger.error('Error saving local review settings:', error);
2086
- res.status(500).json({
2087
- error: 'Failed to save review settings'
2088
- });
2089
- }
2090
- });
2091
-
2092
- /**
2093
- * Get all analysis runs for a local review
2094
- */
2095
- router.get('/api/local/:reviewId/analysis-runs', async (req, res) => {
2096
- try {
2097
- const reviewId = parseInt(req.params.reviewId, 10);
2098
-
2099
- if (isNaN(reviewId) || reviewId <= 0) {
2100
- return res.status(400).json({ error: 'Invalid review ID' });
2101
- }
2102
-
2103
- const db = req.app.get('db');
2104
- const analysisRunRepo = new AnalysisRunRepository(db);
2105
- const runs = await analysisRunRepo.getByReviewId(reviewId);
2106
-
2107
- res.json({ runs: runs.map(r => ({
2108
- ...r,
2109
- levels_config: r.levels_config ? JSON.parse(r.levels_config) : null
2110
- })) });
2111
- } catch (error) {
2112
- logger.error('Error fetching analysis runs:', error);
2113
- res.status(500).json({ error: 'Failed to fetch analysis runs' });
2114
- }
2115
- });
2116
-
2117
- /**
2118
- * Get the most recent analysis run for a local review
2119
- */
2120
- router.get('/api/local/:reviewId/analysis-runs/latest', async (req, res) => {
2121
- try {
2122
- const reviewId = parseInt(req.params.reviewId, 10);
2123
-
2124
- if (isNaN(reviewId) || reviewId <= 0) {
2125
- return res.status(400).json({ error: 'Invalid review ID' });
2126
- }
2127
-
2128
- const db = req.app.get('db');
2129
- const analysisRunRepo = new AnalysisRunRepository(db);
2130
- const run = await analysisRunRepo.getLatestByReviewId(reviewId);
2131
-
2132
- if (!run) {
2133
- return res.status(404).json({ error: 'No analysis runs found' });
2134
- }
2135
-
2136
- res.json({ run });
2137
1229
  } catch (error) {
2138
- logger.error('Error fetching latest analysis run:', error);
2139
- res.status(500).json({ error: 'Failed to fetch latest analysis run' });
1230
+ logger.error('Error starting local council analysis:', error);
1231
+ res.status(500).json({ error: 'Failed to start council analysis' });
2140
1232
  }
2141
1233
  });
2142
1234