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 Gz,
148 Zip,
149}
150
151pub fn build_asset_url(repo_name_with_owner: &str, tag: &str, kind: AssetKind) -> Result<String> {
152 let mut url = Url::parse(&format!(
153 "https://github.com/{repo_name_with_owner}/archive/refs/tags",
154 ))?;
155 // We're pushing this here, because tags may contain `/` and other characters
156 // that need to be escaped.
157 let asset_filename = format!(
158 "{tag}.{extension}",
159 extension = match kind {
160 AssetKind::TarGz => "tar.gz",
161 AssetKind::Gz => "gz",
162 AssetKind::Zip => "zip",
163 }
164 );
165 url.path_segments_mut()
166 .map_err(|()| anyhow!("cannot modify url path segments"))?
167 .push(&asset_filename);
168 Ok(url.to_string())
169}
170
171#[cfg(test)]
172mod tests {
173 use crate::github::{AssetKind, build_asset_url};
174
175 #[test]
176 fn test_build_asset_url() {
177 let tag = "release/2.3.5";
178 let repo_name_with_owner = "microsoft/vscode-eslint";
179
180 let tarball = build_asset_url(repo_name_with_owner, tag, AssetKind::TarGz).unwrap();
181 assert_eq!(
182 tarball,
183 "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.tar.gz"
184 );
185
186 let zip = build_asset_url(repo_name_with_owner, tag, AssetKind::Zip).unwrap();
187 assert_eq!(
188 zip,
189 "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.zip"
190 );
191 }
192}