@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
|
@@ -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
|
+
}
|