@defrex/autobuild 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/SPEC.md +138 -16
- package/bin/ab-dispatch-kernel.ts +4 -6
- package/docs/architecture.md +155 -73
- package/docs/configuration.md +41 -9
- package/docs/hosted-service.md +31 -5
- package/docs/operator-api.md +8 -1
- package/docs/remote-store-protocol.md +94 -2
- package/docs/remote-ticket-protocol.md +151 -9
- package/docs/setup.md +33 -7
- package/docs/ticket-migration.md +64 -0
- package/package.json +4 -3
- package/packages/core/src/cli/attention.ts +18 -164
- package/packages/core/src/cli/build-control.ts +23 -16
- package/packages/core/src/cli/bulk-control.ts +3 -2
- package/packages/core/src/cli/connect.ts +141 -38
- package/packages/core/src/cli/context.ts +23 -79
- package/packages/core/src/cli/dashboard/model.ts +36 -22
- package/packages/core/src/cli/dashboard/poll.ts +1 -1
- package/packages/core/src/cli/dispatch-frontend.ts +24 -67
- package/packages/core/src/cli/dispatch-process.ts +14 -7
- package/packages/core/src/cli/dispatch-repository-feed.ts +196 -0
- package/packages/core/src/cli/dispatch.ts +304 -82
- package/packages/core/src/cli/dispatcher-liveness.ts +26 -19
- package/packages/core/src/cli/harvest.ts +84 -42
- package/packages/core/src/cli/help.ts +14 -8
- package/packages/core/src/cli/historical-build-state.ts +27 -0
- package/packages/core/src/cli/installation.ts +67 -13
- package/packages/core/src/cli/main.ts +23 -5
- package/packages/core/src/cli/mcp.ts +2 -1
- package/packages/core/src/cli/pr-attachments.ts +10 -13
- package/packages/core/src/cli/pr-summary-publication.ts +27 -60
- package/packages/core/src/cli/pr-summary.ts +43 -13
- package/packages/core/src/cli/publication-settlement.ts +10 -20
- package/packages/core/src/cli/repository-status.ts +38 -2
- package/packages/core/src/cli/self-update.ts +5 -1
- package/packages/core/src/cli/status.ts +62 -32
- package/packages/core/src/cli/terminals.ts +25 -142
- 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 +166 -36
- package/packages/core/src/cli/wait.ts +91 -84
- package/packages/core/src/cli/watch.ts +60 -97
- package/packages/core/src/config/pipeline-source.ts +4 -3
- 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/events/repository.ts +7 -3
- package/packages/core/src/integration/harness.ts +38 -4
- package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +2444 -9
- package/packages/core/src/kernel/auto-merge.ts +19 -3
- package/packages/core/src/kernel/dispatcher-liveness.ts +10 -4
- package/packages/core/src/kernel/generators/build-log.ts +1 -1
- package/packages/core/src/kernel/generators/repository-journals.ts +14 -3
- package/packages/core/src/kernel/harvest.ts +25 -23
- package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
- package/packages/core/src/kernel/log-index-value.ts +38 -0
- package/packages/core/src/kernel/read-facts.ts +637 -0
- package/packages/core/src/kernel/reducer-registry.ts +139 -3
- package/packages/core/src/kernel/repository-attention.ts +179 -0
- package/packages/core/src/kernel/service-facts.ts +135 -0
- package/packages/core/src/kernel/terminal-guard.ts +133 -0
- package/packages/core/src/ontology.ts +1 -0
- package/packages/core/src/operator/annotations.ts +12 -0
- package/packages/core/src/operator/control.ts +5 -6
- package/packages/core/src/operator/index.ts +7 -0
- package/packages/core/src/operator/protocol.ts +19 -2
- package/packages/core/src/operator/query.ts +37 -20
- package/packages/core/src/operator/read-view.ts +4 -13
- package/packages/core/src/operator/registry.ts +61 -8
- package/packages/core/src/operator/requests.ts +2 -14
- package/packages/core/src/operator/sandbox.ts +10 -9
- package/packages/core/src/operator/ticket-source-backend.ts +17 -21
- package/packages/core/src/operator/tickets.ts +132 -22
- package/packages/core/src/plugin-sdk/index.ts +19 -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/build-child.ts +6 -6
- package/packages/core/src/processes/build-runner.ts +62 -70
- package/packages/core/src/processes/dispatch-failure-budget.ts +5 -4
- package/packages/core/src/processes/dispatcher.ts +223 -205
- 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-execution-state.ts +15 -9
- package/packages/core/src/processes/harvest-runner.ts +58 -74
- package/packages/core/src/processes/harvest.ts +72 -29
- package/packages/core/src/processes/infrastructure-failure-budget.ts +13 -15
- package/packages/core/src/processes/orchestrator-tick.ts +2 -2
- package/packages/core/src/processes/phase-failure-budget.ts +1 -0
- package/packages/core/src/processes/publication-loss.ts +35 -21
- package/packages/core/src/processes/repo-view.ts +229 -1099
- package/packages/core/src/processes/tick-publication.ts +110 -0
- package/packages/core/src/store/adapter.ts +23 -0
- package/packages/core/src/store/build-scope.ts +17 -0
- package/packages/core/src/store/contract.ts +283 -1
- package/packages/core/src/store/local/schema.ts +1 -0
- package/packages/core/src/store/local/store.ts +180 -55
- package/packages/core/src/store/memory.ts +119 -29
- package/packages/core/src/store/phase-session.ts +17 -0
- package/packages/core/src/store/reduced-reads.ts +239 -0
- package/packages/core/src/store/reducer-value-shapes.json +14056 -0
- package/packages/core/src/store/reducer-value-validation.ts +96 -0
- package/packages/core/src/store/reducer-values.ts +145 -0
- package/packages/core/src/store/remote/client.ts +41 -0
- package/packages/core/src/store/remote/compatibility.ts +2 -0
- package/packages/core/src/store/remote/index.ts +19 -0
- package/packages/core/src/store/remote/protocol.ts +16 -0
- package/packages/core/src/store/remote/version.ts +3 -1
- package/packages/core/src/store/repo-state-events.ts +1 -1
- package/packages/core/src/store/repo-tick.ts +72 -0
- package/packages/core/src/store/session-handle.ts +19 -0
- package/packages/core/src/store/snapshots.ts +10 -0
- 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 +40 -3
- package/packages/core/src/testing/dispatch-read-budget.ts +215 -0
- package/packages/core/src/testing/index.ts +8 -0
- package/packages/core/src/testing/reduced-read-budget.ts +462 -0
- package/skills/guide/SKILL.md +97 -12
- package/skills/guide/references/operator-api.md +8 -1
- package/skills/guide/references/remote-store-protocol.md +94 -2
- 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
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { readBuildFacts } from '../store/reduced-reads'
|
|
2
|
+
import { samplePinnedAssets, preparePinnedAssets } from '../store/ticket-assets'
|
|
3
|
+
import type { TicketRef } from '../ontology'
|
|
1
4
|
import type { Config } from '../config/schema'
|
|
2
|
-
import { reduceBuild } from '../kernel/reducer'
|
|
3
5
|
import {
|
|
6
|
+
TicketOperationError,
|
|
4
7
|
changeBlockers,
|
|
5
8
|
createTicket,
|
|
6
9
|
moveTicket,
|
|
@@ -9,7 +12,7 @@ import {
|
|
|
9
12
|
} from '../ports/tickets/operations'
|
|
10
13
|
import type { DependencyState, Ticket, TicketSource, TicketUpdate } from '../ports/types'
|
|
11
14
|
import type { BuildStore } from '../store/types'
|
|
12
|
-
import {
|
|
15
|
+
import { ticketLifecycle } from '../ports/tickets/lifecycle'
|
|
13
16
|
import { effectiveConfig } from './query'
|
|
14
17
|
|
|
15
18
|
export interface OperatorTicketContext {
|
|
@@ -21,6 +24,7 @@ export interface OperatorTicketContext {
|
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export interface OperatorTicketBackend {
|
|
27
|
+
contextFor?(repo: string): Promise<OperatorTicketContext>
|
|
24
28
|
sourceFor(context: OperatorTicketContext): TicketSource | Promise<TicketSource>
|
|
25
29
|
statesFor(context: OperatorTicketContext, source: TicketSource): string[] | Promise<string[]>
|
|
26
30
|
}
|
|
@@ -63,10 +67,11 @@ export async function openOperatorTickets(opts: {
|
|
|
63
67
|
store: BuildStore
|
|
64
68
|
repo: string
|
|
65
69
|
backend: OperatorTicketBackend
|
|
66
|
-
}): Promise<{
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
}): Promise<{ context: OperatorTicketContext; source: TicketSource }> {
|
|
71
|
+
const context = opts.backend.contextFor
|
|
72
|
+
? await opts.backend.contextFor(opts.repo)
|
|
73
|
+
: contextFor((await effectiveConfig(opts.store, opts.repo)).config, opts.repo)
|
|
74
|
+
return { context, source: await opts.backend.sourceFor(context) }
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
export async function listOperatorTickets(opts: {
|
|
@@ -76,9 +81,9 @@ export async function listOperatorTickets(opts: {
|
|
|
76
81
|
state?: string
|
|
77
82
|
labels?: string[]
|
|
78
83
|
}): Promise<OperatorTicketQueue> {
|
|
79
|
-
const {
|
|
80
|
-
const triageState =
|
|
81
|
-
const readyState =
|
|
84
|
+
const { context, source } = await openOperatorTickets(opts)
|
|
85
|
+
const triageState = ticketLifecycle(context.tickets).triage
|
|
86
|
+
const readyState = context.tickets.readyState
|
|
82
87
|
// The operator surface is a grooming queue. Unlike the CLI's ticket list,
|
|
83
88
|
// labels narrow the default state instead of replacing its lifecycle gate.
|
|
84
89
|
const criteria = {
|
|
@@ -103,14 +108,40 @@ async function matchingBuild(
|
|
|
103
108
|
store: BuildStore,
|
|
104
109
|
repo: string,
|
|
105
110
|
id: string,
|
|
111
|
+
source: TicketSource,
|
|
112
|
+
configuredSource: string,
|
|
106
113
|
): Promise<OperatorTicketBuild | null> {
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
// The service's database adapter implements the externally named hosted source.
|
|
115
|
+
const hosted =
|
|
116
|
+
source.name === 'hosted' || (configuredSource === 'hosted' && source.name === 'database')
|
|
117
|
+
const candidates = (await store.listBuilds()).filter(
|
|
118
|
+
(record) =>
|
|
119
|
+
record.repo === repo &&
|
|
120
|
+
(record.ticket?.source === source.name || (hosted && record.ticket?.source === 'hosted')),
|
|
109
121
|
)
|
|
122
|
+
const matches = candidates.filter((record) => record.ticket!.id === id)
|
|
123
|
+
// The service database store always projects current ids, even when there is
|
|
124
|
+
// no matching build. Only a hosted client over an unprojected legacy store
|
|
125
|
+
// needs fallback lookups; failure must not undo a mutation.
|
|
126
|
+
if (matches.length === 0 && source.name === 'hosted') {
|
|
127
|
+
const identities = new Map<string, string>()
|
|
128
|
+
try {
|
|
129
|
+
for (const record of candidates) {
|
|
130
|
+
const raw = record.ticket!.id
|
|
131
|
+
if (!identities.has(raw)) {
|
|
132
|
+
const ticket = await source.get(raw)
|
|
133
|
+
identities.set(raw, ticket?.ref.source === source.name ? ticket.ref.id : raw)
|
|
134
|
+
}
|
|
135
|
+
if (identities.get(raw) === id) matches.push(record)
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// Build association is optional presentation; keep the successful ticket result.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
110
141
|
const projected = await Promise.all(
|
|
111
142
|
matches.map(async (record) => ({
|
|
112
143
|
record,
|
|
113
|
-
state:
|
|
144
|
+
state: (await readBuildFacts(store, record.slug)).value.state,
|
|
114
145
|
})),
|
|
115
146
|
)
|
|
116
147
|
projected.sort((a, b) => {
|
|
@@ -136,10 +167,20 @@ export async function getOperatorTicket(opts: {
|
|
|
136
167
|
backend: OperatorTicketBackend
|
|
137
168
|
id: string
|
|
138
169
|
}): Promise<OperatorTicketDetail> {
|
|
139
|
-
const { source } = await openOperatorTickets(opts)
|
|
170
|
+
const { source, context } = await openOperatorTickets(opts)
|
|
140
171
|
const ticket = await requireTicket(source, opts.id)
|
|
141
172
|
const blockers = await source.dependencyStates(ticket.blockedBy ?? [])
|
|
142
|
-
return {
|
|
173
|
+
return {
|
|
174
|
+
ticket,
|
|
175
|
+
blockers,
|
|
176
|
+
build: await matchingBuild(
|
|
177
|
+
opts.store,
|
|
178
|
+
opts.repo,
|
|
179
|
+
ticket.ref.id,
|
|
180
|
+
source,
|
|
181
|
+
context.tickets.source,
|
|
182
|
+
),
|
|
183
|
+
}
|
|
143
184
|
}
|
|
144
185
|
|
|
145
186
|
export async function mutateOperatorTicket(opts: {
|
|
@@ -155,21 +196,90 @@ export async function mutateOperatorTicket(opts: {
|
|
|
155
196
|
state?: string
|
|
156
197
|
blockedBy?: string[]
|
|
157
198
|
}
|
|
158
|
-
| { kind: 'update'; id: string; patch: TicketUpdate }
|
|
159
|
-
| { kind: 'move'; id: string; state: string }
|
|
160
|
-
| { kind: 'block' | 'unblock'; id: string; blockerIds: string[] }
|
|
199
|
+
| { kind: 'update'; id: string; patch: TicketUpdate; expectedRevision?: string }
|
|
200
|
+
| { kind: 'move'; id: string; state: string; expectedRevision?: string }
|
|
201
|
+
| { kind: 'block' | 'unblock'; id: string; blockerIds: string[]; expectedRevision?: string }
|
|
161
202
|
}): Promise<OperatorTicketDetail> {
|
|
162
|
-
const { source } = await openOperatorTickets(opts)
|
|
203
|
+
const { source, context } = await openOperatorTickets(opts)
|
|
163
204
|
const operation = opts.operation
|
|
164
205
|
if (operation.kind === 'update') await requireTicket(source, operation.id)
|
|
165
206
|
const ticket =
|
|
166
207
|
operation.kind === 'create'
|
|
167
208
|
? await createTicket(source, operation)
|
|
168
209
|
: operation.kind === 'update'
|
|
169
|
-
? (await updateTicket(
|
|
210
|
+
? (await updateTicket(
|
|
211
|
+
source,
|
|
212
|
+
operation.id,
|
|
213
|
+
operation.patch,
|
|
214
|
+
true,
|
|
215
|
+
operation.expectedRevision,
|
|
216
|
+
))!
|
|
170
217
|
: operation.kind === 'move'
|
|
171
|
-
? await moveTicket(source, operation.id, operation.state)
|
|
172
|
-
: await changeBlockers(
|
|
218
|
+
? await moveTicket(source, operation.id, operation.state, operation.expectedRevision)
|
|
219
|
+
: await changeBlockers(
|
|
220
|
+
source,
|
|
221
|
+
operation.id,
|
|
222
|
+
operation.blockerIds,
|
|
223
|
+
operation.kind,
|
|
224
|
+
operation.expectedRevision,
|
|
225
|
+
)
|
|
173
226
|
const blockers = await source.dependencyStates(ticket.blockedBy ?? [])
|
|
174
|
-
return {
|
|
227
|
+
return {
|
|
228
|
+
ticket,
|
|
229
|
+
blockers,
|
|
230
|
+
build: await matchingBuild(
|
|
231
|
+
opts.store,
|
|
232
|
+
opts.repo,
|
|
233
|
+
ticket.ref.id,
|
|
234
|
+
source,
|
|
235
|
+
context.tickets.source,
|
|
236
|
+
),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function prepareOperatorTicketAssets(opts: {
|
|
241
|
+
store: BuildStore
|
|
242
|
+
repo: string
|
|
243
|
+
backend?: OperatorTicketBackend
|
|
244
|
+
ref: TicketRef
|
|
245
|
+
}) {
|
|
246
|
+
if (!opts.backend) {
|
|
247
|
+
if (opts.ref.source !== 'hosted') return samplePinnedAssets(opts.store, opts.repo, opts.ref.id)
|
|
248
|
+
throw new Error('ticket operator backend is not configured')
|
|
249
|
+
}
|
|
250
|
+
const { context, source } = await openOperatorTickets({ ...opts, backend: opts.backend })
|
|
251
|
+
if (context.tickets.source !== opts.ref.source)
|
|
252
|
+
throw new Error('build ticket source differs from configured source')
|
|
253
|
+
return preparePinnedAssets(source, opts.store, opts.repo, opts.ref.id)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function listOperatorTicketStates(opts: {
|
|
257
|
+
store: BuildStore
|
|
258
|
+
repo: string
|
|
259
|
+
backend: OperatorTicketBackend
|
|
260
|
+
}) {
|
|
261
|
+
const { source } = await openOperatorTickets(opts)
|
|
262
|
+
if (!source.listStates)
|
|
263
|
+
throw new TicketOperationError('refusal', `state discovery is not supported by ${source.name}`)
|
|
264
|
+
return source.listStates()
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function createOperatorTicketState(opts: {
|
|
268
|
+
store: BuildStore
|
|
269
|
+
repo: string
|
|
270
|
+
backend: OperatorTicketBackend
|
|
271
|
+
name: string
|
|
272
|
+
about?: string
|
|
273
|
+
}) {
|
|
274
|
+
const { source } = await openOperatorTickets(opts)
|
|
275
|
+
if (!source.addState)
|
|
276
|
+
throw new TicketOperationError('refusal', `state creation is not supported by ${source.name}`)
|
|
277
|
+
try {
|
|
278
|
+
return await source.addState(opts.name, opts.about === undefined ? {} : { about: opts.about })
|
|
279
|
+
} catch (error) {
|
|
280
|
+
throw new TicketOperationError(
|
|
281
|
+
'refusal',
|
|
282
|
+
error instanceof Error ? error.message : String(error),
|
|
283
|
+
)
|
|
284
|
+
}
|
|
175
285
|
}
|
|
@@ -152,6 +152,12 @@ export type {
|
|
|
152
152
|
|
|
153
153
|
export { FakeTicketSource } from '../ports/tickets/fake'
|
|
154
154
|
export { validateTicketUpdate } from '../ports/tickets/update'
|
|
155
|
+
export {
|
|
156
|
+
TicketRevisionConflict,
|
|
157
|
+
TicketRevisionUnsupported,
|
|
158
|
+
ticketRevision,
|
|
159
|
+
checkTicketRevision,
|
|
160
|
+
} from '../ports/tickets/revision'
|
|
155
161
|
export { FakeWorkspaceProvider } from '../ports/workspace/fake'
|
|
156
162
|
export { FakeForge } from '../ports/forge/fake'
|
|
157
163
|
// ScriptContext + defaultTurnResult are the scripted fake's own API: the
|
|
@@ -223,3 +229,16 @@ export type {
|
|
|
223
229
|
TicketAssetMeta,
|
|
224
230
|
TicketAssetSummary,
|
|
225
231
|
} from '../store/ticket-assets'
|
|
232
|
+
|
|
233
|
+
export {
|
|
234
|
+
readReducerValue,
|
|
235
|
+
resolveReducerValue,
|
|
236
|
+
reducerValueRequestSchema,
|
|
237
|
+
reducerValueSchema,
|
|
238
|
+
} from '../store/reducer-values'
|
|
239
|
+
export type {
|
|
240
|
+
ReducerValue,
|
|
241
|
+
ReducerValueRequest,
|
|
242
|
+
ReducerValueStorage,
|
|
243
|
+
ValueCacheRow,
|
|
244
|
+
} from '../store/reducer-values'
|
|
@@ -40,7 +40,11 @@ export async function materializePluginRuntimes(
|
|
|
40
40
|
const merged = Object.assign(Object.create(null) as RuntimeRegistry, builtins)
|
|
41
41
|
const config = Object.freeze({})
|
|
42
42
|
const env = Object.freeze(
|
|
43
|
-
Object.fromEntries(
|
|
43
|
+
Object.fromEntries(
|
|
44
|
+
Object.entries(opts.env).filter(
|
|
45
|
+
([key]) => !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key),
|
|
46
|
+
),
|
|
47
|
+
),
|
|
44
48
|
)
|
|
45
49
|
const context: PluginFactoryContext = {
|
|
46
50
|
config,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { updateTicket, moveTicket, changeBlockers } from './operations'
|
|
1
2
|
import { describe, expect, test } from 'bun:test'
|
|
2
3
|
import type {
|
|
3
4
|
DependencyState,
|
|
@@ -116,6 +117,64 @@ export function describeTicketSourceContract(
|
|
|
116
117
|
factory: TicketSourceContractFactory,
|
|
117
118
|
): void {
|
|
118
119
|
describe(`TicketSource contract: ${name}`, () => {
|
|
120
|
+
test('revisions cover mutable content and guarded writes fail closed', async () => {
|
|
121
|
+
await withTicketSource(factory, async (harness) => {
|
|
122
|
+
const source = harness.source
|
|
123
|
+
const created = await createTracked(
|
|
124
|
+
harness,
|
|
125
|
+
{ title: contractTicketTitle('revisions'), body: CONTRACT_TICKET_BODY },
|
|
126
|
+
{ state: harness.states.ready },
|
|
127
|
+
)
|
|
128
|
+
const id = created.ref.id
|
|
129
|
+
expect(created.revision.trim()).not.toBe('')
|
|
130
|
+
expect((await source.get(id))?.revision).toBe(created.revision)
|
|
131
|
+
expect(
|
|
132
|
+
(await source.listReady({ state: harness.states.ready })).tickets.find(
|
|
133
|
+
(t) => t.ref.id === id,
|
|
134
|
+
)?.revision,
|
|
135
|
+
).toBe(created.revision)
|
|
136
|
+
if (!source.mutateIfRevision) {
|
|
137
|
+
await expect(
|
|
138
|
+
updateTicket(source, id, { title: 'Never written' }, false, created.revision),
|
|
139
|
+
).rejects.toThrow('does not support')
|
|
140
|
+
expect((await source.get(id))?.revision).toBe(created.revision)
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
let current = created
|
|
144
|
+
for (const patch of [
|
|
145
|
+
{ title: contractTicketTitle('renamed') },
|
|
146
|
+
{ body: 'Changed body' },
|
|
147
|
+
{ labels: [harness.editableLabel] },
|
|
148
|
+
]) {
|
|
149
|
+
await updateTicket(source, id, patch, false, current.revision)
|
|
150
|
+
const next = (await source.get(id))!
|
|
151
|
+
expect(next.revision).not.toBe(current.revision)
|
|
152
|
+
await expect(updateTicket(source, id, patch, false, current.revision)).rejects.toThrow(
|
|
153
|
+
'current revision',
|
|
154
|
+
)
|
|
155
|
+
expect(await source.get(id)).toEqual(next)
|
|
156
|
+
current = next
|
|
157
|
+
}
|
|
158
|
+
await moveTicket(source, id, harness.states.completed, current.revision)
|
|
159
|
+
const moved = (await source.get(id))!
|
|
160
|
+
expect(moved.revision).not.toBe(current.revision)
|
|
161
|
+
await expect(
|
|
162
|
+
moveTicket(source, id, harness.states.ready, current.revision),
|
|
163
|
+
).rejects.toThrow('current revision')
|
|
164
|
+
const blocker = await createTracked(harness, {
|
|
165
|
+
title: contractTicketTitle('blocker'),
|
|
166
|
+
body: CONTRACT_TICKET_BODY,
|
|
167
|
+
})
|
|
168
|
+
await changeBlockers(source, id, [blocker.ref.id], 'block', moved.revision)
|
|
169
|
+
const blocked = (await source.get(id))!
|
|
170
|
+
expect(blocked.revision).not.toBe(moved.revision)
|
|
171
|
+
await expect(
|
|
172
|
+
changeBlockers(source, id, [blocker.ref.id], 'unblock', moved.revision),
|
|
173
|
+
).rejects.toThrow('current revision')
|
|
174
|
+
await changeBlockers(source, id, [blocker.ref.id], 'unblock', blocked.revision)
|
|
175
|
+
expect((await source.get(id))?.revision).not.toBe(blocked.revision)
|
|
176
|
+
})
|
|
177
|
+
})
|
|
119
178
|
test('completion projections agree across create, get, listing, and transitions', async () => {
|
|
120
179
|
await withTicketSource(factory, async (harness) => {
|
|
121
180
|
const created = await createTracked(
|
|
@@ -156,7 +215,13 @@ export function describeTicketSourceContract(
|
|
|
156
215
|
state: harness.states.ready,
|
|
157
216
|
labels: [],
|
|
158
217
|
})
|
|
159
|
-
|
|
218
|
+
const detail = await harness.source.get(created.ref.id)
|
|
219
|
+
expect(detail).not.toBeNull()
|
|
220
|
+
// Detail reads may expose structured history absent from write responses.
|
|
221
|
+
const { comments, ...record } = detail!
|
|
222
|
+
const { comments: createdComments, ...createdRecord } = created
|
|
223
|
+
expect(record).toEqual(createdRecord)
|
|
224
|
+
if (comments !== undefined) expect(comments).toEqual(createdComments ?? [])
|
|
160
225
|
})
|
|
161
226
|
})
|
|
162
227
|
|
|
@@ -16,6 +16,8 @@ import type { TicketSource } from '../types'
|
|
|
16
16
|
import { DEFAULT_TICKETS_DIR, FileTicketSource } from './file'
|
|
17
17
|
import { LinearTicketSource } from './linear'
|
|
18
18
|
import { HostedTicketSource } from './remote'
|
|
19
|
+
import { ticketLifecycle } from './lifecycle'
|
|
20
|
+
import { normalizeServiceURL } from '../../connection'
|
|
19
21
|
|
|
20
22
|
function reason(error: unknown): string {
|
|
21
23
|
return error instanceof Error ? error.message : String(error)
|
|
@@ -32,26 +34,41 @@ export async function createTicketSource(
|
|
|
32
34
|
plugins: PluginRegistry = createPluginRegistry(),
|
|
33
35
|
): Promise<TicketSource> {
|
|
34
36
|
if (config.source === 'hosted') {
|
|
35
|
-
const
|
|
37
|
+
const buildUrl = env.AB_STORE?.trim() || configuredStore(targetRepo)
|
|
38
|
+
const rawUrl = config.url ?? buildUrl
|
|
36
39
|
if (!rawUrl) {
|
|
37
40
|
throw new Error(
|
|
38
|
-
'
|
|
41
|
+
'Hosted tickets require [tickets] url when builds are local; alternatively set [store] url or AB_STORE to a service',
|
|
39
42
|
)
|
|
40
43
|
}
|
|
41
44
|
let serviceUrl: URL
|
|
42
45
|
try {
|
|
43
|
-
serviceUrl = new URL(rawUrl)
|
|
46
|
+
serviceUrl = new URL(normalizeServiceURL(rawUrl, 'tickets.url'))
|
|
44
47
|
} catch {
|
|
45
|
-
throw new Error(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
throw new Error('AB_STORE must be an HTTP(S) URL when [tickets].source = "hosted"')
|
|
48
|
+
throw new Error(
|
|
49
|
+
'Hosted tickets require [tickets] url when [store] url / AB_STORE selects local builds',
|
|
50
|
+
)
|
|
49
51
|
}
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
env
|
|
52
|
+
const ticketSession = env.AB_TICKET_TOKEN !== undefined || env.AB_TICKET_URL !== undefined
|
|
53
|
+
if (
|
|
54
|
+
ticketSession &&
|
|
55
|
+
(!env.AB_TICKET_TOKEN ||
|
|
56
|
+
!env.AB_TICKET_URL ||
|
|
57
|
+
normalizeServiceURL(env.AB_TICKET_URL) !== normalizeServiceURL(rawUrl))
|
|
54
58
|
)
|
|
59
|
+
throw new Error('Ticket session credential does not match the selected ticket service')
|
|
60
|
+
const token = ticketSession
|
|
61
|
+
? env.AB_TICKET_TOKEN
|
|
62
|
+
: projectToken(
|
|
63
|
+
rawUrl,
|
|
64
|
+
(await resolveRepoOrigin(targetRepo, spawnExec)) ?? targetRepo,
|
|
65
|
+
config.url &&
|
|
66
|
+
(!buildUrl ||
|
|
67
|
+
!/^https?:/.test(buildUrl) ||
|
|
68
|
+
normalizeServiceURL(config.url) !== normalizeServiceURL(buildUrl))
|
|
69
|
+
? { ...env, AB_TOKEN: undefined }
|
|
70
|
+
: env,
|
|
71
|
+
)
|
|
55
72
|
if (!token) {
|
|
56
73
|
throw new Error(
|
|
57
74
|
'No project credential; run ab connect (or set AB_TOKEN) — a project credential is required when [tickets].source = "hosted"',
|
|
@@ -60,13 +77,32 @@ export async function createTicketSource(
|
|
|
60
77
|
if (config.teamKey === undefined) {
|
|
61
78
|
throw new Error('[tickets].source = "hosted" requires teamKey')
|
|
62
79
|
}
|
|
63
|
-
|
|
80
|
+
const source = new HostedTicketSource({
|
|
64
81
|
url: serviceUrl.toString(),
|
|
65
82
|
token,
|
|
66
83
|
teamKey: config.teamKey,
|
|
67
84
|
...(config.claimedState !== undefined ? { claimedState: config.claimedState } : {}),
|
|
68
85
|
...(config.createState !== undefined ? { createState: config.createState } : {}),
|
|
69
86
|
})
|
|
87
|
+
// Delegated sessions may read policy, but never redefine project authority.
|
|
88
|
+
if (!ticketSession && !env.AB_HARVEST && !env.AB_PHASE) {
|
|
89
|
+
const lifecycle = ticketLifecycle(config)
|
|
90
|
+
await source.synchronizePolicy({
|
|
91
|
+
version: 1,
|
|
92
|
+
tickets: {
|
|
93
|
+
source: 'hosted',
|
|
94
|
+
teamKey: config.teamKey,
|
|
95
|
+
readyState: lifecycle.ready,
|
|
96
|
+
triageState: lifecycle.triage,
|
|
97
|
+
claimedState: lifecycle.doing,
|
|
98
|
+
doneState: lifecycle.done,
|
|
99
|
+
createState: lifecycle.create,
|
|
100
|
+
proposalState: lifecycle.proposal,
|
|
101
|
+
...(config.readyLabels !== undefined ? { readyLabels: config.readyLabels } : {}),
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
return source
|
|
70
106
|
}
|
|
71
107
|
|
|
72
108
|
if (config.source === 'linear') {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { fileRequestedIds } from './requested-id'
|
|
2
|
+
import { ticketRevision, checkTicketRevision } from './revision'
|
|
1
3
|
/**
|
|
2
4
|
* In-memory TicketSource for seam tests (SPEC §3.2, §13). Mirrors the policy
|
|
3
5
|
* shape of the real adapters: initiation (listReady/get/claim/create),
|
|
@@ -12,11 +14,13 @@ import type {
|
|
|
12
14
|
TicketListing,
|
|
13
15
|
TicketSource,
|
|
14
16
|
TicketUpdate,
|
|
17
|
+
TicketMutation,
|
|
15
18
|
} from '../types'
|
|
16
19
|
import { validateTicketUpdate } from './update'
|
|
17
20
|
|
|
18
21
|
function cloneTicket(ticket: Ticket, doneState: string): Ticket {
|
|
19
22
|
return {
|
|
23
|
+
revision: ticketRevision(ticket),
|
|
20
24
|
ref: { ...ticket.ref },
|
|
21
25
|
...(ticket.creationKey !== undefined ? { creationKey: ticket.creationKey } : {}),
|
|
22
26
|
title: ticket.title,
|
|
@@ -29,6 +33,7 @@ function cloneTicket(ticket: Ticket, doneState: string): Ticket {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export class FakeTicketSource implements TicketSource {
|
|
36
|
+
readonly requestedIds = fileRequestedIds
|
|
32
37
|
readonly name = 'fake'
|
|
33
38
|
|
|
34
39
|
/** Journal of every projection sent outward (§13), in call order. */
|
|
@@ -111,6 +116,7 @@ export class FakeTicketSource implements TicketSource {
|
|
|
111
116
|
const ticket = this.tickets.get(id)
|
|
112
117
|
if (!ticket || ticket.state === this.doneState || this.claimed.has(id)) return false
|
|
113
118
|
this.claimed.add(id)
|
|
119
|
+
ticket.state = 'Doing'
|
|
114
120
|
return true
|
|
115
121
|
}
|
|
116
122
|
|
|
@@ -131,8 +137,10 @@ export class FakeTicketSource implements TicketSource {
|
|
|
131
137
|
const adopted = this.idempotency.get(opts.idempotencyKey)
|
|
132
138
|
if (adopted !== undefined) return cloneTicket(this.require(adopted, 'create'), this.doneState)
|
|
133
139
|
}
|
|
134
|
-
const id = `fake-${this.nextId++}`
|
|
140
|
+
const id = opts.requestedId ?? `fake-${this.nextId++}`
|
|
141
|
+
if (this.tickets.has(id)) throw new Error(`requested ticket id already occupied: ${id}`)
|
|
135
142
|
const ticket: Ticket = {
|
|
143
|
+
revision: 'pending',
|
|
136
144
|
ref: { source: this.name, id, title: draft.title },
|
|
137
145
|
...(opts.idempotencyKey !== undefined ? { creationKey: opts.idempotencyKey } : {}),
|
|
138
146
|
title: draft.title,
|
|
@@ -153,6 +161,27 @@ export class FakeTicketSource implements TicketSource {
|
|
|
153
161
|
return cloneTicket(ticket, this.doneState)
|
|
154
162
|
}
|
|
155
163
|
|
|
164
|
+
async mutateIfRevision(id: string, expected: string, mutation: TicketMutation): Promise<void> {
|
|
165
|
+
const ticket = this.require(id, 'revision-checked write')
|
|
166
|
+
checkTicketRevision(cloneTicket(ticket, this.doneState), expected)
|
|
167
|
+
if (mutation.kind === 'update') await this.update(id, mutation.patch)
|
|
168
|
+
else if (mutation.kind === 'transition') await this.transition(id, mutation.state)
|
|
169
|
+
else {
|
|
170
|
+
for (const blocker of mutation.ids) {
|
|
171
|
+
this.require(blocker, 'blocker')
|
|
172
|
+
if (mutation.operation === 'block' && blocker === id)
|
|
173
|
+
throw new Error(`ticket "${id}" cannot block itself`)
|
|
174
|
+
}
|
|
175
|
+
// No await between validation and in-memory batch application.
|
|
176
|
+
const blockers = new Set(ticket.blockedBy ?? [])
|
|
177
|
+
for (const blocker of mutation.ids) {
|
|
178
|
+
if (mutation.operation === 'block') blockers.add(blocker)
|
|
179
|
+
else blockers.delete(blocker)
|
|
180
|
+
}
|
|
181
|
+
ticket.blockedBy = [...blockers]
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
156
185
|
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
157
186
|
const validated = validateTicketUpdate(patch)
|
|
158
187
|
const ticket = this.require(id, 'update')
|