Skip to main content

sr_github/
lib.rs

1use std::process::Command;
2
3use sr_core::error::ReleaseError;
4use sr_core::release::VcsProvider;
5
6/// GitHub implementation of the VcsProvider trait using the `gh` CLI.
7pub struct GitHubProvider {
8    owner: String,
9    repo: String,
10}
11
12impl GitHubProvider {
13    pub fn new(owner: String, repo: String) -> Self {
14        Self { owner, repo }
15    }
16}
17
18impl VcsProvider for GitHubProvider {
19    fn create_release(
20        &self,
21        tag: &str,
22        name: &str,
23        body: &str,
24        prerelease: bool,
25    ) -> Result<String, ReleaseError> {
26        let repo_slug = format!("{}/{}", self.owner, self.repo);
27
28        let mut args = vec![
29            "release", "create", tag, "--repo", &repo_slug, "--title", name, "--notes", body,
30        ];
31
32        if prerelease {
33            args.push("--prerelease");
34        }
35
36        let output = Command::new("gh")
37            .args(&args)
38            .output()
39            .map_err(|e| ReleaseError::Vcs(format!("failed to run gh: {e}")))?;
40
41        if !output.status.success() {
42            let stderr = String::from_utf8_lossy(&output.stderr);
43            return Err(ReleaseError::Vcs(format!(
44                "gh release create failed: {stderr}"
45            )));
46        }
47
48        let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
49        Ok(url)
50    }
51
52    fn compare_url(&self, base: &str, head: &str) -> Result<String, ReleaseError> {
53        Ok(format!(
54            "https://github.com/{}/{}/compare/{base}...{head}",
55            self.owner, self.repo,
56        ))
57    }
58
59    fn release_exists(&self, tag: &str) -> Result<bool, ReleaseError> {
60        let repo_slug = format!("{}/{}", self.owner, self.repo);
61        let output = Command::new("gh")
62            .args(["release", "view", tag, "--repo", &repo_slug])
63            .output()
64            .map_err(|e| ReleaseError::Vcs(format!("failed to run gh: {e}")))?;
65        Ok(output.status.success())
66    }
67
68    fn repo_url(&self) -> Option<String> {
69        Some(format!("https://github.com/{}/{}", self.owner, self.repo))
70    }
71
72    fn delete_release(&self, tag: &str) -> Result<(), ReleaseError> {
73        let repo_slug = format!("{}/{}", self.owner, self.repo);
74        let output = Command::new("gh")
75            .args(["release", "delete", tag, "--repo", &repo_slug, "--yes"])
76            .output()
77            .map_err(|e| ReleaseError::Vcs(format!("failed to run gh: {e}")))?;
78        if !output.status.success() {
79            let stderr = String::from_utf8_lossy(&output.stderr);
80            return Err(ReleaseError::Vcs(format!(
81                "gh release delete failed: {stderr}"
82            )));
83        }
84        Ok(())
85    }
86
87    fn upload_assets(&self, tag: &str, files: &[&str]) -> Result<(), ReleaseError> {
88        let repo_slug = format!("{}/{}", self.owner, self.repo);
89        let mut args = vec!["release", "upload", tag, "--repo", &repo_slug, "--clobber"];
90        args.extend(files);
91
92        let output = Command::new("gh")
93            .args(&args)
94            .output()
95            .map_err(|e| ReleaseError::Vcs(format!("failed to run gh: {e}")))?;
96
97        if !output.status.success() {
98            let stderr = String::from_utf8_lossy(&output.stderr);
99            return Err(ReleaseError::Vcs(format!(
100                "gh release upload failed: {stderr}"
101            )));
102        }
103
104        Ok(())
105    }
106
107    fn resolve_contributors(
108        &self,
109        author_shas: &[(&str, &str)],
110    ) -> std::collections::HashMap<String, String> {
111        let mut map = std::collections::HashMap::new();
112        let repo_slug = format!("{}/{}", self.owner, self.repo);
113        for &(author, sha) in author_shas {
114            let endpoint = format!("repos/{repo_slug}/commits/{sha}");
115            let output = Command::new("gh")
116                .args(["api", &endpoint, "--jq", ".author.login"])
117                .output();
118            if let Ok(output) = output
119                && output.status.success()
120            {
121                let login = String::from_utf8_lossy(&output.stdout).trim().to_string();
122                if !login.is_empty() {
123                    map.insert(author.to_string(), format!("@{login}"));
124                }
125            }
126        }
127        map
128    }
129}