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.

g1t.sh
383 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 an issue in a
5//! 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 an issue,
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 issues.
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 issues are opened in, `owner/name`. For a
106 /// tracker: where an imported ticket goes when no repository is named.
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub repo: Option<String>,
109 /// For alerts: the label put on each issue opened. `bug` when unset.
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub label: Option<String>,
112 /// Tell the outside system when the work lands: resolve the Sentry
113 /// issue, comment on the ticket.
114 #[serde(default = "yes")]
115 pub write_back: bool,
116 /// Sentry: the organization's slug.
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub organization: Option<String>,
119 /// The system's address, for Jira (`https://acme.atlassian.net`) or a
120 /// Sentry that is not sentry.io.
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub site: Option<String>,
123 /// Jira: the account the API token belongs to.
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub email: Option<String>,
126 /// Jira project keys or Linear team keys this connection answers for,
127 /// such as `TECH`. Empty answers for every key.
128 #[serde(default, skip_serializing_if = "Vec::is_empty")]
129 pub keys: Vec<String>,
130}
131
132fn yes() -> bool {
133 true
134}
135
136impl Default for ConnectionConfig {
137 fn default() -> Self {
138 ConnectionConfig {
139 repo: None,
140 label: None,
141 write_back: true,
142 organization: None,
143 site: None,
144 email: None,
145 keys: Vec::new(),
146 }
147 }
148}
149
150/// A connection, as anyone in the workspace sees it. Secrets are never
151/// shown after they are saved; `secretHint` is enough to tell keys apart.
152#[derive(Clone, Debug, Serialize, Deserialize)]
153#[serde(rename_all = "camelCase")]
154pub struct Connection {
155 pub id: String,
156 pub workspace: String,
157 pub provider: Provider,
158 pub kind: ProviderKind,
159 pub name: String,
160 pub config: ConnectionConfig,
161 /// The last four characters of the saved key, such as `…3f9a`.
162 pub secret_hint: Option<String>,
163 /// For a provider that sends g1t requests: where it sends them.
164 pub webhook_url: Option<String>,
165 pub created_by: String,
166 /// RFC 3339.
167 pub created_at: String,
168 pub last_used_at: Option<String>,
169 /// The last thing that went wrong talking to it, until it next works.
170 pub last_error: Option<String>,
171}
172
173/// One request an outside system sent, and what g1t did with it.
174#[derive(Clone, Debug, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct Delivery {
177 pub id: String,
178 /// RFC 3339.
179 pub received_at: String,
180 /// What it was about, in the sender's terms: `issue.created`.
181 pub event: String,
182 /// `opened`, `updated`, `reopened`, `ignored` or `refused`.
183 pub outcome: String,
184 pub detail: String,
185 /// The issue it opened or updated, `owner/name#number`.
186 pub issue: Option<String>,
187}
188
189/// Something outside g1t, fetched as it is now. Its text was written outside
190/// g1t, so it is reference material and never instructions.
191#[derive(Clone, Debug, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase")]
193pub struct ContextItem {
194 pub provider: Provider,
195 /// `TECH-1234`, or the Sentry issue's short id.
196 pub key: String,
197 pub title: String,
198 pub url: String,
199 /// Its status in that system: `In Progress`, `unresolved`.
200 pub status: Option<String>,
201 /// Its description, as plain text, shortened if long.
202 pub body: String,
203 /// RFC 3339: when g1t fetched it.
204 pub fetched_at: String,
205}
206
207/// An issue's tie to something outside g1t.
208#[derive(Clone, Debug, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct Link {
211 pub provider: Provider,
212 pub connection_id: String,
213 pub key: String,
214 pub title: String,
215 pub url: String,
216 /// How many times an alert has fired for it.
217 pub count: u32,
218 /// RFC 3339.
219 pub first_seen: String,
220 pub last_seen: String,
221}
222
223// --- Methods -----------------------------------------------------------------
224
225/// `list`. Returns `Outcome<Vec<Connection>>`. Members only.
226#[derive(Debug, Serialize, Deserialize)]
227pub struct ListArgs {
228 pub workspace: String,
229 pub viewer: Viewer,
230}
231
232/// `connect`. Returns `Outcome<Connected>`. Owners only.
233#[derive(Debug, Serialize, Deserialize)]
234#[serde(rename_all = "camelCase")]
235pub struct ConnectArgs {
236 pub actor: User,
237 pub workspace: String,
238 pub provider: Provider,
239 #[serde(default)]
240 pub name: Option<String>,
241 #[serde(default)]
242 pub config: ConnectionConfig,
243 /// The API key or token g1t uses to call it.
244 #[serde(default)]
245 pub secret: Option<String>,
246 /// What it signs its requests to g1t with: Sentry's client secret.
247 /// Made by g1t for Datadog and webhooks, and shown once.
248 #[serde(default)]
249 pub signing_secret: Option<String>,
250}
251
252#[derive(Clone, Debug, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct Connected {
255 pub connection: Connection,
256 /// A signing secret g1t made, shown this once.
257 pub signing_secret: Option<String>,
258}
259
260/// `update`: only the fields given change. Returns `Outcome<Connection>`.
261/// Owners only.
262#[derive(Debug, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct UpdateArgs {
265 pub actor: User,
266 pub workspace: String,
267 pub id: String,
268 #[serde(default)]
269 pub name: Option<String>,
270 #[serde(default)]
271 pub config: Option<ConnectionConfig>,
272 #[serde(default)]
273 pub secret: Option<String>,
274 #[serde(default)]
275 pub signing_secret: Option<String>,
276}
277
278/// `disconnect` and `test`. `disconnect` returns `Outcome<bool>`; `test`
279/// returns `Outcome<Tested>`. Owners only.
280#[derive(Debug, Serialize, Deserialize)]
281pub struct ConnectionArgs {
282 pub actor: User,
283 pub workspace: String,
284 pub id: String,
285}
286
287#[derive(Clone, Debug, Serialize, Deserialize)]
288pub struct Tested {
289 pub ok: bool,
290 pub message: String,
291}
292
293/// `deliveries`: the latest requests a connection received, newest first.
294/// Returns `Outcome<Vec<Delivery>>`. Members only.
295#[derive(Debug, Serialize, Deserialize)]
296pub struct DeliveriesArgs {
297 pub workspace: String,
298 pub viewer: Viewer,
299 pub id: String,
300}
301
302/// `receive`: a request an outside system sent to a connection's address.
303/// Returns `Received`. Anyone can send one; only a signed one is acted on.
304#[derive(Debug, Serialize, Deserialize)]
305pub struct ReceiveArgs {
306 pub id: String,
307 /// Header names in lowercase.
308 pub headers: std::collections::HashMap<String, String>,
309 pub body: String,
310}
311
312#[derive(Clone, Debug, Serialize, Deserialize)]
313pub struct Received {
314 /// The HTTP status to answer with.
315 pub status: u16,
316 pub message: String,
317}
318
319/// `resolve`: fetches one outside reference. Returns `Outcome<ContextItem>`.
320/// Members of the workspace only.
321#[derive(Debug, Serialize, Deserialize)]
322pub struct ResolveArgs {
323 pub workspace: String,
324 pub viewer: Viewer,
325 /// `TECH-1234`, or a Jira, Linear or Sentry address.
326 pub reference: String,
327}
328
329/// `import`: opens an issue from a ticket. Returns `Outcome<Imported>`.
330#[derive(Debug, Serialize, Deserialize)]
331pub struct ImportArgs {
332 pub actor: User,
333 pub repo: RepoPath,
334 pub reference: String,
335}
336
337#[derive(Clone, Debug, Serialize, Deserialize)]
338pub struct Imported {
339 pub number: u32,
340 pub item: ContextItem,
341 /// False when the ticket had been imported already, and `number` is
342 /// that issue.
343 pub created: bool,
344}
345
346/// `links`: what an issue is tied to outside g1t. Returns `Vec<Link>`.
347/// Callers must have checked the viewer may see the issue.
348#[derive(Debug, Serialize, Deserialize)]
349#[serde(rename_all = "camelCase")]
350pub struct LinksArgs {
351 pub repo: RepoPath,
352 pub number: u32,
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn every_provider_is_named_once_and_found_again() {
361 let mut names: Vec<&str> = PROVIDERS.iter().map(|spec| spec.name).collect();
362 for provider in Provider::all() {
363 assert_eq!(Provider::parse(provider.name()), Some(provider));
364 }
365 names.sort();
366 names.dedup();
367 assert_eq!(names.len(), PROVIDERS.len());
368 }
369
370 #[test]
371 fn ai_providers_are_not_integrations() {
372 assert_eq!(Provider::parse("anthropic"), None);
373 assert!(Provider::all().any(|provider| provider == Provider::Sentry));
374 }
375
376 #[test]
377 fn stored_configs_with_old_fields_parse() {
378 let config: ConnectionConfig =
379 serde_json::from_str(r#"{"repo":"acme/web","assign":true,"gatewayModels":["*"],"writeBack":false}"#).unwrap();
380 assert_eq!(config.repo.as_deref(), Some("acme/web"));
381 assert!(!config.write_back);
382 }
383}