@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
@@ -30,6 +30,7 @@ import {
30
30
  buildNonPrCompletion,
31
31
  type NonPrCompletionInput,
32
32
  } from "../workbase/completion"
33
+ import { VersionControlService } from "./VersionControlService"
33
34
 
34
35
  class PhaseError extends Data.TaggedError("PhaseError")<{
35
36
  readonly message: string
@@ -92,11 +93,18 @@ export class PhaseService extends Effect.Service<PhaseService>()(
92
93
  Effect.gen(function* () {
93
94
  const fs = yield* FileSystemService
94
95
  const workbase = yield* WorkbaseService
96
+ const versionControl = yield* VersionControlService
95
97
  const tasks = yield* TaskService
96
98
  const root = yield* workbase.discover(startPath)
99
+ const backend = yield* versionControl.forWorkbase(root)
97
100
  const taskId = yield* decodeId(input.taskId, "task")
98
101
  const id = yield* decodeId(input.id, "phase")
99
102
  const task = yield* tasks.show(taskId, root)
103
+ if ("review" in task.data) {
104
+ return yield* new PhaseError({
105
+ message: `Review task '${taskId}' cannot be converted to phases`,
106
+ })
107
+ }
100
108
  const isMultiPhase = "phases" in task.data
101
109
  let firstPhaseId: string | undefined
102
110
  if (!isMultiPhase) {
@@ -263,95 +271,213 @@ export class PhaseService extends Effect.Service<PhaseService>()(
263
271
  firstData.repo,
264
272
  ...(firstData.repos ?? []).map((reference) => reference.repo),
265
273
  ]
266
- const repair = async (basePath: string) => {
267
- for (const alias of checkoutAliases) {
268
- const checkoutPath = join(basePath, alias)
269
- try {
270
- await lstat(checkoutPath)
271
- } catch {
272
- continue
273
- }
274
- const result = Bun.spawnSync([
275
- "git",
276
- "-C",
277
- join(root, "repos", alias),
278
- "worktree",
279
- "repair",
280
- checkoutPath,
281
- ])
282
- if (result.exitCode !== 0) {
283
- throw new Error(
284
- `Failed to repair moved worktree for '${alias}': ${new TextDecoder().decode(result.stderr)}`,
285
- )
286
- }
287
- }
288
- }
289
- steps.push({
290
- label: `move and repair code for ${taskId}/${firstPhaseId}`,
291
- preflight: async () => {
292
- for (const entry of await readdir(oldCodePath)) {
293
- if (!checkoutAliases.includes(entry))
294
- throw new Error(
295
- `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
274
+ if (backend.kind === "jj") {
275
+ const workspaces: {
276
+ alias: string
277
+ name: string
278
+ head: string
279
+ oldPath: string
280
+ newPath: string
281
+ }[] = []
282
+ const runBackend = <A>(
283
+ effect: Effect.Effect<A, unknown, any>,
284
+ ) =>
285
+ Effect.runPromise(
286
+ effect.pipe(
287
+ Effect.provideService(FileSystemService, fs),
288
+ ) as Effect.Effect<A, unknown, never>,
289
+ )
290
+ steps.push({
291
+ label: `move jj workspaces for ${taskId}/${firstPhaseId}`,
292
+ preflight: async () => {
293
+ for (const entry of await readdir(oldCodePath)) {
294
+ if (!checkoutAliases.includes(entry))
295
+ throw new Error(
296
+ `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
297
+ )
298
+ }
299
+ for (const alias of checkoutAliases) {
300
+ const oldPath = join(oldCodePath, alias)
301
+ try {
302
+ await lstat(oldPath)
303
+ } catch {
304
+ continue
305
+ }
306
+ const expected = await realpath(oldPath)
307
+ const registered = (
308
+ await runBackend(
309
+ backend.listWorkspaces(join(root, "repos", alias)),
310
+ )
311
+ ).find((workspace) => workspace.path === expected)
312
+ if (!registered?.name)
313
+ throw new Error(
314
+ `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a jj workspace`,
315
+ )
316
+ if (await runBackend(backend.workspaceDirty(oldPath)))
317
+ throw new Error(
318
+ `Cannot convert task '${taskId}'; jj workspace '${alias}' has uncommitted changes`,
319
+ )
320
+ const head = await runBackend(
321
+ backend.workspaceHead(oldPath),
296
322
  )
297
- }
323
+ if (!head)
324
+ throw new Error(
325
+ `Cannot resolve jj workspace '${alias}' before conversion`,
326
+ )
327
+ workspaces.push({
328
+ alias,
329
+ name: registered.name,
330
+ head,
331
+ oldPath,
332
+ newPath: join(firstCodePath, alias),
333
+ })
334
+ }
335
+ },
336
+ apply: async () => {
337
+ await mkdir(firstCodePath, { recursive: true })
338
+ for (const workspace of workspaces) {
339
+ const repositoryPath = join(
340
+ root,
341
+ "repos",
342
+ workspace.alias,
343
+ )
344
+ await runBackend(
345
+ backend.removeWorkspace({
346
+ repositoryPath,
347
+ workspacePath: workspace.oldPath,
348
+ workspaceName: workspace.name,
349
+ }),
350
+ )
351
+ await runBackend(
352
+ backend.createWorkspace({
353
+ repositoryPath,
354
+ workspacePath: workspace.newPath,
355
+ workspaceName: workspace.name,
356
+ revision: workspace.head,
357
+ }),
358
+ )
359
+ }
360
+ await rm(oldCodePath, { recursive: true, force: true })
361
+ },
362
+ rollback: async () => {
363
+ await mkdir(oldCodePath, { recursive: true })
364
+ for (const workspace of [...workspaces].reverse()) {
365
+ const repositoryPath = join(
366
+ root,
367
+ "repos",
368
+ workspace.alias,
369
+ )
370
+ await runBackend(
371
+ backend.removeWorkspace({
372
+ repositoryPath,
373
+ workspacePath: workspace.newPath,
374
+ workspaceName: workspace.name,
375
+ }),
376
+ )
377
+ await runBackend(
378
+ backend.createWorkspace({
379
+ repositoryPath,
380
+ workspacePath: workspace.oldPath,
381
+ workspaceName: workspace.name,
382
+ revision: workspace.head,
383
+ }),
384
+ )
385
+ }
386
+ await rm(firstDirectory, { recursive: true, force: true })
387
+ },
388
+ manualRecovery: `Restore jj workspaces from ${firstCodePath} to ${oldCodePath}`,
389
+ })
390
+ } else {
391
+ const repair = async (basePath: string) => {
298
392
  for (const alias of checkoutAliases) {
299
- const checkoutPath = join(oldCodePath, alias)
393
+ const checkoutPath = join(basePath, alias)
300
394
  try {
301
395
  await lstat(checkoutPath)
302
396
  } catch {
303
397
  continue
304
398
  }
305
- const listed = Bun.spawnSync([
399
+ const result = Bun.spawnSync([
306
400
  "git",
307
401
  "-C",
308
402
  join(root, "repos", alias),
309
403
  "worktree",
310
- "list",
311
- "--porcelain",
404
+ "repair",
405
+ checkoutPath,
312
406
  ])
313
- if (listed.exitCode !== 0)
407
+ if (result.exitCode !== 0) {
314
408
  throw new Error(
315
- `Failed to inspect worktrees for '${alias}'`,
409
+ `Failed to repair moved worktree for '${alias}': ${new TextDecoder().decode(result.stderr)}`,
316
410
  )
317
- const expected = await realpath(checkoutPath)
318
- let registered = false
319
- for (const line of new TextDecoder()
320
- .decode(listed.stdout)
321
- .split("\n")) {
322
- if (!line.startsWith("worktree ")) continue
323
- try {
324
- if ((await realpath(line.slice(9))) === expected) {
325
- registered = true
326
- break
327
- }
328
- } catch {}
329
411
  }
330
- if (!registered)
331
- throw new Error(
332
- `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
333
- )
334
412
  }
335
- },
336
- apply: async () => {
337
- await mkdir(firstDirectory, { recursive: true })
338
- await rename(oldCodePath, firstCodePath)
339
- try {
340
- await repair(firstCodePath)
341
- } catch (cause) {
413
+ }
414
+ steps.push({
415
+ label: `move and repair code for ${taskId}/${firstPhaseId}`,
416
+ preflight: async () => {
417
+ for (const entry of await readdir(oldCodePath)) {
418
+ if (!checkoutAliases.includes(entry))
419
+ throw new Error(
420
+ `Cannot convert task '${taskId}'; code contains unmanaged entry '${entry}'`,
421
+ )
422
+ }
423
+ for (const alias of checkoutAliases) {
424
+ const checkoutPath = join(oldCodePath, alias)
425
+ try {
426
+ await lstat(checkoutPath)
427
+ } catch {
428
+ continue
429
+ }
430
+ const listed = Bun.spawnSync([
431
+ "git",
432
+ "-C",
433
+ join(root, "repos", alias),
434
+ "worktree",
435
+ "list",
436
+ "--porcelain",
437
+ ])
438
+ if (listed.exitCode !== 0)
439
+ throw new Error(
440
+ `Failed to inspect worktrees for '${alias}'`,
441
+ )
442
+ const expected = await realpath(checkoutPath)
443
+ let registered = false
444
+ for (const line of new TextDecoder()
445
+ .decode(listed.stdout)
446
+ .split("\n")) {
447
+ if (!line.startsWith("worktree ")) continue
448
+ try {
449
+ if ((await realpath(line.slice(9))) === expected) {
450
+ registered = true
451
+ break
452
+ }
453
+ } catch {}
454
+ }
455
+ if (!registered)
456
+ throw new Error(
457
+ `Cannot convert task '${taskId}'; checkout '${alias}' is not registered as a Git worktree`,
458
+ )
459
+ }
460
+ },
461
+ apply: async () => {
462
+ await mkdir(firstDirectory, { recursive: true })
463
+ await rename(oldCodePath, firstCodePath)
464
+ try {
465
+ await repair(firstCodePath)
466
+ } catch (cause) {
467
+ await rename(firstCodePath, oldCodePath)
468
+ await repair(oldCodePath)
469
+ await rm(firstDirectory, { recursive: true, force: true })
470
+ throw cause
471
+ }
472
+ },
473
+ rollback: async () => {
342
474
  await rename(firstCodePath, oldCodePath)
343
475
  await repair(oldCodePath)
344
476
  await rm(firstDirectory, { recursive: true, force: true })
345
- throw cause
346
- }
347
- },
348
- rollback: async () => {
349
- await rename(firstCodePath, oldCodePath)
350
- await repair(oldCodePath)
351
- await rm(firstDirectory, { recursive: true, force: true })
352
- },
353
- manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
354
- })
477
+ },
478
+ manualRecovery: `Move ${firstCodePath} back to ${oldCodePath} and run git worktree repair`,
479
+ })
480
+ }
355
481
  }
356
482
  steps.push(
357
483
  documentWriteStep(root, [
@@ -353,7 +353,7 @@ process.exit(${exitCode})
353
353
  test("blocks a dirty checkout before push or gh", async () => {
354
354
  await createTask()
355
355
  const workspace = await materialize()
356
- await Bun.write(join(workspace.writablePath, "dirty.txt"), "dirty\n")
356
+ await Bun.write(join(workspace.writablePath!, "dirty.txt"), "dirty\n")
357
357
  await writeFakeGh({ stdout: "https://github.com/example/agency/pull/45" })
358
358
 
359
359
  await expect(createPullRequest()).rejects.toThrow(
@@ -518,7 +518,7 @@ process.stdout.write(${JSON.stringify(JSON.stringify(providerRecord))})
518
518
  test("reports git status failure before push or gh", async () => {
519
519
  await createTask()
520
520
  const workspace = await materialize()
521
- await rm(join(workspace.writablePath, ".git"))
521
+ await rm(join(workspace.writablePath!, ".git"))
522
522
  await writeFakeGh({ stdout: "https://github.com/example/agency/pull/48" })
523
523
 
524
524
  await expect(createPullRequest()).rejects.toThrow(
@@ -6,6 +6,7 @@ import type { BaseCommandOptions } from "../utils/command"
6
6
  import { TaskService } from "./TaskService"
7
7
  import { PhaseService } from "./PhaseService"
8
8
  import { ReadinessService } from "./ReadinessService"
9
+ import { VersionControlService } from "./VersionControlService"
9
10
  import {
10
11
  formatMarkdownDocument,
11
12
  parseFrontmatter,
@@ -50,6 +51,11 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
50
51
  const phases = yield* PhaseService
51
52
  const root = yield* workbase.discover(startPath)
52
53
  const task = yield* tasks.show(taskId, root)
54
+ if ("review" in task.data) {
55
+ return yield* new PullRequestError({
56
+ message: `Review task '${taskId}' cannot record a delivery pull request`,
57
+ })
58
+ }
53
59
  const target =
54
60
  "phases" in task.data
55
61
  ? phaseId
@@ -105,15 +111,21 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
105
111
  const worktrees = yield* WorktreeService
106
112
  const readiness = yield* ReadinessService
107
113
  const workbase = yield* WorkbaseService
108
- const task = yield* tasks.show(taskId, startPath)
114
+ const versionControl = yield* VersionControlService
115
+ const requestedTask = yield* tasks.show(taskId, startPath)
116
+ if ("review" in requestedTask.data) {
117
+ return yield* new PullRequestError({
118
+ message: `Review task '${taskId}' cannot create a delivery pull request`,
119
+ })
120
+ }
109
121
  const target =
110
- "phases" in task.data
122
+ "phases" in requestedTask.data
111
123
  ? phaseId
112
124
  ? yield* phases.show(taskId, phaseId, startPath)
113
125
  : yield* new PullRequestError({
114
126
  message: `Task '${taskId}' requires a phase ID`,
115
127
  })
116
- : task
128
+ : requestedTask
117
129
  if ("completion" in target.data && target.data.completion) {
118
130
  return yield* new PullRequestError({
119
131
  message:
@@ -134,56 +146,48 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
134
146
  options,
135
147
  )
136
148
  const workspaceTask = yield* tasks.show(taskId, workspace.root)
149
+ if ("review" in workspaceTask.data) {
150
+ return yield* new PullRequestError({
151
+ message: `Review task '${taskId}' cannot create a delivery pull request`,
152
+ })
153
+ }
137
154
  const execution =
138
155
  "phases" in workspaceTask.data
139
156
  ? (yield* phases.show(taskId, phaseId!, workspace.root)).data
140
157
  : workspaceTask.data
141
158
  const { config } = yield* workbase.loadConfig(workspace.root)
159
+ const backend = yield* versionControl.forWorkbase(workspace.root)
160
+ if (!workspace.writablePath) {
161
+ return yield* new PullRequestError({
162
+ message: `Task '${taskId}' has no writable checkout`,
163
+ })
164
+ }
142
165
  const remote = config.delivery?.remote ?? "origin"
143
166
 
144
- const status = yield* fs.runCommand(
145
- ["git", "-C", workspace.writablePath, "status", "--porcelain"],
146
- { captureOutput: true },
147
- )
148
- if (status.exitCode !== 0) {
167
+ const dirty = yield* backend.workspaceDirty(workspace.writablePath)
168
+ if (dirty === null) {
149
169
  return yield* new PullRequestError({
150
- message: `Failed to inspect worktree status: ${status.stderr}`,
170
+ message: "Failed to inspect worktree status",
151
171
  })
152
172
  }
153
- if (status.stdout) {
173
+ if (dirty) {
154
174
  return yield* new PullRequestError({
155
175
  message: "Cannot create a PR with a dirty worktree",
156
176
  })
157
177
  }
158
178
 
159
- const push = yield* fs.runCommand(
160
- [
161
- "git",
162
- "-C",
163
- workspace.writablePath,
164
- "push",
165
- "--set-upstream",
166
- remote,
167
- execution.branch,
168
- ],
169
- { captureOutput: true },
170
- )
171
- if (push.exitCode !== 0) {
172
- return yield* new PullRequestError({
173
- message: `Failed to push branch: ${push.stderr}`,
174
- })
175
- }
179
+ yield* backend.push(workspace.writablePath, remote, execution.branch)
176
180
 
177
- const remoteResult = yield* fs.runCommand(
178
- ["git", "-C", workspace.writablePath, "remote", "get-url", remote],
179
- { captureOutput: true },
181
+ const remoteUrl = yield* backend.remoteUrl(
182
+ workspace.writablePath,
183
+ remote,
180
184
  )
181
- if (remoteResult.exitCode !== 0) {
185
+ if (!remoteUrl) {
182
186
  return yield* new PullRequestError({
183
- message: `Failed to inspect delivery remote '${remote}': ${remoteResult.stderr}`,
187
+ message: `Failed to inspect delivery remote '${remote}'`,
184
188
  })
185
189
  }
186
- const repository = repositoryFromRemote(remoteResult.stdout.trim())
190
+ const repository = repositoryFromRemote(remoteUrl)
187
191
  const resolved = config.delivery
188
192
  ? resolveDeliveryCommand(config.delivery, "create", {
189
193
  repository,
@@ -77,7 +77,7 @@ const itemFor = (
77
77
  ? task.data.epic
78
78
  : undefined
79
79
  const dependentIds = new Set(node.dependents)
80
- if (node.data.phaseId) {
80
+ if ("phaseId" in node.data && node.data.phaseId) {
81
81
  const siblings = graph.nodes.filter(
82
82
  (candidate): candidate is ExecutionNode =>
83
83
  candidate.kind === "execution-unit" &&
@@ -93,10 +93,14 @@ const itemFor = (
93
93
  rank,
94
94
  key: node.key,
95
95
  taskId: node.data.taskId,
96
- ...(node.data.phaseId ? { phaseId: node.data.phaseId } : {}),
96
+ ...("phaseId" in node.data && node.data.phaseId
97
+ ? { phaseId: node.data.phaseId }
98
+ : {}),
97
99
  ...(node.data.description ? { description: node.data.description } : {}),
98
100
  parent: {
99
- ...(node.data.phaseId ? { taskId: node.data.taskId } : {}),
101
+ ...("phaseId" in node.data && node.data.phaseId
102
+ ? { taskId: node.data.taskId }
103
+ : {}),
100
104
  ...(epicId ? { epicId } : {}),
101
105
  },
102
106
  status: node.status,
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { mkdir, stat } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { RepositoryService } from "./RepositoryService"
@@ -98,6 +98,36 @@ describe("RepositoryService", () => {
98
98
  ])
99
99
  })
100
100
 
101
+ test("initializes a managed clone with jj for a jj workbase", async () => {
102
+ if (!Bun.which("jj")) return
103
+ await Bun.write(
104
+ join(root, "agency.json"),
105
+ JSON.stringify({ version: 2, vcs: "jj" }),
106
+ )
107
+ const source = join(root, "source")
108
+ await runGit(["init", "--initial-branch=main", source])
109
+ await runGit(["-C", source, "config", "user.email", "test@example.com"])
110
+ await runGit(["-C", source, "config", "user.name", "Test"])
111
+ await Bun.write(join(source, "README.md"), "example\n")
112
+ await runGit(["-C", source, "add", "README.md"])
113
+ await runGit(["-C", source, "commit", "-m", "initial"])
114
+ await setPortableOrigin(source, "jj-agency")
115
+
116
+ const destination = await runTestEffect(
117
+ RepositoryService.pipe(
118
+ Effect.flatMap((service) => service.add("agency", source, root)),
119
+ ),
120
+ )
121
+ expect((await stat(join(destination, ".jj"))).isDirectory()).toBe(true)
122
+ expect(
123
+ await runTestEffect(
124
+ RepositoryService.pipe(
125
+ Effect.flatMap((service) => service.show("agency", root)),
126
+ ),
127
+ ),
128
+ ).toMatchObject({ kind: "repository" })
129
+ })
130
+
101
131
  test("links an existing repository", async () => {
102
132
  const target = join(root, "linked-repository")
103
133
  await mkdir(target, { recursive: true })