qtcloud_devops_cli/release/
util.rs1use std::path::Path;
2use std::process::Command;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
5pub enum PublishTarget {
6 PyPI,
7 PubDev,
8 Crates,
9}
10
11pub fn validate_version(version: &str) -> bool {
12 crate::contract::validate_version(version)
13}
14
15pub fn normalize_version(version: &str) -> String {
16 crate::contract::normalize_version(version)
17}
18
19pub fn precheck_version_changelog(version: &str, changelog_path: &Path) -> Vec<String> {
20 let mut errors = Vec::new();
21 if !validate_version(version) {
22 errors.push(format!("版本号格式错误: {}", version));
23 }
24 if changelog_path.exists() {
25 let content = std::fs::read_to_string(changelog_path).unwrap_or_default();
26 let ver = normalize_version(version);
27 let marker = format!("## [{}]", ver);
28 let v_marker = format!("## [v{}]", ver);
29 if !content.contains(&marker) && !content.contains(&v_marker) {
30 errors.push(format!("CHANGELOG.md 未找到 {} 版本记录", ver));
31 }
32 } else {
33 errors.push(format!("CHANGELOG.md 不存在: {}", changelog_path.display()));
34 }
35 errors
36}
37
38pub fn extract_notes(version: &str, changelog_path: &Path) -> Option<String> {
39 let content = std::fs::read_to_string(changelog_path).ok()?;
40 let ver = normalize_version(version);
41 let start_marker = format!("## [{}]", ver);
42 let start_marker_v = format!("## [v{}]", ver);
43 let mut capture = false;
44 let mut notes: Vec<&str> = Vec::new();
45 for line in content.lines() {
46 if line.trim().starts_with(&start_marker) || line.trim().starts_with(&start_marker_v) {
47 capture = true;
48 continue;
49 }
50 if capture {
51 if line.starts_with("## [") {
52 if line.contains(&ver) || line.contains(&format!("v{}", ver)) {
53 continue;
54 }
55 break;
56 }
57 notes.push(line);
58 }
59 }
60 let text = notes
61 .iter()
62 .filter(|l| !l.trim().starts_with("## ["))
63 .cloned()
64 .collect::<Vec<_>>()
65 .join("\n")
66 .trim()
67 .to_string();
68 if text.is_empty() {
69 None
70 } else {
71 Some(text)
72 }
73}
74
75pub fn confirm_release(version: &str, yes: bool) -> bool {
76 if yes {
77 return true;
78 }
79 use std::io::Write;
80 println!("\n发布版本: {}", version);
81 print!("确认发布? (y/N): ");
82 std::io::stdout().flush().ok();
83 let mut input = String::new();
84 std::io::stdin().read_line(&mut input).ok();
85 input.trim().to_lowercase() == "y" || input.trim().to_lowercase() == "yes"
86}
87
88pub fn create_tag(version: &str, repo_path: &Path) -> bool {
90 let repo = match git2::Repository::open(repo_path) {
91 Ok(r) => r,
92 Err(e) => {
93 eprintln!("打开仓库失败: {}", e);
94 return false;
95 }
96 };
97 let refname = format!("refs/tags/{}", version);
98 if repo.find_reference(&refname).is_ok() {
100 return true;
101 }
102 let target = match repo.head().ok().and_then(|h| h.target()) {
103 Some(t) => t,
104 None => return false,
105 };
106 let result = repo.reference(&refname, target, false, "");
107 match result {
108 Ok(_) => true,
109 Err(e) => {
110 eprintln!("创建标签失败: {}", e);
111 false
112 }
113 }
114}
115
116pub fn push_tag(version: &str, repo_path: &Path) -> bool {
118 let out = Command::new("git")
119 .args([
120 "-C",
121 &repo_path.to_string_lossy(),
122 "push",
123 "origin",
124 version,
125 ])
126 .output();
127 match out {
128 Ok(out) if out.status.success() => true,
129 Ok(out) => {
130 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
131 if msg.contains("does not appear") || msg.contains("repository '' does not exist") {
132 return true;
133 }
134 eprintln!("推送标签失败: {}", msg);
135 false
136 }
137 Err(e) => {
138 eprintln!("推送标签失败: {}", e);
139 false
140 }
141 }
142}
143
144pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
146 let repo = git2::Repository::open(repo_path).ok()?;
147 let remote = repo.find_remote("origin").ok()?;
148 let url = remote.url()?;
149 parse_github_repo(url)
150}
151
152pub fn parse_github_repo(url: &str) -> Option<String> {
153 let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
154 let caps = re.captures(url)?;
155 Some(caps.get(1)?.as_str().to_string())
156}
157
158pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
159 let out = Command::new("gh")
160 .args([
161 "release", "create", version, "--title", version, "--notes", notes, "--repo", repo,
162 ])
163 .output();
164 match out {
165 Ok(out) if out.status.success() => true,
166 Ok(out) => {
167 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
168 if msg.contains("already exists") || msg.contains("已存在") {
169 return true;
170 }
171 eprintln!("创建 Release 失败: {}", msg);
172 false
173 }
174 Err(e) => {
175 eprintln!("创建 Release 失败: {}", e);
176 false
177 }
178 }
179}
180
181pub fn rollback_tag(version: &str, repo_path: &Path) {
183 if let Ok(repo) = git2::Repository::open(repo_path) {
185 let refname = format!("refs/tags/{}", version);
186 if let Ok(mut reference) = repo.find_reference(&refname) {
187 let _ = reference.delete();
188 }
189 }
190 Command::new("git")
192 .args([
193 "-C",
194 &repo_path.to_string_lossy(),
195 "push",
196 "origin",
197 "--delete",
198 version,
199 ])
200 .output()
201 .ok();
202 println!("↻ 标签 {} 已回滚", version);
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use std::path::Path;
209
210 fn git_init(path: &Path) {
211 std::process::Command::new("git")
212 .args(["init", "-b", "main"])
213 .current_dir(path)
214 .output()
215 .unwrap();
216 std::process::Command::new("git")
217 .args(["config", "user.email", "test@test.com"])
218 .current_dir(path)
219 .output()
220 .unwrap();
221 std::process::Command::new("git")
222 .args(["config", "user.name", "Test"])
223 .current_dir(path)
224 .output()
225 .unwrap();
226 }
227
228 fn git_commit(path: &Path, msg: &str) {
229 std::fs::write(path.join("file"), msg).unwrap();
230 std::process::Command::new("git")
231 .args(["add", "."])
232 .current_dir(path)
233 .output()
234 .unwrap();
235 std::process::Command::new("git")
236 .args(["commit", "-m", msg])
237 .current_dir(path)
238 .output()
239 .unwrap();
240 }
241
242 #[test]
243 fn test_parse_github_repo_https() {
244 assert_eq!(
245 parse_github_repo("https://github.com/owner/repo.git"),
246 Some("owner/repo".into())
247 );
248 }
249 #[test]
250 fn test_parse_github_repo_ssh() {
251 assert_eq!(
252 parse_github_repo("git@github.com:owner/repo.git"),
253 Some("owner/repo".into())
254 );
255 }
256 #[test]
257 fn test_parse_github_repo_not_github() {
258 assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None);
259 }
260 #[test]
261 fn test_extract_notes_found() {
262 let d = tempfile::tempdir().unwrap();
263 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
264 assert!(extract_notes("v1.0.0", &d.path().join("C.md")).is_some());
265 }
266 #[test]
267 fn test_extract_notes_not_found() {
268 let d = tempfile::tempdir().unwrap();
269 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
270 assert!(extract_notes("v2.0.0", &d.path().join("C.md")).is_none());
271 }
272 #[test]
273 fn test_extract_notes_filters_header_lines() {
274 let d = tempfile::tempdir().unwrap();
275 std::fs::write(
276 d.path().join("C.md"),
277 "## [1.0.0] - 2026-06-26\n\n\
278 ## [v1.0.0] - 2023-08-31\n\n\
279 ### Added\n- feature\n",
280 )
281 .unwrap();
282 let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap_or_default();
283 assert!(!notes.contains("## ["), "提取内容应过滤 ## [ 行: {}", notes);
284 assert!(notes.contains("### Added"));
285 assert!(notes.contains("- feature"));
286 }
287
288 #[test]
289 fn test_confirm_release_yes_flag() {
290 assert!(confirm_release("v1.0.0", true));
291 }
292 #[test]
293 fn test_precheck_changelog_no_errors() {
294 let d = tempfile::tempdir().unwrap();
295 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
296 assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty());
297 }
298 #[test]
299 fn test_precheck_changelog_missing_entry() {
300 let d = tempfile::tempdir().unwrap();
301 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
302 assert!(precheck_version_changelog("v2.0.0", &d.path().join("C.md"))
303 .iter()
304 .any(|e| e.contains("未找到")));
305 }
306 #[test]
307 fn test_precheck_changelog_file_not_found() {
308 let d = tempfile::tempdir().unwrap();
309 assert!(precheck_version_changelog("v1.0.0", &d.path().join("N.md"))
310 .iter()
311 .any(|e| e.contains("不存在")));
312 }
313 #[test]
314 fn test_precheck_changelog_version_invalid() {
315 let d = tempfile::tempdir().unwrap();
316 assert!(precheck_version_changelog("bad", &d.path().join("C.md"))
317 .iter()
318 .any(|e| e.contains("格式错误")));
319 }
320 #[test]
321 fn test_publish_target_debug() {
322 assert_eq!(format!("{:?}", PublishTarget::PyPI), "PyPI");
323 }
324
325 #[test]
326 fn test_publish_target_clone_eq() {
327 assert_eq!(PublishTarget::PyPI, PublishTarget::PyPI);
328 }
329 #[test]
330 fn test_get_remote_repo_no_git_repo() {
331 assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None);
332 }
333 #[test]
334 fn test_create_release_no_gh() {
335 assert!(!create_release("v0.0.0-test", "", "no/repo"));
336 }
337 #[test]
338 fn test_create_tag_in_non_git_dir() {
339 assert!(!create_tag(
340 "v0.0.0-test",
341 tempfile::tempdir().unwrap().path()
342 ));
343 }
344 #[test]
345 fn test_create_tag_idempotent() {
346 let d = tempfile::tempdir().unwrap();
347 git_init(d.path());
348 git_commit(d.path(), "init");
349 assert!(create_tag("v0.0.0-test", d.path()));
350 assert!(create_tag("v0.0.0-test", d.path()));
351 }
352 #[test]
353 fn test_push_tag_in_non_git_dir() {
354 assert!(!push_tag(
355 "v0.0.0-test",
356 tempfile::tempdir().unwrap().path()
357 ));
358 }
359 #[test]
360 fn test_push_tag_fails_with_non_existent_remote() {
361 let d = tempfile::tempdir().unwrap();
362 git_init(d.path());
363 git_commit(d.path(), "init");
364 assert!(create_tag("v0.0.0-test-remote", d.path()));
365 std::process::Command::new("git")
366 .args([
367 "remote",
368 "add",
369 "origin",
370 "https://nonexistent.invalid/repo.git",
371 ])
372 .current_dir(d.path())
373 .output()
374 .unwrap();
375 assert!(!push_tag("v0.0.0-test-remote", d.path()));
376 }
377 #[test]
378 fn test_get_remote_repo_in_git_without_remote() {
379 let d = tempfile::tempdir().unwrap();
380 std::process::Command::new("git")
381 .args(["init", "-b", "main"])
382 .current_dir(d.path())
383 .output()
384 .unwrap();
385 assert_eq!(get_remote_repo(d.path()), None);
386 }
387 #[test]
388 fn test_rollback_tag_removes_tag() {
389 let d = tempfile::tempdir().unwrap();
390 std::process::Command::new("git")
391 .args(["init", "-b", "main"])
392 .current_dir(d.path())
393 .output()
394 .unwrap();
395 std::fs::write(d.path().join("f"), "").unwrap();
396 std::process::Command::new("git")
397 .args(["add", "."])
398 .current_dir(d.path())
399 .output()
400 .unwrap();
401 std::process::Command::new("git")
402 .args([
403 "-c",
404 "user.name=t",
405 "-c",
406 "user.email=t@t",
407 "commit",
408 "-m",
409 "x",
410 ])
411 .current_dir(d.path())
412 .output()
413 .unwrap();
414 assert!(create_tag("v0.0.0-test-rollback", d.path()));
415 rollback_tag("v0.0.0-test-rollback", d.path());
416 let o = std::process::Command::new("git")
417 .args(["tag", "-l"])
418 .current_dir(d.path())
419 .output()
420 .unwrap();
421 assert!(!String::from_utf8_lossy(&o.stdout).contains("v0.0.0-test-rollback"));
422 }
423}