@markjaquith/agency 3.2.5 → 3.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.2.5",
3
+ "version": "3.2.6",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -89,16 +89,28 @@ describe("PullRequestService", () => {
89
89
  stdout = "",
90
90
  stderr = "",
91
91
  exitCode = 0,
92
+ listStdouts = ["[]"],
92
93
  }: {
93
94
  stdout?: string
94
95
  stderr?: string
95
96
  exitCode?: number
97
+ listStdouts?: readonly string[]
96
98
  }) => {
97
99
  const path = join(root, "bin", "gh")
98
100
  await Bun.write(
99
101
  path,
100
102
  `#!/usr/bin/env bun
101
- await Bun.write(${JSON.stringify(ghCallPath)}, JSON.stringify({ args: Bun.argv.slice(2), cwd: process.cwd() }))
103
+ const args = Bun.argv.slice(2)
104
+ const callFile = Bun.file(${JSON.stringify(ghCallPath)})
105
+ const calls = await callFile.exists() ? await callFile.json() : []
106
+ const listIndex = calls.filter((call) => call.args[1] === "list").length
107
+ calls.push({ args, cwd: process.cwd() })
108
+ await Bun.write(${JSON.stringify(ghCallPath)}, JSON.stringify(calls))
109
+ if (args[1] === "list") {
110
+ const responses = ${JSON.stringify(listStdouts)}
111
+ process.stdout.write(responses[Math.min(listIndex, responses.length - 1)] ?? "[]")
112
+ process.exit(0)
113
+ }
102
114
  process.stdout.write(${JSON.stringify(stdout)})
103
115
  process.stderr.write(${JSON.stringify(stderr)})
104
116
  process.exit(${exitCode})
@@ -108,10 +120,33 @@ process.exit(${exitCode})
108
120
  }
109
121
 
110
122
  const readGhCall = async () =>
123
+ (
124
+ (await Bun.file(ghCallPath).json()) as {
125
+ args: string[]
126
+ cwd: string
127
+ }[]
128
+ ).at(-1)!
129
+
130
+ const readGhCalls = async () =>
111
131
  (await Bun.file(ghCallPath).json()) as {
112
132
  args: string[]
113
133
  cwd: string
114
- }
134
+ }[]
135
+
136
+ const githubRecord = (
137
+ number: number,
138
+ branch = "task/example",
139
+ base = "main",
140
+ ) => ({
141
+ number,
142
+ url: `https://github.com/example/agency/pull/${number}`,
143
+ state: "OPEN",
144
+ isDraft: false,
145
+ headRefName: branch,
146
+ baseRefName: base,
147
+ headRepository: { nameWithOwner: "example/agency" },
148
+ mergeable: "UNKNOWN",
149
+ })
115
150
 
116
151
  const expectRemoteBranch = async (branch: string, exists = true) => {
117
152
  const result = await runCommand([
@@ -233,7 +268,17 @@ process.exit(${exitCode})
233
268
  await expectRemoteBranch("task/example")
234
269
  const ghCall = await readGhCall()
235
270
  expect(ghCall).toEqual({
236
- args: ["pr", "create", "--fill", "--base", "main"],
271
+ args: [
272
+ "pr",
273
+ "create",
274
+ "--fill",
275
+ "--repo",
276
+ remotePath.replace(/\.git$/, ""),
277
+ "--base",
278
+ "main",
279
+ "--head",
280
+ "task/example",
281
+ ],
237
282
  cwd: await realpath(join(root, "tasks", "example", "code", "agency")),
238
283
  })
239
284
  const updated = await Bun.file(taskPath).text()
@@ -254,8 +299,12 @@ process.exit(${exitCode})
254
299
  "pr",
255
300
  "create",
256
301
  "--fill",
302
+ "--repo",
303
+ remotePath.replace(/\.git$/, ""),
257
304
  "--base",
258
305
  "main",
306
+ "--head",
307
+ "task/example",
259
308
  "--draft",
260
309
  ])
261
310
  })
@@ -279,6 +328,8 @@ process.exit(${exitCode})
279
328
  "--fill",
280
329
  "--title",
281
330
  "Ship the workflow",
331
+ "--repo",
332
+ remotePath.replace(/\.git$/, ""),
282
333
  "--base",
283
334
  "main",
284
335
  "--head",
@@ -291,6 +342,94 @@ process.exit(${exitCode})
291
342
  ])
292
343
  })
293
344
 
345
+ test("records an existing matching GitHub PR without creating another", async () => {
346
+ await createTask()
347
+ const existing = githubRecord(46)
348
+ await writeFakeGh({ listStdouts: [JSON.stringify([existing])] })
349
+
350
+ expect(await createPullRequest()).toBe(existing.url)
351
+ const calls = await readGhCalls()
352
+ expect(calls).toHaveLength(1)
353
+ expect(calls[0]!.args).toEqual([
354
+ "pr",
355
+ "list",
356
+ "--repo",
357
+ remotePath.replace(/\.git$/, ""),
358
+ "--head",
359
+ "task/example",
360
+ "--base",
361
+ "main",
362
+ "--state",
363
+ "all",
364
+ "--json",
365
+ "number,state,isDraft,headRefName,baseRefName,headRepository,url,mergedAt,mergeable",
366
+ ])
367
+ })
368
+
369
+ test("recovers a matching GitHub PR after an ambiguous create failure", async () => {
370
+ await createTask()
371
+ const recovered = githubRecord(47)
372
+ await writeFakeGh({
373
+ stderr: "a pull request already exists",
374
+ exitCode: 1,
375
+ listStdouts: ["[]", JSON.stringify([recovered])],
376
+ })
377
+
378
+ expect(await createPullRequest()).toBe(recovered.url)
379
+ expect((await readGhCalls()).map((call) => call.args[1])).toEqual([
380
+ "list",
381
+ "create",
382
+ "list",
383
+ ])
384
+ const task = await runTestEffect(
385
+ TaskService.pipe(
386
+ Effect.flatMap((service) => service.show("example", root)),
387
+ ),
388
+ )
389
+ expect("pr" in task.data && task.data.pr).toMatchObject({
390
+ url: recovered.url,
391
+ headBranch: "task/example",
392
+ baseBranch: "main",
393
+ })
394
+ })
395
+
396
+ test("recovers a matching GitHub PR after create output is lost", async () => {
397
+ await createTask()
398
+ const recovered = githubRecord(48)
399
+ await writeFakeGh({
400
+ stdout: "Pull request created successfully",
401
+ listStdouts: ["[]", JSON.stringify([recovered])],
402
+ })
403
+
404
+ expect(await createPullRequest()).toBe(recovered.url)
405
+ expect((await readGhCalls()).map((call) => call.args[1])).toEqual([
406
+ "list",
407
+ "create",
408
+ "list",
409
+ ])
410
+ })
411
+
412
+ test("does not adopt a GitHub PR with mismatched refs", async () => {
413
+ await createTask()
414
+ await writeFakeGh({
415
+ stdout: "Pull request created successfully",
416
+ listStdouts: [
417
+ JSON.stringify([githubRecord(49, "task/other")]),
418
+ JSON.stringify([githubRecord(49, "task/other")]),
419
+ ],
420
+ })
421
+
422
+ await expect(createPullRequest()).rejects.toThrow(
423
+ "GitHub CLI did not return a pull request URL",
424
+ )
425
+ const task = await runTestEffect(
426
+ TaskService.pipe(
427
+ Effect.flatMap((service) => service.show("example", root)),
428
+ ),
429
+ )
430
+ expect("pr" in task.data && task.data.pr).toBeNull()
431
+ })
432
+
294
433
  test("rejects task-aware head and base values that contradict declarations", async () => {
295
434
  await createTask()
296
435
  await expect(
@@ -15,12 +15,16 @@ import type { PullRequestRecord } from "../workbase/schemas"
15
15
  import {
16
16
  normalizePullRequestRecord,
17
17
  parsePullRequestRecord,
18
+ recordFromGitHubJson,
18
19
  recordFromGitHubUrl,
19
20
  repositoryFromRemote,
20
21
  resolveDeliveryCommand,
21
22
  resolveGitHubCreateCommand,
22
23
  } from "../workbase/delivery-command"
23
24
 
25
+ const GITHUB_PR_FIELDS =
26
+ "number,state,isDraft,headRefName,baseRefName,headRepository,url,mergedAt,mergeable"
27
+
24
28
  class PullRequestError extends Data.TaggedError("PullRequestError")<{
25
29
  readonly message: string
26
30
  }> {}
@@ -205,6 +209,84 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
205
209
  })
206
210
  }
207
211
  const repository = repositoryFromRemote(remoteUrl)
212
+ const isGitHubRepository = /^[^/]+\/[^/]+$/.test(repository)
213
+ const discoverGitHubPullRequest = () =>
214
+ Effect.gen(function* () {
215
+ const listed = yield* fs.runCommand(
216
+ [
217
+ "gh",
218
+ "pr",
219
+ "list",
220
+ "--repo",
221
+ repository,
222
+ "--head",
223
+ execution.branch,
224
+ "--base",
225
+ execution.base,
226
+ "--state",
227
+ "all",
228
+ "--json",
229
+ GITHUB_PR_FIELDS,
230
+ ],
231
+ { cwd: workspace.writablePath!, captureOutput: true },
232
+ )
233
+ if (listed.exitCode !== 0) {
234
+ return yield* new PullRequestError({
235
+ message: `Failed to discover existing pull requests: ${listed.stderr}`,
236
+ })
237
+ }
238
+ const records = yield* Effect.try({
239
+ try: () => {
240
+ const parsed: unknown = JSON.parse(listed.stdout)
241
+ if (!Array.isArray(parsed)) {
242
+ throw new Error(
243
+ "GitHub CLI did not return a pull request list",
244
+ )
245
+ }
246
+ return parsed.map((value) =>
247
+ recordFromGitHubJson(value as Record<string, unknown>),
248
+ )
249
+ },
250
+ catch: (cause) =>
251
+ new PullRequestError({
252
+ message:
253
+ cause instanceof Error ? cause.message : String(cause),
254
+ }),
255
+ })
256
+ const matches = records.filter(
257
+ (record) =>
258
+ (!isGitHubRepository ||
259
+ (record.repository.toLowerCase() ===
260
+ repository.toLowerCase() &&
261
+ record.headRepository?.toLowerCase() ===
262
+ repository.toLowerCase() &&
263
+ record.baseRepository?.toLowerCase() ===
264
+ repository.toLowerCase())) &&
265
+ record.headBranch === execution.branch &&
266
+ record.baseBranch === execution.base,
267
+ )
268
+ if (matches.length > 1) {
269
+ return yield* new PullRequestError({
270
+ message: `Multiple pull requests match '${execution.branch}' -> '${execution.base}'`,
271
+ })
272
+ }
273
+ return matches[0] ?? null
274
+ })
275
+ const recoverGitHubPullRequest = () =>
276
+ discoverGitHubPullRequest().pipe(
277
+ Effect.catchAll(() => Effect.succeed(null)),
278
+ )
279
+ if (!config.delivery) {
280
+ const existing = yield* discoverGitHubPullRequest()
281
+ if (existing) {
282
+ return yield* service.setRecord(
283
+ taskId,
284
+ phaseId,
285
+ existing,
286
+ workspace.root,
287
+ )
288
+ }
289
+ }
208
290
  const resolved = config.delivery
209
291
  ? resolveDeliveryCommand(config.delivery, "create", {
210
292
  repository,
@@ -215,10 +297,11 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
215
297
  identifier: "",
216
298
  })
217
299
  : resolveGitHubCreateCommand({
300
+ repository,
218
301
  base: execution.base,
219
302
  draft,
220
303
  title: options.title,
221
- head: options.head,
304
+ head: execution.branch,
222
305
  labels: options.labels,
223
306
  })
224
307
  const created = yield* fs.runCommand(resolved.argv, {
@@ -227,38 +310,79 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
227
310
  env: resolved.environment,
228
311
  })
229
312
  if (created.exitCode !== 0) {
313
+ if (!config.delivery) {
314
+ const recovered = yield* recoverGitHubPullRequest()
315
+ if (recovered) {
316
+ return yield* service.setRecord(
317
+ taskId,
318
+ phaseId,
319
+ recovered,
320
+ workspace.root,
321
+ )
322
+ }
323
+ }
230
324
  return yield* new PullRequestError({
231
325
  message: `Failed to create pull request: ${created.stderr}`,
232
326
  })
233
327
  }
234
- const record = yield* Effect.try({
235
- try: () => {
236
- if (config.delivery) return parsePullRequestRecord(created.stdout)
237
- const url = created.stdout.split(/\s+/).find((value) => {
238
- try {
239
- recordFromGitHubUrl(value)
240
- return true
241
- } catch {
242
- return false
328
+ const parsedRecord = yield* Effect.either(
329
+ Effect.try({
330
+ try: () => {
331
+ if (config.delivery)
332
+ return parsePullRequestRecord(created.stdout)
333
+ const url = created.stdout.split(/\s+/).find((value) => {
334
+ try {
335
+ recordFromGitHubUrl(value)
336
+ return true
337
+ } catch {
338
+ return false
339
+ }
340
+ })
341
+ if (!url)
342
+ throw new Error(
343
+ "GitHub CLI did not return a pull request URL",
344
+ )
345
+ const normalized = normalizePullRequestRecord(url)
346
+ if (
347
+ isGitHubRepository &&
348
+ normalized.repository.toLowerCase() !==
349
+ repository.toLowerCase()
350
+ ) {
351
+ throw new Error(
352
+ "GitHub CLI returned a pull request for the wrong repository",
353
+ )
243
354
  }
244
- })
245
- if (!url)
246
- throw new Error("GitHub CLI did not return a pull request URL")
247
- const normalized = normalizePullRequestRecord(url)
248
- return {
249
- ...normalized,
250
- headRepository: repository,
251
- headBranch: execution.branch,
252
- baseRepository: normalized.repository,
253
- baseBranch: execution.base,
254
- draft,
355
+ return {
356
+ ...normalized,
357
+ headRepository: repository,
358
+ headBranch: execution.branch,
359
+ baseRepository: normalized.repository,
360
+ baseBranch: execution.base,
361
+ draft,
362
+ }
363
+ },
364
+ catch: (cause) =>
365
+ new PullRequestError({
366
+ message:
367
+ cause instanceof Error ? cause.message : String(cause),
368
+ }),
369
+ }),
370
+ )
371
+ if (parsedRecord._tag === "Left") {
372
+ if (!config.delivery) {
373
+ const recovered = yield* recoverGitHubPullRequest()
374
+ if (recovered) {
375
+ return yield* service.setRecord(
376
+ taskId,
377
+ phaseId,
378
+ recovered,
379
+ workspace.root,
380
+ )
255
381
  }
256
- },
257
- catch: (cause) =>
258
- new PullRequestError({
259
- message: cause instanceof Error ? cause.message : String(cause),
260
- }),
261
- })
382
+ }
383
+ return yield* parsedRecord.left
384
+ }
385
+ const record = parsedRecord.right
262
386
  if (
263
387
  config.delivery &&
264
388
  (record.provider !== config.delivery.provider ||
@@ -34,24 +34,19 @@ describe("delivery commands", () => {
34
34
 
35
35
  test("builds the default GitHub create command", () => {
36
36
  const input = {
37
+ repository: "example/agency",
37
38
  base: "main",
38
39
  draft: false,
40
+ head: "feat/example",
39
41
  } as const
40
42
  expect(resolveGitHubCreateCommand(input)).toEqual({
41
- argv: ["gh", "pr", "create", "--fill", "--base", "main"],
42
- environment: {},
43
- })
44
- expect(
45
- resolveGitHubCreateCommand({
46
- ...input,
47
- head: "feat/example",
48
- }),
49
- ).toEqual({
50
43
  argv: [
51
44
  "gh",
52
45
  "pr",
53
46
  "create",
54
47
  "--fill",
48
+ "--repo",
49
+ "example/agency",
55
50
  "--base",
56
51
  "main",
57
52
  "--head",
@@ -74,6 +69,8 @@ describe("delivery commands", () => {
74
69
  "--fill",
75
70
  "--title",
76
71
  "Requested",
72
+ "--repo",
73
+ "example/agency",
77
74
  "--base",
78
75
  "main",
79
76
  "--head",
@@ -19,16 +19,18 @@ export const repositoryFromRemote = (remote: string) =>
19
19
  .replace(/\/$/, "")
20
20
 
21
21
  export const resolveGitHubCreateCommand = ({
22
+ repository,
22
23
  base,
23
24
  draft,
24
25
  title,
25
26
  head,
26
27
  labels = [],
27
28
  }: {
29
+ readonly repository: string
28
30
  readonly base: string
29
31
  readonly draft: boolean
30
32
  readonly title?: string
31
- readonly head?: string
33
+ readonly head: string
32
34
  readonly labels?: readonly string[]
33
35
  }) => ({
34
36
  argv: [
@@ -36,9 +38,12 @@ export const resolveGitHubCreateCommand = ({
36
38
  "pr",
37
39
  "create",
38
40
  ...(title ? ["--fill", "--title", title] : ["--fill"]),
41
+ "--repo",
42
+ repository,
39
43
  "--base",
40
44
  base,
41
- ...(head ? ["--head", head] : []),
45
+ "--head",
46
+ head,
42
47
  ...(draft ? ["--draft"] : []),
43
48
  ...labels.flatMap((label) => ["--label", label]),
44
49
  ],