@markjaquith/agency 2.49.0 → 2.51.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.
Files changed (50) hide show
  1. package/README.md +56 -9
  2. package/cli.ts +56 -1
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +142 -38
  5. package/src/cli-parser.test.ts +81 -0
  6. package/src/cli-parser.ts +78 -1
  7. package/src/cli.test.ts +4 -4
  8. package/src/commands/init.test.ts +2 -0
  9. package/src/commands/review.ts +38 -0
  10. package/src/commands/task.ts +27 -5
  11. package/src/commands/vcs.test.ts +75 -0
  12. package/src/commands/vcs.ts +72 -0
  13. package/src/commands/work.test.ts +2 -0
  14. package/src/commands/work.ts +2 -2
  15. package/src/commands/worktree.ts +1 -1
  16. package/src/graph-schema.test.ts +1 -1
  17. package/src/graph-schema.ts +36 -13
  18. package/src/protocol.ts +1 -0
  19. package/src/services/ArchiveService.test.ts +2 -2
  20. package/src/services/ArchiveService.ts +24 -0
  21. package/src/services/ClaimService.ts +3 -2
  22. package/src/services/ContextService.ts +118 -17
  23. package/src/services/DoctorService.ts +58 -7
  24. package/src/services/GraphMutationService.ts +5 -0
  25. package/src/services/GraphService.ts +119 -54
  26. package/src/services/PhaseService.ts +196 -70
  27. package/src/services/PullRequestService.test.ts +2 -2
  28. package/src/services/PullRequestService.ts +37 -33
  29. package/src/services/ReadinessService.ts +7 -3
  30. package/src/services/RepositoryService.test.ts +31 -1
  31. package/src/services/RepositoryService.ts +63 -31
  32. package/src/services/ReviewService.test.ts +472 -0
  33. package/src/services/ReviewService.ts +427 -0
  34. package/src/services/SyncService.test.ts +69 -2
  35. package/src/services/SyncService.ts +161 -90
  36. package/src/services/TaskPhaseService.test.ts +80 -0
  37. package/src/services/TaskService.ts +82 -9
  38. package/src/services/VcsMigrationService.test.ts +211 -0
  39. package/src/services/VcsMigrationService.ts +816 -0
  40. package/src/services/VersionControlService.test.ts +100 -0
  41. package/src/services/VersionControlService.ts +479 -0
  42. package/src/services/WorkbaseService.ts +7 -2
  43. package/src/services/WorktreeService.test.ts +88 -17
  44. package/src/services/WorktreeService.ts +865 -329
  45. package/src/test-utils.ts +12 -0
  46. package/src/work-view.ts +14 -6
  47. package/src/workbase/AGENTS.md +2 -2
  48. package/src/workbase/schemas.test.ts +57 -0
  49. package/src/workbase/schemas.ts +57 -0
  50. package/src/workbase/version-control.ts +5 -0
@@ -25,6 +25,7 @@ import {
25
25
  RepositoryService,
26
26
  type RepositorySetupResult,
27
27
  } from "./RepositoryService"
28
+ import { VersionControlService } from "./VersionControlService"
28
29
 
29
30
  class SyncError extends Data.TaggedError("SyncError")<{
30
31
  readonly message: string
@@ -83,11 +84,16 @@ interface CheckoutState {
83
84
  interface ExecutionSyncState {
84
85
  readonly target: string
85
86
  readonly status: WorkStatus
86
- readonly branch: string
87
- readonly base: string
87
+ readonly branch: string | null
88
+ readonly base: string | null
88
89
  readonly claim: ClaimRecord | null
89
90
  readonly checkouts: readonly CheckoutState[]
90
91
  readonly pr: Record<string, unknown>
92
+ readonly review?: {
93
+ readonly pinnedCommit: string
94
+ readonly sourceCommit: string | null
95
+ readonly sourceAvailable: boolean
96
+ }
91
97
  }
92
98
 
93
99
  interface SyncResult {
@@ -156,7 +162,9 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
156
162
  const worktrees = yield* WorktreeService
157
163
  const claims = yield* ClaimService
158
164
  const repositories = yield* RepositoryService
165
+ const versionControl = yield* VersionControlService
159
166
  const { root, config } = yield* workbase.loadConfig(options.cwd)
167
+ const backend = yield* versionControl.forWorkbase(root)
160
168
  const validation = yield* workbase.validate(root)
161
169
  if (!validation.valid) {
162
170
  return yield* new SyncError({
@@ -219,7 +227,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
219
227
  data: phase.data,
220
228
  })
221
229
  }
222
- } else {
230
+ } else if (!("review" in task.data)) {
223
231
  records.push({
224
232
  key: `task:${task.id}`,
225
233
  taskId: task.id,
@@ -263,33 +271,27 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
263
271
  workspaceConflict = true
264
272
  continue
265
273
  }
266
- const listed = yield* fs.runCommand(
267
- [
268
- "git",
269
- "-C",
270
- repositoryPath,
271
- "worktree",
272
- "list",
273
- "--porcelain",
274
- "-z",
275
- ],
276
- { captureOutput: true },
274
+ const listed = yield* Effect.either(
275
+ backend.listWorkspaces(repositoryPath),
277
276
  )
278
- if (listed.exitCode !== 0) {
277
+ if (Either.isLeft(listed)) {
279
278
  unresolved.push({
280
279
  kind: "worktree-inspection-failed",
281
280
  target: record.key,
282
- message:
283
- listed.stderr.trim() || `Cannot inspect '${checkout.repo}'`,
281
+ message: `Cannot inspect '${checkout.repo}'`,
284
282
  })
285
283
  workspaceConflict = true
286
284
  continue
287
285
  }
288
286
  const exists = yield* fs.isDirectory(checkoutPath)
289
287
  const registered: RegisteredWorktree[] = []
290
- for (const item of parseWorktrees(listed.stdout)) {
288
+ for (const item of listed.right) {
291
289
  registered.push({
292
- ...item,
290
+ head: item.commit,
291
+ branch:
292
+ backend.kind === "jj" && "branch" in checkout
293
+ ? checkout.branch
294
+ : item.branch,
293
295
  path: (yield* fs.exists(item.path))
294
296
  ? yield* fs.realPath(item.path)
295
297
  : resolve(item.path),
@@ -301,39 +303,36 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
301
303
  ? join(yield* fs.realPath(codePath), checkout.repo)
302
304
  : resolve(checkoutPath)
303
305
  let atPath = registered.find((item) => item.path === expectedPath)
306
+ if (backend.kind === "jj" && atPath && exists) {
307
+ atPath = {
308
+ ...atPath,
309
+ head: yield* backend.workspaceHead(checkoutPath),
310
+ }
311
+ }
304
312
  const branchRef =
305
- "branch" in checkout ? `refs/heads/${checkout.branch}` : null
306
- const branchElsewhere = branchRef
307
- ? registered.find(
308
- (item) =>
309
- item.branch === branchRef && item.path !== expectedPath,
310
- )
311
- : undefined
313
+ "branch" in checkout
314
+ ? backend.kind === "jj"
315
+ ? checkout.branch
316
+ : `refs/heads/${checkout.branch}`
317
+ : null
318
+ const branchElsewhere =
319
+ branchRef && backend.kind !== "jj"
320
+ ? registered.find(
321
+ (item) =>
322
+ item.branch === branchRef && item.path !== expectedPath,
323
+ )
324
+ : undefined
312
325
  if ("branch" in checkout && !exists && !branchElsewhere) {
313
- const branch = yield* fs.runCommand(
314
- [
315
- "git",
316
- "-C",
317
- repositoryPath,
318
- "rev-parse",
319
- "--verify",
320
- `${checkout.branch}^{commit}`,
321
- ],
322
- { captureOutput: true },
326
+ const branch = yield* backend.resolveRevision(
327
+ repositoryPath,
328
+ checkout.branch,
323
329
  )
324
- if (branch.exitCode !== 0) {
325
- const base = yield* fs.runCommand(
326
- [
327
- "git",
328
- "-C",
329
- repositoryPath,
330
- "rev-parse",
331
- "--verify",
332
- `${data.base}^{commit}`,
333
- ],
334
- { captureOutput: true },
330
+ if (!branch) {
331
+ const base = yield* backend.resolveRevision(
332
+ repositoryPath,
333
+ data.base,
335
334
  )
336
- if (base.exitCode !== 0) {
335
+ if (!base) {
337
336
  unresolved.push({
338
337
  kind: "unresolved-base",
339
338
  target: record.key,
@@ -399,22 +398,10 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
399
398
  })
400
399
  }
401
400
  }
402
- const resolvedRef = resolvedCommit
403
- ? null
404
- : yield* fs.runCommand(
405
- [
406
- "git",
407
- "-C",
408
- repositoryPath,
409
- "rev-parse",
410
- "--verify",
411
- `${checkout.ref}^{commit}`,
412
- ],
413
- { captureOutput: true },
414
- )
415
- if (resolvedRef?.exitCode === 0) {
416
- resolvedCommit = resolvedRef.stdout.trim()
417
- }
401
+ resolvedCommit ??= yield* backend.resolveRevision(
402
+ repositoryPath,
403
+ checkout.ref,
404
+ )
418
405
  if (!resolvedCommit) {
419
406
  unresolved.push({
420
407
  kind: "unresolved-reference",
@@ -426,23 +413,15 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
426
413
  }
427
414
  }
428
415
 
429
- const dirtyResult =
416
+ const dirty =
430
417
  exists && atPath
431
- ? yield* fs.runCommand(
432
- ["git", "-C", checkoutPath, "status", "--porcelain"],
433
- { captureOutput: true },
434
- )
418
+ ? yield* backend.workspaceDirty(checkoutPath)
435
419
  : null
436
- const dirty = dirtyResult
437
- ? dirtyResult.exitCode === 0
438
- ? dirtyResult.stdout.length > 0
439
- : null
440
- : null
441
- if (dirtyResult && dirtyResult.exitCode !== 0) {
420
+ if (exists && atPath && dirty === null) {
442
421
  warnings.push({
443
422
  kind: "status-inspection-failed",
444
423
  target: record.key,
445
- message: `Could not inspect dirtiness for ${checkoutPath}: ${dirtyResult.stderr.trim()}`,
424
+ message: `Could not inspect dirtiness for ${checkoutPath}`,
446
425
  action: "Inspect the checkout manually before changing it",
447
426
  })
448
427
  }
@@ -551,7 +530,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
551
530
  },
552
531
  root,
553
532
  )
554
- data = expired.data
533
+ data = expired.data as ExecutionData
555
534
  revision = expired.revision
556
535
  } else {
557
536
  const claim: ClaimRecord = {
@@ -602,15 +581,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
602
581
  let prConflict = false
603
582
  const repositoryPath = join(root, "repos", data.repo)
604
583
  const remoteName = config.delivery?.remote ?? "origin"
605
- const remote = yield* runExternal([
606
- "git",
607
- "-C",
608
- repositoryPath,
609
- "remote",
610
- "get-url",
611
- remoteName,
612
- ])
613
- const remoteRepository = remote.stdout
584
+ const remoteUrl = yield* backend.remoteUrl(repositoryPath, remoteName)
585
+ const remoteRepository = (remoteUrl ?? "")
614
586
  .trim()
615
587
  .replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
616
588
  .replace(/^[^:]+:/, "")
@@ -630,11 +602,11 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
630
602
  })
631
603
  }
632
604
 
633
- if (config.delivery && remote.exitCode !== 0) {
605
+ if (config.delivery && !remoteUrl) {
634
606
  warnings.push({
635
607
  kind: "delivery-remote-unavailable",
636
608
  target: record.key,
637
- message: `Could not inspect delivery remote '${remoteName}': ${remote.stderr.trim()}`,
609
+ message: `Could not inspect delivery remote '${remoteName}'`,
638
610
  })
639
611
  } else if (config.delivery) {
640
612
  const resolved = resolveDeliveryCommand(config.delivery, "query", {
@@ -787,7 +759,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
787
759
  },
788
760
  root,
789
761
  )
790
- data = recorded.data
762
+ data = recorded.data as ExecutionData
791
763
  revision = recorded.revision
792
764
  }
793
765
  changes.push({
@@ -823,7 +795,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
823
795
  },
824
796
  root,
825
797
  )
826
- data = completed.data
798
+ data = completed.data as ExecutionData
827
799
  revision = completed.revision
828
800
  }
829
801
  changes.push({
@@ -846,6 +818,105 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
846
818
  })
847
819
  }
848
820
 
821
+ for (const task of (yield* tasks.list(root)).filter(
822
+ (task) => "review" in task.data,
823
+ )) {
824
+ if (!("review" in task.data)) continue
825
+ let data = task.data
826
+ let revision = task.revision
827
+ if (
828
+ isExpired(data.claim, now) &&
829
+ (data.status === "working" || data.status === "delegated")
830
+ ) {
831
+ if (apply) {
832
+ const expired = yield* claims.expire(
833
+ { taskId: task.id, revision, now },
834
+ root,
835
+ )
836
+ if ("review" in expired.data) data = expired.data
837
+ revision = expired.revision
838
+ }
839
+ changes.push({
840
+ kind: "release-stale-claim",
841
+ target: `task:${task.id}`,
842
+ message: `Release expired claim '${data.claim?.sessionId ?? "unknown"}'`,
843
+ status: apply ? "applied" : "planned",
844
+ })
845
+ }
846
+ const inspection = yield* worktrees.inspect(task.id, undefined, root)
847
+ for (const conflict of inspection.conflicts) {
848
+ unresolved.push({
849
+ kind: conflict.kind,
850
+ target: `task:${task.id}`,
851
+ message: conflict.message,
852
+ action: "Repair or remove the review checkout explicitly",
853
+ })
854
+ }
855
+ const checkout = inspection.checkouts[0]
856
+ if (
857
+ !checkout?.exists &&
858
+ inspection.conflicts.length === 0 &&
859
+ (data.status === "working" || data.status === "delegated")
860
+ ) {
861
+ if (apply) yield* worktrees.materialize(task.id, undefined, root)
862
+ changes.push({
863
+ kind: "materialize-workspace",
864
+ target: `task:${task.id}`,
865
+ message: `Materialize pinned review checkout under ${inspection.codePath}`,
866
+ status: apply ? "applied" : "planned",
867
+ })
868
+ }
869
+ const repositoryPath = join(root, "repos", data.review.repo)
870
+ const source = yield* runExternal([
871
+ "git",
872
+ "-C",
873
+ repositoryPath,
874
+ "ls-remote",
875
+ "origin",
876
+ data.review.source.kind === "pull-request"
877
+ ? data.review.source.fetchRef
878
+ : originRef(data.review.source.ref),
879
+ ])
880
+ const sourceCommit = source.stdout.trim().split(/\s+/)[0] || null
881
+ if (!sourceCommit) {
882
+ warnings.push({
883
+ kind: "review-source-unavailable",
884
+ target: `task:${task.id}`,
885
+ message:
886
+ "Review source is unavailable; the pinned commit is unchanged",
887
+ })
888
+ }
889
+ executions.push({
890
+ target: `task:${task.id}`,
891
+ status: data.status,
892
+ branch: null,
893
+ base: null,
894
+ claim: data.claim ?? null,
895
+ checkouts: checkout
896
+ ? [
897
+ {
898
+ repo: checkout.repo,
899
+ kind: "reference",
900
+ path: checkout.path,
901
+ requestedRef: data.review.commit,
902
+ resolvedCommit: checkout.expectedCommit,
903
+ registered: checkout.registered,
904
+ exists: checkout.exists,
905
+ head: checkout.actualCommit,
906
+ branch: checkout.actualBranch,
907
+ dirty: checkout.dirty,
908
+ },
909
+ ]
910
+ : [],
911
+ pr: { url: null, state: "none" },
912
+ review: {
913
+ pinnedCommit: data.review.commit,
914
+ sourceCommit,
915
+ sourceAvailable: sourceCommit !== null,
916
+ },
917
+ })
918
+ }
919
+
849
920
  return {
850
921
  root,
851
922
  mode: apply ? "apply" : "dry-run",
@@ -7,6 +7,13 @@ import { EpicService } from "./EpicService"
7
7
  import { TaskService } from "./TaskService"
8
8
  import { PhaseService } from "./PhaseService"
9
9
  import { PullRequestService } from "./PullRequestService"
10
+ import { WorktreeService } from "./WorktreeService"
11
+
12
+ const run = async (args: string[], cwd?: string) => {
13
+ const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
14
+ const exitCode = await child.exited
15
+ if (exitCode !== 0) throw new Error(await new Response(child.stderr).text())
16
+ }
10
17
 
11
18
  describe("task and phase services", () => {
12
19
  let root: string
@@ -283,6 +290,79 @@ describe("task and phase services", () => {
283
290
  })
284
291
  })
285
292
 
293
+ test("recreates jj workspaces when converting a task to phases", async () => {
294
+ if (!Bun.which("jj")) return
295
+ const source = join(root, "source")
296
+ const repository = join(root, "repos/agency")
297
+ await rm(repository, { recursive: true, force: true })
298
+ await mkdir(source)
299
+ await run(["git", "init", "--initial-branch=main"], source)
300
+ await run(["git", "config", "user.email", "test@example.com"], source)
301
+ await run(["git", "config", "user.name", "Test"], source)
302
+ await Bun.write(join(source, "README.md"), "example\n")
303
+ await run(["git", "add", "README.md"], source)
304
+ await run(["git", "commit", "-m", "initial"], source)
305
+ await run(["git", "clone", source, repository])
306
+ await run(["jj", "git", "init", "--colocate", repository])
307
+ await Bun.write(
308
+ join(root, "agency.json"),
309
+ JSON.stringify({ version: 2, vcs: "jj" }),
310
+ )
311
+ await runTestEffect(
312
+ TaskService.pipe(
313
+ Effect.flatMap((service) =>
314
+ service.create(
315
+ {
316
+ id: "jj-single",
317
+ ticketUrl: null,
318
+ repo: "agency",
319
+ branch: "task/jj-single",
320
+ base: "main",
321
+ },
322
+ root,
323
+ ),
324
+ ),
325
+ ),
326
+ )
327
+ const workspace = await runTestEffect(
328
+ WorktreeService.pipe(
329
+ Effect.flatMap((service) =>
330
+ service.materialize("jj-single", undefined, root),
331
+ ),
332
+ ),
333
+ )
334
+
335
+ await runTestEffect(
336
+ PhaseService.pipe(
337
+ Effect.flatMap((service) =>
338
+ service.create(
339
+ {
340
+ taskId: "jj-single",
341
+ id: "extra",
342
+ firstPhase: "implementation",
343
+ repo: "agency",
344
+ branch: "task/extra",
345
+ base: "main",
346
+ },
347
+ root,
348
+ ),
349
+ ),
350
+ ),
351
+ )
352
+
353
+ const moved = join(
354
+ root,
355
+ "tasks/jj-single/phases/implementation/code/agency",
356
+ )
357
+ expect(await Bun.file(workspace.codePath).exists()).toBe(false)
358
+ expect(await Bun.file(join(moved, "README.md")).text()).toBe("example\n")
359
+ const listed = Bun.spawnSync(
360
+ ["jj", "-R", repository, "workspace", "list", "-T", 'root ++ "\\n"'],
361
+ { stdout: "pipe" },
362
+ )
363
+ expect(new TextDecoder().decode(listed.stdout)).toContain(moved)
364
+ })
365
+
286
366
  test("preserves non-PR completion when converting a task to phases", async () => {
287
367
  await runTestEffect(
288
368
  TaskService.pipe(
@@ -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",