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

//! GitHub: `GET /user/repos`, paginated by the `Link` header.
//!
//! # Why `/user/repos` and not `/users/{login}/repos`
//!
//! The second one does not need a token and is the obvious choice — and it
//! returns only *public* repositories, and only ones the named user *owns*. A
//! picker built on it silently omits every private repository and everything
//! the user has through an organisation, which is most of what a working
//! developer would actually want to mirror. `/user/repos` answers for the
//! token's own identity and includes both. The cost is that the token is
//! mandatory, which it was anyway.
//!
//! # Why the `Link` header and not "keep going until a page is short"
//!
//! GitHub documents `Link` as the way to paginate and explicitly asks clients
//! not to construct page URLs themselves. The short-page heuristic is what one
//! writes without reading that, and it is wrong in a way that only shows up on
//! big accounts: GitHub may return **fewer than `per_page`** items on a full
//! page after filtering, and a client that stopped there would silently
//! truncate the list. Silent truncation in a mirror picker means a repository
//! nobody notices is missing until they need it.
//!
//! `Link` is also what makes the self-hosted case work: GitHub Enterprise
//! Server's URLs are `<host>/api/v3/...`, and since we only read the `page`
//! number out of the `next` link and re-issue against our own base, the
//! enterprise path prefix never has to be special-cased.

use crate::driver::{AuthHeader, ForgeDriver, PageMeta};
use crate::error::ForgeError;
use crate::json::{as_array, bool_or_false, opt_string, opt_u64, req_str, str_or_empty};
use crate::repo::{ForgeKind, Repo};

pub(crate) struct GitHub;

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

    fn auth(&self) -> AuthHeader {
        AuthHeader {
            name: "Authorization",
            prefix: "Bearer ",
        }
    }

    fn extra_headers(&self) -> &'static [(&'static str, &'static str)] {
        // Pinning both is GitHub's own advice, and both are load-bearing:
        // without `Accept` the API is free to serve a preview media type whose
        // field names differ, and without the version pin a breaking change
        // ships to us on GitHub's schedule rather than ours.
        &[
            ("Accept", "application/vnd.github+json"),
            ("X-GitHub-Api-Version", "2022-11-28"),
        ]
    }

    fn page_path(&self, page: u32, per_page: u32) -> String {
        // `affiliation` is spelled out rather than left to the default. The
        // default IS these three, but it is a *server-side* default that has
        // changed before; a picker that quietly stopped showing organisation
        // repositories after a GitHub deploy would look like a gunnar bug.
        format!(
            "/user/repos?per_page={per_page}&page={page}\
             &affiliation=owner,collaborator,organization_member&sort=full_name"
        )
    }

    fn parse_page(&self, body: &str) -> Result<Vec<Repo>, ForgeError> {
        parse(ForgeKind::GitHub, body)
    }

    fn next_page(&self, meta: &PageMeta<'_>) -> Option<u32> {
        let link = meta.headers.get("link")?;
        next_from_link(link)
    }
}

/// Shared with `forgejo.rs`: Gitea deliberately copied GitHub's repository JSON
/// field-for-field for the fields this crate reads (`full_name`, `clone_url`,
/// `ssh_url`, `default_branch`, `private`, `archived`, `size`, `description`),
/// so one parser serves both. Written once (one implementation) — two copies that could
/// drift apart would be two places to fix the day either forge renames a field,
/// and the tests would still pass on the one that was fixed.
pub(crate) fn parse(forge: ForgeKind, body: &str) -> Result<Vec<Repo>, ForgeError> {
    let v: serde_json::Value = serde_json::from_str(body).map_err(|e| ForgeError::Malformed {
        forge,
        // `e.line()`/`e.column()` and NOT `e.to_string()`: serde_json's message
        // for a syntax error can quote the offending input, and the offending
        // input is a forge response we did not write.
        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 repositories".to_string(),
    })?;

    let mut out = Vec::with_capacity(arr.len());
    for item in arr {
        // A row missing any of these three is not a repository anyone can
        // clone, so it is skipped rather than failing the page: one broken row
        // in an account of four hundred must not empty the picker. It is also
        // not silently equivalent to success — `Malformed` is returned only if
        // the page was structurally unreadable, and a skipped row leaves the
        // count short, which the caller can see.
        let (Some(full_name), Some(https), Some(ssh)) = (
            req_str(item, "full_name"),
            req_str(item, "clone_url"),
            req_str(item, "ssh_url"),
        ) 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"),
            private: bool_or_false(item, "private"),
            archived: bool_or_false(item, "archived"),
            // Already KiB on both forges — GitHub documents `size` as
            // "the size of the repository, in kilobytes".
            size_kib: opt_u64(item, "size"),
            description: opt_string(item, "description"),
        });
    }
    Ok(out)
}

/// Pull the `page` number out of the `rel="next"` entry of an RFC 8288 `Link`.
///
/// Only the number is taken, not the URL. Two reasons, and the second is a
/// security one: re-issuing against our own base URL keeps a self-hosted or
/// enterprise deployment working without special-casing its path prefix, and it
/// means **a forge cannot redirect our credential to a host of its choosing by
/// putting one in a `Link` header**. The `Never` redirect-auth setting in
/// `engine.rs` covers the 302 form of that attack; this covers the `Link` form.
fn next_from_link(link: &str) -> Option<u32> {
    for part in link.split(',') {
        let mut bits = part.split(';');
        let url = bits.next()?.trim();
        let is_next = bits.any(|b| {
            let b = b.trim();
            b == "rel=\"next\"" || b == "rel=next"
        });
        if !is_next {
            continue;
        }
        let url = url.trim_start_matches('<').trim_end_matches('>');
        let query = url.split_once('?').map(|(_, q)| q).unwrap_or("");
        for kv in query.split('&') {
            if let Some(n) = kv.strip_prefix("page=") {
                return n.parse().ok();
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::next_from_link;

    /// The exact header shape GitHub's pagination documentation prints,
    /// including the space-separated `rel` and the four links on a middle page.
    #[test]
    fn reads_the_next_page_number() {
        let h = r#"<https://api.github.com/user/repos?page=1>; rel="prev", <https://api.github.com/user/repos?page=3>; rel="next", <https://api.github.com/user/repos?page=9>; rel="last", <https://api.github.com/user/repos?page=1>; rel="first""#;
        assert_eq!(next_from_link(h), Some(3));
    }

    /// The last page carries `prev`/`first` and NO `next`. This is the only
    /// signal that the list is complete.
    #[test]
    fn no_next_on_the_last_page() {
        let h = r#"<https://api.github.com/user/repos?page=8>; rel="prev", <https://api.github.com/user/repos?page=1>; rel="first""#;
        assert_eq!(next_from_link(h), None);
    }

    /// `rel=next` without quotes is legal RFC 8288 and some proxies rewrite it
    /// that way. Asserted so the parser is not accidentally quote-dependent.
    #[test]
    fn unquoted_rel_is_accepted() {
        assert_eq!(next_from_link("<https://h/x?page=2>; rel=next"), Some(2));
    }

    /// A `Link` pointing at another host still only contributes a NUMBER. See
    /// the function docs: this is the credential-redirection defence.
    #[test]
    fn only_the_number_is_taken_from_a_foreign_host() {
        assert_eq!(
            next_from_link(r#"<https://evil.example/collect?page=2>; rel="next""#),
            Some(2)
        );
    }
}