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(_) => return false,
93 };
94 let refname = format!("refs/tags/{}", version);
95 if repo.find_reference(&refname).is_ok() {
96 return true;
97 }
98 let head_id = match repo.head().ok().and_then(|h| h.target()) {
99 Some(id) => id,
100 None => return false,
101 };
102 let result = repo.reference(&refname, head_id, false, "");
103 result.is_ok()
104}
105
106pub fn push_tag(version: &str, repo_path: &Path) -> bool {
108 let repo = match git2::Repository::open(repo_path) {
109 Ok(r) => r,
110 Err(_) => return false,
111 };
112 let mut remote = match repo.find_remote("origin") {
113 Ok(r) => r,
114 Err(_) => return true, };
116 remote.push(&[version], None).is_ok()
117}
118
119pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
121 let repo = gix::open(repo_path).ok()?;
122 let remote = repo.find_remote("origin").ok()?;
123 let url = remote.url(gix::remote::Direction::Fetch)?;
124 parse_github_repo(url.to_string().as_str())
125}
126
127pub fn parse_github_repo(url: &str) -> Option<String> {
128 let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
129 let caps = re.captures(url)?;
130 Some(caps.get(1)?.as_str().to_string())
131}
132
133pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
134 let out = Command::new("gh")
135 .args([
136 "release", "create", version, "--title", version, "--notes", notes, "--repo", repo,
137 ])
138 .output();
139 match out {
140 Ok(out) if out.status.success() => true,
141 Ok(out) => {
142 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
143 if msg.contains("already exists") || msg.contains("已存在") {
144 return true;
145 }
146 eprintln!("创建 Release 失败: {}", msg);
147 false
148 }
149 Err(e) => {
150 eprintln!("创建 Release 失败: {}", e);
151 false
152 }
153 }
154}
155
156pub fn rollback_tag(version: &str, repo_path: &Path) {
158 let local_ok = delete_local_tag(version, repo_path);
159 let remote_ok = delete_remote_tag(version, repo_path);
160 if local_ok && remote_ok {
161 eprintln!("已回滚标签 {}", version);
162 }
163}
164
165pub fn delete_local_tag(version: &str, repo_path: &Path) -> bool {
167 let repo = match git2::Repository::open(repo_path) {
168 Ok(r) => r,
169 Err(_) => return true,
170 };
171 let refname = format!("refs/tags/{}", version);
172 repo.find_reference(&refname)
173 .ok()
174 .and_then(|mut r| r.delete().ok())
175 .is_some()
176}
177
178pub fn delete_remote_tag(version: &str, repo_path: &Path) -> bool {
180 let repo = match git2::Repository::open(repo_path) {
181 Ok(r) => r,
182 Err(_) => return false,
183 };
184 let mut remote = match repo.find_remote("origin") {
185 Ok(r) => r,
186 Err(_) => return true, };
188 let refspec = format!(":refs/tags/{}", version);
189 remote.push(&[&refspec], None).is_ok()
190}
191
192pub fn delete_release(version: &str, repo: &str) -> bool {
194 let out = Command::new("gh")
195 .args(["release", "delete", version, "--yes", "--repo", repo])
196 .output();
197 match out {
198 Ok(out) if out.status.success() => true,
199 Ok(out) => {
200 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
201 if msg.contains("not found") || msg.contains("404") {
202 return true; }
204 eprintln!("删除 Release 失败: {}", msg);
205 false
206 }
207 Err(e) => {
208 eprintln!("删除 Release 失败: {}", e);
209 false
210 }
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 fn git_init(path: &std::path::Path) {
217 let repo = git2::Repository::init(path).unwrap();
218 let mut cfg = repo.config().unwrap();
219 cfg.set_str("user.email", "t@t").unwrap();
220 cfg.set_str("user.name", "t").unwrap();
221 }
222
223 fn git_commit(path: &std::path::Path, msg: &str) {
224 std::fs::write(path.join("f"), msg).unwrap();
225 let repo = git2::Repository::open(path).unwrap();
226 let mut index = repo.index().unwrap();
227 index.add_path(std::path::Path::new("f")).unwrap();
228 index.write().unwrap();
229 let tree_id = index.write_tree().unwrap();
230 let tree = repo.find_tree(tree_id).unwrap();
231 let sig = repo.signature().unwrap();
232 let parent = repo.head().and_then(|h| h.peel_to_commit()).ok();
233 let parents: Vec<&git2::Commit> = parent.iter().collect();
234 repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
235 .unwrap();
236 }
237
238 use super::*;
239
240 #[test]
241 fn test_parse_github_repo_https() {
242 assert_eq!(
243 parse_github_repo("https://github.com/owner/repo.git"),
244 Some("owner/repo".into())
245 );
246 }
247 #[test]
248 fn test_parse_github_repo_ssh() {
249 assert_eq!(
250 parse_github_repo("git@github.com:owner/repo.git"),
251 Some("owner/repo".into())
252 );
253 }
254 #[test]
255 fn test_parse_github_repo_not_github() {
256 assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None);
257 }
258 #[test]
259 fn test_extract_notes_found() {
260 let d = tempfile::tempdir().unwrap();
261 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
262 assert!(extract_notes("v1.0.0", &d.path().join("C.md")).is_some());
263 }
264 #[test]
265 fn test_extract_notes_not_found() {
266 let d = tempfile::tempdir().unwrap();
267 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
268 assert!(extract_notes("v2.0.0", &d.path().join("C.md")).is_none());
269 }
270 #[test]
271 fn test_extract_notes_filters_header_lines() {
272 let d = tempfile::tempdir().unwrap();
273 std::fs::write(
274 d.path().join("C.md"),
275 "## [1.0.0] - 2026-06-26\n\n\
276 ## [v1.0.0] - 2023-08-31\n\n\
277 ### Added\n- feature\n",
278 )
279 .unwrap();
280 let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap_or_default();
281 assert!(!notes.contains("## ["), "提取内容应过滤 ## [ 行: {}", notes);
282 assert!(notes.contains("### Added"));
283 assert!(notes.contains("- feature"));
284 }
285
286 #[test]
287 fn test_extract_notes_with_v_prefix_marker() {
288 let d = tempfile::tempdir().unwrap();
289 std::fs::write(d.path().join("C.md"), "## [v1.0.0]\n\ncontent line").unwrap();
290 let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap_or_default();
291 assert!(notes.contains("content line"));
292 }
293
294 #[test]
295 fn test_extract_notes_next_version_stops() {
296 let d = tempfile::tempdir().unwrap();
297 std::fs::write(
298 d.path().join("C.md"),
299 "## [1.0.0]\n\ncontent\n## [2.0.0]\n\nnext\n",
300 )
301 .unwrap();
302 let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap();
303 assert!(notes.contains("content"));
304 assert!(!notes.contains("next"));
305 }
306
307 #[test]
308 fn test_precheck_changelog_with_v_prefix_marker() {
309 let d = tempfile::tempdir().unwrap();
310 std::fs::write(d.path().join("C.md"), "## [v1.0.0]\n\ncontent\n").unwrap();
311 assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty());
312 }
313
314 #[test]
315 fn test_confirm_release_yes_flag() {
316 assert!(confirm_release("v1.0.0", true));
317 }
318 #[test]
319 fn test_precheck_changelog_no_errors() {
320 let d = tempfile::tempdir().unwrap();
321 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
322 assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty());
323 }
324 #[test]
325 fn test_precheck_changelog_missing_entry() {
326 let d = tempfile::tempdir().unwrap();
327 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
328 assert!(precheck_version_changelog("v2.0.0", &d.path().join("C.md"))
329 .iter()
330 .any(|e| e.contains("未找到")));
331 }
332 #[test]
333 fn test_precheck_changelog_file_not_found() {
334 let d = tempfile::tempdir().unwrap();
335 assert!(precheck_version_changelog("v1.0.0", &d.path().join("N.md"))
336 .iter()
337 .any(|e| e.contains("不存在")));
338 }
339 #[test]
340 fn test_precheck_changelog_version_invalid() {
341 let d = tempfile::tempdir().unwrap();
342 assert!(precheck_version_changelog("bad", &d.path().join("C.md"))
343 .iter()
344 .any(|e| e.contains("格式错误")));
345 }
346 #[test]
347 fn test_publish_target_debug() {
348 assert_eq!(format!("{:?}", PublishTarget::PyPI), "PyPI");
349 }
350
351 #[test]
352 fn test_publish_target_clone_eq() {
353 assert_eq!(PublishTarget::PyPI, PublishTarget::PyPI);
354 }
355 #[test]
356 fn test_get_remote_repo_no_git_repo() {
357 assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None);
358 }
359 #[test]
360 fn test_create_release_no_gh() {
361 assert!(!create_release("v0.0.0-test", "", "no/repo"));
362 }
363 #[test]
364 fn test_create_tag_in_non_git_dir() {
365 assert!(!create_tag(
366 "v0.0.0-test",
367 tempfile::tempdir().unwrap().path()
368 ));
369 }
370 #[test]
371 fn test_create_tag_idempotent() {
372 let d = tempfile::tempdir().unwrap();
373 git_init(d.path());
374 git_commit(d.path(), "init");
375 assert!(create_tag("v0.0.0-test", d.path()));
376 assert!(create_tag("v0.0.0-test", d.path()));
377 }
378 #[test]
379 fn test_push_tag_in_non_git_dir() {
380 assert!(!push_tag(
381 "v0.0.0-test",
382 tempfile::tempdir().unwrap().path()
383 ));
384 }
385 #[test]
386 fn test_push_tag_fails_with_non_existent_remote() {
387 let d = tempfile::tempdir().unwrap();
388 git_init(d.path());
389 git_commit(d.path(), "init");
390 assert!(create_tag("v0.0.0-test-remote", d.path()));
391 std::process::Command::new("git")
392 .args([
393 "remote",
394 "add",
395 "origin",
396 "https://nonexistent.invalid/repo.git",
397 ])
398 .current_dir(d.path())
399 .output()
400 .unwrap();
401 assert!(!push_tag("v0.0.0-test-remote", d.path()));
402 }
403 #[test]
404 fn test_get_remote_repo_in_git_without_remote() {
405 let d = tempfile::tempdir().unwrap();
406 std::process::Command::new("git")
407 .args(["init", "-b", "main"])
408 .current_dir(d.path())
409 .output()
410 .unwrap();
411 assert_eq!(get_remote_repo(d.path()), None);
412 }
413 #[test]
414 fn test_rollback_tag_removes_tag() {
415 let d = tempfile::tempdir().unwrap();
416 std::process::Command::new("git")
417 .args(["init", "-b", "main"])
418 .current_dir(d.path())
419 .output()
420 .unwrap();
421 std::fs::write(d.path().join("f"), "").unwrap();
422 std::process::Command::new("git")
423 .args(["add", "."])
424 .current_dir(d.path())
425 .output()
426 .unwrap();
427 std::process::Command::new("git")
428 .args([
429 "-c",
430 "user.name=t",
431 "-c",
432 "user.email=t@t",
433 "commit",
434 "-m",
435 "x",
436 ])
437 .current_dir(d.path())
438 .output()
439 .unwrap();
440 assert!(create_tag("v0.0.0-test-rollback", d.path()));
441 rollback_tag("v0.0.0-test-rollback", d.path());
442 let o = std::process::Command::new("git")
443 .args(["tag", "-l"])
444 .current_dir(d.path())
445 .output()
446 .unwrap();
447 assert!(!String::from_utf8_lossy(&o.stdout).contains("v0.0.0-test-rollback"));
448 }
449}