@nickmeriano/task 0.11.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 (73) hide show
  1. package/README.md +175 -1
  2. package/dist/board/github.d.ts +68 -0
  3. package/dist/board/github.d.ts.map +1 -0
  4. package/dist/board/github.js +112 -0
  5. package/dist/board/github.js.map +1 -0
  6. package/dist/board/handler.d.ts +50 -0
  7. package/dist/board/handler.d.ts.map +1 -0
  8. package/dist/board/handler.js +183 -0
  9. package/dist/board/handler.js.map +1 -0
  10. package/dist/board/handler.test.d.ts +11 -0
  11. package/dist/board/handler.test.d.ts.map +1 -0
  12. package/dist/board/handler.test.js +229 -0
  13. package/dist/board/handler.test.js.map +1 -0
  14. package/dist/board/pages-function.d.ts +23 -0
  15. package/dist/board/pages-function.d.ts.map +1 -0
  16. package/dist/board/pages-function.js +34 -0
  17. package/dist/board/pages-function.js.map +1 -0
  18. package/dist/board/source.d.ts +118 -0
  19. package/dist/board/source.d.ts.map +1 -0
  20. package/dist/board/source.js +333 -0
  21. package/dist/board/source.js.map +1 -0
  22. package/dist/board/source.test.d.ts +12 -0
  23. package/dist/board/source.test.d.ts.map +1 -0
  24. package/dist/board/source.test.js +165 -0
  25. package/dist/board/source.test.js.map +1 -0
  26. package/dist/board/tar.d.ts +28 -0
  27. package/dist/board/tar.d.ts.map +1 -0
  28. package/dist/board/tar.js +188 -0
  29. package/dist/board/tar.js.map +1 -0
  30. package/dist/board/tar.test.d.ts +9 -0
  31. package/dist/board/tar.test.d.ts.map +1 -0
  32. package/dist/board/tar.test.js +110 -0
  33. package/dist/board/tar.test.js.map +1 -0
  34. package/dist/cli.js +37 -2
  35. package/dist/cli.js.map +1 -1
  36. package/dist/export.d.ts +31 -0
  37. package/dist/export.d.ts.map +1 -1
  38. package/dist/export.js +61 -1
  39. package/dist/export.js.map +1 -1
  40. package/dist/export.test.js +80 -1
  41. package/dist/export.test.js.map +1 -1
  42. package/dist/functions/board.js +709 -0
  43. package/dist/git-serve.d.ts +14 -1
  44. package/dist/git-serve.d.ts.map +1 -1
  45. package/dist/git-serve.js +30 -2
  46. package/dist/git-serve.js.map +1 -1
  47. package/dist/git-serve.test.d.ts +1 -0
  48. package/dist/git-serve.test.d.ts.map +1 -1
  49. package/dist/git-serve.test.js +36 -1
  50. package/dist/git-serve.test.js.map +1 -1
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -0
  54. package/dist/index.js.map +1 -1
  55. package/package.json +3 -3
  56. package/skill/SKILL.md +10 -3
  57. package/src/board/github.ts +151 -0
  58. package/src/board/handler.test.ts +276 -0
  59. package/src/board/handler.ts +228 -0
  60. package/src/board/pages-function.ts +42 -0
  61. package/src/board/source.test.ts +203 -0
  62. package/src/board/source.ts +422 -0
  63. package/src/board/tar.test.ts +128 -0
  64. package/src/board/tar.ts +199 -0
  65. package/src/cli.ts +36 -2
  66. package/src/export.test.ts +108 -1
  67. package/src/export.ts +79 -1
  68. package/src/git-serve.test.ts +41 -1
  69. package/src/git-serve.ts +30 -2
  70. package/src/index.ts +10 -0
  71. package/ui/dist/assets/index-B8M_DaOt.js +229 -0
  72. package/ui/dist/index.html +1 -1
  73. package/ui/dist/assets/index-mJmm4sWq.js +0 -229
@@ -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
+ })
@@ -0,0 +1,422 @@
1
+ /**
2
+ * A repository, read as boards.
3
+ *
4
+ * This is `findBoards` + `FileStore`, re-expressed against a git tree instead
5
+ * of a filesystem, and producing byte-identical API payloads. That
6
+ * equivalence is the whole point: the hosted board and an exported board's
7
+ * live proxy run the *same* SPA as `task serve`, so anything that differs
8
+ * here shows up as a broken screen rather than a type error. The shapes below
9
+ * are pinned to `server.ts` — change one and change both — and the ticket
10
+ * format itself is not restated: parsing comes from `ticket-doc.ts`, the one
11
+ * definition every server shares, in its lenient mode (a hand-edited file
12
+ * that no longer parses becomes a degraded ticket or a skipped comment, never
13
+ * a broken board).
14
+ *
15
+ * Read-only by construction. There is no code here that could write, which is
16
+ * how "the board can't edit your repo" stays true no matter what the routing
17
+ * layer does.
18
+ *
19
+ * Cache-free on purpose. A snapshot is immutable per commit, so *where* to
20
+ * keep one is a deployment question — the hosted worker layers isolate memory
21
+ * and KV over `snapshotAt`, the live proxy keeps a bounded `SnapshotMemory` —
22
+ * and this module answers only "what is at this commit".
23
+ */
24
+
25
+ import {
26
+ parseAskLenient,
27
+ parseCommentLenient,
28
+ parseGoalLenient,
29
+ parseTicketLenient,
30
+ type TicketDoc,
31
+ } from "../ticket-doc.ts"
32
+ import { isTicketKey } from "../id.ts"
33
+ import type { Ask, Comment, Goal, Task as StoredTask } from "../types.ts"
34
+ import { fetchRepoArchive, headCommit, type CommitInfo } from "./github.ts"
35
+
36
+ export { STATUSES, type Status } from "../types.ts"
37
+ export type { Ask, Comment, Goal }
38
+
39
+ const TASK_DIR = ".task"
40
+ const CONFIG_FILE = "config.json"
41
+ const TICKETS_DIR = "tickets"
42
+ const TICKET_FILE = "ticket.md"
43
+ const COMMENTS_DIR = "comments"
44
+ const ASKS_DIR = "asks"
45
+ const GOALS_DIR = "goals"
46
+
47
+ /** Same ceiling as the CLI's `findBoards`, for the same reason. */
48
+ const MAX_DEPTH = 6
49
+
50
+ export interface BoardInfo {
51
+ id: string
52
+ name: string
53
+ prefix: string
54
+ }
55
+
56
+ /** A stored task plus the count `GET /tasks` sends — exactly what `task serve` lists. */
57
+ export interface Task extends StoredTask {
58
+ commentCount: number
59
+ }
60
+
61
+ export interface BoardData {
62
+ info: BoardInfo
63
+ tasks: Task[]
64
+ /** Keyed by task key. */
65
+ comments: Map<string, Comment[]>
66
+ /** Live goals first, archived flagged — the shape `GET /goals` serves. */
67
+ goals: Goal[]
68
+ }
69
+
70
+ export interface Snapshot {
71
+ commit: CommitInfo
72
+ boards: BoardInfo[]
73
+ data: Map<string, BoardData>
74
+ /**
75
+ * Historically "the tree listing hit GitHub's cap". The tarball read is
76
+ * always complete, so this is now always false — kept because the SPA's
77
+ * payloads carry it.
78
+ */
79
+ truncated: boolean
80
+ }
81
+
82
+ export class BoardSourceError extends Error {
83
+ readonly status: number
84
+ /**
85
+ * Machine-readable cause, sent beside the message so the SPA can choose a
86
+ * screen rather than infer one from a status code. Optional: most of these
87
+ * are one of a kind and the message is the whole story.
88
+ */
89
+ readonly reason: string | null
90
+ constructor(status: number, message: string, reason: string | null = null) {
91
+ super(message)
92
+ this.name = "BoardSourceError"
93
+ this.status = status
94
+ this.reason = reason
95
+ }
96
+ }
97
+
98
+ function str(value: unknown, fallback = ""): string {
99
+ return typeof value === "string" ? value : fallback
100
+ }
101
+
102
+ function num(value: unknown, fallback = 0): number {
103
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback
104
+ }
105
+
106
+ interface ParsedConfig {
107
+ name: string
108
+ prefix: string
109
+ version: number
110
+ }
111
+
112
+ /** `.task/config.json`, defensively — it's a file anyone can hand-edit. */
113
+ function parseConfig(bytes: Uint8Array, fallbackName: string): ParsedConfig {
114
+ try {
115
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes))
116
+ if (parsed && typeof parsed === "object") {
117
+ const config = parsed as Record<string, unknown>
118
+ return {
119
+ name: str(config.name, fallbackName),
120
+ prefix: str(config.prefix, "TASK").toUpperCase(),
121
+ version: num(config.version, 1),
122
+ }
123
+ }
124
+ } catch {
125
+ // fall through
126
+ }
127
+ return { name: fallbackName, prefix: "TASK", version: 1 }
128
+ }
129
+
130
+ /**
131
+ * Board directories, from a flat list of repo paths.
132
+ *
133
+ * The CLI finds a board by the presence of `.task/config.json`; so does this.
134
+ * Depth is capped the same way, and the ordering — "." first, then
135
+ * lexicographic — is copied so the default board is the same one you'd get
136
+ * locally.
137
+ */
138
+ export function findBoardDirs(paths: string[]): string[] {
139
+ const marker = `${TASK_DIR}/${CONFIG_FILE}`
140
+ const dirs: string[] = []
141
+ for (const path of paths) {
142
+ // The directory has to be `.task` itself, not merely end in it — a bare
143
+ // suffix match would read `x.task/config.json` as a board at `x`.
144
+ if (path !== marker && !path.endsWith(`/${marker}`)) continue
145
+ const dir = path.slice(0, -(TASK_DIR.length + CONFIG_FILE.length + 2))
146
+ const id = dir === "" ? "." : dir.replace(/\/$/, "")
147
+ if (id !== "." && id.split("/").length > MAX_DEPTH) continue
148
+ dirs.push(id)
149
+ }
150
+ dirs.sort((a, b) => (a === "." ? -1 : b === "." ? 1 : a < b ? -1 : 1))
151
+ return dirs
152
+ }
153
+
154
+ /** `projects/phone/web` → "phone web"-ish fallback name, matching `task init`. */
155
+ function nameFromDir(id: string): string {
156
+ if (id === ".") return "tasks"
157
+ return id.split("/").pop() ?? "tasks"
158
+ }
159
+
160
+ /**
161
+ * One board from its ticket files. `files` is keyed by path relative to the
162
+ * board's `.task/` — `tickets/12/ticket.md`, `tickets/12/comments/<stem>.md` —
163
+ * which is how `snapshotAt` fetches them and how the test feeds them from
164
+ * disk.
165
+ */
166
+ export function readTicketsBoard(
167
+ id: string,
168
+ configBytes: Uint8Array,
169
+ files: Map<string, Uint8Array>,
170
+ ): BoardData {
171
+ const config = parseConfig(configBytes, nameFromDir(id))
172
+ const decoder = new TextDecoder()
173
+
174
+ const docs = new Map<string, TicketDoc>()
175
+ const comments = new Map<string, Comment[]>()
176
+ const asks = new Map<string, Ask[]>()
177
+ const goals: Goal[] = []
178
+ for (const [path, bytes] of files) {
179
+ // Goals: one file per goal, archived ones one level down. Old branches
180
+ // simply have no goals/ — the list stays empty and every reader tolerates
181
+ // a ticket's goal ref not resolving.
182
+ const goalMatch = new RegExp(`^${GOALS_DIR}/(archive/)?([^/]+)\\.md$`).exec(path)
183
+ if (goalMatch) {
184
+ if (goalMatch[2].startsWith(".")) continue
185
+ const doc = parseGoalLenient(decoder.decode(bytes))
186
+ if (doc) {
187
+ goals.push({
188
+ slug: goalMatch[2],
189
+ title: doc.title,
190
+ description: doc.description,
191
+ createdAt: doc.createdAt,
192
+ updatedAt: doc.updatedAt,
193
+ ...(goalMatch[1] ? { archived: true } : {}),
194
+ })
195
+ }
196
+ continue
197
+ }
198
+ const ticketMatch = new RegExp(`^${TICKETS_DIR}/([^/]+)/${TICKET_FILE}$`).exec(path)
199
+ if (ticketMatch && isTicketKey(ticketMatch[1])) {
200
+ const doc = parseTicketLenient(decoder.decode(bytes))
201
+ if (doc) docs.set(ticketMatch[1], doc)
202
+ continue
203
+ }
204
+ const commentMatch = new RegExp(`^${TICKETS_DIR}/([^/]+)/${COMMENTS_DIR}/([^/]+)\\.md$`).exec(
205
+ path,
206
+ )
207
+ if (commentMatch && isTicketKey(commentMatch[1]) && !commentMatch[2].startsWith(".")) {
208
+ const doc = parseCommentLenient(decoder.decode(bytes))
209
+ if (!doc) continue
210
+ const taskKey = commentMatch[1]
211
+ const list = comments.get(taskKey) ?? []
212
+ list.push({
213
+ id: commentMatch[2],
214
+ taskId: `${config.prefix}-${taskKey}`,
215
+ author: doc.author,
216
+ body: doc.body,
217
+ createdAt: doc.createdAt,
218
+ })
219
+ comments.set(taskKey, list)
220
+ continue
221
+ }
222
+ const askMatch = new RegExp(`^${TICKETS_DIR}/([^/]+)/${ASKS_DIR}/([^/]+)\\.md$`).exec(path)
223
+ if (askMatch && isTicketKey(askMatch[1]) && !askMatch[2].startsWith(".")) {
224
+ const doc = parseAskLenient(decoder.decode(bytes))
225
+ if (!doc) continue
226
+ const taskKey = askMatch[1]
227
+ const list = asks.get(taskKey) ?? []
228
+ // Ordinal assigned after the sort below — 0 is a placeholder.
229
+ list.push({
230
+ id: askMatch[2],
231
+ taskId: `${config.prefix}-${taskKey}`,
232
+ ordinal: 0,
233
+ text: doc.body,
234
+ author: doc.author,
235
+ createdAt: doc.createdAt,
236
+ resolvedAt: doc.resolvedAt,
237
+ resolvedBy: doc.resolvedBy,
238
+ })
239
+ asks.set(taskKey, list)
240
+ }
241
+ }
242
+ // Comments whose ticket file is missing or unreadable would count against a
243
+ // ticket that isn't drawn — drop them, and order the rest the CLI's way.
244
+ for (const taskKey of comments.keys()) {
245
+ if (!docs.has(taskKey)) comments.delete(taskKey)
246
+ }
247
+ for (const list of comments.values()) {
248
+ list.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
249
+ }
250
+ // Asks: same orphan rule, and ordinals are creation-order positions — the
251
+ // exact numbering the CLI's `asks()` derives.
252
+ for (const taskKey of asks.keys()) {
253
+ if (!docs.has(taskKey)) asks.delete(taskKey)
254
+ }
255
+ for (const list of asks.values()) {
256
+ list.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
257
+ list.forEach((ask, index) => (ask.ordinal = index + 1))
258
+ }
259
+
260
+ // `blocks` is derived from the other tickets' stored `blocked_by` — the
261
+ // relation lives on one side only, so the two views can't disagree.
262
+ const blocks = new Map<string, string[]>()
263
+ for (const [key, doc] of docs) {
264
+ for (const blocker of doc.blockedBy) {
265
+ blocks.set(blocker, [...(blocks.get(blocker) ?? []), key])
266
+ }
267
+ }
268
+ for (const list of blocks.values()) list.sort((a, b) => a.localeCompare(b))
269
+
270
+ const tasks: Task[] = [...docs.entries()]
271
+ .map(([key, doc]) => ({
272
+ id: `${config.prefix}-${key}`,
273
+ key,
274
+ title: doc.title,
275
+ description: doc.description,
276
+ status: doc.status,
277
+ tags: doc.tags,
278
+ goal: doc.goal,
279
+ needsHuman: (asks.get(key) ?? []).some((a) => !a.resolvedAt),
280
+ asks: asks.get(key) ?? [],
281
+ blocks: blocks.get(key) ?? [],
282
+ blockedBy: doc.blockedBy,
283
+ prs: doc.prs,
284
+ position: doc.position,
285
+ createdAt: doc.createdAt,
286
+ updatedAt: doc.updatedAt,
287
+ commentCount: comments.get(key)?.length ?? 0,
288
+ }))
289
+ .sort(
290
+ (a, b) =>
291
+ a.position - b.position ||
292
+ a.createdAt.localeCompare(b.createdAt) ||
293
+ a.key.localeCompare(b.key),
294
+ )
295
+
296
+ goals.sort(
297
+ (a, b) => Number(a.archived === true) - Number(b.archived === true) || a.slug.localeCompare(b.slug),
298
+ )
299
+ return { info: { id, name: config.name, prefix: config.prefix }, tasks, comments, goals }
300
+ }
301
+
302
+ /**
303
+ * Every board in a repository at a known commit, ready to serve.
304
+ *
305
+ * One request for the whole repository: every path, plus bytes for anything
306
+ * under a .task/ directory. (Earlier shapes — a tree listing plus per-file or
307
+ * batched blob reads — scaled subrequests with board size and tripped
308
+ * Workers' 50-subrequest ceiling; a tarball is one request regardless.)
309
+ *
310
+ * `ref` is only for the error message: told a branch, "this repository has
311
+ * no .task directory" is both wrong and confusing — the repository plainly
312
+ * has one, you were just looking at it — and `task init` is not the fix for
313
+ * previewing a branch that forked before the boards existed.
314
+ */
315
+ export async function snapshotAt(
316
+ token: string,
317
+ owner: string,
318
+ repo: string,
319
+ commit: CommitInfo,
320
+ ref = "HEAD",
321
+ ): Promise<Snapshot> {
322
+ const { paths, files } = await fetchRepoArchive(token, owner, repo, commit.sha, (path) =>
323
+ path.startsWith(`${TASK_DIR}/`) || path.includes(`/${TASK_DIR}/`),
324
+ )
325
+ const dirs = findBoardDirs(paths)
326
+ if (dirs.length === 0) {
327
+ throw new BoardSourceError(
328
+ 404,
329
+ ref === "HEAD"
330
+ ? "no .task directory in this repository — run `task init` and commit it"
331
+ : `no .task directory on ${ref}`,
332
+ "no-boards",
333
+ )
334
+ }
335
+
336
+ const data = new Map<string, BoardData>()
337
+ for (const id of dirs) {
338
+ const base = id === "." ? TASK_DIR : `${id}/${TASK_DIR}`
339
+ const configBytes = files.get(`${base}/${CONFIG_FILE}`)
340
+ if (!configBytes) continue
341
+
342
+ const prefix = `${base}/`
343
+ const ticketPaths = paths.filter(
344
+ (path) =>
345
+ (path.startsWith(`${prefix}${TICKETS_DIR}/`) || path.startsWith(`${prefix}${GOALS_DIR}/`)) &&
346
+ path.endsWith(".md"),
347
+ )
348
+ // A config with no committed tickets can be two very different things: a
349
+ // real (empty) text board, or a pre-0.6 board whose state is a SQLite
350
+ // database this app no longer reads. The config's version says which —
351
+ // skip the legacy one rather than render an empty board that lies.
352
+ if (ticketPaths.length === 0 && parseConfig(configBytes, "").version < 2) continue
353
+
354
+ const tickets = new Map<string, Uint8Array>()
355
+ for (const path of ticketPaths) {
356
+ const bytes = files.get(path)
357
+ if (bytes) tickets.set(path.slice(prefix.length), bytes)
358
+ }
359
+ const board = readTicketsBoard(id, configBytes, tickets)
360
+ data.set(board.info.id, board)
361
+ }
362
+ if (data.size === 0) {
363
+ throw new BoardSourceError(
364
+ 404,
365
+ "found .task/config.json but no committed tickets — are .task/tickets/ gitignored, or is this a pre-0.6 SQLite board that still needs `task migrate` (@nickmeriano/task@0.6)?",
366
+ )
367
+ }
368
+
369
+ return {
370
+ commit,
371
+ boards: [...data.values()].map((board) => board.info),
372
+ data,
373
+ truncated: false,
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Every board at `ref`, cache-free: "what is HEAD" plus one tarball. Callers
379
+ * that serve more than one view put a cache between the two — see
380
+ * `SnapshotMemory` — because the second request is the expensive one and a
381
+ * commit never changes.
382
+ */
383
+ export async function loadSnapshot(
384
+ token: string,
385
+ owner: string,
386
+ repo: string,
387
+ ref: string,
388
+ ): Promise<Snapshot> {
389
+ const commit = await headCommit(token, owner, repo, ref)
390
+ return snapshotAt(token, owner, repo, commit, ref)
391
+ }
392
+
393
+ /**
394
+ * Parsed snapshots, keyed by `owner/repo@sha`, bounded.
395
+ *
396
+ * A commit is immutable, so this never serves anything stale: the only lookup
397
+ * that goes to GitHub on a warm instance is "what is HEAD right now", and a
398
+ * new commit is a new key. Bounded because an isolate's memory is not ours to
399
+ * fill; `Map` iterates in insertion order, so the first key is the oldest.
400
+ */
401
+ export class SnapshotMemory {
402
+ private readonly snapshots = new Map<string, Snapshot>()
403
+ private readonly max: number
404
+
405
+ constructor(max = 24) {
406
+ this.max = max
407
+ }
408
+
409
+ get(key: string): Snapshot | undefined {
410
+ return this.snapshots.get(key)
411
+ }
412
+
413
+ remember(key: string, snapshot: Snapshot): Snapshot {
414
+ this.snapshots.set(key, snapshot)
415
+ while (this.snapshots.size > this.max) {
416
+ const oldest = this.snapshots.keys().next().value
417
+ if (oldest === undefined) break
418
+ this.snapshots.delete(oldest)
419
+ }
420
+ return snapshot
421
+ }
422
+ }