Skip to content

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

341 lines13,304 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"] },
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9729 {
Merge checks: statuses and check runs on every commit30 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-a69aeabc4b0deeb9742 title: "Deployments",
43 events: ["deployment.created", "deployment_status.created", "deployment.succeeded", "deployment.failed"],
44 },
Agents asked while not at work are woken to answer45 { title: "Agents", events: ["session.appended", "agent.asked"] },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look46 { 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 bar47 {
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 retried61];
62
63function 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 retried66 return <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${color}`} />;
67}
68
69function 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. */
75function 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
84function 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 retried128 <RotateCw size={14} />
129 Redeliver
Merge branch 'worktree-agent-a985c01f43538af01'130 </SubmitButton>
Webhooks: every event, to your own addresses, signed and retried131 </Form>
132 )}
133 </div>
134 </div>
135 </details>
136 );
137}
138
139function 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 retried169 <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 retried173 {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 retried176 <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. */
195function IconButton({ intent, id, label, children }: { intent: string; id: string; label: string; children: React.ReactNode }) {
Webhooks: every event, to your own addresses, signed and retried196 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 retried209 );
210}
211
212function 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 control223 <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 retried233 {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 control242 <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 retried250 ))}
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 retried263 </Form>
264 );
265}
266
267export 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 retried284 <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 retried302 ) : (
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 phones312 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 retried313 </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 retried330 <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.