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    // 本地删除
187    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    // 远程删除
194    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}