@markjaquith/agency 3.2.13 → 3.2.15
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/package.json +7 -2
- package/src/workbase/schemas.ts +12 -0
- package/fixtures/protocol/orchestration-recipes.json +0 -53
- package/pi-extensions/agency.test.ts +0 -117
- package/src/check-commits.test.ts +0 -58
- package/src/cli-parser.test.ts +0 -945
- package/src/cli.test.ts +0 -1889
- package/src/commands/act.test.ts +0 -418
- package/src/commands/archive.test.ts +0 -236
- package/src/commands/context.test.ts +0 -665
- package/src/commands/description.test.ts +0 -103
- package/src/commands/doctor.test.ts +0 -185
- package/src/commands/epic.test.ts +0 -196
- package/src/commands/init.test.ts +0 -117
- package/src/commands/integration.test.ts +0 -165
- package/src/commands/pr.test.ts +0 -248
- package/src/commands/push.test.ts +0 -56
- package/src/commands/read-only.test.ts +0 -161
- package/src/commands/repo.test.ts +0 -199
- package/src/commands/restore.test.ts +0 -106
- package/src/commands/status.test.ts +0 -113
- package/src/commands/sync.test.ts +0 -200
- package/src/commands/task-phase.test.ts +0 -410
- package/src/commands/task.test.ts +0 -281
- package/src/commands/validate.test.ts +0 -186
- package/src/commands/work.test.ts +0 -1375
- package/src/commands/workbase.test.ts +0 -170
- package/src/graph-schema.test.ts +0 -124
- package/src/protocol.test.ts +0 -163
- package/src/readiness.test.ts +0 -105
- package/src/services/ArchiveBulkService.test.ts +0 -384
- package/src/services/ArchiveService.test.ts +0 -1092
- package/src/services/EpicService.test.ts +0 -74
- package/src/services/FileSystemService.test.ts +0 -81
- package/src/services/GraphMutationService.test.ts +0 -486
- package/src/services/GraphService.test.ts +0 -494
- package/src/services/IntegrationService.test.ts +0 -1091
- package/src/services/LifecycleTransaction.test.ts +0 -145
- package/src/services/PullRequestService.test.ts +0 -716
- package/src/services/PushService.test.ts +0 -408
- package/src/services/ReadinessService.test.ts +0 -290
- package/src/services/RepositoryService.test.ts +0 -789
- package/src/services/ReviewService.test.ts +0 -408
- package/src/services/SyncService.test.ts +0 -1377
- package/src/services/TaskPhaseService.test.ts +0 -852
- package/src/services/VersionControlService.test.ts +0 -62
- package/src/services/WorkbaseService.test.ts +0 -910
- package/src/services/WorktreeLock.test.ts +0 -205
- package/src/services/WorktreePerformance.test.ts +0 -71
- package/src/services/WorktreeService.test.ts +0 -2292
- package/src/test-utils.ts +0 -110
- package/src/usage-log.test.ts +0 -188
- package/src/utils/chooser.test.ts +0 -192
- package/src/utils/effect.test.ts +0 -30
- package/src/utils/interactive.pty.test.ts +0 -128
- package/src/utils/interactive.test.tsx +0 -860
- package/src/utils/process.test.ts +0 -132
- package/src/utils/progress.test.ts +0 -37
- package/src/work-view.test.ts +0 -151
- package/src/workbase/agent-command.test.ts +0 -128
- package/src/workbase/checkout-command.test.ts +0 -62
- package/src/workbase/delivery-command.test.ts +0 -180
- package/src/workbase/dependency-graph.test.ts +0 -50
- package/src/workbase/execution-contract.test.ts +0 -161
- package/src/workbase/frontmatter.test.ts +0 -67
- package/src/workbase/repository-reference.test.ts +0 -25
- package/src/workbase/schemas.test.ts +0 -543
- package/src/workbase/work-target.test.ts +0 -108
- package/src/workbase/worktree-command.test.ts +0 -61
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { Effect } from "effect"
|
|
3
|
-
import { chmod, mkdir } from "node:fs/promises"
|
|
4
|
-
import { join, resolve } from "node:path"
|
|
5
|
-
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
-
import { errorEnvelope } from "../protocol"
|
|
7
|
-
import { PushService } from "./PushService"
|
|
8
|
-
import { TaskService } from "./TaskService"
|
|
9
|
-
import { WorktreeService } from "./WorktreeService"
|
|
10
|
-
import { parseGitCommits } from "./push-validation"
|
|
11
|
-
|
|
12
|
-
interface CommandResult {
|
|
13
|
-
readonly stdout: string
|
|
14
|
-
readonly stderr: string
|
|
15
|
-
readonly exitCode: number
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const runCommand = async (
|
|
19
|
-
args: readonly string[],
|
|
20
|
-
cwd?: string,
|
|
21
|
-
): Promise<CommandResult> => {
|
|
22
|
-
const child = Bun.spawn([...args], {
|
|
23
|
-
cwd,
|
|
24
|
-
stdout: "pipe",
|
|
25
|
-
stderr: "pipe",
|
|
26
|
-
})
|
|
27
|
-
const [stdout, stderr, exitCode] = await Promise.all([
|
|
28
|
-
new Response(child.stdout).text(),
|
|
29
|
-
new Response(child.stderr).text(),
|
|
30
|
-
child.exited,
|
|
31
|
-
])
|
|
32
|
-
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const requireCommand = async (args: readonly string[], cwd?: string) => {
|
|
36
|
-
const result = await runCommand(args, cwd)
|
|
37
|
-
if (result.exitCode !== 0) {
|
|
38
|
-
throw new Error(`${args.join(" ")} failed: ${result.stderr}`)
|
|
39
|
-
}
|
|
40
|
-
return result
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe("PushService", () => {
|
|
44
|
-
test("parses batched Git commit metadata", () => {
|
|
45
|
-
const output = [
|
|
46
|
-
"abc123\0Agency Test\0agency@example.com\0First change\0\x1e",
|
|
47
|
-
"def456\0Agency Test\0agency@example.com\0Second change\0\x1e",
|
|
48
|
-
].join("\n")
|
|
49
|
-
const commits = parseGitCommits(output)
|
|
50
|
-
|
|
51
|
-
expect(
|
|
52
|
-
commits.map(({ commitId, description }) => ({ commitId, description })),
|
|
53
|
-
).toEqual([
|
|
54
|
-
{ commitId: "abc123", description: "First change" },
|
|
55
|
-
{ commitId: "def456", description: "Second change" },
|
|
56
|
-
])
|
|
57
|
-
expect(commits.every((commit) => commit.authorEmail.includes("@"))).toBe(
|
|
58
|
-
true,
|
|
59
|
-
)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
const roots: string[] = []
|
|
63
|
-
|
|
64
|
-
afterEach(async () => {
|
|
65
|
-
await Promise.all(roots.splice(0).map(cleanupTempDir))
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
const setup = async () => {
|
|
69
|
-
const root = await createTempDir()
|
|
70
|
-
roots.push(root)
|
|
71
|
-
const remote = join(root, "remote.git")
|
|
72
|
-
const seed = join(root, "seed")
|
|
73
|
-
const repository = join(root, "repos", "agency")
|
|
74
|
-
await mkdir(join(root, "repos"), { recursive: true })
|
|
75
|
-
await Bun.write(join(root, "agency.json"), '{"version":2,"vcs":"git"}\n')
|
|
76
|
-
await requireCommand([
|
|
77
|
-
"git",
|
|
78
|
-
"init",
|
|
79
|
-
"--bare",
|
|
80
|
-
"--initial-branch=main",
|
|
81
|
-
remote,
|
|
82
|
-
])
|
|
83
|
-
await requireCommand(["git", "init", "--initial-branch=main", seed])
|
|
84
|
-
await requireCommand(["git", "config", "user.name", "Agency Test"], seed)
|
|
85
|
-
await requireCommand(
|
|
86
|
-
["git", "config", "user.email", "agency@example.com"],
|
|
87
|
-
seed,
|
|
88
|
-
)
|
|
89
|
-
await Bun.write(join(seed, "README.md"), "# Test repository\n")
|
|
90
|
-
await requireCommand(["git", "add", "README.md"], seed)
|
|
91
|
-
await requireCommand(["git", "commit", "-m", "Initial commit"], seed)
|
|
92
|
-
await requireCommand(["git", "remote", "add", "origin", remote], seed)
|
|
93
|
-
await requireCommand(["git", "push", "-u", "origin", "main"], seed)
|
|
94
|
-
await requireCommand(["git", "clone", "--bare", remote, repository])
|
|
95
|
-
|
|
96
|
-
await runTestEffect(
|
|
97
|
-
TaskService.pipe(
|
|
98
|
-
Effect.flatMap((service) =>
|
|
99
|
-
service.create(
|
|
100
|
-
{
|
|
101
|
-
id: "example",
|
|
102
|
-
ticketUrl: null,
|
|
103
|
-
repo: "agency",
|
|
104
|
-
branch: "task/example",
|
|
105
|
-
base: "main",
|
|
106
|
-
},
|
|
107
|
-
root,
|
|
108
|
-
),
|
|
109
|
-
),
|
|
110
|
-
),
|
|
111
|
-
)
|
|
112
|
-
await runTestEffect(
|
|
113
|
-
TaskService.pipe(
|
|
114
|
-
Effect.flatMap((service) =>
|
|
115
|
-
service.setStatus("example", "working", root),
|
|
116
|
-
),
|
|
117
|
-
),
|
|
118
|
-
)
|
|
119
|
-
const workspace = await runTestEffect(
|
|
120
|
-
WorktreeService.pipe(
|
|
121
|
-
Effect.flatMap((service) =>
|
|
122
|
-
service.materialize("example", undefined, root),
|
|
123
|
-
),
|
|
124
|
-
),
|
|
125
|
-
)
|
|
126
|
-
return {
|
|
127
|
-
root,
|
|
128
|
-
remote,
|
|
129
|
-
checkout: workspace.writablePath!,
|
|
130
|
-
taskPath: join(root, "tasks", "example"),
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const publish = (
|
|
135
|
-
taskPath: string,
|
|
136
|
-
options: {
|
|
137
|
-
fetchTimeoutMs?: number
|
|
138
|
-
pushTimeoutMs?: number
|
|
139
|
-
retryDelayMs?: number
|
|
140
|
-
} = {},
|
|
141
|
-
) =>
|
|
142
|
-
runTestEffect(
|
|
143
|
-
PushService.pipe(
|
|
144
|
-
Effect.flatMap((service) => service.publish(taskPath, options)),
|
|
145
|
-
),
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
const remoteBranch = async (remote: string) =>
|
|
149
|
-
(
|
|
150
|
-
await requireCommand([
|
|
151
|
-
"git",
|
|
152
|
-
"--git-dir",
|
|
153
|
-
remote,
|
|
154
|
-
"rev-parse",
|
|
155
|
-
"refs/heads/task/example",
|
|
156
|
-
])
|
|
157
|
-
).stdout
|
|
158
|
-
|
|
159
|
-
const configureAuthor = async (checkout: string) => {
|
|
160
|
-
await requireCommand(
|
|
161
|
-
["git", "config", "user.name", "Agency Test"],
|
|
162
|
-
checkout,
|
|
163
|
-
)
|
|
164
|
-
await requireCommand(
|
|
165
|
-
["git", "config", "user.email", "agency@example.com"],
|
|
166
|
-
checkout,
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const prePushHook = async (checkout: string) =>
|
|
171
|
-
resolve(
|
|
172
|
-
checkout,
|
|
173
|
-
(
|
|
174
|
-
await requireCommand(
|
|
175
|
-
["git", "rev-parse", "--git-path", "hooks/pre-push"],
|
|
176
|
-
checkout,
|
|
177
|
-
)
|
|
178
|
-
).stdout,
|
|
179
|
-
)
|
|
180
|
-
|
|
181
|
-
test("publishes a clean Git HEAD and establishes upstream tracking", async () => {
|
|
182
|
-
const fixture = await setup()
|
|
183
|
-
await configureAuthor(fixture.checkout)
|
|
184
|
-
await Bun.write(join(fixture.checkout, "feature.txt"), "published\n")
|
|
185
|
-
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
186
|
-
await requireCommand(
|
|
187
|
-
["git", "commit", "-m", "Add published feature"],
|
|
188
|
-
fixture.checkout,
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
const result = await publish(fixture.taskPath)
|
|
192
|
-
expect(result).toMatchObject({
|
|
193
|
-
vcs: "git",
|
|
194
|
-
branch: "task/example",
|
|
195
|
-
base: "main",
|
|
196
|
-
remote: "origin",
|
|
197
|
-
})
|
|
198
|
-
expect(await remoteBranch(fixture.remote)).toBe(result.tip)
|
|
199
|
-
expect(
|
|
200
|
-
(
|
|
201
|
-
await requireCommand(
|
|
202
|
-
["git", "rev-parse", "--abbrev-ref", "@{upstream}"],
|
|
203
|
-
fixture.checkout,
|
|
204
|
-
)
|
|
205
|
-
).stdout,
|
|
206
|
-
).toBe("origin/task/example")
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
test("rejects dirty, mismatched, and undescribed Git publication", async () => {
|
|
210
|
-
const dirty = await setup()
|
|
211
|
-
await Bun.write(join(dirty.checkout, "dirty.txt"), "dirty\n")
|
|
212
|
-
await expect(publish(dirty.taskPath)).rejects.toThrow("dirty Git worktree")
|
|
213
|
-
|
|
214
|
-
const mismatched = await setup()
|
|
215
|
-
await requireCommand(
|
|
216
|
-
["git", "branch", "-m", "wrong-branch"],
|
|
217
|
-
mismatched.checkout,
|
|
218
|
-
)
|
|
219
|
-
await expect(publish(mismatched.taskPath)).rejects.toThrow(
|
|
220
|
-
"does not match checked-out Git branch",
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
const undescribed = await setup()
|
|
224
|
-
await configureAuthor(undescribed.checkout)
|
|
225
|
-
await requireCommand(
|
|
226
|
-
["git", "commit", "--allow-empty", "--allow-empty-message", "-m", ""],
|
|
227
|
-
undescribed.checkout,
|
|
228
|
-
)
|
|
229
|
-
await expect(publish(undescribed.taskPath)).rejects.toThrow(
|
|
230
|
-
"has an empty message",
|
|
231
|
-
)
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
test("rejects Git remote divergence after refreshing remote state", async () => {
|
|
235
|
-
const fixture = await setup()
|
|
236
|
-
await configureAuthor(fixture.checkout)
|
|
237
|
-
await Bun.write(join(fixture.checkout, "feature.txt"), "first\n")
|
|
238
|
-
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
239
|
-
await requireCommand(
|
|
240
|
-
["git", "commit", "-m", "First change"],
|
|
241
|
-
fixture.checkout,
|
|
242
|
-
)
|
|
243
|
-
await publish(fixture.taskPath)
|
|
244
|
-
|
|
245
|
-
const other = join(fixture.root, "other")
|
|
246
|
-
await requireCommand(["git", "clone", fixture.remote, other])
|
|
247
|
-
await requireCommand(["git", "checkout", "task/example"], other)
|
|
248
|
-
await requireCommand(["git", "config", "user.name", "Other"], other)
|
|
249
|
-
await requireCommand(
|
|
250
|
-
["git", "config", "user.email", "other@example.com"],
|
|
251
|
-
other,
|
|
252
|
-
)
|
|
253
|
-
await Bun.write(join(other, "remote.txt"), "remote\n")
|
|
254
|
-
await requireCommand(["git", "add", "remote.txt"], other)
|
|
255
|
-
await requireCommand(["git", "commit", "-m", "Remote change"], other)
|
|
256
|
-
await requireCommand(["git", "push", "origin", "task/example"], other)
|
|
257
|
-
|
|
258
|
-
await Bun.write(join(fixture.checkout, "local.txt"), "local\n")
|
|
259
|
-
await requireCommand(["git", "add", "local.txt"], fixture.checkout)
|
|
260
|
-
await requireCommand(
|
|
261
|
-
["git", "commit", "-m", "Local change"],
|
|
262
|
-
fixture.checkout,
|
|
263
|
-
)
|
|
264
|
-
await expect(publish(fixture.taskPath)).rejects.toThrow(
|
|
265
|
-
"refusing a non-fast-forward update",
|
|
266
|
-
)
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
test("requires working status and the declared Git base in history", async () => {
|
|
270
|
-
const open = await setup()
|
|
271
|
-
await runTestEffect(
|
|
272
|
-
TaskService.pipe(
|
|
273
|
-
Effect.flatMap((service) =>
|
|
274
|
-
service.setStatus("example", "open", open.root),
|
|
275
|
-
),
|
|
276
|
-
),
|
|
277
|
-
)
|
|
278
|
-
await expect(publish(open.taskPath)).rejects.toThrow(
|
|
279
|
-
"status 'open'; status must be working",
|
|
280
|
-
)
|
|
281
|
-
|
|
282
|
-
const unrelated = await setup()
|
|
283
|
-
await configureAuthor(unrelated.checkout)
|
|
284
|
-
await requireCommand(
|
|
285
|
-
["git", "checkout", "--orphan", "unrelated"],
|
|
286
|
-
unrelated.checkout,
|
|
287
|
-
)
|
|
288
|
-
await requireCommand(["git", "rm", "-rf", "."], unrelated.checkout)
|
|
289
|
-
await Bun.write(join(unrelated.checkout, "unrelated.txt"), "unrelated\n")
|
|
290
|
-
await requireCommand(["git", "add", "unrelated.txt"], unrelated.checkout)
|
|
291
|
-
await requireCommand(
|
|
292
|
-
["git", "commit", "-m", "Unrelated history"],
|
|
293
|
-
unrelated.checkout,
|
|
294
|
-
)
|
|
295
|
-
await requireCommand(
|
|
296
|
-
["git", "branch", "-M", "task/example"],
|
|
297
|
-
unrelated.checkout,
|
|
298
|
-
)
|
|
299
|
-
await expect(publish(unrelated.taskPath)).rejects.toThrow(
|
|
300
|
-
"is not an ancestor of Git HEAD",
|
|
301
|
-
)
|
|
302
|
-
})
|
|
303
|
-
|
|
304
|
-
test("rejects invalid Git authors", async () => {
|
|
305
|
-
const fixture = await setup()
|
|
306
|
-
await configureAuthor(fixture.checkout)
|
|
307
|
-
await requireCommand(
|
|
308
|
-
[
|
|
309
|
-
"git",
|
|
310
|
-
"commit",
|
|
311
|
-
"--allow-empty",
|
|
312
|
-
"--author",
|
|
313
|
-
"Bad <bad>",
|
|
314
|
-
"-m",
|
|
315
|
-
"Invalid author",
|
|
316
|
-
],
|
|
317
|
-
fixture.checkout,
|
|
318
|
-
)
|
|
319
|
-
await expect(publish(fixture.taskPath)).rejects.toThrow(
|
|
320
|
-
"has an invalid author",
|
|
321
|
-
)
|
|
322
|
-
})
|
|
323
|
-
|
|
324
|
-
test("bounds a stalled pre-push hook and confirms non-publication", async () => {
|
|
325
|
-
const fixture = await setup()
|
|
326
|
-
await configureAuthor(fixture.checkout)
|
|
327
|
-
await Bun.write(join(fixture.checkout, "feature.txt"), "timeout\n")
|
|
328
|
-
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
329
|
-
await requireCommand(
|
|
330
|
-
["git", "commit", "-m", "Add timed publication"],
|
|
331
|
-
fixture.checkout,
|
|
332
|
-
)
|
|
333
|
-
const hook = await prePushHook(fixture.checkout)
|
|
334
|
-
await Bun.write(hook, "#!/bin/sh\nsleep 30\n")
|
|
335
|
-
await chmod(hook, 0o755)
|
|
336
|
-
|
|
337
|
-
const startedAt = performance.now()
|
|
338
|
-
const failure = await publish(fixture.taskPath, {
|
|
339
|
-
pushTimeoutMs: 25,
|
|
340
|
-
}).catch((error) => error)
|
|
341
|
-
expect(performance.now() - startedAt).toBeLessThan(1_000)
|
|
342
|
-
expect(errorEnvelope(failure).error).toMatchObject({
|
|
343
|
-
code: "PUSH_TIMEOUT",
|
|
344
|
-
fields: { category: "timeout", stage: "publish" },
|
|
345
|
-
retryable: true,
|
|
346
|
-
})
|
|
347
|
-
await expect(remoteBranch(fixture.remote)).rejects.toThrow()
|
|
348
|
-
})
|
|
349
|
-
|
|
350
|
-
test("classifies hook rejection separately from transport failure", async () => {
|
|
351
|
-
const fixture = await setup()
|
|
352
|
-
await configureAuthor(fixture.checkout)
|
|
353
|
-
await Bun.write(join(fixture.checkout, "feature.txt"), "rejected\n")
|
|
354
|
-
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
355
|
-
await requireCommand(
|
|
356
|
-
["git", "commit", "-m", "Add rejected publication"],
|
|
357
|
-
fixture.checkout,
|
|
358
|
-
)
|
|
359
|
-
const hook = await prePushHook(fixture.checkout)
|
|
360
|
-
await Bun.write(
|
|
361
|
-
hook,
|
|
362
|
-
"#!/bin/sh\necho 'pre-push hook declined' >&2\nexit 1\n",
|
|
363
|
-
)
|
|
364
|
-
await chmod(hook, 0o755)
|
|
365
|
-
|
|
366
|
-
const failure = await publish(fixture.taskPath).catch((error) => error)
|
|
367
|
-
expect(errorEnvelope(failure).error).toMatchObject({
|
|
368
|
-
code: "PUSH_HOOK_REJECTED",
|
|
369
|
-
fields: { category: "hook_rejection", stage: "publish" },
|
|
370
|
-
retryable: false,
|
|
371
|
-
})
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
test("retries one transient fetch with non-interactive authentication", async () => {
|
|
375
|
-
const fixture = await setup()
|
|
376
|
-
await configureAuthor(fixture.checkout)
|
|
377
|
-
await Bun.write(join(fixture.checkout, "feature.txt"), "retried\n")
|
|
378
|
-
await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
|
|
379
|
-
await requireCommand(
|
|
380
|
-
["git", "commit", "-m", "Add retried publication"],
|
|
381
|
-
fixture.checkout,
|
|
382
|
-
)
|
|
383
|
-
const attempts = join(fixture.root, "upload-pack-attempts")
|
|
384
|
-
const uploadPack = join(fixture.root, "upload-pack")
|
|
385
|
-
await Bun.write(
|
|
386
|
-
uploadPack,
|
|
387
|
-
`#!/bin/sh
|
|
388
|
-
echo x >> ${JSON.stringify(attempts)}
|
|
389
|
-
test "$GIT_TERMINAL_PROMPT" = 0 || exit 2
|
|
390
|
-
test "$GCM_INTERACTIVE" = Never || exit 2
|
|
391
|
-
if test "$(wc -l < ${JSON.stringify(attempts)})" -eq 1; then
|
|
392
|
-
echo 'Connection reset by peer' >&2
|
|
393
|
-
exit 1
|
|
394
|
-
fi
|
|
395
|
-
exec git-upload-pack "$@"
|
|
396
|
-
`,
|
|
397
|
-
)
|
|
398
|
-
await chmod(uploadPack, 0o755)
|
|
399
|
-
await requireCommand(
|
|
400
|
-
["git", "config", "remote.origin.uploadpack", uploadPack],
|
|
401
|
-
fixture.checkout,
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
const result = await publish(fixture.taskPath, { retryDelayMs: 0 })
|
|
405
|
-
expect(result.tip).toBe(await remoteBranch(fixture.remote))
|
|
406
|
-
expect((await Bun.file(attempts).text()).trim().split("\n")).toHaveLength(2)
|
|
407
|
-
})
|
|
408
|
-
})
|
|
@@ -1,290 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
-
import { Effect } from "effect"
|
|
3
|
-
import { mkdir } from "node:fs/promises"
|
|
4
|
-
import { dirname, join } from "node:path"
|
|
5
|
-
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
-
import { ReadinessService } from "./ReadinessService"
|
|
7
|
-
|
|
8
|
-
const write = async (root: string, path: string, content: string) => {
|
|
9
|
-
const fullPath = join(root, path)
|
|
10
|
-
await mkdir(dirname(fullPath), { recursive: true })
|
|
11
|
-
await Bun.write(fullPath, content)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const execution = (status: string, branch: string) => `---
|
|
15
|
-
repo: agency
|
|
16
|
-
branch: ${branch}
|
|
17
|
-
base: main
|
|
18
|
-
pr: null
|
|
19
|
-
status: ${status}
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
# Execution
|
|
23
|
-
`
|
|
24
|
-
|
|
25
|
-
const createWorkbase = async () => {
|
|
26
|
-
const root = await createTempDir()
|
|
27
|
-
await write(root, "agency.json", '{"version":2}\n')
|
|
28
|
-
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
29
|
-
await write(
|
|
30
|
-
root,
|
|
31
|
-
"epics/delivery/EPIC.md",
|
|
32
|
-
`---
|
|
33
|
-
ticketUrl: https://example.com/delivery
|
|
34
|
-
repos:
|
|
35
|
-
- repo: agency
|
|
36
|
-
ref: main
|
|
37
|
-
tasks:
|
|
38
|
-
- id: prepare
|
|
39
|
-
- id: ship
|
|
40
|
-
dependsOn: [prepare]
|
|
41
|
-
- id: deploy
|
|
42
|
-
dependsOn: [ship]
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
# Delivery
|
|
46
|
-
`,
|
|
47
|
-
)
|
|
48
|
-
await write(
|
|
49
|
-
root,
|
|
50
|
-
"tasks/prepare/TASK.md",
|
|
51
|
-
`---
|
|
52
|
-
ticketUrl: null
|
|
53
|
-
epic: delivery
|
|
54
|
-
repo: agency
|
|
55
|
-
branch: feat/prepare
|
|
56
|
-
base: main
|
|
57
|
-
pr: null
|
|
58
|
-
status: done
|
|
59
|
-
---
|
|
60
|
-
|
|
61
|
-
# Prepare
|
|
62
|
-
`,
|
|
63
|
-
)
|
|
64
|
-
await write(
|
|
65
|
-
root,
|
|
66
|
-
"tasks/ship/TASK.md",
|
|
67
|
-
`---
|
|
68
|
-
ticketUrl: null
|
|
69
|
-
description: Ship the feature.
|
|
70
|
-
epic: delivery
|
|
71
|
-
phases:
|
|
72
|
-
- id: implement
|
|
73
|
-
- id: verify
|
|
74
|
-
dependsOn: [implement]
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
# Ship
|
|
78
|
-
`,
|
|
79
|
-
)
|
|
80
|
-
await write(
|
|
81
|
-
root,
|
|
82
|
-
"tasks/ship/phases/implement/PHASE.md",
|
|
83
|
-
execution("open", "feat/implement"),
|
|
84
|
-
)
|
|
85
|
-
await write(
|
|
86
|
-
root,
|
|
87
|
-
"tasks/ship/phases/verify/PHASE.md",
|
|
88
|
-
execution("open", "feat/verify"),
|
|
89
|
-
)
|
|
90
|
-
await write(
|
|
91
|
-
root,
|
|
92
|
-
"tasks/abandoned/TASK.md",
|
|
93
|
-
`---
|
|
94
|
-
ticketUrl: null
|
|
95
|
-
repo: agency
|
|
96
|
-
branch: feat/abandoned
|
|
97
|
-
base: main
|
|
98
|
-
pr: null
|
|
99
|
-
status: dropped
|
|
100
|
-
---
|
|
101
|
-
|
|
102
|
-
# Abandoned
|
|
103
|
-
`,
|
|
104
|
-
)
|
|
105
|
-
await write(
|
|
106
|
-
root,
|
|
107
|
-
"tasks/deploy/TASK.md",
|
|
108
|
-
`---
|
|
109
|
-
ticketUrl: null
|
|
110
|
-
epic: delivery
|
|
111
|
-
repo: agency
|
|
112
|
-
branch: feat/deploy
|
|
113
|
-
base: main
|
|
114
|
-
pr: null
|
|
115
|
-
status: open
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
# Deploy
|
|
119
|
-
`,
|
|
120
|
-
)
|
|
121
|
-
return root
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const service = <A>(
|
|
125
|
-
run: (readiness: ReadinessService) => Effect.Effect<A, unknown, any>,
|
|
126
|
-
) => runTestEffect(ReadinessService.pipe(Effect.flatMap(run)))
|
|
127
|
-
|
|
128
|
-
describe("ReadinessService", () => {
|
|
129
|
-
const roots: string[] = []
|
|
130
|
-
|
|
131
|
-
afterEach(async () => {
|
|
132
|
-
await Promise.all(roots.splice(0).map(cleanupTempDir))
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
test("ranks ready work and explains every excluded execution unit", async () => {
|
|
136
|
-
const root = await createWorkbase()
|
|
137
|
-
roots.push(root)
|
|
138
|
-
|
|
139
|
-
const result = await service((readiness) => readiness.getNext(root, true))
|
|
140
|
-
|
|
141
|
-
expect(result.ready.map((item) => item.key)).toEqual([
|
|
142
|
-
"phase/ship/implement",
|
|
143
|
-
])
|
|
144
|
-
expect(result.selected).toMatchObject({
|
|
145
|
-
key: "phase/ship/implement",
|
|
146
|
-
parent: { epicId: "delivery", taskId: "ship" },
|
|
147
|
-
priority: { dependentCount: 1 },
|
|
148
|
-
})
|
|
149
|
-
expect(result.excluded.map((item) => item.key)).toEqual([
|
|
150
|
-
"task/prepare",
|
|
151
|
-
"phase/ship/verify",
|
|
152
|
-
"task/abandoned",
|
|
153
|
-
"task/deploy",
|
|
154
|
-
])
|
|
155
|
-
expect(
|
|
156
|
-
result.excluded.find((item) => item.key === "phase/ship/verify"),
|
|
157
|
-
).toMatchObject({
|
|
158
|
-
ready: false,
|
|
159
|
-
terminal: false,
|
|
160
|
-
blockedBy: ["phase:ship/implement"],
|
|
161
|
-
blockers: [
|
|
162
|
-
{
|
|
163
|
-
kind: "dependency",
|
|
164
|
-
status: "open",
|
|
165
|
-
reason: "Phase dependency is open",
|
|
166
|
-
},
|
|
167
|
-
],
|
|
168
|
-
})
|
|
169
|
-
expect(
|
|
170
|
-
result.excluded.find((item) => item.key === "task/abandoned"),
|
|
171
|
-
).toMatchObject({ status: "dropped", terminal: true })
|
|
172
|
-
})
|
|
173
|
-
|
|
174
|
-
test("includes cross-task unlocks in final-phase priority", async () => {
|
|
175
|
-
const root = await createWorkbase()
|
|
176
|
-
roots.push(root)
|
|
177
|
-
await Bun.write(
|
|
178
|
-
join(root, "tasks/ship/phases/implement/PHASE.md"),
|
|
179
|
-
execution("done", "feat/implement"),
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
const result = await service((readiness) => readiness.getNext(root, true))
|
|
183
|
-
|
|
184
|
-
expect(result.selected).toMatchObject({
|
|
185
|
-
key: "phase/ship/verify",
|
|
186
|
-
priority: { dependentCount: 1 },
|
|
187
|
-
})
|
|
188
|
-
})
|
|
189
|
-
|
|
190
|
-
test("returns ready target IDs and guards structural work targets", async () => {
|
|
191
|
-
const root = await createWorkbase()
|
|
192
|
-
roots.push(root)
|
|
193
|
-
|
|
194
|
-
const readyIds = await service((readiness) =>
|
|
195
|
-
readiness.getReadyWorkTargetIds(root),
|
|
196
|
-
)
|
|
197
|
-
expect([...readyIds].sort()).toEqual([
|
|
198
|
-
"epic:delivery",
|
|
199
|
-
"execution-unit:phase/ship/implement",
|
|
200
|
-
"phase:ship/implement",
|
|
201
|
-
"task:ship",
|
|
202
|
-
])
|
|
203
|
-
await service((readiness) =>
|
|
204
|
-
readiness.guardWorkTarget("phase:ship/implement", root),
|
|
205
|
-
)
|
|
206
|
-
expect(
|
|
207
|
-
[
|
|
208
|
-
...(await service((readiness) => readiness.getWorkTargetIds(root))),
|
|
209
|
-
].sort(),
|
|
210
|
-
).toEqual([...readyIds].sort())
|
|
211
|
-
|
|
212
|
-
await write(
|
|
213
|
-
root,
|
|
214
|
-
"tasks/ship/phases/implement/PHASE.md",
|
|
215
|
-
execution("working", "feat/implement"),
|
|
216
|
-
)
|
|
217
|
-
const resumableIds = await service((readiness) =>
|
|
218
|
-
readiness.getWorkTargetIds(root),
|
|
219
|
-
)
|
|
220
|
-
expect(resumableIds).toContain("epic:delivery")
|
|
221
|
-
expect(resumableIds).toContain("task:ship")
|
|
222
|
-
expect(resumableIds).toContain("execution-unit:phase/ship/implement")
|
|
223
|
-
await service((readiness) =>
|
|
224
|
-
readiness.guardWorkTarget("execution-unit:phase/ship/implement", root),
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
const blocked = await service((readiness) =>
|
|
228
|
-
Effect.either(readiness.guardWorkTarget("phase:ship/verify", root)),
|
|
229
|
-
)
|
|
230
|
-
expect(blocked).toMatchObject({
|
|
231
|
-
_tag: "Left",
|
|
232
|
-
left: {
|
|
233
|
-
_tag: "ExecutionGuardError",
|
|
234
|
-
action: "work",
|
|
235
|
-
target: "phase:ship/verify",
|
|
236
|
-
status: "open",
|
|
237
|
-
blockedBy: ["phase:ship/implement"],
|
|
238
|
-
},
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
const missing = await service((readiness) =>
|
|
242
|
-
Effect.either(readiness.guardWorkTarget("task:missing", root)),
|
|
243
|
-
)
|
|
244
|
-
expect(missing).toMatchObject({
|
|
245
|
-
_tag: "Left",
|
|
246
|
-
left: {
|
|
247
|
-
_tag: "ExecutionGuardError",
|
|
248
|
-
target: "task:missing",
|
|
249
|
-
blockers: [],
|
|
250
|
-
},
|
|
251
|
-
})
|
|
252
|
-
await service((readiness) =>
|
|
253
|
-
readiness.guardWorkTarget("task:missing", root, true),
|
|
254
|
-
)
|
|
255
|
-
})
|
|
256
|
-
|
|
257
|
-
test("uses the same readiness for work and PR guards", async () => {
|
|
258
|
-
const root = await createWorkbase()
|
|
259
|
-
roots.push(root)
|
|
260
|
-
|
|
261
|
-
await service((readiness) =>
|
|
262
|
-
readiness.guard("work", "ship", "implement", root),
|
|
263
|
-
)
|
|
264
|
-
await expect(
|
|
265
|
-
service((readiness) => readiness.guard("work", "ship", "verify", root)),
|
|
266
|
-
).rejects.toThrow("Phase dependency is open")
|
|
267
|
-
await expect(
|
|
268
|
-
service((readiness) => readiness.guard("pr", "ship", "verify", root)),
|
|
269
|
-
).rejects.toThrow("Phase dependency is open")
|
|
270
|
-
await service((readiness) =>
|
|
271
|
-
readiness.guard("work", "ship", "verify", root, true),
|
|
272
|
-
)
|
|
273
|
-
})
|
|
274
|
-
|
|
275
|
-
test("allows active PR targets but rejects terminal outcomes", async () => {
|
|
276
|
-
const root = await createWorkbase()
|
|
277
|
-
roots.push(root)
|
|
278
|
-
const path = join(root, "tasks/ship/phases/implement/PHASE.md")
|
|
279
|
-
await Bun.write(path, execution("working", "feat/implement"))
|
|
280
|
-
|
|
281
|
-
await service((readiness) =>
|
|
282
|
-
readiness.guard("pr", "ship", "implement", root),
|
|
283
|
-
)
|
|
284
|
-
|
|
285
|
-
await Bun.write(path, execution("done", "feat/implement"))
|
|
286
|
-
await expect(
|
|
287
|
-
service((readiness) => readiness.guard("pr", "ship", "implement", root)),
|
|
288
|
-
).rejects.toThrow("Phase status is done")
|
|
289
|
-
})
|
|
290
|
-
})
|