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)?;
214
215 let floating_release = if self.release_exists(floating_tag)? {
217 let existing = self.get_release_by_tag(floating_tag)?;
218 for asset in &existing.assets {
220 let url = format!(
221 "{}/repos/{}/{}/releases/assets/{}",
222 self.api_url(),
223 self.owner,
224 self.repo,
225 asset.id
226 );
227 let _ = self
228 .agent()
229 .delete(&url)
230 .header("Authorization", &format!("Bearer {}", self.token))
231 .header("Accept", "application/vnd.github+json")
232 .header("X-GitHub-Api-Version", "2022-11-28")
233 .header("User-Agent", "sr-github")
234 .call();
235 }
236 let url = format!(
238 "{}/repos/{}/{}/releases/{}",
239 self.api_url(),
240 self.owner,
241 self.repo,
242 existing.id
243 );
244 let payload = serde_json::json!({
245 "tag_name": floating_tag,
246 "name": floating_tag,
247 "body": format!("Points to {versioned_tag}. Use this tag for GitHub Actions."),
248 "make_latest": "false",
249 });
250 self.agent()
251 .patch(&url)
252 .header("Authorization", &format!("Bearer {}", self.token))
253 .header("Accept", "application/vnd.github+json")
254 .header("X-GitHub-Api-Version", "2022-11-28")
255 .header("User-Agent", "sr-github")
256 .send_json(&payload)
257 .map_err(|e| {
258 ReleaseError::Vcs(format!("GitHub API PATCH floating release: {e}"))
259 })?;
260 self.get_release_by_tag(floating_tag)?
261 } else {
262 let url = format!(
263 "{}/repos/{}/{}/releases",
264 self.api_url(),
265 self.owner,
266 self.repo
267 );
268 let payload = serde_json::json!({
269 "tag_name": floating_tag,
270 "name": floating_tag,
271 "body": format!("Points to {versioned_tag}. Use this tag for GitHub Actions."),
272 "make_latest": "false",
273 });
274 let resp = self
275 .agent()
276 .post(&url)
277 .header("Authorization", &format!("Bearer {}", self.token))
278 .header("Accept", "application/vnd.github+json")
279 .header("X-GitHub-Api-Version", "2022-11-28")
280 .header("User-Agent", "sr-github")
281 .send_json(&payload)
282 .map_err(|e| ReleaseError::Vcs(format!("GitHub API POST floating release: {e}")))?;
283 resp.into_body()
284 .read_json()
285 .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?
286 };
287
288 if !versioned.assets.is_empty() {
290 let upload_base = floating_release
291 .upload_url
292 .split('{')
293 .next()
294 .unwrap_or(&floating_release.upload_url);
295
296 for asset in &versioned.assets {
297 let data = self
299 .agent()
300 .get(&asset.browser_download_url)
301 .header("Authorization", &format!("Bearer {}", self.token))
302 .header("Accept", "application/octet-stream")
303 .header("User-Agent", "sr-github")
304 .call()
305 .map_err(|e| ReleaseError::Vcs(format!("download asset {}: {e}", asset.name)))?
306 .into_body()
307 .read_to_vec()
308 .map_err(|e| {
309 ReleaseError::Vcs(format!("read asset body {}: {e}", asset.name))
310 })?;
311
312 let content_type = mime_from_extension(&asset.name);
313 let url = format!("{}?name={}", upload_base, asset.name);
314
315 self.agent()
316 .post(&url)
317 .header("Authorization", &format!("Bearer {}", self.token))
318 .header("Accept", "application/vnd.github+json")
319 .header("X-GitHub-Api-Version", "2022-11-28")
320 .header("User-Agent", "sr-github")
321 .header("Content-Type", content_type)
322 .send(&data[..])
323 .map_err(|e| {
324 ReleaseError::Vcs(format!("upload asset {} to floating: {e}", asset.name))
325 })?;
326 }
327 }
328
329 eprintln!(
330 "Synced floating release {floating_tag} with {} ({} asset(s))",
331 versioned_tag,
332 versioned.assets.len()
333 );
334 Ok(())
335 }
336
337 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
338 let release = self.get_release_by_tag(tag)?;
339 let upload_base = release
343 .upload_url
344 .split('{')
345 .next()
346 .unwrap_or(&release.upload_url);
347
348 for file_path in files {
349 let path = std::path::Path::new(file_path);
350 let file_name = path
351 .file_name()
352 .and_then(|n| n.to_str())
353 .ok_or_else(|| ReleaseError::Vcs(format!("invalid file path: {file_path}")))?;
354
355 let data = std::fs::read(path)
356 .map_err(|e| ReleaseError::Vcs(format!("failed to read asset {file_path}: {e}")))?;
357
358 let content_type = mime_from_extension(file_name);
359 let url = format!("{upload_base}?name={file_name}");
360
361 let mut last_err = None;
363 for attempt in 0..3 {
364 if attempt > 0 {
365 std::thread::sleep(std::time::Duration::from_secs(1 << attempt));
366 eprintln!(
367 "Retrying upload of {file_name} (attempt {}/3)...",
368 attempt + 1
369 );
370 }
371 match self
372 .agent()
373 .post(&url)
374 .header("Authorization", &format!("Bearer {}", self.token))
375 .header("Accept", "application/vnd.github+json")
376 .header("X-GitHub-Api-Version", "2022-11-28")
377 .header("User-Agent", "sr-github")
378 .header("Content-Type", content_type)
379 .send(&data[..])
380 {
381 Ok(_) => {
382 last_err = None;
383 break;
384 }
385 Err(e) => {
386 last_err = Some(format!("GitHub API upload asset {file_name}: {e}"));
387 }
388 }
389 }
390 if let Some(err_msg) = last_err {
391 return Err(ReleaseError::Vcs(err_msg));
392 }
393 }
394
395 Ok(())
396 }
397
398 fn verify_release(&self, tag: &str) -> Result<(), ReleaseError> {
399 self.get_release_by_tag(tag)?;
401 Ok(())
402 }
403}
404
405fn mime_from_extension(filename: &str) -> &'static str {
407 match filename.rsplit('.').next().unwrap_or("") {
408 "gz" | "tgz" => "application/gzip",
409 "zip" => "application/zip",
410 "tar" => "application/x-tar",
411 "xz" => "application/x-xz",
412 "bz2" => "application/x-bzip2",
413 "zst" | "zstd" => "application/zstd",
414 "deb" => "application/vnd.debian.binary-package",
415 "rpm" => "application/x-rpm",
416 "dmg" => "application/x-apple-diskimage",
417 "msi" => "application/x-msi",
418 "exe" => "application/vnd.microsoft.portable-executable",
419 "sig" | "asc" => "application/pgp-signature",
420 "sha256" | "sha512" => "text/plain",
421 "json" => "application/json",
422 "txt" | "md" => "text/plain",
423 _ => "application/octet-stream",
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 fn github_com_provider() -> GitHubProvider {
432 GitHubProvider::new(
433 "urmzd".into(),
434 "sr".into(),
435 "github.com".into(),
436 "test-token".into(),
437 )
438 }
439
440 fn ghes_provider() -> GitHubProvider {
441 GitHubProvider::new(
442 "org".into(),
443 "repo".into(),
444 "ghes.example.com".into(),
445 "test-token".into(),
446 )
447 }
448
449 #[test]
450 fn test_api_url_github_com() {
451 assert_eq!(github_com_provider().api_url(), "https://api.github.com");
452 }
453
454 #[test]
455 fn test_api_url_ghes() {
456 assert_eq!(ghes_provider().api_url(), "https://ghes.example.com/api/v3");
457 }
458
459 #[test]
460 fn test_base_url() {
461 assert_eq!(
462 github_com_provider().base_url(),
463 "https://github.com/urmzd/sr"
464 );
465 assert_eq!(
466 ghes_provider().base_url(),
467 "https://ghes.example.com/org/repo"
468 );
469 }
470
471 #[test]
472 fn test_compare_url() {
473 let p = github_com_provider();
474 assert_eq!(
475 p.compare_url("v0.9.0", "v1.0.0").unwrap(),
476 "https://github.com/urmzd/sr/compare/v0.9.0...v1.0.0"
477 );
478 }
479
480 #[test]
481 fn test_repo_url() {
482 assert_eq!(
483 github_com_provider().repo_url().unwrap(),
484 "https://github.com/urmzd/sr"
485 );
486 }
487}