buddy-workbench 0.1.71 → 0.1.73
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.
|
|
3
|
+
"version": "0.1.73",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -59,5 +59,8 @@
|
|
|
59
59
|
"@jsquash/jpeg": "1.6.0",
|
|
60
60
|
"axios": "1.7.9",
|
|
61
61
|
"express": "5.1.0"
|
|
62
|
+
},
|
|
63
|
+
"overrides": {
|
|
64
|
+
"content-type": "2.0.0"
|
|
62
65
|
}
|
|
63
66
|
}
|
|
@@ -3,8 +3,6 @@ import axios from 'axios';
|
|
|
3
3
|
import { Router } from 'express';
|
|
4
4
|
import { readSettings } from '../repositories/settings.js';
|
|
5
5
|
import { listTodos } from '../repositories/todos.js';
|
|
6
|
-
import { listBranchSyncApps } from '../repositories/branch-sync.js';
|
|
7
|
-
import { getPresentations } from '../repositories/presentations.js';
|
|
8
6
|
import { recordApiError } from '../lib/api-errors.js';
|
|
9
7
|
|
|
10
8
|
const router = Router();
|
|
@@ -16,16 +14,6 @@ const httpClient = axios.create({
|
|
|
16
14
|
|
|
17
15
|
const isSuccess = (response) => response.status >= 200 && response.status < 300;
|
|
18
16
|
const apiMessage = (response, fallback) => response?.data?.errors?.[0]?.message || response?.data?.errorMessages?.[0] || response?.data?.message || response?.statusText || fallback;
|
|
19
|
-
const getPresentationInsights = () => getPresentations().plans
|
|
20
|
-
.slice()
|
|
21
|
-
.sort((a, b) => Number(b.updatedAt || b.createdAt || 0) - Number(a.updatedAt || a.createdAt || 0))
|
|
22
|
-
.map((plan) => ({
|
|
23
|
-
id: plan.id,
|
|
24
|
-
title: plan.title || plan.name || 'Untitled presentation',
|
|
25
|
-
summary: `${Array.isArray(plan.sections) ? plan.sections.length : 0} sections`,
|
|
26
|
-
updated: plan.updatedAt || plan.createdAt || null,
|
|
27
|
-
url: '#/presentation-plan'
|
|
28
|
-
}));
|
|
29
17
|
|
|
30
18
|
function jiraHost(domain) {
|
|
31
19
|
const clean = String(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, '');
|
|
@@ -39,20 +27,7 @@ function bitbucketHost(domain) {
|
|
|
39
27
|
return clean.includes('bitbucket') ? clean : `bitbucket.${clean}`;
|
|
40
28
|
}
|
|
41
29
|
|
|
42
|
-
function parseRepo(repo) {
|
|
43
|
-
const raw = String(repo || '').replace(/\.git$/i, '');
|
|
44
|
-
try {
|
|
45
|
-
const url = new URL(raw.startsWith('ssh://') ? raw.replace(/^ssh:\/\//i, 'https://') : raw);
|
|
46
|
-
const parts = url.pathname.split('/').filter(Boolean);
|
|
47
|
-
if (parts[0] === 'scm' && parts.length >= 3) return { host: url.host, project: parts[1], slug: parts[2] };
|
|
48
|
-
if ((parts[0] === 'projects' || parts[0] === 'users') && parts.length >= 4) return { host: url.host, project: parts[0] === 'users' ? `~${parts[1]}` : parts[1], slug: parts[3] };
|
|
49
|
-
if (parts.length >= 2) return { host: url.host, project: parts.at(-2), slug: parts.at(-1) };
|
|
50
|
-
} catch {}
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
30
|
function jiraUrl(host, path) { return `https://${host}/rest/api/2${path}`; }
|
|
55
|
-
function bitbucketUrl(host, path) { return `https://${host}/rest/api/latest${path}`; }
|
|
56
31
|
|
|
57
32
|
async function requestSection(name, loader) {
|
|
58
33
|
try {
|
|
@@ -95,10 +70,9 @@ async function loadJiraData(host, token) {
|
|
|
95
70
|
const identity = { accountId: me.accountId || '', name: me.name || '', displayName: me.displayName || '' };
|
|
96
71
|
const fields = 'summary,status,priority,duedate,updated,assignee,reporter';
|
|
97
72
|
|
|
98
|
-
const [due, activity
|
|
73
|
+
const [due, activity] = await Promise.all([
|
|
99
74
|
searchJira(host, token, 'assignee = currentUser() AND due = startOfDay() AND resolution = Unresolved ORDER BY priority DESC', fields),
|
|
100
|
-
searchJira(host, token, 'updated >= -14d AND resolution = Unresolved AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC', fields, 30)
|
|
101
|
-
searchJira(host, token, 'status = Blocked AND updated <= -3d AND resolution = Unresolved ORDER BY updated ASC', fields)
|
|
75
|
+
searchJira(host, token, 'updated >= -14d AND resolution = Unresolved AND (assignee = currentUser() OR reporter = currentUser() OR watcher = currentUser()) ORDER BY updated DESC', fields, 30)
|
|
102
76
|
]);
|
|
103
77
|
|
|
104
78
|
const activityItems = [];
|
|
@@ -130,15 +104,14 @@ async function loadJiraData(host, token) {
|
|
|
130
104
|
return {
|
|
131
105
|
identity,
|
|
132
106
|
dueToday: due.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) })),
|
|
133
|
-
activity: activityItems.map((item) => ({ ...item, url: issueUrl(host, item.key) }))
|
|
134
|
-
blocked: blocked.map((issue) => ({ ...mapJiraIssue(issue), url: issueUrl(host, issue.key) }))
|
|
107
|
+
activity: activityItems.map((item) => ({ ...item, url: issueUrl(host, item.key) }))
|
|
135
108
|
};
|
|
136
109
|
}
|
|
137
110
|
|
|
138
|
-
async function
|
|
139
|
-
const url = `https://${host}/rest/api/latest/dashboard/pull-requests?role
|
|
111
|
+
async function loadAuthoredPullRequests(host, token) {
|
|
112
|
+
const url = `https://${host}/rest/api/latest/dashboard/pull-requests?role=author&state=OPEN&limit=100`;
|
|
140
113
|
const response = await httpClient.get(url, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
|
|
141
|
-
if (!isSuccess(response)) throw new Error(`Bitbucket
|
|
114
|
+
if (!isSuccess(response)) throw new Error(`Bitbucket author pull requests failed (${response.status}): ${apiMessage(response, 'Request failed')}`);
|
|
142
115
|
return (response.data?.values || []).map((pr) => ({
|
|
143
116
|
id: pr.id,
|
|
144
117
|
title: pr.title || `Pull request #${pr.id}`,
|
|
@@ -149,65 +122,21 @@ async function loadPullRequests(host, token, role) {
|
|
|
149
122
|
}));
|
|
150
123
|
}
|
|
151
124
|
|
|
152
|
-
async function loadFailedBuilds(host, token) {
|
|
153
|
-
const settings = readSettings();
|
|
154
|
-
const apps = listBranchSyncApps();
|
|
155
|
-
const repos = [];
|
|
156
|
-
for (const app of apps) {
|
|
157
|
-
for (const branch of [...new Set([app.activeBranch, app.lastReleaseBranch, 'master'].filter(Boolean))]) {
|
|
158
|
-
repos.push({ name: app.name, repo: app.repo, branch });
|
|
159
|
-
}
|
|
160
|
-
for (const sub of app.subApps || []) {
|
|
161
|
-
for (const branch of [...new Set([sub.branch, app.activeBranch, 'master'].filter(Boolean))]) {
|
|
162
|
-
repos.push({ name: sub.name || sub.directory, repo: sub.repo, branch });
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
const unique = [...new Map(repos.map((item) => [`${item.repo}|${item.branch}`, item])).values()];
|
|
167
|
-
const failed = [];
|
|
168
|
-
for (const item of unique.slice(0, 30)) {
|
|
169
|
-
const parsed = parseRepo(item.repo);
|
|
170
|
-
if (!parsed || (parsed.host && parsed.host !== host && !parsed.host.includes('bitbucket'))) continue;
|
|
171
|
-
const commitUrl = bitbucketUrl(parsed.host || host, `/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/commits?until=${encodeURIComponent(item.branch)}&limit=1`);
|
|
172
|
-
const commitResponse = await httpClient.get(commitUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
|
|
173
|
-
if (!isSuccess(commitResponse)) {
|
|
174
|
-
recordApiError({ source: 'Overview (Bitbucket Builds)', method: 'GET', url: commitUrl, status: commitResponse.status, message: apiMessage(commitResponse, 'Failed to fetch branch commit.') });
|
|
175
|
-
continue;
|
|
176
|
-
}
|
|
177
|
-
const hash = commitResponse.data?.values?.[0]?.id;
|
|
178
|
-
if (!hash) continue;
|
|
179
|
-
const buildUrl = `https://${parsed.host || host}/rest/api/latest/projects/${encodeURIComponent(parsed.project)}/repos/${encodeURIComponent(parsed.slug)}/commits/${encodeURIComponent(hash)}/builds`;
|
|
180
|
-
const buildResponse = await httpClient.get(buildUrl, { headers: { Accept: 'application/json', Authorization: `Bearer ${token}` } });
|
|
181
|
-
if (!isSuccess(buildResponse)) {
|
|
182
|
-
if (buildResponse.status !== 404) recordApiError({ source: 'Overview (Bitbucket Builds)', method: 'GET', url: buildUrl, status: buildResponse.status, message: apiMessage(buildResponse, 'Failed to fetch build status.') });
|
|
183
|
-
continue;
|
|
184
|
-
}
|
|
185
|
-
for (const build of buildResponse.data?.values || []) {
|
|
186
|
-
if (String(build.state || '').toUpperCase() === 'FAILED') {
|
|
187
|
-
failed.push({ name: item.name, branch: item.branch, repository: item.repo, build: build.name || build.key || 'Build', date: build.dateAdded || null, url: item.repo });
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
return failed;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
125
|
router.get('/', async (_req, res) => {
|
|
195
126
|
const settings = readSettings();
|
|
196
|
-
const missingTokens = ['jira', 'bitbucket'
|
|
127
|
+
const missingTokens = ['jira', 'bitbucket'].filter((service) => !settings[`${service}AccessToken`]);
|
|
197
128
|
if (missingTokens.length > 0) {
|
|
198
|
-
return res.json({ configured: false, missingTokens, fetchedAt: new Date().toISOString(), categories: {
|
|
129
|
+
return res.json({ configured: false, missingTokens, fetchedAt: new Date().toISOString(), categories: {} });
|
|
199
130
|
}
|
|
200
131
|
const jira = jiraHost(settings.domain);
|
|
201
132
|
const bitbucket = bitbucketHost(settings.domain);
|
|
202
|
-
if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: {
|
|
133
|
+
if (!jira || !bitbucket) return res.json({ configured: false, missingTokens: ['domain'], fetchedAt: new Date().toISOString(), categories: {} });
|
|
203
134
|
|
|
204
135
|
const sections = await Promise.all([
|
|
205
136
|
requestSection('Jira', () => loadJiraData(jira, settings.jiraAccessToken)),
|
|
206
|
-
requestSection('Bitbucket
|
|
207
|
-
requestSection('Bitbucket Author', () => loadPullRequests(bitbucket, settings.bitbucketAccessToken, 'author')),
|
|
208
|
-
requestSection('Bitbucket Builds', () => loadFailedBuilds(bitbucket, settings.bitbucketAccessToken))
|
|
137
|
+
requestSection('Bitbucket Author', () => loadAuthoredPullRequests(bitbucket, settings.bitbucketAccessToken))
|
|
209
138
|
]);
|
|
210
|
-
const [jiraData,
|
|
139
|
+
const [jiraData, authoredPrs] = sections;
|
|
211
140
|
const todos = listTodos().filter((todo) => !todo.completed && !todo.archived).map((todo) => ({ ...todo, url: '#/todo-list' }));
|
|
212
141
|
const errors = sections.filter((section) => section.error).map((section) => section.error);
|
|
213
142
|
res.json({
|
|
@@ -215,14 +144,10 @@ router.get('/', async (_req, res) => {
|
|
|
215
144
|
missingTokens: [],
|
|
216
145
|
fetchedAt: new Date().toISOString(),
|
|
217
146
|
categories: {
|
|
147
|
+
activity: jiraData.items?.activity || [],
|
|
218
148
|
jiraDueToday: jiraData.items?.dueToday || [],
|
|
219
149
|
assignedTodos: todos,
|
|
220
|
-
|
|
221
|
-
authoredPrs: authoredPrs.items || [],
|
|
222
|
-
failedBuilds: failedBuilds.items || [],
|
|
223
|
-
activity: jiraData.items?.activity || [],
|
|
224
|
-
blockedIssues: jiraData.items?.blocked || [],
|
|
225
|
-
presentations: getPresentationInsights()
|
|
150
|
+
authoredPrs: authoredPrs.items || []
|
|
226
151
|
},
|
|
227
152
|
errors
|
|
228
153
|
});
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { basename, dirname, join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
|
|
7
7
|
const execFileAsync = promisify(execFile);
|
|
8
8
|
const NPM_KEYS = ['prefix', 'registry', 'strict-ssl', 'cache', 'proxy', 'https-proxy'];
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
function getNvmDir() {
|
|
11
|
+
const home = homedir();
|
|
12
|
+
const candidates = [
|
|
13
|
+
process.env.NVM_DIR,
|
|
14
|
+
join(home, '.nvm'),
|
|
15
|
+
'/opt/homebrew/opt/nvm',
|
|
16
|
+
'/usr/local/opt/nvm'
|
|
17
|
+
].filter(Boolean);
|
|
18
|
+
for (const dir of candidates) {
|
|
19
|
+
if (existsSync(join(dir, 'nvm.sh'))) return dir;
|
|
20
|
+
}
|
|
21
|
+
return process.env.NVM_DIR || join(home, '.nvm');
|
|
22
|
+
}
|
|
10
23
|
|
|
11
24
|
function getEnrichedEnv() {
|
|
12
25
|
const home = homedir();
|
|
@@ -22,7 +35,7 @@ function getEnrichedEnv() {
|
|
|
22
35
|
'/usr/sbin',
|
|
23
36
|
'/sbin'
|
|
24
37
|
];
|
|
25
|
-
const nvmDir =
|
|
38
|
+
const nvmDir = getNvmDir();
|
|
26
39
|
if (existsSync(nvmDir)) {
|
|
27
40
|
try {
|
|
28
41
|
const versionsDir = join(nvmDir, 'versions', 'node');
|
|
@@ -86,9 +99,18 @@ async function runNvm(script, timeout = 5000) {
|
|
|
86
99
|
if (process.platform === 'win32') {
|
|
87
100
|
throw new Error('Unix NVM shell is not supported on Windows.');
|
|
88
101
|
}
|
|
89
|
-
|
|
102
|
+
const nvmDir = getNvmDir();
|
|
103
|
+
const nvmSh = join(nvmDir, 'nvm.sh');
|
|
104
|
+
const nvmLoad = existsSync(nvmSh) ? `. "${nvmSh}"; ` : '';
|
|
105
|
+
const fullScript = `${nvmLoad}${script}`;
|
|
106
|
+
for (const shell of ['bash', 'sh', 'zsh']) {
|
|
90
107
|
try {
|
|
91
|
-
const { stdout } = await execFileAsync(shell, ['-
|
|
108
|
+
const { stdout } = await execFileAsync(shell, ['-c', fullScript], {
|
|
109
|
+
timeout,
|
|
110
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
111
|
+
env: { ...getEnrichedEnv(), NVM_DIR: nvmDir },
|
|
112
|
+
windowsHide: false
|
|
113
|
+
});
|
|
92
114
|
return stdout.trim();
|
|
93
115
|
} catch {}
|
|
94
116
|
}
|
|
@@ -100,22 +122,90 @@ function parseNvmVersions(output) {
|
|
|
100
122
|
}
|
|
101
123
|
|
|
102
124
|
export async function readNvmConfiguration() {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const defaultLine = value('DEFAULT');
|
|
125
|
+
const nvmDir = getNvmDir();
|
|
126
|
+
const nvmSh = join(nvmDir, 'nvm.sh');
|
|
127
|
+
if (!existsSync(nvmSh)) {
|
|
107
128
|
return {
|
|
108
|
-
installed:
|
|
109
|
-
version:
|
|
110
|
-
currentVersion:
|
|
111
|
-
defaultVersion:
|
|
112
|
-
nvmDir:
|
|
113
|
-
nodeJsMirror:
|
|
114
|
-
installedVersions:
|
|
129
|
+
installed: false,
|
|
130
|
+
version: '',
|
|
131
|
+
currentVersion: '',
|
|
132
|
+
defaultVersion: '',
|
|
133
|
+
nvmDir: '',
|
|
134
|
+
nodeJsMirror: '',
|
|
135
|
+
installedVersions: []
|
|
115
136
|
};
|
|
116
|
-
} catch {
|
|
117
|
-
return { installed: false, version: '', currentVersion: '', defaultVersion: '', nvmDir: '', nodeJsMirror: '', installedVersions: [] };
|
|
118
137
|
}
|
|
138
|
+
|
|
139
|
+
let version = '';
|
|
140
|
+
try {
|
|
141
|
+
const pkgFile = join(nvmDir, 'package.json');
|
|
142
|
+
if (existsSync(pkgFile)) {
|
|
143
|
+
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'));
|
|
144
|
+
version = pkg.version || '';
|
|
145
|
+
}
|
|
146
|
+
} catch {}
|
|
147
|
+
|
|
148
|
+
const installedVersions = [];
|
|
149
|
+
const versionsDir = join(nvmDir, 'versions', 'node');
|
|
150
|
+
if (existsSync(versionsDir)) {
|
|
151
|
+
try {
|
|
152
|
+
for (const entry of readdirSync(versionsDir)) {
|
|
153
|
+
if (/^v?\d+/.test(entry)) {
|
|
154
|
+
installedVersions.push(entry.startsWith('v') ? entry : `v${entry}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {}
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
for (const entry of readdirSync(nvmDir)) {
|
|
161
|
+
if (/^v\d+\.\d+\.\d+$/.test(entry) && existsSync(join(nvmDir, entry, 'bin', 'node'))) {
|
|
162
|
+
if (!installedVersions.includes(entry)) installedVersions.push(entry);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
installedVersions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true, sensitivity: 'base' }));
|
|
167
|
+
|
|
168
|
+
let defaultVersion = '';
|
|
169
|
+
try {
|
|
170
|
+
const defaultAliasFile = join(nvmDir, 'alias', 'default');
|
|
171
|
+
if (existsSync(defaultAliasFile)) {
|
|
172
|
+
defaultVersion = readFileSync(defaultAliasFile, 'utf8').trim();
|
|
173
|
+
}
|
|
174
|
+
} catch {}
|
|
175
|
+
|
|
176
|
+
let currentVersion = process.version || '';
|
|
177
|
+
try {
|
|
178
|
+
const currentLink = join(nvmDir, 'current');
|
|
179
|
+
if (existsSync(currentLink)) {
|
|
180
|
+
const real = basename(realpathSync(currentLink));
|
|
181
|
+
if (/^v?\d+/.test(real)) {
|
|
182
|
+
currentVersion = real.startsWith('v') ? real : `v${real}`;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch {}
|
|
186
|
+
|
|
187
|
+
let nodeJsMirror = process.env.NVM_NODEJS_ORG_MIRROR || '';
|
|
188
|
+
if (!nodeJsMirror) {
|
|
189
|
+
try {
|
|
190
|
+
const shellFile = basename(process.env.SHELL || '') === 'bash' ? '.bashrc' : '.zshrc';
|
|
191
|
+
const file = join(homedir(), shellFile);
|
|
192
|
+
if (existsSync(file)) {
|
|
193
|
+
const content = readFileSync(file, 'utf8');
|
|
194
|
+
const match = content.match(/export\s+NVM_NODEJS_ORG_MIRROR="([^"]*)"/);
|
|
195
|
+
if (match) nodeJsMirror = match[1];
|
|
196
|
+
}
|
|
197
|
+
} catch {}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
installed: true,
|
|
202
|
+
version,
|
|
203
|
+
currentVersion,
|
|
204
|
+
defaultVersion,
|
|
205
|
+
nvmDir,
|
|
206
|
+
nodeJsMirror,
|
|
207
|
+
installedVersions
|
|
208
|
+
};
|
|
119
209
|
}
|
|
120
210
|
|
|
121
211
|
async function readNpmConfig(key) {
|