@markjaquith/agency 2.22.0 → 2.24.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.
@@ -40,9 +40,12 @@ describe("epic command", () => {
40
40
  ),
41
41
  )
42
42
 
43
- expect(JSON.parse(logs[0]!)).toEqual({
43
+ const created = JSON.parse(logs[0]!)
44
+ expect(created.revision).toMatch(/^[a-f0-9]{64}$/)
45
+ expect(created).toEqual({
44
46
  id: "example",
45
47
  path: join(root, "epics/example/EPIC.md"),
48
+ revision: created.revision,
46
49
  data: {
47
50
  ticketUrl: "https://example.com/epic",
48
51
  repos: [{ repo: "agency", ref: "main" }],
@@ -68,10 +71,13 @@ describe("epic command", () => {
68
71
  epic({ subcommand: "list", args: [], cwd: root, json: true }),
69
72
  ),
70
73
  )
71
- expect(JSON.parse(listLogs[0]!)).toEqual([
74
+ const listed = JSON.parse(listLogs[0]!)
75
+ expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
76
+ expect(listed).toEqual([
72
77
  {
73
78
  id: "example",
74
79
  path: join(root, "epics/example/EPIC.md"),
80
+ revision: listed[0].revision,
75
81
  data: {
76
82
  ticketUrl: "https://example.com/epic",
77
83
  repos: [{ repo: "agency", ref: "main" }],
@@ -90,9 +96,12 @@ describe("epic command", () => {
90
96
  }),
91
97
  ),
92
98
  )
93
- expect(JSON.parse(showLogs[0]!)).toEqual({
99
+ const shown = JSON.parse(showLogs[0]!)
100
+ expect(shown.revision).toBe(listed[0].revision)
101
+ expect(shown).toEqual({
94
102
  id: "example",
95
103
  path: join(root, "epics/example/EPIC.md"),
104
+ revision: shown.revision,
96
105
  data: {
97
106
  ticketUrl: "https://example.com/epic",
98
107
  repos: [{ repo: "agency", ref: "main" }],
@@ -13,6 +13,7 @@ interface EpicOptions extends BaseCommandOptions {
13
13
  readonly ticketUrl?: string
14
14
  readonly description?: string
15
15
  readonly clearDescription?: boolean
16
+ readonly ifRevision?: string
16
17
  readonly repos?: readonly string[]
17
18
  readonly json?: boolean
18
19
  readonly statuses?: readonly string[]
@@ -122,6 +123,7 @@ export const epic = (options: EpicOptions) =>
122
123
  : parseRepositoryReferences(options.repos),
123
124
  },
124
125
  cwd,
126
+ options.ifRevision,
125
127
  )
126
128
  log(
127
129
  options.json
@@ -138,7 +140,12 @@ export const epic = (options: EpicOptions) =>
138
140
  new Error("Epic ID and new ID are required"),
139
141
  )
140
142
  }
141
- const output = yield* mutations.renameEpic(id, newId, cwd)
143
+ const output = yield* mutations.renameEpic(
144
+ id,
145
+ newId,
146
+ cwd,
147
+ options.ifRevision,
148
+ )
142
149
  log(
143
150
  options.json
144
151
  ? JSON.stringify(output, null, 2)
@@ -177,6 +184,9 @@ Update options:
177
184
  --clear-description Remove the description
178
185
  --repo <alias>:<ref> Replace repository references; repeatable
179
186
 
187
+ Mutation options:
188
+ --if-revision <hash> Require the target's current revision
189
+
180
190
  Options:
181
191
  --json Output results as JSON
182
192
  --status <status> Filter list by status; repeatable
@@ -19,6 +19,7 @@ interface PhaseOptions extends BaseCommandOptions {
19
19
  readonly clearReferences?: boolean
20
20
  readonly prUrl?: string
21
21
  readonly clearPr?: boolean
22
+ readonly ifRevision?: string
22
23
  readonly dependsOn?: readonly string[]
23
24
  readonly firstPhase?: string
24
25
  readonly json?: boolean
@@ -181,6 +182,7 @@ export const phase = (options: PhaseOptions) =>
181
182
  pr: options.clearPr ? null : options.prUrl,
182
183
  },
183
184
  cwd,
185
+ options.ifRevision,
184
186
  )
185
187
  log(
186
188
  options.json
@@ -196,7 +198,13 @@ export const phase = (options: PhaseOptions) =>
196
198
  new Error("Task ID, phase ID, and new ID are required"),
197
199
  )
198
200
  }
199
- const output = yield* mutations.renamePhase(taskId, phaseId, newId, cwd)
201
+ const output = yield* mutations.renamePhase(
202
+ taskId,
203
+ phaseId,
204
+ newId,
205
+ cwd,
206
+ options.ifRevision,
207
+ )
200
208
  log(
201
209
  options.json
202
210
  ? JSON.stringify(output, null, 2)
@@ -225,6 +233,7 @@ export const phase = (options: PhaseOptions) =>
225
233
  dependencyPhaseId,
226
234
  dependencyId,
227
235
  cwd,
236
+ options.ifRevision,
228
237
  )
229
238
  log(
230
239
  options.json
@@ -257,6 +266,9 @@ Subcommands:
257
266
  dependency <operation> <task> <phase> <dependency>
258
267
  Add or remove a phase dependency
259
268
 
269
+ Mutation option:
270
+ --if-revision <hash> Require the target's current revision
271
+
260
272
  Create options:
261
273
  --description <text> Short description of the phase
262
274
  --repo <alias> Writable repository
@@ -0,0 +1,98 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { join } from "node:path"
3
+ import {
4
+ captureLogs,
5
+ cleanupTempDir,
6
+ createTempDir,
7
+ runTestEffect,
8
+ } from "../test-utils"
9
+ import { archive } from "./archive"
10
+ import { restore } from "./restore"
11
+ import { task } from "./task"
12
+
13
+ describe("restore command", () => {
14
+ let root: string
15
+
16
+ beforeEach(async () => {
17
+ root = await createTempDir()
18
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
19
+ const initialized = Bun.spawnSync([
20
+ "git",
21
+ "init",
22
+ "--bare",
23
+ join(root, "repos/agency"),
24
+ ])
25
+ if (initialized.exitCode !== 0)
26
+ throw new Error(new TextDecoder().decode(initialized.stderr))
27
+ await runTestEffect(
28
+ task({
29
+ subcommand: "create",
30
+ args: ["example"],
31
+ repo: "agency",
32
+ branch: "task/example",
33
+ base: "main",
34
+ cwd: root,
35
+ silent: true,
36
+ }),
37
+ )
38
+ await runTestEffect(
39
+ archive({ type: "task", args: ["example"], cwd: root, silent: true }),
40
+ )
41
+ })
42
+
43
+ afterEach(async () => cleanupTempDir(root))
44
+
45
+ test("dry-runs then restores an archived task", async () => {
46
+ const preview = await captureLogs(() =>
47
+ runTestEffect(
48
+ restore({
49
+ type: "task",
50
+ args: ["example"],
51
+ cwd: root,
52
+ dryRun: true,
53
+ }),
54
+ ),
55
+ )
56
+ expect(preview[0]).toContain("Would restore task 'example'")
57
+ expect(await Bun.file(join(root, "tasks/example/TASK.md")).exists()).toBe(
58
+ false,
59
+ )
60
+
61
+ const logs = await captureLogs(() =>
62
+ runTestEffect(
63
+ restore({ type: "task", args: ["example"], cwd: root, json: true }),
64
+ ),
65
+ )
66
+ expect(JSON.parse(logs[0]!)).toMatchObject({
67
+ operation: "restore",
68
+ kind: "task",
69
+ id: "example",
70
+ dryRun: false,
71
+ })
72
+ expect(await Bun.file(join(root, "tasks/example/TASK.md")).exists()).toBe(
73
+ true,
74
+ )
75
+ })
76
+
77
+ test("lists and shows archived work", async () => {
78
+ const listed = await captureLogs(() =>
79
+ runTestEffect(
80
+ archive({
81
+ type: "list",
82
+ args: [],
83
+ kinds: ["task"],
84
+ repositories: ["agency"],
85
+ cwd: root,
86
+ }),
87
+ ),
88
+ )
89
+ expect(listed).toEqual(["task\texample"])
90
+
91
+ const shown = await captureLogs(() =>
92
+ runTestEffect(
93
+ archive({ type: "show", args: ["task", "example"], cwd: root }),
94
+ ),
95
+ )
96
+ expect(shown[0]).toContain("# Example")
97
+ })
98
+ })
@@ -0,0 +1,73 @@
1
+ import { Effect } from "effect"
2
+ import { ArchiveService } from "../services/ArchiveService"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface RestoreOptions extends BaseCommandOptions {
7
+ readonly type?: string
8
+ readonly args: readonly string[]
9
+ readonly json?: boolean
10
+ readonly dryRun?: boolean
11
+ }
12
+
13
+ export const restore = (options: RestoreOptions) =>
14
+ Effect.gen(function* () {
15
+ const archives = yield* ArchiveService
16
+ const { log } = createLoggers(options)
17
+ const cwd = options.cwd ?? process.cwd()
18
+ const [id, phaseId] = options.args
19
+ let result
20
+ switch (options.type) {
21
+ case "epic":
22
+ if (!id)
23
+ return yield* Effect.fail(
24
+ new Error("Usage: agency restore epic <epic-id>"),
25
+ )
26
+ result = yield* archives.restoreEpic(id, cwd, {
27
+ dryRun: options.dryRun,
28
+ })
29
+ break
30
+ case "task":
31
+ if (!id)
32
+ return yield* Effect.fail(
33
+ new Error("Usage: agency restore task <task-id>"),
34
+ )
35
+ result = yield* archives.restoreTask(id, cwd, {
36
+ dryRun: options.dryRun,
37
+ })
38
+ break
39
+ case "phase":
40
+ if (!id || !phaseId)
41
+ return yield* Effect.fail(
42
+ new Error("Usage: agency restore phase <task-id> <phase-id>"),
43
+ )
44
+ result = yield* archives.restorePhase(id, phaseId, cwd, {
45
+ dryRun: options.dryRun,
46
+ })
47
+ break
48
+ default:
49
+ return yield* Effect.fail(
50
+ new Error("Work item type is required. Available: epic, task, phase"),
51
+ )
52
+ }
53
+ log(
54
+ options.json
55
+ ? JSON.stringify(result, null, 2)
56
+ : `${result.dryRun ? "Would restore" : "Restored"} ${result.kind} '${result.id}' to ${result.path}`,
57
+ )
58
+ })
59
+
60
+ export const help = `
61
+ Usage: agency restore <epic|task|phase>
62
+
63
+ Restore archived work after preflighting IDs, backlinks, dependencies, and paths.
64
+
65
+ Commands:
66
+ epic <epic-id> Restore an epic and its tasks
67
+ task <task-id> Restore a task
68
+ phase <task-id> <phase-id> Restore a phase
69
+
70
+ Options:
71
+ --dry-run Preflight without changing files
72
+ --json Output results as JSON
73
+ `
@@ -48,10 +48,13 @@ describe("task and phase command JSON output", () => {
48
48
  task({ subcommand: "list", args: [], cwd: root, json: true }),
49
49
  ),
50
50
  )
51
- expect(JSON.parse(listLogs[0]!)).toEqual([
51
+ const listed = JSON.parse(listLogs[0]!)
52
+ expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
53
+ expect(listed).toEqual([
52
54
  {
53
55
  id: "multi",
54
56
  path: join(root, "tasks/multi/TASK.md"),
57
+ revision: listed[0].revision,
55
58
  data: {
56
59
  ticketUrl: "https://example.com/task",
57
60
  phases: [{ id: "first" }],
@@ -69,9 +72,12 @@ describe("task and phase command JSON output", () => {
69
72
  }),
70
73
  ),
71
74
  )
72
- expect(JSON.parse(showLogs[0]!)).toEqual({
75
+ const shown = JSON.parse(showLogs[0]!)
76
+ expect(shown.revision).toBe(listed[0].revision)
77
+ expect(shown).toEqual({
73
78
  id: "multi",
74
79
  path: join(root, "tasks/multi/TASK.md"),
80
+ revision: shown.revision,
75
81
  data: {
76
82
  ticketUrl: "https://example.com/task",
77
83
  phases: [{ id: "first" }],
@@ -85,11 +91,14 @@ describe("task and phase command JSON output", () => {
85
91
  phase({ subcommand: "list", args: ["multi"], cwd: root, json: true }),
86
92
  ),
87
93
  )
88
- expect(JSON.parse(listLogs[0]!)).toEqual([
94
+ const listed = JSON.parse(listLogs[0]!)
95
+ expect(listed[0].revision).toMatch(/^[a-f0-9]{64}$/)
96
+ expect(listed).toEqual([
89
97
  {
90
98
  taskId: "multi",
91
99
  id: "first",
92
100
  path: join(root, "tasks/multi/phases/first/PHASE.md"),
101
+ revision: listed[0].revision,
93
102
  data: {
94
103
  repo: "agency",
95
104
  branch: "task/first",
@@ -110,10 +119,13 @@ describe("task and phase command JSON output", () => {
110
119
  }),
111
120
  ),
112
121
  )
113
- expect(JSON.parse(showLogs[0]!)).toEqual({
122
+ const shown = JSON.parse(showLogs[0]!)
123
+ expect(shown.revision).toBe(listed[0].revision)
124
+ expect(shown).toEqual({
114
125
  taskId: "multi",
115
126
  id: "first",
116
127
  path: join(root, "tasks/multi/phases/first/PHASE.md"),
128
+ revision: shown.revision,
117
129
  data: {
118
130
  repo: "agency",
119
131
  branch: "task/first",
@@ -27,6 +27,7 @@ interface TaskOptions extends BaseCommandOptions {
27
27
  readonly clearReferences?: boolean
28
28
  readonly prUrl?: string
29
29
  readonly clearPr?: boolean
30
+ readonly ifRevision?: string
30
31
  readonly noEpic?: boolean
31
32
  readonly multiPhase?: boolean
32
33
  readonly json?: boolean
@@ -318,6 +319,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
318
319
  pr: options.clearPr ? null : options.prUrl,
319
320
  },
320
321
  cwd,
322
+ options.ifRevision,
321
323
  )
322
324
  log(
323
325
  options.json
@@ -333,7 +335,12 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
333
335
  new Error("Task ID and new ID are required"),
334
336
  )
335
337
  }
336
- const output = yield* mutations.renameTask(id, newId, cwd)
338
+ const output = yield* mutations.renameTask(
339
+ id,
340
+ newId,
341
+ cwd,
342
+ options.ifRevision,
343
+ )
337
344
  log(
338
345
  options.json
339
346
  ? JSON.stringify(output, null, 2)
@@ -348,6 +355,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
348
355
  id,
349
356
  options.noEpic ? null : (options.epic ?? null),
350
357
  cwd,
358
+ options.ifRevision,
351
359
  )
352
360
  log(
353
361
  options.json
@@ -376,6 +384,7 @@ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
376
384
  id,
377
385
  dependencyId,
378
386
  cwd,
387
+ options.ifRevision,
379
388
  )
380
389
  log(
381
390
  options.json
@@ -408,6 +417,9 @@ Subcommands:
408
417
  dependency <operation> <task> <dependency>
409
418
  Add or remove a task dependency
410
419
 
420
+ Mutation option:
421
+ --if-revision <hash> Require the target's current revision
422
+
411
423
  Create options:
412
424
  --ticket-url <url> External ticket URL (optional)
413
425
  --description <text> Short description of the task
@@ -108,5 +108,24 @@ describe("machine protocol", () => {
108
108
  },
109
109
  },
110
110
  })
111
+ expect(
112
+ errorEnvelope({
113
+ _tag: "RevisionConflictError",
114
+ message: "revision conflict",
115
+ path: "tasks/example/TASK.md",
116
+ expectedRevision: "a".repeat(64),
117
+ currentRevision: "b".repeat(64),
118
+ }),
119
+ ).toMatchObject({
120
+ error: {
121
+ code: "REVISION_CONFLICT",
122
+ retryable: true,
123
+ fields: {
124
+ path: "tasks/example/TASK.md",
125
+ expectedRevision: "a".repeat(64),
126
+ currentRevision: "b".repeat(64),
127
+ },
128
+ },
129
+ })
111
130
  })
112
131
  })