@nickmeriano/task 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +175 -1
  2. package/dist/board/github.d.ts +68 -0
  3. package/dist/board/github.d.ts.map +1 -0
  4. package/dist/board/github.js +112 -0
  5. package/dist/board/github.js.map +1 -0
  6. package/dist/board/handler.d.ts +50 -0
  7. package/dist/board/handler.d.ts.map +1 -0
  8. package/dist/board/handler.js +183 -0
  9. package/dist/board/handler.js.map +1 -0
  10. package/dist/board/handler.test.d.ts +11 -0
  11. package/dist/board/handler.test.d.ts.map +1 -0
  12. package/dist/board/handler.test.js +229 -0
  13. package/dist/board/handler.test.js.map +1 -0
  14. package/dist/board/pages-function.d.ts +23 -0
  15. package/dist/board/pages-function.d.ts.map +1 -0
  16. package/dist/board/pages-function.js +34 -0
  17. package/dist/board/pages-function.js.map +1 -0
  18. package/dist/board/source.d.ts +118 -0
  19. package/dist/board/source.d.ts.map +1 -0
  20. package/dist/board/source.js +333 -0
  21. package/dist/board/source.js.map +1 -0
  22. package/dist/board/source.test.d.ts +12 -0
  23. package/dist/board/source.test.d.ts.map +1 -0
  24. package/dist/board/source.test.js +165 -0
  25. package/dist/board/source.test.js.map +1 -0
  26. package/dist/board/tar.d.ts +28 -0
  27. package/dist/board/tar.d.ts.map +1 -0
  28. package/dist/board/tar.js +188 -0
  29. package/dist/board/tar.js.map +1 -0
  30. package/dist/board/tar.test.d.ts +9 -0
  31. package/dist/board/tar.test.d.ts.map +1 -0
  32. package/dist/board/tar.test.js +110 -0
  33. package/dist/board/tar.test.js.map +1 -0
  34. package/dist/cli.js +37 -2
  35. package/dist/cli.js.map +1 -1
  36. package/dist/export.d.ts +31 -0
  37. package/dist/export.d.ts.map +1 -1
  38. package/dist/export.js +61 -1
  39. package/dist/export.js.map +1 -1
  40. package/dist/export.test.js +80 -1
  41. package/dist/export.test.js.map +1 -1
  42. package/dist/functions/board.js +709 -0
  43. package/dist/git-serve.d.ts +14 -1
  44. package/dist/git-serve.d.ts.map +1 -1
  45. package/dist/git-serve.js +30 -2
  46. package/dist/git-serve.js.map +1 -1
  47. package/dist/git-serve.test.d.ts +1 -0
  48. package/dist/git-serve.test.d.ts.map +1 -1
  49. package/dist/git-serve.test.js +36 -1
  50. package/dist/git-serve.test.js.map +1 -1
  51. package/dist/index.d.ts +2 -0
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +6 -0
  54. package/dist/index.js.map +1 -1
  55. package/package.json +3 -3
  56. package/skill/SKILL.md +10 -3
  57. package/src/board/github.ts +151 -0
  58. package/src/board/handler.test.ts +276 -0
  59. package/src/board/handler.ts +228 -0
  60. package/src/board/pages-function.ts +42 -0
  61. package/src/board/source.test.ts +203 -0
  62. package/src/board/source.ts +422 -0
  63. package/src/board/tar.test.ts +128 -0
  64. package/src/board/tar.ts +199 -0
  65. package/src/cli.ts +36 -2
  66. package/src/export.test.ts +108 -1
  67. package/src/export.ts +79 -1
  68. package/src/git-serve.test.ts +41 -1
  69. package/src/git-serve.ts +30 -2
  70. package/src/index.ts +10 -0
  71. package/ui/dist/assets/index-B8M_DaOt.js +229 -0
  72. package/ui/dist/index.html +1 -1
  73. package/ui/dist/assets/index-mJmm4sWq.js +0 -229
@@ -0,0 +1,709 @@
1
+ class TarError extends Error {
2
+ }
3
+ const BLOCK = 512;
4
+ class ByteReader {
5
+ reader;
6
+ chunks = [];
7
+ have = 0;
8
+ done = false;
9
+ constructor(stream) {
10
+ this.reader = stream.getReader();
11
+ }
12
+ async pull() {
13
+ if (this.done) return false;
14
+ const { done, value } = await this.reader.read();
15
+ if (done) {
16
+ this.done = true;
17
+ return false;
18
+ }
19
+ if (value && value.length > 0) {
20
+ this.chunks.push(value);
21
+ this.have += value.length;
22
+ }
23
+ return true;
24
+ }
25
+ /** Exactly `n` bytes; null at a clean end-of-stream, throws mid-entry. */
26
+ async read(n) {
27
+ while (this.have < n) {
28
+ if (!await this.pull()) {
29
+ if (this.have === 0) return null;
30
+ throw new TarError("truncated archive");
31
+ }
32
+ }
33
+ const out = new Uint8Array(n);
34
+ let filled = 0;
35
+ while (filled < n) {
36
+ const head = this.chunks[0];
37
+ const take = Math.min(head.length, n - filled);
38
+ out.set(head.subarray(0, take), filled);
39
+ filled += take;
40
+ if (take === head.length) this.chunks.shift();
41
+ else this.chunks[0] = head.subarray(take);
42
+ }
43
+ this.have -= n;
44
+ return out;
45
+ }
46
+ /** Discard `n` bytes without accumulating them. */
47
+ async skip(n) {
48
+ while (n > 0) {
49
+ if (this.chunks.length === 0) {
50
+ if (!await this.pull()) throw new TarError("truncated archive");
51
+ continue;
52
+ }
53
+ const head = this.chunks[0];
54
+ const take = Math.min(head.length, n);
55
+ if (take === head.length) this.chunks.shift();
56
+ else this.chunks[0] = head.subarray(take);
57
+ this.have -= take;
58
+ n -= take;
59
+ }
60
+ }
61
+ }
62
+ const decoder = new TextDecoder();
63
+ function field$1(header, start, length) {
64
+ const slice = header.subarray(start, start + length);
65
+ const nul = slice.indexOf(0);
66
+ return decoder.decode(nul < 0 ? slice : slice.subarray(0, nul));
67
+ }
68
+ function octal(header, start, length) {
69
+ const text = field$1(header, start, length).trim();
70
+ if (text === "") return 0;
71
+ const value = parseInt(text, 8);
72
+ if (Number.isNaN(value) || value < 0) throw new TarError("bad size field");
73
+ return value;
74
+ }
75
+ function paxPath(data) {
76
+ const text = decoder.decode(data);
77
+ for (let at = 0; at < text.length; ) {
78
+ const space = text.indexOf(" ", at);
79
+ if (space < 0) break;
80
+ const length = Number(text.slice(at, space));
81
+ if (!Number.isInteger(length) || length <= 0) break;
82
+ const record = text.slice(space + 1, at + length);
83
+ if (record.startsWith("path=")) return record.slice(5).replace(/\n$/, "");
84
+ at += length;
85
+ }
86
+ return null;
87
+ }
88
+ async function readTar(stream, keep) {
89
+ const bytes = new ByteReader(stream);
90
+ const paths = [];
91
+ const files = /* @__PURE__ */ new Map();
92
+ let overridePath = null;
93
+ for (; ; ) {
94
+ const header = await bytes.read(BLOCK);
95
+ if (header === null) break;
96
+ if (header.every((b) => b === 0)) {
97
+ break;
98
+ }
99
+ const size = octal(header, 124, 12);
100
+ const padded = Math.ceil(size / BLOCK) * BLOCK;
101
+ const type = header[156];
102
+ if (type === 120 || type === 76) {
103
+ const data = await bytes.read(size);
104
+ if (data === null) throw new TarError("truncated archive");
105
+ overridePath = type === 120 ? paxPath(data) ?? overridePath : decoder.decode(data).replace(/\0+$/, "");
106
+ await bytes.skip(padded - size);
107
+ continue;
108
+ }
109
+ let name = field$1(header, 0, 100);
110
+ const prefix = field$1(header, 257, 6).startsWith("ustar") ? field$1(header, 345, 155) : "";
111
+ if (prefix) name = `${prefix}/${name}`;
112
+ if (overridePath !== null) {
113
+ name = overridePath;
114
+ overridePath = null;
115
+ }
116
+ if (type !== 48 && type !== 0) {
117
+ await bytes.skip(padded);
118
+ continue;
119
+ }
120
+ const slash = name.indexOf("/");
121
+ if (slash < 0) {
122
+ await bytes.skip(padded);
123
+ continue;
124
+ }
125
+ const path = name.slice(slash + 1);
126
+ if (path === "") {
127
+ await bytes.skip(padded);
128
+ continue;
129
+ }
130
+ paths.push(path);
131
+ if (keep(path)) {
132
+ const data = await bytes.read(size);
133
+ if (data === null) throw new TarError("truncated archive");
134
+ files.set(path, data);
135
+ await bytes.skip(padded - size);
136
+ } else {
137
+ await bytes.skip(padded);
138
+ }
139
+ }
140
+ return { paths, files };
141
+ }
142
+
143
+ const API = "https://api.github.com";
144
+ const UA = "@nickmeriano/task";
145
+ class GitHubError extends Error {
146
+ status;
147
+ constructor(status, message) {
148
+ super(message);
149
+ this.name = "GitHubError";
150
+ this.status = status;
151
+ }
152
+ }
153
+ async function apiGet(token, path) {
154
+ const res = await fetch(`${API}${path}`, {
155
+ headers: {
156
+ Authorization: `Bearer ${token}`,
157
+ Accept: "application/vnd.github+json",
158
+ "User-Agent": UA
159
+ }
160
+ });
161
+ if (!res.ok) throw new GitHubError(res.status, `GET ${path} failed (${res.status})`);
162
+ return await res.json();
163
+ }
164
+ async function repoInfo(token, owner, repo) {
165
+ const data = await apiGet(
166
+ token,
167
+ `/repos/${owner}/${repo}`
168
+ );
169
+ return { defaultBranch: data.default_branch, private: data.private };
170
+ }
171
+ async function listBranches(token, owner, repo) {
172
+ const data = await apiGet(
173
+ token,
174
+ `/repos/${owner}/${repo}/branches?per_page=100`
175
+ );
176
+ return data.map((branch) => branch.name);
177
+ }
178
+ async function headCommit(token, owner, repo, ref) {
179
+ const data = await apiGet(token, `/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`);
180
+ return {
181
+ sha: data.sha,
182
+ committedAt: data.commit.committer.date,
183
+ message: data.commit.message.split("\n")[0]
184
+ };
185
+ }
186
+ function isMissingRef(error) {
187
+ return error instanceof GitHubError && (error.status === 404 || error.status === 422);
188
+ }
189
+ async function fetchRepoArchive(token, owner, repo, sha, keep) {
190
+ const res = await fetch(`${API}/repos/${owner}/${repo}/tarball/${encodeURIComponent(sha)}`, {
191
+ headers: {
192
+ Authorization: `Bearer ${token}`,
193
+ Accept: "application/vnd.github+json",
194
+ "User-Agent": UA
195
+ }
196
+ });
197
+ if (!res.ok || !res.body) {
198
+ throw new GitHubError(res.status, `could not read the repository archive (${res.status})`);
199
+ }
200
+ try {
201
+ return await readTar(res.body.pipeThrough(new DecompressionStream("gzip")), keep);
202
+ } catch (error) {
203
+ if (error instanceof TarError) {
204
+ throw new GitHubError(502, `could not read the repository archive (${error.message})`);
205
+ }
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ const STATUSES = [
211
+ "backlog",
212
+ "todo",
213
+ "in_progress",
214
+ "done",
215
+ "canceled"
216
+ ];
217
+ function isStatus(value) {
218
+ return STATUSES.includes(value);
219
+ }
220
+
221
+ function parseScalar(raw) {
222
+ const trimmed = raw.trim();
223
+ if (trimmed === "") return "";
224
+ try {
225
+ return JSON.parse(trimmed);
226
+ } catch {
227
+ return trimmed;
228
+ }
229
+ }
230
+ function splitRaw(text, where) {
231
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
232
+ if (lines[0] !== "---") {
233
+ return null;
234
+ }
235
+ const end = lines.indexOf("---", 1);
236
+ if (end < 0) {
237
+ return null;
238
+ }
239
+ const fields = /* @__PURE__ */ new Map();
240
+ for (const line of lines.slice(1, end)) {
241
+ if (!line.trim()) continue;
242
+ const colon = line.indexOf(":");
243
+ if (colon < 0) {
244
+ continue;
245
+ }
246
+ fields.set(line.slice(0, colon).trim(), line.slice(colon + 1));
247
+ }
248
+ const body = lines.slice(end + 1).join("\n");
249
+ return { fields, body: body.replace(/^\n+/, "").replace(/\s+$/, "") };
250
+ }
251
+ function field(raw, key) {
252
+ const value = raw.fields.get(key);
253
+ return value === void 0 ? void 0 : parseScalar(value);
254
+ }
255
+ function splitTitle(body) {
256
+ if (!body.startsWith("# ")) return { title: null, description: body };
257
+ const newline = body.indexOf("\n");
258
+ if (newline < 0) return { title: body.slice(2).trim(), description: "" };
259
+ return {
260
+ title: body.slice(2, newline).trim(),
261
+ description: body.slice(newline + 1).replace(/^\n+/, "")
262
+ };
263
+ }
264
+ function parseGoalLenient(text) {
265
+ const raw = splitRaw(text);
266
+ if (!raw) return null;
267
+ const { title, description } = splitTitle(raw.body);
268
+ return {
269
+ title: title ?? "",
270
+ description,
271
+ createdAt: lenientString(field(raw, "created")),
272
+ updatedAt: lenientString(field(raw, "updated"))
273
+ };
274
+ }
275
+ function parseAskLenient(text) {
276
+ const raw = splitRaw(text);
277
+ if (!raw) return null;
278
+ const resolvedAt = field(raw, "resolved");
279
+ return {
280
+ author: lenientString(field(raw, "author")),
281
+ createdAt: lenientString(field(raw, "created")),
282
+ resolvedAt: typeof resolvedAt === "string" && resolvedAt !== "" ? resolvedAt : null,
283
+ resolvedBy: lenientString(field(raw, "resolved_by")) || null,
284
+ body: raw.body
285
+ };
286
+ }
287
+ function lenientStrings(value) {
288
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
289
+ }
290
+ function lenientKeys(value) {
291
+ return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
292
+ }
293
+ function lenientString(value) {
294
+ return typeof value === "string" ? value : "";
295
+ }
296
+ function parseTicketLenient(text) {
297
+ const raw = splitRaw(text);
298
+ if (!raw) return null;
299
+ const status = field(raw, "status");
300
+ const goal = field(raw, "goal");
301
+ const position = field(raw, "position");
302
+ const { title, description } = splitTitle(raw.body);
303
+ return {
304
+ title: title ?? "",
305
+ description,
306
+ status: typeof status === "string" && isStatus(status) ? status : "backlog",
307
+ tags: lenientStrings(field(raw, "tags")),
308
+ goal: typeof goal === "string" && goal !== "" ? goal : null,
309
+ blockedBy: [...new Set(lenientKeys(field(raw, "blocked_by")))].sort(),
310
+ prs: lenientStrings(field(raw, "prs")),
311
+ position: typeof position === "number" && Number.isFinite(position) ? position : 0,
312
+ createdAt: lenientString(field(raw, "created")),
313
+ updatedAt: lenientString(field(raw, "updated"))
314
+ };
315
+ }
316
+ function parseCommentLenient(text) {
317
+ const raw = splitRaw(text);
318
+ if (!raw) return null;
319
+ return {
320
+ author: lenientString(field(raw, "author")),
321
+ createdAt: lenientString(field(raw, "created")),
322
+ body: raw.body
323
+ };
324
+ }
325
+
326
+ const KEY_PATTERN = /^(?=.*[a-z])[a-z0-9]{4,12}$/;
327
+ function isTicketKey(value) {
328
+ return KEY_PATTERN.test(value);
329
+ }
330
+
331
+ const TASK_DIR = ".task";
332
+ const CONFIG_FILE = "config.json";
333
+ const TICKETS_DIR = "tickets";
334
+ const TICKET_FILE = "ticket.md";
335
+ const COMMENTS_DIR = "comments";
336
+ const ASKS_DIR = "asks";
337
+ const GOALS_DIR = "goals";
338
+ const MAX_DEPTH = 6;
339
+ class BoardSourceError extends Error {
340
+ status;
341
+ /**
342
+ * Machine-readable cause, sent beside the message so the SPA can choose a
343
+ * screen rather than infer one from a status code. Optional: most of these
344
+ * are one of a kind and the message is the whole story.
345
+ */
346
+ reason;
347
+ constructor(status, message, reason = null) {
348
+ super(message);
349
+ this.name = "BoardSourceError";
350
+ this.status = status;
351
+ this.reason = reason;
352
+ }
353
+ }
354
+ function str(value, fallback = "") {
355
+ return typeof value === "string" ? value : fallback;
356
+ }
357
+ function num(value, fallback = 0) {
358
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
359
+ }
360
+ function parseConfig(bytes, fallbackName) {
361
+ try {
362
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
363
+ if (parsed && typeof parsed === "object") {
364
+ const config = parsed;
365
+ return {
366
+ name: str(config.name, fallbackName),
367
+ prefix: str(config.prefix, "TASK").toUpperCase(),
368
+ version: num(config.version, 1)
369
+ };
370
+ }
371
+ } catch {
372
+ }
373
+ return { name: fallbackName, prefix: "TASK", version: 1 };
374
+ }
375
+ function findBoardDirs(paths) {
376
+ const marker = `${TASK_DIR}/${CONFIG_FILE}`;
377
+ const dirs = [];
378
+ for (const path of paths) {
379
+ if (path !== marker && !path.endsWith(`/${marker}`)) continue;
380
+ const dir = path.slice(0, -(TASK_DIR.length + CONFIG_FILE.length + 2));
381
+ const id = dir === "" ? "." : dir.replace(/\/$/, "");
382
+ if (id !== "." && id.split("/").length > MAX_DEPTH) continue;
383
+ dirs.push(id);
384
+ }
385
+ dirs.sort((a, b) => a === "." ? -1 : b === "." ? 1 : a < b ? -1 : 1);
386
+ return dirs;
387
+ }
388
+ function nameFromDir(id) {
389
+ if (id === ".") return "tasks";
390
+ return id.split("/").pop() ?? "tasks";
391
+ }
392
+ function readTicketsBoard(id, configBytes, files) {
393
+ const config = parseConfig(configBytes, nameFromDir(id));
394
+ const decoder = new TextDecoder();
395
+ const docs = /* @__PURE__ */ new Map();
396
+ const comments = /* @__PURE__ */ new Map();
397
+ const asks = /* @__PURE__ */ new Map();
398
+ const goals = [];
399
+ for (const [path, bytes] of files) {
400
+ const goalMatch = new RegExp(`^${GOALS_DIR}/(archive/)?([^/]+)\\.md$`).exec(path);
401
+ if (goalMatch) {
402
+ if (goalMatch[2].startsWith(".")) continue;
403
+ const doc = parseGoalLenient(decoder.decode(bytes));
404
+ if (doc) {
405
+ goals.push({
406
+ slug: goalMatch[2],
407
+ title: doc.title,
408
+ description: doc.description,
409
+ createdAt: doc.createdAt,
410
+ updatedAt: doc.updatedAt,
411
+ ...goalMatch[1] ? { archived: true } : {}
412
+ });
413
+ }
414
+ continue;
415
+ }
416
+ const ticketMatch = new RegExp(`^${TICKETS_DIR}/([^/]+)/${TICKET_FILE}$`).exec(path);
417
+ if (ticketMatch && isTicketKey(ticketMatch[1])) {
418
+ const doc = parseTicketLenient(decoder.decode(bytes));
419
+ if (doc) docs.set(ticketMatch[1], doc);
420
+ continue;
421
+ }
422
+ const commentMatch = new RegExp(`^${TICKETS_DIR}/([^/]+)/${COMMENTS_DIR}/([^/]+)\\.md$`).exec(
423
+ path
424
+ );
425
+ if (commentMatch && isTicketKey(commentMatch[1]) && !commentMatch[2].startsWith(".")) {
426
+ const doc = parseCommentLenient(decoder.decode(bytes));
427
+ if (!doc) continue;
428
+ const taskKey = commentMatch[1];
429
+ const list = comments.get(taskKey) ?? [];
430
+ list.push({
431
+ id: commentMatch[2],
432
+ taskId: `${config.prefix}-${taskKey}`,
433
+ author: doc.author,
434
+ body: doc.body,
435
+ createdAt: doc.createdAt
436
+ });
437
+ comments.set(taskKey, list);
438
+ continue;
439
+ }
440
+ const askMatch = new RegExp(`^${TICKETS_DIR}/([^/]+)/${ASKS_DIR}/([^/]+)\\.md$`).exec(path);
441
+ if (askMatch && isTicketKey(askMatch[1]) && !askMatch[2].startsWith(".")) {
442
+ const doc = parseAskLenient(decoder.decode(bytes));
443
+ if (!doc) continue;
444
+ const taskKey = askMatch[1];
445
+ const list = asks.get(taskKey) ?? [];
446
+ list.push({
447
+ id: askMatch[2],
448
+ taskId: `${config.prefix}-${taskKey}`,
449
+ ordinal: 0,
450
+ text: doc.body,
451
+ author: doc.author,
452
+ createdAt: doc.createdAt,
453
+ resolvedAt: doc.resolvedAt,
454
+ resolvedBy: doc.resolvedBy
455
+ });
456
+ asks.set(taskKey, list);
457
+ }
458
+ }
459
+ for (const taskKey of comments.keys()) {
460
+ if (!docs.has(taskKey)) comments.delete(taskKey);
461
+ }
462
+ for (const list of comments.values()) {
463
+ list.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
464
+ }
465
+ for (const taskKey of asks.keys()) {
466
+ if (!docs.has(taskKey)) asks.delete(taskKey);
467
+ }
468
+ for (const list of asks.values()) {
469
+ list.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
470
+ list.forEach((ask, index) => ask.ordinal = index + 1);
471
+ }
472
+ const blocks = /* @__PURE__ */ new Map();
473
+ for (const [key, doc] of docs) {
474
+ for (const blocker of doc.blockedBy) {
475
+ blocks.set(blocker, [...blocks.get(blocker) ?? [], key]);
476
+ }
477
+ }
478
+ for (const list of blocks.values()) list.sort((a, b) => a.localeCompare(b));
479
+ const tasks = [...docs.entries()].map(([key, doc]) => ({
480
+ id: `${config.prefix}-${key}`,
481
+ key,
482
+ title: doc.title,
483
+ description: doc.description,
484
+ status: doc.status,
485
+ tags: doc.tags,
486
+ goal: doc.goal,
487
+ needsHuman: (asks.get(key) ?? []).some((a) => !a.resolvedAt),
488
+ asks: asks.get(key) ?? [],
489
+ blocks: blocks.get(key) ?? [],
490
+ blockedBy: doc.blockedBy,
491
+ prs: doc.prs,
492
+ position: doc.position,
493
+ createdAt: doc.createdAt,
494
+ updatedAt: doc.updatedAt,
495
+ commentCount: comments.get(key)?.length ?? 0
496
+ })).sort(
497
+ (a, b) => a.position - b.position || a.createdAt.localeCompare(b.createdAt) || a.key.localeCompare(b.key)
498
+ );
499
+ goals.sort(
500
+ (a, b) => Number(a.archived === true) - Number(b.archived === true) || a.slug.localeCompare(b.slug)
501
+ );
502
+ return { info: { id, name: config.name, prefix: config.prefix }, tasks, comments, goals };
503
+ }
504
+ async function snapshotAt(token, owner, repo, commit, ref = "HEAD") {
505
+ const { paths, files } = await fetchRepoArchive(
506
+ token,
507
+ owner,
508
+ repo,
509
+ commit.sha,
510
+ (path) => path.startsWith(`${TASK_DIR}/`) || path.includes(`/${TASK_DIR}/`)
511
+ );
512
+ const dirs = findBoardDirs(paths);
513
+ if (dirs.length === 0) {
514
+ throw new BoardSourceError(
515
+ 404,
516
+ ref === "HEAD" ? "no .task directory in this repository — run `task init` and commit it" : `no .task directory on ${ref}`,
517
+ "no-boards"
518
+ );
519
+ }
520
+ const data = /* @__PURE__ */ new Map();
521
+ for (const id of dirs) {
522
+ const base = id === "." ? TASK_DIR : `${id}/${TASK_DIR}`;
523
+ const configBytes = files.get(`${base}/${CONFIG_FILE}`);
524
+ if (!configBytes) continue;
525
+ const prefix = `${base}/`;
526
+ const ticketPaths = paths.filter(
527
+ (path) => (path.startsWith(`${prefix}${TICKETS_DIR}/`) || path.startsWith(`${prefix}${GOALS_DIR}/`)) && path.endsWith(".md")
528
+ );
529
+ if (ticketPaths.length === 0 && parseConfig(configBytes, "").version < 2) continue;
530
+ const tickets = /* @__PURE__ */ new Map();
531
+ for (const path of ticketPaths) {
532
+ const bytes = files.get(path);
533
+ if (bytes) tickets.set(path.slice(prefix.length), bytes);
534
+ }
535
+ const board = readTicketsBoard(id, configBytes, tickets);
536
+ data.set(board.info.id, board);
537
+ }
538
+ if (data.size === 0) {
539
+ throw new BoardSourceError(
540
+ 404,
541
+ "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)?"
542
+ );
543
+ }
544
+ return {
545
+ commit,
546
+ boards: [...data.values()].map((board) => board.info),
547
+ data,
548
+ truncated: false
549
+ };
550
+ }
551
+ class SnapshotMemory {
552
+ snapshots = /* @__PURE__ */ new Map();
553
+ max;
554
+ constructor(max = 24) {
555
+ this.max = max;
556
+ }
557
+ get(key) {
558
+ return this.snapshots.get(key);
559
+ }
560
+ remember(key, snapshot) {
561
+ this.snapshots.set(key, snapshot);
562
+ while (this.snapshots.size > this.max) {
563
+ const oldest = this.snapshots.keys().next().value;
564
+ if (oldest === void 0) break;
565
+ this.snapshots.delete(oldest);
566
+ }
567
+ return snapshot;
568
+ }
569
+ }
570
+
571
+ const REF_PATTERN = /^[\w.\-/]{1,255}$/;
572
+ const REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/;
573
+ function json(body, status = 200) {
574
+ return new Response(JSON.stringify(body), {
575
+ status,
576
+ headers: {
577
+ "Content-Type": "application/json; charset=utf-8",
578
+ // Every answer is "the repository right now"; nothing between this
579
+ // function and the browser should hold on to one.
580
+ "Cache-Control": "no-store"
581
+ }
582
+ });
583
+ }
584
+ function pickBoard(snapshot, requested) {
585
+ const id = requested && snapshot.data.has(requested) ? requested : snapshot.boards[0]?.id;
586
+ return id === void 0 ? null : snapshot.data.get(id) ?? null;
587
+ }
588
+ function createBoardHandler(options) {
589
+ const { repo: slug, ref: defaultRef = "HEAD" } = options;
590
+ if (!REPO_PATTERN.test(slug)) throw new Error(`repo must be owner/name — got ${JSON.stringify(slug)}`);
591
+ const [owner, repo] = slug.split("/");
592
+ const basePath = (options.basePath ?? "/api").replace(/\/+$/, "");
593
+ const memory = new SnapshotMemory(options.maxCachedSnapshots);
594
+ const inFlight = /* @__PURE__ */ new Map();
595
+ async function resolveToken() {
596
+ const token = typeof options.token === "function" ? await options.token() : options.token;
597
+ if (!token) {
598
+ throw new BoardSourceError(503, "no GitHub token configured for the live board", "no-token");
599
+ }
600
+ return token;
601
+ }
602
+ async function load(token, ref, explicit) {
603
+ let commit;
604
+ try {
605
+ commit = await headCommit(token, owner, repo, ref);
606
+ } catch (error) {
607
+ if (explicit && isMissingRef(error)) {
608
+ throw new BoardSourceError(404, `no such branch or ref: ${ref}`, "no-such-ref");
609
+ }
610
+ throw error;
611
+ }
612
+ const key = `${owner}/${repo}@${commit.sha}`;
613
+ const cached = memory.get(key);
614
+ if (cached) return cached;
615
+ let pending = inFlight.get(key);
616
+ if (!pending) {
617
+ pending = snapshotAt(token, owner, repo, commit, ref).then((snapshot) => memory.remember(key, snapshot)).finally(() => inFlight.delete(key));
618
+ inFlight.set(key, pending);
619
+ }
620
+ return pending;
621
+ }
622
+ async function handle(request) {
623
+ if (request.method !== "GET" && request.method !== "HEAD") {
624
+ return json(
625
+ { error: "this board is read-only — commit and push to change it", reason: "read-only" },
626
+ 405
627
+ );
628
+ }
629
+ const url = new URL(request.url);
630
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
631
+ return json({ error: `not an API path: ${url.pathname}` }, 404);
632
+ }
633
+ const route = url.pathname.slice(basePath.length) || "/";
634
+ const token = await resolveToken();
635
+ if (route === "/branches") {
636
+ const [names, info] = await Promise.all([
637
+ listBranches(token, owner, repo),
638
+ repoInfo(token, owner, repo)
639
+ ]);
640
+ const branches = names.map((name) => ({ name, isDefault: name === info.defaultBranch })).sort((a, b) => Number(b.isDefault) - Number(a.isDefault) || a.name.localeCompare(b.name));
641
+ return json({ branches });
642
+ }
643
+ const requestedRef = url.searchParams.get("ref");
644
+ const ref = requestedRef && REF_PATTERN.test(requestedRef) && !requestedRef.includes("..") ? requestedRef : null;
645
+ const snapshot = await load(token, ref ?? defaultRef, ref !== null);
646
+ if (route === "/boards") return json({ boards: snapshot.boards });
647
+ const board = pickBoard(snapshot, url.searchParams.get("board"));
648
+ if (!board) return json({ error: "no such board" }, 404);
649
+ if (route === "/project") {
650
+ return json({
651
+ name: board.info.name,
652
+ prefix: board.info.prefix,
653
+ statuses: STATUSES,
654
+ // The payload the SPA reads to decide whether to offer editing at all.
655
+ // The data is a commit; the only way to change one is to push.
656
+ readOnly: true,
657
+ // Locally this is who would author a comment. Nothing here can
658
+ // comment, and nothing here knows who is looking.
659
+ author: ""
660
+ });
661
+ }
662
+ if (route === "/tasks") return json({ tasks: board.tasks });
663
+ if (route === "/goals") return json({ goals: board.goals });
664
+ const detail = /^\/tasks\/([^/]+)$/.exec(route);
665
+ if (detail) {
666
+ const match = /^(?:[A-Za-z0-9]+-)?([A-Za-z0-9]+)$/.exec(decodeURIComponent(detail[1]));
667
+ if (!match) return json({ error: "invalid task id" }, 400);
668
+ const key = match[1].toLowerCase();
669
+ const task = board.tasks.find((candidate) => candidate.key === key);
670
+ if (!task) return json({ error: `no such task: ${key}` }, 404);
671
+ return json({ task, comments: board.comments.get(key) ?? [] });
672
+ }
673
+ return json({ error: `not here: ${route}` }, 404);
674
+ }
675
+ return async (request) => {
676
+ try {
677
+ return await handle(request);
678
+ } catch (error) {
679
+ if (error instanceof BoardSourceError) {
680
+ return json({ error: error.message, reason: error.reason }, error.status);
681
+ }
682
+ if (error instanceof GitHubError) {
683
+ return json(
684
+ {
685
+ error: `GitHub answered ${error.status} — check the token and the repository name`,
686
+ reason: "github"
687
+ },
688
+ 502
689
+ );
690
+ }
691
+ return json({ error: error instanceof Error ? error.message : "internal error" }, 500);
692
+ }
693
+ };
694
+ }
695
+
696
+ const REPO = "__TASK_EXPORT_REPO__";
697
+ const BASE_PATH = "__TASK_EXPORT_BASE_PATH__";
698
+ const TOKEN_SECRET = "TASK_GITHUB_TOKEN";
699
+ let currentToken = "";
700
+ let handler = null;
701
+ function onRequest(context) {
702
+ const token = context.env[TOKEN_SECRET];
703
+ if (!token) return new Response(null, { status: 503 });
704
+ currentToken = token;
705
+ handler ??= createBoardHandler({ token: () => currentToken, repo: REPO, basePath: BASE_PATH });
706
+ return handler(context.request);
707
+ }
708
+
709
+ export { TOKEN_SECRET, onRequest };