@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,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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tar walk is hand-written against a binary format, so it's tested the
|
|
3
|
+
* way the SQLite reader used to be: against archives built independently —
|
|
4
|
+
* here by a minimal tar *writer* that produces the same shapes GitHub's
|
|
5
|
+
* tarballs contain (ustar headers, a pax_global_header, pax path overrides,
|
|
6
|
+
* a wrapping root directory), gzipped through node:zlib.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict"
|
|
10
|
+
import { test } from "node:test"
|
|
11
|
+
import { gzipSync } from "node:zlib"
|
|
12
|
+
import { readTar, TarError } from "./tar.ts"
|
|
13
|
+
|
|
14
|
+
const encoder = new TextEncoder()
|
|
15
|
+
|
|
16
|
+
interface Entry {
|
|
17
|
+
name: string
|
|
18
|
+
type?: string
|
|
19
|
+
data?: string
|
|
20
|
+
/** Extra pax header naming this entry, the way GitHub emits long paths. */
|
|
21
|
+
paxPath?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function header(name: string, type: string, size: number): Uint8Array {
|
|
25
|
+
const block = new Uint8Array(512)
|
|
26
|
+
block.set(encoder.encode(name).subarray(0, 100), 0)
|
|
27
|
+
block.set(encoder.encode("0000644\0"), 100)
|
|
28
|
+
block.set(encoder.encode(size.toString(8).padStart(11, "0") + "\0"), 124)
|
|
29
|
+
block.set(encoder.encode("00000000000\0"), 136)
|
|
30
|
+
block[156] = type.charCodeAt(0)
|
|
31
|
+
block.set(encoder.encode("ustar\x0000"), 257)
|
|
32
|
+
// Checksum: field treated as spaces while summing.
|
|
33
|
+
block.set(encoder.encode(" "), 148)
|
|
34
|
+
const sum = block.reduce((a, b) => a + b, 0)
|
|
35
|
+
block.set(encoder.encode(sum.toString(8).padStart(6, "0") + "\0 "), 148)
|
|
36
|
+
return block
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tarball(entries: Entry[]): Uint8Array {
|
|
40
|
+
const blocks: Uint8Array[] = []
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.paxPath) {
|
|
43
|
+
const record = ` path=${entry.paxPath}\n`
|
|
44
|
+
const body = `${record.length + String(record.length + 2).length}${record}`
|
|
45
|
+
const bytes = encoder.encode(body)
|
|
46
|
+
blocks.push(header("./PaxHeaders/x", "x", bytes.length), pad(bytes))
|
|
47
|
+
}
|
|
48
|
+
const data = encoder.encode(entry.data ?? "")
|
|
49
|
+
blocks.push(header(entry.name, entry.type ?? "0", data.length))
|
|
50
|
+
if (data.length > 0) blocks.push(pad(data))
|
|
51
|
+
}
|
|
52
|
+
blocks.push(new Uint8Array(1024))
|
|
53
|
+
const total = blocks.reduce((n, b) => n + b.length, 0)
|
|
54
|
+
const out = new Uint8Array(total)
|
|
55
|
+
let at = 0
|
|
56
|
+
for (const block of blocks) {
|
|
57
|
+
out.set(block, at)
|
|
58
|
+
at += block.length
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pad(data: Uint8Array): Uint8Array {
|
|
64
|
+
const padded = new Uint8Array(Math.ceil(data.length / 512) * 512)
|
|
65
|
+
padded.set(data)
|
|
66
|
+
return padded
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
70
|
+
return new Response(gzipSync(bytes)).body!.pipeThrough(
|
|
71
|
+
new DecompressionStream("gzip"),
|
|
72
|
+
) as ReadableStream<Uint8Array>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test("walks a GitHub-shaped tarball: root stripped, kept bytes kept, rest skipped", async () => {
|
|
76
|
+
const bytes = tarball([
|
|
77
|
+
{ name: "pax_global_header", type: "g", data: "52 comment=1234\n" },
|
|
78
|
+
{ name: "owner-repo-abc123/", type: "5" },
|
|
79
|
+
{ name: "owner-repo-abc123/README.md", data: "# hi" },
|
|
80
|
+
{ name: "owner-repo-abc123/.task/config.json", data: '{"name":"x","prefix":"X","version":2}' },
|
|
81
|
+
{ name: "owner-repo-abc123/.task/tickets/1/ticket.md", data: "---\nstatus: todo\n---\n\n# T\n" },
|
|
82
|
+
{ name: "owner-repo-abc123/big/blob.bin", data: "z".repeat(5000) },
|
|
83
|
+
{ name: "owner-repo-abc123/pkg/.task/config.json", data: "{}" },
|
|
84
|
+
])
|
|
85
|
+
const { paths, files } = await readTar(stream(bytes), (path) => path.includes(".task/"))
|
|
86
|
+
assert.deepEqual(paths, [
|
|
87
|
+
"README.md",
|
|
88
|
+
".task/config.json",
|
|
89
|
+
".task/tickets/1/ticket.md",
|
|
90
|
+
"big/blob.bin",
|
|
91
|
+
"pkg/.task/config.json",
|
|
92
|
+
])
|
|
93
|
+
assert.deepEqual([...files.keys()], [
|
|
94
|
+
".task/config.json",
|
|
95
|
+
".task/tickets/1/ticket.md",
|
|
96
|
+
"pkg/.task/config.json",
|
|
97
|
+
])
|
|
98
|
+
assert.equal(new TextDecoder().decode(files.get(".task/tickets/1/ticket.md")), "---\nstatus: todo\n---\n\n# T\n")
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("pax path records override the header name, as GitHub does for long paths", async () => {
|
|
102
|
+
const longPath = `owner-repo-abc123/${"deeply/".repeat(20)}.task/config.json`
|
|
103
|
+
const bytes = tarball([
|
|
104
|
+
{ name: "owner-repo-abc123/x", paxPath: longPath, data: "{}" },
|
|
105
|
+
])
|
|
106
|
+
const { paths, files } = await readTar(stream(bytes), () => true)
|
|
107
|
+
assert.deepEqual(paths, [longPath.slice("owner-repo-abc123/".length)])
|
|
108
|
+
assert.equal(files.size, 1)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("ustar prefix fields are joined back onto the name", async () => {
|
|
112
|
+
const bytes = tarball([{ name: "owner-repo-abc123/plain.txt", data: "ok" }])
|
|
113
|
+
// Craft one entry using the prefix field by hand.
|
|
114
|
+
const block = header("tail.txt", "0", 2)
|
|
115
|
+
block.set(encoder.encode("owner-repo-abc123/some/dir"), 345)
|
|
116
|
+
const data = pad(encoder.encode("ok"))
|
|
117
|
+
const joined = new Uint8Array(bytes.length + block.length + data.length)
|
|
118
|
+
joined.set(block, 0)
|
|
119
|
+
joined.set(data, block.length)
|
|
120
|
+
joined.set(bytes, block.length + data.length)
|
|
121
|
+
const { paths } = await readTar(stream(joined), () => false)
|
|
122
|
+
assert.deepEqual(paths, ["some/dir/tail.txt", "plain.txt"])
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("a truncated archive throws instead of returning half a repository", async () => {
|
|
126
|
+
const bytes = tarball([{ name: "owner-repo-abc123/a.txt", data: "hello" }])
|
|
127
|
+
await assert.rejects(readTar(stream(bytes.subarray(0, 600)), () => true), TarError)
|
|
128
|
+
})
|