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 if let Ok(repo) = git2::Repository::open(repo_path) {
188 let refname = format!("refs/tags/{}", version);
189 if let Ok(mut reference) = repo.find_reference(&refname) {
190 let _ = reference.delete();
191 }
192 }
193 Command::new("git")
195 .args([
196 "-C",
197 &repo_path.to_string_lossy(),
198 "push",
199 "origin",
200 "--delete",
201 version,
202 ])
203 .output()
204 .ok();
205 println!("↻ 标签 {} 已回滚", version);
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use std::path::Path;
212
213 fn git_init(path: &Path) {
214 std::process::Command::new("git")
215 .args(["init", "-b", "main"])
216 .current_dir(path)
217 .output()
218 .unwrap();
219 std::process::Command::new("git")
220 .args(["config", "user.email", "test@test.com"])
221 .current_dir(path)
222 .output()
223 .unwrap();
224 std::process::Command::new("git")
225 .args(["config", "user.name", "Test"])
226 .current_dir(path)
227 .output()
228 .unwrap();
229 }
230
231 fn git_commit(path: &Path, msg: &str) {
232 std::fs::write(path.join("file"), msg).unwrap();
233 std::process::Command::new("git")
234 .args(["add", "."])
235 .current_dir(path)
236 .output()
237 .unwrap();
238 std::process::Command::new("git")
239 .args(["commit", "-m", msg])
240 .current_dir(path)
241 .output()
242 .unwrap();
243 }
244
245 #[test]
246 fn test_parse_github_repo_https() {
247 assert_eq!(
248 parse_github_repo("https://github.com/owner/repo.git"),
249 Some("owner/repo".into())
250 );
251 }
252 #[test]
253 fn test_parse_github_repo_ssh() {
254 assert_eq!(
255 parse_github_repo("git@github.com:owner/repo.git"),
256 Some("owner/repo".into())
257 );
258 }
259 #[test]
260 fn test_parse_github_repo_not_github() {
261 assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None);
262 }
263 #[test]
264 fn test_extract_notes_found() {
265 let d = tempfile::tempdir().unwrap();
266 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
267 assert!(extract_notes("v1.0.0", &d.path().join("C.md")).is_some());
268 }
269 #[test]
270 fn test_extract_notes_not_found() {
271 let d = tempfile::tempdir().unwrap();
272 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
273 assert!(extract_notes("v2.0.0", &d.path().join("C.md")).is_none());
274 }
275 #[test]
276 fn test_extract_notes_filters_header_lines() {
277 let d = tempfile::tempdir().unwrap();
278 std::fs::write(
279 d.path().join("C.md"),
280 "## [1.0.0] - 2026-06-26\n\n\
281 ## [v1.0.0] - 2023-08-31\n\n\
282 ### Added\n- feature\n",
283 )
284 .unwrap();
285 let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap_or_default();
286 assert!(!notes.contains("## ["), "提取内容应过滤 ## [ 行: {}", notes);
287 assert!(notes.contains("### Added"));
288 assert!(notes.contains("- feature"));
289 }
290
291 #[test]
292 fn test_confirm_release_yes_flag() {
293 assert!(confirm_release("v1.0.0", true));
294 }
295 #[test]
296 fn test_precheck_changelog_no_errors() {
297 let d = tempfile::tempdir().unwrap();
298 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
299 assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty());
300 }
301 #[test]
302 fn test_precheck_changelog_missing_entry() {
303 let d = tempfile::tempdir().unwrap();
304 std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
305 assert!(precheck_version_changelog("v2.0.0", &d.path().join("C.md"))
306 .iter()
307 .any(|e| e.contains("未找到")));
308 }
309 #[test]
310 fn test_precheck_changelog_file_not_found() {
311 let d = tempfile::tempdir().unwrap();
312 assert!(precheck_version_changelog("v1.0.0", &d.path().join("N.md"))
313 .iter()
314 .any(|e| e.contains("不存在")));
315 }
316 #[test]
317 fn test_precheck_changelog_version_invalid() {
318 let d = tempfile::tempdir().unwrap();
319 assert!(precheck_version_changelog("bad", &d.path().join("C.md"))
320 .iter()
321 .any(|e| e.contains("格式错误")));
322 }
323 #[test]
324 fn test_publish_target_debug() {
325 assert_eq!(format!("{:?}", PublishTarget::PyPI), "PyPI");
326 }
327
328 #[test]
329 fn test_publish_target_clone_eq() {
330 assert_eq!(PublishTarget::PyPI, PublishTarget::PyPI);
331 }
332 #[test]
333 fn test_get_remote_repo_no_git_repo() {
334 assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None);
335 }
336 #[test]
337 fn test_create_release_no_gh() {
338 assert!(!create_release("v0.0.0-test", "", "no/repo"));
339 }
340 #[test]
341 fn test_create_tag_in_non_git_dir() {
342 assert!(!create_tag(
343 "v0.0.0-test",
344 tempfile::tempdir().unwrap().path()
345 ));
346 }
347 #[test]
348 fn test_create_tag_idempotent() {
349 let d = tempfile::tempdir().unwrap();
350 git_init(d.path());
351 git_commit(d.path(), "init");
352 assert!(create_tag("v0.0.0-test", d.path()));
353 assert!(create_tag("v0.0.0-test", d.path()));
354 }
355 #[test]
356 fn test_push_tag_in_non_git_dir() {
357 assert!(!push_tag(
358 "v0.0.0-test",
359 tempfile::tempdir().unwrap().path()
360 ));
361 }
362 #[test]
363 fn test_push_tag_fails_with_non_existent_remote() {
364 let d = tempfile::tempdir().unwrap();
365 git_init(d.path());
366 git_commit(d.path(), "init");
367 assert!(create_tag("v0.0.0-test-remote", d.path()));
368 std::process::Command::new("git")
369 .args([
370 "remote",
371 "add",
372 "origin",
373 "https://nonexistent.invalid/repo.git",
374 ])
375 .current_dir(d.path())
376 .output()
377 .unwrap();
378 assert!(!push_tag("v0.0.0-test-remote", d.path()));
379 }
380 #[test]
381 fn test_get_remote_repo_in_git_without_remote() {
382 let d = tempfile::tempdir().unwrap();
383 std::process::Command::new("git")
384 .args(["init", "-b", "main"])
385 .current_dir(d.path())
386 .output()
387 .unwrap();
388 assert_eq!(get_remote_repo(d.path()), None);
389 }
390 #[test]
391 fn test_rollback_tag_removes_tag() {
392 let d = tempfile::tempdir().unwrap();
393 std::process::Command::new("git")
394 .args(["init", "-b", "main"])
395 .current_dir(d.path())
396 .output()
397 .unwrap();
398 std::fs::write(d.path().join("f"), "").unwrap();
399 std::process::Command::new("git")
400 .args(["add", "."])
401 .current_dir(d.path())
402 .output()
403 .unwrap();
404 std::process::Command::new("git")
405 .args([
406 "-c",
407 "user.name=t",
408 "-c",
409 "user.email=t@t",
410 "commit",
411 "-m",
412 "x",
413 ])
414 .current_dir(d.path())
415 .output()
416 .unwrap();
417 assert!(create_tag("v0.0.0-test-rollback", d.path()));
418 rollback_tag("v0.0.0-test-rollback", d.path());
419 let o = std::process::Command::new("git")
420 .args(["tag", "-l"])
421 .current_dir(d.path())
422 .output()
423 .unwrap();
424 assert!(!String::from_utf8_lossy(&o.stdout).contains("v0.0.0-test-rollback"));
425 }
426}