| 1 | //! The issues a pull request says it closes: a closing keyword followed by |
| 2 | //! an issue of the same repository, in its title or description, such as |
| 3 | //! `Fixes #12`, `closes: #3` or `Resolves flagon-io/g1t#40`. They close |
| 4 | //! when it merges into the default branch (`Work::record_merge`), where the |
| 5 | //! repository closes issues that way (`RepoSettings::auto_close_issues`). |
| 6 | //! |
| 7 | //! What sits in code, a `span` or a fenced block, says nothing: it is |
| 8 | //! usually an example. |
| 9 | |
| 10 | use g1t_contracts::repos::RepoPath; |
| 11 | |
| 12 | const KEYWORDS: [&str; 9] = ["close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"]; |
| 13 | |
| 14 | /// The most issues one pull request closes this way. |
| 15 | pub(crate) const MAX_CLOSES: usize = 50; |
| 16 | |
| 17 | /// The issue numbers `text` names after a closing keyword, in the order |
| 18 | /// named, each once. `repo` is the repository the pull request is in: a |
| 19 | /// reference naming another repository closes nothing. |
| 20 | pub(crate) fn references(text: &str, repo: &RepoPath) -> Vec<u32> { |
| 21 | let mut found = Vec::new(); |
| 22 | for prose in outside_code(text) { |
| 23 | scan(&prose, repo, &mut found); |
| 24 | } |
| 25 | found.truncate(MAX_CLOSES); |
| 26 | found |
| 27 | } |
| 28 | |
| 29 | /// The issues a pull request closes: its own issue first, then those its |
| 30 | /// title and description name. |
| 31 | pub(crate) fn closes(issue: Option<u32>, title: &str, body: Option<&str>, repo: &RepoPath) -> Vec<u32> { |
| 32 | let mut closes: Vec<u32> = issue.into_iter().collect(); |
| 33 | let text = format!("{title}\n{}", body.unwrap_or_default()); |
| 34 | for number in references(&text, repo) { |
| 35 | if !closes.contains(&number) { |
| 36 | closes.push(number); |
| 37 | } |
| 38 | } |
| 39 | closes.truncate(MAX_CLOSES); |
| 40 | closes |
| 41 | } |
| 42 | |
| 43 | /// The parts of Markdown outside fenced code blocks and code spans, each |
| 44 | /// with code replaced by a space so words on either side stay apart. |
| 45 | fn outside_code(text: &str) -> Vec<String> { |
| 46 | let mut parts = Vec::new(); |
| 47 | let mut current = String::new(); |
| 48 | // The fence that opened the block being skipped: its character and length. |
| 49 | let mut fence: Option<(char, usize)> = None; |
| 50 | for line in text.lines() { |
| 51 | let trimmed = line.trim_start(); |
| 52 | let opener = ['`', '~'].into_iter().find_map(|mark| { |
| 53 | let run = trimmed.chars().take_while(|c| *c == mark).count(); |
| 54 | (run >= 3).then_some((mark, run)) |
| 55 | }); |
| 56 | match (fence, opener) { |
| 57 | (None, Some(opened)) => { |
| 58 | fence = Some(opened); |
| 59 | parts.push(std::mem::take(&mut current)); |
| 60 | } |
| 61 | (Some((mark, length)), Some((closing, run))) if closing == mark && run >= length && trimmed.trim_end().chars().all(|c| c == mark) => { |
| 62 | fence = None; |
| 63 | } |
| 64 | (Some(_), _) => {} |
| 65 | (None, None) => { |
| 66 | current.push_str(&without_spans(line)); |
| 67 | current.push('\n'); |
| 68 | } |
| 69 | } |
| 70 | } |
| 71 | parts.push(current); |
| 72 | parts |
| 73 | } |
| 74 | |
| 75 | /// A line with its code spans (between matching runs of backticks) blanked. |
| 76 | fn without_spans(line: &str) -> String { |
| 77 | let chars: Vec<char> = line.chars().collect(); |
| 78 | let mut out = String::with_capacity(line.len()); |
| 79 | let mut i = 0; |
| 80 | while i < chars.len() { |
| 81 | if chars[i] != '`' { |
| 82 | out.push(chars[i]); |
| 83 | i += 1; |
| 84 | continue; |
| 85 | } |
| 86 | let run = chars[i..].iter().take_while(|c| **c == '`').count(); |
| 87 | // The closing run of the same length, if there is one. |
| 88 | let mut j = i + run; |
| 89 | let mut close = None; |
| 90 | while j < chars.len() { |
| 91 | if chars[j] == '`' { |
| 92 | let other = chars[j..].iter().take_while(|c| **c == '`').count(); |
| 93 | if other == run { |
| 94 | close = Some(j + other); |
| 95 | break; |
| 96 | } |
| 97 | j += other; |
| 98 | } else { |
| 99 | j += 1; |
| 100 | } |
| 101 | } |
| 102 | match close { |
| 103 | Some(end) => { |
| 104 | out.push(' '); |
| 105 | i = end; |
| 106 | } |
| 107 | // An unmatched run is literal backticks. |
| 108 | None => { |
| 109 | out.extend(&chars[i..i + run]); |
| 110 | i += run; |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | out |
| 115 | } |
| 116 | |
| 117 | fn is_word(c: char) -> bool { |
| 118 | c.is_alphanumeric() || c == '_' |
| 119 | } |
| 120 | |
| 121 | /// Every keyword-and-reference pair in `text`. |
| 122 | fn scan(text: &str, repo: &RepoPath, found: &mut Vec<u32>) { |
| 123 | let chars: Vec<char> = text.chars().collect(); |
| 124 | let mut i = 0; |
| 125 | while i < chars.len() { |
| 126 | // A word starts here. |
| 127 | if !is_word(chars[i]) || (i > 0 && is_word(chars[i - 1])) { |
| 128 | i += 1; |
| 129 | continue; |
| 130 | } |
| 131 | let end = chars[i..].iter().position(|c| !is_word(*c)).map_or(chars.len(), |n| i + n); |
| 132 | let word: String = chars[i..end].iter().collect::<String>().to_lowercase(); |
| 133 | i = end; |
| 134 | if !KEYWORDS.contains(&word.as_str()) { |
| 135 | continue; |
| 136 | } |
| 137 | // An optional colon, then spaces on the same line; without a colon, |
| 138 | // at least one space. |
| 139 | let mut at = end; |
| 140 | let colon = chars.get(at) == Some(&':'); |
| 141 | if colon { |
| 142 | at += 1; |
| 143 | } |
| 144 | let spaces = chars[at..].iter().take_while(|c| **c == ' ' || **c == '\t').count(); |
| 145 | if spaces == 0 && !colon { |
| 146 | continue; |
| 147 | } |
| 148 | at += spaces; |
| 149 | if let Some((number, after)) = reference(&chars, at, repo) { |
| 150 | if !found.contains(&number) { |
| 151 | found.push(number); |
| 152 | } |
| 153 | i = after; |
| 154 | } |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | /// An issue of `repo` referenced at `at`: `#12` or `owner/name#12`, ending |
| 159 | /// at a word boundary. The number and where the reference ends. |
| 160 | fn reference(chars: &[char], at: usize, repo: &RepoPath) -> Option<(u32, usize)> { |
| 161 | let hash = if chars.get(at) == Some(&'#') { |
| 162 | at |
| 163 | } else { |
| 164 | // owner/name#N, naming this repository. |
| 165 | let length = chars[at..].iter().position(|c| *c == '#' || c.is_whitespace()).unwrap_or(chars.len() - at); |
| 166 | let hash = at + length; |
| 167 | if chars.get(hash) != Some(&'#') { |
| 168 | return None; |
| 169 | } |
| 170 | let named: String = chars[at..hash].iter().collect(); |
| 171 | let (owner, name) = named.split_once('/')?; |
| 172 | if !owner.eq_ignore_ascii_case(&repo.namespace) || !name.eq_ignore_ascii_case(&repo.name) { |
| 173 | return None; |
| 174 | } |
| 175 | hash |
| 176 | }; |
| 177 | let digits = chars[hash + 1..].iter().take_while(|c| c.is_ascii_digit()).count(); |
| 178 | if digits == 0 || digits > 9 { |
| 179 | return None; |
| 180 | } |
| 181 | let after = hash + 1 + digits; |
| 182 | if chars.get(after).is_some_and(|c| is_word(*c)) { |
| 183 | return None; |
| 184 | } |
| 185 | let number: u32 = chars[hash + 1..after].iter().collect::<String>().parse().ok()?; |
| 186 | (number > 0).then_some((number, after)) |
| 187 | } |
| 188 | |
| 189 | #[cfg(test)] |
| 190 | mod tests { |
| 191 | use super::*; |
| 192 | |
| 193 | fn repo() -> RepoPath { |
| 194 | RepoPath { namespace: "flagon-io".into(), name: "g1t".into() } |
| 195 | } |
| 196 | |
| 197 | fn refs(text: &str) -> Vec<u32> { |
| 198 | references(text, &repo()) |
| 199 | } |
| 200 | |
| 201 | #[test] |
| 202 | fn every_keyword_closes_whatever_its_case() { |
| 203 | for keyword in KEYWORDS { |
| 204 | assert_eq!(refs(&format!("{keyword} #7")), vec![7], "{keyword}"); |
| 205 | assert_eq!(refs(&format!("{} #7", keyword.to_uppercase())), vec![7], "{keyword}"); |
| 206 | } |
| 207 | assert_eq!(refs("Fixes: #12"), vec![12]); |
| 208 | assert_eq!(refs("fixes:#12"), vec![12]); |
| 209 | assert_eq!(refs("This fixes #3 and resolves #4, closes #3 again."), vec![3, 4]); |
| 210 | } |
| 211 | |
| 212 | #[test] |
| 213 | fn only_whole_words_and_whole_numbers() { |
| 214 | assert!(refs("prefixes #3").is_empty()); |
| 215 | assert!(refs("fixesthis #3").is_empty()); |
| 216 | assert!(refs("fixes#3").is_empty(), "no space and no colon"); |
| 217 | assert!(refs("fixes #3a").is_empty()); |
| 218 | assert!(refs("fixes #").is_empty()); |
| 219 | assert!(refs("fixes #0").is_empty()); |
| 220 | assert!(refs("fixes\n#3").is_empty(), "on the same line"); |
| 221 | assert_eq!(refs("(fixes #3)."), vec![3]); |
| 222 | assert!(refs("fixes issue #3").is_empty(), "the reference follows the keyword"); |
| 223 | } |
| 224 | |
| 225 | #[test] |
| 226 | fn a_reference_to_this_repository_by_name_counts_and_others_do_not() { |
| 227 | assert_eq!(refs("Closes flagon-io/g1t#40"), vec![40]); |
| 228 | assert_eq!(refs("closes Flagon-IO/G1T#40"), vec![40]); |
| 229 | assert!(refs("closes someone/else#40").is_empty()); |
| 230 | assert!(refs("closes flagon-io/g1t-docs#40").is_empty()); |
| 231 | assert!(refs("closes g1t#40").is_empty()); |
| 232 | } |
| 233 | |
| 234 | #[test] |
| 235 | fn code_says_nothing() { |
| 236 | assert!(refs("Write `fixes #3` to close an issue.").is_empty()); |
| 237 | assert!(refs("Write ``fixes #3 `x` `` here.").is_empty()); |
| 238 | assert!(refs("```\nfixes #3\n```").is_empty()); |
| 239 | assert!(refs("~~~md\nfixes #3\n~~~").is_empty()); |
| 240 | assert_eq!(refs("```\nfixes #3\n```\nfixes #4"), vec![4]); |
| 241 | // An unclosed span is just a backtick. |
| 242 | assert_eq!(refs("a ` fixes #5"), vec![5]); |
| 243 | // An unclosed fence hides the rest. |
| 244 | assert!(refs("````\nfixes #6\n```\nfixes #7").is_empty()); |
| 245 | } |
| 246 | |
| 247 | #[test] |
| 248 | fn its_own_issue_comes_first_and_nothing_twice() { |
| 249 | assert_eq!(closes(Some(12), "Fix it", Some("Fixes #12, fixes #3"), &repo()), vec![12, 3]); |
| 250 | assert_eq!(closes(None, "Resolve #9", None, &repo()), vec![9]); |
| 251 | assert!(closes(None, "Nothing", Some(""), &repo()).is_empty()); |
| 252 | let many: String = (1..=80).map(|n| format!("fixes #{n} ")).collect(); |
| 253 | assert_eq!(closes(None, "", Some(&many), &repo()).len(), MAX_CLOSES); |
| 254 | } |
| 255 | } |