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
package/src/changes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join, relative } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { type FrontmatterData, readFrontmatter, updateFrontmatterValue } from "./frontmatter";
|
|
4
5
|
import { resolveSchubRoot } from "./schub-root";
|
|
5
6
|
import { resolveTemplatePath } from "./templates";
|
|
6
7
|
|
|
@@ -11,8 +12,19 @@ export type ChangeInfo = {
|
|
|
11
12
|
path: string;
|
|
12
13
|
};
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
export type ChangeOverviewInfo = ChangeInfo & {
|
|
16
|
+
statusLabel: string;
|
|
17
|
+
statusOrder: number;
|
|
18
|
+
};
|
|
19
|
+
export type ChangeDetail = {
|
|
20
|
+
changeId: string;
|
|
21
|
+
created: string;
|
|
22
|
+
status: string;
|
|
23
|
+
input: string;
|
|
24
|
+
summary: string;
|
|
25
|
+
};
|
|
26
|
+
const CHANGE_ID_PATTERN = /^(?:[Cc]\d+_)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
27
|
+
const SUMMARY_FALLBACK = "No summary provided.";
|
|
16
28
|
const isDirectory = (path: string) => {
|
|
17
29
|
try {
|
|
18
30
|
return statSync(path).isDirectory();
|
|
@@ -20,28 +32,52 @@ const isDirectory = (path: string) => {
|
|
|
20
32
|
return false;
|
|
21
33
|
}
|
|
22
34
|
};
|
|
23
|
-
|
|
35
|
+
const readFrontmatterString = (data: FrontmatterData, key: string) => {
|
|
36
|
+
const value = data[key];
|
|
37
|
+
if (typeof value === "string") {
|
|
38
|
+
return value.trim();
|
|
39
|
+
}
|
|
40
|
+
return "";
|
|
41
|
+
};
|
|
24
42
|
const parseProposal = (content: string, changeId: string) => {
|
|
25
43
|
const titleMatch = content.match(/^#\s+Proposal\s+-\s+(.*)$/m);
|
|
26
|
-
const
|
|
44
|
+
const { data } = readFrontmatter(content);
|
|
45
|
+
const status = readFrontmatterString(data, "status");
|
|
27
46
|
|
|
28
47
|
return {
|
|
29
48
|
title: titleMatch?.[1]?.trim() ?? changeId,
|
|
30
|
-
status:
|
|
49
|
+
status: status || "unknown",
|
|
31
50
|
};
|
|
32
51
|
};
|
|
52
|
+
const parseProposalSummary = (content: string) => {
|
|
53
|
+
const summaryMatch = content.match(/(?:^|\n)##\s+Summary[^\S\r\n]*\n([\s\S]*?)(?:\n##\s|\n#\s|$)/);
|
|
54
|
+
const summary = summaryMatch?.[1]?.trim() ?? "";
|
|
55
|
+
return summary || SUMMARY_FALLBACK;
|
|
56
|
+
};
|
|
57
|
+
const parseProposalDetail = (content: string, changeId: string) => {
|
|
58
|
+
const { data, body } = readFrontmatter(content);
|
|
59
|
+
const status = readFrontmatterString(data, "status");
|
|
60
|
+
const storedChangeId = readFrontmatterString(data, "change_id");
|
|
61
|
+
const created = readFrontmatterString(data, "created");
|
|
62
|
+
const input = readFrontmatterString(data, "input");
|
|
33
63
|
|
|
64
|
+
return {
|
|
65
|
+
changeId: storedChangeId || changeId,
|
|
66
|
+
created,
|
|
67
|
+
status: status || "unknown",
|
|
68
|
+
input,
|
|
69
|
+
summary: parseProposalSummary(body),
|
|
70
|
+
};
|
|
71
|
+
};
|
|
34
72
|
export const normalizeChangeId = (value: string) => {
|
|
35
73
|
const trimmed = value.trim();
|
|
36
|
-
const match = trimmed.match(/^([Cc])(\d
|
|
74
|
+
const match = trimmed.match(/^([Cc])(\d+)_(.+)$/);
|
|
37
75
|
if (match) {
|
|
38
76
|
return `C${match[2]}_${match[3]}`;
|
|
39
77
|
}
|
|
40
78
|
return trimmed;
|
|
41
79
|
};
|
|
42
|
-
|
|
43
80
|
export const isValidChangeId = (value: string) => CHANGE_ID_PATTERN.test(value.trim());
|
|
44
|
-
|
|
45
81
|
export const readChangeSummary = (schubDir: string, changeId: string) => {
|
|
46
82
|
const trimmed = changeId.trim();
|
|
47
83
|
if (!trimmed) {
|
|
@@ -49,7 +85,7 @@ export const readChangeSummary = (schubDir: string, changeId: string) => {
|
|
|
49
85
|
}
|
|
50
86
|
|
|
51
87
|
if (!isValidChangeId(trimmed)) {
|
|
52
|
-
throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g.,
|
|
88
|
+
throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C1_add-user-auth).`);
|
|
53
89
|
}
|
|
54
90
|
|
|
55
91
|
const normalized = normalizeChangeId(trimmed);
|
|
@@ -72,7 +108,19 @@ export const readChangeSummary = (schubDir: string, changeId: string) => {
|
|
|
72
108
|
proposalPath,
|
|
73
109
|
};
|
|
74
110
|
};
|
|
111
|
+
export const readChangeDetail = (schubDir: string, changeId: string) => {
|
|
112
|
+
const summary = readChangeSummary(schubDir, changeId);
|
|
113
|
+
const content = readFileSync(summary.proposalPath, "utf8");
|
|
114
|
+
const detail = parseProposalDetail(content, summary.changeId);
|
|
75
115
|
|
|
116
|
+
return {
|
|
117
|
+
changeId: detail.changeId,
|
|
118
|
+
created: detail.created,
|
|
119
|
+
status: detail.status,
|
|
120
|
+
input: detail.input,
|
|
121
|
+
summary: detail.summary,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
76
124
|
export const updateChangeStatus = (schubDir: string, changeId: string, status: string) => {
|
|
77
125
|
const nextStatus = status.trim();
|
|
78
126
|
if (!nextStatus) {
|
|
@@ -81,16 +129,16 @@ export const updateChangeStatus = (schubDir: string, changeId: string, status: s
|
|
|
81
129
|
|
|
82
130
|
const summary = readChangeSummary(schubDir, changeId);
|
|
83
131
|
const content = readFileSync(summary.proposalPath, "utf8");
|
|
84
|
-
const
|
|
85
|
-
const previousStatus =
|
|
132
|
+
const { data } = readFrontmatter(content);
|
|
133
|
+
const previousStatus = readFrontmatterString(data, "status");
|
|
86
134
|
|
|
87
135
|
if (!previousStatus) {
|
|
88
136
|
throw new Error(
|
|
89
|
-
`Proposal status not found in ${summary.proposalPath}.\nAdd a '
|
|
137
|
+
`Proposal status not found in ${summary.proposalPath}.\nAdd a 'status' field in frontmatter before updating status.`,
|
|
90
138
|
);
|
|
91
139
|
}
|
|
92
140
|
|
|
93
|
-
const updated = content
|
|
141
|
+
const updated = updateFrontmatterValue(content, "status", nextStatus);
|
|
94
142
|
writeFileSync(summary.proposalPath, updated, "utf8");
|
|
95
143
|
|
|
96
144
|
return {
|
|
@@ -100,32 +148,46 @@ export const updateChangeStatus = (schubDir: string, changeId: string, status: s
|
|
|
100
148
|
status: nextStatus,
|
|
101
149
|
};
|
|
102
150
|
};
|
|
151
|
+
export const archiveChange = (schubDir: string, changeId: string) => {
|
|
152
|
+
const summary = readChangeSummary(schubDir, changeId);
|
|
153
|
+
const archiveRoot = join(schubDir, "archive", "changes");
|
|
154
|
+
const archivePath = join(archiveRoot, summary.changeId);
|
|
103
155
|
|
|
156
|
+
if (existsSync(archivePath)) {
|
|
157
|
+
throw new Error(`Archive already exists: ${archivePath}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
mkdirSync(archiveRoot, { recursive: true });
|
|
161
|
+
|
|
162
|
+
const updated = updateChangeStatus(schubDir, summary.changeId, "Archived");
|
|
163
|
+
renameSync(summary.changeDir, archivePath);
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
changeId: updated.changeId,
|
|
167
|
+
previousStatus: updated.previousStatus,
|
|
168
|
+
status: updated.status,
|
|
169
|
+
proposalPath: join(archivePath, "proposal.md"),
|
|
170
|
+
archivePath,
|
|
171
|
+
};
|
|
172
|
+
};
|
|
104
173
|
const changeNumber = (id: string) => {
|
|
105
174
|
const match = id.match(/\d+/);
|
|
106
175
|
return match ? Number(match[0]) : Number.POSITIVE_INFINITY;
|
|
107
176
|
};
|
|
108
|
-
|
|
109
|
-
export const listChanges = (schubDir: string) => {
|
|
110
|
-
const changesRoot = join(schubDir, "changes");
|
|
177
|
+
const readChangesFromRoot = (changesRoot: string, repoRoot: string) => {
|
|
111
178
|
if (!existsSync(changesRoot) || !isDirectory(changesRoot)) {
|
|
112
179
|
return [];
|
|
113
180
|
}
|
|
114
|
-
|
|
115
|
-
const repoRoot = dirname(schubDir);
|
|
116
181
|
const entries = readdirSync(changesRoot, { withFileTypes: true });
|
|
117
182
|
const changes: ChangeInfo[] = [];
|
|
118
|
-
|
|
119
183
|
for (const entry of entries) {
|
|
120
184
|
if (!entry.isDirectory()) {
|
|
121
185
|
continue;
|
|
122
186
|
}
|
|
123
|
-
|
|
124
187
|
const proposalPath = join(changesRoot, entry.name, "proposal.md");
|
|
125
188
|
if (!existsSync(proposalPath)) {
|
|
126
189
|
continue;
|
|
127
190
|
}
|
|
128
|
-
|
|
129
191
|
const content = readFileSync(proposalPath, "utf8");
|
|
130
192
|
const parsed = parseProposal(content, entry.name);
|
|
131
193
|
changes.push({
|
|
@@ -135,58 +197,96 @@ export const listChanges = (schubDir: string) => {
|
|
|
135
197
|
path: relative(repoRoot, proposalPath),
|
|
136
198
|
});
|
|
137
199
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
200
|
+
return changes;
|
|
201
|
+
};
|
|
202
|
+
export const listChanges = (schubDir: string) => {
|
|
203
|
+
const repoRoot = dirname(schubDir);
|
|
204
|
+
const changes = readChangesFromRoot(join(schubDir, "changes"), repoRoot);
|
|
205
|
+
return changes.sort((left, right) => {
|
|
206
|
+
const numberDiff = changeNumber(left.id) - changeNumber(right.id);
|
|
141
207
|
if (numberDiff !== 0) {
|
|
142
208
|
return numberDiff;
|
|
143
209
|
}
|
|
144
|
-
return
|
|
210
|
+
return left.id.localeCompare(right.id);
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
const STATUS_GROUPS = [
|
|
214
|
+
{ label: "Draft", match: "draft" },
|
|
215
|
+
{ label: "Pending Review", match: "review" },
|
|
216
|
+
{ label: "Accepted", match: "accepted" },
|
|
217
|
+
{ label: "Implementing", match: "implement" },
|
|
218
|
+
{ label: "Done", match: "done" },
|
|
219
|
+
{ label: "Archived", match: "archiv" },
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
const normalizeStatusLabel = (status: string) => {
|
|
223
|
+
const trimmed = status.trim();
|
|
224
|
+
const normalized = trimmed.toLowerCase();
|
|
225
|
+
for (const [index, group] of STATUS_GROUPS.entries()) {
|
|
226
|
+
if (normalized.includes(group.match)) {
|
|
227
|
+
return { label: group.label, order: index };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const label = trimmed
|
|
231
|
+
.split(/\s+/)
|
|
232
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
233
|
+
.join(" ");
|
|
234
|
+
return { label: label || "Unknown", order: STATUS_GROUPS.length };
|
|
235
|
+
};
|
|
236
|
+
export const listChangeOverview = (schubDir: string) => {
|
|
237
|
+
const repoRoot = dirname(schubDir);
|
|
238
|
+
const combined = new Map<string, ChangeInfo>();
|
|
239
|
+
for (const root of [join(schubDir, "changes"), join(schubDir, "archive", "changes")]) {
|
|
240
|
+
for (const change of readChangesFromRoot(root, repoRoot)) {
|
|
241
|
+
if (!combined.has(change.id)) {
|
|
242
|
+
combined.set(change.id, change);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return Array.from(combined.values()).map((change) => {
|
|
247
|
+
const normalized = normalizeStatusLabel(change.status);
|
|
248
|
+
return {
|
|
249
|
+
...change,
|
|
250
|
+
statusLabel: normalized.label,
|
|
251
|
+
statusOrder: normalized.order,
|
|
252
|
+
};
|
|
145
253
|
});
|
|
146
254
|
};
|
|
147
|
-
|
|
148
255
|
const slugify = (value: string) => {
|
|
149
256
|
let slug = value.trim().toLowerCase();
|
|
150
257
|
slug = slug.replace(/[^a-z0-9]+/g, "-");
|
|
151
258
|
slug = slug.replace(/-{2,}/g, "-");
|
|
152
259
|
return slug.replace(/^-|-$/g, "");
|
|
153
260
|
};
|
|
154
|
-
|
|
155
261
|
const splitPrefixedChangeId = (changeId: string) => {
|
|
156
|
-
const match = changeId.match(/^([Cc])(\d
|
|
262
|
+
const match = changeId.match(/^([Cc])(\d+)_([a-z0-9]+(?:-[a-z0-9]+)*)$/);
|
|
157
263
|
if (match) {
|
|
158
264
|
return { prefix: match[2], slug: match[3] };
|
|
159
265
|
}
|
|
160
266
|
return { prefix: null, slug: changeId };
|
|
161
267
|
};
|
|
162
|
-
|
|
163
268
|
const nextChangePrefix = (schubDir: string) => {
|
|
164
269
|
const changesRoot = join(schubDir, "changes");
|
|
165
270
|
const archiveRoot = join(schubDir, "archive", "changes");
|
|
166
271
|
const prefixes: number[] = [];
|
|
167
|
-
|
|
168
272
|
const scan = (root: string) => {
|
|
169
273
|
if (!existsSync(root) || !isDirectory(root)) return;
|
|
170
274
|
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
171
275
|
if (!entry.isDirectory()) {
|
|
172
276
|
continue;
|
|
173
277
|
}
|
|
174
|
-
const match = entry.name.match(/^[Cc](\d
|
|
278
|
+
const match = entry.name.match(/^[Cc](\d+)_/);
|
|
175
279
|
if (match) {
|
|
176
280
|
prefixes.push(Number.parseInt(match[1], 10));
|
|
177
281
|
}
|
|
178
282
|
}
|
|
179
283
|
};
|
|
180
|
-
|
|
181
284
|
scan(changesRoot);
|
|
182
285
|
scan(archiveRoot);
|
|
183
|
-
|
|
184
286
|
const next = prefixes.length > 0 ? Math.max(...prefixes) + 1 : 1;
|
|
185
|
-
return next.toString().padStart(
|
|
287
|
+
return next.toString().padStart(4, "0");
|
|
186
288
|
};
|
|
187
|
-
|
|
188
289
|
const CHANGE_PREFIX = "C";
|
|
189
|
-
|
|
190
290
|
const BUNDLED_PROPOSAL_TEMPLATE_PATH = fileURLToPath(
|
|
191
291
|
new URL("../templates/create-proposal/proposal-template.md", import.meta.url),
|
|
192
292
|
);
|
|
@@ -203,31 +303,36 @@ const readProposalTemplate = (schubDir: string) => {
|
|
|
203
303
|
throw new Error(`[ERROR] Template not found: ${templatePath}`);
|
|
204
304
|
}
|
|
205
305
|
};
|
|
206
|
-
|
|
207
306
|
const isValidSlug = (value: string) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value);
|
|
208
|
-
|
|
209
307
|
const changeExists = (schubDir: string, changeId: string) => {
|
|
210
308
|
const active = join(schubDir, "changes", changeId);
|
|
211
309
|
if (existsSync(active)) {
|
|
212
310
|
return true;
|
|
213
311
|
}
|
|
214
|
-
|
|
215
312
|
const archiveRoot = join(schubDir, "archive", "changes");
|
|
216
313
|
if (!existsSync(archiveRoot) || !isDirectory(archiveRoot)) {
|
|
217
314
|
return false;
|
|
218
315
|
}
|
|
219
|
-
|
|
220
316
|
for (const entry of readdirSync(archiveRoot, { withFileTypes: true })) {
|
|
221
317
|
if (entry.isDirectory() && entry.name.includes(changeId)) {
|
|
222
318
|
return true;
|
|
223
319
|
}
|
|
224
320
|
}
|
|
225
|
-
|
|
226
321
|
return false;
|
|
227
322
|
};
|
|
228
|
-
|
|
323
|
+
const findChangeByPrefix = (schubDir: string, prefix: string) => {
|
|
324
|
+
const normalizedPrefix = prefix.toUpperCase();
|
|
325
|
+
for (const root of [join(schubDir, "changes"), join(schubDir, "archive", "changes")]) {
|
|
326
|
+
if (!existsSync(root) || !isDirectory(root)) continue;
|
|
327
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
328
|
+
if (entry.isDirectory() && entry.name.toUpperCase().startsWith(`${normalizedPrefix}_`)) {
|
|
329
|
+
return entry.name;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
};
|
|
229
335
|
export const resolveChangeRoot = resolveSchubRoot;
|
|
230
|
-
|
|
231
336
|
export const createChange = (
|
|
232
337
|
schubDir: string,
|
|
233
338
|
options: { changeId?: string; title?: string; input?: string; overwrite?: boolean },
|
|
@@ -235,7 +340,6 @@ export const createChange = (
|
|
|
235
340
|
let changeId = (options.changeId || "").trim();
|
|
236
341
|
let title = (options.title || "").trim();
|
|
237
342
|
const input = (options.input || "").trim();
|
|
238
|
-
|
|
239
343
|
if (!changeId) {
|
|
240
344
|
if (!title) {
|
|
241
345
|
throw new Error("Provide --change-id or --title.");
|
|
@@ -246,13 +350,11 @@ export const createChange = (
|
|
|
246
350
|
}
|
|
247
351
|
console.warn(`[WARN] Derived change-id '${changeId}' from --title. Prefer verb-led ids.`);
|
|
248
352
|
}
|
|
249
|
-
|
|
250
353
|
const originalChangeId = changeId;
|
|
251
354
|
const { prefix: existingPrefix, slug } = splitPrefixedChangeId(changeId);
|
|
252
355
|
if (!isValidSlug(slug)) {
|
|
253
356
|
throw new Error(`Invalid change-id '${changeId}'. Use kebab-case (lowercase letters/digits and hyphens).`);
|
|
254
357
|
}
|
|
255
|
-
|
|
256
358
|
if (!existingPrefix) {
|
|
257
359
|
const prefix = nextChangePrefix(schubDir);
|
|
258
360
|
changeId = `${CHANGE_PREFIX}${prefix}_${slug}`;
|
|
@@ -263,18 +365,24 @@ export const createChange = (
|
|
|
263
365
|
console.error(`[INFO] Normalized change-id to '${changeId}'.`);
|
|
264
366
|
}
|
|
265
367
|
}
|
|
266
|
-
|
|
267
368
|
if (!title) {
|
|
268
369
|
title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
269
370
|
}
|
|
270
|
-
|
|
371
|
+
const prefixMatch = changeId.match(/^([Cc]\d+)_/);
|
|
372
|
+
if (prefixMatch) {
|
|
373
|
+
const prefix = prefixMatch[1].toUpperCase();
|
|
374
|
+
const existing = findChangeByPrefix(schubDir, prefix);
|
|
375
|
+
if (existing && existing !== changeId) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`Change prefix '${prefix}' already exists as '${existing}'. Choose a new prefix or omit it to auto-generate.`,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
271
381
|
const changeDir = join(schubDir, "changes", changeId);
|
|
272
382
|
const proposalPath = join(changeDir, "proposal.md");
|
|
273
|
-
|
|
274
383
|
if (changeExists(schubDir, changeId) && !options.overwrite) {
|
|
275
384
|
throw new Error(`Change '${changeId}' already exists under ${schubDir}. Choose a unique id or pass --overwrite.`);
|
|
276
385
|
}
|
|
277
|
-
|
|
278
386
|
const template = readProposalTemplate(schubDir);
|
|
279
387
|
const today = new Date().toISOString().split("T")[0];
|
|
280
388
|
const rendered = template
|
|
@@ -283,13 +391,10 @@ export const createChange = (
|
|
|
283
391
|
.replace("{{DATE}}", today)
|
|
284
392
|
.replace("{{INPUT}}", input || "[no input provided]")
|
|
285
393
|
.replace("{{AGENT_ROOT}}", schubDir);
|
|
286
|
-
|
|
287
394
|
if (existsSync(proposalPath) && !options.overwrite) {
|
|
288
395
|
throw new Error(`Refusing to overwrite existing file: ${proposalPath}`);
|
|
289
396
|
}
|
|
290
|
-
|
|
291
397
|
mkdirSync(changeDir, { recursive: true });
|
|
292
398
|
writeFileSync(proposalPath, rendered, "utf8");
|
|
293
|
-
|
|
294
399
|
return proposalPath;
|
|
295
400
|
};
|
package/src/commands/adr.test.ts
CHANGED
|
@@ -37,19 +37,20 @@ const seedChange = (schubRoot: string, changeId: string, title: string) => {
|
|
|
37
37
|
const changeDir = join(schubRoot, "changes", changeId);
|
|
38
38
|
mkdirSync(changeDir, { recursive: true });
|
|
39
39
|
const proposal = [
|
|
40
|
+
"---",
|
|
41
|
+
`change_id: ${changeId}`,
|
|
42
|
+
"created: 2024-01-01",
|
|
43
|
+
"status: Draft",
|
|
44
|
+
"---",
|
|
40
45
|
`# Proposal - ${title}`,
|
|
41
46
|
"",
|
|
42
|
-
`**Change ID**: \`${changeId}\``,
|
|
43
|
-
"**Created**: 2024-01-01",
|
|
44
|
-
"**Status**: Draft",
|
|
45
|
-
"",
|
|
46
47
|
].join("\n");
|
|
47
48
|
writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
test("adr create scaffolds ADR file", () => {
|
|
51
52
|
const { cwd, schubRoot } = createRepo();
|
|
52
|
-
const changeId = "
|
|
53
|
+
const changeId = "C0003_new-adr";
|
|
53
54
|
const changeTitle = "New ADR";
|
|
54
55
|
seedChange(schubRoot, changeId, changeTitle);
|
|
55
56
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { spawnSync } from "bun";
|
|
7
7
|
|
|
@@ -38,6 +38,34 @@ const runChangesStatus = (schubCwd: string, args: string[] = []) => {
|
|
|
38
38
|
};
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
+
const runChangesArchive = (schubCwd: string, args: string[] = []) => {
|
|
42
|
+
const result = spawnSync({
|
|
43
|
+
cmd: ["bun", "run", "schub", "changes", "archive", ...args],
|
|
44
|
+
cwd: cliDir,
|
|
45
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
result,
|
|
50
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
51
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const runChangesList = (schubCwd: string, args: string[] = []) => {
|
|
56
|
+
const result = spawnSync({
|
|
57
|
+
cmd: ["bun", "run", "schub", "changes", "list", ...args],
|
|
58
|
+
cwd: cliDir,
|
|
59
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
result,
|
|
64
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
65
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
41
69
|
const createRepo = () => {
|
|
42
70
|
const base = mkdtempSync(join(tmpdir(), "schub-changes-"));
|
|
43
71
|
const repoRoot = join(base, "repo");
|
|
@@ -52,17 +80,27 @@ const seedChange = (schubRoot: string, changeId: string) => {
|
|
|
52
80
|
writeFileSync(join(changeDir, "proposal.md"), "# Proposal - Seed\n", "utf8");
|
|
53
81
|
};
|
|
54
82
|
|
|
55
|
-
const seedProposal = (schubRoot: string, changeId: string, status: string) => {
|
|
83
|
+
const seedProposal = (schubRoot: string, changeId: string, status: string, title = "Seed") => {
|
|
56
84
|
const changeDir = join(schubRoot, "changes", changeId);
|
|
57
85
|
mkdirSync(changeDir, { recursive: true });
|
|
58
|
-
const proposal =
|
|
86
|
+
const proposal = `---\nstatus: ${status}\n---\n# Proposal - ${title}\n`;
|
|
59
87
|
writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
|
|
60
88
|
};
|
|
61
89
|
|
|
90
|
+
const seedTask = (schubRoot: string, status: string, taskId: string, changeId: string, titleSlug = "seed-task") => {
|
|
91
|
+
const taskDir = join(schubRoot, "tasks", status);
|
|
92
|
+
mkdirSync(taskDir, { recursive: true });
|
|
93
|
+
const fileName = `${taskId}_${titleSlug}.md`;
|
|
94
|
+
const taskPath = join(taskDir, fileName);
|
|
95
|
+
const taskBody = `---\nchange_id: ${changeId}\n---\n# Task: ${taskId} Seed\n`;
|
|
96
|
+
writeFileSync(taskPath, taskBody, "utf8");
|
|
97
|
+
return taskPath;
|
|
98
|
+
};
|
|
99
|
+
|
|
62
100
|
test("changes create scaffolds proposal with prefixed id", () => {
|
|
63
101
|
const { repoRoot, cwd } = createRepo();
|
|
64
102
|
const schubRoot = join(repoRoot, ".schub");
|
|
65
|
-
seedChange(schubRoot, "
|
|
103
|
+
seedChange(schubRoot, "C0002_existing-change");
|
|
66
104
|
|
|
67
105
|
const title = "Update CLI scaffolding";
|
|
68
106
|
const input = "user prompt";
|
|
@@ -77,7 +115,7 @@ test("changes create scaffolds proposal with prefixed id", () => {
|
|
|
77
115
|
|
|
78
116
|
expect(result.exitCode).toBe(0);
|
|
79
117
|
|
|
80
|
-
const changeId = "
|
|
118
|
+
const changeId = "C0003_update-cli-scaffolding";
|
|
81
119
|
const proposalPath = join(schubRoot, "changes", changeId, "proposal.md");
|
|
82
120
|
expect(existsSync(proposalPath)).toBe(true);
|
|
83
121
|
|
|
@@ -115,7 +153,7 @@ test("changes create requires change id or title", () => {
|
|
|
115
153
|
test("changes create respects overwrite", () => {
|
|
116
154
|
const { cwd } = createRepo();
|
|
117
155
|
const schubRoot = join(cwd, ".schub");
|
|
118
|
-
const changeId = "
|
|
156
|
+
const changeId = "C0001_repeatable-change";
|
|
119
157
|
|
|
120
158
|
const first = runChangesCreate(cwd, ["--change-id", changeId, "--title", "First"]);
|
|
121
159
|
expect(first.result.exitCode).toBe(0);
|
|
@@ -143,29 +181,113 @@ test("changes create rejects schub root flags", () => {
|
|
|
143
181
|
test("changes status updates accepted proposals", () => {
|
|
144
182
|
const { repoRoot, cwd } = createRepo();
|
|
145
183
|
const schubRoot = join(repoRoot, ".schub");
|
|
146
|
-
seedProposal(schubRoot, "
|
|
184
|
+
seedProposal(schubRoot, "C0001_update-cli", "Accepted");
|
|
147
185
|
|
|
148
|
-
const { result, stdout } = runChangesStatus(cwd, ["--change-id", "
|
|
186
|
+
const { result, stdout } = runChangesStatus(cwd, ["--change-id", "C0001_update-cli", "--status", "Done"]);
|
|
149
187
|
|
|
150
188
|
expect(result.exitCode).toBe(0);
|
|
151
189
|
|
|
152
|
-
const proposalPath = join(schubRoot, "changes", "
|
|
190
|
+
const proposalPath = join(schubRoot, "changes", "C0001_update-cli", "proposal.md");
|
|
153
191
|
const updated = readFileSync(proposalPath, "utf8");
|
|
154
|
-
expect(updated).toContain("
|
|
192
|
+
expect(updated).toContain("status: Done");
|
|
155
193
|
expect(stdout).toContain("[OK] Updated status");
|
|
156
194
|
});
|
|
157
195
|
|
|
158
196
|
test("changes status updates WIP proposals", () => {
|
|
159
197
|
const { repoRoot, cwd } = createRepo();
|
|
160
198
|
const schubRoot = join(repoRoot, ".schub");
|
|
161
|
-
seedProposal(schubRoot, "
|
|
199
|
+
seedProposal(schubRoot, "C0001_update-cli", "WIP");
|
|
162
200
|
|
|
163
|
-
const { result, stdout } = runChangesStatus(cwd, ["--change-id", "
|
|
201
|
+
const { result, stdout } = runChangesStatus(cwd, ["--change-id", "C0001_update-cli", "--status", "Done"]);
|
|
164
202
|
|
|
165
203
|
expect(result.exitCode).toBe(0);
|
|
166
204
|
|
|
167
|
-
const proposalPath = join(schubRoot, "changes", "
|
|
205
|
+
const proposalPath = join(schubRoot, "changes", "C0001_update-cli", "proposal.md");
|
|
168
206
|
const updated = readFileSync(proposalPath, "utf8");
|
|
169
|
-
expect(updated).toContain("
|
|
207
|
+
expect(updated).toContain("status: Done");
|
|
170
208
|
expect(stdout).toContain("[OK] Updated status");
|
|
171
209
|
});
|
|
210
|
+
|
|
211
|
+
test("changes list prints change summaries", () => {
|
|
212
|
+
const { repoRoot, cwd } = createRepo();
|
|
213
|
+
const schubRoot = join(repoRoot, ".schub");
|
|
214
|
+
seedProposal(schubRoot, "C0002_second-change", "Done", "Second Change");
|
|
215
|
+
seedProposal(schubRoot, "C0001_first-change", "Accepted", "First Change");
|
|
216
|
+
|
|
217
|
+
const { result, stdout } = runChangesList(cwd);
|
|
218
|
+
|
|
219
|
+
expect(result.exitCode).toBe(0);
|
|
220
|
+
expect(stdout.trim().split("\n")).toEqual([
|
|
221
|
+
"C0001_first-change First Change (Accepted)",
|
|
222
|
+
"C0002_second-change Second Change (Done)",
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("changes archive moves change and tasks by default", () => {
|
|
227
|
+
const { repoRoot, cwd } = createRepo();
|
|
228
|
+
const schubRoot = join(repoRoot, ".schub");
|
|
229
|
+
const changeId = "C0001_archive-change";
|
|
230
|
+
seedProposal(schubRoot, changeId, "Accepted");
|
|
231
|
+
|
|
232
|
+
const readyTask = seedTask(schubRoot, "ready", "T0001_archive-change", changeId, "ready-task");
|
|
233
|
+
const doneTask = seedTask(schubRoot, "done", "T0002_archive-change", changeId, "done-task");
|
|
234
|
+
const otherTask = seedTask(schubRoot, "backlog", "T0003_other-change", "C0002_other-change", "other-task");
|
|
235
|
+
|
|
236
|
+
const { result, stdout } = runChangesArchive(cwd, ["--change-id", changeId]);
|
|
237
|
+
|
|
238
|
+
expect(result.exitCode).toBe(0);
|
|
239
|
+
expect(stdout).toContain(`[OK] Archived change ${changeId}`);
|
|
240
|
+
|
|
241
|
+
const archivedChangePath = join(schubRoot, "archive", "changes", changeId);
|
|
242
|
+
expect(existsSync(archivedChangePath)).toBe(true);
|
|
243
|
+
expect(existsSync(join(schubRoot, "changes", changeId))).toBe(false);
|
|
244
|
+
|
|
245
|
+
const archivedReadyTask = join(schubRoot, "tasks", "archived", basename(readyTask));
|
|
246
|
+
const archivedDoneTask = join(schubRoot, "tasks", "archived", basename(doneTask));
|
|
247
|
+
expect(existsSync(archivedReadyTask)).toBe(true);
|
|
248
|
+
expect(existsSync(archivedDoneTask)).toBe(true);
|
|
249
|
+
expect(existsSync(readyTask)).toBe(false);
|
|
250
|
+
expect(existsSync(doneTask)).toBe(false);
|
|
251
|
+
expect(existsSync(otherTask)).toBe(true);
|
|
252
|
+
|
|
253
|
+
const proposalPath = join(archivedChangePath, "proposal.md");
|
|
254
|
+
const updated = readFileSync(proposalPath, "utf8");
|
|
255
|
+
expect(updated).toContain("status: Archived");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("changes archive skips tasks when requested", () => {
|
|
259
|
+
const { repoRoot, cwd } = createRepo();
|
|
260
|
+
const schubRoot = join(repoRoot, ".schub");
|
|
261
|
+
const changeId = "C0001_archive-change";
|
|
262
|
+
seedProposal(schubRoot, changeId, "Accepted");
|
|
263
|
+
|
|
264
|
+
const readyTask = seedTask(schubRoot, "ready", "T0001_archive-change", changeId, "ready-task");
|
|
265
|
+
|
|
266
|
+
const { result } = runChangesArchive(cwd, ["--change-id", changeId, "--skip-tasks"]);
|
|
267
|
+
|
|
268
|
+
expect(result.exitCode).toBe(0);
|
|
269
|
+
expect(existsSync(readyTask)).toBe(true);
|
|
270
|
+
expect(existsSync(join(schubRoot, "tasks", "archived", basename(readyTask)))).toBe(false);
|
|
271
|
+
expect(existsSync(join(schubRoot, "archive", "changes", changeId))).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("changes archive reports collisions and leaves tasks alone", () => {
|
|
275
|
+
const { repoRoot, cwd } = createRepo();
|
|
276
|
+
const schubRoot = join(repoRoot, ".schub");
|
|
277
|
+
const changeId = "C0001_archive-change";
|
|
278
|
+
seedProposal(schubRoot, changeId, "Accepted");
|
|
279
|
+
|
|
280
|
+
const readyTask = seedTask(schubRoot, "ready", "T0001_archive-change", changeId, "ready-task");
|
|
281
|
+
|
|
282
|
+
const archiveRoot = join(schubRoot, "archive", "changes", changeId);
|
|
283
|
+
mkdirSync(archiveRoot, { recursive: true });
|
|
284
|
+
writeFileSync(join(archiveRoot, "proposal.md"), "---\nstatus: Archived\n---\n# Proposal - Existing\n", "utf8");
|
|
285
|
+
|
|
286
|
+
const { result, stderr } = runChangesArchive(cwd, ["--change-id", changeId]);
|
|
287
|
+
|
|
288
|
+
expect(result.exitCode).not.toBe(0);
|
|
289
|
+
expect(stderr).toContain("Archive already exists");
|
|
290
|
+
expect(existsSync(readyTask)).toBe(true);
|
|
291
|
+
expect(existsSync(join(schubRoot, "tasks", "archived", basename(readyTask)))).toBe(false);
|
|
292
|
+
expect(existsSync(join(schubRoot, "changes", changeId))).toBe(true);
|
|
293
|
+
});
|