buddy-workbench 0.1.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.
@@ -0,0 +1,378 @@
1
+ import { Router } from 'express';
2
+ import { readSettings } from '../repositories/settings.js';
3
+
4
+ const router = Router();
5
+ const previewLimit = 2000;
6
+ let lastUsedHost = '';
7
+
8
+ function getBitbucketHost() {
9
+ if (lastUsedHost) return lastUsedHost;
10
+ const settings = readSettings();
11
+ const domain = settings.domain || '';
12
+ if (!domain) return '';
13
+ if (domain.includes('bitbucket')) return domain;
14
+ return `bitbucket.${domain}`;
15
+ }
16
+
17
+ function parseBitbucketPrUrl(url) {
18
+ const match = url.match(/https?:\/\/([^/]+)\/projects\/([^/]+)\/repos\/([^/]+)\/pull-requests\/(\d+)/i);
19
+ if (match) {
20
+ return {
21
+ host: match[1],
22
+ projectKey: match[2],
23
+ repositorySlug: match[3],
24
+ pullRequestId: match[4]
25
+ };
26
+ }
27
+ const userMatch = url.match(/https?:\/\/([^/]+)\/users\/([^/]+)\/repos\/([^/]+)\/pull-requests\/(\d+)/i);
28
+ if (userMatch) {
29
+ return {
30
+ host: userMatch[1],
31
+ projectKey: `~${userMatch[2]}`,
32
+ repositorySlug: userMatch[3],
33
+ pullRequestId: userMatch[4]
34
+ };
35
+ }
36
+ return null;
37
+ }
38
+
39
+ const isFiltered = (filePath) => {
40
+ const name = filePath.split('/').pop();
41
+ if (name === 'bd.json' || name === 'package-lock.json') return true;
42
+ if (/\.spec\.[^.]+$/.test(name) || /\.test\.[^.]+$/.test(name)) return true;
43
+ return false;
44
+ };
45
+
46
+ const analyzeDiff = (filePath, hunks) => {
47
+ const issues = [];
48
+ for (const hunk of hunks || []) {
49
+ for (const segment of hunk.segments || []) {
50
+ if (segment.type !== 'ADDED') continue;
51
+ for (const line of segment.lines || []) {
52
+ const text = line.text || '';
53
+ const lineNum = line.destinationLine;
54
+
55
+ // Rule 1: No debugger
56
+ if (/\bdebugger\b/.test(text)) {
57
+ issues.push({
58
+ severity: 'critical',
59
+ rule: 'No Debugger',
60
+ message: 'Avoid leaving `debugger;` statements in production code.',
61
+ line: lineNum,
62
+ code: text
63
+ });
64
+ }
65
+
66
+ // Rule 2: Conflict Markers
67
+ if (/^<<<<<<<|^=======|^>>>>>>>/.test(text)) {
68
+ issues.push({
69
+ severity: 'critical',
70
+ rule: 'Conflict Markers',
71
+ message: 'Unresolved git conflict markers found.',
72
+ line: lineNum,
73
+ code: text
74
+ });
75
+ }
76
+
77
+ // Rule 3: Focused tests
78
+ if (/\.(only|skip)\(/.test(text) && /\.(test|spec)\./i.test(filePath)) {
79
+ issues.push({
80
+ severity: 'critical',
81
+ rule: 'Focused/Skipped Test',
82
+ message: 'Do not commit focused (`.only`) or skipped (`.skip`) tests.',
83
+ line: lineNum,
84
+ code: text
85
+ });
86
+ }
87
+
88
+ // Rule 4: Secrets
89
+ if (/(password|passwd|secret|token|api_key|apikey|private_key|auth_token)\s*[:=]\s*['"`][a-zA-Z0-9_\-+=/]{16,}['"`]/i.test(text)) {
90
+ if (!text.trim().startsWith('//') && !text.includes('placeholder') && !text.includes('dummy')) {
91
+ issues.push({
92
+ severity: 'critical',
93
+ rule: 'Hardcoded Secret',
94
+ message: 'Potential hardcoded token, password, or key detected.',
95
+ line: lineNum,
96
+ code: text
97
+ });
98
+ }
99
+ }
100
+
101
+ // Rule 5: Console log
102
+ if (/\bconsole\.log\(/.test(text)) {
103
+ issues.push({
104
+ severity: 'warning',
105
+ rule: 'No Console Log',
106
+ message: 'Consider removing `console.log` statements before merging.',
107
+ line: lineNum,
108
+ code: text
109
+ });
110
+ }
111
+
112
+ // Rule 6: TODO/FIXME
113
+ if (/\b(TODO|FIXME)\b/i.test(text)) {
114
+ issues.push({
115
+ severity: 'info',
116
+ rule: 'Pending Task',
117
+ message: 'Track open TODO or FIXME items.',
118
+ line: lineNum,
119
+ code: text
120
+ });
121
+ }
122
+
123
+ // Rule 7: IP Addresses
124
+ const ipMatch = text.match(/\b(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.\d{1,3}\.\d{1,3})\b/);
125
+ if (ipMatch) {
126
+ issues.push({
127
+ severity: 'warning',
128
+ rule: 'Hardcoded IP Address',
129
+ message: `Avoid hardcoding internal/private IP addresses (${ipMatch[0]}).`,
130
+ line: lineNum,
131
+ code: text
132
+ });
133
+ }
134
+
135
+ // Rule 8: Hardcoded user-facing text (Chinese / Non-ascii characters)
136
+ if (/[\u4e00-\u9fa5]+/.test(text)) {
137
+ if (!text.trim().startsWith('//') && !text.trim().startsWith('*')) {
138
+ issues.push({
139
+ severity: 'warning',
140
+ rule: 'Hardcoded Text',
141
+ message: 'Avoid hardcoding user-facing text strings directly; consider using internationalization (i18n).',
142
+ line: lineNum,
143
+ code: text
144
+ });
145
+ }
146
+ }
147
+
148
+ // Rule 9: Deep object access without optional chaining (a.b.c)
149
+ const deepAccessRegex = /\b(?!(?:process\.env|e\.target|event\.target|res\.data|response\.data)\b)[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*\.[a-zA-Z_$][\w$]*\b/;
150
+ if (deepAccessRegex.test(text)) {
151
+ issues.push({
152
+ severity: 'warning',
153
+ rule: 'Deep Object Access',
154
+ message: 'Deep object property access (depth > 1) can cause runtime crashes if intermediate properties are nullish. Consider using optional chaining (?.).',
155
+ line: lineNum,
156
+ code: text
157
+ });
158
+ }
159
+
160
+ // Rule 10: useMemo/useCallback with empty dependency array []
161
+ if (/\buse(Memo|Callback)\b.*,\s*\[\s*\]/.test(text)) {
162
+ issues.push({
163
+ severity: 'warning',
164
+ rule: 'Static Hook Dependency',
165
+ message: 'useMemo/useCallback has an empty dependency array []. Consider declaring the function or value outside the component if it has no dynamic scope dependencies.',
166
+ line: lineNum,
167
+ code: text
168
+ });
169
+ }
170
+
171
+ // Rule 11: variant="primary" (often a Bootstrap/MUI style leak instead of Button type="primary")
172
+ if (/\bvariant\s*=\s*['"`]primary['"`]/i.test(text)) {
173
+ issues.push({
174
+ severity: 'warning',
175
+ rule: 'Incorrect Button Variant',
176
+ message: '`variant="primary"` is the default variant.',
177
+ line: lineNum,
178
+ code: text
179
+ });
180
+ }
181
+ }
182
+ }
183
+ }
184
+ return issues;
185
+ };
186
+
187
+ router.post('/check', async (req, res) => {
188
+ const { prLink } = req.body || {};
189
+ if (!prLink) return res.status(400).json({ error: 'PR Link is required.' });
190
+
191
+ const parsed = parseBitbucketPrUrl(prLink);
192
+ if (!parsed) return res.status(400).json({ error: 'Invalid Bitbucket Pull Request URL.' });
193
+
194
+ const { host, projectKey, repositorySlug, pullRequestId } = parsed;
195
+ lastUsedHost = host;
196
+ const settings = readSettings();
197
+ const token = settings.bitbucketAccessToken;
198
+
199
+ const headers = { 'Accept': 'application/json' };
200
+ if (token) headers['Authorization'] = `Bearer ${token}`;
201
+
202
+ try {
203
+ // 1. Fetch Pull Request details to show basic info
204
+ const prUrl = `https://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${pullRequestId}`;
205
+ const prRes = await fetch(prUrl, { headers });
206
+ if (!prRes.ok) {
207
+ if (prRes.status === 401) {
208
+ return res.status(401).json({ error: 'Unauthorized. Please check your Bitbucket Access Token in Settings.' });
209
+ }
210
+ return res.status(prRes.status).json({ error: `Failed to fetch PR info: ${prRes.statusText}` });
211
+ }
212
+ const prInfo = await prRes.json();
213
+
214
+ // 2. Fetch changes
215
+ const changesUrl = `https://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${pullRequestId}/changes?limit=1000`;
216
+ const changesRes = await fetch(changesUrl, { headers });
217
+ if (!changesRes.ok) {
218
+ return res.status(changesRes.status).json({ error: `Failed to fetch PR changes: ${changesRes.statusText}` });
219
+ }
220
+ const changesData = await changesRes.json();
221
+
222
+ const allChanges = changesData.values || [];
223
+ const filteredOut = [];
224
+ const targetChanges = [];
225
+
226
+ for (const change of allChanges) {
227
+ const filePath = change.path.toString;
228
+ if (isFiltered(filePath)) {
229
+ filteredOut.push(filePath);
230
+ } else {
231
+ targetChanges.push(change);
232
+ }
233
+ }
234
+
235
+ // 3. Fetch diffs and run rule-based checks
236
+ const reviewFiles = [];
237
+ let totalIssues = 0;
238
+ let criticalCount = 0;
239
+ let warningCount = 0;
240
+ let infoCount = 0;
241
+
242
+ for (const change of targetChanges) {
243
+ const filePath = change.path.toString;
244
+ const diffUrl = `https://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${pullRequestId}/diff/${encodeURIComponent(filePath)}?context=0`;
245
+ const diffRes = await fetch(diffUrl, { headers });
246
+ if (!diffRes.ok) continue; // skip file if diff cannot be retrieved
247
+
248
+ const diffData = await diffRes.json();
249
+ const fileIssues = analyzeDiff(filePath, diffData.hunks);
250
+
251
+ if (fileIssues.length > 0) {
252
+ reviewFiles.push({
253
+ filePath,
254
+ type: change.type,
255
+ issues: fileIssues
256
+ });
257
+ totalIssues += fileIssues.length;
258
+ criticalCount += fileIssues.filter(i => i.severity === 'critical').length;
259
+ warningCount += fileIssues.filter(i => i.severity === 'warning').length;
260
+ infoCount += fileIssues.filter(i => i.severity === 'info').length;
261
+ }
262
+ }
263
+
264
+ res.json({
265
+ pr: {
266
+ title: prInfo.title,
267
+ description: prInfo.description,
268
+ author: prInfo.author?.user?.displayName || 'Unknown',
269
+ sourceBranch: prInfo.fromRef?.displayId || 'Unknown',
270
+ targetBranch: prInfo.toRef?.displayId || 'Unknown',
271
+ projectKey,
272
+ repositorySlug,
273
+ pullRequestId
274
+ },
275
+ stats: {
276
+ totalFilesChanged: allChanges.length,
277
+ filteredCount: filteredOut.length,
278
+ reviewedCount: targetChanges.length,
279
+ totalIssues,
280
+ criticalCount,
281
+ warningCount,
282
+ infoCount
283
+ },
284
+ filteredOut,
285
+ files: reviewFiles
286
+ });
287
+
288
+ } catch (error) {
289
+ res.status(500).json({ error: `Internal error: ${error.message}` });
290
+ }
291
+ });
292
+
293
+ function enrichPrList(values, host) {
294
+ return values.map(pr => {
295
+ let href = '';
296
+ if (Array.isArray(pr.links?.self)) {
297
+ href = pr.links.self[0]?.href || '';
298
+ } else if (pr.links?.self?.href) {
299
+ href = pr.links.self.href;
300
+ }
301
+ if (!href && pr.toRef?.repository?.project?.key && pr.toRef?.repository?.slug && pr.id) {
302
+ const proj = pr.toRef.repository.project.key;
303
+ const repo = pr.toRef.repository.slug;
304
+ const isPersonal = proj.startsWith('~');
305
+ const projectPath = isPersonal ? `users/${proj.slice(1)}` : `projects/${proj}`;
306
+ href = `https://${host}/${projectPath}/repos/${repo}/pull-requests/${pr.id}`;
307
+ }
308
+ return {
309
+ ...pr,
310
+ reviewUrl: href
311
+ };
312
+ });
313
+ }
314
+
315
+ router.get('/my-prs', async (req, res) => {
316
+ const host = getBitbucketHost();
317
+ if (!host) return res.json({ values: [] });
318
+
319
+ const settings = readSettings();
320
+ const token = settings.bitbucketAccessToken;
321
+ const headers = { 'Accept': 'application/json' };
322
+ if (token) headers['Authorization'] = `Bearer ${token}`;
323
+
324
+ try {
325
+ const url = `https://${host}/rest/api/1.0/dashboard/pull-requests?role=author&state=OPEN&limit=100`;
326
+ const response = await fetch(url, { headers });
327
+ if (!response.ok) {
328
+ if (response.status === 401) {
329
+ return res.status(401).json({ error: 'Unauthorized. Please check your Bitbucket Token.' });
330
+ }
331
+ return res.status(response.status).json({ error: `Bitbucket API error: ${response.statusText}` });
332
+ }
333
+ const data = await response.json();
334
+ const values = data.values || [];
335
+
336
+ const sorted = values.sort((a, b) => {
337
+ const aDraft = a.draft === true;
338
+ const bDraft = b.draft === true;
339
+ if (aDraft !== bDraft) return aDraft ? 1 : -1;
340
+ return (b.updatedDate || 0) - (a.updatedDate || 0);
341
+ });
342
+
343
+ res.json({ values: enrichPrList(sorted, host) });
344
+ } catch (error) {
345
+ res.status(500).json({ error: error.message });
346
+ }
347
+ });
348
+
349
+ router.get('/review-prs', async (req, res) => {
350
+ const host = getBitbucketHost();
351
+ if (!host) return res.json({ values: [] });
352
+
353
+ const settings = readSettings();
354
+ const token = settings.bitbucketAccessToken;
355
+ const headers = { 'Accept': 'application/json' };
356
+ if (token) headers['Authorization'] = `Bearer ${token}`;
357
+
358
+ try {
359
+ const url = `https://${host}/rest/api/1.0/dashboard/pull-requests?role=reviewer&state=OPEN&limit=100`;
360
+ const response = await fetch(url, { headers });
361
+ if (!response.ok) {
362
+ if (response.status === 401) {
363
+ return res.status(401).json({ error: 'Unauthorized. Please check your Bitbucket Token.' });
364
+ }
365
+ return res.status(response.status).json({ error: `Bitbucket API error: ${response.statusText}` });
366
+ }
367
+ const data = await response.json();
368
+ const values = data.values || [];
369
+
370
+ const sorted = values.sort((a, b) => (b.updatedDate || 0) - (a.updatedDate || 0));
371
+
372
+ res.json({ values: enrichPrList(sorted, host) });
373
+ } catch (error) {
374
+ res.status(500).json({ error: error.message });
375
+ }
376
+ });
377
+
378
+ export default router;
@@ -0,0 +1,18 @@
1
+ import { Router } from 'express';
2
+ import { saveAccessToken, saveDomain, settingsStatus } from '../repositories/settings.js';
3
+
4
+ const router = Router();
5
+
6
+ router.get('/', (_req, res) => res.json(settingsStatus()));
7
+ router.put('/domain', (req, res) => {
8
+ const { domain } = req.body || {};
9
+ if (typeof domain !== 'string') return res.status(400).json({ error: 'Domain must be a string.' });
10
+ res.json(saveDomain(domain.trim()));
11
+ });
12
+ router.put('/:service-access-token', (req, res) => {
13
+ const { token } = req.body || {};
14
+ if (typeof token !== 'string') return res.status(400).json({ error: 'Token must be a string.' });
15
+ try { res.json(saveAccessToken(req.params.service, token.trim())); } catch (error) { res.status(400).json({ error: error.message }); }
16
+ });
17
+
18
+ export default router;
@@ -0,0 +1,85 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { paths } from '../config.js';
6
+
7
+ const execFileAsync = promisify(execFile);
8
+ const previewLimit = 2000;
9
+ let lastValue = '';
10
+
11
+ function today() { return new Intl.DateTimeFormat('en-CA').format(new Date()); }
12
+ function dayFile(date) { const [year, month, day] = date.split('-'); return join(paths.clipboardDir, year, month, `${day}.json`); }
13
+ function readJson(file, fallback) { try { return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : fallback; } catch { return fallback; } }
14
+ function writeJson(file, data) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(data, null, 2)); }
15
+
16
+ export function clipboardDates() {
17
+ if (!existsSync(paths.clipboardDir)) return [];
18
+ return readdirSync(paths.clipboardDir, { withFileTypes: true }).filter((year) => year.isDirectory() && /^\d{4}$/.test(year.name)).flatMap((year) => readdirSync(join(paths.clipboardDir, year.name), { withFileTypes: true }).filter((month) => month.isDirectory() && /^\d{2}$/.test(month.name)).flatMap((month) => readdirSync(join(paths.clipboardDir, year.name, month.name), { withFileTypes: true }).filter((day) => day.isFile() && /^\d{2}\.json$/.test(day.name)).map((day) => `${year.name}-${month.name}-${day.name.slice(0, 2)}`))).sort().reverse();
19
+ }
20
+
21
+ function dayItems(date) { return readJson(dayFile(date), []); }
22
+ export function clipboardItems(date = today()) { return dayItems(date).map((item) => item.contentFile ? { ...item, editorUrl: `vscode://file${encodeURI(join(paths.clipboardDir, 'content', item.contentFile))}` } : item); }
23
+ export function clipboardOriginal(date, id) { const item = dayItems(date).find((entry) => entry.id === id); if (!item?.contentFile) return item?.text || null; try { return readFileSync(join(paths.clipboardDir, 'content', item.contentFile), 'utf8'); } catch { return null; } }
24
+ export function deleteClipboardItem(date, id) {
25
+ const items = dayItems(date); const item = items.find((entry) => entry.id === id);
26
+ if (!item) return false;
27
+ writeJson(dayFile(date), items.filter((entry) => entry.id !== id));
28
+ if (item.contentFile) {
29
+ const contentPath = join(paths.clipboardDir, 'content', item.contentFile);
30
+ if (existsSync(contentPath)) unlinkSync(contentPath);
31
+ }
32
+ return true;
33
+ }
34
+
35
+ export async function captureClipboard() {
36
+ if (process.platform !== 'darwin') return;
37
+ try {
38
+ const { stdout } = await execFileAsync('pbpaste'); const text = stdout.trim();
39
+ if (!text || text === lastValue) return;
40
+ lastValue = text;
41
+
42
+ const now = Date.now();
43
+ const limit = now - 60 * 60 * 1000;
44
+ const date = today();
45
+ const items = dayItems(date);
46
+
47
+ let recentItems = [...items];
48
+ const yesterdayDate = new Intl.DateTimeFormat('en-CA').format(new Date(now - 24 * 60 * 60 * 1000));
49
+ if (date !== yesterdayDate) {
50
+ recentItems = [...recentItems, ...dayItems(yesterdayDate)];
51
+ }
52
+
53
+ const oneHourItems = recentItems.filter(item => {
54
+ const itemTime = new Date(item.createdAt).getTime();
55
+ return itemTime >= limit;
56
+ });
57
+
58
+ const alreadyExists = oneHourItems.some(item => {
59
+ if (item.text !== undefined) {
60
+ return item.text === text;
61
+ }
62
+ if (item.contentFile) {
63
+ const prefix = item.preview.slice(0, -1);
64
+ if (text.startsWith(prefix)) {
65
+ try {
66
+ const fullText = readFileSync(join(paths.clipboardDir, 'content', item.contentFile), 'utf8');
67
+ return fullText === text;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+ }
73
+ return false;
74
+ });
75
+
76
+ if (alreadyExists) return;
77
+
78
+ const id = crypto.randomUUID(); const isLong = text.length > previewLimit;
79
+ if (isLong) { mkdirSync(join(paths.clipboardDir, 'content'), { recursive: true }); writeFileSync(join(paths.clipboardDir, 'content', `${id}.txt`), text, 'utf8'); }
80
+ const item = isLong ? { id, preview: `${text.slice(0, previewLimit)}…`, contentFile: `${id}.txt`, createdAt: new Date().toISOString() } : { id, text, createdAt: new Date().toISOString() };
81
+ writeJson(dayFile(date), [item, ...items].slice(0, 200));
82
+ } catch {}
83
+ }
84
+
85
+ export function startClipboardCapture() { captureClipboard(); const timer = setInterval(captureClipboard, 1000); timer.unref(); }
@@ -0,0 +1,14 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+
4
+ const execFileAsync = promisify(execFile);
5
+
6
+ export async function currentGitBranch(folder) {
7
+ if (!folder) return null;
8
+ try {
9
+ const { stdout } = await execFileAsync('git', ['branch', '--show-current'], { cwd: folder, timeout: 1500 });
10
+ return stdout.trim() || null;
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
@@ -0,0 +1,11 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ export function readPackageScripts(folder) {
5
+ const packageFile = join(folder, 'package.json');
6
+ if (!existsSync(packageFile)) return [];
7
+ try {
8
+ const { scripts = {} } = JSON.parse(readFileSync(packageFile, 'utf8'));
9
+ return Object.entries(scripts).map(([name, command]) => ({ id: `package:${name}`, name, command: String(command) }));
10
+ } catch { return []; }
11
+ }
@@ -0,0 +1,15 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { paths } from '../config.js';
4
+
5
+ export function listPlugins() {
6
+ if (!existsSync(paths.plugins)) return [];
7
+ return readdirSync(paths.plugins, { withFileTypes: true }).filter((entry) => entry.isDirectory()).flatMap((entry) => {
8
+ const manifest = join(paths.plugins, entry.name, 'plugin.json');
9
+ if (!existsSync(manifest)) return [];
10
+ try {
11
+ const plugin = JSON.parse(readFileSync(manifest, 'utf8'));
12
+ return [{ id: entry.name, name: plugin.name || entry.name, icon: plugin.icon || '▦', view: plugin.view ? `/plugins/${encodeURIComponent(entry.name)}/${plugin.view}` : '' }];
13
+ } catch { return []; }
14
+ });
15
+ }