@markjaquith/agency 3.2.11 → 3.2.13

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.11",
3
+ "version": "3.2.13",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -1,12 +1,17 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
2
2
  import { mkdir, readFile as nodeReadFile, rename, rm } from "node:fs/promises"
3
3
  import { dirname, join } from "node:path"
4
+ import { Effect } from "effect"
4
5
  import {
5
6
  captureLogs,
6
7
  cleanupTempDir,
7
8
  createTempDir,
8
9
  runTestEffect,
10
+ trackDocumentReadConcurrency,
9
11
  } from "../test-utils"
12
+ import { documentLoadConcurrency } from "../workbase/document-loading"
13
+ import { ContextService } from "../services/ContextService"
14
+ import { FileSystemService } from "../services/FileSystemService"
10
15
  import { context } from "./context"
11
16
 
12
17
  const write = async (root: string, path: string, content: string) => {
@@ -157,6 +162,33 @@ Phase prose.
157
162
  ])
158
163
  })
159
164
 
165
+ test("bounds document reads across nested task and phase traversal", async () => {
166
+ await Promise.all(
167
+ Array.from({ length: documentLoadConcurrency + 8 }, (_, index) =>
168
+ write(
169
+ root,
170
+ `tasks/agent-contract/phases/extra-${index}/PHASE.md`,
171
+ `---\nrepo: agency\nbranch: extra-${index}\nbase: main\npr: null\nstatus: open\n---\n`,
172
+ ),
173
+ ),
174
+ )
175
+ const fs = await Effect.runPromise(
176
+ FileSystemService.pipe(Effect.provide(FileSystemService.Default)),
177
+ )
178
+ const tracked = trackDocumentReadConcurrency(fs)
179
+
180
+ await runTestEffect(
181
+ ContextService.pipe(
182
+ Effect.flatMap((service) =>
183
+ service.get({ cwd: root, target: "foundations" }),
184
+ ),
185
+ Effect.provideService(FileSystemService, tracked.fs),
186
+ ),
187
+ )
188
+
189
+ expect(tracked.maximum()).toBe(documentLoadConcurrency)
190
+ })
191
+
160
192
  afterEach(async () => {
161
193
  await cleanupTempDir(root)
162
194
  })
@@ -4,7 +4,14 @@ import { Effect } from "effect"
4
4
  import { mkdir } from "node:fs/promises"
5
5
  import { dirname, join } from "node:path"
6
6
  import { AgencyGraph } from "../graph-schema"
7
- import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
7
+ import {
8
+ cleanupTempDir,
9
+ createTempDir,
10
+ runTestEffect,
11
+ trackDocumentReadConcurrency,
12
+ } from "../test-utils"
13
+ import { documentLoadConcurrency } from "../workbase/document-loading"
14
+ import { FileSystemService } from "./FileSystemService"
8
15
  import { GraphService } from "./GraphService"
9
16
  import {
10
17
  VersionControlService,
@@ -209,6 +216,33 @@ describe("GraphService", () => {
209
216
  expect(await getGraph(root)).toEqual(graph)
210
217
  })
211
218
 
219
+ test("bounds document reads across nested task and phase traversal", async () => {
220
+ const root = await createWorkbase()
221
+ roots.push(root)
222
+ await Promise.all(
223
+ Array.from({ length: documentLoadConcurrency + 8 }, (_, index) =>
224
+ write(
225
+ root,
226
+ `tasks/ship/phases/extra-${index}/PHASE.md`,
227
+ `---\nrepo: agency\nbranch: extra-${index}\nbase: main\npr: null\nstatus: open\n---\n`,
228
+ ),
229
+ ),
230
+ )
231
+ const fs = await Effect.runPromise(
232
+ FileSystemService.pipe(Effect.provide(FileSystemService.Default)),
233
+ )
234
+ const tracked = trackDocumentReadConcurrency(fs)
235
+
236
+ await runTestEffect(
237
+ GraphService.pipe(
238
+ Effect.flatMap((service) => service.get({ cwd: root })),
239
+ Effect.provideService(FileSystemService, tracked.fs),
240
+ ),
241
+ )
242
+
243
+ expect(tracked.maximum()).toBe(documentLoadConcurrency)
244
+ })
245
+
212
246
  test("does not resolve a VCS backend when git details are not requested", async () => {
213
247
  const root = await createWorkbase()
214
248
  roots.push(root)
@@ -22,6 +22,7 @@ import {
22
22
  import { parseFrontmatter } from "../workbase/frontmatter"
23
23
  import { normalizePullRequestRecord } from "../workbase/delivery-command"
24
24
  import { documentRevision } from "../workbase/document-revision"
25
+ import { documentLoadConcurrency } from "../workbase/document-loading"
25
26
  import {
26
27
  EpicFrontmatter,
27
28
  PhaseFrontmatter,
@@ -201,7 +202,7 @@ export class GraphService extends Effect.Service<GraphService>()(
201
202
  )
202
203
  }),
203
204
  ),
204
- { concurrency: "unbounded" },
205
+ { concurrency: documentLoadConcurrency },
205
206
  )
206
207
  for (const document of epicDocuments) {
207
208
  if (document) epics.set(document.id, document)
@@ -224,40 +225,37 @@ export class GraphService extends Effect.Service<GraphService>()(
224
225
  const phaseIds = yield* directories(
225
226
  join(root, "tasks", id, "phases"),
226
227
  )
227
- const taskPhases = yield* Effect.all(
228
- phaseIds.map((phaseId) =>
229
- Effect.gen(function* () {
230
- const phasePath = join(
231
- root,
232
- "tasks",
233
- id,
234
- "phases",
235
- phaseId,
236
- "PHASE.md",
237
- )
238
- return yield* readDocument(
239
- phaseId,
240
- phasePath,
241
- PhaseFrontmatter,
242
- ).pipe(
243
- Effect.catchTag("FileNotFoundError", () =>
244
- Effect.succeed(null),
245
- ),
246
- )
247
- }),
228
+ return { id, task, phaseIds }
229
+ }),
230
+ ),
231
+ { concurrency: documentLoadConcurrency },
232
+ )
233
+ const phaseDocuments = yield* Effect.all(
234
+ taskDocuments.flatMap(({ id, phaseIds }) =>
235
+ phaseIds.map((phaseId) => {
236
+ const phasePath = join(
237
+ root,
238
+ "tasks",
239
+ id,
240
+ "phases",
241
+ phaseId,
242
+ "PHASE.md",
243
+ )
244
+ return readDocument(phaseId, phasePath, PhaseFrontmatter).pipe(
245
+ Effect.catchTag("FileNotFoundError", () =>
246
+ Effect.succeed(null),
248
247
  ),
249
- { concurrency: "unbounded" },
248
+ Effect.map((document) => ({ taskId: id, document })),
250
249
  )
251
- return { id, task, phases: taskPhases }
252
250
  }),
253
251
  ),
254
- { concurrency: "unbounded" },
252
+ { concurrency: documentLoadConcurrency },
255
253
  )
256
254
  for (const documents of taskDocuments) {
257
255
  if (documents.task) tasks.set(documents.id, documents.task)
258
- for (const phase of documents.phases) {
259
- if (phase) phases.set(`${documents.id}/${phase.id}`, phase)
260
- }
256
+ }
257
+ for (const { taskId, document } of phaseDocuments) {
258
+ if (document) phases.set(`${taskId}/${document.id}`, document)
261
259
  }
262
260
 
263
261
  const repositoryRecords = new Map<string, RepositoryRecord>()
@@ -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() ||
@@ -26,6 +26,7 @@ import { validateAgents } from "../workbase/agent-command"
26
26
  import { findDependencyCycles } from "../workbase/dependency-graph"
27
27
  import { validateDelivery } from "../workbase/delivery-command"
28
28
  import { documentRevision } from "../workbase/document-revision"
29
+ import { documentLoadConcurrency } from "../workbase/document-loading"
29
30
 
30
31
  class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
31
32
  readonly message: string
@@ -66,8 +67,6 @@ interface DocumentRecord<T> {
66
67
  readonly data: T
67
68
  }
68
69
 
69
- const validationConcurrency = 32
70
-
71
70
  interface ValidationDocuments {
72
71
  readonly epics: readonly DocumentRecord<EpicData>[]
73
72
  readonly tasks: readonly DocumentRecord<TaskData>[]
@@ -751,7 +750,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
751
750
  return document ? { id, path, ...document } : null
752
751
  }),
753
752
  ),
754
- { concurrency: validationConcurrency },
753
+ { concurrency: documentLoadConcurrency },
755
754
  )
756
755
  for (const document of epicDocuments) {
757
756
  if (document) epics.set(document.id, document)
@@ -793,7 +792,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
793
792
  : null
794
793
  }),
795
794
  ),
796
- { concurrency: validationConcurrency },
795
+ { concurrency: documentLoadConcurrency },
797
796
  )
798
797
  return {
799
798
  id,
@@ -802,7 +801,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
802
801
  }
803
802
  }),
804
803
  ),
805
- { concurrency: validationConcurrency },
804
+ { concurrency: documentLoadConcurrency },
806
805
  )
807
806
  for (const documents of taskDocuments) {
808
807
  if (documents.task) tasks.set(documents.id, documents.task)
package/src/test-utils.ts CHANGED
@@ -31,6 +31,26 @@ export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
31
31
  export const cleanupTempDir = (path: string) =>
32
32
  rm(path, { recursive: true, force: true })
33
33
 
34
+ export const trackDocumentReadConcurrency = (fs: FileSystemService) => {
35
+ let active = 0
36
+ let maximum = 0
37
+ return {
38
+ fs: {
39
+ ...fs,
40
+ readFile: (path: string) => {
41
+ if (!/(?:EPIC|TASK|PHASE)\.md$/.test(path)) return fs.readFile(path)
42
+ return Effect.gen(function* () {
43
+ active += 1
44
+ maximum = Math.max(maximum, active)
45
+ yield* Effect.sleep(5)
46
+ return yield* fs.readFile(path)
47
+ }).pipe(Effect.ensuring(Effect.sync(() => (active -= 1))))
48
+ },
49
+ } satisfies FileSystemService,
50
+ maximum: () => maximum,
51
+ }
52
+ }
53
+
34
54
  const TestLayer = Layer.mergeAll(
35
55
  FileSystemService.Default,
36
56
  WorkbaseService.Default,
@@ -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 =>
@@ -0,0 +1 @@
1
+ export const documentLoadConcurrency = 32