@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.
- package/README.md +3 -1
- package/SPEC.md +335 -31
- package/docs/architecture.md +125 -7
- package/docs/configuration.md +142 -21
- package/docs/hosted-dispatcher.md +17 -2
- package/docs/hosted-service.md +240 -0
- package/docs/mcp.md +8 -2
- package/docs/operator-api.md +97 -7
- package/docs/remote-store-protocol.md +199 -144
- package/docs/remote-ticket-protocol.md +249 -19
- package/docs/setup.md +199 -16
- package/docs/ticket-dependencies.md +1 -1
- package/docs/ticket-migration.md +64 -0
- package/package.json +7 -6
- package/packages/core/src/cli/attention.ts +209 -0
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/configured-store.ts +17 -0
- package/packages/core/src/cli/connect.ts +364 -0
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/model.ts +4 -5
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +336 -41
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +51 -16
- package/packages/core/src/cli/init-validation.ts +27 -2
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +60 -16
- package/packages/core/src/cli/mcp.ts +9 -2
- package/packages/core/src/cli/pr-summary-publication.ts +220 -0
- package/packages/core/src/cli/pr-summary.ts +2 -0
- package/packages/core/src/cli/project-credentials.ts +132 -0
- package/packages/core/src/cli/repo-state.ts +15 -2
- package/packages/core/src/cli/self-update.ts +15 -1
- package/packages/core/src/cli/store-opening.ts +22 -14
- package/packages/core/src/cli/terminals.ts +0 -10
- package/packages/core/src/cli/ticket-assets.ts +7 -2
- package/packages/core/src/cli/ticket-editor.ts +82 -0
- package/packages/core/src/cli/ticket.ts +172 -35
- package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
- package/packages/core/src/cli/wait.ts +84 -17
- package/packages/core/src/cli/watch.ts +197 -60
- package/packages/core/src/config/live.ts +3 -0
- package/packages/core/src/config/schema.ts +90 -11
- package/packages/core/src/connection.ts +86 -0
- package/packages/core/src/events/catalog.ts +11 -2
- package/packages/core/src/events/payloads.ts +50 -0
- package/packages/core/src/events/repository.ts +13 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
- package/packages/core/src/kernel/engine.ts +24 -0
- package/packages/core/src/kernel/generators/build-log.ts +11 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/log-index.ts +17 -0
- package/packages/core/src/kernel/pr-summary.ts +89 -0
- package/packages/core/src/kernel/reducer-registry.ts +10 -0
- package/packages/core/src/kernel/reducer.ts +8 -0
- package/packages/core/src/ontology.ts +2 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +12 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +38 -16
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -20
- package/packages/core/src/operator/tickets.ts +136 -28
- package/packages/core/src/plugin-sdk/index.ts +7 -0
- package/packages/core/src/plugins/manifest.ts +5 -3
- package/packages/core/src/plugins/runtimes.ts +7 -1
- package/packages/core/src/ports/forge/fake.ts +13 -1
- package/packages/core/src/ports/forge/github-transport.ts +36 -1
- package/packages/core/src/ports/forge/github.ts +29 -5
- package/packages/core/src/ports/forge/local-git.ts +12 -4
- package/packages/core/src/ports/forge/summary-marker.ts +8 -0
- package/packages/core/src/ports/runner/session-env.ts +3 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +54 -10
- package/packages/core/src/ports/tickets/fake.ts +30 -1
- package/packages/core/src/ports/tickets/file-assets.ts +159 -0
- package/packages/core/src/ports/tickets/file-codec.ts +90 -0
- package/packages/core/src/ports/tickets/file.ts +114 -120
- package/packages/core/src/ports/tickets/grants.ts +47 -0
- package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
- package/packages/core/src/ports/tickets/identity.ts +20 -0
- package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
- package/packages/core/src/ports/tickets/linear.ts +74 -9
- package/packages/core/src/ports/tickets/migration.ts +336 -0
- package/packages/core/src/ports/tickets/operations.ts +38 -7
- package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
- package/packages/core/src/ports/tickets/remote.ts +61 -3
- package/packages/core/src/ports/tickets/requested-id.ts +46 -0
- package/packages/core/src/ports/tickets/revision.ts +62 -0
- package/packages/core/src/ports/tickets/states.ts +61 -0
- package/packages/core/src/ports/types.ts +40 -3
- package/packages/core/src/ports/workspace/build-execution.ts +6 -1
- package/packages/core/src/ports/workspace/create.ts +15 -2
- package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
- package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
- package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
- package/packages/core/src/processes/build-execution-state.ts +1 -1
- package/packages/core/src/processes/build-runner.ts +87 -26
- package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
- package/packages/core/src/processes/dispatcher.ts +168 -41
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +31 -4
- package/packages/core/src/processes/harvest-execution-state.ts +19 -2
- package/packages/core/src/processes/harvest-runner.ts +166 -104
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +2 -0
- package/packages/core/src/processes/publication-loss.ts +18 -1
- package/packages/core/src/processes/repo-view.ts +35 -0
- package/packages/core/src/store/contract.ts +32 -1
- package/packages/core/src/store/local/store.ts +1 -1
- package/packages/core/src/store/remote/client.ts +1 -1
- package/packages/core/src/store/remote/compatibility.ts +90 -0
- package/packages/core/src/store/remote/credentials.ts +28 -0
- package/packages/core/src/store/remote/index.ts +14 -1
- package/packages/core/src/store/remote/protocol.ts +25 -0
- package/packages/core/src/store/remote/token.ts +74 -13
- package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
- package/packages/core/src/store/ticket-assets.ts +86 -8
- package/packages/core/src/store/types.ts +1 -1
- package/packages/core/src/testing/index.ts +7 -0
- package/skills/guide/SKILL.md +232 -34
- package/skills/guide/references/operator-api.md +142 -13
- package/skills/guide/references/plugin-authoring.md +10 -2
- package/skills/guide/references/remote-store-protocol.md +199 -144
- package/skills/guide/references/remote-ticket-protocol.md +253 -18
- package/skills/guide/references/setup.md +199 -16
- package/skills/guide/references/ticket-dependencies.md +1 -1
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/operate/SKILL.md +126 -36
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +13 -0
- 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 {
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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<
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
484
|
-
|
|
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
|
|
508
|
-
|
|
509
|
-
|
|
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(
|
|
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>]` —
|
|
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 =
|
|
648
|
-
|
|
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 =
|
|
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
|
-
{
|
|
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(
|
|
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 = {
|
|
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(
|
|
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
|
+
}
|