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
392 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all1/**
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";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.13import { Card } from "./ui/card";
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all14import { CheckboxOption } from "./ui/checkbox";
Merge branch 'worktree-agent-ae1299e92e4462012'15import { Hint } from "./ui/hint";
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all16import { 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",
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts23 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 ],
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all33 },
34 {
35 title: "Pull requests",
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts36 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 ],
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all58 },
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9759 {
Merge branch 'worktree-agent-ab8033ab973f8d99a'60 title: "Checks and the queue",
61 events: ["checks.completed", "workflow.completed", "queue.changed", "integration.failed", "integration.passed"],
62 },
63 {
Merge checks: statuses and check runs on every commit64 title: "Statuses and check runs",
65 events: [
66 "status.created",
67 "check_run.created",
68 "check_run.completed",
69 "check_run.rerequested",
70 "check_run.requested_action",
71 "check_suite.completed",
72 "check_suite.rerequested",
73 ],
74 },
75 {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9776 title: "Deployments",
77 events: ["deployment.created", "deployment_status.created", "deployment.succeeded", "deployment.failed"],
78 },
Merge branch 'worktree-agent-a6ec0a89d7fe7a9ae'79 { title: "Sessions and decisions", events: ["session.started", "session.ended", "session.appended", "decision.recorded"] },
80 {
81 title: "Coordination",
82 events: [
83 "work.claimed",
84 "work.released",
85 "work.claim_expired",
86 "work.handoff_requested",
87 "dependency.changed",
88 "overlap.detected",
89 ],
90 },
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.91 { title: "Access", events: ["workspace.member_joined", "repo.collaborator_added", "repo.collaborator_removed", "repo.collaborator_role_changed"] },
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar92 {
93 title: "Teams",
94 events: [
95 "team.created",
96 "team.edited",
97 "team.deleted",
98 "team.member_added",
99 "team.member_role_changed",
100 "team.member_removed",
101 "team.repo_added",
102 "team.repo_role_changed",
103 "team.repo_removed",
104 ],
105 },
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all106];
107
108function StatusDot({ status }: { status: string | null }) {
109 const color =
Merge branch 'worktree-agent-ad7c6d88d93adc817'110 status === "delivered" ? "bg-success" : status === "failed" ? "bg-danger" : status === "pending" ? "bg-warn" : "bg-faint";
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all111 return <span aria-hidden="true" className={`size-2 shrink-0 rounded-full ${color}`} />;
112}
113
114function describeEvents(events: string[]): string {
115 if (events.includes("*")) return "Every event";
116 return events.length === 1 ? events[0] : `${events.length} events`;
117}
118
119/** JSON, indented for reading, or the text as it is. */
120function pretty(text: string | null): string {
121 if (!text) return "";
122 try {
123 return JSON.stringify(JSON.parse(text), null, 2);
124 } catch {
125 return text;
126 }
127}
128
129function DeliveryRow({ delivery, manage }: { delivery: HookDelivery; manage: boolean }) {
130 return (
131 <details className="group border-t border-line first:border-t-0">
132 <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40">
133 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
134 <StatusDot status={delivery.status} />
135 <span className="font-mono text-[0.8125rem]">{delivery.event}</span>
136 <span className="min-w-0 truncate text-xs text-muted">
137 {delivery.responseStatus != null
138 ? `${delivery.responseStatus}`
139 : delivery.error
140 ? delivery.error
141 : "Not yet sent"}
142 {delivery.attempts > 1 && ` · ${delivery.attempts} attempts`}
143 {delivery.status === "pending" && delivery.nextAttemptAt && " · will try again"}
144 </span>
145 <span className="ml-auto shrink-0 font-mono text-xs text-faint">
146 {delivery.durationMs != null && `${delivery.durationMs}ms · `}
147 <TimeAgo at={delivery.createdAt} />
148 </span>
149 </summary>
150 <div className="grid gap-4 border-t border-line bg-bg/40 p-4 lg:grid-cols-2">
151 <div className="min-w-0">
152 <p className="mb-1.5 text-xs font-medium text-muted">Request</p>
153 <pre className="max-h-80 overflow-auto rounded-lg bg-surface p-3 font-mono text-xs ring-1 ring-line">
154 <code>{pretty(delivery.payload)}</code>
155 </pre>
156 </div>
157 <div className="min-w-0">
158 <p className="mb-1.5 text-xs font-medium text-muted">
159 Response{delivery.responseStatus != null && ` · ${delivery.responseStatus}`}
160 </p>
161 <pre className="max-h-80 overflow-auto rounded-lg bg-surface p-3 font-mono text-xs ring-1 ring-line">
162 <code>{delivery.error ?? (pretty(delivery.responseBody) || "No body.")}</code>
163 </pre>
164 {manage && (
165 <Form method="post" className="mt-3">
166 <input type="hidden" name="intent" value="redeliver" />
167 <input type="hidden" name="delivery" value={delivery.id} />
168 <SubmitButton
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.169 variant="outline"
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all170 match={{ intent: "redeliver", delivery: delivery.id }}
171 pending="Redelivering…"
172 >
173 <RotateCw size={14} />
174 Redeliver
175 </SubmitButton>
176 </Form>
177 )}
178 </div>
179 </div>
180 </details>
181 );
182}
183
184function HookRow({ hook, open, deliveries, manage }: { hook: Hook; open: boolean; deliveries: HookDelivery[]; manage: boolean }) {
185 return (
186 <li className="border-t border-line first:border-t-0">
187 <div className="flex items-center gap-3 px-4 py-3">
188 <StatusDot status={hook.active ? hook.lastStatus : null} />
189 <div className="min-w-0 grow">
190 <p className="truncate font-mono text-[0.8125rem]">{hook.url}</p>
191 <p className="truncate text-xs text-muted">
192 {describeEvents(hook.events)}
193 {!hook.active && " · paused"}
194 {hook.lastDeliveredAt && (
195 <>
196 {" · last sent "}
197 <TimeAgo at={hook.lastDeliveredAt} />
198 </>
199 )}
200 {` · secret ${hook.secretHint}`}
201 </p>
202 </div>
203 <Link
204 to={open ? "?" : `?hook=${hook.id}`}
205 preventScrollReset
206 className="shrink-0 rounded-md px-2.5 py-1.5 text-xs text-muted transition-colors hover:bg-raised hover:text-fg"
207 >
208 {open ? "Hide deliveries" : "Deliveries"}
209 </Link>
210 {manage && (
211 <Form method="post" className="flex shrink-0 gap-1">
212 <input type="hidden" name="id" value={hook.id} />
213 <IconButton intent="ping" id={hook.id} label="Send a ping">
214 <Send size={14} />
215 </IconButton>
216 <input type="hidden" name="active" value={hook.active ? "false" : "true"} />
217 <IconButton intent="toggle" id={hook.id} label={hook.active ? "Pause" : "Resume"}>
218 {hook.active ? <Pause size={14} /> : <Play size={14} />}
219 </IconButton>
220 <IconButton intent="delete" id={hook.id} label="Delete">
221 <Trash2 size={14} />
222 </IconButton>
223 </Form>
224 )}
225 </div>
226 {open && (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.227 <Card tone="plain" radius="lg" className="mx-4 mb-4 overflow-hidden">
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all228 {deliveries.length === 0 ? (
229 <p className="px-4 py-3 text-sm text-muted">Nothing sent yet.</p>
230 ) : (
231 deliveries.map((delivery) => <DeliveryRow key={delivery.id} delivery={delivery} manage={manage} />)
232 )}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.233 </Card>
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all234 )}
235 </li>
236 );
237}
238
239/** One webhook's action, as an icon: a spinner in its place while that action goes. */
240function IconButton({ intent, id, label, children }: { intent: string; id: string; label: string; children: React.ReactNode }) {
241 return (
Merge branch 'worktree-agent-ae1299e92e4462012'242 <Hint label={label}>
243 <SubmitButton
244 icon
245 name="intent"
246 value={intent}
247 match={{ id }}
248 aria-label={label}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.249 variant="ghost"
250 size="inline"
251 className="p-2"
Merge branch 'worktree-agent-ae1299e92e4462012'252 >
253 {children}
254 </SubmitButton>
255 </Hint>
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all256 );
257}
258
259function AddWebhook() {
260 const [which, setWhich] = useState<"all" | "some">("all");
261 return (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.262 <Card asChild className="space-y-4 p-5">
263 <Form method="post">
264 <input type="hidden" name="intent" value="create" />
265 <p className="font-medium">Add a webhook</p>
266 <Field label="Payload URL" hint="An HTTPS address on the public internet. g1t sends it a ping as soon as you add it.">
267 <Input name="url" type="url" required placeholder="https://example.com/g1t/events" />
268 </Field>
269 <fieldset>
270 <legend className="mb-1.5 text-sm font-medium text-muted">Which events</legend>
271 <RadioGroup
272 name="which"
273 value={which}
274 onValueChange={(value) => setWhich(value as typeof which)}
275 aria-label="Which events"
276 className="flex flex-wrap gap-x-6 gap-y-2"
277 >
278 <RadioOption value="all" label="Everything, including events added later" />
279 <RadioOption value="some" label="Let me choose" />
280 </RadioGroup>
281 {which === "some" && (
282 <div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
283 {GROUPS.map((group) => (
284 <div key={group.title}>
285 <p className="mb-1.5 text-xs font-medium text-faint">{group.title}</p>
286 <div className="space-y-1">
287 {group.events
288 .filter((event) => (EVENT_TYPES as readonly string[]).includes(event))
289 .map((event) => (
290 <CheckboxOption
291 key={event}
292 name="event"
293 value={event}
294 label={event}
295 className="items-center"
296 labelClassName="font-mono text-[0.8125rem]"
297 />
298 ))}
299 </div>
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all300 </div>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.301 ))}
302 </div>
303 )}
304 </fieldset>
305 <Field label="Secret" hint="Optional. Deliveries are signed with it. Leave it empty and g1t makes one, shown once.">
306 <Input name="secret" type="password" autoComplete="off" />
307 </Field>
308 <SubmitButton match={{ intent: "create" }} pending="Adding…">
309 Add webhook
310 </SubmitButton>
311 </Form>
312 </Card>
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all313 );
314}
315
316export function WebhooksPanel({
317 data,
318 action,
319 manage,
320 scope,
321}: {
322 data: WebhooksData;
323 action: WebhooksAction | undefined;
324 manage: boolean;
325 /** Says whose events these are. */
326 scope: string;
327}) {
328 const created = action?.created;
329 return (
330 <div className="max-w-4xl space-y-6">
331 {created && (
Merge branch 'worktree-agent-ad7c6d88d93adc817'332 <div className="space-y-3 rounded-xl border border-success-dim/60 bg-success/5 p-5 text-sm">
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all333 <p className="font-medium">Added. g1t sent it a ping.</p>
334 {created.secret ? (
335 <>
336 <p className="text-muted">
337 Every delivery is signed with this secret in <code>X-G1t-Signature-256</code>. It is shown this once.
338 </p>
339 <CopyLine text={created.secret} />
340 </>
341 ) : (
342 <p className="text-muted">Deliveries are signed with the secret you gave.</p>
343 )}
344 </div>
345 )}
346 {action?.sent && (
347 <p className="text-sm text-muted">
348 Sent {action.sent.event}:{" "}
349 {action.sent.status === "delivered" ? (
Merge branch 'worktree-agent-ad7c6d88d93adc817'350 <span className="text-success">delivered, {action.sent.responseStatus}</span>
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all351 ) : (
352 <span className="text-danger">{action.sent.error ?? `answered ${action.sent.responseStatus}`}</span>
353 )}
354 .
355 </p>
356 )}
357 <ErrorText>{action?.error}</ErrorText>
358
359 {data.hooks.length === 0 ? (
360 <EmptyState title="No webhooks yet">
361 Add one and {scope} sends its events to your address as they happen, signed, and retried until it answers.
362 </EmptyState>
363 ) : (
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.364 <Card asChild className="overflow-hidden">
365 <ul>
366 {data.hooks.map((hook) => (
367 <HookRow
368 key={hook.id}
369 hook={hook}
370 open={data.open === hook.id}
371 deliveries={data.open === hook.id ? data.deliveries : []}
372 manage={manage}
373 />
374 ))}
375 </ul>
376 </Card>
Inbox: a bell in the top bar opens it beside the page, and /inbox holds it all377 )}
378
379 {/* Keyed to the webhook just added, so the form starts empty for the next. */}
380 {manage && <AddWebhook key={created?.hook.id ?? "new"} />}
381 <p className="text-xs text-faint">
382 Each delivery is an HTTPS POST of JSON with <code>X-G1t-Event</code>, <code>X-G1t-Delivery</code> and{" "}
383 <code>X-G1t-Signature-256</code>. One that is not answered with a 2xx is tried again after 1 minute, 5 minutes,
384 30 minutes, 2 hours and 5 hours.{" "}
385 <a href="https://docs.g1t.sh/guides/webhooks/" className="text-muted underline underline-offset-4">
386 How to check the signature
387 </a>
388 .
389 </p>
390 </div>
391 );
392}

This file's history is long; its oldest lines are credited to the oldest commit read.