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 if msg.contains("already exists") || msg.contains("already up to date") {
135 return true;
136 }
137 eprintln!("推送标签失败: {}", msg);
138 false
139 }
140 Err(e) => {
141 eprintln!("推送标签失败: {}", e);
142 false
143 }
144 }
145}
146
147pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
149 let repo = git2::Repository::open(repo_path).ok()?;
150 let remote = repo.find_remote("origin").ok()?;
151 let url = remote.url()?;
152 parse_github_repo(url)
153}
154
155pub fn parse_github_repo(url: &str) -> Option<String> {
156 let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
157 let caps = re.captures(url)?;
158 Some(caps.get(1)?.as_str().to_string())
159}
160
161pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
162 let out = Command::new("gh")
163 .args([
164 "release", "create", version, "--title", version, "--notes", notes, "--repo", repo,
165 ])
166 .output();
167 match out {
168 Ok(out) if out.status.success() => true,
169 Ok(out) => {
170 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
171 if msg.contains("already exists") || msg.contains("已存在") {
172 return true;
173 }
174 eprintln!("创建 Release 失败: {}", msg);
175 false
176 }
177 Err(e) => {
178 eprintln!("创建 Release 失败: {}", e);
179 false
180 }
181 }
182}
183
184pub fn rollback_tag(version: &str, repo_path: &Path) {
186 let local_ok = delete_local_tag(version, repo_path);
187 let remote_ok = delete_remote_tag(version, repo_path);
188 if local_ok && remote_ok {
189 eprintln!("已回滚标签 {}", version);
190 }
191}
192
193pub fn delete_local_tag(version: &str, repo_path: &Path) -> bool {
195 let repo = match git2::Repository::open(repo_path) {
196 Ok(r) => r,
197 Err(e) => {
198 eprintln!("打开仓库失败: {}", e);
199 return false;
200 }
201 };
202 let refname = format!("refs/tags/{}", version);
203 if let Ok(mut reference) = repo.find_reference(&refname) {
204 reference.delete().ok();
205 }
206 true }
208
209pub fn delete_remote_tag(version: &str, repo_path: &Path) -> bool {
211 if get_remote_repo(repo_path).is_none() {
212 return true; }
214 let out = Command::new("git")
215 .args([
216 "-C",
217 &repo_path.to_string_lossy(),
218 "push",
219 "--delete",
220 "origin",
221 version,
222 ])
223 .output();
224 match out {
225 Ok(out) if out.status.success() => true,
226 Ok(out) => {
227 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
228 if msg.contains("does not appear") || msg.contains("repository '' does not exist") {
229 return true;
230 }
231 if msg.contains("ould not delete") && msg.contains("remote ref does not exist") {
232 return true; }
234 eprintln!("删除远端标签失败: {}", msg);
235 false
236 }
237 Err(e) => {
238 eprintln!("删除远端标签失败: {}", e);
239 false
240 }
241 }
242}
243
244pub fn delete_release(version: &str, repo: &str) -> bool {
246 let out = Command::new("gh")
247 .args(["release", "delete", version, "--yes", "--repo", repo])
248 .output();
249 match out {
250 Ok(out) if out.status.success() => true,
251 Ok(out) => {
252 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
253 if msg.contains("not found") || msg.contains("404") {
254 return true; }
256 eprintln!("删除 Release 失败: {}", msg);
257 false
258 }
259 Err(e) => {
260 eprintln!("删除 Release 失败: {}", e);
261 false
262 }
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use std::path::Path;
270
271 fn git_init(path: &Path) {
272 std::process::Command::new("git")
273 .args(["init", "-b", "main"])
274 .current_dir(path)
275 .output()
276 .unwrap();
277 std::process::Command::new("git")
278 .args(["config", "user.email", "test@test.com"])
279 .current_dir(path)
280 .output()
281 .unwrap();
282 std::process::Command::new("git")
283 .args(["config", "user.name", "Test"])
284 .current_dir(path)
285 .output()
286 .unwrap();
287 }
288
289 fn git_commit(path: &Path, msg: &str) {
290 std::fs::write(path.join("file"), msg).unwrap();
291 std::process::Command::new("git")
292 .args(["add", "."])
293 .current_dir(path)
294 .output()
295 .unwrap();
296 std::process::Command::new("git")
297 .args(["commit", "-m", msg])
298 .current_dir(path)
299 .output()
300 .unwrap();
301 }
302
303 #[test]
304 fn test_parse_github_repo_https() {
305 assert_eq!(
306 parse_github_repo("https://github.com/owner/repo.git"),
307 Some("owner/repo".into())
308 );
309 }
310 #[test]
311 fn test_parse_github_repo_ssh() {
312 assert_eq!(
313 parse_github_repo("git@github.com:owner/repo.git"),
314 Some("owner/repo".into())
315 );
316 }
317 #[test]
318 fn test_parse_github_repo_not_github() {
319 assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None);
320 }
321 #[test]
322 fn test_extract_notes_found() {
323 let d = tempfile::tempdir().unwrap();
324 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
325 assert!(extract_notes("v1.0.0", &d.path().join("C.md")).is_some());
326 }
327 #[test]
328 fn test_extract_notes_not_found() {
329 let d = tempfile::tempdir().unwrap();
330 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
331 assert!(extract_notes("v2.0.0", &d.path().join("C.md")).is_none());
332 }
333 #[test]
334 fn test_extract_notes_filters_header_lines() {
335 let d = tempfile::tempdir().unwrap();
336 std::fs::write(
337 d.path().join("C.md"),
338 "## [1.0.0] - 2026-06-26\n\n\
339 ## [v1.0.0] - 2023-08-31\n\n\
340 ### Added\n- feature\n",
341 )
342 .unwrap();
343 let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap_or_default();
344 assert!(!notes.contains("## ["), "提取内容应过滤 ## [ 行: {}", notes);
345 assert!(notes.contains("### Added"));
346 assert!(notes.contains("- feature"));
347 }
348
349 #[test]
350 fn test_confirm_release_yes_flag() {
351 assert!(confirm_release("v1.0.0", true));
352 }
353 #[test]
354 fn test_precheck_changelog_no_errors() {
355 let d = tempfile::tempdir().unwrap();
356 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
357 assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty());
358 }
359 #[test]
360 fn test_precheck_changelog_missing_entry() {
361 let d = tempfile::tempdir().unwrap();
362 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
363 assert!(precheck_version_changelog("v2.0.0", &d.path().join("C.md"))
364 .iter()
365 .any(|e| e.contains("未找到")));
366 }
367 #[test]
368 fn test_precheck_changelog_file_not_found() {
369 let d = tempfile::tempdir().unwrap();
370 assert!(precheck_version_changelog("v1.0.0", &d.path().join("N.md"))
371 .iter()
372 .any(|e| e.contains("不存在")));
373 }
374 #[test]
375 fn test_precheck_changelog_version_invalid() {
376 let d = tempfile::tempdir().unwrap();
377 assert!(precheck_version_changelog("bad", &d.path().join("C.md"))
378 .iter()
379 .any(|e| e.contains("格式错误")));
380 }
381 #[test]
382 fn test_publish_target_debug() {
383 assert_eq!(format!("{:?}", PublishTarget::PyPI), "PyPI");
384 }
385
386 #[test]
387 fn test_publish_target_clone_eq() {
388 assert_eq!(PublishTarget::PyPI, PublishTarget::PyPI);
389 }
390 #[test]
391 fn test_get_remote_repo_no_git_repo() {
392 assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None);
393 }
394 #[test]
395 fn test_create_release_no_gh() {
396 assert!(!create_release("v0.0.0-test", "", "no/repo"));
397 }
398 #[test]
399 fn test_create_tag_in_non_git_dir() {
400 assert!(!create_tag(
401 "v0.0.0-test",
402 tempfile::tempdir().unwrap().path()
403 ));
404 }
405 #[test]
406 fn test_create_tag_idempotent() {
407 let d = tempfile::tempdir().unwrap();
408 git_init(d.path());
409 git_commit(d.path(), "init");
410 assert!(create_tag("v0.0.0-test", d.path()));
411 assert!(create_tag("v0.0.0-test", d.path()));
412 }
413 #[test]
414 fn test_push_tag_in_non_git_dir() {
415 assert!(!push_tag(
416 "v0.0.0-test",
417 tempfile::tempdir().unwrap().path()
418 ));
419 }
420 #[test]
421 fn test_push_tag_fails_with_non_existent_remote() {
422 let d = tempfile::tempdir().unwrap();
423 git_init(d.path());
424 git_commit(d.path(), "init");
425 assert!(create_tag("v0.0.0-test-remote", d.path()));
426 std::process::Command::new("git")
427 .args([
428 "remote",
429 "add",
430 "origin",
431 "https://nonexistent.invalid/repo.git",
432 ])
433 .current_dir(d.path())
434 .output()
435 .unwrap();
436 assert!(!push_tag("v0.0.0-test-remote", d.path()));
437 }
438 #[test]
439 fn test_get_remote_repo_in_git_without_remote() {
440 let d = tempfile::tempdir().unwrap();
441 std::process::Command::new("git")
442 .args(["init", "-b", "main"])
443 .current_dir(d.path())
444 .output()
445 .unwrap();
446 assert_eq!(get_remote_repo(d.path()), None);
447 }
448 #[test]
449 fn test_rollback_tag_removes_tag() {
450 let d = tempfile::tempdir().unwrap();
451 std::process::Command::new("git")
452 .args(["init", "-b", "main"])
453 .current_dir(d.path())
454 .output()
455 .unwrap();
456 std::fs::write(d.path().join("f"), "").unwrap();
457 std::process::Command::new("git")
458 .args(["add", "."])
459 .current_dir(d.path())
460 .output()
461 .unwrap();
462 std::process::Command::new("git")
463 .args([
464 "-c",
465 "user.name=t",
466 "-c",
467 "user.email=t@t",
468 "commit",
469 "-m",
470 "x",
471 ])
472 .current_dir(d.path())
473 .output()
474 .unwrap();
475 assert!(create_tag("v0.0.0-test-rollback", d.path()));
476 rollback_tag("v0.0.0-test-rollback", d.path());
477 let o = std::process::Command::new("git")
478 .args(["tag", "-l"])
479 .current_dir(d.path())
480 .output()
481 .unwrap();
482 assert!(!String::from_utf8_lossy(&o.stdout).contains("v0.0.0-test-rollback"));
483 }
484}