From 4f1d04eab7ce470e9a110d486fcec1a8811c4371 Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Thu, 27 Aug 2026 23:03:10 +0700 Subject: [PATCH 1/2] feat: aggregate release notes across intermediate versions on update --- core/engine/src/ipc/handlers/update.rs | 6 +- core/engine/src/updater.rs | 136 ++++++++++++++++++++++--- 2 files changed, 126 insertions(+), 16 deletions(-) diff --git a/core/engine/src/ipc/handlers/update.rs b/core/engine/src/ipc/handlers/update.rs index b7f3174..f2bd1da 100644 --- a/core/engine/src/ipc/handlers/update.rs +++ b/core/engine/src/ipc/handlers/update.rs @@ -14,10 +14,10 @@ pub fn check_for_updates( let url = get_string(&data, "manifestUrl") .unwrap_or_else(|| "https://raw.githubusercontent.com/vantoan1511/orbit/main/update-manifest.json".to_string()); - match crate::updater::UpdateManifest::fetch(&url).await { + let current_engine = env!("CARGO_PKG_VERSION"); + + match crate::updater::UpdateManifest::fetch(&url, current_engine).await { Ok(manifest) => { - let current_engine = env!("CARGO_PKG_VERSION"); - let has_update = manifest.has_update(current_engine).unwrap_or(false); let _ = Bridge::send_event( diff --git a/core/engine/src/updater.rs b/core/engine/src/updater.rs index 1e16fb1..5130657 100644 --- a/core/engine/src/updater.rs +++ b/core/engine/src/updater.rs @@ -9,20 +9,22 @@ pub struct UpdateManifest { } #[derive(Deserialize)] -struct GithubReleaseResponse { +struct GithubReleaseItem { + #[serde(default)] + tag_name: String, body: Option, } impl UpdateManifest { /// Fetch the latest update manifest from the given URL. - pub async fn fetch(url: &str) -> Result> { + pub async fn fetch(url: &str, current_version: &str) -> Result> { let mut manifest = reqwest::get(url) .await? .json::() .await?; if manifest.release_notes.is_none() || manifest.release_notes.as_deref() == Some("") { - if let Some(notes) = Self::fetch_github_release_notes(&manifest.version).await { + if let Some(notes) = Self::fetch_github_release_notes(&manifest.version, current_version).await { manifest.release_notes = Some(notes); } else { manifest.release_notes = Some("No release notes provided for this version.".to_string()); @@ -32,30 +34,42 @@ impl UpdateManifest { Ok(manifest) } - async fn fetch_github_release_notes(version: &str) -> Option { - let tag = if version.starts_with('v') { - version.to_string() - } else { - format!("v{}", version) - }; - let api_url = format!("https://api.github.com/repos/vantoan1511/orbit/releases/tags/{}", tag); - + async fn fetch_github_release_notes(target_version: &str, current_version: &str) -> Option { let client = reqwest::Client::builder() .user_agent("orbit-engine") .timeout(std::time::Duration::from_secs(5)) .build() .ok()?; + let list_api_url = "https://api.github.com/repos/vantoan1511/orbit/releases?per_page=30"; + if let Ok(resp) = client.get(list_api_url).send().await { + if resp.status().is_success() { + if let Ok(releases) = resp.json::>().await { + if let Some(notes) = Self::aggregate_release_notes(&releases, target_version, current_version) { + return Some(notes); + } + } + } + } + + // Fallback: try tag endpoint if list fails or is empty + let tag = if target_version.starts_with('v') { + target_version.to_string() + } else { + format!("v{}", target_version) + }; + let api_url = format!("https://api.github.com/repos/vantoan1511/orbit/releases/tags/{}", tag); + let resp = client.get(&api_url).send().await.ok()?; if resp.status().is_success() { - let release = resp.json::().await.ok()?; + let release = resp.json::().await.ok()?; release.body } else { // Fallback: try latest release endpoint if specific tag lookup fails let fallback_url = "https://api.github.com/repos/vantoan1511/orbit/releases/latest"; let resp = client.get(fallback_url).send().await.ok()?; if resp.status().is_success() { - let release = resp.json::().await.ok()?; + let release = resp.json::().await.ok()?; release.body } else { None @@ -103,5 +117,101 @@ impl UpdateManifest { Ok(temp_path) } + + fn aggregate_release_notes( + releases: &[GithubReleaseItem], + target_version: &str, + current_version: &str, + ) -> Option { + let target_sem = semver::Version::parse(target_version.trim_start_matches('v')).ok()?; + let current_sem = semver::Version::parse(current_version.trim_start_matches('v')).ok()?; + + let mut combined_notes = String::new(); + + for release in releases { + if let Ok(release_sem) = semver::Version::parse(release.tag_name.trim_start_matches('v')) { + if release_sem > current_sem && release_sem <= target_sem { + if let Some(body) = &release.body { + let trimmed_body = body.trim(); + if !trimmed_body.is_empty() { + if !combined_notes.is_empty() { + combined_notes.push_str("\n\n"); + } + combined_notes.push_str(&format!("## Release {}\n\n{}", release.tag_name, trimmed_body)); + } + } + } + } + } + + if !combined_notes.is_empty() { + Some(combined_notes) + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_aggregate_release_notes_multiple_intermediate_versions() { + let releases = vec![ + GithubReleaseItem { + tag_name: "v0.7.1".to_string(), + body: Some("### Features\n- Fix bug A".to_string()), + }, + GithubReleaseItem { + tag_name: "v0.7.0".to_string(), + body: Some("### Features\n- Add feature B".to_string()), + }, + GithubReleaseItem { + tag_name: "v0.6.0".to_string(), + body: Some("### Features\n- Old feature".to_string()), + }, + ]; + + let result = UpdateManifest::aggregate_release_notes(&releases, "0.7.1", "0.6.0"); + assert!(result.is_some()); + let notes = result.unwrap(); + assert!(notes.contains("## Release v0.7.1\n\n### Features\n- Fix bug A")); + assert!(notes.contains("## Release v0.7.0\n\n### Features\n- Add feature B")); + assert!(!notes.contains("v0.6.0")); + } + + #[test] + fn test_aggregate_release_notes_single_version() { + let releases = vec![ + GithubReleaseItem { + tag_name: "v0.7.1".to_string(), + body: Some("### Features\n- Fix bug A".to_string()), + }, + GithubReleaseItem { + tag_name: "v0.7.0".to_string(), + body: Some("### Features\n- Add feature B".to_string()), + }, + ]; + + let result = UpdateManifest::aggregate_release_notes(&releases, "v0.7.1", "v0.7.0"); + assert!(result.is_some()); + let notes = result.unwrap(); + assert!(notes.contains("## Release v0.7.1")); + assert!(!notes.contains("v0.7.0")); + } + + #[test] + fn test_aggregate_release_notes_no_matching_versions() { + let releases = vec![ + GithubReleaseItem { + tag_name: "v0.6.0".to_string(), + body: Some("### Features\n- Old feature".to_string()), + }, + ]; + + let result = UpdateManifest::aggregate_release_notes(&releases, "0.7.1", "0.7.0"); + assert!(result.is_none()); + } } From d06c36422fbc1befbf180ad70c60447f300929e6 Mon Sep 17 00:00:00 2001 From: Toan Nguyen Date: Thu, 27 Aug 2026 23:11:29 +0700 Subject: [PATCH 2/2] fix(updater): address PR review findings on release notes aggregation --- core/engine/src/updater.rs | 159 ++++++++++++++++++++++++------------- 1 file changed, 105 insertions(+), 54 deletions(-) diff --git a/core/engine/src/updater.rs b/core/engine/src/updater.rs index 5130657..af5b64c 100644 --- a/core/engine/src/updater.rs +++ b/core/engine/src/updater.rs @@ -8,10 +8,11 @@ pub struct UpdateManifest { pub release_notes: Option, } +const GITHUB_REPO: &str = "vantoan1511/orbit"; + #[derive(Deserialize)] struct GithubReleaseItem { - #[serde(default)] - tag_name: String, + tag_name: Option, body: Option, } @@ -41,40 +42,51 @@ impl UpdateManifest { .build() .ok()?; - let list_api_url = "https://api.github.com/repos/vantoan1511/orbit/releases?per_page=30"; - if let Ok(resp) = client.get(list_api_url).send().await { - if resp.status().is_success() { - if let Ok(releases) = resp.json::>().await { - if let Some(notes) = Self::aggregate_release_notes(&releases, target_version, current_version) { - return Some(notes); - } - } - } + if let Some(notes) = Self::fetch_aggregated_release_notes(&client, target_version, current_version).await { + return Some(notes); } - // Fallback: try tag endpoint if list fails or is empty + Self::fetch_single_release_notes(&client, target_version).await + } + + async fn fetch_aggregated_release_notes( + client: &reqwest::Client, + target_version: &str, + current_version: &str, + ) -> Option { + let list_api_url = format!("https://api.github.com/repos/{}/releases?per_page=100", GITHUB_REPO); + let resp = client.get(&list_api_url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + + let releases = resp.json::>().await.ok()?; + Self::aggregate_release_notes(&releases, target_version, current_version) + } + + async fn fetch_release_by_url(client: &reqwest::Client, url: &str) -> Option { + let resp = client.get(url).send().await.ok()?; + if !resp.status().is_success() { + return None; + } + let release = resp.json::().await.ok()?; + release.body + } + + async fn fetch_single_release_notes(client: &reqwest::Client, target_version: &str) -> Option { let tag = if target_version.starts_with('v') { target_version.to_string() } else { format!("v{}", target_version) }; - let api_url = format!("https://api.github.com/repos/vantoan1511/orbit/releases/tags/{}", tag); - - let resp = client.get(&api_url).send().await.ok()?; - if resp.status().is_success() { - let release = resp.json::().await.ok()?; - release.body - } else { - // Fallback: try latest release endpoint if specific tag lookup fails - let fallback_url = "https://api.github.com/repos/vantoan1511/orbit/releases/latest"; - let resp = client.get(fallback_url).send().await.ok()?; - if resp.status().is_success() { - let release = resp.json::().await.ok()?; - release.body - } else { - None - } + let tag_url = format!("https://api.github.com/repos/{}/releases/tags/{}", GITHUB_REPO, tag); + if let Some(body) = Self::fetch_release_by_url(client, &tag_url).await { + return Some(body); } + + // Fallback: try latest release endpoint if specific tag lookup fails + let fallback_url = format!("https://api.github.com/repos/{}/releases/latest", GITHUB_REPO); + Self::fetch_release_by_url(client, &fallback_url).await } /// Check if an update is available. @@ -126,29 +138,42 @@ impl UpdateManifest { let target_sem = semver::Version::parse(target_version.trim_start_matches('v')).ok()?; let current_sem = semver::Version::parse(current_version.trim_start_matches('v')).ok()?; - let mut combined_notes = String::new(); - - for release in releases { - if let Ok(release_sem) = semver::Version::parse(release.tag_name.trim_start_matches('v')) { + let mut matching_releases: Vec<(semver::Version, &str, &Option)> = releases + .iter() + .filter_map(|release| { + let tag = release.tag_name.as_deref()?; + let release_sem = semver::Version::parse(tag.trim_start_matches('v')).ok()?; if release_sem > current_sem && release_sem <= target_sem { - if let Some(body) = &release.body { - let trimmed_body = body.trim(); - if !trimmed_body.is_empty() { - if !combined_notes.is_empty() { - combined_notes.push_str("\n\n"); - } - combined_notes.push_str(&format!("## Release {}\n\n{}", release.tag_name, trimmed_body)); - } - } + Some((release_sem, tag, &release.body)) + } else { + None } - } + }) + .collect(); + + if matching_releases.is_empty() { + return None; } - if !combined_notes.is_empty() { - Some(combined_notes) - } else { - None + // Sort descending (newest first) + matching_releases.sort_by(|a, b| b.0.cmp(&a.0)); + + let mut combined_notes = String::new(); + + for (_, tag, body) in matching_releases { + let body_text = body + .as_deref() + .map(str::trim) + .filter(|b| !b.is_empty()) + .unwrap_or("_No release notes provided._"); + + if !combined_notes.is_empty() { + combined_notes.push_str("\n\n"); + } + combined_notes.push_str(&format!("## Release {}\n\n{}", tag, body_text)); } + + Some(combined_notes) } } @@ -158,17 +183,18 @@ mod tests { #[test] fn test_aggregate_release_notes_multiple_intermediate_versions() { + // Provided in arbitrary/reverse order to verify that sorting guarantees descending order let releases = vec![ GithubReleaseItem { - tag_name: "v0.7.1".to_string(), - body: Some("### Features\n- Fix bug A".to_string()), + tag_name: Some("v0.7.0".to_string()), + body: Some("### Features\n- Add feature B".to_string()), }, GithubReleaseItem { - tag_name: "v0.7.0".to_string(), - body: Some("### Features\n- Add feature B".to_string()), + tag_name: Some("v0.7.1".to_string()), + body: Some("### Features\n- Fix bug A".to_string()), }, GithubReleaseItem { - tag_name: "v0.6.0".to_string(), + tag_name: Some("v0.6.0".to_string()), body: Some("### Features\n- Old feature".to_string()), }, ]; @@ -179,17 +205,22 @@ mod tests { assert!(notes.contains("## Release v0.7.1\n\n### Features\n- Fix bug A")); assert!(notes.contains("## Release v0.7.0\n\n### Features\n- Add feature B")); assert!(!notes.contains("v0.6.0")); + + // Verify descending order (newest first) + let pos_v071 = notes.find("## Release v0.7.1").unwrap(); + let pos_v070 = notes.find("## Release v0.7.0").unwrap(); + assert!(pos_v071 < pos_v070, "v0.7.1 should appear before v0.7.0 in descending order"); } #[test] fn test_aggregate_release_notes_single_version() { let releases = vec![ GithubReleaseItem { - tag_name: "v0.7.1".to_string(), + tag_name: Some("v0.7.1".to_string()), body: Some("### Features\n- Fix bug A".to_string()), }, GithubReleaseItem { - tag_name: "v0.7.0".to_string(), + tag_name: Some("v0.7.0".to_string()), body: Some("### Features\n- Add feature B".to_string()), }, ]; @@ -205,7 +236,7 @@ mod tests { fn test_aggregate_release_notes_no_matching_versions() { let releases = vec![ GithubReleaseItem { - tag_name: "v0.6.0".to_string(), + tag_name: Some("v0.6.0".to_string()), body: Some("### Features\n- Old feature".to_string()), }, ]; @@ -213,5 +244,25 @@ mod tests { let result = UpdateManifest::aggregate_release_notes(&releases, "0.7.1", "0.7.0"); assert!(result.is_none()); } + + #[test] + fn test_aggregate_release_notes_empty_body_fallback() { + let releases = vec![ + GithubReleaseItem { + tag_name: Some("v0.7.1".to_string()), + body: None, + }, + GithubReleaseItem { + tag_name: Some("v0.7.0".to_string()), + body: Some(" ".to_string()), + }, + ]; + + let result = UpdateManifest::aggregate_release_notes(&releases, "v0.7.1", "v0.6.0"); + assert!(result.is_some()); + let notes = result.unwrap(); + assert!(notes.contains("## Release v0.7.1\n\n_No release notes provided._")); + assert!(notes.contains("## Release v0.7.0\n\n_No release notes provided._")); + } }