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