@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.
- package/README.md +302 -113
- package/cli.ts +123 -415
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +258 -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 +172 -419
- package/src/commands/work.ts +47 -166
- 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/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 -518
- package/src/commands/push.ts +0 -434
- 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 -1183
- 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,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
|
+
})
|