@defrex/autobuild 0.11.0 → 0.13.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 (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. package/templates/hosted-service/vercel.json +7 -0
@@ -0,0 +1,82 @@
1
+ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import type { TicketSource, TicketUpdate } from '../ports/types'
5
+ import { parseTicketFile, serializeTicketFile } from '../ports/tickets/file-codec'
6
+ import { TicketRevisionUnsupported } from '../ports/tickets/revision'
7
+ import { requireTicket, updateTicket } from '../ports/tickets/operations'
8
+
9
+ export type TicketEditorRunner = (command: string, path: string) => Promise<number>
10
+
11
+ /** The configured editor is a shell command; only our generated path is quoted. */
12
+ export const runTicketEditor: TicketEditorRunner = async (command, path) => {
13
+ const quoted = `'${path.replaceAll("'", "'\\''")}'`
14
+ return Bun.spawn(['sh', '-c', `${command} ${quoted}`], {
15
+ stdin: 'inherit',
16
+ stdout: 'inherit',
17
+ stderr: 'inherit',
18
+ }).exited
19
+ }
20
+
21
+ export async function editTicket(
22
+ source: TicketSource,
23
+ id: string,
24
+ env: Record<string, string | undefined>,
25
+ stdout: (line: string) => void,
26
+ runner: TicketEditorRunner = runTicketEditor,
27
+ ): Promise<void> {
28
+ if (!source.mutateIfRevision) throw new TicketRevisionUnsupported(source.name)
29
+ const command = env.VISUAL?.trim() || env.EDITOR?.trim()
30
+ if (!command) throw new Error('set VISUAL or EDITOR to edit a ticket')
31
+ const ticket = await requireTicket(source, id)
32
+ const front = {
33
+ id,
34
+ title: ticket.title,
35
+ labels: [...ticket.labels],
36
+ blockedBy: [...(ticket.blockedBy ?? [])],
37
+ ...(ticket.creationKey !== undefined ? { idempotencyKey: ticket.creationKey } : {}),
38
+ }
39
+ // Reference comments remain part of the frontmatter, outside the editable keys.
40
+ const reference = `# Read-only reference\n# state = ${JSON.stringify(ticket.state ?? null)}\n# revision = ${JSON.stringify(ticket.revision)}\n`
41
+ const serialized = serializeTicketFile(front, ticket.body)
42
+ const original = serialized.replace('+++\n', `+++\n${reference}`)
43
+ const directory = await mkdtemp(join(tmpdir(), 'ab-ticket-edit-'))
44
+ const path = join(directory, 'ticket.md')
45
+ await writeFile(path, original, { mode: 0o600 })
46
+ try {
47
+ const code = await runner(command, path)
48
+ if (code !== 0) throw new Error(`editor exited with status ${code}`)
49
+ const edited = await readFile(path, 'utf8')
50
+ if (edited !== original) {
51
+ const parsed = parseTicketFile(path, edited)
52
+ const close = edited.indexOf('\n+++\n', 4)
53
+ const comments = edited
54
+ .slice(4, close)
55
+ .split('\n')
56
+ .filter((line) => line.trimStart().startsWith('#'))
57
+ .join('\n')
58
+ if (comments !== reference.trimEnd())
59
+ throw new Error('read-only reference comments cannot change')
60
+ if (
61
+ parsed.front.id !== id ||
62
+ parsed.front.idempotencyKey !== front.idempotencyKey ||
63
+ JSON.stringify(parsed.front.blockedBy ?? []) !== JSON.stringify(front.blockedBy)
64
+ )
65
+ throw new Error('id, idempotencyKey and blockedBy are read-only')
66
+ const patch: TicketUpdate = {}
67
+ if (parsed.front.title !== ticket.title) patch.title = parsed.front.title
68
+ if (parsed.body !== ticket.body) patch.body = parsed.body
69
+ if (JSON.stringify(parsed.front.labels) !== JSON.stringify(ticket.labels))
70
+ patch.labels = parsed.front.labels
71
+ if (Object.keys(patch).length > 0)
72
+ await updateTicket(source, id, patch, false, ticket.revision)
73
+ }
74
+ await rm(directory, { recursive: true })
75
+ stdout(`ticket edit complete: ${source.name}:${id}`)
76
+ } catch (error) {
77
+ throw new Error(
78
+ `${error instanceof Error ? error.message : String(error)}; edited text kept at ${path}`,
79
+ { cause: error },
80
+ )
81
+ }
82
+ }
@@ -1,3 +1,6 @@
1
+ import { exportTickets, importTickets } from '../ports/tickets/migration'
2
+ import { composeTicketAssets } from '../store/ticket-assets'
3
+ import { editTicket, type TicketEditorRunner } from './ticket-editor'
1
4
  /**
2
5
  * Source-agnostic pre-build ticket operations (SPEC §8.8). These commands run
3
6
  * outside build sessions and resolve the repository's configured TicketSource,
@@ -19,7 +22,13 @@ import {
19
22
  ticketListCriteria,
20
23
  updateTicket,
21
24
  } from '../ports/tickets/operations'
22
- import type { Ticket, TicketSource, TicketStateInfo, TicketUpdate } from '../ports/types'
25
+ import type {
26
+ TicketAssetStore,
27
+ Ticket,
28
+ TicketSource,
29
+ TicketStateInfo,
30
+ TicketUpdate,
31
+ } from '../ports/types'
23
32
  import { reduceBuild } from '../kernel/reducer'
24
33
  import type { BuildStore } from '../store/types'
25
34
  import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
@@ -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,18 +207,40 @@ 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)
200
228
  const repoState = resolveRepoStatePaths({
201
229
  repo: targetRepo,
230
+ configStore: config.store?.url,
202
231
  ...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
203
232
  ...(opts.env.AB_STORE !== undefined ? { envStore: opts.env.AB_STORE } : {}),
204
233
  })
205
234
  const factory = opts.sourceFactory ?? createTicketSource
206
235
  return {
207
236
  config,
208
- source: await factory(config.tickets, opts.env, targetRepo, repoState.localStateRoot, plugins),
237
+ source: await factory(
238
+ config.tickets,
239
+ config.tickets.source === 'hosted' ? { ...opts.env, AB_STORE: repoState.storeRef } : opts.env,
240
+ targetRepo,
241
+ repoState.localStateRoot,
242
+ plugins,
243
+ ),
209
244
  }
210
245
  }
211
246
 
@@ -306,18 +341,18 @@ export async function abTicketUpdate(opts: TicketUpdateOpts): Promise<void> {
306
341
  ...(opts.labels !== undefined ? { labels: [...opts.labels] } : {}),
307
342
  }
308
343
  if (opts.json === true) {
309
- const updated = await updateTicket(source, opts.id, patch)
344
+ const updated = await updateTicket(source, opts.id, patch, true, opts.expectedRevision)
310
345
  if (updated !== null) emitTicketJson(opts, updated)
311
346
  return
312
347
  }
313
- await updateTicket(source, opts.id, patch, false)
348
+ await updateTicket(source, opts.id, patch, false, opts.expectedRevision)
314
349
  opts.stdout(`ticket updated: ${source.name}:${opts.id}`)
315
350
  }
316
351
 
317
352
  export async function abTicketBlock(opts: TicketBlockerOpts): Promise<void> {
318
353
  const { source } = await resolveTicketCommand(opts, 'block')
319
354
  const blockerIds = [...new Set(opts.blockerIds)]
320
- const updated = await changeBlockers(source, opts.id, blockerIds, 'block')
355
+ const updated = await changeBlockers(source, opts.id, blockerIds, 'block', opts.expectedRevision)
321
356
  if (opts.json === true) {
322
357
  emitTicketJson(opts, updated)
323
358
  return
@@ -330,7 +365,13 @@ export async function abTicketBlock(opts: TicketBlockerOpts): Promise<void> {
330
365
  export async function abTicketUnblock(opts: TicketBlockerOpts): Promise<void> {
331
366
  const { source } = await resolveTicketCommand(opts, 'unblock')
332
367
  const blockerIds = [...new Set(opts.blockerIds)]
333
- 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
+ )
334
375
  if (opts.json === true) {
335
376
  emitTicketJson(opts, updated)
336
377
  return
@@ -365,7 +406,7 @@ export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
365
406
  const { source } = await resolveTicketCommand(opts, 'show')
366
407
  const ticket = await requireTicket(source, opts.id)
367
408
  // An asset-listing failure fails the command rather than hiding the assets.
368
- const assets = await withTicketStore(opts, ({ store, repo }) =>
409
+ const assets = await withTicketAssets(opts, source, ({ store, repo }) =>
369
410
  store.listTicketAssets(repo, opts.id),
370
411
  )
371
412
  if (opts.json === true) {
@@ -392,6 +433,12 @@ export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
392
433
  // Keep the adapter-provided body untouched. It may itself be multiline and
393
434
  // may intentionally end (or not end) with a newline.
394
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
+ }
395
442
  }
396
443
 
397
444
  function withTicketStore<T>(
@@ -410,6 +457,19 @@ function withTicketStore<T>(
410
457
  )
411
458
  }
412
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
+
413
473
  /** Slugs of this repo's non-terminal builds for the ticket. A build freezes its
414
474
  * ticket assets at the claim (SPEC §6.3), so a change to them does not reach
415
475
  * it. The notice is advisory: a failed lookup yields none and never blocks. */
@@ -448,18 +508,26 @@ export async function abTicketAttach(opts: TicketAttachOpts): Promise<void> {
448
508
  await requireTicket(source, opts.id)
449
509
  const walk = await walkAssetPath(opts.path)
450
510
  const name = opts.name ?? walk.basename
451
- const stored = await withTicketStore(opts, async ({ store, repo }) => {
511
+ const stored = await withTicketAssets(opts, source, async ({ store, buildStore, repo }) => {
452
512
  const limits = await store.ticketAssetLimits(repo)
453
513
  const input = await buildAssetInput(walk, { kind: opts.kind, name }, limits)
454
514
  const live = await store.listTicketAssets(repo, opts.id)
455
515
  const replacing = live.some((asset) => asset.kind === opts.kind && asset.name === name)
456
516
  const meta = await store.putTicketAsset(repo, opts.id, input)
457
- return { meta, replacing, active: await activeBuildsFor(store, repo, opts.id) }
517
+ return { meta, replacing, active: await activeBuildsFor(buildStore, repo, opts.id) }
458
518
  })
459
519
  const { meta } = stored
460
520
  warnActiveBuilds(opts, stored.active)
461
521
  try {
462
- 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
+ )
463
531
  } catch (error) {
464
532
  throw new Error(
465
533
  `ticket asset ${meta.kind}/${meta.name} was stored as revision ${meta.revision}, but the note on ` +
@@ -480,9 +548,17 @@ export async function abTicketAttach(opts: TicketAttachOpts): Promise<void> {
480
548
  /** `ab ticket asset get <id> <kind> <name> <dest>` — exact bytes at the
481
549
  * latest or a named revision. */
482
550
  export async function abTicketAssetGet(opts: TicketAssetGetOpts): Promise<void> {
483
- const asset = await withTicketStore(opts, ({ store, repo }) =>
484
- store.getTicketAsset(repo, opts.id, opts.kind, opts.name, opts.rev),
485
- )
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
+ )
486
562
  if (asset === null) {
487
563
  const revision = opts.rev === undefined ? '' : ` revision ${opts.rev}`
488
564
  throw new Error(
@@ -504,16 +580,23 @@ export async function abTicketAssetGet(opts: TicketAssetGetOpts): Promise<void>
504
580
  * (earlier revisions stay retrievable) and note the removal on the ticket. */
505
581
  export async function abTicketAssetRm(opts: TicketAssetRmOpts): Promise<void> {
506
582
  const { source } = await resolveTicketCommand(opts, 'asset rm')
507
- const { removed, active } = await withTicketStore(opts, async ({ store, repo }) => ({
508
- removed: await store.removeTicketAsset(repo, opts.id, opts.kind, opts.name),
509
- active: await activeBuildsFor(store, repo, opts.id),
510
- }))
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
+ )
511
591
  if (removed === null) {
512
592
  throw new Error(`ticket ${opts.id} has no current asset ${opts.kind}/${opts.name} to remove`)
513
593
  }
514
594
  warnActiveBuilds(opts, active)
515
595
  try {
516
- await source.comment(opts.id, ticketAssetNote('removed', removed))
596
+ await source.comment(
597
+ opts.id,
598
+ ticketAssetNote('removed', removed, 'ab', source.name === 'hosted'),
599
+ )
517
600
  } catch (error) {
518
601
  throw new Error(
519
602
  `ticket asset ${opts.kind}/${opts.name} was removed, but the note on ${source.name}:${opts.id} ` +
@@ -530,7 +613,7 @@ export async function abTicketAssetRm(opts: TicketAssetRmOpts): Promise<void> {
530
613
  /** `ab ticket move <id> <state>` — adapter-owned state validation and move. */
531
614
  export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
532
615
  const { source } = await resolveTicketCommand(opts, 'move')
533
- const moved = await moveTicket(source, opts.id, opts.state)
616
+ const moved = await moveTicket(source, opts.id, opts.state, opts.expectedRevision)
534
617
  if (opts.json === true) {
535
618
  opts.stdout(JSON.stringify(moved, null, 2))
536
619
  return
@@ -559,10 +642,10 @@ export function stateRoles(
559
642
  const named: Array<[TicketStateRole, string]> = [
560
643
  ['create', tickets.createState ?? 'triage'],
561
644
  ['ready', tickets.readyState],
562
- ['claimed', 'doing'],
645
+ ['claimed', tickets.source === 'hosted' ? (tickets.claimedState ?? 'doing') : 'doing'],
563
646
  ['triage', triage],
564
647
  ['proposal', tickets.proposalState ?? triage],
565
- ['done', 'done'],
648
+ ['done', tickets.source === 'hosted' ? (tickets.doneState ?? 'done') : 'done'],
566
649
  ]
567
650
  const roles = new Map<string, TicketStateRole[]>(names.map((name) => [name, []]))
568
651
  for (const [role, target] of named) {
@@ -613,7 +696,7 @@ export async function abTicketStates(opts: TicketStatesOpts): Promise<void> {
613
696
  })
614
697
  }
615
698
 
616
- /** `ab ticket state create <name> [--about <text>]` — idempotent. */
699
+ /** `ab ticket state create <name> [--about <text>]` — duplicate policy belongs to the source. */
617
700
  export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<void> {
618
701
  const { config, source } = await resolveTicketCommand(opts, 'state create')
619
702
  if (typeof source.addState !== 'function') {
@@ -643,9 +726,11 @@ export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<
643
726
  const CREATE_USAGE =
644
727
  'usage: ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json] (§8.8)'
645
728
  const UPDATE_USAGE =
646
- 'usage: ab ticket update <id> [--title <title>] [--body <file>] [--labels a,b] [--json] (§8.8)'
647
- const BLOCK_USAGE = 'usage: ab ticket block <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
648
- 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)'
649
734
  const LIST_USAGE = 'usage: ab ticket list [--state <state>] [--labels a,b] [--json] (§8.8)'
650
735
  const SHOW_USAGE = 'usage: ab ticket show <id> [--store <ref>] [--json] (§8.8)'
651
736
  const ATTACH_USAGE =
@@ -654,11 +739,15 @@ const ASSET_GET_USAGE =
654
739
  'usage: ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json] (§8.8)'
655
740
  const ASSET_RM_USAGE =
656
741
  'usage: ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json] (§8.8)'
657
- 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)'
658
744
  const STATES_USAGE = 'usage: ab ticket states [--json] (§8.8)'
659
745
  const STATE_CREATE_USAGE = 'usage: ab ticket state create <name> [--about <text>] [--json] (§8.8)'
660
746
  export const TICKET_USAGE = [
747
+ 'usage: ab ticket export <dir> [--store <ref>]',
748
+ 'usage: ab ticket import <dir> [--store <ref>]',
661
749
  CREATE_USAGE,
750
+ 'usage: ab ticket edit <id> (§8.8)',
662
751
  UPDATE_USAGE,
663
752
  BLOCK_USAGE,
664
753
  UNBLOCK_USAGE,
@@ -689,6 +778,25 @@ interface TicketCliOpts extends TicketCommandOpts {
689
778
  export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<void> {
690
779
  const [command, ...args] = argv
691
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
+
692
800
  case 'create': {
693
801
  const parsed = parseArgs(
694
802
  args,
@@ -716,10 +824,23 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
716
824
  return
717
825
  }
718
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
+
719
834
  case 'update': {
720
835
  const parsed = parseArgs(
721
836
  args,
722
- { 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
+ },
723
844
  TICKET_USAGE,
724
845
  )
725
846
  const [id, ...extra] = parsed.positionals
@@ -736,6 +857,7 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
736
857
  await abTicketUpdate({
737
858
  ...opts,
738
859
  id,
860
+ expectedRevision: stringFlag(parsed, 'expected-revision'),
739
861
  ...(title !== undefined ? { title } : {}),
740
862
  ...(bodyFile !== undefined ? { bodyFile } : {}),
741
863
  ...(labels !== undefined ? { labels: commaList(labels) } : {}),
@@ -746,7 +868,11 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
746
868
 
747
869
  case 'block':
748
870
  case 'unblock': {
749
- const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
871
+ const parsed = parseArgs(
872
+ args,
873
+ { json: 'boolean', 'expected-revision': 'value' },
874
+ TICKET_USAGE,
875
+ )
750
876
  const [id, blockerList, ...extra] = parsed.positionals
751
877
  if (
752
878
  id === undefined ||
@@ -759,7 +885,13 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
759
885
  }
760
886
  const blockerIds = commaList(blockerList)
761
887
  if (blockerIds.length === 0) throw new Error(TICKET_USAGE)
762
- 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
+ }
763
895
  if (command === 'block') await abTicketBlock(blockerOpts)
764
896
  else await abTicketUnblock(blockerOpts)
765
897
  return
@@ -903,7 +1035,11 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
903
1035
  }
904
1036
 
905
1037
  case 'move': {
906
- const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
1038
+ const parsed = parseArgs(
1039
+ args,
1040
+ { json: 'boolean', 'expected-revision': 'value' },
1041
+ TICKET_USAGE,
1042
+ )
907
1043
  const [id, state, ...extra] = parsed.positionals
908
1044
  if (
909
1045
  id === undefined ||
@@ -918,6 +1054,7 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
918
1054
  ...opts,
919
1055
  id,
920
1056
  state,
1057
+ expectedRevision: stringFlag(parsed, 'expected-revision'),
921
1058
  json: parsed.flags.has('json'),
922
1059
  })
923
1060
  return
@@ -0,0 +1,80 @@
1
+ import { spawnExec } from '../ports/workspace/git-worktree'
2
+ import { readDistributionIdentity } from './installation'
3
+ import { isRemoteStoreRef, resolveRepoState } from './repo-state'
4
+ import {
5
+ classifyClient,
6
+ compatibilityAdvice,
7
+ stableVersion,
8
+ supportedClientRangeSchema,
9
+ } from '../store/remote/compatibility'
10
+
11
+ export type CompatibilityFetch = (url: string, init: RequestInit) => Promise<Response>
12
+
13
+ /** Read-only, advisory, and deliberately credential-free. Never expose a store URL. */
14
+ export async function checkUpgradeCompatibility(options: {
15
+ targetRepo: string
16
+ version?: string
17
+ distRoot?: string
18
+ env?: Record<string, string | undefined>
19
+ stderr: (line: string) => void
20
+ fetch?: CompatibilityFetch
21
+ timeoutMs?: number
22
+ }): Promise<void> {
23
+ const paths = await resolveRepoState({
24
+ targetRepo: options.targetRepo,
25
+ exec: spawnExec,
26
+ envStore: (options.env ?? process.env).AB_STORE,
27
+ })
28
+ if (!isRemoteStoreRef(paths.storeRef)) return
29
+ const unknown = () =>
30
+ options.stderr(
31
+ 'warning: hosted service compatibility could not be verified; the upgraded project may fail to connect',
32
+ )
33
+ try {
34
+ const targetVersion =
35
+ options.version ?? (await readDistributionIdentity(options.distRoot)).version
36
+ const url = new URL(paths.storeRef)
37
+ url.username = ''
38
+ url.password = ''
39
+ url.search = ''
40
+ url.hash = ''
41
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/health`
42
+ const controller = new AbortController()
43
+ let timeout: ReturnType<typeof setTimeout> | undefined
44
+ const expired = new Promise<never>((_, reject) => {
45
+ timeout = setTimeout(() => {
46
+ controller.abort()
47
+ reject(new Error('timeout'))
48
+ }, options.timeoutMs ?? 3000)
49
+ })
50
+ const response = await Promise.race([
51
+ (async () => {
52
+ const response = await (options.fetch ?? fetch)(url.toString(), {
53
+ signal: controller.signal,
54
+ redirect: 'error',
55
+ })
56
+ if (response.status !== 200) throw new Error('health failed')
57
+ return (await response.json()) as Record<string, unknown>
58
+ })(),
59
+ expired,
60
+ ]).finally(() => clearTimeout(timeout))
61
+ if (!response || !stableVersion(response.autobuildVersion)) return unknown()
62
+ const serviceVersion = response.autobuildVersion as string
63
+ if (!Object.hasOwn(response, 'supportedClientRange')) {
64
+ if (targetVersion !== serviceVersion)
65
+ options.stderr(
66
+ `warning: service Autobuild ${serviceVersion} requires an exact client version; target ${targetVersion} differs. Upgrade the service to the compatibility-window release first to publish a supported client range; the upgraded project may fail to connect`,
67
+ )
68
+ return
69
+ }
70
+ const range = supportedClientRangeSchema.safeParse(response.supportedClientRange)
71
+ if (!range.success || classifyClient(targetVersion, range.data) === 'invalid') return unknown()
72
+ const advice = compatibilityAdvice(targetVersion, serviceVersion, range.data)
73
+ if (advice)
74
+ options.stderr(
75
+ `warning: target Autobuild ${targetVersion}: ${advice}; the upgraded project may fail to connect`,
76
+ )
77
+ } catch {
78
+ unknown()
79
+ }
80
+ }