@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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `task promote` — from the outside (real CLI subprocesses, exit codes are
|
|
3
|
+
* the contract):
|
|
4
|
+
*
|
|
5
|
+
* 1. Any backlog ticket promotes to todo, exit 0 — there is no quality gate;
|
|
6
|
+
* planning happens at claim time.
|
|
7
|
+
* 2. Only backlog tickets promote — todo/in_progress/done are refused.
|
|
8
|
+
* 3. Blocked and needs-human tickets still promote, with the hold reported
|
|
9
|
+
* (stdout note, `holds` in --json) — those are claim-time gates.
|
|
10
|
+
* 4. `task add` with no --status lands in backlog: the dump list is the
|
|
11
|
+
* default, todo is the deliberate choice promote makes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import assert from "node:assert/strict"
|
|
15
|
+
import { test } from "node:test"
|
|
16
|
+
import { execFile } from "node:child_process"
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
18
|
+
import { tmpdir } from "node:os"
|
|
19
|
+
import { join } from "node:path"
|
|
20
|
+
import { fileURLToPath } from "node:url"
|
|
21
|
+
import { initProject } from "./file-store.ts"
|
|
22
|
+
import type { Task } from "./types.ts"
|
|
23
|
+
|
|
24
|
+
const CLI = fileURLToPath(new URL("./cli.ts", import.meta.url))
|
|
25
|
+
|
|
26
|
+
function tempDir(): string {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), "task-promote-test-"))
|
|
28
|
+
process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
|
|
29
|
+
return dir
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CliResult {
|
|
33
|
+
code: number
|
|
34
|
+
stdout: string
|
|
35
|
+
stderr: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The CLI as callers see it — a subprocess with an exit code. */
|
|
39
|
+
function cli(cwd: string, ...args: string[]): Promise<CliResult> {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
execFile(
|
|
42
|
+
process.execPath,
|
|
43
|
+
["--experimental-strip-types", CLI, ...args],
|
|
44
|
+
{ cwd },
|
|
45
|
+
(error, stdout, stderr) => {
|
|
46
|
+
resolve({ code: error ? ((error as { code?: number }).code ?? 1) : 0, stdout, stderr })
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A board with every promotion shape. Promotion never touches git, so a bare
|
|
54
|
+
* .task/ directory is the whole fixture:
|
|
55
|
+
* 1 "One-liner" backlog (no tags — nothing else required)
|
|
56
|
+
* 2 "Also backlog" backlog
|
|
57
|
+
* 3 "Blocked" backlog, blocked by 2
|
|
58
|
+
* 4 "For a person" backlog, needs-human
|
|
59
|
+
* 5 "Already queued" todo
|
|
60
|
+
*/
|
|
61
|
+
function fixture(): string {
|
|
62
|
+
const dir = tempDir()
|
|
63
|
+
const store = initProject(dir, { name: "promote board", prefix: "PRO" })
|
|
64
|
+
store.create({ title: "One-liner", status: "backlog" })
|
|
65
|
+
store.create({ title: "Also backlog", status: "backlog" })
|
|
66
|
+
store.create({ title: "Blocked", status: "backlog", blockedBy: [2] })
|
|
67
|
+
store.create({ title: "For a person", status: "backlog", needsHuman: true })
|
|
68
|
+
store.create({ title: "Already queued", status: "todo" })
|
|
69
|
+
store.close()
|
|
70
|
+
return dir
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function show(dir: string, id: string): Promise<Task> {
|
|
74
|
+
const result = await cli(dir, "show", id, "--json")
|
|
75
|
+
assert.equal(result.code, 0, result.stderr)
|
|
76
|
+
return (JSON.parse(result.stdout) as { task: Task }).task
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("any backlog ticket promotes to todo — no quality gate", async () => {
|
|
80
|
+
const dir = fixture()
|
|
81
|
+
const result = await cli(dir, "promote", "PRO-1", "--json")
|
|
82
|
+
assert.equal(result.code, 0, result.stderr)
|
|
83
|
+
const { task, holds } = JSON.parse(result.stdout) as { task: Task; holds: string[] }
|
|
84
|
+
assert.equal(task.status, "todo")
|
|
85
|
+
assert.deepEqual(holds, [])
|
|
86
|
+
assert.equal((await show(dir, "PRO-1")).status, "todo")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("only backlog tickets promote", async () => {
|
|
90
|
+
const dir = fixture()
|
|
91
|
+
const result = await cli(dir, "promote", "PRO-5")
|
|
92
|
+
assert.equal(result.code, 2)
|
|
93
|
+
assert.match(result.stderr, /is todo — only backlog tickets/)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test("blocked and needs-human tickets promote with the hold reported", async () => {
|
|
97
|
+
const dir = fixture()
|
|
98
|
+
const blocked = await cli(dir, "promote", "PRO-3", "--json")
|
|
99
|
+
assert.equal(blocked.code, 0, blocked.stderr)
|
|
100
|
+
const parsed = JSON.parse(blocked.stdout) as { task: Task; holds: string[] }
|
|
101
|
+
assert.equal(parsed.task.status, "todo")
|
|
102
|
+
assert.deepEqual(parsed.holds, ["blocked by PRO-2"])
|
|
103
|
+
|
|
104
|
+
const person = await cli(dir, "promote", "PRO-4")
|
|
105
|
+
assert.equal(person.code, 0, person.stderr)
|
|
106
|
+
assert.match(person.stdout, /needs-human — in todo, but not claimable/)
|
|
107
|
+
assert.equal((await show(dir, "PRO-4")).status, "todo")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test("task add with no --status lands in backlog", async () => {
|
|
111
|
+
const dir = fixture()
|
|
112
|
+
const added = await cli(dir, "add", "Dumped in passing", "--json")
|
|
113
|
+
assert.equal(added.code, 0, added.stderr)
|
|
114
|
+
const { task } = JSON.parse(added.stdout) as { task: Task }
|
|
115
|
+
assert.equal(task.status, "backlog")
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("--description-file reads markdown from disk; combining both flags refuses", async () => {
|
|
119
|
+
const dir = fixture()
|
|
120
|
+
const body = "## Plan\n\nA description with \"quotes\", `backticks`,\nand $(subshells) that never touch a shell.\n"
|
|
121
|
+
writeFileSync(join(dir, "desc.md"), body)
|
|
122
|
+
|
|
123
|
+
const added = await cli(dir, "add", "From a file", "--description-file", "desc.md", "--json")
|
|
124
|
+
assert.equal(added.code, 0, added.stderr)
|
|
125
|
+
const { task } = JSON.parse(added.stdout) as { task: Task }
|
|
126
|
+
assert.match(task.description ?? "", /"quotes", `backticks`,\nand \$\(subshells\)/)
|
|
127
|
+
|
|
128
|
+
const both = await cli(dir, "update", task.id, "--description", "x", "--description-file", "desc.md")
|
|
129
|
+
assert.equal(both.code, 1)
|
|
130
|
+
assert.match(both.stderr, /not both/)
|
|
131
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { test } from "node:test"
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs"
|
|
4
|
+
import { tmpdir } from "node:os"
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
import { initProject } from "./file-store.ts"
|
|
7
|
+
import { searchStore } from "./search.ts"
|
|
8
|
+
|
|
9
|
+
function tempRoot(): string {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "task-search-"))
|
|
11
|
+
process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
|
|
12
|
+
return dir
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("search covers titles, descriptions and comments, case-insensitively", () => {
|
|
16
|
+
const store = initProject(tempRoot(), { name: "search", prefix: "SRC" })
|
|
17
|
+
const a = store.create({ title: "Wire up Webhook retries", description: "exponential backoff" })
|
|
18
|
+
const b = store.create({ title: "Unrelated", description: "The webhook queue drains here." })
|
|
19
|
+
const c = store.create({ title: "Also unrelated" })
|
|
20
|
+
store.create({ title: "Noise" })
|
|
21
|
+
store.addComment(c.number, "root cause: the WEBHOOK signature check", "claude")
|
|
22
|
+
|
|
23
|
+
const results = searchStore(store, "webhook", {}, false)
|
|
24
|
+
assert.deepEqual(
|
|
25
|
+
results.map((r) => r.task.number).sort(),
|
|
26
|
+
[a.number, b.number, c.number],
|
|
27
|
+
)
|
|
28
|
+
const byNumber = new Map(results.map((r) => [r.task.number, r.matches]))
|
|
29
|
+
assert.deepEqual(byNumber.get(a.number)!.map((m) => m.field), ["title"])
|
|
30
|
+
assert.equal(byNumber.get(b.number)![0].snippet, "The webhook queue drains here.")
|
|
31
|
+
const comment = byNumber.get(c.number)![0]
|
|
32
|
+
assert.equal(comment.field, "comment")
|
|
33
|
+
assert.equal(comment.author, "claude")
|
|
34
|
+
assert.match(comment.snippet, /WEBHOOK signature/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("filters scope the search; --archived adds the archive in", () => {
|
|
38
|
+
const store = initProject(tempRoot(), { name: "scope", prefix: "SCP" })
|
|
39
|
+
const live = store.create({ title: "deploy pipeline", tags: ["infra"] })
|
|
40
|
+
store.create({ title: "deploy docs", tags: ["docs"] })
|
|
41
|
+
const old = store.create({ title: "deploy v1", status: "done" })
|
|
42
|
+
store.archive(old.number)
|
|
43
|
+
|
|
44
|
+
assert.deepEqual(
|
|
45
|
+
searchStore(store, "deploy", { tags: ["infra"] }, false).map((r) => r.task.number),
|
|
46
|
+
[live.number],
|
|
47
|
+
)
|
|
48
|
+
const withArchive = searchStore(store, "deploy", {}, true)
|
|
49
|
+
assert.ok(withArchive.some((r) => r.task.number === old.number && r.task.archived))
|
|
50
|
+
assert.equal(withArchive.length, 3)
|
|
51
|
+
// Without the flag the archived ticket stays out.
|
|
52
|
+
assert.equal(searchStore(store, "deploy", {}, false).length, 2)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("long lines are windowed so the hit is visible", () => {
|
|
56
|
+
const store = initProject(tempRoot(), { name: "win", prefix: "WIN" })
|
|
57
|
+
const filler = "x".repeat(150)
|
|
58
|
+
store.create({ title: "Long", description: `${filler} needle in here ${filler}` })
|
|
59
|
+
const [result] = searchStore(store, "needle", {}, false)
|
|
60
|
+
assert.ok(result.matches[0].snippet.length <= 102) // window + ellipses
|
|
61
|
+
assert.match(result.matches[0].snippet, /needle in here/)
|
|
62
|
+
assert.match(result.matches[0].snippet, /^…/)
|
|
63
|
+
assert.match(result.matches[0].snippet, /…$/)
|
|
64
|
+
})
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `task search` — find tickets by what they say, not just what they're tagged.
|
|
3
|
+
*
|
|
4
|
+
* Case-insensitive substring match over titles, descriptions and comments,
|
|
5
|
+
* scoped by the usual list filters. At current board sizes a straight scan of
|
|
6
|
+
* the parsed files is plenty; if boards ever grow to thousands of tickets,
|
|
7
|
+
* this is the one feature that would justify a derived FTS index — rebuilt
|
|
8
|
+
* from the files, never as state, which the text-canonical format was designed
|
|
9
|
+
* to allow.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Store } from "./store.ts"
|
|
13
|
+
import { STATUSES, type Task, type TaskFilter } from "./types.ts"
|
|
14
|
+
|
|
15
|
+
export interface SearchMatch {
|
|
16
|
+
field: "title" | "description" | "comment"
|
|
17
|
+
/** The matching line, trimmed and windowed around the first hit. */
|
|
18
|
+
snippet: string
|
|
19
|
+
/** Set on comment matches. */
|
|
20
|
+
commentId?: string
|
|
21
|
+
author?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SearchResult {
|
|
25
|
+
task: Task
|
|
26
|
+
matches: SearchMatch[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** How much of a matching line a snippet shows. */
|
|
30
|
+
const SNIPPET_MAX = 100
|
|
31
|
+
|
|
32
|
+
/** The first line containing the needle, windowed so the hit is visible. */
|
|
33
|
+
function snippetOf(text: string, needle: string): string {
|
|
34
|
+
const lines = text.split("\n")
|
|
35
|
+
const line = (lines.find((l) => l.toLowerCase().includes(needle)) ?? lines[0]).trim()
|
|
36
|
+
if (line.length <= SNIPPET_MAX) return line
|
|
37
|
+
const at = line.toLowerCase().indexOf(needle)
|
|
38
|
+
const start = Math.max(0, Math.min(at - 30, line.length - SNIPPET_MAX))
|
|
39
|
+
const end = Math.min(line.length, start + SNIPPET_MAX)
|
|
40
|
+
return `${start > 0 ? "…" : ""}${line.slice(start, end).trim()}${end < line.length ? "…" : ""}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Every task matching `query`, with one snippet per matching field (and one
|
|
45
|
+
* per matching comment). `includeArchived` searches the archive *in addition
|
|
46
|
+
* to* the board — unlike `list --archived`, which shows the archive instead,
|
|
47
|
+
* a search is a question about everything ever written down.
|
|
48
|
+
*/
|
|
49
|
+
export function searchStore(
|
|
50
|
+
store: Store,
|
|
51
|
+
query: string,
|
|
52
|
+
filter: TaskFilter,
|
|
53
|
+
includeArchived: boolean,
|
|
54
|
+
): SearchResult[] {
|
|
55
|
+
const needle = query.toLowerCase()
|
|
56
|
+
const pool = [
|
|
57
|
+
...store.list(filter),
|
|
58
|
+
...(includeArchived ? store.list({ ...filter, archived: true }) : []),
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
const results: SearchResult[] = []
|
|
62
|
+
for (const task of pool) {
|
|
63
|
+
const matches: SearchMatch[] = []
|
|
64
|
+
if (task.title.toLowerCase().includes(needle)) {
|
|
65
|
+
matches.push({ field: "title", snippet: snippetOf(task.title, needle) })
|
|
66
|
+
}
|
|
67
|
+
if (task.description.toLowerCase().includes(needle)) {
|
|
68
|
+
matches.push({ field: "description", snippet: snippetOf(task.description, needle) })
|
|
69
|
+
}
|
|
70
|
+
for (const comment of store.comments(task.number)) {
|
|
71
|
+
if (!comment.body.toLowerCase().includes(needle)) continue
|
|
72
|
+
matches.push({
|
|
73
|
+
field: "comment",
|
|
74
|
+
snippet: snippetOf(comment.body, needle),
|
|
75
|
+
commentId: comment.id,
|
|
76
|
+
...(comment.author ? { author: comment.author } : {}),
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
if (matches.length > 0) results.push({ task, matches })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Board order — grouped by status column, then position — same as `list`.
|
|
83
|
+
const order = new Map(STATUSES.map((s, i) => [s, i]))
|
|
84
|
+
results.sort(
|
|
85
|
+
(a, b) =>
|
|
86
|
+
order.get(a.task.status)! - order.get(b.task.status)! ||
|
|
87
|
+
a.task.position - b.task.position ||
|
|
88
|
+
a.task.number - b.task.number,
|
|
89
|
+
)
|
|
90
|
+
return results
|
|
91
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -45,8 +45,8 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
|
|
|
45
45
|
patch.status = body.status
|
|
46
46
|
}
|
|
47
47
|
if (Array.isArray(body.tags)) patch.tags = body.tags.map(String)
|
|
48
|
-
if (typeof body.
|
|
49
|
-
patch.
|
|
48
|
+
if (typeof body.goal === "string" || body.goal === null) {
|
|
49
|
+
patch.goal = body.goal === "" ? null : body.goal
|
|
50
50
|
}
|
|
51
51
|
if (typeof body.needsHuman === "boolean") patch.needsHuman = body.needsHuman
|
|
52
52
|
if (Array.isArray(body.blocks)) patch.blocks = body.blocks.map(taskNumber)
|
|
@@ -223,6 +223,15 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
223
223
|
return
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
if (path === "/api/goals" && req.method === "GET") {
|
|
227
|
+
// Read-only on purpose: a goal is created deliberately, with a
|
|
228
|
+
// description, via `task goal add` — a picker that mints goals from
|
|
229
|
+
// typed strings would just be milestones with extra steps. Archived
|
|
230
|
+
// goals ride along flagged, so old chips can still resolve a title.
|
|
231
|
+
json(res, 200, { goals: [...store.goals(), ...store.goals(true)] })
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
226
235
|
if (path === "/api/tasks" && req.method === "GET") {
|
|
227
236
|
const counts = store.commentCounts()
|
|
228
237
|
const tasks = store
|
package/src/store.test.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* 1. The text format round-trips — parse(serialize(x)) loses nothing.
|
|
5
5
|
* 2. `FileStore` behaves like the store always has (positions, links,
|
|
6
6
|
* comments, cascade on delete).
|
|
7
|
-
* 3.
|
|
8
|
-
* 4. Concurrent-writer shapes merge by construction: a comment is a new file,
|
|
7
|
+
* 3. Concurrent-writer shapes merge by construction: a comment is a new file,
|
|
9
8
|
* a claim is a one-line status diff.
|
|
10
9
|
*/
|
|
11
10
|
|
|
@@ -14,9 +13,9 @@ import { test } from "node:test"
|
|
|
14
13
|
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
15
14
|
import { tmpdir } from "node:os"
|
|
16
15
|
import { join } from "node:path"
|
|
17
|
-
import { FileStore, initProject,
|
|
16
|
+
import { FileStore, initProject, openBoard } from "./file-store.ts"
|
|
18
17
|
import { parseTicket, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
|
|
19
|
-
import {
|
|
18
|
+
import { findBoardsByPrefix, findScopeRoot } from "./store.ts"
|
|
20
19
|
|
|
21
20
|
function tempRoot(): string {
|
|
22
21
|
const dir = mkdtempSync(join(tmpdir(), "task-test-"))
|
|
@@ -31,7 +30,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
|
|
|
31
30
|
description: "One line.",
|
|
32
31
|
status: "todo",
|
|
33
32
|
tags: [],
|
|
34
|
-
|
|
33
|
+
goal: null,
|
|
35
34
|
needsHuman: false,
|
|
36
35
|
blockedBy: [],
|
|
37
36
|
prs: [],
|
|
@@ -46,7 +45,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
|
|
|
46
45
|
description: "# Not the title\n\n---\n\nstatus: not-a-field\n\n```md\n---\n```",
|
|
47
46
|
status: "in_progress",
|
|
48
47
|
tags: ["storage", "architecture"],
|
|
49
|
-
|
|
48
|
+
goal: "v2-storage",
|
|
50
49
|
needsHuman: true,
|
|
51
50
|
blockedBy: [3, 7],
|
|
52
51
|
prs: ["https://github.com/x/y/pull/1"],
|
|
@@ -59,7 +58,7 @@ test("ticket documents round-trip, including the awkward bodies", () => {
|
|
|
59
58
|
description: "",
|
|
60
59
|
status: "backlog",
|
|
61
60
|
tags: [],
|
|
62
|
-
|
|
61
|
+
goal: null,
|
|
63
62
|
needsHuman: false,
|
|
64
63
|
blockedBy: [],
|
|
65
64
|
prs: [],
|
|
@@ -77,7 +76,7 @@ test("hand-edits are forgiven where they're unambiguous", () => {
|
|
|
77
76
|
const text = [
|
|
78
77
|
"---",
|
|
79
78
|
"status: todo",
|
|
80
|
-
"
|
|
79
|
+
"goal: launch", // unquoted string — the YAML instinct
|
|
81
80
|
"position: 8",
|
|
82
81
|
"created: 2026-08-15T16:00:33.758Z",
|
|
83
82
|
"updated: 2026-08-15T16:00:33.758Z",
|
|
@@ -88,9 +87,16 @@ test("hand-edits are forgiven where they're unambiguous", () => {
|
|
|
88
87
|
"Body.",
|
|
89
88
|
].join("\n")
|
|
90
89
|
const doc = parseTicket(text, "test")
|
|
91
|
-
assert.equal(doc.
|
|
90
|
+
assert.equal(doc.goal, "launch")
|
|
92
91
|
assert.equal(doc.position, 8)
|
|
93
92
|
|
|
93
|
+
// The pre-0.8 spelling still reads as the goal — old boards keep rendering,
|
|
94
|
+
// and `task check --fix`'s canonical rewrite is the migration.
|
|
95
|
+
const legacy = parseTicket(text.replace("goal: launch", "milestone: launch"), "test")
|
|
96
|
+
assert.equal(legacy.goal, "launch")
|
|
97
|
+
assert.match(serializeTicket(legacy), /goal: "launch"/)
|
|
98
|
+
assert.doesNotMatch(serializeTicket(legacy), /milestone/)
|
|
99
|
+
|
|
94
100
|
assert.throws(
|
|
95
101
|
() => parseTicket(text.replace("status: todo", "status: todoo"), "here"),
|
|
96
102
|
/here.*invalid status/,
|
|
@@ -104,7 +110,7 @@ test("FileStore keeps the store's contract", () => {
|
|
|
104
110
|
assert.equal(store.config.prefix, "SAM")
|
|
105
111
|
assert.ok(store instanceof FileStore)
|
|
106
112
|
|
|
107
|
-
const a = store.create({ title: "First", description: "alpha", tags: ["api"] })
|
|
113
|
+
const a = store.create({ title: "First", description: "alpha", tags: ["api"], status: "todo" })
|
|
108
114
|
const b = store.create({ title: "Second", status: "todo" })
|
|
109
115
|
const c = store.create({ title: "Third", status: "backlog" })
|
|
110
116
|
assert.deepEqual([a.number, b.number, c.number], [1, 2, 3])
|
|
@@ -153,70 +159,39 @@ test("FileStore keeps the store's contract", () => {
|
|
|
153
159
|
assert.equal(d.number, 4)
|
|
154
160
|
})
|
|
155
161
|
|
|
156
|
-
test("openBoard
|
|
162
|
+
test("openBoard reads text boards, and refuses a pre-0.6 SQLite board loudly", () => {
|
|
157
163
|
const fileRoot = tempRoot()
|
|
158
164
|
initProject(fileRoot, { name: "files" })
|
|
159
165
|
assert.ok(openBoard(fileRoot) instanceof FileStore)
|
|
160
166
|
|
|
167
|
+
// A pre-0.6 board: version-1 config, committed database, no tickets/ tree.
|
|
168
|
+
// Reading it as an empty text board would silently strand every ticket in
|
|
169
|
+
// the database, so it must refuse instead — pointing at the last release
|
|
170
|
+
// that still carries `task migrate`.
|
|
161
171
|
const legacyRoot = tempRoot()
|
|
162
172
|
mkdirSync(join(legacyRoot, ".task"))
|
|
163
173
|
writeFileSync(
|
|
164
174
|
join(legacyRoot, ".task", "config.json"),
|
|
165
175
|
`${JSON.stringify({ name: "legacy", prefix: "LEG", version: 1 }, null, 2)}\n`,
|
|
166
176
|
)
|
|
167
|
-
|
|
168
|
-
assert.
|
|
169
|
-
legacy.close()
|
|
170
|
-
})
|
|
177
|
+
writeFileSync(join(legacyRoot, ".task", "tasks.db"), "not really a database")
|
|
178
|
+
assert.throws(() => openBoard(legacyRoot), /pre-0\.6 SQLite database.*migrate/)
|
|
171
179
|
|
|
172
|
-
|
|
173
|
-
const
|
|
174
|
-
mkdirSync(join(
|
|
180
|
+
// A version-2 board with no tickets yet is just an empty board, not legacy.
|
|
181
|
+
const emptyRoot = tempRoot()
|
|
182
|
+
mkdirSync(join(emptyRoot, ".task"))
|
|
175
183
|
writeFileSync(
|
|
176
|
-
join(
|
|
177
|
-
`${JSON.stringify({ name: "
|
|
178
|
-
)
|
|
179
|
-
const legacy = new TaskStore(root)
|
|
180
|
-
const one = legacy.create({ title: "Keep me", description: "with *body*", tags: ["a", "b"] })
|
|
181
|
-
const two = legacy.create({
|
|
182
|
-
title: "Blocked one",
|
|
183
|
-
status: "in_progress",
|
|
184
|
-
milestone: "launch",
|
|
185
|
-
needsHuman: true,
|
|
186
|
-
blockedBy: [one.number],
|
|
187
|
-
prs: ["https://github.com/x/y/pull/9"],
|
|
188
|
-
})
|
|
189
|
-
legacy.addComment(one.number, "hello", "nick")
|
|
190
|
-
legacy.addComment(one.number, "and back", "claude")
|
|
191
|
-
const expectedTasks = legacy.list({})
|
|
192
|
-
const expectedComments = legacy.comments(one.number)
|
|
193
|
-
legacy.close()
|
|
194
|
-
|
|
195
|
-
const result = migrateBoard(root)
|
|
196
|
-
assert.deepEqual(result, { tasks: 2, comments: 2 })
|
|
197
|
-
assert.throws(() => migrateBoard(root), /already migrated/)
|
|
198
|
-
|
|
199
|
-
const store = openBoard(root)
|
|
200
|
-
assert.ok(store instanceof FileStore)
|
|
201
|
-
assert.deepEqual(store.list({}), expectedTasks)
|
|
202
|
-
assert.deepEqual(
|
|
203
|
-
store.comments(one.number).map(({ author, body, createdAt }) => ({ author, body, createdAt })),
|
|
204
|
-
expectedComments.map(({ author, body, createdAt }) => ({ author, body, createdAt })),
|
|
205
|
-
)
|
|
206
|
-
assert.deepEqual(store.get(two.number)!.blockedBy, [one.number])
|
|
207
|
-
assert.match(readFileSync(join(root, ".task", ".gitignore"), "utf8"), /tasks\.db/)
|
|
208
|
-
assert.equal(
|
|
209
|
-
(JSON.parse(readFileSync(join(root, ".task", "config.json"), "utf8")) as { version: number })
|
|
210
|
-
.version,
|
|
211
|
-
2,
|
|
184
|
+
join(emptyRoot, ".task", "config.json"),
|
|
185
|
+
`${JSON.stringify({ name: "empty", prefix: "EMP", version: 2 }, null, 2)}\n`,
|
|
212
186
|
)
|
|
187
|
+
assert.deepEqual(openBoard(emptyRoot).list({}), [])
|
|
213
188
|
})
|
|
214
189
|
|
|
215
190
|
test("archive moves finished tickets off the hot path, losing nothing", () => {
|
|
216
191
|
const root = tempRoot()
|
|
217
192
|
const store = initProject(root, { name: "archive test", prefix: "ARC" })
|
|
218
|
-
const keep = store.create({ title: "Still open" })
|
|
219
|
-
const old = store.create({ title: "Finished work" })
|
|
193
|
+
const keep = store.create({ title: "Still open", status: "todo" })
|
|
194
|
+
const old = store.create({ title: "Finished work", status: "todo" })
|
|
220
195
|
store.link(keep.number, "blocked_by", old.number)
|
|
221
196
|
store.addComment(old.number, "how it went", "nick")
|
|
222
197
|
|
|
@@ -258,6 +233,63 @@ test("archive moves finished tickets off the hot path, losing nothing", () => {
|
|
|
258
233
|
assert.throws(() => store.unarchive(old.number), /isn't archived/)
|
|
259
234
|
})
|
|
260
235
|
|
|
236
|
+
test("goals: titled and described, one per task, progress always derived", () => {
|
|
237
|
+
const root = tempRoot()
|
|
238
|
+
const store = initProject(root, { name: "goals test", prefix: "GOA" })
|
|
239
|
+
|
|
240
|
+
// Created deliberately, slug derived from the title.
|
|
241
|
+
const goal = store.createGoal({
|
|
242
|
+
title: "Trust & guardrail layer",
|
|
243
|
+
description: "Why: DialMCP context lives here once, not in every ticket.",
|
|
244
|
+
})
|
|
245
|
+
assert.equal(goal.slug, "trust-guardrail-layer")
|
|
246
|
+
assert.throws(() => store.createGoal({ title: "Trust & guardrail layer" }), /already exists/)
|
|
247
|
+
assert.throws(() => store.createGoal({ title: "x", slug: "archive" }), /invalid goal slug/)
|
|
248
|
+
assert.throws(() => store.createGoal({ title: "x", slug: "Not A Slug" }), /invalid goal slug/)
|
|
249
|
+
|
|
250
|
+
// Assignment is validated: a goal must exist before a task can belong to it.
|
|
251
|
+
assert.throws(() => store.create({ title: "T", goal: "nope" }), /no such goal/)
|
|
252
|
+
const a = store.create({ title: "First", goal: goal.slug })
|
|
253
|
+
const b = store.create({ title: "Second" })
|
|
254
|
+
store.update(b.number, { goal: goal.slug })
|
|
255
|
+
assert.equal(store.get(b.number)!.goal, goal.slug)
|
|
256
|
+
assert.deepEqual(store.list({ goal: goal.slug }).map((t) => t.number), [a.number, b.number])
|
|
257
|
+
|
|
258
|
+
// The file is the state: goal ref serialized on the ticket, goal file on disk.
|
|
259
|
+
assert.match(
|
|
260
|
+
readFileSync(join(root, ".task", "tickets", "1", "ticket.md"), "utf8"),
|
|
261
|
+
/goal: "trust-guardrail-layer"/,
|
|
262
|
+
)
|
|
263
|
+
const goalFile = readFileSync(join(root, ".task", "goals", "trust-guardrail-layer.md"), "utf8")
|
|
264
|
+
assert.match(goalFile, /# Trust & guardrail layer/)
|
|
265
|
+
|
|
266
|
+
// Update touches the file; clearing a task's goal is `goal: null`.
|
|
267
|
+
store.updateGoal(goal.slug, { description: "Sharper why." })
|
|
268
|
+
assert.equal(store.getGoal(goal.slug)!.description, "Sharper why.")
|
|
269
|
+
store.update(b.number, { goal: null })
|
|
270
|
+
assert.equal(store.get(b.number)!.goal, null)
|
|
271
|
+
|
|
272
|
+
// Archive refuses while open work references the goal, allows after.
|
|
273
|
+
assert.throws(() => store.archiveGoal(goal.slug), /still has open tasks \(GOA-1\)/)
|
|
274
|
+
store.update(a.number, { status: "done" })
|
|
275
|
+
const shelved = store.archiveGoal(goal.slug)
|
|
276
|
+
assert.equal(shelved.archived, true)
|
|
277
|
+
assert.deepEqual(store.goals().map((g) => g.slug), [])
|
|
278
|
+
assert.deepEqual(store.goals(true).map((g) => g.slug), [goal.slug])
|
|
279
|
+
// Still resolvable (done tickets keep their ref readable), not assignable.
|
|
280
|
+
assert.equal(store.getGoal(goal.slug)!.title, "Trust & guardrail layer")
|
|
281
|
+
assert.throws(() => store.update(b.number, { goal: goal.slug }), /is archived — run/)
|
|
282
|
+
assert.throws(() => store.updateGoal(goal.slug, { title: "x" }), /is archived — run/)
|
|
283
|
+
|
|
284
|
+
// Delete needs every live ref cleared first — pruning organization is a
|
|
285
|
+
// human call, so the human makes it.
|
|
286
|
+
store.unarchiveGoal(goal.slug)
|
|
287
|
+
assert.throws(() => store.deleteGoal(goal.slug), /referenced by GOA-1/)
|
|
288
|
+
store.update(a.number, { goal: null })
|
|
289
|
+
store.deleteGoal(goal.slug)
|
|
290
|
+
assert.equal(store.getGoal(goal.slug), null)
|
|
291
|
+
})
|
|
292
|
+
|
|
261
293
|
test("prefix routing helpers find the right board from anywhere", () => {
|
|
262
294
|
const repo = tempRoot()
|
|
263
295
|
initProject(repo, { name: "root board", prefix: "NIC" })
|
|
@@ -276,7 +308,7 @@ test("prefix routing helpers find the right board from anywhere", () => {
|
|
|
276
308
|
test("concurrent-writer shapes merge by construction", () => {
|
|
277
309
|
const root = tempRoot()
|
|
278
310
|
const store = initProject(root, { name: "merge" })
|
|
279
|
-
const task = store.create({ title: "Contended" })
|
|
311
|
+
const task = store.create({ title: "Contended", status: "todo" })
|
|
280
312
|
|
|
281
313
|
// Two agents commenting at once = two files; simulate the second writer by
|
|
282
314
|
// dropping its file in directly, the way a git merge would.
|