| 1 | import type { |
| 2 | EmitterWebhookEvent, |
| 3 | EmitterWebhookEventName, |
| 4 | } from "@octokit/webhooks"; |
| 5 | import * as Context from "effect/Context"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import type * as Redacted from "effect/Redacted"; |
| 8 | import { sanitizeKey } from "../RuntimeContext.ts"; |
| 9 | import type { Providers } from "./Providers.ts"; |
| 10 | |
| 11 | /** |
| 12 | * A reference to the GitHub repository whose events you want to receive. |
| 13 | */ |
| 14 | export interface RepositoryRef { |
| 15 | /** |
| 16 | * Repository owner (user or organization). |
| 17 | */ |
| 18 | owner: string; |
| 19 | |
| 20 | /** |
| 21 | * Repository name. |
| 22 | */ |
| 23 | repository: string; |
| 24 | } |
| 25 | |
| 26 | /** |
| 27 | * The bare GitHub webhook event names (e.g. `push`, `pull_request`), sourced |
| 28 | * from `@octokit/webhooks`. Excludes the `event.action` emitter variants |
| 29 | * (e.g. `pull_request.opened`) — webhooks are configured by bare event name. |
| 30 | */ |
| 31 | export type GitHubEventName = Exclude< |
| 32 | EmitterWebhookEventName, |
| 33 | `${string}.${string}` |
| 34 | >; |
| 35 | |
| 36 | /** |
| 37 | * Names selectable in {@link RepositoryEventSourceProps.events}: every bare |
| 38 | * GitHub event name plus `"*"` to subscribe to all of them. |
| 39 | * |
| 40 | * @see {@link https://docs.github.com/en/webhooks/webhook-events-and-payloads | GitHub webhook events and payloads} |
| 41 | */ |
| 42 | export type WebhookEventName = GitHubEventName | "*"; |
| 43 | |
| 44 | /** |
| 45 | * A single GitHub webhook delivery — Octokit's `EmitterWebhookEvent`, a |
| 46 | * complete discriminated union of `{ id, name, payload }` keyed on `name` |
| 47 | * with a fully-typed `payload` per event. `Name` narrows the union to the |
| 48 | * events the subscriber selected, so `switch (event.name)` exhaustively |
| 49 | * narrows `event.payload`. |
| 50 | */ |
| 51 | export type WebhookEvent<Name extends GitHubEventName = GitHubEventName> = |
| 52 | EmitterWebhookEvent<Name>; |
| 53 | |
| 54 | /** |
| 55 | * The set of event names a handler can observe given the `events` it |
| 56 | * selected. Selecting `"*"` (or omitting `events`) widens back to every |
| 57 | * {@link GitHubEventName}; otherwise it's the union of the chosen literals. |
| 58 | */ |
| 59 | export type SelectedEvent<E extends readonly WebhookEventName[]> = |
| 60 | "*" extends E[number] ? GitHubEventName : Exclude<E[number], "*">; |
| 61 | |
| 62 | export interface RepositoryEventSourceProps< |
| 63 | E extends readonly WebhookEventName[] = readonly WebhookEventName[], |
| 64 | > extends RepositoryRef { |
| 65 | /** |
| 66 | * GitHub event names to subscribe to (e.g. `["push", "pull_request"]`). |
| 67 | * Use `["*"]` to receive every event GitHub emits. |
| 68 | * @default ["push"] |
| 69 | */ |
| 70 | events?: E; |
| 71 | |
| 72 | /** |
| 73 | * Secret used to verify each delivery's `HMAC-SHA256` signature. When set, |
| 74 | * the event source provisions the webhook with this secret and the runtime |
| 75 | * rejects deliveries whose `X-Hub-Signature-256` header doesn't match. |
| 76 | * Strongly recommended — without it, anyone who learns the delivery URL can |
| 77 | * forge events. |
| 78 | */ |
| 79 | secret?: Redacted.Redacted<string>; |
| 80 | |
| 81 | /** |
| 82 | * Path on the host that GitHub delivers to. Defaults to a deterministic |
| 83 | * per-repository path so deliveries don't collide with your application |
| 84 | * routes. Override only if you need a fixed, well-known path. |
| 85 | */ |
| 86 | path?: string; |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Subscribe to events emitted by a GitHub repository. |
| 91 | * |
| 92 | * Call it in the init phase of a host (e.g. a Cloudflare Worker) and pass a |
| 93 | * `process` function that receives each {@link WebhookEvent} and returns an |
| 94 | * `Effect`. The handler runs once per webhook delivery. |
| 95 | * |
| 96 | * Wiring the webhook (delivery URL, secret, IAM/bindings) is handled by the |
| 97 | * host-specific runtime layer — see |
| 98 | * `Cloudflare.Workers.GitHubRepositoryEventSourceLive` for the Cloudflare Worker |
| 99 | * implementation. |
| 100 | * **Example:** Example |
| 101 | * ```typescript |
| 102 | * // `event.name` is narrowed to "push" | "pull_request" |
| 103 | * yield* GitHub.consumeRepositoryEvents( |
| 104 | * { |
| 105 | * owner: "my-org", |
| 106 | * repository: "my-repo", |
| 107 | * events: ["push", "pull_request"], |
| 108 | * secret, |
| 109 | * }, |
| 110 | * (event) => Effect.log(`received ${event.name} (${event.id})`), |
| 111 | * ); |
| 112 | * ``` |
| 113 | * |
| 114 | * **Example:** Example |
| 115 | * ```typescript |
| 116 | * // When you don't need to pass any options, the handler is the only argument. |
| 117 | * yield* GitHub.consumeRepositoryEvents((event) => |
| 118 | * Effect.log(`received ${event.name} (${event.id})`), |
| 119 | * ); |
| 120 | * ``` |
| 121 | * |
| 122 | * @binding |
| 123 | */ |
| 124 | export function consumeRepositoryEvents<Req = never>( |
| 125 | process: ( |
| 126 | event: WebhookEvent<SelectedEvent<readonly WebhookEventName[]>>, |
| 127 | ) => Effect.Effect<void, never, Req | Providers>, |
| 128 | ): Effect.Effect<void, never, RepositoryEventSource>; |
| 129 | export function consumeRepositoryEvents< |
| 130 | const E extends readonly WebhookEventName[] = readonly WebhookEventName[], |
| 131 | Req = never, |
| 132 | >( |
| 133 | props: RepositoryEventSourceProps<E>, |
| 134 | process: ( |
| 135 | event: WebhookEvent<SelectedEvent<E>>, |
| 136 | ) => Effect.Effect<void, never, Req | Providers>, |
| 137 | ): Effect.Effect<void, never, RepositoryEventSource>; |
| 138 | export function consumeRepositoryEvents< |
| 139 | const E extends readonly WebhookEventName[] = readonly WebhookEventName[], |
| 140 | Req = never, |
| 141 | >( |
| 142 | propsOrProcess: |
| 143 | | RepositoryEventSourceProps<E> |
| 144 | | (( |
| 145 | event: WebhookEvent<SelectedEvent<E>>, |
| 146 | ) => Effect.Effect<void, never, Req | Providers>), |
| 147 | maybeProcess?: ( |
| 148 | event: WebhookEvent<SelectedEvent<E>>, |
| 149 | ) => Effect.Effect<void, never, Req | Providers>, |
| 150 | ) { |
| 151 | const [props, process] = |
| 152 | typeof propsOrProcess === "function" |
| 153 | ? [{} as RepositoryEventSourceProps<E>, propsOrProcess] |
| 154 | : [propsOrProcess, maybeProcess!]; |
| 155 | return RepositoryEventSource.use((source) => source(props, process)); |
| 156 | } |
| 157 | |
| 158 | export type RepositoryEventSourceService = < |
| 159 | E extends readonly WebhookEventName[] = readonly WebhookEventName[], |
| 160 | Req = never, |
| 161 | >( |
| 162 | props: RepositoryEventSourceProps<E>, |
| 163 | process: ( |
| 164 | event: WebhookEvent<SelectedEvent<E>>, |
| 165 | ) => Effect.Effect<void, never, Req>, |
| 166 | ) => Effect.Effect<void, never, never>; |
| 167 | |
| 168 | export class RepositoryEventSource extends Context.Service< |
| 169 | RepositoryEventSource, |
| 170 | RepositoryEventSourceService |
| 171 | >()("GitHub.RepositoryEventSource") {} |
| 172 | |
| 173 | /** |
| 174 | * Deterministic delivery path for a repository's webhook. Shared by the |
| 175 | * deploy-time policy (which registers the webhook URL) and the runtime |
| 176 | * (which only claims requests on this path), so both sides agree. |
| 177 | */ |
| 178 | export const webhookPath = (props: RepositoryRef & { path?: string }): string => |
| 179 | props.path ?? `/__alchemy/github/${props.owner}/${props.repository}`; |
| 180 | |
| 181 | /** |
| 182 | * Deterministic env var name under which the deploy-time policy stores the |
| 183 | * webhook secret on the host, so the runtime can read it back to verify |
| 184 | * signatures. |
| 185 | */ |
| 186 | export const webhookSecretEnvName = (repository: RepositoryRef): string => |
| 187 | `ALCHEMY_GITHUB_WEBHOOK_SECRET_${sanitizeKey(repository.owner)}_${sanitizeKey( |
| 188 | repository.repository, |
| 189 | )}`; |