Skip to content

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.sh
255 linesCodeBlameRaw
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
10use g1t_contracts::repos::RepoPath;
11
12const KEYWORDS: [&str; 9] = ["close", "closes", "closed", "fix", "fixes", "fixed", "resolve", "resolves", "resolved"];
13
14/// The most issues one pull request closes this way.
15pub(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.
20pub(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.
31pub(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.
45fn 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.
76fn 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
117fn is_word(c: char) -> bool {
118 c.is_alphanumeric() || c == '_'
119}
120
121/// Every keyword-and-reference pair in `text`.
122fn 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.
160fn 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)]
190mod 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}