@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.
- package/README.md +313 -117
- package/cli.ts +124 -420
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +266 -0
- package/src/cli.test.ts +290 -0
- package/src/commands/description.test.ts +103 -0
- package/src/commands/epic.test.ts +103 -0
- package/src/commands/epic.ts +101 -0
- package/src/commands/init.test.ts +42 -290
- package/src/commands/init.ts +15 -123
- package/src/commands/phase.ts +120 -0
- package/src/commands/pr.test.ts +19 -170
- package/src/commands/pr.ts +23 -179
- package/src/commands/repo.test.ts +80 -0
- package/src/commands/repo.ts +83 -0
- package/src/commands/status.test.ts +35 -477
- package/src/commands/status.ts +24 -213
- package/src/commands/task-phase.test.ts +206 -0
- package/src/commands/task.ts +83 -1285
- package/src/commands/validate.test.ts +91 -0
- package/src/commands/validate.ts +50 -0
- package/src/commands/work.test.ts +326 -399
- package/src/commands/work.ts +153 -153
- package/src/services/EpicService.test.ts +71 -0
- package/src/services/EpicService.ts +135 -0
- package/src/services/FileSystemService.ts +61 -92
- package/src/services/PhaseService.ts +323 -0
- package/src/services/PullRequestService.test.ts +386 -0
- package/src/services/PullRequestService.ts +143 -0
- package/src/services/RepositoryService.test.ts +106 -0
- package/src/services/RepositoryService.ts +159 -0
- package/src/services/TaskPhaseService.test.ts +217 -0
- package/src/services/TaskService.ts +192 -0
- package/src/services/WorkbaseService.test.ts +446 -0
- package/src/services/WorkbaseService.ts +493 -0
- package/src/services/WorktreeService.test.ts +599 -0
- package/src/services/WorktreeService.ts +357 -0
- package/src/test-utils.ts +41 -438
- package/src/types.ts +1 -215
- package/src/utils/command.ts +1 -5
- package/src/utils/effect.test.ts +24 -0
- package/src/utils/effect.ts +3 -273
- package/src/utils/process.test.ts +0 -15
- package/src/utils/process.ts +1 -60
- package/src/workbase/frontmatter.test.ts +50 -0
- package/src/workbase/frontmatter.ts +77 -0
- package/src/workbase/repository-reference.test.ts +25 -0
- package/src/workbase/repository-reference.ts +18 -0
- package/src/workbase/schemas.test.ts +146 -0
- package/src/workbase/schemas.ts +78 -0
- package/src/workbase/work-target.test.ts +77 -0
- package/src/workbase/work-target.ts +180 -0
- package/src/workbase/worktree-command.test.ts +61 -0
- package/src/workbase/worktree-command.ts +52 -0
- package/src/commands/base.test.ts +0 -198
- package/src/commands/base.ts +0 -198
- package/src/commands/clean.test.ts +0 -299
- package/src/commands/clean.ts +0 -274
- package/src/commands/completions.test.ts +0 -51
- package/src/commands/completions.ts +0 -308
- package/src/commands/emit.integration.test.ts +0 -549
- package/src/commands/emit.test.ts +0 -576
- package/src/commands/emit.ts +0 -700
- package/src/commands/emitted.test.ts +0 -226
- package/src/commands/emitted.ts +0 -57
- package/src/commands/loop.test.ts +0 -621
- package/src/commands/loop.ts +0 -406
- package/src/commands/merge.integration.test.ts +0 -195
- package/src/commands/merge.test.ts +0 -246
- package/src/commands/merge.ts +0 -244
- package/src/commands/pull.test.ts +0 -395
- package/src/commands/pull.ts +0 -205
- package/src/commands/push.test.ts +0 -641
- package/src/commands/push.ts +0 -482
- package/src/commands/rebase.test.ts +0 -521
- package/src/commands/rebase.ts +0 -232
- package/src/commands/save.test.ts +0 -247
- package/src/commands/save.ts +0 -162
- package/src/commands/source.test.ts +0 -195
- package/src/commands/source.ts +0 -72
- package/src/commands/switch.test.ts +0 -194
- package/src/commands/switch.ts +0 -84
- package/src/commands/task-branching.test.ts +0 -633
- package/src/commands/task-continue.test.ts +0 -311
- package/src/commands/task-edit.test.ts +0 -235
- package/src/commands/task-main.test.ts +0 -1066
- package/src/commands/task-squash.test.ts +0 -266
- package/src/commands/tasks.test.ts +0 -391
- package/src/commands/tasks.ts +0 -161
- package/src/commands/template-delete.test.ts +0 -178
- package/src/commands/template-delete.ts +0 -98
- package/src/commands/template-list.test.ts +0 -135
- package/src/commands/template-list.ts +0 -87
- package/src/commands/template-view.test.ts +0 -158
- package/src/commands/template-view.ts +0 -86
- package/src/commands/template.test.ts +0 -32
- package/src/commands/template.ts +0 -96
- package/src/commands/use.test.ts +0 -87
- package/src/commands/use.ts +0 -97
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -17
- package/src/schemas.ts +0 -33
- package/src/services/AgencyMetadataService.ts +0 -311
- package/src/services/ClaudeService.test.ts +0 -184
- package/src/services/ClaudeService.ts +0 -91
- package/src/services/ConfigService.ts +0 -115
- package/src/services/FilterRepoService.ts +0 -164
- package/src/services/FormatterService.test.ts +0 -432
- package/src/services/FormatterService.ts +0 -219
- package/src/services/GitService.ts +0 -1187
- package/src/services/MockFilterRepoService.ts +0 -140
- package/src/services/OpencodeService.ts +0 -263
- package/src/services/PromptService.ts +0 -183
- package/src/services/TemplateService.ts +0 -81
- package/src/types/native-exec.d.ts +0 -8
- package/src/utils/colors.ts +0 -178
- package/src/utils/git-path.ts +0 -6
- package/src/utils/glob.test.ts +0 -161
- package/src/utils/glob.ts +0 -78
- package/src/utils/paths.ts +0 -51
- package/src/utils/pr-branch.test.ts +0 -376
- package/src/utils/pr-branch.ts +0 -508
- package/src/utils/spinner.test.ts +0 -45
- package/src/utils/spinner.ts +0 -96
- package/src/utils/task-parser.test.ts +0 -172
- package/src/utils/task-parser.ts +0 -93
- package/templates/AGENCY.md +0 -22
- package/templates/AGENTS.md +0 -11
- package/templates/CLAUDE.md +0 -3
- package/templates/TASK.md +0 -5
- 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
|
+
})
|