@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.
@@ -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.multiPhase) {
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
- "repo" in data
136
- ? [
137
- data.repo,
138
- ...(data.repos ?? []).map((reference) => reference.repo),
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: [documentWriteStep(root, writes)],
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 all = [writable, ...referenceAliases].filter(
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, "README.md")).text(),
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, "branch", "--show-current"],
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, "README.md")).text(),
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, "README.md"),
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, "README.md")).text(),
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, "status", "--porcelain"],
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, "uncommitted.txt"),
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, "uncommitted.txt")).text(),
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, "README.md")).exists(),
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, "README.md")).exists(),
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, "README.md")).exists(),
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, "README.md")).exists(),
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, "README.md")).exists(),
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, "README.md")).text(),
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
- repo: string
186
- repos?: readonly RepositoryReference[]
187
- branch: string
188
- base: string
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
- { repo: execution.repo, branch: execution.branch },
249
- ...(execution.repos ?? []),
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
- repo: string
613
- repos?: readonly RepositoryReference[]
614
- branch: string
615
- base: string
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
- { repo: execution.repo, branch: execution.branch },
644
- ...(execution.repos ?? []),
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
- `${execution.base}^{commit}`,
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(execution.base),
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 '${execution.base}' for repository '${alias}' does not resolve to a commit`,
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: execution.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: execution.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
- execution.base,
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
- `${execution.base}^{commit}`,
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: join(codePath, execution.repo),
1270
- repo: execution.repo,
1271
- repos: execution.repos ?? [],
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
- repo: string
1417
- repos?: readonly RepositoryReference[]
1418
- branch: string
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
- { repo: execution.repo, branch: execution.branch },
1455
- ...(execution.repos ?? []),
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(executions.map((execution) => execution.data.branch)),
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(executions, (execution) => Boolean(execution.data.pr), [
109
- "present",
110
- "absent",
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((execution) => Boolean(execution.data.pr)),
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",