buddy-workbench 0.1.72 → 0.1.74

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,5 +59,8 @@
59
59
  "@jsquash/jpeg": "1.6.0",
60
60
  "axios": "1.7.9",
61
61
  "express": "5.1.0"
62
+ },
63
+ "overrides": {
64
+ "content-type": "2.0.0"
62
65
  }
63
66
  }
@@ -3,8 +3,6 @@ import axios from 'axios';
3
3
  import { Router } from 'express';
4
4
  import { readSettings } from '../repositories/settings.js';
5
5
  import { listTodos } from '../repositories/todos.js';
6
- import { listBranchSyncApps } from '../repositories/branch-sync.js';
7
- import { getPresentations } from '../repositories/presentations.js';
8
6
  import { recordApiError } from '../lib/api-errors.js';
9
7
 
10
8
  const router = Router();
@@ -16,16 +14,6 @@ const httpClient = axios.create({
16
14
 
17
15
  const isSuccess = (response) => response.status >= 200 && response.status < 300;
18
16
  const apiMessage = (response, fallback) => response?.data?.errors?.[0]?.message || response?.data?.errorMessages?.[0] || response?.data?.message || response?.statusText || fallback;
19
- const getPresentationInsights = () => getPresentations().plans
20
- .slice()
21
- .sort((a, b) => Number(b.updatedAt || b.createdAt || 0) - Number(a.updatedAt || a.createdAt || 0))
22
- .map((plan) => ({
23
- id: plan.id,
24
- title: plan.title || plan.name || 'Untitled presentation',
25
- summary: `${Array.isArray(plan.sections) ? plan.sections.length : 0} sections`,
26
- updated: plan.updatedAt || plan.createdAt || null,
27
- url: '#/presentation-plan'
28
- }));
29
17
 
30
18
  function jiraHost(domain) {
31
19
  const clean = String(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, '');
@@ -39,20 +27,7 @@ function bitbucketHost(domain) {
39
27
  return clean.includes('bitbucket') ? clean : `bitbucket.${clean}`;
40
28
  }
41
29
 
42
- function parseRepo(repo) {
43
- const raw = String(repo || '').replace(/\.git$/i, '');
44
- try {
45
- const url = new URL(raw.startsWith('ssh://') ? raw.replace(/^ssh:\/\//i, 'https://') : raw);
46
- const parts = url.pathname.split('/').filter(Boolean);
47
- if (parts[0] === 'scm' && parts.length >= 3) return { host: url.host, project: parts[1], slug: parts[2] };
48
- if ((parts[0] === 'projects' || parts[0] === 'users') && parts.length >= 4) return { host: url.host, project: parts[0] === 'users' ? `~${parts[1]}` : parts[1], slug: parts[3] };
49
- if (parts.length >= 2) return { host: url.host, project: parts.at(-2), slug: parts.at(-1) };
50
- } catch {}
51
- return null;
52
- }
53
-
54
30
  function jiraUrl(host, path) { return `https://${host}/rest/api/2${path}`; }
55
- function bitbucketUrl(host, path) { return `https://${host}/rest/api/latest${path}`; }
56
31
 
57
32
  async function requestSection(name, loader) {
58
33
  try {
@@ -95,10 +70,9 @@ async function loadJiraData(host, token) {
95
70
  const identity = { accountId: me.accountId || '', name: me.name || '', displayName: me.displayName || '' };
96
71
  const fields = 'summary,status,priority,duedate,updated,assignee,reporter';
97
72
 
98
- const [due, activity, blocked] = await Promise.all([
73
+ const [due, activity] = await Promise.all([
99
74
  searchJira(host, token, 'assignee = currentUser() AND due = startOfDay() AND resolution = Unresolved ORDER BY priority DESC', fields),
100
- searchJira(host, token, 'updated >= -14d AND resolution = Unresolved AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC', fields, 30),
101
- searchJira(host, token, 'status = Blocked AND updated <= -3d AND resolution = Unresolved ORDER BY updated ASC', fields)
75
+ searchJira(host, token, 'updated >= -14d AND resolution = Unresolved AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC', fields, 30)
102
76
  ]);
103
77
 
104
78
  const activityItems = [];
@@ -130,15 +104,14 @@ async function loadJiraData(host, token) {
130
104
  return {
131
105
  identity,
132
106
  dueToday: due.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) })),
133
- activity: activityItems.map((item) => ({ ...item, url: issueUrl(host, item.key) })),
134
- blocked: blocked.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) }))
107
+ activity: activityItems.map((item) => ({ ...item, url: issueUrl(host, item.key) }))
135
108
  };
136
109
  }
137
110
 
138
- async function loadPullRequests(host, token, role) {
139
- const url = `https://${host}/rest/api/latest/dashboard/pull-requests?role=${role}&state=OPEN&limit=100`;
111
+ async function loadAuthoredPullRequests(host, token) {
112
+ const url = `https://${host}/rest/api/latest/dashboard/pull-requests?role=author&state=OPEN&limit=100`;
140
113
  const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
141
- if (!isSuccess(response)) throw new Error(`Bitbucket ${role} pull requests failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
114
+ if (!isSuccess(response)) throw new Error(`Bitbucket author pull requests failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
142
115
  return (response.data?.values || []).map((pr) => ({
143
116
  id: pr.id,
144
117
  title: pr.title || `Pull request #${pr.id}`,
@@ -149,65 +122,21 @@ async function loadPullRequests(host, token, role) {
149
122
  }));
150
123
  }
151
124
 
152
- async function loadFailedBuilds(host, token) {
153
- const settings = readSettings();
154
- const apps = listBranchSyncApps();
155
- const repos = [];
156
- for (const app of apps) {
157
- for (const branch of [...new Set([app.activeBranch, app.lastReleaseBranch, 'master'].filter(Boolean))]) {
158
- repos.push({ name: app.name, repo: app.repo, branch });
159
- }
160
- for (const sub of app.subApps || []) {
161
- for (const branch of [...new Set([sub.branch, app.activeBranch, 'master'].filter(Boolean))]) {
162
- repos.push({ name: sub.name || sub.directory, repo: sub.repo, branch });
163
- }
164
- }
165
- }
166
- const unique = [...new Map(repos.map((item) => [`${item.repo}|${item.branch}`, item])).values()];
167
- const failed = [];
168
- for (const item of unique.slice(0, 30)) {
169
- const parsed = parseRepo(item.repo);
170
- if (!parsed || (parsed.host && parsed.host !== host && !parsed.host.includes('bitbucket'))) continue;
171
- const commitUrl = bitbucketUrl(parsed.host || host, `/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/commits?until=${encodeURIComponent(item.branch)}&limit=1`);
172
- const commitResponse = await httpClient.get(commitUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
173
- if (!isSuccess(commitResponse)) {
174
- recordApiError({ source: 'Overview (Bitbucket Builds)', method: 'GET', url: commitUrl, status: commitResponse.status, message: apiMessage(commitResponse, 'Failed to fetch branch commit.') });
175
- continue;
176
- }
177
- const hash = commitResponse.data?.values?.[0]?.id;
178
- if (!hash) continue;
179
- const buildUrl = `https://${parsed.host || host}/rest/api/latest/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/commits/${encodeURIComponent(hash)}/builds`;
180
- const buildResponse = await httpClient.get(buildUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
181
- if (!isSuccess(buildResponse)) {
182
- if (buildResponse.status !== 404) recordApiError({ source: 'Overview (Bitbucket Builds)', method: 'GET', url: buildUrl, status: buildResponse.status, message: apiMessage(buildResponse, 'Failed to fetch build status.') });
183
- continue;
184
- }
185
- for (const build of buildResponse.data?.values || []) {
186
- if (String(build.state || '').toUpperCase() === 'FAILED') {
187
- failed.push({ name: item.name, branch: item.branch, repository: item.repo, build: build.name || build.key || 'Build', date: build.dateAdded || null, url: item.repo });
188
- }
189
- }
190
- }
191
- return failed;
192
- }
193
-
194
125
  router.get('/', async (_req, res) => {
195
126
  const settings = readSettings();
196
- const missingTokens = ['jira', 'bitbucket', 'confluence'].filter((service) => !settings[`${service}AccessToken`]);
127
+ const missingTokens = ['jira', 'bitbucket'].filter((service) => !settings[`${service}AccessToken`]);
197
128
  if (missingTokens.length > 0) {
198
- return res.json({ configured: false, missingTokens, fetchedAt: new Date().toISOString(), categories: { presentations: getPresentationInsights() } });
129
+ return res.json({ configured: false, missingTokens, fetchedAt: new Date().toISOString(), categories: {} });
199
130
  }
200
131
  const jira = jiraHost(settings.domain);
201
132
  const bitbucket = bitbucketHost(settings.domain);
202
- if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: { presentations: getPresentationInsights() } });
133
+ if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: {} });
203
134
 
204
135
  const sections = await Promise.all([
205
136
  requestSection('Jira', () => loadJiraData(jira, settings.jiraAccessToken)),
206
- requestSection('Bitbucket Review', () => loadPullRequests(bitbucket, settings.bitbucketAccessToken, 'reviewer')),
207
- requestSection('Bitbucket Author', () => loadPullRequests(bitbucket, settings.bitbucketAccessToken, 'author')),
208
- requestSection('Bitbucket Builds', () => loadFailedBuilds(bitbucket, settings.bitbucketAccessToken))
137
+ requestSection('Bitbucket Author', () => loadAuthoredPullRequests(bitbucket, settings.bitbucketAccessToken))
209
138
  ]);
210
- const [jiraData, reviewPrs, authoredPrs, failedBuilds] = sections;
139
+ const [jiraData, authoredPrs] = sections;
211
140
  const todos = listTodos().filter((todo) => !todo.completed && !todo.archived).map((todo) => ({ ...todo, url: '#/todo-list' }));
212
141
  const errors = sections.filter((section) => section.error).map((section) => section.error);
213
142
  res.json({
@@ -215,14 +144,10 @@ router.get('/', async (_req, res) => {
215
144
  missingTokens: [],
216
145
  fetchedAt: new Date().toISOString(),
217
146
  categories: {
147
+ activity: jiraData.items?.activity || [],
218
148
  jiraDueToday: jiraData.items?.dueToday || [],
219
149
  assignedTodos: todos,
220
- reviewPrs: reviewPrs.items || [],
221
- authoredPrs: authoredPrs.items || [],
222
- failedBuilds: failedBuilds.items || [],
223
- activity: jiraData.items?.activity || [],
224
- blockedIssues: jiraData.items?.blocked || [],
225
- presentations: getPresentationInsights()
150
+ authoredPrs: authoredPrs.items || []
226
151
  },
227
152
  errors
228
153
  });
@@ -87,9 +87,9 @@ async function findWorkspaceRepository(parsed) {
87
87
 
88
88
  for (const folder of candidates) {
89
89
  const identity = remoteIdentity(await getGitRemoteUrl(folder));
90
- if (identity.projectKey === parsed.projectKey && identity.repositorySlug === parsed.repositorySlug) return folder;
90
+ if (identity.repositorySlug && parsed.repositorySlug && identity.repositorySlug.toLowerCase() === parsed.repositorySlug.toLowerCase()) return folder;
91
91
  }
92
- throw new Error(`Could not find ${parsed.projectKey}/${parsed.repositorySlug} in workspace: ${workspaceDir}`);
92
+ throw new Error(`Could not find ${parsed.repositorySlug} in workspace: ${workspaceDir}`);
93
93
  }
94
94
 
95
95
  function validateRelativeFilePath(filePath) {
@@ -188,6 +188,74 @@ router.get('/my-conflicts', async (_req, res) => {
188
188
  }
189
189
  });
190
190
 
191
+ router.post('/check-branch', async (req, res) => {
192
+ const parsed = parsePrUrl(req.body?.prUrl);
193
+ const branchName = typeof req.body?.branchName === 'string' ? req.body.branchName.trim() : '';
194
+ if (!branchName) return res.json({ exists: false });
195
+ if (!parsed) return res.status(400).json({ error: 'Invalid Pull Request URL.' });
196
+
197
+ // 1. Validate ref format
198
+ if (/[\s~^:?*\[\\]|\.\.|\/\.|\/\/|@\{|^\/|\/$|\.lock$/i.test(branchName)) {
199
+ return res.json({ exists: true, error: `Invalid branch name format: ${branchName}` });
200
+ }
201
+
202
+ // 2. Try checking local repo if workspace exists
203
+ try {
204
+ const folder = await findWorkspaceRepository(parsed);
205
+ try {
206
+ await git(folder, ['check-ref-format', '--branch', branchName]);
207
+ } catch {
208
+ return res.json({ exists: true, error: `Invalid branch name: ${branchName}` });
209
+ }
210
+
211
+ try {
212
+ await git(folder, ['rev-parse', '--verify', `refs/heads/${branchName}`]);
213
+ return res.json({ exists: true, error: `Local branch already exists: ${branchName}` });
214
+ } catch {}
215
+
216
+ try {
217
+ await git(folder, ['ls-remote', '--exit-code', '--heads', 'origin', branchName]);
218
+ return res.json({ exists: true, error: `Remote branch already exists on origin: ${branchName}` });
219
+ } catch {}
220
+ } catch {
221
+ // If local repo cannot be located, fallback to Bitbucket REST API
222
+ try {
223
+ const branchesUrl = `https://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.projectKey)}/repos/${encodeURIComponent(parsed.repositorySlug)}/branches?filterText=${encodeURIComponent(branchName)}&limit=10`;
224
+ const response = await client.get(branchesUrl, { headers: authHeaders() });
225
+ if (response.status >= 200 && response.status < 300) {
226
+ const values = Array.isArray(response.data?.values) ? response.data.values : [];
227
+ const found = values.find((b) => (b.displayId || b.id?.replace('refs/heads/', '')) === branchName);
228
+ if (found) {
229
+ return res.json({ exists: true, error: `Remote branch already exists on origin: ${branchName}` });
230
+ }
231
+ }
232
+ } catch {}
233
+ }
234
+
235
+ return res.json({ exists: false });
236
+ });
237
+
238
+ async function applyFileChoice(folder, filePath, choiceRef) {
239
+ try {
240
+ await git(folder, ['checkout', choiceRef, '--', filePath]);
241
+ await git(folder, ['add', '--', filePath]);
242
+ } catch {
243
+ const content = await readGitFile(folder, choiceRef, filePath);
244
+ const absolutePath = join(folder, filePath);
245
+ if (content === null) {
246
+ if (existsSync(absolutePath)) rmSync(absolutePath, { force: true, recursive: true });
247
+ try {
248
+ await git(folder, ['rm', '-f', '--', filePath]);
249
+ } catch {
250
+ await git(folder, ['add', '-A', '--', filePath]);
251
+ }
252
+ } else {
253
+ writeFileSync(absolutePath, content);
254
+ await git(folder, ['add', '--', filePath]);
255
+ }
256
+ }
257
+ }
258
+
191
259
  router.post('/resolve', async (req, res) => {
192
260
  const parsed = parsePrUrl(req.body?.prUrl);
193
261
  const files = Array.isArray(req.body?.files) ? req.body.files : [];
@@ -228,7 +296,6 @@ router.post('/resolve', async (req, res) => {
228
296
  const targetRef = `origin/${targetBranch}`;
229
297
  await git(folder, ['rev-parse', '--verify', sourceRef]);
230
298
  await git(folder, ['rev-parse', '--verify', targetRef]);
231
- await git(folder, ['checkout', '-B', sourceBranch, sourceRef]);
232
299
 
233
300
  let commitBranch = sourceBranch;
234
301
  if (createNewBranch) {
@@ -241,28 +308,48 @@ router.post('/resolve', async (req, res) => {
241
308
  await git(folder, ['ls-remote', '--exit-code', '--heads', 'origin', newBranchName]);
242
309
  return res.status(409).json({ error: `Remote branch already exists: ${newBranchName}` });
243
310
  } catch {}
244
- await git(folder, ['checkout', '-b', newBranchName]);
311
+
312
+ // Checkout from target branch (b), then create resolution branch
313
+ await git(folder, ['checkout', '-B', newBranchName, targetRef]);
245
314
  commitBranch = newBranchName;
315
+
316
+ // Merge source branch (a) into resolution branch
317
+ try {
318
+ await git(folder, ['merge', '--no-commit', '--no-ff', sourceRef]);
319
+ } catch {
320
+ // Merge conflicts are expected and will be resolved per file choices
321
+ }
322
+ } else {
323
+ // Direct resolution on source branch (a) by merging target branch (b)
324
+ await git(folder, ['checkout', '-B', sourceBranch, sourceRef]);
325
+ commitBranch = sourceBranch;
326
+
327
+ try {
328
+ await git(folder, ['merge', '--no-commit', '--no-ff', targetRef]);
329
+ } catch {
330
+ // Merge conflicts are expected and will be resolved per file choices
331
+ }
246
332
  }
247
333
 
248
334
  const resolvedPaths = [];
249
335
  for (const file of files) {
250
336
  const filePath = validateRelativeFilePath(file.path);
251
337
  const choiceRef = choices[file.path] === 'target' ? targetRef : sourceRef;
252
- const content = await readGitFile(folder, choiceRef, filePath);
253
- const absolutePath = join(folder, filePath);
254
- if (content === null) {
255
- if (choices[file.path] === 'target' && existsSync(absolutePath)) rmSync(absolutePath);
256
- else if (choices[file.path] === 'source') throw new Error(`File not found on source branch: ${filePath}`);
257
- } else {
258
- writeFileSync(absolutePath, content);
259
- }
338
+ await applyFileChoice(folder, filePath, choiceRef);
260
339
  resolvedPaths.push(filePath);
261
340
  }
262
341
 
263
- await git(folder, ['add', '--', ...resolvedPaths]);
264
- const staged = String(await git(folder, ['diff', '--cached', '--name-only'])).trim();
265
- if (!staged) return res.status(409).json({ error: 'No file changes to commit. The selected versions already match the source branch.' });
342
+ // Resolve any remaining unmerged paths that might not have been in the files array
343
+ const unmergedStr = String(await git(folder, ['diff', '--name-only', '--diff-filter=U'])).trim();
344
+ if (unmergedStr) {
345
+ const unmergedFiles = unmergedStr.split('\n').map((s) => s.trim()).filter(Boolean);
346
+ for (const unmergedFile of unmergedFiles) {
347
+ const choiceRef = choices[unmergedFile] === 'target' ? targetRef : sourceRef;
348
+ await applyFileChoice(folder, unmergedFile, choiceRef);
349
+ }
350
+ }
351
+
352
+ // Commit merge resolution
266
353
  await git(folder, ['commit', '-m', commitMessage]);
267
354
  await git(folder, ['push', 'origin', commitBranch]);
268
355
  const commitId = String(await git(folder, ['rev-parse', 'HEAD'])).trim();
@@ -272,7 +359,7 @@ router.post('/resolve', async (req, res) => {
272
359
  const createPrUrl = `https://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.projectKey)}/repos/${encodeURIComponent(parsed.repositorySlug)}/pull-requests`;
273
360
  const createPrResponse = await client.post(createPrUrl, {
274
361
  title: commitMessage,
275
- description: `Resolution Pull Request created from ${commitBranch}.\n\nThis branch contains the selected resolutions for Pull Request #${parsed.pullRequestId}.`,
362
+ description: `Resolution Pull Request created from ${commitBranch}.\n\nThis branch resolves conflicts for Pull Request #${parsed.pullRequestId} (${sourceBranch} → ${targetBranch}).`,
276
363
  fromRef: {
277
364
  id: `refs/heads/${commitBranch}`,
278
365
  repository: { slug: parsed.repositorySlug, project: { key: parsed.projectKey } }
@@ -300,10 +387,16 @@ router.post('/resolve', async (req, res) => {
300
387
  prUrl: resolutionPrUrl
301
388
  });
302
389
  } catch (error) {
303
- res.status(500).json({ error: `Unable to create resolution commit: ${error.stderr || error.message}` });
390
+ const errorMsg = (error.stderr ? String(error.stderr).trim() : '') || error.message || String(error);
391
+ res.status(500).json({ error: `Unable to create resolution commit: ${errorMsg}` });
304
392
  } finally {
305
393
  if (folder) {
306
394
  try {
395
+ if (existsSync(join(folder, '.git', 'MERGE_HEAD'))) {
396
+ await git(folder, ['merge', '--abort']).catch(() => {});
397
+ }
398
+ await git(folder, ['reset', '--hard', 'HEAD']).catch(() => {});
399
+ await git(folder, ['clean', '-fd']).catch(() => {});
307
400
  if (originalBranch) await git(folder, ['checkout', originalBranch]);
308
401
  else if (originalHead) await git(folder, ['checkout', '--detach', originalHead]);
309
402
  } catch {}