@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nickmeriano/task",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "A task manager that lives in your repo — plain-text tickets in .task/, a zero-dependency CLI for humans and agents, and a live kanban/table UI via `task serve`.",
6
6
  "license": "MIT",
@@ -40,11 +40,11 @@
40
40
  "skill"
41
41
  ],
42
42
  "scripts": {
43
- "build": "tsc -p tsconfig.build.json && vite build ui",
43
+ "build": "tsc -p tsconfig.build.json && vite build ui && vite build -c vite.functions.config.ts",
44
44
  "prepack": "pnpm build",
45
45
  "dev": "vite ui",
46
46
  "lint": "eslint .",
47
- "test": "node --test --experimental-strip-types src/*.test.ts ui/src/*.test.ts",
47
+ "test": "node --test --experimental-strip-types src/*.test.ts src/board/*.test.ts ui/src/*.test.ts",
48
48
  "typecheck": "tsc --noEmit && tsc --noEmit -p ui && tsc --noEmit -p ui/tsconfig.test.json"
49
49
  },
50
50
  "engines": {
package/skill/SKILL.md CHANGED
@@ -91,6 +91,8 @@ task unarchive <id> --json # put one back on the board
91
91
  task whoami # who comments are attributed to
92
92
  task check [--fix] --json # lint the board files (dangling refs,
93
93
  # bad files); --fix repairs mechanically
94
+ task export [--out dir] [--base path] [--force] [--no-functions] --json # static files + a dormant live-proxy function — see
95
+ # "The UI" below
94
96
  ```
95
97
 
96
98
  `task publish` and `task unpublish` also exist — they give the board a URL so
@@ -186,7 +188,7 @@ don't switch: claim with `task claim <id> --lock-only` and keep working on
186
188
  your assigned branch. It takes the same atomic lock by pushing the
187
189
  `in_progress` flip commit built from origin's default branch — your checkout,
188
190
  even a dirty one, is never touched — and records your branch in the flip
189
- commit (`delivered_by:`), so anyone reading the claim sees where the work
191
+ commit (`Delivered-By:`), so anyone reading the claim sees where the work
190
192
  lands. Do the rest of the ticket on your branch as usual, `task move <id>
191
193
  done` + `--pr` included. Otherwise plain `task claim` is right: it switches
192
194
  you to the claim branch, and prints a reminder about `--lock-only` if it just
@@ -294,6 +296,19 @@ This changes nothing for the other commands: they still operate on the
294
296
  nearest `.task/` walking up from the current directory, so `cd` into the
295
297
  package whose board you mean before running them.
296
298
 
299
+ `task export [--out dir] [--base path] [--force] [--no-functions]` writes that same board out as static
300
+ files — the UI plus a `snapshot.json` it reads instead of an API — for a CI
301
+ job to put on a static host, read-only and with a staleness banner. It's a
302
+ build step, not a way to look at the board: use `task serve` for that, and
303
+ `--json` for anything you need to read yourself. Asked to deploy the board,
304
+ follow the README's "Deploy the board" recipe rather than inventing a workflow
305
+ — note it writes an SPA fallback (`_redirects`) that `task export` does not.
306
+ The export also writes a Cloudflare Pages Function that stays dormant until
307
+ the host has a `TASK_GITHUB_TOKEN` secret (a fine-grained PAT, Contents:
308
+ read-only), then reads the repo live — with no viewer auth, which the README's
309
+ "Live data for an exported board" spells out. `--no-functions` skips it.
310
+ Never mint or paste a token yourself.
311
+
297
312
  On a git checkout with an origin, the served UI also carries a git surface:
298
313
  a branch chip (current branch, dirty board files, ahead/behind), board-scoped
299
314
  commits (`chore(board): …`, pathspec-limited to `.task/` so unrelated dirty
package/src/asks.test.ts CHANGED
@@ -275,6 +275,17 @@ test("a claimed ticket's asks reach the inbox from its branch", async () => {
275
275
  sh(verify, "git", "show", `FETCH_HEAD:.task/tickets/${key}/asks/${askFile}`),
276
276
  /resolved_by: "nick"/,
277
277
  )
278
+ // The trailer contract (trailers.ts): the resolve landed as its own commit
279
+ // whose Task-Op/Task-Id automation keys on — pinned, because a poke
280
+ // workflow reads exactly these off the head of the push.
281
+ assert.equal(
282
+ sh(verify, "git", "log", "-1", "--format=%(trailers:key=Task-Op,valueonly)", "FETCH_HEAD"),
283
+ "resolve-ask",
284
+ )
285
+ assert.equal(
286
+ sh(verify, "git", "log", "-1", "--format=%(trailers:key=Task-Id,valueonly)", "FETCH_HEAD"),
287
+ `CIN-${key}`,
288
+ )
278
289
  const cleared = await cli(owner, "inbox", "--json")
279
290
  assert.equal((JSON.parse(cleared.stdout) as { inbox: InboxEntry[] }).inbox.length, 0)
280
291
 
@@ -289,6 +300,10 @@ test("a claimed ticket's asks reach the inbox from its branch", async () => {
289
300
  sh(verify, "git", "ls-tree", "--name-only", `FETCH_HEAD:.task/tickets/${key}/asks`).split("\n").length,
290
301
  2,
291
302
  )
303
+ assert.equal(
304
+ sh(verify, "git", "log", "-1", "--format=%(trailers:key=Task-Op,valueonly)", "FETCH_HEAD"),
305
+ "ask",
306
+ )
292
307
  const again = await cli(owner, "inbox", "--json")
293
308
  const entries = (JSON.parse(again.stdout) as { inbox: InboxEntry[] }).inbox
294
309
  assert.deepEqual(entries[0].asks.map((a) => a.text), ["Also rotate the old key"])
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Reading a repository from GitHub, with a bearer token and nothing else.
3
+ *
4
+ * This is the read half of the hosted board's `worker/github.ts`, moved here
5
+ * so an exported board's live proxy (`handler.ts`) and the hosted worker read
6
+ * a repository through the same four calls. What deliberately did *not* move
7
+ * with it: everything that mints or exchanges a credential — App JWTs,
8
+ * installation tokens, the viewer's OAuth flow. Those are the hosted
9
+ * product's auth surface and stay in the worker; here a token is a string
10
+ * somebody already holds (a fine-grained PAT, an installation token the
11
+ * worker minted), and this module only ever spends it on reads.
12
+ *
13
+ * Web-standard only — `fetch`, `Response`, `DecompressionStream` — so the same
14
+ * source runs under Node 22, a Cloudflare Worker, and a Pages Function.
15
+ */
16
+
17
+ import { readTar, TarError, type TarContents } from "./tar.ts"
18
+
19
+ const API = "https://api.github.com"
20
+
21
+ /** Sent on every call; GitHub rejects requests without a User-Agent. */
22
+ const UA = "@nickmeriano/task"
23
+
24
+ export class GitHubError extends Error {
25
+ readonly status: number
26
+ constructor(status: number, message: string) {
27
+ super(message)
28
+ this.name = "GitHubError"
29
+ this.status = status
30
+ }
31
+ }
32
+
33
+ async function apiGet<T>(token: string, path: string): Promise<T> {
34
+ const res = await fetch(`${API}${path}`, {
35
+ headers: {
36
+ Authorization: `Bearer ${token}`,
37
+ Accept: "application/vnd.github+json",
38
+ "User-Agent": UA,
39
+ },
40
+ })
41
+ if (!res.ok) throw new GitHubError(res.status, `GET ${path} failed (${res.status})`)
42
+ return (await res.json()) as T
43
+ }
44
+
45
+ export interface RepoInfo {
46
+ defaultBranch: string
47
+ private: boolean
48
+ }
49
+
50
+ export async function repoInfo(token: string, owner: string, repo: string): Promise<RepoInfo> {
51
+ const data = await apiGet<{ default_branch: string; private: boolean }>(
52
+ token,
53
+ `/repos/${owner}/${repo}`,
54
+ )
55
+ return { defaultBranch: data.default_branch, private: data.private }
56
+ }
57
+
58
+ /**
59
+ * Branch names, for the board's branch switcher. One page of 100: a selector
60
+ * over more branches than that stops being a selector, and the switcher's
61
+ * search box only filters what's listed. Sorting is the caller's job — it
62
+ * knows the default branch; this endpoint doesn't.
63
+ */
64
+ export async function listBranches(token: string, owner: string, repo: string): Promise<string[]> {
65
+ const data = await apiGet<Array<{ name: string }>>(
66
+ token,
67
+ `/repos/${owner}/${repo}/branches?per_page=100`,
68
+ )
69
+ return data.map((branch) => branch.name)
70
+ }
71
+
72
+ /** A commit's identity, so the board can say how fresh it is. */
73
+ export interface CommitInfo {
74
+ sha: string
75
+ committedAt: string
76
+ message: string
77
+ }
78
+
79
+ export async function headCommit(
80
+ token: string,
81
+ owner: string,
82
+ repo: string,
83
+ ref: string,
84
+ ): Promise<CommitInfo> {
85
+ const data = await apiGet<{
86
+ sha: string
87
+ commit: { message: string; committer: { date: string } }
88
+ }>(token, `/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`)
89
+ return {
90
+ sha: data.sha,
91
+ committedAt: data.commit.committer.date,
92
+ message: data.commit.message.split("\n")[0],
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Did GitHub just say "that ref doesn't exist"?
98
+ *
99
+ * It says it two different ways, and the obvious one is the rarer one. Asking
100
+ * `/repos/{o}/{r}/commits/{ref}` for a branch that isn't there answers **422**
101
+ * ("No commit found for SHA: …"), not 404 — 404 is what you get when the
102
+ * repository itself is out of reach. Checking only for 404 let every dead
103
+ * `?ref=` link fall through to a generic "GitHub is unhappy — try again"
104
+ * screen: a retry prompt for something no amount of retrying will fix.
105
+ */
106
+ export function isMissingRef(error: unknown): boolean {
107
+ return error instanceof GitHubError && (error.status === 404 || error.status === 422)
108
+ }
109
+
110
+ /**
111
+ * The whole repository at `sha`, in one request: every file path, plus the
112
+ * bytes of the paths `keep` selects.
113
+ *
114
+ * This replaced two earlier shapes, each broken by arithmetic. Per-file
115
+ * contents reads were one subrequest per ticket, against the 50-subrequest
116
+ * ceiling Workers get on the free plan — every cold board view of this repo
117
+ * 500'd. Batched GraphQL blob reads fixed that, but still cost one tree
118
+ * listing plus one request per 80 files, scaling with board size. A tarball
119
+ * is a single request whatever the repo holds, and it carries the path
120
+ * listing too. Streamed through gzip and a tar walk, keeping bytes only for
121
+ * `keep`-selected paths, so memory is bounded by the boards rather than the
122
+ * repository.
123
+ *
124
+ * The tarball endpoint answers with a redirect to codeload; fetch follows it.
125
+ */
126
+ export async function fetchRepoArchive(
127
+ token: string,
128
+ owner: string,
129
+ repo: string,
130
+ sha: string,
131
+ keep: (path: string) => boolean,
132
+ ): Promise<TarContents> {
133
+ const res = await fetch(`${API}/repos/${owner}/${repo}/tarball/${encodeURIComponent(sha)}`, {
134
+ headers: {
135
+ Authorization: `Bearer ${token}`,
136
+ Accept: "application/vnd.github+json",
137
+ "User-Agent": UA,
138
+ },
139
+ })
140
+ if (!res.ok || !res.body) {
141
+ throw new GitHubError(res.status, `could not read the repository archive (${res.status})`)
142
+ }
143
+ try {
144
+ return await readTar(res.body.pipeThrough(new DecompressionStream("gzip")), keep)
145
+ } catch (error) {
146
+ if (error instanceof TarError) {
147
+ throw new GitHubError(502, `could not read the repository archive (${error.message})`)
148
+ }
149
+ throw error
150
+ }
151
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * The live proxy, against a fake GitHub.
3
+ *
4
+ * `globalThis.fetch` is replaced with a tiny router that answers the four
5
+ * calls the handler makes — head commit, tarball, branches, repo info — from
6
+ * an in-memory repository, so what's under test is the handler's routing,
7
+ * caching and error shapes, not the network. The payloads themselves are
8
+ * pinned elsewhere (`source.test.ts` diffs them against the CLI's store).
9
+ */
10
+
11
+ import assert from "node:assert/strict"
12
+ import { afterEach, beforeEach, mock, test } from "node:test"
13
+ import { gzipSync } from "node:zlib"
14
+ import { createBoardHandler, type BoardHandler } from "./handler.ts"
15
+
16
+ const encoder = new TextEncoder()
17
+
18
+ /** Just enough tar to be a GitHub tarball: ustar headers, one root dir. */
19
+ function tarball(root: string, files: Record<string, string>): Uint8Array {
20
+ const blocks: Uint8Array[] = []
21
+ for (const [path, text] of Object.entries(files)) {
22
+ const data = encoder.encode(text)
23
+ const header = new Uint8Array(512)
24
+ header.set(encoder.encode(`${root}/${path}`).subarray(0, 100), 0)
25
+ header.set(encoder.encode("0000644\0"), 100)
26
+ header.set(encoder.encode(data.length.toString(8).padStart(11, "0") + "\0"), 124)
27
+ header.set(encoder.encode("00000000000\0"), 136)
28
+ header[156] = 0x30
29
+ header.set(encoder.encode("ustar\x0000"), 257)
30
+ header.set(encoder.encode(" "), 148)
31
+ const sum = header.reduce((a, b) => a + b, 0)
32
+ header.set(encoder.encode(sum.toString(8).padStart(6, "0") + "\0 "), 148)
33
+ const padded = new Uint8Array(Math.ceil(data.length / 512) * 512)
34
+ padded.set(data)
35
+ blocks.push(header, padded)
36
+ }
37
+ blocks.push(new Uint8Array(1024))
38
+ const out = new Uint8Array(blocks.reduce((n, b) => n + b.length, 0))
39
+ let at = 0
40
+ for (const block of blocks) {
41
+ out.set(block, at)
42
+ at += block.length
43
+ }
44
+ return out
45
+ }
46
+
47
+ const CONFIG = JSON.stringify({ name: "acme", prefix: "ACM", version: 2 })
48
+ const ticket = (title: string, status: string, position = 0) =>
49
+ `---\nstatus: ${status}\nposition: ${position}\ncreated: 2026-01-01T00:00:00.000Z\nupdated: 2026-01-01T00:00:00.000Z\n---\n\n# ${title}\n`
50
+
51
+ /** Two commits: `main` at `aaa`, `feature` at `bbb`, each its own board state. */
52
+ const COMMITS: Record<string, string> = { HEAD: "aaa", main: "aaa", feature: "bbb" }
53
+ const TREES: Record<string, Record<string, string>> = {
54
+ aaa: {
55
+ ".task/config.json": CONFIG,
56
+ ".task/tickets/x7k4m/ticket.md": ticket("Ship it", "todo"),
57
+ ".task/tickets/x7k4m/comments/2026-01-02T000000-nick.md":
58
+ '---\nauthor: "nick"\ncreated: 2026-01-02T00:00:00.000Z\n---\n\nsoon',
59
+ ".task/goals/launch.md":
60
+ "---\ncreated: 2026-01-01T00:00:00.000Z\nupdated: 2026-01-01T00:00:00.000Z\n---\n\n# Launch\n",
61
+ "pkg/.task/config.json": JSON.stringify({ name: "pkg", prefix: "PKG", version: 2 }),
62
+ "pkg/.task/tickets/q3v8d/ticket.md": ticket("Nested", "backlog"),
63
+ },
64
+ bbb: {
65
+ ".task/config.json": CONFIG,
66
+ ".task/tickets/x7k4m/ticket.md": ticket("Ship it", "done"),
67
+ ".task/tickets/zz9zz/ticket.md": ticket("Only on the branch", "todo", 1),
68
+ },
69
+ }
70
+
71
+ interface Call {
72
+ path: string
73
+ auth: string | null
74
+ }
75
+
76
+ let calls: Call[] = []
77
+ let githubStatus: number | null = null
78
+
79
+ function fakeGitHub(input: string | URL | Request, init?: RequestInit): Promise<Response> {
80
+ const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url)
81
+ const auth = new Headers(init?.headers).get("Authorization")
82
+ calls.push({ path: url.pathname, auth })
83
+ if (githubStatus !== null) return Promise.resolve(new Response("nope", { status: githubStatus }))
84
+
85
+ const commit = /^\/repos\/acme\/widgets\/commits\/(.+)$/.exec(url.pathname)
86
+ if (commit) {
87
+ const sha = COMMITS[decodeURIComponent(commit[1])]
88
+ if (!sha) return Promise.resolve(new Response("{}", { status: 422 }))
89
+ return Promise.resolve(
90
+ Response.json({
91
+ sha,
92
+ commit: { message: `commit ${sha}\n\nbody`, committer: { date: "2026-01-03T00:00:00Z" } },
93
+ }),
94
+ )
95
+ }
96
+ const tar = /^\/repos\/acme\/widgets\/tarball\/(.+)$/.exec(url.pathname)
97
+ if (tar) {
98
+ const tree = TREES[tar[1]]
99
+ if (!tree) return Promise.resolve(new Response(null, { status: 404 }))
100
+ return Promise.resolve(new Response(gzipSync(tarball(`acme-widgets-${tar[1]}`, tree))))
101
+ }
102
+ if (url.pathname === "/repos/acme/widgets/branches") {
103
+ return Promise.resolve(Response.json([{ name: "feature" }, { name: "main" }, { name: "docs" }]))
104
+ }
105
+ if (url.pathname === "/repos/acme/widgets") {
106
+ return Promise.resolve(Response.json({ default_branch: "main", private: true }))
107
+ }
108
+ return Promise.resolve(new Response(null, { status: 404 }))
109
+ }
110
+
111
+ beforeEach(() => {
112
+ calls = []
113
+ githubStatus = null
114
+ mock.method(globalThis, "fetch", fakeGitHub)
115
+ })
116
+ afterEach(() => mock.restoreAll())
117
+
118
+ function handler(extra: Partial<Parameters<typeof createBoardHandler>[0]> = {}): BoardHandler {
119
+ return createBoardHandler({ token: "ghp_test", repo: "acme/widgets", ...extra })
120
+ }
121
+
122
+ async function get(h: BoardHandler, path: string, base = "/api") {
123
+ const res = await h(new Request(`https://board.example${base}${path}`))
124
+ return { status: res.status, body: (await res.json()) as Record<string, unknown> }
125
+ }
126
+
127
+ test("answers the read routes in the serve API's shapes", async () => {
128
+ const h = handler()
129
+
130
+ const project = await get(h, "/project")
131
+ assert.equal(project.status, 200)
132
+ assert.deepEqual(project.body, {
133
+ name: "acme",
134
+ prefix: "ACM",
135
+ statuses: ["backlog", "todo", "in_progress", "done", "canceled"],
136
+ readOnly: true,
137
+ author: "",
138
+ })
139
+
140
+ const boards = await get(h, "/boards")
141
+ assert.deepEqual(boards.body, {
142
+ boards: [
143
+ { id: ".", name: "acme", prefix: "ACM" },
144
+ { id: "pkg", name: "pkg", prefix: "PKG" },
145
+ ],
146
+ })
147
+
148
+ const tasks = await get(h, "/tasks")
149
+ const list = tasks.body.tasks as Array<Record<string, unknown>>
150
+ assert.deepEqual(
151
+ list.map((t) => [t.id, t.status, t.commentCount]),
152
+ [["ACM-x7k4m", "todo", 1]],
153
+ )
154
+
155
+ const goals = await get(h, "/goals")
156
+ assert.deepEqual((goals.body.goals as Array<{ slug: string }>).map((g) => g.slug), ["launch"])
157
+
158
+ // Detail takes the display id or the bare key, like the CLI.
159
+ for (const id of ["ACM-x7k4m", "x7k4m"]) {
160
+ const detail = await get(h, `/tasks/${id}`)
161
+ assert.equal(detail.status, 200, id)
162
+ assert.equal((detail.body.task as { title: string }).title, "Ship it")
163
+ assert.deepEqual(
164
+ (detail.body.comments as Array<{ author: string; body: string }>).map((c) => [c.author, c.body]),
165
+ [["nick", "soon"]],
166
+ )
167
+ }
168
+ assert.equal((await get(h, "/tasks/nope1")).status, 404)
169
+ assert.equal((await get(h, "/tasks/not%20an%20id")).status, 400)
170
+
171
+ // `?board=` picks the nested board; an unknown one falls back to the default.
172
+ const nested = await get(h, "/tasks?board=pkg")
173
+ assert.deepEqual((nested.body.tasks as Array<{ id: string }>).map((t) => t.id), ["PKG-q3v8d"])
174
+ assert.equal((await get(h, "/project?board=nope")).body.prefix, "ACM")
175
+
176
+ // Not in this API: the SPA degrades on a 404 exactly as it does hosted.
177
+ assert.equal((await get(h, "/inbox")).status, 404)
178
+ assert.equal((await get(h, "/git/status")).status, 404)
179
+ })
180
+
181
+ test("anything but a read is refused with the read-only reason", async () => {
182
+ const h = handler()
183
+ const res = await h(
184
+ new Request("https://board.example/api/tasks", { method: "POST", body: "{}" }),
185
+ )
186
+ assert.equal(res.status, 405)
187
+ assert.equal(((await res.json()) as { reason: string }).reason, "read-only")
188
+ // Refused before GitHub is asked anything.
189
+ assert.equal(calls.length, 0)
190
+ })
191
+
192
+ test("?ref= reads another branch; a missing ref is a 404 the SPA can name", async () => {
193
+ const h = handler()
194
+ const branch = await get(h, "/tasks?ref=feature")
195
+ assert.deepEqual(
196
+ (branch.body.tasks as Array<{ id: string; status: string }>).map((t) => [t.id, t.status]),
197
+ [
198
+ ["ACM-x7k4m", "done"],
199
+ ["ACM-zz9zz", "todo"],
200
+ ],
201
+ )
202
+
203
+ const gone = await get(h, "/tasks?ref=gone")
204
+ assert.equal(gone.status, 404)
205
+ assert.equal(gone.body.reason, "no-such-ref")
206
+
207
+ // A ref that isn't a git name is ignored rather than sent to GitHub.
208
+ const bad = await get(h, "/tasks?ref=..%2F..%2Fetc")
209
+ assert.equal(bad.status, 200)
210
+ assert.ok(calls.every((c) => !c.path.includes("etc")))
211
+ })
212
+
213
+ test("/branches lists the repo's branches, default first", async () => {
214
+ const h = handler()
215
+ const res = await get(h, "/branches")
216
+ assert.deepEqual(res.body, {
217
+ branches: [
218
+ { name: "main", isDefault: true },
219
+ { name: "docs", isDefault: false },
220
+ { name: "feature", isDefault: false },
221
+ ],
222
+ })
223
+ })
224
+
225
+ test("one tarball per commit, however many requests ask", async () => {
226
+ const h = handler()
227
+ // The SPA's boot: four requests at once, before anything is cached.
228
+ await Promise.all([get(h, "/boards"), get(h, "/project"), get(h, "/tasks"), get(h, "/goals")])
229
+ await get(h, "/tasks/x7k4m")
230
+ const tarballs = calls.filter((c) => c.path.includes("/tarball/"))
231
+ assert.equal(tarballs.length, 1, "the tarball was fetched more than once for one sha")
232
+ // HEAD is asked every time — that is the freshness check.
233
+ assert.equal(calls.filter((c) => c.path.includes("/commits/")).length, 5)
234
+ // Another ref is another commit, hence another tarball; then cached too.
235
+ await get(h, "/tasks?ref=feature")
236
+ await get(h, "/tasks?ref=feature")
237
+ assert.equal(calls.filter((c) => c.path.includes("/tarball/")).length, 2)
238
+ })
239
+
240
+ test("the token can be a getter, and no token is a 503 the probe reads as no API", async () => {
241
+ let minted = 0
242
+ const h = handler({
243
+ token: async () => {
244
+ minted++
245
+ return "ghp_minted"
246
+ },
247
+ })
248
+ assert.equal((await get(h, "/project")).status, 200)
249
+ assert.equal(minted, 1)
250
+ assert.equal(calls[0].auth, "Bearer ghp_minted")
251
+
252
+ const unset = handler({ token: () => "" })
253
+ const res = await get(unset, "/project")
254
+ assert.equal(res.status, 503)
255
+ assert.equal(res.body.reason, "no-token")
256
+ })
257
+
258
+ test("mounted under a base path, only that path is the API", async () => {
259
+ const h = handler({ basePath: "/board/api" })
260
+ assert.equal((await get(h, "/project", "/board/api")).status, 200)
261
+ assert.equal((await get(h, "/project", "/api")).status, 404)
262
+ assert.equal((await get(h, "", "/board/api")).status, 404)
263
+ })
264
+
265
+ test("GitHub refusing the token is a 502, not a board", async () => {
266
+ githubStatus = 401
267
+ const res = await get(handler(), "/project")
268
+ assert.equal(res.status, 502)
269
+ assert.equal(res.body.reason, "github")
270
+ assert.match(String(res.body.error), /401/)
271
+ })
272
+
273
+ test("a repo that isn't owner/name is refused at construction", () => {
274
+ assert.throws(() => createBoardHandler({ token: "t", repo: "not a repo" }), /owner\/name/)
275
+ assert.throws(() => createBoardHandler({ token: "t", repo: "a/b/c" }), /owner\/name/)
276
+ })