Skip to main content

sr_github/
lib.rs

1use sr_core::error::ReleaseError;
2use sr_core::release::VcsProvider;
3
4/// GitHub implementation of the VcsProvider trait using the GitHub REST API.
5pub 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        // Create or update the floating tag release
215        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        // Swap assets per-asset: delete old then upload new for each, minimising
269        // the window where a given asset is unavailable.
270        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            // Download from versioned release first
278            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            // Delete the matching old asset (if any) then immediately upload
293            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        // Clean up any leftover assets not present in the versioned release
331        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        // The upload_url from the API looks like:
364        //   https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}
365        // Strip the {?name,label} template suffix.
366        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            // Retry up to 3 times for transient upload failures
386            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        // GET the release by tag to confirm it exists and is accessible
424        self.get_release_by_tag(tag)?;
425        Ok(())
426    }
427}
428
429/// Map file extension to MIME type for GitHub asset uploads.
430fn 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}