@markjaquith/agency 1.11.1 → 2.1.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 (132) hide show
  1. package/README.md +313 -117
  2. package/cli.ts +124 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +266 -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 +326 -399
  24. package/src/commands/work.ts +153 -153
  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/work-target.test.ts +77 -0
  53. package/src/workbase/work-target.ts +180 -0
  54. package/src/workbase/worktree-command.test.ts +61 -0
  55. package/src/workbase/worktree-command.ts +52 -0
  56. package/src/commands/base.test.ts +0 -198
  57. package/src/commands/base.ts +0 -198
  58. package/src/commands/clean.test.ts +0 -299
  59. package/src/commands/clean.ts +0 -274
  60. package/src/commands/completions.test.ts +0 -51
  61. package/src/commands/completions.ts +0 -308
  62. package/src/commands/emit.integration.test.ts +0 -549
  63. package/src/commands/emit.test.ts +0 -576
  64. package/src/commands/emit.ts +0 -700
  65. package/src/commands/emitted.test.ts +0 -226
  66. package/src/commands/emitted.ts +0 -57
  67. package/src/commands/loop.test.ts +0 -621
  68. package/src/commands/loop.ts +0 -406
  69. package/src/commands/merge.integration.test.ts +0 -195
  70. package/src/commands/merge.test.ts +0 -246
  71. package/src/commands/merge.ts +0 -244
  72. package/src/commands/pull.test.ts +0 -395
  73. package/src/commands/pull.ts +0 -205
  74. package/src/commands/push.test.ts +0 -641
  75. package/src/commands/push.ts +0 -482
  76. package/src/commands/rebase.test.ts +0 -521
  77. package/src/commands/rebase.ts +0 -232
  78. package/src/commands/save.test.ts +0 -247
  79. package/src/commands/save.ts +0 -162
  80. package/src/commands/source.test.ts +0 -195
  81. package/src/commands/source.ts +0 -72
  82. package/src/commands/switch.test.ts +0 -194
  83. package/src/commands/switch.ts +0 -84
  84. package/src/commands/task-branching.test.ts +0 -633
  85. package/src/commands/task-continue.test.ts +0 -311
  86. package/src/commands/task-edit.test.ts +0 -235
  87. package/src/commands/task-main.test.ts +0 -1066
  88. package/src/commands/task-squash.test.ts +0 -266
  89. package/src/commands/tasks.test.ts +0 -391
  90. package/src/commands/tasks.ts +0 -161
  91. package/src/commands/template-delete.test.ts +0 -178
  92. package/src/commands/template-delete.ts +0 -98
  93. package/src/commands/template-list.test.ts +0 -135
  94. package/src/commands/template-list.ts +0 -87
  95. package/src/commands/template-view.test.ts +0 -158
  96. package/src/commands/template-view.ts +0 -86
  97. package/src/commands/template.test.ts +0 -32
  98. package/src/commands/template.ts +0 -96
  99. package/src/commands/use.test.ts +0 -87
  100. package/src/commands/use.ts +0 -97
  101. package/src/constants.ts +0 -10
  102. package/src/errors.ts +0 -17
  103. package/src/schemas.ts +0 -33
  104. package/src/services/AgencyMetadataService.ts +0 -311
  105. package/src/services/ClaudeService.test.ts +0 -184
  106. package/src/services/ClaudeService.ts +0 -91
  107. package/src/services/ConfigService.ts +0 -115
  108. package/src/services/FilterRepoService.ts +0 -164
  109. package/src/services/FormatterService.test.ts +0 -432
  110. package/src/services/FormatterService.ts +0 -219
  111. package/src/services/GitService.ts +0 -1187
  112. package/src/services/MockFilterRepoService.ts +0 -140
  113. package/src/services/OpencodeService.ts +0 -263
  114. package/src/services/PromptService.ts +0 -183
  115. package/src/services/TemplateService.ts +0 -81
  116. package/src/types/native-exec.d.ts +0 -8
  117. package/src/utils/colors.ts +0 -178
  118. package/src/utils/git-path.ts +0 -6
  119. package/src/utils/glob.test.ts +0 -161
  120. package/src/utils/glob.ts +0 -78
  121. package/src/utils/paths.ts +0 -51
  122. package/src/utils/pr-branch.test.ts +0 -376
  123. package/src/utils/pr-branch.ts +0 -508
  124. package/src/utils/spinner.test.ts +0 -45
  125. package/src/utils/spinner.ts +0 -96
  126. package/src/utils/task-parser.test.ts +0 -172
  127. package/src/utils/task-parser.ts +0 -93
  128. package/templates/AGENCY.md +0 -22
  129. package/templates/AGENTS.md +0 -11
  130. package/templates/CLAUDE.md +0 -3
  131. package/templates/TASK.md +0 -5
  132. package/templates/opencode.json +0 -4
@@ -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
+ })
@@ -0,0 +1,143 @@
1
+ import { Data, Effect } from "effect"
2
+ import { FileSystemService } from "./FileSystemService"
3
+ import { WorkbaseService } from "./WorkbaseService"
4
+ import { WorktreeService } from "./WorktreeService"
5
+ import { TaskService } from "./TaskService"
6
+ import { PhaseService } from "./PhaseService"
7
+ import {
8
+ formatMarkdownDocument,
9
+ parseFrontmatter,
10
+ } from "../workbase/frontmatter"
11
+
12
+ class PullRequestError extends Data.TaggedError("PullRequestError")<{
13
+ readonly message: string
14
+ }> {}
15
+
16
+ const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
17
+
18
+ export class PullRequestService extends Effect.Service<PullRequestService>()(
19
+ "PullRequestService",
20
+ {
21
+ sync: () => ({
22
+ setUrl: (
23
+ taskId: string,
24
+ phaseId: string | undefined,
25
+ url: string,
26
+ startPath: string = process.cwd(),
27
+ ) =>
28
+ Effect.gen(function* () {
29
+ if (!PR_URL.test(url)) {
30
+ return yield* new PullRequestError({
31
+ message: `Invalid GitHub pull request URL: ${url}`,
32
+ })
33
+ }
34
+ const fs = yield* FileSystemService
35
+ const workbase = yield* WorkbaseService
36
+ const tasks = yield* TaskService
37
+ const phases = yield* PhaseService
38
+ const root = yield* workbase.discover(startPath)
39
+ const task = yield* tasks.show(taskId, root)
40
+ const record =
41
+ "phases" in task.data
42
+ ? phaseId
43
+ ? yield* phases.show(taskId, phaseId, root)
44
+ : yield* new PullRequestError({
45
+ message: `Task '${taskId}' requires a phase ID`,
46
+ })
47
+ : task
48
+ const parsed = yield* parseFrontmatter(record.content, record.path)
49
+ const data = { ...record.data, pr: url }
50
+ yield* fs.writeFile(
51
+ record.path,
52
+ formatMarkdownDocument(data, parsed.body),
53
+ )
54
+ return url
55
+ }),
56
+
57
+ create: (
58
+ taskId: string,
59
+ phaseId?: string,
60
+ draft = false,
61
+ startPath: string = process.cwd(),
62
+ ) =>
63
+ Effect.gen(function* () {
64
+ const service = yield* PullRequestService
65
+ const fs = yield* FileSystemService
66
+ const tasks = yield* TaskService
67
+ const phases = yield* PhaseService
68
+ const worktrees = yield* WorktreeService
69
+ const workspace = yield* worktrees.materialize(
70
+ taskId,
71
+ phaseId,
72
+ startPath,
73
+ )
74
+ const task = yield* tasks.show(taskId, workspace.root)
75
+ const execution =
76
+ "phases" in task.data
77
+ ? (yield* phases.show(taskId, phaseId!, workspace.root)).data
78
+ : task.data
79
+
80
+ const status = yield* fs.runCommand(
81
+ ["git", "-C", workspace.writablePath, "status", "--porcelain"],
82
+ { captureOutput: true },
83
+ )
84
+ if (status.exitCode !== 0) {
85
+ return yield* new PullRequestError({
86
+ message: `Failed to inspect worktree status: ${status.stderr}`,
87
+ })
88
+ }
89
+ if (status.stdout) {
90
+ return yield* new PullRequestError({
91
+ message: "Cannot create a PR with a dirty worktree",
92
+ })
93
+ }
94
+
95
+ const push = yield* fs.runCommand(
96
+ [
97
+ "git",
98
+ "-C",
99
+ workspace.writablePath,
100
+ "push",
101
+ "--set-upstream",
102
+ "origin",
103
+ execution.branch,
104
+ ],
105
+ { captureOutput: true },
106
+ )
107
+ if (push.exitCode !== 0) {
108
+ return yield* new PullRequestError({
109
+ message: `Failed to push branch: ${push.stderr}`,
110
+ })
111
+ }
112
+
113
+ const args = [
114
+ "gh",
115
+ "pr",
116
+ "create",
117
+ "--fill",
118
+ "--base",
119
+ execution.base,
120
+ ]
121
+ if (draft) args.push("--draft")
122
+ const created = yield* fs.runCommand(args, {
123
+ cwd: workspace.writablePath,
124
+ captureOutput: true,
125
+ })
126
+ if (created.exitCode !== 0) {
127
+ return yield* new PullRequestError({
128
+ message: `Failed to create pull request: ${created.stderr}`,
129
+ })
130
+ }
131
+ const url = created.stdout
132
+ .split(/\s+/)
133
+ .find((value) => PR_URL.test(value))
134
+ if (!url) {
135
+ return yield* new PullRequestError({
136
+ message: "GitHub CLI did not return a pull request URL",
137
+ })
138
+ }
139
+ return yield* service.setUrl(taskId, phaseId, url, workspace.root)
140
+ }),
141
+ }),
142
+ },
143
+ ) {}
@@ -0,0 +1,106 @@
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 { RepositoryService } from "./RepositoryService"
7
+
8
+ const runGit = async (args: string[]) => {
9
+ const process = Bun.spawn(["git", ...args], {
10
+ stdout: "pipe",
11
+ stderr: "pipe",
12
+ })
13
+ await process.exited
14
+ if (process.exitCode !== 0) {
15
+ throw new Error(await new Response(process.stderr).text())
16
+ }
17
+ }
18
+
19
+ describe("RepositoryService", () => {
20
+ let root: string
21
+
22
+ beforeEach(async () => {
23
+ root = await createTempDir()
24
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
25
+ })
26
+
27
+ afterEach(async () => {
28
+ await cleanupTempDir(root)
29
+ })
30
+
31
+ test("adds a bare repository from a remote", async () => {
32
+ const source = join(root, "source.git")
33
+ await runGit(["init", "--bare", "--initial-branch=main", source])
34
+
35
+ const destination = await runTestEffect(
36
+ RepositoryService.pipe(
37
+ Effect.flatMap((service) => service.add("agency", source, root)),
38
+ ),
39
+ )
40
+
41
+ expect(destination).toBe(join(root, "repos/agency"))
42
+ expect(await Bun.file(join(destination, "HEAD")).exists()).toBe(true)
43
+
44
+ const repositories = await runTestEffect(
45
+ RepositoryService.pipe(Effect.flatMap((service) => service.list(root))),
46
+ )
47
+ expect(repositories).toEqual([
48
+ {
49
+ alias: "agency",
50
+ path: destination,
51
+ kind: "bare",
52
+ remote: source,
53
+ target: null,
54
+ },
55
+ ])
56
+ })
57
+
58
+ test("links an existing repository", async () => {
59
+ const target = join(root, "linked-repository")
60
+ await mkdir(target, { recursive: true })
61
+ await runGit(["init", "--initial-branch=main", target])
62
+
63
+ const destination = await runTestEffect(
64
+ RepositoryService.pipe(
65
+ Effect.flatMap((service) => service.link("effect", target, root)),
66
+ ),
67
+ )
68
+
69
+ const repositories = await runTestEffect(
70
+ RepositoryService.pipe(Effect.flatMap((service) => service.list(root))),
71
+ )
72
+ expect(repositories[0]).toEqual({
73
+ alias: "effect",
74
+ path: destination,
75
+ kind: "symlink",
76
+ remote: null,
77
+ target,
78
+ })
79
+ })
80
+
81
+ test("rejects invalid and duplicate aliases", async () => {
82
+ const source = join(root, "source.git")
83
+ await runGit(["init", "--bare", "--initial-branch=main", source])
84
+
85
+ await expect(
86
+ runTestEffect(
87
+ RepositoryService.pipe(
88
+ Effect.flatMap((service) => service.add("bad/alias", source, root)),
89
+ ),
90
+ ),
91
+ ).rejects.toThrow("Invalid repository alias")
92
+
93
+ await runTestEffect(
94
+ RepositoryService.pipe(
95
+ Effect.flatMap((service) => service.add("agency", source, root)),
96
+ ),
97
+ )
98
+ await expect(
99
+ runTestEffect(
100
+ RepositoryService.pipe(
101
+ Effect.flatMap((service) => service.add("agency", source, root)),
102
+ ),
103
+ ),
104
+ ).rejects.toThrow("already exists")
105
+ })
106
+ })