@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
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { samplePinnedAssets, preparePinnedAssets } from '../store/ticket-assets'
|
|
2
|
+
import type { TicketRef } from '../ontology'
|
|
1
3
|
import type { Config } from '../config/schema'
|
|
2
4
|
import { reduceBuild } from '../kernel/reducer'
|
|
3
5
|
import {
|
|
6
|
+
TicketOperationError,
|
|
4
7
|
changeBlockers,
|
|
5
8
|
createTicket,
|
|
6
9
|
moveTicket,
|
|
@@ -9,16 +12,19 @@ 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 {
|
|
13
|
-
import { effectiveConfig
|
|
15
|
+
import { ticketLifecycle } from '../ports/tickets/lifecycle'
|
|
16
|
+
import { effectiveConfig } from './query'
|
|
14
17
|
|
|
15
18
|
export interface OperatorTicketContext {
|
|
19
|
+
repo: string
|
|
20
|
+
tickets: Config['tickets']
|
|
16
21
|
teamKey: string
|
|
17
22
|
claimedState?: string
|
|
18
23
|
createState?: string
|
|
19
24
|
}
|
|
20
25
|
|
|
21
26
|
export interface OperatorTicketBackend {
|
|
27
|
+
contextFor?(repo: string): Promise<OperatorTicketContext>
|
|
22
28
|
sourceFor(context: OperatorTicketContext): TicketSource | Promise<TicketSource>
|
|
23
29
|
statesFor(context: OperatorTicketContext, source: TicketSource): string[] | Promise<string[]>
|
|
24
30
|
}
|
|
@@ -48,14 +54,10 @@ export interface OperatorTicketDetail {
|
|
|
48
54
|
|
|
49
55
|
function contextFor(config: Config, repo: string): OperatorTicketContext {
|
|
50
56
|
const tickets = config.tickets
|
|
51
|
-
if (tickets.teamKey === undefined) {
|
|
52
|
-
throw new OperatorQueryError(
|
|
53
|
-
'effective-config-unavailable',
|
|
54
|
-
`effective config unavailable for repository "${repo}": [tickets].teamKey is required for hosted ticket access`,
|
|
55
|
-
)
|
|
56
|
-
}
|
|
57
57
|
return {
|
|
58
|
-
|
|
58
|
+
repo,
|
|
59
|
+
tickets,
|
|
60
|
+
teamKey: tickets.teamKey ?? '',
|
|
59
61
|
...(tickets.claimedState !== undefined ? { claimedState: tickets.claimedState } : {}),
|
|
60
62
|
...(tickets.createState !== undefined ? { createState: tickets.createState } : {}),
|
|
61
63
|
}
|
|
@@ -65,10 +67,11 @@ export async function openOperatorTickets(opts: {
|
|
|
65
67
|
store: BuildStore
|
|
66
68
|
repo: string
|
|
67
69
|
backend: OperatorTicketBackend
|
|
68
|
-
}): Promise<{
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
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) }
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
export async function listOperatorTickets(opts: {
|
|
@@ -78,9 +81,9 @@ export async function listOperatorTickets(opts: {
|
|
|
78
81
|
state?: string
|
|
79
82
|
labels?: string[]
|
|
80
83
|
}): Promise<OperatorTicketQueue> {
|
|
81
|
-
const {
|
|
82
|
-
const triageState =
|
|
83
|
-
const readyState =
|
|
84
|
+
const { context, source } = await openOperatorTickets(opts)
|
|
85
|
+
const triageState = ticketLifecycle(context.tickets).triage
|
|
86
|
+
const readyState = context.tickets.readyState
|
|
84
87
|
// The operator surface is a grooming queue. Unlike the CLI's ticket list,
|
|
85
88
|
// labels narrow the default state instead of replacing its lifecycle gate.
|
|
86
89
|
const criteria = {
|
|
@@ -105,10 +108,36 @@ async function matchingBuild(
|
|
|
105
108
|
store: BuildStore,
|
|
106
109
|
repo: string,
|
|
107
110
|
id: string,
|
|
111
|
+
source: TicketSource,
|
|
112
|
+
configuredSource: string,
|
|
108
113
|
): Promise<OperatorTicketBuild | null> {
|
|
109
|
-
|
|
110
|
-
|
|
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')),
|
|
111
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
|
+
}
|
|
112
141
|
const projected = await Promise.all(
|
|
113
142
|
matches.map(async (record) => ({
|
|
114
143
|
record,
|
|
@@ -138,10 +167,20 @@ export async function getOperatorTicket(opts: {
|
|
|
138
167
|
backend: OperatorTicketBackend
|
|
139
168
|
id: string
|
|
140
169
|
}): Promise<OperatorTicketDetail> {
|
|
141
|
-
const { source } = await openOperatorTickets(opts)
|
|
170
|
+
const { source, context } = await openOperatorTickets(opts)
|
|
142
171
|
const ticket = await requireTicket(source, opts.id)
|
|
143
172
|
const blockers = await source.dependencyStates(ticket.blockedBy ?? [])
|
|
144
|
-
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
|
+
}
|
|
145
184
|
}
|
|
146
185
|
|
|
147
186
|
export async function mutateOperatorTicket(opts: {
|
|
@@ -157,21 +196,90 @@ export async function mutateOperatorTicket(opts: {
|
|
|
157
196
|
state?: string
|
|
158
197
|
blockedBy?: string[]
|
|
159
198
|
}
|
|
160
|
-
| { kind: 'update'; id: string; patch: TicketUpdate }
|
|
161
|
-
| { kind: 'move'; id: string; state: string }
|
|
162
|
-
| { 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 }
|
|
163
202
|
}): Promise<OperatorTicketDetail> {
|
|
164
|
-
const { source } = await openOperatorTickets(opts)
|
|
203
|
+
const { source, context } = await openOperatorTickets(opts)
|
|
165
204
|
const operation = opts.operation
|
|
166
205
|
if (operation.kind === 'update') await requireTicket(source, operation.id)
|
|
167
206
|
const ticket =
|
|
168
207
|
operation.kind === 'create'
|
|
169
208
|
? await createTicket(source, operation)
|
|
170
209
|
: operation.kind === 'update'
|
|
171
|
-
? (await updateTicket(
|
|
210
|
+
? (await updateTicket(
|
|
211
|
+
source,
|
|
212
|
+
operation.id,
|
|
213
|
+
operation.patch,
|
|
214
|
+
true,
|
|
215
|
+
operation.expectedRevision,
|
|
216
|
+
))!
|
|
172
217
|
: operation.kind === 'move'
|
|
173
|
-
? await moveTicket(source, operation.id, operation.state)
|
|
174
|
-
: 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
|
+
)
|
|
175
226
|
const blockers = await source.dependencyStates(ticket.blockedBy ?? [])
|
|
176
|
-
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
|
+
}
|
|
177
285
|
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export type * from '../ports/types'
|
|
7
7
|
export type * from '../ports/workspace/build-execution'
|
|
8
|
+
export type * from '../ports/workspace/harvest-execution'
|
|
8
9
|
export type * from '../store/types'
|
|
9
10
|
export * from '../store/session-reducer'
|
|
10
11
|
export type {
|
|
@@ -151,6 +152,12 @@ export type {
|
|
|
151
152
|
|
|
152
153
|
export { FakeTicketSource } from '../ports/tickets/fake'
|
|
153
154
|
export { validateTicketUpdate } from '../ports/tickets/update'
|
|
155
|
+
export {
|
|
156
|
+
TicketRevisionConflict,
|
|
157
|
+
TicketRevisionUnsupported,
|
|
158
|
+
ticketRevision,
|
|
159
|
+
checkTicketRevision,
|
|
160
|
+
} from '../ports/tickets/revision'
|
|
154
161
|
export { FakeWorkspaceProvider } from '../ports/workspace/fake'
|
|
155
162
|
export { FakeForge } from '../ports/forge/fake'
|
|
156
163
|
// ScriptContext + defaultTurnResult are the scripted fake's own API: the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { delegateCredential } from '../store/remote/credentials'
|
|
1
2
|
import semver from 'semver'
|
|
2
3
|
import { z } from 'zod'
|
|
3
4
|
import { openMap } from '../open-map'
|
|
@@ -51,9 +52,10 @@ export interface WorkspaceProviderPluginFactoryContext<Config = Record<string, u
|
|
|
51
52
|
* `createWorkspaceProvider` refuses construction before the factory runs
|
|
52
53
|
* when either store seam is missing. */
|
|
53
54
|
storeRef?: string
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
/** @deprecated Project credentials are never delivered to plugin factories. */
|
|
56
|
+
storeToken?: never
|
|
57
|
+
/** Host-only exchange; pass the returned per-resource token at launch. */
|
|
58
|
+
delegateCredential?: typeof delegateCredential
|
|
57
59
|
/** Host-derived effective runtime routes (the referenced runtimes and their
|
|
58
60
|
* selection provenance). Absent at call sites that reference no runtimes
|
|
59
61
|
* (for example the MCP server), which never construct a workspace. */
|
|
@@ -39,7 +39,13 @@ export async function materializePluginRuntimes(
|
|
|
39
39
|
|
|
40
40
|
const merged = Object.assign(Object.create(null) as RuntimeRegistry, builtins)
|
|
41
41
|
const config = Object.freeze({})
|
|
42
|
-
const env = Object.freeze(
|
|
42
|
+
const env = Object.freeze(
|
|
43
|
+
Object.fromEntries(
|
|
44
|
+
Object.entries(opts.env).filter(
|
|
45
|
+
([key]) => !['AB_TOKEN', 'AB_TICKET_TOKEN', 'AB_TICKET_URL'].includes(key),
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
43
49
|
const context: PluginFactoryContext = {
|
|
44
50
|
config,
|
|
45
51
|
env,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findSummaryMarker } from './summary-marker'
|
|
1
2
|
/**
|
|
2
3
|
* FakeForge (SPEC §3.2): in-memory Forge for seam tests. Gate existence and
|
|
3
4
|
* current PR merge state are independent inputs, matching GitHubForge's safety
|
|
@@ -514,6 +515,17 @@ export class FakeForge implements Forge {
|
|
|
514
515
|
|
|
515
516
|
async commentOnPr(workspacePath: string, number: number, body: string): Promise<void> {
|
|
516
517
|
this.assertPr(number)
|
|
517
|
-
|
|
518
|
+
const marker = findSummaryMarker(body)
|
|
519
|
+
const existing =
|
|
520
|
+
marker === undefined
|
|
521
|
+
? undefined
|
|
522
|
+
: this.comments.find(
|
|
523
|
+
(comment) =>
|
|
524
|
+
comment.number === number &&
|
|
525
|
+
comment.workspacePath === workspacePath &&
|
|
526
|
+
findSummaryMarker(comment.body) === marker,
|
|
527
|
+
)
|
|
528
|
+
if (existing !== undefined) existing.body = body
|
|
529
|
+
else this.comments.push({ workspacePath, number, body })
|
|
518
530
|
}
|
|
519
531
|
}
|
|
@@ -52,7 +52,12 @@ export class GitHubApiError extends Error {
|
|
|
52
52
|
readonly status: number
|
|
53
53
|
readonly body?: unknown
|
|
54
54
|
|
|
55
|
-
constructor(
|
|
55
|
+
constructor(
|
|
56
|
+
status: number,
|
|
57
|
+
message: string,
|
|
58
|
+
body?: unknown,
|
|
59
|
+
readonly headers: Record<string, string> = {},
|
|
60
|
+
) {
|
|
56
61
|
super(message)
|
|
57
62
|
this.name = 'GitHubApiError'
|
|
58
63
|
this.status = status
|
|
@@ -225,6 +230,7 @@ export function createGitHubFetchTransport(opts: {
|
|
|
225
230
|
response.status,
|
|
226
231
|
`${githubErrorMessage(response.status, json, text)}${anonymous}`,
|
|
227
232
|
json,
|
|
233
|
+
headerMap,
|
|
228
234
|
)
|
|
229
235
|
}
|
|
230
236
|
return {
|
|
@@ -361,3 +367,32 @@ export function githubTokenSource(
|
|
|
361
367
|
): () => Promise<GitHubCredential> {
|
|
362
368
|
return () => resolveGitHubToken(env, exec)
|
|
363
369
|
}
|
|
370
|
+
|
|
371
|
+
/** Documented primary and secondary limits; permission failures are ordinary errors. */
|
|
372
|
+
export function githubRateLimitUntil(error: unknown, now: number): string | undefined {
|
|
373
|
+
if (!(error instanceof GitHubApiError)) return undefined
|
|
374
|
+
if (
|
|
375
|
+
error.status !== 429 &&
|
|
376
|
+
!(
|
|
377
|
+
error.status === 403 &&
|
|
378
|
+
(error.headers['x-ratelimit-remaining'] === '0' ||
|
|
379
|
+
/rate limit|secondary.*limit|content.creation.*limit/i.test(error.message))
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
return undefined
|
|
383
|
+
const retry = error.headers['retry-after']
|
|
384
|
+
const seconds = retry === undefined ? NaN : Number(retry)
|
|
385
|
+
const retryAt = Number.isFinite(seconds) ? now + seconds * 1000 : Date.parse(retry ?? '')
|
|
386
|
+
// The reset describes the primary quota window, even on secondary-limit responses.
|
|
387
|
+
const reset =
|
|
388
|
+
error.headers['x-ratelimit-remaining'] === '0'
|
|
389
|
+
? Number(error.headers['x-ratelimit-reset']) * 1000
|
|
390
|
+
: NaN
|
|
391
|
+
return new Date(
|
|
392
|
+
Math.max(
|
|
393
|
+
now + 60_000,
|
|
394
|
+
Number.isFinite(retryAt) ? retryAt : 0,
|
|
395
|
+
Number.isFinite(reset) ? reset : 0,
|
|
396
|
+
),
|
|
397
|
+
).toISOString()
|
|
398
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findSummaryMarker } from './summary-marker'
|
|
1
2
|
/**
|
|
2
3
|
* GitHubForge (SPEC §3.2): the Forge adapter for GitHub, speaking the GitHub
|
|
3
4
|
* REST API through an injectable transport seam. All remote access is
|
|
@@ -1131,11 +1132,34 @@ export class GitHubForge implements Forge {
|
|
|
1131
1132
|
/** The build's summary comment (SPEC §7.5) — links into the store. */
|
|
1132
1133
|
async commentOnPr(_workspacePath: string, number: number, body: string): Promise<void> {
|
|
1133
1134
|
const { owner, name } = await this.requireCoordinates()
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1135
|
+
const root = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
|
|
1136
|
+
const collection = `${root}/issues/${number}/comments`
|
|
1137
|
+
const marker = findSummaryMarker(body)
|
|
1138
|
+
if (marker !== undefined) {
|
|
1139
|
+
const schema = z.array(z.object({ id: z.number().int().positive(), body: z.string() }))
|
|
1140
|
+
// A failed discovery never authorizes creation. Re-scan once if an edit
|
|
1141
|
+
// races deletion; an uncertain POST is rediscovered on the next phase.
|
|
1142
|
+
for (let scan = 0; scan < 2; scan++) {
|
|
1143
|
+
let match: { id: number; body: string } | undefined
|
|
1144
|
+
for (let page = 1; ; page++) {
|
|
1145
|
+
const response = await this.request('GET', collection, {
|
|
1146
|
+
query: { per_page: '100', page: String(page) },
|
|
1147
|
+
})
|
|
1148
|
+
const comments = schema.parse(response.json)
|
|
1149
|
+
match ??= comments.find((comment) => findSummaryMarker(comment.body) === marker)
|
|
1150
|
+
if (comments.length < 100) break
|
|
1151
|
+
}
|
|
1152
|
+
if (match === undefined) break
|
|
1153
|
+
if (match.body === body) return
|
|
1154
|
+
try {
|
|
1155
|
+
await this.request('PATCH', `${root}/issues/comments/${match.id}`, { body: { body } })
|
|
1156
|
+
return
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
if (!(error instanceof GitHubApiError) || error.status !== 404 || scan !== 0) throw error
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
await this.request('POST', collection, { body: { body } })
|
|
1139
1163
|
}
|
|
1140
1164
|
|
|
1141
1165
|
// ── Optional capabilities (checkout-less dispatcher) ──────────────────────
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findSummaryMarker } from './summary-marker'
|
|
1
2
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
3
|
import { tmpdir } from 'node:os'
|
|
3
4
|
import { join } from 'node:path'
|
|
@@ -590,9 +591,16 @@ export class LocalGitForge implements Forge {
|
|
|
590
591
|
}
|
|
591
592
|
|
|
592
593
|
async commentOnPr(workspacePath: string, number: number, body: string): Promise<void> {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
594
|
+
const marker = findSummaryMarker(body)
|
|
595
|
+
await this.updateRecord(workspacePath, number, (record) => {
|
|
596
|
+
const index =
|
|
597
|
+
marker === undefined
|
|
598
|
+
? -1
|
|
599
|
+
: record.comments.findIndex((comment) => findSummaryMarker(comment) === marker)
|
|
600
|
+
const comments = [...record.comments]
|
|
601
|
+
if (index >= 0) comments[index] = body
|
|
602
|
+
else if (!comments.includes(body)) comments.push(body)
|
|
603
|
+
return { ...record, comments }
|
|
604
|
+
})
|
|
597
605
|
}
|
|
598
606
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Stable, injection-safe identity shared by built-in forge adapters. */
|
|
2
|
+
export function summaryMarker(build: string): string {
|
|
3
|
+
return `<!-- autobuild-summary:v1:${Buffer.from(build).toString('hex')} -->`
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function findSummaryMarker(body: string): string | undefined {
|
|
7
|
+
return body.split('\n').find((line) => /^<!-- autobuild-summary:v1:[0-9a-f]+ -->$/.test(line))
|
|
8
|
+
}
|
|
@@ -21,6 +21,9 @@ export function sessionEnv(
|
|
|
21
21
|
for (const [key, value] of Object.entries(ambient)) {
|
|
22
22
|
if (value !== undefined) env[key] = value
|
|
23
23
|
}
|
|
24
|
+
delete env.AB_TICKET_TOKEN
|
|
25
|
+
delete env.AB_TICKET_URL
|
|
26
|
+
delete env.AB_TOKEN
|
|
24
27
|
Object.assign(env, scoped)
|
|
25
28
|
|
|
26
29
|
const inheritedPath = env.PATH
|
|
@@ -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
|
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { configuredStore } from '../../cli/configured-store'
|
|
2
|
+
import { projectToken } from '../../cli/project-credentials'
|
|
3
|
+
import { resolveRepoOrigin } from '../../cli/repo-state'
|
|
4
|
+
import { spawnExec } from '../workspace/git-worktree'
|
|
1
5
|
/**
|
|
2
6
|
* TicketSource factory (SPEC §3.2, §13): construct the builtin or plugin source
|
|
3
7
|
* named by [tickets]. Plugin credentials, like LINEAR_API_KEY, come only from
|
|
@@ -12,6 +16,8 @@ import type { TicketSource } from '../types'
|
|
|
12
16
|
import { DEFAULT_TICKETS_DIR, FileTicketSource } from './file'
|
|
13
17
|
import { LinearTicketSource } from './linear'
|
|
14
18
|
import { HostedTicketSource } from './remote'
|
|
19
|
+
import { ticketLifecycle } from './lifecycle'
|
|
20
|
+
import { normalizeServiceURL } from '../../connection'
|
|
15
21
|
|
|
16
22
|
function reason(error: unknown): string {
|
|
17
23
|
return error instanceof Error ? error.message : String(error)
|
|
@@ -28,37 +34,75 @@ export async function createTicketSource(
|
|
|
28
34
|
plugins: PluginRegistry = createPluginRegistry(),
|
|
29
35
|
): Promise<TicketSource> {
|
|
30
36
|
if (config.source === 'hosted') {
|
|
31
|
-
const
|
|
37
|
+
const buildUrl = env.AB_STORE?.trim() || configuredStore(targetRepo)
|
|
38
|
+
const rawUrl = config.url ?? buildUrl
|
|
32
39
|
if (!rawUrl) {
|
|
33
40
|
throw new Error(
|
|
34
|
-
'
|
|
41
|
+
'Hosted tickets require [tickets] url when builds are local; alternatively set [store] url or AB_STORE to a service',
|
|
35
42
|
)
|
|
36
43
|
}
|
|
37
44
|
let serviceUrl: URL
|
|
38
45
|
try {
|
|
39
|
-
serviceUrl = new URL(rawUrl)
|
|
46
|
+
serviceUrl = new URL(normalizeServiceURL(rawUrl, 'tickets.url'))
|
|
40
47
|
} catch {
|
|
41
|
-
throw new Error(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
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
|
+
)
|
|
45
51
|
}
|
|
46
|
-
const
|
|
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))
|
|
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
|
+
)
|
|
47
72
|
if (!token) {
|
|
48
73
|
throw new Error(
|
|
49
|
-
'
|
|
74
|
+
'No project credential; run ab connect (or set AB_TOKEN) — a project credential is required when [tickets].source = "hosted"',
|
|
50
75
|
)
|
|
51
76
|
}
|
|
52
77
|
if (config.teamKey === undefined) {
|
|
53
78
|
throw new Error('[tickets].source = "hosted" requires teamKey')
|
|
54
79
|
}
|
|
55
|
-
|
|
80
|
+
const source = new HostedTicketSource({
|
|
56
81
|
url: serviceUrl.toString(),
|
|
57
82
|
token,
|
|
58
83
|
teamKey: config.teamKey,
|
|
59
84
|
...(config.claimedState !== undefined ? { claimedState: config.claimedState } : {}),
|
|
60
85
|
...(config.createState !== undefined ? { createState: config.createState } : {}),
|
|
61
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
|
|
62
106
|
}
|
|
63
107
|
|
|
64
108
|
if (config.source === 'linear') {
|