This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

web/settings: wire repo webhooks to the appview xrpc

Replaces the mocked hooks list and new-webhook form with real list/create/
toggle/delete against org.tangled.temp.repo.*Webhook, plus a shared Skeleton
loading primitive.

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

author
Anirudh Oppiliappan
date (Aug 4, 2026, 5:09 PM +0300) commit 69d176f5 parent fab8cf53 change-id pmnxuvtm
+260 -63
+132
web/src/lib/api/webhooks.ts
··· 1 + import { authedGet, authedPost, type AppviewContext } from "./appview"; 2 + import type { XrpcRequestInit } from "./client"; 3 + 4 + // canonical event names the appview dispatches on (models.WebhookEvent*). the 5 + // values are the wire format; labels are a UI concern. 6 + export const WEBHOOK_EVENTS = [ 7 + { id: "push", label: "Push events" }, 8 + { id: "repository:renamed", label: "Repository renamed" }, 9 + { id: "pull_request:created", label: "Pull request opened" }, 10 + { id: "pull_request:resubmitted", label: "Pull request resubmitted" }, 11 + { id: "pull_request:merged", label: "Pull request merged" }, 12 + { id: "pull_request:closed", label: "Pull request closed" }, 13 + { id: "pull_request:reopened", label: "Pull request reopened" } 14 + ] as const; 15 + 16 + export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number]["id"]; 17 + 18 + // mirrors org.tangled.temp.repo.listWebhooks#webhook 19 + export interface Webhook { 20 + id: number; 21 + url: string; 22 + active: boolean; 23 + events: string[]; 24 + createdAt: string; 25 + updatedAt?: string; 26 + } 27 + 28 + // mirrors org.tangled.temp.repo.listWebhookDeliveries#delivery 29 + export interface WebhookDelivery { 30 + id: number; 31 + deliveryId: string; 32 + event: string; 33 + url: string; 34 + success: boolean; 35 + createdAt: string; 36 + requestBody?: string; 37 + responseBody?: string; 38 + responseCode?: number; 39 + } 40 + 41 + export interface CreateWebhookInput { 42 + url: string; 43 + events: string[]; 44 + secret?: string; 45 + active?: boolean; 46 + } 47 + 48 + export interface UpdateWebhookInput { 49 + url?: string; 50 + events?: string[]; 51 + secret?: string; 52 + active?: boolean; 53 + } 54 + 55 + const LIST = "org.tangled.temp.repo.listWebhooks"; 56 + const CREATE = "org.tangled.temp.repo.createWebhook"; 57 + const UPDATE = "org.tangled.temp.repo.updateWebhook"; 58 + const DELETE = "org.tangled.temp.repo.deleteWebhook"; 59 + const TOGGLE = "org.tangled.temp.repo.toggleWebhook"; 60 + const LIST_DELIVERIES = "org.tangled.temp.repo.listWebhookDeliveries"; 61 + const RETRY_DELIVERY = "org.tangled.temp.repo.retryWebhookDelivery"; 62 + 63 + export const listWebhooks = async ( 64 + ctx: AppviewContext, 65 + repoDid: string, 66 + init?: XrpcRequestInit 67 + ): Promise<Webhook[]> => { 68 + const res = await authedGet<{ webhooks?: Webhook[] }>(ctx, LIST, { repoDid }, init); 69 + return res.webhooks ?? []; 70 + }; 71 + 72 + export const createWebhook = async ( 73 + ctx: AppviewContext, 74 + repoDid: string, 75 + input: CreateWebhookInput, 76 + init?: XrpcRequestInit 77 + ): Promise<number | undefined> => { 78 + const res = await authedPost<{ id: number }>(ctx, CREATE, { repoDid, ...input }, init); 79 + return res?.id; 80 + }; 81 + 82 + export const updateWebhook = ( 83 + ctx: AppviewContext, 84 + repoDid: string, 85 + id: number, 86 + patch: UpdateWebhookInput, 87 + init?: XrpcRequestInit 88 + ): Promise<void> => 89 + authedPost(ctx, UPDATE, { repoDid, id, ...patch }, init).then(() => undefined); 90 + 91 + export const deleteWebhook = ( 92 + ctx: AppviewContext, 93 + repoDid: string, 94 + id: number, 95 + init?: XrpcRequestInit 96 + ): Promise<void> => authedPost(ctx, DELETE, { repoDid, id }, init).then(() => undefined); 97 + 98 + // flips active state server-side; returns the new value. 99 + export const toggleWebhook = async ( 100 + ctx: AppviewContext, 101 + repoDid: string, 102 + id: number, 103 + init?: XrpcRequestInit 104 + ): Promise<boolean | undefined> => { 105 + const res = await authedPost<{ active: boolean }>(ctx, TOGGLE, { repoDid, id }, init); 106 + return res?.active; 107 + }; 108 + 109 + export const listWebhookDeliveries = async ( 110 + ctx: AppviewContext, 111 + repoDid: string, 112 + id: number, 113 + limit?: number, 114 + init?: XrpcRequestInit 115 + ): Promise<WebhookDelivery[]> => { 116 + const res = await authedGet<{ deliveries?: WebhookDelivery[] }>( 117 + ctx, 118 + LIST_DELIVERIES, 119 + { repoDid, id, limit }, 120 + init 121 + ); 122 + return res.deliveries ?? []; 123 + }; 124 + 125 + export const retryWebhookDelivery = ( 126 + ctx: AppviewContext, 127 + repoDid: string, 128 + webhookId: number, 129 + deliveryId: string, 130 + init?: XrpcRequestInit 131 + ): Promise<void> => 132 + authedPost(ctx, RETRY_DELIVERY, { repoDid, webhookId, deliveryId }, init).then(() => undefined);
+85 -40
web/src/routes/[handle]/[repo]/settings/hooks/+page.svelte
··· 1 1 <script lang="ts"> 2 - import { untrack } from "svelte"; 3 2 import { resolve } from "$app/paths"; 4 - import Avatar from "$lib/components/ui/Avatar.svelte"; 3 + import { page } from "$app/state"; 4 + import { getAuth } from "$lib/auth.svelte"; 5 + import { createAppviewClient } from "$lib/api/appview"; 6 + import { listWebhooks, toggleWebhook, deleteWebhook, type Webhook } from "$lib/api/webhooks"; 7 + import { createLoad, createAction } from "$lib/action.svelte"; 5 8 import Button from "$lib/components/ui/Button.svelte"; 6 9 import Toggle from "$lib/components/ui/Toggle.svelte"; 10 + import Skeleton from "$lib/components/ui/Skeleton.svelte"; 11 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 12 + import TimeAgo from "$lib/components/ui/TimeAgo.svelte"; 7 13 import SettingsEmpty from "$lib/components/settings/SettingsEmpty.svelte"; 8 14 import SettingsBlock from "$lib/components/settings/SettingsBlock.svelte"; 9 15 import SettingsList from "$lib/components/settings/SettingsList.svelte"; 10 16 import CodeChip from "$lib/components/settings/CodeChip.svelte"; 11 17 import Plus from "$icon/plus"; 12 18 import Eye from "$icon/eye"; 13 - import Pencil from "$icon/pencil"; 14 19 import Trash from "$icon/trash-2"; 15 - import Inbox from "$icon/inbox"; 16 20 17 21 let { data } = $props(); 18 22 23 + const auth = getAuth(); 24 + const apiUrl = $derived(page.data.publicConfig?.apiUrl as string | undefined); 25 + const repoDid = $derived(data.repo.repoDid); 19 26 const base = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings`); 20 27 const path = (p: string) => resolve(p as "/"); 21 28 22 - // mocked — webhooks are not wired up yet 23 - let hooks = $state([ 24 - { 25 - id: "1", 26 - url: "https://hooks.user.tld/tg/repository-cs-hk", 27 - events: "push, pull_request:created, pull_request:closed", 28 - added: "Added 2 mins ago by", 29 - by: untrack(() => data.repo.ownerHandle), 30 - enabled: true 31 - }, 32 - { 33 - id: "2", 34 - url: "https://hooks.user.tld/tg/repository", 35 - events: "push, pull_request:created, pull_request:closed", 36 - added: "Added 5 hours ago by", 37 - by: untrack(() => data.repo.ownerHandle), 38 - enabled: false 39 - } 40 - ]); 29 + // resolves the authed appview client + repoDid, or throws if either is missing. 30 + const requireClient = () => { 31 + const agent = auth.agent; 32 + const url = apiUrl; 33 + const did = repoDid; 34 + if (!agent || !url || !did) throw new Error("You must own this repository to manage webhooks."); 35 + return { ctx: createAppviewClient({ apiUrl: url, agent }), did }; 36 + }; 37 + 38 + const loaded = createLoad(async (): Promise<Webhook[]> => { 39 + const agent = auth.agent; 40 + const url = apiUrl; 41 + const did = repoDid; 42 + if (!agent || !url || !did) return []; 43 + return listWebhooks(createAppviewClient({ apiUrl: url, agent }), did); 44 + }); 45 + 46 + const toggle = createAction(async (hook: Webhook) => { 47 + const { ctx, did } = requireClient(); 48 + const active = await toggleWebhook(ctx, did, hook.id); 49 + loaded.update((list) => 50 + list.map((h) => (h.id === hook.id ? { ...h, active: active ?? !h.active } : h)) 51 + ); 52 + }); 53 + 54 + const remove = createAction(async (hook: Webhook) => { 55 + const { ctx, did } = requireClient(); 56 + await deleteWebhook(ctx, did, hook.id); 57 + loaded.update((list) => list.filter((h) => h.id !== hook.id)); 58 + }); 41 59 42 - const remove = (id: string) => { 43 - hooks = hooks.filter((h) => h.id !== id); 60 + const confirmRemove = (hook: Webhook) => { 61 + if (remove.loading) return; 62 + if (!confirm(`Delete the webhook for ${hook.url}?`)) return; 63 + void remove.run(hook); 44 64 }; 65 + 66 + const actionError = $derived(toggle.error ?? remove.error); 45 67 </script> 46 68 47 69 <div class="flex min-h-8 w-full items-center gap-4"> ··· 58 80 <Button icon={Plus} href={path(`${base}/hooks/new`)}>New webhook</Button> 59 81 {/snippet} 60 82 61 - {#if hooks.length === 0} 83 + {#if loaded.error} 84 + <ErrorAlert label={loaded.error} /> 85 + {/if} 86 + {#if actionError} 87 + <ErrorAlert label={actionError} /> 88 + {/if} 89 + 90 + {#if loaded.loading} 91 + <SettingsList> 92 + {#each Array.from({ length: 2 }) as _, i (i)} 93 + <div class="flex w-full flex-col gap-2"> 94 + <Skeleton class="h-5 w-72 max-w-full rounded" /> 95 + <Skeleton class="h-4 w-56 max-w-full rounded" /> 96 + <Skeleton class="h-4 w-40 max-w-full rounded" /> 97 + </div> 98 + {/each} 99 + </SettingsList> 100 + {:else if (loaded.data?.length ?? 0) === 0} 62 101 <SettingsEmpty message="No webhooks yet" /> 63 - {:else} 102 + {:else if loaded.data} 64 103 <SettingsList> 65 - {#each hooks as hook (hook.id)} 104 + {#each loaded.data as hook (hook.id)} 66 105 <!-- the toggle rides the top-right corner while the actions sit under it, 67 106 so this row is a two-column block rather than a SettingsRow. On a 68 107 phone the two columns stack and the actions spread across the width. --> ··· 73 112 class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" 74 113 > 75 114 <Eye class="size-3.5 shrink-0" aria-hidden="true" /> 76 - {hook.events} 115 + {hook.events.join(", ")} 77 116 </span> 78 - <span 79 - class="flex items-center gap-1.5 typography-paragraph-regular text-foreground-muted" 80 - > 81 - {hook.added} 82 - <Avatar handle={hook.by} size="small" /> 83 - {hook.by} 117 + <span class="typography-paragraph-regular text-foreground-muted"> 118 + Added <TimeAgo value={hook.createdAt} variant="full" /> 84 119 </span> 85 120 </div> 86 121 <div class="flex flex-col items-end gap-4 sm:shrink-0"> 87 - <Toggle bind:checked={hook.enabled} aria-label="Enable {hook.url}" /> 88 - <div class="flex w-full items-center justify-between gap-2 sm:w-auto sm:justify-end"> 89 - <Button icon={Inbox}>Deliveries</Button> 90 - <Button icon={Pencil}>Edit</Button> 91 - <Button variant="danger" icon={Trash} onclick={() => remove(hook.id)}>Delete</Button> 122 + <Toggle 123 + checked={hook.active} 124 + disabled={toggle.loading} 125 + onchange={() => toggle.run(hook)} 126 + aria-label="Enable {hook.url}" 127 + /> 128 + <div class="flex w-full items-center justify-end gap-2 sm:w-auto"> 129 + <Button 130 + variant="danger" 131 + icon={Trash} 132 + loading={remove.loading && remove.args?.[0].id === hook.id} 133 + onclick={() => confirmRemove(hook)} 134 + > 135 + Delete 136 + </Button> 92 137 </div> 93 138 </div> 94 139 </div>
+43 -23
web/src/routes/[handle]/[repo]/settings/hooks/new/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { goto } from "$app/navigation"; 3 3 import { resolve } from "$app/paths"; 4 + import { page } from "$app/state"; 5 + import { getAuth } from "$lib/auth.svelte"; 6 + import { createAppviewClient } from "$lib/api/appview"; 7 + import { createWebhook, WEBHOOK_EVENTS } from "$lib/api/webhooks"; 8 + import { createAction } from "$lib/action.svelte"; 4 9 import Button from "$lib/components/ui/Button.svelte"; 5 10 import Input from "$lib/components/ui/Input.svelte"; 6 11 import Checkbox from "$lib/components/ui/Checkbox.svelte"; 12 + import ErrorAlert from "$lib/components/ui/Error.svelte"; 7 13 import DrillDown from "$lib/components/settings/DrillDown.svelte"; 8 14 import SettingsList from "$lib/components/settings/SettingsList.svelte"; 9 15 import FormRow from "$lib/components/settings/FormRow.svelte"; ··· 13 19 14 20 let { data } = $props(); 15 21 22 + const auth = getAuth(); 23 + const apiUrl = $derived(page.data.publicConfig?.apiUrl as string | undefined); 24 + const repoDid = $derived(data.repo.repoDid); 16 25 const back = $derived(`/${data.repo.ownerHandle}/${data.repo.name}/settings/hooks`); 17 26 18 27 let url = $state(""); 19 28 let secret = $state(""); 20 - // mocked — webhooks are not wired up yet. Push is preselected, as in the design. 21 - let events = $state<Record<string, boolean>>({ 22 - push: true, 23 - renamed: false, 24 - opened: false, 25 - resubmitted: false, 26 - merged: false, 27 - closed: false, 28 - reopened: false 29 - }); 29 + // canonical event ids -> selected; push is preselected, as in the design. 30 + let events = $state<Record<string, boolean>>( 31 + Object.fromEntries(WEBHOOK_EVENTS.map((e) => [e.id, e.id === "push"])) 32 + ); 30 33 31 - const EVENTS: { id: string; label: string }[] = [ 32 - { id: "push", label: "Push events" }, 33 - { id: "renamed", label: "Repository renamed" }, 34 - { id: "opened", label: "Pull request opened" }, 35 - { id: "resubmitted", label: "Pull request resubmitted" }, 36 - { id: "merged", label: "Pull request merged" }, 37 - { id: "closed", label: "Pull request closed" }, 38 - { id: "reopened", label: "Pull request reopened" } 39 - ]; 34 + const selected = $derived(WEBHOOK_EVENTS.filter((e) => events[e.id]).map((e) => e.id)); 35 + const valid = $derived(url.trim().length > 0 && selected.length > 0); 40 36 41 - const valid = $derived(url.trim().length > 0 && Object.values(events).some(Boolean)); 42 - const submit = () => goto(resolve(back as "/")); 37 + const create = createAction(async () => { 38 + const agent = auth.agent; 39 + const url_ = apiUrl; 40 + const did = repoDid; 41 + if (!agent || !url_ || !did) throw new Error("You must own this repository to add a webhook."); 42 + const ctx = createAppviewClient({ apiUrl: url_, agent }); 43 + const trimmedSecret = secret.trim(); 44 + await createWebhook(ctx, did, { 45 + url: url.trim(), 46 + events: selected, 47 + secret: trimmedSecret ? trimmedSecret : undefined 48 + }); 49 + await goto(resolve(back as "/")); 50 + }); 43 51 </script> 44 52 45 53 <DrillDown backLabel="Hooks" backHref={back} title="New webhook"> 54 + {#if create.error} 55 + <ErrorAlert label={create.error} /> 56 + {/if} 57 + 46 58 <SettingsList padding="tight"> 47 59 <FormRow 48 60 label="Payload URL" ··· 67 79 align="start" 68 80 > 69 81 <div class="flex flex-col gap-1"> 70 - {#each EVENTS as event (event.id)} 82 + {#each WEBHOOK_EVENTS as event (event.id)} 71 83 <Checkbox bind:checked={events[event.id]}>{event.label}</Checkbox> 72 84 {/each} 73 85 </div> ··· 76 88 77 89 <FormActions> 78 90 <Button href={resolve(back as "/")} icon={X}>Cancel</Button> 79 - <Button variant="primary" icon={Plus} disabled={!valid} onclick={submit}>Add</Button> 91 + <Button 92 + variant="primary" 93 + icon={Plus} 94 + loading={create.loading} 95 + disabled={!valid} 96 + onclick={create.run} 97 + > 98 + Add 99 + </Button> 80 100 </FormActions> 81 101 </DrillDown>