flagon-io/g1t

public

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/services/integrations/src/trackers.rs

243 lines9,422 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Integrations: your own model provider, alerts that open issues, tickets agents read1//! Jira and Linear: a ticket key resolves to the ticket, and the ticket is
2//! told when work on it starts and lands.
3
4use base64::Engine;
5use base64::engine::general_purpose::STANDARD;
6use g1t_contracts::integrations::{ConnectionConfig, ContextItem, Provider};
7use g1t_contracts::time::rfc3339;
8use g1t_kit::now_ms;
9use serde_json::{Value, json};
10use worker::{Method, Result};
11
12use crate::http::{self, Answer};
13
14/// A ticket, and the id its system's API takes for it.
15pub struct Ticket {
16 pub item: ContextItem,
17 pub external_id: String,
18}
19
20/// `Ok(None)` when the system has no such ticket; `Err` with what went
21/// wrong otherwise.
22pub type Fetched = std::result::Result<Option<Ticket>, String>;
23
24const MAX_BODY: usize = 8000;
25
26// --- Jira ----------------------------------------------------------------
27
28fn jira_site(config: &ConnectionConfig) -> String {
29 config.site.as_deref().unwrap_or_default().trim_end_matches('/').to_owned()
30}
31
32async fn jira(config: &ConnectionConfig, token: &str, method: Method, path: &str, body: Option<Value>) -> Result<Answer> {
33 let auth = format!(
34 "Basic {}",
35 STANDARD.encode(format!("{}:{token}", config.email.as_deref().unwrap_or_default()))
36 );
37 http::send(
38 method,
39 &format!("{}/rest/api/3{path}", jira_site(config)),
40 &[("authorization", &auth)],
41 body.map(|body| body.to_string()),
42 )
43 .await
44}
45
46/// Atlassian's document format, as plain text.
47pub fn adf_text(node: &Value) -> String {
48 match node["type"].as_str() {
49 Some("text") => node["text"].as_str().unwrap_or_default().to_owned(),
50 Some("hardBreak") => "\n".to_owned(),
51 Some("mention") => node["attrs"]["text"].as_str().unwrap_or_default().to_owned(),
52 Some("inlineCard") => node["attrs"]["url"].as_str().unwrap_or_default().to_owned(),
53 kind => {
54 let inner: Vec<String> = node["content"].as_array().map(|nodes| nodes.iter().map(adf_text).collect()).unwrap_or_default();
55 match kind {
56 Some("paragraph" | "heading" | "blockquote" | "rule") => format!("{}\n\n", inner.concat()),
57 Some("listItem") => format!("- {}", inner.concat().trim_end()) + "\n",
58 Some("bulletList" | "orderedList") => format!("{}\n", inner.concat()),
59 Some("codeBlock") => format!("```\n{}\n```\n\n", inner.concat()),
60 _ => inner.concat(),
61 }
62 }
63 }
64}
65
66pub async fn jira_fetch(config: &ConnectionConfig, token: &str, key: &str) -> Result<Fetched> {
67 let answer = jira(
68 config,
69 token,
70 Method::Get,
71 &format!("/issue/{key}?fields=summary,description,status,issuetype,priority,labels"),
72 None,
73 )
74 .await?;
75 if answer.status == 404 {
76 return Ok(Ok(None));
77 }
78 if !answer.ok() {
79 return Ok(Err(answer.problem("Jira")));
80 }
81 let issue = answer.json();
82 let fields = &issue["fields"];
83 let mut body = Vec::new();
84 let kind = [fields["issuetype"]["name"].as_str(), fields["priority"]["name"].as_str()]
85 .into_iter()
86 .flatten()
87 .collect::<Vec<_>>()
88 .join(", priority ");
89 if !kind.is_empty() {
90 body.push(format!("{kind}."));
91 }
92 let description = adf_text(&fields["description"]);
93 if !description.trim().is_empty() {
94 body.push(description.trim().to_owned());
95 }
96 if let Some(labels) = fields["labels"].as_array().filter(|labels| !labels.is_empty()) {
97 body.push(format!(
98 "Labels: {}",
99 labels.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(", ")
100 ));
101 }
102 let key = issue["key"].as_str().unwrap_or(key).to_owned();
103 Ok(Ok(Some(Ticket {
104 external_id: key.clone(),
105 item: ContextItem {
106 provider: Provider::Jira,
107 url: format!("{}/browse/{key}", jira_site(config)),
108 key,
109 title: fields["summary"].as_str().unwrap_or_default().to_owned(),
110 status: fields["status"]["name"].as_str().map(str::to_owned),
111 body: http::shorten(&body.join("\n\n"), MAX_BODY),
112 fetched_at: rfc3339(now_ms()),
113 },
114 })))
115}
116
117pub async fn jira_comment(config: &ConnectionConfig, token: &str, key: &str, text: &str, link: &str) -> Result<std::result::Result<(), String>> {
118 let body = json!({ "body": { "type": "doc", "version": 1, "content": [{
119 "type": "paragraph",
120 "content": [
121 { "type": "text", "text": format!("{text} ") },
122 { "type": "text", "text": link, "marks": [{ "type": "link", "attrs": { "href": link } }] }
123 ]
124 }]}});
125 let answer = jira(config, token, Method::Post, &format!("/issue/{key}/comment"), Some(body)).await?;
126 Ok(if answer.ok() { Ok(()) } else { Err(answer.problem("Jira")) })
127}
128
129pub async fn jira_test(config: &ConnectionConfig, token: &str) -> Result<std::result::Result<String, String>> {
130 let answer = jira(config, token, Method::Get, "/myself", None).await?;
131 Ok(if answer.ok() {
132 Ok(format!(
133 "Connected to {} as {}.",
134 jira_site(config),
135 answer.json()["displayName"].as_str().unwrap_or("you")
136 ))
137 } else {
138 Err(answer.problem("Jira"))
139 })
140}
141
142// --- Linear --------------------------------------------------------------
143
144async fn linear(token: &str, query: &str, variables: Value) -> Result<Answer> {
145 http::send(
146 Method::Post,
147 "https://api.linear.app/graphql",
148 &[("authorization", token)],
149 Some(json!({ "query": query, "variables": variables }).to_string()),
150 )
151 .await
152}
153
154fn linear_problem(answer: &Answer) -> Option<String> {
155 if !answer.ok() {
156 return Some(answer.problem("Linear"));
157 }
158 answer.json()["errors"][0]["message"].as_str().map(|message| format!("Linear: {message}"))
159}
160
161pub async fn linear_fetch(token: &str, key: &str) -> Result<Fetched> {
162 let answer = linear(
163 token,
164 "query($id: String!) { issue(id: $id) { id identifier title description url priorityLabel state { name } labels { nodes { name } } } }",
165 json!({ "id": key }),
166 )
167 .await?;
168 let said = answer.json();
169 if let Some(problem) = linear_problem(&answer) {
170 // Linear says a missing issue is an error like any other.
171 return Ok(if problem.to_ascii_lowercase().contains("not found") { Ok(None) } else { Err(problem) });
172 }
173 let issue = &said["data"]["issue"];
174 if issue.is_null() {
175 return Ok(Ok(None));
176 }
177 let mut body = Vec::new();
178 if let Some(priority) = issue["priorityLabel"].as_str().filter(|p| *p != "No priority") {
179 body.push(format!("Priority {priority}."));
180 }
181 if let Some(description) = issue["description"].as_str().filter(|d| !d.trim().is_empty()) {
182 body.push(description.trim().to_owned());
183 }
184 Ok(Ok(Some(Ticket {
185 external_id: issue["id"].as_str().unwrap_or_default().to_owned(),
186 item: ContextItem {
187 provider: Provider::Linear,
188 key: issue["identifier"].as_str().unwrap_or(key).to_owned(),
189 title: issue["title"].as_str().unwrap_or_default().to_owned(),
190 url: issue["url"].as_str().unwrap_or_default().to_owned(),
191 status: issue["state"]["name"].as_str().map(str::to_owned),
192 body: http::shorten(&body.join("\n\n"), MAX_BODY),
193 fetched_at: rfc3339(now_ms()),
194 },
195 })))
196}
197
198pub async fn linear_comment(token: &str, issue_id: &str, text: &str, link: &str) -> Result<std::result::Result<(), String>> {
199 let answer = linear(
200 token,
201 "mutation($input: CommentCreateInput!) { commentCreate(input: $input) { success } }",
202 json!({ "input": { "issueId": issue_id, "body": format!("{text} [{link}]({link})") } }),
203 )
204 .await?;
205 Ok(match linear_problem(&answer) {
206 Some(problem) => Err(problem),
207 None => Ok(()),
208 })
209}
210
211pub async fn linear_test(token: &str) -> Result<std::result::Result<String, String>> {
212 let answer = linear(token, "{ viewer { name } organization { name } }", json!({})).await?;
213 if let Some(problem) = linear_problem(&answer) {
214 return Ok(Err(problem));
215 }
216 let said = answer.json();
217 Ok(Ok(format!(
218 "Connected to {} as {}.",
219 said["data"]["organization"]["name"].as_str().unwrap_or("Linear"),
220 said["data"]["viewer"]["name"].as_str().unwrap_or("you")
221 )))
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227
228 #[test]
229 fn atlassian_documents_read_as_text() {
230 let doc = json!({ "type": "doc", "content": [
231 { "type": "paragraph", "content": [{ "type": "text", "text": "Users see a 500 on " }, { "type": "text", "text": "/login" }] },
232 { "type": "bulletList", "content": [
233 { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "on Safari" }] }] },
234 { "type": "listItem", "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "since Monday" }] }] }
235 ]},
236 { "type": "codeBlock", "content": [{ "type": "text", "text": "GET /login 500" }] }
237 ]});
238 let text = adf_text(&doc);
239 assert!(text.starts_with("Users see a 500 on /login\n\n"));
240 assert!(text.contains("- on Safari\n- since Monday\n"));
241 assert!(text.contains("```\nGET /login 500\n```"));
242 }
243}