@markjaquith/agency 2.21.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.21.0",
3
+ "version": "2.22.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -33,8 +33,8 @@ export const pr = (options: PrOptions) =>
33
33
  export const help = `
34
34
  Usage: agency pr create <task-id> [phase-id]
35
35
 
36
- Push the execution branch, create a GitHub pull request, and update its
37
- task or phase document.
36
+ Push the execution branch, create a pull request with the delivery provider,
37
+ and update its task or phase document.
38
38
 
39
39
  Options:
40
40
  --draft Create a draft pull request
@@ -2,6 +2,7 @@ import { Schema } from "@effect/schema"
2
2
  import {
3
3
  EpicFrontmatter,
4
4
  PhaseFrontmatter,
5
+ PullRequestRecord,
5
6
  TaskFrontmatter,
6
7
  WorkStatus,
7
8
  } from "./workbase/schemas"
@@ -109,16 +110,7 @@ export const GraphExecutionGit = Schema.Struct({
109
110
  export const GraphPr = Schema.Union(
110
111
  Schema.Struct({ url: Schema.Null, state: Schema.Literal("none") }),
111
112
  Schema.Struct({ url: Schema.String, state: Schema.Literal("unavailable") }),
112
- Schema.Struct({
113
- recordedUrl: Schema.String,
114
- number: Schema.Number,
115
- state: Schema.String,
116
- title: Schema.String,
117
- isDraft: Schema.Boolean,
118
- headRefName: Schema.String,
119
- baseRefName: Schema.String,
120
- url: Schema.String,
121
- }),
113
+ PullRequestRecord,
122
114
  )
123
115
 
124
116
  const NodeIdentity = {
@@ -23,6 +23,7 @@ import {
23
23
  PhaseFrontmatter,
24
24
  TaskFrontmatter,
25
25
  type ClaimRecord,
26
+ type PullRequestRecord,
26
27
  type PhaseFrontmatter as PhaseData,
27
28
  type TaskFrontmatter as TaskData,
28
29
  } from "../workbase/schemas"
@@ -98,7 +99,7 @@ interface ReconcileInput {
98
99
  readonly taskId: string
99
100
  readonly phaseId?: string
100
101
  readonly revision: string
101
- readonly pr?: string
102
+ readonly pr?: string | PullRequestRecord
102
103
  readonly status?: "done"
103
104
  }
104
105
 
@@ -341,7 +342,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
341
342
 
342
343
  reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
343
344
  Effect.gen(function* () {
344
- if (input.pr !== undefined && !PR_URL.test(input.pr)) {
345
+ if (
346
+ typeof input.pr === "string" &&
347
+ input.pr !== undefined &&
348
+ !PR_URL.test(input.pr)
349
+ ) {
345
350
  return yield* new ClaimError({
346
351
  message: `Invalid GitHub pull request URL: ${input.pr}`,
347
352
  })
@@ -3,6 +3,7 @@ import { Data, Effect, Either } from "effect"
3
3
  import { join, relative, resolve, sep } from "node:path"
4
4
  import { FileSystemService } from "./FileSystemService"
5
5
  import { WorkbaseService } from "./WorkbaseService"
6
+ import { normalizePullRequestRecord } from "../workbase/delivery-command"
6
7
  import { RepositoryService } from "./RepositoryService"
7
8
  import { aggregateProgress, readinessState } from "../readiness"
8
9
  import { parseFrontmatter } from "../workbase/frontmatter"
@@ -857,10 +858,9 @@ export class ContextService extends Effect.Service<ContextService>()(
857
858
  references: referenceCheckouts,
858
859
  warnings: inspectionWarnings,
859
860
  },
860
- pr: {
861
- url: executionData?.pr ?? null,
862
- state: executionData?.pr ? "recorded" : "none",
863
- },
861
+ pr: executionData?.pr
862
+ ? normalizePullRequestRecord(executionData.pr)
863
+ : { url: null, state: "none" },
864
864
  validation: {
865
865
  valid: validation.valid,
866
866
  warnings: validation.issues,
@@ -20,6 +20,7 @@ import {
20
20
  readinessState,
21
21
  } from "../readiness"
22
22
  import { parseFrontmatter } from "../workbase/frontmatter"
23
+ import { normalizePullRequestRecord } from "../workbase/delivery-command"
23
24
  import {
24
25
  EpicFrontmatter,
25
26
  PhaseFrontmatter,
@@ -683,17 +684,7 @@ export class GraphService extends Effect.Service<GraphService>()(
683
684
  if (!data.pr) {
684
685
  result.pr = { url: null, state: "none" }
685
686
  } else {
686
- const detail = yield* run(fs, [
687
- "gh",
688
- "pr",
689
- "view",
690
- data.pr,
691
- "--json",
692
- "number,state,title,isDraft,headRefName,baseRefName,url",
693
- ])
694
- result.pr = detail
695
- ? { ...JSON.parse(detail), recordedUrl: data.pr }
696
- : { url: data.pr, state: "unavailable" }
687
+ result.pr = normalizePullRequestRecord(data.pr)
697
688
  }
698
689
  }
699
690
  return result
@@ -187,7 +187,12 @@ process.exit(${exitCode})
187
187
  Effect.flatMap((service) => service.show("example", root)),
188
188
  ),
189
189
  )
190
- expect("pr" in task.data && task.data.pr).toBe(url)
190
+ expect("pr" in task.data && task.data.pr).toMatchObject({
191
+ provider: "github",
192
+ repository: "markjaquith/agency",
193
+ identifier: "123",
194
+ url,
195
+ })
191
196
  })
192
197
 
193
198
  test("rejects non-GitHub PR URLs", async () => {
@@ -228,7 +233,8 @@ process.exit(${exitCode})
228
233
  cwd: await realpath(join(root, "tasks", "example", "code", "agency")),
229
234
  })
230
235
  const updated = await Bun.file(taskPath).text()
231
- expect(updated).toContain("pr: https://github.com/example/agency/pull/42")
236
+ expect(updated).toContain("pr:\n provider: github")
237
+ expect(updated).toContain("url: https://github.com/example/agency/pull/42")
232
238
  expect(updated.endsWith(`${body}\n`)).toBe(true)
233
239
  })
234
240
 
@@ -315,7 +321,7 @@ process.exit(${exitCode})
315
321
  await createPullRequest("multi", "implementation")
316
322
 
317
323
  expect(await Bun.file(taskPath).text()).toBe(originalTask)
318
- expect(await Bun.file(phasePath).text()).toContain(`pr: ${url}`)
324
+ expect(await Bun.file(phasePath).text()).toContain(`url: ${url}`)
319
325
  await expectRemoteBranch("task/multi-implementation")
320
326
  })
321
327
 
@@ -400,7 +406,88 @@ process.exit(${exitCode})
400
406
  Effect.flatMap((service) => service.show("example", root)),
401
407
  ),
402
408
  )
403
- expect("pr" in task.data && task.data.pr).toBe(url)
409
+ expect("pr" in task.data && task.data.pr).toMatchObject({ url })
410
+ })
411
+
412
+ test("uses a configured remote and argv provider", async () => {
413
+ await createTask()
414
+ await requireCommand(
415
+ ["git", "remote", "rename", "origin", "upstream"],
416
+ join(root, "repos", "agency"),
417
+ )
418
+ const callPath = join(root, "delivery-call.json")
419
+ const providerRecord = {
420
+ provider: "forge",
421
+ repository: remotePath.replace(/\.git$/, ""),
422
+ identifier: "17",
423
+ url: "https://forge.example/example/agency/pulls/17",
424
+ state: "open",
425
+ draft: false,
426
+ merged: false,
427
+ } as const
428
+ await Bun.write(
429
+ join(root, "bin", "deliver"),
430
+ `#!/usr/bin/env bun
431
+ await Bun.write(${JSON.stringify(callPath)}, JSON.stringify({ args: Bun.argv.slice(2), base: process.env.DELIVERY_BASE }))
432
+ process.stdout.write(${JSON.stringify(JSON.stringify(providerRecord))})
433
+ `,
434
+ )
435
+ await chmod(join(root, "bin", "deliver"), 0o755)
436
+ await Bun.write(
437
+ join(root, "agency.json"),
438
+ JSON.stringify({
439
+ version: 2,
440
+ delivery: {
441
+ provider: "forge",
442
+ remote: "upstream",
443
+ createCommand: ["deliver", "create", "{repository}", "{branch}"],
444
+ queryCommand: ["deliver", "query", "{identifier}"],
445
+ environment: { DELIVERY_BASE: "{base}" },
446
+ },
447
+ }),
448
+ )
449
+
450
+ expect(await createPullRequest()).toBe(providerRecord.url)
451
+ expect(await Bun.file(callPath).json()).toEqual({
452
+ args: ["create", remotePath.replace(/\.git$/, ""), "task/example"],
453
+ base: "main",
454
+ })
455
+ const task = await runTestEffect(
456
+ TaskService.pipe(
457
+ Effect.flatMap((service) => service.show("example", root)),
458
+ ),
459
+ )
460
+ expect("pr" in task.data && task.data.pr).toEqual(providerRecord)
461
+ })
462
+
463
+ test("does not persist malformed provider output", async () => {
464
+ await createTask()
465
+ await Bun.write(
466
+ join(root, "bin", "deliver"),
467
+ "#!/usr/bin/env bun\nprocess.stdout.write('{}')\n",
468
+ )
469
+ await chmod(join(root, "bin", "deliver"), 0o755)
470
+ await Bun.write(
471
+ join(root, "agency.json"),
472
+ JSON.stringify({
473
+ version: 2,
474
+ delivery: {
475
+ provider: "forge",
476
+ createCommand: ["deliver", "create"],
477
+ queryCommand: ["deliver", "query"],
478
+ },
479
+ }),
480
+ )
481
+
482
+ await expect(createPullRequest()).rejects.toThrow(
483
+ "Delivery provider did not return a valid pull request record",
484
+ )
485
+ const task = await runTestEffect(
486
+ TaskService.pipe(
487
+ Effect.flatMap((service) => service.show("example", root)),
488
+ ),
489
+ )
490
+ expect("pr" in task.data && task.data.pr).toBeNull()
404
491
  })
405
492
 
406
493
  test("reports git status failure before push or gh", async () => {
@@ -10,12 +10,24 @@ import {
10
10
  formatMarkdownDocument,
11
11
  parseFrontmatter,
12
12
  } from "../workbase/frontmatter"
13
+ import type { PullRequestRecord } from "../workbase/schemas"
14
+ import {
15
+ normalizePullRequestRecord,
16
+ parsePullRequestRecord,
17
+ recordFromGitHubUrl,
18
+ resolveDeliveryCommand,
19
+ } from "../workbase/delivery-command"
13
20
 
14
21
  class PullRequestError extends Data.TaggedError("PullRequestError")<{
15
22
  readonly message: string
16
23
  }> {}
17
24
 
18
- const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
25
+ const repositoryFromRemote = (remote: string) =>
26
+ remote
27
+ .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
28
+ .replace(/^[^:]+:/, "")
29
+ .replace(/\.git\/?$/, "")
30
+ .replace(/\/$/, "")
19
31
 
20
32
  interface PullRequestOptions extends BaseCommandOptions {
21
33
  readonly force?: boolean
@@ -25,25 +37,20 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
25
37
  "PullRequestService",
26
38
  {
27
39
  sync: () => ({
28
- setUrl: (
40
+ setRecord: (
29
41
  taskId: string,
30
42
  phaseId: string | undefined,
31
- url: string,
43
+ record: PullRequestRecord,
32
44
  startPath: string = process.cwd(),
33
45
  ) =>
34
46
  Effect.gen(function* () {
35
- if (!PR_URL.test(url)) {
36
- return yield* new PullRequestError({
37
- message: `Invalid GitHub pull request URL: ${url}`,
38
- })
39
- }
40
47
  const fs = yield* FileSystemService
41
48
  const workbase = yield* WorkbaseService
42
49
  const tasks = yield* TaskService
43
50
  const phases = yield* PhaseService
44
51
  const root = yield* workbase.discover(startPath)
45
52
  const task = yield* tasks.show(taskId, root)
46
- const record =
53
+ const target =
47
54
  "phases" in task.data
48
55
  ? phaseId
49
56
  ? yield* phases.show(taskId, phaseId, root)
@@ -51,13 +58,30 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
51
58
  message: `Task '${taskId}' requires a phase ID`,
52
59
  })
53
60
  : task
54
- const parsed = yield* parseFrontmatter(record.content, record.path)
55
- const data = { ...record.data, pr: url }
61
+ const parsed = yield* parseFrontmatter(target.content, target.path)
56
62
  yield* fs.writeFile(
57
- record.path,
58
- formatMarkdownDocument(data, parsed.body),
63
+ target.path,
64
+ formatMarkdownDocument({ ...target.data, pr: record }, parsed.body),
59
65
  )
60
- return url
66
+ return record.url
67
+ }),
68
+
69
+ setUrl: (
70
+ taskId: string,
71
+ phaseId: string | undefined,
72
+ url: string,
73
+ startPath: string = process.cwd(),
74
+ ) =>
75
+ Effect.gen(function* () {
76
+ const service = yield* PullRequestService
77
+ const record = yield* Effect.try({
78
+ try: () => recordFromGitHubUrl(url),
79
+ catch: (cause) =>
80
+ new PullRequestError({
81
+ message: cause instanceof Error ? cause.message : String(cause),
82
+ }),
83
+ })
84
+ return yield* service.setRecord(taskId, phaseId, record, startPath)
61
85
  }),
62
86
 
63
87
  create: (
@@ -74,6 +98,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
74
98
  const phases = yield* PhaseService
75
99
  const worktrees = yield* WorktreeService
76
100
  const readiness = yield* ReadinessService
101
+ const workbase = yield* WorkbaseService
77
102
  yield* readiness.guard(
78
103
  "pr",
79
104
  taskId,
@@ -92,6 +117,8 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
92
117
  "phases" in task.data
93
118
  ? (yield* phases.show(taskId, phaseId!, workspace.root)).data
94
119
  : task.data
120
+ const { config } = yield* workbase.loadConfig(workspace.root)
121
+ const remote = config.delivery?.remote ?? "origin"
95
122
 
96
123
  const status = yield* fs.runCommand(
97
124
  ["git", "-C", workspace.writablePath, "status", "--porcelain"],
@@ -115,7 +142,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
115
142
  workspace.writablePath,
116
143
  "push",
117
144
  "--set-upstream",
118
- "origin",
145
+ remote,
119
146
  execution.branch,
120
147
  ],
121
148
  { captureOutput: true },
@@ -126,33 +153,83 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
126
153
  })
127
154
  }
128
155
 
129
- const args = [
130
- "gh",
131
- "pr",
132
- "create",
133
- "--fill",
134
- "--base",
135
- execution.base,
136
- ]
137
- if (draft) args.push("--draft")
138
- const created = yield* fs.runCommand(args, {
156
+ const remoteResult = yield* fs.runCommand(
157
+ ["git", "-C", workspace.writablePath, "remote", "get-url", remote],
158
+ { captureOutput: true },
159
+ )
160
+ if (remoteResult.exitCode !== 0) {
161
+ return yield* new PullRequestError({
162
+ message: `Failed to inspect delivery remote '${remote}': ${remoteResult.stderr}`,
163
+ })
164
+ }
165
+ const repository = repositoryFromRemote(remoteResult.stdout.trim())
166
+ const resolved = config.delivery
167
+ ? resolveDeliveryCommand(config.delivery, "create", {
168
+ repository,
169
+ branch: execution.branch,
170
+ base: execution.base,
171
+ draft: String(draft),
172
+ url: "",
173
+ identifier: "",
174
+ })
175
+ : {
176
+ argv: [
177
+ "gh",
178
+ "pr",
179
+ "create",
180
+ "--fill",
181
+ "--base",
182
+ execution.base,
183
+ ...(draft ? ["--draft"] : []),
184
+ ],
185
+ environment: {},
186
+ }
187
+ const created = yield* fs.runCommand(resolved.argv, {
139
188
  cwd: workspace.writablePath,
140
189
  captureOutput: true,
190
+ env: resolved.environment,
141
191
  })
142
192
  if (created.exitCode !== 0) {
143
193
  return yield* new PullRequestError({
144
194
  message: `Failed to create pull request: ${created.stderr}`,
145
195
  })
146
196
  }
147
- const url = created.stdout
148
- .split(/\s+/)
149
- .find((value) => PR_URL.test(value))
150
- if (!url) {
197
+ const record = yield* Effect.try({
198
+ try: () => {
199
+ if (config.delivery) return parsePullRequestRecord(created.stdout)
200
+ const url = created.stdout.split(/\s+/).find((value) => {
201
+ try {
202
+ recordFromGitHubUrl(value)
203
+ return true
204
+ } catch {
205
+ return false
206
+ }
207
+ })
208
+ if (!url)
209
+ throw new Error("GitHub CLI did not return a pull request URL")
210
+ return { ...normalizePullRequestRecord(url), draft }
211
+ },
212
+ catch: (cause) =>
213
+ new PullRequestError({
214
+ message: cause instanceof Error ? cause.message : String(cause),
215
+ }),
216
+ })
217
+ if (
218
+ config.delivery &&
219
+ (record.provider !== config.delivery.provider ||
220
+ record.repository.toLowerCase() !== repository.toLowerCase())
221
+ ) {
151
222
  return yield* new PullRequestError({
152
- message: "GitHub CLI did not return a pull request URL",
223
+ message:
224
+ "Delivery provider returned a record for the wrong provider or repository",
153
225
  })
154
226
  }
155
- return yield* service.setUrl(taskId, phaseId, url, workspace.root)
227
+ return yield* service.setRecord(
228
+ taskId,
229
+ phaseId,
230
+ record,
231
+ workspace.root,
232
+ )
156
233
  }),
157
234
  }),
158
235
  },
@@ -176,11 +176,105 @@ JSON
176
176
  )
177
177
  expect(task.data).toMatchObject({
178
178
  status: "done",
179
- pr: "https://github.com/example/agency/pull/42",
179
+ pr: {
180
+ provider: "github",
181
+ repository: "example/agency",
182
+ identifier: "42",
183
+ url: "https://github.com/example/agency/pull/42",
184
+ state: "merged",
185
+ draft: false,
186
+ merged: true,
187
+ },
180
188
  claim: { state: "released", sessionId: "session-1" },
181
189
  })
182
190
  })
183
191
 
192
+ test("queries and records a configured delivery provider", async () => {
193
+ await runTestEffect(
194
+ TaskService.pipe(
195
+ Effect.flatMap((service) =>
196
+ service.create(
197
+ {
198
+ id: "custom",
199
+ ticketUrl: null,
200
+ repo: "agency",
201
+ branch: "feat/custom",
202
+ base: "main",
203
+ },
204
+ root,
205
+ ),
206
+ ),
207
+ ),
208
+ )
209
+ await runTestEffect(
210
+ WorktreeService.pipe(
211
+ Effect.flatMap((service) =>
212
+ service.materialize("custom", undefined, root),
213
+ ),
214
+ ),
215
+ )
216
+ await git(
217
+ [
218
+ "remote",
219
+ "set-url",
220
+ "origin",
221
+ "https://forge.example/example/agency.git",
222
+ ],
223
+ join(root, "repos/agency"),
224
+ )
225
+ const callPath = join(root, "query-call.json")
226
+ const record = {
227
+ provider: "forge",
228
+ repository: "example/agency",
229
+ identifier: "17",
230
+ url: "https://forge.example/example/agency/pulls/17",
231
+ state: "open",
232
+ draft: false,
233
+ merged: false,
234
+ } as const
235
+ await Bun.write(
236
+ join(root, "bin", "deliver"),
237
+ `#!/usr/bin/env bun
238
+ await Bun.write(${JSON.stringify(callPath)}, JSON.stringify({ args: Bun.argv.slice(2), base: process.env.DELIVERY_BASE }))
239
+ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
240
+ `,
241
+ )
242
+ await chmod(join(root, "bin", "deliver"), 0o755)
243
+ await Bun.write(
244
+ join(root, "agency.json"),
245
+ JSON.stringify({
246
+ version: 2,
247
+ delivery: {
248
+ provider: "forge",
249
+ createCommand: ["deliver", "create"],
250
+ queryCommand: ["deliver", "query", "{repository}", "{branch}"],
251
+ environment: { DELIVERY_BASE: "{base}" },
252
+ },
253
+ }),
254
+ )
255
+
256
+ const result = await runTestEffect(
257
+ SyncService.pipe(
258
+ Effect.flatMap((service) =>
259
+ service.reconcile({ cwd: root, apply: true }),
260
+ ),
261
+ ),
262
+ )
263
+ expect(result.changes).toContainEqual(
264
+ expect.objectContaining({ kind: "record-pr", target: "task:custom" }),
265
+ )
266
+ expect(await Bun.file(callPath).json()).toEqual({
267
+ args: ["query", "example/agency", "feat/custom"],
268
+ base: "main",
269
+ })
270
+ const task = await runTestEffect(
271
+ TaskService.pipe(
272
+ Effect.flatMap((service) => service.show("custom", root)),
273
+ ),
274
+ )
275
+ expect("pr" in task.data && task.data.pr).toEqual(record)
276
+ })
277
+
184
278
  test("materializes missing workspaces but leaves branch conflicts unresolved", async () => {
185
279
  for (const [id, branch] of [
186
280
  ["missing", "feat/missing"],
@@ -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
  })
@@ -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,
@@ -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
@@ -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,
@@ -51,6 +51,26 @@ const GitHubPullRequestUrl = NonEmptyString.pipe(
51
51
  Schema.pattern(/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/),
52
52
  )
53
53
 
54
+ export const PullRequestRecord = Schema.Struct({
55
+ provider: EntityId,
56
+ repository: NonEmptyString,
57
+ identifier: NonEmptyString,
58
+ url: Url,
59
+ state: Schema.Literal("open", "closed", "merged"),
60
+ draft: Schema.Boolean,
61
+ merged: Schema.Boolean,
62
+ })
63
+
64
+ const DeliveryProvider = Schema.Struct({
65
+ provider: EntityId,
66
+ remote: Schema.optional(NonEmptyString),
67
+ createCommand: Schema.NonEmptyArray(NonEmptyString),
68
+ queryCommand: Schema.NonEmptyArray(NonEmptyString),
69
+ environment: Schema.optional(
70
+ Schema.Record({ key: EnvironmentName, value: Schema.String }),
71
+ ),
72
+ })
73
+
54
74
  export const WorkbaseConfig = Schema.Struct({
55
75
  version: Schema.Literal(2),
56
76
  chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
@@ -67,6 +87,7 @@ export const WorkbaseConfig = Schema.Struct({
67
87
  }),
68
88
  }),
69
89
  ),
90
+ delivery: Schema.optional(DeliveryProvider),
70
91
  })
71
92
 
72
93
  export const LegacyWorkbaseRegistry = Schema.Struct({
@@ -96,7 +117,7 @@ const ExecutionUnit = {
96
117
  repos: Schema.optional(Schema.Array(RepositoryReference)),
97
118
  branch: NonEmptyString,
98
119
  base: NonEmptyString,
99
- pr: Schema.NullOr(GitHubPullRequestUrl),
120
+ pr: Schema.NullOr(Schema.Union(GitHubPullRequestUrl, PullRequestRecord)),
100
121
  status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
101
122
  claim: Schema.optional(ClaimRecord),
102
123
  }
@@ -141,6 +162,7 @@ export type Dependency = Schema.Schema.Type<typeof Dependency>
141
162
  export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
142
163
  export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
143
164
  export type ClaimRecord = Schema.Schema.Type<typeof ClaimRecord>
165
+ export type PullRequestRecord = Schema.Schema.Type<typeof PullRequestRecord>
144
166
  export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
145
167
  export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
146
168
  export type PhaseFrontmatter = Schema.Schema.Type<typeof PhaseFrontmatter>