@markjaquith/agency 3.2.10 → 3.2.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.2.10",
3
+ "version": "3.2.12",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -14,8 +14,8 @@ import {
14
14
  import type { PullRequestRecord } from "../workbase/schemas"
15
15
  import {
16
16
  normalizePullRequestRecord,
17
+ parseGitHubPullRequestList,
17
18
  parsePullRequestRecord,
18
- recordFromGitHubJson,
19
19
  recordFromGitHubUrl,
20
20
  repositoryFromRemote,
21
21
  resolveDeliveryCommand,
@@ -236,17 +236,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
236
236
  })
237
237
  }
238
238
  const records = yield* Effect.try({
239
- try: () => {
240
- const parsed: unknown = JSON.parse(listed.stdout)
241
- if (!Array.isArray(parsed)) {
242
- throw new Error(
243
- "GitHub CLI did not return a pull request list",
244
- )
245
- }
246
- return parsed.map((value) =>
247
- recordFromGitHubJson(value as Record<string, unknown>),
248
- )
249
- },
239
+ try: () => parseGitHubPullRequestList(listed.stdout),
250
240
  catch: (cause) =>
251
241
  new PullRequestError({
252
242
  message:
@@ -613,6 +613,89 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
613
613
  ).toBe(false)
614
614
  })
615
615
 
616
+ test("reports malformed successful GitHub responses", async () => {
617
+ await runTestEffect(
618
+ TaskService.pipe(
619
+ Effect.flatMap((service) =>
620
+ service.create(
621
+ {
622
+ id: "malformed-detail",
623
+ ticketUrl: null,
624
+ repo: "agency",
625
+ branch: "feat/example",
626
+ base: "main",
627
+ },
628
+ root,
629
+ ),
630
+ ),
631
+ ),
632
+ )
633
+ await runTestEffect(
634
+ PullRequestService.pipe(
635
+ Effect.flatMap((service) =>
636
+ service.setUrl(
637
+ "malformed-detail",
638
+ undefined,
639
+ "https://github.com/example/agency/pull/42",
640
+ root,
641
+ ),
642
+ ),
643
+ ),
644
+ )
645
+ await runTestEffect(
646
+ TaskService.pipe(
647
+ Effect.flatMap((service) =>
648
+ service.create(
649
+ {
650
+ id: "malformed-list",
651
+ ticketUrl: null,
652
+ repo: "agency",
653
+ branch: "feat/malformed-list",
654
+ base: "main",
655
+ },
656
+ root,
657
+ ),
658
+ ),
659
+ ),
660
+ )
661
+ await Bun.write(join(root, "bin", "gh"), "#!/bin/sh\nprintf '{}\\n'\n")
662
+ await chmod(join(root, "bin", "gh"), 0o755)
663
+
664
+ const result = await runTestEffect(
665
+ SyncService.pipe(
666
+ Effect.flatMap((service) =>
667
+ service.reconcile({ cwd: root, taskId: "malformed-detail" }),
668
+ ),
669
+ ),
670
+ )
671
+ expect(result.warnings).toContainEqual({
672
+ kind: "pr-provider-invalid-output",
673
+ target: "task:malformed-detail",
674
+ message: expect.stringContaining(
675
+ "GitHub CLI did not return a valid pull request",
676
+ ),
677
+ })
678
+ expect(result.executions[0]?.pr).toMatchObject({
679
+ url: "https://github.com/example/agency/pull/42",
680
+ state: "open",
681
+ })
682
+
683
+ const listResult = await runTestEffect(
684
+ SyncService.pipe(
685
+ Effect.flatMap((service) =>
686
+ service.reconcile({ cwd: root, taskId: "malformed-list" }),
687
+ ),
688
+ ),
689
+ )
690
+ expect(listResult.warnings).toContainEqual({
691
+ kind: "pr-provider-invalid-output",
692
+ target: "task:malformed-list",
693
+ message: expect.stringContaining(
694
+ "GitHub CLI did not return a valid pull request list",
695
+ ),
696
+ })
697
+ })
698
+
616
699
  test("reconciles a uniquely discovered merged PR without materializing", async () => {
617
700
  await runTestEffect(
618
701
  TaskService.pipe(
@@ -14,8 +14,9 @@ import {
14
14
  } from "../workbase/frontmatter"
15
15
  import {
16
16
  normalizePullRequestRecord,
17
+ parseGitHubPullRequest,
18
+ parseGitHubPullRequestList,
17
19
  parseOptionalPullRequestRecord,
18
- recordFromGitHubJson,
19
20
  resolveDeliveryCommand,
20
21
  } from "../workbase/delivery-command"
21
22
  import { FileSystemService } from "./FileSystemService"
@@ -135,14 +136,6 @@ const parseWorktrees = (output: string): RegisteredWorktree[] => {
135
136
  return worktrees
136
137
  }
137
138
 
138
- const parseJson = <T>(value: string, fallback: T): T => {
139
- try {
140
- return JSON.parse(value) as T
141
- } catch {
142
- return fallback
143
- }
144
- }
145
-
146
139
  const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
147
140
 
148
141
  const originRef = (ref: string) =>
@@ -172,17 +165,18 @@ interface PullRequestQuery {
172
165
  const mergedPullRequestFromGitHub = (
173
166
  data: ExecutionData,
174
167
  query: PullRequestQuery | undefined,
168
+ details: readonly PullRequestRecord[] | undefined,
175
169
  ) => {
176
- if (!query?.result || query.result.exitCode !== 0) return null
170
+ if (!query?.result || query.result.exitCode !== 0 || !details) return null
177
171
  const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
178
- const details = existing
179
- ? [parseJson<Record<string, unknown>>(query.result.stdout, {})]
180
- : parseJson<Record<string, unknown>[]>(query.result.stdout, []).filter(
172
+ const matches = existing
173
+ ? details
174
+ : details.filter(
181
175
  (item) =>
182
- item.headRefName === data.branch && item.baseRefName === data.base,
176
+ item.headBranch === data.branch && item.baseBranch === data.base,
183
177
  )
184
- if (details.length !== 1) return null
185
- const current = recordFromGitHubJson(details[0]!)
178
+ if (matches.length !== 1) return null
179
+ const current = matches[0]!
186
180
  if (
187
181
  current.merged !== true ||
188
182
  current.headRepository?.toLowerCase() !==
@@ -493,6 +487,37 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
493
487
  { concurrency: 8 },
494
488
  ),
495
489
  )
490
+ const githubResponses = new Map<
491
+ string,
492
+ | {
493
+ readonly ok: true
494
+ readonly details: readonly PullRequestRecord[]
495
+ }
496
+ | { readonly ok: false; readonly message: string }
497
+ >()
498
+ if (!config.delivery) {
499
+ for (const record of queryRecords) {
500
+ const query = prQueries.get(record.key)
501
+ if (query?.result?.exitCode !== 0) continue
502
+ try {
503
+ githubResponses.set(record.key, {
504
+ ok: true,
505
+ details: record.data.pr
506
+ ? [parseGitHubPullRequest(query.result.stdout)]
507
+ : parseGitHubPullRequestList(query.result.stdout),
508
+ })
509
+ } catch (cause) {
510
+ const message =
511
+ cause instanceof Error ? cause.message : String(cause)
512
+ githubResponses.set(record.key, { ok: false, message })
513
+ warnings.push({
514
+ kind: "pr-provider-invalid-output",
515
+ target: record.key,
516
+ message,
517
+ })
518
+ }
519
+ }
520
+ }
496
521
  const reviewSourceQueries = new Map(
497
522
  yield* Effect.forEach(
498
523
  reviewRecords,
@@ -534,11 +559,13 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
534
559
  }
535
560
  const checkoutRecords = records.filter((record) => {
536
561
  if (record.data.completion) return false
562
+ const githubResponse = githubResponses.get(record.key)
537
563
  const merged = config.delivery
538
564
  ? null
539
565
  : mergedPullRequestFromGitHub(
540
566
  record.data,
541
567
  prQueries.get(record.key),
568
+ githubResponse?.ok ? githubResponse.details : undefined,
542
569
  )
543
570
  return merged === null
544
571
  })
@@ -590,9 +617,13 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
590
617
  const codePath = join(dirname(record.path), "code")
591
618
  const checkoutStates: CheckoutState[] = []
592
619
  const query = prQueries.get(record.key)
620
+ const githubResponse = githubResponses.get(record.key)
621
+ const githubDetails = githubResponse?.ok
622
+ ? githubResponse.details
623
+ : undefined
593
624
  const remoteMergedPr = config.delivery
594
625
  ? null
595
- : mergedPullRequestFromGitHub(data, query)
626
+ : mergedPullRequestFromGitHub(data, query, githubDetails)
596
627
  const skipCheckoutReconciliation =
597
628
  Boolean(data.completion) || remoteMergedPr !== null
598
629
  let materialize = false
@@ -947,27 +978,25 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
947
978
  } else if (existing) {
948
979
  const viewed = query.result!
949
980
  if (viewed.exitCode === 0) {
950
- const detail = parseJson<Record<string, unknown>>(
951
- viewed.stdout,
952
- {},
953
- )
954
- current = recordFromGitHubJson(detail)
955
- pr = { ...detail, ...current }
956
- if (
957
- current.headRepository?.toLowerCase() !==
958
- remoteRepository.toLowerCase() ||
959
- current.headBranch !== data.branch ||
960
- current.baseRepository?.toLowerCase() !==
961
- current.repository.toLowerCase() ||
962
- current.baseBranch !== data.base
963
- ) {
964
- prConflict = true
965
- unresolved.push({
966
- kind: "pr-repository-conflict",
967
- target: record.key,
968
- message: `Recorded PR head does not match '${remoteRepository}:${data.branch}' or base '${current.repository}:${data.base}'`,
969
- action: "Correct the declaration or recorded PR URL",
970
- })
981
+ if (githubDetails) {
982
+ current = githubDetails[0]!
983
+ pr = current
984
+ if (
985
+ current.headRepository?.toLowerCase() !==
986
+ remoteRepository.toLowerCase() ||
987
+ current.headBranch !== data.branch ||
988
+ current.baseRepository?.toLowerCase() !==
989
+ current.repository.toLowerCase() ||
990
+ current.baseBranch !== data.base
991
+ ) {
992
+ prConflict = true
993
+ unresolved.push({
994
+ kind: "pr-repository-conflict",
995
+ target: record.key,
996
+ message: `Recorded PR head does not match '${remoteRepository}:${data.branch}' or base '${current.repository}:${data.base}'`,
997
+ action: "Correct the declaration or recorded PR URL",
998
+ })
999
+ }
971
1000
  }
972
1001
  } else {
973
1002
  pr = { url: existing.url, state: "unavailable" }
@@ -980,17 +1009,14 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
980
1009
  } else {
981
1010
  const listed = query.result!
982
1011
  if (listed.exitCode === 0) {
983
- const matches = parseJson<Record<string, unknown>[]>(
984
- listed.stdout,
985
- [],
986
- ).filter(
1012
+ const matches = (githubDetails ?? []).filter(
987
1013
  (item) =>
988
- item.headRefName === data.branch &&
989
- item.baseRefName === data.base,
1014
+ item.headBranch === data.branch &&
1015
+ item.baseBranch === data.base,
990
1016
  )
991
1017
  if (matches.length === 1) {
992
- current = recordFromGitHubJson(matches[0]!)
993
- pr = { ...matches[0], ...current }
1018
+ current = matches[0]!
1019
+ pr = current
994
1020
  if (
995
1021
  current.headRepository?.toLowerCase() !==
996
1022
  remoteRepository.toLowerCase() ||
@@ -1,7 +1,39 @@
1
1
  import { describe, expect, spyOn, test } from "bun:test"
2
- import { Effect } from "effect"
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"
3
6
  import { spawnProcess } from "./process"
4
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
+
5
37
  describe("spawnProcess", () => {
6
38
  test("forwards and captures output in tee mode", async () => {
7
39
  const forwardedStdout: Uint8Array[] = []
@@ -74,4 +106,27 @@ describe("spawnProcess", () => {
74
106
  ).rejects.toThrow("Process timed out")
75
107
  expect(performance.now() - startedAt).toBeLessThan(1_000)
76
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
+ })
77
132
  })
@@ -21,22 +21,38 @@ interface SpawnOptions {
21
21
  readonly timeoutMs?: number
22
22
  }
23
23
 
24
- const readOutput = async (
24
+ interface OutputReader {
25
+ readonly output: Promise<string>
26
+ readonly cancel: () => Promise<void>
27
+ }
28
+
29
+ const readOutput = (
25
30
  stream: ReadableStream<Uint8Array> | null | undefined,
26
31
  target?: { write(chunk: Uint8Array): unknown },
27
- ) => {
28
- if (!stream) return ""
32
+ ): OutputReader => {
33
+ if (!stream) {
34
+ return { output: Promise.resolve(""), cancel: () => Promise.resolve() }
35
+ }
29
36
 
30
37
  const reader = stream.getReader()
31
- const decoder = new TextDecoder()
32
- let output = ""
33
- while (true) {
34
- const { done, value } = await reader.read()
35
- if (done) break
36
- target?.write(value)
37
- output += decoder.decode(value, { stream: true })
38
+ return {
39
+ output: (async () => {
40
+ const decoder = new TextDecoder()
41
+ let output = ""
42
+ try {
43
+ while (true) {
44
+ const { done, value } = await reader.read()
45
+ if (done) break
46
+ target?.write(value)
47
+ output += decoder.decode(value, { stream: true })
48
+ }
49
+ return output + decoder.decode()
50
+ } finally {
51
+ reader.releaseLock()
52
+ }
53
+ })(),
54
+ cancel: () => reader.cancel(),
38
55
  }
39
- return output + decoder.decode()
40
56
  }
41
57
 
42
58
  /**
@@ -70,95 +86,139 @@ export const spawnProcess = (
70
86
  args: readonly string[],
71
87
  options?: SpawnOptions,
72
88
  ): Effect.Effect<ProcessResult, ProcessError> =>
73
- Effect.tryPromise({
74
- try: async () => {
75
- const startedAt = performance.now()
76
- const proc = Bun.spawn([...args], {
77
- cwd: options?.cwd ?? process.cwd(),
78
- stdin: options?.stdin ?? "pipe",
79
- stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
80
- stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
81
- env: options?.env ? { ...process.env, ...options.env } : process.env,
82
- detached: options?.timeoutMs !== undefined,
83
- })
84
- // Start draining stdout/stderr immediately so verbose subprocesses
85
- // cannot block on filled pipe buffers before they exit.
86
- const stdoutPromise =
87
- options?.stdout === "inherit"
88
- ? Promise.resolve("")
89
- : readOutput(
90
- proc.stdout,
91
- options?.stdout === "tee" ? process.stdout : undefined,
92
- )
93
- const stderrPromise =
94
- options?.stderr === "inherit"
95
- ? Promise.resolve("")
96
- : readOutput(
97
- proc.stderr,
98
- options?.stderr === "tee" ? process.stderr : undefined,
99
- )
89
+ Effect.acquireUseRelease(
90
+ Effect.try({
91
+ try: () => {
92
+ const detached = options?.timeoutMs !== undefined
93
+ const proc = Bun.spawn([...args], {
94
+ cwd: options?.cwd ?? process.cwd(),
95
+ stdin: options?.stdin ?? "pipe",
96
+ stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
97
+ stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
98
+ env: options?.env ? { ...process.env, ...options.env } : process.env,
99
+ detached,
100
+ })
101
+ // Start draining stdout/stderr immediately so verbose subprocesses
102
+ // cannot block on filled pipe buffers before they exit.
103
+ const stdout =
104
+ options?.stdout === "inherit"
105
+ ? readOutput(undefined)
106
+ : readOutput(
107
+ proc.stdout,
108
+ options?.stdout === "tee" ? process.stdout : undefined,
109
+ )
110
+ const stderr =
111
+ options?.stderr === "inherit"
112
+ ? readOutput(undefined)
113
+ : readOutput(
114
+ proc.stderr,
115
+ options?.stderr === "tee" ? process.stderr : undefined,
116
+ )
100
117
 
101
- let timedOut = false
102
- let timer: ReturnType<typeof setTimeout> | undefined
103
- const exited =
104
- options?.timeoutMs === undefined
105
- ? proc.exited
106
- : Promise.race([
107
- proc.exited,
108
- new Promise<number>((resolve) => {
109
- timer = setTimeout(async () => {
110
- timedOut = true
111
- try {
112
- process.kill(-proc.pid, "SIGTERM")
113
- } catch {
114
- proc.kill("SIGTERM")
115
- }
116
- const stopped = await Promise.race([
117
- proc.exited.then(() => true),
118
- Bun.sleep(250).then(() => false),
119
- ])
120
- if (!stopped) {
121
- try {
122
- process.kill(-proc.pid, "SIGKILL")
123
- } catch {
124
- proc.kill("SIGKILL")
125
- }
126
- }
127
- resolve(await proc.exited)
128
- }, options.timeoutMs)
129
- }),
118
+ let termination: Promise<void> | undefined
119
+ const terminate = (): Promise<void> => {
120
+ if (proc.exitCode !== null) return Promise.resolve()
121
+ return (termination ??= (async () => {
122
+ const signal = (name: "SIGTERM" | "SIGKILL") => {
123
+ if (!detached) {
124
+ proc.kill(name)
125
+ return
126
+ }
127
+ try {
128
+ process.kill(-proc.pid, name)
129
+ } catch {
130
+ proc.kill(name)
131
+ }
132
+ }
133
+ signal("SIGTERM")
134
+ const stopped = await Promise.race([
135
+ proc.exited.then(() => true),
136
+ Bun.sleep(250).then(() => false),
130
137
  ])
131
- const [exitCode, stdout, stderr] = await Promise.all([
132
- exited,
133
- stdoutPromise,
134
- stderrPromise,
135
- ])
136
- if (timer) clearTimeout(timer)
137
- if (timedOut) {
138
- throw new ProcessError({
138
+ if (!stopped) signal("SIGKILL")
139
+ await proc.exited
140
+ })())
141
+ }
142
+
143
+ return {
144
+ proc,
145
+ stdout,
146
+ stderr,
147
+ terminate,
148
+ startedAt: performance.now(),
149
+ state: {
150
+ timedOut: false,
151
+ timer: undefined as ReturnType<typeof setTimeout> | undefined,
152
+ },
153
+ }
154
+ },
155
+ catch: (error) =>
156
+ new ProcessError({
139
157
  command: args.join(" "),
140
- exitCode:
141
- typeof exitCode === "number" ? exitCode : (proc.exitCode ?? -1),
142
- stderr: stderr.trim(),
143
- timedOut: true,
144
- timeoutMs: options?.timeoutMs,
145
- elapsedMs: Math.round(performance.now() - startedAt),
146
- })
147
- }
158
+ exitCode: -1,
159
+ stderr: error instanceof Error ? error.message : String(error),
160
+ }),
161
+ }),
162
+ ({ proc, stdout, stderr, terminate, startedAt, state }) =>
163
+ Effect.tryPromise({
164
+ try: async () => {
165
+ const exited =
166
+ options?.timeoutMs === undefined
167
+ ? proc.exited
168
+ : Promise.race([
169
+ proc.exited,
170
+ new Promise<number>((resolve) => {
171
+ state.timer = setTimeout(async () => {
172
+ state.timedOut = true
173
+ await terminate()
174
+ resolve(await proc.exited)
175
+ }, options.timeoutMs)
176
+ }),
177
+ ])
178
+ const [exitCode, stdoutOutput, stderrOutput] = await Promise.all([
179
+ exited,
180
+ stdout.output,
181
+ stderr.output,
182
+ ])
183
+ if (state.timer) clearTimeout(state.timer)
184
+ if (state.timedOut) {
185
+ throw new ProcessError({
186
+ command: args.join(" "),
187
+ exitCode:
188
+ typeof exitCode === "number" ? exitCode : (proc.exitCode ?? -1),
189
+ stderr: stderrOutput.trim(),
190
+ timedOut: true,
191
+ timeoutMs: options?.timeoutMs,
192
+ elapsedMs: Math.round(performance.now() - startedAt),
193
+ })
194
+ }
148
195
 
149
- return {
150
- stdout: stdout.trim(),
151
- stderr: stderr.trim(),
152
- exitCode:
153
- typeof exitCode === "number" ? exitCode : (proc.exitCode ?? 0),
154
- }
155
- },
156
- catch: (error) =>
157
- error instanceof ProcessError
158
- ? error
159
- : new ProcessError({
160
- command: args.join(" "),
161
- exitCode: -1,
162
- stderr: error instanceof Error ? error.message : String(error),
163
- }),
164
- })
196
+ return {
197
+ stdout: stdoutOutput.trim(),
198
+ stderr: stderrOutput.trim(),
199
+ exitCode:
200
+ typeof exitCode === "number" ? exitCode : (proc.exitCode ?? 0),
201
+ }
202
+ },
203
+ catch: (error) =>
204
+ error instanceof ProcessError
205
+ ? error
206
+ : new ProcessError({
207
+ command: args.join(" "),
208
+ exitCode: -1,
209
+ stderr: error instanceof Error ? error.message : String(error),
210
+ }),
211
+ }),
212
+ ({ proc, stdout, stderr, terminate, state }) =>
213
+ Effect.promise(async () => {
214
+ if (state.timer) clearTimeout(state.timer)
215
+ const terminating = terminate()
216
+ await Promise.allSettled([stdout.cancel(), stderr.cancel()])
217
+ await Promise.allSettled([
218
+ terminating,
219
+ proc.exited,
220
+ stdout.output,
221
+ stderr.output,
222
+ ])
223
+ }),
224
+ )
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test"
2
2
  import {
3
+ parseGitHubPullRequest,
4
+ parseGitHubPullRequestList,
3
5
  parsePullRequestRecord,
4
- recordFromGitHubJson,
5
6
  resolveDeliveryCommand,
6
7
  resolveGitHubCreateCommand,
7
8
  validateDelivery,
@@ -116,18 +117,24 @@ describe("delivery commands", () => {
116
117
  const base = {
117
118
  number: 17,
118
119
  url: "https://github.com/example/agency/pull/17",
120
+ title: "Ship",
119
121
  isDraft: false,
122
+ headRefName: "feat/example",
123
+ baseRefName: "main",
124
+ headRepository: { nameWithOwner: "fork/agency" },
125
+ mergedAt: null,
126
+ mergeCommit: null,
127
+ mergeable: "UNKNOWN",
120
128
  }
121
129
  expect(
122
- recordFromGitHubJson({
123
- ...base,
124
- state: "OPEN",
125
- headRefName: "feat/example",
126
- baseRefName: "main",
127
- headRepository: { nameWithOwner: "fork/agency" },
128
- baseRepository: { nameWithOwner: "example/agency" },
129
- mergeable: "MERGEABLE",
130
- }),
130
+ parseGitHubPullRequest(
131
+ JSON.stringify({
132
+ ...base,
133
+ state: "OPEN",
134
+ baseRepository: { nameWithOwner: "example/agency" },
135
+ mergeable: "MERGEABLE",
136
+ }),
137
+ ),
131
138
  ).toMatchObject({
132
139
  state: "open",
133
140
  merged: false,
@@ -138,19 +145,36 @@ describe("delivery commands", () => {
138
145
  mergeable: true,
139
146
  })
140
147
  expect(
141
- recordFromGitHubJson({
142
- ...base,
143
- state: "OPEN",
144
- mergeable: "CONFLICTING",
145
- }),
148
+ parseGitHubPullRequest(
149
+ JSON.stringify({
150
+ ...base,
151
+ state: "OPEN",
152
+ mergeable: "CONFLICTING",
153
+ }),
154
+ ),
146
155
  ).toMatchObject({ state: "open", merged: false, mergeable: false })
147
156
  expect(
148
- recordFromGitHubJson({
149
- ...base,
150
- state: "CLOSED",
151
- mergedAt: "2026-07-21T00:00:00Z",
152
- mergeable: "UNKNOWN",
153
- }),
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
+ ),
154
166
  ).toMatchObject({ state: "merged", merged: true, mergeable: null })
155
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
+ })
156
180
  })
@@ -1,4 +1,4 @@
1
- import { Schema } from "@effect/schema"
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import type { PullRequestRecord, WorkbaseConfig } from "./schemas"
3
3
  import { PullRequestRecord as PullRequestRecordSchema } from "./schemas"
4
4
 
@@ -155,9 +155,47 @@ export const recordFromGitHubUrl = (url: string): PullRequestRecord => {
155
155
  }
156
156
  }
157
157
 
158
- export const recordFromGitHubJson = (value: Record<string, unknown>) => {
159
- const url = typeof value.url === "string" ? value.url : ""
160
- const record = recordFromGitHubUrl(url)
158
+ const GitHubRepository = Schema.Struct({
159
+ nameWithOwner: Schema.String.pipe(Schema.minLength(1)),
160
+ })
161
+
162
+ const GitHubPullRequest = Schema.Struct({
163
+ number: Schema.Number,
164
+ state: Schema.Literal("OPEN", "CLOSED", "MERGED"),
165
+ title: Schema.optional(Schema.String),
166
+ isDraft: Schema.Boolean,
167
+ headRefName: Schema.String,
168
+ baseRefName: Schema.String,
169
+ headRepository: Schema.NullOr(GitHubRepository),
170
+ baseRepository: Schema.optional(Schema.NullOr(GitHubRepository)),
171
+ url: Schema.String,
172
+ mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
173
+ mergeCommit: Schema.optional(
174
+ Schema.NullOr(Schema.Struct({ oid: Schema.String })),
175
+ ),
176
+ mergeable: Schema.Literal("MERGEABLE", "CONFLICTING", "UNKNOWN"),
177
+ })
178
+
179
+ const decodeGitHubPullRequest = Schema.decodeUnknownEither(GitHubPullRequest)
180
+ const decodeGitHubPullRequests = Schema.decodeUnknownEither(
181
+ Schema.Array(GitHubPullRequest),
182
+ )
183
+
184
+ const invalidGitHubResponse = (
185
+ kind: "pull request" | "pull request list",
186
+ error: Parameters<typeof TreeFormatter.formatErrorSync>[0],
187
+ ) =>
188
+ new Error(
189
+ `GitHub CLI did not return a valid ${kind}: ${TreeFormatter.formatErrorSync(error)}`,
190
+ )
191
+
192
+ const recordFromGitHubJson = (value: unknown): PullRequestRecord => {
193
+ const decoded = decodeGitHubPullRequest(value)
194
+ if (decoded._tag === "Left") {
195
+ throw invalidGitHubResponse("pull request", decoded.left)
196
+ }
197
+ const detail = decoded.right
198
+ const record = recordFromGitHubUrl(detail.url)
161
199
  const repositoryName = (repository: unknown) => {
162
200
  if (!repository || typeof repository !== "object") return undefined
163
201
  const nameWithOwner = (repository as Record<string, unknown>).nameWithOwner
@@ -165,19 +203,17 @@ export const recordFromGitHubJson = (value: Record<string, unknown>) => {
165
203
  ? nameWithOwner
166
204
  : undefined
167
205
  }
168
- const githubState = String(value.state ?? "OPEN").toLowerCase()
169
- const merged = githubState === "merged" || value.mergedAt != null
170
- const mergeable = String(value.mergeable ?? "UNKNOWN").toLowerCase()
206
+ const githubState = detail.state.toLowerCase()
207
+ const merged = githubState === "merged" || detail.mergedAt != null
208
+ const mergeable = detail.mergeable.toLowerCase()
171
209
  return {
172
210
  ...record,
173
- headRepository: repositoryName(value.headRepository),
174
- headBranch:
175
- typeof value.headRefName === "string" ? value.headRefName : undefined,
176
- baseRepository: repositoryName(value.baseRepository) ?? record.repository,
177
- baseBranch:
178
- typeof value.baseRefName === "string" ? value.baseRefName : undefined,
211
+ headRepository: repositoryName(detail.headRepository),
212
+ headBranch: detail.headRefName,
213
+ baseRepository: repositoryName(detail.baseRepository) ?? record.repository,
214
+ baseBranch: detail.baseRefName,
179
215
  state: merged ? "merged" : githubState === "closed" ? "closed" : "open",
180
- draft: value.isDraft === true,
216
+ draft: detail.isDraft,
181
217
  merged,
182
218
  mergeable:
183
219
  mergeable === "mergeable"
@@ -188,6 +224,32 @@ export const recordFromGitHubJson = (value: Record<string, unknown>) => {
188
224
  } satisfies PullRequestRecord
189
225
  }
190
226
 
227
+ const parseJson = (
228
+ value: string,
229
+ kind: "pull request" | "pull request list",
230
+ ) => {
231
+ try {
232
+ return JSON.parse(value) as unknown
233
+ } catch {
234
+ throw new Error(`GitHub CLI did not return valid JSON for ${kind}`)
235
+ }
236
+ }
237
+
238
+ export const parseGitHubPullRequest = (value: string): PullRequestRecord =>
239
+ recordFromGitHubJson(parseJson(value, "pull request"))
240
+
241
+ export const parseGitHubPullRequestList = (
242
+ value: string,
243
+ ): readonly PullRequestRecord[] => {
244
+ const decoded = decodeGitHubPullRequests(
245
+ parseJson(value, "pull request list"),
246
+ )
247
+ if (decoded._tag === "Left") {
248
+ throw invalidGitHubResponse("pull request list", decoded.left)
249
+ }
250
+ return decoded.right.map(recordFromGitHubJson)
251
+ }
252
+
191
253
  export const normalizePullRequestRecord = (
192
254
  record: PullRequestRecord | string,
193
255
  ): PullRequestRecord =>