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/forgejo.rs

//! Codeberg, and every other Forgejo/Gitea: `GET /api/v1/user/repos`.
//!
//! # Why this is one driver and not "Codeberg" plus "Forgejo" plus "Gitea"
//!
//! Codeberg *is* a Forgejo instance, Forgejo is a hard fork of Gitea, and all
//! three answer the same `/api/v1` with the same repository object for the
//! eight fields this crate reads. Three drivers would be three copies of one
//! parser, kept in step by nothing (one implementation). What differs between
//! them is the **base URL**, which is already a parameter — so
//! `Forge::codeberg()` and `Forge::forgejo("https://git.example.com")` are two
//! constructors over one driver, and a self-hosted instance is not a fork of
//! anything.
//!
//! # The pagination is the interesting part
//!
//! Gitea's API returns `X-Total-Count`, and its own docs say to page with
//! `page`/`limit`. Both halves have a hole:
//!
//! * **`X-Total-Count` is not always there.** It is set by the handler for most
//!   list endpoints and missing on some deployments and behind some reverse
//!   proxies that drop non-standard headers (`X-` headers are routinely
//!   stripped by corporate proxies). A driver that only knew the header would
//!   read page 1 and declare the account finished — silent truncation again.
//! * **A short page is a reliable end signal.** Gitea returns exactly `limit`
//!   items while more remain.
//!
//! So this driver uses **both**, header first: if `X-Total-Count` is present and
//! we have not yet seen that many, ask for the next page; otherwise fall back to
//! "a full page means there may be more". Getting one page too many is one
//! wasted request; getting one page too few is a missing repository.
//!
//! `limit` is Gitea's spelling of the page size. It is not `per_page`, and a
//! Gitea that does not recognise a parameter ignores it silently and serves its
//! default of 30 — which still works here, because the short-page rule does not
//! depend on the page size being what we asked for.

use crate::driver::{AuthHeader, ForgeDriver, PageMeta};
use crate::error::ForgeError;
use crate::repo::{ForgeKind, Repo};

pub(crate) struct Forgejo;

impl ForgeDriver for Forgejo {
    fn kind(&self) -> ForgeKind {
        ForgeKind::Codeberg
    }

    fn auth(&self) -> AuthHeader {
        // Gitea/Forgejo's own spelling. It also accepts `Bearer`, but `token`
        // is what its documentation and its own CLI use, and it is the one that
        // works on the older instances a self-hosted base URL is most likely to
        // be pointing at.
        AuthHeader {
            name: "Authorization",
            prefix: "token ",
        }
    }

    fn page_path(&self, page: u32, per_page: u32) -> String {
        format!("/api/v1/user/repos?page={page}&limit={per_page}")
    }

    fn parse_page(&self, body: &str) -> Result<Vec<Repo>, ForgeError> {
        // Gitea copied GitHub's repository JSON for every field this crate
        // reads, so the GitHub parser is reused rather than duplicated. See
        // `github::parse`.
        crate::github::parse(ForgeKind::Codeberg, body)
    }

    fn next_page(&self, meta: &PageMeta<'_>) -> Option<u32> {
        // An empty page always ends the list, whatever any header claims — this
        // is what stops a stale or wrong `X-Total-Count` from spinning.
        if meta.received == 0 {
            return None;
        }
        if let Some(total) = meta
            .headers
            .get("x-total-count")
            .and_then(|v| v.trim().parse::<u64>().ok())
        {
            // Against what ARRIVED, not against `page * per_page` — see
            // `PageMeta::seen_total` for why those differ.
            return if (meta.seen_total as u64) < total {
                Some(meta.page + 1)
            } else {
                None
            };
        }
        // No header — a proxy stripped it, or the instance is old. Keep going
        // until an EMPTY page.
        //
        // The tempting rule here is "stop when a page is shorter than the size
        // we asked for", and it is wrong on exactly the instances that lack the
        // header: Gitea clamps `limit` to its own `MAX_RESPONSE_ITEMS` (50 by
        // default) without saying so, so the first page of a 200-repository
        // account arrives with 50 items against a requested 100 and the short-
        // page rule declares the account finished at a quarter of its size.
        // Silent truncation in a mirror picker is the failure nobody notices
        // until the repository is needed, so the cost of one extra request per
        // listing is paid instead.
        Some(meta.page + 1)
    }
}