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    let mut scopes: Vec<(String, String)> = Vec::new();
148    for t in all {
149        let scope = if t.contains('/') {
150            t.split('/').next().unwrap_or("").to_string()
151        } else {
152            "(root)".to_string()
153        };
154        let tag_only = t.split('/').last().unwrap_or(t);
155        let pre = tag_only.contains('-');
156        if let Some(pos) = scopes.iter().position(|(s, _)| s == &scope) {
157            let et = scopes[pos].1.split('/').last().unwrap_or(&scopes[pos].1);
158            if !pre && et.contains('-') {
159                scopes[pos] = (scope, t.to_string());
160            }
161        } else {
162            scopes.push((scope, t.to_string()));
163        }
164    }
165    scopes
166}
167
168fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
169    let range = format!("{}..HEAD", tag);
170    if scope_dir == repo_path {
171        let out = std::process::Command::new("git")
172            .args([
173                "-C",
174                &repo_path.to_string_lossy(),
175                "rev-list",
176                "--count",
177                &range,
178            ])
179            .output()
180            .ok();
181        return match out {
182            Some(o) if o.status.success() => std::str::from_utf8(&o.stdout)
183                .unwrap_or("0")
184                .trim()
185                .parse()
186                .unwrap_or(0),
187            _ => 0,
188        };
189    }
190    let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
191    let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
192    let out = std::process::Command::new("git")
193        .args([
194            "-C",
195            &repo_path.to_string_lossy(),
196            "rev-list",
197            "--count",
198            &range,
199            "--",
200            &rel_str,
201        ])
202        .output()
203        .ok();
204    match out {
205        Some(o) if o.status.success() => std::str::from_utf8(&o.stdout)
206            .unwrap_or("0")
207            .trim()
208            .parse()
209            .unwrap_or(0),
210        _ => 0,
211    }
212}
213
214fn get_github_repo(repo_path: &Path) -> Option<String> {
215    let out = std::process::Command::new("git")
216        .args([
217            "-C",
218            &repo_path.to_string_lossy(),
219            "remote",
220            "get-url",
221            "origin",
222        ])
223        .output()
224        .ok()?;
225    if !out.status.success() {
226        return None;
227    }
228    let url = std::str::from_utf8(&out.stdout).ok()?.trim().to_string();
229    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
230    let caps = re.captures(&url)?;
231    Some(caps.get(1)?.as_str().to_string())
232}
233
234fn check_all_configs(repo_path: &Path, other_scope_dirs: &[std::path::PathBuf], expected: &str) {
235    let checks: [(&str, fn(&str) -> Option<String>); 5] = [
236        ("Cargo.toml", |c| extract_kv(c, "version")),
237        ("pyproject.toml", |c| extract_kv(c, "version")),
238        ("package.json", extract_json_version),
239        ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
240        ("setup.cfg", |c| extract_kv(c, "version")),
241    ];
242    for (name, extract) in &checks {
243        let content = match std::fs::read_to_string(&repo_path.join(name)) {
244            Ok(c) => c,
245            Err(_) => continue,
246        };
247        match extract(&content) {
248            Some(v) if v == expected => println!("    {:<15} {} ✅", format!("{}:", name), v),
249            Some(v) => println!(
250                "    {:<15} {} ❌ (期望 {})",
251                format!("{}:", name),
252                v,
253                expected
254            ),
255            None => println!("    {:<15} (未找到版本字段)", format!("{}:", name)),
256        }
257    }
258    let vf = repo_path.join("VERSION");
259    if let Ok(c) = std::fs::read_to_string(&vf) {
260        let v = c.trim().to_string();
261        if !v.is_empty() {
262            if v == expected {
263                println!("    VERSION          {} ✅", v);
264            } else {
265                println!("    VERSION          {} ❌ (期望 {})", v, expected);
266            }
267        }
268    }
269    for p in find_go_files(repo_path, other_scope_dirs) {
270        let content = match std::fs::read_to_string(&p) {
271            Ok(c) => c,
272            Err(_) => continue,
273        };
274        for prefix in &[
275            "var Version = \"",
276            "var VERSION = \"",
277            "const Version = \"",
278            "const VERSION = \"",
279        ] {
280            for line in content.lines() {
281                let t = line.trim();
282                if let Some(rest) = t.strip_prefix(prefix) {
283                    if let Some(end) = rest.find('"') {
284                        let v = rest[..end].to_string();
285                        if !v.is_empty() {
286                            let rel = p.strip_prefix(repo_path).unwrap_or(&p);
287                            let name = rel.to_string_lossy();
288                            if v == expected {
289                                println!("    {:<15} {} ✅", format!("{}:", name), v);
290                            } else {
291                                println!(
292                                    "    {:<15} {} ❌ (期望 {})",
293                                    format!("{}:", name),
294                                    v,
295                                    expected
296                                );
297                            }
298                        }
299                    }
300                }
301            }
302        }
303    }
304}
305
306fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
307    let mut files = Vec::new();
308    let entries = match std::fs::read_dir(dir) {
309        Ok(e) => e,
310        Err(_) => return files,
311    };
312    for entry in entries.flatten() {
313        let p = entry.path();
314        if p.is_dir() {
315            if excludes.iter().any(|e| p == *e) {
316                continue;
317            }
318            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
319            if !name.starts_with('.')
320                && name != "node_modules"
321                && name != "target"
322                && name != "vendor"
323            {
324                files.extend(find_go_files(&p, excludes));
325            }
326        } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
327            files.push(p);
328        }
329    }
330    files
331}
332
333fn extract_kv(content: &str, key: &str) -> Option<String> {
334    let p1 = format!("{} = \"", key);
335    let p2 = format!("{} = '", key);
336    for line in content.lines() {
337        let t = line.trim();
338        if let Some(r) = t.strip_prefix(&p1) {
339            if let Some(e) = r.find('"') {
340                let v = r[..e].to_string();
341                if !v.is_empty() {
342                    return Some(v);
343                }
344            }
345        }
346        if let Some(r) = t.strip_prefix(&p2) {
347            if let Some(e) = r.find('\'') {
348                let v = r[..e].to_string();
349                if !v.is_empty() {
350                    return Some(v);
351                }
352            }
353        }
354    }
355    None
356}
357
358fn extract_json_version(content: &str) -> Option<String> {
359    for line in content.lines() {
360        let t = line.trim();
361        if let Some(r) = t.strip_prefix("\"version\":") {
362            let v = r
363                .trim()
364                .trim_matches('"')
365                .trim_matches('\'')
366                .trim_matches(',');
367            if !v.is_empty() {
368                return Some(v.to_string());
369            }
370        }
371    }
372    None
373}
374
375fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
376    let p = format!("{}:", key);
377    for line in content.lines() {
378        let t = line.trim();
379        if let Some(r) = t.strip_prefix(&p) {
380            let v = r.trim();
381            if !v.is_empty() && !v.starts_with('#') {
382                return Some(v.to_string());
383            }
384        }
385    }
386    None
387}
388
389fn check_changelog(repo_path: &Path, version: &str) -> bool {
390    if version.is_empty() {
391        return false;
392    }
393    std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
394        .unwrap_or_default()
395        .contains(&format!("[{}]", version))
396}
397
398fn is_dirty(repo_path: &Path) -> bool {
399    let out = std::process::Command::new("git")
400        .args(["-C", &repo_path.to_string_lossy(), "status", "--porcelain"])
401        .output()
402        .ok();
403    match out {
404        Some(o) => !o.stdout.is_empty(),
405        None => false,
406    }
407}