@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.
Files changed (251) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +192 -0
  3. package/SPEC.md +2269 -0
  4. package/bin/ab-build-runner.ts +47 -0
  5. package/bin/ab-dev.ts +27 -0
  6. package/bin/ab-dispatch-kernel.ts +92 -0
  7. package/bin/ab-harvest-runner.ts +29 -0
  8. package/bin/ab-init-probe.ts +19 -0
  9. package/bin/ab-process-group-reaper.ts +32 -0
  10. package/bin/ab.ts +6 -0
  11. package/bin/agent/ab +3 -0
  12. package/docs/answering-blocked-builds.md +89 -0
  13. package/docs/architecture.md +726 -0
  14. package/docs/assets/headline-wide.png +0 -0
  15. package/docs/configuration.md +1623 -0
  16. package/docs/hosted-dispatcher-cutover-validation.md +178 -0
  17. package/docs/hosted-dispatcher.md +204 -0
  18. package/docs/mcp.md +119 -0
  19. package/docs/operator-api.md +186 -0
  20. package/docs/remote-store-protocol.md +972 -0
  21. package/docs/remote-ticket-protocol.md +79 -0
  22. package/docs/setup.md +289 -0
  23. package/docs/spec-standard.md +63 -0
  24. package/docs/ticket-dependencies.md +59 -0
  25. package/docs/vercel-sandbox-rollout-probe.md +7 -0
  26. package/package.json +135 -0
  27. package/packages/core/src/cli/args.ts +74 -0
  28. package/packages/core/src/cli/artifact.ts +182 -0
  29. package/packages/core/src/cli/binary.ts +198 -0
  30. package/packages/core/src/cli/build-control.ts +789 -0
  31. package/packages/core/src/cli/build-progress.ts +55 -0
  32. package/packages/core/src/cli/bulk-control.ts +385 -0
  33. package/packages/core/src/cli/context.ts +470 -0
  34. package/packages/core/src/cli/dashboard/actions.ts +59 -0
  35. package/packages/core/src/cli/dashboard/cells.ts +92 -0
  36. package/packages/core/src/cli/dashboard/composer.ts +230 -0
  37. package/packages/core/src/cli/dashboard/detail.ts +100 -0
  38. package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
  39. package/packages/core/src/cli/dashboard/live.ts +152 -0
  40. package/packages/core/src/cli/dashboard/model.ts +1173 -0
  41. package/packages/core/src/cli/dashboard/poll.ts +196 -0
  42. package/packages/core/src/cli/dashboard/render.ts +1568 -0
  43. package/packages/core/src/cli/dashboard/selection.ts +66 -0
  44. package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
  45. package/packages/core/src/cli/dashboard/session-view.ts +506 -0
  46. package/packages/core/src/cli/dashboard/transcript.ts +108 -0
  47. package/packages/core/src/cli/dev.ts +46 -0
  48. package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
  49. package/packages/core/src/cli/dispatch-process.ts +259 -0
  50. package/packages/core/src/cli/dispatch.ts +3692 -0
  51. package/packages/core/src/cli/dotenv.ts +48 -0
  52. package/packages/core/src/cli/env.ts +206 -0
  53. package/packages/core/src/cli/harvest.ts +698 -0
  54. package/packages/core/src/cli/help.ts +713 -0
  55. package/packages/core/src/cli/init-agent.ts +106 -0
  56. package/packages/core/src/cli/init-validation.ts +583 -0
  57. package/packages/core/src/cli/init.ts +756 -0
  58. package/packages/core/src/cli/installation.ts +333 -0
  59. package/packages/core/src/cli/keyboard.ts +261 -0
  60. package/packages/core/src/cli/main.ts +1399 -0
  61. package/packages/core/src/cli/mcp.ts +279 -0
  62. package/packages/core/src/cli/models.ts +65 -0
  63. package/packages/core/src/cli/observe.ts +55 -0
  64. package/packages/core/src/cli/plugin.ts +227 -0
  65. package/packages/core/src/cli/pr-attachments.ts +138 -0
  66. package/packages/core/src/cli/pr-summary.ts +124 -0
  67. package/packages/core/src/cli/publication-settlement.ts +188 -0
  68. package/packages/core/src/cli/repo-state.ts +213 -0
  69. package/packages/core/src/cli/repository-status.ts +80 -0
  70. package/packages/core/src/cli/self-update.ts +443 -0
  71. package/packages/core/src/cli/status.ts +895 -0
  72. package/packages/core/src/cli/store-opening.contract.ts +289 -0
  73. package/packages/core/src/cli/store-opening.ts +167 -0
  74. package/packages/core/src/cli/store-ref.ts +33 -0
  75. package/packages/core/src/cli/terminal-restore.ts +174 -0
  76. package/packages/core/src/cli/terminal.ts +361 -0
  77. package/packages/core/src/cli/terminals.ts +995 -0
  78. package/packages/core/src/cli/testkit.ts +194 -0
  79. package/packages/core/src/cli/ticket.ts +484 -0
  80. package/packages/core/src/cli/upgrade-agent.ts +180 -0
  81. package/packages/core/src/cli/upgrade-commit.ts +468 -0
  82. package/packages/core/src/cli/upgrade-notice.ts +91 -0
  83. package/packages/core/src/cli/upgrade-progress.ts +110 -0
  84. package/packages/core/src/cli/upgrade.ts +888 -0
  85. package/packages/core/src/cli/wait.ts +587 -0
  86. package/packages/core/src/cli/watch.ts +932 -0
  87. package/packages/core/src/config/live.ts +255 -0
  88. package/packages/core/src/config/load.ts +98 -0
  89. package/packages/core/src/config/roles.ts +438 -0
  90. package/packages/core/src/config/schema.ts +774 -0
  91. package/packages/core/src/distribution.ts +24 -0
  92. package/packages/core/src/events/catalog.ts +201 -0
  93. package/packages/core/src/events/envelope.ts +59 -0
  94. package/packages/core/src/events/payloads.ts +618 -0
  95. package/packages/core/src/events/repository.ts +559 -0
  96. package/packages/core/src/events/sessions.ts +180 -0
  97. package/packages/core/src/harvest/schema.ts +147 -0
  98. package/packages/core/src/ids.ts +45 -0
  99. package/packages/core/src/integration/harness.ts +681 -0
  100. package/packages/core/src/kernel/auto-merge.ts +259 -0
  101. package/packages/core/src/kernel/converge.ts +117 -0
  102. package/packages/core/src/kernel/dispatch-settings.ts +60 -0
  103. package/packages/core/src/kernel/dispatch-status.ts +141 -0
  104. package/packages/core/src/kernel/engine.ts +1047 -0
  105. package/packages/core/src/kernel/harvest.ts +823 -0
  106. package/packages/core/src/kernel/origin.ts +69 -0
  107. package/packages/core/src/kernel/phases.ts +196 -0
  108. package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
  109. package/packages/core/src/kernel/pr-attachments.ts +82 -0
  110. package/packages/core/src/kernel/reducer.ts +752 -0
  111. package/packages/core/src/kernel/stall.ts +155 -0
  112. package/packages/core/src/kernel/verify-applicability.ts +52 -0
  113. package/packages/core/src/markdown.ts +67 -0
  114. package/packages/core/src/ontology.ts +288 -0
  115. package/packages/core/src/open-map.ts +203 -0
  116. package/packages/core/src/operator/annotations.ts +187 -0
  117. package/packages/core/src/operator/client.ts +311 -0
  118. package/packages/core/src/operator/control.ts +117 -0
  119. package/packages/core/src/operator/index.ts +62 -0
  120. package/packages/core/src/operator/presentation.ts +22 -0
  121. package/packages/core/src/operator/protocol.ts +98 -0
  122. package/packages/core/src/operator/query.ts +201 -0
  123. package/packages/core/src/operator/registry.ts +1043 -0
  124. package/packages/core/src/operator/requests.ts +138 -0
  125. package/packages/core/src/operator/sandbox.ts +458 -0
  126. package/packages/core/src/operator/server.ts +616 -0
  127. package/packages/core/src/operator/sessions.ts +223 -0
  128. package/packages/core/src/operator/tickets.ts +177 -0
  129. package/packages/core/src/plugin-sdk/index.ts +174 -0
  130. package/packages/core/src/plugins/contract-entry.ts +74 -0
  131. package/packages/core/src/plugins/contract-env.ts +3 -0
  132. package/packages/core/src/plugins/load.ts +259 -0
  133. package/packages/core/src/plugins/manifest.ts +236 -0
  134. package/packages/core/src/plugins/registry.ts +274 -0
  135. package/packages/core/src/plugins/runtimes.ts +75 -0
  136. package/packages/core/src/ports/forge/contract.ts +258 -0
  137. package/packages/core/src/ports/forge/create.ts +74 -0
  138. package/packages/core/src/ports/forge/fake.ts +488 -0
  139. package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
  140. package/packages/core/src/ports/forge/github-transport.ts +352 -0
  141. package/packages/core/src/ports/forge/github.ts +1162 -0
  142. package/packages/core/src/ports/forge/local-git.ts +568 -0
  143. package/packages/core/src/ports/runner/claude.ts +873 -0
  144. package/packages/core/src/ports/runner/cli-stream.ts +90 -0
  145. package/packages/core/src/ports/runner/codex.ts +873 -0
  146. package/packages/core/src/ports/runner/contract.ts +492 -0
  147. package/packages/core/src/ports/runner/fake.ts +213 -0
  148. package/packages/core/src/ports/runner/json-record.ts +2 -0
  149. package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
  150. package/packages/core/src/ports/runner/one-shot.ts +29 -0
  151. package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
  152. package/packages/core/src/ports/runner/pi-bridge.js +6 -0
  153. package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
  154. package/packages/core/src/ports/runner/pi.ts +617 -0
  155. package/packages/core/src/ports/runner/production.ts +74 -0
  156. package/packages/core/src/ports/runner/provider-error.ts +121 -0
  157. package/packages/core/src/ports/runner/routing.ts +234 -0
  158. package/packages/core/src/ports/runner/runtime.ts +214 -0
  159. package/packages/core/src/ports/runner/session-env.ts +34 -0
  160. package/packages/core/src/ports/runner/stream-parts.ts +103 -0
  161. package/packages/core/src/ports/tickets/contract.ts +570 -0
  162. package/packages/core/src/ports/tickets/create.ts +135 -0
  163. package/packages/core/src/ports/tickets/fake.ts +215 -0
  164. package/packages/core/src/ports/tickets/file.ts +559 -0
  165. package/packages/core/src/ports/tickets/linear.ts +811 -0
  166. package/packages/core/src/ports/tickets/operations.ts +161 -0
  167. package/packages/core/src/ports/tickets/remote-index.ts +13 -0
  168. package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
  169. package/packages/core/src/ports/tickets/remote-server.ts +185 -0
  170. package/packages/core/src/ports/tickets/remote.ts +130 -0
  171. package/packages/core/src/ports/tickets/update.ts +51 -0
  172. package/packages/core/src/ports/types.ts +528 -0
  173. package/packages/core/src/ports/workspace/build-execution.ts +123 -0
  174. package/packages/core/src/ports/workspace/contract.ts +136 -0
  175. package/packages/core/src/ports/workspace/create.ts +132 -0
  176. package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
  177. package/packages/core/src/ports/workspace/fake.ts +432 -0
  178. package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
  179. package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
  180. package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
  181. package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
  182. package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
  183. package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
  184. package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
  185. package/packages/core/src/processes/build-child-exit.ts +78 -0
  186. package/packages/core/src/processes/build-child.ts +119 -0
  187. package/packages/core/src/processes/build-execution-state.ts +95 -0
  188. package/packages/core/src/processes/build-parent-watch.ts +35 -0
  189. package/packages/core/src/processes/build-runner.ts +2614 -0
  190. package/packages/core/src/processes/dispatcher.ts +2825 -0
  191. package/packages/core/src/processes/execution-settlement.ts +171 -0
  192. package/packages/core/src/processes/harvest-child.ts +115 -0
  193. package/packages/core/src/processes/harvest-execution-state.ts +121 -0
  194. package/packages/core/src/processes/harvest-runner.ts +1656 -0
  195. package/packages/core/src/processes/harvest.ts +595 -0
  196. package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
  197. package/packages/core/src/processes/phase-failure-budget.ts +52 -0
  198. package/packages/core/src/processes/process-group-reaper.ts +35 -0
  199. package/packages/core/src/processes/process-group.ts +65 -0
  200. package/packages/core/src/processes/publication-loss.ts +62 -0
  201. package/packages/core/src/processes/publication-state.ts +93 -0
  202. package/packages/core/src/processes/sandbox-state.ts +101 -0
  203. package/packages/core/src/registry.ts +24 -0
  204. package/packages/core/src/sessions/reducer.ts +185 -0
  205. package/packages/core/src/skills.ts +7 -0
  206. package/packages/core/src/spec-standard.ts +47 -0
  207. package/packages/core/src/store/adapter.ts +80 -0
  208. package/packages/core/src/store/build-scope.ts +272 -0
  209. package/packages/core/src/store/contract.ts +2581 -0
  210. package/packages/core/src/store/local/blobs.ts +38 -0
  211. package/packages/core/src/store/local/schema.ts +180 -0
  212. package/packages/core/src/store/local/store.ts +1713 -0
  213. package/packages/core/src/store/memory.ts +1138 -0
  214. package/packages/core/src/store/phase-session.ts +425 -0
  215. package/packages/core/src/store/remote/client.ts +780 -0
  216. package/packages/core/src/store/remote/index.ts +12 -0
  217. package/packages/core/src/store/remote/protocol.ts +348 -0
  218. package/packages/core/src/store/remote/server.ts +923 -0
  219. package/packages/core/src/store/remote/token.ts +137 -0
  220. package/packages/core/src/store/remote/version.ts +10 -0
  221. package/packages/core/src/store/retention.ts +91 -0
  222. package/packages/core/src/store/session-handle.ts +265 -0
  223. package/packages/core/src/store/streams/assemble.ts +79 -0
  224. package/packages/core/src/store/streams/lock.ts +42 -0
  225. package/packages/core/src/store/streams/session-writer.ts +306 -0
  226. package/packages/core/src/store/streams/types.ts +171 -0
  227. package/packages/core/src/store/streams/wait.ts +127 -0
  228. package/packages/core/src/store/subscribe.ts +105 -0
  229. package/packages/core/src/store/types.ts +466 -0
  230. package/packages/core/src/testing/fixed.ts +44 -0
  231. package/packages/core/src/testing/store-failures.ts +32 -0
  232. package/packages/core/src/zod-issues.ts +91 -0
  233. package/skills/code-review/SKILL.md +100 -0
  234. package/skills/finalize/SKILL.md +44 -0
  235. package/skills/guide/SKILL.md +1781 -0
  236. package/skills/guide/references/operator-api.md +164 -0
  237. package/skills/guide/references/plugin-authoring.md +354 -0
  238. package/skills/guide/references/remote-store-protocol.md +972 -0
  239. package/skills/guide/references/remote-ticket-protocol.md +79 -0
  240. package/skills/guide/references/setup.md +289 -0
  241. package/skills/guide/references/spec-standard.md +63 -0
  242. package/skills/guide/references/ticket-dependencies.md +59 -0
  243. package/skills/harvest/SKILL.md +66 -0
  244. package/skills/harvest-review/SKILL.md +63 -0
  245. package/skills/implement/SKILL.md +82 -0
  246. package/skills/plan/SKILL.md +123 -0
  247. package/skills/plan-review/SKILL.md +121 -0
  248. package/skills/reconcile/SKILL.md +65 -0
  249. package/skills/spec/SKILL.md +132 -0
  250. package/skills/tickets/SKILL.md +139 -0
  251. package/templates/autobuild.toml +36 -0
@@ -0,0 +1,351 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { z } from 'zod'
3
+ import {
4
+ hostedPrAttachmentAssetSchema,
5
+ prAttachmentSchema,
6
+ prImageHostSchema,
7
+ type HostedPrAttachmentAsset,
8
+ } from '../../ontology'
9
+ import type {
10
+ PrAttachmentHosting,
11
+ PrAttachmentReclaimRequest,
12
+ PrAttachmentUploadRequest,
13
+ } from '../types'
14
+ import { GitHubApiError, type GitHubRequest } from './github-transport'
15
+
16
+ const repositoryJson = z
17
+ .object({
18
+ private: z.boolean().optional(),
19
+ visibility: z.enum(['public', 'private', 'internal']).optional(),
20
+ })
21
+ .refine(
22
+ (repository) => repository.private !== undefined || repository.visibility !== undefined,
23
+ 'GitHub repository response must expose private or visibility',
24
+ )
25
+
26
+ const releaseJson = z.object({
27
+ id: z.number().int().positive(),
28
+ draft: z.boolean(),
29
+ published_at: z.string().min(1).nullable(),
30
+ upload_url: z.string().url(),
31
+ immutable: z.boolean(),
32
+ })
33
+
34
+ const releaseAssetJson = z.object({
35
+ id: z.number().int().positive(),
36
+ name: z.string().min(1),
37
+ state: z.string().min(1),
38
+ content_type: z.string().min(1),
39
+ size: z.number().int().nonnegative(),
40
+ digest: z.string().nullable().optional(),
41
+ browser_download_url: z.string().url(),
42
+ })
43
+ type ReleaseAsset = z.infer<typeof releaseAssetJson>
44
+
45
+ const SHA256 = /^[0-9a-f]{64}$/
46
+ /** Bounded page walk for the release-asset listing. */
47
+ const ASSET_PAGE_SIZE = 100
48
+ const MAX_ASSET_PAGES = 20
49
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000
50
+
51
+ function errorMessage(error: unknown): string {
52
+ return error instanceof Error ? error.message : String(error)
53
+ }
54
+
55
+ function repositoryParts(repository: string): [string, string] {
56
+ prImageHostSchema.shape.repository.parse(repository)
57
+ return repository.split('/') as [string, string]
58
+ }
59
+
60
+ function repositoryEndpoint(repository: string): string {
61
+ const [owner, name] = repositoryParts(repository)
62
+ return `repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`
63
+ }
64
+
65
+ /** Hash all identity inputs with explicit separators. Only a short,
66
+ * validated extension survives from the user-controlled filename. */
67
+ export function githubPrAttachmentAssetName(
68
+ request: Pick<PrAttachmentUploadRequest, 'prUrl' | 'attachment' | 'sha256'>,
69
+ ): string {
70
+ const { artifact, filename, mediaType } = request.attachment
71
+ const extension = filename.match(/\.([A-Za-z0-9]{1,10})$/)?.[1]?.toLowerCase()
72
+ const digest = createHash('sha256')
73
+ .update(request.prUrl)
74
+ .update('\0')
75
+ .update(artifact.kind)
76
+ .update('\0')
77
+ .update(String(artifact.rev))
78
+ .update('\0')
79
+ .update(filename)
80
+ .update('\0')
81
+ .update(mediaType)
82
+ .update('\0')
83
+ .update(request.sha256)
84
+ .digest('hex')
85
+ return `autobuild-attachment-${digest}${extension === undefined ? '' : `.${extension}`}`
86
+ }
87
+
88
+ function uploadEndpoint(uploadUrl: string, name: string): string {
89
+ const base = uploadUrl.replace(/\{[^}]*\}$/, '')
90
+ return `${base}${base.includes('?') ? '&' : '?'}name=${encodeURIComponent(name)}`
91
+ }
92
+
93
+ export class GitHubPrAttachmentHosting implements PrAttachmentHosting {
94
+ private readonly transport: GitHubRequest
95
+ private readonly requestTimeoutMs: number
96
+ /** One GitHubForge instance serves one plumbing operation in production;
97
+ * share the target probe across that operation's attachment uploads. */
98
+ private readonly targetValidations = new Map<string, Promise<z.infer<typeof releaseJson>>>()
99
+
100
+ constructor(opts: { transport: GitHubRequest; requestTimeoutMs?: number }) {
101
+ this.transport = opts.transport
102
+ this.requestTimeoutMs = opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
103
+ }
104
+
105
+ /** One bounded transport call with the class's request timeout applied. */
106
+ private call(
107
+ method: string,
108
+ path: string,
109
+ opts?: { headers?: Record<string, string>; raw?: Uint8Array },
110
+ ): Promise<{
111
+ status: number
112
+ headers: Record<string, string>
113
+ json?: unknown
114
+ bytes?: Uint8Array
115
+ }> {
116
+ const signal = AbortSignal.timeout(this.requestTimeoutMs)
117
+ return this.transport(method, path, {
118
+ ...(opts?.headers !== undefined ? { headers: opts.headers } : {}),
119
+ ...(opts?.raw !== undefined ? { raw: opts.raw } : {}),
120
+ signal,
121
+ })
122
+ }
123
+
124
+ private parseJson<S extends z.ZodType>(
125
+ schema: S,
126
+ response: { json?: unknown },
127
+ what: string,
128
+ ): z.infer<S> {
129
+ const parsed = schema.safeParse(response.json)
130
+ if (!parsed.success) {
131
+ throw new Error(`unexpected GitHub response for ${what}: ${parsed.error.message}`)
132
+ }
133
+ return parsed.data
134
+ }
135
+
136
+ private async validateTarget(
137
+ request: PrAttachmentUploadRequest,
138
+ ): Promise<z.infer<typeof releaseJson>> {
139
+ const target = prImageHostSchema.parse(request.target)
140
+ const root = repositoryEndpoint(target.repository)
141
+ const repository = this.parseJson(
142
+ repositoryJson,
143
+ await this.call('GET', root),
144
+ `repository read of ${target.repository}`,
145
+ )
146
+ if (
147
+ repository.private === true ||
148
+ (repository.visibility !== undefined && repository.visibility !== 'public')
149
+ ) {
150
+ throw new Error(
151
+ `PR attachment host ${target.repository} is private; GitHub cannot render authenticated release assets inline`,
152
+ )
153
+ }
154
+
155
+ const release = this.parseJson(
156
+ releaseJson,
157
+ await this.call('GET', `${root}/releases/${target.releaseId}`),
158
+ `release read of ${target.repository}#${target.releaseId}`,
159
+ )
160
+ if (release.id !== target.releaseId) {
161
+ throw new Error(
162
+ `PR attachment host returned release ${release.id}, expected ${target.releaseId}`,
163
+ )
164
+ }
165
+ if (release.draft || release.published_at === null) {
166
+ throw new Error(
167
+ `PR attachment host release ${target.repository}#${target.releaseId} is not published`,
168
+ )
169
+ }
170
+ if (release.immutable === true) {
171
+ throw new Error(
172
+ `PR attachment host release ${target.repository}#${target.releaseId} is immutable`,
173
+ )
174
+ }
175
+ return release
176
+ }
177
+
178
+ private validatedTarget(
179
+ request: PrAttachmentUploadRequest,
180
+ ): Promise<z.infer<typeof releaseJson>> {
181
+ const key = `${request.target.repository}\0${request.target.releaseId}`
182
+ const existing = this.targetValidations.get(key)
183
+ if (existing !== undefined) return existing
184
+ const pending = this.validateTarget(request).catch((error) => {
185
+ this.targetValidations.delete(key)
186
+ throw error
187
+ })
188
+ this.targetValidations.set(key, pending)
189
+ return pending
190
+ }
191
+
192
+ private async listAssets(request: PrAttachmentUploadRequest): Promise<ReleaseAsset[]> {
193
+ const root = repositoryEndpoint(request.target.repository)
194
+ const assets: ReleaseAsset[] = []
195
+ for (let page = 1; page <= MAX_ASSET_PAGES; page += 1) {
196
+ const response = await this.call(
197
+ 'GET',
198
+ `${root}/releases/${request.target.releaseId}/assets?per_page=${ASSET_PAGE_SIZE}&page=${page}`,
199
+ )
200
+ const batch = this.parseJson(
201
+ z.array(releaseAssetJson),
202
+ response,
203
+ `asset listing of ${request.target.repository}#${request.target.releaseId}`,
204
+ )
205
+ assets.push(...batch)
206
+ if (batch.length < ASSET_PAGE_SIZE) return assets
207
+ }
208
+ throw new Error(
209
+ `asset listing of ${request.target.repository}#${request.target.releaseId} exceeded ${MAX_ASSET_PAGES} pages`,
210
+ )
211
+ }
212
+
213
+ private assertCompatibleAsset(
214
+ asset: ReleaseAsset,
215
+ expected: { name: string; mediaType: string; size: number; digest: string },
216
+ target: PrAttachmentUploadRequest['target'],
217
+ ): HostedPrAttachmentAsset {
218
+ if (asset.name !== expected.name) {
219
+ throw new Error(
220
+ `PR attachment asset has name ${JSON.stringify(asset.name)}, expected ${JSON.stringify(expected.name)}`,
221
+ )
222
+ }
223
+ if (asset.state !== 'uploaded') {
224
+ throw new Error(
225
+ `PR attachment asset ${asset.name} has state ${JSON.stringify(asset.state)}, expected "uploaded"`,
226
+ )
227
+ }
228
+ if (asset.content_type !== expected.mediaType) {
229
+ throw new Error(
230
+ `PR attachment asset ${asset.name} has content type ${JSON.stringify(asset.content_type)}, expected ${JSON.stringify(expected.mediaType)}`,
231
+ )
232
+ }
233
+ if (asset.size !== expected.size) {
234
+ throw new Error(
235
+ `PR attachment asset ${asset.name} has size ${asset.size}, expected ${expected.size}`,
236
+ )
237
+ }
238
+ if (asset.digest != null && asset.digest !== expected.digest) {
239
+ throw new Error(
240
+ `PR attachment asset ${asset.name} has digest ${JSON.stringify(asset.digest)}, expected ${JSON.stringify(expected.digest)}`,
241
+ )
242
+ }
243
+ return hostedPrAttachmentAssetSchema.parse({
244
+ provider: 'github-release',
245
+ repository: target.repository,
246
+ releaseId: target.releaseId,
247
+ assetId: asset.id,
248
+ url: asset.browser_download_url,
249
+ })
250
+ }
251
+
252
+ async upload(request: PrAttachmentUploadRequest): Promise<HostedPrAttachmentAsset> {
253
+ const target = prImageHostSchema.parse(request.target)
254
+ if (!SHA256.test(request.sha256)) {
255
+ throw new Error('PR attachment upload requires a full lowercase SHA-256 blob ref')
256
+ }
257
+ const actual = createHash('sha256').update(request.content).digest('hex')
258
+ if (actual !== request.sha256) {
259
+ throw new Error(`PR attachment bytes hash to ${actual}, not expected blob ${request.sha256}`)
260
+ }
261
+ const attachment = prAttachmentSchema.parse(request.attachment)
262
+ if (!attachment.mediaType.startsWith('image/')) {
263
+ throw new Error(
264
+ `PR attachment image host accepts only image/* media, got ${JSON.stringify(attachment.mediaType)}`,
265
+ )
266
+ }
267
+ if (request.prUrl.trim() === '') {
268
+ throw new Error('PR attachment upload PR URL must be non-blank')
269
+ }
270
+
271
+ const normalized = { ...request, target, attachment }
272
+ const release = await this.validatedTarget(normalized)
273
+ const filename = githubPrAttachmentAssetName(normalized)
274
+ const expected = {
275
+ name: filename,
276
+ mediaType: normalized.attachment.mediaType,
277
+ size: normalized.content.byteLength,
278
+ digest: `sha256:${normalized.sha256}`,
279
+ }
280
+ const existing = (await this.listAssets(normalized)).find((asset) => asset.name === filename)
281
+
282
+ if (existing !== undefined) {
283
+ // GitHub may leave an incomplete starter/open row when an upload dies.
284
+ // It is safe to remove because its deterministic name belongs to this
285
+ // exact PR/attachment/blob identity; an uploaded mismatch is never clobbered.
286
+ if (existing.state === 'starter' || existing.state === 'open') {
287
+ await this.deleteAsset(target.repository, existing.id)
288
+ } else {
289
+ return this.assertCompatibleAsset(existing, expected, target)
290
+ }
291
+ }
292
+
293
+ try {
294
+ const uploaded = this.parseJson(
295
+ releaseAssetJson,
296
+ await this.call('POST', uploadEndpoint(release.upload_url, filename), {
297
+ headers: { 'Content-Type': normalized.attachment.mediaType },
298
+ raw: normalized.content,
299
+ }),
300
+ 'PR attachment upload',
301
+ )
302
+ if (uploaded.name !== filename) {
303
+ throw new Error(
304
+ `PR attachment upload returned name ${JSON.stringify(uploaded.name)}, expected ${JSON.stringify(filename)}`,
305
+ )
306
+ }
307
+ return this.assertCompatibleAsset(uploaded, expected, target)
308
+ } catch (uploadError) {
309
+ // A lost response may still have committed the external write.
310
+ // Reconcile once before degrading finalize: adopt a compatible upload,
311
+ // or remove the incomplete starter/open remnant so failed attempts
312
+ // cannot accumulate untracked release storage.
313
+ let candidate: ReleaseAsset | undefined
314
+ try {
315
+ candidate = (await this.listAssets(normalized)).find((asset) => asset.name === filename)
316
+ } catch {
317
+ throw uploadError
318
+ }
319
+ if (candidate === undefined) throw uploadError
320
+ if (candidate.state === 'starter' || candidate.state === 'open') {
321
+ try {
322
+ await this.deleteAsset(target.repository, candidate.id)
323
+ } catch {
324
+ // Preserve the primary upload error; its deterministic name keeps
325
+ // the remnant identifiable to a later explicit retry or cleanup.
326
+ }
327
+ throw uploadError
328
+ }
329
+ return this.assertCompatibleAsset(candidate, expected, target)
330
+ }
331
+ }
332
+
333
+ private async deleteAsset(repository: string, assetId: number): Promise<void> {
334
+ try {
335
+ await this.call('DELETE', `${repositoryEndpoint(repository)}/releases/assets/${assetId}`)
336
+ } catch (error) {
337
+ // Absent (already deleted) is the reclaim goal met; anything else is a
338
+ // real failure.
339
+ if (error instanceof GitHubApiError && error.status === 404) return
340
+ throw new Error(
341
+ `PR attachment asset delete failed for ${repository}#${assetId}: ${errorMessage(error)}`,
342
+ { cause: error },
343
+ )
344
+ }
345
+ }
346
+
347
+ async reclaim(request: PrAttachmentReclaimRequest): Promise<void> {
348
+ const asset = hostedPrAttachmentAssetSchema.parse(request.asset)
349
+ await this.deleteAsset(asset.repository, asset.assetId)
350
+ }
351
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * GitHub REST transport seam, shared by the forge adapter and its PR
3
+ * attachment hosting, plus the credential resolution every GitHub caller
4
+ * shares. Imports nothing from the forge module so both can import it
5
+ * without a cycle (the workspace module's `spawnExec` is the only production
6
+ * dependency), and tests inject a fake transport or exec seam instead of
7
+ * shelling `gh`.
8
+ */
9
+ import { z } from 'zod'
10
+ import { spawnExec } from '../workspace/git-worktree'
11
+
12
+ export interface GitHubRequestOpts {
13
+ query?: Record<string, string>
14
+ /** JSON-serializable request body. Ignored when `raw` is set. */
15
+ body?: unknown
16
+ /** Extra headers (e.g. `Accept: application/vnd.github.raw`). */
17
+ headers?: Record<string, string>
18
+ /** Raw request bytes (release-asset upload); sets the body verbatim. */
19
+ raw?: Uint8Array
20
+ signal?: AbortSignal
21
+ }
22
+
23
+ export interface GitHubResponse {
24
+ status: number
25
+ /** Lowercased response headers of interest. */
26
+ headers: Record<string, string>
27
+ /** Parsed JSON body when the response declared one. */
28
+ json?: unknown
29
+ /** Raw response bytes when the body was not JSON. */
30
+ bytes?: Uint8Array
31
+ }
32
+
33
+ /**
34
+ * One authenticated GitHub REST call. `path` is API-relative
35
+ * (`repos/{owner}/{repo}/pulls/42`) or an absolute URL (release-asset
36
+ * `upload_url`). Non-2xx responses throw {@link GitHubApiError} — except
37
+ * `304 Not Modified`, which is returned (never thrown) because it is a
38
+ * documented *success* for a conditional request: callers that send
39
+ * revalidation headers such as `If-None-Match` read the 304 as "the cached
40
+ * representation is still current". A 304 carries no body, so the response
41
+ * has no `json` and no `bytes`.
42
+ */
43
+ export type GitHubRequest = (
44
+ method: string,
45
+ path: string,
46
+ opts?: GitHubRequestOpts,
47
+ ) => Promise<GitHubResponse>
48
+
49
+ /** Typed non-2xx response. `body` carries the parsed JSON (when any) so
50
+ * fail-closed callers can classify documented error shapes. */
51
+ export class GitHubApiError extends Error {
52
+ readonly status: number
53
+ readonly body?: unknown
54
+
55
+ constructor(status: number, message: string, body?: unknown) {
56
+ super(message)
57
+ this.name = 'GitHubApiError'
58
+ this.status = status
59
+ this.body = body
60
+ }
61
+ }
62
+
63
+ const GITHUB_API_BASE = 'https://api.github.com'
64
+ const GITHUB_API_VERSION = '2022-11-28'
65
+
66
+ function githubErrorMessage(status: number, body: unknown, text: string): string {
67
+ if (body !== null && typeof body === 'object' && 'message' in body) {
68
+ const message = (body as { message?: unknown }).message
69
+ if (typeof message === 'string' && message !== '') return message
70
+ }
71
+ return text.trim() !== '' ? text.trim() : `GitHub API responded ${status}`
72
+ }
73
+
74
+ /** The documented plan-limitation error shape for the rulesets endpoint.
75
+ * Shared by the forge's fail-closed gate probe and its tests. */
76
+ export const restPlanLimitation = z
77
+ .object({
78
+ message: z.literal(
79
+ 'Upgrade to GitHub Pro or make this repository public to enable this feature.',
80
+ ),
81
+ documentation_url: z.literal('https://docs.github.com/rest/repos/rules#get-rules-for-a-branch'),
82
+ })
83
+ .passthrough()
84
+
85
+ /** Where a transport's bearer token comes from: a literal, nothing (anonymous
86
+ * requests), or a resolver the transport invokes lazily — a bare token or a
87
+ * {@link GitHubCredential}, whose miss reason the transport then attaches to
88
+ * the error of any request that had to go out anonymously. The transport asks
89
+ * a resolver again at most once per {@link CREDENTIAL_MISS_TTL_MS}: after a
90
+ * miss (a momentarily locked keyring is neither frozen into anonymous access
91
+ * for the process lifetime nor re-probed on every request), and after a
92
+ * request answers `401 Unauthorized` (a rotated or revoked `gh auth login` is
93
+ * picked up without a restart, while a revoked-but-still-stored login cannot
94
+ * drive a gh spawn per request). */
95
+ export type GitHubTokenSource =
96
+ | string
97
+ | undefined
98
+ | (() => Promise<string | undefined | GitHubCredential>)
99
+
100
+ /** Minimum interval between two invocations of a token resolver. */
101
+ export const CREDENTIAL_MISS_TTL_MS = 60_000
102
+
103
+ /** Production transport: token-authenticated fetch against api.github.com.
104
+ * Callers pass a literal token or a resolver such as
105
+ * {@link githubTokenSource}; no `Authorization` header is sent when neither
106
+ * yields one. */
107
+ export function createGitHubFetchTransport(opts: {
108
+ token?: GitHubTokenSource
109
+ apiBase?: string
110
+ now?: () => number
111
+ }): GitHubRequest {
112
+ const source = opts.token
113
+ const now = opts.now ?? Date.now
114
+ let resolved: Promise<GitHubCredential> | undefined
115
+ /** When the current answer arrived; `undefined` while one is in flight. */
116
+ let resolvedAt: number | undefined
117
+ let missed = false
118
+ const stale = (): boolean =>
119
+ resolvedAt !== undefined && now() - resolvedAt >= CREDENTIAL_MISS_TTL_MS
120
+ const normalize = (answer: string | undefined | GitHubCredential): GitHubCredential => {
121
+ if (typeof answer === 'object') return answer
122
+ return answer === undefined || answer === ''
123
+ ? { token: undefined, reason: 'no GitHub credential was available' }
124
+ : { token: answer }
125
+ }
126
+ const resolveToken = (): Promise<GitHubCredential> => {
127
+ if (missed && stale()) resolved = undefined
128
+ if (resolved === undefined) {
129
+ resolvedAt = undefined
130
+ missed = false
131
+ const attempt = (typeof source === 'function' ? source() : Promise.resolve(source)).then(
132
+ normalize,
133
+ )
134
+ resolved = attempt
135
+ attempt.then(
136
+ (credential) => {
137
+ if (resolved !== attempt) return
138
+ resolvedAt = now()
139
+ missed = credential.token === undefined
140
+ },
141
+ () => {
142
+ // A rejected probe is not an answer at all — ask again next time.
143
+ if (resolved === attempt) resolved = undefined
144
+ },
145
+ )
146
+ }
147
+ return resolved
148
+ }
149
+ /** 401: the memoized token is no longer accepted. Drop it once the interval
150
+ * has passed so the next request resolves again; inside the interval the
151
+ * stale token keeps failing without spawning anything. */
152
+ const forgetToken = (): void => {
153
+ if (typeof source === 'function' && stale()) resolved = undefined
154
+ }
155
+ const base = opts.apiBase ?? GITHUB_API_BASE
156
+ return async (method, path, request = {}) => {
157
+ const credential = await resolveToken()
158
+ const token = credential.token
159
+ let url = /^https:\/\//i.test(path) ? path : `${base}/${path.replace(/^\/+/, '')}`
160
+ if (request.query !== undefined) {
161
+ const params = new URLSearchParams(request.query)
162
+ url += (url.includes('?') ? '&' : '?') + params.toString()
163
+ }
164
+ const headers: Record<string, string> = {
165
+ 'X-GitHub-Api-Version': GITHUB_API_VERSION,
166
+ Accept: 'application/vnd.github+json',
167
+ ...request.headers,
168
+ }
169
+ if (token !== undefined && token !== '') headers.Authorization = `Bearer ${token}`
170
+ let body: BodyInit | undefined
171
+ if (request.raw !== undefined) body = request.raw as unknown as BodyInit
172
+ else if (request.body !== undefined) {
173
+ headers['Content-Type'] = 'application/json'
174
+ body = JSON.stringify(request.body)
175
+ }
176
+ const response = await fetch(url, {
177
+ method,
178
+ headers,
179
+ ...(body !== undefined ? { body } : {}),
180
+ ...(request.signal !== undefined ? { signal: request.signal } : {}),
181
+ })
182
+ const headerMap: Record<string, string> = {}
183
+ response.headers.forEach((value, key) => {
184
+ headerMap[key.toLowerCase()] = value
185
+ })
186
+ // A 304 Not Modified answers a conditional request (If-None-Match) and
187
+ // always carries an empty body — return it before the non-2xx throw so
188
+ // callers can treat revalidation success as success, not an error.
189
+ if (response.status === 304) {
190
+ return { status: 304, headers: headerMap }
191
+ }
192
+ const contentType = headerMap['content-type'] ?? ''
193
+ let text = ''
194
+ let json: unknown
195
+ let bytes: Uint8Array | undefined
196
+ if (contentType.includes('application/json')) {
197
+ text = await response.text()
198
+ try {
199
+ json = JSON.parse(text)
200
+ } catch {
201
+ // A non-JSON body behind a JSON content type is ordinary garbage.
202
+ }
203
+ } else {
204
+ // Binary bodies (release assets, raw file contents) are read as bytes —
205
+ // never round-tripped through text, which would corrupt arbitrary data.
206
+ const buffer = await response.arrayBuffer()
207
+ if (buffer.byteLength > 0) bytes = new Uint8Array(buffer)
208
+ }
209
+ if (!response.ok) {
210
+ if (response.status === 401) forgetToken()
211
+ if (bytes !== undefined) text = new TextDecoder().decode(bytes)
212
+ // An anonymous request that failed names why it was anonymous: a 404 on
213
+ // a private repository is otherwise indistinguishable from a bad path,
214
+ // and the operator's fix (export a token, or `gh auth login`) is the
215
+ // point of the message.
216
+ const anonymous =
217
+ credential.token === undefined ? ` (request was unauthenticated: ${credential.reason})` : ''
218
+ throw new GitHubApiError(
219
+ response.status,
220
+ `${githubErrorMessage(response.status, json, text)}${anonymous}`,
221
+ json,
222
+ )
223
+ }
224
+ return {
225
+ status: response.status,
226
+ headers: headerMap,
227
+ ...(json !== undefined ? { json } : {}),
228
+ ...(bytes !== undefined ? { bytes } : {}),
229
+ }
230
+ }
231
+ }
232
+
233
+ export function githubTokenFromEnv(
234
+ env: Readonly<Record<string, string | undefined>>,
235
+ ): string | undefined {
236
+ // `||`, not `??`: an exported-but-empty GITHUB_TOKEN (a templated .env or
237
+ // unit file) must not mask a valid GH_TOKEN.
238
+ const token = env.GITHUB_TOKEN || env.GH_TOKEN
239
+ return token !== undefined && token !== '' ? token : undefined
240
+ }
241
+
242
+ /** Subprocess seam for the gh CLI credential probe: argv and an abort signal
243
+ * in; exit code, stdout, and stderr out. The workspace module's `spawnExec`
244
+ * satisfies it and is the default; callers that own a richer exec seam adapt
245
+ * it so tests never reach a real `gh`. The signal is the probe's deadline. */
246
+ export type GitHubCliExec = (
247
+ cmd: string[],
248
+ opts: { signal: AbortSignal },
249
+ ) => Promise<{ exitCode: number; stdout: string; stderr: string }>
250
+
251
+ /** How long the gh probe may take before it is treated as "no credential".
252
+ * `gh auth token` never prompts, so anything this slow is a wedged keyring. */
253
+ export const GH_CLI_TOKEN_TIMEOUT_MS = 10_000
254
+
255
+ /** The argv of the gh CLI credential probe. Exported so exec seams in tests
256
+ * can recognize it. `--hostname` pins github.com even when the operator's
257
+ * default gh host is an enterprise instance, because the transport only ever
258
+ * speaks to api.github.com. */
259
+ export const GH_CLI_TOKEN_COMMAND: readonly string[] = [
260
+ 'gh',
261
+ 'auth',
262
+ 'token',
263
+ '--hostname',
264
+ 'github.com',
265
+ ]
266
+
267
+ /** Outcome of one credential lookup. A miss carries the reason so a
268
+ * fail-fast caller can say *why* (gh not installed, not logged in to
269
+ * github.com, timed out) instead of a generic "no token". */
270
+ export type GitHubCredential = { token: string } | { token: undefined; reason: string }
271
+
272
+ /** The credential the operator stored with `gh auth login`, read through
273
+ * `gh auth token`. Never throws: gh missing, unauthenticated, silent, or
274
+ * slower than the deadline all resolve to a miss with its reason. The
275
+ * deadline is enforced here — not in the spawner — so it holds for every
276
+ * injected exec seam: the probe is aborted through the signal and its result
277
+ * abandoned, so a wrapper script that forked the real gh and left the pipes
278
+ * open cannot hold the caller. The probe inherits this process's environment
279
+ * (gh reads its own keyring) and needs no working directory. */
280
+ export async function githubTokenFromGhCli(
281
+ exec: GitHubCliExec = spawnExec,
282
+ timeoutMs: number = GH_CLI_TOKEN_TIMEOUT_MS,
283
+ ): Promise<GitHubCredential> {
284
+ const command = GH_CLI_TOKEN_COMMAND.join(' ')
285
+ const controller = new AbortController()
286
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
287
+ const deadline = new Promise<GitHubCredential>((resolve) => {
288
+ controller.signal.addEventListener(
289
+ 'abort',
290
+ () =>
291
+ resolve({
292
+ token: undefined,
293
+ reason: `\`${command}\` did not answer within ${timeoutMs} ms`,
294
+ }),
295
+ { once: true },
296
+ )
297
+ })
298
+ const probe = (async (): Promise<GitHubCredential> => {
299
+ try {
300
+ const result = await exec([...GH_CLI_TOKEN_COMMAND], { signal: controller.signal })
301
+ if (result.exitCode !== 0) {
302
+ const detail = result.stderr.trim() || result.stdout.trim()
303
+ return {
304
+ token: undefined,
305
+ reason: `\`${command}\` exited ${result.exitCode}${detail === '' ? '' : `: ${detail}`}`,
306
+ }
307
+ }
308
+ const token = result.stdout.trim()
309
+ if (token === '') return { token: undefined, reason: `\`${command}\` printed no token` }
310
+ return { token }
311
+ } catch (error) {
312
+ const detail = error instanceof Error ? error.message : String(error)
313
+ return { token: undefined, reason: `\`${command}\` could not run: ${detail}` }
314
+ }
315
+ })()
316
+ try {
317
+ return await Promise.race([probe, deadline])
318
+ } finally {
319
+ clearTimeout(timer)
320
+ }
321
+ }
322
+
323
+ /** GitHub credential resolution for kernel-side forge access: `GITHUB_TOKEN`,
324
+ * then `GH_TOKEN` ({@link githubTokenFromEnv}), then the gh CLI's stored
325
+ * login. The environment variables serve hosted and sandboxed dispatchers,
326
+ * which have no gh and no keyring; the gh fallback keeps a local
327
+ * checkout-mode dispatcher working with nothing more than `gh auth login`, as
328
+ * it did before the adapter moved off gh subprocesses. A miss names what was
329
+ * tried. */
330
+ export async function resolveGitHubToken(
331
+ env: Readonly<Record<string, string | undefined>>,
332
+ exec?: GitHubCliExec,
333
+ ): Promise<GitHubCredential> {
334
+ const fromEnv = githubTokenFromEnv(env)
335
+ if (fromEnv !== undefined) return { token: fromEnv }
336
+ const probe = await githubTokenFromGhCli(exec)
337
+ if (probe.token !== undefined) return probe
338
+ return {
339
+ token: undefined,
340
+ reason: `GITHUB_TOKEN and GH_TOKEN are unset and ${probe.reason}`,
341
+ }
342
+ }
343
+
344
+ /** A transport token source over {@link resolveGitHubToken}: the builtin
345
+ * forge's default. Resolves whenever the transport asks (first request, then
346
+ * after a miss interval or a 401). */
347
+ export function githubTokenSource(
348
+ env: Readonly<Record<string, string | undefined>>,
349
+ exec?: GitHubCliExec,
350
+ ): () => Promise<GitHubCredential> {
351
+ return () => resolveGitHubToken(env, exec)
352
+ }