Skip to main content

Module guide

Module guide 

Source
Expand description

§vcs-forge — the forge facade

vcs-forge is a forge-agnostic facade over vcs-github, vcs-gitlab, and vcs-gitea — the gh/glab/tea analogue of how vcs-core sits over git and jj. A Forge handle dispatches the common forge operations to whichever CLI backs it and returns unified DTOs, so a tool can target “the forge” instead of one specifically.

Consumers can hold a &dyn ForgeApi to stay generic over the runner; build a Forge over a fake runner for hermetic tests.

§No auto-detection — construct explicitly

A repository has a filesystem marker (.git/.jj) that vcs-core detects; a forge does not — it’s identified by the remote host. So a Forge is built explicitly:

use vcs_forge::{Forge, ForgeApi};

let forge = Forge::github(".");   // or ::gitlab(".") / ::gitea(".")

ForgeKind::from_remote_url is a pure, best-effort helper for picking the kind from a remote URL you already hold (e.g. from a vcs_core::Repo):

use vcs_forge::{Forge, ForgeKind};

let forge = match ForgeKind::from_remote_url(url) {
    Some(ForgeKind::GitLab) => Forge::gitlab("."),
    Some(ForgeKind::Gitea)  => Forge::gitea("."),
    _                       => Forge::github("."), // github.com or unknown
};

It recognises the public SaaS hosts — github.com, gitlab.com, gitea.com, codeberg.org, and their proper subdomains — with an anchored match, so a lookalike like gitlab.com.attacker.net returns None, not GitLab. A self-hosted instance on an arbitrary domain also returns None (indistinguishable by host alone — pick the kind yourself).

Forge::for_github(cwd, client) / for_gitlab / for_gitea take an explicit client (the test seam); forge.at(dir) re-binds the cwd, sharing the client.

§Operations

pub async fn auth_status(&self)  -> Result<bool>;
pub async fn repo_view(&self)    -> Result<ForgeRepo>;
pub async fn pr_list(&self)      -> Result<Vec<ForgePr>>;
pub async fn pr_view(&self, number: u64) -> Result<ForgePr>;
pub async fn pr_create(&self, spec: PrCreate) -> Result<String>;
pub async fn pr_merge(&self, number: u64, strategy: MergeStrategy) -> Result<()>;
pub async fn pr_mark_ready(&self, number: u64) -> Result<()>;
pub async fn pr_close(&self, number: u64, delete_branch: bool) -> Result<()>;
pub async fn pr_checks(&self, number: u64) -> Result<CiStatus>;
pub async fn issue_list(&self)   -> Result<Vec<ForgeIssue>>;
pub async fn issue_view(&self, number: u64) -> Result<ForgeIssue>;
pub async fn issue_create(&self, title: &str, body: &str) -> Result<String>;
pub async fn release_list(&self) -> Result<Vec<ForgeRelease>>;
pub async fn release_view(&self, tag: &str) -> Result<ForgeRelease>;

PrCreate is the unified open-a-PR/MR spec — PrCreate::new(title, body).source(branch).target(branch), where source defaults to the current branch and target to the repo default; the facade maps them to each CLI’s own flags (gh/tea --head/--base, glab --source-branch/--target-branch).

Every method mirrors an inherent method on Forge; the object-safe ForgeApi trait adds nothing but the &dyn boundary.

§Unified DTOs

ForgePr generalises GitHub’s PR, GitLab’s MR, and Gitea’s PR: number (the id each CLI takes — GitLab’s iid), title, state (ForgePrState), source_branch, target_branch, url, draft.

State normalisation (ForgePrState):

Forge“open”“closed”“merged”
GitHubOPENCLOSEDMERGED
GitLabopenedclosed / lockedmerged
Giteastate="open"state="closed"merged=true

ForgeRepo is name / owner / default_branch / url / private (GitLab’s owner is the namespace path). CiStatus is Passing / Failing / Pending / None — GitHub aggregates its per-check buckets into it, GitLab maps its pipeline status. MergeStrategy (Merge / Squash / Rebase) maps to each CLI’s flag.

draft is best-effort: only GitLab reports it on the lean surface; GitHub and Gitea report false (their lean JSON doesn’t carry the flag).

ForgeIssue generalises the three issue shapes: number (GitLab’s iid), title, state (ForgeIssueStateClosed for any case of “closed”, everything else reads as Open, so an unmodelled state is treated as live), body, url. body/url are best-effort: GitHub’s lean issue_list doesn’t fetch them (empty there); issue_view fills them on every forge.

ForgeRelease is tag / title / url / published_at: Option<String> (None for an unpublished draft or when the backend doesn’t report one). The url is best-effort: empty from GitHub’s lean release_list (filled by release_view), and always empty on Giteatea releases list exposes no release-page URL at all (only a tar/zip download URL, deliberately not surfaced), and tea has no release_view.

§Capability matrix

The CLIs differ in coverage. Gitea’s tea lacks four operations, which return [Error::Unsupported { forge, operation }] (the call does not spawn); delete_branch on pr_close is GitHub-only.

OperationGitHubGitLabGitea
auth_status / pr_list / pr_view / pr_create / pr_merge / pr_close
issue_list / issue_view / issue_create / release_list
repo_view❌ Unsupported
pr_mark_ready❌ Unsupported
pr_checks❌ Unsupported
release_view❌ Unsupported (tea releases only lists — filter release_list)
pr_close honours delete_branchignoredignored
pr_create / issue_create return the URLtextual summary (tea ends issue create output with the URL; pr create prints none)
pr_list / issue_list / release_list result cap (explicit, documented)100100100
match forge.pr_checks(7).await {
    Ok(status) => println!("CI: {status:?}"),
    Err(e) if e.is_unsupported() => println!("this forge has no checks command"),
    Err(e) => eprintln!("{e}"),
}

Error is Forge(processkit::Error) or Unsupported { forge, operation }, with is_unsupported() and is_transient_fetch_error() classifiers.

§When to drop to the wrapped client (the escape hatch)

The facade carries the portable intersection; the wrappers are re-exported (vcs_forge::vcs_github / vcs_gitlab / vcs_gitea) so anything beyond it is one constructor away — without adding a dependency.

You need…Use
The common lifecycle, portably (list/view/create/merge/close PRs, issues, releases)the Forge facade
An op the facade marks Unsupported on your forge (e.g. a Gitea release by tag)there’s nothing to call — the CLI can’t do it; go through the forge’s REST API (gh api via vcs_github::GitHubApi::api, or your own HTTP)
A forge-specific op (GitHub workflow runs, review submission, draft toggle, gist…)the wrapper client directly: GitHub::new().run_list(dir)…
More than 100 list results, custom JSON fields, exotic flagsthe wrapper’s raw run(dir, args)
A field the unified DTO drops (e.g. a release’s draft/prerelease flags)the wrapper method — its DTO keeps the per-CLI fields

§See also