@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { Config } from '../../config/schema'
|
|
2
|
+
import { readyCriteria } from '../../processes/dispatcher'
|
|
3
|
+
import type { Ticket, TicketDraft, TicketSource, TicketUpdate } from '../types'
|
|
4
|
+
|
|
5
|
+
/** Expected ticket-operation failures which operator transports may expose verbatim. */
|
|
6
|
+
export class TicketOperationError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
readonly code: 'not-found' | 'refusal',
|
|
9
|
+
message: string,
|
|
10
|
+
) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = 'TicketOperationError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function missingTicket(source: TicketSource, id: string): TicketOperationError {
|
|
17
|
+
return new TicketOperationError(
|
|
18
|
+
'not-found',
|
|
19
|
+
`no ticket "${id}" in the configured ${source.name} ticket source — ticket ids are source-local`,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function requireTicket(source: TicketSource, id: string): Promise<Ticket> {
|
|
24
|
+
const ticket = await source.get(id)
|
|
25
|
+
if (ticket === null) throw missingTicket(source, id)
|
|
26
|
+
return ticket
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function requirePostMutationTicket(
|
|
30
|
+
source: TicketSource,
|
|
31
|
+
id: string,
|
|
32
|
+
operation: string,
|
|
33
|
+
): Promise<Ticket> {
|
|
34
|
+
const ticket = await source.get(id)
|
|
35
|
+
if (ticket === null) {
|
|
36
|
+
throw new TicketOperationError(
|
|
37
|
+
'refusal',
|
|
38
|
+
`ticket "${id}" disappeared from the configured ${source.name} ticket source after ${operation}`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
return ticket
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function ticketListCriteria(
|
|
45
|
+
config: Config,
|
|
46
|
+
filters: { state?: string; labels?: string[] },
|
|
47
|
+
): { state?: string; labels?: string[] } {
|
|
48
|
+
return filters.state === undefined && filters.labels === undefined
|
|
49
|
+
? readyCriteria(config)
|
|
50
|
+
: filters
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function knownBlockers(
|
|
54
|
+
source: TicketSource,
|
|
55
|
+
blockerIds: string[],
|
|
56
|
+
prefix: string,
|
|
57
|
+
suffix = '',
|
|
58
|
+
): Promise<string[]> {
|
|
59
|
+
const ids = [...new Set(blockerIds)]
|
|
60
|
+
if (ids.length === 0) return ids
|
|
61
|
+
const states = await source.dependencyStates(ids)
|
|
62
|
+
const unknown = states.filter((state) => !state.exists).map((state) => state.id)
|
|
63
|
+
if (unknown.length > 0) {
|
|
64
|
+
throw new TicketOperationError(
|
|
65
|
+
'not-found',
|
|
66
|
+
`${prefix}no ticket ${unknown.map((id) => `"${id}"`).join(', ')} in the configured ${source.name} ticket source — blocker ids are source-local${suffix}`,
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
return ids
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function createTicket(
|
|
73
|
+
source: TicketSource,
|
|
74
|
+
input: TicketDraft & { state?: string },
|
|
75
|
+
options: { blockerErrorPrefix?: string; blockerErrorSuffix?: string } = {},
|
|
76
|
+
): Promise<Ticket> {
|
|
77
|
+
const blockedBy = await knownBlockers(
|
|
78
|
+
source,
|
|
79
|
+
input.blockedBy ?? [],
|
|
80
|
+
options.blockerErrorPrefix ?? '',
|
|
81
|
+
options.blockerErrorSuffix ?? '',
|
|
82
|
+
)
|
|
83
|
+
const draft: TicketDraft = {
|
|
84
|
+
title: input.title,
|
|
85
|
+
body: input.body,
|
|
86
|
+
...(input.labels !== undefined ? { labels: [...input.labels] } : {}),
|
|
87
|
+
...(blockedBy.length > 0 ? { blockedBy } : {}),
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
return input.state === undefined
|
|
91
|
+
? await source.create(draft)
|
|
92
|
+
: await source.create(draft, { state: input.state })
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new TicketOperationError(
|
|
95
|
+
'refusal',
|
|
96
|
+
error instanceof Error ? error.message : String(error),
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function updateTicket(
|
|
102
|
+
source: TicketSource,
|
|
103
|
+
id: string,
|
|
104
|
+
patch: TicketUpdate,
|
|
105
|
+
readAfter = true,
|
|
106
|
+
): Promise<Ticket | null> {
|
|
107
|
+
try {
|
|
108
|
+
await source.update(id, patch)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new TicketOperationError(
|
|
111
|
+
'refusal',
|
|
112
|
+
error instanceof Error ? error.message : String(error),
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
return readAfter ? requirePostMutationTicket(source, id, 'the update') : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function moveTicket(source: TicketSource, id: string, state: string): Promise<Ticket> {
|
|
119
|
+
await requireTicket(source, id)
|
|
120
|
+
try {
|
|
121
|
+
await source.transition(id, state)
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new TicketOperationError(
|
|
124
|
+
'refusal',
|
|
125
|
+
error instanceof Error ? error.message : String(error),
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
return requirePostMutationTicket(source, id, 'the move')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function changeBlockers(
|
|
132
|
+
source: TicketSource,
|
|
133
|
+
id: string,
|
|
134
|
+
blockerIds: string[],
|
|
135
|
+
operation: 'block' | 'unblock',
|
|
136
|
+
): Promise<Ticket> {
|
|
137
|
+
await requireTicket(source, id)
|
|
138
|
+
const ids = await knownBlockers(source, blockerIds, '')
|
|
139
|
+
if (operation === 'block' && ids.includes(id)) {
|
|
140
|
+
throw new TicketOperationError(
|
|
141
|
+
'refusal',
|
|
142
|
+
`ticket "${id}" in the configured ${source.name} ticket source cannot block itself`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
for (const blockerId of ids) {
|
|
147
|
+
if (operation === 'block') await source.addBlocker(id, blockerId)
|
|
148
|
+
else await source.removeBlocker(id, blockerId)
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
throw new TicketOperationError(
|
|
152
|
+
'refusal',
|
|
153
|
+
error instanceof Error ? error.message : String(error),
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
return requirePostMutationTicket(
|
|
157
|
+
source,
|
|
158
|
+
id,
|
|
159
|
+
operation === 'block' ? 'adding blockers' : 'removing blockers',
|
|
160
|
+
)
|
|
161
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { HostedTicketSource } from './remote'
|
|
2
|
+
export type { HostedTicketFetch, HostedTicketSourceOptions } from './remote'
|
|
3
|
+
export { createTicketServer } from './remote-server'
|
|
4
|
+
export type { TicketServerOptions } from './remote-server'
|
|
5
|
+
export {
|
|
6
|
+
HOSTED_TICKET_OPERATIONS,
|
|
7
|
+
hostedTicketContextSchema,
|
|
8
|
+
hostedTicketRequestSchemas,
|
|
9
|
+
} from './remote-protocol'
|
|
10
|
+
export type { HostedTicketContext, HostedTicketOperation } from './remote-protocol'
|
|
11
|
+
// The hosted service composes the already live-proven Linear adapter; provider
|
|
12
|
+
// credentials remain server-side.
|
|
13
|
+
export { LinearTicketSource } from './linear'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { ticketRefSchema } from '../../ontology'
|
|
3
|
+
|
|
4
|
+
/** Repository-owned policy sent with every hosted ticket operation. */
|
|
5
|
+
export const hostedTicketContextSchema = z.strictObject({
|
|
6
|
+
teamKey: z.string().min(1),
|
|
7
|
+
claimedState: z.string().min(1).optional(),
|
|
8
|
+
createState: z.string().min(1).optional(),
|
|
9
|
+
})
|
|
10
|
+
export type HostedTicketContext = z.infer<typeof hostedTicketContextSchema>
|
|
11
|
+
|
|
12
|
+
export const ticketWireSchema = z.strictObject({
|
|
13
|
+
ref: ticketRefSchema,
|
|
14
|
+
creationKey: z.string().optional(),
|
|
15
|
+
title: z.string(),
|
|
16
|
+
body: z.string(),
|
|
17
|
+
state: z.string().optional(),
|
|
18
|
+
labels: z.array(z.string()),
|
|
19
|
+
blockedBy: z.array(z.string()).optional(),
|
|
20
|
+
})
|
|
21
|
+
export const ticketListingWireSchema = z.strictObject({
|
|
22
|
+
tickets: z.array(ticketWireSchema),
|
|
23
|
+
diagnostics: z.array(z.string()),
|
|
24
|
+
})
|
|
25
|
+
export const dependencyStateWireSchema = z.strictObject({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
exists: z.boolean(),
|
|
28
|
+
resolved: z.boolean(),
|
|
29
|
+
blockedBy: z.array(z.string()),
|
|
30
|
+
})
|
|
31
|
+
export const dependencyStatesWireSchema = z.array(dependencyStateWireSchema)
|
|
32
|
+
export const successWireSchema = z.strictObject({ ok: z.literal(true) })
|
|
33
|
+
export const claimWireSchema = z.strictObject({ claimed: z.boolean() })
|
|
34
|
+
|
|
35
|
+
const criteriaSchema = z.strictObject({
|
|
36
|
+
labels: z.array(z.string()).optional(),
|
|
37
|
+
state: z.string().optional(),
|
|
38
|
+
})
|
|
39
|
+
const idSchema = z.strictObject({ id: z.string().min(1) })
|
|
40
|
+
const draftSchema = z.strictObject({
|
|
41
|
+
title: z.string(),
|
|
42
|
+
body: z.string(),
|
|
43
|
+
labels: z.array(z.string()).optional(),
|
|
44
|
+
blockedBy: z.array(z.string()).optional(),
|
|
45
|
+
})
|
|
46
|
+
const createOptionsSchema = z.strictObject({
|
|
47
|
+
state: z.string().optional(),
|
|
48
|
+
idempotencyKey: z.string().optional(),
|
|
49
|
+
})
|
|
50
|
+
const updateSchema = z
|
|
51
|
+
.strictObject({
|
|
52
|
+
title: z.string().optional(),
|
|
53
|
+
body: z.string().optional(),
|
|
54
|
+
labels: z.array(z.string()).optional(),
|
|
55
|
+
})
|
|
56
|
+
.refine((value) => Object.keys(value).length > 0, 'update must name at least one field')
|
|
57
|
+
|
|
58
|
+
function envelope<T extends z.ZodType>(input: T) {
|
|
59
|
+
return z.strictObject({ context: hostedTicketContextSchema, input })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const hostedTicketRequestSchemas = {
|
|
63
|
+
'list-ready': envelope(criteriaSchema),
|
|
64
|
+
get: envelope(idSchema),
|
|
65
|
+
claim: envelope(idSchema),
|
|
66
|
+
comment: envelope(z.strictObject({ id: z.string().min(1), body: z.string() })),
|
|
67
|
+
transition: envelope(z.strictObject({ id: z.string().min(1), state: z.string().min(1) })),
|
|
68
|
+
create: envelope(z.strictObject({ draft: draftSchema, options: createOptionsSchema.optional() })),
|
|
69
|
+
update: envelope(z.strictObject({ id: z.string().min(1), patch: updateSchema })),
|
|
70
|
+
'add-blocker': envelope(z.strictObject({ id: z.string().min(1), blockerId: z.string().min(1) })),
|
|
71
|
+
'remove-blocker': envelope(
|
|
72
|
+
z.strictObject({ id: z.string().min(1), blockerId: z.string().min(1) }),
|
|
73
|
+
),
|
|
74
|
+
'dependency-states': envelope(z.strictObject({ ids: z.array(z.string().min(1)) })),
|
|
75
|
+
} as const
|
|
76
|
+
|
|
77
|
+
export type HostedTicketOperation = keyof typeof hostedTicketRequestSchemas
|
|
78
|
+
export const HOSTED_TICKET_OPERATIONS = Object.keys(
|
|
79
|
+
hostedTicketRequestSchemas,
|
|
80
|
+
) as HostedTicketOperation[]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { TicketSource } from '../types'
|
|
2
|
+
import { systemClock, type Clock } from '../../store/types'
|
|
3
|
+
import type { ErrorBody, ErrorKind } from '../../store/remote/protocol'
|
|
4
|
+
import { tokenResource, verifyToken } from '../../store/remote/token'
|
|
5
|
+
import {
|
|
6
|
+
AUTOBUILD_VERSION,
|
|
7
|
+
AUTOBUILD_VERSION_HEADER,
|
|
8
|
+
REMOTE_STORE_PROTOCOL_VERSION,
|
|
9
|
+
REMOTE_STORE_PROTOCOL_VERSION_HEADER,
|
|
10
|
+
} from '../../store/remote/version'
|
|
11
|
+
import {
|
|
12
|
+
hostedTicketRequestSchemas,
|
|
13
|
+
type HostedTicketContext,
|
|
14
|
+
type HostedTicketOperation,
|
|
15
|
+
} from './remote-protocol'
|
|
16
|
+
|
|
17
|
+
export interface TicketServerOptions {
|
|
18
|
+
secret: string
|
|
19
|
+
sourceFor(context: HostedTicketContext): TicketSource | Promise<TicketSource>
|
|
20
|
+
clock?: Clock
|
|
21
|
+
/** Observes unexpected source failures without changing their wire response. */
|
|
22
|
+
onInternalError?: (error: unknown, request: Request) => unknown | Promise<unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class TicketRequestError extends Error {
|
|
26
|
+
constructor(
|
|
27
|
+
readonly status: number,
|
|
28
|
+
readonly kind: ErrorKind,
|
|
29
|
+
message: string,
|
|
30
|
+
) {
|
|
31
|
+
super(message)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function json(status: number, body: unknown): Response {
|
|
36
|
+
return Response.json(body, { status })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function failure(status: number, kind: ErrorKind, error: string): Response {
|
|
40
|
+
return json(status, { error, kind } satisfies ErrorBody)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** HTTP face of the TicketSource port. Authentication deliberately runs before
|
|
44
|
+
* JSON parsing and source construction, so denied callers cannot probe either. */
|
|
45
|
+
export function createTicketServer(options: TicketServerOptions): {
|
|
46
|
+
fetch(req: Request): Promise<Response>
|
|
47
|
+
} {
|
|
48
|
+
const clock = options.clock ?? systemClock
|
|
49
|
+
|
|
50
|
+
function identify(req: Request): void {
|
|
51
|
+
const autobuild = req.headers.get(AUTOBUILD_VERSION_HEADER)
|
|
52
|
+
const protocol = req.headers.get(REMOTE_STORE_PROTOCOL_VERSION_HEADER)
|
|
53
|
+
if (autobuild !== AUTOBUILD_VERSION || protocol !== REMOTE_STORE_PROTOCOL_VERSION) {
|
|
54
|
+
throw new TicketRequestError(
|
|
55
|
+
409,
|
|
56
|
+
'conflict',
|
|
57
|
+
`remote store version mismatch: client Autobuild ${autobuild ?? '(missing)'} protocol ${protocol ?? '(missing)'}; server Autobuild ${AUTOBUILD_VERSION} protocol ${REMOTE_STORE_PROTOCOL_VERSION}`,
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function authorize(req: Request): void {
|
|
63
|
+
const match = /^Bearer\s+(.+)$/i.exec(req.headers.get('authorization') ?? '')
|
|
64
|
+
if (!match) throw new TicketRequestError(401, 'auth', 'missing bearer token')
|
|
65
|
+
const scope = verifyToken(options.secret, match[1]!, clock())
|
|
66
|
+
if (scope === null) throw new TicketRequestError(401, 'auth', 'invalid or expired token')
|
|
67
|
+
const resource = tokenResource(scope)
|
|
68
|
+
if (resource.kind !== 'deployment') {
|
|
69
|
+
throw new TicketRequestError(
|
|
70
|
+
403,
|
|
71
|
+
'auth',
|
|
72
|
+
`token scoped to ${resource.kind} "${resource.id}" may not access ticket operations`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function body(req: Request): Promise<unknown> {
|
|
78
|
+
try {
|
|
79
|
+
return await req.json()
|
|
80
|
+
} catch {
|
|
81
|
+
throw new TicketRequestError(400, 'validation', 'request body is not valid JSON')
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function route(req: Request, operation: HostedTicketOperation): Promise<Response> {
|
|
86
|
+
const raw = await body(req)
|
|
87
|
+
const parsed = hostedTicketRequestSchemas[operation].safeParse(raw)
|
|
88
|
+
if (!parsed.success) {
|
|
89
|
+
throw new TicketRequestError(
|
|
90
|
+
400,
|
|
91
|
+
'validation',
|
|
92
|
+
`invalid request body: ${parsed.error.message}`,
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
const context = parsed.data.context
|
|
96
|
+
const source = await options.sourceFor(context)
|
|
97
|
+
|
|
98
|
+
// Parse again through the operation-specific schema to preserve precise
|
|
99
|
+
// inference in each branch while keeping the shared pre-source validation.
|
|
100
|
+
switch (operation) {
|
|
101
|
+
case 'list-ready': {
|
|
102
|
+
const request = hostedTicketRequestSchemas['list-ready'].parse(raw)
|
|
103
|
+
return json(200, await source.listReady(request.input))
|
|
104
|
+
}
|
|
105
|
+
case 'get': {
|
|
106
|
+
const request = hostedTicketRequestSchemas.get.parse(raw)
|
|
107
|
+
return json(200, await source.get(request.input.id))
|
|
108
|
+
}
|
|
109
|
+
case 'claim': {
|
|
110
|
+
const request = hostedTicketRequestSchemas.claim.parse(raw)
|
|
111
|
+
return json(200, { claimed: await source.claim(request.input.id) })
|
|
112
|
+
}
|
|
113
|
+
case 'comment': {
|
|
114
|
+
const request = hostedTicketRequestSchemas.comment.parse(raw)
|
|
115
|
+
await source.comment(request.input.id, request.input.body)
|
|
116
|
+
return json(200, { ok: true })
|
|
117
|
+
}
|
|
118
|
+
case 'transition': {
|
|
119
|
+
const request = hostedTicketRequestSchemas.transition.parse(raw)
|
|
120
|
+
await source.transition(request.input.id, request.input.state)
|
|
121
|
+
return json(200, { ok: true })
|
|
122
|
+
}
|
|
123
|
+
case 'create': {
|
|
124
|
+
const request = hostedTicketRequestSchemas.create.parse(raw)
|
|
125
|
+
return json(201, await source.create(request.input.draft, request.input.options))
|
|
126
|
+
}
|
|
127
|
+
case 'update': {
|
|
128
|
+
const request = hostedTicketRequestSchemas.update.parse(raw)
|
|
129
|
+
await source.update(request.input.id, request.input.patch)
|
|
130
|
+
return json(200, { ok: true })
|
|
131
|
+
}
|
|
132
|
+
case 'add-blocker': {
|
|
133
|
+
const request = hostedTicketRequestSchemas['add-blocker'].parse(raw)
|
|
134
|
+
await source.addBlocker(request.input.id, request.input.blockerId)
|
|
135
|
+
return json(200, { ok: true })
|
|
136
|
+
}
|
|
137
|
+
case 'remove-blocker': {
|
|
138
|
+
const request = hostedTicketRequestSchemas['remove-blocker'].parse(raw)
|
|
139
|
+
await source.removeBlocker(request.input.id, request.input.blockerId)
|
|
140
|
+
return json(200, { ok: true })
|
|
141
|
+
}
|
|
142
|
+
case 'dependency-states': {
|
|
143
|
+
const request = hostedTicketRequestSchemas['dependency-states'].parse(raw)
|
|
144
|
+
return json(200, await source.dependencyStates(request.input.ids))
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
async fetch(req: Request): Promise<Response> {
|
|
151
|
+
try {
|
|
152
|
+
const url = new URL(req.url)
|
|
153
|
+
if (req.method !== 'POST' || !url.pathname.startsWith('/tickets/')) {
|
|
154
|
+
return failure(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
|
|
155
|
+
}
|
|
156
|
+
identify(req)
|
|
157
|
+
authorize(req)
|
|
158
|
+
const operation = url.pathname.slice('/tickets/'.length) as HostedTicketOperation
|
|
159
|
+
if (!Object.hasOwn(hostedTicketRequestSchemas, operation)) {
|
|
160
|
+
return failure(404, 'not-found', `no route: ${req.method} ${url.pathname}`)
|
|
161
|
+
}
|
|
162
|
+
return await route(req, operation)
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof TicketRequestError) {
|
|
165
|
+
return failure(error.status, error.kind, error.message)
|
|
166
|
+
}
|
|
167
|
+
// Only the message crosses the trust boundary; causes and stacks never do.
|
|
168
|
+
const message = error instanceof Error ? error.message : 'ticket backend failed'
|
|
169
|
+
const notFound = /unknown (?:ticket|issue)|not found/i.test(message)
|
|
170
|
+
// These are intentional domain feedback from TicketSource implementations.
|
|
171
|
+
// Their historical standalone mapping remains unchanged.
|
|
172
|
+
const expectedDomainFailure =
|
|
173
|
+
message.startsWith('invalid ticket update') || message.includes('cannot block itself')
|
|
174
|
+
if (!notFound && !expectedDomainFailure && options.onInternalError !== undefined) {
|
|
175
|
+
try {
|
|
176
|
+
await options.onInternalError(error, req)
|
|
177
|
+
} catch {
|
|
178
|
+
// Diagnostics must never replace the protocol response.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return failure(notFound ? 404 : 500, notFound ? 'not-found' : 'internal', message)
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DependencyState,
|
|
3
|
+
Ticket,
|
|
4
|
+
TicketCreateOptions,
|
|
5
|
+
TicketDraft,
|
|
6
|
+
TicketListing,
|
|
7
|
+
TicketSource,
|
|
8
|
+
TicketUpdate,
|
|
9
|
+
} from '../types'
|
|
10
|
+
import { AuthError } from '../../store/remote/client'
|
|
11
|
+
import { errorBodySchema } from '../../store/remote/protocol'
|
|
12
|
+
import {
|
|
13
|
+
claimWireSchema,
|
|
14
|
+
dependencyStatesWireSchema,
|
|
15
|
+
type HostedTicketContext,
|
|
16
|
+
successWireSchema,
|
|
17
|
+
ticketListingWireSchema,
|
|
18
|
+
ticketWireSchema,
|
|
19
|
+
} from './remote-protocol'
|
|
20
|
+
import {
|
|
21
|
+
AUTOBUILD_VERSION,
|
|
22
|
+
AUTOBUILD_VERSION_HEADER,
|
|
23
|
+
REMOTE_STORE_PROTOCOL_VERSION,
|
|
24
|
+
REMOTE_STORE_PROTOCOL_VERSION_HEADER,
|
|
25
|
+
} from '../../store/remote/version'
|
|
26
|
+
|
|
27
|
+
export type HostedTicketFetch = (
|
|
28
|
+
input: string | URL | Request,
|
|
29
|
+
init?: RequestInit,
|
|
30
|
+
) => Promise<Response>
|
|
31
|
+
|
|
32
|
+
export interface HostedTicketSourceOptions extends HostedTicketContext {
|
|
33
|
+
url: string
|
|
34
|
+
token: string
|
|
35
|
+
fetchFn?: HostedTicketFetch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Full TicketSource client for a hosted Autobuild service. */
|
|
39
|
+
export class HostedTicketSource implements TicketSource {
|
|
40
|
+
readonly name = 'hosted'
|
|
41
|
+
private readonly base: string
|
|
42
|
+
private readonly token: string
|
|
43
|
+
private readonly fetchFn: HostedTicketFetch
|
|
44
|
+
private readonly context: HostedTicketContext
|
|
45
|
+
|
|
46
|
+
constructor(options: HostedTicketSourceOptions) {
|
|
47
|
+
this.base = options.url.replace(/\/+$/, '')
|
|
48
|
+
this.token = options.token
|
|
49
|
+
this.fetchFn = options.fetchFn ?? fetch
|
|
50
|
+
this.context = {
|
|
51
|
+
teamKey: options.teamKey,
|
|
52
|
+
...(options.claimedState !== undefined ? { claimedState: options.claimedState } : {}),
|
|
53
|
+
...(options.createState !== undefined ? { createState: options.createState } : {}),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private project(ticket: Ticket): Ticket {
|
|
58
|
+
return { ...ticket, ref: { ...ticket.ref, source: this.name } }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async request<T>(
|
|
62
|
+
operation: string,
|
|
63
|
+
input: unknown,
|
|
64
|
+
schema: { parse(value: unknown): T },
|
|
65
|
+
): Promise<T> {
|
|
66
|
+
const response = await this.fetchFn(`${this.base}/tickets/${operation}`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'content-type': 'application/json',
|
|
70
|
+
authorization: `Bearer ${this.token}`,
|
|
71
|
+
[AUTOBUILD_VERSION_HEADER]: AUTOBUILD_VERSION,
|
|
72
|
+
[REMOTE_STORE_PROTOCOL_VERSION_HEADER]: REMOTE_STORE_PROTOCOL_VERSION,
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({ context: this.context, input }),
|
|
75
|
+
})
|
|
76
|
+
if (!response.ok) {
|
|
77
|
+
let message = `ticket service responded ${response.status}`
|
|
78
|
+
try {
|
|
79
|
+
message = errorBodySchema.parse(await response.json()).error
|
|
80
|
+
} catch {
|
|
81
|
+
// Keep the status-only safe fallback for non-protocol responses.
|
|
82
|
+
}
|
|
83
|
+
if (response.status === 401 || response.status === 403) throw new AuthError(message)
|
|
84
|
+
throw new Error(message)
|
|
85
|
+
}
|
|
86
|
+
return schema.parse(await response.json())
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async listReady(criteria: { labels?: string[]; state?: string }): Promise<TicketListing> {
|
|
90
|
+
const listing = await this.request('list-ready', criteria, ticketListingWireSchema)
|
|
91
|
+
return { ...listing, tickets: listing.tickets.map((ticket) => this.project(ticket)) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async get(id: string): Promise<Ticket | null> {
|
|
95
|
+
const ticket = await this.request('get', { id }, ticketWireSchema.nullable())
|
|
96
|
+
return ticket === null ? null : this.project(ticket)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async claim(id: string): Promise<boolean> {
|
|
100
|
+
return (await this.request('claim', { id }, claimWireSchema)).claimed
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async comment(id: string, body: string): Promise<void> {
|
|
104
|
+
await this.request('comment', { id, body }, successWireSchema)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async transition(id: string, state: string): Promise<void> {
|
|
108
|
+
await this.request('transition', { id, state }, successWireSchema)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async create(draft: TicketDraft, options: TicketCreateOptions = {}): Promise<Ticket> {
|
|
112
|
+
return this.project(await this.request('create', { draft, options }, ticketWireSchema))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async update(id: string, patch: TicketUpdate): Promise<void> {
|
|
116
|
+
await this.request('update', { id, patch }, successWireSchema)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async addBlocker(id: string, blockerId: string): Promise<void> {
|
|
120
|
+
await this.request('add-blocker', { id, blockerId }, successWireSchema)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async removeBlocker(id: string, blockerId: string): Promise<void> {
|
|
124
|
+
await this.request('remove-blocker', { id, blockerId }, successWireSchema)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
dependencyStates(ids: string[]): Promise<DependencyState[]> {
|
|
128
|
+
return this.request('dependency-states', { ids }, dependencyStatesWireSchema)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { expandIssues } from '../../zod-issues'
|
|
3
|
+
import type { TicketUpdate } from '../types'
|
|
4
|
+
|
|
5
|
+
const ticketUpdateSchema = z
|
|
6
|
+
.strictObject({
|
|
7
|
+
title: z
|
|
8
|
+
.string()
|
|
9
|
+
.refine((value) => value.trim().length > 0, 'title must not be blank')
|
|
10
|
+
.optional(),
|
|
11
|
+
body: z
|
|
12
|
+
.string()
|
|
13
|
+
.refine((value) => value.trim().length > 0, 'body must not be blank')
|
|
14
|
+
.optional(),
|
|
15
|
+
labels: z.array(z.string()).optional(),
|
|
16
|
+
})
|
|
17
|
+
.refine(
|
|
18
|
+
(patch) => patch.title !== undefined || patch.body !== undefined || patch.labels !== undefined,
|
|
19
|
+
'update must name at least one of title, body, or labels',
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
/** Render ticket-update issues while retaining nested union branch detail. */
|
|
23
|
+
export function renderTicketUpdateIssues(issues: readonly z.core.$ZodIssue[]): string {
|
|
24
|
+
return expandIssues(issues)
|
|
25
|
+
.map((issue) => {
|
|
26
|
+
const path = issue.path.join('.')
|
|
27
|
+
return path === '' ? issue.message : `${path}: ${issue.message}`
|
|
28
|
+
})
|
|
29
|
+
.join('; ')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate the common partial-update contract before an adapter performs any
|
|
34
|
+
* write. Keeping this at the port boundary (rather than only in the CLI)
|
|
35
|
+
* gives direct callers and every provider identical required-field and strict
|
|
36
|
+
* key semantics.
|
|
37
|
+
*/
|
|
38
|
+
export function validateTicketUpdate(patch: TicketUpdate): TicketUpdate {
|
|
39
|
+
const result = ticketUpdateSchema.safeParse(patch)
|
|
40
|
+
if (!result.success) {
|
|
41
|
+
throw new Error(`invalid ticket update — ${renderTicketUpdateIssues(result.error.issues)}`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Reconstruct the patch so arrays cannot be mutated behind an adapter's
|
|
45
|
+
// back and explicit `undefined` never masquerades as a named field.
|
|
46
|
+
return {
|
|
47
|
+
...(result.data.title !== undefined ? { title: result.data.title } : {}),
|
|
48
|
+
...(result.data.body !== undefined ? { body: result.data.body } : {}),
|
|
49
|
+
...(result.data.labels !== undefined ? { labels: [...result.data.labels] } : {}),
|
|
50
|
+
}
|
|
51
|
+
}
|