github.rs

  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}