Skip to content

g1t/apps/web/app/components/webhooks.tsx

325 lines12,880 bytesCodeBlame

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

This file's history is long; its oldest lines are credited to the oldest commit read.