@markjaquith/agency 3.2.14 → 3.3.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 (75) hide show
  1. package/README.md +20 -3
  2. package/cli-main.ts +1 -0
  3. package/package.json +7 -2
  4. package/src/cli-parser.ts +13 -0
  5. package/src/commands/work.ts +70 -2
  6. package/src/services/ReadinessService.ts +20 -0
  7. package/src/workbase/execution-contract.ts +10 -1
  8. package/src/workbase/schemas.ts +12 -0
  9. package/fixtures/protocol/orchestration-recipes.json +0 -53
  10. package/pi-extensions/agency.test.ts +0 -117
  11. package/src/check-commits.test.ts +0 -58
  12. package/src/cli-parser.test.ts +0 -945
  13. package/src/cli.test.ts +0 -1889
  14. package/src/commands/act.test.ts +0 -418
  15. package/src/commands/archive.test.ts +0 -236
  16. package/src/commands/context.test.ts +0 -711
  17. package/src/commands/description.test.ts +0 -103
  18. package/src/commands/doctor.test.ts +0 -185
  19. package/src/commands/epic.test.ts +0 -196
  20. package/src/commands/init.test.ts +0 -117
  21. package/src/commands/integration.test.ts +0 -165
  22. package/src/commands/pr.test.ts +0 -248
  23. package/src/commands/push.test.ts +0 -56
  24. package/src/commands/read-only.test.ts +0 -161
  25. package/src/commands/repo.test.ts +0 -199
  26. package/src/commands/restore.test.ts +0 -106
  27. package/src/commands/status.test.ts +0 -113
  28. package/src/commands/sync.test.ts +0 -200
  29. package/src/commands/task-phase.test.ts +0 -410
  30. package/src/commands/task.test.ts +0 -281
  31. package/src/commands/validate.test.ts +0 -186
  32. package/src/commands/work.test.ts +0 -1375
  33. package/src/commands/workbase.test.ts +0 -170
  34. package/src/graph-schema.test.ts +0 -124
  35. package/src/protocol.test.ts +0 -163
  36. package/src/readiness.test.ts +0 -105
  37. package/src/services/ArchiveBulkService.test.ts +0 -384
  38. package/src/services/ArchiveService.test.ts +0 -1092
  39. package/src/services/EpicService.test.ts +0 -74
  40. package/src/services/FileSystemService.test.ts +0 -81
  41. package/src/services/GraphMutationService.test.ts +0 -486
  42. package/src/services/GraphService.test.ts +0 -494
  43. package/src/services/IntegrationService.test.ts +0 -1091
  44. package/src/services/LifecycleTransaction.test.ts +0 -145
  45. package/src/services/PullRequestService.test.ts +0 -716
  46. package/src/services/PushService.test.ts +0 -408
  47. package/src/services/ReadinessService.test.ts +0 -290
  48. package/src/services/RepositoryService.test.ts +0 -789
  49. package/src/services/ReviewService.test.ts +0 -408
  50. package/src/services/SyncService.test.ts +0 -1377
  51. package/src/services/TaskPhaseService.test.ts +0 -852
  52. package/src/services/VersionControlService.test.ts +0 -62
  53. package/src/services/WorkbaseService.test.ts +0 -910
  54. package/src/services/WorktreeLock.test.ts +0 -205
  55. package/src/services/WorktreePerformance.test.ts +0 -71
  56. package/src/services/WorktreeService.test.ts +0 -2292
  57. package/src/test-utils.ts +0 -110
  58. package/src/usage-log.test.ts +0 -188
  59. package/src/utils/chooser.test.ts +0 -192
  60. package/src/utils/effect.test.ts +0 -30
  61. package/src/utils/interactive.pty.test.ts +0 -128
  62. package/src/utils/interactive.test.tsx +0 -860
  63. package/src/utils/process.test.ts +0 -132
  64. package/src/utils/progress.test.ts +0 -37
  65. package/src/work-view.test.ts +0 -151
  66. package/src/workbase/agent-command.test.ts +0 -128
  67. package/src/workbase/checkout-command.test.ts +0 -62
  68. package/src/workbase/delivery-command.test.ts +0 -180
  69. package/src/workbase/dependency-graph.test.ts +0 -50
  70. package/src/workbase/execution-contract.test.ts +0 -161
  71. package/src/workbase/frontmatter.test.ts +0 -67
  72. package/src/workbase/repository-reference.test.ts +0 -25
  73. package/src/workbase/schemas.test.ts +0 -543
  74. package/src/workbase/work-target.test.ts +0 -108
  75. package/src/workbase/worktree-command.test.ts +0 -61
@@ -1,132 +0,0 @@
1
- import { describe, expect, spyOn, test } from "bun:test"
2
- import { mkdtemp, readFile, rm } from "node:fs/promises"
3
- import { tmpdir } from "node:os"
4
- import { join } from "node:path"
5
- import { Effect, Fiber } from "effect"
6
- import { spawnProcess } from "./process"
7
-
8
- const waitFor = async <A>(attempt: () => Promise<A>): Promise<A> => {
9
- const deadline = Date.now() + 2_000
10
- while (true) {
11
- try {
12
- return await attempt()
13
- } catch (error) {
14
- if (Date.now() >= deadline) throw error
15
- await Bun.sleep(10)
16
- }
17
- }
18
- }
19
-
20
- const isProcessRunning = (pid: number): boolean => {
21
- try {
22
- process.kill(pid, 0)
23
- return true
24
- } catch (error) {
25
- if (
26
- typeof error === "object" &&
27
- error !== null &&
28
- "code" in error &&
29
- error.code === "ESRCH"
30
- ) {
31
- return false
32
- }
33
- throw error
34
- }
35
- }
36
-
37
- describe("spawnProcess", () => {
38
- test("forwards and captures output in tee mode", async () => {
39
- const forwardedStdout: Uint8Array[] = []
40
- const forwardedStderr: Uint8Array[] = []
41
- const stdout = spyOn(process.stdout, "write").mockImplementation(((
42
- chunk: Uint8Array,
43
- ) => {
44
- forwardedStdout.push(chunk)
45
- return true
46
- }) as never)
47
- const stderr = spyOn(process.stderr, "write").mockImplementation(((
48
- chunk: Uint8Array,
49
- ) => {
50
- forwardedStderr.push(chunk)
51
- return true
52
- }) as never)
53
-
54
- try {
55
- const result = await Effect.runPromise(
56
- spawnProcess(
57
- ["sh", "-c", "printf 'standard output'; printf 'standard error' >&2"],
58
- { stdout: "tee", stderr: "tee" },
59
- ),
60
- )
61
-
62
- expect(result).toEqual({
63
- stdout: "standard output",
64
- stderr: "standard error",
65
- exitCode: 0,
66
- })
67
- expect(Buffer.concat(forwardedStdout).toString()).toBe("standard output")
68
- expect(Buffer.concat(forwardedStderr).toString()).toBe("standard error")
69
- } finally {
70
- stdout.mockRestore()
71
- stderr.mockRestore()
72
- }
73
- })
74
-
75
- test("captures large stdout and stderr without hanging", async () => {
76
- const line = "x".repeat(4096)
77
- const lineCount = 256
78
- const script = [
79
- `const line = ${JSON.stringify(line)}`,
80
- `const indexes = Array.from({ length: ${lineCount} }, (_, i) => i)`,
81
- "const stdout = indexes.map((i) => `out:${i}:${line}`).join(`\n`)",
82
- "const stderr = indexes.map((i) => `err:${i}:${line}`).join(`\n`)",
83
- "const write = (stream, output) => new Promise((resolve, reject) => {",
84
- "stream.write(output, (error) => error ? reject(error) : resolve())",
85
- "})",
86
- "await Promise.all([write(process.stdout, stdout), write(process.stderr, stderr)])",
87
- ].join("\n")
88
-
89
- const result = await Effect.runPromise(
90
- spawnProcess([process.execPath, "-e", script]),
91
- )
92
-
93
- expect(result.exitCode).toBe(0)
94
- expect(result.stdout).toContain("out:0:")
95
- expect(result.stdout).toContain(`out:${lineCount - 1}:`)
96
- expect(result.stderr).toContain("err:0:")
97
- expect(result.stderr).toContain(`err:${lineCount - 1}:`)
98
- })
99
-
100
- test("terminates timed-out process groups", async () => {
101
- const startedAt = performance.now()
102
- await expect(
103
- Effect.runPromise(
104
- spawnProcess(["sh", "-c", "sleep 30 & wait"], { timeoutMs: 25 }),
105
- ),
106
- ).rejects.toThrow("Process timed out")
107
- expect(performance.now() - startedAt).toBeLessThan(1_000)
108
- })
109
-
110
- test("terminates the subprocess when interrupted", async () => {
111
- const directory = await mkdtemp(join(tmpdir(), "agency-process-"))
112
- const pidPath = join(directory, "pid")
113
- const script = [
114
- `process.on("SIGTERM", () => {})`,
115
- `await Bun.write(${JSON.stringify(pidPath)}, String(process.pid))`,
116
- `setInterval(() => process.stdout.write("running\\n"), 10)`,
117
- ].join("\n")
118
- const fiber = Effect.runFork(spawnProcess([process.execPath, "-e", script]))
119
-
120
- try {
121
- const pid = Number(await waitFor(() => readFile(pidPath, "utf8")))
122
- expect(isProcessRunning(pid)).toBe(true)
123
-
124
- await Effect.runPromise(Fiber.interrupt(fiber))
125
-
126
- expect(isProcessRunning(pid)).toBe(false)
127
- } finally {
128
- await Effect.runPromise(Fiber.interrupt(fiber))
129
- await rm(directory, { recursive: true, force: true })
130
- }
131
- })
132
- })
@@ -1,37 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import { createProgress } from "./progress"
3
-
4
- describe("progress", () => {
5
- test("replaces an active TTY line with its completed state", () => {
6
- const output: string[] = []
7
- const progress = createProgress(
8
- {},
9
- { isTTY: true, write: (text) => output.push(text) },
10
- )
11
-
12
- progress.start("Preparing workspace...")
13
- progress.succeed("Workspace ready")
14
-
15
- expect(output).toEqual([
16
- "\r\x1b[2K\x1b[2m○\x1b[0m Preparing workspace...",
17
- "\r\x1b[2K\x1b[32m✓\x1b[0m Workspace ready\n",
18
- ])
19
- })
20
-
21
- test("stays quiet for silent or non-TTY output", () => {
22
- const output: string[] = []
23
- for (const [silent, isTTY] of [
24
- [true, true],
25
- [false, false],
26
- ] as const) {
27
- const progress = createProgress(
28
- { silent },
29
- { isTTY, write: (text) => output.push(text) },
30
- )
31
- progress.start("Preparing workspace...")
32
- progress.fail("Workspace preparation failed")
33
- }
34
-
35
- expect(output).toEqual([])
36
- })
37
- })
@@ -1,151 +0,0 @@
1
- import { afterEach, beforeEach, 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 { getWorkViews } from "./work-view"
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
- describe("work views", () => {
15
- let root: string
16
-
17
- beforeEach(async () => {
18
- root = await createTempDir()
19
- await write(root, "agency.json", '{"version":2}\n')
20
- await mkdir(join(root, "repos/agency"), { recursive: true })
21
- await write(
22
- root,
23
- "epics/delivery/EPIC.md",
24
- `---
25
- ticketUrl: https://example.com/delivery
26
- repos:
27
- - repo: agency
28
- ref: main
29
- tasks:
30
- - id: zeta
31
- - id: alpha
32
- ---
33
- `,
34
- )
35
- await write(
36
- root,
37
- "tasks/zeta/TASK.md",
38
- `---
39
- ticketUrl: null
40
- epic: delivery
41
- phases:
42
- - id: verify
43
- dependsOn: [implement]
44
- - id: implement
45
- ---
46
- `,
47
- )
48
- await write(
49
- root,
50
- "tasks/zeta/phases/verify/PHASE.md",
51
- `---
52
- repo: agency
53
- branch: feat/verify
54
- base: main
55
- pr: null
56
- status: open
57
- ---
58
- `,
59
- )
60
- await write(
61
- root,
62
- "tasks/zeta/phases/implement/PHASE.md",
63
- `---
64
- repo: agency
65
- branch: feat/implement
66
- base: main
67
- pr: https://github.com/example/agency/pull/1
68
- status: dropped
69
- ---
70
- `,
71
- )
72
- await mkdir(join(root, "tasks/zeta/phases/implement/code/agency"), {
73
- recursive: true,
74
- })
75
- await write(
76
- root,
77
- "tasks/alpha/TASK.md",
78
- `---
79
- ticketUrl: null
80
- epic: delivery
81
- repo: agency
82
- branch: feat/alpha
83
- base: main
84
- pr: null
85
- status: open
86
- ---
87
- `,
88
- )
89
- })
90
-
91
- afterEach(async () => cleanupTempDir(root))
92
-
93
- const views = (options: Record<string, unknown> = {}) =>
94
- runTestEffect(Effect.suspend(() => getWorkViews({ cwd: root, ...options })))
95
-
96
- test("uses declared graph order and exposes operational state", async () => {
97
- const result = await views()
98
-
99
- expect(result.taskRows.map((row) => row.id)).toEqual(["zeta", "alpha"])
100
- expect(result.phaseRows.map((row) => row.id)).toEqual([
101
- "verify",
102
- "implement",
103
- ])
104
- expect(result.executionRows.map((row) => row.key)).toEqual([
105
- "zeta/verify",
106
- "zeta/implement",
107
- "alpha",
108
- ])
109
- expect(result.phaseRows[0]).toMatchObject({
110
- parent: "zeta",
111
- status: "open",
112
- readiness: "blocked",
113
- repositories: "agency",
114
- branch: "feat/verify",
115
- pr: "absent",
116
- worktree: "absent",
117
- })
118
- expect(result.phaseRows[1]).toMatchObject({
119
- readiness: "terminal",
120
- pr: "present",
121
- worktree: "materialized",
122
- })
123
- expect(result.taskRows[0]).toMatchObject({
124
- parent: "delivery",
125
- branch: "multiple",
126
- pr: "1/2 present",
127
- worktree: "1/2 materialized",
128
- })
129
- })
130
-
131
- test("composes lifecycle, repository, readiness, and PR filters", async () => {
132
- expect(
133
- (await views({ statuses: ["dropped"] })).executionRows.map(
134
- (row) => row.key,
135
- ),
136
- ).toEqual(["zeta/implement"])
137
- expect(
138
- (await views({ repositories: ["agency"], blocked: true })).phaseRows.map(
139
- (row) => row.id,
140
- ),
141
- ).toEqual(["verify"])
142
- expect(
143
- (await views({ ready: true, pr: false })).executionRows.map(
144
- (row) => row.key,
145
- ),
146
- ).toEqual(["alpha"])
147
- expect((await views({ pr: true })).phaseRows.map((row) => row.id)).toEqual([
148
- "implement",
149
- ])
150
- })
151
- })
@@ -1,128 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import {
3
- printableEnvironment,
4
- resolveAgentCommand,
5
- agentEnvironment,
6
- validateAgents,
7
- } from "./agent-command"
8
-
9
- const variables = {
10
- prompt: "Read the task.",
11
- workbase: "/workbase",
12
- target: "execution-unit:phase/task/build",
13
- task: "task",
14
- phase: "build",
15
- sessionId: "session-1",
16
- }
17
-
18
- describe("agent commands", () => {
19
- test("uses promptless interactive commands for built-in presets", () => {
20
- expect(
21
- resolveAgentCommand("opencode2", undefined, variables, false).argv,
22
- ).toEqual(["opencode2"])
23
- expect(
24
- resolveAgentCommand("opencode2", undefined, variables, true).argv,
25
- ).toEqual(["opencode2", "--continue"])
26
- expect(
27
- resolveAgentCommand("opencode", undefined, variables, false).argv,
28
- ).toEqual(["opencode"])
29
- expect(
30
- resolveAgentCommand("opencode", undefined, variables, true).argv,
31
- ).toEqual(["opencode", "--continue"])
32
- expect(resolveAgentCommand("pi", undefined, variables, false).argv).toEqual(
33
- ["pi"],
34
- )
35
- expect(resolveAgentCommand("pi", undefined, variables, true).argv).toEqual([
36
- "pi",
37
- "--continue",
38
- ])
39
- expect(
40
- resolveAgentCommand("claude", undefined, variables, true).argv,
41
- ).toEqual(["claude", "--continue"])
42
- })
43
-
44
- test("uses autonomous commands when a prompt is requested", () => {
45
- expect(
46
- resolveAgentCommand("opencode2", undefined, variables, false, true).argv,
47
- ).toEqual(["opencode2", "--prompt", "Read the task."])
48
- expect(
49
- resolveAgentCommand("opencode2", undefined, variables, true, true).argv,
50
- ).toEqual(["opencode2", "--continue", "--prompt", "Read the task."])
51
- expect(
52
- resolveAgentCommand("opencode", undefined, variables, false, true).argv,
53
- ).toEqual(["opencode", "--prompt", "Read the task."])
54
- expect(
55
- resolveAgentCommand("opencode", undefined, variables, true, true).argv,
56
- ).toEqual(["opencode", "--continue", "--prompt", "Read the task."])
57
- expect(
58
- resolveAgentCommand("pi", undefined, variables, false, true).argv,
59
- ).toEqual(["pi", "Read the task."])
60
- expect(
61
- resolveAgentCommand("claude", undefined, variables, true, true).argv,
62
- ).toEqual(["claude", "--continue", "Read the task."])
63
- })
64
-
65
- test("expands configured argv and environment without a shell", () => {
66
- const resolved = resolveAgentCommand(
67
- "custom",
68
- {
69
- custom: {
70
- command: ["agent"],
71
- autoCommand: ["agent", "--target={target}", "{prompt}"],
72
- environment: { CUSTOM_SESSION: "{sessionId}" },
73
- },
74
- },
75
- variables,
76
- false,
77
- true,
78
- )
79
-
80
- expect(resolved).toEqual({
81
- argv: [
82
- "agent",
83
- "--target=execution-unit:phase/task/build",
84
- "Read the task.",
85
- ],
86
- environment: { CUSTOM_SESSION: "session-1" },
87
- })
88
- })
89
-
90
- test("rejects --auto for configured agents without an auto command", () => {
91
- expect(() =>
92
- resolveAgentCommand(
93
- "custom",
94
- { custom: { command: ["agent"] } },
95
- variables,
96
- false,
97
- true,
98
- ),
99
- ).toThrow("Agent 'custom' does not support --auto")
100
- })
101
-
102
- test("rejects unknown placeholders", () => {
103
- expect(() =>
104
- validateAgents({ custom: { command: ["agent", "{unknown}"] } }),
105
- ).toThrow("Unknown agent 'custom' placeholder: {unknown}")
106
- })
107
-
108
- test("provides normalized Agency environment and filters secret values", () => {
109
- const environment = {
110
- ...agentEnvironment("custom", variables),
111
- VISIBLE: "yes",
112
- ACCESS_TOKEN: "secret",
113
- }
114
-
115
- expect(environment).toMatchObject({
116
- AGENCY_AGENT: "custom",
117
- AGENCY_INVOCATION_SOURCE: "agent",
118
- AGENCY_SESSION_ID: "session-1",
119
- AGENCY_WORKBASE: "/workbase",
120
- AGENCY_TARGET: "execution-unit:phase/task/build",
121
- AGENCY_TASK_ID: "task",
122
- AGENCY_PHASE_ID: "build",
123
- AGENCY_PROMPT: "Read the task.",
124
- })
125
- expect(printableEnvironment(environment).VISIBLE).toBe("yes")
126
- expect(printableEnvironment(environment).ACCESS_TOKEN).toBeUndefined()
127
- })
128
- })
@@ -1,62 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import {
3
- expandPostCheckoutCommand,
4
- postCheckoutCommandEnvironment,
5
- } from "./checkout-command"
6
-
7
- const variables = {
8
- repoAlias: "app",
9
- repositoryPath: "/work/repos/app",
10
- checkoutPath: "/work/tasks/example/code/app",
11
- checkoutKind: "reference" as const,
12
- requestedRef: "main",
13
- base: "",
14
- vcs: "git" as const,
15
- workbaseRoot: "/work",
16
- taskId: "example",
17
- phaseId: "",
18
- }
19
-
20
- describe("post-checkout command templates", () => {
21
- test("expands all context placeholders without shell interpolation", () => {
22
- expect(
23
- expandPostCheckoutCommand(
24
- [
25
- "tool",
26
- "{repoAlias}",
27
- "{repositoryPath}",
28
- "{checkoutPath}",
29
- "{checkoutKind}",
30
- "{requestedRef}",
31
- "{base}",
32
- "{vcs}",
33
- "{workbaseRoot}",
34
- "{taskId}",
35
- "{phaseId}",
36
- ],
37
- variables,
38
- ),
39
- ).toEqual(["tool", ...Object.values(variables)])
40
- })
41
-
42
- test("rejects unknown placeholders", () => {
43
- expect(() =>
44
- expandPostCheckoutCommand(["tool", "{unknown}"], variables),
45
- ).toThrow("{unknown}")
46
- })
47
-
48
- test("provides matching environment variables with empty optional values", () => {
49
- expect(postCheckoutCommandEnvironment(variables)).toEqual({
50
- AGENCY_REPO_ALIAS: variables.repoAlias,
51
- AGENCY_REPOSITORY_PATH: variables.repositoryPath,
52
- AGENCY_CHECKOUT_PATH: variables.checkoutPath,
53
- AGENCY_CHECKOUT_KIND: variables.checkoutKind,
54
- AGENCY_REQUESTED_REF: variables.requestedRef,
55
- AGENCY_BASE: "",
56
- AGENCY_VCS: variables.vcs,
57
- AGENCY_WORKBASE_ROOT: variables.workbaseRoot,
58
- AGENCY_TASK_ID: variables.taskId,
59
- AGENCY_PHASE_ID: "",
60
- })
61
- })
62
- })
@@ -1,180 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import {
3
- parseGitHubPullRequest,
4
- parseGitHubPullRequestList,
5
- parsePullRequestRecord,
6
- resolveDeliveryCommand,
7
- resolveGitHubCreateCommand,
8
- validateDelivery,
9
- } from "./delivery-command"
10
-
11
- const delivery = {
12
- provider: "forge",
13
- remote: "upstream",
14
- createCommand: ["forge", "create", "{repository}", "{branch}"],
15
- queryCommand: ["forge", "query", "{identifier}"],
16
- environment: { FORGE_BASE: "{base}" },
17
- } as const
18
-
19
- const variables = {
20
- repository: "example/agency",
21
- branch: "feat/example",
22
- base: "main",
23
- draft: "false",
24
- url: "",
25
- identifier: "",
26
- }
27
-
28
- describe("delivery commands", () => {
29
- test("expands argv and environment without shell evaluation", () => {
30
- expect(resolveDeliveryCommand(delivery, "create", variables)).toEqual({
31
- argv: ["forge", "create", "example/agency", "feat/example"],
32
- environment: { FORGE_BASE: "main" },
33
- })
34
- })
35
-
36
- test("builds the default GitHub create command", () => {
37
- const input = {
38
- repository: "example/agency",
39
- base: "main",
40
- draft: false,
41
- head: "feat/example",
42
- } as const
43
- expect(resolveGitHubCreateCommand(input)).toEqual({
44
- argv: [
45
- "gh",
46
- "pr",
47
- "create",
48
- "--fill",
49
- "--repo",
50
- "example/agency",
51
- "--base",
52
- "main",
53
- "--head",
54
- "feat/example",
55
- ],
56
- environment: {},
57
- })
58
- expect(
59
- resolveGitHubCreateCommand({
60
- ...input,
61
- title: "Requested",
62
- head: "feat/example",
63
- labels: ["ai-assisted", "platform"],
64
- }),
65
- ).toEqual({
66
- argv: [
67
- "gh",
68
- "pr",
69
- "create",
70
- "--fill",
71
- "--title",
72
- "Requested",
73
- "--repo",
74
- "example/agency",
75
- "--base",
76
- "main",
77
- "--head",
78
- "feat/example",
79
- "--label",
80
- "ai-assisted",
81
- "--label",
82
- "platform",
83
- ],
84
- environment: {},
85
- })
86
- })
87
-
88
- test("rejects unknown placeholders", () => {
89
- expect(() =>
90
- validateDelivery({ ...delivery, queryCommand: ["forge", "{unknown}"] }),
91
- ).toThrow("Unknown delivery provider 'forge' placeholder")
92
- })
93
-
94
- test("requires complete and consistent normalized records", () => {
95
- const record = {
96
- provider: "forge",
97
- repository: "example/agency",
98
- identifier: "17",
99
- url: "https://forge.example/example/agency/pulls/17",
100
- state: "merged",
101
- draft: false,
102
- merged: true,
103
- } as const
104
- expect(parsePullRequestRecord(JSON.stringify(record))).toEqual(record)
105
- expect(() =>
106
- parsePullRequestRecord(JSON.stringify({ ...record, merged: false })),
107
- ).toThrow("inconsistent merge state")
108
- expect(
109
- parsePullRequestRecord(JSON.stringify({ ...record, mergeable: null })),
110
- ).toEqual({ ...record, mergeable: null })
111
- expect(() =>
112
- parsePullRequestRecord(JSON.stringify({ ...record, mergeable: "yes" })),
113
- ).toThrow("valid pull request record")
114
- })
115
-
116
- test("normalizes GitHub state and mergeability", () => {
117
- const base = {
118
- number: 17,
119
- url: "https://github.com/example/agency/pull/17",
120
- title: "Ship",
121
- isDraft: false,
122
- headRefName: "feat/example",
123
- baseRefName: "main",
124
- headRepository: { nameWithOwner: "fork/agency" },
125
- mergedAt: null,
126
- mergeCommit: null,
127
- mergeable: "UNKNOWN",
128
- }
129
- expect(
130
- parseGitHubPullRequest(
131
- JSON.stringify({
132
- ...base,
133
- state: "OPEN",
134
- baseRepository: { nameWithOwner: "example/agency" },
135
- mergeable: "MERGEABLE",
136
- }),
137
- ),
138
- ).toMatchObject({
139
- state: "open",
140
- merged: false,
141
- headRepository: "fork/agency",
142
- headBranch: "feat/example",
143
- baseRepository: "example/agency",
144
- baseBranch: "main",
145
- mergeable: true,
146
- })
147
- expect(
148
- parseGitHubPullRequest(
149
- JSON.stringify({
150
- ...base,
151
- state: "OPEN",
152
- mergeable: "CONFLICTING",
153
- }),
154
- ),
155
- ).toMatchObject({ state: "open", merged: false, mergeable: false })
156
- expect(
157
- parseGitHubPullRequest(
158
- JSON.stringify({
159
- ...base,
160
- state: "CLOSED",
161
- mergedAt: "2026-07-21T00:00:00Z",
162
- mergeCommit: { oid: "abc" },
163
- mergeable: "UNKNOWN",
164
- }),
165
- ),
166
- ).toMatchObject({ state: "merged", merged: true, mergeable: null })
167
- })
168
-
169
- test("rejects malformed GitHub detail and list responses", () => {
170
- expect(() => parseGitHubPullRequest("not-json")).toThrow(
171
- "GitHub CLI did not return valid JSON for pull request",
172
- )
173
- expect(() => parseGitHubPullRequest("{}")).toThrow(
174
- "GitHub CLI did not return a valid pull request",
175
- )
176
- expect(() => parseGitHubPullRequestList("{}")).toThrow(
177
- "GitHub CLI did not return a valid pull request list",
178
- )
179
- })
180
- })