@markjaquith/agency 2.21.0 → 2.23.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
  })
@@ -16,6 +16,7 @@ import {
16
16
  parseFrontmatter,
17
17
  } from "../workbase/frontmatter"
18
18
  import { canTransitionStatus } from "../readiness"
19
+ import { documentRevision } from "../workbase/document-revision"
19
20
 
20
21
  class TaskError extends Data.TaggedError("TaskError")<{
21
22
  readonly message: string
@@ -25,6 +26,7 @@ interface TaskRecord {
25
26
  readonly id: string
26
27
  readonly path: string
27
28
  readonly content: string
29
+ readonly revision: string
28
30
  readonly data: TaskData
29
31
  }
30
32
 
@@ -158,7 +160,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
158
160
  yield* fs.writeFile(parentEpic.path, updated)
159
161
  }
160
162
 
161
- return { id, path, content, data } satisfies TaskRecord
163
+ return {
164
+ id,
165
+ path,
166
+ content,
167
+ revision: documentRevision(content),
168
+ data,
169
+ } satisfies TaskRecord
162
170
  }),
163
171
 
164
172
  list: (startPath: string = process.cwd()) =>
@@ -178,7 +186,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
178
186
  const content = yield* fs.readFile(path)
179
187
  const parsed = yield* parseFrontmatter(content, path)
180
188
  const data = yield* decodeTask(parsed.data)
181
- records.push({ id: entry.name, path, content, data })
189
+ records.push({
190
+ id: entry.name,
191
+ path,
192
+ content,
193
+ revision: documentRevision(content),
194
+ data,
195
+ })
182
196
  }
183
197
  return records
184
198
  }),
@@ -233,7 +247,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
233
247
  const data = { ...record.data, status: validStatus }
234
248
  const content = formatMarkdownDocument(data, parsed.body)
235
249
  yield* fs.writeFile(record.path, content)
236
- return { ...record, content, data } satisfies TaskRecord
250
+ return {
251
+ ...record,
252
+ content,
253
+ revision: documentRevision(content),
254
+ data,
255
+ } satisfies TaskRecord
237
256
  }),
238
257
  }),
239
258
  }) {}
@@ -22,6 +22,7 @@ import {
22
22
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
23
23
  import { validateRunners } from "../workbase/runner-command"
24
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
@@ -246,6 +247,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
246
247
  }
247
248
  try {
248
249
  validateRunners(decoded.value.runners)
250
+ validateDelivery(decoded.value.delivery)
249
251
  } catch (cause) {
250
252
  return yield* new WorkbaseConfigError({
251
253
  path: configPath,
package/src/work-view.ts CHANGED
@@ -23,6 +23,7 @@ export interface WorkViewRow {
23
23
  readonly kind: "epic" | "task" | "phase"
24
24
  readonly id: string
25
25
  readonly key: string
26
+ readonly revision: string
26
27
  readonly parent: string
27
28
  readonly status: WorkStatus
28
29
  readonly readiness: "ready" | "blocked" | "waiting" | "terminal"
@@ -91,6 +92,7 @@ const rowFor = (
91
92
  kind: node.kind,
92
93
  id,
93
94
  key: node.key,
95
+ revision: node.data.sha256,
94
96
  parent,
95
97
  status: node.status,
96
98
  readiness: readinessLabel(node),
@@ -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
@@ -1,2 +1,18 @@
1
+ import { Data } from "effect"
2
+
1
3
  export const documentRevision = (content: string) =>
2
4
  new Bun.CryptoHasher("sha256").update(content).digest("hex")
5
+
6
+ export const isDocumentRevision = (revision: string) =>
7
+ /^[a-f0-9]{64}$/.test(revision)
8
+
9
+ export class RevisionConflictError extends Data.TaggedError(
10
+ "RevisionConflictError",
11
+ )<{
12
+ readonly message: string
13
+ readonly path: string
14
+ readonly target?: string
15
+ readonly expectedRevision: string
16
+ readonly currentRevision: string
17
+ readonly claim?: unknown
18
+ }> {}
@@ -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,