@markjaquith/agency 2.20.0 → 2.22.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.
@@ -1,4 +1,4 @@
1
- import { Data, Effect } from "effect"
1
+ import { Data, Effect, Either } from "effect"
2
2
  import { dirname, join, resolve } from "node:path"
3
3
  import { documentRevision } from "../workbase/document-revision"
4
4
  import type {
@@ -7,7 +7,14 @@ import type {
7
7
  RepositoryReference,
8
8
  TaskFrontmatter,
9
9
  WorkStatus,
10
+ PullRequestRecord,
10
11
  } from "../workbase/schemas"
12
+ import {
13
+ normalizePullRequestRecord,
14
+ parseOptionalPullRequestRecord,
15
+ recordFromGitHubJson,
16
+ resolveDeliveryCommand,
17
+ } from "../workbase/delivery-command"
11
18
  import { ClaimService } from "./ClaimService"
12
19
  import { FileSystemService } from "./FileSystemService"
13
20
  import { PhaseService } from "./PhaseService"
@@ -143,7 +150,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
143
150
  const phases = yield* PhaseService
144
151
  const worktrees = yield* WorktreeService
145
152
  const claims = yield* ClaimService
146
- const root = yield* workbase.discover(options.cwd)
153
+ const { root, config } = yield* workbase.loadConfig(options.cwd)
147
154
  const validation = yield* workbase.validate(root)
148
155
  if (!validation.valid) {
149
156
  return yield* new SyncError({
@@ -161,12 +168,16 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
161
168
  const executions: ExecutionSyncState[] = []
162
169
  const runExternal = (
163
170
  args: readonly string[],
164
- commandOptions?: { readonly cwd?: string },
171
+ commandOptions?: {
172
+ readonly cwd?: string
173
+ readonly env?: Record<string, string>
174
+ },
165
175
  ) =>
166
176
  fs
167
177
  .runCommand(args, {
168
178
  cwd: commandOptions?.cwd,
169
179
  captureOutput: true,
180
+ env: commandOptions?.env,
170
181
  })
171
182
  .pipe(
172
183
  Effect.catchAll((error) =>
@@ -551,50 +562,127 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
551
562
  })
552
563
  }
553
564
 
554
- let pr: Record<string, unknown> = { url: data.pr, state: "none" }
565
+ const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
566
+ let current: PullRequestRecord | null = existing
567
+ let pr: Record<string, unknown> = existing ?? {
568
+ url: null,
569
+ state: "none",
570
+ }
555
571
  let prConflict = false
556
- if (data.pr) {
557
- const remote = yield* runExternal([
558
- "git",
559
- "-C",
560
- join(root, "repos", data.repo),
561
- "remote",
562
- "get-url",
563
- "origin",
564
- ])
565
- const remoteRepository = remote.stdout
566
- .trim()
567
- .match(/(?:github\.com[/:])([^/]+\/[^/]+)$/)?.[1]
568
- ?.replace(/\.git$/, "")
569
- const prRepository = data.pr.match(
570
- /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/\d+\/?$/,
571
- )?.[1]
572
- if (
573
- !remoteRepository ||
574
- remoteRepository.toLowerCase() !== prRepository?.toLowerCase()
575
- ) {
576
- prConflict = true
577
- unresolved.push({
578
- kind: "pr-repository-conflict",
572
+ const repositoryPath = join(root, "repos", data.repo)
573
+ const remoteName = config.delivery?.remote ?? "origin"
574
+ const remote = yield* runExternal([
575
+ "git",
576
+ "-C",
577
+ repositoryPath,
578
+ "remote",
579
+ "get-url",
580
+ remoteName,
581
+ ])
582
+ const remoteRepository = remote.stdout
583
+ .trim()
584
+ .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
585
+ .replace(/^[^:]+:/, "")
586
+ .replace(/\.git\/?$/, "")
587
+ .replace(/\/$/, "")
588
+
589
+ if (
590
+ existing &&
591
+ remoteRepository.toLowerCase() !== existing.repository.toLowerCase()
592
+ ) {
593
+ prConflict = true
594
+ unresolved.push({
595
+ kind: "pr-repository-conflict",
596
+ target: record.key,
597
+ message: `Recorded PR repository does not match writable repository remote '${remoteName}'`,
598
+ action: "Correct the configured remote or recorded PR",
599
+ })
600
+ }
601
+
602
+ if (config.delivery && remote.exitCode !== 0) {
603
+ warnings.push({
604
+ kind: "delivery-remote-unavailable",
605
+ target: record.key,
606
+ message: `Could not inspect delivery remote '${remoteName}': ${remote.stderr.trim()}`,
607
+ })
608
+ } else if (config.delivery) {
609
+ const resolved = resolveDeliveryCommand(config.delivery, "query", {
610
+ repository: remoteRepository,
611
+ branch: data.branch,
612
+ base: data.base,
613
+ draft: existing ? String(existing.draft) : "",
614
+ url: existing?.url ?? "",
615
+ identifier: existing?.identifier ?? "",
616
+ })
617
+ const queried = yield* runExternal(resolved.argv, {
618
+ cwd: repositoryPath,
619
+ env: resolved.environment,
620
+ })
621
+ if (queried.exitCode === 0) {
622
+ const parsed = yield* Effect.try({
623
+ try: () => parseOptionalPullRequestRecord(queried.stdout),
624
+ catch: (cause) =>
625
+ new SyncError({
626
+ message:
627
+ cause instanceof Error ? cause.message : String(cause),
628
+ }),
629
+ }).pipe(Effect.either)
630
+ if (Either.isLeft(parsed)) {
631
+ warnings.push({
632
+ kind: "pr-provider-invalid-output",
633
+ target: record.key,
634
+ message: parsed.left.message,
635
+ })
636
+ } else if (
637
+ parsed.right &&
638
+ (parsed.right.provider !== config.delivery.provider ||
639
+ parsed.right.repository.toLowerCase() !==
640
+ remoteRepository.toLowerCase())
641
+ ) {
642
+ if (parsed.right) {
643
+ prConflict = true
644
+ unresolved.push({
645
+ kind: "pr-provider-conflict",
646
+ target: record.key,
647
+ message:
648
+ "Delivery provider returned a record for the wrong provider or repository",
649
+ action: "Correct the delivery provider output",
650
+ })
651
+ }
652
+ } else {
653
+ current = parsed.right
654
+ pr = parsed.right ?? { url: null, state: "none" }
655
+ }
656
+ } else {
657
+ pr = existing
658
+ ? { url: existing.url, state: "unavailable" }
659
+ : { url: null, state: "none" }
660
+ warnings.push({
661
+ kind: existing ? "pr-unavailable" : "pr-discovery-unavailable",
579
662
  target: record.key,
580
663
  message:
581
- "Recorded PR repository does not match the writable repository origin",
582
- action: "Correct the repository origin or recorded PR URL",
664
+ queried.stderr.trim() || "Could not query delivery provider",
583
665
  })
584
666
  }
667
+ } else if (existing) {
585
668
  const viewed = yield* runExternal([
586
669
  "gh",
587
670
  "pr",
588
671
  "view",
589
- data.pr,
672
+ existing.url,
590
673
  "--json",
591
674
  "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit",
592
675
  ])
593
676
  if (viewed.exitCode === 0) {
594
- pr = parseJson(viewed.stdout, pr)
677
+ const detail = parseJson<Record<string, unknown>>(
678
+ viewed.stdout,
679
+ {},
680
+ )
681
+ current = recordFromGitHubJson(detail)
682
+ pr = { ...detail, ...current }
595
683
  if (
596
- pr.headRefName !== data.branch ||
597
- pr.baseRefName !== data.base
684
+ detail.headRefName !== data.branch ||
685
+ detail.baseRefName !== data.base
598
686
  ) {
599
687
  prConflict = true
600
688
  unresolved.push({
@@ -605,11 +693,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
605
693
  })
606
694
  }
607
695
  } else {
608
- pr = { url: data.pr, state: "unavailable" }
696
+ pr = { url: existing.url, state: "unavailable" }
609
697
  warnings.push({
610
698
  kind: "pr-unavailable",
611
699
  target: record.key,
612
- message: `Could not inspect ${data.pr}: ${viewed.stderr.trim()}`,
700
+ message: `Could not inspect ${existing.url}: ${viewed.stderr.trim()}`,
613
701
  })
614
702
  }
615
703
  } else {
@@ -625,7 +713,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
625
713
  "--json",
626
714
  "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit",
627
715
  ],
628
- { cwd: join(root, "repos", data.repo) },
716
+ { cwd: repositoryPath },
629
717
  )
630
718
  if (listed.exitCode === 0) {
631
719
  const matches = parseJson<Record<string, unknown>[]>(
@@ -634,31 +722,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
634
722
  ).filter(
635
723
  (item) =>
636
724
  item.headRefName === data.branch &&
637
- item.baseRefName === data.base &&
638
- typeof item.url === "string",
725
+ item.baseRefName === data.base,
639
726
  )
640
727
  if (matches.length === 1) {
641
- pr = matches[0]!
642
- const url = pr.url as string
643
- if (apply) {
644
- const recorded = yield* claims.reconcile(
645
- {
646
- taskId: record.taskId,
647
- phaseId: record.phaseId,
648
- revision,
649
- pr: url,
650
- },
651
- root,
652
- )
653
- data = recorded.data
654
- revision = recorded.revision
655
- }
656
- changes.push({
657
- kind: "record-pr",
658
- target: record.key,
659
- message: `Record pull request ${url}`,
660
- status: apply ? "applied" : "planned",
661
- })
728
+ current = recordFromGitHubJson(matches[0]!)
729
+ pr = { ...matches[0], ...current }
662
730
  } else if (matches.length > 1) {
663
731
  unresolved.push({
664
732
  kind: "multiple-prs",
@@ -677,8 +745,30 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
677
745
  }
678
746
  }
679
747
 
748
+ if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
749
+ if (apply) {
750
+ const recorded = yield* claims.reconcile(
751
+ {
752
+ taskId: record.taskId,
753
+ phaseId: record.phaseId,
754
+ revision,
755
+ pr: current,
756
+ },
757
+ root,
758
+ )
759
+ data = recorded.data
760
+ revision = recorded.revision
761
+ }
762
+ changes.push({
763
+ kind: "record-pr",
764
+ target: record.key,
765
+ message: `Record pull request ${current.url}`,
766
+ status: apply ? "applied" : "planned",
767
+ })
768
+ }
769
+
680
770
  if (
681
- pr.state === "MERGED" &&
771
+ current?.merged === true &&
682
772
  !prConflict &&
683
773
  data.status !== "done" &&
684
774
  data.status !== "dropped"
@@ -211,7 +211,15 @@ describe("task and phase services", () => {
211
211
  repos: [{ repo: "effect", ref: "main" }],
212
212
  branch: "task/single",
213
213
  base: "main",
214
- pr: "https://github.com/example/agency/pull/42",
214
+ pr: {
215
+ provider: "github",
216
+ repository: "example/agency",
217
+ identifier: "42",
218
+ url: "https://github.com/example/agency/pull/42",
219
+ state: "open",
220
+ draft: false,
221
+ merged: false,
222
+ },
215
223
  status: "open",
216
224
  })
217
225
  })
@@ -13,7 +13,6 @@ import {
13
13
  TaskFrontmatter,
14
14
  WorkbaseConfig,
15
15
  WorkbaseRegistry,
16
- type Dependency,
17
16
  type EpicFrontmatter as EpicData,
18
17
  type PhaseFrontmatter as PhaseData,
19
18
  type TaskFrontmatter as TaskData,
@@ -22,6 +21,8 @@ import {
22
21
  } from "../workbase/schemas"
23
22
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
24
23
  import { validateRunners } from "../workbase/runner-command"
24
+ import { findDependencyCycles } from "../workbase/dependency-graph"
25
+ import { validateDelivery } from "../workbase/delivery-command"
25
26
 
26
27
  class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
27
28
  readonly message: string
@@ -145,40 +146,6 @@ const writeRegistry = (
145
146
  yield* fs.writeJSON(path, registry)
146
147
  })
147
148
 
148
- const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
149
- const dependencies = new Map(
150
- nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
151
- )
152
- const visiting = new Set<string>()
153
- const visited = new Set<string>()
154
- const cycles = new Set<string>()
155
-
156
- const visit = (id: string) => {
157
- if (visiting.has(id)) {
158
- cycles.add(id)
159
- return
160
- }
161
- if (visited.has(id)) {
162
- return
163
- }
164
-
165
- visiting.add(id)
166
- for (const dependency of dependencies.get(id) ?? []) {
167
- if (dependencies.has(dependency)) {
168
- visit(dependency)
169
- }
170
- }
171
- visiting.delete(id)
172
- visited.add(id)
173
- }
174
-
175
- for (const id of dependencies.keys()) {
176
- visit(id)
177
- }
178
-
179
- return [...cycles].sort()
180
- }
181
-
182
149
  export class WorkbaseService extends Effect.Service<WorkbaseService>()(
183
150
  "WorkbaseService",
184
151
  {
@@ -280,6 +247,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
280
247
  }
281
248
  try {
282
249
  validateRunners(decoded.value.runners)
250
+ validateDelivery(decoded.value.delivery)
283
251
  } catch (cause) {
284
252
  return yield* new WorkbaseConfigError({
285
253
  path: configPath,
@@ -693,7 +661,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
693
661
  }
694
662
  }
695
663
  }
696
- for (const cycle of findCycles(epic.data.tasks)) {
664
+ for (const cycle of findDependencyCycles(epic.data.tasks)) {
697
665
  issue(epic.path, `Task dependency cycle includes '${cycle}'`)
698
666
  }
699
667
  }
@@ -739,7 +707,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
739
707
  issue(task.path, `Unlisted phase '${phaseId}'`)
740
708
  }
741
709
  }
742
- for (const cycle of findCycles(task.data.phases)) {
710
+ for (const cycle of findDependencyCycles(task.data.phases)) {
743
711
  issue(task.path, `Phase dependency cycle includes '${cycle}'`)
744
712
  }
745
713
  } else if (actualPhaseIds.length > 0) {
package/src/test-utils.ts CHANGED
@@ -18,6 +18,7 @@ import { GraphService } from "./services/GraphService"
18
18
  import { ClaimService } from "./services/ClaimService"
19
19
  import { SyncService } from "./services/SyncService"
20
20
  import { ReadinessService } from "./services/ReadinessService"
21
+ import { GraphMutationService } from "./services/GraphMutationService"
21
22
 
22
23
  export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
23
24
 
@@ -40,6 +41,7 @@ const TestLayer = Layer.mergeAll(
40
41
  ClaimService.Default,
41
42
  SyncService.Default,
42
43
  ReadinessService.Default,
44
+ GraphMutationService.Default,
43
45
  )
44
46
 
45
47
  export async function runTestEffect<A, E>(
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ parsePullRequestRecord,
4
+ resolveDeliveryCommand,
5
+ validateDelivery,
6
+ } from "./delivery-command"
7
+
8
+ const delivery = {
9
+ provider: "forge",
10
+ remote: "upstream",
11
+ createCommand: ["forge", "create", "{repository}", "{branch}"],
12
+ queryCommand: ["forge", "query", "{identifier}"],
13
+ environment: { FORGE_BASE: "{base}" },
14
+ } as const
15
+
16
+ const variables = {
17
+ repository: "example/agency",
18
+ branch: "feat/example",
19
+ base: "main",
20
+ draft: "false",
21
+ url: "",
22
+ identifier: "",
23
+ }
24
+
25
+ describe("delivery commands", () => {
26
+ test("expands argv and environment without shell evaluation", () => {
27
+ expect(resolveDeliveryCommand(delivery, "create", variables)).toEqual({
28
+ argv: ["forge", "create", "example/agency", "feat/example"],
29
+ environment: { FORGE_BASE: "main" },
30
+ })
31
+ })
32
+
33
+ test("rejects unknown placeholders", () => {
34
+ expect(() =>
35
+ validateDelivery({ ...delivery, queryCommand: ["forge", "{unknown}"] }),
36
+ ).toThrow("Unknown delivery provider 'forge' placeholder")
37
+ })
38
+
39
+ test("requires complete and consistent normalized records", () => {
40
+ const record = {
41
+ provider: "forge",
42
+ repository: "example/agency",
43
+ identifier: "17",
44
+ url: "https://forge.example/example/agency/pulls/17",
45
+ state: "merged",
46
+ draft: false,
47
+ merged: true,
48
+ } as const
49
+ expect(parsePullRequestRecord(JSON.stringify(record))).toEqual(record)
50
+ expect(() =>
51
+ parsePullRequestRecord(JSON.stringify({ ...record, merged: false })),
52
+ ).toThrow("inconsistent merge state")
53
+ })
54
+ })
@@ -0,0 +1,135 @@
1
+ import { Schema } from "@effect/schema"
2
+ import type { PullRequestRecord, WorkbaseConfig } from "./schemas"
3
+ import { PullRequestRecord as PullRequestRecordSchema } from "./schemas"
4
+
5
+ export interface DeliveryCommandVariables {
6
+ readonly repository: string
7
+ readonly branch: string
8
+ readonly base: string
9
+ readonly draft: string
10
+ readonly url: string
11
+ readonly identifier: string
12
+ }
13
+
14
+ const PLACEHOLDERS = new Set<keyof DeliveryCommandVariables>([
15
+ "repository",
16
+ "branch",
17
+ "base",
18
+ "draft",
19
+ "url",
20
+ "identifier",
21
+ ])
22
+
23
+ const validateTemplate = (provider: string, value: string) => {
24
+ for (const match of value.matchAll(/\{([^{}]+)\}/g)) {
25
+ const placeholder = match[1]!
26
+ if (!PLACEHOLDERS.has(placeholder as keyof DeliveryCommandVariables)) {
27
+ throw new Error(
28
+ `Unknown delivery provider '${provider}' placeholder: {${placeholder}}`,
29
+ )
30
+ }
31
+ }
32
+ }
33
+
34
+ export const validateDelivery = (
35
+ delivery: WorkbaseConfig["delivery"],
36
+ ): void => {
37
+ if (!delivery) return
38
+ for (const value of [
39
+ ...delivery.createCommand,
40
+ ...delivery.queryCommand,
41
+ ...Object.values(delivery.environment ?? {}),
42
+ ]) {
43
+ validateTemplate(delivery.provider, value)
44
+ }
45
+ }
46
+
47
+ const expand = (value: string, variables: DeliveryCommandVariables) =>
48
+ value.replaceAll(
49
+ /\{([^{}]+)\}/g,
50
+ (match, placeholder: string) =>
51
+ variables[placeholder as keyof DeliveryCommandVariables] ?? match,
52
+ )
53
+
54
+ export const resolveDeliveryCommand = (
55
+ delivery: NonNullable<WorkbaseConfig["delivery"]>,
56
+ kind: "create" | "query",
57
+ variables: DeliveryCommandVariables,
58
+ ) => {
59
+ validateDelivery(delivery)
60
+ const template =
61
+ kind === "create" ? delivery.createCommand : delivery.queryCommand
62
+ return {
63
+ argv: template.map((argument) => expand(argument, variables)),
64
+ environment: Object.fromEntries(
65
+ Object.entries(delivery.environment ?? {}).map(([key, value]) => [
66
+ key,
67
+ expand(value, variables),
68
+ ]),
69
+ ),
70
+ }
71
+ }
72
+
73
+ const decodeRecord = Schema.decodeUnknownEither(PullRequestRecordSchema, {
74
+ onExcessProperty: "error",
75
+ })
76
+
77
+ export const parsePullRequestRecord = (value: string): PullRequestRecord => {
78
+ let input: unknown
79
+ try {
80
+ input = JSON.parse(value)
81
+ } catch {
82
+ throw new Error("Delivery provider did not return valid JSON")
83
+ }
84
+ const decoded = decodeRecord(input)
85
+ if (decoded._tag === "Left") {
86
+ throw new Error(
87
+ "Delivery provider did not return a valid pull request record",
88
+ )
89
+ }
90
+ if (decoded.right.merged !== (decoded.right.state === "merged")) {
91
+ throw new Error("Delivery provider returned inconsistent merge state")
92
+ }
93
+ return decoded.right
94
+ }
95
+
96
+ export const parseOptionalPullRequestRecord = (
97
+ value: string,
98
+ ): PullRequestRecord | null => {
99
+ if (value.trim() === "null") return null
100
+ return parsePullRequestRecord(value)
101
+ }
102
+
103
+ const GITHUB_URL = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/
104
+
105
+ export const recordFromGitHubUrl = (url: string): PullRequestRecord => {
106
+ const match = url.match(GITHUB_URL)
107
+ if (!match) throw new Error(`Invalid GitHub pull request URL: ${url}`)
108
+ return {
109
+ provider: "github",
110
+ repository: match[1]!,
111
+ identifier: match[2]!,
112
+ url,
113
+ state: "open",
114
+ draft: false,
115
+ merged: false,
116
+ }
117
+ }
118
+
119
+ export const recordFromGitHubJson = (value: Record<string, unknown>) => {
120
+ const url = typeof value.url === "string" ? value.url : ""
121
+ const record = recordFromGitHubUrl(url)
122
+ const state = String(value.state ?? "OPEN").toLowerCase()
123
+ return {
124
+ ...record,
125
+ state:
126
+ state === "merged" ? "merged" : state === "closed" ? "closed" : "open",
127
+ draft: value.isDraft === true,
128
+ merged: state === "merged" || value.mergedAt != null,
129
+ } satisfies PullRequestRecord
130
+ }
131
+
132
+ export const normalizePullRequestRecord = (
133
+ record: PullRequestRecord | string,
134
+ ): PullRequestRecord =>
135
+ typeof record === "string" ? recordFromGitHubUrl(record) : record
@@ -0,0 +1,51 @@
1
+ import type { Dependency } from "./schemas"
2
+
3
+ export const findDependencyCycles = (
4
+ nodes: readonly Dependency[],
5
+ ): readonly string[] => {
6
+ const dependencies = new Map(
7
+ nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
8
+ )
9
+ const visiting = new Set<string>()
10
+ const visited = new Set<string>()
11
+ const cycles = new Set<string>()
12
+
13
+ const visit = (id: string) => {
14
+ if (visiting.has(id)) {
15
+ cycles.add(id)
16
+ return
17
+ }
18
+ if (visited.has(id)) return
19
+
20
+ visiting.add(id)
21
+ for (const dependency of dependencies.get(id) ?? []) {
22
+ if (dependencies.has(dependency)) visit(dependency)
23
+ }
24
+ visiting.delete(id)
25
+ visited.add(id)
26
+ }
27
+
28
+ for (const id of dependencies.keys()) visit(id)
29
+ return [...cycles].sort()
30
+ }
31
+
32
+ export const validateDependencies = (
33
+ nodes: readonly Dependency[],
34
+ label: string,
35
+ ): string | undefined => {
36
+ const singular = label.endsWith("s") ? label.slice(0, -1) : label
37
+ const ids = new Set(nodes.map((node) => node.id))
38
+ if (ids.size !== nodes.length) return `${label} IDs must be unique`
39
+ for (const node of nodes) {
40
+ for (const dependency of node.dependsOn ?? []) {
41
+ if (dependency === node.id) {
42
+ return `${singular} '${node.id}' cannot depend on itself`
43
+ }
44
+ if (!ids.has(dependency)) {
45
+ return `Unknown ${singular.toLowerCase()} dependency '${dependency}'`
46
+ }
47
+ }
48
+ }
49
+ const cycle = findDependencyCycles(nodes)[0]
50
+ return cycle ? `${singular} dependency cycle includes '${cycle}'` : undefined
51
+ }
@@ -104,6 +104,41 @@ describe("runner configuration", () => {
104
104
  })
105
105
  })
106
106
 
107
+ describe("delivery configuration", () => {
108
+ test("accepts an argv-based create and query provider", () => {
109
+ const config = Schema.decodeUnknownSync(WorkbaseConfig)({
110
+ version: 2,
111
+ delivery: {
112
+ provider: "forge",
113
+ remote: "upstream",
114
+ createCommand: ["forge", "create", "{branch}"],
115
+ queryCommand: ["forge", "query", "{identifier}"],
116
+ },
117
+ })
118
+ expect(config.delivery?.remote).toBe("upstream")
119
+ })
120
+
121
+ test("accepts normalized non-GitHub pull request records", () => {
122
+ const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
123
+ repo: "agency",
124
+ branch: "feat/example",
125
+ base: "main",
126
+ pr: {
127
+ provider: "forge",
128
+ repository: "example/agency",
129
+ identifier: "17",
130
+ url: "https://forge.example/example/agency/pulls/17",
131
+ state: "open",
132
+ draft: false,
133
+ merged: false,
134
+ },
135
+ })
136
+ expect(phase.pr && typeof phase.pr !== "string" && phase.pr.provider).toBe(
137
+ "forge",
138
+ )
139
+ })
140
+ })
141
+
107
142
  describe("work status", () => {
108
143
  const supportedStatuses: Record<WorkStatus, true> = {
109
144
  open: true,