@markjaquith/agency 2.10.0 → 2.11.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.
@@ -0,0 +1,277 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { Schema } from "@effect/schema"
3
+ import { Effect } from "effect"
4
+ import { mkdir } from "node:fs/promises"
5
+ import { dirname, join } from "node:path"
6
+ import { AgencyGraph } from "../graph-schema"
7
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
8
+ import { GraphService } from "./GraphService"
9
+
10
+ const write = async (root: string, path: string, content: string) => {
11
+ const fullPath = join(root, path)
12
+ await mkdir(dirname(fullPath), { recursive: true })
13
+ await Bun.write(fullPath, content)
14
+ }
15
+
16
+ const createWorkbase = async () => {
17
+ const root = await createTempDir()
18
+ await write(root, "agency.json", '{"version":2}\n')
19
+ await mkdir(join(root, "repos/agency"), { recursive: true })
20
+ await write(
21
+ root,
22
+ "epics/delivery/EPIC.md",
23
+ `---
24
+ ticketUrl: https://example.com/delivery
25
+ repos:
26
+ - repo: agency
27
+ ref: main
28
+ tasks:
29
+ - id: prepare
30
+ - id: ship
31
+ dependsOn: [prepare]
32
+ ---
33
+
34
+ # Delivery
35
+ Deliver the graph.
36
+ `,
37
+ )
38
+ await write(
39
+ root,
40
+ "tasks/prepare/TASK.md",
41
+ `---
42
+ ticketUrl: null
43
+ epic: delivery
44
+ repo: agency
45
+ branch: feat/prepare
46
+ base: main
47
+ pr: null
48
+ status: done
49
+ ---
50
+
51
+ # Prepare
52
+ `,
53
+ )
54
+ await write(
55
+ root,
56
+ "tasks/ship/TASK.md",
57
+ `---
58
+ ticketUrl: null
59
+ epic: delivery
60
+ phases:
61
+ - id: implement
62
+ - id: verify
63
+ dependsOn: [implement]
64
+ ---
65
+
66
+ # Ship
67
+ `,
68
+ )
69
+ await write(
70
+ root,
71
+ "tasks/ship/phases/implement/PHASE.md",
72
+ `---
73
+ repo: agency
74
+ branch: feat/implement
75
+ base: main
76
+ pr: null
77
+ status: open
78
+ ---
79
+
80
+ # Implement
81
+ `,
82
+ )
83
+ await write(
84
+ root,
85
+ "tasks/ship/phases/verify/PHASE.md",
86
+ `---
87
+ repo: agency
88
+ branch: feat/verify
89
+ base: main
90
+ pr: null
91
+ status: open
92
+ ---
93
+
94
+ # Verify
95
+ `,
96
+ )
97
+ return root
98
+ }
99
+
100
+ const getGraph = (root: string, options: Record<string, unknown> = {}) =>
101
+ runTestEffect(
102
+ GraphService.pipe(
103
+ Effect.flatMap((service) => service.get({ cwd: root, ...options })),
104
+ ),
105
+ )
106
+
107
+ describe("GraphService", () => {
108
+ const roots: string[] = []
109
+
110
+ afterEach(async () => {
111
+ await Promise.all(roots.splice(0).map(cleanupTempDir))
112
+ })
113
+
114
+ test("exports deterministic nodes, typed edges, and computed state", async () => {
115
+ const root = await createWorkbase()
116
+ roots.push(root)
117
+ const graph = await getGraph(root)
118
+
119
+ expect(Schema.decodeUnknownSync(AgencyGraph)(graph)).toEqual(graph)
120
+ expect(graph.nodes.map((node) => node.id)).toEqual([
121
+ "epic:delivery",
122
+ "execution-unit:phase/ship/implement",
123
+ "execution-unit:phase/ship/verify",
124
+ "execution-unit:task/prepare",
125
+ "phase:ship/implement",
126
+ "phase:ship/verify",
127
+ "repository:agency",
128
+ "task:prepare",
129
+ "task:ship",
130
+ ])
131
+ expect(graph.edges).toContainEqual({
132
+ id: "depends_on:task:ship->task:prepare",
133
+ kind: "depends_on",
134
+ from: "task:ship",
135
+ to: "task:prepare",
136
+ })
137
+ expect(graph.edges).toContainEqual({
138
+ id: "owns:phase:ship/verify->execution-unit:phase/ship/verify",
139
+ kind: "owns",
140
+ from: "phase:ship/verify",
141
+ to: "execution-unit:phase/ship/verify",
142
+ })
143
+ expect(graph.edges).toContainEqual({
144
+ id: "writes:execution-unit:task/prepare->repository:agency",
145
+ kind: "writes",
146
+ from: "execution-unit:task/prepare",
147
+ to: "repository:agency",
148
+ })
149
+
150
+ const prepare = graph.nodes.find((node) => node.id === "task:prepare")!
151
+ const ship = graph.nodes.find((node) => node.id === "task:ship")!
152
+ const implement = graph.nodes.find(
153
+ (node) => node.id === "phase:ship/implement",
154
+ )!
155
+ const verify = graph.nodes.find((node) => node.id === "phase:ship/verify")!
156
+ expect(prepare.dependents).toEqual(["task:ship"])
157
+ expect(ship.readiness).toMatchObject({ ready: true, blocked: false })
158
+ expect(implement.dependents).toEqual(["phase:ship/verify"])
159
+ expect(implement.readiness).toMatchObject({ ready: true, blocked: false })
160
+ expect(verify.readiness).toMatchObject({
161
+ ready: false,
162
+ blocked: true,
163
+ blockers: [
164
+ {
165
+ kind: "dependency",
166
+ id: "phase:ship/implement",
167
+ status: "open",
168
+ },
169
+ ],
170
+ })
171
+ expect(graph.summary).toEqual({
172
+ status: "open",
173
+ total: 3,
174
+ open: 2,
175
+ working: 0,
176
+ delegated: 0,
177
+ done: 1,
178
+ dropped: 0,
179
+ terminal: 1,
180
+ })
181
+ expect(await getGraph(root)).toEqual(graph)
182
+ })
183
+
184
+ test("applies filters after computing graph state", async () => {
185
+ const root = await createWorkbase()
186
+ roots.push(root)
187
+
188
+ const ready = await getGraph(root, {
189
+ ready: true,
190
+ kinds: ["execution-unit"],
191
+ repositories: ["agency"],
192
+ statuses: ["open"],
193
+ })
194
+ expect(ready.nodes.map((node) => node.id)).toEqual([
195
+ "execution-unit:phase/ship/implement",
196
+ ])
197
+ expect(ready.edges).toEqual([])
198
+
199
+ const blocked = await getGraph(root, { blocked: true, kinds: ["phase"] })
200
+ expect(blocked.nodes.map((node) => node.id)).toEqual(["phase:ship/verify"])
201
+ })
202
+
203
+ test("does not report structurally invalid phases as ready", async () => {
204
+ const root = await createWorkbase()
205
+ roots.push(root)
206
+ await write(
207
+ root,
208
+ "tasks/ship/phases/orphan/PHASE.md",
209
+ `---
210
+ repo: agency
211
+ branch: feat/orphan
212
+ base: main
213
+ pr: null
214
+ status: open
215
+ ---
216
+
217
+ # Orphan
218
+ `,
219
+ )
220
+
221
+ const graph = await getGraph(root, { kinds: ["phase"], ready: true })
222
+ expect(graph.nodes.map((node) => node.id)).not.toContain(
223
+ "phase:ship/orphan",
224
+ )
225
+ const complete = await getGraph(root)
226
+ expect(
227
+ complete.nodes.find((node) => node.id === "phase:ship/orphan")?.readiness,
228
+ ).toMatchObject({
229
+ ready: false,
230
+ blocked: true,
231
+ blockers: [
232
+ {
233
+ kind: "validation",
234
+ id: "tasks/ship/TASK.md",
235
+ reason: "Unlisted phase 'orphan'",
236
+ },
237
+ ],
238
+ })
239
+ })
240
+
241
+ test("adds body, workspace, git, and PR details only when requested", async () => {
242
+ const root = await createWorkbase()
243
+ roots.push(root)
244
+ const baseline = await getGraph(root)
245
+ for (const node of baseline.nodes) {
246
+ expect("body" in node).toBe(false)
247
+ expect("workspace" in node).toBe(false)
248
+ expect("git" in node).toBe(false)
249
+ expect("pr" in node).toBe(false)
250
+ }
251
+ expect(baseline.workbase.root).toBeUndefined()
252
+
253
+ const detailed = await getGraph(root, {
254
+ include: ["bodies", "workspace", "git", "pr"],
255
+ })
256
+ expect(detailed.includes).toEqual(["bodies", "git", "pr", "workspace"])
257
+ expect(detailed.workbase.root).toBe(root)
258
+ const epic = detailed.nodes.find((node) => node.id === "epic:delivery")
259
+ const repository = detailed.nodes.find(
260
+ (node) => node.id === "repository:agency",
261
+ )
262
+ const execution = detailed.nodes.find(
263
+ (node) => node.id === "execution-unit:phase/ship/implement",
264
+ )
265
+ if (epic?.kind !== "epic") throw new Error("Missing epic node")
266
+ if (repository?.kind !== "repository") {
267
+ throw new Error("Missing repository node")
268
+ }
269
+ if (execution?.kind !== "execution-unit") {
270
+ throw new Error("Missing execution-unit node")
271
+ }
272
+ expect(epic?.body).toContain("Deliver the graph")
273
+ expect(repository?.git).toMatchObject({ kind: null })
274
+ expect(execution?.pr).toEqual({ url: null, state: "none" })
275
+ expect(Schema.decodeUnknownSync(AgencyGraph)(detailed)).toEqual(detailed)
276
+ })
277
+ })