use super::{
ForgeKind, ForgeRemote, GitHubReviewRequestAdapter, GitLabReviewRequestAdapter,
ReviewRequestError,
};
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ParsedRemote {
pub(crate) host: String,
pub(crate) namespace: String,
pub(crate) project: String,
pub(crate) repo_url: String,
pub(crate) web_url: String,
}
impl ParsedRemote {
pub(crate) fn into_forge_remote(self, forge_kind: ForgeKind) -> ForgeRemote {
ForgeRemote {
command_working_directory: None,
forge_kind,
host: self.host,
namespace: self.namespace,
project: self.project,
repo_url: self.repo_url,
web_url: self.web_url,
}
}
}
pub fn detect_remote(repo_url: &str) -> Result<ForgeRemote, ReviewRequestError> {
if let Some(remote) = GitHubReviewRequestAdapter::detect_remote(repo_url) {
return Ok(remote);
}
if let Some(remote) = GitLabReviewRequestAdapter::detect_remote(repo_url) {
return Ok(remote);
}
Err(ReviewRequestError::UnsupportedRemote {
repo_url: repo_url.to_string(),
})
}
pub(crate) fn parse_remote_url(repo_url: &str) -> Option<ParsedRemote> {
let trimmed_url = repo_url.trim().trim_end_matches('/');
if trimmed_url.is_empty() {
return None;
}
if let Some(ssh_remote) = trimmed_url.strip_prefix("git@") {
let (host, path) = ssh_remote.split_once(':')?;
return parsed_remote_from_parts(trimmed_url, host, path, true);
}
let (scheme, scheme_rest) = trimmed_url.split_once("://")?;
let scheme_rest = scheme_rest.strip_prefix("git@").unwrap_or(scheme_rest);
let (authority, path) = scheme_rest.split_once('/')?;
let host = strip_userinfo(authority);
let strip_transport_port = scheme.eq_ignore_ascii_case("ssh");
parsed_remote_from_parts(trimmed_url, host, path, strip_transport_port)
}
pub(crate) fn strip_port(host: &str) -> &str {
host.split(':').next().unwrap_or(host)
}
fn strip_userinfo(authority: &str) -> &str {
authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host)
}
fn parsed_remote_from_parts(
repo_url: &str,
host: &str,
path: &str,
strip_transport_port: bool,
) -> Option<ParsedRemote> {
let host = host.trim().trim_matches('/').to_ascii_lowercase();
let host = if strip_transport_port {
strip_port(&host).to_string()
} else {
host
};
let path = path.trim().trim_matches('/').trim_end_matches(".git");
if host.is_empty() || path.is_empty() {
return None;
}
let (namespace, project) = path.rsplit_once('/')?;
if namespace.is_empty() || project.is_empty() {
return None;
}
Some(ParsedRemote {
host: host.clone(),
namespace: namespace.to_string(),
project: project.to_string(),
repo_url: repo_url.to_string(),
web_url: format!("https://{host}/{path}"),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_remote_returns_github_remote_for_https_origin() {
let repo_url = "https://github.com/agentty-xyz/agentty.git";
let remote = detect_remote(repo_url).expect("github remote should be supported");
assert_eq!(
remote,
ForgeRemote {
command_working_directory: None,
forge_kind: ForgeKind::GitHub,
host: "github.com".to_string(),
namespace: "agentty-xyz".to_string(),
project: "agentty".to_string(),
repo_url: repo_url.to_string(),
web_url: "https://github.com/agentty-xyz/agentty".to_string(),
}
);
}
#[test]
fn detect_remote_ignores_https_userinfo_for_github_origin() {
let repo_url = "https://build-bot:token123@github.com/agentty-xyz/agentty.git";
let remote =
detect_remote(repo_url).expect("github remote with https credentials should work");
assert_eq!(remote.forge_kind, ForgeKind::GitHub);
assert_eq!(remote.host, "github.com");
assert_eq!(remote.namespace, "agentty-xyz");
assert_eq!(remote.project, "agentty");
assert_eq!(remote.repo_url, repo_url);
assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
}
#[test]
fn detect_remote_returns_github_remote_for_ssh_origin() {
let repo_url = "git@github.com:agentty-xyz/agentty.git";
let remote = detect_remote(repo_url).expect("github ssh remote should be supported");
assert_eq!(remote.forge_kind, ForgeKind::GitHub);
assert_eq!(remote.web_url, "https://github.com/agentty-xyz/agentty");
assert_eq!(remote.project_path(), "agentty-xyz/agentty");
}
#[test]
fn detect_remote_returns_unsupported_remote_error_for_non_forge_origin() {
let repo_url = "https://example.com/team/project.git";
let error = detect_remote(repo_url).expect_err("non-forge remote should be rejected");
assert_eq!(
error,
ReviewRequestError::UnsupportedRemote {
repo_url: repo_url.to_string(),
}
);
assert!(error.detail_message().contains("GitHub and GitLab remotes"));
assert!(error.detail_message().contains("example.com"));
}
#[test]
fn detect_remote_returns_gitlab_remote_for_https_origin() {
let repo_url = "https://gitlab.com/agentty-xyz/agentty.git";
let remote = detect_remote(repo_url).expect("gitlab remote should be supported");
assert_eq!(
remote,
ForgeRemote {
command_working_directory: None,
forge_kind: ForgeKind::GitLab,
host: "gitlab.com".to_string(),
namespace: "agentty-xyz".to_string(),
project: "agentty".to_string(),
repo_url: repo_url.to_string(),
web_url: "https://gitlab.com/agentty-xyz/agentty".to_string(),
}
);
}
#[test]
fn detect_remote_returns_gitlab_remote_for_gitlab_subdomain_origin() {
let repo_url = "git@gitlab.company.org:team/agentty.git";
let remote = detect_remote(repo_url).expect("gitlab subdomain remote should be supported");
assert_eq!(remote.forge_kind, ForgeKind::GitLab);
assert_eq!(remote.host, "gitlab.company.org");
assert_eq!(remote.project_path(), "team/agentty");
assert_eq!(remote.web_url, "https://gitlab.company.org/team/agentty");
}
}