torii_lib/platforms/github/
release.rs1use 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 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#[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}