@markjaquith/agency 2.54.0 → 2.54.2
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 +7 -3
- package/cli-main.ts +37 -9
- package/package.json +1 -1
- package/src/cli-parser.test.ts +24 -0
- package/src/cli-parser.ts +29 -8
- package/src/cli.test.ts +14 -0
- package/src/commands/pr.test.ts +42 -2
- package/src/commands/pr.ts +29 -3
- package/src/services/DoctorService.ts +5 -26
- 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/AGENTS.md +2 -2
package/README.md
CHANGED
|
@@ -805,6 +805,7 @@ for restoration. Archived IDs are reserved until restored.
|
|
|
805
805
|
agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print-command]
|
|
806
806
|
agency work prepare [target] [--dry-run] [--json]
|
|
807
807
|
agency worktree <list|inspect|prepare|remove|rebuild|repair>
|
|
808
|
+
agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
|
|
808
809
|
agency pr [args...]
|
|
809
810
|
```
|
|
810
811
|
|
|
@@ -851,9 +852,12 @@ Agency resolves the ref to a commit and creates a detached worktree. Existing
|
|
|
851
852
|
reference worktrees are reused only while their commit still matches the declared
|
|
852
853
|
ref; use a commit SHA as `ref` when reproducibility matters.
|
|
853
854
|
|
|
854
|
-
`agency pr
|
|
855
|
-
|
|
856
|
-
|
|
855
|
+
Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
|
|
856
|
+
including readiness checks and durable PR recording. Other `agency pr`
|
|
857
|
+
invocations forward every argument to `gh pr`. From an execution task or phase
|
|
858
|
+
directory, including descendants, passthrough runs in that execution unit's
|
|
859
|
+
authoritative writable checkout. Otherwise it runs in the caller's current
|
|
860
|
+
directory.
|
|
857
861
|
|
|
858
862
|
### Status and Validation
|
|
859
863
|
|
package/cli-main.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join, resolve } from "node:path"
|
|
|
5
5
|
import { parseCli } from "./src/cli-parser"
|
|
6
6
|
import { init, help as initHelp } from "./src/commands/init"
|
|
7
7
|
import { task, help as taskHelp } from "./src/commands/task"
|
|
8
|
-
import { pr, help as prHelp } from "./src/commands/pr"
|
|
8
|
+
import { pr, prCreate, help as prHelp } from "./src/commands/pr"
|
|
9
9
|
import { work, workPrepare, help as workHelp } from "./src/commands/work"
|
|
10
10
|
import { worktree, help as worktreeHelp } from "./src/commands/worktree"
|
|
11
11
|
import { status, help as statusHelp } from "./src/commands/status"
|
|
@@ -134,7 +134,7 @@ const resolveInvocationCwd = (
|
|
|
134
134
|
) =>
|
|
135
135
|
runEffect(
|
|
136
136
|
Effect.gen(function* () {
|
|
137
|
-
if (commandName === "pr") {
|
|
137
|
+
if (commandName === "pr" && options.passthrough) {
|
|
138
138
|
return resolve(options.cwd ?? process.cwd())
|
|
139
139
|
}
|
|
140
140
|
if (
|
|
@@ -287,7 +287,22 @@ const commands: Record<string, Command> = {
|
|
|
287
287
|
console.log(prHelp)
|
|
288
288
|
return
|
|
289
289
|
}
|
|
290
|
-
|
|
290
|
+
if (options.passthrough) {
|
|
291
|
+
process.exitCode = await runEffect(pr(args, options.cwd))
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
await runCommand(
|
|
295
|
+
prCreate({
|
|
296
|
+
taskId: args[1]!,
|
|
297
|
+
phaseId: args[2],
|
|
298
|
+
draft: options.draft,
|
|
299
|
+
force: options.force,
|
|
300
|
+
json: options.json,
|
|
301
|
+
silent: options.silent,
|
|
302
|
+
verbose: options.verbose,
|
|
303
|
+
cwd: options.cwd,
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
291
306
|
},
|
|
292
307
|
},
|
|
293
308
|
phase: {
|
|
@@ -726,7 +741,7 @@ Commands:
|
|
|
726
741
|
worktree <subcommand> Inspect and maintain managed workspaces
|
|
727
742
|
vcs <subcommand> Inspect or migrate the version-control backend
|
|
728
743
|
next List or select ready execution units
|
|
729
|
-
pr [
|
|
744
|
+
pr create / pr [...] Create an Agency PR or run gh pr with repository focus
|
|
730
745
|
review refresh Explicitly refresh a pinned review task
|
|
731
746
|
repo <subcommand> Manage workbase repositories
|
|
732
747
|
status Show status for the current workbase
|
|
@@ -761,7 +776,7 @@ const machineMode = process.argv
|
|
|
761
776
|
|
|
762
777
|
try {
|
|
763
778
|
const args = process.argv.slice(2)
|
|
764
|
-
const { commandName, args: commandArgs, values } = parseCli(args)
|
|
779
|
+
const { commandName, args: commandArgs, passthrough, values } = parseCli(args)
|
|
765
780
|
|
|
766
781
|
// Handle global flags
|
|
767
782
|
if (values.version) {
|
|
@@ -785,16 +800,29 @@ try {
|
|
|
785
800
|
!values.json &&
|
|
786
801
|
!values["no-input"] &&
|
|
787
802
|
Boolean(process.stdin.isTTY && process.stdout.isTTY)
|
|
788
|
-
const cwd = await resolveInvocationCwd(commandName,
|
|
803
|
+
const cwd = await resolveInvocationCwd(commandName, {
|
|
804
|
+
...values,
|
|
805
|
+
passthrough,
|
|
806
|
+
})
|
|
789
807
|
if (values.json || (values.jsonl && values.help)) {
|
|
790
808
|
const result = await collectCommandResult(() =>
|
|
791
|
-
command.run(commandArgs, { ...values, cwd, inputAllowed }),
|
|
809
|
+
command.run(commandArgs, { ...values, cwd, inputAllowed, passthrough }),
|
|
792
810
|
)
|
|
793
811
|
writeEnvelope(successEnvelope(result))
|
|
794
812
|
} else if (values.jsonl) {
|
|
795
|
-
await command.run(commandArgs, {
|
|
813
|
+
await command.run(commandArgs, {
|
|
814
|
+
...values,
|
|
815
|
+
cwd,
|
|
816
|
+
inputAllowed: false,
|
|
817
|
+
passthrough,
|
|
818
|
+
})
|
|
796
819
|
} else {
|
|
797
|
-
await command.run(commandArgs, {
|
|
820
|
+
await command.run(commandArgs, {
|
|
821
|
+
...values,
|
|
822
|
+
cwd,
|
|
823
|
+
inputAllowed,
|
|
824
|
+
passthrough,
|
|
825
|
+
})
|
|
798
826
|
}
|
|
799
827
|
} catch (error) {
|
|
800
828
|
if (machineMode) {
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -367,10 +367,34 @@ describe("strict CLI parsing", () => {
|
|
|
367
367
|
expect(parseCli(["--cwd", "/workbase", "pr", ...args])).toEqual({
|
|
368
368
|
commandName: "pr",
|
|
369
369
|
args,
|
|
370
|
+
passthrough: true,
|
|
370
371
|
values: { cwd: "/workbase" },
|
|
371
372
|
})
|
|
372
373
|
})
|
|
373
374
|
|
|
375
|
+
test("parses task-aware pr create without routing it to passthrough", () => {
|
|
376
|
+
expect(
|
|
377
|
+
parseCli([
|
|
378
|
+
"pr",
|
|
379
|
+
"create",
|
|
380
|
+
"ship",
|
|
381
|
+
"release",
|
|
382
|
+
"--draft",
|
|
383
|
+
"--force",
|
|
384
|
+
"--json",
|
|
385
|
+
]),
|
|
386
|
+
).toEqual({
|
|
387
|
+
commandName: "pr",
|
|
388
|
+
args: ["create", "ship", "release"],
|
|
389
|
+
values: { draft: true, force: true, json: true },
|
|
390
|
+
})
|
|
391
|
+
const selected = parseCli(["pr", "create", "--task", "ship"])
|
|
392
|
+
expect(selected).toMatchObject({
|
|
393
|
+
args: ["create", "ship"],
|
|
394
|
+
})
|
|
395
|
+
expect(selected.passthrough).toBeUndefined()
|
|
396
|
+
})
|
|
397
|
+
|
|
374
398
|
test("parses explicit dry-run reconciliation", () => {
|
|
375
399
|
expect(parseCli(["sync"])).toMatchObject({
|
|
376
400
|
commandName: "sync",
|
package/src/cli-parser.ts
CHANGED
|
@@ -895,12 +895,22 @@ const commands = {
|
|
|
895
895
|
},
|
|
896
896
|
},
|
|
897
897
|
pr: {
|
|
898
|
-
usage: "agency pr [args...]",
|
|
899
|
-
options:
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
898
|
+
usage: "agency pr create <task-id> [phase-id] | agency pr [args...]",
|
|
899
|
+
options: {
|
|
900
|
+
...outputOptions,
|
|
901
|
+
draft: { type: "boolean" },
|
|
902
|
+
force: { type: "boolean" },
|
|
903
|
+
task: { type: "string" },
|
|
904
|
+
phase: { type: "string" },
|
|
905
|
+
},
|
|
906
|
+
subcommands: {
|
|
907
|
+
create: {
|
|
908
|
+
usage:
|
|
909
|
+
"agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
|
|
910
|
+
minArgs: 1,
|
|
911
|
+
maxArgs: 2,
|
|
912
|
+
options: ["draft", "force", "json", "task", "phase"],
|
|
913
|
+
},
|
|
904
914
|
},
|
|
905
915
|
},
|
|
906
916
|
next: {
|
|
@@ -1019,6 +1029,7 @@ const preCommandValueOptions = new Set(["--workbase", "--cwd"])
|
|
|
1019
1029
|
export interface ParsedCli {
|
|
1020
1030
|
readonly commandName?: keyof typeof commands
|
|
1021
1031
|
readonly args: string[]
|
|
1032
|
+
readonly passthrough?: boolean
|
|
1022
1033
|
readonly values: Record<
|
|
1023
1034
|
string,
|
|
1024
1035
|
boolean | string | (boolean | string)[] | undefined
|
|
@@ -1091,6 +1102,7 @@ const targetSlots = (
|
|
|
1091
1102
|
if (commandName === "worktree" && subcommand !== "list") {
|
|
1092
1103
|
return ["task", "phase"]
|
|
1093
1104
|
}
|
|
1105
|
+
if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
|
|
1094
1106
|
return []
|
|
1095
1107
|
}
|
|
1096
1108
|
|
|
@@ -1311,7 +1323,15 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1311
1323
|
"agency <command> [options]",
|
|
1312
1324
|
)
|
|
1313
1325
|
}
|
|
1314
|
-
|
|
1326
|
+
const prArgs = args.slice(commandIndex + 1)
|
|
1327
|
+
const taskAwarePrCreate =
|
|
1328
|
+
commandName === "pr" &&
|
|
1329
|
+
prArgs[0] === "create" &&
|
|
1330
|
+
((prArgs[1] !== undefined && !prArgs[1].startsWith("-")) ||
|
|
1331
|
+
prArgs.some(
|
|
1332
|
+
(argument) => argument === "--task" || argument.startsWith("--task="),
|
|
1333
|
+
))
|
|
1334
|
+
if (commandName === "pr" && !taskAwarePrCreate) {
|
|
1315
1335
|
const parsed = parse(
|
|
1316
1336
|
args.slice(0, commandIndex),
|
|
1317
1337
|
rootOptions,
|
|
@@ -1336,7 +1356,8 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
1336
1356
|
}
|
|
1337
1357
|
return {
|
|
1338
1358
|
commandName,
|
|
1339
|
-
args:
|
|
1359
|
+
args: prArgs,
|
|
1360
|
+
passthrough: true,
|
|
1340
1361
|
values: parsed.values,
|
|
1341
1362
|
}
|
|
1342
1363
|
}
|
package/src/cli.test.ts
CHANGED
|
@@ -598,6 +598,20 @@ exit 23
|
|
|
598
598
|
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
599
599
|
},
|
|
600
600
|
])
|
|
601
|
+
|
|
602
|
+
const blockedPr = await runCli(["pr", "create", "finished", "--json"], root)
|
|
603
|
+
expect(blockedPr.exitCode).toBe(1)
|
|
604
|
+
expect(blockedPr.stderr).toBe("")
|
|
605
|
+
expect(JSON.parse(blockedPr.stdout)).toMatchObject({
|
|
606
|
+
ok: false,
|
|
607
|
+
error: {
|
|
608
|
+
code: "EXECUTION_BLOCKED",
|
|
609
|
+
fields: {
|
|
610
|
+
status: "dropped",
|
|
611
|
+
blockers: [{ kind: "status", reason: "Task status is dropped" }],
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
})
|
|
601
615
|
})
|
|
602
616
|
|
|
603
617
|
test("reports and synchronizes managed integration files", async () => {
|
package/src/commands/pr.test.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
2
3
|
import { chmod, mkdir, realpath } from "node:fs/promises"
|
|
3
4
|
import { dirname, join } from "node:path"
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
5
|
+
import { PullRequestService } from "../services/PullRequestService"
|
|
6
|
+
import {
|
|
7
|
+
captureLogs,
|
|
8
|
+
cleanupTempDir,
|
|
9
|
+
createTempDir,
|
|
10
|
+
runTestEffect,
|
|
11
|
+
} from "../test-utils"
|
|
12
|
+
import { pr, prCreate } from "./pr"
|
|
6
13
|
|
|
7
14
|
const write = async (root: string, path: string, content: string) => {
|
|
8
15
|
const fullPath = join(root, path)
|
|
@@ -95,6 +102,39 @@ status: open
|
|
|
95
102
|
return root
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
test("creates and records an Agency pull request", async () => {
|
|
106
|
+
const url = "https://github.com/markjaquith/agency/pull/123"
|
|
107
|
+
let received: unknown[] = []
|
|
108
|
+
const logs = await captureLogs(() =>
|
|
109
|
+
Effect.runPromise(
|
|
110
|
+
prCreate({
|
|
111
|
+
taskId: "example",
|
|
112
|
+
phaseId: "implementation",
|
|
113
|
+
draft: true,
|
|
114
|
+
force: true,
|
|
115
|
+
cwd: "/workbase",
|
|
116
|
+
json: true,
|
|
117
|
+
}).pipe(
|
|
118
|
+
Effect.provideService(PullRequestService, {
|
|
119
|
+
create: (...args: unknown[]) => {
|
|
120
|
+
received = args
|
|
121
|
+
return Effect.succeed(url)
|
|
122
|
+
},
|
|
123
|
+
} as never),
|
|
124
|
+
) as Effect.Effect<void, unknown, never>,
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
expect(JSON.parse(logs[0]!)).toEqual({ url })
|
|
129
|
+
expect(received).toEqual([
|
|
130
|
+
"example",
|
|
131
|
+
"implementation",
|
|
132
|
+
true,
|
|
133
|
+
"/workbase",
|
|
134
|
+
expect.objectContaining({ force: true, draft: true, json: true }),
|
|
135
|
+
])
|
|
136
|
+
})
|
|
137
|
+
|
|
98
138
|
test("focuses task and descendant invocations on the writable checkout", async () => {
|
|
99
139
|
const root = await createExecutionWorkbase()
|
|
100
140
|
const task = join(root, "tasks/single")
|
package/src/commands/pr.ts
CHANGED
|
@@ -2,6 +2,30 @@ import { Effect } from "effect"
|
|
|
2
2
|
import { resolve } from "node:path"
|
|
3
3
|
import { ContextService } from "../services/ContextService"
|
|
4
4
|
import { FileSystemService } from "../services/FileSystemService"
|
|
5
|
+
import { PullRequestService } from "../services/PullRequestService"
|
|
6
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
7
|
+
import { createLoggers } from "../utils/effect"
|
|
8
|
+
|
|
9
|
+
interface PrCreateOptions extends BaseCommandOptions {
|
|
10
|
+
readonly taskId: string
|
|
11
|
+
readonly phaseId?: string
|
|
12
|
+
readonly draft?: boolean
|
|
13
|
+
readonly force?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const prCreate = (options: PrCreateOptions) =>
|
|
17
|
+
Effect.gen(function* () {
|
|
18
|
+
const pullRequests = yield* PullRequestService
|
|
19
|
+
const { log } = createLoggers(options)
|
|
20
|
+
const url = yield* pullRequests.create(
|
|
21
|
+
options.taskId,
|
|
22
|
+
options.phaseId,
|
|
23
|
+
options.draft,
|
|
24
|
+
options.cwd ?? process.cwd(),
|
|
25
|
+
options,
|
|
26
|
+
)
|
|
27
|
+
log(options.json ? JSON.stringify({ url }, null, 2) : url)
|
|
28
|
+
})
|
|
5
29
|
|
|
6
30
|
export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
7
31
|
Effect.gen(function* () {
|
|
@@ -24,8 +48,10 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
|
|
|
24
48
|
})
|
|
25
49
|
|
|
26
50
|
export const help = `
|
|
27
|
-
Usage: agency pr [
|
|
51
|
+
Usage: agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
|
|
52
|
+
agency pr [args...]
|
|
28
53
|
|
|
29
|
-
|
|
30
|
-
|
|
54
|
+
Create records a pull request for an Agency execution unit. Other invocations
|
|
55
|
+
run gh pr unchanged, focusing the writable repository checkout when invoked from
|
|
56
|
+
an Agency execution task or phase.
|
|
31
57
|
`
|
|
@@ -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",
|
|
@@ -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
|
|
package/src/workbase/AGENTS.md
CHANGED
|
@@ -59,8 +59,8 @@ to override readiness.
|
|
|
59
59
|
- Do not run `agency work` from an active agent session unless the user
|
|
60
60
|
explicitly asks to launch another agent.
|
|
61
61
|
- Run `agency validate` before worktree or pull-request operations.
|
|
62
|
-
- Create a pull request only with explicit user intent. Run
|
|
63
|
-
|
|
62
|
+
- Create a pull request only with explicit user intent. Run
|
|
63
|
+
`agency pr create <task> [phase]` so the URL is recorded durably.
|
|
64
64
|
|
|
65
65
|
## Execution
|
|
66
66
|
|