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.

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