@markjaquith/agency 2.65.0 → 2.65.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 CHANGED
@@ -650,8 +650,8 @@ detailed dependency, validation, or status blockers for orchestrators.
650
650
 
651
651
  `agency work` consults this shared readiness model before materializing. Blocked,
652
652
  done, and dropped targets are rejected unless `--force` is supplied explicitly.
653
- `agency pr` leaves command semantics to `gh`; run `agency context . --json` first
654
- when readiness or validation state needs inspection.
653
+ Task-aware `agency pr create` applies Agency readiness and validation checks;
654
+ untargeted `agency pr` invocations leave command semantics to `gh`.
655
655
 
656
656
  ### Reconciliation
657
657
 
@@ -663,6 +663,10 @@ structured `changes`, `warnings`, `unresolved`, and per-execution evidence. The
663
663
  default mode applies safe reconciliation transitions; `--dry-run` is explicitly
664
664
  observational.
665
665
 
666
+ Pass `<task-id>` to scope reconciliation to one task and its repositories. A
667
+ multi-phase task scope includes all of its phases; add `[phase-id]` to select one
668
+ phase. Scoped sync does not query, materialize, or reconcile unrelated work.
669
+
666
670
  `agency sync` performs only these safe transitions:
667
671
 
668
672
  - materialize declared but missing repositories from their canonical remotes;
@@ -1050,7 +1054,7 @@ agency work [<directory> | --epic <epic-id>] [--runner <name>] [--auto] [--print
1050
1054
  agency work prepare [target] [--evidence <json-or-path>] [--dry-run] [--json]
1051
1055
  agency worktree <list|inspect|prepare|remove|rebuild|repair>
1052
1056
  agency push [--json]
1053
- agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
1057
+ agency pr create <task-id> [phase-id] [--draft] [--title <title>] [--head <branch>] [--base <branch>] [--label <label>] [--force] [--json]
1054
1058
  agency pr [args...]
1055
1059
  ```
1056
1060
 
@@ -1137,9 +1141,16 @@ post-commit working copy, in which case it publishes `@-`; described empty
1137
1141
  changes remain intentional publication tips. Missing descriptions or authors
1138
1142
  stop with exact change IDs and remediation commands. Agency creates or safely
1139
1143
  advances only the declared bookmark and never invents a `push-*` bookmark.
1144
+ If the fetched remote base advanced outside the local stack, Agency prints the
1145
+ exact `jj rebase` command needed to move the stack onto `<base>@<remote>`. Push
1146
+ reports deterministic fetch, inspection, validation, and publication progress
1147
+ on stderr, including while `--json` reserves stdout for one machine result.
1140
1148
 
1141
1149
  Task-aware `agency pr create <task-id> [phase-id]` uses Agency's delivery flow,
1142
- including readiness checks and durable PR recording. Other `agency pr`
1150
+ including readiness checks and durable PR recording. It accepts draft, title,
1151
+ declared head/base confirmation, and repeatable label options; a contradicting
1152
+ head or base is rejected rather than recording inconsistent delivery metadata.
1153
+ Other `agency pr`
1143
1154
  invocations forward every argument to `gh pr`. From an execution task or phase
1144
1155
  directory, including descendants, passthrough runs in that execution unit's
1145
1156
  authoritative writable checkout. Otherwise it runs in the caller's current
package/cli-main.ts CHANGED
@@ -322,6 +322,10 @@ const commands: Record<string, Command> = {
322
322
  phaseId: args[2],
323
323
  draft: options.draft,
324
324
  force: options.force,
325
+ title: options.title,
326
+ head: options.head,
327
+ base: options.base,
328
+ labels: options.label,
325
329
  json: options.json,
326
330
  silent: options.silent,
327
331
  verbose: options.verbose,
@@ -750,13 +754,15 @@ const commands: Record<string, Command> = {
750
754
  },
751
755
  },
752
756
  sync: {
753
- run: async (_args: string[], options: Record<string, any>) => {
757
+ run: async (args: string[], options: Record<string, any>) => {
754
758
  if (options.help) {
755
759
  console.log(syncHelp)
756
760
  return
757
761
  }
758
762
  await runCommand(
759
763
  sync({
764
+ taskId: args[0],
765
+ phaseId: args[1],
760
766
  dryRun: options["dry-run"],
761
767
  json: options.json,
762
768
  silent: options.silent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.65.0",
3
+ "version": "2.65.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -334,7 +334,7 @@ describe("strict CLI parsing", () => {
334
334
  [["context", "one", "two"], "agency context"],
335
335
  [["graph", "extra"], "agency graph"],
336
336
  [["next", "extra"], "agency next"],
337
- [["sync", "extra"], "agency sync"],
337
+ [["sync", "one", "two", "three"], "agency sync"],
338
338
  [
339
339
  [
340
340
  "claim",
@@ -430,6 +430,31 @@ describe("strict CLI parsing", () => {
430
430
  args: ["create", "ship"],
431
431
  })
432
432
  expect(selected.passthrough).toBeUndefined()
433
+ expect(
434
+ parseCli([
435
+ "pr",
436
+ "create",
437
+ "ship",
438
+ "--title",
439
+ "Ship it",
440
+ "--head",
441
+ "task/ship",
442
+ "--base",
443
+ "main",
444
+ "--label",
445
+ "ai-assisted",
446
+ "--label",
447
+ "product-area:platform",
448
+ ]),
449
+ ).toMatchObject({
450
+ args: ["create", "ship"],
451
+ values: {
452
+ title: "Ship it",
453
+ head: "task/ship",
454
+ base: "main",
455
+ label: ["ai-assisted", "product-area:platform"],
456
+ },
457
+ })
433
458
  })
434
459
 
435
460
  test("parses explicit dry-run reconciliation", () => {
@@ -441,6 +466,11 @@ describe("strict CLI parsing", () => {
441
466
  commandName: "sync",
442
467
  values: { "dry-run": true, json: true },
443
468
  })
469
+ expect(parseCli(["sync", "ship", "release", "--json"])).toMatchObject({
470
+ commandName: "sync",
471
+ args: ["ship", "release"],
472
+ values: { json: true },
473
+ })
444
474
  expect(() => parseCli(["sync", "--apply"])).toThrow("Unknown option")
445
475
  })
446
476
 
package/src/cli-parser.ts CHANGED
@@ -732,16 +732,17 @@ const commands = {
732
732
  },
733
733
  },
734
734
  sync: {
735
- usage: "agency sync [--dry-run] [--json]",
735
+ usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
736
736
  options: {
737
737
  ...outputOptions,
738
+ ...entitySelectorOptions,
738
739
  "dry-run": { type: "boolean" },
739
740
  },
740
741
  command: {
741
- usage: "agency sync [--dry-run] [--json]",
742
+ usage: "agency sync [<task-id> [phase-id]] [--dry-run] [--json]",
742
743
  minArgs: 0,
743
- maxArgs: 0,
744
- options: ["dry-run", "json"],
744
+ maxArgs: 2,
745
+ options: ["dry-run", "json", "task", "phase"],
745
746
  },
746
747
  },
747
748
  archive: {
@@ -964,14 +965,28 @@ const commands = {
964
965
  force: { type: "boolean" },
965
966
  task: { type: "string" },
966
967
  phase: { type: "string" },
968
+ title: { type: "string" },
969
+ head: { type: "string" },
970
+ base: { type: "string" },
971
+ label: { type: "string", multiple: true },
967
972
  },
968
973
  subcommands: {
969
974
  create: {
970
- usage:
971
- "agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
975
+ usage: "agency pr create <task-id> [phase-id] [options]",
972
976
  minArgs: 1,
973
977
  maxArgs: 2,
974
- options: ["draft", "force", "json", "task", "phase"],
978
+ options: [
979
+ "draft",
980
+ "force",
981
+ "json",
982
+ "task",
983
+ "phase",
984
+ "title",
985
+ "head",
986
+ "base",
987
+ "label",
988
+ ],
989
+ repeatable: ["label"],
975
990
  },
976
991
  },
977
992
  },
@@ -219,6 +219,73 @@ Phase prose.
219
219
  )
220
220
  })
221
221
 
222
+ test("reports the current jj change without inventing a missing bookmark", async () => {
223
+ if (!Bun.which("jj")) return
224
+ await cleanupTempDir(root)
225
+ root = await createTempDir()
226
+ const remote = join(root, "remote.git")
227
+ const seed = join(root, "seed")
228
+ const repository = join(root, "repos/agency")
229
+ const checkout = join(root, "tasks/example/code/agency")
230
+ await mkdir(join(root, "repos"), { recursive: true })
231
+ await write(root, "agency.json", '{"version":2,"vcs":"jj"}\n')
232
+ await run(root, ["git", "init", "--bare", "--initial-branch=main", remote])
233
+ await run(root, ["git", "init", "--initial-branch=main", seed])
234
+ await run(seed, ["git", "config", "user.email", "test@example.com"])
235
+ await run(seed, ["git", "config", "user.name", "Test"])
236
+ await Bun.write(join(seed, "README.md"), "example\n")
237
+ await run(seed, ["git", "add", "README.md"])
238
+ await run(seed, ["git", "commit", "-m", "initial"])
239
+ await run(seed, ["git", "remote", "add", "origin", remote])
240
+ await run(seed, ["git", "push", "origin", "main"])
241
+ await run(root, ["jj", "git", "clone", "--no-colocate", remote, repository])
242
+ await write(
243
+ root,
244
+ "tasks/example/TASK.md",
245
+ `---
246
+ ticketUrl: null
247
+ repo: agency
248
+ branch: task/example
249
+ base: main
250
+ pr: null
251
+ status: working
252
+ ---
253
+
254
+ # Example
255
+ `,
256
+ )
257
+ await mkdir(dirname(checkout), { recursive: true })
258
+ await run(repository, [
259
+ "jj",
260
+ "workspace",
261
+ "add",
262
+ "--name",
263
+ "task-example",
264
+ "-r",
265
+ "main",
266
+ checkout,
267
+ ])
268
+ await Bun.write(join(checkout, "feature.txt"), "current change\n")
269
+ const current = Bun.spawn(
270
+ ["jj", "log", "--no-graph", "-r", "@", "-T", "commit_id"],
271
+ { cwd: checkout, stdout: "pipe", stderr: "pipe" },
272
+ )
273
+ const currentCommit = (await new Response(current.stdout).text()).trim()
274
+ expect(await current.exited).toBe(0)
275
+
276
+ const result = await readContext(root, "tasks/example")
277
+ expect(result.workspace.writable).toMatchObject({
278
+ branchCommit: null,
279
+ checkoutCommit: currentCommit,
280
+ checkoutBranch: null,
281
+ detached: true,
282
+ dirty: false,
283
+ })
284
+ expect(result.workspace.warnings).toContain(
285
+ `Unable to resolve branch 'task/example' in ${repository}`,
286
+ )
287
+ })
288
+
222
289
  test("makes compact projection explicit without omitting essential identity", async () => {
223
290
  const result = await readContext(
224
291
  root,
@@ -158,6 +158,10 @@ status: open
158
158
  phaseId: "implementation",
159
159
  draft: true,
160
160
  force: true,
161
+ title: "Ship it",
162
+ head: "task/example",
163
+ base: "main",
164
+ labels: ["ai-assisted"],
161
165
  cwd: "/workbase",
162
166
  json: true,
163
167
  }).pipe(
@@ -177,7 +181,15 @@ status: open
177
181
  "implementation",
178
182
  true,
179
183
  "/workbase",
180
- expect.objectContaining({ force: true, draft: true, json: true }),
184
+ expect.objectContaining({
185
+ force: true,
186
+ draft: true,
187
+ title: "Ship it",
188
+ head: "task/example",
189
+ base: "main",
190
+ labels: ["ai-assisted"],
191
+ json: true,
192
+ }),
181
193
  ])
182
194
  })
183
195
 
@@ -14,6 +14,10 @@ interface PrCreateOptions extends BaseCommandOptions {
14
14
  readonly phaseId?: string
15
15
  readonly draft?: boolean
16
16
  readonly force?: boolean
17
+ readonly title?: string
18
+ readonly head?: string
19
+ readonly base?: string
20
+ readonly labels?: readonly string[]
17
21
  }
18
22
 
19
23
  const branchTargetCommands = new Set([
@@ -126,11 +130,21 @@ export const pr = (args: readonly string[], cwd: string = process.cwd()) =>
126
130
  })
127
131
 
128
132
  export const help = `
129
- Usage: agency pr create <task-id> [phase-id] [--draft] [--force] [--json]
133
+ Usage: agency pr create <task-id> [phase-id] [options]
130
134
  agency pr [args...]
131
135
 
132
- Create records a pull request for an Agency execution unit. Other invocations
133
- run gh pr unchanged, focusing the writable repository checkout when invoked from
136
+ Create records a pull request for an Agency execution unit and accepts the
137
+ options listed below. Invocations without an Agency task target run gh pr
138
+ unchanged, focusing the writable repository checkout when invoked from
134
139
  an Agency execution task or phase. In jj workbases, Agency supplies the declared
135
140
  branch and repository to gh subcommands that would otherwise infer Git context.
141
+
142
+ Options:
143
+ --draft Create the pull request as a draft
144
+ --title <title> Override the generated pull request title
145
+ --head <branch> Confirm the declared head branch
146
+ --base <branch> Confirm the declared base branch
147
+ --label <label> Add a label (repeatable)
148
+ --force Override readiness checks
149
+ --json Output one versioned machine result
136
150
  `
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { PushService } from "../services/PushService"
4
+ import { captureLogs } from "../test-utils"
5
+ import type { Progress } from "../utils/progress"
6
+ import { push } from "./push"
7
+
8
+ describe("push command", () => {
9
+ test("reports deterministic progress while preserving one JSON result", async () => {
10
+ const updates: string[] = []
11
+ const progress: Progress = {
12
+ start: (message) => updates.push(`start:${message}`),
13
+ succeed: (message) => updates.push(`succeed:${message}`),
14
+ fail: (message) => updates.push(`fail:${message}`),
15
+ }
16
+ const result = {
17
+ vcs: "jj" as const,
18
+ taskId: "example",
19
+ branch: "task/example",
20
+ base: "main",
21
+ remote: "origin",
22
+ tip: "abc123",
23
+ changeId: "change",
24
+ }
25
+ const logs = await captureLogs(() =>
26
+ Effect.runPromise(
27
+ push({ cwd: "/workbase", json: true }, progress).pipe(
28
+ Effect.provideService(PushService, {
29
+ publish: (_cwd: string, options: any) => {
30
+ for (const stage of [
31
+ "context",
32
+ "fetch",
33
+ "inspect",
34
+ "validate",
35
+ "publish",
36
+ ] as const)
37
+ options.onProgress(stage)
38
+ return Effect.succeed(result)
39
+ },
40
+ } as never),
41
+ ) as Effect.Effect<void, unknown, never>,
42
+ ),
43
+ )
44
+
45
+ expect(logs).toEqual([JSON.stringify(result, null, 2)])
46
+ expect(updates).toEqual([
47
+ "start:Inspecting Agency execution context",
48
+ "start:Fetching remote state",
49
+ "start:Selecting the publication tip",
50
+ "start:Validating outgoing changes",
51
+ "start:Publishing the declared branch",
52
+ "succeed:Published task/example to origin",
53
+ ])
54
+ })
55
+ })
@@ -2,12 +2,39 @@ import { Effect } from "effect"
2
2
  import { PushService } from "../services/PushService"
3
3
  import type { BaseCommandOptions } from "../utils/command"
4
4
  import { createLoggers } from "../utils/effect"
5
+ import { createProgress, type Progress } from "../utils/progress"
5
6
 
6
- export const push = (options: BaseCommandOptions = {}) =>
7
+ const stageMessage = {
8
+ context: "Inspecting Agency execution context",
9
+ fetch: "Fetching remote state",
10
+ inspect: "Selecting the publication tip",
11
+ validate: "Validating outgoing changes",
12
+ publish: "Publishing the declared branch",
13
+ } as const
14
+
15
+ export const push = (
16
+ options: BaseCommandOptions = {},
17
+ progress: Progress = createProgress({ silent: options.silent }),
18
+ ) =>
7
19
  Effect.gen(function* () {
8
20
  const publications = yield* PushService
9
21
  const { log } = createLoggers(options)
10
- const result = yield* publications.publish(options.cwd ?? process.cwd())
22
+ const showProgress = !options.silent
23
+ const result = yield* publications
24
+ .publish(options.cwd ?? process.cwd(), {
25
+ onProgress: showProgress
26
+ ? (stage) => progress.start(stageMessage[stage])
27
+ : undefined,
28
+ })
29
+ .pipe(
30
+ Effect.tapError(() =>
31
+ Effect.sync(() => {
32
+ if (showProgress) progress.fail("Publication failed")
33
+ }),
34
+ ),
35
+ )
36
+ if (showProgress)
37
+ progress.succeed(`Published ${result.branch} to ${result.remote}`)
11
38
  log(
12
39
  options.json
13
40
  ? JSON.stringify(result, null, 2)
@@ -82,6 +82,41 @@ describe("sync command", () => {
82
82
  ).toBe(false)
83
83
  })
84
84
 
85
+ test("scopes reconciliation to one task", async () => {
86
+ await runTestEffect(
87
+ TaskService.pipe(
88
+ Effect.flatMap((service) =>
89
+ service.create(
90
+ {
91
+ id: "second",
92
+ ticketUrl: null,
93
+ repo: "agency",
94
+ branch: "task/second",
95
+ base: "main",
96
+ },
97
+ root,
98
+ ),
99
+ ),
100
+ ),
101
+ )
102
+
103
+ const logs = await captureLogs(() =>
104
+ runTestEffect(sync({ cwd: root, taskId: "example", json: true })),
105
+ )
106
+ const result = JSON.parse(logs[0]!)
107
+ expect(result.executions.map((execution: any) => execution.target)).toEqual(
108
+ ["task:example"],
109
+ )
110
+ expect(
111
+ await Bun.file(
112
+ join(root, "tasks/example/code/agency/README.md"),
113
+ ).exists(),
114
+ ).toBe(true)
115
+ expect(
116
+ await Bun.file(join(root, "tasks/second/code/agency/README.md")).exists(),
117
+ ).toBe(false)
118
+ })
119
+
85
120
  test("preserves structured output behind --json", async () => {
86
121
  const logs = await captureLogs(() =>
87
122
  runTestEffect(sync({ cwd: root, dryRun: true, json: true })),
@@ -6,6 +6,8 @@ import { createProgress, type Progress } from "../utils/progress"
6
6
 
7
7
  interface SyncCommandOptions extends BaseCommandOptions {
8
8
  readonly dryRun?: boolean
9
+ readonly taskId?: string
10
+ readonly phaseId?: string
9
11
  }
10
12
 
11
13
  interface Notice {
@@ -44,6 +46,8 @@ export const sync = (
44
46
  .reconcile({
45
47
  cwd: options.cwd,
46
48
  apply: options.dryRun !== true,
49
+ taskId: options.taskId,
50
+ phaseId: options.phaseId,
47
51
  onProgress: showProgress
48
52
  ? ({ stage, current, total, target }) => {
49
53
  if (stage === "repositories") {
@@ -118,11 +122,13 @@ export const sync = (
118
122
  })
119
123
 
120
124
  export const help = `
121
- Usage: agency sync [--dry-run] [--json]
125
+ Usage: agency sync [<task-id> [phase-id]] [--dry-run] [--json]
122
126
 
123
127
  Compare portable repository declarations and execution state with local Git
124
128
  repositories, worktrees, branches, references, claims, and pull requests.
125
129
  Safe reconciliation transitions are applied by default.
130
+ When a task or phase is provided, only that target and its repositories are
131
+ queried or reconciled. Task scope includes all phases of a multi-phase task.
126
132
 
127
133
  Options:
128
134
  --dry-run Report planned safe transitions without changing state
@@ -17,6 +17,13 @@ const git = (args: readonly string[], cwd?: string) => {
17
17
  }
18
18
  }
19
19
 
20
+ const jj = (args: readonly string[], cwd?: string) => {
21
+ const result = Bun.spawnSync(["jj", ...args], { cwd })
22
+ if (result.exitCode !== 0) {
23
+ throw new Error(new TextDecoder().decode(result.stderr))
24
+ }
25
+ }
26
+
20
27
  describe("ArchiveService bulk task archive", () => {
21
28
  let root: string
22
29
  let source: string
@@ -436,6 +443,98 @@ claim:
436
443
  ).toBe(true)
437
444
  })
438
445
 
446
+ test("archives a jj working-copy commit preserved by its task bookmark", async () => {
447
+ if (!Bun.which("jj")) return
448
+ const repository = join(root, "repos/agency")
449
+ await rm(repository, { recursive: true, force: true })
450
+ git(["clone", source, repository])
451
+ jj(["git", "init", "--colocate", repository])
452
+ await Bun.write(
453
+ join(root, "agency.json"),
454
+ JSON.stringify({ version: 2, vcs: "jj" }),
455
+ )
456
+ await createTask("jj-bookmarked")
457
+ await dropTask("jj-bookmarked")
458
+ const workspace = await runTestEffect(
459
+ WorktreeService.pipe(
460
+ Effect.flatMap((service) =>
461
+ service.materialize("jj-bookmarked", undefined, root),
462
+ ),
463
+ ),
464
+ )
465
+ await Bun.write(join(workspace.writablePath!, "preserved.txt"), "keep\n")
466
+ jj(
467
+ ["bookmark", "set", "task/jj-bookmarked", "-r", "@"],
468
+ workspace.writablePath!,
469
+ )
470
+
471
+ const preview = await archiveTasks(true)
472
+ expect(preview.tasks[0]).toMatchObject({
473
+ id: "jj-bookmarked",
474
+ disposition: "planned",
475
+ removedWorktrees: [workspace.writablePath!],
476
+ })
477
+ expect(
478
+ await Bun.file(join(workspace.writablePath!, "preserved.txt")).text(),
479
+ ).toBe("keep\n")
480
+
481
+ const result = await archiveTasks()
482
+ expect(result.tasks[0]).toMatchObject({
483
+ id: "jj-bookmarked",
484
+ disposition: "archived",
485
+ })
486
+ expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
487
+ const preserved = Bun.spawnSync([
488
+ "jj",
489
+ "-R",
490
+ repository,
491
+ "file",
492
+ "show",
493
+ "-r",
494
+ "task/jj-bookmarked",
495
+ 'root:"preserved.txt"',
496
+ ])
497
+ if (preserved.exitCode !== 0) {
498
+ throw new Error(preserved.stderr.toString())
499
+ }
500
+ expect(preserved.stdout.toString()).toBe("keep\n")
501
+ })
502
+
503
+ test("archives a forgotten jj workspace preserved by its task bookmark", async () => {
504
+ if (!Bun.which("jj")) return
505
+ const repository = join(root, "repos/agency")
506
+ await rm(repository, { recursive: true, force: true })
507
+ git(["clone", source, repository])
508
+ jj(["git", "init", "--colocate", repository])
509
+ await Bun.write(
510
+ join(root, "agency.json"),
511
+ JSON.stringify({ version: 2, vcs: "jj" }),
512
+ )
513
+ await createTask("jj-stale")
514
+ await dropTask("jj-stale")
515
+ const workspace = await runTestEffect(
516
+ WorktreeService.pipe(
517
+ Effect.flatMap((service) =>
518
+ service.materialize("jj-stale", undefined, root),
519
+ ),
520
+ ),
521
+ )
522
+ await Bun.write(join(workspace.writablePath!, "preserved.txt"), "keep\n")
523
+ jj(["bookmark", "set", "task/jj-stale", "-r", "@"], workspace.writablePath!)
524
+ jj(["-R", repository, "workspace", "forget", "agency-jj-stale-task-agency"])
525
+
526
+ const result = await archiveTasks(true)
527
+
528
+ expect(result.tasks[0]).toMatchObject({
529
+ id: "jj-stale",
530
+ disposition: "planned",
531
+ removedWorktrees: [workspace.writablePath!],
532
+ })
533
+ expect(
534
+ await Bun.file(join(workspace.writablePath!, "preserved.txt")).text(),
535
+ ).toBe("keep\n")
536
+ })
537
+
439
538
  test("rolls back the entire cohort when application fails", async () => {
440
539
  await runTestEffect(
441
540
  EpicService.pipe(
@@ -898,6 +898,7 @@ export class ContextService extends Effect.Service<ContextService>()(
898
898
  repositoryPath: string,
899
899
  checkoutPath: string,
900
900
  expectedBranch: string | null = null,
901
+ branchCommit: string | null = null,
901
902
  ) =>
902
903
  Effect.gen(function* (): Generator<any, CheckoutInspection, any> {
903
904
  const materialized = yield* fs.isDirectory(checkoutPath)
@@ -914,9 +915,10 @@ export class ContextService extends Effect.Service<ContextService>()(
914
915
  const canonicalCheckoutPath = materialized
915
916
  ? yield* fs.realPath(checkoutPath)
916
917
  : resolve(checkoutPath)
917
- const registered = workspaces.some(
918
+ const workspace = workspaces.find(
918
919
  (workspace) => workspace.path === canonicalCheckoutPath,
919
920
  )
921
+ const registered = workspace !== undefined
920
922
  if (!materialized) {
921
923
  return {
922
924
  materialized: false,
@@ -930,10 +932,14 @@ export class ContextService extends Effect.Service<ContextService>()(
930
932
  return {
931
933
  materialized: true,
932
934
  registered,
933
- checkoutCommit: yield* backend.workspaceHead(checkoutPath),
934
- checkoutBranch: expectedBranch,
935
- detached: expectedBranch === null,
936
- dirty: yield* backend.workspaceDirty(checkoutPath),
935
+ checkoutCommit: workspace?.commit ?? null,
936
+ checkoutBranch:
937
+ expectedBranch && branchCommit === workspace?.commit
938
+ ? expectedBranch
939
+ : null,
940
+ detached:
941
+ !expectedBranch || branchCommit !== workspace?.commit,
942
+ dirty: workspace ? false : null,
937
943
  }
938
944
  }
939
945
  const listed = yield* runGit(fs, repositoryPath, [
@@ -1014,10 +1020,8 @@ export class ContextService extends Effect.Service<ContextService>()(
1014
1020
  repositoryPath,
1015
1021
  checkoutPath,
1016
1022
  executionData.branch,
1023
+ branchCommit,
1017
1024
  )
1018
- if (backend.kind === "jj" && branchCommit === null) {
1019
- branchCommit = checkout.checkoutCommit
1020
- }
1021
1025
  if (branchCommit === null) {
1022
1026
  inspectionWarnings.push(
1023
1027
  `Unable to resolve branch '${executionData.branch}' in ${repositoryPath}`,
@@ -68,11 +68,12 @@ describe("PullRequestService", () => {
68
68
  phaseId?: string,
69
69
  draft = false,
70
70
  force = false,
71
+ options: Record<string, unknown> = {},
71
72
  ) =>
72
73
  runTestEffect(
73
74
  PullRequestService.pipe(
74
75
  Effect.flatMap((service) =>
75
- service.create(taskId, phaseId, draft, root, { force }),
76
+ service.create(taskId, phaseId, draft, root, { force, ...options }),
76
77
  ),
77
78
  ),
78
79
  )
@@ -303,6 +304,51 @@ process.exit(${exitCode})
303
304
  ])
304
305
  })
305
306
 
307
+ test("passes task-aware GitHub title and labels with declared refs", async () => {
308
+ await createTask()
309
+ await writeFakeGh({
310
+ stdout: "https://github.com/example/agency/pull/45\n",
311
+ })
312
+
313
+ await createPullRequest("example", undefined, true, false, {
314
+ title: "Ship the workflow",
315
+ head: "task/example",
316
+ base: "main",
317
+ labels: ["ai-assisted", "platform"],
318
+ })
319
+
320
+ expect((await readGhCall()).args).toEqual([
321
+ "pr",
322
+ "create",
323
+ "--fill",
324
+ "--title",
325
+ "Ship the workflow",
326
+ "--base",
327
+ "main",
328
+ "--head",
329
+ "task/example",
330
+ "--draft",
331
+ "--label",
332
+ "ai-assisted",
333
+ "--label",
334
+ "platform",
335
+ ])
336
+ })
337
+
338
+ test("rejects task-aware head and base values that contradict declarations", async () => {
339
+ await createTask()
340
+ await expect(
341
+ createPullRequest("example", undefined, false, false, {
342
+ head: "other",
343
+ }),
344
+ ).rejects.toThrow("does not match declared branch")
345
+ await expect(
346
+ createPullRequest("example", undefined, false, false, {
347
+ base: "release",
348
+ }),
349
+ ).rejects.toThrow("does not match declared base")
350
+ })
351
+
306
352
  test("guards terminal targets before materializing unless forced", async () => {
307
353
  await createTask()
308
354
  await runTestEffect(
@@ -27,6 +27,10 @@ class PullRequestError extends Data.TaggedError("PullRequestError")<{
27
27
 
28
28
  interface PullRequestOptions extends BaseCommandOptions {
29
29
  readonly force?: boolean
30
+ readonly title?: string
31
+ readonly head?: string
32
+ readonly base?: string
33
+ readonly labels?: readonly string[]
30
34
  }
31
35
 
32
36
  export class PullRequestService extends Effect.Service<PullRequestService>()(
@@ -158,6 +162,25 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
158
162
  })
159
163
  }
160
164
  const remote = config.delivery?.remote ?? "origin"
165
+ if (options.head && options.head !== execution.branch) {
166
+ return yield* new PullRequestError({
167
+ message: `Requested head '${options.head}' does not match declared branch '${execution.branch}'`,
168
+ })
169
+ }
170
+ if (options.base && options.base !== execution.base) {
171
+ return yield* new PullRequestError({
172
+ message: `Requested base '${options.base}' does not match declared base '${execution.base}'`,
173
+ })
174
+ }
175
+ if (
176
+ config.delivery &&
177
+ (options.title || (options.labels?.length ?? 0) > 0)
178
+ ) {
179
+ return yield* new PullRequestError({
180
+ message:
181
+ "Task-aware --title and --label options require the default GitHub delivery provider",
182
+ })
183
+ }
161
184
 
162
185
  const dirty = yield* backend.workspaceDirty(workspace.writablePath)
163
186
  if (dirty === null) {
@@ -211,6 +234,9 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
211
234
  repository,
212
235
  draft,
213
236
  vcs: config.vcs ?? "git",
237
+ title: options.title,
238
+ head: options.head,
239
+ labels: options.labels,
214
240
  ...(defaults ? { defaults } : {}),
215
241
  })
216
242
  const gitEnvironment = config.delivery
@@ -376,6 +376,42 @@ describe("PushService", () => {
376
376
  )
377
377
  ).stdout,
378
378
  ).toBe("true")
379
+
380
+ await requireCommand(["jj", "new", "@"], fixture.checkout)
381
+ await Bun.write(join(fixture.checkout, "follow-up.txt"), "follow up\n")
382
+ await requireCommand(
383
+ ["jj", "describe", "-m", "Add follow-up"],
384
+ fixture.checkout,
385
+ )
386
+ const advanced = await publish(fixture.taskPath)
387
+ expect(advanced.tip).not.toBe(result.tip)
388
+ expect(await remoteBranch(fixture.remote)).toBe(advanced.tip)
389
+ }, 15_000)
390
+
391
+ test("diagnoses a jj stack whose remote base advanced", async () => {
392
+ if (!Bun.which("jj")) return
393
+ const fixture = await setup("jj")
394
+ await Bun.write(join(fixture.checkout, "feature.txt"), "local\n")
395
+ await requireCommand(
396
+ ["jj", "describe", "-m", "Local feature"],
397
+ fixture.checkout,
398
+ )
399
+
400
+ const other = join(fixture.root, "other-main")
401
+ await requireCommand(["git", "clone", fixture.remote, other])
402
+ await requireCommand(["git", "config", "user.name", "Other"], other)
403
+ await requireCommand(
404
+ ["git", "config", "user.email", "other@example.com"],
405
+ other,
406
+ )
407
+ await Bun.write(join(other, "remote.txt"), "remote\n")
408
+ await requireCommand(["git", "add", "remote.txt"], other)
409
+ await requireCommand(["git", "commit", "-m", "Advance main"], other)
410
+ await requireCommand(["git", "push", "origin", "main"], other)
411
+
412
+ await expect(publish(fixture.taskPath)).rejects.toThrow(
413
+ "jj rebase -s 'roots(main@origin..@)' -d main@origin",
414
+ )
379
415
  })
380
416
 
381
417
  test("selects the described parent of a canonical jj post-commit working copy", async () => {
@@ -424,7 +460,7 @@ describe("PushService", () => {
424
460
  await expect(publish(undescribed.taskPath)).rejects.toThrow(
425
461
  `Change ${changeId} has no description. Run: jj describe -r ${changeId}`,
426
462
  )
427
- })
463
+ }, 15_000)
428
464
 
429
465
  test("rejects an empty jj task and remote bookmark divergence", async () => {
430
466
  if (!Bun.which("jj")) return
@@ -463,7 +499,7 @@ describe("PushService", () => {
463
499
  await expect(publish(fixture.taskPath)).rejects.toThrow(
464
500
  "refusing to move it",
465
501
  )
466
- })
502
+ }, 15_000)
467
503
 
468
504
  test("rejects conflicted jj changes with an actionable change ID", async () => {
469
505
  if (!Bun.which("jj")) return
@@ -37,6 +37,12 @@ interface PushResult {
37
37
  readonly changeId?: string
38
38
  }
39
39
 
40
+ type PushStage = "context" | "fetch" | "inspect" | "validate" | "publish"
41
+
42
+ interface PushOptions {
43
+ readonly onProgress?: (stage: PushStage) => void
44
+ }
45
+
40
46
  const validEmail = (email: string) => /^[^@\s]+@[^@\s]+$/.test(email)
41
47
 
42
48
  const requireCommand = (
@@ -153,8 +159,10 @@ const publishGit = (
153
159
  remote: string,
154
160
  branch: string,
155
161
  base: string,
162
+ onProgress?: (stage: PushStage) => void,
156
163
  ) =>
157
164
  Effect.gen(function* () {
165
+ onProgress?.("inspect")
158
166
  const currentBranch = yield* git(
159
167
  fs,
160
168
  checkout,
@@ -179,6 +187,7 @@ const publishGit = (
179
187
  })
180
188
  }
181
189
 
190
+ onProgress?.("fetch")
182
191
  yield* git(
183
192
  fs,
184
193
  checkout,
@@ -202,6 +211,7 @@ const publishGit = (
202
211
  })
203
212
  }
204
213
 
214
+ onProgress?.("validate")
205
215
  const log = yield* git(
206
216
  fs,
207
217
  checkout,
@@ -228,6 +238,7 @@ const publishGit = (
228
238
  })
229
239
  }
230
240
 
241
+ onProgress?.("publish")
231
242
  yield* git(
232
243
  fs,
233
244
  checkout,
@@ -361,14 +372,17 @@ const publishJj = (
361
372
  remote: string,
362
373
  branch: string,
363
374
  base: string,
375
+ onProgress?: (stage: PushStage) => void,
364
376
  ) =>
365
377
  Effect.gen(function* () {
378
+ onProgress?.("fetch")
366
379
  yield* jj(
367
380
  fs,
368
381
  checkout,
369
382
  ["git", "fetch", "--remote", remote],
370
383
  `Failed to fetch remote '${remote}'`,
371
384
  )
385
+ onProgress?.("inspect")
372
386
  const workingCopy = yield* jjRevision(fs, checkout, "@")
373
387
  if (!workingCopy) {
374
388
  return yield* new PushError({
@@ -403,10 +417,11 @@ const publishJj = (
403
417
  !(yield* jjAncestor(fs, checkout, baseRevision.commitId, tip.commitId))
404
418
  ) {
405
419
  return yield* new PushError({
406
- message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId})`,
420
+ message: `Declared base '${base}' (${baseRevision.commitId}) is not an ancestor of jj tip ${tip.changeId} (${tip.commitId}). Rebase the stack with: jj rebase -s 'roots(${baseBookmark}..@)' -d ${baseBookmark}`,
407
421
  })
408
422
  }
409
423
 
424
+ onProgress?.("validate")
410
425
  const outgoing = yield* jjCommits(
411
426
  fs,
412
427
  checkout,
@@ -444,6 +459,7 @@ const publishJj = (
444
459
  })
445
460
  }
446
461
 
462
+ onProgress?.("publish")
447
463
  yield* jj(
448
464
  fs,
449
465
  checkout,
@@ -467,8 +483,9 @@ const publishJj = (
467
483
 
468
484
  export class PushService extends Effect.Service<PushService>()("PushService", {
469
485
  sync: () => ({
470
- publish: (startPath: string = process.cwd()) =>
486
+ publish: (startPath: string = process.cwd(), options: PushOptions = {}) =>
471
487
  Effect.gen(function* () {
488
+ options.onProgress?.("context")
472
489
  const contexts = yield* ContextService
473
490
  const fs = yield* FileSystemService
474
491
  const tasks = yield* TaskService
@@ -554,6 +571,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
554
571
  remote,
555
572
  execution.branch,
556
573
  execution.base,
574
+ options.onProgress,
557
575
  )
558
576
  : yield* publishGit(
559
577
  fs,
@@ -561,6 +579,7 @@ export class PushService extends Effect.Service<PushService>()("PushService", {
561
579
  remote,
562
580
  execution.branch,
563
581
  execution.base,
582
+ options.onProgress,
564
583
  )
565
584
  return {
566
585
  vcs: context.workbase.vcs,
@@ -811,7 +811,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
811
811
  }),
812
812
 
813
813
  setup: (
814
- options: { readonly cwd?: string; readonly apply?: boolean } = {},
814
+ options: {
815
+ readonly cwd?: string
816
+ readonly apply?: boolean
817
+ readonly aliases?: readonly string[]
818
+ } = {},
815
819
  ) =>
816
820
  Effect.gen(function* () {
817
821
  const service = yield* RepositoryService
@@ -819,7 +823,13 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
819
823
  const versionControl = yield* VersionControlService
820
824
  const state = yield* configState(options.cwd ?? process.cwd())
821
825
  const backend = yield* versionControl.forWorkbase(state.root)
822
- const repositories = yield* service.list(state.root)
826
+ const requestedAliases = options.aliases
827
+ ? new Set(options.aliases)
828
+ : null
829
+ const repositories = (yield* service.list(state.root)).filter(
830
+ (repository) =>
831
+ !requestedAliases || requestedAliases.has(repository.alias),
832
+ )
823
833
  const planned: Omit<RepositorySetupAction, "status">[] = []
824
834
  const unresolved: RepositorySetupIssue[] = []
825
835
 
@@ -933,7 +943,11 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
933
943
  unresolved,
934
944
  repositories:
935
945
  options.apply === true
936
- ? yield* service.list(state.root)
946
+ ? (yield* service.list(state.root)).filter(
947
+ (repository) =>
948
+ !requestedAliases ||
949
+ requestedAliases.has(repository.alias),
950
+ )
937
951
  : repositories,
938
952
  } satisfies RepositorySetupResult
939
953
  }),
@@ -170,6 +170,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
170
170
  readonly apply?: boolean
171
171
  readonly now?: Date
172
172
  readonly onProgress?: (progress: SyncProgress) => void
173
+ readonly taskId?: string
174
+ readonly phaseId?: string
173
175
  } = {},
174
176
  ) =>
175
177
  Effect.gen(function* () {
@@ -191,9 +193,66 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
191
193
  .join("\n"),
192
194
  })
193
195
  }
196
+ if (options.phaseId && !options.taskId) {
197
+ return yield* new SyncError({
198
+ message: "A phase sync scope requires a task ID",
199
+ })
200
+ }
201
+ const allTaskRecords = yield* tasks.list(root)
202
+ const taskRecords = options.taskId
203
+ ? allTaskRecords.filter((task) => task.id === options.taskId)
204
+ : allTaskRecords
205
+ if (options.taskId && taskRecords.length === 0) {
206
+ return yield* new SyncError({
207
+ message: `Task '${options.taskId}' does not exist`,
208
+ })
209
+ }
210
+ const records: ExecutionRecord[] = []
211
+ for (const task of taskRecords) {
212
+ if ("phases" in task.data) {
213
+ for (const phase of yield* phases.list(task.id, root)) {
214
+ if (options.phaseId && phase.id !== options.phaseId) continue
215
+ records.push({
216
+ key: `phase:${task.id}/${phase.id}`,
217
+ taskId: task.id,
218
+ phaseId: phase.id,
219
+ path: phase.path,
220
+ revision: documentRevision(phase.content),
221
+ data: phase.data,
222
+ })
223
+ }
224
+ } else if (!options.phaseId && !("review" in task.data)) {
225
+ records.push({
226
+ key: `task:${task.id}`,
227
+ taskId: task.id,
228
+ path: task.path,
229
+ revision: documentRevision(task.content),
230
+ data: task.data,
231
+ })
232
+ }
233
+ }
234
+ const reviewRecords = options.phaseId
235
+ ? []
236
+ : taskRecords.filter((task) => "review" in task.data)
237
+ if (options.phaseId && records.length === 0) {
238
+ return yield* new SyncError({
239
+ message: `Phase '${options.taskId}/${options.phaseId}' does not exist`,
240
+ })
241
+ }
242
+ const repositoryAliases = new Set<string>()
243
+ for (const record of records) {
244
+ repositoryAliases.add(record.data.repo)
245
+ for (const reference of record.data.repos ?? [])
246
+ repositoryAliases.add(reference.repo)
247
+ }
248
+ for (const task of reviewRecords) {
249
+ if ("review" in task.data)
250
+ repositoryAliases.add(task.data.review.repo)
251
+ }
194
252
  const repositorySetup = yield* repositories.setup({
195
253
  cwd: root,
196
254
  apply: options.apply === true,
255
+ ...(options.taskId ? { aliases: [...repositoryAliases] } : {}),
197
256
  })
198
257
  options.onProgress?.({
199
258
  stage: "repositories",
@@ -241,30 +300,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
241
300
  }),
242
301
  ),
243
302
  )
244
- const taskRecords = yield* tasks.list(root)
245
- const records: ExecutionRecord[] = []
246
- for (const task of taskRecords) {
247
- if ("phases" in task.data) {
248
- for (const phase of yield* phases.list(task.id, root)) {
249
- records.push({
250
- key: `phase:${task.id}/${phase.id}`,
251
- taskId: task.id,
252
- phaseId: phase.id,
253
- path: phase.path,
254
- revision: documentRevision(phase.content),
255
- data: phase.data,
256
- })
257
- }
258
- } else if (!("review" in task.data)) {
259
- records.push({
260
- key: `task:${task.id}`,
261
- taskId: task.id,
262
- path: task.path,
263
- revision: documentRevision(task.content),
264
- data: task.data,
265
- })
266
- }
267
- }
268
303
  const listRegistered = (repositoryPath: string) =>
269
304
  Effect.gen(function* () {
270
305
  if (registeredByRepository.has(repositoryPath))
@@ -409,9 +444,6 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
409
444
  { concurrency: 8 },
410
445
  ),
411
446
  )
412
- const reviewRecords = taskRecords.filter(
413
- (task) => "review" in task.data,
414
- )
415
447
  const executionTotal = records.length + reviewRecords.length
416
448
  let reconciledExecutions = 0
417
449
  const reportExecution = (target: string) => {
@@ -474,7 +474,9 @@ const inspectExecution = (
474
474
  : yield* backend.workspaceHead(checkoutPath)
475
475
  : null
476
476
  const dirty =
477
- exists && atPath ? yield* backend.workspaceDirty(checkoutPath) : null
477
+ exists && atPath
478
+ ? yield* backend.workspaceDirty(checkoutPath)
479
+ : (atPath?.dirty ?? null)
478
480
  const expectedCommit =
479
481
  "branch" in checkout && context?.skipWritableRevisionResolution
480
482
  ? actualCommit
@@ -1659,10 +1661,26 @@ const removeJj = (
1659
1661
  bookmark: string
1660
1662
  }[] = []
1661
1663
  for (const checkout of inspection.checkouts) {
1664
+ const repositoryPath = join(root, "repos", checkout.repo)
1665
+ const registered = checkout.registeredPath
1666
+ ? (yield* backend.listWorkspaces(repositoryPath)).find(
1667
+ (workspace) => workspace.path === checkout.registeredPath,
1668
+ )
1669
+ : undefined
1662
1670
  if (checkout.dirty === true && options.persistResume === false) {
1663
- return yield* new WorktreeError({
1664
- message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1665
- })
1671
+ const preservedByBookmark =
1672
+ checkout.kind === "writable" &&
1673
+ registered?.commit !== null &&
1674
+ registered?.commit !== undefined &&
1675
+ (yield* backend.resolveRevision(
1676
+ repositoryPath,
1677
+ checkout.requestedRef,
1678
+ )) === registered.commit
1679
+ if (!preservedByBookmark) {
1680
+ return yield* new WorktreeError({
1681
+ message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1682
+ })
1683
+ }
1666
1684
  }
1667
1685
  if (
1668
1686
  checkout.exists &&
@@ -1673,7 +1691,6 @@ const removeJj = (
1673
1691
  message: `Failed to remove workspace for '${checkout.repo}': checkout cleanliness could not be verified`,
1674
1692
  })
1675
1693
  }
1676
- const repositoryPath = join(root, "repos", checkout.repo)
1677
1694
  if (!checkout.registeredPath) {
1678
1695
  const recoveryRevision = recoverable.get(checkout.path)
1679
1696
  if (!recoveryRevision) continue
@@ -1696,9 +1713,6 @@ const removeJj = (
1696
1713
  })
1697
1714
  continue
1698
1715
  }
1699
- const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1700
- (workspace) => workspace.path === checkout.registeredPath,
1701
- )
1702
1716
  if (!registered?.name || !checkout.actualCommit || !registered.commit) {
1703
1717
  return yield* new WorktreeError({
1704
1718
  message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
@@ -67,6 +67,55 @@ describe("delivery commands", () => {
67
67
  argv: ["gh", "pr", "create", "--fill", "--base", "main"],
68
68
  environment: {},
69
69
  })
70
+ expect(
71
+ resolveGitHubCreateCommand({
72
+ ...input,
73
+ vcs: "git",
74
+ head: "feat/example",
75
+ }),
76
+ ).toEqual({
77
+ argv: [
78
+ "gh",
79
+ "pr",
80
+ "create",
81
+ "--fill",
82
+ "--base",
83
+ "main",
84
+ "--head",
85
+ "feat/example",
86
+ ],
87
+ environment: {},
88
+ })
89
+ expect(
90
+ resolveGitHubCreateCommand({
91
+ ...input,
92
+ vcs: "jj",
93
+ defaults: { title: "Generated", body: "Details" },
94
+ title: "Requested",
95
+ labels: ["ai-assisted", "platform"],
96
+ }),
97
+ ).toEqual({
98
+ argv: [
99
+ "gh",
100
+ "pr",
101
+ "create",
102
+ "--title",
103
+ "Requested",
104
+ "--body",
105
+ "Details",
106
+ "--base",
107
+ "main",
108
+ "--head",
109
+ "feat/example",
110
+ "--repo",
111
+ "example/agency",
112
+ "--label",
113
+ "ai-assisted",
114
+ "--label",
115
+ "platform",
116
+ ],
117
+ environment: {},
118
+ })
70
119
  })
71
120
 
72
121
  test("rejects unknown placeholders", () => {
@@ -25,6 +25,9 @@ export const resolveGitHubCreateCommand = ({
25
25
  draft,
26
26
  vcs,
27
27
  defaults,
28
+ title,
29
+ head,
30
+ labels = [],
28
31
  }: {
29
32
  readonly base: string
30
33
  readonly branch: string
@@ -32,18 +35,25 @@ export const resolveGitHubCreateCommand = ({
32
35
  readonly draft: boolean
33
36
  readonly vcs: "git" | "jj"
34
37
  readonly defaults?: { readonly title: string; readonly body: string }
38
+ readonly title?: string
39
+ readonly head?: string
40
+ readonly labels?: readonly string[]
35
41
  }) => ({
36
42
  argv: [
37
43
  "gh",
38
44
  "pr",
39
45
  "create",
40
46
  ...(defaults
41
- ? ["--title", defaults.title, "--body", defaults.body]
42
- : ["--fill"]),
47
+ ? ["--title", title ?? defaults.title, "--body", defaults.body]
48
+ : title
49
+ ? ["--fill", "--title", title]
50
+ : ["--fill"]),
43
51
  "--base",
44
52
  base,
45
- ...(vcs === "jj" ? ["--head", branch, "--repo", repository] : []),
53
+ ...(vcs === "jj" || head ? ["--head", head ?? branch] : []),
54
+ ...(vcs === "jj" ? ["--repo", repository] : []),
46
55
  ...(draft ? ["--draft"] : []),
56
+ ...labels.flatMap((label) => ["--label", label]),
47
57
  ],
48
58
  environment: {},
49
59
  })