Skip to main content

intelli_shell/service/
release.rs

1use 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
15/// Maximum number of releases to keep in the database
16const MAX_RELEASES_KEPT: usize = 50;
17/// Number of releases to fetch per page
18const PAGE_SIZE: usize = 30;
19/// Maximum number of pages to fetch, as a safeguard to prevent infinite loops
20const MAX_PAGES: usize = MAX_RELEASES_KEPT.div_ceil(PAGE_SIZE);
21
22impl IntelliShellService {
23    /// Retrieves releases from the database sorted by descending version, optionally fetching them from GitHub.
24    ///
25    /// Returns stored releases, fetching from GitHub if data is missing, stale or if `force_fetch` is true.
26    #[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        // Determine if we should hit the GitHub API
36        let should_fetch = force_fetch
37            || latest_info.as_ref().is_none_or(|(version, fetched_at)| {
38                // Fetch if interval passed or we lack current version's data
39                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                // Upsert all fetched releases and maintain database size.
49                // We always upsert to:
50                // 1. Refresh 'fetched_at' timestamp (preventing immediate re-fetch).
51                // 2. Pull content updates/fixes (e.g. body typo fixes) from GitHub.
52                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        // Return stored releases
60        self.storage.get_releases().await
61    }
62}
63
64/// A simple struct to deserialize the relevant fields from the GitHub API response
65#[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
79/// Fetches release history from GitHub starting from the `target_version` (or strictly newer).
80///
81/// This function handles pagination to ensure all intermediate releases are fetched.
82/// It returns a vector of releases found (newer than target_version).
83async 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    // Fetch paginated releases from GitHub
94    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            // Keep only published stable releases
104            if r.draft || r.prerelease {
105                continue;
106            }
107
108            // Parse version
109            let version_str = r.tag_name.trim_start_matches('v');
110            if let Ok(v) = Version::parse(version_str) {
111                // If we hit the target, we mark that we should stop AFTER this page.
112                // This ensures all releases on this page are returned and upserted,
113                // but we won't fetch any more pages.
114                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
139/// Fetches a single page of releases from GitHub
140async 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}