@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/index.ts
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
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
|
-
export {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
export {
|
|
4
|
+
TaskStore,
|
|
5
|
+
boardConfig,
|
|
6
|
+
derivePrefix,
|
|
7
|
+
findBoards,
|
|
8
|
+
findBoardsByPrefix,
|
|
9
|
+
findRoot,
|
|
10
|
+
findScopeRoot,
|
|
11
|
+
type BoardRef,
|
|
12
|
+
type Store,
|
|
13
|
+
} from "./store.ts"
|
|
14
|
+
export {
|
|
15
|
+
ARCHIVE_DIR,
|
|
16
|
+
FileStore,
|
|
17
|
+
TICKETS_DIR,
|
|
18
|
+
initProject,
|
|
19
|
+
migrateBoard,
|
|
20
|
+
openBoard,
|
|
21
|
+
} from "./file-store.ts"
|
|
22
|
+
export {
|
|
23
|
+
parseTicket,
|
|
24
|
+
serializeTicket,
|
|
25
|
+
parseComment,
|
|
26
|
+
serializeComment,
|
|
27
|
+
type TicketDoc,
|
|
28
|
+
type CommentDoc,
|
|
29
|
+
} from "./ticket-doc.ts"
|
|
30
|
+
export { createTaskServer } from "./server.ts"
|
|
31
|
+
export { resolveAuthor, ANONYMOUS, type Author, type AuthorSource } from "./author.ts"
|
|
32
|
+
export * from "./types.ts"
|
package/src/server.ts
CHANGED
|
@@ -2,9 +2,10 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
|
|
2
2
|
import { watch, existsSync, readFileSync, statSync, type FSWatcher } from "node:fs"
|
|
3
3
|
import { extname, join, normalize } from "node:path"
|
|
4
4
|
import { fileURLToPath } from "node:url"
|
|
5
|
-
import { resolveAuthor } from "./author.
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { resolveAuthor } from "./author.ts"
|
|
6
|
+
import { openBoard } from "./file-store.ts"
|
|
7
|
+
import { findBoards, type BoardRef, type Store } from "./store.ts"
|
|
8
|
+
import { STATUSES, isStatus, type TaskPatch } from "./types.ts"
|
|
8
9
|
|
|
9
10
|
/** The prebuilt SPA, shipped inside the package next to dist/. */
|
|
10
11
|
const UI_DIR = fileURLToPath(new URL("../ui/dist", import.meta.url))
|
|
@@ -48,10 +49,26 @@ function parsePatch(body: Record<string, unknown>): TaskPatch {
|
|
|
48
49
|
patch.milestone = body.milestone === "" ? null : body.milestone
|
|
49
50
|
}
|
|
50
51
|
if (typeof body.needsHuman === "boolean") patch.needsHuman = body.needsHuman
|
|
52
|
+
if (Array.isArray(body.blocks)) patch.blocks = body.blocks.map(taskNumber)
|
|
53
|
+
if (Array.isArray(body.blockedBy)) patch.blockedBy = body.blockedBy.map(taskNumber)
|
|
54
|
+
if (Array.isArray(body.prs)) {
|
|
55
|
+
patch.prs = body.prs.map((pr) => {
|
|
56
|
+
const url = String(pr).trim()
|
|
57
|
+
// Anything the UI would render as a clickable link has to be a link.
|
|
58
|
+
if (!/^https?:\/\//.test(url)) throw new Error(`not a PR url: ${url}`)
|
|
59
|
+
return url
|
|
60
|
+
})
|
|
61
|
+
}
|
|
51
62
|
if (typeof body.position === "number") patch.position = body.position
|
|
52
63
|
return patch
|
|
53
64
|
}
|
|
54
65
|
|
|
66
|
+
function taskNumber(value: unknown): number {
|
|
67
|
+
const n = Number(value)
|
|
68
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error(`invalid task number: ${String(value)}`)
|
|
69
|
+
return n
|
|
70
|
+
}
|
|
71
|
+
|
|
55
72
|
/**
|
|
56
73
|
* `task serve` — one plain node:http server for the static UI, the JSON API,
|
|
57
74
|
* and an SSE stream that pings whenever anything writes to a `.task/` (the UI
|
|
@@ -89,15 +106,18 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
89
106
|
}
|
|
90
107
|
|
|
91
108
|
interface Board {
|
|
92
|
-
store:
|
|
109
|
+
store: Store
|
|
93
110
|
watcher: FSWatcher
|
|
94
111
|
}
|
|
95
112
|
const boards = new Map<string, Board>()
|
|
96
113
|
let ordered: string[] = []
|
|
97
114
|
|
|
98
|
-
const
|
|
99
|
-
const store =
|
|
100
|
-
|
|
115
|
+
const attachBoard = (ref: BoardRef): void => {
|
|
116
|
+
const store = openBoard(ref.root)
|
|
117
|
+
// Recursive: text-canonical state is a tree (tickets/<n>/comments/…), and
|
|
118
|
+
// a write anywhere in it is a board change. Node supports this on Linux,
|
|
119
|
+
// macOS and Windows for the versions this package requires.
|
|
120
|
+
const watcher = watch(store.taskDir, { recursive: true }, () => noteChange(ref.id))
|
|
101
121
|
// A deleted board dir can make fs.watch emit; the next rescan cleans up.
|
|
102
122
|
watcher.on("error", () => {})
|
|
103
123
|
boards.set(ref.id, { store, watcher })
|
|
@@ -114,7 +134,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
114
134
|
boards.delete(id)
|
|
115
135
|
}
|
|
116
136
|
}
|
|
117
|
-
for (const ref of found) if (!boards.has(ref.id))
|
|
137
|
+
for (const ref of found) if (!boards.has(ref.id)) attachBoard(ref)
|
|
118
138
|
ordered = found.map((ref) => ref.id)
|
|
119
139
|
return found
|
|
120
140
|
}
|
|
@@ -188,7 +208,7 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
188
208
|
}
|
|
189
209
|
|
|
190
210
|
function routeApi(
|
|
191
|
-
store:
|
|
211
|
+
store: Store,
|
|
192
212
|
req: IncomingMessage,
|
|
193
213
|
res: ServerResponse,
|
|
194
214
|
path: string,
|
|
@@ -226,11 +246,11 @@ export function createTaskServer(serveRoot: string): Server {
|
|
|
226
246
|
return
|
|
227
247
|
}
|
|
228
248
|
|
|
229
|
-
const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/(
|
|
249
|
+
const taskMatch = /^\/api\/tasks\/([^/]+)(\/comments(?:\/([^/]+))?)?$/.exec(path)
|
|
230
250
|
if (taskMatch) {
|
|
231
251
|
const number = store.parseId(decodeURIComponent(taskMatch[1]))
|
|
232
252
|
if (taskMatch[3] && req.method === "DELETE") {
|
|
233
|
-
const commentId =
|
|
253
|
+
const commentId = decodeURIComponent(taskMatch[3])
|
|
234
254
|
const existing = store.comments(number).find((c) => c.id === commentId)
|
|
235
255
|
if (!existing) {
|
|
236
256
|
json(res, 404, { error: `no such comment: ${commentId}` })
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The properties TAS-12 exists for, checked from the outside:
|
|
3
|
+
*
|
|
4
|
+
* 1. The text format round-trips — parse(serialize(x)) loses nothing.
|
|
5
|
+
* 2. `FileStore` behaves like the store always has (positions, links,
|
|
6
|
+
* comments, cascade on delete).
|
|
7
|
+
* 3. `task migrate` reproduces a legacy SQLite board in files, exactly.
|
|
8
|
+
* 4. Concurrent-writer shapes merge by construction: a comment is a new file,
|
|
9
|
+
* a claim is a one-line status diff.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from "node:assert/strict"
|
|
13
|
+
import { test } from "node:test"
|
|
14
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
15
|
+
import { tmpdir } from "node:os"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
import { FileStore, initProject, migrateBoard, openBoard } from "./file-store.ts"
|
|
18
|
+
import { parseTicket, serializeTicket, type TicketDoc } from "./ticket-doc.ts"
|
|
19
|
+
import { TaskStore, findBoardsByPrefix, findScopeRoot } from "./store.ts"
|
|
20
|
+
|
|
21
|
+
function tempRoot(): string {
|
|
22
|
+
const dir = mkdtempSync(join(tmpdir(), "task-test-"))
|
|
23
|
+
process.on("exit", () => rmSync(dir, { recursive: true, force: true }))
|
|
24
|
+
return dir
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("ticket documents round-trip, including the awkward bodies", () => {
|
|
28
|
+
const docs: TicketDoc[] = [
|
|
29
|
+
{
|
|
30
|
+
title: "Plain ticket",
|
|
31
|
+
description: "One line.",
|
|
32
|
+
status: "todo",
|
|
33
|
+
tags: [],
|
|
34
|
+
milestone: null,
|
|
35
|
+
needsHuman: false,
|
|
36
|
+
blockedBy: [],
|
|
37
|
+
prs: [],
|
|
38
|
+
position: 1024,
|
|
39
|
+
createdAt: "2026-08-15T16:00:33.758Z",
|
|
40
|
+
updatedAt: "2026-08-15T16:00:33.758Z",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
title: 'Tricky: "quotes", colons: yes — and unicode ✓',
|
|
44
|
+
// A description that opens with a heading, contains a frontmatter-like
|
|
45
|
+
// fence, and ends without punctuation.
|
|
46
|
+
description: "# Not the title\n\n---\n\nstatus: not-a-field\n\n```md\n---\n```",
|
|
47
|
+
status: "in_progress",
|
|
48
|
+
tags: ["storage", "architecture"],
|
|
49
|
+
milestone: "v2 storage",
|
|
50
|
+
needsHuman: true,
|
|
51
|
+
blockedBy: [3, 7],
|
|
52
|
+
prs: ["https://github.com/x/y/pull/1"],
|
|
53
|
+
position: -512.5,
|
|
54
|
+
createdAt: "2026-08-15T16:00:33.758Z",
|
|
55
|
+
updatedAt: "2026-08-15T17:12:00.001Z",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
title: "Empty description",
|
|
59
|
+
description: "",
|
|
60
|
+
status: "backlog",
|
|
61
|
+
tags: [],
|
|
62
|
+
milestone: null,
|
|
63
|
+
needsHuman: false,
|
|
64
|
+
blockedBy: [],
|
|
65
|
+
prs: [],
|
|
66
|
+
position: 0,
|
|
67
|
+
createdAt: "2026-08-15T16:00:33.758Z",
|
|
68
|
+
updatedAt: "2026-08-15T16:00:33.758Z",
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
for (const doc of docs) {
|
|
72
|
+
assert.deepEqual(parseTicket(serializeTicket(doc), "test"), doc, doc.title)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("hand-edits are forgiven where they're unambiguous", () => {
|
|
77
|
+
const text = [
|
|
78
|
+
"---",
|
|
79
|
+
"status: todo",
|
|
80
|
+
"milestone: launch", // unquoted string — the YAML instinct
|
|
81
|
+
"position: 8",
|
|
82
|
+
"created: 2026-08-15T16:00:33.758Z",
|
|
83
|
+
"updated: 2026-08-15T16:00:33.758Z",
|
|
84
|
+
"---",
|
|
85
|
+
"",
|
|
86
|
+
"# Hand-written ticket",
|
|
87
|
+
"",
|
|
88
|
+
"Body.",
|
|
89
|
+
].join("\n")
|
|
90
|
+
const doc = parseTicket(text, "test")
|
|
91
|
+
assert.equal(doc.milestone, "launch")
|
|
92
|
+
assert.equal(doc.position, 8)
|
|
93
|
+
|
|
94
|
+
assert.throws(
|
|
95
|
+
() => parseTicket(text.replace("status: todo", "status: todoo"), "here"),
|
|
96
|
+
/here.*invalid status/,
|
|
97
|
+
)
|
|
98
|
+
assert.throws(() => parseTicket("no frontmatter", "here"), /here.*frontmatter/)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("FileStore keeps the store's contract", () => {
|
|
102
|
+
const root = tempRoot()
|
|
103
|
+
const store = initProject(root, { name: "sample project" })
|
|
104
|
+
assert.equal(store.config.prefix, "SAM")
|
|
105
|
+
assert.ok(store instanceof FileStore)
|
|
106
|
+
|
|
107
|
+
const a = store.create({ title: "First", description: "alpha", tags: ["api"] })
|
|
108
|
+
const b = store.create({ title: "Second", status: "todo" })
|
|
109
|
+
const c = store.create({ title: "Third", status: "backlog" })
|
|
110
|
+
assert.deepEqual([a.number, b.number, c.number], [1, 2, 3])
|
|
111
|
+
// New tasks land at the bottom of their column.
|
|
112
|
+
assert.ok(b.position > a.position)
|
|
113
|
+
|
|
114
|
+
// Status move without an explicit slot lands on top of the new column.
|
|
115
|
+
const moved = store.update(c.number, { status: "todo" })
|
|
116
|
+
assert.ok(moved.position < a.position)
|
|
117
|
+
assert.equal(store.list({ statuses: ["todo"] })[0].number, c.number)
|
|
118
|
+
|
|
119
|
+
// One relation, two views, stored on one side only.
|
|
120
|
+
store.link(a.number, "blocked_by", b.number)
|
|
121
|
+
assert.deepEqual(store.get(b.number)!.blocks, [a.number])
|
|
122
|
+
const onDisk = readFileSync(join(root, ".task", "tickets", "1", "ticket.md"), "utf8")
|
|
123
|
+
assert.match(onDisk, /blocked_by: \[2\]/)
|
|
124
|
+
assert.doesNotMatch(
|
|
125
|
+
readFileSync(join(root, ".task", "tickets", "2", "ticket.md"), "utf8"),
|
|
126
|
+
/blocked_by/,
|
|
127
|
+
)
|
|
128
|
+
store.unlink(b.number, "blocks", a.number)
|
|
129
|
+
assert.deepEqual(store.get(a.number)!.blockedBy, [])
|
|
130
|
+
|
|
131
|
+
assert.throws(() => store.link(a.number, "blocks", a.number), /can't block itself/)
|
|
132
|
+
assert.throws(() => store.update(a.number, { blockedBy: [99] }), /no such task: SAM-99/)
|
|
133
|
+
|
|
134
|
+
// Comments: one file each, stable ids, author preserved.
|
|
135
|
+
const comment = store.addComment(a.number, "first!\n\nwith **markdown**", "claude")
|
|
136
|
+
assert.match(comment.id, /^\d{4}-\d{2}-\d{2}T\d{6}-claude$/)
|
|
137
|
+
assert.equal(store.comments(a.number)[0].body, "first!\n\nwith **markdown**")
|
|
138
|
+
assert.equal(store.commentCounts().get(a.number), 1)
|
|
139
|
+
const second = store.addComment(a.number, "again", "claude")
|
|
140
|
+
assert.notEqual(second.id, comment.id)
|
|
141
|
+
store.deleteComment(a.number, second.id)
|
|
142
|
+
assert.equal(store.comments(a.number).length, 1)
|
|
143
|
+
assert.throws(() => store.deleteComment(a.number, "../../escape"), /no such comment/)
|
|
144
|
+
|
|
145
|
+
// Delete cascades the relation away, like the FK did.
|
|
146
|
+
store.link(b.number, "blocked_by", a.number)
|
|
147
|
+
store.delete(a.number)
|
|
148
|
+
assert.equal(store.get(a.number), null)
|
|
149
|
+
assert.deepEqual(store.get(b.number)!.blockedBy, [])
|
|
150
|
+
|
|
151
|
+
// Numbers never move backwards past an existing ticket.
|
|
152
|
+
const d = store.create({ title: "Fourth" })
|
|
153
|
+
assert.equal(d.number, 4)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
test("openBoard picks the backend by what's on disk", () => {
|
|
157
|
+
const fileRoot = tempRoot()
|
|
158
|
+
initProject(fileRoot, { name: "files" })
|
|
159
|
+
assert.ok(openBoard(fileRoot) instanceof FileStore)
|
|
160
|
+
|
|
161
|
+
const legacyRoot = tempRoot()
|
|
162
|
+
mkdirSync(join(legacyRoot, ".task"))
|
|
163
|
+
writeFileSync(
|
|
164
|
+
join(legacyRoot, ".task", "config.json"),
|
|
165
|
+
`${JSON.stringify({ name: "legacy", prefix: "LEG", version: 1 }, null, 2)}\n`,
|
|
166
|
+
)
|
|
167
|
+
const legacy = openBoard(legacyRoot)
|
|
168
|
+
assert.ok(legacy instanceof TaskStore)
|
|
169
|
+
legacy.close()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test("migrate reproduces a legacy board, byte-visible and loss-free", () => {
|
|
173
|
+
const root = tempRoot()
|
|
174
|
+
mkdirSync(join(root, ".task"))
|
|
175
|
+
writeFileSync(
|
|
176
|
+
join(root, ".task", "config.json"),
|
|
177
|
+
`${JSON.stringify({ name: "legacy board", prefix: "LEG", version: 1 }, null, 2)}\n`,
|
|
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,
|
|
212
|
+
)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test("archive moves finished tickets off the hot path, losing nothing", () => {
|
|
216
|
+
const root = tempRoot()
|
|
217
|
+
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" })
|
|
220
|
+
store.link(keep.number, "blocked_by", old.number)
|
|
221
|
+
store.addComment(old.number, "how it went", "nick")
|
|
222
|
+
|
|
223
|
+
// Only finished work qualifies — archive is history, not a hiding place.
|
|
224
|
+
assert.throws(() => store.archive(old.number), /is todo/)
|
|
225
|
+
store.update(old.number, { status: "done" })
|
|
226
|
+
const archived = store.archive(old.number)
|
|
227
|
+
assert.equal(archived.archived, true)
|
|
228
|
+
|
|
229
|
+
// Off the board and out of every default read…
|
|
230
|
+
assert.deepEqual(store.list({}).map((t) => t.number), [keep.number])
|
|
231
|
+
assert.equal(store.commentCounts().get(old.number), undefined)
|
|
232
|
+
// …but reads still reach it, comments included, and its links still show.
|
|
233
|
+
const shown = store.get(old.number)!
|
|
234
|
+
assert.equal(shown.archived, true)
|
|
235
|
+
assert.deepEqual(shown.blocks, [keep.number])
|
|
236
|
+
assert.equal(store.comments(old.number)[0].body, "how it went")
|
|
237
|
+
const listed = store.list({ archived: true })
|
|
238
|
+
assert.deepEqual(listed.map((t) => [t.number, t.archived]), [[old.number, true]])
|
|
239
|
+
|
|
240
|
+
// Writes refuse until unarchived, with a message that says what to do.
|
|
241
|
+
assert.throws(() => store.update(old.number, { title: "x" }), /archived — run/)
|
|
242
|
+
assert.throws(() => store.addComment(old.number, "x", "y"), /archived — run/)
|
|
243
|
+
assert.throws(() => store.delete(old.number), /archived — run/)
|
|
244
|
+
assert.throws(() => store.archive(old.number), /already archived/)
|
|
245
|
+
// Linking *to* an archived target refuses; pruning a stale link succeeds.
|
|
246
|
+
assert.throws(() => store.link(keep.number, "blocked_by", old.number), /archived — run/)
|
|
247
|
+
assert.deepEqual(store.update(keep.number, { blockedBy: [] }).blockedBy, [])
|
|
248
|
+
|
|
249
|
+
// Numbers stay reserved — the next ticket never reuses an archived one.
|
|
250
|
+
assert.equal(store.create({ title: "Next" }).number, 3)
|
|
251
|
+
|
|
252
|
+
// Round trip: unarchive puts it back exactly as it was.
|
|
253
|
+
const back = store.unarchive(old.number)
|
|
254
|
+
assert.equal(back.archived, undefined)
|
|
255
|
+
assert.equal(back.status, "done")
|
|
256
|
+
assert.deepEqual(store.list({ archived: true }), [])
|
|
257
|
+
assert.equal(store.comments(old.number).length, 1)
|
|
258
|
+
assert.throws(() => store.unarchive(old.number), /isn't archived/)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
test("prefix routing helpers find the right board from anywhere", () => {
|
|
262
|
+
const repo = tempRoot()
|
|
263
|
+
initProject(repo, { name: "root board", prefix: "NIC" })
|
|
264
|
+
mkdirSync(join(repo, "projects", "widget"), { recursive: true })
|
|
265
|
+
initProject(join(repo, "projects", "widget"), { name: "widget", prefix: "WID" })
|
|
266
|
+
|
|
267
|
+
// From deep inside a package, the scope is still the repo root.
|
|
268
|
+
assert.equal(findScopeRoot(join(repo, "projects", "widget")), repo)
|
|
269
|
+
assert.equal(findScopeRoot(repo), repo)
|
|
270
|
+
|
|
271
|
+
const hit = findBoardsByPrefix(repo, "wid")
|
|
272
|
+
assert.deepEqual(hit.map((b) => b.id), ["projects/widget"])
|
|
273
|
+
assert.deepEqual(findBoardsByPrefix(repo, "NOPE"), [])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test("concurrent-writer shapes merge by construction", () => {
|
|
277
|
+
const root = tempRoot()
|
|
278
|
+
const store = initProject(root, { name: "merge" })
|
|
279
|
+
const task = store.create({ title: "Contended" })
|
|
280
|
+
|
|
281
|
+
// Two agents commenting at once = two files; simulate the second writer by
|
|
282
|
+
// dropping its file in directly, the way a git merge would.
|
|
283
|
+
store.addComment(task.number, "from session one", "claude")
|
|
284
|
+
const commentsDir = join(root, ".task", "tickets", String(task.number), "comments")
|
|
285
|
+
writeFileSync(
|
|
286
|
+
join(commentsDir, "2026-08-15T170000-other.md"),
|
|
287
|
+
"---\nauthor: \"other\"\ncreated: 2026-08-15T17:00:00.000Z\n---\n\nfrom session two\n",
|
|
288
|
+
)
|
|
289
|
+
assert.equal(store.comments(task.number).length, 2)
|
|
290
|
+
|
|
291
|
+
// A claim is a one-line diff: exactly one meaningful frontmatter line
|
|
292
|
+
// changes. `updated:` is excluded because it only changes when the update
|
|
293
|
+
// lands in a different millisecond than the create — true except when this
|
|
294
|
+
// test runs fast enough, which is exactly the kind of coin-flip a test must
|
|
295
|
+
// not depend on.
|
|
296
|
+
const path = join(root, ".task", "tickets", String(task.number), "ticket.md")
|
|
297
|
+
const before = readFileSync(path, "utf8").split("\n")
|
|
298
|
+
store.update(task.number, { status: "in_progress", position: task.position })
|
|
299
|
+
const after = readFileSync(path, "utf8").split("\n")
|
|
300
|
+
const changed = before.filter((line, i) => after[i] !== line)
|
|
301
|
+
assert.deepEqual(
|
|
302
|
+
changed.filter((line) => !line.startsWith("updated: ")),
|
|
303
|
+
["status: todo"],
|
|
304
|
+
)
|
|
305
|
+
})
|