@in-the-loop-labs/pair-review 2.6.3 → 3.0.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 (150) hide show
  1. package/.pi/extensions/task/index.ts +1 -1
  2. package/.pi/skills/review-roulette/SKILL.md +1 -1
  3. package/LICENSE +201 -674
  4. package/README.md +2 -2
  5. package/bin/pair-review.js +1 -1
  6. package/package.json +2 -2
  7. package/plugin/.claude-plugin/plugin.json +2 -2
  8. package/plugin-code-critic/.claude-plugin/plugin.json +2 -2
  9. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
  10. package/public/css/ai-summary-modal.css +1 -1
  11. package/public/css/pr.css +194 -0
  12. package/public/index.html +168 -3
  13. package/public/js/components/AIPanel.js +17 -3
  14. package/public/js/components/AISummaryModal.js +1 -1
  15. package/public/js/components/AdvancedConfigTab.js +1 -1
  16. package/public/js/components/AnalysisConfigModal.js +1 -1
  17. package/public/js/components/ChatPanel.js +42 -7
  18. package/public/js/components/ConfirmDialog.js +22 -3
  19. package/public/js/components/CouncilProgressModal.js +14 -1
  20. package/public/js/components/DiffOptionsDropdown.js +411 -24
  21. package/public/js/components/EmojiPicker.js +1 -1
  22. package/public/js/components/KeyboardShortcuts.js +1 -1
  23. package/public/js/components/PanelGroup.js +1 -1
  24. package/public/js/components/PreviewModal.js +1 -1
  25. package/public/js/components/ReviewModal.js +1 -1
  26. package/public/js/components/SplitButton.js +1 -1
  27. package/public/js/components/StatusIndicator.js +1 -1
  28. package/public/js/components/SuggestionNavigator.js +13 -6
  29. package/public/js/components/TabTitle.js +96 -0
  30. package/public/js/components/TextInputDialog.js +1 -1
  31. package/public/js/components/TimeoutSelect.js +1 -1
  32. package/public/js/components/Toast.js +7 -1
  33. package/public/js/components/VoiceCentricConfigTab.js +1 -1
  34. package/public/js/index.js +649 -44
  35. package/public/js/local.js +570 -77
  36. package/public/js/modules/analysis-history.js +4 -3
  37. package/public/js/modules/comment-manager.js +6 -1
  38. package/public/js/modules/comment-minimizer.js +304 -0
  39. package/public/js/modules/diff-context.js +1 -1
  40. package/public/js/modules/diff-renderer.js +1 -1
  41. package/public/js/modules/file-comment-manager.js +1 -1
  42. package/public/js/modules/file-list-merger.js +1 -1
  43. package/public/js/modules/gap-coordinates.js +1 -1
  44. package/public/js/modules/hunk-parser.js +1 -1
  45. package/public/js/modules/line-tracker.js +1 -1
  46. package/public/js/modules/panel-resizer.js +1 -1
  47. package/public/js/modules/storage-cleanup.js +1 -1
  48. package/public/js/modules/suggestion-manager.js +1 -1
  49. package/public/js/pr.js +83 -7
  50. package/public/js/repo-settings.js +1 -1
  51. package/public/js/utils/category-emoji.js +1 -1
  52. package/public/js/utils/file-order.js +1 -1
  53. package/public/js/utils/markdown.js +1 -1
  54. package/public/js/utils/suggestion-ui.js +1 -1
  55. package/public/js/utils/tier-icons.js +1 -1
  56. package/public/js/utils/time.js +1 -1
  57. package/public/js/ws-client.js +1 -1
  58. package/public/local.html +14 -0
  59. package/public/pr.html +3 -0
  60. package/public/setup.html +1 -1
  61. package/src/ai/analyzer.js +18 -12
  62. package/src/ai/claude-cli.js +1 -1
  63. package/src/ai/claude-provider.js +1 -1
  64. package/src/ai/codex-provider.js +1 -1
  65. package/src/ai/copilot-provider.js +1 -1
  66. package/src/ai/cursor-agent-provider.js +1 -1
  67. package/src/ai/gemini-provider.js +1 -1
  68. package/src/ai/index.js +1 -1
  69. package/src/ai/opencode-provider.js +1 -1
  70. package/src/ai/pi-provider.js +1 -1
  71. package/src/ai/prompts/baseline/consolidation/balanced.js +1 -1
  72. package/src/ai/prompts/baseline/consolidation/fast.js +1 -1
  73. package/src/ai/prompts/baseline/consolidation/thorough.js +1 -1
  74. package/src/ai/prompts/baseline/level1/balanced.js +1 -1
  75. package/src/ai/prompts/baseline/level1/fast.js +1 -1
  76. package/src/ai/prompts/baseline/level1/thorough.js +1 -1
  77. package/src/ai/prompts/baseline/level2/balanced.js +1 -1
  78. package/src/ai/prompts/baseline/level2/fast.js +1 -1
  79. package/src/ai/prompts/baseline/level2/thorough.js +1 -1
  80. package/src/ai/prompts/baseline/level3/balanced.js +1 -1
  81. package/src/ai/prompts/baseline/level3/fast.js +1 -1
  82. package/src/ai/prompts/baseline/level3/thorough.js +1 -1
  83. package/src/ai/prompts/baseline/orchestration/balanced.js +1 -1
  84. package/src/ai/prompts/baseline/orchestration/fast.js +1 -1
  85. package/src/ai/prompts/baseline/orchestration/thorough.js +1 -1
  86. package/src/ai/prompts/config.js +1 -1
  87. package/src/ai/prompts/index.js +1 -1
  88. package/src/ai/prompts/line-number-guidance.js +1 -1
  89. package/src/ai/prompts/render-for-skill.js +1 -1
  90. package/src/ai/prompts/shared/diff-instructions.js +1 -1
  91. package/src/ai/prompts/shared/output-schema.js +1 -1
  92. package/src/ai/prompts/shared/valid-files.js +1 -1
  93. package/src/ai/prompts/sparse-checkout-guidance.js +1 -1
  94. package/src/ai/provider-availability.js +1 -1
  95. package/src/ai/provider.js +1 -1
  96. package/src/ai/stream-parser.js +1 -1
  97. package/src/chat/acp-bridge.js +1 -1
  98. package/src/chat/api-reference.js +1 -1
  99. package/src/chat/chat-providers.js +1 -1
  100. package/src/chat/claude-code-bridge.js +1 -1
  101. package/src/chat/codex-bridge.js +1 -1
  102. package/src/chat/pi-bridge.js +1 -1
  103. package/src/chat/prompt-builder.js +1 -1
  104. package/src/chat/session-manager.js +1 -1
  105. package/src/config.js +3 -1
  106. package/src/database.js +591 -40
  107. package/src/events/review-events.js +1 -1
  108. package/src/git/base-branch.js +173 -0
  109. package/src/git/gitattributes.js +1 -1
  110. package/src/git/sha-abbrev.js +35 -0
  111. package/src/git/worktree.js +1 -1
  112. package/src/github/client.js +33 -2
  113. package/src/github/parser.js +1 -1
  114. package/src/hooks/hook-runner.js +100 -0
  115. package/src/hooks/payloads.js +212 -0
  116. package/src/local-review.js +469 -130
  117. package/src/local-scope.js +58 -0
  118. package/src/main.js +56 -5
  119. package/src/mcp-stdio.js +1 -1
  120. package/src/protocol-handler.js +1 -1
  121. package/src/routes/analyses.js +74 -11
  122. package/src/routes/chat.js +34 -1
  123. package/src/routes/config.js +2 -1
  124. package/src/routes/context-files.js +1 -1
  125. package/src/routes/councils.js +1 -1
  126. package/src/routes/github-collections.js +1 -1
  127. package/src/routes/local.js +735 -69
  128. package/src/routes/mcp.js +21 -11
  129. package/src/routes/pr.js +91 -13
  130. package/src/routes/reviews.js +1 -1
  131. package/src/routes/setup.js +2 -1
  132. package/src/routes/shared.js +1 -1
  133. package/src/routes/worktrees.js +213 -149
  134. package/src/server.js +31 -1
  135. package/src/setup/local-setup.js +47 -6
  136. package/src/setup/pr-setup.js +29 -6
  137. package/src/utils/auto-context.js +1 -1
  138. package/src/utils/category-emoji.js +1 -1
  139. package/src/utils/comment-formatter.js +1 -1
  140. package/src/utils/diff-annotator.js +1 -1
  141. package/src/utils/diff-file-list.js +1 -1
  142. package/src/utils/instructions.js +1 -1
  143. package/src/utils/json-extractor.js +1 -1
  144. package/src/utils/line-validation.js +1 -1
  145. package/src/utils/logger.js +1 -1
  146. package/src/utils/paths.js +1 -1
  147. package/src/utils/safe-parse-json.js +1 -1
  148. package/src/utils/stats-calculator.js +1 -1
  149. package/src/ws/index.js +1 -1
  150. package/src/ws/server.js +1 -1
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  /**
3
3
  * Worktree Management Routes
4
4
  *
@@ -9,9 +9,10 @@
9
9
  */
10
10
 
11
11
  const express = require('express');
12
- const { query, queryOne, run, WorktreeRepository } = require('../database');
12
+ const { query, queryOne, run, ReviewRepository } = require('../database');
13
13
  const { setupPRReview } = require('../setup/pr-setup');
14
14
  const { GitHubApiError } = require('../github/client');
15
+ const { GitWorktreeManager } = require('../git/worktree');
15
16
  const fs = require('fs').promises;
16
17
  const logger = require('../utils/logger');
17
18
 
@@ -116,211 +117,274 @@ router.post('/api/worktrees/create', async (req, res) => {
116
117
  });
117
118
 
118
119
  /**
119
- * Get recently accessed worktrees with cursor-based pagination.
120
- * Returns list of recently reviewed PRs with metadata.
121
- * Filters out stale worktrees where the directory no longer exists.
120
+ * Get recently reviewed PRs with cursor-based pagination.
121
+ * Lists from pr_metadata (source of truth) and includes storage status
122
+ * based on whether a local worktree directory exists.
122
123
  *
123
124
  * Query parameters:
124
- * limit - Number of worktrees to return (default 10, max 50)
125
- * before - ISO timestamp cursor: return worktrees accessed before this time.
125
+ * limit - Number of reviews to return (default 10, max 50)
126
+ * before - ISO timestamp cursor: return reviews accessed before this time.
126
127
  * For subsequent pages, send the last_accessed_at of the last item
127
128
  * from the previous page. Omit for the initial load.
128
129
  *
129
130
  * Response includes:
130
- * worktrees - Array of worktree objects
131
- * hasMore - Whether more worktrees are available beyond this page
131
+ * reviews - Array of review objects with storage_status
132
+ * hasMore - Whether more reviews are available beyond this page
132
133
  */
133
134
  router.get('/api/worktrees/recent', async (req, res) => {
134
135
  try {
135
- const limit = Math.min(parseInt(req.query.limit) || 10, 50); // Default 10, max 50
136
- const before = req.query.before || null; // ISO timestamp cursor
136
+ const limit = Math.min(parseInt(req.query.limit) || 10, 50);
137
+ const before = req.query.before || null;
137
138
  const db = req.app.get('db');
138
139
 
139
- // Fetch a constant overshoot per page to account for stale entries
140
- // that will be filtered out, plus one extra to determine hasMore
141
- const fetchCount = limit * 3 + 1;
142
-
143
- let enrichedWorktrees;
144
- if (before) {
145
- // Cursor-based: fetch rows older than the cursor.
146
- // Strict less-than: entries sharing the cursor timestamp may be skipped,
147
- // acceptable given millisecond precision and small dataset size.
148
- enrichedWorktrees = await query(db, `
149
- SELECT
150
- w.id,
151
- w.repository,
152
- w.pr_number,
153
- w.branch,
154
- w.path,
155
- w.last_accessed_at,
156
- w.created_at,
157
- pm.title as pr_title,
158
- pm.author,
159
- pm.head_branch,
160
- json_extract(pm.pr_data, '$.html_url') as html_url
161
- FROM worktrees w
162
- LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
163
- WHERE w.last_accessed_at < ?
164
- ORDER BY w.last_accessed_at DESC
165
- LIMIT ?
166
- `, [before, fetchCount]);
167
- } else {
168
- // Initial load: no cursor, just fetch from the top
169
- enrichedWorktrees = await query(db, `
170
- SELECT
171
- w.id,
172
- w.repository,
173
- w.pr_number,
174
- w.branch,
175
- w.path,
176
- w.last_accessed_at,
177
- w.created_at,
178
- pm.title as pr_title,
179
- pm.author,
180
- pm.head_branch,
181
- json_extract(pm.pr_data, '$.html_url') as html_url
182
- FROM worktrees w
183
- LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
184
- ORDER BY w.last_accessed_at DESC
185
- LIMIT ?
186
- `, [fetchCount]);
187
- }
188
-
189
- // Filter out worktrees where:
190
- // 1. The directory no longer exists
191
- // 2. The data is incomplete/corrupted (no author, unknown branch)
192
- const staleIds = [];
193
- const validWorktrees = [];
194
-
195
- for (const w of enrichedWorktrees) {
196
- // Check for corrupted/incomplete data
197
- if (w.branch === 'unknown' || !w.pr_title || w.pr_title === `PR #${w.pr_number}`) {
198
- staleIds.push(w.id);
199
- continue;
200
- }
201
-
202
- // Check if path still exists
203
- try {
204
- await fs.access(w.path);
205
- validWorktrees.push(w);
206
- } catch {
207
- // Path doesn't exist - mark for cleanup
208
- staleIds.push(w.id);
209
- }
210
- }
211
-
212
- // Only run stale cleanup on initial (non-paginated) requests to keep the
213
- // dataset stable while the user pages through results.
214
- if (staleIds.length > 0 && !before) {
215
- setImmediate(async () => {
140
+ // Fetch limit + 1 to determine hasMore
141
+ const fetchCount = limit + 1;
142
+
143
+ const params = before ? [before, fetchCount] : [fetchCount];
144
+ const rows = await query(db, `
145
+ SELECT
146
+ pm.id,
147
+ pm.repository,
148
+ pm.pr_number,
149
+ pm.title,
150
+ pm.author,
151
+ pm.head_branch,
152
+ pm.last_accessed_at,
153
+ pm.created_at,
154
+ json_extract(pm.pr_data, '$.html_url') as html_url,
155
+ w.id as worktree_id,
156
+ w.path as worktree_path,
157
+ w.branch
158
+ FROM pr_metadata pm
159
+ LEFT JOIN worktrees w ON pm.pr_number = w.pr_number AND pm.repository = w.repository COLLATE NOCASE
160
+ WHERE pm.title IS NOT NULL AND pm.title != ''
161
+ ${before ? 'AND pm.last_accessed_at < ?' : ''}
162
+ ORDER BY pm.last_accessed_at DESC
163
+ LIMIT ?
164
+ `, params);
165
+
166
+ // Determine storage status for each entry
167
+ const reviews = [];
168
+ for (const row of rows) {
169
+ let storageStatus = 'cached';
170
+ if (row.worktree_path) {
216
171
  try {
217
- const placeholders = staleIds.map(() => '?').join(',');
218
- await run(db, `DELETE FROM worktrees WHERE id IN (${placeholders})`, staleIds);
219
- logger.info(`Cleaned up ${staleIds.length} stale worktree records`);
220
- } catch (err) {
221
- logger.warn(`Failed to cleanup stale worktrees: ${err.message}`);
172
+ await fs.access(row.worktree_path);
173
+ storageStatus = 'local';
174
+ } catch {
175
+ // Worktree dir missing — clean up stale record asynchronously on first page
176
+ if (!before) {
177
+ setImmediate(async () => {
178
+ try {
179
+ await run(db, 'DELETE FROM worktrees WHERE id = ?', [row.worktree_id]);
180
+ logger.info(`Cleaned up stale worktree record ${row.worktree_id}`);
181
+ } catch (err) {
182
+ logger.warn(`Failed to cleanup stale worktree: ${err.message}`);
183
+ }
184
+ });
185
+ }
222
186
  }
187
+ }
188
+ reviews.push({
189
+ id: row.id,
190
+ repository: row.repository,
191
+ pr_number: row.pr_number,
192
+ pr_title: row.title,
193
+ author: row.author || null,
194
+ head_branch: row.head_branch || row.branch || null,
195
+ last_accessed_at: row.last_accessed_at,
196
+ created_at: row.created_at,
197
+ storage_status: storageStatus,
198
+ html_url: row.html_url || null
223
199
  });
224
200
  }
225
201
 
226
- // Take the first `limit` valid results; anything beyond means hasMore
227
- const pageWorktrees = validWorktrees.slice(0, limit);
228
- const hasMore = validWorktrees.length > limit;
229
-
230
- // Format the results with fallback values
231
- const formattedWorktrees = pageWorktrees.map(w => ({
232
- id: w.id,
233
- repository: w.repository,
234
- pr_number: w.pr_number,
235
- pr_title: w.pr_title || `PR #${w.pr_number}`,
236
- author: w.author || null,
237
- branch: w.branch,
238
- head_branch: w.head_branch || w.branch,
239
- last_accessed_at: w.last_accessed_at,
240
- created_at: w.created_at,
241
- html_url: w.html_url || null
242
- }));
202
+ // Take the first `limit` results; anything beyond means hasMore
203
+ const hasMore = reviews.length > limit;
204
+ const pageReviews = hasMore ? reviews.slice(0, limit) : reviews;
243
205
 
244
206
  res.json({
245
207
  success: true,
246
- worktrees: formattedWorktrees,
208
+ reviews: pageReviews,
247
209
  hasMore
248
210
  });
249
211
 
250
212
  } catch (error) {
251
- logger.error('Error fetching recent worktrees:', error);
213
+ logger.error('Error fetching recent reviews:', error);
252
214
  res.status(500).json({
253
215
  success: false,
254
- error: 'Failed to fetch recent worktrees'
216
+ error: 'Failed to fetch recent reviews'
255
217
  });
256
218
  }
257
219
  });
258
220
 
259
221
  /**
260
- * Delete a worktree
261
- * Removes the worktree record from the database and optionally deletes the directory
222
+ * Delete a single review by pr_metadata ID.
223
+ * Cleans up: worktree directory, worktree record, comments, reviews, and pr_metadata.
224
+ *
225
+ * @param {object} db - Database handle
226
+ * @param {number} metadataId - pr_metadata.id
227
+ * @returns {{ success: boolean, message: string }}
228
+ * @throws {Error} if deletion fails
229
+ */
230
+ async function deleteReviewById(db, metadataId) {
231
+ const metadata = await queryOne(db, `
232
+ SELECT id, pr_number, repository FROM pr_metadata WHERE id = ?
233
+ `, [metadataId]);
234
+
235
+ if (!metadata) {
236
+ return { success: false, message: 'Review not found' };
237
+ }
238
+
239
+ const { pr_number: prNumber, repository } = metadata;
240
+ logger.info(`Deleting review for ${repository} #${prNumber} (metadata ID ${metadataId})`);
241
+
242
+ // Look up associated worktree path for cleanup after DB commit
243
+ const worktree = await queryOne(db, `
244
+ SELECT id, path FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE
245
+ `, [prNumber, repository]);
246
+
247
+ // Delete all associated database records in a transaction
248
+ await run(db, 'BEGIN TRANSACTION');
249
+ try {
250
+ await run(db, 'DELETE FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE', [prNumber, repository]);
251
+ await run(db, 'DELETE FROM chat_sessions WHERE review_id IN (SELECT id FROM reviews WHERE pr_number = ? AND repository = ? COLLATE NOCASE)', [prNumber, repository]);
252
+ await run(db, `
253
+ DELETE FROM comments WHERE review_id IN (
254
+ SELECT id FROM reviews WHERE pr_number = ? AND repository = ? COLLATE NOCASE
255
+ )
256
+ `, [prNumber, repository]);
257
+ await run(db, 'DELETE FROM reviews WHERE pr_number = ? AND repository = ? COLLATE NOCASE', [prNumber, repository]);
258
+ await run(db, 'DELETE FROM pr_metadata WHERE id = ?', [metadataId]);
259
+ // Clean up cached GitHub PR data
260
+ const parts = repository.split('/');
261
+ if (parts.length === 2) {
262
+ await run(db, 'DELETE FROM github_pr_cache WHERE owner = ? AND repo = ? AND number = ?', [parts[0], parts[1], prNumber]);
263
+ }
264
+ await run(db, 'COMMIT');
265
+ } catch (txError) {
266
+ await run(db, 'ROLLBACK');
267
+ throw txError;
268
+ }
269
+
270
+ // Clean up worktree AFTER successful DB commit so rollback doesn't orphan data
271
+ if (worktree && worktree.path) {
272
+ try {
273
+ const worktreeManager = new GitWorktreeManager(db);
274
+ await worktreeManager.cleanupWorktree(worktree.path);
275
+ logger.info(`Cleaned up worktree: ${worktree.path}`);
276
+ } catch {
277
+ logger.warn(`Could not clean up worktree (may not exist): ${worktree.path}`);
278
+ }
279
+ }
280
+
281
+ logger.success(`Deleted review for ${repository} #${prNumber}`);
282
+ return { success: true, message: `Review for ${repository} #${prNumber} deleted` };
283
+ }
284
+
285
+ /**
286
+ * Delete a review and all associated data.
287
+ * Cleans up: worktree directory, worktree record, comments, reviews, and pr_metadata.
288
+ *
289
+ * :id is the pr_metadata.id (integer primary key)
262
290
  */
263
291
  router.delete('/api/worktrees/:id', async (req, res) => {
264
292
  try {
265
- const worktreeId = req.params.id;
293
+ const metadataId = parseInt(req.params.id, 10);
266
294
 
267
- if (!worktreeId) {
295
+ if (!metadataId || isNaN(metadataId)) {
268
296
  return res.status(400).json({
269
297
  success: false,
270
- error: 'Invalid worktree ID'
298
+ error: 'Invalid review ID'
271
299
  });
272
300
  }
273
301
 
274
302
  const db = req.app.get('db');
275
- const worktreeRepo = new WorktreeRepository(db);
303
+ const result = await deleteReviewById(db, metadataId);
276
304
 
277
- // Get worktree info before deletion
278
- const worktree = await queryOne(db, `
279
- SELECT id, path, pr_number, repository FROM worktrees WHERE id = ?
280
- `, [worktreeId]);
281
-
282
- if (!worktree) {
305
+ if (!result.success) {
283
306
  return res.status(404).json({
284
307
  success: false,
285
- error: 'Worktree not found'
308
+ error: result.message
286
309
  });
287
310
  }
288
311
 
289
- logger.info(`Deleting worktree ID ${worktreeId} for ${worktree.repository} #${worktree.pr_number}`);
312
+ res.json({
313
+ success: true,
314
+ message: result.message
315
+ });
290
316
 
291
- // Delete the worktree directory if it exists
292
- if (worktree.path) {
293
- try {
294
- await fs.access(worktree.path);
295
- // Directory exists, try to remove it
296
- await fs.rm(worktree.path, { recursive: true, force: true });
297
- logger.info(`Deleted worktree directory: ${worktree.path}`);
298
- } catch (pathError) {
299
- // Directory doesn't exist or can't be accessed - that's okay
300
- logger.warn(`Could not delete worktree directory (may not exist): ${worktree.path}`);
301
- }
317
+ } catch (error) {
318
+ logger.error('Error deleting review:', error);
319
+ res.status(500).json({
320
+ success: false,
321
+ error: 'Failed to delete review: ' + error.message
322
+ });
323
+ }
324
+ });
325
+
326
+ /**
327
+ * Bulk delete reviews by pr_metadata IDs.
328
+ * Accepts { ids: number[] } in request body. Max 50 IDs per request.
329
+ * Each deletion is independent — partial failures are reported per-ID.
330
+ */
331
+ router.post('/api/worktrees/bulk-delete', async (req, res) => {
332
+ try {
333
+ const { ids } = req.body || {};
334
+
335
+ if (!Array.isArray(ids) || ids.length === 0) {
336
+ return res.status(400).json({
337
+ success: false,
338
+ error: 'Request body must contain a non-empty "ids" array'
339
+ });
302
340
  }
303
341
 
304
- // Delete the worktree record from the database
305
- await run(db, `DELETE FROM worktrees WHERE id = ?`, [worktreeId]);
342
+ if (ids.length > 50) {
343
+ return res.status(400).json({
344
+ success: false,
345
+ error: 'Maximum 50 IDs per request'
346
+ });
347
+ }
306
348
 
307
- // Also delete associated PR metadata and comments (optional cleanup)
308
- // Keep PR metadata for now as user might want to reload the PR later
309
- // await run(db, `DELETE FROM pr_metadata WHERE pr_number = ? AND repository = ?`,
310
- // [worktree.pr_number, worktree.repository]);
349
+ const parsedIds = ids.map(id => parseInt(id, 10));
350
+ if (parsedIds.some(id => isNaN(id) || id <= 0)) {
351
+ return res.status(400).json({
352
+ success: false,
353
+ error: 'All IDs must be positive integers'
354
+ });
355
+ }
311
356
 
312
- logger.success(`Deleted worktree ID ${worktreeId}`);
357
+ const db = req.app.get('db');
358
+ let deleted = 0;
359
+ const errors = [];
360
+
361
+ for (const id of parsedIds) {
362
+ try {
363
+ const result = await deleteReviewById(db, id);
364
+ if (result.success) {
365
+ deleted++;
366
+ } else {
367
+ errors.push({ id, error: result.message });
368
+ }
369
+ } catch (err) {
370
+ errors.push({ id, error: err.message });
371
+ }
372
+ }
373
+
374
+ if (deleted > 0) logger.success(`Bulk deleted ${deleted} review(s)`);
313
375
 
314
376
  res.json({
315
- success: true,
316
- message: `Worktree for ${worktree.repository} #${worktree.pr_number} deleted`
377
+ success: deleted > 0 || errors.length === 0,
378
+ deleted,
379
+ failed: errors.length,
380
+ errors
317
381
  });
318
382
 
319
383
  } catch (error) {
320
- logger.error('Error deleting worktree:', error);
384
+ logger.error('Error in bulk delete:', error);
321
385
  res.status(500).json({
322
386
  success: false,
323
- error: 'Failed to delete worktree: ' + error.message
387
+ error: 'Failed to process bulk delete: ' + error.message
324
388
  });
325
389
  }
326
390
  });
package/src/server.js CHANGED
@@ -1,4 +1,4 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const express = require('express');
3
3
  const path = require('path');
4
4
  const { loadConfig, getGitHubToken, resolveDbName, warnIfDevModeWithoutDbName } = require('./config');
@@ -208,6 +208,34 @@ async function startServer(sharedDb = null) {
208
208
  res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
209
209
  });
210
210
 
211
+ // Bulk-open — opens multiple local URLs in the OS browser via the `open` package.
212
+ // Bypasses popup blockers since the server shells out directly.
213
+ const openUrl = (...args) => import('open').then(({ default: open }) => open(...args));
214
+ app.post('/api/bulk-open', async (req, res) => {
215
+ try {
216
+ const { urls } = req.body || {};
217
+ if (!Array.isArray(urls) || urls.length === 0) {
218
+ return res.status(400).json({ error: 'urls array required' });
219
+ }
220
+ if (urls.length > 20) {
221
+ return res.status(400).json({ error: 'Maximum 20 URLs per request' });
222
+ }
223
+ // Only allow local paths starting with / (prevent open-redirect)
224
+ const validUrls = urls.filter(u => typeof u === 'string' && u.startsWith('/'));
225
+ if (validUrls.length === 0) {
226
+ return res.status(400).json({ error: 'All URLs must be local paths starting with /' });
227
+ }
228
+ const port = req.socket.localPort;
229
+ for (const url of validUrls) {
230
+ await openUrl(`http://localhost:${port}${url}`);
231
+ }
232
+ res.json({ success: true, opened: validUrls.length });
233
+ } catch (error) {
234
+ logger.error('Bulk open failed:', error);
235
+ res.status(500).json({ error: 'Failed to open URLs' });
236
+ }
237
+ });
238
+
211
239
  // PR display route - serves pr.html if review data exists, setup.html otherwise
212
240
  app.get('/pr/:owner/:repo/:number', async (req, res) => {
213
241
  const { owner, repo, number } = req.params;
@@ -225,6 +253,8 @@ async function startServer(sharedDb = null) {
225
253
  // pr.html for a missing worktree, causing 404s on file fetches.
226
254
  const worktree = await queryOne(db, 'SELECT id FROM worktrees WHERE pr_number = ? AND repository = ? COLLATE NOCASE', [prNumber, repository]);
227
255
  if (worktree) {
256
+ // Update last_accessed_at so the recent reviews list reflects actual access
257
+ run(db, 'UPDATE pr_metadata SET last_accessed_at = ? WHERE id = ?', [new Date().toISOString(), existing.id]).catch(err => logger.warn(`Failed to update last_accessed_at: ${err.message}`));
228
258
  res.sendFile(path.join(__dirname, '..', 'public', 'pr.html'));
229
259
  } else {
230
260
  logger.info(`PR metadata exists but no worktree for ${repository} #${prNumber}, serving setup page`);
@@ -1,7 +1,11 @@
1
- // SPDX-License-Identifier: GPL-3.0-or-later
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
2
  const { findGitRoot, getHeadSha, getCurrentBranch, getRepositoryName, generateLocalDiff, generateLocalReviewId, computeLocalDiffDigest, findMainGitRoot } = require('../local-review');
3
3
  const { ReviewRepository, RepoSettingsRepository } = require('../database');
4
4
  const { localReviewDiffs } = require('../routes/shared');
5
+ const { fireHooks, hasHooks } = require('../hooks/hook-runner');
6
+ const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('../hooks/payloads');
7
+ const { STOPS, DEFAULT_SCOPE } = require('../local-scope');
8
+ const logger = require('../utils/logger');
5
9
  const path = require('path');
6
10
  const fs = require('fs').promises;
7
11
 
@@ -15,9 +19,10 @@ const fs = require('fs').promises;
15
19
  * @param {Object} options.db - Initialized database instance
16
20
  * @param {string} options.targetPath - Path to review (file or directory)
17
21
  * @param {Function} [options.onProgress] - Progress callback ({ step, status, message })
22
+ * @param {Object} [options.config] - App config (for hooks)
18
23
  * @returns {Promise<Object>} Review session info
19
24
  */
20
- async function setupLocalReview({ db, targetPath, onProgress }) {
25
+ async function setupLocalReview({ db, targetPath, onProgress, config }) {
21
26
  const progress = typeof onProgress === 'function'
22
27
  ? onProgress
23
28
  : () => {};
@@ -56,7 +61,21 @@ async function setupLocalReview({ db, targetPath, onProgress }) {
56
61
  reviewId = generateLocalReviewId(repoPath, headSha);
57
62
 
58
63
  const reviewRepo = new ReviewRepository(db);
59
- existingReview = await reviewRepo.getLocalReview(repoPath, headSha);
64
+ existingReview = await reviewRepo.getLocalReview(repoPath, headSha, branch);
65
+
66
+ // Adopt legacy sessions that predate branch tracking
67
+ if (!existingReview) {
68
+ const legacy = await reviewRepo.getLocalReviewByPathAndSha(repoPath, headSha);
69
+ if (legacy && legacy.local_head_branch === null) {
70
+ existingReview = legacy;
71
+ }
72
+ }
73
+
74
+ // Check for branch-scope session (persists across HEAD changes)
75
+ if (!existingReview) {
76
+ const branchSession = await reviewRepo.getLocalBranchReview(repoPath, branch);
77
+ if (branchSession) existingReview = branchSession;
78
+ }
60
79
 
61
80
  repository = await getRepositoryName(repoPath);
62
81
 
@@ -102,14 +121,21 @@ async function setupLocalReview({ db, targetPath, onProgress }) {
102
121
  try {
103
122
  progress({ step: 'store', status: 'running', message: 'Persisting review session...' });
104
123
 
124
+ const storeRepo = new ReviewRepository(db);
105
125
  if (existingReview) {
106
126
  sessionId = existingReview.id;
127
+ if (existingReview.local_head_sha !== headSha) {
128
+ await storeRepo.updateLocalHeadSha(sessionId, headSha);
129
+ }
130
+ if (existingReview.local_head_branch === null) {
131
+ await storeRepo.updateReview(sessionId, { local_head_branch: branch });
132
+ }
107
133
  } else {
108
- const reviewRepo = new ReviewRepository(db);
109
- sessionId = await reviewRepo.upsertLocalReview({
134
+ sessionId = await storeRepo.upsertLocalReview({
110
135
  localPath: repoPath,
111
136
  localHeadSha: headSha,
112
- repository
137
+ repository,
138
+ localHeadBranch: branch
113
139
  });
114
140
  }
115
141
 
@@ -121,6 +147,21 @@ async function setupLocalReview({ db, targetPath, onProgress }) {
121
147
  throw err;
122
148
  }
123
149
 
150
+ // Fire review hook (non-blocking)
151
+ const hookEvent = existingReview ? 'review.loaded' : 'review.started';
152
+ if (config && hasHooks(hookEvent, config)) {
153
+ getCachedUser(config).then(user => {
154
+ const builder = existingReview ? buildReviewLoadedPayload : buildReviewStartedPayload;
155
+ const scopeStart = existingReview?.local_scope_start || DEFAULT_SCOPE.start;
156
+ const scopeEnd = existingReview?.local_scope_end || DEFAULT_SCOPE.end;
157
+ const si = STOPS.indexOf(scopeStart);
158
+ const ei = STOPS.indexOf(scopeEnd);
159
+ const scope = STOPS.slice(si, ei + 1);
160
+ const payload = builder({ reviewId: sessionId, mode: 'local', localContext: { path: repoPath, branch, headSha, scope }, user });
161
+ fireHooks(hookEvent, payload, config);
162
+ }).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
163
+ }
164
+
124
165
  return {
125
166
  reviewId: sessionId,
126
167
  reviewUrl: '/local/' + sessionId,