From 1866f2965bb7cb1adbfff3f95707f0cae1d53da9 Mon Sep 17 00:00:00 2001 From: Nick Winans Date: Tue, 13 Jan 2026 11:48:07 -0800 Subject: [PATCH 1/4] Add paging to GitHub releases --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/tool_provider/github.rs | 68 ++++++++++++++----- tests/snapshots/help_command.snap | 3 +- .../install_all_tools_before_failing.snap | 7 +- 5 files changed, 58 insertions(+), 24 deletions(-) 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..e57677c 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,40 @@ 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)?; - let releases: Vec = serde_json::from_str(&response_body).map_err(|err| { - ForemanError::unexpected_response_body(err.to_string(), response_body, url) - })?; + // 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 response_body = response.text().map_err(ForemanError::request_failed)?; + + 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(releases.into_iter().map(Into::into).collect()) + Ok(all_releases.into_iter().map(Into::into).collect()) } fn download_asset(&self, url: &str) -> ForemanResult> { 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"} From 94d803222f39bf71b17a6e4d877890e069dc5248 Mon Sep 17 00:00:00 2001 From: Nick Winans Date: Tue, 13 Jan 2026 11:58:03 -0800 Subject: [PATCH 2/4] cargo fmt --- src/tool_provider/github.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tool_provider/github.rs b/src/tool_provider/github.rs index e57677c..ffde804 100644 --- a/src/tool_provider/github.rs +++ b/src/tool_provider/github.rs @@ -54,8 +54,10 @@ impl ToolProviderImpl for GithubProvider { let auth_store = AuthStore::load(&self.paths.auth_store())?; 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 mut next_url: Option = Some(format!( + "https://api.github.com/repos/{}/releases?per_page=100", + repo + )); while let Some(url) = next_url.take() { let mut builder = client.get(&url).header(USER_AGENT, "Roblox/foreman"); From 80b68856c07856d48e23bdfc77b8a0cb2dbaeb6a Mon Sep 17 00:00:00 2001 From: Nick Winans Date: Tue, 13 Jan 2026 13:12:16 -0800 Subject: [PATCH 3/4] add tests for parse next --- src/tool_provider/github.rs | 43 +++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/tool_provider/github.rs b/src/tool_provider/github.rs index ffde804..b3b2aa5 100644 --- a/src/tool_provider/github.rs +++ b/src/tool_provider/github.rs @@ -146,3 +146,46 @@ 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()) + ); + } +} From eff24de7e7fe6c974a7990676f63d5b1bb59b033 Mon Sep 17 00:00:00 2001 From: Nick Winans Date: Tue, 13 Jan 2026 13:13:17 -0800 Subject: [PATCH 4/4] format --- src/tool_provider/github.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/tool_provider/github.rs b/src/tool_provider/github.rs index b3b2aa5..4648ec4 100644 --- a/src/tool_provider/github.rs +++ b/src/tool_provider/github.rs @@ -156,7 +156,9 @@ mod tests { 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()) + Some( + "https://api.github.com/repos/owner/repo/releases?per_page=100&page=2".to_string() + ) ); } @@ -165,7 +167,9 @@ mod tests { 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()) + Some( + "https://api.github.com/repos/owner/repo/releases?per_page=100&page=3".to_string() + ) ); } @@ -182,10 +186,13 @@ mod tests { #[test] fn parse_next_link_only_next() { - let header = r#"; rel="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()) + Some( + "https://api.github.com/repos/owner/repo/releases?per_page=100&page=2".to_string() + ) ); } }