@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.
@@ -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 — body.board_id required so we know which DB to import
481
- // into. Imports replace within one board's DB only.
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);
@@ -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
- requiredString
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: 6,
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
+ }