@nickmeriano/task 0.10.0 → 0.12.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.
Files changed (101) hide show
  1. package/README.md +232 -1
  2. package/dist/asks.test.js +6 -0
  3. package/dist/asks.test.js.map +1 -1
  4. package/dist/board/github.d.ts +68 -0
  5. package/dist/board/github.d.ts.map +1 -0
  6. package/dist/board/github.js +112 -0
  7. package/dist/board/github.js.map +1 -0
  8. package/dist/board/handler.d.ts +50 -0
  9. package/dist/board/handler.d.ts.map +1 -0
  10. package/dist/board/handler.js +183 -0
  11. package/dist/board/handler.js.map +1 -0
  12. package/dist/board/handler.test.d.ts +11 -0
  13. package/dist/board/handler.test.d.ts.map +1 -0
  14. package/dist/board/handler.test.js +229 -0
  15. package/dist/board/handler.test.js.map +1 -0
  16. package/dist/board/pages-function.d.ts +23 -0
  17. package/dist/board/pages-function.d.ts.map +1 -0
  18. package/dist/board/pages-function.js +34 -0
  19. package/dist/board/pages-function.js.map +1 -0
  20. package/dist/board/source.d.ts +118 -0
  21. package/dist/board/source.d.ts.map +1 -0
  22. package/dist/board/source.js +333 -0
  23. package/dist/board/source.js.map +1 -0
  24. package/dist/board/source.test.d.ts +12 -0
  25. package/dist/board/source.test.d.ts.map +1 -0
  26. package/dist/board/source.test.js +165 -0
  27. package/dist/board/source.test.js.map +1 -0
  28. package/dist/board/tar.d.ts +28 -0
  29. package/dist/board/tar.d.ts.map +1 -0
  30. package/dist/board/tar.js +188 -0
  31. package/dist/board/tar.js.map +1 -0
  32. package/dist/board/tar.test.d.ts +9 -0
  33. package/dist/board/tar.test.d.ts.map +1 -0
  34. package/dist/board/tar.test.js +110 -0
  35. package/dist/board/tar.test.js.map +1 -0
  36. package/dist/claim-io.d.ts.map +1 -1
  37. package/dist/claim-io.js +4 -3
  38. package/dist/claim-io.js.map +1 -1
  39. package/dist/claim.d.ts +14 -2
  40. package/dist/claim.d.ts.map +1 -1
  41. package/dist/claim.js +9 -3
  42. package/dist/claim.js.map +1 -1
  43. package/dist/claim.test.js +41 -1
  44. package/dist/claim.test.js.map +1 -1
  45. package/dist/cli.js +127 -7
  46. package/dist/cli.js.map +1 -1
  47. package/dist/export.d.ts +163 -0
  48. package/dist/export.d.ts.map +1 -0
  49. package/dist/export.js +259 -0
  50. package/dist/export.js.map +1 -0
  51. package/dist/export.test.d.ts +12 -0
  52. package/dist/export.test.d.ts.map +1 -0
  53. package/dist/export.test.js +264 -0
  54. package/dist/export.test.js.map +1 -0
  55. package/dist/functions/board.js +709 -0
  56. package/dist/git-serve.d.ts +14 -1
  57. package/dist/git-serve.d.ts.map +1 -1
  58. package/dist/git-serve.js +30 -2
  59. package/dist/git-serve.js.map +1 -1
  60. package/dist/git-serve.test.d.ts +1 -0
  61. package/dist/git-serve.test.d.ts.map +1 -1
  62. package/dist/git-serve.test.js +36 -1
  63. package/dist/git-serve.test.js.map +1 -1
  64. package/dist/index.d.ts +2 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +6 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/server.d.ts +6 -0
  69. package/dist/server.d.ts.map +1 -1
  70. package/dist/server.js +12 -3
  71. package/dist/server.js.map +1 -1
  72. package/dist/trailers.d.ts +51 -0
  73. package/dist/trailers.d.ts.map +1 -0
  74. package/dist/trailers.js +32 -0
  75. package/dist/trailers.js.map +1 -0
  76. package/package.json +3 -3
  77. package/skill/SKILL.md +16 -1
  78. package/src/asks.test.ts +15 -0
  79. package/src/board/github.ts +151 -0
  80. package/src/board/handler.test.ts +276 -0
  81. package/src/board/handler.ts +228 -0
  82. package/src/board/pages-function.ts +42 -0
  83. package/src/board/source.test.ts +203 -0
  84. package/src/board/source.ts +422 -0
  85. package/src/board/tar.test.ts +128 -0
  86. package/src/board/tar.ts +199 -0
  87. package/src/claim-io.ts +12 -3
  88. package/src/claim.test.ts +56 -1
  89. package/src/claim.ts +27 -4
  90. package/src/cli.ts +127 -7
  91. package/src/export.test.ts +361 -0
  92. package/src/export.ts +354 -0
  93. package/src/git-serve.test.ts +41 -1
  94. package/src/git-serve.ts +30 -2
  95. package/src/index.ts +10 -0
  96. package/src/server.ts +12 -3
  97. package/src/trailers.ts +65 -0
  98. package/ui/dist/assets/index-B8M_DaOt.js +229 -0
  99. package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
  100. package/ui/dist/index.html +3 -3
  101. package/ui/dist/assets/index-BjsorZOU.js +0 -229
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The live proxy: an exported board's API, as one fetch handler.
3
+ *
4
+ * `task export` ships the board as static files plus a baked `snapshot.json`.
5
+ * Drop this next to them — as a Cloudflare Pages Function, or behind any host
6
+ * that can run a `(Request) => Response` — hand it a token, and the same
7
+ * files become a live board: the SPA probes for an API before it mounts,
8
+ * finds this one answering, and never reads the snapshot at all. No token,
9
+ * no function, and the probe fails and the snapshot is what you get. The
10
+ * upgrade is the deploy, not a rebuild.
11
+ *
12
+ * The routes are `task serve`'s read routes — the same ones the snapshot
13
+ * answers (`ui/src/snapshot.ts`), plus the two things a live proxy is *for*:
14
+ * `?ref=` to read any branch, and `/branches` so the switcher has something to
15
+ * list. Payloads come from `source.ts`, which is what the hosted board serves,
16
+ * so a board rendered from this differs from the hosted one in one thing:
17
+ * who may look. This tier has no viewer auth — the perimeter is wherever you
18
+ * deploy it — and it says so in `/project` the only way the SPA reads:
19
+ * `readOnly: true`, no `git` capability.
20
+ *
21
+ * Read-only by construction: anything but GET is a 405 before a token is
22
+ * even resolved.
23
+ */
24
+
25
+ import { GitHubError, headCommit, isMissingRef, listBranches, repoInfo } from "./github.ts"
26
+ import {
27
+ BoardSourceError,
28
+ SnapshotMemory,
29
+ STATUSES,
30
+ snapshotAt,
31
+ type BoardData,
32
+ type Snapshot,
33
+ } from "./source.ts"
34
+
35
+ export interface BoardHandlerOptions {
36
+ /**
37
+ * A bearer token GitHub will accept for reading the repository — a
38
+ * fine-grained PAT with `Contents: Read-only` on this one repo is the
39
+ * shape the README walks through — or a function that produces one, for
40
+ * an adapter that mints its own (an installation token, say). Called per
41
+ * request; cache in the getter if minting is expensive.
42
+ */
43
+ token: string | (() => string | Promise<string>)
44
+ /** `owner/name`. */
45
+ repo: string
46
+ /** What a request with no `?ref=` reads. Default `HEAD`: the default branch. */
47
+ ref?: string
48
+ /**
49
+ * Where the handler is mounted, so it can strip the prefix off incoming
50
+ * paths. `task export` writes `<base>api` here — `/api` at a domain root,
51
+ * `/board/api` under `--base /board/` — matching where the SPA looks.
52
+ */
53
+ basePath?: string
54
+ /** Snapshots kept in memory, by commit. Default 24. */
55
+ maxCachedSnapshots?: number
56
+ }
57
+
58
+ export type BoardHandler = (request: Request) => Promise<Response>
59
+
60
+ const REF_PATTERN = /^[\w.\-/]{1,255}$/
61
+
62
+ /** `owner/name`, both halves as GitHub spells them. */
63
+ export const REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/
64
+
65
+ function json(body: unknown, status = 200): Response {
66
+ return new Response(JSON.stringify(body), {
67
+ status,
68
+ headers: {
69
+ "Content-Type": "application/json; charset=utf-8",
70
+ // Every answer is "the repository right now"; nothing between this
71
+ // function and the browser should hold on to one.
72
+ "Cache-Control": "no-store",
73
+ },
74
+ })
75
+ }
76
+
77
+ /** The board the request asked for, or the repo's default — `pickBoard`'s rule. */
78
+ function pickBoard(snapshot: Snapshot, requested: string | null): BoardData | null {
79
+ const id = requested && snapshot.data.has(requested) ? requested : snapshot.boards[0]?.id
80
+ return id === undefined ? null : (snapshot.data.get(id) ?? null)
81
+ }
82
+
83
+ export function createBoardHandler(options: BoardHandlerOptions): BoardHandler {
84
+ const { repo: slug, ref: defaultRef = "HEAD" } = options
85
+ if (!REPO_PATTERN.test(slug)) throw new Error(`repo must be owner/name — got ${JSON.stringify(slug)}`)
86
+ const [owner, repo] = slug.split("/")
87
+ const basePath = (options.basePath ?? "/api").replace(/\/+$/, "")
88
+ const memory = new SnapshotMemory(options.maxCachedSnapshots)
89
+ // A cold start sees the SPA's four boot requests at once, all for the same
90
+ // commit. One tarball, not four: the first caller's load is shared with
91
+ // everyone who asks for that sha while it is still in flight.
92
+ const inFlight = new Map<string, Promise<Snapshot>>()
93
+
94
+ async function resolveToken(): Promise<string> {
95
+ const token = typeof options.token === "function" ? await options.token() : options.token
96
+ if (!token) {
97
+ // Unconfigured is not an error the board can act on; it is "no API",
98
+ // and a 5xx is exactly what makes the SPA's probe fall back to the
99
+ // snapshot sitting next to this function.
100
+ throw new BoardSourceError(503, "no GitHub token configured for the live board", "no-token")
101
+ }
102
+ return token
103
+ }
104
+
105
+ async function load(token: string, ref: string, explicit: boolean): Promise<Snapshot> {
106
+ let commit
107
+ try {
108
+ commit = await headCommit(token, owner, repo, ref)
109
+ } catch (error) {
110
+ // With an explicit ref, a miss almost always means the branch — a
111
+ // deleted preview, a typo — not the repository.
112
+ if (explicit && isMissingRef(error)) {
113
+ throw new BoardSourceError(404, `no such branch or ref: ${ref}`, "no-such-ref")
114
+ }
115
+ throw error
116
+ }
117
+ const key = `${owner}/${repo}@${commit.sha}`
118
+ const cached = memory.get(key)
119
+ if (cached) return cached
120
+ let pending = inFlight.get(key)
121
+ if (!pending) {
122
+ pending = snapshotAt(token, owner, repo, commit, ref)
123
+ .then((snapshot) => memory.remember(key, snapshot))
124
+ .finally(() => inFlight.delete(key))
125
+ inFlight.set(key, pending)
126
+ }
127
+ return pending
128
+ }
129
+
130
+ async function handle(request: Request): Promise<Response> {
131
+ if (request.method !== "GET" && request.method !== "HEAD") {
132
+ // Every editing affordance is already hidden by `readOnly`; this is the
133
+ // backstop, in the shape `ApiError` reads.
134
+ return json(
135
+ { error: "this board is read-only — commit and push to change it", reason: "read-only" },
136
+ 405,
137
+ )
138
+ }
139
+ const url = new URL(request.url)
140
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
141
+ return json({ error: `not an API path: ${url.pathname}` }, 404)
142
+ }
143
+ const route = url.pathname.slice(basePath.length) || "/"
144
+ const token = await resolveToken()
145
+
146
+ if (route === "/branches") {
147
+ // Default branch first (it's the way back), then alphabetical — the
148
+ // hosted board's order, which is the order the switcher expects.
149
+ const [names, info] = await Promise.all([
150
+ listBranches(token, owner, repo),
151
+ repoInfo(token, owner, repo),
152
+ ])
153
+ const branches = names
154
+ .map((name) => ({ name, isDefault: name === info.defaultBranch }))
155
+ .sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name))
156
+ return json({ branches })
157
+ }
158
+
159
+ // A ref is a viewer-supplied string that lands in a URL path; anything
160
+ // outside git's own naming rules is ignored rather than escaped.
161
+ const requestedRef = url.searchParams.get("ref")
162
+ const ref =
163
+ requestedRef && REF_PATTERN.test(requestedRef) && !requestedRef.includes("..")
164
+ ? requestedRef
165
+ : null
166
+ const snapshot = await load(token, ref ?? defaultRef, ref !== null)
167
+
168
+ if (route === "/boards") return json({ boards: snapshot.boards })
169
+
170
+ const board = pickBoard(snapshot, url.searchParams.get("board"))
171
+ if (!board) return json({ error: "no such board" }, 404)
172
+
173
+ if (route === "/project") {
174
+ return json({
175
+ name: board.info.name,
176
+ prefix: board.info.prefix,
177
+ statuses: STATUSES,
178
+ // The payload the SPA reads to decide whether to offer editing at all.
179
+ // The data is a commit; the only way to change one is to push.
180
+ readOnly: true,
181
+ // Locally this is who would author a comment. Nothing here can
182
+ // comment, and nothing here knows who is looking.
183
+ author: "",
184
+ })
185
+ }
186
+ if (route === "/tasks") return json({ tasks: board.tasks })
187
+ if (route === "/goals") return json({ goals: board.goals })
188
+
189
+ const detail = /^\/tasks\/([^/]+)$/.exec(route)
190
+ if (detail) {
191
+ // The CLI accepts "PREFIX-x7k4m" or "x7k4m"; so does this.
192
+ const match = /^(?:[A-Za-z0-9]+-)?([A-Za-z0-9]+)$/.exec(decodeURIComponent(detail[1]))
193
+ if (!match) return json({ error: "invalid task id" }, 400)
194
+ const key = match[1].toLowerCase()
195
+ const task = board.tasks.find((candidate) => candidate.key === key)
196
+ if (!task) return json({ error: `no such task: ${key}` }, 404)
197
+ return json({ task, comments: board.comments.get(key) ?? [] })
198
+ }
199
+
200
+ // `/inbox` and the git surface are deliberately absent: the SPA degrades
201
+ // to no inbox and no git chrome when they 404, same as the hosted board.
202
+ return json({ error: `not here: ${route}` }, 404)
203
+ }
204
+
205
+ return async (request) => {
206
+ try {
207
+ return await handle(request)
208
+ } catch (error) {
209
+ if (error instanceof BoardSourceError) {
210
+ return json({ error: error.message, reason: error.reason }, error.status)
211
+ }
212
+ if (error instanceof GitHubError) {
213
+ // A bad or expired token, a repo the token can't see, GitHub itself
214
+ // down: all of them are "the live board can't answer right now". A
215
+ // 5xx is deliberate — on the boot probe it is what sends the SPA to
216
+ // the snapshot instead of an error screen.
217
+ return json(
218
+ {
219
+ error: `GitHub answered ${error.status} — check the token and the repository name`,
220
+ reason: "github",
221
+ },
222
+ 502,
223
+ )
224
+ }
225
+ return json({ error: error instanceof Error ? error.message : "internal error" }, 500)
226
+ }
227
+ }
228
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The Cloudflare Pages Function `task export --functions` writes.
3
+ *
4
+ * Bundled into one dependency-free module (`dist/functions/board.js`, built
5
+ * by `vite.functions.config.ts`) because the deployed directory has no
6
+ * `node_modules` — the file has to carry the handler with it. The export
7
+ * copies it to `functions[/<base>]/api/[[path]].js` and fills in the two
8
+ * placeholders below; nothing else about it is per-repo.
9
+ *
10
+ * The token is read per request from the Pages project's secrets. Unset, the
11
+ * function answers 503 without touching GitHub: the SPA's boot probe reads
12
+ * that as "no API" and falls back to the snapshot beside this file, which is
13
+ * the documented behaviour of a live board that isn't configured yet.
14
+ */
15
+
16
+ import { createBoardHandler, type BoardHandler } from "./handler.ts"
17
+
18
+ /** Replaced by `task export` when the file is written. */
19
+ const REPO = "__TASK_EXPORT_REPO__"
20
+ const BASE_PATH = "__TASK_EXPORT_BASE_PATH__"
21
+
22
+ /** The secret's name in the Pages project; the README says how to set it. */
23
+ export const TOKEN_SECRET = "TASK_GITHUB_TOKEN"
24
+
25
+ interface PagesContext {
26
+ request: Request
27
+ env: Record<string, string | undefined>
28
+ }
29
+
30
+ let currentToken = ""
31
+ let handler: BoardHandler | null = null
32
+
33
+ export function onRequest(context: PagesContext): Promise<Response> | Response {
34
+ const token = context.env[TOKEN_SECRET]
35
+ if (!token) return new Response(null, { status: 503 })
36
+ currentToken = token
37
+ // One handler per isolate, so the snapshot cache survives between requests;
38
+ // the token is read through a getter so a rotated secret takes effect
39
+ // without waiting for the isolate to be recycled.
40
+ handler ??= createBoardHandler({ token: () => currentToken, repo: REPO, basePath: BASE_PATH })
41
+ return handler(context.request)
42
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The hosted board and an exported board's live proxy serve the same SPA as
3
+ * `task serve`, so their payloads have to be the same payloads — not
4
+ * "compatible", the same. This diffs them against the CLI's own `FileStore`.
5
+ *
6
+ * That is the test that actually protects the design: if someone adds a field
7
+ * to the CLI's `toTask` and not here, the hosted board silently loses it, and
8
+ * the only symptom is a piece of the UI that renders blank for one deployment
9
+ * and not the other.
10
+ */
11
+
12
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"
13
+ import { tmpdir } from "node:os"
14
+ import { fileURLToPath } from "node:url"
15
+ import { join } from "node:path"
16
+ import assert from "node:assert/strict"
17
+ import { test } from "node:test"
18
+ import type { Store } from "../store.ts"
19
+ import { FileStore, initProject } from "../file-store.ts"
20
+ import { findBoardDirs, readTicketsBoard, type BoardData } from "./source.ts"
21
+
22
+ const REPO_ROOT = fileURLToPath(new URL("../../../../..", import.meta.url))
23
+
24
+ /** Every board this repo actually commits, as `findBoards` would order them. */
25
+ const BOARDS = [".", "projects/inspo", "projects/phone/web", "projects/portal", "projects/task/cli"]
26
+
27
+ function tempRoot(): string {
28
+ const dir = mkdtempSync(join(tmpdir(), "board-source-test-"))
29
+ process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
30
+ return dir
31
+ }
32
+
33
+ /** A board's `.task/` read off disk into the path→bytes map the worker sees. */
34
+ function ticketFiles(taskDir: string): Map<string, Uint8Array> {
35
+ const files = new Map<string, Uint8Array>()
36
+ for (const dir of ["goals", "goals/archive"]) {
37
+ const goalsDir = join(taskDir, dir)
38
+ if (!existsSync(goalsDir)) continue
39
+ for (const entry of readdirSync(goalsDir, { withFileTypes: true })) {
40
+ if (!entry.isFile() || !entry.name.endsWith(".md")) continue
41
+ files.set(`${dir}/${entry.name}`, new Uint8Array(readFileSync(join(goalsDir, entry.name))))
42
+ }
43
+ }
44
+ const ticketsDir = join(taskDir, "tickets")
45
+ if (!existsSync(ticketsDir)) return files
46
+ for (const number of readdirSync(ticketsDir)) {
47
+ const ticket = join(ticketsDir, number, "ticket.md")
48
+ if (existsSync(ticket)) {
49
+ files.set(`tickets/${number}/ticket.md`, new Uint8Array(readFileSync(ticket)))
50
+ }
51
+ for (const kind of ["comments", "asks"]) {
52
+ const dir = join(ticketsDir, number, kind)
53
+ if (!existsSync(dir)) continue
54
+ for (const entry of readdirSync(dir)) {
55
+ files.set(
56
+ `tickets/${number}/${kind}/${entry}`,
57
+ new Uint8Array(readFileSync(join(dir, entry))),
58
+ )
59
+ }
60
+ }
61
+ }
62
+ return files
63
+ }
64
+
65
+ function loadTicketsBoard(id: string, root: string): BoardData {
66
+ const taskDir = join(root, ".task")
67
+ return readTicketsBoard(
68
+ id,
69
+ new Uint8Array(readFileSync(join(taskDir, "config.json"))),
70
+ ticketFiles(taskDir),
71
+ )
72
+ }
73
+
74
+ /** `GET /api/tasks` + per-task comments, straight from the CLI store. */
75
+ function expectedPayloads(store: Store) {
76
+ const counts = store.commentCounts()
77
+ const tasks = store.list().map((task) => ({
78
+ ...task,
79
+ commentCount: counts.get(task.key) ?? 0,
80
+ }))
81
+ return { tasks, comments: new Map(tasks.map((t) => [t.key, store.comments(t.key)])) }
82
+ }
83
+
84
+ function assertSamePayloads(id: string, hosted: BoardData, store: Store): void {
85
+ assert.equal(hosted.info.name, store.config.name, `${id}: project name`)
86
+ assert.equal(hosted.info.prefix, store.config.prefix, `${id}: prefix`)
87
+ const expected = expectedPayloads(store)
88
+ assert.equal(hosted.tasks.length, expected.tasks.length, `${id}: task count`)
89
+ for (let i = 0; i < expected.tasks.length; i++) {
90
+ assert.deepEqual(hosted.tasks[i], expected.tasks[i], `${id}: task ${expected.tasks[i].id}`)
91
+ }
92
+ for (const task of expected.tasks) {
93
+ assert.deepEqual(
94
+ hosted.comments.get(task.key) ?? [],
95
+ expected.comments.get(task.key),
96
+ `${id}: comments on ${task.id}`,
97
+ )
98
+ }
99
+ assert.deepEqual(
100
+ hosted.goals,
101
+ [...store.goals(), ...store.goals(true)],
102
+ `${id}: goals`,
103
+ )
104
+ }
105
+
106
+ test("finds the same boards the CLI would, in the same order", () => {
107
+ // The shape a recursive git-tree listing arrives in, plus the noise it
108
+ // really contains: nested boards, unrelated files, and a `.task` path that
109
+ // is a file rather than a board.
110
+ const paths = [
111
+ "README.md",
112
+ ".task/config.json",
113
+ ".task/tickets/1/ticket.md",
114
+ "projects/phone/web/.task/config.json",
115
+ "projects/task/cli/.task/config.json",
116
+ "docs/.taskrc",
117
+ "vendor/thing/.task/config.json.bak",
118
+ // A directory that merely *ends* in `.task` is not a board directory.
119
+ "lib/x.task/config.json",
120
+ ]
121
+ assert.deepEqual(findBoardDirs(paths), [".", "projects/phone/web", "projects/task/cli"])
122
+ })
123
+
124
+ test("ignores boards nested deeper than the CLI would walk", () => {
125
+ const deep = "a/b/c/d/e/f/g/.task/config.json"
126
+ assert.deepEqual(findBoardDirs([deep]), [])
127
+ assert.deepEqual(findBoardDirs(["a/b/c/.task/config.json"]), ["a/b/c"])
128
+ })
129
+
130
+ test("text boards produce the same payloads the CLI's FileStore does", () => {
131
+ const root = tempRoot()
132
+ const store = initProject(root, { name: "fixture board", prefix: "FIX" })
133
+ store.createGoal({ title: "Launch", description: "the shared why" })
134
+ const one = store.create({
135
+ title: "Every field exercised",
136
+ description: "A *body* with\n\n# an inner heading\n\nand `code`.",
137
+ tags: ["api", "infra"],
138
+ goal: "launch",
139
+ prs: ["https://github.com/x/y/pull/3"],
140
+ })
141
+ store.addAsk(one.key, "Approve the launch window", "claude")
142
+ const two = store.create({ title: "Mover", status: "backlog" })
143
+ store.create({ title: "Plain" })
144
+ store.update(two.key, { status: "in_progress" }) // lands on top → negative position
145
+ store.link(two.key, "blocked_by", one.key)
146
+ store.addComment(one.key, "first, with **markdown**", "nick")
147
+ store.addComment(one.key, "second", "claude")
148
+
149
+ assertSamePayloads("fixture", loadTicketsBoard("fixture", root), store)
150
+ })
151
+
152
+ test("this repo's committed boards read as text, same as the CLI reads them", () => {
153
+ for (const id of BOARDS) {
154
+ const root = id === "." ? REPO_ROOT : join(REPO_ROOT, id)
155
+ assertSamePayloads(id, loadTicketsBoard(id, root), new FileStore(root))
156
+ }
157
+ })
158
+
159
+ test("every committed board is non-empty and self-consistent", () => {
160
+ // A smoke check that the fixtures above are actually exercising something —
161
+ // boards that all happened to be empty would make the diff vacuous.
162
+ const loaded = BOARDS.map((id) =>
163
+ loadTicketsBoard(id, id === "." ? REPO_ROOT : join(REPO_ROOT, id)),
164
+ )
165
+ const total = loaded.reduce((sum, board) => sum + board.tasks.length, 0)
166
+ assert.ok(total > 10, `expected the repo's boards to hold real tasks, found ${total}`)
167
+
168
+ for (const board of loaded) {
169
+ for (const task of board.tasks) {
170
+ assert.match(task.id, /^[A-Z0-9]+-[a-z0-9]+$/, `${board.info.id}: display id`)
171
+ assert.ok(Array.isArray(task.tags), `${board.info.id}: tags parsed`)
172
+ assert.equal(typeof task.needsHuman, "boolean", `${board.info.id}: needsHuman`)
173
+ }
174
+ }
175
+ })
176
+
177
+ test("a broken ticket degrades instead of breaking the board", () => {
178
+ const config = new TextEncoder().encode(
179
+ JSON.stringify({ name: "rough", prefix: "RGH", version: 2 }),
180
+ )
181
+ const encode = (s: string) => new TextEncoder().encode(s)
182
+ const board = readTicketsBoard(
183
+ "rough",
184
+ config,
185
+ new Map([
186
+ ["tickets/bad2c/ticket.md", encode("---\nstatus: todoo\nposition: not-a-number\n---\n\nno heading")],
187
+ ["tickets/gone3/ticket.md", encode("not even frontmatter")],
188
+ ["tickets/fine4/ticket.md", encode("---\nstatus: todo\nposition: 1\ncreated: c\nupdated: u\n---\n\n# Fine\n")],
189
+ // A comment on a ticket that didn't parse must not surface anywhere.
190
+ ["tickets/gone3/comments/2026-08-15T120000-x.md", encode("---\nauthor: \"x\"\ncreated: c\n---\n\nhi")],
191
+ // A pre-TAS-42 numeric directory is not a ticket to this reader at all.
192
+ ["tickets/42/ticket.md", encode("---\nstatus: todo\nposition: 1\ncreated: c\nupdated: u\n---\n\n# Old format\n")],
193
+ ]),
194
+ )
195
+ assert.deepEqual(
196
+ board.tasks.map((t) => [t.key, t.status, t.title]),
197
+ [
198
+ ["bad2c", "backlog", ""],
199
+ ["fine4", "todo", "Fine"],
200
+ ],
201
+ )
202
+ assert.equal(board.comments.size, 0)
203
+ })