@markjaquith/agency 1.11.0 → 2.0.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.
Files changed (130) hide show
  1. package/README.md +302 -113
  2. package/cli.ts +123 -415
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +258 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +172 -419
  24. package/src/commands/work.ts +47 -166
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/worktree-command.test.ts +61 -0
  53. package/src/workbase/worktree-command.ts +52 -0
  54. package/src/commands/base.test.ts +0 -198
  55. package/src/commands/base.ts +0 -198
  56. package/src/commands/clean.test.ts +0 -299
  57. package/src/commands/clean.ts +0 -274
  58. package/src/commands/completions.test.ts +0 -51
  59. package/src/commands/completions.ts +0 -308
  60. package/src/commands/emit.integration.test.ts +0 -549
  61. package/src/commands/emit.test.ts +0 -576
  62. package/src/commands/emit.ts +0 -700
  63. package/src/commands/emitted.test.ts +0 -226
  64. package/src/commands/emitted.ts +0 -57
  65. package/src/commands/loop.test.ts +0 -621
  66. package/src/commands/loop.ts +0 -406
  67. package/src/commands/merge.integration.test.ts +0 -195
  68. package/src/commands/merge.test.ts +0 -246
  69. package/src/commands/merge.ts +0 -244
  70. package/src/commands/pull.test.ts +0 -395
  71. package/src/commands/pull.ts +0 -205
  72. package/src/commands/push.test.ts +0 -518
  73. package/src/commands/push.ts +0 -434
  74. package/src/commands/rebase.test.ts +0 -521
  75. package/src/commands/rebase.ts +0 -232
  76. package/src/commands/save.test.ts +0 -247
  77. package/src/commands/save.ts +0 -162
  78. package/src/commands/source.test.ts +0 -195
  79. package/src/commands/source.ts +0 -72
  80. package/src/commands/switch.test.ts +0 -194
  81. package/src/commands/switch.ts +0 -84
  82. package/src/commands/task-branching.test.ts +0 -633
  83. package/src/commands/task-continue.test.ts +0 -311
  84. package/src/commands/task-edit.test.ts +0 -235
  85. package/src/commands/task-main.test.ts +0 -1066
  86. package/src/commands/task-squash.test.ts +0 -266
  87. package/src/commands/tasks.test.ts +0 -391
  88. package/src/commands/tasks.ts +0 -161
  89. package/src/commands/template-delete.test.ts +0 -178
  90. package/src/commands/template-delete.ts +0 -98
  91. package/src/commands/template-list.test.ts +0 -135
  92. package/src/commands/template-list.ts +0 -87
  93. package/src/commands/template-view.test.ts +0 -158
  94. package/src/commands/template-view.ts +0 -86
  95. package/src/commands/template.test.ts +0 -32
  96. package/src/commands/template.ts +0 -96
  97. package/src/commands/use.test.ts +0 -87
  98. package/src/commands/use.ts +0 -97
  99. package/src/constants.ts +0 -10
  100. package/src/errors.ts +0 -17
  101. package/src/schemas.ts +0 -33
  102. package/src/services/AgencyMetadataService.ts +0 -311
  103. package/src/services/ClaudeService.test.ts +0 -184
  104. package/src/services/ClaudeService.ts +0 -91
  105. package/src/services/ConfigService.ts +0 -115
  106. package/src/services/FilterRepoService.ts +0 -164
  107. package/src/services/FormatterService.test.ts +0 -432
  108. package/src/services/FormatterService.ts +0 -219
  109. package/src/services/GitService.ts +0 -1183
  110. package/src/services/MockFilterRepoService.ts +0 -140
  111. package/src/services/OpencodeService.ts +0 -263
  112. package/src/services/PromptService.ts +0 -183
  113. package/src/services/TemplateService.ts +0 -81
  114. package/src/types/native-exec.d.ts +0 -8
  115. package/src/utils/colors.ts +0 -178
  116. package/src/utils/git-path.ts +0 -6
  117. package/src/utils/glob.test.ts +0 -161
  118. package/src/utils/glob.ts +0 -78
  119. package/src/utils/paths.ts +0 -51
  120. package/src/utils/pr-branch.test.ts +0 -376
  121. package/src/utils/pr-branch.ts +0 -508
  122. package/src/utils/spinner.test.ts +0 -45
  123. package/src/utils/spinner.ts +0 -96
  124. package/src/utils/task-parser.test.ts +0 -172
  125. package/src/utils/task-parser.ts +0 -93
  126. package/templates/AGENCY.md +0 -22
  127. package/templates/AGENTS.md +0 -11
  128. package/templates/CLAUDE.md +0 -3
  129. package/templates/TASK.md +0 -5
  130. package/templates/opencode.json +0 -4
@@ -0,0 +1,323 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { join } from "node:path"
4
+ import { FileSystemService } from "./FileSystemService"
5
+ import { WorkbaseService } from "./WorkbaseService"
6
+ import { TaskService } from "./TaskService"
7
+ import {
8
+ EntityId,
9
+ PhaseFrontmatter,
10
+ type PhaseFrontmatter as PhaseData,
11
+ type RepositoryReference,
12
+ } from "../workbase/schemas"
13
+ import {
14
+ formatMarkdownDocument,
15
+ parseFrontmatter,
16
+ } from "../workbase/frontmatter"
17
+
18
+ class PhaseError extends Data.TaggedError("PhaseError")<{
19
+ readonly message: string
20
+ }> {}
21
+
22
+ interface PhaseRecord {
23
+ readonly taskId: string
24
+ readonly id: string
25
+ readonly path: string
26
+ readonly content: string
27
+ readonly data: PhaseData
28
+ }
29
+
30
+ export interface CreatePhaseInput {
31
+ readonly taskId: string
32
+ readonly id: string
33
+ readonly description?: string
34
+ readonly repo: string
35
+ readonly repos?: readonly RepositoryReference[]
36
+ readonly branch: string
37
+ readonly base: string
38
+ readonly dependsOn?: readonly string[]
39
+ readonly firstPhase?: string
40
+ }
41
+
42
+ const decodeId = (id: string, label: string) => {
43
+ const result = Schema.decodeUnknownEither(EntityId)(id)
44
+ return Either.isLeft(result)
45
+ ? Effect.fail(new PhaseError({ message: `Invalid ${label} ID '${id}'` }))
46
+ : Effect.succeed(result.right)
47
+ }
48
+
49
+ const decodePhase = (input: unknown) => {
50
+ const result = Schema.decodeUnknownEither(PhaseFrontmatter, {
51
+ errors: "all",
52
+ onExcessProperty: "error",
53
+ })(input)
54
+ return Either.isLeft(result)
55
+ ? Effect.fail(
56
+ new PhaseError({ message: TreeFormatter.formatErrorSync(result.left) }),
57
+ )
58
+ : Effect.succeed(result.right)
59
+ }
60
+
61
+ export class PhaseService extends Effect.Service<PhaseService>()(
62
+ "PhaseService",
63
+ {
64
+ sync: () => ({
65
+ create: (input: CreatePhaseInput, startPath: string = process.cwd()) =>
66
+ Effect.gen(function* () {
67
+ const fs = yield* FileSystemService
68
+ const workbase = yield* WorkbaseService
69
+ const tasks = yield* TaskService
70
+ const root = yield* workbase.discover(startPath)
71
+ const taskId = yield* decodeId(input.taskId, "task")
72
+ const id = yield* decodeId(input.id, "phase")
73
+ const task = yield* tasks.show(taskId, root)
74
+ const isMultiPhase = "phases" in task.data
75
+ let firstPhaseId: string | undefined
76
+ if (!isMultiPhase) {
77
+ if (!input.firstPhase) {
78
+ return yield* new PhaseError({
79
+ message:
80
+ "Converting a single-phase task requires --first-phase <id>",
81
+ })
82
+ }
83
+ firstPhaseId = yield* decodeId(input.firstPhase, "first phase")
84
+ if (firstPhaseId === id) {
85
+ return yield* new PhaseError({
86
+ message: "The existing and new phase IDs must be different",
87
+ })
88
+ }
89
+ } else if (input.firstPhase) {
90
+ return yield* new PhaseError({
91
+ message:
92
+ "--first-phase is only valid when converting a single-phase task",
93
+ })
94
+ }
95
+
96
+ if (
97
+ isMultiPhase &&
98
+ task.data.phases.some((phase) => phase.id === id)
99
+ ) {
100
+ return yield* new PhaseError({
101
+ message: `Phase '${id}' already exists on task '${taskId}'`,
102
+ })
103
+ }
104
+ const knownPhases = new Set(
105
+ isMultiPhase
106
+ ? task.data.phases.map((phase) => phase.id)
107
+ : [firstPhaseId!],
108
+ )
109
+ for (const dependency of input.dependsOn ?? []) {
110
+ if (!knownPhases.has(dependency)) {
111
+ return yield* new PhaseError({
112
+ message: `Unknown phase dependency '${dependency}'`,
113
+ })
114
+ }
115
+ }
116
+
117
+ const data = yield* decodePhase({
118
+ ...(input.description !== undefined
119
+ ? { description: input.description }
120
+ : {}),
121
+ repo: input.repo,
122
+ ...(input.repos?.length ? { repos: input.repos } : {}),
123
+ branch: input.branch,
124
+ base: input.base,
125
+ pr: null,
126
+ })
127
+ const existingExecution = isMultiPhase ? undefined : task.data
128
+ const aliases = new Set([
129
+ data.repo,
130
+ ...(data.repos ?? []).map((reference) => reference.repo),
131
+ ...(existingExecution
132
+ ? [
133
+ existingExecution.repo,
134
+ ...(existingExecution.repos ?? []).map(
135
+ (reference) => reference.repo,
136
+ ),
137
+ ]
138
+ : []),
139
+ ])
140
+ for (const alias of aliases) {
141
+ if (!(yield* fs.exists(join(root, "repos", alias)))) {
142
+ return yield* new PhaseError({
143
+ message: `Unknown repository alias '${alias}'`,
144
+ })
145
+ }
146
+ }
147
+
148
+ const directory = join(root, "tasks", taskId, "phases", id)
149
+ const path = join(directory, "PHASE.md")
150
+ if (yield* fs.exists(directory)) {
151
+ return yield* new PhaseError({
152
+ message: `Phase directory already exists: ${id}`,
153
+ })
154
+ }
155
+
156
+ const parsedTask = yield* parseFrontmatter(task.content, task.path)
157
+ const title = id
158
+ .split("-")
159
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
160
+ .join(" ")
161
+ const content = formatMarkdownDocument(
162
+ data,
163
+ `# ${title}\n\nDescribe the phase outcome.`,
164
+ )
165
+
166
+ if (!isMultiPhase) {
167
+ const firstDirectory = join(
168
+ root,
169
+ "tasks",
170
+ taskId,
171
+ "phases",
172
+ firstPhaseId!,
173
+ )
174
+ if (yield* fs.exists(firstDirectory)) {
175
+ return yield* new PhaseError({
176
+ message: `Phase directory already exists: ${firstPhaseId}`,
177
+ })
178
+ }
179
+ const firstData = yield* decodePhase({
180
+ repo: task.data.repo,
181
+ ...(task.data.repos?.length ? { repos: task.data.repos } : {}),
182
+ branch: task.data.branch,
183
+ base: task.data.base,
184
+ pr: task.data.pr,
185
+ })
186
+ const firstTitle = firstPhaseId!
187
+ .split("-")
188
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
189
+ .join(" ")
190
+
191
+ yield* fs.createDirectory(firstDirectory)
192
+ yield* fs.createDirectory(directory)
193
+ yield* fs.writeFile(
194
+ join(firstDirectory, "PHASE.md"),
195
+ formatMarkdownDocument(
196
+ firstData,
197
+ `# ${firstTitle}\n\nDescribe the phase outcome.`,
198
+ ),
199
+ )
200
+ yield* fs.writeFile(path, content)
201
+
202
+ const oldCodePath = join(root, "tasks", taskId, "code")
203
+ if (yield* fs.isDirectory(oldCodePath)) {
204
+ const firstCodePath = join(firstDirectory, "code")
205
+ yield* fs.moveDirectory(oldCodePath, firstCodePath)
206
+ for (const alias of [
207
+ firstData.repo,
208
+ ...(firstData.repos ?? []).map((reference) => reference.repo),
209
+ ]) {
210
+ const checkoutPath = join(firstCodePath, alias)
211
+ if (!(yield* fs.isDirectory(checkoutPath))) continue
212
+ const repair = yield* fs.runCommand(
213
+ [
214
+ "git",
215
+ "-C",
216
+ join(root, "repos", alias),
217
+ "worktree",
218
+ "repair",
219
+ checkoutPath,
220
+ ],
221
+ { captureOutput: true },
222
+ )
223
+ if (repair.exitCode !== 0) {
224
+ return yield* new PhaseError({
225
+ message: `Failed to repair moved worktree for '${alias}': ${repair.stderr}`,
226
+ })
227
+ }
228
+ }
229
+ }
230
+
231
+ const convertedTaskData = {
232
+ ticketUrl: task.data.ticketUrl,
233
+ ...(task.data.description
234
+ ? { description: task.data.description }
235
+ : {}),
236
+ ...(task.data.epic ? { epic: task.data.epic } : {}),
237
+ phases: [
238
+ { id: firstPhaseId! },
239
+ {
240
+ id,
241
+ ...(input.dependsOn?.length
242
+ ? { dependsOn: input.dependsOn }
243
+ : {}),
244
+ },
245
+ ],
246
+ }
247
+ yield* fs.writeFile(
248
+ task.path,
249
+ formatMarkdownDocument(convertedTaskData, parsedTask.body),
250
+ )
251
+ return { taskId, id, path, content, data } satisfies PhaseRecord
252
+ }
253
+
254
+ yield* fs.createDirectory(directory)
255
+ yield* fs.writeFile(path, content)
256
+ const updatedTaskData = {
257
+ ...task.data,
258
+ phases: [
259
+ ...task.data.phases,
260
+ {
261
+ id,
262
+ ...(input.dependsOn?.length
263
+ ? { dependsOn: input.dependsOn }
264
+ : {}),
265
+ },
266
+ ],
267
+ }
268
+ yield* fs.writeFile(
269
+ task.path,
270
+ formatMarkdownDocument(updatedTaskData, parsedTask.body),
271
+ )
272
+
273
+ return { taskId, id, path, content, data } satisfies PhaseRecord
274
+ }),
275
+
276
+ list: (taskId: string, startPath: string = process.cwd()) =>
277
+ Effect.gen(function* () {
278
+ const fs = yield* FileSystemService
279
+ const workbase = yield* WorkbaseService
280
+ const tasks = yield* TaskService
281
+ const root = yield* workbase.discover(startPath)
282
+ const validTaskId = yield* decodeId(taskId, "task")
283
+ yield* tasks.show(validTaskId, root)
284
+ const directory = join(root, "tasks", validTaskId, "phases")
285
+ if (!(yield* fs.isDirectory(directory))) return [] as PhaseRecord[]
286
+ const entries = (yield* fs.readDirectory(directory))
287
+ .filter((entry) => entry.isDirectory)
288
+ .sort((a, b) => a.name.localeCompare(b.name))
289
+ const records: PhaseRecord[] = []
290
+ for (const entry of entries) {
291
+ const path = join(directory, entry.name, "PHASE.md")
292
+ if (!(yield* fs.exists(path))) continue
293
+ const content = yield* fs.readFile(path)
294
+ const parsed = yield* parseFrontmatter(content, path)
295
+ const data = yield* decodePhase(parsed.data)
296
+ records.push({
297
+ taskId: validTaskId,
298
+ id: entry.name,
299
+ path,
300
+ content,
301
+ data,
302
+ })
303
+ }
304
+ return records
305
+ }),
306
+
307
+ show: (taskId: string, id: string, startPath: string = process.cwd()) =>
308
+ Effect.gen(function* () {
309
+ const service = yield* PhaseService
310
+ const validId = yield* decodeId(id, "phase")
311
+ const record = (yield* service.list(taskId, startPath)).find(
312
+ (phase) => phase.id === validId,
313
+ )
314
+ if (!record) {
315
+ return yield* new PhaseError({
316
+ message: `Phase '${validId}' does not exist on task '${taskId}'`,
317
+ })
318
+ }
319
+ return record
320
+ }),
321
+ }),
322
+ },
323
+ ) {}
@@ -0,0 +1,386 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { chmod, mkdir, realpath, rm } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { PhaseService } from "./PhaseService"
7
+ import { TaskService } from "./TaskService"
8
+ import { PullRequestService } from "./PullRequestService"
9
+ import { WorktreeService } from "./WorktreeService"
10
+
11
+ interface CommandResult {
12
+ readonly stdout: string
13
+ readonly stderr: string
14
+ readonly exitCode: number
15
+ }
16
+
17
+ const runCommand = async (
18
+ args: readonly string[],
19
+ cwd?: string,
20
+ ): Promise<CommandResult> => {
21
+ const process = Bun.spawn([...args], {
22
+ cwd,
23
+ stdout: "pipe",
24
+ stderr: "pipe",
25
+ })
26
+ const [stdout, stderr, exitCode] = await Promise.all([
27
+ new Response(process.stdout).text(),
28
+ new Response(process.stderr).text(),
29
+ process.exited,
30
+ ])
31
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
32
+ }
33
+
34
+ const requireCommand = async (args: readonly string[], cwd?: string) => {
35
+ const result = await runCommand(args, cwd)
36
+ if (result.exitCode !== 0) {
37
+ throw new Error(`${args.join(" ")} failed: ${result.stderr}`)
38
+ }
39
+ return result
40
+ }
41
+
42
+ describe("PullRequestService", () => {
43
+ let root: string
44
+ let remotePath: string
45
+ let ghCallPath: string
46
+ let originalPath: string | undefined
47
+
48
+ const createTask = (id = "example", branch = `task/${id}`) =>
49
+ runTestEffect(
50
+ TaskService.pipe(
51
+ Effect.flatMap((service) =>
52
+ service.create(
53
+ {
54
+ id,
55
+ ticketUrl: "https://example.com/task",
56
+ repo: "agency",
57
+ branch,
58
+ base: "main",
59
+ },
60
+ root,
61
+ ),
62
+ ),
63
+ ),
64
+ )
65
+
66
+ const createPullRequest = (
67
+ taskId = "example",
68
+ phaseId?: string,
69
+ draft = false,
70
+ ) =>
71
+ runTestEffect(
72
+ PullRequestService.pipe(
73
+ Effect.flatMap((service) =>
74
+ service.create(taskId, phaseId, draft, root),
75
+ ),
76
+ ),
77
+ )
78
+
79
+ const materialize = (taskId = "example", phaseId?: string) =>
80
+ runTestEffect(
81
+ WorktreeService.pipe(
82
+ Effect.flatMap((service) => service.materialize(taskId, phaseId, root)),
83
+ ),
84
+ )
85
+
86
+ const writeFakeGh = async ({
87
+ stdout = "",
88
+ stderr = "",
89
+ exitCode = 0,
90
+ }: {
91
+ stdout?: string
92
+ stderr?: string
93
+ exitCode?: number
94
+ }) => {
95
+ const path = join(root, "bin", "gh")
96
+ await Bun.write(
97
+ path,
98
+ `#!/usr/bin/env bun
99
+ await Bun.write(${JSON.stringify(ghCallPath)}, JSON.stringify({ args: Bun.argv.slice(2), cwd: process.cwd() }))
100
+ process.stdout.write(${JSON.stringify(stdout)})
101
+ process.stderr.write(${JSON.stringify(stderr)})
102
+ process.exit(${exitCode})
103
+ `,
104
+ )
105
+ await chmod(path, 0o755)
106
+ }
107
+
108
+ const readGhCall = async () =>
109
+ (await Bun.file(ghCallPath).json()) as {
110
+ args: string[]
111
+ cwd: string
112
+ }
113
+
114
+ const expectRemoteBranch = async (branch: string, exists = true) => {
115
+ const result = await runCommand([
116
+ "git",
117
+ "--git-dir",
118
+ remotePath,
119
+ "show-ref",
120
+ "--verify",
121
+ `refs/heads/${branch}`,
122
+ ])
123
+ expect(result.exitCode === 0).toBe(exists)
124
+ }
125
+
126
+ beforeEach(async () => {
127
+ root = await createTempDir()
128
+ remotePath = join(root, "remote.git")
129
+ ghCallPath = join(root, "gh-call.json")
130
+ originalPath = process.env.PATH
131
+ await mkdir(join(root, "bin"), { recursive: true })
132
+ process.env.PATH = `${join(root, "bin")}:${originalPath ?? ""}`
133
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
134
+ await mkdir(join(root, "repos"), { recursive: true })
135
+ await requireCommand([
136
+ "git",
137
+ "init",
138
+ "--bare",
139
+ "--initial-branch=main",
140
+ remotePath,
141
+ ])
142
+ const seedPath = join(root, "seed")
143
+ await requireCommand(["git", "init", "--initial-branch=main", seedPath])
144
+ await requireCommand(
145
+ ["git", "config", "user.name", "Agency Test"],
146
+ seedPath,
147
+ )
148
+ await requireCommand(
149
+ ["git", "config", "user.email", "agency@example.com"],
150
+ seedPath,
151
+ )
152
+ await Bun.write(join(seedPath, "README.md"), "# Test repository\n")
153
+ await requireCommand(["git", "add", "README.md"], seedPath)
154
+ await requireCommand(["git", "commit", "-m", "Initial commit"], seedPath)
155
+ await requireCommand(
156
+ ["git", "remote", "add", "origin", remotePath],
157
+ seedPath,
158
+ )
159
+ await requireCommand(["git", "push", "-u", "origin", "main"], seedPath)
160
+ await requireCommand([
161
+ "git",
162
+ "clone",
163
+ "--bare",
164
+ remotePath,
165
+ join(root, "repos", "agency"),
166
+ ])
167
+ })
168
+
169
+ afterEach(async () => {
170
+ process.env.PATH = originalPath
171
+ await cleanupTempDir(root)
172
+ })
173
+
174
+ test("updates the execution document with a PR URL", async () => {
175
+ await createTask()
176
+ const url = "https://github.com/markjaquith/agency/pull/123"
177
+ await runTestEffect(
178
+ PullRequestService.pipe(
179
+ Effect.flatMap((service) =>
180
+ service.setUrl("example", undefined, url, root),
181
+ ),
182
+ ),
183
+ )
184
+ const task = await runTestEffect(
185
+ TaskService.pipe(
186
+ Effect.flatMap((service) => service.show("example", root)),
187
+ ),
188
+ )
189
+ expect("pr" in task.data && task.data.pr).toBe(url)
190
+ })
191
+
192
+ test("rejects non-GitHub PR URLs", async () => {
193
+ await createTask()
194
+ await expect(
195
+ runTestEffect(
196
+ PullRequestService.pipe(
197
+ Effect.flatMap((service) =>
198
+ service.setUrl(
199
+ "example",
200
+ undefined,
201
+ "https://example.com/pr/1",
202
+ root,
203
+ ),
204
+ ),
205
+ ),
206
+ ),
207
+ ).rejects.toThrow("Invalid GitHub pull request URL")
208
+ })
209
+
210
+ test("creates a clean single-phase PR and preserves the task body", async () => {
211
+ await createTask()
212
+ const taskPath = join(root, "tasks", "example", "TASK.md")
213
+ const body = "# Example\n\nKeep this exact body after creating the PR."
214
+ const original = await Bun.file(taskPath).text()
215
+ await Bun.write(
216
+ taskPath,
217
+ original.replace("# Example\n\nDescribe the task outcome.", body),
218
+ )
219
+ const url = "https://github.com/example/agency/pull/42"
220
+ await writeFakeGh({ stdout: `${url}\n` })
221
+
222
+ expect(await createPullRequest()).toBe(url)
223
+ await expectRemoteBranch("task/example")
224
+ const ghCall = await readGhCall()
225
+ expect(ghCall).toEqual({
226
+ args: ["pr", "create", "--fill", "--base", "main"],
227
+ cwd: await realpath(join(root, "tasks", "example", "code", "agency")),
228
+ })
229
+ const updated = await Bun.file(taskPath).text()
230
+ expect(updated).toContain("pr: https://github.com/example/agency/pull/42")
231
+ expect(updated.endsWith(`${body}\n`)).toBe(true)
232
+ })
233
+
234
+ test("passes --draft to gh", async () => {
235
+ await createTask()
236
+ await writeFakeGh({
237
+ stdout: "https://github.com/example/agency/pull/43\n",
238
+ })
239
+
240
+ await createPullRequest("example", undefined, true)
241
+
242
+ expect((await readGhCall()).args).toEqual([
243
+ "pr",
244
+ "create",
245
+ "--fill",
246
+ "--base",
247
+ "main",
248
+ "--draft",
249
+ ])
250
+ })
251
+
252
+ test("updates only PHASE.md for a phase PR", async () => {
253
+ await runTestEffect(
254
+ TaskService.pipe(
255
+ Effect.flatMap((service) =>
256
+ service.create(
257
+ {
258
+ id: "multi",
259
+ ticketUrl: "https://example.com/task",
260
+ multiPhase: true,
261
+ },
262
+ root,
263
+ ),
264
+ ),
265
+ ),
266
+ )
267
+ await runTestEffect(
268
+ PhaseService.pipe(
269
+ Effect.flatMap((service) =>
270
+ service.create(
271
+ {
272
+ taskId: "multi",
273
+ id: "implementation",
274
+ repo: "agency",
275
+ branch: "task/multi-implementation",
276
+ base: "main",
277
+ },
278
+ root,
279
+ ),
280
+ ),
281
+ ),
282
+ )
283
+ const taskPath = join(root, "tasks", "multi", "TASK.md")
284
+ const phasePath = join(
285
+ root,
286
+ "tasks",
287
+ "multi",
288
+ "phases",
289
+ "implementation",
290
+ "PHASE.md",
291
+ )
292
+ const originalTask = await Bun.file(taskPath).text()
293
+ const url = "https://github.com/example/agency/pull/44"
294
+ await writeFakeGh({ stdout: url })
295
+
296
+ await createPullRequest("multi", "implementation")
297
+
298
+ expect(await Bun.file(taskPath).text()).toBe(originalTask)
299
+ expect(await Bun.file(phasePath).text()).toContain(`pr: ${url}`)
300
+ await expectRemoteBranch("task/multi-implementation")
301
+ })
302
+
303
+ test("blocks a dirty checkout before push or gh", async () => {
304
+ await createTask()
305
+ const workspace = await materialize()
306
+ await Bun.write(join(workspace.writablePath, "dirty.txt"), "dirty\n")
307
+ await writeFakeGh({ stdout: "https://github.com/example/agency/pull/45" })
308
+
309
+ await expect(createPullRequest()).rejects.toThrow(
310
+ "Cannot create a PR with a dirty worktree",
311
+ )
312
+
313
+ await expectRemoteBranch("task/example", false)
314
+ expect(await Bun.file(ghCallPath).exists()).toBe(false)
315
+ })
316
+
317
+ test("reports push failure and does not invoke gh", async () => {
318
+ await createTask()
319
+ await materialize()
320
+ const hookPath = join(remotePath, "hooks", "pre-receive")
321
+ await Bun.write(hookPath, "#!/bin/sh\necho push rejected >&2\nexit 1\n")
322
+ await chmod(hookPath, 0o755)
323
+ await writeFakeGh({ stdout: "https://github.com/example/agency/pull/46" })
324
+
325
+ await expect(createPullRequest()).rejects.toThrow("Failed to push branch")
326
+
327
+ await expectRemoteBranch("task/example", false)
328
+ expect(await Bun.file(ghCallPath).exists()).toBe(false)
329
+ })
330
+
331
+ test("reports gh failure without persisting a URL", async () => {
332
+ await createTask()
333
+ await writeFakeGh({ stderr: "authentication failed\n", exitCode: 1 })
334
+
335
+ await expect(createPullRequest()).rejects.toThrow(
336
+ "Failed to create pull request: authentication failed",
337
+ )
338
+
339
+ const task = await runTestEffect(
340
+ TaskService.pipe(
341
+ Effect.flatMap((service) => service.show("example", root)),
342
+ ),
343
+ )
344
+ expect("pr" in task.data && task.data.pr).toBeNull()
345
+ })
346
+
347
+ test("rejects successful gh output without a PR URL", async () => {
348
+ await createTask()
349
+ await writeFakeGh({ stdout: "Pull request created successfully\n" })
350
+
351
+ await expect(createPullRequest()).rejects.toThrow(
352
+ "GitHub CLI did not return a pull request URL",
353
+ )
354
+ })
355
+
356
+ test("extracts a PR URL from noisy gh output", async () => {
357
+ await createTask()
358
+ const url = "https://github.com/example/agency/pull/47"
359
+ await writeFakeGh({
360
+ stdout: `Creating pull request...\n${url}\nView it in your browser.\n`,
361
+ })
362
+
363
+ expect(await createPullRequest()).toBe(url)
364
+
365
+ const task = await runTestEffect(
366
+ TaskService.pipe(
367
+ Effect.flatMap((service) => service.show("example", root)),
368
+ ),
369
+ )
370
+ expect("pr" in task.data && task.data.pr).toBe(url)
371
+ })
372
+
373
+ test("reports git status failure before push or gh", async () => {
374
+ await createTask()
375
+ const workspace = await materialize()
376
+ await rm(join(workspace.writablePath, ".git"))
377
+ await writeFakeGh({ stdout: "https://github.com/example/agency/pull/48" })
378
+
379
+ await expect(createPullRequest()).rejects.toThrow(
380
+ "Failed to inspect worktree status",
381
+ )
382
+
383
+ await expectRemoteBranch("task/example", false)
384
+ expect(await Bun.file(ghCallPath).exists()).toBe(false)
385
+ })
386
+ })