| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Redacted from "effect/Redacted"; |
| 3 | import type { Input } from "../Input.ts"; |
| 4 | import { isResolved } from "../Diff.ts"; |
| 5 | import * as Provider from "../Provider.ts"; |
| 6 | import { Resource } from "../Resource.ts"; |
| 7 | import { gitHubBaseUrlChanged, Octokit, octokitFor } from "./Octokit.ts"; |
| 8 | import type * as GitHub from "./Providers.ts"; |
| 9 | import type { WebhookEventName } from "./RepositoryEventSource.ts"; |
| 10 | |
| 11 | export interface WebhookProps { |
| 12 | /** |
| 13 | * Repository owner (user or organization). |
| 14 | */ |
| 15 | owner: string; |
| 16 | |
| 17 | /** |
| 18 | * Repository name. |
| 19 | */ |
| 20 | repository: string; |
| 21 | |
| 22 | /** |
| 23 | * The URL that GitHub will `POST` events to. Accepts an `Output<string>` |
| 24 | * (e.g. a Cloudflare Worker's `url`) so the webhook can target a |
| 25 | * resource provisioned in the same stack. |
| 26 | */ |
| 27 | url: Input<string>; |
| 28 | |
| 29 | /** |
| 30 | * GitHub event names to subscribe to (e.g. `["push", "pull_request"]`). |
| 31 | * Use `["*"]` to receive every event GitHub emits. |
| 32 | * @default ["push"] |
| 33 | */ |
| 34 | events?: WebhookEventName[]; |
| 35 | |
| 36 | /** |
| 37 | * Secret used to sign each delivery with `HMAC-SHA256`. The signature is |
| 38 | * sent in the `X-Hub-Signature-256` header so the receiver can verify the |
| 39 | * payload originated from GitHub. Wrap with `Redacted.make` to keep it out |
| 40 | * of logs and state. |
| 41 | */ |
| 42 | secret?: Redacted.Redacted<string>; |
| 43 | |
| 44 | /** |
| 45 | * Payload media type. |
| 46 | * @default "json" |
| 47 | */ |
| 48 | contentType?: "json" | "form"; |
| 49 | |
| 50 | /** |
| 51 | * Whether deliveries are active. |
| 52 | * @default true |
| 53 | */ |
| 54 | active?: boolean; |
| 55 | |
| 56 | /** |
| 57 | * Skip TLS verification of the delivery URL (`insecure_ssl`). Only use for |
| 58 | * local/testing endpoints. |
| 59 | * @default false |
| 60 | */ |
| 61 | insecureSsl?: boolean; |
| 62 | |
| 63 | /** |
| 64 | * Override the GitHub host or API base URL for this resource only (e.g. |
| 65 | * `github.example.com` for GitHub Enterprise). Falls back to |
| 66 | * `GitHub.providers({ baseUrl })`, then to the host resolved by the auth |
| 67 | * provider. Changing it replaces the resource — the same name on a |
| 68 | * different GitHub instance is a different physical resource. |
| 69 | */ |
| 70 | baseUrl?: string; |
| 71 | } |
| 72 | |
| 73 | export interface Webhook extends Resource< |
| 74 | "GitHub.Webhook", |
| 75 | WebhookProps, |
| 76 | { |
| 77 | /** |
| 78 | * Numeric ID of the webhook in GitHub. |
| 79 | */ |
| 80 | webhookId: number; |
| 81 | |
| 82 | /** |
| 83 | * The configured delivery URL. |
| 84 | */ |
| 85 | url: string; |
| 86 | |
| 87 | /** |
| 88 | * URL used by GitHub to send a `ping` event. |
| 89 | */ |
| 90 | pingUrl: string | undefined; |
| 91 | |
| 92 | /** |
| 93 | * URL used by GitHub to re-deliver the last event. |
| 94 | */ |
| 95 | testUrl: string | undefined; |
| 96 | |
| 97 | /** |
| 98 | * ISO-8601 timestamp of the last update. |
| 99 | */ |
| 100 | updatedAt: string; |
| 101 | }, |
| 102 | never, |
| 103 | GitHub.Providers |
| 104 | > {} |
| 105 | |
| 106 | /** |
| 107 | * A GitHub repository webhook. |
| 108 | * |
| 109 | * `Webhook` manages the lifecycle of a repository webhook that `POST`s |
| 110 | * events to a delivery URL. It is created on the first deploy and updated |
| 111 | * in place on subsequent deploys, and is deleted when the resource is |
| 112 | * destroyed. |
| 113 | * |
| 114 | * Authentication is resolved via the `GitHubCredentials` service supplied |
| 115 | * by `GitHub.providers()` (env, stored PAT, or `gh` CLI). The token needs |
| 116 | * `repo` scope (admin access to the repository) to manage webhooks. |
| 117 | * |
| 118 | * Most users don't construct `Webhook` directly — prefer |
| 119 | * {@link import("./RepositoryEventSource.ts").events | events(repository, handler)} |
| 120 | * inside a Cloudflare Worker, which provisions the webhook, wires the |
| 121 | * delivery URL to the Worker, and forwards verified events to your handler. |
| 122 | * ### Creating a Webhook |
| 123 | * **Example:** Forward push events to a URL |
| 124 | * ```typescript |
| 125 | * yield* GitHub.Webhook("ci-webhook", { |
| 126 | * owner: "my-org", |
| 127 | * repository: "my-repo", |
| 128 | * url: "https://example.com/github", |
| 129 | * events: ["push", "pull_request"], |
| 130 | * secret: Redacted.make(process.env.WEBHOOK_SECRET!), |
| 131 | * }); |
| 132 | * ``` |
| 133 | * |
| 134 | * **Example:** Point a webhook at a Worker |
| 135 | * ```typescript |
| 136 | * const worker = yield* Cloudflare.Worker("Api", { ... }); |
| 137 | * |
| 138 | * yield* GitHub.Webhook("repo-webhook", { |
| 139 | * owner: "my-org", |
| 140 | * repository: "my-repo", |
| 141 | * url: worker.url!, |
| 142 | * events: ["*"], |
| 143 | * }); |
| 144 | * ``` |
| 145 | * |
| 146 | * @resource |
| 147 | * @product Webhook |
| 148 | */ |
| 149 | export const Webhook = Resource<Webhook>("GitHub.Webhook"); |
| 150 | |
| 151 | export const WebhookProvider = () => |
| 152 | Provider.succeed(Webhook, { |
| 153 | stables: ["webhookId"], |
| 154 | |
| 155 | // A webhook belongs to (host, owner, repository) — its server-assigned |
| 156 | // id is meaningless in any other repo, so moving it replaces the |
| 157 | // resource: the engine creates the new webhook first, then `delete` |
| 158 | // removes the old one from the old repo. Everything else (url, events, |
| 159 | // secret, active) is mutated in place by `reconcile`. Replacement is |
| 160 | // safe here — a webhook is declarative config that is fully |
| 161 | // re-creatable from props. |
| 162 | diff: Effect.fn(function* ({ news, olds }) { |
| 163 | if (!isResolved(news)) return; |
| 164 | if (olds === undefined) return; |
| 165 | if ( |
| 166 | news.owner !== olds.owner || |
| 167 | news.repository !== olds.repository || |
| 168 | (yield* gitHubBaseUrlChanged(olds, news)) |
| 169 | ) { |
| 170 | return { action: "replace" }; |
| 171 | } |
| 172 | }), |
| 173 | |
| 174 | reconcile: Effect.fn(function* ({ news, output }) { |
| 175 | const octokit = yield* octokitFor(news.baseUrl); |
| 176 | |
| 177 | const config = { |
| 178 | url: news.url as string, |
| 179 | content_type: news.contentType ?? "json", |
| 180 | secret: news.secret ? Redacted.value(news.secret) : undefined, |
| 181 | insecure_ssl: news.insecureSsl ? "1" : "0", |
| 182 | }; |
| 183 | const events = news.events ?? ["push"]; |
| 184 | const active = news.active ?? true; |
| 185 | |
| 186 | // Observe — GitHub assigns the hook id server-side. Probe for live |
| 187 | // state via the cached id; a 404 (deleted out-of-band, or never |
| 188 | // created) collapses to "no observed webhook" so we converge by |
| 189 | // creating a fresh one. |
| 190 | const observed = output?.webhookId |
| 191 | ? yield* Effect.tryPromise({ |
| 192 | try: async () => { |
| 193 | try { |
| 194 | const { data } = await octokit.rest.repos.getWebhook({ |
| 195 | owner: news.owner, |
| 196 | repo: news.repository, |
| 197 | hook_id: output.webhookId, |
| 198 | }); |
| 199 | return data; |
| 200 | } catch (error: any) { |
| 201 | if (error.status === 404) return undefined; |
| 202 | throw error; |
| 203 | } |
| 204 | }, |
| 205 | catch: (e) => e as Error, |
| 206 | }) |
| 207 | : undefined; |
| 208 | |
| 209 | // Ensure — POST creates the webhook. |
| 210 | if (observed === undefined) { |
| 211 | const { data } = yield* Effect.tryPromise(() => |
| 212 | octokit.rest.repos.createWebhook({ |
| 213 | owner: news.owner, |
| 214 | repo: news.repository, |
| 215 | name: "web", |
| 216 | active, |
| 217 | events, |
| 218 | config, |
| 219 | }), |
| 220 | ); |
| 221 | return toAttrs(data); |
| 222 | } |
| 223 | |
| 224 | // Sync — PATCH the existing webhook with the desired config. The |
| 225 | // secret can never be read back, so we always send the full config |
| 226 | // to converge rather than diffing. |
| 227 | const { data } = yield* Effect.tryPromise(() => |
| 228 | octokit.rest.repos.updateWebhook({ |
| 229 | owner: news.owner, |
| 230 | repo: news.repository, |
| 231 | hook_id: observed.id, |
| 232 | active, |
| 233 | events, |
| 234 | config, |
| 235 | }), |
| 236 | ); |
| 237 | return toAttrs(data); |
| 238 | }), |
| 239 | |
| 240 | // GitHub has no global webhook list — hooks are repo-scoped. Enumerate every |
| 241 | // repository the authenticated token can see, then list each repo's webhooks. |
| 242 | list: Effect.fn(function* () { |
| 243 | const octokit = yield* Octokit; |
| 244 | |
| 245 | // `octokit.paginate` walks every page and flattens to a single array. |
| 246 | const repos = yield* Effect.tryPromise({ |
| 247 | try: () => |
| 248 | octokit.paginate(octokit.rest.repos.listForAuthenticatedUser, { |
| 249 | per_page: 100, |
| 250 | }), |
| 251 | catch: (e) => e as Error, |
| 252 | }); |
| 253 | |
| 254 | const perRepo = yield* Effect.forEach( |
| 255 | repos, |
| 256 | (repo) => |
| 257 | Effect.tryPromise({ |
| 258 | try: async () => { |
| 259 | try { |
| 260 | const hooks = await octokit.paginate( |
| 261 | octokit.rest.repos.listWebhooks, |
| 262 | { |
| 263 | owner: repo.owner.login, |
| 264 | repo: repo.name, |
| 265 | per_page: 100, |
| 266 | }, |
| 267 | ); |
| 268 | return hooks.map(toAttrs); |
| 269 | } catch (error: any) { |
| 270 | // Repos where the token lacks admin access reject the webhooks |
| 271 | // endpoint with 403/404 — skip them per the per-item not-found |
| 272 | // rule rather than failing the whole enumeration. |
| 273 | if (error.status === 403 || error.status === 404) { |
| 274 | return []; |
| 275 | } |
| 276 | throw error; |
| 277 | } |
| 278 | }, |
| 279 | catch: (e) => e as Error, |
| 280 | }), |
| 281 | { concurrency: 10 }, |
| 282 | ); |
| 283 | |
| 284 | return perRepo.flat(); |
| 285 | }), |
| 286 | |
| 287 | delete: Effect.fn(function* ({ olds, output }) { |
| 288 | const octokit = yield* octokitFor(olds.baseUrl); |
| 289 | |
| 290 | yield* Effect.tryPromise(async () => { |
| 291 | try { |
| 292 | await octokit.rest.repos.deleteWebhook({ |
| 293 | owner: olds.owner, |
| 294 | repo: olds.repository, |
| 295 | hook_id: output.webhookId, |
| 296 | }); |
| 297 | } catch (error: any) { |
| 298 | if (error.status !== 404) { |
| 299 | throw error; |
| 300 | } |
| 301 | } |
| 302 | }); |
| 303 | }), |
| 304 | }); |
| 305 | |
| 306 | const toAttrs = (data: { |
| 307 | id: number; |
| 308 | config?: { url?: string } | null; |
| 309 | ping_url?: string | null; |
| 310 | test_url?: string | null; |
| 311 | updated_at?: string | null; |
| 312 | }): Webhook["Attributes"] => ({ |
| 313 | webhookId: data.id, |
| 314 | url: data.config?.url ?? "", |
| 315 | pingUrl: data.ping_url ?? undefined, |
| 316 | testUrl: data.test_url ?? undefined, |
| 317 | updatedAt: data.updated_at ?? new Date().toISOString(), |
| 318 | }); |