use anyhow::bail;
use crate::git::Git;
use crate::model::config::GitHubConfig;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitHubRepo {
pub owner: String,
pub repo: String,
}
impl GitHubRepo {
pub fn new(owner: impl Into<String>, repo: impl Into<String>) -> anyhow::Result<Self> {
let owner = owner.into();
let repo = repo.into();
Self::validate_identifier(&owner, "owner")?;
Self::validate_identifier(&repo, "repo")?;
Ok(Self { owner, repo })
}
fn validate_identifier(value: &str, field: &str) -> anyhow::Result<()> {
if value.is_empty()
|| !value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
anyhow::bail!("Invalid GitHub {field}: {value:?}");
}
Ok(())
}
pub(crate) fn parse_url(url: &str) -> Option<Self> {
let url = url.trim();
let path = if let Some(rest) = url.strip_prefix("https://github.com") {
let rest = strip_optional_port(rest)?;
rest.strip_prefix('/')?
} else if let Some(rest) = url.strip_prefix("ssh://") {
let rest = rest.split_once('@').map_or(rest, |(_, after)| after);
let rest = rest.strip_prefix("github.com")?;
let rest = strip_optional_port(rest)?;
rest.strip_prefix('/')?
} else {
url.strip_prefix("git@github.com:")?
};
let path = path.strip_suffix(".git").unwrap_or(path);
let (owner, repo) = path.split_once('/')?;
GitHubRepo::new(owner, repo).ok()
}
pub(crate) async fn detect_in(git: &dyn Git) -> anyhow::Result<Option<Self>> {
match git.remote_origin_url().await? {
Some(url) => Ok(Self::parse_url(&url)),
None => Ok(None),
}
}
pub async fn resolve(github_config: &GitHubConfig, git: &dyn Git) -> anyhow::Result<Self> {
match (github_config.owner(), github_config.repo()) {
(Some(owner), Some(repo)) => {
return GitHubRepo::new(owner, repo);
}
(Some(_), None) | (None, Some(_)) => bail!(
"[github].owner and [github].repo must be set together; \
set both or omit both for auto-detection."
),
(None, None) => {}
}
match Self::detect_in(git).await? {
Some(gh_repo) => Ok(gh_repo),
None => bail!(
"Could not determine GitHub repository. Set [github] owner and repo in config, \
or ensure the git remote 'origin' points to a GitHub repository."
),
}
}
}
fn strip_optional_port(s: &str) -> Option<&str> {
let Some(after_colon) = s.strip_prefix(':') else {
return Some(s);
};
let digit_end = after_colon
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after_colon.len());
if digit_end == 0 {
return None;
}
Some(&after_colon[digit_end..])
}