diff --git a/Cargo.lock b/Cargo.lock index b4d6a5f..343fec8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -390,7 +390,7 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "foreman" -version = "1.6.4" +version = "1.6.5" dependencies = [ "artiaa_auth", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 0fc18e4..019c152 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ default-members = [".", "artiaa_auth"] [package] name = "foreman" description = "Toolchain manager for simple binary tools" -version = "1.6.4" +version = "1.6.5" authors = [ "Lucien Greathouse ", "Matt Hargett ", diff --git a/src/tool_provider/github.rs b/src/tool_provider/github.rs index 0b40c1f..4648ec4 100644 --- a/src/tool_provider/github.rs +++ b/src/tool_provider/github.rs @@ -14,6 +14,29 @@ use crate::{ }; use url::Url; +/// Parses the GitHub `Link` header to extract the "next" page URL. +/// Example header: `; rel="next", <...>; rel="last"` +fn parse_next_link(link_header: &str) -> Option { + for part in link_header.split(',') { + let mut url = None; + let mut is_next = false; + + for segment in part.split(';') { + let segment = segment.trim(); + if segment.starts_with('<') && segment.ends_with('>') { + url = Some(segment[1..segment.len() - 1].to_string()); + } else if segment == r#"rel="next""# { + is_next = true; + } + } + + if is_next { + return url; + } + } + None +} + #[derive(Debug)] pub struct GithubProvider { paths: ForemanPaths, @@ -28,27 +51,42 @@ impl GithubProvider { impl ToolProviderImpl for GithubProvider { fn get_releases(&self, repo: &str, _host: &Url) -> ForemanResult> { let client = Client::new(); + let auth_store = AuthStore::load(&self.paths.auth_store())?; - let url = format!("https://api.github.com/repos/{}/releases", repo); - let mut builder = client.get(&url).header(USER_AGENT, "Roblox/foreman"); + let mut all_releases: Vec = Vec::new(); + let mut next_url: Option = Some(format!( + "https://api.github.com/repos/{}/releases?per_page=100", + repo + )); - let auth_store = AuthStore::load(&self.paths.auth_store())?; - if let Some(token) = &auth_store.github { - builder = builder.header(AUTHORIZATION, format!("token {}", token)); - } + while let Some(url) = next_url.take() { + let mut builder = client.get(&url).header(USER_AGENT, "Roblox/foreman"); - log::debug!("Downloading github releases for {}", repo); - let response_body = builder - .send() - .map_err(ForemanError::request_failed)? - .text() - .map_err(ForemanError::request_failed)?; + if let Some(token) = &auth_store.github { + builder = builder.header(AUTHORIZATION, format!("token {}", token)); + } + + log::debug!("Downloading github releases for {} (url: {})", repo, url); + let response = builder.send().map_err(ForemanError::request_failed)?; + + // Parse the Link header for pagination + next_url = response + .headers() + .get("link") + .and_then(|h| h.to_str().ok()) + .and_then(parse_next_link); - let releases: Vec = serde_json::from_str(&response_body).map_err(|err| { - ForemanError::unexpected_response_body(err.to_string(), response_body, url) - })?; + let response_body = response.text().map_err(ForemanError::request_failed)?; - Ok(releases.into_iter().map(Into::into).collect()) + let releases: Vec = + serde_json::from_str(&response_body).map_err(|err| { + ForemanError::unexpected_response_body(err.to_string(), response_body, url) + })?; + + all_releases.extend(releases); + } + + Ok(all_releases.into_iter().map(Into::into).collect()) } fn download_asset(&self, url: &str) -> ForemanResult> { @@ -108,3 +146,53 @@ impl From for ReleaseAsset { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_next_link_with_next_and_last() { + let header = r#"; rel="next", ; rel="last""#; + assert_eq!( + parse_next_link(header), + Some( + "https://api.github.com/repos/owner/repo/releases?per_page=100&page=2".to_string() + ) + ); + } + + #[test] + fn parse_next_link_with_all_rels() { + let header = r#"; rel="prev", ; rel="next", ; rel="last", ; rel="first""#; + assert_eq!( + parse_next_link(header), + Some( + "https://api.github.com/repos/owner/repo/releases?per_page=100&page=3".to_string() + ) + ); + } + + #[test] + fn parse_next_link_last_page_no_next() { + let header = r#"; rel="prev", ; rel="first""#; + assert_eq!(parse_next_link(header), None); + } + + #[test] + fn parse_next_link_empty_header() { + assert_eq!(parse_next_link(""), None); + } + + #[test] + fn parse_next_link_only_next() { + let header = + r#"; rel="next""#; + assert_eq!( + parse_next_link(header), + Some( + "https://api.github.com/repos/owner/repo/releases?per_page=100&page=2".to_string() + ) + ); + } +} diff --git a/tests/snapshots/help_command.snap b/tests/snapshots/help_command.snap index 69a03e4..7bc4702 100644 --- a/tests/snapshots/help_command.snap +++ b/tests/snapshots/help_command.snap @@ -1,9 +1,8 @@ --- source: tests/cli.rs -assertion_line: 100 expression: content --- -foreman 1.6.4 +foreman 1.6.5 USAGE: foreman [FLAGS] diff --git a/tests/snapshots/install_all_tools_before_failing.snap b/tests/snapshots/install_all_tools_before_failing.snap index 0de92b3..bf1865b 100644 --- a/tests/snapshots/install_all_tools_before_failing.snap +++ b/tests/snapshots/install_all_tools_before_failing.snap @@ -1,19 +1,18 @@ --- source: tests/cli.rs -assertion_line: 100 expression: content --- [INFO ] Downloading github.com/Roblox/NotARepository@^0.1.0 [ERROR] The following error occurred while trying to download tool "also-not-a-real-tool": unexpected response body: invalid type: map, expected a sequence at line 1 column 0 - Request from `https://api.github.com/repos/Roblox/NotARepository/releases` + Request from `https://api.github.com/repos/Roblox/NotARepository/releases?per_page=100` Received body: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/releases/releases#list-releases","status":"404"} [INFO ] Downloading github.com/Roblox/@^0.2.0 [ERROR] The following error occurred while trying to download tool "badly-formatted-tool": unexpected response body: invalid type: map, expected a sequence at line 1 column 0 - Request from `https://api.github.com/repos/Roblox//releases` + Request from `https://api.github.com/repos/Roblox//releases?per_page=100` Received body: { @@ -24,7 +23,7 @@ expression: content [INFO ] Downloading github.com/Roblox/VeryFakeRepository@^0.1.0 [ERROR] The following error occurred while trying to download tool "not-a-real-tool": unexpected response body: invalid type: map, expected a sequence at line 1 column 0 - Request from `https://api.github.com/repos/Roblox/VeryFakeRepository/releases` + Request from `https://api.github.com/repos/Roblox/VeryFakeRepository/releases?per_page=100` Received body: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/releases/releases#list-releases","status":"404"}