schub 0.1.2 → 0.1.3
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/dist/index.js +263 -52
- package/package.json +1 -1
- package/skills/create-proposal/SKILL.md +1 -1
- package/skills/create-tasks/SKILL.md +3 -3
- package/skills/review-proposal/SKILL.md +2 -2
- package/src/App.test.tsx +54 -2
- package/src/App.tsx +19 -2
- package/src/changes.test.ts +52 -0
- package/src/changes.ts +30 -7
- package/src/commands/adr.test.ts +1 -1
- package/src/commands/changes.test.ts +134 -12
- package/src/commands/changes.ts +102 -1
- package/src/commands/cookbook.test.ts +1 -1
- package/src/commands/init.test.ts +69 -2
- package/src/commands/init.ts +43 -5
- package/src/commands/review.test.ts +2 -2
- package/src/commands/review.ts +1 -1
- package/src/commands/tasks-create.test.ts +21 -21
- package/src/commands/tasks-list.test.ts +27 -27
- package/src/components/PlanView.test.tsx +14 -14
- package/src/components/StatusView.test.tsx +88 -36
- package/src/components/StatusView.tsx +56 -3
- package/src/features/tasks/create.ts +5 -5
- package/src/features/tasks/filesystem.test.ts +68 -18
- package/src/features/tasks/filesystem.ts +32 -3
- package/src/features/tasks/index.ts +1 -1
- package/src/index.ts +11 -1
- package/src/opencode.ts +6 -0
- package/src/tasks.ts +1 -0
|
@@ -33,7 +33,7 @@ test("plan shows backlog tasks with satisfied dependencies as ready", () => {
|
|
|
33
33
|
const base = mkdtempSync(join(tmpdir(), "schub-plan-view-"));
|
|
34
34
|
const tasksDir = join(base, ".schub", "tasks", "backlog");
|
|
35
35
|
mkdirSync(tasksDir, { recursive: true });
|
|
36
|
-
writeFileSync(join(tasksDir, "
|
|
36
|
+
writeFileSync(join(tasksDir, "T0001_ready-task.md"), "# Task: T0001 Ready Task\n", "utf8");
|
|
37
37
|
|
|
38
38
|
try {
|
|
39
39
|
process.chdir(base);
|
|
@@ -41,7 +41,7 @@ test("plan shows backlog tasks with satisfied dependencies as ready", () => {
|
|
|
41
41
|
const output = stripAnsi(lastFrame() ?? "");
|
|
42
42
|
const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
|
|
43
43
|
|
|
44
|
-
expect(readySection).toContain("
|
|
44
|
+
expect(readySection).toContain("T0001");
|
|
45
45
|
expect(readySection).not.toContain("No tasks ready for implementation.");
|
|
46
46
|
} finally {
|
|
47
47
|
process.chdir(originalCwd);
|
|
@@ -53,7 +53,7 @@ test("plan view omits per-item shortcuts for ready tasks", () => {
|
|
|
53
53
|
const base = mkdtempSync(join(tmpdir(), "schub-plan-actions-"));
|
|
54
54
|
const tasksDir = join(base, ".schub", "tasks", "backlog");
|
|
55
55
|
mkdirSync(tasksDir, { recursive: true });
|
|
56
|
-
writeFileSync(join(tasksDir, "
|
|
56
|
+
writeFileSync(join(tasksDir, "T0010_quick-action.md"), "# Task: T0010 Quick Action\n", "utf8");
|
|
57
57
|
|
|
58
58
|
try {
|
|
59
59
|
process.chdir(base);
|
|
@@ -61,7 +61,7 @@ test("plan view omits per-item shortcuts for ready tasks", () => {
|
|
|
61
61
|
const output = stripAnsi(lastFrame() ?? "");
|
|
62
62
|
const readySection = extractSection(output, "Ready to Implement", "Dependency Plan");
|
|
63
63
|
|
|
64
|
-
expect(readySection).toContain("
|
|
64
|
+
expect(readySection).toContain("T0010");
|
|
65
65
|
expect(readySection).not.toContain("[o open file]");
|
|
66
66
|
expect(readySection).not.toContain("[c copy]");
|
|
67
67
|
} finally {
|
|
@@ -83,29 +83,29 @@ test("plan view refreshes ready list and dependency graph when tasks change", as
|
|
|
83
83
|
try {
|
|
84
84
|
process.chdir(base);
|
|
85
85
|
writeFileSync(
|
|
86
|
-
join(tasksDir, "
|
|
87
|
-
"# Task:
|
|
86
|
+
join(tasksDir, "T0002_waiting-task.md"),
|
|
87
|
+
"# Task: T0002 Waiting Task\n\n**Depends on**: T0003\n",
|
|
88
88
|
"utf8",
|
|
89
89
|
);
|
|
90
|
-
writeFileSync(join(tasksDir, "
|
|
90
|
+
writeFileSync(join(tasksDir, "T0003_dependency.md"), "# Task: T0003 Dependency\n", "utf8");
|
|
91
91
|
const rendered = render(<PlanView refreshIntervalMs={refreshIntervalMs} onCopyId={noopCopy} />);
|
|
92
92
|
unmount = rendered.unmount;
|
|
93
93
|
const initial = stripAnsi(rendered.lastFrame() ?? "");
|
|
94
94
|
const initialReady = extractSection(initial, "Ready to Implement", "Dependency Plan");
|
|
95
|
-
expect(initialReady).not.toContain("
|
|
95
|
+
expect(initialReady).not.toContain("T0002");
|
|
96
96
|
const initialGraph = extractSection(initial, "Dependency Plan");
|
|
97
|
-
expect(initialGraph).toContain("
|
|
98
|
-
expect(initialGraph).toContain("
|
|
97
|
+
expect(initialGraph).toContain("T0002");
|
|
98
|
+
expect(initialGraph).toContain("T0003");
|
|
99
99
|
|
|
100
|
-
renameSync(join(tasksDir, "
|
|
100
|
+
renameSync(join(tasksDir, "T0003_dependency.md"), join(doneDir, "T0003_dependency.md"));
|
|
101
101
|
await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
|
|
102
102
|
|
|
103
103
|
const refreshed = stripAnsi(rendered.lastFrame() ?? "");
|
|
104
104
|
const refreshedReady = extractSection(refreshed, "Ready to Implement", "Dependency Plan");
|
|
105
|
-
expect(refreshedReady).toContain("
|
|
105
|
+
expect(refreshedReady).toContain("T0002");
|
|
106
106
|
const refreshedGraph = extractSection(refreshed, "Dependency Plan");
|
|
107
|
-
expect(refreshedGraph).toContain("
|
|
108
|
-
expect(refreshedGraph).not.toContain("
|
|
107
|
+
expect(refreshedGraph).toContain("T0002");
|
|
108
|
+
expect(refreshedGraph).not.toContain("T0003");
|
|
109
109
|
} finally {
|
|
110
110
|
unmount?.();
|
|
111
111
|
process.chdir(originalCwd);
|
|
@@ -37,20 +37,20 @@ test("status view shows checklist indicator only for wip tasks", () => {
|
|
|
37
37
|
mkdirSync(wipRoot, { recursive: true });
|
|
38
38
|
mkdirSync(readyRoot, { recursive: true });
|
|
39
39
|
|
|
40
|
-
writeTask(wipRoot, "
|
|
41
|
-
writeTask(wipRoot, "
|
|
42
|
-
writeTask(wipRoot, "
|
|
43
|
-
writeTask(readyRoot, "
|
|
40
|
+
writeTask(wipRoot, "T0100", "wip-checklist", "# Task: T0100 Wip Checklist\n\n## Steps\n- [ ] First\n- [x] Second\n");
|
|
41
|
+
writeTask(wipRoot, "T0101", "wip-complete", "# Task: T0101 Wip Complete\n\n## Steps\n- [x] First\n- [x] Second\n");
|
|
42
|
+
writeTask(wipRoot, "T0102", "wip-empty", "# Task: T0102 Wip Empty\n\n## Steps\n- Do the thing\n");
|
|
43
|
+
writeTask(readyRoot, "T0200", "ready-checklist", "# Task: T0200 Ready Checklist\n\n## Steps\n- [ ] First\n");
|
|
44
44
|
|
|
45
45
|
try {
|
|
46
46
|
process.chdir(base);
|
|
47
47
|
const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
|
|
48
48
|
const output = stripAnsi(lastFrame() ?? "");
|
|
49
49
|
|
|
50
|
-
const wipChecklistLine = findLine(output, "
|
|
51
|
-
const wipCompleteLine = findLine(output, "
|
|
52
|
-
const wipEmptyLine = findLine(output, "
|
|
53
|
-
const readyLine = findLine(output, "
|
|
50
|
+
const wipChecklistLine = findLine(output, "T0100");
|
|
51
|
+
const wipCompleteLine = findLine(output, "T0101");
|
|
52
|
+
const wipEmptyLine = findLine(output, "T0102");
|
|
53
|
+
const readyLine = findLine(output, "T0200");
|
|
54
54
|
|
|
55
55
|
expect(wipChecklistLine).toContain("wip checklist");
|
|
56
56
|
expect(wipChecklistLine).toContain("(1/2)");
|
|
@@ -74,11 +74,11 @@ test("status view omits per-item shortcuts for tasks and proposals", () => {
|
|
|
74
74
|
mkdirSync(changesRoot, { recursive: true });
|
|
75
75
|
mkdirSync(readyRoot, { recursive: true });
|
|
76
76
|
|
|
77
|
-
const changeId = "
|
|
77
|
+
const changeId = "C0003_demo-change";
|
|
78
78
|
const displayId = changeDisplayId(changeId);
|
|
79
79
|
|
|
80
80
|
writeProposal(changesRoot, changeId, "Draft");
|
|
81
|
-
writeTask(readyRoot, "
|
|
81
|
+
writeTask(readyRoot, "T0003", "ready-task", "# Task: T0003 Ready Task\n");
|
|
82
82
|
|
|
83
83
|
try {
|
|
84
84
|
process.chdir(base);
|
|
@@ -87,8 +87,8 @@ test("status view omits per-item shortcuts for tasks and proposals", () => {
|
|
|
87
87
|
|
|
88
88
|
expect(findLine(output, displayId)).not.toContain("[o open file]");
|
|
89
89
|
expect(findLine(output, displayId)).not.toContain("[c copy]");
|
|
90
|
-
expect(findLine(output, "
|
|
91
|
-
expect(findLine(output, "
|
|
90
|
+
expect(findLine(output, "T0003")).not.toContain("[o open file]");
|
|
91
|
+
expect(findLine(output, "T0003")).not.toContain("[c copy]");
|
|
92
92
|
} finally {
|
|
93
93
|
process.chdir(originalCwd);
|
|
94
94
|
}
|
|
@@ -102,7 +102,7 @@ test("status view shows proposal titles", () => {
|
|
|
102
102
|
|
|
103
103
|
mkdirSync(changesRoot, { recursive: true });
|
|
104
104
|
|
|
105
|
-
const changeId = "
|
|
105
|
+
const changeId = "C0004_pretty-proposals";
|
|
106
106
|
const changeTitle = "Pretty Print Change Proposals";
|
|
107
107
|
const displayId = changeDisplayId(changeId);
|
|
108
108
|
|
|
@@ -122,6 +122,33 @@ test("status view shows proposal titles", () => {
|
|
|
122
122
|
}
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
test("status view formats default proposal titles from ids", () => {
|
|
126
|
+
const originalCwd = process.cwd();
|
|
127
|
+
const base = mkdtempSync(join(tmpdir(), "schub-status-proposal-id-title-"));
|
|
128
|
+
const schubDir = join(base, ".schub");
|
|
129
|
+
const changesRoot = join(schubDir, "changes");
|
|
130
|
+
|
|
131
|
+
mkdirSync(changesRoot, { recursive: true });
|
|
132
|
+
|
|
133
|
+
const changeId = "C0001_pending-review-quick-action";
|
|
134
|
+
const displayId = changeDisplayId(changeId);
|
|
135
|
+
|
|
136
|
+
writeProposal(changesRoot, changeId, "Pending Review");
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
process.chdir(base);
|
|
140
|
+
const { lastFrame } = render(<StatusView onCopyId={noopCopy} />);
|
|
141
|
+
const output = stripAnsi(lastFrame() ?? "");
|
|
142
|
+
const line = findLine(output, displayId);
|
|
143
|
+
|
|
144
|
+
expect(line).toContain(displayId);
|
|
145
|
+
expect(line).toContain("Pending Review Quick Action");
|
|
146
|
+
expect(line).not.toContain("pending-review-quick-action");
|
|
147
|
+
} finally {
|
|
148
|
+
process.chdir(originalCwd);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
125
152
|
test("status view copies the selected id on c", async () => {
|
|
126
153
|
const originalCwd = process.cwd();
|
|
127
154
|
const base = mkdtempSync(join(tmpdir(), "schub-status-copy-"));
|
|
@@ -133,14 +160,39 @@ test("status view copies the selected id on c", async () => {
|
|
|
133
160
|
};
|
|
134
161
|
|
|
135
162
|
mkdirSync(readyRoot, { recursive: true });
|
|
136
|
-
writeTask(readyRoot, "
|
|
163
|
+
writeTask(readyRoot, "T0010", "copy-task", "# Task: T0010 Copy Task\n");
|
|
137
164
|
|
|
138
165
|
try {
|
|
139
166
|
process.chdir(base);
|
|
140
167
|
const rendered = render(<StatusView onCopyId={recordCopy} />);
|
|
141
168
|
rendered.stdin.write("c");
|
|
142
169
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
143
|
-
expect(copied).toBe("
|
|
170
|
+
expect(copied).toBe("T0010");
|
|
171
|
+
} finally {
|
|
172
|
+
process.chdir(originalCwd);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("status view triggers review on r for pending review proposals", async () => {
|
|
177
|
+
const originalCwd = process.cwd();
|
|
178
|
+
const base = mkdtempSync(join(tmpdir(), "schub-status-review-"));
|
|
179
|
+
const schubDir = join(base, ".schub");
|
|
180
|
+
const changesRoot = join(schubDir, "changes");
|
|
181
|
+
const changeId = "C020_pending-review";
|
|
182
|
+
let reviewed = "";
|
|
183
|
+
const recordReview = (id: string) => {
|
|
184
|
+
reviewed = id;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
mkdirSync(changesRoot, { recursive: true });
|
|
188
|
+
writeProposal(changesRoot, changeId, "Pending Review");
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
process.chdir(base);
|
|
192
|
+
const rendered = render(<StatusView onCopyId={noopCopy} onReview={recordReview} />);
|
|
193
|
+
rendered.stdin.write("r");
|
|
194
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
195
|
+
expect(reviewed).toBe(changeId);
|
|
144
196
|
} finally {
|
|
145
197
|
process.chdir(originalCwd);
|
|
146
198
|
}
|
|
@@ -158,17 +210,17 @@ test("status view shows pending implementation counts and no tasks defined secti
|
|
|
158
210
|
mkdirSync(doneRoot, { recursive: true });
|
|
159
211
|
mkdirSync(changesRoot, { recursive: true });
|
|
160
212
|
|
|
161
|
-
const changeWithTasks = "
|
|
162
|
-
const changeNoTasks = "
|
|
213
|
+
const changeWithTasks = "C0001_with-tasks";
|
|
214
|
+
const changeNoTasks = "C0002_no-tasks";
|
|
163
215
|
const displayWithTasks = changeDisplayId(changeWithTasks);
|
|
164
216
|
const displayNoTasks = changeDisplayId(changeNoTasks);
|
|
165
217
|
|
|
166
218
|
writeProposal(changesRoot, changeWithTasks, "Accepted");
|
|
167
219
|
writeProposal(changesRoot, changeNoTasks, "Accepted");
|
|
168
220
|
|
|
169
|
-
writeTask(backlogRoot, "
|
|
170
|
-
writeTask(backlogRoot, "
|
|
171
|
-
writeTask(doneRoot, "
|
|
221
|
+
writeTask(backlogRoot, "T0010", "first-task", `# Task: T0010 First Task\n\n**Change ID**: ${changeWithTasks}\n`);
|
|
222
|
+
writeTask(backlogRoot, "T0011", "second-task", `# Task: T0011 Second Task\n\n**Change ID**: ${changeWithTasks}\n`);
|
|
223
|
+
writeTask(doneRoot, "T0012", "done-task", `# Task: T0012 Done Task\n\n**Change ID**: ${changeWithTasks}\n`);
|
|
172
224
|
|
|
173
225
|
try {
|
|
174
226
|
process.chdir(base);
|
|
@@ -197,11 +249,11 @@ test("status view hides pending implementation when tasks complete", () => {
|
|
|
197
249
|
mkdirSync(changesRoot, { recursive: true });
|
|
198
250
|
mkdirSync(doneRoot, { recursive: true });
|
|
199
251
|
|
|
200
|
-
const completedChange = "
|
|
252
|
+
const completedChange = "C0003_all-done";
|
|
201
253
|
|
|
202
254
|
writeProposal(changesRoot, completedChange, "Accepted");
|
|
203
|
-
writeTask(doneRoot, "
|
|
204
|
-
writeTask(doneRoot, "
|
|
255
|
+
writeTask(doneRoot, "T0020", "done-task", `# Task: T0020 Done Task\n\n**Change ID**: ${completedChange}\n`);
|
|
256
|
+
writeTask(doneRoot, "T0021", "done-task-two", `# Task: T0021 Done Task\n\n**Change ID**: ${completedChange}\n`);
|
|
205
257
|
|
|
206
258
|
try {
|
|
207
259
|
process.chdir(base);
|
|
@@ -226,12 +278,12 @@ test("status view auto-marks completed accepted changes as done", async () => {
|
|
|
226
278
|
mkdirSync(changesRoot, { recursive: true });
|
|
227
279
|
mkdirSync(doneRoot, { recursive: true });
|
|
228
280
|
|
|
229
|
-
const changeId = "
|
|
281
|
+
const changeId = "C0011_auto-done";
|
|
230
282
|
const proposalPath = join(changesRoot, changeId, "proposal.md");
|
|
231
283
|
|
|
232
284
|
writeProposal(changesRoot, changeId, "Accepted");
|
|
233
|
-
writeTask(doneRoot, "
|
|
234
|
-
writeTask(doneRoot, "
|
|
285
|
+
writeTask(doneRoot, "T0100", "done-task", `# Task: T0100 Done Task\n\n**Change ID**: ${changeId}\n`);
|
|
286
|
+
writeTask(doneRoot, "T0101", "done-task-two", `# Task: T0101 Done Task\n\n**Change ID**: ${changeId}\n`);
|
|
235
287
|
|
|
236
288
|
try {
|
|
237
289
|
process.chdir(base);
|
|
@@ -258,7 +310,7 @@ test("status view does not auto-mark proposals without tasks", async () => {
|
|
|
258
310
|
|
|
259
311
|
mkdirSync(changesRoot, { recursive: true });
|
|
260
312
|
|
|
261
|
-
const changeId = "
|
|
313
|
+
const changeId = "C0012_no-tasks";
|
|
262
314
|
const proposalPath = join(changesRoot, changeId, "proposal.md");
|
|
263
315
|
|
|
264
316
|
writeProposal(changesRoot, changeId, "Accepted");
|
|
@@ -295,11 +347,11 @@ test("status view refreshes when tasks change", async () => {
|
|
|
295
347
|
const output = stripAnsi(rendered.lastFrame() ?? "");
|
|
296
348
|
expect(output).toContain("No active changes or tasks found.");
|
|
297
349
|
|
|
298
|
-
writeTask(readyRoot, "
|
|
350
|
+
writeTask(readyRoot, "T0001", "new-task", "# Task: T0001 New Task\n");
|
|
299
351
|
await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
|
|
300
352
|
|
|
301
353
|
const refreshed = stripAnsi(rendered.lastFrame() ?? "");
|
|
302
|
-
expect(refreshed).toContain("
|
|
354
|
+
expect(refreshed).toContain("T0001");
|
|
303
355
|
} finally {
|
|
304
356
|
unmount?.();
|
|
305
357
|
process.chdir(originalCwd);
|
|
@@ -316,7 +368,7 @@ test("status view refreshes when proposals change", async () => {
|
|
|
316
368
|
|
|
317
369
|
mkdirSync(changesRoot, { recursive: true });
|
|
318
370
|
|
|
319
|
-
const changeId = "
|
|
371
|
+
const changeId = "C0010_refresh-proposal";
|
|
320
372
|
const displayId = changeDisplayId(changeId);
|
|
321
373
|
writeProposal(changesRoot, changeId, "Draft");
|
|
322
374
|
|
|
@@ -352,8 +404,8 @@ test("status view clamps selection when tasks are removed", async () => {
|
|
|
352
404
|
let unmount: (() => void) | undefined;
|
|
353
405
|
|
|
354
406
|
mkdirSync(readyRoot, { recursive: true });
|
|
355
|
-
writeTask(readyRoot, "
|
|
356
|
-
writeTask(readyRoot, "
|
|
407
|
+
writeTask(readyRoot, "T0001", "alpha-task", "# Task: T0001 Alpha Task\n");
|
|
408
|
+
writeTask(readyRoot, "T0002", "beta-task", "# Task: T0002 Beta Task\n");
|
|
357
409
|
|
|
358
410
|
try {
|
|
359
411
|
process.chdir(base);
|
|
@@ -365,14 +417,14 @@ test("status view clamps selection when tasks are removed", async () => {
|
|
|
365
417
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
366
418
|
|
|
367
419
|
const moved = stripAnsi(rendered.lastFrame() ?? "");
|
|
368
|
-
expect(findLine(moved, "
|
|
420
|
+
expect(findLine(moved, "T0002")).toContain(selectionMarker);
|
|
369
421
|
|
|
370
|
-
unlinkSync(join(readyRoot, "
|
|
422
|
+
unlinkSync(join(readyRoot, "T0002_beta-task.md"));
|
|
371
423
|
await new Promise((resolve) => setTimeout(resolve, refreshIntervalMs * 2));
|
|
372
424
|
|
|
373
425
|
const refreshed = stripAnsi(rendered.lastFrame() ?? "");
|
|
374
|
-
expect(findLine(refreshed, "
|
|
375
|
-
expect(refreshed).not.toContain("
|
|
426
|
+
expect(findLine(refreshed, "T0001")).toContain(selectionMarker);
|
|
427
|
+
expect(refreshed).not.toContain("T0002");
|
|
376
428
|
} finally {
|
|
377
429
|
unmount?.();
|
|
378
430
|
process.chdir(originalCwd);
|
|
@@ -4,6 +4,7 @@ import React from "react";
|
|
|
4
4
|
import { type ChangeInfo, listChanges, updateChangeStatus } from "../changes";
|
|
5
5
|
import { findSchubRoot, loadTaskDependencies, type TaskInfo, trimTaskTitle } from "../features/tasks";
|
|
6
6
|
import { openInVsCode } from "../ide";
|
|
7
|
+
import { launchOpencodeReview } from "../opencode";
|
|
7
8
|
|
|
8
9
|
type StatusSortable = {
|
|
9
10
|
id: string;
|
|
@@ -11,9 +12,16 @@ type StatusSortable = {
|
|
|
11
12
|
status: string;
|
|
12
13
|
};
|
|
13
14
|
|
|
15
|
+
type Shortcut = {
|
|
16
|
+
keyLabel: string;
|
|
17
|
+
label: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
14
20
|
type StatusViewProps = {
|
|
15
21
|
refreshIntervalMs?: number;
|
|
16
22
|
onCopyId: (id: string) => void;
|
|
23
|
+
onReview?: (changeId: string) => void;
|
|
24
|
+
onShortcutsChange?: (shortcuts: Shortcut[]) => void;
|
|
17
25
|
};
|
|
18
26
|
|
|
19
27
|
const DEFAULT_REFRESH_INTERVAL_MS = 1000;
|
|
@@ -26,6 +34,7 @@ type ChangeTaskCount = {
|
|
|
26
34
|
};
|
|
27
35
|
|
|
28
36
|
const AUTO_MARK_STATUSES = new Set(["accepted", "wip"]);
|
|
37
|
+
const REVIEW_SHORTCUT: Shortcut = { keyLabel: "r", label: "review" };
|
|
29
38
|
|
|
30
39
|
const compareText = (left: string, right: string): number =>
|
|
31
40
|
left.localeCompare(right, undefined, { sensitivity: "base" });
|
|
@@ -50,10 +59,27 @@ const isTaskItem = (item: ChangeInfo | TaskInfo): item is TaskInfo => {
|
|
|
50
59
|
};
|
|
51
60
|
|
|
52
61
|
const formatChangeId = (value: string) => {
|
|
53
|
-
const match = value.match(/^([Cc]\d{
|
|
62
|
+
const match = value.match(/^([Cc]\d{4})_/);
|
|
54
63
|
return match ? match[1].toUpperCase() : value;
|
|
55
64
|
};
|
|
56
65
|
|
|
66
|
+
const formatChangeTitle = (changeId: string, title: string) => {
|
|
67
|
+
const trimmedTitle = title.trim();
|
|
68
|
+
if (trimmedTitle !== changeId) {
|
|
69
|
+
return trimmedTitle;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const match = changeId.match(/^([Cc]\d{4})_(.+)$/);
|
|
73
|
+
if (!match) {
|
|
74
|
+
return trimmedTitle;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return match[2]
|
|
78
|
+
.split("-")
|
|
79
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
80
|
+
.join(" ");
|
|
81
|
+
};
|
|
82
|
+
|
|
57
83
|
const canAutoMarkChange = (status: string) => AUTO_MARK_STATUSES.has(status.trim().toLowerCase());
|
|
58
84
|
|
|
59
85
|
const autoMarkChangesDone = (schubDir: string) => {
|
|
@@ -185,7 +211,12 @@ const buildStatusData = (schubDir: string | null) => {
|
|
|
185
211
|
};
|
|
186
212
|
};
|
|
187
213
|
|
|
188
|
-
export default function StatusView({
|
|
214
|
+
export default function StatusView({
|
|
215
|
+
refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS,
|
|
216
|
+
onCopyId,
|
|
217
|
+
onReview = launchOpencodeReview,
|
|
218
|
+
onShortcutsChange,
|
|
219
|
+
}: StatusViewProps) {
|
|
189
220
|
const schubDir = findSchubRoot();
|
|
190
221
|
const [, setRefreshTick] = React.useState(0);
|
|
191
222
|
const {
|
|
@@ -214,6 +245,24 @@ export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVA
|
|
|
214
245
|
|
|
215
246
|
const [selection, setSelection] = React.useState(0);
|
|
216
247
|
const totalItems = allItems.length;
|
|
248
|
+
const pendingReviewIds = new Set(pendingReview.map((change) => change.id));
|
|
249
|
+
const selectedItem = totalItems > 0 ? allItems[selection] : null;
|
|
250
|
+
const selectedReviewId =
|
|
251
|
+
selectedItem && !isTaskItem(selectedItem) && pendingReviewIds.has(selectedItem.id) ? selectedItem.id : null;
|
|
252
|
+
const lastReviewIdRef = React.useRef<string | null>(null);
|
|
253
|
+
|
|
254
|
+
React.useEffect(() => {
|
|
255
|
+
if (!onShortcutsChange) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (lastReviewIdRef.current === selectedReviewId) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
lastReviewIdRef.current = selectedReviewId;
|
|
264
|
+
onShortcutsChange(selectedReviewId ? [REVIEW_SHORTCUT] : []);
|
|
265
|
+
}, [onShortcutsChange, selectedReviewId]);
|
|
217
266
|
|
|
218
267
|
React.useEffect(() => {
|
|
219
268
|
if (!schubDir) {
|
|
@@ -260,6 +309,10 @@ export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVA
|
|
|
260
309
|
const selectedItem = allItems[selection];
|
|
261
310
|
onCopyId(selectedItem.id);
|
|
262
311
|
}
|
|
312
|
+
|
|
313
|
+
if (input === "r" && selectedReviewId) {
|
|
314
|
+
onReview(selectedReviewId);
|
|
315
|
+
}
|
|
263
316
|
});
|
|
264
317
|
|
|
265
318
|
if (!schubDir) {
|
|
@@ -287,7 +340,7 @@ export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVA
|
|
|
287
340
|
task && task.status === "wip" && typeof task.checklistTotal === "number"
|
|
288
341
|
? ` (${task.checklistRemaining ?? 0}/${task.checklistTotal})`
|
|
289
342
|
: "";
|
|
290
|
-
const changeTitle = item.title;
|
|
343
|
+
const changeTitle = task ? "" : formatChangeTitle(item.id, item.title);
|
|
291
344
|
const changeDetail = detail ? ` ${detail}` : "";
|
|
292
345
|
const displayTitle = task ? `${title}${checklistIndicator}` : `${changeTitle}${changeDetail}`.trim();
|
|
293
346
|
const displayId = task ? item.id : formatChangeId(item.id);
|
|
@@ -29,13 +29,13 @@ export const createTask = (
|
|
|
29
29
|
const titles = options.titles.map((t) => t.trim()).filter(Boolean);
|
|
30
30
|
|
|
31
31
|
// Validate change ID format (simple check)
|
|
32
|
-
if (!/^(?:[Cc]\d{
|
|
33
|
-
throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g.,
|
|
32
|
+
if (!/^(?:[Cc]\d{4}_)?[a-z0-9]+(?:-[a-z0-9]+)*$/.test(changeId)) {
|
|
33
|
+
throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C0001_add-user-auth).`);
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
// Normalize change ID (ensure C prefix if it matches pattern)
|
|
37
37
|
let normalizedChangeId = changeId;
|
|
38
|
-
const match = changeId.match(/^([Cc])(\d{
|
|
38
|
+
const match = changeId.match(/^([Cc])(\d{4})_(.+)$/);
|
|
39
39
|
if (match) {
|
|
40
40
|
normalizedChangeId = `C${match[2]}_${match[3]}`;
|
|
41
41
|
}
|
|
@@ -86,7 +86,7 @@ export const createTask = (
|
|
|
86
86
|
if (entry.isDirectory()) {
|
|
87
87
|
scan(join(dir, entry.name));
|
|
88
88
|
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
89
|
-
const m = entry.name.match(/(?:^|-)T(\d{
|
|
89
|
+
const m = entry.name.match(/(?:^|-)T(\d{4})(?:_[^.]+)?\.md$/);
|
|
90
90
|
if (m) existingNumbers.add(Number.parseInt(m[1], 10));
|
|
91
91
|
}
|
|
92
92
|
}
|
|
@@ -102,7 +102,7 @@ export const createTask = (
|
|
|
102
102
|
const createdPaths: string[] = [];
|
|
103
103
|
|
|
104
104
|
for (const title of titles) {
|
|
105
|
-
const taskId = `T${nextNumber.toString().padStart(
|
|
105
|
+
const taskId = `T${nextNumber.toString().padStart(4, "0")}`;
|
|
106
106
|
let slug = title
|
|
107
107
|
.toLowerCase()
|
|
108
108
|
.replace(/[^a-z0-9]+/g, "-")
|
|
@@ -1,8 +1,8 @@
|
|
|
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 { listTasks, loadTaskDependencies } from "./filesystem";
|
|
5
|
+
import { archiveTasksForChange, listTasks, loadTaskDependencies } from "./filesystem";
|
|
6
6
|
|
|
7
7
|
const setupChecklistRepo = () => {
|
|
8
8
|
const base = mkdtempSync(join(tmpdir(), "schub-checklist-"));
|
|
@@ -12,24 +12,24 @@ const setupChecklistRepo = () => {
|
|
|
12
12
|
|
|
13
13
|
const tasks = [
|
|
14
14
|
{
|
|
15
|
-
id: "
|
|
15
|
+
id: "T0001",
|
|
16
16
|
slug: "unchecked-only",
|
|
17
|
-
body: `# Task:
|
|
17
|
+
body: `# Task: T0001 Unchecked Only\n\n## Steps\n- [ ] First item\n- [ ] Second item\n`,
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
|
-
id: "
|
|
20
|
+
id: "T0002",
|
|
21
21
|
slug: "checked-only",
|
|
22
|
-
body: `# Task:
|
|
22
|
+
body: `# Task: T0002 Checked Only\n\n## Steps\n- [x] Finished item\n`,
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
|
-
id: "
|
|
25
|
+
id: "T0003",
|
|
26
26
|
slug: "mixed-checklist",
|
|
27
|
-
body: `# Task:
|
|
27
|
+
body: `# Task: T0003 Mixed Checklist\n\n## Steps\n- [ ] Remaining item\n- [x] Done item\n\n## Acceptance\n- [ ] Ignore this\n`,
|
|
28
28
|
},
|
|
29
29
|
{
|
|
30
|
-
id: "
|
|
30
|
+
id: "T0004",
|
|
31
31
|
slug: "missing-checklist",
|
|
32
|
-
body: `# Task:
|
|
32
|
+
body: `# Task: T0004 Missing Checklist\n\n## Steps\n- Do the thing\n- Another item\n\n## Acceptance\n- [ ] Ignore this too\n`,
|
|
33
33
|
},
|
|
34
34
|
];
|
|
35
35
|
|
|
@@ -41,6 +41,36 @@ const setupChecklistRepo = () => {
|
|
|
41
41
|
return schubDir;
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
+
const setupArchiveRepo = () => {
|
|
45
|
+
const base = mkdtempSync(join(tmpdir(), "schub-archive-"));
|
|
46
|
+
const schubDir = join(base, ".schub");
|
|
47
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
48
|
+
const statuses = ["backlog", "wip", "done"];
|
|
49
|
+
|
|
50
|
+
for (const status of statuses) {
|
|
51
|
+
mkdirSync(join(tasksRoot, status), { recursive: true });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const changeId = "C0001_archive-change";
|
|
55
|
+
writeFileSync(
|
|
56
|
+
join(tasksRoot, "backlog", "T0010_backlog-task.md"),
|
|
57
|
+
`# Task: T0010 Backlog Task\n\n**Change ID**: \`${changeId}\`\n`,
|
|
58
|
+
"utf8",
|
|
59
|
+
);
|
|
60
|
+
writeFileSync(
|
|
61
|
+
join(tasksRoot, "wip", "T0011_wip-task.md"),
|
|
62
|
+
`# Task: T0011 Wip Task\n\n**Change ID**: \`${changeId}\`\n`,
|
|
63
|
+
"utf8",
|
|
64
|
+
);
|
|
65
|
+
writeFileSync(
|
|
66
|
+
join(tasksRoot, "done", "T0012_other-task.md"),
|
|
67
|
+
"# Task: T0012 Other Task\n\n**Change ID**: `C0002_other-change`\n",
|
|
68
|
+
"utf8",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return { schubDir, tasksRoot, changeId };
|
|
72
|
+
};
|
|
73
|
+
|
|
44
74
|
const getTaskById = <T extends { id: string }>(tasks: T[], id: string) => {
|
|
45
75
|
return tasks.find((task) => task.id === id);
|
|
46
76
|
};
|
|
@@ -49,10 +79,10 @@ test("listTasks includes Steps-only checklist counts", () => {
|
|
|
49
79
|
const schubDir = setupChecklistRepo();
|
|
50
80
|
const tasks = listTasks(schubDir);
|
|
51
81
|
|
|
52
|
-
const unchecked = getTaskById(tasks, "
|
|
53
|
-
const checked = getTaskById(tasks, "
|
|
54
|
-
const mixed = getTaskById(tasks, "
|
|
55
|
-
const missing = getTaskById(tasks, "
|
|
82
|
+
const unchecked = getTaskById(tasks, "T0001");
|
|
83
|
+
const checked = getTaskById(tasks, "T0002");
|
|
84
|
+
const mixed = getTaskById(tasks, "T0003");
|
|
85
|
+
const missing = getTaskById(tasks, "T0004");
|
|
56
86
|
|
|
57
87
|
expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
|
|
58
88
|
expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
|
|
@@ -65,10 +95,10 @@ test("loadTaskDependencies includes Steps-only checklist counts", () => {
|
|
|
65
95
|
const schubDir = setupChecklistRepo();
|
|
66
96
|
const tasks = loadTaskDependencies(schubDir);
|
|
67
97
|
|
|
68
|
-
const unchecked = getTaskById(tasks, "
|
|
69
|
-
const checked = getTaskById(tasks, "
|
|
70
|
-
const mixed = getTaskById(tasks, "
|
|
71
|
-
const missing = getTaskById(tasks, "
|
|
98
|
+
const unchecked = getTaskById(tasks, "T0001");
|
|
99
|
+
const checked = getTaskById(tasks, "T0002");
|
|
100
|
+
const mixed = getTaskById(tasks, "T0003");
|
|
101
|
+
const missing = getTaskById(tasks, "T0004");
|
|
72
102
|
|
|
73
103
|
expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
|
|
74
104
|
expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
|
|
@@ -76,3 +106,23 @@ test("loadTaskDependencies includes Steps-only checklist counts", () => {
|
|
|
76
106
|
expect(missing?.checklistTotal).toBeUndefined();
|
|
77
107
|
expect(missing?.checklistRemaining).toBeUndefined();
|
|
78
108
|
});
|
|
109
|
+
|
|
110
|
+
test("archiveTasksForChange moves matching tasks to archived", () => {
|
|
111
|
+
const { schubDir, tasksRoot, changeId } = setupArchiveRepo();
|
|
112
|
+
const archived = archiveTasksForChange(schubDir, changeId);
|
|
113
|
+
|
|
114
|
+
const archiveRoot = join(tasksRoot, "archived");
|
|
115
|
+
const backlogTask = join(archiveRoot, "T0010_backlog-task.md");
|
|
116
|
+
const wipTask = join(archiveRoot, "T0011_wip-task.md");
|
|
117
|
+
|
|
118
|
+
expect(existsSync(archiveRoot)).toBe(true);
|
|
119
|
+
expect(existsSync(backlogTask)).toBe(true);
|
|
120
|
+
expect(existsSync(wipTask)).toBe(true);
|
|
121
|
+
expect(existsSync(join(tasksRoot, "backlog", "T0010_backlog-task.md"))).toBe(false);
|
|
122
|
+
expect(existsSync(join(tasksRoot, "wip", "T0011_wip-task.md"))).toBe(false);
|
|
123
|
+
expect(existsSync(join(tasksRoot, "done", "T0012_other-task.md"))).toBe(true);
|
|
124
|
+
|
|
125
|
+
expect(archived.map((task) => task.id)).toEqual(["T0010", "T0011"]);
|
|
126
|
+
expect(archived.every((task) => task.status === "archived")).toBe(true);
|
|
127
|
+
expect(archived[0]?.path).toContain(".schub/tasks/archived/");
|
|
128
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
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
3
|
import { TASK_STATUSES, type TaskDependency, type TaskInfo, type TaskStatus } from "./constants";
|
|
4
4
|
import { compareTaskIds, compareTasks } from "./sorting";
|
|
5
5
|
|
|
@@ -40,6 +40,9 @@ const parseTaskFilename = (fileName: string): { id: string; title: string } | nu
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const id = baseName.slice(0, underscoreIndex);
|
|
43
|
+
if (!/^T\d{4}$/.test(id)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
43
46
|
const titleSlug = baseName.slice(underscoreIndex + 1);
|
|
44
47
|
return { id, title: titleSlug.replace(/-/g, " ") };
|
|
45
48
|
};
|
|
@@ -112,7 +115,7 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
|
|
|
112
115
|
if (dependsMatch) {
|
|
113
116
|
const raw = dependsMatch[1].trim();
|
|
114
117
|
if (!/^none$/i.test(raw)) {
|
|
115
|
-
const matches = raw.match(/\bT\d
|
|
118
|
+
const matches = raw.match(/\bT\d{4}\b/g);
|
|
116
119
|
if (matches) {
|
|
117
120
|
dependsOn = Array.from(new Set(matches));
|
|
118
121
|
}
|
|
@@ -230,3 +233,29 @@ export const loadTaskDependencies = (
|
|
|
230
233
|
|
|
231
234
|
return tasks.sort(compareTasks);
|
|
232
235
|
};
|
|
236
|
+
|
|
237
|
+
const ACTIVE_TASK_STATUSES = TASK_STATUSES.filter((status) => status !== "archived");
|
|
238
|
+
|
|
239
|
+
export const archiveTasksForChange = (schubDir: string, changeId: string) => {
|
|
240
|
+
const normalizedChangeId = changeId.trim();
|
|
241
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
242
|
+
const archiveRoot = join(tasksRoot, "archived");
|
|
243
|
+
mkdirSync(archiveRoot, { recursive: true });
|
|
244
|
+
|
|
245
|
+
const repoRoot = dirname(schubDir);
|
|
246
|
+
const tasks = loadTaskDependencies(schubDir, ACTIVE_TASK_STATUSES).filter(
|
|
247
|
+
(task) => task.changeId === normalizedChangeId,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return tasks.map((task) => {
|
|
251
|
+
const currentPath = join(repoRoot, task.path);
|
|
252
|
+
const archivePath = join(archiveRoot, basename(task.path));
|
|
253
|
+
renameSync(currentPath, archivePath);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
...task,
|
|
257
|
+
status: "archived",
|
|
258
|
+
path: relative(repoRoot, archivePath),
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
};
|