@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.
Files changed (69) hide show
  1. package/package.json +7 -2
  2. package/src/workbase/schemas.ts +12 -0
  3. package/fixtures/protocol/orchestration-recipes.json +0 -53
  4. package/pi-extensions/agency.test.ts +0 -117
  5. package/src/check-commits.test.ts +0 -58
  6. package/src/cli-parser.test.ts +0 -945
  7. package/src/cli.test.ts +0 -1889
  8. package/src/commands/act.test.ts +0 -418
  9. package/src/commands/archive.test.ts +0 -236
  10. package/src/commands/context.test.ts +0 -665
  11. package/src/commands/description.test.ts +0 -103
  12. package/src/commands/doctor.test.ts +0 -185
  13. package/src/commands/epic.test.ts +0 -196
  14. package/src/commands/init.test.ts +0 -117
  15. package/src/commands/integration.test.ts +0 -165
  16. package/src/commands/pr.test.ts +0 -248
  17. package/src/commands/push.test.ts +0 -56
  18. package/src/commands/read-only.test.ts +0 -161
  19. package/src/commands/repo.test.ts +0 -199
  20. package/src/commands/restore.test.ts +0 -106
  21. package/src/commands/status.test.ts +0 -113
  22. package/src/commands/sync.test.ts +0 -200
  23. package/src/commands/task-phase.test.ts +0 -410
  24. package/src/commands/task.test.ts +0 -281
  25. package/src/commands/validate.test.ts +0 -186
  26. package/src/commands/work.test.ts +0 -1375
  27. package/src/commands/workbase.test.ts +0 -170
  28. package/src/graph-schema.test.ts +0 -124
  29. package/src/protocol.test.ts +0 -163
  30. package/src/readiness.test.ts +0 -105
  31. package/src/services/ArchiveBulkService.test.ts +0 -384
  32. package/src/services/ArchiveService.test.ts +0 -1092
  33. package/src/services/EpicService.test.ts +0 -74
  34. package/src/services/FileSystemService.test.ts +0 -81
  35. package/src/services/GraphMutationService.test.ts +0 -486
  36. package/src/services/GraphService.test.ts +0 -494
  37. package/src/services/IntegrationService.test.ts +0 -1091
  38. package/src/services/LifecycleTransaction.test.ts +0 -145
  39. package/src/services/PullRequestService.test.ts +0 -716
  40. package/src/services/PushService.test.ts +0 -408
  41. package/src/services/ReadinessService.test.ts +0 -290
  42. package/src/services/RepositoryService.test.ts +0 -789
  43. package/src/services/ReviewService.test.ts +0 -408
  44. package/src/services/SyncService.test.ts +0 -1377
  45. package/src/services/TaskPhaseService.test.ts +0 -852
  46. package/src/services/VersionControlService.test.ts +0 -62
  47. package/src/services/WorkbaseService.test.ts +0 -910
  48. package/src/services/WorktreeLock.test.ts +0 -205
  49. package/src/services/WorktreePerformance.test.ts +0 -71
  50. package/src/services/WorktreeService.test.ts +0 -2292
  51. package/src/test-utils.ts +0 -110
  52. package/src/usage-log.test.ts +0 -188
  53. package/src/utils/chooser.test.ts +0 -192
  54. package/src/utils/effect.test.ts +0 -30
  55. package/src/utils/interactive.pty.test.ts +0 -128
  56. package/src/utils/interactive.test.tsx +0 -860
  57. package/src/utils/process.test.ts +0 -132
  58. package/src/utils/progress.test.ts +0 -37
  59. package/src/work-view.test.ts +0 -151
  60. package/src/workbase/agent-command.test.ts +0 -128
  61. package/src/workbase/checkout-command.test.ts +0 -62
  62. package/src/workbase/delivery-command.test.ts +0 -180
  63. package/src/workbase/dependency-graph.test.ts +0 -50
  64. package/src/workbase/execution-contract.test.ts +0 -161
  65. package/src/workbase/frontmatter.test.ts +0 -67
  66. package/src/workbase/repository-reference.test.ts +0 -25
  67. package/src/workbase/schemas.test.ts +0 -543
  68. package/src/workbase/work-target.test.ts +0 -108
  69. 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
- })