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/// 创建轻量 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(_) => return false,
93    };
94    let refname = format!("refs/tags/{}", version);
95    if repo.find_reference(&refname).is_ok() {
96        return true;
97    }
98    let head_id = match repo.head().ok().and_then(|h| h.target()) {
99        Some(id) => id,
100        None => return false,
101    };
102    let result = repo.reference(&refname, head_id, false, "");
103    result.is_ok()
104}
105
106/// 推送 tag 到远程(需要网络)。
107pub fn push_tag(version: &str, repo_path: &Path) -> bool {
108    let repo = match git2::Repository::open(repo_path) {
109        Ok(r) => r,
110        Err(_) => return false,
111    };
112    let mut remote = match repo.find_remote("origin") {
113        Ok(r) => r,
114        Err(_) => return true, // 无 remote 视为成功
115    };
116    remote.push(&[version], None).is_ok()
117}
118
119/// 查询 remote origin 的 GitHub 仓库标识。
120pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
121    let repo = gix::open(repo_path).ok()?;
122    let remote = repo.find_remote("origin").ok()?;
123    let url = remote.url(gix::remote::Direction::Fetch)?;
124    parse_github_repo(url.to_string().as_str())
125}
126
127pub fn parse_github_repo(url: &str) -> Option<String> {
128    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
129    let caps = re.captures(url)?;
130    Some(caps.get(1)?.as_str().to_string())
131}
132
133pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
134    let out = Command::new("gh")
135        .args([
136            "release", "create", version, "--title", version, "--notes", notes, "--repo", repo,
137        ])
138        .output();
139    match out {
140        Ok(out) if out.status.success() => true,
141        Ok(out) => {
142            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
143            if msg.contains("already exists") || msg.contains("已存在") {
144                return true;
145            }
146            eprintln!("创建 Release 失败: {}", msg);
147            false
148        }
149        Err(e) => {
150            eprintln!("创建 Release 失败: {}", e);
151            false
152        }
153    }
154}
155
156/// 回滚 tag:删除本地和远端 tag。
157pub fn rollback_tag(version: &str, repo_path: &Path) {
158    let local_ok = delete_local_tag(version, repo_path);
159    let remote_ok = delete_remote_tag(version, repo_path);
160    if local_ok && remote_ok {
161        eprintln!("已回滚标签 {}", version);
162    }
163}
164
165/// 删除本地 tag(`git tag -d <version>`)。不存在也算成功。
166pub fn delete_local_tag(version: &str, repo_path: &Path) -> bool {
167    let repo = match git2::Repository::open(repo_path) {
168        Ok(r) => r,
169        Err(_) => return true,
170    };
171    let refname = format!("refs/tags/{}", version);
172    repo.find_reference(&refname)
173        .ok()
174        .and_then(|mut r| r.delete().ok())
175        .is_some()
176}
177
178/// 删除远端 tag(等价于 `git push --delete origin <version>`)。
179pub fn delete_remote_tag(version: &str, repo_path: &Path) -> bool {
180    let repo = match git2::Repository::open(repo_path) {
181        Ok(r) => r,
182        Err(_) => return false,
183    };
184    let mut remote = match repo.find_remote("origin") {
185        Ok(r) => r,
186        Err(_) => return true, // 无 remote 视为成功
187    };
188    let refspec = format!(":refs/tags/{}", version);
189    remote.push(&[&refspec], None).is_ok()
190}
191
192/// 删除 GitHub Release(等价于 `gh release delete <version> --yes`)。
193pub fn delete_release(version: &str, repo: &str) -> bool {
194    let out = Command::new("gh")
195        .args(["release", "delete", version, "--yes", "--repo", repo])
196        .output();
197    match out {
198        Ok(out) if out.status.success() => true,
199        Ok(out) => {
200            let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
201            if msg.contains("not found") || msg.contains("404") {
202                return true; // 不存在也算成功
203            }
204            eprintln!("删除 Release 失败: {}", msg);
205            false
206        }
207        Err(e) => {
208            eprintln!("删除 Release 失败: {}", e);
209            false
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    fn git_init(path: &std::path::Path) {
217        let repo = git2::Repository::init(path).unwrap();
218        let mut cfg = repo.config().unwrap();
219        cfg.set_str("user.email", "t@t").unwrap();
220        cfg.set_str("user.name", "t").unwrap();
221    }
222
223    fn git_commit(path: &std::path::Path, msg: &str) {
224        std::fs::write(path.join("f"), msg).unwrap();
225        let repo = git2::Repository::open(path).unwrap();
226        let mut index = repo.index().unwrap();
227        index.add_path(std::path::Path::new("f")).unwrap();
228        index.write().unwrap();
229        let tree_id = index.write_tree().unwrap();
230        let tree = repo.find_tree(tree_id).unwrap();
231        let sig = repo.signature().unwrap();
232        let parent = repo.head().and_then(|h| h.peel_to_commit()).ok();
233        let parents: Vec<&git2::Commit> = parent.iter().collect();
234        repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents)
235            .unwrap();
236    }
237
238    use super::*;
239
240    #[test]
241    fn test_parse_github_repo_https() {
242        assert_eq!(
243            parse_github_repo("https://github.com/owner/repo.git"),
244            Some("owner/repo".into())
245        );
246    }
247    #[test]
248    fn test_parse_github_repo_ssh() {
249        assert_eq!(
250            parse_github_repo("git@github.com:owner/repo.git"),
251            Some("owner/repo".into())
252        );
253    }
254    #[test]
255    fn test_parse_github_repo_not_github() {
256        assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None);
257    }
258    #[test]
259    fn test_extract_notes_found() {
260        let d = tempfile::tempdir().unwrap();
261        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
262        assert!(extract_notes("v1.0.0", &d.path().join("C.md")).is_some());
263    }
264    #[test]
265    fn test_extract_notes_not_found() {
266        let d = tempfile::tempdir().unwrap();
267        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
268        assert!(extract_notes("v2.0.0", &d.path().join("C.md")).is_none());
269    }
270    #[test]
271    fn test_extract_notes_filters_header_lines() {
272        let d = tempfile::tempdir().unwrap();
273        std::fs::write(
274            d.path().join("C.md"),
275            "## [1.0.0] - 2026-06-26\n\n\
276             ## [v1.0.0] - 2023-08-31\n\n\
277             ### Added\n- feature\n",
278        )
279        .unwrap();
280        let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap_or_default();
281        assert!(!notes.contains("## ["), "提取内容应过滤 ## [ 行: {}", notes);
282        assert!(notes.contains("### Added"));
283        assert!(notes.contains("- feature"));
284    }
285
286    #[test]
287    fn test_extract_notes_with_v_prefix_marker() {
288        let d = tempfile::tempdir().unwrap();
289        std::fs::write(d.path().join("C.md"), "## [v1.0.0]\n\ncontent line").unwrap();
290        let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap_or_default();
291        assert!(notes.contains("content line"));
292    }
293
294    #[test]
295    fn test_extract_notes_next_version_stops() {
296        let d = tempfile::tempdir().unwrap();
297        std::fs::write(
298            d.path().join("C.md"),
299            "## [1.0.0]\n\ncontent\n## [2.0.0]\n\nnext\n",
300        )
301        .unwrap();
302        let notes = extract_notes("v1.0.0", &d.path().join("C.md")).unwrap();
303        assert!(notes.contains("content"));
304        assert!(!notes.contains("next"));
305    }
306
307    #[test]
308    fn test_precheck_changelog_with_v_prefix_marker() {
309        let d = tempfile::tempdir().unwrap();
310        std::fs::write(d.path().join("C.md"), "## [v1.0.0]\n\ncontent\n").unwrap();
311        assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty());
312    }
313
314    #[test]
315    fn test_confirm_release_yes_flag() {
316        assert!(confirm_release("v1.0.0", true));
317    }
318    #[test]
319    fn test_precheck_changelog_no_errors() {
320        let d = tempfile::tempdir().unwrap();
321        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
322        assert!(precheck_version_changelog("v1.0.0", &d.path().join("C.md")).is_empty());
323    }
324    #[test]
325    fn test_precheck_changelog_missing_entry() {
326        let d = tempfile::tempdir().unwrap();
327        std::fs::write(d.path().join("C.md"), "## [1.0.0]\n\ncontent").unwrap();
328        assert!(precheck_version_changelog("v2.0.0", &d.path().join("C.md"))
329            .iter()
330            .any(|e| e.contains("未找到")));
331    }
332    #[test]
333    fn test_precheck_changelog_file_not_found() {
334        let d = tempfile::tempdir().unwrap();
335        assert!(precheck_version_changelog("v1.0.0", &d.path().join("N.md"))
336            .iter()
337            .any(|e| e.contains("不存在")));
338    }
339    #[test]
340    fn test_precheck_changelog_version_invalid() {
341        let d = tempfile::tempdir().unwrap();
342        assert!(precheck_version_changelog("bad", &d.path().join("C.md"))
343            .iter()
344            .any(|e| e.contains("格式错误")));
345    }
346    #[test]
347    fn test_publish_target_debug() {
348        assert_eq!(format!("{:?}", PublishTarget::PyPI), "PyPI");
349    }
350
351    #[test]
352    fn test_publish_target_clone_eq() {
353        assert_eq!(PublishTarget::PyPI, PublishTarget::PyPI);
354    }
355    #[test]
356    fn test_get_remote_repo_no_git_repo() {
357        assert_eq!(get_remote_repo(tempfile::tempdir().unwrap().path()), None);
358    }
359    #[test]
360    fn test_create_release_no_gh() {
361        assert!(!create_release("v0.0.0-test", "", "no/repo"));
362    }
363    #[test]
364    fn test_create_tag_in_non_git_dir() {
365        assert!(!create_tag(
366            "v0.0.0-test",
367            tempfile::tempdir().unwrap().path()
368        ));
369    }
370    #[test]
371    fn test_create_tag_idempotent() {
372        let d = tempfile::tempdir().unwrap();
373        git_init(d.path());
374        git_commit(d.path(), "init");
375        assert!(create_tag("v0.0.0-test", d.path()));
376        assert!(create_tag("v0.0.0-test", d.path()));
377    }
378    #[test]
379    fn test_push_tag_in_non_git_dir() {
380        assert!(!push_tag(
381            "v0.0.0-test",
382            tempfile::tempdir().unwrap().path()
383        ));
384    }
385    #[test]
386    fn test_push_tag_fails_with_non_existent_remote() {
387        let d = tempfile::tempdir().unwrap();
388        git_init(d.path());
389        git_commit(d.path(), "init");
390        assert!(create_tag("v0.0.0-test-remote", d.path()));
391        std::process::Command::new("git")
392            .args([
393                "remote",
394                "add",
395                "origin",
396                "https://nonexistent.invalid/repo.git",
397            ])
398            .current_dir(d.path())
399            .output()
400            .unwrap();
401        assert!(!push_tag("v0.0.0-test-remote", d.path()));
402    }
403    #[test]
404    fn test_get_remote_repo_in_git_without_remote() {
405        let d = tempfile::tempdir().unwrap();
406        std::process::Command::new("git")
407            .args(["init", "-b", "main"])
408            .current_dir(d.path())
409            .output()
410            .unwrap();
411        assert_eq!(get_remote_repo(d.path()), None);
412    }
413    #[test]
414    fn test_rollback_tag_removes_tag() {
415        let d = tempfile::tempdir().unwrap();
416        std::process::Command::new("git")
417            .args(["init", "-b", "main"])
418            .current_dir(d.path())
419            .output()
420            .unwrap();
421        std::fs::write(d.path().join("f"), "").unwrap();
422        std::process::Command::new("git")
423            .args(["add", "."])
424            .current_dir(d.path())
425            .output()
426            .unwrap();
427        std::process::Command::new("git")
428            .args([
429                "-c",
430                "user.name=t",
431                "-c",
432                "user.email=t@t",
433                "commit",
434                "-m",
435                "x",
436            ])
437            .current_dir(d.path())
438            .output()
439            .unwrap();
440        assert!(create_tag("v0.0.0-test-rollback", d.path()));
441        rollback_tag("v0.0.0-test-rollback", d.path());
442        let o = std::process::Command::new("git")
443            .args(["tag", "-l"])
444            .current_dir(d.path())
445            .output()
446            .unwrap();
447        assert!(!String::from_utf8_lossy(&o.stdout).contains("v0.0.0-test-rollback"));
448    }
449}