Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <me@lpghatguy.com>",
"Matt Hargett <plaztiksyke@gmail.com>",
Expand Down
120 changes: 104 additions & 16 deletions src/tool_provider/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ use crate::{
};
use url::Url;

/// Parses the GitHub `Link` header to extract the "next" page URL.
/// Example header: `<https://api.github.com/...?page=2>; rel="next", <...>; rel="last"`
fn parse_next_link(link_header: &str) -> Option<String> {
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,
Expand All @@ -28,27 +51,42 @@ impl GithubProvider {
impl ToolProviderImpl for GithubProvider {
fn get_releases(&self, repo: &str, _host: &Url) -> ForemanResult<Vec<Release>> {
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<GithubRelease> = Vec::new();
let mut next_url: Option<String> = 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<GithubRelease> = 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<GithubRelease> =
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<Vec<u8>> {
Expand Down Expand Up @@ -108,3 +146,53 @@ impl From<GithubAsset> for ReleaseAsset {
}
}
}

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

#[test]
fn parse_next_link_with_next_and_last() {
let header = r#"<https://api.github.com/repos/owner/repo/releases?per_page=100&page=2>; rel="next", <https://api.github.com/repos/owner/repo/releases?per_page=100&page=5>; 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#"<https://api.github.com/repos/owner/repo/releases?per_page=100&page=1>; rel="prev", <https://api.github.com/repos/owner/repo/releases?per_page=100&page=3>; rel="next", <https://api.github.com/repos/owner/repo/releases?per_page=100&page=5>; rel="last", <https://api.github.com/repos/owner/repo/releases?per_page=100&page=1>; 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#"<https://api.github.com/repos/owner/repo/releases?per_page=100&page=4>; rel="prev", <https://api.github.com/repos/owner/repo/releases?per_page=100&page=1>; 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#"<https://api.github.com/repos/owner/repo/releases?per_page=100&page=2>; rel="next""#;
assert_eq!(
parse_next_link(header),
Some(
"https://api.github.com/repos/owner/repo/releases?per_page=100&page=2".to_string()
)
);
}
}
3 changes: 1 addition & 2 deletions tests/snapshots/help_command.snap
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
---
source: tests/cli.rs
assertion_line: 100
expression: content
---
foreman 1.6.4
foreman 1.6.5

USAGE:
foreman [FLAGS] <SUBCOMMAND>
Expand Down
7 changes: 3 additions & 4 deletions tests/snapshots/install_all_tools_before_failing.snap
Original file line number Diff line number Diff line change
@@ -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:
{
Expand All @@ -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"}
Expand Down