@markjaquith/agency 2.61.0 → 2.61.1

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.61.0",
3
+ "version": "2.61.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli.test.ts CHANGED
@@ -1,4 +1,11 @@
1
- import { afterAll, afterEach, describe, expect, test } from "bun:test"
1
+ import {
2
+ afterAll,
3
+ afterEach,
4
+ describe,
5
+ expect,
6
+ setDefaultTimeout,
7
+ test,
8
+ } from "bun:test"
2
9
  import { access, chmod, mkdir, realpath, stat, symlink } from "node:fs/promises"
3
10
  import { join, sep } from "node:path"
4
11
  import errorFixture from "../fixtures/protocol/error.json"
@@ -9,6 +16,8 @@ const projectRoot = join(import.meta.dir, "..")
9
16
  const cliPath = join(projectRoot, "cli.ts")
10
17
  const isolatedConfigHome = await createTempDir()
11
18
 
19
+ setDefaultTimeout(15_000)
20
+
12
21
  afterAll(() => cleanupTempDir(isolatedConfigHome))
13
22
 
14
23
  interface CliResult {
@@ -239,7 +239,15 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
239
239
  })
240
240
  if (!url)
241
241
  throw new Error("GitHub CLI did not return a pull request URL")
242
- return { ...normalizePullRequestRecord(url), draft }
242
+ const normalized = normalizePullRequestRecord(url)
243
+ return {
244
+ ...normalized,
245
+ headRepository: repository,
246
+ headBranch: execution.branch,
247
+ baseRepository: normalized.repository,
248
+ baseBranch: execution.base,
249
+ draft,
250
+ }
243
251
  },
244
252
  catch: (cause) =>
245
253
  new PullRequestError({
@@ -249,7 +257,8 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
249
257
  if (
250
258
  config.delivery &&
251
259
  (record.provider !== config.delivery.provider ||
252
- record.repository.toLowerCase() !== repository.toLowerCase())
260
+ (record.headRepository ?? record.repository).toLowerCase() !==
261
+ repository.toLowerCase())
253
262
  ) {
254
263
  return yield* new PullRequestError({
255
264
  message:
@@ -66,12 +66,12 @@ case "$*" in
66
66
  esac
67
67
  if [ "$2" = "view" ]; then
68
68
  cat <<'JSON'
69
- {"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}
69
+ {"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}
70
70
  JSON
71
71
  exit 0
72
72
  fi
73
73
  cat <<'JSON'
74
- [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
74
+ [{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"},"mergeable":"MERGEABLE"}]
75
75
  JSON
76
76
  `,
77
77
  )
@@ -307,6 +307,10 @@ pr: null
307
307
  state: "merged",
308
308
  draft: false,
309
309
  merged: true,
310
+ headRepository: "example/agency",
311
+ headBranch: "feat/example",
312
+ baseRepository: "example/agency",
313
+ baseBranch: "main",
310
314
  mergeable: true,
311
315
  },
312
316
  claim: { state: "released", sessionId: "session-1" },
@@ -357,7 +361,7 @@ pr: null
357
361
  join(root, "bin", "gh"),
358
362
  `#!/bin/sh
359
363
  cat <<'JSON'
360
- {"number":42,"state":"OPEN","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":null,"mergeCommit":null,"mergeable":"CONFLICTING"}
364
+ {"number":42,"state":"OPEN","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","headRepository":{"nameWithOwner":"example/agency"},"baseRepository":{"nameWithOwner":"example/agency"},"url":"https://github.com/example/agency/pull/42","mergedAt":null,"mergeCommit":null,"mergeable":"CONFLICTING"}
361
365
  JSON
362
366
  `,
363
367
  )
@@ -747,6 +751,89 @@ process.stdout.write(${JSON.stringify(JSON.stringify(record))})
747
751
  expect(task.data).toMatchObject({ status: "open" })
748
752
  })
749
753
 
754
+ test("reconciles a merged upstream PR whose head is the writable fork", async () => {
755
+ await runTestEffect(
756
+ TaskService.pipe(
757
+ Effect.flatMap((service) =>
758
+ service.create(
759
+ {
760
+ id: "forked",
761
+ ticketUrl: null,
762
+ repo: "agency",
763
+ branch: "task/polish-readme",
764
+ base: "main",
765
+ },
766
+ root,
767
+ ),
768
+ ),
769
+ ),
770
+ )
771
+ await runTestEffect(
772
+ WorktreeService.pipe(
773
+ Effect.flatMap((service) =>
774
+ service.materialize("forked", undefined, root),
775
+ ),
776
+ ),
777
+ )
778
+ await git(
779
+ ["remote", "set-url", "origin", "git@github.com:markjaquith/Pasted.git"],
780
+ join(root, "repos/agency"),
781
+ )
782
+ await runTestEffect(
783
+ PullRequestService.pipe(
784
+ Effect.flatMap((service) =>
785
+ service.setUrl(
786
+ "forked",
787
+ undefined,
788
+ "https://github.com/getpasted/pasted/pull/17",
789
+ root,
790
+ ),
791
+ ),
792
+ ),
793
+ )
794
+ await Bun.write(
795
+ join(root, "bin", "gh"),
796
+ `#!/bin/sh
797
+ cat <<'JSON'
798
+ {"number":17,"state":"MERGED","title":"Polish README","isDraft":false,"headRefName":"task/polish-readme","baseRefName":"main","headRepository":{"nameWithOwner":"markjaquith/Pasted"},"baseRepository":{"nameWithOwner":"getpasted/pasted"},"url":"https://github.com/getpasted/pasted/pull/17","mergedAt":"2026-08-11T00:00:00Z","mergeCommit":{"oid":"241d45da1115ef685c91a5e6882d118c16801550"},"mergeable":"UNKNOWN"}
799
+ JSON
800
+ `,
801
+ )
802
+ await chmod(join(root, "bin", "gh"), 0o755)
803
+
804
+ const applied = await runTestEffect(
805
+ SyncService.pipe(
806
+ Effect.flatMap((service) =>
807
+ service.reconcile({ cwd: root, apply: true }),
808
+ ),
809
+ ),
810
+ )
811
+ expect(
812
+ applied.unresolved.filter((notice) => notice.target === "task:forked"),
813
+ ).toEqual([])
814
+ expect(applied.changes.map((change) => change.kind)).toEqual([
815
+ "record-pr",
816
+ "mark-done",
817
+ ])
818
+ const task = await runTestEffect(
819
+ TaskService.pipe(
820
+ Effect.flatMap((service) => service.show("forked", root)),
821
+ ),
822
+ )
823
+ expect(task.data).toMatchObject({
824
+ status: "done",
825
+ pr: {
826
+ repository: "getpasted/pasted",
827
+ headRepository: "markjaquith/Pasted",
828
+ headBranch: "task/polish-readme",
829
+ baseRepository: "getpasted/pasted",
830
+ baseBranch: "main",
831
+ state: "merged",
832
+ merged: true,
833
+ },
834
+ })
835
+ })
836
+
750
837
  test("leaves non-PR completion unchanged when a matching PR is discoverable", async () => {
751
838
  await runTestEffect(
752
839
  TaskService.pipe(
@@ -349,7 +349,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
349
349
  "view",
350
350
  existing.url,
351
351
  "--json",
352
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
352
+ "number,state,title,isDraft,headRefName,baseRefName,headRepository,baseRepository,url,mergedAt,mergeCommit,mergeable",
353
353
  ],
354
354
  {
355
355
  cwd: repositoryPath,
@@ -372,7 +372,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
372
372
  "--state",
373
373
  "all",
374
374
  "--json",
375
- "number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit,mergeable",
375
+ "number,state,title,isDraft,headRefName,baseRefName,headRepository,baseRepository,url,mergedAt,mergeCommit,mergeable",
376
376
  ],
377
377
  {
378
378
  cwd: repositoryPath,
@@ -773,19 +773,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
773
773
  const query = prQueries.get(record.key)!
774
774
  const { remoteUrl, remoteRepository } = query
775
775
 
776
- if (
777
- existing &&
778
- remoteRepository.toLowerCase() !== existing.repository.toLowerCase()
779
- ) {
780
- prConflict = true
781
- unresolved.push({
782
- kind: "pr-repository-conflict",
783
- target: record.key,
784
- message: `Recorded PR repository does not match writable repository remote '${remoteName}'`,
785
- action: "Correct the configured remote or recorded PR",
786
- })
787
- }
788
-
789
776
  if (config.delivery && !remoteUrl) {
790
777
  warnings.push({
791
778
  kind: "delivery-remote-unavailable",
@@ -812,8 +799,9 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
812
799
  } else if (
813
800
  parsed.right &&
814
801
  (parsed.right.provider !== config.delivery.provider ||
815
- parsed.right.repository.toLowerCase() !==
816
- remoteRepository.toLowerCase())
802
+ (
803
+ parsed.right.headRepository ?? parsed.right.repository
804
+ ).toLowerCase() !== remoteRepository.toLowerCase())
817
805
  ) {
818
806
  if (parsed.right) {
819
807
  prConflict = true
@@ -850,14 +838,18 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
850
838
  current = recordFromGitHubJson(detail)
851
839
  pr = { ...detail, ...current }
852
840
  if (
853
- detail.headRefName !== data.branch ||
854
- detail.baseRefName !== data.base
841
+ current.headRepository?.toLowerCase() !==
842
+ remoteRepository.toLowerCase() ||
843
+ current.headBranch !== data.branch ||
844
+ current.baseRepository?.toLowerCase() !==
845
+ current.repository.toLowerCase() ||
846
+ current.baseBranch !== data.base
855
847
  ) {
856
848
  prConflict = true
857
849
  unresolved.push({
858
- kind: "pr-branch-conflict",
850
+ kind: "pr-repository-conflict",
859
851
  target: record.key,
860
- message: `Recorded PR branches do not match '${data.branch}' -> '${data.base}'`,
852
+ message: `Recorded PR head does not match '${remoteRepository}:${data.branch}' or base '${current.repository}:${data.base}'`,
861
853
  action: "Correct the declaration or recorded PR URL",
862
854
  })
863
855
  }
@@ -883,6 +875,20 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
883
875
  if (matches.length === 1) {
884
876
  current = recordFromGitHubJson(matches[0]!)
885
877
  pr = { ...matches[0], ...current }
878
+ if (
879
+ current.headRepository?.toLowerCase() !==
880
+ remoteRepository.toLowerCase() ||
881
+ current.baseRepository?.toLowerCase() !==
882
+ current.repository.toLowerCase()
883
+ ) {
884
+ prConflict = true
885
+ unresolved.push({
886
+ kind: "pr-repository-conflict",
887
+ target: record.key,
888
+ message: `Discovered PR repositories do not match writable repository '${remoteRepository}' and base '${current.repository}'`,
889
+ action: "Record the authoritative PR URL manually",
890
+ })
891
+ }
886
892
  } else if (matches.length > 1) {
887
893
  unresolved.push({
888
894
  kind: "multiple-prs",
@@ -104,8 +104,24 @@ describe("delivery commands", () => {
104
104
  isDraft: false,
105
105
  }
106
106
  expect(
107
- recordFromGitHubJson({ ...base, state: "OPEN", mergeable: "MERGEABLE" }),
108
- ).toMatchObject({ state: "open", merged: false, mergeable: true })
107
+ recordFromGitHubJson({
108
+ ...base,
109
+ state: "OPEN",
110
+ headRefName: "feat/example",
111
+ baseRefName: "main",
112
+ headRepository: { nameWithOwner: "fork/agency" },
113
+ baseRepository: { nameWithOwner: "example/agency" },
114
+ mergeable: "MERGEABLE",
115
+ }),
116
+ ).toMatchObject({
117
+ state: "open",
118
+ merged: false,
119
+ headRepository: "fork/agency",
120
+ headBranch: "feat/example",
121
+ baseRepository: "example/agency",
122
+ baseBranch: "main",
123
+ mergeable: true,
124
+ })
109
125
  expect(
110
126
  recordFromGitHubJson({
111
127
  ...base,
@@ -156,11 +156,24 @@ export const recordFromGitHubUrl = (url: string): PullRequestRecord => {
156
156
  export const recordFromGitHubJson = (value: Record<string, unknown>) => {
157
157
  const url = typeof value.url === "string" ? value.url : ""
158
158
  const record = recordFromGitHubUrl(url)
159
+ const repositoryName = (repository: unknown) => {
160
+ if (!repository || typeof repository !== "object") return undefined
161
+ const nameWithOwner = (repository as Record<string, unknown>).nameWithOwner
162
+ return typeof nameWithOwner === "string" && nameWithOwner
163
+ ? nameWithOwner
164
+ : undefined
165
+ }
159
166
  const githubState = String(value.state ?? "OPEN").toLowerCase()
160
167
  const merged = githubState === "merged" || value.mergedAt != null
161
168
  const mergeable = String(value.mergeable ?? "UNKNOWN").toLowerCase()
162
169
  return {
163
170
  ...record,
171
+ headRepository: repositoryName(value.headRepository),
172
+ headBranch:
173
+ typeof value.headRefName === "string" ? value.headRefName : undefined,
174
+ baseRepository: repositoryName(value.baseRepository) ?? record.repository,
175
+ baseBranch:
176
+ typeof value.baseRefName === "string" ? value.baseRefName : undefined,
164
177
  state: merged ? "merged" : githubState === "closed" ? "closed" : "open",
165
178
  draft: value.isDraft === true,
166
179
  merged,
@@ -72,6 +72,10 @@ const GitHubPullRequestUrl = NonEmptyString.pipe(
72
72
  export const PullRequestRecord = Schema.Struct({
73
73
  provider: EntityId,
74
74
  repository: NonEmptyString,
75
+ headRepository: Schema.optional(NonEmptyString),
76
+ headBranch: Schema.optional(NonEmptyString),
77
+ baseRepository: Schema.optional(NonEmptyString),
78
+ baseBranch: Schema.optional(NonEmptyString),
75
79
  identifier: NonEmptyString,
76
80
  url: Url,
77
81
  state: Schema.Literal("open", "closed", "merged"),