@in-the-loop-labs/pair-review 1.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/LICENSE +674 -0
- package/README.md +371 -0
- package/bin/git-diff-lines +146 -0
- package/bin/pair-review.js +49 -0
- package/package.json +71 -0
- package/public/css/ai-summary-modal.css +183 -0
- package/public/css/pr.css +8698 -0
- package/public/css/repo-settings.css +891 -0
- package/public/css/styles.css +479 -0
- package/public/favicon.png +0 -0
- package/public/index.html +1104 -0
- package/public/js/components/AIPanel.js +1639 -0
- package/public/js/components/AISummaryModal.js +278 -0
- package/public/js/components/AnalysisConfigModal.js +684 -0
- package/public/js/components/ConfirmDialog.js +227 -0
- package/public/js/components/PreviewModal.js +344 -0
- package/public/js/components/ProgressModal.js +678 -0
- package/public/js/components/ReviewModal.js +531 -0
- package/public/js/components/SplitButton.js +382 -0
- package/public/js/components/StatusIndicator.js +265 -0
- package/public/js/components/SuggestionNavigator.js +489 -0
- package/public/js/components/Toast.js +166 -0
- package/public/js/local.js +1580 -0
- package/public/js/modules/analysis-history.js +940 -0
- package/public/js/modules/comment-manager.js +643 -0
- package/public/js/modules/diff-renderer.js +585 -0
- package/public/js/modules/file-comment-manager.js +1242 -0
- package/public/js/modules/gap-coordinates.js +190 -0
- package/public/js/modules/hunk-parser.js +358 -0
- package/public/js/modules/line-tracker.js +386 -0
- package/public/js/modules/panel-resizer.js +228 -0
- package/public/js/modules/storage-cleanup.js +36 -0
- package/public/js/modules/suggestion-manager.js +692 -0
- package/public/js/pr.js +3503 -0
- package/public/js/repo-settings.js +691 -0
- package/public/js/utils/file-order.js +87 -0
- package/public/js/utils/markdown.js +97 -0
- package/public/js/utils/suggestion-ui.js +55 -0
- package/public/js/utils/tier-icons.js +25 -0
- package/public/local.html +460 -0
- package/public/pr.html +329 -0
- package/public/repo-settings.html +243 -0
- package/src/ai/analyzer.js +2592 -0
- package/src/ai/claude-cli.js +153 -0
- package/src/ai/claude-provider.js +261 -0
- package/src/ai/codex-provider.js +361 -0
- package/src/ai/copilot-provider.js +345 -0
- package/src/ai/gemini-provider.js +375 -0
- package/src/ai/index.js +47 -0
- package/src/ai/prompts/baseline/_meta.json +14 -0
- package/src/ai/prompts/baseline/level1/balanced.js +239 -0
- package/src/ai/prompts/baseline/level1/fast.js +194 -0
- package/src/ai/prompts/baseline/level1/thorough.js +319 -0
- package/src/ai/prompts/baseline/level2/balanced.js +248 -0
- package/src/ai/prompts/baseline/level2/fast.js +201 -0
- package/src/ai/prompts/baseline/level2/thorough.js +367 -0
- package/src/ai/prompts/baseline/level3/balanced.js +280 -0
- package/src/ai/prompts/baseline/level3/fast.js +220 -0
- package/src/ai/prompts/baseline/level3/thorough.js +459 -0
- package/src/ai/prompts/baseline/orchestration/balanced.js +259 -0
- package/src/ai/prompts/baseline/orchestration/fast.js +213 -0
- package/src/ai/prompts/baseline/orchestration/thorough.js +446 -0
- package/src/ai/prompts/config.js +52 -0
- package/src/ai/prompts/index.js +267 -0
- package/src/ai/prompts/shared/diff-instructions.js +50 -0
- package/src/ai/prompts/shared/output-schema.js +179 -0
- package/src/ai/prompts/shared/valid-files.js +37 -0
- package/src/ai/provider.js +260 -0
- package/src/config.js +139 -0
- package/src/database.js +2284 -0
- package/src/git/gitattributes.js +207 -0
- package/src/git/worktree.js +688 -0
- package/src/github/client.js +893 -0
- package/src/github/parser.js +247 -0
- package/src/local-review.js +691 -0
- package/src/main.js +987 -0
- package/src/routes/analysis.js +897 -0
- package/src/routes/comments.js +534 -0
- package/src/routes/config.js +250 -0
- package/src/routes/local.js +1728 -0
- package/src/routes/pr.js +1164 -0
- package/src/routes/shared.js +218 -0
- package/src/routes/worktrees.js +500 -0
- package/src/server.js +295 -0
- package/src/utils/diff-annotator.js +414 -0
- package/src/utils/instructions.js +33 -0
- package/src/utils/json-extractor.js +107 -0
- package/src/utils/line-validation.js +183 -0
- package/src/utils/logger.js +142 -0
- package/src/utils/paths.js +161 -0
- package/src/utils/stats-calculator.js +86 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
/**
|
|
3
|
+
* Worktree Management Routes
|
|
4
|
+
*
|
|
5
|
+
* Handles all worktree-related endpoints:
|
|
6
|
+
* - Creating worktrees from PR URLs
|
|
7
|
+
* - Getting recent worktrees
|
|
8
|
+
* - Deleting worktrees
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const express = require('express');
|
|
12
|
+
const { query, queryOne, run, WorktreeRepository, RepoSettingsRepository } = require('../database');
|
|
13
|
+
const { GitWorktreeManager } = require('../git/worktree');
|
|
14
|
+
const { GitHubClient } = require('../github/client');
|
|
15
|
+
const { normalizeRepository } = require('../utils/paths');
|
|
16
|
+
const fs = require('fs').promises;
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const logger = require('../utils/logger');
|
|
19
|
+
const simpleGit = require('simple-git');
|
|
20
|
+
|
|
21
|
+
const router = express.Router();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Create worktree from PR URL (for web UI start review)
|
|
25
|
+
* Creates worktree, fetches PR data from GitHub, stores in database
|
|
26
|
+
*/
|
|
27
|
+
router.post('/api/worktrees/create', async (req, res) => {
|
|
28
|
+
try {
|
|
29
|
+
const { owner, repo, prNumber } = req.body;
|
|
30
|
+
|
|
31
|
+
// Validate required parameters
|
|
32
|
+
if (!owner || !repo || !prNumber) {
|
|
33
|
+
return res.status(400).json({
|
|
34
|
+
success: false,
|
|
35
|
+
error: 'Missing required parameters: owner, repo, prNumber'
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const parsedPrNumber = parseInt(prNumber, 10);
|
|
40
|
+
if (isNaN(parsedPrNumber) || parsedPrNumber <= 0) {
|
|
41
|
+
return res.status(400).json({
|
|
42
|
+
success: false,
|
|
43
|
+
error: 'Invalid pull request number'
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const db = req.app.get('db');
|
|
48
|
+
const config = req.app.get('config');
|
|
49
|
+
|
|
50
|
+
// Validate GitHub token
|
|
51
|
+
if (!config || !config.github_token) {
|
|
52
|
+
return res.status(500).json({
|
|
53
|
+
success: false,
|
|
54
|
+
error: 'GitHub token not configured. Please set github_token in ~/.pair-review/config.json'
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const repository = normalizeRepository(owner, repo);
|
|
59
|
+
logger.section(`Web UI Start Review - PR #${parsedPrNumber}`);
|
|
60
|
+
logger.log('API', `Repository: ${repository}`, 'magenta');
|
|
61
|
+
|
|
62
|
+
// Create GitHub client and validate token
|
|
63
|
+
const githubClient = new GitHubClient(config.github_token);
|
|
64
|
+
const tokenValid = await githubClient.validateToken();
|
|
65
|
+
if (!tokenValid) {
|
|
66
|
+
return res.status(401).json({
|
|
67
|
+
success: false,
|
|
68
|
+
error: 'GitHub authentication failed. Please check your token in ~/.pair-review/config.json'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check if repository is accessible
|
|
73
|
+
const repoExists = await githubClient.repositoryExists(owner, repo);
|
|
74
|
+
if (!repoExists) {
|
|
75
|
+
return res.status(404).json({
|
|
76
|
+
success: false,
|
|
77
|
+
error: `Repository ${repository} not found or not accessible`
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fetch PR data from GitHub
|
|
82
|
+
logger.info('Fetching pull request data from GitHub...');
|
|
83
|
+
let prData;
|
|
84
|
+
try {
|
|
85
|
+
prData = await githubClient.fetchPullRequest(owner, repo, parsedPrNumber);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.message && error.message.includes('not found')) {
|
|
88
|
+
return res.status(404).json({
|
|
89
|
+
success: false,
|
|
90
|
+
error: `Pull request #${parsedPrNumber} not found in ${repository}`
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Get current working directory for worktree creation
|
|
97
|
+
// Since we're running from the web UI, we need to find a valid git repository
|
|
98
|
+
// The worktree manager will handle this by using its configured base directory
|
|
99
|
+
const worktreeManager = new GitWorktreeManager(db);
|
|
100
|
+
|
|
101
|
+
// We need a source repository to create worktrees from
|
|
102
|
+
// Tier 0: Check known local path from repo_settings (registered by CLI usage)
|
|
103
|
+
let repositoryPath;
|
|
104
|
+
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
105
|
+
const worktreeRepo = new WorktreeRepository(db);
|
|
106
|
+
const knownPath = await repoSettingsRepo.getLocalPath(repository);
|
|
107
|
+
|
|
108
|
+
if (knownPath && await worktreeManager.pathExists(knownPath)) {
|
|
109
|
+
// Validate it's still a valid git repo
|
|
110
|
+
try {
|
|
111
|
+
const git = simpleGit(knownPath);
|
|
112
|
+
await git.revparse(['--is-inside-work-tree']);
|
|
113
|
+
repositoryPath = knownPath;
|
|
114
|
+
logger.info(`Using known repository location at ${repositoryPath}`);
|
|
115
|
+
} catch {
|
|
116
|
+
// Path exists but isn't a valid git repo anymore, clear it
|
|
117
|
+
logger.warn(`Known path ${knownPath} is no longer a valid git repo, clearing`);
|
|
118
|
+
await repoSettingsRepo.setLocalPath(repository, null);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Tier 1: Check if we have an existing worktree for this repo
|
|
123
|
+
if (!repositoryPath) {
|
|
124
|
+
const existingWorktree = await worktreeRepo.findByPR(parsedPrNumber, repository);
|
|
125
|
+
|
|
126
|
+
if (existingWorktree && await worktreeManager.pathExists(existingWorktree.path)) {
|
|
127
|
+
// Use the existing worktree path to find the parent git repository
|
|
128
|
+
try {
|
|
129
|
+
const git = simpleGit(existingWorktree.path);
|
|
130
|
+
repositoryPath = await git.revparse(['--show-toplevel']);
|
|
131
|
+
repositoryPath = repositoryPath.trim();
|
|
132
|
+
logger.info(`Using repository from existing worktree at ${repositoryPath}`);
|
|
133
|
+
} catch {
|
|
134
|
+
// If we can't get the git root, we'll need to clone
|
|
135
|
+
repositoryPath = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Tier 2 & 3: Check cached clone or clone fresh
|
|
141
|
+
if (!repositoryPath) {
|
|
142
|
+
// Check if there's a cached clone for this repository
|
|
143
|
+
const { getConfigDir } = require('../config');
|
|
144
|
+
const cachedRepoPath = path.join(getConfigDir(), 'repos', owner, repo);
|
|
145
|
+
|
|
146
|
+
if (await worktreeManager.pathExists(cachedRepoPath)) {
|
|
147
|
+
repositoryPath = cachedRepoPath;
|
|
148
|
+
logger.info(`Using cached repository at ${repositoryPath}`);
|
|
149
|
+
} else {
|
|
150
|
+
// Clone the repository
|
|
151
|
+
logger.info(`Cloning repository ${repository}...`);
|
|
152
|
+
await fs.mkdir(path.dirname(cachedRepoPath), { recursive: true });
|
|
153
|
+
|
|
154
|
+
const git = simpleGit();
|
|
155
|
+
|
|
156
|
+
// Clone with minimal depth for efficiency
|
|
157
|
+
const cloneUrl = `https://github.com/${owner}/${repo}.git`;
|
|
158
|
+
try {
|
|
159
|
+
await git.clone(cloneUrl, cachedRepoPath, ['--filter=blob:none', '--no-checkout']);
|
|
160
|
+
repositoryPath = cachedRepoPath;
|
|
161
|
+
logger.info(`Cloned repository to ${repositoryPath}`);
|
|
162
|
+
} catch (cloneError) {
|
|
163
|
+
return res.status(500).json({
|
|
164
|
+
success: false,
|
|
165
|
+
error: `Failed to clone repository: ${cloneError.message}`
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Setup git worktree
|
|
172
|
+
logger.info('Setting up git worktree...');
|
|
173
|
+
const prInfo = { owner, repo, number: parsedPrNumber };
|
|
174
|
+
const worktreePath = await worktreeManager.createWorktreeForPR(prInfo, prData, repositoryPath);
|
|
175
|
+
|
|
176
|
+
// Generate unified diff
|
|
177
|
+
logger.info('Generating unified diff...');
|
|
178
|
+
const diff = await worktreeManager.generateUnifiedDiff(worktreePath, prData);
|
|
179
|
+
const changedFiles = await worktreeManager.getChangedFiles(worktreePath, prData);
|
|
180
|
+
|
|
181
|
+
// Store PR data in database (similar to storePRData in main.js)
|
|
182
|
+
logger.info('Storing pull request data...');
|
|
183
|
+
await run(db, 'BEGIN TRANSACTION');
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
// Store or update worktree record
|
|
187
|
+
await worktreeRepo.getOrCreate({
|
|
188
|
+
prNumber: parsedPrNumber,
|
|
189
|
+
repository,
|
|
190
|
+
branch: prData.head_branch,
|
|
191
|
+
path: worktreePath
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Prepare extended PR data
|
|
195
|
+
const extendedPRData = {
|
|
196
|
+
...prData,
|
|
197
|
+
diff: diff,
|
|
198
|
+
changed_files: changedFiles,
|
|
199
|
+
worktree_path: worktreePath,
|
|
200
|
+
fetched_at: new Date().toISOString()
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Check if PR metadata exists
|
|
204
|
+
const existingPR = await queryOne(db, `
|
|
205
|
+
SELECT id FROM pr_metadata WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
206
|
+
`, [parsedPrNumber, repository]);
|
|
207
|
+
|
|
208
|
+
if (existingPR) {
|
|
209
|
+
// Update existing PR metadata
|
|
210
|
+
await run(db, `
|
|
211
|
+
UPDATE pr_metadata
|
|
212
|
+
SET title = ?, description = ?, author = ?,
|
|
213
|
+
base_branch = ?, head_branch = ?, pr_data = ?,
|
|
214
|
+
updated_at = CURRENT_TIMESTAMP
|
|
215
|
+
WHERE id = ?
|
|
216
|
+
`, [
|
|
217
|
+
prData.title,
|
|
218
|
+
prData.body,
|
|
219
|
+
prData.author,
|
|
220
|
+
prData.base_branch,
|
|
221
|
+
prData.head_branch,
|
|
222
|
+
JSON.stringify(extendedPRData),
|
|
223
|
+
existingPR.id
|
|
224
|
+
]);
|
|
225
|
+
logger.info(`Updated existing PR metadata (ID: ${existingPR.id})`);
|
|
226
|
+
} else {
|
|
227
|
+
// Insert new PR metadata
|
|
228
|
+
const result = await run(db, `
|
|
229
|
+
INSERT INTO pr_metadata
|
|
230
|
+
(pr_number, repository, title, description, author, base_branch, head_branch, pr_data)
|
|
231
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
232
|
+
`, [
|
|
233
|
+
parsedPrNumber,
|
|
234
|
+
repository,
|
|
235
|
+
prData.title,
|
|
236
|
+
prData.body,
|
|
237
|
+
prData.author,
|
|
238
|
+
prData.base_branch,
|
|
239
|
+
prData.head_branch,
|
|
240
|
+
JSON.stringify(extendedPRData)
|
|
241
|
+
]);
|
|
242
|
+
logger.info(`Created new PR metadata (ID: ${result.lastID})`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Create or update review record
|
|
246
|
+
const existingReview = await queryOne(db, `
|
|
247
|
+
SELECT id FROM reviews WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
248
|
+
`, [parsedPrNumber, repository]);
|
|
249
|
+
|
|
250
|
+
if (existingReview) {
|
|
251
|
+
await run(db, `
|
|
252
|
+
UPDATE reviews
|
|
253
|
+
SET review_data = ?, updated_at = CURRENT_TIMESTAMP
|
|
254
|
+
WHERE id = ?
|
|
255
|
+
`, [
|
|
256
|
+
JSON.stringify({
|
|
257
|
+
worktree_path: worktreePath,
|
|
258
|
+
created_at: new Date().toISOString()
|
|
259
|
+
}),
|
|
260
|
+
existingReview.id
|
|
261
|
+
]);
|
|
262
|
+
} else {
|
|
263
|
+
await run(db, `
|
|
264
|
+
INSERT INTO reviews
|
|
265
|
+
(pr_number, repository, status, review_data)
|
|
266
|
+
VALUES (?, ?, 'draft', ?)
|
|
267
|
+
`, [
|
|
268
|
+
parsedPrNumber,
|
|
269
|
+
repository,
|
|
270
|
+
JSON.stringify({
|
|
271
|
+
worktree_path: worktreePath,
|
|
272
|
+
created_at: new Date().toISOString()
|
|
273
|
+
})
|
|
274
|
+
]);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await run(db, 'COMMIT');
|
|
278
|
+
logger.success(`Stored PR data for ${repository} #${parsedPrNumber}`);
|
|
279
|
+
|
|
280
|
+
// Register the repository path for future use if it wasn't already known
|
|
281
|
+
// This ensures web UI discoveries also benefit future sessions
|
|
282
|
+
// Skip registration if: (1) knownPath was used (path === knownPath), or
|
|
283
|
+
// (2) we have a knownPath but it failed validation (already cleared above)
|
|
284
|
+
// Only register when we discovered a genuinely new path
|
|
285
|
+
if (repositoryPath && knownPath === null) {
|
|
286
|
+
// Only register if this path isn't already stored (avoid redundant writes)
|
|
287
|
+
const currentPath = await repoSettingsRepo.getLocalPath(repository);
|
|
288
|
+
if (path.resolve(currentPath || '') !== path.resolve(repositoryPath)) {
|
|
289
|
+
await repoSettingsRepo.setLocalPath(repository, repositoryPath);
|
|
290
|
+
logger.info(`Registered repository location: ${repositoryPath}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
} catch (dbError) {
|
|
295
|
+
await run(db, 'ROLLBACK');
|
|
296
|
+
throw new Error(`Failed to store PR data: ${dbError.message}`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Return success with review URL
|
|
300
|
+
const reviewUrl = `/pr/${owner}/${repo}/${parsedPrNumber}`;
|
|
301
|
+
|
|
302
|
+
logger.success(`Review ready at ${reviewUrl}`);
|
|
303
|
+
|
|
304
|
+
res.json({
|
|
305
|
+
success: true,
|
|
306
|
+
reviewUrl,
|
|
307
|
+
prNumber: parsedPrNumber,
|
|
308
|
+
repository,
|
|
309
|
+
title: prData.title
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
} catch (error) {
|
|
313
|
+
logger.error('Error creating worktree from web UI:', error);
|
|
314
|
+
|
|
315
|
+
// Provide user-friendly error messages
|
|
316
|
+
if (error.message && error.message.includes('authentication failed')) {
|
|
317
|
+
return res.status(401).json({
|
|
318
|
+
success: false,
|
|
319
|
+
error: 'GitHub authentication failed. Please check your token.'
|
|
320
|
+
});
|
|
321
|
+
} else if (error.message && error.message.includes('rate limit')) {
|
|
322
|
+
return res.status(429).json({
|
|
323
|
+
success: false,
|
|
324
|
+
error: 'GitHub API rate limit exceeded. Please try again later.'
|
|
325
|
+
});
|
|
326
|
+
} else if (error.message && error.message.includes('Network error')) {
|
|
327
|
+
return res.status(503).json({
|
|
328
|
+
success: false,
|
|
329
|
+
error: 'Network error. Please check your internet connection.'
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
res.status(500).json({
|
|
334
|
+
success: false,
|
|
335
|
+
error: error.message || 'Failed to create worktree'
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Get recently accessed worktrees
|
|
342
|
+
* Returns list of recently reviewed PRs with metadata
|
|
343
|
+
* Filters out stale worktrees where the directory no longer exists
|
|
344
|
+
*/
|
|
345
|
+
router.get('/api/worktrees/recent', async (req, res) => {
|
|
346
|
+
try {
|
|
347
|
+
const limit = Math.min(parseInt(req.query.limit) || 10, 50); // Default 10, max 50
|
|
348
|
+
const db = req.app.get('db');
|
|
349
|
+
|
|
350
|
+
// Get more worktrees than requested to account for stale ones we'll filter out
|
|
351
|
+
const enrichedWorktrees = await query(db, `
|
|
352
|
+
SELECT
|
|
353
|
+
w.id,
|
|
354
|
+
w.repository,
|
|
355
|
+
w.pr_number,
|
|
356
|
+
w.branch,
|
|
357
|
+
w.path,
|
|
358
|
+
w.last_accessed_at,
|
|
359
|
+
w.created_at,
|
|
360
|
+
pm.title as pr_title,
|
|
361
|
+
pm.author,
|
|
362
|
+
pm.head_branch
|
|
363
|
+
FROM worktrees w
|
|
364
|
+
LEFT JOIN pr_metadata pm ON w.pr_number = pm.pr_number AND w.repository = pm.repository COLLATE NOCASE
|
|
365
|
+
ORDER BY w.last_accessed_at DESC
|
|
366
|
+
LIMIT ?
|
|
367
|
+
`, [limit * 2]); // Fetch extra to account for stale entries
|
|
368
|
+
|
|
369
|
+
// Filter out worktrees where:
|
|
370
|
+
// 1. The directory no longer exists
|
|
371
|
+
// 2. The data is incomplete/corrupted (no author, unknown branch)
|
|
372
|
+
const staleIds = [];
|
|
373
|
+
const validWorktrees = [];
|
|
374
|
+
|
|
375
|
+
for (const w of enrichedWorktrees) {
|
|
376
|
+
// Check for corrupted/incomplete data
|
|
377
|
+
if (w.branch === 'unknown' || !w.pr_title || w.pr_title === `PR #${w.pr_number}`) {
|
|
378
|
+
staleIds.push(w.id);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Check if path still exists
|
|
383
|
+
try {
|
|
384
|
+
await fs.access(w.path);
|
|
385
|
+
validWorktrees.push(w);
|
|
386
|
+
} catch {
|
|
387
|
+
// Path doesn't exist - mark for cleanup
|
|
388
|
+
staleIds.push(w.id);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Cleanup stale worktree records in background (don't block response)
|
|
393
|
+
if (staleIds.length > 0) {
|
|
394
|
+
setImmediate(async () => {
|
|
395
|
+
try {
|
|
396
|
+
const placeholders = staleIds.map(() => '?').join(',');
|
|
397
|
+
await run(db, `DELETE FROM worktrees WHERE id IN (${placeholders})`, staleIds);
|
|
398
|
+
logger.info(`Cleaned up ${staleIds.length} stale worktree records`);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
logger.warn(`Failed to cleanup stale worktrees: ${err.message}`);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Format the results with fallback values, limited to requested count
|
|
406
|
+
const formattedWorktrees = validWorktrees.slice(0, limit).map(w => ({
|
|
407
|
+
id: w.id,
|
|
408
|
+
repository: w.repository,
|
|
409
|
+
pr_number: w.pr_number,
|
|
410
|
+
pr_title: w.pr_title || `PR #${w.pr_number}`,
|
|
411
|
+
author: w.author || null,
|
|
412
|
+
branch: w.branch,
|
|
413
|
+
head_branch: w.head_branch || w.branch,
|
|
414
|
+
last_accessed_at: w.last_accessed_at,
|
|
415
|
+
created_at: w.created_at
|
|
416
|
+
}));
|
|
417
|
+
|
|
418
|
+
res.json({
|
|
419
|
+
success: true,
|
|
420
|
+
worktrees: formattedWorktrees
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
} catch (error) {
|
|
424
|
+
console.error('Error fetching recent worktrees:', error);
|
|
425
|
+
res.status(500).json({
|
|
426
|
+
error: 'Failed to fetch recent worktrees'
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Delete a worktree
|
|
433
|
+
* Removes the worktree record from the database and optionally deletes the directory
|
|
434
|
+
*/
|
|
435
|
+
router.delete('/api/worktrees/:id', async (req, res) => {
|
|
436
|
+
try {
|
|
437
|
+
const worktreeId = req.params.id;
|
|
438
|
+
|
|
439
|
+
if (!worktreeId) {
|
|
440
|
+
return res.status(400).json({
|
|
441
|
+
success: false,
|
|
442
|
+
error: 'Invalid worktree ID'
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const db = req.app.get('db');
|
|
447
|
+
const worktreeRepo = new WorktreeRepository(db);
|
|
448
|
+
|
|
449
|
+
// Get worktree info before deletion
|
|
450
|
+
const worktree = await queryOne(db, `
|
|
451
|
+
SELECT id, path, pr_number, repository FROM worktrees WHERE id = ?
|
|
452
|
+
`, [worktreeId]);
|
|
453
|
+
|
|
454
|
+
if (!worktree) {
|
|
455
|
+
return res.status(404).json({
|
|
456
|
+
success: false,
|
|
457
|
+
error: 'Worktree not found'
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
logger.info(`Deleting worktree ID ${worktreeId} for ${worktree.repository} #${worktree.pr_number}`);
|
|
462
|
+
|
|
463
|
+
// Delete the worktree directory if it exists
|
|
464
|
+
if (worktree.path) {
|
|
465
|
+
try {
|
|
466
|
+
await fs.access(worktree.path);
|
|
467
|
+
// Directory exists, try to remove it
|
|
468
|
+
await fs.rm(worktree.path, { recursive: true, force: true });
|
|
469
|
+
logger.info(`Deleted worktree directory: ${worktree.path}`);
|
|
470
|
+
} catch (pathError) {
|
|
471
|
+
// Directory doesn't exist or can't be accessed - that's okay
|
|
472
|
+
logger.warn(`Could not delete worktree directory (may not exist): ${worktree.path}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Delete the worktree record from the database
|
|
477
|
+
await run(db, `DELETE FROM worktrees WHERE id = ?`, [worktreeId]);
|
|
478
|
+
|
|
479
|
+
// Also delete associated PR metadata and comments (optional cleanup)
|
|
480
|
+
// Keep PR metadata for now as user might want to reload the PR later
|
|
481
|
+
// await run(db, `DELETE FROM pr_metadata WHERE pr_number = ? AND repository = ?`,
|
|
482
|
+
// [worktree.pr_number, worktree.repository]);
|
|
483
|
+
|
|
484
|
+
logger.success(`Deleted worktree ID ${worktreeId}`);
|
|
485
|
+
|
|
486
|
+
res.json({
|
|
487
|
+
success: true,
|
|
488
|
+
message: `Worktree for ${worktree.repository} #${worktree.pr_number} deleted`
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
} catch (error) {
|
|
492
|
+
logger.error('Error deleting worktree:', error);
|
|
493
|
+
res.status(500).json({
|
|
494
|
+
success: false,
|
|
495
|
+
error: 'Failed to delete worktree: ' + error.message
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
module.exports = router;
|