@starlove/orbit 1.1.2 → 1.2.0
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/SKILL-ORBIT.md +2 -0
- package/build.mjs +11 -5
- package/dist/full/index.html +169 -20
- package/dist/full/js/app.ccc442f7.js +706 -0
- package/dist/full/js/app.ccc442f7.js.map +7 -0
- package/dist/full/styles.baccc8e3.css +1 -0
- package/dist/full/version.json +2 -2
- package/docs/ORBIT_DISPATCH.md +68 -8
- package/package.json +19 -18
- package/public/index.html +167 -18
- package/public/js/board-menu.js +16 -2
- package/public/js/card-actions.js +19 -0
- package/public/js/create-card.js +1 -1
- package/public/js/drawer.js +17 -43
- package/public/js/format.js +7 -8
- package/public/js/kanban.js +167 -101
- package/public/js/main.js +2 -1
- package/public/js/motion-preference.js +14 -3
- package/public/js/search.js +1 -1
- package/public/js/settings.js +162 -46
- package/public/js/ticket-detail.js +92 -24
- package/public/styles.css +569 -476
- package/src/cli/orbit.js +22 -0
- package/src/core/board.js +1 -0
- package/src/core/css-bundle.js +29 -0
- package/src/core/db.js +19 -0
- package/src/core/dispatch.js +176 -11
- package/src/core/http.js +10 -3
- package/src/core/registry.js +2 -2
- package/src/core/review-verdicts.js +138 -0
- package/src/core/router.js +32 -3
- package/src/core/snapshots.js +429 -9
- package/src/mcp/orbit-client.js +45 -19
- package/src/mcp-server.js +102 -27
- package/dist/full/js/app.a4f045fc.js +0 -676
- package/dist/full/js/app.a4f045fc.js.map +0 -7
- package/dist/full/styles.836872e7.css +0 -5962
package/src/core/router.js
CHANGED
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
updateBoard,
|
|
29
29
|
updateBoardEntry
|
|
30
30
|
} from "./boards.js";
|
|
31
|
-
import { exportBoard, importBoardSnapshot } from "./snapshots.js";
|
|
31
|
+
import { exportBoard, importBoardSnapshot, importBoardSnapshotAsNewBoard } from "./snapshots.js";
|
|
32
32
|
import {
|
|
33
33
|
createState,
|
|
34
34
|
deleteState,
|
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
readComments,
|
|
72
72
|
readTicket
|
|
73
73
|
} from "./agent.js";
|
|
74
|
+
import { createReviewVerdict, getReviewVerdict, listReviewVerdicts } from "./review-verdicts.js";
|
|
74
75
|
|
|
75
76
|
/** Build a ctx for an already-resolved registry row + actor. */
|
|
76
77
|
function ctxFromBoardRow(boardRow, actor) {
|
|
@@ -313,6 +314,15 @@ export async function handleApi(req, res, url) {
|
|
|
313
314
|
sendMutationJson(res, 201, createComment(ticketId, body, ctx), ctx);
|
|
314
315
|
return;
|
|
315
316
|
}
|
|
317
|
+
if (sub === "review-verdicts" && req.method === "GET") {
|
|
318
|
+
sendJson(res, 200, { review_verdicts: listReviewVerdicts(ticketId, ctx, { limit: url.searchParams.get("limit") }) });
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
if (sub === "review-verdicts" && req.method === "POST") {
|
|
322
|
+
const body = await readJson(req);
|
|
323
|
+
sendMutationJson(res, 201, createReviewVerdict(ticketId, body, ctx), ctx);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
316
326
|
if (sub === "attachments" && req.method === "GET") {
|
|
317
327
|
sendJson(res, 200, listTicketAttachments(ticketId, ctx));
|
|
318
328
|
return;
|
|
@@ -342,6 +352,16 @@ export async function handleApi(req, res, url) {
|
|
|
342
352
|
return;
|
|
343
353
|
}
|
|
344
354
|
|
|
355
|
+
const reviewVerdictPath = url.pathname.match(/^\/api\/review-verdicts\/([^/]+)$/);
|
|
356
|
+
if (reviewVerdictPath && req.method === "GET") {
|
|
357
|
+
const reviewId = decodeURIComponent(reviewVerdictPath[1]);
|
|
358
|
+
const { boardId, boardSlug } = boardHintsFromQuery(url);
|
|
359
|
+
const row = resolveBoardFromHint(boardId, boardSlug);
|
|
360
|
+
if (!row) throw httpError(400, "board_id_required");
|
|
361
|
+
sendJson(res, 200, getReviewVerdict(reviewId, ctxFromBoardRow(row, actor)));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
345
365
|
// /api/states/:id PATCH/DELETE — board resolved from query hint or scan.
|
|
346
366
|
const statePath = url.pathname.match(/^\/api\/states\/([^/]+)$/);
|
|
347
367
|
if (statePath) {
|
|
@@ -477,13 +497,22 @@ export async function handleApi(req, res, url) {
|
|
|
477
497
|
return;
|
|
478
498
|
}
|
|
479
499
|
|
|
480
|
-
// /api/admin/import —
|
|
481
|
-
//
|
|
500
|
+
// /api/admin/import — by default callers import into a brand-new board.
|
|
501
|
+
// Legacy/advanced replace imports require body.board_id so we know which
|
|
502
|
+
// board DB to replace. Replacement is destructive within that one board DB.
|
|
482
503
|
if (req.method === "POST" && url.pathname === "/api/admin/import") {
|
|
483
504
|
const body = await readJson(req);
|
|
484
505
|
const hintId = body.board_id || url.searchParams.get("board_id");
|
|
485
506
|
const hintSlug = body.board || url.searchParams.get("board");
|
|
486
507
|
const row = resolveBoardFromHint(hintId, hintSlug);
|
|
508
|
+
if (body.create_new) {
|
|
509
|
+
const seedCtx = row ? ctxFromBoardRow(row, actor) : { actor, board: {}, db: null };
|
|
510
|
+
const result = importBoardSnapshotAsNewBoard(body, seedCtx);
|
|
511
|
+
sendJson(res, 201, result);
|
|
512
|
+
const newRow = getBoardByRegistryId(result.imported_board_id);
|
|
513
|
+
if (newRow) scheduleAutomaticBoardBackup(newRow, openBoardDb(newRow));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
487
516
|
if (!row) throw httpError(400, "board_id_required");
|
|
488
517
|
const ctx = ctxFromBoardRow(row, actor);
|
|
489
518
|
sendMutationJson(res, 201, importBoardSnapshot(body, ctx), ctx);
|
package/src/core/snapshots.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
|
-
import { resetBoard, tx } from "./db.js";
|
|
1
|
+
import { resetBoard, tx, getRegistry } from "./db.js";
|
|
2
2
|
import { backupBoardDatabase } from "./backups.js";
|
|
3
3
|
import { boardById, labelsForTicket } from "./queries.js";
|
|
4
4
|
import { requireBoardAccess } from "./auth.js";
|
|
5
5
|
import { recordEvent } from "./events.js";
|
|
6
|
-
import { syncRegistryFromBoardDb } from "./registry.js";
|
|
6
|
+
import { computeNewBoardDbPath, ensureBoardDbFileAndSchema, syncRegistryFromBoardDb } from "./registry.js";
|
|
7
7
|
import {
|
|
8
8
|
httpError,
|
|
9
|
+
id,
|
|
9
10
|
normalizePriority,
|
|
10
11
|
normalizeProjectEntryType,
|
|
11
12
|
normalizeTicketType,
|
|
12
13
|
now,
|
|
13
|
-
|
|
14
|
+
normalizePath,
|
|
15
|
+
requiredString,
|
|
16
|
+
slugify
|
|
14
17
|
} from "./util.js";
|
|
15
18
|
import { reindexAllTickets } from "./tickets.js";
|
|
16
19
|
import {
|
|
@@ -37,6 +40,11 @@ export function exportBoard(boardId, ctx, options = {}) {
|
|
|
37
40
|
.prepare("SELECT * FROM comments WHERE ticket_id IN (SELECT id FROM tickets WHERE board_id = ?) ORDER BY created_at ASC")
|
|
38
41
|
.all(boardId)
|
|
39
42
|
: [];
|
|
43
|
+
const reviewVerdicts = ticketIds.length
|
|
44
|
+
? db
|
|
45
|
+
.prepare("SELECT * FROM review_verdicts WHERE ticket_id IN (SELECT id FROM tickets WHERE board_id = ?) ORDER BY created_at ASC")
|
|
46
|
+
.all(boardId)
|
|
47
|
+
: [];
|
|
40
48
|
const relations = ticketIds.length
|
|
41
49
|
? db
|
|
42
50
|
.prepare(
|
|
@@ -67,7 +75,7 @@ export function exportBoard(boardId, ctx, options = {}) {
|
|
|
67
75
|
|
|
68
76
|
return {
|
|
69
77
|
format: "orbit-board-export",
|
|
70
|
-
version:
|
|
78
|
+
version: 7,
|
|
71
79
|
exported_at: now(),
|
|
72
80
|
include_attachments: Boolean(options.includeAttachments),
|
|
73
81
|
board: { ...innerBoard },
|
|
@@ -75,6 +83,7 @@ export function exportBoard(boardId, ctx, options = {}) {
|
|
|
75
83
|
labels,
|
|
76
84
|
tickets,
|
|
77
85
|
comments,
|
|
86
|
+
review_verdicts: reviewVerdicts,
|
|
78
87
|
relations,
|
|
79
88
|
attachments,
|
|
80
89
|
board_entries: entries,
|
|
@@ -84,7 +93,7 @@ export function exportBoard(boardId, ctx, options = {}) {
|
|
|
84
93
|
|
|
85
94
|
export function importBoardSnapshot(body, ctx) {
|
|
86
95
|
const { db, actor, board } = ctx;
|
|
87
|
-
const snapshot = body?.snapshot || body;
|
|
96
|
+
const snapshot = normalizeImportSnapshot(body?.snapshot || body, ctx);
|
|
88
97
|
const format = snapshot?.format;
|
|
89
98
|
if (!snapshot || format !== "orbit-board-export") throw httpError(400, "invalid_import_snapshot");
|
|
90
99
|
if (!snapshot.board) throw httpError(400, "snapshot_missing_board");
|
|
@@ -160,8 +169,8 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
160
169
|
`INSERT INTO tickets
|
|
161
170
|
(id, board_id, number, title, description, type, parent_ticket_id, ai_plan,
|
|
162
171
|
implementation_summary, implementation_updates, state_id, priority,
|
|
163
|
-
created_by, created_at, updated_at)
|
|
164
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
172
|
+
created_by, created_at, updated_at, archived_at)
|
|
173
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
165
174
|
).run(
|
|
166
175
|
ticket.id,
|
|
167
176
|
targetBoardId,
|
|
@@ -177,7 +186,8 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
177
186
|
normalizePriority(ticket.priority),
|
|
178
187
|
ticket.created_by || "import",
|
|
179
188
|
ticket.created_at || now(),
|
|
180
|
-
ticket.updated_at || now()
|
|
189
|
+
ticket.updated_at || now(),
|
|
190
|
+
ticket.archived_at || null
|
|
181
191
|
);
|
|
182
192
|
|
|
183
193
|
for (const label of ticket.labels || []) {
|
|
@@ -198,6 +208,31 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
198
208
|
);
|
|
199
209
|
}
|
|
200
210
|
|
|
211
|
+
for (const review of snapshot.review_verdicts || []) {
|
|
212
|
+
db.prepare(
|
|
213
|
+
`INSERT INTO review_verdicts
|
|
214
|
+
(id, ticket_id, verdict, blocking_findings_json, optional_findings_json,
|
|
215
|
+
evidence_commands_json, reviewer_profile, reviewer_session_id,
|
|
216
|
+
reviewed_commit_sha, dispatch_run_id, supersedes_prior_review_id,
|
|
217
|
+
created_by, created_at)
|
|
218
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
219
|
+
).run(
|
|
220
|
+
review.id,
|
|
221
|
+
review.ticket_id,
|
|
222
|
+
review.verdict,
|
|
223
|
+
review.blocking_findings_json || "[]",
|
|
224
|
+
review.optional_findings_json || "[]",
|
|
225
|
+
review.evidence_commands_json || "[]",
|
|
226
|
+
review.reviewer_profile || "",
|
|
227
|
+
review.reviewer_session_id || "",
|
|
228
|
+
review.reviewed_commit_sha || "",
|
|
229
|
+
review.dispatch_run_id || "",
|
|
230
|
+
review.supersedes_prior_review_id || null,
|
|
231
|
+
review.created_by || "import",
|
|
232
|
+
review.created_at || now()
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
201
236
|
for (const relation of snapshot.relations || []) {
|
|
202
237
|
db.prepare(
|
|
203
238
|
"INSERT OR IGNORE INTO relations (id, source_ticket_id, target_ticket_id, type, created_at) VALUES (?, ?, ?, ?, ?)"
|
|
@@ -256,7 +291,14 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
256
291
|
ok: true,
|
|
257
292
|
imported_board_id: targetBoardId,
|
|
258
293
|
imported_board_slug: importedBoard.slug,
|
|
259
|
-
replace_existing: replaceExisting
|
|
294
|
+
replace_existing: replaceExisting,
|
|
295
|
+
source_format: snapshot.source_format || "orbit-board-export",
|
|
296
|
+
imported_counts: snapshot.imported_counts || {
|
|
297
|
+
states: (snapshot.states || []).length,
|
|
298
|
+
tickets: (snapshot.tickets || []).length,
|
|
299
|
+
comments: (snapshot.comments || []).length,
|
|
300
|
+
labels: (snapshot.labels || []).length
|
|
301
|
+
}
|
|
260
302
|
};
|
|
261
303
|
});
|
|
262
304
|
|
|
@@ -265,3 +307,381 @@ export function importBoardSnapshot(body, ctx) {
|
|
|
265
307
|
syncRegistryFromBoardDb(board, db);
|
|
266
308
|
return result;
|
|
267
309
|
}
|
|
310
|
+
|
|
311
|
+
export function importBoardSnapshotAsNewBoard(body, ctx) {
|
|
312
|
+
const rawSnapshot = body?.snapshot || body;
|
|
313
|
+
const actor = ctx.actor;
|
|
314
|
+
const sourceBoard = ctx.board || {};
|
|
315
|
+
const repoPath = normalizePath(body?.repo_path || body?.system_path || sourceBoard.repo_path || sourceBoard.system_path || process.cwd());
|
|
316
|
+
const sourceFormatSnapshot = normalizeImportSnapshot(rawSnapshot, {
|
|
317
|
+
...ctx,
|
|
318
|
+
board: {
|
|
319
|
+
id: id(),
|
|
320
|
+
slug: "",
|
|
321
|
+
name: "",
|
|
322
|
+
repo_path: repoPath,
|
|
323
|
+
system_path: repoPath,
|
|
324
|
+
repo_url: sourceBoard.repo_url || "",
|
|
325
|
+
default_branch: sourceBoard.default_branch || "main"
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
const format = sourceFormatSnapshot?.format;
|
|
329
|
+
if (!sourceFormatSnapshot || format !== "orbit-board-export") throw httpError(400, "invalid_import_snapshot");
|
|
330
|
+
if (!sourceFormatSnapshot.board) throw httpError(400, "snapshot_missing_board");
|
|
331
|
+
|
|
332
|
+
const boardName = cleanText(sourceFormatSnapshot.board.name) || cleanText(rawSnapshot?.name) || "Imported Board";
|
|
333
|
+
const boardSlug = uniqueImportedBoardSlug(body?.slug || sourceFormatSnapshot.board.slug || rawSnapshot?.name || boardName);
|
|
334
|
+
const boardId = id();
|
|
335
|
+
const dbPath = computeNewBoardDbPath(repoPath, boardSlug);
|
|
336
|
+
const db = ensureBoardDbFileAndSchema(dbPath);
|
|
337
|
+
const newBoardCtx = {
|
|
338
|
+
actor,
|
|
339
|
+
board: {
|
|
340
|
+
id: boardId,
|
|
341
|
+
slug: boardSlug,
|
|
342
|
+
name: boardName,
|
|
343
|
+
repo_path: repoPath,
|
|
344
|
+
db_path: dbPath,
|
|
345
|
+
system_path: repoPath,
|
|
346
|
+
repo_url: sourceBoard.repo_url || sourceFormatSnapshot.board.repo_url || "",
|
|
347
|
+
default_branch: sourceBoard.default_branch || sourceFormatSnapshot.board.default_branch || "main"
|
|
348
|
+
},
|
|
349
|
+
db
|
|
350
|
+
};
|
|
351
|
+
const normalizedSnapshot = normalizeImportSnapshot(rawSnapshot, newBoardCtx);
|
|
352
|
+
const snapshotForNewBoard = {
|
|
353
|
+
...normalizedSnapshot,
|
|
354
|
+
board: {
|
|
355
|
+
...normalizedSnapshot.board,
|
|
356
|
+
id: boardId,
|
|
357
|
+
slug: boardSlug,
|
|
358
|
+
name: cleanText(normalizedSnapshot.board?.name) || boardName,
|
|
359
|
+
repo_url: normalizedSnapshot.board?.repo_url || sourceBoard.repo_url || "",
|
|
360
|
+
system_path: normalizedSnapshot.board?.system_path || repoPath,
|
|
361
|
+
default_branch: normalizedSnapshot.board?.default_branch || sourceBoard.default_branch || "main"
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
...importBoardSnapshot({ snapshot: snapshotForNewBoard, replace_existing: false }, newBoardCtx),
|
|
367
|
+
created_new_board: true
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function uniqueImportedBoardSlug(value) {
|
|
372
|
+
const base = slugify(value) || `imported-board-${id().slice(0, 8)}`;
|
|
373
|
+
const reg = getRegistry();
|
|
374
|
+
if (!reg.prepare("SELECT id FROM boards WHERE slug = ?").get(base)) return base;
|
|
375
|
+
for (let i = 2; i < 1000; i += 1) {
|
|
376
|
+
const candidate = `${base}-${i}`;
|
|
377
|
+
if (!reg.prepare("SELECT id FROM boards WHERE slug = ?").get(candidate)) return candidate;
|
|
378
|
+
}
|
|
379
|
+
return `${base}-${id().slice(0, 8)}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function normalizeImportSnapshot(snapshot, ctx) {
|
|
383
|
+
if (snapshot?.format === "orbit-board-export") return snapshot;
|
|
384
|
+
if (looksLikeTrelloExport(snapshot)) return trelloExportToOrbitSnapshot(snapshot, ctx);
|
|
385
|
+
return snapshot;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function looksLikeTrelloExport(value) {
|
|
389
|
+
return Boolean(
|
|
390
|
+
value &&
|
|
391
|
+
typeof value === "object" &&
|
|
392
|
+
Array.isArray(value.cards) &&
|
|
393
|
+
Array.isArray(value.lists) &&
|
|
394
|
+
(typeof value.id === "string" || typeof value.name === "string")
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const TRELLO_LABEL_COLORS = {
|
|
399
|
+
green: "#61bd4f",
|
|
400
|
+
yellow: "#f2d600",
|
|
401
|
+
orange: "#ff9f1a",
|
|
402
|
+
red: "#eb5a46",
|
|
403
|
+
purple: "#c377e0",
|
|
404
|
+
blue: "#0079bf",
|
|
405
|
+
sky: "#00c2e0",
|
|
406
|
+
lime: "#51e898",
|
|
407
|
+
pink: "#ff78cb",
|
|
408
|
+
black: "#344563"
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
function trelloExportToOrbitSnapshot(trello, ctx) {
|
|
412
|
+
const time = now();
|
|
413
|
+
const targetBoard = ctx.board || {};
|
|
414
|
+
const boardName = cleanText(trello.name) || "Imported Trello Board";
|
|
415
|
+
const boardSlug = targetBoard.slug || slugify(boardName) || `trello-${id().slice(0, 8)}`;
|
|
416
|
+
const labelsById = buildTrelloLabelMap(trello, time);
|
|
417
|
+
const checklistsByCard = groupTrelloChecklists(trello);
|
|
418
|
+
const commentsByCard = groupTrelloComments(trello);
|
|
419
|
+
const sortedLists = [...(trello.lists || [])].sort(compareTrelloPosition);
|
|
420
|
+
const states = buildTrelloStates(sortedLists, time);
|
|
421
|
+
const fallbackStateId = states[0]?.id;
|
|
422
|
+
const stateIdsByTrelloListId = new Map(sortedLists.map((list, index) => [list.id, states[index]?.id || fallbackStateId]));
|
|
423
|
+
const sortedCards = [...(trello.cards || [])].sort(compareTrelloPosition);
|
|
424
|
+
const tickets = [];
|
|
425
|
+
const comments = [];
|
|
426
|
+
|
|
427
|
+
sortedCards.forEach((card, index) => {
|
|
428
|
+
const ticketId = trelloScopedId("card", card.id || `${index + 1}`);
|
|
429
|
+
const stateId = stateIdsByTrelloListId.get(card.idList) || fallbackStateId;
|
|
430
|
+
if (!stateId) return;
|
|
431
|
+
const cardUpdatedAt = cleanText(card.dateLastActivity) || time;
|
|
432
|
+
const cardCreatedAt = dateFromTrelloObjectId(card.id) || cardUpdatedAt;
|
|
433
|
+
const cardComments = commentsByCard.get(card.id) || [];
|
|
434
|
+
const ticketLabels = trelloLabelsForCard(card, labelsById);
|
|
435
|
+
const importedDescription = renderTrelloCardDescription(card, {
|
|
436
|
+
listName: listNameForCard(trello.lists, card.idList),
|
|
437
|
+
checklists: checklistsByCard.get(card.id) || []
|
|
438
|
+
});
|
|
439
|
+
tickets.push({
|
|
440
|
+
id: ticketId,
|
|
441
|
+
number: index + 1,
|
|
442
|
+
title: cleanText(card.name) || `Untitled Trello card ${index + 1}`,
|
|
443
|
+
description: importedDescription,
|
|
444
|
+
type: "task",
|
|
445
|
+
parent_ticket_id: null,
|
|
446
|
+
ai_plan: "",
|
|
447
|
+
implementation_summary: "",
|
|
448
|
+
implementation_updates: "",
|
|
449
|
+
state_id: stateId,
|
|
450
|
+
priority: priorityFromTrelloCard(card),
|
|
451
|
+
created_by: "trello-import",
|
|
452
|
+
created_at: cardCreatedAt,
|
|
453
|
+
updated_at: cardUpdatedAt,
|
|
454
|
+
archived_at: card.closed ? cardUpdatedAt : null,
|
|
455
|
+
labels: ticketLabels
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
cardComments.forEach((action, commentIndex) => {
|
|
459
|
+
const commentId = trelloScopedId("comment", action.id || `${card.id}-${commentIndex + 1}`);
|
|
460
|
+
comments.push({
|
|
461
|
+
id: commentId,
|
|
462
|
+
ticket_id: ticketId,
|
|
463
|
+
author: trelloMemberName(action.memberCreator),
|
|
464
|
+
kind: "human_comment",
|
|
465
|
+
body: cleanText(action.data?.text) || "",
|
|
466
|
+
created_at: cleanText(action.date) || cardUpdatedAt
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
format: "orbit-board-export",
|
|
473
|
+
source_format: "trello-board-export",
|
|
474
|
+
version: 7,
|
|
475
|
+
exported_at: time,
|
|
476
|
+
include_attachments: false,
|
|
477
|
+
imported_counts: {
|
|
478
|
+
states: states.length,
|
|
479
|
+
tickets: tickets.length,
|
|
480
|
+
comments: comments.length,
|
|
481
|
+
labels: labelsById.size
|
|
482
|
+
},
|
|
483
|
+
board: {
|
|
484
|
+
id: targetBoard.id || trelloScopedId("board", trello.id || id()),
|
|
485
|
+
slug: boardSlug,
|
|
486
|
+
name: boardName,
|
|
487
|
+
repo_url: targetBoard.repo_url || "",
|
|
488
|
+
system_path: targetBoard.system_path || targetBoard.repo_path || "",
|
|
489
|
+
default_branch: targetBoard.default_branch || "main",
|
|
490
|
+
project_notes: cleanText(trello.desc) ? `Imported from Trello.\n\n${cleanText(trello.desc)}` : "Imported from Trello.",
|
|
491
|
+
agent_instructions: "",
|
|
492
|
+
ai_enabled: 1,
|
|
493
|
+
created_at: cleanText(trello.dateLastActivity) || time,
|
|
494
|
+
updated_at: time
|
|
495
|
+
},
|
|
496
|
+
states,
|
|
497
|
+
labels: [...labelsById.values()],
|
|
498
|
+
tickets,
|
|
499
|
+
comments: comments.filter((comment) => comment.body),
|
|
500
|
+
review_verdicts: [],
|
|
501
|
+
relations: [],
|
|
502
|
+
attachments: [],
|
|
503
|
+
board_entries: [],
|
|
504
|
+
events: []
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function buildTrelloStates(lists = [], time) {
|
|
509
|
+
const sorted = [...lists].sort(compareTrelloPosition);
|
|
510
|
+
const rolesSeen = new Set();
|
|
511
|
+
const firstOpenIndex = sorted.findIndex((list) => !list.closed);
|
|
512
|
+
const defaultIndex = firstOpenIndex >= 0 ? firstOpenIndex : 0;
|
|
513
|
+
return sorted.map((list, index) => {
|
|
514
|
+
const role = uniqueTrelloStateRole(inferTrelloStateRole(list.name), rolesSeen);
|
|
515
|
+
return {
|
|
516
|
+
id: trelloScopedId("list", list.id || `${index + 1}`),
|
|
517
|
+
name: cleanText(list.name) || `Trello List ${index + 1}`,
|
|
518
|
+
position: index,
|
|
519
|
+
is_default: index === defaultIndex ? 1 : 0,
|
|
520
|
+
role,
|
|
521
|
+
created_at: dateFromTrelloObjectId(list.id) || time
|
|
522
|
+
};
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function buildTrelloLabelMap(trello, time) {
|
|
527
|
+
const labels = new Map();
|
|
528
|
+
const addLabel = (raw) => {
|
|
529
|
+
if (!raw) return;
|
|
530
|
+
const key = raw.id || `${raw.color || "label"}-${raw.name || ""}`;
|
|
531
|
+
const name = trelloLabelName(raw, trello.labelNames);
|
|
532
|
+
if (!name || labels.has(key)) return;
|
|
533
|
+
labels.set(key, {
|
|
534
|
+
id: trelloScopedId("label", key),
|
|
535
|
+
name,
|
|
536
|
+
color: TRELLO_LABEL_COLORS[String(raw.color || "").toLowerCase()] || "#64748b",
|
|
537
|
+
created_at: dateFromTrelloObjectId(raw.id) || time
|
|
538
|
+
});
|
|
539
|
+
};
|
|
540
|
+
for (const label of trello.labels || []) addLabel(label);
|
|
541
|
+
for (const card of trello.cards || []) for (const label of card.labels || []) addLabel(label);
|
|
542
|
+
return labels;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function groupTrelloChecklists(trello) {
|
|
546
|
+
const grouped = new Map();
|
|
547
|
+
const add = (cardId, checklist) => {
|
|
548
|
+
if (!cardId || !checklist) return;
|
|
549
|
+
if (!grouped.has(cardId)) grouped.set(cardId, []);
|
|
550
|
+
grouped.get(cardId).push(checklist);
|
|
551
|
+
};
|
|
552
|
+
for (const checklist of trello.checklists || []) add(checklist.idCard, checklist);
|
|
553
|
+
for (const card of trello.cards || []) for (const checklist of card.checklists || []) add(card.id, checklist);
|
|
554
|
+
return grouped;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function groupTrelloComments(trello) {
|
|
558
|
+
const grouped = new Map();
|
|
559
|
+
for (const action of trello.actions || []) {
|
|
560
|
+
if (action.type !== "commentCard") continue;
|
|
561
|
+
const cardId = action.data?.card?.id;
|
|
562
|
+
if (!cardId) continue;
|
|
563
|
+
if (!grouped.has(cardId)) grouped.set(cardId, []);
|
|
564
|
+
grouped.get(cardId).push(action);
|
|
565
|
+
}
|
|
566
|
+
for (const actions of grouped.values()) actions.sort((a, b) => String(a.date || "").localeCompare(String(b.date || "")));
|
|
567
|
+
return grouped;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function trelloLabelsForCard(card, labelsById) {
|
|
571
|
+
const result = [];
|
|
572
|
+
const seen = new Set();
|
|
573
|
+
const addByRawId = (rawId) => {
|
|
574
|
+
const label = labelsById.get(rawId);
|
|
575
|
+
if (label && !seen.has(label.id)) {
|
|
576
|
+
seen.add(label.id);
|
|
577
|
+
result.push(label);
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
for (const label of card.labels || []) addByRawId(label.id || `${label.color || "label"}-${label.name || ""}`);
|
|
581
|
+
for (const labelId of card.idLabels || []) addByRawId(labelId);
|
|
582
|
+
return result;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function renderTrelloCardDescription(card, { listName, checklists }) {
|
|
586
|
+
const parts = [];
|
|
587
|
+
const desc = cleanText(card.desc);
|
|
588
|
+
if (desc) parts.push(desc);
|
|
589
|
+
const meta = [];
|
|
590
|
+
if (card.idShort) meta.push(`Trello card #${card.idShort}`);
|
|
591
|
+
if (listName) meta.push(`List: ${listName}`);
|
|
592
|
+
if (card.shortUrl || card.url) meta.push(`URL: ${card.shortUrl || card.url}`);
|
|
593
|
+
if (card.due) meta.push(`Due: ${card.due}${card.dueComplete ? " (complete)" : ""}`);
|
|
594
|
+
if (card.closed) meta.push("Archived in Trello: yes");
|
|
595
|
+
if (meta.length) parts.push(["Imported from Trello:", ...meta.map((line) => `- ${line}`)].join("\n"));
|
|
596
|
+
|
|
597
|
+
const checklistText = renderTrelloChecklists(checklists);
|
|
598
|
+
if (checklistText) parts.push(checklistText);
|
|
599
|
+
const attachmentText = renderTrelloAttachments(card.attachments || []);
|
|
600
|
+
if (attachmentText) parts.push(attachmentText);
|
|
601
|
+
return parts.join("\n\n");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function renderTrelloChecklists(checklists = []) {
|
|
605
|
+
const rendered = checklists
|
|
606
|
+
.map((checklist) => {
|
|
607
|
+
const items = [...(checklist.checkItems || [])].sort(compareTrelloPosition);
|
|
608
|
+
if (!items.length) return "";
|
|
609
|
+
return [`Checklist: ${cleanText(checklist.name) || "Untitled"}`, ...items.map((item) => `- [${item.state === "complete" ? "x" : " "}] ${cleanText(item.name) || "Untitled item"}`)].join("\n");
|
|
610
|
+
})
|
|
611
|
+
.filter(Boolean);
|
|
612
|
+
return rendered.join("\n\n");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function renderTrelloAttachments(attachments = []) {
|
|
616
|
+
const rendered = attachments
|
|
617
|
+
.map((attachment) => {
|
|
618
|
+
const name = cleanText(attachment.name) || cleanText(attachment.fileName) || "Attachment";
|
|
619
|
+
const url = cleanText(attachment.url);
|
|
620
|
+
return url ? `- ${name}: ${url}` : `- ${name}`;
|
|
621
|
+
})
|
|
622
|
+
.filter(Boolean);
|
|
623
|
+
return rendered.length ? ["Trello attachments:", ...rendered].join("\n") : "";
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function trelloLabelName(label, labelNames = {}) {
|
|
627
|
+
const explicit = cleanText(label.name);
|
|
628
|
+
if (explicit) return explicit;
|
|
629
|
+
const color = cleanText(label.color);
|
|
630
|
+
const namedColor = color ? cleanText(labelNames[color]) : "";
|
|
631
|
+
if (namedColor) return namedColor;
|
|
632
|
+
return color ? `Trello ${color}` : "";
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function listNameForCard(lists = [], idList) {
|
|
636
|
+
return cleanText(lists.find((list) => list.id === idList)?.name);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function priorityFromTrelloCard(card) {
|
|
640
|
+
const names = (card.labels || []).map((label) => cleanText(label.name || label.color).toLowerCase()).join(" ");
|
|
641
|
+
if (/urgent|critical|blocker|high|red/.test(names)) return 4;
|
|
642
|
+
if (/low|someday|backlog/.test(names)) return 1;
|
|
643
|
+
return 2;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function inferTrelloStateRole(name) {
|
|
647
|
+
const normalized = cleanText(name).toLowerCase();
|
|
648
|
+
if (!normalized) return null;
|
|
649
|
+
if (/^ai ready$|ai[- ]ready/.test(normalized)) return "ai_ready";
|
|
650
|
+
if (/review|qa|verify|validation/.test(normalized)) return "review";
|
|
651
|
+
if (/doing|in progress|active|working|wip/.test(normalized)) return "in_progress";
|
|
652
|
+
if (/done|complete|completed|shipped|closed/.test(normalized)) return "done";
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function uniqueTrelloStateRole(role, rolesSeen) {
|
|
657
|
+
if (!role || rolesSeen.has(role)) return null;
|
|
658
|
+
rolesSeen.add(role);
|
|
659
|
+
return role;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function trelloMemberName(member) {
|
|
663
|
+
return cleanText(member?.fullName) || cleanText(member?.username) || "Trello";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function compareTrelloPosition(a, b) {
|
|
667
|
+
const pa = Number(a?.pos ?? Number.MAX_SAFE_INTEGER);
|
|
668
|
+
const pb = Number(b?.pos ?? Number.MAX_SAFE_INTEGER);
|
|
669
|
+
if (pa !== pb) return pa - pb;
|
|
670
|
+
return String(a?.id || "").localeCompare(String(b?.id || ""));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function dateFromTrelloObjectId(value) {
|
|
674
|
+
const idValue = String(value || "");
|
|
675
|
+
if (!/^[0-9a-fA-F]{8}/.test(idValue)) return null;
|
|
676
|
+
const seconds = Number.parseInt(idValue.slice(0, 8), 16);
|
|
677
|
+
if (!Number.isFinite(seconds)) return null;
|
|
678
|
+
return new Date(seconds * 1000).toISOString();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function cleanText(value) {
|
|
682
|
+
return String(value ?? "").replace(/\r\n/g, "\n").trim();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function trelloScopedId(prefix, value) {
|
|
686
|
+
return `trello-${prefix}-${String(value || id()).replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
687
|
+
}
|