1use sr_core::error::ReleaseError;
2use sr_core::release::VcsProvider;
3
4pub 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 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}