pr_01m47d15m3e54sn21z27rpy5n9/services/integrations/src/refs.rs
| 1 | //! Finding references to outside things in text: `TECH-1234`, a Jira or |
| 2 | //! Linear address, a Sentry issue's address. |
| 3 | |
| 4 | /// One reference, as written. |
| 5 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 6 | pub enum Reference { |
| 7 | /// A ticket key, `TECH-1234`, bare or from a Jira or Linear address. |
| 8 | /// `from` says which system the address was, when there was one. |
| 9 | Key { key: String, from: Option<Source> }, |
| 10 | /// A Sentry issue, by its numeric id, from its address. |
| 11 | SentryIssue { id: String }, |
| 12 | } |
| 13 | |
| 14 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 15 | pub enum Source { |
| 16 | Jira, |
| 17 | Linear, |
| 18 | } |
| 19 | |
| 20 | /// Whether `word` is a ticket key: a project of 2 to 10 capital letters or |
| 21 | /// digits starting with a letter, a dash, and a number. |
| 22 | pub fn as_key(word: &str) -> Option<String> { |
| 23 | let (project, number) = word.split_once('-')?; |
| 24 | let project_ok = (2..=10).contains(&project.len()) |
| 25 | && project.chars().next().is_some_and(|c| c.is_ascii_uppercase()) |
| 26 | && project.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()); |
| 27 | let number_ok = (1..=7).contains(&number.len()) && number.chars().all(|c| c.is_ascii_digit()); |
| 28 | (project_ok && number_ok).then(|| word.to_owned()) |
| 29 | } |
| 30 | |
| 31 | /// The project part of a key: `TECH` in `TECH-1234`. |
| 32 | pub fn project(key: &str) -> &str { |
| 33 | key.split_once('-').map_or(key, |(project, _)| project) |
| 34 | } |
| 35 | |
| 36 | fn from_url(url: &str) -> Option<Reference> { |
| 37 | let path_segments: Vec<&str> = url |
| 38 | .split(['?', '#']) |
| 39 | .next() |
| 40 | .unwrap_or(url) |
| 41 | .split('/') |
| 42 | .filter(|segment| !segment.is_empty()) |
| 43 | .collect(); |
| 44 | let host = path_segments.get(1).copied().unwrap_or_default().to_ascii_lowercase(); |
| 45 | let after = |name: &str| { |
| 46 | path_segments |
| 47 | .iter() |
| 48 | .position(|segment| *segment == name) |
| 49 | .and_then(|at| path_segments.get(at + 1).copied()) |
| 50 | }; |
| 51 | if host == "linear.app" || host.ends_with(".linear.app") { |
| 52 | let key = as_key(after("issue")?)?; |
| 53 | return Some(Reference::Key { |
| 54 | key, |
| 55 | from: Some(Source::Linear), |
| 56 | }); |
| 57 | } |
| 58 | if host == "sentry.io" || host.ends_with(".sentry.io") { |
| 59 | let id = after("issues")?; |
| 60 | return id.chars().all(|c| c.is_ascii_digit()).then(|| Reference::SentryIssue { id: id.to_owned() }); |
| 61 | } |
| 62 | // Jira, wherever it is hosted: `/browse/TECH-1234`. |
| 63 | let key = as_key(after("browse")?)?; |
| 64 | Some(Reference::Key { |
| 65 | key, |
| 66 | from: Some(Source::Jira), |
| 67 | }) |
| 68 | } |
| 69 | |
| 70 | /// Every reference in `text`, in order, each once. |
| 71 | pub fn find(text: &str) -> Vec<Reference> { |
| 72 | let mut found: Vec<Reference> = Vec::new(); |
| 73 | let mut push = |reference: Reference| { |
| 74 | let seen = found.iter().any(|existing| match (existing, &reference) { |
| 75 | (Reference::Key { key: a, .. }, Reference::Key { key: b, .. }) => a == b, |
| 76 | _ => *existing == reference, |
| 77 | }); |
| 78 | if !seen { |
| 79 | found.push(reference); |
| 80 | } |
| 81 | }; |
| 82 | for word in text.split(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | '[' | ']' | '<' | '>' | '"' | '\'' | '`' | ',' | ';')) { |
| 83 | let word = word.trim_end_matches(['.', ':', '!', '?']); |
| 84 | if word.starts_with("https://") || word.starts_with("http://") { |
| 85 | if let Some(reference) = from_url(word) { |
| 86 | push(reference); |
| 87 | } |
| 88 | } else if let Some(key) = as_key(word) { |
| 89 | push(Reference::Key { key, from: None }); |
| 90 | } |
| 91 | } |
| 92 | found |
| 93 | } |
| 94 | |
| 95 | #[cfg(test)] |
| 96 | mod tests { |
| 97 | use super::*; |
| 98 | |
| 99 | fn key(key: &str, from: Option<Source>) -> Reference { |
| 100 | Reference::Key { |
| 101 | key: key.to_owned(), |
| 102 | from, |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | #[test] |
| 107 | fn bare_keys_are_found_once() { |
| 108 | assert_eq!( |
| 109 | find("I need to accomplish TECH-1234, see also (OPS-7) and TECH-1234."), |
| 110 | vec![key("TECH-1234", None), key("OPS-7", None)] |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | #[test] |
| 115 | fn things_that_only_look_like_keys_are_not() { |
| 116 | assert!(find("UTF-8 is fine, so is A-1, x-12 and SHA-256abc").iter().all(|r| *r == key("UTF-8", None))); |
| 117 | assert!(find("covid-19 and ISO-8601-2").is_empty()); |
| 118 | } |
| 119 | |
| 120 | #[test] |
| 121 | fn addresses_say_which_system() { |
| 122 | assert_eq!( |
| 123 | find("https://acme.atlassian.net/browse/TECH-12?focusedCommentId=3"), |
| 124 | vec![key("TECH-12", Some(Source::Jira))] |
| 125 | ); |
| 126 | assert_eq!( |
| 127 | find("https://linear.app/acme/issue/ENG-42/fix-the-thing"), |
| 128 | vec![key("ENG-42", Some(Source::Linear))] |
| 129 | ); |
| 130 | assert_eq!( |
| 131 | find("Broke in https://acme.sentry.io/issues/4509812345/?project=1"), |
| 132 | vec![Reference::SentryIssue { |
| 133 | id: "4509812345".to_owned() |
| 134 | }] |
| 135 | ); |
| 136 | assert_eq!( |
| 137 | find("https://sentry.io/organizations/acme/issues/77/"), |
| 138 | vec![Reference::SentryIssue { id: "77".to_owned() }] |
| 139 | ); |
| 140 | } |
| 141 | |
| 142 | #[test] |
| 143 | fn the_project_is_the_part_before_the_dash() { |
| 144 | assert_eq!(project("TECH-1234"), "TECH"); |
| 145 | } |
| 146 | } |