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 pretty_assertions::assert_eq;
130
131    use super::*;
132
133    #[test]
134    fn test_parse_remote_url_given_ssh_url() {
135        let parsed_remote = Bitbucket::public_instance()
136            .parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
137            .unwrap();
138
139        assert_eq!(
140            parsed_remote,
141            ParsedGitRemote {
142                owner: "zed-industries".into(),
143                repo: "zed".into(),
144            }
145        );
146    }
147
148    #[test]
149    fn test_parse_remote_url_given_https_url() {
150        let parsed_remote = Bitbucket::public_instance()
151            .parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
152            .unwrap();
153
154        assert_eq!(
155            parsed_remote,
156            ParsedGitRemote {
157                owner: "zed-industries".into(),
158                repo: "zed".into(),
159            }
160        );
161    }
162
163    #[test]
164    fn test_parse_remote_url_given_https_url_with_username() {
165        let parsed_remote = Bitbucket::public_instance()
166            .parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
167            .unwrap();
168
169        assert_eq!(
170            parsed_remote,
171            ParsedGitRemote {
172                owner: "zed-industries".into(),
173                repo: "zed".into(),
174            }
175        );
176    }
177
178    #[test]
179    fn test_build_bitbucket_permalink() {
180        let permalink = Bitbucket::public_instance().build_permalink(
181            ParsedGitRemote {
182                owner: "zed-industries".into(),
183                repo: "zed".into(),
184            },
185            BuildPermalinkParams {
186                sha: "f00b4r",
187                path: "main.rs",
188                selection: None,
189            },
190        );
191
192        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
193        assert_eq!(permalink.to_string(), expected_url.to_string())
194    }
195
196    #[test]
197    fn test_build_bitbucket_permalink_with_single_line_selection() {
198        let permalink = Bitbucket::public_instance().build_permalink(
199            ParsedGitRemote {
200                owner: "zed-industries".into(),
201                repo: "zed".into(),
202            },
203            BuildPermalinkParams {
204                sha: "f00b4r",
205                path: "main.rs",
206                selection: Some(6..6),
207            },
208        );
209
210        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
211        assert_eq!(permalink.to_string(), expected_url.to_string())
212    }
213
214    #[test]
215    fn test_build_bitbucket_permalink_with_multi_line_selection() {
216        let permalink = Bitbucket::public_instance().build_permalink(
217            ParsedGitRemote {
218                owner: "zed-industries".into(),
219                repo: "zed".into(),
220            },
221            BuildPermalinkParams {
222                sha: "f00b4r",
223                path: "main.rs",
224                selection: Some(23..47),
225            },
226        );
227
228        let expected_url =
229            "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
230        assert_eq!(permalink.to_string(), expected_url.to_string())
231    }
232
233    #[test]
234    fn test_bitbucket_pull_requests() {
235        use indoc::indoc;
236
237        let remote = ParsedGitRemote {
238            owner: "zed-industries".into(),
239            repo: "zed".into(),
240        };
241
242        let bitbucket = Bitbucket::public_instance();
243
244        // Test message without PR reference
245        let message = "This does not contain a pull request";
246        assert!(bitbucket.extract_pull_request(&remote, message).is_none());
247
248        // Pull request number at end of first line
249        let message = indoc! {r#"
250            Merged in feature-branch (pull request #123)
251
252            Some detailed description of the changes.
253        "#};
254
255        let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
256        assert_eq!(pr.number, 123);
257        assert_eq!(
258            pr.url.as_str(),
259            "https://bitbucket.org/zed-industries/zed/pull-requests/123"
260        );
261    }
262}