@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
|
@@ -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
|
-
|
|
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: [
|
|
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:
|
|
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
|
|
235
|
-
try
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
-
|
|
45
|
+
"--head",
|
|
46
|
+
head,
|
|
42
47
|
...(draft ? ["--draft"] : []),
|
|
43
48
|
...labels.flatMap((label) => ["--label", label]),
|
|
44
49
|
],
|