Skip to main content

qtcloud_devops_cli/commands/
release.rs

1use std::path::Path;
2use std::process::Command;
3
4pub fn validate_version(version: &str) -> bool {
5    let re = regex::Regex::new(
6        r"^(v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?|[a-zA-Z0-9_.-]+/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$",
7    )
8    .unwrap();
9    re.is_match(version)
10}
11
12fn normalize_version(version: &str) -> String {
13    let s = version.strip_prefix('v').unwrap_or(version);
14    s.split("/v").last().unwrap_or(s).to_string()
15}
16
17pub fn precheck_version_changelog(version: &str, changelog_path: &Path) -> Vec<String> {
18    let mut errors = Vec::new();
19    if !validate_version(version) {
20        errors.push(format!("版本号格式错误: {}", version));
21    }
22    if changelog_path.exists() {
23        let content = std::fs::read_to_string(changelog_path).unwrap_or_default();
24        let ver = normalize_version(version);
25        let marker = format!("## [{}]", ver);
26        if !content.contains(&marker) {
27            errors.push(format!("CHANGELOG.md 未找到 {} 版本记录", ver));
28        }
29    } else {
30        errors.push(format!("CHANGELOG.md 不存在: {}", changelog_path.display()));
31    }
32    errors
33}
34
35pub fn extract_notes(version: &str, changelog_path: &Path) -> Option<String> {
36    let content = std::fs::read_to_string(changelog_path).ok()?;
37    let ver = normalize_version(version);
38    let start_marker = format!("## [{}]", ver);
39    let mut capture = false;
40    let mut notes: Vec<&str> = Vec::new();
41    for line in content.lines() {
42        if line.trim().starts_with(&start_marker) {
43            capture = true;
44            continue;
45        }
46        if capture {
47            if line.starts_with("## [") {
48                break;
49            }
50            notes.push(line);
51        }
52    }
53    let text = notes.join("\n").trim().to_string();
54    if text.is_empty() { None } else { Some(text) }
55}
56
57pub fn confirm_release(version: &str, yes: bool) -> bool {
58    if yes {
59        return true;
60    }
61    use std::io::Write;
62    println!("\n发布版本: {}", version);
63    print!("确认发布? (y/N): ");
64    std::io::stdout().flush().ok();
65    let mut input = String::new();
66    std::io::stdin().read_line(&mut input).ok();
67    let input = input.trim().to_lowercase();
68    input == "y" || input == "yes"
69}
70
71fn git_args(args: &[&str], repo_path: &Path) -> Command {
72    let mut cmd = Command::new("git");
73    cmd.arg("-C");
74    cmd.arg(repo_path);
75    cmd.args(args);
76    cmd
77}
78
79pub fn create_tag(version: &str, repo_path: &Path) -> bool {
80    match git_args(&["tag", version], repo_path).output() {
81        Ok(out) if out.status.success() => true,
82        Ok(out) => {
83            eprintln!("创建标签失败: {}", String::from_utf8_lossy(&out.stderr).trim());
84            false
85        }
86        Err(e) => {
87            eprintln!("创建标签失败: {}", e);
88            false
89        }
90    }
91}
92
93pub fn push_tag(version: &str, repo_path: &Path) -> bool {
94    match git_args(&["push", "origin", version], repo_path).output() {
95        Ok(out) if out.status.success() => true,
96        Ok(out) => {
97            eprintln!("推送标签失败: {}", String::from_utf8_lossy(&out.stderr).trim());
98            false
99        }
100        Err(e) => {
101            eprintln!("推送标签失败: {}", e);
102            false
103        }
104    }
105}
106
107pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
108    let result = git_args(&["remote", "get-url", "origin"], repo_path).output().ok()?;
109    if !result.status.success() {
110        return None;
111    }
112    let url = String::from_utf8_lossy(&result.stdout).trim().to_string();
113    parse_github_repo(&url)
114}
115
116pub fn parse_github_repo(url: &str) -> Option<String> {
117    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
118    let caps = re.captures(url)?;
119    Some(caps.get(1)?.as_str().to_string())
120}
121
122pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
123    match Command::new("gh")
124        .args(["release", "create", version, "--title", version, "--notes", notes, "--repo", repo])
125        .output()
126    {
127        Ok(out) if out.status.success() => true,
128        Ok(out) => {
129            eprintln!("创建 Release 失败: {}", String::from_utf8_lossy(&out.stderr).trim());
130            false
131        }
132        Err(e) => {
133            eprintln!("创建 Release 失败: {}", e);
134            false
135        }
136    }
137}
138
139pub fn rollback_tag(version: &str, repo_path: &Path) {
140    git_args(&["tag", "-d", version], repo_path).output().ok();
141    git_args(&["push", "origin", "--delete", version], repo_path).output().ok();
142    println!("↻ 标签 {} 已回滚", version);
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_validate_version_v_prefix() {
151        assert!(validate_version("v1.2.3"));
152    }
153
154    #[test]
155    fn test_validate_version_with_suffix() {
156        assert!(validate_version("v1.2.3-alpha.1"));
157        assert!(validate_version("v1.2.3-rc1"));
158    }
159
160    #[test]
161    fn test_validate_version_pkg() {
162        assert!(validate_version("pkg/v1.2.3"));
163        assert!(validate_version("cli/v0.1.0"));
164    }
165
166    #[test]
167    fn test_validate_version_invalid() {
168        assert!(!validate_version("1.2.3"));
169        assert!(!validate_version("v1.2"));
170        assert!(!validate_version("abc"));
171    }
172
173    #[test]
174    fn test_parse_github_repo_https() {
175        assert_eq!(
176            parse_github_repo("https://github.com/owner/repo.git"),
177            Some("owner/repo".into())
178        );
179    }
180
181    #[test]
182    fn test_parse_github_repo_ssh() {
183        assert_eq!(
184            parse_github_repo("git@github.com:owner/repo.git"),
185            Some("owner/repo".into())
186        );
187    }
188
189    #[test]
190    fn test_parse_github_repo_not_github() {
191        assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None);
192    }
193
194    #[test]
195    fn test_extract_notes_found() {
196        let dir = tempfile::tempdir().unwrap();
197        let path = dir.path().join("CHANGELOG.md");
198        std::fs::write(&path, "## [1.0.0]\n\ncontent").unwrap();
199        let notes = extract_notes("v1.0.0", &path);
200        assert!(notes.is_some());
201        assert!(notes.unwrap().contains("content"));
202    }
203
204    #[test]
205    fn test_extract_notes_not_found() {
206        let dir = tempfile::tempdir().unwrap();
207        let path = dir.path().join("CHANGELOG.md");
208        std::fs::write(&path, "## [1.0.0]\n\ncontent").unwrap();
209        assert!(extract_notes("v2.0.0", &path).is_none());
210    }
211
212    #[test]
213    fn test_confirm_release_yes_flag() {
214        assert!(confirm_release("v1.0.0", true));
215    }
216
217    #[test]
218    fn test_precheck_changelog_no_errors() {
219        let dir = tempfile::tempdir().unwrap();
220        let path = dir.path().join("CHANGELOG.md");
221        std::fs::write(&path, "## [1.0.0]\n\ncontent").unwrap();
222        assert!(precheck_version_changelog("v1.0.0", &path).is_empty());
223    }
224
225    #[test]
226    fn test_precheck_changelog_missing_entry() {
227        let dir = tempfile::tempdir().unwrap();
228        let path = dir.path().join("CHANGELOG.md");
229        std::fs::write(&path, "## [1.0.0]\n\ncontent").unwrap();
230        let errors = precheck_version_changelog("v2.0.0", &path);
231        assert!(errors.iter().any(|e| e.contains("未找到")));
232    }
233}