schub 0.1.3 → 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 +17231 -7669
- package/package.json +5 -2
- package/skills/create-proposal/SKILL.md +4 -0
- package/skills/create-tasks/SKILL.md +2 -1
- package/skills/implement-task/SKILL.md +6 -1
- package/skills/review-proposal/SKILL.md +1 -0
- package/skills/update-roadmap/SKILL.md +23 -0
- package/src/changes.test.ts +119 -5
- package/src/changes.ts +136 -54
- package/src/commands/adr.test.ts +5 -4
- package/src/commands/changes.test.ts +6 -6
- package/src/commands/cookbook.test.ts +5 -4
- package/src/commands/init.ts +5 -0
- package/src/commands/review.test.ts +5 -4
- 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 +1 -1
- package/src/commands/tasks-implement.test.ts +253 -0
- package/src/commands/tasks-implement.ts +121 -0
- 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 +9 -7
- package/src/features/tasks/filesystem.test.ts +221 -4
- package/src/features/tasks/filesystem.ts +124 -40
- 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 +225 -118
- package/src/opencode.test.ts +53 -0
- package/src/opencode.ts +71 -3
- package/src/tasks.ts +1 -0
- package/src/tui/App.test.tsx +418 -0
- package/src/tui/App.tsx +343 -0
- package/src/{components → tui/components}/PlanView.test.tsx +26 -38
- 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 -145
- package/src/App.tsx +0 -172
- package/src/components/PlanView.tsx +0 -160
- package/src/components/StatusView.test.tsx +0 -432
- package/src/components/StatusView.tsx +0 -420
- 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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawnSync } from "bun";
|
|
7
|
+
|
|
8
|
+
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const cliDir = resolve(testDir, "..", "..");
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
|
|
12
|
+
const runTasksUpdate = (schubCwd: string, args: string[] = []) => {
|
|
13
|
+
const result = spawnSync({
|
|
14
|
+
cmd: ["bun", "run", "schub", "tasks", "update", ...args],
|
|
15
|
+
cwd: cliDir,
|
|
16
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
result,
|
|
21
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
22
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const createTaskRepo = () => {
|
|
27
|
+
const base = mkdtempSync(join(tmpdir(), "schub-tasks-update-"));
|
|
28
|
+
const repoRoot = join(base, "repo");
|
|
29
|
+
const cwd = join(repoRoot, "nested", "dir");
|
|
30
|
+
mkdirSync(cwd, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const tasksRoot = join(repoRoot, ".schub", "tasks");
|
|
33
|
+
const statuses = ["backlog", "ready", "wip", "blocked", "done", "archived"];
|
|
34
|
+
|
|
35
|
+
for (const status of statuses) {
|
|
36
|
+
mkdirSync(join(tasksRoot, status), { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { cwd, tasksRoot };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const writeTask = (tasksRoot: string, status: string, id: string, slug: string) => {
|
|
43
|
+
const filePath = join(tasksRoot, status, `${id}_${slug}.md`);
|
|
44
|
+
writeFileSync(filePath, `# Task: ${id} ${slug}\n`, "utf8");
|
|
45
|
+
return filePath;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
test("tasks update moves multiple backlog tasks to ready", () => {
|
|
49
|
+
const { cwd, tasksRoot } = createTaskRepo();
|
|
50
|
+
writeTask(tasksRoot, "backlog", "T0001", "first-task");
|
|
51
|
+
writeTask(tasksRoot, "backlog", "T0002", "second-task");
|
|
52
|
+
|
|
53
|
+
const { result, stdout } = runTasksUpdate(cwd, ["--status", "ready", "--id", "T0001", "--id", "T0002"]);
|
|
54
|
+
|
|
55
|
+
expect(result.exitCode).toBe(0);
|
|
56
|
+
|
|
57
|
+
const readyRoot = join(tasksRoot, "ready");
|
|
58
|
+
const movedFirst = join(readyRoot, "T0001_first-task.md");
|
|
59
|
+
const movedSecond = join(readyRoot, "T0002_second-task.md");
|
|
60
|
+
|
|
61
|
+
expect(existsSync(movedFirst)).toBe(true);
|
|
62
|
+
expect(existsSync(movedSecond)).toBe(true);
|
|
63
|
+
expect(existsSync(join(tasksRoot, "backlog", "T0001_first-task.md"))).toBe(false);
|
|
64
|
+
expect(existsSync(join(tasksRoot, "backlog", "T0002_second-task.md"))).toBe(false);
|
|
65
|
+
|
|
66
|
+
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
67
|
+
expect(lines).toHaveLength(2);
|
|
68
|
+
expect(lines[0]).toContain("[OK]");
|
|
69
|
+
expect(lines[0]).toContain("T0001");
|
|
70
|
+
expect(lines[0]).toContain("ready");
|
|
71
|
+
expect(lines[1]).toContain("[OK]");
|
|
72
|
+
expect(lines[1]).toContain("T0002");
|
|
73
|
+
expect(lines[1]).toContain("ready");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("tasks update moves backlog task to archived", () => {
|
|
77
|
+
const { cwd, tasksRoot } = createTaskRepo();
|
|
78
|
+
writeTask(tasksRoot, "backlog", "T0003", "archive-task");
|
|
79
|
+
|
|
80
|
+
const { result, stdout } = runTasksUpdate(cwd, ["--status", "archived", "--id", "T0003"]);
|
|
81
|
+
|
|
82
|
+
expect(result.exitCode).toBe(0);
|
|
83
|
+
|
|
84
|
+
const archivedRoot = join(tasksRoot, "archived");
|
|
85
|
+
const archivedTask = join(archivedRoot, "T0003_archive-task.md");
|
|
86
|
+
|
|
87
|
+
expect(existsSync(archivedTask)).toBe(true);
|
|
88
|
+
expect(existsSync(join(tasksRoot, "backlog", "T0003_archive-task.md"))).toBe(false);
|
|
89
|
+
expect(stdout).toContain("[OK]");
|
|
90
|
+
expect(stdout).toContain("T0003");
|
|
91
|
+
expect(stdout).toContain("archived");
|
|
92
|
+
});
|
package/src/commands/tasks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { resolveChangeRoot } from "../changes";
|
|
2
|
-
import { createTask, listTasks, TASK_STATUSES, type TaskStatus } from "../features/tasks";
|
|
2
|
+
import { createTask, listTasks, TASK_STATUSES, type TaskStatus, updateTaskStatuses } from "../features/tasks";
|
|
3
3
|
|
|
4
4
|
const parseStatusFilter = (value: string | undefined) => {
|
|
5
5
|
if (!value) {
|
|
@@ -138,6 +138,88 @@ const parseTaskCreateOptions = (args: string[]) => {
|
|
|
138
138
|
return options;
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
+
const BACKLOG_UPDATE_STATUSES = ["ready", "archived"] as const;
|
|
142
|
+
|
|
143
|
+
type BacklogUpdateStatus = (typeof BACKLOG_UPDATE_STATUSES)[number];
|
|
144
|
+
|
|
145
|
+
type TaskUpdateOptions = {
|
|
146
|
+
taskIds: string[];
|
|
147
|
+
status: BacklogUpdateStatus;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const parseTaskUpdateOptions = (args: string[]) => {
|
|
151
|
+
let statusValue: string | undefined;
|
|
152
|
+
const taskIds: string[] = [];
|
|
153
|
+
const unknown: string[] = [];
|
|
154
|
+
|
|
155
|
+
const rejectUnsupported = (flag: string) => {
|
|
156
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
160
|
+
const arg = args[index];
|
|
161
|
+
if (arg === "--status") {
|
|
162
|
+
statusValue = args[index + 1];
|
|
163
|
+
if (!statusValue) {
|
|
164
|
+
throw new Error("Missing value for --status.");
|
|
165
|
+
}
|
|
166
|
+
index += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (arg.startsWith("--status=")) {
|
|
170
|
+
statusValue = arg.slice("--status=".length);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (arg === "--id") {
|
|
174
|
+
const taskId = args[index + 1];
|
|
175
|
+
if (!taskId) {
|
|
176
|
+
throw new Error("Missing value for --id.");
|
|
177
|
+
}
|
|
178
|
+
taskIds.push(taskId);
|
|
179
|
+
index += 1;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (arg.startsWith("--id=")) {
|
|
183
|
+
taskIds.push(arg.slice("--id=".length));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
187
|
+
rejectUnsupported(arg);
|
|
188
|
+
}
|
|
189
|
+
if (arg.startsWith("--schub-root=")) {
|
|
190
|
+
rejectUnsupported("--schub-root");
|
|
191
|
+
}
|
|
192
|
+
if (arg.startsWith("--agent-root=")) {
|
|
193
|
+
rejectUnsupported("--agent-root");
|
|
194
|
+
}
|
|
195
|
+
unknown.push(arg);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (unknown.length > 0) {
|
|
199
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!statusValue) {
|
|
203
|
+
throw new Error("Provide --status.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const normalizedStatus = statusValue.trim().toLowerCase();
|
|
207
|
+
if (!BACKLOG_UPDATE_STATUSES.includes(normalizedStatus as BacklogUpdateStatus)) {
|
|
208
|
+
throw new Error(`Invalid status '${statusValue}'. Use ready or archived.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const normalizedIds = taskIds.map((id) => id.trim()).filter(Boolean);
|
|
212
|
+
if (normalizedIds.length === 0) {
|
|
213
|
+
throw new Error("Provide at least one --id.");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const options: TaskUpdateOptions = {
|
|
217
|
+
taskIds: normalizedIds,
|
|
218
|
+
status: normalizedStatus as BacklogUpdateStatus,
|
|
219
|
+
};
|
|
220
|
+
return options;
|
|
221
|
+
};
|
|
222
|
+
|
|
141
223
|
export const runTasksList = (schubDir: string | null, args: string[]) => {
|
|
142
224
|
if (!schubDir) {
|
|
143
225
|
throw new Error("No .schub directory found.");
|
|
@@ -161,6 +243,19 @@ export const runTasksList = (schubDir: string | null, args: string[]) => {
|
|
|
161
243
|
process.stdout.write(`${lines.join("\n")}\n`);
|
|
162
244
|
};
|
|
163
245
|
|
|
246
|
+
export const runTasksUpdate = (schubDir: string | null, args: string[]) => {
|
|
247
|
+
if (!schubDir) {
|
|
248
|
+
throw new Error("No .schub directory found.");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const options = parseTaskUpdateOptions(args);
|
|
252
|
+
const updated = updateTaskStatuses(schubDir, options.taskIds, options.status);
|
|
253
|
+
|
|
254
|
+
for (const task of updated) {
|
|
255
|
+
process.stdout.write(`[OK] Updated task ${task.id}: backlog -> ${task.status}\n`);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
164
259
|
export const runTasksCreate = (args: string[], startDir: string) => {
|
|
165
260
|
const options = parseTaskCreateOptions(args);
|
|
166
261
|
const schubDir = resolveChangeRoot(startDir);
|
|
@@ -170,3 +265,5 @@ export const runTasksCreate = (args: string[], startDir: string) => {
|
|
|
170
265
|
process.stdout.write(`[OK] Wrote task: ${taskPath}\n`);
|
|
171
266
|
}
|
|
172
267
|
};
|
|
268
|
+
|
|
269
|
+
export { runTasksImplement } from "./tasks-implement";
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createChange, listChangeOverview } from "../../changes";
|
|
5
|
+
import { resolveTemplatePath } from "../../templates";
|
|
6
|
+
|
|
7
|
+
export type RoadmapItem = {
|
|
8
|
+
index: number;
|
|
9
|
+
lineIndex: number;
|
|
10
|
+
story: string;
|
|
11
|
+
proposalRef: string | null;
|
|
12
|
+
proposalId?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type RoadmapFile = {
|
|
16
|
+
path: string;
|
|
17
|
+
lines: string[];
|
|
18
|
+
items: RoadmapItem[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type RoadmapWriteResult = {
|
|
22
|
+
path: string;
|
|
23
|
+
created: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const ROADMAP_PLACEHOLDER = "<PROPOSAL_REF>";
|
|
27
|
+
const ROADMAP_BULLET_PATTERN = /^\s*-\s+(.*)$/;
|
|
28
|
+
const ROADMAP_REF_PATTERN = /^\*\*(.+?)\*\*:\s*(.*)$/;
|
|
29
|
+
const CHANGE_PREFIX_PATTERN = /^([Cc])(\d+)(?:_.+)?$/;
|
|
30
|
+
|
|
31
|
+
const BUNDLED_ROADMAP_TEMPLATE_PATH = fileURLToPath(
|
|
32
|
+
new URL("../../../templates/create-roadmap/roadmap.md", import.meta.url),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const normalizeProposalRef = (value: string) => {
|
|
36
|
+
const trimmed = value.trim();
|
|
37
|
+
if (!trimmed || trimmed === ROADMAP_PLACEHOLDER) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const match = trimmed.match(CHANGE_PREFIX_PATTERN);
|
|
41
|
+
if (!match) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return `C${match[2]}`;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const normalizeChangePrefix = (value: string) => {
|
|
48
|
+
const match = value.trim().match(CHANGE_PREFIX_PATTERN);
|
|
49
|
+
if (!match) {
|
|
50
|
+
throw new Error(`Invalid proposal prefix '${value}'. Use C<number>.`);
|
|
51
|
+
}
|
|
52
|
+
return `C${match[2]}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const ensureStoryPunctuation = (story: string) => {
|
|
56
|
+
if (/[.!?]$/.test(story)) {
|
|
57
|
+
return story;
|
|
58
|
+
}
|
|
59
|
+
return `${story}.`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const parseRoadmapLine = (line: string) => {
|
|
63
|
+
const bulletMatch = line.match(ROADMAP_BULLET_PATTERN);
|
|
64
|
+
if (!bulletMatch) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const content = bulletMatch[1].trim();
|
|
69
|
+
const refMatch = content.match(ROADMAP_REF_PATTERN);
|
|
70
|
+
if (refMatch) {
|
|
71
|
+
const proposalRef = normalizeProposalRef(refMatch[1]);
|
|
72
|
+
return { proposalRef, story: refMatch[2].trim() };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { proposalRef: null, story: content };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const buildChangePrefixMap = (schubDir: string) => {
|
|
79
|
+
const changes = listChangeOverview(schubDir);
|
|
80
|
+
const map = new Map<string, string>();
|
|
81
|
+
|
|
82
|
+
for (const change of changes) {
|
|
83
|
+
const prefixMatch = change.id.match(/^([Cc]\d+)_/);
|
|
84
|
+
if (!prefixMatch) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const prefix = prefixMatch[1].toUpperCase();
|
|
88
|
+
const existing = map.get(prefix);
|
|
89
|
+
if (existing) {
|
|
90
|
+
throw new Error(`Duplicate change prefix '${prefix}' found for ${existing} and ${change.id}.`);
|
|
91
|
+
}
|
|
92
|
+
map.set(prefix, change.id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return map;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const getRoadmapPath = (schubDir: string) => join(schubDir, "roadmap.md");
|
|
99
|
+
|
|
100
|
+
const readRoadmapFile = (schubDir: string): RoadmapFile => {
|
|
101
|
+
const roadmapPath = getRoadmapPath(schubDir);
|
|
102
|
+
if (!existsSync(roadmapPath)) {
|
|
103
|
+
throw new Error(`Roadmap file not found at ${roadmapPath}. Run schub init first.`);
|
|
104
|
+
}
|
|
105
|
+
const content = readFileSync(roadmapPath, "utf8");
|
|
106
|
+
const lines = content.split(/\r?\n/);
|
|
107
|
+
const items: RoadmapItem[] = [];
|
|
108
|
+
let index = 1;
|
|
109
|
+
|
|
110
|
+
for (const [lineIndex, line] of lines.entries()) {
|
|
111
|
+
const parsed = parseRoadmapLine(line);
|
|
112
|
+
if (!parsed) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
items.push({
|
|
116
|
+
index,
|
|
117
|
+
lineIndex,
|
|
118
|
+
story: parsed.story,
|
|
119
|
+
proposalRef: parsed.proposalRef,
|
|
120
|
+
});
|
|
121
|
+
index += 1;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const changeMap = buildChangePrefixMap(schubDir);
|
|
125
|
+
const enriched = items.map((item) => {
|
|
126
|
+
if (!item.proposalRef) {
|
|
127
|
+
return item;
|
|
128
|
+
}
|
|
129
|
+
const proposalId = changeMap.get(item.proposalRef) ?? undefined;
|
|
130
|
+
return { ...item, proposalId };
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return { path: roadmapPath, lines, items: enriched };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const writeRoadmapLines = (roadmapPath: string, lines: string[]) => {
|
|
137
|
+
writeFileSync(roadmapPath, lines.join("\n"), "utf8");
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const appendRoadmapLine = (lines: string[], line: string) => {
|
|
141
|
+
const updated = [...lines];
|
|
142
|
+
const insertIndex = updated.length > 0 && updated[updated.length - 1] === "" ? updated.length - 1 : updated.length;
|
|
143
|
+
updated.splice(insertIndex, 0, line);
|
|
144
|
+
if (updated[updated.length - 1] !== "") {
|
|
145
|
+
updated.push("");
|
|
146
|
+
}
|
|
147
|
+
return updated;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const listRoadmapItems = (schubDir: string) => {
|
|
151
|
+
const roadmap = readRoadmapFile(schubDir);
|
|
152
|
+
return roadmap.items;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const addRoadmapItem = (schubDir: string, story: string) => {
|
|
156
|
+
const trimmed = story.trim();
|
|
157
|
+
if (!trimmed) {
|
|
158
|
+
throw new Error("Provide a roadmap story.");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const roadmap = readRoadmapFile(schubDir);
|
|
162
|
+
const normalizedStory = ensureStoryPunctuation(trimmed);
|
|
163
|
+
const line = `- **${ROADMAP_PLACEHOLDER}**: ${normalizedStory}`;
|
|
164
|
+
const updated = appendRoadmapLine(roadmap.lines, line);
|
|
165
|
+
writeRoadmapLines(roadmap.path, updated);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export const updateRoadmapItemProposal = (schubDir: string, index: number, proposalRef: string) => {
|
|
169
|
+
const roadmap = readRoadmapFile(schubDir);
|
|
170
|
+
const target = roadmap.items.find((item) => item.index === index);
|
|
171
|
+
if (!target) {
|
|
172
|
+
throw new Error(`No roadmap item found for index ${index}.`);
|
|
173
|
+
}
|
|
174
|
+
if (target.proposalRef) {
|
|
175
|
+
throw new Error(`Roadmap item ${index} already has a proposal ref (${target.proposalRef}).`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const normalizedRef = normalizeChangePrefix(proposalRef);
|
|
179
|
+
const duplicate = roadmap.items.find((item) => item.proposalRef === normalizedRef);
|
|
180
|
+
if (duplicate) {
|
|
181
|
+
throw new Error(`Roadmap item ${duplicate.index} already references ${normalizedRef}.`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
roadmap.lines[target.lineIndex] = `- **${normalizedRef}**: ${target.story}`;
|
|
185
|
+
writeRoadmapLines(roadmap.path, roadmap.lines);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const ensureRoadmapFile = (schubDir: string): RoadmapWriteResult => {
|
|
189
|
+
const roadmapPath = getRoadmapPath(schubDir);
|
|
190
|
+
if (existsSync(roadmapPath)) {
|
|
191
|
+
return { path: roadmapPath, created: false };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const templatePath = resolveTemplatePath(
|
|
195
|
+
schubDir,
|
|
196
|
+
join("create-roadmap", "roadmap.md"),
|
|
197
|
+
BUNDLED_ROADMAP_TEMPLATE_PATH,
|
|
198
|
+
);
|
|
199
|
+
const template = readFileSync(templatePath, "utf8");
|
|
200
|
+
writeFileSync(roadmapPath, template, "utf8");
|
|
201
|
+
return { path: roadmapPath, created: true };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const proposeRoadmapItem = (schubDir: string, index: number) => {
|
|
205
|
+
const roadmap = readRoadmapFile(schubDir);
|
|
206
|
+
const target = roadmap.items.find((item) => item.index === index);
|
|
207
|
+
if (!target) {
|
|
208
|
+
throw new Error(`No roadmap item found for index ${index}.`);
|
|
209
|
+
}
|
|
210
|
+
if (target.proposalRef) {
|
|
211
|
+
throw new Error(`Roadmap item ${index} already has a proposal ref (${target.proposalRef}).`);
|
|
212
|
+
}
|
|
213
|
+
if (!target.story.trim()) {
|
|
214
|
+
throw new Error(`Roadmap item ${index} has no story text.`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const proposalPath = createChange(schubDir, {
|
|
218
|
+
title: target.story,
|
|
219
|
+
input: target.story,
|
|
220
|
+
});
|
|
221
|
+
const changeId = basename(dirname(proposalPath));
|
|
222
|
+
const prefixMatch = changeId.match(/^([Cc]\d+)_/);
|
|
223
|
+
if (!prefixMatch) {
|
|
224
|
+
throw new Error(`Unable to derive prefix from change id '${changeId}'.`);
|
|
225
|
+
}
|
|
226
|
+
const proposalRef = prefixMatch[1].toUpperCase();
|
|
227
|
+
updateRoadmapItemProposal(schubDir, index, proposalRef);
|
|
228
|
+
|
|
229
|
+
return { proposalPath, changeId, proposalRef };
|
|
230
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { addRoadmapItem, ensureRoadmapFile, listRoadmapItems, updateRoadmapItemProposal } from "./index";
|
|
6
|
+
|
|
7
|
+
const createRoadmapRepo = () => {
|
|
8
|
+
const base = mkdtempSync(join(tmpdir(), "schub-roadmap-"));
|
|
9
|
+
const schubDir = join(base, ".schub");
|
|
10
|
+
mkdirSync(schubDir, { recursive: true });
|
|
11
|
+
const roadmapPath = join(schubDir, "roadmap.md");
|
|
12
|
+
const roadmap = [
|
|
13
|
+
"---",
|
|
14
|
+
'title: "Roadmap"',
|
|
15
|
+
"---",
|
|
16
|
+
"## Roadmap",
|
|
17
|
+
"",
|
|
18
|
+
"- **<PROPOSAL_REF>**: As a user, I want to sign in.",
|
|
19
|
+
"- **C0001**: As an admin, I want to manage accounts.",
|
|
20
|
+
"",
|
|
21
|
+
].join("\n");
|
|
22
|
+
writeFileSync(roadmapPath, roadmap, "utf8");
|
|
23
|
+
return { schubDir, roadmapPath };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const seedProposal = (schubDir: string, changeId: string) => {
|
|
27
|
+
const changeDir = join(schubDir, "changes", changeId);
|
|
28
|
+
mkdirSync(changeDir, { recursive: true });
|
|
29
|
+
writeFileSync(join(changeDir, "proposal.md"), "---\nstatus: Draft\n---\n# Proposal\n", "utf8");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
test("listRoadmapItems parses items with proposal refs", () => {
|
|
33
|
+
const { schubDir } = createRoadmapRepo();
|
|
34
|
+
const items = listRoadmapItems(schubDir);
|
|
35
|
+
|
|
36
|
+
expect(items).toHaveLength(2);
|
|
37
|
+
expect(items[0]).toMatchObject({ index: 1, proposalRef: null, story: "As a user, I want to sign in." });
|
|
38
|
+
expect(items[1]).toMatchObject({ index: 2, proposalRef: "C0001", story: "As an admin, I want to manage accounts." });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("addRoadmapItem appends a placeholder entry", () => {
|
|
42
|
+
const { schubDir, roadmapPath } = createRoadmapRepo();
|
|
43
|
+
addRoadmapItem(schubDir, "As a guest, I want to browse.");
|
|
44
|
+
|
|
45
|
+
const content = readFileSync(roadmapPath, "utf8");
|
|
46
|
+
expect(content).toContain("- **<PROPOSAL_REF>**: As a guest, I want to browse.");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("updateRoadmapItemProposal replaces placeholder ref", () => {
|
|
50
|
+
const { schubDir, roadmapPath } = createRoadmapRepo();
|
|
51
|
+
updateRoadmapItemProposal(schubDir, 1, "C0002");
|
|
52
|
+
|
|
53
|
+
const content = readFileSync(roadmapPath, "utf8");
|
|
54
|
+
expect(content).toContain("- **C0002**: As a user, I want to sign in.");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("ensureRoadmapFile writes the bundled template", () => {
|
|
58
|
+
const base = mkdtempSync(join(tmpdir(), "schub-roadmap-template-"));
|
|
59
|
+
const schubDir = join(base, ".schub");
|
|
60
|
+
mkdirSync(schubDir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
const templatePath = fileURLToPath(new URL("../../../templates/create-roadmap/roadmap.md", import.meta.url));
|
|
63
|
+
const template = readFileSync(templatePath, "utf8");
|
|
64
|
+
const result = ensureRoadmapFile(schubDir);
|
|
65
|
+
|
|
66
|
+
const content = readFileSync(result.path, "utf8");
|
|
67
|
+
expect(result.created).toBe(true);
|
|
68
|
+
expect(content).toBe(template);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("listRoadmapItems throws when proposal prefixes are duplicated", () => {
|
|
72
|
+
const { schubDir } = createRoadmapRepo();
|
|
73
|
+
seedProposal(schubDir, "C0001_first-change");
|
|
74
|
+
seedProposal(schubDir, "C0001_second-change");
|
|
75
|
+
|
|
76
|
+
expect(() => listRoadmapItems(schubDir)).toThrow("Duplicate change prefix");
|
|
77
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
import { readFrontmatter } from "../../frontmatter";
|
|
4
5
|
import { resolveTemplatePath } from "../../templates";
|
|
5
6
|
|
|
6
7
|
const BUNDLED_TASK_TEMPLATE_PATH = fileURLToPath(
|
|
@@ -29,13 +30,13 @@ export const createTask = (
|
|
|
29
30
|
const titles = options.titles.map((t) => t.trim()).filter(Boolean);
|
|
30
31
|
|
|
31
32
|
// 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.,
|
|
33
|
+
if (!/^(?:[Cc]\d+_)?[a-z0-9]+(?:-[a-z0-9]+)*$/.test(changeId)) {
|
|
34
|
+
throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C1_add-user-auth).`);
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
// Normalize change ID (ensure C prefix if it matches pattern)
|
|
37
38
|
let normalizedChangeId = changeId;
|
|
38
|
-
const match = changeId.match(/^([Cc])(\d
|
|
39
|
+
const match = changeId.match(/^([Cc])(\d+)_(.+)$/);
|
|
39
40
|
if (match) {
|
|
40
41
|
normalizedChangeId = `C${match[2]}_${match[3]}`;
|
|
41
42
|
}
|
|
@@ -60,12 +61,13 @@ export const createTask = (
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
const content = readFileSync(proposalPath, "utf8");
|
|
63
|
-
const
|
|
64
|
-
const
|
|
64
|
+
const { data } = readFrontmatter(content);
|
|
65
|
+
const proposalStatusValue = data.status;
|
|
66
|
+
const proposalStatus = typeof proposalStatusValue === "string" ? proposalStatusValue.trim() : "";
|
|
65
67
|
|
|
66
68
|
if (!proposalStatus) {
|
|
67
69
|
throw new Error(
|
|
68
|
-
`Proposal status not found in ${proposalPath}.\nAdd a '
|
|
70
|
+
`Proposal status not found in ${proposalPath}.\nAdd a 'status' field in frontmatter before scaffolding tasks.`,
|
|
69
71
|
);
|
|
70
72
|
}
|
|
71
73
|
if (proposalStatus.toLowerCase() !== "accepted") {
|
|
@@ -86,7 +88,7 @@ export const createTask = (
|
|
|
86
88
|
if (entry.isDirectory()) {
|
|
87
89
|
scan(join(dir, entry.name));
|
|
88
90
|
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
89
|
-
const m = entry.name.match(/(?:^|-)T(\d
|
|
91
|
+
const m = entry.name.match(/(?:^|-)T(\d+)(?:_[^.]+)?\.md$/);
|
|
90
92
|
if (m) existingNumbers.add(Number.parseInt(m[1], 10));
|
|
91
93
|
}
|
|
92
94
|
}
|