@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.
Files changed (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. package/server/ws.test.js +52 -0
@@ -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
+ });