Skip to main content

qtcloud_devops_cli/release/
util.rs

1use 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
88/// 用 git2 创建轻量 tag(等价于 `git tag <version>`)。
89pub 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    // 已存在则视为成功(幂等)
99    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
116/// 推送 tag 到远程(保持 CLI,需要网络)。
117pub 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
147/// 查询 remote origin 的 GitHub 仓库标识。
148pub 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
184/// 回滚 tag:本地删除用 git2,远程删除用 CLI。
185pub 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
193/// 删除本地 tag(等价于 `git tag -d <version>`)。
194pub 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 // 不存在也算成功
207}
208
209/// 删除远端 tag(等价于 `git push --delete origin <version>`)。
210pub fn delete_remote_tag(version: &str, repo_path: &Path) -> bool {
211    if get_remote_repo(repo_path).is_none() {
212        return true; // 没有 remote 则跳过
213    }
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; // 远端 tag 不存在也算成功
233            }
234            eprintln!("删除远端标签失败: {}", msg);
235            false
236        }
237        Err(e) => {
238            eprintln!("删除远端标签失败: {}", e);
239            false
240        }
241    }
242}
243
244/// 删除 GitHub Release(等价于 `gh release delete <version> --yes`)。
245pub 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; // 不存在也算成功
255            }
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}