@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.
- package/README.md +232 -1
- package/dist/asks.test.js +6 -0
- package/dist/asks.test.js.map +1 -1
- package/dist/board/github.d.ts +68 -0
- package/dist/board/github.d.ts.map +1 -0
- package/dist/board/github.js +112 -0
- package/dist/board/github.js.map +1 -0
- package/dist/board/handler.d.ts +50 -0
- package/dist/board/handler.d.ts.map +1 -0
- package/dist/board/handler.js +183 -0
- package/dist/board/handler.js.map +1 -0
- package/dist/board/handler.test.d.ts +11 -0
- package/dist/board/handler.test.d.ts.map +1 -0
- package/dist/board/handler.test.js +229 -0
- package/dist/board/handler.test.js.map +1 -0
- package/dist/board/pages-function.d.ts +23 -0
- package/dist/board/pages-function.d.ts.map +1 -0
- package/dist/board/pages-function.js +34 -0
- package/dist/board/pages-function.js.map +1 -0
- package/dist/board/source.d.ts +118 -0
- package/dist/board/source.d.ts.map +1 -0
- package/dist/board/source.js +333 -0
- package/dist/board/source.js.map +1 -0
- package/dist/board/source.test.d.ts +12 -0
- package/dist/board/source.test.d.ts.map +1 -0
- package/dist/board/source.test.js +165 -0
- package/dist/board/source.test.js.map +1 -0
- package/dist/board/tar.d.ts +28 -0
- package/dist/board/tar.d.ts.map +1 -0
- package/dist/board/tar.js +188 -0
- package/dist/board/tar.js.map +1 -0
- package/dist/board/tar.test.d.ts +9 -0
- package/dist/board/tar.test.d.ts.map +1 -0
- package/dist/board/tar.test.js +110 -0
- package/dist/board/tar.test.js.map +1 -0
- package/dist/claim-io.d.ts.map +1 -1
- package/dist/claim-io.js +4 -3
- package/dist/claim-io.js.map +1 -1
- package/dist/claim.d.ts +14 -2
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +9 -3
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.js +41 -1
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +127 -7
- package/dist/cli.js.map +1 -1
- package/dist/export.d.ts +163 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +259 -0
- package/dist/export.js.map +1 -0
- package/dist/export.test.d.ts +12 -0
- package/dist/export.test.d.ts.map +1 -0
- package/dist/export.test.js +264 -0
- package/dist/export.test.js.map +1 -0
- package/dist/functions/board.js +709 -0
- package/dist/git-serve.d.ts +14 -1
- package/dist/git-serve.d.ts.map +1 -1
- package/dist/git-serve.js +30 -2
- package/dist/git-serve.js.map +1 -1
- package/dist/git-serve.test.d.ts +1 -0
- package/dist/git-serve.test.d.ts.map +1 -1
- package/dist/git-serve.test.js +36 -1
- package/dist/git-serve.test.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +6 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +12 -3
- package/dist/server.js.map +1 -1
- package/dist/trailers.d.ts +51 -0
- package/dist/trailers.d.ts.map +1 -0
- package/dist/trailers.js +32 -0
- package/dist/trailers.js.map +1 -0
- package/package.json +3 -3
- package/skill/SKILL.md +16 -1
- package/src/asks.test.ts +15 -0
- package/src/board/github.ts +151 -0
- package/src/board/handler.test.ts +276 -0
- package/src/board/handler.ts +228 -0
- package/src/board/pages-function.ts +42 -0
- package/src/board/source.test.ts +203 -0
- package/src/board/source.ts +422 -0
- package/src/board/tar.test.ts +128 -0
- package/src/board/tar.ts +199 -0
- package/src/claim-io.ts +12 -3
- package/src/claim.test.ts +56 -1
- package/src/claim.ts +27 -4
- package/src/cli.ts +127 -7
- package/src/export.test.ts +361 -0
- package/src/export.ts +354 -0
- package/src/git-serve.test.ts +41 -1
- package/src/git-serve.ts +30 -2
- package/src/index.ts +10 -0
- package/src/server.ts +12 -3
- package/src/trailers.ts +65 -0
- package/ui/dist/assets/index-B8M_DaOt.js +229 -0
- package/ui/dist/assets/{index-CoKCUYic.css → index-eHsqltgs.css} +1 -1
- package/ui/dist/index.html +3 -3
- 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.
|
|
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 (`
|
|
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
|
+
})
|