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