g1t/apps/web/app/routes/workspace/integrations.tsx

892 lines34,686 bytesCodeBlame
1import {
2 AlertTriangle,
3 Bot,
4 CheckCircle2,
5 ChevronRight,
6 Cpu,
7 Plug,
8 Siren,
9 Ticket,
10 Webhook,
11 X,
12} from "lucide-react";
13import { env } from "cloudflare:workers";
14import type { ReactNode } from "react";
15import { Form, Link, useNavigation } from "react-router";
16
17import {
18 type Connection,
19 type ConnectionConfig,
20 type Delivery,
21 MODEL_TASKS,
22 type ModelRoute,
23 type ModelTask,
24 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();
45 const [connections, listed, account, access, routes] = await Promise.all([
46 integrations.list(slug, viewer),
47 repos.list(viewer, { namespace: slug }),
48 billing.account(slug, viewer),
49 env.RUNNER.modelAccess(slug),
50 integrations.routes(slug, viewer),
51 ]);
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,
71 hostedOpen: access.hosted,
72 routes: routes.ok ? routes.value : [],
73 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 }
116 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 }
130 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: {
151 title: "Model provider",
152 icon: <Cpu size={16} />,
153 blurb: "Where your agents' model requests go, and who pays for them.",
154 },
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> = {
168 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.",
173 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,
184 openai: 160,
185 gemini: 230,
186 anthropic_endpoint: 280,
187 openai_endpoint: 140,
188 sentry: 300,
189 datadog: 290,
190 webhook: 200,
191 jira: 250,
192 linear: 265,
193 };
194 const icon =
195 provider === "webhook" ? (
196 <Webhook size={size * 0.5} />
197 ) : provider === "anthropic_endpoint" || provider === "openai_endpoint" ? (
198 <Bot size={size * 0.5} />
199 ) : null;
200 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) {
223 const { slug, role, connections, deliveries, repos: repoNames, adding, feeMicros, marginPercent, hostedOpen, routes } =
224 loaderData;
225 const owner = role === "owner";
226 const busy = useNavigation().state === "submitting";
227 const modelConnections = connections.filter((connection) => connection.kind === "models");
228 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">
254 {!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 />
276 <p className="mt-3 text-xs text-faint">
277 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.
280 </p>
281 {owner && <Choices kind="models" slug={slug} adding={adding} />}
282 {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 )}
291 </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} />}
303 {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 )}
312 </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} />}
324 {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 )}
333 </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
340const 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
439function 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
484function ConnectionRow({
485 connection,
486 owner,
487 busy,
488 tested,
489 deliveries,
490}: {
491 connection: Connection;
492 owner: boolean;
493 busy: boolean;
494 tested: { id: string; ok: boolean; message: string } | null;
495 deliveries: Delivery[];
496}) {
497 const { config } = connection;
498 const facts = [
499 PROVIDERS[connection.provider].label,
500 config.repo && `issues in ${config.repo}`,
501 config.assign && "agents start at once",
502 config.organization,
503 config.site?.replace(/^https:\/\//, ""),
504 config.baseUrl?.replace(/^https:\/\//, ""),
505 config.model && `default ${config.model}`,
506 connection.models.length > 0 && `${connection.models.length} models`,
507 config.keys?.length ? config.keys.join(", ") : null,
508 connection.secretHint && `key ${connection.secretHint}`,
509 ].filter(Boolean);
510 const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt;
511 return (
512 <div>
513 <div className="flex flex-wrap items-center gap-3">
514 <ProviderMark provider={connection.provider} />
515 <div className="min-w-0 grow">
516 <p className="truncate text-sm font-medium">{connection.name}</p>
517 <p className="truncate text-xs text-muted">{facts.join(" · ")}</p>
518 </div>
519 {connection.lastUsedAt && (
520 <span className="text-xs text-faint">
521 Used <TimeAgo at={connection.lastUsedAt} />
522 </span>
523 )}
524 {owner && (
525 <Form method="post" className="flex gap-2">
526 <input type="hidden" name="id" value={connection.id} />
527 <Button variant="quiet" type="submit" name="intent" value="test" disabled={busy}>
528 Test
529 </Button>
530 <Button variant="quiet" type="submit" name="intent" value="disconnect" disabled={busy} aria-label="Disconnect">
531 <X size={14} />
532 </Button>
533 </Form>
534 )}
535 </div>
536 {tested?.id === connection.id && (
537 <p className={`mt-3 text-sm ${tested.ok ? "text-accent" : "text-danger"}`}>{tested.message}</p>
538 )}
539 {connection.lastError && (
540 <p className="mt-3 flex items-start gap-2 text-xs text-warn">
541 <AlertTriangle size={13} className="mt-0.5 shrink-0" />
542 {connection.lastError}
543 </p>
544 )}
545 {connection.webhookUrl && (
546 <div className="mt-3">
547 <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p>
548 <CopyLine text={connection.webhookUrl} />
549 </div>
550 )}
551 {owner && waitingForSecret && (
552 <Form method="post" className="mt-3 flex flex-wrap items-end gap-2">
553 <input type="hidden" name="intent" value="update" />
554 <input type="hidden" name="id" value={connection.id} />
555 <div className="min-w-64 grow">
556 <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.">
557 <Input name="signingSecret" type="password" placeholder="Paste to replace" />
558 </Field>
559 </div>
560 <Button type="submit" variant="quiet" disabled={busy}>
561 Save
562 </Button>
563 </Form>
564 )}
565 {deliveries.length > 0 && (
566 <ul className="mt-3 space-y-1 text-xs">
567 {deliveries.map((delivery) => (
568 <li key={delivery.id} className="flex items-center gap-2 text-muted">
569 <span
570 className={`size-1.5 shrink-0 rounded-full ${
571 delivery.outcome === "refused"
572 ? "bg-danger"
573 : delivery.outcome === "ignored"
574 ? "bg-faint"
575 : "bg-accent"
576 }`}
577 />
578 <span className="font-mono text-faint">{delivery.event}</span>
579 <span className="min-w-0 truncate">{delivery.detail}</span>
580 {delivery.issue && (
581 <Link
582 to={`/${delivery.issue.replace("#", "/issues/")}`}
583 className="shrink-0 font-mono text-fg hover:underline"
584 >
585 {delivery.issue}
586 </Link>
587 )}
588 <span className="ml-auto shrink-0 text-faint">
589 <TimeAgo at={delivery.receivedAt} />
590 </span>
591 </li>
592 ))}
593 </ul>
594 )}
595 </div>
596 );
597}
598
599function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) {
600 const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind);
601 return (
602 <div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
603 {providers.map((provider) => (
604 <Link
605 key={provider}
606 to={`/${slug}/-/integrations?add=${provider}#add`}
607 preventScrollReset
608 className={`group flex items-start gap-3 rounded-xl border p-3.5 transition-colors ${
609 adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface"
610 }`}
611 >
612 <ProviderMark provider={provider} size={28} />
613 <span className="min-w-0 grow">
614 <span className="flex items-center gap-1 text-sm font-medium">
615 {PROVIDERS[provider].label}
616 <ChevronRight size={13} className="text-faint transition-transform group-hover:translate-x-0.5" />
617 </span>
618 <span className="mt-0.5 block text-xs leading-relaxed text-muted">{PROVIDER_BLURB[provider]}</span>
619 </span>
620 </Link>
621 ))}
622 </div>
623 );
624}
625
626function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) {
627 return (
628 <Field label="Repository" hint={hint}>
629 <select
630 name="repo"
631 required={required}
632 defaultValue={repos[0] ?? ""}
633 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"
634 >
635 {!required && <option value="">None</option>}
636 {repos.map((repo) => (
637 <option key={repo} value={repo}>
638 {repo}
639 </option>
640 ))}
641 </select>
642 </Field>
643 );
644}
645
646function AssignField() {
647 return (
648 <label className="flex items-start gap-2.5 text-sm">
649 <input type="checkbox" name="assign" className="mt-1 accent-[var(--color-accent)]" />
650 <span>
651 <span className="font-medium">Put a g1t agent on each new issue</span>
652 <span className="block text-xs text-muted">
653 It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has
654 to look. Agents run only where g1t agents are enabled.
655 </span>
656 </span>
657 </label>
658 );
659}
660
661function AddForm({
662 provider,
663 slug,
664 repos,
665 busy,
666 error,
667}: {
668 provider: Provider;
669 slug: string;
670 repos: string[];
671 busy: boolean;
672 error: string | null;
673}) {
674 const fields: Record<Provider, ReactNode> = {
675 anthropic: (
676 <Field label="API key" hint="From console.anthropic.com. Sealed when saved; nobody sees it again.">
677 <Input name="secret" type="password" required placeholder="sk-ant-…" />
678 </Field>
679 ),
680 openai: (
681 <>
682 <Field label="API key" hint="From platform.openai.com → API keys. Sealed when saved; nobody sees it again.">
683 <Input name="secret" type="password" required placeholder="sk-…" />
684 </Field>
685 <Field label="Default model" hint="Optional. g1t lists the key's models when you connect, and you choose per kind of work below.">
686 <Input name="model" placeholder="gpt-5" />
687 </Field>
688 </>
689 ),
690 gemini: (
691 <>
692 <Field label="API key" hint="From aistudio.google.com → Get API key.">
693 <Input name="secret" type="password" required />
694 </Field>
695 <Field label="Default model" hint="Optional. g1t lists the key's models when you connect.">
696 <Input name="model" placeholder="gemini-2.5-pro" />
697 </Field>
698 </>
699 ),
700 openai_endpoint: (
701 <>
702 <Field label="Base URL" hint="Up to and including the version, such as https://openrouter.ai/api/v1. g1t calls /chat/completions under it.">
703 <Input name="baseUrl" type="url" required placeholder="https://openrouter.ai/api/v1" />
704 </Field>
705 <Field label="Key" hint="Optional, if the endpoint needs one.">
706 <Input name="secret" type="password" />
707 </Field>
708 <Field label="Send the key as">
709 <select name="authHeader" className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
710 <option value="authorization">Authorization: Bearer</option>
711 <option value="x-api-key">x-api-key</option>
712 </select>
713 </Field>
714 <Field label="Default model" hint="The model to use. g1t also lists the endpoint's models if it offers a list.">
715 <Input name="model" placeholder="anthropic/claude-sonnet-4.5" />
716 </Field>
717 </>
718 ),
719 anthropic_endpoint: (
720 <>
721 <Field label="Base URL" hint="Without /v1. For a Cloudflare AI Gateway: https://gateway.ai.cloudflare.com/v1/<account>/<gateway>/anthropic">
722 <Input name="baseUrl" type="url" required placeholder="https://llm.example.com" />
723 </Field>
724 <Field label="Key" hint="Optional, if the endpoint needs one.">
725 <Input name="secret" type="password" />
726 </Field>
727 <Field label="Send the key as">
728 <select name="authHeader" className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm">
729 <option value="x-api-key">x-api-key</option>
730 <option value="authorization">Authorization: Bearer</option>
731 </select>
732 </Field>
733 <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.">
734 <Input name="model" placeholder="claude-sonnet-5-5" />
735 </Field>
736 </>
737 ),
738 sentry: (
739 <>
740 <Field label="Organization" hint="Its slug, as in acme.sentry.io.">
741 <Input name="organization" required placeholder="acme" />
742 </Field>
743 <RepoField repos={repos} required hint="Where issues are opened." />
744 <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.">
745 <Input name="secret" type="password" />
746 </Field>
747 <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.">
748 <Input name="signingSecret" type="password" />
749 </Field>
750 <AssignField />
751 </>
752 ),
753 datadog: (
754 <>
755 <RepoField repos={repos} required hint="Where issues are opened." />
756 <AssignField />
757 </>
758 ),
759 webhook: (
760 <>
761 <RepoField repos={repos} required hint="Where issues are opened." />
762 <AssignField />
763 </>
764 ),
765 jira: (
766 <>
767 <Field label="Site" hint="Your Jira's address.">
768 <Input name="site" type="url" required placeholder="https://acme.atlassian.net" />
769 </Field>
770 <Field label="Email" hint="The account the API token belongs to.">
771 <Input name="email" type="email" required autoComplete="off" />
772 </Field>
773 <Field label="API token" hint="From id.atlassian.com → Security → API tokens.">
774 <Input name="secret" type="password" required />
775 </Field>
776 <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key.">
777 <Input name="keys" placeholder="TECH, OPS" />
778 </Field>
779 </>
780 ),
781 linear: (
782 <>
783 <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys.">
784 <Input name="secret" type="password" required placeholder="lin_api_…" />
785 </Field>
786 <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key.">
787 <Input name="keys" placeholder="ENG" />
788 </Field>
789 </>
790 ),
791 };
792 return (
793 <section id="add" className="mt-4 scroll-mt-20 rounded-xl border border-line bg-surface p-5">
794 <div className="flex items-center gap-3">
795 <ProviderMark provider={provider} />
796 <div className="grow">
797 <h3 className="font-medium">
798 Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label}
799 </h3>
800 <p className="text-xs text-muted">{PROVIDER_BLURB[provider]}</p>
801 </div>
802 <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
803 <X size={16} />
804 </Link>
805 </div>
806 <Form method="post" className="mt-5 grid max-w-xl gap-4">
807 <input type="hidden" name="provider" value={provider} />
808 {fields[provider]}
809 <ErrorText>{error}</ErrorText>
810 <div>
811 <Button type="submit" disabled={busy}>
812 {busy ? "Connecting…" : "Connect"}
813 </Button>
814 </div>
815 </Form>
816 </section>
817 );
818}
819
820/** What to do next, right after connecting: shown once. */
821function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) {
822 const { connection, signingSecret } = connected;
823 const url = connection.webhookUrl;
824 return (
825 <div className="mt-6 rounded-xl border border-accent-dim/60 bg-accent/5 p-5">
826 <p className="flex items-center gap-2 font-medium">
827 <CheckCircle2 size={16} className="text-accent" />
828 {connection.name} is connected.
829 </p>
830 {connection.provider === "sentry" && url && (
831 <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted">
832 <li>
833 In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal
834 integration.
835 </li>
836 <li>
837 Set its webhook URL to <CopyLine text={url} />
838 </li>
839 <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li>
840 <li>Give it Issue &amp; Event read and write, save, and paste its client secret below, on the connection.</li>
841 </ol>
842 )}
843 {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && (
844 <div className="mt-3 space-y-3 text-sm text-muted">
845 <p>This secret is shown once. Copy it now.</p>
846 <CopyLine text={signingSecret} />
847 {connection.provider === "datadog" ? (
848 <>
849 <p>
850 In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL:
851 </p>
852 <CopyLine text={url} />
853 <p>Custom headers:</p>
854 <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} />
855 <p>Payload:</p>
856 <CopyLine
857 text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`}
858 />
859 <p>
860 Then mention <code>@webhook-g1t</code> in any monitor's message.
861 </p>
862 </>
863 ) : (
864 <>
865 <p>Send JSON with at least a title to:</p>
866 <CopyLine text={url} />
867 <p>
868 with <code>Authorization: Bearer &lt;secret&gt;</code>, or an HMAC-SHA256 of the body in{" "}
869 <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue.
870 </p>
871 </>
872 )}
873 </div>
874 )}
875 {connection.kind === "tracker" && (
876 <p className="mt-2 text-sm text-muted">
877 Agents now read tickets that work mentions, and you can import one from any repository's new
878 issue page. Use Test to check the token.
879 </p>
880 )}
881 {connection.kind === "models" && (
882 <p className="mt-2 text-sm text-muted">
883 {connection.lastError
884 ? `It was saved, but the check failed: ${connection.lastError}`
885 : connection.models.length > 0
886 ? `It offers ${connection.models.length} models. Choose which work goes to it under “Which model does which work”.`
887 : "Choose which work goes to it under “Which model does which work”."}
888 </p>
889 )}
890 </div>
891 );
892}