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                    .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        // The upload_url from the API looks like:
342        //   https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}
343        // Strip the {?name,label} template suffix.
344        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            // Retry up to 3 times for transient upload failures
364            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        // GET the release by tag to confirm it exists and is accessible
402        self.get_release_by_tag(tag)?;
403        Ok(())
404    }
405}
406
407/// Map file extension to MIME type for GitHub asset uploads.
408fn 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}