@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.
- package/.pi/extensions/task/index.ts +1 -1
- package/.pi/skills/review-roulette/SKILL.md +1 -1
- package/LICENSE +201 -674
- package/README.md +2 -2
- package/bin/pair-review.js +1 -1
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin-code-critic/.claude-plugin/plugin.json +2 -2
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +1 -1
- package/public/css/ai-summary-modal.css +1 -1
- package/public/css/pr.css +194 -0
- package/public/index.html +168 -3
- package/public/js/components/AIPanel.js +17 -3
- package/public/js/components/AISummaryModal.js +1 -1
- package/public/js/components/AdvancedConfigTab.js +1 -1
- package/public/js/components/AnalysisConfigModal.js +1 -1
- package/public/js/components/ChatPanel.js +42 -7
- package/public/js/components/ConfirmDialog.js +22 -3
- package/public/js/components/CouncilProgressModal.js +14 -1
- package/public/js/components/DiffOptionsDropdown.js +411 -24
- package/public/js/components/EmojiPicker.js +1 -1
- package/public/js/components/KeyboardShortcuts.js +1 -1
- package/public/js/components/PanelGroup.js +1 -1
- package/public/js/components/PreviewModal.js +1 -1
- package/public/js/components/ReviewModal.js +1 -1
- package/public/js/components/SplitButton.js +1 -1
- package/public/js/components/StatusIndicator.js +1 -1
- package/public/js/components/SuggestionNavigator.js +13 -6
- package/public/js/components/TabTitle.js +96 -0
- package/public/js/components/TextInputDialog.js +1 -1
- package/public/js/components/TimeoutSelect.js +1 -1
- package/public/js/components/Toast.js +7 -1
- package/public/js/components/VoiceCentricConfigTab.js +1 -1
- package/public/js/index.js +649 -44
- package/public/js/local.js +570 -77
- package/public/js/modules/analysis-history.js +4 -3
- package/public/js/modules/comment-manager.js +6 -1
- package/public/js/modules/comment-minimizer.js +304 -0
- package/public/js/modules/diff-context.js +1 -1
- package/public/js/modules/diff-renderer.js +1 -1
- package/public/js/modules/file-comment-manager.js +1 -1
- package/public/js/modules/file-list-merger.js +1 -1
- package/public/js/modules/gap-coordinates.js +1 -1
- package/public/js/modules/hunk-parser.js +1 -1
- package/public/js/modules/line-tracker.js +1 -1
- package/public/js/modules/panel-resizer.js +1 -1
- package/public/js/modules/storage-cleanup.js +1 -1
- package/public/js/modules/suggestion-manager.js +1 -1
- package/public/js/pr.js +83 -7
- package/public/js/repo-settings.js +1 -1
- package/public/js/utils/category-emoji.js +1 -1
- package/public/js/utils/file-order.js +1 -1
- package/public/js/utils/markdown.js +1 -1
- package/public/js/utils/suggestion-ui.js +1 -1
- package/public/js/utils/tier-icons.js +1 -1
- package/public/js/utils/time.js +1 -1
- package/public/js/ws-client.js +1 -1
- package/public/local.html +14 -0
- package/public/pr.html +3 -0
- package/public/setup.html +1 -1
- package/src/ai/analyzer.js +18 -12
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +1 -1
- package/src/ai/codex-provider.js +1 -1
- package/src/ai/copilot-provider.js +1 -1
- package/src/ai/cursor-agent-provider.js +1 -1
- package/src/ai/gemini-provider.js +1 -1
- package/src/ai/index.js +1 -1
- package/src/ai/opencode-provider.js +1 -1
- package/src/ai/pi-provider.js +1 -1
- package/src/ai/prompts/baseline/consolidation/balanced.js +1 -1
- package/src/ai/prompts/baseline/consolidation/fast.js +1 -1
- package/src/ai/prompts/baseline/consolidation/thorough.js +1 -1
- package/src/ai/prompts/baseline/level1/balanced.js +1 -1
- package/src/ai/prompts/baseline/level1/fast.js +1 -1
- package/src/ai/prompts/baseline/level1/thorough.js +1 -1
- package/src/ai/prompts/baseline/level2/balanced.js +1 -1
- package/src/ai/prompts/baseline/level2/fast.js +1 -1
- package/src/ai/prompts/baseline/level2/thorough.js +1 -1
- package/src/ai/prompts/baseline/level3/balanced.js +1 -1
- package/src/ai/prompts/baseline/level3/fast.js +1 -1
- package/src/ai/prompts/baseline/level3/thorough.js +1 -1
- package/src/ai/prompts/baseline/orchestration/balanced.js +1 -1
- package/src/ai/prompts/baseline/orchestration/fast.js +1 -1
- package/src/ai/prompts/baseline/orchestration/thorough.js +1 -1
- package/src/ai/prompts/config.js +1 -1
- package/src/ai/prompts/index.js +1 -1
- package/src/ai/prompts/line-number-guidance.js +1 -1
- package/src/ai/prompts/render-for-skill.js +1 -1
- package/src/ai/prompts/shared/diff-instructions.js +1 -1
- package/src/ai/prompts/shared/output-schema.js +1 -1
- package/src/ai/prompts/shared/valid-files.js +1 -1
- package/src/ai/prompts/sparse-checkout-guidance.js +1 -1
- package/src/ai/provider-availability.js +1 -1
- package/src/ai/provider.js +1 -1
- package/src/ai/stream-parser.js +1 -1
- package/src/chat/acp-bridge.js +1 -1
- package/src/chat/api-reference.js +1 -1
- package/src/chat/chat-providers.js +1 -1
- package/src/chat/claude-code-bridge.js +1 -1
- package/src/chat/codex-bridge.js +1 -1
- package/src/chat/pi-bridge.js +1 -1
- package/src/chat/prompt-builder.js +1 -1
- package/src/chat/session-manager.js +1 -1
- package/src/config.js +3 -1
- package/src/database.js +591 -40
- package/src/events/review-events.js +1 -1
- package/src/git/base-branch.js +173 -0
- package/src/git/gitattributes.js +1 -1
- package/src/git/sha-abbrev.js +35 -0
- package/src/git/worktree.js +1 -1
- package/src/github/client.js +33 -2
- package/src/github/parser.js +1 -1
- package/src/hooks/hook-runner.js +100 -0
- package/src/hooks/payloads.js +212 -0
- package/src/local-review.js +469 -130
- package/src/local-scope.js +58 -0
- package/src/main.js +56 -5
- package/src/mcp-stdio.js +1 -1
- package/src/protocol-handler.js +1 -1
- package/src/routes/analyses.js +74 -11
- package/src/routes/chat.js +34 -1
- package/src/routes/config.js +2 -1
- package/src/routes/context-files.js +1 -1
- package/src/routes/councils.js +1 -1
- package/src/routes/github-collections.js +1 -1
- package/src/routes/local.js +735 -69
- package/src/routes/mcp.js +21 -11
- package/src/routes/pr.js +91 -13
- package/src/routes/reviews.js +1 -1
- package/src/routes/setup.js +2 -1
- package/src/routes/shared.js +1 -1
- package/src/routes/worktrees.js +213 -149
- package/src/server.js +31 -1
- package/src/setup/local-setup.js +47 -6
- package/src/setup/pr-setup.js +29 -6
- package/src/utils/auto-context.js +1 -1
- package/src/utils/category-emoji.js +1 -1
- package/src/utils/comment-formatter.js +1 -1
- package/src/utils/diff-annotator.js +1 -1
- package/src/utils/diff-file-list.js +1 -1
- package/src/utils/instructions.js +1 -1
- package/src/utils/json-extractor.js +1 -1
- package/src/utils/line-validation.js +1 -1
- package/src/utils/logger.js +1 -1
- package/src/utils/paths.js +1 -1
- package/src/utils/safe-parse-json.js +1 -1
- package/src/utils/stats-calculator.js +1 -1
- package/src/ws/index.js +1 -1
- package/src/ws/server.js +1 -1
package/src/routes/worktrees.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// SPDX-License-Identifier:
|
|
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,
|
|
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
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
|
125
|
-
* before - ISO timestamp cursor: return
|
|
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
|
-
*
|
|
131
|
-
* hasMore
|
|
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);
|
|
136
|
-
const before = req.query.before || null;
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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`
|
|
227
|
-
const
|
|
228
|
-
const
|
|
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
|
-
|
|
208
|
+
reviews: pageReviews,
|
|
247
209
|
hasMore
|
|
248
210
|
});
|
|
249
211
|
|
|
250
212
|
} catch (error) {
|
|
251
|
-
logger.error('Error fetching recent
|
|
213
|
+
logger.error('Error fetching recent reviews:', error);
|
|
252
214
|
res.status(500).json({
|
|
253
215
|
success: false,
|
|
254
|
-
error: 'Failed to fetch recent
|
|
216
|
+
error: 'Failed to fetch recent reviews'
|
|
255
217
|
});
|
|
256
218
|
}
|
|
257
219
|
});
|
|
258
220
|
|
|
259
221
|
/**
|
|
260
|
-
* Delete a
|
|
261
|
-
*
|
|
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
|
|
293
|
+
const metadataId = parseInt(req.params.id, 10);
|
|
266
294
|
|
|
267
|
-
if (!
|
|
295
|
+
if (!metadataId || isNaN(metadataId)) {
|
|
268
296
|
return res.status(400).json({
|
|
269
297
|
success: false,
|
|
270
|
-
error: 'Invalid
|
|
298
|
+
error: 'Invalid review ID'
|
|
271
299
|
});
|
|
272
300
|
}
|
|
273
301
|
|
|
274
302
|
const db = req.app.get('db');
|
|
275
|
-
const
|
|
303
|
+
const result = await deleteReviewById(db, metadataId);
|
|
276
304
|
|
|
277
|
-
|
|
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:
|
|
308
|
+
error: result.message
|
|
286
309
|
});
|
|
287
310
|
}
|
|
288
311
|
|
|
289
|
-
|
|
312
|
+
res.json({
|
|
313
|
+
success: true,
|
|
314
|
+
message: result.message
|
|
315
|
+
});
|
|
290
316
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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:
|
|
316
|
-
|
|
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
|
|
384
|
+
logger.error('Error in bulk delete:', error);
|
|
321
385
|
res.status(500).json({
|
|
322
386
|
success: false,
|
|
323
|
-
error: 'Failed to delete
|
|
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:
|
|
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`);
|
package/src/setup/local-setup.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
// SPDX-License-Identifier:
|
|
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
|
-
|
|
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,
|