pub mod bitbucket;
pub mod gitea;
pub mod github;
pub mod gitlab;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use anyhow::Result;
pub use crate::config::ForgeKind;
pub struct MergeRequestResult {
pub id: u64,
pub auto_merge_key: String,
}
#[derive(Default)]
pub struct ReleaseResult {
pub id: Option<u64>,
pub url: Option<String>,
}
pub trait Forge: Send + Sync {
fn create_release(
&self,
tag: &str,
body: &str,
prerelease: bool,
draft: bool,
) -> Result<ReleaseResult>;
fn find_draft_release(&self, tag: &str) -> Result<Option<u64>>;
fn publish_release(&self, release_id: u64) -> Result<()>;
fn create_merge_request(
&self,
head: &str,
base: &str,
title: &str,
body: &str,
) -> Result<MergeRequestResult>;
fn enable_auto_merge(&self, mr: &MergeRequestResult) -> Result<()>;
fn mr_noun(&self) -> &'static str;
fn release_noun(&self) -> &'static str;
fn find_comment(&self, pr_id: u64, marker: &str) -> Result<Option<u64>>;
fn create_comment(&self, pr_id: u64, body: &str) -> Result<()>;
fn update_comment(&self, pr_id: u64, comment_id: u64, body: &str) -> Result<()>;
fn find_open_pr(&self, head: &str, base: &str) -> Result<Option<u64>>;
fn update_merge_request(&self, id: u64, title: &str, body: &str) -> Result<MergeRequestResult>;
}
pub fn detect_pr_number() -> Option<u64> {
if let Ok(ref_name) = std::env::var("GITHUB_REF")
&& let Some(num) = ref_name
.strip_prefix("refs/pull/")
.and_then(|s| s.strip_suffix("/merge"))
&& let Ok(n) = num.parse()
{
return Some(n);
}
if let Ok(iid) = std::env::var("CI_MERGE_REQUEST_IID") {
return iid.parse().ok();
}
None
}
pub fn detect_forge_from_url(url: &str) -> Option<ForgeKind> {
if url.contains("github.com") {
Some(ForgeKind::Github)
} else if url.contains("gitlab.com") {
Some(ForgeKind::Gitlab)
} else if url.contains("codeberg.org") || url.contains("gitea.io") {
Some(ForgeKind::Gitea)
} else if url.contains("bitbucket.org") {
Some(ForgeKind::Bitbucket)
} else {
None
}
}
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
static PROBE_CACHE: OnceLock<Mutex<HashMap<String, Option<ForgeKind>>>> = OnceLock::new();
pub fn detect_forge_with_probe(url: &str) -> Option<ForgeKind> {
if let Some(kind) = detect_forge_from_url(url) {
return Some(kind);
}
let host = extract_host(url)?;
let cache = PROBE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Some(cached) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&host) {
return *cached;
}
let detected = probe_host(&host);
cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(host, detected);
detected
}
fn probe_host(host: &str) -> Option<ForgeKind> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(PROBE_TIMEOUT))
.build()
.into();
let status = |path: &str| probe_status(&agent, &format!("https://{host}{path}"));
kind_from_probe_statuses(
status("/api/v4/version"),
status("/api/v1/version"),
status("/api/v3"),
)
}
fn probe_status(agent: &ureq::Agent, url: &str) -> Option<u16> {
match agent.get(url).header("User-Agent", "ferrflow").call() {
Ok(response) => Some(response.status().as_u16()),
Err(ureq::Error::StatusCode(code)) => Some(code),
Err(_) => None,
}
}
fn kind_from_probe_statuses(
gitlab: Option<u16>,
gitea: Option<u16>,
github: Option<u16>,
) -> Option<ForgeKind> {
if matches!(gitlab, Some(200) | Some(401)) {
return Some(ForgeKind::Gitlab);
}
if gitea == Some(200) {
return Some(ForgeKind::Gitea);
}
if github == Some(200) {
return Some(ForgeKind::Github);
}
None
}
pub fn extract_host(url: &str) -> Option<String> {
if let Some(rest) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
{
let authority = rest.split('/').next()?;
let host_port = authority.rsplit('@').next()?;
let host = host_port.split(':').next()?;
valid_host(host)
} else if url.contains('@') && url.contains(':') {
let after_at = url.rsplit('@').next()?;
let host = after_at.split(':').next()?;
valid_host(host)
} else {
None
}
}
fn valid_host(host: &str) -> Option<String> {
let ok = !host.is_empty()
&& host.len() <= 253
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-');
ok.then(|| host.to_string())
}
pub fn extract_repo_slug(url: &str) -> Option<String> {
for host in ["github.com", "gitlab.com"] {
let after = if url.contains(&format!("{host}/")) {
url.split(&format!("{host}/")).nth(1)
} else if url.contains(&format!("{host}:")) {
url.split(&format!("{host}:")).nth(1)
} else {
None
};
if let Some(slug) = after {
return Some(slug.trim_end_matches(".git").to_string());
}
}
let path = if let Some(rest) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
{
rest.split_once('/').map(|x| x.1)
} else if url.contains('@') && url.contains(':') {
url.split_once(':').map(|x| x.1)
} else {
None
};
path.map(|p| p.trim_end_matches(".git").to_string())
.filter(|s| s.contains('/') && !s.is_empty())
}
pub fn web_base_url(remote_url: &str) -> Option<String> {
let kind = detect_forge_from_url(remote_url)?;
let host = extract_host(remote_url)?;
let slug = extract_repo_slug(remote_url)?;
match kind {
ForgeKind::Github | ForgeKind::Gitlab | ForgeKind::Gitea | ForgeKind::Bitbucket => {
Some(format!("https://{host}/{slug}"))
}
ForgeKind::Auto => None,
}
}
pub fn resolve_token(kind: ForgeKind) -> Option<String> {
if let Ok(token) = std::env::var("FERRFLOW_TOKEN")
&& !token.is_empty()
{
return Some(token);
}
match kind {
ForgeKind::Github => std::env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty()),
ForgeKind::Gitlab => std::env::var("GITLAB_TOKEN").ok().filter(|t| !t.is_empty()),
ForgeKind::Gitea => std::env::var("GITEA_TOKEN")
.ok()
.filter(|t| !t.is_empty())
.or_else(|| {
std::env::var("FORGEJO_TOKEN")
.ok()
.filter(|t| !t.is_empty())
}),
ForgeKind::Bitbucket => std::env::var("BITBUCKET_TOKEN")
.ok()
.filter(|t| !t.is_empty()),
ForgeKind::Auto => None,
}
}
pub fn build_forge(kind: ForgeKind, token: String, slug: String, host: String) -> Box<dyn Forge> {
let agent = crate::http::agent();
match kind {
ForgeKind::Github => {
let api_base = if host == "github.com" {
"https://api.github.com".to_string()
} else {
format!("https://{host}/api/v3")
};
Box::new(github::GitHubForge {
token,
slug,
api_base,
agent,
})
}
ForgeKind::Gitlab => {
let api_base = format!("https://{host}/api/v4");
Box::new(gitlab::GitLabForge {
token,
slug,
api_base,
agent,
})
}
ForgeKind::Gitea => {
let api_base = format!("https://{host}/api/v1");
Box::new(gitea::GiteaForge {
token,
slug,
api_base,
agent,
})
}
ForgeKind::Bitbucket => {
let is_cloud = host == "bitbucket.org";
let api_base = if is_cloud {
"https://api.bitbucket.org/2.0".to_string()
} else {
format!("https://{host}/rest/api/1.0")
};
Box::new(bitbucket::BitbucketForge {
token,
slug,
api_base,
is_cloud,
agent,
})
}
ForgeKind::Auto => unreachable!("ForgeKind::Auto must be resolved before building"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn detect_github_https() {
assert_eq!(
detect_forge_from_url("https://github.com/owner/repo.git"),
Some(ForgeKind::Github)
);
}
#[test]
fn detect_github_ssh() {
assert_eq!(
detect_forge_from_url("git@github.com:owner/repo.git"),
Some(ForgeKind::Github)
);
}
#[test]
fn detect_gitlab_https() {
assert_eq!(
detect_forge_from_url("https://gitlab.com/owner/repo.git"),
Some(ForgeKind::Gitlab)
);
}
#[test]
fn detect_gitlab_ssh() {
assert_eq!(
detect_forge_from_url("git@gitlab.com:owner/repo.git"),
Some(ForgeKind::Gitlab)
);
}
#[test]
fn detect_gitea_codeberg() {
assert_eq!(
detect_forge_from_url("https://codeberg.org/owner/repo.git"),
Some(ForgeKind::Gitea)
);
assert_eq!(
detect_forge_from_url("git@codeberg.org:owner/repo.git"),
Some(ForgeKind::Gitea)
);
}
#[test]
fn detect_gitea_io() {
assert_eq!(
detect_forge_from_url("https://gitea.io/owner/repo.git"),
Some(ForgeKind::Gitea)
);
}
#[test]
fn gitea_web_base_url() {
assert_eq!(
web_base_url("https://codeberg.org/owner/repo.git").as_deref(),
Some("https://codeberg.org/owner/repo")
);
}
#[test]
fn detect_unknown_host() {
assert_eq!(
detect_forge_from_url("https://git.example.com/owner/repo.git"),
None
);
}
#[test]
fn detect_bitbucket_cloud() {
assert_eq!(
detect_forge_from_url("https://bitbucket.org/workspace/repo.git"),
Some(ForgeKind::Bitbucket)
);
assert_eq!(
detect_forge_from_url("git@bitbucket.org:workspace/repo.git"),
Some(ForgeKind::Bitbucket)
);
assert_eq!(
web_base_url("https://bitbucket.org/workspace/repo.git").as_deref(),
Some("https://bitbucket.org/workspace/repo")
);
}
#[test]
fn probe_classifier_maps_statuses_to_kinds() {
assert_eq!(
kind_from_probe_statuses(Some(200), None, None),
Some(ForgeKind::Gitlab)
);
assert_eq!(
kind_from_probe_statuses(Some(401), None, None),
Some(ForgeKind::Gitlab)
);
assert_eq!(
kind_from_probe_statuses(Some(401), Some(200), Some(200)),
Some(ForgeKind::Gitlab)
);
assert_eq!(
kind_from_probe_statuses(Some(404), Some(200), None),
Some(ForgeKind::Gitea)
);
assert_eq!(
kind_from_probe_statuses(Some(404), Some(404), Some(200)),
Some(ForgeKind::Github)
);
assert_eq!(
kind_from_probe_statuses(Some(404), Some(404), Some(404)),
None
);
assert_eq!(kind_from_probe_statuses(None, None, None), None);
}
#[test]
fn probe_short_circuits_known_saas_hosts_without_network() {
assert_eq!(
detect_forge_with_probe("https://github.com/o/r.git"),
Some(ForgeKind::Github)
);
assert_eq!(
detect_forge_with_probe("git@gitlab.com:o/r.git"),
Some(ForgeKind::Gitlab)
);
assert_eq!(
detect_forge_with_probe("https://bitbucket.org/o/r.git"),
Some(ForgeKind::Bitbucket)
);
}
#[test]
fn resolve_token_bitbucket_from_bitbucket_token() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("FERRFLOW_TOKEN");
std::env::set_var("BITBUCKET_TOKEN", "bb-tok");
}
let result = resolve_token(ForgeKind::Bitbucket);
unsafe {
std::env::remove_var("BITBUCKET_TOKEN");
}
assert_eq!(result, Some("bb-tok".to_string()));
}
#[test]
fn slug_github_https() {
assert_eq!(
extract_repo_slug("https://github.com/owner/repo.git"),
Some("owner/repo".to_string())
);
}
#[test]
fn slug_github_ssh() {
assert_eq!(
extract_repo_slug("git@github.com:owner/repo.git"),
Some("owner/repo".to_string())
);
}
#[test]
fn slug_gitlab_https() {
assert_eq!(
extract_repo_slug("https://gitlab.com/owner/repo.git"),
Some("owner/repo".to_string())
);
}
#[test]
fn slug_gitlab_ssh() {
assert_eq!(
extract_repo_slug("git@gitlab.com:owner/repo.git"),
Some("owner/repo".to_string())
);
}
#[test]
fn slug_gitlab_subgroup() {
assert_eq!(
extract_repo_slug("https://gitlab.com/group/subgroup/repo.git"),
Some("group/subgroup/repo".to_string())
);
}
#[test]
fn slug_custom_domain_https() {
assert_eq!(
extract_repo_slug("https://git.company.com/team/project.git"),
Some("team/project".to_string())
);
}
#[test]
fn slug_custom_domain_ssh() {
assert_eq!(
extract_repo_slug("git@git.company.com:team/project.git"),
Some("team/project".to_string())
);
}
#[test]
fn forge_kind_deserialize_lowercase() {
let kind: ForgeKind = serde_json::from_str("\"github\"").unwrap();
assert_eq!(kind, ForgeKind::Github);
}
#[test]
fn forge_kind_deserialize_default() {
let kind: ForgeKind = serde_json::from_str("\"auto\"").unwrap();
assert_eq!(kind, ForgeKind::Auto);
}
#[test]
fn forge_kind_serialize() {
assert_eq!(
serde_json::to_string(&ForgeKind::Gitlab).unwrap(),
"\"gitlab\""
);
}
#[test]
fn resolve_token_ferrflow_token_takes_precedence() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("FERRFLOW_TOKEN", "ferrflow-tok");
std::env::set_var("GITHUB_TOKEN", "gh-tok");
}
let result = resolve_token(ForgeKind::Github);
unsafe {
std::env::remove_var("FERRFLOW_TOKEN");
std::env::remove_var("GITHUB_TOKEN");
}
assert_eq!(result, Some("ferrflow-tok".to_string()));
}
#[test]
fn resolve_token_falls_back_to_github_token() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("FERRFLOW_TOKEN");
std::env::set_var("GITHUB_TOKEN", "gh-tok");
}
let result = resolve_token(ForgeKind::Github);
unsafe {
std::env::remove_var("GITHUB_TOKEN");
}
assert_eq!(result, Some("gh-tok".to_string()));
}
#[test]
fn resolve_token_falls_back_to_gitlab_token() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("FERRFLOW_TOKEN");
std::env::set_var("GITLAB_TOKEN", "gl-tok");
}
let result = resolve_token(ForgeKind::Gitlab);
unsafe {
std::env::remove_var("GITLAB_TOKEN");
}
assert_eq!(result, Some("gl-tok".to_string()));
}
#[test]
fn resolve_token_gitea_from_gitea_token() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("FERRFLOW_TOKEN");
std::env::remove_var("FORGEJO_TOKEN");
std::env::set_var("GITEA_TOKEN", "gitea-tok");
}
let result = resolve_token(ForgeKind::Gitea);
unsafe {
std::env::remove_var("GITEA_TOKEN");
}
assert_eq!(result, Some("gitea-tok".to_string()));
}
#[test]
fn resolve_token_gitea_falls_back_to_forgejo_token() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("FERRFLOW_TOKEN");
std::env::remove_var("GITEA_TOKEN");
std::env::set_var("FORGEJO_TOKEN", "forgejo-tok");
}
let result = resolve_token(ForgeKind::Gitea);
unsafe {
std::env::remove_var("FORGEJO_TOKEN");
}
assert_eq!(result, Some("forgejo-tok".to_string()));
}
#[test]
fn resolve_token_empty_ferrflow_token_ignored() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::set_var("FERRFLOW_TOKEN", "");
std::env::set_var("GITHUB_TOKEN", "gh-tok");
}
let result = resolve_token(ForgeKind::Github);
unsafe {
std::env::remove_var("FERRFLOW_TOKEN");
std::env::remove_var("GITHUB_TOKEN");
}
assert_eq!(result, Some("gh-tok".to_string()));
}
#[test]
fn resolve_token_auto_returns_none() {
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
unsafe {
std::env::remove_var("FERRFLOW_TOKEN");
}
assert_eq!(resolve_token(ForgeKind::Auto), None);
}
#[test]
fn build_forge_github() {
let forge = build_forge(
ForgeKind::Github,
"tok".into(),
"owner/repo".into(),
"github.com".into(),
);
assert_eq!(forge.mr_noun(), "PR");
assert_eq!(forge.release_noun(), "GitHub Release");
}
#[test]
fn build_forge_gitlab() {
let forge = build_forge(
ForgeKind::Gitlab,
"tok".into(),
"owner/repo".into(),
"gitlab.com".into(),
);
assert_eq!(forge.mr_noun(), "MR");
assert_eq!(forge.release_noun(), "GitLab Release");
}
#[test]
#[should_panic(expected = "unreachable")]
fn build_forge_auto_panics() {
build_forge(
ForgeKind::Auto,
"tok".into(),
"owner/repo".into(),
"github.com".into(),
);
}
#[test]
fn slug_no_suffix() {
assert_eq!(
extract_repo_slug("https://github.com/owner/repo"),
Some("owner/repo".to_string())
);
}
#[test]
fn detect_forge_empty_string() {
assert_eq!(detect_forge_from_url(""), None);
}
#[test]
fn extract_host_github_https() {
assert_eq!(
extract_host("https://github.com/owner/repo.git"),
Some("github.com".to_string())
);
}
#[test]
fn extract_host_github_ssh() {
assert_eq!(
extract_host("git@github.com:owner/repo.git"),
Some("github.com".to_string())
);
}
#[test]
fn extract_host_gitlab_https() {
assert_eq!(
extract_host("https://gitlab.com/owner/repo.git"),
Some("gitlab.com".to_string())
);
}
#[test]
fn extract_host_self_hosted_https() {
assert_eq!(
extract_host("https://git.company.com/team/project.git"),
Some("git.company.com".to_string())
);
}
#[test]
fn extract_host_self_hosted_ssh() {
assert_eq!(
extract_host("git@gitlab.internal:team/project.git"),
Some("gitlab.internal".to_string())
);
}
#[test]
fn extract_host_empty() {
assert_eq!(extract_host(""), None);
}
#[test]
fn extract_host_rejects_non_hostname_chars() {
assert_eq!(extract_host("https://ho st/owner/repo"), None);
assert_eq!(extract_host("https://host_underscore/o/r"), None);
assert_eq!(extract_host("https://h%40ck/o/r"), None);
assert_eq!(
extract_host("https://git.corp.example.com/o/r").as_deref(),
Some("git.corp.example.com")
);
}
#[test]
fn build_forge_github_self_hosted() {
let forge = build_forge(
ForgeKind::Github,
"tok".into(),
"owner/repo".into(),
"github.corp.com".into(),
);
assert_eq!(forge.mr_noun(), "PR");
}
#[test]
fn build_forge_gitlab_self_hosted() {
let forge = build_forge(
ForgeKind::Gitlab,
"tok".into(),
"team/project".into(),
"gitlab.internal".into(),
);
assert_eq!(forge.mr_noun(), "MR");
}
}