Integrations are a directory of connectors, for a workspace and for each person
- One catalog (packages/contracts connectors.ts): every connector's category, what it does in g1t, whether it's for a workspace, a person or both, and whether it's available or coming; adding one is one entry - The workspace's directory: categories, search, what's connected (and what needs attention), what's available, what's coming, and asking for one; setup pages per kind, unchanged - Your integrations in your settings: just for you, in every workspace; calendars, mail and status from chat apps coming
28 files+1463−390/28 viewed
| 358 | 358 | ||
| 359 | 359 | ### Your own providers | |
| 360 | 360 | ||
| 361 | − | Connect a model provider, or any compatible endpoint, in **Settings → | |
| 362 | − | Integrations**. See [model providers](/guides/models/#connect-a-provider). | |
| 361 | + | Connect a model provider, or any compatible endpoint, in the workspace's | |
| 362 | + | **Integrations**, under **AI models**. See [model providers](/guides/models/#connect-a-provider). | |
| 363 | 363 | Then, on an agent: | |
| 364 | 364 | ||
| 365 | 365 | - **Both** (the default, when nothing is chosen): the agent may use |
| 41 | 41 | | Invites to g1t | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites to g1t](#making-invites), while g1t is invite-only; after that, the invites you made. | | |
| 42 | 42 | | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. | | |
| 43 | 43 | | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens): their permissions, where they reach, and when they expire. | | |
| 44 | + | | Integrations | [`/settings/integrations`](https://g1t.sh/settings/integrations) | [Your own integrations](/guides/integrations/#workspace-and-personal): what you have connected for yourself, in every workspace, and what is coming. | | |
| 44 | 45 | | GitHub | [`/settings/github`](https://g1t.sh/settings/github) | [Linking and unlinking GitHub](/guides/github/#link-and-unlink-github). | | |
| 45 | 46 | | Connected applications | [`/settings/applications`](https://g1t.sh/settings/applications) | Tools you [signed in to with OAuth](#signing-in-with-oauth), such as an agent using the MCP server. | | |
| 46 | 47 | | Two-factor authentication | [`/settings/two-factor`](https://g1t.sh/settings/two-factor) | [An authenticator app and recovery codes](#two-factor-authentication). | |
| 473 | 473 | ||
| 474 | 474 | A status has a source: you, a calendar, or another integration. Calendar | |
| 475 | 475 | and integration statuses (such as "In a meeting" while one is on your | |
| 476 | − | calendar) are coming with those integrations. One you set yourself always | |
| 476 | + | calendar) are coming with those integrations, which you will connect under | |
| 477 | + | [your integrations](/guides/integrations/#workspace-and-personal). One you set yourself always | |
| 477 | 478 | wins: a calendar never replaces or clears it. <Soon /> | |
| 478 | 479 | ||
| 479 | 480 | ## Notifications |
| 1 | 1 | --- | |
| 2 | 2 | title: Integrations | |
| 3 | − | description: Connect Sentry, Datadog, Jira, Linear and anything that sends a webhook, so problems become issues and agents read the tickets the work refers to. | |
| 3 | + | description: Connect the tools your team and you already use. Workspace integrations work for everyone in a workspace; personal ones work just for you, in every workspace. What is available today, what is coming, and how to ask for one. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | A workspace connects to the systems its work already lives in. There are | |
| 7 | − | three kinds of connection: | |
| 6 | + | g1t connects to the tools your work already lives in: where errors are | |
| 7 | + | reported, where tickets are tracked, which models your agents think with, | |
| 8 | + | where code came from. Every integration is listed in one directory, by | |
| 9 | + | category, whether it works today or is still being built. | |
| 10 | + | ||
| 11 | + | ## Workspace and personal | |
| 8 | 12 | ||
| 9 | − | | Kind | Systems | What it does | | |
| 13 | + | There are two places to connect things, and they are for different people: | |
| 14 | + | ||
| 15 | + | | | Workspace integrations | Your integrations | | |
| 10 | 16 | | --- | --- | --- | | |
| 11 | − | | [Model providers](/guides/models/) | Anthropic, OpenAI, Google Gemini, xAI, Mistral, DeepSeek, Azure OpenAI, OpenRouter, Groq, Together AI, Fireworks AI, Cerebras, and any Anthropic- or OpenAI-compatible endpoint | Your agents' model requests go to your own accounts, routed by kind of work. | | |
| 12 | − | | [Alerts](#alerts) | Sentry, Datadog, a signed webhook | A problem opens an issue, once however often it fires, and an agent can start on it at once. | | |
| 13 | − | | [Trackers](#trackers) | Jira, Linear | Agents read the tickets that work mentions, people import tickets as issues, and tickets hear back when the work lands. | | |
| 17 | + | | Where | The workspace's **Integrations**, at `g1t.sh/<workspace>/-/integrations` | **Your settings → Integrations**, at [`g1t.sh/settings/integrations`](https://g1t.sh/settings/integrations) | | |
| 18 | + | | Who it is for | Everyone in that workspace, people and agents | Just you, in every workspace you belong to | | |
| 19 | + | | Who connects | Workspace owners. Members see what is connected. | You | | |
| 20 | + | | Examples | Model providers, Sentry, Jira, Linear, GitHub mirroring, webhooks | Signing in with GitHub, agents you connect through MCP, and soon your calendar | | |
| 14 | 21 | ||
| 15 | − | Open the workspace's **Settings → Integrations**. Every member | |
| 16 | − | can see the connections; only owners can add, test or remove them. | |
| 22 | + | Some tools have both sides. GitHub, for instance: a workspace owner | |
| 23 | + | installs g1t's GitHub App to import and mirror the team's repositories, and | |
| 24 | + | you link your own GitHub account to sign in with it. Each card says when | |
| 25 | + | the other side exists. | |
| 26 | + | ||
| 27 | + | ## The directory | |
| 17 | 28 | ||
| 29 | + | Both pages are the same directory: | |
| 30 | + | ||
| 31 | + | - **Categories** down the side (along the top on a phone): Code & CI, | |
| 32 | + | Issues & projects, Chat & meetings, Calendar & email, Docs & knowledge, | |
| 33 | + | Monitoring & incidents, Cloud & deploy, AI models, Data & analytics, | |
| 34 | + | CRM & support, Design, Security & identity, and Files & storage. Each | |
| 35 | + | shows how many integrations it has. A link can open on one, with | |
| 36 | + | `?category=ai` and so on. | |
| 37 | + | - **Search** finds an integration by name, by what it does ("in a meeting", | |
| 38 | + | "opens issues"), or by a tag, across every category. | |
| 39 | + | - **Connected** comes first: what is set up here, with a line about each | |
| 40 | + | (the account, how many connections) and **Needs attention** when | |
| 41 | + | something is wrong, such as a key that stopped working or a GitHub | |
| 42 | + | installation that was suspended. Choose one to manage it. | |
| 43 | + | - **Available** can be connected now. Choose one to open its setup page. | |
| 44 | + | - **Soon** is being built. Each says what it will do, and **Ask for this** | |
| 45 | + | tells g1t you want it (below). | |
| 46 | + | ||
| 47 | + | The tags on a card say what an integration lets g1t do: **Agents can | |
| 48 | + | read** (agents fetch what work refers to), **Opens issues**, **Writes | |
| 49 | + | back** (the other system hears when work lands), **Mirrors repositories**, | |
| 50 | + | **Runs models**, **Sets your status**, and so on. | |
| 51 | + | ||
| 52 | + | ## Available today | |
| 53 | + | ||
| 54 | + | | Category | Workspace | Personal | | |
| 55 | + | | --- | --- | --- | | |
| 56 | + | | Code & CI | [GitHub](/guides/github/) import and mirroring, [webhooks](/guides/webhooks/) | [Signing in with GitHub](/guides/github/#link-and-unlink-github) | | |
| 57 | + | | Issues & projects | [Jira and Linear](#trackers) | | | |
| 58 | + | | Monitoring & incidents | [Sentry, Datadog, and any system that sends a webhook](#alerts) | | | |
| 59 | + | | AI models | [Model providers](/guides/models/): Anthropic, OpenAI, Google Gemini, xAI, Mistral, DeepSeek, Azure OpenAI, OpenRouter, Groq, Together AI, Fireworks AI, Cerebras, and any Anthropic- or OpenAI-compatible endpoint; the [AI Gateway](/guides/ai-gateway/) | [MCP clients](/guides/authentication/#signing-in-with-oauth): your own agent, connected to g1t as you | | |
| 60 | + | ||
| 61 | + | Model providers, alerts and trackers each have a setup page under the | |
| 62 | + | directory: `-/integrations/models`, `-/integrations/alerts` and | |
| 63 | + | `-/integrations/trackers`. GitHub, webhooks and the AI Gateway keep their | |
| 64 | + | own pages; the directory links to them. | |
| 65 | + | ||
| 18 | 66 | Secrets are sealed when they are saved and never shown again, to anyone. | |
| 19 | − | The page shows the last four characters of a key, so you can tell keys | |
| 67 | + | A setup page shows the last four characters of a key, so you can tell keys | |
| 20 | 68 | apart. Agents never see a connection's secrets. | |
| 21 | 69 | ||
| 70 | + | ## Coming soon | |
| 71 | + | ||
| 72 | + | Every category has integrations on the way, among them Slack, Microsoft | |
| 73 | + | Teams, Notion, Confluence, PagerDuty, Cloudflare, AWS, Figma, Okta and | |
| 74 | + | Google Drive for workspaces. For you: **Google Calendar** and **Outlook | |
| 75 | + | Calendar** will set your [status](/guides/chat/#presence-and-status) to *In a meeting* while | |
| 76 | + | you are in one, and let agents find a time that suits everyone; **Zoom** | |
| 77 | + | and **Google Meet** will set it to *On a call*. A status you set by hand | |
| 78 | + | always wins over one an integration sets. | |
| 79 | + | ||
| 80 | + | The directory lists them all. Nothing on a **Soon** card does anything | |
| 81 | + | yet. | |
| 82 | + | ||
| 83 | + | ## Ask for an integration | |
| 84 | + | ||
| 85 | + | On any **Soon** card, **Ask for this** opens an email to g1t with the | |
| 86 | + | integration and your workspace (or "for my own account") filled in. Add | |
| 87 | + | what you would use it for and send it. Searching for something that is | |
| 88 | + | not listed offers the same, for whatever you searched for. Requests decide | |
| 89 | + | what is built next. | |
| 90 | + | ||
| 22 | 91 | ## Alerts | |
| 23 | 92 | ||
| 24 | 93 | An alert source opens issues in one repository. Each problem it reports is |
| 94 | 94 | ||
| 95 | 95 | ## Connect a provider | |
| 96 | 96 | ||
| 97 | − | 1. Open the workspace's **Settings → Integrations**. You need to be an owner. | |
| 98 | − | 2. Under **Model providers**, choose one, and give its key (and address, for | |
| 99 | − | an endpoint). | |
| 97 | + | 1. Open the workspace's **Integrations** and choose **AI models**, or go | |
| 98 | + | straight to **Model providers** at `g1t.sh/<workspace>/-/integrations/models`. | |
| 99 | + | You need to be an owner. | |
| 100 | + | 2. Choose a provider, and give its key (and address, for an endpoint). | |
| 100 | 101 | 3. **Connect**. g1t checks the key at once and lists the provider's models. | |
| 101 | 102 | **Test** checks it again later. | |
| 102 | 103 |
| 561 | 561 | | **Guardrails** | Members | What agents may do and spend across the workspace. Owners change them. | | |
| 562 | 562 | | [**Secrets and variables**](/guides/secrets-and-variables/) | Members | What runs and deployments are given. Owners change them. | | |
| 563 | 563 | | **Runners** | Owners | The workspace's self-hosted machines, their groups and registration tokens. | | |
| 564 | − | | [**Integrations**](/guides/integrations/) | Members | Model providers and connected services. Owners connect and remove them. | | |
| 564 | + | | [**Integrations**](/guides/integrations/) | Members | Every tool the workspace connects to, by category: what is connected, what is available, and what is coming. Owners connect and remove them. | | |
| 565 | 565 | | [**Webhooks**](/guides/webhooks/) | Members | Where the workspace's events are sent. Owners add and change them. | | |
| 566 | 566 | | **Billing and plans** | Members | [The g1t plan](/guides/usage-and-billing/#the-g1t-plan), [limits](/guides/usage-and-billing/#limits) and the statement. Owners start the plan, check a card, prepay and set limits. | | |
| 567 | 567 | | **Audit log** | Members | [Every action agents, people and tokens took](/guides/audit-log/). | |
| 119 | 119 | ||
| 120 | 120 | Agent work on code uses the same plan or trial as step 3 above, plus | |
| 121 | 121 | sandbox time. To use your own model provider's key, or any compatible | |
| 122 | − | endpoint, connect it in your workspace's **Settings → Integrations**. | |
| 122 | + | endpoint, connect it in your workspace's **Integrations**, under **AI models**. | |
| 123 | 123 | Your provider bills you for the model; g1t charges the agent rate and | |
| 124 | 124 | the sandbox time. See [model providers](/guides/models/). | |
| 125 | 125 |
| 1 | + | import { | |
| 2 | + | AlertTriangle, | |
| 3 | + | BarChart3, | |
| 4 | + | BookOpen, | |
| 5 | + | Bot, | |
| 6 | + | CalendarDays, | |
| 7 | + | ChevronRight, | |
| 8 | + | Cloud, | |
| 9 | + | Code2, | |
| 10 | + | FolderOpen, | |
| 11 | + | Headset, | |
| 12 | + | LayoutGrid, | |
| 13 | + | ListChecks, | |
| 14 | + | MessagesSquare, | |
| 15 | + | Palette, | |
| 16 | + | Search, | |
| 17 | + | ShieldCheck, | |
| 18 | + | Siren, | |
| 19 | + | } from "lucide-react"; | |
| 20 | + | import { type ReactNode, useState } from "react"; | |
| 21 | + | import { Link, useSearchParams } from "react-router"; | |
| 22 | + | ||
| 23 | + | import { type ConnectorScope, type ConnectorView, connectorPath } from "@g1t/contracts/connectors"; | |
| 24 | + | ||
| 25 | + | import { CONTACT } from "../lib/legal"; | |
| 26 | + | import { | |
| 27 | + | type CategoryFilter, | |
| 28 | + | type ConnectedState, | |
| 29 | + | arrange, | |
| 30 | + | askHref, | |
| 31 | + | categoriesWith, | |
| 32 | + | categoryCounts, | |
| 33 | + | categoryFilter, | |
| 34 | + | categoryTitle, | |
| 35 | + | groupByCategory, | |
| 36 | + | monogram, | |
| 37 | + | tileColor, | |
| 38 | + | } from "../lib/connectors"; | |
| 39 | + | import { cn } from "../lib/cn"; | |
| 40 | + | import { ProviderMark } from "./model-providers"; | |
| 41 | + | import { Badge } from "./ui/badge"; | |
| 42 | + | import { Hint } from "./ui/hint"; | |
| 43 | + | import { Input } from "./ui"; | |
| 44 | + | ||
| 45 | + | const CATEGORY_ICONS: Record<CategoryFilter, ReactNode> = { | |
| 46 | + | all: <LayoutGrid size={15} />, | |
| 47 | + | code: <Code2 size={15} />, | |
| 48 | + | issues: <ListChecks size={15} />, | |
| 49 | + | chat: <MessagesSquare size={15} />, | |
| 50 | + | calendar: <CalendarDays size={15} />, | |
| 51 | + | docs: <BookOpen size={15} />, | |
| 52 | + | monitoring: <Siren size={15} />, | |
| 53 | + | cloud: <Cloud size={15} />, | |
| 54 | + | ai: <Bot size={15} />, | |
| 55 | + | data: <BarChart3 size={15} />, | |
| 56 | + | crm: <Headset size={15} />, | |
| 57 | + | design: <Palette size={15} />, | |
| 58 | + | security: <ShieldCheck size={15} />, | |
| 59 | + | files: <FolderOpen size={15} />, | |
| 60 | + | }; | |
| 61 | + | ||
| 62 | + | /** A connector's tile: its provider's mark, or its letters on a steady colour. No logos. */ | |
| 63 | + | export function ConnectorMark({ view, size = 36 }: { view: Pick<ConnectorView, "id" | "name" | "provider">; size?: number }) { | |
| 64 | + | if (view.provider) return <ProviderMark provider={view.provider} size={size} />; | |
| 65 | + | const color = tileColor(view.id); | |
| 66 | + | const letters = monogram(view.name); | |
| 67 | + | return ( | |
| 68 | + | <span | |
| 69 | + | aria-hidden="true" | |
| 70 | + | className="inline-flex shrink-0 items-center justify-center font-semibold tracking-tight" | |
| 71 | + | style={{ | |
| 72 | + | width: size, | |
| 73 | + | height: size, | |
| 74 | + | borderRadius: size * 0.28, | |
| 75 | + | fontSize: size * (letters.length > 1 ? 0.34 : 0.42), | |
| 76 | + | background: `color-mix(in srgb, ${color} 16%, transparent)`, | |
| 77 | + | color: `color-mix(in srgb, ${color} 72%, white)`, | |
| 78 | + | boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`, | |
| 79 | + | }} | |
| 80 | + | > | |
| 81 | + | {letters} | |
| 82 | + | </span> | |
| 83 | + | ); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | export type ConnectorDirectoryProps = { | |
| 87 | + | scope: ConnectorScope; | |
| 88 | + | views: ConnectorView[]; | |
| 89 | + | /** What is connected here, by connector id. */ | |
| 90 | + | connected: Record<string, ConnectedState>; | |
| 91 | + | /** The workspace's slug on its page; null on your own. */ | |
| 92 | + | workspace: string | null; | |
| 93 | + | /** Whether the viewer can connect things here (an owner, or anyone for themselves). */ | |
| 94 | + | canManage: boolean; | |
| 95 | + | /** Category navigation as a sidebar beside the cards (wide pages), or as chips above them. */ | |
| 96 | + | nav?: "sidebar" | "chips"; | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | /** | |
| 100 | + | * The integrations directory: categories to browse, a search, and every | |
| 101 | + | * connector as a card, sorted into Connected, Available and Soon. | |
| 102 | + | */ | |
| 103 | + | export function ConnectorDirectory({ scope, views, connected, workspace, canManage, nav = "sidebar" }: ConnectorDirectoryProps) { | |
| 104 | + | const [params, setParams] = useSearchParams(); | |
| 105 | + | const category = categoryFilter(params.get("category")); | |
| 106 | + | const [query, setQuery] = useState(""); | |
| 107 | + | const counts = categoryCounts(views); | |
| 108 | + | const categories = categoriesWith(views); | |
| 109 | + | const found = arrange(views, { category, query, connected: new Set(Object.keys(connected)) }); | |
| 110 | + | const searching = query.trim().length > 0; | |
| 111 | + | const grouped = category === "all" && !searching; | |
| 112 | + | const empty = found.connected.length + found.available.length + found.soon.length === 0; | |
| 113 | + | ||
| 114 | + | const choose = (next: CategoryFilter) => { | |
| 115 | + | setParams( | |
| 116 | + | (current) => { | |
| 117 | + | const updated = new URLSearchParams(current); | |
| 118 | + | if (next === "all") updated.delete("category"); | |
| 119 | + | else updated.set("category", next); | |
| 120 | + | return updated; | |
| 121 | + | }, | |
| 122 | + | { replace: true, preventScrollReset: true }, | |
| 123 | + | ); | |
| 124 | + | setQuery(""); | |
| 125 | + | }; | |
| 126 | + | ||
| 127 | + | const options: { id: CategoryFilter; title: string }[] = [{ id: "all", title: "All" }, ...categories]; | |
| 128 | + | const card = (view: ConnectorView) => ( | |
| 129 | + | <ConnectorCard | |
| 130 | + | key={view.id} | |
| 131 | + | view={view} | |
| 132 | + | scope={scope} | |
| 133 | + | state={connected[view.id] ?? null} | |
| 134 | + | workspace={workspace} | |
| 135 | + | canManage={canManage} | |
| 136 | + | showCategory={category === "all" || searching} | |
| 137 | + | /> | |
| 138 | + | ); | |
| 139 | + | ||
| 140 | + | return ( | |
| 141 | + | <div className={cn(nav === "sidebar" && "md:grid md:grid-cols-[12rem_minmax(0,1fr)] md:gap-8")}> | |
| 142 | + | {/* Categories: a list beside the cards on a wide page, chips that scroll sideways otherwise. */} | |
| 143 | + | <nav aria-label="Categories" className={cn(nav === "sidebar" && "md:sticky md:top-20 md:self-start")}> | |
| 144 | + | <ul | |
| 145 | + | className={cn( | |
| 146 | + | "-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:px-0", | |
| 147 | + | nav === "sidebar" && "md:mx-0 md:flex-col md:gap-px md:overflow-visible md:px-0", | |
| 148 | + | )} | |
| 149 | + | > | |
| 150 | + | {options.map((option) => { | |
| 151 | + | const current = option.id === category; | |
| 152 | + | return ( | |
| 153 | + | <li key={option.id} className="shrink-0"> | |
| 154 | + | <button | |
| 155 | + | type="button" | |
| 156 | + | onClick={() => choose(option.id)} | |
| 157 | + | aria-current={current ? "page" : undefined} | |
| 158 | + | className={cn( | |
| 159 | + | "flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors", | |
| 160 | + | nav === "sidebar" && "md:w-full md:rounded-md md:border-transparent md:px-2.5", | |
| 161 | + | current | |
| 162 | + | ? "border-accent/40 bg-accent/10 text-fg md:bg-raised" | |
| 163 | + | : "border-line text-muted hover:border-line-strong hover:text-fg md:hover:bg-surface", | |
| 164 | + | )} | |
| 165 | + | > | |
| 166 | + | <span className={cn("hidden text-faint", nav === "sidebar" && "md:inline-flex", current && "text-accent")}> | |
| 167 | + | {CATEGORY_ICONS[option.id]} | |
| 168 | + | </span> | |
| 169 | + | <span className="grow text-left">{option.title}</span> | |
| 170 | + | <span className="text-xs text-faint tabular-nums">{counts[option.id]}</span> | |
| 171 | + | </button> | |
| 172 | + | </li> | |
| 173 | + | ); | |
| 174 | + | })} | |
| 175 | + | </ul> | |
| 176 | + | </nav> | |
| 177 | + | ||
| 178 | + | <div className="mt-5 min-w-0 md:mt-0"> | |
| 179 | + | <div className="relative"> | |
| 180 | + | <Search size={15} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 181 | + | <Input | |
| 182 | + | type="search" | |
| 183 | + | value={query} | |
| 184 | + | onChange={(event) => setQuery(event.target.value)} | |
| 185 | + | placeholder={category === "all" ? "Search integrations" : `Search all integrations, not just ${categoryTitle(category)}`} | |
| 186 | + | aria-label="Search integrations" | |
| 187 | + | className="pl-9" | |
| 188 | + | /> | |
| 189 | + | </div> | |
| 190 | + | ||
| 191 | + | {empty && ( | |
| 192 | + | <p className="mt-8 rounded-xl border border-dashed border-line px-4 py-8 text-center text-sm text-muted"> | |
| 193 | + | Nothing matches “{query.trim()}”.{" "} | |
| 194 | + | <a | |
| 195 | + | href={askHref({ connector: { id: "other", name: query.trim() || "something else" }, scope, workspace, address: CONTACT.support })} | |
| 196 | + | className="text-accent hover:underline" | |
| 197 | + | > | |
| 198 | + | Ask for it | |
| 199 | + | </a> | |
| 200 | + | , and say what you would use it for. | |
| 201 | + | </p> | |
| 202 | + | )} | |
| 203 | + | ||
| 204 | + | {found.connected.length > 0 && ( | |
| 205 | + | <Group title="Connected" count={found.connected.length}> | |
| 206 | + | <Cards>{found.connected.map(card)}</Cards> | |
| 207 | + | </Group> | |
| 208 | + | )} | |
| 209 | + | {found.available.length > 0 && ( | |
| 210 | + | <Group | |
| 211 | + | title="Available" | |
| 212 | + | count={found.available.length} | |
| 213 | + | about={ | |
| 214 | + | scope === "workspace" | |
| 215 | + | ? canManage | |
| 216 | + | ? "Ready to connect now." | |
| 217 | + | : `Ready now. An owner of ${workspace} connects them.` | |
| 218 | + | : "Ready to connect now." | |
| 219 | + | } | |
| 220 | + | > | |
| 221 | + | <Cards>{found.available.map(card)}</Cards> | |
| 222 | + | </Group> | |
| 223 | + | )} | |
| 224 | + | {found.soon.length > 0 && ( | |
| 225 | + | <Group title="Soon" count={found.soon.length} about="Being built. Ask for the ones you need, and they come sooner."> | |
| 226 | + | {grouped ? ( | |
| 227 | + | <div className="space-y-6"> | |
| 228 | + | {groupByCategory(found.soon).map((group) => ( | |
| 229 | + | <div key={group.id}> | |
| 230 | + | <p className="mb-2 flex items-center gap-2 text-xs font-medium tracking-wide text-faint uppercase"> | |
| 231 | + | {CATEGORY_ICONS[group.id]} | |
| 232 | + | {group.title} | |
| 233 | + | </p> | |
| 234 | + | <Cards>{group.views.map((view) => <ConnectorCard key={view.id} view={view} scope={scope} state={null} workspace={workspace} canManage={canManage} showCategory={false} />)}</Cards> | |
| 235 | + | </div> | |
| 236 | + | ))} | |
| 237 | + | </div> | |
| 238 | + | ) : ( | |
| 239 | + | <Cards>{found.soon.map(card)}</Cards> | |
| 240 | + | )} | |
| 241 | + | </Group> | |
| 242 | + | )} | |
| 243 | + | </div> | |
| 244 | + | </div> | |
| 245 | + | ); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | function Group({ title, count, about, children }: { title: string; count: number; about?: string; children: ReactNode }) { | |
| 249 | + | return ( | |
| 250 | + | <section className="mt-8"> | |
| 251 | + | <h2 className="flex items-baseline gap-2 text-sm font-medium"> | |
| 252 | + | {title} | |
| 253 | + | <span className="text-xs font-normal text-faint tabular-nums">{count}</span> | |
| 254 | + | </h2> | |
| 255 | + | {about && <p className="mt-0.5 text-xs text-muted">{about}</p>} | |
| 256 | + | <div className="mt-3">{children}</div> | |
| 257 | + | </section> | |
| 258 | + | ); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | function Cards({ children }: { children: ReactNode }) { | |
| 262 | + | return <ul className="grid gap-3 sm:grid-cols-2">{children}</ul>; | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | function ConnectorCard({ | |
| 266 | + | view, | |
| 267 | + | scope, | |
| 268 | + | state, | |
| 269 | + | workspace, | |
| 270 | + | canManage, | |
| 271 | + | showCategory, | |
| 272 | + | }: { | |
| 273 | + | view: ConnectorView; | |
| 274 | + | scope: ConnectorScope; | |
| 275 | + | state: ConnectedState | null; | |
| 276 | + | workspace: string | null; | |
| 277 | + | canManage: boolean; | |
| 278 | + | showCategory: boolean; | |
| 279 | + | }) { | |
| 280 | + | const soon = view.status === "soon"; | |
| 281 | + | const setup = view.href ? connectorPath(view.href, workspace ?? "") : null; | |
| 282 | + | const to = state?.manage ?? setup; | |
| 283 | + | const also = | |
| 284 | + | view.alsoIn === "personal" | |
| 285 | + | ? "Each person can also connect their own, under their settings." | |
| 286 | + | : view.alsoIn === "workspace" | |
| 287 | + | ? "A workspace owner can also connect it for everyone." | |
| 288 | + | : null; | |
| 289 | + | return ( | |
| 290 | + | <li | |
| 291 | + | className={cn( | |
| 292 | + | "relative flex flex-col rounded-xl border p-4 transition-colors", | |
| 293 | + | soon ? "border-line/70 bg-surface/40" : "border-line bg-surface", | |
| 294 | + | !soon && to && "hover:border-line-strong", | |
| 295 | + | )} | |
| 296 | + | > | |
| 297 | + | <div className="flex items-start gap-3"> | |
| 298 | + | <span className={cn(soon && "opacity-70 grayscale-[35%]")}> | |
| 299 | + | <ConnectorMark view={view} /> | |
| 300 | + | </span> | |
| 301 | + | <div className="min-w-0 grow"> | |
| 302 | + | <div className="flex items-center gap-2"> | |
| 303 | + | {!soon && to ? ( | |
| 304 | + | // The whole card opens it: the link stretches over the card. | |
| 305 | + | <Link to={to} className="truncate text-sm font-medium after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none"> | |
| 306 | + | {view.name} | |
| 307 | + | </Link> | |
| 308 | + | ) : ( | |
| 309 | + | <p className={cn("truncate text-sm font-medium", soon && "text-fg/80")}>{view.name}</p> | |
| 310 | + | )} | |
| 311 | + | {state ? ( | |
| 312 | + | state.problem ? ( | |
| 313 | + | <Hint label={state.problem}> | |
| 314 | + | <Badge tone="warn" className="relative z-10" tabIndex={0}> | |
| 315 | + | <AlertTriangle size={11} /> Needs attention | |
| 316 | + | </Badge> | |
| 317 | + | </Hint> | |
| 318 | + | ) : ( | |
| 319 | + | <Badge tone="success">Connected</Badge> | |
| 320 | + | ) | |
| 321 | + | ) : soon ? ( | |
| 322 | + | <Badge>Soon</Badge> | |
| 323 | + | ) : null} | |
| 324 | + | </div> | |
| 325 | + | {showCategory && <p className="text-xs text-faint">{categoryTitle(view.category)}</p>} | |
| 326 | + | </div> | |
| 327 | + | </div> | |
| 328 | + | <p className={cn("mt-2.5 text-sm", soon ? "text-muted/90" : "text-muted")}>{view.description}</p> | |
| 329 | + | {state?.detail && <p className="mt-1.5 truncate text-xs text-faint">{state.detail}</p>} | |
| 330 | + | {view.capabilities.length > 0 && ( | |
| 331 | + | <ul className="mt-3 flex flex-wrap gap-1.5"> | |
| 332 | + | {view.capabilities.map((capability) => ( | |
| 333 | + | <li key={capability} className="rounded-md bg-raised px-1.5 py-0.5 text-[0.6875rem] text-muted"> | |
| 334 | + | {capability} | |
| 335 | + | </li> | |
| 336 | + | ))} | |
| 337 | + | </ul> | |
| 338 | + | )} | |
| 339 | + | <div className="mt-auto flex items-end justify-between gap-3 pt-3"> | |
| 340 | + | <p className="text-[0.6875rem] text-faint">{also}</p> | |
| 341 | + | {soon ? ( | |
| 342 | + | <a | |
| 343 | + | href={askHref({ connector: view, scope, workspace, address: CONTACT.support })} | |
| 344 | + | className="relative z-10 shrink-0 rounded-md border border-line px-2.5 py-1 text-xs font-medium text-fg/80 hover:border-line-strong hover:bg-raised hover:text-fg" | |
| 345 | + | > | |
| 346 | + | Ask for this | |
| 347 | + | </a> | |
| 348 | + | ) : to ? ( | |
| 349 | + | <span className="flex shrink-0 items-center gap-0.5 text-xs font-medium text-accent"> | |
| 350 | + | {state ? (canManage ? "Manage" : "View") : canManage ? "Set up" : "View"} | |
| 351 | + | <ChevronRight size={13} /> | |
| 352 | + | </span> | |
| 353 | + | ) : null} | |
| 354 | + | </div> | |
| 355 | + | </li> | |
| 356 | + | ); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | /** A short line saying who a page's connections are for, and where the other kind lives. */ | |
| 360 | + | export function ScopeNote({ scope, workspace }: { scope: ConnectorScope; workspace: string | null }) { | |
| 361 | + | return ( | |
| 362 | + | <p className="mb-6 rounded-xl border border-line bg-surface/60 px-4 py-3 text-sm text-muted"> | |
| 363 | + | {scope === "workspace" ? ( | |
| 364 | + | <> | |
| 365 | + | Connected here, a tool works <span className="text-fg">for everyone in {workspace}</span>: its people and its agents. | |
| 366 | + | Your own accounts, such as your calendar, are under{" "} | |
| 367 | + | <Link to="/settings/integrations" className="text-accent hover:underline"> | |
| 368 | + | your integrations | |
| 369 | + | </Link> | |
| 370 | + | . | |
| 371 | + | </> | |
| 372 | + | ) : ( | |
| 373 | + | <> | |
| 374 | + | Connected here, a tool works <span className="text-fg">just for you, in every workspace</span> you belong to. Tools | |
| 375 | + | the whole team shares are connected by a workspace owner, under the workspace's Integrations. | |
| 376 | + | </> | |
| 377 | + | )} | |
| 378 | + | </p> | |
| 379 | + | ); | |
| 380 | + | } |
| 777 | 777 | about: trial.granted | |
| 778 | 778 | ? `This workspace has ${dollars(trial.limitMicros - trial.usedMicros)} of its ${dollars(trial.limitMicros)} trial credit left, for g1t's models and sandboxes.` | |
| 779 | 779 | : `This workspace gets ${dollars(trial.limitMicros)} of trial credit, for g1t's models and sandboxes, the first time g1t works here.`, | |
| 780 | − | to: workspace ? `/${workspace}/-/integrations` : null, | |
| 780 | + | to: workspace ? `/${workspace}/-/integrations/models` : null, | |
| 781 | 781 | action: "Connect", | |
| 782 | 782 | } | |
| 783 | 783 | : canRunAgents | |
| ⋯ | |||
| 795 | 795 | about: `${ | |
| 796 | 796 | trialClosed(trial, workspace ?? "This workspace") ? `${trialClosed(trial, workspace ?? "This workspace")} ` : "" | |
| 797 | 797 | }Agents need a model to think with. Connect your Anthropic or OpenAI key, or any compatible endpoint.`, | |
| 798 | − | to: workspace ? `/${workspace}/-/integrations` : null, | |
| 798 | + | to: workspace ? `/${workspace}/-/integrations/models` : null, | |
| 799 | 799 | action: "Connect", | |
| 800 | 800 | }, | |
| 801 | 801 | { | |
| ⋯ | |||
| 832 | 832 | <> | |
| 833 | 833 | Agents need a model first.{" "} | |
| 834 | 834 | {workspace && ( | |
| 835 | − | <Link to={`/${workspace}/-/integrations`} className="font-medium text-fg hover:underline"> | |
| 835 | + | <Link to={`/${workspace}/-/integrations/models`} className="font-medium text-fg hover:underline"> | |
| 836 | 836 | Connect one | |
| 837 | 837 | </Link> | |
| 838 | 838 | )} | |
| 9 | 9 | ||
| 10 | 10 | import { type Connection, MODEL_TASKS, MODEL_TIERS, type ModelRoute, type ModelTask, type ModelTier, type Provider, PROVIDERS, gatewayPatterns } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | + | import { integrationsSection } from "../lib/integration-sections"; | |
| 12 | 13 | import { Field, Input, SubmitButton } from "./ui"; | |
| 13 | 14 | import { Hint } from "./ui/hint"; | |
| 14 | 15 | import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select"; | |
| ⋯ | |||
| 208 | 209 | {providers.map((provider) => ( | |
| 209 | 210 | <Link | |
| 210 | 211 | key={provider} | |
| 211 | − | to={`/${slug}/-/integrations?add=${provider}#add`} | |
| 212 | + | to={`/${slug}/-/integrations/${integrationsSection(PROVIDERS[provider].kind)}?add=${provider}#add`} | |
| 212 | 213 | preventScrollReset | |
| 213 | 214 | className={`group flex items-center gap-3 rounded-xl border px-3 py-2.5 transition-colors ${ | |
| 214 | 215 | adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface" | |
| 1 | − | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Smile, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 1 | + | import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Blocks, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Smile, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; | |
| 3 | 3 | import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 1235 | 1235 | </div> | |
| 1236 | 1236 | <Rule /> | |
| 1237 | 1237 | <div className="space-y-px"> | |
| 1238 | + | {link("integrations", <Blocks size={15} />)} | |
| 1238 | 1239 | {link("github", <GithubMark className="size-[15px]" />)} | |
| 1239 | 1240 | {link("applications", <Plug size={15} />)} | |
| 1240 | 1241 | </div> | |
| 10 | 10 | | "invites" | |
| 11 | 11 | | "keys" | |
| 12 | 12 | | "tokens" | |
| 13 | + | | "integrations" | |
| 13 | 14 | | "github" | |
| 14 | 15 | | "applications" | |
| 15 | 16 | | "two-factor" | |
| ⋯ | |||
| 41 | 42 | about: | |
| 42 | 43 | "Use a token as the password when git asks for one over HTTPS, and to authenticate agents and the API. A token here acts as you.", | |
| 43 | 44 | }, | |
| 45 | + | integrations: { | |
| 46 | + | title: "Integrations", | |
| 47 | + | heading: "Your integrations", | |
| 48 | + | about: | |
| 49 | + | "Your own accounts, connected just for you and working in every workspace you belong to, such as your calendar setting your status. Tools the whole team shares are a workspace's.", | |
| 50 | + | }, | |
| 44 | 51 | github: { | |
| 45 | 52 | title: "GitHub", | |
| 46 | 53 | about: "Sign in with your GitHub account, and import or mirror repositories you can reach there.", | |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 5 | + | ||
| 6 | + | import { | |
| 7 | + | arrange, | |
| 8 | + | askHref, | |
| 9 | + | categoriesWith, | |
| 10 | + | categoryCounts, | |
| 11 | + | categoryFilter, | |
| 12 | + | groupByCategory, | |
| 13 | + | matchesQuery, | |
| 14 | + | monogram, | |
| 15 | + | tileColor, | |
| 16 | + | } from "./connectors.ts"; | |
| 17 | + | ||
| 18 | + | const workspace = connectorsFor("workspace"); | |
| 19 | + | const personal = connectorsFor("personal"); | |
| 20 | + | const none = new Set<string>(); | |
| 21 | + | ||
| 22 | + | test("the two contexts list different connectors", () => { | |
| 23 | + | const ids = (views: typeof workspace) => new Set(views.map((view) => view.id)); | |
| 24 | + | assert.ok(ids(personal).has("google-calendar")); | |
| 25 | + | assert.ok(!ids(workspace).has("google-calendar"), "a calendar is your own"); | |
| 26 | + | assert.ok(ids(workspace).has("sentry")); | |
| 27 | + | assert.ok(!ids(personal).has("sentry"), "alerts are the workspace's"); | |
| 28 | + | assert.ok(ids(workspace).has("github") && ids(personal).has("github"), "GitHub is both"); | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | test("a category shows only its own connectors, sorted into connected, available and soon", () => { | |
| 32 | + | const found = arrange(workspace, { category: "monitoring", query: "", connected: new Set(["sentry"]) }); | |
| 33 | + | assert.deepEqual(found.connected.map((view) => view.id), ["sentry"]); | |
| 34 | + | assert.ok(found.available.some((view) => view.id === "datadog")); | |
| 35 | + | assert.ok(!found.available.some((view) => view.id === "sentry"), "connected is not also available"); | |
| 36 | + | assert.ok(found.soon.some((view) => view.id === "pagerduty")); | |
| 37 | + | for (const view of [...found.connected, ...found.available, ...found.soon]) assert.equal(view.category, "monitoring"); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("all shows everything once", () => { | |
| 41 | + | const found = arrange(workspace, { category: "all", query: "", connected: none }); | |
| 42 | + | assert.equal(found.available.length + found.soon.length, workspace.length); | |
| 43 | + | }); | |
| 44 | + | ||
| 45 | + | test("search finds by name, description, tag and keyword, across every category", () => { | |
| 46 | + | const found = arrange(workspace, { category: "design", query: "sentry", connected: none }); | |
| 47 | + | assert.deepEqual(found.available.map((view) => view.id), ["sentry"], "a search is not held to the category"); | |
| 48 | + | assert.ok(matchesQuery(personal.find((view) => view.id === "google-calendar")!, "in a meeting")); | |
| 49 | + | assert.ok(matchesQuery(workspace.find((view) => view.id === "anthropic")!, "claude")); | |
| 50 | + | assert.ok(matchesQuery(workspace.find((view) => view.id === "incident-io")!, "incidentio")); | |
| 51 | + | assert.ok(matchesQuery(workspace.find((view) => view.id === "pagerduty")!, "opens ISSUES")); | |
| 52 | + | assert.ok(!matchesQuery(workspace.find((view) => view.id === "figma")!, "kubernetes")); | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | test("the navigation counts each category and leaves out empty ones", () => { | |
| 56 | + | const counts = categoryCounts(workspace); | |
| 57 | + | assert.equal(counts.all, workspace.length); | |
| 58 | + | assert.ok(counts.ai >= 14); | |
| 59 | + | const mine = categoriesWith(personal).map((category) => category.id); | |
| 60 | + | assert.ok(mine.includes("calendar")); | |
| 61 | + | const groups = groupByCategory(personal); | |
| 62 | + | assert.equal(groups.reduce((sum, group) => sum + group.views.length, 0), personal.length); | |
| 63 | + | }); | |
| 64 | + | ||
| 65 | + | test("an unknown category is all of them", () => { | |
| 66 | + | assert.equal(categoryFilter("ai"), "ai"); | |
| 67 | + | assert.equal(categoryFilter("nope"), "all"); | |
| 68 | + | assert.equal(categoryFilter(null), "all"); | |
| 69 | + | }); | |
| 70 | + | ||
| 71 | + | test("a tile has the connector's letters and a steady colour", () => { | |
| 72 | + | assert.equal(monogram("PagerDuty"), "PD"); | |
| 73 | + | assert.equal(monogram("Google Calendar"), "GC"); | |
| 74 | + | assert.equal(monogram("Sentry"), "S"); | |
| 75 | + | assert.equal(monogram("incident.io"), "II"); | |
| 76 | + | assert.equal(monogram("1Password"), "1P"); | |
| 77 | + | assert.equal(tileColor("figma"), tileColor("figma")); | |
| 78 | + | }); | |
| 79 | + | ||
| 80 | + | test("asking for a connector names it and who it is for", () => { | |
| 81 | + | const href = askHref({ connector: { id: "slack", name: "Slack" }, scope: "workspace", workspace: "acme", address: "hey@example.com" }); | |
| 82 | + | assert.ok(href.startsWith("mailto:hey@example.com?subject=Integration%20request%3A%20Slack")); | |
| 83 | + | assert.ok(decodeURIComponent(href).includes("For the workspace: acme")); | |
| 84 | + | const mine = askHref({ connector: { id: "google-calendar", name: "Google Calendar" }, scope: "personal", workspace: null, address: "hey@example.com" }); | |
| 85 | + | assert.ok(decodeURIComponent(mine).includes("For my own account.")); | |
| 86 | + | }); |
| 1 | + | /** | |
| 2 | + | * The integrations directory: the connector catalog (@g1t/contracts/connectors) | |
| 3 | + | * filtered, searched and sorted into what a workspace, or a person, has | |
| 4 | + | * connected, can connect now, and will be able to soon. | |
| 5 | + | */ | |
| 6 | + | ||
| 7 | + | import { | |
| 8 | + | CONNECTOR_CATEGORIES, | |
| 9 | + | type ConnectorCategory, | |
| 10 | + | type ConnectorScope, | |
| 11 | + | type ConnectorView, | |
| 12 | + | } from "@g1t/contracts/connectors"; | |
| 13 | + | ||
| 14 | + | /** "all", or one category: what the directory's navigation selects. */ | |
| 15 | + | export type CategoryFilter = ConnectorCategory | "all"; | |
| 16 | + | ||
| 17 | + | /** What is connected, by connector id: how it is doing, for its card. */ | |
| 18 | + | export type ConnectedState = { | |
| 19 | + | /** A line about it: an account, how many connections. */ | |
| 20 | + | detail: string | null; | |
| 21 | + | /** Something is wrong with it, said plainly. */ | |
| 22 | + | problem: string | null; | |
| 23 | + | /** Where it is managed, when that is not its setup page. */ | |
| 24 | + | manage: string | null; | |
| 25 | + | }; | |
| 26 | + | ||
| 27 | + | export type Directory = { | |
| 28 | + | connected: ConnectorView[]; | |
| 29 | + | available: ConnectorView[]; | |
| 30 | + | soon: ConnectorView[]; | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | /** The category a `?category=` asks for, or all of them. */ | |
| 34 | + | export function categoryFilter(value: string | null | undefined): CategoryFilter { | |
| 35 | + | return CONNECTOR_CATEGORIES.some((category) => category.id === value) ? (value as ConnectorCategory) : "all"; | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /** A category's title. */ | |
| 39 | + | export function categoryTitle(id: ConnectorCategory): string { | |
| 40 | + | return CONNECTOR_CATEGORIES.find((category) => category.id === id)?.title ?? id; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | /** Lowercased, without accents or punctuation, for matching. */ | |
| 44 | + | function fold(text: string): string { | |
| 45 | + | return text | |
| 46 | + | .normalize("NFKD") | |
| 47 | + | .replace(/[̀-ͯ]/g, "") | |
| 48 | + | .toLowerCase() | |
| 49 | + | .replace(/[^a-z0-9]+/g, " ") | |
| 50 | + | .trim(); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /** Whether a connector answers a search: every word in its name, description, category, tags or keywords. */ | |
| 54 | + | export function matchesQuery(view: ConnectorView, query: string): boolean { | |
| 55 | + | const words = fold(query).split(" ").filter(Boolean); | |
| 56 | + | if (words.length === 0) return true; | |
| 57 | + | const haystack = fold([view.name, view.description, categoryTitle(view.category), ...view.capabilities, ...view.keywords].join(" ")); | |
| 58 | + | const compact = haystack.replace(/ /g, ""); | |
| 59 | + | return words.every((word) => haystack.includes(word) || compact.includes(word)); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /** | |
| 63 | + | * The directory for one category and search: what is connected first, then | |
| 64 | + | * what can be connected now, then what is coming. Search spans every | |
| 65 | + | * category, so a match in another one is never hidden. | |
| 66 | + | */ | |
| 67 | + | export function arrange( | |
| 68 | + | views: ConnectorView[], | |
| 69 | + | { category, query, connected }: { category: CategoryFilter; query: string; connected: ReadonlySet<string> }, | |
| 70 | + | ): Directory { | |
| 71 | + | const searching = query.trim().length > 0; | |
| 72 | + | const shown = views.filter( | |
| 73 | + | (view) => (searching || category === "all" || view.category === category) && matchesQuery(view, query), | |
| 74 | + | ); | |
| 75 | + | return { | |
| 76 | + | connected: shown.filter((view) => connected.has(view.id)), | |
| 77 | + | available: shown.filter((view) => view.status === "available" && !connected.has(view.id)), | |
| 78 | + | soon: shown.filter((view) => view.status === "soon" && !connected.has(view.id)), | |
| 79 | + | }; | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /** How many connectors each category has, for its row in the navigation; `all` is every one. */ | |
| 83 | + | export function categoryCounts(views: ConnectorView[]): Record<CategoryFilter, number> { | |
| 84 | + | const counts = { all: views.length } as Record<CategoryFilter, number>; | |
| 85 | + | for (const category of CONNECTOR_CATEGORIES) counts[category.id] = 0; | |
| 86 | + | for (const view of views) counts[view.category] += 1; | |
| 87 | + | return counts; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | /** The categories with something in them for `views`, in catalog order. */ | |
| 91 | + | export function categoriesWith(views: ConnectorView[]): { id: ConnectorCategory; title: string }[] { | |
| 92 | + | const present = new Set(views.map((view) => view.category)); | |
| 93 | + | return CONNECTOR_CATEGORIES.filter((category) => present.has(category.id)); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | /** Connectors grouped under their categories, in catalog order, leaving out empty ones. */ | |
| 97 | + | export function groupByCategory(views: ConnectorView[]): { id: ConnectorCategory; title: string; views: ConnectorView[] }[] { | |
| 98 | + | return CONNECTOR_CATEGORIES.map((category) => ({ | |
| 99 | + | ...category, | |
| 100 | + | views: views.filter((view) => view.category === category.id), | |
| 101 | + | })).filter((group) => group.views.length > 0); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | /** The letters on a connector's tile: two words' initials, or a name's capitals ("PagerDuty" → "PD"). */ | |
| 105 | + | export function monogram(name: string): string { | |
| 106 | + | const words = name.split(/[\s.\-/]+/).filter((word) => /[A-Za-z0-9]/.test(word)); | |
| 107 | + | if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase(); | |
| 108 | + | const word = words[0] ?? name; | |
| 109 | + | const capitals = word.slice(1).match(/[A-Z0-9]/); | |
| 110 | + | return capitals ? (word[0]! + capitals[0]).toUpperCase() : word.slice(0, 1).toUpperCase(); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /** A tile's colour, always the same for the same connector. */ | |
| 114 | + | const PALETTE = ["#8b8ff0", "#5b8def", "#5eead4", "#4ade80", "#facc15", "#fb923c", "#f87171", "#f472b6", "#c084fc", "#a1a1aa", "#38bdf8", "#a3e635"]; | |
| 115 | + | ||
| 116 | + | export function tileColor(id: string): string { | |
| 117 | + | let hash = 0; | |
| 118 | + | for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) >>> 0; | |
| 119 | + | return PALETTE[hash % PALETTE.length]!; | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /** Where a request for a connector goes: an email to g1t, filled in. */ | |
| 123 | + | export function askHref({ | |
| 124 | + | connector, | |
| 125 | + | scope, | |
| 126 | + | workspace, | |
| 127 | + | address, | |
| 128 | + | }: { | |
| 129 | + | connector: Pick<ConnectorView, "id" | "name">; | |
| 130 | + | scope: ConnectorScope; | |
| 131 | + | workspace: string | null; | |
| 132 | + | address: string; | |
| 133 | + | }): string { | |
| 134 | + | const subject = `Integration request: ${connector.name}`; | |
| 135 | + | const body = [ | |
| 136 | + | `I would like g1t to connect to ${connector.name} (${connector.id}).`, | |
| 137 | + | scope === "workspace" && workspace ? `For the workspace: ${workspace}` : "For my own account.", | |
| 138 | + | "", | |
| 139 | + | "What we would use it for:", | |
| 140 | + | "", | |
| 141 | + | ].join("\n"); | |
| 142 | + | return `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; | |
| 143 | + | } |
| 1 | + | import type { ProviderKind } from "@g1t/contracts"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * The workspace's integration setup pages, under `-/integrations/<section>`: | |
| 5 | + | * one for each kind of provider connection. The directory at | |
| 6 | + | * `-/integrations` links into them. | |
| 7 | + | */ | |
| 8 | + | export type IntegrationsSection = "models" | "alerts" | "trackers"; | |
| 9 | + | ||
| 10 | + | const SECTIONS: Record<ProviderKind, IntegrationsSection> = { | |
| 11 | + | models: "models", | |
| 12 | + | alerts: "alerts", | |
| 13 | + | tracker: "trackers", | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | /** The page a kind of provider is set up on. */ | |
| 17 | + | export function integrationsSection(kind: ProviderKind): IntegrationsSection { | |
| 18 | + | return SECTIONS[kind]; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** The kind of provider a section's page is for, or null when it is no section. */ | |
| 22 | + | export function sectionKind(section: string | undefined): ProviderKind | null { | |
| 23 | + | const found = (Object.keys(SECTIONS) as ProviderKind[]).find((kind) => SECTIONS[kind] === section); | |
| 24 | + | return found ?? null; | |
| 25 | + | } |
| 41 | 41 | route("tokens/:id", "routes/settings/token.tsx"), | |
| 42 | 42 | route("github", "routes/settings/github.tsx"), | |
| 43 | 43 | route("applications", "routes/settings/applications.tsx"), | |
| 44 | + | route("integrations", "routes/settings/integrations.tsx"), | |
| 44 | 45 | route("two-factor", "routes/settings/two-factor.tsx"), | |
| 45 | 46 | route("security-log", "routes/settings/security-log.tsx"), | |
| 46 | 47 | route("account", "routes/settings/account.tsx"), | |
| ⋯ | |||
| 118 | 119 | route("-/billing", "routes/workspace/billing.tsx"), | |
| 119 | 120 | route("-/billing/entries", "routes/workspace/statement-entries.ts"), | |
| 120 | 121 | route("-/billing/statement.csv", "routes/workspace/statement-csv.ts"), | |
| 121 | − | route("-/integrations", "routes/workspace/integrations.tsx"), | |
| 122 | + | // The integrations directory, and the setup page for each kind of | |
| 123 | + | // provider connection: models, alerts, trackers (lib/integration-sections). | |
| 124 | + | route("-/integrations", "routes/workspace/integrations-directory.tsx"), | |
| 125 | + | route("-/integrations/:section", "routes/workspace/integrations.tsx"), | |
| 122 | 126 | route("-/webhooks", "routes/workspace/webhooks.tsx"), | |
| 123 | 127 | // The workspace's own emoji, for chat (components/emoji). | |
| 124 | 128 | route("-/emoji", "routes/workspace/emoji.tsx"), | |
| 587 | 587 | through checks, review and fixes. Agents run on a paid workspace, or on the free trial. | |
| 588 | 588 | </p> | |
| 589 | 589 | <Link | |
| 590 | − | to={`/${params.owner}/-/integrations`} | |
| 590 | + | to={`/${params.owner}/-/integrations/models`} | |
| 591 | 591 | className="mt-2 inline-block text-xs text-accent hover:underline" | |
| 592 | 592 | > | |
| 593 | 593 | Connect a model |
| 1 | + | import { Link } from "react-router"; | |
| 2 | + | ||
| 3 | + | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 4 | + | ||
| 5 | + | import type { Route } from "./+types/integrations"; | |
| 6 | + | import { ConnectorDirectory } from "../../components/connectors"; | |
| 7 | + | import type { ConnectedState } from "../../lib/connectors"; | |
| 8 | + | import { githubSignIn } from "../../lib/github.server"; | |
| 9 | + | import { page } from "../../lib/meta"; | |
| 10 | + | import { identity } from "../../lib/services.server"; | |
| 11 | + | import { requireUser } from "../../lib/session.server"; | |
| 12 | + | ||
| 13 | + | export function meta(args: Route.MetaArgs) { | |
| 14 | + | return page(args, { title: "Integrations · Settings · g1t" }); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | /** | |
| 18 | + | * Your own integrations: the connector directory's personal side, with | |
| 19 | + | * what you have connected (GitHub sign-in, applications on the MCP server). | |
| 20 | + | * Each is still set up on its own settings page. | |
| 21 | + | */ | |
| 22 | + | export async function loader({ request, context }: Route.LoaderArgs) { | |
| 23 | + | const user = requireUser(context, request); | |
| 24 | + | const [github, grants] = await Promise.all([ | |
| 25 | + | githubSignIn.account(user).catch(() => null), | |
| 26 | + | identity.listOAuthGrants(user).catch(() => []), | |
| 27 | + | ]); | |
| 28 | + | const connected: Record<string, ConnectedState> = {}; | |
| 29 | + | if (github?.account) { | |
| 30 | + | connected.github = { | |
| 31 | + | detail: `Linked to @${github.account.login}`, | |
| 32 | + | problem: github.account.authorized ? null : "Access ended: link it again to import repositories.", | |
| 33 | + | manage: null, | |
| 34 | + | }; | |
| 35 | + | } | |
| 36 | + | if (grants.length > 0) { | |
| 37 | + | connected.mcp = { | |
| 38 | + | detail: grants.length === 1 ? grants[0]!.clientName : `${grants.length} applications`, | |
| 39 | + | problem: null, | |
| 40 | + | manage: null, | |
| 41 | + | }; | |
| 42 | + | } | |
| 43 | + | const workspaces = (user.workspaces ?? []).map((membership) => ({ slug: membership.slug, name: membership.name ?? membership.slug })); | |
| 44 | + | return { connected, workspaces }; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | export default function PersonalIntegrations({ loaderData }: Route.ComponentProps) { | |
| 48 | + | const { connected, workspaces } = loaderData; | |
| 49 | + | return ( | |
| 50 | + | <div> | |
| 51 | + | <ConnectorDirectory | |
| 52 | + | scope="personal" | |
| 53 | + | views={connectorsFor("personal")} | |
| 54 | + | connected={connected} | |
| 55 | + | workspace={null} | |
| 56 | + | canManage | |
| 57 | + | nav="chips" | |
| 58 | + | /> | |
| 59 | + | {workspaces.length > 0 && ( | |
| 60 | + | <p className="mt-10 border-t border-line pt-5 text-sm text-muted"> | |
| 61 | + | Tools the whole team shares are connected for each workspace:{" "} | |
| 62 | + | {workspaces.map((workspace, index) => ( | |
| 63 | + | <span key={workspace.slug}> | |
| 64 | + | {index > 0 && ", "} | |
| 65 | + | <Link to={`/${workspace.slug}/-/integrations`} className="text-accent hover:underline"> | |
| 66 | + | {workspace.name} | |
| 67 | + | </Link> | |
| 68 | + | </span> | |
| 69 | + | ))} | |
| 70 | + | . | |
| 71 | + | </p> | |
| 72 | + | )} | |
| 73 | + | </div> | |
| 74 | + | ); | |
| 75 | + | } |
| 1 | + | import { redirect } from "react-router"; | |
| 2 | + | ||
| 3 | + | import { type Provider, PROVIDERS } from "@g1t/contracts"; | |
| 4 | + | import { CONNECTORS, connectorsFor } from "@g1t/contracts/connectors"; | |
| 5 | + | ||
| 6 | + | import type { Route } from "./+types/integrations-directory"; | |
| 7 | + | import { ConnectorDirectory, ScopeNote } from "../../components/connectors"; | |
| 8 | + | import type { ConnectedState } from "../../lib/connectors"; | |
| 9 | + | import { githubApp } from "../../lib/github.server"; | |
| 10 | + | import { integrationsSection } from "../../lib/integration-sections"; | |
| 11 | + | import { page } from "../../lib/meta"; | |
| 12 | + | import { integrations, webhooks } from "../../lib/services.server"; | |
| 13 | + | import { getViewer, roleIn } from "../../lib/session.server"; | |
| 14 | + | ||
| 15 | + | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 16 | + | return page(args, { title: `Integrations · ${params.owner} · g1t` }); | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /** | |
| 20 | + | * The workspace's integrations: every connector g1t has or will have, by | |
| 21 | + | * category, with what this workspace has connected first. Each one is set | |
| 22 | + | * up on its own page (the model providers, alerts and trackers pages, the | |
| 23 | + | * GitHub import, webhooks); this page only finds them. | |
| 24 | + | */ | |
| 25 | + | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 26 | + | const viewer = getViewer(context); | |
| 27 | + | const role = roleIn(viewer, params.owner); | |
| 28 | + | if (!role || !viewer) throw new Response(null, { status: 404 }); | |
| 29 | + | const slug = params.owner.toLowerCase(); | |
| 30 | + | ||
| 31 | + | // An older link to one provider's form (`?add=sentry`): its own page now. | |
| 32 | + | const add = new URL(request.url).searchParams.get("add"); | |
| 33 | + | if (add && add in PROVIDERS) { | |
| 34 | + | throw redirect(`/${slug}/-/integrations/${integrationsSection(PROVIDERS[add as Provider].kind)}?add=${add}#add`); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | const [connections, github, hooks] = await Promise.all([ | |
| 38 | + | integrations.list(slug, viewer), | |
| 39 | + | githubApp.status(viewer, slug).catch(() => null), | |
| 40 | + | webhooks.list(viewer, { workspace: slug }).catch(() => null), | |
| 41 | + | ]); | |
| 42 | + | ||
| 43 | + | const connected: Record<string, ConnectedState> = {}; | |
| 44 | + | if (connections.ok) { | |
| 45 | + | for (const connector of CONNECTORS) { | |
| 46 | + | if (!connector.provider) continue; | |
| 47 | + | const mine = connections.value.filter((connection) => connection.provider === connector.provider); | |
| 48 | + | if (mine.length === 0) continue; | |
| 49 | + | const broken = mine.find((connection) => connection.lastError); | |
| 50 | + | connected[connector.id] = { | |
| 51 | + | detail: mine.length === 1 ? mine[0]!.name : `${mine.length} connections: ${mine.map((c) => c.name).join(", ")}`, | |
| 52 | + | problem: broken ? `${broken.name}: ${broken.lastError}` : null, | |
| 53 | + | manage: `/${slug}/-/integrations/${integrationsSection(PROVIDERS[connector.provider].kind)}`, | |
| 54 | + | }; | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | if (github?.ok && github.value.installations.length > 0) { | |
| 58 | + | const suspended = github.value.installations.find((installation) => installation.suspended); | |
| 59 | + | connected.github = { | |
| 60 | + | detail: `On ${github.value.installations.map((installation) => installation.account).join(", ")}`, | |
| 61 | + | problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null, | |
| 62 | + | manage: null, | |
| 63 | + | }; | |
| 64 | + | } | |
| 65 | + | const ours = hooks?.ok ? hooks.value.filter((hook) => hook.scope === "workspace") : []; | |
| 66 | + | if (ours.length > 0) { | |
| 67 | + | const failing = ours.filter((hook) => hook.lastStatus === "failed"); | |
| 68 | + | connected.webhooks = { | |
| 69 | + | detail: ours.length === 1 ? ours[0]!.url : `${ours.length} webhooks`, | |
| 70 | + | problem: failing.length > 0 ? `The last delivery to ${failing[0]!.url} failed.` : null, | |
| 71 | + | manage: null, | |
| 72 | + | }; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | return { slug, owner: role === "owner", connected }; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | export default function WorkspaceIntegrationsDirectory({ loaderData }: Route.ComponentProps) { | |
| 79 | + | const { slug, owner, connected } = loaderData; | |
| 80 | + | return ( | |
| 81 | + | <div> | |
| 82 | + | <ScopeNote scope="workspace" workspace={slug} /> | |
| 83 | + | <ConnectorDirectory | |
| 84 | + | scope="workspace" | |
| 85 | + | views={connectorsFor("workspace")} | |
| 86 | + | connected={connected} | |
| 87 | + | workspace={slug} | |
| 88 | + | canManage={owner} | |
| 89 | + | /> | |
| 90 | + | </div> | |
| 91 | + | ); | |
| 92 | + | } |
| 1 | 1 | import { | |
| 2 | 2 | AlertTriangle, | |
| 3 | + | ArrowLeft, | |
| 3 | 4 | CheckCircle2, | |
| 4 | 5 | ChevronRight, | |
| 5 | 6 | Cpu, | |
| ⋯ | |||
| 28 | 29 | import { trialClosed } from "../../lib/trial"; | |
| 29 | 30 | import { GatewayModelsField, MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers"; | |
| 30 | 31 | import { parseGatewayModels } from "../../lib/gateway"; | |
| 32 | + | import { integrationsSection, sectionKind } from "../../lib/integration-sections"; | |
| 31 | 33 | import { Avatar, CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui"; | |
| 32 | 34 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 33 | 35 | import { Combobox } from "../../components/ui/combobox"; | |
| ⋯ | |||
| 35 | 37 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 36 | 38 | ||
| 37 | 39 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 38 | − | return page(args, { title: `Integrations · ${params.owner} · g1t` }); | |
| 40 | + | const kind = sectionKind(params.section); | |
| 41 | + | return page(args, { title: `${kind ? KIND_INFO[kind].title : "Integrations"} · ${params.owner} · g1t` }); | |
| 39 | 42 | } | |
| 40 | 43 | ||
| 41 | 44 | const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS; | |
| ⋯ | |||
| 43 | 46 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 44 | 47 | const viewer = getViewer(context); | |
| 45 | 48 | const role = roleIn(viewer, params.owner); | |
| 46 | − | if (!role) throw new Response(null, { status: 404 }); | |
| 49 | + | // One kind of connection's page: the directory at `-/integrations` finds them. | |
| 50 | + | const kind = sectionKind(params.section); | |
| 51 | + | if (!role || !kind) throw new Response(null, { status: 404 }); | |
| 47 | 52 | const slug = params.owner.toLowerCase(); | |
| 48 | 53 | const [connections, listed, account, access, routes] = await Promise.all([ | |
| 49 | 54 | integrations.list(slug, viewer), | |
| ⋯ | |||
| 67 | 72 | return { | |
| 68 | 73 | slug, | |
| 69 | 74 | role, | |
| 75 | + | kind, | |
| 70 | 76 | connections: all, | |
| 71 | 77 | deliveries, | |
| 72 | 78 | repos: listed.map((repo) => `${repo.namespace}/${repo.name}`), | |
| ⋯ | |||
| 197 | 203 | } | |
| 198 | 204 | ||
| 199 | 205 | export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) { | |
| 200 | − | const { slug, role, connections, deliveries, repos: repoNames, adding, free, marginPercent, hostedOpen, hostedPreview, trial, routes } = | |
| 206 | + | const { slug, role, kind, connections, deliveries, repos: repoNames, adding, free, marginPercent, hostedOpen, hostedPreview, trial, routes } = | |
| 201 | 207 | loaderData; | |
| 202 | 208 | const owner = role === "owner"; | |
| 203 | 209 | const modelConnections = connections.filter((connection) => connection.kind === "models"); | |
| ⋯ | |||
| 208 | 214 | ||
| 209 | 215 | return ( | |
| 210 | 216 | <div> | |
| 217 | + | <Link to={`/${slug}/-/integrations`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg"> | |
| 218 | + | <ArrowLeft size={14} /> All integrations | |
| 219 | + | </Link> | |
| 211 | 220 | {justConnected && <Connected connected={justConnected} />} | |
| 212 | 221 | <div className="mt-4"> | |
| 213 | 222 | <ErrorText>{error}</ErrorText> | |
| 214 | 223 | </div> | |
| 215 | 224 | ||
| 216 | 225 | {/* Models -------------------------------------------------------------- */} | |
| 217 | − | <Section kind="models" first> | |
| 226 | + | {kind === "models" && ( | |
| 227 | + | <Section kind="models"> | |
| 218 | 228 | {!hostedOpen && modelConnections.length === 0 && ( | |
| 219 | 229 | <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4"> | |
| 220 | 230 | <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30"> | |
| ⋯ | |||
| 271 | 281 | /> | |
| 272 | 282 | )} | |
| 273 | 283 | </Section> | |
| 284 | + | )} | |
| 274 | 285 | ||
| 275 | 286 | {/* Alerts -------------------------------------------------------------- */} | |
| 287 | + | {kind === "alerts" && ( | |
| 276 | 288 | <Section kind="alerts"> | |
| 277 | 289 | <Connections | |
| 278 | 290 | list={connections.filter((c) => c.kind === "alerts")} | |
| ⋯ | |||
| 291 | 303 | /> | |
| 292 | 304 | )} | |
| 293 | 305 | </Section> | |
| 306 | + | )} | |
| 294 | 307 | ||
| 295 | 308 | {/* Trackers ------------------------------------------------------------ */} | |
| 309 | + | {kind === "tracker" && ( | |
| 296 | 310 | <Section kind="tracker"> | |
| 297 | 311 | <Connections | |
| 298 | 312 | list={connections.filter((c) => c.kind === "tracker")} | |
| ⋯ | |||
| 311 | 325 | /> | |
| 312 | 326 | )} | |
| 313 | 327 | </Section> | |
| 328 | + | )} | |
| 314 | 329 | ||
| 315 | 330 | {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>} | |
| 316 | 331 | </div> | |
| 317 | 332 | ); | |
| 318 | 333 | } | |
| 319 | 334 | ||
| 320 | − | function Section({ kind, first, children }: { kind: ProviderKind; first?: boolean; children: ReactNode }) { | |
| 335 | + | /** One kind of connection's page: its heading, then what is connected and what to connect. */ | |
| 336 | + | function Section({ kind, children }: { kind: ProviderKind; children: ReactNode }) { | |
| 321 | 337 | const info = KIND_INFO[kind]; | |
| 322 | 338 | return ( | |
| 323 | − | <section className={first ? "" : "mt-12"}> | |
| 324 | − | <h3 className="flex items-center gap-2 text-sm font-medium"> | |
| 339 | + | <section className="mt-4"> | |
| 340 | + | <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight [&_svg]:size-5"> | |
| 325 | 341 | <span className="text-muted">{info.icon}</span> | |
| 326 | 342 | {info.title} | |
| 327 | − | </h3> | |
| 328 | − | <p className="mt-1 mb-4 text-sm text-muted">{info.blurb}</p> | |
| 343 | + | </h1> | |
| 344 | + | <p className="mt-1.5 mb-6 border-b border-line pb-6 text-sm text-muted">{info.blurb}</p> | |
| 329 | 345 | {children} | |
| 330 | 346 | </section> | |
| 331 | 347 | ); | |
| ⋯ | |||
| 651 | 667 | </h3> | |
| 652 | 668 | <p className="text-xs text-muted">{blurb(provider)}</p> | |
| 653 | 669 | </div> | |
| 654 | − | <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close"> | |
| 670 | + | <Link | |
| 671 | + | to={`/${slug}/-/integrations/${integrationsSection(PROVIDERS[provider].kind)}`} | |
| 672 | + | className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" | |
| 673 | + | aria-label="Close" | |
| 674 | + | > | |
| 655 | 675 | <X size={16} /> | |
| 656 | 676 | </Link> | |
| 657 | 677 | </div> | |
| 121 | 121 | }, | |
| 122 | 122 | integrations: { | |
| 123 | 123 | title: "Integrations", | |
| 124 | − | about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.", | |
| 124 | + | about: | |
| 125 | + | "The tools your team works with, connected once for everyone in the workspace and its agents. Secrets are sealed when saved, and agents never see them.", | |
| 125 | 126 | }, | |
| 126 | 127 | }; | |
| 127 | 128 |
| 1227 | 1227 | when it is there: toasts while the window is in front, native | |
| 1228 | 1228 | notifications while it is not, and no Web Push. | |
| 1229 | 1229 | ||
| 1230 | + | ## Integrations directory | |
| 1231 | + | ||
| 1232 | + | Built 2026-10-09. One catalog, `packages/contracts/src/connectors.ts` | |
| 1233 | + | (`@g1t/contracts/connectors`), lists every connector: id, name, category, | |
| 1234 | + | one-line description, `scopes` (`workspace`, `personal`, or both, with a | |
| 1235 | + | `personal` override for what differs), `status` (`available` or `soon`), | |
| 1236 | + | `href` per scope for available ones (`:workspace` is filled in), capability | |
| 1237 | + | tags, and the integration `provider` behind it. Adding a connector, or | |
| 1238 | + | moving one from soon to available, is an edit there plus its setup page. | |
| 1239 | + | ||
| 1240 | + | Two pages draw from it with the same component (`components/connectors.tsx`): | |
| 1241 | + | the workspace's `-/integrations` ("for everyone in <workspace>", owners | |
| 1242 | + | manage) and `/settings/integrations` ("just for you, in every workspace"). | |
| 1243 | + | The workspace's setup pages for providers moved to | |
| 1244 | + | `-/integrations/{models,alerts,trackers}`. Connected state comes from | |
| 1245 | + | integrations (connections and their `last_error`), the GitHub App's | |
| 1246 | + | installations, workspace webhooks, GitHub sign-in and OAuth grants. | |
| 1247 | + | ||
| 1248 | + | **Ask for this** on a Soon card is a prefilled email to support for now; a | |
| 1249 | + | stored request (workspace, connector, person) can replace it without | |
| 1250 | + | changing the cards. The Calendar connectors will set presence with | |
| 1251 | + | `source: "calendar"` (Presence and status, above). | |
| 1252 | + | ||
| 1230 | 1253 | ## Services | |
| 1231 | 1254 | ||
| 1232 | 1255 | Following the architecture principles: separate services, interfaces in |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "license": "MIT", | |
| 7 | − | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts" }, | |
| 7 | + | "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 1 | + | import assert from "node:assert/strict"; | |
| 2 | + | import { test } from "node:test"; | |
| 3 | + | ||
| 4 | + | import { CONNECTOR_CATEGORIES, CONNECTORS, connectorPath, connectorView, connectorsFor } from "./connectors.ts"; | |
| 5 | + | import { PROVIDERS } from "./integrations.ts"; | |
| 6 | + | ||
| 7 | + | test("every connector has a unique id, a category and a scope", () => { | |
| 8 | + | const ids = new Set<string>(); | |
| 9 | + | const categories = new Set(CONNECTOR_CATEGORIES.map((category) => category.id)); | |
| 10 | + | for (const connector of CONNECTORS) { | |
| 11 | + | assert.ok(!ids.has(connector.id), `${connector.id} is listed twice`); | |
| 12 | + | ids.add(connector.id); | |
| 13 | + | assert.match(connector.id, /^[a-z0-9-]+$/, connector.id); | |
| 14 | + | assert.ok(categories.has(connector.category), connector.id); | |
| 15 | + | assert.ok(connector.scopes.length > 0, connector.id); | |
| 16 | + | assert.ok(connector.description.length > 0 && !connector.description.includes("\n"), connector.id); | |
| 17 | + | } | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | test("every category has connectors, and an available one in each scope it has is set up somewhere", () => { | |
| 21 | + | for (const category of CONNECTOR_CATEGORIES) { | |
| 22 | + | assert.ok(CONNECTORS.some((connector) => connector.category === category.id), category.id); | |
| 23 | + | } | |
| 24 | + | for (const scope of ["workspace", "personal"] as const) { | |
| 25 | + | for (const view of connectorsFor(scope)) { | |
| 26 | + | if (view.status === "available") assert.ok(view.href?.startsWith("/"), `${view.id} (${scope}) has nowhere to set it up`); | |
| 27 | + | else assert.equal(view.href, null, view.id); | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | }); | |
| 31 | + | ||
| 32 | + | test("every integration provider is an available connector", () => { | |
| 33 | + | for (const provider of Object.keys(PROVIDERS)) { | |
| 34 | + | const connector = CONNECTORS.find((c) => c.provider === provider); | |
| 35 | + | assert.ok(connector, `${provider} has no connector`); | |
| 36 | + | assert.equal(connector.status, "available", provider); | |
| 37 | + | } | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("a connector shows its personal side to you", () => { | |
| 41 | + | const github = CONNECTORS.find((c) => c.id === "github")!; | |
| 42 | + | const mine = connectorView(github, "personal")!; | |
| 43 | + | assert.equal(mine.href, "/settings/github"); | |
| 44 | + | assert.ok(mine.capabilities.includes("Signs you in")); | |
| 45 | + | assert.equal(mine.alsoIn, "workspace"); | |
| 46 | + | const linear = CONNECTORS.find((c) => c.id === "linear")!; | |
| 47 | + | assert.equal(connectorView(linear, "workspace")!.status, "available"); | |
| 48 | + | assert.equal(connectorView(linear, "personal")!.status, "soon"); | |
| 49 | + | assert.equal(connectorView(linear, "personal")!.href, null); | |
| 50 | + | const calendar = CONNECTORS.find((c) => c.id === "google-calendar")!; | |
| 51 | + | assert.equal(connectorView(calendar, "workspace"), null); | |
| 52 | + | }); | |
| 53 | + | ||
| 54 | + | test("a setup path names the workspace", () => { | |
| 55 | + | assert.equal(connectorPath("/:workspace/-/integrations/models?add=openai#add", "acme"), "/acme/-/integrations/models?add=openai#add"); | |
| 56 | + | assert.equal(connectorPath("/new/github?workspace=:workspace", "acme"), "/new/github?workspace=acme"); | |
| 57 | + | }); |
| 1 | + | import type { Provider } from "./integrations"; | |
| 2 | + | ||
| 3 | + | /** | |
| 4 | + | * The connector catalog: every tool g1t connects to, or will, in one list. | |
| 5 | + | * The workspace's Integrations page and your own Integrations settings are | |
| 6 | + | * both drawn from it, so a new connector is added here, once. | |
| 7 | + | * | |
| 8 | + | * A connector is `available` when g1t can set it up today (its `href` is | |
| 9 | + | * where), and `soon` while it is planned. `scopes` says who connects it: | |
| 10 | + | * | |
| 11 | + | * - `workspace`: an owner connects it once, for everyone in the workspace. | |
| 12 | + | * - `personal`: each person connects their own account, and it follows them | |
| 13 | + | * into every workspace they belong to (a calendar, a personal sign-in). | |
| 14 | + | * | |
| 15 | + | * A connector with both scopes can say something different for each, in | |
| 16 | + | * `personal`: its own description, status, capabilities or setup page. | |
| 17 | + | * `connectorView` resolves that into what one context shows. | |
| 18 | + | * | |
| 19 | + | * Paths in `href` are g1t's own pages. `:workspace` stands for the | |
| 20 | + | * workspace's slug. | |
| 21 | + | */ | |
| 22 | + | ||
| 23 | + | export type ConnectorScope = "workspace" | "personal"; | |
| 24 | + | export type ConnectorStatus = "available" | "soon"; | |
| 25 | + | ||
| 26 | + | export type ConnectorCategory = | |
| 27 | + | | "code" | |
| 28 | + | | "issues" | |
| 29 | + | | "chat" | |
| 30 | + | | "calendar" | |
| 31 | + | | "docs" | |
| 32 | + | | "monitoring" | |
| 33 | + | | "cloud" | |
| 34 | + | | "ai" | |
| 35 | + | | "data" | |
| 36 | + | | "crm" | |
| 37 | + | | "design" | |
| 38 | + | | "security" | |
| 39 | + | | "files"; | |
| 40 | + | ||
| 41 | + | /** What a connector lets g1t do, as the small tags on its card. */ | |
| 42 | + | export type ConnectorCapability = | |
| 43 | + | | "Agents can read" | |
| 44 | + | | "Agents can act" | |
| 45 | + | | "Opens issues" | |
| 46 | + | | "Writes back" | |
| 47 | + | | "Imports repositories" | |
| 48 | + | | "Mirrors repositories" | |
| 49 | + | | "Signs you in" | |
| 50 | + | | "Sends events" | |
| 51 | + | | "Alerts into chat" | |
| 52 | + | | "Sets your status" | |
| 53 | + | | "Finds a time" | |
| 54 | + | | "Runs models" | |
| 55 | + | | "Deploys" | |
| 56 | + | | "Syncs people"; | |
| 57 | + | ||
| 58 | + | export type Connector = { | |
| 59 | + | /** Stable and unique: used in links and requests. */ | |
| 60 | + | id: string; | |
| 61 | + | name: string; | |
| 62 | + | category: ConnectorCategory; | |
| 63 | + | /** One plain line of what it does in g1t, for a workspace (or for you, when it is only personal). */ | |
| 64 | + | description: string; | |
| 65 | + | scopes: ConnectorScope[]; | |
| 66 | + | status: ConnectorStatus; | |
| 67 | + | /** Where an available connector is set up, for each scope it has. */ | |
| 68 | + | href?: Partial<Record<ConnectorScope, string>>; | |
| 69 | + | capabilities?: ConnectorCapability[]; | |
| 70 | + | /** The integration provider behind it, whose connections show it as connected. */ | |
| 71 | + | provider?: Provider; | |
| 72 | + | /** What differs when you connect it for yourself. */ | |
| 73 | + | personal?: { description?: string; status?: ConnectorStatus; capabilities?: ConnectorCapability[] }; | |
| 74 | + | /** More words search should find it by. */ | |
| 75 | + | keywords?: string[]; | |
| 76 | + | }; | |
| 77 | + | ||
| 78 | + | /** A connector as one context (a workspace, or you) shows it. */ | |
| 79 | + | export type ConnectorView = { | |
| 80 | + | id: string; | |
| 81 | + | name: string; | |
| 82 | + | category: ConnectorCategory; | |
| 83 | + | description: string; | |
| 84 | + | status: ConnectorStatus; | |
| 85 | + | href: string | null; | |
| 86 | + | capabilities: ConnectorCapability[]; | |
| 87 | + | provider: Provider | null; | |
| 88 | + | keywords: string[]; | |
| 89 | + | /** The other scope it can also be connected in. */ | |
| 90 | + | alsoIn: ConnectorScope | null; | |
| 91 | + | }; | |
| 92 | + | ||
| 93 | + | /** The categories, in the order the directory lists them. */ | |
| 94 | + | export const CONNECTOR_CATEGORIES: { id: ConnectorCategory; title: string }[] = [ | |
| 95 | + | { id: "code", title: "Code & CI" }, | |
| 96 | + | { id: "issues", title: "Issues & projects" }, | |
| 97 | + | { id: "chat", title: "Chat & meetings" }, | |
| 98 | + | { id: "calendar", title: "Calendar & email" }, | |
| 99 | + | { id: "docs", title: "Docs & knowledge" }, | |
| 100 | + | { id: "monitoring", title: "Monitoring & incidents" }, | |
| 101 | + | { id: "cloud", title: "Cloud & deploy" }, | |
| 102 | + | { id: "ai", title: "AI models" }, | |
| 103 | + | { id: "data", title: "Data & analytics" }, | |
| 104 | + | { id: "crm", title: "CRM & support" }, | |
| 105 | + | { id: "design", title: "Design" }, | |
| 106 | + | { id: "security", title: "Security & identity" }, | |
| 107 | + | { id: "files", title: "Files & storage" }, | |
| 108 | + | ]; | |
| 109 | + | ||
| 110 | + | const W: ConnectorScope[] = ["workspace"]; | |
| 111 | + | const P: ConnectorScope[] = ["personal"]; | |
| 112 | + | const BOTH: ConnectorScope[] = ["workspace", "personal"]; | |
| 113 | + | ||
| 114 | + | /** A model provider: set up on the workspace's model providers page. */ | |
| 115 | + | function model(provider: Provider, name: string, description: string, keywords: string[] = []): Connector { | |
| 116 | + | return { | |
| 117 | + | id: provider.replace(/_/g, "-"), | |
| 118 | + | name, | |
| 119 | + | category: "ai", | |
| 120 | + | description, | |
| 121 | + | scopes: W, | |
| 122 | + | status: "available", | |
| 123 | + | href: { workspace: `/:workspace/-/integrations/models?add=${provider}#add` }, | |
| 124 | + | capabilities: ["Runs models"], | |
| 125 | + | provider, | |
| 126 | + | keywords: ["model", "llm", ...keywords], | |
| 127 | + | }; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /** A planned connector. */ | |
| 131 | + | function soon( | |
| 132 | + | id: string, | |
| 133 | + | name: string, | |
| 134 | + | category: ConnectorCategory, | |
| 135 | + | scopes: ConnectorScope[], | |
| 136 | + | description: string, | |
| 137 | + | capabilities: ConnectorCapability[] = [], | |
| 138 | + | personal?: Connector["personal"], | |
| 139 | + | ): Connector { | |
| 140 | + | return { id, name, category, description, scopes, status: "soon", capabilities, ...(personal ? { personal } : {}) }; | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | export const CONNECTORS: Connector[] = [ | |
| 144 | + | // Code & CI ------------------------------------------------------------- | |
| 145 | + | { | |
| 146 | + | id: "github", | |
| 147 | + | name: "GitHub", | |
| 148 | + | category: "code", | |
| 149 | + | description: "Import repositories, or mirror them both ways while your team moves over.", | |
| 150 | + | scopes: BOTH, | |
| 151 | + | status: "available", | |
| 152 | + | href: { workspace: "/new/github?workspace=:workspace", personal: "/settings/github" }, | |
| 153 | + | capabilities: ["Imports repositories", "Mirrors repositories"], | |
| 154 | + | personal: { | |
| 155 | + | description: "Sign in with your GitHub account, and reach the repositories you can see there to import them.", | |
| 156 | + | capabilities: ["Signs you in", "Imports repositories"], | |
| 157 | + | }, | |
| 158 | + | keywords: ["git", "import", "mirror", "sign in"], | |
| 159 | + | }, | |
| 160 | + | { | |
| 161 | + | id: "webhooks", | |
| 162 | + | name: "Webhooks", | |
| 163 | + | category: "code", | |
| 164 | + | description: "Every repository's events, sent to your own addresses as they happen, signed.", | |
| 165 | + | scopes: W, | |
| 166 | + | status: "available", | |
| 167 | + | href: { workspace: "/:workspace/-/webhooks" }, | |
| 168 | + | capabilities: ["Sends events"], | |
| 169 | + | keywords: ["http", "events", "outgoing"], | |
| 170 | + | }, | |
| 171 | + | soon("gitlab", "GitLab", "code", BOTH, "Import projects and keep them mirrored, merge requests included.", ["Imports repositories", "Mirrors repositories"], { | |
| 172 | + | description: "Sign in with GitLab, and reach your own projects to import them.", | |
| 173 | + | capabilities: ["Signs you in", "Imports repositories"], | |
| 174 | + | }), | |
| 175 | + | soon("bitbucket", "Bitbucket", "code", BOTH, "Import repositories and keep them mirrored while you move.", ["Imports repositories", "Mirrors repositories"], { | |
| 176 | + | description: "Reach your own Bitbucket repositories to import them.", | |
| 177 | + | capabilities: ["Imports repositories"], | |
| 178 | + | }), | |
| 179 | + | soon("circleci", "CircleCI", "code", W, "Pipeline results as checks on pull requests, which agents read when a build fails.", ["Agents can read"]), | |
| 180 | + | soon("buildkite", "Buildkite", "code", W, "Build results as checks on pull requests, with the failing step for agents to fix.", ["Agents can read"]), | |
| 181 | + | soon("docker-hub", "Docker Hub", "code", W, "Push images your workflows build, and pull private base images.", ["Deploys"]), | |
| 182 | + | ||
| 183 | + | // Issues & projects ----------------------------------------------------- | |
| 184 | + | { | |
| 185 | + | id: "linear", | |
| 186 | + | name: "Linear", | |
| 187 | + | category: "issues", | |
| 188 | + | description: "Agents read ENG-42 when work mentions it; import issues, and they hear back when the work lands.", | |
| 189 | + | scopes: BOTH, | |
| 190 | + | status: "available", | |
| 191 | + | href: { workspace: "/:workspace/-/integrations/trackers?add=linear#add" }, | |
| 192 | + | capabilities: ["Agents can read", "Writes back"], | |
| 193 | + | provider: "linear", | |
| 194 | + | personal: { | |
| 195 | + | description: "Issues assigned to you in Linear, in your g1t inbox beside everything else.", | |
| 196 | + | status: "soon", | |
| 197 | + | capabilities: ["Agents can read"], | |
| 198 | + | }, | |
| 199 | + | keywords: ["tracker", "tickets"], | |
| 200 | + | }, | |
| 201 | + | { | |
| 202 | + | id: "jira", | |
| 203 | + | name: "Jira", | |
| 204 | + | category: "issues", | |
| 205 | + | description: "Agents read TECH-1234 when work mentions it; import tickets, and they hear back when the work lands.", | |
| 206 | + | scopes: W, | |
| 207 | + | status: "available", | |
| 208 | + | href: { workspace: "/:workspace/-/integrations/trackers?add=jira#add" }, | |
| 209 | + | capabilities: ["Agents can read", "Writes back"], | |
| 210 | + | provider: "jira", | |
| 211 | + | keywords: ["atlassian", "tracker", "tickets"], | |
| 212 | + | }, | |
| 213 | + | soon("asana", "Asana", "issues", BOTH, "Tasks agents can read, import as issues, and close when the work merges.", ["Agents can read", "Writes back"], { | |
| 214 | + | description: "Tasks assigned to you in Asana, in your g1t inbox.", | |
| 215 | + | capabilities: ["Agents can read"], | |
| 216 | + | }), | |
| 217 | + | soon("shortcut", "Shortcut", "issues", W, "Stories agents read when work mentions them, updated when the work lands.", ["Agents can read", "Writes back"]), | |
| 218 | + | soon("clickup", "ClickUp", "issues", W, "Tasks agents can read and import, kept in step with their pull requests.", ["Agents can read", "Writes back"]), | |
| 219 | + | soon("trello", "Trello", "issues", W, "Cards become issues, and move along their board as the work does.", ["Opens issues", "Writes back"]), | |
| 220 | + | ||
| 221 | + | // Chat & meetings ------------------------------------------------------- | |
| 222 | + | soon("slack", "Slack", "chat", BOTH, "Alerts and agent updates in the channels your team already reads, and agents to talk to there.", ["Alerts into chat", "Agents can act"], { | |
| 223 | + | description: "Your status in g1t follows the one you set in Slack, and the other way round.", | |
| 224 | + | capabilities: ["Sets your status"], | |
| 225 | + | }), | |
| 226 | + | soon("microsoft-teams", "Microsoft Teams", "chat", BOTH, "Alerts and agent updates posted to Teams channels, with agents to talk to there.", ["Alerts into chat", "Agents can act"], { | |
| 227 | + | description: "Your status in g1t follows your Teams presence.", | |
| 228 | + | capabilities: ["Sets your status"], | |
| 229 | + | }), | |
| 230 | + | soon("discord", "Discord", "chat", W, "Releases, alerts and agent updates posted to a Discord server.", ["Alerts into chat"]), | |
| 231 | + | soon("zoom", "Zoom", "chat", BOTH, "Meeting notes and recordings agents can read when work refers to them.", ["Agents can read"], { | |
| 232 | + | description: "Sets your status to On a call while you are in a Zoom meeting.", | |
| 233 | + | capabilities: ["Sets your status"], | |
| 234 | + | }), | |
| 235 | + | soon("google-meet", "Google Meet", "chat", P, "Sets your status to On a call while you are in a meeting, and starts a call from any channel.", ["Sets your status"]), | |
| 236 | + | ||
| 237 | + | // Calendar & email ------------------------------------------------------ | |
| 238 | + | soon("google-calendar", "Google Calendar", "calendar", P, "Sets your status to In a meeting while you are in one; agents can find a time that suits everyone.", ["Sets your status", "Finds a time"]), | |
| 239 | + | soon("outlook-calendar", "Outlook Calendar", "calendar", P, "Sets your status to In a meeting while you are in one; agents can find a time that suits everyone.", ["Sets your status", "Finds a time"]), | |
| 240 | + | soon("gmail", "Gmail", "calendar", P, "Forward a thread to an agent, or turn an email into an issue, from your own inbox.", ["Opens issues", "Agents can read"]), | |
| 241 | + | soon("outlook-mail", "Outlook Mail", "calendar", P, "Forward a thread to an agent, or turn an email into an issue, from your own inbox.", ["Opens issues", "Agents can read"]), | |
| 242 | + | ||
| 243 | + | // Docs & knowledge ------------------------------------------------------ | |
| 244 | + | soon("notion", "Notion", "docs", BOTH, "Import pages into Docs, and let agents read the ones work refers to.", ["Agents can read"], { | |
| 245 | + | description: "Reach your own Notion pages, to bring them into Docs.", | |
| 246 | + | capabilities: ["Agents can read"], | |
| 247 | + | }), | |
| 248 | + | soon("confluence", "Confluence", "docs", W, "Import spaces into Docs, and let agents read pages work refers to.", ["Agents can read"]), | |
| 249 | + | soon("google-docs", "Google Docs", "docs", BOTH, "Agents read the specs and plans linked from issues and chat.", ["Agents can read"], { | |
| 250 | + | description: "Docs you share in chat open for agents as well as people.", | |
| 251 | + | capabilities: ["Agents can read"], | |
| 252 | + | }), | |
| 253 | + | soon("coda", "Coda", "docs", W, "Agents read the docs and tables linked from issues and chat.", ["Agents can read"]), | |
| 254 | + | ||
| 255 | + | // Monitoring & incidents ------------------------------------------------ | |
| 256 | + | { | |
| 257 | + | id: "sentry", | |
| 258 | + | name: "Sentry", | |
| 259 | + | category: "monitoring", | |
| 260 | + | description: "New errors open issues with the stack trace, and resolve in Sentry when the fix merges.", | |
| 261 | + | scopes: W, | |
| 262 | + | status: "available", | |
| 263 | + | href: { workspace: "/:workspace/-/integrations/alerts?add=sentry#add" }, | |
| 264 | + | capabilities: ["Opens issues", "Agents can read", "Writes back"], | |
| 265 | + | provider: "sentry", | |
| 266 | + | keywords: ["errors", "alerts", "exceptions"], | |
| 267 | + | }, | |
| 268 | + | { | |
| 269 | + | id: "datadog", | |
| 270 | + | name: "Datadog", | |
| 271 | + | category: "monitoring", | |
| 272 | + | description: "Monitors that trigger open issues, and recoveries are noted on them.", | |
| 273 | + | scopes: W, | |
| 274 | + | status: "available", | |
| 275 | + | href: { workspace: "/:workspace/-/integrations/alerts?add=datadog#add" }, | |
| 276 | + | capabilities: ["Opens issues"], | |
| 277 | + | provider: "datadog", | |
| 278 | + | keywords: ["monitors", "alerts", "apm"], | |
| 279 | + | }, | |
| 280 | + | { | |
| 281 | + | id: "alerts-webhook", | |
| 282 | + | name: "Alerts webhook", | |
| 283 | + | category: "monitoring", | |
| 284 | + | description: "Anything that can send signed JSON opens issues: your own scripts, or any monitoring tool.", | |
| 285 | + | scopes: W, | |
| 286 | + | status: "available", | |
| 287 | + | href: { workspace: "/:workspace/-/integrations/alerts?add=webhook#add" }, | |
| 288 | + | capabilities: ["Opens issues"], | |
| 289 | + | provider: "webhook", | |
| 290 | + | keywords: ["incoming", "http", "alerts", "pagerduty", "grafana"], | |
| 291 | + | }, | |
| 292 | + | soon("pagerduty", "PagerDuty", "monitoring", BOTH, "Incidents open issues an agent can start on, and resolve when the fix lands.", ["Opens issues", "Writes back"], { | |
| 293 | + | description: "Sets your status to On call while you are on call.", | |
| 294 | + | capabilities: ["Sets your status"], | |
| 295 | + | }), | |
| 296 | + | soon("opsgenie", "Opsgenie", "monitoring", W, "Alerts open issues, and close when the fix lands.", ["Opens issues", "Writes back"]), | |
| 297 | + | soon("incident-io", "incident.io", "monitoring", W, "Incidents get a channel in g1t, with agents to dig through what changed.", ["Opens issues", "Alerts into chat"]), | |
| 298 | + | soon("grafana", "Grafana", "monitoring", W, "Alert rules open issues, and agents read the dashboards they link to.", ["Opens issues", "Agents can read"]), | |
| 299 | + | soon("new-relic", "New Relic", "monitoring", W, "Alerts open issues, with the traces agents need to find the cause.", ["Opens issues", "Agents can read"]), | |
| 300 | + | soon("honeycomb", "Honeycomb", "monitoring", W, "Triggers open issues, and agents query traces while they work.", ["Opens issues", "Agents can read"]), | |
| 301 | + | soon("better-stack", "Better Stack", "monitoring", W, "Uptime incidents and log alerts open issues.", ["Opens issues"]), | |
| 302 | + | ||
| 303 | + | // Cloud & deploy -------------------------------------------------------- | |
| 304 | + | soon("cloudflare", "Cloudflare", "cloud", W, "Deploy projects to your own Cloudflare account, and manage their domains.", ["Deploys"]), | |
| 305 | + | soon("aws", "Amazon Web Services", "cloud", W, "Workflows and deployments reach AWS with short-lived credentials, no stored keys.", ["Deploys"]), | |
| 306 | + | soon("google-cloud", "Google Cloud", "cloud", W, "Workflows and deployments reach Google Cloud with short-lived credentials.", ["Deploys"]), | |
| 307 | + | soon("azure", "Microsoft Azure", "cloud", W, "Workflows and deployments reach Azure with short-lived credentials.", ["Deploys"]), | |
| 308 | + | soon("vercel", "Vercel", "cloud", W, "Preview and production deployments as checks on pull requests.", ["Deploys", "Agents can read"]), | |
| 309 | + | soon("netlify", "Netlify", "cloud", W, "Deploy previews as checks on pull requests.", ["Deploys", "Agents can read"]), | |
| 310 | + | soon("fly-io", "Fly.io", "cloud", W, "Deploy apps from workflows, and roll back from the deployment's page.", ["Deploys"]), | |
| 311 | + | soon("kubernetes", "Kubernetes", "cloud", W, "Roll out to your own clusters from workflows, with each rollout's status on g1t.", ["Deploys"]), | |
| 312 | + | ||
| 313 | + | // AI models ------------------------------------------------------------- | |
| 314 | + | model("anthropic", "Anthropic", "Claude models on your own Anthropic account, billed there.", ["claude"]), | |
| 315 | + | model("openai", "OpenAI", "GPT models on your own OpenAI account, billed there.", ["gpt"]), | |
| 316 | + | model("gemini", "Google Gemini", "Gemini models on your own Google AI account, billed there.", ["google"]), | |
| 317 | + | model("xai", "xAI", "Grok models on your own xAI account, billed there.", ["grok"]), | |
| 318 | + | model("mistral", "Mistral", "Mistral models on your own account, billed there."), | |
| 319 | + | model("deepseek", "DeepSeek", "DeepSeek models on your own account, billed there."), | |
| 320 | + | model("azure_openai", "Azure OpenAI", "Your Azure OpenAI deployments, in your own subscription.", ["microsoft"]), | |
| 321 | + | model("openrouter", "OpenRouter", "Many labs' models through one OpenRouter key."), | |
| 322 | + | model("groq", "Groq", "Fast open models on your own Groq account."), | |
| 323 | + | model("together", "Together AI", "Open models on your own Together AI account."), | |
| 324 | + | model("fireworks", "Fireworks AI", "Open models on your own Fireworks AI account."), | |
| 325 | + | model("cerebras", "Cerebras", "Fast open models on your own Cerebras account."), | |
| 326 | + | model("anthropic_endpoint", "Anthropic-compatible endpoint", "Any server that speaks the Anthropic API, such as your own gateway.", ["self-hosted", "proxy"]), | |
| 327 | + | model("openai_endpoint", "OpenAI-compatible endpoint", "Any server that speaks the OpenAI API, such as a model you host yourself.", ["self-hosted", "proxy", "ollama", "vllm"]), | |
| 328 | + | { | |
| 329 | + | id: "ai-gateway", | |
| 330 | + | name: "AI Gateway", | |
| 331 | + | category: "ai", | |
| 332 | + | description: "Your own code's model requests through g1t, with the workspace's access tokens, logged and costed.", | |
| 333 | + | scopes: W, | |
| 334 | + | status: "available", | |
| 335 | + | href: { workspace: "/:workspace/-/gateway" }, | |
| 336 | + | capabilities: ["Runs models"], | |
| 337 | + | keywords: ["proxy", "api", "tokens"], | |
| 338 | + | }, | |
| 339 | + | { | |
| 340 | + | id: "mcp", | |
| 341 | + | name: "MCP clients", | |
| 342 | + | category: "ai", | |
| 343 | + | description: "Your own agent, connected to g1t's MCP server, works in g1t as you.", | |
| 344 | + | scopes: P, | |
| 345 | + | status: "available", | |
| 346 | + | href: { personal: "/settings/applications" }, | |
| 347 | + | capabilities: ["Agents can act"], | |
| 348 | + | keywords: ["model context protocol", "agent", "applications", "oauth"], | |
| 349 | + | }, | |
| 350 | + | soon("amazon-bedrock", "Amazon Bedrock", "ai", W, "Models in your own AWS account, billed there.", ["Runs models"]), | |
| 351 | + | soon("vertex-ai", "Google Vertex AI", "ai", W, "Models in your own Google Cloud project, billed there.", ["Runs models"]), | |
| 352 | + | soon("hugging-face", "Hugging Face", "ai", W, "Inference endpoints you run on Hugging Face, for any kind of work.", ["Runs models"]), | |
| 353 | + | ||
| 354 | + | // Data & analytics ------------------------------------------------------ | |
| 355 | + | soon("postgresql", "PostgreSQL", "data", W, "Agents read your schema, and run read-only queries while they investigate.", ["Agents can read"]), | |
| 356 | + | soon("snowflake", "Snowflake", "data", W, "Agents answer questions from your warehouse with read-only queries.", ["Agents can read"]), | |
| 357 | + | soon("bigquery", "BigQuery", "data", W, "Agents answer questions from your datasets with read-only queries.", ["Agents can read"]), | |
| 358 | + | soon("posthog", "PostHog", "data", W, "Agents read product analytics and feature flags while they plan a change.", ["Agents can read"]), | |
| 359 | + | soon("amplitude", "Amplitude", "data", W, "Agents read the charts that work refers to.", ["Agents can read"]), | |
| 360 | + | soon("segment", "Segment", "data", W, "Agents read your tracking plan, and check new events against it.", ["Agents can read"]), | |
| 361 | + | ||
| 362 | + | // CRM & support --------------------------------------------------------- | |
| 363 | + | soon("zendesk", "Zendesk", "crm", W, "Turn a ticket into an issue, and tell the customer when the fix ships.", ["Opens issues", "Writes back"]), | |
| 364 | + | soon("intercom", "Intercom", "crm", W, "Conversations become issues, and hear back when the fix ships.", ["Opens issues", "Writes back"]), | |
| 365 | + | soon("hubspot", "HubSpot", "crm", W, "Agents read the account behind a request when work mentions it.", ["Agents can read"]), | |
| 366 | + | soon("salesforce", "Salesforce", "crm", W, "Agents read the account and case behind a request.", ["Agents can read"]), | |
| 367 | + | soon("stripe", "Stripe", "crm", W, "Agents read customers and payments while they work on billing.", ["Agents can read"]), | |
| 368 | + | ||
| 369 | + | // Design ---------------------------------------------------------------- | |
| 370 | + | soon("figma", "Figma", "design", BOTH, "Agents read the frames linked from an issue, and build to them.", ["Agents can read"], { | |
| 371 | + | description: "Reach your own Figma files, to link and preview them in chat and issues.", | |
| 372 | + | capabilities: ["Agents can read"], | |
| 373 | + | }), | |
| 374 | + | soon("miro", "Miro", "design", W, "Agents read the boards linked from issues and chat.", ["Agents can read"]), | |
| 375 | + | soon("storybook", "Storybook", "design", W, "Each pull request's stories as a check, with what changed.", ["Agents can read"]), | |
| 376 | + | ||
| 377 | + | // Security & identity --------------------------------------------------- | |
| 378 | + | soon("okta", "Okta", "security", W, "Single sign-on, and people added and removed with your directory.", ["Signs you in", "Syncs people"]), | |
| 379 | + | soon("entra-id", "Microsoft Entra ID", "security", W, "Single sign-on, and people added and removed with your directory.", ["Signs you in", "Syncs people"]), | |
| 380 | + | soon("google-workspace", "Google Workspace", "security", BOTH, "Single sign-on, and people added and removed with your directory.", ["Signs you in", "Syncs people"], { | |
| 381 | + | description: "Sign in to g1t with your Google account.", | |
| 382 | + | capabilities: ["Signs you in"], | |
| 383 | + | }), | |
| 384 | + | soon("1password", "1Password", "security", W, "Secrets from a vault, read by workflows and deployments when they run.", ["Deploys"]), | |
| 385 | + | soon("snyk", "Snyk", "security", W, "Findings open issues, and an agent opens the fix.", ["Opens issues"]), | |
| 386 | + | ||
| 387 | + | // Files & storage ------------------------------------------------------- | |
| 388 | + | soon("google-drive", "Google Drive", "files", BOTH, "Agents read the files linked from issues and chat.", ["Agents can read"], { | |
| 389 | + | description: "Attach files from your Drive in chat, without downloading them first.", | |
| 390 | + | capabilities: ["Agents can read"], | |
| 391 | + | }), | |
| 392 | + | soon("dropbox", "Dropbox", "files", BOTH, "Agents read the files linked from issues and chat.", ["Agents can read"], { | |
| 393 | + | description: "Attach files from your Dropbox in chat.", | |
| 394 | + | capabilities: ["Agents can read"], | |
| 395 | + | }), | |
| 396 | + | soon("onedrive", "OneDrive and SharePoint", "files", BOTH, "Agents read the files linked from issues and chat.", ["Agents can read"], { | |
| 397 | + | description: "Attach files from your OneDrive in chat.", | |
| 398 | + | capabilities: ["Agents can read"], | |
| 399 | + | }), | |
| 400 | + | soon("box", "Box", "files", W, "Agents read the files linked from issues and chat.", ["Agents can read"]), | |
| 401 | + | ]; | |
| 402 | + | ||
| 403 | + | /** The connector by its id. */ | |
| 404 | + | export function connectorById(id: string): Connector | undefined { | |
| 405 | + | return CONNECTORS.find((connector) => connector.id === id); | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | /** A connector as `scope` shows it, or null when it cannot be connected there. */ | |
| 409 | + | export function connectorView(connector: Connector, scope: ConnectorScope): ConnectorView | null { | |
| 410 | + | if (!connector.scopes.includes(scope)) return null; | |
| 411 | + | const own = scope === "personal" ? connector.personal : undefined; | |
| 412 | + | const status = own?.status ?? connector.status; | |
| 413 | + | const other: ConnectorScope = scope === "workspace" ? "personal" : "workspace"; | |
| 414 | + | return { | |
| 415 | + | id: connector.id, | |
| 416 | + | name: connector.name, | |
| 417 | + | category: connector.category, | |
| 418 | + | description: own?.description ?? connector.description, | |
| 419 | + | status, | |
| 420 | + | href: status === "available" ? (connector.href?.[scope] ?? null) : null, | |
| 421 | + | capabilities: own?.capabilities ?? connector.capabilities ?? [], | |
| 422 | + | provider: connector.provider ?? null, | |
| 423 | + | keywords: connector.keywords ?? [], | |
| 424 | + | alsoIn: connector.scopes.includes(other) ? other : null, | |
| 425 | + | }; | |
| 426 | + | } | |
| 427 | + | ||
| 428 | + | /** Every connector `scope` can show, in catalog order. */ | |
| 429 | + | export function connectorsFor(scope: ConnectorScope): ConnectorView[] { | |
| 430 | + | return CONNECTORS.map((connector) => connectorView(connector, scope)).filter((view): view is ConnectorView => view != null); | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | /** A setup path with its `:workspace` filled in. */ | |
| 434 | + | export function connectorPath(path: string, workspace: string): string { | |
| 435 | + | return path.replace(/:workspace\b/g, encodeURIComponent(workspace)); | |
| 436 | + | } |
| 11 | 11 | export * from "./clients"; | |
| 12 | 12 | export * from "./codeowners"; | |
| 13 | 13 | export * from "./compute"; | |
| 14 | + | export * from "./connectors"; | |
| 14 | 15 | export * from "./context"; | |
| 15 | 16 | export * from "./d1"; | |
| 16 | 17 | export * from "./docs"; |
| 13 | 13 | ["trial_used", "https://g1t.sh/acme/-/billing"], | |
| 14 | 14 | ["limit", "https://g1t.sh/acme/-/billing#limit"], | |
| 15 | 15 | ["issue_cap", "https://g1t.sh/acme/-/billing#caps"], | |
| 16 | − | ["no_model", "https://g1t.sh/acme/-/integrations"], | |
| 16 | + | ["no_model", "https://g1t.sh/acme/-/integrations/models"], | |
| 17 | 17 | // Nothing on a page fixes these: support does, or a minute's wait. | |
| 18 | 18 | ["paused", null], | |
| 19 | 19 | ["billing_unavailable", null], |
| 35 | 35 | case "issue_cap": | |
| 36 | 36 | return `${SITE}/${slug}/-/billing#caps`; | |
| 37 | 37 | case "no_model": | |
| 38 | − | return `${SITE}/${slug}/-/integrations`; | |
| 38 | + | return `${SITE}/${slug}/-/integrations/models`; | |
| 39 | 39 | default: | |
| 40 | 40 | return null; | |
| 41 | 41 | } |