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    ) -> Result<String, ReleaseError> {
77        let url = format!(
78            "{}/repos/{}/{}/releases",
79            self.api_url(),
80            self.owner,
81            self.repo
82        );
83        let payload = serde_json::json!({
84            "tag_name": tag,
85            "name": name,
86            "body": body,
87            "prerelease": prerelease,
88        });
89
90        let resp = self
91            .agent()
92            .post(&url)
93            .header("Authorization", &format!("Bearer {}", self.token))
94            .header("Accept", "application/vnd.github+json")
95            .header("X-GitHub-Api-Version", "2022-11-28")
96            .header("User-Agent", "sr-github")
97            .send_json(&payload)
98            .map_err(|e| ReleaseError::Vcs(format!("GitHub API POST {url}: {e}")))?;
99
100        let release: ReleaseResponse = resp
101            .into_body()
102            .read_json()
103            .map_err(|e| ReleaseError::Vcs(format!("failed to parse release response: {e}")))?;
104
105        Ok(release.html_url)
106    }
107
108    fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
109        Ok(format!("{}/compare/{base}...{head}", self.base_url()))
110    }
111
112    fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
113        let url = format!(
114            "{}/repos/{}/{}/releases/tags/{tag}",
115            self.api_url(),
116            self.owner,
117            self.repo
118        );
119        match self
120            .agent()
121            .get(&url)
122            .header("Authorization", &format!("Bearer {}", self.token))
123            .header("Accept", "application/vnd.github+json")
124            .header("X-GitHub-Api-Version", "2022-11-28")
125            .header("User-Agent", "sr-github")
126            .call()
127        {
128            Ok(_) => Ok(true),
129            Err(ureq::Error::StatusCode(404)) => Ok(false),
130            Err(e) => Err(ReleaseError::Vcs(format!("GitHub API GET {url}: {e}"))),
131        }
132    }
133
134    fn repo_url(&self) -> Option<String> {
135        Some(self.base_url())
136    }
137
138    fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
139        let release = self.get_release_by_tag(tag)?;
140        let url = format!(
141            "{}/repos/{}/{}/releases/{}",
142            self.api_url(),
143            self.owner,
144            self.repo,
145            release.id
146        );
147        self.agent()
148            .delete(&url)
149            .header("Authorization", &format!("Bearer {}", self.token))
150            .header("Accept", "application/vnd.github+json")
151            .header("X-GitHub-Api-Version", "2022-11-28")
152            .header("User-Agent", "sr-github")
153            .call()
154            .map_err(|e| ReleaseError::Vcs(format!("GitHub API DELETE {url}: {e}")))?;
155        Ok(())
156    }
157
158    fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
159        let release = self.get_release_by_tag(tag)?;
160        // The upload_url from the API looks like:
161        //   https://uploads.github.com/repos/owner/repo/releases/123/assets{?name,label}
162        // Strip the {?name,label} template suffix.
163        let upload_base = release
164            .upload_url
165            .split('{')
166            .next()
167            .unwrap_or(&release.upload_url);
168
169        for file_path in files {
170            let path = std::path::Path::new(file_path);
171            let file_name = path
172                .file_name()
173                .and_then(|n| n.to_str())
174                .ok_or_else(|| ReleaseError::Vcs(format!("invalid file path: {file_path}")))?;
175
176            let data = std::fs::read(path)
177                .map_err(|e| ReleaseError::Vcs(format!("failed to read asset {file_path}: {e}")))?;
178
179            let url = format!("{upload_base}?name={file_name}");
180            self.agent()
181                .post(&url)
182                .header("Authorization", &format!("Bearer {}", self.token))
183                .header("Accept", "application/vnd.github+json")
184                .header("X-GitHub-Api-Version", "2022-11-28")
185                .header("User-Agent", "sr-github")
186                .header("Content-Type", "application/octet-stream")
187                .send(&data[..])
188                .map_err(|e| {
189                    ReleaseError::Vcs(format!("GitHub API upload asset {file_name}: {e}"))
190                })?;
191        }
192
193        Ok(())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn github_com_provider() -> GitHubProvider {
202        GitHubProvider::new(
203            "urmzd".into(),
204            "semantic-release".into(),
205            "github.com".into(),
206            "test-token".into(),
207        )
208    }
209
210    fn ghes_provider() -> GitHubProvider {
211        GitHubProvider::new(
212            "org".into(),
213            "repo".into(),
214            "ghes.example.com".into(),
215            "test-token".into(),
216        )
217    }
218
219    #[test]
220    fn test_api_url_github_com() {
221        assert_eq!(github_com_provider().api_url(), "https://api.github.com");
222    }
223
224    #[test]
225    fn test_api_url_ghes() {
226        assert_eq!(ghes_provider().api_url(), "https://ghes.example.com/api/v3");
227    }
228
229    #[test]
230    fn test_base_url() {
231        assert_eq!(
232            github_com_provider().base_url(),
233            "https://github.com/urmzd/semantic-release"
234        );
235        assert_eq!(
236            ghes_provider().base_url(),
237            "https://ghes.example.com/org/repo"
238        );
239    }
240
241    #[test]
242    fn test_compare_url() {
243        let p = github_com_provider();
244        assert_eq!(
245            p.compare_url("v0.9.0", "v1.0.0").unwrap(),
246            "https://github.com/urmzd/semantic-release/compare/v0.9.0...v1.0.0"
247        );
248    }
249
250    #[test]
251    fn test_repo_url() {
252        assert_eq!(
253            github_com_provider().repo_url().unwrap(),
254            "https://github.com/urmzd/semantic-release"
255        );
256    }
257}