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