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