github.rs

  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}