Skip to main content

qtcloud_devops_cli/release/
status.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::contract;
5
6pub fn status(repo_path: &Path) {
7    let scopes_map = load_scopes_map(repo_path);
8    let latest_tags = get_latest_tags_by_scope(repo_path);
9    let dirty = is_dirty(repo_path);
10
11    let other_scope_dirs: Vec<std::path::PathBuf> = scopes_map
12        .iter()
13        .filter(|(k, _)| *k != "(root)")
14        .map(|(_, v)| repo_path.join(v))
15        .collect();
16
17    println!("发布状态");
18    println!("{}", "─".repeat(40));
19
20    if latest_tags.is_empty() {
21        println!("  最新标签:     (无)");
22        return;
23    }
24
25    for (scope, tag) in &latest_tags {
26        let tag_only = tag.split('/').last().unwrap_or(tag);
27        let ver = tag_only.strip_prefix('v').unwrap_or(tag_only);
28
29        let scope_dir = if scope == "(root)" {
30            repo_path.to_path_buf()
31        } else {
32            match scopes_map.get(scope) {
33                Some(rel) => repo_path.join(rel),
34                None => {
35                    let d = repo_path.join(scope);
36                    if d.is_dir() {
37                        d
38                    } else {
39                        repo_path.to_path_buf()
40                    }
41                }
42            }
43        };
44
45        println!("  [{}]", scope);
46        let rel_path = scopes_map.get(scope).cloned().unwrap_or_else(|| {
47            if scope == "(root)" {
48                ".".to_string()
49            } else {
50                scope.clone()
51            }
52        });
53        println!("    路径:         {}", rel_path);
54        println!("    最新标签:     {}", tag);
55
56        let unreleased = count_unreleased_in_dir(repo_path, tag, &scope_dir);
57        println!("    未发布提交:   {}", unreleased);
58
59        if check_changelog(&scope_dir, ver) {
60            println!("    CHANGELOG:    ✅");
61        } else {
62            println!("    CHANGELOG:    ❌ 缺少 {} 条目", ver);
63        }
64
65        check_github_release(repo_path, tag, &scope_dir, ver);
66        check_all_configs(&scope_dir, &other_scope_dirs, ver);
67    }
68
69    if dirty {
70        println!("  工作区:       ❌ 有未提交变更");
71    } else {
72        println!("  工作区:       ✅ 干净");
73    }
74}
75
76/// 检查 GitHub Release 是否存在,以及 body 是否与 CHANGELOG 同步。
77fn check_github_release(repo_path: &Path, tag: &str, scope_dir: &Path, _version: &str) {
78    // 解析 GitHub 仓库
79    let repo = get_github_repo(repo_path);
80    let repo = match repo {
81        Some(r) => r,
82        None => return,
83    };
84
85    // 查询 Release
86    let out = std::process::Command::new("gh")
87        .args([
88            "release", "view", tag, "--repo", &repo, "--json", "body", "--jq", ".body",
89        ])
90        .output()
91        .ok();
92
93    let body = match out {
94        Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
95        _ => {
96            println!("    GitHub Release: ❌ 不存在");
97            return;
98        }
99    };
100
101    // 从 CHANGELOG 提取当前版本的 notes
102    let changelog_path = scope_dir.join("CHANGELOG.md");
103    let notes = super::util::extract_notes(tag, &changelog_path);
104    let notes = notes.unwrap_or_default();
105
106    if body == notes {
107        println!("    GitHub Release: ✅ body 与 CHANGELOG 一致");
108    } else if body.trim().is_empty() {
109        println!("    GitHub Release: ⚠️ body 为空");
110    } else if notes.is_empty() {
111        println!("    GitHub Release: ✅ 已创建 (CHANGELOG 无此版本条目)");
112    } else {
113        println!("    GitHub Release: ⚠️ body 与 CHANGELOG 不同步");
114    }
115}
116
117/// 从契约加载 scope 列表,转为 (name → dir) 映射。
118fn load_scopes_map(repo_path: &Path) -> HashMap<String, String> {
119    let mut map: HashMap<String, String> = contract::load_scopes(repo_path)
120        .into_iter()
121        .map(|s| (s.name, s.dir))
122        .collect();
123    if !map.contains_key("(root)") {
124        map.insert("(root)".to_string(), "".to_string());
125    }
126    map
127}
128
129fn get_latest_tags_by_scope(repo_path: &Path) -> Vec<(String, String)> {
130    let repo = match git2::Repository::open(repo_path) {
131        Ok(r) => r,
132        Err(_) => return vec![],
133    };
134    let tag_names = match repo.tag_names(None) {
135        Ok(t) => t,
136        Err(_) => return vec![],
137    };
138    let mut tags: Vec<&str> = tag_names.iter().flatten().collect();
139    tags.sort_by(|a, b| b.cmp(a));
140    collect_latest_tags(&tags)
141}
142
143pub fn collect_latest_tags(tags: &[&str]) -> Vec<(String, String)> {
144    let mut scopes: Vec<(String, String)> = Vec::new();
145    for t in tags {
146        let scope = if t.contains('/') {
147            t.split('/').next().unwrap_or("").to_string()
148        } else {
149            "(root)".to_string()
150        };
151        if !scopes.iter().any(|(s, _)| s == &scope) {
152            scopes.push((scope, t.to_string()));
153        }
154    }
155    scopes
156}
157
158fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
159    let repo = match git2::Repository::open(repo_path) {
160        Ok(r) => r,
161        Err(_) => return 0,
162    };
163    let tag_ref = format!("refs/tags/{}", tag);
164    let tag_oid = match repo.find_reference(&tag_ref).ok().and_then(|r| r.target()) {
165        Some(t) => t,
166        None => return 0,
167    };
168    let head_oid = match repo.head().ok().and_then(|h| h.target()) {
169        Some(t) => t,
170        None => return 0,
171    };
172    let mut revwalk = match repo.revwalk() {
173        Ok(w) => w,
174        Err(_) => return 0,
175    };
176    if revwalk.push(head_oid).is_err() || revwalk.hide(tag_oid).is_err() {
177        return 0;
178    }
179    if scope_dir == repo_path {
180        return revwalk.count();
181    }
182    let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
183    let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
184    revwalk
185        .filter_map(|oid| oid.ok())
186        .filter(|oid| {
187            if let Ok(commit) = repo.find_commit(*oid) {
188                if let Ok(tree) = commit.tree() {
189                    tree.iter().any(|entry| {
190                        entry.name().map_or(false, |n| {
191                            n == &rel_str || n.starts_with(&format!("{}/", rel_str))
192                        })
193                    })
194                } else {
195                    false
196                }
197            } else {
198                false
199            }
200        })
201        .count()
202}
203
204fn get_github_repo(repo_path: &Path) -> Option<String> {
205    let repo = git2::Repository::open(repo_path).ok()?;
206    let remote = repo.find_remote("origin").ok()?;
207    let url = remote.url()?;
208    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
209    let caps = re.captures(url)?;
210    Some(caps.get(1)?.as_str().to_string())
211}
212
213fn check_all_configs(repo_path: &Path, other_scope_dirs: &[std::path::PathBuf], expected: &str) {
214    let checks: [(&str, fn(&str) -> Option<String>); 5] = [
215        ("Cargo.toml", |c| extract_kv(c, "version")),
216        ("pyproject.toml", |c| extract_kv(c, "version")),
217        ("package.json", extract_json_version),
218        ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
219        ("setup.cfg", |c| extract_kv(c, "version")),
220    ];
221    for (name, extract) in &checks {
222        let content = match std::fs::read_to_string(&repo_path.join(name)) {
223            Ok(c) => c,
224            Err(_) => continue,
225        };
226        match extract(&content) {
227            Some(v) if v == expected => println!("    {:<15} {} ✅", format!("{}:", name), v),
228            Some(v) => println!(
229                "    {:<15} {} ❌ (期望 {})",
230                format!("{}:", name),
231                v,
232                expected
233            ),
234            None => println!("    {:<15} (未找到版本字段)", format!("{}:", name)),
235        }
236    }
237    let vf = repo_path.join("VERSION");
238    if let Ok(c) = std::fs::read_to_string(&vf) {
239        let v = c.trim().to_string();
240        if !v.is_empty() {
241            if v == expected {
242                println!("    VERSION          {} ✅", v);
243            } else {
244                println!("    VERSION          {} ❌ (期望 {})", v, expected);
245            }
246        }
247    }
248    for p in find_go_files(repo_path, other_scope_dirs) {
249        let content = match std::fs::read_to_string(&p) {
250            Ok(c) => c,
251            Err(_) => continue,
252        };
253        for prefix in &[
254            "var Version = \"",
255            "var VERSION = \"",
256            "const Version = \"",
257            "const VERSION = \"",
258        ] {
259            for line in content.lines() {
260                let t = line.trim();
261                if let Some(rest) = t.strip_prefix(prefix) {
262                    if let Some(end) = rest.find('"') {
263                        let v = rest[..end].to_string();
264                        if !v.is_empty() {
265                            let rel = p.strip_prefix(repo_path).unwrap_or(&p);
266                            let name = rel.to_string_lossy();
267                            if v == expected {
268                                println!("    {:<15} {} ✅", format!("{}:", name), v);
269                            } else {
270                                println!(
271                                    "    {:<15} {} ❌ (期望 {})",
272                                    format!("{}:", name),
273                                    v,
274                                    expected
275                                );
276                            }
277                        }
278                    }
279                }
280            }
281        }
282    }
283}
284
285fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
286    let mut files = Vec::new();
287    let entries = match std::fs::read_dir(dir) {
288        Ok(e) => e,
289        Err(_) => return files,
290    };
291    for entry in entries.flatten() {
292        let p = entry.path();
293        if p.is_dir() {
294            if excludes.iter().any(|e| p == *e) {
295                continue;
296            }
297            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
298            if !name.starts_with('.')
299                && name != "node_modules"
300                && name != "target"
301                && name != "vendor"
302            {
303                files.extend(find_go_files(&p, excludes));
304            }
305        } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
306            files.push(p);
307        }
308    }
309    files
310}
311
312fn extract_kv(content: &str, key: &str) -> Option<String> {
313    let p1 = format!("{} = \"", key);
314    let p2 = format!("{} = '", key);
315    for line in content.lines() {
316        let t = line.trim();
317        if let Some(r) = t.strip_prefix(&p1) {
318            if let Some(e) = r.find('"') {
319                let v = r[..e].to_string();
320                if !v.is_empty() {
321                    return Some(v);
322                }
323            }
324        }
325        if let Some(r) = t.strip_prefix(&p2) {
326            if let Some(e) = r.find('\'') {
327                let v = r[..e].to_string();
328                if !v.is_empty() {
329                    return Some(v);
330                }
331            }
332        }
333    }
334    None
335}
336
337fn extract_json_version(content: &str) -> Option<String> {
338    for line in content.lines() {
339        let t = line.trim();
340        // 用 find 而非 strip_prefix,支持单行 JSON("version": 不在行首)
341        if let Some(pos) = t.find("\"version\":") {
342            let after_colon = t[pos + "\"version\":".len()..].trim();
343            // 定位第一个引号 -> value 起点
344            let value_start = after_colon.find('"')?;
345            let after_open = &after_colon[value_start + 1..];
346            // 定位闭合引号 -> value 终点
347            let value_end = after_open.find('"')?;
348            let v = &after_open[..value_end];
349            if !v.is_empty() {
350                return Some(v.to_string());
351            }
352        }
353    }
354    None
355}
356
357fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
358    let p = format!("{}:", key);
359    for line in content.lines() {
360        let t = line.trim();
361        if let Some(r) = t.strip_prefix(&p) {
362            let v = r.trim();
363            if !v.is_empty() && !v.starts_with('#') {
364                return Some(v.to_string());
365            }
366        }
367    }
368    None
369}
370
371fn check_changelog(repo_path: &Path, version: &str) -> bool {
372    if version.is_empty() {
373        return false;
374    }
375    std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
376        .unwrap_or_default()
377        .contains(&format!("[{}]", version))
378}
379
380fn is_dirty(repo_path: &Path) -> bool {
381    let repo = match git2::Repository::open(repo_path) {
382        Ok(r) => r,
383        Err(_) => return false,
384    };
385    repo.statuses(None).map_or(false, |s| !s.is_empty())
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    // ── extract_kv ────────────────────────────────────────────
393
394    #[test]
395    fn test_extract_kv_double_quotes() {
396        assert_eq!(
397            extract_kv("version = \"1.0.0\"\n", "version"),
398            Some("1.0.0".into())
399        );
400    }
401
402    #[test]
403    fn test_extract_kv_single_quotes() {
404        assert_eq!(
405            extract_kv("version = '2.0.0'\n", "version"),
406            Some("2.0.0".into())
407        );
408    }
409
410    #[test]
411    fn test_extract_kv_missing_key() {
412        assert_eq!(extract_kv("name = \"foo\"\n", "version"), None);
413    }
414
415    #[test]
416    fn test_extract_kv_empty_value() {
417        assert_eq!(extract_kv("version = \"\"\n", "version"), None);
418    }
419
420    #[test]
421    fn test_extract_kv_indented() {
422        assert_eq!(
423            extract_kv("  version = \"0.5.0\"\n", "version"),
424            Some("0.5.0".into())
425        );
426    }
427
428    // ── extract_json_version ───────────────────────────────────
429
430    #[test]
431    fn test_extract_json_version_normal() {
432        let content = "{\n  \"version\": \"1.0.0\",\n}\n";
433        assert_eq!(extract_json_version(content), Some("1.0.0".into()));
434    }
435
436    #[test]
437    fn test_extract_json_version_single_line() {
438        let content = r#"{"name":"foo","version":"2.0.0"}"#;
439        assert_eq!(extract_json_version(content), Some("2.0.0".into()));
440    }
441
442    #[test]
443    fn test_extract_json_version_trailing_comma() {
444        let content = r#"{"version":"1.0.0",}"#;
445        assert_eq!(extract_json_version(content), Some("1.0.0".into()));
446    }
447
448    #[test]
449    fn test_extract_json_version_missing() {
450        let content = r#"{"name":"foo"}"#;
451        assert_eq!(extract_json_version(content), None);
452    }
453
454    #[test]
455    fn test_extract_json_version_empty() {
456        let content = r#"{"version":""}"#;
457        assert_eq!(extract_json_version(content), None);
458    }
459
460    // ── extract_kv_yaml ───────────────────────────────────────
461
462    #[test]
463    fn test_extract_kv_yaml_normal() {
464        assert_eq!(
465            extract_kv_yaml("version: 1.0.0\n", "version"),
466            Some("1.0.0".into())
467        );
468    }
469
470    #[test]
471    fn test_extract_kv_yaml_indented() {
472        assert_eq!(
473            extract_kv_yaml("  version: 3.0.0\n", "version"),
474            Some("3.0.0".into())
475        );
476    }
477
478    #[test]
479    fn test_extract_kv_yaml_ignores_comment() {
480        assert_eq!(extract_kv_yaml("version: # 注释\n", "version"), None);
481    }
482
483    #[test]
484    fn test_extract_kv_yaml_missing() {
485        assert_eq!(extract_kv_yaml("name: foo\n", "version"), None);
486    }
487
488    #[test]
489    fn test_extract_kv_yaml_empty_value() {
490        assert_eq!(extract_kv_yaml("version:\n", "version"), None);
491    }
492
493    #[test]
494    fn test_collect_tags_empty() {
495        assert!(collect_latest_tags(&[]).is_empty());
496    }
497
498    #[test]
499    fn test_collect_tags_root_only() {
500        let tags = collect_latest_tags(&["v2.0.0", "v1.0.0"]);
501        assert_eq!(tags.len(), 1);
502        assert_eq!(tags[0].0, "(root)");
503        assert_eq!(tags[0].1, "v2.0.0");
504    }
505
506    #[test]
507    fn test_collect_tags_scoped() {
508        let tags = collect_latest_tags(&["cli/v0.1.0", "web/v0.2.0"]);
509        assert_eq!(tags.len(), 2);
510        assert_eq!(tags[0].0, "cli");
511        assert_eq!(tags[1].0, "web");
512    }
513
514    #[test]
515    fn test_collect_tags_prerelease_is_kept() {
516        // 输入已按版本降序,首个 tag 胜出(含 prerelease)
517        let tags = collect_latest_tags(&["cli/v0.2.0-rc.1", "cli/v0.1.0"]);
518        assert_eq!(tags.len(), 1);
519        assert_eq!(tags[0].1, "cli/v0.2.0-rc.1");
520    }
521
522    #[test]
523    fn test_collect_tags_prerelease_as_fallback() {
524        let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
525        assert_eq!(tags.len(), 1);
526        assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
527    }
528
529    /// 创建 mock bin 脚本并前置到 PATH,测试结束后还原。
530    fn with_mock_path<F: FnOnce(&Path) -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
531        let dir = tempfile::tempdir().unwrap();
532        let bin = dir.path().join("bin");
533        std::fs::create_dir(&bin).unwrap();
534        for (name, body) in scripts {
535            let path = bin.join(name);
536            std::fs::write(&path, body).unwrap();
537            #[cfg(unix)]
538            std::process::Command::new("chmod")
539                .args(["+x", path.to_str().unwrap()])
540                .output()
541                .unwrap();
542        }
543        let old_path = std::env::var("PATH").unwrap_or_default();
544        std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
545        let result = f(dir.path());
546        std::env::set_var("PATH", &old_path);
547        result
548    }
549
550    const GH_NOT_FOUND: &str = "#!/bin/sh\nexit 1\n";
551    const GH_WITH_BODY: &str = "#!/bin/sh\necho '{\"body\":\"content\"}'\n";
552
553    #[test]
554    fn test_status_gh_not_found() {
555        // 有 GitHub remote 但 gh CLI 返回不存在 → 不 panic
556        let dir = tempfile::tempdir().unwrap();
557        git_init_test(dir.path());
558        git_tag_test(dir.path(), "v1.0.0");
559        set_remote(dir.path());
560        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
561            status(dir.path());
562        });
563    }
564
565    #[test]
566    fn test_status_gh_with_body() {
567        // gh 返回 body,CHANGELOG 匹配 → 一致
568        let dir = tempfile::tempdir().unwrap();
569        git_init_test(dir.path());
570        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
571        git_commit_test(dir.path());
572        git_tag_test(dir.path(), "v1.0.0");
573        set_remote(dir.path());
574        with_mock_path(&[("gh", GH_WITH_BODY)], |_| {
575            status(dir.path());
576        });
577    }
578
579    #[test]
580    fn test_status_custom_tags() {
581        // 自定义 mock git 返回的标签列表,覆盖多 scope 路径
582        let dir = tempfile::tempdir().unwrap();
583        // 用真实 git repo + 标签,验证 status 不 panic
584        git_init_test(dir.path());
585        git_tag_test(dir.path(), "cli/v0.1.0");
586        git_tag_test(dir.path(), "web/v0.2.0");
587        set_remote(dir.path());
588        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
589            status(dir.path());
590        });
591    }
592
593    // ── 测试辅助 ────────────────────────────────────────────────
594
595    fn git_init_test(path: &Path) {
596        std::process::Command::new("git")
597            .args(["init", "-b", "main"])
598            .current_dir(path)
599            .output()
600            .unwrap();
601        std::process::Command::new("git")
602            .args(["config", "user.email", "t@t"])
603            .current_dir(path)
604            .output()
605            .unwrap();
606        std::process::Command::new("git")
607            .args(["config", "user.name", "t"])
608            .current_dir(path)
609            .output()
610            .unwrap();
611        std::fs::write(path.join("f"), "").unwrap();
612        std::process::Command::new("git")
613            .args(["add", "."])
614            .current_dir(path)
615            .output()
616            .unwrap();
617        std::process::Command::new("git")
618            .args(["commit", "-m", "init"])
619            .current_dir(path)
620            .output()
621            .unwrap();
622    }
623
624    fn git_commit_test(path: &Path) {
625        std::fs::write(path.join("f"), "x").unwrap();
626        std::process::Command::new("git")
627            .args(["add", "."])
628            .current_dir(path)
629            .output()
630            .unwrap();
631        std::process::Command::new("git")
632            .args(["commit", "-m", "x"])
633            .current_dir(path)
634            .output()
635            .unwrap();
636    }
637
638    fn git_tag_test(path: &Path, tag: &str) {
639        std::process::Command::new("git")
640            .args(["-C", path.to_str().unwrap(), "tag", tag])
641            .output()
642            .unwrap();
643    }
644
645    fn set_remote(path: &Path) {
646        std::process::Command::new("git")
647            .args([
648                "-C",
649                path.to_str().unwrap(),
650                "remote",
651                "add",
652                "origin",
653                "https://github.com/owner/repo.git",
654            ])
655            .output()
656            .unwrap();
657    }
658
659    #[test]
660    fn test_collect_tags_mixed_root_and_scoped() {
661        let tags = collect_latest_tags(&["v1.0.0", "cli/v0.2.0", "cli/v0.1.0"]);
662        assert_eq!(tags.len(), 2);
663        let root = tags.iter().find(|(s, _)| s == "(root)").unwrap();
664        assert_eq!(root.1, "v1.0.0");
665        let cli = tags.iter().find(|(s, _)| s == "cli").unwrap();
666        assert_eq!(cli.1, "cli/v0.2.0");
667    }
668}