@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defrex/autobuild",
|
|
3
|
-
"version": "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.
|
|
143
|
-
"@defrex/autobuild-vercel-sandbox": "0.
|
|
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
|
-
|
|
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
|
|
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
|
'',
|