diff --git a/web/src/lib/api/webhooks.ts b/web/src/lib/api/webhooks.ts new file mode 100644 index 000000000..d8dd1f9e0 --- /dev/null +++ b/web/src/lib/api/webhooks.ts @@ -0,0 +1,132 @@ +import { authedGet, authedPost, type AppviewContext } from "./appview"; +import type { XrpcRequestInit } from "./client"; + +// canonical event names the appview dispatches on (models.WebhookEvent*). the +// values are the wire format; labels are a UI concern. +export const WEBHOOK_EVENTS = [ + { id: "push", label: "Push events" }, + { id: "repository:renamed", label: "Repository renamed" }, + { id: "pull_request:created", label: "Pull request opened" }, + { id: "pull_request:resubmitted", label: "Pull request resubmitted" }, + { id: "pull_request:merged", label: "Pull request merged" }, + { id: "pull_request:closed", label: "Pull request closed" }, + { id: "pull_request:reopened", label: "Pull request reopened" } +] as const; + +export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]["id"]; + +// mirrors org.tangled.temp.repo.listWebhooks#webhook +export interface Webhook { + id: number; + url: string; + active: boolean; + events: string[]; + createdAt: string; + updatedAt?: string; +} + +// mirrors org.tangled.temp.repo.listWebhookDeliveries#delivery +export interface WebhookDelivery { + id: number; + deliveryId: string; + event: string; + url: string; + success: boolean; + createdAt: string; + requestBody?: string; + responseBody?: string; + responseCode?: number; +} + +export interface CreateWebhookInput { + url: string; + events: string[]; + secret?: string; + active?: boolean; +} + +export interface UpdateWebhookInput { + url?: string; + events?: string[]; + secret?: string; + active?: boolean; +} + +const LIST = "org.tangled.temp.repo.listWebhooks"; +const CREATE = "org.tangled.temp.repo.createWebhook"; +const UPDATE = "org.tangled.temp.repo.updateWebhook"; +const DELETE = "org.tangled.temp.repo.deleteWebhook"; +const TOGGLE = "org.tangled.temp.repo.toggleWebhook"; +const LIST_DELIVERIES = "org.tangled.temp.repo.listWebhookDeliveries"; +const RETRY_DELIVERY = "org.tangled.temp.repo.retryWebhookDelivery"; + +export const listWebhooks = async ( + ctx: AppviewContext, + repoDid: string, + init?: XrpcRequestInit +): Promise => { + const res = await authedGet<{ webhooks?: Webhook[] }>(ctx, LIST, { repoDid }, init); + return res.webhooks ?? []; +}; + +export const createWebhook = async ( + ctx: AppviewContext, + repoDid: string, + input: CreateWebhookInput, + init?: XrpcRequestInit +): Promise => { + const res = await authedPost<{ id: number }>(ctx, CREATE, { repoDid, ...input }, init); + return res?.id; +}; + +export const updateWebhook = ( + ctx: AppviewContext, + repoDid: string, + id: number, + patch: UpdateWebhookInput, + init?: XrpcRequestInit +): Promise => + authedPost(ctx, UPDATE, { repoDid, id, ...patch }, init).then(() => undefined); + +export const deleteWebhook = ( + ctx: AppviewContext, + repoDid: string, + id: number, + init?: XrpcRequestInit +): Promise => authedPost(ctx, DELETE, { repoDid, id }, init).then(() => undefined); + +// flips active state server-side; returns the new value. +export const toggleWebhook = async ( + ctx: AppviewContext, + repoDid: string, + id: number, + init?: XrpcRequestInit +): Promise => { + const res = await authedPost<{ active: boolean }>(ctx, TOGGLE, { repoDid, id }, init); + return res?.active; +}; + +export const listWebhookDeliveries = async ( + ctx: AppviewContext, + repoDid: string, + id: number, + limit?: number, + init?: XrpcRequestInit +): Promise => { + const res = await authedGet<{ deliveries?: WebhookDelivery[] }>( + ctx, + LIST_DELIVERIES, + { repoDid, id, limit }, + init + ); + return res.deliveries ?? []; +}; + +export const retryWebhookDelivery = ( + ctx: AppviewContext, + repoDid: string, + webhookId: number, + deliveryId: string, + init?: XrpcRequestInit +): Promise => + authedPost(ctx, RETRY_DELIVERY, { repoDid, webhookId, deliveryId }, init).then(() => undefined); diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte b/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte index 963691b45..e74f234b5 100644 --- a/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte @@ -1,47 +1,69 @@
@@ -58,11 +80,28 @@ {/snippet} - {#if hooks.length === 0} + {#if loaded.error} + + {/if} + {#if actionError} + + {/if} + + {#if loaded.loading} + + {#each Array.from({ length: 2 }) as _, i (i)} +
+ + + +
+ {/each} +
+ {:else if (loaded.data?.length ?? 0) === 0} - {:else} + {:else if loaded.data} - {#each hooks as hook (hook.id)} + {#each loaded.data as hook (hook.id)} @@ -73,22 +112,28 @@ class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" >
- -
- - - + toggle.run(hook)} + aria-label="Enable {hook.url}" + /> +
+
diff --git a/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte b/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte index 3e95e1476..b10e9d0ef 100644 --- a/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte @@ -1,9 +1,15 @@ + {#if create.error} + + {/if} +
- {#each EVENTS as event (event.id)} + {#each WEBHOOK_EVENTS as event (event.id)} {event.label} {/each}
@@ -76,6 +88,14 @@ - +