Skip to content

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

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

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