@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,493 @@
|
|
|
1
|
+
import { Schema } from "@effect/schema"
|
|
2
|
+
import { TreeFormatter } from "@effect/schema"
|
|
3
|
+
import { Data, Effect, Either } from "effect"
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path"
|
|
5
|
+
import { FileSystemService } from "./FileSystemService"
|
|
6
|
+
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
7
|
+
import {
|
|
8
|
+
EpicFrontmatter,
|
|
9
|
+
PhaseFrontmatter,
|
|
10
|
+
TaskFrontmatter,
|
|
11
|
+
WorkbaseConfig,
|
|
12
|
+
type Dependency,
|
|
13
|
+
type EpicFrontmatter as EpicData,
|
|
14
|
+
type PhaseFrontmatter as PhaseData,
|
|
15
|
+
type TaskFrontmatter as TaskData,
|
|
16
|
+
} from "../workbase/schemas"
|
|
17
|
+
import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
|
|
18
|
+
|
|
19
|
+
class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
|
|
20
|
+
readonly message: string
|
|
21
|
+
}> {}
|
|
22
|
+
|
|
23
|
+
class WorkbaseConfigError extends Data.TaggedError("WorkbaseConfigError")<{
|
|
24
|
+
readonly message: string
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly cause?: unknown
|
|
27
|
+
}> {}
|
|
28
|
+
|
|
29
|
+
interface ValidationIssue {
|
|
30
|
+
readonly path: string
|
|
31
|
+
readonly message: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ValidationReport {
|
|
35
|
+
readonly root: string
|
|
36
|
+
readonly issues: readonly ValidationIssue[]
|
|
37
|
+
readonly epicCount: number
|
|
38
|
+
readonly taskCount: number
|
|
39
|
+
readonly phaseCount: number
|
|
40
|
+
readonly valid: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface DocumentRecord<T> {
|
|
44
|
+
readonly id: string
|
|
45
|
+
readonly path: string
|
|
46
|
+
readonly data: T
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type DecodeResult<T> =
|
|
50
|
+
| { readonly success: true; readonly value: T }
|
|
51
|
+
| { readonly success: false; readonly error: string }
|
|
52
|
+
|
|
53
|
+
const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
54
|
+
schema: S,
|
|
55
|
+
input: unknown,
|
|
56
|
+
): DecodeResult<Schema.Schema.Type<S>> => {
|
|
57
|
+
const result = Schema.decodeUnknownEither(schema, {
|
|
58
|
+
errors: "all",
|
|
59
|
+
onExcessProperty: "error",
|
|
60
|
+
})(input)
|
|
61
|
+
|
|
62
|
+
return Either.isLeft(result)
|
|
63
|
+
? { success: false, error: TreeFormatter.formatErrorSync(result.left) }
|
|
64
|
+
: { success: true, value: result.right }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
|
|
68
|
+
const dependencies = new Map(
|
|
69
|
+
nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
|
|
70
|
+
)
|
|
71
|
+
const visiting = new Set<string>()
|
|
72
|
+
const visited = new Set<string>()
|
|
73
|
+
const cycles = new Set<string>()
|
|
74
|
+
|
|
75
|
+
const visit = (id: string) => {
|
|
76
|
+
if (visiting.has(id)) {
|
|
77
|
+
cycles.add(id)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (visited.has(id)) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
visiting.add(id)
|
|
85
|
+
for (const dependency of dependencies.get(id) ?? []) {
|
|
86
|
+
if (dependencies.has(dependency)) {
|
|
87
|
+
visit(dependency)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
visiting.delete(id)
|
|
91
|
+
visited.add(id)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
for (const id of dependencies.keys()) {
|
|
95
|
+
visit(id)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return [...cycles].sort()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
102
|
+
"WorkbaseService",
|
|
103
|
+
{
|
|
104
|
+
sync: () => ({
|
|
105
|
+
initialize: (path: string = process.cwd()) =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
const fs = yield* FileSystemService
|
|
108
|
+
const root = resolve(path)
|
|
109
|
+
const configPath = join(root, "agency.json")
|
|
110
|
+
|
|
111
|
+
if (yield* fs.exists(configPath)) {
|
|
112
|
+
return yield* new WorkbaseConfigError({
|
|
113
|
+
path: configPath,
|
|
114
|
+
message: `Agency configuration already exists: ${configPath}`,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
yield* fs.createDirectory(root)
|
|
119
|
+
yield* fs.writeJSON(configPath, { version: 2 })
|
|
120
|
+
for (const directory of ["repos", "epics", "tasks"]) {
|
|
121
|
+
yield* fs.createDirectory(join(root, directory))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ignorePath = join(root, ".gitignore")
|
|
125
|
+
const requiredPatterns = [
|
|
126
|
+
"/repos/",
|
|
127
|
+
"/tasks/*/code/",
|
|
128
|
+
"/tasks/*/phases/*/code/",
|
|
129
|
+
]
|
|
130
|
+
const existing = (yield* fs.exists(ignorePath))
|
|
131
|
+
? yield* fs.readFile(ignorePath)
|
|
132
|
+
: ""
|
|
133
|
+
const existingLines = new Set(existing.split(/\r?\n/))
|
|
134
|
+
const missing = requiredPatterns.filter(
|
|
135
|
+
(pattern) => !existingLines.has(pattern),
|
|
136
|
+
)
|
|
137
|
+
if (missing.length > 0) {
|
|
138
|
+
const prefix =
|
|
139
|
+
existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""
|
|
140
|
+
yield* fs.writeFile(
|
|
141
|
+
ignorePath,
|
|
142
|
+
`${existing}${prefix}${missing.join("\n")}\n`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return root
|
|
147
|
+
}),
|
|
148
|
+
|
|
149
|
+
discover: (startPath: string = process.cwd()) =>
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const fs = yield* FileSystemService
|
|
152
|
+
let current = resolve(startPath)
|
|
153
|
+
|
|
154
|
+
if (!(yield* fs.isDirectory(current))) {
|
|
155
|
+
current = dirname(current)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
while (true) {
|
|
159
|
+
const configPath = join(current, "agency.json")
|
|
160
|
+
if (yield* fs.exists(configPath)) {
|
|
161
|
+
const content = yield* fs.readFile(configPath)
|
|
162
|
+
let input: unknown
|
|
163
|
+
try {
|
|
164
|
+
input = JSON.parse(content)
|
|
165
|
+
} catch (cause) {
|
|
166
|
+
return yield* new WorkbaseConfigError({
|
|
167
|
+
path: configPath,
|
|
168
|
+
message: `Invalid JSON in ${configPath}`,
|
|
169
|
+
cause,
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
typeof input === "object" &&
|
|
175
|
+
input !== null &&
|
|
176
|
+
"version" in input &&
|
|
177
|
+
input.version === 2
|
|
178
|
+
) {
|
|
179
|
+
const decoded = decode(WorkbaseConfig, input)
|
|
180
|
+
if (!decoded.success) {
|
|
181
|
+
return yield* new WorkbaseConfigError({
|
|
182
|
+
path: configPath,
|
|
183
|
+
message: `Invalid workbase configuration in ${configPath}:\n${decoded.error}`,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
if (decoded.value.worktreeCreateCommand) {
|
|
187
|
+
try {
|
|
188
|
+
validateWorktreeCreateCommand(
|
|
189
|
+
decoded.value.worktreeCreateCommand,
|
|
190
|
+
)
|
|
191
|
+
} catch (cause) {
|
|
192
|
+
return yield* new WorkbaseConfigError({
|
|
193
|
+
path: configPath,
|
|
194
|
+
message:
|
|
195
|
+
cause instanceof Error
|
|
196
|
+
? cause.message
|
|
197
|
+
: "Invalid worktreeCreateCommand",
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return current
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const parent = dirname(current)
|
|
206
|
+
if (parent === current) {
|
|
207
|
+
return yield* new WorkbaseNotFoundError({
|
|
208
|
+
message: `No Agency workbase found from ${resolve(startPath)}`,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
current = parent
|
|
212
|
+
}
|
|
213
|
+
}),
|
|
214
|
+
|
|
215
|
+
loadConfig: (startPath: string = process.cwd()) =>
|
|
216
|
+
Effect.gen(function* () {
|
|
217
|
+
const service = yield* WorkbaseService
|
|
218
|
+
const fs = yield* FileSystemService
|
|
219
|
+
const root = yield* service.discover(startPath)
|
|
220
|
+
const configPath = join(root, "agency.json")
|
|
221
|
+
const content = yield* fs.readFile(configPath)
|
|
222
|
+
let input: unknown
|
|
223
|
+
try {
|
|
224
|
+
input = JSON.parse(content)
|
|
225
|
+
} catch (cause) {
|
|
226
|
+
return yield* new WorkbaseConfigError({
|
|
227
|
+
path: configPath,
|
|
228
|
+
message: `Invalid JSON in ${configPath}`,
|
|
229
|
+
cause,
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
const decoded = decode(WorkbaseConfig, input)
|
|
233
|
+
if (!decoded.success) {
|
|
234
|
+
return yield* new WorkbaseConfigError({
|
|
235
|
+
path: configPath,
|
|
236
|
+
message: `Invalid workbase configuration in ${configPath}:\n${decoded.error}`,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
return { root, config: decoded.value }
|
|
240
|
+
}),
|
|
241
|
+
|
|
242
|
+
validate: (startPath: string = process.cwd()) =>
|
|
243
|
+
Effect.gen(function* () {
|
|
244
|
+
const service = yield* WorkbaseService
|
|
245
|
+
const fs = yield* FileSystemService
|
|
246
|
+
const root = yield* service.discover(startPath)
|
|
247
|
+
const issues: ValidationIssue[] = []
|
|
248
|
+
const epics = new Map<string, DocumentRecord<EpicData>>()
|
|
249
|
+
const tasks = new Map<string, DocumentRecord<TaskData>>()
|
|
250
|
+
const phases = new Map<string, DocumentRecord<PhaseData>>()
|
|
251
|
+
|
|
252
|
+
const issue = (path: string, message: string) => {
|
|
253
|
+
issues.push({ path: relative(root, path) || ".", message })
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const readDirectories = (path: string) =>
|
|
257
|
+
Effect.gen(function* () {
|
|
258
|
+
if (!(yield* fs.isDirectory(path))) {
|
|
259
|
+
return []
|
|
260
|
+
}
|
|
261
|
+
const entries = yield* fs.readDirectory(path)
|
|
262
|
+
return entries
|
|
263
|
+
.filter((entry) => entry.isDirectory)
|
|
264
|
+
.map((entry) => entry.name)
|
|
265
|
+
.sort()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const readAliases = Effect.gen(function* () {
|
|
269
|
+
const reposPath = join(root, "repos")
|
|
270
|
+
if (!(yield* fs.isDirectory(reposPath))) {
|
|
271
|
+
return new Set<string>()
|
|
272
|
+
}
|
|
273
|
+
const entries = yield* fs.readDirectory(reposPath)
|
|
274
|
+
return new Set(
|
|
275
|
+
entries
|
|
276
|
+
.filter((entry) => entry.isDirectory || entry.isSymlink)
|
|
277
|
+
.map((entry) => entry.name),
|
|
278
|
+
)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
const aliases = yield* readAliases
|
|
282
|
+
|
|
283
|
+
const readDocument = <S extends Schema.Schema.AnyNoContext>(
|
|
284
|
+
path: string,
|
|
285
|
+
schema: S,
|
|
286
|
+
) =>
|
|
287
|
+
Effect.gen(function* () {
|
|
288
|
+
if (!(yield* fs.exists(path))) {
|
|
289
|
+
issue(path, "Required document is missing")
|
|
290
|
+
return null
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const content = yield* fs.readFile(path)
|
|
294
|
+
const parsed = yield* Effect.either(
|
|
295
|
+
parseFrontmatter(content, path),
|
|
296
|
+
)
|
|
297
|
+
if (Either.isLeft(parsed)) {
|
|
298
|
+
issue(path, parsed.left.message)
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const decoded = decode(schema, parsed.right.data)
|
|
303
|
+
if (!decoded.success) {
|
|
304
|
+
issue(path, decoded.error)
|
|
305
|
+
return null
|
|
306
|
+
}
|
|
307
|
+
return decoded.value
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
for (const id of yield* readDirectories(join(root, "epics"))) {
|
|
311
|
+
const path = join(root, "epics", id, "EPIC.md")
|
|
312
|
+
const data = yield* readDocument(path, EpicFrontmatter)
|
|
313
|
+
if (data) {
|
|
314
|
+
epics.set(id, { id, path, data })
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
for (const id of yield* readDirectories(join(root, "tasks"))) {
|
|
319
|
+
const taskPath = join(root, "tasks", id)
|
|
320
|
+
const path = join(taskPath, "TASK.md")
|
|
321
|
+
const data = yield* readDocument(path, TaskFrontmatter)
|
|
322
|
+
if (data) {
|
|
323
|
+
tasks.set(id, { id, path, data })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
for (const phaseId of yield* readDirectories(
|
|
327
|
+
join(taskPath, "phases"),
|
|
328
|
+
)) {
|
|
329
|
+
const phasePath = join(taskPath, "phases", phaseId, "PHASE.md")
|
|
330
|
+
const phase = yield* readDocument(phasePath, PhaseFrontmatter)
|
|
331
|
+
if (phase) {
|
|
332
|
+
phases.set(`${id}/${phaseId}`, {
|
|
333
|
+
id: phaseId,
|
|
334
|
+
path: phasePath,
|
|
335
|
+
data: phase,
|
|
336
|
+
})
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const validateRepositories = (
|
|
342
|
+
record: DocumentRecord<EpicData | TaskData | PhaseData>,
|
|
343
|
+
) => {
|
|
344
|
+
const data = record.data
|
|
345
|
+
const writable = "repo" in data ? data.repo : undefined
|
|
346
|
+
const references = "repos" in data ? (data.repos ?? []) : []
|
|
347
|
+
const referenceAliases = references.map(
|
|
348
|
+
(reference) => reference.repo,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
if (writable && referenceAliases.includes(writable)) {
|
|
352
|
+
issue(
|
|
353
|
+
record.path,
|
|
354
|
+
`Repository '${writable}' cannot also be a reference`,
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const all = [writable, ...referenceAliases].filter(
|
|
359
|
+
(alias): alias is string => alias !== undefined,
|
|
360
|
+
)
|
|
361
|
+
for (const alias of new Set(all)) {
|
|
362
|
+
if (!aliases.has(alias)) {
|
|
363
|
+
issue(record.path, `Unknown repository alias '${alias}'`)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (new Set(referenceAliases).size !== referenceAliases.length) {
|
|
368
|
+
issue(record.path, "Repository references must be unique")
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const branchOwners = new Map<
|
|
373
|
+
string,
|
|
374
|
+
DocumentRecord<TaskData | PhaseData>
|
|
375
|
+
>()
|
|
376
|
+
const validateBranchOwnership = (
|
|
377
|
+
record: DocumentRecord<TaskData | PhaseData>,
|
|
378
|
+
) => {
|
|
379
|
+
if (!("repo" in record.data)) return
|
|
380
|
+
const key = `${record.data.repo}\u0000${record.data.branch}`
|
|
381
|
+
const owner = branchOwners.get(key)
|
|
382
|
+
if (owner) {
|
|
383
|
+
issue(
|
|
384
|
+
record.path,
|
|
385
|
+
`Writable branch '${record.data.branch}' for repository '${record.data.repo}' is also owned by ${relative(root, owner.path)}`,
|
|
386
|
+
)
|
|
387
|
+
} else {
|
|
388
|
+
branchOwners.set(key, record)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for (const epic of epics.values()) {
|
|
393
|
+
validateRepositories(epic)
|
|
394
|
+
const ids = new Set(epic.data.tasks.map((task) => task.id))
|
|
395
|
+
if (ids.size !== epic.data.tasks.length) {
|
|
396
|
+
issue(epic.path, "Epic task IDs must be unique")
|
|
397
|
+
}
|
|
398
|
+
for (const task of epic.data.tasks) {
|
|
399
|
+
const child = tasks.get(task.id)
|
|
400
|
+
if (!child) {
|
|
401
|
+
issue(epic.path, `Unknown child task '${task.id}'`)
|
|
402
|
+
} else if (child.data.epic !== epic.id) {
|
|
403
|
+
issue(
|
|
404
|
+
child.path,
|
|
405
|
+
`Task must reference parent epic '${epic.id}'`,
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
for (const dependency of task.dependsOn ?? []) {
|
|
409
|
+
if (!ids.has(dependency)) {
|
|
410
|
+
issue(epic.path, `Unknown task dependency '${dependency}'`)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const cycle of findCycles(epic.data.tasks)) {
|
|
415
|
+
issue(epic.path, `Task dependency cycle includes '${cycle}'`)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
for (const task of tasks.values()) {
|
|
420
|
+
validateRepositories(task)
|
|
421
|
+
validateBranchOwnership(task)
|
|
422
|
+
if (task.data.epic) {
|
|
423
|
+
const parent = epics.get(task.data.epic)
|
|
424
|
+
if (!parent) {
|
|
425
|
+
issue(task.path, `Unknown parent epic '${task.data.epic}'`)
|
|
426
|
+
} else if (
|
|
427
|
+
!parent.data.tasks.some((child) => child.id === task.id)
|
|
428
|
+
) {
|
|
429
|
+
issue(parent.path, `Epic does not list child task '${task.id}'`)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const phasePrefix = `${task.id}/`
|
|
434
|
+
const actualPhaseIds = [...phases.keys()]
|
|
435
|
+
.filter((key) => key.startsWith(phasePrefix))
|
|
436
|
+
.map((key) => key.slice(phasePrefix.length))
|
|
437
|
+
|
|
438
|
+
if ("phases" in task.data) {
|
|
439
|
+
const declaredIds = new Set(
|
|
440
|
+
task.data.phases.map((phase) => phase.id),
|
|
441
|
+
)
|
|
442
|
+
if (declaredIds.size !== task.data.phases.length) {
|
|
443
|
+
issue(task.path, "Task phase IDs must be unique")
|
|
444
|
+
}
|
|
445
|
+
for (const phase of task.data.phases) {
|
|
446
|
+
if (!phases.has(`${task.id}/${phase.id}`)) {
|
|
447
|
+
issue(task.path, `Missing phase '${phase.id}'`)
|
|
448
|
+
}
|
|
449
|
+
for (const dependency of phase.dependsOn ?? []) {
|
|
450
|
+
if (!declaredIds.has(dependency)) {
|
|
451
|
+
issue(task.path, `Unknown phase dependency '${dependency}'`)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
for (const phaseId of actualPhaseIds) {
|
|
456
|
+
if (!declaredIds.has(phaseId)) {
|
|
457
|
+
issue(task.path, `Unlisted phase '${phaseId}'`)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (const cycle of findCycles(task.data.phases)) {
|
|
461
|
+
issue(task.path, `Phase dependency cycle includes '${cycle}'`)
|
|
462
|
+
}
|
|
463
|
+
} else if (actualPhaseIds.length > 0) {
|
|
464
|
+
issue(
|
|
465
|
+
task.path,
|
|
466
|
+
"Single-phase task cannot contain phase directories",
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
for (const phase of phases.values()) {
|
|
472
|
+
validateRepositories(phase)
|
|
473
|
+
validateBranchOwnership(phase)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
issues.sort((a, b) =>
|
|
477
|
+
a.path === b.path
|
|
478
|
+
? a.message.localeCompare(b.message)
|
|
479
|
+
: a.path.localeCompare(b.path),
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
root,
|
|
484
|
+
issues,
|
|
485
|
+
epicCount: epics.size,
|
|
486
|
+
taskCount: tasks.size,
|
|
487
|
+
phaseCount: phases.size,
|
|
488
|
+
valid: issues.length === 0,
|
|
489
|
+
} satisfies ValidationReport
|
|
490
|
+
}),
|
|
491
|
+
}),
|
|
492
|
+
},
|
|
493
|
+
) {}
|