@defrex/autobuild 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +138 -16
  3. package/bin/ab-dispatch-kernel.ts +4 -6
  4. package/docs/architecture.md +155 -73
  5. package/docs/configuration.md +41 -9
  6. package/docs/hosted-service.md +31 -5
  7. package/docs/operator-api.md +8 -1
  8. package/docs/remote-store-protocol.md +94 -2
  9. package/docs/remote-ticket-protocol.md +151 -9
  10. package/docs/setup.md +33 -7
  11. package/docs/ticket-migration.md +64 -0
  12. package/package.json +4 -3
  13. package/packages/core/src/cli/attention.ts +18 -164
  14. package/packages/core/src/cli/build-control.ts +23 -16
  15. package/packages/core/src/cli/bulk-control.ts +3 -2
  16. package/packages/core/src/cli/connect.ts +141 -38
  17. package/packages/core/src/cli/context.ts +23 -79
  18. package/packages/core/src/cli/dashboard/model.ts +36 -22
  19. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  20. package/packages/core/src/cli/dispatch-frontend.ts +24 -67
  21. package/packages/core/src/cli/dispatch-process.ts +14 -7
  22. package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
  23. package/packages/core/src/cli/dispatch.ts +304 -82
  24. package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
  25. package/packages/core/src/cli/harvest.ts +84 -42
  26. package/packages/core/src/cli/help.ts +14 -8
  27. package/packages/core/src/cli/historical-build-state.ts +27 -0
  28. package/packages/core/src/cli/installation.ts +67 -13
  29. package/packages/core/src/cli/main.ts +23 -5
  30. package/packages/core/src/cli/mcp.ts +2 -1
  31. package/packages/core/src/cli/pr-attachments.ts +10 -13
  32. package/packages/core/src/cli/pr-summary-publication.ts +27 -60
  33. package/packages/core/src/cli/pr-summary.ts +43 -13
  34. package/packages/core/src/cli/publication-settlement.ts +10 -20
  35. package/packages/core/src/cli/repository-status.ts +38 -2
  36. package/packages/core/src/cli/self-update.ts +5 -1
  37. package/packages/core/src/cli/status.ts +62 -32
  38. package/packages/core/src/cli/terminals.ts +25 -142
  39. package/packages/core/src/cli/ticket-assets.ts +7 -2
  40. package/packages/core/src/cli/ticket-editor.ts +82 -0
  41. package/packages/core/src/cli/ticket.ts +166 -36
  42. package/packages/core/src/cli/wait.ts +91 -84
  43. package/packages/core/src/cli/watch.ts +60 -97
  44. package/packages/core/src/config/pipeline-source.ts +4 -3
  45. package/packages/core/src/config/schema.ts +35 -3
  46. package/packages/core/src/connection.ts +3 -3
  47. package/packages/core/src/events/catalog.ts +3 -2
  48. package/packages/core/src/events/payloads.ts +6 -0
  49. package/packages/core/src/events/repository.ts +7 -3
  50. package/packages/core/src/integration/harness.ts +38 -4
  51. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
  52. package/packages/core/src/kernel/auto-merge.ts +19 -3
  53. package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
  54. package/packages/core/src/kernel/generators/build-log.ts +1 -1
  55. package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
  56. package/packages/core/src/kernel/harvest.ts +25 -23
  57. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  58. package/packages/core/src/kernel/log-index-value.ts +38 -0
  59. package/packages/core/src/kernel/read-facts.ts +637 -0
  60. package/packages/core/src/kernel/reducer-registry.ts +139 -3
  61. package/packages/core/src/kernel/repository-attention.ts +179 -0
  62. package/packages/core/src/kernel/service-facts.ts +135 -0
  63. package/packages/core/src/kernel/terminal-guard.ts +133 -0
  64. package/packages/core/src/ontology.ts +1 -0
  65. package/packages/core/src/operator/annotations.ts +12 -0
  66. package/packages/core/src/operator/control.ts +5 -6
  67. package/packages/core/src/operator/index.ts +7 -0
  68. package/packages/core/src/operator/protocol.ts +19 -2
  69. package/packages/core/src/operator/query.ts +37 -20
  70. package/packages/core/src/operator/read-view.ts +4 -13
  71. package/packages/core/src/operator/registry.ts +61 -8
  72. package/packages/core/src/operator/requests.ts +2 -14
  73. package/packages/core/src/operator/sandbox.ts +10 -9
  74. package/packages/core/src/operator/ticket-source-backend.ts +17 -21
  75. package/packages/core/src/operator/tickets.ts +132 -22
  76. package/packages/core/src/plugin-sdk/index.ts +19 -0
  77. package/packages/core/src/plugins/runtimes.ts +5 -1
  78. package/packages/core/src/ports/runner/session-env.ts +2 -0
  79. package/packages/core/src/ports/tickets/contract.ts +66 -1
  80. package/packages/core/src/ports/tickets/create.ts +48 -12
  81. package/packages/core/src/ports/tickets/fake.ts +30 -1
  82. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  83. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  84. package/packages/core/src/ports/tickets/file.ts +114 -120
  85. package/packages/core/src/ports/tickets/grants.ts +47 -0
  86. package/packages/core/src/ports/tickets/hosted-index.ts +17 -0
  87. package/packages/core/src/ports/tickets/identity.ts +20 -0
  88. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  89. package/packages/core/src/ports/tickets/linear.ts +74 -9
  90. package/packages/core/src/ports/tickets/migration.ts +336 -0
  91. package/packages/core/src/ports/tickets/operations.ts +38 -7
  92. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  93. package/packages/core/src/ports/tickets/remote-protocol.ts +95 -0
  94. package/packages/core/src/ports/tickets/remote.ts +60 -3
  95. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  96. package/packages/core/src/ports/tickets/revision.ts +62 -0
  97. package/packages/core/src/ports/tickets/states.ts +61 -0
  98. package/packages/core/src/ports/types.ts +40 -3
  99. package/packages/core/src/ports/workspace/build-execution.ts +1 -0
  100. package/packages/core/src/ports/workspace/create.ts +5 -1
  101. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  102. package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
  103. package/packages/core/src/processes/build-child.ts +6 -6
  104. package/packages/core/src/processes/build-runner.ts +62 -70
  105. package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
  106. package/packages/core/src/processes/dispatcher.ts +223 -205
  107. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  108. package/packages/core/src/processes/harvest-child.ts +30 -4
  109. package/packages/core/src/processes/harvest-execution-state.ts +15 -9
  110. package/packages/core/src/processes/harvest-runner.ts +58 -74
  111. package/packages/core/src/processes/harvest.ts +72 -29
  112. package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
  113. package/packages/core/src/processes/orchestrator-tick.ts +2 -2
  114. package/packages/core/src/processes/phase-failure-budget.ts +1 -0
  115. package/packages/core/src/processes/publication-loss.ts +35 -21
  116. package/packages/core/src/processes/repo-view.ts +229 -1099
  117. package/packages/core/src/processes/tick-publication.ts +110 -0
  118. package/packages/core/src/store/adapter.ts +23 -0
  119. package/packages/core/src/store/build-scope.ts +17 -0
  120. package/packages/core/src/store/contract.ts +283 -1
  121. package/packages/core/src/store/local/schema.ts +1 -0
  122. package/packages/core/src/store/local/store.ts +180 -55
  123. package/packages/core/src/store/memory.ts +119 -29
  124. package/packages/core/src/store/phase-session.ts +17 -0
  125. package/packages/core/src/store/reduced-reads.ts +239 -0
  126. package/packages/core/src/store/reducer-value-shapes.json +14056 -0
  127. package/packages/core/src/store/reducer-value-validation.ts +96 -0
  128. package/packages/core/src/store/reducer-values.ts +145 -0
  129. package/packages/core/src/store/remote/client.ts +41 -0
  130. package/packages/core/src/store/remote/compatibility.ts +2 -0
  131. package/packages/core/src/store/remote/index.ts +19 -0
  132. package/packages/core/src/store/remote/protocol.ts +16 -0
  133. package/packages/core/src/store/remote/version.ts +3 -1
  134. package/packages/core/src/store/repo-state-events.ts +1 -1
  135. package/packages/core/src/store/repo-tick.ts +72 -0
  136. package/packages/core/src/store/session-handle.ts +19 -0
  137. package/packages/core/src/store/snapshots.ts +10 -0
  138. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  139. package/packages/core/src/store/ticket-assets.ts +86 -8
  140. package/packages/core/src/store/types.ts +40 -3
  141. package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
  142. package/packages/core/src/testing/index.ts +8 -0
  143. package/packages/core/src/testing/reduced-read-budget.ts +462 -0
  144. package/skills/guide/SKILL.md +97 -12
  145. package/skills/guide/references/operator-api.md +8 -1
  146. package/skills/guide/references/remote-store-protocol.md +94 -2
  147. package/skills/guide/references/remote-ticket-protocol.md +151 -9
  148. package/skills/guide/references/setup.md +33 -7
  149. package/skills/guide/references/ticket-migration.md +64 -0
  150. package/skills/tickets/SKILL.md +71 -0
  151. package/templates/hosted-service/package.json +2 -2
@@ -1,3 +1,7 @@
1
+ import { readBuildFacts } from '../store/reduced-reads'
2
+ import { exportTickets, importTickets } from '../ports/tickets/migration'
3
+ import { composeTicketAssets } from '../store/ticket-assets'
4
+ import { editTicket, type TicketEditorRunner } from './ticket-editor'
1
5
  /**
2
6
  * Source-agnostic pre-build ticket operations (SPEC §8.8). These commands run
3
7
  * outside build sessions and resolve the repository's configured TicketSource,
@@ -19,8 +23,13 @@ import {
19
23
  ticketListCriteria,
20
24
  updateTicket,
21
25
  } from '../ports/tickets/operations'
22
- import type { Ticket, TicketSource, TicketStateInfo, TicketUpdate } from '../ports/types'
23
- import { reduceBuild } from '../kernel/reducer'
26
+ import type {
27
+ TicketAssetStore,
28
+ Ticket,
29
+ TicketSource,
30
+ TicketStateInfo,
31
+ TicketUpdate,
32
+ } from '../ports/types'
24
33
  import type { BuildStore } from '../store/types'
25
34
  import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
26
35
  import type { TicketAssetSummary } from '../store/ticket-assets'
@@ -45,6 +54,8 @@ export type TicketSourceFactory = (
45
54
 
46
55
  export interface TicketCommandOpts {
47
56
  targetRepo: string
57
+ expectedRevision?: string
58
+ editorRunner?: TicketEditorRunner
48
59
  /** Process environment — store selection and adapter secrets. */
49
60
  env: Record<string, string | undefined>
50
61
  /** Git seam supplied by the CLI; omitted direct callers use targetRepo. */
@@ -152,10 +163,13 @@ export interface TicketStateCreateOpts extends TicketCommandOpts {
152
163
  }
153
164
 
154
165
  type TicketCommandName =
166
+ | 'export'
167
+ | 'import'
155
168
  | 'states'
156
169
  | 'state create'
157
170
  | 'create'
158
171
  | 'update'
172
+ | 'edit'
159
173
  | 'block'
160
174
  | 'unblock'
161
175
  | 'list'
@@ -170,12 +184,11 @@ export interface ResolvedTicketCommand {
170
184
  source: TicketSource
171
185
  }
172
186
 
173
- /** Resolve linked-worktree identity, config, selected local state, and adapter
174
- * exactly once for one ticket command or another sessionless caller. */
175
- export async function openTicketSource(
176
- opts: TicketCommandOpts & { storeRef?: string },
187
+ /** Resolve the main checkout and config without requiring provider credentials. */
188
+ async function resolveTicketConfig(
189
+ opts: TicketCommandOpts,
177
190
  label: string,
178
- ): Promise<ResolvedTicketCommand> {
191
+ ): Promise<{ targetRepo: string; config: Config }> {
179
192
  const targetRepo =
180
193
  opts.exec === undefined
181
194
  ? resolve(opts.targetRepo)
@@ -194,6 +207,21 @@ export async function openTicketSource(
194
207
  throw error
195
208
  }
196
209
 
210
+ return { targetRepo, config }
211
+ }
212
+
213
+ /** Resolve configuration and construct the configured ticket adapter. */
214
+ export async function openTicketSource(
215
+ opts: TicketCommandOpts & { storeRef?: string },
216
+ label: string,
217
+ ): Promise<ResolvedTicketCommand> {
218
+ return createTicketCommandSource(opts, await resolveTicketConfig(opts, label))
219
+ }
220
+
221
+ async function createTicketCommandSource(
222
+ opts: TicketCommandOpts,
223
+ { targetRepo, config }: { targetRepo: string; config: Config },
224
+ ): Promise<ResolvedTicketCommand> {
197
225
  // Ticket commands use the same trusted plugin catalog as dispatch. Loading
198
226
  // and registration finish before any adapter is constructed or called.
199
227
  const plugins = await loadPlugins(config.plugins, targetRepo)
@@ -313,18 +341,18 @@ export async function abTicketUpdate(opts: TicketUpdateOpts): Promise<void> {
313
341
  ...(opts.labels !== undefined ? { labels: [...opts.labels] } : {}),
314
342
  }
315
343
  if (opts.json === true) {
316
- const updated = await updateTicket(source, opts.id, patch)
344
+ const updated = await updateTicket(source, opts.id, patch, true, opts.expectedRevision)
317
345
  if (updated !== null) emitTicketJson(opts, updated)
318
346
  return
319
347
  }
320
- await updateTicket(source, opts.id, patch, false)
348
+ await updateTicket(source, opts.id, patch, false, opts.expectedRevision)
321
349
  opts.stdout(`ticket updated: ${source.name}:${opts.id}`)
322
350
  }
323
351
 
324
352
  export async function abTicketBlock(opts: TicketBlockerOpts): Promise<void> {
325
353
  const { source } = await resolveTicketCommand(opts, 'block')
326
354
  const blockerIds = [...new Set(opts.blockerIds)]
327
- const updated = await changeBlockers(source, opts.id, blockerIds, 'block')
355
+ const updated = await changeBlockers(source, opts.id, blockerIds, 'block', opts.expectedRevision)
328
356
  if (opts.json === true) {
329
357
  emitTicketJson(opts, updated)
330
358
  return
@@ -337,7 +365,13 @@ export async function abTicketBlock(opts: TicketBlockerOpts): Promise<void> {
337
365
  export async function abTicketUnblock(opts: TicketBlockerOpts): Promise<void> {
338
366
  const { source } = await resolveTicketCommand(opts, 'unblock')
339
367
  const blockerIds = [...new Set(opts.blockerIds)]
340
- const updated = await changeBlockers(source, opts.id, blockerIds, 'unblock')
368
+ const updated = await changeBlockers(
369
+ source,
370
+ opts.id,
371
+ blockerIds,
372
+ 'unblock',
373
+ opts.expectedRevision,
374
+ )
341
375
  if (opts.json === true) {
342
376
  emitTicketJson(opts, updated)
343
377
  return
@@ -372,7 +406,7 @@ export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
372
406
  const { source } = await resolveTicketCommand(opts, 'show')
373
407
  const ticket = await requireTicket(source, opts.id)
374
408
  // An asset-listing failure fails the command rather than hiding the assets.
375
- const assets = await withTicketStore(opts, ({ store, repo }) =>
409
+ const assets = await withTicketAssets(opts, source, ({ store, repo }) =>
376
410
  store.listTicketAssets(repo, opts.id),
377
411
  )
378
412
  if (opts.json === true) {
@@ -399,6 +433,12 @@ export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
399
433
  // Keep the adapter-provided body untouched. It may itself be multiline and
400
434
  // may intentionally end (or not end) with a newline.
401
435
  opts.stdout(ticket.body)
436
+ for (const comment of ticket.comments ?? []) {
437
+ opts.stdout(
438
+ `Comment ${comment.createdAt}${comment.author !== undefined ? ` — ${comment.author}` : ''}`,
439
+ )
440
+ opts.stdout(comment.body)
441
+ }
402
442
  }
403
443
 
404
444
  function withTicketStore<T>(
@@ -417,6 +457,19 @@ function withTicketStore<T>(
417
457
  )
418
458
  }
419
459
 
460
+ function withTicketAssets<T>(
461
+ opts: TicketCommandOpts,
462
+ source: TicketSource,
463
+ use: (context: { store: TicketAssetStore; buildStore: BuildStore; repo: string }) => Promise<T>,
464
+ ): Promise<T> {
465
+ if (source.name === 'hosted' && !source.assets) {
466
+ throw new Error('hosted ticket source has no asset capability')
467
+ }
468
+ return withTicketStore(opts, ({ store, repo }) =>
469
+ use({ store: composeTicketAssets(source, store), buildStore: store, repo }),
470
+ )
471
+ }
472
+
420
473
  /** Slugs of this repo's non-terminal builds for the ticket. A build freezes its
421
474
  * ticket assets at the claim (SPEC §6.3), so a change to them does not reach
422
475
  * it. The notice is advisory: a failed lookup yields none and never blocks. */
@@ -425,7 +478,7 @@ async function activeBuildsFor(store: BuildStore, repo: string, id: string): Pro
425
478
  const active: string[] = []
426
479
  for (const record of await store.listBuilds()) {
427
480
  if (record.repo !== repo || record.ticket?.id !== id) continue
428
- const { status } = reduceBuild(await store.getEvents(record.slug))
481
+ const { status } = (await readBuildFacts(store, record.slug)).value.state
429
482
  if (status !== 'done' && status !== 'aborted') active.push(record.slug)
430
483
  }
431
484
  return active
@@ -455,18 +508,26 @@ export async function abTicketAttach(opts: TicketAttachOpts): Promise<void> {
455
508
  await requireTicket(source, opts.id)
456
509
  const walk = await walkAssetPath(opts.path)
457
510
  const name = opts.name ?? walk.basename
458
- const stored = await withTicketStore(opts, async ({ store, repo }) => {
511
+ const stored = await withTicketAssets(opts, source, async ({ store, buildStore, repo }) => {
459
512
  const limits = await store.ticketAssetLimits(repo)
460
513
  const input = await buildAssetInput(walk, { kind: opts.kind, name }, limits)
461
514
  const live = await store.listTicketAssets(repo, opts.id)
462
515
  const replacing = live.some((asset) => asset.kind === opts.kind && asset.name === name)
463
516
  const meta = await store.putTicketAsset(repo, opts.id, input)
464
- return { meta, replacing, active: await activeBuildsFor(store, repo, opts.id) }
517
+ return { meta, replacing, active: await activeBuildsFor(buildStore, repo, opts.id) }
465
518
  })
466
519
  const { meta } = stored
467
520
  warnActiveBuilds(opts, stored.active)
468
521
  try {
469
- await source.comment(opts.id, ticketAssetNote(stored.replacing ? 'replaced' : 'attached', meta))
522
+ await source.comment(
523
+ opts.id,
524
+ ticketAssetNote(
525
+ stored.replacing ? 'replaced' : 'attached',
526
+ meta,
527
+ 'ab',
528
+ source.name === 'hosted',
529
+ ),
530
+ )
470
531
  } catch (error) {
471
532
  throw new Error(
472
533
  `ticket asset ${meta.kind}/${meta.name} was stored as revision ${meta.revision}, but the note on ` +
@@ -487,9 +548,17 @@ export async function abTicketAttach(opts: TicketAttachOpts): Promise<void> {
487
548
  /** `ab ticket asset get <id> <kind> <name> <dest>` — exact bytes at the
488
549
  * latest or a named revision. */
489
550
  export async function abTicketAssetGet(opts: TicketAssetGetOpts): Promise<void> {
490
- const asset = await withTicketStore(opts, ({ store, repo }) =>
491
- store.getTicketAsset(repo, opts.id, opts.kind, opts.name, opts.rev),
492
- )
551
+ const resolved = await resolveTicketConfig(opts, 'ab ticket asset get')
552
+ const readAsset = ({ store, repo }: { store: TicketAssetStore; repo: string }) =>
553
+ store.getTicketAsset(repo, opts.id, opts.kind, opts.name, opts.rev)
554
+ const asset =
555
+ resolved.config.tickets.source === 'linear'
556
+ ? await withTicketStore(opts, readAsset)
557
+ : await withTicketAssets(
558
+ opts,
559
+ (await createTicketCommandSource(opts, resolved)).source,
560
+ readAsset,
561
+ )
493
562
  if (asset === null) {
494
563
  const revision = opts.rev === undefined ? '' : ` revision ${opts.rev}`
495
564
  throw new Error(
@@ -511,16 +580,23 @@ export async function abTicketAssetGet(opts: TicketAssetGetOpts): Promise<void>
511
580
  * (earlier revisions stay retrievable) and note the removal on the ticket. */
512
581
  export async function abTicketAssetRm(opts: TicketAssetRmOpts): Promise<void> {
513
582
  const { source } = await resolveTicketCommand(opts, 'asset rm')
514
- const { removed, active } = await withTicketStore(opts, async ({ store, repo }) => ({
515
- removed: await store.removeTicketAsset(repo, opts.id, opts.kind, opts.name),
516
- active: await activeBuildsFor(store, repo, opts.id),
517
- }))
583
+ const { removed, active } = await withTicketAssets(
584
+ opts,
585
+ source,
586
+ async ({ store, buildStore, repo }) => ({
587
+ removed: await store.removeTicketAsset(repo, opts.id, opts.kind, opts.name),
588
+ active: await activeBuildsFor(buildStore, repo, opts.id),
589
+ }),
590
+ )
518
591
  if (removed === null) {
519
592
  throw new Error(`ticket ${opts.id} has no current asset ${opts.kind}/${opts.name} to remove`)
520
593
  }
521
594
  warnActiveBuilds(opts, active)
522
595
  try {
523
- await source.comment(opts.id, ticketAssetNote('removed', removed))
596
+ await source.comment(
597
+ opts.id,
598
+ ticketAssetNote('removed', removed, 'ab', source.name === 'hosted'),
599
+ )
524
600
  } catch (error) {
525
601
  throw new Error(
526
602
  `ticket asset ${opts.kind}/${opts.name} was removed, but the note on ${source.name}:${opts.id} ` +
@@ -537,7 +613,7 @@ export async function abTicketAssetRm(opts: TicketAssetRmOpts): Promise<void> {
537
613
  /** `ab ticket move <id> <state>` — adapter-owned state validation and move. */
538
614
  export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
539
615
  const { source } = await resolveTicketCommand(opts, 'move')
540
- const moved = await moveTicket(source, opts.id, opts.state)
616
+ const moved = await moveTicket(source, opts.id, opts.state, opts.expectedRevision)
541
617
  if (opts.json === true) {
542
618
  opts.stdout(JSON.stringify(moved, null, 2))
543
619
  return
@@ -566,10 +642,10 @@ export function stateRoles(
566
642
  const named: Array<[TicketStateRole, string]> = [
567
643
  ['create', tickets.createState ?? 'triage'],
568
644
  ['ready', tickets.readyState],
569
- ['claimed', 'doing'],
645
+ ['claimed', tickets.source === 'hosted' ? (tickets.claimedState ?? 'doing') : 'doing'],
570
646
  ['triage', triage],
571
647
  ['proposal', tickets.proposalState ?? triage],
572
- ['done', 'done'],
648
+ ['done', tickets.source === 'hosted' ? (tickets.doneState ?? 'done') : 'done'],
573
649
  ]
574
650
  const roles = new Map<string, TicketStateRole[]>(names.map((name) => [name, []]))
575
651
  for (const [role, target] of named) {
@@ -620,7 +696,7 @@ export async function abTicketStates(opts: TicketStatesOpts): Promise<void> {
620
696
  })
621
697
  }
622
698
 
623
- /** `ab ticket state create <name> [--about <text>]` — idempotent. */
699
+ /** `ab ticket state create <name> [--about <text>]` — duplicate policy belongs to the source. */
624
700
  export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<void> {
625
701
  const { config, source } = await resolveTicketCommand(opts, 'state create')
626
702
  if (typeof source.addState !== 'function') {
@@ -650,9 +726,11 @@ export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<
650
726
  const CREATE_USAGE =
651
727
  'usage: ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json] (§8.8)'
652
728
  const UPDATE_USAGE =
653
- 'usage: ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--json] (§8.8)'
654
- const BLOCK_USAGE = 'usage: ab ticket block <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
655
- const UNBLOCK_USAGE = 'usage: ab ticket unblock <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
729
+ 'usage: ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--expected-revision <opaque>] [--json] (§8.8)'
730
+ const BLOCK_USAGE =
731
+ 'usage: ab ticket block <id> <blocker-id[,blocker-id...]> [--expected-revision <opaque>] [--json] (§8.8)'
732
+ const UNBLOCK_USAGE =
733
+ 'usage: ab ticket unblock <id> <blocker-id[,blocker-id...]> [--expected-revision <opaque>] [--json] (§8.8)'
656
734
  const LIST_USAGE = 'usage: ab ticket list [--state <state>] [--labels a,b] [--json] (§8.8)'
657
735
  const SHOW_USAGE = 'usage: ab ticket show <id> [--store <ref>] [--json] (§8.8)'
658
736
  const ATTACH_USAGE =
@@ -661,11 +739,15 @@ const ASSET_GET_USAGE =
661
739
  'usage: ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json] (§8.8)'
662
740
  const ASSET_RM_USAGE =
663
741
  'usage: ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json] (§8.8)'
664
- const MOVE_USAGE = 'usage: ab ticket move <id> <state> [--json] (§8.8)'
742
+ const MOVE_USAGE =
743
+ 'usage: ab ticket move <id> <state> [--expected-revision <opaque>] [--json] (§8.8)'
665
744
  const STATES_USAGE = 'usage: ab ticket states [--json] (§8.8)'
666
745
  const STATE_CREATE_USAGE = 'usage: ab ticket state create <name> [--about <text>] [--json] (§8.8)'
667
746
  export const TICKET_USAGE = [
747
+ 'usage: ab ticket export <dir> [--store <ref>]',
748
+ 'usage: ab ticket import <dir> [--store <ref>]',
668
749
  CREATE_USAGE,
750
+ 'usage: ab ticket edit <id> (§8.8)',
669
751
  UPDATE_USAGE,
670
752
  BLOCK_USAGE,
671
753
  UNBLOCK_USAGE,
@@ -696,6 +778,25 @@ interface TicketCliOpts extends TicketCommandOpts {
696
778
  export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<void> {
697
779
  const [command, ...args] = argv
698
780
  switch (command) {
781
+ case 'export':
782
+ case 'import': {
783
+ const parsed = parseArgs(args, { store: 'value' }, TICKET_USAGE)
784
+ const [dir, ...extra] = parsed.positionals
785
+ if (!dir?.trim() || extra.length) throw new Error(TICKET_USAGE)
786
+ const migrationOpts = { ...opts, storeRef: stringFlag(parsed, 'store') ?? opts.storeRef }
787
+ const { source } = await resolveTicketCommand(migrationOpts, command)
788
+ await withTicketAssets(migrationOpts, source, ({ store, repo }) =>
789
+ (command === 'export' ? exportTickets : importTickets)({
790
+ source,
791
+ assets: store,
792
+ repo,
793
+ dir,
794
+ print: opts.stdout,
795
+ }),
796
+ )
797
+ return
798
+ }
799
+
699
800
  case 'create': {
700
801
  const parsed = parseArgs(
701
802
  args,
@@ -723,10 +824,23 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
723
824
  return
724
825
  }
725
826
 
827
+ case 'edit': {
828
+ if (args.length !== 1 || !args[0]?.trim()) throw new Error('usage: ab ticket edit <id>')
829
+ const { source } = await resolveTicketCommand(opts, 'edit')
830
+ await editTicket(source, args[0], opts.env, opts.stdout, opts.editorRunner)
831
+ return
832
+ }
833
+
726
834
  case 'update': {
727
835
  const parsed = parseArgs(
728
836
  args,
729
- { title: 'value', body: 'value', labels: 'value', json: 'boolean' },
837
+ {
838
+ title: 'value',
839
+ body: 'value',
840
+ labels: 'value',
841
+ 'expected-revision': 'value',
842
+ json: 'boolean',
843
+ },
730
844
  TICKET_USAGE,
731
845
  )
732
846
  const [id, ...extra] = parsed.positionals
@@ -743,6 +857,7 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
743
857
  await abTicketUpdate({
744
858
  ...opts,
745
859
  id,
860
+ expectedRevision: stringFlag(parsed, 'expected-revision'),
746
861
  ...(title !== undefined ? { title } : {}),
747
862
  ...(bodyFile !== undefined ? { bodyFile } : {}),
748
863
  ...(labels !== undefined ? { labels: commaList(labels) } : {}),
@@ -753,7 +868,11 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
753
868
 
754
869
  case 'block':
755
870
  case 'unblock': {
756
- const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
871
+ const parsed = parseArgs(
872
+ args,
873
+ { json: 'boolean', 'expected-revision': 'value' },
874
+ TICKET_USAGE,
875
+ )
757
876
  const [id, blockerList, ...extra] = parsed.positionals
758
877
  if (
759
878
  id === undefined ||
@@ -766,7 +885,13 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
766
885
  }
767
886
  const blockerIds = commaList(blockerList)
768
887
  if (blockerIds.length === 0) throw new Error(TICKET_USAGE)
769
- const blockerOpts = { ...opts, id, blockerIds, json: parsed.flags.has('json') }
888
+ const blockerOpts = {
889
+ ...opts,
890
+ id,
891
+ blockerIds,
892
+ expectedRevision: stringFlag(parsed, 'expected-revision'),
893
+ json: parsed.flags.has('json'),
894
+ }
770
895
  if (command === 'block') await abTicketBlock(blockerOpts)
771
896
  else await abTicketUnblock(blockerOpts)
772
897
  return
@@ -910,7 +1035,11 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
910
1035
  }
911
1036
 
912
1037
  case 'move': {
913
- const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
1038
+ const parsed = parseArgs(
1039
+ args,
1040
+ { json: 'boolean', 'expected-revision': 'value' },
1041
+ TICKET_USAGE,
1042
+ )
914
1043
  const [id, state, ...extra] = parsed.positionals
915
1044
  if (
916
1045
  id === undefined ||
@@ -925,6 +1054,7 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
925
1054
  ...opts,
926
1055
  id,
927
1056
  state,
1057
+ expectedRevision: stringFlag(parsed, 'expected-revision'),
928
1058
  json: parsed.flags.has('json'),
929
1059
  })
930
1060
  return