@markjaquith/agency 2.15.0 → 2.17.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 +35 -1
- package/cli.ts +40 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +34 -0
- package/src/cli-parser.ts +38 -4
- package/src/cli.test.ts +50 -0
- package/src/commands/next.ts +64 -0
- package/src/commands/pr.ts +2 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/commands/sync.ts +36 -0
- package/src/commands/work.test.ts +84 -0
- package/src/commands/work.ts +25 -2
- package/src/protocol.ts +11 -0
- package/src/services/ClaimService.ts +94 -0
- package/src/services/GraphService.test.ts +36 -0
- package/src/services/GraphService.ts +8 -1
- package/src/services/PullRequestService.test.ts +20 -1
- package/src/services/PullRequestService.ts +14 -1
- package/src/services/ReadinessService.test.ts +223 -0
- package/src/services/ReadinessService.ts +230 -0
- package/src/services/SyncService.test.ts +364 -0
- package/src/services/SyncService.ts +738 -0
- package/src/services/WorktreeService.test.ts +12 -0
- package/src/services/WorktreeService.ts +6 -2
- package/src/test-utils.ts +4 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import type { AgencyGraph, GraphBlocker, GraphNode } from "../graph-schema"
|
|
3
|
+
import type { WorkStatus } from "../workbase/schemas"
|
|
4
|
+
import { GraphService } from "./GraphService"
|
|
5
|
+
|
|
6
|
+
type ExecutionNode = Extract<GraphNode, { readonly kind: "execution-unit" }>
|
|
7
|
+
|
|
8
|
+
interface NextItem {
|
|
9
|
+
readonly rank: number
|
|
10
|
+
readonly key: string
|
|
11
|
+
readonly taskId: string
|
|
12
|
+
readonly phaseId?: string
|
|
13
|
+
readonly description?: string
|
|
14
|
+
readonly parent: {
|
|
15
|
+
readonly taskId?: string
|
|
16
|
+
readonly epicId?: string
|
|
17
|
+
}
|
|
18
|
+
readonly status: WorkStatus
|
|
19
|
+
readonly repositories: readonly string[]
|
|
20
|
+
readonly priority: {
|
|
21
|
+
readonly dependentCount: number
|
|
22
|
+
}
|
|
23
|
+
readonly ready: boolean
|
|
24
|
+
readonly terminal: boolean
|
|
25
|
+
readonly blockedBy: readonly string[]
|
|
26
|
+
readonly blockers: readonly GraphBlocker[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface NextResult {
|
|
30
|
+
readonly ready: readonly NextItem[]
|
|
31
|
+
readonly excluded: readonly NextItem[]
|
|
32
|
+
readonly selected?: NextItem
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class ExecutionGuardError extends Data.TaggedError("ExecutionGuardError")<{
|
|
36
|
+
readonly message: string
|
|
37
|
+
readonly action: "work" | "pr"
|
|
38
|
+
readonly target: string
|
|
39
|
+
readonly status: WorkStatus
|
|
40
|
+
readonly blockedBy: readonly string[]
|
|
41
|
+
readonly blockers: readonly GraphBlocker[]
|
|
42
|
+
}> {}
|
|
43
|
+
|
|
44
|
+
const executionNodeId = (taskId: string, phaseId?: string) =>
|
|
45
|
+
phaseId
|
|
46
|
+
? `execution-unit:phase/${taskId}/${phaseId}`
|
|
47
|
+
: `execution-unit:task/${taskId}`
|
|
48
|
+
|
|
49
|
+
const itemFor = (
|
|
50
|
+
node: ExecutionNode,
|
|
51
|
+
graph: AgencyGraph,
|
|
52
|
+
rank: number,
|
|
53
|
+
): NextItem => {
|
|
54
|
+
const task = graph.nodes.find(
|
|
55
|
+
(candidate) =>
|
|
56
|
+
candidate.kind === "task" && candidate.key === node.data.taskId,
|
|
57
|
+
)
|
|
58
|
+
const epicId =
|
|
59
|
+
task?.kind === "task" && typeof task.data.epic === "string"
|
|
60
|
+
? task.data.epic
|
|
61
|
+
: undefined
|
|
62
|
+
const dependentIds = new Set(node.dependents)
|
|
63
|
+
if (node.data.phaseId) {
|
|
64
|
+
const siblings = graph.nodes.filter(
|
|
65
|
+
(candidate): candidate is ExecutionNode =>
|
|
66
|
+
candidate.kind === "execution-unit" &&
|
|
67
|
+
candidate.data.taskId === node.data.taskId &&
|
|
68
|
+
candidate.id !== node.id,
|
|
69
|
+
)
|
|
70
|
+
if (siblings.every((sibling) => sibling.status === "done")) {
|
|
71
|
+
for (const dependent of task?.dependents ?? [])
|
|
72
|
+
dependentIds.add(dependent)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
rank,
|
|
77
|
+
key: node.key,
|
|
78
|
+
taskId: node.data.taskId,
|
|
79
|
+
...(node.data.phaseId ? { phaseId: node.data.phaseId } : {}),
|
|
80
|
+
...(node.data.description ? { description: node.data.description } : {}),
|
|
81
|
+
parent: {
|
|
82
|
+
...(node.data.phaseId ? { taskId: node.data.taskId } : {}),
|
|
83
|
+
...(epicId ? { epicId } : {}),
|
|
84
|
+
},
|
|
85
|
+
status: node.status,
|
|
86
|
+
repositories: node.repositories,
|
|
87
|
+
priority: { dependentCount: dependentIds.size },
|
|
88
|
+
ready: node.readiness.ready,
|
|
89
|
+
terminal: node.readiness.terminal,
|
|
90
|
+
blockedBy: node.readiness.blockedBy,
|
|
91
|
+
blockers: node.readiness.blockers,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rankedItems = (graph: AgencyGraph) =>
|
|
96
|
+
graph.nodes
|
|
97
|
+
.filter((node): node is ExecutionNode => node.kind === "execution-unit")
|
|
98
|
+
.map((node) => itemFor(node, graph, 0))
|
|
99
|
+
.sort(
|
|
100
|
+
(left, right) =>
|
|
101
|
+
right.priority.dependentCount - left.priority.dependentCount ||
|
|
102
|
+
left.key.localeCompare(right.key),
|
|
103
|
+
)
|
|
104
|
+
.map((item, index) => ({ ...item, rank: index + 1 }))
|
|
105
|
+
|
|
106
|
+
const guardMessage = (
|
|
107
|
+
action: "work" | "pr",
|
|
108
|
+
item: Pick<NextItem, "key" | "status" | "blockers">,
|
|
109
|
+
) => {
|
|
110
|
+
const reasons = item.blockers.map((blocker) => blocker.reason)
|
|
111
|
+
return `Cannot ${action === "work" ? "work on" : "create a pull request for"} '${item.key}': ${reasons.length > 0 ? reasons.join("; ") : `status is ${item.status}`}. Use --force to override.`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class ReadinessService extends Effect.Service<ReadinessService>()(
|
|
115
|
+
"ReadinessService",
|
|
116
|
+
{
|
|
117
|
+
sync: () => ({
|
|
118
|
+
getReadyWorkTargetIds: (cwd: string = process.cwd()) =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const graphs = yield* GraphService
|
|
121
|
+
const graph = yield* graphs.get({ cwd })
|
|
122
|
+
return new Set(
|
|
123
|
+
graph.nodes
|
|
124
|
+
.filter((node) => node.readiness?.ready)
|
|
125
|
+
.map((node) => node.id),
|
|
126
|
+
)
|
|
127
|
+
}),
|
|
128
|
+
|
|
129
|
+
getNext: (cwd: string = process.cwd(), select = false) =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const graphs = yield* GraphService
|
|
132
|
+
const graph = yield* graphs.get({ cwd })
|
|
133
|
+
const items = rankedItems(graph)
|
|
134
|
+
const ready = items
|
|
135
|
+
.filter((item) => item.ready)
|
|
136
|
+
.map((item, index) => ({ ...item, rank: index + 1 }))
|
|
137
|
+
const excluded = items
|
|
138
|
+
.filter((item) => !item.ready)
|
|
139
|
+
.map((item, index) => ({ ...item, rank: index + 1 }))
|
|
140
|
+
return {
|
|
141
|
+
ready,
|
|
142
|
+
excluded,
|
|
143
|
+
...(select && ready[0] ? { selected: ready[0] } : {}),
|
|
144
|
+
} satisfies NextResult
|
|
145
|
+
}),
|
|
146
|
+
|
|
147
|
+
guardWorkTarget: (
|
|
148
|
+
target: string,
|
|
149
|
+
cwd: string = process.cwd(),
|
|
150
|
+
override = false,
|
|
151
|
+
) =>
|
|
152
|
+
Effect.gen(function* () {
|
|
153
|
+
if (override) return
|
|
154
|
+
const graphs = yield* GraphService
|
|
155
|
+
const graph = yield* graphs.get({ cwd })
|
|
156
|
+
const node = graph.nodes.find((candidate) => candidate.id === target)
|
|
157
|
+
if (!node || !node.readiness) {
|
|
158
|
+
return yield* new ExecutionGuardError({
|
|
159
|
+
message: `Work target '${target}' was not found in the work graph.`,
|
|
160
|
+
action: "work",
|
|
161
|
+
target,
|
|
162
|
+
status: "open",
|
|
163
|
+
blockedBy: [],
|
|
164
|
+
blockers: [],
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
if (!node.readiness.ready) {
|
|
168
|
+
const item = {
|
|
169
|
+
key: node.key,
|
|
170
|
+
status: node.status!,
|
|
171
|
+
blockers: node.readiness.blockers,
|
|
172
|
+
}
|
|
173
|
+
return yield* new ExecutionGuardError({
|
|
174
|
+
message: guardMessage("work", item),
|
|
175
|
+
action: "work",
|
|
176
|
+
target,
|
|
177
|
+
status: node.status!,
|
|
178
|
+
blockedBy: node.readiness.blockedBy,
|
|
179
|
+
blockers: node.readiness.blockers,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
guard: (
|
|
185
|
+
action: "work" | "pr",
|
|
186
|
+
taskId: string,
|
|
187
|
+
phaseId?: string,
|
|
188
|
+
cwd: string = process.cwd(),
|
|
189
|
+
override = false,
|
|
190
|
+
) =>
|
|
191
|
+
Effect.gen(function* () {
|
|
192
|
+
if (override) return
|
|
193
|
+
const graphs = yield* GraphService
|
|
194
|
+
const graph = yield* graphs.get({ cwd })
|
|
195
|
+
const items = rankedItems(graph)
|
|
196
|
+
const key = phaseId ? `phase/${taskId}/${phaseId}` : `task/${taskId}`
|
|
197
|
+
const item = items.find((candidate) => candidate.key === key)
|
|
198
|
+
if (!item) {
|
|
199
|
+
return yield* new ExecutionGuardError({
|
|
200
|
+
message: `Execution unit '${key}' was not found in the work graph.`,
|
|
201
|
+
action,
|
|
202
|
+
target: executionNodeId(taskId, phaseId),
|
|
203
|
+
status: "open",
|
|
204
|
+
blockedBy: [],
|
|
205
|
+
blockers: [],
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
const actionable =
|
|
209
|
+
action === "work"
|
|
210
|
+
? item.ready
|
|
211
|
+
: !item.terminal &&
|
|
212
|
+
!item.blockers.some(
|
|
213
|
+
(blocker) =>
|
|
214
|
+
blocker.kind === "dependency" ||
|
|
215
|
+
blocker.kind === "validation",
|
|
216
|
+
)
|
|
217
|
+
if (!actionable) {
|
|
218
|
+
return yield* new ExecutionGuardError({
|
|
219
|
+
message: guardMessage(action, item),
|
|
220
|
+
action,
|
|
221
|
+
target: executionNodeId(taskId, phaseId),
|
|
222
|
+
status: item.status,
|
|
223
|
+
blockedBy: item.blockedBy,
|
|
224
|
+
blockers: item.blockers,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
}),
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
) {}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { chmod, mkdir, rm } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { ClaimService } from "./ClaimService"
|
|
7
|
+
import { PullRequestService } from "./PullRequestService"
|
|
8
|
+
import { SyncService } from "./SyncService"
|
|
9
|
+
import { TaskService } from "./TaskService"
|
|
10
|
+
import { WorktreeService } from "./WorktreeService"
|
|
11
|
+
|
|
12
|
+
const git = async (args: string[], cwd?: string) => {
|
|
13
|
+
const process = Bun.spawn(["git", ...args], {
|
|
14
|
+
cwd,
|
|
15
|
+
stdout: "pipe",
|
|
16
|
+
stderr: "pipe",
|
|
17
|
+
})
|
|
18
|
+
await process.exited
|
|
19
|
+
if (process.exitCode !== 0) {
|
|
20
|
+
throw new Error(await new Response(process.stderr).text())
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("SyncService", () => {
|
|
25
|
+
let root: string
|
|
26
|
+
let originalPath: string | undefined
|
|
27
|
+
|
|
28
|
+
beforeEach(async () => {
|
|
29
|
+
root = await createTempDir()
|
|
30
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
31
|
+
const source = join(root, "source")
|
|
32
|
+
await mkdir(source, { recursive: true })
|
|
33
|
+
await git(["init", "--initial-branch=main"], source)
|
|
34
|
+
await git(["config", "user.email", "test@example.com"], source)
|
|
35
|
+
await git(["config", "user.name", "Test"], source)
|
|
36
|
+
await Bun.write(join(source, "README.md"), "example\n")
|
|
37
|
+
await git(["add", "README.md"], source)
|
|
38
|
+
await git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"], source)
|
|
39
|
+
await mkdir(join(root, "repos"), { recursive: true })
|
|
40
|
+
await git(["clone", "--bare", source, join(root, "repos/agency")])
|
|
41
|
+
await git(["clone", "--bare", source, join(root, "repos/reference")])
|
|
42
|
+
|
|
43
|
+
const bin = join(root, "bin")
|
|
44
|
+
await mkdir(bin)
|
|
45
|
+
const gh = join(bin, "gh")
|
|
46
|
+
await Bun.write(
|
|
47
|
+
gh,
|
|
48
|
+
`#!/bin/sh
|
|
49
|
+
if [ "$2" = "view" ]; then
|
|
50
|
+
cat <<'JSON'
|
|
51
|
+
{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"}}
|
|
52
|
+
JSON
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
cat <<'JSON'
|
|
56
|
+
[{"number":42,"state":"MERGED","title":"Ship","isDraft":false,"headRefName":"feat/example","baseRefName":"main","url":"https://github.com/example/agency/pull/42","mergedAt":"2100-01-01T00:00:00Z","mergeCommit":{"oid":"abc"}}]
|
|
57
|
+
JSON
|
|
58
|
+
`,
|
|
59
|
+
)
|
|
60
|
+
await chmod(gh, 0o755)
|
|
61
|
+
originalPath = process.env.PATH
|
|
62
|
+
process.env.PATH = `${bin}:${originalPath}`
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
afterEach(async () => {
|
|
66
|
+
if (originalPath === undefined) delete process.env.PATH
|
|
67
|
+
else process.env.PATH = originalPath
|
|
68
|
+
await cleanupTempDir(root)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("observes drift without mutation and applies only safe transitions", async () => {
|
|
72
|
+
await runTestEffect(
|
|
73
|
+
TaskService.pipe(
|
|
74
|
+
Effect.flatMap((service) =>
|
|
75
|
+
service.create(
|
|
76
|
+
{
|
|
77
|
+
id: "example",
|
|
78
|
+
ticketUrl: null,
|
|
79
|
+
repo: "agency",
|
|
80
|
+
repos: [{ repo: "reference", ref: "main" }],
|
|
81
|
+
branch: "feat/example",
|
|
82
|
+
base: "main",
|
|
83
|
+
},
|
|
84
|
+
root,
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
const workspace = await runTestEffect(
|
|
90
|
+
WorktreeService.pipe(
|
|
91
|
+
Effect.flatMap((service) =>
|
|
92
|
+
service.materialize("example", undefined, root),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
await git(
|
|
97
|
+
["remote", "set-url", "origin", "git@github.com:example/agency.git"],
|
|
98
|
+
join(root, "repos/agency"),
|
|
99
|
+
)
|
|
100
|
+
const inspected = await runTestEffect(
|
|
101
|
+
ClaimService.pipe(
|
|
102
|
+
Effect.flatMap((service) =>
|
|
103
|
+
service.inspect("example", undefined, root),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
await runTestEffect(
|
|
108
|
+
ClaimService.pipe(
|
|
109
|
+
Effect.flatMap((service) =>
|
|
110
|
+
service.claim(
|
|
111
|
+
{
|
|
112
|
+
taskId: "example",
|
|
113
|
+
claimant: "orchestrator",
|
|
114
|
+
runner: "agent",
|
|
115
|
+
sessionId: "session-1",
|
|
116
|
+
revision: inspected.revision,
|
|
117
|
+
expiresAt: "2099-01-01T00:00:00.000Z",
|
|
118
|
+
},
|
|
119
|
+
root,
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
await Bun.write(
|
|
125
|
+
join(workspace.codePath, "reference", "LOCAL.md"),
|
|
126
|
+
"dirty\n",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const taskPath = join(root, "tasks/example/TASK.md")
|
|
130
|
+
const before = await Bun.file(taskPath).text()
|
|
131
|
+
const observed = await runTestEffect(
|
|
132
|
+
SyncService.pipe(
|
|
133
|
+
Effect.flatMap((service) =>
|
|
134
|
+
service.reconcile({ cwd: root, now: new Date("2100-01-02") }),
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
expect(observed.mode).toBe("dry-run")
|
|
140
|
+
expect(observed.warnings).toContainEqual(
|
|
141
|
+
expect.objectContaining({
|
|
142
|
+
kind: "dirty-reference",
|
|
143
|
+
target: "task:example",
|
|
144
|
+
}),
|
|
145
|
+
)
|
|
146
|
+
expect(observed.changes.map((change) => change.kind)).toEqual([
|
|
147
|
+
"release-stale-claim",
|
|
148
|
+
"record-pr",
|
|
149
|
+
"mark-done",
|
|
150
|
+
])
|
|
151
|
+
expect(await Bun.file(taskPath).text()).toBe(before)
|
|
152
|
+
|
|
153
|
+
const applied = await runTestEffect(
|
|
154
|
+
SyncService.pipe(
|
|
155
|
+
Effect.flatMap((service) =>
|
|
156
|
+
service.reconcile({
|
|
157
|
+
cwd: root,
|
|
158
|
+
apply: true,
|
|
159
|
+
now: new Date("2100-01-02"),
|
|
160
|
+
}),
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
expect(applied.changes.map((change) => change.kind)).toEqual([
|
|
165
|
+
"release-stale-claim",
|
|
166
|
+
"record-pr",
|
|
167
|
+
"mark-done",
|
|
168
|
+
])
|
|
169
|
+
expect(applied.changes.every((change) => change.status === "applied")).toBe(
|
|
170
|
+
true,
|
|
171
|
+
)
|
|
172
|
+
const task = await runTestEffect(
|
|
173
|
+
TaskService.pipe(
|
|
174
|
+
Effect.flatMap((service) => service.show("example", root)),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
expect(task.data).toMatchObject({
|
|
178
|
+
status: "done",
|
|
179
|
+
pr: "https://github.com/example/agency/pull/42",
|
|
180
|
+
claim: { state: "released", sessionId: "session-1" },
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("materializes missing workspaces but leaves branch conflicts unresolved", async () => {
|
|
185
|
+
for (const [id, branch] of [
|
|
186
|
+
["missing", "feat/missing"],
|
|
187
|
+
["conflict", "feat/conflict"],
|
|
188
|
+
] as const) {
|
|
189
|
+
await runTestEffect(
|
|
190
|
+
TaskService.pipe(
|
|
191
|
+
Effect.flatMap((service) =>
|
|
192
|
+
service.create(
|
|
193
|
+
{ id, ticketUrl: null, repo: "agency", branch, base: "main" },
|
|
194
|
+
root,
|
|
195
|
+
),
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
const repository = join(root, "repos/agency")
|
|
201
|
+
await git(["branch", "feat/conflict", "main"], repository)
|
|
202
|
+
await git(
|
|
203
|
+
["worktree", "add", join(root, "external-conflict"), "feat/conflict"],
|
|
204
|
+
repository,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const observed = await runTestEffect(
|
|
208
|
+
SyncService.pipe(
|
|
209
|
+
Effect.flatMap((service) => service.reconcile({ cwd: root })),
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
expect(observed.changes).toContainEqual(
|
|
213
|
+
expect.objectContaining({
|
|
214
|
+
kind: "materialize-workspace",
|
|
215
|
+
target: "task:missing",
|
|
216
|
+
status: "planned",
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
expect(observed.unresolved).toContainEqual(
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
kind: "branch-conflict",
|
|
222
|
+
target: "task:conflict",
|
|
223
|
+
}),
|
|
224
|
+
)
|
|
225
|
+
expect(
|
|
226
|
+
await Bun.file(
|
|
227
|
+
join(root, "tasks/missing/code/agency/README.md"),
|
|
228
|
+
).exists(),
|
|
229
|
+
).toBe(false)
|
|
230
|
+
|
|
231
|
+
const applied = await runTestEffect(
|
|
232
|
+
SyncService.pipe(
|
|
233
|
+
Effect.flatMap((service) =>
|
|
234
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
235
|
+
),
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
expect(applied.changes).toContainEqual(
|
|
239
|
+
expect.objectContaining({
|
|
240
|
+
kind: "materialize-workspace",
|
|
241
|
+
target: "task:missing",
|
|
242
|
+
status: "applied",
|
|
243
|
+
}),
|
|
244
|
+
)
|
|
245
|
+
expect(
|
|
246
|
+
applied.executions.find((item) => item.target === "task:missing")
|
|
247
|
+
?.checkouts[0],
|
|
248
|
+
).toMatchObject({ exists: true, registered: true, dirty: false })
|
|
249
|
+
expect(
|
|
250
|
+
await Bun.file(join(root, "tasks/missing/code/agency/README.md")).text(),
|
|
251
|
+
).toBe("example\n")
|
|
252
|
+
expect(
|
|
253
|
+
await Bun.file(
|
|
254
|
+
join(root, "tasks/conflict/code/agency/README.md"),
|
|
255
|
+
).exists(),
|
|
256
|
+
).toBe(false)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test("leaves a missing checkout registration unresolved", async () => {
|
|
260
|
+
await runTestEffect(
|
|
261
|
+
TaskService.pipe(
|
|
262
|
+
Effect.flatMap((service) =>
|
|
263
|
+
service.create(
|
|
264
|
+
{
|
|
265
|
+
id: "stale",
|
|
266
|
+
ticketUrl: null,
|
|
267
|
+
repo: "agency",
|
|
268
|
+
branch: "feat/stale",
|
|
269
|
+
base: "main",
|
|
270
|
+
},
|
|
271
|
+
root,
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
const workspace = await runTestEffect(
|
|
277
|
+
WorktreeService.pipe(
|
|
278
|
+
Effect.flatMap((service) =>
|
|
279
|
+
service.materialize("stale", undefined, root),
|
|
280
|
+
),
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
await rm(workspace.writablePath, { recursive: true, force: true })
|
|
284
|
+
|
|
285
|
+
const observed = await runTestEffect(
|
|
286
|
+
SyncService.pipe(
|
|
287
|
+
Effect.flatMap((service) => service.reconcile({ cwd: root })),
|
|
288
|
+
),
|
|
289
|
+
)
|
|
290
|
+
expect(observed.changes).toEqual([])
|
|
291
|
+
expect(observed.unresolved).toContainEqual(
|
|
292
|
+
expect.objectContaining({
|
|
293
|
+
kind: "stale-registration",
|
|
294
|
+
target: "task:stale",
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
const applied = await runTestEffect(
|
|
299
|
+
SyncService.pipe(
|
|
300
|
+
Effect.flatMap((service) =>
|
|
301
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
302
|
+
),
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
expect(applied.changes).toEqual([])
|
|
306
|
+
expect(
|
|
307
|
+
await Bun.file(join(workspace.writablePath, "README.md")).exists(),
|
|
308
|
+
).toBe(false)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test("does not trust a recorded PR from another repository", async () => {
|
|
312
|
+
await runTestEffect(
|
|
313
|
+
TaskService.pipe(
|
|
314
|
+
Effect.flatMap((service) =>
|
|
315
|
+
service.create(
|
|
316
|
+
{
|
|
317
|
+
id: "example",
|
|
318
|
+
ticketUrl: null,
|
|
319
|
+
repo: "agency",
|
|
320
|
+
branch: "feat/example",
|
|
321
|
+
base: "main",
|
|
322
|
+
},
|
|
323
|
+
root,
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
),
|
|
327
|
+
)
|
|
328
|
+
await runTestEffect(
|
|
329
|
+
PullRequestService.pipe(
|
|
330
|
+
Effect.flatMap((service) =>
|
|
331
|
+
service.setUrl(
|
|
332
|
+
"example",
|
|
333
|
+
undefined,
|
|
334
|
+
"https://github.com/other/repository/pull/42",
|
|
335
|
+
root,
|
|
336
|
+
),
|
|
337
|
+
),
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
const applied = await runTestEffect(
|
|
342
|
+
SyncService.pipe(
|
|
343
|
+
Effect.flatMap((service) =>
|
|
344
|
+
service.reconcile({ cwd: root, apply: true }),
|
|
345
|
+
),
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
expect(applied.unresolved).toContainEqual(
|
|
349
|
+
expect.objectContaining({
|
|
350
|
+
kind: "pr-repository-conflict",
|
|
351
|
+
target: "task:example",
|
|
352
|
+
}),
|
|
353
|
+
)
|
|
354
|
+
expect(applied.changes.some((change) => change.kind === "mark-done")).toBe(
|
|
355
|
+
false,
|
|
356
|
+
)
|
|
357
|
+
const task = await runTestEffect(
|
|
358
|
+
TaskService.pipe(
|
|
359
|
+
Effect.flatMap((service) => service.show("example", root)),
|
|
360
|
+
),
|
|
361
|
+
)
|
|
362
|
+
expect(task.data).toMatchObject({ status: "open" })
|
|
363
|
+
})
|
|
364
|
+
})
|