@rsktash/beads-ui 0.1.0
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/.github/workflows/publish.yml +28 -0
- package/app/protocol.js +216 -0
- package/bin/bdui +19 -0
- package/client/index.html +12 -0
- package/client/postcss.config.js +11 -0
- package/client/src/App.tsx +35 -0
- package/client/src/components/IssueCard.tsx +73 -0
- package/client/src/components/Layout.tsx +175 -0
- package/client/src/components/Markdown.tsx +77 -0
- package/client/src/components/PriorityBadge.tsx +26 -0
- package/client/src/components/SearchDialog.tsx +137 -0
- package/client/src/components/SectionEditor.tsx +212 -0
- package/client/src/components/StatusBadge.tsx +64 -0
- package/client/src/components/TypeBadge.tsx +26 -0
- package/client/src/hooks/use-mutation.ts +55 -0
- package/client/src/hooks/use-search.ts +19 -0
- package/client/src/hooks/use-subscription.ts +187 -0
- package/client/src/index.css +133 -0
- package/client/src/lib/avatar.ts +17 -0
- package/client/src/lib/types.ts +115 -0
- package/client/src/lib/ws-client.ts +214 -0
- package/client/src/lib/ws-context.tsx +28 -0
- package/client/src/main.tsx +10 -0
- package/client/src/views/Board.tsx +200 -0
- package/client/src/views/Detail.tsx +398 -0
- package/client/src/views/List.tsx +461 -0
- package/client/tailwind.config.ts +68 -0
- package/client/tsconfig.json +16 -0
- package/client/vite.config.ts +20 -0
- package/package.json +43 -0
- package/server/app.js +120 -0
- package/server/app.test.js +30 -0
- package/server/bd.js +227 -0
- package/server/bd.test.js +194 -0
- package/server/cli/cli.test.js +207 -0
- package/server/cli/commands.integration.test.js +148 -0
- package/server/cli/commands.js +285 -0
- package/server/cli/commands.unit.test.js +408 -0
- package/server/cli/daemon.js +340 -0
- package/server/cli/daemon.test.js +31 -0
- package/server/cli/index.js +135 -0
- package/server/cli/open.js +178 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +27 -0
- package/server/config.js +36 -0
- package/server/db.js +154 -0
- package/server/db.test.js +169 -0
- package/server/dolt-pool.js +257 -0
- package/server/dolt-queries.js +646 -0
- package/server/index.js +97 -0
- package/server/list-adapters.js +395 -0
- package/server/list-adapters.test.js +208 -0
- package/server/logging.js +23 -0
- package/server/registry-watcher.js +200 -0
- package/server/subscriptions.js +299 -0
- package/server/subscriptions.test.js +128 -0
- package/server/validators.js +124 -0
- package/server/watcher.js +139 -0
- package/server/watcher.test.js +120 -0
- package/server/ws.comments.test.js +262 -0
- package/server/ws.delete.test.js +119 -0
- package/server/ws.js +1309 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.list-refresh.coalesce.test.js +95 -0
- package/server/ws.list-subscriptions.test.js +403 -0
- package/server/ws.mutation-window.test.js +147 -0
- package/server/ws.mutations.test.js +389 -0
- package/server/ws.test.js +52 -0
package/server/index.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createApp } from './app.js';
|
|
3
|
+
import { printServerUrl } from './cli/daemon.js';
|
|
4
|
+
import { getConfig } from './config.js';
|
|
5
|
+
import { resolveWorkspaceDatabase } from './db.js';
|
|
6
|
+
import { startDoltServer, stopDoltServer } from './dolt-pool.js';
|
|
7
|
+
import { debug, enableAllDebug } from './logging.js';
|
|
8
|
+
import { registerWorkspace, watchRegistry } from './registry-watcher.js';
|
|
9
|
+
import { watchDb } from './watcher.js';
|
|
10
|
+
import { attachWsServer } from './ws.js';
|
|
11
|
+
|
|
12
|
+
if (process.argv.includes('--debug') || process.argv.includes('-d')) {
|
|
13
|
+
enableAllDebug();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Parse --host and --port from argv and set env vars before getConfig()
|
|
17
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
18
|
+
if (process.argv[i] === '--host' && process.argv[i + 1]) {
|
|
19
|
+
process.env.HOST = process.argv[++i];
|
|
20
|
+
}
|
|
21
|
+
if (process.argv[i] === '--port' && process.argv[i + 1]) {
|
|
22
|
+
process.env.PORT = process.argv[++i];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = getConfig();
|
|
27
|
+
const app = createApp(config);
|
|
28
|
+
const server = createServer(app);
|
|
29
|
+
const log = debug('server');
|
|
30
|
+
|
|
31
|
+
// Register the initial workspace (from cwd) so it appears in the workspace picker
|
|
32
|
+
// even without the beads daemon running
|
|
33
|
+
const workspace_database = resolveWorkspaceDatabase({ cwd: config.root_dir });
|
|
34
|
+
if (workspace_database.source !== 'home-default' && workspace_database.exists) {
|
|
35
|
+
registerWorkspace({
|
|
36
|
+
path: config.root_dir,
|
|
37
|
+
database: workspace_database.path
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Start Dolt SQL server BEFORE accepting connections.
|
|
42
|
+
// The SQL server holds an exclusive lock on the embedded DB, so bd CLI
|
|
43
|
+
// can't run as fallback while it's up. We must wait for the pool.
|
|
44
|
+
const doltPool = await startDoltServer(config.root_dir);
|
|
45
|
+
if (doltPool) {
|
|
46
|
+
log('Dolt SQL server started — fast query mode enabled (sub-ms queries)');
|
|
47
|
+
} else {
|
|
48
|
+
log('Dolt SQL server not available — using bd CLI fallback (~600ms/query)');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Watch the active beads DB and schedule subscription refresh for active lists
|
|
52
|
+
const db_watcher = watchDb(config.root_dir, () => {
|
|
53
|
+
// Schedule subscription list refresh run for active subscriptions
|
|
54
|
+
log('db change detected → schedule refresh');
|
|
55
|
+
scheduleListRefresh();
|
|
56
|
+
// v2: all updates flow via subscription push envelopes only
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const { scheduleListRefresh } = attachWsServer(server, {
|
|
60
|
+
path: '/ws',
|
|
61
|
+
heartbeat_ms: 30000,
|
|
62
|
+
// Coalesce DB change bursts into one refresh run
|
|
63
|
+
refresh_debounce_ms: 75,
|
|
64
|
+
root_dir: config.root_dir,
|
|
65
|
+
watcher: db_watcher
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Watch the global registry for workspace changes (e.g., when user starts
|
|
69
|
+
// bd daemon in a different project). This enables automatic workspace switching.
|
|
70
|
+
watchRegistry(
|
|
71
|
+
(entries) => {
|
|
72
|
+
log('registry changed: %d entries', entries.length);
|
|
73
|
+
// Find if there's a newer workspace that matches our initial root
|
|
74
|
+
// For now, we just log the change - users can switch via set-workspace
|
|
75
|
+
// Future: could auto-switch if a workspace was started in a parent/child dir
|
|
76
|
+
},
|
|
77
|
+
{ debounce_ms: 500 }
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Graceful shutdown: stop Dolt server
|
|
81
|
+
process.on('SIGTERM', async () => {
|
|
82
|
+
await stopDoltServer();
|
|
83
|
+
process.exit(0);
|
|
84
|
+
});
|
|
85
|
+
process.on('SIGINT', async () => {
|
|
86
|
+
await stopDoltServer();
|
|
87
|
+
process.exit(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
server.listen(config.port, config.host, () => {
|
|
91
|
+
printServerUrl();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
server.on('error', (err) => {
|
|
95
|
+
log('server error %o', err);
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
});
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { runBdJson } from './bd.js';
|
|
2
|
+
import {
|
|
3
|
+
isDoltPoolReady,
|
|
4
|
+
queryAllIssues,
|
|
5
|
+
queryBlockedIssues,
|
|
6
|
+
queryEpics,
|
|
7
|
+
queryIssueDetail,
|
|
8
|
+
queryIssuesByStatus,
|
|
9
|
+
queryReadyIssues,
|
|
10
|
+
querySearchIssues
|
|
11
|
+
} from './dolt-queries.js';
|
|
12
|
+
import { debug } from './logging.js';
|
|
13
|
+
|
|
14
|
+
const log = debug('list-adapters');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build concrete `bd` CLI args for a subscription type + params.
|
|
18
|
+
* Always includes `--json` for parseable output.
|
|
19
|
+
*
|
|
20
|
+
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
21
|
+
* @returns {string[]}
|
|
22
|
+
*/
|
|
23
|
+
export function mapSubscriptionToBdArgs(spec) {
|
|
24
|
+
const t = String(spec.type);
|
|
25
|
+
switch (t) {
|
|
26
|
+
case 'all-issues': {
|
|
27
|
+
return ['list', '--json', '--tree=false', '--all'];
|
|
28
|
+
}
|
|
29
|
+
case 'epics': {
|
|
30
|
+
return ['list', '--json', '--tree=false', '--type=epic', '--all'];
|
|
31
|
+
}
|
|
32
|
+
case 'blocked-issues': {
|
|
33
|
+
return ['blocked', '--json'];
|
|
34
|
+
}
|
|
35
|
+
case 'ready-issues': {
|
|
36
|
+
return ['ready', '--limit', '1000', '--json'];
|
|
37
|
+
}
|
|
38
|
+
case 'in-progress-issues': {
|
|
39
|
+
return ['list', '--json', '--tree=false', '--status', 'in_progress'];
|
|
40
|
+
}
|
|
41
|
+
case 'closed-issues': {
|
|
42
|
+
return [
|
|
43
|
+
'list',
|
|
44
|
+
'--json',
|
|
45
|
+
'--tree=false',
|
|
46
|
+
'--status',
|
|
47
|
+
'closed',
|
|
48
|
+
'--limit',
|
|
49
|
+
'1000'
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
case 'search-issues': {
|
|
53
|
+
const p = spec.params || {};
|
|
54
|
+
const q = String(p.q || '').trim();
|
|
55
|
+
const args = ['list', '--json', '--tree=false', '--all'];
|
|
56
|
+
// bd CLI doesn't support FULLTEXT — use list with status/type flags
|
|
57
|
+
if (p.status && p.status !== 'all') args.push('--status', String(p.status));
|
|
58
|
+
if (p.type && p.type !== 'all') args.push('--type', String(p.type));
|
|
59
|
+
// When there's a query term, use `bd search` (limited but best we can do via CLI)
|
|
60
|
+
if (q.length > 0) return ['search', q, '--json'];
|
|
61
|
+
return args;
|
|
62
|
+
}
|
|
63
|
+
case 'issue-detail': {
|
|
64
|
+
const p = spec.params || {};
|
|
65
|
+
const id = String(p.id || '').trim();
|
|
66
|
+
if (id.length === 0) {
|
|
67
|
+
throw badRequest('Missing param: params.id');
|
|
68
|
+
}
|
|
69
|
+
return ['show', id, '--json'];
|
|
70
|
+
}
|
|
71
|
+
default: {
|
|
72
|
+
throw badRequest(`Unknown subscription type: ${t}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Normalize bd list output to minimal Issue shape used by the registry.
|
|
79
|
+
* - Ensures `id` is a string.
|
|
80
|
+
* - Coerces timestamps to numbers.
|
|
81
|
+
* - `closed_at` defaults to null when missing or invalid.
|
|
82
|
+
*
|
|
83
|
+
* @param {unknown} value
|
|
84
|
+
* @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeIssueList(value) {
|
|
87
|
+
if (!Array.isArray(value)) {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
/** @type {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>} */
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const it of value) {
|
|
93
|
+
const id = String(it.id ?? '');
|
|
94
|
+
if (id.length === 0) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const created_at = parseTimestamp(/** @type {any} */ (it).created_at);
|
|
98
|
+
const updated_at = parseTimestamp(it.updated_at);
|
|
99
|
+
const closed_raw = it.closed_at;
|
|
100
|
+
/** @type {number | null} */
|
|
101
|
+
let closed_at = null;
|
|
102
|
+
if (closed_raw !== undefined && closed_raw !== null) {
|
|
103
|
+
const n = parseTimestamp(closed_raw);
|
|
104
|
+
closed_at = Number.isFinite(n) ? n : null;
|
|
105
|
+
}
|
|
106
|
+
out.push({
|
|
107
|
+
...it,
|
|
108
|
+
id,
|
|
109
|
+
created_at: Number.isFinite(created_at) ? created_at : 0,
|
|
110
|
+
updated_at: Number.isFinite(updated_at) ? updated_at : 0,
|
|
111
|
+
closed_at
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @typedef {Object} FetchListResultSuccess
|
|
119
|
+
* @property {true} ok
|
|
120
|
+
* @property {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
|
|
121
|
+
* @property {number} [total] - Total count for paginated queries
|
|
122
|
+
*/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @typedef {Object} FetchListResultFailure
|
|
126
|
+
* @property {false} ok
|
|
127
|
+
* @property {{ code: string, message: string, details?: Record<string, unknown> }} error
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Execute the mapped `bd` command for a subscription spec and return normalized items.
|
|
132
|
+
* Errors do not throw; they are surfaced as a structured object.
|
|
133
|
+
*
|
|
134
|
+
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
135
|
+
* @param {{ cwd?: string }} [options] - Optional working directory for bd command
|
|
136
|
+
* @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
|
|
137
|
+
*/
|
|
138
|
+
export async function fetchListForSubscription(spec, options = {}) {
|
|
139
|
+
if (isDoltPoolReady()) {
|
|
140
|
+
log('using SQL fast path for %s', spec.type);
|
|
141
|
+
try {
|
|
142
|
+
return await fetchViaSQL(spec);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
log('SQL fast path failed for %s: %o', spec.type, err);
|
|
145
|
+
// Don't fall back to bd CLI — sql-server holds the lock
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
error: {
|
|
149
|
+
code: 'db_error',
|
|
150
|
+
message: (err && /** @type {any} */ (err).message) || 'SQL query failed'
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
log('using bd CLI fallback for %s', spec.type);
|
|
157
|
+
return fetchViaBdCli(spec, options);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Hydrate parent context for a single detail item.
|
|
162
|
+
* `bd show --json` can include the parent id without the parent's display fields.
|
|
163
|
+
*
|
|
164
|
+
* @param {Record<string, unknown>} item
|
|
165
|
+
* @param {{ cwd?: string }} [options]
|
|
166
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
167
|
+
*/
|
|
168
|
+
export async function enrichIssueDetailParentContext(item, options = {}) {
|
|
169
|
+
const parentIdRaw = item.parent_id ?? item.parent;
|
|
170
|
+
const parentId =
|
|
171
|
+
typeof parentIdRaw === 'string'
|
|
172
|
+
? parentIdRaw.trim()
|
|
173
|
+
: parentIdRaw === undefined || parentIdRaw === null
|
|
174
|
+
? ''
|
|
175
|
+
: String(parentIdRaw).trim();
|
|
176
|
+
|
|
177
|
+
if (parentId.length === 0) {
|
|
178
|
+
return item;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const enriched = {
|
|
182
|
+
...item,
|
|
183
|
+
parent_id: parentId,
|
|
184
|
+
parent: parentId
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
typeof enriched.parent_title === 'string' &&
|
|
189
|
+
typeof enriched.parent_status === 'string' &&
|
|
190
|
+
typeof enriched.parent_type === 'string'
|
|
191
|
+
) {
|
|
192
|
+
return enriched;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const res = await runBdJson(['show', parentId, '--json'], { cwd: options.cwd });
|
|
196
|
+
if (
|
|
197
|
+
!res ||
|
|
198
|
+
res.code !== 0 ||
|
|
199
|
+
!res.stdoutJson ||
|
|
200
|
+
typeof res.stdoutJson !== 'object'
|
|
201
|
+
) {
|
|
202
|
+
return enriched;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// bd show --json may return an array or a single object
|
|
206
|
+
const parent = /** @type {Record<string, unknown>} */ (
|
|
207
|
+
Array.isArray(res.stdoutJson) ? res.stdoutJson[0] : res.stdoutJson
|
|
208
|
+
);
|
|
209
|
+
if (!parent) return enriched;
|
|
210
|
+
if (typeof parent.title === 'string') {
|
|
211
|
+
enriched.parent_title = parent.title;
|
|
212
|
+
}
|
|
213
|
+
if (typeof parent.status === 'string') {
|
|
214
|
+
enriched.parent_status = parent.status;
|
|
215
|
+
}
|
|
216
|
+
if (typeof parent.issue_type === 'string') {
|
|
217
|
+
enriched.parent_type = parent.issue_type;
|
|
218
|
+
}
|
|
219
|
+
return enriched;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
224
|
+
* @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
|
|
225
|
+
*/
|
|
226
|
+
async function fetchViaSQL(spec) {
|
|
227
|
+
const t = String(spec.type);
|
|
228
|
+
const p = spec.params || {};
|
|
229
|
+
const pagination = {
|
|
230
|
+
limit: typeof p.limit === 'number' ? p.limit : 0,
|
|
231
|
+
offset: typeof p.offset === 'number' ? p.offset : 0
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* @param {{ ok: true, items: any[], total: number }} res
|
|
236
|
+
* @returns {FetchListResultSuccess}
|
|
237
|
+
*/
|
|
238
|
+
const withTotal = (res) => ({ ok: true, items: normalizeIssueList(res.items), total: res.total });
|
|
239
|
+
|
|
240
|
+
/** @param {() => Promise<any>} queryFn */
|
|
241
|
+
const run = async (queryFn) => {
|
|
242
|
+
const res = await queryFn();
|
|
243
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
244
|
+
return withTotal(res);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
switch (t) {
|
|
248
|
+
case 'all-issues':
|
|
249
|
+
return run(() => queryAllIssues(pagination));
|
|
250
|
+
case 'epics':
|
|
251
|
+
return run(() => queryEpics(pagination));
|
|
252
|
+
case 'blocked-issues':
|
|
253
|
+
return run(() => queryBlockedIssues(pagination));
|
|
254
|
+
case 'ready-issues':
|
|
255
|
+
return run(() => queryReadyIssues(pagination));
|
|
256
|
+
case 'in-progress-issues':
|
|
257
|
+
return run(() => queryIssuesByStatus('in_progress', pagination));
|
|
258
|
+
case 'closed-issues':
|
|
259
|
+
return run(() => queryIssuesByStatus('closed', pagination.limit ? pagination : { limit: 1000, offset: 0 }));
|
|
260
|
+
case 'search-issues': {
|
|
261
|
+
const q = String(p.q || '').trim();
|
|
262
|
+
const status = typeof p.status === 'string' ? p.status : undefined;
|
|
263
|
+
const type = typeof p.type === 'string' ? p.type : undefined;
|
|
264
|
+
// Always use querySearchIssues — it handles empty q with status/type filters
|
|
265
|
+
return run(() => querySearchIssues(q, { ...pagination, status, type }));
|
|
266
|
+
}
|
|
267
|
+
case 'issue-detail': {
|
|
268
|
+
const id = String(p.id || '').trim();
|
|
269
|
+
if (id.length === 0) {
|
|
270
|
+
return { ok: false, error: { code: 'bad_request', message: 'Missing param: params.id' } };
|
|
271
|
+
}
|
|
272
|
+
const res = await queryIssueDetail(id);
|
|
273
|
+
if (!res.ok) return { ok: false, error: res.error };
|
|
274
|
+
return { ok: true, items: normalizeIssueList([res.item]), total: 1 };
|
|
275
|
+
}
|
|
276
|
+
default:
|
|
277
|
+
return { ok: false, error: { code: 'bad_request', message: `Unknown subscription type: ${t}` } };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Slow fallback: spawn bd CLI subprocess.
|
|
283
|
+
*
|
|
284
|
+
* @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
|
|
285
|
+
* @param {{ cwd?: string }} options
|
|
286
|
+
* @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
|
|
287
|
+
*/
|
|
288
|
+
async function fetchViaBdCli(spec, options) {
|
|
289
|
+
/** @type {string[]} */
|
|
290
|
+
let args;
|
|
291
|
+
try {
|
|
292
|
+
args = mapSubscriptionToBdArgs(spec);
|
|
293
|
+
} catch (err) {
|
|
294
|
+
// Surface bad requests (e.g., missing params)
|
|
295
|
+
log('mapSubscriptionToBdArgs failed for %o: %o', spec, err);
|
|
296
|
+
const e = toErrorObject(err);
|
|
297
|
+
return { ok: false, error: e };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
const res = await runBdJson(args, { cwd: options.cwd });
|
|
302
|
+
if (!res || res.code !== 0 || !('stdoutJson' in res)) {
|
|
303
|
+
log(
|
|
304
|
+
'bd failed for %o (args=%o) code=%s stderr=%s',
|
|
305
|
+
spec,
|
|
306
|
+
args,
|
|
307
|
+
res?.code,
|
|
308
|
+
res?.stderr || ''
|
|
309
|
+
);
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
error: {
|
|
313
|
+
code: 'bd_error',
|
|
314
|
+
message: String(res?.stderr || 'bd failed'),
|
|
315
|
+
details: { exit_code: res?.code ?? -1 }
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// bd show may return a single object; normalize to an array first
|
|
320
|
+
let raw = Array.isArray(res.stdoutJson)
|
|
321
|
+
? res.stdoutJson
|
|
322
|
+
: res.stdoutJson && typeof res.stdoutJson === 'object'
|
|
323
|
+
? [res.stdoutJson]
|
|
324
|
+
: [];
|
|
325
|
+
|
|
326
|
+
let items = normalizeIssueList(raw);
|
|
327
|
+
if (String(spec.type) === 'issue-detail') {
|
|
328
|
+
items = await Promise.all(
|
|
329
|
+
items.map((item) => enrichIssueDetailParentContext(item, options))
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return { ok: true, items };
|
|
333
|
+
} catch (err) {
|
|
334
|
+
log('bd invocation failed for %o (args=%o): %o', spec, args, err);
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
error: {
|
|
338
|
+
code: 'bd_error',
|
|
339
|
+
message:
|
|
340
|
+
(err && /** @type {any} */ (err).message) || 'bd invocation failed'
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Create a `bad_request` error object.
|
|
348
|
+
*
|
|
349
|
+
* @param {string} message
|
|
350
|
+
*/
|
|
351
|
+
function badRequest(message) {
|
|
352
|
+
const e = new Error(message);
|
|
353
|
+
// @ts-expect-error add code
|
|
354
|
+
e.code = 'bad_request';
|
|
355
|
+
return e;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Normalize arbitrary thrown values to a structured error object.
|
|
360
|
+
*
|
|
361
|
+
* @param {unknown} err
|
|
362
|
+
* @returns {FetchListResultFailure['error']}
|
|
363
|
+
*/
|
|
364
|
+
function toErrorObject(err) {
|
|
365
|
+
if (err && typeof err === 'object') {
|
|
366
|
+
const any = /** @type {{ code?: unknown, message?: unknown }} */ (err);
|
|
367
|
+
const code = typeof any.code === 'string' ? any.code : 'bad_request';
|
|
368
|
+
const message =
|
|
369
|
+
typeof any.message === 'string' ? any.message : 'Request error';
|
|
370
|
+
return { code, message };
|
|
371
|
+
}
|
|
372
|
+
return { code: 'bad_request', message: 'Request error' };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Parse a bd timestamp string to epoch ms using Date.parse.
|
|
377
|
+
* Falls back to numeric coercion when parsing fails.
|
|
378
|
+
*
|
|
379
|
+
* @param {unknown} v
|
|
380
|
+
* @returns {number}
|
|
381
|
+
*/
|
|
382
|
+
function parseTimestamp(v) {
|
|
383
|
+
if (typeof v === 'string') {
|
|
384
|
+
const ms = Date.parse(v);
|
|
385
|
+
if (Number.isFinite(ms)) {
|
|
386
|
+
return ms;
|
|
387
|
+
}
|
|
388
|
+
const n = Number(v);
|
|
389
|
+
return Number.isFinite(n) ? n : 0;
|
|
390
|
+
}
|
|
391
|
+
if (typeof v === 'number') {
|
|
392
|
+
return Number.isFinite(v) ? v : 0;
|
|
393
|
+
}
|
|
394
|
+
return 0;
|
|
395
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { runBdJson } from './bd.js';
|
|
3
|
+
import {
|
|
4
|
+
fetchListForSubscription,
|
|
5
|
+
mapSubscriptionToBdArgs
|
|
6
|
+
} from './list-adapters.js';
|
|
7
|
+
|
|
8
|
+
vi.mock('./bd.js', () => ({ runBdJson: vi.fn() }));
|
|
9
|
+
|
|
10
|
+
describe('list adapters for subscription types', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
/** @type {import('vitest').Mock} */ (runBdJson).mockReset();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('mapSubscriptionToBdArgs returns args for all-issues', () => {
|
|
16
|
+
const args = mapSubscriptionToBdArgs({ type: 'all-issues' });
|
|
17
|
+
expect(args).toEqual(['list', '--json', '--tree=false', '--all']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('mapSubscriptionToBdArgs returns args for epics', () => {
|
|
21
|
+
const args = mapSubscriptionToBdArgs({ type: 'epics' });
|
|
22
|
+
expect(args).toEqual(['list', '--json', '--tree=false', '--type=epic', '--all']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('mapSubscriptionToBdArgs returns args for blocked-issues', () => {
|
|
26
|
+
const args = mapSubscriptionToBdArgs({ type: 'blocked-issues' });
|
|
27
|
+
// We choose dedicated subcommand mapping for blocked
|
|
28
|
+
expect(args).toEqual(['blocked', '--json']);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('mapSubscriptionToBdArgs returns args for ready-issues', () => {
|
|
32
|
+
const args = mapSubscriptionToBdArgs({ type: 'ready-issues' });
|
|
33
|
+
expect(args).toEqual(['ready', '--limit', '1000', '--json']);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('mapSubscriptionToBdArgs returns args for in-progress-issues', () => {
|
|
37
|
+
const args = mapSubscriptionToBdArgs({ type: 'in-progress-issues' });
|
|
38
|
+
expect(args).toEqual([
|
|
39
|
+
'list',
|
|
40
|
+
'--json',
|
|
41
|
+
'--tree=false',
|
|
42
|
+
'--status',
|
|
43
|
+
'in_progress'
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('mapSubscriptionToBdArgs returns args for closed-issues', () => {
|
|
48
|
+
const args = mapSubscriptionToBdArgs({ type: 'closed-issues' });
|
|
49
|
+
expect(args).toEqual([
|
|
50
|
+
'list',
|
|
51
|
+
'--json',
|
|
52
|
+
'--tree=false',
|
|
53
|
+
'--status',
|
|
54
|
+
'closed',
|
|
55
|
+
'--limit',
|
|
56
|
+
'1000'
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('mapSubscriptionToBdArgs returns args for issue-detail', () => {
|
|
61
|
+
const args = mapSubscriptionToBdArgs({
|
|
62
|
+
type: 'issue-detail',
|
|
63
|
+
params: { id: 'UI-123' }
|
|
64
|
+
});
|
|
65
|
+
expect(args).toEqual(['show', 'UI-123', '--json']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('fetchListForSubscription returns normalized items (Date.parse)', async () => {
|
|
69
|
+
/** @type {import('vitest').Mock} */ (runBdJson).mockResolvedValue({
|
|
70
|
+
code: 0,
|
|
71
|
+
stdoutJson: [
|
|
72
|
+
{
|
|
73
|
+
id: 'A-1',
|
|
74
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
75
|
+
closed_at: null,
|
|
76
|
+
extra: 'x'
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
id: 'A-2',
|
|
80
|
+
updated_at: '2024-01-01T00:00:01.000Z',
|
|
81
|
+
closed_at: '2024-01-01T00:00:05.000Z'
|
|
82
|
+
},
|
|
83
|
+
{ id: 3, updated_at: 'not-a-date' }
|
|
84
|
+
]
|
|
85
|
+
});
|
|
86
|
+
const res = await fetchListForSubscription({ type: 'all-issues' });
|
|
87
|
+
expect(res.ok).toBe(true);
|
|
88
|
+
if (res.ok) {
|
|
89
|
+
expect(res.items.length).toBe(3);
|
|
90
|
+
expect(res.items[0]).toMatchObject({
|
|
91
|
+
id: 'A-1',
|
|
92
|
+
updated_at: Date.parse('2024-01-01T00:00:00.000Z'),
|
|
93
|
+
closed_at: null
|
|
94
|
+
});
|
|
95
|
+
expect(res.items[1]).toMatchObject({
|
|
96
|
+
id: 'A-2',
|
|
97
|
+
updated_at: Date.parse('2024-01-01T00:00:01.000Z'),
|
|
98
|
+
closed_at: Date.parse('2024-01-01T00:00:05.000Z')
|
|
99
|
+
});
|
|
100
|
+
// id coerced to string, closed_at defaults to null
|
|
101
|
+
expect(res.items[2]).toMatchObject({
|
|
102
|
+
id: '3',
|
|
103
|
+
updated_at: 0,
|
|
104
|
+
closed_at: null
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('issue-detail via bd fallback hydrates parent context', async () => {
|
|
110
|
+
/** @type {import('vitest').Mock} */ (runBdJson)
|
|
111
|
+
.mockResolvedValueOnce({
|
|
112
|
+
code: 0,
|
|
113
|
+
stdoutJson: {
|
|
114
|
+
id: 'UI-1',
|
|
115
|
+
title: 'Child issue',
|
|
116
|
+
parent: 'EP-1',
|
|
117
|
+
created_at: '2024-01-01T00:00:00.000Z',
|
|
118
|
+
updated_at: '2024-01-01T00:00:01.000Z'
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
.mockResolvedValueOnce({
|
|
122
|
+
code: 0,
|
|
123
|
+
stdoutJson: {
|
|
124
|
+
id: 'EP-1',
|
|
125
|
+
title: 'Parent epic',
|
|
126
|
+
status: 'in_progress',
|
|
127
|
+
issue_type: 'epic'
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const res = await fetchListForSubscription({
|
|
132
|
+
type: 'issue-detail',
|
|
133
|
+
params: { id: 'UI-1' }
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(res.ok).toBe(true);
|
|
137
|
+
if (res.ok) {
|
|
138
|
+
expect(res.items).toHaveLength(1);
|
|
139
|
+
expect(res.items[0]).toMatchObject({
|
|
140
|
+
id: 'UI-1',
|
|
141
|
+
parent: 'EP-1',
|
|
142
|
+
parent_id: 'EP-1',
|
|
143
|
+
parent_title: 'Parent epic',
|
|
144
|
+
parent_status: 'in_progress',
|
|
145
|
+
parent_type: 'epic'
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('epics subscription returns flat issue list', async () => {
|
|
151
|
+
// bd list --type=epic --all returns flat issue objects (no nested .epic key)
|
|
152
|
+
/** @type {import('vitest').Mock} */ (runBdJson).mockResolvedValue({
|
|
153
|
+
code: 0,
|
|
154
|
+
stdoutJson: [
|
|
155
|
+
{
|
|
156
|
+
id: 'E-1',
|
|
157
|
+
status: 'open',
|
|
158
|
+
issue_type: 'epic',
|
|
159
|
+
created_at: '2024-01-01T00:00:00.000Z',
|
|
160
|
+
updated_at: '2024-01-01T00:00:00.000Z',
|
|
161
|
+
closed_at: null
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'E-2',
|
|
165
|
+
status: 'closed',
|
|
166
|
+
issue_type: 'epic',
|
|
167
|
+
created_at: '2024-01-01T00:00:00.000Z',
|
|
168
|
+
updated_at: '2024-02-01T00:00:00.000Z',
|
|
169
|
+
closed_at: '2024-02-01T00:00:00.000Z'
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const res = await fetchListForSubscription({ type: 'epics' });
|
|
175
|
+
|
|
176
|
+
expect(res.ok).toBe(true);
|
|
177
|
+
if (res.ok) {
|
|
178
|
+
expect(res.items).toHaveLength(2);
|
|
179
|
+
expect(res.items[0]).toMatchObject({ id: 'E-1', status: 'open' });
|
|
180
|
+
expect(res.items[1]).toMatchObject({ id: 'E-2', status: 'closed' });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('fetchListForSubscription surfaces bd error', async () => {
|
|
185
|
+
/** @type {import('vitest').Mock} */ (runBdJson).mockResolvedValue({
|
|
186
|
+
code: 2,
|
|
187
|
+
stderr: 'boom'
|
|
188
|
+
});
|
|
189
|
+
const res = await fetchListForSubscription({ type: 'all-issues' });
|
|
190
|
+
expect(res.ok).toBe(false);
|
|
191
|
+
if (!res.ok) {
|
|
192
|
+
expect(res.error.code).toBe('bd_error');
|
|
193
|
+
expect(res.error.message).toContain('boom');
|
|
194
|
+
expect(res.error.details && res.error.details.exit_code).toBe(2);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('fetchListForSubscription returns error for unknown type', async () => {
|
|
199
|
+
const res = await fetchListForSubscription(
|
|
200
|
+
/** @type {any} */ ({ type: 'unknown' })
|
|
201
|
+
);
|
|
202
|
+
expect(res.ok).toBe(false);
|
|
203
|
+
if (!res.ok) {
|
|
204
|
+
expect(res.error.code).toBe('bad_request');
|
|
205
|
+
expect(res.error.message).toMatch(/Unknown subscription type/);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|