@markjaquith/agency 1.11.0 → 2.0.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.
- package/README.md +302 -113
- package/cli.ts +123 -415
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +258 -0
- package/src/cli.test.ts +290 -0
- package/src/commands/description.test.ts +103 -0
- package/src/commands/epic.test.ts +103 -0
- package/src/commands/epic.ts +101 -0
- package/src/commands/init.test.ts +42 -290
- package/src/commands/init.ts +15 -123
- package/src/commands/phase.ts +120 -0
- package/src/commands/pr.test.ts +19 -170
- package/src/commands/pr.ts +23 -179
- package/src/commands/repo.test.ts +80 -0
- package/src/commands/repo.ts +83 -0
- package/src/commands/status.test.ts +35 -477
- package/src/commands/status.ts +24 -213
- package/src/commands/task-phase.test.ts +206 -0
- package/src/commands/task.ts +83 -1285
- package/src/commands/validate.test.ts +91 -0
- package/src/commands/validate.ts +50 -0
- package/src/commands/work.test.ts +172 -419
- package/src/commands/work.ts +47 -166
- package/src/services/EpicService.test.ts +71 -0
- package/src/services/EpicService.ts +135 -0
- package/src/services/FileSystemService.ts +61 -92
- package/src/services/PhaseService.ts +323 -0
- package/src/services/PullRequestService.test.ts +386 -0
- package/src/services/PullRequestService.ts +143 -0
- package/src/services/RepositoryService.test.ts +106 -0
- package/src/services/RepositoryService.ts +159 -0
- package/src/services/TaskPhaseService.test.ts +217 -0
- package/src/services/TaskService.ts +192 -0
- package/src/services/WorkbaseService.test.ts +446 -0
- package/src/services/WorkbaseService.ts +493 -0
- package/src/services/WorktreeService.test.ts +599 -0
- package/src/services/WorktreeService.ts +357 -0
- package/src/test-utils.ts +41 -438
- package/src/types.ts +1 -215
- package/src/utils/command.ts +1 -5
- package/src/utils/effect.test.ts +24 -0
- package/src/utils/effect.ts +3 -273
- package/src/utils/process.test.ts +0 -15
- package/src/utils/process.ts +1 -60
- package/src/workbase/frontmatter.test.ts +50 -0
- package/src/workbase/frontmatter.ts +77 -0
- package/src/workbase/repository-reference.test.ts +25 -0
- package/src/workbase/repository-reference.ts +18 -0
- package/src/workbase/schemas.test.ts +146 -0
- package/src/workbase/schemas.ts +78 -0
- package/src/workbase/worktree-command.test.ts +61 -0
- package/src/workbase/worktree-command.ts +52 -0
- package/src/commands/base.test.ts +0 -198
- package/src/commands/base.ts +0 -198
- package/src/commands/clean.test.ts +0 -299
- package/src/commands/clean.ts +0 -274
- package/src/commands/completions.test.ts +0 -51
- package/src/commands/completions.ts +0 -308
- package/src/commands/emit.integration.test.ts +0 -549
- package/src/commands/emit.test.ts +0 -576
- package/src/commands/emit.ts +0 -700
- package/src/commands/emitted.test.ts +0 -226
- package/src/commands/emitted.ts +0 -57
- package/src/commands/loop.test.ts +0 -621
- package/src/commands/loop.ts +0 -406
- package/src/commands/merge.integration.test.ts +0 -195
- package/src/commands/merge.test.ts +0 -246
- package/src/commands/merge.ts +0 -244
- package/src/commands/pull.test.ts +0 -395
- package/src/commands/pull.ts +0 -205
- package/src/commands/push.test.ts +0 -518
- package/src/commands/push.ts +0 -434
- package/src/commands/rebase.test.ts +0 -521
- package/src/commands/rebase.ts +0 -232
- package/src/commands/save.test.ts +0 -247
- package/src/commands/save.ts +0 -162
- package/src/commands/source.test.ts +0 -195
- package/src/commands/source.ts +0 -72
- package/src/commands/switch.test.ts +0 -194
- package/src/commands/switch.ts +0 -84
- package/src/commands/task-branching.test.ts +0 -633
- package/src/commands/task-continue.test.ts +0 -311
- package/src/commands/task-edit.test.ts +0 -235
- package/src/commands/task-main.test.ts +0 -1066
- package/src/commands/task-squash.test.ts +0 -266
- package/src/commands/tasks.test.ts +0 -391
- package/src/commands/tasks.ts +0 -161
- package/src/commands/template-delete.test.ts +0 -178
- package/src/commands/template-delete.ts +0 -98
- package/src/commands/template-list.test.ts +0 -135
- package/src/commands/template-list.ts +0 -87
- package/src/commands/template-view.test.ts +0 -158
- package/src/commands/template-view.ts +0 -86
- package/src/commands/template.test.ts +0 -32
- package/src/commands/template.ts +0 -96
- package/src/commands/use.test.ts +0 -87
- package/src/commands/use.ts +0 -97
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -17
- package/src/schemas.ts +0 -33
- package/src/services/AgencyMetadataService.ts +0 -311
- package/src/services/ClaudeService.test.ts +0 -184
- package/src/services/ClaudeService.ts +0 -91
- package/src/services/ConfigService.ts +0 -115
- package/src/services/FilterRepoService.ts +0 -164
- package/src/services/FormatterService.test.ts +0 -432
- package/src/services/FormatterService.ts +0 -219
- package/src/services/GitService.ts +0 -1183
- package/src/services/MockFilterRepoService.ts +0 -140
- package/src/services/OpencodeService.ts +0 -263
- package/src/services/PromptService.ts +0 -183
- package/src/services/TemplateService.ts +0 -81
- package/src/types/native-exec.d.ts +0 -8
- package/src/utils/colors.ts +0 -178
- package/src/utils/git-path.ts +0 -6
- package/src/utils/glob.test.ts +0 -161
- package/src/utils/glob.ts +0 -78
- package/src/utils/paths.ts +0 -51
- package/src/utils/pr-branch.test.ts +0 -376
- package/src/utils/pr-branch.ts +0 -508
- package/src/utils/spinner.test.ts +0 -45
- package/src/utils/spinner.ts +0 -96
- package/src/utils/task-parser.test.ts +0 -172
- package/src/utils/task-parser.ts +0 -93
- package/templates/AGENCY.md +0 -22
- package/templates/AGENTS.md +0 -11
- package/templates/CLAUDE.md +0 -3
- package/templates/TASK.md +0 -5
- package/templates/opencode.json +0 -4
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
|
+
import { Data, Effect, Either } from "effect"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { FileSystemService } from "./FileSystemService"
|
|
5
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
6
|
+
import { EpicService, type EpicRecord } from "./EpicService"
|
|
7
|
+
import {
|
|
8
|
+
EntityId,
|
|
9
|
+
TaskFrontmatter,
|
|
10
|
+
type RepositoryReference,
|
|
11
|
+
type TaskFrontmatter as TaskData,
|
|
12
|
+
} from "../workbase/schemas"
|
|
13
|
+
import {
|
|
14
|
+
formatMarkdownDocument,
|
|
15
|
+
parseFrontmatter,
|
|
16
|
+
} from "../workbase/frontmatter"
|
|
17
|
+
|
|
18
|
+
class TaskError extends Data.TaggedError("TaskError")<{
|
|
19
|
+
readonly message: string
|
|
20
|
+
}> {}
|
|
21
|
+
|
|
22
|
+
interface TaskRecord {
|
|
23
|
+
readonly id: string
|
|
24
|
+
readonly path: string
|
|
25
|
+
readonly content: string
|
|
26
|
+
readonly data: TaskData
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CreateTaskInput {
|
|
30
|
+
readonly id: string
|
|
31
|
+
readonly ticketUrl: string
|
|
32
|
+
readonly description?: string
|
|
33
|
+
readonly epic?: string
|
|
34
|
+
readonly multiPhase?: boolean
|
|
35
|
+
readonly repo?: string
|
|
36
|
+
readonly repos?: readonly RepositoryReference[]
|
|
37
|
+
readonly branch?: string
|
|
38
|
+
readonly base?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const decodeTask = (input: unknown) => {
|
|
42
|
+
const result = Schema.decodeUnknownEither(TaskFrontmatter, {
|
|
43
|
+
errors: "all",
|
|
44
|
+
onExcessProperty: "error",
|
|
45
|
+
})(input)
|
|
46
|
+
return Either.isLeft(result)
|
|
47
|
+
? Effect.fail(
|
|
48
|
+
new TaskError({ message: TreeFormatter.formatErrorSync(result.left) }),
|
|
49
|
+
)
|
|
50
|
+
: Effect.succeed(result.right)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const decodeId = (id: string) => {
|
|
54
|
+
const result = Schema.decodeUnknownEither(EntityId)(id)
|
|
55
|
+
return Either.isLeft(result)
|
|
56
|
+
? Effect.fail(new TaskError({ message: `Invalid task ID '${id}'` }))
|
|
57
|
+
: Effect.succeed(result.right)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
61
|
+
sync: () => ({
|
|
62
|
+
create: (input: CreateTaskInput, startPath: string = process.cwd()) =>
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const fs = yield* FileSystemService
|
|
65
|
+
const workbase = yield* WorkbaseService
|
|
66
|
+
const epics = yield* EpicService
|
|
67
|
+
const root = yield* workbase.discover(startPath)
|
|
68
|
+
const id = yield* decodeId(input.id)
|
|
69
|
+
const directory = join(root, "tasks", id)
|
|
70
|
+
const path = join(directory, "TASK.md")
|
|
71
|
+
|
|
72
|
+
if (yield* fs.exists(directory)) {
|
|
73
|
+
return yield* new TaskError({
|
|
74
|
+
message: `Task '${id}' already exists`,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let data: TaskData
|
|
79
|
+
if (input.multiPhase) {
|
|
80
|
+
data = yield* decodeTask({
|
|
81
|
+
ticketUrl: input.ticketUrl,
|
|
82
|
+
...(input.description !== undefined
|
|
83
|
+
? { description: input.description }
|
|
84
|
+
: {}),
|
|
85
|
+
...(input.epic ? { epic: input.epic } : {}),
|
|
86
|
+
phases: [],
|
|
87
|
+
})
|
|
88
|
+
} else {
|
|
89
|
+
if (!input.repo || !input.branch || !input.base) {
|
|
90
|
+
return yield* new TaskError({
|
|
91
|
+
message: "Single-phase tasks require repo, branch, and base",
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
data = yield* decodeTask({
|
|
95
|
+
ticketUrl: input.ticketUrl,
|
|
96
|
+
...(input.description !== undefined
|
|
97
|
+
? { description: input.description }
|
|
98
|
+
: {}),
|
|
99
|
+
...(input.epic ? { epic: input.epic } : {}),
|
|
100
|
+
repo: input.repo,
|
|
101
|
+
...(input.repos?.length ? { repos: input.repos } : {}),
|
|
102
|
+
branch: input.branch,
|
|
103
|
+
base: input.base,
|
|
104
|
+
pr: null,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const referencedRepos =
|
|
109
|
+
"repo" in data
|
|
110
|
+
? [
|
|
111
|
+
data.repo,
|
|
112
|
+
...(data.repos ?? []).map((reference) => reference.repo),
|
|
113
|
+
]
|
|
114
|
+
: []
|
|
115
|
+
for (const alias of referencedRepos) {
|
|
116
|
+
if (!(yield* fs.exists(join(root, "repos", alias)))) {
|
|
117
|
+
return yield* new TaskError({
|
|
118
|
+
message: `Unknown repository alias '${alias}'`,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let parentEpic: EpicRecord | undefined
|
|
124
|
+
if (input.epic) {
|
|
125
|
+
parentEpic = yield* epics.show(input.epic, root)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const title = id
|
|
129
|
+
.split("-")
|
|
130
|
+
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
|
131
|
+
.join(" ")
|
|
132
|
+
const content = formatMarkdownDocument(
|
|
133
|
+
data,
|
|
134
|
+
`# ${title}\n\nDescribe the task outcome.`,
|
|
135
|
+
)
|
|
136
|
+
yield* fs.createDirectory(directory)
|
|
137
|
+
yield* fs.writeFile(path, content)
|
|
138
|
+
|
|
139
|
+
if (input.epic && parentEpic) {
|
|
140
|
+
const parsed = yield* parseFrontmatter(
|
|
141
|
+
parentEpic.content,
|
|
142
|
+
parentEpic.path,
|
|
143
|
+
)
|
|
144
|
+
const epicData = {
|
|
145
|
+
...parentEpic.data,
|
|
146
|
+
tasks: [...parentEpic.data.tasks, { id }],
|
|
147
|
+
}
|
|
148
|
+
const updated = formatMarkdownDocument(epicData, parsed.body)
|
|
149
|
+
yield* fs.writeFile(parentEpic.path, updated)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { id, path, content, data } satisfies TaskRecord
|
|
153
|
+
}),
|
|
154
|
+
|
|
155
|
+
list: (startPath: string = process.cwd()) =>
|
|
156
|
+
Effect.gen(function* () {
|
|
157
|
+
const fs = yield* FileSystemService
|
|
158
|
+
const workbase = yield* WorkbaseService
|
|
159
|
+
const root = yield* workbase.discover(startPath)
|
|
160
|
+
const directory = join(root, "tasks")
|
|
161
|
+
if (!(yield* fs.isDirectory(directory))) return [] as TaskRecord[]
|
|
162
|
+
const entries = (yield* fs.readDirectory(directory))
|
|
163
|
+
.filter((entry) => entry.isDirectory)
|
|
164
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
165
|
+
const records: TaskRecord[] = []
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
const path = join(directory, entry.name, "TASK.md")
|
|
168
|
+
if (!(yield* fs.exists(path))) continue
|
|
169
|
+
const content = yield* fs.readFile(path)
|
|
170
|
+
const parsed = yield* parseFrontmatter(content, path)
|
|
171
|
+
const data = yield* decodeTask(parsed.data)
|
|
172
|
+
records.push({ id: entry.name, path, content, data })
|
|
173
|
+
}
|
|
174
|
+
return records
|
|
175
|
+
}),
|
|
176
|
+
|
|
177
|
+
show: (id: string, startPath: string = process.cwd()) =>
|
|
178
|
+
Effect.gen(function* () {
|
|
179
|
+
const service = yield* TaskService
|
|
180
|
+
const validId = yield* decodeId(id)
|
|
181
|
+
const record = (yield* service.list(startPath)).find(
|
|
182
|
+
(task) => task.id === validId,
|
|
183
|
+
)
|
|
184
|
+
if (!record) {
|
|
185
|
+
return yield* new TaskError({
|
|
186
|
+
message: `Task '${validId}' does not exist`,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
return record
|
|
190
|
+
}),
|
|
191
|
+
}),
|
|
192
|
+
}) {}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
7
|
+
|
|
8
|
+
const write = async (root: string, path: string, content: string) => {
|
|
9
|
+
const fullPath = join(root, path)
|
|
10
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
11
|
+
await Bun.write(fullPath, content)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("WorkbaseService", () => {
|
|
15
|
+
let root: string
|
|
16
|
+
|
|
17
|
+
beforeEach(async () => {
|
|
18
|
+
root = await createTempDir()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await cleanupTempDir(root)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("discovers a workbase from a nested directory", async () => {
|
|
26
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
27
|
+
await mkdir(join(root, "nested/repository/src"), { recursive: true })
|
|
28
|
+
|
|
29
|
+
const discovered = await runTestEffect(
|
|
30
|
+
WorkbaseService.pipe(
|
|
31
|
+
Effect.flatMap((service) =>
|
|
32
|
+
service.discover(join(root, "nested/repository/src")),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
expect(discovered).toBe(root)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("rejects an invalid worktree command template", async () => {
|
|
41
|
+
await write(
|
|
42
|
+
root,
|
|
43
|
+
"agency.json",
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
version: 2,
|
|
46
|
+
worktreeCreateCommand: ["tool", "{repo}"],
|
|
47
|
+
}),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
await expect(
|
|
51
|
+
runTestEffect(
|
|
52
|
+
WorkbaseService.pipe(
|
|
53
|
+
Effect.flatMap((service) => service.discover(root)),
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
).rejects.toThrow("{worktree}")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test("validates a workbase with an epic and multi-phase task", async () => {
|
|
60
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
61
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
62
|
+
await mkdir(join(root, "repos/effect"), { recursive: true })
|
|
63
|
+
await write(
|
|
64
|
+
root,
|
|
65
|
+
"epics/example/EPIC.md",
|
|
66
|
+
`---
|
|
67
|
+
ticketUrl: https://example.com/epics/example
|
|
68
|
+
repos:
|
|
69
|
+
- repo: agency
|
|
70
|
+
ref: main
|
|
71
|
+
tasks:
|
|
72
|
+
- id: example-task
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
# Example
|
|
76
|
+
`,
|
|
77
|
+
)
|
|
78
|
+
await write(
|
|
79
|
+
root,
|
|
80
|
+
"tasks/example-task/TASK.md",
|
|
81
|
+
`---
|
|
82
|
+
ticketUrl: https://example.com/tasks/example
|
|
83
|
+
epic: example
|
|
84
|
+
phases:
|
|
85
|
+
- id: implementation
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
# Example task
|
|
89
|
+
`,
|
|
90
|
+
)
|
|
91
|
+
await write(
|
|
92
|
+
root,
|
|
93
|
+
"tasks/example-task/phases/implementation/PHASE.md",
|
|
94
|
+
`---
|
|
95
|
+
repo: agency
|
|
96
|
+
repos:
|
|
97
|
+
- repo: effect
|
|
98
|
+
ref: main
|
|
99
|
+
branch: task/example
|
|
100
|
+
base: main
|
|
101
|
+
pr: null
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
# Implementation
|
|
105
|
+
`,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const report = await runTestEffect(
|
|
109
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
expect(report.valid).toBe(true)
|
|
113
|
+
expect(report.issues).toEqual([])
|
|
114
|
+
expect(report.epicCount).toBe(1)
|
|
115
|
+
expect(report.taskCount).toBe(1)
|
|
116
|
+
expect(report.phaseCount).toBe(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("reports schema, alias, backlink, and dependency errors", async () => {
|
|
120
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
121
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
122
|
+
await write(
|
|
123
|
+
root,
|
|
124
|
+
"epics/example/EPIC.md",
|
|
125
|
+
`---
|
|
126
|
+
ticketUrl: https://example.com/epics/example
|
|
127
|
+
repos:
|
|
128
|
+
- repo: missing
|
|
129
|
+
ref: main
|
|
130
|
+
tasks:
|
|
131
|
+
- id: example-task
|
|
132
|
+
dependsOn:
|
|
133
|
+
- absent-task
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
# Example
|
|
137
|
+
`,
|
|
138
|
+
)
|
|
139
|
+
await write(
|
|
140
|
+
root,
|
|
141
|
+
"tasks/example-task/TASK.md",
|
|
142
|
+
`---
|
|
143
|
+
ticketUrl: https://example.com/tasks/example
|
|
144
|
+
repo: agency
|
|
145
|
+
repos:
|
|
146
|
+
- repo: agency
|
|
147
|
+
ref: main
|
|
148
|
+
branch: task/example
|
|
149
|
+
base: main
|
|
150
|
+
pr: null
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
# Example task
|
|
154
|
+
`,
|
|
155
|
+
)
|
|
156
|
+
await write(
|
|
157
|
+
root,
|
|
158
|
+
"tasks/bad-schema/TASK.md",
|
|
159
|
+
`---
|
|
160
|
+
ticketUrl: https://example.com/tasks/bad-schema
|
|
161
|
+
repo: agency
|
|
162
|
+
branch: task/bad-schema
|
|
163
|
+
base: main
|
|
164
|
+
pr: not-a-url
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
# Bad schema
|
|
168
|
+
`,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const report = await runTestEffect(
|
|
172
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
expect(report.valid).toBe(false)
|
|
176
|
+
expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
|
|
177
|
+
"Unknown repository alias 'missing'",
|
|
178
|
+
)
|
|
179
|
+
expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
|
|
180
|
+
"Unknown task dependency 'absent-task'",
|
|
181
|
+
)
|
|
182
|
+
expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
|
|
183
|
+
"Task must reference parent epic 'example'",
|
|
184
|
+
)
|
|
185
|
+
expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
|
|
186
|
+
"Repository 'agency' cannot also be a reference",
|
|
187
|
+
)
|
|
188
|
+
expect(
|
|
189
|
+
report.issues.some(
|
|
190
|
+
(issue) => issue.path.endsWith("bad-schema/TASK.md") && issue.message,
|
|
191
|
+
),
|
|
192
|
+
).toBe(true)
|
|
193
|
+
expect(report.issues.some((issue) => issue.path.endsWith("TASK.md"))).toBe(
|
|
194
|
+
true,
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test("reports duplicate writable branch ownership", async () => {
|
|
199
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
200
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
201
|
+
for (const id of ["first", "second"]) {
|
|
202
|
+
await write(
|
|
203
|
+
root,
|
|
204
|
+
`tasks/${id}/TASK.md`,
|
|
205
|
+
`---
|
|
206
|
+
ticketUrl: https://example.com/tasks/${id}
|
|
207
|
+
repo: agency
|
|
208
|
+
branch: task/shared
|
|
209
|
+
base: main
|
|
210
|
+
pr: null
|
|
211
|
+
---
|
|
212
|
+
`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const report = await runTestEffect(
|
|
217
|
+
WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
expect(report.valid).toBe(false)
|
|
221
|
+
expect(report.issues).toContainEqual({
|
|
222
|
+
path: "tasks/second/TASK.md",
|
|
223
|
+
message:
|
|
224
|
+
"Writable branch 'task/shared' for repository 'agency' is also owned by tasks/first/TASK.md",
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test("reports high-risk structural validation failures", async () => {
|
|
229
|
+
const fixtures: readonly {
|
|
230
|
+
name: string
|
|
231
|
+
files: Readonly<Record<string, string>>
|
|
232
|
+
directories?: readonly string[]
|
|
233
|
+
expected: readonly string[]
|
|
234
|
+
}[] = [
|
|
235
|
+
{
|
|
236
|
+
name: "missing-documents",
|
|
237
|
+
files: {},
|
|
238
|
+
directories: [
|
|
239
|
+
"epics/missing-epic",
|
|
240
|
+
"tasks/missing-task",
|
|
241
|
+
"tasks/missing-task/phases/missing-phase",
|
|
242
|
+
],
|
|
243
|
+
expected: [
|
|
244
|
+
"epics/missing-epic/EPIC.md: Required document is missing",
|
|
245
|
+
"tasks/missing-task/TASK.md: Required document is missing",
|
|
246
|
+
"tasks/missing-task/phases/missing-phase/PHASE.md: Required document is missing",
|
|
247
|
+
],
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: "duplicates",
|
|
251
|
+
files: {
|
|
252
|
+
"epics/duplicate/EPIC.md": `---
|
|
253
|
+
ticketUrl: https://example.com/epics/duplicate
|
|
254
|
+
repos:
|
|
255
|
+
- repo: agency
|
|
256
|
+
ref: main
|
|
257
|
+
- repo: agency
|
|
258
|
+
ref: release
|
|
259
|
+
tasks:
|
|
260
|
+
- id: child
|
|
261
|
+
- id: child
|
|
262
|
+
---
|
|
263
|
+
`,
|
|
264
|
+
"tasks/child/TASK.md": `---
|
|
265
|
+
ticketUrl: https://example.com/tasks/child
|
|
266
|
+
epic: duplicate
|
|
267
|
+
phases:
|
|
268
|
+
- id: implementation
|
|
269
|
+
- id: implementation
|
|
270
|
+
---
|
|
271
|
+
`,
|
|
272
|
+
"tasks/child/phases/implementation/PHASE.md": `---
|
|
273
|
+
repo: agency
|
|
274
|
+
branch: task/duplicate
|
|
275
|
+
base: main
|
|
276
|
+
pr: null
|
|
277
|
+
---
|
|
278
|
+
`,
|
|
279
|
+
},
|
|
280
|
+
expected: [
|
|
281
|
+
"Repository references must be unique",
|
|
282
|
+
"Epic task IDs must be unique",
|
|
283
|
+
"Task phase IDs must be unique",
|
|
284
|
+
],
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: "backlinks",
|
|
288
|
+
files: {
|
|
289
|
+
"epics/parent/EPIC.md": `---
|
|
290
|
+
ticketUrl: https://example.com/epics/parent
|
|
291
|
+
repos:
|
|
292
|
+
- repo: agency
|
|
293
|
+
ref: main
|
|
294
|
+
tasks:
|
|
295
|
+
- id: missing-backlink
|
|
296
|
+
---
|
|
297
|
+
`,
|
|
298
|
+
"tasks/missing-backlink/TASK.md": `---
|
|
299
|
+
ticketUrl: https://example.com/tasks/missing-backlink
|
|
300
|
+
repo: agency
|
|
301
|
+
branch: task/missing-backlink
|
|
302
|
+
base: main
|
|
303
|
+
pr: null
|
|
304
|
+
---
|
|
305
|
+
`,
|
|
306
|
+
"tasks/unlisted/TASK.md": `---
|
|
307
|
+
ticketUrl: https://example.com/tasks/unlisted
|
|
308
|
+
epic: parent
|
|
309
|
+
repo: agency
|
|
310
|
+
branch: task/unlisted
|
|
311
|
+
base: main
|
|
312
|
+
pr: null
|
|
313
|
+
---
|
|
314
|
+
`,
|
|
315
|
+
},
|
|
316
|
+
expected: [
|
|
317
|
+
"Task must reference parent epic 'parent'",
|
|
318
|
+
"Epic does not list child task 'unlisted'",
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
name: "dependency-cycles",
|
|
323
|
+
files: {
|
|
324
|
+
"epics/cycles/EPIC.md": `---
|
|
325
|
+
ticketUrl: https://example.com/epics/cycles
|
|
326
|
+
repos:
|
|
327
|
+
- repo: agency
|
|
328
|
+
ref: main
|
|
329
|
+
tasks:
|
|
330
|
+
- id: first
|
|
331
|
+
dependsOn: [second]
|
|
332
|
+
- id: second
|
|
333
|
+
dependsOn: [first]
|
|
334
|
+
---
|
|
335
|
+
`,
|
|
336
|
+
"tasks/first/TASK.md": `---
|
|
337
|
+
ticketUrl: https://example.com/tasks/first
|
|
338
|
+
epic: cycles
|
|
339
|
+
phases:
|
|
340
|
+
- id: contract
|
|
341
|
+
dependsOn: [delivery]
|
|
342
|
+
- id: delivery
|
|
343
|
+
dependsOn: [contract]
|
|
344
|
+
---
|
|
345
|
+
`,
|
|
346
|
+
"tasks/first/phases/contract/PHASE.md": `---
|
|
347
|
+
repo: agency
|
|
348
|
+
branch: task/contract
|
|
349
|
+
base: main
|
|
350
|
+
pr: null
|
|
351
|
+
---
|
|
352
|
+
`,
|
|
353
|
+
"tasks/first/phases/delivery/PHASE.md": `---
|
|
354
|
+
repo: agency
|
|
355
|
+
branch: task/delivery
|
|
356
|
+
base: main
|
|
357
|
+
pr: null
|
|
358
|
+
---
|
|
359
|
+
`,
|
|
360
|
+
"tasks/second/TASK.md": `---
|
|
361
|
+
ticketUrl: https://example.com/tasks/second
|
|
362
|
+
epic: cycles
|
|
363
|
+
repo: agency
|
|
364
|
+
branch: task/second
|
|
365
|
+
base: main
|
|
366
|
+
pr: null
|
|
367
|
+
---
|
|
368
|
+
`,
|
|
369
|
+
},
|
|
370
|
+
expected: [
|
|
371
|
+
"Task dependency cycle includes 'first'",
|
|
372
|
+
"Phase dependency cycle includes 'contract'",
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "phase-layout",
|
|
377
|
+
files: {
|
|
378
|
+
"tasks/multi/TASK.md": `---
|
|
379
|
+
ticketUrl: https://example.com/tasks/multi
|
|
380
|
+
phases:
|
|
381
|
+
- id: listed
|
|
382
|
+
---
|
|
383
|
+
`,
|
|
384
|
+
"tasks/multi/phases/listed/PHASE.md": `---
|
|
385
|
+
repo: agency
|
|
386
|
+
branch: task/listed
|
|
387
|
+
base: main
|
|
388
|
+
pr: null
|
|
389
|
+
---
|
|
390
|
+
`,
|
|
391
|
+
"tasks/multi/phases/unlisted/PHASE.md": `---
|
|
392
|
+
repo: agency
|
|
393
|
+
branch: task/unlisted
|
|
394
|
+
base: main
|
|
395
|
+
pr: null
|
|
396
|
+
---
|
|
397
|
+
`,
|
|
398
|
+
"tasks/single/TASK.md": `---
|
|
399
|
+
ticketUrl: https://example.com/tasks/single
|
|
400
|
+
repo: agency
|
|
401
|
+
branch: task/single
|
|
402
|
+
base: main
|
|
403
|
+
pr: null
|
|
404
|
+
---
|
|
405
|
+
`,
|
|
406
|
+
"tasks/single/phases/unexpected/PHASE.md": `---
|
|
407
|
+
repo: agency
|
|
408
|
+
branch: task/unexpected
|
|
409
|
+
base: main
|
|
410
|
+
pr: null
|
|
411
|
+
---
|
|
412
|
+
`,
|
|
413
|
+
},
|
|
414
|
+
expected: [
|
|
415
|
+
"Unlisted phase 'unlisted'",
|
|
416
|
+
"Single-phase task cannot contain phase directories",
|
|
417
|
+
],
|
|
418
|
+
},
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
for (const fixture of fixtures) {
|
|
422
|
+
const fixtureRoot = join(root, fixture.name)
|
|
423
|
+
await write(fixtureRoot, "agency.json", '{"version":2}\n')
|
|
424
|
+
await mkdir(join(fixtureRoot, "repos/agency"), { recursive: true })
|
|
425
|
+
for (const directory of fixture.directories ?? []) {
|
|
426
|
+
await mkdir(join(fixtureRoot, directory), { recursive: true })
|
|
427
|
+
}
|
|
428
|
+
for (const [path, content] of Object.entries(fixture.files)) {
|
|
429
|
+
await write(fixtureRoot, path, content)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const report = await runTestEffect(
|
|
433
|
+
WorkbaseService.pipe(
|
|
434
|
+
Effect.flatMap((service) => service.validate(fixtureRoot)),
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
const messages = report.issues
|
|
438
|
+
.map((issue) => `${issue.path}: ${issue.message}`)
|
|
439
|
+
.join("\n")
|
|
440
|
+
expect(report.valid, fixture.name).toBe(false)
|
|
441
|
+
for (const expected of fixture.expected) {
|
|
442
|
+
expect(messages, fixture.name).toContain(expected)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
})
|