@worca/ui 0.1.0-rc.1
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/app/index.html +23 -0
- package/app/main.bundle.js +5738 -0
- package/app/main.bundle.js.map +7 -0
- package/app/styles.css +3897 -0
- package/app/vendor/shoelace-dark.css +483 -0
- package/app/vendor/shoelace-light.css +484 -0
- package/app/vendor/xterm.css +285 -0
- package/bin/worca-ui.js +540 -0
- package/package.json +71 -0
- package/scripts/build-frontend.js +49 -0
- package/server/app.js +421 -0
- package/server/beads-reader.js +199 -0
- package/server/index.js +131 -0
- package/server/log-tailer.js +156 -0
- package/server/multi-watcher.js +237 -0
- package/server/preferences.js +17 -0
- package/server/process-manager.js +546 -0
- package/server/project-registry.js +145 -0
- package/server/project-routes.js +1265 -0
- package/server/settings-merge.js +83 -0
- package/server/settings-reader.js +23 -0
- package/server/settings-validator.js +506 -0
- package/server/watcher-set.js +286 -0
- package/server/watcher.js +357 -0
- package/server/webhook-inbox.js +59 -0
- package/server/worca-setup.js +114 -0
- package/server/ws-beads-watcher.js +62 -0
- package/server/ws-broadcaster.js +106 -0
- package/server/ws-client-manager.js +129 -0
- package/server/ws-event-watcher.js +124 -0
- package/server/ws-log-watcher.js +299 -0
- package/server/ws-message-router.js +870 -0
- package/server/ws-modular.js +309 -0
- package/server/ws-status-watcher.js +259 -0
- package/server/ws.js +5 -0
package/server/app.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// server/app.js
|
|
2
|
+
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
|
|
9
|
+
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
10
|
+
import { ProcessManager } from './process-manager.js';
|
|
11
|
+
import {
|
|
12
|
+
createProjectRoutes,
|
|
13
|
+
createProjectScopedRoutes,
|
|
14
|
+
projectResolver,
|
|
15
|
+
} from './project-routes.js';
|
|
16
|
+
import { createInbox } from './webhook-inbox.js';
|
|
17
|
+
|
|
18
|
+
export function createApp(options = {}) {
|
|
19
|
+
const app = express();
|
|
20
|
+
const appDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
|
|
21
|
+
const { settingsPath, worcaDir, projectRoot, prefsDir } = options;
|
|
22
|
+
|
|
23
|
+
app.use(express.json());
|
|
24
|
+
|
|
25
|
+
// ─── Security headers ──────────────────────────────────────────────────
|
|
26
|
+
app.use((_req, res, next) => {
|
|
27
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
28
|
+
res.setHeader('X-Frame-Options', 'DENY');
|
|
29
|
+
next();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ─── CSRF Origin check ────────────────────────────────────────────────
|
|
33
|
+
// Block cross-origin state-mutating requests. Webhooks from pipeline
|
|
34
|
+
// processes use X-Worca-Event header to bypass (they aren't browsers).
|
|
35
|
+
app.use((req, res, next) => {
|
|
36
|
+
if (
|
|
37
|
+
req.method === 'GET' ||
|
|
38
|
+
req.method === 'HEAD' ||
|
|
39
|
+
req.method === 'OPTIONS'
|
|
40
|
+
) {
|
|
41
|
+
return next();
|
|
42
|
+
}
|
|
43
|
+
// Allow non-browser clients (webhook callbacks, curl, etc.)
|
|
44
|
+
if (req.headers['x-worca-event']) return next();
|
|
45
|
+
|
|
46
|
+
const origin = req.headers.origin;
|
|
47
|
+
if (!origin) return next(); // non-browser request (curl, server-to-server)
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const parsed = new URL(origin);
|
|
51
|
+
const host = parsed.hostname;
|
|
52
|
+
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') {
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// malformed origin — reject
|
|
57
|
+
}
|
|
58
|
+
res
|
|
59
|
+
.status(403)
|
|
60
|
+
.json({ ok: false, error: 'Forbidden: cross-origin request' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Webhook inbox — shared in-memory store (also exposed for WS server)
|
|
64
|
+
const webhookInbox = options.webhookInbox || createInbox();
|
|
65
|
+
app.locals.webhookInbox = webhookInbox;
|
|
66
|
+
|
|
67
|
+
// ─── Legacy single-project API ─────────────────────────────────────────
|
|
68
|
+
// Mounts the shared project-scoped routes at /api with a middleware that
|
|
69
|
+
// injects req.project from the closure options, so /api/runs, /api/settings,
|
|
70
|
+
// etc. work identically to /api/projects/:projectId/runs, etc.
|
|
71
|
+
app.use(
|
|
72
|
+
'/api',
|
|
73
|
+
(req, _res, next) => {
|
|
74
|
+
req.project = {
|
|
75
|
+
name: 'default',
|
|
76
|
+
path: projectRoot || process.cwd(),
|
|
77
|
+
worcaDir,
|
|
78
|
+
settingsPath,
|
|
79
|
+
projectRoot: projectRoot || process.cwd(),
|
|
80
|
+
pm: worcaDir
|
|
81
|
+
? new ProcessManager({
|
|
82
|
+
worcaDir,
|
|
83
|
+
projectRoot: projectRoot || process.cwd(),
|
|
84
|
+
})
|
|
85
|
+
: null,
|
|
86
|
+
};
|
|
87
|
+
next();
|
|
88
|
+
},
|
|
89
|
+
createProjectScopedRoutes(),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// ─── Unique routes (not in project-scoped router) ──────────────────────
|
|
93
|
+
|
|
94
|
+
// GET /api/beads/issues
|
|
95
|
+
app.get('/api/beads/issues', (_req, res) => {
|
|
96
|
+
if (!worcaDir)
|
|
97
|
+
return res
|
|
98
|
+
.status(501)
|
|
99
|
+
.json({ ok: false, error: 'worcaDir not configured' });
|
|
100
|
+
const beadsDbPath = join(worcaDir, '..', '.beads', 'beads.db');
|
|
101
|
+
if (!dbExists(beadsDbPath)) {
|
|
102
|
+
return res.json({
|
|
103
|
+
ok: true,
|
|
104
|
+
issues: [],
|
|
105
|
+
dbExists: false,
|
|
106
|
+
dbPath: beadsDbPath,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const issues = listIssues(beadsDbPath);
|
|
111
|
+
res.json({ ok: true, issues, dbExists: true, dbPath: beadsDbPath });
|
|
112
|
+
} catch (err) {
|
|
113
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// POST /api/beads/issues/:id/start
|
|
118
|
+
app.post('/api/beads/issues/:id/start', async (req, res) => {
|
|
119
|
+
if (!worcaDir)
|
|
120
|
+
return res
|
|
121
|
+
.status(501)
|
|
122
|
+
.json({ ok: false, error: 'worcaDir not configured' });
|
|
123
|
+
const issueId = parseInt(req.params.id, 10);
|
|
124
|
+
if (!Number.isInteger(issueId) || issueId <= 0) {
|
|
125
|
+
return res
|
|
126
|
+
.status(400)
|
|
127
|
+
.json({ ok: false, error: 'Issue ID must be a positive integer' });
|
|
128
|
+
}
|
|
129
|
+
const beadsDbPath = join(worcaDir, '..', '.beads', 'beads.db');
|
|
130
|
+
const issue = getIssue(beadsDbPath, issueId);
|
|
131
|
+
if (!issue) {
|
|
132
|
+
return res
|
|
133
|
+
.status(404)
|
|
134
|
+
.json({ ok: false, error: `Issue ${issueId} not found` });
|
|
135
|
+
}
|
|
136
|
+
if (issue.status !== 'ready') {
|
|
137
|
+
return res.status(409).json({
|
|
138
|
+
ok: false,
|
|
139
|
+
error: `Issue ${issueId} is not in 'ready' state (current: ${issue.status})`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (issue.blocked_by.length > 0) {
|
|
143
|
+
return res.status(409).json({
|
|
144
|
+
ok: false,
|
|
145
|
+
error: `Issue ${issueId} is blocked by issues: ${issue.blocked_by.join(', ')}`,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
const pm = new ProcessManager({
|
|
150
|
+
worcaDir,
|
|
151
|
+
projectRoot: projectRoot || process.cwd(),
|
|
152
|
+
});
|
|
153
|
+
const prompt =
|
|
154
|
+
`[Beads #${issue.id}] ${issue.title}\n\n${(issue.body || '').trim()}`.trim();
|
|
155
|
+
const result = await pm.startPipeline({
|
|
156
|
+
inputType: 'prompt',
|
|
157
|
+
inputValue: prompt,
|
|
158
|
+
msize: 1,
|
|
159
|
+
mloops: 1,
|
|
160
|
+
});
|
|
161
|
+
if (app.locals.broadcast) {
|
|
162
|
+
app.locals.broadcast('run-started', { pid: result.pid });
|
|
163
|
+
}
|
|
164
|
+
res.json({ ok: true, pid: result.pid, issueId, prompt });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const status = (err.message || '').includes('already running')
|
|
167
|
+
? 409
|
|
168
|
+
: 500;
|
|
169
|
+
res.status(status).json({ ok: false, error: err.message });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// POST /api/webhooks/test — send a pipeline.test.ping to a webhook URL
|
|
174
|
+
app.post('/api/webhooks/test', async (req, res) => {
|
|
175
|
+
const { url, secret, timeout_ms } = req.body || {};
|
|
176
|
+
|
|
177
|
+
const trimmedUrl = typeof url === 'string' ? url.trim() : '';
|
|
178
|
+
if (!trimmedUrl) {
|
|
179
|
+
return res.status(400).json({ ok: false, error: 'url is required' });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let parsedUrl;
|
|
183
|
+
try {
|
|
184
|
+
parsedUrl = new URL(trimmedUrl);
|
|
185
|
+
} catch {
|
|
186
|
+
return res
|
|
187
|
+
.status(400)
|
|
188
|
+
.json({ ok: false, error: 'url is not a valid URL' });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
|
192
|
+
return res
|
|
193
|
+
.status(400)
|
|
194
|
+
.json({ ok: false, error: 'url must use http or https protocol' });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const event = {
|
|
198
|
+
schema_version: '1',
|
|
199
|
+
event_id: randomUUID(),
|
|
200
|
+
event_type: 'pipeline.test.ping',
|
|
201
|
+
timestamp: new Date().toISOString(),
|
|
202
|
+
run_id: null,
|
|
203
|
+
pipeline: null,
|
|
204
|
+
payload: { test: true },
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const body = JSON.stringify(event);
|
|
208
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
209
|
+
|
|
210
|
+
if (secret && typeof secret === 'string' && secret.length > 0) {
|
|
211
|
+
const hmac = createHmac('sha256', secret);
|
|
212
|
+
hmac.update(body);
|
|
213
|
+
headers['X-Worca-Signature'] = `sha256=${hmac.digest('hex')}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const timeoutMs =
|
|
217
|
+
typeof timeout_ms === 'number' && timeout_ms > 0
|
|
218
|
+
? Math.min(timeout_ms, 30000)
|
|
219
|
+
: 10000;
|
|
220
|
+
|
|
221
|
+
const startMs = Date.now();
|
|
222
|
+
try {
|
|
223
|
+
const controller = new AbortController();
|
|
224
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
225
|
+
let response;
|
|
226
|
+
try {
|
|
227
|
+
response = await fetch(trimmedUrl, {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers,
|
|
230
|
+
body,
|
|
231
|
+
signal: controller.signal,
|
|
232
|
+
});
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(timer);
|
|
235
|
+
}
|
|
236
|
+
res.json({
|
|
237
|
+
ok: true,
|
|
238
|
+
status_code: response.status,
|
|
239
|
+
response_ms: Date.now() - startMs,
|
|
240
|
+
});
|
|
241
|
+
} catch (err) {
|
|
242
|
+
res.json({
|
|
243
|
+
ok: false,
|
|
244
|
+
error: err.message,
|
|
245
|
+
response_ms: Date.now() - startMs,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// POST /api/webhooks/inbox — receive webhook events
|
|
251
|
+
app.post('/api/webhooks/inbox', (req, res) => {
|
|
252
|
+
const headers = {
|
|
253
|
+
'x-worca-event': req.headers['x-worca-event'] || '',
|
|
254
|
+
'x-worca-delivery': req.headers['x-worca-delivery'] || '',
|
|
255
|
+
'x-worca-signature': req.headers['x-worca-signature'] || '',
|
|
256
|
+
'content-type': req.headers['content-type'] || '',
|
|
257
|
+
};
|
|
258
|
+
const runId = req.body?.run_id || null;
|
|
259
|
+
const projectId =
|
|
260
|
+
runId && app.locals.resolveRunProject
|
|
261
|
+
? app.locals.resolveRunProject(runId)
|
|
262
|
+
: null;
|
|
263
|
+
const stored = webhookInbox.push({
|
|
264
|
+
headers,
|
|
265
|
+
envelope: req.body || {},
|
|
266
|
+
projectId,
|
|
267
|
+
});
|
|
268
|
+
if (app.locals.broadcast) {
|
|
269
|
+
app.locals.broadcast('webhook-inbox-event', stored);
|
|
270
|
+
}
|
|
271
|
+
res.json({ control: { action: webhookInbox.getControlAction() } });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// GET /api/webhooks/inbox — list stored events
|
|
275
|
+
app.get('/api/webhooks/inbox', (req, res) => {
|
|
276
|
+
const since =
|
|
277
|
+
req.query.since != null ? parseInt(req.query.since, 10) : undefined;
|
|
278
|
+
const projectId = req.query.projectId || undefined;
|
|
279
|
+
res.json({
|
|
280
|
+
ok: true,
|
|
281
|
+
events: webhookInbox.list(since, projectId),
|
|
282
|
+
controlAction: webhookInbox.getControlAction(),
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// DELETE /api/webhooks/inbox — clear all events
|
|
287
|
+
app.delete('/api/webhooks/inbox', (_req, res) => {
|
|
288
|
+
webhookInbox.clear();
|
|
289
|
+
if (app.locals.broadcast) {
|
|
290
|
+
app.locals.broadcast('webhook-inbox-cleared', {});
|
|
291
|
+
}
|
|
292
|
+
res.json({ ok: true });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// GET /api/webhooks/inbox/control — get current control action
|
|
296
|
+
app.get('/api/webhooks/inbox/control', (_req, res) => {
|
|
297
|
+
res.json({ ok: true, action: webhookInbox.getControlAction() });
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// PUT /api/webhooks/inbox/control — set control action
|
|
301
|
+
app.put('/api/webhooks/inbox/control', (req, res) => {
|
|
302
|
+
const { action } = req.body || {};
|
|
303
|
+
if (!['continue', 'pause', 'abort'].includes(action)) {
|
|
304
|
+
return res.status(400).json({
|
|
305
|
+
ok: false,
|
|
306
|
+
error: 'action must be "continue", "pause", or "abort"',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
webhookInbox.setControlAction(action);
|
|
310
|
+
if (app.locals.broadcast) {
|
|
311
|
+
app.locals.broadcast('webhook-control-changed', { action });
|
|
312
|
+
}
|
|
313
|
+
res.json({ ok: true, action });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// GET /api/project-info
|
|
317
|
+
app.get('/api/project-info', (_req, res) => {
|
|
318
|
+
res.json({ name: projectRoot ? basename(projectRoot) : '' });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// POST /api/projects/inbox — webhook hint for immediate status refresh
|
|
322
|
+
app.post('/api/projects/inbox', (req, res) => {
|
|
323
|
+
const body = req.body || {};
|
|
324
|
+
const projectId =
|
|
325
|
+
body.project_id ||
|
|
326
|
+
req.headers['x-worca-project'] ||
|
|
327
|
+
(body.run_id && app.locals.resolveRunProject?.(body.run_id)) ||
|
|
328
|
+
null;
|
|
329
|
+
|
|
330
|
+
if (!projectId) {
|
|
331
|
+
return res.status(400).json({
|
|
332
|
+
ok: false,
|
|
333
|
+
error:
|
|
334
|
+
'Could not identify project. Provide project_id, X-Worca-Project header, or run_id.',
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const refreshed = app.locals.scheduleRefresh?.(projectId);
|
|
339
|
+
if (refreshed === false) {
|
|
340
|
+
console.warn(`[webhook-hint] unknown project: ${projectId}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
res.json({ ok: true, project: projectId });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// POST /api/choose-directory — native folder picker (cross-platform)
|
|
347
|
+
app.post('/api/choose-directory', (_req, res) => {
|
|
348
|
+
try {
|
|
349
|
+
let chosenPath;
|
|
350
|
+
if (process.platform === 'darwin') {
|
|
351
|
+
chosenPath = execFileSync(
|
|
352
|
+
'osascript',
|
|
353
|
+
[
|
|
354
|
+
'-e',
|
|
355
|
+
'POSIX path of (choose folder with prompt "Select project directory")',
|
|
356
|
+
],
|
|
357
|
+
{ encoding: 'utf8' },
|
|
358
|
+
).trim();
|
|
359
|
+
} else if (process.platform === 'win32') {
|
|
360
|
+
const ps = execFileSync(
|
|
361
|
+
'powershell.exe',
|
|
362
|
+
[
|
|
363
|
+
'-NoProfile',
|
|
364
|
+
'-Command',
|
|
365
|
+
'Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.FolderBrowserDialog; $d.Description = "Select project directory"; if ($d.ShowDialog() -eq "OK") { $d.SelectedPath } else { exit 1 }',
|
|
366
|
+
],
|
|
367
|
+
{ encoding: 'utf8' },
|
|
368
|
+
).trim();
|
|
369
|
+
chosenPath = ps;
|
|
370
|
+
} else {
|
|
371
|
+
// Linux: try zenity, then kdialog
|
|
372
|
+
try {
|
|
373
|
+
chosenPath = execFileSync(
|
|
374
|
+
'zenity',
|
|
375
|
+
[
|
|
376
|
+
'--file-selection',
|
|
377
|
+
'--directory',
|
|
378
|
+
'--title=Select project directory',
|
|
379
|
+
],
|
|
380
|
+
{ encoding: 'utf8' },
|
|
381
|
+
).trim();
|
|
382
|
+
} catch {
|
|
383
|
+
chosenPath = execFileSync(
|
|
384
|
+
'kdialog',
|
|
385
|
+
[
|
|
386
|
+
'--getexistingdirectory',
|
|
387
|
+
'.',
|
|
388
|
+
'--title',
|
|
389
|
+
'Select project directory',
|
|
390
|
+
],
|
|
391
|
+
{ encoding: 'utf8' },
|
|
392
|
+
).trim();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
chosenPath = chosenPath.replace(/[\\/]+$/, '');
|
|
396
|
+
if (chosenPath) {
|
|
397
|
+
res.json({ ok: true, path: chosenPath });
|
|
398
|
+
} else {
|
|
399
|
+
res.json({ ok: false });
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
res.json({ ok: false });
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ─── Multi-project routes ──────────────────────────────────────────────
|
|
407
|
+
if (prefsDir) {
|
|
408
|
+
app.use('/api/projects', createProjectRoutes({ prefsDir, projectRoot }));
|
|
409
|
+
app.use(
|
|
410
|
+
'/api/projects/:projectId',
|
|
411
|
+
projectResolver({ prefsDir, projectRoot }),
|
|
412
|
+
createProjectScopedRoutes(),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
app.use(express.static(appDir));
|
|
417
|
+
app.get('/{*splat}', (_req, res) => {
|
|
418
|
+
res.sendFile('index.html', { root: appDir });
|
|
419
|
+
});
|
|
420
|
+
return app;
|
|
421
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
|
|
4
|
+
export function dbExists(beadsDb) {
|
|
5
|
+
return existsSync(beadsDb);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function listIssues(beadsDb) {
|
|
9
|
+
let db;
|
|
10
|
+
try {
|
|
11
|
+
db = new Database(beadsDb, { readonly: true, fileMustExist: true });
|
|
12
|
+
const rows = db
|
|
13
|
+
.prepare(
|
|
14
|
+
`SELECT id, title, description AS body, status, priority, created_at, external_ref
|
|
15
|
+
FROM issues
|
|
16
|
+
WHERE status NOT IN ('closed','tombstone')
|
|
17
|
+
ORDER BY priority ASC, id ASC`,
|
|
18
|
+
)
|
|
19
|
+
.all();
|
|
20
|
+
|
|
21
|
+
const depStmt = db.prepare(
|
|
22
|
+
`SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
|
|
23
|
+
);
|
|
24
|
+
const statusMap = new Map(rows.map((r) => [r.id, r.status]));
|
|
25
|
+
|
|
26
|
+
return rows.map((row) => {
|
|
27
|
+
const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
|
|
28
|
+
const blocked_by = depends_on.filter((depId) => statusMap.has(depId));
|
|
29
|
+
return { ...row, depends_on, blocked_by };
|
|
30
|
+
});
|
|
31
|
+
} catch {
|
|
32
|
+
return [];
|
|
33
|
+
} finally {
|
|
34
|
+
try {
|
|
35
|
+
db?.close();
|
|
36
|
+
} catch {
|
|
37
|
+
/* ignore */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listIssuesByLabel(beadsDb, label) {
|
|
43
|
+
let db;
|
|
44
|
+
try {
|
|
45
|
+
db = new Database(beadsDb, { readonly: true, fileMustExist: true });
|
|
46
|
+
const rows = db
|
|
47
|
+
.prepare(
|
|
48
|
+
`SELECT i.id, i.title, i.description AS body, i.status, i.priority, i.created_at
|
|
49
|
+
FROM issues i
|
|
50
|
+
JOIN labels l ON l.issue_id = i.id
|
|
51
|
+
WHERE l.label = ?
|
|
52
|
+
ORDER BY i.priority ASC, i.id ASC`,
|
|
53
|
+
)
|
|
54
|
+
.all(label);
|
|
55
|
+
|
|
56
|
+
const depStmt = db.prepare(
|
|
57
|
+
`SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
|
|
58
|
+
);
|
|
59
|
+
const statusMap = new Map(rows.map((r) => [r.id, r.status]));
|
|
60
|
+
|
|
61
|
+
return rows.map((row) => {
|
|
62
|
+
const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
|
|
63
|
+
const blocked_by = depends_on.filter((depId) => {
|
|
64
|
+
const s = statusMap.get(depId);
|
|
65
|
+
return s && s !== 'closed';
|
|
66
|
+
});
|
|
67
|
+
return { ...row, depends_on, blocked_by };
|
|
68
|
+
});
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
} finally {
|
|
72
|
+
try {
|
|
73
|
+
db?.close();
|
|
74
|
+
} catch {
|
|
75
|
+
/* ignore */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function listUnlinkedIssues(beadsDb) {
|
|
81
|
+
let db;
|
|
82
|
+
try {
|
|
83
|
+
db = new Database(beadsDb, { readonly: true, fileMustExist: true });
|
|
84
|
+
const rows = db
|
|
85
|
+
.prepare(
|
|
86
|
+
`SELECT i.id, i.title, i.description AS body, i.status, i.priority, i.created_at
|
|
87
|
+
FROM issues i
|
|
88
|
+
WHERE NOT EXISTS (
|
|
89
|
+
SELECT 1 FROM labels l WHERE l.issue_id = i.id AND l.label LIKE 'run:%'
|
|
90
|
+
)
|
|
91
|
+
AND i.status NOT IN ('closed','tombstone')
|
|
92
|
+
ORDER BY i.priority ASC, i.id ASC`,
|
|
93
|
+
)
|
|
94
|
+
.all();
|
|
95
|
+
|
|
96
|
+
const depStmt = db.prepare(
|
|
97
|
+
`SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
|
|
98
|
+
);
|
|
99
|
+
const statusMap = new Map(rows.map((r) => [r.id, r.status]));
|
|
100
|
+
|
|
101
|
+
return rows.map((row) => {
|
|
102
|
+
const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
|
|
103
|
+
const blocked_by = depends_on.filter((depId) => statusMap.has(depId));
|
|
104
|
+
return { ...row, depends_on, blocked_by };
|
|
105
|
+
});
|
|
106
|
+
} catch {
|
|
107
|
+
return [];
|
|
108
|
+
} finally {
|
|
109
|
+
try {
|
|
110
|
+
db?.close();
|
|
111
|
+
} catch {
|
|
112
|
+
/* ignore */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function countIssuesByRunLabel(beadsDb) {
|
|
118
|
+
let db;
|
|
119
|
+
try {
|
|
120
|
+
db = new Database(beadsDb, { readonly: true, fileMustExist: true });
|
|
121
|
+
const rows = db
|
|
122
|
+
.prepare(
|
|
123
|
+
`SELECT l.label, COUNT(*) AS count FROM labels l
|
|
124
|
+
WHERE l.label LIKE 'run:%' GROUP BY l.label`,
|
|
125
|
+
)
|
|
126
|
+
.all();
|
|
127
|
+
const counts = {};
|
|
128
|
+
for (const row of rows) {
|
|
129
|
+
const runId = row.label.replace('run:', '');
|
|
130
|
+
counts[runId] = row.count;
|
|
131
|
+
}
|
|
132
|
+
return counts;
|
|
133
|
+
} catch {
|
|
134
|
+
return {};
|
|
135
|
+
} finally {
|
|
136
|
+
try {
|
|
137
|
+
db?.close();
|
|
138
|
+
} catch {
|
|
139
|
+
/* ignore */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function listDistinctRunLabels(beadsDb) {
|
|
145
|
+
let db;
|
|
146
|
+
try {
|
|
147
|
+
db = new Database(beadsDb, { readonly: true, fileMustExist: true });
|
|
148
|
+
const rows = db
|
|
149
|
+
.prepare(`SELECT DISTINCT label FROM labels WHERE label LIKE 'run:%'`)
|
|
150
|
+
.all();
|
|
151
|
+
return rows.map((r) => r.label);
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
} finally {
|
|
155
|
+
try {
|
|
156
|
+
db?.close();
|
|
157
|
+
} catch {
|
|
158
|
+
/* ignore */
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function getIssue(beadsDb, id) {
|
|
164
|
+
let db;
|
|
165
|
+
try {
|
|
166
|
+
db = new Database(beadsDb, { readonly: true, fileMustExist: true });
|
|
167
|
+
const row = db
|
|
168
|
+
.prepare(
|
|
169
|
+
`SELECT id, title, description AS body, status, priority, created_at, external_ref
|
|
170
|
+
FROM issues WHERE id = ?`,
|
|
171
|
+
)
|
|
172
|
+
.get(id);
|
|
173
|
+
if (!row) return null;
|
|
174
|
+
|
|
175
|
+
const depends_on = db
|
|
176
|
+
.prepare(`SELECT depends_on_id FROM dependencies WHERE issue_id = ?`)
|
|
177
|
+
.all(id)
|
|
178
|
+
.map((d) => d.depends_on_id);
|
|
179
|
+
|
|
180
|
+
const blocked_by = [];
|
|
181
|
+
for (const depId of depends_on) {
|
|
182
|
+
const dep = db
|
|
183
|
+
.prepare(`SELECT status FROM issues WHERE id = ?`)
|
|
184
|
+
.get(depId);
|
|
185
|
+
if (dep && dep.status !== 'closed' && dep.status !== 'tombstone') {
|
|
186
|
+
blocked_by.push(depId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { ...row, depends_on, blocked_by };
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
} finally {
|
|
193
|
+
try {
|
|
194
|
+
db?.close();
|
|
195
|
+
} catch {
|
|
196
|
+
/* ignore */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|