@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.
@@ -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.
@@ -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
- Schema.Struct({
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 (input.pr !== undefined && !PR_URL.test(input.pr)) {
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
- url: executionData?.pr ?? null,
862
- state: executionData?.pr ? "recorded" : "none",
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
+ })