1use crate::HttpClient;
  2use anyhow::{Context as _, Result, anyhow, bail};
  3use futures::AsyncReadExt;
  4use serde::Deserialize;
  5use std::sync::Arc;
  6use url::Url;
  7
  8pub struct GitHubLspBinaryVersion {
  9    pub name: String,
 10    pub url: String,
 11    pub digest: Option<String>,
 12}
 13
 14#[derive(Deserialize, Debug)]
 15pub struct GithubRelease {
 16    pub tag_name: String,
 17    #[serde(rename = "prerelease")]
 18    pub pre_release: bool,
 19    pub assets: Vec<GithubReleaseAsset>,
 20    pub tarball_url: String,
 21    pub zipball_url: String,
 22}
 23
 24#[derive(Deserialize, Debug)]
 25pub struct GithubReleaseAsset {
 26    pub name: String,
 27    pub browser_download_url: String,
 28    pub digest: Option<String>,
 29}
 30
 31pub async fn latest_github_release(
 32    repo_name_with_owner: &str,
 33    require_assets: bool,
 34    pre_release: bool,
 35    http: Arc<dyn HttpClient>,
 36) -> anyhow::Result<GithubRelease> {
 37    let mut response = http
 38        .get(
 39            format!("https://api.github.com/repos/{repo_name_with_owner}/releases").as_str(),
 40            Default::default(),
 41            true,
 42        )
 43        .await
 44        .context("error fetching latest release")?;
 45
 46    let mut body = Vec::new();
 47    response
 48        .body_mut()
 49        .read_to_end(&mut body)
 50        .await
 51        .context("error reading latest release")?;
 52
 53    if response.status().is_client_error() {
 54        let text = String::from_utf8_lossy(body.as_slice());
 55        bail!(
 56            "status error {}, response: {text:?}",
 57            response.status().as_u16()
 58        );
 59    }
 60
 61    let releases = match serde_json::from_slice::<Vec<GithubRelease>>(body.as_slice()) {
 62        Ok(releases) => releases,
 63
 64        Err(err) => {
 65            log::error!("Error deserializing: {err:?}");
 66            log::error!(
 67                "GitHub API response text: {:?}",
 68                String::from_utf8_lossy(body.as_slice())
 69            );
 70            anyhow::bail!("error deserializing latest release: {err:?}");
 71        }
 72    };
 73
 74    let mut release = releases
 75        .into_iter()
 76        .filter(|release| !require_assets || !release.assets.is_empty())
 77        .find(|release| release.pre_release == pre_release)
 78        .context("finding a prerelease")?;
 79    release.assets.iter_mut().for_each(|asset| {
 80        if let Some(digest) = &mut asset.digest
 81            && let Some(stripped) = digest.strip_prefix("sha256:")
 82        {
 83            *digest = stripped.to_owned();
 84        }
 85    });
 86    Ok(release)
 87}
 88
 89pub async fn get_release_by_tag_name(
 90    repo_name_with_owner: &str,
 91    tag: &str,
 92    http: Arc<dyn HttpClient>,
 93) -> anyhow::Result<GithubRelease> {
 94    let mut response = http
 95        .get(
 96            &format!("https://api.github.com/repos/{repo_name_with_owner}/releases/tags/{tag}"),
 97            Default::default(),
 98            true,
 99        )
100        .await
101        .context("error fetching latest release")?;
102
103    let mut body = Vec::new();
104    let status = response.status();
105    response
106        .body_mut()
107        .read_to_end(&mut body)
108        .await
109        .context("error reading latest release")?;
110
111    if status.is_client_error() {
112        let text = String::from_utf8_lossy(body.as_slice());
113        bail!(
114            "status error {}, response: {text:?}",
115            response.status().as_u16()
116        );
117    }
118
119    let release = serde_json::from_slice::<GithubRelease>(body.as_slice()).map_err(|err| {
120        log::error!("Error deserializing: {err:?}");
121        log::error!(
122            "GitHub API response text: {:?}",
123            String::from_utf8_lossy(body.as_slice())
124        );
125        anyhow!("error deserializing GitHub release: {err:?}")
126    })?;
127
128    Ok(release)
129}
130
131#[derive(Debug, PartialEq, Eq, Clone, Copy)]
132pub enum AssetKind {
133    TarGz,
134    Gz,
135    Zip,
136}
137
138pub fn build_asset_url(repo_name_with_owner: &str, tag: &str, kind: AssetKind) -> Result<String> {
139    let mut url = Url::parse(&format!(
140        "https://github.com/{repo_name_with_owner}/archive/refs/tags",
141    ))?;
142    // We're pushing this here, because tags may contain `/` and other characters
143    // that need to be escaped.
144    let asset_filename = format!(
145        "{tag}.{extension}",
146        extension = match kind {
147            AssetKind::TarGz => "tar.gz",
148            AssetKind::Gz => "gz",
149            AssetKind::Zip => "zip",
150        }
151    );
152    url.path_segments_mut()
153        .map_err(|()| anyhow!("cannot modify url path segments"))?
154        .push(&asset_filename);
155    Ok(url.to_string())
156}
157
158#[cfg(test)]
159mod tests {
160    use crate::github::{AssetKind, build_asset_url};
161
162    #[test]
163    fn test_build_asset_url() {
164        let tag = "release/2.3.5";
165        let repo_name_with_owner = "microsoft/vscode-eslint";
166
167        let tarball = build_asset_url(repo_name_with_owner, tag, AssetKind::TarGz).unwrap();
168        assert_eq!(
169            tarball,
170            "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.tar.gz"
171        );
172
173        let zip = build_asset_url(repo_name_with_owner, tag, AssetKind::Zip).unwrap();
174        assert_eq!(
175            zip,
176            "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.zip"
177        );
178    }
179}