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