@in-the-loop-labs/pair-review 2.6.3 → 2.7.0

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 (43) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  4. package/public/css/pr.css +194 -0
  5. package/public/index.html +168 -3
  6. package/public/js/components/AIPanel.js +16 -2
  7. package/public/js/components/ChatPanel.js +41 -6
  8. package/public/js/components/ConfirmDialog.js +21 -2
  9. package/public/js/components/CouncilProgressModal.js +13 -0
  10. package/public/js/components/DiffOptionsDropdown.js +410 -23
  11. package/public/js/components/SuggestionNavigator.js +12 -5
  12. package/public/js/components/TabTitle.js +96 -0
  13. package/public/js/components/Toast.js +6 -0
  14. package/public/js/index.js +648 -43
  15. package/public/js/local.js +569 -76
  16. package/public/js/modules/analysis-history.js +3 -2
  17. package/public/js/modules/comment-manager.js +5 -0
  18. package/public/js/modules/comment-minimizer.js +304 -0
  19. package/public/js/pr.js +82 -6
  20. package/public/local.html +14 -0
  21. package/public/pr.html +3 -0
  22. package/src/ai/analyzer.js +17 -11
  23. package/src/config.js +2 -0
  24. package/src/database.js +590 -39
  25. package/src/git/base-branch.js +173 -0
  26. package/src/git/sha-abbrev.js +35 -0
  27. package/src/github/client.js +32 -1
  28. package/src/hooks/hook-runner.js +100 -0
  29. package/src/hooks/payloads.js +212 -0
  30. package/src/local-review.js +468 -129
  31. package/src/local-scope.js +58 -0
  32. package/src/main.js +55 -4
  33. package/src/routes/analyses.js +73 -10
  34. package/src/routes/chat.js +33 -0
  35. package/src/routes/config.js +1 -0
  36. package/src/routes/local.js +734 -68
  37. package/src/routes/mcp.js +20 -10
  38. package/src/routes/pr.js +90 -12
  39. package/src/routes/setup.js +1 -0
  40. package/src/routes/worktrees.js +212 -148
  41. package/src/server.js +30 -0
  42. package/src/setup/local-setup.js +46 -5
  43. package/src/setup/pr-setup.js +28 -5
package/src/database.js CHANGED
@@ -20,7 +20,7 @@ function getDbPath() {
20
20
  /**
21
21
  * Current schema version - increment this when adding new migrations
22
22
  */
23
- const CURRENT_SCHEMA_VERSION = 27;
23
+ const CURRENT_SCHEMA_VERSION = 33;
24
24
 
25
25
  /**
26
26
  * Database schema SQL statements
@@ -42,7 +42,12 @@ const SCHEMA_SQL = {
42
42
  local_path TEXT,
43
43
  local_head_sha TEXT,
44
44
  summary TEXT,
45
- name TEXT
45
+ name TEXT,
46
+ local_mode TEXT DEFAULT 'uncommitted',
47
+ local_base_branch TEXT,
48
+ local_head_branch TEXT,
49
+ local_scope_start TEXT DEFAULT 'unstaged',
50
+ local_scope_end TEXT DEFAULT 'untracked'
46
51
  )
47
52
  `,
48
53
 
@@ -94,6 +99,7 @@ const SCHEMA_SQL = {
94
99
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
95
100
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
96
101
 
102
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
97
103
  FOREIGN KEY (adopted_as_id) REFERENCES comments(id),
98
104
  FOREIGN KEY (parent_id) REFERENCES comments(id)
99
105
  )
@@ -113,6 +119,7 @@ const SCHEMA_SQL = {
113
119
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
114
120
  pr_data TEXT,
115
121
  last_ai_run_id TEXT,
122
+ last_accessed_at TEXT,
116
123
  UNIQUE(pr_number, repository)
117
124
  )
118
125
  `,
@@ -141,6 +148,7 @@ const SCHEMA_SQL = {
141
148
  default_tab TEXT,
142
149
  default_chat_instructions TEXT,
143
150
  local_path TEXT,
151
+ auto_branch_review INTEGER DEFAULT 0,
144
152
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
145
153
  updated_at TEXT DEFAULT CURRENT_TIMESTAMP
146
154
  )
@@ -167,6 +175,8 @@ const SCHEMA_SQL = {
167
175
  parent_run_id TEXT,
168
176
  config_type TEXT DEFAULT 'single',
169
177
  levels_config TEXT,
178
+ scope_start TEXT,
179
+ scope_end TEXT,
170
180
  FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
171
181
  )
172
182
  `,
@@ -220,8 +230,8 @@ const SCHEMA_SQL = {
220
230
  status TEXT DEFAULT 'active' CHECK(status IN ('active', 'closed', 'error')),
221
231
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
222
232
  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
223
- FOREIGN KEY (review_id) REFERENCES reviews(id),
224
- FOREIGN KEY (context_comment_id) REFERENCES comments(id)
233
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
234
+ FOREIGN KEY (context_comment_id) REFERENCES comments(id) ON DELETE SET NULL
225
235
  )
226
236
  `,
227
237
 
@@ -277,10 +287,11 @@ const INDEX_SQL = [
277
287
  'CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status)',
278
288
  'CREATE INDEX IF NOT EXISTS idx_comments_file_level ON comments(review_id, file, is_file_level)',
279
289
  'CREATE UNIQUE INDEX IF NOT EXISTS idx_pr_metadata_unique ON pr_metadata(pr_number, repository)',
290
+ 'CREATE INDEX IF NOT EXISTS idx_pr_metadata_last_accessed ON pr_metadata(last_accessed_at)',
280
291
  'CREATE INDEX IF NOT EXISTS idx_worktrees_last_accessed ON worktrees(last_accessed_at)',
281
292
  'CREATE INDEX IF NOT EXISTS idx_worktrees_repo ON worktrees(repository)',
282
293
  'CREATE UNIQUE INDEX IF NOT EXISTS idx_repo_settings_repository ON repo_settings(repository)',
283
- 'CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_local ON reviews(local_path, local_head_sha) WHERE review_type = \'local\'',
294
+ 'CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_local ON reviews(local_path, local_head_sha, local_head_branch) WHERE review_type = \'local\'',
284
295
  // Partial unique index for PR reviews only (NULL pr_number values for local reviews should not conflict)
285
296
  'CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_pr_unique ON reviews(pr_number, repository) WHERE review_type = \'pr\'',
286
297
  // Analysis runs indexes
@@ -1230,6 +1241,329 @@ const MIGRATIONS = {
1230
1241
  }
1231
1242
 
1232
1243
  console.log('Migration to schema version 27 complete');
1244
+ },
1245
+
1246
+ 28: (db) => {
1247
+ console.log('Migrating to schema version 28: Add branch review columns');
1248
+
1249
+ // Add local_mode to reviews
1250
+ if (!columnExists(db, 'reviews', 'local_mode')) {
1251
+ try {
1252
+ db.prepare("ALTER TABLE reviews ADD COLUMN local_mode TEXT DEFAULT 'uncommitted'").run();
1253
+ console.log(' Added local_mode column to reviews');
1254
+ } catch (error) {
1255
+ if (!error.message.includes('duplicate column name')) throw error;
1256
+ console.log(' Column local_mode already exists (race condition)');
1257
+ }
1258
+ } else {
1259
+ console.log(' Column local_mode already exists');
1260
+ }
1261
+
1262
+ // Add local_base_branch to reviews
1263
+ if (!columnExists(db, 'reviews', 'local_base_branch')) {
1264
+ try {
1265
+ db.prepare('ALTER TABLE reviews ADD COLUMN local_base_branch TEXT').run();
1266
+ console.log(' Added local_base_branch column to reviews');
1267
+ } catch (error) {
1268
+ if (!error.message.includes('duplicate column name')) throw error;
1269
+ console.log(' Column local_base_branch already exists (race condition)');
1270
+ }
1271
+ } else {
1272
+ console.log(' Column local_base_branch already exists');
1273
+ }
1274
+
1275
+ // Add auto_branch_review to repo_settings
1276
+ if (!columnExists(db, 'repo_settings', 'auto_branch_review')) {
1277
+ try {
1278
+ db.prepare('ALTER TABLE repo_settings ADD COLUMN auto_branch_review INTEGER DEFAULT 0').run();
1279
+ console.log(' Added auto_branch_review column to repo_settings');
1280
+ } catch (error) {
1281
+ if (!error.message.includes('duplicate column name')) throw error;
1282
+ console.log(' Column auto_branch_review already exists (race condition)');
1283
+ }
1284
+ } else {
1285
+ console.log(' Column auto_branch_review already exists');
1286
+ }
1287
+
1288
+ console.log('Migration to schema version 28 complete');
1289
+ },
1290
+
1291
+ // Migration to version 29: adds scope columns for flexible diff range selection
1292
+ 29: (db) => {
1293
+ console.log('Migrating to schema version 29: Add scope columns to reviews and analysis_runs');
1294
+
1295
+ // Add local_scope_start to reviews
1296
+ if (!columnExists(db, 'reviews', 'local_scope_start')) {
1297
+ try {
1298
+ db.prepare("ALTER TABLE reviews ADD COLUMN local_scope_start TEXT DEFAULT 'unstaged'").run();
1299
+ console.log(' Added local_scope_start column to reviews');
1300
+ } catch (error) {
1301
+ if (!error.message.includes('duplicate column name')) throw error;
1302
+ console.log(' Column local_scope_start already exists (race condition)');
1303
+ }
1304
+ } else {
1305
+ console.log(' Column local_scope_start already exists');
1306
+ }
1307
+
1308
+ // Add local_scope_end to reviews
1309
+ if (!columnExists(db, 'reviews', 'local_scope_end')) {
1310
+ try {
1311
+ db.prepare("ALTER TABLE reviews ADD COLUMN local_scope_end TEXT DEFAULT 'untracked'").run();
1312
+ console.log(' Added local_scope_end column to reviews');
1313
+ } catch (error) {
1314
+ if (!error.message.includes('duplicate column name')) throw error;
1315
+ console.log(' Column local_scope_end already exists (race condition)');
1316
+ }
1317
+ } else {
1318
+ console.log(' Column local_scope_end already exists');
1319
+ }
1320
+
1321
+ // Migrate existing data from local_mode to new scope columns
1322
+ // uncommitted → start='unstaged', end='untracked'
1323
+ db.prepare(`
1324
+ UPDATE reviews SET local_scope_start = 'unstaged', local_scope_end = 'untracked'
1325
+ WHERE local_mode = 'uncommitted' OR local_mode IS NULL
1326
+ `).run();
1327
+ console.log(' Migrated uncommitted reviews to scope columns');
1328
+
1329
+ // branch → start='branch', end='branch'
1330
+ db.prepare(`
1331
+ UPDATE reviews SET local_scope_start = 'branch', local_scope_end = 'branch'
1332
+ WHERE local_mode = 'branch'
1333
+ `).run();
1334
+ console.log(' Migrated branch reviews to scope columns');
1335
+
1336
+ // Add scope_start to analysis_runs
1337
+ if (!columnExists(db, 'analysis_runs', 'scope_start')) {
1338
+ try {
1339
+ db.prepare('ALTER TABLE analysis_runs ADD COLUMN scope_start TEXT').run();
1340
+ console.log(' Added scope_start column to analysis_runs');
1341
+ } catch (error) {
1342
+ if (!error.message.includes('duplicate column name')) throw error;
1343
+ console.log(' Column scope_start already exists (race condition)');
1344
+ }
1345
+ } else {
1346
+ console.log(' Column scope_start already exists');
1347
+ }
1348
+
1349
+ // Add scope_end to analysis_runs
1350
+ if (!columnExists(db, 'analysis_runs', 'scope_end')) {
1351
+ try {
1352
+ db.prepare('ALTER TABLE analysis_runs ADD COLUMN scope_end TEXT').run();
1353
+ console.log(' Added scope_end column to analysis_runs');
1354
+ } catch (error) {
1355
+ if (!error.message.includes('duplicate column name')) throw error;
1356
+ console.log(' Column scope_end already exists (race condition)');
1357
+ }
1358
+ } else {
1359
+ console.log(' Column scope_end already exists');
1360
+ }
1361
+
1362
+ console.log('Migration to schema version 29 complete');
1363
+ },
1364
+
1365
+ // Migration to version 30: adds head branch tracking for branch-aware session identity
1366
+ 30: (db) => {
1367
+ console.log('Migrating to schema version 30: Add local_head_branch to reviews');
1368
+
1369
+ if (!columnExists(db, 'reviews', 'local_head_branch')) {
1370
+ try {
1371
+ db.prepare('ALTER TABLE reviews ADD COLUMN local_head_branch TEXT').run();
1372
+ console.log(' Added local_head_branch column to reviews');
1373
+ } catch (error) {
1374
+ if (!error.message.includes('duplicate column name')) throw error;
1375
+ console.log(' Column local_head_branch already exists (race condition)');
1376
+ }
1377
+ } else {
1378
+ console.log(' Column local_head_branch already exists');
1379
+ }
1380
+
1381
+ // Recreate unique index to include local_head_branch in session identity
1382
+ db.prepare('DROP INDEX IF EXISTS idx_reviews_local').run();
1383
+ db.prepare("CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_local ON reviews(local_path, local_head_sha, local_head_branch) WHERE review_type = 'local'").run();
1384
+ console.log(' Recreated idx_reviews_local with local_head_branch');
1385
+
1386
+ console.log('Migration to schema version 30 complete');
1387
+ },
1388
+
1389
+ // Migration to version 31: Add last_accessed_at to pr_metadata and backfill from worktrees
1390
+ 31: (db) => {
1391
+ console.log('Migrating to schema version 31: Add last_accessed_at to pr_metadata');
1392
+
1393
+ const hasCol = columnExists(db, 'pr_metadata', 'last_accessed_at');
1394
+ if (!hasCol) {
1395
+ try {
1396
+ db.prepare('ALTER TABLE pr_metadata ADD COLUMN last_accessed_at TEXT').run();
1397
+ console.log(' Added last_accessed_at column to pr_metadata');
1398
+ } catch (error) {
1399
+ if (!error.message.includes('duplicate column name')) {
1400
+ throw error;
1401
+ }
1402
+ console.log(' Column last_accessed_at already exists (race condition)');
1403
+ }
1404
+ } else {
1405
+ console.log(' Column last_accessed_at already exists');
1406
+ }
1407
+
1408
+ // Backfill from worktrees table (best available access timestamp)
1409
+ const backfilled = db.prepare(`
1410
+ UPDATE pr_metadata
1411
+ SET last_accessed_at = (
1412
+ SELECT MAX(w.last_accessed_at) FROM worktrees w
1413
+ WHERE w.pr_number = pr_metadata.pr_number
1414
+ AND w.repository = pr_metadata.repository COLLATE NOCASE
1415
+ )
1416
+ WHERE last_accessed_at IS NULL
1417
+ `).run();
1418
+ console.log(` Backfilled ${backfilled.changes} pr_metadata rows from worktrees`);
1419
+
1420
+ // For any remaining rows without a worktree, use updated_at as fallback
1421
+ const fallback = db.prepare(`
1422
+ UPDATE pr_metadata
1423
+ SET last_accessed_at = updated_at
1424
+ WHERE last_accessed_at IS NULL
1425
+ `).run();
1426
+ if (fallback.changes > 0) {
1427
+ console.log(` Used updated_at fallback for ${fallback.changes} pr_metadata rows`);
1428
+ }
1429
+
1430
+ console.log('Migration to schema version 31 complete');
1431
+ },
1432
+
1433
+ // Migration to version 32: Add ON DELETE CASCADE/SET NULL to chat_sessions FKs
1434
+ // SQLite doesn't support ALTER CONSTRAINT, so we recreate the table.
1435
+ 32: (db) => {
1436
+ console.log('Migrating to schema version 32: Add cascade deletes to chat_sessions FKs');
1437
+
1438
+ if (!tableExists(db, 'chat_sessions')) {
1439
+ console.log(' chat_sessions table does not exist, skipping');
1440
+ console.log('Migration to schema version 32 complete');
1441
+ return;
1442
+ }
1443
+
1444
+ db.prepare(`CREATE TABLE IF NOT EXISTS chat_sessions_new (
1445
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1446
+ review_id INTEGER NOT NULL,
1447
+ context_comment_id INTEGER,
1448
+ agent_session_id TEXT,
1449
+ provider TEXT NOT NULL,
1450
+ model TEXT,
1451
+ status TEXT DEFAULT 'active' CHECK(status IN ('active', 'closed', 'error')),
1452
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
1453
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
1454
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
1455
+ FOREIGN KEY (context_comment_id) REFERENCES comments(id) ON DELETE SET NULL
1456
+ )`).run();
1457
+
1458
+ db.prepare(`INSERT INTO chat_sessions_new
1459
+ SELECT * FROM chat_sessions`).run();
1460
+
1461
+ db.prepare('DROP TABLE chat_sessions').run();
1462
+ db.prepare('ALTER TABLE chat_sessions_new RENAME TO chat_sessions').run();
1463
+
1464
+ console.log(' Recreated chat_sessions with ON DELETE CASCADE/SET NULL');
1465
+ console.log('Migration to schema version 32 complete');
1466
+ },
1467
+
1468
+ // Migration to version 33: Add ON DELETE CASCADE to comments FK for review_id
1469
+ // SQLite doesn't support ALTER CONSTRAINT, so we recreate the table.
1470
+ 33: (db) => {
1471
+ console.log('Migrating to schema version 33: Add cascade delete to comments.review_id FK');
1472
+
1473
+ if (!tableExists(db, 'comments')) {
1474
+ console.log(' comments table does not exist, skipping');
1475
+ console.log('Migration to schema version 33 complete');
1476
+ return;
1477
+ }
1478
+
1479
+ // Disable FK checks for the rebuild — old databases may have orphaned
1480
+ // comments (review_id pointing to deleted reviews) because FK enforcement
1481
+ // wasn't always active or CASCADE wasn't defined.
1482
+ db.pragma('foreign_keys = OFF');
1483
+
1484
+ // Clean up orphaned comments before rebuild
1485
+ if (tableExists(db, 'reviews')) {
1486
+ const orphaned = db.prepare(
1487
+ 'DELETE FROM comments WHERE review_id IS NOT NULL AND review_id NOT IN (SELECT id FROM reviews)'
1488
+ ).run();
1489
+ if (orphaned.changes > 0) {
1490
+ console.log(` Cleaned up ${orphaned.changes} orphaned comments`);
1491
+ }
1492
+ }
1493
+
1494
+ db.prepare(`CREATE TABLE IF NOT EXISTS comments_rebuild (
1495
+ id INTEGER PRIMARY KEY,
1496
+ review_id INTEGER,
1497
+ source TEXT,
1498
+ author TEXT,
1499
+
1500
+ ai_run_id TEXT,
1501
+ ai_level INTEGER,
1502
+ ai_confidence REAL,
1503
+
1504
+ file TEXT,
1505
+ line_start INTEGER,
1506
+ line_end INTEGER,
1507
+ diff_position INTEGER,
1508
+ side TEXT DEFAULT 'RIGHT' CHECK(side IN ('LEFT', 'RIGHT')),
1509
+ commit_sha TEXT,
1510
+ type TEXT,
1511
+ title TEXT,
1512
+ body TEXT,
1513
+ suggestion_text TEXT,
1514
+ reasoning TEXT,
1515
+
1516
+ status TEXT DEFAULT 'active' CHECK(status IN ('active', 'dismissed', 'adopted', 'submitted', 'draft', 'inactive')),
1517
+ adopted_as_id INTEGER,
1518
+ parent_id INTEGER,
1519
+ is_file_level INTEGER DEFAULT 0,
1520
+
1521
+ voice_id TEXT,
1522
+ is_raw INTEGER DEFAULT 0,
1523
+
1524
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1525
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
1526
+
1527
+ FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE,
1528
+ FOREIGN KEY (adopted_as_id) REFERENCES comments(id),
1529
+ FOREIGN KEY (parent_id) REFERENCES comments(id)
1530
+ )`).run();
1531
+
1532
+ // Use explicit column names — SELECT * would break if column order differs
1533
+ // (e.g., columns added via ALTER TABLE ADD COLUMN are appended to the end)
1534
+ const cols = [
1535
+ 'id', 'review_id', 'source', 'author',
1536
+ 'ai_run_id', 'ai_level', 'ai_confidence',
1537
+ 'file', 'line_start', 'line_end', 'diff_position',
1538
+ 'side', 'commit_sha', 'type', 'title', 'body',
1539
+ 'suggestion_text', 'reasoning',
1540
+ 'status', 'adopted_as_id', 'parent_id', 'is_file_level',
1541
+ 'voice_id', 'is_raw',
1542
+ 'created_at', 'updated_at'
1543
+ ].join(', ');
1544
+ // Wrap in transaction so a crash between DROP and RENAME can't strand
1545
+ // data in comments_rebuild. PRAGMA foreign_keys = OFF must stay outside
1546
+ // the transaction (SQLite requirement).
1547
+ const rebuild = db.transaction(() => {
1548
+ db.prepare(`INSERT INTO comments_rebuild (${cols}) SELECT ${cols} FROM comments`).run();
1549
+ db.prepare('DROP TABLE comments').run();
1550
+ db.prepare('ALTER TABLE comments_rebuild RENAME TO comments').run();
1551
+ });
1552
+ rebuild();
1553
+
1554
+ // Re-enable FK checks
1555
+ db.pragma('foreign_keys = ON');
1556
+
1557
+ // Recreate all indexes on the new table
1558
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_comments_review_file ON comments(review_id, file, line_start)').run();
1559
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_comments_ai_run ON comments(ai_run_id)').run();
1560
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status)').run();
1561
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_comments_file_level ON comments(review_id, file, is_file_level)').run();
1562
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_comments_voice ON comments(voice_id)').run();
1563
+ db.prepare('CREATE INDEX IF NOT EXISTS idx_comments_is_raw ON comments(is_raw)').run();
1564
+
1565
+ console.log(' Recreated comments with ON DELETE CASCADE for review_id');
1566
+ console.log('Migration to schema version 33 complete');
1233
1567
  }
1234
1568
  };
1235
1569
 
@@ -1729,7 +2063,7 @@ class RepoSettingsRepository {
1729
2063
  */
1730
2064
  async getRepoSettings(repository) {
1731
2065
  const row = await queryOne(this.db, `
1732
- SELECT id, repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, created_at, updated_at
2066
+ SELECT id, repository, default_instructions, default_provider, default_model, default_council_id, default_tab, default_chat_instructions, local_path, auto_branch_review, created_at, updated_at
1733
2067
  FROM repo_settings
1734
2068
  WHERE repository = ? COLLATE NOCASE
1735
2069
  `, [repository]);
@@ -2420,6 +2754,11 @@ class ReviewRepository {
2420
2754
  params.push(updates.name);
2421
2755
  }
2422
2756
 
2757
+ if (updates.local_head_branch !== undefined) {
2758
+ setClauses.push('local_head_branch = ?');
2759
+ params.push(updates.local_head_branch);
2760
+ }
2761
+
2423
2762
  if (updates.submittedAt !== undefined) {
2424
2763
  setClauses.push('submitted_at = ?');
2425
2764
  const submittedAt = updates.submittedAt instanceof Date
@@ -2500,16 +2839,17 @@ class ReviewRepository {
2500
2839
  * @param {string} reviewInfo.repository - Repository in owner/repo format
2501
2840
  * @param {Object} [reviewInfo.reviewData] - Additional review data
2502
2841
  * @param {string} [reviewInfo.customInstructions] - Custom instructions
2503
- * @returns {Promise<Object>} Review record (existing or newly created)
2842
+ * @returns {Promise<{review: Object, created: boolean}>} Tuple with review record and creation flag
2504
2843
  */
2505
2844
  async getOrCreate({ prNumber, repository, reviewData = null, customInstructions = null }) {
2506
2845
  const existing = await this.getReviewByPR(prNumber, repository);
2507
2846
 
2508
2847
  if (existing) {
2509
- return existing;
2848
+ return { review: existing, created: false };
2510
2849
  }
2511
2850
 
2512
- return this.createReview({ prNumber, repository, reviewData, customInstructions });
2851
+ const review = await this.createReview({ prNumber, repository, reviewData, customInstructions });
2852
+ return { review, created: true };
2513
2853
  }
2514
2854
 
2515
2855
  /**
@@ -2572,19 +2912,6 @@ class ReviewRepository {
2572
2912
  }
2573
2913
  }
2574
2914
 
2575
- /**
2576
- * Delete a review record by ID
2577
- * @param {number} id - Review ID
2578
- * @returns {Promise<boolean>} True if record was deleted
2579
- */
2580
- async deleteReview(id) {
2581
- const result = await run(this.db, `
2582
- DELETE FROM reviews WHERE id = ?
2583
- `, [id]);
2584
-
2585
- return result.changes > 0;
2586
- }
2587
-
2588
2915
  /**
2589
2916
  * List reviews for a repository
2590
2917
  * @param {string} repository - Repository in owner/repo format
@@ -2617,42 +2944,106 @@ class ReviewRepository {
2617
2944
  * @param {string} context.repository - Repository identifier (can be derived from path)
2618
2945
  * @returns {Promise<number>} The review ID
2619
2946
  */
2620
- async upsertLocalReview({ localPath, localHeadSha, repository }) {
2621
- // Try to find existing local review by path and SHA
2622
- const existing = await this.getLocalReview(localPath, localHeadSha);
2947
+ async upsertLocalReview({ localPath, localHeadSha, repository, scopeStart, scopeEnd, localMode, localBaseBranch, localHeadBranch }) {
2948
+ // Try to find existing local review by path, SHA, and branch
2949
+ const existing = await this.getLocalReview(localPath, localHeadSha, localHeadBranch);
2623
2950
 
2624
2951
  if (existing) {
2625
- // Update the updated_at timestamp
2952
+ // Update the updated_at timestamp (and scope/base if provided)
2953
+ const updates = ['updated_at = CURRENT_TIMESTAMP'];
2954
+ const params = [];
2955
+ if (scopeStart !== undefined) {
2956
+ updates.push('local_scope_start = ?');
2957
+ params.push(scopeStart);
2958
+ // Also write local_mode for backward compat during transition
2959
+ updates.push('local_mode = ?');
2960
+ params.push(scopeStart === 'branch' ? 'branch' : 'uncommitted');
2961
+ } else if (localMode !== undefined) {
2962
+ updates.push('local_mode = ?');
2963
+ params.push(localMode);
2964
+ }
2965
+ if (scopeEnd !== undefined) {
2966
+ updates.push('local_scope_end = ?');
2967
+ params.push(scopeEnd);
2968
+ }
2969
+ if (localBaseBranch !== undefined) {
2970
+ updates.push('local_base_branch = ?');
2971
+ params.push(localBaseBranch);
2972
+ }
2973
+ if (localHeadBranch !== undefined) {
2974
+ updates.push('local_head_branch = ?');
2975
+ params.push(localHeadBranch);
2976
+ }
2977
+ params.push(existing.id);
2626
2978
  await run(this.db, `
2627
2979
  UPDATE reviews
2628
- SET updated_at = CURRENT_TIMESTAMP
2980
+ SET ${updates.join(', ')}
2629
2981
  WHERE id = ?
2630
- `, [existing.id]);
2982
+ `, params);
2631
2983
  return existing.id;
2632
2984
  }
2633
2985
 
2986
+ // Derive local_mode from scopeStart for backward compat
2987
+ const effectiveMode = scopeStart === 'branch' ? 'branch' : (localMode || 'uncommitted');
2988
+ const effectiveScopeStart = scopeStart || 'unstaged';
2989
+ const effectiveScopeEnd = scopeEnd || 'untracked';
2990
+
2634
2991
  // Create new local review
2635
2992
  const result = await run(this.db, `
2636
- INSERT INTO reviews (pr_number, repository, status, review_type, local_path, local_head_sha)
2637
- VALUES (NULL, ?, 'draft', 'local', ?, ?)
2638
- `, [repository, localPath, localHeadSha]);
2993
+ INSERT INTO reviews (pr_number, repository, status, review_type, local_path, local_head_sha, local_mode, local_base_branch, local_head_branch, local_scope_start, local_scope_end)
2994
+ VALUES (NULL, ?, 'draft', 'local', ?, ?, ?, ?, ?, ?, ?)
2995
+ `, [repository, localPath, localHeadSha, effectiveMode, localBaseBranch || null, localHeadBranch || null, effectiveScopeStart, effectiveScopeEnd]);
2639
2996
 
2640
2997
  return result.lastID;
2641
2998
  }
2642
2999
 
2643
3000
  /**
2644
- * Get a local review by path and HEAD SHA
3001
+ * Get a local review by path, HEAD SHA, and branch.
2645
3002
  * @param {string} localPath - Absolute path to the local repository
2646
3003
  * @param {string} localHeadSha - Current HEAD SHA of the repository
3004
+ * @param {string} [headBranch] - Branch name; when falsy, matches only NULL-branch sessions
2647
3005
  * @returns {Promise<Object|null>} Review record or null if not found
2648
3006
  */
2649
- async getLocalReview(localPath, localHeadSha) {
3007
+ async getLocalReview(localPath, localHeadSha, headBranch) {
3008
+ const branchClause = headBranch
3009
+ ? 'AND local_head_branch = ?'
3010
+ : 'AND local_head_branch IS NULL';
3011
+ const params = [localPath, localHeadSha];
3012
+ if (headBranch) params.push(headBranch);
2650
3013
  const row = await queryOne(this.db, `
2651
3014
  SELECT id, pr_number, repository, status, review_id,
2652
3015
  created_at, updated_at, submitted_at, review_data, custom_instructions,
2653
- review_type, local_path, local_head_sha, summary, name
3016
+ review_type, local_path, local_head_sha, summary, name,
3017
+ local_mode, local_base_branch, local_head_branch, local_scope_start, local_scope_end
3018
+ FROM reviews
3019
+ WHERE review_type = 'local' AND local_path = ? AND local_head_sha = ? ${branchClause}
3020
+ `, params);
3021
+
3022
+ if (!row) return null;
3023
+
3024
+ return {
3025
+ ...row,
3026
+ review_data: row.review_data ? JSON.parse(row.review_data) : null
3027
+ };
3028
+ }
3029
+
3030
+ /**
3031
+ * Get a local review by path and HEAD SHA only (ignoring branch).
3032
+ * Used by external callers (MCP, analysis results) that may not have branch context.
3033
+ * @param {string} localPath - Absolute path to the local repository
3034
+ * @param {string} localHeadSha - Current HEAD SHA of the repository
3035
+ * @returns {Promise<Object|null>} Review record or null if not found
3036
+ */
3037
+ async getLocalReviewByPathAndSha(localPath, localHeadSha) {
3038
+ const row = await queryOne(this.db, `
3039
+ SELECT id, pr_number, repository, status, review_id,
3040
+ created_at, updated_at, submitted_at, review_data, custom_instructions,
3041
+ review_type, local_path, local_head_sha, summary, name,
3042
+ local_mode, local_base_branch, local_head_branch, local_scope_start, local_scope_end
2654
3043
  FROM reviews
2655
3044
  WHERE review_type = 'local' AND local_path = ? AND local_head_sha = ?
3045
+ ORDER BY updated_at DESC
3046
+ LIMIT 1
2656
3047
  `, [localPath, localHeadSha]);
2657
3048
 
2658
3049
  if (!row) return null;
@@ -2663,6 +3054,102 @@ class ReviewRepository {
2663
3054
  };
2664
3055
  }
2665
3056
 
3057
+ /**
3058
+ * Find a local review by path and SHA, trying branch-exact match first,
3059
+ * then falling back to branch-agnostic lookup.
3060
+ * @param {string} localPath - Absolute path to the local repository
3061
+ * @param {string} localHeadSha - Current HEAD SHA of the repository
3062
+ * @param {string} [headBranch] - Branch name for exact match
3063
+ * @returns {Promise<Object|null>} Review record or null if not found
3064
+ */
3065
+ async findLocalReview(localPath, localHeadSha, headBranch) {
3066
+ const review = await this.getLocalReview(localPath, localHeadSha, headBranch);
3067
+ if (review) return review;
3068
+ // Only adopt sessions that predate branch tracking (NULL branch)
3069
+ const fallback = await this.getLocalReviewByPathAndSha(localPath, localHeadSha);
3070
+ if (fallback && fallback.local_head_branch === null) return fallback;
3071
+ return null;
3072
+ }
3073
+
3074
+ /**
3075
+ * Get an existing branch-mode local review by path (ignoring HEAD SHA).
3076
+ * Branch-mode sessions persist across HEAD changes — only the path matters.
3077
+ * @param {string} localPath - Absolute path to the local repository
3078
+ * @returns {Promise<Object|null>} Most recent branch-mode review or null
3079
+ */
3080
+ async getLocalBranchReview(localPath, headBranch) {
3081
+ return this.getLocalBranchScopeReview(localPath, headBranch);
3082
+ }
3083
+
3084
+ /**
3085
+ * Get an existing branch-scope local review by path and head branch.
3086
+ * Branch-scope sessions persist across HEAD changes but are scoped to a specific branch.
3087
+ * @param {string} localPath - Absolute path to the local repository
3088
+ * @param {string} headBranch - Current branch name
3089
+ * @returns {Promise<Object|null>} Most recent branch-scope review or null
3090
+ */
3091
+ async getLocalBranchScopeReview(localPath, headBranch) {
3092
+ const row = await queryOne(this.db, `
3093
+ SELECT id, pr_number, repository, status, review_id,
3094
+ created_at, updated_at, submitted_at, review_data, custom_instructions,
3095
+ review_type, local_path, local_head_sha, summary, name,
3096
+ local_mode, local_base_branch, local_head_branch, local_scope_start, local_scope_end
3097
+ FROM reviews
3098
+ WHERE review_type = 'local' AND local_path = ? AND local_scope_start = 'branch' AND local_head_branch = ?
3099
+ ORDER BY updated_at DESC
3100
+ LIMIT 1
3101
+ `, [localPath, headBranch]);
3102
+ if (!row) return null;
3103
+ return { ...row, review_data: row.review_data ? JSON.parse(row.review_data) : null };
3104
+ }
3105
+
3106
+ /**
3107
+ * Update the HEAD SHA for a local review.
3108
+ * Used when branch-mode sessions persist across commits — the unique index
3109
+ * on (local_path, local_head_sha) requires careful handling.
3110
+ * @param {number} id - Review ID
3111
+ * @param {string} newHeadSha - New HEAD SHA to set
3112
+ * @returns {Promise<boolean>} True if record was updated
3113
+ */
3114
+ async updateLocalHeadSha(id, newHeadSha) {
3115
+ const result = await run(this.db, `
3116
+ UPDATE reviews
3117
+ SET local_head_sha = ?, updated_at = CURRENT_TIMESTAMP
3118
+ WHERE id = ?
3119
+ `, [newHeadSha, id]);
3120
+ return result.changes > 0;
3121
+ }
3122
+
3123
+ /**
3124
+ * Update the scope range and optionally the base branch for a local review.
3125
+ * @param {number} id - Review ID
3126
+ * @param {string} scopeStart - Scope start value (e.g. 'unstaged', 'staged', 'branch')
3127
+ * @param {string} scopeEnd - Scope end value (e.g. 'staged', 'untracked', 'branch')
3128
+ * @param {string} [baseBranch] - Base branch name (only relevant for branch scope)
3129
+ * @returns {Promise<boolean>} True if record was updated
3130
+ */
3131
+ async updateLocalScope(id, scopeStart, scopeEnd, baseBranch, headBranch) {
3132
+ const updates = ['local_scope_start = ?', 'local_scope_end = ?', 'updated_at = CURRENT_TIMESTAMP'];
3133
+ const params = [scopeStart, scopeEnd];
3134
+ // Also write local_mode for backward compat
3135
+ updates.push('local_mode = ?');
3136
+ params.push(scopeStart === 'branch' ? 'branch' : 'uncommitted');
3137
+ if (baseBranch !== undefined) {
3138
+ updates.push('local_base_branch = ?');
3139
+ params.push(baseBranch);
3140
+ }
3141
+ // Store head branch when entering branch scope, clear when leaving
3142
+ updates.push('local_head_branch = ?');
3143
+ params.push(scopeStart === 'branch' ? (headBranch || null) : null);
3144
+ params.push(id);
3145
+ const result = await run(this.db, `
3146
+ UPDATE reviews
3147
+ SET ${updates.join(', ')}
3148
+ WHERE id = ?
3149
+ `, params);
3150
+ return result.changes > 0;
3151
+ }
3152
+
2666
3153
  /**
2667
3154
  * Get a local review by its database ID
2668
3155
  * @param {number} id - Review ID
@@ -2672,7 +3159,8 @@ class ReviewRepository {
2672
3159
  const row = await queryOne(this.db, `
2673
3160
  SELECT id, pr_number, repository, status, review_id,
2674
3161
  created_at, updated_at, submitted_at, review_data, custom_instructions,
2675
- review_type, local_path, local_head_sha, summary, name
3162
+ review_type, local_path, local_head_sha, summary, name,
3163
+ local_mode, local_base_branch, local_head_branch, local_scope_start, local_scope_end
2676
3164
  FROM reviews
2677
3165
  WHERE id = ? AND review_type = 'local'
2678
3166
  `, [id]);
@@ -2798,6 +3286,69 @@ class ReviewRepository {
2798
3286
  `, [reviewId]);
2799
3287
  return result.changes > 0;
2800
3288
  }
3289
+
3290
+ /**
3291
+ * Find reviews older than the given cutoff date (based on updated_at).
3292
+ * @param {string} cutoffDate - ISO 8601 date string
3293
+ * @returns {Promise<Array<{id: number, pr_number: number|null, repository: string, review_type: string}>>}
3294
+ */
3295
+ async findStale(cutoffDate) {
3296
+ return query(this.db, `
3297
+ SELECT id, pr_number, repository, review_type
3298
+ FROM reviews
3299
+ WHERE updated_at < ?
3300
+ `, [cutoffDate]);
3301
+ }
3302
+
3303
+ /**
3304
+ * Delete a review and all associated data in a single transaction.
3305
+ *
3306
+ * Cascade-deleted by FK constraints: analysis_runs, local_diffs, github_reviews,
3307
+ * chat_sessions (→ chat_messages), context_files, comments.
3308
+ *
3309
+ * Orphan-cleaned explicitly: pr_metadata, github_pr_cache (only when no other
3310
+ * reviews reference the same PR).
3311
+ *
3312
+ * @param {number} reviewId - Review ID to delete
3313
+ * @param {Object} [opts] - Options
3314
+ * @param {number|null} [opts.prNumber] - PR number (skips orphan cleanup if null)
3315
+ * @param {string|null} [opts.repository] - Repository in owner/repo format
3316
+ * @returns {Promise<boolean>} True if review was deleted
3317
+ */
3318
+ async deleteWithRelatedData(reviewId, { prNumber = null, repository = null } = {}) {
3319
+ return withTransaction(this.db, async () => {
3320
+ // Delete the review row — FK cascades handle related tables
3321
+ const result = await run(this.db, 'DELETE FROM reviews WHERE id = ?', [reviewId]);
3322
+
3323
+ if (result.changes === 0) return false;
3324
+
3325
+ // Clean up orphaned pr_metadata and github_pr_cache if this was a PR review
3326
+ if (prNumber != null && repository) {
3327
+ const remaining = await queryOne(this.db, `
3328
+ SELECT COUNT(*) as cnt FROM reviews
3329
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
3330
+ `, [prNumber, repository]);
3331
+
3332
+ if (remaining.cnt === 0) {
3333
+ await run(this.db, `
3334
+ DELETE FROM pr_metadata
3335
+ WHERE pr_number = ? AND repository = ? COLLATE NOCASE
3336
+ `, [prNumber, repository]);
3337
+
3338
+ // Parse owner/repo for github_pr_cache
3339
+ const parts = repository.split('/');
3340
+ if (parts.length === 2) {
3341
+ await run(this.db, `
3342
+ DELETE FROM github_pr_cache
3343
+ WHERE owner = ? AND repo = ? AND number = ?
3344
+ `, [parts[0], parts[1], prNumber]);
3345
+ }
3346
+ }
3347
+ }
3348
+
3349
+ return true;
3350
+ });
3351
+ }
2801
3352
  }
2802
3353
 
2803
3354
  /**
@@ -2927,14 +3478,14 @@ class AnalysisRunRepository {
2927
3478
  * @param {string} [runInfo.status='running'] - Initial status (default 'running'; pass 'completed' for externally-produced results)
2928
3479
  * @returns {Promise<Object>} Created analysis run record
2929
3480
  */
2930
- async create({ id, reviewId, provider = null, model = null, tier = null, customInstructions = null, repoInstructions = null, requestInstructions = null, headSha = null, diff = null, status = 'running', parentRunId = null, configType = 'single', levelsConfig = null }) {
3481
+ async create({ id, reviewId, provider = null, model = null, tier = null, customInstructions = null, repoInstructions = null, requestInstructions = null, headSha = null, diff = null, status = 'running', parentRunId = null, configType = 'single', levelsConfig = null, scopeStart = null, scopeEnd = null }) {
2931
3482
  const isTerminal = ['completed', 'failed', 'cancelled'].includes(status);
2932
3483
  const completedAt = isTerminal ? 'CURRENT_TIMESTAMP' : 'NULL';
2933
3484
  const levelsConfigJson = levelsConfig ? JSON.stringify(levelsConfig) : null;
2934
3485
  await run(this.db, `
2935
- INSERT INTO analysis_runs (id, review_id, provider, model, tier, custom_instructions, repo_instructions, request_instructions, head_sha, diff, status, completed_at, parent_run_id, config_type, levels_config)
2936
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${completedAt}, ?, ?, ?)
2937
- `, [id, reviewId, provider, model, tier, customInstructions, repoInstructions, requestInstructions, headSha, diff, status, parentRunId, configType, levelsConfigJson]);
3486
+ INSERT INTO analysis_runs (id, review_id, provider, model, tier, custom_instructions, repo_instructions, request_instructions, head_sha, diff, status, completed_at, parent_run_id, config_type, levels_config, scope_start, scope_end)
3487
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${completedAt}, ?, ?, ?, ?, ?)
3488
+ `, [id, reviewId, provider, model, tier, customInstructions, repoInstructions, requestInstructions, headSha, diff, status, parentRunId, configType, levelsConfigJson, scopeStart, scopeEnd]);
2938
3489
 
2939
3490
  // Query back the inserted row to return actual database values (including timestamps)
2940
3491
  return await this.getById(id);