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
|
@@ -11,7 +11,7 @@ $ARGUMENTS
|
|
|
11
11
|
|
|
12
12
|
## Workflow
|
|
13
13
|
|
|
14
|
-
1. Derive a concise, verb-led `change-id` suffix from the request (kebab-case: `add-`, `update-`, `remove-`, `refactor-`, `fix-`) and prefix it with `C` + the next
|
|
14
|
+
1. Derive a concise, verb-led `change-id` suffix from the request (kebab-case: `add-`, `update-`, `remove-`, `refactor-`, `fix-`) and prefix it with `C` + the next 4-digit sequence + `_` (e.g., `C0001_add-user-auth`).
|
|
15
15
|
2. Run `npx schub changes create --change-id "<change-id>" --title "<title>" --input "<user prompt verbatim>"` to scaffold the proposal template.
|
|
16
16
|
- Supported flags: `--change-id`, `--title`, `--input`, `--overwrite`.
|
|
17
17
|
3. Update the generated proposal with concrete, testable statements.
|
|
@@ -3,7 +3,7 @@ name: create-tasks
|
|
|
3
3
|
description: "Break an accepted proposal into actionable task files under `.schub/tasks/` using the task template. Use when asked to create tasks for a change-id."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
## User Input (should contain change-id or shorthand `C
|
|
6
|
+
## User Input (should contain change-id or shorthand `C####`)
|
|
7
7
|
|
|
8
8
|
```text
|
|
9
9
|
$ARGUMENTS
|
|
@@ -11,13 +11,13 @@ $ARGUMENTS
|
|
|
11
11
|
|
|
12
12
|
## Workflow
|
|
13
13
|
|
|
14
|
-
1. If the user provides shorthand `C
|
|
14
|
+
1. If the user provides shorthand `C####`, resolve it to the matching `C####_<suffix>` change folder (error if ambiguous). Confirm `.schub/changes/<change-id>/proposal.md` exists and that it is marked accepted. If it is missing, ask the user to run the create-proposal skill first. If it is still a draft, ask the user to review it first.
|
|
15
15
|
2. Read the proposal and other files in the <change-id> folder.
|
|
16
16
|
3. Derive tasks, each task should be:
|
|
17
17
|
- Small enough to implement in one sitting.
|
|
18
18
|
- Independently testable.
|
|
19
19
|
- Explicit about file paths and affected components.
|
|
20
|
-
4. Assign a unique task id using `T
|
|
20
|
+
4. Assign a unique task id using `T####` (ex: `T0001`) and avoid collisions with existing tasks.
|
|
21
21
|
5. Run `npx schub tasks create --change-id "<change-id>" --title "<task title>"` to scaffold tasks.
|
|
22
22
|
- Repeat `--title` for multiple tasks.
|
|
23
23
|
- Optional flags: `--status "<status>"` (default: `backlog`), `--overwrite`.
|
|
@@ -3,7 +3,7 @@ name: review-proposal
|
|
|
3
3
|
description: "Run a review session for a proposal by creating a list of open questions, updating the proposal, adding a Q&A section, and deleting REVIEW_ME.md on completion. Use when asked to review proposals."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
## User Input (change-id or shorthand `C
|
|
6
|
+
## User Input (change-id or shorthand `C####`)
|
|
7
7
|
|
|
8
8
|
```text
|
|
9
9
|
$ARGUMENTS
|
|
@@ -11,7 +11,7 @@ $ARGUMENTS
|
|
|
11
11
|
|
|
12
12
|
## Workflow
|
|
13
13
|
|
|
14
|
-
0. If the user provides shorthand `C
|
|
14
|
+
0. If the user provides shorthand `C####`, resolve it to the matching `C####_<suffix>` change folder (error if ambiguous). Mark the proposal Status as "In Review"
|
|
15
15
|
1. Review the proposal by checking the [MISSING INFORMATION] tags and the potential issues listed.
|
|
16
16
|
2. If there are issues, or missing information, run `npx schub review create --change-id "<change-id>"` to scaffold `.schub/changes/<change-id>/REVIEW_ME.md`.
|
|
17
17
|
3. Triage each item:
|
package/src/App.test.tsx
CHANGED
|
@@ -15,6 +15,12 @@ const displaySchubDir =
|
|
|
15
15
|
const ansiPattern = new RegExp(`${String.fromCharCode(27)}[[0-9;]*m`, "g");
|
|
16
16
|
const stripAnsi = (value: string) => value.replace(ansiPattern, "");
|
|
17
17
|
|
|
18
|
+
const writeProposal = (changesRoot: string, changeId: string, status: string, title = changeId) => {
|
|
19
|
+
const changeDir = join(changesRoot, changeId);
|
|
20
|
+
mkdirSync(changeDir, { recursive: true });
|
|
21
|
+
writeFileSync(join(changeDir, "proposal.md"), `# Proposal - ${title}\n**Status**: ${status}\n`, "utf8");
|
|
22
|
+
};
|
|
23
|
+
|
|
18
24
|
test("renders tabs with header and footer details", () => {
|
|
19
25
|
const { lastFrame } = render(<App />);
|
|
20
26
|
const output = stripAnsi(lastFrame() || "");
|
|
@@ -27,6 +33,51 @@ test("renders tabs with header and footer details", () => {
|
|
|
27
33
|
expect(output).toContain(packageJson.version);
|
|
28
34
|
});
|
|
29
35
|
|
|
36
|
+
test("shows review shortcut when pending review proposal is selected", async () => {
|
|
37
|
+
const originalCwd = process.cwd();
|
|
38
|
+
const baseDir = mkdtempSync(join(tmpdir(), "schub-app-review-shortcut-"));
|
|
39
|
+
const changesRoot = join(baseDir, ".schub", "changes");
|
|
40
|
+
|
|
41
|
+
mkdirSync(changesRoot, { recursive: true });
|
|
42
|
+
writeProposal(changesRoot, "C001_pending-review", "Pending Review");
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
process.chdir(baseDir);
|
|
46
|
+
const rendered = render(<App />);
|
|
47
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
48
|
+
const output = stripAnsi(rendered.lastFrame() ?? "");
|
|
49
|
+
expect(output).toContain("[r review]");
|
|
50
|
+
} finally {
|
|
51
|
+
process.chdir(originalCwd);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("hides review shortcut when selection moves off pending review", async () => {
|
|
56
|
+
const originalCwd = process.cwd();
|
|
57
|
+
const baseDir = mkdtempSync(join(tmpdir(), "schub-app-review-hide-"));
|
|
58
|
+
const changesRoot = join(baseDir, ".schub", "changes");
|
|
59
|
+
|
|
60
|
+
mkdirSync(changesRoot, { recursive: true });
|
|
61
|
+
writeProposal(changesRoot, "C001_pending-review", "Pending Review");
|
|
62
|
+
writeProposal(changesRoot, "C002_draft", "Draft");
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
process.chdir(baseDir);
|
|
66
|
+
const rendered = render(<App />);
|
|
67
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
68
|
+
const initial = stripAnsi(rendered.lastFrame() ?? "");
|
|
69
|
+
expect(initial).toContain("[r review]");
|
|
70
|
+
|
|
71
|
+
rendered.stdin.write("\u001B[B");
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
74
|
+
const moved = stripAnsi(rendered.lastFrame() ?? "");
|
|
75
|
+
expect(moved).not.toContain("[r review]");
|
|
76
|
+
} finally {
|
|
77
|
+
process.chdir(originalCwd);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
30
81
|
test("selected tab shows a blue left indicator", () => {
|
|
31
82
|
const originalForceColor = process.env.FORCE_COLOR;
|
|
32
83
|
process.env.FORCE_COLOR = "1";
|
|
@@ -56,15 +107,16 @@ test("shows a copy banner after copying an item", async () => {
|
|
|
56
107
|
};
|
|
57
108
|
|
|
58
109
|
mkdirSync(readyRoot, { recursive: true });
|
|
59
|
-
writeFileSync(join(readyRoot, "
|
|
110
|
+
writeFileSync(join(readyRoot, "T0900_copy-task.md"), "# Task: T0900 Copy Task\n", "utf8");
|
|
60
111
|
|
|
61
112
|
try {
|
|
62
113
|
process.chdir(baseDir);
|
|
63
114
|
const rendered = render(<App copyToClipboard={recordCopy} />);
|
|
64
115
|
rendered.stdin.write("c");
|
|
65
116
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
66
118
|
const output = rendered.lastFrame() ?? "";
|
|
67
|
-
expect(copied).toBe("
|
|
119
|
+
expect(copied).toBe("T0900");
|
|
68
120
|
expect(output).toContain("Copied to clipboard !");
|
|
69
121
|
} finally {
|
|
70
122
|
process.chdir(originalCwd);
|
package/src/App.tsx
CHANGED
|
@@ -14,6 +14,11 @@ type TabDefinition = {
|
|
|
14
14
|
label: string;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
type Shortcut = {
|
|
18
|
+
keyLabel: string;
|
|
19
|
+
label: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
17
22
|
const tabs: TabDefinition[] = [
|
|
18
23
|
{ id: "status", label: "Status" },
|
|
19
24
|
{ id: "plan", label: "Plan" },
|
|
@@ -34,6 +39,7 @@ export default function App({ copyToClipboard = copyToClipboardDefault }: AppPro
|
|
|
34
39
|
rows: stdout.rows,
|
|
35
40
|
}));
|
|
36
41
|
const [copyBanner, setCopyBanner] = React.useState<string | null>(null);
|
|
42
|
+
const [statusShortcuts, setStatusShortcuts] = React.useState<Shortcut[]>([]);
|
|
37
43
|
const versionLabel = `${packageJson.version}`;
|
|
38
44
|
const homeDir = homedir();
|
|
39
45
|
const startDir = process.env.SCHUB_CWD ?? process.cwd();
|
|
@@ -81,15 +87,22 @@ export default function App({ copyToClipboard = copyToClipboardDefault }: AppPro
|
|
|
81
87
|
}
|
|
82
88
|
});
|
|
83
89
|
|
|
90
|
+
React.useEffect(() => {
|
|
91
|
+
if (mode !== "status") {
|
|
92
|
+
setStatusShortcuts([]);
|
|
93
|
+
}
|
|
94
|
+
}, [mode]);
|
|
95
|
+
|
|
84
96
|
const handleCopyId = (value: string) => {
|
|
85
97
|
copyToClipboard(value);
|
|
86
98
|
setCopyBanner(COPY_BANNER_TEXT);
|
|
87
99
|
};
|
|
88
100
|
|
|
89
|
-
const
|
|
101
|
+
const baseShortcuts: Shortcut[] = [
|
|
90
102
|
{ keyLabel: "o", label: "open file" },
|
|
91
103
|
{ keyLabel: "c", label: "copy" },
|
|
92
104
|
];
|
|
105
|
+
const shortcuts = mode === "status" ? [...baseShortcuts, ...statusShortcuts] : baseShortcuts;
|
|
93
106
|
|
|
94
107
|
return (
|
|
95
108
|
<Box backgroundColor="black" flexDirection="column" width={dimensions.columns} height={dimensions.rows}>
|
|
@@ -127,7 +140,11 @@ export default function App({ copyToClipboard = copyToClipboardDefault }: AppPro
|
|
|
127
140
|
</Box>
|
|
128
141
|
</Box>
|
|
129
142
|
<Box flexDirection="column" paddingX={2} paddingY={1} flexGrow={1} flexShrink={1}>
|
|
130
|
-
{mode === "status" ?
|
|
143
|
+
{mode === "status" ? (
|
|
144
|
+
<StatusView onCopyId={handleCopyId} onShortcutsChange={setStatusShortcuts} />
|
|
145
|
+
) : (
|
|
146
|
+
<PlanView onCopyId={handleCopyId} />
|
|
147
|
+
)}
|
|
131
148
|
</Box>
|
|
132
149
|
<Box flexDirection="column" paddingX={2} paddingBottom={1} flexShrink={0}>
|
|
133
150
|
<Box justifyContent="space-between">
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { archiveChange } from "./changes";
|
|
6
|
+
|
|
7
|
+
const createSchubRoot = () => {
|
|
8
|
+
const base = mkdtempSync(join(tmpdir(), "schub-change-archive-"));
|
|
9
|
+
const schubDir = join(base, ".schub");
|
|
10
|
+
mkdirSync(schubDir, { recursive: true });
|
|
11
|
+
return { schubDir };
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const seedProposal = (schubDir: string, changeId: string, status: string) => {
|
|
15
|
+
const changeDir = join(schubDir, "changes", changeId);
|
|
16
|
+
mkdirSync(changeDir, { recursive: true });
|
|
17
|
+
const proposal = `# Proposal - Seed\n**Status**: ${status}\n`;
|
|
18
|
+
writeFileSync(join(changeDir, "proposal.md"), proposal, "utf8");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
test("archiveChange updates proposal and moves directory", () => {
|
|
22
|
+
const { schubDir } = createSchubRoot();
|
|
23
|
+
const changeId = "C0001_archive-change";
|
|
24
|
+
seedProposal(schubDir, changeId, "Accepted");
|
|
25
|
+
|
|
26
|
+
archiveChange(schubDir, changeId);
|
|
27
|
+
|
|
28
|
+
const archivePath = join(schubDir, "archive", "changes", changeId);
|
|
29
|
+
expect(existsSync(join(schubDir, "changes", changeId))).toBe(false);
|
|
30
|
+
expect(existsSync(archivePath)).toBe(true);
|
|
31
|
+
|
|
32
|
+
const proposalPath = join(archivePath, "proposal.md");
|
|
33
|
+
const updated = readFileSync(proposalPath, "utf8");
|
|
34
|
+
expect(updated).toContain("**Status**: Archived");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("archiveChange rejects archive collisions", () => {
|
|
38
|
+
const { schubDir } = createSchubRoot();
|
|
39
|
+
const changeId = "C0001_archive-change";
|
|
40
|
+
seedProposal(schubDir, changeId, "Accepted");
|
|
41
|
+
|
|
42
|
+
const archivePath = join(schubDir, "archive", "changes", changeId);
|
|
43
|
+
mkdirSync(archivePath, { recursive: true });
|
|
44
|
+
const archivedProposal = join(archivePath, "proposal.md");
|
|
45
|
+
writeFileSync(archivedProposal, "# Proposal - Existing\n**Status**: Archived\n", "utf8");
|
|
46
|
+
|
|
47
|
+
expect(() => archiveChange(schubDir, changeId)).toThrow(`Archive already exists: ${archivePath}`);
|
|
48
|
+
|
|
49
|
+
const activeProposal = readFileSync(join(schubDir, "changes", changeId, "proposal.md"), "utf8");
|
|
50
|
+
expect(activeProposal).toContain("**Status**: Accepted");
|
|
51
|
+
expect(readFileSync(archivedProposal, "utf8")).toContain("# Proposal - Existing");
|
|
52
|
+
});
|
package/src/changes.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
import { resolveSchubRoot } from "./schub-root";
|
|
@@ -11,7 +11,7 @@ export type ChangeInfo = {
|
|
|
11
11
|
path: string;
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
const CHANGE_ID_PATTERN = /^(?:[Cc]\d{
|
|
14
|
+
const CHANGE_ID_PATTERN = /^(?:[Cc]\d{4}_)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
15
|
|
|
16
16
|
const isDirectory = (path: string) => {
|
|
17
17
|
try {
|
|
@@ -33,7 +33,7 @@ const parseProposal = (content: string, changeId: string) => {
|
|
|
33
33
|
|
|
34
34
|
export const normalizeChangeId = (value: string) => {
|
|
35
35
|
const trimmed = value.trim();
|
|
36
|
-
const match = trimmed.match(/^([Cc])(\d{
|
|
36
|
+
const match = trimmed.match(/^([Cc])(\d{4})_(.+)$/);
|
|
37
37
|
if (match) {
|
|
38
38
|
return `C${match[2]}_${match[3]}`;
|
|
39
39
|
}
|
|
@@ -49,7 +49,7 @@ export const readChangeSummary = (schubDir: string, changeId: string) => {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
if (!isValidChangeId(trimmed)) {
|
|
52
|
-
throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g.,
|
|
52
|
+
throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C0001_add-user-auth).`);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const normalized = normalizeChangeId(trimmed);
|
|
@@ -101,6 +101,29 @@ export const updateChangeStatus = (schubDir: string, changeId: string, status: s
|
|
|
101
101
|
};
|
|
102
102
|
};
|
|
103
103
|
|
|
104
|
+
export const archiveChange = (schubDir: string, changeId: string) => {
|
|
105
|
+
const summary = readChangeSummary(schubDir, changeId);
|
|
106
|
+
const archiveRoot = join(schubDir, "archive", "changes");
|
|
107
|
+
const archivePath = join(archiveRoot, summary.changeId);
|
|
108
|
+
|
|
109
|
+
if (existsSync(archivePath)) {
|
|
110
|
+
throw new Error(`Archive already exists: ${archivePath}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
mkdirSync(archiveRoot, { recursive: true });
|
|
114
|
+
|
|
115
|
+
const updated = updateChangeStatus(schubDir, summary.changeId, "Archived");
|
|
116
|
+
renameSync(summary.changeDir, archivePath);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
changeId: updated.changeId,
|
|
120
|
+
previousStatus: updated.previousStatus,
|
|
121
|
+
status: updated.status,
|
|
122
|
+
proposalPath: join(archivePath, "proposal.md"),
|
|
123
|
+
archivePath,
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
104
127
|
const changeNumber = (id: string) => {
|
|
105
128
|
const match = id.match(/\d+/);
|
|
106
129
|
return match ? Number(match[0]) : Number.POSITIVE_INFINITY;
|
|
@@ -153,7 +176,7 @@ const slugify = (value: string) => {
|
|
|
153
176
|
};
|
|
154
177
|
|
|
155
178
|
const splitPrefixedChangeId = (changeId: string) => {
|
|
156
|
-
const match = changeId.match(/^([Cc])(\d{
|
|
179
|
+
const match = changeId.match(/^([Cc])(\d{4})_([a-z0-9]+(?:-[a-z0-9]+)*)$/);
|
|
157
180
|
if (match) {
|
|
158
181
|
return { prefix: match[2], slug: match[3] };
|
|
159
182
|
}
|
|
@@ -171,7 +194,7 @@ const nextChangePrefix = (schubDir: string) => {
|
|
|
171
194
|
if (!entry.isDirectory()) {
|
|
172
195
|
continue;
|
|
173
196
|
}
|
|
174
|
-
const match = entry.name.match(/^[Cc](\d{
|
|
197
|
+
const match = entry.name.match(/^[Cc](\d{4})_/);
|
|
175
198
|
if (match) {
|
|
176
199
|
prefixes.push(Number.parseInt(match[1], 10));
|
|
177
200
|
}
|
|
@@ -182,7 +205,7 @@ const nextChangePrefix = (schubDir: string) => {
|
|
|
182
205
|
scan(archiveRoot);
|
|
183
206
|
|
|
184
207
|
const next = prefixes.length > 0 ? Math.max(...prefixes) + 1 : 1;
|
|
185
|
-
return next.toString().padStart(
|
|
208
|
+
return next.toString().padStart(4, "0");
|
|
186
209
|
};
|
|
187
210
|
|
|
188
211
|
const CHANGE_PREFIX = "C";
|
package/src/commands/adr.test.ts
CHANGED
|
@@ -49,7 +49,7 @@ const seedChange = (schubRoot: string, changeId: string, title: string) => {
|
|
|
49
49
|
|
|
50
50
|
test("adr create scaffolds ADR file", () => {
|
|
51
51
|
const { cwd, schubRoot } = createRepo();
|
|
52
|
-
const changeId = "
|
|
52
|
+
const changeId = "C0003_new-adr";
|
|
53
53
|
const changeTitle = "New ADR";
|
|
54
54
|
seedChange(schubRoot, changeId, changeTitle);
|
|
55
55
|
|
|
@@ -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 = `# Proposal -
|
|
86
|
+
const proposal = `# Proposal - ${title}\n**Status**: ${status}\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 = `# Task: ${taskId} Seed\n\n**Change ID**: ${changeId}\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,13 +181,13 @@ 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
192
|
expect(updated).toContain("**Status**: Done");
|
|
155
193
|
expect(stdout).toContain("[OK] Updated status");
|
|
@@ -158,14 +196,98 @@ test("changes status updates accepted proposals", () => {
|
|
|
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
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"), "# Proposal - Existing\n**Status**: Archived\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
|
+
});
|