@markjaquith/agency 2.74.0 → 3.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.
Files changed (57) hide show
  1. package/README.md +23 -121
  2. package/cli-main.ts +0 -26
  3. package/cli.ts +1 -17
  4. package/package.json +1 -2
  5. package/schemas/agency-graph-v1.schema.json +1 -1
  6. package/src/cli-parser.test.ts +0 -19
  7. package/src/cli-parser.ts +0 -23
  8. package/src/cli.test.ts +3 -3
  9. package/src/commands/context.test.ts +0 -67
  10. package/src/commands/init.test.ts +1 -2
  11. package/src/commands/pr.test.ts +2 -87
  12. package/src/commands/pr.ts +2 -79
  13. package/src/commands/push.test.ts +1 -2
  14. package/src/graph-schema.ts +1 -1
  15. package/src/services/ArchiveBulkService.test.ts +1 -142
  16. package/src/services/ArchiveService.test.ts +1 -172
  17. package/src/services/ArchiveService.ts +0 -26
  18. package/src/services/ContextService.ts +3 -47
  19. package/src/services/DoctorService.ts +8 -24
  20. package/src/services/GraphService.test.ts +1 -0
  21. package/src/services/GraphService.ts +2 -5
  22. package/src/services/PhaseService.ts +70 -191
  23. package/src/services/PullRequestService.test.ts +1 -45
  24. package/src/services/PullRequestService.ts +1 -22
  25. package/src/services/PushService.test.ts +29 -295
  26. package/src/services/PushService.ts +10 -239
  27. package/src/services/RepositoryService.test.ts +1 -61
  28. package/src/services/RepositoryService.ts +2 -13
  29. package/src/services/ReviewService.test.ts +1 -84
  30. package/src/services/ReviewService.ts +9 -48
  31. package/src/services/SyncService.test.ts +0 -81
  32. package/src/services/SyncService.ts +12 -39
  33. package/src/services/TaskPhaseService.test.ts +0 -80
  34. package/src/services/TaskService.ts +1 -9
  35. package/src/services/VersionControlService.test.ts +4 -117
  36. package/src/services/VersionControlService.ts +3 -426
  37. package/src/services/WorkbaseService.test.ts +0 -20
  38. package/src/services/WorkbaseService.ts +1 -18
  39. package/src/services/WorktreeService.test.ts +1 -705
  40. package/src/services/WorktreeService.ts +395 -1604
  41. package/src/services/push-validation.ts +0 -7
  42. package/src/test-utils.ts +0 -4
  43. package/src/workbase/AGENTS.md +4 -5
  44. package/src/workbase/checkout-command.test.ts +1 -1
  45. package/src/workbase/checkout-command.ts +1 -1
  46. package/src/workbase/delivery-command.test.ts +4 -35
  47. package/src/workbase/delivery-command.ts +2 -15
  48. package/src/workbase/schemas.test.ts +0 -19
  49. package/src/workbase/schemas.ts +1 -2
  50. package/src/commands/vcs.test.ts +0 -75
  51. package/src/commands/vcs.ts +0 -72
  52. package/src/services/VcsMigrationService.test.ts +0 -348
  53. package/src/services/VcsMigrationService.ts +0 -857
  54. package/src/vcs-status-fast.ts +0 -310
  55. package/src/workbase/version-control.ts +0 -5
  56. package/src/workbase/workspace-command.test.ts +0 -70
  57. package/src/workbase/workspace-command.ts +0 -63
@@ -1,310 +0,0 @@
1
- import { lstat, readdir, realpath, stat } from "node:fs/promises"
2
- import { dirname, join, resolve } from "node:path"
3
-
4
- interface Execution {
5
- readonly taskId: string
6
- readonly phaseId?: string
7
- readonly documentPath: string
8
- readonly repo: string
9
- readonly branch: string
10
- readonly claimActive: boolean
11
- }
12
-
13
- interface Workspace {
14
- readonly path: string
15
- readonly dirty: boolean
16
- }
17
-
18
- interface Blocker {
19
- readonly kind: string
20
- readonly target: string
21
- readonly message: string
22
- }
23
-
24
- const run = async (args: readonly string[]) => {
25
- const process = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
26
- const [exitCode, stdout] = await Promise.all([
27
- process.exited,
28
- new Response(process.stdout).text(),
29
- ])
30
- return { exitCode, stdout: stdout.trim() }
31
- }
32
-
33
- const directoryExists = async (path: string) => {
34
- try {
35
- return (await stat(path)).isDirectory()
36
- } catch {
37
- return false
38
- }
39
- }
40
-
41
- const frontmatter = (content: string) => {
42
- if (!content.startsWith("---\n")) return null
43
- const end = content.indexOf("\n---\n", 4)
44
- return end === -1 ? null : content.slice(4, end)
45
- }
46
-
47
- const scalar = (content: string, key: string) => {
48
- const match = content.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m"))
49
- if (!match) return null
50
- const value = match[1]!
51
- return value.startsWith('"') && value.endsWith('"')
52
- ? value.slice(1, -1)
53
- : value
54
- }
55
-
56
- const activeClaim = (content: string) => {
57
- const claim = content.match(/^claim:\s*\n((?:^[ \t]+.*(?:\n|$))*)/m)?.[1]
58
- return claim ? /^\s+state:\s*active\s*$/m.test(claim) : false
59
- }
60
-
61
- const discoverRoot = async (startPath: string) => {
62
- let current = startPath
63
- while (true) {
64
- const configPath = join(current, "agency.json")
65
- if (await Bun.file(configPath).exists()) return current
66
- const parent = dirname(current)
67
- if (parent === current) return null
68
- current = parent
69
- }
70
- }
71
-
72
- const readExecution = async (
73
- documentPath: string,
74
- taskId: string,
75
- phaseId?: string,
76
- ): Promise<Execution | null> => {
77
- const content = frontmatter(await Bun.file(documentPath).text())
78
- if (!content || /^repos:/m.test(content) || /^review:/m.test(content))
79
- return null
80
- const repo = scalar(content, "repo")
81
- const branch = scalar(content, "branch")
82
- if (!repo || !branch) return null
83
- return {
84
- taskId,
85
- ...(phaseId ? { phaseId } : {}),
86
- documentPath,
87
- repo,
88
- branch,
89
- claimActive: activeClaim(content),
90
- }
91
- }
92
-
93
- const readExecutions = async (root: string) => {
94
- const tasksPath = join(root, "tasks")
95
- const entries = (await readdir(tasksPath, { withFileTypes: true }))
96
- .filter((entry) => entry.isDirectory())
97
- .sort((left, right) => left.name.localeCompare(right.name))
98
- const executions: Execution[] = []
99
- for (const entry of entries) {
100
- const taskPath = join(tasksPath, entry.name, "TASK.md")
101
- const taskContent = frontmatter(await Bun.file(taskPath).text())
102
- if (!taskContent) return null
103
- if (/^phases:/m.test(taskContent)) {
104
- const phasesPath = join(tasksPath, entry.name, "phases")
105
- const phases = (await readdir(phasesPath, { withFileTypes: true }))
106
- .filter((phase) => phase.isDirectory())
107
- .sort((left, right) => left.name.localeCompare(right.name))
108
- for (const phase of phases) {
109
- const execution = await readExecution(
110
- join(phasesPath, phase.name, "PHASE.md"),
111
- entry.name,
112
- phase.name,
113
- )
114
- if (!execution) return null
115
- executions.push(execution)
116
- }
117
- } else {
118
- const execution = await readExecution(taskPath, entry.name)
119
- if (!execution) return null
120
- executions.push(execution)
121
- }
122
- }
123
- return executions
124
- }
125
-
126
- const inspectRepository = async (root: string, alias: string) => {
127
- const path = join(root, "repos", alias)
128
- let stats
129
- try {
130
- stats = await lstat(path)
131
- } catch {
132
- return null
133
- }
134
- if (!stats.isDirectory() && !stats.isSymbolicLink()) return null
135
- const jj = await run(["jj", "-R", path, "root"])
136
- if (jj.exitCode !== 0) return null
137
- return {
138
- alias,
139
- path,
140
- kind: stats.isSymbolicLink()
141
- ? ("symlink" as const)
142
- : ("repository" as const),
143
- initialized: await directoryExists(join(path, ".jj")),
144
- }
145
- }
146
-
147
- const listWorkspaces = async (repositoryPath: string) => {
148
- const result = await run([
149
- "jj",
150
- "-R",
151
- repositoryPath,
152
- "--no-pager",
153
- "workspace",
154
- "list",
155
- "-T",
156
- 'name ++ "\\t" ++ root ++ "\\t" ++ target.empty() ++ "\\n"',
157
- ])
158
- if (result.exitCode !== 0) return null
159
- return result.stdout
160
- .split("\n")
161
- .filter(Boolean)
162
- .map((line): Workspace => {
163
- const [, path, empty] = line.split("\t")
164
- return { path: path!, dirty: empty === "false" }
165
- })
166
- }
167
-
168
- const inspectVcsStatusFast = async (startPath: string) => {
169
- const root = await discoverRoot(startPath)
170
- if (!root) return null
171
- const config = await Bun.file(join(root, "agency.json")).json()
172
- if (config?.version !== 2 || config?.vcs !== "jj") return null
173
- const executions = await readExecutions(root)
174
- if (!executions) return null
175
-
176
- const localRepositories = (
177
- await readdir(join(root, "repos"), {
178
- withFileTypes: true,
179
- })
180
- )
181
- .filter((entry) => !entry.name.startsWith(".agency-"))
182
- .map((entry) => entry.name)
183
- const aliases = [
184
- ...new Set([
185
- ...Object.keys(config.repositories ?? {}),
186
- ...localRepositories,
187
- ]),
188
- ].sort()
189
- const repositories = await Promise.all(
190
- aliases.map((alias) => inspectRepository(root, alias)),
191
- )
192
- if (repositories.some((repository) => repository === null)) return null
193
- const repositoryRecords = repositories.filter(
194
- (repository) => repository !== null,
195
- )
196
- if (repositoryRecords.some((repository) => !repository.initialized))
197
- return null
198
-
199
- const workspaceLists = await Promise.all(
200
- repositoryRecords.map(async (repository) => ({
201
- alias: repository.alias,
202
- workspaces: await listWorkspaces(repository.path),
203
- })),
204
- )
205
- if (workspaceLists.some(({ workspaces }) => workspaces === null)) return null
206
- const workspacesByRepo = new Map(
207
- workspaceLists.map(({ alias, workspaces }) => [alias, workspaces!]),
208
- )
209
- const owners = new Map<string, Execution[]>()
210
- for (const execution of executions) {
211
- const key = `${execution.repo}:${execution.branch}`
212
- const entries = owners.get(key) ?? []
213
- entries.push(execution)
214
- owners.set(key, entries)
215
- }
216
-
217
- const blockers: Blocker[] = []
218
- for (const execution of executions) {
219
- if (!execution.claimActive) continue
220
- const target = execution.phaseId
221
- ? `phase:${execution.taskId}/${execution.phaseId}`
222
- : `task:${execution.taskId}`
223
- blockers.push({
224
- kind: "active-work",
225
- target,
226
- message: `${target} is active; finish or release it before migration`,
227
- })
228
- }
229
-
230
- let workspaceCount = 0
231
- for (const execution of executions) {
232
- const checkoutPath = join(
233
- dirname(execution.documentPath),
234
- "code",
235
- execution.repo,
236
- )
237
- const exists = await directoryExists(checkoutPath)
238
- const expectedPath = exists
239
- ? await realpath(checkoutPath)
240
- : resolve(checkoutPath)
241
- const registered = workspacesByRepo
242
- .get(execution.repo)
243
- ?.find((workspace) => workspace.path === expectedPath)
244
- const conflicts: string[] = []
245
- if ((owners.get(`${execution.repo}:${execution.branch}`)?.length ?? 0) > 1)
246
- conflicts.push(
247
- `Branch '${execution.branch}' for repository '${execution.repo}' has multiple Agency owners`,
248
- )
249
- if (registered && !exists)
250
- conflicts.push(
251
- `Workspace registry contains a missing checkout at ${checkoutPath}`,
252
- )
253
- if (exists && !registered)
254
- conflicts.push(
255
- `Existing checkout ${checkoutPath} is not registered as a jj workspace`,
256
- )
257
- if (conflicts.length > 0) {
258
- blockers.push({
259
- kind: "workspace-conflict",
260
- target: checkoutPath,
261
- message: conflicts.join("; "),
262
- })
263
- continue
264
- }
265
- if (!exists || !registered) continue
266
- if (registered.dirty) {
267
- blockers.push({
268
- kind: "dirty-workspace",
269
- target: checkoutPath,
270
- message: `Workspace ${checkoutPath} must be clean before migration`,
271
- })
272
- continue
273
- }
274
- workspaceCount++
275
- }
276
-
277
- return {
278
- root,
279
- configured: "jj",
280
- source: "jj",
281
- target: "jj",
282
- available: { git: Bun.which("git") !== null, jj: Bun.which("jj") !== null },
283
- repositories: repositoryRecords,
284
- workspaceCount,
285
- blockers,
286
- }
287
- }
288
-
289
- export const runVcsStatusFast = async (
290
- json: boolean,
291
- startPath: string = process.cwd(),
292
- write: (message: string) => void = console.log,
293
- ) => {
294
- const status = await inspectVcsStatusFast(startPath)
295
- if (!status) return false
296
- if (json) {
297
- write(JSON.stringify({ version: 1, ok: true, result: status }))
298
- } else {
299
- write("Version control: jj")
300
- write(
301
- `Tools: git=${status.available.git ? "available" : "missing"} jj=${status.available.jj ? "available" : "missing"}`,
302
- )
303
- write(
304
- `Repositories: ${status.repositories.length}; managed workspaces: ${status.workspaceCount}; blockers: ${status.blockers.length}`,
305
- )
306
- for (const blocker of status.blockers)
307
- write(`blocker ${blocker.kind} ${blocker.target}: ${blocker.message}`)
308
- }
309
- return true
310
- }
@@ -1,5 +0,0 @@
1
- export type VersionControlKind = "git" | "jj"
2
-
3
- export const preferredVersionControl = (
4
- which: (executable: string) => string | null = Bun.which,
5
- ): VersionControlKind => (which("jj") ? "jj" : "git")
@@ -1,70 +0,0 @@
1
- import { describe, expect, test } from "bun:test"
2
- import {
3
- expandWorkspaceCreateCommand,
4
- workspaceCommandEnvironment,
5
- } from "./workspace-command"
6
-
7
- const variables = {
8
- repo: "/work/repos/app",
9
- workspace: "/work/tasks/example/code/app",
10
- name: "agency-example-task-app",
11
- revision: "0123456789abcdef",
12
- kind: "writable" as const,
13
- requestedRef: "task/example",
14
- }
15
-
16
- describe("workspace command templates", () => {
17
- test("expands argv placeholders without shell interpolation", () => {
18
- expect(
19
- expandWorkspaceCreateCommand(
20
- [
21
- "tool",
22
- "--repo={repo}",
23
- "--workspace={workspace}",
24
- "--name={name}",
25
- "--revision={revision}",
26
- "{kind}",
27
- "{requestedRef}",
28
- ],
29
- variables,
30
- ),
31
- ).toEqual([
32
- "tool",
33
- "--repo=/work/repos/app",
34
- "--workspace=/work/tasks/example/code/app",
35
- "--name=agency-example-task-app",
36
- "--revision=0123456789abcdef",
37
- "writable",
38
- "task/example",
39
- ])
40
- })
41
-
42
- test("requires creation identity placeholders", () => {
43
- expect(() =>
44
- expandWorkspaceCreateCommand(
45
- ["tool", "{repo}", "{workspace}", "{name}"],
46
- variables,
47
- ),
48
- ).toThrow("{revision}")
49
- })
50
-
51
- test("rejects unknown placeholders", () => {
52
- expect(() =>
53
- expandWorkspaceCreateCommand(
54
- ["tool", "{repo}", "{workspace}", "{name}", "{revision}", "{base}"],
55
- variables,
56
- ),
57
- ).toThrow("{base}")
58
- })
59
-
60
- test("provides equivalent environment variables", () => {
61
- expect(workspaceCommandEnvironment(variables)).toEqual({
62
- AGENCY_REPO: variables.repo,
63
- AGENCY_WORKSPACE: variables.workspace,
64
- AGENCY_WORKSPACE_NAME: variables.name,
65
- AGENCY_REVISION: variables.revision,
66
- AGENCY_CHECKOUT_KIND: variables.kind,
67
- AGENCY_REQUESTED_REF: variables.requestedRef,
68
- })
69
- })
70
- })
@@ -1,63 +0,0 @@
1
- interface WorkspaceCommandVariables {
2
- readonly repo: string
3
- readonly workspace: string
4
- readonly name: string
5
- readonly revision: string
6
- readonly kind: "writable" | "reference"
7
- readonly requestedRef: string
8
- }
9
-
10
- const REQUIRED_PLACEHOLDERS = ["repo", "workspace", "name", "revision"] as const
11
- const PLACEHOLDERS = new Set([
12
- "repo",
13
- "workspace",
14
- "name",
15
- "revision",
16
- "kind",
17
- "requestedRef",
18
- ])
19
-
20
- export const validateWorkspaceCreateCommand = (command: readonly string[]) => {
21
- const template = command.join("\u0000")
22
- for (const placeholder of REQUIRED_PLACEHOLDERS) {
23
- if (!template.includes(`{${placeholder}}`)) {
24
- throw new Error(
25
- `workspaceCreateCommand must include the {${placeholder}} placeholder`,
26
- )
27
- }
28
- }
29
- for (const argument of command) {
30
- for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
31
- const placeholder = match[1]!
32
- if (!PLACEHOLDERS.has(placeholder)) {
33
- throw new Error(
34
- `Unknown workspaceCreateCommand placeholder: {${placeholder}}`,
35
- )
36
- }
37
- }
38
- }
39
- }
40
-
41
- export const expandWorkspaceCreateCommand = (
42
- command: readonly string[],
43
- variables: WorkspaceCommandVariables,
44
- ): string[] => {
45
- validateWorkspaceCreateCommand(command)
46
-
47
- return command.map((argument) =>
48
- argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {
49
- return variables[placeholder as keyof WorkspaceCommandVariables] ?? match
50
- }),
51
- )
52
- }
53
-
54
- export const workspaceCommandEnvironment = (
55
- variables: WorkspaceCommandVariables,
56
- ): Record<string, string> => ({
57
- AGENCY_REPO: variables.repo,
58
- AGENCY_WORKSPACE: variables.workspace,
59
- AGENCY_WORKSPACE_NAME: variables.name,
60
- AGENCY_REVISION: variables.revision,
61
- AGENCY_CHECKOUT_KIND: variables.kind,
62
- AGENCY_REQUESTED_REF: variables.requestedRef,
63
- })