@markjaquith/agency 1.11.1 → 2.1.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 +313 -117
- package/cli.ts +124 -420
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +266 -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 +326 -399
- package/src/commands/work.ts +153 -153
- 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/work-target.test.ts +77 -0
- package/src/workbase/work-target.ts +180 -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 -641
- package/src/commands/push.ts +0 -482
- 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 -1187
- 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,159 @@
|
|
|
1
|
+
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
|
+
import { Data, Effect, Either } from "effect"
|
|
3
|
+
import { join, resolve } from "node:path"
|
|
4
|
+
import { FileSystemService } from "./FileSystemService"
|
|
5
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
6
|
+
import { RepositoryAlias } from "../workbase/schemas"
|
|
7
|
+
|
|
8
|
+
class RepositoryError extends Data.TaggedError("RepositoryError")<{
|
|
9
|
+
readonly message: string
|
|
10
|
+
readonly cause?: unknown
|
|
11
|
+
}> {}
|
|
12
|
+
|
|
13
|
+
interface RepositoryInfo {
|
|
14
|
+
readonly alias: string
|
|
15
|
+
readonly path: string
|
|
16
|
+
readonly kind: "bare" | "repository" | "symlink"
|
|
17
|
+
readonly remote: string | null
|
|
18
|
+
readonly target: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const validateAlias = (alias: string) => {
|
|
22
|
+
const result = Schema.decodeUnknownEither(RepositoryAlias)(alias)
|
|
23
|
+
return Either.isLeft(result)
|
|
24
|
+
? Effect.fail(
|
|
25
|
+
new RepositoryError({
|
|
26
|
+
message: `Invalid repository alias '${alias}': ${TreeFormatter.formatErrorSync(result.left)}`,
|
|
27
|
+
}),
|
|
28
|
+
)
|
|
29
|
+
: Effect.succeed(result.right)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
33
|
+
"RepositoryService",
|
|
34
|
+
{
|
|
35
|
+
sync: () => ({
|
|
36
|
+
add: (alias: string, remote: string, startPath: string = process.cwd()) =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const fs = yield* FileSystemService
|
|
39
|
+
const workbase = yield* WorkbaseService
|
|
40
|
+
const validAlias = yield* validateAlias(alias)
|
|
41
|
+
const root = yield* workbase.discover(startPath)
|
|
42
|
+
const reposPath = join(root, "repos")
|
|
43
|
+
const destination = join(reposPath, validAlias)
|
|
44
|
+
|
|
45
|
+
if (!remote.trim()) {
|
|
46
|
+
return yield* new RepositoryError({
|
|
47
|
+
message: "Repository remote is required",
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
if (yield* fs.exists(destination)) {
|
|
51
|
+
return yield* new RepositoryError({
|
|
52
|
+
message: `Repository alias '${validAlias}' already exists`,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
yield* fs.createDirectory(reposPath)
|
|
57
|
+
const result = yield* fs.runCommand(
|
|
58
|
+
["git", "clone", "--bare", remote, destination],
|
|
59
|
+
{ captureOutput: true },
|
|
60
|
+
)
|
|
61
|
+
if (result.exitCode !== 0) {
|
|
62
|
+
yield* fs.deleteDirectory(destination).pipe(Effect.ignore)
|
|
63
|
+
return yield* new RepositoryError({
|
|
64
|
+
message: `Failed to clone repository '${remote}': ${result.stderr.trim()}`,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return destination
|
|
69
|
+
}),
|
|
70
|
+
|
|
71
|
+
link: (
|
|
72
|
+
alias: string,
|
|
73
|
+
target: string,
|
|
74
|
+
startPath: string = process.cwd(),
|
|
75
|
+
) =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const fs = yield* FileSystemService
|
|
78
|
+
const workbase = yield* WorkbaseService
|
|
79
|
+
const validAlias = yield* validateAlias(alias)
|
|
80
|
+
const root = yield* workbase.discover(startPath)
|
|
81
|
+
const reposPath = join(root, "repos")
|
|
82
|
+
const destination = join(reposPath, validAlias)
|
|
83
|
+
const resolvedTarget = resolve(startPath, target)
|
|
84
|
+
|
|
85
|
+
if (yield* fs.exists(destination)) {
|
|
86
|
+
return yield* new RepositoryError({
|
|
87
|
+
message: `Repository alias '${validAlias}' already exists`,
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
if (!(yield* fs.isDirectory(resolvedTarget))) {
|
|
91
|
+
return yield* new RepositoryError({
|
|
92
|
+
message: `Repository path does not exist: ${resolvedTarget}`,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result = yield* fs.runCommand(
|
|
97
|
+
["git", "-C", resolvedTarget, "rev-parse", "--git-dir"],
|
|
98
|
+
{ captureOutput: true },
|
|
99
|
+
)
|
|
100
|
+
if (result.exitCode !== 0) {
|
|
101
|
+
return yield* new RepositoryError({
|
|
102
|
+
message: `Path is not a Git repository: ${resolvedTarget}`,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
yield* fs.createDirectory(reposPath)
|
|
107
|
+
yield* fs.createSymlink(resolvedTarget, destination)
|
|
108
|
+
return destination
|
|
109
|
+
}),
|
|
110
|
+
|
|
111
|
+
list: (startPath: string = process.cwd()) =>
|
|
112
|
+
Effect.gen(function* () {
|
|
113
|
+
const fs = yield* FileSystemService
|
|
114
|
+
const workbase = yield* WorkbaseService
|
|
115
|
+
const root = yield* workbase.discover(startPath)
|
|
116
|
+
const reposPath = join(root, "repos")
|
|
117
|
+
|
|
118
|
+
if (!(yield* fs.isDirectory(reposPath))) {
|
|
119
|
+
return [] as RepositoryInfo[]
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const entries = (yield* fs.readDirectory(reposPath))
|
|
123
|
+
.filter((entry) => entry.isDirectory || entry.isSymlink)
|
|
124
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
125
|
+
const repositories: RepositoryInfo[] = []
|
|
126
|
+
|
|
127
|
+
for (const entry of entries) {
|
|
128
|
+
const path = join(reposPath, entry.name)
|
|
129
|
+
const target = entry.isSymlink
|
|
130
|
+
? yield* fs.readSymlinkTarget(path)
|
|
131
|
+
: null
|
|
132
|
+
const bareResult = yield* fs.runCommand(
|
|
133
|
+
["git", "-C", path, "rev-parse", "--is-bare-repository"],
|
|
134
|
+
{ captureOutput: true },
|
|
135
|
+
)
|
|
136
|
+
const remoteResult = yield* fs.runCommand(
|
|
137
|
+
["git", "-C", path, "remote", "get-url", "origin"],
|
|
138
|
+
{ captureOutput: true },
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
repositories.push({
|
|
142
|
+
alias: entry.name,
|
|
143
|
+
path,
|
|
144
|
+
kind: entry.isSymlink
|
|
145
|
+
? "symlink"
|
|
146
|
+
: bareResult.stdout.trim() === "true"
|
|
147
|
+
? "bare"
|
|
148
|
+
: "repository",
|
|
149
|
+
remote:
|
|
150
|
+
remoteResult.exitCode === 0 ? remoteResult.stdout.trim() : null,
|
|
151
|
+
target,
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return repositories
|
|
156
|
+
}),
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
) {}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { EpicService } from "./EpicService"
|
|
7
|
+
import { TaskService } from "./TaskService"
|
|
8
|
+
import { PhaseService } from "./PhaseService"
|
|
9
|
+
import { PullRequestService } from "./PullRequestService"
|
|
10
|
+
|
|
11
|
+
describe("task and phase services", () => {
|
|
12
|
+
let root: string
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
root = await createTempDir()
|
|
16
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
17
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
18
|
+
await mkdir(join(root, "repos/effect"), { recursive: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(async () => cleanupTempDir(root))
|
|
22
|
+
|
|
23
|
+
test("creates a single-phase task and updates its epic", async () => {
|
|
24
|
+
await runTestEffect(
|
|
25
|
+
EpicService.pipe(
|
|
26
|
+
Effect.flatMap((service) =>
|
|
27
|
+
service.create(
|
|
28
|
+
"example",
|
|
29
|
+
"https://example.com/epic",
|
|
30
|
+
[{ repo: "agency", ref: "main" }],
|
|
31
|
+
root,
|
|
32
|
+
),
|
|
33
|
+
),
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
await runTestEffect(
|
|
37
|
+
TaskService.pipe(
|
|
38
|
+
Effect.flatMap((service) =>
|
|
39
|
+
service.create(
|
|
40
|
+
{
|
|
41
|
+
id: "task-one",
|
|
42
|
+
ticketUrl: "https://example.com/task",
|
|
43
|
+
epic: "example",
|
|
44
|
+
repo: "agency",
|
|
45
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
46
|
+
branch: "task/one",
|
|
47
|
+
base: "main",
|
|
48
|
+
},
|
|
49
|
+
root,
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const epic = await runTestEffect(
|
|
56
|
+
EpicService.pipe(
|
|
57
|
+
Effect.flatMap((service) => service.show("example", root)),
|
|
58
|
+
),
|
|
59
|
+
)
|
|
60
|
+
expect(epic.data.tasks).toEqual([{ id: "task-one" }])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("creates and sequences phases on a multi-phase task", async () => {
|
|
64
|
+
await runTestEffect(
|
|
65
|
+
TaskService.pipe(
|
|
66
|
+
Effect.flatMap((service) =>
|
|
67
|
+
service.create(
|
|
68
|
+
{
|
|
69
|
+
id: "multi",
|
|
70
|
+
ticketUrl: "https://example.com/task",
|
|
71
|
+
multiPhase: true,
|
|
72
|
+
},
|
|
73
|
+
root,
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
for (const phase of [
|
|
79
|
+
{ id: "first", dependsOn: undefined },
|
|
80
|
+
{ id: "second", dependsOn: ["first"] },
|
|
81
|
+
]) {
|
|
82
|
+
await runTestEffect(
|
|
83
|
+
PhaseService.pipe(
|
|
84
|
+
Effect.flatMap((service) =>
|
|
85
|
+
service.create(
|
|
86
|
+
{
|
|
87
|
+
taskId: "multi",
|
|
88
|
+
id: phase.id,
|
|
89
|
+
repo: "agency",
|
|
90
|
+
branch: `task/${phase.id}`,
|
|
91
|
+
base: "main",
|
|
92
|
+
dependsOn: phase.dependsOn,
|
|
93
|
+
},
|
|
94
|
+
root,
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const task = await runTestEffect(
|
|
102
|
+
TaskService.pipe(
|
|
103
|
+
Effect.flatMap((service) => service.show("multi", root)),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
expect("phases" in task.data && task.data.phases).toEqual([
|
|
107
|
+
{ id: "first" },
|
|
108
|
+
{ id: "second", dependsOn: ["first"] },
|
|
109
|
+
])
|
|
110
|
+
const phases = await runTestEffect(
|
|
111
|
+
PhaseService.pipe(
|
|
112
|
+
Effect.flatMap((service) => service.list("multi", root)),
|
|
113
|
+
),
|
|
114
|
+
)
|
|
115
|
+
expect(phases.map((phase) => phase.id)).toEqual(["first", "second"])
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
test("converts a single-phase task when the existing phase ID is provided", async () => {
|
|
119
|
+
await runTestEffect(
|
|
120
|
+
TaskService.pipe(
|
|
121
|
+
Effect.flatMap((service) =>
|
|
122
|
+
service.create(
|
|
123
|
+
{
|
|
124
|
+
id: "single",
|
|
125
|
+
ticketUrl: "https://example.com/task",
|
|
126
|
+
description: "Deliver the complete task.",
|
|
127
|
+
repo: "agency",
|
|
128
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
129
|
+
branch: "task/single",
|
|
130
|
+
base: "main",
|
|
131
|
+
},
|
|
132
|
+
root,
|
|
133
|
+
),
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
await runTestEffect(
|
|
138
|
+
PullRequestService.pipe(
|
|
139
|
+
Effect.flatMap((service) =>
|
|
140
|
+
service.setUrl(
|
|
141
|
+
"single",
|
|
142
|
+
undefined,
|
|
143
|
+
"https://github.com/example/agency/pull/42",
|
|
144
|
+
root,
|
|
145
|
+
),
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
)
|
|
149
|
+
await expect(
|
|
150
|
+
runTestEffect(
|
|
151
|
+
PhaseService.pipe(
|
|
152
|
+
Effect.flatMap((service) =>
|
|
153
|
+
service.create(
|
|
154
|
+
{
|
|
155
|
+
taskId: "single",
|
|
156
|
+
id: "extra",
|
|
157
|
+
repo: "agency",
|
|
158
|
+
branch: "task/extra",
|
|
159
|
+
base: "main",
|
|
160
|
+
},
|
|
161
|
+
root,
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
).rejects.toThrow("requires --first-phase")
|
|
167
|
+
|
|
168
|
+
await runTestEffect(
|
|
169
|
+
PhaseService.pipe(
|
|
170
|
+
Effect.flatMap((service) =>
|
|
171
|
+
service.create(
|
|
172
|
+
{
|
|
173
|
+
taskId: "single",
|
|
174
|
+
id: "extra",
|
|
175
|
+
firstPhase: "implementation",
|
|
176
|
+
repo: "agency",
|
|
177
|
+
branch: "task/extra",
|
|
178
|
+
base: "main",
|
|
179
|
+
dependsOn: ["implementation"],
|
|
180
|
+
},
|
|
181
|
+
root,
|
|
182
|
+
),
|
|
183
|
+
),
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const task = await runTestEffect(
|
|
188
|
+
TaskService.pipe(
|
|
189
|
+
Effect.flatMap((service) => service.show("single", root)),
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
expect(task.data).toEqual({
|
|
193
|
+
ticketUrl: "https://example.com/task",
|
|
194
|
+
description: "Deliver the complete task.",
|
|
195
|
+
phases: [
|
|
196
|
+
{ id: "implementation" },
|
|
197
|
+
{ id: "extra", dependsOn: ["implementation"] },
|
|
198
|
+
],
|
|
199
|
+
})
|
|
200
|
+
expect(task.content).toContain("Describe the task outcome.")
|
|
201
|
+
|
|
202
|
+
const firstPhase = await runTestEffect(
|
|
203
|
+
PhaseService.pipe(
|
|
204
|
+
Effect.flatMap((service) =>
|
|
205
|
+
service.show("single", "implementation", root),
|
|
206
|
+
),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
expect(firstPhase.data).toEqual({
|
|
210
|
+
repo: "agency",
|
|
211
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
212
|
+
branch: "task/single",
|
|
213
|
+
base: "main",
|
|
214
|
+
pr: "https://github.com/example/agency/pull/42",
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -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
|
+
}) {}
|