Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

396 linesCodeBlameRaw
1//! The integrations service: a workspace's connections to systems outside
2//! g1t, and everything that crosses between them.
3//!
4//! - **Alerts.** Sentry, Datadog or any signed webhook opens a work item
5//! filed from a repository, once per problem however often it fires.
6//! - **Trackers.** A Jira or Linear key, such as `TECH-1234`, resolves to the
7//! ticket: people and their tools read it, people import it as a work item,
8//! and when the work lands the ticket is told.
9//!
10//! A workspace's AI providers and Ask g1t are kept by the same service but
11//! are not integrations: see [`crate::ai`].
12//!
13//! Mirrors `packages/contracts/src/integrations.ts`.
14
15use serde::{Deserialize, Serialize};
16
17use crate::repos::RepoPath;
18use crate::{User, Viewer};
19
20/// Which outside system a connection is to.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum Provider {
24 // Alerts.
25 Sentry,
26 Datadog,
27 /// Anything that can send a signed JSON request.
28 Webhook,
29 // Trackers.
30 Jira,
31 Linear,
32}
33
34/// What g1t knows about a provider.
35pub struct Spec {
36 pub provider: Provider,
37 pub name: &'static str,
38 pub label: &'static str,
39 pub kind: ProviderKind,
40}
41
42const fn other(provider: Provider, name: &'static str, label: &'static str, kind: ProviderKind) -> Spec {
43 Spec { provider, name, label, kind }
44}
45
46/// Every provider, in the order people are shown them.
47pub const PROVIDERS: [Spec; 5] = [
48 other(Provider::Sentry, "sentry", "Sentry", ProviderKind::Alerts),
49 other(Provider::Datadog, "datadog", "Datadog", ProviderKind::Alerts),
50 other(Provider::Webhook, "webhook", "Webhook", ProviderKind::Alerts),
51 other(Provider::Jira, "jira", "Jira", ProviderKind::Tracker),
52 other(Provider::Linear, "linear", "Linear", ProviderKind::Tracker),
53];
54
55impl Provider {
56 pub fn spec(self) -> &'static Spec {
57 PROVIDERS
58 .iter()
59 .find(|spec| spec.provider == self)
60 .expect("every provider is in the catalogue")
61 }
62
63 pub fn all() -> impl Iterator<Item = Provider> {
64 PROVIDERS.iter().map(|spec| spec.provider)
65 }
66
67 pub fn name(self) -> &'static str {
68 self.spec().name
69 }
70
71 pub fn parse(name: &str) -> Option<Provider> {
72 PROVIDERS.iter().find(|spec| spec.name == name).map(|spec| spec.provider)
73 }
74
75 /// What people call it.
76 pub fn label(self) -> &'static str {
77 self.spec().label
78 }
79
80 pub fn kind(self) -> ProviderKind {
81 self.spec().kind
82 }
83
84 /// Whether it sends g1t requests, at the connection's own address.
85 pub fn receives(self) -> bool {
86 self.kind() == ProviderKind::Alerts
87 }
88
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "snake_case")]
93pub enum ProviderKind {
94 /// Problems that become work items.
95 Alerts,
96 /// Tickets that people read and import.
97 Tracker,
98}
99
100/// A connection's settings: everything about it except its secrets. Each
101/// provider uses the fields that apply to it.
102#[derive(Clone, Debug, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct ConnectionConfig {
105 /// For alerts: the repository work items are filed from (their
106 /// origin), `owner/name`. For a tracker: the origin of an imported
107 /// ticket's work item when no repository is named.
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub repo: Option<String>,
110 /// For alerts: the label put on each work item opened. `bug` when unset.
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub label: Option<String>,
113 /// Tell the outside system when the work lands: resolve the Sentry
114 /// issue, comment on the ticket.
115 #[serde(default = "yes")]
116 pub write_back: bool,
117 /// Sentry: the organization's slug.
118 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub organization: Option<String>,
120 /// The system's address, for Jira (`https://acme.atlassian.net`) or a
121 /// Sentry that is not sentry.io.
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub site: Option<String>,
124 /// Jira: the account the API token belongs to.
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub email: Option<String>,
127 /// Jira project keys or Linear team keys this connection answers for,
128 /// such as `TECH`. Empty answers for every key.
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub keys: Vec<String>,
131}
132
133fn yes() -> bool {
134 true
135}
136
137impl Default for ConnectionConfig {
138 fn default() -> Self {
139 ConnectionConfig {
140 repo: None,
141 label: None,
142 write_back: true,
143 organization: None,
144 site: None,
145 email: None,
146 keys: Vec::new(),
147 }
148 }
149}
150
151/// A connection, as anyone in the workspace sees it. Secrets are never
152/// shown after they are saved; `secretHint` is enough to tell keys apart.
153#[derive(Clone, Debug, Serialize, Deserialize)]
154#[serde(rename_all = "camelCase")]
155pub struct Connection {
156 pub id: String,
157 pub workspace: String,
158 pub provider: Provider,
159 pub kind: ProviderKind,
160 pub name: String,
161 pub config: ConnectionConfig,
162 /// The last four characters of the saved key, such as `…3f9a`.
163 pub secret_hint: Option<String>,
164 /// For a provider that sends g1t requests: where it sends them.
165 pub webhook_url: Option<String>,
166 pub created_by: String,
167 /// RFC 3339.
168 pub created_at: String,
169 pub last_used_at: Option<String>,
170 /// The last thing that went wrong talking to it, until it next works.
171 pub last_error: Option<String>,
172}
173
174/// One request an outside system sent, and what g1t did with it.
175#[derive(Clone, Debug, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct Delivery {
178 pub id: String,
179 /// RFC 3339.
180 pub received_at: String,
181 /// What it was about, in the sender's terms: `issue.created`.
182 pub event: String,
183 /// `opened`, `updated`, `reopened`, `ignored` or `refused`.
184 pub outcome: String,
185 pub detail: String,
186 /// The work item it opened or updated, `workspace#number`.
187 pub item: Option<String>,
188}
189
190/// Something outside g1t, fetched as it is now. Its text was written outside
191/// g1t, so it is reference material and never instructions.
192#[derive(Clone, Debug, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct ContextItem {
195 pub provider: Provider,
196 /// `TECH-1234`, or the Sentry issue's short id.
197 pub key: String,
198 pub title: String,
199 pub url: String,
200 /// Its status in that system: `In Progress`, `unresolved`.
201 pub status: Option<String>,
202 /// Its description, as plain text, shortened if long.
203 pub body: String,
204 /// RFC 3339: when g1t fetched it.
205 pub fetched_at: String,
206}
207
208/// A work item's tie to something outside g1t.
209#[derive(Clone, Debug, Serialize, Deserialize)]
210#[serde(rename_all = "camelCase")]
211pub struct Link {
212 pub provider: Provider,
213 pub connection_id: String,
214 pub key: String,
215 pub title: String,
216 pub url: String,
217 /// How many times an alert has fired for it.
218 pub count: u32,
219 /// RFC 3339.
220 pub first_seen: String,
221 pub last_seen: String,
222}
223
224// --- Methods -----------------------------------------------------------------
225
226/// `list`. Returns `Outcome<Vec<Connection>>`. Members only.
227#[derive(Debug, Serialize, Deserialize)]
228pub struct ListArgs {
229 pub workspace: String,
230 pub viewer: Viewer,
231}
232
233/// `connect`. Returns `Outcome<Connected>`. Owners only.
234#[derive(Debug, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct ConnectArgs {
237 pub actor: User,
238 pub workspace: String,
239 pub provider: Provider,
240 #[serde(default)]
241 pub name: Option<String>,
242 #[serde(default)]
243 pub config: ConnectionConfig,
244 /// The API key or token g1t uses to call it.
245 #[serde(default)]
246 pub secret: Option<String>,
247 /// What it signs its requests to g1t with: Sentry's client secret.
248 /// Made by g1t for Datadog and webhooks, and shown once.
249 #[serde(default)]
250 pub signing_secret: Option<String>,
251}
252
253#[derive(Clone, Debug, Serialize, Deserialize)]
254#[serde(rename_all = "camelCase")]
255pub struct Connected {
256 pub connection: Connection,
257 /// A signing secret g1t made, shown this once.
258 pub signing_secret: Option<String>,
259}
260
261/// `update`: only the fields given change. Returns `Outcome<Connection>`.
262/// Owners only.
263#[derive(Debug, Serialize, Deserialize)]
264#[serde(rename_all = "camelCase")]
265pub struct UpdateArgs {
266 pub actor: User,
267 pub workspace: String,
268 pub id: String,
269 #[serde(default)]
270 pub name: Option<String>,
271 #[serde(default)]
272 pub config: Option<ConnectionConfig>,
273 #[serde(default)]
274 pub secret: Option<String>,
275 #[serde(default)]
276 pub signing_secret: Option<String>,
277}
278
279/// `disconnect` and `test`. `disconnect` returns `Outcome<bool>`; `test`
280/// returns `Outcome<Tested>`. Owners only.
281#[derive(Debug, Serialize, Deserialize)]
282pub struct ConnectionArgs {
283 pub actor: User,
284 pub workspace: String,
285 pub id: String,
286}
287
288#[derive(Clone, Debug, Serialize, Deserialize)]
289pub struct Tested {
290 pub ok: bool,
291 pub message: String,
292}
293
294/// `deliveries`: the latest requests a connection received, newest first.
295/// Returns `Outcome<Vec<Delivery>>`. Members only.
296#[derive(Debug, Serialize, Deserialize)]
297pub struct DeliveriesArgs {
298 pub workspace: String,
299 pub viewer: Viewer,
300 pub id: String,
301}
302
303/// `receive`: a request an outside system sent to a connection's address.
304/// Returns `Received`. Anyone can send one; only a signed one is acted on.
305#[derive(Debug, Serialize, Deserialize)]
306pub struct ReceiveArgs {
307 pub id: String,
308 /// Header names in lowercase.
309 pub headers: std::collections::HashMap<String, String>,
310 pub body: String,
311}
312
313#[derive(Clone, Debug, Serialize, Deserialize)]
314pub struct Received {
315 /// The HTTP status to answer with.
316 pub status: u16,
317 pub message: String,
318}
319
320/// `resolve`: fetches one outside reference. Returns `Outcome<ContextItem>`.
321/// Members of the workspace only.
322#[derive(Debug, Serialize, Deserialize)]
323pub struct ResolveArgs {
324 pub workspace: String,
325 pub viewer: Viewer,
326 /// `TECH-1234`, or a Jira, Linear or Sentry address.
327 pub reference: String,
328}
329
330/// `import`: opens a work item in `workspace` from a ticket, filed from
331/// `repo` when given (its origin; `workspace` may then be empty, meaning
332/// the repository's), with `targets` as where the work is meant to land.
333/// Members of the workspace only. Returns `Outcome<Imported>`.
334#[derive(Debug, Serialize, Deserialize)]
335pub struct ImportArgs {
336 pub actor: User,
337 #[serde(default)]
338 pub workspace: String,
339 #[serde(default)]
340 pub repo: Option<RepoPath>,
341 #[serde(default)]
342 pub targets: Vec<RepoPath>,
343 pub reference: String,
344}
345
346#[derive(Clone, Debug, Serialize, Deserialize)]
347pub struct Imported {
348 /// The work item's workspace and number.
349 pub workspace: String,
350 pub number: u32,
351 /// The ticket, as fetched.
352 pub item: ContextItem,
353 /// False when the ticket had been imported already, and `number` is
354 /// that work item.
355 pub created: bool,
356}
357
358/// `links`: what work item `number` of `workspace` is tied to outside
359/// g1t. Returns `Vec<Link>`. Callers must have checked the viewer may see
360/// the work item.
361#[derive(Debug, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct LinksArgs {
364 pub workspace: String,
365 pub number: u32,
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn every_provider_is_named_once_and_found_again() {
374 let mut names: Vec<&str> = PROVIDERS.iter().map(|spec| spec.name).collect();
375 for provider in Provider::all() {
376 assert_eq!(Provider::parse(provider.name()), Some(provider));
377 }
378 names.sort();
379 names.dedup();
380 assert_eq!(names.len(), PROVIDERS.len());
381 }
382
383 #[test]
384 fn ai_providers_are_not_integrations() {
385 assert_eq!(Provider::parse("anthropic"), None);
386 assert!(Provider::all().any(|provider| provider == Provider::Sentry));
387 }
388
389 #[test]
390 fn stored_configs_with_old_fields_parse() {
391 let config: ConnectionConfig =
392 serde_json::from_str(r#"{"repo":"acme/web","assign":true,"gatewayModels":["*"],"writeBack":false}"#).unwrap();
393 assert_eq!(config.repo.as_deref(), Some("acme/web"));
394 assert!(!config.write_back);
395 }
396}