use anyhow::Error;
use chrono::{DateTime, Utc};
use futures::future::join_all;
use semver::Version;
use serde::{Deserialize, Serialize};
use tracing::trace;
use super::get_download_client_with_user_agent;
type DateTimeUtc = DateTime<Utc>;
const GITHUB_PER_PAGE: u32 = 100;
const GITHUB_REQUEST_BATCH_SIZE: u32 = 30;
const VERSION_FETCHER_CLIENT_USER_AGENT: &str =
concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
async fn get_chronologically_sorted_releases(
owner: &str,
repo: &str,
after_version: &semver::Version,
) -> Result<Vec<GitHubRelease>, anyhow::Error> {
let releases_of_repo = fetch_latest_releases(owner, repo, after_version).await?;
let mut releases_of_repo = releases_of_repo.into_iter().collect::<Vec<GitHubRelease>>();
releases_of_repo.sort_by(|a, b| b.published_at.cmp(&a.published_at));
Ok(releases_of_repo)
}
pub async fn new_patch_releases_after(
owner: &str,
repo: &str,
after_version: &Version,
) -> Result<Vec<GitHubRelease>, Error> {
let releases = get_chronologically_sorted_releases(owner, repo, after_version).await?;
let main_releases = releases
.into_iter()
.filter(|release| match &release.get_main_artifact_release() {
Some(version) => {
after_version.major == version.major && after_version.minor == version.minor
}
None => false,
})
.collect::<Vec<GitHubRelease>>();
Ok(main_releases)
}
pub async fn new_patch_version_of_after_string(
owner: &str,
repo: &str,
after_version: &str,
) -> Result<Option<Version>, Error> {
let after_version = after_version.strip_prefix('v').unwrap_or(after_version);
let after_version = match Version::parse(after_version) {
Ok(v) => v,
Err(_) => return Ok(None),
};
match new_patch_releases_after(owner, repo, &after_version).await {
Ok(patches) => match patches.first() {
Some(patch) => Ok(patch.get_main_artifact_release()),
None => Ok(None),
},
_ => Ok(None),
}
}
#[derive(Deserialize, Serialize, Debug, Clone)]
pub struct GitHubRelease {
pub tag_name: String,
pub name: String,
#[serde(with = "github_date_format")]
pub published_at: DateTimeUtc,
pub draft: bool,
pub prerelease: bool,
}
impl PartialEq for GitHubRelease {
fn eq(&self, other: &Self) -> bool {
self.tag_name == other.tag_name
}
}
impl GitHubRelease {
pub fn get_main_artifact_release(&self) -> Option<semver::Version> {
match self.tag_name.strip_prefix('v') {
Some(v) => match semver::Version::parse(v) {
Ok(v) => {
if self.draft || self.prerelease {
None
} else {
Some(v)
}
}
Err(_) => None,
},
None => None,
}
}
}
fn format_latest_releases_url(owner: &str, repo: &str, page: u32) -> String {
format!(
"https://api.github.com/repos/{}/{}/releases?page={}&per_page={}",
owner, repo, page, GITHUB_PER_PAGE
)
}
async fn fetch_latest_releases(
owner: &str,
repo: &str,
latest_interested: &semver::Version,
) -> Result<Vec<GitHubRelease>, anyhow::Error> {
let client = get_download_client_with_user_agent(VERSION_FETCHER_CLIENT_USER_AGENT)?;
let mut page = 0u32;
let mut releases: Vec<GitHubRelease> = Vec::new();
'fetch_loop: loop {
trace!(
"Fetching releases from GitHub starting at page: {} with batch {}",
page,
GITHUB_REQUEST_BATCH_SIZE
);
let batchreleases =
get_release_batch(page, GITHUB_REQUEST_BATCH_SIZE, owner, repo, client.clone()).await?;
for release in batchreleases.iter() {
if let Some(main_release) = release.get_main_artifact_release() {
if main_release == *latest_interested {
break 'fetch_loop;
}
}
releases.push(release.clone());
}
if batchreleases.is_empty() {
break 'fetch_loop;
}
page += GITHUB_REQUEST_BATCH_SIZE;
}
Ok(releases)
}
async fn get_release_batch(
current_page: u32,
batch_size: u32,
owner: &str,
repo: &str,
client: reqwest::Client,
) -> Result<Vec<GitHubRelease>, reqwest::Error> {
let mut tasks = Vec::new();
for page in current_page..=current_page + batch_size {
let url = format_latest_releases_url(owner, repo, page);
let client_clone = client.clone();
tasks.push(async move {
let response = client_clone.get(&url).send().await?;
if !response.status().is_success() {
return Err(anyhow::Error::msg(format!(
"Failed to fetch releases from GitHub at url: {} with status {}",
url,
response.status()
)));
}
let releases_on_page = response.json::<Vec<GitHubRelease>>().await?;
Ok(releases_on_page)
});
}
let results = join_all(tasks).await;
Ok(results.into_iter().flatten().flatten().collect())
}
mod github_date_format {
use chrono::{DateTime, NaiveDateTime, Utc};
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%dT%H:%M:%SZ";
pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s = format!("{}", date.format(FORMAT));
serializer.serialize_str(&s)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let dt = NaiveDateTime::parse_from_str(&s, FORMAT).map_err(serde::de::Error::custom)?;
Ok(DateTime::<Utc>::from_naive_utc_and_offset(dt, Utc))
}
}
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use super::*;
#[test]
fn test_github_release_is_parsed_correctly() {
let raw_string = r#####"
{
"url": "https://api.github.com/repos/wasmCloud/wasmCloud/releases/165886656",
"assets_url": "https://api.github.com/repos/wasmCloud/wasmCloud/releases/165886656/assets",
"upload_url": "https://uploads.github.com/repos/wasmCloud/wasmCloud/releases/165886656/assets{?name,label}",
"html_url": "https://github.com/wasmCloud/wasmCloud/releases/tag/washboard-ui-v0.4.0",
"id": 165886656,
"node_id": "RE_kwDOEiTU7M4J4zrA",
"tag_name": "washboard-ui-v0.4.0",
"target_commitish": "main",
"name": "washboard-ui-v0.4.0",
"draft": false,
"prerelease": false,
"created_at": "2024-07-17T14:47:54Z",
"published_at": "2024-07-17T16:15:15Z",
"tarball_url": "https://api.github.com/repos/wasmCloud/wasmCloud/tarball/washboard-ui-v0.4.0",
"zipball_url": "https://api.github.com/repos/wasmCloud/wasmCloud/zipball/washboard-ui-v0.4.0",
"mentions_count": 5
}
"#####;
let release = serde_json::from_str::<GitHubRelease>(raw_string);
assert!(release.is_ok());
let release = release.unwrap();
assert_eq!(release.tag_name, "washboard-ui-v0.4.0");
assert_eq!(release.name, "washboard-ui-v0.4.0");
let expected_date = NaiveDate::from_ymd_opt(2024, 0o7, 17)
.unwrap()
.and_hms_opt(16, 15, 15)
.unwrap()
.and_utc();
assert_eq!(release.published_at, expected_date);
assert!(!release.draft);
assert!(!release.prerelease);
}
#[test]
fn test_github_release_is_not_draft_or_pre_release() {
let release = GitHubRelease {
tag_name: "v0.4.0".to_string(),
name: "v0.4.0".to_string(),
published_at: Utc::now(),
draft: false,
prerelease: false,
};
assert!(release.get_main_artifact_release().is_some());
}
#[test]
fn test_semver_without_prefix() {
let release = GitHubRelease {
tag_name: "v0.4.0".to_string(),
name: "v0.4.0".to_string(),
published_at: Utc::now(),
draft: false,
prerelease: false,
};
let version = release.get_main_artifact_release();
assert!(version.is_some());
assert_eq!(version.unwrap(), semver::Version::parse("0.4.0").unwrap());
let release_with_prefix = GitHubRelease {
tag_name: "washboard-ui-v0.4.0".to_string(),
name: "washboard-ui-v0.4.0".to_string(),
published_at: Utc::now(),
draft: false,
prerelease: false,
};
let version = release_with_prefix.get_main_artifact_release();
assert!(version.is_none());
}
}