@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
package/src/core/seed.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { addComment } from "./queries.js";
|
|
5
|
+
import { recordEvent } from "./events.js";
|
|
6
|
+
import { id, now, normalizeTicketType, slugify, titleize } from "./util.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Seed a fresh per-board DB with the universal first-run profile. Caller
|
|
10
|
+
* supplies the connection and the repo path the board lives in. Returns the
|
|
11
|
+
* new board row, or null if the DB already had a board.
|
|
12
|
+
*/
|
|
13
|
+
export function seedIfEmpty(db, repoPath, options = {}) {
|
|
14
|
+
const existing = db.prepare("SELECT COUNT(*) AS count FROM boards").get().count;
|
|
15
|
+
if (existing > 0) return null;
|
|
16
|
+
|
|
17
|
+
const time = now();
|
|
18
|
+
const boardId = id();
|
|
19
|
+
const seed = buildBoardFromRepo(repoPath);
|
|
20
|
+
|
|
21
|
+
db.exec("BEGIN;");
|
|
22
|
+
try {
|
|
23
|
+
db.prepare(
|
|
24
|
+
`INSERT INTO boards
|
|
25
|
+
(id, slug, name, repo_url, system_path, default_branch,
|
|
26
|
+
project_notes, agent_instructions, created_at, updated_at)
|
|
27
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
28
|
+
).run(
|
|
29
|
+
boardId,
|
|
30
|
+
seed.boardSlug,
|
|
31
|
+
seed.boardName,
|
|
32
|
+
seed.repoUrl,
|
|
33
|
+
seed.systemPath,
|
|
34
|
+
seed.defaultBranch,
|
|
35
|
+
seed.projectNotes,
|
|
36
|
+
seed.agentInstructions,
|
|
37
|
+
time,
|
|
38
|
+
time
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const states = [
|
|
42
|
+
["Backlog", null],
|
|
43
|
+
["Todo", null],
|
|
44
|
+
["In Progress", "in_progress"],
|
|
45
|
+
["Review", "review"],
|
|
46
|
+
["Done", "done"],
|
|
47
|
+
["Cancelled", null]
|
|
48
|
+
];
|
|
49
|
+
const stateIds = new Map();
|
|
50
|
+
states.forEach(([name, role], position) => {
|
|
51
|
+
const stateId = id();
|
|
52
|
+
stateIds.set(name, stateId);
|
|
53
|
+
db.prepare(
|
|
54
|
+
`INSERT INTO states
|
|
55
|
+
(id, board_id, name, position, is_default, role, created_at)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
57
|
+
).run(stateId, boardId, name, position, name === "Todo" ? 1 : 0, role, time);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// User-defined labels only at seed time.
|
|
61
|
+
const labelIds = new Map();
|
|
62
|
+
[
|
|
63
|
+
["human-only", "#7c2d12"],
|
|
64
|
+
["needs-human-input", "#dc2626"],
|
|
65
|
+
["needs-decomposition", "#9333ea"],
|
|
66
|
+
["needs-followup", "#b45309"],
|
|
67
|
+
["tech-debt", "#64748b"],
|
|
68
|
+
["security", "#991b1b"],
|
|
69
|
+
["onboarding", "#0f766e"]
|
|
70
|
+
].forEach(([name, color]) => {
|
|
71
|
+
const labelId = id();
|
|
72
|
+
labelIds.set(name, labelId);
|
|
73
|
+
db.prepare(
|
|
74
|
+
"INSERT INTO labels (id, board_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
75
|
+
).run(labelId, boardId, name, color, time);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (options.includeExamples) {
|
|
79
|
+
const epicTicket = insertSeedTicket(db, boardId, 1, {
|
|
80
|
+
title: `Onboard ${seed.boardName} into this board`,
|
|
81
|
+
description:
|
|
82
|
+
"First-run onboarding epic. Use this to turn the generic seed into a useful repo-specific project context and work queue.",
|
|
83
|
+
type: "epic",
|
|
84
|
+
aiPlan:
|
|
85
|
+
"1. Fill Settings → AI (Agent Instructions) and Settings → Notes (Notes For You).\n2. Capture initial decisions and lessons in the Journal.\n3. Create the first real epic or AI-ready implementation ticket.",
|
|
86
|
+
stateId: stateIds.get("Todo"),
|
|
87
|
+
priority: 4,
|
|
88
|
+
labels: [labelIds.get("human-only"), labelIds.get("needs-decomposition"), labelIds.get("onboarding")]
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const firstTicket = insertSeedTicket(db, boardId, 2, {
|
|
92
|
+
title: "Fill agent context & personal notes",
|
|
93
|
+
description:
|
|
94
|
+
"Open Settings → AI (Agent Instructions) and Settings → Notes, and replace the seed text with real project context plus your own reminders.",
|
|
95
|
+
type: "feature",
|
|
96
|
+
parentTicketId: epicTicket,
|
|
97
|
+
stateId: stateIds.get("Todo"),
|
|
98
|
+
priority: 3,
|
|
99
|
+
labels: [labelIds.get("human-only"), labelIds.get("onboarding")]
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
insertSeedTicket(db, boardId, 3, {
|
|
103
|
+
title: "Create the first real implementation ticket",
|
|
104
|
+
description:
|
|
105
|
+
"Create a focused feature, task, or bug card with acceptance criteria, labels, and enough context for an agent or human to execute.",
|
|
106
|
+
type: "feature",
|
|
107
|
+
parentTicketId: epicTicket,
|
|
108
|
+
stateId: stateIds.get("Backlog"),
|
|
109
|
+
priority: 1,
|
|
110
|
+
labels: [labelIds.get("human-only"), labelIds.get("onboarding")]
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// #12 is a stable “try the agent on this card” handle for docs and `orbit init` next steps (MCP + claim-next).
|
|
114
|
+
insertSeedTicket(db, boardId, 12, {
|
|
115
|
+
title: "Try Orbit MCP on this ticket",
|
|
116
|
+
description:
|
|
117
|
+
"After `orbit init` and registering MCP, ask your agent from this repo to use the Orbit MCP tools on ticket 12 (claim context, update fields, move lanes). Read SKILL-ORBIT.md first.",
|
|
118
|
+
type: "task",
|
|
119
|
+
stateId: stateIds.get("Backlog"),
|
|
120
|
+
priority: 2,
|
|
121
|
+
labels: [labelIds.get("onboarding")]
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
addComment(db, epicTicket, "system", "note", "Universal first-run seed. Delete or complete these onboarding cards once this board reflects the repo.");
|
|
125
|
+
addComment(db, firstTicket, "system", "note", "Settings → AI: agent_instructions (ticket context). Settings → Notes: project_notes (you only).");
|
|
126
|
+
}
|
|
127
|
+
recordEvent(db, boardId, "seed", null, "system", { board: seed.boardSlug });
|
|
128
|
+
db.exec("COMMIT;");
|
|
129
|
+
} catch (error) {
|
|
130
|
+
db.exec("ROLLBACK;");
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return db.prepare("SELECT * FROM boards WHERE id = ?").get(boardId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function insertSeedTicket(db, boardId, number, ticket) {
|
|
138
|
+
const ticketId = id();
|
|
139
|
+
const time = now();
|
|
140
|
+
db.prepare(
|
|
141
|
+
`INSERT INTO tickets
|
|
142
|
+
(id, board_id, number, title, description, type, parent_ticket_id, ai_plan,
|
|
143
|
+
implementation_summary, implementation_updates, state_id, priority, created_by, created_at, updated_at)
|
|
144
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
145
|
+
).run(
|
|
146
|
+
ticketId,
|
|
147
|
+
boardId,
|
|
148
|
+
number,
|
|
149
|
+
ticket.title,
|
|
150
|
+
ticket.description,
|
|
151
|
+
normalizeTicketType(ticket.type || (ticket.parentTicketId ? "feature" : "task")),
|
|
152
|
+
ticket.parentTicketId || null,
|
|
153
|
+
ticket.aiPlan || "",
|
|
154
|
+
ticket.implementationSummary || "",
|
|
155
|
+
ticket.implementationUpdates || "",
|
|
156
|
+
ticket.stateId,
|
|
157
|
+
ticket.priority,
|
|
158
|
+
"system",
|
|
159
|
+
time,
|
|
160
|
+
time
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
for (const labelId of ticket.labels || []) {
|
|
164
|
+
db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticketId, labelId);
|
|
165
|
+
}
|
|
166
|
+
return ticketId;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Sniff repo metadata for a new board (package.json, git, env). */
|
|
170
|
+
export function buildBoardFromRepo(projectRoot) {
|
|
171
|
+
const packageInfo = readPackageInfo(projectRoot);
|
|
172
|
+
const directoryName = basename(projectRoot);
|
|
173
|
+
const boardName =
|
|
174
|
+
readSeedValue("board_name") || titleize(packageInfo.name || directoryName || "My Board");
|
|
175
|
+
const boardSlug = slugify(
|
|
176
|
+
readSeedValue("board_slug") || packageInfo.name || boardName || "my-board"
|
|
177
|
+
);
|
|
178
|
+
const repoUrl = readSeedValue("repo_url") || gitValue(["config", "--get", "remote.origin.url"], projectRoot) || "";
|
|
179
|
+
const systemPath = readSeedValue("system_path") || projectRoot;
|
|
180
|
+
const defaultBranch =
|
|
181
|
+
readSeedValue("default_branch") ||
|
|
182
|
+
gitValue(["branch", "--show-current"], projectRoot) ||
|
|
183
|
+
gitValue(["symbolic-ref", "--short", "HEAD"], projectRoot) ||
|
|
184
|
+
"main";
|
|
185
|
+
const defaultProjectNotes =
|
|
186
|
+
"Personal reminders (Settings → Notes: Notes For You). Point yourself at SKILL-ORBIT.md, runbooks, or nudges for your tools.";
|
|
187
|
+
const defaultAgentInstructions = `Describe why ${boardName} exists, who it serves, and what success looks like. Add features, stack, constraints, and anything agents should know — this project-level context is provided to agents when they work on tickets.`;
|
|
188
|
+
return {
|
|
189
|
+
boardName,
|
|
190
|
+
boardSlug,
|
|
191
|
+
repoUrl,
|
|
192
|
+
systemPath,
|
|
193
|
+
defaultBranch,
|
|
194
|
+
projectNotes: readSeedValue("project_notes") || defaultProjectNotes,
|
|
195
|
+
agentInstructions: readSeedValue("agent_instructions") || defaultAgentInstructions
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function readPackageInfo(root) {
|
|
200
|
+
const path = join(root, "package.json");
|
|
201
|
+
if (!existsSync(path)) return {};
|
|
202
|
+
try {
|
|
203
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
204
|
+
} catch {
|
|
205
|
+
return {};
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readSeedValue(key) {
|
|
210
|
+
const envValue = process.env[key.toUpperCase()];
|
|
211
|
+
if (typeof envValue === "string" && envValue.trim()) return envValue.trim();
|
|
212
|
+
return "";
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function gitValue(args, cwd) {
|
|
216
|
+
try {
|
|
217
|
+
return execFileSync("git", args, {
|
|
218
|
+
cwd,
|
|
219
|
+
encoding: "utf8",
|
|
220
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
221
|
+
timeout: 2000
|
|
222
|
+
}).trim();
|
|
223
|
+
} catch {
|
|
224
|
+
return "";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { resetBoard, tx } from "./db.js";
|
|
2
|
+
import { backupBoardDatabase } from "./backups.js";
|
|
3
|
+
import { boardById, labelsForTicket } from "./queries.js";
|
|
4
|
+
import { requireBoardAccess } from "./auth.js";
|
|
5
|
+
import { recordEvent } from "./events.js";
|
|
6
|
+
import { syncRegistryFromBoardDb } from "./registry.js";
|
|
7
|
+
import {
|
|
8
|
+
httpError,
|
|
9
|
+
normalizePriority,
|
|
10
|
+
normalizeProjectEntryType,
|
|
11
|
+
normalizeTicketType,
|
|
12
|
+
now,
|
|
13
|
+
requiredString
|
|
14
|
+
} from "./util.js";
|
|
15
|
+
import { reindexAllTickets } from "./tickets.js";
|
|
16
|
+
|
|
17
|
+
export function exportBoard(boardId, ctx) {
|
|
18
|
+
const { db, board, actor } = ctx;
|
|
19
|
+
if (boardId !== board.id) throw httpError(404, "board_not_found");
|
|
20
|
+
const innerBoard = boardById(db, boardId);
|
|
21
|
+
if (!innerBoard) throw httpError(404, "board_not_found");
|
|
22
|
+
requireBoardAccess(actor, innerBoard);
|
|
23
|
+
|
|
24
|
+
const states = db.prepare("SELECT * FROM states WHERE board_id = ? ORDER BY position").all(boardId);
|
|
25
|
+
const tickets = db
|
|
26
|
+
.prepare("SELECT * FROM tickets WHERE board_id = ? ORDER BY number ASC")
|
|
27
|
+
.all(boardId)
|
|
28
|
+
.map((ticket) => ({ ...ticket, labels: labelsForTicket(db, ticket.id) }));
|
|
29
|
+
const ticketIds = tickets.map((ticket) => ticket.id);
|
|
30
|
+
const comments = ticketIds.length
|
|
31
|
+
? db
|
|
32
|
+
.prepare("SELECT * FROM comments WHERE ticket_id IN (SELECT id FROM tickets WHERE board_id = ?) ORDER BY created_at ASC")
|
|
33
|
+
.all(boardId)
|
|
34
|
+
: [];
|
|
35
|
+
const relations = ticketIds.length
|
|
36
|
+
? db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT *
|
|
39
|
+
FROM relations
|
|
40
|
+
WHERE source_ticket_id IN (SELECT id FROM tickets WHERE board_id = ?)
|
|
41
|
+
OR target_ticket_id IN (SELECT id FROM tickets WHERE board_id = ?)
|
|
42
|
+
ORDER BY created_at ASC`
|
|
43
|
+
)
|
|
44
|
+
.all(boardId, boardId)
|
|
45
|
+
: [];
|
|
46
|
+
const entries = db
|
|
47
|
+
.prepare("SELECT * FROM board_entries WHERE board_id = ? ORDER BY created_at ASC")
|
|
48
|
+
.all(boardId);
|
|
49
|
+
const labels = db.prepare("SELECT * FROM labels WHERE board_id = ? ORDER BY name ASC").all(boardId);
|
|
50
|
+
const events = db
|
|
51
|
+
.prepare(
|
|
52
|
+
`SELECT *
|
|
53
|
+
FROM events
|
|
54
|
+
WHERE ticket_id IS NULL
|
|
55
|
+
OR ticket_id IN (SELECT id FROM tickets WHERE board_id = ?)
|
|
56
|
+
ORDER BY created_at ASC`
|
|
57
|
+
)
|
|
58
|
+
.all(boardId);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
format: "orbit-board-export",
|
|
62
|
+
version: 5,
|
|
63
|
+
exported_at: now(),
|
|
64
|
+
board: { ...innerBoard },
|
|
65
|
+
states,
|
|
66
|
+
labels,
|
|
67
|
+
tickets,
|
|
68
|
+
comments,
|
|
69
|
+
relations,
|
|
70
|
+
board_entries: entries,
|
|
71
|
+
events
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function importBoardSnapshot(body, ctx) {
|
|
76
|
+
const { db, actor, board } = ctx;
|
|
77
|
+
const snapshot = body?.snapshot || body;
|
|
78
|
+
const format = snapshot?.format;
|
|
79
|
+
if (!snapshot || format !== "orbit-board-export") throw httpError(400, "invalid_import_snapshot");
|
|
80
|
+
if (!snapshot.board) throw httpError(400, "snapshot_missing_board");
|
|
81
|
+
|
|
82
|
+
const replaceExisting = Boolean(body?.replace_existing);
|
|
83
|
+
const existingCount = db.prepare("SELECT COUNT(*) AS count FROM boards").get().count;
|
|
84
|
+
if (existingCount > 0 && !replaceExisting) {
|
|
85
|
+
throw httpError(409, "import_requires_empty_board_or_replace_existing");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Snapshot the existing DB file before a destructive replace, outside the
|
|
89
|
+
// transaction (it copies the file on disk, not rows).
|
|
90
|
+
if (existingCount > 0 && replaceExisting) {
|
|
91
|
+
backupBoardDatabase(ctx.board, db, "pre-import-replace");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const targetBoardId = board.id;
|
|
95
|
+
const importedBoard = {
|
|
96
|
+
...snapshot.board,
|
|
97
|
+
id: targetBoardId
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const result = tx(db, () => {
|
|
101
|
+
if (existingCount > 0 && replaceExisting) resetBoard(db);
|
|
102
|
+
|
|
103
|
+
db.prepare(
|
|
104
|
+
`INSERT INTO boards
|
|
105
|
+
(id, slug, name, repo_url, system_path, default_branch,
|
|
106
|
+
project_notes, agent_instructions, ai_enabled, created_at, updated_at)
|
|
107
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
108
|
+
).run(
|
|
109
|
+
importedBoard.id,
|
|
110
|
+
importedBoard.slug,
|
|
111
|
+
importedBoard.name,
|
|
112
|
+
importedBoard.repo_url || "",
|
|
113
|
+
importedBoard.system_path || "",
|
|
114
|
+
importedBoard.default_branch || "main",
|
|
115
|
+
importedBoard.project_notes || "",
|
|
116
|
+
importedBoard.agent_instructions || "",
|
|
117
|
+
importedBoard.ai_enabled === 0 ? 0 : 1,
|
|
118
|
+
importedBoard.created_at || now(),
|
|
119
|
+
importedBoard.updated_at || now()
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
for (const state of snapshot.states || []) {
|
|
123
|
+
db.prepare(
|
|
124
|
+
`INSERT INTO states
|
|
125
|
+
(id, board_id, name, position, is_default, role, created_at)
|
|
126
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
127
|
+
).run(
|
|
128
|
+
state.id,
|
|
129
|
+
targetBoardId,
|
|
130
|
+
state.name,
|
|
131
|
+
state.position,
|
|
132
|
+
state.is_default || 0,
|
|
133
|
+
state.role || null,
|
|
134
|
+
state.created_at || now()
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const label of snapshot.labels || []) {
|
|
139
|
+
db.prepare("INSERT INTO labels (id, board_id, name, color, created_at) VALUES (?, ?, ?, ?, ?)").run(
|
|
140
|
+
label.id,
|
|
141
|
+
targetBoardId,
|
|
142
|
+
label.name,
|
|
143
|
+
label.color || "#64748b",
|
|
144
|
+
label.created_at || now()
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const ticket of snapshot.tickets || []) {
|
|
149
|
+
db.prepare(
|
|
150
|
+
`INSERT INTO tickets
|
|
151
|
+
(id, board_id, number, title, description, type, parent_ticket_id, ai_plan,
|
|
152
|
+
implementation_summary, implementation_updates, state_id, priority,
|
|
153
|
+
created_by, created_at, updated_at)
|
|
154
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
155
|
+
).run(
|
|
156
|
+
ticket.id,
|
|
157
|
+
targetBoardId,
|
|
158
|
+
ticket.number,
|
|
159
|
+
ticket.title,
|
|
160
|
+
ticket.description || "",
|
|
161
|
+
normalizeTicketType(ticket.type || "task"),
|
|
162
|
+
ticket.parent_ticket_id || null,
|
|
163
|
+
ticket.ai_plan || "",
|
|
164
|
+
ticket.implementation_summary || "",
|
|
165
|
+
ticket.implementation_updates || "",
|
|
166
|
+
ticket.state_id,
|
|
167
|
+
normalizePriority(ticket.priority),
|
|
168
|
+
ticket.created_by || "import",
|
|
169
|
+
ticket.created_at || now(),
|
|
170
|
+
ticket.updated_at || now()
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
for (const label of ticket.labels || []) {
|
|
174
|
+
db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticket.id, label.id);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const comment of snapshot.comments || []) {
|
|
179
|
+
db.prepare(
|
|
180
|
+
"INSERT INTO comments (id, ticket_id, author, kind, body, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
181
|
+
).run(
|
|
182
|
+
comment.id,
|
|
183
|
+
comment.ticket_id,
|
|
184
|
+
comment.author || "import",
|
|
185
|
+
comment.kind || "comment",
|
|
186
|
+
comment.body || "",
|
|
187
|
+
comment.created_at || now()
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const relation of snapshot.relations || []) {
|
|
192
|
+
db.prepare(
|
|
193
|
+
"INSERT OR IGNORE INTO relations (id, source_ticket_id, target_ticket_id, type, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
194
|
+
).run(
|
|
195
|
+
relation.id,
|
|
196
|
+
relation.source_ticket_id,
|
|
197
|
+
relation.target_ticket_id,
|
|
198
|
+
relation.type || "relates_to",
|
|
199
|
+
relation.created_at || now()
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const entry of snapshot.board_entries || []) {
|
|
204
|
+
db.prepare(
|
|
205
|
+
`INSERT INTO board_entries
|
|
206
|
+
(id, board_id, type, title, body, ticket_id, created_by, created_at, updated_at, struck_at)
|
|
207
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
208
|
+
).run(
|
|
209
|
+
entry.id,
|
|
210
|
+
targetBoardId,
|
|
211
|
+
normalizeProjectEntryType(entry.type),
|
|
212
|
+
requiredString(entry.title, "entry_title"),
|
|
213
|
+
entry.body || "",
|
|
214
|
+
entry.ticket_id || null,
|
|
215
|
+
entry.created_by || "import",
|
|
216
|
+
entry.created_at || now(),
|
|
217
|
+
entry.updated_at || now(),
|
|
218
|
+
entry.struck_at || null
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const event of snapshot.events || []) {
|
|
223
|
+
db.prepare(
|
|
224
|
+
"INSERT INTO events (id, ticket_id, actor, type, body_json, created_at) VALUES (?, ?, ?, ?, ?, ?)"
|
|
225
|
+
).run(
|
|
226
|
+
event.id,
|
|
227
|
+
event.ticket_id || null,
|
|
228
|
+
event.actor || "import",
|
|
229
|
+
event.type || "imported_event",
|
|
230
|
+
event.body_json || "{}",
|
|
231
|
+
event.created_at || now()
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Reindex inside the transaction: a committed import must never leave the
|
|
236
|
+
// FTS table empty/stale (previously this ran after COMMIT and a throw here
|
|
237
|
+
// left search broken on otherwise-imported data).
|
|
238
|
+
reindexAllTickets(db);
|
|
239
|
+
recordEvent(db, targetBoardId, "board_imported", null, actor.name, {
|
|
240
|
+
board_id: targetBoardId,
|
|
241
|
+
replace_existing: replaceExisting
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
ok: true,
|
|
245
|
+
imported_board_id: targetBoardId,
|
|
246
|
+
imported_board_slug: importedBoard.slug,
|
|
247
|
+
replace_existing: replaceExisting
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Registry sync writes the separate registry DB (a different connection), so
|
|
252
|
+
// it runs after the board-DB transaction commits, not inside it.
|
|
253
|
+
syncRegistryFromBoardDb(board, db);
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { boardById, compactStatePositions, stateById } from "./queries.js";
|
|
2
|
+
import { tx } from "./db.js";
|
|
3
|
+
import { requireBoardAccess, requirePermission } from "./auth.js";
|
|
4
|
+
import { recordEvent } from "./events.js";
|
|
5
|
+
import {
|
|
6
|
+
httpError,
|
|
7
|
+
id,
|
|
8
|
+
normalizeStateRole,
|
|
9
|
+
now,
|
|
10
|
+
requiredString
|
|
11
|
+
} from "./util.js";
|
|
12
|
+
|
|
13
|
+
export function createState(boardId, body, ctx) {
|
|
14
|
+
const { db, actor } = ctx;
|
|
15
|
+
const board = boardById(db, boardId);
|
|
16
|
+
if (!board) throw httpError(404, "board_not_found");
|
|
17
|
+
requireBoardAccess(actor, board);
|
|
18
|
+
requirePermission(actor, "write");
|
|
19
|
+
|
|
20
|
+
const role = body.role !== undefined ? normalizeStateRole(body.role) : null;
|
|
21
|
+
// role='ai_ready' implies canonical name; reject mismatches so the AI tab
|
|
22
|
+
// checkbox always lands a lane named exactly "AI Ready".
|
|
23
|
+
const name =
|
|
24
|
+
role === "ai_ready"
|
|
25
|
+
? body.name && body.name !== "AI Ready"
|
|
26
|
+
? (() => { throw httpError(409, "ai_ready_lane_must_be_named_ai_ready"); })()
|
|
27
|
+
: "AI Ready"
|
|
28
|
+
: requiredString(body.name, "name");
|
|
29
|
+
const stateId = id();
|
|
30
|
+
return tx(db, () => {
|
|
31
|
+
if (role) {
|
|
32
|
+
db.prepare("UPDATE states SET role = NULL WHERE board_id = ? AND role = ?").run(boardId, role);
|
|
33
|
+
}
|
|
34
|
+
const maxPosition = db.prepare("SELECT MAX(position) AS value FROM states WHERE board_id = ?").get(boardId).value;
|
|
35
|
+
db.prepare(
|
|
36
|
+
`INSERT INTO states
|
|
37
|
+
(id, board_id, name, position, is_default, role, created_at)
|
|
38
|
+
VALUES (?, ?, ?, ?, 0, ?, ?)`
|
|
39
|
+
).run(stateId, boardId, name, Number(maxPosition || 0) + 1, role, now());
|
|
40
|
+
recordEvent(db, boardId, "state_created", null, actor.name, {
|
|
41
|
+
board_id: boardId,
|
|
42
|
+
state_id: stateId,
|
|
43
|
+
name,
|
|
44
|
+
role
|
|
45
|
+
});
|
|
46
|
+
return stateById(db, stateId);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function updateState(stateId, body, ctx) {
|
|
51
|
+
const { db, actor } = ctx;
|
|
52
|
+
const before = stateById(db, stateId);
|
|
53
|
+
if (!before) throw httpError(404, "state_not_found");
|
|
54
|
+
const board = boardById(db, before.board_id);
|
|
55
|
+
requireBoardAccess(actor, board);
|
|
56
|
+
requirePermission(actor, "write");
|
|
57
|
+
|
|
58
|
+
return tx(db, () => {
|
|
59
|
+
const sets = [];
|
|
60
|
+
const values = [];
|
|
61
|
+
if (body.name !== undefined) {
|
|
62
|
+
const nextName = requiredString(body.name, "name");
|
|
63
|
+
// The AI Ready lane is the canonical anchor for agent claim-next. Locking
|
|
64
|
+
// its name removes a whole class of silent breakage; users who want a
|
|
65
|
+
// different label can disable the AI Ready column from Settings → AI.
|
|
66
|
+
if (before.role === "ai_ready" && nextName !== "AI Ready") {
|
|
67
|
+
throw httpError(409, "cannot_rename_ai_ready_lane");
|
|
68
|
+
}
|
|
69
|
+
sets.push("name = ?");
|
|
70
|
+
values.push(nextName);
|
|
71
|
+
}
|
|
72
|
+
if (body.is_default !== undefined) {
|
|
73
|
+
const nextDefault = body.is_default ? 1 : 0;
|
|
74
|
+
if (nextDefault) db.prepare("UPDATE states SET is_default = 0 WHERE board_id = ?").run(before.board_id);
|
|
75
|
+
sets.push("is_default = ?");
|
|
76
|
+
values.push(nextDefault);
|
|
77
|
+
}
|
|
78
|
+
if (body.role !== undefined) {
|
|
79
|
+
const nextRole = normalizeStateRole(body.role);
|
|
80
|
+
// Roles are unique per board: at most one lane plays each role. If another
|
|
81
|
+
// lane already holds it, clear that one first so the new assignment wins.
|
|
82
|
+
if (nextRole) {
|
|
83
|
+
db.prepare("UPDATE states SET role = NULL WHERE board_id = ? AND role = ? AND id != ?")
|
|
84
|
+
.run(before.board_id, nextRole, stateId);
|
|
85
|
+
}
|
|
86
|
+
// Assigning role='ai_ready' implies the lane name must be "AI Ready" (the
|
|
87
|
+
// canonical name agents key off). If the caller is also renaming via
|
|
88
|
+
// body.name above, that took precedence; otherwise force it here.
|
|
89
|
+
if (nextRole === "ai_ready") {
|
|
90
|
+
const finalName = body.name !== undefined ? requiredString(body.name, "name") : before.name;
|
|
91
|
+
if (finalName !== "AI Ready") {
|
|
92
|
+
if (body.name === undefined) {
|
|
93
|
+
// Caller didn't ask to rename — silently set the canonical name.
|
|
94
|
+
sets.push("name = ?");
|
|
95
|
+
values.push("AI Ready");
|
|
96
|
+
} else {
|
|
97
|
+
throw httpError(409, "ai_ready_lane_must_be_named_ai_ready");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
sets.push("role = ?");
|
|
102
|
+
values.push(nextRole);
|
|
103
|
+
}
|
|
104
|
+
if (sets.length === 0) return before;
|
|
105
|
+
|
|
106
|
+
values.push(stateId);
|
|
107
|
+
db.prepare(`UPDATE states SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
108
|
+
recordEvent(db, before.board_id, "state_updated", null, actor.name, {
|
|
109
|
+
board_id: before.board_id,
|
|
110
|
+
state_id: stateId,
|
|
111
|
+
fields: Object.keys(body)
|
|
112
|
+
});
|
|
113
|
+
return stateById(db, stateId);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function reorderStates(boardId, body, ctx) {
|
|
118
|
+
const { db, actor } = ctx;
|
|
119
|
+
const board = boardById(db, boardId);
|
|
120
|
+
if (!board) throw httpError(404, "board_not_found");
|
|
121
|
+
requireBoardAccess(actor, board);
|
|
122
|
+
requirePermission(actor, "write");
|
|
123
|
+
|
|
124
|
+
const ids = Array.isArray(body.state_ids) ? body.state_ids : [];
|
|
125
|
+
const existing = db.prepare("SELECT id FROM states WHERE board_id = ?").all(boardId).map((row) => row.id);
|
|
126
|
+
if (ids.length !== existing.length || !existing.every((stateId) => ids.includes(stateId))) {
|
|
127
|
+
throw httpError(400, "invalid_state_order");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return tx(db, () => {
|
|
131
|
+
ids.forEach((stateId, position) => {
|
|
132
|
+
db.prepare("UPDATE states SET position = ? WHERE id = ? AND board_id = ?").run(position, stateId, boardId);
|
|
133
|
+
});
|
|
134
|
+
recordEvent(db, boardId, "states_reordered", null, actor.name, { board_id: boardId, state_ids: ids });
|
|
135
|
+
return db.prepare("SELECT * FROM states WHERE board_id = ? ORDER BY position").all(boardId);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function deleteState(stateId, ctx) {
|
|
140
|
+
const { db, actor } = ctx;
|
|
141
|
+
const state = stateById(db, stateId);
|
|
142
|
+
if (!state) throw httpError(404, "state_not_found");
|
|
143
|
+
const board = boardById(db, state.board_id);
|
|
144
|
+
requireBoardAccess(actor, board);
|
|
145
|
+
requirePermission(actor, "write");
|
|
146
|
+
|
|
147
|
+
const ticketCount = db.prepare("SELECT COUNT(*) AS count FROM tickets WHERE state_id = ?").get(stateId).count;
|
|
148
|
+
if (ticketCount > 0) throw httpError(409, "state_has_tickets");
|
|
149
|
+
|
|
150
|
+
const stateCount = db.prepare("SELECT COUNT(*) AS count FROM states WHERE board_id = ?").get(state.board_id).count;
|
|
151
|
+
if (stateCount <= 1) throw httpError(409, "cannot_delete_last_state");
|
|
152
|
+
|
|
153
|
+
return tx(db, () => {
|
|
154
|
+
db.prepare("DELETE FROM states WHERE id = ?").run(stateId);
|
|
155
|
+
compactStatePositions(db, state.board_id);
|
|
156
|
+
if (state.is_default) {
|
|
157
|
+
const first = db.prepare("SELECT id FROM states WHERE board_id = ? ORDER BY position LIMIT 1").get(state.board_id);
|
|
158
|
+
if (first) db.prepare("UPDATE states SET is_default = 1 WHERE id = ?").run(first.id);
|
|
159
|
+
}
|
|
160
|
+
recordEvent(db, state.board_id, "state_deleted", null, actor.name, {
|
|
161
|
+
board_id: state.board_id,
|
|
162
|
+
state_id: stateId,
|
|
163
|
+
name: state.name
|
|
164
|
+
});
|
|
165
|
+
return { ok: true, deleted_state_id: stateId };
|
|
166
|
+
});
|
|
167
|
+
}
|