1use crate::{AsyncBody, HttpClient, HttpRequestExt};
2use anyhow::{Context as _, Result, anyhow, bail};
3use futures::AsyncReadExt;
4use http::Request;
5use serde::{Deserialize, Deserializer};
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 #[serde(default, deserialize_with = "deserialize_sha256_digest")]
33 pub digest: Option<String>,
34}
35
36fn deserialize_sha256_digest<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
37where
38 D: Deserializer<'de>,
39{
40 const PREFIX: &str = "sha256:";
41
42 let mut digest = Option::<String>::deserialize(deserializer)?;
43 if let Some(digest) = digest.as_mut()
44 && digest.starts_with(PREFIX)
45 {
46 digest.replace_range(..PREFIX.len(), "");
47 }
48 Ok(digest)
49}
50
51pub async fn latest_github_release(
52 repo_name_with_owner: &str,
53 require_assets: bool,
54 pre_release: bool,
55 http: Arc<dyn HttpClient>,
56) -> anyhow::Result<GithubRelease> {
57 let url = format!("{GITHUB_API_URL}/repos/{repo_name_with_owner}/releases");
58
59 let request = github_api_request(&url)?;
60
61 let mut response = http
62 .send(request)
63 .await
64 .context("error fetching latest release")?;
65
66 let mut body = Vec::new();
67 response
68 .body_mut()
69 .read_to_end(&mut body)
70 .await
71 .context("error reading latest release")?;
72
73 if response.status().is_client_error() {
74 let text = String::from_utf8_lossy(body.as_slice());
75 bail!(
76 "status error {}, response: {text:?}",
77 response.status().as_u16()
78 );
79 }
80
81 let releases = match serde_json::from_slice::<Vec<GithubRelease>>(body.as_slice()) {
82 Ok(releases) => releases,
83
84 Err(err) => {
85 log::error!("Error deserializing: {err:?}");
86 log::error!(
87 "GitHub API response text: {:?}",
88 String::from_utf8_lossy(body.as_slice())
89 );
90 anyhow::bail!("error deserializing latest release: {err:?}");
91 }
92 };
93
94 releases
95 .into_iter()
96 .filter(|release| !require_assets || !release.assets.is_empty())
97 .find(|release| release.pre_release == pre_release)
98 .context("finding a prerelease")
99}
100
101pub async fn get_release_by_tag_name(
102 repo_name_with_owner: &str,
103 tag: &str,
104 http: Arc<dyn HttpClient>,
105) -> anyhow::Result<GithubRelease> {
106 let url = format!("{GITHUB_API_URL}/repos/{repo_name_with_owner}/releases/tags/{tag}");
107
108 let request = github_api_request(&url)?;
109
110 let mut response = http
111 .send(request)
112 .await
113 .context("error fetching latest release")?;
114
115 let mut body = Vec::new();
116 let status = response.status();
117 response
118 .body_mut()
119 .read_to_end(&mut body)
120 .await
121 .context("error reading latest release")?;
122
123 if status.is_client_error() {
124 let text = String::from_utf8_lossy(body.as_slice());
125 bail!(
126 "status error {}, response: {text:?}",
127 response.status().as_u16()
128 );
129 }
130
131 let release = serde_json::from_slice::<GithubRelease>(body.as_slice()).map_err(|err| {
132 log::error!("Error deserializing: {err:?}");
133 log::error!(
134 "GitHub API response text: {:?}",
135 String::from_utf8_lossy(body.as_slice())
136 );
137 anyhow!("error deserializing GitHub release: {err:?}")
138 })?;
139
140 Ok(release)
141}
142
143fn github_api_request(url: &str) -> Result<Request<AsyncBody>> {
144 Request::get(url)
145 .follow_redirects(crate::RedirectPolicy::FollowAll)
146 .timeout(GITHUB_RELEASE_REQUEST_TIMEOUT)
147 .when_some(std::env::var("GITHUB_TOKEN").ok(), |builder, token| {
148 builder.header("Authorization", format!("Bearer {}", token))
149 })
150 .body(Default::default())
151 .map_err(Into::into)
152}
153
154#[derive(Debug, PartialEq, Eq, Clone, Copy)]
155pub enum AssetKind {
156 TarGz,
157 TarBz2,
158 Gz,
159 Zip,
160}
161
162pub fn build_asset_url(repo_name_with_owner: &str, tag: &str, kind: AssetKind) -> Result<String> {
163 let mut url = Url::parse(&format!(
164 "https://github.com/{repo_name_with_owner}/archive/refs/tags",
165 ))?;
166 let asset_filename = format!(
169 "{tag}.{extension}",
170 extension = match kind {
171 AssetKind::TarGz => "tar.gz",
172 AssetKind::TarBz2 => "tar.bz2",
173 AssetKind::Gz => "gz",
174 AssetKind::Zip => "zip",
175 }
176 );
177 url.path_segments_mut()
178 .map_err(|()| anyhow!("cannot modify url path segments"))?
179 .push(&asset_filename);
180 Ok(url.to_string())
181}
182
183#[cfg(test)]
184mod tests {
185 use crate::{
186 RequestTimeout,
187 github::{
188 AssetKind, GITHUB_RELEASE_REQUEST_TIMEOUT, GithubReleaseAsset, build_asset_url,
189 github_api_request,
190 },
191 };
192
193 #[test]
194 fn test_asset_digest_deserialization() {
195 let parse = |json: &str| {
196 serde_json::from_str::<GithubReleaseAsset>(json)
197 .unwrap()
198 .digest
199 };
200
201 assert_eq!(
202 parse(r#"{"name":"a","browser_download_url":"u","digest":"sha256:abc"}"#),
203 Some("abc".to_string())
204 );
205 assert_eq!(
206 parse(r#"{"name":"a","browser_download_url":"u","digest":"abc"}"#),
207 Some("abc".to_string())
208 );
209 assert_eq!(
210 parse(r#"{"name":"a","browser_download_url":"u","digest":null}"#),
211 None
212 );
213 assert_eq!(parse(r#"{"name":"a","browser_download_url":"u"}"#), None);
214 }
215
216 #[test]
217 fn github_api_requests_have_a_total_deadline() {
218 let request =
219 github_api_request("https://api.github.com/repos/zed-industries/zed/releases").unwrap();
220
221 assert_eq!(
222 request.extensions().get::<RequestTimeout>(),
223 Some(&RequestTimeout(GITHUB_RELEASE_REQUEST_TIMEOUT))
224 );
225 }
226
227 #[test]
228 fn test_build_asset_url() {
229 let tag = "release/2.3.5";
230 let repo_name_with_owner = "microsoft/vscode-eslint";
231
232 let tarball = build_asset_url(repo_name_with_owner, tag, AssetKind::TarGz).unwrap();
233 assert_eq!(
234 tarball,
235 "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.tar.gz"
236 );
237
238 let zip = build_asset_url(repo_name_with_owner, tag, AssetKind::Zip).unwrap();
239 assert_eq!(
240 zip,
241 "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.zip"
242 );
243 }
244}