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