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