1use crate::http::HttpClient;
2use anyhow::{anyhow, bail, Context, Result};
3use futures::AsyncReadExt;
4use isahc::{config::Configurable, AsyncBody, Request};
5use serde::Deserialize;
6use std::sync::Arc;
7use url::Url;
8
9pub struct GitHubLspBinaryVersion {
10 pub name: String,
11 pub url: String,
12}
13
14#[derive(Deserialize, Debug)]
15pub struct GithubRelease {
16 pub tag_name: String,
17 #[serde(rename = "prerelease")]
18 pub pre_release: bool,
19 pub assets: Vec<GithubReleaseAsset>,
20 pub tarball_url: String,
21 pub zipball_url: String,
22}
23
24#[derive(Deserialize, Debug)]
25pub struct GithubReleaseAsset {
26 pub name: String,
27 pub browser_download_url: String,
28}
29
30#[derive(Debug, Deserialize)]
31struct CommitDetails {
32 commit: Commit,
33 author: Option<User>,
34}
35
36#[derive(Debug, Deserialize)]
37struct Commit {
38 author: Author,
39}
40
41#[derive(Debug, Deserialize)]
42struct Author {
43 email: String,
44}
45
46#[derive(Debug, Deserialize)]
47struct User {
48 pub id: u64,
49 pub avatar_url: String,
50}
51
52#[derive(Debug)]
53pub struct GitHubAuthor {
54 pub id: u64,
55 pub email: String,
56 pub avatar_url: String,
57}
58
59pub async fn fetch_github_commit_author(
60 repo_owner: &str,
61 repo: &str,
62 commit: &str,
63 client: &Arc<dyn HttpClient>,
64) -> Result<Option<GitHubAuthor>> {
65 let url = format!("https://api.github.com/repos/{repo_owner}/{repo}/commits/{commit}");
66
67 let mut request = Request::get(&url)
68 .redirect_policy(isahc::config::RedirectPolicy::Follow)
69 .header("Content-Type", "application/json");
70
71 if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
72 request = request.header("Authorization", format!("Bearer {}", github_token));
73 }
74
75 let mut response = client
76 .send(request.body(AsyncBody::default())?)
77 .await
78 .with_context(|| format!("error fetching GitHub commit details at {:?}", url))?;
79
80 let mut body = Vec::new();
81 response.body_mut().read_to_end(&mut body).await?;
82
83 if response.status().is_client_error() {
84 let text = String::from_utf8_lossy(body.as_slice());
85 bail!(
86 "status error {}, response: {text:?}",
87 response.status().as_u16()
88 );
89 }
90
91 let body_str = std::str::from_utf8(&body)?;
92
93 serde_json::from_str::<CommitDetails>(body_str)
94 .map(|github_commit| {
95 if let Some(author) = github_commit.author {
96 Some(GitHubAuthor {
97 id: author.id,
98 avatar_url: author.avatar_url,
99 email: github_commit.commit.author.email,
100 })
101 } else {
102 None
103 }
104 })
105 .context("deserializing GitHub commit details failed")
106}
107
108pub async fn latest_github_release(
109 repo_name_with_owner: &str,
110 require_assets: bool,
111 pre_release: bool,
112 http: Arc<dyn HttpClient>,
113) -> Result<GithubRelease, anyhow::Error> {
114 let mut response = http
115 .get(
116 &format!("https://api.github.com/repos/{repo_name_with_owner}/releases"),
117 Default::default(),
118 true,
119 )
120 .await
121 .context("error fetching latest release")?;
122
123 let mut body = Vec::new();
124 response
125 .body_mut()
126 .read_to_end(&mut body)
127 .await
128 .context("error reading latest release")?;
129
130 if response.status().is_client_error() {
131 let text = String::from_utf8_lossy(body.as_slice());
132 bail!(
133 "status error {}, response: {text:?}",
134 response.status().as_u16()
135 );
136 }
137
138 let releases = match serde_json::from_slice::<Vec<GithubRelease>>(body.as_slice()) {
139 Ok(releases) => releases,
140
141 Err(err) => {
142 log::error!("Error deserializing: {:?}", err);
143 log::error!(
144 "GitHub API response text: {:?}",
145 String::from_utf8_lossy(body.as_slice())
146 );
147 return Err(anyhow!("error deserializing latest release"));
148 }
149 };
150
151 releases
152 .into_iter()
153 .filter(|release| !require_assets || !release.assets.is_empty())
154 .find(|release| release.pre_release == pre_release)
155 .ok_or(anyhow!("Failed to find a release"))
156}
157
158pub fn build_tarball_url(repo_name_with_owner: &str, tag: &str) -> Result<String> {
159 let mut url = Url::parse(&format!(
160 "https://github.com/{repo_name_with_owner}/archive/refs/tags",
161 ))?;
162 // We're pushing this here, because tags may contain `/` and other characters
163 // that need to be escaped.
164 let tarball_filename = format!("{}.tar.gz", tag);
165 url.path_segments_mut()
166 .map_err(|_| anyhow!("cannot modify url path segments"))?
167 .push(&tarball_filename);
168 Ok(url.to_string())
169}
170
171#[cfg(test)]
172mod tests {
173 use crate::github::build_tarball_url;
174
175 #[test]
176 fn test_build_tarball_url() {
177 let tag = "release/2.3.5";
178 let repo_name_with_owner = "microsoft/vscode-eslint";
179
180 let have = build_tarball_url(repo_name_with_owner, tag).unwrap();
181
182 assert_eq!(
183 have,
184 "https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F2.3.5.tar.gz"
185 );
186 }
187}