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
package/src/commands/changes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createChange, resolveChangeRoot, updateChangeStatus } from "../changes";
|
|
1
|
+
import { archiveChange, createChange, listChanges, resolveChangeRoot, updateChangeStatus } from "../changes";
|
|
2
|
+
import { archiveTasksForChange } from "../tasks";
|
|
2
3
|
|
|
3
4
|
type ChangeCreateOptions = {
|
|
4
5
|
changeId?: string;
|
|
@@ -12,6 +13,11 @@ type ChangeStatusOptions = {
|
|
|
12
13
|
status: string;
|
|
13
14
|
};
|
|
14
15
|
|
|
16
|
+
type ChangeArchiveOptions = {
|
|
17
|
+
changeId: string;
|
|
18
|
+
skipTasks: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
15
21
|
const parseChangeCreateOptions = (args: string[]) => {
|
|
16
22
|
let changeId: string | undefined;
|
|
17
23
|
let title: string | undefined;
|
|
@@ -148,6 +154,82 @@ const parseChangeStatusOptions = (args: string[]) => {
|
|
|
148
154
|
return options;
|
|
149
155
|
};
|
|
150
156
|
|
|
157
|
+
const parseChangeArchiveOptions = (args: string[]) => {
|
|
158
|
+
let changeId: string | undefined;
|
|
159
|
+
let skipTasks = false;
|
|
160
|
+
const unknown: string[] = [];
|
|
161
|
+
|
|
162
|
+
const rejectUnsupported = (flag: string) => {
|
|
163
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
167
|
+
const arg = args[index];
|
|
168
|
+
if (arg === "--skip-tasks") {
|
|
169
|
+
skipTasks = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (arg === "--change-id") {
|
|
173
|
+
changeId = args[index + 1];
|
|
174
|
+
if (changeId === undefined) {
|
|
175
|
+
throw new Error("Missing value for --change-id.");
|
|
176
|
+
}
|
|
177
|
+
index += 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (arg.startsWith("--change-id=")) {
|
|
181
|
+
changeId = arg.slice("--change-id=".length);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
185
|
+
rejectUnsupported(arg);
|
|
186
|
+
}
|
|
187
|
+
if (arg.startsWith("--schub-root=")) {
|
|
188
|
+
rejectUnsupported("--schub-root");
|
|
189
|
+
}
|
|
190
|
+
if (arg.startsWith("--agent-root=")) {
|
|
191
|
+
rejectUnsupported("--agent-root");
|
|
192
|
+
}
|
|
193
|
+
unknown.push(arg);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (unknown.length > 0) {
|
|
197
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!changeId) {
|
|
201
|
+
throw new Error("Provide --change-id.");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const options: ChangeArchiveOptions = { changeId, skipTasks };
|
|
205
|
+
return options;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const parseChangeListOptions = (args: string[]) => {
|
|
209
|
+
const unknown: string[] = [];
|
|
210
|
+
|
|
211
|
+
const rejectUnsupported = (flag: string) => {
|
|
212
|
+
throw new Error(`Unsupported option: ${flag}.`);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
for (const arg of args) {
|
|
216
|
+
if (arg === "--schub-root" || arg === "--agent-root") {
|
|
217
|
+
rejectUnsupported(arg);
|
|
218
|
+
}
|
|
219
|
+
if (arg.startsWith("--schub-root=")) {
|
|
220
|
+
rejectUnsupported("--schub-root");
|
|
221
|
+
}
|
|
222
|
+
if (arg.startsWith("--agent-root=")) {
|
|
223
|
+
rejectUnsupported("--agent-root");
|
|
224
|
+
}
|
|
225
|
+
unknown.push(arg);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (unknown.length > 0) {
|
|
229
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
151
233
|
export const runChangesCreate = (args: string[], startDir: string) => {
|
|
152
234
|
const options = parseChangeCreateOptions(args);
|
|
153
235
|
const schubDir = resolveChangeRoot(startDir);
|
|
@@ -161,3 +243,22 @@ export const runChangesStatus = (args: string[], startDir: string) => {
|
|
|
161
243
|
const updated = updateChangeStatus(schubDir, options.changeId, options.status);
|
|
162
244
|
process.stdout.write(`[OK] Updated status for ${updated.changeId}: ${updated.previousStatus} -> ${updated.status}\n`);
|
|
163
245
|
};
|
|
246
|
+
|
|
247
|
+
export const runChangesArchive = (args: string[], startDir: string) => {
|
|
248
|
+
const options = parseChangeArchiveOptions(args);
|
|
249
|
+
const schubDir = resolveChangeRoot(startDir);
|
|
250
|
+
const archived = archiveChange(schubDir, options.changeId);
|
|
251
|
+
const archivedTasks = options.skipTasks ? [] : archiveTasksForChange(schubDir, archived.changeId);
|
|
252
|
+
const taskLabel = options.skipTasks
|
|
253
|
+
? "tasks kept"
|
|
254
|
+
: `${archivedTasks.length} task${archivedTasks.length === 1 ? "" : "s"} archived`;
|
|
255
|
+
process.stdout.write(`[OK] Archived change ${archived.changeId} (${taskLabel})\n`);
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export const runChangesList = (args: string[], startDir: string) => {
|
|
259
|
+
parseChangeListOptions(args);
|
|
260
|
+
const schubDir = resolveChangeRoot(startDir);
|
|
261
|
+
const changes = listChanges(schubDir);
|
|
262
|
+
const lines = changes.map((change) => `${change.id} ${change.title} (${change.status})`);
|
|
263
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
264
|
+
};
|
|
@@ -49,7 +49,7 @@ const seedChange = (schubRoot: string, changeId: string, title: string) => {
|
|
|
49
49
|
|
|
50
50
|
test("cookbook create scaffolds cookbook file", () => {
|
|
51
51
|
const { cwd, schubRoot } = createRepo();
|
|
52
|
-
const changeId = "
|
|
52
|
+
const changeId = "C0004_new-cookbook";
|
|
53
53
|
const changeTitle = "New Cookbook";
|
|
54
54
|
seedChange(schubRoot, changeId, changeTitle);
|
|
55
55
|
|
|
@@ -3,7 +3,13 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
installCodexSkills,
|
|
8
|
+
installOpencodeSkills,
|
|
9
|
+
resolveCodexSkillsRoot,
|
|
10
|
+
resolveOpencodeSkillsRoot,
|
|
11
|
+
selectProviders,
|
|
12
|
+
} from "./init";
|
|
7
13
|
|
|
8
14
|
const createRepoFixture = () => {
|
|
9
15
|
const base = mkdtempSync(join(tmpdir(), "schub-init-codex-"));
|
|
@@ -15,7 +21,9 @@ const createRepoFixture = () => {
|
|
|
15
21
|
|
|
16
22
|
test("selectProviders handles numeric and named inputs", () => {
|
|
17
23
|
expect(selectProviders("1")).toEqual(["codex"]);
|
|
18
|
-
expect(selectProviders("
|
|
24
|
+
expect(selectProviders("2")).toEqual(["opencode"]);
|
|
25
|
+
expect(selectProviders("codex, opencode")).toEqual(["codex", "opencode"]);
|
|
26
|
+
expect(selectProviders("Opencode, 1")).toEqual(["opencode", "codex"]);
|
|
19
27
|
});
|
|
20
28
|
|
|
21
29
|
test("resolveCodexSkillsRoot prefers worktree .codex when present", () => {
|
|
@@ -63,6 +71,51 @@ test("resolveCodexSkillsRoot uses cwd when git is unavailable", () => {
|
|
|
63
71
|
}
|
|
64
72
|
});
|
|
65
73
|
|
|
74
|
+
test("resolveOpencodeSkillsRoot prefers worktree .opencode when present", () => {
|
|
75
|
+
const { repoRoot, startDir } = createRepoFixture();
|
|
76
|
+
const gitInit = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
77
|
+
|
|
78
|
+
expect(gitInit.status).toBe(0);
|
|
79
|
+
|
|
80
|
+
mkdirSync(join(repoRoot, ".opencode"), { recursive: true });
|
|
81
|
+
|
|
82
|
+
const destination = resolveOpencodeSkillsRoot(startDir);
|
|
83
|
+
const expectedRoot = realpathSync(repoRoot);
|
|
84
|
+
|
|
85
|
+
expect(destination).toBe(join(expectedRoot, ".opencode", "skills"));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("resolveOpencodeSkillsRoot falls back to home when worktree .opencode is missing", () => {
|
|
89
|
+
const { repoRoot, startDir } = createRepoFixture();
|
|
90
|
+
const gitInit = spawnSync("git", ["init"], { cwd: repoRoot, stdio: "ignore" });
|
|
91
|
+
|
|
92
|
+
expect(gitInit.status).toBe(0);
|
|
93
|
+
|
|
94
|
+
const originalHome = process.env.HOME;
|
|
95
|
+
const homeDir = mkdtempSync(join(tmpdir(), "schub-home-"));
|
|
96
|
+
process.env.HOME = homeDir;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const destination = resolveOpencodeSkillsRoot(startDir);
|
|
100
|
+
expect(destination).toBe(join(homeDir, ".opencode", "skills"));
|
|
101
|
+
} finally {
|
|
102
|
+
process.env.HOME = originalHome;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("resolveOpencodeSkillsRoot uses cwd when git is unavailable", () => {
|
|
107
|
+
const { startDir } = createRepoFixture();
|
|
108
|
+
const originalPath = process.env.PATH;
|
|
109
|
+
process.env.PATH = "";
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const destination = resolveOpencodeSkillsRoot(startDir);
|
|
113
|
+
expect(destination).toBe(join(startDir, ".opencode", "skills"));
|
|
114
|
+
} finally {
|
|
115
|
+
process.env.PATH = originalPath;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
66
119
|
test("installCodexSkills skips existing skill directories", () => {
|
|
67
120
|
const base = mkdtempSync(join(tmpdir(), "schub-init-install-"));
|
|
68
121
|
const destination = join(base, "skills");
|
|
@@ -76,3 +129,17 @@ test("installCodexSkills skips existing skill directories", () => {
|
|
|
76
129
|
expect(installed.length).toBeGreaterThan(0);
|
|
77
130
|
expect(existsSync(join(destination, "create-tasks", "SKILL.md"))).toBe(true);
|
|
78
131
|
});
|
|
132
|
+
|
|
133
|
+
test("installOpencodeSkills skips existing skill directories", () => {
|
|
134
|
+
const base = mkdtempSync(join(tmpdir(), "schub-init-opencode-install-"));
|
|
135
|
+
const destination = join(base, "skills");
|
|
136
|
+
const existingSkill = join(destination, "review-proposal");
|
|
137
|
+
mkdirSync(existingSkill, { recursive: true });
|
|
138
|
+
writeFileSync(join(existingSkill, "SKILL.md"), "existing", "utf8");
|
|
139
|
+
|
|
140
|
+
const { installed, skipped } = installOpencodeSkills(destination);
|
|
141
|
+
|
|
142
|
+
expect(skipped).toContain("review-proposal");
|
|
143
|
+
expect(installed.length).toBeGreaterThan(0);
|
|
144
|
+
expect(existsSync(join(destination, "create-tasks", "SKILL.md"))).toBe(true);
|
|
145
|
+
});
|
package/src/commands/init.ts
CHANGED
|
@@ -5,14 +5,17 @@ import { createInterface } from "node:readline";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { initSchubRoot, resolveGitRoot } from "../init";
|
|
7
7
|
|
|
8
|
-
type ProviderId = "codex";
|
|
8
|
+
type ProviderId = "codex" | "opencode";
|
|
9
9
|
|
|
10
10
|
type ProviderOption = {
|
|
11
11
|
id: ProviderId;
|
|
12
12
|
label: string;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
const PROVIDERS: ProviderOption[] = [
|
|
15
|
+
const PROVIDERS: ProviderOption[] = [
|
|
16
|
+
{ id: "codex", label: "Codex" },
|
|
17
|
+
{ id: "opencode", label: "Opencode" },
|
|
18
|
+
];
|
|
16
19
|
const BUNDLED_SKILLS_ROOT = fileURLToPath(new URL("../../skills", import.meta.url));
|
|
17
20
|
|
|
18
21
|
const isDirectory = (path: string) => {
|
|
@@ -82,7 +85,24 @@ export const resolveCodexSkillsRoot = (startDir: string) => {
|
|
|
82
85
|
return join(home, ".codex", "skills");
|
|
83
86
|
};
|
|
84
87
|
|
|
85
|
-
export const
|
|
88
|
+
export const resolveOpencodeSkillsRoot = (startDir: string) => {
|
|
89
|
+
const resolvedStart = resolve(startDir);
|
|
90
|
+
const gitRoot = resolveGitRoot(resolvedStart);
|
|
91
|
+
|
|
92
|
+
if (!gitRoot) {
|
|
93
|
+
return join(resolvedStart, ".opencode", "skills");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const localOpencodeRoot = join(gitRoot, ".opencode");
|
|
97
|
+
if (isDirectory(localOpencodeRoot)) {
|
|
98
|
+
return join(localOpencodeRoot, "skills");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const home = process.env.HOME ?? homedir();
|
|
102
|
+
return join(home, ".opencode", "skills");
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const installBundledSkills = (destination: string) => {
|
|
86
106
|
mkdirSync(destination, { recursive: true });
|
|
87
107
|
|
|
88
108
|
const entries = readdirSync(BUNDLED_SKILLS_ROOT, { withFileTypes: true });
|
|
@@ -109,8 +129,12 @@ export const installCodexSkills = (destination: string) => {
|
|
|
109
129
|
return { installed, skipped };
|
|
110
130
|
};
|
|
111
131
|
|
|
112
|
-
const
|
|
113
|
-
|
|
132
|
+
export const installCodexSkills = (destination: string) => installBundledSkills(destination);
|
|
133
|
+
|
|
134
|
+
export const installOpencodeSkills = (destination: string) => installBundledSkills(destination);
|
|
135
|
+
|
|
136
|
+
const reportSkillsInstall = (label: string, destination: string, installed: string[], skipped: string[]) => {
|
|
137
|
+
process.stdout.write(`[OK] ${label} skills: ${destination}\n`);
|
|
114
138
|
|
|
115
139
|
for (const skill of installed) {
|
|
116
140
|
process.stdout.write(`[OK] Installed ${skill}\n`);
|
|
@@ -121,6 +145,14 @@ const reportCodexInstall = (destination: string, installed: string[], skipped: s
|
|
|
121
145
|
}
|
|
122
146
|
};
|
|
123
147
|
|
|
148
|
+
const reportCodexInstall = (destination: string, installed: string[], skipped: string[]) => {
|
|
149
|
+
reportSkillsInstall("Codex", destination, installed, skipped);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const reportOpencodeInstall = (destination: string, installed: string[], skipped: string[]) => {
|
|
153
|
+
reportSkillsInstall("Opencode", destination, installed, skipped);
|
|
154
|
+
};
|
|
155
|
+
|
|
124
156
|
const parseInitOptions = (args: string[]) => {
|
|
125
157
|
if (args.length === 0) {
|
|
126
158
|
return;
|
|
@@ -141,4 +173,10 @@ export const runInit = async (args: string[], startDir: string) => {
|
|
|
141
173
|
const { installed, skipped } = installCodexSkills(destination);
|
|
142
174
|
reportCodexInstall(destination, installed, skipped);
|
|
143
175
|
}
|
|
176
|
+
|
|
177
|
+
if (providers.includes("opencode")) {
|
|
178
|
+
const destination = resolveOpencodeSkillsRoot(startDir);
|
|
179
|
+
const { installed, skipped } = installOpencodeSkills(destination);
|
|
180
|
+
reportOpencodeInstall(destination, installed, skipped);
|
|
181
|
+
}
|
|
144
182
|
};
|
|
@@ -49,7 +49,7 @@ const seedChange = (schubRoot: string, changeId: string, title: string) => {
|
|
|
49
49
|
|
|
50
50
|
test("review create scaffolds REVIEW_ME from template", () => {
|
|
51
51
|
const { cwd, schubRoot } = createRepo();
|
|
52
|
-
const changeId = "
|
|
52
|
+
const changeId = "C0001_sample-change";
|
|
53
53
|
const changeTitle = "Sample Change";
|
|
54
54
|
seedChange(schubRoot, changeId, changeTitle);
|
|
55
55
|
|
|
@@ -72,7 +72,7 @@ test("review create scaffolds REVIEW_ME from template", () => {
|
|
|
72
72
|
|
|
73
73
|
test("review complete creates Q&A with review content", () => {
|
|
74
74
|
const { cwd, schubRoot } = createRepo();
|
|
75
|
-
const changeId = "
|
|
75
|
+
const changeId = "C0002_review-complete";
|
|
76
76
|
const changeTitle = "Review Complete";
|
|
77
77
|
seedChange(schubRoot, changeId, changeTitle);
|
|
78
78
|
|
package/src/commands/review.ts
CHANGED
|
@@ -171,7 +171,7 @@ export const runReviewCreate = (args: string[], startDir: string) => {
|
|
|
171
171
|
|
|
172
172
|
if (!isValidChangeId(trimmedId)) {
|
|
173
173
|
throw new Error(
|
|
174
|
-
`Invalid change-id '${options.changeId}'. Use kebab-case or a C-prefixed id (e.g.,
|
|
174
|
+
`Invalid change-id '${options.changeId}'. Use kebab-case or a C-prefixed id (e.g., C0001_add-user-auth).`,
|
|
175
175
|
);
|
|
176
176
|
}
|
|
177
177
|
|
|
@@ -49,12 +49,12 @@ const writeExistingTask = (schubRoot: string, status: string, id: string, slug:
|
|
|
49
49
|
test("tasks create scaffolds multiple task files", () => {
|
|
50
50
|
const { repoRoot, cwd } = createRepo();
|
|
51
51
|
const schubRoot = join(repoRoot, ".schub");
|
|
52
|
-
seedProposal(schubRoot, "
|
|
53
|
-
writeExistingTask(schubRoot, "backlog", "
|
|
52
|
+
seedProposal(schubRoot, "C0001_update-cli");
|
|
53
|
+
writeExistingTask(schubRoot, "backlog", "T0003", "existing-task");
|
|
54
54
|
|
|
55
55
|
const { result, stdout } = runTasksCreate(cwd, [
|
|
56
56
|
"--change-id",
|
|
57
|
-
"
|
|
57
|
+
"C0001_update-cli",
|
|
58
58
|
"--status",
|
|
59
59
|
"ready",
|
|
60
60
|
"--title",
|
|
@@ -65,8 +65,8 @@ test("tasks create scaffolds multiple task files", () => {
|
|
|
65
65
|
|
|
66
66
|
expect(result.exitCode).toBe(0);
|
|
67
67
|
|
|
68
|
-
const firstPath = join(schubRoot, "tasks", "ready", "
|
|
69
|
-
const secondPath = join(schubRoot, "tasks", "ready", "
|
|
68
|
+
const firstPath = join(schubRoot, "tasks", "ready", "T0004_first-task.md");
|
|
69
|
+
const secondPath = join(schubRoot, "tasks", "ready", "T0005_second-task.md");
|
|
70
70
|
|
|
71
71
|
expect(existsSync(firstPath)).toBe(true);
|
|
72
72
|
expect(existsSync(secondPath)).toBe(true);
|
|
@@ -74,13 +74,13 @@ test("tasks create scaffolds multiple task files", () => {
|
|
|
74
74
|
const firstContent = readFileSync(firstPath, "utf8");
|
|
75
75
|
const template = readFileSync(taskTemplatePath, "utf8");
|
|
76
76
|
const expectedFirst = template
|
|
77
|
-
.replace("{{TASK_ID}}", "
|
|
77
|
+
.replace("{{TASK_ID}}", "T0004")
|
|
78
78
|
.replace("{{TASK_TITLE}}", "First Task")
|
|
79
|
-
.replace("{{CHANGE_ID}}", "
|
|
79
|
+
.replace("{{CHANGE_ID}}", "C0001_update-cli");
|
|
80
80
|
const expectedSecond = template
|
|
81
|
-
.replace("{{TASK_ID}}", "
|
|
81
|
+
.replace("{{TASK_ID}}", "T0005")
|
|
82
82
|
.replace("{{TASK_TITLE}}", "Second: Task!")
|
|
83
|
-
.replace("{{CHANGE_ID}}", "
|
|
83
|
+
.replace("{{CHANGE_ID}}", "C0001_update-cli");
|
|
84
84
|
|
|
85
85
|
expect(firstContent).toBe(expectedFirst);
|
|
86
86
|
expect(readFileSync(secondPath, "utf8")).toBe(expectedSecond);
|
|
@@ -92,7 +92,7 @@ test("tasks create scaffolds multiple task files", () => {
|
|
|
92
92
|
test("tasks create prefers local template overrides", () => {
|
|
93
93
|
const { repoRoot, cwd } = createRepo();
|
|
94
94
|
const schubRoot = join(repoRoot, ".schub");
|
|
95
|
-
seedProposal(schubRoot, "
|
|
95
|
+
seedProposal(schubRoot, "C0001_update-cli");
|
|
96
96
|
|
|
97
97
|
const localTemplateDir = join(schubRoot, "templates", "create-tasks");
|
|
98
98
|
mkdirSync(localTemplateDir, { recursive: true });
|
|
@@ -101,7 +101,7 @@ test("tasks create prefers local template overrides", () => {
|
|
|
101
101
|
|
|
102
102
|
const { result } = runTasksCreate(cwd, [
|
|
103
103
|
"--change-id",
|
|
104
|
-
"
|
|
104
|
+
"C0001_update-cli",
|
|
105
105
|
"--status",
|
|
106
106
|
"ready",
|
|
107
107
|
"--title",
|
|
@@ -110,12 +110,12 @@ test("tasks create prefers local template overrides", () => {
|
|
|
110
110
|
|
|
111
111
|
expect(result.exitCode).toBe(0);
|
|
112
112
|
|
|
113
|
-
const taskPath = join(schubRoot, "tasks", "ready", "
|
|
113
|
+
const taskPath = join(schubRoot, "tasks", "ready", "T0001_local-task.md");
|
|
114
114
|
const content = readFileSync(taskPath, "utf8");
|
|
115
115
|
const expected = localTemplate
|
|
116
|
-
.replace("{{TASK_ID}}", "
|
|
116
|
+
.replace("{{TASK_ID}}", "T0001")
|
|
117
117
|
.replace("{{TASK_TITLE}}", "Local Task")
|
|
118
|
-
.replace("{{CHANGE_ID}}", "
|
|
118
|
+
.replace("{{CHANGE_ID}}", "C0001_update-cli");
|
|
119
119
|
|
|
120
120
|
expect(content).toBe(expected);
|
|
121
121
|
});
|
|
@@ -123,9 +123,9 @@ test("tasks create prefers local template overrides", () => {
|
|
|
123
123
|
test("tasks create rejects invalid status", () => {
|
|
124
124
|
const { repoRoot, cwd } = createRepo();
|
|
125
125
|
const schubRoot = join(repoRoot, ".schub");
|
|
126
|
-
seedProposal(schubRoot, "
|
|
126
|
+
seedProposal(schubRoot, "C0001_update-cli");
|
|
127
127
|
|
|
128
|
-
const { result, stderr } = runTasksCreate(cwd, ["--change-id", "
|
|
128
|
+
const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C0001_update-cli", "--status", "bad status"]);
|
|
129
129
|
|
|
130
130
|
expect(result.exitCode).not.toBe(0);
|
|
131
131
|
expect(stderr).toContain("Invalid status");
|
|
@@ -136,7 +136,7 @@ test("tasks create rejects missing proposal", () => {
|
|
|
136
136
|
const schubRoot = join(repoRoot, ".schub");
|
|
137
137
|
mkdirSync(join(schubRoot, "changes"), { recursive: true });
|
|
138
138
|
|
|
139
|
-
const { result, stderr } = runTasksCreate(cwd, ["--change-id", "
|
|
139
|
+
const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C0001_missing", "--title", "New Task"]);
|
|
140
140
|
|
|
141
141
|
expect(result.exitCode).not.toBe(0);
|
|
142
142
|
expect(stderr).toContain("Required change files missing");
|
|
@@ -145,9 +145,9 @@ test("tasks create rejects missing proposal", () => {
|
|
|
145
145
|
test("tasks create rejects non-accepted proposals", () => {
|
|
146
146
|
const { repoRoot, cwd } = createRepo();
|
|
147
147
|
const schubRoot = join(repoRoot, ".schub");
|
|
148
|
-
seedProposal(schubRoot, "
|
|
148
|
+
seedProposal(schubRoot, "C0001_update-cli", "Draft");
|
|
149
149
|
|
|
150
|
-
const { result, stderr } = runTasksCreate(cwd, ["--change-id", "
|
|
150
|
+
const { result, stderr } = runTasksCreate(cwd, ["--change-id", "C0001_update-cli", "--title", "New Task"]);
|
|
151
151
|
|
|
152
152
|
expect(result.exitCode).not.toBe(0);
|
|
153
153
|
expect(stderr).toContain("Mark the proposal as Accepted");
|
|
@@ -156,11 +156,11 @@ test("tasks create rejects non-accepted proposals", () => {
|
|
|
156
156
|
test("tasks create rejects schub root flags", () => {
|
|
157
157
|
const { repoRoot, cwd } = createRepo();
|
|
158
158
|
const schubRoot = join(repoRoot, ".schub");
|
|
159
|
-
seedProposal(schubRoot, "
|
|
159
|
+
seedProposal(schubRoot, "C0001_update-cli");
|
|
160
160
|
|
|
161
161
|
const { result, stderr } = runTasksCreate(cwd, [
|
|
162
162
|
"--change-id",
|
|
163
|
-
"
|
|
163
|
+
"C0001_update-cli",
|
|
164
164
|
"--title",
|
|
165
165
|
"New Task",
|
|
166
166
|
"--schub-root",
|
|
@@ -48,44 +48,44 @@ const writeTask = (tasksRoot: string, status: string, id: string, slug: string,
|
|
|
48
48
|
|
|
49
49
|
test("tasks list shows tasks sorted by id", () => {
|
|
50
50
|
const { cwd, tasksRoot } = createTaskRepo();
|
|
51
|
-
writeTask(tasksRoot, "ready", "
|
|
52
|
-
writeTask(tasksRoot, "backlog", "
|
|
53
|
-
writeTask(tasksRoot, "archived", "
|
|
54
|
-
writeTask(tasksRoot, "wip", "
|
|
51
|
+
writeTask(tasksRoot, "ready", "T0010", "later-task");
|
|
52
|
+
writeTask(tasksRoot, "backlog", "T0002", "middle-task");
|
|
53
|
+
writeTask(tasksRoot, "archived", "T0003", "archived-task");
|
|
54
|
+
writeTask(tasksRoot, "wip", "T0001", "first-task");
|
|
55
55
|
|
|
56
56
|
const { result, stdout } = runCli(cwd);
|
|
57
57
|
|
|
58
58
|
expect(result.exitCode).toBe(0);
|
|
59
59
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
60
60
|
expect(lines).toHaveLength(4);
|
|
61
|
-
expect(lines[0]).toContain("
|
|
61
|
+
expect(lines[0]).toContain("T0001");
|
|
62
62
|
expect(lines[0]).toContain("first task");
|
|
63
63
|
expect(lines[0]).toContain("(wip)");
|
|
64
|
-
expect(lines[1]).toContain("
|
|
65
|
-
expect(lines[2]).toContain("
|
|
66
|
-
expect(lines[3]).toContain("
|
|
64
|
+
expect(lines[1]).toContain("T0002");
|
|
65
|
+
expect(lines[2]).toContain("T0003");
|
|
66
|
+
expect(lines[3]).toContain("T0010");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
69
|
test("tasks list filters by status", () => {
|
|
70
70
|
const { cwd, tasksRoot } = createTaskRepo();
|
|
71
|
-
writeTask(tasksRoot, "ready", "
|
|
72
|
-
writeTask(tasksRoot, "backlog", "
|
|
73
|
-
writeTask(tasksRoot, "wip", "
|
|
71
|
+
writeTask(tasksRoot, "ready", "T0010", "later-task");
|
|
72
|
+
writeTask(tasksRoot, "backlog", "T0002", "middle-task");
|
|
73
|
+
writeTask(tasksRoot, "wip", "T0001", "first-task");
|
|
74
74
|
|
|
75
75
|
const { result, stdout } = runCli(cwd, ["--status", "ready,wip"]);
|
|
76
76
|
|
|
77
77
|
expect(result.exitCode).toBe(0);
|
|
78
78
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
79
79
|
expect(lines).toHaveLength(2);
|
|
80
|
-
expect(lines.join("\n")).toContain("
|
|
81
|
-
expect(lines.join("\n")).toContain("
|
|
82
|
-
expect(lines.join("\n")).not.toContain("
|
|
80
|
+
expect(lines.join("\n")).toContain("T0010");
|
|
81
|
+
expect(lines.join("\n")).toContain("T0001");
|
|
82
|
+
expect(lines.join("\n")).not.toContain("T0002");
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
test("tasks list supports json output", () => {
|
|
86
86
|
const { cwd, tasksRoot } = createTaskRepo();
|
|
87
|
-
writeTask(tasksRoot, "ready", "
|
|
88
|
-
writeTask(tasksRoot, "wip", "
|
|
87
|
+
writeTask(tasksRoot, "ready", "T0010", "later-task");
|
|
88
|
+
writeTask(tasksRoot, "wip", "T0001", "first-task");
|
|
89
89
|
|
|
90
90
|
const { result, stdout } = runCli(cwd, ["--json"]);
|
|
91
91
|
|
|
@@ -97,11 +97,11 @@ test("tasks list supports json output", () => {
|
|
|
97
97
|
path: string;
|
|
98
98
|
}>;
|
|
99
99
|
expect(tasks[0]).toMatchObject({
|
|
100
|
-
id: "
|
|
100
|
+
id: "T0001",
|
|
101
101
|
title: "first task",
|
|
102
102
|
status: "wip",
|
|
103
103
|
});
|
|
104
|
-
expect(tasks[0].path).toContain(".schub/tasks/wip/
|
|
104
|
+
expect(tasks[0].path).toContain(".schub/tasks/wip/T0001_first-task.md");
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("tasks list shows checklist counts in text output", () => {
|
|
@@ -109,10 +109,10 @@ test("tasks list shows checklist counts in text output", () => {
|
|
|
109
109
|
writeTask(
|
|
110
110
|
tasksRoot,
|
|
111
111
|
"wip",
|
|
112
|
-
"
|
|
112
|
+
"T0001",
|
|
113
113
|
"first-task",
|
|
114
114
|
[
|
|
115
|
-
"# Task:
|
|
115
|
+
"# Task: T0001 First task",
|
|
116
116
|
"",
|
|
117
117
|
"## Steps",
|
|
118
118
|
"- [ ] Draft outline",
|
|
@@ -123,17 +123,17 @@ test("tasks list shows checklist counts in text output", () => {
|
|
|
123
123
|
"",
|
|
124
124
|
].join("\n"),
|
|
125
125
|
);
|
|
126
|
-
writeTask(tasksRoot, "backlog", "
|
|
126
|
+
writeTask(tasksRoot, "backlog", "T0002", "second-task");
|
|
127
127
|
|
|
128
128
|
const { result, stdout } = runCli(cwd);
|
|
129
129
|
|
|
130
130
|
expect(result.exitCode).toBe(0);
|
|
131
131
|
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
132
132
|
expect(lines).toHaveLength(2);
|
|
133
|
-
expect(lines[0]).toContain("
|
|
133
|
+
expect(lines[0]).toContain("T0001");
|
|
134
134
|
expect(lines[0]).toContain("(wip)");
|
|
135
135
|
expect(lines[0]).toContain("(1/2)");
|
|
136
|
-
expect(lines[1]).toContain("
|
|
136
|
+
expect(lines[1]).toContain("T0002");
|
|
137
137
|
expect(lines[1]).not.toMatch(/\(\d+\/\d+\)/);
|
|
138
138
|
});
|
|
139
139
|
|
|
@@ -142,11 +142,11 @@ test("tasks list json includes checklist counts", () => {
|
|
|
142
142
|
writeTask(
|
|
143
143
|
tasksRoot,
|
|
144
144
|
"ready",
|
|
145
|
-
"
|
|
145
|
+
"T0001",
|
|
146
146
|
"first-task",
|
|
147
|
-
["# Task:
|
|
147
|
+
["# Task: T0001 First task", "", "## Steps", "- [ ] Draft outline", "- [x] Review outline", ""].join("\n"),
|
|
148
148
|
);
|
|
149
|
-
writeTask(tasksRoot, "ready", "
|
|
149
|
+
writeTask(tasksRoot, "ready", "T0002", "second-task");
|
|
150
150
|
|
|
151
151
|
const { result, stdout } = runCli(cwd, ["--json"]);
|
|
152
152
|
|
|
@@ -160,7 +160,7 @@ test("tasks list json includes checklist counts", () => {
|
|
|
160
160
|
checklistTotal?: number;
|
|
161
161
|
}>;
|
|
162
162
|
expect(tasks[0]).toMatchObject({
|
|
163
|
-
id: "
|
|
163
|
+
id: "T0001",
|
|
164
164
|
checklistRemaining: 1,
|
|
165
165
|
checklistTotal: 2,
|
|
166
166
|
});
|