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    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}