Skip to main content

git_disjoint/
issue.rs

1use std::fmt::Display;
2use std::sync::OnceLock;
3
4macro_rules! regex {
5    ($re:literal $(,)?) => {{
6        static RE: OnceLock<regex::Regex> = OnceLock::new();
7        RE.get_or_init(|| regex::Regex::new($re).unwrap())
8    }};
9}
10
11/// Jira or GitHub issue identifier.
12#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13#[cfg_attr(test, derive(proptest_derive::Arbitrary))]
14pub enum Issue {
15    Jira(String),
16    GitHub(String),
17}
18
19impl Issue {
20    pub fn parse_from_commit_message<S: AsRef<str>>(commit_message: S) -> Option<Issue> {
21        let regex_jira_issue = regex!(r"(?m)^(?:Closes )?Ticket:\s+(\S+)");
22        if let Some(jira_captures) = regex_jira_issue.captures(commit_message.as_ref()) {
23            return Some(Issue::Jira(
24                jira_captures[jira_captures.len() - 1].to_owned(),
25            ));
26        }
27
28        let regex_github_issue = regex!(r"(?im)^(?:closes|close|closed|fixes|fixed)\s+#(\d+)");
29        if let Some(github_captures) = regex_github_issue.captures(commit_message.as_ref()) {
30            return Some(Issue::GitHub(
31                github_captures[github_captures.len() - 1].to_owned(),
32            ));
33        }
34        None
35    }
36
37    pub fn issue_identifier(&self) -> &str {
38        match self {
39            Issue::Jira(ticket) => ticket,
40            Issue::GitHub(issue) => issue,
41        }
42    }
43}
44
45impl Display for Issue {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(
48            f,
49            "{}{}",
50            match self {
51                Issue::Jira(_) => "Jira ",
52                Issue::GitHub(_) => "GitHub #",
53            },
54            self.issue_identifier()
55        )
56    }
57}
58
59#[cfg(test)]
60mod test {
61    use super::Issue;
62
63    #[test]
64    fn display_jira_issue() {
65        let issue = Issue::Jira("GD-0".to_string());
66        assert_eq!(format!("{issue}"), "Jira GD-0");
67    }
68
69    #[test]
70    fn display_github_issue() {
71        let issue = Issue::GitHub("123".to_string());
72        assert_eq!(format!("{issue}"), "GitHub #123");
73    }
74
75    macro_rules! test_parses {
76        ($unit_test:ident, $input:expr, $output:expr) => {
77            #[test]
78            fn $unit_test() {
79                let message = $input;
80                let issue = Issue::parse_from_commit_message(message);
81                assert!(
82                    issue.is_some(),
83                    "Expected to parse issue from commit message"
84                );
85                let issue = issue.unwrap();
86                assert_eq!(issue, $output);
87            }
88        };
89    }
90
91    test_parses!(
92        successfully_parse_jira_ticket_from_commit_message_without_newline,
93        r#"
94feat(foo): add hyperdrive
95
96Ticket: AB-123     
97"#,
98        Issue::Jira("AB-123".to_string())
99    );
100
101    test_parses!(
102        successfully_parse_jira_ticket_from_commit_message_with_newline,
103        r#"
104feat(foo): add hyperdrive
105
106Ticket: AB-123
107        
108"#,
109        Issue::Jira("AB-123".to_string())
110    );
111
112    test_parses!(
113        successfully_parse_jira_ticket_from_commit_message_with_trailer,
114        r#"
115feat(foo): add hyperdrive
116
117Ticket: AB-123
118Footer: http://example.com
119"#,
120        Issue::Jira("AB-123".to_string())
121    );
122
123    test_parses!(
124        successfully_parse_jira_ticket_closes_ticket_from_commit_message_without_newline,
125        r#"
126feat(foo): add hyperdrive
127
128Closes Ticket: AB-123
129"#,
130        Issue::Jira("AB-123".to_string())
131    );
132
133    test_parses!(
134        successfully_parse_jira_ticket_closes_ticket_from_commit_message_with_newline,
135        r#"
136feat(foo): add hyperdrive
137
138Closes Ticket: AB-123
139
140"#,
141        Issue::Jira("AB-123".to_string())
142    );
143
144    test_parses!(
145        successfully_parse_jira_ticket_closes_ticket_from_commit_message_with_trailer,
146        r#"
147feat(foo): add hyperdrive
148
149Closes Ticket: AB-123
150Footer: http://example.com
151"#,
152        Issue::Jira("AB-123".to_string())
153    );
154
155    test_parses!(
156        successfully_parse_github_issue_from_commit_message_without_newline,
157        r#"
158feat(foo): add hyperdrive
159
160Closes #123
161"#,
162        Issue::GitHub("123".to_string())
163    );
164
165    test_parses!(
166        successfully_parse_github_issue_from_commit_message_with_newline,
167        r#"
168feat(foo): add hyperdrive
169
170Closes #123
171"#,
172        Issue::GitHub("123".to_string())
173    );
174
175    test_parses!(
176        successfully_parse_github_issue_from_commit_message_with_trailer,
177        r#"
178feat(foo): add hyperdrive
179
180Closes #123
181            Footer: http://example.com
182"#,
183        Issue::GitHub("123".to_string())
184    );
185
186    test_parses!(
187        successfully_parse_github_issue_closes_ticket_from_commit_message_without_newline,
188        r#"
189feat(foo): add hyperdrive
190
191Closes #123
192"#,
193        Issue::GitHub("123".to_string())
194    );
195
196    test_parses!(
197        successfully_parse_github_issue_closes_ticket_from_commit_message_with_newline,
198        r#"
199feat(foo): add hyperdrive
200
201Closes #123
202
203"#,
204        Issue::GitHub("123".to_string())
205    );
206
207    test_parses!(
208        successfully_parse_github_issue_closes_ticket_from_commit_message_with_trailer,
209        r#"
210feat(foo): add hyperdrive
211
212Closes #123
213            Footer: http://example.com
214"#,
215        Issue::GitHub("123".to_string())
216    );
217
218    #[test]
219    fn unnsuccessfully_parse_from_commit_message() {
220        let message = "feat(foo): add hyperdrive";
221        let issue = Issue::parse_from_commit_message(message);
222        assert!(
223            issue.is_none(),
224            "Expected to find no issue to parse from commit message"
225        );
226    }
227
228    use proptest::prelude::*;
229
230    proptest! {
231        #[test]
232        fn display_is_never_empty(issue in prop_oneof![
233            "[A-Z]{2,6}-[0-9]{1,6}".prop_map(Issue::Jira),
234            "[0-9]{1,6}".prop_map(Issue::GitHub),
235        ]) {
236            let display = format!("{}", issue);
237            prop_assert!(!display.is_empty());
238        }
239
240        #[test]
241        fn display_then_parse_roundtrip_jira(
242            project in "[A-Z]{2,6}",
243            number in 1u32..=99999u32,
244        ) {
245            let id = format!("{project}-{number}");
246            let message = format!("feat: x\n\nTicket: {id}");
247            let parsed = Issue::parse_from_commit_message(&message);
248            prop_assert_eq!(parsed, Some(Issue::Jira(id)));
249        }
250
251        #[test]
252        fn display_then_parse_roundtrip_github(number in 1u32..=99999u32) {
253            let message = format!("fix: x\n\nCloses #{number}");
254            let parsed = Issue::parse_from_commit_message(&message);
255            prop_assert_eq!(parsed, Some(Issue::GitHub(number.to_string())));
256        }
257    }
258}