1import * as Effect from "effect/Effect";
2import * as Redacted from "effect/Redacted";
3import type { Input } from "../Input.ts";
4import { isResolved } from "../Diff.ts";
5import * as Provider from "../Provider.ts";
6import { Resource } from "../Resource.ts";
7import { gitHubBaseUrlChanged, Octokit, octokitFor } from "./Octokit.ts";
8import type * as GitHub from "./Providers.ts";
9import type { WebhookEventName } from "./RepositoryEventSource.ts";
10
11export 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
73export 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 */
149export const Webhook = Resource<Webhook>("GitHub.Webhook");
150
151export 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
306const 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});