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
|
@@ -35,7 +35,7 @@ const createRepo = () => {
|
|
|
35
35
|
const seedProposal = (schubRoot: string, changeId: string, status = "Accepted") => {
|
|
36
36
|
const changeDir = join(schubRoot, "changes", changeId);
|
|
37
37
|
mkdirSync(changeDir, { recursive: true });
|
|
38
|
-
writeFileSync(join(changeDir, "proposal.md"),
|
|
38
|
+
writeFileSync(join(changeDir, "proposal.md"), `---\nstatus: ${status}\n---\n# Proposal - Seed\n`, "utf8");
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
const writeExistingTask = (schubRoot: string, status: string, id: string, slug: string) => {
|
|
@@ -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",
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { delimiter, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { spawnSync } from "bun";
|
|
7
|
+
|
|
8
|
+
const testDir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const cliDir = resolve(testDir, "..", "..");
|
|
10
|
+
const decoder = new TextDecoder();
|
|
11
|
+
|
|
12
|
+
const runTasksImplement = (schubCwd: string, args: string[], envOverrides: NodeJS.ProcessEnv = {}) => {
|
|
13
|
+
const result = spawnSync({
|
|
14
|
+
cmd: ["bun", "run", "schub", "tasks", "implement", ...args],
|
|
15
|
+
cwd: cliDir,
|
|
16
|
+
env: { ...process.env, FORCE_COLOR: "0", SCHUB_CWD: schubCwd, ...envOverrides },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
result,
|
|
21
|
+
stdout: decoder.decode(result.stdout ?? new Uint8Array()),
|
|
22
|
+
stderr: decoder.decode(result.stderr ?? new Uint8Array()),
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const createTaskRepo = () => {
|
|
27
|
+
const base = mkdtempSync(join(tmpdir(), "schub-tasks-implement-"));
|
|
28
|
+
const repoRoot = join(base, "repo");
|
|
29
|
+
const cwd = join(repoRoot, "nested", "dir");
|
|
30
|
+
mkdirSync(cwd, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const tasksRoot = join(repoRoot, ".schub", "tasks");
|
|
33
|
+
const statuses = ["backlog", "ready", "wip", "blocked", "done", "archived"];
|
|
34
|
+
|
|
35
|
+
for (const status of statuses) {
|
|
36
|
+
mkdirSync(join(tasksRoot, status), { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { base, cwd, repoRoot, tasksRoot };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const writeTask = (tasksRoot: string, status: string, id: string, slug: string) => {
|
|
43
|
+
const filePath = join(tasksRoot, status, `${id}_${slug}.md`);
|
|
44
|
+
writeFileSync(filePath, `# Task: ${id} ${slug}\n`, "utf8");
|
|
45
|
+
return filePath;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const writeOpencodeStub = (baseDir: string) => {
|
|
49
|
+
const binDir = join(baseDir, "bin");
|
|
50
|
+
mkdirSync(binDir, { recursive: true });
|
|
51
|
+
const stubPath = join(binDir, "opencode");
|
|
52
|
+
writeFileSync(
|
|
53
|
+
stubPath,
|
|
54
|
+
[
|
|
55
|
+
"#!/usr/bin/env bash",
|
|
56
|
+
'if [ -n "$OPENCODE_LOG_FILE" ]; then',
|
|
57
|
+
' printf "%s" "$SCHUB_CWD" > "$OPENCODE_LOG_FILE"',
|
|
58
|
+
"fi",
|
|
59
|
+
"",
|
|
60
|
+
].join("\n"),
|
|
61
|
+
"utf8",
|
|
62
|
+
);
|
|
63
|
+
chmodSync(stubPath, 0o755);
|
|
64
|
+
return binDir;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const writeGitStub = (baseDir: string) => {
|
|
68
|
+
const binDir = join(baseDir, "bin");
|
|
69
|
+
mkdirSync(binDir, { recursive: true });
|
|
70
|
+
const stubPath = join(binDir, "git");
|
|
71
|
+
writeFileSync(stubPath, ["#!/usr/bin/env bash", "exit 1", ""].join("\n"), "utf8");
|
|
72
|
+
chmodSync(stubPath, 0o755);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const runGit = (repoRoot: string, args: string[]) => {
|
|
76
|
+
return spawnSync({
|
|
77
|
+
cmd: ["git", ...args],
|
|
78
|
+
cwd: repoRoot,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const runGitChecked = (repoRoot: string, args: string[]) => {
|
|
83
|
+
const result = runGit(repoRoot, args);
|
|
84
|
+
if (result.exitCode !== 0) {
|
|
85
|
+
const stderr = decoder.decode(result.stderr ?? new Uint8Array());
|
|
86
|
+
throw new Error(`Git command failed: git ${args.join(" ")}\n${stderr}`);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const initGitRepo = (repoRoot: string) => {
|
|
92
|
+
runGitChecked(repoRoot, ["init"]);
|
|
93
|
+
writeFileSync(join(repoRoot, "README.md"), "test", "utf8");
|
|
94
|
+
runGitChecked(repoRoot, ["add", "."]);
|
|
95
|
+
runGitChecked(repoRoot, ["-c", "user.email=schub@example.com", "-c", "user.name=Schub", "commit", "-m", "init"]);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const waitForFile = async (filePath: string) => {
|
|
99
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
100
|
+
if (existsSync(filePath)) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
throw new Error(`Timed out waiting for ${filePath}`);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
test("tasks implement --mode none moves task to wip and launches opencode from repo root", async () => {
|
|
110
|
+
const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
111
|
+
writeTask(tasksRoot, "ready", "T0008", "none-mode");
|
|
112
|
+
|
|
113
|
+
const logPath = join(base, "opencode-log.txt");
|
|
114
|
+
const binDir = writeOpencodeStub(base);
|
|
115
|
+
const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
|
|
116
|
+
|
|
117
|
+
const { result } = runTasksImplement(
|
|
118
|
+
cwd,
|
|
119
|
+
["--id", "T0008", "--mode", "none", "--worktree-root", join(repoRoot, ".schub", "worktrees")],
|
|
120
|
+
{
|
|
121
|
+
PATH: path,
|
|
122
|
+
OPENCODE_LOG_FILE: logPath,
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(result.exitCode).toBe(0);
|
|
127
|
+
|
|
128
|
+
const wipPath = join(tasksRoot, "wip", "T0008_none-mode.md");
|
|
129
|
+
expect(existsSync(wipPath)).toBe(true);
|
|
130
|
+
expect(existsSync(join(tasksRoot, "ready", "T0008_none-mode.md"))).toBe(false);
|
|
131
|
+
|
|
132
|
+
await waitForFile(logPath);
|
|
133
|
+
const loggedCwd = readFileSync(logPath, "utf8");
|
|
134
|
+
expect(realpathSync(loggedCwd)).toBe(realpathSync(repoRoot));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("tasks implement --mode worktree creates a worktree and launches opencode from worktree root", async () => {
|
|
138
|
+
const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
139
|
+
initGitRepo(repoRoot);
|
|
140
|
+
writeTask(tasksRoot, "ready", "T0009", "worktree-mode");
|
|
141
|
+
|
|
142
|
+
const logPath = join(base, "opencode-log.txt");
|
|
143
|
+
const binDir = writeOpencodeStub(base);
|
|
144
|
+
const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
|
|
145
|
+
|
|
146
|
+
const { result } = runTasksImplement(cwd, ["--id", "T0009", "--mode", "worktree"], {
|
|
147
|
+
PATH: path,
|
|
148
|
+
OPENCODE_LOG_FILE: logPath,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
expect(result.exitCode).toBe(0);
|
|
152
|
+
|
|
153
|
+
const wipPath = join(tasksRoot, "wip", "T0009_worktree-mode.md");
|
|
154
|
+
expect(existsSync(wipPath)).toBe(true);
|
|
155
|
+
|
|
156
|
+
const worktreePath = join(repoRoot, ".schub", "worktrees", "T0009");
|
|
157
|
+
expect(existsSync(worktreePath)).toBe(true);
|
|
158
|
+
|
|
159
|
+
const branchResult = runGit(repoRoot, ["rev-parse", "--verify", "task/T0009"]);
|
|
160
|
+
expect(branchResult.exitCode).toBe(0);
|
|
161
|
+
|
|
162
|
+
await waitForFile(logPath);
|
|
163
|
+
const loggedCwd = readFileSync(logPath, "utf8");
|
|
164
|
+
expect(realpathSync(loggedCwd)).toBe(realpathSync(worktreePath));
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("tasks implement --mode worktree respects --worktree-root override", async () => {
|
|
168
|
+
const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
169
|
+
initGitRepo(repoRoot);
|
|
170
|
+
writeTask(tasksRoot, "ready", "T0010", "worktree-root");
|
|
171
|
+
|
|
172
|
+
const logPath = join(base, "opencode-log.txt");
|
|
173
|
+
const binDir = writeOpencodeStub(base);
|
|
174
|
+
const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
|
|
175
|
+
|
|
176
|
+
const { result } = runTasksImplement(
|
|
177
|
+
cwd,
|
|
178
|
+
["--id", "T0010", "--mode", "worktree", "--worktree-root", ".schub/custom-worktrees"],
|
|
179
|
+
{
|
|
180
|
+
PATH: path,
|
|
181
|
+
OPENCODE_LOG_FILE: logPath,
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(result.exitCode).toBe(0);
|
|
186
|
+
|
|
187
|
+
const worktreePath = join(repoRoot, ".schub", "custom-worktrees", "T0010");
|
|
188
|
+
expect(existsSync(worktreePath)).toBe(true);
|
|
189
|
+
|
|
190
|
+
await waitForFile(logPath);
|
|
191
|
+
const loggedCwd = readFileSync(logPath, "utf8");
|
|
192
|
+
expect(realpathSync(loggedCwd)).toBe(realpathSync(worktreePath));
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("tasks implement --mode worktree errors when branch exists", () => {
|
|
196
|
+
const { cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
197
|
+
initGitRepo(repoRoot);
|
|
198
|
+
writeTask(tasksRoot, "ready", "T0011", "branch-exists");
|
|
199
|
+
runGitChecked(repoRoot, ["branch", "task/T0011"]);
|
|
200
|
+
|
|
201
|
+
const { result, stderr } = runTasksImplement(cwd, ["--id", "T0011", "--mode", "worktree"]);
|
|
202
|
+
|
|
203
|
+
expect(result.exitCode).not.toBe(0);
|
|
204
|
+
expect(stderr).toContain("Branch task/T0011 already exists");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("tasks implement --mode worktree errors when worktree path exists", () => {
|
|
208
|
+
const { cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
209
|
+
initGitRepo(repoRoot);
|
|
210
|
+
writeTask(tasksRoot, "ready", "T0012", "worktree-exists");
|
|
211
|
+
|
|
212
|
+
const worktreePath = join(repoRoot, ".schub", "worktrees", "T0012");
|
|
213
|
+
mkdirSync(worktreePath, { recursive: true });
|
|
214
|
+
|
|
215
|
+
const { result, stderr } = runTasksImplement(cwd, ["--id", "T0012", "--mode", "worktree"]);
|
|
216
|
+
|
|
217
|
+
expect(result.exitCode).not.toBe(0);
|
|
218
|
+
expect(stderr).toContain("Worktree path already exists");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("tasks implement --mode worktree falls back to none when git is unavailable", async () => {
|
|
222
|
+
const { base, cwd, repoRoot, tasksRoot } = createTaskRepo();
|
|
223
|
+
initGitRepo(repoRoot);
|
|
224
|
+
writeTask(tasksRoot, "ready", "T0013", "git-missing");
|
|
225
|
+
|
|
226
|
+
const logPath = join(base, "opencode-log.txt");
|
|
227
|
+
const binDir = writeOpencodeStub(base);
|
|
228
|
+
writeGitStub(base);
|
|
229
|
+
const path = [binDir, process.env.PATH ?? ""].filter(Boolean).join(delimiter);
|
|
230
|
+
|
|
231
|
+
const { result } = runTasksImplement(cwd, ["--id", "T0013", "--mode", "worktree"], {
|
|
232
|
+
PATH: path,
|
|
233
|
+
OPENCODE_LOG_FILE: logPath,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.exitCode).toBe(0);
|
|
237
|
+
|
|
238
|
+
const worktreePath = join(repoRoot, ".schub", "worktrees", "T0013");
|
|
239
|
+
expect(existsSync(worktreePath)).toBe(false);
|
|
240
|
+
|
|
241
|
+
await waitForFile(logPath);
|
|
242
|
+
const loggedCwd = readFileSync(logPath, "utf8");
|
|
243
|
+
expect(realpathSync(loggedCwd)).toBe(realpathSync(repoRoot));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("tasks implement errors when task id is not found", () => {
|
|
247
|
+
const { cwd } = createTaskRepo();
|
|
248
|
+
|
|
249
|
+
const { result, stderr } = runTasksImplement(cwd, ["--id", "T9999", "--mode", "none"]);
|
|
250
|
+
|
|
251
|
+
expect(result.exitCode).not.toBe(0);
|
|
252
|
+
expect(stderr).toContain("Task T9999 not found");
|
|
253
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { assignTaskToWip, createTaskWorktree } from "../features/tasks";
|
|
3
|
+
import { launchOpencodeImplement } from "../opencode";
|
|
4
|
+
|
|
5
|
+
type TaskImplementMode = "none" | "worktree";
|
|
6
|
+
|
|
7
|
+
type TaskImplementOptions = {
|
|
8
|
+
taskId: string;
|
|
9
|
+
mode: TaskImplementMode;
|
|
10
|
+
worktreeRoot?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const parseTaskImplementOptions = (args: string[]) => {
|
|
14
|
+
let taskId: string | undefined;
|
|
15
|
+
let mode: string | undefined;
|
|
16
|
+
let worktreeRoot: string | undefined;
|
|
17
|
+
const unknown: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
20
|
+
const arg = args[index];
|
|
21
|
+
if (arg === "--id") {
|
|
22
|
+
const value = args[index + 1];
|
|
23
|
+
if (value === undefined) {
|
|
24
|
+
throw new Error("Missing value for --id.");
|
|
25
|
+
}
|
|
26
|
+
taskId = value;
|
|
27
|
+
index += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg.startsWith("--id=")) {
|
|
31
|
+
taskId = arg.slice("--id=".length);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === "--mode") {
|
|
35
|
+
const value = args[index + 1];
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
throw new Error("Missing value for --mode.");
|
|
38
|
+
}
|
|
39
|
+
mode = value;
|
|
40
|
+
index += 1;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (arg.startsWith("--mode=")) {
|
|
44
|
+
mode = arg.slice("--mode=".length);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (arg === "--worktree-root") {
|
|
48
|
+
const value = args[index + 1];
|
|
49
|
+
if (value === undefined) {
|
|
50
|
+
throw new Error("Missing value for --worktree-root.");
|
|
51
|
+
}
|
|
52
|
+
worktreeRoot = value;
|
|
53
|
+
index += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg.startsWith("--worktree-root=")) {
|
|
57
|
+
worktreeRoot = arg.slice("--worktree-root=".length);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
unknown.push(arg);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (unknown.length > 0) {
|
|
64
|
+
throw new Error(`Unknown option(s): ${unknown.join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!taskId || taskId.trim() === "") {
|
|
68
|
+
throw new Error("Provide --id.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (mode !== undefined && mode.trim() === "") {
|
|
72
|
+
throw new Error("Missing value for --mode.");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (worktreeRoot !== undefined && worktreeRoot.trim() === "") {
|
|
76
|
+
throw new Error("Missing value for --worktree-root.");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const normalizedMode = (mode ?? "none").trim().toLowerCase();
|
|
80
|
+
const allowedModes: TaskImplementMode[] = ["none", "worktree"];
|
|
81
|
+
if (!allowedModes.includes(normalizedMode as TaskImplementMode)) {
|
|
82
|
+
throw new Error(`Invalid mode '${mode}'. Use none or worktree.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const options: TaskImplementOptions = {
|
|
86
|
+
taskId: taskId.trim().toUpperCase(),
|
|
87
|
+
mode: normalizedMode as TaskImplementMode,
|
|
88
|
+
worktreeRoot: worktreeRoot?.trim() || undefined,
|
|
89
|
+
};
|
|
90
|
+
return options;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const runTasksImplement = (schubDir: string | null, args: string[]) => {
|
|
94
|
+
if (!schubDir) {
|
|
95
|
+
throw new Error("No .schub directory found.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const options = parseTaskImplementOptions(args);
|
|
99
|
+
const assigned = assignTaskToWip(schubDir, options.taskId);
|
|
100
|
+
const repoRoot = dirname(schubDir);
|
|
101
|
+
|
|
102
|
+
let launchRoot = repoRoot;
|
|
103
|
+
|
|
104
|
+
if (options.mode === "worktree") {
|
|
105
|
+
const worktree = createTaskWorktree({
|
|
106
|
+
repoRoot,
|
|
107
|
+
taskId: options.taskId,
|
|
108
|
+
worktreeRoot: options.worktreeRoot,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (worktree) {
|
|
112
|
+
launchRoot = worktree.worktreePath;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
launchOpencodeImplement(options.taskId, repoRoot, assigned.title, {
|
|
117
|
+
env: { ...process.env, SCHUB_CWD: launchRoot },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
process.stdout.write(`[OK] Assigned task ${assigned.id}: ${assigned.status}\n`);
|
|
121
|
+
};
|
|
@@ -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
|
});
|