logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
34
crates/git_hosting_providers/Cargo.toml
Normal file
34
crates/git_hosting_providers/Cargo.toml
Normal file
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "git_hosting_providers"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/git_hosting_providers.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
async-trait.workspace = true
|
||||
futures.workspace = true
|
||||
git.workspace = true
|
||||
gpui.workspace = true
|
||||
http_client.workspace = true
|
||||
itertools.workspace = true
|
||||
regex.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
settings.workspace = true
|
||||
url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
util.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
indoc.workspace = true
|
||||
serde_json.workspace = true
|
||||
pretty_assertions.workspace = true
|
||||
git = { workspace = true, features = ["test-support"] }
|
||||
1
crates/git_hosting_providers/LICENSE-GPL
Symbolic link
1
crates/git_hosting_providers/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
102
crates/git_hosting_providers/src/git_hosting_providers.rs
Normal file
102
crates/git_hosting_providers/src/git_hosting_providers.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
mod providers;
|
||||
mod settings;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use git::GitHostingProviderRegistry;
|
||||
use git::repository::GitRepository;
|
||||
use gpui::App;
|
||||
use url::Url;
|
||||
use util::maybe;
|
||||
|
||||
pub use crate::providers::*;
|
||||
pub use crate::settings::*;
|
||||
|
||||
/// Initializes the Git hosting providers.
|
||||
pub fn init(cx: &mut App) {
|
||||
crate::settings::init(cx);
|
||||
|
||||
let provider_registry = GitHostingProviderRegistry::global(cx);
|
||||
provider_registry.register_hosting_provider(Arc::new(Azure));
|
||||
provider_registry.register_hosting_provider(Arc::new(Bitbucket::public_instance()));
|
||||
provider_registry.register_hosting_provider(Arc::new(Chromium));
|
||||
provider_registry.register_hosting_provider(Arc::new(Forgejo::public_instance()));
|
||||
provider_registry.register_hosting_provider(Arc::new(Gitea::public_instance()));
|
||||
provider_registry.register_hosting_provider(Arc::new(Gitee));
|
||||
provider_registry.register_hosting_provider(Arc::new(Github::public_instance()));
|
||||
provider_registry.register_hosting_provider(Arc::new(Gitlab::public_instance()));
|
||||
provider_registry.register_hosting_provider(Arc::new(SourceHut::public_instance()));
|
||||
}
|
||||
|
||||
/// Registers additional Git hosting providers.
|
||||
///
|
||||
/// These require information from the Git repository to construct, so their
|
||||
/// registration is deferred until we have a Git repository initialized.
|
||||
pub async fn register_additional_providers(
|
||||
provider_registry: Arc<GitHostingProviderRegistry>,
|
||||
repository: Arc<dyn GitRepository>,
|
||||
) {
|
||||
let Some(origin_url) = repository.remote_url("origin").await else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Ok(gitlab_self_hosted) = Gitlab::from_remote_url(&origin_url) {
|
||||
provider_registry.register_hosting_provider(Arc::new(gitlab_self_hosted));
|
||||
} else if let Ok(github_self_hosted) = Github::from_remote_url(&origin_url) {
|
||||
provider_registry.register_hosting_provider(Arc::new(github_self_hosted));
|
||||
} else if let Ok(forgejo_self_hosted) = Forgejo::from_remote_url(&origin_url) {
|
||||
provider_registry.register_hosting_provider(Arc::new(forgejo_self_hosted));
|
||||
} else if let Ok(gitea_self_hosted) = Gitea::from_remote_url(&origin_url) {
|
||||
provider_registry.register_hosting_provider(Arc::new(gitea_self_hosted));
|
||||
} else if let Ok(bitbucket_self_hosted) = Bitbucket::from_remote_url(&origin_url) {
|
||||
provider_registry.register_hosting_provider(Arc::new(bitbucket_self_hosted));
|
||||
} else if let Ok(sourcehut_self_hosted) = SourceHut::from_remote_url(&origin_url) {
|
||||
provider_registry.register_hosting_provider(Arc::new(sourcehut_self_hosted));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_host_from_git_remote_url(remote_url: &str) -> Result<String> {
|
||||
maybe!({
|
||||
if let Some(remote_url) = remote_url.strip_prefix("git@")
|
||||
&& let Some((host, _)) = remote_url.trim_start_matches("git@").split_once(':')
|
||||
{
|
||||
return Some(host.to_string());
|
||||
}
|
||||
|
||||
Url::parse(remote_url)
|
||||
.ok()
|
||||
.and_then(|remote_url| remote_url.host_str().map(|host| host.to_string()))
|
||||
})
|
||||
.context("URL has no host")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::get_host_from_git_remote_url;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_get_host_from_git_remote_url() {
|
||||
let tests = [
|
||||
(
|
||||
"https://jlannister@github.com/some-org/some-repo.git",
|
||||
Some("github.com".to_string()),
|
||||
),
|
||||
(
|
||||
"git@github.com:zed-industries/zed.git",
|
||||
Some("github.com".to_string()),
|
||||
),
|
||||
(
|
||||
"git@my.super.long.subdomain.com:zed-industries/zed.git",
|
||||
Some("my.super.long.subdomain.com".to_string()),
|
||||
),
|
||||
];
|
||||
|
||||
for (remote_url, expected_host) in tests {
|
||||
let host = get_host_from_git_remote_url(remote_url).ok();
|
||||
assert_eq!(host, expected_host);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
crates/git_hosting_providers/src/providers.rs
Normal file
19
crates/git_hosting_providers/src/providers.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
mod azure;
|
||||
mod bitbucket;
|
||||
mod chromium;
|
||||
mod forgejo;
|
||||
mod gitea;
|
||||
mod gitee;
|
||||
mod github;
|
||||
mod gitlab;
|
||||
mod sourcehut;
|
||||
|
||||
pub use azure::*;
|
||||
pub use bitbucket::*;
|
||||
pub use chromium::*;
|
||||
pub use forgejo::*;
|
||||
pub use gitea::*;
|
||||
pub use gitee::*;
|
||||
pub use github::*;
|
||||
pub use gitlab::*;
|
||||
pub use sourcehut::*;
|
||||
388
crates/git_hosting_providers/src/providers/azure.rs
Normal file
388
crates/git_hosting_providers/src/providers/azure.rs
Normal file
@@ -0,0 +1,388 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use url::Url;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
PullRequest, RemoteUrl,
|
||||
};
|
||||
|
||||
fn pull_request_regex() -> &'static Regex {
|
||||
static PULL_REQUEST_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^Merged PR (\d+):").unwrap());
|
||||
&PULL_REQUEST_REGEX
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Azure;
|
||||
|
||||
impl Azure {
|
||||
fn parse_dev_azure_com_url(&self, url: &RemoteUrl) -> Option<ParsedGitRemote> {
|
||||
let host = url.host_str()?;
|
||||
|
||||
if host == "ssh.dev.azure.com" {
|
||||
// SSH format: git@ssh.dev.azure.com:v3/{organization}/{project}/{repo}
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let _v3 = path_segments.next()?;
|
||||
let organization = path_segments.next()?;
|
||||
let project = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
return Some(ParsedGitRemote {
|
||||
owner: format!("{organization}/{project}").into(),
|
||||
repo: repo.into(),
|
||||
});
|
||||
}
|
||||
|
||||
if host != "dev.azure.com" {
|
||||
return None;
|
||||
}
|
||||
|
||||
// HTTPS format: https://dev.azure.com/{organization}/{project}/_git/{repo}
|
||||
// or: https://{organization}@dev.azure.com/{organization}/{project}/_git/{repo}
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let organization = path_segments.next()?;
|
||||
let project = path_segments.next()?;
|
||||
let _git = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: format!("{organization}/{project}").into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_visualstudio_com_url(&self, url: &RemoteUrl) -> Option<ParsedGitRemote> {
|
||||
let host = url.host_str()?;
|
||||
|
||||
if !host.ends_with(".visualstudio.com") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let organization = host.strip_suffix(".visualstudio.com")?;
|
||||
|
||||
// HTTPS format: https://{organization}.visualstudio.com/{project}/_git/{repo}
|
||||
// or with DefaultCollection: https://{organization}.visualstudio.com/DefaultCollection/{project}/_git/{repo}
|
||||
let mut path_segments = url.path_segments()?.peekable();
|
||||
|
||||
let first_segment = path_segments.next()?;
|
||||
let project = if first_segment == "DefaultCollection" {
|
||||
path_segments.next()?
|
||||
} else {
|
||||
first_segment
|
||||
};
|
||||
|
||||
let _git = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: format!("{organization}/{project}").into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GitHostingProvider for Azure {
|
||||
fn name(&self) -> String {
|
||||
"Azure DevOps".to_string()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
Url::parse("https://dev.azure.com").unwrap()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn format_line_number(&self, line: u32) -> String {
|
||||
format!("line={line}&lineEnd={line}&lineStartColumn=1&lineEndColumn=1")
|
||||
}
|
||||
|
||||
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
|
||||
format!("line={start_line}&lineEnd={end_line}&lineStartColumn=1&lineEndColumn=1")
|
||||
}
|
||||
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
self.parse_dev_azure_com_url(&url)
|
||||
.or_else(|| self.parse_visualstudio_com_url(&url))
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
params: BuildCommitPermalinkParams,
|
||||
) -> Url {
|
||||
let BuildCommitPermalinkParams { sha } = params;
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
|
||||
let mut url = self
|
||||
.base_url()
|
||||
.join(&format!("{owner}/_git/{repo}/commit/{sha}"))
|
||||
.expect("failed to build commit permalink");
|
||||
url.set_query(None);
|
||||
url
|
||||
}
|
||||
|
||||
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let BuildPermalinkParams {
|
||||
sha,
|
||||
path,
|
||||
selection,
|
||||
} = params;
|
||||
|
||||
let mut permalink = self
|
||||
.base_url()
|
||||
.join(&format!("{owner}/_git/{repo}"))
|
||||
.expect("failed to build permalink");
|
||||
|
||||
let mut query = format!("path=/{path}&version=GC{sha}");
|
||||
if let Some(selection) = selection {
|
||||
query.push('&');
|
||||
query.push_str(&self.line_fragment(&selection));
|
||||
}
|
||||
permalink.set_query(Some(&query));
|
||||
|
||||
permalink
|
||||
}
|
||||
|
||||
fn build_create_pull_request_url(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
source_branch: &str,
|
||||
) -> Option<Url> {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let encoded_source = urlencoding::encode(source_branch);
|
||||
|
||||
let mut url = self
|
||||
.base_url()
|
||||
.join(&format!("{owner}/_git/{repo}/pullrequestcreate"))
|
||||
.ok()?;
|
||||
url.set_query(Some(&format!("sourceRef={encoded_source}")));
|
||||
Some(url)
|
||||
}
|
||||
|
||||
fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
|
||||
let first_line = message.lines().next()?;
|
||||
let capture = pull_request_regex().captures(first_line)?;
|
||||
let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
|
||||
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let url = self
|
||||
.base_url()
|
||||
.join(&format!("{owner}/_git/{repo}/pullrequest/{number}"))
|
||||
.ok()?;
|
||||
|
||||
Some(PullRequest { number, url })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use git::repository::repo_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_https_dev_azure() {
|
||||
let parsed_remote = Azure
|
||||
.parse_remote_url("https://dev.azure.com/myorg/myproject/_git/myrepo")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_https_dev_azure_with_username() {
|
||||
let parsed_remote = Azure
|
||||
.parse_remote_url("https://myorg@dev.azure.com/myorg/myproject/_git/myrepo")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_ssh_dev_azure() {
|
||||
let parsed_remote = Azure
|
||||
.parse_remote_url("git@ssh.dev.azure.com:v3/myorg/myproject/myrepo")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_visualstudio_com() {
|
||||
let parsed_remote = Azure
|
||||
.parse_remote_url("https://myorg.visualstudio.com/myproject/_git/myrepo")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_visualstudio_com_with_default_collection() {
|
||||
let parsed_remote = Azure
|
||||
.parse_remote_url(
|
||||
"https://myorg.visualstudio.com/DefaultCollection/myproject/_git/myrepo",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_returns_none_for_github() {
|
||||
let result = Azure.parse_remote_url("https://github.com/owner/repo.git");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_azure_permalink() {
|
||||
let permalink = Azure.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
},
|
||||
BuildPermalinkParams::new("abc123def456", &repo_path("src/main.rs"), None),
|
||||
);
|
||||
|
||||
let expected_url = "https://dev.azure.com/myorg/myproject/_git/myrepo?path=/src/main.rs&version=GCabc123def456";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_azure_permalink_with_single_line_selection() {
|
||||
let permalink = Azure.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
},
|
||||
BuildPermalinkParams::new("abc123def456", &repo_path("src/main.rs"), Some(6..6)),
|
||||
);
|
||||
|
||||
let expected_url = "https://dev.azure.com/myorg/myproject/_git/myrepo?path=/src/main.rs&version=GCabc123def456&line=7&lineEnd=7&lineStartColumn=1&lineEndColumn=1";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_azure_permalink_with_multi_line_selection() {
|
||||
let permalink = Azure.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
},
|
||||
BuildPermalinkParams::new("abc123def456", &repo_path("src/main.rs"), Some(23..47)),
|
||||
);
|
||||
|
||||
let expected_url = "https://dev.azure.com/myorg/myproject/_git/myrepo?path=/src/main.rs&version=GCabc123def456&line=24&lineEnd=48&lineStartColumn=1&lineEndColumn=1";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_azure_commit_permalink() {
|
||||
let permalink = Azure.build_commit_permalink(
|
||||
&ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
},
|
||||
BuildCommitPermalinkParams {
|
||||
sha: "abc123def456",
|
||||
},
|
||||
);
|
||||
|
||||
let expected_url = "https://dev.azure.com/myorg/myproject/_git/myrepo/commit/abc123def456";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_azure_create_pr_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
};
|
||||
|
||||
let url = Azure
|
||||
.build_create_pull_request_url(&remote, "feature/my-branch")
|
||||
.expect("url should be constructed");
|
||||
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequestcreate?sourceRef=feature%2Fmy-branch"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_azure_extract_pull_request() {
|
||||
use indoc::indoc;
|
||||
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "myorg/myproject".into(),
|
||||
repo: "myrepo".into(),
|
||||
};
|
||||
|
||||
let message = "This does not contain a pull request";
|
||||
assert!(Azure.extract_pull_request(&remote, message).is_none());
|
||||
|
||||
let message = indoc! {r#"
|
||||
Merged PR 123: Add new feature
|
||||
|
||||
This PR adds a new feature to the application.
|
||||
"#};
|
||||
|
||||
let pull_request = Azure.extract_pull_request(&remote, message).unwrap();
|
||||
assert_eq!(pull_request.number, 123);
|
||||
assert_eq!(
|
||||
pull_request.url.as_str(),
|
||||
"https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/123"
|
||||
);
|
||||
|
||||
let message = "Merged PR 456: Fix bug in authentication";
|
||||
let pull_request = Azure.extract_pull_request(&remote, message).unwrap();
|
||||
assert_eq!(pull_request.number, 456);
|
||||
assert_eq!(
|
||||
pull_request.url.as_str(),
|
||||
"https://dev.azure.com/myorg/myproject/_git/myrepo/pullrequest/456"
|
||||
);
|
||||
|
||||
let message = "This mentions PR 789 but not at the start";
|
||||
assert!(Azure.extract_pull_request(&remote, message).is_none());
|
||||
}
|
||||
}
|
||||
592
crates/git_hosting_providers/src/providers/bitbucket.rs
Normal file
592
crates/git_hosting_providers/src/providers/bitbucket.rs
Normal file
@@ -0,0 +1,592 @@
|
||||
use std::sync::LazyLock;
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::SharedString;
|
||||
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
|
||||
use itertools::Itertools as _;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
PullRequest, RemoteUrl,
|
||||
};
|
||||
|
||||
use crate::get_host_from_git_remote_url;
|
||||
|
||||
fn pull_request_regex() -> &'static Regex {
|
||||
static PULL_REQUEST_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
// This matches Bitbucket PR reference pattern: (pull request #xxx)
|
||||
Regex::new(r"\(pull request #(\d+)\)").unwrap()
|
||||
});
|
||||
&PULL_REQUEST_REGEX
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommitDetails {
|
||||
author: Author,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Author {
|
||||
user: Account,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Account {
|
||||
links: AccountLinks,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AccountLinks {
|
||||
avatar: Option<Link>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Link {
|
||||
href: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommitDetailsSelfHosted {
|
||||
author: AuthorSelfHosted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthorSelfHosted {
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Bitbucket {
|
||||
name: String,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
impl Bitbucket {
|
||||
pub fn new(name: impl Into<String>, base_url: Url) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_instance() -> Self {
|
||||
Self::new("Bitbucket", Url::parse("https://bitbucket.org").unwrap())
|
||||
}
|
||||
|
||||
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
|
||||
let host = get_host_from_git_remote_url(remote_url)?;
|
||||
if host == "bitbucket.org" {
|
||||
bail!("the BitBucket instance is not self-hosted");
|
||||
}
|
||||
|
||||
// TODO: detecting self hosted instances by checking whether "bitbucket" is in the url or not
|
||||
// is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
|
||||
// information.
|
||||
if !host.contains("bitbucket") {
|
||||
bail!("not a BitBucket URL");
|
||||
}
|
||||
|
||||
Ok(Self::new(
|
||||
"BitBucket Self-Hosted",
|
||||
Url::parse(&format!("https://{}", host))?,
|
||||
))
|
||||
}
|
||||
|
||||
fn is_self_hosted(&self) -> bool {
|
||||
self.base_url
|
||||
.host_str()
|
||||
.is_some_and(|host| host != "bitbucket.org")
|
||||
}
|
||||
|
||||
async fn fetch_bitbucket_commit_author(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<Option<String>> {
|
||||
let Some(host) = self.base_url.host_str() else {
|
||||
bail!("failed to get host from bitbucket base url");
|
||||
};
|
||||
let is_self_hosted = self.is_self_hosted();
|
||||
let url = if is_self_hosted {
|
||||
format!(
|
||||
"https://{host}/rest/api/latest/projects/{repo_owner}/repos/{repo}/commits/{commit}?avatarSize=128"
|
||||
)
|
||||
} else {
|
||||
format!("https://api.{host}/2.0/repositories/{repo_owner}/{repo}/commit/{commit}")
|
||||
};
|
||||
|
||||
let request = Request::get(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.follow_redirects(http_client::RedirectPolicy::FollowAll);
|
||||
|
||||
let mut response = client
|
||||
.send(request.body(AsyncBody::default())?)
|
||||
.await
|
||||
.with_context(|| format!("error fetching BitBucket commit details at {:?}", url))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let body_str = std::str::from_utf8(&body)?;
|
||||
|
||||
if is_self_hosted {
|
||||
serde_json::from_str::<CommitDetailsSelfHosted>(body_str)
|
||||
.map(|commit| commit.author.avatar_url)
|
||||
} else {
|
||||
serde_json::from_str::<CommitDetails>(body_str)
|
||||
.map(|commit| commit.author.user.links.avatar.map(|link| link.href))
|
||||
}
|
||||
.context("failed to deserialize BitBucket commit details")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GitHostingProvider for Bitbucket {
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
self.base_url.clone()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn format_line_number(&self, line: u32) -> String {
|
||||
if self.is_self_hosted() {
|
||||
return format!("{line}");
|
||||
}
|
||||
format!("lines-{line}")
|
||||
}
|
||||
|
||||
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
|
||||
if self.is_self_hosted() {
|
||||
return format!("{start_line}-{end_line}");
|
||||
}
|
||||
format!("lines-{start_line}:{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let host = url.host_str()?;
|
||||
if host != self.base_url.host_str()? {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_segments = url.path_segments()?.collect::<Vec<_>>();
|
||||
let repo = path_segments.pop()?.trim_end_matches(".git");
|
||||
let owner = if path_segments.get(0).is_some_and(|v| *v == "scm") && path_segments.len() > 1
|
||||
{
|
||||
// Skip the "scm" segment if it's not the only segment
|
||||
// https://github.com/gitkraken/vscode-gitlens/blob/a6e3c6fbb255116507eaabaa9940c192ed7bb0e1/src/git/remotes/bitbucket-server.ts#L72-L74
|
||||
path_segments.into_iter().skip(1).join("/")
|
||||
} else {
|
||||
path_segments.into_iter().join("/")
|
||||
};
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
params: BuildCommitPermalinkParams,
|
||||
) -> Url {
|
||||
let BuildCommitPermalinkParams { sha } = params;
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
if self.is_self_hosted() {
|
||||
return self
|
||||
.base_url()
|
||||
.join(&format!("projects/{owner}/repos/{repo}/commits/{sha}"))
|
||||
.unwrap();
|
||||
}
|
||||
self.base_url()
|
||||
.join(&format!("{owner}/{repo}/commits/{sha}"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let BuildPermalinkParams {
|
||||
sha,
|
||||
path,
|
||||
selection,
|
||||
} = params;
|
||||
|
||||
let mut permalink = if self.is_self_hosted() {
|
||||
self.base_url()
|
||||
.join(&format!(
|
||||
"projects/{owner}/repos/{repo}/browse/{path}?at={sha}"
|
||||
))
|
||||
.unwrap()
|
||||
} else {
|
||||
self.base_url()
|
||||
.join(&format!("{owner}/{repo}/src/{sha}/{path}"))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
permalink.set_fragment(
|
||||
selection
|
||||
.map(|selection| self.line_fragment(&selection))
|
||||
.as_deref(),
|
||||
);
|
||||
permalink
|
||||
}
|
||||
|
||||
fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
|
||||
// Check first line of commit message for PR references
|
||||
let first_line = message.lines().next()?;
|
||||
|
||||
// Try to match against our PR patterns
|
||||
let capture = pull_request_regex().captures(first_line)?;
|
||||
let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
|
||||
|
||||
// Construct the PR URL in Bitbucket format
|
||||
let mut url = self.base_url();
|
||||
let path = if self.is_self_hosted() {
|
||||
format!(
|
||||
"/projects/{}/repos/{}/pull-requests/{}",
|
||||
remote.owner, remote.repo, number
|
||||
)
|
||||
} else {
|
||||
format!("/{}/{}/pull-requests/{}", remote.owner, remote.repo, number)
|
||||
};
|
||||
url.set_path(&path);
|
||||
|
||||
Some(PullRequest { number, url })
|
||||
}
|
||||
|
||||
async fn commit_author_avatar_url(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: SharedString,
|
||||
_author_email: Option<SharedString>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<Url>> {
|
||||
let commit = commit.to_string();
|
||||
let avatar_url = self
|
||||
.fetch_bitbucket_commit_author(repo_owner, repo, &commit, &http_client)
|
||||
.await?
|
||||
.map(|avatar_url| Url::parse(&avatar_url))
|
||||
.transpose()?;
|
||||
Ok(avatar_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use git::repository::repo_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Bitbucket::public_instance()
|
||||
.parse_remote_url("git@bitbucket.org:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Bitbucket::public_instance()
|
||||
.parse_remote_url("https://bitbucket.org/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url_with_username() {
|
||||
let parsed_remote = Bitbucket::public_instance()
|
||||
.parse_remote_url("https://thorstenballzed@bitbucket.org/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_ssh_url() {
|
||||
let remote_url = "git@bitbucket.company.com:zed-industries/zed.git";
|
||||
|
||||
let parsed_remote = Bitbucket::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_https_url() {
|
||||
let remote_url = "https://bitbucket.company.com/zed-industries/zed.git";
|
||||
|
||||
let parsed_remote = Bitbucket::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
|
||||
// Test with "scm" in the path
|
||||
let remote_url = "https://bitbucket.company.com/scm/zed-industries/zed.git";
|
||||
|
||||
let parsed_remote = Bitbucket::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
|
||||
// Test with only "scm" as owner
|
||||
let remote_url = "https://bitbucket.company.com/scm/zed.git";
|
||||
|
||||
let parsed_remote = Bitbucket::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "scm".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_https_url_with_username() {
|
||||
let remote_url = "https://thorstenballzed@bitbucket.company.com/zed-industries/zed.git";
|
||||
|
||||
let parsed_remote = Bitbucket::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bitbucket_permalink() {
|
||||
let permalink = Bitbucket::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
|
||||
);
|
||||
|
||||
let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bitbucket_self_hosted_permalink() {
|
||||
let permalink =
|
||||
Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git")
|
||||
.unwrap()
|
||||
.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), None),
|
||||
);
|
||||
|
||||
let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bitbucket_permalink_with_single_line_selection() {
|
||||
let permalink = Bitbucket::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
|
||||
);
|
||||
|
||||
let expected_url = "https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bitbucket_self_hosted_permalink_with_single_line_selection() {
|
||||
let permalink =
|
||||
Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git")
|
||||
.unwrap()
|
||||
.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(6..6)),
|
||||
);
|
||||
|
||||
let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bitbucket_permalink_with_multi_line_selection() {
|
||||
let permalink = Bitbucket::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
|
||||
);
|
||||
|
||||
let expected_url =
|
||||
"https://bitbucket.org/zed-industries/zed/src/f00b4r/main.rs#lines-24:48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_bitbucket_self_hosted_permalink_with_multi_line_selection() {
|
||||
let permalink =
|
||||
Bitbucket::from_remote_url("git@bitbucket.company.com:zed-industries/zed.git")
|
||||
.unwrap()
|
||||
.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new("f00b4r", &repo_path("main.rs"), Some(23..47)),
|
||||
);
|
||||
|
||||
let expected_url = "https://bitbucket.company.com/projects/zed-industries/repos/zed/browse/main.rs?at=f00b4r#24-48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitbucket_pull_requests() {
|
||||
use indoc::indoc;
|
||||
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let bitbucket = Bitbucket::public_instance();
|
||||
|
||||
// Test message without PR reference
|
||||
let message = "This does not contain a pull request";
|
||||
assert!(bitbucket.extract_pull_request(&remote, message).is_none());
|
||||
|
||||
// Pull request number at end of first line
|
||||
let message = indoc! {r#"
|
||||
Merged in feature-branch (pull request #123)
|
||||
|
||||
Some detailed description of the changes.
|
||||
"#};
|
||||
|
||||
let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
|
||||
assert_eq!(pr.number, 123);
|
||||
assert_eq!(
|
||||
pr.url.as_str(),
|
||||
"https://bitbucket.org/zed-industries/zed/pull-requests/123"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bitbucket_self_hosted_pull_requests() {
|
||||
use indoc::indoc;
|
||||
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let bitbucket =
|
||||
Bitbucket::from_remote_url("https://bitbucket.company.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
// Test message without PR reference
|
||||
let message = "This does not contain a pull request";
|
||||
assert!(bitbucket.extract_pull_request(&remote, message).is_none());
|
||||
|
||||
// Pull request number at end of first line
|
||||
let message = indoc! {r#"
|
||||
Merged in feature-branch (pull request #123)
|
||||
|
||||
Some detailed description of the changes.
|
||||
"#};
|
||||
|
||||
let pr = bitbucket.extract_pull_request(&remote, message).unwrap();
|
||||
assert_eq!(pr.number, 123);
|
||||
assert_eq!(
|
||||
pr.url.as_str(),
|
||||
"https://bitbucket.company.com/projects/zed-industries/repos/zed/pull-requests/123"
|
||||
);
|
||||
}
|
||||
}
|
||||
304
crates/git_hosting_providers/src/providers/chromium.rs
Normal file
304
crates/git_hosting_providers/src/providers/chromium.rs
Normal file
@@ -0,0 +1,304 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use futures::AsyncReadExt;
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
PullRequest, RemoteUrl,
|
||||
};
|
||||
use gpui::SharedString;
|
||||
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
const CHROMIUM_REVIEW_URL: &str = "https://chromium-review.googlesource.com";
|
||||
|
||||
/// Parses Gerrit URLs like
|
||||
/// https://chromium-review.googlesource.com/c/chromium/src/+/3310961.
|
||||
fn pull_request_regex() -> &'static Regex {
|
||||
static PULL_REQUEST_NUMBER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(&format!(
|
||||
r#"Reviewed-on: ({CHROMIUM_REVIEW_URL}/c/(.*)/\+/(\d+))"#
|
||||
))
|
||||
.unwrap()
|
||||
});
|
||||
&PULL_REQUEST_NUMBER_REGEX
|
||||
}
|
||||
|
||||
/// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChangeInfo {
|
||||
owner: AccountInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AccountInfo {
|
||||
#[serde(rename = "_account_id")]
|
||||
id: u64,
|
||||
}
|
||||
|
||||
pub struct Chromium;
|
||||
|
||||
impl Chromium {
|
||||
async fn fetch_chromium_commit_author(
|
||||
&self,
|
||||
_repo: &str,
|
||||
commit: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<Option<AccountInfo>> {
|
||||
let url = format!("{CHROMIUM_REVIEW_URL}/changes/{commit}");
|
||||
|
||||
let request = Request::get(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.follow_redirects(http_client::RedirectPolicy::FollowAll);
|
||||
|
||||
let mut response = client
|
||||
.send(request.body(AsyncBody::default())?)
|
||||
.await
|
||||
.with_context(|| format!("error fetching Gerrit commit details at {:?}", url))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
// Remove XSSI protection prefix.
|
||||
let body_str = std::str::from_utf8(&body)?.trim_start_matches(")]}'");
|
||||
|
||||
serde_json::from_str::<ChangeInfo>(body_str)
|
||||
.map(|change| Some(change.owner))
|
||||
.context("failed to deserialize Gerrit change info")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GitHostingProvider for Chromium {
|
||||
fn name(&self) -> String {
|
||||
"Chromium".to_string()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
Url::parse("https://chromium.googlesource.com").unwrap()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn format_line_number(&self, line: u32) -> String {
|
||||
format!("{line}")
|
||||
}
|
||||
|
||||
fn format_line_numbers(&self, start_line: u32, _end_line: u32) -> String {
|
||||
format!("{start_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let host = url.host_str()?;
|
||||
if host != self.base_url().host_str()? {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path_segments = url.path_segments()?.collect::<Vec<_>>();
|
||||
let joined_path = path_segments.join("/");
|
||||
let repo = joined_path.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: Arc::from(""),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
params: BuildCommitPermalinkParams,
|
||||
) -> Url {
|
||||
let BuildCommitPermalinkParams { sha } = params;
|
||||
let ParsedGitRemote { owner: _, repo } = remote;
|
||||
|
||||
self.base_url().join(&format!("{repo}/+/{sha}")).unwrap()
|
||||
}
|
||||
|
||||
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
|
||||
let ParsedGitRemote { owner: _, repo } = remote;
|
||||
let BuildPermalinkParams {
|
||||
sha,
|
||||
path,
|
||||
selection,
|
||||
} = params;
|
||||
|
||||
let mut permalink = self
|
||||
.base_url()
|
||||
.join(&format!("{repo}/+/{sha}/{path}"))
|
||||
.unwrap();
|
||||
permalink.set_fragment(
|
||||
selection
|
||||
.map(|selection| self.line_fragment(&selection))
|
||||
.as_deref(),
|
||||
);
|
||||
permalink
|
||||
}
|
||||
|
||||
fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
|
||||
let capture = pull_request_regex().captures(message)?;
|
||||
let url = Url::parse(capture.get(1)?.as_str()).unwrap();
|
||||
let repo = capture.get(2)?.as_str();
|
||||
if repo != remote.repo.as_ref() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let number = capture.get(3)?.as_str().parse::<u32>().ok()?;
|
||||
|
||||
Some(PullRequest { number, url })
|
||||
}
|
||||
|
||||
async fn commit_author_avatar_url(
|
||||
&self,
|
||||
_repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: SharedString,
|
||||
_author_email: Option<SharedString>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<Url>> {
|
||||
let commit = commit.to_string();
|
||||
let Some(author) = self
|
||||
.fetch_chromium_commit_author(repo, &commit, &http_client)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let mut avatar_url = Url::parse(&format!(
|
||||
"{CHROMIUM_REVIEW_URL}/accounts/{}/avatar",
|
||||
&author.id
|
||||
))?;
|
||||
avatar_url.set_query(Some("size=128"));
|
||||
|
||||
Ok(Some(avatar_url))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use git::repository::repo_path;
|
||||
use indoc::indoc;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Chromium
|
||||
.parse_remote_url("https://chromium.googlesource.com/chromium/src")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: Arc::from(""),
|
||||
repo: "chromium/src".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chromium_permalink() {
|
||||
let permalink = Chromium.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: Arc::from(""),
|
||||
repo: "chromium/src".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"fea5080b182fc92e3be0c01c5dece602fe70b588",
|
||||
&repo_path("ui/base/cursor/cursor.h"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chromium_permalink_with_single_line_selection() {
|
||||
let permalink = Chromium.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: Arc::from(""),
|
||||
repo: "chromium/src".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"fea5080b182fc92e3be0c01c5dece602fe70b588",
|
||||
&repo_path("ui/base/cursor/cursor.h"),
|
||||
Some(18..18),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chromium_permalink_with_multi_line_selection() {
|
||||
let permalink = Chromium.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: Arc::from(""),
|
||||
repo: "chromium/src".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"fea5080b182fc92e3be0c01c5dece602fe70b588",
|
||||
&repo_path("ui/base/cursor/cursor.h"),
|
||||
Some(18..30),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://chromium.googlesource.com/chromium/src/+/fea5080b182fc92e3be0c01c5dece602fe70b588/ui/base/cursor/cursor.h#19";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chromium_pull_requests() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: Arc::from(""),
|
||||
repo: "chromium/src".into(),
|
||||
};
|
||||
|
||||
let message = "This does not contain a pull request";
|
||||
assert!(Chromium.extract_pull_request(&remote, message).is_none());
|
||||
|
||||
// Pull request number at end of "Reviewed-on:" line
|
||||
let message = indoc! {r#"
|
||||
Test commit header
|
||||
|
||||
Test commit description with multiple
|
||||
lines.
|
||||
|
||||
Bug: 1193775, 1270302
|
||||
Change-Id: Id15e9b4d75cce43ebd5fe34f0fb37d5e1e811b66
|
||||
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3310961
|
||||
Reviewed-by: Test reviewer <test@example.com>
|
||||
Cr-Commit-Position: refs/heads/main@{#1054973}
|
||||
"#
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
Chromium
|
||||
.extract_pull_request(&remote, message)
|
||||
.unwrap()
|
||||
.url
|
||||
.as_str(),
|
||||
"https://chromium-review.googlesource.com/c/chromium/src/+/3310961"
|
||||
);
|
||||
}
|
||||
}
|
||||
434
crates/git_hosting_providers/src/providers/forgejo.rs
Normal file
434
crates/git_hosting_providers/src/providers/forgejo.rs
Normal file
@@ -0,0 +1,434 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::SharedString;
|
||||
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
RemoteUrl,
|
||||
};
|
||||
|
||||
use crate::get_host_from_git_remote_url;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommitDetails {
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
commit: Commit,
|
||||
author: Option<User>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Commit {
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
author: Author,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Author {
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
name: String,
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
email: String,
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
date: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct User {
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
pub login: String,
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
pub id: u64,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
pub struct Forgejo {
|
||||
name: String,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
impl Forgejo {
|
||||
pub fn new(name: impl Into<String>, base_url: Url) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_instance() -> Self {
|
||||
Self::new("Codeberg", Url::parse("https://codeberg.org").unwrap())
|
||||
}
|
||||
|
||||
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
|
||||
let host = get_host_from_git_remote_url(remote_url)?;
|
||||
if host == "codeberg.org" {
|
||||
bail!("the Forgejo instance is not self-hosted");
|
||||
}
|
||||
|
||||
// TODO: detecting self hosted instances by checking whether "forgejo" is in the url or not
|
||||
// is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
|
||||
// information.
|
||||
if !host.contains("forgejo") {
|
||||
bail!("not a Forgejo URL");
|
||||
}
|
||||
|
||||
Ok(Self::new(
|
||||
"Forgejo Self-Hosted",
|
||||
Url::parse(&format!("https://{}", host))?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_forgejo_commit_author(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<Option<User>> {
|
||||
let Some(host) = self.base_url.host_str() else {
|
||||
bail!("failed to get host from forgejo base url");
|
||||
};
|
||||
let url = format!(
|
||||
"https://{host}/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}?stat=false&verification=false&files=false"
|
||||
);
|
||||
|
||||
let mut request = Request::get(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.follow_redirects(http_client::RedirectPolicy::FollowAll);
|
||||
|
||||
// TODO: not renamed yet for compatibility reasons, may require a refactor later
|
||||
// see https://github.com/zed-industries/zed/issues/11043#issuecomment-3480446231
|
||||
if host == "codeberg.org"
|
||||
&& let Ok(codeberg_token) = std::env::var("CODEBERG_TOKEN")
|
||||
{
|
||||
request = request.header("Authorization", format!("Bearer {}", codeberg_token));
|
||||
}
|
||||
|
||||
let mut response = client
|
||||
.send(request.body(AsyncBody::default())?)
|
||||
.await
|
||||
.with_context(|| format!("error fetching Forgejo commit details at {:?}", url))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let body_str = std::str::from_utf8(&body)?;
|
||||
|
||||
serde_json::from_str::<CommitDetails>(body_str)
|
||||
.map(|commit| commit.author)
|
||||
.context("failed to deserialize Forgejo commit details")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GitHostingProvider for Forgejo {
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
self.base_url.clone()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn format_line_number(&self, line: u32) -> String {
|
||||
format!("L{line}")
|
||||
}
|
||||
|
||||
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
|
||||
format!("L{start_line}-L{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let host = url.host_str()?;
|
||||
if host != self.base_url.host_str()? {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let owner = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
params: BuildCommitPermalinkParams,
|
||||
) -> Url {
|
||||
let BuildCommitPermalinkParams { sha } = params;
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
|
||||
self.base_url()
|
||||
.join(&format!("{owner}/{repo}/commit/{sha}"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let BuildPermalinkParams {
|
||||
sha,
|
||||
path,
|
||||
selection,
|
||||
} = params;
|
||||
|
||||
let mut permalink = self
|
||||
.base_url()
|
||||
.join(&format!("{owner}/{repo}/src/commit/{sha}/{path}"))
|
||||
.unwrap();
|
||||
permalink.set_fragment(
|
||||
selection
|
||||
.map(|selection| self.line_fragment(&selection))
|
||||
.as_deref(),
|
||||
);
|
||||
permalink
|
||||
}
|
||||
|
||||
async fn commit_author_avatar_url(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: SharedString,
|
||||
_author_email: Option<SharedString>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<Url>> {
|
||||
let commit = commit.to_string();
|
||||
let avatar_url = self
|
||||
.fetch_forgejo_commit_author(repo_owner, repo, &commit, &http_client)
|
||||
.await?
|
||||
.map(|author| -> Result<Url, url::ParseError> {
|
||||
let mut url = Url::parse(&author.avatar_url)?;
|
||||
if let Some(host) = url.host_str() {
|
||||
let size_query = if host.contains("gravatar") || host.contains("libravatar") {
|
||||
Some("s=128")
|
||||
} else if self
|
||||
.base_url
|
||||
.host_str()
|
||||
.is_some_and(|base_host| host.contains(base_host))
|
||||
{
|
||||
// This parameter exists on Codeberg but does not seem to take effect. setting it anyway
|
||||
Some("size=128")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
url.set_query(size_query);
|
||||
}
|
||||
Ok(url)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(avatar_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use git::repository::repo_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Forgejo::public_instance()
|
||||
.parse_remote_url("git@codeberg.org:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Forgejo::public_instance()
|
||||
.parse_remote_url("https://codeberg.org/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_ssh_url() {
|
||||
let remote_url = "git@forgejo.my-enterprise.com:zed-industries/zed.git";
|
||||
|
||||
let parsed_remote = Forgejo::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_https_url() {
|
||||
let remote_url = "https://forgejo.my-enterprise.com/zed-industries/zed.git";
|
||||
let parsed_remote = Forgejo::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink() {
|
||||
let permalink = Forgejo::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_with_single_line_selection() {
|
||||
let permalink = Forgejo::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(6..6),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_with_multi_line_selection() {
|
||||
let permalink = Forgejo::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(23..47),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://codeberg.org/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_forgejo_self_hosted_permalink_from_ssh_url() {
|
||||
let forgejo =
|
||||
Forgejo::from_remote_url("git@forgejo.some-enterprise.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
let permalink = forgejo.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://forgejo.some-enterprise.com/zed-industries/zed/src/commit/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_forgejo_self_hosted_permalink_from_https_url() {
|
||||
let forgejo =
|
||||
Forgejo::from_remote_url("https://forgejo-instance.big-co.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
let permalink = forgejo.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
&repo_path("crates/zed/src/main.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://forgejo-instance.big-co.com/zed-industries/zed/src/commit/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
}
|
||||
381
crates/git_hosting_providers/src/providers/gitea.rs
Normal file
381
crates/git_hosting_providers/src/providers/gitea.rs
Normal file
@@ -0,0 +1,381 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::SharedString;
|
||||
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
RemoteUrl,
|
||||
};
|
||||
|
||||
use crate::get_host_from_git_remote_url;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommitDetails {
|
||||
author: Option<User>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct User {
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
pub struct Gitea {
|
||||
name: String,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
impl Gitea {
|
||||
pub fn new(name: impl Into<String>, base_url: Url) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_instance() -> Self {
|
||||
Self::new("Gitea", Url::parse("https://gitea.com").unwrap())
|
||||
}
|
||||
|
||||
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
|
||||
let host = get_host_from_git_remote_url(remote_url)?;
|
||||
if host == "gitea.com" {
|
||||
bail!("the Gitea instance is not self-hosted");
|
||||
}
|
||||
|
||||
// TODO: detecting self hosted instances by checking whether "gitea" is in the url or not
|
||||
// is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
|
||||
// information.
|
||||
if !host.contains("gitea") {
|
||||
bail!("not a Gitea URL");
|
||||
}
|
||||
|
||||
Ok(Self::new(
|
||||
"Gitea Self-Hosted",
|
||||
Url::parse(&format!("https://{}", host))?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_gitea_commit_author(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<Option<User>> {
|
||||
let Some(host) = self.base_url.host_str() else {
|
||||
bail!("failed to get host from gitea base url");
|
||||
};
|
||||
let url = format!(
|
||||
"https://{host}/api/v1/repos/{repo_owner}/{repo}/git/commits/{commit}?stat=false&verification=false&files=false"
|
||||
);
|
||||
|
||||
let request = Request::get(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.follow_redirects(http_client::RedirectPolicy::FollowAll);
|
||||
|
||||
let mut response = client
|
||||
.send(request.body(AsyncBody::default())?)
|
||||
.await
|
||||
.with_context(|| format!("error fetching Gitea commit details at {:?}", url))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let body_str = std::str::from_utf8(&body)?;
|
||||
|
||||
serde_json::from_str::<CommitDetails>(body_str)
|
||||
.map(|commit| commit.author)
|
||||
.context("failed to deserialize Gitea commit details")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GitHostingProvider for Gitea {
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
self.base_url.clone()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn format_line_number(&self, line: u32) -> String {
|
||||
format!("L{line}")
|
||||
}
|
||||
|
||||
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
|
||||
format!("L{start_line}-L{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let host = url.host_str()?;
|
||||
if host != self.base_url.host_str()? {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let owner = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
params: BuildCommitPermalinkParams,
|
||||
) -> Url {
|
||||
let BuildCommitPermalinkParams { sha } = params;
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
|
||||
self.base_url()
|
||||
.join(&format!("{owner}/{repo}/commit/{sha}"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let BuildPermalinkParams {
|
||||
sha,
|
||||
path,
|
||||
selection,
|
||||
} = params;
|
||||
|
||||
let mut permalink = self
|
||||
.base_url()
|
||||
.join(&format!("{owner}/{repo}/src/commit/{sha}/{path}"))
|
||||
.unwrap();
|
||||
permalink.set_fragment(
|
||||
selection
|
||||
.map(|selection| self.line_fragment(&selection))
|
||||
.as_deref(),
|
||||
);
|
||||
permalink
|
||||
}
|
||||
|
||||
async fn commit_author_avatar_url(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: SharedString,
|
||||
_author_email: Option<SharedString>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<Url>> {
|
||||
let commit = commit.to_string();
|
||||
let avatar_url = self
|
||||
.fetch_gitea_commit_author(repo_owner, repo, &commit, &http_client)
|
||||
.await?
|
||||
.map(|author| -> Result<Url, url::ParseError> {
|
||||
let mut url = Url::parse(&author.avatar_url)?;
|
||||
if let Some(host) = url.host_str() {
|
||||
let size_query = if host.contains("gravatar") || host.contains("libravatar") {
|
||||
Some("s=128")
|
||||
} else if self
|
||||
.base_url
|
||||
.host_str()
|
||||
.is_some_and(|base_host| host.contains(base_host))
|
||||
{
|
||||
Some("size=128")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
url.set_query(size_query);
|
||||
}
|
||||
Ok(url)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(avatar_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use git::repository::repo_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Gitea::public_instance()
|
||||
.parse_remote_url("git@gitea.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Gitea::public_instance()
|
||||
.parse_remote_url("https://gitea.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_ssh_url() {
|
||||
let remote_url = "git@gitea.my-enterprise.com:zed-industries/zed.git";
|
||||
|
||||
let parsed_remote = Gitea::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_https_url() {
|
||||
let remote_url = "https://gitea.my-enterprise.com/zed-industries/zed.git";
|
||||
let parsed_remote = Gitea::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink() {
|
||||
let permalink = Gitea::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitea.com/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_with_single_line_selection() {
|
||||
let permalink = Gitea::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(6..6),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitea.com/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_codeberg_permalink_with_multi_line_selection() {
|
||||
let permalink = Gitea::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(23..47),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitea.com/zed-industries/zed/src/commit/faa6f979be417239b2e070dbbf6392b909224e0b/crates/editor/src/git/permalink.rs#L24-L48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitea_self_hosted_permalink_from_ssh_url() {
|
||||
let gitea =
|
||||
Gitea::from_remote_url("git@gitea.some-enterprise.com:zed-industries/zed.git").unwrap();
|
||||
let permalink = gitea.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitea.some-enterprise.com/zed-industries/zed/src/commit/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitea_self_hosted_permalink_from_https_url() {
|
||||
let gitea =
|
||||
Gitea::from_remote_url("https://gitea-instance.big-co.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
let permalink = gitea.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
&repo_path("crates/zed/src/main.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitea-instance.big-co.com/zed-industries/zed/src/commit/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
}
|
||||
251
crates/git_hosting_providers/src/providers/gitee.rs
Normal file
251
crates/git_hosting_providers/src/providers/gitee.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
use std::{str::FromStr, sync::Arc};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::SharedString;
|
||||
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
RemoteUrl,
|
||||
};
|
||||
|
||||
pub struct Gitee;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommitDetails {
|
||||
author: Option<Author>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Author {
|
||||
avatar_url: String,
|
||||
}
|
||||
|
||||
impl Gitee {
|
||||
async fn fetch_gitee_commit_author(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<Option<Author>> {
|
||||
let url = format!("https://gitee.com/api/v5/repos/{repo_owner}/{repo}/commits/{commit}");
|
||||
|
||||
let request = Request::get(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.follow_redirects(http_client::RedirectPolicy::FollowAll);
|
||||
|
||||
let mut response = client
|
||||
.send(request.body(AsyncBody::default())?)
|
||||
.await
|
||||
.with_context(|| format!("error fetching Gitee commit details at {:?}", url))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let body_str = std::str::from_utf8(&body)?;
|
||||
|
||||
serde_json::from_str::<CommitDetails>(body_str)
|
||||
.map(|commit| commit.author)
|
||||
.context("failed to deserialize Gitee commit details")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GitHostingProvider for Gitee {
|
||||
fn name(&self) -> String {
|
||||
"Gitee".to_string()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
Url::parse("https://gitee.com").unwrap()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn format_line_number(&self, line: u32) -> String {
|
||||
format!("L{line}")
|
||||
}
|
||||
|
||||
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
|
||||
format!("L{start_line}-{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let host = url.host_str()?;
|
||||
if host != "gitee.com" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let owner = path_segments.next()?;
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
params: BuildCommitPermalinkParams,
|
||||
) -> Url {
|
||||
let BuildCommitPermalinkParams { sha } = params;
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
|
||||
self.base_url()
|
||||
.join(&format!("{owner}/{repo}/commit/{sha}"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let BuildPermalinkParams {
|
||||
sha,
|
||||
path,
|
||||
selection,
|
||||
} = params;
|
||||
|
||||
let mut permalink = self
|
||||
.base_url()
|
||||
.join(&format!("{owner}/{repo}/blob/{sha}/{path}"))
|
||||
.unwrap();
|
||||
permalink.set_fragment(
|
||||
selection
|
||||
.map(|selection| self.line_fragment(&selection))
|
||||
.as_deref(),
|
||||
);
|
||||
permalink
|
||||
}
|
||||
|
||||
async fn commit_author_avatar_url(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: SharedString,
|
||||
_author_email: Option<SharedString>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<Url>> {
|
||||
let commit = commit.to_string();
|
||||
let avatar_url = self
|
||||
.fetch_gitee_commit_author(repo_owner, repo, &commit, &http_client)
|
||||
.await?
|
||||
.map(|author| -> Result<Url, url::ParseError> {
|
||||
let mut url = Url::parse(&author.avatar_url)?;
|
||||
url.set_query(Some("width=128"));
|
||||
Ok(url)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(avatar_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use git::repository::repo_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Gitee
|
||||
.parse_remote_url("git@gitee.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Gitee
|
||||
.parse_remote_url("https://gitee.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink() {
|
||||
let permalink = Gitee.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink_with_single_line_selection() {
|
||||
let permalink = Gitee.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(6..6),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitee_permalink_with_multi_line_selection() {
|
||||
let permalink = Gitee.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e5fe811d7ad0fc26934edd76f891d20bdc3bb194",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(23..47),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitee.com/zed-industries/zed/blob/e5fe811d7ad0fc26934edd76f891d20bdc3bb194/crates/editor/src/git/permalink.rs#L24-48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
}
|
||||
669
crates/git_hosting_providers/src/providers/github.rs
Normal file
669
crates/git_hosting_providers/src/providers/github.rs
Normal file
@@ -0,0 +1,669 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::SharedString;
|
||||
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
use urlencoding::encode;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
PullRequest, RemoteUrl,
|
||||
};
|
||||
|
||||
use crate::get_host_from_git_remote_url;
|
||||
|
||||
fn pull_request_number_regex() -> &'static Regex {
|
||||
static PULL_REQUEST_NUMBER_REGEX: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\(#(\d+)\)$").unwrap());
|
||||
&PULL_REQUEST_NUMBER_REGEX
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommitDetails {
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
commit: Commit,
|
||||
author: Option<User>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Commit {
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
author: Author,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Author {
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct User {
|
||||
#[expect(
|
||||
unused,
|
||||
reason = "This field was found to be unused with serde library bump; it's left as is due to insufficient context on PO's side, but it *may* be fine to remove"
|
||||
)]
|
||||
pub id: u64,
|
||||
pub avatar_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Github {
|
||||
name: String,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
fn normalize_author_email(email: &str) -> &str {
|
||||
email.trim_start_matches('<').trim_end_matches('>')
|
||||
}
|
||||
|
||||
fn build_cdn_avatar_url(email: &str) -> Result<Url> {
|
||||
let email = normalize_author_email(email);
|
||||
Url::parse(&format!(
|
||||
"https://avatars.githubusercontent.com/u/e?email={}&s=128",
|
||||
encode(email)
|
||||
))
|
||||
.context("failed to construct avatar URL")
|
||||
}
|
||||
|
||||
fn build_cdn_avatar_url_for_author_email(email: &str) -> Result<Option<Url>> {
|
||||
let email = normalize_author_email(email);
|
||||
if email.ends_with("[bot]@users.noreply.github.com") {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
build_cdn_avatar_url(email).map(Some)
|
||||
}
|
||||
|
||||
impl Github {
|
||||
pub fn new(name: impl Into<String>, base_url: Url) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_instance() -> Self {
|
||||
Self::new("GitHub", Url::parse("https://github.com").unwrap())
|
||||
}
|
||||
|
||||
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
|
||||
let host = get_host_from_git_remote_url(remote_url)?;
|
||||
if host == "github.com" {
|
||||
bail!("the GitHub instance is not self-hosted");
|
||||
}
|
||||
|
||||
// TODO: detecting self hosted instances by checking whether "github" is in the url or not
|
||||
// is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
|
||||
// information.
|
||||
if !host.contains("github") {
|
||||
bail!("not a GitHub URL");
|
||||
}
|
||||
|
||||
Ok(Self::new(
|
||||
"GitHub Self-Hosted",
|
||||
Url::parse(&format!("https://{}", host))?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_github_commit_author(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<Option<User>> {
|
||||
let Some(host) = self.base_url.host_str() else {
|
||||
bail!("failed to get host from github base url");
|
||||
};
|
||||
let url = format!("https://api.{host}/repos/{repo_owner}/{repo}/commits/{commit}");
|
||||
|
||||
let mut request = Request::get(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.follow_redirects(http_client::RedirectPolicy::FollowAll);
|
||||
|
||||
if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
|
||||
request = request.header("Authorization", format!("Bearer {}", github_token));
|
||||
}
|
||||
|
||||
let mut response = client
|
||||
.send(request.body(AsyncBody::default())?)
|
||||
.await
|
||||
.with_context(|| format!("error fetching GitHub commit details at {:?}", url))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let body_str = std::str::from_utf8(&body)?;
|
||||
|
||||
serde_json::from_str::<CommitDetails>(body_str)
|
||||
.map(|commit| commit.author)
|
||||
.context("failed to deserialize GitHub commit details")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GitHostingProvider for Github {
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
self.base_url.clone()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
// Avatars are not supported for self-hosted GitHub instances
|
||||
// See tracking issue: https://github.com/zed-industries/zed/issues/11043
|
||||
&self.name == "GitHub"
|
||||
}
|
||||
|
||||
fn format_line_number(&self, line: u32) -> String {
|
||||
format!("L{line}")
|
||||
}
|
||||
|
||||
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
|
||||
format!("L{start_line}-L{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let host = url.host_str()?;
|
||||
if host != self.base_url.host_str()? {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let mut owner = path_segments.next()?;
|
||||
if owner.is_empty() {
|
||||
owner = path_segments.next()?;
|
||||
}
|
||||
|
||||
let repo = path_segments.next()?.trim_end_matches(".git");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
params: BuildCommitPermalinkParams,
|
||||
) -> Url {
|
||||
let BuildCommitPermalinkParams { sha } = params;
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
|
||||
self.base_url()
|
||||
.join(&format!("{owner}/{repo}/commit/{sha}"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let BuildPermalinkParams {
|
||||
sha,
|
||||
path,
|
||||
selection,
|
||||
} = params;
|
||||
|
||||
let mut permalink = self
|
||||
.base_url()
|
||||
.join(&format!("{owner}/{repo}/blob/{sha}/{path}"))
|
||||
.unwrap();
|
||||
if path.ends_with(".md") {
|
||||
permalink.set_query(Some("plain=1"));
|
||||
}
|
||||
permalink.set_fragment(
|
||||
selection
|
||||
.map(|selection| self.line_fragment(&selection))
|
||||
.as_deref(),
|
||||
);
|
||||
permalink
|
||||
}
|
||||
|
||||
fn build_create_pull_request_url(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
source_branch: &str,
|
||||
) -> Option<Url> {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let encoded_source = encode(source_branch);
|
||||
|
||||
self.base_url()
|
||||
.join(&format!("{owner}/{repo}/pull/new/{encoded_source}"))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
|
||||
let line = message.lines().next()?;
|
||||
let capture = pull_request_number_regex().captures(line)?;
|
||||
let number = capture.get(1)?.as_str().parse::<u32>().ok()?;
|
||||
|
||||
let mut url = self.base_url();
|
||||
let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
|
||||
url.set_path(&path);
|
||||
|
||||
Some(PullRequest { number, url })
|
||||
}
|
||||
|
||||
async fn commit_author_avatar_url(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: SharedString,
|
||||
author_email: Option<SharedString>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<Url>> {
|
||||
if let Some(email) = author_email
|
||||
&& let Some(avatar_url) = build_cdn_avatar_url_for_author_email(&email)?
|
||||
{
|
||||
return Ok(Some(avatar_url));
|
||||
}
|
||||
|
||||
let commit = commit.to_string();
|
||||
let avatar_url = self
|
||||
.fetch_github_commit_author(repo_owner, repo, &commit, &http_client)
|
||||
.await?
|
||||
.map(|author| -> Result<Url, url::ParseError> {
|
||||
let mut url = Url::parse(&author.avatar_url)?;
|
||||
url.set_query(Some("size=128"));
|
||||
Ok(url)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(avatar_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use git::repository::repo_path;
|
||||
use indoc::indoc;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_remote_url_with_root_slash() {
|
||||
let remote_url = "git@github.com:/zed-industries/zed";
|
||||
let parsed_remote = Github::public_instance()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_self_hosted_remote_url() {
|
||||
let remote_url = "git@github.com:zed-industries/zed.git";
|
||||
let github = Github::from_remote_url(remote_url);
|
||||
assert!(github.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_remote_url_ssh() {
|
||||
let remote_url = "git@github.my-enterprise.com:zed-industries/zed.git";
|
||||
let github = Github::from_remote_url(remote_url).unwrap();
|
||||
|
||||
assert!(!github.supports_avatars());
|
||||
assert_eq!(github.name, "GitHub Self-Hosted".to_string());
|
||||
assert_eq!(
|
||||
github.base_url,
|
||||
Url::parse("https://github.my-enterprise.com").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_remote_url_https() {
|
||||
let remote_url = "https://github.my-enterprise.com/zed-industries/zed.git";
|
||||
let github = Github::from_remote_url(remote_url).unwrap();
|
||||
|
||||
assert!(!github.supports_avatars());
|
||||
assert_eq!(github.name, "GitHub Self-Hosted".to_string());
|
||||
assert_eq!(
|
||||
github.base_url,
|
||||
Url::parse("https://github.my-enterprise.com").unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_ssh_url() {
|
||||
let remote_url = "git@github.my-enterprise.com:zed-industries/zed.git";
|
||||
let parsed_remote = Github::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_https_url_with_subgroup() {
|
||||
let remote_url = "https://github.my-enterprise.com/zed-industries/zed.git";
|
||||
let parsed_remote = Github::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Github::public_instance()
|
||||
.parse_remote_url("git@github.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Github::public_instance()
|
||||
.parse_remote_url("https://github.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url_with_username() {
|
||||
let parsed_remote = Github::public_instance()
|
||||
.parse_remote_url("https://jlannister@github.com/some-org/some-repo.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "some-org".into(),
|
||||
repo: "some-repo".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink_from_ssh_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
let permalink = Github::public_instance().build_permalink(
|
||||
remote,
|
||||
BuildPermalinkParams::new(
|
||||
"e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink() {
|
||||
let permalink = Github::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
&repo_path("crates/zed/src/main.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink_with_single_line_selection() {
|
||||
let permalink = Github::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(6..6),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_permalink_with_multi_line_selection() {
|
||||
let permalink = Github::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(23..47),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_github_create_pr_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let provider = Github::public_instance();
|
||||
|
||||
let url = provider
|
||||
.build_create_pull_request_url(&remote, "feature/something cool")
|
||||
.expect("url should be constructed");
|
||||
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://github.com/zed-industries/zed/pull/new/feature%2Fsomething%20cool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_github_pull_requests() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let github = Github::public_instance();
|
||||
let message = "This does not contain a pull request";
|
||||
assert!(github.extract_pull_request(&remote, message).is_none());
|
||||
|
||||
// Pull request number at end of first line
|
||||
let message = indoc! {r#"
|
||||
project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
|
||||
|
||||
Fixes #10597
|
||||
|
||||
Release Notes:
|
||||
|
||||
- Fixed "project panel: collapse all entries" expanding collapsed worktrees.
|
||||
"#
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
github
|
||||
.extract_pull_request(&remote, message)
|
||||
.unwrap()
|
||||
.url
|
||||
.as_str(),
|
||||
"https://github.com/zed-industries/zed/pull/10687"
|
||||
);
|
||||
|
||||
// Pull request number in middle of line, which we want to ignore
|
||||
let message = indoc! {r#"
|
||||
Follow-up to #10687 to fix problems
|
||||
|
||||
See the original PR, this is a fix.
|
||||
"#
|
||||
};
|
||||
assert_eq!(github.extract_pull_request(&remote, message), None);
|
||||
}
|
||||
|
||||
/// Regression test for issue #39875
|
||||
#[test]
|
||||
fn test_git_permalink_url_escaping() {
|
||||
let permalink = Github::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "nonexistent".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"3ef1539900037dd3601be7149b2b39ed6d0ce3db",
|
||||
&repo_path("app/blog/[slug]/page.tsx"),
|
||||
Some(7..7),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://github.com/zed-industries/nonexistent/blob/3ef1539900037dd3601be7149b2b39ed6d0ce3db/app/blog/%5Bslug%5D/page.tsx#L8";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_create_pull_request_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let github = Github::public_instance();
|
||||
let url = github
|
||||
.build_create_pull_request_url(&remote, "feature/new-feature")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://github.com/zed-industries/zed/pull/new/feature%2Fnew-feature"
|
||||
);
|
||||
|
||||
let base_url = Url::parse("https://github.zed.com").unwrap();
|
||||
let github = Github::new("GitHub Self-Hosted", base_url);
|
||||
let url = github
|
||||
.build_create_pull_request_url(&remote, "feature/new-feature")
|
||||
.expect("should be able to build pull request url");
|
||||
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://github.zed.com/zed-industries/zed/pull/new/feature%2Fnew-feature"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_cdn_avatar_url_simple_email() {
|
||||
let url = build_cdn_avatar_url("user@example.com").unwrap();
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://avatars.githubusercontent.com/u/e?email=user%40example.com&s=128"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_cdn_avatar_url_with_angle_brackets() {
|
||||
let url = build_cdn_avatar_url("<user@example.com>").unwrap();
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://avatars.githubusercontent.com/u/e?email=user%40example.com&s=128"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_cdn_avatar_url_with_special_chars() {
|
||||
let url = build_cdn_avatar_url("user+tag@example.com").unwrap();
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://avatars.githubusercontent.com/u/e?email=user%2Btag%40example.com&s=128"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_cdn_avatar_url_for_author_email_skips_bot_noreply_emails() {
|
||||
for email in [
|
||||
"41898282+github-actions[bot]@users.noreply.github.com",
|
||||
"<41898282+github-actions[bot]@users.noreply.github.com>",
|
||||
] {
|
||||
assert_eq!(build_cdn_avatar_url_for_author_email(email).unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_cdn_avatar_url_for_author_email_uses_user_noreply_emails() {
|
||||
let url = build_cdn_avatar_url_for_author_email("12345+octocat@users.noreply.github.com")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://avatars.githubusercontent.com/u/e?email=12345%2Boctocat%40users.noreply.github.com&s=128"
|
||||
);
|
||||
}
|
||||
}
|
||||
599
crates/git_hosting_providers/src/providers/gitlab.rs
Normal file
599
crates/git_hosting_providers/src/providers/gitlab.rs
Normal file
@@ -0,0 +1,599 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::{Context as _, Result, bail};
|
||||
use async_trait::async_trait;
|
||||
use futures::AsyncReadExt;
|
||||
use gpui::SharedString;
|
||||
use http_client::{AsyncBody, HttpClient, HttpRequestExt, Request};
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
use urlencoding::encode;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
PullRequest, RemoteUrl,
|
||||
};
|
||||
|
||||
fn merge_request_number_regex() -> &'static Regex {
|
||||
static MERGE_REQUEST_NUMBER_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||
// Matches GitLab MR references:
|
||||
// - "(!123)" at the end of line (squash merge pattern)
|
||||
// - "See merge request group/project!123" (standard merge commit)
|
||||
Regex::new(r"(?:\(!(\d+)\)$|See merge request [^\s]+!(\d+))").unwrap()
|
||||
});
|
||||
&MERGE_REQUEST_NUMBER_REGEX
|
||||
}
|
||||
|
||||
use crate::get_host_from_git_remote_url;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CommitDetails {
|
||||
author_email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AvatarInfo {
|
||||
avatar_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Gitlab {
|
||||
name: String,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
impl Gitlab {
|
||||
pub fn new(name: impl Into<String>, base_url: Url) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_instance() -> Self {
|
||||
Self::new("GitLab", Url::parse("https://gitlab.com").unwrap())
|
||||
}
|
||||
|
||||
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
|
||||
let host = get_host_from_git_remote_url(remote_url)?;
|
||||
if host == "gitlab.com" {
|
||||
bail!("the GitLab instance is not self-hosted");
|
||||
}
|
||||
|
||||
// TODO: detecting self hosted instances by checking whether "gitlab" is in the url or not
|
||||
// is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
|
||||
// information.
|
||||
if !host.contains("gitlab") {
|
||||
bail!("not a GitLab URL");
|
||||
}
|
||||
|
||||
Ok(Self::new(
|
||||
"GitLab Self-Hosted",
|
||||
Url::parse(&format!("https://{}", host))?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn fetch_gitlab_commit_author(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: &str,
|
||||
client: &Arc<dyn HttpClient>,
|
||||
) -> Result<Option<AvatarInfo>> {
|
||||
let Some(host) = self.base_url.host_str() else {
|
||||
bail!("failed to get host from gitlab base url");
|
||||
};
|
||||
let project_path = format!("{}/{}", repo_owner, repo);
|
||||
let project_path_encoded = urlencoding::encode(&project_path);
|
||||
let url = format!(
|
||||
"https://{host}/api/v4/projects/{project_path_encoded}/repository/commits/{commit}"
|
||||
);
|
||||
|
||||
let request = Request::get(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.follow_redirects(http_client::RedirectPolicy::FollowAll);
|
||||
|
||||
let mut response = client
|
||||
.send(request.body(AsyncBody::default())?)
|
||||
.await
|
||||
.with_context(|| format!("error fetching GitLab commit details at {:?}", url))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let body_str = std::str::from_utf8(&body)?;
|
||||
|
||||
let author_email = serde_json::from_str::<CommitDetails>(body_str)
|
||||
.map(|commit| commit.author_email)
|
||||
.context("failed to deserialize GitLab commit details")?;
|
||||
|
||||
let avatar_info_url = format!("https://{host}/api/v4/avatar?email={author_email}");
|
||||
|
||||
let request = Request::get(&avatar_info_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.follow_redirects(http_client::RedirectPolicy::FollowAll);
|
||||
|
||||
let mut response = client
|
||||
.send(request.body(AsyncBody::default())?)
|
||||
.await
|
||||
.with_context(|| format!("error fetching GitLab avatar info at {:?}", url))?;
|
||||
|
||||
let mut body = Vec::new();
|
||||
response.body_mut().read_to_end(&mut body).await?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let text = String::from_utf8_lossy(body.as_slice());
|
||||
bail!(
|
||||
"status error {}, response: {text:?}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
|
||||
let body_str = std::str::from_utf8(&body)?;
|
||||
|
||||
serde_json::from_str::<Option<AvatarInfo>>(body_str)
|
||||
.context("failed to deserialize GitLab avatar info")
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GitHostingProvider for Gitlab {
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
self.base_url.clone()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn format_line_number(&self, line: u32) -> String {
|
||||
format!("L{line}")
|
||||
}
|
||||
|
||||
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
|
||||
format!("L{start_line}-{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let host = url.host_str()?;
|
||||
if host != self.base_url.host_str()? {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_segments = url.path_segments()?.collect::<Vec<_>>();
|
||||
let repo = path_segments.pop()?.trim_end_matches(".git");
|
||||
let owner = path_segments.join("/");
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
params: BuildCommitPermalinkParams,
|
||||
) -> Url {
|
||||
let BuildCommitPermalinkParams { sha } = params;
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
|
||||
self.base_url()
|
||||
.join(&format!("{owner}/{repo}/-/commit/{sha}"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let BuildPermalinkParams {
|
||||
sha,
|
||||
path,
|
||||
selection,
|
||||
} = params;
|
||||
|
||||
let mut permalink = self
|
||||
.base_url()
|
||||
.join(&format!("{owner}/{repo}/-/blob/{sha}/{path}"))
|
||||
.unwrap();
|
||||
if path.ends_with(".md") {
|
||||
permalink.set_query(Some("plain=1"));
|
||||
}
|
||||
permalink.set_fragment(
|
||||
selection
|
||||
.map(|selection| self.line_fragment(&selection))
|
||||
.as_deref(),
|
||||
);
|
||||
permalink
|
||||
}
|
||||
|
||||
fn build_create_pull_request_url(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
source_branch: &str,
|
||||
) -> Option<Url> {
|
||||
let mut url = self
|
||||
.base_url()
|
||||
.join(&format!(
|
||||
"{}/{}/-/merge_requests/new",
|
||||
remote.owner, remote.repo
|
||||
))
|
||||
.ok()?;
|
||||
|
||||
let query = format!("merge_request%5Bsource_branch%5D={}", encode(source_branch));
|
||||
|
||||
url.set_query(Some(&query));
|
||||
Some(url)
|
||||
}
|
||||
|
||||
fn extract_pull_request(&self, remote: &ParsedGitRemote, message: &str) -> Option<PullRequest> {
|
||||
// Check commit message for GitLab MR references
|
||||
let capture = merge_request_number_regex().captures(message)?;
|
||||
// The regex has two capture groups - one for "(!123)" pattern, one for "See merge request" pattern
|
||||
let number = capture
|
||||
.get(1)
|
||||
.or_else(|| capture.get(2))?
|
||||
.as_str()
|
||||
.parse::<u32>()
|
||||
.ok()?;
|
||||
|
||||
let mut url = self.base_url();
|
||||
let path = format!(
|
||||
"{}/{}/-/merge_requests/{}",
|
||||
remote.owner, remote.repo, number
|
||||
);
|
||||
url.set_path(&path);
|
||||
|
||||
Some(PullRequest { number, url })
|
||||
}
|
||||
|
||||
async fn commit_author_avatar_url(
|
||||
&self,
|
||||
repo_owner: &str,
|
||||
repo: &str,
|
||||
commit: SharedString,
|
||||
_author_email: Option<SharedString>,
|
||||
http_client: Arc<dyn HttpClient>,
|
||||
) -> Result<Option<Url>> {
|
||||
let commit = commit.to_string();
|
||||
let avatar_url = self
|
||||
.fetch_gitlab_commit_author(repo_owner, repo, &commit, &http_client)
|
||||
.await?
|
||||
.map(|author| -> Result<Url, url::ParseError> {
|
||||
let mut url = Url::parse(&author.avatar_url)?;
|
||||
if let Some(host) = url.host_str() {
|
||||
let size_query = if host.contains("gravatar") || host.contains("libravatar") {
|
||||
Some("s=128")
|
||||
} else if self
|
||||
.base_url
|
||||
.host_str()
|
||||
.is_some_and(|base_host| host.contains(base_host))
|
||||
{
|
||||
Some("width=128")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
url.set_query(size_query);
|
||||
}
|
||||
Ok(url)
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(avatar_url)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use git::repository::repo_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_invalid_self_hosted_remote_url() {
|
||||
let remote_url = "https://gitlab.com/zed-industries/zed.git";
|
||||
let gitlab = Gitlab::from_remote_url(remote_url);
|
||||
assert!(gitlab.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = Gitlab::public_instance()
|
||||
.parse_remote_url("git@gitlab.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = Gitlab::public_instance()
|
||||
.parse_remote_url("https://gitlab.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_ssh_url() {
|
||||
let remote_url = "git@gitlab.my-enterprise.com:zed-industries/zed.git";
|
||||
|
||||
let parsed_remote = Gitlab::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_https_url_with_subgroup() {
|
||||
let remote_url = "https://gitlab.my-enterprise.com/group/subgroup/zed.git";
|
||||
let parsed_remote = Gitlab::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "group/subgroup".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink() {
|
||||
let permalink = Gitlab::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink_with_single_line_selection() {
|
||||
let permalink = Gitlab::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(6..6),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_permalink_with_multi_line_selection() {
|
||||
let permalink = Gitlab::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(23..47),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_create_pr_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let provider = Gitlab::public_instance();
|
||||
|
||||
let url = provider
|
||||
.build_create_pull_request_url(&remote, "feature/cool stuff")
|
||||
.expect("create PR url should be constructed");
|
||||
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://gitlab.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fcool%20stuff"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_self_hosted_permalink_from_ssh_url() {
|
||||
let gitlab =
|
||||
Gitlab::from_remote_url("git@gitlab.some-enterprise.com:zed-industries/zed.git")
|
||||
.unwrap();
|
||||
let permalink = gitlab.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitlab.some-enterprise.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_gitlab_self_hosted_permalink_from_https_url() {
|
||||
let gitlab =
|
||||
Gitlab::from_remote_url("https://gitlab-instance.big-co.com/zed-industries/zed.git")
|
||||
.unwrap();
|
||||
let permalink = gitlab.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"b2efec9824c45fcc90c9a7eb107a50d1772a60aa",
|
||||
&repo_path("crates/zed/src/main.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://gitlab-instance.big-co.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_create_pull_request_url() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let github = Gitlab::public_instance();
|
||||
let url = github
|
||||
.build_create_pull_request_url(&remote, "feature/new-feature")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://gitlab.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fnew-feature"
|
||||
);
|
||||
|
||||
let base_url = Url::parse("https://gitlab.zed.com").unwrap();
|
||||
let github = Gitlab::new("GitLab Self-Hosted", base_url);
|
||||
let url = github
|
||||
.build_create_pull_request_url(&remote, "feature/new-feature")
|
||||
.expect("should be able to build pull request url");
|
||||
|
||||
assert_eq!(
|
||||
url.as_str(),
|
||||
"https://gitlab.zed.com/zed-industries/zed/-/merge_requests/new?merge_request%5Bsource_branch%5D=feature%2Fnew-feature"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_merge_request_from_squash_commit() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let provider = Gitlab::public_instance();
|
||||
|
||||
// Test squash merge pattern: "commit message (!123)"
|
||||
let message = "Add new feature (!456)";
|
||||
let pull_request = provider.extract_pull_request(&remote, message).unwrap();
|
||||
|
||||
assert_eq!(pull_request.number, 456);
|
||||
assert_eq!(
|
||||
pull_request.url.as_str(),
|
||||
"https://gitlab.com/zed-industries/zed/-/merge_requests/456"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_merge_request_from_merge_commit() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let provider = Gitlab::public_instance();
|
||||
|
||||
// Test standard merge commit pattern: "See merge request group/project!123"
|
||||
let message =
|
||||
"Merge branch 'feature' into 'main'\n\nSee merge request zed-industries/zed!789";
|
||||
let pull_request = provider.extract_pull_request(&remote, message).unwrap();
|
||||
|
||||
assert_eq!(pull_request.number, 789);
|
||||
assert_eq!(
|
||||
pull_request.url.as_str(),
|
||||
"https://gitlab.com/zed-industries/zed/-/merge_requests/789"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_merge_request_self_hosted() {
|
||||
let base_url = Url::parse("https://gitlab.my-company.com").unwrap();
|
||||
let provider = Gitlab::new("GitLab Self-Hosted", base_url);
|
||||
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "team".into(),
|
||||
repo: "project".into(),
|
||||
};
|
||||
|
||||
let message = "Fix bug (!42)";
|
||||
let pull_request = provider.extract_pull_request(&remote, message).unwrap();
|
||||
|
||||
assert_eq!(pull_request.number, 42);
|
||||
assert_eq!(
|
||||
pull_request.url.as_str(),
|
||||
"https://gitlab.my-company.com/team/project/-/merge_requests/42"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_merge_request_no_match() {
|
||||
let remote = ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
};
|
||||
|
||||
let provider = Gitlab::public_instance();
|
||||
|
||||
// No MR reference in message
|
||||
let message = "Just a regular commit message";
|
||||
let pull_request = provider.extract_pull_request(&remote, message);
|
||||
|
||||
assert!(pull_request.is_none());
|
||||
}
|
||||
}
|
||||
385
crates/git_hosting_providers/src/providers/sourcehut.rs
Normal file
385
crates/git_hosting_providers/src/providers/sourcehut.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use url::Url;
|
||||
|
||||
use git::{
|
||||
BuildCommitPermalinkParams, BuildPermalinkParams, GitHostingProvider, ParsedGitRemote,
|
||||
RemoteUrl,
|
||||
};
|
||||
|
||||
use crate::get_host_from_git_remote_url;
|
||||
|
||||
pub struct SourceHut {
|
||||
name: String,
|
||||
base_url: Url,
|
||||
}
|
||||
|
||||
impl SourceHut {
|
||||
pub fn new(name: &str, base_url: Url) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn public_instance() -> Self {
|
||||
Self::new("SourceHut", Url::parse("https://git.sr.ht").unwrap())
|
||||
}
|
||||
|
||||
pub fn from_remote_url(remote_url: &str) -> Result<Self> {
|
||||
let host = get_host_from_git_remote_url(remote_url)?;
|
||||
if host == "git.sr.ht" {
|
||||
bail!("the SourceHut instance is not self-hosted");
|
||||
}
|
||||
|
||||
// TODO: detecting self hosted instances by checking whether "sourcehut" is in the url or not
|
||||
// is not very reliable. See https://github.com/zed-industries/zed/issues/26393 for more
|
||||
// information.
|
||||
if !host.contains("sourcehut") {
|
||||
bail!("not a SourceHut URL");
|
||||
}
|
||||
|
||||
Ok(Self::new(
|
||||
"SourceHut Self-Hosted",
|
||||
Url::parse(&format!("https://{}", host))?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl GitHostingProvider for SourceHut {
|
||||
fn name(&self) -> String {
|
||||
self.name.clone()
|
||||
}
|
||||
|
||||
fn base_url(&self) -> Url {
|
||||
self.base_url.clone()
|
||||
}
|
||||
|
||||
fn supports_avatars(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn format_line_number(&self, line: u32) -> String {
|
||||
format!("L{line}")
|
||||
}
|
||||
|
||||
fn format_line_numbers(&self, start_line: u32, end_line: u32) -> String {
|
||||
format!("L{start_line}-{end_line}")
|
||||
}
|
||||
|
||||
fn parse_remote_url(&self, url: &str) -> Option<ParsedGitRemote> {
|
||||
let url = RemoteUrl::from_str(url).ok()?;
|
||||
|
||||
let host = url.host_str()?;
|
||||
if host != self.base_url.host_str()? {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_segments = url.path_segments()?;
|
||||
let owner = path_segments.next()?.trim_start_matches('~');
|
||||
// We don't trim the `.git` suffix here like we do elsewhere, as
|
||||
// sourcehut treats a repo with `.git` suffix as a separate repo.
|
||||
//
|
||||
// For example, `git@git.sr.ht:~username/repo` and `git@git.sr.ht:~username/repo.git`
|
||||
// are two distinct repositories.
|
||||
let repo = path_segments.next()?;
|
||||
|
||||
Some(ParsedGitRemote {
|
||||
owner: owner.into(),
|
||||
repo: repo.into(),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_commit_permalink(
|
||||
&self,
|
||||
remote: &ParsedGitRemote,
|
||||
params: BuildCommitPermalinkParams,
|
||||
) -> Url {
|
||||
let BuildCommitPermalinkParams { sha } = params;
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
|
||||
self.base_url()
|
||||
.join(&format!("~{owner}/{repo}/commit/{sha}"))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn build_permalink(&self, remote: ParsedGitRemote, params: BuildPermalinkParams) -> Url {
|
||||
let ParsedGitRemote { owner, repo } = remote;
|
||||
let BuildPermalinkParams {
|
||||
sha,
|
||||
path,
|
||||
selection,
|
||||
} = params;
|
||||
|
||||
let mut permalink = self
|
||||
.base_url()
|
||||
.join(&format!("~{owner}/{repo}/tree/{sha}/item/{path}"))
|
||||
.unwrap();
|
||||
permalink.set_fragment(
|
||||
selection
|
||||
.map(|selection| self.line_fragment(&selection))
|
||||
.as_deref(),
|
||||
);
|
||||
permalink
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use git::repository::repo_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url() {
|
||||
let parsed_remote = SourceHut::public_instance()
|
||||
.parse_remote_url("git@git.sr.ht:~zed-industries/zed")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_ssh_url_with_git_suffix() {
|
||||
let parsed_remote = SourceHut::public_instance()
|
||||
.parse_remote_url("git@git.sr.ht:~zed-industries/zed.git")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed.git".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_https_url() {
|
||||
let parsed_remote = SourceHut::public_instance()
|
||||
.parse_remote_url("https://git.sr.ht/~zed-industries/zed")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_ssh_url() {
|
||||
let remote_url = "git@sourcehut.org:~zed-industries/zed";
|
||||
|
||||
let parsed_remote = SourceHut::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_ssh_url_with_git_suffix() {
|
||||
let remote_url = "git@sourcehut.org:~zed-industries/zed.git";
|
||||
|
||||
let parsed_remote = SourceHut::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed.git".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_remote_url_given_self_hosted_https_url() {
|
||||
let remote_url = "https://sourcehut.org/~zed-industries/zed";
|
||||
|
||||
let parsed_remote = SourceHut::from_remote_url(remote_url)
|
||||
.unwrap()
|
||||
.parse_remote_url(remote_url)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
parsed_remote,
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink() {
|
||||
let permalink = SourceHut::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_with_git_suffix() {
|
||||
let permalink = SourceHut::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed.git".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_self_hosted_permalink() {
|
||||
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed")
|
||||
.unwrap()
|
||||
.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_self_hosted_permalink_with_git_suffix() {
|
||||
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed.git")
|
||||
.unwrap()
|
||||
.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed.git".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://sourcehut.org/~zed-industries/zed.git/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_with_single_line_selection() {
|
||||
let permalink = SourceHut::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(6..6),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_permalink_with_multi_line_selection() {
|
||||
let permalink = SourceHut::public_instance().build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(23..47),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://git.sr.ht/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_self_hosted_permalink_with_single_line_selection() {
|
||||
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed")
|
||||
.unwrap()
|
||||
.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(6..6),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L7";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_sourcehut_self_hosted_permalink_with_multi_line_selection() {
|
||||
let permalink = SourceHut::from_remote_url("https://sourcehut.org/~zed-industries/zed")
|
||||
.unwrap()
|
||||
.build_permalink(
|
||||
ParsedGitRemote {
|
||||
owner: "zed-industries".into(),
|
||||
repo: "zed".into(),
|
||||
},
|
||||
BuildPermalinkParams::new(
|
||||
"faa6f979be417239b2e070dbbf6392b909224e0b",
|
||||
&repo_path("crates/editor/src/git/permalink.rs"),
|
||||
Some(23..47),
|
||||
),
|
||||
);
|
||||
|
||||
let expected_url = "https://sourcehut.org/~zed-industries/zed/tree/faa6f979be417239b2e070dbbf6392b909224e0b/item/crates/editor/src/git/permalink.rs#L24-48";
|
||||
assert_eq!(permalink.to_string(), expected_url.to_string())
|
||||
}
|
||||
}
|
||||
76
crates/git_hosting_providers/src/settings.rs
Normal file
76
crates/git_hosting_providers/src/settings.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use git::GitHostingProviderRegistry;
|
||||
use gpui::App;
|
||||
use settings::{
|
||||
GitHostingProviderConfig, GitHostingProviderKind, RegisterSetting, Settings, SettingsStore,
|
||||
};
|
||||
use url::Url;
|
||||
use util::ResultExt as _;
|
||||
|
||||
use crate::{Bitbucket, Forgejo, Gitea, Github, Gitlab, SourceHut};
|
||||
|
||||
pub(crate) fn init(cx: &mut App) {
|
||||
init_git_hosting_provider_settings(cx);
|
||||
}
|
||||
|
||||
fn init_git_hosting_provider_settings(cx: &mut App) {
|
||||
update_git_hosting_providers_from_settings(cx);
|
||||
|
||||
cx.observe_global::<SettingsStore>(update_git_hosting_providers_from_settings)
|
||||
.detach();
|
||||
}
|
||||
|
||||
fn update_git_hosting_providers_from_settings(cx: &mut App) {
|
||||
let settings_store = cx.global::<SettingsStore>();
|
||||
let settings = GitHostingProviderSettings::get_global(cx);
|
||||
let provider_registry = GitHostingProviderRegistry::global(cx);
|
||||
|
||||
let local_values: Vec<GitHostingProviderConfig> = settings_store
|
||||
.get_all_locals::<GitHostingProviderSettings>()
|
||||
.into_iter()
|
||||
.flat_map(|(_, _, providers)| providers.git_hosting_providers.clone())
|
||||
.collect();
|
||||
|
||||
let iter = settings
|
||||
.git_hosting_providers
|
||||
.clone()
|
||||
.into_iter()
|
||||
.chain(local_values)
|
||||
.filter_map(|provider| {
|
||||
let url = Url::parse(&provider.base_url).log_err()?;
|
||||
|
||||
Some(match provider.provider {
|
||||
GitHostingProviderKind::Bitbucket => {
|
||||
Arc::new(Bitbucket::new(&provider.name, url)) as _
|
||||
}
|
||||
GitHostingProviderKind::Github => Arc::new(Github::new(&provider.name, url)) as _,
|
||||
GitHostingProviderKind::Gitlab => Arc::new(Gitlab::new(&provider.name, url)) as _,
|
||||
GitHostingProviderKind::Gitea => Arc::new(Gitea::new(&provider.name, url)) as _,
|
||||
GitHostingProviderKind::Forgejo => Arc::new(Forgejo::new(&provider.name, url)) as _,
|
||||
GitHostingProviderKind::SourceHut => {
|
||||
Arc::new(SourceHut::new(&provider.name, url)) as _
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
provider_registry.set_setting_providers(iter);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, RegisterSetting)]
|
||||
pub struct GitHostingProviderSettings {
|
||||
pub git_hosting_providers: Vec<GitHostingProviderConfig>,
|
||||
}
|
||||
|
||||
impl Settings for GitHostingProviderSettings {
|
||||
fn from_settings(content: &settings::SettingsContent) -> Self {
|
||||
Self {
|
||||
git_hosting_providers: content
|
||||
.project
|
||||
.git_hosting_providers
|
||||
.clone()
|
||||
.unwrap()
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user