@nickmeriano/task 0.4.2 → 0.6.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 +77 -9
- package/dist/cli.js +290 -19
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +113 -0
- package/dist/file-store.d.ts.map +1 -0
- package/dist/file-store.js +604 -0
- package/dist/file-store.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +30 -7
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +69 -16
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +169 -39
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +12 -0
- package/dist/store.test.d.ts.map +1 -0
- package/dist/store.test.js +252 -0
- package/dist/store.test.js.map +1 -0
- package/dist/ticket-doc.d.ts +57 -0
- package/dist/ticket-doc.d.ts.map +1 -0
- package/dist/ticket-doc.js +197 -0
- package/dist/ticket-doc.js.map +1 -0
- package/dist/types.d.ts +35 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
- package/skill/SKILL.md +40 -9
- package/src/cli.ts +304 -22
- package/src/file-store.ts +693 -0
- package/src/index.ts +30 -4
- package/src/server.ts +31 -11
- package/src/store.test.ts +305 -0
- package/src/store.ts +210 -49
- package/src/ticket-doc.ts +226 -0
- package/src/types.ts +35 -3
- package/ui/dist/assets/index-CXW8uT5f.css +1 -0
- package/ui/dist/assets/{index-Dm3ToURf.js → index-oJzomUDL.js} +67 -67
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-DXFbw9bM.css +0 -1
package/src/store.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatabaseSync } from "node:sqlite"
|
|
2
|
-
import { existsSync,
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
|
3
3
|
import { dirname, join, relative, sep } from "node:path"
|
|
4
4
|
import type {
|
|
5
5
|
Comment,
|
|
@@ -9,14 +9,43 @@ import type {
|
|
|
9
9
|
TaskFilter,
|
|
10
10
|
TaskInput,
|
|
11
11
|
TaskPatch,
|
|
12
|
-
} from "./types.
|
|
12
|
+
} from "./types.ts"
|
|
13
13
|
|
|
14
14
|
export const TASK_DIR = ".task"
|
|
15
15
|
export const DB_FILE = "tasks.db"
|
|
16
16
|
export const CONFIG_FILE = "config.json"
|
|
17
17
|
|
|
18
18
|
/** Spacing between adjacent board positions — leaves room to drop between. */
|
|
19
|
-
const POSITION_GAP = 1024
|
|
19
|
+
export const POSITION_GAP = 1024
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What a board's persistence layer looks like, whichever representation backs
|
|
23
|
+
* it. Two implementations: `FileStore` (text files in `.task/tickets/`, the
|
|
24
|
+
* canonical format) and the legacy `TaskStore` (a committed SQLite database),
|
|
25
|
+
* which survives so un-migrated boards keep working. `openBoard` picks.
|
|
26
|
+
*/
|
|
27
|
+
export interface Store {
|
|
28
|
+
readonly root: string
|
|
29
|
+
readonly taskDir: string
|
|
30
|
+
readonly config: ProjectConfig
|
|
31
|
+
close(): void
|
|
32
|
+
displayId(number: number): string
|
|
33
|
+
parseId(ref: string): number
|
|
34
|
+
list(filter?: TaskFilter): Task[]
|
|
35
|
+
get(number: number): Task | null
|
|
36
|
+
create(input: TaskInput): Task
|
|
37
|
+
update(number: number, patch: TaskPatch): Task
|
|
38
|
+
link(number: number, relation: "blocks" | "blocked_by", target: number): Task
|
|
39
|
+
unlink(number: number, relation: "blocks" | "blocked_by", target: number): Task
|
|
40
|
+
delete(number: number): void
|
|
41
|
+
/** Move a done/canceled ticket to `.task/archive/`, out of the board's hot path. */
|
|
42
|
+
archive(number: number): Task
|
|
43
|
+
unarchive(number: number): Task
|
|
44
|
+
comments(number: number): Comment[]
|
|
45
|
+
addComment(number: number, body: string, author?: string): Comment
|
|
46
|
+
deleteComment(number: number, commentId: string): Comment
|
|
47
|
+
commentCounts(): Map<number, number>
|
|
48
|
+
}
|
|
20
49
|
|
|
21
50
|
/**
|
|
22
51
|
* Walk up from `from` looking for a `.task/config.json`, the way git finds its
|
|
@@ -39,6 +68,46 @@ export interface BoardRef {
|
|
|
39
68
|
root: string
|
|
40
69
|
}
|
|
41
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The widest board scope `from` belongs to: the *outermost* ancestor holding a
|
|
73
|
+
* `.task/` (a monorepo's root board), or `from` itself when no ancestor has
|
|
74
|
+
* one. Where `findRoot` answers "which board am I on", this answers "which
|
|
75
|
+
* repo of boards am I in" — the anchor for anything cross-board: prefix
|
|
76
|
+
* routing and `task boards`.
|
|
77
|
+
*/
|
|
78
|
+
export function findScopeRoot(from: string): string {
|
|
79
|
+
let dir = from
|
|
80
|
+
let top: string | null = null
|
|
81
|
+
for (;;) {
|
|
82
|
+
if (existsSync(join(dir, TASK_DIR, CONFIG_FILE))) top = dir
|
|
83
|
+
const parent = dirname(dir)
|
|
84
|
+
if (parent === dir) break
|
|
85
|
+
dir = parent
|
|
86
|
+
}
|
|
87
|
+
return top ?? from
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** A board's config, read without opening a store. Throws on a broken file. */
|
|
91
|
+
export function boardConfig(root: string): ProjectConfig {
|
|
92
|
+
return JSON.parse(readFileSync(join(root, TASK_DIR, CONFIG_FILE), "utf8")) as ProjectConfig
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every board in scope whose prefix is `prefix` (case-insensitive). Normally
|
|
97
|
+
* zero or one — two boards sharing a prefix is a repo mistake this surfaces
|
|
98
|
+
* rather than resolves. Boards with unreadable configs can't match.
|
|
99
|
+
*/
|
|
100
|
+
export function findBoardsByPrefix(scopeRoot: string, prefix: string): BoardRef[] {
|
|
101
|
+
const wanted = prefix.toUpperCase()
|
|
102
|
+
return findBoards(scopeRoot).filter((ref) => {
|
|
103
|
+
try {
|
|
104
|
+
return boardConfig(ref.root).prefix.toUpperCase() === wanted
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
42
111
|
/** Directories that never contain a board worth serving. */
|
|
43
112
|
const SKIP_DIRS = new Set(["node_modules", "dist", "build", "out", "coverage", "target"])
|
|
44
113
|
|
|
@@ -79,10 +148,10 @@ export function findBoards(serveRoot: string, maxDepth = 6): BoardRef[] {
|
|
|
79
148
|
return boards
|
|
80
149
|
}
|
|
81
150
|
|
|
82
|
-
/** Derive an id prefix from a project name: "phone agent" → "
|
|
151
|
+
/** Derive an id prefix from a project name: "phone agent" → "PHO". */
|
|
83
152
|
export function derivePrefix(name: string): string {
|
|
84
153
|
const word = name.toUpperCase().replace(/[^A-Z0-9]+/g, " ").trim().split(" ")[0]
|
|
85
|
-
return (word || "
|
|
154
|
+
return (word || "TAS").slice(0, 3)
|
|
86
155
|
}
|
|
87
156
|
|
|
88
157
|
interface TaskRow {
|
|
@@ -93,6 +162,7 @@ interface TaskRow {
|
|
|
93
162
|
tags: string
|
|
94
163
|
milestone: string | null
|
|
95
164
|
needs_human: number
|
|
165
|
+
prs: string
|
|
96
166
|
position: number
|
|
97
167
|
created_at: string
|
|
98
168
|
updated_at: string
|
|
@@ -111,11 +181,12 @@ function now(): string {
|
|
|
111
181
|
}
|
|
112
182
|
|
|
113
183
|
/**
|
|
114
|
-
* The
|
|
184
|
+
* The legacy persistence layer: one SQLite database inside `.task/`, opened
|
|
115
185
|
* synchronously via node:sqlite (built into Node ≥22.13 — zero dependencies,
|
|
116
|
-
* nothing to compile, safe for `npx`).
|
|
186
|
+
* nothing to compile, safe for `npx`). Boards created before the text format
|
|
187
|
+
* still run on this until `task migrate` moves them over.
|
|
117
188
|
*/
|
|
118
|
-
export class TaskStore {
|
|
189
|
+
export class TaskStore implements Store {
|
|
119
190
|
readonly root: string
|
|
120
191
|
readonly taskDir: string
|
|
121
192
|
readonly config: ProjectConfig
|
|
@@ -160,15 +231,27 @@ export class TaskStore {
|
|
|
160
231
|
tags: JSON.parse(row.tags) as string[],
|
|
161
232
|
milestone: row.milestone,
|
|
162
233
|
needsHuman: row.needs_human !== 0,
|
|
234
|
+
blocks: this.linkedNumbers("blocker_id", row.id),
|
|
235
|
+
blockedBy: this.linkedNumbers("blocked_id", row.id),
|
|
236
|
+
prs: JSON.parse(row.prs) as string[],
|
|
163
237
|
position: row.position,
|
|
164
238
|
createdAt: row.created_at,
|
|
165
239
|
updatedAt: row.updated_at,
|
|
166
240
|
}
|
|
167
241
|
}
|
|
168
242
|
|
|
243
|
+
/** The other end of every link where `column` is this task. */
|
|
244
|
+
private linkedNumbers(column: "blocker_id" | "blocked_id", number: number): number[] {
|
|
245
|
+
const other = column === "blocker_id" ? "blocked_id" : "blocker_id"
|
|
246
|
+
const rows = this.db
|
|
247
|
+
.prepare(`SELECT ${other} AS n FROM task_links WHERE ${column} = ? ORDER BY ${other}`)
|
|
248
|
+
.all(number) as unknown as { n: number }[]
|
|
249
|
+
return rows.map((r) => r.n)
|
|
250
|
+
}
|
|
251
|
+
|
|
169
252
|
private toComment(row: CommentRow): Comment {
|
|
170
253
|
return {
|
|
171
|
-
id: row.id,
|
|
254
|
+
id: String(row.id),
|
|
172
255
|
taskId: this.displayId(row.task_id),
|
|
173
256
|
author: row.author,
|
|
174
257
|
body: row.body,
|
|
@@ -177,6 +260,9 @@ export class TaskStore {
|
|
|
177
260
|
}
|
|
178
261
|
|
|
179
262
|
list(filter: TaskFilter = {}): Task[] {
|
|
263
|
+
if (filter.archived) {
|
|
264
|
+
throw new Error("archiving needs a text-format board — run `task migrate` first")
|
|
265
|
+
}
|
|
180
266
|
const where: string[] = []
|
|
181
267
|
const params: (string | number)[] = []
|
|
182
268
|
if (filter.statuses?.length) {
|
|
@@ -217,8 +303,8 @@ export class TaskStore {
|
|
|
217
303
|
const timestamp = now()
|
|
218
304
|
const result = this.db
|
|
219
305
|
.prepare(
|
|
220
|
-
`INSERT INTO tasks (title, description, status, tags, milestone, needs_human, position, created_at, updated_at)
|
|
221
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
306
|
+
`INSERT INTO tasks (title, description, status, tags, milestone, needs_human, prs, position, created_at, updated_at)
|
|
307
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
222
308
|
)
|
|
223
309
|
.run(
|
|
224
310
|
input.title,
|
|
@@ -227,17 +313,31 @@ export class TaskStore {
|
|
|
227
313
|
JSON.stringify(input.tags ?? []),
|
|
228
314
|
input.milestone ?? null,
|
|
229
315
|
input.needsHuman ? 1 : 0,
|
|
316
|
+
JSON.stringify(input.prs ?? []),
|
|
230
317
|
(max.max ?? 0) + POSITION_GAP,
|
|
231
318
|
timestamp,
|
|
232
319
|
timestamp,
|
|
233
320
|
)
|
|
234
|
-
|
|
321
|
+
const number = Number(result.lastInsertRowid)
|
|
322
|
+
if (input.blocks !== undefined) this.reconcileLinks("blocker_id", number, input.blocks)
|
|
323
|
+
if (input.blockedBy !== undefined) this.reconcileLinks("blocked_id", number, input.blockedBy)
|
|
324
|
+
return this.get(number)!
|
|
235
325
|
}
|
|
236
326
|
|
|
237
327
|
update(number: number, patch: TaskPatch): Task {
|
|
238
328
|
const existing = this.get(number)
|
|
239
329
|
if (!existing) throw new Error(`no such task: ${this.displayId(number)}`)
|
|
240
330
|
|
|
331
|
+
// Reconciled outside the UPDATE — links live in their own table. Done
|
|
332
|
+
// first so a bad target rejects the whole patch before anything writes.
|
|
333
|
+
let linksChanged = false
|
|
334
|
+
if (patch.blocks !== undefined) {
|
|
335
|
+
linksChanged = this.reconcileLinks("blocker_id", number, patch.blocks)
|
|
336
|
+
}
|
|
337
|
+
if (patch.blockedBy !== undefined) {
|
|
338
|
+
linksChanged = this.reconcileLinks("blocked_id", number, patch.blockedBy) || linksChanged
|
|
339
|
+
}
|
|
340
|
+
|
|
241
341
|
const sets: string[] = []
|
|
242
342
|
const params: (string | number | null)[] = []
|
|
243
343
|
const set = (column: string, value: string | number | null) => {
|
|
@@ -250,6 +350,7 @@ export class TaskStore {
|
|
|
250
350
|
if (patch.tags !== undefined) set("tags", JSON.stringify(patch.tags))
|
|
251
351
|
if (patch.milestone !== undefined) set("milestone", patch.milestone)
|
|
252
352
|
if (patch.needsHuman !== undefined) set("needs_human", patch.needsHuman ? 1 : 0)
|
|
353
|
+
if (patch.prs !== undefined) set("prs", JSON.stringify(patch.prs))
|
|
253
354
|
if (patch.status !== undefined) {
|
|
254
355
|
set("status", patch.status)
|
|
255
356
|
if (patch.position === undefined && patch.status !== existing.status) {
|
|
@@ -263,10 +364,73 @@ export class TaskStore {
|
|
|
263
364
|
}
|
|
264
365
|
if (patch.position !== undefined) set("position", patch.position)
|
|
265
366
|
|
|
266
|
-
if (sets.length === 0) return existing
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
367
|
+
if (sets.length === 0 && !linksChanged) return existing
|
|
368
|
+
if (sets.length > 0) {
|
|
369
|
+
set("updated_at", now())
|
|
370
|
+
params.push(number)
|
|
371
|
+
this.db.prepare(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`).run(...params)
|
|
372
|
+
}
|
|
373
|
+
return this.get(number)!
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Make the link table agree with `targets` for one side of one task —
|
|
378
|
+
* `blocker_id` when setting what `number` blocks, `blocked_id` when setting
|
|
379
|
+
* what blocks it. The other task's view is the same row read from the other
|
|
380
|
+
* end, so there is no second write to keep consistent. Every task touched
|
|
381
|
+
* (both ends of every added or removed link) gets its `updated_at` bumped.
|
|
382
|
+
*/
|
|
383
|
+
private reconcileLinks(
|
|
384
|
+
column: "blocker_id" | "blocked_id",
|
|
385
|
+
number: number,
|
|
386
|
+
targets: number[],
|
|
387
|
+
): boolean {
|
|
388
|
+
const other = column === "blocker_id" ? "blocked_id" : "blocker_id"
|
|
389
|
+
const wanted = [...new Set(targets)]
|
|
390
|
+
for (const target of wanted) {
|
|
391
|
+
if (target === number) {
|
|
392
|
+
throw new Error(`a task can't block itself: ${this.displayId(number)}`)
|
|
393
|
+
}
|
|
394
|
+
if (!this.db.prepare("SELECT id FROM tasks WHERE id = ?").get(target)) {
|
|
395
|
+
throw new Error(`no such task: ${this.displayId(target)}`)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const current = new Set(this.linkedNumbers(column, number))
|
|
399
|
+
const removed = [...current].filter((n) => !wanted.includes(n))
|
|
400
|
+
const added = wanted.filter((n) => !current.has(n))
|
|
401
|
+
for (const n of removed) {
|
|
402
|
+
this.db
|
|
403
|
+
.prepare(`DELETE FROM task_links WHERE ${column} = ? AND ${other} = ?`)
|
|
404
|
+
.run(number, n)
|
|
405
|
+
}
|
|
406
|
+
for (const n of added) {
|
|
407
|
+
this.db
|
|
408
|
+
.prepare(`INSERT OR IGNORE INTO task_links (${column}, ${other}) VALUES (?, ?)`)
|
|
409
|
+
.run(number, n)
|
|
410
|
+
}
|
|
411
|
+
const touched = [...removed, ...added]
|
|
412
|
+
if (touched.length > 0) {
|
|
413
|
+
const timestamp = now()
|
|
414
|
+
const bump = this.db.prepare("UPDATE tasks SET updated_at = ? WHERE id = ?")
|
|
415
|
+
for (const n of [...touched, number]) bump.run(timestamp, n)
|
|
416
|
+
}
|
|
417
|
+
return touched.length > 0
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** `task link A --blocks B` and friends — additive, unlike the patch form. */
|
|
421
|
+
link(number: number, relation: "blocks" | "blocked_by", target: number): Task {
|
|
422
|
+
if (!this.get(number)) throw new Error(`no such task: ${this.displayId(number)}`)
|
|
423
|
+
const column = relation === "blocks" ? "blocker_id" : "blocked_id"
|
|
424
|
+
const current = this.linkedNumbers(column, number)
|
|
425
|
+
this.reconcileLinks(column, number, [...current, target])
|
|
426
|
+
return this.get(number)!
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
unlink(number: number, relation: "blocks" | "blocked_by", target: number): Task {
|
|
430
|
+
if (!this.get(number)) throw new Error(`no such task: ${this.displayId(number)}`)
|
|
431
|
+
const column = relation === "blocks" ? "blocker_id" : "blocked_id"
|
|
432
|
+
const current = this.linkedNumbers(column, number)
|
|
433
|
+
this.reconcileLinks(column, number, current.filter((n) => n !== target))
|
|
270
434
|
return this.get(number)!
|
|
271
435
|
}
|
|
272
436
|
|
|
@@ -275,6 +439,15 @@ export class TaskStore {
|
|
|
275
439
|
if (result.changes === 0) throw new Error(`no such task: ${this.displayId(number)}`)
|
|
276
440
|
}
|
|
277
441
|
|
|
442
|
+
/** The archive is a text-format feature — a directory move, not a table. */
|
|
443
|
+
archive(_number: number): Task {
|
|
444
|
+
throw new Error("archiving needs a text-format board — run `task migrate` first")
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
unarchive(_number: number): Task {
|
|
448
|
+
throw new Error("archiving needs a text-format board — run `task migrate` first")
|
|
449
|
+
}
|
|
450
|
+
|
|
278
451
|
comments(number: number): Comment[] {
|
|
279
452
|
const rows = this.db
|
|
280
453
|
.prepare("SELECT * FROM comments WHERE task_id = ? ORDER BY id")
|
|
@@ -293,12 +466,17 @@ export class TaskStore {
|
|
|
293
466
|
return this.toComment(row)
|
|
294
467
|
}
|
|
295
468
|
|
|
296
|
-
deleteComment(number: number, commentId:
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
.
|
|
469
|
+
deleteComment(number: number, commentId: string): Comment {
|
|
470
|
+
const rowid = Number(commentId)
|
|
471
|
+
const row = (
|
|
472
|
+
Number.isInteger(rowid)
|
|
473
|
+
? this.db
|
|
474
|
+
.prepare("SELECT * FROM comments WHERE id = ? AND task_id = ?")
|
|
475
|
+
.get(rowid, number)
|
|
476
|
+
: undefined
|
|
477
|
+
) as CommentRow | undefined
|
|
300
478
|
if (!row) throw new Error(`no such comment on ${this.displayId(number)}: ${commentId}`)
|
|
301
|
-
this.db.prepare("DELETE FROM comments WHERE id = ?").run(
|
|
479
|
+
this.db.prepare("DELETE FROM comments WHERE id = ?").run(rowid)
|
|
302
480
|
return this.toComment(row)
|
|
303
481
|
}
|
|
304
482
|
|
|
@@ -365,6 +543,17 @@ const MIGRATIONS: string[] = [
|
|
|
365
543
|
ALTER TABLE tasks ADD COLUMN needs_human INTEGER NOT NULL DEFAULT 0;
|
|
366
544
|
ALTER TABLE tasks DROP COLUMN priority;
|
|
367
545
|
ALTER TABLE tasks DROP COLUMN assignee;`,
|
|
546
|
+
|
|
547
|
+
// v3 — dependencies and pull requests. One direction of "blocks" is the
|
|
548
|
+
// whole relation: (blocker, blocked) read from the blocked side is
|
|
549
|
+
// "blocked by", so the mirror is a query, not a second row. PRs are a JSON
|
|
550
|
+
// list of URLs, same shape as tags.
|
|
551
|
+
`CREATE TABLE task_links (
|
|
552
|
+
blocker_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
553
|
+
blocked_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
554
|
+
PRIMARY KEY (blocker_id, blocked_id)
|
|
555
|
+
);
|
|
556
|
+
ALTER TABLE tasks ADD COLUMN prs TEXT NOT NULL DEFAULT '[]';`,
|
|
368
557
|
]
|
|
369
558
|
|
|
370
559
|
/**
|
|
@@ -388,31 +577,3 @@ function migrate(db: DatabaseSync): void {
|
|
|
388
577
|
}
|
|
389
578
|
}
|
|
390
579
|
|
|
391
|
-
export interface InitOptions {
|
|
392
|
-
name: string
|
|
393
|
-
prefix?: string
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* Create `.task/` in `root`: config, an empty database, and a .gitignore for
|
|
398
|
-
* SQLite's transient sidecar files (the database itself is meant to be
|
|
399
|
-
* committed — that's the point).
|
|
400
|
-
*/
|
|
401
|
-
export function initProject(root: string, options: InitOptions): TaskStore {
|
|
402
|
-
const taskDir = join(root, TASK_DIR)
|
|
403
|
-
if (existsSync(join(taskDir, CONFIG_FILE))) {
|
|
404
|
-
throw new Error(`already initialized: ${join(taskDir, CONFIG_FILE)} exists`)
|
|
405
|
-
}
|
|
406
|
-
mkdirSync(taskDir, { recursive: true })
|
|
407
|
-
const config: ProjectConfig = {
|
|
408
|
-
name: options.name,
|
|
409
|
-
prefix: options.prefix ?? derivePrefix(options.name),
|
|
410
|
-
version: 1,
|
|
411
|
-
}
|
|
412
|
-
writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
413
|
-
writeFileSync(
|
|
414
|
-
join(taskDir, ".gitignore"),
|
|
415
|
-
"# SQLite transients — tasks.db itself is committed.\n*.db-journal\n*.db-wal\n*.db-shm\n",
|
|
416
|
-
)
|
|
417
|
-
return new TaskStore(root)
|
|
418
|
-
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text format a board is made of: one markdown file per ticket, one per
|
|
3
|
+
* comment, each carrying a small frontmatter block. This module is pure
|
|
4
|
+
* (de)serialization — no filesystem — so the store, the migration, and the
|
|
5
|
+
* hosted board's worker can all agree on bytes by construction.
|
|
6
|
+
*
|
|
7
|
+
* The frontmatter is a deliberately tiny YAML subset: every value on one line,
|
|
8
|
+
* written either bare (timestamps, statuses) or as JSON (strings that need
|
|
9
|
+
* quoting, arrays, numbers, booleans). JSON is valid YAML, so the files render
|
|
10
|
+
* as ordinary frontmatter on GitHub while the parser stays `JSON.parse` plus a
|
|
11
|
+
* bare-string fallback — no dependency, no ambiguity.
|
|
12
|
+
*
|
|
13
|
+
* The ticket's title is the body's first `# ` heading and the description is
|
|
14
|
+
* everything after it. That keeps the whole file readable as a document — a PR
|
|
15
|
+
* diff of a new ticket *is* the ticket.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { STATUSES, isStatus, type Status } from "./types.ts"
|
|
19
|
+
|
|
20
|
+
/** Everything `ticket.md` stores. Relations live on the blocked side only. */
|
|
21
|
+
export interface TicketDoc {
|
|
22
|
+
title: string
|
|
23
|
+
description: string
|
|
24
|
+
status: Status
|
|
25
|
+
tags: string[]
|
|
26
|
+
milestone: string | null
|
|
27
|
+
needsHuman: boolean
|
|
28
|
+
/** Task numbers blocking this one — the single stored side of the relation. */
|
|
29
|
+
blockedBy: number[]
|
|
30
|
+
prs: string[]
|
|
31
|
+
position: number
|
|
32
|
+
createdAt: string
|
|
33
|
+
updatedAt: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CommentDoc {
|
|
37
|
+
author: string
|
|
38
|
+
createdAt: string
|
|
39
|
+
body: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A parse that names the file it failed in — these files invite hand-editing. */
|
|
43
|
+
export class TicketParseError extends Error {}
|
|
44
|
+
|
|
45
|
+
// ── The frontmatter subset ───────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
interface RawDoc {
|
|
48
|
+
fields: Map<string, string>
|
|
49
|
+
body: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* One scalar: JSON if it parses, the raw string otherwise. The fallback is
|
|
54
|
+
* what lets timestamps and simple strings be written bare — and what forgives
|
|
55
|
+
* a hand-edit that writes `milestone: launch` without quotes.
|
|
56
|
+
*/
|
|
57
|
+
function parseScalar(raw: string): unknown {
|
|
58
|
+
const trimmed = raw.trim()
|
|
59
|
+
if (trimmed === "") return ""
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(trimmed) as unknown
|
|
62
|
+
} catch {
|
|
63
|
+
return trimmed
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function splitRaw(text: string, where: string): RawDoc {
|
|
68
|
+
const lines = text.replace(/\r\n/g, "\n").split("\n")
|
|
69
|
+
if (lines[0] !== "---") {
|
|
70
|
+
throw new TicketParseError(`${where}: expected the file to start with a --- frontmatter block`)
|
|
71
|
+
}
|
|
72
|
+
const end = lines.indexOf("---", 1)
|
|
73
|
+
if (end < 0) {
|
|
74
|
+
throw new TicketParseError(`${where}: unterminated frontmatter — no closing ---`)
|
|
75
|
+
}
|
|
76
|
+
const fields = new Map<string, string>()
|
|
77
|
+
for (const line of lines.slice(1, end)) {
|
|
78
|
+
if (!line.trim()) continue
|
|
79
|
+
const colon = line.indexOf(":")
|
|
80
|
+
if (colon < 0) {
|
|
81
|
+
throw new TicketParseError(`${where}: not a "key: value" frontmatter line: ${line}`)
|
|
82
|
+
}
|
|
83
|
+
fields.set(line.slice(0, colon).trim(), line.slice(colon + 1))
|
|
84
|
+
}
|
|
85
|
+
const body = lines.slice(end + 1).join("\n")
|
|
86
|
+
return { fields, body: body.replace(/^\n+/, "").replace(/\s+$/, "") }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function field(raw: RawDoc, key: string): unknown {
|
|
90
|
+
const value = raw.fields.get(key)
|
|
91
|
+
return value === undefined ? undefined : parseScalar(value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function stringField(raw: RawDoc, key: string, where: string, fallback: string): string {
|
|
95
|
+
const value = field(raw, key)
|
|
96
|
+
if (value === undefined) return fallback
|
|
97
|
+
if (typeof value === "string") return value
|
|
98
|
+
throw new TicketParseError(`${where}: ${key} should be a string`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function stringList(raw: RawDoc, key: string, where: string): string[] {
|
|
102
|
+
const value = field(raw, key)
|
|
103
|
+
if (value === undefined) return []
|
|
104
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "string")) return value
|
|
105
|
+
throw new TicketParseError(`${where}: ${key} should be a list of strings, e.g. ["a", "b"]`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function numberList(raw: RawDoc, key: string, where: string): number[] {
|
|
109
|
+
const value = field(raw, key)
|
|
110
|
+
if (value === undefined) return []
|
|
111
|
+
if (Array.isArray(value) && value.every((v) => typeof v === "number" && Number.isInteger(v))) {
|
|
112
|
+
return value as number[]
|
|
113
|
+
}
|
|
114
|
+
throw new TicketParseError(`${where}: ${key} should be a list of task numbers, e.g. [3, 7]`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Tickets ──────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Title ↔ first `# ` heading. Only the very first line can be the title, so a
|
|
121
|
+
* description that itself opens with a heading round-trips unambiguously.
|
|
122
|
+
*/
|
|
123
|
+
function splitTitle(body: string): { title: string | null; description: string } {
|
|
124
|
+
if (!body.startsWith("# ")) return { title: null, description: body }
|
|
125
|
+
const newline = body.indexOf("\n")
|
|
126
|
+
if (newline < 0) return { title: body.slice(2).trim(), description: "" }
|
|
127
|
+
return {
|
|
128
|
+
title: body.slice(2, newline).trim(),
|
|
129
|
+
description: body.slice(newline + 1).replace(/^\n+/, ""),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function parseTicket(text: string, where: string): TicketDoc {
|
|
134
|
+
const raw = splitRaw(text, where)
|
|
135
|
+
const status = stringField(raw, "status", where, "todo")
|
|
136
|
+
if (!isStatus(status)) {
|
|
137
|
+
throw new TicketParseError(
|
|
138
|
+
`${where}: invalid status "${status}" — one of: ${STATUSES.join(", ")}`,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
const milestone = field(raw, "milestone")
|
|
142
|
+
if (milestone !== undefined && milestone !== null && typeof milestone !== "string") {
|
|
143
|
+
throw new TicketParseError(`${where}: milestone should be a string`)
|
|
144
|
+
}
|
|
145
|
+
const needsHuman = field(raw, "needs_human")
|
|
146
|
+
const position = field(raw, "position")
|
|
147
|
+
if (position !== undefined && typeof position !== "number") {
|
|
148
|
+
throw new TicketParseError(`${where}: position should be a number`)
|
|
149
|
+
}
|
|
150
|
+
const { title, description } = splitTitle(raw.body)
|
|
151
|
+
if (title === null) {
|
|
152
|
+
throw new TicketParseError(`${where}: expected the body to start with "# <title>"`)
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
title,
|
|
156
|
+
description,
|
|
157
|
+
status,
|
|
158
|
+
tags: stringList(raw, "tags", where),
|
|
159
|
+
milestone: typeof milestone === "string" && milestone !== "" ? milestone : null,
|
|
160
|
+
needsHuman: needsHuman === true,
|
|
161
|
+
blockedBy: numberList(raw, "blocked_by", where),
|
|
162
|
+
prs: stringList(raw, "prs", where),
|
|
163
|
+
position: typeof position === "number" ? position : 0,
|
|
164
|
+
createdAt: stringField(raw, "created", where, ""),
|
|
165
|
+
updatedAt: stringField(raw, "updated", where, ""),
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** JSON array on one line — `["a", "b"]` — which is also valid YAML flow style. */
|
|
170
|
+
function list(values: (string | number)[]): string {
|
|
171
|
+
return `[${values.map((v) => JSON.stringify(v)).join(", ")}]`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The canonical bytes for a ticket. Field order is fixed and empty fields are
|
|
176
|
+
* omitted, so an unchanged ticket always serializes identically — diffs show
|
|
177
|
+
* edits, never churn.
|
|
178
|
+
*/
|
|
179
|
+
export function serializeTicket(doc: TicketDoc): string {
|
|
180
|
+
const lines = ["---", `status: ${doc.status}`]
|
|
181
|
+
if (doc.tags.length) lines.push(`tags: ${list(doc.tags)}`)
|
|
182
|
+
if (doc.milestone) lines.push(`milestone: ${JSON.stringify(doc.milestone)}`)
|
|
183
|
+
if (doc.needsHuman) lines.push(`needs_human: true`)
|
|
184
|
+
if (doc.blockedBy.length) lines.push(`blocked_by: ${list(doc.blockedBy)}`)
|
|
185
|
+
if (doc.prs.length) lines.push(`prs: ${list(doc.prs)}`)
|
|
186
|
+
lines.push(`position: ${doc.position}`)
|
|
187
|
+
lines.push(`created: ${doc.createdAt}`)
|
|
188
|
+
lines.push(`updated: ${doc.updatedAt}`)
|
|
189
|
+
lines.push("---", "", `# ${doc.title}`)
|
|
190
|
+
if (doc.description) lines.push("", doc.description)
|
|
191
|
+
return `${lines.join("\n")}\n`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Comments ─────────────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
export function parseComment(text: string, where: string): CommentDoc {
|
|
197
|
+
const raw = splitRaw(text, where)
|
|
198
|
+
return {
|
|
199
|
+
author: stringField(raw, "author", where, ""),
|
|
200
|
+
createdAt: stringField(raw, "created", where, ""),
|
|
201
|
+
body: raw.body,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function serializeComment(doc: CommentDoc): string {
|
|
206
|
+
const lines = ["---"]
|
|
207
|
+
if (doc.author) lines.push(`author: ${JSON.stringify(doc.author)}`)
|
|
208
|
+
lines.push(`created: ${doc.createdAt}`, "---", "", doc.body)
|
|
209
|
+
return `${lines.join("\n")}\n`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A comment's filename stem — readable, sortable, and unique enough that two
|
|
214
|
+
* branches almost never mint the same one: `2026-08-15T165943-claude`. The
|
|
215
|
+
* frontmatter stays authoritative for author and timestamp; the name is for
|
|
216
|
+
* humans scanning a directory and for the comment's stable id.
|
|
217
|
+
*/
|
|
218
|
+
export function commentStem(createdAt: string, author: string): string {
|
|
219
|
+
const compact = `${createdAt.slice(0, 10)}T${createdAt.slice(11, 19).replace(/:/g, "")}`
|
|
220
|
+
const slug = author
|
|
221
|
+
.toLowerCase()
|
|
222
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
223
|
+
.replace(/^-+|-+$/g, "")
|
|
224
|
+
.slice(0, 24)
|
|
225
|
+
return slug ? `${compact}-${slug}` : compact
|
|
226
|
+
}
|