#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum ForgeKind {
GitHub,
GitLab,
Gitea,
}
impl ForgeKind {
pub fn as_str(self) -> &'static str {
match self {
ForgeKind::GitHub => "github",
ForgeKind::GitLab => "gitlab",
ForgeKind::Gitea => "gitea",
}
}
pub fn from_remote_url(url: &str) -> Option<ForgeKind> {
let host = host_of(url)?.to_ascii_lowercase();
if host_is(&host, "github.com") {
Some(ForgeKind::GitHub)
} else if host_is(&host, "gitlab.com") {
Some(ForgeKind::GitLab)
} else if host_is(&host, "gitea.com") || host_is(&host, "codeberg.org") {
Some(ForgeKind::Gitea)
} else {
None
}
}
}
fn host_is(host: &str, domain: &str) -> bool {
host == domain
|| host
.strip_suffix(domain)
.is_some_and(|prefix| prefix.ends_with('.'))
}
fn host_of(url: &str) -> Option<&str> {
let rest = match url.split_once("://") {
Some((_scheme, after)) => {
let authority = after.split(['/', '?', '#']).next().unwrap_or(after);
let host_port = authority.rsplit('@').next().unwrap_or(authority);
return host_port.split(':').next().filter(|h| !h.is_empty());
}
None => url,
};
let after_user = rest.rsplit('@').next().unwrap_or(rest);
after_user
.split([':', '/'])
.next()
.filter(|h| !h.is_empty())
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct ForgePr {
pub number: u64,
pub title: String,
pub state: ForgePrState,
pub source_branch: String,
pub target_branch: String,
pub url: String,
pub draft: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum ForgePrState {
Open,
Closed,
Merged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct ForgeRepo {
pub name: String,
pub owner: String,
pub default_branch: String,
pub url: String,
pub private: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct ForgeIssue {
pub number: u64,
pub title: String,
pub state: ForgeIssueState,
pub body: String,
pub url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum ForgeIssueState {
Open,
Closed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct ForgeRelease {
pub tag: String,
pub title: String,
pub url: String,
pub published_at: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum CiStatus {
Passing,
Failing,
Pending,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct PrCreate {
pub title: String,
pub body: String,
pub source: Option<String>,
pub target: Option<String>,
}
impl PrCreate {
pub fn new(title: impl Into<String>, body: impl Into<String>) -> Self {
Self {
title: title.into(),
body: body.into(),
source: None,
target: None,
}
}
pub fn source(mut self, branch: impl Into<String>) -> Self {
self.source = Some(branch.into());
self
}
pub fn target(mut self, branch: impl Into<String>) -> Self {
self.target = Some(branch.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub enum MergeStrategy {
Merge,
Squash,
Rebase,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_remote_url_classifies_saas_hosts() {
use ForgeKind::*;
for (url, want) in [
("https://github.com/o/r.git", Some(GitHub)),
("git@github.com:o/r.git", Some(GitHub)),
("https://foo.github.com/o/r", Some(GitHub)), ("https://gitlab.com/o/r", Some(GitLab)),
("https://user:pass@gitlab.com/o/r", Some(GitLab)), ("ssh://git@gitlab.com:22/o/r.git", Some(GitLab)),
("https://gitea.com/o/r.git", Some(Gitea)),
("git@codeberg.org:o/r.git", Some(Gitea)),
("https://docs.codeberg.org/o/r", Some(Gitea)), ] {
assert_eq!(ForgeKind::from_remote_url(url), want, "{url}");
}
}
#[test]
fn from_remote_url_rejects_self_hosted_and_lookalikes() {
for url in [
"https://gitlab.example.com/o/r.git", "https://gitea.example.org/o/r.git", "https://git.acme.io/o/r.git", "https://gitlab.com.attacker.net/o/r", "git@gitlab.attacker.com:o/r.git", "https://my-gitea-host.evil.com/o/r", "https://notgithub.com/o/r", "https://github.com.evil.example/o/r", "",
] {
assert_eq!(ForgeKind::from_remote_url(url), None, "{url}");
}
}
#[test]
fn as_str_maps_each_kind() {
assert_eq!(ForgeKind::GitHub.as_str(), "github");
assert_eq!(ForgeKind::GitLab.as_str(), "gitlab");
assert_eq!(ForgeKind::Gitea.as_str(), "gitea");
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn url_around(host: impl Strategy<Value = String>) -> impl Strategy<Value = String> {
host.prop_flat_map(|h| {
prop_oneof![
Just(format!("https://{h}/o/r.git")),
Just(format!("https://user:pass@{h}/o/r")),
Just(format!("ssh://git@{h}:22/o/r.git")),
Just(format!("git@{h}:o/r.git")),
Just(format!("{h}/o/r")),
]
})
}
fn lookalike_host() -> impl Strategy<Value = String> {
let trusted = || {
prop_oneof![
Just("github.com"),
Just("gitlab.com"),
Just("gitea.com"),
Just("codeberg.org"),
]
};
let evil = || "[a-z]{1,8}\\.(net|io|dev|xyz)";
prop_oneof![
(trusted(), evil()).prop_map(|(t, e)| format!("{t}.{e}")),
(prop_oneof![Just("not"), Just("my"), Just("x")], trusted())
.prop_map(|(p, t)| format!("{p}{t}")),
(evil(), trusted()).prop_map(|(e, t)| format!("x.{t}.{e}")),
]
}
proptest! {
#[test]
fn from_remote_url_never_panics(s in any::<String>()) {
let _ = ForgeKind::from_remote_url(&s);
}
#[test]
fn from_remote_url_rejects_lookalikes(url in url_around(lookalike_host())) {
prop_assert_eq!(
ForgeKind::from_remote_url(&url),
None,
"lookalike must not classify: {}",
url
);
}
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_tests {
use super::*;
#[test]
fn forge_pr_serializes_to_clean_json() {
let pr = ForgePr {
number: 7,
title: "Add X".into(),
state: ForgePrState::Merged,
source_branch: "feat".into(),
target_branch: "main".into(),
url: "u".into(),
draft: false,
};
let v = serde_json::to_value(&pr).unwrap();
assert_eq!(v["number"], 7);
assert_eq!(v["state"], "Merged"); assert_eq!(v["source_branch"], "feat");
}
#[test]
fn issue_release_and_pr_create_serialize_to_clean_json() {
let issue = ForgeIssue {
number: 3,
title: "Bug".into(),
state: ForgeIssueState::Closed,
body: "b".into(),
url: "u".into(),
};
let v = serde_json::to_value(&issue).unwrap();
assert_eq!(v["number"], 3);
assert_eq!(v["state"], "Closed");
assert_eq!(v["body"], "b");
let release = ForgeRelease {
tag: "v1".into(),
title: "One".into(),
url: "u".into(),
published_at: None,
};
let v = serde_json::to_value(&release).unwrap();
assert_eq!(v["tag"], "v1");
assert!(v["published_at"].is_null(), "draft date must be null");
let spec = PrCreate::new("T", "B").source("feat");
let v = serde_json::to_value(&spec).unwrap();
assert_eq!(v["title"], "T");
assert_eq!(v["source"], "feat");
assert!(v["target"].is_null());
}
}