zed_extension_api: Add `github_release_by_tag_name` (#12172)

Marshall Bowers created

This PR adds a new `github_release_by_tag_name` method to the
`zed_extension_api` to allow for retrieving a GitHub release by its tag
name.

Release Notes:

- N/A

Change summary

crates/extension/src/wasm_host/wit/since_v0_0_7.rs | 45 ++++++++++++---
crates/extension_api/src/extension_api.rs          |  3 
crates/extension_api/wit/since_v0.0.7/github.wit   |  5 +
crates/http/src/github.rs                          | 41 ++++++++++++++
4 files changed, 82 insertions(+), 12 deletions(-)

Detailed changes

crates/extension/src/wasm_host/wit/since_v0_0_7.rs 🔗

@@ -147,6 +147,24 @@ impl nodejs::Host for WasmState {
 #[async_trait]
 impl lsp::Host for WasmState {}
 
+impl From<http::github::GithubRelease> for github::GithubRelease {
+    fn from(value: http::github::GithubRelease) -> Self {
+        Self {
+            version: value.tag_name,
+            assets: value.assets.into_iter().map(Into::into).collect(),
+        }
+    }
+}
+
+impl From<http::github::GithubReleaseAsset> for github::GithubReleaseAsset {
+    fn from(value: http::github::GithubReleaseAsset) -> Self {
+        Self {
+            name: value.name,
+            download_url: value.browser_download_url,
+        }
+    }
+}
+
 #[async_trait]
 impl github::Host for WasmState {
     async fn latest_github_release(
@@ -162,17 +180,22 @@ impl github::Host for WasmState {
                 self.host.http_client.clone(),
             )
             .await?;
-            Ok(github::GithubRelease {
-                version: release.tag_name,
-                assets: release
-                    .assets
-                    .into_iter()
-                    .map(|asset| github::GithubReleaseAsset {
-                        name: asset.name,
-                        download_url: asset.browser_download_url,
-                    })
-                    .collect(),
-            })
+            Ok(release.into())
+        })
+        .await
+        .to_wasmtime_result()
+    }
+
+    async fn github_release_by_tag_name(
+        &mut self,
+        repo: String,
+        tag: String,
+    ) -> wasmtime::Result<Result<github::GithubRelease, String>> {
+        maybe!(async {
+            let release =
+                http::github::get_release_by_tag_name(&repo, &tag, self.host.http_client.clone())
+                    .await?;
+            Ok(release.into())
         })
         .await
         .to_wasmtime_result()

crates/extension_api/src/extension_api.rs 🔗

@@ -16,7 +16,8 @@ pub use serde_json;
 pub use wit::{
     download_file, make_file_executable,
     zed::extension::github::{
-        latest_github_release, GithubRelease, GithubReleaseAsset, GithubReleaseOptions,
+        github_release_by_tag_name, latest_github_release, GithubRelease, GithubReleaseAsset,
+        GithubReleaseOptions,
     },
     zed::extension::nodejs::{
         node_binary_path, npm_install_package, npm_package_installed_version,

crates/extension_api/wit/since_v0.0.7/github.wit 🔗

@@ -25,4 +25,9 @@ interface github {
 
     /// Returns the latest release for the given GitHub repository.
     latest-github-release: func(repo: string, options: github-release-options) -> result<github-release, string>;
+
+    /// Returns the GitHub release with the specified tag name for the given GitHub repository.
+    ///
+    /// Returns an error if a release with the given tag name does not exist.
+    github-release-by-tag-name: func(repo: string, tag: string) -> result<github-release, string>;
 }

crates/http/src/github.rs 🔗

@@ -76,6 +76,47 @@ pub async fn latest_github_release(
         .ok_or(anyhow!("Failed to find a release"))
 }
 
+pub async fn get_release_by_tag_name(
+    repo_name_with_owner: &str,
+    tag: &str,
+    http: Arc<dyn HttpClient>,
+) -> Result<GithubRelease, anyhow::Error> {
+    let mut response = http
+        .get(
+            &format!("https://api.github.com/repos/{repo_name_with_owner}/releases/tags/{tag}"),
+            Default::default(),
+            true,
+        )
+        .await
+        .context("error fetching latest release")?;
+
+    let mut body = Vec::new();
+    response
+        .body_mut()
+        .read_to_end(&mut body)
+        .await
+        .context("error reading latest release")?;
+
+    if response.status().is_client_error() {
+        let text = String::from_utf8_lossy(body.as_slice());
+        bail!(
+            "status error {}, response: {text:?}",
+            response.status().as_u16()
+        );
+    }
+
+    let release = serde_json::from_slice::<GithubRelease>(body.as_slice()).map_err(|err| {
+        log::error!("Error deserializing: {:?}", err);
+        log::error!(
+            "GitHub API response text: {:?}",
+            String::from_utf8_lossy(body.as_slice())
+        );
+        anyhow!("error deserializing GitHub release")
+    })?;
+
+    Ok(release)
+}
+
 pub fn build_tarball_url(repo_name_with_owner: &str, tag: &str) -> Result<String> {
     let mut url = Url::parse(&format!(
         "https://github.com/{repo_name_with_owner}/archive/refs/tags",