@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.
@@ -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"],