@markjaquith/agency 2.21.0 → 2.23.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/README.md +9 -0
- package/cli.ts +3 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +25 -0
- package/src/cli-parser.ts +22 -7
- package/src/cli.test.ts +35 -0
- package/src/commands/epic.test.ts +12 -3
- package/src/commands/epic.ts +11 -1
- package/src/commands/phase.ts +13 -1
- package/src/commands/pr.ts +2 -2
- package/src/commands/task-phase.test.ts +16 -4
- package/src/commands/task.ts +13 -1
- package/src/graph-schema.ts +2 -10
- package/src/protocol.test.ts +19 -0
- package/src/services/ClaimService.ts +34 -23
- package/src/services/ContextService.ts +8 -10
- package/src/services/EpicService.ts +16 -2
- package/src/services/GraphMutationService.test.ts +31 -0
- package/src/services/GraphMutationService.ts +126 -11
- package/src/services/GraphService.ts +4 -15
- package/src/services/PhaseService.ts +26 -4
- 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/TaskService.ts +22 -3
- package/src/services/WorkbaseService.ts +2 -0
- package/src/work-view.ts +2 -0
- package/src/workbase/delivery-command.test.ts +54 -0
- package/src/workbase/delivery-command.ts +135 -0
- package/src/workbase/document-revision.ts +16 -0
- package/src/workbase/schemas.test.ts +35 -0
- package/src/workbase/schemas.ts +23 -1
|
@@ -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
|
})
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
parseFrontmatter,
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
18
|
import { canTransitionStatus } from "../readiness"
|
|
19
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
19
20
|
|
|
20
21
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
21
22
|
readonly message: string
|
|
@@ -25,6 +26,7 @@ interface TaskRecord {
|
|
|
25
26
|
readonly id: string
|
|
26
27
|
readonly path: string
|
|
27
28
|
readonly content: string
|
|
29
|
+
readonly revision: string
|
|
28
30
|
readonly data: TaskData
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -158,7 +160,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
158
160
|
yield* fs.writeFile(parentEpic.path, updated)
|
|
159
161
|
}
|
|
160
162
|
|
|
161
|
-
return {
|
|
163
|
+
return {
|
|
164
|
+
id,
|
|
165
|
+
path,
|
|
166
|
+
content,
|
|
167
|
+
revision: documentRevision(content),
|
|
168
|
+
data,
|
|
169
|
+
} satisfies TaskRecord
|
|
162
170
|
}),
|
|
163
171
|
|
|
164
172
|
list: (startPath: string = process.cwd()) =>
|
|
@@ -178,7 +186,13 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
178
186
|
const content = yield* fs.readFile(path)
|
|
179
187
|
const parsed = yield* parseFrontmatter(content, path)
|
|
180
188
|
const data = yield* decodeTask(parsed.data)
|
|
181
|
-
records.push({
|
|
189
|
+
records.push({
|
|
190
|
+
id: entry.name,
|
|
191
|
+
path,
|
|
192
|
+
content,
|
|
193
|
+
revision: documentRevision(content),
|
|
194
|
+
data,
|
|
195
|
+
})
|
|
182
196
|
}
|
|
183
197
|
return records
|
|
184
198
|
}),
|
|
@@ -233,7 +247,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
233
247
|
const data = { ...record.data, status: validStatus }
|
|
234
248
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
235
249
|
yield* fs.writeFile(record.path, content)
|
|
236
|
-
return {
|
|
250
|
+
return {
|
|
251
|
+
...record,
|
|
252
|
+
content,
|
|
253
|
+
revision: documentRevision(content),
|
|
254
|
+
data,
|
|
255
|
+
} satisfies TaskRecord
|
|
237
256
|
}),
|
|
238
257
|
}),
|
|
239
258
|
}) {}
|
|
@@ -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,
|
package/src/work-view.ts
CHANGED
|
@@ -23,6 +23,7 @@ export interface WorkViewRow {
|
|
|
23
23
|
readonly kind: "epic" | "task" | "phase"
|
|
24
24
|
readonly id: string
|
|
25
25
|
readonly key: string
|
|
26
|
+
readonly revision: string
|
|
26
27
|
readonly parent: string
|
|
27
28
|
readonly status: WorkStatus
|
|
28
29
|
readonly readiness: "ready" | "blocked" | "waiting" | "terminal"
|
|
@@ -91,6 +92,7 @@ const rowFor = (
|
|
|
91
92
|
kind: node.kind,
|
|
92
93
|
id,
|
|
93
94
|
key: node.key,
|
|
95
|
+
revision: node.data.sha256,
|
|
94
96
|
parent,
|
|
95
97
|
status: node.status,
|
|
96
98
|
readiness: readinessLabel(node),
|
|
@@ -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
|
|
@@ -1,2 +1,18 @@
|
|
|
1
|
+
import { Data } from "effect"
|
|
2
|
+
|
|
1
3
|
export const documentRevision = (content: string) =>
|
|
2
4
|
new Bun.CryptoHasher("sha256").update(content).digest("hex")
|
|
5
|
+
|
|
6
|
+
export const isDocumentRevision = (revision: string) =>
|
|
7
|
+
/^[a-f0-9]{64}$/.test(revision)
|
|
8
|
+
|
|
9
|
+
export class RevisionConflictError extends Data.TaggedError(
|
|
10
|
+
"RevisionConflictError",
|
|
11
|
+
)<{
|
|
12
|
+
readonly message: string
|
|
13
|
+
readonly path: string
|
|
14
|
+
readonly target?: string
|
|
15
|
+
readonly expectedRevision: string
|
|
16
|
+
readonly currentRevision: string
|
|
17
|
+
readonly claim?: unknown
|
|
18
|
+
}> {}
|
|
@@ -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,
|