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