Skip to main content

mcp_methods/
git_refs.rs

1use fancy_regex::Regex as FancyRegex;
2use regex::Regex;
3use std::collections::HashSet;
4use std::sync::LazyLock;
5
6static LINK_RE: LazyLock<Regex> = LazyLock::new(|| {
7    Regex::new(r"https?://github\.com/([a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+)/(?:issues|pull)/(\d+)")
8        .unwrap()
9});
10
11static CROSS_RE: LazyLock<Regex> =
12    LazyLock::new(|| Regex::new(r"([a-zA-Z0-9_.\-]+/[a-zA-Z0-9_.\-]+)#(\d+)\b").unwrap());
13
14static SHORT_RE: LazyLock<FancyRegex> =
15    LazyLock::new(|| FancyRegex::new(r"(?<![a-zA-Z0-9/])#(\d+)\b").unwrap());
16
17/// Validate `org/repo` format. Returns an error string, or empty string if valid.
18pub fn validate_repo(repo_name: &str) -> Option<String> {
19    let parts: Vec<&str> = repo_name.split('/').collect();
20    if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
21        return Some("Invalid repo name. Use 'org/repo' format, e.g. 'numpy/numpy'.".to_string());
22    }
23    None
24}
25
26/// Extract GitHub issue/PR references from text.
27/// Returns a list of (repo_name, number) tuples.
28pub fn extract_github_refs(text: &str, default_repo: &str) -> Vec<(String, u64)> {
29    if text.is_empty() {
30        return Vec::new();
31    }
32
33    let mut refs: HashSet<(String, u64)> = HashSet::new();
34
35    // Full GitHub URLs: https://github.com/org/repo/issues/123
36    for cap in LINK_RE.captures_iter(text) {
37        if let (Some(repo), Some(num)) = (cap.get(1), cap.get(2)) {
38            if let Ok(n) = num.as_str().parse::<u64>() {
39                refs.insert((repo.as_str().to_string(), n));
40            }
41        }
42    }
43
44    // Cross-repo refs: org/repo#123
45    for cap in CROSS_RE.captures_iter(text) {
46        if let (Some(repo), Some(num)) = (cap.get(1), cap.get(2)) {
47            if let Ok(n) = num.as_str().parse::<u64>() {
48                refs.insert((repo.as_str().to_string(), n));
49            }
50        }
51    }
52
53    // Short refs: #123 (with lookbehind to exclude org/repo#123)
54    let mut start = 0;
55    while start < text.len() {
56        match SHORT_RE.find_from_pos(text, start) {
57            Ok(Some(m)) => {
58                if let Ok(Some(cap)) = SHORT_RE.captures_from_pos(text, m.start()) {
59                    if let Some(num_match) = cap.get(1) {
60                        if let Ok(n) = num_match.as_str().parse::<u64>() {
61                            refs.insert((default_repo.to_string(), n));
62                        }
63                    }
64                }
65                start = m.end();
66            }
67            _ => break,
68        }
69    }
70
71    let mut result: Vec<(String, u64)> = refs.into_iter().collect();
72    result.sort();
73    result
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn test_validate_repo_valid() {
82        assert_eq!(validate_repo("numpy/numpy"), None);
83    }
84
85    #[test]
86    fn test_validate_repo_invalid() {
87        assert!(validate_repo("noslash").is_some());
88        assert!(validate_repo("/empty").is_some());
89        assert!(validate_repo("empty/").is_some());
90    }
91
92    #[test]
93    fn test_extract_refs() {
94        let text = "See #42 and https://github.com/org/repo/issues/10 and other/lib#5";
95        let refs = extract_github_refs(text, "default/repo");
96        assert!(refs.contains(&("default/repo".to_string(), 42)));
97        assert!(refs.contains(&("org/repo".to_string(), 10)));
98        assert!(refs.contains(&("other/lib".to_string(), 5)));
99    }
100}