@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,93 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { normalizePath } from "./util.js";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TITLE = "Choose repo folder for Orbit board";
|
|
8
|
+
|
|
9
|
+
export function folderPickerCommands(platform = process.platform, title = DEFAULT_TITLE) {
|
|
10
|
+
if (platform === "darwin") {
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
command: "osascript",
|
|
14
|
+
args: ["-e", `POSIX path of (choose folder with prompt ${JSON.stringify(title)})`]
|
|
15
|
+
}
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (platform === "win32") {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
command: "powershell.exe",
|
|
23
|
+
args: [
|
|
24
|
+
"-NoProfile",
|
|
25
|
+
"-STA",
|
|
26
|
+
"-Command",
|
|
27
|
+
[
|
|
28
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
29
|
+
"$dialog = New-Object System.Windows.Forms.FolderBrowserDialog",
|
|
30
|
+
`$dialog.Description = ${JSON.stringify(title)}`,
|
|
31
|
+
"if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {",
|
|
32
|
+
" [Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
|
|
33
|
+
" Write-Output $dialog.SelectedPath",
|
|
34
|
+
"}"
|
|
35
|
+
].join("; ")
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
command: "zenity",
|
|
44
|
+
args: ["--file-selection", "--directory", "--title", title]
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
command: "kdialog",
|
|
48
|
+
args: ["--getexistingdirectory", ".", title]
|
|
49
|
+
}
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isMissingCommand(error) {
|
|
54
|
+
return error?.code === "ENOENT";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isPickerCancel(error) {
|
|
58
|
+
const stderr = String(error?.stderr || "");
|
|
59
|
+
return error?.code === 1 && /cancel|user canceled|user cancelled/i.test(stderr);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function pickFolder(options = {}) {
|
|
63
|
+
const platform = options.platform || process.platform;
|
|
64
|
+
const title = options.title || DEFAULT_TITLE;
|
|
65
|
+
const execFileImpl = options.execFileImpl || execFileAsync;
|
|
66
|
+
const commands = folderPickerCommands(platform, title);
|
|
67
|
+
let missingCommands = 0;
|
|
68
|
+
|
|
69
|
+
for (const spec of commands) {
|
|
70
|
+
try {
|
|
71
|
+
const result = await execFileImpl(spec.command, spec.args, {
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
timeout: 120000,
|
|
74
|
+
windowsHide: false
|
|
75
|
+
});
|
|
76
|
+
const selectedPath = normalizePath(String(result.stdout || "").trim());
|
|
77
|
+
return selectedPath ? { path: selectedPath } : { canceled: true };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (isMissingCommand(error)) {
|
|
80
|
+
missingCommands += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (isPickerCancel(error)) return { canceled: true };
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (missingCommands === commands.length) {
|
|
89
|
+
return { unsupported: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { canceled: true };
|
|
93
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addComment,
|
|
3
|
+
boardById,
|
|
4
|
+
defaultStateId,
|
|
5
|
+
ensureLabel,
|
|
6
|
+
ticketById
|
|
7
|
+
} from "./queries.js";
|
|
8
|
+
import { requireBoardAccess, requirePermission } from "./auth.js";
|
|
9
|
+
import { tx } from "./db.js";
|
|
10
|
+
import { recordEvent } from "./events.js";
|
|
11
|
+
import {
|
|
12
|
+
httpError,
|
|
13
|
+
id,
|
|
14
|
+
now,
|
|
15
|
+
normalizePriority,
|
|
16
|
+
normalizeTicketType,
|
|
17
|
+
requiredString
|
|
18
|
+
} from "./util.js";
|
|
19
|
+
|
|
20
|
+
export function createTicket(body, ctx) {
|
|
21
|
+
const { db, board, actor } = ctx;
|
|
22
|
+
// The router resolves `ctx.board` from path/body/query before we get here.
|
|
23
|
+
// If the caller also sent `body.board_id`, it must agree with ctx.board.
|
|
24
|
+
if (body.board_id && body.board_id !== board.id) {
|
|
25
|
+
throw httpError(400, "board_id_mismatch");
|
|
26
|
+
}
|
|
27
|
+
const boardId = board.id;
|
|
28
|
+
const innerBoard = boardById(db, boardId);
|
|
29
|
+
if (!innerBoard) throw httpError(404, "board_not_found");
|
|
30
|
+
requireBoardAccess(actor, innerBoard);
|
|
31
|
+
requirePermission(actor, "write");
|
|
32
|
+
|
|
33
|
+
const title = requiredString(body.title, "title");
|
|
34
|
+
const stateId = body.state_id || defaultStateId(db, boardId);
|
|
35
|
+
const state = db.prepare("SELECT * FROM states WHERE id = ? AND board_id = ?").get(stateId, boardId);
|
|
36
|
+
if (!state) throw httpError(400, "invalid_state");
|
|
37
|
+
|
|
38
|
+
const ticketType = normalizeTicketType(body.type || (body.parent_ticket_id ? "feature" : "task"));
|
|
39
|
+
const parentTicketId = normalizeParentTicketId(db, body.parent_ticket_id, boardId, ticketType);
|
|
40
|
+
const ticketId = id();
|
|
41
|
+
const time = now();
|
|
42
|
+
|
|
43
|
+
return tx(db, () => {
|
|
44
|
+
// MAX(number)+1 must be read and consumed inside the transaction: BEGIN
|
|
45
|
+
// IMMEDIATE holds the write lock so a concurrent process (e.g. an MCP
|
|
46
|
+
// agent creating a ticket) can't pick the same number and trip the
|
|
47
|
+
// UNIQUE(board_id, number) constraint.
|
|
48
|
+
const nextNumber =
|
|
49
|
+
(db.prepare("SELECT MAX(number) AS value FROM tickets WHERE board_id = ?").get(boardId).value || 0) + 1;
|
|
50
|
+
|
|
51
|
+
db.prepare(
|
|
52
|
+
`INSERT INTO tickets
|
|
53
|
+
(id, board_id, number, title, description, type, parent_ticket_id, ai_plan,
|
|
54
|
+
implementation_summary, implementation_updates, state_id, priority, created_by, created_at, updated_at)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
56
|
+
).run(
|
|
57
|
+
ticketId,
|
|
58
|
+
boardId,
|
|
59
|
+
nextNumber,
|
|
60
|
+
title,
|
|
61
|
+
body.description || "",
|
|
62
|
+
ticketType,
|
|
63
|
+
parentTicketId,
|
|
64
|
+
body.ai_plan || "",
|
|
65
|
+
body.implementation_summary || "",
|
|
66
|
+
body.implementation_updates || "",
|
|
67
|
+
stateId,
|
|
68
|
+
normalizePriority(body.priority),
|
|
69
|
+
actor.name,
|
|
70
|
+
time,
|
|
71
|
+
time
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
for (const labelName of body.labels || []) {
|
|
75
|
+
const label = ensureLabel(db, boardId, labelName);
|
|
76
|
+
db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticketId, label.id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
recordEvent(db, boardId, "ticket_created", ticketId, actor.name, {
|
|
80
|
+
title,
|
|
81
|
+
type: ticketType,
|
|
82
|
+
parent_ticket_id: parentTicketId
|
|
83
|
+
});
|
|
84
|
+
reindexTicket(db, ticketId);
|
|
85
|
+
return ticketById(db, ticketId);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function updateTicket(ticketId, body, ctx) {
|
|
90
|
+
const { db, board, actor } = ctx;
|
|
91
|
+
const before = ticketById(db, ticketId);
|
|
92
|
+
if (!before) throw httpError(404, "ticket_not_found");
|
|
93
|
+
if (before.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
94
|
+
const innerBoard = boardById(db, before.board_id);
|
|
95
|
+
requireBoardAccess(actor, innerBoard);
|
|
96
|
+
requirePermission(actor, "write");
|
|
97
|
+
|
|
98
|
+
const allowed = [
|
|
99
|
+
"title",
|
|
100
|
+
"description",
|
|
101
|
+
"type",
|
|
102
|
+
"parent_ticket_id",
|
|
103
|
+
"ai_plan",
|
|
104
|
+
"implementation_summary",
|
|
105
|
+
"implementation_updates",
|
|
106
|
+
"state_id",
|
|
107
|
+
"priority"
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const nextType = body.type !== undefined ? normalizeTicketType(body.type) : before.type;
|
|
111
|
+
|
|
112
|
+
// Resolve every requested field to the value that will actually be written,
|
|
113
|
+
// without mutating the caller's request body. The same resolved map drives
|
|
114
|
+
// both the UPDATE and the audit event, so history reflects what was stored
|
|
115
|
+
// (normalized priority/type, validated parent) rather than raw input.
|
|
116
|
+
const applied = {};
|
|
117
|
+
for (const field of allowed) {
|
|
118
|
+
if (body[field] === undefined) continue;
|
|
119
|
+
if (field === "type") applied.type = nextType;
|
|
120
|
+
else if (field === "priority") applied.priority = normalizePriority(body.priority);
|
|
121
|
+
else if (field === "parent_ticket_id") {
|
|
122
|
+
applied.parent_ticket_id = normalizeParentTicketId(db, body.parent_ticket_id, before.board_id, nextType, before.id);
|
|
123
|
+
} else applied[field] = body[field];
|
|
124
|
+
}
|
|
125
|
+
// Promoting a ticket to epic clears any parent, even if the caller didn't
|
|
126
|
+
// mention parent_ticket_id (epics cannot have parents).
|
|
127
|
+
if (nextType === "epic" && applied.parent_ticket_id === undefined) {
|
|
128
|
+
applied.parent_ticket_id = null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const labelSync = Array.isArray(body.labels);
|
|
132
|
+
const fields = Object.keys(applied);
|
|
133
|
+
|
|
134
|
+
if (fields.length === 0 && !labelSync) return before;
|
|
135
|
+
|
|
136
|
+
if (applied.state_id) {
|
|
137
|
+
const state = db.prepare("SELECT * FROM states WHERE id = ? AND board_id = ?").get(applied.state_id, before.board_id);
|
|
138
|
+
if (!state) throw httpError(400, "invalid_state");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return tx(db, () => {
|
|
142
|
+
if (fields.length > 0) {
|
|
143
|
+
const sets = fields.map((field) => `${field} = ?`);
|
|
144
|
+
const values = fields.map((field) => applied[field]);
|
|
145
|
+
sets.push("updated_at = ?");
|
|
146
|
+
values.push(now(), ticketId);
|
|
147
|
+
db.prepare(`UPDATE tickets SET ${sets.join(", ")} WHERE id = ?`).run(...values);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Replace ticket labels when `labels` is sent (array of names). Labels are board-scoped.
|
|
151
|
+
if (labelSync) {
|
|
152
|
+
const boardScopeId = before.board_id;
|
|
153
|
+
db.prepare("DELETE FROM ticket_labels WHERE ticket_id = ?").run(ticketId);
|
|
154
|
+
for (const labelName of body.labels) {
|
|
155
|
+
const clean = String(labelName ?? "")
|
|
156
|
+
.trim()
|
|
157
|
+
.replace(/\s+/g, " ");
|
|
158
|
+
if (!clean) continue;
|
|
159
|
+
const label = ensureLabel(db, boardScopeId, clean);
|
|
160
|
+
db.prepare("INSERT OR IGNORE INTO ticket_labels (ticket_id, label_id) VALUES (?, ?)").run(ticketId, label.id);
|
|
161
|
+
}
|
|
162
|
+
if (fields.length === 0) {
|
|
163
|
+
db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(now(), ticketId);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const after = ticketById(db, ticketId);
|
|
168
|
+
if (before.state_id !== after.state_id) {
|
|
169
|
+
// Done lane is the auto-resolver for blocker relations. When a ticket
|
|
170
|
+
// lands in a role='done' lane, every relation that names this ticket as a
|
|
171
|
+
// blocker (`blocks` from this ticket, `blocked_by` toward this ticket)
|
|
172
|
+
// becomes meaningless — the relation row is the SSOT, so we delete it.
|
|
173
|
+
if (after.state_role === "done" && before.state_role !== "done") {
|
|
174
|
+
db.prepare(
|
|
175
|
+
`DELETE FROM relations
|
|
176
|
+
WHERE (source_ticket_id = ? AND type = 'blocks')
|
|
177
|
+
OR (target_ticket_id = ? AND type = 'blocked_by')`
|
|
178
|
+
).run(ticketId, ticketId);
|
|
179
|
+
}
|
|
180
|
+
recordEvent(db, before.board_id, "state_changed", ticketId, actor.name, {
|
|
181
|
+
from: before.state_name,
|
|
182
|
+
to: after.state_name,
|
|
183
|
+
actor_type: actor.type,
|
|
184
|
+
actor_id: actor.id
|
|
185
|
+
});
|
|
186
|
+
} else {
|
|
187
|
+
const eventPayload = { ...applied };
|
|
188
|
+
if (labelSync) eventPayload.labels = body.labels;
|
|
189
|
+
if (Object.keys(eventPayload).length > 0) {
|
|
190
|
+
recordEvent(db, before.board_id, "ticket_updated", ticketId, actor.name, eventPayload);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
reindexTicket(db, ticketId);
|
|
194
|
+
return after;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function archiveTicket(ticketId, ctx) {
|
|
199
|
+
const { db, board, actor } = ctx;
|
|
200
|
+
const ticket = ticketById(db, ticketId);
|
|
201
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
202
|
+
requireBoardAccess(actor, boardById(db, ticket.board_id));
|
|
203
|
+
requirePermission(actor, "write");
|
|
204
|
+
|
|
205
|
+
if (ticket.archived_at) return ticket;
|
|
206
|
+
|
|
207
|
+
return tx(db, () => {
|
|
208
|
+
const time = now();
|
|
209
|
+
db.prepare("UPDATE tickets SET archived_at = ?, updated_at = ? WHERE id = ?").run(time, time, ticketId);
|
|
210
|
+
recordEvent(db, ticket.board_id, "ticket_archived", ticketId, actor.name, { title: ticket.title });
|
|
211
|
+
return ticketById(db, ticketId);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function restoreTicket(ticketId, ctx) {
|
|
216
|
+
const { db, board, actor } = ctx;
|
|
217
|
+
const ticket = ticketById(db, ticketId);
|
|
218
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
219
|
+
requireBoardAccess(actor, boardById(db, ticket.board_id));
|
|
220
|
+
requirePermission(actor, "write");
|
|
221
|
+
|
|
222
|
+
if (!ticket.archived_at) return ticket;
|
|
223
|
+
|
|
224
|
+
return tx(db, () => {
|
|
225
|
+
const time = now();
|
|
226
|
+
db.prepare("UPDATE tickets SET archived_at = NULL, updated_at = ? WHERE id = ?").run(time, ticketId);
|
|
227
|
+
recordEvent(db, ticket.board_id, "ticket_restored", ticketId, actor.name, { title: ticket.title });
|
|
228
|
+
return ticketById(db, ticketId);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function deleteTicket(ticketId, ctx) {
|
|
233
|
+
const { db, board, actor } = ctx;
|
|
234
|
+
const ticket = ticketById(db, ticketId);
|
|
235
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
236
|
+
requireBoardAccess(actor, boardById(db, ticket.board_id));
|
|
237
|
+
requirePermission(actor, "write");
|
|
238
|
+
|
|
239
|
+
if (!ticket.archived_at) throw httpError(409, "ticket_not_archived");
|
|
240
|
+
|
|
241
|
+
return tx(db, () => {
|
|
242
|
+
recordEvent(db, ticket.board_id, "ticket_deleted", ticketId, actor.name, {
|
|
243
|
+
title: ticket.title,
|
|
244
|
+
type: ticket.type,
|
|
245
|
+
board_id: ticket.board_id
|
|
246
|
+
});
|
|
247
|
+
db.prepare("DELETE FROM ticket_fts WHERE ticket_id = ?").run(ticketId);
|
|
248
|
+
db.prepare("DELETE FROM tickets WHERE id = ?").run(ticketId);
|
|
249
|
+
return { ok: true, deleted_id: ticketId };
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function createComment(ticketId, body, ctx) {
|
|
254
|
+
const { db, board, actor } = ctx;
|
|
255
|
+
const ticket = ticketById(db, ticketId);
|
|
256
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
257
|
+
requireBoardAccess(actor, boardById(db, ticket.board_id));
|
|
258
|
+
requirePermission(actor, "write");
|
|
259
|
+
|
|
260
|
+
const commentBody = requiredString(body.body, "body");
|
|
261
|
+
const author = body.author || actor.name;
|
|
262
|
+
const kind = body.kind || (actor.type === "agent" ? "agent_note" : "human_comment");
|
|
263
|
+
|
|
264
|
+
return tx(db, () => {
|
|
265
|
+
const comment = addComment(db, ticketId, author, kind, commentBody);
|
|
266
|
+
db.prepare("UPDATE tickets SET updated_at = ? WHERE id = ?").run(now(), ticketId);
|
|
267
|
+
recordEvent(db, ticket.board_id, "comment_created", ticketId, actor.name, { kind: comment.kind });
|
|
268
|
+
reindexTicket(db, ticketId);
|
|
269
|
+
return comment;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function getTicketStatusHistory(ticketId, ctx) {
|
|
274
|
+
const { db, board, actor } = ctx;
|
|
275
|
+
const ticket = ticketById(db, ticketId);
|
|
276
|
+
if (!ticket || ticket.board_id !== board.id) throw httpError(404, "ticket_not_found");
|
|
277
|
+
requireBoardAccess(actor, boardById(db, ticket.board_id));
|
|
278
|
+
|
|
279
|
+
return db
|
|
280
|
+
.prepare(
|
|
281
|
+
`SELECT * FROM events
|
|
282
|
+
WHERE ticket_id = ? AND type IN ('state_changed', 'checkpoint_requested', 'agent_completed')
|
|
283
|
+
ORDER BY created_at ASC`
|
|
284
|
+
)
|
|
285
|
+
.all(ticketId)
|
|
286
|
+
.map((e) => ({ ...e, body: JSON.parse(e.body_json) }));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function normalizeParentTicketId(db, value, boardId, ticketType, currentTicketId = "") {
|
|
290
|
+
if (value === undefined || value === null || value === "") {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
if (ticketType === "epic") throw httpError(400, "epic_cannot_have_parent");
|
|
294
|
+
if (value === currentTicketId) throw httpError(400, "ticket_cannot_parent_itself");
|
|
295
|
+
|
|
296
|
+
const parent = ticketById(db, value);
|
|
297
|
+
if (!parent || parent.board_id !== boardId) throw httpError(400, "invalid_parent_ticket");
|
|
298
|
+
|
|
299
|
+
// Walk the full ancestor chain, not just one hop: parenting onto `parent`
|
|
300
|
+
// must not make `currentTicketId` its own ancestor (A→B→C→A). The depth cap
|
|
301
|
+
// also stops a pre-existing corrupt cycle from looping forever here.
|
|
302
|
+
const seen = new Set();
|
|
303
|
+
let ancestor = parent;
|
|
304
|
+
let guard = 0;
|
|
305
|
+
while (ancestor) {
|
|
306
|
+
if (currentTicketId && ancestor.id === currentTicketId) {
|
|
307
|
+
throw httpError(400, "ticket_parent_cycle");
|
|
308
|
+
}
|
|
309
|
+
if (!ancestor.parent_ticket_id || seen.has(ancestor.parent_ticket_id) || guard++ > 1000) break;
|
|
310
|
+
seen.add(ancestor.parent_ticket_id);
|
|
311
|
+
ancestor = ticketById(db, ancestor.parent_ticket_id);
|
|
312
|
+
}
|
|
313
|
+
return parent.id;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function reindexTicket(db, ticketId) {
|
|
317
|
+
const ticket = db.prepare("SELECT * FROM tickets WHERE id = ?").get(ticketId);
|
|
318
|
+
if (!ticket) return;
|
|
319
|
+
const comments = db
|
|
320
|
+
.prepare("SELECT body FROM comments WHERE ticket_id = ? ORDER BY created_at ASC")
|
|
321
|
+
.all(ticketId)
|
|
322
|
+
.map((comment) => comment.body)
|
|
323
|
+
.join("\n");
|
|
324
|
+
const implementationText = [
|
|
325
|
+
`Type: ${ticket.type}`,
|
|
326
|
+
ticket.ai_plan,
|
|
327
|
+
ticket.implementation_summary,
|
|
328
|
+
ticket.implementation_updates
|
|
329
|
+
]
|
|
330
|
+
.filter(Boolean)
|
|
331
|
+
.join("\n");
|
|
332
|
+
db.prepare("DELETE FROM ticket_fts WHERE ticket_id = ?").run(ticketId);
|
|
333
|
+
db.prepare("INSERT INTO ticket_fts (ticket_id, title, description, comments) VALUES (?, ?, ?, ?)").run(
|
|
334
|
+
ticketId,
|
|
335
|
+
ticket.title,
|
|
336
|
+
ticket.description,
|
|
337
|
+
[implementationText, comments].filter(Boolean).join("\n")
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function reindexAllTickets(db) {
|
|
342
|
+
const rows = db.prepare("SELECT id FROM tickets").all();
|
|
343
|
+
for (const row of rows) reindexTicket(db, row.id);
|
|
344
|
+
}
|
package/src/core/util.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Normalize repo paths for registry lookup (trailing slashes, slashes, Windows case). */
|
|
5
|
+
export function normalizePath(raw) {
|
|
6
|
+
if (raw === null || raw === undefined) return "";
|
|
7
|
+
let s = String(raw).trim();
|
|
8
|
+
if (!s) return "";
|
|
9
|
+
try {
|
|
10
|
+
s = resolve(s);
|
|
11
|
+
} catch {
|
|
12
|
+
return "";
|
|
13
|
+
}
|
|
14
|
+
s = s.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
15
|
+
if (process.platform === "win32") s = s.toLowerCase();
|
|
16
|
+
return s;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function id() {
|
|
20
|
+
return randomUUID();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function now() {
|
|
24
|
+
return new Date().toISOString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function slugify(value) {
|
|
28
|
+
return String(value)
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
32
|
+
.replace(/^-+|-+$/g, "")
|
|
33
|
+
.slice(0, 80);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function titleize(value) {
|
|
37
|
+
return String(value)
|
|
38
|
+
.replace(/[-_]+/g, " ")
|
|
39
|
+
.replace(/\s+/g, " ")
|
|
40
|
+
.trim()
|
|
41
|
+
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function httpError(status, code) {
|
|
45
|
+
const error = new Error(code);
|
|
46
|
+
error.status = status;
|
|
47
|
+
error.code = code;
|
|
48
|
+
return error;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function requiredString(value, field) {
|
|
52
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
53
|
+
throw httpError(400, `missing_${field}`);
|
|
54
|
+
}
|
|
55
|
+
return value.trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function normalizePriority(value) {
|
|
59
|
+
const number = Number(value ?? 2);
|
|
60
|
+
if (!Number.isFinite(number)) return 2;
|
|
61
|
+
return Math.max(0, Math.min(4, Math.round(number)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeTicketType(value) {
|
|
65
|
+
const type = String(value || "task").toLowerCase().trim();
|
|
66
|
+
const allowed = new Set(["epic", "feature", "task", "bug"]);
|
|
67
|
+
if (!allowed.has(type)) throw httpError(400, "invalid_ticket_type");
|
|
68
|
+
return type;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function normalizeProjectEntryType(value) {
|
|
72
|
+
const type = String(value || "").toLowerCase().trim();
|
|
73
|
+
const allowed = new Set(["decision", "lesson"]);
|
|
74
|
+
if (!allowed.has(type)) throw httpError(400, "invalid_project_entry_type");
|
|
75
|
+
return type;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeStateRole(value) {
|
|
79
|
+
if (value === null || value === undefined || value === "") return null;
|
|
80
|
+
const role = String(value).toLowerCase().trim();
|
|
81
|
+
// `done` marks the terminal lane that auto-resolves blockers (relation-row
|
|
82
|
+
// SSOT — see updateTicket / unresolvedBlockers). The agent-flow roles
|
|
83
|
+
// (ai_ready/in_progress/review) are anchors the claim-next pipeline keys off.
|
|
84
|
+
const allowed = new Set(["ai_ready", "in_progress", "review", "done"]);
|
|
85
|
+
if (!allowed.has(role)) throw httpError(400, "invalid_state_role");
|
|
86
|
+
return role;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function appendFieldNote(existing, note, actorName) {
|
|
90
|
+
const text = String(note || "").trim();
|
|
91
|
+
if (!text) return existing || "";
|
|
92
|
+
const entry = `## ${new Date().toISOString()} - ${actorName}\n\n${text}`;
|
|
93
|
+
return [existing, entry].filter(Boolean).join("\n\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function toFtsQuery(q) {
|
|
97
|
+
return q
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.replace(/[^a-z0-9_ -]/g, " ")
|
|
100
|
+
.split(/\s+/)
|
|
101
|
+
.map((part) => part.trim())
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.map((part) => `${part}*`)
|
|
104
|
+
.join(" AND ");
|
|
105
|
+
}
|