Skip to main content

torii_lib/platforms/github/
release.rs

1//! GitHub — release client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::release::*;
5use reqwest::blocking::Client;
6
7pub struct GitHubReleaseClient {
8    token: String,
9    base_url: String,
10}
11
12impl GitHubReleaseClient {
13    pub fn new() -> Result<Self> {
14        let token = crate::auth::resolve_token("github", ".")
15            .value
16            .ok_or_else(|| ToriiError::Auth {
17                provider: "github".into(),
18                message: "GitHub token not found. Run: torii auth set github YOUR_TOKEN"
19                    .to_string(),
20            })?;
21        Ok(Self {
22            token,
23            base_url: "https://api.github.com".to_string(),
24        })
25    }
26
27    fn client(&self) -> Client {
28        crate::http::make_client()
29    }
30    fn auth(&self) -> String {
31        format!("token {}", self.token)
32    }
33}
34
35impl ReleaseClient for GitHubReleaseClient {
36    fn list(&self, owner: &str, repo: &str, limit: usize) -> Result<Vec<Release>> {
37        let url = format!(
38            "{}/repos/{}/{}/releases?per_page={}",
39            self.base_url,
40            owner,
41            repo,
42            limit.clamp(1, 100)
43        );
44        let req = self
45            .client()
46            .get(&url)
47            .header("Authorization", self.auth())
48            .header("Accept", "application/vnd.github+json");
49        let json = crate::http::send_json(req, &format!("GitHub (url: {})", url))?;
50        crate::http::extract_array(&json, &url)?
51            .iter()
52            .map(parse_github_release)
53            .collect()
54    }
55
56    fn get(&self, owner: &str, repo: &str, tag: &str) -> Result<Release> {
57        let url = format!(
58            "{}/repos/{}/{}/releases/tags/{}",
59            self.base_url, owner, repo, tag
60        );
61        let req = self
62            .client()
63            .get(&url)
64            .header("Authorization", self.auth())
65            .header("Accept", "application/vnd.github+json");
66        let json = crate::http::send_json(req, &format!("GitHub (tag: {})", tag))?;
67        parse_github_release(&json)
68    }
69
70    fn edit(
71        &self,
72        owner: &str,
73        repo: &str,
74        tag: &str,
75        name: Option<&str>,
76        description: Option<&str>,
77    ) -> Result<()> {
78        // GitHub edit uses the numeric release id, not the tag — fetch it first.
79        let release = self.get(owner, repo, tag)?;
80        let id = release.id.ok_or_else(|| ToriiError::MalformedResponse {
81            provider: "github".into(),
82            message: "GitHub release missing id field; cannot edit".to_string(),
83        })?;
84        let url = format!("{}/repos/{}/{}/releases/{}", self.base_url, owner, repo, id);
85        let mut body = serde_json::Map::new();
86        if let Some(n) = name {
87            body.insert("name".into(), serde_json::Value::String(n.into()));
88        }
89        if let Some(d) = description {
90            body.insert("body".into(), serde_json::Value::String(d.into()));
91        }
92        if body.is_empty() {
93            return Err(ToriiError::Usage(
94                "edit needs at least one of --name or --notes".to_string(),
95            ));
96        }
97        let req = self
98            .client()
99            .patch(&url)
100            .header("Authorization", self.auth())
101            .header("Accept", "application/vnd.github+json")
102            .json(&serde_json::Value::Object(body));
103        crate::http::send_empty(req, "GitHub edit release")
104    }
105
106    fn delete(&self, owner: &str, repo: &str, tag: &str) -> Result<()> {
107        let release = self.get(owner, repo, tag)?;
108        let id = release.id.ok_or_else(|| ToriiError::MalformedResponse {
109            provider: "github".into(),
110            message: "GitHub release missing id; cannot delete".to_string(),
111        })?;
112        let url = format!("{}/repos/{}/{}/releases/{}", self.base_url, owner, repo, id);
113        let req = self
114            .client()
115            .delete(&url)
116            .header("Authorization", self.auth())
117            .header("Accept", "application/vnd.github+json");
118        crate::http::send_empty(req, "GitHub delete release")
119    }
120}
121
122pub(crate) fn parse_github_release(v: &serde_json::Value) -> Result<Release> {
123    let tag = v["tag_name"].as_str().unwrap_or("").to_string();
124    let id = v["id"]
125        .as_u64()
126        .map(|n| n.to_string())
127        .or_else(|| v["id"].as_str().map(String::from));
128    Ok(Release {
129        tag: tag.clone(),
130        name: v["name"].as_str().unwrap_or(&tag).to_string(),
131        description: v["body"].as_str().unwrap_or("").to_string(),
132        created_at: v["created_at"].as_str().unwrap_or("").to_string(),
133        web_url: v["html_url"].as_str().unwrap_or("").to_string(),
134        id,
135    })
136}
137
138// ============================================================================
139// GitLab Releases
140// ============================================================================
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use httpmock::prelude::*;
146
147    fn client_for(server: &MockServer) -> GitHubReleaseClient {
148        GitHubReleaseClient {
149            token: "test-token".into(),
150            base_url: server.base_url(),
151        }
152    }
153
154    #[test]
155    fn parse_github_release_maps_all_fields() {
156        let json = serde_json::json!({
157            "tag_name": "v1.2.3",
158            "name": "Release 1.2.3",
159            "body": "Changelog",
160            "created_at": "2026-03-01T00:00:00Z",
161            "html_url": "https://github.com/o/r/releases/tag/v1.2.3",
162            "id": 987u64,
163        });
164        let r = parse_github_release(&json).unwrap();
165        assert_eq!(r.tag, "v1.2.3");
166        assert_eq!(r.name, "Release 1.2.3");
167        assert_eq!(r.description, "Changelog");
168        assert_eq!(r.created_at, "2026-03-01T00:00:00Z");
169        assert_eq!(r.web_url, "https://github.com/o/r/releases/tag/v1.2.3");
170        assert_eq!(r.id.as_deref(), Some("987"));
171    }
172
173    #[test]
174    fn parse_github_release_falls_back_to_tag_when_name_missing() {
175        let json = serde_json::json!({ "tag_name": "v0.1.0" });
176        let r = parse_github_release(&json).unwrap();
177        assert_eq!(r.tag, "v0.1.0");
178        assert_eq!(r.name, "v0.1.0");
179        assert_eq!(r.description, "");
180        assert_eq!(r.id, None);
181    }
182
183    #[test]
184    fn list_parses_releases_from_api() {
185        let server = MockServer::start();
186        let m = server.mock(|when, then| {
187            when.method(GET)
188                .path("/repos/octo/demo/releases")
189                .query_param("per_page", "2")
190                .header("Authorization", "token test-token");
191            then.status(200).json_body(serde_json::json!([
192                { "tag_name": "v2.0.0", "name": "Two", "id": 2u64 },
193                { "tag_name": "v1.0.0", "name": "One", "id": 1u64 },
194            ]));
195        });
196        let releases = client_for(&server).list("octo", "demo", 2).unwrap();
197        m.assert();
198        assert_eq!(releases.len(), 2);
199        assert_eq!(releases[0].tag, "v2.0.0");
200        assert_eq!(releases[0].name, "Two");
201        assert_eq!(releases[1].id.as_deref(), Some("1"));
202    }
203
204    #[test]
205    fn delete_fetches_id_by_tag_then_deletes_with_auth() {
206        let server = MockServer::start();
207        let get_mock = server.mock(|when, then| {
208            when.method(GET)
209                .path("/repos/octo/demo/releases/tags/v1.0.0")
210                .header("Authorization", "token test-token");
211            then.status(200)
212                .json_body(serde_json::json!({ "tag_name": "v1.0.0", "id": 55u64 }));
213        });
214        let del_mock = server.mock(|when, then| {
215            when.method(DELETE)
216                .path("/repos/octo/demo/releases/55")
217                .header("Authorization", "token test-token");
218            then.status(204);
219        });
220        client_for(&server)
221            .delete("octo", "demo", "v1.0.0")
222            .unwrap();
223        get_mock.assert();
224        del_mock.assert();
225    }
226
227    #[test]
228    fn get_maps_404_to_platform_api_error() {
229        let server = MockServer::start();
230        server.mock(|when, then| {
231            when.method(GET)
232                .path("/repos/octo/demo/releases/tags/v9.9.9");
233            then.status(404)
234                .json_body(serde_json::json!({ "message": "Not Found" }));
235        });
236        let err = client_for(&server)
237            .get("octo", "demo", "v9.9.9")
238            .unwrap_err();
239        assert!(
240            matches!(err, ToriiError::PlatformApi { status: 404, .. }),
241            "expected PlatformApi 404, got: {err:?}"
242        );
243    }
244}