@markjaquith/agency 2.20.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/README.md +23 -0
- package/cli.ts +13 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +45 -1
- package/src/cli-parser.ts +158 -5
- package/src/cli.test.ts +62 -0
- package/src/commands/epic.ts +51 -1
- package/src/commands/phase.ts +94 -1
- package/src/commands/pr.ts +2 -2
- package/src/commands/task.ts +108 -1
- package/src/graph-schema.ts +2 -10
- package/src/services/ClaimService.ts +7 -2
- package/src/services/ContextService.ts +4 -4
- package/src/services/GraphMutationService.test.ts +336 -0
- package/src/services/GraphMutationService.ts +952 -0
- 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 +5 -37
- package/src/test-utils.ts +2 -0
- package/src/workbase/delivery-command.test.ts +54 -0
- package/src/workbase/delivery-command.ts +135 -0
- package/src/workbase/dependency-graph.ts +51 -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
|
})
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
TaskFrontmatter,
|
|
14
14
|
WorkbaseConfig,
|
|
15
15
|
WorkbaseRegistry,
|
|
16
|
-
type Dependency,
|
|
17
16
|
type EpicFrontmatter as EpicData,
|
|
18
17
|
type PhaseFrontmatter as PhaseData,
|
|
19
18
|
type TaskFrontmatter as TaskData,
|
|
@@ -22,6 +21,8 @@ import {
|
|
|
22
21
|
} from "../workbase/schemas"
|
|
23
22
|
import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
|
|
24
23
|
import { validateRunners } from "../workbase/runner-command"
|
|
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
|
|
@@ -145,40 +146,6 @@ const writeRegistry = (
|
|
|
145
146
|
yield* fs.writeJSON(path, registry)
|
|
146
147
|
})
|
|
147
148
|
|
|
148
|
-
const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
|
|
149
|
-
const dependencies = new Map(
|
|
150
|
-
nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
|
|
151
|
-
)
|
|
152
|
-
const visiting = new Set<string>()
|
|
153
|
-
const visited = new Set<string>()
|
|
154
|
-
const cycles = new Set<string>()
|
|
155
|
-
|
|
156
|
-
const visit = (id: string) => {
|
|
157
|
-
if (visiting.has(id)) {
|
|
158
|
-
cycles.add(id)
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
if (visited.has(id)) {
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
visiting.add(id)
|
|
166
|
-
for (const dependency of dependencies.get(id) ?? []) {
|
|
167
|
-
if (dependencies.has(dependency)) {
|
|
168
|
-
visit(dependency)
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
visiting.delete(id)
|
|
172
|
-
visited.add(id)
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
for (const id of dependencies.keys()) {
|
|
176
|
-
visit(id)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return [...cycles].sort()
|
|
180
|
-
}
|
|
181
|
-
|
|
182
149
|
export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
183
150
|
"WorkbaseService",
|
|
184
151
|
{
|
|
@@ -280,6 +247,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
280
247
|
}
|
|
281
248
|
try {
|
|
282
249
|
validateRunners(decoded.value.runners)
|
|
250
|
+
validateDelivery(decoded.value.delivery)
|
|
283
251
|
} catch (cause) {
|
|
284
252
|
return yield* new WorkbaseConfigError({
|
|
285
253
|
path: configPath,
|
|
@@ -693,7 +661,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
693
661
|
}
|
|
694
662
|
}
|
|
695
663
|
}
|
|
696
|
-
for (const cycle of
|
|
664
|
+
for (const cycle of findDependencyCycles(epic.data.tasks)) {
|
|
697
665
|
issue(epic.path, `Task dependency cycle includes '${cycle}'`)
|
|
698
666
|
}
|
|
699
667
|
}
|
|
@@ -739,7 +707,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
739
707
|
issue(task.path, `Unlisted phase '${phaseId}'`)
|
|
740
708
|
}
|
|
741
709
|
}
|
|
742
|
-
for (const cycle of
|
|
710
|
+
for (const cycle of findDependencyCycles(task.data.phases)) {
|
|
743
711
|
issue(task.path, `Phase dependency cycle includes '${cycle}'`)
|
|
744
712
|
}
|
|
745
713
|
} else if (actualPhaseIds.length > 0) {
|
package/src/test-utils.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { GraphService } from "./services/GraphService"
|
|
|
18
18
|
import { ClaimService } from "./services/ClaimService"
|
|
19
19
|
import { SyncService } from "./services/SyncService"
|
|
20
20
|
import { ReadinessService } from "./services/ReadinessService"
|
|
21
|
+
import { GraphMutationService } from "./services/GraphMutationService"
|
|
21
22
|
|
|
22
23
|
export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
|
|
23
24
|
|
|
@@ -40,6 +41,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
40
41
|
ClaimService.Default,
|
|
41
42
|
SyncService.Default,
|
|
42
43
|
ReadinessService.Default,
|
|
44
|
+
GraphMutationService.Default,
|
|
43
45
|
)
|
|
44
46
|
|
|
45
47
|
export async function runTestEffect<A, E>(
|
|
@@ -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
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Dependency } from "./schemas"
|
|
2
|
+
|
|
3
|
+
export const findDependencyCycles = (
|
|
4
|
+
nodes: readonly Dependency[],
|
|
5
|
+
): readonly string[] => {
|
|
6
|
+
const dependencies = new Map(
|
|
7
|
+
nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
|
|
8
|
+
)
|
|
9
|
+
const visiting = new Set<string>()
|
|
10
|
+
const visited = new Set<string>()
|
|
11
|
+
const cycles = new Set<string>()
|
|
12
|
+
|
|
13
|
+
const visit = (id: string) => {
|
|
14
|
+
if (visiting.has(id)) {
|
|
15
|
+
cycles.add(id)
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
if (visited.has(id)) return
|
|
19
|
+
|
|
20
|
+
visiting.add(id)
|
|
21
|
+
for (const dependency of dependencies.get(id) ?? []) {
|
|
22
|
+
if (dependencies.has(dependency)) visit(dependency)
|
|
23
|
+
}
|
|
24
|
+
visiting.delete(id)
|
|
25
|
+
visited.add(id)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const id of dependencies.keys()) visit(id)
|
|
29
|
+
return [...cycles].sort()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const validateDependencies = (
|
|
33
|
+
nodes: readonly Dependency[],
|
|
34
|
+
label: string,
|
|
35
|
+
): string | undefined => {
|
|
36
|
+
const singular = label.endsWith("s") ? label.slice(0, -1) : label
|
|
37
|
+
const ids = new Set(nodes.map((node) => node.id))
|
|
38
|
+
if (ids.size !== nodes.length) return `${label} IDs must be unique`
|
|
39
|
+
for (const node of nodes) {
|
|
40
|
+
for (const dependency of node.dependsOn ?? []) {
|
|
41
|
+
if (dependency === node.id) {
|
|
42
|
+
return `${singular} '${node.id}' cannot depend on itself`
|
|
43
|
+
}
|
|
44
|
+
if (!ids.has(dependency)) {
|
|
45
|
+
return `Unknown ${singular.toLowerCase()} dependency '${dependency}'`
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const cycle = findDependencyCycles(nodes)[0]
|
|
50
|
+
return cycle ? `${singular} dependency cycle includes '${cycle}'` : undefined
|
|
51
|
+
}
|
|
@@ -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,
|