g1t/services/integrations/src/trackers.rs
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 read | 1 | //! Jira and Linear: a ticket key resolves to the ticket, and the ticket is |
| 2 | //! told when work on it starts and lands. | |
| 3 | ||
| 4 | use base64::Engine; | |
| 5 | use base64::engine::general_purpose::STANDARD; | |
| 6 | use g1t_contracts::integrations::{ConnectionConfig, ContextItem, Provider}; | |
| 7 | use g1t_contracts::time::rfc3339; | |
| 8 | use g1t_kit::now_ms; | |
| 9 | use serde_json::{Value, json}; | |
| 10 | use worker::{Method, Result}; | |
| 11 | ||
| 12 | use crate::http::{self, Answer}; | |
| 13 | ||
| 14 | /// A ticket, and the id its system's API takes for it. | |
| 15 | pub 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. | |
| 22 | pub type Fetched = std::result::Result<Option<Ticket>, String>; | |
| 23 | ||
| 24 | const MAX_BODY: usize = 8000; | |
| 25 | ||
| 26 | // --- Jira ---------------------------------------------------------------- | |
| 27 | ||
| 28 | fn jira_site(config: &ConnectionConfig) -> String { | |
| 29 | config.site.as_deref().unwrap_or_default().trim_end_matches('/').to_owned() | |
| 30 | } | |
| 31 | ||
| 32 | async 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. | |
| 47 | pub 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 | ||
| 66 | pub 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 | ||
| 117 | pub 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 | ||
| 129 | pub 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 | ||
| 144 | async 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 | ||
| 154 | fn 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 | ||
| 161 | pub 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 | ||
| 198 | pub 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 | ||
| 211 | pub 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)] | |
| 225 | mod 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 | } |