buddy-workbench 0.1.48 → 0.1.50

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.48",
3
+ "version": "0.1.50",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,6 +114,7 @@ async function checkBranchPair(parsedRepo, sourceBranch, targetBranch, headers)
114
114
  merged: false,
115
115
  unmergedCommitCount: 0,
116
116
  openPr: null,
117
+ conflicted: false,
117
118
  error: null
118
119
  };
119
120
 
@@ -160,7 +161,19 @@ async function checkBranchPair(parsedRepo, sourceBranch, targetBranch, headers)
160
161
  title: existingPr.title,
161
162
  url: existingPr.links?.self?.[0]?.href || `${protocol}://${host}/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${existingPr.id}`
162
163
  };
164
+
165
+ // A PR can be open but still have merge conflicts. Bitbucket exposes
166
+ // that state through the PR mergeability endpoint.
167
+ try {
168
+ const mergeabilityUrl = `${protocol}://${host}/rest/api/latest/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${existingPr.id}/merge`;
169
+ const mergeabilityRes = await httpClient.get(mergeabilityUrl, { headers });
170
+ if (mergeabilityRes.status >= 200 && mergeabilityRes.status < 300) {
171
+ result.conflicted = Boolean(mergeabilityRes.data?.conflicted);
172
+ }
173
+ } catch (err) {
174
+ recordApiError({ source: 'Bitbucket API (Branch Sync)', method: 'GET', message: err.message || 'Failed to check PR mergeability.' });
163
175
  }
176
+ }
164
177
  } else {
165
178
  const message = `Failed to check open pull requests (${prRes.status}): ${prRes.data?.errors?.[0]?.message || prRes.data?.message || prRes.statusText}`;
166
179
  recordApiError({ source: 'Bitbucket API (Branch Sync)', method: 'GET', url: prUrl, status: prRes.status, message });
@@ -1,3 +1,4 @@
1
+ import https from 'node:https';
1
2
  import axios from 'axios';
2
3
  import { Router } from 'express';
3
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -11,6 +12,11 @@ import { recordApiError } from '../lib/api-errors.js';
11
12
  const router = Router();
12
13
  const exec = promisify(execFile);
13
14
  const running = new Set();
15
+ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
16
+ const httpClient = axios.create({
17
+ httpsAgent,
18
+ validateStatus: () => true
19
+ });
14
20
 
15
21
  function repoName(url) { return basename(String(url || '').replace(/[\\/]$/, '').replace(/\.git$/i, '').replace(/[:/]+$/, '')) || 'repo'; }
16
22
  function repoPath(data, repo) { return join(data.workspaceDir, repo.directory || repoName(repo.url)); }
@@ -24,6 +30,35 @@ function parseBitbucket(url) {
24
30
  if (parts[0] === 'projects' || parts[0] === 'users') return { protocol, host: u.host, project: parts[1], slug: parts[3] };
25
31
  return parts.length >= 2 ? { protocol, host: u.host, project: parts.at(-2), slug: parts.at(-1) } : null;
26
32
  }
33
+ function bitbucketHeaders() {
34
+ const settings = readSettings();
35
+ const headers = { Accept: 'application/json' };
36
+ if (settings.bitbucketAccessToken) headers.Authorization = `Bearer ${settings.bitbucketAccessToken}`;
37
+ return headers;
38
+ }
39
+ function branchTimestamp(commit) {
40
+ const value = commit?.committerTimestamp || commit?.authorTimestamp || commit?.committer?.timestamp || commit?.author?.timestamp || commit?.date;
41
+ const timestamp = typeof value === 'number' ? value : Date.parse(value || '');
42
+ return Number.isNaN(timestamp) ? 0 : timestamp;
43
+ }
44
+ async function fetchRemoteBranches(repo) {
45
+ const parsed = parseBitbucket(repo.url);
46
+ if (!parsed) throw new Error(`Could not parse Bitbucket repository URL for ${repo.name || repoName(repo.url)}.`);
47
+ const base = `${parsed.protocol}://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}`;
48
+ const response = await httpClient.get(`${base}/branches?limit=200&orderBy=MODIFICATION`, { headers: bitbucketHeaders() });
49
+ if (response.status < 200 || response.status >= 300) throw new Error(response.data?.errors?.[0]?.message || `Bitbucket branches failed (${response.status})`);
50
+ const values = Array.isArray(response.data?.values) ? response.data.values : [];
51
+ return { base, branches: values.map((branch, index) => ({ name: branch.displayId || branch.id?.replace(/^refs\/heads\//, ''), latestCommit: branch.latestCommit, rank: index })).filter((branch) => branch.name) };
52
+ }
53
+ async function fetchBranchUpdatedAt(base, branch) {
54
+ if (!branch.latestCommit) return { name: branch.name, updatedAt: 0, rank: branch.rank };
55
+ try {
56
+ const response = await httpClient.get(`${base}/commits/${encodeURIComponent(branch.latestCommit)}`, { headers: bitbucketHeaders() });
57
+ return { name: branch.name, updatedAt: response.status >= 200 && response.status < 300 ? branchTimestamp(response.data) : 0, rank: branch.rank };
58
+ } catch {
59
+ return { name: branch.name, updatedAt: 0, rank: branch.rank };
60
+ }
61
+ }
27
62
  function updateTask(taskId, updater) {
28
63
  const data = readPackageUpgradeData(); const task = data.tasks.find((item) => item.id === taskId);
29
64
  if (!task) return;
@@ -82,7 +117,7 @@ async function runRepo(task, repo) {
82
117
  if (!parsed) throw new Error('Could not parse Bitbucket repository URL');
83
118
  const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }; if (settings.bitbucketAccessToken) headers.Authorization = `Bearer ${settings.bitbucketAccessToken}`;
84
119
  const api = `${parsed.protocol}://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/pull-requests`;
85
- const response = await axios.post(api, { title: task.commitMessage, description: `Upgrade ${task.packageName} to ${task.version}`, fromRef: { id: `refs/heads/${task.sourceBranch}` }, toRef: { id: `refs/heads/${task.targetBranch}` } }, { headers, validateStatus: () => true });
120
+ const response = await httpClient.post(api, { title: task.commitMessage, description: `Upgrade ${task.packageName} to ${task.version}`, fromRef: { id: `refs/heads/${task.sourceBranch}` }, toRef: { id: `refs/heads/${task.targetBranch}` } }, { headers, validateStatus: () => true });
86
121
  if (response.status < 200 || response.status >= 300) throw new Error(response.data?.errors?.[0]?.message || `Bitbucket PR failed (${response.status})`);
87
122
  const prUrl = response.data?.links?.self?.[0]?.href || `${parsed.protocol}://${parsed.host}/projects/${parsed.project}/repos/${parsed.slug}/pull-requests/${response.data.id}`;
88
123
  updateTask(task.id, (t) => ({ ...t, repos: t.repos.map((r) => r.repoId === repo.id ? { ...result, status: 'success', prUrl } : r) }));
@@ -117,6 +152,26 @@ router.post('/repos/packages', (req, res) => {
117
152
  });
118
153
  res.json([...packageNames].sort((a, b) => a.localeCompare(b)));
119
154
  });
155
+ router.post('/repos/branches', async (req, res) => {
156
+ const data = readPackageUpgradeData();
157
+ const repoIds = Array.isArray(req.body?.repoIds) ? req.body.repoIds : [];
158
+ const repos = repoIds.map((repoId) => data.repos.find((repo) => repo.id === repoId)).filter(Boolean);
159
+ if (!repos.length) return res.json([]);
160
+ try {
161
+ const remote = await Promise.all(repos.map((repo) => fetchRemoteBranches(repo)));
162
+ const branchSets = remote.map(({ branches }) => new Set(branches.map((branch) => branch.name)));
163
+ const commonNames = remote[0].branches.map((branch) => branch.name).filter((name) => branchSets.every((branches) => branches.has(name)));
164
+ const commonBranches = await Promise.all(commonNames.map(async (name) => {
165
+ const branches = remote.map(({ base, branches: items }) => ({ base, branch: items.find((item) => item.name === name) })).filter(({ branch }) => branch);
166
+ const updated = await Promise.all(branches.map(({ base, branch }) => fetchBranchUpdatedAt(base, branch)));
167
+ return { name, updatedAt: Math.max(...updated.map((item) => item.updatedAt)), rank: Math.min(...updated.map((item) => item.rank)) };
168
+ }));
169
+ commonBranches.sort((a, b) => b.updatedAt - a.updatedAt || a.rank - b.rank || a.name.localeCompare(b.name));
170
+ res.json(commonBranches.map(({ name, updatedAt }) => ({ name, updatedAt: updatedAt ? new Date(updatedAt).toISOString() : null })));
171
+ } catch (error) {
172
+ res.status(502).json({ error: error.message || 'Failed to fetch common repository branches.' });
173
+ }
174
+ });
120
175
  router.post('/repos/:id/clone', async (req, res) => { const data = readPackageUpgradeData(); const repo = data.repos.find((item) => item.id === req.params.id); if (!repo) return res.status(404).json({ error: 'Repository not found.' }); if (!data.workspaceDir) return res.status(400).json({ error: 'Configure a workspace directory first.' }); const folder = repoPath(data, repo); try { mkdirSync(data.workspaceDir, { recursive: true }); if (!existsSync(join(folder, '.git'))) await exec('git', ['clone', repo.url, folder], { cwd: data.workspaceDir, timeout: 20 * 60 * 1000, maxBuffer: 2 * 1024 * 1024 }); res.json({ ...repo, cloned: true }); } catch (error) { res.status(400).json({ error: error.stderr || error.message }); } });
121
176
  router.delete('/repos/:id', (req, res) => { const data = readPackageUpgradeData(); data.repos = data.repos.filter((r) => r.id !== req.params.id); savePackageUpgradeData(data); res.status(204).end(); });
122
177
  router.post('/tasks', (req, res) => { const body = req.body || {}; const required = ['sourceBranch', 'targetBranch', 'packageName', 'version', 'commitMessage']; if (required.some((key) => !String(body[key] || '').trim()) || !Array.isArray(body.repoIds) || !body.repoIds.length) return res.status(400).json({ error: 'All fields and at least one repository are required.' }); const data = readPackageUpgradeData(); if (!data.workspaceDir) return res.status(400).json({ error: 'Configure a workspace directory first.' }); const repoIds = body.repoIds.filter((id) => data.repos.some((repo) => repo.id === id)); if (!repoIds.length) return res.status(400).json({ error: 'Select at least one configured repository.' }); const task = { id: `task-${Date.now()}`, ...Object.fromEntries(required.map((key) => [key, String(body[key]).trim()])), repoIds, repos: repoIds.map((repoId) => ({ repoId, status: 'queued' })), status: 'running', createdAt: new Date().toISOString() }; data.tasks.unshift(task); savePackageUpgradeData(data); void execute(task); res.status(201).json(task); });
@@ -0,0 +1,314 @@
1
+ import https from 'node:https';
2
+ import { existsSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { execFile } from 'node:child_process';
5
+ import { promisify } from 'node:util';
6
+ import axios from 'axios';
7
+ import { Router } from 'express';
8
+ import { readSettings } from '../repositories/settings.js';
9
+ import { readPackageUpgradeData } from '../repositories/package-upgrade.js';
10
+ import { getGitRemoteUrl } from '../services/git.js';
11
+
12
+ const router = Router();
13
+ const execFileAsync = promisify(execFile);
14
+ const httpsAgent = new https.Agent({ rejectUnauthorized: false });
15
+ const client = axios.create({ httpsAgent, validateStatus: () => true });
16
+
17
+ function parsePrUrl(value) {
18
+ const match = String(value || '').match(/https?:\/\/([^/]+)\/(?:projects\/([^/]+)|users\/([^/]+))\/repos\/([^/]+)\/pull-requests\/(\d+)/i);
19
+ if (!match) return null;
20
+ return { host: match[1], projectKey: match[2] || `~${match[3]}`, repositorySlug: match[4], pullRequestId: match[5] };
21
+ }
22
+
23
+ function authHeaders() {
24
+ const token = readSettings().bitbucketAccessToken;
25
+ return { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
26
+ }
27
+
28
+ function configuredBitbucketHost() {
29
+ const domain = readSettings().domain || '';
30
+ if (!domain) return '';
31
+ return domain.includes('bitbucket') ? domain : `bitbucket.${domain}`;
32
+ }
33
+
34
+ function webPullRequestUrl(host, pr) {
35
+ const project = pr.toRef?.repository?.project?.key;
36
+ const repository = pr.toRef?.repository?.slug;
37
+ if (!project || !repository || !pr.id) return '';
38
+ const projectPath = String(project).startsWith('~') ? `users/${String(project).slice(1)}` : `projects/${project}`;
39
+ return `https://${host}/${projectPath}/repos/${repository}/pull-requests/${pr.id}`;
40
+ }
41
+
42
+ async function git(folder, args, options = {}) {
43
+ const result = await execFileAsync('git', args, {
44
+ cwd: folder,
45
+ timeout: 10 * 60 * 1000,
46
+ maxBuffer: 4 * 1024 * 1024,
47
+ ...options
48
+ });
49
+ return result.stdout;
50
+ }
51
+
52
+ function remoteIdentity(remoteUrl) {
53
+ const raw = String(remoteUrl || '').replace(/\.git$/i, '');
54
+ let pathname = '';
55
+ try {
56
+ const url = new URL(raw.startsWith('git@') ? `ssh://${raw.replace(/^git@/, '').replace(':', '/')}` : raw.replace(/^ssh:\/\//i, 'https://'));
57
+ pathname = url.pathname;
58
+ } catch {
59
+ pathname = raw.split(':').at(-1) || raw;
60
+ }
61
+ const parts = pathname.split('/').filter(Boolean);
62
+ if (parts[0] === 'scm') return { projectKey: parts[1], repositorySlug: parts[2] };
63
+ if (parts[0] === 'projects' || parts[0] === 'users') return { projectKey: parts[1], repositorySlug: parts[3] };
64
+ return { projectKey: parts.at(-2), repositorySlug: parts.at(-1) };
65
+ }
66
+
67
+ async function findWorkspaceRepository(parsed) {
68
+ const data = readPackageUpgradeData();
69
+ if (!data.workspaceDir) throw new Error('Configure a workspace directory in Package Upgrade settings first.');
70
+ const workspaceDir = resolve(data.workspaceDir);
71
+ if (!existsSync(workspaceDir) || !statSync(workspaceDir).isDirectory()) {
72
+ throw new Error(`Workspace directory does not exist: ${workspaceDir}`);
73
+ }
74
+
75
+ const candidates = new Set();
76
+ if (existsSync(join(workspaceDir, '.git'))) candidates.add(workspaceDir);
77
+ for (const repo of data.repos || []) {
78
+ const folder = resolve(workspaceDir, repo.directory || basename(String(repo.url || '').replace(/\.git$/i, '')));
79
+ if (existsSync(join(folder, '.git'))) candidates.add(folder);
80
+ }
81
+ for (const entry of readdirSync(workspaceDir, { withFileTypes: true })) {
82
+ if (entry.isDirectory()) {
83
+ const folder = join(workspaceDir, entry.name);
84
+ if (existsSync(join(folder, '.git'))) candidates.add(folder);
85
+ }
86
+ }
87
+
88
+ for (const folder of candidates) {
89
+ const identity = remoteIdentity(await getGitRemoteUrl(folder));
90
+ if (identity.projectKey === parsed.projectKey && identity.repositorySlug === parsed.repositorySlug) return folder;
91
+ }
92
+ throw new Error(`Could not find ${parsed.projectKey}/${parsed.repositorySlug} in workspace: ${workspaceDir}`);
93
+ }
94
+
95
+ function validateRelativeFilePath(filePath) {
96
+ const value = String(filePath || '').replace(/^\/+/, '');
97
+ if (!value || value.includes('\0') || value.includes('\\') || value.split('/').includes('..')) {
98
+ throw new Error(`Invalid repository file path: ${filePath}`);
99
+ }
100
+ return value;
101
+ }
102
+
103
+ async function readGitFile(folder, ref, filePath) {
104
+ try {
105
+ const { stdout } = await execFileAsync('git', ['show', `${ref}:${filePath}`], {
106
+ cwd: folder,
107
+ timeout: 60 * 1000,
108
+ maxBuffer: 100 * 1024 * 1024,
109
+ encoding: 'buffer'
110
+ });
111
+ return stdout;
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ function pathOf(change) {
118
+ return change?.path?.toString || change?.path?.name || change?.path || change?.src?.toString || '';
119
+ }
120
+
121
+ async function readFile({ host, projectKey, repositorySlug, commit, path }) {
122
+ if (!commit || !path) return '';
123
+ const url = `https://${host}/rest/api/latest/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repositorySlug)}/browse/${path.split('/').map(encodeURIComponent).join('/')}?at=${encodeURIComponent(commit)}&raw=true`;
124
+ const response = await client.get(url, { headers: authHeaders(), responseType: 'text' });
125
+ if (response.status < 200 || response.status >= 300) return '';
126
+ if (typeof response.data === 'string') return response.data;
127
+ return (response.data.lines || []).map((line) => line.text ?? line).join('\n');
128
+ }
129
+
130
+ router.post('/load', async (req, res) => {
131
+ const parsed = parsePrUrl(req.body?.prUrl);
132
+ if (!parsed) return res.status(400).json({ error: 'Invalid Bitbucket Server Pull Request URL.' });
133
+
134
+ const base = `https://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.projectKey)}/repos/${encodeURIComponent(parsed.repositorySlug)}/pull-requests/${parsed.pullRequestId}`;
135
+ try {
136
+ const [prResponse, mergeResponse, changesResponse] = await Promise.all([
137
+ client.get(base, { headers: authHeaders() }),
138
+ client.get(`${base}/merge`, { headers: authHeaders() }),
139
+ client.get(`${base}/changes?limit=1000`, { headers: authHeaders() })
140
+ ]);
141
+ if (prResponse.status < 200 || prResponse.status >= 300) return res.status(prResponse.status).json({ error: 'Unable to load Pull Request details.' });
142
+ if (mergeResponse.status < 200 || mergeResponse.status >= 300) return res.status(mergeResponse.status).json({ error: 'Unable to load Pull Request mergeability.' });
143
+ const pr = prResponse.data;
144
+ const changes = changesResponse.data?.values || [];
145
+ const sourceCommit = pr.fromRef?.latestCommit || pr.fromRef?.id;
146
+ const targetCommit = pr.toRef?.latestCommit || pr.toRef?.id;
147
+ const files = await Promise.all(changes.map(async (change) => {
148
+ const path = pathOf(change);
149
+ const [sourceContent, targetContent] = await Promise.all([
150
+ readFile({ ...parsed, commit: sourceCommit, path }),
151
+ readFile({ ...parsed, commit: targetCommit, path })
152
+ ]);
153
+ return { path, sourceContent, targetContent, defaultChoice: 'source' };
154
+ }));
155
+ 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) });
156
+ } catch (error) {
157
+ res.status(500).json({ error: `Unable to load conflict data: ${error.message}` });
158
+ }
159
+ });
160
+
161
+ router.get('/my-conflicts', async (_req, res) => {
162
+ const host = configuredBitbucketHost();
163
+ if (!host) return res.json({ values: [] });
164
+ const dashboardUrl = `https://${host}/rest/api/latest/dashboard/pull-requests?role=author&state=OPEN&limit=100`;
165
+ try {
166
+ const response = await client.get(dashboardUrl, { headers: authHeaders() });
167
+ if (response.status < 200 || response.status >= 300) return res.status(response.status).json({ error: `Unable to load authored Pull Requests (${response.status}).` });
168
+ const values = Array.isArray(response.data?.values) ? response.data.values : [];
169
+ const conflicts = await Promise.all(values.map(async (pr) => {
170
+ const project = pr.toRef?.repository?.project?.key;
171
+ const repository = pr.toRef?.repository?.slug;
172
+ if (!project || !repository || !pr.id) return null;
173
+ const mergeUrl = `https://${host}/rest/api/latest/projects/${encodeURIComponent(project)}/repos/${encodeURIComponent(repository)}/pull-requests/${pr.id}/merge`;
174
+ const mergeResponse = await client.get(mergeUrl, { headers: authHeaders() });
175
+ if (mergeResponse.status < 200 || mergeResponse.status >= 300 || !mergeResponse.data?.conflicted) return null;
176
+ return {
177
+ id: pr.id,
178
+ title: pr.title || `Pull Request #${pr.id}`,
179
+ sourceBranch: pr.fromRef?.displayId || pr.fromRef?.id,
180
+ targetBranch: pr.toRef?.displayId || pr.toRef?.id,
181
+ updatedDate: pr.updatedDate,
182
+ url: webPullRequestUrl(host, pr)
183
+ };
184
+ }));
185
+ res.json({ values: conflicts.filter(Boolean).sort((a, b) => (b.updatedDate || 0) - (a.updatedDate || 0)) });
186
+ } catch (error) {
187
+ res.status(500).json({ error: `Unable to load authored Pull Requests: ${error.message}` });
188
+ }
189
+ });
190
+
191
+ router.post('/resolve', async (req, res) => {
192
+ const parsed = parsePrUrl(req.body?.prUrl);
193
+ const files = Array.isArray(req.body?.files) ? req.body.files : [];
194
+ const choices = req.body?.choices || {};
195
+ const commitMessage = typeof req.body?.commitMessage === 'string' ? req.body.commitMessage.trim() : '';
196
+ const createNewBranch = req.body?.createNewBranch === true;
197
+ const newBranchName = typeof req.body?.newBranchName === 'string' ? req.body.newBranchName.trim() : '';
198
+ if (!parsed) return res.status(400).json({ error: 'Invalid Bitbucket Server Pull Request URL.' });
199
+ if (files.length === 0) return res.status(400).json({ error: 'At least one resolved file is required.' });
200
+ if (!commitMessage) return res.status(400).json({ error: 'Commit message is required.' });
201
+ if (createNewBranch && !newBranchName) return res.status(400).json({ error: 'New branch name is required.' });
202
+ if (files.some((file) => !['source', 'target'].includes(choices[file.path]))) {
203
+ return res.status(400).json({ error: 'Every resolved file must have a source or target choice.' });
204
+ }
205
+
206
+ let folder;
207
+ let originalBranch = null;
208
+ let originalHead = null;
209
+ try {
210
+ folder = await findWorkspaceRepository(parsed);
211
+ originalBranch = String(await git(folder, ['branch', '--show-current'])).trim() || null;
212
+ originalHead = String(await git(folder, ['rev-parse', 'HEAD'])).trim();
213
+ // The workspace is disposable for this workflow: discard all tracked and
214
+ // untracked local changes before switching branches and resolving files.
215
+ await git(folder, ['reset', '--hard', 'HEAD']);
216
+ await git(folder, ['clean', '-fd']);
217
+
218
+ const prUrl = `https://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.projectKey)}/repos/${encodeURIComponent(parsed.repositorySlug)}/pull-requests/${parsed.pullRequestId}`;
219
+ const prResponse = await client.get(prUrl, { headers: authHeaders() });
220
+ if (prResponse.status < 200 || prResponse.status >= 300) return res.status(prResponse.status).json({ error: `Unable to load Pull Request details (${prResponse.status}).` });
221
+ const pr = prResponse.data;
222
+ const sourceBranch = pr.fromRef?.displayId;
223
+ const targetBranch = pr.toRef?.displayId;
224
+ if (!sourceBranch || !targetBranch) return res.status(400).json({ error: 'Pull Request source or target branch could not be determined.' });
225
+
226
+ await git(folder, ['fetch', 'origin', '--prune']);
227
+ const sourceRef = `origin/${sourceBranch}`;
228
+ const targetRef = `origin/${targetBranch}`;
229
+ await git(folder, ['rev-parse', '--verify', sourceRef]);
230
+ await git(folder, ['rev-parse', '--verify', targetRef]);
231
+ await git(folder, ['checkout', '-B', sourceBranch, sourceRef]);
232
+
233
+ let commitBranch = sourceBranch;
234
+ if (createNewBranch) {
235
+ await git(folder, ['check-ref-format', '--branch', newBranchName]);
236
+ try {
237
+ await git(folder, ['rev-parse', '--verify', `refs/heads/${newBranchName}`]);
238
+ return res.status(409).json({ error: `Local branch already exists: ${newBranchName}` });
239
+ } catch {}
240
+ try {
241
+ await git(folder, ['ls-remote', '--exit-code', '--heads', 'origin', newBranchName]);
242
+ return res.status(409).json({ error: `Remote branch already exists: ${newBranchName}` });
243
+ } catch {}
244
+ await git(folder, ['checkout', '-b', newBranchName]);
245
+ commitBranch = newBranchName;
246
+ }
247
+
248
+ const resolvedPaths = [];
249
+ for (const file of files) {
250
+ const filePath = validateRelativeFilePath(file.path);
251
+ 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
+ }
260
+ resolvedPaths.push(filePath);
261
+ }
262
+
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.' });
266
+ await git(folder, ['commit', '-m', commitMessage]);
267
+ await git(folder, ['push', 'origin', commitBranch]);
268
+ const commitId = String(await git(folder, ['rev-parse', 'HEAD'])).trim();
269
+ const commitUrl = `https://${parsed.host}/projects/${encodeURIComponent(parsed.projectKey)}/repos/${encodeURIComponent(parsed.repositorySlug)}/commits/${commitId}`;
270
+ let resolutionPrUrl = '';
271
+ if (createNewBranch) {
272
+ const createPrUrl = `https://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.projectKey)}/repos/${encodeURIComponent(parsed.repositorySlug)}/pull-requests`;
273
+ const createPrResponse = await client.post(createPrUrl, {
274
+ title: commitMessage,
275
+ description: `Resolution Pull Request created from ${commitBranch}.\n\nThis branch contains the selected resolutions for Pull Request #${parsed.pullRequestId}.`,
276
+ fromRef: {
277
+ id: `refs/heads/${commitBranch}`,
278
+ repository: { slug: parsed.repositorySlug, project: { key: parsed.projectKey } }
279
+ },
280
+ toRef: {
281
+ id: `refs/heads/${targetBranch}`,
282
+ repository: { slug: parsed.repositorySlug, project: { key: parsed.projectKey } }
283
+ }
284
+ }, { headers: { ...authHeaders(), 'Content-Type': 'application/json' } });
285
+ if (createPrResponse.status < 200 || createPrResponse.status >= 300) {
286
+ const detail = createPrResponse.data?.errors?.[0]?.message || createPrResponse.data?.message || `Bitbucket returned status ${createPrResponse.status}.`;
287
+ throw new Error(`Resolution branch was pushed, but creating the Pull Request failed: ${detail}`);
288
+ }
289
+ const createdPr = createPrResponse.data || {};
290
+ resolutionPrUrl = `https://${parsed.host}/${String(parsed.projectKey).startsWith('~') ? `users/${String(parsed.projectKey).slice(1)}` : `projects/${encodeURIComponent(parsed.projectKey)}`}/repos/${encodeURIComponent(parsed.repositorySlug)}/pull-requests/${createdPr.id}`;
291
+ }
292
+
293
+ res.json({
294
+ success: true,
295
+ message: `Resolution commit created successfully on ${commitBranch}.`,
296
+ commitUrl,
297
+ commitUrls: [commitUrl],
298
+ branch: commitBranch,
299
+ commitId,
300
+ prUrl: resolutionPrUrl
301
+ });
302
+ } catch (error) {
303
+ res.status(500).json({ error: `Unable to create resolution commit: ${error.stderr || error.message}` });
304
+ } finally {
305
+ if (folder) {
306
+ try {
307
+ if (originalBranch) await git(folder, ['checkout', originalBranch]);
308
+ else if (originalHead) await git(folder, ['checkout', '--detach', originalHead]);
309
+ } catch {}
310
+ }
311
+ }
312
+ });
313
+
314
+ export default router;
@@ -138,6 +138,27 @@ function getEditorUrl(editor = 'vscode', path = '') {
138
138
  }
139
139
 
140
140
  function dayItems(date) { return readJson(dayFile(date), []); }
141
+
142
+ async function getClipboardText() {
143
+ const commands = process.platform === 'darwin'
144
+ ? [['pbpaste', []]]
145
+ : process.platform === 'win32'
146
+ ? [['powershell.exe', ['-NoProfile', '-Command', 'Get-Clipboard -Format Text -Raw']]]
147
+ : [
148
+ ['wl-paste', ['--no-newline', '--type', 'text']],
149
+ ['xclip', ['-selection', 'clipboard', '-o', '-t', 'UTF8_STRING']],
150
+ ['xsel', ['--clipboard', '--output']]
151
+ ];
152
+
153
+ for (const [command, args] of commands) {
154
+ try {
155
+ const { stdout } = await execFileAsync(command, args, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
156
+ return stdout;
157
+ } catch {}
158
+ }
159
+ return '';
160
+ }
161
+
141
162
  export function clipboardItems(date = today()) {
142
163
  const settings = readSettings();
143
164
  const editor = settings.defaultEditor || 'vscode';
@@ -208,7 +229,7 @@ export function deleteClipboardItem(date, id) {
208
229
  const contentPath = join(paths.clipboardDir, 'content', item.contentFile);
209
230
  if (existsSync(contentPath)) unlinkSync(contentPath);
210
231
  }
211
- if (item.imageFile) {
232
+ if (item.imageFile && process.platform === 'darwin') {
212
233
  const imagePath = join(paths.clipboardDir, 'content', item.imageFile);
213
234
  if (existsSync(imagePath)) unlinkSync(imagePath);
214
235
  }
@@ -216,13 +237,12 @@ export function deleteClipboardItem(date, id) {
216
237
  }
217
238
 
218
239
  export async function captureClipboard() {
219
- if (process.platform !== 'darwin') return;
220
240
  const settings = readSettings();
221
241
  if (settings.clipboardEnabled === false) return;
222
242
 
223
243
  // 1. Text capture
224
244
  try {
225
- const { stdout } = await execFileAsync('pbpaste');
245
+ const stdout = await getClipboardText();
226
246
  const text = stdout.trim();
227
247
  if (text && text !== lastValue) {
228
248
  lastValue = text;
@@ -277,7 +297,7 @@ export async function captureClipboard() {
277
297
  } catch {}
278
298
 
279
299
  // 2. Image capture (Mac only & clipboardImageEnabled !== false)
280
- if (settings.clipboardImageEnabled !== false) {
300
+ if (process.platform === 'darwin' && settings.clipboardImageEnabled !== false) {
281
301
  try {
282
302
  const imageData = await getMacClipboardImageData();
283
303
  if (imageData && imageData.rgbaBuf.length > 0) {
package/server.js CHANGED
@@ -13,6 +13,7 @@ import groupTaskRoutes from './server/routes/group-tasks.js';
13
13
  import portDiagnosticsRoutes from './server/routes/port-diagnostics.js';
14
14
  import settingsRoutes from './server/routes/settings.js';
15
15
  import prReviewRoutes from './server/routes/pr-review.js';
16
+ import prConflictRoutes from './server/routes/pr-conflicts.js';
16
17
  import jiraFiltersRoutes from './server/routes/jira-filters.js';
17
18
  import todoRoutes from './server/routes/todos.js';
18
19
  import staticPagesRoutes from './server/routes/static-pages.js';
@@ -88,6 +89,7 @@ app.use('/api/group-tasks', groupTaskRoutes);
88
89
  app.use('/api/port-diagnostics', portDiagnosticsRoutes);
89
90
  app.use('/api/settings', settingsRoutes);
90
91
  app.use('/api/pr-review', prReviewRoutes);
92
+ app.use('/api/pr-conflicts', prConflictRoutes);
91
93
  app.use('/api/jira-filters', jiraFiltersRoutes);
92
94
  app.use('/api/todos', todoRoutes);
93
95
  app.use('/api/static-pages', staticPagesRoutes);