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}
18
19impl GitHubProvider {
20    pub fn new(owner: String, repo: String, hostname: String, token: String) -> Self {
21        Self {
22            owner,
23            repo,
24            hostname,
25            token,
26        }
27    }
28
29    fn base_url(&self) -> String {
30        format!("https://{}/{}/{}", self.hostname, self.owner, self.repo)
31    }
32
33    fn api_url(&self) -> String {
34        if self.hostname == "github.com" {
35            "https://api.github.com".to_string()
36        } else {
37            format!("https://{}/api/v3", self.hostname)
38        }
39    }
40
41    fn agent(&self) -> ureq::Agent {
42        ureq::Agent::new_with_config(ureq::config::Config::builder().https_only(true).build())
43    }
44
45    fn get_release_by_tag(&self, tag: &str) -> Result<ReleaseResponse, ReleaseError> {
46        let url = format!(
47            "{}/repos/{}/{}/releases/tags/{tag}",
48            self.api_url(),
49            self.owner,
50            self.repo
51        );
52        let resp = self
53            .agent()
54            .get(&url)
55            .header("Authorization", &format!("Bearer {}", self.token))
56            .header("Accept", "application/vnd.github+json")
57            .header("X-GitHub-Api-Version", "2022-11-28")
58            .header("User-Agent", "sr-github")
59            .call()
60            .map_err(|e| ReleaseError::Vcs(format!("GitHub API GET {url}: {e}")))?;
61        let release: ReleaseResponse = resp
62            .into_body()
63            .read_json()
64            .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
65        Ok(release)
66    }
67}
68
69impl VcsProvider for GitHubProvider {
70    fn create_release(
71        &self,
72        tag: &str,
73        name: &str,
74        body: &str,
75        prerelease: bool,
76        draft: bool,
77    ) -> Result<String, ReleaseError> {
78        let url = format!(
79            "{}/repos/{}/{}/releases",
80            self.api_url(),
81            self.owner,
82            self.repo
83        );
84        let payload = serde_json::json!({
85            "tag_name": tag,
86            "name": name,
87            "body": body,
88            "prerelease": prerelease,
89            "draft": draft,
90        });
91
92        let resp = self
93            .agent()
94            .post(&url)
95            .header("Authorization", &format!("Bearer {}", self.token))
96            .header("Accept", "application/vnd.github+json")
97            .header("X-GitHub-Api-Version", "2022-11-28")
98            .header("User-Agent", "sr-github")
99            .send_json(&payload)
100            .map_err(|e| ReleaseError::Vcs(format!("GitHub API POST {url}: {e}")))?;
101
102        let release: ReleaseResponse = resp
103            .into_body()
104            .read_json()
105            .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
106
107        Ok(release.html_url)
108    }
109
110    fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
111        Ok(format!("{}/compare/{base}...{head}", self.base_url()))
112    }
113
114    fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
115        let url = format!(
116            "{}/repos/{}/{}/releases/tags/{tag}",
117            self.api_url(),
118            self.owner,
119            self.repo
120        );
121        match self
122            .agent()
123            .get(&url)
124            .header("Authorization", &format!("Bearer {}", self.token))
125            .header("Accept", "application/vnd.github+json")
126            .header("X-GitHub-Api-Version", "2022-11-28")
127            .header("User-Agent", "sr-github")
128            .call()
129        {
130            Ok(_) => Ok(true),
131            Err(ureq::Error::StatusCode(404)) => Ok(false),
132            Err(e) => Err(ReleaseError::Vcs(format!("GitHub API GET {url}: {e}"))),
133        }
134    }
135
136    fn repo_url(&self) -> Option<String> {
137        Some(self.base_url())
138    }
139
140    fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
141        let release = self.get_release_by_tag(tag)?;
142        let url = format!(
143            "{}/repos/{}/{}/releases/{}",
144            self.api_url(),
145            self.owner,
146            self.repo,
147            release.id
148        );
149        self.agent()
150            .delete(&url)
151            .header("Authorization", &format!("Bearer {}", self.token))
152            .header("Accept", "application/vnd.github+json")
153            .header("X-GitHub-Api-Version", "2022-11-28")
154            .header("User-Agent", "sr-github")
155            .call()
156            .map_err(|e| ReleaseError::Vcs(format!("GitHub API DELETE {url}: {e}")))?;
157        Ok(())
158    }
159
160    fn update_release(
161        &self,
162        tag: &str,
163        name: &str,
164        body: &str,
165        prerelease: bool,
166        draft: bool,
167    ) -> Result<String, ReleaseError> {
168        let release = self.get_release_by_tag(tag)?;
169        let url = format!(
170            "{}/repos/{}/{}/releases/{}",
171            self.api_url(),
172            self.owner,
173            self.repo,
174            release.id
175        );
176        let payload = serde_json::json!({
177            "name": name,
178            "body": body,
179            "prerelease": prerelease,
180            "draft": draft,
181        });
182        let resp = self
183            .agent()
184            .patch(&url)
185            .header("Authorization", &format!("Bearer {}", self.token))
186            .header("Accept", "application/vnd.github+json")
187            .header("X-GitHub-Api-Version", "2022-11-28")
188            .header("User-Agent", "sr-github")
189            .send_json(&payload)
190            .map_err(|e| ReleaseError::Vcs(format!("GitHub API PATCH {url}: {e}")))?;
191        let updated: ReleaseResponse = resp
192            .into_body()
193            .read_json()
194            .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
195        Ok(updated.html_url)
196    }
197
198    fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
199        let release = self.get_release_by_tag(tag)?;
200        // The upload_url from the API looks like:
201        //   https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}
202        // Strip the {?name,label} template suffix.
203        let upload_base = release
204            .upload_url
205            .split('{')
206            .next()
207            .unwrap_or(&release.upload_url);
208
209        for file_path in files {
210            let path = std::path::Path::new(file_path);
211            let file_name = path
212                .file_name()
213                .and_then(|n| n.to_str())
214                .ok_or_else(|| ReleaseError::Vcs(format!("invalid file path: {file_path}")))?;
215
216            let data = std::fs::read(path)
217                .map_err(|e| ReleaseError::Vcs(format!("failed to read asset {file_path}: {e}")))?;
218
219            let content_type = mime_from_extension(file_name);
220            let url = format!("{upload_base}?name={file_name}");
221
222            // Retry up to 3 times for transient upload failures
223            let mut last_err = None;
224            for attempt in 0..3 {
225                if attempt > 0 {
226                    std::thread::sleep(std::time::Duration::from_secs(1 << attempt));
227                    eprintln!(
228                        "Retrying upload of {file_name} (attempt {}/3)...",
229                        attempt + 1
230                    );
231                }
232                match self
233                    .agent()
234                    .post(&url)
235                    .header("Authorization", &format!("Bearer {}", self.token))
236                    .header("Accept", "application/vnd.github+json")
237                    .header("X-GitHub-Api-Version", "2022-11-28")
238                    .header("User-Agent", "sr-github")
239                    .header("Content-Type", content_type)
240                    .send(&data[..])
241                {
242                    Ok(_) => {
243                        last_err = None;
244                        break;
245                    }
246                    Err(e) => {
247                        last_err = Some(format!("GitHub API upload asset {file_name}: {e}"));
248                    }
249                }
250            }
251            if let Some(err_msg) = last_err {
252                return Err(ReleaseError::Vcs(err_msg));
253            }
254        }
255
256        Ok(())
257    }
258
259    fn verify_release(&self, tag: &str) -> Result<(), ReleaseError> {
260        // GET the release by tag to confirm it exists and is accessible
261        self.get_release_by_tag(tag)?;
262        Ok(())
263    }
264}
265
266/// Map file extension to MIME type for GitHub asset uploads.
267fn mime_from_extension(filename: &str) -> &'static str {
268    match filename.rsplit('.').next().unwrap_or("") {
269        "gz" | "tgz" => "application/gzip",
270        "zip" => "application/zip",
271        "tar" => "application/x-tar",
272        "xz" => "application/x-xz",
273        "bz2" => "application/x-bzip2",
274        "zst" | "zstd" => "application/zstd",
275        "deb" => "application/vnd.debian.binary-package",
276        "rpm" => "application/x-rpm",
277        "dmg" => "application/x-apple-diskimage",
278        "msi" => "application/x-msi",
279        "exe" => "application/vnd.microsoft.portable-executable",
280        "sig" | "asc" => "application/pgp-signature",
281        "sha256" | "sha512" => "text/plain",
282        "json" => "application/json",
283        "txt" | "md" => "text/plain",
284        _ => "application/octet-stream",
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn github_com_provider() -> GitHubProvider {
293        GitHubProvider::new(
294            "urmzd".into(),
295            "sr".into(),
296            "github.com".into(),
297            "test-token".into(),
298        )
299    }
300
301    fn ghes_provider() -> GitHubProvider {
302        GitHubProvider::new(
303            "org".into(),
304            "repo".into(),
305            "ghes.example.com".into(),
306            "test-token".into(),
307        )
308    }
309
310    #[test]
311    fn test_api_url_github_com() {
312        assert_eq!(github_com_provider().api_url(), "https://api.github.com");
313    }
314
315    #[test]
316    fn test_api_url_ghes() {
317        assert_eq!(ghes_provider().api_url(), "https://ghes.example.com/api/v3");
318    }
319
320    #[test]
321    fn test_base_url() {
322        assert_eq!(
323            github_com_provider().base_url(),
324            "https://github.com/urmzd/sr"
325        );
326        assert_eq!(
327            ghes_provider().base_url(),
328            "https://ghes.example.com/org/repo"
329        );
330    }
331
332    #[test]
333    fn test_compare_url() {
334        let p = github_com_provider();
335        assert_eq!(
336            p.compare_url("v0.9.0", "v1.0.0").unwrap(),
337            "https://github.com/urmzd/sr/compare/v0.9.0...v1.0.0"
338        );
339    }
340
341    #[test]
342    fn test_repo_url() {
343        assert_eq!(
344            github_com_provider().repo_url().unwrap(),
345            "https://github.com/urmzd/sr"
346        );
347    }
348}