@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,246 @@
1
+ // Read/write helpers that all take an explicit `db` connection. No hidden
2
+ // "active board" state — callers pass in the connection for the board they
3
+ // resolved at the request boundary.
4
+
5
+ import { httpError, id, now, requiredString } from "./util.js";
6
+
7
+ export function ticketByNumber(db, boardId, number) {
8
+ return db
9
+ .prepare(
10
+ `SELECT t.*, s.name AS state_name, s.role AS state_role,
11
+ b.slug AS board_slug, b.name AS board_name
12
+ FROM tickets t
13
+ JOIN states s ON s.id = t.state_id
14
+ JOIN boards b ON b.id = t.board_id
15
+ WHERE t.board_id = ? AND t.number = ?`
16
+ )
17
+ .get(boardId, number);
18
+ }
19
+
20
+ export function ticketByTitle(db, boardId, title) {
21
+ return db
22
+ .prepare(
23
+ `SELECT t.*, s.name AS state_name, s.role AS state_role,
24
+ b.slug AS board_slug, b.name AS board_name
25
+ FROM tickets t
26
+ JOIN states s ON s.id = t.state_id
27
+ JOIN boards b ON b.id = t.board_id
28
+ WHERE t.board_id = ? AND lower(t.title) = lower(?)
29
+ ORDER BY t.archived_at IS NULL DESC, t.number DESC
30
+ LIMIT 1`
31
+ )
32
+ .get(boardId, title);
33
+ }
34
+
35
+ export function ticketById(db, ticketId) {
36
+ return db
37
+ .prepare(
38
+ `SELECT t.*, s.name AS state_name, s.role AS state_role,
39
+ b.slug AS board_slug, b.name AS board_name
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
+ WHERE t.id = ?`
44
+ )
45
+ .get(ticketId);
46
+ }
47
+
48
+ export function boardById(db, boardId) {
49
+ return db.prepare("SELECT * FROM boards WHERE id = ?").get(boardId);
50
+ }
51
+
52
+ export function stateByName(db, boardId, name) {
53
+ return db.prepare("SELECT * FROM states WHERE board_id = ? AND name = ?").get(boardId, name);
54
+ }
55
+
56
+ export function stateByRole(db, boardId, role) {
57
+ return db
58
+ .prepare("SELECT * FROM states WHERE board_id = ? AND role = ? ORDER BY position LIMIT 1")
59
+ .get(boardId, role);
60
+ }
61
+
62
+ export function stateById(db, stateId) {
63
+ return db.prepare("SELECT * FROM states WHERE id = ?").get(stateId);
64
+ }
65
+
66
+ export function defaultStateId(db, boardId) {
67
+ const state =
68
+ db.prepare("SELECT id FROM states WHERE board_id = ? AND is_default = 1").get(boardId) ||
69
+ db.prepare("SELECT id FROM states WHERE board_id = ? ORDER BY position LIMIT 1").get(boardId);
70
+ if (!state) throw httpError(400, "board_has_no_states");
71
+ return state.id;
72
+ }
73
+
74
+ export function compactStatePositions(db, boardId) {
75
+ const states = db.prepare("SELECT id FROM states WHERE board_id = ? ORDER BY position").all(boardId);
76
+ states.forEach((state, position) => {
77
+ db.prepare("UPDATE states SET position = ? WHERE id = ?").run(position, state.id);
78
+ });
79
+ }
80
+
81
+ export function labelsForTicket(db, ticketId) {
82
+ return db
83
+ .prepare(
84
+ `SELECT l.*
85
+ FROM ticket_labels tl
86
+ JOIN labels l ON l.id = tl.label_id
87
+ WHERE tl.ticket_id = ?
88
+ ORDER BY l.name`
89
+ )
90
+ .all(ticketId);
91
+ }
92
+
93
+ export function childTicketsFor(db, ticketId) {
94
+ return db
95
+ .prepare(
96
+ `SELECT t.*, s.name AS state_name, s.role AS state_role,
97
+ b.slug AS board_slug, b.name AS board_name
98
+ FROM tickets t
99
+ JOIN states s ON s.id = t.state_id
100
+ JOIN boards b ON b.id = t.board_id
101
+ WHERE t.parent_ticket_id = ? AND t.archived_at IS NULL
102
+ ORDER BY t.number ASC`
103
+ )
104
+ .all(ticketId);
105
+ }
106
+
107
+ export function archivedTicketsForBoard(db, boardId) {
108
+ return db
109
+ .prepare(
110
+ `SELECT t.*, s.name AS state_name, s.role AS state_role,
111
+ b.slug AS board_slug, b.name AS board_name
112
+ FROM tickets t
113
+ JOIN states s ON s.id = t.state_id
114
+ JOIN boards b ON b.id = t.board_id
115
+ WHERE t.board_id = ? AND t.archived_at IS NOT NULL
116
+ ORDER BY t.archived_at DESC`
117
+ )
118
+ .all(boardId);
119
+ }
120
+
121
+ export function compactTicket(ticket) {
122
+ if (!ticket) return null;
123
+ return {
124
+ id: ticket.id,
125
+ board_id: ticket.board_id,
126
+ board_slug: ticket.board_slug,
127
+ number: ticket.number,
128
+ title: ticket.title,
129
+ type: ticket.type,
130
+ state_name: ticket.state_name,
131
+ state_role: ticket.state_role,
132
+ priority: ticket.priority
133
+ };
134
+ }
135
+
136
+ export function uniqueTickets(tickets) {
137
+ const seen = new Set();
138
+ const result = [];
139
+ for (const ticket of tickets) {
140
+ if (!ticket || seen.has(ticket.id)) continue;
141
+ seen.add(ticket.id);
142
+ result.push(ticket);
143
+ }
144
+ return result;
145
+ }
146
+
147
+ export function relationRows(db, ticketId) {
148
+ const rows = db
149
+ .prepare(
150
+ `SELECT r.*,
151
+ source.title AS source_title,
152
+ source.number AS source_number,
153
+ source.board_id AS source_board_id,
154
+ target.title AS target_title,
155
+ target.number AS target_number,
156
+ target.board_id AS target_board_id
157
+ FROM relations r
158
+ JOIN tickets source ON source.id = r.source_ticket_id
159
+ JOIN tickets target ON target.id = r.target_ticket_id
160
+ WHERE (r.source_ticket_id = ? OR r.target_ticket_id = ?)
161
+ AND source.archived_at IS NULL AND target.archived_at IS NULL
162
+ ORDER BY r.created_at ASC`
163
+ )
164
+ .all(ticketId, ticketId);
165
+
166
+ return rows.map((row) => {
167
+ const isSource = row.source_ticket_id === ticketId;
168
+ const otherId = isSource ? row.target_ticket_id : row.source_ticket_id;
169
+ const other = ticketById(db, otherId);
170
+ return {
171
+ id: row.id,
172
+ type: row.type,
173
+ direction: isSource ? "outgoing" : "incoming",
174
+ other_ticket: other
175
+ };
176
+ });
177
+ }
178
+
179
+ // Returns the set of tickets currently blocking `ticketId`. The relation row
180
+ // IS the source of truth: a `blocked_by` row exists iff the target is still
181
+ // blocking. Cleanup happens at state-change time (tickets.js auto-deletes
182
+ // blocks/blocked_by rows when a ticket moves to a role='done' lane) or when
183
+ // the user removes the relation manually in the UI. So this resolver doesn't
184
+ // re-check lane state — it just returns the unarchived rows. Exception: epic
185
+ // targets expand to their children, since the epic itself isn't what gets
186
+ // "done"; each open child (state role != 'done', not archived) is a blocker
187
+ // and carries via_epic_id so callers can explain the dependency.
188
+ export function unresolvedBlockers(db, ticketId) {
189
+ const direct = db
190
+ .prepare(
191
+ `SELECT target.*, s.name AS state_name, s.role AS state_role
192
+ FROM relations r
193
+ JOIN tickets target ON target.id = r.target_ticket_id
194
+ JOIN states s ON s.id = target.state_id
195
+ WHERE r.source_ticket_id = ? AND r.type = 'blocked_by' AND target.archived_at IS NULL`
196
+ )
197
+ .all(ticketId);
198
+
199
+ const expandEpicChildren = db.prepare(
200
+ `SELECT t.*, s.name AS state_name, s.role AS state_role
201
+ FROM tickets t
202
+ JOIN states s ON s.id = t.state_id
203
+ WHERE t.parent_ticket_id = ? AND t.archived_at IS NULL AND (s.role IS NULL OR s.role != 'done')`
204
+ );
205
+
206
+ const seen = new Set();
207
+ const blockers = [];
208
+ for (const row of direct) {
209
+ if (row.type === "epic") {
210
+ const children = expandEpicChildren.all(row.id);
211
+ for (const child of children) {
212
+ if (seen.has(child.id)) continue;
213
+ seen.add(child.id);
214
+ blockers.push({ ...child, via_epic_id: row.id });
215
+ }
216
+ continue;
217
+ }
218
+ if (seen.has(row.id)) continue;
219
+ seen.add(row.id);
220
+ blockers.push(row);
221
+ }
222
+ return blockers;
223
+ }
224
+
225
+ export function ensureLabel(db, boardId, name, color = "#64748b") {
226
+ const cleanName = requiredString(name, "label").trim();
227
+ const existing = db.prepare("SELECT * FROM labels WHERE board_id = ? AND name = ?").get(boardId, cleanName);
228
+ if (existing) return existing;
229
+ const labelId = id();
230
+ db.prepare("INSERT INTO labels (id, board_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)").run(
231
+ labelId,
232
+ boardId,
233
+ cleanName,
234
+ color,
235
+ now()
236
+ );
237
+ return db.prepare("SELECT * FROM labels WHERE id = ?").get(labelId);
238
+ }
239
+
240
+ export function addComment(db, ticketId, author, kind, body) {
241
+ const commentId = id();
242
+ db.prepare(
243
+ "INSERT INTO comments (id, ticket_id, author, kind, body, created_at) VALUES (?, ?, ?, ?, ?, ?)"
244
+ ).run(commentId, ticketId, author, kind, body, now());
245
+ return db.prepare("SELECT * FROM comments WHERE id = ?").get(commentId);
246
+ }
@@ -0,0 +1,207 @@
1
+ // Registry-level operations. No mutable global "active board" state lives
2
+ // here: every function either reads from the registry, opens a known
3
+ // per-board DB by path, or scans a bounded set of registry rows to locate
4
+ // the board that owns a given ticket / state / entry / relation. Callers
5
+ // receive `{ board, db }` and pass it down via `ctx`.
6
+
7
+ import { mkdirSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { createBoardSchema, getRegistry, openConnection } from "./db.js";
10
+ import { now, slugify } from "./util.js";
11
+
12
+ export function listBoards() {
13
+ return getRegistry().prepare("SELECT * FROM boards ORDER BY name").all();
14
+ }
15
+
16
+ export function getBoardByRegistryId(boardId) {
17
+ return getRegistry().prepare("SELECT * FROM boards WHERE id = ?").get(boardId);
18
+ }
19
+
20
+ export function getBoardBySlug(slug) {
21
+ return getRegistry().prepare("SELECT * FROM boards WHERE slug = ?").get(slugify(slug));
22
+ }
23
+
24
+ export function getBoardByRepoPath(repoPathNorm) {
25
+ return getRegistry()
26
+ .prepare(
27
+ `SELECT * FROM boards
28
+ WHERE repo_path = ?
29
+ ORDER BY (last_active_at IS NULL) ASC, last_active_at DESC
30
+ LIMIT 1`
31
+ )
32
+ .get(repoPathNorm);
33
+ }
34
+
35
+ /** Most-recently-active registry row, or null. Used as a tie-breaker for the
36
+ * bootstrap-default case when no board is specified by the caller. */
37
+ export function pickDefaultBoard() {
38
+ return getRegistry()
39
+ .prepare(
40
+ `SELECT * FROM boards
41
+ ORDER BY (last_active_at IS NULL) ASC, last_active_at DESC, created_at ASC
42
+ LIMIT 1`
43
+ )
44
+ .get();
45
+ }
46
+
47
+ export function deleteBoard(boardId) {
48
+ getRegistry().prepare("DELETE FROM boards WHERE id = ?").run(boardId);
49
+ }
50
+
51
+ export function touchBoardActive(boardId) {
52
+ const t = now();
53
+ getRegistry()
54
+ .prepare("UPDATE boards SET last_active_at = ?, updated_at = ? WHERE id = ?")
55
+ .run(t, t, boardId);
56
+ }
57
+
58
+ export function insertBoard(row) {
59
+ const t = row.created_at || now();
60
+ getRegistry()
61
+ .prepare(
62
+ `INSERT INTO boards (id, slug, name, repo_path, db_path, repo_url, default_branch, last_active_at, created_at, updated_at)
63
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
64
+ )
65
+ .run(
66
+ row.id,
67
+ row.slug,
68
+ row.name,
69
+ row.repo_path,
70
+ row.db_path,
71
+ row.repo_url || "",
72
+ row.default_branch || "main",
73
+ row.last_active_at || t,
74
+ t,
75
+ row.updated_at || t
76
+ );
77
+ }
78
+
79
+ export function updateBoardMeta(boardId, patch) {
80
+ const sets = [];
81
+ const vals = [];
82
+ for (const k of ["slug", "name", "repo_path", "db_path", "repo_url", "default_branch"]) {
83
+ if (patch[k] !== undefined) {
84
+ sets.push(`${k} = ?`);
85
+ vals.push(patch[k]);
86
+ }
87
+ }
88
+ if (!sets.length) return;
89
+ vals.push(now(), boardId);
90
+ getRegistry()
91
+ .prepare(`UPDATE boards SET ${sets.join(", ")}, updated_at = ? WHERE id = ?`)
92
+ .run(...vals);
93
+ }
94
+
95
+ /** Where a new board.db should live: flat .orbit/board.db for a repo's first
96
+ * board, or .orbit/boards/<slug>/board.db for additional boards on the same
97
+ * repo. */
98
+ export function computeNewBoardDbPath(repoPathNorm, slug) {
99
+ const reg = getRegistry();
100
+ const count = reg.prepare("SELECT COUNT(*) AS c FROM boards WHERE repo_path = ?").get(repoPathNorm).c;
101
+ if (count === 0) return join(repoPathNorm, ".orbit", "board.db");
102
+ return join(repoPathNorm, ".orbit", "boards", slug, "board.db");
103
+ }
104
+
105
+ /** Create the directory + file and apply the per-board schema. Returns the
106
+ * open connection. */
107
+ export function ensureBoardDbFileAndSchema(dbPath) {
108
+ mkdirSync(dirname(dbPath), { recursive: true });
109
+ const conn = openConnection(dbPath);
110
+ createBoardSchema(conn);
111
+ return conn;
112
+ }
113
+
114
+ /** Open the connection for a registry row. */
115
+ export function openBoardDb(boardRow) {
116
+ return openConnection(boardRow.db_path);
117
+ }
118
+
119
+ // Whitelisted table names — these come from our own call sites, never user
120
+ // input, but interpolating into SQL is only safe because of this guard.
121
+ const ROW_OWNER_TABLES = new Set(["tickets", "states", "board_entries", "relations"]);
122
+
123
+ /**
124
+ * Find which registry board's DB contains a row with `id` in `table`. Pure:
125
+ * opens one db per candidate to probe and returns `{ board, db }` (no global
126
+ * side effects). If `hintBoardId` is supplied it's checked first to avoid a
127
+ * full scan across every registered board.
128
+ */
129
+ function findBoardForRow(table, rowId, hintBoardId = null) {
130
+ if (!ROW_OWNER_TABLES.has(table)) throw new Error(`unsupported owner table: ${table}`);
131
+ const probe = `SELECT 1 FROM ${table} WHERE id = ?`;
132
+ if (hintBoardId) {
133
+ const hint = getBoardByRegistryId(hintBoardId);
134
+ if (hint) {
135
+ const db = openBoardDb(hint);
136
+ if (db.prepare(probe).get(rowId)) return { board: hint, db };
137
+ }
138
+ }
139
+ for (const row of listBoards()) {
140
+ if (hintBoardId && row.id === hintBoardId) continue;
141
+ const db = openBoardDb(row);
142
+ if (db.prepare(probe).get(rowId)) return { board: row, db };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ export const findBoardForTicket = (ticketId, hintBoardId = null) =>
148
+ findBoardForRow("tickets", ticketId, hintBoardId);
149
+ export const findBoardForState = (stateId, hintBoardId = null) =>
150
+ findBoardForRow("states", stateId, hintBoardId);
151
+ export const findBoardForBoardEntry = (entryId, hintBoardId = null) =>
152
+ findBoardForRow("board_entries", entryId, hintBoardId);
153
+ export const findBoardForRelation = (relationId, hintBoardId = null) =>
154
+ findBoardForRow("relations", relationId, hintBoardId);
155
+
156
+ /** Sync a per-board DB's single boards-row into the registry. Idempotent. */
157
+ export function syncRegistryFromBoardDb(boardRow, db) {
158
+ const b = db.prepare("SELECT * FROM boards LIMIT 1").get();
159
+ if (!b) return;
160
+
161
+ const reg = getRegistry();
162
+ const t = now();
163
+ const existing = reg.prepare("SELECT id FROM boards WHERE id = ?").get(b.id);
164
+ if (existing) {
165
+ reg
166
+ .prepare(
167
+ `UPDATE boards SET slug = ?, name = ?, db_path = ?, repo_url = ?, default_branch = ?, updated_at = ?
168
+ WHERE id = ?`
169
+ )
170
+ .run(b.slug, b.name, boardRow.db_path, b.repo_url || "", b.default_branch || "main", t, b.id);
171
+ return;
172
+ }
173
+
174
+ const existingPath = reg.prepare("SELECT id FROM boards WHERE db_path = ?").get(boardRow.db_path);
175
+ if (existingPath) {
176
+ reg
177
+ .prepare(
178
+ `UPDATE boards SET id = ?, slug = ?, name = ?, repo_path = ?, db_path = ?, repo_url = ?, default_branch = ?, updated_at = ?
179
+ WHERE id = ?`
180
+ )
181
+ .run(
182
+ b.id,
183
+ b.slug,
184
+ b.name,
185
+ boardRow.repo_path,
186
+ boardRow.db_path,
187
+ b.repo_url || "",
188
+ b.default_branch || "main",
189
+ t,
190
+ existingPath.id
191
+ );
192
+ return;
193
+ }
194
+
195
+ insertBoard({
196
+ id: b.id,
197
+ slug: b.slug,
198
+ name: b.name,
199
+ repo_path: boardRow.repo_path,
200
+ db_path: boardRow.db_path,
201
+ repo_url: b.repo_url || "",
202
+ default_branch: b.default_branch || "main",
203
+ last_active_at: t,
204
+ created_at: t,
205
+ updated_at: t
206
+ });
207
+ }
@@ -0,0 +1,163 @@
1
+ import { boardById, childTicketsFor, relationRows, ticketById, unresolvedBlockers } from "./queries.js";
2
+ import { tx } from "./db.js";
3
+ import { requireBoardAccess, requirePermission } from "./auth.js";
4
+ import { recordEvent } from "./events.js";
5
+ import { httpError, id, now, requiredString } from "./util.js";
6
+
7
+ export function createRelation(body, ctx) {
8
+ const { db, board, actor } = ctx;
9
+ const sourceId = requiredString(body.source_ticket_id, "source_ticket_id");
10
+ const source = ticketById(db, sourceId);
11
+ if (!source || source.board_id !== board.id) throw httpError(404, "ticket_not_found");
12
+
13
+ const target = ticketById(db, requiredString(body.target_ticket_id, "target_ticket_id"));
14
+ // Relations are only meaningful within a single board's DB. Cross-board
15
+ // relations are not supported by the schema (foreign keys live in this DB
16
+ // only) and would invent silent invariants.
17
+ if (!target || target.board_id !== board.id) throw httpError(404, "ticket_not_found");
18
+
19
+ requireBoardAccess(actor, boardById(db, source.board_id));
20
+ requirePermission(actor, "write");
21
+
22
+ if (source.id === target.id) throw httpError(400, "relation_self");
23
+ if (source.parent_ticket_id === target.id || target.parent_ticket_id === source.id) {
24
+ throw httpError(400, "relation_redundant_with_parent");
25
+ }
26
+
27
+ const relationType = body.type || "relates_to";
28
+ // Hierarchy is stored on the ticket row (parent_ticket_id), not in the
29
+ // relations table. The `child_of` / `parent_of` types only appear as
30
+ // read-only synthetic entries in getTicketRelations output — pointing
31
+ // callers at the right write path keeps the two models disjoint.
32
+ if (relationType === "child_of" || relationType === "parent_of") {
33
+ throw httpError(400, "hierarchy_uses_parent_ticket_id");
34
+ }
35
+ // Blocker relations are meaningless against a target that's already in a
36
+ // done lane — the row would be a stale blocker on creation. Reject so
37
+ // callers fix the data instead of silently creating dead relations.
38
+ if (relationType === "blocked_by" && target.state_role === "done") {
39
+ throw httpError(400, "blocker_target_already_done");
40
+ }
41
+ if (relationType === "blocks" && source.state_role === "done") {
42
+ throw httpError(400, "blocker_source_already_done");
43
+ }
44
+ const existing = db
45
+ .prepare("SELECT * FROM relations WHERE source_ticket_id = ? AND target_ticket_id = ? AND type = ?")
46
+ .get(source.id, target.id, relationType);
47
+ // Don't silently 201 on a no-op: an INSERT OR IGNORE that hit the UNIQUE
48
+ // constraint would have returned the pre-existing row, hiding from the
49
+ // caller that nothing was created.
50
+ if (existing) throw httpError(409, "relation_exists");
51
+
52
+ const relationId = id();
53
+ return tx(db, () => {
54
+ db.prepare(
55
+ "INSERT INTO relations (id, source_ticket_id, target_ticket_id, type, created_at) VALUES (?, ?, ?, ?, ?)"
56
+ ).run(relationId, source.id, target.id, relationType, now());
57
+ recordEvent(db, source.board_id, "relation_created", source.id, actor.name, {
58
+ target_ticket_id: target.id,
59
+ type: relationType
60
+ });
61
+ return db.prepare("SELECT * FROM relations WHERE id = ?").get(relationId);
62
+ });
63
+ }
64
+
65
+ export function deleteRelation(relationId, ctx) {
66
+ const { db, board, actor } = ctx;
67
+ const row = db.prepare("SELECT * FROM relations WHERE id = ?").get(relationId);
68
+ if (!row) throw httpError(404, "relation_not_found");
69
+ const source = ticketById(db, row.source_ticket_id);
70
+ const target = ticketById(db, row.target_ticket_id);
71
+ // Relation must live in the resolved board (sanity check — same DB by
72
+ // definition since router resolved this board, but keep the assertion).
73
+ if (source && source.board_id !== board.id) throw httpError(404, "relation_not_found");
74
+ if (source) requireBoardAccess(actor, boardById(db, source.board_id));
75
+ if (target) requireBoardAccess(actor, boardById(db, target.board_id));
76
+ requirePermission(actor, "write");
77
+ return tx(db, () => {
78
+ db.prepare("DELETE FROM relations WHERE id = ?").run(relationId);
79
+ recordEvent(db, source?.board_id || board.id, "relation_deleted", row.source_ticket_id, actor.name, {
80
+ target_ticket_id: row.target_ticket_id,
81
+ type: row.type
82
+ });
83
+ return { ok: true };
84
+ });
85
+ }
86
+
87
+ export function getTicketRelations(ticketId, ctx) {
88
+ const { db, board, actor } = ctx;
89
+ const ticket = ticketById(db, ticketId);
90
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
91
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
92
+ const compactOther = (other) => other && {
93
+ id: other.id,
94
+ number: other.number,
95
+ title: other.title,
96
+ type: other.type,
97
+ state_id: other.state_id,
98
+ state_name: other.state_name,
99
+ state_role: other.state_role,
100
+ parent_ticket_id: other.parent_ticket_id
101
+ };
102
+
103
+ const relations = relationRows(db, ticketId).map((row) => ({
104
+ id: row.id,
105
+ type: row.type,
106
+ direction: row.direction,
107
+ source: "relation",
108
+ other_ticket: compactOther(row.other_ticket)
109
+ }));
110
+
111
+ // Hierarchy is the other relationship model in the system. We don't store
112
+ // it in the `relations` table (parent_ticket_id is a column on the ticket
113
+ // row, kept disjoint from peer relations), but agents asking "what's linked
114
+ // to this ticket?" want a single answer. Synthesize read-only `child_of` /
115
+ // `parent_of` entries here. Marked source='hierarchy' so callers know they
116
+ // can't delete via DELETE /api/relations/:id; mutate via PATCH on the
117
+ // ticket's parent_ticket_id instead.
118
+ if (ticket.parent_ticket_id) {
119
+ const parent = ticketById(db, ticket.parent_ticket_id);
120
+ if (parent && !parent.archived_at) {
121
+ relations.push({
122
+ id: null,
123
+ type: "child_of",
124
+ direction: "outgoing",
125
+ source: "hierarchy",
126
+ other_ticket: compactOther(parent)
127
+ });
128
+ }
129
+ }
130
+ for (const child of childTicketsFor(db, ticketId)) {
131
+ relations.push({
132
+ id: null,
133
+ type: "parent_of",
134
+ direction: "outgoing",
135
+ source: "hierarchy",
136
+ other_ticket: compactOther(child)
137
+ });
138
+ }
139
+
140
+ return { ticket_id: ticket.id, relations };
141
+ }
142
+
143
+ export function getTicketBlockers(ticketId, ctx) {
144
+ const { db, board, actor } = ctx;
145
+ const ticket = ticketById(db, ticketId);
146
+ if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
147
+ requireBoardAccess(actor, boardById(db, ticket.board_id));
148
+ const blockers = unresolvedBlockers(db, ticketId).map((row) => ({
149
+ id: row.id,
150
+ number: row.number,
151
+ title: row.title,
152
+ type: row.type,
153
+ state_id: row.state_id,
154
+ state_name: row.state_name,
155
+ state_role: row.state_role,
156
+ via_epic_id: row.via_epic_id || null
157
+ }));
158
+ return {
159
+ ticket_id: ticket.id,
160
+ can_start: blockers.length === 0,
161
+ blockers
162
+ };
163
+ }