g1t/apps/web/app/components/webhooks.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Webhooks: every event, to your own addresses, signed and retried | 1 | /** |
| 2 | * A repository's or a workspace's webhooks: the addresses events are sent | |
| 3 | * to, adding one, and every delivery with what was sent and what came back. | |
| 4 | */ | |
| The webhooks empty state is a sentence, without an icon squeezed to a dot on phones | 5 | import { ChevronRight, Pause, Play, RotateCw, Send, Trash2 } from "lucide-react"; |
| Webhooks: every event, to your own addresses, signed and retried | 6 | import { useState } from "react"; |
| Merge branch 'worktree-agent-a985c01f43538af01' | 7 | import { Form, Link } from "react-router"; |
| Webhooks: every event, to your own addresses, signed and retried | 8 | |
| 9 | import { EVENT_TYPES, type Hook, type HookDelivery } from "@g1t/contracts"; | |
| 10 | ||
| 11 | import type { WebhooksAction, WebhooksData } from "../lib/webhooks.server"; | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 12 | import { CopyLine, EmptyState, ErrorText, Field, Input, SubmitButton, TimeAgo } from "./ui"; |
| Workspace names and icons, and a component kit for every control | 13 | import { CheckboxOption } from "./ui/checkbox"; |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 14 | import { Hint } from "./ui/hint"; |
| Workspace names and icons, and a component kit for every control | 15 | import { RadioGroup, RadioOption } from "./ui/radio-group"; |
| Webhooks: every event, to your own addresses, signed and retried | 16 | |
| 17 | /** The events, in groups people recognise. */ | |
| 18 | const GROUPS: { title: string; events: string[] }[] = [ | |
| 19 | { title: "Code", events: ["git.push", "repo.created", "repo.forked"] }, | |
| 20 | { | |
| 21 | title: "Issues", | |
| 22 | events: ["issue.opened", "issue.updated", "issue.assigned", "issue.closed", "issue.reopened", "comment.created"], | |
| 23 | }, | |
| 24 | { | |
| 25 | title: "Pull requests", | |
| 26 | events: ["pull.opened", "pull.ready", "pull.updated", "pull.merge_requested", "pull.merged", "pull.closed"], | |
| 27 | }, | |
| Actions: workflow_run, workflow.completed, artifacts on the run page, Node 24 | 28 | { title: "Checks, reviews and the queue", events: ["checks.completed", "workflow.completed", "review.completed", "queue.changed"] }, |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 29 | { |
| Merge checks: statuses and check runs on every commit | 30 | title: "Statuses and check runs", |
| 31 | events: [ | |
| 32 | "status.created", | |
| 33 | "check_run.created", | |
| 34 | "check_run.completed", | |
| 35 | "check_run.rerequested", | |
| 36 | "check_run.requested_action", | |
| 37 | "check_suite.completed", | |
| 38 | "check_suite.rerequested", | |
| 39 | ], | |
| 40 | }, | |
| 41 | { | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 42 | title: "Deployments", |
| 43 | events: ["deployment.created", "deployment_status.created", "deployment.succeeded", "deployment.failed"], | |
| 44 | }, | |
| Agents asked while not at work are woken to answer | 45 | { title: "Agents", events: ["session.appended", "agent.asked"] }, |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 46 | { title: "Access", events: ["repo.collaborator_added", "repo.collaborator_removed", "repo.collaborator_role_changed"] }, |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 47 | { |
| 48 | title: "Teams", | |
| 49 | events: [ | |
| 50 | "team.created", | |
| 51 | "team.edited", | |
| 52 | "team.deleted", | |
| 53 | "team.member_added", | |
| 54 | "team.member_role_changed", | |
| 55 | "team.member_removed", | |
| 56 | "team.repo_added", | |
| 57 | "team.repo_role_changed", | |
| 58 | "team.repo_removed", | |
| 59 | ], | |
| 60 | }, | |
| Webhooks: every event, to your own addresses, signed and retried | 61 | ]; |
| 62 | ||
| 63 | function StatusDot({ status }: { status: string | null }) { | |
| 64 | const color = | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 65 | status === "delivered" ? "bg-success" : status === "failed" ? "bg-danger" : status === "pending" ? "bg-warn" : "bg-faint"; |
| Webhooks: every event, to your own addresses, signed and retried | 66 | return <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${color}`} />; |
| 67 | } | |
| 68 | ||
| 69 | function describeEvents(events: string[]): string { | |
| 70 | if (events.includes("*")) return "Every event"; | |
| 71 | return events.length === 1 ? events[0] : `${events.length} events`; | |
| 72 | } | |
| 73 | ||
| 74 | /** JSON, indented for reading, or the text as it is. */ | |
| 75 | function pretty(text: string | null): string { | |
| 76 | if (!text) return ""; | |
| 77 | try { | |
| 78 | return JSON.stringify(JSON.parse(text), null, 2); | |
| 79 | } catch { | |
| 80 | return text; | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | function DeliveryRow({ delivery, manage }: { delivery: HookDelivery; manage: boolean }) { | |
| 85 | return ( | |
| 86 | <details className="group border-t border-line first:border-t-0"> | |
| 87 | <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40"> | |
| 88 | <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" /> | |
| 89 | <StatusDot status={delivery.status} /> | |
| 90 | <span className="font-mono text-[0.8125rem]">{delivery.event}</span> | |
| 91 | <span className="min-w-0 truncate text-xs text-muted"> | |
| 92 | {delivery.responseStatus != null | |
| 93 | ? `${delivery.responseStatus}` | |
| 94 | : delivery.error | |
| 95 | ? delivery.error | |
| 96 | : "Not yet sent"} | |
| 97 | {delivery.attempts > 1 && ` · ${delivery.attempts} attempts`} | |
| 98 | {delivery.status === "pending" && delivery.nextAttemptAt && " · will try again"} | |
| 99 | </span> | |
| 100 | <span className="ml-auto shrink-0 font-mono text-xs text-faint"> | |
| 101 | {delivery.durationMs != null && `${delivery.durationMs}ms · `} | |
| 102 | <TimeAgo at={delivery.createdAt} /> | |
| 103 | </span> | |
| 104 | </summary> | |
| 105 | <div className="grid gap-4 border-t border-line bg-bg/40 p-4 lg:grid-cols-2"> | |
| 106 | <div className="min-w-0"> | |
| 107 | <p className="mb-1.5 text-xs font-medium text-muted">Request</p> | |
| 108 | <pre className="max-h-80 overflow-auto rounded-lg bg-surface p-3 font-mono text-xs ring-1 ring-line"> | |
| 109 | <code>{pretty(delivery.payload)}</code> | |
| 110 | </pre> | |
| 111 | </div> | |
| 112 | <div className="min-w-0"> | |
| 113 | <p className="mb-1.5 text-xs font-medium text-muted"> | |
| 114 | Response{delivery.responseStatus != null && ` · ${delivery.responseStatus}`} | |
| 115 | </p> | |
| 116 | <pre className="max-h-80 overflow-auto rounded-lg bg-surface p-3 font-mono text-xs ring-1 ring-line"> | |
| 117 | <code>{delivery.error ?? (pretty(delivery.responseBody) || "No body.")}</code> | |
| 118 | </pre> | |
| 119 | {manage && ( | |
| 120 | <Form method="post" className="mt-3"> | |
| 121 | <input type="hidden" name="intent" value="redeliver" /> | |
| 122 | <input type="hidden" name="delivery" value={delivery.id} /> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 123 | <SubmitButton |
| 124 | variant="quiet" | |
| 125 | match={{ intent: "redeliver", delivery: delivery.id }} | |
| 126 | pending="Redelivering…" | |
| 127 | > | |
| Webhooks: every event, to your own addresses, signed and retried | 128 | <RotateCw size={14} /> |
| 129 | Redeliver | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 130 | </SubmitButton> |
| Webhooks: every event, to your own addresses, signed and retried | 131 | </Form> |
| 132 | )} | |
| 133 | </div> | |
| 134 | </div> | |
| 135 | </details> | |
| 136 | ); | |
| 137 | } | |
| 138 | ||
| 139 | function HookRow({ hook, open, deliveries, manage }: { hook: Hook; open: boolean; deliveries: HookDelivery[]; manage: boolean }) { | |
| 140 | return ( | |
| 141 | <li className="border-t border-line first:border-t-0"> | |
| 142 | <div className="flex items-center gap-3 px-4 py-3"> | |
| 143 | <StatusDot status={hook.active ? hook.lastStatus : null} /> | |
| 144 | <div className="min-w-0 grow"> | |
| 145 | <p className="truncate font-mono text-[0.8125rem]">{hook.url}</p> | |
| 146 | <p className="truncate text-xs text-muted"> | |
| 147 | {describeEvents(hook.events)} | |
| 148 | {!hook.active && " · paused"} | |
| 149 | {hook.lastDeliveredAt && ( | |
| 150 | <> | |
| 151 | {" · last sent "} | |
| 152 | <TimeAgo at={hook.lastDeliveredAt} /> | |
| 153 | </> | |
| 154 | )} | |
| 155 | {` · secret ${hook.secretHint}`} | |
| 156 | </p> | |
| 157 | </div> | |
| 158 | <Link | |
| 159 | to={open ? "?" : `?hook=${hook.id}`} | |
| 160 | preventScrollReset | |
| 161 | className="shrink-0 rounded-md px-2.5 py-1.5 text-xs text-muted transition-colors hover:bg-raised hover:text-fg" | |
| 162 | > | |
| 163 | {open ? "Hide deliveries" : "Deliveries"} | |
| 164 | </Link> | |
| 165 | {manage && ( | |
| 166 | <Form method="post" className="flex shrink-0 gap-1"> | |
| 167 | <input type="hidden" name="id" value={hook.id} /> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 168 | <IconButton intent="ping" id={hook.id} label="Send a ping"> |
| Webhooks: every event, to your own addresses, signed and retried | 169 | <Send size={14} /> |
| 170 | </IconButton> | |
| 171 | <input type="hidden" name="active" value={hook.active ? "false" : "true"} /> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 172 | <IconButton intent="toggle" id={hook.id} label={hook.active ? "Pause" : "Resume"}> |
| Webhooks: every event, to your own addresses, signed and retried | 173 | {hook.active ? <Pause size={14} /> : <Play size={14} />} |
| 174 | </IconButton> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 175 | <IconButton intent="delete" id={hook.id} label="Delete"> |
| Webhooks: every event, to your own addresses, signed and retried | 176 | <Trash2 size={14} /> |
| 177 | </IconButton> | |
| 178 | </Form> | |
| 179 | )} | |
| 180 | </div> | |
| 181 | {open && ( | |
| 182 | <div className="mx-4 mb-4 overflow-hidden rounded-lg border border-line"> | |
| 183 | {deliveries.length === 0 ? ( | |
| 184 | <p className="px-4 py-3 text-sm text-muted">Nothing sent yet.</p> | |
| 185 | ) : ( | |
| 186 | deliveries.map((delivery) => <DeliveryRow key={delivery.id} delivery={delivery} manage={manage} />) | |
| 187 | )} | |
| 188 | </div> | |
| 189 | )} | |
| 190 | </li> | |
| 191 | ); | |
| 192 | } | |
| 193 | ||
| Merge branch 'worktree-agent-a985c01f43538af01' | 194 | /** One webhook's action, as an icon: a spinner in its place while that action goes. */ |
| 195 | function IconButton({ intent, id, label, children }: { intent: string; id: string; label: string; children: React.ReactNode }) { | |
| Webhooks: every event, to your own addresses, signed and retried | 196 | return ( |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 197 | <Hint label={label}> |
| 198 | <SubmitButton | |
| 199 | icon | |
| 200 | name="intent" | |
| 201 | value={intent} | |
| 202 | match={{ id }} | |
| 203 | aria-label={label} | |
| 204 | className="rounded-md p-2 text-muted transition-colors hover:bg-raised hover:text-fg disabled:opacity-50" | |
| 205 | > | |
| 206 | {children} | |
| 207 | </SubmitButton> | |
| 208 | </Hint> | |
| Webhooks: every event, to your own addresses, signed and retried | 209 | ); |
| 210 | } | |
| 211 | ||
| 212 | function AddWebhook() { | |
| 213 | const [which, setWhich] = useState<"all" | "some">("all"); | |
| 214 | return ( | |
| 215 | <Form method="post" className="space-y-4 rounded-xl border border-line bg-surface p-5"> | |
| 216 | <input type="hidden" name="intent" value="create" /> | |
| 217 | <p className="font-medium">Add a webhook</p> | |
| 218 | <Field label="Payload URL" hint="An HTTPS address on the public internet. g1t sends it a ping as soon as you add it."> | |
| 219 | <Input name="url" type="url" required placeholder="https://example.com/g1t/events" /> | |
| 220 | </Field> | |
| 221 | <fieldset> | |
| 222 | <legend className="mb-1.5 text-sm font-medium text-muted">Which events</legend> | |
| Workspace names and icons, and a component kit for every control | 223 | <RadioGroup |
| 224 | name="which" | |
| 225 | value={which} | |
| 226 | onValueChange={(value) => setWhich(value as typeof which)} | |
| 227 | aria-label="Which events" | |
| 228 | className="flex flex-wrap gap-x-6 gap-y-2" | |
| 229 | > | |
| 230 | <RadioOption value="all" label="Everything, including events added later" /> | |
| 231 | <RadioOption value="some" label="Let me choose" /> | |
| 232 | </RadioGroup> | |
| Webhooks: every event, to your own addresses, signed and retried | 233 | {which === "some" && ( |
| 234 | <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> | |
| 235 | {GROUPS.map((group) => ( | |
| 236 | <div key={group.title}> | |
| 237 | <p className="mb-1.5 text-xs font-medium text-faint">{group.title}</p> | |
| 238 | <div className="space-y-1"> | |
| 239 | {group.events | |
| 240 | .filter((event) => (EVENT_TYPES as readonly string[]).includes(event)) | |
| 241 | .map((event) => ( | |
| Workspace names and icons, and a component kit for every control | 242 | <CheckboxOption |
| 243 | key={event} | |
| 244 | name="event" | |
| 245 | value={event} | |
| 246 | label={event} | |
| 247 | className="items-center" | |
| 248 | labelClassName="font-mono text-[0.8125rem]" | |
| 249 | /> | |
| Webhooks: every event, to your own addresses, signed and retried | 250 | ))} |
| 251 | </div> | |
| 252 | </div> | |
| 253 | ))} | |
| 254 | </div> | |
| 255 | )} | |
| 256 | </fieldset> | |
| 257 | <Field label="Secret" hint="Optional. Deliveries are signed with it. Leave it empty and g1t makes one, shown once."> | |
| 258 | <Input name="secret" type="password" autoComplete="off" /> | |
| 259 | </Field> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 260 | <SubmitButton match={{ intent: "create" }} pending="Adding…"> |
| 261 | Add webhook | |
| 262 | </SubmitButton> | |
| Webhooks: every event, to your own addresses, signed and retried | 263 | </Form> |
| 264 | ); | |
| 265 | } | |
| 266 | ||
| 267 | export function WebhooksPanel({ | |
| 268 | data, | |
| 269 | action, | |
| 270 | manage, | |
| 271 | scope, | |
| 272 | }: { | |
| 273 | data: WebhooksData; | |
| 274 | action: WebhooksAction | undefined; | |
| 275 | manage: boolean; | |
| 276 | /** Says whose events these are. */ | |
| 277 | scope: string; | |
| 278 | }) { | |
| 279 | const created = action?.created; | |
| 280 | return ( | |
| 281 | <div className="max-w-4xl space-y-6"> | |
| 282 | {created && ( | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 283 | <div className="space-y-3 rounded-xl border border-success-dim/60 bg-success/5 p-5 text-sm"> |
| Webhooks: every event, to your own addresses, signed and retried | 284 | <p className="font-medium">Added. g1t sent it a ping.</p> |
| 285 | {created.secret ? ( | |
| 286 | <> | |
| 287 | <p className="text-muted"> | |
| 288 | Every delivery is signed with this secret in <code>X-G1t-Signature-256</code>. It is shown this once. | |
| 289 | </p> | |
| 290 | <CopyLine text={created.secret} /> | |
| 291 | </> | |
| 292 | ) : ( | |
| 293 | <p className="text-muted">Deliveries are signed with the secret you gave.</p> | |
| 294 | )} | |
| 295 | </div> | |
| 296 | )} | |
| 297 | {action?.sent && ( | |
| 298 | <p className="text-sm text-muted"> | |
| 299 | Sent {action.sent.event}:{" "} | |
| 300 | {action.sent.status === "delivered" ? ( | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 301 | <span className="text-success">delivered, {action.sent.responseStatus}</span> |
| Webhooks: every event, to your own addresses, signed and retried | 302 | ) : ( |
| 303 | <span className="text-danger">{action.sent.error ?? `answered ${action.sent.responseStatus}`}</span> | |
| 304 | )} | |
| 305 | . | |
| 306 | </p> | |
| 307 | )} | |
| 308 | <ErrorText>{action?.error}</ErrorText> | |
| 309 | ||
| 310 | {data.hooks.length === 0 ? ( | |
| 311 | <EmptyState title="No webhooks yet"> | |
| The webhooks empty state is a sentence, without an icon squeezed to a dot on phones | 312 | Add one and {scope} sends its events to your address as they happen, signed, and retried until it answers. |
| Webhooks: every event, to your own addresses, signed and retried | 313 | </EmptyState> |
| 314 | ) : ( | |
| 315 | <ul className="overflow-hidden rounded-xl border border-line bg-surface"> | |
| 316 | {data.hooks.map((hook) => ( | |
| 317 | <HookRow | |
| 318 | key={hook.id} | |
| 319 | hook={hook} | |
| 320 | open={data.open === hook.id} | |
| 321 | deliveries={data.open === hook.id ? data.deliveries : []} | |
| 322 | manage={manage} | |
| 323 | /> | |
| 324 | ))} | |
| 325 | </ul> | |
| 326 | )} | |
| 327 | ||
| Merge branch 'worktree-agent-a985c01f43538af01' | 328 | {/* Keyed to the webhook just added, so the form starts empty for the next. */} |
| 329 | {manage && <AddWebhook key={created?.hook.id ?? "new"} />} | |
| Webhooks: every event, to your own addresses, signed and retried | 330 | <p className="text-xs text-faint"> |
| 331 | Each delivery is an HTTPS POST of JSON with <code>X-G1t-Event</code>, <code>X-G1t-Delivery</code> and{" "} | |
| 332 | <code>X-G1t-Signature-256</code>. One that is not answered with a 2xx is tried again after 1 minute, 5 minutes, | |
| 333 | 30 minutes, 2 hours and 5 hours.{" "} | |
| 334 | <a href="https://docs.g1t.sh/guides/webhooks/" className="text-muted underline underline-offset-4"> | |
| 335 | How to check the signature | |
| 336 | </a> | |
| 337 | . | |
| 338 | </p> | |
| 339 | </div> | |
| 340 | ); | |
| 341 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.