#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use processkit::{JobRunner, ProcessRunner};
use vcs_gitea::Gitea;
use vcs_github::GitHub;
use vcs_gitlab::GitLab;
mod dto;
mod error;
mod gitea_forge;
mod github_forge;
mod gitlab_forge;
pub use dto::{
CiStatus, ForgeIssue, ForgeIssueState, ForgeKind, ForgePr, ForgePrState, ForgeRelease,
ForgeRepo, MergeStrategy, PrCreate,
};
pub use error::{Error, Result};
pub use vcs_gitea;
pub use vcs_github;
pub use vcs_gitlab;
#[cfg(feature = "cancellation")]
#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
pub use processkit::CancellationToken;
enum Backend<R: ProcessRunner> {
GitHub(Arc<GitHub<R>>),
GitLab(Arc<GitLab<R>>),
Gitea(Arc<Gitea<R>>),
}
impl<R: ProcessRunner> Backend<R> {
fn shared(&self) -> Self {
match self {
Backend::GitHub(c) => Backend::GitHub(Arc::clone(c)),
Backend::GitLab(c) => Backend::GitLab(Arc::clone(c)),
Backend::Gitea(c) => Backend::Gitea(Arc::clone(c)),
}
}
}
pub struct Forge<R: ProcessRunner = JobRunner> {
cwd: PathBuf,
backend: Backend<R>,
}
impl Forge<JobRunner> {
pub fn github(cwd: impl Into<PathBuf>) -> Self {
Forge {
cwd: cwd.into(),
backend: Backend::GitHub(Arc::new(GitHub::new())),
}
}
pub fn gitlab(cwd: impl Into<PathBuf>) -> Self {
Forge {
cwd: cwd.into(),
backend: Backend::GitLab(Arc::new(GitLab::new())),
}
}
pub fn gitea(cwd: impl Into<PathBuf>) -> Self {
Forge {
cwd: cwd.into(),
backend: Backend::Gitea(Arc::new(Gitea::new())),
}
}
}
impl<R: ProcessRunner> Forge<R> {
pub fn for_github(cwd: impl Into<PathBuf>, client: GitHub<R>) -> Self {
Forge {
cwd: cwd.into(),
backend: Backend::GitHub(Arc::new(client)),
}
}
pub fn for_gitlab(cwd: impl Into<PathBuf>, client: GitLab<R>) -> Self {
Forge {
cwd: cwd.into(),
backend: Backend::GitLab(Arc::new(client)),
}
}
pub fn for_gitea(cwd: impl Into<PathBuf>, client: Gitea<R>) -> Self {
Forge {
cwd: cwd.into(),
backend: Backend::Gitea(Arc::new(client)),
}
}
pub fn kind(&self) -> ForgeKind {
match &self.backend {
Backend::GitHub(_) => ForgeKind::GitHub,
Backend::GitLab(_) => ForgeKind::GitLab,
Backend::Gitea(_) => ForgeKind::Gitea,
}
}
pub fn cwd(&self) -> &Path {
&self.cwd
}
pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
Forge {
cwd: dir.into(),
backend: self.backend.shared(),
}
}
pub async fn auth_status(&self) -> Result<bool> {
match &self.backend {
Backend::GitHub(c) => github_forge::auth_status(c).await,
Backend::GitLab(c) => gitlab_forge::auth_status(c).await,
Backend::Gitea(c) => gitea_forge::auth_status(c).await,
}
}
pub async fn repo_view(&self) -> Result<ForgeRepo> {
match &self.backend {
Backend::GitHub(c) => github_forge::repo_view(c, &self.cwd).await,
Backend::GitLab(c) => gitlab_forge::repo_view(c, &self.cwd).await,
Backend::Gitea(_) => Err(unsupported(ForgeKind::Gitea, "repo_view")),
}
}
pub async fn pr_list(&self) -> Result<Vec<ForgePr>> {
match &self.backend {
Backend::GitHub(c) => github_forge::pr_list(c, &self.cwd).await,
Backend::GitLab(c) => gitlab_forge::pr_list(c, &self.cwd).await,
Backend::Gitea(c) => gitea_forge::pr_list(c, &self.cwd).await,
}
}
pub async fn pr_view(&self, number: u64) -> Result<ForgePr> {
match &self.backend {
Backend::GitHub(c) => github_forge::pr_view(c, &self.cwd, number).await,
Backend::GitLab(c) => gitlab_forge::pr_view(c, &self.cwd, number).await,
Backend::Gitea(c) => gitea_forge::pr_view(c, &self.cwd, number).await,
}
}
pub async fn pr_create(&self, spec: PrCreate) -> Result<String> {
match &self.backend {
Backend::GitHub(c) => github_forge::pr_create(c, &self.cwd, spec).await,
Backend::GitLab(c) => gitlab_forge::pr_create(c, &self.cwd, spec).await,
Backend::Gitea(c) => gitea_forge::pr_create(c, &self.cwd, spec).await,
}
}
pub async fn pr_merge(&self, number: u64, strategy: MergeStrategy) -> Result<()> {
match &self.backend {
Backend::GitHub(c) => github_forge::pr_merge(c, &self.cwd, number, strategy).await,
Backend::GitLab(c) => gitlab_forge::pr_merge(c, &self.cwd, number, strategy).await,
Backend::Gitea(c) => gitea_forge::pr_merge(c, &self.cwd, number, strategy).await,
}
}
pub async fn pr_mark_ready(&self, number: u64) -> Result<()> {
match &self.backend {
Backend::GitHub(c) => github_forge::pr_mark_ready(c, &self.cwd, number).await,
Backend::GitLab(c) => gitlab_forge::pr_mark_ready(c, &self.cwd, number).await,
Backend::Gitea(_) => Err(unsupported(ForgeKind::Gitea, "pr_mark_ready")),
}
}
pub async fn pr_close(&self, number: u64, delete_branch: bool) -> Result<()> {
match &self.backend {
Backend::GitHub(c) => github_forge::pr_close(c, &self.cwd, number, delete_branch).await,
Backend::GitLab(c) => gitlab_forge::pr_close(c, &self.cwd, number).await,
Backend::Gitea(c) => gitea_forge::pr_close(c, &self.cwd, number).await,
}
}
pub async fn pr_checks(&self, number: u64) -> Result<CiStatus> {
match &self.backend {
Backend::GitHub(c) => github_forge::pr_checks(c, &self.cwd, number).await,
Backend::GitLab(c) => gitlab_forge::pr_checks(c, &self.cwd, number).await,
Backend::Gitea(_) => Err(unsupported(ForgeKind::Gitea, "pr_checks")),
}
}
pub async fn issue_list(&self) -> Result<Vec<ForgeIssue>> {
match &self.backend {
Backend::GitHub(c) => github_forge::issue_list(c, &self.cwd).await,
Backend::GitLab(c) => gitlab_forge::issue_list(c, &self.cwd).await,
Backend::Gitea(c) => gitea_forge::issue_list(c, &self.cwd).await,
}
}
pub async fn issue_view(&self, number: u64) -> Result<ForgeIssue> {
match &self.backend {
Backend::GitHub(c) => github_forge::issue_view(c, &self.cwd, number).await,
Backend::GitLab(c) => gitlab_forge::issue_view(c, &self.cwd, number).await,
Backend::Gitea(c) => gitea_forge::issue_view(c, &self.cwd, number).await,
}
}
pub async fn issue_create(&self, title: &str, body: &str) -> Result<String> {
match &self.backend {
Backend::GitHub(c) => github_forge::issue_create(c, &self.cwd, title, body).await,
Backend::GitLab(c) => gitlab_forge::issue_create(c, &self.cwd, title, body).await,
Backend::Gitea(c) => gitea_forge::issue_create(c, &self.cwd, title, body).await,
}
}
pub async fn release_list(&self) -> Result<Vec<ForgeRelease>> {
match &self.backend {
Backend::GitHub(c) => github_forge::release_list(c, &self.cwd).await,
Backend::GitLab(c) => gitlab_forge::release_list(c, &self.cwd).await,
Backend::Gitea(c) => gitea_forge::release_list(c, &self.cwd).await,
}
}
pub async fn release_view(&self, tag: &str) -> Result<ForgeRelease> {
match &self.backend {
Backend::GitHub(c) => github_forge::release_view(c, &self.cwd, tag).await,
Backend::GitLab(c) => gitlab_forge::release_view(c, &self.cwd, tag).await,
Backend::Gitea(_) => Err(unsupported(ForgeKind::Gitea, "release_view")),
}
}
}
fn unsupported(forge: ForgeKind, operation: &'static str) -> Error {
Error::Unsupported { forge, operation }
}
macro_rules! facade_trait {
(
$(#[doc = $tdoc:expr])*
trait $Trait:ident for $Ty:ident;
sync {
$( #[doc = $sdoc:expr] fn $sn:ident( $($sa:ident: $sat:ty),* $(,)? ) -> $sr:ty; )*
}
async {
$( fn $an:ident( $($aa:ident: $aat:ty),* $(,)? ) -> $ar:ty; )*
}
) => {
$(#[doc = $tdoc])*
#[async_trait::async_trait]
pub trait $Trait: Send + Sync {
$(
#[doc = $sdoc]
fn $sn(&self, $($sa: $sat),*) -> $sr;
)*
$(
#[doc = concat!("See [`", stringify!($Ty), "::", stringify!($an), "`].")]
async fn $an(&self, $($aa: $aat),*) -> $ar;
)*
}
#[async_trait::async_trait]
impl<R: ProcessRunner> $Trait for $Ty<R> {
$(
fn $sn(&self, $($sa: $sat),*) -> $sr {
self.$sn($($sa),*)
}
)*
$(
async fn $an(&self, $($aa: $aat),*) -> $ar {
self.$an($($aa),*).await
}
)*
}
};
}
facade_trait! {
trait ForgeApi for Forge;
sync {
#[doc = "Which forge drives this handle."]
fn kind() -> ForgeKind;
#[doc = "The directory operations run against."]
fn cwd() -> &Path;
}
async {
fn auth_status() -> Result<bool>;
fn repo_view() -> Result<ForgeRepo>;
fn pr_list() -> Result<Vec<ForgePr>>;
fn pr_view(number: u64) -> Result<ForgePr>;
fn pr_create(spec: PrCreate) -> Result<String>;
fn pr_merge(number: u64, strategy: MergeStrategy) -> Result<()>;
fn pr_mark_ready(number: u64) -> Result<()>;
fn pr_close(number: u64, delete_branch: bool) -> Result<()>;
fn pr_checks(number: u64) -> Result<CiStatus>;
fn issue_list() -> Result<Vec<ForgeIssue>>;
fn issue_view(number: u64) -> Result<ForgeIssue>;
fn issue_create(title: &str, body: &str) -> Result<String>;
fn release_list() -> Result<Vec<ForgeRelease>>;
fn release_view(tag: &str) -> Result<ForgeRelease>;
}
}
#[cfg(test)]
mod tests {
use super::*;
use processkit::{RecordingRunner, Reply, ScriptedRunner};
fn github(runner: ScriptedRunner) -> Forge<ScriptedRunner> {
Forge::for_github("/repo", GitHub::with_runner(runner))
}
fn gitlab(runner: ScriptedRunner) -> Forge<ScriptedRunner> {
Forge::for_gitlab("/repo", GitLab::with_runner(runner))
}
fn gitea(runner: ScriptedRunner) -> Forge<ScriptedRunner> {
Forge::for_gitea("/repo", Gitea::with_runner(runner))
}
#[tokio::test]
async fn kind_reflects_backend() {
assert_eq!(github(ScriptedRunner::new()).kind(), ForgeKind::GitHub);
assert_eq!(gitlab(ScriptedRunner::new()).kind(), ForgeKind::GitLab);
assert_eq!(gitea(ScriptedRunner::new()).kind(), ForgeKind::Gitea);
}
#[tokio::test]
async fn github_pr_list_maps_to_unified() {
let json = r#"[{"number":7,"title":"X","state":"MERGED","headRefName":"feat","baseRefName":"main","url":"u"}]"#;
let forge = github(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
let prs = forge.pr_list().await.unwrap();
assert_eq!(prs[0].number, 7);
assert_eq!(prs[0].state, ForgePrState::Merged);
assert_eq!(prs[0].source_branch, "feat");
}
#[tokio::test]
async fn gitlab_repo_view_maps_public_visibility() {
let json = r#"{"name":"cli","path_with_namespace":"gitlab-org/cli","default_branch":"main","web_url":"u","visibility":"public"}"#;
let forge = gitlab(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
let repo = forge.repo_view().await.unwrap();
assert_eq!(repo.owner, "gitlab-org");
assert_eq!(repo.name, "cli");
assert!(!repo.private);
}
#[tokio::test]
async fn gitlab_repo_view_absent_visibility_is_not_private() {
let json =
r#"{"name":"cli","path_with_namespace":"o/cli","default_branch":"main","web_url":"u"}"#;
let forge = gitlab(ScriptedRunner::new().on(["repo", "view"], Reply::ok(json)));
let repo = forge.repo_view().await.unwrap();
assert!(!repo.private, "absent visibility must not be private");
}
#[tokio::test]
async fn gitlab_pr_list_maps_iid_and_state() {
let json = r#"[{"iid":12,"title":"X","state":"opened","source_branch":"feat","target_branch":"main","web_url":"u","draft":true}]"#;
let forge = gitlab(ScriptedRunner::new().on(["mr", "list"], Reply::ok(json)));
let prs = forge.pr_list().await.unwrap();
assert_eq!(prs[0].number, 12);
assert_eq!(prs[0].state, ForgePrState::Open);
assert!(prs[0].draft);
}
#[tokio::test]
async fn gitea_pr_view_filters_and_maps_merged() {
let json =
r#"[{"index":"9","title":"Nine","state":"merged","head":"f","base":"main","url":"u"}]"#;
let forge = gitea(ScriptedRunner::new().on(["pr", "list"], Reply::ok(json)));
let pr = forge.pr_view(9).await.unwrap();
assert_eq!(pr.state, ForgePrState::Merged);
assert_eq!(pr.target_branch, "main");
}
#[tokio::test]
async fn gitea_unsupported_ops_error_without_spawning() {
let rec = RecordingRunner::replying(Reply::ok(""));
let forge = Forge::for_gitea("/repo", Gitea::with_runner(&rec));
for err in [
forge.repo_view().await.unwrap_err(),
forge.pr_mark_ready(1).await.unwrap_err(),
forge.pr_checks(1).await.unwrap_err(),
forge.release_view("v1.0.0").await.unwrap_err(),
] {
assert!(err.is_unsupported(), "{err:?}");
}
assert!(rec.calls().is_empty(), "unsupported ops must not spawn");
}
#[tokio::test]
async fn issue_list_maps_states_per_backend() {
let json = r#"[{"number":3,"title":"A","state":"OPEN"},{"number":4,"title":"B","state":"CLOSED"}]"#;
let forge = github(ScriptedRunner::new().on(["issue", "list"], Reply::ok(json)));
let issues = forge.issue_list().await.unwrap();
assert_eq!(issues[0].state, ForgeIssueState::Open);
assert_eq!(issues[1].state, ForgeIssueState::Closed);
let json = r#"[{"iid":12,"title":"X","state":"opened","description":"d","web_url":"u"}]"#;
let forge = gitlab(ScriptedRunner::new().on(["issue", "list"], Reply::ok(json)));
let issues = forge.issue_list().await.unwrap();
assert_eq!(issues[0].number, 12);
assert_eq!(issues[0].state, ForgeIssueState::Open);
assert_eq!(issues[0].body, "d");
let json = r#"[{"index":"9","title":"Y","state":"open","body":"b","url":"u"}]"#;
let forge = gitea(ScriptedRunner::new().on(["issues", "list"], Reply::ok(json)));
let issues = forge.issue_list().await.unwrap();
assert_eq!(issues[0].number, 9);
assert_eq!(issues[0].state, ForgeIssueState::Open);
}
#[tokio::test]
async fn release_list_maps_published_at_per_backend() {
let json = r#"[{"tagName":"v1","name":"One","publishedAt":"2026-01-01T00:00:00Z"},{"tagName":"v2-draft","name":"","publishedAt":"","isDraft":true}]"#;
let forge = github(ScriptedRunner::new().on(["release", "list"], Reply::ok(json)));
let rels = forge.release_list().await.unwrap();
assert_eq!(rels[0].tag, "v1");
assert_eq!(
rels[0].published_at.as_deref(),
Some("2026-01-01T00:00:00Z")
);
assert_eq!(rels[1].published_at, None);
let json = r#"[{"tag_name":"v1","name":"One","released_at":"2026-01-01T00:00:00Z","_links":{"self":"u"}}]"#;
let forge = gitlab(ScriptedRunner::new().on(["release", "list"], Reply::ok(json)));
let rels = forge.release_list().await.unwrap();
assert_eq!(rels[0].url, "u");
assert!(rels[0].published_at.is_some());
let json = r#"[{"tag-_name":"v1","title":"One","status":"released","published _at":"2026-01-01T00:00:00Z"}]"#;
let forge = gitea(ScriptedRunner::new().on(["releases", "list"], Reply::ok(json)));
let rels = forge.release_list().await.unwrap();
assert_eq!(rels[0].tag, "v1");
assert_eq!(rels[0].title, "One");
assert_eq!(rels[0].url, ""); assert!(rels[0].published_at.is_some());
}
#[tokio::test]
async fn pr_merge_maps_strategy_per_backend() {
let rec = RecordingRunner::replying(Reply::ok(""));
Forge::for_github("/repo", GitHub::with_runner(&rec))
.pr_merge(5, MergeStrategy::Squash)
.await
.unwrap();
assert_eq!(rec.only_call().args_str(), ["pr", "merge", "5", "--squash"]);
let rec = RecordingRunner::replying(Reply::ok(""));
Forge::for_gitlab("/repo", GitLab::with_runner(&rec))
.pr_merge(5, MergeStrategy::Rebase)
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
[
"mr",
"merge",
"5",
"--yes",
"--auto-merge=false",
"--rebase"
]
);
let rec = RecordingRunner::replying(Reply::ok(""));
Forge::for_gitea("/repo", Gitea::with_runner(&rec))
.pr_merge(5, MergeStrategy::Merge)
.await
.unwrap();
assert_eq!(
rec.only_call().args_str(),
["pr", "merge", "5", "--style", "merge"]
);
}
#[tokio::test]
async fn github_pr_checks_aggregates_buckets() {
let json = r#"[{"name":"a","bucket":"pass"},{"name":"b","bucket":"fail"}]"#;
let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok(json)));
assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::Failing);
let json = r#"[{"name":"a","bucket":"pass"},{"name":"b","bucket":"pending"}]"#;
let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok(json)));
assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::Pending);
let json = r#"[{"name":"a","bucket":"pass"},{"name":"b","bucket":"cancel"}]"#;
let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok(json)));
assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::Failing);
let json = r#"[{"name":"a","bucket":"skipping"}]"#;
let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok(json)));
assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::None);
let forge = github(ScriptedRunner::new().on(["pr", "checks"], Reply::ok("[]")));
assert_eq!(forge.pr_checks(1).await.unwrap(), CiStatus::None);
}
#[tokio::test]
async fn at_rebinds_cwd_and_shares_backend() {
let forge = github(ScriptedRunner::new());
let moved = forge.at("/repo/sub");
assert_eq!(moved.cwd(), Path::new("/repo/sub"));
assert_eq!(moved.kind(), ForgeKind::GitHub);
}
#[tokio::test]
async fn forge_api_trait_object_dispatches() {
let json = r#"[{"iid":1,"title":"X","state":"opened","source_branch":"f","target_branch":"main","web_url":"u"}]"#;
let forge = gitlab(
ScriptedRunner::new()
.on(["mr", "list"], Reply::ok(json))
.on(["issue", "create"], Reply::ok("https://gl/i/9\n")),
);
let dynamic: &dyn ForgeApi = &forge;
assert_eq!(dynamic.kind(), ForgeKind::GitLab);
assert_eq!(dynamic.pr_list().await.unwrap()[0].number, 1);
assert_eq!(
dynamic.issue_create("T", "B").await.unwrap(),
"https://gl/i/9"
);
}
}
#[doc = include_str!("../docs/forge.md")]
#[allow(rustdoc::broken_intra_doc_links)]
pub mod guide {}