schub 0.1.2 → 0.1.4
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 +27 -0
- package/dist/index.js +12830 -3057
- package/package.json +5 -2
- package/skills/create-proposal/SKILL.md +5 -1
- package/skills/create-tasks/SKILL.md +5 -4
- package/skills/implement-task/SKILL.md +6 -1
- package/skills/review-proposal/SKILL.md +3 -2
- package/skills/update-roadmap/SKILL.md +23 -0
- package/src/changes.test.ts +166 -0
- package/src/changes.ts +159 -54
- package/src/commands/adr.test.ts +6 -5
- package/src/commands/changes.test.ts +136 -14
- package/src/commands/changes.ts +102 -1
- package/src/commands/cookbook.test.ts +6 -5
- package/src/commands/init.test.ts +69 -2
- package/src/commands/init.ts +48 -5
- package/src/commands/review.test.ts +7 -6
- package/src/commands/review.ts +1 -1
- package/src/commands/roadmap.test.ts +84 -0
- package/src/commands/roadmap.ts +84 -0
- package/src/commands/tasks-create.test.ts +22 -22
- package/src/commands/tasks-implement.test.ts +253 -0
- package/src/commands/tasks-implement.ts +121 -0
- package/src/commands/tasks-list.test.ts +27 -27
- package/src/commands/tasks-update.test.ts +92 -0
- package/src/commands/tasks.ts +98 -1
- package/src/features/roadmap/index.ts +230 -0
- package/src/features/roadmap/roadmap.test.ts +77 -0
- package/src/features/tasks/constants.ts +1 -0
- package/src/features/tasks/create.ts +10 -8
- package/src/features/tasks/filesystem.test.ts +285 -18
- package/src/features/tasks/filesystem.ts +152 -39
- package/src/features/tasks/graph.ts +18 -3
- package/src/features/tasks/index.ts +10 -1
- package/src/features/tasks/worktree.ts +48 -0
- package/src/frontmatter.ts +115 -0
- package/src/index.test.ts +42 -6
- package/src/index.ts +226 -109
- package/src/opencode.test.ts +53 -0
- package/src/opencode.ts +74 -0
- package/src/tasks.ts +2 -0
- package/src/tui/App.test.tsx +418 -0
- package/src/tui/App.tsx +343 -0
- package/src/tui/components/PlanView.test.tsx +101 -0
- package/src/tui/components/PlanView.tsx +89 -0
- package/src/tui/components/PreviewPage.test.tsx +69 -0
- package/src/tui/components/PreviewPage.tsx +87 -0
- package/src/tui/components/ProposalDetailView.test.tsx +169 -0
- package/src/tui/components/ProposalDetailView.tsx +166 -0
- package/src/tui/components/RoadmapView.test.tsx +85 -0
- package/src/tui/components/RoadmapView.tsx +369 -0
- package/src/tui/components/StatusView.test.tsx +1351 -0
- package/src/tui/components/StatusView.tsx +519 -0
- package/src/tui/components/markdown-renderer.test.ts +46 -0
- package/src/tui/components/markdown-renderer.ts +89 -0
- package/src/tui/components/status-view-data.ts +322 -0
- package/src/tui/components/status-view-render.tsx +329 -0
- package/src/tui/index.ts +16 -0
- package/templates/create-proposal/adr-template.md +6 -4
- package/templates/create-proposal/cookbook-template.md +5 -3
- package/templates/create-proposal/proposal-template.md +8 -6
- package/templates/create-roadmap/roadmap.md +5 -0
- package/templates/create-tasks/task-template.md +9 -4
- package/templates/review-proposal/q&a-template.md +8 -3
- package/templates/review-proposal/review-me-template.md +6 -4
- package/templates/setup-project/project-overview-template.md +5 -0
- package/templates/setup-project/project-setup-template.md +5 -0
- package/templates/setup-project/project-wow-template.md +5 -0
- package/src/App.test.tsx +0 -93
- package/src/App.tsx +0 -155
- package/src/components/PlanView.test.tsx +0 -113
- package/src/components/PlanView.tsx +0 -160
- package/src/components/StatusView.test.tsx +0 -380
- package/src/components/StatusView.tsx +0 -367
- package/src/ide.ts +0 -7
- package/templates/templates-parity.test.ts +0 -45
- /package/src/{clipboard.ts → tui/clipboard.ts} +0 -0
- /package/src/{components → tui/components}/statusColor.ts +0 -0
- /package/src/{terminal.test.ts → tui/terminal.test.ts} +0 -0
- /package/src/{terminal.ts → tui/terminal.ts} +0 -0
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import type { TaskStatus } from "./constants";
|
|
6
|
+
import {
|
|
7
|
+
archiveTasksForChange,
|
|
8
|
+
listTasks,
|
|
9
|
+
listTasksForChange,
|
|
10
|
+
loadTaskDependencies,
|
|
11
|
+
updateTaskStatuses,
|
|
12
|
+
} from "./filesystem";
|
|
6
13
|
|
|
7
14
|
const setupChecklistRepo = () => {
|
|
8
15
|
const base = mkdtempSync(join(tmpdir(), "schub-checklist-"));
|
|
@@ -12,24 +19,131 @@ const setupChecklistRepo = () => {
|
|
|
12
19
|
|
|
13
20
|
const tasks = [
|
|
14
21
|
{
|
|
15
|
-
id: "
|
|
22
|
+
id: "T0001",
|
|
16
23
|
slug: "unchecked-only",
|
|
17
|
-
body: `# Task:
|
|
24
|
+
body: `# Task: T0001 Unchecked Only\n\n## Steps\n- [ ] First item\n- [ ] Second item\n`,
|
|
18
25
|
},
|
|
19
26
|
{
|
|
20
|
-
id: "
|
|
27
|
+
id: "T0002",
|
|
21
28
|
slug: "checked-only",
|
|
22
|
-
body: `# Task:
|
|
29
|
+
body: `# Task: T0002 Checked Only\n\n## Steps\n- [x] Finished item\n`,
|
|
23
30
|
},
|
|
24
31
|
{
|
|
25
|
-
id: "
|
|
32
|
+
id: "T0003",
|
|
26
33
|
slug: "mixed-checklist",
|
|
27
|
-
body: `# Task:
|
|
34
|
+
body: `# Task: T0003 Mixed Checklist\n\n## Steps\n- [ ] Remaining item\n- [x] Done item\n\n## Acceptance\n- [ ] Ignore this\n`,
|
|
28
35
|
},
|
|
29
36
|
{
|
|
30
|
-
id: "
|
|
37
|
+
id: "T0004",
|
|
31
38
|
slug: "missing-checklist",
|
|
32
|
-
body: `# Task:
|
|
39
|
+
body: `# Task: T0004 Missing Checklist\n\n## Steps\n- Do the thing\n- Another item\n\n## Acceptance\n- [ ] Ignore this too\n`,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
for (const task of tasks) {
|
|
44
|
+
const filePath = join(tasksRoot, `${task.id}_${task.slug}.md`);
|
|
45
|
+
writeFileSync(filePath, task.body, "utf8");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return schubDir;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const setupArchiveRepo = () => {
|
|
52
|
+
const base = mkdtempSync(join(tmpdir(), "schub-archive-"));
|
|
53
|
+
const schubDir = join(base, ".schub");
|
|
54
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
55
|
+
const statuses = ["backlog", "wip", "done"];
|
|
56
|
+
|
|
57
|
+
for (const status of statuses) {
|
|
58
|
+
mkdirSync(join(tasksRoot, status), { recursive: true });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const changeId = "C0001_archive-change";
|
|
62
|
+
writeFileSync(
|
|
63
|
+
join(tasksRoot, "backlog", "T0010_backlog-task.md"),
|
|
64
|
+
`---\nchange_id: ${changeId}\n---\n# Task: T0010 Backlog Task\n`,
|
|
65
|
+
"utf8",
|
|
66
|
+
);
|
|
67
|
+
writeFileSync(
|
|
68
|
+
join(tasksRoot, "wip", "T0011_wip-task.md"),
|
|
69
|
+
`---\nchange_id: ${changeId}\n---\n# Task: T0011 Wip Task\n`,
|
|
70
|
+
"utf8",
|
|
71
|
+
);
|
|
72
|
+
writeFileSync(
|
|
73
|
+
join(tasksRoot, "done", "T0012_other-task.md"),
|
|
74
|
+
"---\nchange_id: C0002_other-change\n---\n# Task: T0012 Other Task\n",
|
|
75
|
+
"utf8",
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return { schubDir, tasksRoot, changeId };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const setupChangeFilterRepo = () => {
|
|
82
|
+
const base = mkdtempSync(join(tmpdir(), "schub-change-filter-"));
|
|
83
|
+
const schubDir = join(base, ".schub");
|
|
84
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
85
|
+
const statuses = ["backlog", "ready", "wip", "blocked", "done", "archived"];
|
|
86
|
+
|
|
87
|
+
for (const status of statuses) {
|
|
88
|
+
mkdirSync(join(tasksRoot, status), { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const changeId = "C0200_filter-change";
|
|
92
|
+
const otherChangeId = "C0201_other-change";
|
|
93
|
+
const tasks = [
|
|
94
|
+
{ id: "T0200", status: "backlog", slug: "backlog-task", changeId },
|
|
95
|
+
{ id: "T0201", status: "wip", slug: "wip-task", changeId },
|
|
96
|
+
{ id: "T0202", status: "done", slug: "done-task", changeId: otherChangeId },
|
|
97
|
+
{ id: "T0203", status: "blocked", slug: "blocked-task", changeId },
|
|
98
|
+
{ id: "T0204", status: "ready", slug: "ready-task" },
|
|
99
|
+
{ id: "T0205", status: "archived", slug: "archived-task", changeId },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
for (const task of tasks) {
|
|
103
|
+
const frontmatter = task.changeId ? `---\nchange_id: ${task.changeId}\n---\n` : "";
|
|
104
|
+
const title = task.slug.replace(/-/g, " ");
|
|
105
|
+
const body = `${frontmatter}# Task: ${task.id} ${title}\n`;
|
|
106
|
+
const filePath = join(tasksRoot, task.status, `${task.id}_${task.slug}.md`);
|
|
107
|
+
writeFileSync(filePath, body, "utf8");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { schubDir, changeId };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const setupBacklogUpdateRepo = () => {
|
|
114
|
+
const base = mkdtempSync(join(tmpdir(), "schub-backlog-update-"));
|
|
115
|
+
const schubDir = join(base, ".schub");
|
|
116
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
117
|
+
const backlogRoot = join(tasksRoot, "backlog");
|
|
118
|
+
mkdirSync(backlogRoot, { recursive: true });
|
|
119
|
+
|
|
120
|
+
writeFileSync(join(backlogRoot, "T0100_ready-task.md"), "# Task: T0100 Ready Task\n", "utf8");
|
|
121
|
+
writeFileSync(join(backlogRoot, "T0101_archive-task.md"), "# Task: T0101 Archive Task\n", "utf8");
|
|
122
|
+
|
|
123
|
+
return { schubDir, tasksRoot };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const setupBlockedReasonRepo = () => {
|
|
127
|
+
const base = mkdtempSync(join(tmpdir(), "schub-blocked-reason-"));
|
|
128
|
+
const schubDir = join(base, ".schub");
|
|
129
|
+
const tasksRoot = join(schubDir, "tasks", "blocked");
|
|
130
|
+
mkdirSync(tasksRoot, { recursive: true });
|
|
131
|
+
|
|
132
|
+
const tasks = [
|
|
133
|
+
{
|
|
134
|
+
id: "T0020",
|
|
135
|
+
slug: "blocked-with-reason",
|
|
136
|
+
body: "---\nblocked_reason: Waiting on API\n---\n# Task: T0020 Blocked With Reason\n",
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "T0021",
|
|
140
|
+
slug: "blocked-with-reason-two",
|
|
141
|
+
body: "---\nblocked_reason: Vendor response\n---\n# Task: T0021 Blocked Reason Two\n",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: "T0022",
|
|
145
|
+
slug: "blocked-without-reason",
|
|
146
|
+
body: "# Task: T0022 Blocked Without Reason\n",
|
|
33
147
|
},
|
|
34
148
|
];
|
|
35
149
|
|
|
@@ -49,10 +163,10 @@ test("listTasks includes Steps-only checklist counts", () => {
|
|
|
49
163
|
const schubDir = setupChecklistRepo();
|
|
50
164
|
const tasks = listTasks(schubDir);
|
|
51
165
|
|
|
52
|
-
const unchecked = getTaskById(tasks, "
|
|
53
|
-
const checked = getTaskById(tasks, "
|
|
54
|
-
const mixed = getTaskById(tasks, "
|
|
55
|
-
const missing = getTaskById(tasks, "
|
|
166
|
+
const unchecked = getTaskById(tasks, "T0001");
|
|
167
|
+
const checked = getTaskById(tasks, "T0002");
|
|
168
|
+
const mixed = getTaskById(tasks, "T0003");
|
|
169
|
+
const missing = getTaskById(tasks, "T0004");
|
|
56
170
|
|
|
57
171
|
expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
|
|
58
172
|
expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
|
|
@@ -65,10 +179,10 @@ test("loadTaskDependencies includes Steps-only checklist counts", () => {
|
|
|
65
179
|
const schubDir = setupChecklistRepo();
|
|
66
180
|
const tasks = loadTaskDependencies(schubDir);
|
|
67
181
|
|
|
68
|
-
const unchecked = getTaskById(tasks, "
|
|
69
|
-
const checked = getTaskById(tasks, "
|
|
70
|
-
const mixed = getTaskById(tasks, "
|
|
71
|
-
const missing = getTaskById(tasks, "
|
|
182
|
+
const unchecked = getTaskById(tasks, "T0001");
|
|
183
|
+
const checked = getTaskById(tasks, "T0002");
|
|
184
|
+
const mixed = getTaskById(tasks, "T0003");
|
|
185
|
+
const missing = getTaskById(tasks, "T0004");
|
|
72
186
|
|
|
73
187
|
expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
|
|
74
188
|
expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
|
|
@@ -76,3 +190,156 @@ test("loadTaskDependencies includes Steps-only checklist counts", () => {
|
|
|
76
190
|
expect(missing?.checklistTotal).toBeUndefined();
|
|
77
191
|
expect(missing?.checklistRemaining).toBeUndefined();
|
|
78
192
|
});
|
|
193
|
+
|
|
194
|
+
test("listTasks includes blocked reasons when present", () => {
|
|
195
|
+
const schubDir = setupBlockedReasonRepo();
|
|
196
|
+
const tasks = listTasks(schubDir);
|
|
197
|
+
|
|
198
|
+
const blocked = getTaskById(tasks, "T0020");
|
|
199
|
+
const uppercase = getTaskById(tasks, "T0021");
|
|
200
|
+
const missing = getTaskById(tasks, "T0022");
|
|
201
|
+
|
|
202
|
+
expect(blocked?.blockedReason).toBe("Waiting on API");
|
|
203
|
+
expect(uppercase?.blockedReason).toBe("Vendor response");
|
|
204
|
+
expect(missing?.blockedReason).toBeUndefined();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("listTasks accepts variable-length task ids", () => {
|
|
208
|
+
const base = mkdtempSync(join(tmpdir(), "schub-variable-tasks-"));
|
|
209
|
+
const schubDir = join(base, ".schub");
|
|
210
|
+
const tasksRoot = join(schubDir, "tasks", "backlog");
|
|
211
|
+
mkdirSync(tasksRoot, { recursive: true });
|
|
212
|
+
|
|
213
|
+
writeFileSync(join(tasksRoot, "T1_short-task.md"), "# Task: T1 Short Task\n", "utf8");
|
|
214
|
+
writeFileSync(join(tasksRoot, "T12345_long-task.md"), "# Task: T12345 Long Task\n", "utf8");
|
|
215
|
+
|
|
216
|
+
const tasks = listTasks(schubDir, ["backlog"]);
|
|
217
|
+
|
|
218
|
+
expect(tasks.map((task) => task.id)).toEqual(["T1", "T12345"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("loadTaskDependencies includes blocked reasons when present", () => {
|
|
222
|
+
const schubDir = setupBlockedReasonRepo();
|
|
223
|
+
const tasks = loadTaskDependencies(schubDir);
|
|
224
|
+
|
|
225
|
+
const blocked = getTaskById(tasks, "T0020");
|
|
226
|
+
const uppercase = getTaskById(tasks, "T0021");
|
|
227
|
+
const missing = getTaskById(tasks, "T0022");
|
|
228
|
+
|
|
229
|
+
expect(blocked?.blockedReason).toBe("Waiting on API");
|
|
230
|
+
expect(uppercase?.blockedReason).toBe("Vendor response");
|
|
231
|
+
expect(missing?.blockedReason).toBeUndefined();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("loadTaskDependencies captures variable-length dependencies", () => {
|
|
235
|
+
const base = mkdtempSync(join(tmpdir(), "schub-variable-deps-"));
|
|
236
|
+
const schubDir = join(base, ".schub");
|
|
237
|
+
const tasksRoot = join(schubDir, "tasks", "backlog");
|
|
238
|
+
mkdirSync(tasksRoot, { recursive: true });
|
|
239
|
+
|
|
240
|
+
writeFileSync(
|
|
241
|
+
join(tasksRoot, "T1_primary-task.md"),
|
|
242
|
+
"---\ndepends_on:\n - T7\n - T12345\n---\n# Task: T1 Primary Task\n",
|
|
243
|
+
"utf8",
|
|
244
|
+
);
|
|
245
|
+
writeFileSync(join(tasksRoot, "T7_support-task.md"), "# Task: T7 Support Task\n", "utf8");
|
|
246
|
+
writeFileSync(join(tasksRoot, "T12345_support-task.md"), "# Task: T12345 Support Task\n", "utf8");
|
|
247
|
+
|
|
248
|
+
const tasks = loadTaskDependencies(schubDir, ["backlog"]);
|
|
249
|
+
const primary = getTaskById(tasks, "T1");
|
|
250
|
+
|
|
251
|
+
expect(primary?.dependsOn).toEqual(["T7", "T12345"]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("listTasksForChange filters tasks across statuses", () => {
|
|
255
|
+
const { schubDir, changeId } = setupChangeFilterRepo();
|
|
256
|
+
const tasks = listTasksForChange(schubDir, changeId);
|
|
257
|
+
|
|
258
|
+
expect(tasks.map((task) => task.id)).toEqual(["T0200", "T0201", "T0203", "T0205"]);
|
|
259
|
+
expect(getTaskById(tasks, "T0202")).toBeUndefined();
|
|
260
|
+
expect(getTaskById(tasks, "T0204")).toBeUndefined();
|
|
261
|
+
|
|
262
|
+
const expectations = new Map<string, { status: TaskStatus; title: string }>([
|
|
263
|
+
["T0200", { status: "backlog", title: "backlog task" }],
|
|
264
|
+
["T0201", { status: "wip", title: "wip task" }],
|
|
265
|
+
["T0203", { status: "blocked", title: "blocked task" }],
|
|
266
|
+
["T0205", { status: "archived", title: "archived task" }],
|
|
267
|
+
]);
|
|
268
|
+
|
|
269
|
+
for (const task of tasks) {
|
|
270
|
+
const expected = expectations.get(task.id);
|
|
271
|
+
expect(expected).toBeDefined();
|
|
272
|
+
expect(task.changeId).toBe(changeId);
|
|
273
|
+
expect(task.path).toContain(`.schub/tasks/${task.status}/`);
|
|
274
|
+
if (expected) {
|
|
275
|
+
expect(task.status).toBe(expected.status);
|
|
276
|
+
expect(task.title).toBe(expected.title);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("archiveTasksForChange moves matching tasks to archived", () => {
|
|
282
|
+
const { schubDir, tasksRoot, changeId } = setupArchiveRepo();
|
|
283
|
+
const archived = archiveTasksForChange(schubDir, changeId);
|
|
284
|
+
|
|
285
|
+
const archiveRoot = join(tasksRoot, "archived");
|
|
286
|
+
const backlogTask = join(archiveRoot, "T0010_backlog-task.md");
|
|
287
|
+
const wipTask = join(archiveRoot, "T0011_wip-task.md");
|
|
288
|
+
|
|
289
|
+
expect(existsSync(archiveRoot)).toBe(true);
|
|
290
|
+
expect(existsSync(backlogTask)).toBe(true);
|
|
291
|
+
expect(existsSync(wipTask)).toBe(true);
|
|
292
|
+
expect(existsSync(join(tasksRoot, "backlog", "T0010_backlog-task.md"))).toBe(false);
|
|
293
|
+
expect(existsSync(join(tasksRoot, "wip", "T0011_wip-task.md"))).toBe(false);
|
|
294
|
+
expect(existsSync(join(tasksRoot, "done", "T0012_other-task.md"))).toBe(true);
|
|
295
|
+
|
|
296
|
+
expect(archived.map((task) => task.id)).toEqual(["T0010", "T0011"]);
|
|
297
|
+
expect(archived.every((task) => task.status === "archived")).toBe(true);
|
|
298
|
+
expect(archived[0]?.path).toContain(".schub/tasks/archived/");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("updateTaskStatuses moves backlog task to ready", () => {
|
|
302
|
+
const { schubDir, tasksRoot } = setupBacklogUpdateRepo();
|
|
303
|
+
const updated = updateTaskStatuses(schubDir, ["T0100"], "ready");
|
|
304
|
+
|
|
305
|
+
const readyRoot = join(tasksRoot, "ready");
|
|
306
|
+
const readyTask = join(readyRoot, "T0100_ready-task.md");
|
|
307
|
+
|
|
308
|
+
expect(existsSync(readyRoot)).toBe(true);
|
|
309
|
+
expect(existsSync(readyTask)).toBe(true);
|
|
310
|
+
expect(existsSync(join(tasksRoot, "backlog", "T0100_ready-task.md"))).toBe(false);
|
|
311
|
+
expect(updated).toMatchObject([{ id: "T0100", status: "ready" }]);
|
|
312
|
+
expect(updated[0]?.path).toContain(".schub/tasks/ready/");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("updateTaskStatuses moves backlog task to archived", () => {
|
|
316
|
+
const { schubDir, tasksRoot } = setupBacklogUpdateRepo();
|
|
317
|
+
const updated = updateTaskStatuses(schubDir, ["T0101"], "archived");
|
|
318
|
+
|
|
319
|
+
const archivedRoot = join(tasksRoot, "archived");
|
|
320
|
+
const archivedTask = join(archivedRoot, "T0101_archive-task.md");
|
|
321
|
+
|
|
322
|
+
expect(existsSync(archivedRoot)).toBe(true);
|
|
323
|
+
expect(existsSync(archivedTask)).toBe(true);
|
|
324
|
+
expect(existsSync(join(tasksRoot, "backlog", "T0101_archive-task.md"))).toBe(false);
|
|
325
|
+
expect(updated).toMatchObject([{ id: "T0101", status: "archived" }]);
|
|
326
|
+
expect(updated[0]?.path).toContain(".schub/tasks/archived/");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("updateTaskStatuses moves ready task to backlog", () => {
|
|
330
|
+
const { schubDir, tasksRoot } = setupBacklogUpdateRepo();
|
|
331
|
+
const readyRoot = join(tasksRoot, "ready");
|
|
332
|
+
const backlogRoot = join(tasksRoot, "backlog");
|
|
333
|
+
const readyTask = join(readyRoot, "T0102_ready-task.md");
|
|
334
|
+
const backlogTask = join(backlogRoot, "T0102_ready-task.md");
|
|
335
|
+
|
|
336
|
+
mkdirSync(readyRoot, { recursive: true });
|
|
337
|
+
writeFileSync(readyTask, "# Task: T0102 Ready Task\n", "utf8");
|
|
338
|
+
|
|
339
|
+
const updated = updateTaskStatuses(schubDir, ["T0102"], "backlog");
|
|
340
|
+
|
|
341
|
+
expect(existsSync(backlogTask)).toBe(true);
|
|
342
|
+
expect(existsSync(readyTask)).toBe(false);
|
|
343
|
+
expect(updated).toMatchObject([{ id: "T0102", status: "backlog" }]);
|
|
344
|
+
expect(updated[0]?.path).toContain(".schub/tasks/backlog/");
|
|
345
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { readFrontmatter } from "../../frontmatter";
|
|
3
4
|
import { TASK_STATUSES, type TaskDependency, type TaskInfo, type TaskStatus } from "./constants";
|
|
4
5
|
import { compareTaskIds, compareTasks } from "./sorting";
|
|
5
6
|
|
|
@@ -40,6 +41,9 @@ const parseTaskFilename = (fileName: string): { id: string; title: string } | nu
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
const id = baseName.slice(0, underscoreIndex);
|
|
44
|
+
if (!/^T\d+$/.test(id)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
43
47
|
const titleSlug = baseName.slice(underscoreIndex + 1);
|
|
44
48
|
return { id, title: titleSlug.replace(/-/g, " ") };
|
|
45
49
|
};
|
|
@@ -51,6 +55,7 @@ type ParsedTaskFile = {
|
|
|
51
55
|
changeId?: string;
|
|
52
56
|
checklistRemaining?: number;
|
|
53
57
|
checklistTotal?: number;
|
|
58
|
+
blockedReason?: string;
|
|
54
59
|
};
|
|
55
60
|
|
|
56
61
|
const parseChecklistCounts = (content: string) => {
|
|
@@ -104,21 +109,21 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
|
|
|
104
109
|
return { ...fallback, dependsOn: [] };
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
const
|
|
108
|
-
const
|
|
112
|
+
const { data } = readFrontmatter(content);
|
|
113
|
+
const changeIdValue = data.change_id;
|
|
114
|
+
const changeId = typeof changeIdValue === "string" ? changeIdValue.trim() : undefined;
|
|
109
115
|
|
|
110
|
-
const
|
|
116
|
+
const dependsValue = data.depends_on;
|
|
111
117
|
let dependsOn: string[] = [];
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (matches) {
|
|
117
|
-
dependsOn = Array.from(new Set(matches));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
118
|
+
if (Array.isArray(dependsValue)) {
|
|
119
|
+
dependsOn = dependsValue.map((value) => value.trim()).filter(Boolean);
|
|
120
|
+
} else if (typeof dependsValue === "string" && dependsValue.trim()) {
|
|
121
|
+
dependsOn = [dependsValue.trim()];
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
const blockedReasonValue = data.blocked_reason;
|
|
125
|
+
const blockedReason =
|
|
126
|
+
typeof blockedReasonValue === "string" && blockedReasonValue.trim() ? blockedReasonValue.trim() : undefined;
|
|
122
127
|
const checklistCounts = parseChecklistCounts(content);
|
|
123
128
|
|
|
124
129
|
return {
|
|
@@ -126,11 +131,19 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
|
|
|
126
131
|
title: fallback.title,
|
|
127
132
|
dependsOn,
|
|
128
133
|
changeId,
|
|
134
|
+
blockedReason: blockedReason || undefined,
|
|
129
135
|
...(checklistCounts ?? {}),
|
|
130
136
|
};
|
|
131
137
|
};
|
|
138
|
+
type TaskFileEntry = {
|
|
139
|
+
id: string;
|
|
140
|
+
title: string;
|
|
141
|
+
status: TaskStatus;
|
|
142
|
+
path: string;
|
|
143
|
+
parsedFile: ParsedTaskFile;
|
|
144
|
+
};
|
|
132
145
|
|
|
133
|
-
|
|
146
|
+
const loadTaskFiles = (schubDir: string, statuses: readonly TaskStatus[] = TASK_STATUSES) => {
|
|
134
147
|
const tasksRoot = join(schubDir, "tasks");
|
|
135
148
|
if (!existsSync(tasksRoot) || !isDirectory(tasksRoot)) {
|
|
136
149
|
return [];
|
|
@@ -138,7 +151,7 @@ export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TA
|
|
|
138
151
|
|
|
139
152
|
const allowed = new Set(statuses);
|
|
140
153
|
const repoRoot = dirname(schubDir);
|
|
141
|
-
const tasks:
|
|
154
|
+
const tasks: TaskFileEntry[] = [];
|
|
142
155
|
|
|
143
156
|
for (const status of TASK_STATUSES) {
|
|
144
157
|
if (!allowed.has(status)) {
|
|
@@ -168,12 +181,24 @@ export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TA
|
|
|
168
181
|
title: parsed.title,
|
|
169
182
|
status,
|
|
170
183
|
path: relative(repoRoot, filePath),
|
|
171
|
-
|
|
172
|
-
checklistTotal: parsedFile.checklistTotal,
|
|
184
|
+
parsedFile,
|
|
173
185
|
});
|
|
174
186
|
}
|
|
175
187
|
}
|
|
176
188
|
|
|
189
|
+
return tasks;
|
|
190
|
+
};
|
|
191
|
+
export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TASK_STATUSES): TaskInfo[] => {
|
|
192
|
+
const tasks = loadTaskFiles(schubDir, statuses).map((task) => ({
|
|
193
|
+
id: task.id,
|
|
194
|
+
title: task.title,
|
|
195
|
+
status: task.status,
|
|
196
|
+
path: task.path,
|
|
197
|
+
checklistRemaining: task.parsedFile.checklistRemaining,
|
|
198
|
+
checklistTotal: task.parsedFile.checklistTotal,
|
|
199
|
+
blockedReason: task.parsedFile.blockedReason,
|
|
200
|
+
}));
|
|
201
|
+
|
|
177
202
|
return tasks.sort(compareTasks);
|
|
178
203
|
};
|
|
179
204
|
|
|
@@ -181,20 +206,86 @@ export const loadTaskDependencies = (
|
|
|
181
206
|
schubDir: string,
|
|
182
207
|
statuses: readonly TaskStatus[] = TASK_STATUSES,
|
|
183
208
|
): TaskDependency[] => {
|
|
209
|
+
const tasks = loadTaskFiles(schubDir, statuses).map((task) => {
|
|
210
|
+
const dependsOn = task.parsedFile.dependsOn.sort(compareTaskIds);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
id: task.id,
|
|
214
|
+
title: task.title,
|
|
215
|
+
status: task.status,
|
|
216
|
+
path: task.path,
|
|
217
|
+
dependsOn,
|
|
218
|
+
changeId: task.parsedFile.changeId,
|
|
219
|
+
checklistRemaining: task.parsedFile.checklistRemaining,
|
|
220
|
+
checklistTotal: task.parsedFile.checklistTotal,
|
|
221
|
+
blockedReason: task.parsedFile.blockedReason,
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return tasks.sort(compareTasks);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export const listTasksForChange = (
|
|
229
|
+
schubDir: string,
|
|
230
|
+
changeId: string,
|
|
231
|
+
statuses: readonly TaskStatus[] = TASK_STATUSES,
|
|
232
|
+
) => {
|
|
233
|
+
const normalizedChangeId = changeId.trim();
|
|
234
|
+
const tasks = loadTaskFiles(schubDir, statuses)
|
|
235
|
+
.filter((task) => task.parsedFile.changeId === normalizedChangeId)
|
|
236
|
+
.map((task) => ({
|
|
237
|
+
id: task.id,
|
|
238
|
+
title: task.title,
|
|
239
|
+
status: task.status,
|
|
240
|
+
path: task.path,
|
|
241
|
+
changeId: task.parsedFile.changeId,
|
|
242
|
+
}));
|
|
243
|
+
|
|
244
|
+
return tasks.sort(compareTasks);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
type TaskUpdateStatus = "backlog" | "ready" | "archived";
|
|
248
|
+
|
|
249
|
+
export const updateTaskStatuses = (schubDir: string, taskIds: string[], status: TaskUpdateStatus): TaskInfo[] => {
|
|
184
250
|
const tasksRoot = join(schubDir, "tasks");
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
251
|
+
const targetRoot = join(tasksRoot, status);
|
|
252
|
+
mkdirSync(targetRoot, { recursive: true });
|
|
188
253
|
|
|
189
|
-
const allowed = new Set(statuses);
|
|
190
254
|
const repoRoot = dirname(schubDir);
|
|
191
|
-
const
|
|
255
|
+
const sourceTasks = listTasks(schubDir, ["backlog", "ready"]);
|
|
256
|
+
const tasksById = new Map(sourceTasks.map((task) => [task.id, task]));
|
|
257
|
+
|
|
258
|
+
return taskIds
|
|
259
|
+
.map((taskId) => tasksById.get(taskId))
|
|
260
|
+
.filter((task): task is TaskInfo => Boolean(task))
|
|
261
|
+
.map((task) => {
|
|
262
|
+
const currentPath = join(repoRoot, task.path);
|
|
263
|
+
const nextPath = join(targetRoot, basename(task.path));
|
|
264
|
+
renameSync(currentPath, nextPath);
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
...task,
|
|
268
|
+
status,
|
|
269
|
+
path: relative(repoRoot, nextPath),
|
|
270
|
+
};
|
|
271
|
+
});
|
|
272
|
+
};
|
|
192
273
|
|
|
193
|
-
|
|
194
|
-
if (!allowed.has(status)) {
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
274
|
+
const ACTIVE_TASK_STATUSES = TASK_STATUSES.filter((status) => status !== "archived");
|
|
197
275
|
|
|
276
|
+
export const assignTaskToWip = (schubDir: string, taskId: string): TaskInfo => {
|
|
277
|
+
const normalizedId = taskId.trim().toUpperCase();
|
|
278
|
+
if (!normalizedId) {
|
|
279
|
+
throw new Error("Provide --id.");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
283
|
+
const targetRoot = join(tasksRoot, "wip");
|
|
284
|
+
mkdirSync(targetRoot, { recursive: true });
|
|
285
|
+
|
|
286
|
+
const repoRoot = dirname(schubDir);
|
|
287
|
+
|
|
288
|
+
for (const status of ACTIVE_TASK_STATUSES) {
|
|
198
289
|
const statusDir = join(tasksRoot, status);
|
|
199
290
|
if (!existsSync(statusDir) || !isDirectory(statusDir)) {
|
|
200
291
|
continue;
|
|
@@ -207,26 +298,48 @@ export const loadTaskDependencies = (
|
|
|
207
298
|
}
|
|
208
299
|
|
|
209
300
|
const parsed = parseTaskFilename(entry.name);
|
|
210
|
-
if (!parsed) {
|
|
301
|
+
if (!parsed || parsed.id !== normalizedId) {
|
|
211
302
|
continue;
|
|
212
303
|
}
|
|
213
304
|
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
|
|
305
|
+
const currentPath = join(statusDir, entry.name);
|
|
306
|
+
const nextPath = join(targetRoot, entry.name);
|
|
307
|
+
if (currentPath !== nextPath) {
|
|
308
|
+
renameSync(currentPath, nextPath);
|
|
309
|
+
}
|
|
217
310
|
|
|
218
|
-
|
|
311
|
+
return {
|
|
219
312
|
id: parsed.id,
|
|
220
313
|
title: parsed.title,
|
|
221
|
-
status,
|
|
222
|
-
path: relative(repoRoot,
|
|
223
|
-
|
|
224
|
-
changeId: parsedFile.changeId,
|
|
225
|
-
checklistRemaining: parsedFile.checklistRemaining,
|
|
226
|
-
checklistTotal: parsedFile.checklistTotal,
|
|
227
|
-
});
|
|
314
|
+
status: "wip",
|
|
315
|
+
path: relative(repoRoot, nextPath),
|
|
316
|
+
};
|
|
228
317
|
}
|
|
229
318
|
}
|
|
230
319
|
|
|
231
|
-
|
|
320
|
+
throw new Error(`Task ${normalizedId} not found.`);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export const archiveTasksForChange = (schubDir: string, changeId: string) => {
|
|
324
|
+
const normalizedChangeId = changeId.trim();
|
|
325
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
326
|
+
const archiveRoot = join(tasksRoot, "archived");
|
|
327
|
+
mkdirSync(archiveRoot, { recursive: true });
|
|
328
|
+
|
|
329
|
+
const repoRoot = dirname(schubDir);
|
|
330
|
+
const tasks = loadTaskDependencies(schubDir, ACTIVE_TASK_STATUSES).filter(
|
|
331
|
+
(task) => task.changeId === normalizedChangeId,
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
return tasks.map((task) => {
|
|
335
|
+
const currentPath = join(repoRoot, task.path);
|
|
336
|
+
const archivePath = join(archiveRoot, basename(task.path));
|
|
337
|
+
renameSync(currentPath, archivePath);
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
...task,
|
|
341
|
+
status: "archived",
|
|
342
|
+
path: relative(repoRoot, archivePath),
|
|
343
|
+
};
|
|
344
|
+
});
|
|
232
345
|
};
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import type { TaskDependency, TaskGraph, TaskGraphLine, TaskGraphNode } from "./constants";
|
|
2
|
-
import { compareTaskIds
|
|
2
|
+
import { compareTaskIds } from "./sorting";
|
|
3
|
+
|
|
4
|
+
const compareText = (left: string, right: string) => left.localeCompare(right, undefined, { sensitivity: "base" });
|
|
5
|
+
|
|
6
|
+
const taskSortTitle = (task: TaskGraphNode) => {
|
|
7
|
+
const title = trimTaskTitle(task.title);
|
|
8
|
+
return title || task.id;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const compareTaskTitles = (left: TaskGraphNode, right: TaskGraphNode) => {
|
|
12
|
+
const titleCompare = compareText(taskSortTitle(left), taskSortTitle(right));
|
|
13
|
+
if (titleCompare !== 0) {
|
|
14
|
+
return titleCompare;
|
|
15
|
+
}
|
|
16
|
+
return compareText(left.id, right.id);
|
|
17
|
+
};
|
|
3
18
|
|
|
4
19
|
export const buildTaskGraph = (tasks: TaskDependency[]): TaskGraph => {
|
|
5
20
|
const tasksById = new Map(tasks.map((task) => [task.id, task]));
|
|
@@ -25,12 +40,12 @@ export const buildTaskGraph = (tasks: TaskDependency[]): TaskGraph => {
|
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
for (const [id, children] of childrenById) {
|
|
28
|
-
childrenById.set(id, children.sort(
|
|
43
|
+
childrenById.set(id, children.sort(compareTaskTitles));
|
|
29
44
|
}
|
|
30
45
|
|
|
31
46
|
const roots = Array.from(nodes.values())
|
|
32
47
|
.filter((node) => node.dependencyIds.length === 0)
|
|
33
|
-
.sort(
|
|
48
|
+
.sort(compareTaskTitles);
|
|
34
49
|
|
|
35
50
|
return { roots, childrenById };
|
|
36
51
|
};
|
|
@@ -8,5 +8,14 @@ export {
|
|
|
8
8
|
type TaskStatus,
|
|
9
9
|
} from "./constants";
|
|
10
10
|
export { createTask } from "./create";
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
archiveTasksForChange,
|
|
13
|
+
assignTaskToWip,
|
|
14
|
+
findSchubRoot,
|
|
15
|
+
listTasks,
|
|
16
|
+
listTasksForChange,
|
|
17
|
+
loadTaskDependencies,
|
|
18
|
+
updateTaskStatuses,
|
|
19
|
+
} from "./filesystem";
|
|
12
20
|
export { buildTaskGraph, renderTaskGraphLines, trimTaskTitle } from "./graph";
|
|
21
|
+
export { createTaskWorktree } from "./worktree";
|