@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defrex/autobuild",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Agent-driven software delivery from groomed ticket to merged PR",
5
5
  "repository": {
6
6
  "type": "git",
@@ -86,6 +86,10 @@
86
86
  "types": "./packages/core/src/operator/sandbox-host.ts",
87
87
  "import": "./packages/core/src/operator/sandbox-host.ts",
88
88
  "default": "./packages/core/src/operator/sandbox-host.ts"
89
+ },
90
+ "./connection": {
91
+ "types": "./packages/core/src/connection.ts",
92
+ "import": "./packages/core/src/connection.ts"
89
93
  }
90
94
  },
91
95
  "files": [
@@ -139,8 +143,8 @@
139
143
  },
140
144
  "devDependencies": {
141
145
  "@biomejs/biome": "2.5.5",
142
- "@defrex/autobuild-postgres-store": "0.10.1",
143
- "@defrex/autobuild-vercel-sandbox": "0.10.1",
146
+ "@defrex/autobuild-postgres-store": "0.12.0",
147
+ "@defrex/autobuild-vercel-sandbox": "0.12.0",
144
148
  "@resvg/resvg-js": "2.6.2",
145
149
  "@types/bun": "^1.3.14",
146
150
  "@types/node": "^24.0.0",
@@ -150,8 +154,5 @@
150
154
  "git-format-staged": "4.0.1",
151
155
  "husky": "9.1.7",
152
156
  "typescript": "5.9.3"
153
- },
154
- "patchedDependencies": {
155
- "better-auth@1.4.18": "patches/better-auth@1.4.18.patch"
156
157
  }
157
158
  }
@@ -0,0 +1,209 @@
1
+ import type { AbEvent } from '../events/catalog'
2
+ import type { RepositoryEvent } from '../events/repository'
3
+ import { currentAutoMergeDeferral } from '../kernel/auto-merge'
4
+ import { reduceBuild, type BuildState } from '../kernel/reducer'
5
+
6
+ export interface MergeDecision {
7
+ type: 'build.merge-decision'
8
+ pr: { number: number; url: string; headSha: string }
9
+ reason: string
10
+ observedAt: string
11
+ }
12
+
13
+ /** Consent pending application is armed; only a current actionable deferral
14
+ * or withdrawn consent needs a person. Currency is owned by the kernel. */
15
+ export function mergeDecision(
16
+ events: AbEvent[],
17
+ now: Date,
18
+ state = reduceBuild(events),
19
+ ): MergeDecision | null {
20
+ if (
21
+ !state.finalizeCompletedSeq ||
22
+ !state.pr ||
23
+ state.prState !== 'open' ||
24
+ state.status === 'done' ||
25
+ state.status === 'aborted'
26
+ )
27
+ return null
28
+ const deferral = currentAutoMergeDeferral(events, state)
29
+ if (state.autoMerge.requested && deferral === undefined) return null
30
+ return {
31
+ type: 'build.merge-decision',
32
+ pr: state.pr,
33
+ reason: deferral ?? 'Auto-merge is not armed',
34
+ observedAt: now.toISOString(),
35
+ }
36
+ }
37
+
38
+ export function buildNeedsAttention(events: AbEvent[], state: BuildState): boolean {
39
+ return (
40
+ (state.openEscalations?.length ?? 0) > 0 || mergeDecision(events, new Date(0), state) !== null
41
+ )
42
+ }
43
+
44
+ export function mergeIdentity(slug: string, pr: number): string {
45
+ return JSON.stringify([slug, pr])
46
+ }
47
+
48
+ export function repositoryNeedsAttention(event: RepositoryEvent): boolean {
49
+ switch (event.type) {
50
+ case 'harvest.escalated':
51
+ case 'harvest.recovery-exhausted':
52
+ case 'dispatcher.config-rejected':
53
+ return true
54
+ case 'dispatcher.config-reloaded':
55
+ return event.payload.restartRequired.length > 0
56
+ case 'dispatcher.run-stopped':
57
+ return event.payload.outcome === 'abnormal'
58
+ default:
59
+ return false
60
+ }
61
+ }
62
+
63
+ type Degraded = Extract<RepositoryEvent, { type: 'dispatcher.ticket-source-degraded' }>
64
+ type Failed = Extract<RepositoryEvent, { type: 'dispatcher.tick-failed' }>
65
+ export type PersistenceObservation =
66
+ | {
67
+ type: 'dispatcher.ticket-source-persistent'
68
+ source: string
69
+ cause: string
70
+ message: string
71
+ resetAt?: string
72
+ episodeSeq: number
73
+ observedAt: string
74
+ dependencyLimited: boolean
75
+ }
76
+ | {
77
+ type: 'dispatcher.tick-failure-persistent'
78
+ run: string
79
+ firstFailure: string
80
+ error: string
81
+ episodeSeq: number
82
+ observedAt: string
83
+ }
84
+
85
+ /** Incremental journal projection. Episode starts survive run anchors and
86
+ * repeated evidence can strengthen, but never postpone, an existing deadline. */
87
+ export class RepositoryAttention {
88
+ private sources = new Map<
89
+ string,
90
+ {
91
+ event: Degraded
92
+ deadline: number
93
+ dependencyLimited: boolean
94
+ requiresRetry: boolean
95
+ retryObserved: boolean
96
+ }
97
+ >()
98
+ private failure: Failed | undefined
99
+ private tickStarts = new Map<string, number>()
100
+
101
+ private sourceDefersTick(now: number): boolean {
102
+ return [...this.sources.values()].some(
103
+ (source) => now < source.deadline || (source.requiresRetry && !source.retryObserved),
104
+ )
105
+ }
106
+
107
+ /** Crossing resetAt alone does not prove persistence: the dispatcher must
108
+ * finish a tick that could actually retry after the provider cooldown. */
109
+ private observeRetry(run: string): void {
110
+ const startedAt = this.tickStarts.get(run)
111
+ this.tickStarts.delete(run)
112
+ if (startedAt === undefined) return
113
+ for (const source of this.sources.values()) {
114
+ if (startedAt >= source.deadline) source.retryObserved = true
115
+ }
116
+ }
117
+ fold(event: RepositoryEvent): void {
118
+ switch (event.type) {
119
+ case 'dispatcher.ticket-source-degraded': {
120
+ const existing = this.sources.get(event.payload.source)
121
+ const deadline =
122
+ 'resetAt' in event.payload
123
+ ? Date.parse(event.payload.resetAt)
124
+ : Date.parse(event.ts) + 15 * 60_000
125
+ if (existing) {
126
+ existing.deadline = Math.min(existing.deadline, deadline)
127
+ existing.requiresRetry ||= 'resetAt' in event.payload
128
+ } else
129
+ this.sources.set(event.payload.source, {
130
+ event,
131
+ deadline,
132
+ dependencyLimited: false,
133
+ requiresRetry: 'resetAt' in event.payload,
134
+ retryObserved: false,
135
+ })
136
+ // Cooldown-driven failures are not an independent dispatcher outage.
137
+ if (this.sourceDefersTick(Date.parse(event.ts))) this.failure = undefined
138
+ break
139
+ }
140
+ case 'dispatcher.ticket-source-dependency-limited': {
141
+ const source = this.sources.get(event.payload.source)
142
+ if (source) source.dependencyLimited = true
143
+ break
144
+ }
145
+ case 'dispatcher.ticket-source-recovered':
146
+ this.sources.delete(event.payload.source)
147
+ break
148
+ case 'dispatcher.run-started':
149
+ this.failure = undefined
150
+ this.tickStarts.clear()
151
+ break
152
+ case 'dispatcher.run-stopped':
153
+ this.tickStarts.delete(event.payload.run)
154
+ if (this.failure?.payload.run === event.payload.run) this.failure = undefined
155
+ break
156
+ case 'dispatcher.tick-started':
157
+ this.tickStarts.set(event.payload.run, Date.parse(event.ts))
158
+ break
159
+ case 'dispatcher.tick-completed':
160
+ this.observeRetry(event.payload.run)
161
+ if (this.failure?.payload.run === event.payload.run) this.failure = undefined
162
+ break
163
+ case 'dispatcher.tick-failed':
164
+ this.observeRetry(event.payload.run)
165
+ if (this.sourceDefersTick(Date.parse(event.ts))) this.failure = undefined
166
+ else if (this.failure?.payload.run !== event.payload.run) this.failure = event
167
+ break
168
+ }
169
+ }
170
+ pending(now: Date): { identity: string; observation: PersistenceObservation }[] {
171
+ const results: { identity: string; observation: PersistenceObservation }[] = []
172
+ for (const {
173
+ event,
174
+ deadline,
175
+ dependencyLimited,
176
+ requiresRetry,
177
+ retryObserved,
178
+ } of this.sources.values()) {
179
+ if (now.getTime() < deadline || (requiresRetry && !retryObserved)) continue
180
+ results.push({
181
+ identity: `source:${event.seq}`,
182
+ observation: {
183
+ type: 'dispatcher.ticket-source-persistent',
184
+ ...event.payload,
185
+ episodeSeq: event.seq,
186
+ observedAt: now.toISOString(),
187
+ dependencyLimited,
188
+ },
189
+ })
190
+ }
191
+ const event = this.failure
192
+ if (
193
+ event &&
194
+ !this.sourceDefersTick(now.getTime()) &&
195
+ now.getTime() >= Date.parse(event.ts) + 5 * 60_000
196
+ )
197
+ results.push({
198
+ identity: `tick:${event.seq}`,
199
+ observation: {
200
+ type: 'dispatcher.tick-failure-persistent',
201
+ ...event.payload,
202
+ firstFailure: event.ts,
203
+ episodeSeq: event.seq,
204
+ observedAt: now.toISOString(),
205
+ },
206
+ })
207
+ return results
208
+ }
209
+ }
@@ -464,6 +464,7 @@ export interface ControlBuildOpts {
464
464
  via?: Via
465
465
  action: BuildControlAction
466
466
  /** Injected only for --revise-spec-from-ticket. */
467
+ prepareTicketAssets?: (ref: TicketRef, store: BuildStore, repo: string) => Promise<PinnedAsset[]>
467
468
  readTicketBody?: (ref: TicketRef) => Promise<string>
468
469
  }
469
470
 
@@ -624,7 +625,15 @@ export async function controlBuild(opts: ControlBuildOpts): Promise<BuildControl
624
625
  )
625
626
  }
626
627
  body = await opts.readTicketBody(record.ticket)
627
- assets = await samplePinnedAssets(opts.store, record.repo, record.ticket.id)
628
+ if (record.ticket.source === 'hosted' && !opts.prepareTicketAssets) {
629
+ throw new BuildControlError(
630
+ 'ticket-unavailable',
631
+ 'hosted ticket asset preparation is unavailable',
632
+ )
633
+ }
634
+ assets = opts.prepareTicketAssets
635
+ ? await opts.prepareTicketAssets(record.ticket, opts.store, record.repo)
636
+ : await samplePinnedAssets(opts.store, record.repo, record.ticket.id)
628
637
  }
629
638
  const conformance = specConformance(body)
630
639
  if (!conformance.conforms) {
@@ -862,6 +871,7 @@ export interface AbBuildControlOpts {
862
871
  /** Explicit `--store`; selection remains --store > AB_STORE > repo-local. */
863
872
  storeRef?: string
864
873
  /** Injected only for --revise-spec-from-ticket. */
874
+ prepareTicketAssets?: (ref: TicketRef, store: BuildStore, repo: string) => Promise<PinnedAsset[]>
865
875
  readTicketBody?: (ref: TicketRef) => Promise<string>
866
876
  /** Injectable adapter seam for unit tests. */
867
877
  openStore?: StoreOpener
@@ -881,6 +891,7 @@ export async function abBuildControl(opts: AbBuildControlOpts): Promise<BuildCon
881
891
  env: opts.env,
882
892
  action: opts.action,
883
893
  ...(opts.readTicketBody !== undefined ? { readTicketBody: opts.readTicketBody } : {}),
894
+ ...(opts.prepareTicketAssets ? { prepareTicketAssets: opts.prepareTicketAssets } : {}),
884
895
  }),
885
896
  )
886
897
  }
@@ -0,0 +1,17 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parse } from 'smol-toml'
4
+ import { storeSchema } from '../config/schema'
5
+
6
+ /** Store selection happens before full dispatch configuration is loaded. */
7
+ export function configuredStore(checkout: string): string | undefined {
8
+ let text: string
9
+ try {
10
+ text = readFileSync(join(checkout, 'autobuild.toml'), 'utf8')
11
+ } catch (error) {
12
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
13
+ throw error
14
+ }
15
+ const table = parse(text)
16
+ return table.store === undefined ? undefined : storeSchema.parse(table.store).url
17
+ }
@@ -0,0 +1,364 @@
1
+ import { parseConfig } from '../config/load'
2
+ import { HostedTicketSource } from '../ports/tickets/remote'
3
+ import { ticketLifecycle } from '../ports/tickets/lifecycle'
4
+ import { createHash, randomBytes, randomUUID } from 'node:crypto'
5
+ import { access, readFile, rename, unlink, writeFile } from 'node:fs/promises'
6
+ import { constants } from 'node:fs'
7
+ import { dirname, join } from 'node:path'
8
+ import { parse } from 'smol-toml'
9
+ import {
10
+ normalizeConnectionRepository,
11
+ normalizeServiceURL,
12
+ type ConnectionStarted,
13
+ type ConnectionOutcome,
14
+ } from '../connection'
15
+ import type { Exec } from '../ports/workspace/git-worktree'
16
+ import { resolveMainRepo } from './repo-state'
17
+ import { ProjectCredentials } from './project-credentials'
18
+
19
+ interface ConnectOptions {
20
+ tickets?: boolean
21
+ builds?: boolean
22
+ cwd: string
23
+ env: Record<string, string | undefined>
24
+ exec: Exec
25
+ print: (message: string) => void
26
+ signal?: AbortSignal
27
+ fetch?: (input: string, init?: RequestInit) => Promise<Response>
28
+ browser?: (url: string) => Promise<void>
29
+ sleep?: (ms: number) => Promise<void>
30
+ now?: () => number
31
+ }
32
+ function requireOperator(env: ConnectOptions['env']) {
33
+ if (
34
+ ['AB_BUILD', 'AB_PHASE', 'AB_SESSION', 'AB_HARVEST', 'AB_REPO'].some(
35
+ (key) => env[key] !== undefined,
36
+ )
37
+ )
38
+ throw new Error('connect and disconnect are unavailable inside build or harvest sessions')
39
+ }
40
+ async function project(opts: ConnectOptions) {
41
+ requireOperator(opts.env)
42
+ const checkout = await resolveMainRepo(opts.cwd, opts.exec)
43
+ const remote = await opts.exec(['git', 'remote', 'get-url', 'origin'], { cwd: checkout })
44
+ if (remote.exitCode !== 0 || !remote.stdout.trim())
45
+ throw new Error('A network origin remote is required; add an origin before running ab connect')
46
+ const repo = normalizeConnectionRepository(remote.stdout.trim())
47
+ const path = join(checkout, 'autobuild.toml')
48
+ const text = await readFile(path, 'utf8')
49
+ const table = parse(text)
50
+ const store = table.store as { url?: unknown } | undefined
51
+ if (store && (typeof store !== 'object' || Array.isArray(store)))
52
+ throw new Error('Invalid [store] configuration')
53
+ const tickets = table.tickets as { source?: string; url?: string } | undefined
54
+ const buildService = opts.env.AB_STORE?.trim() || store?.url
55
+ const ticketService = tickets?.source === 'hosted' ? (tickets.url ?? buildService) : undefined
56
+ const selected = opts.tickets ? ticketService : buildService
57
+ const service =
58
+ typeof selected === 'string' && /^https?:/.test(selected)
59
+ ? normalizeServiceURL(selected)
60
+ : undefined
61
+ return { checkout, repo, path, text, table, service, buildService, ticketService }
62
+ }
63
+ async function request(
64
+ opts: ConnectOptions,
65
+ service: string,
66
+ path: string,
67
+ body?: unknown,
68
+ token?: string,
69
+ ) {
70
+ const response = await (opts.fetch ?? fetch)(`${service}/connect/v1/${path}`, {
71
+ method: body === undefined ? 'GET' : 'POST',
72
+ headers: {
73
+ 'content-type': 'application/json',
74
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
75
+ },
76
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
77
+ signal: AbortSignal.timeout(15000),
78
+ redirect: 'error',
79
+ })
80
+ return response
81
+ }
82
+ async function revoke(opts: ConnectOptions, service: string, token: string) {
83
+ const response = await request(opts, service, 'revoke', {}, token)
84
+ if (!response.ok && response.status !== 401)
85
+ throw new Error('Remote revocation failed; retry ab disconnect when the service is reachable')
86
+ }
87
+ async function atomicConfig(path: string, before: string, after: string) {
88
+ const temporary = `${path}.${randomUUID()}.tmp`
89
+ try {
90
+ await writeFile(temporary, after, { flag: 'wx' })
91
+ if ((await readFile(path, 'utf8')) !== before)
92
+ throw new Error('autobuild.toml changed during connection; retry ab connect')
93
+ await rename(temporary, path)
94
+ } finally {
95
+ await unlink(temporary).catch(() => {})
96
+ }
97
+ }
98
+ export async function abConnect(raw: string | undefined, opts: ConnectOptions): Promise<void> {
99
+ if (opts.tickets && opts.builds) throw new Error('Choose --tickets or --builds')
100
+ const p = await project(opts)
101
+ if (opts.tickets && (p.table.tickets as { source?: string } | undefined)?.source !== 'hosted')
102
+ throw new Error('ab connect --tickets requires [tickets] source = "hosted"')
103
+ const credentials = new ProjectCredentials(opts.env)
104
+ if (raw === undefined) {
105
+ const connections = new Map<string, string[]>()
106
+ for (const [use, candidate] of [
107
+ ['builds', p.buildService],
108
+ ['tickets', p.ticketService],
109
+ ] as const) {
110
+ if (typeof candidate !== 'string' || !/^https?:/.test(candidate)) continue
111
+ const service = normalizeServiceURL(candidate)
112
+ connections.set(service, [...(connections.get(service) ?? []), use])
113
+ }
114
+ if (connections.size === 0) {
115
+ opts.print(`Project: ${p.repo}\nService: unconfigured`)
116
+ return
117
+ }
118
+ for (const [service, uses] of connections) {
119
+ opts.print(`Uses: ${uses.join(', ')}`)
120
+ if (!service) {
121
+ opts.print(
122
+ `Project: ${p.repo}\nService: unconfigured\nCredential: missing; run ab connect <url>`,
123
+ )
124
+ return
125
+ }
126
+ const entry = credentials.load(service, p.repo)
127
+ let status = 'missing; run ab connect'
128
+ if (entry) {
129
+ try {
130
+ const response = await request(opts, service, 'status', undefined, entry.token)
131
+ if (response.ok) {
132
+ const body = (await response.json()) as {
133
+ repo?: string
134
+ credentialId?: string
135
+ accepted?: boolean
136
+ }
137
+ if (body.repo !== p.repo || body.credentialId !== entry.id || body.accepted !== true)
138
+ throw new Error('Invalid status response')
139
+ }
140
+ status = response.ok
141
+ ? 'accepted'
142
+ : response.status === 401 || response.status === 403
143
+ ? 'rejected; run ab connect'
144
+ : 'unreachable'
145
+ } catch {
146
+ status = 'unreachable'
147
+ }
148
+ }
149
+ opts.print(`Service: ${service}\nProject: ${p.repo}\nCredential: ${status}`)
150
+ }
151
+ return
152
+ }
153
+ const service = normalizeServiceURL(raw)
154
+ if (!opts.tickets && p.service && p.service !== service)
155
+ throw new Error(
156
+ `Configured [${opts.tickets ? 'tickets' : 'store'}] url differs; update it explicitly before connecting to another service`,
157
+ )
158
+ await access(p.path, constants.W_OK)
159
+ await access(dirname(p.path), constants.W_OK)
160
+ const previous = credentials.load(service, p.repo)
161
+ const id = randomBytes(16).toString('hex'),
162
+ secret = randomBytes(32).toString('hex')
163
+ const token = `abp_1_${id}_${secret}`
164
+ const response = await request(opts, service, 'requests', {
165
+ repo: p.repo,
166
+ credentialId: id,
167
+ digest: createHash('sha256').update(secret).digest('hex'),
168
+ })
169
+ if (!response.ok)
170
+ throw new Error('Could not start connection approval; verify the web-enabled service URL')
171
+ const started = (await response.json()) as ConnectionStarted
172
+ if (
173
+ !/^[a-f0-9]{32}$/.test(started.id) ||
174
+ !/^[a-f0-9]{64}$/.test(started.capability) ||
175
+ !/^[A-F0-9]{10}$/.test(started.code) ||
176
+ !Number.isFinite(Date.parse(started.expiresAt))
177
+ )
178
+ throw new Error('Invalid connection response')
179
+ const verification = `${service}/connect?code=${started.code}`
180
+ opts.print(`Approve project ${p.repo}\nOpen ${verification}\nCode: ${started.code}`)
181
+ try {
182
+ if (opts.browser) await opts.browser(verification)
183
+ else {
184
+ const command =
185
+ process.platform === 'darwin'
186
+ ? ['open', verification]
187
+ : process.platform === 'win32'
188
+ ? ['rundll32', 'url.dll,FileProtocolHandler', verification]
189
+ : ['xdg-open', verification]
190
+ const child = Bun.spawn(command, { stdout: 'ignore', stderr: 'ignore' })
191
+ child.unref()
192
+ }
193
+ } catch {
194
+ opts.print('Browser could not be opened. Use the URL and code on another device.')
195
+ }
196
+ const capability = { id: started.id, capability: started.capability }
197
+ const now = opts.now ?? Date.now
198
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
199
+ const interval = Math.max(5, Math.min(30, started.interval || 5)) * 1000
200
+ let approved = false
201
+ let configChanged = false
202
+ let saved = false
203
+ let updatedText = p.text
204
+ try {
205
+ let outcome: ConnectionOutcome | undefined
206
+ while (!opts.signal?.aborted && now() <= Date.parse(started.expiresAt) + interval) {
207
+ await sleep(interval)
208
+ const polled = await request(opts, service, 'poll', capability)
209
+ if (polled.status === 429) continue
210
+ if (!polled.ok) throw new Error('Approval polling failed; retry ab connect')
211
+ outcome = (await polled.json()) as ConnectionOutcome
212
+ if (outcome.status !== 'pending') break
213
+ }
214
+ if (!outcome || outcome.status === 'pending' || opts.signal?.aborted) {
215
+ const cancelled = await request(opts, service, 'cancel', capability)
216
+ if (!cancelled.ok)
217
+ throw new Error(
218
+ 'Could not confirm cancellation; use the owner credential list to check this connection',
219
+ )
220
+ opts.print(
221
+ opts.signal?.aborted
222
+ ? 'Connection cancelled; local configuration unchanged.'
223
+ : 'Connection timed out; local configuration unchanged.',
224
+ )
225
+ return
226
+ }
227
+ if (outcome.status !== 'approved') {
228
+ opts.print(`Connection ${outcome.status}; local configuration unchanged.`)
229
+ return
230
+ }
231
+ approved = true
232
+ if (outcome.repo !== p.repo || outcome.credentialId !== id)
233
+ throw new Error('Approval identity mismatch')
234
+ if (!p.service || (opts.tickets && (p.table.tickets as { url?: string }).url !== service)) {
235
+ updatedText = setConnectionURL(p.text, opts.tickets ? 'tickets' : 'store', service)
236
+ if (opts.tickets) parseConfig(updatedText)
237
+ else parse(updatedText)
238
+ await atomicConfig(p.path, p.text, updatedText)
239
+ configChanged = true
240
+ }
241
+ const configuredTickets = p.table.tickets as { source?: string; url?: string } | undefined
242
+ if (
243
+ opts.tickets ||
244
+ (configuredTickets?.source === 'hosted' &&
245
+ (!configuredTickets.url || normalizeServiceURL(configuredTickets.url) === service))
246
+ ) {
247
+ const tickets = parseConfig(updatedText).tickets
248
+ const lifecycle = ticketLifecycle(tickets)
249
+ await new HostedTicketSource({
250
+ url: service,
251
+ token,
252
+ repo: p.repo,
253
+ teamKey: tickets.teamKey!,
254
+ ...(opts.fetch ? { fetchFn: (input, init) => opts.fetch!(String(input), init) } : {}),
255
+ }).synchronizePolicy({
256
+ version: 1,
257
+ tickets: {
258
+ source: 'hosted',
259
+ teamKey: tickets.teamKey!,
260
+ readyState: lifecycle.ready,
261
+ triageState: lifecycle.triage,
262
+ claimedState: lifecycle.doing,
263
+ doneState: lifecycle.done,
264
+ createState: lifecycle.create,
265
+ proposalState: lifecycle.proposal,
266
+ ...(tickets.readyLabels !== undefined ? { readyLabels: tickets.readyLabels } : {}),
267
+ },
268
+ })
269
+ }
270
+ credentials.save({ service, repo: p.repo, id, token })
271
+ saved = true
272
+ if (previous) {
273
+ try {
274
+ await revoke(opts, service, previous.token)
275
+ } catch {
276
+ opts.print(
277
+ `Connected, but the previous credential ${previous.id} could not be revoked; revoke it using the service administrative tools.`,
278
+ )
279
+ }
280
+ }
281
+ opts.print(`Connected ${p.repo} to ${service}.`)
282
+ if (configChanged) opts.print(`Updated ${p.path}; review and commit this non-secret setting.`)
283
+ } catch (error) {
284
+ if (!saved) {
285
+ try {
286
+ const cancelled = await request(opts, service, 'cancel', capability)
287
+ if (!cancelled.ok) throw new Error()
288
+ } catch {
289
+ opts.print(
290
+ `Could not confirm remote cleanup for credential ${id}; check the service administrative credential list.`,
291
+ )
292
+ }
293
+ if (configChanged) {
294
+ try {
295
+ await atomicConfig(p.path, updatedText, p.text)
296
+ } catch {
297
+ opts.print(
298
+ `Restore [${opts.tickets ? 'tickets' : 'store'}] url manually if needed; configuration changed concurrently.`,
299
+ )
300
+ }
301
+ }
302
+ }
303
+ throw new Error(
304
+ approved
305
+ ? `Connection could not be saved; previous credential retained. ${(error as Error).message}. Repair the reported error and run ab connect again.`
306
+ : (error as Error).message,
307
+ )
308
+ }
309
+ }
310
+ export async function abDisconnect(opts: ConnectOptions): Promise<void> {
311
+ if (opts.tickets && opts.builds) throw new Error('Choose --tickets or --builds')
312
+ const p = await project(opts)
313
+ if (
314
+ !opts.tickets &&
315
+ !opts.builds &&
316
+ p.ticketService &&
317
+ p.service &&
318
+ normalizeServiceURL(String(p.ticketService)) !== p.service
319
+ )
320
+ throw new Error(
321
+ 'Multiple services: use ab disconnect --tickets for tickets; set AB_STORE to the build service and select --builds for builds',
322
+ )
323
+ if (!p.service && !opts.builds && p.ticketService) return abDisconnect({ ...opts, tickets: true })
324
+ if (!p.service) throw new Error('No configured service')
325
+ const credentials = new ProjectCredentials(opts.env)
326
+ const entry = credentials.load(p.service, p.repo)
327
+ if (!entry) {
328
+ opts.print('No stored credential; project configuration and service data retained.')
329
+ return
330
+ }
331
+ if (p.ticketService && p.buildService === p.ticketService)
332
+ opts.print('Builds and tickets share this credential; disconnecting affects both uses.')
333
+ await revoke(opts, p.service, entry.token)
334
+ credentials.remove(p.service, p.repo)
335
+ opts.print('Disconnected; project registration, data, and [store] url retained.')
336
+ }
337
+
338
+ /** Edit only the selected table; preserve unrelated configuration and comments. */
339
+ export function setConnectionURL(
340
+ text: string,
341
+ table: 'tickets' | 'store',
342
+ service: string,
343
+ ): string {
344
+ const lines = text.split('\n')
345
+ let inside = false
346
+ let start = -1
347
+ for (let i = 0; i < lines.length; i++) {
348
+ const line = lines[i]!
349
+ if (/^\s*\[/.test(line)) {
350
+ if (inside) {
351
+ lines.splice(i, 0, `url = ${JSON.stringify(service)}`)
352
+ return lines.join('\n')
353
+ }
354
+ inside = new RegExp(`^\\s*\\[${table}\\]\\s*(?:#.*)?$`).test(line)
355
+ if (inside) start = i
356
+ } else if (inside && /^\s*url\s*=/.test(line)) {
357
+ lines[i] = `url = ${JSON.stringify(service)}`
358
+ return lines.join('\n')
359
+ }
360
+ }
361
+ if (start >= 0) lines.splice(start + 1, 0, `url = ${JSON.stringify(service)}`)
362
+ else lines.push(`[${table}]`, `url = ${JSON.stringify(service)}`)
363
+ return lines.join('\n')
364
+ }
@@ -273,7 +273,13 @@ export async function buildContext(deps: ContextDeps): Promise<ContextManifest>
273
273
  if (inputs.ticket === true) {
274
274
  const created = events.find((event) => event.type === 'build.created')
275
275
  if (created !== undefined && created.type === 'build.created') {
276
- const ticket = created.payload.ticket
276
+ const original = created.payload.ticket
277
+ const current =
278
+ original.source === 'hosted' ? (await store.getBuild(build))?.ticket : undefined
279
+ const ticket = {
280
+ ...original,
281
+ ...(current?.source === original.source ? { id: current.id } : {}),
282
+ }
277
283
  const lines = [
278
284
  `# Ticket: ${ticket.title ?? ticket.id}`,
279
285
  '',