@markjaquith/agency 2.4.0 → 2.5.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.
@@ -53,6 +53,15 @@ describe("body-of-work descriptions", () => {
53
53
  expect(epic.description).toBeUndefined()
54
54
  })
55
55
 
56
+ test("allows tasks without an external ticket", () => {
57
+ const task = Schema.decodeUnknownSync(TaskFrontmatter)({
58
+ ticketUrl: null,
59
+ phases: [],
60
+ })
61
+
62
+ expect(task.ticketUrl).toBeNull()
63
+ })
64
+
56
65
  test("rejects an empty description when present", () => {
57
66
  expect(() =>
58
67
  Schema.decodeUnknownSync(PhaseFrontmatter)({
@@ -66,6 +75,47 @@ describe("body-of-work descriptions", () => {
66
75
  })
67
76
  })
68
77
 
78
+ describe("work status", () => {
79
+ test("defaults execution units to open", () => {
80
+ const task = Schema.decodeUnknownSync(TaskFrontmatter)({
81
+ ticketUrl: "https://example.com/task",
82
+ repo: "agency",
83
+ branch: "task/default-status",
84
+ base: "main",
85
+ pr: null,
86
+ })
87
+ const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
88
+ repo: "agency",
89
+ branch: "task/default-phase-status",
90
+ base: "main",
91
+ pr: null,
92
+ })
93
+
94
+ expect("status" in task && task.status).toBe("open")
95
+ expect(phase.status).toBe("open")
96
+ })
97
+
98
+ test("accepts supported statuses and rejects other values", () => {
99
+ const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
100
+ repo: "agency",
101
+ branch: "task/done",
102
+ base: "main",
103
+ pr: null,
104
+ status: "done",
105
+ })
106
+ expect(phase.status).toBe("done")
107
+ expect(() =>
108
+ Schema.decodeUnknownSync(PhaseFrontmatter)({
109
+ repo: "agency",
110
+ branch: "task/invalid",
111
+ base: "main",
112
+ pr: null,
113
+ status: "blocked",
114
+ }),
115
+ ).toThrow()
116
+ })
117
+ })
118
+
69
119
  describe("schema boundaries", () => {
70
120
  const rejects = <S extends Schema.Schema.AnyNoContext>(
71
121
  schema: S,
@@ -15,6 +15,8 @@ export const RepositoryReference = Schema.Struct({
15
15
  ref: NonEmptyString,
16
16
  })
17
17
 
18
+ export const WorkStatus = Schema.Literal("open", "working", "done", "dropped")
19
+
18
20
  const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
19
21
 
20
22
  const GitHubPullRequestUrl = NonEmptyString.pipe(
@@ -37,6 +39,7 @@ const ExecutionUnit = {
37
39
  branch: NonEmptyString,
38
40
  base: NonEmptyString,
39
41
  pr: Schema.NullOr(GitHubPullRequestUrl),
42
+ status: Schema.optionalWith(WorkStatus, { default: () => "open" as const }),
40
43
  }
41
44
 
42
45
  export const EpicFrontmatter = Schema.Struct({
@@ -47,14 +50,14 @@ export const EpicFrontmatter = Schema.Struct({
47
50
  })
48
51
 
49
52
  const SinglePhaseTaskFrontmatter = Schema.Struct({
50
- ticketUrl: Url,
53
+ ticketUrl: Schema.NullOr(Url),
51
54
  description: Description,
52
55
  epic: Schema.optional(EntityId),
53
56
  ...ExecutionUnit,
54
57
  })
55
58
 
56
59
  const MultiPhaseTaskFrontmatter = Schema.Struct({
57
- ticketUrl: Url,
60
+ ticketUrl: Schema.NullOr(Url),
58
61
  description: Description,
59
62
  epic: Schema.optional(EntityId),
60
63
  phases: Schema.Array(Dependency),
@@ -73,6 +76,7 @@ export const PhaseFrontmatter = Schema.Struct({
73
76
  export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
74
77
  export type Dependency = Schema.Schema.Type<typeof Dependency>
75
78
  export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
79
+ export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
76
80
  export type EpicFrontmatter = Schema.Schema.Type<typeof EpicFrontmatter>
77
81
  export type TaskFrontmatter = Schema.Schema.Type<typeof TaskFrontmatter>
78
82
  export type PhaseFrontmatter = Schema.Schema.Type<typeof PhaseFrontmatter>
@@ -25,7 +25,7 @@ describe("work target choices", () => {
25
25
  {
26
26
  id: "single",
27
27
  path: "/workbase/tasks/single/TASK.md",
28
- data: {},
28
+ data: { status: "done" },
29
29
  },
30
30
  {
31
31
  id: "standalone",
@@ -38,19 +38,19 @@ describe("work target choices", () => {
38
38
  taskId: "multi",
39
39
  id: "verify",
40
40
  path: "/workbase/tasks/multi/phases/verify/PHASE.md",
41
- data: {},
41
+ data: { status: "done" },
42
42
  },
43
43
  {
44
44
  taskId: "multi",
45
45
  id: "build",
46
46
  path: "/workbase/tasks/multi/phases/build/PHASE.md",
47
- data: {},
47
+ data: { status: "working" },
48
48
  },
49
49
  {
50
50
  taskId: "multi",
51
51
  id: "unlisted",
52
52
  path: "/workbase/tasks/multi/phases/unlisted/PHASE.md",
53
- data: {},
53
+ data: { status: "dropped" },
54
54
  },
55
55
  ],
56
56
  )
@@ -58,11 +58,11 @@ describe("work target choices", () => {
58
58
  expect(choices.map((choice) => choice.label)).toEqual([
59
59
  "\x1b[35m\x1b[0m delivery\x1b[2m - Ship the release\x1b[0m",
60
60
  " \x1b[36m󰗡\x1b[0m multi",
61
- " \x1b[33m󰔚\x1b[0m build",
62
- " \x1b[33m󰔚\x1b[0m verify",
63
- " \x1b[33m󰔚\x1b[0m unlisted",
64
- " \x1b[36m󰗡\x1b[0m single",
65
- "\x1b[36m󰗡\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
61
+ " \x1b[34m◐\x1b[0m \x1b[33m󰔚\x1b[0m build",
62
+ " \x1b[32m✓\x1b[0m \x1b[33m󰔚\x1b[0m verify",
63
+ " \x1b[31m⊘\x1b[0m \x1b[33m󰔚\x1b[0m unlisted",
64
+ " \x1b[32m✓\x1b[0m \x1b[36m󰗡\x1b[0m single",
65
+ "\x1b[2m○\x1b[0m \x1b[36m󰗡\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
66
66
  ])
67
67
  expect(choices.map((choice) => choice.target.kind)).toEqual([
68
68
  "epic",
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect"
2
+ import type { WorkStatus } from "./schemas"
2
3
 
3
4
  export type WorkTarget =
4
5
  | {
@@ -36,14 +37,17 @@ interface TaskRecord {
36
37
  readonly description?: string
37
38
  readonly phases: readonly { readonly id: string }[]
38
39
  }
39
- | { readonly description?: string }
40
+ | { readonly description?: string; readonly status?: WorkStatus }
40
41
  }
41
42
 
42
43
  interface PhaseRecord {
43
44
  readonly taskId: string
44
45
  readonly id: string
45
46
  readonly path: string
46
- readonly data: { readonly description?: string }
47
+ readonly data: {
48
+ readonly description?: string
49
+ readonly status?: WorkStatus
50
+ }
47
51
  }
48
52
 
49
53
  export interface WorkTargetChoice {
@@ -51,13 +55,21 @@ export interface WorkTargetChoice {
51
55
  readonly target: WorkTarget
52
56
  }
53
57
 
58
+ const statusIcons: Record<WorkStatus, string> = {
59
+ open: "\x1b[2m○\x1b[0m",
60
+ working: "\x1b[34m◐\x1b[0m",
61
+ done: "\x1b[32m✓\x1b[0m",
62
+ dropped: "\x1b[31m⊘\x1b[0m",
63
+ }
64
+
54
65
  const label = (
55
66
  indent: string,
56
67
  kind: WorkTarget["kind"],
57
68
  id: string,
58
69
  description?: string,
70
+ status?: WorkStatus,
59
71
  ) =>
60
- `${indent}${
72
+ `${indent}${status === undefined ? "" : `${statusIcons[status]} `}${
61
73
  {
62
74
  epic: "\x1b[35m\x1b[0m",
63
75
  task: "\x1b[36m󰗡\x1b[0m",
@@ -73,7 +85,13 @@ const taskChoices = (
73
85
  const multiPhase = "phases" in task.data
74
86
  const choices: WorkTargetChoice[] = [
75
87
  {
76
- label: label(indent, "task", task.id, task.data.description),
88
+ label: label(
89
+ indent,
90
+ "task",
91
+ task.id,
92
+ task.data.description,
93
+ multiPhase ? undefined : (task.data.status ?? "open"),
94
+ ),
77
95
  target: {
78
96
  kind: "task",
79
97
  taskId: task.id,
@@ -91,7 +109,13 @@ const taskChoices = (
91
109
  if (!record) continue
92
110
  renderedPhases.add(record.id)
93
111
  choices.push({
94
- label: label(`${indent} `, "phase", record.id, record.data.description),
112
+ label: label(
113
+ `${indent} `,
114
+ "phase",
115
+ record.id,
116
+ record.data.description,
117
+ record.data.status ?? "open",
118
+ ),
95
119
  target: {
96
120
  kind: "phase",
97
121
  taskId: task.id,
@@ -103,7 +127,13 @@ const taskChoices = (
103
127
  for (const record of phaseRecords) {
104
128
  if (renderedPhases.has(record.id)) continue
105
129
  choices.push({
106
- label: label(`${indent} `, "phase", record.id, record.data.description),
130
+ label: label(
131
+ `${indent} `,
132
+ "phase",
133
+ record.id,
134
+ record.data.description,
135
+ record.data.status ?? "open",
136
+ ),
107
137
  target: {
108
138
  kind: "phase",
109
139
  taskId: task.id,