schub 0.1.0 → 0.1.2
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 +68 -0
- package/dist/index.js +1573 -597
- package/package.json +3 -1
- package/skills/create-proposal/SKILL.md +33 -0
- package/skills/create-tasks/SKILL.md +40 -0
- package/skills/implement-task/SKILL.md +84 -0
- package/skills/review-proposal/SKILL.md +37 -0
- package/skills/setup-project/SKILL.md +29 -0
- package/src/App.test.tsx +93 -0
- package/src/App.tsx +62 -10
- package/src/changes.ts +86 -28
- package/src/clipboard.ts +5 -0
- package/src/commands/adr.test.ts +69 -0
- package/src/commands/adr.ts +107 -0
- package/src/commands/changes.test.ts +171 -0
- package/src/commands/changes.ts +163 -0
- package/src/commands/cookbook.test.ts +71 -0
- package/src/commands/cookbook.ts +95 -0
- package/src/commands/eject.test.ts +74 -0
- package/src/commands/eject.ts +100 -0
- package/src/commands/init.test.ts +78 -0
- package/src/commands/init.ts +144 -0
- package/src/commands/project.test.ts +113 -0
- package/src/commands/project.ts +75 -0
- package/src/commands/review.test.ts +100 -0
- package/src/commands/review.ts +231 -0
- package/src/commands/tasks-create.test.ts +172 -0
- package/src/commands/tasks-list.test.ts +177 -0
- package/src/commands/tasks.ts +172 -0
- package/src/components/PlanView.test.tsx +113 -0
- package/src/components/PlanView.tsx +95 -26
- package/src/components/StatusView.test.tsx +380 -0
- package/src/components/StatusView.tsx +233 -83
- package/src/features/tasks/constants.ts +2 -0
- package/src/features/tasks/create.ts +15 -7
- package/src/features/tasks/filesystem.test.ts +78 -0
- package/src/features/tasks/filesystem.ts +61 -7
- package/src/ide.ts +7 -0
- package/src/index.test.ts +23 -0
- package/src/index.ts +60 -383
- package/src/init.test.ts +43 -0
- package/src/init.ts +27 -0
- package/src/project.ts +5 -32
- package/src/schub-root.ts +33 -0
- package/src/templates.ts +18 -0
- package/src/terminal.test.ts +46 -0
- package/templates/create-proposal/cookbook-template.md +37 -0
- package/templates/review-proposal/q&a-template.md +5 -1
- package/templates/templates-parity.test.ts +45 -0
- package/templates/setup-project/review-me-template.md +0 -18
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { dirname, normalize, sep } from "node:path";
|
|
1
2
|
import { Box, Text, useInput } from "ink";
|
|
2
3
|
import React from "react";
|
|
3
|
-
import { type ChangeInfo, listChanges } from "../changes";
|
|
4
|
+
import { type ChangeInfo, listChanges, updateChangeStatus } from "../changes";
|
|
4
5
|
import { findSchubRoot, loadTaskDependencies, type TaskInfo, trimTaskTitle } from "../features/tasks";
|
|
6
|
+
import { openInVsCode } from "../ide";
|
|
5
7
|
|
|
6
8
|
type StatusSortable = {
|
|
7
9
|
id: string;
|
|
@@ -9,6 +11,22 @@ type StatusSortable = {
|
|
|
9
11
|
status: string;
|
|
10
12
|
};
|
|
11
13
|
|
|
14
|
+
type StatusViewProps = {
|
|
15
|
+
refreshIntervalMs?: number;
|
|
16
|
+
onCopyId: (id: string) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const DEFAULT_REFRESH_INTERVAL_MS = 1000;
|
|
20
|
+
const ACTIVE_TASK_STATUSES = ["blocked", "wip", "ready", "backlog"] as const;
|
|
21
|
+
const CHANGE_TASK_STATUSES = [...ACTIVE_TASK_STATUSES, "done"] as const;
|
|
22
|
+
|
|
23
|
+
type ChangeTaskCount = {
|
|
24
|
+
completed: number;
|
|
25
|
+
total: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const AUTO_MARK_STATUSES = new Set(["accepted", "wip"]);
|
|
29
|
+
|
|
12
30
|
const compareText = (left: string, right: string): number =>
|
|
13
31
|
left.localeCompare(right, undefined, { sensitivity: "base" });
|
|
14
32
|
|
|
@@ -26,82 +44,192 @@ const sortByStatusThenTitle = (left: StatusSortable, right: StatusSortable): num
|
|
|
26
44
|
return compareText(left.id, right.id);
|
|
27
45
|
};
|
|
28
46
|
|
|
29
|
-
|
|
30
|
-
const
|
|
47
|
+
const isTaskItem = (item: ChangeInfo | TaskInfo): item is TaskInfo => {
|
|
48
|
+
const parts = normalize(item.path).split(sep);
|
|
49
|
+
return parts.includes("tasks");
|
|
50
|
+
};
|
|
31
51
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const allChanges = listChanges(schubDir);
|
|
67
|
-
const pendingReviewChanges: ChangeInfo[] = [];
|
|
68
|
-
const pendingImplementationChanges: ChangeInfo[] = [];
|
|
69
|
-
const draftChanges: ChangeInfo[] = [];
|
|
70
|
-
|
|
71
|
-
for (const change of allChanges) {
|
|
72
|
-
const normalized = change.status.toLowerCase();
|
|
73
|
-
if (normalized.includes("review")) {
|
|
74
|
-
pendingReviewChanges.push(change);
|
|
75
|
-
} else if (
|
|
76
|
-
activeChangeIds.has(change.id) ||
|
|
77
|
-
normalized.includes("implementing") ||
|
|
78
|
-
normalized.includes("accepted")
|
|
79
|
-
) {
|
|
80
|
-
pendingImplementationChanges.push(change);
|
|
81
|
-
} else if (normalized.includes("draft")) {
|
|
82
|
-
draftChanges.push(change);
|
|
83
|
-
}
|
|
52
|
+
const formatChangeId = (value: string) => {
|
|
53
|
+
const match = value.match(/^([Cc]\d{3})_/);
|
|
54
|
+
return match ? match[1].toUpperCase() : value;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const canAutoMarkChange = (status: string) => AUTO_MARK_STATUSES.has(status.trim().toLowerCase());
|
|
58
|
+
|
|
59
|
+
const autoMarkChangesDone = (schubDir: string) => {
|
|
60
|
+
const allTasks = loadTaskDependencies(schubDir, CHANGE_TASK_STATUSES);
|
|
61
|
+
const changeTaskCounts = new Map<string, ChangeTaskCount>();
|
|
62
|
+
|
|
63
|
+
for (const task of allTasks) {
|
|
64
|
+
if (!task.changeId) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const current = changeTaskCounts.get(task.changeId) ?? { completed: 0, total: 0 };
|
|
69
|
+
const completed = current.completed + (task.status === "done" ? 1 : 0);
|
|
70
|
+
changeTaskCounts.set(task.changeId, { completed, total: current.total + 1 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (changeTaskCounts.size === 0) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const change of listChanges(schubDir)) {
|
|
78
|
+
const counts = changeTaskCounts.get(change.id);
|
|
79
|
+
if (!counts || counts.total === 0 || counts.completed !== counts.total) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (canAutoMarkChange(change.status)) {
|
|
84
|
+
updateChangeStatus(schubDir, change.id, "Done");
|
|
84
85
|
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
85
88
|
|
|
89
|
+
const buildStatusData = (schubDir: string | null) => {
|
|
90
|
+
if (!schubDir) {
|
|
86
91
|
return {
|
|
87
|
-
pendingReview:
|
|
88
|
-
pendingImplementation:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
pendingReview: [],
|
|
93
|
+
pendingImplementation: [],
|
|
94
|
+
pendingImplementationNoTasks: [],
|
|
95
|
+
drafts: [],
|
|
96
|
+
blocked: [],
|
|
97
|
+
wip: [],
|
|
98
|
+
ready: [],
|
|
99
|
+
backlog: [],
|
|
100
|
+
pendingImplementationCounts: new Map<string, string>(),
|
|
94
101
|
};
|
|
95
|
-
}
|
|
102
|
+
}
|
|
96
103
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
const allTasks = loadTaskDependencies(schubDir, CHANGE_TASK_STATUSES);
|
|
105
|
+
|
|
106
|
+
const blockedTasks: TaskInfo[] = [];
|
|
107
|
+
const wipTasks: TaskInfo[] = [];
|
|
108
|
+
const readyTasks: TaskInfo[] = [];
|
|
109
|
+
const backlogTasks: TaskInfo[] = [];
|
|
110
|
+
|
|
111
|
+
const activeChangeIds = new Set<string>();
|
|
112
|
+
const changeTaskCounts = new Map<string, ChangeTaskCount>();
|
|
113
|
+
|
|
114
|
+
for (const task of allTasks) {
|
|
115
|
+
if (task.status === "blocked") blockedTasks.push(task);
|
|
116
|
+
else if (task.status === "wip") wipTasks.push(task);
|
|
117
|
+
else if (task.status === "ready") readyTasks.push(task);
|
|
118
|
+
else if (task.status === "backlog") backlogTasks.push(task);
|
|
119
|
+
|
|
120
|
+
if (task.changeId && task.status !== "done") {
|
|
121
|
+
activeChangeIds.add(task.changeId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (task.changeId) {
|
|
125
|
+
const current = changeTaskCounts.get(task.changeId) ?? { completed: 0, total: 0 };
|
|
126
|
+
const completed = current.completed + (task.status === "done" ? 1 : 0);
|
|
127
|
+
changeTaskCounts.set(task.changeId, { completed, total: current.total + 1 });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const allChanges = listChanges(schubDir);
|
|
132
|
+
const pendingReviewChanges: ChangeInfo[] = [];
|
|
133
|
+
const pendingImplementationChanges: ChangeInfo[] = [];
|
|
134
|
+
const draftChanges: ChangeInfo[] = [];
|
|
135
|
+
|
|
136
|
+
for (const change of allChanges) {
|
|
137
|
+
const normalized = change.status.toLowerCase();
|
|
138
|
+
if (normalized.includes("review")) {
|
|
139
|
+
pendingReviewChanges.push(change);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const counts = changeTaskCounts.get(change.id);
|
|
144
|
+
const allTasksCompleted = Boolean(counts && counts.total > 0 && counts.completed === counts.total);
|
|
145
|
+
|
|
146
|
+
if (activeChangeIds.has(change.id)) {
|
|
147
|
+
pendingImplementationChanges.push(change);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if ((normalized.includes("implementing") || normalized.includes("accepted")) && !allTasksCompleted) {
|
|
152
|
+
pendingImplementationChanges.push(change);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (normalized.includes("draft")) {
|
|
157
|
+
draftChanges.push(change);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pendingImplementationWithTasks: ChangeInfo[] = [];
|
|
162
|
+
const pendingImplementationNoTasks: ChangeInfo[] = [];
|
|
163
|
+
const pendingImplementationCounts = new Map<string, string>();
|
|
164
|
+
|
|
165
|
+
for (const change of pendingImplementationChanges.sort(sortByStatusThenTitle)) {
|
|
166
|
+
const counts = changeTaskCounts.get(change.id);
|
|
167
|
+
if (counts && counts.total > 0) {
|
|
168
|
+
pendingImplementationWithTasks.push(change);
|
|
169
|
+
pendingImplementationCounts.set(change.id, `${counts.completed}/${counts.total}`);
|
|
170
|
+
} else {
|
|
171
|
+
pendingImplementationNoTasks.push(change);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
pendingReview: pendingReviewChanges.sort(sortByStatusThenTitle),
|
|
177
|
+
pendingImplementation: pendingImplementationWithTasks,
|
|
178
|
+
pendingImplementationNoTasks,
|
|
179
|
+
drafts: draftChanges.sort(sortByStatusThenTitle),
|
|
180
|
+
blocked: blockedTasks.sort(sortByStatusThenTitle),
|
|
181
|
+
wip: wipTasks.sort(sortByStatusThenTitle),
|
|
182
|
+
ready: readyTasks.sort(sortByStatusThenTitle),
|
|
183
|
+
backlog: backlogTasks.sort(sortByStatusThenTitle),
|
|
184
|
+
pendingImplementationCounts,
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export default function StatusView({ refreshIntervalMs = DEFAULT_REFRESH_INTERVAL_MS, onCopyId }: StatusViewProps) {
|
|
189
|
+
const schubDir = findSchubRoot();
|
|
190
|
+
const [, setRefreshTick] = React.useState(0);
|
|
191
|
+
const {
|
|
192
|
+
pendingReview,
|
|
193
|
+
pendingImplementation,
|
|
194
|
+
pendingImplementationNoTasks,
|
|
195
|
+
drafts,
|
|
196
|
+
blocked,
|
|
197
|
+
wip,
|
|
198
|
+
ready,
|
|
199
|
+
backlog,
|
|
200
|
+
pendingImplementationCounts,
|
|
201
|
+
} = buildStatusData(schubDir);
|
|
202
|
+
const repoRoot = schubDir ? dirname(schubDir) : "";
|
|
203
|
+
|
|
204
|
+
const allItems = [
|
|
205
|
+
...pendingReview,
|
|
206
|
+
...pendingImplementation,
|
|
207
|
+
...pendingImplementationNoTasks,
|
|
208
|
+
...drafts,
|
|
209
|
+
...blocked,
|
|
210
|
+
...wip,
|
|
211
|
+
...ready,
|
|
212
|
+
...backlog,
|
|
213
|
+
];
|
|
101
214
|
|
|
102
215
|
const [selection, setSelection] = React.useState(0);
|
|
103
216
|
const totalItems = allItems.length;
|
|
104
217
|
|
|
218
|
+
React.useEffect(() => {
|
|
219
|
+
if (!schubDir) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const interval = setInterval(() => {
|
|
224
|
+
autoMarkChangesDone(schubDir);
|
|
225
|
+
setRefreshTick((current) => current + 1);
|
|
226
|
+
}, refreshIntervalMs);
|
|
227
|
+
|
|
228
|
+
return () => {
|
|
229
|
+
clearInterval(interval);
|
|
230
|
+
};
|
|
231
|
+
}, [refreshIntervalMs, schubDir]);
|
|
232
|
+
|
|
105
233
|
React.useEffect(() => {
|
|
106
234
|
if (totalItems === 0) {
|
|
107
235
|
setSelection(0);
|
|
@@ -110,7 +238,7 @@ export default function StatusView() {
|
|
|
110
238
|
setSelection((current) => Math.min(current, totalItems - 1));
|
|
111
239
|
}, [totalItems]);
|
|
112
240
|
|
|
113
|
-
useInput((
|
|
241
|
+
useInput((input, key) => {
|
|
114
242
|
if (totalItems === 0) {
|
|
115
243
|
return;
|
|
116
244
|
}
|
|
@@ -122,6 +250,16 @@ export default function StatusView() {
|
|
|
122
250
|
if (key.upArrow) {
|
|
123
251
|
setSelection((current) => Math.max(current - 1, 0));
|
|
124
252
|
}
|
|
253
|
+
|
|
254
|
+
if (input === "o") {
|
|
255
|
+
const selectedItem = allItems[selection];
|
|
256
|
+
openInVsCode(repoRoot, selectedItem.path);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (input === "c") {
|
|
260
|
+
const selectedItem = allItems[selection];
|
|
261
|
+
onCopyId(selectedItem.id);
|
|
262
|
+
}
|
|
125
263
|
});
|
|
126
264
|
|
|
127
265
|
if (!schubDir) {
|
|
@@ -141,19 +279,27 @@ export default function StatusView() {
|
|
|
141
279
|
);
|
|
142
280
|
}
|
|
143
281
|
|
|
144
|
-
const renderRow = (item: ChangeInfo | TaskInfo, index: number) => {
|
|
282
|
+
const renderRow = (item: ChangeInfo | TaskInfo, index: number, detail?: string) => {
|
|
145
283
|
const selected = index === selection;
|
|
146
|
-
const
|
|
147
|
-
const title =
|
|
284
|
+
const task = isTaskItem(item) ? item : null;
|
|
285
|
+
const title = task ? trimTaskTitle(task.title) : "";
|
|
286
|
+
const checklistIndicator =
|
|
287
|
+
task && task.status === "wip" && typeof task.checklistTotal === "number"
|
|
288
|
+
? ` (${task.checklistRemaining ?? 0}/${task.checklistTotal})`
|
|
289
|
+
: "";
|
|
290
|
+
const changeTitle = item.title;
|
|
291
|
+
const changeDetail = detail ? ` ${detail}` : "";
|
|
292
|
+
const displayTitle = task ? `${title}${checklistIndicator}` : `${changeTitle}${changeDetail}`.trim();
|
|
293
|
+
const displayId = task ? item.id : formatChangeId(item.id);
|
|
148
294
|
|
|
149
295
|
return (
|
|
150
296
|
<Box key={item.id} marginLeft={1}>
|
|
151
297
|
<Text color={selected ? "blue" : "gray"}>{selected ? "›" : " "}</Text>
|
|
152
298
|
<Box marginLeft={1}>
|
|
153
299
|
<Text color="white" bold={selected}>
|
|
154
|
-
{
|
|
300
|
+
{displayId}
|
|
155
301
|
</Text>
|
|
156
|
-
{
|
|
302
|
+
{displayTitle ? <Text color="gray"> {displayTitle}</Text> : null}
|
|
157
303
|
</Box>
|
|
158
304
|
</Box>
|
|
159
305
|
);
|
|
@@ -161,11 +307,11 @@ export default function StatusView() {
|
|
|
161
307
|
|
|
162
308
|
let currentIndex = 0;
|
|
163
309
|
|
|
164
|
-
const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[]) => {
|
|
310
|
+
const renderSection = (title: string, items: (ChangeInfo | TaskInfo)[], detailById?: Map<string, string>) => {
|
|
165
311
|
if (items.length === 0) return null;
|
|
166
312
|
|
|
167
313
|
const sectionStartIndex = currentIndex;
|
|
168
|
-
const sectionItems = items.map((item, i) => renderRow(item, sectionStartIndex + i));
|
|
314
|
+
const sectionItems = items.map((item, i) => renderRow(item, sectionStartIndex + i, detailById?.get(item.id)));
|
|
169
315
|
currentIndex += items.length;
|
|
170
316
|
|
|
171
317
|
return (
|
|
@@ -182,23 +328,27 @@ export default function StatusView() {
|
|
|
182
328
|
<Box flexDirection="column">
|
|
183
329
|
<Box flexDirection="column" marginBottom={1}>
|
|
184
330
|
<Box marginBottom={1}>
|
|
185
|
-
<Text bold color="white"
|
|
331
|
+
<Text bold color="white">
|
|
186
332
|
Change Proposals
|
|
187
333
|
</Text>
|
|
188
334
|
</Box>
|
|
189
335
|
{renderSection("Pending Review", pendingReview)}
|
|
190
|
-
{renderSection("Pending Implementation", pendingImplementation)}
|
|
336
|
+
{renderSection("Pending Implementation", pendingImplementation, pendingImplementationCounts)}
|
|
337
|
+
{renderSection("No Tasks Defined", pendingImplementationNoTasks)}
|
|
191
338
|
{renderSection("Drafts", drafts)}
|
|
192
|
-
{pendingReview.length === 0 &&
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
339
|
+
{pendingReview.length === 0 &&
|
|
340
|
+
pendingImplementation.length === 0 &&
|
|
341
|
+
pendingImplementationNoTasks.length === 0 &&
|
|
342
|
+
drafts.length === 0 && (
|
|
343
|
+
<Box marginLeft={1}>
|
|
344
|
+
<Text color="gray">No active proposals.</Text>
|
|
345
|
+
</Box>
|
|
346
|
+
)}
|
|
197
347
|
</Box>
|
|
198
348
|
|
|
199
349
|
<Box flexDirection="column">
|
|
200
350
|
<Box marginBottom={1}>
|
|
201
|
-
<Text bold color="white"
|
|
351
|
+
<Text bold color="white">
|
|
202
352
|
Tasks
|
|
203
353
|
</Text>
|
|
204
354
|
</Box>
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { resolveTemplatePath } from "../../templates";
|
|
5
|
+
|
|
6
|
+
const BUNDLED_TASK_TEMPLATE_PATH = fileURLToPath(
|
|
7
|
+
new URL("../../../templates/create-tasks/task-template.md", import.meta.url),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
const readTaskTemplate = (schubDir: string) => {
|
|
11
|
+
const templatePath = resolveTemplatePath(
|
|
12
|
+
schubDir,
|
|
13
|
+
join("create-tasks", "task-template.md"),
|
|
14
|
+
BUNDLED_TASK_TEMPLATE_PATH,
|
|
15
|
+
);
|
|
8
16
|
try {
|
|
9
|
-
return readFileSync(
|
|
17
|
+
return readFileSync(templatePath, "utf8");
|
|
10
18
|
} catch {
|
|
11
|
-
throw new Error(`[ERROR] Template not found: ${
|
|
19
|
+
throw new Error(`[ERROR] Template not found: ${templatePath}`);
|
|
12
20
|
}
|
|
13
21
|
};
|
|
14
22
|
|
|
@@ -89,7 +97,7 @@ export const createTask = (
|
|
|
89
97
|
let nextNumber = existingNumbers.size > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
|
90
98
|
const statusDir = join(tasksRoot, status);
|
|
91
99
|
mkdirSync(statusDir, { recursive: true });
|
|
92
|
-
const template = readTaskTemplate();
|
|
100
|
+
const template = readTaskTemplate(schubDir);
|
|
93
101
|
|
|
94
102
|
const createdPaths: string[] = [];
|
|
95
103
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { listTasks, loadTaskDependencies } from "./filesystem";
|
|
6
|
+
|
|
7
|
+
const setupChecklistRepo = () => {
|
|
8
|
+
const base = mkdtempSync(join(tmpdir(), "schub-checklist-"));
|
|
9
|
+
const schubDir = join(base, ".schub");
|
|
10
|
+
const tasksRoot = join(schubDir, "tasks", "backlog");
|
|
11
|
+
mkdirSync(tasksRoot, { recursive: true });
|
|
12
|
+
|
|
13
|
+
const tasks = [
|
|
14
|
+
{
|
|
15
|
+
id: "T001",
|
|
16
|
+
slug: "unchecked-only",
|
|
17
|
+
body: `# Task: T001 Unchecked Only\n\n## Steps\n- [ ] First item\n- [ ] Second item\n`,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "T002",
|
|
21
|
+
slug: "checked-only",
|
|
22
|
+
body: `# Task: T002 Checked Only\n\n## Steps\n- [x] Finished item\n`,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: "T003",
|
|
26
|
+
slug: "mixed-checklist",
|
|
27
|
+
body: `# Task: T003 Mixed Checklist\n\n## Steps\n- [ ] Remaining item\n- [x] Done item\n\n## Acceptance\n- [ ] Ignore this\n`,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "T004",
|
|
31
|
+
slug: "missing-checklist",
|
|
32
|
+
body: `# Task: T004 Missing Checklist\n\n## Steps\n- Do the thing\n- Another item\n\n## Acceptance\n- [ ] Ignore this too\n`,
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
for (const task of tasks) {
|
|
37
|
+
const filePath = join(tasksRoot, `${task.id}_${task.slug}.md`);
|
|
38
|
+
writeFileSync(filePath, task.body, "utf8");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return schubDir;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const getTaskById = <T extends { id: string }>(tasks: T[], id: string) => {
|
|
45
|
+
return tasks.find((task) => task.id === id);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
test("listTasks includes Steps-only checklist counts", () => {
|
|
49
|
+
const schubDir = setupChecklistRepo();
|
|
50
|
+
const tasks = listTasks(schubDir);
|
|
51
|
+
|
|
52
|
+
const unchecked = getTaskById(tasks, "T001");
|
|
53
|
+
const checked = getTaskById(tasks, "T002");
|
|
54
|
+
const mixed = getTaskById(tasks, "T003");
|
|
55
|
+
const missing = getTaskById(tasks, "T004");
|
|
56
|
+
|
|
57
|
+
expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
|
|
58
|
+
expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
|
|
59
|
+
expect(mixed).toMatchObject({ checklistTotal: 2, checklistRemaining: 1 });
|
|
60
|
+
expect(missing?.checklistTotal).toBeUndefined();
|
|
61
|
+
expect(missing?.checklistRemaining).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("loadTaskDependencies includes Steps-only checklist counts", () => {
|
|
65
|
+
const schubDir = setupChecklistRepo();
|
|
66
|
+
const tasks = loadTaskDependencies(schubDir);
|
|
67
|
+
|
|
68
|
+
const unchecked = getTaskById(tasks, "T001");
|
|
69
|
+
const checked = getTaskById(tasks, "T002");
|
|
70
|
+
const mixed = getTaskById(tasks, "T003");
|
|
71
|
+
const missing = getTaskById(tasks, "T004");
|
|
72
|
+
|
|
73
|
+
expect(unchecked).toMatchObject({ checklistTotal: 2, checklistRemaining: 2 });
|
|
74
|
+
expect(checked).toMatchObject({ checklistTotal: 1, checklistRemaining: 0 });
|
|
75
|
+
expect(mixed).toMatchObject({ checklistTotal: 2, checklistRemaining: 1 });
|
|
76
|
+
expect(missing?.checklistTotal).toBeUndefined();
|
|
77
|
+
expect(missing?.checklistRemaining).toBeUndefined();
|
|
78
|
+
});
|
|
@@ -49,6 +49,51 @@ type ParsedTaskFile = {
|
|
|
49
49
|
title: string;
|
|
50
50
|
dependsOn: string[];
|
|
51
51
|
changeId?: string;
|
|
52
|
+
checklistRemaining?: number;
|
|
53
|
+
checklistTotal?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const parseChecklistCounts = (content: string) => {
|
|
57
|
+
const lines = content.split(/\r?\n/);
|
|
58
|
+
let inSteps = false;
|
|
59
|
+
let checklistTotal = 0;
|
|
60
|
+
let checklistRemaining = 0;
|
|
61
|
+
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const headingMatch = line.match(/^##\s+(.*)$/);
|
|
64
|
+
if (headingMatch) {
|
|
65
|
+
const heading = headingMatch[1].trim().toLowerCase();
|
|
66
|
+
if (heading === "steps") {
|
|
67
|
+
inSteps = true;
|
|
68
|
+
} else if (inSteps) {
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!inSteps) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const checklistMatch = line.match(/^\s*-\s+\[( |x|X)\]/);
|
|
79
|
+
if (!checklistMatch) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
checklistTotal += 1;
|
|
84
|
+
if (checklistMatch[1].toLowerCase() !== "x") {
|
|
85
|
+
checklistRemaining += 1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (checklistTotal === 0) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
checklistRemaining,
|
|
95
|
+
checklistTotal,
|
|
96
|
+
};
|
|
52
97
|
};
|
|
53
98
|
|
|
54
99
|
const parseTaskFile = (filePath: string, fallback: { id: string; title: string }): ParsedTaskFile => {
|
|
@@ -59,10 +104,6 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
|
|
|
59
104
|
return { ...fallback, dependsOn: [] };
|
|
60
105
|
}
|
|
61
106
|
|
|
62
|
-
const headerMatch = content.match(/^#\s*Task:\s*(\S+)\s+(.+)$/m);
|
|
63
|
-
const id = headerMatch?.[1] ?? fallback.id;
|
|
64
|
-
const title = headerMatch?.[2]?.trim() ?? fallback.title;
|
|
65
|
-
|
|
66
107
|
const changeIdMatch = content.match(/^\*\*Change ID\*\*:\s*\[?`?([^\]`]+)`?]?/m);
|
|
67
108
|
const changeId = changeIdMatch?.[1]?.trim();
|
|
68
109
|
|
|
@@ -78,7 +119,15 @@ const parseTaskFile = (filePath: string, fallback: { id: string; title: string }
|
|
|
78
119
|
}
|
|
79
120
|
}
|
|
80
121
|
|
|
81
|
-
|
|
122
|
+
const checklistCounts = parseChecklistCounts(content);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
id: fallback.id,
|
|
126
|
+
title: fallback.title,
|
|
127
|
+
dependsOn,
|
|
128
|
+
changeId,
|
|
129
|
+
...(checklistCounts ?? {}),
|
|
130
|
+
};
|
|
82
131
|
};
|
|
83
132
|
|
|
84
133
|
export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TASK_STATUSES): TaskInfo[] => {
|
|
@@ -113,11 +162,14 @@ export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TA
|
|
|
113
162
|
}
|
|
114
163
|
|
|
115
164
|
const filePath = join(statusDir, entry.name);
|
|
165
|
+
const parsedFile = parseTaskFile(filePath, parsed);
|
|
116
166
|
tasks.push({
|
|
117
167
|
id: parsed.id,
|
|
118
168
|
title: parsed.title,
|
|
119
169
|
status,
|
|
120
170
|
path: relative(repoRoot, filePath),
|
|
171
|
+
checklistRemaining: parsedFile.checklistRemaining,
|
|
172
|
+
checklistTotal: parsedFile.checklistTotal,
|
|
121
173
|
});
|
|
122
174
|
}
|
|
123
175
|
}
|
|
@@ -164,12 +216,14 @@ export const loadTaskDependencies = (
|
|
|
164
216
|
const dependsOn = parsedFile.dependsOn.sort(compareTaskIds);
|
|
165
217
|
|
|
166
218
|
tasks.push({
|
|
167
|
-
id:
|
|
168
|
-
title:
|
|
219
|
+
id: parsed.id,
|
|
220
|
+
title: parsed.title,
|
|
169
221
|
status,
|
|
170
222
|
path: relative(repoRoot, filePath),
|
|
171
223
|
dependsOn,
|
|
172
224
|
changeId: parsedFile.changeId,
|
|
225
|
+
checklistRemaining: parsedFile.checklistRemaining,
|
|
226
|
+
checklistTotal: parsedFile.checklistTotal,
|
|
173
227
|
});
|
|
174
228
|
}
|
|
175
229
|
}
|
package/src/ide.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const openInVsCode = (repoRoot: string, relativePath: string) => {
|
|
5
|
+
const targetPath = resolve(repoRoot, relativePath);
|
|
6
|
+
spawn("code", ["-g", targetPath], { stdio: "ignore" }).unref();
|
|
7
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { spawnSync } from "bun";
|
|
5
|
+
|
|
6
|
+
test("schub help lists tasks list", () => {
|
|
7
|
+
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const cliDir = resolve(testDir, "..");
|
|
9
|
+
const result = spawnSync({
|
|
10
|
+
cmd: ["bun", "run", "schub", "--help"],
|
|
11
|
+
cwd: cliDir,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(result.exitCode).toBe(0);
|
|
15
|
+
const stdout = new TextDecoder().decode(result.stdout ?? new Uint8Array());
|
|
16
|
+
expect(stdout).toContain("changes create");
|
|
17
|
+
expect(stdout).toContain("eject");
|
|
18
|
+
expect(stdout).toContain("init");
|
|
19
|
+
expect(stdout).toContain("tasks list");
|
|
20
|
+
expect(stdout).toContain("review complete");
|
|
21
|
+
expect(stdout).not.toContain("lint");
|
|
22
|
+
expect(stdout).not.toContain("format");
|
|
23
|
+
});
|