crates/gunnar-forge/src/gitlab.rs
//! GitLab: `GET /api/v4/projects?membership=true`.
//!
//! # `membership=true`, and what the alternatives get wrong
//!
//! `/projects` with no parameters returns **every public project on the
//! instance** — on gitlab.com that is millions, paginated at 100, which is not
//! a picker, it is a denial of service against oneself. The candidates:
//!
//! | parameter | what it returns | why not |
//! |---|---|---|
//! | *(none)* | all public projects instance-wide | unusable |
//! | `owned=true` | only what the user owns | drops every group project — for most working developers, that is all of them |
//! | `membership=true` | projects the user is a member of, at any access level, including through a group | this |
//!
//! `min_access_level` was considered and rejected: mirroring needs read access,
//! and Guest (10) already has it on a public project, so filtering by level
//! would only remove rows a user might legitimately want to mirror.
//!
//! # Why `simple=false` is not set, and `statistics` is not asked for
//!
//! `simple=true` halves the payload and drops `archived` and `visibility`,
//! which are two of the eight fields we need. `statistics=true` is the only way
//! to get a repository size out of GitLab — and it requires administrator
//! rights on the instance, returns 403 for everyone else, and would turn a
//! working listing into a failure for the normal case. So GitLab repositories
//! carry `size_kib: None`, which is exactly what that field's `Option` is for.
//!
//! # Pagination: `X-Next-Page`
//!
//! GitLab sends `X-Next-Page` with the next page number, and an **empty
//! string** on the last page — not a missing header, an empty one. A client
//! that tested for presence rather than for a parsable number would loop on the
//! final page forever. That is the shape `tests/list.rs` pins.
//!
//! GitLab also offers keyset pagination and *requires* it past 50 000 records
//! on some endpoints. Offset paging is used here because the cutover point is
//! far beyond any single user's membership list, and keyset would mean carrying
//! an opaque cursor through the driver trait for a case that does not arise.
use crate::driver::{AuthHeader, ForgeDriver, PageMeta};
use crate::error::ForgeError;
use crate::json::{as_array, bool_or_false, opt_string, req_str, str_or_empty};
use crate::repo::{ForgeKind, Repo};
pub(crate) struct GitLab;
impl ForgeDriver for GitLab {
fn kind(&self) -> ForgeKind {
ForgeKind::GitLab
}
fn auth(&self) -> AuthHeader {
// See `AuthHeader`'s docs: `PRIVATE-TOKEN` is the header every GitLab
// example uses and it rejects OAuth access tokens, which is what the UI
// half will be handing us.
AuthHeader {
name: "Authorization",
prefix: "Bearer ",
}
}
fn page_path(&self, page: u32, per_page: u32) -> String {
format!(
"/api/v4/projects?membership=true&per_page={per_page}&page={page}\
&order_by=path&sort=asc"
)
}
fn parse_page(&self, body: &str) -> Result<Vec<Repo>, ForgeError> {
let forge = ForgeKind::GitLab;
let v: serde_json::Value =
serde_json::from_str(body).map_err(|e| ForgeError::Malformed {
forge,
what: format!("not JSON (line {}, column {})", e.line(), e.column()),
})?;
let arr = as_array(&v).ok_or_else(|| ForgeError::Malformed {
forge,
what: "the response was not an array of projects".to_string(),
})?;
let mut out = Vec::with_capacity(arr.len());
for item in arr {
let (Some(full_name), Some(https), Some(ssh)) = (
// NOT `name` and not `path`: `path_with_namespace` is the only
// one that is unique on the instance, and it can be three or
// more segments deep (`group/subgroup/project`), which is why
// `Repo::full_name` refuses to truncate it.
req_str(item, "path_with_namespace"),
req_str(item, "http_url_to_repo"),
req_str(item, "ssh_url_to_repo"),
) else {
continue;
};
out.push(Repo {
forge,
full_name: full_name.to_string(),
clone_url_https: https.to_string(),
clone_url_ssh: ssh.to_string(),
default_branch: str_or_empty(item, "default_branch"),
// GitLab has three visibilities, not a boolean. `internal`
// means "any signed-in user on this instance", which is not
// public — and a picker that showed it as public would be
// telling someone their employer's code is world-readable.
// Anything that is not literally `public` is treated as
// private, so an unknown fourth visibility fails closed.
private: item
.get("visibility")
.and_then(|v| v.as_str())
.map(|s| s != "public")
.unwrap_or(true),
archived: bool_or_false(item, "archived"),
// See the module docs: only an instance administrator can get
// this out of GitLab, so it is never present here.
size_kib: None,
description: opt_string(item, "description"),
});
}
Ok(out)
}
fn next_page(&self, meta: &PageMeta<'_>) -> Option<u32> {
// Parsed, not merely present. GitLab sends the header with an EMPTY
// value on the last page; `is_some()` there is an infinite loop.
meta.headers
.get("x-next-page")
.and_then(|v| v.trim().parse::<u32>().ok())
}
}