@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,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
+ })
@@ -0,0 +1,159 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { join, resolve } from "node:path"
4
+ import { FileSystemService } from "./FileSystemService"
5
+ import { WorkbaseService } from "./WorkbaseService"
6
+ import { RepositoryAlias } from "../workbase/schemas"
7
+
8
+ class RepositoryError extends Data.TaggedError("RepositoryError")<{
9
+ readonly message: string
10
+ readonly cause?: unknown
11
+ }> {}
12
+
13
+ interface RepositoryInfo {
14
+ readonly alias: string
15
+ readonly path: string
16
+ readonly kind: "bare" | "repository" | "symlink"
17
+ readonly remote: string | null
18
+ readonly target: string | null
19
+ }
20
+
21
+ const validateAlias = (alias: string) => {
22
+ const result = Schema.decodeUnknownEither(RepositoryAlias)(alias)
23
+ return Either.isLeft(result)
24
+ ? Effect.fail(
25
+ new RepositoryError({
26
+ message: `Invalid repository alias '${alias}': ${TreeFormatter.formatErrorSync(result.left)}`,
27
+ }),
28
+ )
29
+ : Effect.succeed(result.right)
30
+ }
31
+
32
+ export class RepositoryService extends Effect.Service<RepositoryService>()(
33
+ "RepositoryService",
34
+ {
35
+ sync: () => ({
36
+ add: (alias: string, remote: string, startPath: string = process.cwd()) =>
37
+ Effect.gen(function* () {
38
+ const fs = yield* FileSystemService
39
+ const workbase = yield* WorkbaseService
40
+ const validAlias = yield* validateAlias(alias)
41
+ const root = yield* workbase.discover(startPath)
42
+ const reposPath = join(root, "repos")
43
+ const destination = join(reposPath, validAlias)
44
+
45
+ if (!remote.trim()) {
46
+ return yield* new RepositoryError({
47
+ message: "Repository remote is required",
48
+ })
49
+ }
50
+ if (yield* fs.exists(destination)) {
51
+ return yield* new RepositoryError({
52
+ message: `Repository alias '${validAlias}' already exists`,
53
+ })
54
+ }
55
+
56
+ yield* fs.createDirectory(reposPath)
57
+ const result = yield* fs.runCommand(
58
+ ["git", "clone", "--bare", remote, destination],
59
+ { captureOutput: true },
60
+ )
61
+ if (result.exitCode !== 0) {
62
+ yield* fs.deleteDirectory(destination).pipe(Effect.ignore)
63
+ return yield* new RepositoryError({
64
+ message: `Failed to clone repository '${remote}': ${result.stderr.trim()}`,
65
+ })
66
+ }
67
+
68
+ return destination
69
+ }),
70
+
71
+ link: (
72
+ alias: string,
73
+ target: string,
74
+ startPath: string = process.cwd(),
75
+ ) =>
76
+ Effect.gen(function* () {
77
+ const fs = yield* FileSystemService
78
+ const workbase = yield* WorkbaseService
79
+ const validAlias = yield* validateAlias(alias)
80
+ const root = yield* workbase.discover(startPath)
81
+ const reposPath = join(root, "repos")
82
+ const destination = join(reposPath, validAlias)
83
+ const resolvedTarget = resolve(startPath, target)
84
+
85
+ if (yield* fs.exists(destination)) {
86
+ return yield* new RepositoryError({
87
+ message: `Repository alias '${validAlias}' already exists`,
88
+ })
89
+ }
90
+ if (!(yield* fs.isDirectory(resolvedTarget))) {
91
+ return yield* new RepositoryError({
92
+ message: `Repository path does not exist: ${resolvedTarget}`,
93
+ })
94
+ }
95
+
96
+ const result = yield* fs.runCommand(
97
+ ["git", "-C", resolvedTarget, "rev-parse", "--git-dir"],
98
+ { captureOutput: true },
99
+ )
100
+ if (result.exitCode !== 0) {
101
+ return yield* new RepositoryError({
102
+ message: `Path is not a Git repository: ${resolvedTarget}`,
103
+ })
104
+ }
105
+
106
+ yield* fs.createDirectory(reposPath)
107
+ yield* fs.createSymlink(resolvedTarget, destination)
108
+ return destination
109
+ }),
110
+
111
+ list: (startPath: string = process.cwd()) =>
112
+ Effect.gen(function* () {
113
+ const fs = yield* FileSystemService
114
+ const workbase = yield* WorkbaseService
115
+ const root = yield* workbase.discover(startPath)
116
+ const reposPath = join(root, "repos")
117
+
118
+ if (!(yield* fs.isDirectory(reposPath))) {
119
+ return [] as RepositoryInfo[]
120
+ }
121
+
122
+ const entries = (yield* fs.readDirectory(reposPath))
123
+ .filter((entry) => entry.isDirectory || entry.isSymlink)
124
+ .sort((a, b) => a.name.localeCompare(b.name))
125
+ const repositories: RepositoryInfo[] = []
126
+
127
+ for (const entry of entries) {
128
+ const path = join(reposPath, entry.name)
129
+ const target = entry.isSymlink
130
+ ? yield* fs.readSymlinkTarget(path)
131
+ : null
132
+ const bareResult = yield* fs.runCommand(
133
+ ["git", "-C", path, "rev-parse", "--is-bare-repository"],
134
+ { captureOutput: true },
135
+ )
136
+ const remoteResult = yield* fs.runCommand(
137
+ ["git", "-C", path, "remote", "get-url", "origin"],
138
+ { captureOutput: true },
139
+ )
140
+
141
+ repositories.push({
142
+ alias: entry.name,
143
+ path,
144
+ kind: entry.isSymlink
145
+ ? "symlink"
146
+ : bareResult.stdout.trim() === "true"
147
+ ? "bare"
148
+ : "repository",
149
+ remote:
150
+ remoteResult.exitCode === 0 ? remoteResult.stdout.trim() : null,
151
+ target,
152
+ })
153
+ }
154
+
155
+ return repositories
156
+ }),
157
+ }),
158
+ },
159
+ ) {}
@@ -0,0 +1,217 @@
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 { EpicService } from "./EpicService"
7
+ import { TaskService } from "./TaskService"
8
+ import { PhaseService } from "./PhaseService"
9
+ import { PullRequestService } from "./PullRequestService"
10
+
11
+ describe("task and phase services", () => {
12
+ let root: string
13
+
14
+ beforeEach(async () => {
15
+ root = await createTempDir()
16
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
17
+ await mkdir(join(root, "repos/agency"), { recursive: true })
18
+ await mkdir(join(root, "repos/effect"), { recursive: true })
19
+ })
20
+
21
+ afterEach(async () => cleanupTempDir(root))
22
+
23
+ test("creates a single-phase task and updates its epic", async () => {
24
+ await runTestEffect(
25
+ EpicService.pipe(
26
+ Effect.flatMap((service) =>
27
+ service.create(
28
+ "example",
29
+ "https://example.com/epic",
30
+ [{ repo: "agency", ref: "main" }],
31
+ root,
32
+ ),
33
+ ),
34
+ ),
35
+ )
36
+ await runTestEffect(
37
+ TaskService.pipe(
38
+ Effect.flatMap((service) =>
39
+ service.create(
40
+ {
41
+ id: "task-one",
42
+ ticketUrl: "https://example.com/task",
43
+ epic: "example",
44
+ repo: "agency",
45
+ repos: [{ repo: "effect", ref: "main" }],
46
+ branch: "task/one",
47
+ base: "main",
48
+ },
49
+ root,
50
+ ),
51
+ ),
52
+ ),
53
+ )
54
+
55
+ const epic = await runTestEffect(
56
+ EpicService.pipe(
57
+ Effect.flatMap((service) => service.show("example", root)),
58
+ ),
59
+ )
60
+ expect(epic.data.tasks).toEqual([{ id: "task-one" }])
61
+ })
62
+
63
+ test("creates and sequences phases on a multi-phase task", async () => {
64
+ await runTestEffect(
65
+ TaskService.pipe(
66
+ Effect.flatMap((service) =>
67
+ service.create(
68
+ {
69
+ id: "multi",
70
+ ticketUrl: "https://example.com/task",
71
+ multiPhase: true,
72
+ },
73
+ root,
74
+ ),
75
+ ),
76
+ ),
77
+ )
78
+ for (const phase of [
79
+ { id: "first", dependsOn: undefined },
80
+ { id: "second", dependsOn: ["first"] },
81
+ ]) {
82
+ await runTestEffect(
83
+ PhaseService.pipe(
84
+ Effect.flatMap((service) =>
85
+ service.create(
86
+ {
87
+ taskId: "multi",
88
+ id: phase.id,
89
+ repo: "agency",
90
+ branch: `task/${phase.id}`,
91
+ base: "main",
92
+ dependsOn: phase.dependsOn,
93
+ },
94
+ root,
95
+ ),
96
+ ),
97
+ ),
98
+ )
99
+ }
100
+
101
+ const task = await runTestEffect(
102
+ TaskService.pipe(
103
+ Effect.flatMap((service) => service.show("multi", root)),
104
+ ),
105
+ )
106
+ expect("phases" in task.data && task.data.phases).toEqual([
107
+ { id: "first" },
108
+ { id: "second", dependsOn: ["first"] },
109
+ ])
110
+ const phases = await runTestEffect(
111
+ PhaseService.pipe(
112
+ Effect.flatMap((service) => service.list("multi", root)),
113
+ ),
114
+ )
115
+ expect(phases.map((phase) => phase.id)).toEqual(["first", "second"])
116
+ })
117
+
118
+ test("converts a single-phase task when the existing phase ID is provided", async () => {
119
+ await runTestEffect(
120
+ TaskService.pipe(
121
+ Effect.flatMap((service) =>
122
+ service.create(
123
+ {
124
+ id: "single",
125
+ ticketUrl: "https://example.com/task",
126
+ description: "Deliver the complete task.",
127
+ repo: "agency",
128
+ repos: [{ repo: "effect", ref: "main" }],
129
+ branch: "task/single",
130
+ base: "main",
131
+ },
132
+ root,
133
+ ),
134
+ ),
135
+ ),
136
+ )
137
+ await runTestEffect(
138
+ PullRequestService.pipe(
139
+ Effect.flatMap((service) =>
140
+ service.setUrl(
141
+ "single",
142
+ undefined,
143
+ "https://github.com/example/agency/pull/42",
144
+ root,
145
+ ),
146
+ ),
147
+ ),
148
+ )
149
+ await expect(
150
+ runTestEffect(
151
+ PhaseService.pipe(
152
+ Effect.flatMap((service) =>
153
+ service.create(
154
+ {
155
+ taskId: "single",
156
+ id: "extra",
157
+ repo: "agency",
158
+ branch: "task/extra",
159
+ base: "main",
160
+ },
161
+ root,
162
+ ),
163
+ ),
164
+ ),
165
+ ),
166
+ ).rejects.toThrow("requires --first-phase")
167
+
168
+ await runTestEffect(
169
+ PhaseService.pipe(
170
+ Effect.flatMap((service) =>
171
+ service.create(
172
+ {
173
+ taskId: "single",
174
+ id: "extra",
175
+ firstPhase: "implementation",
176
+ repo: "agency",
177
+ branch: "task/extra",
178
+ base: "main",
179
+ dependsOn: ["implementation"],
180
+ },
181
+ root,
182
+ ),
183
+ ),
184
+ ),
185
+ )
186
+
187
+ const task = await runTestEffect(
188
+ TaskService.pipe(
189
+ Effect.flatMap((service) => service.show("single", root)),
190
+ ),
191
+ )
192
+ expect(task.data).toEqual({
193
+ ticketUrl: "https://example.com/task",
194
+ description: "Deliver the complete task.",
195
+ phases: [
196
+ { id: "implementation" },
197
+ { id: "extra", dependsOn: ["implementation"] },
198
+ ],
199
+ })
200
+ expect(task.content).toContain("Describe the task outcome.")
201
+
202
+ const firstPhase = await runTestEffect(
203
+ PhaseService.pipe(
204
+ Effect.flatMap((service) =>
205
+ service.show("single", "implementation", root),
206
+ ),
207
+ ),
208
+ )
209
+ expect(firstPhase.data).toEqual({
210
+ repo: "agency",
211
+ repos: [{ repo: "effect", ref: "main" }],
212
+ branch: "task/single",
213
+ base: "main",
214
+ pr: "https://github.com/example/agency/pull/42",
215
+ })
216
+ })
217
+ })