@nickmeriano/task 0.8.0 → 0.9.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 +65 -22
- package/dist/asks.test.d.ts +17 -0
- package/dist/asks.test.d.ts.map +1 -0
- package/dist/asks.test.js +278 -0
- package/dist/asks.test.js.map +1 -0
- package/dist/check.d.ts +3 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +99 -60
- package/dist/check.js.map +1 -1
- package/dist/check.test.js +99 -60
- package/dist/check.test.js.map +1 -1
- package/dist/claim-io.d.ts +73 -0
- package/dist/claim-io.d.ts.map +1 -0
- package/dist/claim-io.js +344 -0
- package/dist/claim-io.js.map +1 -0
- package/dist/claim.d.ts +21 -22
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +58 -83
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +111 -94
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +241 -64
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +70 -24
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +319 -141
- package/dist/file-store.js.map +1 -1
- package/dist/git-serve.d.ts +183 -0
- package/dist/git-serve.d.ts.map +1 -0
- package/dist/git-serve.js +503 -0
- package/dist/git-serve.js.map +1 -0
- package/dist/git-serve.test.d.ts +16 -0
- package/dist/git-serve.test.d.ts.map +1 -0
- package/dist/git-serve.test.js +183 -0
- package/dist/git-serve.test.js.map +1 -0
- package/dist/git.d.ts +65 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +114 -0
- package/dist/git.js.map +1 -0
- package/dist/id.d.ts +39 -0
- package/dist/id.d.ts.map +1 -0
- package/dist/id.js +67 -0
- package/dist/id.js.map +1 -0
- package/dist/inbox.d.ts +41 -0
- package/dist/inbox.d.ts.map +1 -0
- package/dist/inbox.js +56 -0
- package/dist/inbox.js.map +1 -0
- package/dist/overview.d.ts +9 -0
- package/dist/overview.d.ts.map +1 -1
- package/dist/overview.js +19 -11
- package/dist/overview.js.map +1 -1
- package/dist/overview.test.js +6 -5
- package/dist/overview.test.js.map +1 -1
- package/dist/promote.test.d.ts +3 -2
- package/dist/promote.test.d.ts.map +1 -1
- package/dist/promote.test.js +29 -31
- package/dist/promote.test.js.map +1 -1
- package/dist/publish.d.ts +2 -17
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +4 -49
- package/dist/publish.js.map +1 -1
- package/dist/search.d.ts +3 -1
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +15 -2
- package/dist/search.js.map +1 -1
- package/dist/search.test.js +9 -9
- package/dist/search.test.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +218 -21
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +28 -14
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js.map +1 -1
- package/dist/store.test.js +88 -75
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +26 -10
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +71 -27
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +51 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +70 -23
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +129 -72
- package/src/check.ts +101 -58
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +141 -99
- package/src/claim.ts +69 -96
- package/src/cli.ts +251 -63
- package/src/file-store.ts +357 -145
- package/src/git-serve.test.ts +240 -0
- package/src/git-serve.ts +595 -0
- package/src/git.ts +141 -0
- package/src/id.ts +68 -0
- package/src/inbox.ts +77 -0
- package/src/overview.test.ts +6 -5
- package/src/overview.ts +25 -10
- package/src/promote.test.ts +40 -28
- package/src/publish.ts +6 -53
- package/src/search.test.ts +11 -11
- package/src/search.ts +17 -3
- package/src/server.ts +221 -19
- package/src/store.test.ts +95 -77
- package/src/store.ts +28 -13
- package/src/ticket-doc.ts +93 -30
- package/src/types.ts +52 -17
- package/ui/dist/assets/index-BjsorZOU.js +229 -0
- package/ui/dist/assets/index-CoKCUYic.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BJmOsOdR.js +0 -229
- package/ui/dist/assets/index-BoqQlqSU.css +0 -1
package/src/file-store.ts
CHANGED
|
@@ -11,16 +11,20 @@ import { basename, dirname, join } from "node:path"
|
|
|
11
11
|
import {
|
|
12
12
|
commentStem,
|
|
13
13
|
isGoalSlug,
|
|
14
|
+
parseAsk,
|
|
14
15
|
parseComment,
|
|
15
16
|
parseGoal,
|
|
16
17
|
parseTicket,
|
|
18
|
+
serializeAsk,
|
|
17
19
|
serializeComment,
|
|
18
20
|
serializeGoal,
|
|
19
21
|
serializeTicket,
|
|
20
22
|
slugifyGoal,
|
|
23
|
+
type AskDoc,
|
|
21
24
|
type GoalDoc,
|
|
22
25
|
type TicketDoc,
|
|
23
26
|
} from "./ticket-doc.ts"
|
|
27
|
+
import { isTicketKey, mintKey } from "./id.ts"
|
|
24
28
|
import {
|
|
25
29
|
CONFIG_FILE,
|
|
26
30
|
POSITION_GAP,
|
|
@@ -29,6 +33,7 @@ import {
|
|
|
29
33
|
type Store,
|
|
30
34
|
} from "./store.ts"
|
|
31
35
|
import type {
|
|
36
|
+
Ask,
|
|
32
37
|
Comment,
|
|
33
38
|
Goal,
|
|
34
39
|
GoalInput,
|
|
@@ -46,6 +51,8 @@ export const TICKETS_DIR = "tickets"
|
|
|
46
51
|
export const ARCHIVE_DIR = "archive"
|
|
47
52
|
export const TICKET_FILE = "ticket.md"
|
|
48
53
|
export const COMMENTS_DIR = "comments"
|
|
54
|
+
/** One file per ask — `tickets/<key>/asks/<stem>.md`, the comment layout. */
|
|
55
|
+
export const ASKS_DIR = "asks"
|
|
49
56
|
/** One file per goal — `.task/goals/<slug>.md`; archived ones one level down. */
|
|
50
57
|
export const GOALS_DIR = "goals"
|
|
51
58
|
export const GOALS_ARCHIVE_DIR = "archive"
|
|
@@ -65,17 +72,28 @@ function writeAtomic(path: string, text: string): void {
|
|
|
65
72
|
}
|
|
66
73
|
|
|
67
74
|
interface Ticket {
|
|
68
|
-
|
|
75
|
+
key: string
|
|
69
76
|
doc: TicketDoc
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
/** Stable sort for key lists — lexicographic, since keys carry no order. */
|
|
80
|
+
function sortKeys(keys: string[]): string[] {
|
|
81
|
+
return keys.sort((a, b) => a.localeCompare(b))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Board order's tiebreak: creation time, then key — keys themselves say nothing. */
|
|
85
|
+
function byCreation(a: Ticket, b: Ticket): number {
|
|
86
|
+
return a.doc.createdAt.localeCompare(b.doc.createdAt) || a.key.localeCompare(b.key)
|
|
87
|
+
}
|
|
88
|
+
|
|
72
89
|
/**
|
|
73
90
|
* The canonical persistence layer: one directory per ticket under
|
|
74
|
-
* `.task/tickets/`,
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* construction
|
|
91
|
+
* `.task/tickets/`, named by the ticket's key (see id.ts), holding a
|
|
92
|
+
* frontmatter+markdown `ticket.md` and one file per comment. Everything is
|
|
93
|
+
* plain text committed to git, which is the point — ticket edits diff, review,
|
|
94
|
+
* and merge like code, and two branches creating tickets (or adding comments
|
|
95
|
+
* to the same one) merge cleanly by construction: keys are random, so parallel
|
|
96
|
+
* branches can't mint the same directory.
|
|
79
97
|
*
|
|
80
98
|
* There is no database and no cache: boards are dozens of tickets, and
|
|
81
99
|
* re-reading a handful of small files per operation is cheaper than opening
|
|
@@ -105,44 +123,57 @@ export class FileStore implements Store {
|
|
|
105
123
|
|
|
106
124
|
close(): void {}
|
|
107
125
|
|
|
108
|
-
displayId(
|
|
109
|
-
return `${this.config.prefix}-${
|
|
126
|
+
displayId(key: string): string {
|
|
127
|
+
return `${this.config.prefix}-${key}`
|
|
110
128
|
}
|
|
111
129
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
130
|
+
/**
|
|
131
|
+
* "PHO-x7k4m", "x7k4m", or any unique prefix of a key ("x7") — resolved
|
|
132
|
+
* git-style against the board's live and archived tickets, case-insensitive.
|
|
133
|
+
* An ambiguous prefix throws naming the candidates; an unknown ref comes
|
|
134
|
+
* back verbatim, so the caller's lookup says "no such task" with it.
|
|
135
|
+
*/
|
|
136
|
+
parseId(ref: string): string {
|
|
137
|
+
const match = /^(?:[A-Za-z0-9]+-)?([A-Za-z0-9]+)$/.exec(ref.trim())
|
|
115
138
|
if (!match) throw new Error(`invalid task id: ${ref}`)
|
|
116
|
-
|
|
139
|
+
const key = match[1].toLowerCase()
|
|
140
|
+
const known = [...this.liveKeys(), ...this.archivedKeys()]
|
|
141
|
+
if (known.includes(key)) return key
|
|
142
|
+
const hits = sortKeys(known.filter((k) => k.startsWith(key)))
|
|
143
|
+
if (hits.length === 1) return hits[0]
|
|
144
|
+
if (hits.length > 1) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`${ref} is ambiguous — matches ${hits.map((k) => this.displayId(k)).join(", ")}`,
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
return key
|
|
117
150
|
}
|
|
118
151
|
|
|
119
152
|
// ── Reading ────────────────────────────────────────────────────────────────
|
|
120
153
|
|
|
121
|
-
private ticketPath(
|
|
122
|
-
return join(this.ticketsDir,
|
|
154
|
+
private ticketPath(key: string): string {
|
|
155
|
+
return join(this.ticketsDir, key, TICKET_FILE)
|
|
123
156
|
}
|
|
124
157
|
|
|
125
158
|
/**
|
|
126
159
|
* A ticket's comments live wherever the ticket does — the whole directory
|
|
127
160
|
* moves on archive, so an archived ticket's discussion stays readable.
|
|
128
161
|
*/
|
|
129
|
-
private commentsPath(
|
|
130
|
-
const home = existsSync(join(this.archiveDir,
|
|
131
|
-
|
|
132
|
-
: this.ticketsDir
|
|
133
|
-
return join(home, String(number), COMMENTS_DIR)
|
|
162
|
+
private commentsPath(key: string): string {
|
|
163
|
+
const home = existsSync(join(this.archiveDir, key)) ? this.archiveDir : this.ticketsDir
|
|
164
|
+
return join(home, key, COMMENTS_DIR)
|
|
134
165
|
}
|
|
135
166
|
|
|
136
167
|
private readDir(dir: string): Ticket[] {
|
|
137
168
|
if (!existsSync(dir)) return []
|
|
138
169
|
const tickets: Ticket[] = []
|
|
139
170
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
140
|
-
if (!entry.isDirectory() ||
|
|
171
|
+
if (!entry.isDirectory() || !isTicketKey(entry.name)) continue
|
|
141
172
|
const path = join(dir, entry.name, TICKET_FILE)
|
|
142
173
|
if (!existsSync(path)) continue
|
|
143
|
-
tickets.push({
|
|
174
|
+
tickets.push({ key: entry.name, doc: parseTicket(readFileSync(path, "utf8"), path) })
|
|
144
175
|
}
|
|
145
|
-
tickets.sort(
|
|
176
|
+
tickets.sort(byCreation)
|
|
146
177
|
return tickets
|
|
147
178
|
}
|
|
148
179
|
|
|
@@ -151,56 +182,66 @@ export class FileStore implements Store {
|
|
|
151
182
|
return this.readDir(this.ticketsDir)
|
|
152
183
|
}
|
|
153
184
|
|
|
154
|
-
private readOne(
|
|
155
|
-
const path = this.ticketPath(
|
|
156
|
-
if (!existsSync(path)) return null
|
|
157
|
-
return {
|
|
185
|
+
private readOne(key: string): Ticket | null {
|
|
186
|
+
const path = this.ticketPath(key)
|
|
187
|
+
if (!isTicketKey(key) || !existsSync(path)) return null
|
|
188
|
+
return { key, doc: parseTicket(readFileSync(path, "utf8"), path) }
|
|
158
189
|
}
|
|
159
190
|
|
|
160
|
-
/**
|
|
161
|
-
private
|
|
162
|
-
if (!existsSync(
|
|
163
|
-
return readdirSync(
|
|
164
|
-
.filter((name) => /^\d+$/.test(name))
|
|
165
|
-
.map(Number)
|
|
191
|
+
/** Directory names that are well-formed keys, without parsing anything. */
|
|
192
|
+
private keysIn(dir: string): string[] {
|
|
193
|
+
if (!existsSync(dir)) return []
|
|
194
|
+
return readdirSync(dir).filter(isTicketKey)
|
|
166
195
|
}
|
|
167
196
|
|
|
168
|
-
private
|
|
169
|
-
return
|
|
197
|
+
private liveKeys(): string[] {
|
|
198
|
+
return this.keysIn(this.ticketsDir)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Cheap presence check — the archive's keys, without parsing anything. */
|
|
202
|
+
private archivedKeys(): string[] {
|
|
203
|
+
return this.keysIn(this.archiveDir)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private isArchived(key: string): boolean {
|
|
207
|
+
return isTicketKey(key) && existsSync(join(this.archiveDir, key, TICKET_FILE))
|
|
170
208
|
}
|
|
171
209
|
|
|
172
210
|
/** The error every write path throws instead of touching the archive. */
|
|
173
|
-
private assertNotArchived(
|
|
174
|
-
if (this.isArchived(
|
|
175
|
-
const id = this.displayId(
|
|
211
|
+
private assertNotArchived(key: string): void {
|
|
212
|
+
if (this.isArchived(key)) {
|
|
213
|
+
const id = this.displayId(key)
|
|
176
214
|
throw new Error(`${id} is archived — run \`task unarchive ${id}\` first`)
|
|
177
215
|
}
|
|
178
216
|
}
|
|
179
217
|
|
|
180
218
|
private writeTicket(ticket: Ticket): void {
|
|
181
|
-
mkdirSync(dirname(this.ticketPath(ticket.
|
|
182
|
-
writeAtomic(this.ticketPath(ticket.
|
|
219
|
+
mkdirSync(dirname(this.ticketPath(ticket.key)), { recursive: true })
|
|
220
|
+
writeAtomic(this.ticketPath(ticket.key), serializeTicket(ticket.doc))
|
|
183
221
|
}
|
|
184
222
|
|
|
185
223
|
/**
|
|
186
224
|
* Only `blocked_by` is stored (on the blocked ticket); the `blocks` side is
|
|
187
|
-
* derived here, so the two views can never disagree.
|
|
225
|
+
* derived here, so the two views can never disagree. `needsHuman` is derived
|
|
226
|
+
* too — open asks, nothing else — so a flagged ticket with no stated reason
|
|
227
|
+
* cannot exist.
|
|
188
228
|
*/
|
|
189
|
-
private toTask(ticket: Ticket, all: Ticket[]): Task {
|
|
190
|
-
const blocks =
|
|
191
|
-
.filter((t) => t.doc.blockedBy.includes(ticket.
|
|
192
|
-
|
|
229
|
+
private toTask(ticket: Ticket, all: Ticket[], asks: Ask[] = this.asks(ticket.key)): Task {
|
|
230
|
+
const blocks = sortKeys(
|
|
231
|
+
all.filter((t) => t.doc.blockedBy.includes(ticket.key)).map((t) => t.key),
|
|
232
|
+
)
|
|
193
233
|
return {
|
|
194
|
-
id: this.displayId(ticket.
|
|
195
|
-
|
|
234
|
+
id: this.displayId(ticket.key),
|
|
235
|
+
key: ticket.key,
|
|
196
236
|
title: ticket.doc.title,
|
|
197
237
|
description: ticket.doc.description,
|
|
198
238
|
status: ticket.doc.status,
|
|
199
239
|
tags: ticket.doc.tags,
|
|
200
240
|
goal: ticket.doc.goal,
|
|
201
|
-
needsHuman:
|
|
241
|
+
needsHuman: asks.some((a) => !a.resolvedAt),
|
|
242
|
+
asks,
|
|
202
243
|
blocks,
|
|
203
|
-
blockedBy: [...ticket.doc.blockedBy]
|
|
244
|
+
blockedBy: sortKeys([...ticket.doc.blockedBy]),
|
|
204
245
|
prs: ticket.doc.prs,
|
|
205
246
|
position: ticket.doc.position,
|
|
206
247
|
createdAt: ticket.doc.createdAt,
|
|
@@ -217,7 +258,11 @@ export class FileStore implements Store {
|
|
|
217
258
|
tickets = tickets.filter((t) => t.doc.goal === filter.goal)
|
|
218
259
|
}
|
|
219
260
|
if (filter.needsHuman !== undefined) {
|
|
220
|
-
|
|
261
|
+
// The derived flag, same definition as toTask — filtering must never
|
|
262
|
+
// disagree with what the payload says.
|
|
263
|
+
tickets = tickets.filter(
|
|
264
|
+
(t) => this.asks(t.key).some((a) => !a.resolvedAt) === filter.needsHuman,
|
|
265
|
+
)
|
|
221
266
|
}
|
|
222
267
|
if (filter.tags?.length) {
|
|
223
268
|
const wanted = new Set(filter.tags)
|
|
@@ -235,22 +280,23 @@ export class FileStore implements Store {
|
|
|
235
280
|
const everything = [...this.readAll(), ...archived]
|
|
236
281
|
return this.applyFilter(archived, filter)
|
|
237
282
|
.map((t) => ({ ...this.toTask(t, everything), archived: true }))
|
|
238
|
-
.sort((a, b) => a.position - b.position || a
|
|
283
|
+
.sort((a, b) => a.position - b.position || byCreationTask(a, b))
|
|
239
284
|
}
|
|
240
285
|
const all = this.readAll()
|
|
241
286
|
return this.applyFilter(all, filter)
|
|
242
287
|
.map((t) => this.toTask(t, all))
|
|
243
|
-
.sort((a, b) => a.position - b.position || a
|
|
288
|
+
.sort((a, b) => a.position - b.position || byCreationTask(a, b))
|
|
244
289
|
}
|
|
245
290
|
|
|
246
|
-
get(
|
|
291
|
+
get(key: string): Task | null {
|
|
247
292
|
const all = this.readAll()
|
|
248
|
-
const ticket = all.find((t) => t.
|
|
293
|
+
const ticket = all.find((t) => t.key === key)
|
|
249
294
|
if (ticket) return this.toTask(ticket, all)
|
|
250
295
|
// `show` should reach the archive without ceremony — reads are safe.
|
|
251
|
-
|
|
296
|
+
if (!isTicketKey(key)) return null
|
|
297
|
+
const path = join(this.archiveDir, key, TICKET_FILE)
|
|
252
298
|
if (!existsSync(path)) return null
|
|
253
|
-
const archived: Ticket = {
|
|
299
|
+
const archived: Ticket = { key, doc: parseTicket(readFileSync(path, "utf8"), path) }
|
|
254
300
|
return { ...this.toTask(archived, [...all, archived]), archived: true }
|
|
255
301
|
}
|
|
256
302
|
|
|
@@ -258,15 +304,16 @@ export class FileStore implements Store {
|
|
|
258
304
|
|
|
259
305
|
create(input: TaskInput): Task {
|
|
260
306
|
const all = this.readAll()
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
|
|
264
|
-
const
|
|
307
|
+
// Random, not sequential (TAS-42): the key must be safe to mint on any
|
|
308
|
+
// branch with no coordination. Archived keys stay reserved — a new ticket
|
|
309
|
+
// must never take a name old comments or PR titles still point at.
|
|
310
|
+
const taken = new Set([...all.map((t) => t.key), ...this.archivedKeys()])
|
|
311
|
+
const key = mintKey(taken)
|
|
265
312
|
// Dump by default: new tickets land in backlog, the no-quality-bar list;
|
|
266
313
|
// `todo` (the ready list) is an explicit choice — usually `task promote`.
|
|
267
314
|
const status = input.status ?? "backlog"
|
|
268
315
|
for (const target of [...(input.blocks ?? []), ...(input.blockedBy ?? [])]) {
|
|
269
|
-
if (!all.some((t) => t.
|
|
316
|
+
if (!all.some((t) => t.key === target)) {
|
|
270
317
|
throw new Error(`no such task: ${this.displayId(target)}`)
|
|
271
318
|
}
|
|
272
319
|
}
|
|
@@ -276,15 +323,14 @@ export class FileStore implements Store {
|
|
|
276
323
|
const bottom = column.length ? Math.max(...column.map((t) => t.doc.position)) : 0
|
|
277
324
|
const timestamp = now()
|
|
278
325
|
const ticket: Ticket = {
|
|
279
|
-
|
|
326
|
+
key,
|
|
280
327
|
doc: {
|
|
281
328
|
title: input.title,
|
|
282
329
|
description: input.description ?? "",
|
|
283
330
|
status,
|
|
284
331
|
tags: input.tags ?? [],
|
|
285
332
|
goal: input.goal ?? null,
|
|
286
|
-
|
|
287
|
-
blockedBy: [...new Set(input.blockedBy ?? [])].sort((a, b) => a - b),
|
|
333
|
+
blockedBy: sortKeys([...new Set(input.blockedBy ?? [])]),
|
|
288
334
|
prs: input.prs ?? [],
|
|
289
335
|
position: bottom + POSITION_GAP,
|
|
290
336
|
createdAt: timestamp,
|
|
@@ -293,27 +339,27 @@ export class FileStore implements Store {
|
|
|
293
339
|
}
|
|
294
340
|
this.writeTicket(ticket)
|
|
295
341
|
if (input.blocks !== undefined) {
|
|
296
|
-
this.reconcileBlocks(
|
|
342
|
+
this.reconcileBlocks(key, [...all, ticket], input.blocks, timestamp)
|
|
297
343
|
}
|
|
298
|
-
return this.get(
|
|
344
|
+
return this.get(key)!
|
|
299
345
|
}
|
|
300
346
|
|
|
301
|
-
update(
|
|
347
|
+
update(key: string, patch: TaskPatch): Task {
|
|
302
348
|
const all = this.readAll()
|
|
303
|
-
const ticket = all.find((t) => t.
|
|
349
|
+
const ticket = all.find((t) => t.key === key)
|
|
304
350
|
if (!ticket) {
|
|
305
|
-
this.assertNotArchived(
|
|
306
|
-
throw new Error(`no such task: ${this.displayId(
|
|
351
|
+
this.assertNotArchived(key)
|
|
352
|
+
throw new Error(`no such task: ${this.displayId(key)}`)
|
|
307
353
|
}
|
|
308
354
|
|
|
309
355
|
// Validate link targets before any file is written, so a bad target
|
|
310
356
|
// rejects the whole patch — the transactional behavior the SQLite store
|
|
311
357
|
// got for free.
|
|
312
358
|
for (const target of [...(patch.blocks ?? []), ...(patch.blockedBy ?? [])]) {
|
|
313
|
-
if (target ===
|
|
314
|
-
throw new Error(`a task can't block itself: ${this.displayId(
|
|
359
|
+
if (target === key) {
|
|
360
|
+
throw new Error(`a task can't block itself: ${this.displayId(key)}`)
|
|
315
361
|
}
|
|
316
|
-
if (!all.some((t) => t.
|
|
362
|
+
if (!all.some((t) => t.key === target)) {
|
|
317
363
|
this.assertNotArchived(target)
|
|
318
364
|
throw new Error(`no such task: ${this.displayId(target)}`)
|
|
319
365
|
}
|
|
@@ -322,14 +368,14 @@ export class FileStore implements Store {
|
|
|
322
368
|
const timestamp = now()
|
|
323
369
|
let linksChanged = false
|
|
324
370
|
if (patch.blocks !== undefined) {
|
|
325
|
-
linksChanged = this.reconcileBlocks(
|
|
371
|
+
linksChanged = this.reconcileBlocks(key, all, patch.blocks, timestamp)
|
|
326
372
|
}
|
|
327
373
|
if (patch.blockedBy !== undefined) {
|
|
328
|
-
const wanted = [...new Set(patch.blockedBy)]
|
|
374
|
+
const wanted = sortKeys([...new Set(patch.blockedBy)])
|
|
329
375
|
const current = ticket.doc.blockedBy
|
|
330
376
|
const touched = [
|
|
331
|
-
...current.filter((
|
|
332
|
-
...wanted.filter((
|
|
377
|
+
...current.filter((k) => !wanted.includes(k)),
|
|
378
|
+
...wanted.filter((k) => !current.includes(k)),
|
|
333
379
|
]
|
|
334
380
|
if (touched.length > 0) {
|
|
335
381
|
linksChanged = true
|
|
@@ -337,8 +383,8 @@ export class FileStore implements Store {
|
|
|
337
383
|
// The other end of every added or removed link gets its `updated`
|
|
338
384
|
// bumped too — its derived `blocks` view just changed. A dangling
|
|
339
385
|
// reference (hand-edit pointing at a deleted ticket) has no other end.
|
|
340
|
-
for (const
|
|
341
|
-
const other = all.find((t) => t.
|
|
386
|
+
for (const k of touched) {
|
|
387
|
+
const other = all.find((t) => t.key === k)
|
|
342
388
|
if (!other) continue
|
|
343
389
|
other.doc.updatedAt = timestamp
|
|
344
390
|
this.writeTicket(other)
|
|
@@ -348,8 +394,8 @@ export class FileStore implements Store {
|
|
|
348
394
|
|
|
349
395
|
const doc = ticket.doc
|
|
350
396
|
let fieldsChanged = false
|
|
351
|
-
const set = <K extends keyof TicketDoc>(
|
|
352
|
-
doc[
|
|
397
|
+
const set = <K extends keyof TicketDoc>(field: K, value: TicketDoc[K]) => {
|
|
398
|
+
doc[field] = value
|
|
353
399
|
fieldsChanged = true
|
|
354
400
|
}
|
|
355
401
|
|
|
@@ -360,15 +406,28 @@ export class FileStore implements Store {
|
|
|
360
406
|
if (patch.goal) this.assertGoalAssignable(patch.goal)
|
|
361
407
|
set("goal", patch.goal)
|
|
362
408
|
}
|
|
363
|
-
if (patch.needsHuman !== undefined) set("needsHuman", patch.needsHuman)
|
|
364
409
|
if (patch.prs !== undefined) set("prs", patch.prs)
|
|
365
410
|
if (patch.status !== undefined) {
|
|
411
|
+
// Done is gated on nothing owed: open asks refuse the move. Canceled
|
|
412
|
+
// stays open — a dead ticket's asks die with it and leave the inbox on
|
|
413
|
+
// their own.
|
|
414
|
+
if (patch.status === "done" && doc.status !== "done") {
|
|
415
|
+
const open = this.asks(key).filter((a) => !a.resolvedAt)
|
|
416
|
+
if (open.length > 0) {
|
|
417
|
+
const id = this.displayId(key)
|
|
418
|
+
throw new Error(
|
|
419
|
+
`${id} still has ${open.length} open ask${open.length === 1 ? "" : "s"} — done means nobody owes anything. ` +
|
|
420
|
+
`Resolve them (\`task resolve ${id} <n>\`) or move what's still owed to a new ticket:\n` +
|
|
421
|
+
open.map((a) => ` [${a.ordinal}] ${a.text.split("\n")[0]}`).join("\n"),
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
}
|
|
366
425
|
const previous = doc.status
|
|
367
426
|
set("status", patch.status)
|
|
368
427
|
if (patch.position === undefined && patch.status !== previous) {
|
|
369
428
|
// Moved columns without an explicit slot → land on top, where the
|
|
370
429
|
// freshest movement is visible (Linear's behavior).
|
|
371
|
-
const column = all.filter((t) => t.
|
|
430
|
+
const column = all.filter((t) => t.key !== key && t.doc.status === patch.status)
|
|
372
431
|
const top = column.length ? Math.min(...column.map((t) => t.doc.position)) : 0
|
|
373
432
|
set("position", top - POSITION_GAP)
|
|
374
433
|
}
|
|
@@ -378,7 +437,7 @@ export class FileStore implements Store {
|
|
|
378
437
|
if (!fieldsChanged && !linksChanged) return this.toTask(ticket, all)
|
|
379
438
|
if (fieldsChanged || linksChanged) doc.updatedAt = timestamp
|
|
380
439
|
this.writeTicket(ticket)
|
|
381
|
-
return this.get(
|
|
440
|
+
return this.get(key)!
|
|
382
441
|
}
|
|
383
442
|
|
|
384
443
|
/**
|
|
@@ -387,30 +446,30 @@ export class FileStore implements Store {
|
|
|
387
446
|
* (and, via the caller, this one) gets its `updated` bumped.
|
|
388
447
|
*/
|
|
389
448
|
private reconcileBlocks(
|
|
390
|
-
|
|
449
|
+
key: string,
|
|
391
450
|
all: Ticket[],
|
|
392
|
-
targets:
|
|
451
|
+
targets: string[],
|
|
393
452
|
timestamp: string,
|
|
394
453
|
): boolean {
|
|
395
454
|
const wanted = new Set(targets)
|
|
396
|
-
if (wanted.has(
|
|
397
|
-
throw new Error(`a task can't block itself: ${this.displayId(
|
|
455
|
+
if (wanted.has(key)) {
|
|
456
|
+
throw new Error(`a task can't block itself: ${this.displayId(key)}`)
|
|
398
457
|
}
|
|
399
458
|
let changed = false
|
|
400
459
|
for (const other of all) {
|
|
401
|
-
if (other.
|
|
402
|
-
const has = other.doc.blockedBy.includes(
|
|
403
|
-
const should = wanted.has(other.
|
|
460
|
+
if (other.key === key) continue
|
|
461
|
+
const has = other.doc.blockedBy.includes(key)
|
|
462
|
+
const should = wanted.has(other.key)
|
|
404
463
|
if (has === should) continue
|
|
405
464
|
other.doc.blockedBy = should
|
|
406
|
-
? [...other.doc.blockedBy,
|
|
407
|
-
: other.doc.blockedBy.filter((
|
|
465
|
+
? sortKeys([...other.doc.blockedBy, key])
|
|
466
|
+
: other.doc.blockedBy.filter((k) => k !== key)
|
|
408
467
|
other.doc.updatedAt = timestamp
|
|
409
468
|
this.writeTicket(other)
|
|
410
469
|
changed = true
|
|
411
470
|
}
|
|
412
471
|
if (changed) {
|
|
413
|
-
const self = all.find((t) => t.
|
|
472
|
+
const self = all.find((t) => t.key === key)
|
|
414
473
|
if (self) {
|
|
415
474
|
self.doc.updatedAt = timestamp
|
|
416
475
|
this.writeTicket(self)
|
|
@@ -420,37 +479,37 @@ export class FileStore implements Store {
|
|
|
420
479
|
}
|
|
421
480
|
|
|
422
481
|
/** `task link A --blocks B` and friends — additive, unlike the patch form. */
|
|
423
|
-
link(
|
|
424
|
-
const task = this.get(
|
|
425
|
-
if (!task) throw new Error(`no such task: ${this.displayId(
|
|
482
|
+
link(key: string, relation: "blocks" | "blocked_by", target: string): Task {
|
|
483
|
+
const task = this.get(key)
|
|
484
|
+
if (!task) throw new Error(`no such task: ${this.displayId(key)}`)
|
|
426
485
|
const current = relation === "blocks" ? task.blocks : task.blockedBy
|
|
427
486
|
const patch: TaskPatch =
|
|
428
487
|
relation === "blocks"
|
|
429
488
|
? { blocks: [...current, target] }
|
|
430
489
|
: { blockedBy: [...current, target] }
|
|
431
|
-
return this.update(
|
|
490
|
+
return this.update(key, patch)
|
|
432
491
|
}
|
|
433
492
|
|
|
434
|
-
unlink(
|
|
435
|
-
const task = this.get(
|
|
436
|
-
if (!task) throw new Error(`no such task: ${this.displayId(
|
|
493
|
+
unlink(key: string, relation: "blocks" | "blocked_by", target: string): Task {
|
|
494
|
+
const task = this.get(key)
|
|
495
|
+
if (!task) throw new Error(`no such task: ${this.displayId(key)}`)
|
|
437
496
|
const current = relation === "blocks" ? task.blocks : task.blockedBy
|
|
438
|
-
const kept = current.filter((
|
|
497
|
+
const kept = current.filter((k) => k !== target)
|
|
439
498
|
const patch: TaskPatch = relation === "blocks" ? { blocks: kept } : { blockedBy: kept }
|
|
440
|
-
return this.update(
|
|
499
|
+
return this.update(key, patch)
|
|
441
500
|
}
|
|
442
501
|
|
|
443
|
-
delete(
|
|
444
|
-
if (!this.readOne(
|
|
445
|
-
this.assertNotArchived(
|
|
446
|
-
throw new Error(`no such task: ${this.displayId(
|
|
502
|
+
delete(key: string): void {
|
|
503
|
+
if (!this.readOne(key)) {
|
|
504
|
+
this.assertNotArchived(key)
|
|
505
|
+
throw new Error(`no such task: ${this.displayId(key)}`)
|
|
447
506
|
}
|
|
448
|
-
rmSync(join(this.ticketsDir,
|
|
507
|
+
rmSync(join(this.ticketsDir, key), { recursive: true })
|
|
449
508
|
// Links pointing at the deleted ticket go with it — the same cascade the
|
|
450
509
|
// relation table had, so quiet on the other tickets' `updated`.
|
|
451
510
|
for (const other of this.readAll()) {
|
|
452
|
-
if (!other.doc.blockedBy.includes(
|
|
453
|
-
other.doc.blockedBy = other.doc.blockedBy.filter((
|
|
511
|
+
if (!other.doc.blockedBy.includes(key)) continue
|
|
512
|
+
other.doc.blockedBy = other.doc.blockedBy.filter((k) => k !== key)
|
|
454
513
|
this.writeTicket(other)
|
|
455
514
|
}
|
|
456
515
|
}
|
|
@@ -461,41 +520,41 @@ export class FileStore implements Store {
|
|
|
461
520
|
* Move a finished ticket's whole directory to `.task/archive/` — one rename,
|
|
462
521
|
* which git records as a move, so history follows the ticket. Archived
|
|
463
522
|
* tickets leave every hot path (`list`, the board, link derivation) but stay
|
|
464
|
-
* readable via `get`/`comments` and keep their
|
|
523
|
+
* readable via `get`/`comments` and keep their key reserved forever.
|
|
465
524
|
* Links other tickets hold on this one are left in place: they're
|
|
466
525
|
* dangling-tolerant everywhere, and unarchiving puts them back in force.
|
|
467
526
|
*/
|
|
468
|
-
archive(
|
|
469
|
-
const ticket = this.readOne(
|
|
527
|
+
archive(key: string): Task {
|
|
528
|
+
const ticket = this.readOne(key)
|
|
470
529
|
if (!ticket) {
|
|
471
|
-
if (this.isArchived(
|
|
472
|
-
throw new Error(`${this.displayId(
|
|
530
|
+
if (this.isArchived(key)) {
|
|
531
|
+
throw new Error(`${this.displayId(key)} is already archived`)
|
|
473
532
|
}
|
|
474
|
-
throw new Error(`no such task: ${this.displayId(
|
|
533
|
+
throw new Error(`no such task: ${this.displayId(key)}`)
|
|
475
534
|
}
|
|
476
535
|
if (ticket.doc.status !== "done" && ticket.doc.status !== "canceled") {
|
|
477
536
|
throw new Error(
|
|
478
|
-
`${this.displayId(
|
|
537
|
+
`${this.displayId(key)} is ${ticket.doc.status} — only done or canceled tickets can be archived`,
|
|
479
538
|
)
|
|
480
539
|
}
|
|
481
540
|
mkdirSync(this.archiveDir, { recursive: true })
|
|
482
|
-
renameSync(join(this.ticketsDir,
|
|
483
|
-
return this.get(
|
|
541
|
+
renameSync(join(this.ticketsDir, key), join(this.archiveDir, key))
|
|
542
|
+
return this.get(key)!
|
|
484
543
|
}
|
|
485
544
|
|
|
486
|
-
unarchive(
|
|
487
|
-
if (!this.isArchived(
|
|
545
|
+
unarchive(key: string): Task {
|
|
546
|
+
if (!this.isArchived(key)) {
|
|
488
547
|
throw new Error(
|
|
489
|
-
this.readOne(
|
|
490
|
-
? `${this.displayId(
|
|
491
|
-
: `no such task: ${this.displayId(
|
|
548
|
+
this.readOne(key)
|
|
549
|
+
? `${this.displayId(key)} isn't archived`
|
|
550
|
+
: `no such task: ${this.displayId(key)}`,
|
|
492
551
|
)
|
|
493
552
|
}
|
|
494
553
|
// A checkout where everything is archived has no tickets/ at all — git
|
|
495
554
|
// doesn't keep empty directories.
|
|
496
555
|
mkdirSync(this.ticketsDir, { recursive: true })
|
|
497
|
-
renameSync(join(this.archiveDir,
|
|
498
|
-
return this.get(
|
|
556
|
+
renameSync(join(this.archiveDir, key), join(this.ticketsDir, key))
|
|
557
|
+
return this.get(key)!
|
|
499
558
|
}
|
|
500
559
|
|
|
501
560
|
// ── Goals ──────────────────────────────────────────────────────────────────
|
|
@@ -621,7 +680,7 @@ export class FileStore implements Store {
|
|
|
621
680
|
(t) => t.doc.goal === slug && t.doc.status !== "done" && t.doc.status !== "canceled",
|
|
622
681
|
)
|
|
623
682
|
if (open.length > 0) {
|
|
624
|
-
const ids = open.map((t) => this.displayId(t.
|
|
683
|
+
const ids = open.map((t) => this.displayId(t.key)).join(", ")
|
|
625
684
|
throw new Error(`goal "${slug}" still has open tasks (${ids}) — finish or reassign them first`)
|
|
626
685
|
}
|
|
627
686
|
mkdirSync(this.goalsArchiveDir, { recursive: true })
|
|
@@ -659,7 +718,7 @@ export class FileStore implements Store {
|
|
|
659
718
|
}
|
|
660
719
|
const referencing = this.readAll().filter((t) => t.doc.goal === slug)
|
|
661
720
|
if (referencing.length > 0) {
|
|
662
|
-
const ids = referencing.map((t) => this.displayId(t.
|
|
721
|
+
const ids = referencing.map((t) => this.displayId(t.key)).join(", ")
|
|
663
722
|
throw new Error(
|
|
664
723
|
`goal "${slug}" is referenced by ${ids} — clear those with \`task update <id> --goal ""\` first`,
|
|
665
724
|
)
|
|
@@ -674,8 +733,8 @@ export class FileStore implements Store {
|
|
|
674
733
|
* the same ticket produce two files and merge without a conflict — which is
|
|
675
734
|
* why they aren't lines inside `ticket.md`.
|
|
676
735
|
*/
|
|
677
|
-
comments(
|
|
678
|
-
const dir = this.commentsPath(
|
|
736
|
+
comments(key: string): Comment[] {
|
|
737
|
+
const dir = this.commentsPath(key)
|
|
679
738
|
if (!existsSync(dir)) return []
|
|
680
739
|
const comments: Comment[] = []
|
|
681
740
|
for (const entry of readdirSync(dir)) {
|
|
@@ -684,7 +743,7 @@ export class FileStore implements Store {
|
|
|
684
743
|
const doc = parseComment(readFileSync(path, "utf8"), path)
|
|
685
744
|
comments.push({
|
|
686
745
|
id: entry.slice(0, -3),
|
|
687
|
-
taskId: this.displayId(
|
|
746
|
+
taskId: this.displayId(key),
|
|
688
747
|
author: doc.author,
|
|
689
748
|
body: doc.body,
|
|
690
749
|
createdAt: doc.createdAt,
|
|
@@ -696,45 +755,187 @@ export class FileStore implements Store {
|
|
|
696
755
|
return comments
|
|
697
756
|
}
|
|
698
757
|
|
|
699
|
-
addComment(
|
|
700
|
-
if (!this.readOne(
|
|
701
|
-
this.assertNotArchived(
|
|
702
|
-
throw new Error(`no such task: ${this.displayId(
|
|
758
|
+
addComment(key: string, body: string, author = ""): Comment {
|
|
759
|
+
if (!this.readOne(key)) {
|
|
760
|
+
this.assertNotArchived(key)
|
|
761
|
+
throw new Error(`no such task: ${this.displayId(key)}`)
|
|
703
762
|
}
|
|
704
763
|
const createdAt = now()
|
|
705
|
-
const dir = this.commentsPath(
|
|
764
|
+
const dir = this.commentsPath(key)
|
|
706
765
|
mkdirSync(dir, { recursive: true })
|
|
707
766
|
const stem = commentStem(createdAt, author)
|
|
708
767
|
let id = stem
|
|
709
768
|
for (let n = 2; existsSync(join(dir, `${id}.md`)); n++) id = `${stem}-${n}`
|
|
710
769
|
writeAtomic(join(dir, `${id}.md`), serializeComment({ author, createdAt, body }))
|
|
711
|
-
return { id, taskId: this.displayId(
|
|
770
|
+
return { id, taskId: this.displayId(key), author, body, createdAt }
|
|
712
771
|
}
|
|
713
772
|
|
|
714
|
-
deleteComment(
|
|
773
|
+
deleteComment(key: string, commentId: string): Comment {
|
|
715
774
|
// Resolve the id against the directory listing rather than building a path
|
|
716
775
|
// from it — the id arrived over HTTP and must not be able to point
|
|
717
776
|
// anywhere but at an actual comment of this task.
|
|
718
|
-
const existing = this.comments(
|
|
777
|
+
const existing = this.comments(key).find((c) => c.id === commentId)
|
|
719
778
|
if (!existing) {
|
|
720
|
-
throw new Error(`no such comment on ${this.displayId(
|
|
779
|
+
throw new Error(`no such comment on ${this.displayId(key)}: ${commentId}`)
|
|
721
780
|
}
|
|
722
|
-
rmSync(join(this.commentsPath(
|
|
781
|
+
rmSync(join(this.commentsPath(key), `${commentId}.md`))
|
|
723
782
|
return existing
|
|
724
783
|
}
|
|
725
784
|
|
|
726
|
-
commentCounts(): Map<
|
|
727
|
-
const counts = new Map<
|
|
785
|
+
commentCounts(): Map<string, number> {
|
|
786
|
+
const counts = new Map<string, number>()
|
|
728
787
|
for (const ticket of this.readAll()) {
|
|
729
|
-
const dir = this.commentsPath(ticket.
|
|
788
|
+
const dir = this.commentsPath(ticket.key)
|
|
730
789
|
if (!existsSync(dir)) continue
|
|
731
790
|
const count = readdirSync(dir).filter(
|
|
732
791
|
(f) => f.endsWith(".md") && !f.startsWith("."),
|
|
733
792
|
).length
|
|
734
|
-
if (count > 0) counts.set(ticket.
|
|
793
|
+
if (count > 0) counts.set(ticket.key, count)
|
|
735
794
|
}
|
|
736
795
|
return counts
|
|
737
796
|
}
|
|
797
|
+
|
|
798
|
+
// ── Asks ───────────────────────────────────────────────────────────────────
|
|
799
|
+
|
|
800
|
+
/** Like comments, an archived ticket keeps its asks readable in place. */
|
|
801
|
+
private asksPath(key: string): string {
|
|
802
|
+
const home = existsSync(join(this.archiveDir, key)) ? this.archiveDir : this.ticketsDir
|
|
803
|
+
return join(home, key, ASKS_DIR)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Every ask on a ticket, open and resolved, in creation order — the order
|
|
808
|
+
* that fixes each ask's ordinal, the short handle `task resolve` takes.
|
|
809
|
+
* Ordinals are positions in this listing rather than stored, so two branches
|
|
810
|
+
* adding asks concurrently can't mint a duplicate; they shift only if an ask
|
|
811
|
+
* is deleted outright, which is why resolving (not deleting) is the normal
|
|
812
|
+
* way an ask ends.
|
|
813
|
+
*/
|
|
814
|
+
asks(key: string): Ask[] {
|
|
815
|
+
const dir = this.asksPath(key)
|
|
816
|
+
if (!existsSync(dir)) return []
|
|
817
|
+
const asks: Omit<Ask, "ordinal">[] = []
|
|
818
|
+
for (const entry of readdirSync(dir)) {
|
|
819
|
+
if (!entry.endsWith(".md") || entry.startsWith(".")) continue
|
|
820
|
+
const path = join(dir, entry)
|
|
821
|
+
const doc = parseAsk(readFileSync(path, "utf8"), path)
|
|
822
|
+
asks.push({
|
|
823
|
+
id: entry.slice(0, -3),
|
|
824
|
+
taskId: this.displayId(key),
|
|
825
|
+
text: doc.body,
|
|
826
|
+
author: doc.author,
|
|
827
|
+
createdAt: doc.createdAt,
|
|
828
|
+
resolvedAt: doc.resolvedAt,
|
|
829
|
+
resolvedBy: doc.resolvedBy,
|
|
830
|
+
})
|
|
831
|
+
}
|
|
832
|
+
asks.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))
|
|
833
|
+
return asks.map((ask, index) => ({ ...ask, ordinal: index + 1 }))
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* File an ask — allowed only while the ticket is open. A done ticket can't
|
|
838
|
+
* quietly start owing things again (that's the NIC-17 failure this feature
|
|
839
|
+
* exists to end): reopen it, or file the ask as a new ticket.
|
|
840
|
+
*/
|
|
841
|
+
addAsk(key: string, text: string, author = ""): Ask {
|
|
842
|
+
const ticket = this.readOne(key)
|
|
843
|
+
if (!ticket) {
|
|
844
|
+
this.assertNotArchived(key)
|
|
845
|
+
throw new Error(`no such task: ${this.displayId(key)}`)
|
|
846
|
+
}
|
|
847
|
+
if (ticket.doc.status === "done" || ticket.doc.status === "canceled") {
|
|
848
|
+
throw new Error(
|
|
849
|
+
`${this.displayId(key)} is ${ticket.doc.status} — asks belong on open tickets. Reopen it, or file the ask as a new ticket`,
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
const createdAt = now()
|
|
853
|
+
const dir = this.asksPath(key)
|
|
854
|
+
mkdirSync(dir, { recursive: true })
|
|
855
|
+
const stem = commentStem(createdAt, author)
|
|
856
|
+
let id = stem
|
|
857
|
+
for (let n = 2; existsSync(join(dir, `${id}.md`)); n++) id = `${stem}-${n}`
|
|
858
|
+
const doc: AskDoc = { author, createdAt, resolvedAt: null, resolvedBy: null, body: text }
|
|
859
|
+
writeAtomic(join(dir, `${id}.md`), serializeAsk(doc))
|
|
860
|
+
return this.asks(key).find((a) => a.id === id)!
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Resolve an ask against the current listing — `ref` is an ordinal ("2") or
|
|
865
|
+
* the ask's id. Resolution edits the ask's own file, so the person's answer
|
|
866
|
+
* and the worker's question live in one place whatever branch each came from.
|
|
867
|
+
*/
|
|
868
|
+
resolveAsk(key: string, ref: string, resolvedBy = ""): Ask {
|
|
869
|
+
return this.setAskResolution(key, ref, now(), resolvedBy)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Reopen a resolved ask — same refusals as adding one. */
|
|
873
|
+
reopenAsk(key: string, ref: string): Ask {
|
|
874
|
+
return this.setAskResolution(key, ref, null, null)
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private setAskResolution(
|
|
878
|
+
key: string,
|
|
879
|
+
ref: string,
|
|
880
|
+
resolvedAt: string | null,
|
|
881
|
+
resolvedBy: string | null,
|
|
882
|
+
): Ask {
|
|
883
|
+
this.assertNotArchived(key)
|
|
884
|
+
const ask = this.findAsk(key, ref)
|
|
885
|
+
const ticket = this.readOne(key)
|
|
886
|
+
if (
|
|
887
|
+
resolvedAt === null &&
|
|
888
|
+
(ticket?.doc.status === "done" || ticket?.doc.status === "canceled")
|
|
889
|
+
) {
|
|
890
|
+
throw new Error(
|
|
891
|
+
`${this.displayId(key)} is ${ticket.doc.status} — reopening an ask would leave a finished ticket owing something. Reopen the ticket first, or file a new one`,
|
|
892
|
+
)
|
|
893
|
+
}
|
|
894
|
+
const doc: AskDoc = {
|
|
895
|
+
author: ask.author,
|
|
896
|
+
createdAt: ask.createdAt,
|
|
897
|
+
resolvedAt,
|
|
898
|
+
resolvedBy: resolvedAt === null ? null : resolvedBy,
|
|
899
|
+
body: ask.text,
|
|
900
|
+
}
|
|
901
|
+
writeAtomic(join(this.asksPath(key), `${ask.id}.md`), serializeAsk(doc))
|
|
902
|
+
return this.asks(key).find((a) => a.id === ask.id)!
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
deleteAsk(key: string, ref: string): Ask {
|
|
906
|
+
this.assertNotArchived(key)
|
|
907
|
+
const ask = this.findAsk(key, ref)
|
|
908
|
+
rmSync(join(this.asksPath(key), `${ask.id}.md`))
|
|
909
|
+
return ask
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* Resolve an ordinal or id against the directory listing rather than
|
|
914
|
+
* building a path from it — same rule as comment ids, the ref arrived over
|
|
915
|
+
* HTTP or a shell and must not point anywhere but at this ticket's asks.
|
|
916
|
+
*/
|
|
917
|
+
private findAsk(key: string, ref: string): Ask {
|
|
918
|
+
const asks = this.asks(key)
|
|
919
|
+
const trimmed = ref.trim()
|
|
920
|
+
const byOrdinal = /^\d+$/.test(trimmed)
|
|
921
|
+
? asks.find((a) => a.ordinal === Number(trimmed))
|
|
922
|
+
: undefined
|
|
923
|
+
const ask = byOrdinal ?? asks.find((a) => a.id === trimmed)
|
|
924
|
+
if (!ask) {
|
|
925
|
+
const id = this.displayId(key)
|
|
926
|
+
throw new Error(
|
|
927
|
+
asks.length === 0
|
|
928
|
+
? `${id} has no asks`
|
|
929
|
+
: `no such ask on ${id}: ${ref} — ordinals run 1-${asks.length} (\`task show ${id}\` lists them)`,
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
return ask
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** The list() tiebreak, restated over Task — creation time, then key. */
|
|
937
|
+
function byCreationTask(a: Task, b: Task): number {
|
|
938
|
+
return a.createdAt.localeCompare(b.createdAt) || a.key.localeCompare(b.key)
|
|
738
939
|
}
|
|
739
940
|
|
|
740
941
|
// ── Opening and initializing ─────────────────────────────────────────────────
|
|
@@ -767,6 +968,18 @@ export function openBoard(root: string): Store {
|
|
|
767
968
|
"this board still stores its state in a pre-0.6 SQLite database — run `npx @nickmeriano/task@0.6 migrate` once, then retry",
|
|
768
969
|
)
|
|
769
970
|
}
|
|
971
|
+
} else {
|
|
972
|
+
// A board whose tickets are ALL numeric directories predates the TAS-42
|
|
973
|
+
// id cutover. Reading it as empty (the reader skips numeric dirs) would
|
|
974
|
+
// silently strand every ticket — refuse loudly instead, same policy as
|
|
975
|
+
// the SQLite case. A mixed board (a stray numeric dir merged in from an
|
|
976
|
+
// old branch) still opens; `task check` names the straggler.
|
|
977
|
+
const entries = readdirSync(join(taskDir, TICKETS_DIR))
|
|
978
|
+
if (entries.some((name) => /^\d+$/.test(name)) && !entries.some(isTicketKey)) {
|
|
979
|
+
throw new Error(
|
|
980
|
+
"this board's tickets use the pre-TAS-42 sequential-id format, which is no longer read — run the id migration (tools/migrate-ids/migrate.mjs in the repo that ships this board, or the migration script from the release notes) once, then retry",
|
|
981
|
+
)
|
|
982
|
+
}
|
|
770
983
|
}
|
|
771
984
|
return new FileStore(root)
|
|
772
985
|
}
|
|
@@ -796,4 +1009,3 @@ export function initProject(root: string, options: InitOptions): Store {
|
|
|
796
1009
|
writeFileSync(join(taskDir, ".gitignore"), GITIGNORE)
|
|
797
1010
|
return new FileStore(root)
|
|
798
1011
|
}
|
|
799
|
-
|