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                // 同版本重复头部(LLM 混入)跳过,不同版本停止
53                if line.contains(&ver) || line.contains(&format!("v{}", ver)) {
54                    continue;
55                }
56                break;
57            }
58            notes.push(line);
59        }
60    }
61    let text = notes
62        .iter()
63        .filter(|l| !l.trim().starts_with("## ["))
64        .cloned()
65        .collect::<Vec<_>>()
66        .join("\n")
67        .trim()
68        .to_string();
69    if text.is_empty() {
70        None
71    } else {
72        Some(text)
73    }
74}
75
76pub fn confirm_release(version: &str, yes: bool) -> bool {
77    if yes {
78        return true;
79    }
80    use std::io::Write;
81    println!("\n发布版本: {}", version);
82    print!("确认发布? (y/N): ");
83    std::io::stdout().flush().ok();
84    let mut input = String::new();
85    std::io::stdin().read_line(&mut input).ok();
86    input.trim().to_lowercase() == "y" || input.trim().to_lowercase() == "yes"
87}
88
89fn git_args(args: &[&str], repo_path: &Path) -> Command {
90    let mut cmd = Command::new("git");
91    cmd.arg("-C");
92    cmd.arg(repo_path);
93    cmd.args(args);
94    cmd
95}
96
97pub fn create_tag(version: &str, repo_path: &Path) -> bool {
98    match git_args(&["tag", version], repo_path).output() {
99        Ok(out) if out.status.success() => true,
100        Ok(out) => {
101            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
102            if msg.contains("already exists") || msg.contains("已存在") {
103                return true;
104            }
105            eprintln!("创建标签失败: {}", msg);
106            false
107        }
108        Err(e) => {
109            eprintln!("创建标签失败: {}", e);
110            false
111        }
112    }
113}
114
115pub fn push_tag(version: &str, repo_path: &Path) -> bool {
116    match git_args(&["push", "origin", version], repo_path).output() {
117        Ok(out) if out.status.success() => true,
118        Ok(out) => {
119            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
120            if msg.contains("does not appear") || msg.contains("repository '' does not exist") {
121                return true;
122            }
123            eprintln!("推送标签失败: {}", msg);
124            false
125        }
126        Err(e) => {
127            eprintln!("推送标签失败: {}", e);
128            false
129        }
130    }
131}
132
133pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
134    let result = git_args(&["remote", "get-url", "origin"], repo_path)
135        .output()
136        .ok()?;
137    if !result.status.success() {
138        return None;
139    }
140    parse_github_repo(&String::from_utf8_lossy(&result.stdout).trim())
141}
142
143pub fn parse_github_repo(url: &str) -> Option<String> {
144    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
145    let caps = re.captures(url)?;
146    Some(caps.get(1)?.as_str().to_string())
147}
148
149pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
150    let out = Command::new("gh")
151        .args([
152            "release", "create", version, "--title", version, "--notes", notes, "--repo", repo,
153        ])
154        .output();
155    match out {
156        Ok(out) if out.status.success() => true,
157        Ok(out) => {
158            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
159            if msg.contains("already exists") || msg.contains("已存在") {
160                return true;
161            }
162            eprintln!("创建 Release 失败: {}", msg);
163            false
164        }
165        Err(e) => {
166            eprintln!("创建 Release 失败: {}", e);
167            false
168        }
169    }
170}
171
172pub fn rollback_tag(version: &str, repo_path: &Path) {
173    git_args(&["tag", "-d", version], repo_path).output().ok();
174    git_args(&["push", "origin", "--delete", version], repo_path)
175        .output()
176        .ok();
177    println!("↻ 标签 {} 已回滚", version);
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use std::path::Path;
184
185    fn git_init(path: &Path) {
186        std::process::Command::new("git")
187            .args(["init", "-b", "main"])
188            .current_dir(path)
189            .output()
190            .unwrap();
191        std::process::Command::new("git")
192            .args(["config", "user.email", "test@test.com"])
193            .current_dir(path)
194            .output()
195            .unwrap();
196        std::process::Command::new("git")
197            .args(["config", "user.name", "Test"])
198            .current_dir(path)
199            .output()
200            .unwrap();
201    }
202
203    fn git_commit(path: &Path, msg: &str) {
204        std::fs::write(path.join("file"), msg).unwrap();
205        std::process::Command::new("git")
206            .args(["add", "."])
207            .current_dir(path)
208            .output()
209            .unwrap();
210        std::process::Command::new("git")
211            .args(["commit", "-m", msg])
212            .current_dir(path)
213            .output()
214            .unwrap();
215    }
216
217    #[test]
218    fn test_parse_github_repo_https() {
219        assert_eq!(
220            parse_github_repo("https://github.com/owner/repo.git"),
221            Some("owner/repo".into())
222        );
223    }
224    #[test]
225    fn test_parse_github_repo_ssh() {
226        assert_eq!(
227            parse_github_repo("git@github.com:owner/repo.git"),
228            Some("owner/repo".into())
229        );
230    }
231    #[test]
232    fn test_parse_github_repo_not_github() {
233        assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None);
234    }
235    #[test]
236    fn test_extract_notes_found() {
237        let d = tempfile::tempdir().unwrap();
238        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
239        assert!(extract_notes("v1.0.0", &d.path().join("C.md")).is_some());
240    }
241    #[test]
242    fn test_extract_notes_not_found() {
243        let d = tempfile::tempdir().unwrap();
244        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
245        assert!(extract_notes("v2.0.0", &d.path().join("C.md")).is_none());
246    }
247    #[test]
248    fn test_extract_notes_filters_header_lines() {
249        let d = tempfile::tempdir().unwrap();
250        // 模拟 LLM 产物中混入版本头部行
251        std::fs::write(
252            d.path().join("C.md"),
253            "## [1.0.0] - 2026-06-26\n\n\
254             ## [v1.0.0] - 2023-08-31\n\n\
255             ### Added\n- feature\n",
256        )
257        .unwrap();
258        let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap_or_default();
259        assert!(!notes.contains("## ["), "提取内容应过滤 ## [ 行: {}", notes);
260        assert!(notes.contains("### Added"));
261        assert!(notes.contains("- feature"));
262    }
263
264    #[test]
265    fn test_confirm_release_yes_flag() {
266        assert!(confirm_release("v1.0.0", true));
267    }
268    #[test]
269    fn test_precheck_changelog_no_errors() {
270        let d = tempfile::tempdir().unwrap();
271        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
272        assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty());
273    }
274    #[test]
275    fn test_precheck_changelog_missing_entry() {
276        let d = tempfile::tempdir().unwrap();
277        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
278        assert!(precheck_version_changelog("v2.0.0", &d.path().join("C.md"))
279            .iter()
280            .any(|e| e.contains("未找到")));
281    }
282    #[test]
283    fn test_precheck_changelog_file_not_found() {
284        let d = tempfile::tempdir().unwrap();
285        assert!(precheck_version_changelog("v1.0.0", &d.path().join("N.md"))
286            .iter()
287            .any(|e| e.contains("不存在")));
288    }
289    #[test]
290    fn test_precheck_changelog_version_invalid() {
291        let d = tempfile::tempdir().unwrap();
292        assert!(precheck_version_changelog("bad", &d.path().join("C.md"))
293            .iter()
294            .any(|e| e.contains("格式错误")));
295    }
296    #[test]
297    fn test_publish_target_debug() {
298        assert_eq!(format!("{:?}", PublishTarget::PyPI), "PyPI");
299    }
300
301    #[test]
302    fn test_publish_target_clone_eq() {
303        assert_eq!(PublishTarget::PyPI, PublishTarget::PyPI);
304    }
305    #[test]
306    fn test_get_remote_repo_no_git_repo() {
307        assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None);
308    }
309    #[test]
310    fn test_create_release_no_gh() {
311        assert!(!create_release("v0.0.0-test", "", "no/repo"));
312    }
313    #[test]
314    fn test_create_tag_in_non_git_dir() {
315        assert!(!create_tag(
316            "v0.0.0-test",
317            tempfile::tempdir().unwrap().path()
318        ));
319    }
320    #[test]
321    fn test_create_tag_idempotent() {
322        let d = tempfile::tempdir().unwrap();
323        git_init(d.path());
324        git_commit(d.path(), "init");
325        assert!(create_tag("v0.0.0-test", d.path()));
326        assert!(create_tag("v0.0.0-test", d.path()));
327    }
328    #[test]
329    fn test_push_tag_in_non_git_dir() {
330        assert!(!push_tag(
331            "v0.0.0-test",
332            tempfile::tempdir().unwrap().path()
333        ));
334    }
335    #[test]
336    fn test_push_tag_fails_with_non_existent_remote() {
337        let d = tempfile::tempdir().unwrap();
338        git_init(d.path());
339        git_commit(d.path(), "init");
340        assert!(create_tag("v0.0.0-test-remote", d.path()));
341        std::process::Command::new("git")
342            .args([
343                "remote",
344                "add",
345                "origin",
346                "https://nonexistent.invalid/repo.git",
347            ])
348            .current_dir(d.path())
349            .output()
350            .unwrap();
351        assert!(!push_tag("v0.0.0-test-remote", d.path()));
352    }
353    #[test]
354    fn test_get_remote_repo_in_git_without_remote() {
355        let d = tempfile::tempdir().unwrap();
356        std::process::Command::new("git")
357            .args(["init", "-b", "main"])
358            .current_dir(d.path())
359            .output()
360            .unwrap();
361        assert_eq!(get_remote_repo(d.path()), None);
362    }
363    #[test]
364    fn test_rollback_tag_removes_tag() {
365        let d = tempfile::tempdir().unwrap();
366        std::process::Command::new("git")
367            .args(["init", "-b", "main"])
368            .current_dir(d.path())
369            .output()
370            .unwrap();
371        std::fs::write(d.path().join("f"), "").unwrap();
372        std::process::Command::new("git")
373            .args(["add", "."])
374            .current_dir(d.path())
375            .output()
376            .unwrap();
377        std::process::Command::new("git")
378            .args([
379                "-c",
380                "user.name=t",
381                "-c",
382                "user.email=t@t",
383                "commit",
384                "-m",
385                "x",
386            ])
387            .current_dir(d.path())
388            .output()
389            .unwrap();
390        assert!(create_tag("v0.0.0-test-rollback", d.path()));
391        rollback_tag("v0.0.0-test-rollback", d.path());
392        let o = std::process::Command::new("git")
393            .args(["tag", "-l"])
394            .current_dir(d.path())
395            .output()
396            .unwrap();
397        assert!(!String::from_utf8_lossy(&o.stdout).contains("v0.0.0-test-rollback"));
398    }
399}