Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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,
        )
    }
}