1import type {
2 EmitterWebhookEvent,
3 EmitterWebhookEventName,
4} from "@octokit/webhooks";
5import * as Context from "effect/Context";
6import * as Effect from "effect/Effect";
7import type * as Redacted from "effect/Redacted";
8import { sanitizeKey } from "../RuntimeContext.ts";
9import type { Providers } from "./Providers.ts";
10
11/**
12 * A reference to the GitHub repository whose events you want to receive.
13 */
14export 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 */
31export 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 */
42export 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 */
51export 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 */
59export type SelectedEvent<E extends readonly WebhookEventName[]> =
60 "*" extends E[number] ? GitHubEventName : Exclude<E[number], "*">;
61
62export 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 */
124export 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>;
129export 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>;
138export 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
158export 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
168export 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 */
178export 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 */
186export const webhookSecretEnvName = (repository: RepositoryRef): string =>
187 `ALCHEMY_GITHUB_WEBHOOK_SECRET_${sanitizeKey(repository.owner)}_${sanitizeKey(
188 repository.repository,
189 )}`;