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        // Get the versioned release to read its assets and metadata
213        let versioned = self.get_release_by_tag(versioned_tag)?;
214
215        // Create or update the floating tag release
216        let floating_release = if self.release_exists(floating_tag)? {
217            let existing = self.get_release_by_tag(floating_tag)?;
218            // Delete existing assets first
219            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            // Update the release metadata and ensure it's not marked as latest
237            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        // Copy assets from the versioned release to the floating release
289        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                // Download the asset from the versioned release
298                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        // The upload_url from the API looks like:
340        //   https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}
341        // Strip the {?name,label} template suffix.
342        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            // Retry up to 3 times for transient upload failures
362            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        // GET the release by tag to confirm it exists and is accessible
400        self.get_release_by_tag(tag)?;
401        Ok(())
402    }
403}
404
405/// Map file extension to MIME type for GitHub asset uploads.
406fn 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}