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

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:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

View 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"] }

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View 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);
}
}
}

View 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::*;

View 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());
}
}

View 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"
);
}
}

View 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"
);
}
}

View 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())
}
}

View 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())
}
}

View 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())
}
}

View 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"
);
}
}

View 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());
}
}

View 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())
}
}

View 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(),
}
}
}