@nickmeriano/task 0.7.1 → 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 +73 -25
- 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 +43 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +403 -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 +248 -0
- package/dist/check.test.js.map +1 -0
- 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 +61 -9
- package/dist/claim.d.ts.map +1 -1
- package/dist/claim.js +197 -67
- package/dist/claim.js.map +1 -1
- package/dist/claim.test.d.ts +2 -2
- package/dist/claim.test.js +235 -64
- package/dist/claim.test.js.map +1 -1
- package/dist/cli.js +724 -136
- package/dist/cli.js.map +1 -1
- package/dist/file-store.d.ts +110 -38
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +514 -238
- 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/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 +52 -0
- package/dist/overview.d.ts.map +1 -0
- package/dist/overview.js +61 -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 +48 -0
- package/dist/overview.test.js.map +1 -0
- package/dist/promote.test.d.ts +15 -0
- package/dist/promote.test.d.ts.map +1 -0
- package/dist/promote.test.js +104 -0
- package/dist/promote.test.js.map +1 -0
- 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 +34 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +79 -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 +228 -23
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +43 -63
- 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 +148 -106
- package/dist/store.test.js.map +1 -1
- package/dist/ticket-doc.d.ts +74 -5
- package/dist/ticket-doc.d.ts.map +1 -1
- package/dist/ticket-doc.js +229 -15
- package/dist/ticket-doc.js.map +1 -1
- package/dist/types.d.ts +115 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +153 -40
- package/src/asks.test.ts +355 -0
- package/src/check.test.ts +328 -0
- package/src/check.ts +497 -0
- package/src/claim-io.ts +401 -0
- package/src/claim.test.ts +301 -71
- package/src/claim.ts +238 -81
- package/src/cli.ts +740 -131
- package/src/file-store.ts +572 -254
- 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/index.ts +4 -2
- package/src/overview.test.ts +52 -0
- package/src/overview.ts +105 -0
- package/src/promote.test.ts +143 -0
- package/src/publish.ts +6 -53
- package/src/search.test.ts +64 -0
- package/src/search.ts +105 -0
- package/src/server.ts +232 -21
- package/src/store.test.ts +166 -116
- package/src/store.ts +46 -444
- package/src/ticket-doc.ts +284 -21
- package/src/types.ts +120 -28
- 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-COunM-QN.css +0 -1
- package/ui/dist/assets/index-D4homvrQ.js +0 -229
package/dist/file-store.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
2
2
|
import { basename, dirname, join } from "node:path";
|
|
3
|
-
import { commentStem, parseComment, parseTicket, serializeComment, serializeTicket, } from "./ticket-doc.js";
|
|
4
|
-
import {
|
|
3
|
+
import { commentStem, isGoalSlug, parseAsk, parseComment, parseGoal, parseTicket, serializeAsk, serializeComment, serializeGoal, serializeTicket, slugifyGoal, } from "./ticket-doc.js";
|
|
4
|
+
import { isTicketKey, mintKey } from "./id.js";
|
|
5
|
+
import { CONFIG_FILE, POSITION_GAP, TASK_DIR, derivePrefix, } from "./store.js";
|
|
5
6
|
export const TICKETS_DIR = "tickets";
|
|
6
7
|
/** Finished tickets moved out of the hot path — same per-ticket layout. */
|
|
7
8
|
export const ARCHIVE_DIR = "archive";
|
|
8
|
-
const TICKET_FILE = "ticket.md";
|
|
9
|
-
const COMMENTS_DIR = "comments";
|
|
9
|
+
export const TICKET_FILE = "ticket.md";
|
|
10
|
+
export const COMMENTS_DIR = "comments";
|
|
11
|
+
/** One file per ask — `tickets/<key>/asks/<stem>.md`, the comment layout. */
|
|
12
|
+
export const ASKS_DIR = "asks";
|
|
13
|
+
/** One file per goal — `.task/goals/<slug>.md`; archived ones one level down. */
|
|
14
|
+
export const GOALS_DIR = "goals";
|
|
15
|
+
export const GOALS_ARCHIVE_DIR = "archive";
|
|
10
16
|
function now() {
|
|
11
17
|
return new Date().toISOString();
|
|
12
18
|
}
|
|
@@ -19,13 +25,22 @@ function writeAtomic(path, text) {
|
|
|
19
25
|
writeFileSync(tmp, text);
|
|
20
26
|
renameSync(tmp, path);
|
|
21
27
|
}
|
|
28
|
+
/** Stable sort for key lists — lexicographic, since keys carry no order. */
|
|
29
|
+
function sortKeys(keys) {
|
|
30
|
+
return keys.sort((a, b) => a.localeCompare(b));
|
|
31
|
+
}
|
|
32
|
+
/** Board order's tiebreak: creation time, then key — keys themselves say nothing. */
|
|
33
|
+
function byCreation(a, b) {
|
|
34
|
+
return a.doc.createdAt.localeCompare(b.doc.createdAt) || a.key.localeCompare(b.key);
|
|
35
|
+
}
|
|
22
36
|
/**
|
|
23
37
|
* The canonical persistence layer: one directory per ticket under
|
|
24
|
-
* `.task/tickets/`,
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* construction
|
|
38
|
+
* `.task/tickets/`, named by the ticket's key (see id.ts), holding a
|
|
39
|
+
* frontmatter+markdown `ticket.md` and one file per comment. Everything is
|
|
40
|
+
* plain text committed to git, which is the point — ticket edits diff, review,
|
|
41
|
+
* and merge like code, and two branches creating tickets (or adding comments
|
|
42
|
+
* to the same one) merge cleanly by construction: keys are random, so parallel
|
|
43
|
+
* branches can't mint the same directory.
|
|
29
44
|
*
|
|
30
45
|
* There is no database and no cache: boards are dozens of tickets, and
|
|
31
46
|
* re-reading a handful of small files per operation is cheaper than opening
|
|
@@ -38,104 +53,127 @@ export class FileStore {
|
|
|
38
53
|
config;
|
|
39
54
|
ticketsDir;
|
|
40
55
|
archiveDir;
|
|
56
|
+
goalsDir;
|
|
57
|
+
goalsArchiveDir;
|
|
41
58
|
constructor(root) {
|
|
42
59
|
this.root = root;
|
|
43
60
|
this.taskDir = join(root, TASK_DIR);
|
|
44
61
|
this.config = JSON.parse(readFileSync(join(this.taskDir, CONFIG_FILE), "utf8"));
|
|
45
62
|
this.ticketsDir = join(this.taskDir, TICKETS_DIR);
|
|
46
63
|
this.archiveDir = join(this.taskDir, ARCHIVE_DIR);
|
|
64
|
+
this.goalsDir = join(this.taskDir, GOALS_DIR);
|
|
65
|
+
this.goalsArchiveDir = join(this.goalsDir, GOALS_ARCHIVE_DIR);
|
|
47
66
|
}
|
|
48
67
|
close() { }
|
|
49
|
-
displayId(
|
|
50
|
-
return `${this.config.prefix}-${
|
|
68
|
+
displayId(key) {
|
|
69
|
+
return `${this.config.prefix}-${key}`;
|
|
51
70
|
}
|
|
52
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* "PHO-x7k4m", "x7k4m", or any unique prefix of a key ("x7") — resolved
|
|
73
|
+
* git-style against the board's live and archived tickets, case-insensitive.
|
|
74
|
+
* An ambiguous prefix throws naming the candidates; an unknown ref comes
|
|
75
|
+
* back verbatim, so the caller's lookup says "no such task" with it.
|
|
76
|
+
*/
|
|
53
77
|
parseId(ref) {
|
|
54
|
-
const match = /^(?:[A-Za-z0-9]+-)?(
|
|
78
|
+
const match = /^(?:[A-Za-z0-9]+-)?([A-Za-z0-9]+)$/.exec(ref.trim());
|
|
55
79
|
if (!match)
|
|
56
80
|
throw new Error(`invalid task id: ${ref}`);
|
|
57
|
-
|
|
81
|
+
const key = match[1].toLowerCase();
|
|
82
|
+
const known = [...this.liveKeys(), ...this.archivedKeys()];
|
|
83
|
+
if (known.includes(key))
|
|
84
|
+
return key;
|
|
85
|
+
const hits = sortKeys(known.filter((k) => k.startsWith(key)));
|
|
86
|
+
if (hits.length === 1)
|
|
87
|
+
return hits[0];
|
|
88
|
+
if (hits.length > 1) {
|
|
89
|
+
throw new Error(`${ref} is ambiguous — matches ${hits.map((k) => this.displayId(k)).join(", ")}`);
|
|
90
|
+
}
|
|
91
|
+
return key;
|
|
58
92
|
}
|
|
59
93
|
// ── Reading ────────────────────────────────────────────────────────────────
|
|
60
|
-
ticketPath(
|
|
61
|
-
return join(this.ticketsDir,
|
|
94
|
+
ticketPath(key) {
|
|
95
|
+
return join(this.ticketsDir, key, TICKET_FILE);
|
|
62
96
|
}
|
|
63
97
|
/**
|
|
64
98
|
* A ticket's comments live wherever the ticket does — the whole directory
|
|
65
99
|
* moves on archive, so an archived ticket's discussion stays readable.
|
|
66
100
|
*/
|
|
67
|
-
commentsPath(
|
|
68
|
-
const home = existsSync(join(this.archiveDir,
|
|
69
|
-
|
|
70
|
-
: this.ticketsDir;
|
|
71
|
-
return join(home, String(number), COMMENTS_DIR);
|
|
101
|
+
commentsPath(key) {
|
|
102
|
+
const home = existsSync(join(this.archiveDir, key)) ? this.archiveDir : this.ticketsDir;
|
|
103
|
+
return join(home, key, COMMENTS_DIR);
|
|
72
104
|
}
|
|
73
105
|
readDir(dir) {
|
|
74
106
|
if (!existsSync(dir))
|
|
75
107
|
return [];
|
|
76
108
|
const tickets = [];
|
|
77
109
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
78
|
-
if (!entry.isDirectory() ||
|
|
110
|
+
if (!entry.isDirectory() || !isTicketKey(entry.name))
|
|
79
111
|
continue;
|
|
80
112
|
const path = join(dir, entry.name, TICKET_FILE);
|
|
81
113
|
if (!existsSync(path))
|
|
82
114
|
continue;
|
|
83
|
-
tickets.push({
|
|
115
|
+
tickets.push({ key: entry.name, doc: parseTicket(readFileSync(path, "utf8"), path) });
|
|
84
116
|
}
|
|
85
|
-
tickets.sort(
|
|
117
|
+
tickets.sort(byCreation);
|
|
86
118
|
return tickets;
|
|
87
119
|
}
|
|
88
120
|
/** Every live ticket on the board, freshly parsed — the files are the state. */
|
|
89
121
|
readAll() {
|
|
90
122
|
return this.readDir(this.ticketsDir);
|
|
91
123
|
}
|
|
92
|
-
readOne(
|
|
93
|
-
const path = this.ticketPath(
|
|
94
|
-
if (!existsSync(path))
|
|
124
|
+
readOne(key) {
|
|
125
|
+
const path = this.ticketPath(key);
|
|
126
|
+
if (!isTicketKey(key) || !existsSync(path))
|
|
95
127
|
return null;
|
|
96
|
-
return {
|
|
128
|
+
return { key, doc: parseTicket(readFileSync(path, "utf8"), path) };
|
|
97
129
|
}
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
if (!existsSync(
|
|
130
|
+
/** Directory names that are well-formed keys, without parsing anything. */
|
|
131
|
+
keysIn(dir) {
|
|
132
|
+
if (!existsSync(dir))
|
|
101
133
|
return [];
|
|
102
|
-
return readdirSync(
|
|
103
|
-
.filter((name) => /^\d+$/.test(name))
|
|
104
|
-
.map(Number);
|
|
134
|
+
return readdirSync(dir).filter(isTicketKey);
|
|
105
135
|
}
|
|
106
|
-
|
|
107
|
-
return
|
|
136
|
+
liveKeys() {
|
|
137
|
+
return this.keysIn(this.ticketsDir);
|
|
138
|
+
}
|
|
139
|
+
/** Cheap presence check — the archive's keys, without parsing anything. */
|
|
140
|
+
archivedKeys() {
|
|
141
|
+
return this.keysIn(this.archiveDir);
|
|
142
|
+
}
|
|
143
|
+
isArchived(key) {
|
|
144
|
+
return isTicketKey(key) && existsSync(join(this.archiveDir, key, TICKET_FILE));
|
|
108
145
|
}
|
|
109
146
|
/** The error every write path throws instead of touching the archive. */
|
|
110
|
-
assertNotArchived(
|
|
111
|
-
if (this.isArchived(
|
|
112
|
-
const id = this.displayId(
|
|
147
|
+
assertNotArchived(key) {
|
|
148
|
+
if (this.isArchived(key)) {
|
|
149
|
+
const id = this.displayId(key);
|
|
113
150
|
throw new Error(`${id} is archived — run \`task unarchive ${id}\` first`);
|
|
114
151
|
}
|
|
115
152
|
}
|
|
116
153
|
writeTicket(ticket) {
|
|
117
|
-
mkdirSync(dirname(this.ticketPath(ticket.
|
|
118
|
-
writeAtomic(this.ticketPath(ticket.
|
|
154
|
+
mkdirSync(dirname(this.ticketPath(ticket.key)), { recursive: true });
|
|
155
|
+
writeAtomic(this.ticketPath(ticket.key), serializeTicket(ticket.doc));
|
|
119
156
|
}
|
|
120
157
|
/**
|
|
121
158
|
* Only `blocked_by` is stored (on the blocked ticket); the `blocks` side is
|
|
122
|
-
* derived here, so the two views can never disagree.
|
|
159
|
+
* derived here, so the two views can never disagree. `needsHuman` is derived
|
|
160
|
+
* too — open asks, nothing else — so a flagged ticket with no stated reason
|
|
161
|
+
* cannot exist.
|
|
123
162
|
*/
|
|
124
|
-
toTask(ticket, all) {
|
|
125
|
-
const blocks = all
|
|
126
|
-
.filter((t) => t.doc.blockedBy.includes(ticket.number))
|
|
127
|
-
.map((t) => t.number);
|
|
163
|
+
toTask(ticket, all, asks = this.asks(ticket.key)) {
|
|
164
|
+
const blocks = sortKeys(all.filter((t) => t.doc.blockedBy.includes(ticket.key)).map((t) => t.key));
|
|
128
165
|
return {
|
|
129
|
-
id: this.displayId(ticket.
|
|
130
|
-
|
|
166
|
+
id: this.displayId(ticket.key),
|
|
167
|
+
key: ticket.key,
|
|
131
168
|
title: ticket.doc.title,
|
|
132
169
|
description: ticket.doc.description,
|
|
133
170
|
status: ticket.doc.status,
|
|
134
171
|
tags: ticket.doc.tags,
|
|
135
|
-
|
|
136
|
-
needsHuman:
|
|
172
|
+
goal: ticket.doc.goal,
|
|
173
|
+
needsHuman: asks.some((a) => !a.resolvedAt),
|
|
174
|
+
asks,
|
|
137
175
|
blocks,
|
|
138
|
-
blockedBy: [...ticket.doc.blockedBy]
|
|
176
|
+
blockedBy: sortKeys([...ticket.doc.blockedBy]),
|
|
139
177
|
prs: ticket.doc.prs,
|
|
140
178
|
position: ticket.doc.position,
|
|
141
179
|
createdAt: ticket.doc.createdAt,
|
|
@@ -147,11 +185,13 @@ export class FileStore {
|
|
|
147
185
|
const wanted = new Set(filter.statuses);
|
|
148
186
|
tickets = tickets.filter((t) => wanted.has(t.doc.status));
|
|
149
187
|
}
|
|
150
|
-
if (filter.
|
|
151
|
-
tickets = tickets.filter((t) => t.doc.
|
|
188
|
+
if (filter.goal) {
|
|
189
|
+
tickets = tickets.filter((t) => t.doc.goal === filter.goal);
|
|
152
190
|
}
|
|
153
191
|
if (filter.needsHuman !== undefined) {
|
|
154
|
-
|
|
192
|
+
// The derived flag, same definition as toTask — filtering must never
|
|
193
|
+
// disagree with what the payload says.
|
|
194
|
+
tickets = tickets.filter((t) => this.asks(t.key).some((a) => !a.resolvedAt) === filter.needsHuman);
|
|
155
195
|
}
|
|
156
196
|
if (filter.tags?.length) {
|
|
157
197
|
const wanted = new Set(filter.tags);
|
|
@@ -168,52 +208,58 @@ export class FileStore {
|
|
|
168
208
|
const everything = [...this.readAll(), ...archived];
|
|
169
209
|
return this.applyFilter(archived, filter)
|
|
170
210
|
.map((t) => ({ ...this.toTask(t, everything), archived: true }))
|
|
171
|
-
.sort((a, b) => a.position - b.position || a
|
|
211
|
+
.sort((a, b) => a.position - b.position || byCreationTask(a, b));
|
|
172
212
|
}
|
|
173
213
|
const all = this.readAll();
|
|
174
214
|
return this.applyFilter(all, filter)
|
|
175
215
|
.map((t) => this.toTask(t, all))
|
|
176
|
-
.sort((a, b) => a.position - b.position || a
|
|
216
|
+
.sort((a, b) => a.position - b.position || byCreationTask(a, b));
|
|
177
217
|
}
|
|
178
|
-
get(
|
|
218
|
+
get(key) {
|
|
179
219
|
const all = this.readAll();
|
|
180
|
-
const ticket = all.find((t) => t.
|
|
220
|
+
const ticket = all.find((t) => t.key === key);
|
|
181
221
|
if (ticket)
|
|
182
222
|
return this.toTask(ticket, all);
|
|
183
223
|
// `show` should reach the archive without ceremony — reads are safe.
|
|
184
|
-
|
|
224
|
+
if (!isTicketKey(key))
|
|
225
|
+
return null;
|
|
226
|
+
const path = join(this.archiveDir, key, TICKET_FILE);
|
|
185
227
|
if (!existsSync(path))
|
|
186
228
|
return null;
|
|
187
|
-
const archived = {
|
|
229
|
+
const archived = { key, doc: parseTicket(readFileSync(path, "utf8"), path) };
|
|
188
230
|
return { ...this.toTask(archived, [...all, archived]), archived: true };
|
|
189
231
|
}
|
|
190
232
|
// ── Writing ────────────────────────────────────────────────────────────────
|
|
191
233
|
create(input) {
|
|
192
234
|
const all = this.readAll();
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
const
|
|
235
|
+
// Random, not sequential (TAS-42): the key must be safe to mint on any
|
|
236
|
+
// branch with no coordination. Archived keys stay reserved — a new ticket
|
|
237
|
+
// must never take a name old comments or PR titles still point at.
|
|
238
|
+
const taken = new Set([...all.map((t) => t.key), ...this.archivedKeys()]);
|
|
239
|
+
const key = mintKey(taken);
|
|
240
|
+
// Dump by default: new tickets land in backlog, the no-quality-bar list;
|
|
241
|
+
// `todo` (the ready list) is an explicit choice — usually `task promote`.
|
|
242
|
+
const status = input.status ?? "backlog";
|
|
198
243
|
for (const target of [...(input.blocks ?? []), ...(input.blockedBy ?? [])]) {
|
|
199
|
-
if (!all.some((t) => t.
|
|
244
|
+
if (!all.some((t) => t.key === target)) {
|
|
200
245
|
throw new Error(`no such task: ${this.displayId(target)}`);
|
|
201
246
|
}
|
|
202
247
|
}
|
|
248
|
+
if (input.goal)
|
|
249
|
+
this.assertGoalAssignable(input.goal);
|
|
203
250
|
// New tasks land at the bottom of their column.
|
|
204
251
|
const column = all.filter((t) => t.doc.status === status);
|
|
205
252
|
const bottom = column.length ? Math.max(...column.map((t) => t.doc.position)) : 0;
|
|
206
253
|
const timestamp = now();
|
|
207
254
|
const ticket = {
|
|
208
|
-
|
|
255
|
+
key,
|
|
209
256
|
doc: {
|
|
210
257
|
title: input.title,
|
|
211
258
|
description: input.description ?? "",
|
|
212
259
|
status,
|
|
213
260
|
tags: input.tags ?? [],
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
blockedBy: [...new Set(input.blockedBy ?? [])].sort((a, b) => a - b),
|
|
261
|
+
goal: input.goal ?? null,
|
|
262
|
+
blockedBy: sortKeys([...new Set(input.blockedBy ?? [])]),
|
|
217
263
|
prs: input.prs ?? [],
|
|
218
264
|
position: bottom + POSITION_GAP,
|
|
219
265
|
createdAt: timestamp,
|
|
@@ -222,25 +268,25 @@ export class FileStore {
|
|
|
222
268
|
};
|
|
223
269
|
this.writeTicket(ticket);
|
|
224
270
|
if (input.blocks !== undefined) {
|
|
225
|
-
this.reconcileBlocks(
|
|
271
|
+
this.reconcileBlocks(key, [...all, ticket], input.blocks, timestamp);
|
|
226
272
|
}
|
|
227
|
-
return this.get(
|
|
273
|
+
return this.get(key);
|
|
228
274
|
}
|
|
229
|
-
update(
|
|
275
|
+
update(key, patch) {
|
|
230
276
|
const all = this.readAll();
|
|
231
|
-
const ticket = all.find((t) => t.
|
|
277
|
+
const ticket = all.find((t) => t.key === key);
|
|
232
278
|
if (!ticket) {
|
|
233
|
-
this.assertNotArchived(
|
|
234
|
-
throw new Error(`no such task: ${this.displayId(
|
|
279
|
+
this.assertNotArchived(key);
|
|
280
|
+
throw new Error(`no such task: ${this.displayId(key)}`);
|
|
235
281
|
}
|
|
236
282
|
// Validate link targets before any file is written, so a bad target
|
|
237
283
|
// rejects the whole patch — the transactional behavior the SQLite store
|
|
238
284
|
// got for free.
|
|
239
285
|
for (const target of [...(patch.blocks ?? []), ...(patch.blockedBy ?? [])]) {
|
|
240
|
-
if (target ===
|
|
241
|
-
throw new Error(`a task can't block itself: ${this.displayId(
|
|
286
|
+
if (target === key) {
|
|
287
|
+
throw new Error(`a task can't block itself: ${this.displayId(key)}`);
|
|
242
288
|
}
|
|
243
|
-
if (!all.some((t) => t.
|
|
289
|
+
if (!all.some((t) => t.key === target)) {
|
|
244
290
|
this.assertNotArchived(target);
|
|
245
291
|
throw new Error(`no such task: ${this.displayId(target)}`);
|
|
246
292
|
}
|
|
@@ -248,14 +294,14 @@ export class FileStore {
|
|
|
248
294
|
const timestamp = now();
|
|
249
295
|
let linksChanged = false;
|
|
250
296
|
if (patch.blocks !== undefined) {
|
|
251
|
-
linksChanged = this.reconcileBlocks(
|
|
297
|
+
linksChanged = this.reconcileBlocks(key, all, patch.blocks, timestamp);
|
|
252
298
|
}
|
|
253
299
|
if (patch.blockedBy !== undefined) {
|
|
254
|
-
const wanted = [...new Set(patch.blockedBy)]
|
|
300
|
+
const wanted = sortKeys([...new Set(patch.blockedBy)]);
|
|
255
301
|
const current = ticket.doc.blockedBy;
|
|
256
302
|
const touched = [
|
|
257
|
-
...current.filter((
|
|
258
|
-
...wanted.filter((
|
|
303
|
+
...current.filter((k) => !wanted.includes(k)),
|
|
304
|
+
...wanted.filter((k) => !current.includes(k)),
|
|
259
305
|
];
|
|
260
306
|
if (touched.length > 0) {
|
|
261
307
|
linksChanged = true;
|
|
@@ -263,8 +309,8 @@ export class FileStore {
|
|
|
263
309
|
// The other end of every added or removed link gets its `updated`
|
|
264
310
|
// bumped too — its derived `blocks` view just changed. A dangling
|
|
265
311
|
// reference (hand-edit pointing at a deleted ticket) has no other end.
|
|
266
|
-
for (const
|
|
267
|
-
const other = all.find((t) => t.
|
|
312
|
+
for (const k of touched) {
|
|
313
|
+
const other = all.find((t) => t.key === k);
|
|
268
314
|
if (!other)
|
|
269
315
|
continue;
|
|
270
316
|
other.doc.updatedAt = timestamp;
|
|
@@ -274,8 +320,8 @@ export class FileStore {
|
|
|
274
320
|
}
|
|
275
321
|
const doc = ticket.doc;
|
|
276
322
|
let fieldsChanged = false;
|
|
277
|
-
const set = (
|
|
278
|
-
doc[
|
|
323
|
+
const set = (field, value) => {
|
|
324
|
+
doc[field] = value;
|
|
279
325
|
fieldsChanged = true;
|
|
280
326
|
};
|
|
281
327
|
if (patch.title !== undefined)
|
|
@@ -284,19 +330,32 @@ export class FileStore {
|
|
|
284
330
|
set("description", patch.description);
|
|
285
331
|
if (patch.tags !== undefined)
|
|
286
332
|
set("tags", patch.tags);
|
|
287
|
-
if (patch.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
set("
|
|
333
|
+
if (patch.goal !== undefined) {
|
|
334
|
+
if (patch.goal)
|
|
335
|
+
this.assertGoalAssignable(patch.goal);
|
|
336
|
+
set("goal", patch.goal);
|
|
337
|
+
}
|
|
291
338
|
if (patch.prs !== undefined)
|
|
292
339
|
set("prs", patch.prs);
|
|
293
340
|
if (patch.status !== undefined) {
|
|
341
|
+
// Done is gated on nothing owed: open asks refuse the move. Canceled
|
|
342
|
+
// stays open — a dead ticket's asks die with it and leave the inbox on
|
|
343
|
+
// their own.
|
|
344
|
+
if (patch.status === "done" && doc.status !== "done") {
|
|
345
|
+
const open = this.asks(key).filter((a) => !a.resolvedAt);
|
|
346
|
+
if (open.length > 0) {
|
|
347
|
+
const id = this.displayId(key);
|
|
348
|
+
throw new Error(`${id} still has ${open.length} open ask${open.length === 1 ? "" : "s"} — done means nobody owes anything. ` +
|
|
349
|
+
`Resolve them (\`task resolve ${id} <n>\`) or move what's still owed to a new ticket:\n` +
|
|
350
|
+
open.map((a) => ` [${a.ordinal}] ${a.text.split("\n")[0]}`).join("\n"));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
294
353
|
const previous = doc.status;
|
|
295
354
|
set("status", patch.status);
|
|
296
355
|
if (patch.position === undefined && patch.status !== previous) {
|
|
297
356
|
// Moved columns without an explicit slot → land on top, where the
|
|
298
357
|
// freshest movement is visible (Linear's behavior).
|
|
299
|
-
const column = all.filter((t) => t.
|
|
358
|
+
const column = all.filter((t) => t.key !== key && t.doc.status === patch.status);
|
|
300
359
|
const top = column.length ? Math.min(...column.map((t) => t.doc.position)) : 0;
|
|
301
360
|
set("position", top - POSITION_GAP);
|
|
302
361
|
}
|
|
@@ -308,35 +367,35 @@ export class FileStore {
|
|
|
308
367
|
if (fieldsChanged || linksChanged)
|
|
309
368
|
doc.updatedAt = timestamp;
|
|
310
369
|
this.writeTicket(ticket);
|
|
311
|
-
return this.get(
|
|
370
|
+
return this.get(key);
|
|
312
371
|
}
|
|
313
372
|
/**
|
|
314
373
|
* Make every other ticket's `blocked_by` agree with "this task blocks
|
|
315
374
|
* exactly `targets`". Returns whether anything changed; every touched ticket
|
|
316
375
|
* (and, via the caller, this one) gets its `updated` bumped.
|
|
317
376
|
*/
|
|
318
|
-
reconcileBlocks(
|
|
377
|
+
reconcileBlocks(key, all, targets, timestamp) {
|
|
319
378
|
const wanted = new Set(targets);
|
|
320
|
-
if (wanted.has(
|
|
321
|
-
throw new Error(`a task can't block itself: ${this.displayId(
|
|
379
|
+
if (wanted.has(key)) {
|
|
380
|
+
throw new Error(`a task can't block itself: ${this.displayId(key)}`);
|
|
322
381
|
}
|
|
323
382
|
let changed = false;
|
|
324
383
|
for (const other of all) {
|
|
325
|
-
if (other.
|
|
384
|
+
if (other.key === key)
|
|
326
385
|
continue;
|
|
327
|
-
const has = other.doc.blockedBy.includes(
|
|
328
|
-
const should = wanted.has(other.
|
|
386
|
+
const has = other.doc.blockedBy.includes(key);
|
|
387
|
+
const should = wanted.has(other.key);
|
|
329
388
|
if (has === should)
|
|
330
389
|
continue;
|
|
331
390
|
other.doc.blockedBy = should
|
|
332
|
-
? [...other.doc.blockedBy,
|
|
333
|
-
: other.doc.blockedBy.filter((
|
|
391
|
+
? sortKeys([...other.doc.blockedBy, key])
|
|
392
|
+
: other.doc.blockedBy.filter((k) => k !== key);
|
|
334
393
|
other.doc.updatedAt = timestamp;
|
|
335
394
|
this.writeTicket(other);
|
|
336
395
|
changed = true;
|
|
337
396
|
}
|
|
338
397
|
if (changed) {
|
|
339
|
-
const self = all.find((t) => t.
|
|
398
|
+
const self = all.find((t) => t.key === key);
|
|
340
399
|
if (self) {
|
|
341
400
|
self.doc.updatedAt = timestamp;
|
|
342
401
|
this.writeTicket(self);
|
|
@@ -345,37 +404,37 @@ export class FileStore {
|
|
|
345
404
|
return changed;
|
|
346
405
|
}
|
|
347
406
|
/** `task link A --blocks B` and friends — additive, unlike the patch form. */
|
|
348
|
-
link(
|
|
349
|
-
const task = this.get(
|
|
407
|
+
link(key, relation, target) {
|
|
408
|
+
const task = this.get(key);
|
|
350
409
|
if (!task)
|
|
351
|
-
throw new Error(`no such task: ${this.displayId(
|
|
410
|
+
throw new Error(`no such task: ${this.displayId(key)}`);
|
|
352
411
|
const current = relation === "blocks" ? task.blocks : task.blockedBy;
|
|
353
412
|
const patch = relation === "blocks"
|
|
354
413
|
? { blocks: [...current, target] }
|
|
355
414
|
: { blockedBy: [...current, target] };
|
|
356
|
-
return this.update(
|
|
415
|
+
return this.update(key, patch);
|
|
357
416
|
}
|
|
358
|
-
unlink(
|
|
359
|
-
const task = this.get(
|
|
417
|
+
unlink(key, relation, target) {
|
|
418
|
+
const task = this.get(key);
|
|
360
419
|
if (!task)
|
|
361
|
-
throw new Error(`no such task: ${this.displayId(
|
|
420
|
+
throw new Error(`no such task: ${this.displayId(key)}`);
|
|
362
421
|
const current = relation === "blocks" ? task.blocks : task.blockedBy;
|
|
363
|
-
const kept = current.filter((
|
|
422
|
+
const kept = current.filter((k) => k !== target);
|
|
364
423
|
const patch = relation === "blocks" ? { blocks: kept } : { blockedBy: kept };
|
|
365
|
-
return this.update(
|
|
424
|
+
return this.update(key, patch);
|
|
366
425
|
}
|
|
367
|
-
delete(
|
|
368
|
-
if (!this.readOne(
|
|
369
|
-
this.assertNotArchived(
|
|
370
|
-
throw new Error(`no such task: ${this.displayId(
|
|
426
|
+
delete(key) {
|
|
427
|
+
if (!this.readOne(key)) {
|
|
428
|
+
this.assertNotArchived(key);
|
|
429
|
+
throw new Error(`no such task: ${this.displayId(key)}`);
|
|
371
430
|
}
|
|
372
|
-
rmSync(join(this.ticketsDir,
|
|
431
|
+
rmSync(join(this.ticketsDir, key), { recursive: true });
|
|
373
432
|
// Links pointing at the deleted ticket go with it — the same cascade the
|
|
374
433
|
// relation table had, so quiet on the other tickets' `updated`.
|
|
375
434
|
for (const other of this.readAll()) {
|
|
376
|
-
if (!other.doc.blockedBy.includes(
|
|
435
|
+
if (!other.doc.blockedBy.includes(key))
|
|
377
436
|
continue;
|
|
378
|
-
other.doc.blockedBy = other.doc.blockedBy.filter((
|
|
437
|
+
other.doc.blockedBy = other.doc.blockedBy.filter((k) => k !== key);
|
|
379
438
|
this.writeTicket(other);
|
|
380
439
|
}
|
|
381
440
|
}
|
|
@@ -384,36 +443,192 @@ export class FileStore {
|
|
|
384
443
|
* Move a finished ticket's whole directory to `.task/archive/` — one rename,
|
|
385
444
|
* which git records as a move, so history follows the ticket. Archived
|
|
386
445
|
* tickets leave every hot path (`list`, the board, link derivation) but stay
|
|
387
|
-
* readable via `get`/`comments` and keep their
|
|
446
|
+
* readable via `get`/`comments` and keep their key reserved forever.
|
|
388
447
|
* Links other tickets hold on this one are left in place: they're
|
|
389
448
|
* dangling-tolerant everywhere, and unarchiving puts them back in force.
|
|
390
449
|
*/
|
|
391
|
-
archive(
|
|
392
|
-
const ticket = this.readOne(
|
|
450
|
+
archive(key) {
|
|
451
|
+
const ticket = this.readOne(key);
|
|
393
452
|
if (!ticket) {
|
|
394
|
-
if (this.isArchived(
|
|
395
|
-
throw new Error(`${this.displayId(
|
|
453
|
+
if (this.isArchived(key)) {
|
|
454
|
+
throw new Error(`${this.displayId(key)} is already archived`);
|
|
396
455
|
}
|
|
397
|
-
throw new Error(`no such task: ${this.displayId(
|
|
456
|
+
throw new Error(`no such task: ${this.displayId(key)}`);
|
|
398
457
|
}
|
|
399
458
|
if (ticket.doc.status !== "done" && ticket.doc.status !== "canceled") {
|
|
400
|
-
throw new Error(`${this.displayId(
|
|
459
|
+
throw new Error(`${this.displayId(key)} is ${ticket.doc.status} — only done or canceled tickets can be archived`);
|
|
401
460
|
}
|
|
402
461
|
mkdirSync(this.archiveDir, { recursive: true });
|
|
403
|
-
renameSync(join(this.ticketsDir,
|
|
404
|
-
return this.get(
|
|
405
|
-
}
|
|
406
|
-
unarchive(
|
|
407
|
-
if (!this.isArchived(
|
|
408
|
-
throw new Error(this.readOne(
|
|
409
|
-
? `${this.displayId(
|
|
410
|
-
: `no such task: ${this.displayId(
|
|
462
|
+
renameSync(join(this.ticketsDir, key), join(this.archiveDir, key));
|
|
463
|
+
return this.get(key);
|
|
464
|
+
}
|
|
465
|
+
unarchive(key) {
|
|
466
|
+
if (!this.isArchived(key)) {
|
|
467
|
+
throw new Error(this.readOne(key)
|
|
468
|
+
? `${this.displayId(key)} isn't archived`
|
|
469
|
+
: `no such task: ${this.displayId(key)}`);
|
|
411
470
|
}
|
|
412
471
|
// A checkout where everything is archived has no tickets/ at all — git
|
|
413
472
|
// doesn't keep empty directories.
|
|
414
473
|
mkdirSync(this.ticketsDir, { recursive: true });
|
|
415
|
-
renameSync(join(this.archiveDir,
|
|
416
|
-
return this.get(
|
|
474
|
+
renameSync(join(this.archiveDir, key), join(this.ticketsDir, key));
|
|
475
|
+
return this.get(key);
|
|
476
|
+
}
|
|
477
|
+
// ── Goals ──────────────────────────────────────────────────────────────────
|
|
478
|
+
goalPath(slug, archived) {
|
|
479
|
+
return join(archived ? this.goalsArchiveDir : this.goalsDir, `${slug}.md`);
|
|
480
|
+
}
|
|
481
|
+
readGoalDir(archived) {
|
|
482
|
+
const dir = archived ? this.goalsArchiveDir : this.goalsDir;
|
|
483
|
+
if (!existsSync(dir))
|
|
484
|
+
return [];
|
|
485
|
+
const goals = [];
|
|
486
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
487
|
+
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name.startsWith("."))
|
|
488
|
+
continue;
|
|
489
|
+
const slug = entry.name.slice(0, -3);
|
|
490
|
+
const path = join(dir, entry.name);
|
|
491
|
+
const doc = parseGoal(readFileSync(path, "utf8"), path);
|
|
492
|
+
goals.push(this.toGoal(slug, doc, archived));
|
|
493
|
+
}
|
|
494
|
+
goals.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
495
|
+
return goals;
|
|
496
|
+
}
|
|
497
|
+
toGoal(slug, doc, archived) {
|
|
498
|
+
return {
|
|
499
|
+
slug,
|
|
500
|
+
title: doc.title,
|
|
501
|
+
description: doc.description,
|
|
502
|
+
createdAt: doc.createdAt,
|
|
503
|
+
updatedAt: doc.updatedAt,
|
|
504
|
+
// Absent on live goals, so existing payloads carry no new field.
|
|
505
|
+
...(archived ? { archived: true } : {}),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
goals(archived = false) {
|
|
509
|
+
return this.readGoalDir(archived);
|
|
510
|
+
}
|
|
511
|
+
getGoal(slug) {
|
|
512
|
+
for (const archived of [false, true]) {
|
|
513
|
+
const path = this.goalPath(slug, archived);
|
|
514
|
+
if (!existsSync(path))
|
|
515
|
+
continue;
|
|
516
|
+
return this.toGoal(slug, parseGoal(readFileSync(path, "utf8"), path), archived);
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* The gate every ticket write goes through: a `goal:` may only reference a
|
|
522
|
+
* *live* goal. Naming an archived one is refused too — assigning new work to
|
|
523
|
+
* a goal that's been shelved is the same mistake as editing an archived
|
|
524
|
+
* ticket, and gets the same answer.
|
|
525
|
+
*/
|
|
526
|
+
assertGoalAssignable(slug) {
|
|
527
|
+
if (existsSync(this.goalPath(slug, false)))
|
|
528
|
+
return;
|
|
529
|
+
if (existsSync(this.goalPath(slug, true))) {
|
|
530
|
+
throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`);
|
|
531
|
+
}
|
|
532
|
+
throw new Error(`no such goal: "${slug}" — create it with \`task goal add\``);
|
|
533
|
+
}
|
|
534
|
+
writeGoal(slug, doc, archived) {
|
|
535
|
+
mkdirSync(archived ? this.goalsArchiveDir : this.goalsDir, { recursive: true });
|
|
536
|
+
writeAtomic(this.goalPath(slug, archived), serializeGoal(doc));
|
|
537
|
+
}
|
|
538
|
+
createGoal(input) {
|
|
539
|
+
const title = input.title.trim();
|
|
540
|
+
if (!title)
|
|
541
|
+
throw new Error("a goal needs a title");
|
|
542
|
+
const slug = input.slug ?? slugifyGoal(title);
|
|
543
|
+
if (!isGoalSlug(slug)) {
|
|
544
|
+
throw new Error(`invalid goal slug: "${slug}" — lowercase letters, digits and dashes ("archive" is reserved)`);
|
|
545
|
+
}
|
|
546
|
+
if (this.getGoal(slug))
|
|
547
|
+
throw new Error(`goal "${slug}" already exists`);
|
|
548
|
+
const timestamp = now();
|
|
549
|
+
const doc = {
|
|
550
|
+
title,
|
|
551
|
+
description: input.description ?? "",
|
|
552
|
+
createdAt: timestamp,
|
|
553
|
+
updatedAt: timestamp,
|
|
554
|
+
};
|
|
555
|
+
this.writeGoal(slug, doc, false);
|
|
556
|
+
return this.toGoal(slug, doc, false);
|
|
557
|
+
}
|
|
558
|
+
updateGoal(slug, patch) {
|
|
559
|
+
const path = this.goalPath(slug, false);
|
|
560
|
+
if (!existsSync(path)) {
|
|
561
|
+
if (existsSync(this.goalPath(slug, true))) {
|
|
562
|
+
throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`);
|
|
563
|
+
}
|
|
564
|
+
throw new Error(`no such goal: "${slug}"`);
|
|
565
|
+
}
|
|
566
|
+
const doc = parseGoal(readFileSync(path, "utf8"), path);
|
|
567
|
+
if (patch.title !== undefined) {
|
|
568
|
+
const title = patch.title.trim();
|
|
569
|
+
if (!title)
|
|
570
|
+
throw new Error("a goal needs a title");
|
|
571
|
+
doc.title = title;
|
|
572
|
+
}
|
|
573
|
+
if (patch.description !== undefined)
|
|
574
|
+
doc.description = patch.description;
|
|
575
|
+
doc.updatedAt = now();
|
|
576
|
+
this.writeGoal(slug, doc, false);
|
|
577
|
+
return this.toGoal(slug, doc, false);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* A goal leaves the board only after its work has: archiving is refused
|
|
581
|
+
* while any open task still points here. Done/canceled stragglers are fine —
|
|
582
|
+
* their references stay readable from the archive either way.
|
|
583
|
+
*/
|
|
584
|
+
archiveGoal(slug) {
|
|
585
|
+
const path = this.goalPath(slug, false);
|
|
586
|
+
if (!existsSync(path)) {
|
|
587
|
+
if (existsSync(this.goalPath(slug, true))) {
|
|
588
|
+
throw new Error(`goal "${slug}" is already archived`);
|
|
589
|
+
}
|
|
590
|
+
throw new Error(`no such goal: "${slug}"`);
|
|
591
|
+
}
|
|
592
|
+
const open = this.readAll().filter((t) => t.doc.goal === slug && t.doc.status !== "done" && t.doc.status !== "canceled");
|
|
593
|
+
if (open.length > 0) {
|
|
594
|
+
const ids = open.map((t) => this.displayId(t.key)).join(", ");
|
|
595
|
+
throw new Error(`goal "${slug}" still has open tasks (${ids}) — finish or reassign them first`);
|
|
596
|
+
}
|
|
597
|
+
mkdirSync(this.goalsArchiveDir, { recursive: true });
|
|
598
|
+
renameSync(path, this.goalPath(slug, true));
|
|
599
|
+
return this.getGoal(slug);
|
|
600
|
+
}
|
|
601
|
+
unarchiveGoal(slug) {
|
|
602
|
+
const path = this.goalPath(slug, true);
|
|
603
|
+
if (!existsSync(path)) {
|
|
604
|
+
throw new Error(existsSync(this.goalPath(slug, false))
|
|
605
|
+
? `goal "${slug}" isn't archived`
|
|
606
|
+
: `no such goal: "${slug}"`);
|
|
607
|
+
}
|
|
608
|
+
mkdirSync(this.goalsDir, { recursive: true });
|
|
609
|
+
renameSync(path, this.goalPath(slug, false));
|
|
610
|
+
return this.getGoal(slug);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Deleting is for goals that never happened. Any live reference — whatever
|
|
614
|
+
* its status — blocks it: pruning organization off finished tickets is a
|
|
615
|
+
* human call, so the human makes it by clearing the refs first. Archived
|
|
616
|
+
* tickets' references are left to dangle; every reader tolerates that.
|
|
617
|
+
*/
|
|
618
|
+
deleteGoal(slug) {
|
|
619
|
+
const path = this.goalPath(slug, false);
|
|
620
|
+
if (!existsSync(path)) {
|
|
621
|
+
if (existsSync(this.goalPath(slug, true))) {
|
|
622
|
+
throw new Error(`goal "${slug}" is archived — run \`task goal unarchive ${slug}\` first`);
|
|
623
|
+
}
|
|
624
|
+
throw new Error(`no such goal: "${slug}"`);
|
|
625
|
+
}
|
|
626
|
+
const referencing = this.readAll().filter((t) => t.doc.goal === slug);
|
|
627
|
+
if (referencing.length > 0) {
|
|
628
|
+
const ids = referencing.map((t) => this.displayId(t.key)).join(", ");
|
|
629
|
+
throw new Error(`goal "${slug}" is referenced by ${ids} — clear those with \`task update <id> --goal ""\` first`);
|
|
630
|
+
}
|
|
631
|
+
rmSync(path);
|
|
417
632
|
}
|
|
418
633
|
// ── Comments ───────────────────────────────────────────────────────────────
|
|
419
634
|
/**
|
|
@@ -421,8 +636,8 @@ export class FileStore {
|
|
|
421
636
|
* the same ticket produce two files and merge without a conflict — which is
|
|
422
637
|
* why they aren't lines inside `ticket.md`.
|
|
423
638
|
*/
|
|
424
|
-
comments(
|
|
425
|
-
const dir = this.commentsPath(
|
|
639
|
+
comments(key) {
|
|
640
|
+
const dir = this.commentsPath(key);
|
|
426
641
|
if (!existsSync(dir))
|
|
427
642
|
return [];
|
|
428
643
|
const comments = [];
|
|
@@ -433,7 +648,7 @@ export class FileStore {
|
|
|
433
648
|
const doc = parseComment(readFileSync(path, "utf8"), path);
|
|
434
649
|
comments.push({
|
|
435
650
|
id: entry.slice(0, -3),
|
|
436
|
-
taskId: this.displayId(
|
|
651
|
+
taskId: this.displayId(key),
|
|
437
652
|
author: doc.author,
|
|
438
653
|
body: doc.body,
|
|
439
654
|
createdAt: doc.createdAt,
|
|
@@ -442,68 +657,205 @@ export class FileStore {
|
|
|
442
657
|
comments.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
443
658
|
return comments;
|
|
444
659
|
}
|
|
445
|
-
addComment(
|
|
446
|
-
if (!this.readOne(
|
|
447
|
-
this.assertNotArchived(
|
|
448
|
-
throw new Error(`no such task: ${this.displayId(
|
|
660
|
+
addComment(key, body, author = "") {
|
|
661
|
+
if (!this.readOne(key)) {
|
|
662
|
+
this.assertNotArchived(key);
|
|
663
|
+
throw new Error(`no such task: ${this.displayId(key)}`);
|
|
449
664
|
}
|
|
450
665
|
const createdAt = now();
|
|
451
|
-
const dir = this.commentsPath(
|
|
666
|
+
const dir = this.commentsPath(key);
|
|
452
667
|
mkdirSync(dir, { recursive: true });
|
|
453
668
|
const stem = commentStem(createdAt, author);
|
|
454
669
|
let id = stem;
|
|
455
670
|
for (let n = 2; existsSync(join(dir, `${id}.md`)); n++)
|
|
456
671
|
id = `${stem}-${n}`;
|
|
457
672
|
writeAtomic(join(dir, `${id}.md`), serializeComment({ author, createdAt, body }));
|
|
458
|
-
return { id, taskId: this.displayId(
|
|
673
|
+
return { id, taskId: this.displayId(key), author, body, createdAt };
|
|
459
674
|
}
|
|
460
|
-
deleteComment(
|
|
675
|
+
deleteComment(key, commentId) {
|
|
461
676
|
// Resolve the id against the directory listing rather than building a path
|
|
462
677
|
// from it — the id arrived over HTTP and must not be able to point
|
|
463
678
|
// anywhere but at an actual comment of this task.
|
|
464
|
-
const existing = this.comments(
|
|
679
|
+
const existing = this.comments(key).find((c) => c.id === commentId);
|
|
465
680
|
if (!existing) {
|
|
466
|
-
throw new Error(`no such comment on ${this.displayId(
|
|
681
|
+
throw new Error(`no such comment on ${this.displayId(key)}: ${commentId}`);
|
|
467
682
|
}
|
|
468
|
-
rmSync(join(this.commentsPath(
|
|
683
|
+
rmSync(join(this.commentsPath(key), `${commentId}.md`));
|
|
469
684
|
return existing;
|
|
470
685
|
}
|
|
471
686
|
commentCounts() {
|
|
472
687
|
const counts = new Map();
|
|
473
688
|
for (const ticket of this.readAll()) {
|
|
474
|
-
const dir = this.commentsPath(ticket.
|
|
689
|
+
const dir = this.commentsPath(ticket.key);
|
|
475
690
|
if (!existsSync(dir))
|
|
476
691
|
continue;
|
|
477
692
|
const count = readdirSync(dir).filter((f) => f.endsWith(".md") && !f.startsWith(".")).length;
|
|
478
693
|
if (count > 0)
|
|
479
|
-
counts.set(ticket.
|
|
694
|
+
counts.set(ticket.key, count);
|
|
480
695
|
}
|
|
481
696
|
return counts;
|
|
482
697
|
}
|
|
698
|
+
// ── Asks ───────────────────────────────────────────────────────────────────
|
|
699
|
+
/** Like comments, an archived ticket keeps its asks readable in place. */
|
|
700
|
+
asksPath(key) {
|
|
701
|
+
const home = existsSync(join(this.archiveDir, key)) ? this.archiveDir : this.ticketsDir;
|
|
702
|
+
return join(home, key, ASKS_DIR);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Every ask on a ticket, open and resolved, in creation order — the order
|
|
706
|
+
* that fixes each ask's ordinal, the short handle `task resolve` takes.
|
|
707
|
+
* Ordinals are positions in this listing rather than stored, so two branches
|
|
708
|
+
* adding asks concurrently can't mint a duplicate; they shift only if an ask
|
|
709
|
+
* is deleted outright, which is why resolving (not deleting) is the normal
|
|
710
|
+
* way an ask ends.
|
|
711
|
+
*/
|
|
712
|
+
asks(key) {
|
|
713
|
+
const dir = this.asksPath(key);
|
|
714
|
+
if (!existsSync(dir))
|
|
715
|
+
return [];
|
|
716
|
+
const asks = [];
|
|
717
|
+
for (const entry of readdirSync(dir)) {
|
|
718
|
+
if (!entry.endsWith(".md") || entry.startsWith("."))
|
|
719
|
+
continue;
|
|
720
|
+
const path = join(dir, entry);
|
|
721
|
+
const doc = parseAsk(readFileSync(path, "utf8"), path);
|
|
722
|
+
asks.push({
|
|
723
|
+
id: entry.slice(0, -3),
|
|
724
|
+
taskId: this.displayId(key),
|
|
725
|
+
text: doc.body,
|
|
726
|
+
author: doc.author,
|
|
727
|
+
createdAt: doc.createdAt,
|
|
728
|
+
resolvedAt: doc.resolvedAt,
|
|
729
|
+
resolvedBy: doc.resolvedBy,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
asks.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
733
|
+
return asks.map((ask, index) => ({ ...ask, ordinal: index + 1 }));
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* File an ask — allowed only while the ticket is open. A done ticket can't
|
|
737
|
+
* quietly start owing things again (that's the NIC-17 failure this feature
|
|
738
|
+
* exists to end): reopen it, or file the ask as a new ticket.
|
|
739
|
+
*/
|
|
740
|
+
addAsk(key, text, author = "") {
|
|
741
|
+
const ticket = this.readOne(key);
|
|
742
|
+
if (!ticket) {
|
|
743
|
+
this.assertNotArchived(key);
|
|
744
|
+
throw new Error(`no such task: ${this.displayId(key)}`);
|
|
745
|
+
}
|
|
746
|
+
if (ticket.doc.status === "done" || ticket.doc.status === "canceled") {
|
|
747
|
+
throw new Error(`${this.displayId(key)} is ${ticket.doc.status} — asks belong on open tickets. Reopen it, or file the ask as a new ticket`);
|
|
748
|
+
}
|
|
749
|
+
const createdAt = now();
|
|
750
|
+
const dir = this.asksPath(key);
|
|
751
|
+
mkdirSync(dir, { recursive: true });
|
|
752
|
+
const stem = commentStem(createdAt, author);
|
|
753
|
+
let id = stem;
|
|
754
|
+
for (let n = 2; existsSync(join(dir, `${id}.md`)); n++)
|
|
755
|
+
id = `${stem}-${n}`;
|
|
756
|
+
const doc = { author, createdAt, resolvedAt: null, resolvedBy: null, body: text };
|
|
757
|
+
writeAtomic(join(dir, `${id}.md`), serializeAsk(doc));
|
|
758
|
+
return this.asks(key).find((a) => a.id === id);
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Resolve an ask against the current listing — `ref` is an ordinal ("2") or
|
|
762
|
+
* the ask's id. Resolution edits the ask's own file, so the person's answer
|
|
763
|
+
* and the worker's question live in one place whatever branch each came from.
|
|
764
|
+
*/
|
|
765
|
+
resolveAsk(key, ref, resolvedBy = "") {
|
|
766
|
+
return this.setAskResolution(key, ref, now(), resolvedBy);
|
|
767
|
+
}
|
|
768
|
+
/** Reopen a resolved ask — same refusals as adding one. */
|
|
769
|
+
reopenAsk(key, ref) {
|
|
770
|
+
return this.setAskResolution(key, ref, null, null);
|
|
771
|
+
}
|
|
772
|
+
setAskResolution(key, ref, resolvedAt, resolvedBy) {
|
|
773
|
+
this.assertNotArchived(key);
|
|
774
|
+
const ask = this.findAsk(key, ref);
|
|
775
|
+
const ticket = this.readOne(key);
|
|
776
|
+
if (resolvedAt === null &&
|
|
777
|
+
(ticket?.doc.status === "done" || ticket?.doc.status === "canceled")) {
|
|
778
|
+
throw new Error(`${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`);
|
|
779
|
+
}
|
|
780
|
+
const doc = {
|
|
781
|
+
author: ask.author,
|
|
782
|
+
createdAt: ask.createdAt,
|
|
783
|
+
resolvedAt,
|
|
784
|
+
resolvedBy: resolvedAt === null ? null : resolvedBy,
|
|
785
|
+
body: ask.text,
|
|
786
|
+
};
|
|
787
|
+
writeAtomic(join(this.asksPath(key), `${ask.id}.md`), serializeAsk(doc));
|
|
788
|
+
return this.asks(key).find((a) => a.id === ask.id);
|
|
789
|
+
}
|
|
790
|
+
deleteAsk(key, ref) {
|
|
791
|
+
this.assertNotArchived(key);
|
|
792
|
+
const ask = this.findAsk(key, ref);
|
|
793
|
+
rmSync(join(this.asksPath(key), `${ask.id}.md`));
|
|
794
|
+
return ask;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Resolve an ordinal or id against the directory listing rather than
|
|
798
|
+
* building a path from it — same rule as comment ids, the ref arrived over
|
|
799
|
+
* HTTP or a shell and must not point anywhere but at this ticket's asks.
|
|
800
|
+
*/
|
|
801
|
+
findAsk(key, ref) {
|
|
802
|
+
const asks = this.asks(key);
|
|
803
|
+
const trimmed = ref.trim();
|
|
804
|
+
const byOrdinal = /^\d+$/.test(trimmed)
|
|
805
|
+
? asks.find((a) => a.ordinal === Number(trimmed))
|
|
806
|
+
: undefined;
|
|
807
|
+
const ask = byOrdinal ?? asks.find((a) => a.id === trimmed);
|
|
808
|
+
if (!ask) {
|
|
809
|
+
const id = this.displayId(key);
|
|
810
|
+
throw new Error(asks.length === 0
|
|
811
|
+
? `${id} has no asks`
|
|
812
|
+
: `no such ask on ${id}: ${ref} — ordinals run 1-${asks.length} (\`task show ${id}\` lists them)`);
|
|
813
|
+
}
|
|
814
|
+
return ask;
|
|
815
|
+
}
|
|
483
816
|
}
|
|
484
|
-
|
|
817
|
+
/** The list() tiebreak, restated over Task — creation time, then key. */
|
|
818
|
+
function byCreationTask(a, b) {
|
|
819
|
+
return a.createdAt.localeCompare(b.createdAt) || a.key.localeCompare(b.key);
|
|
820
|
+
}
|
|
821
|
+
// ── Opening and initializing ─────────────────────────────────────────────────
|
|
822
|
+
/** The database a pre-0.6 board committed. Never read anymore, only ignored. */
|
|
823
|
+
const LEGACY_DB_FILE = "tasks.db";
|
|
485
824
|
/** What `.task/.gitignore` says on a text-canonical board. */
|
|
486
825
|
const GITIGNORE = `# Board state is the text files in tickets/ — the database (if one is still
|
|
487
|
-
# around from
|
|
488
|
-
${
|
|
826
|
+
# around from a pre-0.6 board) is legacy and stays out of git.
|
|
827
|
+
${LEGACY_DB_FILE}
|
|
489
828
|
*.db-journal
|
|
490
829
|
*.db-wal
|
|
491
830
|
*.db-shm
|
|
492
831
|
*.tmp
|
|
493
832
|
`;
|
|
494
833
|
/**
|
|
495
|
-
* The board in `root
|
|
496
|
-
*
|
|
497
|
-
*
|
|
834
|
+
* The board in `root`. Text files are the only backend; a board whose state is
|
|
835
|
+
* still a pre-0.6 committed SQLite database is refused loudly rather than read
|
|
836
|
+
* as empty — silently starting a parallel text board next to a database full
|
|
837
|
+
* of tickets is the one failure mode worse than an error.
|
|
498
838
|
*/
|
|
499
839
|
export function openBoard(root) {
|
|
500
840
|
const taskDir = join(root, TASK_DIR);
|
|
501
|
-
if (existsSync(join(taskDir, TICKETS_DIR)))
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
841
|
+
if (!existsSync(join(taskDir, TICKETS_DIR))) {
|
|
842
|
+
const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8"));
|
|
843
|
+
if ((config.version ?? 1) < 2 && existsSync(join(taskDir, LEGACY_DB_FILE))) {
|
|
844
|
+
throw new Error("this board still stores its state in a pre-0.6 SQLite database — run `npx @nickmeriano/task@0.6 migrate` once, then retry");
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
else {
|
|
848
|
+
// A board whose tickets are ALL numeric directories predates the TAS-42
|
|
849
|
+
// id cutover. Reading it as empty (the reader skips numeric dirs) would
|
|
850
|
+
// silently strand every ticket — refuse loudly instead, same policy as
|
|
851
|
+
// the SQLite case. A mixed board (a stray numeric dir merged in from an
|
|
852
|
+
// old branch) still opens; `task check` names the straggler.
|
|
853
|
+
const entries = readdirSync(join(taskDir, TICKETS_DIR));
|
|
854
|
+
if (entries.some((name) => /^\d+$/.test(name)) && !entries.some(isTicketKey)) {
|
|
855
|
+
throw new Error("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");
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return new FileStore(root);
|
|
507
859
|
}
|
|
508
860
|
/**
|
|
509
861
|
* Create `.task/` in `root`: config and a .gitignore. New boards are
|
|
@@ -525,80 +877,4 @@ export function initProject(root, options) {
|
|
|
525
877
|
writeFileSync(join(taskDir, ".gitignore"), GITIGNORE);
|
|
526
878
|
return new FileStore(root);
|
|
527
879
|
}
|
|
528
|
-
/**
|
|
529
|
-
* `task migrate` — export every ticket and comment from a legacy `tasks.db`
|
|
530
|
-
* into the text layout, flip the config to version 2, and gitignore the
|
|
531
|
-
* database. The database file itself is left on disk untouched, as a backup;
|
|
532
|
-
* it just stops being the state.
|
|
533
|
-
*/
|
|
534
|
-
export function migrateBoard(root) {
|
|
535
|
-
const taskDir = join(root, TASK_DIR);
|
|
536
|
-
if (existsSync(join(taskDir, TICKETS_DIR))) {
|
|
537
|
-
throw new Error("already migrated — .task/tickets/ exists");
|
|
538
|
-
}
|
|
539
|
-
if (!existsSync(join(taskDir, DB_FILE))) {
|
|
540
|
-
throw new Error(`nothing to migrate — no ${DB_FILE} in .task/`);
|
|
541
|
-
}
|
|
542
|
-
const legacy = new TaskStore(root);
|
|
543
|
-
let comments = 0;
|
|
544
|
-
try {
|
|
545
|
-
const tasks = legacy.list({});
|
|
546
|
-
for (const task of tasks) {
|
|
547
|
-
const dir = join(taskDir, TICKETS_DIR, String(task.number));
|
|
548
|
-
mkdirSync(dir, { recursive: true });
|
|
549
|
-
writeFileSync(join(dir, TICKET_FILE), serializeTicket({
|
|
550
|
-
title: task.title,
|
|
551
|
-
description: task.description,
|
|
552
|
-
status: task.status,
|
|
553
|
-
tags: task.tags,
|
|
554
|
-
milestone: task.milestone,
|
|
555
|
-
needsHuman: task.needsHuman,
|
|
556
|
-
blockedBy: task.blockedBy,
|
|
557
|
-
prs: task.prs,
|
|
558
|
-
position: task.position,
|
|
559
|
-
createdAt: task.createdAt,
|
|
560
|
-
updatedAt: task.updatedAt,
|
|
561
|
-
}));
|
|
562
|
-
const taskComments = legacy.comments(task.number);
|
|
563
|
-
if (taskComments.length === 0)
|
|
564
|
-
continue;
|
|
565
|
-
const commentsDir = join(dir, COMMENTS_DIR);
|
|
566
|
-
mkdirSync(commentsDir);
|
|
567
|
-
let previous = null;
|
|
568
|
-
for (const comment of taskComments) {
|
|
569
|
-
// `comments()` sorts by (createdAt, id), so ids must sort in legacy
|
|
570
|
-
// (rowid) order wherever timestamps tie — and a fresh stem for a
|
|
571
|
-
// different author can sort *before* the previous one. Chain off the
|
|
572
|
-
// previous id instead: the collision suffix below then appends `-2`,
|
|
573
|
-
// which sorts after. The frontmatter stays authoritative for the
|
|
574
|
-
// author; the filename is only an ordering key.
|
|
575
|
-
let stem = commentStem(comment.createdAt, comment.author);
|
|
576
|
-
if (previous &&
|
|
577
|
-
comment.createdAt === previous.createdAt &&
|
|
578
|
-
stem.localeCompare(previous.id) < 0) {
|
|
579
|
-
stem = previous.id;
|
|
580
|
-
}
|
|
581
|
-
let id = stem;
|
|
582
|
-
for (let n = 2; existsSync(join(commentsDir, `${id}.md`)); n++)
|
|
583
|
-
id = `${stem}-${n}`;
|
|
584
|
-
previous = { createdAt: comment.createdAt, id };
|
|
585
|
-
writeFileSync(join(commentsDir, `${id}.md`), serializeComment({
|
|
586
|
-
author: comment.author,
|
|
587
|
-
createdAt: comment.createdAt,
|
|
588
|
-
body: comment.body,
|
|
589
|
-
}));
|
|
590
|
-
comments++;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
// Preserve whatever else the config carries; only the version flips.
|
|
594
|
-
const config = JSON.parse(readFileSync(join(taskDir, CONFIG_FILE), "utf8"));
|
|
595
|
-
config.version = 2;
|
|
596
|
-
writeFileSync(join(taskDir, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`);
|
|
597
|
-
writeFileSync(join(taskDir, ".gitignore"), GITIGNORE);
|
|
598
|
-
return { tasks: tasks.length, comments };
|
|
599
|
-
}
|
|
600
|
-
finally {
|
|
601
|
-
legacy.close();
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
880
|
//# sourceMappingURL=file-store.js.map
|