work_tuimer/integrations/
mod.rs1use crate::config::Config;
2use anyhow::Result;
3use regex::Regex;
4
5pub fn extract_ticket_from_name(name: &str) -> Option<String> {
7 let re = Regex::new(r"\b([A-Z]{2,10}-\d+)\b").ok()?;
9
10 re.captures(name)
11 .and_then(|caps| caps.get(1))
12 .map(|m| m.as_str().to_string())
13}
14
15pub fn detect_tracker(ticket: &str, config: &Config) -> Option<String> {
18 for (name, tracker_config) in &config.integrations.trackers {
20 if tracker_config.enabled && matches_patterns(ticket, &tracker_config.ticket_patterns) {
21 return Some(name.clone());
22 }
23 }
24
25 config.integrations.default_tracker.clone()
27}
28
29fn matches_patterns(ticket: &str, patterns: &[String]) -> bool {
31 patterns.iter().any(|pattern| {
32 Regex::new(pattern)
33 .ok()
34 .map(|re| re.is_match(ticket))
35 .unwrap_or(false)
36 })
37}
38
39pub fn build_url(
41 ticket: &str,
42 tracker_name: &str,
43 config: &Config,
44 for_worklog: bool,
45) -> Result<String> {
46 let tracker_config = config
47 .integrations
48 .trackers
49 .get(tracker_name)
50 .ok_or_else(|| anyhow::anyhow!("Tracker '{}' not found in config", tracker_name))?;
51
52 if !tracker_config.enabled {
53 anyhow::bail!("Tracker '{}' is not enabled in config", tracker_name);
54 }
55
56 let template = if for_worklog && !tracker_config.worklog_url.is_empty() {
57 &tracker_config.worklog_url
58 } else {
59 &tracker_config.browse_url
60 };
61
62 let url = template
63 .replace("{base_url}", &tracker_config.base_url)
64 .replace("{ticket}", ticket);
65
66 Ok(url)
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn test_extract_ticket_simple() {
75 let name = "PROJ-123 Fix login bug";
76 let ticket = extract_ticket_from_name(name);
77 assert_eq!(ticket, Some("PROJ-123".to_string()));
78 }
79
80 #[test]
81 fn test_extract_ticket_wl_format() {
82 let name = "WL-1 Morning standup";
83 let ticket = extract_ticket_from_name(name);
84 assert_eq!(ticket, Some("WL-1".to_string()));
85 }
86
87 #[test]
88 fn test_extract_ticket_lin_format() {
89 let name = "LIN-456 Code review";
90 let ticket = extract_ticket_from_name(name);
91 assert_eq!(ticket, Some("LIN-456".to_string()));
92 }
93
94 #[test]
95 fn test_extract_ticket_bracketed() {
96 let name = "[ABC-789] Task name";
97 let ticket = extract_ticket_from_name(name);
98 assert_eq!(ticket, Some("ABC-789".to_string()));
99 }
100
101 #[test]
102 fn test_extract_ticket_in_middle() {
103 let name = "Work on PROJ-456 - code cleanup";
104 let ticket = extract_ticket_from_name(name);
105 assert_eq!(ticket, Some("PROJ-456".to_string()));
106 }
107
108 #[test]
109 fn test_extract_ticket_no_ticket() {
110 let name = "Just a regular task";
111 let ticket = extract_ticket_from_name(name);
112 assert_eq!(ticket, None);
113 }
114
115 #[test]
116 fn test_extract_ticket_invalid_format() {
117 let name = "task-123 invalid";
118 let ticket = extract_ticket_from_name(name);
119 assert_eq!(ticket, None); }
121
122 #[test]
123 fn test_detect_tracker_by_pattern() {
124 let toml_str = r#"
125[integrations]
126default_tracker = "my-jira"
127
128[integrations.trackers.my-jira]
129enabled = true
130base_url = "https://test.atlassian.net"
131ticket_patterns = ["^PROJ-\\d+$", "^WL-\\d+$"]
132browse_url = "{base_url}/browse/{ticket}"
133 "#;
134 let config: Config = toml::from_str(toml_str).unwrap();
135
136 let tracker = detect_tracker("PROJ-123", &config);
137 assert_eq!(tracker, Some("my-jira".to_string()));
138
139 let tracker = detect_tracker("WL-1", &config);
140 assert_eq!(tracker, Some("my-jira".to_string()));
141 }
142
143 #[test]
144 fn test_detect_tracker_default_fallback() {
145 let toml_str = r#"
146[integrations]
147default_tracker = "my-jira"
148
149[integrations.trackers.my-jira]
150enabled = true
151base_url = "https://test.atlassian.net"
152ticket_patterns = ["^PROJ-\\d+$"]
153browse_url = "{base_url}/browse/{ticket}"
154 "#;
155 let config: Config = toml::from_str(toml_str).unwrap();
156
157 let tracker = detect_tracker("UNKNOWN-999", &config);
159 assert_eq!(tracker, Some("my-jira".to_string()));
160 }
161
162 #[test]
163 fn test_detect_tracker_multiple_trackers() {
164 let toml_str = r#"
165[integrations]
166default_tracker = "my-jira"
167
168[integrations.trackers.my-jira]
169enabled = true
170base_url = "https://test.atlassian.net"
171ticket_patterns = ["^PROJ-\\d+$"]
172browse_url = "{base_url}/browse/{ticket}"
173
174[integrations.trackers.github]
175enabled = true
176base_url = "https://github.com/user/repo"
177ticket_patterns = ["^#\\d+$"]
178browse_url = "{base_url}/issues/{ticket}"
179 "#;
180 let config: Config = toml::from_str(toml_str).unwrap();
181
182 let tracker = detect_tracker("PROJ-123", &config);
183 assert_eq!(tracker, Some("my-jira".to_string()));
184
185 let tracker = detect_tracker("#456", &config);
186 assert_eq!(tracker, Some("github".to_string()));
187 }
188
189 #[test]
190 fn test_detect_tracker_overlapping_patterns_first_wins() {
191 let toml_str = r#"
193[integrations]
194default_tracker = "fallback"
195
196[integrations.trackers.jira]
197enabled = true
198base_url = "https://jira.example.com"
199ticket_patterns = ["^[A-Z]+-\\d+$"]
200browse_url = "{base_url}/browse/{ticket}"
201
202[integrations.trackers.linear]
203enabled = true
204base_url = "https://linear.app/team"
205ticket_patterns = ["^[A-Z]+-\\d+$"]
206browse_url = "{base_url}/issue/{ticket}"
207 "#;
208 let config: Config = toml::from_str(toml_str).unwrap();
209
210 let tracker = detect_tracker("PROJ-123", &config);
212 assert!(tracker.is_some());
213
214 let tracker_name = tracker.unwrap();
217 assert!(tracker_name == "jira" || tracker_name == "linear");
218
219 let tracker2 = detect_tracker("PROJ-123", &config);
221 assert_eq!(tracker2, Some(tracker_name));
222 }
223
224 #[test]
225 fn test_detect_tracker_no_match_no_default() {
226 let toml_str = r#"
228[integrations]
229
230[integrations.trackers.jira]
231enabled = true
232base_url = "https://jira.example.com"
233ticket_patterns = ["^PROJ-\\d+$"]
234browse_url = "{base_url}/browse/{ticket}"
235 "#;
236 let config: Config = toml::from_str(toml_str).unwrap();
237
238 let tracker = detect_tracker("UNKNOWN-999", &config);
240 assert_eq!(tracker, None);
241 }
242
243 #[test]
244 fn test_build_url_browse() {
245 let toml_str = r#"
246[integrations]
247default_tracker = "my-jira"
248
249[integrations.trackers.my-jira]
250enabled = true
251base_url = "https://test.atlassian.net"
252ticket_patterns = ["^[A-Z]+-\\d+$"]
253browse_url = "{base_url}/browse/{ticket}"
254worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1"
255 "#;
256 let config: Config = toml::from_str(toml_str).unwrap();
257 let url = build_url("WL-1", "my-jira", &config, false);
258 assert!(url.is_ok());
259 assert_eq!(url.unwrap(), "https://test.atlassian.net/browse/WL-1");
260 }
261
262 #[test]
263 fn test_build_url_worklog() {
264 let toml_str = r#"
265[integrations]
266default_tracker = "my-jira"
267
268[integrations.trackers.my-jira]
269enabled = true
270base_url = "https://test.atlassian.net"
271ticket_patterns = ["^[A-Z]+-\\d+$"]
272browse_url = "{base_url}/browse/{ticket}"
273worklog_url = "{base_url}/browse/{ticket}?focusedWorklogId=-1"
274 "#;
275 let config: Config = toml::from_str(toml_str).unwrap();
276 let url = build_url("WL-1", "my-jira", &config, true);
277 assert!(url.is_ok());
278 assert_eq!(
279 url.unwrap(),
280 "https://test.atlassian.net/browse/WL-1?focusedWorklogId=-1"
281 );
282 }
283
284 #[test]
285 fn test_build_url_github() {
286 let toml_str = r#"
287[integrations]
288
289[integrations.trackers.github]
290enabled = true
291base_url = "https://github.com/user/repo"
292ticket_patterns = ["^#\\d+$"]
293browse_url = "{base_url}/issues/{ticket}"
294worklog_url = ""
295 "#;
296
297 let config: Config = toml::from_str(toml_str).unwrap();
298 let url = build_url("#456", "github", &config, false);
299 assert!(url.is_ok());
300 assert_eq!(url.unwrap(), "https://github.com/user/repo/issues/#456");
301 }
302
303 #[test]
304 fn test_matches_patterns() {
305 let patterns = vec!["^[A-Z]+-\\d+$".to_string()];
306 assert!(matches_patterns("PROJ-123", &patterns));
307 assert!(matches_patterns("WL-1", &patterns));
308 assert!(!matches_patterns("invalid", &patterns));
309 }
310
311 #[test]
312 fn test_extract_first_ticket_only() {
313 let name = "PROJ-123 and WL-456 task";
315 let ticket = extract_ticket_from_name(name);
316 assert_eq!(ticket, Some("PROJ-123".to_string()));
317 }
318
319 #[test]
320 fn test_build_url_with_query_params_in_browse_url() {
321 let toml_str = r#"
324[integrations]
325default_tracker = "zentao"
326
327[integrations.trackers.zentao]
328enabled = true
329base_url = "http://domain/index.php"
330ticket_patterns = ["^BUG-\\d+$"]
331browse_url = "{base_url}?m=my&f=work&mode=bug&type=assignedTo"
332worklog_url = "{base_url}?m=bug&f=view&bugID={ticket}"
333 "#;
334 let config: Config = toml::from_str(toml_str).unwrap();
335
336 let url = build_url("BUG-123", "zentao", &config, false);
338 assert!(url.is_ok());
339 let url_str = url.unwrap();
340 assert_eq!(
341 url_str,
342 "http://domain/index.php?m=my&f=work&mode=bug&type=assignedTo"
343 );
344 assert!(url_str.contains("m=my"));
346 assert!(url_str.contains("f=work"));
347 assert!(url_str.contains("mode=bug"));
348 assert!(url_str.contains("type=assignedTo"));
349 }
350
351 #[test]
352 fn test_build_url_with_ticket_in_query_params() {
353 let toml_str = r#"
355[integrations]
356default_tracker = "zentao"
357
358[integrations.trackers.zentao]
359enabled = true
360base_url = "http://domain/index.php"
361ticket_patterns = ["^BUG-\\d+$"]
362browse_url = "{base_url}?m=my&f=work&mode=bug"
363worklog_url = "{base_url}?m=bug&f=view&bugID={ticket}"
364 "#;
365 let config: Config = toml::from_str(toml_str).unwrap();
366
367 let url = build_url("BUG-456", "zentao", &config, true);
369 assert!(url.is_ok());
370 let url_str = url.unwrap();
371 assert_eq!(
372 url_str,
373 "http://domain/index.php?m=bug&f=view&bugID=BUG-456"
374 );
375 assert!(url_str.contains("bugID=BUG-456"));
377 assert!(url_str.contains("m=bug"));
378 assert!(url_str.contains("f=view"));
379 }
380
381 #[test]
382 fn test_build_url_complex_query_string() {
383 let toml_str = r#"
385[integrations]
386default_tracker = "tracker"
387
388[integrations.trackers.tracker]
389enabled = true
390base_url = "https://tracker.example.com"
391ticket_patterns = ["^ISSUE-\\d+$"]
392browse_url = "{base_url}/view?id={ticket}&action=show&tab=details&expand=true"
393worklog_url = ""
394 "#;
395 let config: Config = toml::from_str(toml_str).unwrap();
396
397 let url = build_url("ISSUE-789", "tracker", &config, false);
398 assert!(url.is_ok());
399 let url_str = url.unwrap();
400 assert_eq!(
401 url_str,
402 "https://tracker.example.com/view?id=ISSUE-789&action=show&tab=details&expand=true"
403 );
404 assert!(url_str.contains("id=ISSUE-789"));
406 assert!(url_str.contains("action=show"));
407 assert!(url_str.contains("tab=details"));
408 assert!(url_str.contains("expand=true"));
409 }
410}