@defrex/autobuild 0.8.0 → 0.10.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.
- package/README.md +1 -1
- package/SPEC.md +323 -26
- package/bin/ab-build-runner.ts +8 -2
- package/docs/architecture.md +166 -14
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +175 -28
- package/docs/hosted-dispatcher.md +69 -13
- package/docs/operator-api.md +2 -2
- package/docs/remote-store-protocol.md +204 -16
- package/docs/remote-ticket-protocol.md +1 -1
- package/docs/setup.md +12 -9
- package/docs/website.md +14 -0
- package/package.json +45 -23
- package/packages/core/src/cli/artifact.ts +130 -6
- package/packages/core/src/cli/binary.ts +9 -1
- package/packages/core/src/cli/build-control.ts +110 -13
- package/packages/core/src/cli/context.ts +46 -0
- package/packages/core/src/cli/dashboard/facts.ts +215 -0
- package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
- package/packages/core/src/cli/dashboard/model.ts +141 -153
- package/packages/core/src/cli/dashboard/poll.ts +76 -12
- package/packages/core/src/cli/dashboard/render.ts +34 -5
- package/packages/core/src/cli/dispatch-frontend.ts +54 -5
- package/packages/core/src/cli/dispatch-process.ts +1 -13
- package/packages/core/src/cli/dispatch.ts +392 -75
- package/packages/core/src/cli/harvest.ts +122 -8
- package/packages/core/src/cli/help.ts +62 -9
- package/packages/core/src/cli/init-readiness-remote.ts +110 -0
- package/packages/core/src/cli/init-readiness-shared.ts +72 -0
- package/packages/core/src/cli/init-validation.ts +266 -222
- package/packages/core/src/cli/installation.ts +50 -8
- package/packages/core/src/cli/main.ts +70 -11
- package/packages/core/src/cli/mcp.ts +23 -1
- package/packages/core/src/cli/plugin.ts +7 -4
- package/packages/core/src/cli/remote-poll.ts +277 -0
- package/packages/core/src/cli/repo-state.ts +22 -0
- package/packages/core/src/cli/repository-status.ts +39 -1
- package/packages/core/src/cli/self-update.ts +77 -2
- package/packages/core/src/cli/status.ts +150 -28
- package/packages/core/src/cli/terminals.ts +2 -1
- package/packages/core/src/cli/ticket-assets.ts +247 -0
- package/packages/core/src/cli/ticket.ts +453 -8
- package/packages/core/src/cli/wait.ts +157 -41
- package/packages/core/src/cli/watch.ts +138 -168
- package/packages/core/src/config/live.ts +23 -0
- package/packages/core/src/config/pipeline-source.ts +388 -0
- package/packages/core/src/config/schema.ts +251 -19
- package/packages/core/src/events/catalog.ts +1 -0
- package/packages/core/src/events/globs.ts +47 -0
- package/packages/core/src/events/payloads.ts +52 -2
- package/packages/core/src/events/repository.ts +52 -1
- package/packages/core/src/events/sessions.ts +69 -10
- package/packages/core/src/events/workspace-remote.ts +12 -0
- package/packages/core/src/integration/harness.ts +115 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
- package/packages/core/src/kernel/auto-merge-default.ts +157 -0
- package/packages/core/src/kernel/auto-merge.ts +99 -22
- package/packages/core/src/kernel/dispatch-settings.ts +42 -17
- package/packages/core/src/kernel/dispatch-status.ts +23 -0
- package/packages/core/src/kernel/engine.ts +9 -438
- package/packages/core/src/kernel/generators/build-log.ts +259 -0
- package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
- package/packages/core/src/kernel/generators/session-log.ts +72 -0
- package/packages/core/src/kernel/harvest.ts +62 -18
- package/packages/core/src/kernel/incremental-contract.ts +75 -0
- package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
- package/packages/core/src/kernel/incremental.ts +53 -0
- package/packages/core/src/kernel/log-index.ts +620 -0
- package/packages/core/src/kernel/phases.ts +16 -2
- package/packages/core/src/kernel/pr-attachments.ts +164 -46
- package/packages/core/src/kernel/reducer-registry.ts +164 -0
- package/packages/core/src/kernel/reducer.ts +254 -73
- package/packages/core/src/operator/annotations.ts +6 -0
- package/packages/core/src/operator/control.ts +19 -5
- package/packages/core/src/operator/index.ts +71 -6
- package/packages/core/src/operator/query.ts +253 -24
- package/packages/core/src/operator/read-view.ts +98 -0
- package/packages/core/src/operator/registry.ts +55 -10
- package/packages/core/src/operator/requests.ts +9 -6
- package/packages/core/src/operator/sandbox-host.ts +140 -0
- package/packages/core/src/operator/sandbox.ts +247 -14
- package/packages/core/src/operator/ticket-source-backend.ts +47 -0
- package/packages/core/src/orchestrator/approvals.ts +115 -0
- package/packages/core/src/orchestrator/conversation.ts +167 -0
- package/packages/core/src/orchestrator/model.ts +86 -0
- package/packages/core/src/orchestrator/prompt.ts +61 -0
- package/packages/core/src/orchestrator/tools.ts +117 -0
- package/packages/core/src/orchestrator/turn-runner.ts +457 -0
- package/packages/core/src/plugin-sdk/index.ts +51 -1
- package/packages/core/src/plugins/load.ts +135 -7
- package/packages/core/src/plugins/manifest.ts +183 -10
- package/packages/core/src/plugins/registry.ts +73 -4
- package/packages/core/src/ports/forge/create.ts +1 -1
- package/packages/core/src/ports/forge/fake.ts +31 -0
- package/packages/core/src/ports/forge/github-transport.ts +13 -2
- package/packages/core/src/ports/forge/github.ts +13 -2
- package/packages/core/src/ports/forge/local-git.ts +31 -1
- package/packages/core/src/ports/runner/claude.ts +2 -2
- package/packages/core/src/ports/runner/codex.ts +6 -2
- package/packages/core/src/ports/runner/fake.ts +7 -1
- package/packages/core/src/ports/runner/pi.ts +8 -6
- package/packages/core/src/ports/runner/provider-error.ts +13 -3
- package/packages/core/src/ports/tickets/fake.ts +5 -1
- package/packages/core/src/ports/tickets/file.ts +169 -48
- package/packages/core/src/ports/tickets/{remote-index.ts → hosted-index.ts} +3 -2
- package/packages/core/src/ports/tickets/linear.ts +33 -1
- package/packages/core/src/ports/tickets/operations.ts +1 -1
- package/packages/core/src/ports/tickets/remote.ts +8 -1
- package/packages/core/src/ports/types.ts +49 -3
- package/packages/core/src/ports/workspace/builtin-capabilities.ts +114 -0
- package/packages/core/src/ports/workspace/create.ts +74 -39
- package/packages/core/src/ports/workspace/distribution-archive.ts +171 -12
- package/packages/core/src/ports/workspace/fake.ts +169 -4
- package/packages/core/src/ports/workspace/git-worktree.ts +246 -26
- package/packages/core/src/ports/workspace/github-origin.ts +36 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +7 -1
- package/packages/core/src/ports/workspace/provider-capabilities.ts +171 -0
- package/packages/core/src/ports/workspace/vercel-capabilities.ts +110 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +142 -114
- package/packages/core/src/processes/build-child.ts +17 -2
- package/packages/core/src/processes/build-execution-state.ts +68 -15
- package/packages/core/src/processes/build-runner.ts +238 -93
- package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
- package/packages/core/src/processes/dispatcher.ts +501 -201
- package/packages/core/src/processes/execution-settlement.ts +72 -39
- package/packages/core/src/processes/harvest-child.ts +5 -0
- package/packages/core/src/processes/harvest-execution-state.ts +41 -29
- package/packages/core/src/processes/harvest-runner.ts +66 -19
- package/packages/core/src/processes/harvest.ts +196 -24
- package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
- package/packages/core/src/processes/orchestrator-tick.ts +360 -0
- package/packages/core/src/processes/publication-state.ts +164 -52
- package/packages/core/src/processes/repo-view.ts +1436 -0
- package/packages/core/src/processes/sandbox-state.ts +36 -2
- package/packages/core/src/processes/tick-state.ts +33 -0
- package/packages/core/src/store/adapter.ts +41 -0
- package/packages/core/src/store/build-scope.ts +90 -0
- package/packages/core/src/store/contract.ts +1205 -0
- package/packages/core/src/store/digest.ts +102 -0
- package/packages/core/src/store/local/schema.ts +46 -2
- package/packages/core/src/store/local/store.ts +445 -4
- package/packages/core/src/store/memory.ts +246 -0
- package/packages/core/src/store/new-build.ts +17 -0
- package/packages/core/src/store/phase-session.ts +94 -0
- package/packages/core/src/store/remote/client.ts +310 -2
- package/packages/core/src/store/remote/index.ts +14 -2
- package/packages/core/src/store/remote/protocol.ts +115 -12
- package/packages/core/src/store/remote/version.ts +6 -2
- package/packages/core/src/store/repo-state-events.ts +205 -0
- package/packages/core/src/store/retention.ts +7 -0
- package/packages/core/src/store/session-handle.ts +94 -0
- package/packages/core/src/{sessions/reducer.ts → store/session-reducer.ts} +79 -19
- package/packages/core/src/store/snapshots.ts +88 -0
- package/packages/core/src/store/streams/session-writer.ts +42 -2
- package/packages/core/src/store/streams/wait.ts +4 -5
- package/packages/core/src/store/ticket-assets.ts +458 -0
- package/packages/core/src/store/types.ts +160 -1
- package/packages/core/src/testing/index.ts +59 -0
- package/skills/code-review/SKILL.md +5 -1
- package/skills/guide/SKILL.md +152 -40
- package/skills/guide/references/operator-api.md +2 -2
- package/skills/guide/references/plugin-authoring.md +17 -0
- package/skills/guide/references/remote-store-protocol.md +204 -16
- package/skills/guide/references/remote-ticket-protocol.md +1 -1
- package/skills/guide/references/setup.md +12 -9
- package/skills/implement/SKILL.md +3 -0
- package/skills/operate/SKILL.md +126 -0
- package/skills/plan/SKILL.md +4 -0
- package/skills/plan-review/SKILL.md +5 -1
- package/skills/spec/SKILL.md +46 -3
- package/skills/tickets/SKILL.md +57 -5
- package/packages/core/src/cli/dashboard/frame-image.ts +0 -373
- package/packages/core/src/cli/store-opening.contract.ts +0 -289
- package/packages/core/src/cli/testkit.ts +0 -194
- package/packages/core/src/markdown.ts +0 -67
- package/packages/core/src/operator/client.ts +0 -311
- package/packages/core/src/operator/server.ts +0 -616
- package/packages/core/src/operator/sessions.ts +0 -223
- package/packages/core/src/ports/runner/live-contract-fixture.ts +0 -176
- package/packages/core/src/ports/tickets/remote-server.ts +0 -185
- package/packages/core/src/store/remote/server.ts +0 -923
- package/packages/core/src/testing/store-failures.ts +0 -32
|
@@ -19,10 +19,21 @@ import {
|
|
|
19
19
|
ticketListCriteria,
|
|
20
20
|
updateTicket,
|
|
21
21
|
} from '../ports/tickets/operations'
|
|
22
|
-
import type { Ticket, TicketSource, TicketUpdate } from '../ports/types'
|
|
23
|
-
import
|
|
22
|
+
import type { Ticket, TicketSource, TicketStateInfo, TicketUpdate } from '../ports/types'
|
|
23
|
+
import { reduceBuild } from '../kernel/reducer'
|
|
24
|
+
import type { BuildStore } from '../store/types'
|
|
25
|
+
import { spawnExec, type Exec } from '../ports/workspace/git-worktree'
|
|
26
|
+
import type { TicketAssetSummary } from '../store/ticket-assets'
|
|
24
27
|
import { parseArgs, stringFlag } from './args'
|
|
25
28
|
import { resolveMainRepo, resolveRepoStatePaths } from './repo-state'
|
|
29
|
+
import { withSessionlessStore, type StoreOpener } from './store-opening'
|
|
30
|
+
import {
|
|
31
|
+
buildAssetInput,
|
|
32
|
+
formatAssetSize,
|
|
33
|
+
ticketAssetNote,
|
|
34
|
+
walkAssetPath,
|
|
35
|
+
writeAssetTo,
|
|
36
|
+
} from './ticket-assets'
|
|
26
37
|
|
|
27
38
|
export type TicketSourceFactory = (
|
|
28
39
|
config: TicketsConfig,
|
|
@@ -41,6 +52,11 @@ export interface TicketCommandOpts {
|
|
|
41
52
|
stdout: (line: string) => void
|
|
42
53
|
/** Injectable for tests; defaults to the real adapter factory. */
|
|
43
54
|
sourceFactory?: TicketSourceFactory
|
|
55
|
+
/** Store seam for the ticket-asset commands and `show`'s asset list. */
|
|
56
|
+
openStore?: StoreOpener
|
|
57
|
+
/** Explicit `--store` for the store-backed commands; precedence is applied
|
|
58
|
+
* by repo-state.ts. */
|
|
59
|
+
storeRef?: string
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
export interface TicketCreateOpts extends TicketCommandOpts {
|
|
@@ -88,6 +104,35 @@ export interface TicketShowOpts extends TicketCommandOpts {
|
|
|
88
104
|
json?: boolean
|
|
89
105
|
}
|
|
90
106
|
|
|
107
|
+
export interface TicketAttachOpts extends TicketCommandOpts {
|
|
108
|
+
id: string
|
|
109
|
+
kind: string
|
|
110
|
+
path: string
|
|
111
|
+
/** Defaults to the path's basename. */
|
|
112
|
+
name?: string
|
|
113
|
+
json?: boolean
|
|
114
|
+
/** Warning sink (active-build notice); stdout stays the command's result. */
|
|
115
|
+
stderr?: (line: string) => void
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface TicketAssetGetOpts extends TicketCommandOpts {
|
|
119
|
+
id: string
|
|
120
|
+
kind: string
|
|
121
|
+
name: string
|
|
122
|
+
dest: string
|
|
123
|
+
rev?: number
|
|
124
|
+
json?: boolean
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface TicketAssetRmOpts extends TicketCommandOpts {
|
|
128
|
+
id: string
|
|
129
|
+
kind: string
|
|
130
|
+
name: string
|
|
131
|
+
json?: boolean
|
|
132
|
+
/** Warning sink (active-build notice); stdout stays the command's result. */
|
|
133
|
+
stderr?: (line: string) => void
|
|
134
|
+
}
|
|
135
|
+
|
|
91
136
|
export interface TicketMoveOpts extends TicketCommandOpts {
|
|
92
137
|
id: string
|
|
93
138
|
/** Source-local workflow state; validation belongs to the adapter. */
|
|
@@ -95,7 +140,30 @@ export interface TicketMoveOpts extends TicketCommandOpts {
|
|
|
95
140
|
json?: boolean
|
|
96
141
|
}
|
|
97
142
|
|
|
98
|
-
|
|
143
|
+
export interface TicketStatesOpts extends TicketCommandOpts {
|
|
144
|
+
json?: boolean
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface TicketStateCreateOpts extends TicketCommandOpts {
|
|
148
|
+
name: string
|
|
149
|
+
/** One-line purpose; absent leaves an existing purpose untouched. */
|
|
150
|
+
about?: string
|
|
151
|
+
json?: boolean
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type TicketCommandName =
|
|
155
|
+
| 'states'
|
|
156
|
+
| 'state create'
|
|
157
|
+
| 'create'
|
|
158
|
+
| 'update'
|
|
159
|
+
| 'block'
|
|
160
|
+
| 'unblock'
|
|
161
|
+
| 'list'
|
|
162
|
+
| 'show'
|
|
163
|
+
| 'move'
|
|
164
|
+
| 'attach'
|
|
165
|
+
| 'asset get'
|
|
166
|
+
| 'asset rm'
|
|
99
167
|
|
|
100
168
|
export interface ResolvedTicketCommand {
|
|
101
169
|
config: Config
|
|
@@ -173,7 +241,11 @@ function ticketSummary(ticket: Ticket): string {
|
|
|
173
241
|
return fields.join(' ')
|
|
174
242
|
}
|
|
175
243
|
|
|
176
|
-
function
|
|
244
|
+
function assetLine(asset: TicketAssetSummary): string {
|
|
245
|
+
return ` ${asset.kind}/${asset.name} rev ${asset.revision} ${formatAssetSize(asset.size)}`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function ticketDetail(ticket: Ticket, assets: TicketAssetSummary[] = []): string[] {
|
|
177
249
|
const lines = [
|
|
178
250
|
`ticket ${ticket.ref.source}:${ticket.ref.id}`,
|
|
179
251
|
` title: ${ticket.title}`,
|
|
@@ -184,6 +256,7 @@ function ticketDetail(ticket: Ticket): string[] {
|
|
|
184
256
|
lines.push(` blocked by: ${ticket.blockedBy.join(', ')}`)
|
|
185
257
|
}
|
|
186
258
|
if (ticket.ref.url !== undefined) lines.push(` url: ${ticket.ref.url}`)
|
|
259
|
+
if (assets.length > 0) lines.push(' assets:', ...assets.map(assetLine))
|
|
187
260
|
lines.push(' body:')
|
|
188
261
|
return lines
|
|
189
262
|
}
|
|
@@ -291,16 +364,169 @@ export async function abTicketList(opts: TicketListOpts): Promise<void> {
|
|
|
291
364
|
export async function abTicketShow(opts: TicketShowOpts): Promise<void> {
|
|
292
365
|
const { source } = await resolveTicketCommand(opts, 'show')
|
|
293
366
|
const ticket = await requireTicket(source, opts.id)
|
|
367
|
+
// An asset-listing failure fails the command rather than hiding the assets.
|
|
368
|
+
const assets = await withTicketStore(opts, ({ store, repo }) =>
|
|
369
|
+
store.listTicketAssets(repo, opts.id),
|
|
370
|
+
)
|
|
294
371
|
if (opts.json === true) {
|
|
295
|
-
opts.stdout(
|
|
372
|
+
opts.stdout(
|
|
373
|
+
JSON.stringify(
|
|
374
|
+
{
|
|
375
|
+
...ticket,
|
|
376
|
+
assets: assets.map(({ kind, name, revision, size, layout, fileCount }) => ({
|
|
377
|
+
kind,
|
|
378
|
+
name,
|
|
379
|
+
revision,
|
|
380
|
+
size,
|
|
381
|
+
layout,
|
|
382
|
+
fileCount,
|
|
383
|
+
})),
|
|
384
|
+
},
|
|
385
|
+
null,
|
|
386
|
+
2,
|
|
387
|
+
),
|
|
388
|
+
)
|
|
296
389
|
return
|
|
297
390
|
}
|
|
298
|
-
for (const line of ticketDetail(ticket)) opts.stdout(line)
|
|
391
|
+
for (const line of ticketDetail(ticket, assets)) opts.stdout(line)
|
|
299
392
|
// Keep the adapter-provided body untouched. It may itself be multiline and
|
|
300
393
|
// may intentionally end (or not end) with a newline.
|
|
301
394
|
opts.stdout(ticket.body)
|
|
302
395
|
}
|
|
303
396
|
|
|
397
|
+
function withTicketStore<T>(
|
|
398
|
+
opts: TicketCommandOpts,
|
|
399
|
+
use: (context: { store: BuildStore; repo: string }) => Promise<T>,
|
|
400
|
+
): Promise<T> {
|
|
401
|
+
return withSessionlessStore(
|
|
402
|
+
{
|
|
403
|
+
targetRepo: opts.targetRepo,
|
|
404
|
+
env: opts.env,
|
|
405
|
+
exec: opts.exec ?? spawnExec,
|
|
406
|
+
...(opts.storeRef !== undefined ? { storeRef: opts.storeRef } : {}),
|
|
407
|
+
...(opts.openStore !== undefined ? { openStore: opts.openStore } : {}),
|
|
408
|
+
},
|
|
409
|
+
use,
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Slugs of this repo's non-terminal builds for the ticket. A build freezes its
|
|
414
|
+
* ticket assets at the claim (SPEC §6.3), so a change to them does not reach
|
|
415
|
+
* it. The notice is advisory: a failed lookup yields none and never blocks. */
|
|
416
|
+
async function activeBuildsFor(store: BuildStore, repo: string, id: string): Promise<string[]> {
|
|
417
|
+
try {
|
|
418
|
+
const active: string[] = []
|
|
419
|
+
for (const record of await store.listBuilds()) {
|
|
420
|
+
if (record.repo !== repo || record.ticket?.id !== id) continue
|
|
421
|
+
const { status } = reduceBuild(await store.getEvents(record.slug))
|
|
422
|
+
if (status !== 'done' && status !== 'aborted') active.push(record.slug)
|
|
423
|
+
}
|
|
424
|
+
return active
|
|
425
|
+
} catch {
|
|
426
|
+
return []
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function warnActiveBuilds(
|
|
431
|
+
opts: { stderr?: (line: string) => void; id: string },
|
|
432
|
+
slugs: string[],
|
|
433
|
+
): void {
|
|
434
|
+
for (const slug of slugs) {
|
|
435
|
+
opts.stderr?.(
|
|
436
|
+
`warning: build ${slug} is active for ticket ${opts.id}; it keeps the assets it froze at ` +
|
|
437
|
+
`the claim unless its spec is revised from the ticket (ab answer ${slug} --revise-spec-from-ticket)`,
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** `ab ticket attach <id> <kind> <path>` — store a file or folder as a ticket
|
|
443
|
+
* asset, then leave a note on the ticket in its source. Order matters: the
|
|
444
|
+
* source is asked first, so an unknown id stores nothing; the note follows the
|
|
445
|
+
* store write, so it never points at nothing. */
|
|
446
|
+
export async function abTicketAttach(opts: TicketAttachOpts): Promise<void> {
|
|
447
|
+
const { source } = await resolveTicketCommand(opts, 'attach')
|
|
448
|
+
await requireTicket(source, opts.id)
|
|
449
|
+
const walk = await walkAssetPath(opts.path)
|
|
450
|
+
const name = opts.name ?? walk.basename
|
|
451
|
+
const stored = await withTicketStore(opts, async ({ store, repo }) => {
|
|
452
|
+
const limits = await store.ticketAssetLimits(repo)
|
|
453
|
+
const input = await buildAssetInput(walk, { kind: opts.kind, name }, limits)
|
|
454
|
+
const live = await store.listTicketAssets(repo, opts.id)
|
|
455
|
+
const replacing = live.some((asset) => asset.kind === opts.kind && asset.name === name)
|
|
456
|
+
const meta = await store.putTicketAsset(repo, opts.id, input)
|
|
457
|
+
return { meta, replacing, active: await activeBuildsFor(store, repo, opts.id) }
|
|
458
|
+
})
|
|
459
|
+
const { meta } = stored
|
|
460
|
+
warnActiveBuilds(opts, stored.active)
|
|
461
|
+
try {
|
|
462
|
+
await source.comment(opts.id, ticketAssetNote(stored.replacing ? 'replaced' : 'attached', meta))
|
|
463
|
+
} catch (error) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
`ticket asset ${meta.kind}/${meta.name} was stored as revision ${meta.revision}, but the note on ` +
|
|
466
|
+
`${source.name}:${opts.id} failed: ${error instanceof Error ? error.message : String(error)} ` +
|
|
467
|
+
'(re-running attach stores another revision)',
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
if (opts.json === true) {
|
|
471
|
+
opts.stdout(JSON.stringify(meta, null, 2))
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
opts.stdout(
|
|
475
|
+
`ticket asset attached: ${source.name}:${opts.id} ${meta.kind}/${meta.name} revision ${meta.revision} ` +
|
|
476
|
+
`(${formatAssetSize(meta.size)})`,
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** `ab ticket asset get <id> <kind> <name> <dest>` — exact bytes at the
|
|
481
|
+
* latest or a named revision. */
|
|
482
|
+
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
|
+
)
|
|
486
|
+
if (asset === null) {
|
|
487
|
+
const revision = opts.rev === undefined ? '' : ` revision ${opts.rev}`
|
|
488
|
+
throw new Error(
|
|
489
|
+
`ticket ${opts.id} has no asset ${opts.kind}/${opts.name}${revision}` +
|
|
490
|
+
(opts.rev === undefined ? ' (it may have been removed — try --rev <n>)' : ''),
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
const written = await writeAssetTo(asset, opts.dest)
|
|
494
|
+
if (opts.json === true) {
|
|
495
|
+
opts.stdout(JSON.stringify({ ...asset.meta, path: written }, null, 2))
|
|
496
|
+
return
|
|
497
|
+
}
|
|
498
|
+
opts.stdout(
|
|
499
|
+
`ticket asset downloaded: ${opts.kind}/${opts.name} revision ${asset.meta.revision} → ${written}`,
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** `ab ticket asset rm <id> <kind> <name>` — remove from the current assets
|
|
504
|
+
* (earlier revisions stay retrievable) and note the removal on the ticket. */
|
|
505
|
+
export async function abTicketAssetRm(opts: TicketAssetRmOpts): Promise<void> {
|
|
506
|
+
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
|
+
}))
|
|
511
|
+
if (removed === null) {
|
|
512
|
+
throw new Error(`ticket ${opts.id} has no current asset ${opts.kind}/${opts.name} to remove`)
|
|
513
|
+
}
|
|
514
|
+
warnActiveBuilds(opts, active)
|
|
515
|
+
try {
|
|
516
|
+
await source.comment(opts.id, ticketAssetNote('removed', removed))
|
|
517
|
+
} catch (error) {
|
|
518
|
+
throw new Error(
|
|
519
|
+
`ticket asset ${opts.kind}/${opts.name} was removed, but the note on ${source.name}:${opts.id} ` +
|
|
520
|
+
`failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
if (opts.json === true) {
|
|
524
|
+
opts.stdout(JSON.stringify(removed, null, 2))
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
opts.stdout(`ticket asset removed: ${source.name}:${opts.id} ${opts.kind}/${opts.name}`)
|
|
528
|
+
}
|
|
529
|
+
|
|
304
530
|
/** `ab ticket move <id> <state>` — adapter-owned state validation and move. */
|
|
305
531
|
export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
|
|
306
532
|
const { source } = await resolveTicketCommand(opts, 'move')
|
|
@@ -312,6 +538,108 @@ export async function abTicketMove(opts: TicketMoveOpts): Promise<void> {
|
|
|
312
538
|
opts.stdout(`ticket moved: ${ticketSummary(moved)}`)
|
|
313
539
|
}
|
|
314
540
|
|
|
541
|
+
export type TicketStateRole = 'create' | 'ready' | 'claimed' | 'triage' | 'proposal' | 'done'
|
|
542
|
+
|
|
543
|
+
export interface TicketStateRow extends TicketStateInfo {
|
|
544
|
+
roles: TicketStateRole[]
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The lifecycle roles each state plays under `config`, for a source whose
|
|
549
|
+
* states are directories (names match case-insensitively, as `ab ticket move`
|
|
550
|
+
* does). The file adapter always claims into `doing`, so that is shown as
|
|
551
|
+
* claimed whatever `[tickets].claimedState` says. Roles naming no existing
|
|
552
|
+
* state are omitted.
|
|
553
|
+
*/
|
|
554
|
+
export function stateRoles(
|
|
555
|
+
tickets: TicketsConfig,
|
|
556
|
+
names: string[],
|
|
557
|
+
): Map<string, TicketStateRole[]> {
|
|
558
|
+
const triage = tickets.triageState ?? 'triage'
|
|
559
|
+
const named: Array<[TicketStateRole, string]> = [
|
|
560
|
+
['create', tickets.createState ?? 'triage'],
|
|
561
|
+
['ready', tickets.readyState],
|
|
562
|
+
['claimed', 'doing'],
|
|
563
|
+
['triage', triage],
|
|
564
|
+
['proposal', tickets.proposalState ?? triage],
|
|
565
|
+
['done', 'done'],
|
|
566
|
+
]
|
|
567
|
+
const roles = new Map<string, TicketStateRole[]>(names.map((name) => [name, []]))
|
|
568
|
+
for (const [role, target] of named) {
|
|
569
|
+
const match =
|
|
570
|
+
names.find((name) => name === target) ??
|
|
571
|
+
names.find((name) => name.toLowerCase() === target.toLowerCase())
|
|
572
|
+
if (match !== undefined) roles.get(match)?.push(role)
|
|
573
|
+
}
|
|
574
|
+
return roles
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function stateRow(info: TicketStateInfo, roles: Map<string, TicketStateRole[]>): TicketStateRow {
|
|
578
|
+
return {
|
|
579
|
+
name: info.name,
|
|
580
|
+
roles: roles.get(info.name) ?? [],
|
|
581
|
+
tickets: info.tickets,
|
|
582
|
+
...(info.about !== undefined ? { about: info.about } : {}),
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function unsupported(source: TicketSource, operation: string): Error {
|
|
587
|
+
return new Error(`ticket source "${source.name}" does not support ${operation}`)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** `ab ticket states` — every state of the source, its roles, counts, purpose. */
|
|
591
|
+
export async function abTicketStates(opts: TicketStatesOpts): Promise<void> {
|
|
592
|
+
const { config, source } = await resolveTicketCommand(opts, 'states')
|
|
593
|
+
if (typeof source.listStates !== 'function') {
|
|
594
|
+
throw unsupported(source, 'state discovery (ab ticket states)')
|
|
595
|
+
}
|
|
596
|
+
const infos = await source.listStates()
|
|
597
|
+
const roles = stateRoles(
|
|
598
|
+
config.tickets,
|
|
599
|
+
infos.map((info) => info.name),
|
|
600
|
+
)
|
|
601
|
+
const rows = infos.map((info) => stateRow(info, roles))
|
|
602
|
+
if (opts.json === true) {
|
|
603
|
+
opts.stdout(JSON.stringify(rows, null, 2))
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
const nameWidth = Math.max(...rows.map((row) => row.name.length))
|
|
607
|
+
const roleText = rows.map((row) => (row.roles.length > 0 ? row.roles.join(',') : 'none'))
|
|
608
|
+
const roleWidth = Math.max(...roleText.map((text) => text.length))
|
|
609
|
+
rows.forEach((row, index) => {
|
|
610
|
+
const count = `${row.tickets} ${row.tickets === 1 ? 'ticket' : 'tickets'}`
|
|
611
|
+
const line = `${row.name.padEnd(nameWidth)} ${(roleText[index] as string).padEnd(roleWidth)} ${count}`
|
|
612
|
+
opts.stdout(row.about === undefined ? line : `${line} ${row.about}`)
|
|
613
|
+
})
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** `ab ticket state create <name> [--about <text>]` — idempotent. */
|
|
617
|
+
export async function abTicketStateCreate(opts: TicketStateCreateOpts): Promise<void> {
|
|
618
|
+
const { config, source } = await resolveTicketCommand(opts, 'state create')
|
|
619
|
+
if (typeof source.addState !== 'function') {
|
|
620
|
+
throw unsupported(source, 'state creation (ab ticket state create)')
|
|
621
|
+
}
|
|
622
|
+
const aboutBefore =
|
|
623
|
+
opts.about === undefined
|
|
624
|
+
? undefined
|
|
625
|
+
: (await source.listStates?.())?.find((info) => info.name === opts.name)?.about
|
|
626
|
+
const { state, created } = await source.addState(opts.name, {
|
|
627
|
+
...(opts.about !== undefined ? { about: opts.about } : {}),
|
|
628
|
+
})
|
|
629
|
+
const names = (await source.listStates?.())?.map((info) => info.name) ?? [state.name]
|
|
630
|
+
const row = stateRow(state, stateRoles(config.tickets, names))
|
|
631
|
+
if (opts.json === true) {
|
|
632
|
+
opts.stdout(JSON.stringify({ ...row, created }, null, 2))
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
const purposeChanged = !created && opts.about !== undefined && state.about !== aboutBefore
|
|
636
|
+
opts.stdout(
|
|
637
|
+
created
|
|
638
|
+
? `ticket state created: ${state.name}`
|
|
639
|
+
: `ticket state already exists: ${state.name}${purposeChanged ? ' (purpose updated)' : ''}`,
|
|
640
|
+
)
|
|
641
|
+
}
|
|
642
|
+
|
|
315
643
|
const CREATE_USAGE =
|
|
316
644
|
'usage: ab ticket create <title> --body <file> [--state <state>] [--labels a,b] [--blocked-by id,id] [--json] (§8.8)'
|
|
317
645
|
const UPDATE_USAGE =
|
|
@@ -319,8 +647,16 @@ const UPDATE_USAGE =
|
|
|
319
647
|
const BLOCK_USAGE = 'usage: ab ticket block <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
|
|
320
648
|
const UNBLOCK_USAGE = 'usage: ab ticket unblock <id> <blocker-id[,blocker-id...]> [--json] (§8.8)'
|
|
321
649
|
const LIST_USAGE = 'usage: ab ticket list [--state <state>] [--labels a,b] [--json] (§8.8)'
|
|
322
|
-
const SHOW_USAGE = 'usage: ab ticket show <id> [--json] (§8.8)'
|
|
650
|
+
const SHOW_USAGE = 'usage: ab ticket show <id> [--store <ref>] [--json] (§8.8)'
|
|
651
|
+
const ATTACH_USAGE =
|
|
652
|
+
'usage: ab ticket attach <id> <kind> <path> [--name <name>] [--store <ref>] [--json] (§8.8)'
|
|
653
|
+
const ASSET_GET_USAGE =
|
|
654
|
+
'usage: ab ticket asset get <id> <kind> <name> <dest> [--rev <n>] [--store <ref>] [--json] (§8.8)'
|
|
655
|
+
const ASSET_RM_USAGE =
|
|
656
|
+
'usage: ab ticket asset rm <id> <kind> <name> [--store <ref>] [--json] (§8.8)'
|
|
323
657
|
const MOVE_USAGE = 'usage: ab ticket move <id> <state> [--json] (§8.8)'
|
|
658
|
+
const STATES_USAGE = 'usage: ab ticket states [--json] (§8.8)'
|
|
659
|
+
const STATE_CREATE_USAGE = 'usage: ab ticket state create <name> [--about <text>] [--json] (§8.8)'
|
|
324
660
|
export const TICKET_USAGE = [
|
|
325
661
|
CREATE_USAGE,
|
|
326
662
|
UPDATE_USAGE,
|
|
@@ -329,6 +665,11 @@ export const TICKET_USAGE = [
|
|
|
329
665
|
LIST_USAGE,
|
|
330
666
|
SHOW_USAGE,
|
|
331
667
|
MOVE_USAGE,
|
|
668
|
+
STATES_USAGE,
|
|
669
|
+
STATE_CREATE_USAGE,
|
|
670
|
+
ATTACH_USAGE,
|
|
671
|
+
ASSET_GET_USAGE,
|
|
672
|
+
ASSET_RM_USAGE,
|
|
332
673
|
].join('\n')
|
|
333
674
|
|
|
334
675
|
function commaList(value: string): string[] {
|
|
@@ -444,14 +785,118 @@ export async function abTicket(argv: string[], opts: TicketCliOpts): Promise<voi
|
|
|
444
785
|
}
|
|
445
786
|
|
|
446
787
|
case 'show': {
|
|
447
|
-
const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
|
|
788
|
+
const parsed = parseArgs(args, { json: 'boolean', store: 'value' }, TICKET_USAGE)
|
|
448
789
|
const [id, ...extra] = parsed.positionals
|
|
449
790
|
if (id === undefined || id.trim() === '' || extra.length > 0) {
|
|
450
791
|
throw new Error(TICKET_USAGE)
|
|
451
792
|
}
|
|
793
|
+
const store = stringFlag(parsed, 'store')
|
|
452
794
|
await abTicketShow({
|
|
453
795
|
...opts,
|
|
796
|
+
...(store !== undefined ? { storeRef: store } : {}),
|
|
797
|
+
id,
|
|
798
|
+
json: parsed.flags.has('json'),
|
|
799
|
+
})
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
case 'attach': {
|
|
804
|
+
const parsed = parseArgs(
|
|
805
|
+
args,
|
|
806
|
+
{ name: 'value', store: 'value', json: 'boolean' },
|
|
807
|
+
TICKET_USAGE,
|
|
808
|
+
)
|
|
809
|
+
const [id, kind, path, ...extra] = parsed.positionals
|
|
810
|
+
if (!id?.trim() || !kind?.trim() || !path?.trim() || extra.length > 0) {
|
|
811
|
+
throw new Error(TICKET_USAGE)
|
|
812
|
+
}
|
|
813
|
+
const name = stringFlag(parsed, 'name')
|
|
814
|
+
if (name !== undefined && name.trim() === '') throw new Error(TICKET_USAGE)
|
|
815
|
+
const store = stringFlag(parsed, 'store')
|
|
816
|
+
await abTicketAttach({
|
|
817
|
+
...opts,
|
|
818
|
+
...(store !== undefined ? { storeRef: store } : {}),
|
|
454
819
|
id,
|
|
820
|
+
kind,
|
|
821
|
+
path,
|
|
822
|
+
...(name !== undefined ? { name } : {}),
|
|
823
|
+
json: parsed.flags.has('json'),
|
|
824
|
+
})
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
case 'asset': {
|
|
829
|
+
const [action, ...assetArgs] = args
|
|
830
|
+
if (action === 'get') {
|
|
831
|
+
const parsed = parseArgs(
|
|
832
|
+
assetArgs,
|
|
833
|
+
{ rev: 'value', store: 'value', json: 'boolean' },
|
|
834
|
+
TICKET_USAGE,
|
|
835
|
+
)
|
|
836
|
+
const [id, kind, name, dest, ...extra] = parsed.positionals
|
|
837
|
+
if (!id?.trim() || !kind?.trim() || !name?.trim() || !dest?.trim() || extra.length > 0) {
|
|
838
|
+
throw new Error(TICKET_USAGE)
|
|
839
|
+
}
|
|
840
|
+
const rawRev = stringFlag(parsed, 'rev')
|
|
841
|
+
if (rawRev !== undefined && !/^\d+$/.test(rawRev)) {
|
|
842
|
+
throw new Error(
|
|
843
|
+
`--rev must be a nonnegative integer, got "${rawRev}" — ${ASSET_GET_USAGE}`,
|
|
844
|
+
)
|
|
845
|
+
}
|
|
846
|
+
const store = stringFlag(parsed, 'store')
|
|
847
|
+
await abTicketAssetGet({
|
|
848
|
+
...opts,
|
|
849
|
+
...(store !== undefined ? { storeRef: store } : {}),
|
|
850
|
+
id,
|
|
851
|
+
kind,
|
|
852
|
+
name,
|
|
853
|
+
dest,
|
|
854
|
+
...(rawRev !== undefined ? { rev: Number(rawRev) } : {}),
|
|
855
|
+
json: parsed.flags.has('json'),
|
|
856
|
+
})
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
if (action === 'rm') {
|
|
860
|
+
const parsed = parseArgs(assetArgs, { store: 'value', json: 'boolean' }, TICKET_USAGE)
|
|
861
|
+
const [id, kind, name, ...extra] = parsed.positionals
|
|
862
|
+
if (!id?.trim() || !kind?.trim() || !name?.trim() || extra.length > 0) {
|
|
863
|
+
throw new Error(TICKET_USAGE)
|
|
864
|
+
}
|
|
865
|
+
const store = stringFlag(parsed, 'store')
|
|
866
|
+
await abTicketAssetRm({
|
|
867
|
+
...opts,
|
|
868
|
+
...(store !== undefined ? { storeRef: store } : {}),
|
|
869
|
+
id,
|
|
870
|
+
kind,
|
|
871
|
+
name,
|
|
872
|
+
json: parsed.flags.has('json'),
|
|
873
|
+
})
|
|
874
|
+
return
|
|
875
|
+
}
|
|
876
|
+
throw new Error(TICKET_USAGE)
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
case 'states': {
|
|
880
|
+
const parsed = parseArgs(args, { json: 'boolean' }, TICKET_USAGE)
|
|
881
|
+
if (parsed.positionals.length !== 0) throw new Error(STATES_USAGE)
|
|
882
|
+
await abTicketStates({ ...opts, json: parsed.flags.has('json') })
|
|
883
|
+
return
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
case 'state': {
|
|
887
|
+
const [action, ...stateArgs] = args
|
|
888
|
+
if (action !== 'create') throw new Error(STATE_CREATE_USAGE)
|
|
889
|
+
const parsed = parseArgs(stateArgs, { about: 'value', json: 'boolean' }, TICKET_USAGE)
|
|
890
|
+
const [name, ...extra] = parsed.positionals
|
|
891
|
+
if (name === undefined || name.trim() === '' || extra.length > 0) {
|
|
892
|
+
throw new Error(STATE_CREATE_USAGE)
|
|
893
|
+
}
|
|
894
|
+
const about = stringFlag(parsed, 'about')
|
|
895
|
+
if (about !== undefined && about.trim() === '') throw new Error(STATE_CREATE_USAGE)
|
|
896
|
+
await abTicketStateCreate({
|
|
897
|
+
...opts,
|
|
898
|
+
name,
|
|
899
|
+
...(about !== undefined ? { about } : {}),
|
|
455
900
|
json: parsed.flags.has('json'),
|
|
456
901
|
})
|
|
457
902
|
return
|