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