1use sr_core::error::ReleaseError;
2use sr_core::release::VcsProvider;
3
4pub struct GitHubProvider {
6 owner: String,
7 repo: String,
8 hostname: String,
9 token: String,
10}
11
12#[derive(serde::Deserialize)]
13struct ReleaseResponse {
14 id: u64,
15 html_url: String,
16 upload_url: String,
17}
18
19impl GitHubProvider {
20 pub fn new(owner: String, repo: String, hostname: String, token: String) -> Self {
21 Self {
22 owner,
23 repo,
24 hostname,
25 token,
26 }
27 }
28
29 fn base_url(&self) -> String {
30 format!("https://{}/{}/{}", self.hostname, self.owner, self.repo)
31 }
32
33 fn api_url(&self) -> String {
34 if self.hostname == "github.com" {
35 "https://api.github.com".to_string()
36 } else {
37 format!("https://{}/api/v3", self.hostname)
38 }
39 }
40
41 fn agent(&self) -> ureq::Agent {
42 ureq::Agent::new_with_config(ureq::config::Config::builder().https_only(true).build())
43 }
44
45 fn get_release_by_tag(&self, tag: &str) -> Result<ReleaseResponse, ReleaseError> {
46 let url = format!(
47 "{}/repos/{}/{}/releases/tags/{tag}",
48 self.api_url(),
49 self.owner,
50 self.repo
51 );
52 let resp = self
53 .agent()
54 .get(&url)
55 .header("Authorization", &format!("Bearer {}", self.token))
56 .header("Accept", "application/vnd.github+json")
57 .header("X-GitHub-Api-Version", "2022-11-28")
58 .header("User-Agent", "sr-github")
59 .call()
60 .map_err(|e| ReleaseError::Vcs(format!("GitHub API GET {url}: {e}")))?;
61 let release: ReleaseResponse = resp
62 .into_body()
63 .read_json()
64 .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
65 Ok(release)
66 }
67}
68
69impl VcsProvider for GitHubProvider {
70 fn create_release(
71 &self,
72 tag: &str,
73 name: &str,
74 body: &str,
75 prerelease: bool,
76 draft: bool,
77 ) -> Result<String, ReleaseError> {
78 let url = format!(
79 "{}/repos/{}/{}/releases",
80 self.api_url(),
81 self.owner,
82 self.repo
83 );
84 let payload = serde_json::json!({
85 "tag_name": tag,
86 "name": name,
87 "body": body,
88 "prerelease": prerelease,
89 "draft": draft,
90 });
91
92 let resp = self
93 .agent()
94 .post(&url)
95 .header("Authorization", &format!("Bearer {}", self.token))
96 .header("Accept", "application/vnd.github+json")
97 .header("X-GitHub-Api-Version", "2022-11-28")
98 .header("User-Agent", "sr-github")
99 .send_json(&payload)
100 .map_err(|e| ReleaseError::Vcs(format!("GitHub API POST {url}: {e}")))?;
101
102 let release: ReleaseResponse = resp
103 .into_body()
104 .read_json()
105 .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
106
107 Ok(release.html_url)
108 }
109
110 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
111 Ok(format!("{}/compare/{base}...{head}", self.base_url()))
112 }
113
114 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
115 let url = format!(
116 "{}/repos/{}/{}/releases/tags/{tag}",
117 self.api_url(),
118 self.owner,
119 self.repo
120 );
121 match self
122 .agent()
123 .get(&url)
124 .header("Authorization", &format!("Bearer {}", self.token))
125 .header("Accept", "application/vnd.github+json")
126 .header("X-GitHub-Api-Version", "2022-11-28")
127 .header("User-Agent", "sr-github")
128 .call()
129 {
130 Ok(_) => Ok(true),
131 Err(ureq::Error::StatusCode(404)) => Ok(false),
132 Err(e) => Err(ReleaseError::Vcs(format!("GitHub API GET {url}: {e}"))),
133 }
134 }
135
136 fn repo_url(&self) -> Option<String> {
137 Some(self.base_url())
138 }
139
140 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
141 let release = self.get_release_by_tag(tag)?;
142 let url = format!(
143 "{}/repos/{}/{}/releases/{}",
144 self.api_url(),
145 self.owner,
146 self.repo,
147 release.id
148 );
149 self.agent()
150 .delete(&url)
151 .header("Authorization", &format!("Bearer {}", self.token))
152 .header("Accept", "application/vnd.github+json")
153 .header("X-GitHub-Api-Version", "2022-11-28")
154 .header("User-Agent", "sr-github")
155 .call()
156 .map_err(|e| ReleaseError::Vcs(format!("GitHub API DELETE {url}: {e}")))?;
157 Ok(())
158 }
159
160 fn update_release(
161 &self,
162 tag: &str,
163 name: &str,
164 body: &str,
165 prerelease: bool,
166 draft: bool,
167 ) -> Result<String, ReleaseError> {
168 let release = self.get_release_by_tag(tag)?;
169 let url = format!(
170 "{}/repos/{}/{}/releases/{}",
171 self.api_url(),
172 self.owner,
173 self.repo,
174 release.id
175 );
176 let payload = serde_json::json!({
177 "name": name,
178 "body": body,
179 "prerelease": prerelease,
180 "draft": draft,
181 });
182 let resp = self
183 .agent()
184 .patch(&url)
185 .header("Authorization", &format!("Bearer {}", self.token))
186 .header("Accept", "application/vnd.github+json")
187 .header("X-GitHub-Api-Version", "2022-11-28")
188 .header("User-Agent", "sr-github")
189 .send_json(&payload)
190 .map_err(|e| ReleaseError::Vcs(format!("GitHub API PATCH {url}: {e}")))?;
191 let updated: ReleaseResponse = resp
192 .into_body()
193 .read_json()
194 .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
195 Ok(updated.html_url)
196 }
197
198 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
199 let release = self.get_release_by_tag(tag)?;
200 let upload_base = release
204 .upload_url
205 .split('{')
206 .next()
207 .unwrap_or(&release.upload_url);
208
209 for file_path in files {
210 let path = std::path::Path::new(file_path);
211 let file_name = path
212 .file_name()
213 .and_then(|n| n.to_str())
214 .ok_or_else(|| ReleaseError::Vcs(format!("invalid file path: {file_path}")))?;
215
216 let data = std::fs::read(path)
217 .map_err(|e| ReleaseError::Vcs(format!("failed to read asset {file_path}: {e}")))?;
218
219 let content_type = mime_from_extension(file_name);
220 let url = format!("{upload_base}?name={file_name}");
221
222 let mut last_err = None;
224 for attempt in 0..3 {
225 if attempt > 0 {
226 std::thread::sleep(std::time::Duration::from_secs(1 << attempt));
227 eprintln!(
228 "Retrying upload of {file_name} (attempt {}/3)...",
229 attempt + 1
230 );
231 }
232 match self
233 .agent()
234 .post(&url)
235 .header("Authorization", &format!("Bearer {}", self.token))
236 .header("Accept", "application/vnd.github+json")
237 .header("X-GitHub-Api-Version", "2022-11-28")
238 .header("User-Agent", "sr-github")
239 .header("Content-Type", content_type)
240 .send(&data[..])
241 {
242 Ok(_) => {
243 last_err = None;
244 break;
245 }
246 Err(e) => {
247 last_err = Some(format!("GitHub API upload asset {file_name}: {e}"));
248 }
249 }
250 }
251 if let Some(err_msg) = last_err {
252 return Err(ReleaseError::Vcs(err_msg));
253 }
254 }
255
256 Ok(())
257 }
258
259 fn verify_release(&self, tag: &str) -> Result<(), ReleaseError> {
260 self.get_release_by_tag(tag)?;
262 Ok(())
263 }
264}
265
266fn mime_from_extension(filename: &str) -> &'static str {
268 match filename.rsplit('.').next().unwrap_or("") {
269 "gz" | "tgz" => "application/gzip",
270 "zip" => "application/zip",
271 "tar" => "application/x-tar",
272 "xz" => "application/x-xz",
273 "bz2" => "application/x-bzip2",
274 "zst" | "zstd" => "application/zstd",
275 "deb" => "application/vnd.debian.binary-package",
276 "rpm" => "application/x-rpm",
277 "dmg" => "application/x-apple-diskimage",
278 "msi" => "application/x-msi",
279 "exe" => "application/vnd.microsoft.portable-executable",
280 "sig" | "asc" => "application/pgp-signature",
281 "sha256" | "sha512" => "text/plain",
282 "json" => "application/json",
283 "txt" | "md" => "text/plain",
284 _ => "application/octet-stream",
285 }
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 fn github_com_provider() -> GitHubProvider {
293 GitHubProvider::new(
294 "urmzd".into(),
295 "sr".into(),
296 "github.com".into(),
297 "test-token".into(),
298 )
299 }
300
301 fn ghes_provider() -> GitHubProvider {
302 GitHubProvider::new(
303 "org".into(),
304 "repo".into(),
305 "ghes.example.com".into(),
306 "test-token".into(),
307 )
308 }
309
310 #[test]
311 fn test_api_url_github_com() {
312 assert_eq!(github_com_provider().api_url(), "https://api.github.com");
313 }
314
315 #[test]
316 fn test_api_url_ghes() {
317 assert_eq!(ghes_provider().api_url(), "https://ghes.example.com/api/v3");
318 }
319
320 #[test]
321 fn test_base_url() {
322 assert_eq!(
323 github_com_provider().base_url(),
324 "https://github.com/urmzd/sr"
325 );
326 assert_eq!(
327 ghes_provider().base_url(),
328 "https://ghes.example.com/org/repo"
329 );
330 }
331
332 #[test]
333 fn test_compare_url() {
334 let p = github_com_provider();
335 assert_eq!(
336 p.compare_url("v0.9.0", "v1.0.0").unwrap(),
337 "https://github.com/urmzd/sr/compare/v0.9.0...v1.0.0"
338 );
339 }
340
341 #[test]
342 fn test_repo_url() {
343 assert_eq!(
344 github_com_provider().repo_url().unwrap(),
345 "https://github.com/urmzd/sr"
346 );
347 }
348}