@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.
Files changed (136) hide show
  1. package/README.md +3 -1
  2. package/SPEC.md +335 -31
  3. package/docs/architecture.md +125 -7
  4. package/docs/configuration.md +142 -21
  5. package/docs/hosted-dispatcher.md +17 -2
  6. package/docs/hosted-service.md +240 -0
  7. package/docs/mcp.md +8 -2
  8. package/docs/operator-api.md +97 -7
  9. package/docs/remote-store-protocol.md +199 -144
  10. package/docs/remote-ticket-protocol.md +249 -19
  11. package/docs/setup.md +199 -16
  12. package/docs/ticket-dependencies.md +1 -1
  13. package/docs/ticket-migration.md +64 -0
  14. package/package.json +7 -6
  15. package/packages/core/src/cli/attention.ts +209 -0
  16. package/packages/core/src/cli/build-control.ts +12 -1
  17. package/packages/core/src/cli/configured-store.ts +17 -0
  18. package/packages/core/src/cli/connect.ts +364 -0
  19. package/packages/core/src/cli/context.ts +7 -1
  20. package/packages/core/src/cli/dashboard/model.ts +4 -5
  21. package/packages/core/src/cli/dashboard/poll.ts +1 -1
  22. package/packages/core/src/cli/dispatch.ts +336 -41
  23. package/packages/core/src/cli/harvest.ts +13 -3
  24. package/packages/core/src/cli/help.ts +51 -16
  25. package/packages/core/src/cli/init-validation.ts +27 -2
  26. package/packages/core/src/cli/installation.ts +67 -13
  27. package/packages/core/src/cli/main.ts +60 -16
  28. package/packages/core/src/cli/mcp.ts +9 -2
  29. package/packages/core/src/cli/pr-summary-publication.ts +220 -0
  30. package/packages/core/src/cli/pr-summary.ts +2 -0
  31. package/packages/core/src/cli/project-credentials.ts +132 -0
  32. package/packages/core/src/cli/repo-state.ts +15 -2
  33. package/packages/core/src/cli/self-update.ts +15 -1
  34. package/packages/core/src/cli/store-opening.ts +22 -14
  35. package/packages/core/src/cli/terminals.ts +0 -10
  36. package/packages/core/src/cli/ticket-assets.ts +7 -2
  37. package/packages/core/src/cli/ticket-editor.ts +82 -0
  38. package/packages/core/src/cli/ticket.ts +172 -35
  39. package/packages/core/src/cli/upgrade-compatibility.ts +80 -0
  40. package/packages/core/src/cli/wait.ts +84 -17
  41. package/packages/core/src/cli/watch.ts +197 -60
  42. package/packages/core/src/config/live.ts +3 -0
  43. package/packages/core/src/config/schema.ts +90 -11
  44. package/packages/core/src/connection.ts +86 -0
  45. package/packages/core/src/events/catalog.ts +11 -2
  46. package/packages/core/src/events/payloads.ts +50 -0
  47. package/packages/core/src/events/repository.ts +13 -0
  48. package/packages/core/src/integration/harness.ts +37 -2
  49. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +145 -83
  50. package/packages/core/src/kernel/engine.ts +24 -0
  51. package/packages/core/src/kernel/generators/build-log.ts +11 -1
  52. package/packages/core/src/kernel/incremental-fixtures.ts +13 -2
  53. package/packages/core/src/kernel/log-index.ts +17 -0
  54. package/packages/core/src/kernel/pr-summary.ts +89 -0
  55. package/packages/core/src/kernel/reducer-registry.ts +10 -0
  56. package/packages/core/src/kernel/reducer.ts +8 -0
  57. package/packages/core/src/ontology.ts +2 -0
  58. package/packages/core/src/operator/annotations.ts +12 -0
  59. package/packages/core/src/operator/index.ts +12 -0
  60. package/packages/core/src/operator/protocol.ts +19 -2
  61. package/packages/core/src/operator/query.ts +38 -16
  62. package/packages/core/src/operator/registry.ts +61 -8
  63. package/packages/core/src/operator/ticket-source-backend.ts +17 -20
  64. package/packages/core/src/operator/tickets.ts +136 -28
  65. package/packages/core/src/plugin-sdk/index.ts +7 -0
  66. package/packages/core/src/plugins/manifest.ts +5 -3
  67. package/packages/core/src/plugins/runtimes.ts +7 -1
  68. package/packages/core/src/ports/forge/fake.ts +13 -1
  69. package/packages/core/src/ports/forge/github-transport.ts +36 -1
  70. package/packages/core/src/ports/forge/github.ts +29 -5
  71. package/packages/core/src/ports/forge/local-git.ts +12 -4
  72. package/packages/core/src/ports/forge/summary-marker.ts +8 -0
  73. package/packages/core/src/ports/runner/session-env.ts +3 -0
  74. package/packages/core/src/ports/tickets/contract.ts +66 -1
  75. package/packages/core/src/ports/tickets/create.ts +54 -10
  76. package/packages/core/src/ports/tickets/fake.ts +30 -1
  77. package/packages/core/src/ports/tickets/file-assets.ts +159 -0
  78. package/packages/core/src/ports/tickets/file-codec.ts +90 -0
  79. package/packages/core/src/ports/tickets/file.ts +114 -120
  80. package/packages/core/src/ports/tickets/grants.ts +47 -0
  81. package/packages/core/src/ports/tickets/hosted-index.ts +17 -3
  82. package/packages/core/src/ports/tickets/identity.ts +20 -0
  83. package/packages/core/src/ports/tickets/lifecycle.ts +18 -0
  84. package/packages/core/src/ports/tickets/linear.ts +74 -9
  85. package/packages/core/src/ports/tickets/migration.ts +336 -0
  86. package/packages/core/src/ports/tickets/operations.ts +38 -7
  87. package/packages/core/src/ports/tickets/portable-assets.ts +54 -0
  88. package/packages/core/src/ports/tickets/remote-protocol.ts +96 -0
  89. package/packages/core/src/ports/tickets/remote.ts +61 -3
  90. package/packages/core/src/ports/tickets/requested-id.ts +46 -0
  91. package/packages/core/src/ports/tickets/revision.ts +62 -0
  92. package/packages/core/src/ports/tickets/states.ts +61 -0
  93. package/packages/core/src/ports/types.ts +40 -3
  94. package/packages/core/src/ports/workspace/build-execution.ts +6 -1
  95. package/packages/core/src/ports/workspace/create.ts +15 -2
  96. package/packages/core/src/ports/workspace/distribution-archive.ts +3 -5
  97. package/packages/core/src/ports/workspace/harvest-execution.ts +3 -1
  98. package/packages/core/src/ports/workspace/local-build-execution.ts +8 -1
  99. package/packages/core/src/ports/workspace/operator-sandbox.ts +2 -0
  100. package/packages/core/src/ports/workspace/vercel-sandbox.ts +51 -19
  101. package/packages/core/src/processes/build-execution-state.ts +1 -1
  102. package/packages/core/src/processes/build-runner.ts +87 -26
  103. package/packages/core/src/processes/dispatch-failure-budget.ts +83 -0
  104. package/packages/core/src/processes/dispatcher.ts +168 -41
  105. package/packages/core/src/processes/execution-no-progress.ts +176 -0
  106. package/packages/core/src/processes/harvest-child.ts +31 -4
  107. package/packages/core/src/processes/harvest-execution-state.ts +19 -2
  108. package/packages/core/src/processes/harvest-runner.ts +166 -104
  109. package/packages/core/src/processes/harvest.ts +34 -14
  110. package/packages/core/src/processes/phase-failure-budget.ts +2 -0
  111. package/packages/core/src/processes/publication-loss.ts +18 -1
  112. package/packages/core/src/processes/repo-view.ts +35 -0
  113. package/packages/core/src/store/contract.ts +32 -1
  114. package/packages/core/src/store/local/store.ts +1 -1
  115. package/packages/core/src/store/remote/client.ts +1 -1
  116. package/packages/core/src/store/remote/compatibility.ts +90 -0
  117. package/packages/core/src/store/remote/credentials.ts +28 -0
  118. package/packages/core/src/store/remote/index.ts +14 -1
  119. package/packages/core/src/store/remote/protocol.ts +25 -0
  120. package/packages/core/src/store/remote/token.ts +74 -13
  121. package/packages/core/src/store/ticket-asset-snapshots.ts +192 -0
  122. package/packages/core/src/store/ticket-assets.ts +86 -8
  123. package/packages/core/src/store/types.ts +1 -1
  124. package/packages/core/src/testing/index.ts +7 -0
  125. package/skills/guide/SKILL.md +232 -34
  126. package/skills/guide/references/operator-api.md +142 -13
  127. package/skills/guide/references/plugin-authoring.md +10 -2
  128. package/skills/guide/references/remote-store-protocol.md +199 -144
  129. package/skills/guide/references/remote-ticket-protocol.md +253 -18
  130. package/skills/guide/references/setup.md +199 -16
  131. package/skills/guide/references/ticket-dependencies.md +1 -1
  132. package/skills/guide/references/ticket-migration.md +64 -0
  133. package/skills/operate/SKILL.md +126 -36
  134. package/skills/tickets/SKILL.md +71 -0
  135. package/templates/hosted-service/package.json +13 -0
  136. 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 { defaultTriageState } from '../processes/dispatcher'
13
- import { effectiveConfig, OperatorQueryError } from './query'
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
- teamKey: tickets.teamKey,
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<{ config: Config; context: OperatorTicketContext; source: TicketSource }> {
69
- const { config } = await effectiveConfig(opts.store, opts.repo)
70
- const context = contextFor(config, opts.repo)
71
- return { config, context, source: await opts.backend.sourceFor(context) }
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 { config, context, source } = await openOperatorTickets(opts)
82
- const triageState = defaultTriageState(config)
83
- const readyState = config.tickets.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
- const matches = (await store.listBuilds()).filter(
110
- (record) => record.repo === repo && record.ticket?.id === id,
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 { ticket, blockers, build: await matchingBuild(opts.store, opts.repo, opts.id) }
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(source, operation.id, operation.patch))!
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(source, operation.id, operation.blockerIds, operation.kind)
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 { ticket, blockers, build: await matchingBuild(opts.store, opts.repo, ticket.ref.id) }
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
- /** Scoped store token (`AB_TOKEN`). Guaranteed present under
55
- * `capabilities.storeRequirements` for the same reason. */
56
- storeToken?: string
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({ ...opts.env })
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
- this.comments.push({ workspacePath, number, body })
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(status: number, message: string, body?: unknown) {
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
- await this.request(
1135
- 'POST',
1136
- `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/issues/${number}/comments`,
1137
- { body: { body } },
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
- await this.updateRecord(workspacePath, number, (record) => ({
594
- ...record,
595
- comments: record.comments.includes(body) ? record.comments : [...record.comments, body],
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
- expect(await harness.source.get(created.ref.id)).toEqual(created)
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 rawUrl = env.AB_STORE?.trim()
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
- 'AB_STORE is not set — required when [tickets].source = "hosted"; set it to the hosted service HTTP(S) URL',
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('AB_STORE must be a valid HTTP(S) URL when [tickets].source = "hosted"')
42
- }
43
- if (serviceUrl.protocol !== 'http:' && serviceUrl.protocol !== 'https:') {
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 token = env.AB_TOKEN?.trim()
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
- 'AB_TOKEN is not set — an operator-scope token is required when [tickets].source = "hosted"',
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
- return new HostedTicketSource({
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') {