flagon-io/g1t

public

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/apps/web/app/routes/workspace/integrations.tsx

908 lines35,341 bytesCodeBlame

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.

Integrations: your own model provider, alerts that open issues, tickets agents read1import {
2 AlertTriangle,
3 Bot,
4 CheckCircle2,
5 ChevronRight,
6 Cpu,
7 Plug,
8 Siren,
9 Ticket,
10 Webhook,
11 X,
12} from "lucide-react";
Models per workspace: several providers, routed by kind of work13import { env } from "cloudflare:workers";
Integrations: your own model provider, alerts that open issues, tickets agents read14import type { ReactNode } from "react";
15import { Form, Link, useNavigation } from "react-router";
16
17import {
18 type Connection,
19 type ConnectionConfig,
20 type Delivery,
Models per workspace: several providers, routed by kind of work21 MODEL_TASKS,
22 type ModelRoute,
23 type ModelTask,
Integrations: your own model provider, alerts that open issues, tickets agents read24 type Provider,
25 type ProviderKind,
26 PROVIDERS,
27} from "@g1t/contracts";
28
29import type { Route } from "./+types/integrations";
30import { Button, CopyLine, ErrorText, Field, Input, Pill, TimeAgo } from "../../components/ui";
31import { billing, integrations, repos } from "../../lib/services.server";
32import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
33
34export function meta({ params }: Route.MetaArgs) {
35 return [{ title: `Integrations · ${params.owner} · g1t` }];
36}
37
38const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS;
39
40export async function loader({ params, context, request }: Route.LoaderArgs) {
41 const viewer = getViewer(context);
42 const role = roleIn(viewer, params.owner);
43 if (!role) throw new Response(null, { status: 404 });
44 const slug = params.owner.toLowerCase();
Models per workspace: several providers, routed by kind of work45 const [connections, listed, account, access, routes] = await Promise.all([
Integrations: your own model provider, alerts that open issues, tickets agents read46 integrations.list(slug, viewer),
47 repos.list(viewer, { namespace: slug }),
48 billing.account(slug, viewer),
Models per workspace: several providers, routed by kind of work49 env.RUNNER.modelAccess(slug),
50 integrations.routes(slug, viewer),
Integrations: your own model provider, alerts that open issues, tickets agents read51 ]);
52 const all = unwrap(connections);
53 // What each alert source has sent lately, to see it is wired up.
54 const deliveries: Record<string, Delivery[]> = {};
55 await Promise.all(
56 all
57 .filter((connection) => connection.kind === "alerts")
58 .map(async (connection) => {
59 const found = await integrations.deliveries(slug, viewer, connection.id);
60 deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : [];
61 }),
62 );
63 const adding = new URL(request.url).searchParams.get("add");
64 return {
65 slug,
66 role,
67 connections: all,
68 deliveries,
69 repos: listed.map((repo) => `${repo.namespace}/${repo.name}`),
70 adding: isProvider(adding) ? adding : null,
Models per workspace: several providers, routed by kind of work71 hostedOpen: access.hosted,
72 routes: routes.ok ? routes.value : [],
Integrations: your own model provider, alerts that open issues, tickets agents read73 feeMicros: account.ok ? account.value.orchestrationFeeMicros : 100_000,
74 marginPercent: account.ok ? account.value.marginPercent : 20,
75 };
76}
77
78function text(form: FormData, name: string): string | undefined {
79 const value = String(form.get(name) ?? "").trim();
80 return value || undefined;
81}
82
83/** The settings a form describes, for the provider it is for. */
84function configFrom(form: FormData): ConnectionConfig {
85 const keys = text(form, "keys");
86 return {
87 repo: text(form, "repo"),
88 assign: form.get("assign") === "on",
89 label: text(form, "label"),
90 writeBack: form.get("writeBack") !== "off",
91 organization: text(form, "organization"),
92 site: text(form, "site"),
93 email: text(form, "email"),
94 keys: keys ? keys.split(/[\s,]+/).filter(Boolean) : [],
95 baseUrl: text(form, "baseUrl"),
96 authHeader: text(form, "authHeader"),
97 model: text(form, "model"),
98 };
99}
100
101export async function action({ request, params, context }: Route.ActionArgs) {
102 assertSameOrigin(request);
103 const user = requireUser(context, request);
104 const slug = params.owner.toLowerCase();
105 const form = await request.formData();
106 const intent = form.get("intent");
107 const id = String(form.get("id") ?? "");
108 if (intent === "disconnect") {
109 const removed = await integrations.disconnect(user, slug, id);
110 return { error: removed.ok ? null : removed.error.message };
111 }
112 if (intent === "test") {
113 const tested = await integrations.test(user, slug, id);
114 return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message };
115 }
Models per workspace: several providers, routed by kind of work116 if (intent === "routes") {
117 const routes: ModelRoute[] = [];
118 for (const task of MODEL_TASKS) {
119 const choice = String(form.get(`route-${task}`) ?? "");
120 if (!choice) continue;
121 if (choice === "g1t") routes.push({ task, connectionId: null, model: null });
122 else {
123 const [connectionId, model] = choice.split("::");
124 routes.push({ task, connectionId, model: model || null });
125 }
126 }
127 const saved = await integrations.setRoutes(user, slug, routes);
128 return saved.ok ? { routed: true } : { error: saved.error.message };
129 }
Integrations: your own model provider, alerts that open issues, tickets agents read130 if (intent === "update") {
131 const updated = await integrations.update(user, slug, id, {
132 signingSecret: text(form, "signingSecret"),
133 secret: text(form, "secret"),
134 });
135 return { error: updated.ok ? null : updated.error.message };
136 }
137 const provider = String(form.get("provider") ?? "");
138 if (!isProvider(provider)) return { error: "Choose what to connect." };
139 const connected = await integrations.connect(user, slug, {
140 provider,
141 name: text(form, "name"),
142 config: configFrom(form),
143 secret: text(form, "secret"),
144 signingSecret: text(form, "signingSecret"),
145 });
146 return connected.ok ? { connected: connected.value } : { error: connected.error.message, provider };
147}
148
149const KIND_INFO: Record<ProviderKind, { title: string; icon: ReactNode; blurb: string }> = {
150 models: {
Model providers: gateway tokens for endpoints, tidier rows, and the docs151 title: "Model providers",
Integrations: your own model provider, alerts that open issues, tickets agents read152 icon: <Cpu size={16} />,
Model providers: gateway tokens for endpoints, tidier rows, and the docs153 blurb: "Connect as many as you use, then choose which model does which work, and so who pays for it.",
Integrations: your own model provider, alerts that open issues, tickets agents read154 },
155 alerts: {
156 title: "Alerts",
157 icon: <Siren size={16} />,
158 blurb: "Problems your monitoring finds become issues, and agents can start on them before anyone looks.",
159 },
160 tracker: {
161 title: "Trackers",
162 icon: <Ticket size={16} />,
163 blurb: "Tickets agents can read when work mentions them, that people can import as issues, and that hear back when the work lands.",
164 },
165};
166
167const PROVIDER_BLURB: Record<Provider, string> = {
Models per workspace: several providers, routed by kind of work168 anthropic: "Claude models on your own Anthropic key. Anthropic bills you.",
169 openai: "GPT models on your own OpenAI key. OpenAI bills you.",
170 gemini: "Gemini models on your own Google AI key. Google bills you.",
171 anthropic_endpoint: "Your own AI Gateway, LiteLLM, Bedrock or Vertex behind a proxy: anything that speaks Anthropic's API.",
172 openai_endpoint: "Azure OpenAI, OpenRouter, Groq, Together, vLLM, Ollama: anything that speaks OpenAI's API.",
Integrations: your own model provider, alerts that open issues, tickets agents read173 sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.",
174 datadog: "Monitors that trigger open issues. Recoveries are noted on them.",
175 webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.",
176 jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.",
177 linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.",
178};
179
180/** A small mark for each provider: its initial, in a tile. */
181function ProviderMark({ provider, size = 32 }: { provider: Provider; size?: number }) {
182 const hue: Record<Provider, number> = {
183 anthropic: 40,
Models per workspace: several providers, routed by kind of work184 openai: 160,
185 gemini: 230,
Integrations: your own model provider, alerts that open issues, tickets agents read186 anthropic_endpoint: 280,
Models per workspace: several providers, routed by kind of work187 openai_endpoint: 140,
Integrations: your own model provider, alerts that open issues, tickets agents read188 sentry: 300,
189 datadog: 290,
190 webhook: 200,
191 jira: 250,
192 linear: 265,
193 };
194 const icon =
Models per workspace: several providers, routed by kind of work195 provider === "webhook" ? (
196 <Webhook size={size * 0.5} />
197 ) : provider === "anthropic_endpoint" || provider === "openai_endpoint" ? (
198 <Bot size={size * 0.5} />
199 ) : null;
Integrations: your own model provider, alerts that open issues, tickets agents read200 return (
201 <span
202 aria-hidden="true"
203 className="inline-flex shrink-0 items-center justify-center font-semibold"
204 style={{
205 width: size,
206 height: size,
207 borderRadius: size * 0.28,
208 fontSize: size * 0.42,
209 background: `oklch(0.3 0.06 ${hue[provider]})`,
210 color: `oklch(0.9 0.08 ${hue[provider]})`,
211 }}
212 >
213 {icon ?? PROVIDERS[provider].label[0]}
214 </span>
215 );
216}
217
218function dollars(micros: number): string {
219 return `$${(micros / 1_000_000).toFixed(2)}`;
220}
221
222export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) {
Models per workspace: several providers, routed by kind of work223 const { slug, role, connections, deliveries, repos: repoNames, adding, feeMicros, marginPercent, hostedOpen, routes } =
224 loaderData;
Integrations: your own model provider, alerts that open issues, tickets agents read225 const owner = role === "owner";
226 const busy = useNavigation().state === "submitting";
Models per workspace: several providers, routed by kind of work227 const modelConnections = connections.filter((connection) => connection.kind === "models");
Integrations: your own model provider, alerts that open issues, tickets agents read228 const justConnected = actionData && "connected" in actionData ? actionData.connected : null;
229 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
230 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
231
232 return (
233 <div className="max-w-4xl">
234 <div className="flex items-start gap-3">
235 <span className="mt-0.5 rounded-lg bg-surface p-2 text-muted ring-1 ring-line">
236 <Plug size={18} />
237 </span>
238 <div>
239 <h2 className="text-lg font-semibold tracking-tight">Integrations</h2>
240 <p className="mt-1 max-w-2xl text-sm text-muted">
241 Connect {slug} to the systems your work already lives in. g1t keeps secrets sealed and never
242 shows them again; agents never see them at all.
243 </p>
244 </div>
245 </div>
246
247 {justConnected && <Connected connected={justConnected} />}
248 <div className="mt-4">
249 <ErrorText>{error}</ErrorText>
250 </div>
251
252 {/* Models -------------------------------------------------------------- */}
253 <Section kind="models">
Models per workspace: several providers, routed by kind of work254 {!hostedOpen && modelConnections.length === 0 && (
255 <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4">
256 <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30">
257 <AlertTriangle size={16} />
258 </span>
259 <p className="text-sm text-muted">
260 <span className="font-medium text-fg">Choose how your agents reach a model.</span> g1t's hosted models
261 are not open to {slug} yet. Connect a provider of your own below and your agents start at once,
262 billed by that provider.
263 </p>
264 </div>
265 )}
266 <Connections list={modelConnections} owner={owner} busy={busy} tested={tested} deliveries={deliveries} />
267 <Routing
268 connections={modelConnections}
269 routes={routes}
270 hostedOpen={hostedOpen}
271 marginPercent={marginPercent}
272 owner={owner}
273 busy={busy}
274 saved={actionData != null && "routed" in actionData}
275 />
Integrations: your own model provider, alerts that open issues, tickets agents read276 <p className="mt-3 text-xs text-faint">
Models per workspace: several providers, routed by kind of work277 On your own providers, their bills are yours and g1t charges {dollars(feeMicros)} a run for the
278 sandbox and orchestration. Keys go only from g1t's model proxy to the provider: the agent's
279 sandbox holds a token that dies with the run.
Integrations: your own model provider, alerts that open issues, tickets agents read280 </p>
Models per workspace: several providers, routed by kind of work281 {owner && <Choices kind="models" slug={slug} adding={adding} />}
Integrations: connect forms open under the tile that was chosen282 {adding && PROVIDERS[adding].kind === "models" && owner && (
283 <AddForm
284 provider={adding}
285 slug={slug}
286 repos={repoNames}
287 busy={busy}
288 error={actionData && "provider" in actionData ? error : null}
289 />
290 )}
Integrations: your own model provider, alerts that open issues, tickets agents read291 </Section>
292
293 {/* Alerts -------------------------------------------------------------- */}
294 <Section kind="alerts">
295 <Connections
296 list={connections.filter((c) => c.kind === "alerts")}
297 owner={owner}
298 busy={busy}
299 tested={tested}
300 deliveries={deliveries}
301 />
302 {owner && <Choices kind="alerts" slug={slug} adding={adding} />}
Integrations: connect forms open under the tile that was chosen303 {adding && PROVIDERS[adding].kind === "alerts" && owner && (
304 <AddForm
305 provider={adding}
306 slug={slug}
307 repos={repoNames}
308 busy={busy}
309 error={actionData && "provider" in actionData ? error : null}
310 />
311 )}
Integrations: your own model provider, alerts that open issues, tickets agents read312 </Section>
313
314 {/* Trackers ------------------------------------------------------------ */}
315 <Section kind="tracker">
316 <Connections
317 list={connections.filter((c) => c.kind === "tracker")}
318 owner={owner}
319 busy={busy}
320 tested={tested}
321 deliveries={deliveries}
322 />
323 {owner && <Choices kind="tracker" slug={slug} adding={adding} />}
Integrations: connect forms open under the tile that was chosen324 {adding && PROVIDERS[adding].kind === "tracker" && owner && (
325 <AddForm
326 provider={adding}
327 slug={slug}
328 repos={repoNames}
329 busy={busy}
330 error={actionData && "provider" in actionData ? error : null}
331 />
332 )}
Integrations: your own model provider, alerts that open issues, tickets agents read333 </Section>
334
335 {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>}
336 </div>
337 );
338}
339
Models per workspace: several providers, routed by kind of work340const TASK_LABELS: Record<ModelTask, { label: string; hint: string }> = {
341 default: { label: "Everything", hint: "Unless a kind of work below says otherwise." },
342 implement: { label: "Making changes", hint: "Writing the change for an issue, and revising it." },
343 review: { label: "Reviewing", hint: "The second agent that reviews each change." },
344 plan: { label: "Planning", hint: "Turning an outcome into issues." },
345 update: { label: "Catching up", hint: "Bringing a change up to date with main." },
346};
347
348/** What a route is, as the value of a select. */
349function routeValue(route: ModelRoute | undefined): string {
350 if (!route) return "";
351 return route.connectionId == null ? "g1t" : `${route.connectionId}::${route.model ?? ""}`;
352}
353
354/** Each kind of work, and the provider and model it goes to. */
355function Routing({
356 connections,
357 routes,
358 hostedOpen,
359 marginPercent,
360 owner,
361 busy,
362 saved,
363}: {
364 connections: Connection[];
365 routes: ModelRoute[];
366 hostedOpen: boolean;
367 marginPercent: number;
368 owner: boolean;
369 busy: boolean;
370 saved: boolean;
371}) {
372 const options = (
373 <>
374 <option value="g1t" disabled={!hostedOpen}>
375 g1t's models{hostedOpen ? ` (g1t's choice, your credit at cost + ${marginPercent}%)` : " (not open to this workspace yet)"}
376 </option>
377 {connections.map((connection) => {
378 const models = [...new Set([...(connection.config.model ? [connection.config.model] : []), ...connection.models])];
379 return (
380 <optgroup key={connection.id} label={connection.name}>
381 {PROVIDERS[connection.provider].kind === "models" &&
382 (connection.provider === "anthropic" || connection.provider === "anthropic_endpoint") && (
383 <option value={`${connection.id}::`}>{connection.name}: g1t's choice of Claude model</option>
384 )}
385 {models.map((model) => (
386 <option key={model} value={`${connection.id}::${model}`}>
387 {connection.name}: {model}
388 </option>
389 ))}
390 </optgroup>
391 );
392 })}
393 </>
394 );
395 const fallback = hostedOpen ? "g1t" : connections[0] ? `${connections[0].id}::${connections[0].config.model ?? ""}` : "g1t";
396 return (
397 <Form method="post" className="rounded-xl border border-line bg-surface">
398 <input type="hidden" name="intent" value="routes" />
399 <div className="border-b border-line px-4 py-3">
400 <p className="text-sm font-medium">Which model does which work</p>
401 <p className="text-xs text-muted">
402 Each kind of work can go to g1t's models or to any of your providers, on the model you choose.
403 </p>
404 </div>
405 <ul className="divide-y divide-line">
406 {MODEL_TASKS.map((task) => {
407 const route = routes.find((r) => r.task === task);
408 return (
409 <li key={task} className="grid items-center gap-2 px-4 py-3 sm:grid-cols-[12rem_1fr]">
410 <div>
411 <p className="text-sm font-medium">{TASK_LABELS[task].label}</p>
412 <p className="text-xs text-faint">{TASK_LABELS[task].hint}</p>
413 </div>
414 <select
415 name={`route-${task}`}
416 disabled={!owner}
417 defaultValue={task === "default" ? routeValue(route) || fallback : routeValue(route)}
418 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim disabled:opacity-70"
419 >
420 {task !== "default" && <option value="">Same as everything</option>}
421 {options}
422 </select>
423 </li>
424 );
425 })}
426 </ul>
427 {owner && (
428 <div className="flex items-center gap-3 border-t border-line px-4 py-3">
429 <Button type="submit" variant="quiet" disabled={busy}>
430 Save routing
431 </Button>
432 {saved && <span className="text-sm text-accent">Saved. The next runs use it.</span>}
433 </div>
434 )}
435 </Form>
436 );
437}
438
Integrations: your own model provider, alerts that open issues, tickets agents read439function Section({ kind, children }: { kind: ProviderKind; children: ReactNode }) {
440 const info = KIND_INFO[kind];
441 return (
442 <section className="mt-10">
443 <h3 className="flex items-center gap-2 text-sm font-medium">
444 <span className="text-muted">{info.icon}</span>
445 {info.title}
446 </h3>
447 <p className="mt-1 mb-4 text-sm text-muted">{info.blurb}</p>
448 {children}
449 </section>
450 );
451}
452
453function Connections({
454 list,
455 owner,
456 busy,
457 tested,
458 deliveries,
459}: {
460 list: Connection[];
461 owner: boolean;
462 busy: boolean;
463 tested: { id: string; ok: boolean; message: string } | null;
464 deliveries: Record<string, Delivery[]>;
465}) {
466 if (list.length === 0) return null;
467 return (
468 <ul className="mb-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
469 {list.map((connection) => (
470 <li key={connection.id} className="p-4">
471 <ConnectionRow
472 connection={connection}
473 owner={owner}
474 busy={busy}
475 tested={tested}
476 deliveries={deliveries[connection.id] ?? []}
477 />
478 </li>
479 ))}
480 </ul>
481 );
482}
483
Model providers: gateway tokens for endpoints, tidier rows, and the docs484/** Just the host of an address, which is what tells connections apart. */
485function host(url: string | undefined): string | undefined {
486 if (!url) return undefined;
487 try {
488 return new URL(url).host;
489 } catch {
490 return url;
491 }
492}
493
Integrations: your own model provider, alerts that open issues, tickets agents read494function ConnectionRow({
495 connection,
496 owner,
497 busy,
498 tested,
499 deliveries,
500}: {
501 connection: Connection;
502 owner: boolean;
503 busy: boolean;
504 tested: { id: string; ok: boolean; message: string } | null;
505 deliveries: Delivery[];
506}) {
507 const { config } = connection;
508 const facts = [
509 PROVIDERS[connection.provider].label,
510 config.repo && `issues in ${config.repo}`,
511 config.assign && "agents start at once",
512 config.organization,
Model providers: gateway tokens for endpoints, tidier rows, and the docs513 host(config.site),
514 host(config.baseUrl),
Models per workspace: several providers, routed by kind of work515 config.model && `default ${config.model}`,
516 connection.models.length > 0 && `${connection.models.length} models`,
Integrations: your own model provider, alerts that open issues, tickets agents read517 config.keys?.length ? config.keys.join(", ") : null,
518 connection.secretHint && `key ${connection.secretHint}`,
519 ].filter(Boolean);
520 const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt;
521 return (
522 <div>
Model providers: gateway tokens for endpoints, tidier rows, and the docs523 <div className="flex items-center gap-3">
Integrations: your own model provider, alerts that open issues, tickets agents read524 <ProviderMark provider={connection.provider} />
525 <div className="min-w-0 grow">
526 <p className="truncate text-sm font-medium">{connection.name}</p>
527 <p className="truncate text-xs text-muted">{facts.join(" · ")}</p>
528 </div>
529 {connection.lastUsedAt && (
Model providers: gateway tokens for endpoints, tidier rows, and the docs530 <span className="hidden shrink-0 text-xs text-faint sm:inline">
Integrations: your own model provider, alerts that open issues, tickets agents read531 Used <TimeAgo at={connection.lastUsedAt} />
532 </span>
533 )}
534 {owner && (
Model providers: gateway tokens for endpoints, tidier rows, and the docs535 <Form method="post" className="flex shrink-0 gap-2">
Integrations: your own model provider, alerts that open issues, tickets agents read536 <input type="hidden" name="id" value={connection.id} />
537 <Button variant="quiet" type="submit" name="intent" value="test" disabled={busy}>
538 Test
539 </Button>
540 <Button variant="quiet" type="submit" name="intent" value="disconnect" disabled={busy} aria-label="Disconnect">
541 <X size={14} />
542 </Button>
543 </Form>
544 )}
545 </div>
546 {tested?.id === connection.id && (
547 <p className={`mt-3 text-sm ${tested.ok ? "text-accent" : "text-danger"}`}>{tested.message}</p>
548 )}
549 {connection.lastError && (
550 <p className="mt-3 flex items-start gap-2 text-xs text-warn">
551 <AlertTriangle size={13} className="mt-0.5 shrink-0" />
552 {connection.lastError}
553 </p>
554 )}
555 {connection.webhookUrl && (
556 <div className="mt-3">
557 <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p>
558 <CopyLine text={connection.webhookUrl} />
559 </div>
560 )}
561 {owner && waitingForSecret && (
562 <Form method="post" className="mt-3 flex flex-wrap items-end gap-2">
563 <input type="hidden" name="intent" value="update" />
564 <input type="hidden" name="id" value={connection.id} />
565 <div className="min-w-64 grow">
566 <Field label="Client secret" hint="From the internal integration's page in Sentry, once it has this address. Leave it if it is already saved.">
567 <Input name="signingSecret" type="password" placeholder="Paste to replace" />
568 </Field>
569 </div>
570 <Button type="submit" variant="quiet" disabled={busy}>
571 Save
572 </Button>
573 </Form>
574 )}
575 {deliveries.length > 0 && (
576 <ul className="mt-3 space-y-1 text-xs">
577 {deliveries.map((delivery) => (
578 <li key={delivery.id} className="flex items-center gap-2 text-muted">
579 <span
580 className={`size-1.5 shrink-0 rounded-full ${
581 delivery.outcome === "refused"
582 ? "bg-danger"
583 : delivery.outcome === "ignored"
584 ? "bg-faint"
585 : "bg-accent"
586 }`}
587 />
588 <span className="font-mono text-faint">{delivery.event}</span>
589 <span className="min-w-0 truncate">{delivery.detail}</span>
590 {delivery.issue && (
591 <Link
592 to={`/${delivery.issue.replace("#", "/issues/")}`}
593 className="shrink-0 font-mono text-fg hover:underline"
594 >
595 {delivery.issue}
596 </Link>
597 )}
598 <span className="ml-auto shrink-0 text-faint">
599 <TimeAgo at={delivery.receivedAt} />
600 </span>
601 </li>
602 ))}
603 </ul>
604 )}
605 </div>
606 );
607}
608
609function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) {
610 const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind);
611 return (
612 <div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
613 {providers.map((provider) => (
614 <Link
615 key={provider}
616 to={`/${slug}/-/integrations?add=${provider}#add`}
617 preventScrollReset
618 className={`group flex items-start gap-3 rounded-xl border p-3.5 transition-colors ${
619 adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface"
620 }`}
621 >
622 <ProviderMark provider={provider} size={28} />
623 <span className="min-w-0 grow">
624 <span className="flex items-center gap-1 text-sm font-medium">
625 {PROVIDERS[provider].label}
626 <ChevronRight size={13} className="text-faint transition-transform group-hover:translate-x-0.5" />
627 </span>
628 <span className="mt-0.5 block text-xs leading-relaxed text-muted">{PROVIDER_BLURB[provider]}</span>
629 </span>
630 </Link>
631 ))}
632 </div>
633 );
634}
635
636function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) {
637 return (
638 <Field label="Repository" hint={hint}>
639 <select
640 name="repo"
641 required={required}
642 defaultValue={repos[0] ?? ""}
643 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
644 >
645 {!required && <option value="">None</option>}
646 {repos.map((repo) => (
647 <option key={repo} value={repo}>
648 {repo}
649 </option>
650 ))}
651 </select>
652 </Field>
653 );
654}
655
656function AssignField() {
657 return (
658 <label className="flex items-start gap-2.5 text-sm">
659 <input type="checkbox" name="assign" className="mt-1 accent-[var(--color-accent)]" />
660 <span>
661 <span className="font-medium">Put a g1t agent on each new issue</span>
662 <span className="block text-xs text-muted">
663 It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has
664 to look. Agents run only where g1t agents are enabled.
665 </span>
666 </span>
667 </label>
668 );
669}
670
671function AddForm({
672 provider,
673 slug,
674 repos,
675 busy,
676 error,
677}: {
678 provider: Provider;
679 slug: string;
680 repos: string[];
681 busy: boolean;
682 error: string | null;
683}) {
684 const fields: Record<Provider, ReactNode> = {
685 anthropic: (
686 <Field label="API key" hint="From console.anthropic.com. Sealed when saved; nobody sees it again.">
687 <Input name="secret" type="password" required placeholder="sk-ant-…" />
688 </Field>
689 ),
Models per workspace: several providers, routed by kind of work690 openai: (
691 <>
692 <Field label="API key" hint="From platform.openai.com → API keys. Sealed when saved; nobody sees it again.">
693 <Input name="secret" type="password" required placeholder="sk-…" />
694 </Field>
695 <Field label="Default model" hint="Optional. g1t lists the key's models when you connect, and you choose per kind of work below.">
696 <Input name="model" placeholder="gpt-5" />
697 </Field>
698 </>
699 ),
700 gemini: (
701 <>
702 <Field label="API key" hint="From aistudio.google.com → Get API key.">
703 <Input name="secret" type="password" required />
704 </Field>
705 <Field label="Default model" hint="Optional. g1t lists the key's models when you connect.">
706 <Input name="model" placeholder="gemini-2.5-pro" />
707 </Field>
708 </>
709 ),
710 openai_endpoint: (
711 <>
712 <Field label="Base URL" hint="Up to and including the version, such as https://openrouter.ai/api/v1. g1t calls /chat/completions under it.">
713 <Input name="baseUrl" type="url" required placeholder="https://openrouter.ai/api/v1" />
714 </Field>
715 <Field label="Key" hint="Optional, if the endpoint needs one.">
716 <Input name="secret" type="password" />
717 </Field>
718 <Field label="Send the key as">
719 <select name="authHeader" className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
720 <option value="authorization">Authorization: Bearer</option>
721 <option value="x-api-key">x-api-key</option>
722 </select>
723 </Field>
Model providers: gateway tokens for endpoints, tidier rows, and the docs724 <Field label="Cloudflare AI Gateway token" hint="Only for an authenticated AI Gateway: sent as cf-aig-authorization.">
725 <Input name="signingSecret" type="password" />
726 </Field>
Models per workspace: several providers, routed by kind of work727 <Field label="Default model" hint="The model to use. g1t also lists the endpoint's models if it offers a list.">
728 <Input name="model" placeholder="anthropic/claude-sonnet-4.5" />
729 </Field>
730 </>
731 ),
Integrations: your own model provider, alerts that open issues, tickets agents read732 anthropic_endpoint: (
733 <>
734 <Field label="Base URL" hint="Without /v1. For a Cloudflare AI Gateway: https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic">
735 <Input name="baseUrl" type="url" required placeholder="https://llm.example.com" />
736 </Field>
737 <Field label="Key" hint="Optional, if the endpoint needs one.">
738 <Input name="secret" type="password" />
739 </Field>
740 <Field label="Send the key as">
741 <select name="authHeader" className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
742 <option value="x-api-key">x-api-key</option>
743 <option value="authorization">Authorization: Bearer</option>
744 </select>
745 </Field>
Model providers: gateway tokens for endpoints, tidier rows, and the docs746 <Field label="Cloudflare AI Gateway token" hint="Only for an authenticated AI Gateway: sent as cf-aig-authorization.">
747 <Input name="signingSecret" type="password" />
748 </Field>
Integrations: your own model provider, alerts that open issues, tickets agents read749 <Field label="Model" hint="Optional. Leave empty to use g1t's choice for each kind of work; set it if your endpoint names models its own way.">
750 <Input name="model" placeholder="claude-sonnet-5-5" />
751 </Field>
752 </>
753 ),
754 sentry: (
755 <>
756 <Field label="Organization" hint="Its slug, as in acme.sentry.io.">
757 <Input name="organization" required placeholder="acme" />
758 </Field>
759 <RepoField repos={repos} required hint="Where issues are opened." />
760 <Field label="Auth token" hint="The internal integration's token, with Issue & Event read and write. Lets g1t read stack traces and resolve issues. Optional, but recommended.">
761 <Input name="secret" type="password" />
762 </Field>
763 <Field label="Client secret" hint="You get this from Sentry after giving it this connection's address; you can add it on the next step.">
764 <Input name="signingSecret" type="password" />
765 </Field>
766 <AssignField />
767 </>
768 ),
769 datadog: (
770 <>
771 <RepoField repos={repos} required hint="Where issues are opened." />
772 <AssignField />
773 </>
774 ),
775 webhook: (
776 <>
777 <RepoField repos={repos} required hint="Where issues are opened." />
778 <AssignField />
779 </>
780 ),
781 jira: (
782 <>
783 <Field label="Site" hint="Your Jira's address.">
784 <Input name="site" type="url" required placeholder="https://acme.atlassian.net" />
785 </Field>
786 <Field label="Email" hint="The account the API token belongs to.">
787 <Input name="email" type="email" required autoComplete="off" />
788 </Field>
789 <Field label="API token" hint="From id.atlassian.com → Security → API tokens.">
790 <Input name="secret" type="password" required />
791 </Field>
792 <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key.">
793 <Input name="keys" placeholder="TECH, OPS" />
794 </Field>
795 </>
796 ),
797 linear: (
798 <>
799 <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys.">
800 <Input name="secret" type="password" required placeholder="lin_api_…" />
801 </Field>
802 <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key.">
803 <Input name="keys" placeholder="ENG" />
804 </Field>
805 </>
806 ),
807 };
808 return (
Integrations: connect forms open under the tile that was chosen809 <section id="add" className="mt-4 scroll-mt-20 rounded-xl border border-line bg-surface p-5">
Integrations: your own model provider, alerts that open issues, tickets agents read810 <div className="flex items-center gap-3">
811 <ProviderMark provider={provider} />
812 <div className="grow">
Integrations: connect forms open under the tile that was chosen813 <h3 className="font-medium">
Models per workspace: several providers, routed by kind of work814 Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label}
Integrations: connect forms open under the tile that was chosen815 </h3>
Integrations: your own model provider, alerts that open issues, tickets agents read816 <p className="text-xs text-muted">{PROVIDER_BLURB[provider]}</p>
817 </div>
818 <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
819 <X size={16} />
820 </Link>
821 </div>
822 <Form method="post" className="mt-5 grid max-w-xl gap-4">
823 <input type="hidden" name="provider" value={provider} />
824 {fields[provider]}
825 <ErrorText>{error}</ErrorText>
826 <div>
827 <Button type="submit" disabled={busy}>
828 {busy ? "Connecting…" : "Connect"}
829 </Button>
830 </div>
831 </Form>
832 </section>
833 );
834}
835
836/** What to do next, right after connecting: shown once. */
837function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) {
838 const { connection, signingSecret } = connected;
839 const url = connection.webhookUrl;
840 return (
841 <div className="mt-6 rounded-xl border border-accent-dim/60 bg-accent/5 p-5">
842 <p className="flex items-center gap-2 font-medium">
843 <CheckCircle2 size={16} className="text-accent" />
844 {connection.name} is connected.
845 </p>
846 {connection.provider === "sentry" && url && (
847 <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted">
848 <li>
849 In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal
850 integration.
851 </li>
852 <li>
853 Set its webhook URL to <CopyLine text={url} />
854 </li>
855 <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li>
856 <li>Give it Issue &amp; Event read and write, save, and paste its client secret below, on the connection.</li>
857 </ol>
858 )}
859 {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && (
860 <div className="mt-3 space-y-3 text-sm text-muted">
861 <p>This secret is shown once. Copy it now.</p>
862 <CopyLine text={signingSecret} />
863 {connection.provider === "datadog" ? (
864 <>
865 <p>
866 In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL:
867 </p>
868 <CopyLine text={url} />
869 <p>Custom headers:</p>
870 <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} />
871 <p>Payload:</p>
872 <CopyLine
873 text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`}
874 />
875 <p>
876 Then mention <code>@webhook-g1t</code> in any monitor's message.
877 </p>
878 </>
879 ) : (
880 <>
881 <p>Send JSON with at least a title to:</p>
882 <CopyLine text={url} />
883 <p>
884 with <code>Authorization: Bearer &lt;secret&gt;</code>, or an HMAC-SHA256 of the body in{" "}
885 <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue.
886 </p>
887 </>
888 )}
889 </div>
890 )}
891 {connection.kind === "tracker" && (
892 <p className="mt-2 text-sm text-muted">
893 Agents now read tickets that work mentions, and you can import one from any repository's new
894 issue page. Use Test to check the token.
895 </p>
896 )}
897 {connection.kind === "models" && (
898 <p className="mt-2 text-sm text-muted">
Models per workspace: several providers, routed by kind of work899 {connection.lastError
900 ? `It was saved, but the check failed: ${connection.lastError}`
901 : connection.models.length > 0
902 ? `It offers ${connection.models.length} models. Choose which work goes to it under “Which model does which work”.`
903 : "Choose which work goes to it under “Which model does which work”."}
Integrations: your own model provider, alerts that open issues, tickets agents read904 </p>
905 )}
906 </div>
907 );
908}