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 +1 -1
- package/package.json +1 -1
- package/server/lib/api-errors.js +16 -0
- package/server/routes/branch-sync.js +20 -8
- package/server/routes/jira-filters.js +20 -10
- package/server/routes/overview.js +219 -0
- package/server/routes/package-upgrade.js +3 -1
- package/server/routes/postman.js +6 -0
- package/server/routes/pr-review.js +6 -1
- package/server.js +7 -4
- package/ui/dist/assets/{index-C_q1fqPR.js → index-B3VoISa3.js} +129 -129
- package/ui/dist/assets/index-K9g69_HI.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/sw.js +2 -2
- package/plugins/example/plugin.json +0 -5
- package/plugins/example/view.html +0 -5
- package/ui/dist/assets/index-WmLQHnd7.css +0 -1
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.
|
|
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
|
@@ -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/
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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/
|
|
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
|
}
|
package/server/routes/postman.js
CHANGED
|
@@ -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)
|
|
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 &&
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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({
|