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” |
|---|---|---|---|
| GitHub | OPEN | CLOSED | MERGED |
| GitLab | opened | closed / locked | merged |
| Gitea | state="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 (ForgeIssueState — Closed 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 Gitea — tea 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.
| Operation | GitHub | GitLab | Gitea |
|---|---|---|---|
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_branch | ✅ | ignored | ignored |
pr_create / issue_create return the URL | ✅ | ✅ | textual summary (tea ends issue create output with the URL; pr create prints none) |
pr_list / issue_list / release_list result cap (explicit, documented) | 100 | 100 | 100 |
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 flags | the 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
- vcs-github / vcs-gitlab / vcs-gitea — the wrapped clients and their per-CLI surfaces.
- vcs-core guide — the sibling facade over git/jj.
- Cookbook — the open-a-PR recipe.
- Process model & errors — OS-job containment and the
Errorshapes underneath. - crate docs — quickstart and crate-level docs.