buddy-workbench 0.1.84 → 0.1.86

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.84",
3
+ "version": "0.1.86",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,10 @@ function webPullRequestUrl(host, pr) {
39
39
  return `https://${host}/${projectPath}/repos/${repository}/pull-requests/${pr.id}`;
40
40
  }
41
41
 
42
+ function pullRequestKey(project, repository, id) {
43
+ return `${project}/${repository}/${id}`;
44
+ }
45
+
42
46
  async function git(folder, args, options = {}) {
43
47
  const result = await execFileAsync('git', args, {
44
48
  cwd: folder,
@@ -49,6 +53,31 @@ async function git(folder, args, options = {}) {
49
53
  return result.stdout;
50
54
  }
51
55
 
56
+ async function detectConflictedPaths(folder, sourceRef, targetRef) {
57
+ try {
58
+ const output = await git(folder, [
59
+ 'merge-tree',
60
+ '--write-tree',
61
+ '--name-only',
62
+ '-z',
63
+ '--no-messages',
64
+ targetRef,
65
+ sourceRef
66
+ ]);
67
+ return String(output)
68
+ .split('\0')
69
+ .map((path) => path.trim())
70
+ .filter((path) => path && !/^[0-9a-f]{40}$/i.test(path));
71
+ } catch (error) {
72
+ const output = String(error.stdout || '');
73
+ if (!output) throw error;
74
+ return output
75
+ .split('\0')
76
+ .map((path) => path.trim())
77
+ .filter((path) => path && !/^[0-9a-f]{40}$/i.test(path));
78
+ }
79
+ }
80
+
52
81
  function remoteIdentity(remoteUrl) {
53
82
  const raw = String(remoteUrl || '').replace(/\.git$/i, '');
54
83
  let pathname = '';
@@ -226,6 +255,12 @@ export function UserProfile({ userId }: { userId: string }) {
226
255
  sourceContent: `{\n "name": "enterprise-workbench",\n "version": "2.4.0",\n "endpoints": {\n "auth": "https://auth.internal.company.com/oauth2/token",\n "storage": "https://storage.internal.company.com/data"\n }\n}`,
227
256
  targetContent: `{\n "name": "enterprise-workbench",\n "version": "2.3.1",\n "endpoints": {\n "auth": "https://auth-legacy.internal.company.com/token",\n "storage": "https://storage.internal.company.com/data"\n }\n}`,
228
257
  defaultChoice: 'target'
258
+ },
259
+ {
260
+ path: 'README.md',
261
+ sourceContent: '# Enterprise Workbench\n\nUpdated setup instructions for the new release.\n',
262
+ targetContent: '# Enterprise Workbench\n\nSetup instructions for the current release.\n',
263
+ conflicted: false
229
264
  }
230
265
  ]
231
266
  });
@@ -247,15 +282,26 @@ export function UserProfile({ userId }: { userId: string }) {
247
282
  const changes = changesResponse.data?.values || [];
248
283
  const sourceCommit = pr.fromRef?.latestCommit || pr.fromRef?.id;
249
284
  const targetCommit = pr.toRef?.latestCommit || pr.toRef?.id;
285
+ let conflictedPaths = null;
286
+ if (mergeResponse.data?.conflicted) {
287
+ try {
288
+ const folder = await findWorkspaceRepository(parsed);
289
+ await git(folder, ['fetch', 'origin', '--prune']);
290
+ const sourceRef = `origin/${pr.fromRef?.displayId}`;
291
+ const targetRef = `origin/${pr.toRef?.displayId}`;
292
+ conflictedPaths = new Set(await detectConflictedPaths(folder, sourceRef, targetRef));
293
+ } catch {}
294
+ }
250
295
  const files = await Promise.all(changes.map(async (change) => {
251
296
  const path = pathOf(change);
297
+ if (conflictedPaths && !conflictedPaths.has(path)) return null;
252
298
  const [sourceContent, targetContent] = await Promise.all([
253
299
  readFile({ ...parsed, commit: sourceCommit, path }),
254
300
  readFile({ ...parsed, commit: targetCommit, path })
255
301
  ]);
256
302
  return { path, sourceContent, targetContent, defaultChoice: 'source' };
257
303
  }));
258
- res.json({ conflicted: Boolean(mergeResponse.data.conflicted), pr: { title: pr.title, sourceBranch: pr.fromRef?.displayId, targetBranch: pr.toRef?.displayId }, files: files.filter((file) => file.path) });
304
+ res.json({ conflicted: Boolean(mergeResponse.data.conflicted), pr: { title: pr.title, sourceBranch: pr.fromRef?.displayId, targetBranch: pr.toRef?.displayId }, files: files.filter((file) => file?.path).map((file) => ({ ...file, conflicted: conflictedPaths ? conflictedPaths.has(file.path) : true })) });
259
305
  } catch (error) {
260
306
  res.status(500).json({ error: `Unable to load conflict data: ${error.message}` });
261
307
  }
@@ -264,28 +310,54 @@ export function UserProfile({ userId }: { userId: string }) {
264
310
  router.get('/my-conflicts', async (_req, res) => {
265
311
  const host = configuredBitbucketHost();
266
312
  if (!host) return res.json({ values: [] });
267
- const dashboardUrl = `https://${host}/rest/api/latest/dashboard/pull-requests?role=author&state=OPEN&limit=100`;
313
+ const dashboardUrl = `https://${host}/rest/api/latest/dashboard/pull-requests?role=author&state=ALL&limit=1000`;
268
314
  try {
269
315
  const response = await client.get(dashboardUrl, { headers: authHeaders() });
270
316
  if (response.status < 200 || response.status >= 300) return res.status(response.status).json({ error: `Unable to load authored Pull Requests (${response.status}).` });
271
317
  const values = Array.isArray(response.data?.values) ? response.data.values : [];
318
+ const resolutionPrs = new Map();
319
+ values.forEach((pr) => {
320
+ const project = pr.toRef?.repository?.project?.key;
321
+ const repository = pr.toRef?.repository?.slug;
322
+ const originalId = String(pr.description || '').match(/This branch resolves conflicts for Pull Request #(\d+)/i)?.[1];
323
+ if (project && repository && originalId && pr.id) {
324
+ resolutionPrs.set(pullRequestKey(project, repository, originalId), pr);
325
+ }
326
+ });
327
+
272
328
  const conflicts = await Promise.all(values.map(async (pr) => {
273
329
  const project = pr.toRef?.repository?.project?.key;
274
330
  const repository = pr.toRef?.repository?.slug;
275
331
  if (!project || !repository || !pr.id) return null;
276
- const mergeUrl = `https://${host}/rest/api/latest/projects/${encodeURIComponent(project)}/repos/${encodeURIComponent(repository)}/pull-requests/${pr.id}/merge`;
277
- const mergeResponse = await client.get(mergeUrl, { headers: authHeaders() });
278
- if (mergeResponse.status < 200 || mergeResponse.status >= 300 || !mergeResponse.data?.conflicted) return null;
279
- return {
332
+ if (String(pr.description || '').match(/This branch resolves conflicts for Pull Request #\d+/i)) return null;
333
+
334
+ const resolutionPr = resolutionPrs.get(pullRequestKey(project, repository, pr.id));
335
+ const baseConflict = {
280
336
  id: pr.id,
281
337
  title: pr.title || `Pull Request #${pr.id}`,
338
+ repositoryName: pr.toRef?.repository?.name || repository,
282
339
  sourceBranch: pr.fromRef?.displayId || pr.fromRef?.id,
283
340
  targetBranch: pr.toRef?.displayId || pr.toRef?.id,
284
341
  updatedDate: pr.updatedDate,
285
342
  url: webPullRequestUrl(host, pr)
286
343
  };
344
+ if (resolutionPr) {
345
+ return {
346
+ ...baseConflict,
347
+ resolutionPrUrl: webPullRequestUrl(host, resolutionPr),
348
+ resolutionPrId: resolutionPr.id
349
+ };
350
+ }
351
+
352
+ const mergeUrl = `https://${host}/rest/api/latest/projects/${encodeURIComponent(project)}/repos/${encodeURIComponent(repository)}/pull-requests/${pr.id}/merge`;
353
+ const mergeResponse = await client.get(mergeUrl, { headers: authHeaders() });
354
+ if (mergeResponse.status < 200 || mergeResponse.status >= 300 || !mergeResponse.data?.conflicted) return null;
355
+ return baseConflict;
287
356
  }));
288
- res.json({ values: conflicts.filter(Boolean).sort((a, b) => (b.updatedDate || 0) - (a.updatedDate || 0)) });
357
+ const sorted = conflicts.filter(Boolean).sort((a, b) => (b.updatedDate || 0) - (a.updatedDate || 0));
358
+ const unresolved = sorted.filter((pr) => !pr.resolutionPrUrl);
359
+ const resolved = sorted.filter((pr) => pr.resolutionPrUrl);
360
+ res.json({ values: unresolved, unresolved, resolved });
289
361
  } catch (error) {
290
362
  res.status(500).json({ error: `Unable to load authored Pull Requests: ${error.message}` });
291
363
  }
@@ -108,26 +108,80 @@ function getAiMessageContent(data) {
108
108
  return typeof content === 'string' ? content : '';
109
109
  }
110
110
 
111
+ function cleanSummaryText(str) {
112
+ if (!str || typeof str !== 'string') return '';
113
+ let text = str.trim();
114
+ text = text.replace(/^```(?:markdown|md|json)?\s*/i, '').replace(/\s*```$/i, '').trim();
115
+
116
+ const prefixRegex = /^\{?\s*\\?"?summary\\?"?\s*:\s*\\?"?/i;
117
+ if (prefixRegex.test(text)) {
118
+ text = text.replace(prefixRegex, '');
119
+ text = text.replace(/\\?"\s*,?\s*\\?"?findings\\?"?\s*:\s*\[[\s\S]*$/i, '');
120
+ text = text.replace(/\\?"\s*\}?\s*$/i, '');
121
+ }
122
+
123
+ return text
124
+ .replace(/\\n/g, '\n')
125
+ .replace(/\\"/g, '"')
126
+ .replace(/\\\\/g, '\\')
127
+ .replace(/\\t/g, '\t')
128
+ .trim();
129
+ }
130
+
111
131
  function parseAiReview(content) {
112
- const candidate = String(content || '').replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
132
+ const raw = String(content || '').trim();
133
+ if (!raw) return { summary: '', findings: [] };
134
+
135
+ const sanitizeFindings = (arr) => {
136
+ if (!Array.isArray(arr)) return [];
137
+ return arr
138
+ .filter((finding) => finding && typeof finding.file === 'string' && finding.file.trim())
139
+ .map((finding) => ({
140
+ file: finding.file.trim(),
141
+ line: finding.line == null || finding.line === '' || !Number.isFinite(Number(finding.line)) ? null : Number(finding.line),
142
+ severity: ['critical', 'warning', 'info'].includes(finding.severity) ? finding.severity : 'warning',
143
+ title: typeof finding.title === 'string' && finding.title.trim() ? finding.title.trim() : 'AI finding',
144
+ explanation: typeof finding.explanation === 'string' ? finding.explanation.trim() : '',
145
+ recommendation: typeof finding.recommendation === 'string' ? finding.recommendation.trim() : ''
146
+ }));
147
+ };
148
+
149
+ const jsonMatch = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i) || raw.match(/\{[\s\S]*\}/);
150
+ const candidate = jsonMatch ? (jsonMatch[1] || jsonMatch[0]).trim() : raw;
151
+
113
152
  try {
114
153
  const parsed = JSON.parse(candidate);
115
- return {
116
- summary: typeof parsed.summary === 'string' ? parsed.summary : '',
117
- findings: Array.isArray(parsed.findings) ? parsed.findings
118
- .filter((finding) => finding && typeof finding.file === 'string' && finding.file.trim())
119
- .map((finding) => ({
120
- file: finding.file.trim(),
121
- line: finding.line == null || finding.line === '' || !Number.isFinite(Number(finding.line)) ? null : Number(finding.line),
122
- severity: ['critical', 'warning', 'info'].includes(finding.severity) ? finding.severity : 'warning',
123
- title: typeof finding.title === 'string' && finding.title.trim() ? finding.title.trim() : 'AI finding',
124
- explanation: typeof finding.explanation === 'string' ? finding.explanation.trim() : '',
125
- recommendation: typeof finding.recommendation === 'string' ? finding.recommendation.trim() : ''
126
- })) : []
127
- };
128
- } catch {
129
- return { summary: content, findings: [] };
154
+ if (parsed && typeof parsed === 'object') {
155
+ const summary = typeof parsed.summary === 'string' ? cleanSummaryText(parsed.summary) : '';
156
+ const findings = sanitizeFindings(parsed.findings);
157
+ if (summary || findings.length > 0) {
158
+ return { summary, findings };
159
+ }
160
+ }
161
+ } catch {}
162
+
163
+ let findings = [];
164
+ const findingsMatch = raw.match(/(?:\\?"findings\\?"|findings)\s*:\s*(\[[\s\S]*?\])/i);
165
+ if (findingsMatch) {
166
+ try {
167
+ findings = sanitizeFindings(JSON.parse(findingsMatch[1]));
168
+ } catch {
169
+ const itemRegex = /\{[\s\S]*?\}/g;
170
+ let itemMatch;
171
+ while ((itemMatch = itemRegex.exec(findingsMatch[1])) !== null) {
172
+ try {
173
+ findings.push(JSON.parse(itemMatch[0]));
174
+ } catch {}
175
+ }
176
+ findings = sanitizeFindings(findings);
177
+ }
130
178
  }
179
+
180
+ const summary = cleanSummaryText(raw);
181
+ return {
182
+ summary: summary || (findings.length > 0 ? '### 📋 PR Review Findings\n\nActionable findings found.' : ''),
183
+ findings
184
+ };
131
185
  }
132
186
 
133
187
  async function runAiReview({ aiApiHost, pr, files }) {
@@ -12,6 +12,7 @@ const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
12
12
  const PACKAGE_NAME = packageJson.name;
13
13
  const CURRENT_VERSION = packageJson.version;
14
14
  const CACHE_TTL_MS = 15 * 60 * 1000;
15
+ const RELEASE_VISIBILITY_AGE_MS = 48 * 60 * 60 * 1000;
15
16
 
16
17
  let cachedResult = null;
17
18
  let cachedAt = 0;
@@ -77,7 +78,9 @@ function buildReleaseList(metadata) {
77
78
  });
78
79
  }
79
80
 
81
+ const releaseCutoff = Date.now() - RELEASE_VISIBILITY_AGE_MS;
80
82
  return [...entriesByVersion.values()]
83
+ .filter((entry) => entry.publishedAt && new Date(entry.publishedAt).getTime() <= releaseCutoff)
81
84
  .sort((left, right) => compareVersions(right.version, left.version))
82
85
  .slice(0, 10);
83
86
  }