qtcloud_devops_cli/commands/
release.rs1use 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}