@markjaquith/agency 2.54.1 → 2.54.3
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 +2 -1
- package/package.json +1 -1
- package/src/commands/pr.test.ts +57 -2
- package/src/commands/pr.ts +75 -2
- package/src/services/DoctorService.ts +5 -26
- package/src/services/PullRequestService.ts +9 -19
- package/src/services/ReviewService.test.ts +84 -1
- package/src/services/ReviewService.ts +24 -6
- package/src/services/VersionControlService.ts +35 -12
- package/src/services/WorktreeService.test.ts +38 -0
- package/src/services/WorktreeService.ts +5 -1
- package/src/workbase/delivery-command.test.ts +29 -0
- package/src/workbase/delivery-command.ts +33 -0
package/README.md
CHANGED
|
@@ -857,7 +857,8 @@ including readiness checks and durable PR recording. Other `agency pr`
|
|
|
857
857
|
invocations forward every argument to `gh pr`. From an execution task or phase
|
|
858
858
|
directory, including descendants, passthrough runs in that execution unit's
|
|
859
859
|
authoritative writable checkout. Otherwise it runs in the caller's current
|
|
860
|
-
directory.
|
|
860
|
+
directory. In jj workbases, Agency also supplies the repository and the work
|
|
861
|
+
item's declared branch to subcommands that would otherwise infer Git context.
|
|
861
862
|
|
|
862
863
|
### Status and Validation
|
|
863
864
|
|
package/package.json
CHANGED
package/src/commands/pr.test.ts
CHANGED
|
@@ -50,9 +50,19 @@ exit "\${GH_EXIT:-0}"
|
|
|
50
50
|
const captured = async () =>
|
|
51
51
|
(await Bun.file(capturePath).text()).trim().split("\n")
|
|
52
52
|
|
|
53
|
-
const createExecutionWorkbase = async () => {
|
|
53
|
+
const createExecutionWorkbase = async (vcs: "git" | "jj" = "git") => {
|
|
54
54
|
const root = join(tempRoot, "workbase")
|
|
55
|
-
await write(
|
|
55
|
+
await write(
|
|
56
|
+
root,
|
|
57
|
+
"agency.json",
|
|
58
|
+
`${JSON.stringify({
|
|
59
|
+
version: 2,
|
|
60
|
+
vcs,
|
|
61
|
+
repositories: {
|
|
62
|
+
agency: { remote: "https://github.com/example/agency.git" },
|
|
63
|
+
},
|
|
64
|
+
})}\n`,
|
|
65
|
+
)
|
|
56
66
|
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
57
67
|
await mkdir(join(root, "tasks/single/code/agency"), { recursive: true })
|
|
58
68
|
await write(
|
|
@@ -163,6 +173,51 @@ status: open
|
|
|
163
173
|
])
|
|
164
174
|
})
|
|
165
175
|
|
|
176
|
+
test("injects the execution branch and repository for jj targets", async () => {
|
|
177
|
+
const root = await createExecutionWorkbase("jj")
|
|
178
|
+
const task = join(root, "tasks/single")
|
|
179
|
+
const phase = join(root, "tasks/multi/phases/build")
|
|
180
|
+
|
|
181
|
+
expect(await runTestEffect(pr(["view", "--web"], task))).toBe(0)
|
|
182
|
+
expect(await captured()).toEqual([
|
|
183
|
+
await realpath(join(task, "code/agency")),
|
|
184
|
+
"pr",
|
|
185
|
+
"view",
|
|
186
|
+
"feat/single",
|
|
187
|
+
"--repo",
|
|
188
|
+
"example/agency",
|
|
189
|
+
"--web",
|
|
190
|
+
])
|
|
191
|
+
|
|
192
|
+
expect(
|
|
193
|
+
await runTestEffect(pr(["create", "--title", "Example"], phase)),
|
|
194
|
+
).toBe(0)
|
|
195
|
+
expect(await captured()).toEqual([
|
|
196
|
+
await realpath(join(phase, "code/agency")),
|
|
197
|
+
"pr",
|
|
198
|
+
"create",
|
|
199
|
+
"--head",
|
|
200
|
+
"feat/build",
|
|
201
|
+
"--repo",
|
|
202
|
+
"example/agency",
|
|
203
|
+
"--title",
|
|
204
|
+
"Example",
|
|
205
|
+
])
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test("preserves explicit jj PR and repository targets", async () => {
|
|
209
|
+
const root = await createExecutionWorkbase("jj")
|
|
210
|
+
const task = join(root, "tasks/single")
|
|
211
|
+
const args = ["view", "123", "--repo", "other/repository"]
|
|
212
|
+
|
|
213
|
+
expect(await runTestEffect(pr(args, task))).toBe(0)
|
|
214
|
+
expect(await captured()).toEqual([
|
|
215
|
+
await realpath(join(task, "code/agency")),
|
|
216
|
+
"pr",
|
|
217
|
+
...args,
|
|
218
|
+
])
|
|
219
|
+
})
|
|
220
|
+
|
|
166
221
|
test("falls back to the invocation directory without execution authority", async () => {
|
|
167
222
|
const root = await createExecutionWorkbase()
|
|
168
223
|
const orchestration = join(root, "tasks/multi")
|
package/src/commands/pr.ts
CHANGED
|
@@ -3,8 +3,10 @@ import { resolve } from "node:path"
|
|
|
3
3
|
import { ContextService } from "../services/ContextService"
|
|
4
4
|
import { FileSystemService } from "../services/FileSystemService"
|
|
5
5
|
import { PullRequestService } from "../services/PullRequestService"
|
|
6
|
+
import { WorkbaseService } from "../services/WorkbaseService"
|
|
6
7
|
import type { BaseCommandOptions } from "../utils/command"
|
|
7
8
|
import { createLoggers } from "../utils/effect"
|
|
9
|
+
import { repositoryFromRemote } from "../workbase/delivery-command"
|
|
8
10
|
|
|
9
11
|
interface PrCreateOptions extends BaseCommandOptions {
|
|
10
12
|
readonly taskId: string
|
|
@@ -13,6 +15,56 @@ interface PrCreateOptions extends BaseCommandOptions {
|
|
|
13
15
|
readonly force?: boolean
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
const branchTargetCommands = new Set([
|
|
19
|
+
"checkout",
|
|
20
|
+
"checks",
|
|
21
|
+
"close",
|
|
22
|
+
"comment",
|
|
23
|
+
"diff",
|
|
24
|
+
"edit",
|
|
25
|
+
"lock",
|
|
26
|
+
"merge",
|
|
27
|
+
"ready",
|
|
28
|
+
"reopen",
|
|
29
|
+
"revert",
|
|
30
|
+
"review",
|
|
31
|
+
"unlock",
|
|
32
|
+
"update-branch",
|
|
33
|
+
"view",
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
const hasOption = (args: readonly string[], short: string, long: string) =>
|
|
37
|
+
args.some(
|
|
38
|
+
(argument) =>
|
|
39
|
+
argument === short ||
|
|
40
|
+
argument.startsWith(`${short}=`) ||
|
|
41
|
+
argument === long ||
|
|
42
|
+
argument.startsWith(`${long}=`),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const withJjContext = (
|
|
46
|
+
args: readonly string[],
|
|
47
|
+
branch: string,
|
|
48
|
+
repository: string,
|
|
49
|
+
) => {
|
|
50
|
+
const [command, ...rest] = args
|
|
51
|
+
if (!command) return args
|
|
52
|
+
|
|
53
|
+
const branchArgs =
|
|
54
|
+
(command === "create" || command === "new") &&
|
|
55
|
+
!hasOption(rest, "-H", "--head")
|
|
56
|
+
? ["--head", branch]
|
|
57
|
+
: branchTargetCommands.has(command) &&
|
|
58
|
+
(rest.length === 0 || rest[0]?.startsWith("-"))
|
|
59
|
+
? [branch]
|
|
60
|
+
: []
|
|
61
|
+
const repositoryArgs = hasOption(rest, "-R", "--repo")
|
|
62
|
+
? []
|
|
63
|
+
: ["--repo", repository]
|
|
64
|
+
|
|
65
|
+
return [command, ...branchArgs, ...repositoryArgs, ...rest]
|
|
66
|
+
}
|
|
67
|
+
|
|
16
68
|
export const prCreate = (options: PrCreateOptions) =>
|
|
17
69
|
Effect.gen(function* () {
|
|
18
70
|
const pullRequests = yield* PullRequestService
|
|
@@ -31,6 +83,7 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
|
31
83
|
Effect.gen(function* () {
|
|
32
84
|
const contexts = yield* ContextService
|
|
33
85
|
const fs = yield* FileSystemService
|
|
86
|
+
const workbase = yield* WorkbaseService
|
|
34
87
|
const invocationCwd = resolve(cwd)
|
|
35
88
|
const context = yield* contexts
|
|
36
89
|
.get({ cwd: invocationCwd, target: ".", compact: true })
|
|
@@ -40,7 +93,26 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
|
40
93
|
? context.authority.writable?.checkoutPath
|
|
41
94
|
: null
|
|
42
95
|
const focusedCwd = writableCheckout ?? invocationCwd
|
|
43
|
-
|
|
96
|
+
let forwardedArgs = args
|
|
97
|
+
if (
|
|
98
|
+
writableCheckout &&
|
|
99
|
+
context?.workbase.vcs === "jj" &&
|
|
100
|
+
context.authority.writable
|
|
101
|
+
) {
|
|
102
|
+
const execution =
|
|
103
|
+
context.documents?.phase?.data ?? context.documents?.task?.data
|
|
104
|
+
const { config } = yield* workbase.loadConfig(context.workbase.root)
|
|
105
|
+
const remote =
|
|
106
|
+
config.repositories?.[context.authority.writable.repo]?.remote
|
|
107
|
+
if (execution && "branch" in execution && remote) {
|
|
108
|
+
forwardedArgs = withJjContext(
|
|
109
|
+
args,
|
|
110
|
+
execution.branch,
|
|
111
|
+
repositoryFromRemote(remote),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const result = yield* fs.runCommand(["gh", "pr", ...forwardedArgs], {
|
|
44
116
|
cwd: focusedCwd,
|
|
45
117
|
passthrough: true,
|
|
46
118
|
})
|
|
@@ -53,5 +125,6 @@ Usage: agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
|
|
|
53
125
|
|
|
54
126
|
Create records a pull request for an Agency execution unit. Other invocations
|
|
55
127
|
run gh pr unchanged, focusing the writable repository checkout when invoked from
|
|
56
|
-
an Agency execution task or phase.
|
|
128
|
+
an Agency execution task or phase. In jj workbases, Agency supplies the declared
|
|
129
|
+
branch and repository to gh subcommands that would otherwise infer Git context.
|
|
57
130
|
`
|
|
@@ -10,6 +10,7 @@ import { RepositoryService } from "./RepositoryService"
|
|
|
10
10
|
import { TaskService } from "./TaskService"
|
|
11
11
|
import { WorkbaseService } from "./WorkbaseService"
|
|
12
12
|
import { WorktreeService } from "./WorktreeService"
|
|
13
|
+
import { VersionControlService } from "./VersionControlService"
|
|
13
14
|
|
|
14
15
|
type DoctorCheckLevel = "error" | "warning" | "optional"
|
|
15
16
|
|
|
@@ -87,7 +88,9 @@ export class DoctorService extends Effect.Service<DoctorService>()(
|
|
|
87
88
|
const tasks = yield* TaskService
|
|
88
89
|
const workbases = yield* WorkbaseService
|
|
89
90
|
const worktrees = yield* WorktreeService
|
|
91
|
+
const versionControl = yield* VersionControlService
|
|
90
92
|
const { root, config } = yield* workbases.loadConfig(startPath)
|
|
93
|
+
const backend = yield* versionControl.forWorkbase(root)
|
|
91
94
|
const checks: DoctorCheck[] = []
|
|
92
95
|
const add = (
|
|
93
96
|
check: Omit<DoctorCheck, "remediation"> & {
|
|
@@ -409,32 +412,8 @@ export class DoctorService extends Effect.Service<DoctorService>()(
|
|
|
409
412
|
}
|
|
410
413
|
|
|
411
414
|
for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
"git",
|
|
415
|
-
"-C",
|
|
416
|
-
repository.path,
|
|
417
|
-
"rev-parse",
|
|
418
|
-
"--verify",
|
|
419
|
-
`${ref}^{commit}`,
|
|
420
|
-
],
|
|
421
|
-
{ captureOutput: true },
|
|
422
|
-
)
|
|
423
|
-
const remote =
|
|
424
|
-
local.exitCode === 0
|
|
425
|
-
? local
|
|
426
|
-
: yield* fs.runCommand(
|
|
427
|
-
[
|
|
428
|
-
"git",
|
|
429
|
-
"-C",
|
|
430
|
-
repository.path,
|
|
431
|
-
"rev-parse",
|
|
432
|
-
"--verify",
|
|
433
|
-
`origin/${ref}^{commit}`,
|
|
434
|
-
],
|
|
435
|
-
{ captureOutput: true },
|
|
436
|
-
)
|
|
437
|
-
const found = local.exitCode === 0 || remote.exitCode === 0
|
|
415
|
+
const found =
|
|
416
|
+
(yield* backend.resolveRevision(repository.path, ref)) !== null
|
|
438
417
|
add({
|
|
439
418
|
id: `ref.${repository.alias}.${ref}`,
|
|
440
419
|
category: "ref",
|
|
@@ -16,20 +16,15 @@ import {
|
|
|
16
16
|
normalizePullRequestRecord,
|
|
17
17
|
parsePullRequestRecord,
|
|
18
18
|
recordFromGitHubUrl,
|
|
19
|
+
repositoryFromRemote,
|
|
19
20
|
resolveDeliveryCommand,
|
|
21
|
+
resolveGitHubCreateCommand,
|
|
20
22
|
} from "../workbase/delivery-command"
|
|
21
23
|
|
|
22
24
|
class PullRequestError extends Data.TaggedError("PullRequestError")<{
|
|
23
25
|
readonly message: string
|
|
24
26
|
}> {}
|
|
25
27
|
|
|
26
|
-
const repositoryFromRemote = (remote: string) =>
|
|
27
|
-
remote
|
|
28
|
-
.replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
|
|
29
|
-
.replace(/^[^:]+:/, "")
|
|
30
|
-
.replace(/\.git\/?$/, "")
|
|
31
|
-
.replace(/\/$/, "")
|
|
32
|
-
|
|
33
28
|
interface PullRequestOptions extends BaseCommandOptions {
|
|
34
29
|
readonly force?: boolean
|
|
35
30
|
}
|
|
@@ -197,18 +192,13 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
197
192
|
url: "",
|
|
198
193
|
identifier: "",
|
|
199
194
|
})
|
|
200
|
-
: {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
execution.base,
|
|
208
|
-
...(draft ? ["--draft"] : []),
|
|
209
|
-
],
|
|
210
|
-
environment: {},
|
|
211
|
-
}
|
|
195
|
+
: resolveGitHubCreateCommand({
|
|
196
|
+
base: execution.base,
|
|
197
|
+
branch: execution.branch,
|
|
198
|
+
repository,
|
|
199
|
+
draft,
|
|
200
|
+
vcs: config.vcs ?? "git",
|
|
201
|
+
})
|
|
212
202
|
const created = yield* fs.runCommand(resolved.argv, {
|
|
213
203
|
cwd: workspace.writablePath,
|
|
214
204
|
captureOutput: true,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
-
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
6
|
import { TaskService } from "./TaskService"
|
|
@@ -13,6 +13,7 @@ import { PhaseService } from "./PhaseService"
|
|
|
13
13
|
import { ArchiveService } from "./ArchiveService"
|
|
14
14
|
import { ClaimService } from "./ClaimService"
|
|
15
15
|
import { SyncService } from "./SyncService"
|
|
16
|
+
import { DoctorService } from "./DoctorService"
|
|
16
17
|
import { task as taskCommand } from "../commands/task"
|
|
17
18
|
|
|
18
19
|
const git = async (args: string[], cwd?: string) => {
|
|
@@ -26,6 +27,16 @@ const git = async (args: string[], cwd?: string) => {
|
|
|
26
27
|
throw new Error(await new Response(child.stderr).text())
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
const jj = async (args: string[]) => {
|
|
31
|
+
const child = Bun.spawn(["jj", ...args], {
|
|
32
|
+
stdout: "pipe",
|
|
33
|
+
stderr: "pipe",
|
|
34
|
+
})
|
|
35
|
+
await child.exited
|
|
36
|
+
if (child.exitCode !== 0)
|
|
37
|
+
throw new Error(await new Response(child.stderr).text())
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
describe("ReviewService", () => {
|
|
30
41
|
let root: string
|
|
31
42
|
let source: string
|
|
@@ -151,6 +162,78 @@ describe("ReviewService", () => {
|
|
|
151
162
|
expect(new TextDecoder().decode(oldPins.stdout).trim()).toBe("")
|
|
152
163
|
})
|
|
153
164
|
|
|
165
|
+
test("imports jj reviews for creation, diagnostics, materialization, and refresh", async () => {
|
|
166
|
+
if (!Bun.which("jj")) return
|
|
167
|
+
const repository = join(root, "repos/agency")
|
|
168
|
+
await rm(repository, { recursive: true, force: true })
|
|
169
|
+
await git(["clone", source, repository])
|
|
170
|
+
await git(["switch", "-c", "jj-review"], source)
|
|
171
|
+
await Bun.write(join(source, "README.md"), "jj review one\n")
|
|
172
|
+
await git(["commit", "-am", "jj review one"], source)
|
|
173
|
+
await jj(["git", "init", "--colocate", repository])
|
|
174
|
+
await Bun.write(
|
|
175
|
+
join(root, "agency.json"),
|
|
176
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
await runTestEffect(
|
|
180
|
+
taskCommand({
|
|
181
|
+
subcommand: "create",
|
|
182
|
+
args: ["jj-review"],
|
|
183
|
+
review: "agency",
|
|
184
|
+
ref: "jj-review",
|
|
185
|
+
cwd: root,
|
|
186
|
+
silent: true,
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
const created = await runTestEffect(
|
|
190
|
+
TaskService.pipe(
|
|
191
|
+
Effect.flatMap((service) => service.show("jj-review", root)),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
const original = "review" in created.data ? created.data.review.commit : ""
|
|
195
|
+
|
|
196
|
+
const context = await runTestEffect(
|
|
197
|
+
ContextService.pipe(
|
|
198
|
+
Effect.flatMap((service) =>
|
|
199
|
+
service.get({ target: "jj-review", cwd: root }),
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
expect(context.workspace!.warnings).toEqual([])
|
|
204
|
+
expect(context.review?.checkout?.resolvedCommit).toBe(original)
|
|
205
|
+
|
|
206
|
+
const doctor = await runTestEffect(
|
|
207
|
+
DoctorService.pipe(Effect.flatMap((service) => service.inspect(root))),
|
|
208
|
+
)
|
|
209
|
+
expect(
|
|
210
|
+
doctor.checks.find((check) => check.id === `ref.agency.${original}`),
|
|
211
|
+
).toMatchObject({ status: "pass" })
|
|
212
|
+
|
|
213
|
+
const workspace = await runTestEffect(
|
|
214
|
+
WorktreeService.pipe(
|
|
215
|
+
Effect.flatMap((service) =>
|
|
216
|
+
service.materialize("jj-review", undefined, root),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
expect(
|
|
221
|
+
await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
|
|
222
|
+
).toBe("jj review one\n")
|
|
223
|
+
|
|
224
|
+
await Bun.write(join(source, "README.md"), "jj review two\n")
|
|
225
|
+
await git(["commit", "-am", "jj review two"], source)
|
|
226
|
+
const refreshed = await runTestEffect(
|
|
227
|
+
ReviewService.pipe(
|
|
228
|
+
Effect.flatMap((service) => service.refresh("jj-review", root)),
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
expect(refreshed.changed).toBe(true)
|
|
232
|
+
expect(
|
|
233
|
+
await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
|
|
234
|
+
).toBe("jj review two\n")
|
|
235
|
+
})
|
|
236
|
+
|
|
154
237
|
test("rejects delivery and phase operations even when forced", async () => {
|
|
155
238
|
await createReview()
|
|
156
239
|
await expect(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Data, Effect, Layer } from "effect"
|
|
1
|
+
import { Data, Effect, Either, Layer } from "effect"
|
|
2
2
|
import { randomUUID } from "node:crypto"
|
|
3
3
|
import { lstat, mkdir } from "node:fs/promises"
|
|
4
4
|
import { dirname } from "node:path"
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
GitVersionControlService,
|
|
16
16
|
JjVersionControlService,
|
|
17
17
|
VersionControlService,
|
|
18
|
+
type VersionControlBackend,
|
|
18
19
|
} from "./VersionControlService"
|
|
19
20
|
import { withWorktreeLocks } from "./WorktreeLock"
|
|
20
21
|
import {
|
|
@@ -160,7 +161,11 @@ const normalizeBranch = (input: string, repositoryPath: string) =>
|
|
|
160
161
|
return `refs/heads/${name}`
|
|
161
162
|
})
|
|
162
163
|
|
|
163
|
-
const fetchCommit = (
|
|
164
|
+
const fetchCommit = (
|
|
165
|
+
repoPath: string,
|
|
166
|
+
sourceRef: string,
|
|
167
|
+
backend: VersionControlBackend,
|
|
168
|
+
) =>
|
|
164
169
|
Effect.gen(function* () {
|
|
165
170
|
const fs = yield* FileSystemService
|
|
166
171
|
const temporaryRef = `refs/agency/review-fetch/${process.pid}-${randomUUID()}`
|
|
@@ -196,6 +201,17 @@ const fetchCommit = (repoPath: string, sourceRef: string) =>
|
|
|
196
201
|
],
|
|
197
202
|
{ captureOutput: true },
|
|
198
203
|
)
|
|
204
|
+
const commit = resolved.stdout.trim()
|
|
205
|
+
if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
|
|
206
|
+
yield* fs.runCommand(
|
|
207
|
+
["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
|
|
208
|
+
{ captureOutput: true },
|
|
209
|
+
)
|
|
210
|
+
return yield* new ReviewError({
|
|
211
|
+
message: `Review source '${sourceRef}' did not resolve to a commit`,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
const imported = yield* Effect.either(backend.importGitRefs(repoPath))
|
|
199
215
|
const cleanup = yield* fs.runCommand(
|
|
200
216
|
["git", "-C", repoPath, "update-ref", "-d", temporaryRef],
|
|
201
217
|
{ captureOutput: true },
|
|
@@ -205,10 +221,10 @@ const fetchCommit = (repoPath: string, sourceRef: string) =>
|
|
|
205
221
|
message: `Failed to remove temporary review fetch ref: ${cleanup.stderr.trim()}`,
|
|
206
222
|
})
|
|
207
223
|
}
|
|
208
|
-
|
|
209
|
-
if (resolved.exitCode !== 0 || !/^[a-f0-9]{40}$/.test(commit)) {
|
|
224
|
+
if (Either.isLeft(imported)) {
|
|
210
225
|
return yield* new ReviewError({
|
|
211
|
-
message: `Review source '${sourceRef}'
|
|
226
|
+
message: `Review source '${sourceRef}' was fetched but could not be imported into ${backend.kind}`,
|
|
227
|
+
cause: imported.left,
|
|
212
228
|
})
|
|
213
229
|
}
|
|
214
230
|
return commit
|
|
@@ -225,6 +241,8 @@ export class ReviewService extends Effect.Service<ReviewService>()(
|
|
|
225
241
|
) =>
|
|
226
242
|
Effect.gen(function* () {
|
|
227
243
|
const repositories = yield* RepositoryService
|
|
244
|
+
const versionControl = yield* VersionControlService
|
|
245
|
+
const backend = yield* versionControl.forWorkbase(startPath)
|
|
228
246
|
const repository = yield* repositories.show(repo, startPath)
|
|
229
247
|
if (!repository.remote || repository.states.includes("missing")) {
|
|
230
248
|
return yield* new ReviewError({
|
|
@@ -273,7 +291,7 @@ export class ReviewService extends Effect.Service<ReviewService>()(
|
|
|
273
291
|
message: "Exactly one review source is required",
|
|
274
292
|
})
|
|
275
293
|
}
|
|
276
|
-
const commit = yield* fetchCommit(repository.path, sourceRef)
|
|
294
|
+
const commit = yield* fetchCommit(repository.path, sourceRef, backend)
|
|
277
295
|
return {
|
|
278
296
|
repo,
|
|
279
297
|
source,
|
|
@@ -47,6 +47,9 @@ export interface VersionControlBackend {
|
|
|
47
47
|
remote?: string,
|
|
48
48
|
branch?: string,
|
|
49
49
|
) => Effect.Effect<void, unknown, any>
|
|
50
|
+
readonly importGitRefs: (
|
|
51
|
+
repositoryPath: string,
|
|
52
|
+
) => Effect.Effect<void, unknown, any>
|
|
50
53
|
readonly push: (
|
|
51
54
|
workspacePath: string,
|
|
52
55
|
remote: string,
|
|
@@ -240,6 +243,7 @@ export class GitVersionControlService extends Effect.Service<GitVersionControlSe
|
|
|
240
243
|
),
|
|
241
244
|
)
|
|
242
245
|
}),
|
|
246
|
+
importGitRefs: () => Effect.void,
|
|
243
247
|
push: (workspacePath, remote, branch) =>
|
|
244
248
|
Effect.gen(function* () {
|
|
245
249
|
const fs = yield* FileSystemService
|
|
@@ -333,18 +337,27 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
|
|
|
333
337
|
resolveRevision: (repositoryPath, revision) =>
|
|
334
338
|
Effect.gen(function* () {
|
|
335
339
|
const fs = yield* FileSystemService
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
340
|
+
const resolve = () =>
|
|
341
|
+
fs.runCommand(
|
|
342
|
+
jjCommand(repositoryPath, [
|
|
343
|
+
"log",
|
|
344
|
+
"--ignore-working-copy",
|
|
345
|
+
"--no-graph",
|
|
346
|
+
"-r",
|
|
347
|
+
revision,
|
|
348
|
+
"-T",
|
|
349
|
+
'commit_id ++ "\\n"',
|
|
350
|
+
]),
|
|
351
|
+
{ captureOutput: true },
|
|
352
|
+
)
|
|
353
|
+
let result = yield* resolve()
|
|
354
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
355
|
+
const imported = yield* fs.runCommand(
|
|
356
|
+
jjCommand(repositoryPath, ["git", "import"]),
|
|
357
|
+
{ captureOutput: true },
|
|
358
|
+
)
|
|
359
|
+
if (imported.exitCode === 0) result = yield* resolve()
|
|
360
|
+
}
|
|
348
361
|
return result.exitCode === 0 ? result.stdout.trim() || null : null
|
|
349
362
|
}),
|
|
350
363
|
workspaceHead: (workspacePath) =>
|
|
@@ -430,6 +443,16 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
|
|
|
430
443
|
),
|
|
431
444
|
)
|
|
432
445
|
}),
|
|
446
|
+
importGitRefs: (repositoryPath) =>
|
|
447
|
+
Effect.gen(function* () {
|
|
448
|
+
const fs = yield* FileSystemService
|
|
449
|
+
yield* requireSuccess(
|
|
450
|
+
"Failed to import Git refs into jj",
|
|
451
|
+
fs.runCommand(jjCommand(repositoryPath, ["git", "import"]), {
|
|
452
|
+
captureOutput: true,
|
|
453
|
+
}),
|
|
454
|
+
)
|
|
455
|
+
}),
|
|
433
456
|
push: (workspacePath, remote, branch) =>
|
|
434
457
|
Effect.gen(function* () {
|
|
435
458
|
const fs = yield* FileSystemService
|
|
@@ -164,6 +164,44 @@ describe("WorktreeService", () => {
|
|
|
164
164
|
expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
|
|
165
165
|
})
|
|
166
166
|
|
|
167
|
+
test("recommends a repository fetch when jj cannot resolve a base", async () => {
|
|
168
|
+
if (!Bun.which("jj")) return
|
|
169
|
+
const repository = join(root, "repos/agency")
|
|
170
|
+
await rm(repository, { recursive: true, force: true })
|
|
171
|
+
await git(["clone", source, repository])
|
|
172
|
+
await jj(["git", "init", "--colocate", repository])
|
|
173
|
+
await Bun.write(
|
|
174
|
+
join(root, "agency.json"),
|
|
175
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
176
|
+
)
|
|
177
|
+
await runTestEffect(
|
|
178
|
+
TaskService.pipe(
|
|
179
|
+
Effect.flatMap((service) =>
|
|
180
|
+
service.create(
|
|
181
|
+
{
|
|
182
|
+
id: "jj-missing",
|
|
183
|
+
ticketUrl: null,
|
|
184
|
+
repo: "agency",
|
|
185
|
+
branch: "task/jj-missing",
|
|
186
|
+
base: "absent-base",
|
|
187
|
+
},
|
|
188
|
+
root,
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
await expect(
|
|
195
|
+
runTestEffect(
|
|
196
|
+
WorktreeService.pipe(
|
|
197
|
+
Effect.flatMap((service) =>
|
|
198
|
+
service.materialize("jj-missing", undefined, root),
|
|
199
|
+
),
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
).rejects.toThrow("run 'agency repo fetch agency' and retry")
|
|
203
|
+
})
|
|
204
|
+
|
|
167
205
|
test("does not fetch the origin for an existing writable worktree", async () => {
|
|
168
206
|
await runTestEffect(
|
|
169
207
|
TaskService.pipe(
|
|
@@ -884,8 +884,12 @@ const materializeJj = (options: {
|
|
|
884
884
|
revision = yield* backend.resolveRevision(repositoryPath, base)
|
|
885
885
|
}
|
|
886
886
|
if (!revision) {
|
|
887
|
+
const recovery =
|
|
888
|
+
backend.kind === "jj"
|
|
889
|
+
? `; run 'agency repo fetch ${checkout.repo}' and retry`
|
|
890
|
+
: ""
|
|
887
891
|
return yield* new WorktreeError({
|
|
888
|
-
message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit`,
|
|
892
|
+
message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit${recovery}`,
|
|
889
893
|
})
|
|
890
894
|
}
|
|
891
895
|
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
parsePullRequestRecord,
|
|
4
4
|
recordFromGitHubJson,
|
|
5
5
|
resolveDeliveryCommand,
|
|
6
|
+
resolveGitHubCreateCommand,
|
|
6
7
|
validateDelivery,
|
|
7
8
|
} from "./delivery-command"
|
|
8
9
|
|
|
@@ -31,6 +32,34 @@ describe("delivery commands", () => {
|
|
|
31
32
|
})
|
|
32
33
|
})
|
|
33
34
|
|
|
35
|
+
test("adds explicit jj context to the default GitHub create command", () => {
|
|
36
|
+
const input = {
|
|
37
|
+
base: "main",
|
|
38
|
+
branch: "feat/example",
|
|
39
|
+
repository: "example/agency",
|
|
40
|
+
draft: false,
|
|
41
|
+
} as const
|
|
42
|
+
expect(resolveGitHubCreateCommand({ ...input, vcs: "jj" })).toEqual({
|
|
43
|
+
argv: [
|
|
44
|
+
"gh",
|
|
45
|
+
"pr",
|
|
46
|
+
"create",
|
|
47
|
+
"--fill",
|
|
48
|
+
"--base",
|
|
49
|
+
"main",
|
|
50
|
+
"--head",
|
|
51
|
+
"feat/example",
|
|
52
|
+
"--repo",
|
|
53
|
+
"example/agency",
|
|
54
|
+
],
|
|
55
|
+
environment: {},
|
|
56
|
+
})
|
|
57
|
+
expect(resolveGitHubCreateCommand({ ...input, vcs: "git" })).toEqual({
|
|
58
|
+
argv: ["gh", "pr", "create", "--fill", "--base", "main"],
|
|
59
|
+
environment: {},
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
34
63
|
test("rejects unknown placeholders", () => {
|
|
35
64
|
expect(() =>
|
|
36
65
|
validateDelivery({ ...delivery, queryCommand: ["forge", "{unknown}"] }),
|
|
@@ -11,6 +11,39 @@ export interface DeliveryCommandVariables {
|
|
|
11
11
|
readonly identifier: string
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export const repositoryFromRemote = (remote: string) =>
|
|
15
|
+
remote
|
|
16
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?[^/]+\//i, "")
|
|
17
|
+
.replace(/^[^:]+:/, "")
|
|
18
|
+
.replace(/\.git\/?$/, "")
|
|
19
|
+
.replace(/\/$/, "")
|
|
20
|
+
|
|
21
|
+
export const resolveGitHubCreateCommand = ({
|
|
22
|
+
base,
|
|
23
|
+
branch,
|
|
24
|
+
repository,
|
|
25
|
+
draft,
|
|
26
|
+
vcs,
|
|
27
|
+
}: {
|
|
28
|
+
readonly base: string
|
|
29
|
+
readonly branch: string
|
|
30
|
+
readonly repository: string
|
|
31
|
+
readonly draft: boolean
|
|
32
|
+
readonly vcs: "git" | "jj"
|
|
33
|
+
}) => ({
|
|
34
|
+
argv: [
|
|
35
|
+
"gh",
|
|
36
|
+
"pr",
|
|
37
|
+
"create",
|
|
38
|
+
"--fill",
|
|
39
|
+
"--base",
|
|
40
|
+
base,
|
|
41
|
+
...(vcs === "jj" ? ["--head", branch, "--repo", repository] : []),
|
|
42
|
+
...(draft ? ["--draft"] : []),
|
|
43
|
+
],
|
|
44
|
+
environment: {},
|
|
45
|
+
})
|
|
46
|
+
|
|
14
47
|
const PLACEHOLDERS = new Set<keyof DeliveryCommandVariables>([
|
|
15
48
|
"repository",
|
|
16
49
|
"branch",
|