Skip to main content

cuenv_github/
release.rs

1//! GitHub Releases backend for cuenv.
2//!
3//! Implements the [`ReleaseBackend`] trait for uploading release artifacts
4//! to GitHub Releases.
5
6use 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/// Configuration for the GitHub Releases backend.
17#[derive(Debug, Clone)]
18pub struct GitHubReleaseConfig {
19    /// Repository owner (e.g., "cuenv")
20    pub owner: String,
21    /// Repository name (e.g., "cuenv")
22    pub repo: String,
23    /// GitHub token for authentication
24    pub token: String,
25    /// Whether to create the release as a draft
26    pub draft: bool,
27    /// Whether to mark the release as a prerelease
28    pub prerelease: bool,
29}
30
31impl GitHubReleaseConfig {
32    /// Creates a new GitHub release configuration.
33    #[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    /// Sets the draft flag.
49    #[must_use]
50    pub const fn with_draft(mut self, draft: bool) -> Self {
51        self.draft = draft;
52        self
53    }
54
55    /// Sets the prerelease flag.
56    #[must_use]
57    pub const fn with_prerelease(mut self, prerelease: bool) -> Self {
58        self.prerelease = prerelease;
59        self
60    }
61
62    /// Creates configuration from environment and git remote.
63    ///
64    /// Reads `GITHUB_TOKEN` from environment and parses owner/repo from
65    /// the provided remote URL.
66    #[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
74/// Parse a GitHub remote URL into (owner, repo).
75fn parse_github_remote(url: &str) -> Option<(String, String)> {
76    // Handle SSH format: git@github.com:owner/repo.git
77    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    // Handle HTTPS format: https://github.com/owner/repo.git
84    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
93/// GitHub Releases backend.
94///
95/// Uploads release artifacts to GitHub Releases. Handles:
96/// - Finding or creating the release for the given version
97/// - Uploading tarball assets
98/// - Uploading checksum files
99pub struct GitHubReleaseBackend {
100    config: GitHubReleaseConfig,
101}
102
103impl GitHubReleaseBackend {
104    /// Creates a new GitHub release backend.
105    #[must_use]
106    pub const fn new(config: GitHubReleaseConfig) -> Self {
107        Self { config }
108    }
109
110    /// Creates an authenticated Octocrab client.
111    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    /// Finds an existing release by tag name.
119    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    /// Creates a new release.
137    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    /// Uploads an asset to a release.
155    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            // Find or create release
215            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            // Upload each artifact
224            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            // Upload checksums file if present
245            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        // Non-GitHub remotes should return None
311        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        // Missing repo part
323        assert!(parse_github_remote("https://github.com/owner").is_none());
324    }
325
326    #[test]
327    fn test_parse_github_remote_nested_path() {
328        // Only owner/repo is valid, deeper paths should still parse the first two parts
329        let result = parse_github_remote("https://github.com/owner/repo/extra/path");
330        // This will only get owner/repo/extra/path as the second part
331        // and then split_once('/') gives "repo/extra/path" as repo
332        assert!(result.is_some());
333        let (owner, repo) = result.unwrap();
334        assert_eq!(owner, "owner");
335        // The repo will include the extra path parts
336        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        // Defaults should be false
359        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        // Debug output should contain the field names
399        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        // Clear any existing GITHUB_TOKEN
423        // SAFETY: This test should run in isolation
424        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        // SAFETY: This test should run in isolation
436        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        // Clean up
444        unsafe {
445            std::env::remove_var("GITHUB_TOKEN");
446        }
447    }
448}