@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,646 @@
1
+ /**
2
+ * Direct SQL queries against Dolt, replacing bd CLI subprocess calls.
3
+ * Each query returns data in the same shape as the bd CLI JSON output
4
+ * so callers don't need to change.
5
+ */
6
+ import { getPool } from './dolt-pool.js';
7
+ import { debug } from './logging.js';
8
+
9
+ const log = debug('dolt-queries');
10
+
11
+ // Columns selected for list views (omit large text fields for performance).
12
+ // Note: 'parent' is NOT a real column — it's derived from dependencies table.
13
+ const LIST_COL_NAMES = [
14
+ 'id', 'title', 'status', 'priority', 'issue_type', 'assignee',
15
+ 'created_at', 'created_by', 'updated_at', 'closed_at', 'close_reason',
16
+ 'description', 'owner', 'estimated_minutes', 'external_ref', 'spec_id',
17
+ 'ephemeral', 'pinned', 'is_template', 'mol_type', 'work_type',
18
+ 'source_system', 'source_repo'
19
+ ];
20
+
21
+ // Prefixed with table alias for JOINed queries
22
+ const LIST_COLS_ALIASED = LIST_COL_NAMES.map(c => `i.${c}`).join(', ');
23
+ const LIST_COLS = LIST_COL_NAMES.join(', ');
24
+
25
+ // All columns for detail view (single-issue fetch includes text fields)
26
+ const DETAIL_COLS = '*';
27
+
28
+ /**
29
+ * Check if the Dolt pool is available.
30
+ *
31
+ * @returns {boolean}
32
+ */
33
+ export function isDoltPoolReady() {
34
+ return getPool() !== null;
35
+ }
36
+
37
+ /**
38
+ * Normalize a Dolt datetime row to the format bd CLI returns.
39
+ * bd returns ISO strings; Dolt with dateStrings returns 'YYYY-MM-DD HH:MM:SS'.
40
+ *
41
+ * @param {Record<string, unknown>} row
42
+ * @returns {Record<string, unknown>}
43
+ */
44
+ function normalizeRow(row) {
45
+ const out = { ...row };
46
+ for (const key of ['created_at', 'updated_at', 'closed_at', 'last_activity', 'compacted_at', 'due_at', 'defer_until']) {
47
+ if (out[key] && typeof out[key] === 'string') {
48
+ // Convert '2026-04-05 01:50:19' → '2026-04-05T01:50:19Z'
49
+ const v = /** @type {string} */ (out[key]);
50
+ if (v.includes(' ') && !v.includes('T')) {
51
+ out[key] = v.replace(' ', 'T') + 'Z';
52
+ }
53
+ }
54
+ }
55
+ // Ensure boolean fields are numbers (Dolt returns 0/1)
56
+ for (const key of ['ephemeral', 'pinned', 'is_template', 'no_history']) {
57
+ if (key in out) {
58
+ out[key] = out[key] ? 1 : 0;
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * @typedef {{ limit?: number, offset?: number }} Pagination
66
+ * @typedef {{ ok: true, items: Array<Record<string, unknown>>, total: number }} PaginatedResult
67
+ * @typedef {{ ok: false, error: { code: string, message: string } }} QueryError
68
+ */
69
+
70
+ /**
71
+ * Build SQL LIMIT/OFFSET clause from pagination params.
72
+ *
73
+ * @param {Pagination} [pagination]
74
+ * @returns {{ limitClause: string }}
75
+ */
76
+ function buildPagination(pagination) {
77
+ const limit = pagination?.limit || 0;
78
+ const offset = pagination?.offset || 0;
79
+ const limitClause = limit > 0 ? ` LIMIT ${Number(limit)} OFFSET ${Number(offset)}` : '';
80
+ return { limitClause };
81
+ }
82
+
83
+ /**
84
+ * Fetch total count for a WHERE clause.
85
+ *
86
+ * @param {import('mysql2/promise').Pool} pool
87
+ * @param {string} where - SQL WHERE clause (without WHERE keyword), or empty for all
88
+ * @param {any[]} [params]
89
+ * @returns {Promise<number>}
90
+ */
91
+ async function fetchTotal(pool, where, params = []) {
92
+ const sql = where
93
+ ? `SELECT COUNT(*) AS total FROM issues WHERE ${where}`
94
+ : `SELECT COUNT(*) AS total FROM issues`;
95
+ const [rows] = await pool.query(sql, params);
96
+ return /** @type {any[]} */ (rows)[0]?.total || 0;
97
+ }
98
+
99
+ /**
100
+ * Fetch all issues with pagination (for 'all-issues' subscription).
101
+ *
102
+ * @param {Pagination} [pagination]
103
+ * @returns {Promise<PaginatedResult | QueryError>}
104
+ */
105
+ export async function queryAllIssues(pagination) {
106
+ const pool = getPool();
107
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
108
+ try {
109
+ const total = await fetchTotal(pool, '');
110
+ const { limitClause } = buildPagination(pagination);
111
+ const [rows] = await pool.query(
112
+ `SELECT ${LIST_COLS_ALIASED}, d.depends_on_id AS parent
113
+ FROM issues i
114
+ LEFT JOIN dependencies d ON d.issue_id = i.id AND d.type = 'parent-child'
115
+ ORDER BY i.updated_at DESC${limitClause}`
116
+ );
117
+ return { ok: true, items: /** @type {any[]} */ (rows).map(normalizeRow), total };
118
+ } catch (err) {
119
+ log('queryAllIssues error: %o', err);
120
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Fetch epics (for 'epics' subscription).
126
+ *
127
+ * @returns {Promise<{ ok: true, items: Array<Record<string, unknown>> } | { ok: false, error: { code: string, message: string } }>}
128
+ */
129
+ /**
130
+ * @param {{ limit?: number, offset?: number }} [pagination]
131
+ * @returns {Promise<PaginatedResult | QueryError>}
132
+ */
133
+ export async function queryEpics(pagination) {
134
+ const pool = getPool();
135
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
136
+ try {
137
+ const [countRows] = await pool.query(`SELECT COUNT(*) AS total FROM issues WHERE issue_type = 'epic'`);
138
+ const total = /** @type {any[]} */ (countRows)[0]?.total || 0;
139
+ const { limitClause } = buildPagination(pagination);
140
+ const [rows] = await pool.query(
141
+ `SELECT ${LIST_COLS_ALIASED}, d.depends_on_id AS parent
142
+ FROM issues i
143
+ LEFT JOIN dependencies d ON d.issue_id = i.id AND d.type = 'parent-child'
144
+ WHERE i.issue_type = 'epic'
145
+ ORDER BY i.updated_at DESC${limitClause}`
146
+ );
147
+ return { ok: true, items: /** @type {any[]} */ (rows).map(normalizeRow), total };
148
+ } catch (err) {
149
+ log('queryEpics error: %o', err);
150
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Fetch blocked issues (for 'blocked-issues' subscription).
156
+ *
157
+ * @returns {Promise<{ ok: true, items: Array<Record<string, unknown>> } | { ok: false, error: { code: string, message: string } }>}
158
+ */
159
+ /**
160
+ * @param {Pagination} [pagination]
161
+ * @returns {Promise<PaginatedResult | QueryError>}
162
+ */
163
+ export async function queryBlockedIssues(pagination) {
164
+ const pool = getPool();
165
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
166
+ try {
167
+ const blockedWhere = `status = 'open' AND id IN (
168
+ SELECT bl.issue_id FROM dependencies bl
169
+ JOIN issues blocker ON bl.depends_on_id = blocker.id
170
+ WHERE bl.type = 'blocks' AND blocker.status != 'closed')`;
171
+ const total = await fetchTotal(pool, blockedWhere);
172
+ const { limitClause } = buildPagination(pagination);
173
+ const [rows] = await pool.query(
174
+ `SELECT ${LIST_COLS_ALIASED}, pc.depends_on_id AS parent
175
+ FROM issues i
176
+ LEFT JOIN dependencies pc ON pc.issue_id = i.id AND pc.type = 'parent-child'
177
+ WHERE i.status = 'open'
178
+ AND i.id IN (
179
+ SELECT bl.issue_id FROM dependencies bl
180
+ JOIN issues blocker ON bl.depends_on_id = blocker.id
181
+ WHERE bl.type = 'blocks' AND blocker.status != 'closed'
182
+ )
183
+ ORDER BY i.updated_at DESC${limitClause}`
184
+ );
185
+ return { ok: true, items: /** @type {any[]} */ (rows).map(normalizeRow), total };
186
+ } catch (err) {
187
+ log('queryBlockedIssues error: %o', err);
188
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Fetch ready issues (for 'ready-issues' subscription).
194
+ *
195
+ * @returns {Promise<{ ok: true, items: Array<Record<string, unknown>> } | { ok: false, error: { code: string, message: string } }>}
196
+ */
197
+ /**
198
+ * @param {Pagination} [pagination]
199
+ * @returns {Promise<PaginatedResult | QueryError>}
200
+ */
201
+ export async function queryReadyIssues(pagination) {
202
+ const pool = getPool();
203
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
204
+ try {
205
+ const readyWhere = `status = 'open' AND id NOT IN (
206
+ SELECT bl.issue_id FROM dependencies bl
207
+ JOIN issues blocker ON bl.depends_on_id = blocker.id
208
+ WHERE bl.type = 'blocks' AND blocker.status != 'closed')`;
209
+ const total = await fetchTotal(pool, readyWhere);
210
+ const { limitClause } = buildPagination(pagination);
211
+ const [rows] = await pool.query(
212
+ `SELECT ${LIST_COLS_ALIASED}, pc.depends_on_id AS parent
213
+ FROM issues i
214
+ LEFT JOIN dependencies pc ON pc.issue_id = i.id AND pc.type = 'parent-child'
215
+ WHERE i.status = 'open'
216
+ AND i.id NOT IN (
217
+ SELECT bl.issue_id FROM dependencies bl
218
+ JOIN issues blocker ON bl.depends_on_id = blocker.id
219
+ WHERE bl.type = 'blocks' AND blocker.status != 'closed'
220
+ )
221
+ ORDER BY i.updated_at DESC${limitClause}`
222
+ );
223
+ return { ok: true, items: /** @type {any[]} */ (rows).map(normalizeRow), total };
224
+ } catch (err) {
225
+ log('queryReadyIssues error: %o', err);
226
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Fetch issues by status (for 'in-progress-issues', 'closed-issues' subscriptions).
232
+ *
233
+ * @param {string} status
234
+ * @param {number} [limit]
235
+ * @returns {Promise<{ ok: true, items: Array<Record<string, unknown>> } | { ok: false, error: { code: string, message: string } }>}
236
+ */
237
+ /**
238
+ * @param {string} status
239
+ * @param {Pagination} [pagination]
240
+ * @returns {Promise<PaginatedResult | QueryError>}
241
+ */
242
+ export async function queryIssuesByStatus(status, pagination) {
243
+ const pool = getPool();
244
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
245
+ try {
246
+ const total = await fetchTotal(pool, 'status = ?', [status]);
247
+ const { limitClause } = buildPagination(pagination);
248
+ const [rows] = await pool.query(
249
+ `SELECT ${LIST_COLS_ALIASED}, pc.depends_on_id AS parent
250
+ FROM issues i
251
+ LEFT JOIN dependencies pc ON pc.issue_id = i.id AND pc.type = 'parent-child'
252
+ WHERE i.status = ?
253
+ ORDER BY i.updated_at DESC${limitClause}`,
254
+ [status]
255
+ );
256
+ return { ok: true, items: /** @type {any[]} */ (rows).map(normalizeRow), total };
257
+ } catch (err) {
258
+ log('queryIssuesByStatus error: %o', err);
259
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Search issues with optional FULLTEXT query and status/type filters.
265
+ *
266
+ * When `query` is non-empty, uses Dolt FULLTEXT MATCH...AGAINST for
267
+ * relevance-ranked results. Falls back to LIKE on id for exact ID matches.
268
+ * Status and type filters narrow results server-side.
269
+ *
270
+ * @param {string} query - Search terms (empty string = no text filter)
271
+ * @param {Pagination & { status?: string, type?: string }} [options]
272
+ * @returns {Promise<PaginatedResult | QueryError>}
273
+ */
274
+ export async function querySearchIssues(query, options) {
275
+ const pool = getPool();
276
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
277
+ try {
278
+ const conditions = [];
279
+ const params = [];
280
+
281
+ // FULLTEXT search on title + description
282
+ if (query.length > 0) {
283
+ // Use MATCH...AGAINST for natural language search, plus LIKE on id for exact ID matches
284
+ conditions.push('(MATCH(i.title, i.description) AGAINST (?) OR i.id LIKE ?)');
285
+ params.push(query, `%${query}%`);
286
+ }
287
+
288
+ // Status filter
289
+ if (options?.status && options.status !== 'all') {
290
+ conditions.push('i.status = ?');
291
+ params.push(options.status);
292
+ }
293
+
294
+ // Type filter
295
+ if (options?.type && options.type !== 'all') {
296
+ conditions.push('i.issue_type = ?');
297
+ params.push(options.type);
298
+ }
299
+
300
+ const where = conditions.length > 0 ? conditions.join(' AND ') : '1=1';
301
+
302
+ const [countRows] = await pool.query(
303
+ `SELECT COUNT(*) AS total FROM issues i WHERE ${where}`, params
304
+ );
305
+ const total = /** @type {any[]} */ (countRows)[0]?.total || 0;
306
+
307
+ const { limitClause } = buildPagination(options);
308
+ const [rows] = await pool.query(
309
+ `SELECT ${LIST_COLS_ALIASED}, pc.depends_on_id AS parent
310
+ FROM issues i
311
+ LEFT JOIN dependencies pc ON pc.issue_id = i.id AND pc.type = 'parent-child'
312
+ WHERE ${where}
313
+ ORDER BY i.updated_at DESC${limitClause}`,
314
+ params
315
+ );
316
+ return { ok: true, items: /** @type {any[]} */ (rows).map(normalizeRow), total };
317
+ } catch (err) {
318
+ log('querySearchIssues error: %o', err);
319
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Fetch a single issue with dependencies, labels and parent info
325
+ * (for 'issue-detail' subscription and post-mutation show).
326
+ *
327
+ * @param {string} id
328
+ * @returns {Promise<{ ok: true, item: Record<string, unknown> } | { ok: false, error: { code: string, message: string } }>}
329
+ */
330
+ export async function queryIssueDetail(id) {
331
+ const pool = getPool();
332
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
333
+ try {
334
+ const [issueRows] = await pool.query(
335
+ `SELECT ${DETAIL_COLS} FROM issues WHERE id = ?`, [id]
336
+ );
337
+ const issues = /** @type {any[]} */ (issueRows);
338
+ if (issues.length === 0) {
339
+ return { ok: false, error: { code: 'not_found', message: `Issue ${id} not found` } };
340
+ }
341
+
342
+ const issue = normalizeRow(issues[0]);
343
+
344
+ // Fetch dependencies
345
+ const [depRows] = await pool.query(
346
+ `SELECT issue_id, depends_on_id, type, created_at, created_by, metadata
347
+ FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, [id, id]
348
+ );
349
+ issue.dependencies = /** @type {any[]} */ (depRows).map(normalizeRow);
350
+
351
+ // Derive parent from parent-child dependency (with context)
352
+ const parentDep = /** @type {any[]} */ (depRows).find(
353
+ (d) => d.issue_id === id && d.type === 'parent-child'
354
+ );
355
+ if (parentDep) {
356
+ issue.parent_id = parentDep.depends_on_id;
357
+ issue.parent = parentDep.depends_on_id;
358
+ // Fetch parent title/status for sidebar context
359
+ const [parentRows] = await pool.query(
360
+ `SELECT id, title, status, issue_type FROM issues WHERE id = ?`,
361
+ [parentDep.depends_on_id]
362
+ );
363
+ const parentIssues = /** @type {any[]} */ (parentRows);
364
+ if (parentIssues.length > 0) {
365
+ issue.parent_title = parentIssues[0].title;
366
+ issue.parent_status = parentIssues[0].status;
367
+ issue.parent_type = parentIssues[0].issue_type;
368
+ }
369
+ }
370
+
371
+ // Derive dependency/dependent counts
372
+ issue.dependency_count = /** @type {any[]} */ (depRows).filter(
373
+ (d) => d.issue_id === id && d.type === 'blocks'
374
+ ).length;
375
+ issue.dependent_count = /** @type {any[]} */ (depRows).filter(
376
+ (d) => d.depends_on_id === id && d.type === 'blocks'
377
+ ).length;
378
+
379
+ // Fetch labels
380
+ const [labelRows] = await pool.query(
381
+ `SELECT label FROM labels WHERE issue_id = ?`, [id]
382
+ );
383
+ issue.labels = /** @type {any[]} */ (labelRows).map((r) => r.label);
384
+
385
+ // Fetch children (issues that have a parent-child dep pointing to this issue)
386
+ const [childRows] = await pool.query(
387
+ `SELECT i.id, i.title, i.status, i.priority, i.issue_type, i.assignee
388
+ FROM dependencies d
389
+ JOIN issues i ON i.id = d.issue_id
390
+ WHERE d.depends_on_id = ? AND d.type = 'parent-child'
391
+ ORDER BY i.created_at ASC`, [id]
392
+ );
393
+ const children = /** @type {any[]} */ (childRows).map(normalizeRow);
394
+ if (children.length > 0) {
395
+ issue.dependents = children;
396
+ issue.total_children = children.length;
397
+ issue.closed_children = children.filter((c) => c.status === 'closed').length;
398
+ }
399
+
400
+ // Fetch comment count
401
+ const [commentRows] = await pool.query(
402
+ `SELECT COUNT(*) as cnt FROM comments WHERE issue_id = ?`, [id]
403
+ );
404
+ issue.comment_count = /** @type {any[]} */ (commentRows)[0]?.cnt || 0;
405
+
406
+ return { ok: true, item: issue };
407
+ } catch (err) {
408
+ log('queryIssueDetail error: %o', err);
409
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Fetch comments for an issue.
415
+ *
416
+ * @param {string} issueId
417
+ * @returns {Promise<{ ok: true, items: Array<Record<string, unknown>> } | { ok: false, error: { code: string, message: string } }>}
418
+ */
419
+ export async function queryComments(issueId) {
420
+ const pool = getPool();
421
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
422
+ try {
423
+ const [rows] = await pool.query(
424
+ `SELECT id, issue_id, author, text, created_at FROM comments
425
+ WHERE issue_id = ? ORDER BY created_at ASC`, [issueId]
426
+ );
427
+ return { ok: true, items: /** @type {any[]} */ (rows).map(normalizeRow) };
428
+ } catch (err) {
429
+ log('queryComments error: %o', err);
430
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Update a single field on an issue.
436
+ *
437
+ * @param {string} id
438
+ * @param {string} field - SQL column name
439
+ * @param {string | number | null} value
440
+ * @returns {Promise<{ ok: true } | { ok: false, error: { code: string, message: string } }>}
441
+ */
442
+ export async function updateIssueField(id, field, value) {
443
+ const pool = getPool();
444
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
445
+
446
+ // Whitelist allowed columns to prevent SQL injection
447
+ const ALLOWED_FIELDS = new Set([
448
+ 'title', 'description', 'design', 'acceptance_criteria', 'notes',
449
+ 'status', 'priority', 'assignee', 'issue_type'
450
+ ]);
451
+ if (!ALLOWED_FIELDS.has(field)) {
452
+ return { ok: false, error: { code: 'bad_request', message: `Field '${field}' not allowed` } };
453
+ }
454
+
455
+ try {
456
+ const now = new Date().toISOString().replace('T', ' ').replace('Z', '');
457
+ // Handle status=closed → set closed_at
458
+ if (field === 'status' && value === 'closed') {
459
+ await pool.query(
460
+ `UPDATE issues SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?`,
461
+ [now, now, id]
462
+ );
463
+ } else if (field === 'status' && value !== 'closed') {
464
+ // Reopening: clear closed_at
465
+ await pool.query(
466
+ `UPDATE issues SET status = ?, closed_at = NULL, updated_at = ? WHERE id = ?`,
467
+ [value, now, id]
468
+ );
469
+ } else {
470
+ await pool.query(
471
+ `UPDATE issues SET \`${field}\` = ?, updated_at = ? WHERE id = ?`,
472
+ [value, now, id]
473
+ );
474
+ }
475
+ await doltCommit(pool, `update ${field} on ${id}`);
476
+ return { ok: true };
477
+ } catch (err) {
478
+ log('updateIssueField error: %o', err);
479
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Add a comment to an issue.
485
+ *
486
+ * @param {string} issueId
487
+ * @param {string} text
488
+ * @param {string} author
489
+ * @returns {Promise<{ ok: true } | { ok: false, error: { code: string, message: string } }>}
490
+ */
491
+ export async function addComment(issueId, text, author) {
492
+ const pool = getPool();
493
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
494
+ try {
495
+ await pool.query(
496
+ `INSERT INTO comments (issue_id, author, text) VALUES (?, ?, ?)`,
497
+ [issueId, author, text]
498
+ );
499
+ await doltCommit(pool, `add comment on ${issueId}`);
500
+ return { ok: true };
501
+ } catch (err) {
502
+ log('addComment error: %o', err);
503
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Add a dependency.
509
+ *
510
+ * @param {string} issueId
511
+ * @param {string} dependsOnId
512
+ * @param {string} [createdBy]
513
+ * @returns {Promise<{ ok: true } | { ok: false, error: { code: string, message: string } }>}
514
+ */
515
+ export async function addDependency(issueId, dependsOnId, createdBy = '') {
516
+ const pool = getPool();
517
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
518
+ try {
519
+ await pool.query(
520
+ `INSERT IGNORE INTO dependencies (issue_id, depends_on_id, type, created_by)
521
+ VALUES (?, ?, 'blocks', ?)`,
522
+ [issueId, dependsOnId, createdBy]
523
+ );
524
+ await doltCommit(pool, `add dep ${issueId} → ${dependsOnId}`);
525
+ return { ok: true };
526
+ } catch (err) {
527
+ log('addDependency error: %o', err);
528
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
529
+ }
530
+ }
531
+
532
+ /**
533
+ * Remove a dependency.
534
+ *
535
+ * @param {string} issueId
536
+ * @param {string} dependsOnId
537
+ * @returns {Promise<{ ok: true } | { ok: false, error: { code: string, message: string } }>}
538
+ */
539
+ export async function removeDependency(issueId, dependsOnId) {
540
+ const pool = getPool();
541
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
542
+ try {
543
+ await pool.query(
544
+ `DELETE FROM dependencies WHERE issue_id = ? AND depends_on_id = ? AND type = 'blocks'`,
545
+ [issueId, dependsOnId]
546
+ );
547
+ await doltCommit(pool, `remove dep ${issueId} → ${dependsOnId}`);
548
+ return { ok: true };
549
+ } catch (err) {
550
+ log('removeDependency error: %o', err);
551
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
552
+ }
553
+ }
554
+
555
+ /**
556
+ * Add a label to an issue.
557
+ *
558
+ * @param {string} issueId
559
+ * @param {string} label
560
+ * @returns {Promise<{ ok: true } | { ok: false, error: { code: string, message: string } }>}
561
+ */
562
+ export async function addLabel(issueId, label) {
563
+ const pool = getPool();
564
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
565
+ try {
566
+ await pool.query(
567
+ `INSERT IGNORE INTO labels (issue_id, label) VALUES (?, ?)`,
568
+ [issueId, label]
569
+ );
570
+ await doltCommit(pool, `add label '${label}' on ${issueId}`);
571
+ return { ok: true };
572
+ } catch (err) {
573
+ log('addLabel error: %o', err);
574
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Remove a label from an issue.
580
+ *
581
+ * @param {string} issueId
582
+ * @param {string} label
583
+ * @returns {Promise<{ ok: true } | { ok: false, error: { code: string, message: string } }>}
584
+ */
585
+ export async function removeLabel(issueId, label) {
586
+ const pool = getPool();
587
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
588
+ try {
589
+ await pool.query(
590
+ `DELETE FROM labels WHERE issue_id = ? AND label = ?`,
591
+ [issueId, label]
592
+ );
593
+ await doltCommit(pool, `remove label '${label}' from ${issueId}`);
594
+ return { ok: true };
595
+ } catch (err) {
596
+ log('removeLabel error: %o', err);
597
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
598
+ }
599
+ }
600
+
601
+ /**
602
+ * Delete an issue.
603
+ *
604
+ * @param {string} id
605
+ * @returns {Promise<{ ok: true } | { ok: false, error: { code: string, message: string } }>}
606
+ */
607
+ export async function deleteIssue(id) {
608
+ const pool = getPool();
609
+ if (!pool) return { ok: false, error: { code: 'no_pool', message: 'Dolt pool not available' } };
610
+ const conn = await pool.getConnection();
611
+ try {
612
+ await conn.beginTransaction();
613
+ await conn.query(`DELETE FROM comments WHERE issue_id = ?`, [id]);
614
+ await conn.query(`DELETE FROM labels WHERE issue_id = ?`, [id]);
615
+ await conn.query(`DELETE FROM dependencies WHERE issue_id = ? OR depends_on_id = ?`, [id, id]);
616
+ await conn.query(`DELETE FROM issues WHERE id = ?`, [id]);
617
+ await conn.commit();
618
+ conn.release();
619
+ await doltCommit(pool, `delete issue ${id}`);
620
+ return { ok: true };
621
+ } catch (err) {
622
+ try { await conn.rollback(); } catch { /* ignore */ }
623
+ conn.release();
624
+ log('deleteIssue error: %o', err);
625
+ return { ok: false, error: { code: 'db_error', message: String(/** @type {any} */ (err).message) } };
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Dolt commit (auto-commit to working set).
631
+ * Uses CALL dolt_commit() to persist changes to the Dolt commit graph.
632
+ *
633
+ * @param {import('mysql2/promise').Pool} pool
634
+ * @param {string} message
635
+ */
636
+ async function doltCommit(pool, message) {
637
+ try {
638
+ await pool.query(`CALL dolt_commit('-Am', ?)`, [message]);
639
+ } catch (err) {
640
+ // If nothing to commit, that's fine
641
+ const msg = /** @type {any} */ (err).message || '';
642
+ if (!msg.includes('nothing to commit')) {
643
+ log('dolt_commit warning: %s', msg);
644
+ }
645
+ }
646
+ }