@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
|
@@ -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
|
+
})
|