@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,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
|
+
}
|