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/.beads/issues.jsonl +107 -0
- package/.editorconfig +10 -0
- package/.eslintrc.json +36 -0
- package/.github/workflows/ci.yml +38 -0
- package/.prettierignore +5 -0
- package/AGENTS.md +85 -0
- package/CHANGES.md +5 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/app/data/providers.js +178 -0
- package/app/data/providers.test.js +126 -0
- package/app/index.html +29 -0
- package/app/main.board-switch.test.js +94 -0
- package/app/main.deep-link.test.js +64 -0
- package/app/main.js +280 -0
- package/app/main.live-updates.test.js +229 -0
- package/app/main.test.js +17 -0
- package/app/main.theme.test.js +41 -0
- package/app/main.view-sync.test.js +54 -0
- package/app/protocol.js +200 -0
- package/app/protocol.md +64 -0
- package/app/protocol.test.js +57 -0
- package/app/router.js +78 -0
- package/app/router.test.js +34 -0
- package/app/state.js +87 -0
- package/app/state.test.js +21 -0
- package/app/styles.css +1343 -0
- package/app/utils/issue-id.js +10 -0
- package/app/utils/issue-type.js +27 -0
- package/app/utils/markdown.js +201 -0
- package/app/utils/markdown.test.js +103 -0
- package/app/utils/priority-badge.js +49 -0
- package/app/utils/priority.js +1 -0
- package/app/utils/status-badge.js +33 -0
- package/app/utils/status.js +23 -0
- package/app/utils/type-badge.js +36 -0
- package/app/utils/type-badge.test.js +30 -0
- package/app/views/board.js +183 -0
- package/app/views/board.test.js +184 -0
- package/app/views/detail.acceptance-notes.test.js +67 -0
- package/app/views/detail.assignee.test.js +161 -0
- package/app/views/detail.deps.test.js +97 -0
- package/app/views/detail.edits.test.js +146 -0
- package/app/views/detail.js +1039 -0
- package/app/views/detail.labels.test.js +73 -0
- package/app/views/detail.priority.test.js +86 -0
- package/app/views/detail.test.js +188 -0
- package/app/views/detail.ui47.test.js +78 -0
- package/app/views/epics.js +228 -0
- package/app/views/epics.test.js +283 -0
- package/app/views/issue-row.js +191 -0
- package/app/views/list.inline-edits.test.js +84 -0
- package/app/views/list.js +393 -0
- package/app/views/list.test.js +479 -0
- package/app/views/nav.js +67 -0
- package/app/views/nav.test.js +43 -0
- package/app/ws.js +252 -0
- package/app/ws.test.js +168 -0
- package/bin/bdui.js +18 -0
- package/docs/architecture.md +244 -0
- package/docs/db-watching.md +29 -0
- package/docs/quickstart.md +142 -0
- package/eslint.config.js +59 -0
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/package.json +48 -0
- package/prettier.config.js +13 -0
- package/server/app.js +80 -0
- package/server/app.test.js +29 -0
- package/server/bd.js +125 -0
- package/server/bd.test.js +93 -0
- package/server/cli/cli.test.js +109 -0
- package/server/cli/commands.integration.test.js +155 -0
- package/server/cli/commands.js +91 -0
- package/server/cli/commands.unit.test.js +94 -0
- package/server/cli/daemon.js +239 -0
- package/server/cli/index.js +74 -0
- package/server/cli/open.js +96 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +22 -0
- package/server/config.js +29 -0
- package/server/db.js +100 -0
- package/server/db.test.js +70 -0
- package/server/index.js +29 -0
- package/server/protocol.js +3 -0
- package/server/protocol.test.js +87 -0
- package/server/watcher.js +107 -0
- package/server/watcher.test.js +100 -0
- package/server/ws.handlers.test.js +174 -0
- package/server/ws.js +784 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.mutations.test.js +261 -0
- package/server/ws.subscriptions.test.js +116 -0
- package/server/ws.test.js +52 -0
- package/test/setup-vitest.js +12 -0
- package/tsconfig.json +23 -0
- package/vitest.config.mjs +14 -0
package/server/ws.js
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Server } from 'node:http'
|
|
3
|
+
* @import { RawData, WebSocket } from 'ws'
|
|
4
|
+
* @import { MessageType } from '../app/protocol.js'
|
|
5
|
+
*/
|
|
6
|
+
import { WebSocketServer } from 'ws';
|
|
7
|
+
import { runBd, runBdJson } from './bd.js';
|
|
8
|
+
import { isRequest, makeError, makeOk } from './protocol.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{
|
|
12
|
+
* subscribed: boolean,
|
|
13
|
+
* list_filters?: { status?: 'open'|'in_progress'|'closed', ready?: boolean, limit?: number },
|
|
14
|
+
* show_id?: string | null
|
|
15
|
+
* }} ConnectionSubs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @type {WeakMap<WebSocket, ConnectionSubs>} */
|
|
19
|
+
const SUBS = new WeakMap();
|
|
20
|
+
|
|
21
|
+
/** @type {WebSocketServer | null} */
|
|
22
|
+
let CURRENT_WSS = null;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get or initialize the subscription state for a socket.
|
|
26
|
+
* @param {WebSocket} ws
|
|
27
|
+
* @returns {ConnectionSubs}
|
|
28
|
+
*/
|
|
29
|
+
function getSubs(ws) {
|
|
30
|
+
let s = SUBS.get(ws);
|
|
31
|
+
if (!s) {
|
|
32
|
+
s = { subscribed: false, show_id: null };
|
|
33
|
+
SUBS.set(ws, s);
|
|
34
|
+
}
|
|
35
|
+
return s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Emit an issues-changed event to relevant clients when possible, or broadcast to all.
|
|
40
|
+
* Targeting rules:
|
|
41
|
+
* - If `issue` is provided, send to clients that currently show the same id or whose
|
|
42
|
+
* last list filter likely includes the issue (status match or ready=true).
|
|
43
|
+
* - If only `hint` is provided, but contains ids, send to clients that show one of those ids.
|
|
44
|
+
* - Otherwise, send to all open clients.
|
|
45
|
+
* @param {{ ts?: number, hint?: { ids?: string[] } }} payload
|
|
46
|
+
* @param {{ issue?: any }} [options]
|
|
47
|
+
*/
|
|
48
|
+
export function notifyIssuesChanged(payload, options = {}) {
|
|
49
|
+
const wss = CURRENT_WSS;
|
|
50
|
+
if (!wss) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
/** @type {Set<WebSocket>} */
|
|
54
|
+
const recipients = new Set();
|
|
55
|
+
|
|
56
|
+
/** @type {any} */
|
|
57
|
+
const issue = options.issue;
|
|
58
|
+
/** @type {string[]} */
|
|
59
|
+
const hint_ids = Array.isArray(payload?.hint?.ids)
|
|
60
|
+
? /** @type {string[]} */ (payload.hint.ids)
|
|
61
|
+
: [];
|
|
62
|
+
|
|
63
|
+
if (issue && typeof issue === 'object' && issue.id) {
|
|
64
|
+
for (const ws of wss.clients) {
|
|
65
|
+
if (ws.readyState !== ws.OPEN) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const s = getSubs(/** @type {any} */ (ws));
|
|
69
|
+
if (!s.subscribed) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (s.show_id && s.show_id === issue.id) {
|
|
73
|
+
recipients.add(ws);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (s.list_filters) {
|
|
77
|
+
// Ready lists are conservatively invalidated on any change
|
|
78
|
+
if (s.list_filters.ready === true) {
|
|
79
|
+
recipients.add(ws);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// Status lists: invalidate when status matches updated issue
|
|
83
|
+
if (
|
|
84
|
+
s.list_filters.status &&
|
|
85
|
+
String(s.list_filters.status) === String(issue.status || '')
|
|
86
|
+
) {
|
|
87
|
+
recipients.add(ws);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else if (hint_ids.length > 0) {
|
|
93
|
+
for (const ws of wss.clients) {
|
|
94
|
+
if (ws.readyState !== ws.OPEN) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const s = getSubs(/** @type {any} */ (ws));
|
|
98
|
+
if (!s.subscribed) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (s.show_id && hint_ids.includes(s.show_id)) {
|
|
102
|
+
recipients.add(ws);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @type {string} */
|
|
108
|
+
const msg = JSON.stringify({
|
|
109
|
+
id: `evt-${Date.now()}`,
|
|
110
|
+
ok: true,
|
|
111
|
+
type: /** @type {MessageType} */ ('issues-changed'),
|
|
112
|
+
payload: { ts: Date.now(), ...(payload || {}) }
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (recipients.size > 0) {
|
|
116
|
+
for (const ws of recipients) {
|
|
117
|
+
ws.send(msg);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
// Fallback: full broadcast to keep clients consistent
|
|
121
|
+
for (const ws of wss.clients) {
|
|
122
|
+
if (ws.readyState === ws.OPEN) {
|
|
123
|
+
ws.send(msg);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Attach a WebSocket server to an existing HTTP server.
|
|
131
|
+
* @param {Server} http_server
|
|
132
|
+
* @param {{ path?: string, heartbeat_ms?: number }} [options]
|
|
133
|
+
* @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, notifyIssuesChanged: (payload: { ts?: number, hint?: { ids?: string[] } }) => void }}
|
|
134
|
+
*/
|
|
135
|
+
export function attachWsServer(http_server, options = {}) {
|
|
136
|
+
const path = options.path || '/ws';
|
|
137
|
+
const heartbeat_ms = options.heartbeat_ms ?? 30000;
|
|
138
|
+
|
|
139
|
+
const wss = new WebSocketServer({ server: http_server, path });
|
|
140
|
+
CURRENT_WSS = wss;
|
|
141
|
+
|
|
142
|
+
// Heartbeat: track if client answered the last ping
|
|
143
|
+
wss.on('connection', (ws) => {
|
|
144
|
+
// @ts-expect-error add marker property
|
|
145
|
+
ws.isAlive = true;
|
|
146
|
+
|
|
147
|
+
// Initialize subscription state for this connection
|
|
148
|
+
getSubs(/** @type {any} */ (ws));
|
|
149
|
+
|
|
150
|
+
ws.on('pong', () => {
|
|
151
|
+
// @ts-expect-error marker
|
|
152
|
+
ws.isAlive = true;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
ws.on('message', (data) => {
|
|
156
|
+
handleMessage(ws, data);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const interval = setInterval(() => {
|
|
161
|
+
for (const ws of wss.clients) {
|
|
162
|
+
// @ts-expect-error marker
|
|
163
|
+
if (ws.isAlive === false) {
|
|
164
|
+
ws.terminate();
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// @ts-expect-error marker
|
|
168
|
+
ws.isAlive = false;
|
|
169
|
+
ws.ping();
|
|
170
|
+
}
|
|
171
|
+
}, heartbeat_ms);
|
|
172
|
+
|
|
173
|
+
interval.unref?.();
|
|
174
|
+
|
|
175
|
+
wss.on('close', () => {
|
|
176
|
+
clearInterval(interval);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Broadcast a server-initiated event to all open clients.
|
|
181
|
+
* @param {MessageType} type
|
|
182
|
+
* @param {unknown} [payload]
|
|
183
|
+
*/
|
|
184
|
+
function broadcast(type, payload) {
|
|
185
|
+
const msg = JSON.stringify({
|
|
186
|
+
id: `evt-${Date.now()}`,
|
|
187
|
+
ok: true,
|
|
188
|
+
type,
|
|
189
|
+
payload
|
|
190
|
+
});
|
|
191
|
+
for (const ws of wss.clients) {
|
|
192
|
+
if (ws.readyState === ws.OPEN) {
|
|
193
|
+
ws.send(msg);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return { wss, broadcast, notifyIssuesChanged: (p) => notifyIssuesChanged(p) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Handle an incoming message frame and respond to the same socket.
|
|
203
|
+
* @param {WebSocket} ws
|
|
204
|
+
* @param {RawData} data
|
|
205
|
+
*/
|
|
206
|
+
export async function handleMessage(ws, data) {
|
|
207
|
+
/** @type {unknown} */
|
|
208
|
+
let json;
|
|
209
|
+
try {
|
|
210
|
+
json = JSON.parse(data.toString());
|
|
211
|
+
} catch {
|
|
212
|
+
const reply = {
|
|
213
|
+
id: 'unknown',
|
|
214
|
+
ok: false,
|
|
215
|
+
type: 'bad-json',
|
|
216
|
+
error: { code: 'bad_json', message: 'Invalid JSON' }
|
|
217
|
+
};
|
|
218
|
+
ws.send(JSON.stringify(reply));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (!isRequest(json)) {
|
|
223
|
+
const reply = {
|
|
224
|
+
id: 'unknown',
|
|
225
|
+
ok: false,
|
|
226
|
+
type: 'bad-request',
|
|
227
|
+
error: { code: 'bad_request', message: 'Invalid request envelope' }
|
|
228
|
+
};
|
|
229
|
+
ws.send(JSON.stringify(reply));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const req = json;
|
|
234
|
+
|
|
235
|
+
// Dispatch known types here as we implement them. For now, only a ping utility.
|
|
236
|
+
if (req.type === /** @type {any} */ ('ping')) {
|
|
237
|
+
ws.send(JSON.stringify(makeOk(req, { ts: Date.now() })));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// subscribe-updates: mark this connection as event subscriber
|
|
242
|
+
if (req.type === 'subscribe-updates') {
|
|
243
|
+
const s = getSubs(ws);
|
|
244
|
+
s.subscribed = true;
|
|
245
|
+
ws.send(JSON.stringify(makeOk(req, { subscribed: true })));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// list-issues
|
|
250
|
+
if (req.type === 'list-issues') {
|
|
251
|
+
const { filters } = /** @type {any} */ (req.payload || {});
|
|
252
|
+
// When "ready" is requested, use the dedicated bd subcommand
|
|
253
|
+
if (filters && typeof filters === 'object' && filters.ready === true) {
|
|
254
|
+
const res = await runBdJson(['ready', '--json']);
|
|
255
|
+
if (res.code !== 0) {
|
|
256
|
+
const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
|
|
257
|
+
ws.send(JSON.stringify(err));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// Remember subscription scope for this connection
|
|
261
|
+
try {
|
|
262
|
+
const s = getSubs(ws);
|
|
263
|
+
s.list_filters = { ready: true };
|
|
264
|
+
} catch {
|
|
265
|
+
// ignore tracking errors
|
|
266
|
+
}
|
|
267
|
+
ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** @type {string[]} */
|
|
272
|
+
const args = ['list', '--json'];
|
|
273
|
+
if (filters && typeof filters === 'object') {
|
|
274
|
+
if (typeof filters.status === 'string') {
|
|
275
|
+
// Use long flag for clarity and compatibility
|
|
276
|
+
args.push('--status', filters.status);
|
|
277
|
+
}
|
|
278
|
+
if (typeof filters.priority === 'number') {
|
|
279
|
+
args.push('--priority', String(filters.priority));
|
|
280
|
+
}
|
|
281
|
+
if (typeof filters.limit === 'number' && filters.limit > 0) {
|
|
282
|
+
args.push('--limit', String(filters.limit));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const res = await runBdJson(args);
|
|
286
|
+
if (res.code !== 0) {
|
|
287
|
+
const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
|
|
288
|
+
ws.send(JSON.stringify(err));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Remember last non-ready list filter
|
|
292
|
+
try {
|
|
293
|
+
const s = getSubs(ws);
|
|
294
|
+
/** @type {{ status?: any, limit?: any }} */
|
|
295
|
+
const f = filters && typeof filters === 'object' ? filters : {};
|
|
296
|
+
/** @type {any} */
|
|
297
|
+
const st = f.status;
|
|
298
|
+
/** @type {any} */
|
|
299
|
+
const lim = f.limit;
|
|
300
|
+
s.list_filters = {};
|
|
301
|
+
if (st === 'open' || st === 'in_progress' || st === 'closed') {
|
|
302
|
+
s.list_filters.status = st;
|
|
303
|
+
}
|
|
304
|
+
if (typeof lim === 'number') {
|
|
305
|
+
s.list_filters.limit = lim;
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// ignore tracking errors
|
|
309
|
+
}
|
|
310
|
+
ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// epic-status
|
|
315
|
+
if (req.type === /** @type {any} */ ('epic-status')) {
|
|
316
|
+
const res = await runBdJson(['epic', 'status', '--json']);
|
|
317
|
+
if (res.code !== 0) {
|
|
318
|
+
const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
|
|
319
|
+
ws.send(JSON.stringify(err));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// show-issue
|
|
327
|
+
if (req.type === 'show-issue') {
|
|
328
|
+
const { id } = /** @type {any} */ (req.payload);
|
|
329
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
330
|
+
ws.send(
|
|
331
|
+
JSON.stringify(
|
|
332
|
+
makeError(req, 'bad_request', 'payload.id must be a non-empty string')
|
|
333
|
+
)
|
|
334
|
+
);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const res = await runBdJson(['show', id, '--json']);
|
|
338
|
+
if (res.code !== 0) {
|
|
339
|
+
const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
|
|
340
|
+
ws.send(JSON.stringify(err));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
// bd show can return an array when it supports multiple ids;
|
|
344
|
+
// normalize to a single object for the single-id API.
|
|
345
|
+
/** @type {any} */
|
|
346
|
+
const out = Array.isArray(res.stdoutJson)
|
|
347
|
+
? res.stdoutJson[0]
|
|
348
|
+
: res.stdoutJson;
|
|
349
|
+
if (!out) {
|
|
350
|
+
ws.send(JSON.stringify(makeError(req, 'not_found', 'issue not found')));
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Track current detail subscription for this connection
|
|
354
|
+
try {
|
|
355
|
+
const s = getSubs(ws);
|
|
356
|
+
s.show_id = String(id);
|
|
357
|
+
} catch {
|
|
358
|
+
// ignore
|
|
359
|
+
}
|
|
360
|
+
ws.send(JSON.stringify(makeOk(req, out)));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// type updates are not exposed via UI; no handler
|
|
365
|
+
|
|
366
|
+
// update-assignee
|
|
367
|
+
if (req.type === /** @type {any} */ ('update-assignee')) {
|
|
368
|
+
const { id, assignee } = /** @type {any} */ (req.payload || {});
|
|
369
|
+
if (
|
|
370
|
+
typeof id !== 'string' ||
|
|
371
|
+
id.length === 0 ||
|
|
372
|
+
typeof assignee !== 'string'
|
|
373
|
+
) {
|
|
374
|
+
ws.send(
|
|
375
|
+
JSON.stringify(
|
|
376
|
+
makeError(
|
|
377
|
+
req,
|
|
378
|
+
'bad_request',
|
|
379
|
+
'payload requires { id: string, assignee: string }'
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
// Pass empty string to clear assignee when requested
|
|
386
|
+
const res = await runBd(['update', id, '--assignee', assignee]);
|
|
387
|
+
if (res.code !== 0) {
|
|
388
|
+
ws.send(
|
|
389
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const shown = await runBdJson(['show', id, '--json']);
|
|
394
|
+
if (shown.code !== 0) {
|
|
395
|
+
ws.send(
|
|
396
|
+
JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
|
|
397
|
+
);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// update-status
|
|
405
|
+
if (req.type === 'update-status') {
|
|
406
|
+
const { id, status } = /** @type {any} */ (req.payload);
|
|
407
|
+
const allowed = new Set(['open', 'in_progress', 'closed']);
|
|
408
|
+
if (
|
|
409
|
+
typeof id !== 'string' ||
|
|
410
|
+
id.length === 0 ||
|
|
411
|
+
typeof status !== 'string' ||
|
|
412
|
+
!allowed.has(status)
|
|
413
|
+
) {
|
|
414
|
+
ws.send(
|
|
415
|
+
JSON.stringify(
|
|
416
|
+
makeError(
|
|
417
|
+
req,
|
|
418
|
+
'bad_request',
|
|
419
|
+
"payload requires { id: string, status: 'open'|'in_progress'|'closed' }"
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const res = await runBd(['update', id, '--status', status]);
|
|
426
|
+
if (res.code !== 0) {
|
|
427
|
+
ws.send(
|
|
428
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
429
|
+
);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const shown = await runBdJson(['show', id, '--json']);
|
|
433
|
+
if (shown.code !== 0) {
|
|
434
|
+
ws.send(
|
|
435
|
+
JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
|
|
436
|
+
);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
440
|
+
// Push targeted invalidation with updated issue context
|
|
441
|
+
try {
|
|
442
|
+
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
443
|
+
} catch {
|
|
444
|
+
// ignore fanout errors
|
|
445
|
+
}
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// update-priority
|
|
450
|
+
if (req.type === 'update-priority') {
|
|
451
|
+
const { id, priority } = /** @type {any} */ (req.payload);
|
|
452
|
+
if (
|
|
453
|
+
typeof id !== 'string' ||
|
|
454
|
+
id.length === 0 ||
|
|
455
|
+
typeof priority !== 'number' ||
|
|
456
|
+
priority < 0 ||
|
|
457
|
+
priority > 4
|
|
458
|
+
) {
|
|
459
|
+
ws.send(
|
|
460
|
+
JSON.stringify(
|
|
461
|
+
makeError(
|
|
462
|
+
req,
|
|
463
|
+
'bad_request',
|
|
464
|
+
'payload requires { id: string, priority: 0..4 }'
|
|
465
|
+
)
|
|
466
|
+
)
|
|
467
|
+
);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const res = await runBd(['update', id, '--priority', String(priority)]);
|
|
471
|
+
if (res.code !== 0) {
|
|
472
|
+
ws.send(
|
|
473
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
474
|
+
);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const shown = await runBdJson(['show', id, '--json']);
|
|
478
|
+
if (shown.code !== 0) {
|
|
479
|
+
ws.send(
|
|
480
|
+
JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
|
|
481
|
+
);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
485
|
+
try {
|
|
486
|
+
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
487
|
+
} catch {
|
|
488
|
+
// ignore fanout errors
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// edit-text
|
|
494
|
+
if (req.type === 'edit-text') {
|
|
495
|
+
const { id, field, value } = /** @type {any} */ (req.payload);
|
|
496
|
+
if (
|
|
497
|
+
typeof id !== 'string' ||
|
|
498
|
+
id.length === 0 ||
|
|
499
|
+
(field !== 'title' &&
|
|
500
|
+
field !== 'description' &&
|
|
501
|
+
field !== 'acceptance') ||
|
|
502
|
+
typeof value !== 'string'
|
|
503
|
+
) {
|
|
504
|
+
ws.send(
|
|
505
|
+
JSON.stringify(
|
|
506
|
+
makeError(
|
|
507
|
+
req,
|
|
508
|
+
'bad_request',
|
|
509
|
+
"payload requires { id: string, field: 'title'|'description'|'acceptance', value: string }"
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
// Description updates are currently not supported by bd
|
|
516
|
+
if (field === 'description') {
|
|
517
|
+
ws.send(
|
|
518
|
+
JSON.stringify(
|
|
519
|
+
makeError(
|
|
520
|
+
req,
|
|
521
|
+
'bd_error',
|
|
522
|
+
'editing description is not supported by bd'
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
// Map UI fields to bd CLI flags
|
|
529
|
+
// title → --title
|
|
530
|
+
// acceptance → --acceptance-criteria
|
|
531
|
+
const flag = field === 'title' ? '--title' : '--acceptance-criteria';
|
|
532
|
+
const res = await runBd(['update', id, flag, value]);
|
|
533
|
+
if (res.code !== 0) {
|
|
534
|
+
ws.send(
|
|
535
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
536
|
+
);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
const shown = await runBdJson(['show', id, '--json']);
|
|
540
|
+
if (shown.code !== 0) {
|
|
541
|
+
ws.send(
|
|
542
|
+
JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
|
|
543
|
+
);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
547
|
+
try {
|
|
548
|
+
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
549
|
+
} catch {
|
|
550
|
+
// ignore fanout errors
|
|
551
|
+
}
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// create-issue
|
|
556
|
+
if (req.type === /** @type {any} */ ('create-issue')) {
|
|
557
|
+
const { title, type, priority, description } = /** @type {any} */ (
|
|
558
|
+
req.payload || {}
|
|
559
|
+
);
|
|
560
|
+
if (typeof title !== 'string' || title.length === 0) {
|
|
561
|
+
ws.send(
|
|
562
|
+
JSON.stringify(
|
|
563
|
+
makeError(
|
|
564
|
+
req,
|
|
565
|
+
'bad_request',
|
|
566
|
+
'payload requires { title: string, ... }'
|
|
567
|
+
)
|
|
568
|
+
)
|
|
569
|
+
);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
/** @type {string[]} */
|
|
573
|
+
const args = ['create', title];
|
|
574
|
+
if (
|
|
575
|
+
typeof type === 'string' &&
|
|
576
|
+
(type === 'bug' ||
|
|
577
|
+
type === 'feature' ||
|
|
578
|
+
type === 'task' ||
|
|
579
|
+
type === 'epic' ||
|
|
580
|
+
type === 'chore')
|
|
581
|
+
) {
|
|
582
|
+
args.push('-t', type);
|
|
583
|
+
}
|
|
584
|
+
if (typeof priority === 'number' && priority >= 0 && priority <= 4) {
|
|
585
|
+
args.push('-p', String(priority));
|
|
586
|
+
}
|
|
587
|
+
if (typeof description === 'string' && description.length > 0) {
|
|
588
|
+
args.push('-d', description);
|
|
589
|
+
}
|
|
590
|
+
const res = await runBd(args);
|
|
591
|
+
if (res.code !== 0) {
|
|
592
|
+
ws.send(
|
|
593
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
594
|
+
);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// Rely on watcher to refresh clients; reply with a minimal ack
|
|
598
|
+
ws.send(JSON.stringify(makeOk(req, { created: true })));
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// dep-add: payload { a: string, b: string, view_id?: string }
|
|
603
|
+
if (req.type === /** @type {any} */ ('dep-add')) {
|
|
604
|
+
const { a, b, view_id } = /** @type {any} */ (req.payload || {});
|
|
605
|
+
if (
|
|
606
|
+
typeof a !== 'string' ||
|
|
607
|
+
a.length === 0 ||
|
|
608
|
+
typeof b !== 'string' ||
|
|
609
|
+
b.length === 0
|
|
610
|
+
) {
|
|
611
|
+
ws.send(
|
|
612
|
+
JSON.stringify(
|
|
613
|
+
makeError(
|
|
614
|
+
req,
|
|
615
|
+
'bad_request',
|
|
616
|
+
'payload requires { a: string, b: string }'
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const res = await runBd(['dep', 'add', a, b]);
|
|
623
|
+
if (res.code !== 0) {
|
|
624
|
+
ws.send(
|
|
625
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
626
|
+
);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const id = typeof view_id === 'string' && view_id.length > 0 ? view_id : a;
|
|
630
|
+
const shown = await runBdJson(['show', id, '--json']);
|
|
631
|
+
if (shown.code !== 0) {
|
|
632
|
+
ws.send(
|
|
633
|
+
JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
|
|
634
|
+
);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
638
|
+
try {
|
|
639
|
+
// Dependencies can affect readiness; conservatively target by issue id
|
|
640
|
+
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
641
|
+
} catch {
|
|
642
|
+
// ignore fanout errors
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// dep-remove: payload { a: string, b: string, view_id?: string }
|
|
648
|
+
if (req.type === /** @type {any} */ ('dep-remove')) {
|
|
649
|
+
const { a, b, view_id } = /** @type {any} */ (req.payload || {});
|
|
650
|
+
if (
|
|
651
|
+
typeof a !== 'string' ||
|
|
652
|
+
a.length === 0 ||
|
|
653
|
+
typeof b !== 'string' ||
|
|
654
|
+
b.length === 0
|
|
655
|
+
) {
|
|
656
|
+
ws.send(
|
|
657
|
+
JSON.stringify(
|
|
658
|
+
makeError(
|
|
659
|
+
req,
|
|
660
|
+
'bad_request',
|
|
661
|
+
'payload requires { a: string, b: string }'
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
);
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const res = await runBd(['dep', 'remove', a, b]);
|
|
668
|
+
if (res.code !== 0) {
|
|
669
|
+
ws.send(
|
|
670
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
671
|
+
);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const id = typeof view_id === 'string' && view_id.length > 0 ? view_id : a;
|
|
675
|
+
const shown = await runBdJson(['show', id, '--json']);
|
|
676
|
+
if (shown.code !== 0) {
|
|
677
|
+
ws.send(
|
|
678
|
+
JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
|
|
679
|
+
);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
683
|
+
try {
|
|
684
|
+
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
685
|
+
} catch {
|
|
686
|
+
// ignore fanout errors
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// label-add: payload { id: string, label: string }
|
|
692
|
+
if (req.type === /** @type {any} */ ('label-add')) {
|
|
693
|
+
const { id, label } = /** @type {any} */ (req.payload || {});
|
|
694
|
+
if (
|
|
695
|
+
typeof id !== 'string' ||
|
|
696
|
+
id.length === 0 ||
|
|
697
|
+
typeof label !== 'string' ||
|
|
698
|
+
label.trim().length === 0
|
|
699
|
+
) {
|
|
700
|
+
ws.send(
|
|
701
|
+
JSON.stringify(
|
|
702
|
+
makeError(
|
|
703
|
+
req,
|
|
704
|
+
'bad_request',
|
|
705
|
+
'payload requires { id: string, label: non-empty string }'
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const res = await runBd(['label', 'add', id, label.trim()]);
|
|
712
|
+
if (res.code !== 0) {
|
|
713
|
+
ws.send(
|
|
714
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
715
|
+
);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const shown = await runBdJson(['show', id, '--json']);
|
|
719
|
+
if (shown.code !== 0) {
|
|
720
|
+
ws.send(
|
|
721
|
+
JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
|
|
722
|
+
);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
726
|
+
try {
|
|
727
|
+
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
728
|
+
} catch {
|
|
729
|
+
// ignore
|
|
730
|
+
}
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// label-remove: payload { id: string, label: string }
|
|
735
|
+
if (req.type === /** @type {any} */ ('label-remove')) {
|
|
736
|
+
const { id, label } = /** @type {any} */ (req.payload || {});
|
|
737
|
+
if (
|
|
738
|
+
typeof id !== 'string' ||
|
|
739
|
+
id.length === 0 ||
|
|
740
|
+
typeof label !== 'string' ||
|
|
741
|
+
label.trim().length === 0
|
|
742
|
+
) {
|
|
743
|
+
ws.send(
|
|
744
|
+
JSON.stringify(
|
|
745
|
+
makeError(
|
|
746
|
+
req,
|
|
747
|
+
'bad_request',
|
|
748
|
+
'payload requires { id: string, label: non-empty string }'
|
|
749
|
+
)
|
|
750
|
+
)
|
|
751
|
+
);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const res = await runBd(['label', 'remove', id, label.trim()]);
|
|
755
|
+
if (res.code !== 0) {
|
|
756
|
+
ws.send(
|
|
757
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
758
|
+
);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const shown = await runBdJson(['show', id, '--json']);
|
|
762
|
+
if (shown.code !== 0) {
|
|
763
|
+
ws.send(
|
|
764
|
+
JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
|
|
765
|
+
);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
|
|
769
|
+
try {
|
|
770
|
+
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
771
|
+
} catch {
|
|
772
|
+
// ignore
|
|
773
|
+
}
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Unknown type
|
|
778
|
+
const err = makeError(
|
|
779
|
+
req,
|
|
780
|
+
'unknown_type',
|
|
781
|
+
`Unknown message type: ${req.type}`
|
|
782
|
+
);
|
|
783
|
+
ws.send(JSON.stringify(err));
|
|
784
|
+
}
|