@markjaquith/agency 2.49.0 → 2.50.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 +22 -1
- package/cli.ts +23 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +141 -38
- package/src/cli-parser.test.ts +62 -0
- package/src/cli-parser.ts +55 -1
- package/src/commands/review.ts +38 -0
- package/src/commands/task.ts +27 -5
- package/src/commands/work.test.ts +2 -0
- package/src/commands/work.ts +2 -2
- package/src/graph-schema.ts +35 -13
- package/src/protocol.ts +1 -0
- package/src/services/ArchiveService.test.ts +2 -2
- package/src/services/ArchiveService.ts +1 -0
- package/src/services/ClaimService.ts +3 -2
- package/src/services/ContextService.ts +56 -4
- package/src/services/DoctorService.ts +45 -1
- package/src/services/GraphMutationService.ts +5 -0
- package/src/services/GraphService.ts +118 -54
- package/src/services/PhaseService.ts +5 -0
- package/src/services/PullRequestService.test.ts +2 -2
- package/src/services/PullRequestService.ts +23 -3
- package/src/services/ReadinessService.ts +7 -3
- package/src/services/ReviewService.test.ts +472 -0
- package/src/services/ReviewService.ts +404 -0
- package/src/services/SyncService.test.ts +2 -2
- package/src/services/SyncService.ts +110 -6
- package/src/services/TaskService.ts +82 -9
- package/src/services/WorkbaseService.ts +2 -1
- package/src/services/WorktreeService.test.ts +16 -16
- package/src/services/WorktreeService.ts +76 -40
- package/src/test-utils.ts +2 -0
- package/src/work-view.ts +14 -6
- package/src/workbase/schemas.test.ts +57 -0
- package/src/workbase/schemas.ts +56 -0
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
EntityId,
|
|
9
9
|
TaskFrontmatter,
|
|
10
10
|
type RepositoryReference,
|
|
11
|
+
type ReviewRecord,
|
|
11
12
|
type TaskFrontmatter as TaskData,
|
|
12
13
|
WorkStatus,
|
|
13
14
|
} from "../workbase/schemas"
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
import {
|
|
27
28
|
documentWriteStep,
|
|
28
29
|
runLifecycleTransaction,
|
|
30
|
+
type TransactionStep,
|
|
29
31
|
} from "./LifecycleTransaction"
|
|
30
32
|
|
|
31
33
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
@@ -50,6 +52,7 @@ export interface CreateTaskInput {
|
|
|
50
52
|
readonly repos?: readonly RepositoryReference[]
|
|
51
53
|
readonly branch?: string
|
|
52
54
|
readonly base?: string
|
|
55
|
+
readonly review?: ReviewRecord
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
const decodeTask = (input: unknown) => {
|
|
@@ -78,6 +81,50 @@ const decodeStatus = (status: string) => {
|
|
|
78
81
|
: Effect.succeed(result.right)
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
const reviewPinRef = (taskId: string) =>
|
|
85
|
+
`refs/agency/reviews/${Buffer.from(taskId).toString("hex")}`
|
|
86
|
+
|
|
87
|
+
const reviewPinStep = (
|
|
88
|
+
root: string,
|
|
89
|
+
taskId: string,
|
|
90
|
+
review: ReviewRecord,
|
|
91
|
+
): TransactionStep => {
|
|
92
|
+
const repositoryPath = join(root, "repos", review.repo)
|
|
93
|
+
const ref = reviewPinRef(taskId)
|
|
94
|
+
const run = async (args: readonly string[]) => {
|
|
95
|
+
const child = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
|
|
96
|
+
const [exitCode, stderr] = await Promise.all([
|
|
97
|
+
child.exited,
|
|
98
|
+
new Response(child.stderr).text(),
|
|
99
|
+
])
|
|
100
|
+
if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
label: `retain review pin for ${taskId}`,
|
|
104
|
+
apply: () =>
|
|
105
|
+
run([
|
|
106
|
+
"git",
|
|
107
|
+
"-C",
|
|
108
|
+
repositoryPath,
|
|
109
|
+
"update-ref",
|
|
110
|
+
ref,
|
|
111
|
+
review.commit,
|
|
112
|
+
"0".repeat(40),
|
|
113
|
+
]),
|
|
114
|
+
rollback: () =>
|
|
115
|
+
run([
|
|
116
|
+
"git",
|
|
117
|
+
"-C",
|
|
118
|
+
repositoryPath,
|
|
119
|
+
"update-ref",
|
|
120
|
+
"-d",
|
|
121
|
+
ref,
|
|
122
|
+
review.commit,
|
|
123
|
+
]),
|
|
124
|
+
manualRecovery: `Delete ${ref} from repository '${review.repo}'`,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
81
128
|
export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
82
129
|
sync: () => ({
|
|
83
130
|
create: (input: CreateTaskInput, startPath: string = process.cwd()) =>
|
|
@@ -102,7 +149,28 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
102
149
|
}
|
|
103
150
|
|
|
104
151
|
let data: TaskData
|
|
105
|
-
if (input.
|
|
152
|
+
if (input.review) {
|
|
153
|
+
if (
|
|
154
|
+
input.multiPhase ||
|
|
155
|
+
input.repo !== undefined ||
|
|
156
|
+
input.repos !== undefined ||
|
|
157
|
+
input.branch !== undefined ||
|
|
158
|
+
input.base !== undefined
|
|
159
|
+
) {
|
|
160
|
+
return yield* new TaskError({
|
|
161
|
+
message:
|
|
162
|
+
"Review tasks cannot include writable or multi-phase fields",
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
data = yield* decodeTask({
|
|
166
|
+
ticketUrl: input.ticketUrl,
|
|
167
|
+
...(input.description !== undefined
|
|
168
|
+
? { description: input.description }
|
|
169
|
+
: {}),
|
|
170
|
+
...(input.epic ? { epic: input.epic } : {}),
|
|
171
|
+
review: input.review,
|
|
172
|
+
})
|
|
173
|
+
} else if (input.multiPhase) {
|
|
106
174
|
data = yield* decodeTask({
|
|
107
175
|
ticketUrl: input.ticketUrl,
|
|
108
176
|
...(input.description !== undefined
|
|
@@ -132,12 +200,14 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
132
200
|
}
|
|
133
201
|
|
|
134
202
|
const referencedRepos =
|
|
135
|
-
"
|
|
136
|
-
? [
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
203
|
+
"review" in data
|
|
204
|
+
? [data.review.repo]
|
|
205
|
+
: "repo" in data
|
|
206
|
+
? [
|
|
207
|
+
data.repo,
|
|
208
|
+
...(data.repos ?? []).map((reference) => reference.repo),
|
|
209
|
+
]
|
|
210
|
+
: []
|
|
141
211
|
if (new Set(referencedRepos).size !== referencedRepos.length) {
|
|
142
212
|
return yield* new TaskError({
|
|
143
213
|
message:
|
|
@@ -192,7 +262,10 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
192
262
|
preconditions: parentEpic
|
|
193
263
|
? [{ path: parentEpic.path, revision: parentEpic.revision }]
|
|
194
264
|
: [],
|
|
195
|
-
steps: [
|
|
265
|
+
steps: [
|
|
266
|
+
...(input.review ? [reviewPinStep(root, id, input.review)] : []),
|
|
267
|
+
documentWriteStep(root, writes),
|
|
268
|
+
],
|
|
196
269
|
})
|
|
197
270
|
|
|
198
271
|
return {
|
|
@@ -279,7 +352,7 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
279
352
|
message: "Non-PR completion is valid only with a done status",
|
|
280
353
|
})
|
|
281
354
|
}
|
|
282
|
-
if (nonPrCompletion && record.data.pr !== null) {
|
|
355
|
+
if (nonPrCompletion && "pr" in record.data && record.data.pr !== null) {
|
|
283
356
|
return yield* new TaskError({
|
|
284
357
|
message:
|
|
285
358
|
"Cannot complete without a pull request while an authoritative pull request is recorded",
|
|
@@ -712,7 +712,8 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
712
712
|
)
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
-
const
|
|
715
|
+
const reviewAlias = "review" in data ? data.review.repo : undefined
|
|
716
|
+
const all = [writable, reviewAlias, ...referenceAliases].filter(
|
|
716
717
|
(alias): alias is string => alias !== undefined,
|
|
717
718
|
)
|
|
718
719
|
for (const alias of new Set(all)) {
|
|
@@ -81,13 +81,13 @@ describe("WorktreeService", () => {
|
|
|
81
81
|
)
|
|
82
82
|
|
|
83
83
|
expect(
|
|
84
|
-
await Bun.file(join(workspace.writablePath
|
|
84
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).text(),
|
|
85
85
|
).toBe("example\n")
|
|
86
86
|
expect(
|
|
87
87
|
await Bun.file(join(workspace.codePath, "effect/README.md")).text(),
|
|
88
88
|
).toBe("example\n")
|
|
89
89
|
const branch = Bun.spawnSync(
|
|
90
|
-
["git", "-C", workspace.writablePath
|
|
90
|
+
["git", "-C", workspace.writablePath!, "branch", "--show-current"],
|
|
91
91
|
{ stdout: "pipe" },
|
|
92
92
|
)
|
|
93
93
|
expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
|
|
@@ -272,7 +272,7 @@ describe("WorktreeService", () => {
|
|
|
272
272
|
),
|
|
273
273
|
)
|
|
274
274
|
expect(
|
|
275
|
-
await Bun.file(join(workspace.writablePath
|
|
275
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).text(),
|
|
276
276
|
).toBe("example\n")
|
|
277
277
|
})
|
|
278
278
|
|
|
@@ -599,7 +599,7 @@ pr: null
|
|
|
599
599
|
|
|
600
600
|
expect(
|
|
601
601
|
await Bun.file(
|
|
602
|
-
join(originalWorkspace.writablePath
|
|
602
|
+
join(originalWorkspace.writablePath!, "README.md"),
|
|
603
603
|
).exists(),
|
|
604
604
|
).toBe(false)
|
|
605
605
|
const movedWorkspace = await runTestEffect(
|
|
@@ -613,10 +613,10 @@ pr: null
|
|
|
613
613
|
join(root, "tasks/promoted/phases/implementation/code/agency"),
|
|
614
614
|
)
|
|
615
615
|
expect(
|
|
616
|
-
await Bun.file(join(movedWorkspace.writablePath
|
|
616
|
+
await Bun.file(join(movedWorkspace.writablePath!, "README.md")).text(),
|
|
617
617
|
).toBe("example\n")
|
|
618
618
|
const status = Bun.spawnSync(
|
|
619
|
-
["git", "-C", movedWorkspace.writablePath
|
|
619
|
+
["git", "-C", movedWorkspace.writablePath!, "status", "--porcelain"],
|
|
620
620
|
{ stdout: "pipe", stderr: "pipe" },
|
|
621
621
|
)
|
|
622
622
|
expect(status.exitCode).toBe(0)
|
|
@@ -1072,7 +1072,7 @@ pr: null
|
|
|
1072
1072
|
),
|
|
1073
1073
|
)
|
|
1074
1074
|
await Bun.write(
|
|
1075
|
-
join(workspace.writablePath
|
|
1075
|
+
join(workspace.writablePath!, "uncommitted.txt"),
|
|
1076
1076
|
"keep me\n",
|
|
1077
1077
|
)
|
|
1078
1078
|
|
|
@@ -1084,7 +1084,7 @@ pr: null
|
|
|
1084
1084
|
),
|
|
1085
1085
|
).rejects.toThrow("Failed to remove worktree for 'agency'")
|
|
1086
1086
|
expect(
|
|
1087
|
-
await Bun.file(join(workspace.writablePath
|
|
1087
|
+
await Bun.file(join(workspace.writablePath!, "uncommitted.txt")).text(),
|
|
1088
1088
|
).toBe("keep me\n")
|
|
1089
1089
|
})
|
|
1090
1090
|
|
|
@@ -1131,7 +1131,7 @@ pr: null
|
|
|
1131
1131
|
"--porcelain",
|
|
1132
1132
|
])
|
|
1133
1133
|
expect(new TextDecoder().decode(worktrees.stdout)).not.toContain(
|
|
1134
|
-
workspace.writablePath
|
|
1134
|
+
workspace.writablePath!,
|
|
1135
1135
|
)
|
|
1136
1136
|
expect(
|
|
1137
1137
|
Bun.spawnSync([
|
|
@@ -1325,7 +1325,7 @@ pr: null
|
|
|
1325
1325
|
),
|
|
1326
1326
|
).rejects.toThrow("multiple Agency owners")
|
|
1327
1327
|
expect(
|
|
1328
|
-
await Bun.file(join(workspace.writablePath
|
|
1328
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
1329
1329
|
).toBe(true)
|
|
1330
1330
|
})
|
|
1331
1331
|
|
|
@@ -1371,7 +1371,7 @@ pr: null
|
|
|
1371
1371
|
]),
|
|
1372
1372
|
)
|
|
1373
1373
|
expect(
|
|
1374
|
-
await Bun.file(join(original.writablePath
|
|
1374
|
+
await Bun.file(join(original.writablePath!, "README.md")).exists(),
|
|
1375
1375
|
).toBe(true)
|
|
1376
1376
|
|
|
1377
1377
|
const rebuilt = await runTestEffect(
|
|
@@ -1383,7 +1383,7 @@ pr: null
|
|
|
1383
1383
|
)
|
|
1384
1384
|
expect(rebuilt.inspection.conflicts).toEqual([])
|
|
1385
1385
|
expect(
|
|
1386
|
-
await Bun.file(join(original.writablePath
|
|
1386
|
+
await Bun.file(join(original.writablePath!, "README.md")).exists(),
|
|
1387
1387
|
).toBe(true)
|
|
1388
1388
|
})
|
|
1389
1389
|
|
|
@@ -1435,7 +1435,7 @@ pr: null
|
|
|
1435
1435
|
),
|
|
1436
1436
|
).rejects.toThrow("original worktrees were restored")
|
|
1437
1437
|
expect(
|
|
1438
|
-
await Bun.file(join(workspace.writablePath
|
|
1438
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
1439
1439
|
).toBe(true)
|
|
1440
1440
|
})
|
|
1441
1441
|
|
|
@@ -1473,7 +1473,7 @@ pr: null
|
|
|
1473
1473
|
),
|
|
1474
1474
|
)
|
|
1475
1475
|
expect(plan.actions.join("\n")).toContain("worktree prune --expire now")
|
|
1476
|
-
expect(plan.actions).toContain(`prepare ${workspace.writablePath}`)
|
|
1476
|
+
expect(plan.actions).toContain(`prepare ${workspace.writablePath!}`)
|
|
1477
1477
|
|
|
1478
1478
|
const repaired = await runTestEffect(
|
|
1479
1479
|
WorktreeService.pipe(
|
|
@@ -1484,7 +1484,7 @@ pr: null
|
|
|
1484
1484
|
)
|
|
1485
1485
|
expect(repaired.inspection.conflicts).toEqual([])
|
|
1486
1486
|
expect(
|
|
1487
|
-
await Bun.file(join(workspace.writablePath
|
|
1487
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).exists(),
|
|
1488
1488
|
).toBe(true)
|
|
1489
1489
|
expect(
|
|
1490
1490
|
Bun.spawnSync([
|
|
@@ -1648,7 +1648,7 @@ pr: null
|
|
|
1648
1648
|
),
|
|
1649
1649
|
)
|
|
1650
1650
|
expect(
|
|
1651
|
-
await Bun.file(join(workspace.writablePath
|
|
1651
|
+
await Bun.file(join(workspace.writablePath!, "README.md")).text(),
|
|
1652
1652
|
).toBe("example\n")
|
|
1653
1653
|
})
|
|
1654
1654
|
})
|
|
@@ -43,7 +43,8 @@ interface ExecutionWorkspace {
|
|
|
43
43
|
readonly taskPath: string
|
|
44
44
|
readonly phasePath: string | null
|
|
45
45
|
readonly codePath: string
|
|
46
|
-
readonly writablePath: string
|
|
46
|
+
readonly writablePath: string | null
|
|
47
|
+
readonly reviewPath: string | null
|
|
47
48
|
readonly repo: string
|
|
48
49
|
readonly repos: readonly RepositoryReference[]
|
|
49
50
|
readonly dryRun: boolean
|
|
@@ -181,12 +182,14 @@ const inspectExecution = (
|
|
|
181
182
|
const root = yield* workbase.discover(startPath)
|
|
182
183
|
const task = yield* tasks.show(taskId, root)
|
|
183
184
|
|
|
184
|
-
let execution:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
let execution:
|
|
186
|
+
| {
|
|
187
|
+
repo: string
|
|
188
|
+
repos?: readonly RepositoryReference[]
|
|
189
|
+
branch: string
|
|
190
|
+
base: string
|
|
191
|
+
}
|
|
192
|
+
| { review: { repo: string; commit: string } }
|
|
190
193
|
let owner: WorktreeOwner
|
|
191
194
|
let codePath: string
|
|
192
195
|
if ("phases" in task.data) {
|
|
@@ -230,6 +233,7 @@ const inspectExecution = (
|
|
|
230
233
|
ownership.set(key, owners)
|
|
231
234
|
}
|
|
232
235
|
} else {
|
|
236
|
+
if (!("repo" in taskRecord.data)) continue
|
|
233
237
|
const key = `${taskRecord.data.repo}:${taskRecord.data.branch}`
|
|
234
238
|
const owners = ownership.get(key) ?? []
|
|
235
239
|
owners.push({
|
|
@@ -244,10 +248,13 @@ const inspectExecution = (
|
|
|
244
248
|
const declared: readonly (
|
|
245
249
|
| { readonly repo: string; readonly branch: string }
|
|
246
250
|
| RepositoryReference
|
|
247
|
-
)[] =
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
)[] =
|
|
252
|
+
"review" in execution
|
|
253
|
+
? [{ repo: execution.review.repo, ref: execution.review.commit }]
|
|
254
|
+
: [
|
|
255
|
+
{ repo: execution.repo, branch: execution.branch },
|
|
256
|
+
...(execution.repos ?? []),
|
|
257
|
+
]
|
|
251
258
|
const checkouts: WorktreeCheckoutInspection[] = []
|
|
252
259
|
for (const checkout of declared) {
|
|
253
260
|
const repositoryPath = join(root, "repos", checkout.repo)
|
|
@@ -608,12 +615,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
608
615
|
}
|
|
609
616
|
const task = yield* tasks.show(taskId, root)
|
|
610
617
|
|
|
611
|
-
let execution:
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
618
|
+
let execution:
|
|
619
|
+
| {
|
|
620
|
+
repo: string
|
|
621
|
+
repos?: readonly RepositoryReference[]
|
|
622
|
+
branch: string
|
|
623
|
+
base: string
|
|
624
|
+
}
|
|
625
|
+
| { review: { repo: string; commit: string } }
|
|
617
626
|
let phasePath: string | null = null
|
|
618
627
|
let codePath: string
|
|
619
628
|
if ("phases" in task.data) {
|
|
@@ -639,10 +648,19 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
639
648
|
const requestedCheckouts: readonly (
|
|
640
649
|
| { readonly repo: string; readonly branch: string }
|
|
641
650
|
| RepositoryReference
|
|
642
|
-
)[] =
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
651
|
+
)[] =
|
|
652
|
+
"review" in execution
|
|
653
|
+
? [
|
|
654
|
+
{
|
|
655
|
+
repo: execution.review.repo,
|
|
656
|
+
ref: execution.review.commit,
|
|
657
|
+
},
|
|
658
|
+
]
|
|
659
|
+
: [
|
|
660
|
+
{ repo: execution.repo, branch: execution.branch },
|
|
661
|
+
...(execution.repos ?? []),
|
|
662
|
+
]
|
|
663
|
+
const executionBase = "base" in execution ? execution.base : ""
|
|
646
664
|
const canonicalCodePath = (yield* fs.exists(codePath))
|
|
647
665
|
? yield* fs.realPath(codePath)
|
|
648
666
|
: resolve(codePath)
|
|
@@ -732,7 +750,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
732
750
|
repositoryPath,
|
|
733
751
|
"rev-parse",
|
|
734
752
|
"--verify",
|
|
735
|
-
`${
|
|
753
|
+
`${executionBase}^{commit}`,
|
|
736
754
|
],
|
|
737
755
|
{ captureOutput: true },
|
|
738
756
|
)
|
|
@@ -744,7 +762,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
744
762
|
repositoryPath,
|
|
745
763
|
"ls-remote",
|
|
746
764
|
"origin",
|
|
747
|
-
originRef(
|
|
765
|
+
originRef(executionBase),
|
|
748
766
|
],
|
|
749
767
|
{ captureOutput: true },
|
|
750
768
|
)
|
|
@@ -753,7 +771,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
753
771
|
!remoteBase.stdout.trim()
|
|
754
772
|
) {
|
|
755
773
|
return yield* new WorktreeError({
|
|
756
|
-
message: `Base '${
|
|
774
|
+
message: `Base '${executionBase}' for repository '${alias}' does not resolve to a commit`,
|
|
757
775
|
})
|
|
758
776
|
}
|
|
759
777
|
}
|
|
@@ -764,7 +782,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
764
782
|
repo: repositoryPath,
|
|
765
783
|
worktree: checkoutPath,
|
|
766
784
|
branch: checkout.branch,
|
|
767
|
-
base:
|
|
785
|
+
base: executionBase,
|
|
768
786
|
})
|
|
769
787
|
} catch (cause) {
|
|
770
788
|
return yield* new WorktreeError({
|
|
@@ -991,7 +1009,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
991
1009
|
repo: repositoryPath,
|
|
992
1010
|
worktree: checkoutPath,
|
|
993
1011
|
branch: checkout.branch,
|
|
994
|
-
base:
|
|
1012
|
+
base: executionBase,
|
|
995
1013
|
}
|
|
996
1014
|
try {
|
|
997
1015
|
args = expandWorktreeCreateCommand(
|
|
@@ -1026,7 +1044,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1026
1044
|
repositoryPath,
|
|
1027
1045
|
"branch",
|
|
1028
1046
|
checkout.branch,
|
|
1029
|
-
|
|
1047
|
+
executionBase,
|
|
1030
1048
|
]
|
|
1031
1049
|
operations.push({
|
|
1032
1050
|
action: "create-branch",
|
|
@@ -1085,7 +1103,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1085
1103
|
repositoryPath,
|
|
1086
1104
|
"rev-parse",
|
|
1087
1105
|
"--verify",
|
|
1088
|
-
`${
|
|
1106
|
+
`${executionBase}^{commit}`,
|
|
1089
1107
|
],
|
|
1090
1108
|
{ captureOutput: true },
|
|
1091
1109
|
)
|
|
@@ -1266,9 +1284,17 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1266
1284
|
taskPath: task.path,
|
|
1267
1285
|
phasePath,
|
|
1268
1286
|
codePath,
|
|
1269
|
-
writablePath:
|
|
1270
|
-
|
|
1271
|
-
|
|
1287
|
+
writablePath:
|
|
1288
|
+
"review" in execution ? null : join(codePath, execution.repo),
|
|
1289
|
+
reviewPath:
|
|
1290
|
+
"review" in execution
|
|
1291
|
+
? join(codePath, execution.review.repo)
|
|
1292
|
+
: null,
|
|
1293
|
+
repo:
|
|
1294
|
+
"review" in execution
|
|
1295
|
+
? execution.review.repo
|
|
1296
|
+
: execution.repo,
|
|
1297
|
+
repos: "review" in execution ? [] : (execution.repos ?? []),
|
|
1272
1298
|
dryRun: options.dryRun === true,
|
|
1273
1299
|
checkouts: checkoutReports,
|
|
1274
1300
|
operations,
|
|
@@ -1412,11 +1438,13 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1412
1438
|
}
|
|
1413
1439
|
const task = yield* tasks.show(taskId, root)
|
|
1414
1440
|
|
|
1415
|
-
let execution:
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1441
|
+
let execution:
|
|
1442
|
+
| {
|
|
1443
|
+
repo: string
|
|
1444
|
+
repos?: readonly RepositoryReference[]
|
|
1445
|
+
branch: string
|
|
1446
|
+
}
|
|
1447
|
+
| { review: { repo: string; commit: string } }
|
|
1420
1448
|
let codePath: string
|
|
1421
1449
|
if ("phases" in task.data) {
|
|
1422
1450
|
if (!phaseId) {
|
|
@@ -1450,10 +1478,18 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1450
1478
|
const expectedCheckouts: readonly (
|
|
1451
1479
|
| { readonly repo: string; readonly branch: string }
|
|
1452
1480
|
| RepositoryReference
|
|
1453
|
-
)[] =
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1481
|
+
)[] =
|
|
1482
|
+
"review" in execution
|
|
1483
|
+
? [
|
|
1484
|
+
{
|
|
1485
|
+
repo: execution.review.repo,
|
|
1486
|
+
ref: execution.review.commit,
|
|
1487
|
+
},
|
|
1488
|
+
]
|
|
1489
|
+
: [
|
|
1490
|
+
{ repo: execution.repo, branch: execution.branch },
|
|
1491
|
+
...(execution.repos ?? []),
|
|
1492
|
+
]
|
|
1457
1493
|
const expectedAliases = expectedCheckouts.map(({ repo }) => repo)
|
|
1458
1494
|
if (codeDirectoryExists) {
|
|
1459
1495
|
const unmanaged = (yield* fs.readDirectory(codePath)).filter(
|
package/src/test-utils.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { SyncService } from "./services/SyncService"
|
|
|
20
20
|
import { ReadinessService } from "./services/ReadinessService"
|
|
21
21
|
import { GraphMutationService } from "./services/GraphMutationService"
|
|
22
22
|
import { DoctorService } from "./services/DoctorService"
|
|
23
|
+
import { ReviewService } from "./services/ReviewService"
|
|
23
24
|
|
|
24
25
|
export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
|
|
25
26
|
|
|
@@ -44,6 +45,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
44
45
|
ReadinessService.Default,
|
|
45
46
|
GraphMutationService.Default,
|
|
46
47
|
DoctorService.Default,
|
|
48
|
+
ReviewService.Default,
|
|
47
49
|
)
|
|
48
50
|
|
|
49
51
|
export async function runTestEffect<A, E>(
|
package/src/work-view.ts
CHANGED
|
@@ -79,7 +79,11 @@ const rowFor = (
|
|
|
79
79
|
executions: readonly ExecutionNode[],
|
|
80
80
|
): WorkViewRow => {
|
|
81
81
|
const branches = [
|
|
82
|
-
...new Set(
|
|
82
|
+
...new Set(
|
|
83
|
+
executions.flatMap((execution) =>
|
|
84
|
+
"branch" in execution.data ? [execution.data.branch] : [],
|
|
85
|
+
),
|
|
86
|
+
),
|
|
83
87
|
]
|
|
84
88
|
const parent =
|
|
85
89
|
node.kind === "task"
|
|
@@ -105,16 +109,19 @@ const rowFor = (
|
|
|
105
109
|
: branches.length === 1
|
|
106
110
|
? branches[0]!
|
|
107
111
|
: "multiple",
|
|
108
|
-
pr: aggregateLabel(
|
|
109
|
-
|
|
110
|
-
"
|
|
111
|
-
|
|
112
|
+
pr: aggregateLabel(
|
|
113
|
+
executions,
|
|
114
|
+
(execution) => "pr" in execution.data && Boolean(execution.data.pr),
|
|
115
|
+
["present", "absent"],
|
|
116
|
+
),
|
|
112
117
|
worktree: aggregateLabel(
|
|
113
118
|
executions,
|
|
114
119
|
(execution) => execution.workspace?.materialized === true,
|
|
115
120
|
["materialized", "absent"],
|
|
116
121
|
),
|
|
117
|
-
hasPr: executions.some(
|
|
122
|
+
hasPr: executions.some(
|
|
123
|
+
(execution) => "pr" in execution.data && Boolean(execution.data.pr),
|
|
124
|
+
),
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
|
|
@@ -213,6 +220,7 @@ export const getWorkViews = (options: WorkViewOptions = {}) =>
|
|
|
213
220
|
return executions.filter(
|
|
214
221
|
(execution) =>
|
|
215
222
|
execution.data.taskId === taskId &&
|
|
223
|
+
"phaseId" in execution.data &&
|
|
216
224
|
execution.data.phaseId === phaseId,
|
|
217
225
|
)
|
|
218
226
|
}
|
|
@@ -64,6 +64,63 @@ describe("portable repository declarations", () => {
|
|
|
64
64
|
})
|
|
65
65
|
|
|
66
66
|
describe("body-of-work descriptions", () => {
|
|
67
|
+
test("decodes review tasks strictly and rejects writable execution fields", () => {
|
|
68
|
+
const review = {
|
|
69
|
+
ticketUrl: null,
|
|
70
|
+
review: {
|
|
71
|
+
repo: "agency",
|
|
72
|
+
source: { kind: "branch", ref: "refs/heads/feature/review" },
|
|
73
|
+
commit: "a".repeat(40),
|
|
74
|
+
refreshedAt: "2026-07-23T12:00:00.000Z",
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
expect(
|
|
78
|
+
Schema.decodeUnknownSync(TaskFrontmatter, { onExcessProperty: "error" })(
|
|
79
|
+
review,
|
|
80
|
+
),
|
|
81
|
+
).toMatchObject({ status: "open", review: review.review })
|
|
82
|
+
for (const field of ["repo", "repos", "branch", "base", "pr"]) {
|
|
83
|
+
expect(() =>
|
|
84
|
+
Schema.decodeUnknownSync(TaskFrontmatter, {
|
|
85
|
+
onExcessProperty: "error",
|
|
86
|
+
})({ ...review, [field]: field === "repos" ? [] : "forbidden" }),
|
|
87
|
+
).toThrow()
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("rejects inconsistent pull request source provenance", () => {
|
|
92
|
+
const source = {
|
|
93
|
+
kind: "pull-request",
|
|
94
|
+
provider: "github",
|
|
95
|
+
repository: "owner/repo",
|
|
96
|
+
identifier: "42",
|
|
97
|
+
url: "https://github.com/owner/repo/pull/42",
|
|
98
|
+
fetchRef: "refs/pull/42/head",
|
|
99
|
+
}
|
|
100
|
+
const review = {
|
|
101
|
+
ticketUrl: null,
|
|
102
|
+
review: {
|
|
103
|
+
repo: "agency",
|
|
104
|
+
source,
|
|
105
|
+
commit: "a".repeat(40),
|
|
106
|
+
refreshedAt: "2026-07-23T12:00:00.000Z",
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
expect(Schema.decodeUnknownSync(TaskFrontmatter)(review)).toBeDefined()
|
|
110
|
+
for (const inconsistent of [
|
|
111
|
+
{ ...source, url: "https://github.com/owner/repo/pull/41" },
|
|
112
|
+
{ ...source, repository: "other/repo" },
|
|
113
|
+
{ ...source, fetchRef: "refs/pull/41/head" },
|
|
114
|
+
]) {
|
|
115
|
+
expect(() =>
|
|
116
|
+
Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
117
|
+
...review,
|
|
118
|
+
review: { ...review.review, source: inconsistent },
|
|
119
|
+
}),
|
|
120
|
+
).toThrow()
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
|
|
67
124
|
test("accepts descriptions on epics, tasks, and phases", () => {
|
|
68
125
|
const epic = Schema.decodeUnknownSync(EpicFrontmatter)({
|
|
69
126
|
ticketUrl: "https://example.com/epic",
|