1use bytes::Bytes;
7use cuenv_release::artifact::PackagedArtifact;
8use cuenv_release::backends::{BackendContext, PublishResult, ReleaseBackend};
9use cuenv_release::error::Result;
10use octocrab::Octocrab;
11use std::future::Future;
12use std::path::Path;
13use std::pin::Pin;
14use tracing::{debug, info};
15
16#[derive(Debug, Clone)]
18pub struct GitHubReleaseConfig {
19 pub owner: String,
21 pub repo: String,
23 pub token: String,
25 pub draft: bool,
27 pub prerelease: bool,
29}
30
31impl GitHubReleaseConfig {
32 #[must_use]
34 pub fn new(
35 owner: impl Into<String>,
36 repo: impl Into<String>,
37 token: impl Into<String>,
38 ) -> Self {
39 Self {
40 owner: owner.into(),
41 repo: repo.into(),
42 token: token.into(),
43 draft: false,
44 prerelease: false,
45 }
46 }
47
48 #[must_use]
50 pub const fn with_draft(mut self, draft: bool) -> Self {
51 self.draft = draft;
52 self
53 }
54
55 #[must_use]
57 pub const fn with_prerelease(mut self, prerelease: bool) -> Self {
58 self.prerelease = prerelease;
59 self
60 }
61
62 #[must_use]
67 pub fn from_env(remote_url: &str) -> Option<Self> {
68 let token = std::env::var("GITHUB_TOKEN").ok()?;
69 let (owner, repo) = parse_github_remote(remote_url)?;
70 Some(Self::new(owner, repo, token))
71 }
72}
73
74fn parse_github_remote(url: &str) -> Option<(String, String)> {
76 if let Some(rest) = url.strip_prefix("git@github.com:") {
78 let path = rest.strip_suffix(".git").unwrap_or(rest);
79 let (owner, repo) = path.split_once('/')?;
80 return Some((owner.to_string(), repo.to_string()));
81 }
82
83 if let Some(rest) = url.strip_prefix("https://github.com/") {
85 let path = rest.strip_suffix(".git").unwrap_or(rest);
86 let (owner, repo) = path.split_once('/')?;
87 return Some((owner.to_string(), repo.to_string()));
88 }
89
90 None
91}
92
93pub struct GitHubReleaseBackend {
100 config: GitHubReleaseConfig,
101}
102
103impl GitHubReleaseBackend {
104 #[must_use]
106 pub const fn new(config: GitHubReleaseConfig) -> Self {
107 Self { config }
108 }
109
110 fn client(&self) -> Result<Octocrab> {
112 Octocrab::builder()
113 .personal_token(self.config.token.clone())
114 .build()
115 .map_err(|e| cuenv_release::error::Error::backend("github", e.to_string(), None))
116 }
117
118 async fn find_release(&self, client: &Octocrab, tag: &str) -> Result<Option<u64>> {
120 let repos = client.repos(&self.config.owner, &self.config.repo);
121 let releases = repos.releases();
122
123 match releases.get_by_tag(tag).await {
124 Ok(release) => Ok(Some(release.id.0)),
125 Err(octocrab::Error::GitHub { source, .. }) if source.message.contains("Not Found") => {
126 Ok(None)
127 }
128 Err(e) => Err(cuenv_release::error::Error::backend(
129 "github",
130 e.to_string(),
131 None,
132 )),
133 }
134 }
135
136 async fn create_release(&self, client: &Octocrab, ctx: &BackendContext) -> Result<u64> {
138 let tag = format!("v{}", ctx.version);
139 let repos = client.repos(&self.config.owner, &self.config.repo);
140 let releases = repos.releases();
141
142 let release = releases
143 .create(&tag)
144 .name(&format!("{} v{}", ctx.name, ctx.version))
145 .draft(self.config.draft)
146 .prerelease(self.config.prerelease)
147 .send()
148 .await
149 .map_err(|e| cuenv_release::error::Error::backend("github", e.to_string(), None))?;
150
151 Ok(release.id.0)
152 }
153
154 async fn upload_asset(
156 &self,
157 client: &Octocrab,
158 release_id: u64,
159 path: &Path,
160 name: &str,
161 ) -> Result<String> {
162 let data = tokio::fs::read(path).await.map_err(|e| {
163 cuenv_release::error::Error::artifact(e.to_string(), Some(path.to_path_buf()))
164 })?;
165
166 let repos = client.repos(&self.config.owner, &self.config.repo);
167 let releases = repos.releases();
168
169 let asset = releases
170 .upload_asset(release_id, name, Bytes::from(data))
171 .send()
172 .await
173 .map_err(|e| cuenv_release::error::Error::backend("github", e.to_string(), None))?;
174
175 Ok(asset.browser_download_url.to_string())
176 }
177}
178
179impl ReleaseBackend for GitHubReleaseBackend {
180 fn name(&self) -> &'static str {
181 "GitHub Releases"
182 }
183
184 fn publish<'a>(
185 &'a self,
186 ctx: &'a BackendContext,
187 artifacts: &'a [PackagedArtifact],
188 ) -> Pin<Box<dyn Future<Output = Result<PublishResult>> + Send + 'a>> {
189 Box::pin(async move {
190 let tag = format!("v{}", ctx.version);
191
192 if ctx.dry_run.is_dry_run() {
193 info!(
194 owner = %self.config.owner,
195 repo = %self.config.repo,
196 tag = %tag,
197 artifact_count = artifacts.len(),
198 "Would upload artifacts to GitHub release"
199 );
200 return Ok(PublishResult::dry_run(
201 "GitHub Releases",
202 format!(
203 "Would upload {} artifacts to {}/{} release {}",
204 artifacts.len(),
205 self.config.owner,
206 self.config.repo,
207 tag
208 ),
209 ));
210 }
211
212 let client = self.client()?;
213
214 let release_id = if let Some(id) = self.find_release(&client, &tag).await? {
216 debug!(release_id = id, tag = %tag, "Found existing release");
217 id
218 } else {
219 info!(tag = %tag, "Creating new release");
220 self.create_release(&client, ctx).await?
221 };
222
223 let mut uploaded = Vec::new();
225 for artifact in artifacts {
226 debug!(
227 archive = %artifact.archive_name,
228 target = ?artifact.target,
229 "Uploading artifact"
230 );
231
232 let url = self
233 .upload_asset(
234 &client,
235 release_id,
236 &artifact.archive_path,
237 &artifact.archive_name,
238 )
239 .await?;
240
241 uploaded.push(url);
242 }
243
244 if let Some(first) = artifacts.first() {
246 let checksums_path = first.archive_path.parent().map(|p| p.join("CHECKSUMS.txt"));
247 if let Some(path) = checksums_path.filter(|p| p.exists()) {
248 debug!("Uploading CHECKSUMS.txt");
249 self.upload_asset(&client, release_id, &path, "CHECKSUMS.txt")
250 .await?;
251 }
252 }
253
254 let release_url = format!(
255 "https://github.com/{}/{}/releases/tag/{}",
256 self.config.owner, self.config.repo, tag
257 );
258
259 info!(
260 release_url = %release_url,
261 uploaded_count = uploaded.len(),
262 "Published to GitHub Releases"
263 );
264
265 Ok(PublishResult::success_with_url(
266 "GitHub Releases",
267 format!("Uploaded {} artifacts", uploaded.len()),
268 release_url,
269 ))
270 })
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn test_parse_github_remote_ssh() {
280 let result = parse_github_remote("git@github.com:cuenv/cuenv.git");
281 assert_eq!(result, Some(("cuenv".to_string(), "cuenv".to_string())));
282 }
283
284 #[test]
285 fn test_parse_github_remote_ssh_no_git_suffix() {
286 let result = parse_github_remote("git@github.com:owner/repo");
287 assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
288 }
289
290 #[test]
291 fn test_parse_github_remote_https() {
292 let result = parse_github_remote("https://github.com/cuenv/cuenv.git");
293 assert_eq!(result, Some(("cuenv".to_string(), "cuenv".to_string())));
294 }
295
296 #[test]
297 fn test_parse_github_remote_https_no_git_suffix() {
298 let result = parse_github_remote("https://github.com/owner/repo");
299 assert_eq!(result, Some(("owner".to_string(), "repo".to_string())));
300 }
301
302 #[test]
303 fn test_parse_github_remote_invalid() {
304 assert!(parse_github_remote("https://gitlab.com/owner/repo").is_none());
305 assert!(parse_github_remote("not a url").is_none());
306 }
307
308 #[test]
309 fn test_parse_github_remote_bitbucket() {
310 assert!(parse_github_remote("git@bitbucket.org:owner/repo.git").is_none());
312 assert!(parse_github_remote("https://bitbucket.org/owner/repo.git").is_none());
313 }
314
315 #[test]
316 fn test_parse_github_remote_empty() {
317 assert!(parse_github_remote("").is_none());
318 }
319
320 #[test]
321 fn test_parse_github_remote_partial_url() {
322 assert!(parse_github_remote("https://github.com/owner").is_none());
324 }
325
326 #[test]
327 fn test_parse_github_remote_nested_path() {
328 let result = parse_github_remote("https://github.com/owner/repo/extra/path");
330 assert!(result.is_some());
333 let (owner, repo) = result.unwrap();
334 assert_eq!(owner, "owner");
335 assert!(repo.starts_with("repo"));
337 }
338
339 #[test]
340 fn test_config_builder() {
341 let config = GitHubReleaseConfig::new("owner", "repo", "token")
342 .with_draft(true)
343 .with_prerelease(true);
344
345 assert_eq!(config.owner, "owner");
346 assert_eq!(config.repo, "repo");
347 assert!(config.draft);
348 assert!(config.prerelease);
349 }
350
351 #[test]
352 fn test_config_defaults() {
353 let config = GitHubReleaseConfig::new("owner", "repo", "token");
354
355 assert_eq!(config.owner, "owner");
356 assert_eq!(config.repo, "repo");
357 assert_eq!(config.token, "token");
358 assert!(!config.draft);
360 assert!(!config.prerelease);
361 }
362
363 #[test]
364 fn test_config_with_draft_only() {
365 let config = GitHubReleaseConfig::new("owner", "repo", "token").with_draft(true);
366
367 assert!(config.draft);
368 assert!(!config.prerelease);
369 }
370
371 #[test]
372 fn test_config_with_prerelease_only() {
373 let config = GitHubReleaseConfig::new("owner", "repo", "token").with_prerelease(true);
374
375 assert!(!config.draft);
376 assert!(config.prerelease);
377 }
378
379 #[test]
380 fn test_config_clone() {
381 let config = GitHubReleaseConfig::new("owner", "repo", "token")
382 .with_draft(true)
383 .with_prerelease(true);
384
385 let cloned = config.clone();
386 assert_eq!(cloned.owner, config.owner);
387 assert_eq!(cloned.repo, config.repo);
388 assert_eq!(cloned.token, config.token);
389 assert_eq!(cloned.draft, config.draft);
390 assert_eq!(cloned.prerelease, config.prerelease);
391 }
392
393 #[test]
394 fn test_config_debug() {
395 let config = GitHubReleaseConfig::new("owner", "repo", "token");
396 let debug_str = format!("{:?}", config);
397
398 assert!(debug_str.contains("owner"));
400 assert!(debug_str.contains("repo"));
401 }
402
403 #[test]
404 fn test_backend_new() {
405 let config = GitHubReleaseConfig::new("owner", "repo", "token");
406 let backend = GitHubReleaseBackend::new(config);
407
408 assert_eq!(backend.name(), "GitHub Releases");
409 }
410
411 #[test]
412 fn test_backend_name() {
413 let config = GitHubReleaseConfig::new("owner", "repo", "token");
414 let backend = GitHubReleaseBackend::new(config);
415
416 assert_eq!(backend.name(), "GitHub Releases");
417 }
418
419 #[test]
420 #[allow(unsafe_code)]
421 fn test_from_env_no_token() {
422 unsafe {
425 std::env::remove_var("GITHUB_TOKEN");
426 }
427
428 let result = GitHubReleaseConfig::from_env("git@github.com:owner/repo.git");
429 assert!(result.is_none());
430 }
431
432 #[test]
433 #[allow(unsafe_code)]
434 fn test_from_env_invalid_url() {
435 unsafe {
437 std::env::set_var("GITHUB_TOKEN", "test_token");
438 }
439
440 let result = GitHubReleaseConfig::from_env("not a valid url");
441 assert!(result.is_none());
442
443 unsafe {
445 std::env::remove_var("GITHUB_TOKEN");
446 }
447 }
448}