//! A Forgejo (or Gitea) instance as a release source: its API root, and //! how to find a repo's latest release there. use crate::checker::ReleaseSource; use anyhow::{Context, Result, bail}; use serde::Deserialize; #[derive(Debug, Clone)] pub struct ForgejoEndpoints { /// The instance's API root, i.e. `/api/v1`. pub api: String, } impl ForgejoEndpoints { /// `base_url` is the instance's web root, e.g. /// `https://code.austinschaefer.com`; a trailing slash is tolerated. pub fn from_base_url(base_url: &str) -> Self { Self { api: format!("{}/api/v1", base_url.trim_end_matches('/')), } } } impl ReleaseSource for ForgejoEndpoints { /// One call, unlike GitHub's feed-then-confirm dance: Forgejo's /// `releases/latest` already returns only the newest non-draft, /// non-prerelease *release object*, so a stray tag with no release /// behind it (GitHub's scaleway-cli `-dbg1` problem) can't be returned. fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result { #[derive(Deserialize)] struct Latest { tag_name: String, } let url = format!("{}/repos/{repo}/releases/latest", self.api); let response = client.get(&url).send()?; // Forgejo answers 404 both for an unknown repo and for one with no // releases yet — the common state for a project's very first // release. if response.status() == reqwest::StatusCode::NOT_FOUND { bail!("no published release found at {url} (repo missing, or nothing released yet)"); } let latest: Latest = response .error_for_status() .with_context(|| format!("fetching latest release from {url}"))? .json()?; Ok(latest.tag_name) } fn api(&self) -> &str { &self.api } } #[cfg(test)] mod tests { use super::*; #[test] fn from_base_url_appends_api_v1() { let endpoints = ForgejoEndpoints::from_base_url("https://code.example.com"); assert_eq!(endpoints.api(), "https://code.example.com/api/v1"); } #[test] fn from_base_url_tolerates_trailing_slash() { let endpoints = ForgejoEndpoints::from_base_url("https://code.example.com/"); assert_eq!(endpoints.api(), "https://code.example.com/api/v1"); } #[test] fn latest_release_returns_tag_name() { let mut server = mockito::Server::new(); let _latest = server .mock("GET", "/repos/o/r/releases/latest") .with_status(200) .with_body(r#"{"tag_name": "v0.1.0", "assets": []}"#) .create(); let client = reqwest::blocking::Client::new(); let endpoints = ForgejoEndpoints { api: server.url() }; assert_eq!(endpoints.latest_release(&client, "o/r").unwrap(), "v0.1.0"); } #[test] fn latest_release_names_the_no_releases_case() { let mut server = mockito::Server::new(); let _latest = server .mock("GET", "/repos/o/r/releases/latest") .with_status(404) .create(); let client = reqwest::blocking::Client::new(); let endpoints = ForgejoEndpoints { api: server.url() }; let err = endpoints.latest_release(&client, "o/r").unwrap_err(); assert!(err.to_string().contains("no published release")); } #[test] fn latest_release_surfaces_server_errors() { let mut server = mockito::Server::new(); let _latest = server .mock("GET", "/repos/o/r/releases/latest") .with_status(500) .create(); let client = reqwest::blocking::Client::new(); let endpoints = ForgejoEndpoints { api: server.url() }; let err = endpoints.latest_release(&client, "o/r").unwrap_err(); assert!(err.to_string().contains("fetching latest release")); } }