| 1 | import { |
| 2 | AlertTriangle, |
| 3 | ArrowLeft, |
| 4 | CheckCircle2, |
| 5 | ChevronRight, |
| 6 | Cpu, |
| 7 | Siren, |
| 8 | Ticket, |
| 9 | X, |
| 10 | } from "lucide-react"; |
| 11 | import { type ReactNode, useState } from "react"; |
| 12 | import { Form, Link } from "react-router"; |
| 13 | |
| 14 | import { |
| 15 | type AskSetup, |
| 16 | type Connection, |
| 17 | type ConnectionConfig, |
| 18 | type Delivery, |
| 19 | type Provider, |
| 20 | type ProviderKind, |
| 21 | PROVIDERS, |
| 22 | } from "@g1t/contracts"; |
| 23 | |
| 24 | import type { Route } from "./+types/integrations"; |
| 25 | import { page } from "../../lib/meta"; |
| 26 | import { ProviderMark, ProviderTiles } from "../../components/connectors"; |
| 27 | import { ModelCatalog, ModelProviderFields } from "../../components/model-providers"; |
| 28 | import { AI_PROVIDERS } from "../../lib/ai-providers"; |
| 29 | import { integrationsSection, sectionKind } from "../../lib/integration-sections"; |
| 30 | import { CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; |
| 31 | import { Avatar } from "../../components/ui/avatar"; |
| 32 | import { Card } from "../../components/ui/card"; |
| 33 | import { Combobox } from "../../components/ui/combobox"; |
| 34 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../components/ui/select"; |
| 35 | import { integrations, repos } from "../../lib/services.server"; |
| 36 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; |
| 37 | |
| 38 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 39 | const kind = sectionKind(params.section); |
| 40 | return page(args, { title: `${kind ? KIND_INFO[kind].title : "Integrations"} · ${params.owner} · g1t` }); |
| 41 | } |
| 42 | |
| 43 | const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS; |
| 44 | |
| 45 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 46 | const viewer = getViewer(context); |
| 47 | const role = roleIn(viewer, params.owner); |
| 48 | // One kind of connection's page: the directory at `-/integrations` finds them. |
| 49 | const kind = sectionKind(params.section); |
| 50 | if (!role || !kind) throw new Response(null, { status: 404 }); |
| 51 | const slug = params.owner.toLowerCase(); |
| 52 | const [connections, listed, ask] = await Promise.all([ |
| 53 | integrations.list(slug, viewer), |
| 54 | repos.list(viewer, { namespace: slug }), |
| 55 | // What Ask g1t answers with, on the AI providers' page. |
| 56 | kind === "models" ? integrations.askSettings(slug, viewer) : null, |
| 57 | ]); |
| 58 | const all = unwrap(connections); |
| 59 | // What each alert source has sent lately, to see it is wired up. |
| 60 | const deliveries: Record<string, Delivery[]> = {}; |
| 61 | await Promise.all( |
| 62 | all |
| 63 | .filter((connection) => connection.kind === "alerts") |
| 64 | .map(async (connection) => { |
| 65 | const found = await integrations.deliveries(slug, viewer, connection.id); |
| 66 | deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : []; |
| 67 | }), |
| 68 | ); |
| 69 | const adding = new URL(request.url).searchParams.get("add"); |
| 70 | return { |
| 71 | slug, |
| 72 | role, |
| 73 | kind, |
| 74 | connections: all, |
| 75 | deliveries, |
| 76 | repos: listed.map((repo) => `${repo.namespace}/${repo.name}`), |
| 77 | adding: isProvider(adding) ? adding : null, |
| 78 | ask: ask?.ok ? ask.value : null, |
| 79 | }; |
| 80 | } |
| 81 | |
| 82 | function text(form: FormData, name: string): string | undefined { |
| 83 | const value = String(form.get(name) ?? "").trim(); |
| 84 | return value || undefined; |
| 85 | } |
| 86 | |
| 87 | /** The settings a form describes, for the provider it is for. */ |
| 88 | function configFrom(form: FormData): ConnectionConfig { |
| 89 | const keys = text(form, "keys"); |
| 90 | return { |
| 91 | repo: text(form, "repo"), |
| 92 | label: text(form, "label"), |
| 93 | writeBack: form.get("writeBack") !== "off", |
| 94 | organization: text(form, "organization"), |
| 95 | site: text(form, "site"), |
| 96 | email: text(form, "email"), |
| 97 | keys: keys ? keys.split(/[\s,]+/).filter(Boolean) : [], |
| 98 | baseUrl: text(form, "baseUrl"), |
| 99 | authHeader: text(form, "authHeader"), |
| 100 | model: text(form, "model"), |
| 101 | }; |
| 102 | } |
| 103 | |
| 104 | export async function action({ request, params, context }: Route.ActionArgs) { |
| 105 | assertSameOrigin(request); |
| 106 | const user = requireUser(context, request); |
| 107 | const slug = params.owner.toLowerCase(); |
| 108 | const form = await request.formData(); |
| 109 | const intent = form.get("intent"); |
| 110 | const id = String(form.get("id") ?? ""); |
| 111 | if (intent === "disconnect") { |
| 112 | const removed = await integrations.disconnect(user, slug, id); |
| 113 | return { error: removed.ok ? null : removed.error.message }; |
| 114 | } |
| 115 | if (intent === "test") { |
| 116 | const tested = await integrations.test(user, slug, id); |
| 117 | return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message }; |
| 118 | } |
| 119 | if (intent === "update") { |
| 120 | const updated = await integrations.update(user, slug, id, { |
| 121 | signingSecret: text(form, "signingSecret"), |
| 122 | secret: text(form, "secret"), |
| 123 | }); |
| 124 | return updated.ok ? { updated: id } : { error: updated.error.message }; |
| 125 | } |
| 126 | if (intent === "ask") { |
| 127 | // Which provider and model Ask g1t answers with; none turns it off. |
| 128 | const connection = text(form, "connection"); |
| 129 | const chosen = await integrations.setAskSettings(user, slug, connection ?? null, connection ? (text(form, "model") ?? null) : null); |
| 130 | return chosen.ok ? { asked: true } : { error: chosen.error.message, ask: true }; |
| 131 | } |
| 132 | if (intent === "provider") { |
| 133 | // An AI provider's model and address, and a new key if one was given: |
| 134 | // its other settings as they are. |
| 135 | const listed = await integrations.list(slug, user); |
| 136 | const connection = listed.ok ? listed.value.find((c) => c.id === id) : undefined; |
| 137 | if (!connection) return { error: listed.ok ? "No such integration." : listed.error.message }; |
| 138 | const updated = await integrations.update(user, slug, id, { |
| 139 | config: { |
| 140 | ...connection.config, |
| 141 | model: text(form, "model"), |
| 142 | ...(form.has("baseUrl") ? { baseUrl: text(form, "baseUrl") } : {}), |
| 143 | }, |
| 144 | secret: text(form, "secret"), |
| 145 | }); |
| 146 | return updated.ok ? { updated: id } : { error: updated.error.message }; |
| 147 | } |
| 148 | const provider = String(form.get("provider") ?? ""); |
| 149 | if (!isProvider(provider)) return { error: "Choose what to connect." }; |
| 150 | const connected = await integrations.connect(user, slug, { |
| 151 | provider, |
| 152 | name: text(form, "name"), |
| 153 | config: configFrom(form), |
| 154 | secret: text(form, "secret"), |
| 155 | signingSecret: text(form, "signingSecret"), |
| 156 | }); |
| 157 | return connected.ok ? { connected: connected.value } : { error: connected.error.message, provider }; |
| 158 | } |
| 159 | |
| 160 | const KIND_INFO: Record<ProviderKind, { title: string; icon: ReactNode; blurb: string }> = { |
| 161 | models: { |
| 162 | title: "Your AI providers", |
| 163 | icon: <Cpu size={16} />, |
| 164 | blurb: "Connect the AI providers your team uses, so g1t can hand work to them.", |
| 165 | }, |
| 166 | alerts: { |
| 167 | title: "Alerts", |
| 168 | icon: <Siren size={16} />, |
| 169 | blurb: "Problems your monitoring finds become issues, with what was seen.", |
| 170 | }, |
| 171 | tracker: { |
| 172 | title: "Trackers", |
| 173 | icon: <Ticket size={16} />, |
| 174 | blurb: "Tickets people can import as issues, and that hear back when the work lands.", |
| 175 | }, |
| 176 | }; |
| 177 | |
| 178 | const PROVIDER_BLURB: Partial<Record<Provider, string>> = { |
| 179 | sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.", |
| 180 | datadog: "Monitors that trigger open issues. Recoveries are noted on them.", |
| 181 | webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.", |
| 182 | jira: "Import tickets such as TECH-1234 as issues; they hear back when the work lands.", |
| 183 | linear: "Import issues such as ENG-42; they hear back when the work lands.", |
| 184 | }; |
| 185 | |
| 186 | export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) { |
| 187 | const { slug, role, kind, connections, deliveries, repos: repoNames, adding, ask } = loaderData; |
| 188 | const owner = role === "owner"; |
| 189 | const justConnected = actionData && "connected" in actionData ? actionData.connected : null; |
| 190 | const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null; |
| 191 | const error = (actionData && "error" in actionData ? actionData.error : null) ?? null; |
| 192 | const updated = (actionData && "updated" in actionData ? actionData.updated : null) ?? null; |
| 193 | const asked = actionData != null && "asked" in actionData; |
| 194 | const askError = actionData && "ask" in actionData ? error : null; |
| 195 | |
| 196 | return ( |
| 197 | <div> |
| 198 | <Link to={`/${slug}/-/integrations`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> |
| 199 | <ArrowLeft size={14} /> All integrations |
| 200 | </Link> |
| 201 | {justConnected && <Connected connected={justConnected} />} |
| 202 | <div className="mt-4"> |
| 203 | <ErrorText>{actionData && "ask" in actionData ? null : error}</ErrorText> |
| 204 | </div> |
| 205 | |
| 206 | {/* AI providers -------------------------------------------------------- */} |
| 207 | {kind === "models" && ( |
| 208 | <Section kind="models"> |
| 209 | <Connections |
| 210 | list={connections.filter((c) => c.kind === "models")} |
| 211 | owner={owner} |
| 212 | tested={tested} |
| 213 | updated={updated} |
| 214 | deliveries={deliveries} |
| 215 | /> |
| 216 | <p className="mb-4 text-xs text-faint"> |
| 217 | Keys are sealed when saved and never shown again. Only an owner of {slug} can add, test, change or remove them. |
| 218 | </p> |
| 219 | <AskSetting |
| 220 | connections={connections.filter((c) => c.kind === "models")} |
| 221 | setup={ask} |
| 222 | owner={owner} |
| 223 | saved={asked} |
| 224 | error={askError} |
| 225 | /> |
| 226 | {owner && <ModelCatalog slug={slug} adding={adding} />} |
| 227 | {adding && PROVIDERS[adding].kind === "models" && owner && ( |
| 228 | <AddForm |
| 229 | provider={adding} |
| 230 | slug={slug} |
| 231 | repos={repoNames} |
| 232 | error={actionData && "provider" in actionData ? error : null} |
| 233 | /> |
| 234 | )} |
| 235 | </Section> |
| 236 | )} |
| 237 | |
| 238 | {/* Alerts -------------------------------------------------------------- */} |
| 239 | {kind === "alerts" && ( |
| 240 | <Section kind="alerts"> |
| 241 | <Connections |
| 242 | list={connections.filter((c) => c.kind === "alerts")} |
| 243 | owner={owner} |
| 244 | tested={tested} |
| 245 | updated={updated} |
| 246 | deliveries={deliveries} |
| 247 | /> |
| 248 | {owner && <Choices kind="alerts" slug={slug} adding={adding} />} |
| 249 | {adding && PROVIDERS[adding].kind === "alerts" && owner && ( |
| 250 | <AddForm |
| 251 | provider={adding} |
| 252 | slug={slug} |
| 253 | repos={repoNames} |
| 254 | error={actionData && "provider" in actionData ? error : null} |
| 255 | /> |
| 256 | )} |
| 257 | </Section> |
| 258 | )} |
| 259 | |
| 260 | {/* Trackers ------------------------------------------------------------ */} |
| 261 | {kind === "tracker" && ( |
| 262 | <Section kind="tracker"> |
| 263 | <Connections |
| 264 | list={connections.filter((c) => c.kind === "tracker")} |
| 265 | owner={owner} |
| 266 | tested={tested} |
| 267 | updated={updated} |
| 268 | deliveries={deliveries} |
| 269 | /> |
| 270 | {owner && <Choices kind="tracker" slug={slug} adding={adding} />} |
| 271 | {adding && PROVIDERS[adding].kind === "tracker" && owner && ( |
| 272 | <AddForm |
| 273 | provider={adding} |
| 274 | slug={slug} |
| 275 | repos={repoNames} |
| 276 | error={actionData && "provider" in actionData ? error : null} |
| 277 | /> |
| 278 | )} |
| 279 | </Section> |
| 280 | )} |
| 281 | |
| 282 | {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>} |
| 283 | </div> |
| 284 | ); |
| 285 | } |
| 286 | |
| 287 | /** One kind of connection's page: its heading, then what is connected and what to connect. */ |
| 288 | function Section({ kind, children }: { kind: ProviderKind; children: ReactNode }) { |
| 289 | const info = KIND_INFO[kind]; |
| 290 | return ( |
| 291 | <section className="mt-4"> |
| 292 | <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight [&_svg]:size-5"> |
| 293 | <span className="text-muted">{info.icon}</span> |
| 294 | {info.title} |
| 295 | </h1> |
| 296 | <p className="mt-1.5 mb-6 border-b border-line pb-6 text-sm text-muted">{info.blurb}</p> |
| 297 | {children} |
| 298 | </section> |
| 299 | ); |
| 300 | } |
| 301 | |
| 302 | function Connections({ |
| 303 | list, |
| 304 | owner, |
| 305 | tested, |
| 306 | updated, |
| 307 | deliveries, |
| 308 | }: { |
| 309 | list: Connection[]; |
| 310 | owner: boolean; |
| 311 | tested: { id: string; ok: boolean; message: string } | null; |
| 312 | /** The connection whose secret was just saved. */ |
| 313 | updated: string | null; |
| 314 | deliveries: Record<string, Delivery[]>; |
| 315 | }) { |
| 316 | if (list.length === 0) return null; |
| 317 | return ( |
| 318 | <Card asChild divided className="mb-4 overflow-hidden"> |
| 319 | <ul> |
| 320 | {list.map((connection) => ( |
| 321 | <li key={connection.id} className="p-4"> |
| 322 | <ConnectionRow |
| 323 | connection={connection} |
| 324 | owner={owner} |
| 325 | tested={tested} |
| 326 | updated={updated === connection.id} |
| 327 | deliveries={deliveries[connection.id] ?? []} |
| 328 | /> |
| 329 | </li> |
| 330 | ))} |
| 331 | </ul> |
| 332 | </Card> |
| 333 | ); |
| 334 | } |
| 335 | |
| 336 | /** Just the host of an address, which is what tells connections apart. */ |
| 337 | function host(url: string | undefined): string | undefined { |
| 338 | if (!url) return undefined; |
| 339 | try { |
| 340 | return new URL(url).host; |
| 341 | } catch { |
| 342 | return url; |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | function ConnectionRow({ |
| 347 | connection, |
| 348 | owner, |
| 349 | tested, |
| 350 | updated, |
| 351 | deliveries, |
| 352 | }: { |
| 353 | connection: Connection; |
| 354 | owner: boolean; |
| 355 | tested: { id: string; ok: boolean; message: string } | null; |
| 356 | updated: boolean; |
| 357 | deliveries: Delivery[]; |
| 358 | }) { |
| 359 | const { config } = connection; |
| 360 | const facts = [ |
| 361 | PROVIDERS[connection.provider].label, |
| 362 | config.repo && `issues in ${config.repo}`, |
| 363 | config.organization, |
| 364 | host(config.site), |
| 365 | host(config.baseUrl), |
| 366 | config.model && `model ${config.model}`, |
| 367 | connection.models.length > 0 && `${connection.models.length} models`, |
| 368 | config.keys?.length ? config.keys.join(", ") : null, |
| 369 | connection.secretHint && `key ${connection.secretHint}`, |
| 370 | ].filter(Boolean); |
| 371 | const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt; |
| 372 | return ( |
| 373 | <div> |
| 374 | <div className="flex items-center gap-3"> |
| 375 | <ProviderMark provider={connection.provider} /> |
| 376 | <div className="min-w-0 grow"> |
| 377 | <p className="truncate text-sm font-medium">{connection.name}</p> |
| 378 | <p className="truncate text-xs text-muted">{facts.join(" · ")}</p> |
| 379 | </div> |
| 380 | {connection.lastUsedAt && ( |
| 381 | <span className="hidden shrink-0 text-xs text-faint sm:inline"> |
| 382 | Used <TimeAgo at={connection.lastUsedAt} /> |
| 383 | </span> |
| 384 | )} |
| 385 | {owner && ( |
| 386 | <Form method="post" className="flex shrink-0 gap-2"> |
| 387 | <input type="hidden" name="id" value={connection.id} /> |
| 388 | <SubmitButton variant="outline" name="intent" value="test" match={{ id: connection.id }} pending="Testing…"> |
| 389 | Test |
| 390 | </SubmitButton> |
| 391 | <SubmitButton variant="outline" name="intent" value="disconnect" match={{ id: connection.id }} pending="Disconnecting…" aria-label="Disconnect"> |
| 392 | <X size={14} /> |
| 393 | </SubmitButton> |
| 394 | </Form> |
| 395 | )} |
| 396 | </div> |
| 397 | {tested?.id === connection.id && ( |
| 398 | <p className={`mt-3 text-sm ${tested.ok ? "text-success" : "text-danger"}`}>{tested.message}</p> |
| 399 | )} |
| 400 | {connection.lastError && ( |
| 401 | <p className="mt-3 flex items-start gap-2 text-xs text-warn"> |
| 402 | <AlertTriangle size={13} className="mt-0.5 shrink-0" /> |
| 403 | {connection.lastError} |
| 404 | </p> |
| 405 | )} |
| 406 | {owner && connection.kind === "models" && <ProviderSettings connection={connection} updated={updated} />} |
| 407 | {connection.webhookUrl && ( |
| 408 | <div className="mt-3"> |
| 409 | <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p> |
| 410 | <CopyLine text={connection.webhookUrl} /> |
| 411 | </div> |
| 412 | )} |
| 413 | {owner && waitingForSecret && ( |
| 414 | <Form method="post" className="mt-3 flex flex-wrap items-end gap-2"> |
| 415 | <input type="hidden" name="intent" value="update" /> |
| 416 | <input type="hidden" name="id" value={connection.id} /> |
| 417 | <div className="min-w-64 grow"> |
| 418 | <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."> |
| 419 | <Input name="signingSecret" type="password" placeholder="Paste to replace" /> |
| 420 | </Field> |
| 421 | </div> |
| 422 | <SubmitButton variant="outline" match={{ intent: "update", id: connection.id }} pending="Saving…"> |
| 423 | Save |
| 424 | </SubmitButton> |
| 425 | {updated && <span className="pb-2 text-sm text-muted">Saved.</span>} |
| 426 | </Form> |
| 427 | )} |
| 428 | {deliveries.length > 0 && ( |
| 429 | <ul className="mt-3 space-y-1 text-xs"> |
| 430 | {deliveries.map((delivery) => ( |
| 431 | <li key={delivery.id} className="flex items-center gap-2 text-muted"> |
| 432 | <span |
| 433 | className={`size-1.5 shrink-0 rounded-full ${ |
| 434 | delivery.outcome === "refused" |
| 435 | ? "bg-danger" |
| 436 | : delivery.outcome === "ignored" |
| 437 | ? "bg-faint" |
| 438 | : "bg-success" |
| 439 | }`} |
| 440 | /> |
| 441 | <span className="font-mono text-faint">{delivery.event}</span> |
| 442 | <span className="min-w-0 truncate">{delivery.detail}</span> |
| 443 | {delivery.issue && ( |
| 444 | <Link |
| 445 | to={`/${delivery.issue.replace("#", "/issues/")}`} |
| 446 | className="shrink-0 font-mono text-fg hover:underline" |
| 447 | > |
| 448 | {delivery.issue} |
| 449 | </Link> |
| 450 | )} |
| 451 | <span className="ml-auto shrink-0 text-faint"> |
| 452 | <TimeAgo at={delivery.receivedAt} /> |
| 453 | </span> |
| 454 | </li> |
| 455 | ))} |
| 456 | </ul> |
| 457 | )} |
| 458 | </div> |
| 459 | ); |
| 460 | } |
| 461 | |
| 462 | /** |
| 463 | * Ask g1t's provider and model: one of the workspace's AI providers, and |
| 464 | * one of the models it offered when last checked (or any name, for one |
| 465 | * that lists none). Owners choose; members see what it is. |
| 466 | */ |
| 467 | function AskSetting({ |
| 468 | connections, |
| 469 | setup, |
| 470 | owner, |
| 471 | saved, |
| 472 | error, |
| 473 | }: { |
| 474 | connections: Connection[]; |
| 475 | setup: AskSetup | null; |
| 476 | owner: boolean; |
| 477 | saved: boolean; |
| 478 | error: string | null; |
| 479 | }) { |
| 480 | const current = connections.find((c) => c.id === setup?.connection_id) ?? null; |
| 481 | const [chosen, setChosen] = useState(current?.id ?? connections[0]?.id ?? ""); |
| 482 | const connection = connections.find((c) => c.id === chosen) ?? null; |
| 483 | const azure = connection?.provider === "azure_openai"; |
| 484 | const defaultModel = |
| 485 | connection && connection.id === current?.id ? (setup?.model ?? "") : (connection?.config.model ?? connection?.models[0] ?? ""); |
| 486 | return ( |
| 487 | <Card asChild className="mb-6 scroll-mt-20 p-4"> |
| 488 | <section id="ask" aria-labelledby="ask-heading"> |
| 489 | <h2 id="ask-heading" className="text-sm font-medium"> |
| 490 | Ask g1t |
| 491 | </h2> |
| 492 | <p className="mt-1 text-xs text-muted"> |
| 493 | The top bar's Ask answers with this provider and model, on your workspace's own key. g1t hosts no model for it and |
| 494 | bills nothing for it: what it costs is on your provider's bill. |
| 495 | </p> |
| 496 | {setup?.connection_id && !setup.problem && ( |
| 497 | <p className="mt-3 text-sm"> |
| 498 | Answers with <span className="font-medium">{setup.connection_name}</span> ·{" "} |
| 499 | <span className="font-mono text-[0.8125rem]">{setup.model}</span> |
| 500 | </p> |
| 501 | )} |
| 502 | {setup?.problem && <p className="mt-3 text-sm text-warn">{setup.problem}</p>} |
| 503 | {connections.length === 0 ? ( |
| 504 | <p className="mt-3 text-sm text-muted"> |
| 505 | {owner ? "Connect an AI provider below, then choose it here." : `An owner of the workspace can connect one and choose it here.`} |
| 506 | </p> |
| 507 | ) : ( |
| 508 | owner && ( |
| 509 | <Form method="post" className="mt-4 grid max-w-2xl gap-3 sm:grid-cols-[1fr_1fr_auto] sm:items-end"> |
| 510 | <input type="hidden" name="intent" value="ask" /> |
| 511 | <Field label="Provider"> |
| 512 | <Select name="connection" value={chosen} onValueChange={setChosen}> |
| 513 | <SelectTrigger aria-label="Provider"> |
| 514 | <SelectValue /> |
| 515 | </SelectTrigger> |
| 516 | <SelectContent> |
| 517 | {connections.map((c) => ( |
| 518 | <SelectItem key={c.id} value={c.id} description={PROVIDERS[c.provider].label}> |
| 519 | {c.name} |
| 520 | </SelectItem> |
| 521 | ))} |
| 522 | </SelectContent> |
| 523 | </Select> |
| 524 | </Field> |
| 525 | <Field label={azure ? "Deployment" : "Model"}> |
| 526 | {connection && connection.models.length > 0 && !azure ? ( |
| 527 | <Combobox |
| 528 | key={connection.id} |
| 529 | name="model" |
| 530 | defaultValue={connection.models.includes(defaultModel) ? defaultModel : (connection.models[0] ?? "")} |
| 531 | aria-label="Model" |
| 532 | searchPlaceholder="Find a model" |
| 533 | emptyText="It offers no model by that name." |
| 534 | options={connection.models.map((model) => ({ value: model, label: model }))} |
| 535 | className="font-mono text-[0.8125rem]" |
| 536 | /> |
| 537 | ) : ( |
| 538 | <Input |
| 539 | key={connection?.id} |
| 540 | name="model" |
| 541 | required |
| 542 | defaultValue={defaultModel} |
| 543 | placeholder={azure ? "The deployment's name" : "The model's name"} |
| 544 | className="font-mono text-[0.8125rem]" |
| 545 | autoComplete="off" |
| 546 | /> |
| 547 | )} |
| 548 | </Field> |
| 549 | <div className="flex items-center gap-2"> |
| 550 | <SubmitButton variant="outline" match={{ intent: "ask" }} pending="Saving…"> |
| 551 | Use for Ask |
| 552 | </SubmitButton> |
| 553 | </div> |
| 554 | </Form> |
| 555 | ) |
| 556 | )} |
| 557 | {owner && setup?.connection_id && ( |
| 558 | <Form method="post" className="mt-2"> |
| 559 | <input type="hidden" name="intent" value="ask" /> |
| 560 | <SubmitButton variant="link" size="inline" match={{ intent: "ask" }} className="text-xs text-muted"> |
| 561 | Turn Ask off |
| 562 | </SubmitButton> |
| 563 | </Form> |
| 564 | )} |
| 565 | {saved && !error && <p className="mt-2 text-sm text-success">Saved.</p>} |
| 566 | <ErrorText>{error}</ErrorText> |
| 567 | </section> |
| 568 | </Card> |
| 569 | ); |
| 570 | } |
| 571 | |
| 572 | /** What a provider is for, in a line. */ |
| 573 | function blurb(provider: Provider): string { |
| 574 | return PROVIDER_BLURB[provider] ?? AI_PROVIDERS[provider]?.blurb ?? ""; |
| 575 | } |
| 576 | |
| 577 | /** Changing an AI provider after it is added: its model, its address, a new key. */ |
| 578 | function ProviderSettings({ connection, updated }: { connection: Connection; updated: boolean }) { |
| 579 | const azure = connection.provider === "azure_openai"; |
| 580 | const ownAddress = azure || connection.provider.endsWith("_endpoint"); |
| 581 | return ( |
| 582 | <details className="group mt-3" open={updated || undefined}> |
| 583 | <summary className="inline-flex cursor-pointer list-none items-center gap-1 text-xs text-muted hover:text-fg"> |
| 584 | <ChevronRight size={12} className="transition-transform group-open:rotate-90" /> |
| 585 | Change its model, address or key |
| 586 | </summary> |
| 587 | <Form method="post" className="mt-3 max-w-2xl space-y-3"> |
| 588 | <input type="hidden" name="intent" value="provider" /> |
| 589 | <input type="hidden" name="id" value={connection.id} /> |
| 590 | {ownAddress && ( |
| 591 | <Field label="Base URL"> |
| 592 | <Input name="baseUrl" type="url" required defaultValue={connection.config.baseUrl ?? ""} className="max-w-sm" /> |
| 593 | </Field> |
| 594 | )} |
| 595 | <Field label={azure ? "Deployment" : "Model"} hint={azure ? undefined : "Optional. The model your team uses on it."}> |
| 596 | <Input |
| 597 | name="model" |
| 598 | required={azure} |
| 599 | defaultValue={connection.config.model ?? ""} |
| 600 | list={connection.models.length > 0 ? `models-${connection.id}` : undefined} |
| 601 | className="max-w-sm font-mono text-[0.8125rem]" |
| 602 | autoComplete="off" |
| 603 | /> |
| 604 | </Field> |
| 605 | {connection.models.length > 0 && ( |
| 606 | <datalist id={`models-${connection.id}`}> |
| 607 | {connection.models.map((model) => ( |
| 608 | <option key={model} value={model} /> |
| 609 | ))} |
| 610 | </datalist> |
| 611 | )} |
| 612 | <Field label="Replace the key" hint="Write-only: g1t never shows it again. Leave it empty to keep the key you have."> |
| 613 | <Input |
| 614 | name="secret" |
| 615 | type="password" |
| 616 | placeholder={connection.secretHint ? `Now ${connection.secretHint}` : "Paste a key"} |
| 617 | autoComplete="new-password" |
| 618 | className="max-w-sm" |
| 619 | /> |
| 620 | </Field> |
| 621 | <div className="flex items-center gap-3"> |
| 622 | <SubmitButton variant="outline" match={{ intent: "provider", id: connection.id }} pending="Saving…"> |
| 623 | Save |
| 624 | </SubmitButton> |
| 625 | {updated && <span className="text-sm text-success">Saved.</span>} |
| 626 | </div> |
| 627 | </Form> |
| 628 | </details> |
| 629 | ); |
| 630 | } |
| 631 | |
| 632 | function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) { |
| 633 | const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind); |
| 634 | return ( |
| 635 | <div className="mt-3"> |
| 636 | <ProviderTiles providers={providers} slug={slug} adding={adding} blurb={blurb} /> |
| 637 | </div> |
| 638 | ); |
| 639 | } |
| 640 | |
| 641 | function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) { |
| 642 | return ( |
| 643 | <Field label="Repository" hint={hint}> |
| 644 | <Combobox |
| 645 | name="repo" |
| 646 | defaultValue={repos[0] ?? ""} |
| 647 | aria-label="Repository" |
| 648 | placeholder={repos.length === 0 ? "No repositories yet" : "Choose a repository"} |
| 649 | searchPlaceholder="Find a repository" |
| 650 | emptyText="No repository by that name." |
| 651 | options={[ |
| 652 | ...(required ? [] : [{ value: "", label: "None" }]), |
| 653 | ...repos.map((repo) => ({ value: repo, label: repo, icon: <Avatar name={repo.split("/").pop() ?? repo} size={16} square /> })), |
| 654 | ]} |
| 655 | className="font-mono text-[0.8125rem]" |
| 656 | /> |
| 657 | </Field> |
| 658 | ); |
| 659 | } |
| 660 | |
| 661 | function AddForm({ |
| 662 | provider, |
| 663 | slug, |
| 664 | repos, |
| 665 | error, |
| 666 | }: { |
| 667 | provider: Provider; |
| 668 | slug: string; |
| 669 | repos: string[]; |
| 670 | error: string | null; |
| 671 | }) { |
| 672 | const fields: Partial<Record<Provider, ReactNode>> = { |
| 673 | sentry: ( |
| 674 | <> |
| 675 | <Field label="Organization" hint="Its slug, as in acme.sentry.io."> |
| 676 | <Input name="organization" required placeholder="acme" /> |
| 677 | </Field> |
| 678 | <RepoField repos={repos} required hint="Where issues are opened." /> |
| 679 | <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."> |
| 680 | <Input name="secret" type="password" /> |
| 681 | </Field> |
| 682 | <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."> |
| 683 | <Input name="signingSecret" type="password" /> |
| 684 | </Field> |
| 685 | </> |
| 686 | ), |
| 687 | datadog: ( |
| 688 | <> |
| 689 | <RepoField repos={repos} required hint="Where issues are opened." /> |
| 690 | </> |
| 691 | ), |
| 692 | webhook: ( |
| 693 | <> |
| 694 | <RepoField repos={repos} required hint="Where issues are opened." /> |
| 695 | </> |
| 696 | ), |
| 697 | jira: ( |
| 698 | <> |
| 699 | <Field label="Site" hint="Your Jira's address."> |
| 700 | <Input name="site" type="url" required placeholder="https://acme.atlassian.net" /> |
| 701 | </Field> |
| 702 | <Field label="Email" hint="The account the API token belongs to."> |
| 703 | <Input name="email" type="email" required autoComplete="off" /> |
| 704 | </Field> |
| 705 | <Field label="API token" hint="From id.atlassian.com → Security → API tokens."> |
| 706 | <Input name="secret" type="password" required /> |
| 707 | </Field> |
| 708 | <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key."> |
| 709 | <Input name="keys" placeholder="TECH, OPS" /> |
| 710 | </Field> |
| 711 | </> |
| 712 | ), |
| 713 | linear: ( |
| 714 | <> |
| 715 | <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys."> |
| 716 | <Input name="secret" type="password" required placeholder="lin_api_…" /> |
| 717 | </Field> |
| 718 | <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key."> |
| 719 | <Input name="keys" placeholder="ENG" /> |
| 720 | </Field> |
| 721 | </> |
| 722 | ), |
| 723 | }; |
| 724 | return ( |
| 725 | <Card asChild className="mt-4 scroll-mt-20 p-5"> |
| 726 | <section id="add"> |
| 727 | <div className="flex items-center gap-3"> |
| 728 | <ProviderMark provider={provider} /> |
| 729 | <div className="grow"> |
| 730 | <h3 className="font-medium"> |
| 731 | Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label} |
| 732 | </h3> |
| 733 | <p className="text-xs text-muted">{blurb(provider)}</p> |
| 734 | </div> |
| 735 | <Link |
| 736 | to={`/${slug}/-/integrations/${integrationsSection(PROVIDERS[provider].kind)}`} |
| 737 | className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" |
| 738 | aria-label="Close" |
| 739 | > |
| 740 | <X size={16} /> |
| 741 | </Link> |
| 742 | </div> |
| 743 | <Form method="post" className="mt-5 grid max-w-xl gap-4"> |
| 744 | <input type="hidden" name="provider" value={provider} /> |
| 745 | {fields[provider] ?? <ModelProviderFields provider={provider} />} |
| 746 | <ErrorText>{error}</ErrorText> |
| 747 | <div> |
| 748 | <SubmitButton match={{ provider }} pending="Connecting…"> |
| 749 | Connect |
| 750 | </SubmitButton> |
| 751 | </div> |
| 752 | </Form> |
| 753 | </section> |
| 754 | </Card> |
| 755 | ); |
| 756 | } |
| 757 | |
| 758 | /** What to do next, right after connecting: shown once. */ |
| 759 | function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) { |
| 760 | const { connection, signingSecret } = connected; |
| 761 | const url = connection.webhookUrl; |
| 762 | return ( |
| 763 | <div className="mt-6 rounded-xl border border-success-dim/60 bg-success/5 p-5"> |
| 764 | <p className="flex items-center gap-2 font-medium"> |
| 765 | <CheckCircle2 size={16} className="text-success" /> |
| 766 | {connection.name} is connected. |
| 767 | </p> |
| 768 | {connection.provider === "sentry" && url && ( |
| 769 | <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted"> |
| 770 | <li> |
| 771 | In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal |
| 772 | integration. |
| 773 | </li> |
| 774 | <li> |
| 775 | Set its webhook URL to <CopyLine text={url} /> |
| 776 | </li> |
| 777 | <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li> |
| 778 | <li>Give it Issue & Event read and write, save, and paste its client secret below, on the connection.</li> |
| 779 | </ol> |
| 780 | )} |
| 781 | {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && ( |
| 782 | <div className="mt-3 space-y-3 text-sm text-muted"> |
| 783 | <p>This secret is shown once. Copy it now.</p> |
| 784 | <CopyLine text={signingSecret} /> |
| 785 | {connection.provider === "datadog" ? ( |
| 786 | <> |
| 787 | <p> |
| 788 | In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL: |
| 789 | </p> |
| 790 | <CopyLine text={url} /> |
| 791 | <p>Custom headers:</p> |
| 792 | <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} /> |
| 793 | <p>Payload:</p> |
| 794 | <CopyLine |
| 795 | text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`} |
| 796 | /> |
| 797 | <p> |
| 798 | Then mention <code>@webhook-g1t</code> in any monitor's message. |
| 799 | </p> |
| 800 | </> |
| 801 | ) : ( |
| 802 | <> |
| 803 | <p>Send JSON with at least a title to:</p> |
| 804 | <CopyLine text={url} /> |
| 805 | <p> |
| 806 | with <code>Authorization: Bearer <secret></code>, or an HMAC-SHA256 of the body in{" "} |
| 807 | <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue. |
| 808 | </p> |
| 809 | </> |
| 810 | )} |
| 811 | </div> |
| 812 | )} |
| 813 | {connection.kind === "tracker" && ( |
| 814 | <p className="mt-2 text-sm text-muted"> |
| 815 | You can import a ticket from any repository's new issue page. Use Test to check the token. |
| 816 | </p> |
| 817 | )} |
| 818 | {connection.kind === "models" && ( |
| 819 | <p className="mt-2 text-sm text-muted"> |
| 820 | {connection.lastError |
| 821 | ? `It was saved, but the check failed: ${connection.lastError}` |
| 822 | : connection.models.length > 0 |
| 823 | ? `The key works, and it offers ${connection.models.length} models.` |
| 824 | : "The key is saved. Use Test to check it again."} |
| 825 | </p> |
| 826 | )} |
| 827 | </div> |
| 828 | ); |
| 829 | } |