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 .with_config()
308 .limit(512 * 1024 * 1024)
309 .read_to_vec()
310 .map_err(|e| {
311 ReleaseError::Vcs(format!("read asset body {}: {e}", asset.name))
312 })?;
313
314 let content_type = mime_from_extension(&asset.name);
315 let url = format!("{}?name={}", upload_base, asset.name);
316
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
331 eprintln!(
332 "Synced floating release {floating_tag} with {} ({} asset(s))",
333 versioned_tag,
334 versioned.assets.len()
335 );
336 Ok(())
337 }
338
339 fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
340 let release = self.get_release_by_tag(tag)?;
341 let upload_base = release
345 .upload_url
346 .split('{')
347 .next()
348 .unwrap_or(&release.upload_url);
349
350 for file_path in files {
351 let path = std::path::Path::new(file_path);
352 let file_name = path
353 .file_name()
354 .and_then(|n| n.to_str())
355 .ok_or_else(|| ReleaseError::Vcs(format!("invalid file path: {file_path}")))?;
356
357 let data = std::fs::read(path)
358 .map_err(|e| ReleaseError::Vcs(format!("failed to read asset {file_path}: {e}")))?;
359
360 let content_type = mime_from_extension(file_name);
361 let url = format!("{upload_base}?name={file_name}");
362
363 let mut last_err = None;
365 for attempt in 0..3 {
366 if attempt > 0 {
367 std::thread::sleep(std::time::Duration::from_secs(1 << attempt));
368 eprintln!(
369 "Retrying upload of {file_name} (attempt {}/3)...",
370 attempt + 1
371 );
372 }
373 match self
374 .agent()
375 .post(&url)
376 .header("Authorization", &format!("Bearer {}", self.token))
377 .header("Accept", "application/vnd.github+json")
378 .header("X-GitHub-Api-Version", "2022-11-28")
379 .header("User-Agent", "sr-github")
380 .header("Content-Type", content_type)
381 .send(&data[..])
382 {
383 Ok(_) => {
384 last_err = None;
385 break;
386 }
387 Err(e) => {
388 last_err = Some(format!("GitHub API upload asset {file_name}: {e}"));
389 }
390 }
391 }
392 if let Some(err_msg) = last_err {
393 return Err(ReleaseError::Vcs(err_msg));
394 }
395 }
396
397 Ok(())
398 }
399
400 fn verify_release(&self, tag: &str) -> Result<(), ReleaseError> {
401 self.get_release_by_tag(tag)?;
403 Ok(())
404 }
405}
406
407fn mime_from_extension(filename: &str) -> &'static str {
409 match filename.rsplit('.').next().unwrap_or("") {
410 "gz" | "tgz" => "application/gzip",
411 "zip" => "application/zip",
412 "tar" => "application/x-tar",
413 "xz" => "application/x-xz",
414 "bz2" => "application/x-bzip2",
415 "zst" | "zstd" => "application/zstd",
416 "deb" => "application/vnd.debian.binary-package",
417 "rpm" => "application/x-rpm",
418 "dmg" => "application/x-apple-diskimage",
419 "msi" => "application/x-msi",
420 "exe" => "application/vnd.microsoft.portable-executable",
421 "sig" | "asc" => "application/pgp-signature",
422 "sha256" | "sha512" => "text/plain",
423 "json" => "application/json",
424 "txt" | "md" => "text/plain",
425 _ => "application/octet-stream",
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 fn github_com_provider() -> GitHubProvider {
434 GitHubProvider::new(
435 "urmzd".into(),
436 "sr".into(),
437 "github.com".into(),
438 "test-token".into(),
439 )
440 }
441
442 fn ghes_provider() -> GitHubProvider {
443 GitHubProvider::new(
444 "org".into(),
445 "repo".into(),
446 "ghes.example.com".into(),
447 "test-token".into(),
448 )
449 }
450
451 #[test]
452 fn test_api_url_github_com() {
453 assert_eq!(github_com_provider().api_url(), "https://api.github.com");
454 }
455
456 #[test]
457 fn test_api_url_ghes() {
458 assert_eq!(ghes_provider().api_url(), "https://ghes.example.com/api/v3");
459 }
460
461 #[test]
462 fn test_base_url() {
463 assert_eq!(
464 github_com_provider().base_url(),
465 "https://github.com/urmzd/sr"
466 );
467 assert_eq!(
468 ghes_provider().base_url(),
469 "https://ghes.example.com/org/repo"
470 );
471 }
472
473 #[test]
474 fn test_compare_url() {
475 let p = github_com_provider();
476 assert_eq!(
477 p.compare_url("v0.9.0", "v1.0.0").unwrap(),
478 "https://github.com/urmzd/sr/compare/v0.9.0...v1.0.0"
479 );
480 }
481
482 #[test]
483 fn test_repo_url() {
484 assert_eq!(
485 github_com_provider().repo_url().unwrap(),
486 "https://github.com/urmzd/sr"
487 );
488 }
489}