Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:41:13.893Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

bitbucket.rs

657 lines · 20.6 KB · rust
1use std::sync::LazyLock;
2use std::{str::FromStr, sync::Arc};
3
4use anyhow::{Context as _, Result, bail};
5use async_trait::async_trait;
6use futures::AsyncReadExt;
7use gpui::SharedString;
8use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
9use itertools::Itertools as _;
10use regex::Regex;
11use serde::Deserialize;
12use url::Url;
13
14use git::{
15    BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
16    PullRequest, RemoteUrl,
17};
18use urlencoding::encode;
19
20use crate::get_host_from_git_remote_url;
21
22fn pull_request_regex() -> &'static Regex {
23    static PULL_REQUEST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
24        // This matches Bitbucket PR reference pattern: (pull request #xxx)
25        Regex::new(r"\(pull request #(\d+)\)").unwrap()
26    });
27    &PULL_REQUEST_REGEX
28}
29
30#[derive(Debug, Deserialize)]
31struct CommitDetails {
32    author: Author,
33}
34
35#[derive(Debug, Deserialize)]
36struct Author {
37    user: Account,
38}
39
40#[derive(Debug, Deserialize)]
41struct Account {
42    links: AccountLinks,
43}
44
45#[derive(Debug, Deserialize)]
46struct AccountLinks {
47    avatar: Option<Link>,
48}
49
50#[derive(Debug, Deserialize)]
51struct Link {
52    href: String,
53}
54
55#[derive(Debug, Deserialize)]
56struct CommitDetailsSelfHosted {
57    author: AuthorSelfHosted,
58}
59
60#[derive(Debug, Deserialize)]
61#[serde(rename_all = "camelCase")]
62struct AuthorSelfHosted {
63    avatar_url: Option<String>,
64}
65
66pub struct Bitbucket {
67    name: String,
68    base_url: Url,
69}
70
71impl Bitbucket {
72    pub fn new(name: impl Into<String>, base_url: Url) -> Self {
73        Self {
74            name: name.into(),
75            base_url,
76        }
77    }
78
79    pub fn public_instance() -> Self {
80        Self::new("Bitbucket", Url::parse("https://bitbucket.org").unwrap())
81    }
82
83    pub fn from_remote_url(remote_url: &str) -> Result<Self> {
84        let host = get_host_from_git_remote_url(remote_url)?;
85        if host == "bitbucket.org" {
86            bail!("the BitBucket instance is not self-hosted");
87        }
88
89        // TODO: detecting self hosted instances by checking whether "bitbucket" is in the url or not
90        // is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
91        // information.
92        if !host.contains("bitbucket") {
93            bail!("not a BitBucket URL");
94        }
95
96        Ok(Self::new(
97            "BitBucket Self-Hosted",
98            Url::parse(&format!("https://{}", host))?,
99        ))
100    }
101
102    fn is_self_hosted(&self) -> bool {
103        self.base_url
104            .host_str()
105            .is_some_and(|host| host != "bitbucket.org")
106    }
107
108    async fn fetch_bitbucket_commit_author(
109        &self,
110        repo_owner: &str,
111        repo: &str,
112        commit: &str,
113        client: &Arc<dyn HttpClient>,
114    ) -> Result<Option<String>> {
115        let Some(host) = self.base_url.host_str() else {
116            bail!("failed to get host from bitbucket base url");
117        };
118        let is_self_hosted = self.is_self_hosted();
119        let url = if is_self_hosted {
120            format!(
121                "https://{host}/rest/api/latest/projects/{repo_owner}/repos/{repo}/commits/{commit}?avatarSize=128"
122            )
123        } else {
124            format!("https://api.{host}/2.0/repositories/{repo_owner}/{repo}/commit/{commit}")
125        };
126
127        let request = Request::get(&url)
128            .header("Content-Type", "application/json")
129            .follow_redirects(http_client::RedirectPolicy::FollowAll);
130
131        let mut response = client
132            .send(request.body(AsyncBody::default())?)
133            .await
134            .with_context(|| format!("error fetching BitBucket commit details at {:?}", url))?;
135
136        let mut body = Vec::new();
137        response.body_mut().read_to_end(&mut body).await?;
138
139        if response.status().is_client_error() {
140            let text = String::from_utf8_lossy(body.as_slice());
141            bail!(
142                "status error {}, response: {text:?}",
143                response.status().as_u16()
144            );
145        }
146
147        let body_str = std::str::from_utf8(&body)?;
148
149        if is_self_hosted {
150            serde_json::from_str::<CommitDetailsSelfHosted>(body_str)
151                .map(|commit| commit.author.avatar_url)
152        } else {
153            serde_json::from_str::<CommitDetails>(body_str)
154                .map(|commit| commit.author.user.links.avatar.map(|link| link.href))
155        }
156        .context("failed to deserialize BitBucket commit details")
157    }
158}
159
160#[async_trait]
161impl GitHostingProvider for Bitbucket {
162    fn name(&self) -> String {
163        self.name.clone()
164    }
165
166    fn base_url(&self) -> Url {
167        self.base_url.clone()
168    }
169
170    fn supports_avatars(&self) -> bool {
171        true
172    }
173
174    fn format_line_number(&self, line: u32) -> String {
175        if self.is_self_hosted() {
176            return format!("{line}");
177        }
178        format!("lines-{line}")
179    }
180
181    fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
182        if self.is_self_hosted() {
183            return format!("{start_line}-{end_line}");
184        }
185        format!("lines-{start_line}:{end_line}")
186    }
187
188    fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
189        let url = RemoteUrl::from_str(url).ok()?;
190
191        let host = url.host_str()?;
192        if host != self.base_url.host_str()? {
193            return None;
194        }
195
196        let mut path_segments = url.path_segments()?.collect::<Vec<_>>();
197        let repo = path_segments.pop()?.trim_end_matches(".git");
198        let owner = if path_segments.get(0).is_some_and(|v| *v == "scm") && path_segments.len() > 1
199        {
200            // Skip the "scm" segment if it's not the only segment
201            // https://github.com/gitkraken/vscode-gitlens/blob/a6e3c6fbb255116507eaabaa9940c192ed7bb0e1/src/git/remotes/bitbucket-server.ts#L72-L74
202            path_segments.into_iter().skip(1).join("/")
203        } else {
204            path_segments.into_iter().join("/")
205        };
206
207        Some(ParsedGitRemote {
208            owner: owner.into(),
209            repo: repo.into(),
210        })
211    }
212
213    fn build_commit_permalink(
214        &self,
215        remote: &ParsedGitRemote,
216        params: BuildCommitPermalinkParams,
217    ) -> Url {
218        let BuildCommitPermalinkParams { sha } = params;
219        let ParsedGitRemote { owner, repo } = remote;
220        if self.is_self_hosted() {
221            return self
222                .base_url()
223                .join(&format!("projects/{owner}/repos/{repo}/commits/{sha}"))
224                .unwrap();
225        }
226        self.base_url()
227            .join(&format!("{owner}/{repo}/commits/{sha}"))
228            .unwrap()
229    }
230
231    fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
232        let ParsedGitRemote { owner, repo } = remote;
233        let BuildPermalinkParams {
234            sha,
235            path,
236            selection,
237        } = params;
238
239        let mut permalink = if self.is_self_hosted() {
240            self.base_url()
241                .join(&format!(
242                    "projects/{owner}/repos/{repo}/browse/{path}?at={sha}"
243                ))
244                .unwrap()
245        } else {
246            self.base_url()
247                .join(&format!("{owner}/{repo}/src/{sha}/{path}"))
248                .unwrap()
249        };
250
251        permalink.set_fragment(
252            selection
253                .map(|selection| self.line_fragment(&selection))
254                .as_deref(),
255        );
256        permalink
257    }
258
259    fn build_create_pull_request_url(
260        &self,
261        remote: &ParsedGitRemote,
262        source_branch: &str,
263    ) -> Option<Url> {
264        let ParsedGitRemote { owner, repo } = remote;
265
266        if self.is_self_hosted() {
267            let mut url = self
268                .base_url()
269                .join(&format!("projects/{owner}/repos/{repo}/compare/commits"))
270                .ok()?;
271            let source_ref = format!("refs/heads/{source_branch}");
272            let encoded_ref = encode(&source_ref);
273            url.set_query(Some(&format!("sourceBranch={encoded_ref}")));
274            Some(url)
275        } else {
276            let mut url = self
277                .base_url()
278                .join(&format!("{owner}/{repo}/pull-requests/new"))
279                .ok()?;
280            let encoded_branch = encode(source_branch);
281            url.set_query(Some(&format!("source={encoded_branch}")));
282            Some(url)
283        }
284    }
285
286    fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
287        // Check first line of commit message for PR references
288        let first_line = message.lines().next()?;
289
290        // Try to match against our PR patterns
291        let capture = pull_request_regex().captures(first_line)?;
292        let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
293
294        // Construct the PR URL in Bitbucket format
295        let mut url = self.base_url();
296        let path = if self.is_self_hosted() {
297            format!(
298                "/projects/{}/repos/{}/pull-requests/{}",
299                remote.owner, remote.repo, number
300            )
301        } else {
302            format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number)
303        };
304        url.set_path(&path);
305
306        Some(PullRequest { number, url })
307    }
308
309    async fn commit_author_avatar_url(
310        &self,
311        repo_owner: &str,
312        repo: &str,
313        commit: SharedString,
314        _author_email: Option<SharedString>,
315        http_client: Arc<dyn HttpClient>,
316    ) -> Result<Option<Url>> {
317        let commit = commit.to_string();
318        let avatar_url = self
319            .fetch_bitbucket_commit_author(repo_owner, repo, &commit, &http_client)
320            .await?
321            .map(|avatar_url| Url::parse(&avatar_url))
322            .transpose()?;
323        Ok(avatar_url)
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use git::repository::repo_path;
330    use pretty_assertions::assert_eq;
331
332    use super::*;
333
334    #[test]
335    fn test_parse_remote_url_given_ssh_url() {
336        let parsed_remote = Bitbucket::public_instance()
337            .parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
338            .unwrap();
339
340        assert_eq!(
341            parsed_remote,
342            ParsedGitRemote {
343                owner: "zed-industries".into(),
344                repo: "zed".into(),
345            }
346        );
347    }
348
349    #[test]
350    fn test_parse_remote_url_given_https_url() {
351        let parsed_remote = Bitbucket::public_instance()
352            .parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
353            .unwrap();
354
355        assert_eq!(
356            parsed_remote,
357            ParsedGitRemote {
358                owner: "zed-industries".into(),
359                repo: "zed".into(),
360            }
361        );
362    }
363
364    #[test]
365    fn test_parse_remote_url_given_https_url_with_username() {
366        let parsed_remote = Bitbucket::public_instance()
367            .parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
368            .unwrap();
369
370        assert_eq!(
371            parsed_remote,
372            ParsedGitRemote {
373                owner: "zed-industries".into(),
374                repo: "zed".into(),
375            }
376        );
377    }
378
379    #[test]
380    fn test_parse_remote_url_given_self_hosted_ssh_url() {
381        let remote_url = "git@bitbucket.company.com:zed-industries/zed.git";
382
383        let parsed_remote = Bitbucket::from_remote_url(remote_url)
384            .unwrap()
385            .parse_remote_url(remote_url)
386            .unwrap();
387
388        assert_eq!(
389            parsed_remote,
390            ParsedGitRemote {
391                owner: "zed-industries".into(),
392                repo: "zed".into(),
393            }
394        );
395    }
396
397    #[test]
398    fn test_parse_remote_url_given_self_hosted_https_url() {
399        let remote_url = "https://bitbucket.company.com/zed-industries/zed.git";
400
401        let parsed_remote = Bitbucket::from_remote_url(remote_url)
402            .unwrap()
403            .parse_remote_url(remote_url)
404            .unwrap();
405
406        assert_eq!(
407            parsed_remote,
408            ParsedGitRemote {
409                owner: "zed-industries".into(),
410                repo: "zed".into(),
411            }
412        );
413
414        // Test with "scm" in the path
415        let remote_url = "https://bitbucket.company.com/scm/zed-industries/zed.git";
416
417        let parsed_remote = Bitbucket::from_remote_url(remote_url)
418            .unwrap()
419            .parse_remote_url(remote_url)
420            .unwrap();
421
422        assert_eq!(
423            parsed_remote,
424            ParsedGitRemote {
425                owner: "zed-industries".into(),
426                repo: "zed".into(),
427            }
428        );
429
430        // Test with only "scm" as owner
431        let remote_url = "https://bitbucket.company.com/scm/zed.git";
432
433        let parsed_remote = Bitbucket::from_remote_url(remote_url)
434            .unwrap()
435            .parse_remote_url(remote_url)
436            .unwrap();
437
438        assert_eq!(
439            parsed_remote,
440            ParsedGitRemote {
441                owner: "scm".into(),
442                repo: "zed".into(),
443            }
444        );
445    }
446
447    #[test]
448    fn test_parse_remote_url_given_self_hosted_https_url_with_username() {
449        let remote_url = "https://thorstenballzed@bitbucket.company.com/zed-industries/zed.git";
450
451        let parsed_remote = Bitbucket::from_remote_url(remote_url)
452            .unwrap()
453            .parse_remote_url(remote_url)
454            .unwrap();
455
456        assert_eq!(
457            parsed_remote,
458            ParsedGitRemote {
459                owner: "zed-industries".into(),
460                repo: "zed".into(),
461            }
462        );
463    }
464
465    #[test]
466    fn test_build_bitbucket_permalink() {
467        let permalink = Bitbucket::public_instance().build_permalink(
468            ParsedGitRemote {
469                owner: "zed-industries".into(),
470                repo: "zed".into(),
471            },
472            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
473        );
474
475        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
476        assert_eq!(permalink.to_string(), expected_url.to_string())
477    }
478
479    #[test]
480    fn test_build_bitbucket_self_hosted_permalink() {
481        let permalink =
482            Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git")
483                .unwrap()
484                .build_permalink(
485                    ParsedGitRemote {
486                        owner: "zed-industries".into(),
487                        repo: "zed".into(),
488                    },
489                    BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
490                );
491
492        let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r";
493        assert_eq!(permalink.to_string(), expected_url.to_string())
494    }
495
496    #[test]
497    fn test_build_bitbucket_permalink_with_single_line_selection() {
498        let permalink = Bitbucket::public_instance().build_permalink(
499            ParsedGitRemote {
500                owner: "zed-industries".into(),
501                repo: "zed".into(),
502            },
503            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
504        );
505
506        let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
507        assert_eq!(permalink.to_string(), expected_url.to_string())
508    }
509
510    #[test]
511    fn test_build_bitbucket_self_hosted_permalink_with_single_line_selection() {
512        let permalink =
513            Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git")
514                .unwrap()
515                .build_permalink(
516                    ParsedGitRemote {
517                        owner: "zed-industries".into(),
518                        repo: "zed".into(),
519                    },
520                    BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
521                );
522
523        let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#7";
524        assert_eq!(permalink.to_string(), expected_url.to_string())
525    }
526
527    #[test]
528    fn test_build_bitbucket_permalink_with_multi_line_selection() {
529        let permalink = Bitbucket::public_instance().build_permalink(
530            ParsedGitRemote {
531                owner: "zed-industries".into(),
532                repo: "zed".into(),
533            },
534            BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
535        );
536
537        let expected_url =
538            "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
539        assert_eq!(permalink.to_string(), expected_url.to_string())
540    }
541
542    #[test]
543    fn test_build_bitbucket_self_hosted_permalink_with_multi_line_selection() {
544        let permalink =
545            Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git")
546                .unwrap()
547                .build_permalink(
548                    ParsedGitRemote {
549                        owner: "zed-industries".into(),
550                        repo: "zed".into(),
551                    },
552                    BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
553                );
554
555        let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#24-48";
556        assert_eq!(permalink.to_string(), expected_url.to_string())
557    }
558
559    #[test]
560    fn test_build_bitbucket_create_pr_url() {
561        let remote = ParsedGitRemote {
562            owner: "zed-industries".into(),
563            repo: "zed".into(),
564        };
565
566        let url = Bitbucket::public_instance()
567            .build_create_pull_request_url(&remote, "feature/my-branch")
568            .expect("url should be constructed");
569
570        assert_eq!(
571            url.as_str(),
572            "https://bitbucket.org/zed-industries/zed/pull-requests/new?source=feature%2Fmy-branch"
573        );
574    }
575
576    #[test]
577    fn test_build_bitbucket_self_hosted_create_pr_url() {
578        let remote = ParsedGitRemote {
579            owner: "zed-industries".into(),
580            repo: "zed".into(),
581        };
582
583        let url =
584            Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git")
585                .unwrap()
586                .build_create_pull_request_url(&remote, "feature/my-branch")
587                .expect("url should be constructed");
588
589        assert_eq!(
590            url.as_str(),
591            "https://bitbucket.company.com/projects/zed-industries/repos/zed/compare/commits?sourceBranch=refs%2Fheads%2Ffeature%2Fmy-branch"
592        );
593    }
594
595    #[test]
596    fn test_bitbucket_pull_requests() {
597        use indoc::indoc;
598
599        let remote = ParsedGitRemote {
600            owner: "zed-industries".into(),
601            repo: "zed".into(),
602        };
603
604        let bitbucket = Bitbucket::public_instance();
605
606        // Test message without PR reference
607        let message = "This does not contain a pull request";
608        assert!(bitbucket.extract_pull_request(&remote, message).is_none());
609
610        // Pull request number at end of first line
611        let message = indoc! {r#"
612            Merged in feature-branch (pull request #123)
613
614            Some detailed description of the changes.
615        "#};
616
617        let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
618        assert_eq!(pr.number, 123);
619        assert_eq!(
620            pr.url.as_str(),
621            "https://bitbucket.org/zed-industries/zed/pull-requests/123"
622        );
623    }
624
625    #[test]
626    fn test_bitbucket_self_hosted_pull_requests() {
627        use indoc::indoc;
628
629        let remote = ParsedGitRemote {
630            owner: "zed-industries".into(),
631            repo: "zed".into(),
632        };
633
634        let bitbucket =
635            Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git")
636                .unwrap();
637
638        // Test message without PR reference
639        let message = "This does not contain a pull request";
640        assert!(bitbucket.extract_pull_request(&remote, message).is_none());
641
642        // Pull request number at end of first line
643        let message = indoc! {r#"
644            Merged in feature-branch (pull request #123)
645
646            Some detailed description of the changes.
647        "#};
648
649        let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
650        assert_eq!(pr.number, 123);
651        assert_eq!(
652            pr.url.as_str(),
653            "https://bitbucket.company.com/projects/zed-industries/repos/zed/pull-requests/123"
654        );
655    }
656}
657
Served at tenant.openagents/omega Member data and write actions are omitted.