@starlove/orbit 1.4.1 → 2.0.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/Dockerfile +10 -8
- package/README.md +146 -164
- package/dist/full/csp.txt +1 -0
- package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
- package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
- package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
- package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
- package/dist/full/fonts/Mona_Sans/README.txt +10 -0
- package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
- package/dist/full/fonts/Newsreader/README.txt +8 -0
- package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
- package/dist/full/index.html +36 -653
- package/dist/full/js/app.BmuponPJ.js +32 -0
- package/dist/full/js/app.BmuponPJ.js.map +1 -0
- package/dist/full/js/chunk.1fJGQGbW.js +2 -0
- package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
- package/dist/full/js/chunk.5D7JO2zp.js +2 -0
- package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
- package/dist/full/js/chunk.BrNn_D79.js +2 -0
- package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
- package/dist/full/js/chunk.CokJXIqM.js +2 -0
- package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
- package/dist/full/js/chunk.D_Haqy6R.js +2 -0
- package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
- package/dist/full/js/chunk.RO0jsH6I.js +15 -0
- package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
- package/dist/full/js/chunk.dqWOEtBM.js +2 -0
- package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
- package/dist/full/js/warm.f260bbbc.js +15 -0
- package/dist/full/orbits-favicon.png +0 -0
- package/dist/full/orbits-logo-bg-wide.png +0 -0
- package/dist/full/orbits-logo-bg.png +0 -0
- package/dist/full/orbits-logo.png +0 -0
- package/dist/full/styles.2b154ffe.css +1 -0
- package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
- package/dist/full/version.json +2 -2
- package/docs/AGENT_GUIDE.md +114 -0
- package/docs/UPDATING.md +249 -64
- package/lib/adapters/indexeddb/blobs.d.ts +3 -0
- package/lib/adapters/indexeddb/blobs.js +54 -0
- package/lib/adapters/indexeddb/database.d.ts +60 -0
- package/lib/adapters/indexeddb/database.js +126 -0
- package/lib/adapters/indexeddb/index.d.ts +5 -0
- package/lib/adapters/indexeddb/index.js +3 -0
- package/lib/adapters/indexeddb/locks.d.ts +14 -0
- package/lib/adapters/indexeddb/locks.js +40 -0
- package/lib/adapters/indexeddb/store.d.ts +40 -0
- package/lib/adapters/indexeddb/store.js +424 -0
- package/lib/adapters/indexeddb/tables.d.ts +43 -0
- package/lib/adapters/indexeddb/tables.js +127 -0
- package/lib/adapters/memory/blobs.d.ts +3 -0
- package/lib/adapters/memory/blobs.js +44 -0
- package/lib/adapters/memory/board.d.ts +41 -0
- package/lib/adapters/memory/board.js +521 -0
- package/lib/adapters/memory/index.d.ts +2 -0
- package/lib/adapters/memory/index.js +2 -0
- package/lib/adapters/memory/search.d.ts +6 -0
- package/lib/adapters/memory/search.js +30 -0
- package/lib/adapters/memory/store.d.ts +34 -0
- package/lib/adapters/memory/store.js +266 -0
- package/lib/adapters/sqlite/backups.d.ts +53 -0
- package/lib/adapters/sqlite/backups.js +232 -0
- package/lib/adapters/sqlite/blobs.d.ts +14 -0
- package/lib/adapters/sqlite/blobs.js +109 -0
- package/lib/adapters/sqlite/board.d.ts +77 -0
- package/lib/adapters/sqlite/board.js +328 -0
- package/lib/adapters/sqlite/database.d.ts +67 -0
- package/lib/adapters/sqlite/database.js +285 -0
- package/lib/adapters/sqlite/index.d.ts +3 -0
- package/lib/adapters/sqlite/index.js +2 -0
- package/lib/adapters/sqlite/lock.d.ts +11 -0
- package/lib/adapters/sqlite/lock.js +34 -0
- package/lib/adapters/sqlite/records.d.ts +54 -0
- package/lib/adapters/sqlite/records.js +546 -0
- package/lib/adapters/sqlite/schema.d.ts +54 -0
- package/lib/adapters/sqlite/schema.js +276 -0
- package/lib/adapters/sqlite/store.d.ts +39 -0
- package/lib/adapters/sqlite/store.js +385 -0
- package/lib/browser/in-page.d.ts +31 -0
- package/lib/browser/in-page.js +46 -0
- package/lib/browser/old-preview.d.ts +13 -0
- package/lib/browser/old-preview.js +80 -0
- package/lib/browser/preview-board.d.ts +8 -0
- package/lib/browser/preview-board.js +69 -0
- package/lib/cli/main.d.ts +7 -0
- package/lib/cli/main.js +343 -0
- package/lib/cli/orbit.d.ts +8 -0
- package/lib/cli/orbit.js +25 -0
- package/lib/client/http.d.ts +38 -0
- package/lib/client/http.js +334 -0
- package/lib/client/local.d.ts +26 -0
- package/lib/client/local.js +190 -0
- package/lib/client/object-url.d.ts +8 -0
- package/lib/client/object-url.js +21 -0
- package/lib/client/types.d.ts +268 -0
- package/lib/client/types.js +8 -0
- package/lib/commands/agent-tools.d.ts +31 -0
- package/lib/commands/agent-tools.js +100 -0
- package/lib/commands/index.d.ts +56 -0
- package/lib/commands/index.js +298 -0
- package/lib/commands/parse.d.ts +73 -0
- package/lib/commands/parse.js +231 -0
- package/lib/commands/present.d.ts +134 -0
- package/lib/commands/present.js +198 -0
- package/lib/commands/session.d.ts +85 -0
- package/lib/commands/session.js +138 -0
- package/lib/commands/verbs.d.ts +35 -0
- package/lib/commands/verbs.js +842 -0
- package/lib/core/actors.d.ts +18 -0
- package/lib/core/actors.js +45 -0
- package/lib/core/agent.d.ts +128 -0
- package/lib/core/agent.js +323 -0
- package/lib/core/boards.d.ts +113 -0
- package/lib/core/boards.js +288 -0
- package/lib/core/bootstrap.d.ts +47 -0
- package/lib/core/bootstrap.js +100 -0
- package/lib/core/bytes.d.ts +10 -0
- package/lib/core/bytes.js +52 -0
- package/lib/core/card-paths.d.ts +25 -0
- package/lib/core/card-paths.js +44 -0
- package/lib/core/context.d.ts +60 -0
- package/lib/core/context.js +42 -0
- package/lib/core/cross-board.d.ts +81 -0
- package/lib/core/cross-board.js +228 -0
- package/lib/core/describe.d.ts +21 -0
- package/lib/core/describe.js +81 -0
- package/lib/core/errors.d.ts +159 -0
- package/lib/core/errors.js +160 -0
- package/lib/core/files.d.ts +79 -0
- package/lib/core/files.js +262 -0
- package/lib/core/history.d.ts +20 -0
- package/lib/core/history.js +53 -0
- package/lib/core/image-types.d.ts +16 -0
- package/lib/core/image-types.js +36 -0
- package/lib/core/model.d.ts +263 -0
- package/lib/core/model.js +34 -0
- package/lib/core/nebula-link.d.ts +14 -0
- package/lib/core/nebula-link.js +39 -0
- package/lib/core/relations.d.ts +58 -0
- package/lib/core/relations.js +112 -0
- package/lib/core/search.d.ts +38 -0
- package/lib/core/search.js +92 -0
- package/lib/core/sessions.d.ts +44 -0
- package/lib/core/sessions.js +158 -0
- package/lib/core/snapshot-schema.d.ts +295 -0
- package/lib/core/snapshot-schema.js +82 -0
- package/lib/core/snapshots.d.ts +75 -0
- package/lib/core/snapshots.js +409 -0
- package/lib/core/states.d.ts +25 -0
- package/lib/core/states.js +126 -0
- package/lib/core/text.d.ts +40 -0
- package/lib/core/text.js +113 -0
- package/lib/core/tickets.d.ts +69 -0
- package/lib/core/tickets.js +311 -0
- package/lib/core/trello.d.ts +17 -0
- package/lib/core/trello.js +269 -0
- package/lib/core/verdicts.d.ts +28 -0
- package/lib/core/verdicts.js +92 -0
- package/lib/core/views.d.ts +95 -0
- package/lib/core/views.js +186 -0
- package/lib/http/app.d.ts +46 -0
- package/lib/http/app.js +491 -0
- package/lib/mcp-server.d.ts +10 -0
- package/lib/mcp-server.js +120 -0
- package/lib/node/assets.d.ts +7 -0
- package/lib/node/assets.js +68 -0
- package/lib/node/data-folder.d.ts +21 -0
- package/lib/node/data-folder.js +34 -0
- package/lib/node/env.d.ts +38 -0
- package/lib/node/env.js +49 -0
- package/lib/node/host-check.d.ts +24 -0
- package/lib/node/host-check.js +53 -0
- package/lib/node/host-services.d.ts +9 -0
- package/lib/node/host-services.js +150 -0
- package/lib/node/local.d.ts +22 -0
- package/lib/node/local.js +61 -0
- package/lib/node/migrate.d.ts +53 -0
- package/lib/node/migrate.js +635 -0
- package/lib/node/package.d.ts +7 -0
- package/lib/node/package.js +14 -0
- package/lib/node/repo-board.d.ts +49 -0
- package/lib/node/repo-board.js +245 -0
- package/lib/node/serve.d.ts +8 -0
- package/lib/node/serve.js +106 -0
- package/lib/orbit.d.ts +47 -0
- package/lib/orbit.js +44 -0
- package/lib/quiet-sqlite-warning.d.ts +1 -0
- package/lib/quiet-sqlite-warning.js +18 -0
- package/lib/store/blob-path.d.ts +6 -0
- package/lib/store/blob-path.js +12 -0
- package/lib/store/conformance.d.ts +52 -0
- package/lib/store/conformance.js +1449 -0
- package/lib/store/search-words.d.ts +10 -0
- package/lib/store/search-words.js +19 -0
- package/lib/store/types.d.ts +452 -0
- package/lib/store/types.js +34 -0
- package/llms.txt +63 -0
- package/package.json +109 -25
- package/src/cli/orbit.js +11 -658
- package/src/mcp-server.js +16 -876
- package/SKILL-ORBIT.md +0 -151
- package/build.mjs +0 -210
- package/dist/full/js/app.4e6fbe20.js +0 -952
- package/dist/full/js/app.4e6fbe20.js.map +0 -7
- package/dist/full/styles.4e60d824.css +0 -1
- package/docs/AGENT_PROTOCOL.md +0 -424
- package/docs/MCP_SETUP.md +0 -54
- package/docs/ORBIT_DISPATCH.md +0 -280
- package/docs/SS-Orbit-Dark.png +0 -0
- package/docs/SS-Orbit-Light.png +0 -0
- package/docs/mcp-client.example.json +0 -13
- package/docs/setup/orbit-init-wizard.md +0 -174
- 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/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
- package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
- package/public/fonts/Chakra_Petch/OFL.txt +0 -93
- package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
- package/public/fonts/Inter/OFL.txt +0 -93
- package/public/fonts/Inter/README.txt +0 -118
- package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
- package/public/fonts/JetBrains_Mono/README.txt +0 -79
- package/public/fonts/OpenRing-Regular.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
- package/public/fonts/Open_Sans/README.txt +0 -100
- package/public/fonts/Orbitron/OFL.txt +0 -93
- package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
- package/public/fonts/Orbitron/README.txt +0 -68
- package/public/fonts/Rajdhani/OFL.txt +0 -93
- package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
- package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
- package/public/fonts/runescape-uf.ttf +0 -0
- package/public/index.html +0 -769
- package/public/js/actor-labels.js +0 -65
- package/public/js/api.js +0 -39
- package/public/js/app.js +0 -45
- package/public/js/artifact-drawer.js +0 -79
- package/public/js/board-export.js +0 -161
- package/public/js/board-menu.js +0 -374
- package/public/js/card-actions.js +0 -545
- package/public/js/config.js +0 -20
- package/public/js/create-card.js +0 -190
- package/public/js/dom.js +0 -19
- package/public/js/drawer.js +0 -273
- package/public/js/font-preference.js +0 -230
- package/public/js/format.js +0 -433
- package/public/js/help-menu.js +0 -31
- package/public/js/icons.js +0 -16
- package/public/js/kanban.js +0 -694
- package/public/js/local-backend.js +0 -1015
- package/public/js/main.js +0 -161
- package/public/js/modal.js +0 -33
- package/public/js/motion-preference.js +0 -64
- package/public/js/panel.js +0 -58
- package/public/js/router.js +0 -134
- package/public/js/search.js +0 -56
- package/public/js/settings-tabs.js +0 -7
- package/public/js/settings.js +0 -1634
- package/public/js/sse.js +0 -159
- package/public/js/starscape.js +0 -332
- package/public/js/state.js +0 -129
- package/public/js/text-editing.js +0 -128
- package/public/js/theme-preference.js +0 -95
- package/public/js/ticket-detail.js +0 -1516
- package/public/js/ticket-typeahead.js +0 -49
- package/public/js/toast.js +0 -89
- package/public/js/topbar-notes.js +0 -155
- package/public/js/transport.js +0 -22
- package/public/js/typeahead.js +0 -206
- package/public/js/unread.js +0 -121
- package/public/js/update-check.js +0 -58
- package/public/js/url-routes.js +0 -94
- package/public/orbits-favicon.png +0 -0
- package/public/orbits-logo-bg-wide.png +0 -0
- package/public/orbits-logo-bg.png +0 -0
- package/public/orbits-logo.png +0 -0
- package/public/styles.css +0 -7709
- package/src/core/agent.js +0 -387
- package/src/core/agents-md.js +0 -109
- package/src/core/artifacts.js +0 -149
- package/src/core/attachments.js +0 -238
- package/src/core/auth.js +0 -85
- package/src/core/backups.js +0 -142
- package/src/core/board.js +0 -148
- package/src/core/boards.js +0 -409
- package/src/core/bootstrap.js +0 -170
- package/src/core/css-bundle.js +0 -30
- package/src/core/db.js +0 -315
- package/src/core/delete-board.js +0 -163
- package/src/core/dispatch.js +0 -1007
- package/src/core/events.js +0 -314
- package/src/core/http.js +0 -115
- package/src/core/migrate.js +0 -198
- package/src/core/paths.js +0 -55
- package/src/core/provision-repo-board.js +0 -119
- package/src/core/queries.js +0 -246
- package/src/core/registry.js +0 -211
- package/src/core/relations.js +0 -163
- package/src/core/review-verdicts.js +0 -138
- package/src/core/router.js +0 -556
- package/src/core/search.js +0 -183
- package/src/core/seed.js +0 -234
- package/src/core/snapshots.js +0 -692
- package/src/core/states.js +0 -167
- package/src/core/system-picker.js +0 -109
- package/src/core/tickets.js +0 -400
- package/src/core/util.js +0 -106
- package/src/core/version-check.js +0 -154
- package/src/mcp/orbit-client.js +0 -287
- package/src/server.js +0 -115
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trello's board export, read as an Orbit export: lists become lanes (roles
|
|
3
|
+
* guessed from their names), cards become cards with their checklists,
|
|
4
|
+
* links and due dates in the description, labels and comments come along.
|
|
5
|
+
* Pure: it only reshapes the data it is given.
|
|
6
|
+
*/
|
|
7
|
+
import { slugify } from "./text.js";
|
|
8
|
+
const isRaw = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
const rawList = (value) => (Array.isArray(value) ? value.filter(isRaw) : []);
|
|
10
|
+
const text = (value) => (typeof value === "string" || typeof value === "number" ? String(value) : "").replace(/\r\n/g, "\n").trim();
|
|
11
|
+
export function looksLikeTrello(value) {
|
|
12
|
+
return isRaw(value) && Array.isArray(value.cards) && Array.isArray(value.lists) && (typeof value.id === "string" || typeof value.name === "string");
|
|
13
|
+
}
|
|
14
|
+
const LABEL_COLORS = {
|
|
15
|
+
green: "#61bd4f",
|
|
16
|
+
yellow: "#f2d600",
|
|
17
|
+
orange: "#ff9f1a",
|
|
18
|
+
red: "#eb5a46",
|
|
19
|
+
purple: "#c377e0",
|
|
20
|
+
blue: "#0079bf",
|
|
21
|
+
sky: "#00c2e0",
|
|
22
|
+
lime: "#51e898",
|
|
23
|
+
pink: "#ff78cb",
|
|
24
|
+
black: "#344563"
|
|
25
|
+
};
|
|
26
|
+
function byPosition(a, b) {
|
|
27
|
+
const pa = Number(a.pos ?? Number.MAX_SAFE_INTEGER);
|
|
28
|
+
const pb = Number(b.pos ?? Number.MAX_SAFE_INTEGER);
|
|
29
|
+
if (pa !== pb)
|
|
30
|
+
return pa - pb;
|
|
31
|
+
return text(a.id).localeCompare(text(b.id));
|
|
32
|
+
}
|
|
33
|
+
function dateFromObjectId(value) {
|
|
34
|
+
const id = text(value);
|
|
35
|
+
if (!/^[0-9a-fA-F]{8}/.test(id))
|
|
36
|
+
return null;
|
|
37
|
+
const seconds = Number.parseInt(id.slice(0, 8), 16);
|
|
38
|
+
return Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : null;
|
|
39
|
+
}
|
|
40
|
+
function scopedId(ids, prefix, value) {
|
|
41
|
+
return `trello-${prefix}-${(text(value) || ids.next()).replace(/[^a-zA-Z0-9_-]/g, "-")}`;
|
|
42
|
+
}
|
|
43
|
+
function inferRole(name) {
|
|
44
|
+
const normalized = text(name).toLowerCase();
|
|
45
|
+
if (!normalized)
|
|
46
|
+
return null;
|
|
47
|
+
if (/^ai ready$|ai[- ]ready/.test(normalized))
|
|
48
|
+
return "ai_ready";
|
|
49
|
+
if (/review|qa|verify|validation/.test(normalized))
|
|
50
|
+
return "review";
|
|
51
|
+
if (/doing|in progress|active|working|wip/.test(normalized))
|
|
52
|
+
return "in_progress";
|
|
53
|
+
if (/done|complete|completed|shipped|closed/.test(normalized))
|
|
54
|
+
return "done";
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function labelName(label, names) {
|
|
58
|
+
const explicit = text(label.name);
|
|
59
|
+
if (explicit)
|
|
60
|
+
return explicit;
|
|
61
|
+
const color = text(label.color);
|
|
62
|
+
const named = color && isRaw(names) ? text(names[color]) : "";
|
|
63
|
+
if (named)
|
|
64
|
+
return named;
|
|
65
|
+
return color ? `Trello ${color}` : "";
|
|
66
|
+
}
|
|
67
|
+
function priorityOf(card) {
|
|
68
|
+
const names = rawList(card.labels).map((label) => text(label.name || label.color).toLowerCase()).join(" ");
|
|
69
|
+
if (/urgent|critical|blocker|high|red/.test(names))
|
|
70
|
+
return 4;
|
|
71
|
+
if (/low|someday|backlog/.test(names))
|
|
72
|
+
return 1;
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
function checklistsText(checklists) {
|
|
76
|
+
return checklists
|
|
77
|
+
.map((checklist) => {
|
|
78
|
+
const items = rawList(checklist.checkItems).sort(byPosition);
|
|
79
|
+
if (!items.length)
|
|
80
|
+
return "";
|
|
81
|
+
return [`Checklist: ${text(checklist.name) || "Untitled"}`, ...items.map((item) => `- [${item.state === "complete" ? "x" : " "}] ${text(item.name) || "Untitled item"}`)].join("\n");
|
|
82
|
+
})
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.join("\n\n");
|
|
85
|
+
}
|
|
86
|
+
function attachmentsText(attachments) {
|
|
87
|
+
const lines = attachments.map((attachment) => {
|
|
88
|
+
const name = text(attachment.name) || text(attachment.fileName) || "Attachment";
|
|
89
|
+
const url = text(attachment.url);
|
|
90
|
+
return url ? `- ${name}: ${url}` : `- ${name}`;
|
|
91
|
+
});
|
|
92
|
+
return lines.length ? ["Trello attachments:", ...lines].join("\n") : "";
|
|
93
|
+
}
|
|
94
|
+
function description(card, listName, checklists) {
|
|
95
|
+
const parts = [];
|
|
96
|
+
const desc = text(card.desc);
|
|
97
|
+
if (desc)
|
|
98
|
+
parts.push(desc);
|
|
99
|
+
const meta = [];
|
|
100
|
+
if (card.idShort)
|
|
101
|
+
meta.push(`Trello card #${text(card.idShort)}`);
|
|
102
|
+
if (listName)
|
|
103
|
+
meta.push(`List: ${listName}`);
|
|
104
|
+
if (card.shortUrl || card.url)
|
|
105
|
+
meta.push(`URL: ${text(card.shortUrl || card.url)}`);
|
|
106
|
+
if (card.due)
|
|
107
|
+
meta.push(`Due: ${text(card.due)}${card.dueComplete ? " (complete)" : ""}`);
|
|
108
|
+
if (card.closed)
|
|
109
|
+
meta.push("Archived in Trello: yes");
|
|
110
|
+
if (meta.length)
|
|
111
|
+
parts.push(["Imported from Trello:", ...meta.map((line) => `- ${line}`)].join("\n"));
|
|
112
|
+
const checks = checklistsText(checklists);
|
|
113
|
+
if (checks)
|
|
114
|
+
parts.push(checks);
|
|
115
|
+
const files = attachmentsText(rawList(card.attachments));
|
|
116
|
+
if (files)
|
|
117
|
+
parts.push(files);
|
|
118
|
+
return parts.join("\n\n");
|
|
119
|
+
}
|
|
120
|
+
/** A Trello export as an Orbit export, for the board `target` names (or one named from Trello's). */
|
|
121
|
+
export function trelloToSnapshot(trello, target, clock, ids) {
|
|
122
|
+
const time = clock.now();
|
|
123
|
+
const boardName = text(trello.name) || "Imported Trello Board";
|
|
124
|
+
const boardSlug = target.slug || slugify(boardName) || `trello-${ids.next().slice(0, 8)}`;
|
|
125
|
+
const labels = new Map();
|
|
126
|
+
const addLabel = (raw) => {
|
|
127
|
+
const key = text(raw.id) || `${text(raw.color) || "label"}-${text(raw.name)}`;
|
|
128
|
+
const name = labelName(raw, trello.labelNames);
|
|
129
|
+
if (!name || labels.has(key))
|
|
130
|
+
return;
|
|
131
|
+
labels.set(key, { id: scopedId(ids, "label", key), name, color: LABEL_COLORS[text(raw.color).toLowerCase()] ?? "#64748b", created_at: dateFromObjectId(raw.id) ?? time });
|
|
132
|
+
};
|
|
133
|
+
for (const label of rawList(trello.labels))
|
|
134
|
+
addLabel(label);
|
|
135
|
+
for (const card of rawList(trello.cards))
|
|
136
|
+
for (const label of rawList(card.labels))
|
|
137
|
+
addLabel(label);
|
|
138
|
+
const checklists = new Map();
|
|
139
|
+
const addChecklist = (cardId, checklist) => {
|
|
140
|
+
if (!cardId)
|
|
141
|
+
return;
|
|
142
|
+
checklists.set(cardId, [...(checklists.get(cardId) ?? []), checklist]);
|
|
143
|
+
};
|
|
144
|
+
for (const checklist of rawList(trello.checklists))
|
|
145
|
+
addChecklist(text(checklist.idCard), checklist);
|
|
146
|
+
for (const card of rawList(trello.cards))
|
|
147
|
+
for (const checklist of rawList(card.checklists))
|
|
148
|
+
addChecklist(text(card.id), checklist);
|
|
149
|
+
const commentsByCard = new Map();
|
|
150
|
+
for (const action of rawList(trello.actions)) {
|
|
151
|
+
if (action.type !== "commentCard")
|
|
152
|
+
continue;
|
|
153
|
+
const data = isRaw(action.data) ? action.data : {};
|
|
154
|
+
const cardId = isRaw(data.card) ? text(data.card.id) : "";
|
|
155
|
+
if (!cardId)
|
|
156
|
+
continue;
|
|
157
|
+
commentsByCard.set(cardId, [...(commentsByCard.get(cardId) ?? []), action]);
|
|
158
|
+
}
|
|
159
|
+
for (const actions of commentsByCard.values())
|
|
160
|
+
actions.sort((a, b) => text(a.date).localeCompare(text(b.date)));
|
|
161
|
+
const lists = rawList(trello.lists).sort(byPosition);
|
|
162
|
+
const rolesSeen = new Set();
|
|
163
|
+
const firstOpen = lists.findIndex((list) => !list.closed);
|
|
164
|
+
const defaultIndex = firstOpen >= 0 ? firstOpen : 0;
|
|
165
|
+
const states = lists.map((list, index) => {
|
|
166
|
+
let role = inferRole(list.name);
|
|
167
|
+
if (role && rolesSeen.has(role))
|
|
168
|
+
role = null;
|
|
169
|
+
if (role)
|
|
170
|
+
rolesSeen.add(role);
|
|
171
|
+
return {
|
|
172
|
+
id: scopedId(ids, "list", list.id || `${index + 1}`),
|
|
173
|
+
name: text(list.name) || `Trello List ${index + 1}`,
|
|
174
|
+
position: index,
|
|
175
|
+
is_default: index === defaultIndex ? 1 : 0,
|
|
176
|
+
role,
|
|
177
|
+
created_at: dateFromObjectId(list.id) ?? time
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
const fallbackState = states[0]?.id;
|
|
181
|
+
const stateOfList = new Map(lists.map((list, index) => [text(list.id), states[index]?.id ?? fallbackState]));
|
|
182
|
+
const tickets = [];
|
|
183
|
+
const comments = [];
|
|
184
|
+
rawList(trello.cards)
|
|
185
|
+
.sort(byPosition)
|
|
186
|
+
.forEach((card, index) => {
|
|
187
|
+
const cardId = text(card.id);
|
|
188
|
+
const ticketId = scopedId(ids, "card", cardId || `${index + 1}`);
|
|
189
|
+
const stateId = stateOfList.get(text(card.idList)) ?? fallbackState;
|
|
190
|
+
if (!stateId)
|
|
191
|
+
return;
|
|
192
|
+
const updatedAt = text(card.dateLastActivity) || time;
|
|
193
|
+
const cardLabels = [];
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
const addById = (rawId) => {
|
|
196
|
+
const label = labels.get(rawId);
|
|
197
|
+
if (label && !seen.has(label.id)) {
|
|
198
|
+
seen.add(label.id);
|
|
199
|
+
cardLabels.push(label);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
for (const label of rawList(card.labels))
|
|
203
|
+
addById(text(label.id) || `${text(label.color) || "label"}-${text(label.name)}`);
|
|
204
|
+
for (const labelId of Array.isArray(card.idLabels) ? card.idLabels : [])
|
|
205
|
+
addById(text(labelId));
|
|
206
|
+
const listName = text(lists.find((list) => text(list.id) === text(card.idList))?.name);
|
|
207
|
+
tickets.push({
|
|
208
|
+
id: ticketId,
|
|
209
|
+
number: index + 1,
|
|
210
|
+
title: text(card.name) || `Untitled Trello card ${index + 1}`,
|
|
211
|
+
description: description(card, listName, checklists.get(cardId) ?? []),
|
|
212
|
+
type: "task",
|
|
213
|
+
parent_ticket_id: null,
|
|
214
|
+
ai_plan: "",
|
|
215
|
+
implementation_summary: "",
|
|
216
|
+
implementation_updates: "",
|
|
217
|
+
nebula_url: "",
|
|
218
|
+
state_id: stateId,
|
|
219
|
+
priority: priorityOf(card),
|
|
220
|
+
created_by: "trello-import",
|
|
221
|
+
created_at: dateFromObjectId(card.id) ?? updatedAt,
|
|
222
|
+
updated_at: updatedAt,
|
|
223
|
+
archived_at: card.closed ? updatedAt : null,
|
|
224
|
+
labels: cardLabels
|
|
225
|
+
});
|
|
226
|
+
(commentsByCard.get(cardId) ?? []).forEach((action, i) => {
|
|
227
|
+
const member = isRaw(action.memberCreator) ? action.memberCreator : {};
|
|
228
|
+
const data = isRaw(action.data) ? action.data : {};
|
|
229
|
+
comments.push({
|
|
230
|
+
id: scopedId(ids, "comment", action.id || `${cardId}-${i + 1}`),
|
|
231
|
+
ticket_id: ticketId,
|
|
232
|
+
author: text(member.fullName) || text(member.username) || "Trello",
|
|
233
|
+
kind: "human_comment",
|
|
234
|
+
body: text(data.text),
|
|
235
|
+
omitted_for_ai: 0,
|
|
236
|
+
created_at: text(action.date) || updatedAt
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
return {
|
|
241
|
+
format: "orbit-board-export",
|
|
242
|
+
source_format: "trello-board-export",
|
|
243
|
+
version: 9,
|
|
244
|
+
exported_at: time,
|
|
245
|
+
include_attachments: false,
|
|
246
|
+
imported_counts: { states: states.length, tickets: tickets.length, comments: comments.length, labels: labels.size },
|
|
247
|
+
board: {
|
|
248
|
+
id: target.id ?? scopedId(ids, "board", trello.id || ids.next()),
|
|
249
|
+
slug: boardSlug,
|
|
250
|
+
name: boardName,
|
|
251
|
+
repo_url: target.repo_url ?? "",
|
|
252
|
+
default_branch: target.default_branch ?? "main",
|
|
253
|
+
project_notes: text(trello.desc) ? `Imported from Trello.\n\n${text(trello.desc)}` : "Imported from Trello.",
|
|
254
|
+
agent_instructions: "",
|
|
255
|
+
ai_enabled: 1,
|
|
256
|
+
created_at: text(trello.dateLastActivity) || time,
|
|
257
|
+
updated_at: time
|
|
258
|
+
},
|
|
259
|
+
states,
|
|
260
|
+
labels: [...labels.values()],
|
|
261
|
+
tickets,
|
|
262
|
+
comments: comments.filter((comment) => comment.body),
|
|
263
|
+
review_verdicts: [],
|
|
264
|
+
relations: [],
|
|
265
|
+
attachments: [],
|
|
266
|
+
board_entries: [],
|
|
267
|
+
events: []
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review verdicts: a reviewer's PASS, BLOCK or QUESTION on a card, with its
|
|
3
|
+
* findings and the commands it ran as structured lists, so tools read them
|
|
4
|
+
* instead of parsing comments.
|
|
5
|
+
*/
|
|
6
|
+
import type { BoardCtx } from "./context.ts";
|
|
7
|
+
import type { Id, ReviewVerdict } from "./model.ts";
|
|
8
|
+
declare const LISTS: readonly ["blocking_findings", "optional_findings", "evidence_commands"];
|
|
9
|
+
/** A verdict as answers show it: its lists parsed, under their names without `_json`. */
|
|
10
|
+
export type VerdictView = Omit<ReviewVerdict, "blocking_findings_json" | "optional_findings_json" | "evidence_commands_json"> & Record<(typeof LISTS)[number], unknown[]>;
|
|
11
|
+
export interface NewVerdictInput {
|
|
12
|
+
verdict?: unknown;
|
|
13
|
+
blocking_findings?: unknown;
|
|
14
|
+
optional_findings?: unknown;
|
|
15
|
+
evidence_commands?: unknown;
|
|
16
|
+
reviewer_profile?: unknown;
|
|
17
|
+
reviewer_session_id?: unknown;
|
|
18
|
+
reviewed_commit_sha?: unknown;
|
|
19
|
+
dispatch_run_id?: unknown;
|
|
20
|
+
supersedes_prior_review_id?: unknown;
|
|
21
|
+
}
|
|
22
|
+
export declare function createVerdict(c: BoardCtx, ticketId: Id, input: NewVerdictInput): Promise<VerdictView>;
|
|
23
|
+
export declare function getVerdict(c: BoardCtx, reviewId: Id): Promise<VerdictView>;
|
|
24
|
+
/** A card's verdicts, newest first; 50 unless `limit` says (at most 200). */
|
|
25
|
+
export declare function listVerdicts(c: BoardCtx, ticketId: Id, options?: {
|
|
26
|
+
limit?: unknown;
|
|
27
|
+
}): Promise<VerdictView[]>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review verdicts: a reviewer's PASS, BLOCK or QUESTION on a card, with its
|
|
3
|
+
* findings and the commands it ran as structured lists, so tools read them
|
|
4
|
+
* instead of parsing comments.
|
|
5
|
+
*/
|
|
6
|
+
import { OrbitError } from "./errors.js";
|
|
7
|
+
import { record, touchTicket } from "./history.js";
|
|
8
|
+
import { optionalString, requiredString } from "./text.js";
|
|
9
|
+
import { ticketOn } from "./tickets.js";
|
|
10
|
+
const VERDICTS = ["PASS", "BLOCK", "QUESTION"];
|
|
11
|
+
const LISTS = ["blocking_findings", "optional_findings", "evidence_commands"];
|
|
12
|
+
function viewOf(row) {
|
|
13
|
+
const { blocking_findings_json, optional_findings_json, evidence_commands_json, ...rest } = row;
|
|
14
|
+
const parse = (json) => JSON.parse(json || "[]");
|
|
15
|
+
return {
|
|
16
|
+
...rest,
|
|
17
|
+
blocking_findings: parse(blocking_findings_json),
|
|
18
|
+
optional_findings: parse(optional_findings_json),
|
|
19
|
+
evidence_commands: parse(evidence_commands_json),
|
|
20
|
+
supersedes_prior_review_id: rest.supersedes_prior_review_id || null
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function list(value, field) {
|
|
24
|
+
if (value === undefined || value === null)
|
|
25
|
+
return [];
|
|
26
|
+
if (!Array.isArray(value))
|
|
27
|
+
throw new OrbitError(`invalid_${field}`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
export async function createVerdict(c, ticketId, input) {
|
|
31
|
+
const made = await c.data.write(async (w) => {
|
|
32
|
+
const ticket = await ticketOn(w, ticketId);
|
|
33
|
+
const verdict = requiredString(input.verdict, "verdict").toUpperCase();
|
|
34
|
+
if (!VERDICTS.includes(verdict))
|
|
35
|
+
throw new OrbitError("invalid_review_verdict");
|
|
36
|
+
const [blocking, optional, evidence] = LISTS.map((field) => list(input[field], field));
|
|
37
|
+
const supersedes = optionalString(input.supersedes_prior_review_id) || null;
|
|
38
|
+
if (supersedes) {
|
|
39
|
+
const prior = await w.verdicts.get(supersedes);
|
|
40
|
+
if (!prior)
|
|
41
|
+
throw new OrbitError("invalid_supersedes_prior_review_id");
|
|
42
|
+
if (prior.ticket_id !== ticket.id)
|
|
43
|
+
throw new OrbitError("superseded_review_ticket_mismatch");
|
|
44
|
+
}
|
|
45
|
+
const at = c.clock.now();
|
|
46
|
+
const row = {
|
|
47
|
+
id: c.ids.next(),
|
|
48
|
+
ticket_id: ticket.id,
|
|
49
|
+
verdict: verdict,
|
|
50
|
+
blocking_findings_json: JSON.stringify(blocking),
|
|
51
|
+
optional_findings_json: JSON.stringify(optional),
|
|
52
|
+
evidence_commands_json: JSON.stringify(evidence),
|
|
53
|
+
reviewer_profile: optionalString(input.reviewer_profile),
|
|
54
|
+
reviewer_session_id: optionalString(input.reviewer_session_id),
|
|
55
|
+
reviewed_commit_sha: optionalString(input.reviewed_commit_sha),
|
|
56
|
+
dispatch_run_id: optionalString(input.dispatch_run_id),
|
|
57
|
+
supersedes_prior_review_id: supersedes,
|
|
58
|
+
created_by: c.actor.name,
|
|
59
|
+
created_at: at
|
|
60
|
+
};
|
|
61
|
+
await w.verdicts.insert(row);
|
|
62
|
+
await w.tickets.update(ticket.id, { updated_at: at });
|
|
63
|
+
await record(w, c, "review_verdict_created", ticket.id, {
|
|
64
|
+
review_id: row.id,
|
|
65
|
+
verdict,
|
|
66
|
+
supersedes_prior_review_id: supersedes,
|
|
67
|
+
reviewed_commit_sha: row.reviewed_commit_sha,
|
|
68
|
+
dispatch_run_id: row.dispatch_run_id,
|
|
69
|
+
actor_type: c.actor.type,
|
|
70
|
+
actor_id: c.actor.id
|
|
71
|
+
});
|
|
72
|
+
await touchTicket(w, ticket.parent_ticket_id, at);
|
|
73
|
+
return row;
|
|
74
|
+
});
|
|
75
|
+
c.changed(c.board.id);
|
|
76
|
+
return viewOf(made);
|
|
77
|
+
}
|
|
78
|
+
export async function getVerdict(c, reviewId) {
|
|
79
|
+
const row = await c.data.read((r) => r.verdicts.get(reviewId));
|
|
80
|
+
if (!row)
|
|
81
|
+
throw new OrbitError("review_verdict_not_found");
|
|
82
|
+
return viewOf(row);
|
|
83
|
+
}
|
|
84
|
+
/** A card's verdicts, newest first; 50 unless `limit` says (at most 200). */
|
|
85
|
+
export async function listVerdicts(c, ticketId, options = {}) {
|
|
86
|
+
const n = Number(options.limit ?? 50);
|
|
87
|
+
const limit = !Number.isInteger(n) || n <= 0 ? 50 : Math.min(n, 200);
|
|
88
|
+
return c.data.read(async (r) => {
|
|
89
|
+
await ticketOn(r, ticketId);
|
|
90
|
+
return (await r.verdicts.list(ticketId)).reverse().slice(0, limit).map(viewOf);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes answers carry, built from records: a card with its lane's name
|
|
3
|
+
* and its board (`TicketView`), a short card, a card's blockers and who holds
|
|
4
|
+
* it. Each helper reads what it needs as sets, never one query per card.
|
|
5
|
+
*/
|
|
6
|
+
import type { Board, Id, Label, LaneRole, OrbitEvent, RegisteredBoard, Relation, State, Ticket } from "./model.ts";
|
|
7
|
+
import type { BoardReader } from "../store/types.ts";
|
|
8
|
+
/** A card as answers show it: its record, its lane's name and role, and its board. */
|
|
9
|
+
export interface TicketView extends Ticket {
|
|
10
|
+
state_name: string;
|
|
11
|
+
state_role: LaneRole | null;
|
|
12
|
+
board_slug: string;
|
|
13
|
+
board_name: string;
|
|
14
|
+
}
|
|
15
|
+
/** A card with its lane, as blockers list them. */
|
|
16
|
+
export interface LanedTicket extends Ticket {
|
|
17
|
+
state_name: string;
|
|
18
|
+
state_role: LaneRole | null;
|
|
19
|
+
/** Set when the card blocks because it is an open sub-card of a blocking epic. */
|
|
20
|
+
via_epic_id?: Id;
|
|
21
|
+
}
|
|
22
|
+
/** A card in a few words, for lists inside answers. */
|
|
23
|
+
export interface CompactTicket {
|
|
24
|
+
id: Id;
|
|
25
|
+
board_id: Id;
|
|
26
|
+
board_slug: string | undefined;
|
|
27
|
+
number: number;
|
|
28
|
+
title: string;
|
|
29
|
+
type: Ticket["type"];
|
|
30
|
+
state_name: string;
|
|
31
|
+
state_role: LaneRole | null;
|
|
32
|
+
priority: Ticket["priority"];
|
|
33
|
+
}
|
|
34
|
+
/** The board as answers name it: its slug and name. */
|
|
35
|
+
export type BoardName = Pick<RegisteredBoard | Board, "slug" | "name">;
|
|
36
|
+
/** A board's lanes, by id. */
|
|
37
|
+
export declare class Lanes {
|
|
38
|
+
readonly list: State[];
|
|
39
|
+
private readonly byId;
|
|
40
|
+
constructor(list: State[]);
|
|
41
|
+
static of(r: BoardReader): Promise<Lanes>;
|
|
42
|
+
get(id: Id): State | undefined;
|
|
43
|
+
/** The first lane, by position, with this role. */
|
|
44
|
+
byRole(role: LaneRole): State | undefined;
|
|
45
|
+
byName(name: string): State | undefined;
|
|
46
|
+
/** The lane holding a role, or by its usual name on a board that never set one. */
|
|
47
|
+
forRole(role: LaneRole, name: string): State | undefined;
|
|
48
|
+
nameOf(ticket: Ticket): string;
|
|
49
|
+
roleOf(ticket: Ticket): LaneRole | null;
|
|
50
|
+
}
|
|
51
|
+
export declare function ticketView(ticket: Ticket, lanes: Lanes, board: BoardName): TicketView;
|
|
52
|
+
export declare function laned(ticket: Ticket, lanes: Lanes): LanedTicket;
|
|
53
|
+
export declare function compactTicket(ticket: LanedTicket & {
|
|
54
|
+
board_slug?: string;
|
|
55
|
+
}): CompactTicket;
|
|
56
|
+
/** Each card's labels, by name, for these cards (all when absent). */
|
|
57
|
+
export declare function labelsByTicket(r: BoardReader, ticketIds?: readonly Id[]): Promise<Map<Id, Label[]>>;
|
|
58
|
+
export declare function byCodePoint(a: string, b: string): number;
|
|
59
|
+
/** The ids of the cards blocking `ticketId` directly: "this blocked_by X" on the card, or "X blocks this" on the blocker. */
|
|
60
|
+
export declare function blockerIds(relations: readonly Relation[], ticketId: Id): Id[];
|
|
61
|
+
/**
|
|
62
|
+
* What blocks a card: its open blockers by number, where an epic stands for
|
|
63
|
+
* its open sub-cards not in a done lane (each with `via_epic_id`). The
|
|
64
|
+
* relation is the record: finishing a blocker deletes it.
|
|
65
|
+
*/
|
|
66
|
+
export declare function unresolvedBlockers(r: BoardReader, ticketId: Id, lanes: Lanes): Promise<LanedTicket[]>;
|
|
67
|
+
/**
|
|
68
|
+
* Blockers for many cards at once, from the board's relations and open
|
|
69
|
+
* cards already read: what `view` shows for every open card.
|
|
70
|
+
*/
|
|
71
|
+
export declare function blockersFromSets(ticketId: Id, relations: readonly Relation[], open: ReadonlyMap<Id, Ticket>, childrenOf: ReadonlyMap<Id, Ticket[]>, lanes: Lanes): Ticket[];
|
|
72
|
+
/** The events that start or end an agent's hold on a card. */
|
|
73
|
+
export declare const WORK_EVENTS: readonly ["agent_claimed", "state_changed", "agent_completed", "checkpoint_requested"];
|
|
74
|
+
export interface Holder {
|
|
75
|
+
name: string;
|
|
76
|
+
id: string | null;
|
|
77
|
+
since: string;
|
|
78
|
+
}
|
|
79
|
+
/** Who holds a card: the agent whose claim is the card's latest work event, from that event. */
|
|
80
|
+
export declare function holderFrom(event: OrbitEvent | undefined): Holder | null;
|
|
81
|
+
export declare function currentHolder(r: BoardReader, ticketId: Id): Promise<Holder | null>;
|
|
82
|
+
/** Whether a holder is this actor: by id when the claim recorded one, else by name. */
|
|
83
|
+
export declare function isHolder(holder: Holder | null, actor: {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
}): boolean;
|
|
87
|
+
/** A lane that closes work: a done lane, or one named Cancelled or Won't. */
|
|
88
|
+
export declare function isClosedLane(state: Pick<State, "name" | "role">): boolean;
|
|
89
|
+
/** The cards on either side of a card's relations, both open, oldest relation first. */
|
|
90
|
+
export declare function relationRows(r: BoardReader, ticketId: Id, lanes: Lanes, board: BoardName): Promise<{
|
|
91
|
+
id: Id;
|
|
92
|
+
type: string;
|
|
93
|
+
direction: "outgoing" | "incoming";
|
|
94
|
+
other_ticket: TicketView;
|
|
95
|
+
}[]>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes answers carry, built from records: a card with its lane's name
|
|
3
|
+
* and its board (`TicketView`), a short card, a card's blockers and who holds
|
|
4
|
+
* it. Each helper reads what it needs as sets, never one query per card.
|
|
5
|
+
*/
|
|
6
|
+
/** A board's lanes, by id. */
|
|
7
|
+
export class Lanes {
|
|
8
|
+
list;
|
|
9
|
+
byId;
|
|
10
|
+
constructor(list) {
|
|
11
|
+
this.list = list;
|
|
12
|
+
this.byId = new Map(list.map((state) => [state.id, state]));
|
|
13
|
+
}
|
|
14
|
+
static async of(r) {
|
|
15
|
+
return new Lanes(await r.states.list());
|
|
16
|
+
}
|
|
17
|
+
get(id) {
|
|
18
|
+
return this.byId.get(id);
|
|
19
|
+
}
|
|
20
|
+
/** The first lane, by position, with this role. */
|
|
21
|
+
byRole(role) {
|
|
22
|
+
return this.list.find((state) => state.role === role);
|
|
23
|
+
}
|
|
24
|
+
byName(name) {
|
|
25
|
+
return this.list.find((state) => state.name === name);
|
|
26
|
+
}
|
|
27
|
+
/** The lane holding a role, or by its usual name on a board that never set one. */
|
|
28
|
+
forRole(role, name) {
|
|
29
|
+
return this.byRole(role) ?? this.byName(name);
|
|
30
|
+
}
|
|
31
|
+
nameOf(ticket) {
|
|
32
|
+
return this.byId.get(ticket.state_id)?.name ?? "";
|
|
33
|
+
}
|
|
34
|
+
roleOf(ticket) {
|
|
35
|
+
return this.byId.get(ticket.state_id)?.role ?? null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function ticketView(ticket, lanes, board) {
|
|
39
|
+
return { ...ticket, state_name: lanes.nameOf(ticket), state_role: lanes.roleOf(ticket), board_slug: board.slug, board_name: board.name };
|
|
40
|
+
}
|
|
41
|
+
export function laned(ticket, lanes) {
|
|
42
|
+
return { ...ticket, state_name: lanes.nameOf(ticket), state_role: lanes.roleOf(ticket) };
|
|
43
|
+
}
|
|
44
|
+
export function compactTicket(ticket) {
|
|
45
|
+
return {
|
|
46
|
+
id: ticket.id,
|
|
47
|
+
board_id: ticket.board_id,
|
|
48
|
+
board_slug: ticket.board_slug,
|
|
49
|
+
number: ticket.number,
|
|
50
|
+
title: ticket.title,
|
|
51
|
+
type: ticket.type,
|
|
52
|
+
state_name: ticket.state_name,
|
|
53
|
+
state_role: ticket.state_role,
|
|
54
|
+
priority: ticket.priority
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Each card's labels, by name, for these cards (all when absent). */
|
|
58
|
+
export async function labelsByTicket(r, ticketIds) {
|
|
59
|
+
const [labels, links] = await Promise.all([r.labels.list(), r.ticketLabels.list(ticketIds)]);
|
|
60
|
+
const byId = new Map(labels.map((label) => [label.id, label]));
|
|
61
|
+
const out = new Map();
|
|
62
|
+
for (const link of links) {
|
|
63
|
+
const label = byId.get(link.label_id);
|
|
64
|
+
if (!label)
|
|
65
|
+
continue;
|
|
66
|
+
const list = out.get(link.ticket_id) ?? [];
|
|
67
|
+
list.push(label);
|
|
68
|
+
out.set(link.ticket_id, list);
|
|
69
|
+
}
|
|
70
|
+
for (const list of out.values())
|
|
71
|
+
list.sort((a, b) => byCodePoint(a.name, b.name));
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
export function byCodePoint(a, b) {
|
|
75
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
76
|
+
}
|
|
77
|
+
/** The ids of the cards blocking `ticketId` directly: "this blocked_by X" on the card, or "X blocks this" on the blocker. */
|
|
78
|
+
export function blockerIds(relations, ticketId) {
|
|
79
|
+
const ids = [];
|
|
80
|
+
for (const relation of relations) {
|
|
81
|
+
if (relation.source_ticket_id === ticketId && relation.type === "blocked_by")
|
|
82
|
+
ids.push(relation.target_ticket_id);
|
|
83
|
+
else if (relation.target_ticket_id === ticketId && relation.type === "blocks")
|
|
84
|
+
ids.push(relation.source_ticket_id);
|
|
85
|
+
}
|
|
86
|
+
return ids;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* What blocks a card: its open blockers by number, where an epic stands for
|
|
90
|
+
* its open sub-cards not in a done lane (each with `via_epic_id`). The
|
|
91
|
+
* relation is the record: finishing a blocker deletes it.
|
|
92
|
+
*/
|
|
93
|
+
export async function unresolvedBlockers(r, ticketId, lanes) {
|
|
94
|
+
const ids = blockerIds(await r.relations.list(ticketId), ticketId);
|
|
95
|
+
if (!ids.length)
|
|
96
|
+
return [];
|
|
97
|
+
const direct = (await r.tickets.list({ ids, archived: false })).sort((a, b) => a.number - b.number);
|
|
98
|
+
const seen = new Set();
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const blocker of direct) {
|
|
101
|
+
if (blocker.type === "epic") {
|
|
102
|
+
for (const child of await r.tickets.list({ parent: blocker.id, archived: false })) {
|
|
103
|
+
if (lanes.roleOf(child) === "done" || seen.has(child.id))
|
|
104
|
+
continue;
|
|
105
|
+
seen.add(child.id);
|
|
106
|
+
out.push({ ...laned(child, lanes), via_epic_id: blocker.id });
|
|
107
|
+
}
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (seen.has(blocker.id))
|
|
111
|
+
continue;
|
|
112
|
+
seen.add(blocker.id);
|
|
113
|
+
out.push(laned(blocker, lanes));
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Blockers for many cards at once, from the board's relations and open
|
|
119
|
+
* cards already read: what `view` shows for every open card.
|
|
120
|
+
*/
|
|
121
|
+
export function blockersFromSets(ticketId, relations, open, childrenOf, lanes) {
|
|
122
|
+
const direct = blockerIds(relations, ticketId)
|
|
123
|
+
.map((id) => open.get(id))
|
|
124
|
+
.filter((ticket) => ticket !== undefined)
|
|
125
|
+
.sort((a, b) => a.number - b.number);
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const blocker of direct) {
|
|
129
|
+
const list = blocker.type === "epic" ? (childrenOf.get(blocker.id) ?? []).filter((child) => lanes.roleOf(child) !== "done") : [blocker];
|
|
130
|
+
for (const ticket of list) {
|
|
131
|
+
if (seen.has(ticket.id))
|
|
132
|
+
continue;
|
|
133
|
+
seen.add(ticket.id);
|
|
134
|
+
out.push(ticket);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** The events that start or end an agent's hold on a card. */
|
|
140
|
+
export const WORK_EVENTS = ["agent_claimed", "state_changed", "agent_completed", "checkpoint_requested"];
|
|
141
|
+
/** Who holds a card: the agent whose claim is the card's latest work event, from that event. */
|
|
142
|
+
export function holderFrom(event) {
|
|
143
|
+
if (event?.type !== "agent_claimed")
|
|
144
|
+
return null;
|
|
145
|
+
let id = null;
|
|
146
|
+
try {
|
|
147
|
+
const body = JSON.parse(event.body_json || "{}");
|
|
148
|
+
id = typeof body.agent_id === "string" && body.agent_id ? body.agent_id : null;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
id = null;
|
|
152
|
+
}
|
|
153
|
+
return { name: event.actor, id, since: event.created_at };
|
|
154
|
+
}
|
|
155
|
+
export async function currentHolder(r, ticketId) {
|
|
156
|
+
const [event] = await r.events.latest([ticketId], WORK_EVENTS);
|
|
157
|
+
return holderFrom(event);
|
|
158
|
+
}
|
|
159
|
+
/** Whether a holder is this actor: by id when the claim recorded one, else by name. */
|
|
160
|
+
export function isHolder(holder, actor) {
|
|
161
|
+
if (!holder)
|
|
162
|
+
return false;
|
|
163
|
+
return holder.id ? holder.id === actor.id : holder.name === actor.name;
|
|
164
|
+
}
|
|
165
|
+
/** A lane that closes work: a done lane, or one named Cancelled or Won't. */
|
|
166
|
+
export function isClosedLane(state) {
|
|
167
|
+
return state.role === "done" || /^(cancel|won['\u2019]?t)/i.test(state.name);
|
|
168
|
+
}
|
|
169
|
+
/** The cards on either side of a card's relations, both open, oldest relation first. */
|
|
170
|
+
export async function relationRows(r, ticketId, lanes, board) {
|
|
171
|
+
const relations = await r.relations.list(ticketId);
|
|
172
|
+
if (!relations.length)
|
|
173
|
+
return [];
|
|
174
|
+
const ids = [...new Set(relations.flatMap((relation) => [relation.source_ticket_id, relation.target_ticket_id]))];
|
|
175
|
+
const open = new Map((await r.tickets.list({ ids, archived: false })).map((ticket) => [ticket.id, ticket]));
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const relation of relations) {
|
|
178
|
+
const source = open.get(relation.source_ticket_id);
|
|
179
|
+
const target = open.get(relation.target_ticket_id);
|
|
180
|
+
if (!source || !target)
|
|
181
|
+
continue;
|
|
182
|
+
const outgoing = relation.source_ticket_id === ticketId;
|
|
183
|
+
out.push({ id: relation.id, type: relation.type, direction: outgoing ? "outgoing" : "incoming", other_ticket: ticketView(outgoing ? target : source, lanes, board) });
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|