@markjaquith/agency 1.11.1 → 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 -420
- 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 -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,143 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { FileSystemService } from "./FileSystemService"
|
|
3
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
4
|
+
import { WorktreeService } from "./WorktreeService"
|
|
5
|
+
import { TaskService } from "./TaskService"
|
|
6
|
+
import { PhaseService } from "./PhaseService"
|
|
7
|
+
import {
|
|
8
|
+
formatMarkdownDocument,
|
|
9
|
+
parseFrontmatter,
|
|
10
|
+
} from "../workbase/frontmatter"
|
|
11
|
+
|
|
12
|
+
class PullRequestError extends Data.TaggedError("PullRequestError")<{
|
|
13
|
+
readonly message: string
|
|
14
|
+
}> {}
|
|
15
|
+
|
|
16
|
+
const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
|
|
17
|
+
|
|
18
|
+
export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
19
|
+
"PullRequestService",
|
|
20
|
+
{
|
|
21
|
+
sync: () => ({
|
|
22
|
+
setUrl: (
|
|
23
|
+
taskId: string,
|
|
24
|
+
phaseId: string | undefined,
|
|
25
|
+
url: string,
|
|
26
|
+
startPath: string = process.cwd(),
|
|
27
|
+
) =>
|
|
28
|
+
Effect.gen(function* () {
|
|
29
|
+
if (!PR_URL.test(url)) {
|
|
30
|
+
return yield* new PullRequestError({
|
|
31
|
+
message: `Invalid GitHub pull request URL: ${url}`,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
const fs = yield* FileSystemService
|
|
35
|
+
const workbase = yield* WorkbaseService
|
|
36
|
+
const tasks = yield* TaskService
|
|
37
|
+
const phases = yield* PhaseService
|
|
38
|
+
const root = yield* workbase.discover(startPath)
|
|
39
|
+
const task = yield* tasks.show(taskId, root)
|
|
40
|
+
const record =
|
|
41
|
+
"phases" in task.data
|
|
42
|
+
? phaseId
|
|
43
|
+
? yield* phases.show(taskId, phaseId, root)
|
|
44
|
+
: yield* new PullRequestError({
|
|
45
|
+
message: `Task '${taskId}' requires a phase ID`,
|
|
46
|
+
})
|
|
47
|
+
: task
|
|
48
|
+
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
49
|
+
const data = { ...record.data, pr: url }
|
|
50
|
+
yield* fs.writeFile(
|
|
51
|
+
record.path,
|
|
52
|
+
formatMarkdownDocument(data, parsed.body),
|
|
53
|
+
)
|
|
54
|
+
return url
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
create: (
|
|
58
|
+
taskId: string,
|
|
59
|
+
phaseId?: string,
|
|
60
|
+
draft = false,
|
|
61
|
+
startPath: string = process.cwd(),
|
|
62
|
+
) =>
|
|
63
|
+
Effect.gen(function* () {
|
|
64
|
+
const service = yield* PullRequestService
|
|
65
|
+
const fs = yield* FileSystemService
|
|
66
|
+
const tasks = yield* TaskService
|
|
67
|
+
const phases = yield* PhaseService
|
|
68
|
+
const worktrees = yield* WorktreeService
|
|
69
|
+
const workspace = yield* worktrees.materialize(
|
|
70
|
+
taskId,
|
|
71
|
+
phaseId,
|
|
72
|
+
startPath,
|
|
73
|
+
)
|
|
74
|
+
const task = yield* tasks.show(taskId, workspace.root)
|
|
75
|
+
const execution =
|
|
76
|
+
"phases" in task.data
|
|
77
|
+
? (yield* phases.show(taskId, phaseId!, workspace.root)).data
|
|
78
|
+
: task.data
|
|
79
|
+
|
|
80
|
+
const status = yield* fs.runCommand(
|
|
81
|
+
["git", "-C", workspace.writablePath, "status", "--porcelain"],
|
|
82
|
+
{ captureOutput: true },
|
|
83
|
+
)
|
|
84
|
+
if (status.exitCode !== 0) {
|
|
85
|
+
return yield* new PullRequestError({
|
|
86
|
+
message: `Failed to inspect worktree status: ${status.stderr}`,
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
if (status.stdout) {
|
|
90
|
+
return yield* new PullRequestError({
|
|
91
|
+
message: "Cannot create a PR with a dirty worktree",
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const push = yield* fs.runCommand(
|
|
96
|
+
[
|
|
97
|
+
"git",
|
|
98
|
+
"-C",
|
|
99
|
+
workspace.writablePath,
|
|
100
|
+
"push",
|
|
101
|
+
"--set-upstream",
|
|
102
|
+
"origin",
|
|
103
|
+
execution.branch,
|
|
104
|
+
],
|
|
105
|
+
{ captureOutput: true },
|
|
106
|
+
)
|
|
107
|
+
if (push.exitCode !== 0) {
|
|
108
|
+
return yield* new PullRequestError({
|
|
109
|
+
message: `Failed to push branch: ${push.stderr}`,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const args = [
|
|
114
|
+
"gh",
|
|
115
|
+
"pr",
|
|
116
|
+
"create",
|
|
117
|
+
"--fill",
|
|
118
|
+
"--base",
|
|
119
|
+
execution.base,
|
|
120
|
+
]
|
|
121
|
+
if (draft) args.push("--draft")
|
|
122
|
+
const created = yield* fs.runCommand(args, {
|
|
123
|
+
cwd: workspace.writablePath,
|
|
124
|
+
captureOutput: true,
|
|
125
|
+
})
|
|
126
|
+
if (created.exitCode !== 0) {
|
|
127
|
+
return yield* new PullRequestError({
|
|
128
|
+
message: `Failed to create pull request: ${created.stderr}`,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
const url = created.stdout
|
|
132
|
+
.split(/\s+/)
|
|
133
|
+
.find((value) => PR_URL.test(value))
|
|
134
|
+
if (!url) {
|
|
135
|
+
return yield* new PullRequestError({
|
|
136
|
+
message: "GitHub CLI did not return a pull request URL",
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
return yield* service.setUrl(taskId, phaseId, url, workspace.root)
|
|
140
|
+
}),
|
|
141
|
+
}),
|
|
142
|
+
},
|
|
143
|
+
) {}
|
|
@@ -0,0 +1,106 @@
|
|
|
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 { RepositoryService } from "./RepositoryService"
|
|
7
|
+
|
|
8
|
+
const runGit = async (args: string[]) => {
|
|
9
|
+
const process = Bun.spawn(["git", ...args], {
|
|
10
|
+
stdout: "pipe",
|
|
11
|
+
stderr: "pipe",
|
|
12
|
+
})
|
|
13
|
+
await process.exited
|
|
14
|
+
if (process.exitCode !== 0) {
|
|
15
|
+
throw new Error(await new Response(process.stderr).text())
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("RepositoryService", () => {
|
|
20
|
+
let root: string
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
root = await createTempDir()
|
|
24
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
afterEach(async () => {
|
|
28
|
+
await cleanupTempDir(root)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("adds a bare repository from a remote", async () => {
|
|
32
|
+
const source = join(root, "source.git")
|
|
33
|
+
await runGit(["init", "--bare", "--initial-branch=main", source])
|
|
34
|
+
|
|
35
|
+
const destination = await runTestEffect(
|
|
36
|
+
RepositoryService.pipe(
|
|
37
|
+
Effect.flatMap((service) => service.add("agency", source, root)),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
expect(destination).toBe(join(root, "repos/agency"))
|
|
42
|
+
expect(await Bun.file(join(destination, "HEAD")).exists()).toBe(true)
|
|
43
|
+
|
|
44
|
+
const repositories = await runTestEffect(
|
|
45
|
+
RepositoryService.pipe(Effect.flatMap((service) => service.list(root))),
|
|
46
|
+
)
|
|
47
|
+
expect(repositories).toEqual([
|
|
48
|
+
{
|
|
49
|
+
alias: "agency",
|
|
50
|
+
path: destination,
|
|
51
|
+
kind: "bare",
|
|
52
|
+
remote: source,
|
|
53
|
+
target: null,
|
|
54
|
+
},
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("links an existing repository", async () => {
|
|
59
|
+
const target = join(root, "linked-repository")
|
|
60
|
+
await mkdir(target, { recursive: true })
|
|
61
|
+
await runGit(["init", "--initial-branch=main", target])
|
|
62
|
+
|
|
63
|
+
const destination = await runTestEffect(
|
|
64
|
+
RepositoryService.pipe(
|
|
65
|
+
Effect.flatMap((service) => service.link("effect", target, root)),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const repositories = await runTestEffect(
|
|
70
|
+
RepositoryService.pipe(Effect.flatMap((service) => service.list(root))),
|
|
71
|
+
)
|
|
72
|
+
expect(repositories[0]).toEqual({
|
|
73
|
+
alias: "effect",
|
|
74
|
+
path: destination,
|
|
75
|
+
kind: "symlink",
|
|
76
|
+
remote: null,
|
|
77
|
+
target,
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test("rejects invalid and duplicate aliases", async () => {
|
|
82
|
+
const source = join(root, "source.git")
|
|
83
|
+
await runGit(["init", "--bare", "--initial-branch=main", source])
|
|
84
|
+
|
|
85
|
+
await expect(
|
|
86
|
+
runTestEffect(
|
|
87
|
+
RepositoryService.pipe(
|
|
88
|
+
Effect.flatMap((service) => service.add("bad/alias", source, root)),
|
|
89
|
+
),
|
|
90
|
+
),
|
|
91
|
+
).rejects.toThrow("Invalid repository alias")
|
|
92
|
+
|
|
93
|
+
await runTestEffect(
|
|
94
|
+
RepositoryService.pipe(
|
|
95
|
+
Effect.flatMap((service) => service.add("agency", source, root)),
|
|
96
|
+
),
|
|
97
|
+
)
|
|
98
|
+
await expect(
|
|
99
|
+
runTestEffect(
|
|
100
|
+
RepositoryService.pipe(
|
|
101
|
+
Effect.flatMap((service) => service.add("agency", source, root)),
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
).rejects.toThrow("already exists")
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -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
|
+
})
|