buddy-workbench 0.1.45 → 0.1.47

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/README.md CHANGED
@@ -70,7 +70,7 @@ Create a manifest at `plugins/<plugin-name>/plugin.json` to add an item to the s
70
70
  { "name": "My feature", "icon": "✦", "view": "view.html" }
71
71
  ```
72
72
 
73
- Plugins are discovered automatically. `view` points to an HTML fragment in the plugin directory. See `plugins/example`; place your HTML, scripts, and styles in an isolated plugin folder. Add API routes as needed for server-side capabilities.
73
+ Plugins are discovered automatically. `view` points to an HTML fragment in the plugin directory. Place your HTML, scripts, and styles in an isolated plugin folder. Add API routes as needed for server-side capabilities.
74
74
 
75
75
  ## Scripts and logs
76
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-workbench",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,16 @@
1
+ import { addErrorRecord } from '../repositories/errors.js';
2
+
3
+ function cleanUrl(url) {
4
+ return String(url || '').replace(/([?&](?:token|access_token|api_token|authorization)=)[^&]+/gi, '$1[REDACTED]');
5
+ }
6
+
7
+ export function recordApiError({ source = 'Server API', method = '', url = '', status, message = '', details = '' } = {}) {
8
+ const location = [method, cleanUrl(url)].filter(Boolean).join(' ');
9
+ const statusText = status !== undefined && status !== null ? `HTTP ${status}` : '';
10
+ const detailLines = [location && `Target: ${location}`, statusText, details].filter(Boolean);
11
+ addErrorRecord({
12
+ source,
13
+ message: message || statusText || 'API request failed.',
14
+ details: detailLines.join('\n')
15
+ });
16
+ }
@@ -2,6 +2,7 @@ import https from 'node:https';
2
2
  import axios from 'axios';
3
3
  import { Router } from 'express';
4
4
  import { readSettings } from '../repositories/settings.js';
5
+ import { recordApiError } from '../lib/api-errors.js';
5
6
  import {
6
7
  createBranchSyncApp,
7
8
  listBranchSyncApps,
@@ -116,19 +117,21 @@ async function checkBranchPair(parsedRepo, sourceBranch, targetBranch, headers)
116
117
  error: null
117
118
  };
118
119
 
120
+ const commitsUrl = `${protocol}://${host}/rest/api/latest/projects/${projectKey}/repos/${repositorySlug}/commits?until=${encodeURIComponent(sourceBranch)}&since=${encodeURIComponent(targetBranch)}&limit=1`;
119
121
  try {
120
122
  // 1. Check commits in sourceBranch not in targetBranch (i.e. whether targetBranch contains sourceBranch head commit)
121
- const commitsUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/commits?until=${encodeURIComponent(sourceBranch)}&since=${encodeURIComponent(targetBranch)}&limit=1`;
122
123
  const commitsRes = await httpClient.get(commitsUrl, { headers });
123
124
 
124
125
  if (commitsRes.status === 401) {
125
126
  result.error = 'Unauthorized. Please check your Bitbucket Access Token in Settings.';
127
+ recordApiError({ source: 'Bitbucket API (Branch Sync)', method: 'GET', url: commitsUrl, status: commitsRes.status, message: result.error });
126
128
  return result;
127
129
  }
128
130
 
129
131
  if (commitsRes.status < 200 || commitsRes.status >= 300) {
130
132
  const errDetail = commitsRes.data?.errors?.[0]?.message || commitsRes.data?.message || commitsRes.statusText;
131
133
  result.error = `Failed to check commits (${commitsRes.status}): ${errDetail}`;
134
+ recordApiError({ source: 'Bitbucket API (Branch Sync)', method: 'GET', url: commitsUrl, status: commitsRes.status, message: result.error });
132
135
  return result;
133
136
  }
134
137
 
@@ -141,8 +144,8 @@ async function checkBranchPair(parsedRepo, sourceBranch, targetBranch, headers)
141
144
  result.unmergedCommitCount = commitsRes.data?.size || commits.length;
142
145
 
143
146
  // 2. Check if an open PR already exists
147
+ const prUrl = `${protocol}://${host}/rest/api/latest/projects/${projectKey}/repos/${repositorySlug}/pull-requests?at=refs/heads/${encodeURIComponent(sourceBranch)}&direction=OUTGOING&state=OPEN`;
144
148
  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
149
  const prRes = await httpClient.get(prUrl, { headers });
147
150
  if (prRes.status >= 200 && prRes.status < 300) {
148
151
  const prs = prRes.data?.values || [];
@@ -157,12 +160,18 @@ async function checkBranchPair(parsedRepo, sourceBranch, targetBranch, headers)
157
160
  title: existingPr.title,
158
161
  url: existingPr.links?.self?.[0]?.href || `${protocol}://${host}/projects/${projectKey}/repos/${repositorySlug}/pull-requests/${existingPr.id}`
159
162
  };
160
- }
163
+ }
164
+ } else {
165
+ const message = `Failed to check open pull requests (${prRes.status}): ${prRes.data?.errors?.[0]?.message || prRes.data?.message || prRes.statusText}`;
166
+ recordApiError({ source: 'Bitbucket API (Branch Sync)', method: 'GET', url: prUrl, status: prRes.status, message });
161
167
  }
162
- } catch {}
168
+ } catch (err) {
169
+ recordApiError({ source: 'Bitbucket API (Branch Sync)', method: 'GET', url: prUrl, message: err.message || 'Failed to check open pull requests.' });
170
+ }
163
171
  }
164
172
  } catch (err) {
165
173
  result.error = err.message || 'Network error checking branch status.';
174
+ recordApiError({ source: 'Bitbucket API (Branch Sync)', method: 'GET', url: commitsUrl, message: result.error });
166
175
  }
167
176
 
168
177
  return result;
@@ -196,7 +205,7 @@ router.get('/branches', async (req, res) => {
196
205
  }
197
206
 
198
207
  try {
199
- const branchesUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/branches?limit=200`;
208
+ const branchesUrl = `${protocol}://${host}/rest/api/latest/projects/${projectKey}/repos/${repositorySlug}/branches?limit=200`;
200
209
  const response = await httpClient.get(branchesUrl, { headers });
201
210
 
202
211
  if (response.status >= 200 && response.status < 300) {
@@ -351,7 +360,7 @@ router.post('/create-pr', async (req, res) => {
351
360
  };
352
361
 
353
362
  try {
354
- const prUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/pull-requests`;
363
+ const prUrl = `${protocol}://${host}/rest/api/latest/projects/${projectKey}/repos/${repositorySlug}/pull-requests`;
355
364
  const prRes = await httpClient.post(prUrl, prPayload, { headers });
356
365
 
357
366
  if (prRes.status >= 200 && prRes.status < 300) {
@@ -389,7 +398,7 @@ async function createBranchForRepo(repoStr, sourceBranch, targetBranch, headers)
389
398
  }
390
399
 
391
400
  const startPoint = sourceBranch.startsWith('refs/') ? sourceBranch : `refs/heads/${sourceBranch}`;
392
- const branchUrl = `${protocol}://${host}/rest/api/1.0/projects/${projectKey}/repos/${repositorySlug}/branches`;
401
+ const branchUrl = `${protocol}://${host}/rest/api/latest/projects/${projectKey}/repos/${repositorySlug}/branches`;
393
402
  const payload = {
394
403
  name: targetBranch,
395
404
  startPoint
@@ -401,9 +410,12 @@ async function createBranchForRepo(repoStr, sourceBranch, targetBranch, headers)
401
410
  return { success: true, branch: targetBranch, id: res.data?.id };
402
411
  }
403
412
  const errDetail = res.data?.errors?.[0]?.message || res.data?.message || `Bitbucket returned status ${res.status}: ${res.statusText}`;
413
+ recordApiError({ source: 'Bitbucket API (Branch Sync)', method: 'POST', url: branchUrl, status: res.status, message: errDetail });
404
414
  return { success: false, error: errDetail };
405
415
  } catch (err) {
406
- return { success: false, error: err.message || 'Network error creating branch.' };
416
+ const message = err.message || 'Network error creating branch.';
417
+ recordApiError({ source: 'Bitbucket API (Branch Sync)', method: 'POST', url: branchUrl, message });
418
+ return { success: false, error: message };
407
419
  }
408
420
  }
409
421
 
@@ -1,6 +1,7 @@
1
1
  import https from 'node:https';
2
2
  import axios from 'axios';
3
3
  import { Router } from 'express';
4
+ import { recordApiError } from '../lib/api-errors.js';
4
5
  import { readSettings } from '../repositories/settings.js';
5
6
  import { listJiraFilters, saveJiraFilters, listJiraTemplates, saveJiraTemplates, listRecentlyCreatedJiraIssues, saveRecentlyCreatedJiraIssues } from '../repositories/jira-filters.js';
6
7
 
@@ -253,9 +254,12 @@ router.post('/issues/clone', async (req, res) => {
253
254
  try {
254
255
  // Fetch current authenticated user's info from Jira
255
256
  let assigneeField = null;
257
+ const myselfUrl = `https://${jiraHost}/rest/api/2/myself`;
256
258
  try {
257
- const myselfUrl = `https://${jiraHost}/rest/api/2/myself`;
258
259
  const myselfRes = await httpClient.get(myselfUrl, { headers });
260
+ if (myselfRes.status < 200 || myselfRes.status >= 300) {
261
+ recordApiError({ source: 'Jira API (Clone Issue)', method: 'GET', url: myselfUrl, status: myselfRes.status, message: `Jira user API returned ${myselfRes.status}: ${myselfRes.statusText || 'Failed to fetch current user.'}` });
262
+ }
259
263
  if (myselfRes.status === 200 && myselfRes.data) {
260
264
  if (myselfRes.data.accountId) {
261
265
  assigneeField = { accountId: myselfRes.data.accountId };
@@ -263,8 +267,8 @@ router.post('/issues/clone', async (req, res) => {
263
267
  assigneeField = { name: myselfRes.data.name };
264
268
  }
265
269
  }
266
- } catch {
267
- // Ignore if fetching current user fails
270
+ } catch (error) {
271
+ recordApiError({ source: 'Jira API (Clone Issue)', method: 'GET', url: myselfUrl, message: error.message || 'Failed to fetch current user.' });
268
272
  }
269
273
 
270
274
  // Fetch original issue details to preserve issue type, project, description, priority
@@ -275,9 +279,12 @@ router.post('/issues/clone', async (req, res) => {
275
279
  let origPriority = null;
276
280
 
277
281
  if (issueKey) {
282
+ const issueUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}`;
278
283
  try {
279
- const issueUrl = `https://${jiraHost}/rest/api/2/issue/${encodeURIComponent(issueKey)}`;
280
284
  const origRes = await httpClient.get(issueUrl, { headers });
285
+ if (origRes.status < 200 || origRes.status >= 300) {
286
+ recordApiError({ source: 'Jira API (Clone Issue)', method: 'GET', url: issueUrl, status: origRes.status, message: `Jira issue API returned ${origRes.status}: ${origRes.statusText || 'Failed to fetch original issue.'}` });
287
+ }
281
288
  if (origRes.status === 200 && origRes.data?.fields) {
282
289
  const fields = origRes.data.fields;
283
290
  if (fields.issuetype) {
@@ -304,8 +311,8 @@ router.post('/issues/clone', async (req, res) => {
304
311
  }
305
312
  }
306
313
  }
307
- } catch {
308
- // Ignore if fetching original issue fails
314
+ } catch (error) {
315
+ recordApiError({ source: 'Jira API (Clone Issue)', method: 'GET', url: issueUrl, message: error.message || 'Failed to fetch original issue.' });
309
316
  }
310
317
  }
311
318
 
@@ -353,11 +360,14 @@ router.post('/issues/clone', async (req, res) => {
353
360
 
354
361
  // Fallback: Assign explicitly if not assigned during creation
355
362
  if (createdKey && assigneeField) {
363
+ const assignUrl = `https://${jiraHost}/rest/api/2/issue/${createdKey}/assignee`;
356
364
  try {
357
- const assignUrl = `https://${jiraHost}/rest/api/2/issue/${createdKey}/assignee`;
358
- await httpClient.put(assignUrl, assigneeField, { headers });
359
- } catch {
360
- // Ignore fallback assign error
365
+ const assignRes = await httpClient.put(assignUrl, assigneeField, { headers });
366
+ if (assignRes.status < 200 || assignRes.status >= 300) {
367
+ recordApiError({ source: 'Jira API (Clone Issue)', method: 'PUT', url: assignUrl, status: assignRes.status, message: `Jira assignee API returned ${assignRes.status}: ${assignRes.statusText || 'Failed to assign issue.'}` });
368
+ }
369
+ } catch (error) {
370
+ recordApiError({ source: 'Jira API (Clone Issue)', method: 'PUT', url: assignUrl, message: error.message || 'Failed to assign issue.' });
361
371
  }
362
372
  }
363
373
 
@@ -0,0 +1,219 @@
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 { listTodos } from '../repositories/todos.js';
6
+ import { listBranchSyncApps } from '../repositories/branch-sync.js';
7
+ import { recordApiError } from '../lib/api-errors.js';
8
+
9
+ const router = Router();
10
+ const httpClient = axios.create({
11
+ httpsAgent: new https.Agent({ rejectUnauthorized: false }),
12
+ validateStatus: () => true,
13
+ timeout: 15000
14
+ });
15
+
16
+ const isSuccess = (response) => response.status >= 200 && response.status < 300;
17
+ const apiMessage = (response, fallback) => response?.data?.errors?.[0]?.message || response?.data?.errorMessages?.[0] || response?.data?.message || response?.statusText || fallback;
18
+
19
+ function jiraHost(domain) {
20
+ const clean = String(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, '');
21
+ if (!clean) return '';
22
+ return clean.includes('jira') || clean.includes('.atlassian.net') ? clean : `jira.${clean}`;
23
+ }
24
+
25
+ function bitbucketHost(domain) {
26
+ const clean = String(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, '');
27
+ if (!clean) return '';
28
+ return clean.includes('bitbucket') ? clean : `bitbucket.${clean}`;
29
+ }
30
+
31
+ function parseRepo(repo) {
32
+ const raw = String(repo || '').replace(/\.git$/i, '');
33
+ try {
34
+ const url = new URL(raw.startsWith('ssh://') ? raw.replace(/^ssh:\/\//i, 'https://') : raw);
35
+ const parts = url.pathname.split('/').filter(Boolean);
36
+ if (parts[0] === 'scm' && parts.length >= 3) return { host: url.host, project: parts[1], slug: parts[2] };
37
+ 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] };
38
+ if (parts.length >= 2) return { host: url.host, project: parts.at(-2), slug: parts.at(-1) };
39
+ } catch {}
40
+ return null;
41
+ }
42
+
43
+ function jiraUrl(host, path) { return `https://${host}/rest/api/2${path}`; }
44
+ function bitbucketUrl(host, path) { return `https://${host}/rest/api/latest${path}`; }
45
+
46
+ async function requestSection(name, loader) {
47
+ try {
48
+ return { items: await loader() };
49
+ } catch (error) {
50
+ recordApiError({ source: `Overview (${name})`, message: error.message || `Failed to load ${name}.` });
51
+ return { items: [], error: error.message || `Failed to load ${name}.` };
52
+ }
53
+ }
54
+
55
+ async function searchJira(host, token, jql, fields, maxResults = 25) {
56
+ const url = jiraUrl(host, `/search?jql=${encodeURIComponent(jql)}&maxResults=${maxResults}&fields=${encodeURIComponent(fields)}`);
57
+ const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
58
+ if (!isSuccess(response)) throw new Error(`Jira search failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
59
+ return response.data?.issues || [];
60
+ }
61
+
62
+ function mapJiraIssue(issue) {
63
+ const fields = issue.fields || {};
64
+ return {
65
+ id: issue.id,
66
+ key: issue.key,
67
+ summary: fields.summary || issue.key,
68
+ status: fields.status?.name || '',
69
+ priority: fields.priority?.name || '',
70
+ dueDate: fields.duedate || null,
71
+ updated: fields.updated || null,
72
+ url: null
73
+ };
74
+ }
75
+
76
+ function issueUrl(host, key) { return `https://${host}/browse/${encodeURIComponent(key)}`; }
77
+
78
+ async function loadJiraData(host, token) {
79
+ const headers = { Accept: 'application/json', Authorization: `Bearer ${token}` };
80
+ const meUrl = jiraUrl(host, '/myself');
81
+ const meResponse = await httpClient.get(meUrl, { headers });
82
+ if (!isSuccess(meResponse)) throw new Error(`Jira identity lookup failed (${meResponse.status}): ${apiMessage(meResponse, 'Request failed')}`);
83
+ const me = meResponse.data || {};
84
+ const identity = { accountId: me.accountId || '', name: me.name || '', displayName: me.displayName || '' };
85
+ const fields = 'summary,status,priority,duedate,updated,assignee,reporter';
86
+
87
+ const [due, activity, blocked] = await Promise.all([
88
+ searchJira(host, token, 'assignee = currentUser() AND due = startOfDay() AND resolution = Unresolved ORDER BY priority DESC', fields),
89
+ searchJira(host, token, 'updated >= -14d AND resolution = Unresolved AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC', fields, 30),
90
+ searchJira(host, token, 'status = Blocked AND updated <= -3d AND resolution = Unresolved ORDER BY updated ASC', fields)
91
+ ]);
92
+
93
+ const activityItems = [];
94
+ for (const issue of activity.slice(0, 15)) {
95
+ const key = issue.key;
96
+ const commentsUrl = jiraUrl(host, `/issue/${encodeURIComponent(key)}/comment?orderBy=-updated&maxResults=10`);
97
+ const commentsResponse = await httpClient.get(commentsUrl, { headers });
98
+ if (!isSuccess(commentsResponse)) {
99
+ recordApiError({ source: 'Overview (Jira Activity)', method: 'GET', url: commentsUrl, status: commentsResponse.status, message: apiMessage(commentsResponse, 'Failed to fetch comments.') });
100
+ continue;
101
+ }
102
+ const comments = commentsResponse.data?.comments || [];
103
+ const relevant = comments.find((comment) => {
104
+ const author = comment.author || {};
105
+ const body = typeof comment.body === 'string' ? comment.body : JSON.stringify(comment.body || '');
106
+ const mentionsMe = [identity.name, identity.accountId, identity.displayName].filter(Boolean).some((value) => body.includes(value));
107
+ const writtenBySomeoneElse = author.name !== identity.name && author.accountId !== identity.accountId;
108
+ return mentionsMe || writtenBySomeoneElse;
109
+ });
110
+ if (relevant) {
111
+ activityItems.push({
112
+ ...mapJiraIssue(issue),
113
+ activityType: [identity.name, identity.accountId, identity.displayName].filter(Boolean).some((value) => String(relevant.body || '').includes(value)) ? 'Mentioned' : 'Commented',
114
+ activityAt: relevant.updated || relevant.created || issue.fields?.updated
115
+ });
116
+ }
117
+ }
118
+
119
+ return {
120
+ identity,
121
+ dueToday: due.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) })),
122
+ activity: activityItems.map((item) => ({ ...item, url: issueUrl(host, item.key) })),
123
+ blocked: blocked.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) }))
124
+ };
125
+ }
126
+
127
+ async function loadPullRequests(host, token, role) {
128
+ const url = `https://${host}/rest/api/latest/dashboard/pull-requests?role=${role}&state=OPEN&limit=100`;
129
+ const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
130
+ if (!isSuccess(response)) throw new Error(`Bitbucket ${role} pull requests failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
131
+ return (response.data?.values || []).map((pr) => ({
132
+ id: pr.id,
133
+ title: pr.title || `Pull request #${pr.id}`,
134
+ author: pr.author?.user?.displayName || pr.author?.displayName || '',
135
+ repository: pr.toRef?.repository?.name || pr.toRef?.repository?.slug || '',
136
+ updated: pr.updatedDate ? new Date(pr.updatedDate).toISOString() : null,
137
+ url: pr.links?.self?.[0]?.href || `https://${host}/projects/${pr.toRef?.repository?.project?.key || ''}/repos/${pr.toRef?.repository?.slug || ''}/pull-requests/${pr.id}`
138
+ }));
139
+ }
140
+
141
+ async function loadFailedBuilds(host, token) {
142
+ const settings = readSettings();
143
+ const apps = listBranchSyncApps();
144
+ const repos = [];
145
+ for (const app of apps) {
146
+ for (const branch of [...new Set([app.activeBranch, app.lastReleaseBranch, 'master'].filter(Boolean))]) {
147
+ repos.push({ name: app.name, repo: app.repo, branch });
148
+ }
149
+ for (const sub of app.subApps || []) {
150
+ for (const branch of [...new Set([sub.branch, app.activeBranch, 'master'].filter(Boolean))]) {
151
+ repos.push({ name: sub.name || sub.directory, repo: sub.repo, branch });
152
+ }
153
+ }
154
+ }
155
+ const unique = [...new Map(repos.map((item) => [`${item.repo}|${item.branch}`, item])).values()];
156
+ const failed = [];
157
+ for (const item of unique.slice(0, 30)) {
158
+ const parsed = parseRepo(item.repo);
159
+ if (!parsed || (parsed.host && parsed.host !== host && !parsed.host.includes('bitbucket'))) continue;
160
+ const commitUrl = bitbucketUrl(parsed.host || host, `/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/commits?until=${encodeURIComponent(item.branch)}&limit=1`);
161
+ const commitResponse = await httpClient.get(commitUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
162
+ if (!isSuccess(commitResponse)) {
163
+ recordApiError({ source: 'Overview (Bitbucket Builds)', method: 'GET', url: commitUrl, status: commitResponse.status, message: apiMessage(commitResponse, 'Failed to fetch branch commit.') });
164
+ continue;
165
+ }
166
+ const hash = commitResponse.data?.values?.[0]?.id;
167
+ if (!hash) continue;
168
+ const buildUrl = `https://${parsed.host || host}/rest/api/latest/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/commits/${encodeURIComponent(hash)}/builds`;
169
+ const buildResponse = await httpClient.get(buildUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
170
+ if (!isSuccess(buildResponse)) {
171
+ if (buildResponse.status !== 404) recordApiError({ source: 'Overview (Bitbucket Builds)', method: 'GET', url: buildUrl, status: buildResponse.status, message: apiMessage(buildResponse, 'Failed to fetch build status.') });
172
+ continue;
173
+ }
174
+ for (const build of buildResponse.data?.values || []) {
175
+ if (String(build.state || '').toUpperCase() === 'FAILED') {
176
+ failed.push({ name: item.name, branch: item.branch, repository: item.repo, build: build.name || build.key || 'Build', date: build.dateAdded || null, url: item.repo });
177
+ }
178
+ }
179
+ }
180
+ return failed;
181
+ }
182
+
183
+ router.get('/', async (_req, res) => {
184
+ const settings = readSettings();
185
+ const missingTokens = ['jira', 'bitbucket', 'confluence'].filter((service) => !settings[`${service}AccessToken`]);
186
+ if (missingTokens.length > 0) {
187
+ return res.json({ configured: false, missingTokens, fetchedAt: new Date().toISOString(), categories: {} });
188
+ }
189
+ const jira = jiraHost(settings.domain);
190
+ const bitbucket = bitbucketHost(settings.domain);
191
+ if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: {} });
192
+
193
+ const sections = await Promise.all([
194
+ requestSection('Jira', () => loadJiraData(jira, settings.jiraAccessToken)),
195
+ requestSection('Bitbucket Review', () => loadPullRequests(bitbucket, settings.bitbucketAccessToken, 'reviewer')),
196
+ requestSection('Bitbucket Author', () => loadPullRequests(bitbucket, settings.bitbucketAccessToken, 'author')),
197
+ requestSection('Bitbucket Builds', () => loadFailedBuilds(bitbucket, settings.bitbucketAccessToken))
198
+ ]);
199
+ const [jiraData, reviewPrs, authoredPrs, failedBuilds] = sections;
200
+ const todos = listTodos().filter((todo) => !todo.completed && !todo.archived).map((todo) => ({ ...todo, url: '#/todo-list' }));
201
+ const errors = sections.filter((section) => section.error).map((section) => section.error);
202
+ res.json({
203
+ configured: true,
204
+ missingTokens: [],
205
+ fetchedAt: new Date().toISOString(),
206
+ categories: {
207
+ jiraDueToday: jiraData.items?.dueToday || [],
208
+ assignedTodos: todos,
209
+ reviewPrs: reviewPrs.items || [],
210
+ authoredPrs: authoredPrs.items || [],
211
+ failedBuilds: failedBuilds.items || [],
212
+ activity: jiraData.items?.activity || [],
213
+ blockedIssues: jiraData.items?.blocked || []
214
+ },
215
+ errors
216
+ });
217
+ });
218
+
219
+ export default router;
@@ -6,6 +6,7 @@ import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
7
  import { readSettings } from '../repositories/settings.js';
8
8
  import { readPackageUpgradeData, savePackageUpgradeData } from '../repositories/package-upgrade.js';
9
+ import { recordApiError } from '../lib/api-errors.js';
9
10
 
10
11
  const router = Router();
11
12
  const exec = promisify(execFile);
@@ -80,12 +81,13 @@ async function runRepo(task, repo) {
80
81
  const parsed = parseBitbucket(repo.url); const settings = readSettings();
81
82
  if (!parsed) throw new Error('Could not parse Bitbucket repository URL');
82
83
  const headers = { Accept: 'application/json', 'Content-Type': 'application/json' }; if (settings.bitbucketAccessToken) headers.Authorization = `Bearer ${settings.bitbucketAccessToken}`;
83
- const api = `${parsed.protocol}://${parsed.host}/rest/api/1.0/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/pull-requests`;
84
+ const api = `${parsed.protocol}://${parsed.host}/rest/api/latest/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/pull-requests`;
84
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 });
85
86
  if (response.status < 200 || response.status >= 300) throw new Error(response.data?.errors?.[0]?.message || `Bitbucket PR failed (${response.status})`);
86
87
  const prUrl = response.data?.links?.self?.[0]?.href || `${parsed.protocol}://${parsed.host}/projects/${parsed.project}/repos/${parsed.slug}/pull-requests/${response.data.id}`;
87
88
  updateTask(task.id, (t) => ({ ...t, repos: t.repos.map((r) => r.repoId === repo.id ? { ...result, status: 'success', prUrl } : r) }));
88
89
  } catch (error) {
90
+ recordApiError({ source: `Package Upgrade (${name})`, message: error.stderr || error.message || 'Package upgrade task failed.', details: `Task: ${task.id}\nRepository: ${repo.url}` });
89
91
  updateTask(task.id, (t) => ({ ...t, repos: t.repos.map((r) => r.repoId === repo.id ? { ...result, status: 'failed', message: error.stderr || error.message } : r) }));
90
92
  }
91
93
  }
@@ -1,6 +1,7 @@
1
1
  import { Router } from 'express';
2
2
  import https from 'node:https';
3
3
  import axios from 'axios';
4
+ import { recordApiError } from '../lib/api-errors.js';
4
5
  import { getPostmanData, savePostmanData } from '../repositories/postman.js';
5
6
  import { parsePostmanCollection } from '../services/postman-parser.js';
6
7
 
@@ -252,6 +253,11 @@ router.post('/send', async (req, res) => {
252
253
  maxContentLength: 10 * 1024 * 1024 // 10MB limit
253
254
  });
254
255
 
256
+ if (response.status >= 400) {
257
+ const upstreamMessage = response.data?.error || response.data?.message || response.statusText || `Upstream API returned ${response.status}`;
258
+ recordApiError({ source: 'Postman Upstream API', method, url: resolvedUrl, status: response.status, message: upstreamMessage });
259
+ }
260
+
255
261
  const durationMs = Date.now() - startTime;
256
262
  let responseBody = response.data;
257
263
  const responseHeaders = response.headers || {};
@@ -2,6 +2,7 @@ import https from 'node:https';
2
2
  import axios from 'axios';
3
3
  import { Router } from 'express';
4
4
  import { readSettings } from '../repositories/settings.js';
5
+ import { recordApiError } from '../lib/api-errors.js';
5
6
 
6
7
  const router = Router();
7
8
  const previewLimit = 2000;
@@ -416,7 +417,11 @@ router.post('/check', async (req, res) => {
416
417
  if (sinceCommit) diffUrl += `&since=${encodeURIComponent(sinceCommit)}`;
417
418
 
418
419
  const diffRes = await httpClient.get(diffUrl, { headers });
419
- if (diffRes.status < 200 || diffRes.status >= 300) continue; // skip file if diff cannot be retrieved
420
+ if (diffRes.status < 200 || diffRes.status >= 300) {
421
+ const message = `Bitbucket diff API returned ${diffRes.status}: ${diffRes.statusText || 'Failed to retrieve diff'}`;
422
+ recordApiError({ source: 'Bitbucket API (PR Review)', method: 'GET', url: diffUrl, status: diffRes.status, message });
423
+ continue;
424
+ }
420
425
 
421
426
  const diffData = diffRes.data;
422
427
  let hunks = diffData.hunks;
package/server.js CHANGED
@@ -25,6 +25,7 @@ import updateRoutes from './server/routes/updates.js';
25
25
  import dataBackupRoutes from './server/routes/data-backup.js';
26
26
  import presentationsRoutes from './server/routes/presentations.js';
27
27
  import packageUpgradeRoutes from './server/routes/package-upgrade.js';
28
+ import overviewRoutes from './server/routes/overview.js';
28
29
  import { addErrorRecord } from './server/repositories/errors.js';
29
30
  import { startClipboardCapture } from './server/services/clipboard-history.js';
30
31
  import { compression } from './server/middleware/compression.js';
@@ -49,7 +50,7 @@ app.use((req, res, next) => {
49
50
  app.use((req, res, next) => {
50
51
  const originalJson = res.json;
51
52
  res.json = function (body) {
52
- if (res.statusCode >= 400 && body && body.error && !req.path.startsWith('/api/errors')) {
53
+ if (res.statusCode >= 400 && !req.path.startsWith('/api/errors')) {
53
54
  const host = req.get('host') || req.headers.host || `localhost:${port}`;
54
55
  const fullUrl = `${req.protocol}://${host}${req.originalUrl || req.url}`;
55
56
 
@@ -59,13 +60,14 @@ app.use((req, res, next) => {
59
60
  }
60
61
  detailsLines.push(`Endpoint: ${req.method} ${fullUrl}`);
61
62
  detailsLines.push(`HTTP Status: ${res.statusCode}`);
62
- if (typeof body.error === 'string' && body.error !== `HTTP ${res.statusCode} Error`) {
63
- detailsLines.push(`Error: ${body.error}`);
63
+ const responseError = body?.error;
64
+ if (typeof responseError === 'string' && responseError !== `HTTP ${res.statusCode} Error`) {
65
+ detailsLines.push(`Error: ${responseError}`);
64
66
  }
65
67
 
66
68
  addErrorRecord({
67
69
  source: `Backend API (${req.method} ${req.path})`,
68
- message: typeof body.error === 'string' ? body.error : `HTTP ${res.statusCode} Error`,
70
+ message: typeof responseError === 'string' ? responseError : `HTTP ${res.statusCode} Error`,
69
71
  details: detailsLines.join('\n')
70
72
  });
71
73
  }
@@ -98,6 +100,7 @@ app.use('/api/updates', updateRoutes);
98
100
  app.use('/api/data-backup', dataBackupRoutes);
99
101
  app.use('/api/presentations', presentationsRoutes);
100
102
  app.use('/api/package-upgrade', packageUpgradeRoutes);
103
+ app.use('/api/overview', overviewRoutes);
101
104
 
102
105
  app.use((err, req, res, _next) => {
103
106
  addErrorRecord({