@starlove/orbit 1.0.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.
Files changed (86) hide show
  1. package/.dockerignore +13 -0
  2. package/Dockerfile +34 -0
  3. package/README.md +216 -0
  4. package/SKILL-ORBIT.md +94 -0
  5. package/build.mjs +204 -0
  6. package/dist/full/apple-touch-icon.png +0 -0
  7. package/dist/full/cursors/live_arrow.cur +0 -0
  8. package/dist/full/cursors/live_link.cur +0 -0
  9. package/dist/full/cursors/live_move.cur +0 -0
  10. package/dist/full/cursors/live_select.cur +0 -0
  11. package/dist/full/cursors/live_unavail.cur +0 -0
  12. package/dist/full/favicon-32x32.png +0 -0
  13. package/dist/full/index.html +314 -0
  14. package/dist/full/js/app.70be7d06.js +554 -0
  15. package/dist/full/js/app.70be7d06.js.map +7 -0
  16. package/dist/full/orbits-favicon.png +0 -0
  17. package/dist/full/orbits-logo-bg.png +0 -0
  18. package/dist/full/orbits-logo.png +0 -0
  19. package/dist/full/styles.c2e3d20a.css +4892 -0
  20. package/dist/full/version.json +6 -0
  21. package/docs/AGENT_PROTOCOL.md +379 -0
  22. package/docs/MCP_SETUP.md +49 -0
  23. package/docs/mcp-client.example.json +13 -0
  24. package/package.json +62 -0
  25. package/pnpm-workspace.yaml +2 -0
  26. package/public/apple-touch-icon.png +0 -0
  27. package/public/cursors/live_arrow.cur +0 -0
  28. package/public/cursors/live_link.cur +0 -0
  29. package/public/cursors/live_move.cur +0 -0
  30. package/public/cursors/live_select.cur +0 -0
  31. package/public/cursors/live_unavail.cur +0 -0
  32. package/public/favicon-32x32.png +0 -0
  33. package/public/index.html +314 -0
  34. package/public/js/api.js +35 -0
  35. package/public/js/app.js +35 -0
  36. package/public/js/board-menu.js +145 -0
  37. package/public/js/config.js +19 -0
  38. package/public/js/create-card.js +153 -0
  39. package/public/js/dom.js +19 -0
  40. package/public/js/drawer.js +137 -0
  41. package/public/js/format.js +108 -0
  42. package/public/js/kanban.js +475 -0
  43. package/public/js/local-backend.js +934 -0
  44. package/public/js/main.js +142 -0
  45. package/public/js/modal.js +31 -0
  46. package/public/js/router.js +114 -0
  47. package/public/js/search.js +53 -0
  48. package/public/js/settings-tabs.js +7 -0
  49. package/public/js/settings.js +931 -0
  50. package/public/js/sse.js +156 -0
  51. package/public/js/starscape.js +315 -0
  52. package/public/js/state.js +54 -0
  53. package/public/js/ticket-detail.js +766 -0
  54. package/public/js/toast.js +74 -0
  55. package/public/js/transport.js +22 -0
  56. package/public/js/unread.js +121 -0
  57. package/public/orbits-favicon.png +0 -0
  58. package/public/orbits-logo-bg.png +0 -0
  59. package/public/orbits-logo.png +0 -0
  60. package/public/styles.css +4892 -0
  61. package/src/cli/orbit.js +438 -0
  62. package/src/core/agent.js +262 -0
  63. package/src/core/auth.js +49 -0
  64. package/src/core/backups.js +136 -0
  65. package/src/core/board.js +112 -0
  66. package/src/core/boards.js +310 -0
  67. package/src/core/bootstrap.js +162 -0
  68. package/src/core/db.js +237 -0
  69. package/src/core/delete-board.js +53 -0
  70. package/src/core/events.js +314 -0
  71. package/src/core/http.js +81 -0
  72. package/src/core/paths.js +25 -0
  73. package/src/core/provision-repo-board.js +113 -0
  74. package/src/core/queries.js +246 -0
  75. package/src/core/registry.js +207 -0
  76. package/src/core/relations.js +163 -0
  77. package/src/core/router.js +414 -0
  78. package/src/core/search.js +70 -0
  79. package/src/core/seed.js +226 -0
  80. package/src/core/snapshots.js +255 -0
  81. package/src/core/states.js +167 -0
  82. package/src/core/system-picker.js +93 -0
  83. package/src/core/tickets.js +344 -0
  84. package/src/core/util.js +105 -0
  85. package/src/mcp-server.js +773 -0
  86. package/src/server.js +48 -0
@@ -0,0 +1,414 @@
1
+ // HTTP router. Every request resolves its own per-call `ctx = {actor, board,
2
+ // db}` from the request itself — there is intentionally no module-level
3
+ // "active board" pointer that could race across concurrent requests. If a
4
+ // request reaches a board-scoped route without enough information to pick a
5
+ // board, it is rejected with 400 board_id_required rather than silently
6
+ // inheriting some other caller's recent state.
7
+
8
+ import { localOwnerActor } from "./auth.js";
9
+ import { scheduleAutomaticBoardBackup } from "./backups.js";
10
+ import { readJson, sendJson, setCors } from "./http.js";
11
+ import {
12
+ findBoardForBoardEntry,
13
+ findBoardForRelation,
14
+ findBoardForState,
15
+ findBoardForTicket,
16
+ getBoardByRegistryId,
17
+ getBoardBySlug,
18
+ listBoards,
19
+ openBoardDb,
20
+ touchBoardActive
21
+ } from "./registry.js";
22
+ import { getBootstrap } from "./bootstrap.js";
23
+ import { deleteRegisteredBoard } from "./delete-board.js";
24
+ import {
25
+ createBoard,
26
+ createBoardEntry,
27
+ getBoardContext,
28
+ updateBoard,
29
+ updateBoardEntry
30
+ } from "./boards.js";
31
+ import { exportBoard, importBoardSnapshot } from "./snapshots.js";
32
+ import {
33
+ createState,
34
+ deleteState,
35
+ reorderStates,
36
+ updateState
37
+ } from "./states.js";
38
+ import {
39
+ archiveTicket,
40
+ createComment,
41
+ createTicket,
42
+ deleteTicket,
43
+ getTicketStatusHistory,
44
+ restoreTicket,
45
+ updateTicket
46
+ } from "./tickets.js";
47
+ import { archivedTicketsForBoard } from "./queries.js";
48
+ import { httpError } from "./util.js";
49
+ import { pickFolder } from "./system-picker.js";
50
+ import {
51
+ createRelation,
52
+ deleteRelation,
53
+ getTicketBlockers,
54
+ getTicketRelations
55
+ } from "./relations.js";
56
+ import { searchTickets } from "./search.js";
57
+ import {
58
+ checkpointTicket,
59
+ claimNext,
60
+ completeTicket,
61
+ getContextPack
62
+ } from "./agent.js";
63
+
64
+ /** Build a ctx for an already-resolved registry row + actor. */
65
+ function ctxFromBoardRow(boardRow, actor) {
66
+ return { actor, board: boardRow, db: openBoardDb(boardRow) };
67
+ }
68
+
69
+ function sendMutationJson(res, status, body, ctx) {
70
+ sendJson(res, status, body);
71
+ scheduleAutomaticBoardBackup(ctx.board, ctx.db);
72
+ }
73
+
74
+ /** Resolve a board from a hint (id or slug) or null. Throws 404 if a hint
75
+ * was given but no board matches. */
76
+ function resolveBoardFromHint(idHint, slugHint) {
77
+ if (idHint) {
78
+ const row = getBoardByRegistryId(idHint);
79
+ if (!row) throw httpError(404, "board_not_found");
80
+ return row;
81
+ }
82
+ if (slugHint) {
83
+ const row = getBoardBySlug(slugHint);
84
+ if (!row) throw httpError(404, "board_not_found");
85
+ return row;
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /** Read board hints from query string (or null). */
91
+ function boardHintsFromQuery(url) {
92
+ return {
93
+ boardId: url.searchParams.get("board_id"),
94
+ boardSlug: url.searchParams.get("board")
95
+ };
96
+ }
97
+
98
+ export async function handleApi(req, res, url) {
99
+ setCors(res);
100
+
101
+ if (req.method === "GET" && url.pathname === "/api/health") {
102
+ sendJson(res, 200, { ok: true });
103
+ return;
104
+ }
105
+
106
+ const actor = localOwnerActor();
107
+
108
+ // Bootstrap: read-only registry + one chosen board. No global state mutated.
109
+ if (req.method === "GET" && url.pathname === "/api/bootstrap") {
110
+ sendJson(res, 200, getBootstrap(actor, url.searchParams.get("board_id")));
111
+ return;
112
+ }
113
+
114
+ /** Registry-only board list (small JSON). For tickets/lanes/labels use `GET /api/bootstrap` or open a board context route. */
115
+ if (req.method === "GET" && url.pathname === "/api/boards") {
116
+ sendJson(res, 200, {
117
+ boards: listBoards().map((row) => ({
118
+ id: row.id,
119
+ slug: row.slug,
120
+ name: row.name,
121
+ repo_path: row.repo_path,
122
+ repo_url: row.repo_url,
123
+ default_branch: row.default_branch,
124
+ db_path: row.db_path,
125
+ last_active_at: row.last_active_at ?? null
126
+ }))
127
+ });
128
+ return;
129
+ }
130
+
131
+ if (req.method === "POST" && url.pathname === "/api/system/pick-folder") {
132
+ const result = await pickFolder();
133
+ if (result.unsupported) throw httpError(501, "folder_picker_unavailable");
134
+ sendJson(res, 200, result);
135
+ return;
136
+ }
137
+
138
+ // Board CRUD that doesn't take a per-board ctx.
139
+ if (req.method === "POST" && url.pathname === "/api/boards") {
140
+ const body = await readJson(req);
141
+ const created = createBoard(body, { actor });
142
+ const row = getBoardByRegistryId(created.id);
143
+ if (row) scheduleAutomaticBoardBackup(row, openBoardDb(row));
144
+ sendJson(res, 201, created);
145
+ return;
146
+ }
147
+
148
+ // Path-prefixed board routes: /api/boards/:slugOrId/...
149
+ const boardPrefix = url.pathname.match(/^\/api\/boards\/([^/]+)(?:\/(.*))?$/);
150
+ if (boardPrefix) {
151
+ const ident = decodeURIComponent(boardPrefix[1]);
152
+ const sub = boardPrefix[2] || "";
153
+ const row = getBoardByRegistryId(ident) || getBoardBySlug(ident);
154
+ if (!row) throw httpError(404, "board_not_found");
155
+
156
+ if (sub === "" && req.method === "DELETE") {
157
+ const body = await readJson(req);
158
+ sendJson(res, 200, deleteRegisteredBoard(row, body, actor));
159
+ return;
160
+ }
161
+
162
+ const ctx = ctxFromBoardRow(row, actor);
163
+ touchBoardActive(row.id);
164
+
165
+ if (sub === "" && req.method === "PATCH") {
166
+ const body = await readJson(req);
167
+ sendMutationJson(res, 200, updateBoard(row.id, body, ctx), ctx);
168
+ return;
169
+ }
170
+ if (sub === "context" && req.method === "GET") {
171
+ sendJson(res, 200, getBoardContext(row.id, ctx, {
172
+ includeStruck: url.searchParams.get("include_struck") === "true"
173
+ }));
174
+ return;
175
+ }
176
+ if (sub === "archive" && req.method === "GET") {
177
+ sendJson(res, 200, { tickets: archivedTicketsForBoard(ctx.db, row.id) });
178
+ return;
179
+ }
180
+ if (sub === "entries" && req.method === "POST") {
181
+ const body = await readJson(req);
182
+ sendMutationJson(res, 201, createBoardEntry(row.id, body, ctx), ctx);
183
+ return;
184
+ }
185
+ if (sub === "export" && req.method === "GET") {
186
+ sendJson(res, 200, exportBoard(row.id, ctx));
187
+ return;
188
+ }
189
+ if (sub === "states" && req.method === "POST") {
190
+ const body = await readJson(req);
191
+ sendMutationJson(res, 201, createState(row.id, body, ctx), ctx);
192
+ return;
193
+ }
194
+ if (sub === "states" && req.method === "PATCH") {
195
+ const body = await readJson(req);
196
+ sendMutationJson(res, 200, reorderStates(row.id, body, ctx), ctx);
197
+ return;
198
+ }
199
+ sendJson(res, 404, { error: "not_found" });
200
+ return;
201
+ }
202
+
203
+ // /api/tickets POST — body.board_id required.
204
+ if (req.method === "POST" && url.pathname === "/api/tickets") {
205
+ const body = await readJson(req);
206
+ const hintId = body.board_id || url.searchParams.get("board_id");
207
+ const hintSlug = body.board || url.searchParams.get("board");
208
+ const row = resolveBoardFromHint(hintId, hintSlug);
209
+ if (!row) throw httpError(400, "board_id_required");
210
+ const ctx = ctxFromBoardRow(row, actor);
211
+ sendMutationJson(res, 201, createTicket(body, ctx), ctx);
212
+ return;
213
+ }
214
+
215
+ // /api/tickets/:id/... — board resolved from query hint or via lookup.
216
+ const ticketPath = url.pathname.match(/^\/api\/tickets\/([^/]+)(?:\/(.*))?$/);
217
+ if (ticketPath) {
218
+ const ticketId = decodeURIComponent(ticketPath[1]);
219
+ const sub = ticketPath[2] || "";
220
+ const { boardId, boardSlug } = boardHintsFromQuery(url);
221
+ const hintRow = resolveBoardFromHint(boardId, boardSlug);
222
+ const found = findBoardForTicket(ticketId, hintRow?.id || null);
223
+ if (!found) throw httpError(404, "ticket_not_found");
224
+ const ctx = ctxFromBoardRow(found.board, actor);
225
+
226
+ if (sub === "" && req.method === "PATCH") {
227
+ const body = await readJson(req);
228
+ sendMutationJson(res, 200, updateTicket(ticketId, body, ctx), ctx);
229
+ return;
230
+ }
231
+ if (sub === "" && req.method === "DELETE") {
232
+ sendMutationJson(res, 200, deleteTicket(ticketId, ctx), ctx);
233
+ return;
234
+ }
235
+ if (sub === "archive" && req.method === "POST") {
236
+ sendMutationJson(res, 200, archiveTicket(ticketId, ctx), ctx);
237
+ return;
238
+ }
239
+ if (sub === "restore" && req.method === "POST") {
240
+ sendMutationJson(res, 200, restoreTicket(ticketId, ctx), ctx);
241
+ return;
242
+ }
243
+ if (sub === "context" && req.method === "GET") {
244
+ const depth = Number(url.searchParams.get("depth") || 1);
245
+ sendJson(res, 200, getContextPack(ticketId, ctx, depth));
246
+ return;
247
+ }
248
+ if (sub === "history" && req.method === "GET") {
249
+ sendJson(res, 200, getTicketStatusHistory(ticketId, ctx));
250
+ return;
251
+ }
252
+ if (sub === "relations" && req.method === "GET") {
253
+ sendJson(res, 200, getTicketRelations(ticketId, ctx));
254
+ return;
255
+ }
256
+ if (sub === "blockers" && req.method === "GET") {
257
+ sendJson(res, 200, getTicketBlockers(ticketId, ctx));
258
+ return;
259
+ }
260
+ if (sub === "comments" && req.method === "POST") {
261
+ const body = await readJson(req);
262
+ sendMutationJson(res, 201, createComment(ticketId, body, ctx), ctx);
263
+ return;
264
+ }
265
+ sendJson(res, 404, { error: "not_found" });
266
+ return;
267
+ }
268
+
269
+ // /api/states/:id PATCH/DELETE — board resolved from query hint or scan.
270
+ const statePath = url.pathname.match(/^\/api\/states\/([^/]+)$/);
271
+ if (statePath) {
272
+ const stateId = decodeURIComponent(statePath[1]);
273
+ const { boardId, boardSlug } = boardHintsFromQuery(url);
274
+ const hintRow = resolveBoardFromHint(boardId, boardSlug);
275
+ const found = findBoardForState(stateId, hintRow?.id || null);
276
+ if (!found) throw httpError(404, "state_not_found");
277
+ const ctx = ctxFromBoardRow(found.board, actor);
278
+ if (req.method === "PATCH") {
279
+ const body = await readJson(req);
280
+ sendMutationJson(res, 200, updateState(stateId, body, ctx), ctx);
281
+ return;
282
+ }
283
+ if (req.method === "DELETE") {
284
+ sendMutationJson(res, 200, deleteState(stateId, ctx), ctx);
285
+ return;
286
+ }
287
+ sendJson(res, 405, { error: "method_not_allowed" });
288
+ return;
289
+ }
290
+
291
+ // /api/board-entries/:id PATCH — board resolved from query hint or scan.
292
+ const entryPath = url.pathname.match(/^\/api\/board-entries\/([^/]+)$/);
293
+ if (entryPath && req.method === "PATCH") {
294
+ const entryId = decodeURIComponent(entryPath[1]);
295
+ const { boardId, boardSlug } = boardHintsFromQuery(url);
296
+ const hintRow = resolveBoardFromHint(boardId, boardSlug);
297
+ const found = findBoardForBoardEntry(entryId, hintRow?.id || null);
298
+ if (!found) throw httpError(404, "board_entry_not_found");
299
+ const body = await readJson(req);
300
+ const ctx = ctxFromBoardRow(found.board, actor);
301
+ sendMutationJson(res, 200, updateBoardEntry(entryId, body, ctx), ctx);
302
+ return;
303
+ }
304
+
305
+ // /api/relations POST — board resolved from source_ticket_id (cross-board
306
+ // relations are explicitly not supported; the resolved source's board IS
307
+ // the relation's board).
308
+ if (req.method === "POST" && url.pathname === "/api/relations") {
309
+ const body = await readJson(req);
310
+ const hintId = body.board_id || url.searchParams.get("board_id");
311
+ const hintSlug = body.board || url.searchParams.get("board");
312
+ const explicit = resolveBoardFromHint(hintId, hintSlug);
313
+ let row = explicit;
314
+ if (!row && body.source_ticket_id) {
315
+ const found = findBoardForTicket(body.source_ticket_id);
316
+ if (!found) throw httpError(404, "ticket_not_found");
317
+ row = found.board;
318
+ }
319
+ if (!row) throw httpError(400, "board_id_required");
320
+ const ctx = ctxFromBoardRow(row, actor);
321
+ sendMutationJson(res, 201, createRelation(body, ctx), ctx);
322
+ return;
323
+ }
324
+
325
+ // /api/relations/:id DELETE — board resolved from query hint or scan.
326
+ const relationPath = url.pathname.match(/^\/api\/relations\/([^/]+)$/);
327
+ if (relationPath && req.method === "DELETE") {
328
+ const relationId = decodeURIComponent(relationPath[1]);
329
+ const { boardId, boardSlug } = boardHintsFromQuery(url);
330
+ const hintRow = resolveBoardFromHint(boardId, boardSlug);
331
+ const found = findBoardForRelation(relationId, hintRow?.id || null);
332
+ if (!found) throw httpError(404, "relation_not_found");
333
+ const ctx = ctxFromBoardRow(found.board, actor);
334
+ deleteRelation(relationId, ctx);
335
+ sendMutationJson(res, 200, { ok: true }, ctx);
336
+ return;
337
+ }
338
+
339
+ // /api/search — board required (slug or id).
340
+ if (req.method === "GET" && url.pathname === "/api/search") {
341
+ const { boardId, boardSlug } = boardHintsFromQuery(url);
342
+ const row = resolveBoardFromHint(boardId, boardSlug);
343
+ if (!row) throw httpError(400, "board_id_required");
344
+ const args = {
345
+ q: url.searchParams.get("q") || "",
346
+ limit: url.searchParams.get("limit")
347
+ };
348
+ sendJson(res, 200, searchTickets(args, ctxFromBoardRow(row, actor)));
349
+ return;
350
+ }
351
+
352
+ // Agent routes — board explicitly required to prevent cross-board claim.
353
+ if (req.method === "POST" && url.pathname === "/api/agent/claim-next") {
354
+ const body = await readJson(req);
355
+ const hintId = body.board_id || url.searchParams.get("board_id");
356
+ const hintSlug = body.board || url.searchParams.get("board");
357
+ const row = resolveBoardFromHint(hintId, hintSlug);
358
+ if (!row) throw httpError(400, "board_id_required");
359
+ const ctx = ctxFromBoardRow(row, actor);
360
+ sendMutationJson(res, 200, claimNext(body, ctx), ctx);
361
+ return;
362
+ }
363
+
364
+ if (req.method === "POST" && url.pathname === "/api/agent/checkpoint") {
365
+ const body = await readJson(req);
366
+ const tid = body.ticket_id;
367
+ const hintId = body.board_id || url.searchParams.get("board_id");
368
+ const hintSlug = body.board || url.searchParams.get("board");
369
+ const explicit = resolveBoardFromHint(hintId, hintSlug);
370
+ let row = explicit;
371
+ if (!row && tid) {
372
+ const found = findBoardForTicket(tid);
373
+ if (!found) throw httpError(404, "ticket_not_found");
374
+ row = found.board;
375
+ }
376
+ if (!row) throw httpError(400, "board_id_required");
377
+ const ctx = ctxFromBoardRow(row, actor);
378
+ sendMutationJson(res, 200, checkpointTicket(body, ctx), ctx);
379
+ return;
380
+ }
381
+
382
+ if (req.method === "POST" && url.pathname === "/api/agent/complete") {
383
+ const body = await readJson(req);
384
+ const tid = body.ticket_id;
385
+ const hintId = body.board_id || url.searchParams.get("board_id");
386
+ const hintSlug = body.board || url.searchParams.get("board");
387
+ const explicit = resolveBoardFromHint(hintId, hintSlug);
388
+ let row = explicit;
389
+ if (!row && tid) {
390
+ const found = findBoardForTicket(tid);
391
+ if (!found) throw httpError(404, "ticket_not_found");
392
+ row = found.board;
393
+ }
394
+ if (!row) throw httpError(400, "board_id_required");
395
+ const ctx = ctxFromBoardRow(row, actor);
396
+ sendMutationJson(res, 200, completeTicket(body, ctx), ctx);
397
+ return;
398
+ }
399
+
400
+ // /api/admin/import — body.board_id required so we know which DB to import
401
+ // into. Imports replace within one board's DB only.
402
+ if (req.method === "POST" && url.pathname === "/api/admin/import") {
403
+ const body = await readJson(req);
404
+ const hintId = body.board_id || url.searchParams.get("board_id");
405
+ const hintSlug = body.board || url.searchParams.get("board");
406
+ const row = resolveBoardFromHint(hintId, hintSlug);
407
+ if (!row) throw httpError(400, "board_id_required");
408
+ const ctx = ctxFromBoardRow(row, actor);
409
+ sendMutationJson(res, 201, importBoardSnapshot(body, ctx), ctx);
410
+ return;
411
+ }
412
+
413
+ sendJson(res, 404, { error: "not_found" });
414
+ }
@@ -0,0 +1,70 @@
1
+ import { labelsForTicket } from "./queries.js";
2
+ import { canAccessBoard } from "./auth.js";
3
+ import { toFtsQuery } from "./util.js";
4
+
5
+ /**
6
+ * Search tickets on the resolved board only. The router/MCP layer is
7
+ * responsible for resolving which board to search (slug in URL, body, or
8
+ * session board); this function never crosses board boundaries or mutates
9
+ * shared state.
10
+ */
11
+ export function searchTickets(args, ctx) {
12
+ const { db, board, actor } = ctx;
13
+ const q = String(args.q || "").trim();
14
+ const limit = Math.min(Number(args.limit || 20), 50);
15
+
16
+ if (!q) return { query: q, results: [] };
17
+ if (!canAccessBoard(actor, board)) return { query: q, results: [] };
18
+
19
+ const fts = toFtsQuery(q);
20
+ let rows = [];
21
+ try {
22
+ rows = db
23
+ .prepare(
24
+ `SELECT t.*, s.name AS state_name, b.slug AS board_slug,
25
+ bm25(ticket_fts) AS rank
26
+ FROM ticket_fts
27
+ JOIN tickets t ON t.id = ticket_fts.ticket_id
28
+ JOIN states s ON s.id = t.state_id
29
+ JOIN boards b ON b.id = t.board_id
30
+ WHERE ticket_fts MATCH ? AND t.archived_at IS NULL
31
+ ORDER BY rank
32
+ LIMIT ?`
33
+ )
34
+ .all(fts, limit);
35
+ } catch {
36
+ const like = `%${q}%`;
37
+ rows = db
38
+ .prepare(
39
+ `SELECT DISTINCT t.*, s.name AS state_name, b.slug AS board_slug, 0 AS rank
40
+ FROM tickets t
41
+ JOIN states s ON s.id = t.state_id
42
+ JOIN boards b ON b.id = t.board_id
43
+ LEFT JOIN comments c ON c.ticket_id = t.id
44
+ WHERE t.archived_at IS NULL
45
+ AND (t.title LIKE ? OR t.description LIKE ? OR t.ai_plan LIKE ?
46
+ OR t.implementation_summary LIKE ? OR t.implementation_updates LIKE ? OR c.body LIKE ?)
47
+ ORDER BY t.updated_at DESC
48
+ LIMIT ?`
49
+ )
50
+ .all(like, like, like, like, like, like, limit);
51
+ }
52
+
53
+ rows.sort((a, b) => (a.rank !== b.rank ? a.rank - b.rank : String(b.updated_at).localeCompare(String(a.updated_at))));
54
+
55
+ const seen = new Set();
56
+ const deduped = [];
57
+ for (const ticket of rows) {
58
+ if (seen.has(ticket.id)) continue;
59
+ seen.add(ticket.id);
60
+ deduped.push(ticket);
61
+ if (deduped.length >= limit) break;
62
+ }
63
+
64
+ const results = deduped.map((ticket) => ({
65
+ ...ticket,
66
+ labels: labelsForTicket(db, ticket.id)
67
+ }));
68
+
69
+ return { query: q, results };
70
+ }