@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 +1 -1
- package/src/cli.test.ts +10 -1
- package/src/services/PullRequestService.ts +11 -2
- package/src/services/SyncService.test.ts +90 -3
- package/src/services/SyncService.ts +27 -21
- package/src/workbase/delivery-command.test.ts +18 -2
- package/src/workbase/delivery-command.ts +13 -0
- package/src/workbase/schemas.ts +4 -0
package/package.json
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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() !==
|
|
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
|
-
|
|
816
|
-
|
|
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
|
-
|
|
854
|
-
|
|
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-
|
|
850
|
+
kind: "pr-repository-conflict",
|
|
859
851
|
target: record.key,
|
|
860
|
-
message: `Recorded PR
|
|
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({
|
|
108
|
-
|
|
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,
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -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"),
|