@markjaquith/agency 2.20.0 → 2.22.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 +23 -0
- package/cli.ts +13 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +45 -1
- package/src/cli-parser.ts +158 -5
- package/src/cli.test.ts +62 -0
- package/src/commands/epic.ts +51 -1
- package/src/commands/phase.ts +94 -1
- package/src/commands/pr.ts +2 -2
- package/src/commands/task.ts +108 -1
- package/src/graph-schema.ts +2 -10
- package/src/services/ClaimService.ts +7 -2
- package/src/services/ContextService.ts +4 -4
- package/src/services/GraphMutationService.test.ts +336 -0
- package/src/services/GraphMutationService.ts +952 -0
- package/src/services/GraphService.ts +2 -11
- package/src/services/PullRequestService.test.ts +91 -4
- package/src/services/PullRequestService.ts +108 -31
- package/src/services/SyncService.test.ts +95 -1
- package/src/services/SyncService.ts +150 -60
- package/src/services/TaskPhaseService.test.ts +9 -1
- package/src/services/WorkbaseService.ts +5 -37
- package/src/test-utils.ts +2 -0
- package/src/workbase/delivery-command.test.ts +54 -0
- package/src/workbase/delivery-command.ts +135 -0
- package/src/workbase/dependency-graph.ts +51 -0
- package/src/workbase/schemas.test.ts +35 -0
- package/src/workbase/schemas.ts +23 -1
package/src/commands/task.ts
CHANGED
|
@@ -10,17 +10,24 @@ import { WorkbaseService } from "../services/WorkbaseService"
|
|
|
10
10
|
import { choose } from "../utils/chooser"
|
|
11
11
|
import { formatTable } from "../utils/table"
|
|
12
12
|
import { getWorkViews } from "../work-view"
|
|
13
|
+
import { GraphMutationService } from "../services/GraphMutationService"
|
|
13
14
|
|
|
14
15
|
interface TaskOptions extends BaseCommandOptions {
|
|
15
16
|
readonly subcommand?: string
|
|
16
17
|
readonly args: readonly string[]
|
|
17
18
|
readonly ticketUrl?: string
|
|
18
19
|
readonly description?: string
|
|
20
|
+
readonly clearDescription?: boolean
|
|
21
|
+
readonly clearTicket?: boolean
|
|
19
22
|
readonly epic?: string
|
|
20
23
|
readonly repo?: string
|
|
21
24
|
readonly references?: readonly string[]
|
|
22
25
|
readonly branch?: string
|
|
23
26
|
readonly base?: string
|
|
27
|
+
readonly clearReferences?: boolean
|
|
28
|
+
readonly prUrl?: string
|
|
29
|
+
readonly clearPr?: boolean
|
|
30
|
+
readonly noEpic?: boolean
|
|
24
31
|
readonly multiPhase?: boolean
|
|
25
32
|
readonly json?: boolean
|
|
26
33
|
readonly statuses?: readonly string[]
|
|
@@ -74,6 +81,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
74
81
|
const epics = yield* EpicService
|
|
75
82
|
const repositories = yield* RepositoryService
|
|
76
83
|
const workbase = yield* WorkbaseService
|
|
84
|
+
const mutations = yield* GraphMutationService
|
|
77
85
|
const { log } = createLoggers(options)
|
|
78
86
|
const cwd = options.cwd ?? process.cwd()
|
|
79
87
|
|
|
@@ -291,10 +299,95 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
|
|
|
291
299
|
)
|
|
292
300
|
return
|
|
293
301
|
}
|
|
302
|
+
case "update": {
|
|
303
|
+
const id = options.args[0]
|
|
304
|
+
if (!id) return yield* Effect.fail(new Error("Task ID is required"))
|
|
305
|
+
const output = yield* mutations.updateTask(
|
|
306
|
+
id,
|
|
307
|
+
{
|
|
308
|
+
description: options.clearDescription ? null : options.description,
|
|
309
|
+
ticketUrl: options.clearTicket ? null : options.ticketUrl,
|
|
310
|
+
repo: options.repo,
|
|
311
|
+
repos: options.clearReferences
|
|
312
|
+
? null
|
|
313
|
+
: options.references === undefined
|
|
314
|
+
? undefined
|
|
315
|
+
: parseRepositoryReferences(options.references),
|
|
316
|
+
branch: options.branch,
|
|
317
|
+
base: options.base,
|
|
318
|
+
pr: options.clearPr ? null : options.prUrl,
|
|
319
|
+
},
|
|
320
|
+
cwd,
|
|
321
|
+
)
|
|
322
|
+
log(
|
|
323
|
+
options.json
|
|
324
|
+
? JSON.stringify(output, null, 2)
|
|
325
|
+
: `Updated task '${id}'`,
|
|
326
|
+
)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
case "rename": {
|
|
330
|
+
const [id, newId] = options.args
|
|
331
|
+
if (!id || !newId) {
|
|
332
|
+
return yield* Effect.fail(
|
|
333
|
+
new Error("Task ID and new ID are required"),
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
const output = yield* mutations.renameTask(id, newId, cwd)
|
|
337
|
+
log(
|
|
338
|
+
options.json
|
|
339
|
+
? JSON.stringify(output, null, 2)
|
|
340
|
+
: `Renamed task '${id}' to '${newId}'`,
|
|
341
|
+
)
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
case "move": {
|
|
345
|
+
const id = options.args[0]
|
|
346
|
+
if (!id) return yield* Effect.fail(new Error("Task ID is required"))
|
|
347
|
+
const output = yield* mutations.moveTask(
|
|
348
|
+
id,
|
|
349
|
+
options.noEpic ? null : (options.epic ?? null),
|
|
350
|
+
cwd,
|
|
351
|
+
)
|
|
352
|
+
log(
|
|
353
|
+
options.json
|
|
354
|
+
? JSON.stringify(output, null, 2)
|
|
355
|
+
: options.noEpic
|
|
356
|
+
? `Removed task '${id}' from its epic`
|
|
357
|
+
: `Moved task '${id}' to epic '${options.epic}'`,
|
|
358
|
+
)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
case "dependency": {
|
|
362
|
+
const [operation, id, dependencyId] = options.args
|
|
363
|
+
if (
|
|
364
|
+
(operation !== "add" && operation !== "remove") ||
|
|
365
|
+
!id ||
|
|
366
|
+
!dependencyId
|
|
367
|
+
) {
|
|
368
|
+
return yield* Effect.fail(
|
|
369
|
+
new Error(
|
|
370
|
+
"Usage: agency task dependency <add|remove> <task-id> <dependency-id>",
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
const output = yield* mutations.mutateTaskDependency(
|
|
375
|
+
operation,
|
|
376
|
+
id,
|
|
377
|
+
dependencyId,
|
|
378
|
+
cwd,
|
|
379
|
+
)
|
|
380
|
+
log(
|
|
381
|
+
options.json
|
|
382
|
+
? JSON.stringify(output, null, 2)
|
|
383
|
+
: `${operation === "add" ? "Added" : "Removed"} dependency '${dependencyId}' ${operation === "add" ? "to" : "from"} task '${id}'`,
|
|
384
|
+
)
|
|
385
|
+
return
|
|
386
|
+
}
|
|
294
387
|
default:
|
|
295
388
|
return yield* Effect.fail(
|
|
296
389
|
new Error(
|
|
297
|
-
"Subcommand is required. Available: new, create, list, show, status",
|
|
390
|
+
"Subcommand is required. Available: new, create, list, show, status, update, rename, move, dependency",
|
|
298
391
|
),
|
|
299
392
|
)
|
|
300
393
|
}
|
|
@@ -309,6 +402,11 @@ Subcommands:
|
|
|
309
402
|
list List tasks
|
|
310
403
|
show <id> Show a task
|
|
311
404
|
status <id> <status> Set open, done, or dropped
|
|
405
|
+
update <id> Update task metadata
|
|
406
|
+
rename <id> <new-id> Rename a task and update graph references
|
|
407
|
+
move <id> Move a task with --epic or --no-epic
|
|
408
|
+
dependency <operation> <task> <dependency>
|
|
409
|
+
Add or remove a task dependency
|
|
312
410
|
|
|
313
411
|
Create options:
|
|
314
412
|
--ticket-url <url> External ticket URL (optional)
|
|
@@ -321,6 +419,15 @@ Create options:
|
|
|
321
419
|
--base <name> Base branch (default: main)
|
|
322
420
|
--multi-phase Create a task container for phases
|
|
323
421
|
|
|
422
|
+
Update options:
|
|
423
|
+
--ticket-url <url> / --clear-ticket
|
|
424
|
+
--description <text> / --clear-description
|
|
425
|
+
--repo <alias> Replace the writable repository
|
|
426
|
+
--reference <alias>:<ref> / --clear-references
|
|
427
|
+
--branch <name> Replace the working branch
|
|
428
|
+
--base <name> Replace the base branch
|
|
429
|
+
--pr-url <url> / --clear-pr
|
|
430
|
+
|
|
324
431
|
Task creation is noninteractive. Single-phase tasks require --repo; use
|
|
325
432
|
--multi-phase instead for a task container. Guided input is available only
|
|
326
433
|
through task new, which fails when --no-input is set or no TTY is available.
|
package/src/graph-schema.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Schema } from "@effect/schema"
|
|
|
2
2
|
import {
|
|
3
3
|
EpicFrontmatter,
|
|
4
4
|
PhaseFrontmatter,
|
|
5
|
+
PullRequestRecord,
|
|
5
6
|
TaskFrontmatter,
|
|
6
7
|
WorkStatus,
|
|
7
8
|
} from "./workbase/schemas"
|
|
@@ -109,16 +110,7 @@ export const GraphExecutionGit = Schema.Struct({
|
|
|
109
110
|
export const GraphPr = Schema.Union(
|
|
110
111
|
Schema.Struct({ url: Schema.Null, state: Schema.Literal("none") }),
|
|
111
112
|
Schema.Struct({ url: Schema.String, state: Schema.Literal("unavailable") }),
|
|
112
|
-
|
|
113
|
-
recordedUrl: Schema.String,
|
|
114
|
-
number: Schema.Number,
|
|
115
|
-
state: Schema.String,
|
|
116
|
-
title: Schema.String,
|
|
117
|
-
isDraft: Schema.Boolean,
|
|
118
|
-
headRefName: Schema.String,
|
|
119
|
-
baseRefName: Schema.String,
|
|
120
|
-
url: Schema.String,
|
|
121
|
-
}),
|
|
113
|
+
PullRequestRecord,
|
|
122
114
|
)
|
|
123
115
|
|
|
124
116
|
const NodeIdentity = {
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
PhaseFrontmatter,
|
|
24
24
|
TaskFrontmatter,
|
|
25
25
|
type ClaimRecord,
|
|
26
|
+
type PullRequestRecord,
|
|
26
27
|
type PhaseFrontmatter as PhaseData,
|
|
27
28
|
type TaskFrontmatter as TaskData,
|
|
28
29
|
} from "../workbase/schemas"
|
|
@@ -98,7 +99,7 @@ interface ReconcileInput {
|
|
|
98
99
|
readonly taskId: string
|
|
99
100
|
readonly phaseId?: string
|
|
100
101
|
readonly revision: string
|
|
101
|
-
readonly pr?: string
|
|
102
|
+
readonly pr?: string | PullRequestRecord
|
|
102
103
|
readonly status?: "done"
|
|
103
104
|
}
|
|
104
105
|
|
|
@@ -341,7 +342,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
341
342
|
|
|
342
343
|
reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
|
|
343
344
|
Effect.gen(function* () {
|
|
344
|
-
if (
|
|
345
|
+
if (
|
|
346
|
+
typeof input.pr === "string" &&
|
|
347
|
+
input.pr !== undefined &&
|
|
348
|
+
!PR_URL.test(input.pr)
|
|
349
|
+
) {
|
|
345
350
|
return yield* new ClaimError({
|
|
346
351
|
message: `Invalid GitHub pull request URL: ${input.pr}`,
|
|
347
352
|
})
|
|
@@ -3,6 +3,7 @@ import { Data, Effect, Either } from "effect"
|
|
|
3
3
|
import { join, relative, resolve, sep } from "node:path"
|
|
4
4
|
import { FileSystemService } from "./FileSystemService"
|
|
5
5
|
import { WorkbaseService } from "./WorkbaseService"
|
|
6
|
+
import { normalizePullRequestRecord } from "../workbase/delivery-command"
|
|
6
7
|
import { RepositoryService } from "./RepositoryService"
|
|
7
8
|
import { aggregateProgress, readinessState } from "../readiness"
|
|
8
9
|
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
@@ -857,10 +858,9 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
857
858
|
references: referenceCheckouts,
|
|
858
859
|
warnings: inspectionWarnings,
|
|
859
860
|
},
|
|
860
|
-
pr:
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
},
|
|
861
|
+
pr: executionData?.pr
|
|
862
|
+
? normalizePullRequestRecord(executionData.pr)
|
|
863
|
+
: { url: null, state: "none" },
|
|
864
864
|
validation: {
|
|
865
865
|
valid: validation.valid,
|
|
866
866
|
warnings: validation.issues,
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { Effect } from "effect"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { EpicService } from "./EpicService"
|
|
7
|
+
import { GraphMutationService } from "./GraphMutationService"
|
|
8
|
+
import { PhaseService } from "./PhaseService"
|
|
9
|
+
import { TaskService } from "./TaskService"
|
|
10
|
+
import {
|
|
11
|
+
formatMarkdownDocument,
|
|
12
|
+
parseFrontmatter,
|
|
13
|
+
} from "../workbase/frontmatter"
|
|
14
|
+
|
|
15
|
+
describe("GraphMutationService", () => {
|
|
16
|
+
let root: string
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
root = await createTempDir()
|
|
20
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
21
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
22
|
+
await mkdir(join(root, "repos/docs"), { recursive: true })
|
|
23
|
+
await runTestEffect(
|
|
24
|
+
Effect.gen(function* () {
|
|
25
|
+
const epics = yield* EpicService
|
|
26
|
+
const tasks = yield* TaskService
|
|
27
|
+
const phases = yield* PhaseService
|
|
28
|
+
yield* epics.create(
|
|
29
|
+
"first-epic",
|
|
30
|
+
"https://example.com/first",
|
|
31
|
+
[{ repo: "agency", ref: "main" }],
|
|
32
|
+
root,
|
|
33
|
+
)
|
|
34
|
+
yield* epics.create(
|
|
35
|
+
"second-epic",
|
|
36
|
+
"https://example.com/second",
|
|
37
|
+
[{ repo: "agency", ref: "main" }],
|
|
38
|
+
root,
|
|
39
|
+
)
|
|
40
|
+
for (const id of ["alpha", "beta", "gamma"]) {
|
|
41
|
+
yield* tasks.create(
|
|
42
|
+
{
|
|
43
|
+
id,
|
|
44
|
+
ticketUrl: null,
|
|
45
|
+
epic: "first-epic",
|
|
46
|
+
repo: "agency",
|
|
47
|
+
branch: `task/${id}`,
|
|
48
|
+
base: "main",
|
|
49
|
+
},
|
|
50
|
+
root,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
yield* tasks.create(
|
|
54
|
+
{
|
|
55
|
+
id: "multi",
|
|
56
|
+
ticketUrl: "https://example.com/multi",
|
|
57
|
+
multiPhase: true,
|
|
58
|
+
},
|
|
59
|
+
root,
|
|
60
|
+
)
|
|
61
|
+
yield* phases.create(
|
|
62
|
+
{
|
|
63
|
+
taskId: "multi",
|
|
64
|
+
id: "build",
|
|
65
|
+
repo: "agency",
|
|
66
|
+
branch: "task/multi-build",
|
|
67
|
+
base: "main",
|
|
68
|
+
},
|
|
69
|
+
root,
|
|
70
|
+
)
|
|
71
|
+
yield* phases.create(
|
|
72
|
+
{
|
|
73
|
+
taskId: "multi",
|
|
74
|
+
id: "ship",
|
|
75
|
+
repo: "agency",
|
|
76
|
+
branch: "task/multi-ship",
|
|
77
|
+
base: "main",
|
|
78
|
+
},
|
|
79
|
+
root,
|
|
80
|
+
)
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
afterEach(async () => cleanupTempDir(root))
|
|
86
|
+
|
|
87
|
+
test("updates descriptions, tickets, repositories, and execution metadata", async () => {
|
|
88
|
+
const output = await runTestEffect(
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const mutations = yield* GraphMutationService
|
|
91
|
+
yield* mutations.updateEpic(
|
|
92
|
+
"first-epic",
|
|
93
|
+
{
|
|
94
|
+
description: "Updated epic",
|
|
95
|
+
ticketUrl: "https://example.com/revised",
|
|
96
|
+
repos: [{ repo: "docs", ref: "trunk" }],
|
|
97
|
+
},
|
|
98
|
+
root,
|
|
99
|
+
)
|
|
100
|
+
yield* mutations.updateTask(
|
|
101
|
+
"alpha",
|
|
102
|
+
{
|
|
103
|
+
description: "Updated task",
|
|
104
|
+
ticketUrl: "https://example.com/alpha",
|
|
105
|
+
repos: [{ repo: "docs", ref: "main" }],
|
|
106
|
+
branch: "feature/alpha",
|
|
107
|
+
base: "develop",
|
|
108
|
+
pr: "https://github.com/example/agency/pull/12",
|
|
109
|
+
},
|
|
110
|
+
root,
|
|
111
|
+
)
|
|
112
|
+
return yield* mutations.updatePhase(
|
|
113
|
+
"multi",
|
|
114
|
+
"build",
|
|
115
|
+
{
|
|
116
|
+
description: "Updated phase",
|
|
117
|
+
repo: "docs",
|
|
118
|
+
branch: "feature/build",
|
|
119
|
+
base: "develop",
|
|
120
|
+
},
|
|
121
|
+
root,
|
|
122
|
+
)
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
expect(output).toMatchObject({
|
|
127
|
+
operation: "phase.update",
|
|
128
|
+
changed: true,
|
|
129
|
+
validation: { valid: true },
|
|
130
|
+
})
|
|
131
|
+
await runTestEffect(
|
|
132
|
+
Effect.gen(function* () {
|
|
133
|
+
const epics = yield* EpicService
|
|
134
|
+
const tasks = yield* TaskService
|
|
135
|
+
const phases = yield* PhaseService
|
|
136
|
+
expect((yield* epics.show("first-epic", root)).data).toMatchObject({
|
|
137
|
+
description: "Updated epic",
|
|
138
|
+
ticketUrl: "https://example.com/revised",
|
|
139
|
+
repos: [{ repo: "docs", ref: "trunk" }],
|
|
140
|
+
})
|
|
141
|
+
expect((yield* tasks.show("alpha", root)).data).toMatchObject({
|
|
142
|
+
description: "Updated task",
|
|
143
|
+
ticketUrl: "https://example.com/alpha",
|
|
144
|
+
repos: [{ repo: "docs", ref: "main" }],
|
|
145
|
+
branch: "feature/alpha",
|
|
146
|
+
base: "develop",
|
|
147
|
+
pr: "https://github.com/example/agency/pull/12",
|
|
148
|
+
})
|
|
149
|
+
expect((yield* phases.show("multi", "build", root)).data).toMatchObject(
|
|
150
|
+
{
|
|
151
|
+
description: "Updated phase",
|
|
152
|
+
repo: "docs",
|
|
153
|
+
branch: "feature/build",
|
|
154
|
+
base: "develop",
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("preserves dependency order and rejects cycles", async () => {
|
|
162
|
+
await runTestEffect(
|
|
163
|
+
Effect.gen(function* () {
|
|
164
|
+
const mutations = yield* GraphMutationService
|
|
165
|
+
yield* mutations.mutateTaskDependency("add", "beta", "alpha", root)
|
|
166
|
+
yield* mutations.mutateTaskDependency("add", "beta", "gamma", root)
|
|
167
|
+
yield* mutations.mutateTaskDependency("remove", "beta", "alpha", root)
|
|
168
|
+
yield* mutations.mutatePhaseDependency(
|
|
169
|
+
"add",
|
|
170
|
+
"multi",
|
|
171
|
+
"ship",
|
|
172
|
+
"build",
|
|
173
|
+
root,
|
|
174
|
+
)
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
await expect(
|
|
178
|
+
runTestEffect(
|
|
179
|
+
Effect.gen(function* () {
|
|
180
|
+
return yield* (yield* GraphMutationService).mutateTaskDependency(
|
|
181
|
+
"add",
|
|
182
|
+
"gamma",
|
|
183
|
+
"beta",
|
|
184
|
+
root,
|
|
185
|
+
)
|
|
186
|
+
}),
|
|
187
|
+
),
|
|
188
|
+
).rejects.toThrow("dependency cycle")
|
|
189
|
+
|
|
190
|
+
await expect(
|
|
191
|
+
runTestEffect(
|
|
192
|
+
Effect.gen(function* () {
|
|
193
|
+
return yield* (yield* GraphMutationService).mutateTaskDependency(
|
|
194
|
+
"add",
|
|
195
|
+
"beta",
|
|
196
|
+
"gamma",
|
|
197
|
+
root,
|
|
198
|
+
)
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
201
|
+
).rejects.toThrow("already depends")
|
|
202
|
+
|
|
203
|
+
await runTestEffect(
|
|
204
|
+
Effect.gen(function* () {
|
|
205
|
+
const epics = yield* EpicService
|
|
206
|
+
const tasks = yield* TaskService
|
|
207
|
+
const declarations = (yield* epics.show("first-epic", root)).data.tasks
|
|
208
|
+
expect(
|
|
209
|
+
declarations.find((item) => item.id === "beta")?.dependsOn,
|
|
210
|
+
).toEqual(["gamma"])
|
|
211
|
+
expect((yield* tasks.show("multi", root)).data).toMatchObject({
|
|
212
|
+
phases: [{ id: "build" }, { id: "ship", dependsOn: ["build"] }],
|
|
213
|
+
})
|
|
214
|
+
}),
|
|
215
|
+
)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
test("renames entities and rewrites every structured reference", async () => {
|
|
219
|
+
await runTestEffect(
|
|
220
|
+
Effect.gen(function* () {
|
|
221
|
+
const mutations = yield* GraphMutationService
|
|
222
|
+
yield* mutations.mutateTaskDependency("add", "beta", "alpha", root)
|
|
223
|
+
yield* mutations.mutatePhaseDependency(
|
|
224
|
+
"add",
|
|
225
|
+
"multi",
|
|
226
|
+
"ship",
|
|
227
|
+
"build",
|
|
228
|
+
root,
|
|
229
|
+
)
|
|
230
|
+
yield* mutations.renameTask("alpha", "renamed-alpha", root)
|
|
231
|
+
yield* mutations.renamePhase("multi", "build", "compile", root)
|
|
232
|
+
yield* mutations.renameEpic("first-epic", "renamed-epic", root)
|
|
233
|
+
}),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
await runTestEffect(
|
|
237
|
+
Effect.gen(function* () {
|
|
238
|
+
const epics = yield* EpicService
|
|
239
|
+
const tasks = yield* TaskService
|
|
240
|
+
const phases = yield* PhaseService
|
|
241
|
+
const epic = yield* epics.show("renamed-epic", root)
|
|
242
|
+
expect(
|
|
243
|
+
epic.data.tasks.find((item) => item.id === "beta")?.dependsOn,
|
|
244
|
+
).toEqual(["renamed-alpha"])
|
|
245
|
+
expect((yield* tasks.show("renamed-alpha", root)).data.epic).toBe(
|
|
246
|
+
"renamed-epic",
|
|
247
|
+
)
|
|
248
|
+
expect((yield* tasks.show("multi", root)).data).toMatchObject({
|
|
249
|
+
phases: [{ id: "compile" }, { id: "ship", dependsOn: ["compile"] }],
|
|
250
|
+
})
|
|
251
|
+
expect((yield* phases.show("multi", "compile", root)).id).toBe(
|
|
252
|
+
"compile",
|
|
253
|
+
)
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test("moves task membership in both directions", async () => {
|
|
259
|
+
await runTestEffect(
|
|
260
|
+
Effect.gen(function* () {
|
|
261
|
+
yield* (yield* GraphMutationService).moveTask(
|
|
262
|
+
"alpha",
|
|
263
|
+
"second-epic",
|
|
264
|
+
root,
|
|
265
|
+
)
|
|
266
|
+
const epics = yield* EpicService
|
|
267
|
+
const tasks = yield* TaskService
|
|
268
|
+
expect(
|
|
269
|
+
(yield* epics.show("first-epic", root)).data.tasks.map(
|
|
270
|
+
(item) => item.id,
|
|
271
|
+
),
|
|
272
|
+
).not.toContain("alpha")
|
|
273
|
+
expect(
|
|
274
|
+
(yield* epics.show("second-epic", root)).data.tasks.map(
|
|
275
|
+
(item) => item.id,
|
|
276
|
+
),
|
|
277
|
+
).toEqual(["alpha"])
|
|
278
|
+
expect((yield* tasks.show("alpha", root)).data.epic).toBe("second-epic")
|
|
279
|
+
}),
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test("refuses a rename that would invalidate a materialized worktree", async () => {
|
|
284
|
+
await mkdir(join(root, "tasks/alpha/code/agency"), { recursive: true })
|
|
285
|
+
await expect(
|
|
286
|
+
runTestEffect(
|
|
287
|
+
Effect.gen(function* () {
|
|
288
|
+
return yield* (yield* GraphMutationService).renameTask(
|
|
289
|
+
"alpha",
|
|
290
|
+
"renamed-alpha",
|
|
291
|
+
root,
|
|
292
|
+
)
|
|
293
|
+
}),
|
|
294
|
+
),
|
|
295
|
+
).rejects.toThrow("materialized worktree")
|
|
296
|
+
expect(await Bun.file(join(root, "tasks/alpha/TASK.md")).exists()).toBe(
|
|
297
|
+
true,
|
|
298
|
+
)
|
|
299
|
+
expect(
|
|
300
|
+
await Bun.file(join(root, "tasks/renamed-alpha/TASK.md")).exists(),
|
|
301
|
+
).toBe(false)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test("refuses rename when the parent backlink is inconsistent", async () => {
|
|
305
|
+
await runTestEffect(
|
|
306
|
+
Effect.gen(function* () {
|
|
307
|
+
const epic = yield* (yield* EpicService).show("first-epic", root)
|
|
308
|
+
const parsed = yield* parseFrontmatter(epic.content, epic.path)
|
|
309
|
+
yield* Effect.promise(() =>
|
|
310
|
+
Bun.write(
|
|
311
|
+
epic.path,
|
|
312
|
+
formatMarkdownDocument(
|
|
313
|
+
{
|
|
314
|
+
...epic.data,
|
|
315
|
+
tasks: epic.data.tasks.filter((item) => item.id !== "alpha"),
|
|
316
|
+
},
|
|
317
|
+
parsed.body,
|
|
318
|
+
),
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
}),
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
await expect(
|
|
325
|
+
runTestEffect(
|
|
326
|
+
Effect.gen(function* () {
|
|
327
|
+
return yield* (yield* GraphMutationService).renameTask(
|
|
328
|
+
"alpha",
|
|
329
|
+
"renamed-alpha",
|
|
330
|
+
root,
|
|
331
|
+
)
|
|
332
|
+
}),
|
|
333
|
+
),
|
|
334
|
+
).rejects.toThrow("parent epic 'first-epic' does not list it")
|
|
335
|
+
})
|
|
336
|
+
})
|