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.

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