@defrex/autobuild 0.12.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 +1 -1
- package/SPEC.md +119 -15
- package/docs/architecture.md +59 -3
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +7 -0
- package/docs/remote-store-protocol.md +10 -0
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +3 -3
- package/packages/core/src/cli/build-control.ts +12 -1
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +7 -1
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch.ts +156 -3
- package/packages/core/src/cli/harvest.ts +13 -3
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +20 -2
- package/packages/core/src/cli/mcp.ts +2 -1
- package/packages/core/src/cli/self-update.ts +5 -1
- 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 +164 -34
- package/packages/core/src/config/schema.ts +35 -3
- package/packages/core/src/connection.ts +3 -3
- package/packages/core/src/events/catalog.ts +3 -2
- package/packages/core/src/events/payloads.ts +6 -0
- package/packages/core/src/integration/harness.ts +37 -2
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +15 -0
- package/packages/core/src/kernel/generators/build-log.ts +1 -1
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/reducer-registry.ts +2 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/index.ts +5 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +130 -20
- package/packages/core/src/plugin-sdk/index.ts +6 -0
- package/packages/core/src/plugins/runtimes.ts +5 -1
- package/packages/core/src/ports/runner/session-env.ts +2 -0
- package/packages/core/src/ports/tickets/contract.ts +66 -1
- package/packages/core/src/ports/tickets/create.ts +48 -12
- 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 -0
- 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 +95 -0
- package/packages/core/src/ports/tickets/remote.ts +60 -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 +1 -0
- package/packages/core/src/ports/workspace/create.ts +5 -1
- package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +8 -4
- package/packages/core/src/processes/dispatcher.ts +110 -32
- package/packages/core/src/processes/execution-no-progress.ts +176 -0
- package/packages/core/src/processes/harvest-child.ts +30 -4
- package/packages/core/src/processes/harvest-runner.ts +24 -4
- package/packages/core/src/processes/harvest.ts +34 -14
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- 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/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/skills/guide/SKILL.md +91 -11
- package/skills/guide/references/operator-api.md +7 -0
- package/skills/guide/references/remote-store-protocol.md +10 -0
- package/skills/guide/references/remote-ticket-protocol.md +151 -9
- package/skills/guide/references/setup.md +33 -7
- package/skills/guide/references/ticket-migration.md +64 -0
- package/skills/tickets/SKILL.md +71 -0
- package/templates/hosted-service/package.json +2 -2
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { validateTicketAssetInput } from '../../store/ticket-assets'
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import type { Ticket, TicketSource, TicketAssetStore } from '../types'
|
|
6
|
+
import { FileTicketSource } from './file'
|
|
7
|
+
import { parseTicketFile, serializeTicketFile } from './file-codec'
|
|
8
|
+
import { validateFileTicketId } from './requested-id'
|
|
9
|
+
import { normalizeStateName } from './states'
|
|
10
|
+
import { encodePortableAsset, decodePortableAsset, type PortableAsset } from './portable-assets'
|
|
11
|
+
|
|
12
|
+
const MANIFEST = '.ab-migration.json'
|
|
13
|
+
export const IMPORT_MAP = '.ab-import-map.json'
|
|
14
|
+
const manifestSchema = z.strictObject({
|
|
15
|
+
version: z.literal(1),
|
|
16
|
+
identity: z.string().min(1),
|
|
17
|
+
source: z.string(),
|
|
18
|
+
keys: z.record(z.string(), z.string().min(1)),
|
|
19
|
+
states: z.record(z.string(), z.string()),
|
|
20
|
+
})
|
|
21
|
+
type Manifest = z.infer<typeof manifestSchema>
|
|
22
|
+
async function atomicJson(path: string, value: unknown) {
|
|
23
|
+
const temp = `${path}.${crypto.randomUUID()}.tmp`
|
|
24
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`)
|
|
25
|
+
await rename(temp, path)
|
|
26
|
+
}
|
|
27
|
+
async function inventory(source: TicketSource): Promise<Ticket[]> {
|
|
28
|
+
const listing = await source.listReady({})
|
|
29
|
+
if (listing.diagnostics.length)
|
|
30
|
+
throw new Error(`incomplete ticket scan:\n${listing.diagnostics.join('\n')}`)
|
|
31
|
+
const ids = new Set<string>()
|
|
32
|
+
const tickets: Ticket[] = []
|
|
33
|
+
for (const ticket of listing.tickets) {
|
|
34
|
+
if (ids.has(ticket.ref.id)) throw new Error(`duplicate ticket id: ${ticket.ref.id}`)
|
|
35
|
+
ids.add(ticket.ref.id)
|
|
36
|
+
const detail = await source.get(ticket.ref.id)
|
|
37
|
+
if (!detail) throw new Error(`ticket disappeared: ${ticket.ref.id}`)
|
|
38
|
+
if (!detail.state) throw new Error(`ticket has no state: ${ticket.ref.id}`)
|
|
39
|
+
tickets.push(detail)
|
|
40
|
+
}
|
|
41
|
+
return tickets
|
|
42
|
+
}
|
|
43
|
+
function uniqueStateMap(names: string[], map: (name: string) => string): Record<string, string> {
|
|
44
|
+
const result: Record<string, string> = Object.create(null)
|
|
45
|
+
const groups = new Map<string, string[]>()
|
|
46
|
+
for (const name of new Set(names)) {
|
|
47
|
+
const target = map(name)
|
|
48
|
+
result[name] = target
|
|
49
|
+
groups.set(target, [...(groups.get(target) ?? []), name])
|
|
50
|
+
}
|
|
51
|
+
const collisions = [...groups].filter(([, originals]) => originals.length > 1)
|
|
52
|
+
if (collisions.length)
|
|
53
|
+
throw new Error(
|
|
54
|
+
`state collisions: ${collisions.map(([target, originals]) => `${originals.join(', ')} → ${target}`).join('; ')}`,
|
|
55
|
+
)
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
async function currentAssets(
|
|
59
|
+
store: TicketAssetStore | undefined,
|
|
60
|
+
repo: string,
|
|
61
|
+
id: string,
|
|
62
|
+
): Promise<PortableAsset[]> {
|
|
63
|
+
if (!store) return []
|
|
64
|
+
const result: PortableAsset[] = []
|
|
65
|
+
for (const summary of await store.listTicketAssets(repo, id)) {
|
|
66
|
+
const asset = await store.getTicketAsset(repo, id, summary.kind, summary.name, summary.revision)
|
|
67
|
+
if (!asset) throw new Error(`asset disappeared: ${id} ${summary.kind}/${summary.name}`)
|
|
68
|
+
result.push(encodePortableAsset({ ...asset.meta, entries: asset.entries }))
|
|
69
|
+
}
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
export interface MigrationOptions {
|
|
73
|
+
source: TicketSource
|
|
74
|
+
dir: string
|
|
75
|
+
repo: string
|
|
76
|
+
assets?: TicketAssetStore
|
|
77
|
+
print?: (line: string) => void
|
|
78
|
+
}
|
|
79
|
+
/** Publish a complete portable tracker only after every source read succeeds. */
|
|
80
|
+
export async function exportTickets(opts: MigrationOptions): Promise<void> {
|
|
81
|
+
const destination = resolve(opts.dir)
|
|
82
|
+
try {
|
|
83
|
+
if ((await readdir(destination)).length)
|
|
84
|
+
throw new Error(`export destination is not empty: ${destination}`)
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
87
|
+
}
|
|
88
|
+
const tickets = await inventory(opts.source)
|
|
89
|
+
const names = [
|
|
90
|
+
...tickets.map((ticket) => ticket.state!),
|
|
91
|
+
...((await opts.source.listStates?.()) ?? []).map((state) => state.name),
|
|
92
|
+
]
|
|
93
|
+
const states = uniqueStateMap(names, normalizeStateName)
|
|
94
|
+
const identity = crypto.randomUUID()
|
|
95
|
+
const manifest: Manifest = {
|
|
96
|
+
version: 1,
|
|
97
|
+
identity,
|
|
98
|
+
source: `${opts.source.name}:${opts.repo}`,
|
|
99
|
+
keys: Object.create(null),
|
|
100
|
+
states,
|
|
101
|
+
}
|
|
102
|
+
const records = []
|
|
103
|
+
for (const ticket of tickets) {
|
|
104
|
+
validateFileTicketId(ticket.ref.id)
|
|
105
|
+
const key = ticket.creationKey ?? `migration:${identity}:${ticket.ref.id}`
|
|
106
|
+
manifest.keys[ticket.ref.id] = key
|
|
107
|
+
records.push({
|
|
108
|
+
state: states[ticket.state!]!,
|
|
109
|
+
id: ticket.ref.id,
|
|
110
|
+
raw: serializeTicketFile(
|
|
111
|
+
{
|
|
112
|
+
id: ticket.ref.id,
|
|
113
|
+
title: ticket.title,
|
|
114
|
+
labels: ticket.labels,
|
|
115
|
+
blockedBy: ticket.blockedBy,
|
|
116
|
+
idempotencyKey: key,
|
|
117
|
+
...(ticket.comments !== undefined ? { comments: ticket.comments } : {}),
|
|
118
|
+
attachments: await currentAssets(
|
|
119
|
+
opts.assets ?? opts.source.assets,
|
|
120
|
+
opts.repo,
|
|
121
|
+
ticket.ref.id,
|
|
122
|
+
),
|
|
123
|
+
},
|
|
124
|
+
ticket.body,
|
|
125
|
+
),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
await mkdir(dirname(destination), { recursive: true })
|
|
129
|
+
const staging = `${destination}.${identity}.tmp`
|
|
130
|
+
await mkdir(staging)
|
|
131
|
+
try {
|
|
132
|
+
for (const state of Object.values(states))
|
|
133
|
+
await mkdir(join(staging, state), { recursive: true })
|
|
134
|
+
for (const record of records)
|
|
135
|
+
await writeFile(join(staging, record.state, `${record.id}.md`), record.raw, { flag: 'wx' })
|
|
136
|
+
await atomicJson(join(staging, MANIFEST), manifest)
|
|
137
|
+
// Recheck immediately before publication; never remove a populated destination.
|
|
138
|
+
try {
|
|
139
|
+
await rmdir(destination)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
142
|
+
}
|
|
143
|
+
await rename(staging, destination)
|
|
144
|
+
} finally {
|
|
145
|
+
await rm(staging, { recursive: true, force: true })
|
|
146
|
+
}
|
|
147
|
+
opts.print?.(`exported ${tickets.length} tickets to ${destination}`)
|
|
148
|
+
}
|
|
149
|
+
function matchState(name: string, existing: string[]): string | undefined {
|
|
150
|
+
if (existing.includes(name)) return name
|
|
151
|
+
const matches = existing.filter((value) => value.toLowerCase() === name.toLowerCase())
|
|
152
|
+
if (matches.length > 1) throw new Error(`ambiguous target state ${name}: ${matches.join(', ')}`)
|
|
153
|
+
return matches[0]
|
|
154
|
+
}
|
|
155
|
+
/** Resume through creation keys, never through content similarity or mapping files. */
|
|
156
|
+
export async function importTickets(opts: MigrationOptions): Promise<void> {
|
|
157
|
+
const policy = opts.source.requestedIds
|
|
158
|
+
if (!policy || !opts.source.listStates || !opts.source.addState)
|
|
159
|
+
throw new Error(`ticket source ${opts.source.name} does not support import`)
|
|
160
|
+
const dir = resolve(opts.dir)
|
|
161
|
+
const input = new FileTicketSource({ dir })
|
|
162
|
+
const tickets = await inventory(input)
|
|
163
|
+
const bundles = new Map<string, PortableAsset[]>()
|
|
164
|
+
for (const ticket of tickets) {
|
|
165
|
+
const path = join(dir, ticket.state!, `${ticket.ref.id}.md`)
|
|
166
|
+
bundles.set(
|
|
167
|
+
ticket.ref.id,
|
|
168
|
+
parseTicketFile(path, await readFile(path, 'utf8')).front.attachments ?? [],
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
let manifest: Manifest
|
|
172
|
+
try {
|
|
173
|
+
manifest = manifestSchema.parse(JSON.parse(await readFile(join(dir, MANIFEST), 'utf8')))
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
176
|
+
manifest = {
|
|
177
|
+
version: 1,
|
|
178
|
+
identity: crypto.randomUUID(),
|
|
179
|
+
source: 'file',
|
|
180
|
+
keys: Object.create(null),
|
|
181
|
+
states: {},
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const sourceIds = new Set(tickets.map((ticket) => ticket.ref.id))
|
|
185
|
+
const keys = new Set<string>()
|
|
186
|
+
for (const ticket of tickets) {
|
|
187
|
+
for (const blocker of ticket.blockedBy ?? [])
|
|
188
|
+
if (!sourceIds.has(blocker) || blocker === ticket.ref.id)
|
|
189
|
+
throw new Error(`invalid blocker ${ticket.ref.id} → ${blocker}`)
|
|
190
|
+
const key =
|
|
191
|
+
ticket.creationKey ??
|
|
192
|
+
manifest.keys[ticket.ref.id] ??
|
|
193
|
+
`migration:${manifest.identity}:${ticket.ref.id}`
|
|
194
|
+
if (keys.has(key)) throw new Error(`duplicate creation key: ${key}`)
|
|
195
|
+
keys.add(key)
|
|
196
|
+
manifest.keys[ticket.ref.id] = key
|
|
197
|
+
}
|
|
198
|
+
const target = await inventory(opts.source)
|
|
199
|
+
const byKey = new Map<string, Ticket>()
|
|
200
|
+
for (const ticket of target)
|
|
201
|
+
if (ticket.creationKey) {
|
|
202
|
+
if (byKey.has(ticket.creationKey))
|
|
203
|
+
throw new Error(`ambiguous target creation key: ${ticket.creationKey}`)
|
|
204
|
+
byKey.set(ticket.creationKey, ticket)
|
|
205
|
+
}
|
|
206
|
+
const existingStates = (await opts.source.listStates()).map((state) => state.name)
|
|
207
|
+
// Only actual input directories participate; virtual lifecycle defaults are harmless empty states.
|
|
208
|
+
const inputStates: string[] = []
|
|
209
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
210
|
+
if (
|
|
211
|
+
!entry.name.startsWith('.') &&
|
|
212
|
+
(entry.isDirectory() ||
|
|
213
|
+
(entry.isSymbolicLink() && (await stat(join(dir, entry.name))).isDirectory()))
|
|
214
|
+
)
|
|
215
|
+
inputStates.push(entry.name)
|
|
216
|
+
}
|
|
217
|
+
const states = uniqueStateMap(
|
|
218
|
+
inputStates,
|
|
219
|
+
(name) =>
|
|
220
|
+
matchState(name, existingStates) ??
|
|
221
|
+
matchState(normalizeStateName(name), existingStates) ??
|
|
222
|
+
normalizeStateName(name),
|
|
223
|
+
)
|
|
224
|
+
const planned = new Map<string, string>()
|
|
225
|
+
const occupied = target.map((ticket) => ticket.ref.id)
|
|
226
|
+
const clashes: string[] = []
|
|
227
|
+
for (const ticket of tickets) {
|
|
228
|
+
const adopted = byKey.get(manifest.keys[ticket.ref.id]!)
|
|
229
|
+
if (adopted) {
|
|
230
|
+
planned.set(ticket.ref.id, adopted.ref.id)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const kept = policy.preserve(ticket.ref.id)
|
|
234
|
+
if (kept !== undefined) {
|
|
235
|
+
if (occupied.includes(kept)) clashes.push(`${ticket.ref.id} → ${kept}`)
|
|
236
|
+
planned.set(ticket.ref.id, kept)
|
|
237
|
+
occupied.push(kept)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (clashes.length)
|
|
241
|
+
throw new Error(`ticket id clashes (no target changes):\n${clashes.join('\n')}`)
|
|
242
|
+
for (const ticket of tickets)
|
|
243
|
+
if (!planned.has(ticket.ref.id)) {
|
|
244
|
+
const id = policy.allocate(occupied)
|
|
245
|
+
planned.set(ticket.ref.id, id)
|
|
246
|
+
occupied.push(id)
|
|
247
|
+
}
|
|
248
|
+
const assets = opts.assets ?? opts.source.assets
|
|
249
|
+
if ([...bundles.values()].some((value) => value.length) && !assets)
|
|
250
|
+
throw new Error('target has no attachment capability')
|
|
251
|
+
if (assets && [...bundles.values()].some((value) => value.length)) {
|
|
252
|
+
const limits = await assets.ticketAssetLimits(opts.repo)
|
|
253
|
+
for (const values of bundles.values())
|
|
254
|
+
for (const bundle of values) validateTicketAssetInput(decodePortableAsset(bundle), limits)
|
|
255
|
+
}
|
|
256
|
+
for (const ticket of tickets) {
|
|
257
|
+
const adopted = byKey.get(manifest.keys[ticket.ref.id]!)
|
|
258
|
+
if ((ticket.comments?.length ?? 0) > 0 && adopted && adopted.comments === undefined)
|
|
259
|
+
throw new Error(`target cannot read comment history: ${adopted.ref.id}`)
|
|
260
|
+
}
|
|
261
|
+
// Persist identities before the first target write, including for ordinary layouts.
|
|
262
|
+
await atomicJson(join(dir, MANIFEST), manifest)
|
|
263
|
+
const mapping: Record<string, string> = Object.create(null)
|
|
264
|
+
const mappingPath = join(dir, IMPORT_MAP)
|
|
265
|
+
const saveMap = () =>
|
|
266
|
+
atomicJson(mappingPath, { version: 1, identity: manifest.identity, states, tickets: mapping })
|
|
267
|
+
await saveMap()
|
|
268
|
+
try {
|
|
269
|
+
for (const state of new Set(Object.values(states)))
|
|
270
|
+
if (!existingStates.includes(state)) {
|
|
271
|
+
try {
|
|
272
|
+
await opts.source.addState(state)
|
|
273
|
+
} catch (error) {
|
|
274
|
+
const discovered = matchState(
|
|
275
|
+
state,
|
|
276
|
+
(await opts.source.listStates()).map((item) => item.name),
|
|
277
|
+
)
|
|
278
|
+
if (!discovered) throw error
|
|
279
|
+
for (const original of Object.keys(states))
|
|
280
|
+
if (states[original] === state) states[original] = discovered
|
|
281
|
+
await saveMap()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const [from, to] of Object.entries(states)) opts.print?.(`state ${from} → ${to}`)
|
|
285
|
+
for (const ticket of tickets) {
|
|
286
|
+
const key = manifest.keys[ticket.ref.id]!
|
|
287
|
+
const result =
|
|
288
|
+
byKey.get(key) ??
|
|
289
|
+
(await opts.source.create(
|
|
290
|
+
{ title: ticket.title, body: ticket.body, labels: ticket.labels },
|
|
291
|
+
{
|
|
292
|
+
state: states[ticket.state!]!,
|
|
293
|
+
idempotencyKey: key,
|
|
294
|
+
requestedId: planned.get(ticket.ref.id)!,
|
|
295
|
+
},
|
|
296
|
+
))
|
|
297
|
+
mapping[ticket.ref.id] = result.ref.id
|
|
298
|
+
await saveMap()
|
|
299
|
+
opts.print?.(`${ticket.ref.id} → ${result.ref.id}`)
|
|
300
|
+
}
|
|
301
|
+
for (const ticket of tickets) {
|
|
302
|
+
const id = mapping[ticket.ref.id]!
|
|
303
|
+
for (const blocker of ticket.blockedBy ?? [])
|
|
304
|
+
await opts.source.addBlocker(id, mapping[blocker]!)
|
|
305
|
+
const fresh = await opts.source.get(id)
|
|
306
|
+
if (!fresh) throw new Error(`target disappeared: ${id}`)
|
|
307
|
+
if ((ticket.comments?.length ?? 0) && fresh.comments === undefined)
|
|
308
|
+
throw new Error(`target cannot read comment history: ${id}`)
|
|
309
|
+
const bodies = new Set(fresh.comments?.map((comment) => comment.body))
|
|
310
|
+
for (const comment of ticket.comments ?? [])
|
|
311
|
+
if (!bodies.has(comment.body)) {
|
|
312
|
+
await opts.source.comment(id, comment.body)
|
|
313
|
+
bodies.add(comment.body)
|
|
314
|
+
}
|
|
315
|
+
for (const bundle of bundles.get(ticket.ref.id) ?? []) {
|
|
316
|
+
const current = await assets!.getTicketAsset(opts.repo, id, bundle.kind, bundle.name)
|
|
317
|
+
const canonical = (asset: PortableAsset) =>
|
|
318
|
+
JSON.stringify({
|
|
319
|
+
...asset,
|
|
320
|
+
entries: [...asset.entries].sort((a, b) => a.path.localeCompare(b.path)),
|
|
321
|
+
})
|
|
322
|
+
if (
|
|
323
|
+
!current ||
|
|
324
|
+
canonical(encodePortableAsset({ ...current.meta, entries: current.entries })) !==
|
|
325
|
+
canonical(bundle)
|
|
326
|
+
)
|
|
327
|
+
await assets!.putTicketAsset(opts.repo, id, decodePortableAsset(bundle))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
opts.print?.(`mapping: ${mappingPath}`)
|
|
331
|
+
} catch (error) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`${error instanceof Error ? error.message : error}\nImport progress retained in ${mappingPath}; rerun import to finish.`,
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mutateTicketIfRevision,
|
|
3
|
+
TicketRevisionConflict,
|
|
4
|
+
TicketRevisionUnsupported,
|
|
5
|
+
} from './revision'
|
|
1
6
|
import type { Config } from '../../config/schema'
|
|
2
7
|
import { readyCriteria } from '../../processes/dispatcher'
|
|
3
8
|
import type { Ticket, TicketDraft, TicketSource, TicketUpdate } from '../types'
|
|
@@ -91,6 +96,8 @@ export async function createTicket(
|
|
|
91
96
|
? await source.create(draft)
|
|
92
97
|
: await source.create(draft, { state: input.state })
|
|
93
98
|
} catch (error) {
|
|
99
|
+
if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
|
|
100
|
+
throw error
|
|
94
101
|
throw new TicketOperationError(
|
|
95
102
|
'refusal',
|
|
96
103
|
error instanceof Error ? error.message : String(error),
|
|
@@ -103,10 +110,15 @@ export async function updateTicket(
|
|
|
103
110
|
id: string,
|
|
104
111
|
patch: TicketUpdate,
|
|
105
112
|
readAfter = true,
|
|
113
|
+
expectedRevision?: string,
|
|
106
114
|
): Promise<Ticket | null> {
|
|
107
115
|
try {
|
|
108
|
-
|
|
116
|
+
if (expectedRevision !== undefined)
|
|
117
|
+
await mutateTicketIfRevision(source, id, expectedRevision, { kind: 'update', patch })
|
|
118
|
+
else await source.update(id, patch)
|
|
109
119
|
} catch (error) {
|
|
120
|
+
if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
|
|
121
|
+
throw error
|
|
110
122
|
throw new TicketOperationError(
|
|
111
123
|
'refusal',
|
|
112
124
|
error instanceof Error ? error.message : String(error),
|
|
@@ -115,11 +127,20 @@ export async function updateTicket(
|
|
|
115
127
|
return readAfter ? requirePostMutationTicket(source, id, 'the update') : null
|
|
116
128
|
}
|
|
117
129
|
|
|
118
|
-
export async function moveTicket(
|
|
130
|
+
export async function moveTicket(
|
|
131
|
+
source: TicketSource,
|
|
132
|
+
id: string,
|
|
133
|
+
state: string,
|
|
134
|
+
expectedRevision?: string,
|
|
135
|
+
): Promise<Ticket> {
|
|
119
136
|
await requireTicket(source, id)
|
|
120
137
|
try {
|
|
121
|
-
|
|
138
|
+
if (expectedRevision !== undefined)
|
|
139
|
+
await mutateTicketIfRevision(source, id, expectedRevision, { kind: 'transition', state })
|
|
140
|
+
else await source.transition(id, state)
|
|
122
141
|
} catch (error) {
|
|
142
|
+
if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
|
|
143
|
+
throw error
|
|
123
144
|
throw new TicketOperationError(
|
|
124
145
|
'refusal',
|
|
125
146
|
error instanceof Error ? error.message : String(error),
|
|
@@ -133,6 +154,7 @@ export async function changeBlockers(
|
|
|
133
154
|
id: string,
|
|
134
155
|
blockerIds: string[],
|
|
135
156
|
operation: 'block' | 'unblock',
|
|
157
|
+
expectedRevision?: string,
|
|
136
158
|
): Promise<Ticket> {
|
|
137
159
|
await requireTicket(source, id)
|
|
138
160
|
const ids = await knownBlockers(source, blockerIds, '')
|
|
@@ -143,11 +165,20 @@ export async function changeBlockers(
|
|
|
143
165
|
)
|
|
144
166
|
}
|
|
145
167
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
168
|
+
if (expectedRevision !== undefined)
|
|
169
|
+
await mutateTicketIfRevision(source, id, expectedRevision, {
|
|
170
|
+
kind: 'blockers',
|
|
171
|
+
operation,
|
|
172
|
+
ids,
|
|
173
|
+
})
|
|
174
|
+
else
|
|
175
|
+
for (const blockerId of ids) {
|
|
176
|
+
if (operation === 'block') await source.addBlocker(id, blockerId)
|
|
177
|
+
else await source.removeBlocker(id, blockerId)
|
|
178
|
+
}
|
|
150
179
|
} catch (error) {
|
|
180
|
+
if (error instanceof TicketRevisionConflict || error instanceof TicketRevisionUnsupported)
|
|
181
|
+
throw error
|
|
151
182
|
throw new TicketOperationError(
|
|
152
183
|
'refusal',
|
|
153
184
|
error instanceof Error ? error.message : String(error),
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { validateTicketAssetInput, type TicketAssetInput } from '../../store/ticket-assets'
|
|
3
|
+
|
|
4
|
+
export const portableAssetSchema = z
|
|
5
|
+
.strictObject({
|
|
6
|
+
kind: z.string(),
|
|
7
|
+
name: z.string(),
|
|
8
|
+
layout: z.enum(['file', 'tree']),
|
|
9
|
+
entries: z.array(
|
|
10
|
+
z.discriminatedUnion('type', [
|
|
11
|
+
z.strictObject({ type: z.literal('dir'), path: z.string() }),
|
|
12
|
+
z.strictObject({
|
|
13
|
+
type: z.literal('file'),
|
|
14
|
+
path: z.string(),
|
|
15
|
+
base64: z
|
|
16
|
+
.string()
|
|
17
|
+
.regex(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/),
|
|
18
|
+
}),
|
|
19
|
+
]),
|
|
20
|
+
),
|
|
21
|
+
})
|
|
22
|
+
.superRefine((value, ctx) => {
|
|
23
|
+
try {
|
|
24
|
+
validateTicketAssetInput(decodePortableAsset(value))
|
|
25
|
+
} catch (error) {
|
|
26
|
+
ctx.addIssue({ code: 'custom', message: String(error) })
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
export type PortableAsset = z.infer<typeof portableAssetSchema>
|
|
30
|
+
export function decodePortableAsset(value: PortableAsset): TicketAssetInput {
|
|
31
|
+
return {
|
|
32
|
+
kind: value.kind,
|
|
33
|
+
name: value.name,
|
|
34
|
+
layout: value.layout,
|
|
35
|
+
entries: value.entries.map((entry) =>
|
|
36
|
+
entry.type === 'dir'
|
|
37
|
+
? entry
|
|
38
|
+
: { type: 'file', path: entry.path, content: Buffer.from(entry.base64, 'base64') },
|
|
39
|
+
),
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function encodePortableAsset(input: TicketAssetInput): PortableAsset {
|
|
43
|
+
validateTicketAssetInput(input)
|
|
44
|
+
return {
|
|
45
|
+
kind: input.kind,
|
|
46
|
+
name: input.name,
|
|
47
|
+
layout: input.layout,
|
|
48
|
+
entries: input.entries.map((entry) =>
|
|
49
|
+
entry.type === 'dir'
|
|
50
|
+
? entry
|
|
51
|
+
: { type: 'file', path: entry.path, base64: Buffer.from(entry.content).toString('base64') },
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
import { ticketRefSchema } from '../../ontology'
|
|
3
3
|
|
|
4
|
+
/** Explicit opt-in: released clients through 0.12 use strict decoders without revisions.
|
|
5
|
+
* Source checkouts can share their release version, so version alone is insufficient. */
|
|
6
|
+
export const TICKET_REVISIONS_HEADER = 'x-autobuild-ticket-revisions'
|
|
7
|
+
export const TICKET_REVISIONS_CAPABILITY = '1'
|
|
8
|
+
|
|
4
9
|
/** Repository-owned policy sent with every hosted ticket operation. */
|
|
5
10
|
export const hostedTicketContextSchema = z.strictObject({
|
|
6
11
|
teamKey: z.string().min(1),
|
|
@@ -10,8 +15,19 @@ export const hostedTicketContextSchema = z.strictObject({
|
|
|
10
15
|
})
|
|
11
16
|
export type HostedTicketContext = z.infer<typeof hostedTicketContextSchema>
|
|
12
17
|
|
|
18
|
+
export const TICKET_COMMENTS_HEADER = 'x-autobuild-ticket-comments'
|
|
19
|
+
export const TICKET_COMMENTS_CAPABILITY = '1'
|
|
20
|
+
|
|
21
|
+
export const ticketCommentWireSchema = z.strictObject({
|
|
22
|
+
body: z.string(),
|
|
23
|
+
createdAt: z.string(),
|
|
24
|
+
author: z.string().optional(),
|
|
25
|
+
})
|
|
26
|
+
|
|
13
27
|
export const ticketWireSchema = z.strictObject({
|
|
28
|
+
revision: z.string().min(1),
|
|
14
29
|
ref: ticketRefSchema,
|
|
30
|
+
comments: z.array(ticketCommentWireSchema).optional(),
|
|
15
31
|
creationKey: z.string().optional(),
|
|
16
32
|
title: z.string(),
|
|
17
33
|
body: z.string(),
|
|
@@ -47,6 +63,7 @@ const draftSchema = z.strictObject({
|
|
|
47
63
|
blockedBy: z.array(z.string()).optional(),
|
|
48
64
|
})
|
|
49
65
|
const createOptionsSchema = z.strictObject({
|
|
66
|
+
requestedId: z.string().min(1).optional(),
|
|
50
67
|
state: z.string().optional(),
|
|
51
68
|
idempotencyKey: z.string().optional(),
|
|
52
69
|
})
|
|
@@ -62,8 +79,27 @@ function envelope<T extends z.ZodType>(input: T) {
|
|
|
62
79
|
return z.strictObject({ context: hostedTicketContextSchema, input })
|
|
63
80
|
}
|
|
64
81
|
|
|
82
|
+
const mutationSchema = z.discriminatedUnion('kind', [
|
|
83
|
+
z.strictObject({ kind: z.literal('update'), patch: updateSchema }),
|
|
84
|
+
z.strictObject({ kind: z.literal('transition'), state: z.string().min(1) }),
|
|
85
|
+
z.strictObject({
|
|
86
|
+
kind: z.literal('blockers'),
|
|
87
|
+
operation: z.enum(['block', 'unblock']),
|
|
88
|
+
ids: z.array(z.string().min(1)),
|
|
89
|
+
}),
|
|
90
|
+
])
|
|
91
|
+
|
|
65
92
|
export const hostedTicketRequestSchemas = {
|
|
93
|
+
'mutate-if-revision': envelope(
|
|
94
|
+
z.strictObject({
|
|
95
|
+
id: z.string().min(1),
|
|
96
|
+
expectedRevision: z.string().refine((v) => v.trim().length > 0),
|
|
97
|
+
mutation: mutationSchema,
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
66
100
|
'list-ready': envelope(criteriaSchema),
|
|
101
|
+
states: envelope(z.strictObject({})),
|
|
102
|
+
'add-state': envelope(z.strictObject({ name: z.string(), about: z.string().optional() })),
|
|
67
103
|
get: envelope(idSchema),
|
|
68
104
|
claim: envelope(idSchema),
|
|
69
105
|
comment: envelope(z.strictObject({ id: z.string().min(1), body: z.string() })),
|
|
@@ -81,3 +117,62 @@ export type HostedTicketOperation = keyof typeof hostedTicketRequestSchemas
|
|
|
81
117
|
export const HOSTED_TICKET_OPERATIONS = Object.keys(
|
|
82
118
|
hostedTicketRequestSchemas,
|
|
83
119
|
) as HostedTicketOperation[]
|
|
120
|
+
|
|
121
|
+
/** Project policy is separate from CRUD context and cannot be supplied by delegates. */
|
|
122
|
+
export const hostedTicketPolicySchema = z.strictObject({
|
|
123
|
+
version: z.literal(1),
|
|
124
|
+
tickets: z.strictObject({
|
|
125
|
+
source: z.literal('hosted'),
|
|
126
|
+
teamKey: z.string().min(1),
|
|
127
|
+
readyState: z
|
|
128
|
+
.string()
|
|
129
|
+
.min(1)
|
|
130
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
131
|
+
triageState: z
|
|
132
|
+
.string()
|
|
133
|
+
.min(1)
|
|
134
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
135
|
+
claimedState: z
|
|
136
|
+
.string()
|
|
137
|
+
.min(1)
|
|
138
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
139
|
+
doneState: z
|
|
140
|
+
.string()
|
|
141
|
+
.min(1)
|
|
142
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
143
|
+
createState: z
|
|
144
|
+
.string()
|
|
145
|
+
.min(1)
|
|
146
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
147
|
+
proposalState: z
|
|
148
|
+
.string()
|
|
149
|
+
.min(1)
|
|
150
|
+
.refine((value) => value.trim().length > 0, 'state must be nonblank'),
|
|
151
|
+
readyLabels: z.array(z.string().min(1)).optional(),
|
|
152
|
+
}),
|
|
153
|
+
})
|
|
154
|
+
export type HostedTicketPolicy = z.infer<typeof hostedTicketPolicySchema>
|
|
155
|
+
|
|
156
|
+
export const ticketGrantSchema = z.strictObject({
|
|
157
|
+
id: z.string().min(1),
|
|
158
|
+
repo: z.string().min(1),
|
|
159
|
+
execution: z.string().min(1),
|
|
160
|
+
kind: z.enum(['runner', 'phase']),
|
|
161
|
+
parent: z.string().nullable(),
|
|
162
|
+
expiresAt: z.string().datetime(),
|
|
163
|
+
})
|
|
164
|
+
export const ticketGrantResponseSchema = z.strictObject({
|
|
165
|
+
token: z.string().startsWith('abt_'),
|
|
166
|
+
grant: ticketGrantSchema,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
export const ticketStateWireSchema = z.strictObject({
|
|
170
|
+
name: z.string(),
|
|
171
|
+
tickets: z.number().int().nonnegative(),
|
|
172
|
+
about: z.string().optional(),
|
|
173
|
+
})
|
|
174
|
+
export const ticketStatesWireSchema = z.array(ticketStateWireSchema)
|
|
175
|
+
export const ticketStateCreateWireSchema = z.strictObject({
|
|
176
|
+
state: ticketStateWireSchema,
|
|
177
|
+
created: z.boolean(),
|
|
178
|
+
})
|