use serde::{Deserialize, Serialize};
use crate::http::{self, HttpError};
use crate::pin::{pin_url, PinError, Pinned};
pub const GITHUB_API_BASE: &str = "https://api.github.com";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubAsset {
pub name: String,
pub size: u64,
pub browser_download_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitHubRelease {
pub tag_name: String,
#[serde(default)]
pub prerelease: bool,
#[serde(default)]
pub draft: bool,
#[serde(default)]
pub published_at: String,
#[serde(default)]
pub assets: Vec<GitHubAsset>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReleaseSelector<'a> {
Latest,
Prerelease,
Tag(&'a str),
}
#[derive(Debug, thiserror::Error)]
pub enum GitHubError {
#[error(transparent)]
Http(#[from] HttpError),
#[error("GitHub API {status} listing {repo} releases")]
Api { status: u16, repo: String },
#[error("GitHub returned an unreadable release listing for {repo}: {source}")]
Malformed {
repo: String,
#[source]
source: serde_json::Error,
},
#[error("No {stability}GitHub release in {repo}{filtered}")]
NoRelease {
stability: &'static str,
repo: String,
filtered: &'static str,
},
#[error("GitHub release '{tag}' not found in {repo}. Available: {available}")]
NoTag {
tag: String,
repo: String,
available: String,
},
}
pub fn list_github_releases(
api_base: &str,
repo: &str,
token: Option<&str>,
) -> Result<Vec<GitHubRelease>, GitHubError> {
let url = format!("{api_base}/repos/{repo}/releases?per_page=100");
let auth = token.map(|t| format!("Bearer {t}"));
let mut headers: Vec<(&str, &str)> = vec![
("Accept", "application/vnd.github+json"),
("X-GitHub-Api-Version", "2022-11-28"),
];
if let Some(auth) = auth.as_deref() {
headers.push(("Authorization", auth));
}
let res = http::get(&url, &headers)?;
if !res.ok() {
return Err(GitHubError::Api {
status: res.status,
repo: repo.to_owned(),
});
}
serde_json::from_str(&res.body).map_err(|source| GitHubError::Malformed {
repo: repo.to_owned(),
source,
})
}
pub fn fetch_github_release(
api_base: &str,
repo: &str,
tag: &str,
token: Option<&str>,
) -> Result<GitHubRelease, GitHubError> {
let url = format!("{api_base}/repos/{repo}/releases/tags/{tag}");
let auth = token.map(|t| format!("Bearer {t}"));
let mut headers: Vec<(&str, &str)> = vec![
("Accept", "application/vnd.github+json"),
("X-GitHub-Api-Version", "2022-11-28"),
];
if let Some(auth) = auth.as_deref() {
headers.push(("Authorization", auth));
}
let res = http::get(&url, &headers)?;
if res.status == 404 {
return Err(GitHubError::NoTag {
tag: tag.to_owned(),
repo: repo.to_owned(),
available: "not listed".to_owned(),
});
}
if !res.ok() {
return Err(GitHubError::Api {
status: res.status,
repo: repo.to_owned(),
});
}
serde_json::from_str(&res.body).map_err(|source| GitHubError::Malformed {
repo: repo.to_owned(),
source,
})
}
pub fn github_asset_sha256(asset: &GitHubAsset) -> Option<&str> {
asset.digest.as_deref()?.strip_prefix("sha256:")
}
pub fn pin_github_asset(asset: &GitHubAsset) -> Result<Pinned, PinError> {
match github_asset_sha256(asset) {
Some(digest) => Ok(Pinned {
size: asset.size,
sha256: digest.to_owned(),
}),
None => pin_url(&asset.browser_download_url, &[]),
}
}
pub fn select_github_release<'r>(
releases: &'r [GitHubRelease],
selector: &ReleaseSelector<'_>,
) -> Option<&'r GitHubRelease> {
match selector {
ReleaseSelector::Latest => releases.iter().find(|r| !r.prerelease),
ReleaseSelector::Prerelease => releases.first(),
ReleaseSelector::Tag(tag) => releases.iter().find(|r| r.tag_name == *tag),
}
}
pub fn pick_github_release(
api_base: &str,
repo: &str,
selector: &ReleaseSelector<'_>,
token: Option<&str>,
filter: Option<&dyn Fn(&GitHubRelease) -> bool>,
) -> Result<GitHubRelease, GitHubError> {
let candidates: Vec<GitHubRelease> = list_github_releases(api_base, repo, token)?
.into_iter()
.filter(|r| !r.draft)
.filter(|r| match filter {
Some(keep) => keep(r),
None => true,
})
.collect();
if let Some(picked) = select_github_release(&candidates, selector) {
return Ok(picked.clone());
}
let filtered = if filter.is_some() {
" matching the loader filter"
} else {
""
};
match selector {
ReleaseSelector::Latest => Err(GitHubError::NoRelease {
stability: "stable ",
repo: repo.to_owned(),
filtered,
}),
ReleaseSelector::Prerelease => Err(GitHubError::NoRelease {
stability: "",
repo: repo.to_owned(),
filtered,
}),
ReleaseSelector::Tag(tag) => {
let tags: Vec<&str> = candidates
.iter()
.take(5)
.map(|r| r.tag_name.as_str())
.collect();
let mut available = tags.join(", ");
if candidates.len() > 5 {
available.push_str(", …");
}
Err(GitHubError::NoTag {
tag: (*tag).to_owned(),
repo: repo.to_owned(),
available,
})
}
}
}