@markjaquith/agency 2.21.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.
- package/package.json +1 -1
- package/src/commands/pr.ts +2 -2
- package/src/graph-schema.ts +2 -10
- package/src/services/ClaimService.ts +7 -2
- package/src/services/ContextService.ts +4 -4
- package/src/services/GraphService.ts +2 -11
- package/src/services/PullRequestService.test.ts +91 -4
- package/src/services/PullRequestService.ts +108 -31
- package/src/services/SyncService.test.ts +95 -1
- package/src/services/SyncService.ts +150 -60
- package/src/services/TaskPhaseService.test.ts +9 -1
- package/src/services/WorkbaseService.ts +2 -0
- package/src/workbase/delivery-command.test.ts +54 -0
- package/src/workbase/delivery-command.ts +135 -0
- package/src/workbase/schemas.test.ts +35 -0
- package/src/workbase/schemas.ts +23 -1
package/package.json
CHANGED
package/src/commands/pr.ts
CHANGED
|
@@ -33,8 +33,8 @@ export const pr = (options: PrOptions) =>
|
|
|
33
33
|
export const help = `
|
|
34
34
|
Usage: agency pr create <task-id> [phase-id]
|
|
35
35
|
|
|
36
|
-
Push the execution branch, create a
|
|
37
|
-
task or phase document.
|
|
36
|
+
Push the execution branch, create a pull request with the delivery provider,
|
|
37
|
+
and update its task or phase document.
|
|
38
38
|
|
|
39
39
|
Options:
|
|
40
40
|
--draft Create a draft pull request
|
package/src/graph-schema.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Schema } from "@effect/schema"
|
|
|
2
2
|
import {
|
|
3
3
|
EpicFrontmatter,
|
|
4
4
|
PhaseFrontmatter,
|
|
5
|
+
PullRequestRecord,
|
|
5
6
|
TaskFrontmatter,
|
|
6
7
|
WorkStatus,
|
|
7
8
|
} from "./workbase/schemas"
|
|
@@ -109,16 +110,7 @@ export const GraphExecutionGit = Schema.Struct({
|
|
|
109
110
|
export const GraphPr = Schema.Union(
|
|
110
111
|
Schema.Struct({ url: Schema.Null, state: Schema.Literal("none") }),
|
|
111
112
|
Schema.Struct({ url: Schema.String, state: Schema.Literal("unavailable") }),
|
|
112
|
-
|
|
113
|
-
recordedUrl: Schema.String,
|
|
114
|
-
number: Schema.Number,
|
|
115
|
-
state: Schema.String,
|
|
116
|
-
title: Schema.String,
|
|
117
|
-
isDraft: Schema.Boolean,
|
|
118
|
-
headRefName: Schema.String,
|
|
119
|
-
baseRefName: Schema.String,
|
|
120
|
-
url: Schema.String,
|
|
121
|
-
}),
|
|
113
|
+
PullRequestRecord,
|
|
122
114
|
)
|
|
123
115
|
|
|
124
116
|
const NodeIdentity = {
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
PhaseFrontmatter,
|
|
24
24
|
TaskFrontmatter,
|
|
25
25
|
type ClaimRecord,
|
|
26
|
+
type PullRequestRecord,
|
|
26
27
|
type PhaseFrontmatter as PhaseData,
|
|
27
28
|
type TaskFrontmatter as TaskData,
|
|
28
29
|
} from "../workbase/schemas"
|
|
@@ -98,7 +99,7 @@ interface ReconcileInput {
|
|
|
98
99
|
readonly taskId: string
|
|
99
100
|
readonly phaseId?: string
|
|
100
101
|
readonly revision: string
|
|
101
|
-
readonly pr?: string
|
|
102
|
+
readonly pr?: string | PullRequestRecord
|
|
102
103
|
readonly status?: "done"
|
|
103
104
|
}
|
|
104
105
|
|
|
@@ -341,7 +342,11 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
341
342
|
|
|
342
343
|
reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
|
|
343
344
|
Effect.gen(function* () {
|
|
344
|
-
if (
|
|
345
|
+
if (
|
|
346
|
+
typeof input.pr === "string" &&
|
|
347
|
+
input.pr !== undefined &&
|
|
348
|
+
!PR_URL.test(input.pr)
|
|
349
|
+
) {
|
|
345
350
|
return yield* new ClaimError({
|
|
346
351
|
message: `Invalid GitHub pull request URL: ${input.pr}`,
|
|
347
352
|
})
|
|
@@ -3,6 +3,7 @@ import { Data, Effect, Either } from "effect"
|
|
|
3
3
|
import { join, relative, resolve, sep } from "node:path"
|
|
4
4
|
import { FileSystemService } from "./FileSystemService"
|
|
5
5
|
import { WorkbaseService } from "./WorkbaseService"
|
|
6
|
+
import { normalizePullRequestRecord } from "../workbase/delivery-command"
|
|
6
7
|
import { RepositoryService } from "./RepositoryService"
|
|
7
8
|
import { aggregateProgress, readinessState } from "../readiness"
|
|
8
9
|
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
@@ -857,10 +858,9 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
857
858
|
references: referenceCheckouts,
|
|
858
859
|
warnings: inspectionWarnings,
|
|
859
860
|
},
|
|
860
|
-
pr:
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
},
|
|
861
|
+
pr: executionData?.pr
|
|
862
|
+
? normalizePullRequestRecord(executionData.pr)
|
|
863
|
+
: { url: null, state: "none" },
|
|
864
864
|
validation: {
|
|
865
865
|
valid: validation.valid,
|
|
866
866
|
warnings: validation.issues,
|
|
@@ -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
|
-
|
|
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).
|
|
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:
|
|
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(`
|
|
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).
|
|
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
|
|
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
|
-
|
|
40
|
+
setRecord: (
|
|
29
41
|
taskId: string,
|
|
30
42
|
phaseId: string | undefined,
|
|
31
|
-
|
|
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
|
|
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(
|
|
55
|
-
const data = { ...record.data, pr: url }
|
|
61
|
+
const parsed = yield* parseFrontmatter(target.content, target.path)
|
|
56
62
|
yield* fs.writeFile(
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
130
|
-
"
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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:
|
|
223
|
+
message:
|
|
224
|
+
"Delivery provider returned a record for the wrong provider or repository",
|
|
153
225
|
})
|
|
154
226
|
}
|
|
155
|
-
return yield* service.
|
|
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:
|
|
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"],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Data, Effect } from "effect"
|
|
1
|
+
import { Data, Effect, Either } from "effect"
|
|
2
2
|
import { dirname, join, resolve } from "node:path"
|
|
3
3
|
import { documentRevision } from "../workbase/document-revision"
|
|
4
4
|
import type {
|
|
@@ -7,7 +7,14 @@ import type {
|
|
|
7
7
|
RepositoryReference,
|
|
8
8
|
TaskFrontmatter,
|
|
9
9
|
WorkStatus,
|
|
10
|
+
PullRequestRecord,
|
|
10
11
|
} from "../workbase/schemas"
|
|
12
|
+
import {
|
|
13
|
+
normalizePullRequestRecord,
|
|
14
|
+
parseOptionalPullRequestRecord,
|
|
15
|
+
recordFromGitHubJson,
|
|
16
|
+
resolveDeliveryCommand,
|
|
17
|
+
} from "../workbase/delivery-command"
|
|
11
18
|
import { ClaimService } from "./ClaimService"
|
|
12
19
|
import { FileSystemService } from "./FileSystemService"
|
|
13
20
|
import { PhaseService } from "./PhaseService"
|
|
@@ -143,7 +150,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
143
150
|
const phases = yield* PhaseService
|
|
144
151
|
const worktrees = yield* WorktreeService
|
|
145
152
|
const claims = yield* ClaimService
|
|
146
|
-
const root = yield* workbase.
|
|
153
|
+
const { root, config } = yield* workbase.loadConfig(options.cwd)
|
|
147
154
|
const validation = yield* workbase.validate(root)
|
|
148
155
|
if (!validation.valid) {
|
|
149
156
|
return yield* new SyncError({
|
|
@@ -161,12 +168,16 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
161
168
|
const executions: ExecutionSyncState[] = []
|
|
162
169
|
const runExternal = (
|
|
163
170
|
args: readonly string[],
|
|
164
|
-
commandOptions?: {
|
|
171
|
+
commandOptions?: {
|
|
172
|
+
readonly cwd?: string
|
|
173
|
+
readonly env?: Record<string, string>
|
|
174
|
+
},
|
|
165
175
|
) =>
|
|
166
176
|
fs
|
|
167
177
|
.runCommand(args, {
|
|
168
178
|
cwd: commandOptions?.cwd,
|
|
169
179
|
captureOutput: true,
|
|
180
|
+
env: commandOptions?.env,
|
|
170
181
|
})
|
|
171
182
|
.pipe(
|
|
172
183
|
Effect.catchAll((error) =>
|
|
@@ -551,50 +562,127 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
551
562
|
})
|
|
552
563
|
}
|
|
553
564
|
|
|
554
|
-
|
|
565
|
+
const existing = data.pr ? normalizePullRequestRecord(data.pr) : null
|
|
566
|
+
let current: PullRequestRecord | null = existing
|
|
567
|
+
let pr: Record<string, unknown> = existing ?? {
|
|
568
|
+
url: null,
|
|
569
|
+
state: "none",
|
|
570
|
+
}
|
|
555
571
|
let prConflict = false
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
)
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
)
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
572
|
+
const repositoryPath = join(root, "repos", data.repo)
|
|
573
|
+
const remoteName = config.delivery?.remote ?? "origin"
|
|
574
|
+
const remote = yield* runExternal([
|
|
575
|
+
"git",
|
|
576
|
+
"-C",
|
|
577
|
+
repositoryPath,
|
|
578
|
+
"remote",
|
|
579
|
+
"get-url",
|
|
580
|
+
remoteName,
|
|
581
|
+
])
|
|
582
|
+
const remoteRepository = remote.stdout
|
|
583
|
+
.trim()
|
|
584
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
|
|
585
|
+
.replace(/^[^:]+:/, "")
|
|
586
|
+
.replace(/\.git\/?$/, "")
|
|
587
|
+
.replace(/\/$/, "")
|
|
588
|
+
|
|
589
|
+
if (
|
|
590
|
+
existing &&
|
|
591
|
+
remoteRepository.toLowerCase() !== existing.repository.toLowerCase()
|
|
592
|
+
) {
|
|
593
|
+
prConflict = true
|
|
594
|
+
unresolved.push({
|
|
595
|
+
kind: "pr-repository-conflict",
|
|
596
|
+
target: record.key,
|
|
597
|
+
message: `Recorded PR repository does not match writable repository remote '${remoteName}'`,
|
|
598
|
+
action: "Correct the configured remote or recorded PR",
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (config.delivery && remote.exitCode !== 0) {
|
|
603
|
+
warnings.push({
|
|
604
|
+
kind: "delivery-remote-unavailable",
|
|
605
|
+
target: record.key,
|
|
606
|
+
message: `Could not inspect delivery remote '${remoteName}': ${remote.stderr.trim()}`,
|
|
607
|
+
})
|
|
608
|
+
} else if (config.delivery) {
|
|
609
|
+
const resolved = resolveDeliveryCommand(config.delivery, "query", {
|
|
610
|
+
repository: remoteRepository,
|
|
611
|
+
branch: data.branch,
|
|
612
|
+
base: data.base,
|
|
613
|
+
draft: existing ? String(existing.draft) : "",
|
|
614
|
+
url: existing?.url ?? "",
|
|
615
|
+
identifier: existing?.identifier ?? "",
|
|
616
|
+
})
|
|
617
|
+
const queried = yield* runExternal(resolved.argv, {
|
|
618
|
+
cwd: repositoryPath,
|
|
619
|
+
env: resolved.environment,
|
|
620
|
+
})
|
|
621
|
+
if (queried.exitCode === 0) {
|
|
622
|
+
const parsed = yield* Effect.try({
|
|
623
|
+
try: () => parseOptionalPullRequestRecord(queried.stdout),
|
|
624
|
+
catch: (cause) =>
|
|
625
|
+
new SyncError({
|
|
626
|
+
message:
|
|
627
|
+
cause instanceof Error ? cause.message : String(cause),
|
|
628
|
+
}),
|
|
629
|
+
}).pipe(Effect.either)
|
|
630
|
+
if (Either.isLeft(parsed)) {
|
|
631
|
+
warnings.push({
|
|
632
|
+
kind: "pr-provider-invalid-output",
|
|
633
|
+
target: record.key,
|
|
634
|
+
message: parsed.left.message,
|
|
635
|
+
})
|
|
636
|
+
} else if (
|
|
637
|
+
parsed.right &&
|
|
638
|
+
(parsed.right.provider !== config.delivery.provider ||
|
|
639
|
+
parsed.right.repository.toLowerCase() !==
|
|
640
|
+
remoteRepository.toLowerCase())
|
|
641
|
+
) {
|
|
642
|
+
if (parsed.right) {
|
|
643
|
+
prConflict = true
|
|
644
|
+
unresolved.push({
|
|
645
|
+
kind: "pr-provider-conflict",
|
|
646
|
+
target: record.key,
|
|
647
|
+
message:
|
|
648
|
+
"Delivery provider returned a record for the wrong provider or repository",
|
|
649
|
+
action: "Correct the delivery provider output",
|
|
650
|
+
})
|
|
651
|
+
}
|
|
652
|
+
} else {
|
|
653
|
+
current = parsed.right
|
|
654
|
+
pr = parsed.right ?? { url: null, state: "none" }
|
|
655
|
+
}
|
|
656
|
+
} else {
|
|
657
|
+
pr = existing
|
|
658
|
+
? { url: existing.url, state: "unavailable" }
|
|
659
|
+
: { url: null, state: "none" }
|
|
660
|
+
warnings.push({
|
|
661
|
+
kind: existing ? "pr-unavailable" : "pr-discovery-unavailable",
|
|
579
662
|
target: record.key,
|
|
580
663
|
message:
|
|
581
|
-
|
|
582
|
-
action: "Correct the repository origin or recorded PR URL",
|
|
664
|
+
queried.stderr.trim() || "Could not query delivery provider",
|
|
583
665
|
})
|
|
584
666
|
}
|
|
667
|
+
} else if (existing) {
|
|
585
668
|
const viewed = yield* runExternal([
|
|
586
669
|
"gh",
|
|
587
670
|
"pr",
|
|
588
671
|
"view",
|
|
589
|
-
|
|
672
|
+
existing.url,
|
|
590
673
|
"--json",
|
|
591
674
|
"number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit",
|
|
592
675
|
])
|
|
593
676
|
if (viewed.exitCode === 0) {
|
|
594
|
-
|
|
677
|
+
const detail = parseJson<Record<string, unknown>>(
|
|
678
|
+
viewed.stdout,
|
|
679
|
+
{},
|
|
680
|
+
)
|
|
681
|
+
current = recordFromGitHubJson(detail)
|
|
682
|
+
pr = { ...detail, ...current }
|
|
595
683
|
if (
|
|
596
|
-
|
|
597
|
-
|
|
684
|
+
detail.headRefName !== data.branch ||
|
|
685
|
+
detail.baseRefName !== data.base
|
|
598
686
|
) {
|
|
599
687
|
prConflict = true
|
|
600
688
|
unresolved.push({
|
|
@@ -605,11 +693,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
605
693
|
})
|
|
606
694
|
}
|
|
607
695
|
} else {
|
|
608
|
-
pr = { url:
|
|
696
|
+
pr = { url: existing.url, state: "unavailable" }
|
|
609
697
|
warnings.push({
|
|
610
698
|
kind: "pr-unavailable",
|
|
611
699
|
target: record.key,
|
|
612
|
-
message: `Could not inspect ${
|
|
700
|
+
message: `Could not inspect ${existing.url}: ${viewed.stderr.trim()}`,
|
|
613
701
|
})
|
|
614
702
|
}
|
|
615
703
|
} else {
|
|
@@ -625,7 +713,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
625
713
|
"--json",
|
|
626
714
|
"number,state,title,isDraft,headRefName,baseRefName,url,mergedAt,mergeCommit",
|
|
627
715
|
],
|
|
628
|
-
{ cwd:
|
|
716
|
+
{ cwd: repositoryPath },
|
|
629
717
|
)
|
|
630
718
|
if (listed.exitCode === 0) {
|
|
631
719
|
const matches = parseJson<Record<string, unknown>[]>(
|
|
@@ -634,31 +722,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
634
722
|
).filter(
|
|
635
723
|
(item) =>
|
|
636
724
|
item.headRefName === data.branch &&
|
|
637
|
-
item.baseRefName === data.base
|
|
638
|
-
typeof item.url === "string",
|
|
725
|
+
item.baseRefName === data.base,
|
|
639
726
|
)
|
|
640
727
|
if (matches.length === 1) {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (apply) {
|
|
644
|
-
const recorded = yield* claims.reconcile(
|
|
645
|
-
{
|
|
646
|
-
taskId: record.taskId,
|
|
647
|
-
phaseId: record.phaseId,
|
|
648
|
-
revision,
|
|
649
|
-
pr: url,
|
|
650
|
-
},
|
|
651
|
-
root,
|
|
652
|
-
)
|
|
653
|
-
data = recorded.data
|
|
654
|
-
revision = recorded.revision
|
|
655
|
-
}
|
|
656
|
-
changes.push({
|
|
657
|
-
kind: "record-pr",
|
|
658
|
-
target: record.key,
|
|
659
|
-
message: `Record pull request ${url}`,
|
|
660
|
-
status: apply ? "applied" : "planned",
|
|
661
|
-
})
|
|
728
|
+
current = recordFromGitHubJson(matches[0]!)
|
|
729
|
+
pr = { ...matches[0], ...current }
|
|
662
730
|
} else if (matches.length > 1) {
|
|
663
731
|
unresolved.push({
|
|
664
732
|
kind: "multiple-prs",
|
|
@@ -677,8 +745,30 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
|
|
|
677
745
|
}
|
|
678
746
|
}
|
|
679
747
|
|
|
748
|
+
if (current && JSON.stringify(current) !== JSON.stringify(existing)) {
|
|
749
|
+
if (apply) {
|
|
750
|
+
const recorded = yield* claims.reconcile(
|
|
751
|
+
{
|
|
752
|
+
taskId: record.taskId,
|
|
753
|
+
phaseId: record.phaseId,
|
|
754
|
+
revision,
|
|
755
|
+
pr: current,
|
|
756
|
+
},
|
|
757
|
+
root,
|
|
758
|
+
)
|
|
759
|
+
data = recorded.data
|
|
760
|
+
revision = recorded.revision
|
|
761
|
+
}
|
|
762
|
+
changes.push({
|
|
763
|
+
kind: "record-pr",
|
|
764
|
+
target: record.key,
|
|
765
|
+
message: `Record pull request ${current.url}`,
|
|
766
|
+
status: apply ? "applied" : "planned",
|
|
767
|
+
})
|
|
768
|
+
}
|
|
769
|
+
|
|
680
770
|
if (
|
|
681
|
-
|
|
771
|
+
current?.merged === true &&
|
|
682
772
|
!prConflict &&
|
|
683
773
|
data.status !== "done" &&
|
|
684
774
|
data.status !== "dropped"
|
|
@@ -211,7 +211,15 @@ describe("task and phase services", () => {
|
|
|
211
211
|
repos: [{ repo: "effect", ref: "main" }],
|
|
212
212
|
branch: "task/single",
|
|
213
213
|
base: "main",
|
|
214
|
-
pr:
|
|
214
|
+
pr: {
|
|
215
|
+
provider: "github",
|
|
216
|
+
repository: "example/agency",
|
|
217
|
+
identifier: "42",
|
|
218
|
+
url: "https://github.com/example/agency/pull/42",
|
|
219
|
+
state: "open",
|
|
220
|
+
draft: false,
|
|
221
|
+
merged: false,
|
|
222
|
+
},
|
|
215
223
|
status: "open",
|
|
216
224
|
})
|
|
217
225
|
})
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
|
|
23
23
|
import { validateRunners } from "../workbase/runner-command"
|
|
24
24
|
import { findDependencyCycles } from "../workbase/dependency-graph"
|
|
25
|
+
import { validateDelivery } from "../workbase/delivery-command"
|
|
25
26
|
|
|
26
27
|
class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
|
|
27
28
|
readonly message: string
|
|
@@ -246,6 +247,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
246
247
|
}
|
|
247
248
|
try {
|
|
248
249
|
validateRunners(decoded.value.runners)
|
|
250
|
+
validateDelivery(decoded.value.delivery)
|
|
249
251
|
} catch (cause) {
|
|
250
252
|
return yield* new WorkbaseConfigError({
|
|
251
253
|
path: configPath,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import {
|
|
3
|
+
parsePullRequestRecord,
|
|
4
|
+
resolveDeliveryCommand,
|
|
5
|
+
validateDelivery,
|
|
6
|
+
} from "./delivery-command"
|
|
7
|
+
|
|
8
|
+
const delivery = {
|
|
9
|
+
provider: "forge",
|
|
10
|
+
remote: "upstream",
|
|
11
|
+
createCommand: ["forge", "create", "{repository}", "{branch}"],
|
|
12
|
+
queryCommand: ["forge", "query", "{identifier}"],
|
|
13
|
+
environment: { FORGE_BASE: "{base}" },
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
const variables = {
|
|
17
|
+
repository: "example/agency",
|
|
18
|
+
branch: "feat/example",
|
|
19
|
+
base: "main",
|
|
20
|
+
draft: "false",
|
|
21
|
+
url: "",
|
|
22
|
+
identifier: "",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("delivery commands", () => {
|
|
26
|
+
test("expands argv and environment without shell evaluation", () => {
|
|
27
|
+
expect(resolveDeliveryCommand(delivery, "create", variables)).toEqual({
|
|
28
|
+
argv: ["forge", "create", "example/agency", "feat/example"],
|
|
29
|
+
environment: { FORGE_BASE: "main" },
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("rejects unknown placeholders", () => {
|
|
34
|
+
expect(() =>
|
|
35
|
+
validateDelivery({ ...delivery, queryCommand: ["forge", "{unknown}"] }),
|
|
36
|
+
).toThrow("Unknown delivery provider 'forge' placeholder")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("requires complete and consistent normalized records", () => {
|
|
40
|
+
const record = {
|
|
41
|
+
provider: "forge",
|
|
42
|
+
repository: "example/agency",
|
|
43
|
+
identifier: "17",
|
|
44
|
+
url: "https://forge.example/example/agency/pulls/17",
|
|
45
|
+
state: "merged",
|
|
46
|
+
draft: false,
|
|
47
|
+
merged: true,
|
|
48
|
+
} as const
|
|
49
|
+
expect(parsePullRequestRecord(JSON.stringify(record))).toEqual(record)
|
|
50
|
+
expect(() =>
|
|
51
|
+
parsePullRequestRecord(JSON.stringify({ ...record, merged: false })),
|
|
52
|
+
).toThrow("inconsistent merge state")
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Schema } from "@effect/schema"
|
|
2
|
+
import type { PullRequestRecord, WorkbaseConfig } from "./schemas"
|
|
3
|
+
import { PullRequestRecord as PullRequestRecordSchema } from "./schemas"
|
|
4
|
+
|
|
5
|
+
export interface DeliveryCommandVariables {
|
|
6
|
+
readonly repository: string
|
|
7
|
+
readonly branch: string
|
|
8
|
+
readonly base: string
|
|
9
|
+
readonly draft: string
|
|
10
|
+
readonly url: string
|
|
11
|
+
readonly identifier: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const PLACEHOLDERS = new Set<keyof DeliveryCommandVariables>([
|
|
15
|
+
"repository",
|
|
16
|
+
"branch",
|
|
17
|
+
"base",
|
|
18
|
+
"draft",
|
|
19
|
+
"url",
|
|
20
|
+
"identifier",
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
const validateTemplate = (provider: string, value: string) => {
|
|
24
|
+
for (const match of value.matchAll(/\{([^{}]+)\}/g)) {
|
|
25
|
+
const placeholder = match[1]!
|
|
26
|
+
if (!PLACEHOLDERS.has(placeholder as keyof DeliveryCommandVariables)) {
|
|
27
|
+
throw new Error(
|
|
28
|
+
`Unknown delivery provider '${provider}' placeholder: {${placeholder}}`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const validateDelivery = (
|
|
35
|
+
delivery: WorkbaseConfig["delivery"],
|
|
36
|
+
): void => {
|
|
37
|
+
if (!delivery) return
|
|
38
|
+
for (const value of [
|
|
39
|
+
...delivery.createCommand,
|
|
40
|
+
...delivery.queryCommand,
|
|
41
|
+
...Object.values(delivery.environment ?? {}),
|
|
42
|
+
]) {
|
|
43
|
+
validateTemplate(delivery.provider, value)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const expand = (value: string, variables: DeliveryCommandVariables) =>
|
|
48
|
+
value.replaceAll(
|
|
49
|
+
/\{([^{}]+)\}/g,
|
|
50
|
+
(match, placeholder: string) =>
|
|
51
|
+
variables[placeholder as keyof DeliveryCommandVariables] ?? match,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
export const resolveDeliveryCommand = (
|
|
55
|
+
delivery: NonNullable<WorkbaseConfig["delivery"]>,
|
|
56
|
+
kind: "create" | "query",
|
|
57
|
+
variables: DeliveryCommandVariables,
|
|
58
|
+
) => {
|
|
59
|
+
validateDelivery(delivery)
|
|
60
|
+
const template =
|
|
61
|
+
kind === "create" ? delivery.createCommand : delivery.queryCommand
|
|
62
|
+
return {
|
|
63
|
+
argv: template.map((argument) => expand(argument, variables)),
|
|
64
|
+
environment: Object.fromEntries(
|
|
65
|
+
Object.entries(delivery.environment ?? {}).map(([key, value]) => [
|
|
66
|
+
key,
|
|
67
|
+
expand(value, variables),
|
|
68
|
+
]),
|
|
69
|
+
),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const decodeRecord = Schema.decodeUnknownEither(PullRequestRecordSchema, {
|
|
74
|
+
onExcessProperty: "error",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
export const parsePullRequestRecord = (value: string): PullRequestRecord => {
|
|
78
|
+
let input: unknown
|
|
79
|
+
try {
|
|
80
|
+
input = JSON.parse(value)
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error("Delivery provider did not return valid JSON")
|
|
83
|
+
}
|
|
84
|
+
const decoded = decodeRecord(input)
|
|
85
|
+
if (decoded._tag === "Left") {
|
|
86
|
+
throw new Error(
|
|
87
|
+
"Delivery provider did not return a valid pull request record",
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
if (decoded.right.merged !== (decoded.right.state === "merged")) {
|
|
91
|
+
throw new Error("Delivery provider returned inconsistent merge state")
|
|
92
|
+
}
|
|
93
|
+
return decoded.right
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const parseOptionalPullRequestRecord = (
|
|
97
|
+
value: string,
|
|
98
|
+
): PullRequestRecord | null => {
|
|
99
|
+
if (value.trim() === "null") return null
|
|
100
|
+
return parsePullRequestRecord(value)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const GITHUB_URL = /^https:\/\/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)\/?$/
|
|
104
|
+
|
|
105
|
+
export const recordFromGitHubUrl = (url: string): PullRequestRecord => {
|
|
106
|
+
const match = url.match(GITHUB_URL)
|
|
107
|
+
if (!match) throw new Error(`Invalid GitHub pull request URL: ${url}`)
|
|
108
|
+
return {
|
|
109
|
+
provider: "github",
|
|
110
|
+
repository: match[1]!,
|
|
111
|
+
identifier: match[2]!,
|
|
112
|
+
url,
|
|
113
|
+
state: "open",
|
|
114
|
+
draft: false,
|
|
115
|
+
merged: false,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const recordFromGitHubJson = (value: Record<string, unknown>) => {
|
|
120
|
+
const url = typeof value.url === "string" ? value.url : ""
|
|
121
|
+
const record = recordFromGitHubUrl(url)
|
|
122
|
+
const state = String(value.state ?? "OPEN").toLowerCase()
|
|
123
|
+
return {
|
|
124
|
+
...record,
|
|
125
|
+
state:
|
|
126
|
+
state === "merged" ? "merged" : state === "closed" ? "closed" : "open",
|
|
127
|
+
draft: value.isDraft === true,
|
|
128
|
+
merged: state === "merged" || value.mergedAt != null,
|
|
129
|
+
} satisfies PullRequestRecord
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export const normalizePullRequestRecord = (
|
|
133
|
+
record: PullRequestRecord | string,
|
|
134
|
+
): PullRequestRecord =>
|
|
135
|
+
typeof record === "string" ? recordFromGitHubUrl(record) : record
|
|
@@ -104,6 +104,41 @@ describe("runner configuration", () => {
|
|
|
104
104
|
})
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
+
describe("delivery configuration", () => {
|
|
108
|
+
test("accepts an argv-based create and query provider", () => {
|
|
109
|
+
const config = Schema.decodeUnknownSync(WorkbaseConfig)({
|
|
110
|
+
version: 2,
|
|
111
|
+
delivery: {
|
|
112
|
+
provider: "forge",
|
|
113
|
+
remote: "upstream",
|
|
114
|
+
createCommand: ["forge", "create", "{branch}"],
|
|
115
|
+
queryCommand: ["forge", "query", "{identifier}"],
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
expect(config.delivery?.remote).toBe("upstream")
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("accepts normalized non-GitHub pull request records", () => {
|
|
122
|
+
const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
123
|
+
repo: "agency",
|
|
124
|
+
branch: "feat/example",
|
|
125
|
+
base: "main",
|
|
126
|
+
pr: {
|
|
127
|
+
provider: "forge",
|
|
128
|
+
repository: "example/agency",
|
|
129
|
+
identifier: "17",
|
|
130
|
+
url: "https://forge.example/example/agency/pulls/17",
|
|
131
|
+
state: "open",
|
|
132
|
+
draft: false,
|
|
133
|
+
merged: false,
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
expect(phase.pr && typeof phase.pr !== "string" && phase.pr.provider).toBe(
|
|
137
|
+
"forge",
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
107
142
|
describe("work status", () => {
|
|
108
143
|
const supportedStatuses: Record<WorkStatus, true> = {
|
|
109
144
|
open: true,
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -51,6 +51,26 @@ const GitHubPullRequestUrl = NonEmptyString.pipe(
|
|
|
51
51
|
Schema.pattern(/^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/),
|
|
52
52
|
)
|
|
53
53
|
|
|
54
|
+
export const PullRequestRecord = Schema.Struct({
|
|
55
|
+
provider: EntityId,
|
|
56
|
+
repository: NonEmptyString,
|
|
57
|
+
identifier: NonEmptyString,
|
|
58
|
+
url: Url,
|
|
59
|
+
state: Schema.Literal("open", "closed", "merged"),
|
|
60
|
+
draft: Schema.Boolean,
|
|
61
|
+
merged: Schema.Boolean,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const DeliveryProvider = Schema.Struct({
|
|
65
|
+
provider: EntityId,
|
|
66
|
+
remote: Schema.optional(NonEmptyString),
|
|
67
|
+
createCommand: Schema.NonEmptyArray(NonEmptyString),
|
|
68
|
+
queryCommand: Schema.NonEmptyArray(NonEmptyString),
|
|
69
|
+
environment: Schema.optional(
|
|
70
|
+
Schema.Record({ key: EnvironmentName, value: Schema.String }),
|
|
71
|
+
),
|
|
72
|
+
})
|
|
73
|
+
|
|
54
74
|
export const WorkbaseConfig = Schema.Struct({
|
|
55
75
|
version: Schema.Literal(2),
|
|
56
76
|
chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
|
|
@@ -67,6 +87,7 @@ export const WorkbaseConfig = Schema.Struct({
|
|
|
67
87
|
}),
|
|
68
88
|
}),
|
|
69
89
|
),
|
|
90
|
+
delivery: Schema.optional(DeliveryProvider),
|
|
70
91
|
})
|
|
71
92
|
|
|
72
93
|
export const LegacyWorkbaseRegistry = Schema.Struct({
|
|
@@ -96,7 +117,7 @@ const ExecutionUnit = {
|
|
|
96
117
|
repos: Schema.optional(Schema.Array(RepositoryReference)),
|
|
97
118
|
branch: NonEmptyString,
|
|
98
119
|
base: NonEmptyString,
|
|
99
|
-
pr: Schema.NullOr(GitHubPullRequestUrl),
|
|
120
|
+
pr: Schema.NullOr(Schema.Union(GitHubPullRequestUrl, PullRequestRecord)),
|
|
100
121
|
status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
|
|
101
122
|
claim: Schema.optional(ClaimRecord),
|
|
102
123
|
}
|
|
@@ -141,6 +162,7 @@ export type Dependency = Schema.Schema.Type<typeof Dependency>
|
|
|
141
162
|
export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
|
|
142
163
|
export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
|
|
143
164
|
export type ClaimRecord = Schema.Schema.Type<typeof ClaimRecord>
|
|
165
|
+
export type PullRequestRecord = Schema.Schema.Type<typeof PullRequestRecord>
|
|
144
166
|
export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
|
|
145
167
|
export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
|
|
146
168
|
export type PhaseFrontmatter = Schema.Schema.Type<typeof PhaseFrontmatter>
|