Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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