crates/gunnar-forge/src/lib.rs
//! **What repositories does this account have?** — the one question git cannot
//! answer.
//!
//! # Why a REST crate exists in a git repository server
//!
//! gunnar mirrors repositories, and mirroring is git: `ls-remote`, fetch, push.
//! But the step *before* the mirror is a human picking from a list, and there
//! is no git verb for "enumerate an account". The git protocol starts from a
//! URL you already know; it has no concept of an account, so `git ls-remote`
//! against `github.com` is not a shorter version of this crate, it is a
//! different question with no answer. Every forge exposes the list over its
//! own HTTPS REST API and nowhere else.
//!
//! So this crate is the LIST half, and it is deliberately not the mirror half:
//! it speaks HTTP, holds a token for the duration of one call, and returns
//! [`Repo`] values that the caller hands to something that speaks git.
//!
//! # Where it runs, and where it must never run
//!
//! Two callers: `gunnar-cli` on a laptop, and the axum BFF on the front VM.
//! Both have a filesystem, outbound HTTPS and a human behind them.
//!
//! **It will never run on the appliance.** The appliance's wire half has no
//! outbound HTTPS and must not hold a user's forge token — a long-lived bearer
//! credential for someone's entire GitHub account, sitting on a box whose whole
//! design is that it holds only repositories. That is why nothing in the server
//! graph depends on this crate, and why `Cargo.toml`'s header says so where the
//! next person to add a dependency will read it.
//!
//! # The shape
//!
//! ```no_run
//! use gunnar_forge::{Forge, Repo};
//!
//! # fn main() -> Result<(), gunnar_forge::ForgeError> {
//! let token = std::env::var("GITHUB_TOKEN").unwrap();
//! let repos: Vec<Repo> = Forge::github().list_repos(&token)?;
//!
//! for r in repos.iter().filter(|r| !r.archived) {
//! println!("{:<40} {}", r.full_name, r.clone_url_ssh);
//! }
//! # Ok(())
//! # }
//! ```
//!
//! A self-hosted instance is the same code with a different constructor —
//! [`Forge::forgejo`], [`Forge::github_enterprise`], [`Forge::gitlab_at`] — and
//! three forges at once is three `Forge` values whose results concatenate,
//! because [`Repo::forge`] is what tells the rows apart afterwards.
//!
//! # What is out of scope, and why the boundary is here
//!
//! * **OAuth.** The browser dance belongs to whoever has a browser; this crate
//! consumes the resulting token and does not care how it was obtained. A
//! crate that did both would be untestable without a browser and would have
//! to store a refresh token, which is the one thing worse than storing an
//! access token.
//! * **Azure DevOps and AWS CodeCommit.** Azure's model is org → project →
//! repo, so "list an account's repositories" is two nested paginations and a
//! different trait; AWS stopped accepting new CodeCommit customers in 2024.
//! [`ForgeDriver`] is shaped so a fourth forge is one file — but only for
//! forges that actually have a flat "my repositories" endpoint.
//! * **The mirror itself.** This crate returns URLs. It never opens a git
//! connection, never touches the filesystem, and has no idea what gunnar does
//! with the answer.
#![forbid(unsafe_code)]
#![doc(
html_logo_url = "https://gunnar.rs/download/gunnar.webp",
html_favicon_url = "https://gunnar.rs/download/favicon.svg"
)]
// The credential argument is the reason this crate exists to be careful about,
// so every public entry point that takes one is documented about it.
#![warn(missing_docs)]
mod driver;
mod engine;
mod error;
mod forgejo;
mod github;
mod gitlab;
mod json;
mod repo;
/// The loopback HTTP double every test in this crate runs against, exported so
/// a CALLER can drive a picker without a network. See the module docs for why
/// it is not behind a feature.
pub mod testing;
pub use driver::{AuthHeader, ForgeDriver, Headers, PageMeta};
pub use engine::ListOptions;
pub use error::ForgeError;
pub use repo::{ForgeKind, Repo};
/// One forge account's listing endpoint, bound to a base URL.
///
/// # Why the base URL is a parameter and not three constants
///
/// All three of these forges are routinely self-hosted, and the Codeberg-shaped
/// case — a Forgejo at `git.example.com` — is the one gunnar was actually asked
/// for. A crate with `codeberg.org` baked in makes the self-hosted instance a
/// fork of the driver instead of an argument to it, and that fork is where the
/// two copies start to disagree about which fields exist.
///
/// # `Debug` prints a kind and a URL, because there is nothing else in here
///
/// There is no token field. The credential is an argument to
/// [`Forge::list_repos`] and lives for the duration of that call. This is not a
/// stylistic preference: a stored token is printed by `{:?}`, by a panic
/// message, by a `tracing` span that captured the struct, and by the `Debug`
/// impl of anything that contains one — four leaks from one field. Not having
/// the field removes all four at once.
pub struct Forge {
driver: Box<dyn ForgeDriver>,
base: String,
agent: ureq::Agent,
opts: ListOptions,
}
impl std::fmt::Debug for Forge {
/// Hand-written rather than derived, so that adding a field to `Forge`
/// cannot silently start printing it. If a credential ever *does* have to
/// live in this struct, this impl is where the reviewer will already be
/// looking.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Forge")
.field("kind", &self.driver.kind())
.field("base", &self.base)
.finish_non_exhaustive()
}
}
impl Forge {
fn new(driver: Box<dyn ForgeDriver>, base: impl Into<String>) -> Self {
let opts = ListOptions::default();
Self {
driver,
base: base.into(),
agent: engine::agent(opts.timeout),
opts,
}
}
/// github.com.
pub fn github() -> Self {
// `api.github.com` and not `github.com/api/v3`: the public service and
// GitHub Enterprise Server put the API in different places, which is
// the entire difference between this constructor and the next one.
Self::new(Box::new(github::GitHub), "https://api.github.com")
}
/// A GitHub Enterprise Server.
///
/// `base` is the host, with or without the `/api/v3` suffix — pass exactly
/// what your instance answers on, e.g. `https://github.example.com/api/v3`.
pub fn github_enterprise(base: impl Into<String>) -> Self {
Self::new(Box::new(github::GitHub), base)
}
/// codeberg.org.
pub fn codeberg() -> Self {
Self::new(Box::new(forgejo::Forgejo), "https://codeberg.org")
}
/// A self-hosted Forgejo or Gitea, e.g. `https://git.example.com`.
///
/// The `/api/v1` prefix is added by the driver; pass the site root.
pub fn forgejo(base: impl Into<String>) -> Self {
Self::new(Box::new(forgejo::Forgejo), base)
}
/// gitlab.com.
pub fn gitlab() -> Self {
Self::new(Box::new(gitlab::GitLab), "https://gitlab.com")
}
/// A self-managed GitLab, e.g. `https://gitlab.example.com`.
///
/// The `/api/v4` prefix is added by the driver; pass the site root.
pub fn gitlab_at(base: impl Into<String>) -> Self {
Self::new(Box::new(gitlab::GitLab), base)
}
/// Which forge this is. Also on every [`Repo`] and every [`ForgeError`].
pub fn kind(&self) -> ForgeKind {
self.driver.kind()
}
/// Replace the paging and timeout settings.
pub fn with_options(mut self, opts: ListOptions) -> Self {
self.agent = engine::agent(opts.timeout);
self.opts = opts;
self
}
/// **Every repository the token can see, in one `Vec`.**
///
/// `token` is a personal access token or an OAuth access token. It is
/// borrowed for the duration of the call, put into one request header, and
/// never stored, logged, returned, or included in any error — see
/// [`ForgeError`] for the argument and `tests/token_never_leaks.rs` for the
/// proof.
///
/// # Why one `Vec` and not an iterator
///
/// A lazy page-by-page iterator would let a caller stop early, and no
/// caller does: a picker renders the whole list and a mirror takes a
/// subset of it, both after the list is complete. An iterator would instead
/// mean the token has to be *held* by the iterator for as long as the
/// caller keeps it — turning the one property this crate is careful about
/// into a lifetime the caller controls. A hundred repositories is one
/// request and about 300 KiB of JSON; the largest real account is a handful
/// of requests. The eager version is the one that keeps the credential's
/// lifetime equal to the call.
pub fn list_repos(&self, token: &str) -> Result<Vec<Repo>, ForgeError> {
engine::list_all(
&self.agent,
&self.base,
self.driver.as_ref(),
token,
&self.opts,
)
}
}