@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.
- package/.dockerignore +13 -0
- package/Dockerfile +34 -0
- package/README.md +216 -0
- package/SKILL-ORBIT.md +94 -0
- package/build.mjs +204 -0
- package/dist/full/apple-touch-icon.png +0 -0
- package/dist/full/cursors/live_arrow.cur +0 -0
- package/dist/full/cursors/live_link.cur +0 -0
- package/dist/full/cursors/live_move.cur +0 -0
- package/dist/full/cursors/live_select.cur +0 -0
- package/dist/full/cursors/live_unavail.cur +0 -0
- package/dist/full/favicon-32x32.png +0 -0
- package/dist/full/index.html +314 -0
- package/dist/full/js/app.70be7d06.js +554 -0
- package/dist/full/js/app.70be7d06.js.map +7 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.c2e3d20a.css +4892 -0
- package/dist/full/version.json +6 -0
- package/docs/AGENT_PROTOCOL.md +379 -0
- package/docs/MCP_SETUP.md +49 -0
- package/docs/mcp-client.example.json +13 -0
- package/package.json +62 -0
- package/pnpm-workspace.yaml +2 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/cursors/live_arrow.cur +0 -0
- package/public/cursors/live_link.cur +0 -0
- package/public/cursors/live_move.cur +0 -0
- package/public/cursors/live_select.cur +0 -0
- package/public/cursors/live_unavail.cur +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/index.html +314 -0
- package/public/js/api.js +35 -0
- package/public/js/app.js +35 -0
- package/public/js/board-menu.js +145 -0
- package/public/js/config.js +19 -0
- package/public/js/create-card.js +153 -0
- package/public/js/dom.js +19 -0
- package/public/js/drawer.js +137 -0
- package/public/js/format.js +108 -0
- package/public/js/kanban.js +475 -0
- package/public/js/local-backend.js +934 -0
- package/public/js/main.js +142 -0
- package/public/js/modal.js +31 -0
- package/public/js/router.js +114 -0
- package/public/js/search.js +53 -0
- package/public/js/settings-tabs.js +7 -0
- package/public/js/settings.js +931 -0
- package/public/js/sse.js +156 -0
- package/public/js/starscape.js +315 -0
- package/public/js/state.js +54 -0
- package/public/js/ticket-detail.js +766 -0
- package/public/js/toast.js +74 -0
- package/public/js/transport.js +22 -0
- package/public/js/unread.js +121 -0
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +4892 -0
- package/src/cli/orbit.js +438 -0
- package/src/core/agent.js +262 -0
- package/src/core/auth.js +49 -0
- package/src/core/backups.js +136 -0
- package/src/core/board.js +112 -0
- package/src/core/boards.js +310 -0
- package/src/core/bootstrap.js +162 -0
- package/src/core/db.js +237 -0
- package/src/core/delete-board.js +53 -0
- package/src/core/events.js +314 -0
- package/src/core/http.js +81 -0
- package/src/core/paths.js +25 -0
- package/src/core/provision-repo-board.js +113 -0
- package/src/core/queries.js +246 -0
- package/src/core/registry.js +207 -0
- package/src/core/relations.js +163 -0
- package/src/core/router.js +414 -0
- package/src/core/search.js +70 -0
- package/src/core/seed.js +226 -0
- package/src/core/snapshots.js +255 -0
- package/src/core/states.js +167 -0
- package/src/core/system-picker.js +93 -0
- package/src/core/tickets.js +344 -0
- package/src/core/util.js +105 -0
- package/src/mcp-server.js +773 -0
- 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
|
+
}
|