Skip to main content

http_client/
github.rs

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