intelli_shell/service/
release.rs1use chrono::{DateTime, Utc};
2use color_eyre::eyre::Context;
3use reqwest::header;
4use semver::Version;
5use serde::Deserialize;
6use tokio_util::sync::CancellationToken;
7use tracing::instrument;
8
9use super::IntelliShellService;
10use crate::{
11 errors::{Result, UserFacingError},
12 model::IntelliShellRelease,
13};
14
15const MAX_RELEASES_KEPT: usize = 50;
17const PAGE_SIZE: usize = 30;
19const MAX_PAGES: usize = MAX_RELEASES_KEPT.div_ceil(PAGE_SIZE);
21
22impl IntelliShellService {
23 #[instrument(skip_all)]
27 pub async fn get_or_fetch_releases(
28 &self,
29 force_fetch: bool,
30 token: CancellationToken,
31 ) -> Result<Vec<IntelliShellRelease>> {
32 let now = Utc::now();
33 let latest_info = self.storage.get_latest_stored_version().await?;
34
35 let should_fetch = force_fetch
37 || latest_info.as_ref().is_none_or(|(version, fetched_at)| {
38 let is_stale = (now - *fetched_at) >= super::FETCH_INTERVAL;
40 let is_behind_current = version < &*super::CURRENT_VERSION;
41 is_stale || is_behind_current
42 });
43
44 if should_fetch {
45 let target_version = latest_info.as_ref().map(|(v, _)| v.clone());
46 let fetched_releases = fetch_release_history_from_github(target_version, token).await?;
47 if !fetched_releases.is_empty() {
48 self.storage.upsert_releases(fetched_releases).await?;
53 self.storage.prune_releases(MAX_RELEASES_KEPT).await?;
54 }
55 } else {
56 tracing::debug!("Skipping release retrieval as it was checked recently");
57 }
58
59 self.storage.get_releases().await
61 }
62}
63
64#[derive(Deserialize, Debug)]
66struct GithubRelease {
67 tag_name: String,
68 published_at: DateTime<Utc>,
69 #[serde(default)]
70 name: Option<String>,
71 #[serde(default)]
72 body: Option<String>,
73 #[serde(default)]
74 prerelease: bool,
75 #[serde(default)]
76 draft: bool,
77}
78
79async fn fetch_release_history_from_github(
84 target_version: Option<Version>,
85 token: CancellationToken,
86) -> Result<Vec<IntelliShellRelease>> {
87 tracing::debug!("Fetching GitHub release history (target: {target_version:?})");
88
89 let client = reqwest::Client::new();
90 let fetch_limit = if target_version.is_none() { 1 } else { MAX_PAGES };
91 let mut results = Vec::new();
92
93 for page in 1..=fetch_limit {
95 let releases = fetch_github_page(&client, page, token.clone()).await?;
96
97 if releases.is_empty() {
98 break;
99 }
100
101 let mut should_stop_after_page = false;
102 for r in releases {
103 if r.draft || r.prerelease {
105 continue;
106 }
107
108 let version_str = r.tag_name.trim_start_matches('v');
110 if let Ok(v) = Version::parse(version_str) {
111 if let Some(target) = &target_version
115 && &v <= target
116 {
117 should_stop_after_page = true;
118 }
119
120 results.push(IntelliShellRelease {
121 title: r.name.unwrap_or_else(|| r.tag_name.clone()),
122 tag: r.tag_name,
123 body: r.body,
124 published_at: r.published_at,
125 version: v,
126 fetched_at: Utc::now(),
127 });
128 }
129 }
130
131 if should_stop_after_page {
132 break;
133 }
134 }
135
136 Ok(results)
137}
138
139async fn fetch_github_page(
141 client: &reqwest::Client,
142 page: usize,
143 token: CancellationToken,
144) -> Result<Vec<GithubRelease>> {
145 tracing::trace!("Fetching page {page}...");
146
147 let res = tokio::select! {
148 biased;
149 _ = token.cancelled() => {
150 return Err(UserFacingError::Cancelled.into());
151 }
152 res = client
153 .get("https://api.github.com/repos/lasantosr/intelli-shell/releases")
154 .query(&[("per_page", PAGE_SIZE), ("page", page)])
155 .header(header::USER_AGENT, "intelli-shell")
156 .timeout(super::REQUEST_TIMEOUT)
157 .send() => res.map_err(|err| {
158 tracing::error!("{err:?}");
159 UserFacingError::ReleaseRequestFailed(err.to_string())
160 })?
161 };
162
163 if !res.status().is_success() {
164 let status = res.status();
165 let status_str = status.as_str();
166 let body = res.text().await.unwrap_or_default();
167 let message = serde_json::from_str::<serde_json::Value>(&body)
168 .ok()
169 .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(|s| s.to_string()))
170 .unwrap_or_else(|| format!("received {status_str} response"));
171 if let Some(reason) = status.canonical_reason() {
172 tracing::error!("Got response [{status_str}] {reason}:\n{body}");
173 return Err(UserFacingError::ReleaseRequestFailed(message).into());
174 } else {
175 tracing::error!("Got response [{status_str}]:\n{body}");
176 return Err(UserFacingError::ReleaseRequestFailed(message).into());
177 }
178 }
179
180 Ok(res.json().await.wrap_err("Failed to parse releases response")?)
181}