buddy-workbench 0.1.18 → 0.1.20
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/package.json +2 -1
- package/server/config.js +1 -0
- package/server/repositories/branch-sync.js +56 -0
- package/server/routes/branch-sync.js +464 -0
- package/server/routes/file-organizer.js +3 -2
- package/server/services/clipboard-history.js +2 -2
- package/server/services/file-organizer.js +67 -1
- package/server/services/process-manager.js +14 -5
- package/server.js +2 -0
- package/ui/dist/assets/index-dsVCUzqk.js +530 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-iN37nEIi.js +0 -524
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "buddy-workbench",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"version": "node -e \"const v=process.env.npm_package_version; const fs=require('fs'); ['ui/package.json', 'ui/package-lock.json'].forEach(p=>{if(fs.existsSync(p)){const j=JSON.parse(fs.readFileSync(p)); j.version=v; if(j.packages&&j.packages['']){j.packages[''].version=v;} fs.writeFileSync(p, JSON.stringify(j,null,2)+'\\n');}});\" && git add ui/package.json ui/package-lock.json"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@jsquash/jpeg": "^1.6.0",
|
|
25
26
|
"axios": "^1.7.9",
|
|
26
27
|
"express": "^5.1.0"
|
|
27
28
|
}
|
package/server/config.js
CHANGED
|
@@ -12,6 +12,7 @@ export const paths = {
|
|
|
12
12
|
staticPages: join(root, 'data', 'static-pages.json'),
|
|
13
13
|
errors: join(root, 'data', 'errors.json'),
|
|
14
14
|
postman: join(root, 'data', 'postman.json'),
|
|
15
|
+
branchSync: join(root, 'data', 'branch-sync.json'),
|
|
15
16
|
shutdownLog: join(root, 'data', 'shutdown.log'),
|
|
16
17
|
clipboardDir: join(root, 'data', 'clipboard'),
|
|
17
18
|
plugins: join(root, 'plugins'),
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { paths } from '../config.js';
|
|
4
|
+
|
|
5
|
+
export function listBranchSyncApps() {
|
|
6
|
+
try {
|
|
7
|
+
return existsSync(paths.branchSync) ? JSON.parse(readFileSync(paths.branchSync, 'utf8')) : [];
|
|
8
|
+
} catch {
|
|
9
|
+
return [];
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function saveBranchSyncApps(apps) {
|
|
14
|
+
mkdirSync(dirname(paths.branchSync), { recursive: true });
|
|
15
|
+
writeFileSync(paths.branchSync, JSON.stringify(apps, null, 2));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createBranchSyncApp(data) {
|
|
19
|
+
const apps = listBranchSyncApps();
|
|
20
|
+
const newApp = {
|
|
21
|
+
id: `app-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
22
|
+
name: data.name || '',
|
|
23
|
+
repo: data.repo || '',
|
|
24
|
+
activeBranch: data.activeBranch || '',
|
|
25
|
+
lastReleaseBranch: data.lastReleaseBranch || '',
|
|
26
|
+
subApps: Array.isArray(data.subApps) ? data.subApps : [],
|
|
27
|
+
createdAt: new Date().toISOString()
|
|
28
|
+
};
|
|
29
|
+
apps.push(newApp);
|
|
30
|
+
saveBranchSyncApps(apps);
|
|
31
|
+
return newApp;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function updateBranchSyncApp(id, data) {
|
|
35
|
+
const apps = listBranchSyncApps();
|
|
36
|
+
const index = apps.findIndex((a) => a.id === id);
|
|
37
|
+
if (index === -1) return null;
|
|
38
|
+
apps[index] = {
|
|
39
|
+
...apps[index],
|
|
40
|
+
name: data.name !== undefined ? data.name : apps[index].name,
|
|
41
|
+
repo: data.repo !== undefined ? data.repo : apps[index].repo,
|
|
42
|
+
activeBranch: data.activeBranch !== undefined ? data.activeBranch : apps[index].activeBranch,
|
|
43
|
+
lastReleaseBranch: data.lastReleaseBranch !== undefined ? data.lastReleaseBranch : apps[index].lastReleaseBranch,
|
|
44
|
+
subApps: Array.isArray(data.subApps) ? data.subApps : apps[index].subApps,
|
|
45
|
+
updatedAt: new Date().toISOString()
|
|
46
|
+
};
|
|
47
|
+
saveBranchSyncApps(apps);
|
|
48
|
+
return apps[index];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function removeBranchSyncApp(id) {
|
|
52
|
+
const apps = listBranchSyncApps();
|
|
53
|
+
const filtered = apps.filter((a) => a.id !== id);
|
|
54
|
+
saveBranchSyncApps(filtered);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { Router } from 'express';
|
|
4
|
+
import { readSettings } from '../repositories/settings.js';
|
|
5
|
+
import {
|
|
6
|
+
createBranchSyncApp,
|
|
7
|
+
listBranchSyncApps,
|
|
8
|
+
removeBranchSyncApp,
|
|
9
|
+
updateBranchSyncApp
|
|
10
|
+
} from '../repositories/branch-sync.js';
|
|
11
|
+
|
|
12
|
+
const router = Router();
|
|
13
|
+
|
|
14
|
+
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
|
15
|
+
const httpClient = axios.create({
|
|
16
|
+
httpsAgent,
|
|
17
|
+
validateStatus: () => true
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function parseRepo(repoStr) {
|
|
21
|
+
if (!repoStr || typeof repoStr !== 'string') return null;
|
|
22
|
+
const settings = readSettings();
|
|
23
|
+
let raw = repoStr.trim().replace(/\.git$/i, '');
|
|
24
|
+
|
|
25
|
+
let protocol = 'https';
|
|
26
|
+
if (raw.startsWith('http://')) {
|
|
27
|
+
protocol = 'http';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let host = '';
|
|
31
|
+
let projectKey = '';
|
|
32
|
+
let repositorySlug = '';
|
|
33
|
+
|
|
34
|
+
// 1. Handle HTTP / HTTPS / SSH URLs
|
|
35
|
+
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('ssh://')) {
|
|
36
|
+
try {
|
|
37
|
+
let urlStr = raw;
|
|
38
|
+
if (urlStr.startsWith('ssh://')) {
|
|
39
|
+
urlStr = urlStr.replace(/^ssh:\/\//i, 'https://');
|
|
40
|
+
}
|
|
41
|
+
// Strip basic auth (e.g. http://username@host/...)
|
|
42
|
+
urlStr = urlStr.replace(/^(https?:\/\/)(([^/@]+)@)/i, '$1');
|
|
43
|
+
|
|
44
|
+
const urlObj = new URL(urlStr);
|
|
45
|
+
host = urlObj.host; // Includes hostname and port
|
|
46
|
+
let pathname = urlObj.pathname.replace(/^\/+/, '');
|
|
47
|
+
|
|
48
|
+
// Case A: /projects/PROJ/repos/REPO or /users/USER/repos/REPO
|
|
49
|
+
const webMatch = pathname.match(/^(?:projects\/([^/]+)|users\/([^/]+))\/repos\/([^/]+)/i);
|
|
50
|
+
if (webMatch) {
|
|
51
|
+
projectKey = webMatch[1] ? webMatch[1] : `~${webMatch[2]}`;
|
|
52
|
+
repositorySlug = webMatch[3];
|
|
53
|
+
return { protocol, host, projectKey, repositorySlug };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Case B: /scm/PROJ/REPO or /scm/~USER/REPO
|
|
57
|
+
const scmMatch = pathname.match(/^scm\/([^/]+)\/([^/]+)/i);
|
|
58
|
+
if (scmMatch) {
|
|
59
|
+
projectKey = scmMatch[1];
|
|
60
|
+
repositorySlug = scmMatch[2];
|
|
61
|
+
return { protocol, host, projectKey, repositorySlug };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Case C: /PROJ/REPO
|
|
65
|
+
const parts = pathname.split('/').filter(Boolean);
|
|
66
|
+
if (parts.length >= 2) {
|
|
67
|
+
projectKey = parts[parts.length - 2];
|
|
68
|
+
repositorySlug = parts[parts.length - 1];
|
|
69
|
+
return { protocol, host, projectKey, repositorySlug };
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. SSH / SCP pattern: bitbucket@host/PROJECT/REPO or git@host:PROJECT/REPO
|
|
75
|
+
const scpMatch = raw.match(/^(?:[a-zA-Z0-9_.-]+@)?([^:/]+)[:/](.+)$/);
|
|
76
|
+
if (scpMatch) {
|
|
77
|
+
host = scpMatch[1];
|
|
78
|
+
let path = scpMatch[2].replace(/^\/+/, '').replace(/^scm\//i, '');
|
|
79
|
+
const pathParts = path.split('/').filter(Boolean);
|
|
80
|
+
if (pathParts.length >= 2) {
|
|
81
|
+
projectKey = pathParts[pathParts.length - 2];
|
|
82
|
+
repositorySlug = pathParts[pathParts.length - 1];
|
|
83
|
+
return { protocol, host, projectKey, repositorySlug };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 3. Fallback: PROJ/REPO
|
|
88
|
+
const parts = raw.split('/').filter(Boolean);
|
|
89
|
+
if (parts.length === 2) {
|
|
90
|
+
projectKey = parts[0];
|
|
91
|
+
repositorySlug = parts[1];
|
|
92
|
+
const domain = settings.domain || '';
|
|
93
|
+
host = domain ? (domain.includes('bitbucket') ? domain : `bitbucket.${domain}`) : '';
|
|
94
|
+
return { protocol, host, projectKey, repositorySlug };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check branch merge status & existing PR for a single repo and branch pair
|
|
101
|
+
async function checkBranchPair(parsedRepo, sourceBranch, targetBranch, headers) {
|
|
102
|
+
if (!sourceBranch || !targetBranch) {
|
|
103
|
+
return { error: 'Source and target branches must be specified.' };
|
|
104
|
+
}
|
|
105
|
+
const { protocol = 'https', host, projectKey, repositorySlug } = parsedRepo;
|
|
106
|
+
if (!host) {
|
|
107
|
+
return { error: 'Could not determine Bitbucket host for repository.' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result = {
|
|
111
|
+
sourceBranch,
|
|
112
|
+
targetBranch,
|
|
113
|
+
merged: false,
|
|
114
|
+
unmergedCommitCount: 0,
|
|
115
|
+
openPr: null,
|
|
116
|
+
error: null
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// 1. Check commits in sourceBranch not in targetBranch
|
|
121
|
+
const commitsUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/commits?until=${encodeURIComponent(targetBranch)}&since=${encodeURIComponent(sourceBranch)}&limit=1`;
|
|
122
|
+
const commitsRes = await httpClient.get(commitsUrl, { headers });
|
|
123
|
+
|
|
124
|
+
if (commitsRes.status === 401) {
|
|
125
|
+
result.error = 'Unauthorized. Please check your Bitbucket Access Token in Settings.';
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (commitsRes.status < 200 || commitsRes.status >= 300) {
|
|
130
|
+
const errDetail = commitsRes.data?.errors?.[0]?.message || commitsRes.data?.message || commitsRes.statusText;
|
|
131
|
+
result.error = `Failed to check commits (${commitsRes.status}): ${errDetail}`;
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const commits = commitsRes.data?.values || [];
|
|
136
|
+
if (commits.length === 0) {
|
|
137
|
+
result.merged = true;
|
|
138
|
+
result.unmergedCommitCount = 0;
|
|
139
|
+
} else {
|
|
140
|
+
result.merged = false;
|
|
141
|
+
result.unmergedCommitCount = commitsRes.data?.size || commits.length;
|
|
142
|
+
|
|
143
|
+
// 2. Check if an open PR already exists
|
|
144
|
+
try {
|
|
145
|
+
const prUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests?at=refs/heads/${encodeURIComponent(sourceBranch)}&direction=OUTGOING&state=OPEN`;
|
|
146
|
+
const prRes = await httpClient.get(prUrl, { headers });
|
|
147
|
+
if (prRes.status >= 200 && prRes.status < 300) {
|
|
148
|
+
const prs = prRes.data?.values || [];
|
|
149
|
+
const existingPr = prs.find((pr) => {
|
|
150
|
+
const toId = pr.toRef?.id || '';
|
|
151
|
+
const toDisplay = pr.toRef?.displayId || '';
|
|
152
|
+
return toId === `refs/heads/${targetBranch}` || toDisplay === targetBranch;
|
|
153
|
+
});
|
|
154
|
+
if (existingPr) {
|
|
155
|
+
result.openPr = {
|
|
156
|
+
id: existingPr.id,
|
|
157
|
+
title: existingPr.title,
|
|
158
|
+
url: existingPr.links?.self?.[0]?.href || `${protocol}://${host}/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${existingPr.id}`
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {}
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
result.error = err.message || 'Network error checking branch status.';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// App CRUD Routes
|
|
172
|
+
router.get('/apps', (_req, res) => {
|
|
173
|
+
res.json(listBranchSyncApps());
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Fetch Remote Branches List for a Repository from Bitbucket
|
|
177
|
+
router.get('/branches', async (req, res) => {
|
|
178
|
+
const repoStr = req.query.repo;
|
|
179
|
+
if (!repoStr) {
|
|
180
|
+
return res.status(400).json({ error: 'Repository URL parameter "repo" is required.', branches: [] });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const parsed = parseRepo(String(repoStr));
|
|
184
|
+
if (!parsed) {
|
|
185
|
+
return res.status(400).json({ error: `Invalid repository URL format: ${repoStr}`, branches: [] });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const settings = readSettings();
|
|
189
|
+
const token = settings.bitbucketAccessToken;
|
|
190
|
+
const headers = { Accept: 'application/json' };
|
|
191
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
192
|
+
|
|
193
|
+
const { protocol = 'https', host, projectKey, repositorySlug } = parsed;
|
|
194
|
+
if (!host) {
|
|
195
|
+
return res.status(400).json({ error: 'Could not determine Bitbucket host for repository.', branches: [] });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
const branchesUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/branches?limit=200`;
|
|
200
|
+
const response = await httpClient.get(branchesUrl, { headers });
|
|
201
|
+
|
|
202
|
+
if (response.status >= 200 && response.status < 300) {
|
|
203
|
+
const values = response.data?.values || [];
|
|
204
|
+
const branchNames = values
|
|
205
|
+
.map((b) => b.displayId || b.id?.replace(/^refs\/heads\//, ''))
|
|
206
|
+
.filter(Boolean);
|
|
207
|
+
return res.json({ branches: branchNames });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const errDetail = response.data?.errors?.[0]?.message || response.data?.message || response.statusText;
|
|
211
|
+
return res.status(response.status).json({ error: `Bitbucket error: ${errDetail}`, branches: [] });
|
|
212
|
+
} catch (err) {
|
|
213
|
+
return res.status(500).json({ error: err.message || 'Failed to fetch remote branches.', branches: [] });
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
router.post('/apps', (req, res) => {
|
|
218
|
+
const { name, repo, activeBranch, lastReleaseBranch, subApps } = req.body || {};
|
|
219
|
+
if (!name || !repo) {
|
|
220
|
+
return res.status(400).json({ error: 'App name and repository URL are required.' });
|
|
221
|
+
}
|
|
222
|
+
const app = createBranchSyncApp({ name, repo, activeBranch, lastReleaseBranch, subApps });
|
|
223
|
+
res.status(201).json(app);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
router.put('/apps/:id', (req, res) => {
|
|
227
|
+
const updated = updateBranchSyncApp(req.params.id, req.body || {});
|
|
228
|
+
if (!updated) {
|
|
229
|
+
return res.status(404).json({ error: 'App not found.' });
|
|
230
|
+
}
|
|
231
|
+
res.json(updated);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
router.delete('/apps/:id', (req, res) => {
|
|
235
|
+
removeBranchSyncApp(req.params.id);
|
|
236
|
+
res.status(204).end();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Check Branch Sync Status for an App (and all its nested subApps)
|
|
240
|
+
router.post('/check', async (req, res) => {
|
|
241
|
+
const { app } = req.body || {};
|
|
242
|
+
if (!app || !app.repo) {
|
|
243
|
+
return res.status(400).json({ error: 'Valid App configuration is required.' });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const settings = readSettings();
|
|
247
|
+
const token = settings.bitbucketAccessToken;
|
|
248
|
+
const headers = { Accept: 'application/json' };
|
|
249
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
250
|
+
|
|
251
|
+
const activeBranch = app.activeBranch || 'active';
|
|
252
|
+
const lastReleaseBranch = app.lastReleaseBranch || 'release';
|
|
253
|
+
const targetMasterBranch = 'master';
|
|
254
|
+
|
|
255
|
+
// 1. Check parent app
|
|
256
|
+
const parentParsed = parseRepo(app.repo);
|
|
257
|
+
let parentCheck = null;
|
|
258
|
+
if (!parentParsed) {
|
|
259
|
+
parentCheck = {
|
|
260
|
+
error: `Invalid repository URL format: ${app.repo}`
|
|
261
|
+
};
|
|
262
|
+
} else {
|
|
263
|
+
const releaseToMaster = await checkBranchPair(parentParsed, lastReleaseBranch, targetMasterBranch, headers);
|
|
264
|
+
const masterToActive = await checkBranchPair(parentParsed, targetMasterBranch, activeBranch, headers);
|
|
265
|
+
parentCheck = {
|
|
266
|
+
releaseToMaster,
|
|
267
|
+
masterToActive
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// 2. Check sub-apps (using sub.branch if configured, otherwise parent's activeBranch)
|
|
272
|
+
const subAppResults = [];
|
|
273
|
+
for (const sub of app.subApps || []) {
|
|
274
|
+
const subParsed = parseRepo(sub.repo);
|
|
275
|
+
const subActiveBranch = (sub.branch && typeof sub.branch === 'string' && sub.branch.trim()) ? sub.branch.trim() : activeBranch;
|
|
276
|
+
|
|
277
|
+
if (!subParsed) {
|
|
278
|
+
subAppResults.push({
|
|
279
|
+
name: sub.name,
|
|
280
|
+
repo: sub.repo,
|
|
281
|
+
activeBranch: subActiveBranch,
|
|
282
|
+
error: `Invalid sub-app repository URL format: ${sub.repo}`
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
const releaseToMaster = await checkBranchPair(subParsed, lastReleaseBranch, targetMasterBranch, headers);
|
|
286
|
+
const masterToActive = await checkBranchPair(subParsed, targetMasterBranch, subActiveBranch, headers);
|
|
287
|
+
subAppResults.push({
|
|
288
|
+
name: sub.name,
|
|
289
|
+
repo: sub.repo,
|
|
290
|
+
activeBranch: subActiveBranch,
|
|
291
|
+
releaseToMaster,
|
|
292
|
+
masterToActive
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
res.json({
|
|
298
|
+
appId: app.id,
|
|
299
|
+
appName: app.name,
|
|
300
|
+
parentCheck,
|
|
301
|
+
subAppResults,
|
|
302
|
+
checkedAt: new Date().toISOString()
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Create Pull Request
|
|
307
|
+
router.post('/create-pr', async (req, res) => {
|
|
308
|
+
const { repo, sourceBranch, targetBranch, title } = req.body || {};
|
|
309
|
+
if (!repo || !sourceBranch || !targetBranch) {
|
|
310
|
+
return res.status(400).json({ error: 'Repository, sourceBranch, and targetBranch are required.' });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const parsed = parseRepo(repo);
|
|
314
|
+
if (!parsed) {
|
|
315
|
+
return res.status(400).json({ error: `Invalid repository URL format: ${repo}` });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const settings = readSettings();
|
|
319
|
+
const token = settings.bitbucketAccessToken;
|
|
320
|
+
if (!token) {
|
|
321
|
+
return res.status(400).json({ error: 'Bitbucket Access Token is not configured. Please set your token in Settings.' });
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { protocol = 'https', host, projectKey, repositorySlug } = parsed;
|
|
325
|
+
const headers = {
|
|
326
|
+
Accept: 'application/json',
|
|
327
|
+
'Content-Type': 'application/json',
|
|
328
|
+
Authorization: `Bearer ${token}`
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const prPayload = {
|
|
332
|
+
title: title || `Merge ${sourceBranch} to ${targetBranch}`,
|
|
333
|
+
description: `Automated Pull Request created via DevBuddy Branch Sync Status Check.\n\nMerging \`${sourceBranch}\` into \`${targetBranch}\`.`,
|
|
334
|
+
fromRef: {
|
|
335
|
+
id: `refs/heads/${sourceBranch}`,
|
|
336
|
+
repository: {
|
|
337
|
+
slug: repositorySlug,
|
|
338
|
+
project: { key: projectKey }
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
toRef: {
|
|
342
|
+
id: `refs/heads/${targetBranch}`,
|
|
343
|
+
repository: {
|
|
344
|
+
slug: repositorySlug,
|
|
345
|
+
project: { key: projectKey }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const prUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests`;
|
|
352
|
+
const prRes = await httpClient.post(prUrl, prPayload, { headers });
|
|
353
|
+
|
|
354
|
+
if (prRes.status >= 200 && prRes.status < 300) {
|
|
355
|
+
const data = prRes.data;
|
|
356
|
+
const webUrl = data?.links?.self?.[0]?.href || `${protocol}://${host}/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${data?.id}`;
|
|
357
|
+
return res.json({
|
|
358
|
+
success: true,
|
|
359
|
+
prId: data?.id,
|
|
360
|
+
prUrl: webUrl,
|
|
361
|
+
title: data?.title
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Handle error response from Bitbucket
|
|
366
|
+
const errMessage =
|
|
367
|
+
prRes.data?.errors?.[0]?.message ||
|
|
368
|
+
prRes.data?.message ||
|
|
369
|
+
`Bitbucket returned status ${prRes.status}: ${prRes.statusText || 'Failed to create PR'}`;
|
|
370
|
+
|
|
371
|
+
return res.status(400).json({ error: errMessage });
|
|
372
|
+
} catch (err) {
|
|
373
|
+
return res.status(500).json({ error: err.message || 'Failed to create pull request.' });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Helper to create a single branch on Bitbucket
|
|
378
|
+
async function createBranchForRepo(repoStr, sourceBranch, targetBranch, headers) {
|
|
379
|
+
const parsed = parseRepo(repoStr);
|
|
380
|
+
if (!parsed) {
|
|
381
|
+
return { success: false, error: `Invalid repository URL format: ${repoStr}` };
|
|
382
|
+
}
|
|
383
|
+
const { protocol = 'https', host, projectKey, repositorySlug } = parsed;
|
|
384
|
+
if (!host) {
|
|
385
|
+
return { success: false, error: 'Could not determine Bitbucket host for repository.' };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const startPoint = sourceBranch.startsWith('refs/') ? sourceBranch : `refs/heads/${sourceBranch}`;
|
|
389
|
+
const branchUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/branches`;
|
|
390
|
+
const payload = {
|
|
391
|
+
name: targetBranch,
|
|
392
|
+
startPoint
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
const res = await httpClient.post(branchUrl, payload, { headers });
|
|
397
|
+
if (res.status >= 200 && res.status < 300) {
|
|
398
|
+
return { success: true, branch: targetBranch, id: res.data?.id };
|
|
399
|
+
}
|
|
400
|
+
const errDetail = res.data?.errors?.[0]?.message || res.data?.message || `Bitbucket returned status ${res.status}: ${res.statusText}`;
|
|
401
|
+
return { success: false, error: errDetail };
|
|
402
|
+
} catch (err) {
|
|
403
|
+
return { success: false, error: err.message || 'Network error creating branch.' };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Create Branch endpoint
|
|
408
|
+
router.post('/create-branch', async (req, res) => {
|
|
409
|
+
const { app, repo, sourceBranch, targetBranch } = req.body || {};
|
|
410
|
+
if (!sourceBranch || !targetBranch) {
|
|
411
|
+
return res.status(400).json({ error: 'Source branch and target branch are required.' });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const settings = readSettings();
|
|
415
|
+
const token = settings.bitbucketAccessToken;
|
|
416
|
+
if (!token) {
|
|
417
|
+
return res.status(400).json({ error: 'Bitbucket Access Token is not configured. Please set your token in Settings.' });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const headers = {
|
|
421
|
+
Accept: 'application/json',
|
|
422
|
+
'Content-Type': 'application/json',
|
|
423
|
+
Authorization: `Bearer ${token}`
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
if (app && app.repo) {
|
|
427
|
+
const results = [];
|
|
428
|
+
// 1. Parent App
|
|
429
|
+
const parentRes = await createBranchForRepo(app.repo, sourceBranch, targetBranch, headers);
|
|
430
|
+
results.push({
|
|
431
|
+
name: app.name,
|
|
432
|
+
repo: app.repo,
|
|
433
|
+
isParent: true,
|
|
434
|
+
...parentRes
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// 2. Sub Apps
|
|
438
|
+
for (const sub of app.subApps || []) {
|
|
439
|
+
const subSourceBranch = (sourceBranch === app.activeBranch && sub.branch && typeof sub.branch === 'string' && sub.branch.trim())
|
|
440
|
+
? sub.branch.trim()
|
|
441
|
+
: sourceBranch;
|
|
442
|
+
const subRes = await createBranchForRepo(sub.repo, subSourceBranch, targetBranch, headers);
|
|
443
|
+
results.push({
|
|
444
|
+
name: sub.name,
|
|
445
|
+
repo: sub.repo,
|
|
446
|
+
isParent: false,
|
|
447
|
+
sourceBranch: subSourceBranch,
|
|
448
|
+
...subRes
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return res.json({ results });
|
|
453
|
+
} else if (repo) {
|
|
454
|
+
const singleRes = await createBranchForRepo(repo, sourceBranch, targetBranch, headers);
|
|
455
|
+
if (!singleRes.success) {
|
|
456
|
+
return res.status(400).json({ error: singleRes.error });
|
|
457
|
+
}
|
|
458
|
+
return res.json(singleRes);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return res.status(400).json({ error: 'Repository or App object is required.' });
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
export default router;
|
|
@@ -29,7 +29,7 @@ router.post('/scan', async (req, res, next) => {
|
|
|
29
29
|
// POST /api/file-organizer/execute
|
|
30
30
|
router.post('/execute', async (req, res, next) => {
|
|
31
31
|
try {
|
|
32
|
-
const { sourcePath, targetPath, files, operation = 'move' } = req.body;
|
|
32
|
+
const { sourcePath, targetPath, files, operation = 'move', deleteEmptyFolders = false } = req.body;
|
|
33
33
|
if (!sourcePath || !targetPath) {
|
|
34
34
|
return res.status(400).json({ error: '必须同时提供源文件夹和目标文件夹路径' });
|
|
35
35
|
}
|
|
@@ -41,7 +41,8 @@ router.post('/execute', async (req, res, next) => {
|
|
|
41
41
|
sourcePath,
|
|
42
42
|
targetPath,
|
|
43
43
|
files,
|
|
44
|
-
operation
|
|
44
|
+
operation,
|
|
45
|
+
deleteEmptyFolders: Boolean(deleteEmptyFolders)
|
|
45
46
|
});
|
|
46
47
|
|
|
47
48
|
res.json(session);
|
|
@@ -4,7 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFile
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import encodeMozjpeg, { init as initMozjpeg } from '
|
|
7
|
+
import encodeMozjpeg, { init as initMozjpeg } from '@jsquash/jpeg/encode.js';
|
|
8
8
|
import { paths } from '../config.js';
|
|
9
9
|
import { readSettings } from '../repositories/settings.js';
|
|
10
10
|
|
|
@@ -16,7 +16,7 @@ let mozjpegWasmModule = null;
|
|
|
16
16
|
|
|
17
17
|
async function compressMozjpeg(rgbaBuf, width, height, quality = 75) {
|
|
18
18
|
if (!mozjpegWasmModule) {
|
|
19
|
-
const wasmPath = fileURLToPath(import.meta.resolve('../../ui/
|
|
19
|
+
const wasmPath = fileURLToPath(import.meta.resolve('../../ui/dist/assets/mozjpeg_enc-DO-zoExo.wasm'));
|
|
20
20
|
mozjpegWasmModule = await WebAssembly.compile(readFileSync(wasmPath));
|
|
21
21
|
await initMozjpeg(mozjpegWasmModule);
|
|
22
22
|
}
|
|
@@ -291,11 +291,58 @@ function saveHistory(history) {
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
async function cleanEmptyDirectories(dirPath, excludeTargetDir, deletedDirs = []) {
|
|
295
|
+
if (!dirPath || !fs.existsSync(dirPath)) return deletedDirs;
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
299
|
+
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
if (entry.isDirectory()) {
|
|
302
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
303
|
+
|
|
304
|
+
// Do NOT delete or traverse into the target directory!
|
|
305
|
+
if (excludeTargetDir && (fullPath === excludeTargetDir || fullPath.startsWith(excludeTargetDir + path.sep))) {
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Recursively clean child directories first
|
|
310
|
+
await cleanEmptyDirectories(fullPath, excludeTargetDir, deletedDirs);
|
|
311
|
+
|
|
312
|
+
// Check if current subdirectory is empty
|
|
313
|
+
try {
|
|
314
|
+
const subEntries = await fs.promises.readdir(fullPath);
|
|
315
|
+
const remaining = subEntries.filter(n => !['.DS_Store', '.git', '.localized', 'Thumbs.db'].includes(n));
|
|
316
|
+
|
|
317
|
+
if (remaining.length === 0) {
|
|
318
|
+
// Remove OS metadata files inside if any
|
|
319
|
+
for (const metaFile of subEntries) {
|
|
320
|
+
try {
|
|
321
|
+
await fs.promises.unlink(path.join(fullPath, metaFile));
|
|
322
|
+
} catch {}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
await fs.promises.rmdir(fullPath);
|
|
326
|
+
deletedDirs.push(fullPath);
|
|
327
|
+
}
|
|
328
|
+
} catch (err) {
|
|
329
|
+
console.error(`Failed to clean empty directory ${fullPath}:`, err);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.error(`Failed to read directory ${dirPath} for empty cleanup:`, err);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return deletedDirs;
|
|
338
|
+
}
|
|
339
|
+
|
|
294
340
|
export async function executeOrganize({
|
|
295
341
|
sourcePath,
|
|
296
342
|
targetPath,
|
|
297
343
|
files,
|
|
298
|
-
operation = 'move'
|
|
344
|
+
operation = 'move',
|
|
345
|
+
deleteEmptyFolders = false
|
|
299
346
|
}) {
|
|
300
347
|
const resolvedSource = expandPath(sourcePath);
|
|
301
348
|
const resolvedTarget = resolveTargetPath(targetPath, resolvedSource);
|
|
@@ -350,6 +397,11 @@ export async function executeOrganize({
|
|
|
350
397
|
}
|
|
351
398
|
}
|
|
352
399
|
|
|
400
|
+
let deletedDirs = [];
|
|
401
|
+
if (deleteEmptyFolders) {
|
|
402
|
+
deletedDirs = await cleanEmptyDirectories(resolvedSource, resolvedTarget);
|
|
403
|
+
}
|
|
404
|
+
|
|
353
405
|
// Save history session
|
|
354
406
|
const history = loadHistory();
|
|
355
407
|
const session = {
|
|
@@ -358,6 +410,9 @@ export async function executeOrganize({
|
|
|
358
410
|
sourcePath: resolvedSource,
|
|
359
411
|
targetPath: resolvedTarget,
|
|
360
412
|
operation,
|
|
413
|
+
deleteEmptyFolders: Boolean(deleteEmptyFolders),
|
|
414
|
+
deletedDirsCount: deletedDirs.length,
|
|
415
|
+
deletedDirs,
|
|
361
416
|
totalFiles: files.length,
|
|
362
417
|
successCount,
|
|
363
418
|
failCount,
|
|
@@ -414,6 +469,17 @@ export async function undoSession(sessionId) {
|
|
|
414
469
|
}
|
|
415
470
|
}
|
|
416
471
|
|
|
472
|
+
// Re-create deleted empty directories if any
|
|
473
|
+
if (Array.isArray(session.deletedDirs)) {
|
|
474
|
+
for (const dir of session.deletedDirs) {
|
|
475
|
+
try {
|
|
476
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
477
|
+
} catch (err) {
|
|
478
|
+
console.error(`Failed to recreate directory ${dir}:`, err);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
417
483
|
session.status = 'reverted';
|
|
418
484
|
session.revertedAt = new Date().toISOString();
|
|
419
485
|
session.revertSuccessCount = revertSuccessCount;
|