beads-ui 0.2.0 → 0.3.1
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/CHANGES.md +14 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +103 -0
- package/app/data/providers.js +7 -138
- package/app/data/sort.js +47 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +128 -0
- package/app/data/subscriptions-store.js +227 -0
- package/app/main.js +346 -66
- package/app/protocol.js +23 -17
- package/app/protocol.md +18 -15
- package/app/router.js +3 -0
- package/app/state.js +2 -0
- package/app/styles.css +222 -197
- package/app/utils/issue-id-renderer.js +2 -1
- package/app/utils/issue-id.js +1 -0
- package/app/utils/issue-type.js +2 -0
- package/app/utils/issue-url.js +1 -0
- package/app/utils/markdown.js +13 -198
- package/app/utils/priority-badge.js +1 -2
- package/app/utils/status-badge.js +1 -1
- package/app/utils/status.js +2 -0
- package/app/utils/toast.js +1 -1
- package/app/utils/type-badge.js +1 -3
- package/app/views/board.js +172 -148
- package/app/views/detail.js +79 -66
- package/app/views/epics.js +127 -74
- package/app/views/issue-dialog.js +9 -15
- package/app/views/issue-row.js +2 -3
- package/app/views/list.js +105 -104
- package/app/views/nav.js +1 -0
- package/app/views/new-issue-dialog.js +30 -34
- package/app/ws.js +10 -10
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +34 -84
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +5 -4
- package/server/app.js +2 -0
- package/server/bd.js +4 -2
- package/server/cli/commands.js +5 -2
- package/server/cli/daemon.js +19 -5
- package/server/cli/index.js +2 -2
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +2 -1
- package/server/config.js +13 -6
- package/server/db.js +3 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +224 -0
- package/server/subscriptions.js +289 -0
- package/server/validators.js +113 -0
- package/server/watcher.js +8 -8
- package/server/ws.js +457 -229
package/server/cli/daemon.js
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { SpawnOptions } from 'node:child_process'
|
|
3
|
+
*/
|
|
1
4
|
import { spawn } from 'node:child_process';
|
|
2
5
|
import fs from 'node:fs';
|
|
3
6
|
import os from 'node:os';
|
|
4
7
|
import path from 'node:path';
|
|
5
8
|
import { fileURLToPath } from 'node:url';
|
|
6
9
|
import { getConfig } from '../config.js';
|
|
10
|
+
import { resolveDbPath } from '../db.js';
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
13
|
* Resolve the runtime directory used for PID and log files.
|
|
10
14
|
* Prefers `BDUI_RUNTIME_DIR`, then `$XDG_RUNTIME_DIR/beads-ui`,
|
|
11
15
|
* and finally `os.tmpdir()/beads-ui`.
|
|
16
|
+
*
|
|
12
17
|
* @returns {string}
|
|
13
18
|
*/
|
|
14
19
|
export function getRuntimeDir() {
|
|
15
|
-
/** @type {string | undefined} */
|
|
16
20
|
const override_dir = process.env.BDUI_RUNTIME_DIR;
|
|
17
21
|
if (override_dir && override_dir.length > 0) {
|
|
18
22
|
return ensureDir(override_dir);
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
/** @type {string | undefined} */
|
|
22
25
|
const xdg_dir = process.env.XDG_RUNTIME_DIR;
|
|
23
26
|
if (xdg_dir && xdg_dir.length > 0) {
|
|
24
27
|
return ensureDir(path.join(xdg_dir, 'beads-ui'));
|
|
@@ -29,6 +32,7 @@ export function getRuntimeDir() {
|
|
|
29
32
|
|
|
30
33
|
/**
|
|
31
34
|
* Ensure a directory exists with safe permissions and return its path.
|
|
35
|
+
*
|
|
32
36
|
* @param {string} dir_path
|
|
33
37
|
* @returns {string}
|
|
34
38
|
*/
|
|
@@ -59,6 +63,7 @@ export function getLogFilePath() {
|
|
|
59
63
|
|
|
60
64
|
/**
|
|
61
65
|
* Read PID from the PID file if present.
|
|
66
|
+
*
|
|
62
67
|
* @returns {number | null}
|
|
63
68
|
*/
|
|
64
69
|
export function readPidFile() {
|
|
@@ -98,6 +103,7 @@ export function removePidFile() {
|
|
|
98
103
|
|
|
99
104
|
/**
|
|
100
105
|
* Check whether a process is running.
|
|
106
|
+
*
|
|
101
107
|
* @param {number} pid
|
|
102
108
|
* @returns {boolean}
|
|
103
109
|
*/
|
|
@@ -120,6 +126,7 @@ export function isProcessRunning(pid) {
|
|
|
120
126
|
|
|
121
127
|
/**
|
|
122
128
|
* Compute the absolute path to the server entry file.
|
|
129
|
+
*
|
|
123
130
|
* @returns {string}
|
|
124
131
|
*/
|
|
125
132
|
export function getServerEntryPath() {
|
|
@@ -132,6 +139,7 @@ export function getServerEntryPath() {
|
|
|
132
139
|
/**
|
|
133
140
|
* Spawn the server as a detached daemon, redirecting stdio to the log file.
|
|
134
141
|
* Writes the PID file upon success.
|
|
142
|
+
*
|
|
135
143
|
* @returns {{ pid: number } | null} Returns child PID on success; null on failure.
|
|
136
144
|
*/
|
|
137
145
|
export function startDaemon() {
|
|
@@ -148,7 +156,7 @@ export function startDaemon() {
|
|
|
148
156
|
log_fd = -1;
|
|
149
157
|
}
|
|
150
158
|
|
|
151
|
-
/** @type {
|
|
159
|
+
/** @type {SpawnOptions} */
|
|
152
160
|
const opts = {
|
|
153
161
|
detached: true,
|
|
154
162
|
env: { ...process.env },
|
|
@@ -181,6 +189,7 @@ export function startDaemon() {
|
|
|
181
189
|
|
|
182
190
|
/**
|
|
183
191
|
* Send SIGTERM then (optionally) SIGKILL to stop a process and wait for exit.
|
|
192
|
+
*
|
|
184
193
|
* @param {number} pid
|
|
185
194
|
* @param {number} timeout_ms
|
|
186
195
|
* @returns {Promise<boolean>} Resolves true if the process is gone.
|
|
@@ -233,7 +242,12 @@ function sleep(ms) {
|
|
|
233
242
|
* Print the server URL derived from current config.
|
|
234
243
|
*/
|
|
235
244
|
export function printServerUrl() {
|
|
236
|
-
const
|
|
237
|
-
const url = 'http://' + cfg.host + ':' + String(cfg.port);
|
|
245
|
+
const { url } = getConfig();
|
|
238
246
|
console.log(url);
|
|
247
|
+
|
|
248
|
+
// Resolve from the caller's working directory by default
|
|
249
|
+
const resolved_db = resolveDbPath();
|
|
250
|
+
console.log(
|
|
251
|
+
`db: ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
|
|
252
|
+
);
|
|
239
253
|
}
|
package/server/cli/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { printUsage } from './usage.js';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Parse argv into a command token and flags.
|
|
6
|
+
*
|
|
6
7
|
* @param {string[]} args
|
|
7
8
|
* @returns {{ command: string | null, flags: string[] }}
|
|
8
9
|
*/
|
|
@@ -41,6 +42,7 @@ export function parseArgs(args) {
|
|
|
41
42
|
/**
|
|
42
43
|
* CLI main entry. Returns an exit code and prints usage on `--help` or errors.
|
|
43
44
|
* No side effects beyond invoking stub handlers.
|
|
45
|
+
*
|
|
44
46
|
* @param {string[]} args
|
|
45
47
|
* @returns {Promise<number>}
|
|
46
48
|
*/
|
|
@@ -61,7 +63,6 @@ export async function main(args) {
|
|
|
61
63
|
* Default behavior: do NOT open a browser.
|
|
62
64
|
* `--open` explicitly opens, overriding env/config; `--no-open` forces closed.
|
|
63
65
|
*/
|
|
64
|
-
/** @type {{ no_open: boolean }} */
|
|
65
66
|
const options = {
|
|
66
67
|
no_open: true
|
|
67
68
|
};
|
|
@@ -83,7 +84,6 @@ export async function main(args) {
|
|
|
83
84
|
return await handleStop();
|
|
84
85
|
}
|
|
85
86
|
if (command === 'restart') {
|
|
86
|
-
/** @type {{ no_open: boolean }} */
|
|
87
87
|
const options = { no_open: true };
|
|
88
88
|
const has_open = flags.includes('open');
|
|
89
89
|
const has_no_open = flags.includes('no-open');
|
package/server/cli/open.js
CHANGED
|
@@ -3,6 +3,7 @@ import http from 'node:http';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Compute a platform-specific command to open a URL in the default browser.
|
|
6
|
+
*
|
|
6
7
|
* @param {string} url
|
|
7
8
|
* @param {string} platform
|
|
8
9
|
* @returns {{ cmd: string, args: string[] }}
|
|
@@ -21,6 +22,7 @@ export function computeOpenCommand(url, platform) {
|
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Open the given URL in the default browser. Best-effort; resolves true on spawn success.
|
|
25
|
+
*
|
|
24
26
|
* @param {string} url
|
|
25
27
|
* @returns {Promise<boolean>}
|
|
26
28
|
*/
|
|
@@ -41,6 +43,7 @@ export async function openUrl(url) {
|
|
|
41
43
|
/**
|
|
42
44
|
* Wait until the server at the URL accepts a connection, with a brief retry.
|
|
43
45
|
* Does not throw; returns when either a connection was accepted or timeout elapsed.
|
|
46
|
+
*
|
|
44
47
|
* @param {string} url
|
|
45
48
|
* @param {number} total_timeout_ms
|
|
46
49
|
* @returns {Promise<void>}
|
package/server/cli/usage.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Print CLI usage to a stream-like target.
|
|
3
|
+
*
|
|
3
4
|
* @param {{ write: (chunk: string) => any }} out_stream
|
|
4
5
|
*/
|
|
5
6
|
export function printUsage(out_stream) {
|
|
@@ -7,7 +8,7 @@ export function printUsage(out_stream) {
|
|
|
7
8
|
'Usage: bdui <command> [options]',
|
|
8
9
|
'',
|
|
9
10
|
'Commands:',
|
|
10
|
-
' start Start the UI server
|
|
11
|
+
' start Start the UI server',
|
|
11
12
|
' stop Stop the UI server',
|
|
12
13
|
' restart Restart the UI server',
|
|
13
14
|
'',
|
package/server/config.js
CHANGED
|
@@ -3,27 +3,34 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Resolve runtime configuration for the server.
|
|
6
|
-
*
|
|
6
|
+
* Notes:
|
|
7
|
+
* - `app_dir` is resolved relative to the installed package location.
|
|
8
|
+
* - `root_dir` represents the directory where the process was invoked
|
|
9
|
+
* (i.e., the current working directory) so DB resolution follows the
|
|
10
|
+
* caller's context rather than the install location.
|
|
11
|
+
*
|
|
12
|
+
* @returns {{ host: string, port: number, env: string, app_dir: string, root_dir: string, url: string }}
|
|
7
13
|
*/
|
|
8
14
|
export function getConfig() {
|
|
9
15
|
const this_file = fileURLToPath(new URL(import.meta.url));
|
|
10
16
|
const server_dir = path.dirname(this_file);
|
|
11
|
-
const
|
|
17
|
+
const package_root = path.resolve(server_dir, '..');
|
|
18
|
+
// Always reflect the directory from which the process was started
|
|
19
|
+
const root_dir = process.cwd();
|
|
12
20
|
|
|
13
|
-
/** @type {number} */
|
|
14
21
|
let port_value = Number.parseInt(process.env.PORT || '', 10);
|
|
15
22
|
if (!Number.isFinite(port_value)) {
|
|
16
23
|
port_value = 3000;
|
|
17
24
|
}
|
|
18
25
|
|
|
19
|
-
/** @type {string} */
|
|
20
26
|
const host_value = '127.0.0.1';
|
|
21
27
|
|
|
22
28
|
return {
|
|
23
29
|
host: host_value,
|
|
24
30
|
port: port_value,
|
|
25
31
|
env: process.env.NODE_ENV ? String(process.env.NODE_ENV) : 'development',
|
|
26
|
-
app_dir: path.resolve(
|
|
27
|
-
root_dir
|
|
32
|
+
app_dir: path.resolve(package_root, 'app'),
|
|
33
|
+
root_dir,
|
|
34
|
+
url: `http://${host_value}:${port_value}`
|
|
28
35
|
};
|
|
29
36
|
}
|
package/server/db.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
*
|
|
12
12
|
* Returns a normalized absolute path and a `source` indicator. Existence is
|
|
13
13
|
* returned via the `exists` boolean.
|
|
14
|
+
*
|
|
14
15
|
* @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
|
|
15
16
|
* @returns {{ path: string, source: 'flag'|'env'|'nearest'|'home-default', exists: boolean }}
|
|
16
17
|
*/
|
|
@@ -48,6 +49,7 @@ export function resolveDbPath(options = {}) {
|
|
|
48
49
|
/**
|
|
49
50
|
* Find nearest .beads/*.db by walking up from start.
|
|
50
51
|
* First alphabetical .db.
|
|
52
|
+
*
|
|
51
53
|
* @param {string} start
|
|
52
54
|
* @returns {string | null}
|
|
53
55
|
*/
|
|
@@ -58,7 +60,6 @@ export function findNearestBeadsDb(start) {
|
|
|
58
60
|
const beadsDir = path.join(dir, '.beads');
|
|
59
61
|
try {
|
|
60
62
|
const entries = fs.readdirSync(beadsDir, { withFileTypes: true });
|
|
61
|
-
/** @type {string[]} */
|
|
62
63
|
const dbs = entries
|
|
63
64
|
.filter((e) => e.isFile() && e.name.endsWith('.db'))
|
|
64
65
|
.map((e) => e.name)
|
|
@@ -80,6 +81,7 @@ export function findNearestBeadsDb(start) {
|
|
|
80
81
|
|
|
81
82
|
/**
|
|
82
83
|
* Resolve possibly relative `p` against `cwd` to an absolute filesystem path.
|
|
84
|
+
*
|
|
83
85
|
* @param {string} p
|
|
84
86
|
* @param {string} cwd
|
|
85
87
|
*/
|
package/server/index.js
CHANGED
|
@@ -7,14 +7,18 @@ import { attachWsServer } from './ws.js';
|
|
|
7
7
|
const config = getConfig();
|
|
8
8
|
const app = createApp(config);
|
|
9
9
|
const server = createServer(app);
|
|
10
|
-
const {
|
|
10
|
+
const { scheduleListRefresh } = attachWsServer(server, {
|
|
11
11
|
path: '/ws',
|
|
12
|
-
heartbeat_ms: 30000
|
|
12
|
+
heartbeat_ms: 30000,
|
|
13
|
+
// Coalesce DB change bursts into one refresh run
|
|
14
|
+
refresh_debounce_ms: 75
|
|
13
15
|
});
|
|
14
16
|
|
|
15
|
-
// Watch the active beads DB and
|
|
16
|
-
watchDb(config.root_dir, (
|
|
17
|
-
|
|
17
|
+
// Watch the active beads DB and schedule subscription refresh for active lists
|
|
18
|
+
watchDb(config.root_dir, () => {
|
|
19
|
+
// Schedule subscription list refresh run for active subscriptions
|
|
20
|
+
scheduleListRefresh();
|
|
21
|
+
// v2: all updates flow via subscription push envelopes only
|
|
18
22
|
});
|
|
19
23
|
|
|
20
24
|
server.listen(config.port, config.host, () => {
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { runBdJson } from './bd.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build concrete `bd` CLI args for a subscription type + params.
|
|
5
|
+
* Always includes `--json` for parseable output.
|
|
6
|
+
*
|
|
7
|
+
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
8
|
+
* @returns {string[]}
|
|
9
|
+
*/
|
|
10
|
+
export function mapSubscriptionToBdArgs(spec) {
|
|
11
|
+
const t = String(spec.type);
|
|
12
|
+
switch (t) {
|
|
13
|
+
case 'all-issues': {
|
|
14
|
+
return ['list', '--json'];
|
|
15
|
+
}
|
|
16
|
+
case 'epics': {
|
|
17
|
+
return ['epic', 'status', '--json'];
|
|
18
|
+
}
|
|
19
|
+
case 'blocked-issues': {
|
|
20
|
+
return ['blocked', '--json'];
|
|
21
|
+
}
|
|
22
|
+
case 'ready-issues': {
|
|
23
|
+
return ['ready', '--limit', '1000', '--json'];
|
|
24
|
+
}
|
|
25
|
+
case 'in-progress-issues': {
|
|
26
|
+
return ['list', '--json', '--status', 'in_progress'];
|
|
27
|
+
}
|
|
28
|
+
case 'closed-issues': {
|
|
29
|
+
return ['list', '--json', '--status', 'closed'];
|
|
30
|
+
}
|
|
31
|
+
case 'issue-detail': {
|
|
32
|
+
const p = spec.params || {};
|
|
33
|
+
const id = String(p.id || '').trim();
|
|
34
|
+
if (id.length === 0) {
|
|
35
|
+
throw badRequest('Missing param: params.id');
|
|
36
|
+
}
|
|
37
|
+
return ['show', id, '--json'];
|
|
38
|
+
}
|
|
39
|
+
default: {
|
|
40
|
+
throw badRequest(`Unknown subscription type: ${t}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Normalize bd list output to minimal Issue shape used by the registry.
|
|
47
|
+
* - Ensures `id` is a string.
|
|
48
|
+
* - Coerces timestamps to numbers.
|
|
49
|
+
* - `closed_at` defaults to null when missing or invalid.
|
|
50
|
+
*
|
|
51
|
+
* @param {unknown} value
|
|
52
|
+
* @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeIssueList(value) {
|
|
55
|
+
if (!Array.isArray(value)) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
/** @type {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>} */
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const it of value) {
|
|
61
|
+
const id = String(it.id ?? '');
|
|
62
|
+
if (id.length === 0) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const created_at = parseTimestamp(/** @type {any} */ (it).created_at);
|
|
66
|
+
const updated_at = parseTimestamp(it.updated_at);
|
|
67
|
+
const closed_raw = it.closed_at;
|
|
68
|
+
/** @type {number | null} */
|
|
69
|
+
let closed_at = null;
|
|
70
|
+
if (closed_raw !== undefined && closed_raw !== null) {
|
|
71
|
+
const n = parseTimestamp(closed_raw);
|
|
72
|
+
closed_at = Number.isFinite(n) ? n : null;
|
|
73
|
+
}
|
|
74
|
+
out.push({
|
|
75
|
+
...it,
|
|
76
|
+
id,
|
|
77
|
+
created_at: Number.isFinite(created_at) ? created_at : 0,
|
|
78
|
+
updated_at: Number.isFinite(updated_at) ? updated_at : 0,
|
|
79
|
+
closed_at
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {Object} FetchListResultSuccess
|
|
87
|
+
* @property {true} ok
|
|
88
|
+
* @property {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* @typedef {Object} FetchListResultFailure
|
|
93
|
+
* @property {false} ok
|
|
94
|
+
* @property {{ code: string, message: string, details?: Record<string, unknown> }} error
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Execute the mapped `bd` command for a subscription spec and return normalized items.
|
|
99
|
+
* Errors do not throw; they are surfaced as a structured object.
|
|
100
|
+
*
|
|
101
|
+
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
102
|
+
* @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
|
|
103
|
+
*/
|
|
104
|
+
export async function fetchListForSubscription(spec) {
|
|
105
|
+
/** @type {string[]} */
|
|
106
|
+
let args;
|
|
107
|
+
try {
|
|
108
|
+
args = mapSubscriptionToBdArgs(spec);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
const e = toErrorObject(err);
|
|
111
|
+
return { ok: false, error: e };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const res = await runBdJson(args);
|
|
116
|
+
if (!res || res.code !== 0 || !('stdoutJson' in res)) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
error: {
|
|
120
|
+
code: 'bd_error',
|
|
121
|
+
message: String(res?.stderr || 'bd failed'),
|
|
122
|
+
details: { exit_code: res?.code ?? -1 }
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// bd show may return a single object; normalize to an array first
|
|
127
|
+
let raw = Array.isArray(res.stdoutJson)
|
|
128
|
+
? res.stdoutJson
|
|
129
|
+
: res.stdoutJson && typeof res.stdoutJson === 'object'
|
|
130
|
+
? [res.stdoutJson]
|
|
131
|
+
: [];
|
|
132
|
+
|
|
133
|
+
// Special-case mapping for `epics`: current bd output nests the epic under
|
|
134
|
+
// an `epic` key and exposes counters at the top level. Flatten so that
|
|
135
|
+
// each entry has a top-level `id` and core fields expected by the registry.
|
|
136
|
+
if (String(spec.type) === 'epics') {
|
|
137
|
+
raw = raw.map((it) => {
|
|
138
|
+
if (it && typeof it === 'object' && 'epic' in it) {
|
|
139
|
+
const e = /** @type {any} */ (it).epic || {};
|
|
140
|
+
/** @type {Record<string, unknown>} */
|
|
141
|
+
const flat = {
|
|
142
|
+
// Required minimal fields for registry + client rendering
|
|
143
|
+
id: String(e.id ?? ''),
|
|
144
|
+
title: e.title,
|
|
145
|
+
status: e.status,
|
|
146
|
+
issue_type: e.issue_type || 'epic',
|
|
147
|
+
created_at: e.created_at,
|
|
148
|
+
updated_at: e.updated_at,
|
|
149
|
+
closed_at: e.closed_at ?? null,
|
|
150
|
+
// Preserve useful counters from bd output
|
|
151
|
+
total_children: /** @type {any} */ (it).total_children,
|
|
152
|
+
closed_children: /** @type {any} */ (it).closed_children,
|
|
153
|
+
eligible_for_close: /** @type {any} */ (it).eligible_for_close
|
|
154
|
+
};
|
|
155
|
+
return flat;
|
|
156
|
+
}
|
|
157
|
+
return it;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const items = normalizeIssueList(raw);
|
|
162
|
+
return { ok: true, items };
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: {
|
|
167
|
+
code: 'bd_error',
|
|
168
|
+
message:
|
|
169
|
+
(err && /** @type {any} */ (err).message) || 'bd invocation failed'
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create a `bad_request` error object.
|
|
177
|
+
*
|
|
178
|
+
* @param {string} message
|
|
179
|
+
*/
|
|
180
|
+
function badRequest(message) {
|
|
181
|
+
const e = new Error(message);
|
|
182
|
+
// @ts-expect-error add code
|
|
183
|
+
e.code = 'bad_request';
|
|
184
|
+
return e;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Normalize arbitrary thrown values to a structured error object.
|
|
189
|
+
*
|
|
190
|
+
* @param {unknown} err
|
|
191
|
+
* @returns {FetchListResultFailure['error']}
|
|
192
|
+
*/
|
|
193
|
+
function toErrorObject(err) {
|
|
194
|
+
if (err && typeof err === 'object') {
|
|
195
|
+
const any = /** @type {{ code?: unknown, message?: unknown }} */ (err);
|
|
196
|
+
const code = typeof any.code === 'string' ? any.code : 'bad_request';
|
|
197
|
+
const message =
|
|
198
|
+
typeof any.message === 'string' ? any.message : 'Request error';
|
|
199
|
+
return { code, message };
|
|
200
|
+
}
|
|
201
|
+
return { code: 'bad_request', message: 'Request error' };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Parse a bd timestamp string to epoch ms using Date.parse.
|
|
206
|
+
* Falls back to numeric coercion when parsing fails.
|
|
207
|
+
*
|
|
208
|
+
* @param {unknown} v
|
|
209
|
+
* @returns {number}
|
|
210
|
+
*/
|
|
211
|
+
function parseTimestamp(v) {
|
|
212
|
+
if (typeof v === 'string') {
|
|
213
|
+
const ms = Date.parse(v);
|
|
214
|
+
if (Number.isFinite(ms)) {
|
|
215
|
+
return ms;
|
|
216
|
+
}
|
|
217
|
+
const n = Number(v);
|
|
218
|
+
return Number.isFinite(n) ? n : 0;
|
|
219
|
+
}
|
|
220
|
+
if (typeof v === 'number') {
|
|
221
|
+
return Number.isFinite(v) ? v : 0;
|
|
222
|
+
}
|
|
223
|
+
return 0;
|
|
224
|
+
}
|