Skip to content
613 linesCodeBlameRaw
1//! The integrations service: a workspace's connections to systems outside
2//! g1t, and everything that crosses between them.
3//!
4//! - **AI providers.** A workspace keeps the credentials for the AI
5//! providers its team uses (Anthropic, OpenAI, Gemini, and anything
6//! compatible with either API), sealed, so work can be handed to them.
7//! Workspace admins add, test, update and remove them.
8//! - **Alerts.** Sentry, Datadog or any signed webhook opens an issue in a
9//! repository, once per problem however often it fires.
10//! - **Trackers.** A Jira or Linear key, such as `TECH-1234`, resolves to the
11//! ticket: people and their tools read it, people import it as an issue,
12//! and when the work lands the ticket is told.
13//! - **Ask g1t.** A conversation with g1t from the top bar, answered by the
14//! AI provider and model an owner chose from the workspace's own
15//! connections, on the workspace's own key. g1t hosts no model for it and
16//! bills nothing for it; the key never leaves this service.
17//!
18//! Mirrors `packages/contracts/src/integrations.ts`.
19
20use serde::{Deserialize, Serialize};
21
22use crate::repos::RepoPath;
23use crate::{User, Viewer};
24
25/// Which outside system a connection is to.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Provider {
29 // Model providers: the labs.
30 Anthropic,
31 Openai,
32 /// Through Gemini's OpenAI-compatible endpoint.
33 Gemini,
34 Xai,
35 Mistral,
36 Deepseek,
37 // Model providers: platforms that serve many labs' models.
38 /// A deployment on the workspace's own Azure OpenAI resource.
39 AzureOpenai,
40 Openrouter,
41 Groq,
42 Together,
43 Fireworks,
44 Cerebras,
45 // Model providers: anything else.
46 /// Any endpoint that speaks Anthropic's Messages API: the workspace's
47 /// own Cloudflare AI Gateway, LiteLLM, a proxy in front of Bedrock or
48 /// Vertex, or a self-hosted model.
49 AnthropicEndpoint,
50 /// Any endpoint that speaks OpenAI's Chat Completions API: vLLM,
51 /// Ollama behind a tunnel, LiteLLM, a gateway.
52 OpenaiEndpoint,
53 // Alerts.
54 Sentry,
55 Datadog,
56 /// Anything that can send a signed JSON request.
57 Webhook,
58 // Trackers.
59 Jira,
60 Linear,
61}
62
63/// What g1t knows about a provider.
64pub struct Spec {
65 pub provider: Provider,
66 pub name: &'static str,
67 pub label: &'static str,
68 pub kind: ProviderKind,
69 /// For a model provider: the API it speaks, `anthropic` or `openai`.
70 pub api: &'static str,
71 /// For a model provider with a fixed address: where its API is, with
72 /// the version for OpenAI's API and without it for Anthropic's. Empty
73 /// when the connection gives its own.
74 pub base_url: &'static str,
75 /// The header the key goes in; `authorization` means `Bearer <key>`.
76 pub auth_header: &'static str,
77}
78
79const fn model(provider: Provider, name: &'static str, label: &'static str, api: &'static str, base_url: &'static str, auth_header: &'static str) -> Spec {
80 Spec {
81 provider,
82 name,
83 label,
84 kind: ProviderKind::Models,
85 api,
86 base_url,
87 auth_header,
88 }
89}
90
91const fn other(provider: Provider, name: &'static str, label: &'static str, kind: ProviderKind) -> Spec {
92 Spec {
93 provider,
94 name,
95 label,
96 kind,
97 api: "",
98 base_url: "",
99 auth_header: "",
100 }
101}
102
103/// Every provider, in the order people are shown them.
104pub const PROVIDERS: [Spec; 19] = [
105 model(Provider::Anthropic, "anthropic", "Anthropic", "anthropic", "https://api.anthropic.com", "x-api-key"),
106 model(Provider::Openai, "openai", "OpenAI", "openai", "https://api.openai.com/v1", "authorization"),
107 model(Provider::Gemini, "gemini", "Google Gemini", "openai", "https://generativelanguage.googleapis.com/v1beta/openai", "authorization"),
108 model(Provider::Xai, "xai", "xAI", "openai", "https://api.x.ai/v1", "authorization"),
109 model(Provider::Mistral, "mistral", "Mistral", "openai", "https://api.mistral.ai/v1", "authorization"),
110 model(Provider::Deepseek, "deepseek", "DeepSeek", "openai", "https://api.deepseek.com/v1", "authorization"),
111 model(Provider::AzureOpenai, "azure_openai", "Azure OpenAI", "openai", "", "api-key"),
112 model(Provider::Openrouter, "openrouter", "OpenRouter", "openai", "https://openrouter.ai/api/v1", "authorization"),
113 model(Provider::Groq, "groq", "Groq", "openai", "https://api.groq.com/openai/v1", "authorization"),
114 model(Provider::Together, "together", "Together AI", "openai", "https://api.together.xyz/v1", "authorization"),
115 model(Provider::Fireworks, "fireworks", "Fireworks AI", "openai", "https://api.fireworks.ai/inference/v1", "authorization"),
116 model(Provider::Cerebras, "cerebras", "Cerebras", "openai", "https://api.cerebras.ai/v1", "authorization"),
117 model(Provider::AnthropicEndpoint, "anthropic_endpoint", "Anthropic-compatible endpoint", "anthropic", "", "x-api-key"),
118 model(Provider::OpenaiEndpoint, "openai_endpoint", "OpenAI-compatible endpoint", "openai", "", "authorization"),
119 other(Provider::Sentry, "sentry", "Sentry", ProviderKind::Alerts),
120 other(Provider::Datadog, "datadog", "Datadog", ProviderKind::Alerts),
121 other(Provider::Webhook, "webhook", "Webhook", ProviderKind::Alerts),
122 other(Provider::Jira, "jira", "Jira", ProviderKind::Tracker),
123 other(Provider::Linear, "linear", "Linear", ProviderKind::Tracker),
124];
125
126impl Provider {
127 pub fn spec(self) -> &'static Spec {
128 PROVIDERS
129 .iter()
130 .find(|spec| spec.provider == self)
131 .expect("every provider is in the catalogue")
132 }
133
134 pub fn all() -> impl Iterator<Item = Provider> {
135 PROVIDERS.iter().map(|spec| spec.provider)
136 }
137
138 pub fn name(self) -> &'static str {
139 self.spec().name
140 }
141
142 pub fn parse(name: &str) -> Option<Provider> {
143 PROVIDERS.iter().find(|spec| spec.name == name).map(|spec| spec.provider)
144 }
145
146 /// What people call it.
147 pub fn label(self) -> &'static str {
148 self.spec().label
149 }
150
151 pub fn kind(self) -> ProviderKind {
152 self.spec().kind
153 }
154
155 /// Whether it sends g1t requests, at the connection's own address.
156 pub fn receives(self) -> bool {
157 self.kind() == ProviderKind::Alerts
158 }
159
160 /// For a model provider, the API it speaks: `anthropic` or `openai`.
161 pub fn api(self) -> &'static str {
162 self.spec().api
163 }
164
165 /// Whether the connection gives the address, rather than g1t knowing it.
166 pub fn own_address(self) -> bool {
167 self.kind() == ProviderKind::Models && self.spec().base_url.is_empty()
168 }
169}
170
171#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
172#[serde(rename_all = "snake_case")]
173pub enum ProviderKind {
174 /// An AI provider whose credentials the workspace keeps.
175 Models,
176 /// Problems that become issues.
177 Alerts,
178 /// Tickets that people read and import.
179 Tracker,
180}
181
182/// A connection's settings: everything about it except its secrets. Each
183/// provider uses the fields that apply to it.
184#[derive(Clone, Debug, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct ConnectionConfig {
187 /// For alerts: the repository issues are opened in, `owner/name`. For a
188 /// tracker: where an imported ticket goes when no repository is named.
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub repo: Option<String>,
191 /// For alerts: the label put on each issue opened. `bug` when unset.
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub label: Option<String>,
194 /// Tell the outside system when the work lands: resolve the Sentry
195 /// issue, comment on the ticket.
196 #[serde(default = "yes")]
197 pub write_back: bool,
198 /// Sentry: the organization's slug.
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub organization: Option<String>,
201 /// The system's address, for Jira (`https://acme.atlassian.net`) or a
202 /// Sentry that is not sentry.io.
203 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub site: Option<String>,
205 /// Jira: the account the API token belongs to.
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub email: Option<String>,
208 /// Jira project keys or Linear team keys this connection answers for,
209 /// such as `TECH`. Empty answers for every key.
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
211 pub keys: Vec<String>,
212 /// Your own endpoint: its base URL, without `/v1`.
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub base_url: Option<String>,
215 /// Your own endpoint: send the key as `x-api-key` (the default) or as
216 /// `authorization: Bearer`.
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub auth_header: Option<String>,
219 /// For an AI provider: the model the team uses on it, when it names one.
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub model: Option<String>,
222}
223
224fn yes() -> bool {
225 true
226}
227
228impl Default for ConnectionConfig {
229 fn default() -> Self {
230 ConnectionConfig {
231 repo: None,
232 label: None,
233 write_back: true,
234 organization: None,
235 site: None,
236 email: None,
237 keys: Vec::new(),
238 base_url: None,
239 auth_header: None,
240 model: None,
241 }
242 }
243}
244
245/// A connection, as anyone in the workspace sees it. Secrets are never
246/// shown after they are saved; `secretHint` is enough to tell keys apart.
247#[derive(Clone, Debug, Serialize, Deserialize)]
248#[serde(rename_all = "camelCase")]
249pub struct Connection {
250 pub id: String,
251 pub workspace: String,
252 pub provider: Provider,
253 pub kind: ProviderKind,
254 pub name: String,
255 pub config: ConnectionConfig,
256 /// The last four characters of the saved key, such as `…3f9a`.
257 pub secret_hint: Option<String>,
258 /// For a provider that sends g1t requests: where it sends them.
259 pub webhook_url: Option<String>,
260 pub created_by: String,
261 /// RFC 3339.
262 pub created_at: String,
263 pub last_used_at: Option<String>,
264 /// The last thing that went wrong talking to it, until it next works.
265 pub last_error: Option<String>,
266 /// For a model provider: the models it offered when last checked.
267 #[serde(default)]
268 pub models: Vec<String>,
269}
270
271/// One request an outside system sent, and what g1t did with it.
272#[derive(Clone, Debug, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct Delivery {
275 pub id: String,
276 /// RFC 3339.
277 pub received_at: String,
278 /// What it was about, in the sender's terms: `issue.created`.
279 pub event: String,
280 /// `opened`, `updated`, `reopened`, `ignored` or `refused`.
281 pub outcome: String,
282 pub detail: String,
283 /// The issue it opened or updated, `owner/name#number`.
284 pub issue: Option<String>,
285}
286
287/// Something outside g1t, fetched as it is now. Its text was written outside
288/// g1t, so it is reference material and never instructions.
289#[derive(Clone, Debug, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct ContextItem {
292 pub provider: Provider,
293 /// `TECH-1234`, or the Sentry issue's short id.
294 pub key: String,
295 pub title: String,
296 pub url: String,
297 /// Its status in that system: `In Progress`, `unresolved`.
298 pub status: Option<String>,
299 /// Its description, as plain text, shortened if long.
300 pub body: String,
301 /// RFC 3339: when g1t fetched it.
302 pub fetched_at: String,
303}
304
305/// An issue's tie to something outside g1t.
306#[derive(Clone, Debug, Serialize, Deserialize)]
307#[serde(rename_all = "camelCase")]
308pub struct Link {
309 pub provider: Provider,
310 pub connection_id: String,
311 pub key: String,
312 pub title: String,
313 pub url: String,
314 /// How many times an alert has fired for it.
315 pub count: u32,
316 /// RFC 3339.
317 pub first_seen: String,
318 pub last_seen: String,
319}
320
321// --- Methods -----------------------------------------------------------------
322
323/// `list`. Returns `Outcome<Vec<Connection>>`. Members only.
324#[derive(Debug, Serialize, Deserialize)]
325pub struct ListArgs {
326 pub workspace: String,
327 pub viewer: Viewer,
328}
329
330/// `connect`. Returns `Outcome<Connected>`. Owners only.
331#[derive(Debug, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub struct ConnectArgs {
334 pub actor: User,
335 pub workspace: String,
336 pub provider: Provider,
337 #[serde(default)]
338 pub name: Option<String>,
339 #[serde(default)]
340 pub config: ConnectionConfig,
341 /// The API key or token g1t uses to call it.
342 #[serde(default)]
343 pub secret: Option<String>,
344 /// What it signs its requests to g1t with: Sentry's client secret.
345 /// Made by g1t for Datadog and webhooks, and shown once.
346 #[serde(default)]
347 pub signing_secret: Option<String>,
348}
349
350#[derive(Clone, Debug, Serialize, Deserialize)]
351#[serde(rename_all = "camelCase")]
352pub struct Connected {
353 pub connection: Connection,
354 /// A signing secret g1t made, shown this once.
355 pub signing_secret: Option<String>,
356}
357
358/// `update`: only the fields given change. Returns `Outcome<Connection>`.
359/// Owners only.
360#[derive(Debug, Serialize, Deserialize)]
361#[serde(rename_all = "camelCase")]
362pub struct UpdateArgs {
363 pub actor: User,
364 pub workspace: String,
365 pub id: String,
366 #[serde(default)]
367 pub name: Option<String>,
368 #[serde(default)]
369 pub config: Option<ConnectionConfig>,
370 #[serde(default)]
371 pub secret: Option<String>,
372 #[serde(default)]
373 pub signing_secret: Option<String>,
374}
375
376/// `disconnect` and `test`. `disconnect` returns `Outcome<bool>`; `test`
377/// returns `Outcome<Tested>`. Owners only.
378#[derive(Debug, Serialize, Deserialize)]
379pub struct ConnectionArgs {
380 pub actor: User,
381 pub workspace: String,
382 pub id: String,
383}
384
385#[derive(Clone, Debug, Serialize, Deserialize)]
386pub struct Tested {
387 pub ok: bool,
388 pub message: String,
389}
390
391/// `deliveries`: the latest requests a connection received, newest first.
392/// Returns `Outcome<Vec<Delivery>>`. Members only.
393#[derive(Debug, Serialize, Deserialize)]
394pub struct DeliveriesArgs {
395 pub workspace: String,
396 pub viewer: Viewer,
397 pub id: String,
398}
399
400/// `receive`: a request an outside system sent to a connection's address.
401/// Returns `Received`. Anyone can send one; only a signed one is acted on.
402#[derive(Debug, Serialize, Deserialize)]
403pub struct ReceiveArgs {
404 pub id: String,
405 /// Header names in lowercase.
406 pub headers: std::collections::HashMap<String, String>,
407 pub body: String,
408}
409
410#[derive(Clone, Debug, Serialize, Deserialize)]
411pub struct Received {
412 /// The HTTP status to answer with.
413 pub status: u16,
414 pub message: String,
415}
416
417/// `resolve`: fetches one outside reference. Returns `Outcome<ContextItem>`.
418/// Members of the workspace only.
419#[derive(Debug, Serialize, Deserialize)]
420pub struct ResolveArgs {
421 pub workspace: String,
422 pub viewer: Viewer,
423 /// `TECH-1234`, or a Jira, Linear or Sentry address.
424 pub reference: String,
425}
426
427/// `import`: opens an issue from a ticket. Returns `Outcome<Imported>`.
428#[derive(Debug, Serialize, Deserialize)]
429pub struct ImportArgs {
430 pub actor: User,
431 pub repo: RepoPath,
432 pub reference: String,
433}
434
435#[derive(Clone, Debug, Serialize, Deserialize)]
436pub struct Imported {
437 pub number: u32,
438 pub item: ContextItem,
439 /// False when the ticket had been imported already, and `number` is
440 /// that issue.
441 pub created: bool,
442}
443
444/// `links`: what an issue is tied to outside g1t. Returns `Vec<Link>`.
445/// Callers must have checked the viewer may see the issue.
446#[derive(Debug, Serialize, Deserialize)]
447#[serde(rename_all = "camelCase")]
448pub struct LinksArgs {
449 pub repo: RepoPath,
450 pub number: u32,
451}
452
453// --- Ask g1t ---------------------------------------------------------------
454
455/// The most messages one ask sends: the conversation's latest turns.
456pub const ASK_MAX_MESSAGES: usize = 40;
457/// The longest one message may be, in characters.
458pub const ASK_MAX_MESSAGE_CHARS: usize = 16_000;
459/// The longest a whole conversation may be, in characters.
460pub const ASK_MAX_TOTAL_CHARS: usize = 64_000;
461/// The most tokens one answer may run to.
462pub const ASK_MAX_OUTPUT_TOKENS: u32 = 4_096;
463
464/// What Ask g1t answers with in a workspace: the connection and model an
465/// owner chose, or why it cannot answer yet.
466#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
467pub struct AskSetup {
468 pub connection_id: Option<String>,
469 /// The connection's name, as its owner named it.
470 pub connection_name: Option<String>,
471 pub provider: Option<Provider>,
472 pub model: Option<String>,
473 /// Why Ask cannot answer, in words for the person asking. `None` when
474 /// it can.
475 pub problem: Option<String>,
476}
477
478/// `ask_settings`: what Ask answers with. Returns `Outcome<AskSetup>`.
479/// Members only.
480#[derive(Debug, Serialize, Deserialize)]
481pub struct AskSettingsArgs {
482 pub workspace: String,
483 pub viewer: Viewer,
484}
485
486/// `set_ask_settings`: chooses the connection and model Ask answers with;
487/// no `connection_id` turns Ask off. Returns `Outcome<AskSetup>`. Owners
488/// only.
489#[derive(Debug, Serialize, Deserialize)]
490pub struct SetAskSettingsArgs {
491 pub actor: User,
492 pub workspace: String,
493 #[serde(default)]
494 pub connection_id: Option<String>,
495 /// One of the models the connection offers; its own model (an Azure
496 /// deployment) when not given.
497 #[serde(default)]
498 pub model: Option<String>,
499}
500
501#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
502#[serde(rename_all = "snake_case")]
503pub enum AskRole {
504 User,
505 Assistant,
506}
507
508/// One turn of an Ask conversation.
509#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
510pub struct AskMessage {
511 pub role: AskRole,
512 pub content: String,
513}
514
515/// Where the person asking is in g1t, so the answer is about their work.
516#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
517pub struct AskPage {
518 /// The page's path, such as `/acme/web/issues/12`.
519 #[serde(default)]
520 pub path: Option<String>,
521 #[serde(default)]
522 pub repo: Option<RepoPath>,
523 #[serde(default)]
524 pub issue: Option<u32>,
525 #[serde(default)]
526 pub pull: Option<u32>,
527}
528
529/// `ask`: one answer, streamed as newline-delimited JSON [`AskEvent`]s:
530/// `meta` first, then `text` as it comes, then `done`, or `error` at any
531/// point. Members only. The conversation alternates, starting and ending
532/// with the person.
533#[derive(Debug, Serialize, Deserialize)]
534pub struct AskArgs {
535 pub actor: User,
536 pub workspace: String,
537 pub messages: Vec<AskMessage>,
538 #[serde(default)]
539 pub page: AskPage,
540}
541
542/// One line of an `ask` answer.
543#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
544#[serde(tag = "type", rename_all = "snake_case")]
545pub enum AskEvent {
546 /// Who is answering.
547 Meta { provider: Provider, provider_label: String, model: String },
548 /// The next piece of the answer.
549 Text { text: String },
550 /// The answer is complete. `stop_reason` as the provider said it, such
551 /// as `end_turn`, `stop` or `max_tokens`.
552 Done { stop_reason: Option<String> },
553 /// The answer stopped: what went wrong, for the person asking.
554 Error { message: String },
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn every_provider_is_named_once_and_found_again() {
563 let mut names: Vec<&str> = PROVIDERS.iter().map(|spec| spec.name).collect();
564 for provider in Provider::all() {
565 assert_eq!(Provider::parse(provider.name()), Some(provider));
566 }
567 names.sort();
568 names.dedup();
569 assert_eq!(names.len(), PROVIDERS.len());
570 }
571
572 #[test]
573 fn ai_providers_are_connections_like_any_other() {
574 assert_eq!(Provider::parse("anthropic"), Some(Provider::Anthropic));
575 let stored: Provider = serde_json::from_str("\"openai_endpoint\"").unwrap();
576 assert_eq!(stored.kind(), ProviderKind::Models);
577 assert!(stored.own_address());
578 assert!(!Provider::Anthropic.own_address());
579 assert!(Provider::all().any(|provider| provider == Provider::Groq));
580 assert!(Provider::all().any(|provider| provider == Provider::Sentry));
581 }
582
583 #[test]
584 fn stored_configs_with_old_fields_parse() {
585 let config: ConnectionConfig =
586 serde_json::from_str(r#"{"repo":"acme/web","assign":true,"gatewayModels":["*"],"writeBack":false}"#).unwrap();
587 assert_eq!(config.repo.as_deref(), Some("acme/web"));
588 assert!(!config.write_back);
589 }
590
591 #[test]
592 fn model_providers_say_how_to_reach_them() {
593 for spec in PROVIDERS.iter().filter(|spec| spec.kind == ProviderKind::Models) {
594 assert!(spec.api == "anthropic" || spec.api == "openai", "{}", spec.name);
595 assert!(!spec.auth_header.is_empty(), "{}", spec.name);
596 assert!(spec.base_url.is_empty() || spec.base_url.starts_with("https://"), "{}", spec.name);
597 }
598 }
599
600 #[test]
601 fn ask_events_are_tagged_lines() {
602 let line = serde_json::to_string(&AskEvent::Text { text: "Hi".into() }).unwrap();
603 assert_eq!(line, r#"{"type":"text","text":"Hi"}"#);
604 let meta: AskEvent =
605 serde_json::from_str(r#"{"type":"meta","provider":"anthropic","provider_label":"Anthropic","model":"claude"}"#).unwrap();
606 assert_eq!(
607 meta,
608 AskEvent::Meta { provider: Provider::Anthropic, provider_label: "Anthropic".into(), model: "claude".into() }
609 );
610 let page: AskPage = serde_json::from_str("{}").unwrap();
611 assert_eq!(page, AskPage::default());
612 }
613}