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 out = std::process::Command::new("git")
131        .args([
132            "-C",
133            &repo_path.to_string_lossy(),
134            "tag",
135            "--sort=-version:refname",
136        ])
137        .output()
138        .ok();
139    let out = match out {
140        Some(o) if o.status.success() => o,
141        _ => return vec![],
142    };
143    let all: Vec<&str> = std::str::from_utf8(&out.stdout)
144        .unwrap_or("")
145        .lines()
146        .collect();
147    collect_latest_tags(&all)
148}
149
150pub fn collect_latest_tags(tags: &[&str]) -> Vec<(String, String)> {
151    let mut scopes: Vec<(String, String)> = Vec::new();
152    for t in tags {
153        let scope = if t.contains('/') {
154            t.split('/').next().unwrap_or("").to_string()
155        } else {
156            "(root)".to_string()
157        };
158        let tag_only = t.split('/').last().unwrap_or(t);
159        let pre = tag_only.contains('-');
160        if let Some(pos) = scopes.iter().position(|(s, _)| s == &scope) {
161            let et = scopes[pos].1.split('/').last().unwrap_or(&scopes[pos].1);
162            if !pre && et.contains('-') {
163                scopes[pos] = (scope, t.to_string());
164            }
165        } else {
166            scopes.push((scope, t.to_string()));
167        }
168    }
169    scopes
170}
171
172fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
173    let range = format!("{}..HEAD", tag);
174    if scope_dir == repo_path {
175        let out = std::process::Command::new("git")
176            .args([
177                "-C",
178                &repo_path.to_string_lossy(),
179                "rev-list",
180                "--count",
181                &range,
182            ])
183            .output()
184            .ok();
185        return match out {
186            Some(o) if o.status.success() => std::str::from_utf8(&o.stdout)
187                .unwrap_or("0")
188                .trim()
189                .parse()
190                .unwrap_or(0),
191            _ => 0,
192        };
193    }
194    let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
195    let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
196    let out = std::process::Command::new("git")
197        .args([
198            "-C",
199            &repo_path.to_string_lossy(),
200            "rev-list",
201            "--count",
202            &range,
203            "--",
204            &rel_str,
205        ])
206        .output()
207        .ok();
208    match out {
209        Some(o) if o.status.success() => std::str::from_utf8(&o.stdout)
210            .unwrap_or("0")
211            .trim()
212            .parse()
213            .unwrap_or(0),
214        _ => 0,
215    }
216}
217
218fn get_github_repo(repo_path: &Path) -> Option<String> {
219    let out = std::process::Command::new("git")
220        .args([
221            "-C",
222            &repo_path.to_string_lossy(),
223            "remote",
224            "get-url",
225            "origin",
226        ])
227        .output()
228        .ok()?;
229    if !out.status.success() {
230        return None;
231    }
232    let url = std::str::from_utf8(&out.stdout).ok()?.trim().to_string();
233    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
234    let caps = re.captures(&url)?;
235    Some(caps.get(1)?.as_str().to_string())
236}
237
238fn check_all_configs(repo_path: &Path, other_scope_dirs: &[std::path::PathBuf], expected: &str) {
239    let checks: [(&str, fn(&str) -> Option<String>); 5] = [
240        ("Cargo.toml", |c| extract_kv(c, "version")),
241        ("pyproject.toml", |c| extract_kv(c, "version")),
242        ("package.json", extract_json_version),
243        ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
244        ("setup.cfg", |c| extract_kv(c, "version")),
245    ];
246    for (name, extract) in &checks {
247        let content = match std::fs::read_to_string(&repo_path.join(name)) {
248            Ok(c) => c,
249            Err(_) => continue,
250        };
251        match extract(&content) {
252            Some(v) if v == expected => println!("    {:<15} {} ✅", format!("{}:", name), v),
253            Some(v) => println!(
254                "    {:<15} {} ❌ (期望 {})",
255                format!("{}:", name),
256                v,
257                expected
258            ),
259            None => println!("    {:<15} (未找到版本字段)", format!("{}:", name)),
260        }
261    }
262    let vf = repo_path.join("VERSION");
263    if let Ok(c) = std::fs::read_to_string(&vf) {
264        let v = c.trim().to_string();
265        if !v.is_empty() {
266            if v == expected {
267                println!("    VERSION          {} ✅", v);
268            } else {
269                println!("    VERSION          {} ❌ (期望 {})", v, expected);
270            }
271        }
272    }
273    for p in find_go_files(repo_path, other_scope_dirs) {
274        let content = match std::fs::read_to_string(&p) {
275            Ok(c) => c,
276            Err(_) => continue,
277        };
278        for prefix in &[
279            "var Version = \"",
280            "var VERSION = \"",
281            "const Version = \"",
282            "const VERSION = \"",
283        ] {
284            for line in content.lines() {
285                let t = line.trim();
286                if let Some(rest) = t.strip_prefix(prefix) {
287                    if let Some(end) = rest.find('"') {
288                        let v = rest[..end].to_string();
289                        if !v.is_empty() {
290                            let rel = p.strip_prefix(repo_path).unwrap_or(&p);
291                            let name = rel.to_string_lossy();
292                            if v == expected {
293                                println!("    {:<15} {} ✅", format!("{}:", name), v);
294                            } else {
295                                println!(
296                                    "    {:<15} {} ❌ (期望 {})",
297                                    format!("{}:", name),
298                                    v,
299                                    expected
300                                );
301                            }
302                        }
303                    }
304                }
305            }
306        }
307    }
308}
309
310fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
311    let mut files = Vec::new();
312    let entries = match std::fs::read_dir(dir) {
313        Ok(e) => e,
314        Err(_) => return files,
315    };
316    for entry in entries.flatten() {
317        let p = entry.path();
318        if p.is_dir() {
319            if excludes.iter().any(|e| p == *e) {
320                continue;
321            }
322            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
323            if !name.starts_with('.')
324                && name != "node_modules"
325                && name != "target"
326                && name != "vendor"
327            {
328                files.extend(find_go_files(&p, excludes));
329            }
330        } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
331            files.push(p);
332        }
333    }
334    files
335}
336
337fn extract_kv(content: &str, key: &str) -> Option<String> {
338    let p1 = format!("{} = \"", key);
339    let p2 = format!("{} = '", key);
340    for line in content.lines() {
341        let t = line.trim();
342        if let Some(r) = t.strip_prefix(&p1) {
343            if let Some(e) = r.find('"') {
344                let v = r[..e].to_string();
345                if !v.is_empty() {
346                    return Some(v);
347                }
348            }
349        }
350        if let Some(r) = t.strip_prefix(&p2) {
351            if let Some(e) = r.find('\'') {
352                let v = r[..e].to_string();
353                if !v.is_empty() {
354                    return Some(v);
355                }
356            }
357        }
358    }
359    None
360}
361
362fn extract_json_version(content: &str) -> Option<String> {
363    for line in content.lines() {
364        let t = line.trim();
365        if let Some(r) = t.strip_prefix("\"version\":") {
366            let v = r
367                .trim()
368                .trim_matches('"')
369                .trim_matches('\'')
370                .trim_matches(',');
371            if !v.is_empty() {
372                return Some(v.to_string());
373            }
374        }
375    }
376    None
377}
378
379fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
380    let p = format!("{}:", key);
381    for line in content.lines() {
382        let t = line.trim();
383        if let Some(r) = t.strip_prefix(&p) {
384            let v = r.trim();
385            if !v.is_empty() && !v.starts_with('#') {
386                return Some(v.to_string());
387            }
388        }
389    }
390    None
391}
392
393fn check_changelog(repo_path: &Path, version: &str) -> bool {
394    if version.is_empty() {
395        return false;
396    }
397    std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
398        .unwrap_or_default()
399        .contains(&format!("[{}]", version))
400}
401
402fn is_dirty(repo_path: &Path) -> bool {
403    let out = std::process::Command::new("git")
404        .args(["-C", &repo_path.to_string_lossy(), "status", "--porcelain"])
405        .output()
406        .ok();
407    match out {
408        Some(o) => !o.stdout.is_empty(),
409        None => false,
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn test_collect_tags_empty() {
419        assert!(collect_latest_tags(&[]).is_empty());
420    }
421
422    #[test]
423    fn test_collect_tags_root_only() {
424        let tags = collect_latest_tags(&["v2.0.0", "v1.0.0"]);
425        assert_eq!(tags.len(), 1);
426        assert_eq!(tags[0].0, "(root)");
427        assert_eq!(tags[0].1, "v2.0.0");
428    }
429
430    #[test]
431    fn test_collect_tags_scoped() {
432        let tags = collect_latest_tags(&["cli/v0.1.0", "web/v0.2.0"]);
433        assert_eq!(tags.len(), 2);
434        assert_eq!(tags[0].0, "cli");
435        assert_eq!(tags[1].0, "web");
436    }
437
438    #[test]
439    fn test_collect_tags_prerelease_not_preferred() {
440        let tags = collect_latest_tags(&["cli/v0.2.0-rc.1", "cli/v0.1.0"]);
441        assert_eq!(tags.len(), 1);
442        assert_eq!(tags[0].1, "cli/v0.1.0");
443    }
444
445    #[test]
446    fn test_collect_tags_prerelease_as_fallback() {
447        let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
448        assert_eq!(tags.len(), 1);
449        assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
450    }
451
452    #[test]
453    fn test_collect_tags_no_release_upgrades_prerelease() {
454        // 先出现 rc2(非预发布标志无,因为它有 '-'),后出现 rc1
455        let tags = collect_latest_tags(&["cli/v0.1.0-rc.2", "cli/v0.1.0-rc.1"]);
456        assert_eq!(tags[0].1, "cli/v0.1.0-rc.2");
457    }
458
459    /// 创建 mock bin 脚本并前置到 PATH,测试结束后还原。
460    fn with_mock_path<F: FnOnce(&Path) -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
461        let dir = tempfile::tempdir().unwrap();
462        let bin = dir.path().join("bin");
463        std::fs::create_dir(&bin).unwrap();
464        for (name, body) in scripts {
465            let path = bin.join(name);
466            std::fs::write(&path, body).unwrap();
467            #[cfg(unix)]
468            std::process::Command::new("chmod")
469                .args(["+x", path.to_str().unwrap()])
470                .output()
471                .unwrap();
472        }
473        let old_path = std::env::var("PATH").unwrap_or_default();
474        std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
475        let result = f(dir.path());
476        std::env::set_var("PATH", &old_path);
477        result
478    }
479
480    const GH_NOT_FOUND: &str = "#!/bin/sh\nexit 1\n";
481    const GH_WITH_BODY: &str = "#!/bin/sh\necho '{\"body\":\"content\"}'\n";
482
483    #[test]
484    fn test_status_gh_not_found() {
485        // 有 GitHub remote 但 gh CLI 返回不存在 → 不 panic
486        let dir = tempfile::tempdir().unwrap();
487        git_init_test(dir.path());
488        git_tag_test(dir.path(), "v1.0.0");
489        set_remote(dir.path());
490        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
491            status(dir.path());
492        });
493    }
494
495    #[test]
496    fn test_status_gh_with_body() {
497        // gh 返回 body,CHANGELOG 匹配 → 一致
498        let dir = tempfile::tempdir().unwrap();
499        git_init_test(dir.path());
500        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent\n").unwrap();
501        git_commit_test(dir.path());
502        git_tag_test(dir.path(), "v1.0.0");
503        set_remote(dir.path());
504        with_mock_path(&[("gh", GH_WITH_BODY)], |_| {
505            status(dir.path());
506        });
507    }
508
509    #[test]
510    fn test_status_custom_tags() {
511        // 自定义 mock git 返回的标签列表,覆盖多 scope 路径
512        let dir = tempfile::tempdir().unwrap();
513        // 用真实 git repo + 标签,验证 status 不 panic
514        git_init_test(dir.path());
515        git_tag_test(dir.path(), "cli/v0.1.0");
516        git_tag_test(dir.path(), "web/v0.2.0");
517        set_remote(dir.path());
518        with_mock_path(&[("gh", GH_NOT_FOUND)], |_| {
519            status(dir.path());
520        });
521    }
522
523    // ── 测试辅助 ────────────────────────────────────────────────
524
525    fn git_init_test(path: &Path) {
526        std::process::Command::new("git")
527            .args(["init", "-b", "main"])
528            .current_dir(path)
529            .output()
530            .unwrap();
531        std::process::Command::new("git")
532            .args(["config", "user.email", "t@t"])
533            .current_dir(path)
534            .output()
535            .unwrap();
536        std::process::Command::new("git")
537            .args(["config", "user.name", "t"])
538            .current_dir(path)
539            .output()
540            .unwrap();
541        std::fs::write(path.join("f"), "").unwrap();
542        std::process::Command::new("git")
543            .args(["add", "."])
544            .current_dir(path)
545            .output()
546            .unwrap();
547        std::process::Command::new("git")
548            .args(["commit", "-m", "init"])
549            .current_dir(path)
550            .output()
551            .unwrap();
552    }
553
554    fn git_commit_test(path: &Path) {
555        std::fs::write(path.join("f"), "x").unwrap();
556        std::process::Command::new("git")
557            .args(["add", "."])
558            .current_dir(path)
559            .output()
560            .unwrap();
561        std::process::Command::new("git")
562            .args(["commit", "-m", "x"])
563            .current_dir(path)
564            .output()
565            .unwrap();
566    }
567
568    fn git_tag_test(path: &Path, tag: &str) {
569        std::process::Command::new("git")
570            .args(["-C", path.to_str().unwrap(), "tag", tag])
571            .output()
572            .unwrap();
573    }
574
575    fn set_remote(path: &Path) {
576        std::process::Command::new("git")
577            .args([
578                "-C",
579                path.to_str().unwrap(),
580                "remote",
581                "add",
582                "origin",
583                "https://github.com/owner/repo.git",
584            ])
585            .output()
586            .unwrap();
587    }
588
589    #[test]
590    fn test_collect_tags_mixed_root_and_scoped() {
591        let tags = collect_latest_tags(&["v1.0.0", "cli/v0.2.0", "cli/v0.1.0"]);
592        assert_eq!(tags.len(), 2);
593        let root = tags.iter().find(|(s, _)| s == "(root)").unwrap();
594        assert_eq!(root.1, "v1.0.0");
595        let cli = tags.iter().find(|(s, _)| s == "cli").unwrap();
596        assert_eq!(cli.1, "cli/v0.2.0");
597    }
598}