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.

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