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 read | 1 | import { |
| 2 | AlertTriangle, | |
| 3 | CheckCircle2, | |
| 4 | ChevronRight, | |
| 5 | Cpu, | |
| 6 | Siren, | |
| 7 | Ticket, | |
| 8 | X, | |
| 9 | } from "lucide-react"; | |
| Models per workspace: several providers, routed by kind of work | 10 | import { env } from "cloudflare:workers"; |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 11 | import type { ReactNode } from "react"; |
| Merge branch 'worktree-agent-a985c01f43538af01' | 12 | import { Form, Link } from "react-router"; |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 13 | |
| 14 | import { | |
| 15 | type Connection, | |
| 16 | type ConnectionConfig, | |
| 17 | type Delivery, | |
| Models per workspace: several providers, routed by kind of work | 18 | MODEL_TASKS, |
| 19 | type ModelRoute, | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 20 | type Provider, |
| 21 | type ProviderKind, | |
| 22 | PROVIDERS, | |
| AI Gateway: OpenAI's format, open models, and your own providers | 23 | gatewayPatterns, |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 24 | } from "@g1t/contracts"; |
| 25 | ||
| 26 | import type { Route } from "./+types/integrations"; | |
| Social cards for every page: og.g1t.sh | 27 | import { page } from "../../lib/meta"; |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 28 | import { trialClosed } from "../../lib/trial"; |
| AI Gateway: OpenAI's format, open models, and your own providers | 29 | import { GatewayModelsField, MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers"; |
| 30 | import { parseGatewayModels } from "../../lib/gateway"; | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 31 | import { Avatar, CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; |
| Workspace names and icons, and a component kit for every control | 32 | import { CheckboxOption } from "../../components/ui/checkbox"; |
| 33 | import { Combobox } from "../../components/ui/combobox"; | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 34 | import { billing, integrations, repos } from "../../lib/services.server"; |
| 35 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 36 | ||
| Social cards for every page: og.g1t.sh | 37 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 38 | return page(args, { title: `Integrations · ${params.owner} · g1t` }); | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 39 | } |
| 40 | ||
| 41 | const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS; | |
| 42 | ||
| 43 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 44 | const viewer = getViewer(context); | |
| 45 | const role = roleIn(viewer, params.owner); | |
| 46 | if (!role) throw new Response(null, { status: 404 }); | |
| 47 | const slug = params.owner.toLowerCase(); | |
| Models per workspace: several providers, routed by kind of work | 48 | const [connections, listed, account, access, routes] = await Promise.all([ |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 49 | integrations.list(slug, viewer), |
| 50 | repos.list(viewer, { namespace: slug }), | |
| 51 | billing.account(slug, viewer), | |
| Models per workspace: several providers, routed by kind of work | 52 | env.RUNNER.modelAccess(slug), |
| 53 | integrations.routes(slug, viewer), | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 54 | ]); |
| 55 | const all = unwrap(connections); | |
| 56 | // What each alert source has sent lately, to see it is wired up. | |
| 57 | const deliveries: Record<string, Delivery[]> = {}; | |
| 58 | await Promise.all( | |
| 59 | all | |
| 60 | .filter((connection) => connection.kind === "alerts") | |
| 61 | .map(async (connection) => { | |
| 62 | const found = await integrations.deliveries(slug, viewer, connection.id); | |
| 63 | deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : []; | |
| 64 | }), | |
| 65 | ); | |
| 66 | const adding = new URL(request.url).searchParams.get("add"); | |
| 67 | return { | |
| 68 | slug, | |
| 69 | role, | |
| 70 | connections: all, | |
| 71 | deliveries, | |
| 72 | repos: listed.map((repo) => `${repo.namespace}/${repo.name}`), | |
| 73 | adding: isProvider(adding) ? adding : null, | |
| Models per workspace: several providers, routed by kind of work | 74 | hostedOpen: access.hosted, |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 75 | hostedPreview: access.preview, |
| A free allowance on g1t's models, so anyone can try its agents | 76 | trial: access.trial, |
| Models per workspace: several providers, routed by kind of work | 77 | routes: routes.ok ? routes.value : [], |
| New workspaces are told to connect a model, before anything fails | 78 | free: account.ok ? Boolean(account.value.status.free) : false, |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 79 | marginPercent: account.ok ? account.value.marginPercent : 20, |
| 80 | }; | |
| 81 | } | |
| 82 | ||
| 83 | function text(form: FormData, name: string): string | undefined { | |
| 84 | const value = String(form.get(name) ?? "").trim(); | |
| 85 | return value || undefined; | |
| 86 | } | |
| 87 | ||
| 88 | /** The settings a form describes, for the provider it is for. */ | |
| 89 | function configFrom(form: FormData): ConnectionConfig { | |
| 90 | const keys = text(form, "keys"); | |
| 91 | return { | |
| 92 | repo: text(form, "repo"), | |
| 93 | assign: form.get("assign") === "on", | |
| 94 | label: text(form, "label"), | |
| 95 | writeBack: form.get("writeBack") !== "off", | |
| 96 | organization: text(form, "organization"), | |
| 97 | site: text(form, "site"), | |
| 98 | email: text(form, "email"), | |
| 99 | keys: keys ? keys.split(/[\s,]+/).filter(Boolean) : [], | |
| 100 | baseUrl: text(form, "baseUrl"), | |
| 101 | authHeader: text(form, "authHeader"), | |
| 102 | model: text(form, "model"), | |
| AI Gateway: OpenAI's format, open models, and your own providers | 103 | // Only a model provider's form has the field; empty means none. |
| 104 | gatewayModels: form.has("gatewayModels") ? parseGatewayModels(String(form.get("gatewayModels"))) : undefined, | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 105 | }; |
| 106 | } | |
| 107 | ||
| 108 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 109 | assertSameOrigin(request); | |
| 110 | const user = requireUser(context, request); | |
| 111 | const slug = params.owner.toLowerCase(); | |
| 112 | const form = await request.formData(); | |
| 113 | const intent = form.get("intent"); | |
| 114 | const id = String(form.get("id") ?? ""); | |
| 115 | if (intent === "disconnect") { | |
| 116 | const removed = await integrations.disconnect(user, slug, id); | |
| 117 | return { error: removed.ok ? null : removed.error.message }; | |
| 118 | } | |
| 119 | if (intent === "test") { | |
| 120 | const tested = await integrations.test(user, slug, id); | |
| 121 | return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message }; | |
| 122 | } | |
| Models per workspace: several providers, routed by kind of work | 123 | if (intent === "routes") { |
| 124 | const routes: ModelRoute[] = []; | |
| 125 | for (const task of MODEL_TASKS) { | |
| 126 | const choice = String(form.get(`route-${task}`) ?? ""); | |
| 127 | if (!choice) continue; | |
| 128 | if (choice === "g1t") routes.push({ task, connectionId: null, model: null }); | |
| Merge branch 'model-routing' | 129 | else if (choice.startsWith("g1t::")) routes.push({ task, connectionId: null, model: choice.slice("g1t::".length) || null }); |
| Models per workspace: several providers, routed by kind of work | 130 | else { |
| 131 | const [connectionId, model] = choice.split("::"); | |
| 132 | routes.push({ task, connectionId, model: model || null }); | |
| 133 | } | |
| 134 | } | |
| 135 | const saved = await integrations.setRoutes(user, slug, routes); | |
| 136 | return saved.ok ? { routed: true } : { error: saved.error.message }; | |
| 137 | } | |
| AI Gateway: OpenAI's format, open models, and your own providers | 138 | if (intent === "gateway") { |
| 139 | // A model provider's AI Gateway models, and a new key if one was given: | |
| 140 | // its other settings as they are. | |
| 141 | const listed = await integrations.list(slug, user); | |
| 142 | const connection = listed.ok ? listed.value.find((c) => c.id === id) : undefined; | |
| 143 | if (!connection) return { error: listed.ok ? "No such integration." : listed.error.message }; | |
| 144 | const updated = await integrations.update(user, slug, id, { | |
| 145 | config: { ...connection.config, gatewayModels: parseGatewayModels(String(form.get("gatewayModels") ?? "")) }, | |
| 146 | secret: text(form, "secret"), | |
| 147 | }); | |
| 148 | return updated.ok ? { updated: id } : { error: updated.error.message }; | |
| 149 | } | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 150 | if (intent === "update") { |
| 151 | const updated = await integrations.update(user, slug, id, { | |
| 152 | signingSecret: text(form, "signingSecret"), | |
| 153 | secret: text(form, "secret"), | |
| 154 | }); | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 155 | return updated.ok ? { updated: id } : { error: updated.error.message }; |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 156 | } |
| 157 | const provider = String(form.get("provider") ?? ""); | |
| 158 | if (!isProvider(provider)) return { error: "Choose what to connect." }; | |
| 159 | const connected = await integrations.connect(user, slug, { | |
| 160 | provider, | |
| 161 | name: text(form, "name"), | |
| 162 | config: configFrom(form), | |
| 163 | secret: text(form, "secret"), | |
| 164 | signingSecret: text(form, "signingSecret"), | |
| 165 | }); | |
| 166 | return connected.ok ? { connected: connected.value } : { error: connected.error.message, provider }; | |
| 167 | } | |
| 168 | ||
| 169 | const KIND_INFO: Record<ProviderKind, { title: string; icon: ReactNode; blurb: string }> = { | |
| 170 | models: { | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 171 | title: "Model providers", |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 172 | icon: <Cpu size={16} />, |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 173 | 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 read | 174 | }, |
| 175 | alerts: { | |
| 176 | title: "Alerts", | |
| 177 | icon: <Siren size={16} />, | |
| 178 | blurb: "Problems your monitoring finds become issues, and agents can start on them before anyone looks.", | |
| 179 | }, | |
| 180 | tracker: { | |
| 181 | title: "Trackers", | |
| 182 | icon: <Ticket size={16} />, | |
| 183 | blurb: "Tickets agents can read when work mentions them, that people can import as issues, and that hear back when the work lands.", | |
| 184 | }, | |
| 185 | }; | |
| 186 | ||
| A catalogue of model providers, and settings that feel like settings | 187 | const PROVIDER_BLURB: Partial<Record<Provider, string>> = { |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 188 | sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.", |
| 189 | datadog: "Monitors that trigger open issues. Recoveries are noted on them.", | |
| 190 | webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.", | |
| 191 | jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.", | |
| 192 | linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.", | |
| 193 | }; | |
| 194 | ||
| 195 | function dollars(micros: number): string { | |
| 196 | return `$${(micros / 1_000_000).toFixed(2)}`; | |
| 197 | } | |
| 198 | ||
| 199 | export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 200 | const { slug, role, connections, deliveries, repos: repoNames, adding, free, marginPercent, hostedOpen, hostedPreview, trial, routes } = |
| Models per workspace: several providers, routed by kind of work | 201 | loaderData; |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 202 | const owner = role === "owner"; |
| Models per workspace: several providers, routed by kind of work | 203 | const modelConnections = connections.filter((connection) => connection.kind === "models"); |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 204 | const justConnected = actionData && "connected" in actionData ? actionData.connected : null; |
| 205 | const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null; | |
| 206 | const error = (actionData && "error" in actionData ? actionData.error : null) ?? null; | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 207 | const updated = (actionData && "updated" in actionData ? actionData.updated : null) ?? null; |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 208 | |
| 209 | return ( | |
| A catalogue of model providers, and settings that feel like settings | 210 | <div> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 211 | {justConnected && <Connected connected={justConnected} />} |
| 212 | <div className="mt-4"> | |
| 213 | <ErrorText>{error}</ErrorText> | |
| 214 | </div> | |
| 215 | ||
| 216 | {/* Models -------------------------------------------------------------- */} | |
| A catalogue of model providers, and settings that feel like settings | 217 | <Section kind="models" first> |
| Models per workspace: several providers, routed by kind of work | 218 | {!hostedOpen && modelConnections.length === 0 && ( |
| 219 | <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4"> | |
| 220 | <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30"> | |
| 221 | <AlertTriangle size={16} /> | |
| 222 | </span> | |
| 223 | <p className="text-sm text-muted"> | |
| A free allowance on g1t's models, so anyone can try its agents | 224 | <span className="font-medium text-fg">Choose how your agents reach a model.</span>{" "} |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 225 | {hostedPreview |
| 226 | ? `During the preview, g1t's hosted models run only for a few invited workspaces, and ${slug} is not one of them yet.` | |
| 227 | : (trialClosed(trial, slug) ?? `g1t's hosted models are not open to ${slug}.`)}{" "} | |
| A free allowance on g1t's models, so anyone can try its agents | 228 | Connect a provider of your own below and your agents start at once, billed by that provider. |
| Models per workspace: several providers, routed by kind of work | 229 | </p> |
| 230 | </div> | |
| 231 | )} | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 232 | <Connections list={modelConnections} owner={owner} tested={tested} updated={updated} deliveries={deliveries} /> |
| A catalogue of model providers, and settings that feel like settings | 233 | {modelConnections.length === 0 && hostedOpen && ( |
| 234 | <p className="flex items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted"> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 235 | <CheckCircle2 size={15} className="shrink-0 text-accent" /> |
| A free allowance on g1t's models, so anyone can try its agents | 236 | {trial?.open |
| Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put | 237 | ? trial.granted |
| 238 | ? `All work runs on g1t's models, paid by ${slug}'s trial credit first: ${dollars(Math.max(0, trial.limitMicros - trial.usedMicros))} of ${dollars(trial.limitMicros)} left. Connect a provider of your own for more, or to choose models.` | |
| 239 | : `All work runs on g1t's models. ${slug} gets ${dollars(trial.limitMicros)} of trial credit the first time its agents work. Connect a provider of your own for more, or to choose models.` | |
| A free allowance on g1t's models, so anyone can try its agents | 240 | : free |
| 241 | ? "All work runs on g1t's models, free while g1t is being built out. Connect a provider of your own to choose models and pay for them there." | |
| Merge branch 'model-routing' | 242 | : "All work runs on g1t's models, charged to your AI credit at the provider's price plus the agent rate. Auto picks the model for each job; choose one per kind of work below, or connect a provider of your own and pay for its models there."} |
| A catalogue of model providers, and settings that feel like settings | 243 | </p> |
| 244 | )} | |
| Merge branch 'model-routing' | 245 | {(modelConnections.length > 0 || hostedOpen) && ( |
| Models per workspace: several providers, routed by kind of work | 246 | <Routing |
| Merge branch 'worktree-agent-a985c01f43538af01' | 247 | // Started again from what is saved whenever that changes, such as a provider disconnected. |
| 248 | key={JSON.stringify([routes, modelConnections.map((c) => c.id)])} | |
| Models per workspace: several providers, routed by kind of work | 249 | connections={modelConnections} |
| 250 | routes={routes} | |
| 251 | hostedOpen={hostedOpen} | |
| 252 | owner={owner} | |
| 253 | saved={actionData != null && "routed" in actionData} | |
| 254 | /> | |
| A catalogue of model providers, and settings that feel like settings | 255 | )} |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 256 | <p className="mt-3 text-xs text-faint"> |
| New workspaces are told to connect a model, before anything fails | 257 | On your own providers, their bills are yours.{" "} |
| 258 | {free | |
| Merge branch 'model-routing' | 259 | ? `g1t charges nothing while it is being built out; once pricing starts, each run's sandbox time at cost plus ${marginPercent}%, and the agent rate on the tokens it used.` |
| 260 | : `g1t charges each run's sandbox time at cost plus ${marginPercent}%, and the agent rate on the tokens it used, shown on Usage as "Agent rate, your own model key".`}{" "} | |
| New workspaces are told to connect a model, before anything fails | 261 | Keys go only from g1t's model proxy to the provider: the agent's |
| Models per workspace: several providers, routed by kind of work | 262 | sandbox holds a token that dies with the run. |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 263 | </p> |
| A catalogue of model providers, and settings that feel like settings | 264 | {owner && <ModelCatalog slug={slug} adding={adding} />} |
| Integrations: connect forms open under the tile that was chosen | 265 | {adding && PROVIDERS[adding].kind === "models" && owner && ( |
| 266 | <AddForm | |
| 267 | provider={adding} | |
| 268 | slug={slug} | |
| 269 | repos={repoNames} | |
| 270 | error={actionData && "provider" in actionData ? error : null} | |
| 271 | /> | |
| 272 | )} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 273 | </Section> |
| 274 | ||
| 275 | {/* Alerts -------------------------------------------------------------- */} | |
| 276 | <Section kind="alerts"> | |
| 277 | <Connections | |
| 278 | list={connections.filter((c) => c.kind === "alerts")} | |
| 279 | owner={owner} | |
| 280 | tested={tested} | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 281 | updated={updated} |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 282 | deliveries={deliveries} |
| 283 | /> | |
| 284 | {owner && <Choices kind="alerts" slug={slug} adding={adding} />} | |
| Integrations: connect forms open under the tile that was chosen | 285 | {adding && PROVIDERS[adding].kind === "alerts" && owner && ( |
| 286 | <AddForm | |
| 287 | provider={adding} | |
| 288 | slug={slug} | |
| 289 | repos={repoNames} | |
| 290 | error={actionData && "provider" in actionData ? error : null} | |
| 291 | /> | |
| 292 | )} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 293 | </Section> |
| 294 | ||
| 295 | {/* Trackers ------------------------------------------------------------ */} | |
| 296 | <Section kind="tracker"> | |
| 297 | <Connections | |
| 298 | list={connections.filter((c) => c.kind === "tracker")} | |
| 299 | owner={owner} | |
| 300 | tested={tested} | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 301 | updated={updated} |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 302 | deliveries={deliveries} |
| 303 | /> | |
| 304 | {owner && <Choices kind="tracker" slug={slug} adding={adding} />} | |
| Integrations: connect forms open under the tile that was chosen | 305 | {adding && PROVIDERS[adding].kind === "tracker" && owner && ( |
| 306 | <AddForm | |
| 307 | provider={adding} | |
| 308 | slug={slug} | |
| 309 | repos={repoNames} | |
| 310 | error={actionData && "provider" in actionData ? error : null} | |
| 311 | /> | |
| 312 | )} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 313 | </Section> |
| 314 | ||
| 315 | {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>} | |
| 316 | </div> | |
| 317 | ); | |
| Models per workspace: several providers, routed by kind of work | 318 | } |
| 319 | ||
| A catalogue of model providers, and settings that feel like settings | 320 | function Section({ kind, first, children }: { kind: ProviderKind; first?: boolean; children: ReactNode }) { |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 321 | const info = KIND_INFO[kind]; |
| 322 | return ( | |
| A catalogue of model providers, and settings that feel like settings | 323 | <section className={first ? "" : "mt-12"}> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 324 | <h3 className="flex items-center gap-2 text-sm font-medium"> |
| 325 | <span className="text-muted">{info.icon}</span> | |
| 326 | {info.title} | |
| 327 | </h3> | |
| 328 | <p className="mt-1 mb-4 text-sm text-muted">{info.blurb}</p> | |
| 329 | {children} | |
| 330 | </section> | |
| 331 | ); | |
| 332 | } | |
| 333 | ||
| 334 | function Connections({ | |
| 335 | list, | |
| 336 | owner, | |
| 337 | tested, | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 338 | updated, |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 339 | deliveries, |
| 340 | }: { | |
| 341 | list: Connection[]; | |
| 342 | owner: boolean; | |
| 343 | tested: { id: string; ok: boolean; message: string } | null; | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 344 | /** The connection whose secret was just saved. */ |
| 345 | updated: string | null; | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 346 | deliveries: Record<string, Delivery[]>; |
| 347 | }) { | |
| 348 | if (list.length === 0) return null; | |
| 349 | return ( | |
| 350 | <ul className="mb-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> | |
| 351 | {list.map((connection) => ( | |
| 352 | <li key={connection.id} className="p-4"> | |
| 353 | <ConnectionRow | |
| 354 | connection={connection} | |
| 355 | owner={owner} | |
| 356 | tested={tested} | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 357 | updated={updated === connection.id} |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 358 | deliveries={deliveries[connection.id] ?? []} |
| 359 | /> | |
| 360 | </li> | |
| 361 | ))} | |
| 362 | </ul> | |
| 363 | ); | |
| 364 | } | |
| 365 | ||
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 366 | /** Just the host of an address, which is what tells connections apart. */ |
| 367 | function host(url: string | undefined): string | undefined { | |
| 368 | if (!url) return undefined; | |
| 369 | try { | |
| 370 | return new URL(url).host; | |
| 371 | } catch { | |
| 372 | return url; | |
| 373 | } | |
| 374 | } | |
| 375 | ||
| Integrations: your own model provider, alerts that open issues, tickets agents read | 376 | function ConnectionRow({ |
| 377 | connection, | |
| 378 | owner, | |
| 379 | tested, | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 380 | updated, |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 381 | deliveries, |
| 382 | }: { | |
| 383 | connection: Connection; | |
| 384 | owner: boolean; | |
| 385 | tested: { id: string; ok: boolean; message: string } | null; | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 386 | updated: boolean; |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 387 | deliveries: Delivery[]; |
| 388 | }) { | |
| 389 | const { config } = connection; | |
| 390 | const facts = [ | |
| 391 | PROVIDERS[connection.provider].label, | |
| 392 | config.repo && `issues in ${config.repo}`, | |
| 393 | config.assign && "agents start at once", | |
| 394 | config.organization, | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 395 | host(config.site), |
| 396 | host(config.baseUrl), | |
| Models per workspace: several providers, routed by kind of work | 397 | config.model && `default ${config.model}`, |
| 398 | connection.models.length > 0 && `${connection.models.length} models`, | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 399 | config.keys?.length ? config.keys.join(", ") : null, |
| 400 | connection.secretHint && `key ${connection.secretHint}`, | |
| 401 | ].filter(Boolean); | |
| AI Gateway: OpenAI's format, open models, and your own providers | 402 | const gateway = connection.kind === "models" ? gatewayPatterns(connection.provider, config) : null; |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 403 | const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt; |
| 404 | return ( | |
| 405 | <div> | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 406 | <div className="flex items-center gap-3"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 407 | <ProviderMark provider={connection.provider} /> |
| 408 | <div className="min-w-0 grow"> | |
| 409 | <p className="truncate text-sm font-medium">{connection.name}</p> | |
| 410 | <p className="truncate text-xs text-muted">{facts.join(" · ")}</p> | |
| 411 | </div> | |
| 412 | {connection.lastUsedAt && ( | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 413 | <span className="hidden shrink-0 text-xs text-faint sm:inline"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 414 | Used <TimeAgo at={connection.lastUsedAt} /> |
| 415 | </span> | |
| 416 | )} | |
| 417 | {owner && ( | |
| Model providers: gateway tokens for endpoints, tidier rows, and the docs | 418 | <Form method="post" className="flex shrink-0 gap-2"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 419 | <input type="hidden" name="id" value={connection.id} /> |
| Merge branch 'worktree-agent-a985c01f43538af01' | 420 | <SubmitButton variant="quiet" name="intent" value="test" match={{ id: connection.id }} pending="Testing…"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 421 | Test |
| Merge branch 'worktree-agent-a985c01f43538af01' | 422 | </SubmitButton> |
| 423 | <SubmitButton variant="quiet" name="intent" value="disconnect" match={{ id: connection.id }} pending="Disconnecting…" aria-label="Disconnect"> | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 424 | <X size={14} /> |
| Merge branch 'worktree-agent-a985c01f43538af01' | 425 | </SubmitButton> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 426 | </Form> |
| 427 | )} | |
| 428 | </div> | |
| 429 | {tested?.id === connection.id && ( | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 430 | <p className={`mt-3 text-sm ${tested.ok ? "text-success" : "text-danger"}`}>{tested.message}</p> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 431 | )} |
| 432 | {connection.lastError && ( | |
| 433 | <p className="mt-3 flex items-start gap-2 text-xs text-warn"> | |
| 434 | <AlertTriangle size={13} className="mt-0.5 shrink-0" /> | |
| 435 | {connection.lastError} | |
| 436 | </p> | |
| 437 | )} | |
| AI Gateway: OpenAI's format, open models, and your own providers | 438 | {gateway && ( |
| 439 | <div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs text-muted"> | |
| 440 | <span className="text-faint">AI Gateway</span> | |
| 441 | {gateway.length === 0 ? ( | |
| 442 | <span>no models</span> | |
| 443 | ) : ( | |
| 444 | gateway.map((pattern) => ( | |
| 445 | <code key={pattern} className="rounded border border-line bg-bg px-1.5 py-0.5 font-mono text-[0.75rem] text-fg"> | |
| 446 | {pattern} | |
| 447 | </code> | |
| 448 | )) | |
| 449 | )} | |
| 450 | </div> | |
| 451 | )} | |
| 452 | {owner && gateway && ( | |
| 453 | <details className="group mt-3" open={updated || undefined}> | |
| 454 | <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 455 | <ChevronRight size={12} className="transition-transform group-open:rotate-90" /> | |
| 456 | Change its AI Gateway models or key | |
| 457 | </summary> | |
| 458 | <Form method="post" className="mt-3 max-w-2xl space-y-3"> | |
| 459 | <input type="hidden" name="intent" value="gateway" /> | |
| 460 | <input type="hidden" name="id" value={connection.id} /> | |
| 461 | <GatewayModelsField provider={connection.provider} value={gateway} /> | |
| 462 | <Field label="Replace the key" hint="Write-only: g1t never shows it again. Leave it empty to keep the key you have."> | |
| 463 | <Input name="secret" type="password" placeholder={connection.secretHint ? `Now ${connection.secretHint}` : "Paste a key"} autoComplete="new-password" className="max-w-sm" /> | |
| 464 | </Field> | |
| 465 | <div className="flex items-center gap-3"> | |
| 466 | <SubmitButton variant="quiet" match={{ intent: "gateway", id: connection.id }} pending="Saving…"> | |
| 467 | Save | |
| 468 | </SubmitButton> | |
| 469 | {updated && <span className="text-sm text-success">Saved.</span>} | |
| 470 | </div> | |
| 471 | </Form> | |
| 472 | </details> | |
| 473 | )} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 474 | {connection.webhookUrl && ( |
| 475 | <div className="mt-3"> | |
| 476 | <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p> | |
| 477 | <CopyLine text={connection.webhookUrl} /> | |
| 478 | </div> | |
| 479 | )} | |
| 480 | {owner && waitingForSecret && ( | |
| 481 | <Form method="post" className="mt-3 flex flex-wrap items-end gap-2"> | |
| 482 | <input type="hidden" name="intent" value="update" /> | |
| 483 | <input type="hidden" name="id" value={connection.id} /> | |
| 484 | <div className="min-w-64 grow"> | |
| 485 | <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."> | |
| 486 | <Input name="signingSecret" type="password" placeholder="Paste to replace" /> | |
| 487 | </Field> | |
| 488 | </div> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 489 | <SubmitButton variant="quiet" match={{ intent: "update", id: connection.id }} pending="Saving…"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 490 | Save |
| Merge branch 'worktree-agent-a985c01f43538af01' | 491 | </SubmitButton> |
| 492 | {updated && <span className="pb-2 text-sm text-muted">Saved.</span>} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 493 | </Form> |
| 494 | )} | |
| 495 | {deliveries.length > 0 && ( | |
| 496 | <ul className="mt-3 space-y-1 text-xs"> | |
| 497 | {deliveries.map((delivery) => ( | |
| 498 | <li key={delivery.id} className="flex items-center gap-2 text-muted"> | |
| 499 | <span | |
| 500 | className={`size-1.5 shrink-0 rounded-full ${ | |
| 501 | delivery.outcome === "refused" | |
| 502 | ? "bg-danger" | |
| 503 | : delivery.outcome === "ignored" | |
| 504 | ? "bg-faint" | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 505 | : "bg-success" |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 506 | }`} |
| 507 | /> | |
| 508 | <span className="font-mono text-faint">{delivery.event}</span> | |
| 509 | <span className="min-w-0 truncate">{delivery.detail}</span> | |
| 510 | {delivery.issue && ( | |
| 511 | <Link | |
| 512 | to={`/${delivery.issue.replace("#", "/issues/")}`} | |
| 513 | className="shrink-0 font-mono text-fg hover:underline" | |
| 514 | > | |
| 515 | {delivery.issue} | |
| 516 | </Link> | |
| 517 | )} | |
| 518 | <span className="ml-auto shrink-0 text-faint"> | |
| 519 | <TimeAgo at={delivery.receivedAt} /> | |
| 520 | </span> | |
| 521 | </li> | |
| 522 | ))} | |
| 523 | </ul> | |
| 524 | )} | |
| 525 | </div> | |
| 526 | ); | |
| 527 | } | |
| 528 | ||
| A catalogue of model providers, and settings that feel like settings | 529 | /** What a provider is for, in a line. */ |
| 530 | function blurb(provider: Provider): string { | |
| 531 | return PROVIDER_BLURB[provider] ?? MODEL_CATALOG[provider]?.blurb ?? ""; | |
| 532 | } | |
| 533 | ||
| Integrations: your own model provider, alerts that open issues, tickets agents read | 534 | function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) { |
| 535 | const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind); | |
| 536 | return ( | |
| A catalogue of model providers, and settings that feel like settings | 537 | <div className="mt-3"> |
| 538 | <ProviderTiles providers={providers} slug={slug} adding={adding} blurb={blurb} /> | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 539 | </div> |
| 540 | ); | |
| 541 | } | |
| 542 | ||
| 543 | function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) { | |
| 544 | return ( | |
| 545 | <Field label="Repository" hint={hint}> | |
| Workspace names and icons, and a component kit for every control | 546 | <Combobox |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 547 | name="repo" |
| 548 | defaultValue={repos[0] ?? ""} | |
| Workspace names and icons, and a component kit for every control | 549 | aria-label="Repository" |
| 550 | placeholder={repos.length === 0 ? "No repositories yet" : "Choose a repository"} | |
| 551 | searchPlaceholder="Find a repository" | |
| 552 | emptyText="No repository by that name." | |
| 553 | options={[ | |
| 554 | ...(required ? [] : [{ value: "", label: "None" }]), | |
| 555 | ...repos.map((repo) => ({ value: repo, label: repo, icon: <Avatar name={repo.split("/").pop() ?? repo} size={16} square /> })), | |
| 556 | ]} | |
| 557 | className="font-mono text-[0.8125rem]" | |
| 558 | /> | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 559 | </Field> |
| 560 | ); | |
| 561 | } | |
| 562 | ||
| 563 | function AssignField() { | |
| 564 | return ( | |
| Workspace names and icons, and a component kit for every control | 565 | <CheckboxOption |
| 566 | name="assign" | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 567 | label={<span className="font-medium">Assign each new issue to g1t</span>} |
| Workspace names and icons, and a component kit for every control | 568 | description={ |
| 569 | <span className="text-muted"> | |
| 570 | It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has to look. | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 571 | Agents run only where g1t is enabled. |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 572 | </span> |
| Workspace names and icons, and a component kit for every control | 573 | } |
| 574 | /> | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 575 | ); |
| 576 | } | |
| 577 | ||
| 578 | function AddForm({ | |
| 579 | provider, | |
| 580 | slug, | |
| 581 | repos, | |
| 582 | error, | |
| 583 | }: { | |
| 584 | provider: Provider; | |
| 585 | slug: string; | |
| 586 | repos: string[]; | |
| 587 | error: string | null; | |
| 588 | }) { | |
| A catalogue of model providers, and settings that feel like settings | 589 | const fields: Partial<Record<Provider, ReactNode>> = { |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 590 | sentry: ( |
| 591 | <> | |
| 592 | <Field label="Organization" hint="Its slug, as in acme.sentry.io."> | |
| 593 | <Input name="organization" required placeholder="acme" /> | |
| 594 | </Field> | |
| 595 | <RepoField repos={repos} required hint="Where issues are opened." /> | |
| 596 | <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."> | |
| 597 | <Input name="secret" type="password" /> | |
| 598 | </Field> | |
| 599 | <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."> | |
| 600 | <Input name="signingSecret" type="password" /> | |
| 601 | </Field> | |
| 602 | <AssignField /> | |
| 603 | </> | |
| 604 | ), | |
| 605 | datadog: ( | |
| 606 | <> | |
| 607 | <RepoField repos={repos} required hint="Where issues are opened." /> | |
| 608 | <AssignField /> | |
| 609 | </> | |
| 610 | ), | |
| 611 | webhook: ( | |
| 612 | <> | |
| 613 | <RepoField repos={repos} required hint="Where issues are opened." /> | |
| 614 | <AssignField /> | |
| 615 | </> | |
| 616 | ), | |
| 617 | jira: ( | |
| 618 | <> | |
| 619 | <Field label="Site" hint="Your Jira's address."> | |
| 620 | <Input name="site" type="url" required placeholder="https://acme.atlassian.net" /> | |
| 621 | </Field> | |
| 622 | <Field label="Email" hint="The account the API token belongs to."> | |
| 623 | <Input name="email" type="email" required autoComplete="off" /> | |
| 624 | </Field> | |
| 625 | <Field label="API token" hint="From id.atlassian.com → Security → API tokens."> | |
| 626 | <Input name="secret" type="password" required /> | |
| 627 | </Field> | |
| 628 | <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key."> | |
| 629 | <Input name="keys" placeholder="TECH, OPS" /> | |
| 630 | </Field> | |
| 631 | </> | |
| 632 | ), | |
| 633 | linear: ( | |
| 634 | <> | |
| 635 | <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys."> | |
| 636 | <Input name="secret" type="password" required placeholder="lin_api_…" /> | |
| 637 | </Field> | |
| 638 | <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key."> | |
| 639 | <Input name="keys" placeholder="ENG" /> | |
| 640 | </Field> | |
| 641 | </> | |
| 642 | ), | |
| 643 | }; | |
| 644 | return ( | |
| Integrations: connect forms open under the tile that was chosen | 645 | <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 read | 646 | <div className="flex items-center gap-3"> |
| 647 | <ProviderMark provider={provider} /> | |
| 648 | <div className="grow"> | |
| Integrations: connect forms open under the tile that was chosen | 649 | <h3 className="font-medium"> |
| Models per workspace: several providers, routed by kind of work | 650 | Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label} |
| Integrations: connect forms open under the tile that was chosen | 651 | </h3> |
| A catalogue of model providers, and settings that feel like settings | 652 | <p className="text-xs text-muted">{blurb(provider)}</p> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 653 | </div> |
| 654 | <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close"> | |
| 655 | <X size={16} /> | |
| 656 | </Link> | |
| 657 | </div> | |
| 658 | <Form method="post" className="mt-5 grid max-w-xl gap-4"> | |
| 659 | <input type="hidden" name="provider" value={provider} /> | |
| A catalogue of model providers, and settings that feel like settings | 660 | {fields[provider] ?? <ModelProviderFields provider={provider} />} |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 661 | <ErrorText>{error}</ErrorText> |
| 662 | <div> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 663 | <SubmitButton match={{ provider }} pending="Connecting…"> |
| 664 | Connect | |
| 665 | </SubmitButton> | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 666 | </div> |
| 667 | </Form> | |
| 668 | </section> | |
| 669 | ); | |
| 670 | } | |
| 671 | ||
| 672 | /** What to do next, right after connecting: shown once. */ | |
| 673 | function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) { | |
| 674 | const { connection, signingSecret } = connected; | |
| 675 | const url = connection.webhookUrl; | |
| 676 | return ( | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 677 | <div className="mt-6 rounded-xl border border-success-dim/60 bg-success/5 p-5"> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 678 | <p className="flex items-center gap-2 font-medium"> |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 679 | <CheckCircle2 size={16} className="text-success" /> |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 680 | {connection.name} is connected. |
| 681 | </p> | |
| 682 | {connection.provider === "sentry" && url && ( | |
| 683 | <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted"> | |
| 684 | <li> | |
| 685 | In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal | |
| 686 | integration. | |
| 687 | </li> | |
| 688 | <li> | |
| 689 | Set its webhook URL to <CopyLine text={url} /> | |
| 690 | </li> | |
| 691 | <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li> | |
| 692 | <li>Give it Issue & Event read and write, save, and paste its client secret below, on the connection.</li> | |
| 693 | </ol> | |
| 694 | )} | |
| 695 | {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && ( | |
| 696 | <div className="mt-3 space-y-3 text-sm text-muted"> | |
| 697 | <p>This secret is shown once. Copy it now.</p> | |
| 698 | <CopyLine text={signingSecret} /> | |
| 699 | {connection.provider === "datadog" ? ( | |
| 700 | <> | |
| 701 | <p> | |
| 702 | In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL: | |
| 703 | </p> | |
| 704 | <CopyLine text={url} /> | |
| 705 | <p>Custom headers:</p> | |
| 706 | <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} /> | |
| 707 | <p>Payload:</p> | |
| 708 | <CopyLine | |
| 709 | text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`} | |
| 710 | /> | |
| 711 | <p> | |
| 712 | Then mention <code>@webhook-g1t</code> in any monitor's message. | |
| 713 | </p> | |
| 714 | </> | |
| 715 | ) : ( | |
| 716 | <> | |
| 717 | <p>Send JSON with at least a title to:</p> | |
| 718 | <CopyLine text={url} /> | |
| 719 | <p> | |
| 720 | with <code>Authorization: Bearer <secret></code>, or an HMAC-SHA256 of the body in{" "} | |
| 721 | <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue. | |
| 722 | </p> | |
| 723 | </> | |
| 724 | )} | |
| 725 | </div> | |
| 726 | )} | |
| 727 | {connection.kind === "tracker" && ( | |
| 728 | <p className="mt-2 text-sm text-muted"> | |
| 729 | Agents now read tickets that work mentions, and you can import one from any repository's new | |
| 730 | issue page. Use Test to check the token. | |
| 731 | </p> | |
| 732 | )} | |
| 733 | {connection.kind === "models" && ( | |
| 734 | <p className="mt-2 text-sm text-muted"> | |
| Models per workspace: several providers, routed by kind of work | 735 | {connection.lastError |
| 736 | ? `It was saved, but the check failed: ${connection.lastError}` | |
| 737 | : connection.models.length > 0 | |
| 738 | ? `It offers ${connection.models.length} models. Choose which work goes to it under “Which model does which work”.` | |
| 739 | : "Choose which work goes to it under “Which model does which work”."} | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 740 | </p> |
| 741 | )} | |
| 742 | </div> | |
| 743 | ); | |
| 744 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.