bitbucket.rs

  1use std::str::FromStr;
  2use std::sync::LazyLock;
  3
  4use regex::Regex;
  5use url::Url;
  6
  7use git::{
  8    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
  9    PullRequest, RemoteUrl,
 10};
 11
 12fn pull_request_regex() -> &'static Regex {
 13    static PULL_REQUEST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
 14        // This matches Bitbucket PR reference pattern: (pull request #xxx)
 15        Regex::new(r"\(pull request #(\d+)\)").unwrap()
 16    });
 17    &PULL_REQUEST_REGEX
 18}
 19
 20pub struct Bitbucket {
 21    name: String,
 22    base_url: Url,
 23}
 24
 25impl Bitbucket {
 26    pub fn new(name: impl Into<String>, base_url: Url) -> Self {
 27        Self {
 28            name: name.into(),
 29            base_url,
 30        }
 31    }
 32
 33    pub fn public_instance() -> Self {
 34        Self::new("Bitbucket", Url::parse("https://bitbucket.org").unwrap())
 35    }
 36}
 37
 38impl GitHostingProvider for Bitbucket {
 39    fn name(&self) -> String {
 40        self.name.clone()
 41    }
 42
 43    fn base_url(&self) -> Url {
 44        self.base_url.clone()
 45    }
 46
 47    fn supports_avatars(&self) -> bool {
 48        false
 49    }
 50
 51    fn format_line_number(&self, line: u32) -> String {
 52        format!("lines-{line}")
 53    }
 54
 55    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
 56        format!("lines-{start_line}:{end_line}")
 57    }
 58
 59    fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
 60        let url = RemoteUrl::from_str(url).ok()?;
 61
 62        let host = url.host_str()?;
 63        if host != "bitbucket.org" {
 64            return None;
 65        }
 66
 67        let mut path_segments = url.path_segments()?;
 68        let owner = path_segments.next()?;
 69        let repo = path_segments.next()?.trim_end_matches(".git");
 70
 71        Some(ParsedGitRemote {
 72            owner: owner.into(),
 73            repo: repo.into(),
 74        })
 75    }
 76
 77    fn build_commit_permalink(
 78        &self,
 79        remote: &ParsedGitRemote,
 80        params: BuildCommitPermalinkParams,
 81    ) -> Url {
 82        let BuildCommitPermalinkParams { sha } = params;
 83        let ParsedGitRemote { owner, repo } = remote;
 84
 85        self.base_url()
 86            .join(&format!("{owner}/{repo}/commits/{sha}"))
 87            .unwrap()
 88    }
 89
 90    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
 91        let ParsedGitRemote { owner, repo } = remote;
 92        let BuildPermalinkParams {
 93            sha,
 94            path,
 95            selection,
 96        } = params;
 97
 98        let mut permalink = self
 99            .base_url()
100            .join(&format!("{owner}/{repo}/src/{sha}/{path}"))
101            .unwrap();
102        permalink.set_fragment(
103            selection
104                .map(|selection| self.line_fragment(&selection))
105                .as_deref(),
106        );
107        permalink
108    }
109
110    fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
111        // Check first line of commit message for PR references
112        let first_line = message.lines().next()?;
113
114        // Try to match against our PR patterns
115        let capture = pull_request_regex().captures(first_line)?;
116        let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
117
118        // Construct the PR URL in Bitbucket format
119        let mut url = self.base_url();
120        let path = format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number);
121        url.set_path(&path);
122
123        Some(PullRequest { number, url })
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use git::repository::repo_path;
130    use pretty_assertions::assert_eq;
131
132    use super::*;
133
134    #[test]
135    fn test_parse_remote_url_given_ssh_url() {
136        let parsed_remote = Bitbucket::public_instance()
137            .parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
138            .unwrap();
139
140        assert_eq!(
141            parsed_remote,
142            ParsedGitRemote {
143                owner: "zed-industries".into(),
144                repo: "zed".into(),
145            }
146        );
147    }
148
149    #[test]
150    fn test_parse_remote_url_given_https_url() {
151        let parsed_remote = Bitbucket::public_instance()
152            .parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
153            .unwrap();
154
155        assert_eq!(
156            parsed_remote,
157            ParsedGitRemote {
158                owner: "zed-industries".into(),
159                repo: "zed".into(),
160            }
161        );
162    }
163
164    #[test]
165    fn test_parse_remote_url_given_https_url_with_username() {
166        let parsed_remote = Bitbucket::public_instance()
167            .parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
168            .unwrap();
169
170        assert_eq!(
171            parsed_remote,
172            ParsedGitRemote {
173                owner: "zed-industries".into(),
174                repo: "zed".into(),
175            }
176        );
177    }
178
179    #[test]
180    fn test_build_bitbucket_permalink() {
181        let permalink = Bitbucket::public_instance().build_permalink(
182            ParsedGitRemote {
183                owner: "zed-industries".into(),
184                repo: "zed".into(),
185            },
186            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
187        );
188
189        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
190        assert_eq!(permalink.to_string(), expected_url.to_string())
191    }
192
193    #[test]
194    fn test_build_bitbucket_permalink_with_single_line_selection() {
195        let permalink = Bitbucket::public_instance().build_permalink(
196            ParsedGitRemote {
197                owner: "zed-industries".into(),
198                repo: "zed".into(),
199            },
200            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
201        );
202
203        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
204        assert_eq!(permalink.to_string(), expected_url.to_string())
205    }
206
207    #[test]
208    fn test_build_bitbucket_permalink_with_multi_line_selection() {
209        let permalink = Bitbucket::public_instance().build_permalink(
210            ParsedGitRemote {
211                owner: "zed-industries".into(),
212                repo: "zed".into(),
213            },
214            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
215        );
216
217        let expected_url =
218            "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
219        assert_eq!(permalink.to_string(), expected_url.to_string())
220    }
221
222    #[test]
223    fn test_bitbucket_pull_requests() {
224        use indoc::indoc;
225
226        let remote = ParsedGitRemote {
227            owner: "zed-industries".into(),
228            repo: "zed".into(),
229        };
230
231        let bitbucket = Bitbucket::public_instance();
232
233        // Test message without PR reference
234        let message = "This does not contain a pull request";
235        assert!(bitbucket.extract_pull_request(&remote, message).is_none());
236
237        // Pull request number at end of first line
238        let message = indoc! {r#"
239            Merged in feature-branch (pull request #123)
240
241            Some detailed description of the changes.
242        "#};
243
244        let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
245        assert_eq!(pr.number, 123);
246        assert_eq!(
247            pr.url.as_str(),
248            "https://bitbucket.org/zed-industries/zed/pull-requests/123"
249        );
250    }
251}