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