Skip to main content

fc_gpui_http_client/
github.rs

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