@nickmeriano/task 0.7.0 → 0.8.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 +12 -7
- package/dist/check.d.ts +42 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +364 -0
- package/dist/check.js.map +1 -0
- package/dist/check.test.d.ts +9 -0
- package/dist/check.test.d.ts.map +1 -0
- package/dist/check.test.js +209 -0
- package/dist/check.test.js.map +1 -0
- package/dist/claim.d.ts +56 -3
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +164 -9
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.js +168 -14
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +504 -93
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +41 -15
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +197 -99
- package/dist/file-store.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/overview.d.ts +43 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +53 -0
- package/dist/overview.js.map +1 -0
- package/dist/overview.test.d.ts +8 -0
- package/dist/overview.test.d.ts.map +1 -0
- package/dist/overview.test.js +47 -0
- package/dist/overview.test.js.map +1 -0
- package/dist/promote.test.d.ts +14 -0
- package/dist/promote.test.d.ts.map +1 -0
- package/dist/promote.test.js +106 -0
- package/dist/promote.test.js.map +1 -0
- package/dist/search.d.ts +32 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +66 -0
- package/dist/search.js.map +1 -0
- package/dist/search.test.d.ts +2 -0
- package/dist/search.test.d.ts.map +1 -0
- package/dist/search.test.js +53 -0
- package/dist/search.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +10 -2
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +16 -50
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +0 -368
- package/dist/store.js.map +1 -1
- package/dist/store.test.d.ts +1 -2
- package/dist/store.test.d.ts.map +1 -1
- package/dist/store.test.js +77 -48
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +55 -2
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +177 -7
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +67 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +97 -31
- package/src/check.test.ts +271 -0
- package/src/check.ts +454 -0
- package/src/claim.test.ts +202 -14
- package/src/claim.ts +194 -10
- package/src/cli.ts +510 -89
- package/src/file-store.ts +217 -111
- package/src/index.ts +4 -2
- package/src/overview.test.ts +51 -0
- package/src/overview.ts +90 -0
- package/src/promote.test.ts +131 -0
- package/src/search.test.ts +64 -0
- package/src/search.ts +91 -0
- package/src/server.ts +11 -2
- package/src/store.test.ts +89 -57
- package/src/store.ts +18 -431
- package/src/ticket-doc.ts +210 -10
- package/src/types.ts +71 -14
- package/ui/dist/assets/{index-oJzomUDL.js → index-BJmOsOdR.js} +76 -76
- package/ui/dist/assets/index-BoqQlqSU.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CXW8uT5f.css +0 -1
package/src/file-store.ts
CHANGED
|
@@ -10,23 +10,29 @@ import {
|
|
|
10
10
|
import { basename, dirname, join } from "node:path"
|
|
11
11
|
import {
|
|
12
12
|
commentStem,
|
|
13
|
+
isGoalSlug,
|
|
13
14
|
parseComment,
|
|
15
|
+
parseGoal,
|
|
14
16
|
parseTicket,
|
|
15
17
|
serializeComment,
|
|
18
|
+
serializeGoal,
|
|
16
19
|
serializeTicket,
|
|
20
|
+
slugifyGoal,
|
|
21
|
+
type GoalDoc,
|
|
17
22
|
type TicketDoc,
|
|
18
23
|
} from "./ticket-doc.ts"
|
|
19
24
|
import {
|
|
20
25
|
CONFIG_FILE,
|
|
21
|
-
DB_FILE,
|
|
22
26
|
POSITION_GAP,
|
|
23
27
|
TASK_DIR,
|
|
24
|
-
TaskStore,
|
|
25
28
|
derivePrefix,
|
|
26
29
|
type Store,
|
|
27
30
|
} from "./store.ts"
|
|
28
31
|
import type {
|
|
29
32
|
Comment,
|
|
33
|
+
Goal,
|
|
34
|
+
GoalInput,
|
|
35
|
+
GoalPatch,
|
|
30
36
|
ProjectConfig,
|
|
31
37
|
Status,
|
|
32
38
|
Task,
|
|
@@ -38,8 +44,11 @@ import type {
|
|
|
38
44
|
export const TICKETS_DIR = "tickets"
|
|
39
45
|
/** Finished tickets moved out of the hot path — same per-ticket layout. */
|
|
40
46
|
export const ARCHIVE_DIR = "archive"
|
|
41
|
-
const TICKET_FILE = "ticket.md"
|
|
42
|
-
const COMMENTS_DIR = "comments"
|
|
47
|
+
export const TICKET_FILE = "ticket.md"
|
|
48
|
+
export const COMMENTS_DIR = "comments"
|
|
49
|
+
/** One file per goal — `.task/goals/<slug>.md`; archived ones one level down. */
|
|
50
|
+
export const GOALS_DIR = "goals"
|
|
51
|
+
export const GOALS_ARCHIVE_DIR = "archive"
|
|
43
52
|
|
|
44
53
|
function now(): string {
|
|
45
54
|
return new Date().toISOString()
|
|
@@ -79,6 +88,8 @@ export class FileStore implements Store {
|
|
|
79
88
|
readonly config: ProjectConfig
|
|
80
89
|
private ticketsDir: string
|
|
81
90
|
private archiveDir: string
|
|
91
|
+
private goalsDir: string
|
|
92
|
+
private goalsArchiveDir: string
|
|
82
93
|
|
|
83
94
|
constructor(root: string) {
|
|
84
95
|
this.root = root
|
|
@@ -88,6 +99,8 @@ export class FileStore implements Store {
|
|
|
88
99
|
) as ProjectConfig
|
|
89
100
|
this.ticketsDir = join(this.taskDir, TICKETS_DIR)
|
|
90
101
|
this.archiveDir = join(this.taskDir, ARCHIVE_DIR)
|
|
102
|
+
this.goalsDir = join(this.taskDir, GOALS_DIR)
|
|
103
|
+
this.goalsArchiveDir = join(this.goalsDir, GOALS_ARCHIVE_DIR)
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
close(): void {}
|
|
@@ -184,7 +197,7 @@ export class FileStore implements Store {
|
|
|
184
197
|
description: ticket.doc.description,
|
|
185
198
|
status: ticket.doc.status,
|
|
186
199
|
tags: ticket.doc.tags,
|
|
187
|
-
|
|
200
|
+
goal: ticket.doc.goal,
|
|
188
201
|
needsHuman: ticket.doc.needsHuman,
|
|
189
202
|
blocks,
|
|
190
203
|
blockedBy: [...ticket.doc.blockedBy].sort((a, b) => a - b),
|
|
@@ -200,8 +213,8 @@ export class FileStore implements Store {
|
|
|
200
213
|
const wanted = new Set<Status>(filter.statuses)
|
|
201
214
|
tickets = tickets.filter((t) => wanted.has(t.doc.status))
|
|
202
215
|
}
|
|
203
|
-
if (filter.
|
|
204
|
-
tickets = tickets.filter((t) => t.doc.
|
|
216
|
+
if (filter.goal) {
|
|
217
|
+
tickets = tickets.filter((t) => t.doc.goal === filter.goal)
|
|
205
218
|
}
|
|
206
219
|
if (filter.needsHuman !== undefined) {
|
|
207
220
|
tickets = tickets.filter((t) => t.doc.needsHuman === filter.needsHuman)
|
|
@@ -249,12 +262,15 @@ export class FileStore implements Store {
|
|
|
249
262
|
// that old comments or PR titles still point at.
|
|
250
263
|
const taken = [...all.map((t) => t.number), ...this.archivedNumbers()]
|
|
251
264
|
const number = taken.reduce((max, n) => Math.max(max, n), 0) + 1
|
|
252
|
-
|
|
265
|
+
// Dump by default: new tickets land in backlog, the no-quality-bar list;
|
|
266
|
+
// `todo` (the ready list) is an explicit choice — usually `task promote`.
|
|
267
|
+
const status = input.status ?? "backlog"
|
|
253
268
|
for (const target of [...(input.blocks ?? []), ...(input.blockedBy ?? [])]) {
|
|
254
269
|
if (!all.some((t) => t.number === target)) {
|
|
255
270
|
throw new Error(`no such task: ${this.displayId(target)}`)
|
|
256
271
|
}
|
|
257
272
|
}
|
|
273
|
+
if (input.goal) this.assertGoalAssignable(input.goal)
|
|
258
274
|
// New tasks land at the bottom of their column.
|
|
259
275
|
const column = all.filter((t) => t.doc.status === status)
|
|
260
276
|
const bottom = column.length ? Math.max(...column.map((t) => t.doc.position)) : 0
|
|
@@ -266,7 +282,7 @@ export class FileStore implements Store {
|
|
|
266
282
|
description: input.description ?? "",
|
|
267
283
|
status,
|
|
268
284
|
tags: input.tags ?? [],
|
|
269
|
-
|
|
285
|
+
goal: input.goal ?? null,
|
|
270
286
|
needsHuman: input.needsHuman ?? false,
|
|
271
287
|
blockedBy: [...new Set(input.blockedBy ?? [])].sort((a, b) => a - b),
|
|
272
288
|
prs: input.prs ?? [],
|
|
@@ -340,7 +356,10 @@ export class FileStore implements Store {
|
|
|
340
356
|
if (patch.title !== undefined) set("title", patch.title)
|
|
341
357
|
if (patch.description !== undefined) set("description", patch.description)
|
|
342
358
|
if (patch.tags !== undefined) set("tags", patch.tags)
|
|
343
|
-
if (patch.
|
|
359
|
+
if (patch.goal !== undefined) {
|
|
360
|
+
if (patch.goal) this.assertGoalAssignable(patch.goal)
|
|
361
|
+
set("goal", patch.goal)
|
|
362
|
+
}
|
|
344
363
|
if (patch.needsHuman !== undefined) set("needsHuman", patch.needsHuman)
|
|
345
364
|
if (patch.prs !== undefined) set("prs", patch.prs)
|
|
346
365
|
if (patch.status !== undefined) {
|
|
@@ -479,6 +498,175 @@ export class FileStore implements Store {
|
|
|
479
498
|
return this.get(number)!
|
|
480
499
|
}
|
|
481
500
|
|
|
501
|
+
// ── Goals ──────────────────────────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
private goalPath(slug: string, archived: boolean): string {
|
|
504
|
+
return join(archived ? this.goalsArchiveDir : this.goalsDir, `${slug}.md`)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private readGoalDir(archived: boolean): Goal[] {
|
|
508
|
+
const dir = archived ? this.goalsArchiveDir : this.goalsDir
|
|
509
|
+
if (!existsSync(dir)) return []
|
|
510
|
+
const goals: Goal[] = []
|
|
511
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
512
|
+
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith(".")) continue
|
|
513
|
+
const slug = entry.name.slice(0, -3)
|
|
514
|
+
const path = join(dir, entry.name)
|
|
515
|
+
const doc = parseGoal(readFileSync(path, "utf8"), path)
|
|
516
|
+
goals.push(this.toGoal(slug, doc, archived))
|
|
517
|
+
}
|
|
518
|
+
goals.sort((a, b) => a.slug.localeCompare(b.slug))
|
|
519
|
+
return goals
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private toGoal(slug: string, doc: GoalDoc, archived: boolean): Goal {
|
|
523
|
+
return {
|
|
524
|
+
slug,
|
|
525
|
+
title: doc.title,
|
|
526
|
+
description: doc.description,
|
|
527
|
+
createdAt: doc.createdAt,
|
|
528
|
+
updatedAt: doc.updatedAt,
|
|
529
|
+
// Absent on live goals, so existing payloads carry no new field.
|
|
530
|
+
...(archived ? { archived: true } : {}),
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
goals(archived = false): Goal[] {
|
|
535
|
+
return this.readGoalDir(archived)
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
getGoal(slug: string): Goal | null {
|
|
539
|
+
for (const archived of [false, true]) {
|
|
540
|
+
const path = this.goalPath(slug, archived)
|
|
541
|
+
if (!existsSync(path)) continue
|
|
542
|
+
return this.toGoal(slug, parseGoal(readFileSync(path, "utf8"), path), archived)
|
|
543
|
+
}
|
|
544
|
+
return null
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The gate every ticket write goes through: a `goal:` may only reference a
|
|
549
|
+
* *live* goal. Naming an archived one is refused too — assigning new work to
|
|
550
|
+
* a goal that's been shelved is the same mistake as editing an archived
|
|
551
|
+
* ticket, and gets the same answer.
|
|
552
|
+
*/
|
|
553
|
+
private assertGoalAssignable(slug: string): void {
|
|
554
|
+
if (existsSync(this.goalPath(slug, false))) return
|
|
555
|
+
if (existsSync(this.goalPath(slug, true))) {
|
|
556
|
+
throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`)
|
|
557
|
+
}
|
|
558
|
+
throw new Error(`no such goal: "${slug}" — create it with \`task goal add\``)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
private writeGoal(slug: string, doc: GoalDoc, archived: boolean): void {
|
|
562
|
+
mkdirSync(archived ? this.goalsArchiveDir : this.goalsDir, { recursive: true })
|
|
563
|
+
writeAtomic(this.goalPath(slug, archived), serializeGoal(doc))
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
createGoal(input: GoalInput): Goal {
|
|
567
|
+
const title = input.title.trim()
|
|
568
|
+
if (!title) throw new Error("a goal needs a title")
|
|
569
|
+
const slug = input.slug ?? slugifyGoal(title)
|
|
570
|
+
if (!isGoalSlug(slug)) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
`invalid goal slug: "${slug}" — lowercase letters, digits and dashes ("archive" is reserved)`,
|
|
573
|
+
)
|
|
574
|
+
}
|
|
575
|
+
if (this.getGoal(slug)) throw new Error(`goal "${slug}" already exists`)
|
|
576
|
+
const timestamp = now()
|
|
577
|
+
const doc: GoalDoc = {
|
|
578
|
+
title,
|
|
579
|
+
description: input.description ?? "",
|
|
580
|
+
createdAt: timestamp,
|
|
581
|
+
updatedAt: timestamp,
|
|
582
|
+
}
|
|
583
|
+
this.writeGoal(slug, doc, false)
|
|
584
|
+
return this.toGoal(slug, doc, false)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
updateGoal(slug: string, patch: GoalPatch): Goal {
|
|
588
|
+
const path = this.goalPath(slug, false)
|
|
589
|
+
if (!existsSync(path)) {
|
|
590
|
+
if (existsSync(this.goalPath(slug, true))) {
|
|
591
|
+
throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`)
|
|
592
|
+
}
|
|
593
|
+
throw new Error(`no such goal: "${slug}"`)
|
|
594
|
+
}
|
|
595
|
+
const doc = parseGoal(readFileSync(path, "utf8"), path)
|
|
596
|
+
if (patch.title !== undefined) {
|
|
597
|
+
const title = patch.title.trim()
|
|
598
|
+
if (!title) throw new Error("a goal needs a title")
|
|
599
|
+
doc.title = title
|
|
600
|
+
}
|
|
601
|
+
if (patch.description !== undefined) doc.description = patch.description
|
|
602
|
+
doc.updatedAt = now()
|
|
603
|
+
this.writeGoal(slug, doc, false)
|
|
604
|
+
return this.toGoal(slug, doc, false)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* A goal leaves the board only after its work has: archiving is refused
|
|
609
|
+
* while any open task still points here. Done/canceled stragglers are fine —
|
|
610
|
+
* their references stay readable from the archive either way.
|
|
611
|
+
*/
|
|
612
|
+
archiveGoal(slug: string): Goal {
|
|
613
|
+
const path = this.goalPath(slug, false)
|
|
614
|
+
if (!existsSync(path)) {
|
|
615
|
+
if (existsSync(this.goalPath(slug, true))) {
|
|
616
|
+
throw new Error(`goal "${slug}" is already archived`)
|
|
617
|
+
}
|
|
618
|
+
throw new Error(`no such goal: "${slug}"`)
|
|
619
|
+
}
|
|
620
|
+
const open = this.readAll().filter(
|
|
621
|
+
(t) => t.doc.goal === slug && t.doc.status !== "done" && t.doc.status !== "canceled",
|
|
622
|
+
)
|
|
623
|
+
if (open.length > 0) {
|
|
624
|
+
const ids = open.map((t) => this.displayId(t.number)).join(", ")
|
|
625
|
+
throw new Error(`goal "${slug}" still has open tasks (${ids}) — finish or reassign them first`)
|
|
626
|
+
}
|
|
627
|
+
mkdirSync(this.goalsArchiveDir, { recursive: true })
|
|
628
|
+
renameSync(path, this.goalPath(slug, true))
|
|
629
|
+
return this.getGoal(slug)!
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
unarchiveGoal(slug: string): Goal {
|
|
633
|
+
const path = this.goalPath(slug, true)
|
|
634
|
+
if (!existsSync(path)) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
existsSync(this.goalPath(slug, false))
|
|
637
|
+
? `goal "${slug}" isn't archived`
|
|
638
|
+
: `no such goal: "${slug}"`,
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
mkdirSync(this.goalsDir, { recursive: true })
|
|
642
|
+
renameSync(path, this.goalPath(slug, false))
|
|
643
|
+
return this.getGoal(slug)!
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Deleting is for goals that never happened. Any live reference — whatever
|
|
648
|
+
* its status — blocks it: pruning organization off finished tickets is a
|
|
649
|
+
* human call, so the human makes it by clearing the refs first. Archived
|
|
650
|
+
* tickets' references are left to dangle; every reader tolerates that.
|
|
651
|
+
*/
|
|
652
|
+
deleteGoal(slug: string): void {
|
|
653
|
+
const path = this.goalPath(slug, false)
|
|
654
|
+
if (!existsSync(path)) {
|
|
655
|
+
if (existsSync(this.goalPath(slug, true))) {
|
|
656
|
+
throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`)
|
|
657
|
+
}
|
|
658
|
+
throw new Error(`no such goal: "${slug}"`)
|
|
659
|
+
}
|
|
660
|
+
const referencing = this.readAll().filter((t) => t.doc.goal === slug)
|
|
661
|
+
if (referencing.length > 0) {
|
|
662
|
+
const ids = referencing.map((t) => this.displayId(t.number)).join(", ")
|
|
663
|
+
throw new Error(
|
|
664
|
+
`goal "${slug}" is referenced by ${ids} — clear those with \`task update <id> --goal ""\` first`,
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
rmSync(path)
|
|
668
|
+
}
|
|
669
|
+
|
|
482
670
|
// ── Comments ───────────────────────────────────────────────────────────────
|
|
483
671
|
|
|
484
672
|
/**
|
|
@@ -549,12 +737,15 @@ export class FileStore implements Store {
|
|
|
549
737
|
}
|
|
550
738
|
}
|
|
551
739
|
|
|
552
|
-
// ── Opening
|
|
740
|
+
// ── Opening and initializing ─────────────────────────────────────────────────
|
|
741
|
+
|
|
742
|
+
/** The database a pre-0.6 board committed. Never read anymore, only ignored. */
|
|
743
|
+
const LEGACY_DB_FILE = "tasks.db"
|
|
553
744
|
|
|
554
745
|
/** What `.task/.gitignore` says on a text-canonical board. */
|
|
555
746
|
const GITIGNORE = `# Board state is the text files in tickets/ — the database (if one is still
|
|
556
|
-
# around from
|
|
557
|
-
${
|
|
747
|
+
# around from a pre-0.6 board) is legacy and stays out of git.
|
|
748
|
+
${LEGACY_DB_FILE}
|
|
558
749
|
*.db-journal
|
|
559
750
|
*.db-wal
|
|
560
751
|
*.db-shm
|
|
@@ -562,16 +753,22 @@ ${DB_FILE}
|
|
|
562
753
|
`
|
|
563
754
|
|
|
564
755
|
/**
|
|
565
|
-
* The board in `root
|
|
566
|
-
*
|
|
567
|
-
*
|
|
756
|
+
* The board in `root`. Text files are the only backend; a board whose state is
|
|
757
|
+
* still a pre-0.6 committed SQLite database is refused loudly rather than read
|
|
758
|
+
* as empty — silently starting a parallel text board next to a database full
|
|
759
|
+
* of tickets is the one failure mode worse than an error.
|
|
568
760
|
*/
|
|
569
761
|
export function openBoard(root: string): Store {
|
|
570
762
|
const taskDir = join(root, TASK_DIR)
|
|
571
|
-
if (existsSync(join(taskDir, TICKETS_DIR)))
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
763
|
+
if (!existsSync(join(taskDir, TICKETS_DIR))) {
|
|
764
|
+
const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as ProjectConfig
|
|
765
|
+
if ((config.version ?? 1) < 2 && existsSync(join(taskDir, LEGACY_DB_FILE))) {
|
|
766
|
+
throw new Error(
|
|
767
|
+
"this board still stores its state in a pre-0.6 SQLite database — run `npx @nickmeriano/task@0.6 migrate` once, then retry",
|
|
768
|
+
)
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return new FileStore(root)
|
|
575
772
|
}
|
|
576
773
|
|
|
577
774
|
export interface InitOptions {
|
|
@@ -600,94 +797,3 @@ export function initProject(root: string, options: InitOptions): Store {
|
|
|
600
797
|
return new FileStore(root)
|
|
601
798
|
}
|
|
602
799
|
|
|
603
|
-
export interface MigrateResult {
|
|
604
|
-
tasks: number
|
|
605
|
-
comments: number
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* `task migrate` — export every ticket and comment from a legacy `tasks.db`
|
|
610
|
-
* into the text layout, flip the config to version 2, and gitignore the
|
|
611
|
-
* database. The database file itself is left on disk untouched, as a backup;
|
|
612
|
-
* it just stops being the state.
|
|
613
|
-
*/
|
|
614
|
-
export function migrateBoard(root: string): MigrateResult {
|
|
615
|
-
const taskDir = join(root, TASK_DIR)
|
|
616
|
-
if (existsSync(join(taskDir, TICKETS_DIR))) {
|
|
617
|
-
throw new Error("already migrated — .task/tickets/ exists")
|
|
618
|
-
}
|
|
619
|
-
if (!existsSync(join(taskDir, DB_FILE))) {
|
|
620
|
-
throw new Error(`nothing to migrate — no ${DB_FILE} in .task/`)
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
const legacy = new TaskStore(root)
|
|
624
|
-
let comments = 0
|
|
625
|
-
try {
|
|
626
|
-
const tasks = legacy.list({})
|
|
627
|
-
for (const task of tasks) {
|
|
628
|
-
const dir = join(taskDir, TICKETS_DIR, String(task.number))
|
|
629
|
-
mkdirSync(dir, { recursive: true })
|
|
630
|
-
writeFileSync(
|
|
631
|
-
join(dir, TICKET_FILE),
|
|
632
|
-
serializeTicket({
|
|
633
|
-
title: task.title,
|
|
634
|
-
description: task.description,
|
|
635
|
-
status: task.status,
|
|
636
|
-
tags: task.tags,
|
|
637
|
-
milestone: task.milestone,
|
|
638
|
-
needsHuman: task.needsHuman,
|
|
639
|
-
blockedBy: task.blockedBy,
|
|
640
|
-
prs: task.prs,
|
|
641
|
-
position: task.position,
|
|
642
|
-
createdAt: task.createdAt,
|
|
643
|
-
updatedAt: task.updatedAt,
|
|
644
|
-
}),
|
|
645
|
-
)
|
|
646
|
-
const taskComments = legacy.comments(task.number)
|
|
647
|
-
if (taskComments.length === 0) continue
|
|
648
|
-
const commentsDir = join(dir, COMMENTS_DIR)
|
|
649
|
-
mkdirSync(commentsDir)
|
|
650
|
-
let previous: { createdAt: string; id: string } | null = null
|
|
651
|
-
for (const comment of taskComments) {
|
|
652
|
-
// `comments()` sorts by (createdAt, id), so ids must sort in legacy
|
|
653
|
-
// (rowid) order wherever timestamps tie — and a fresh stem for a
|
|
654
|
-
// different author can sort *before* the previous one. Chain off the
|
|
655
|
-
// previous id instead: the collision suffix below then appends `-2`,
|
|
656
|
-
// which sorts after. The frontmatter stays authoritative for the
|
|
657
|
-
// author; the filename is only an ordering key.
|
|
658
|
-
let stem = commentStem(comment.createdAt, comment.author)
|
|
659
|
-
if (
|
|
660
|
-
previous &&
|
|
661
|
-
comment.createdAt === previous.createdAt &&
|
|
662
|
-
stem.localeCompare(previous.id) < 0
|
|
663
|
-
) {
|
|
664
|
-
stem = previous.id
|
|
665
|
-
}
|
|
666
|
-
let id = stem
|
|
667
|
-
for (let n = 2; existsSync(join(commentsDir, `${id}.md`)); n++) id = `${stem}-${n}`
|
|
668
|
-
previous = { createdAt: comment.createdAt, id }
|
|
669
|
-
writeFileSync(
|
|
670
|
-
join(commentsDir, `${id}.md`),
|
|
671
|
-
serializeComment({
|
|
672
|
-
author: comment.author,
|
|
673
|
-
createdAt: comment.createdAt,
|
|
674
|
-
body: comment.body,
|
|
675
|
-
}),
|
|
676
|
-
)
|
|
677
|
-
comments++
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
// Preserve whatever else the config carries; only the version flips.
|
|
682
|
-
const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8")) as Record<
|
|
683
|
-
string,
|
|
684
|
-
unknown
|
|
685
|
-
>
|
|
686
|
-
config.version = 2
|
|
687
|
-
writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
|
|
688
|
-
writeFileSync(join(taskDir, ".gitignore"), GITIGNORE)
|
|
689
|
-
return { tasks: tasks.length, comments }
|
|
690
|
-
} finally {
|
|
691
|
-
legacy.close()
|
|
692
|
-
}
|
|
693
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// Programmatic access to the same store the CLI and `task serve` use — for
|
|
2
2
|
// scripts that want to read or write .task/ without shelling out.
|
|
3
3
|
export {
|
|
4
|
-
TaskStore,
|
|
5
4
|
boardConfig,
|
|
6
5
|
derivePrefix,
|
|
7
6
|
findBoards,
|
|
@@ -16,17 +15,20 @@ export {
|
|
|
16
15
|
FileStore,
|
|
17
16
|
TICKETS_DIR,
|
|
18
17
|
initProject,
|
|
19
|
-
migrateBoard,
|
|
20
18
|
openBoard,
|
|
21
19
|
} from "./file-store.ts"
|
|
22
20
|
export {
|
|
23
21
|
parseTicket,
|
|
22
|
+
parseTicketLenient,
|
|
24
23
|
serializeTicket,
|
|
25
24
|
parseComment,
|
|
25
|
+
parseCommentLenient,
|
|
26
26
|
serializeComment,
|
|
27
27
|
type TicketDoc,
|
|
28
28
|
type CommentDoc,
|
|
29
29
|
} from "./ticket-doc.ts"
|
|
30
|
+
export { checkBoards, checkRoot, type CheckIssue, type CheckResult } from "./check.ts"
|
|
31
|
+
export { searchStore, type SearchMatch, type SearchResult } from "./search.ts"
|
|
30
32
|
export {
|
|
31
33
|
DEFAULT_CLAIM_PREFIX,
|
|
32
34
|
ClaimError,
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `task overview` derives everything at read time — these tests pin the
|
|
3
|
+
* derivations: per-status counts, per-goal progress, needs-human, staleness
|
|
4
|
+
* against an injected clock, oldest-open, and the "unknown, not zero" answer
|
|
5
|
+
* for claims when there is no origin to ask.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict"
|
|
9
|
+
import { test } from "node:test"
|
|
10
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
11
|
+
import { tmpdir } from "node:os"
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
import { initProject } from "./file-store.ts"
|
|
14
|
+
import { STALE_DAYS, buildOverview } from "./overview.ts"
|
|
15
|
+
|
|
16
|
+
function tempRoot(): string {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), "task-overview-"))
|
|
18
|
+
process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
|
|
19
|
+
return dir
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("overview derives board health at read time", () => {
|
|
23
|
+
const root = tempRoot()
|
|
24
|
+
const store = initProject(root, { name: "health", prefix: "HLT" })
|
|
25
|
+
store.createGoal({ title: "Launch" })
|
|
26
|
+
const a = store.create({ title: "Oldest", status: "todo", goal: "launch" })
|
|
27
|
+
const b = store.create({ title: "Shipped", status: "todo", goal: "launch" })
|
|
28
|
+
store.update(b.number, { status: "done" })
|
|
29
|
+
store.create({ title: "Waiting on a person", status: "todo", needsHuman: true })
|
|
30
|
+
store.create({ title: "Still parked", status: "backlog" })
|
|
31
|
+
|
|
32
|
+
// Age the first ticket by hand — the store always writes "now".
|
|
33
|
+
const ticketPath = join(root, ".task", "tickets", String(a.number), "ticket.md")
|
|
34
|
+
const aged = readFileSync(ticketPath, "utf8")
|
|
35
|
+
.replace(/created: .*/, "created: 2020-01-01T00:00:00.000Z")
|
|
36
|
+
.replace(/updated: .*/, "updated: 2020-01-01T00:00:00.000Z")
|
|
37
|
+
writeFileSync(ticketPath, aged)
|
|
38
|
+
|
|
39
|
+
const overview = buildOverview(store, Date.parse("2020-01-10T00:00:00.000Z") + STALE_DAYS * 86_400_000)
|
|
40
|
+
assert.equal(overview.board, "health")
|
|
41
|
+
assert.equal(overview.prefix, "HLT")
|
|
42
|
+
assert.deepEqual(overview.statuses, { backlog: 1, todo: 2, in_progress: 0, done: 1, canceled: 0 })
|
|
43
|
+
assert.equal(overview.open, 3)
|
|
44
|
+
assert.equal(overview.needsHuman, 1)
|
|
45
|
+
// No origin to ask — unknown, not zero.
|
|
46
|
+
assert.equal(overview.claimed, null)
|
|
47
|
+
assert.deepEqual(overview.goals, [{ slug: "launch", title: "Launch", done: 1, total: 2 }])
|
|
48
|
+
// Only the aged ticket is stale at that clock (the others were written just now).
|
|
49
|
+
assert.deepEqual(overview.stale.map((t) => t.id), ["HLT-1"])
|
|
50
|
+
assert.equal(overview.oldestOpen?.id, "HLT-1")
|
|
51
|
+
})
|
package/src/overview.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `task overview` — one screen of board health (TAS-27): the numbers an agent
|
|
3
|
+
* wants before choosing work and the numbers a human wants in a standup.
|
|
4
|
+
* Nothing configurable, no charts, and nothing stored — every figure is
|
|
5
|
+
* derived from the board at read time, the same rule goals follow.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { claimBranch, claimNamespace, remoteClaims } from "./claim.ts"
|
|
9
|
+
import type { Store } from "./store.ts"
|
|
10
|
+
import { STATUSES, type Status } from "./types.ts"
|
|
11
|
+
|
|
12
|
+
/** Untouched this long, an open ticket is stale. Fixed on purpose. */
|
|
13
|
+
export const STALE_DAYS = 14
|
|
14
|
+
|
|
15
|
+
const OPEN_STATUSES: readonly Status[] = ["backlog", "todo", "in_progress"]
|
|
16
|
+
|
|
17
|
+
export interface GoalProgress {
|
|
18
|
+
slug: string
|
|
19
|
+
title: string
|
|
20
|
+
done: number
|
|
21
|
+
total: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TicketRef {
|
|
25
|
+
id: string
|
|
26
|
+
title: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Overview {
|
|
30
|
+
board: string
|
|
31
|
+
prefix: string
|
|
32
|
+
/** Live board only — the archive is history, not health. */
|
|
33
|
+
statuses: Record<Status, number>
|
|
34
|
+
open: number
|
|
35
|
+
needsHuman: number
|
|
36
|
+
/**
|
|
37
|
+
* Open tickets whose claim branch exists on origin right now; null when
|
|
38
|
+
* origin can't be asked (no remote, offline) — unknown, not zero.
|
|
39
|
+
*/
|
|
40
|
+
claimed: string[] | null
|
|
41
|
+
goals: GoalProgress[]
|
|
42
|
+
/** Open tickets untouched for STALE_DAYS+, oldest first. */
|
|
43
|
+
stale: (TicketRef & { updatedAt: string })[]
|
|
44
|
+
oldestOpen: (TicketRef & { createdAt: string }) | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildOverview(store: Store, now: number = Date.now()): Overview {
|
|
48
|
+
const tasks = store.list({ statuses: undefined })
|
|
49
|
+
const statuses = Object.fromEntries(STATUSES.map((s) => [s, 0])) as Record<Status, number>
|
|
50
|
+
for (const task of tasks) statuses[task.status]++
|
|
51
|
+
const open = tasks.filter((t) => OPEN_STATUSES.includes(t.status))
|
|
52
|
+
|
|
53
|
+
let claimed: string[] | null = null
|
|
54
|
+
try {
|
|
55
|
+
const branches = remoteClaims(store.root, claimNamespace(store.config))
|
|
56
|
+
claimed = open
|
|
57
|
+
.filter((t) => branches.has(claimBranch(store.config, t.number)))
|
|
58
|
+
.map((t) => t.id)
|
|
59
|
+
} catch {
|
|
60
|
+
claimed = null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const goals = store.goals().map((goal) => {
|
|
64
|
+
const mine = tasks.filter((t) => t.goal === goal.slug)
|
|
65
|
+
const done = mine.filter((t) => t.status === "done" || t.status === "canceled").length
|
|
66
|
+
return { slug: goal.slug, title: goal.title, done, total: mine.length }
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const staleBefore = now - STALE_DAYS * 24 * 60 * 60 * 1000
|
|
70
|
+
const stale = open
|
|
71
|
+
.filter((t) => Date.parse(t.updatedAt) < staleBefore)
|
|
72
|
+
.sort((a, b) => Date.parse(a.updatedAt) - Date.parse(b.updatedAt))
|
|
73
|
+
.map((t) => ({ id: t.id, title: t.title, updatedAt: t.updatedAt }))
|
|
74
|
+
|
|
75
|
+
const oldest = [...open].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt))[0]
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
board: store.config.name,
|
|
79
|
+
prefix: store.config.prefix,
|
|
80
|
+
statuses,
|
|
81
|
+
open: open.length,
|
|
82
|
+
needsHuman: open.filter((t) => t.needsHuman).length,
|
|
83
|
+
claimed,
|
|
84
|
+
goals,
|
|
85
|
+
stale,
|
|
86
|
+
oldestOpen: oldest
|
|
87
|
+
? { id: oldest.id, title: oldest.title, createdAt: oldest.createdAt }
|
|
88
|
+
: null,
|
|
89
|
+
}
|
|
90
|
+
}
|