@markjaquith/agency 2.4.0 → 2.6.0
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 +56 -20
- package/cli.ts +43 -2
- package/package.json +1 -1
- package/skills/agency/SKILL.md +41 -5
- package/src/cli.test.ts +31 -3
- package/src/commands/archive.test.ts +69 -0
- package/src/commands/archive.ts +70 -0
- package/src/commands/init.test.ts +3 -0
- package/src/commands/phase.ts +23 -1
- package/src/commands/task-phase.test.ts +46 -1
- package/src/commands/task.test.ts +94 -0
- package/src/commands/task.ts +160 -17
- package/src/commands/validate.test.ts +65 -0
- package/src/commands/validate.ts +19 -5
- package/src/commands/work.test.ts +100 -34
- package/src/commands/work.ts +30 -10
- package/src/commands/workbase.test.ts +62 -0
- package/src/commands/workbase.ts +58 -0
- package/src/services/ArchiveService.test.ts +334 -0
- package/src/services/ArchiveService.ts +246 -0
- package/src/services/PhaseService.ts +28 -0
- package/src/services/TaskPhaseService.test.ts +79 -0
- package/src/services/TaskService.ts +31 -1
- package/src/services/WorkbaseService.test.ts +99 -1
- package/src/services/WorkbaseService.ts +101 -2
- package/src/services/WorktreeService.test.ts +150 -1
- package/src/services/WorktreeService.ts +123 -1
- package/src/test-utils.ts +2 -0
- package/src/utils/progress.test.ts +37 -0
- package/src/utils/progress.ts +36 -0
- package/src/workbase/AGENTS.md +3 -0
- package/src/workbase/opencode-file.ts +37 -0
- package/src/workbase/schemas.test.ts +114 -0
- package/src/workbase/schemas.ts +18 -2
- package/src/workbase/work-target.test.ts +16 -9
- package/src/workbase/work-target.ts +37 -6
- package/src/workbase/workbase-choice.ts +71 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { ArchiveService } from "./ArchiveService"
|
|
7
|
+
import { EpicService } from "./EpicService"
|
|
8
|
+
import { PhaseService } from "./PhaseService"
|
|
9
|
+
import { TaskService } from "./TaskService"
|
|
10
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
11
|
+
import { WorktreeService } from "./WorktreeService"
|
|
12
|
+
|
|
13
|
+
const git = async (args: string[], cwd?: string) => {
|
|
14
|
+
const process = Bun.spawn(["git", ...args], {
|
|
15
|
+
cwd,
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
})
|
|
19
|
+
await process.exited
|
|
20
|
+
if (process.exitCode !== 0) {
|
|
21
|
+
throw new Error(await new Response(process.stderr).text())
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("ArchiveService", () => {
|
|
26
|
+
let root: string
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
root = await createTempDir()
|
|
30
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
31
|
+
const source = join(root, "source")
|
|
32
|
+
await mkdir(source, { recursive: true })
|
|
33
|
+
await git(["init", "--initial-branch=main"], source)
|
|
34
|
+
await git(["config", "user.email", "test@example.com"], source)
|
|
35
|
+
await git(["config", "user.name", "Test"], source)
|
|
36
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
37
|
+
await git(["add", "README.md"], source)
|
|
38
|
+
await git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
|
|
39
|
+
await mkdir(join(root, "repos"), { recursive: true })
|
|
40
|
+
await git(["clone", "--bare", source, join(root, "repos/agency")])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(async () => cleanupTempDir(root))
|
|
44
|
+
|
|
45
|
+
test("archives a task after removing its worktree and preserves its branch", async () => {
|
|
46
|
+
await runTestEffect(
|
|
47
|
+
EpicService.pipe(
|
|
48
|
+
Effect.flatMap((service) =>
|
|
49
|
+
service.create(
|
|
50
|
+
"parent",
|
|
51
|
+
"https://example.com/epic",
|
|
52
|
+
[{ repo: "agency", ref: "main" }],
|
|
53
|
+
root,
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
await runTestEffect(
|
|
59
|
+
TaskService.pipe(
|
|
60
|
+
Effect.flatMap((service) =>
|
|
61
|
+
service.create(
|
|
62
|
+
{
|
|
63
|
+
id: "child",
|
|
64
|
+
ticketUrl: "https://example.com/task",
|
|
65
|
+
epic: "parent",
|
|
66
|
+
repo: "agency",
|
|
67
|
+
branch: "task/child",
|
|
68
|
+
base: "main",
|
|
69
|
+
},
|
|
70
|
+
root,
|
|
71
|
+
),
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
await runTestEffect(
|
|
76
|
+
WorktreeService.pipe(
|
|
77
|
+
Effect.flatMap((service) =>
|
|
78
|
+
service.materialize("child", undefined, root),
|
|
79
|
+
),
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const result = await runTestEffect(
|
|
84
|
+
ArchiveService.pipe(
|
|
85
|
+
Effect.flatMap((service) => service.archiveTask("child", root)),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
expect(result.path).toBe(join(root, "archive/tasks/child"))
|
|
90
|
+
expect(await Bun.file(join(root, "tasks/child/TASK.md")).exists()).toBe(
|
|
91
|
+
false,
|
|
92
|
+
)
|
|
93
|
+
expect(await Bun.file(join(result.path, "TASK.md")).exists()).toBe(true)
|
|
94
|
+
expect(await Bun.file(join(result.path, "code")).exists()).toBe(false)
|
|
95
|
+
const epic = await runTestEffect(
|
|
96
|
+
EpicService.pipe(
|
|
97
|
+
Effect.flatMap((service) => service.show("parent", root)),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
expect(epic.data.tasks).toEqual([])
|
|
101
|
+
const report = await runTestEffect(
|
|
102
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
|
|
103
|
+
)
|
|
104
|
+
expect(report.valid).toBe(true)
|
|
105
|
+
expect(
|
|
106
|
+
Bun.spawnSync([
|
|
107
|
+
"git",
|
|
108
|
+
"-C",
|
|
109
|
+
join(root, "repos/agency"),
|
|
110
|
+
"show-ref",
|
|
111
|
+
"--verify",
|
|
112
|
+
"refs/heads/task/child",
|
|
113
|
+
]).exitCode,
|
|
114
|
+
).toBe(0)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("archives a phase in the mirrored task hierarchy", async () => {
|
|
118
|
+
await runTestEffect(
|
|
119
|
+
TaskService.pipe(
|
|
120
|
+
Effect.flatMap((service) =>
|
|
121
|
+
service.create(
|
|
122
|
+
{
|
|
123
|
+
id: "multi",
|
|
124
|
+
ticketUrl: "https://example.com/task",
|
|
125
|
+
multiPhase: true,
|
|
126
|
+
},
|
|
127
|
+
root,
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
for (const id of ["first", "second"]) {
|
|
133
|
+
await runTestEffect(
|
|
134
|
+
PhaseService.pipe(
|
|
135
|
+
Effect.flatMap((service) =>
|
|
136
|
+
service.create(
|
|
137
|
+
{
|
|
138
|
+
taskId: "multi",
|
|
139
|
+
id,
|
|
140
|
+
repo: "agency",
|
|
141
|
+
branch: `task/${id}`,
|
|
142
|
+
base: "main",
|
|
143
|
+
},
|
|
144
|
+
root,
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
await runTestEffect(
|
|
151
|
+
WorktreeService.pipe(
|
|
152
|
+
Effect.flatMap((service) =>
|
|
153
|
+
service.materialize("multi", "second", root),
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
const result = await runTestEffect(
|
|
159
|
+
ArchiveService.pipe(
|
|
160
|
+
Effect.flatMap((service) =>
|
|
161
|
+
service.archivePhase("multi", "second", root),
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
expect(result.path).toBe(join(root, "archive/tasks/multi/phases/second"))
|
|
167
|
+
expect(await Bun.file(join(result.path, "PHASE.md")).exists()).toBe(true)
|
|
168
|
+
expect(await Bun.file(join(result.path, "code")).exists()).toBe(false)
|
|
169
|
+
const task = await runTestEffect(
|
|
170
|
+
TaskService.pipe(
|
|
171
|
+
Effect.flatMap((service) => service.show("multi", root)),
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
expect("phases" in task.data && task.data.phases).toEqual([{ id: "first" }])
|
|
175
|
+
const report = await runTestEffect(
|
|
176
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
|
|
177
|
+
)
|
|
178
|
+
expect(report.valid).toBe(true)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test("archives an epic and all of its child task folders", async () => {
|
|
182
|
+
await runTestEffect(
|
|
183
|
+
EpicService.pipe(
|
|
184
|
+
Effect.flatMap((service) =>
|
|
185
|
+
service.create(
|
|
186
|
+
"initiative",
|
|
187
|
+
"https://example.com/epic",
|
|
188
|
+
[{ repo: "agency", ref: "main" }],
|
|
189
|
+
root,
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
await runTestEffect(
|
|
195
|
+
TaskService.pipe(
|
|
196
|
+
Effect.flatMap((service) =>
|
|
197
|
+
service.create(
|
|
198
|
+
{
|
|
199
|
+
id: "delivery",
|
|
200
|
+
ticketUrl: "https://example.com/task",
|
|
201
|
+
epic: "initiative",
|
|
202
|
+
repo: "agency",
|
|
203
|
+
branch: "task/delivery",
|
|
204
|
+
base: "main",
|
|
205
|
+
},
|
|
206
|
+
root,
|
|
207
|
+
),
|
|
208
|
+
),
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
const result = await runTestEffect(
|
|
213
|
+
ArchiveService.pipe(
|
|
214
|
+
Effect.flatMap((service) => service.archiveEpic("initiative", root)),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
expect(result.archivedPaths).toEqual([
|
|
219
|
+
join(root, "archive/tasks/delivery"),
|
|
220
|
+
join(root, "archive/epics/initiative"),
|
|
221
|
+
])
|
|
222
|
+
expect(
|
|
223
|
+
await Bun.file(join(root, "archive/epics/initiative/EPIC.md")).exists(),
|
|
224
|
+
).toBe(true)
|
|
225
|
+
expect(
|
|
226
|
+
await Bun.file(join(root, "archive/tasks/delivery/TASK.md")).exists(),
|
|
227
|
+
).toBe(true)
|
|
228
|
+
const report = await runTestEffect(
|
|
229
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
|
|
230
|
+
)
|
|
231
|
+
expect(report.valid).toBe(true)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test("rejects archiving an item required by an active sibling", async () => {
|
|
235
|
+
await runTestEffect(
|
|
236
|
+
TaskService.pipe(
|
|
237
|
+
Effect.flatMap((service) =>
|
|
238
|
+
service.create(
|
|
239
|
+
{
|
|
240
|
+
id: "multi",
|
|
241
|
+
ticketUrl: "https://example.com/task",
|
|
242
|
+
multiPhase: true,
|
|
243
|
+
},
|
|
244
|
+
root,
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
await runTestEffect(
|
|
250
|
+
PhaseService.pipe(
|
|
251
|
+
Effect.flatMap((service) =>
|
|
252
|
+
service.create(
|
|
253
|
+
{
|
|
254
|
+
taskId: "multi",
|
|
255
|
+
id: "first",
|
|
256
|
+
repo: "agency",
|
|
257
|
+
branch: "task/first",
|
|
258
|
+
base: "main",
|
|
259
|
+
},
|
|
260
|
+
root,
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
await runTestEffect(
|
|
266
|
+
PhaseService.pipe(
|
|
267
|
+
Effect.flatMap((service) =>
|
|
268
|
+
service.create(
|
|
269
|
+
{
|
|
270
|
+
taskId: "multi",
|
|
271
|
+
id: "second",
|
|
272
|
+
repo: "agency",
|
|
273
|
+
branch: "task/second",
|
|
274
|
+
base: "main",
|
|
275
|
+
dependsOn: ["first"],
|
|
276
|
+
},
|
|
277
|
+
root,
|
|
278
|
+
),
|
|
279
|
+
),
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
await expect(
|
|
284
|
+
runTestEffect(
|
|
285
|
+
ArchiveService.pipe(
|
|
286
|
+
Effect.flatMap((service) =>
|
|
287
|
+
service.archivePhase("multi", "first", root),
|
|
288
|
+
),
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
).rejects.toThrow("phase 'second' depends on it")
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test("does not move a task when its worktree is dirty", async () => {
|
|
295
|
+
await runTestEffect(
|
|
296
|
+
TaskService.pipe(
|
|
297
|
+
Effect.flatMap((service) =>
|
|
298
|
+
service.create(
|
|
299
|
+
{
|
|
300
|
+
id: "dirty",
|
|
301
|
+
ticketUrl: "https://example.com/task",
|
|
302
|
+
repo: "agency",
|
|
303
|
+
branch: "task/dirty",
|
|
304
|
+
base: "main",
|
|
305
|
+
},
|
|
306
|
+
root,
|
|
307
|
+
),
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
)
|
|
311
|
+
const workspace = await runTestEffect(
|
|
312
|
+
WorktreeService.pipe(
|
|
313
|
+
Effect.flatMap((service) =>
|
|
314
|
+
service.materialize("dirty", undefined, root),
|
|
315
|
+
),
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
await Bun.write(join(workspace.writablePath, "dirty.txt"), "keep me\n")
|
|
319
|
+
|
|
320
|
+
await expect(
|
|
321
|
+
runTestEffect(
|
|
322
|
+
ArchiveService.pipe(
|
|
323
|
+
Effect.flatMap((service) => service.archiveTask("dirty", root)),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
).rejects.toThrow("Failed to remove worktree")
|
|
327
|
+
expect(await Bun.file(join(root, "tasks/dirty/TASK.md")).exists()).toBe(
|
|
328
|
+
true,
|
|
329
|
+
)
|
|
330
|
+
expect(await Bun.file(join(root, "archive/tasks/dirty")).exists()).toBe(
|
|
331
|
+
false,
|
|
332
|
+
)
|
|
333
|
+
})
|
|
334
|
+
})
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { dirname, join } from "node:path"
|
|
3
|
+
import { EpicService, type EpicRecord } from "./EpicService"
|
|
4
|
+
import { FileSystemService } from "./FileSystemService"
|
|
5
|
+
import { PhaseService } from "./PhaseService"
|
|
6
|
+
import { TaskService } from "./TaskService"
|
|
7
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
8
|
+
import { WorktreeService } from "./WorktreeService"
|
|
9
|
+
import {
|
|
10
|
+
formatMarkdownDocument,
|
|
11
|
+
parseFrontmatter,
|
|
12
|
+
} from "../workbase/frontmatter"
|
|
13
|
+
import type { TaskFrontmatter as TaskData } from "../workbase/schemas"
|
|
14
|
+
|
|
15
|
+
class ArchiveError extends Data.TaggedError("ArchiveError")<{
|
|
16
|
+
readonly message: string
|
|
17
|
+
}> {}
|
|
18
|
+
|
|
19
|
+
interface ArchiveResult {
|
|
20
|
+
readonly kind: "epic" | "task" | "phase"
|
|
21
|
+
readonly id: string
|
|
22
|
+
readonly taskId?: string
|
|
23
|
+
readonly path: string
|
|
24
|
+
readonly archivedPaths: readonly string[]
|
|
25
|
+
readonly removedWorktrees: readonly string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface TaskRecord {
|
|
29
|
+
readonly id: string
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly content: string
|
|
32
|
+
readonly data: TaskData
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rejectExistingDestination = (path: string) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const fs = yield* FileSystemService
|
|
38
|
+
if (yield* fs.exists(path)) {
|
|
39
|
+
return yield* new ArchiveError({
|
|
40
|
+
message: `Archive destination already exists: ${path}`,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
46
|
+
"ArchiveService",
|
|
47
|
+
{
|
|
48
|
+
sync: () => ({
|
|
49
|
+
archiveEpic: (id: string, startPath: string = process.cwd()) =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const fs = yield* FileSystemService
|
|
52
|
+
const workbase = yield* WorkbaseService
|
|
53
|
+
const epics = yield* EpicService
|
|
54
|
+
const tasks = yield* TaskService
|
|
55
|
+
const worktrees = yield* WorktreeService
|
|
56
|
+
const root = yield* workbase.discover(startPath)
|
|
57
|
+
const epic = yield* epics.show(id, root)
|
|
58
|
+
const taskRecords: TaskRecord[] = []
|
|
59
|
+
for (const child of epic.data.tasks) {
|
|
60
|
+
const task = yield* tasks.show(child.id, root)
|
|
61
|
+
if (task.data.epic !== id) {
|
|
62
|
+
return yield* new ArchiveError({
|
|
63
|
+
message: `Task '${task.id}' does not reference epic '${id}'`,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
taskRecords.push(task)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const epicDestination = join(root, "archive", "epics", id)
|
|
70
|
+
yield* rejectExistingDestination(epicDestination)
|
|
71
|
+
for (const task of taskRecords) {
|
|
72
|
+
yield* rejectExistingDestination(
|
|
73
|
+
join(root, "archive", "tasks", task.id),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const removedWorktrees: string[] = []
|
|
78
|
+
for (const task of taskRecords) {
|
|
79
|
+
if ("phases" in task.data) {
|
|
80
|
+
for (const phase of task.data.phases) {
|
|
81
|
+
removedWorktrees.push(
|
|
82
|
+
...(yield* worktrees.remove(task.id, phase.id, root)),
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
removedWorktrees.push(
|
|
87
|
+
...(yield* worktrees.remove(task.id, undefined, root)),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const archivedPaths: string[] = []
|
|
93
|
+
for (const task of taskRecords) {
|
|
94
|
+
const destination = join(root, "archive", "tasks", task.id)
|
|
95
|
+
yield* fs.createDirectory(dirname(destination))
|
|
96
|
+
yield* fs.moveDirectory(dirname(task.path), destination)
|
|
97
|
+
archivedPaths.push(destination)
|
|
98
|
+
}
|
|
99
|
+
yield* fs.createDirectory(dirname(epicDestination))
|
|
100
|
+
yield* fs.moveDirectory(dirname(epic.path), epicDestination)
|
|
101
|
+
archivedPaths.push(epicDestination)
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
kind: "epic",
|
|
105
|
+
id,
|
|
106
|
+
path: epicDestination,
|
|
107
|
+
archivedPaths,
|
|
108
|
+
removedWorktrees,
|
|
109
|
+
} satisfies ArchiveResult
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
archiveTask: (id: string, startPath: string = process.cwd()) =>
|
|
113
|
+
Effect.gen(function* () {
|
|
114
|
+
const fs = yield* FileSystemService
|
|
115
|
+
const workbase = yield* WorkbaseService
|
|
116
|
+
const epics = yield* EpicService
|
|
117
|
+
const tasks = yield* TaskService
|
|
118
|
+
const worktrees = yield* WorktreeService
|
|
119
|
+
const root = yield* workbase.discover(startPath)
|
|
120
|
+
const task = yield* tasks.show(id, root)
|
|
121
|
+
const destination = join(root, "archive", "tasks", id)
|
|
122
|
+
yield* rejectExistingDestination(destination)
|
|
123
|
+
|
|
124
|
+
let parentEpic: EpicRecord | undefined
|
|
125
|
+
if (task.data.epic) {
|
|
126
|
+
parentEpic = yield* epics.show(task.data.epic, root)
|
|
127
|
+
const dependent = parentEpic.data.tasks.find((child) =>
|
|
128
|
+
child.dependsOn?.includes(id),
|
|
129
|
+
)
|
|
130
|
+
if (dependent) {
|
|
131
|
+
return yield* new ArchiveError({
|
|
132
|
+
message: `Cannot archive task '${id}'; task '${dependent.id}' depends on it`,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const removedWorktrees: string[] = []
|
|
138
|
+
if ("phases" in task.data) {
|
|
139
|
+
for (const phase of task.data.phases) {
|
|
140
|
+
removedWorktrees.push(
|
|
141
|
+
...(yield* worktrees.remove(id, phase.id, root)),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
removedWorktrees.push(
|
|
146
|
+
...(yield* worktrees.remove(id, undefined, root)),
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (parentEpic) {
|
|
151
|
+
const parsed = yield* parseFrontmatter(
|
|
152
|
+
parentEpic.content,
|
|
153
|
+
parentEpic.path,
|
|
154
|
+
)
|
|
155
|
+
yield* fs.writeFile(
|
|
156
|
+
parentEpic.path,
|
|
157
|
+
formatMarkdownDocument(
|
|
158
|
+
{
|
|
159
|
+
...parentEpic.data,
|
|
160
|
+
tasks: parentEpic.data.tasks.filter(
|
|
161
|
+
(child) => child.id !== id,
|
|
162
|
+
),
|
|
163
|
+
},
|
|
164
|
+
parsed.body,
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
yield* fs.createDirectory(dirname(destination))
|
|
170
|
+
yield* fs.moveDirectory(dirname(task.path), destination)
|
|
171
|
+
return {
|
|
172
|
+
kind: "task",
|
|
173
|
+
id,
|
|
174
|
+
path: destination,
|
|
175
|
+
archivedPaths: [destination],
|
|
176
|
+
removedWorktrees,
|
|
177
|
+
} satisfies ArchiveResult
|
|
178
|
+
}),
|
|
179
|
+
|
|
180
|
+
archivePhase: (
|
|
181
|
+
taskId: string,
|
|
182
|
+
id: string,
|
|
183
|
+
startPath: string = process.cwd(),
|
|
184
|
+
) =>
|
|
185
|
+
Effect.gen(function* () {
|
|
186
|
+
const fs = yield* FileSystemService
|
|
187
|
+
const workbase = yield* WorkbaseService
|
|
188
|
+
const tasks = yield* TaskService
|
|
189
|
+
const phases = yield* PhaseService
|
|
190
|
+
const worktrees = yield* WorktreeService
|
|
191
|
+
const root = yield* workbase.discover(startPath)
|
|
192
|
+
const task = yield* tasks.show(taskId, root)
|
|
193
|
+
if (!("phases" in task.data)) {
|
|
194
|
+
return yield* new ArchiveError({
|
|
195
|
+
message: `Task '${taskId}' is single-phase and does not contain phases`,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
const phase = yield* phases.show(taskId, id, root)
|
|
199
|
+
const dependent = task.data.phases.find((candidate) =>
|
|
200
|
+
candidate.dependsOn?.includes(id),
|
|
201
|
+
)
|
|
202
|
+
if (dependent) {
|
|
203
|
+
return yield* new ArchiveError({
|
|
204
|
+
message: `Cannot archive phase '${id}'; phase '${dependent.id}' depends on it`,
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const destination = join(
|
|
209
|
+
root,
|
|
210
|
+
"archive",
|
|
211
|
+
"tasks",
|
|
212
|
+
taskId,
|
|
213
|
+
"phases",
|
|
214
|
+
id,
|
|
215
|
+
)
|
|
216
|
+
yield* rejectExistingDestination(destination)
|
|
217
|
+
const removedWorktrees = yield* worktrees.remove(taskId, id, root)
|
|
218
|
+
|
|
219
|
+
const parsed = yield* parseFrontmatter(task.content, task.path)
|
|
220
|
+
yield* fs.writeFile(
|
|
221
|
+
task.path,
|
|
222
|
+
formatMarkdownDocument(
|
|
223
|
+
{
|
|
224
|
+
...task.data,
|
|
225
|
+
phases: task.data.phases.filter(
|
|
226
|
+
(candidate) => candidate.id !== id,
|
|
227
|
+
),
|
|
228
|
+
},
|
|
229
|
+
parsed.body,
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
yield* fs.createDirectory(dirname(destination))
|
|
233
|
+
yield* fs.moveDirectory(dirname(phase.path), destination)
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
kind: "phase",
|
|
237
|
+
id,
|
|
238
|
+
taskId,
|
|
239
|
+
path: destination,
|
|
240
|
+
archivedPaths: [destination],
|
|
241
|
+
removedWorktrees,
|
|
242
|
+
} satisfies ArchiveResult
|
|
243
|
+
}),
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
) {}
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
PhaseFrontmatter,
|
|
10
10
|
type PhaseFrontmatter as PhaseData,
|
|
11
11
|
type RepositoryReference,
|
|
12
|
+
WorkStatus,
|
|
12
13
|
} from "../workbase/schemas"
|
|
13
14
|
import {
|
|
14
15
|
formatMarkdownDocument,
|
|
@@ -58,6 +59,15 @@ const decodePhase = (input: unknown) => {
|
|
|
58
59
|
: Effect.succeed(result.right)
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
const decodeStatus = (status: string) => {
|
|
63
|
+
const result = Schema.decodeUnknownEither(WorkStatus)(status)
|
|
64
|
+
return Either.isLeft(result)
|
|
65
|
+
? Effect.fail(
|
|
66
|
+
new PhaseError({ message: `Invalid work status '${status}'` }),
|
|
67
|
+
)
|
|
68
|
+
: Effect.succeed(result.right)
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
export class PhaseService extends Effect.Service<PhaseService>()(
|
|
62
72
|
"PhaseService",
|
|
63
73
|
{
|
|
@@ -318,6 +328,24 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
318
328
|
}
|
|
319
329
|
return record
|
|
320
330
|
}),
|
|
331
|
+
|
|
332
|
+
setStatus: (
|
|
333
|
+
taskId: string,
|
|
334
|
+
id: string,
|
|
335
|
+
status: string,
|
|
336
|
+
startPath: string = process.cwd(),
|
|
337
|
+
) =>
|
|
338
|
+
Effect.gen(function* () {
|
|
339
|
+
const fs = yield* FileSystemService
|
|
340
|
+
const service = yield* PhaseService
|
|
341
|
+
const validStatus = yield* decodeStatus(status)
|
|
342
|
+
const record = yield* service.show(taskId, id, startPath)
|
|
343
|
+
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
344
|
+
const data = { ...record.data, status: validStatus }
|
|
345
|
+
const content = formatMarkdownDocument(data, parsed.body)
|
|
346
|
+
yield* fs.writeFile(record.path, content)
|
|
347
|
+
return { ...record, content, data } satisfies PhaseRecord
|
|
348
|
+
}),
|
|
321
349
|
}),
|
|
322
350
|
},
|
|
323
351
|
) {}
|
|
@@ -212,6 +212,85 @@ describe("task and phase services", () => {
|
|
|
212
212
|
branch: "task/single",
|
|
213
213
|
base: "main",
|
|
214
214
|
pr: "https://github.com/example/agency/pull/42",
|
|
215
|
+
status: "open",
|
|
215
216
|
})
|
|
216
217
|
})
|
|
218
|
+
|
|
219
|
+
test("updates status on execution units", async () => {
|
|
220
|
+
const createdTask = await runTestEffect(
|
|
221
|
+
TaskService.pipe(
|
|
222
|
+
Effect.flatMap((service) =>
|
|
223
|
+
service.create(
|
|
224
|
+
{
|
|
225
|
+
id: "single-status",
|
|
226
|
+
ticketUrl: "https://example.com/task",
|
|
227
|
+
repo: "agency",
|
|
228
|
+
branch: "task/single-status",
|
|
229
|
+
base: "main",
|
|
230
|
+
},
|
|
231
|
+
root,
|
|
232
|
+
),
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
expect(createdTask.content).toContain("status: open")
|
|
237
|
+
const task = await runTestEffect(
|
|
238
|
+
TaskService.pipe(
|
|
239
|
+
Effect.flatMap((service) =>
|
|
240
|
+
service.setStatus("single-status", "delegated", root),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
expect(task.data.status).toBe("delegated")
|
|
245
|
+
expect(task.content).toContain("status: delegated")
|
|
246
|
+
expect(task.content).toContain("Describe the task outcome.")
|
|
247
|
+
|
|
248
|
+
await runTestEffect(
|
|
249
|
+
TaskService.pipe(
|
|
250
|
+
Effect.flatMap((service) =>
|
|
251
|
+
service.create(
|
|
252
|
+
{
|
|
253
|
+
id: "multi-status",
|
|
254
|
+
ticketUrl: "https://example.com/task",
|
|
255
|
+
multiPhase: true,
|
|
256
|
+
},
|
|
257
|
+
root,
|
|
258
|
+
),
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
await runTestEffect(
|
|
263
|
+
PhaseService.pipe(
|
|
264
|
+
Effect.flatMap((service) =>
|
|
265
|
+
service.create(
|
|
266
|
+
{
|
|
267
|
+
taskId: "multi-status",
|
|
268
|
+
id: "implementation",
|
|
269
|
+
repo: "agency",
|
|
270
|
+
branch: "task/multi-status",
|
|
271
|
+
base: "main",
|
|
272
|
+
},
|
|
273
|
+
root,
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
const phase = await runTestEffect(
|
|
279
|
+
PhaseService.pipe(
|
|
280
|
+
Effect.flatMap((service) =>
|
|
281
|
+
service.setStatus("multi-status", "implementation", "dropped", root),
|
|
282
|
+
),
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
expect(phase.data.status).toBe("dropped")
|
|
286
|
+
await expect(
|
|
287
|
+
runTestEffect(
|
|
288
|
+
TaskService.pipe(
|
|
289
|
+
Effect.flatMap((service) =>
|
|
290
|
+
service.setStatus("multi-status", "done", root),
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
).rejects.toThrow("set status on a phase instead")
|
|
295
|
+
})
|
|
217
296
|
})
|