use tracing::debug;
use crate::collect::errors::{CollectError, Result};
use crate::collect::github::client::USER_AGENT_VALUE;
use crate::core::config::{GithubConfig, RepositoryConfig};
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use crate::collect::env_expand::expand_env_var;
pub fn parse_slug(slug: &str) -> Result<(String, String)> {
let (owner, repo) = slug.split_once('/').ok_or_else(|| {
CollectError::Config(format!("github repo must be 'owner/name', got '{slug}'"))
})?;
if owner.is_empty() || repo.is_empty() {
return Err(CollectError::Config(format!(
"github repo must be 'owner/name', got '{slug}'"
)));
}
Ok((owner.to_string(), repo.to_string()))
}
pub(crate) fn build_http_client(config: &GithubConfig) -> Result<reqwest::Client> {
let mut headers = HeaderMap::new();
headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
headers.insert(
ACCEPT,
HeaderValue::from_static("application/vnd.github+json"),
);
if let Some(raw) = &config.token {
let val = HeaderValue::from_str(&format!("Bearer {}", expand_env_var(raw)))
.map_err(|e| CollectError::Config(format!("invalid token header: {e}")))?;
headers.insert(AUTHORIZATION, val);
}
Ok(reqwest::Client::builder()
.default_headers(headers)
.timeout(std::time::Duration::from_secs(30))
.build()?)
}
pub fn owner_repo_from_remote(repo_path: &std::path::Path) -> Option<(String, String)> {
let repo = git2::Repository::open(repo_path).ok()?;
let remote = repo.find_remote("origin").ok()?;
let url = remote.url()?;
extract_owner_repo_from_url(url)
}
pub fn extract_owner_repo_from_url(url: &str) -> Option<(String, String)> {
let cleaned = url.strip_suffix(".git").unwrap_or(url);
if let Some(rest) = cleaned.strip_prefix("git@github.com:") {
return split_owner_repo(rest);
}
for prefix in [
"https://github.com/",
"http://github.com/",
"ssh://git@github.com/",
] {
if let Some(rest) = cleaned.strip_prefix(prefix) {
return split_owner_repo(rest);
}
}
if let Some(after_scheme) = cleaned.strip_prefix("https://") {
if let Some(at_idx) = after_scheme.find('@') {
let after_at = &after_scheme[at_idx + 1..];
if let Some(rest) = after_at.strip_prefix("github.com/") {
return split_owner_repo(rest);
}
}
}
None
}
fn split_owner_repo(rest: &str) -> Option<(String, String)> {
let mut parts = rest.splitn(3, '/');
let owner = parts.next()?;
let name = parts.next()?;
if owner.is_empty() || name.is_empty() {
return None;
}
Some((owner.to_string(), name.to_string()))
}
pub fn resolve_github_repos(
github: &GithubConfig,
repositories: &[RepositoryConfig],
) -> Vec<(String, String)> {
if let Some(slug) = &github.repo {
if let Ok(pair) = parse_slug(slug) {
return vec![pair];
} else {
tracing::warn!(slug = %slug, "github.repo is malformed; falling back to repositories[]");
}
}
let mut out: Vec<(String, String)> = Vec::new();
let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
for repo_cfg in repositories {
let repo_name = repo_cfg
.name
.clone()
.or_else(|| {
repo_cfg
.path
.file_name()
.and_then(|n| n.to_str())
.map(str::to_string)
})
.unwrap_or_default();
let owner_from_cfg = repo_cfg.org.clone().or_else(|| github.org.clone());
let pair = if let Some(owner) = &owner_from_cfg {
if repo_name.is_empty() {
owner_repo_from_remote(&repo_cfg.path)
} else {
Some((owner.clone(), repo_name.clone()))
}
} else {
owner_repo_from_remote(&repo_cfg.path)
};
if let Some(p) = pair {
if seen.insert(p.clone()) {
out.push(p);
}
} else {
debug!(
path = %repo_cfg.path.display(),
"could not resolve owner/repo for repository; skipping for GitHub PR fetch"
);
}
}
out
}