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            eprintln!("推送标签失败: {}", msg);
135            false
136        }
137        Err(e) => {
138            eprintln!("推送标签失败: {}", e);
139            false
140        }
141    }
142}
143
144/// 查询 remote origin 的 GitHub 仓库标识。
145pub 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
181/// 回滚 tag:本地删除用 git2,远程删除用 CLI。
182pub fn rollback_tag(version: &str, repo_path: &Path) {
183    // 本地删除
184    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    // 远程删除
191    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}