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 #[serde(default)]
18 assets: Vec<ReleaseAsset>,
19}
20
21#[derive(serde::Deserialize)]
22struct ReleaseAsset {
23 id: u64,
24 name: String,
25 browser_download_url: String,
26}
27
28impl GitHubProvider {
29 pub fn new(owner: String, repo: String, hostname: String, token: String) -> Self {
30 Self {
31 owner,
32 repo,
33 hostname,
34 token,
35 }
36 }
37
38 fn base_url(&self) -> String {
39 format!("https://{}/{}/{}", self.hostname, self.owner, self.repo)
40 }
41
42 fn api_url(&self) -> String {
43 if self.hostname == "github.com" {
44 "https://api.github.com".to_string()
45 } else {
46 format!("https://{}/api/v3", self.hostname)
47 }
48 }
49
50 fn agent(&self) -> ureq::Agent {
51 ureq::Agent::new_with_config(ureq::config::Config::builder().https_only(true).build())
52 }
53
54 fn get_release_by_tag(&self, tag: &str) -> Result<ReleaseResponse, ReleaseError> {
55 let url = format!(
56 "{}/repos/{}/{}/releases/tags/{tag}",
57 self.api_url(),
58 self.owner,
59 self.repo
60 );
61 let resp = self
62 .agent()
63 .get(&url)
64 .header("Authorization", &format!("Bearer {}", self.token))
65 .header("Accept", "application/vnd.github+json")
66 .header("X-GitHub-Api-Version", "2022-11-28")
67 .header("User-Agent", "sr-github")
68 .call()
69 .map_err(|e| ReleaseError::Vcs(format!("GitHub API GET {url}: {e}")))?;
70 let release: ReleaseResponse = resp
71 .into_body()
72 .read_json()
73 .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
74 Ok(release)
75 }
76}
77
78impl VcsProvider for GitHubProvider {
79 fn create_release(
80 &self,
81 tag: &str,
82 name: &str,
83 body: &str,
84 prerelease: bool,
85 draft: bool,
86 ) -> Result<String, ReleaseError> {
87 let url = format!(
88 "{}/repos/{}/{}/releases",
89 self.api_url(),
90 self.owner,
91 self.repo
92 );
93 let payload = serde_json::json!({
94 "tag_name": tag,
95 "name": name,
96 "body": body,
97 "prerelease": prerelease,
98 "draft": draft,
99 });
100
101 let resp = self
102 .agent()
103 .post(&url)
104 .header("Authorization", &format!("Bearer {}", self.token))
105 .header("Accept", "application/vnd.github+json")
106 .header("X-GitHub-Api-Version", "2022-11-28")
107 .header("User-Agent", "sr-github")
108 .send_json(&payload)
109 .map_err(|e| ReleaseError::Vcs(format!("GitHub API POST {url}: {e}")))?;
110
111 let release: ReleaseResponse = resp
112 .into_body()
113 .read_json()
114 .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
115
116 Ok(release.html_url)
117 }
118
119 fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
120 Ok(format!("{}/compare/{base}...{head}", self.base_url()))
121 }
122
123 fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
124 let url = format!(
125 "{}/repos/{}/{}/releases/tags/{tag}",
126 self.api_url(),
127 self.owner,
128 self.repo
129 );
130 match self
131 .agent()
132 .get(&url)
133 .header("Authorization", &format!("Bearer {}", self.token))
134 .header("Accept", "application/vnd.github+json")
135 .header("X-GitHub-Api-Version", "2022-11-28")
136 .header("User-Agent", "sr-github")
137 .call()
138 {
139 Ok(_) => Ok(true),
140 Err(ureq::Error::StatusCode(404)) => Ok(false),
141 Err(e) => Err(ReleaseError::Vcs(format!("GitHub API GET {url}: {e}"))),
142 }
143 }
144
145 fn repo_url(&self) -> Option<String> {
146 Some(self.base_url())
147 }
148
149 fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
150 let release = self.get_release_by_tag(tag)?;
151 let url = format!(
152 "{}/repos/{}/{}/releases/{}",
153 self.api_url(),
154 self.owner,
155 self.repo,
156 release.id
157 );
158 self.agent()
159 .delete(&url)
160 .header("Authorization", &format!("Bearer {}", self.token))
161 .header("Accept", "application/vnd.github+json")
162 .header("X-GitHub-Api-Version", "2022-11-28")
163 .header("User-Agent", "sr-github")
164 .call()
165 .map_err(|e| ReleaseError::Vcs(format!("GitHub API DELETE {url}: {e}")))?;
166 Ok(())
167 }
168
169 fn update_release(
170 &self,
171 tag: &str,
172 name: &str,
173 body: &str,
174 prerelease: bool,
175 draft: bool,
176 ) -> Result<String, ReleaseError> {
177 let release = self.get_release_by_tag(tag)?;
178 let url = format!(
179 "{}/repos/{}/{}/releases/{}",
180 self.api_url(),
181 self.owner,
182 self.repo,
183 release.id
184 );
185 let payload = serde_json::json!({
186 "name": name,
187 "body": body,
188 "prerelease": prerelease,
189 "draft": draft,
190 });
191 let resp = self
192 .agent()
193 .patch(&url)
194 .header("Authorization", &format!("Bearer {}", self.token))
195 .header("Accept", "application/vnd.github+json")
196 .header("X-GitHub-Api-Version", "2022-11-28")
197 .header("User-Agent", "sr-github")
198 .send_json(&payload)
199 .map_err(|e| ReleaseError::Vcs(format!("GitHub API PATCH {url}: {e}")))?;
200 let updated: ReleaseResponse = resp
201 .into_body()
202 .read_json()
203 .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
204 Ok(updated.html_url)
205 }
206
207 fn sync_floating_release(
208 &self,
209 floating_tag: &str,
210 versioned_tag: &str,
211 ) -> Result<(), ReleaseError> {
212 let versioned = self.get_release_by_tag(versioned_tag)?;
213
214 let floating_release = if self.release_exists(floating_tag)? {
216 let existing = self.get_release_by_tag(floating_tag)?;
217 let url = format!(
218 "{}/repos/{}/{}/releases/{}",
219 self.api_url(),
220 self.owner,
221 self.repo,
222 existing.id
223 );
224 let payload = serde_json::json!({
225 "tag_name": floating_tag,
226 "name": floating_tag,
227 "body": format!("Points to {versioned_tag}. Use this tag for GitHub Actions."),
228 "make_latest": "false",
229 });
230 self.agent()
231 .patch(&url)
232 .header("Authorization", &format!("Bearer {}", self.token))
233 .header("Accept", "application/vnd.github+json")
234 .header("X-GitHub-Api-Version", "2022-11-28")
235 .header("User-Agent", "sr-github")
236 .send_json(&payload)
237 .map_err(|e| {
238 ReleaseError::Vcs(format!("GitHub API PATCH floating release: {e}"))
239 })?;
240 existing
241 } else {
242 let url = format!(
243 "{}/repos/{}/{}/releases",
244 self.api_url(),
245 self.owner,
246 self.repo
247 );
248 let payload = serde_json::json!({
249 "tag_name": floating_tag,
250 "name": floating_tag,
251 "body": format!("Points to {versioned_tag}. Use this tag for GitHub Actions."),
252 "make_latest": "false",
253 });
254 let resp = self
255 .agent()
256 .post(&url)
257 .header("Authorization", &format!("Bearer {}", self.token))
258 .header("Accept", "application/vnd.github+json")
259 .header("X-GitHub-Api-Version", "2022-11-28")
260 .header("User-Agent", "sr-github")
261 .send_json(&payload)
262 .map_err(|e| ReleaseError::Vcs(format!("GitHub API POST floating release: {e}")))?;
263 resp.into_body()
264 .read_json()
265 .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?
266 };
267
268 let upload_base = floating_release
271 .upload_url
272 .split('{')
273 .next()
274 .unwrap_or(&floating_release.upload_url);
275
276 for asset in &versioned.assets {
277 let data = self
279 .agent()
280 .get(&asset.browser_download_url)
281 .header("Authorization", &format!("Bearer {}", self.token))
282 .header("Accept", "application/octet-stream")
283 .header("User-Agent", "sr-github")
284 .call()
285 .map_err(|e| ReleaseError::Vcs(format!("download asset {}: {e}", asset.name)))?
286 .into_body()
287 .with_config()
288 .limit(512 * 1024 * 1024)
289 .read_to_vec()
290 .map_err(|e| ReleaseError::Vcs(format!("read asset body {}: {e}", asset.name)))?;
291
292 if let Some(old) = floating_release
294 .assets
295 .iter()
296 .find(|a| a.name == asset.name)
297 {
298 let del_url = format!(
299 "{}/repos/{}/{}/releases/assets/{}",
300 self.api_url(),
301 self.owner,
302 self.repo,
303 old.id
304 );
305 let _ = self
306 .agent()
307 .delete(&del_url)
308 .header("Authorization", &format!("Bearer {}", self.token))
309 .header("Accept", "application/vnd.github+json")
310 .header("X-GitHub-Api-Version", "2022-11-28")
311 .header("User-Agent", "sr-github")
312 .call();
313 }
314
315 let content_type = mime_from_extension(&asset.name);
316 let url = format!("{}?name={}", upload_base, asset.name);
317 self.agent()
318 .post(&url)
319 .header("Authorization", &format!("Bearer {}", self.token))
320 .header("Accept", "application/vnd.github+json")
321 .header("X-GitHub-Api-Version", "2022-11-28")
322 .header("User-Agent", "sr-github")
323 .header("Content-Type", content_type)
324 .send(&data[..])
325 .map_err(|e| {
326 ReleaseError::Vcs(format!("upload asset {} to floating: {e}", asset.name))
327 })?;
328 }
329
330 let versioned_names: std::collections::HashSet<&str> =
332 versioned.assets.iter().map(|a| a.name.as_str()).collect();
333 for old in &floating_release.assets {
334 if !versioned_names.contains(old.name.as_str()) {
335 let del_url = format!(
336 "{}/repos/{}/{}/releases/assets/{}",
337 self.api_url(),
338 self.owner,
339 self.repo,
340 old.id
341 );
342 let _ = self
343 .agent()
344 .delete(&del_url)
345 .header("Authorization", &format!("Bearer {}", self.token))
346 .header("Accept", "application/vnd.github+json")
347 .header("X-GitHub-Api-Version", "2022-11-28")
348 .header("User-Agent", "sr-github")
349 .call();
350 }
351 }
352
353 eprintln!(
354 "Synced floating release {floating_tag} with {} ({} asset(s))",
355 versioned_tag,
356 versioned.assets.len()
357 );
358 Ok(())
359 }
360
361 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
362 let release = self.get_release_by_tag(tag)?;
363 let upload_base = release
367 .upload_url
368 .split('{')
369 .next()
370 .unwrap_or(&release.upload_url);
371
372 for file_path in files {
373 let path = std::path::Path::new(file_path);
374 let file_name = path
375 .file_name()
376 .and_then(|n| n.to_str())
377 .ok_or_else(|| ReleaseError::Vcs(format!("invalid file path: {file_path}")))?;
378
379 let data = std::fs::read(path)
380 .map_err(|e| ReleaseError::Vcs(format!("failed to read asset {file_path}: {e}")))?;
381
382 let content_type = mime_from_extension(file_name);
383 let url = format!("{upload_base}?name={file_name}");
384
385 let mut last_err = None;
387 for attempt in 0..3 {
388 if attempt > 0 {
389 std::thread::sleep(std::time::Duration::from_secs(1 << attempt));
390 eprintln!(
391 "Retrying upload of {file_name} (attempt {}/3)...",
392 attempt + 1
393 );
394 }
395 match self
396 .agent()
397 .post(&url)
398 .header("Authorization", &format!("Bearer {}", self.token))
399 .header("Accept", "application/vnd.github+json")
400 .header("X-GitHub-Api-Version", "2022-11-28")
401 .header("User-Agent", "sr-github")
402 .header("Content-Type", content_type)
403 .send(&data[..])
404 {
405 Ok(_) => {
406 last_err = None;
407 break;
408 }
409 Err(e) => {
410 last_err = Some(format!("GitHub API upload asset {file_name}: {e}"));
411 }
412 }
413 }
414 if let Some(err_msg) = last_err {
415 return Err(ReleaseError::Vcs(err_msg));
416 }
417 }
418
419 Ok(())
420 }
421
422 fn verify_release(&self, tag: &str) -> Result<(), ReleaseError> {
423 self.get_release_by_tag(tag)?;
425 Ok(())
426 }
427}
428
429fn mime_from_extension(filename: &str) -> &'static str {
431 match filename.rsplit('.').next().unwrap_or("") {
432 "gz" | "tgz" => "application/gzip",
433 "zip" => "application/zip",
434 "tar" => "application/x-tar",
435 "xz" => "application/x-xz",
436 "bz2" => "application/x-bzip2",
437 "zst" | "zstd" => "application/zstd",
438 "deb" => "application/vnd.debian.binary-package",
439 "rpm" => "application/x-rpm",
440 "dmg" => "application/x-apple-diskimage",
441 "msi" => "application/x-msi",
442 "exe" => "application/vnd.microsoft.portable-executable",
443 "sig" | "asc" => "application/pgp-signature",
444 "sha256" | "sha512" => "text/plain",
445 "json" => "application/json",
446 "txt" | "md" => "text/plain",
447 _ => "application/octet-stream",
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 fn github_com_provider() -> GitHubProvider {
456 GitHubProvider::new(
457 "urmzd".into(),
458 "sr".into(),
459 "github.com".into(),
460 "test-token".into(),
461 )
462 }
463
464 fn ghes_provider() -> GitHubProvider {
465 GitHubProvider::new(
466 "org".into(),
467 "repo".into(),
468 "ghes.example.com".into(),
469 "test-token".into(),
470 )
471 }
472
473 #[test]
474 fn test_api_url_github_com() {
475 assert_eq!(github_com_provider().api_url(), "https://api.github.com");
476 }
477
478 #[test]
479 fn test_api_url_ghes() {
480 assert_eq!(ghes_provider().api_url(), "https://ghes.example.com/api/v3");
481 }
482
483 #[test]
484 fn test_base_url() {
485 assert_eq!(
486 github_com_provider().base_url(),
487 "https://github.com/urmzd/sr"
488 );
489 assert_eq!(
490 ghes_provider().base_url(),
491 "https://ghes.example.com/org/repo"
492 );
493 }
494
495 #[test]
496 fn test_compare_url() {
497 let p = github_com_provider();
498 assert_eq!(
499 p.compare_url("v0.9.0", "v1.0.0").unwrap(),
500 "https://github.com/urmzd/sr/compare/v0.9.0...v1.0.0"
501 );
502 }
503
504 #[test]
505 fn test_repo_url() {
506 assert_eq!(
507 github_com_provider().repo_url().unwrap(),
508 "https://github.com/urmzd/sr"
509 );
510 }
511}