vcs-forge 0.1.0

A backend-agnostic facade over the GitHub/GitLab/Gitea CLI wrappers (gh/glab/tea).
Documentation
//! GitLab-backed implementations of the facade operations: thin calls to the
//! `vcs-gitlab` client plus pure mappers from its types into the unified DTOs.

use std::path::Path;

use processkit::ProcessRunner;
use vcs_gitlab::{
    CiStatus as GlCi, GitLab, GitLabApi, Issue, MergeRequest, MergeStrategy as GlMs, MrCreate,
    Project, Release,
};

use crate::dto::{
    CiStatus, ForgeIssue, ForgeIssueState, ForgePr, ForgePrState, ForgeRelease, ForgeRepo,
    MergeStrategy, PrCreate,
};
use crate::error::Result;

pub(crate) async fn auth_status<R: ProcessRunner>(glab: &GitLab<R>) -> Result<bool> {
    Ok(glab.auth_status().await?)
}

pub(crate) async fn repo_view<R: ProcessRunner>(glab: &GitLab<R>, dir: &Path) -> Result<ForgeRepo> {
    Ok(map_project(glab.repo_view(dir).await?))
}

pub(crate) async fn pr_list<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
) -> Result<Vec<ForgePr>> {
    Ok(glab.mr_list(dir).await?.into_iter().map(map_mr).collect())
}

pub(crate) async fn pr_view<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
    number: u64,
) -> Result<ForgePr> {
    Ok(map_mr(glab.mr_view(dir, number).await?))
}

pub(crate) async fn pr_create<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
    spec: PrCreate,
) -> Result<String> {
    // The unified source/target ARE glab's naming — a 1:1 field map.
    let mut create = MrCreate::new(spec.title, spec.body);
    if let Some(source) = spec.source {
        create = create.source(source);
    }
    if let Some(target) = spec.target {
        create = create.target(target);
    }
    Ok(glab.mr_create(dir, create).await?)
}

pub(crate) async fn pr_merge<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
    number: u64,
    strategy: MergeStrategy,
) -> Result<()> {
    let ms = match strategy {
        MergeStrategy::Merge => GlMs::Merge,
        MergeStrategy::Squash => GlMs::Squash,
        MergeStrategy::Rebase => GlMs::Rebase,
    };
    glab.mr_merge(dir, number, ms).await?;
    Ok(())
}

pub(crate) async fn pr_mark_ready<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
    number: u64,
) -> Result<()> {
    glab.mr_ready(dir, number).await?;
    Ok(())
}

// `delete_branch` has no `glab mr close` equivalent (GitLab honours the MR's own
// "delete source branch" setting on merge, not on close), so it is ignored here.
pub(crate) async fn pr_close<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
    number: u64,
) -> Result<()> {
    glab.mr_close(dir, number).await?;
    Ok(())
}

pub(crate) async fn pr_checks<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
    number: u64,
) -> Result<CiStatus> {
    Ok(map_ci(glab.mr_checks(dir, number).await?))
}

pub(crate) async fn issue_list<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
) -> Result<Vec<ForgeIssue>> {
    Ok(glab
        .issue_list(dir)
        .await?
        .into_iter()
        .map(map_issue)
        .collect())
}

pub(crate) async fn issue_view<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
    number: u64,
) -> Result<ForgeIssue> {
    Ok(map_issue(glab.issue_view(dir, number).await?))
}

pub(crate) async fn issue_create<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
    title: &str,
    body: &str,
) -> Result<String> {
    Ok(glab.issue_create(dir, title, body).await?)
}

pub(crate) async fn release_list<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
) -> Result<Vec<ForgeRelease>> {
    Ok(glab
        .release_list(dir)
        .await?
        .into_iter()
        .map(map_release)
        .collect())
}

pub(crate) async fn release_view<R: ProcessRunner>(
    glab: &GitLab<R>,
    dir: &Path,
    tag: &str,
) -> Result<ForgeRelease> {
    Ok(map_release(glab.release_view(dir, tag).await?))
}

fn map_issue(i: Issue) -> ForgeIssue {
    ForgeIssue {
        number: i.number,
        title: i.title,
        state: issue_state_of(&i.state),
        body: i.body,
        url: i.url,
    }
}

fn issue_state_of(state: &str) -> ForgeIssueState {
    // GitLab spells it "closed" (note: open is "opened"); anything unknown
    // reads as live (Open), the same documented fallback as `state_of` below.
    if state.eq_ignore_ascii_case("closed") {
        ForgeIssueState::Closed
    } else {
        ForgeIssueState::Open
    }
}

fn map_release(r: Release) -> ForgeRelease {
    ForgeRelease {
        tag: r.tag_name,
        title: r.name,
        url: r.url,
        // An empty `released_at` (unpublished/upcoming release) surfaces as None.
        published_at: Some(r.published_at).filter(|s| !s.is_empty()),
    }
}

fn map_mr(mr: MergeRequest) -> ForgePr {
    ForgePr {
        number: mr.iid,
        state: state_of(&mr.state),
        title: mr.title,
        source_branch: mr.source_branch,
        target_branch: mr.target_branch,
        url: mr.web_url,
        draft: mr.draft,
    }
}

fn state_of(state: &str) -> ForgePrState {
    // GitLab REST emits lowercase, but match case-insensitively for parity with
    // the GitHub/Gitea mappers (and robustness to a future shape change).
    match state.to_ascii_lowercase().as_str() {
        "merged" => ForgePrState::Merged,
        "closed" | "locked" => ForgePrState::Closed,
        _ => ForgePrState::Open,
    }
}

fn map_project(p: Project) -> ForgeRepo {
    // GitLab has no separate "owner" — split the namespace path: everything
    // before the last `/` is the owner, the last segment the project slug.
    let owner = p
        .path_with_namespace
        .rsplit_once('/')
        .map(|(ns, _)| ns.to_string())
        .unwrap_or_default();
    ForgeRepo {
        name: p.name,
        owner,
        default_branch: p.default_branch,
        url: p.web_url,
        // Conservative: only claim privacy when the visibility is *known* and not
        // "public". An absent visibility (`None`) is unknown, so it maps to
        // `false` (public) — we never assert a privacy we can't prove.
        private: p.visibility.as_deref().is_some_and(|v| v != "public"),
    }
}

fn map_ci(c: GlCi) -> CiStatus {
    match c {
        GlCi::Passing => CiStatus::Passing,
        GlCi::Failing => CiStatus::Failing,
        GlCi::Pending => CiStatus::Pending,
        GlCi::None => CiStatus::None,
        // `vcs_gitlab::CiStatus` is `#[non_exhaustive]`; map any future bucket
        // conservatively to "not known to be done".
        _ => CiStatus::Pending,
    }
}

// `state_of` is private; the proptest lives in-module where it's visible. The
// mapper must never panic on an arbitrary state string, and an UNKNOWN state
// must default to `Open` (the documented fallback) — so a future GitLab state
// we don't model is treated as live, never silently as closed/merged.
#[cfg(test)]
mod proptests {
    use super::*;
    use proptest::prelude::*;

    proptest! {
        // Same contract for issues: only "closed" (any case) maps off Open —
        // GitLab's "opened" and any future state both read as live.
        #[test]
        fn issue_state_mapping_never_panics_and_unknowns_default(s in any::<String>()) {
            let mapped = issue_state_of(&s);
            if s.eq_ignore_ascii_case("closed") {
                prop_assert_eq!(mapped, ForgeIssueState::Closed);
            } else {
                prop_assert_eq!(mapped, ForgeIssueState::Open, "unknown must default to Open: {:?}", s);
            }
        }

        #[test]
        fn pr_state_mapping_never_panics_and_unknowns_default(s in any::<String>()) {
            let mapped = state_of(&s);
            // The only inputs that map off `Open` are the known states
            // (case-insensitively); everything else must default to `Open`.
            match s.to_ascii_lowercase().as_str() {
                "merged" => prop_assert_eq!(mapped, ForgePrState::Merged),
                "closed" | "locked" => prop_assert_eq!(mapped, ForgePrState::Closed),
                _ => prop_assert_eq!(mapped, ForgePrState::Open, "unknown must default to Open: {:?}", s),
            }
        }
    }
}