Skip to main content

qtcloud_devops_cli/release/
status.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4pub fn status(repo_path: &Path) {
5    let scopes_map = read_contract_scopes(repo_path);
6    let latest_tags = get_latest_tags_by_scope(repo_path);
7    let dirty = is_dirty(repo_path);
8
9    let other_scope_dirs: Vec<std::path::PathBuf> = scopes_map
10        .iter()
11        .filter(|(k, _)| *k != "(root)")
12        .map(|(_, v)| repo_path.join(v))
13        .collect();
14
15    println!("发布状态");
16    println!("{}", "─".repeat(40));
17
18    if latest_tags.is_empty() {
19        println!("  最新标签:     (无)");
20        return;
21    }
22
23    for (scope, tag) in &latest_tags {
24        let tag_only = tag.split('/').last().unwrap_or(tag);
25        let ver = tag_only.strip_prefix('v').unwrap_or(tag_only);
26
27        let scope_dir = if scope == "(root)" {
28            repo_path.to_path_buf()
29        } else {
30            match scopes_map.get(scope) {
31                Some(rel) => repo_path.join(rel),
32                None => {
33                    let d = repo_path.join(scope);
34                    if d.is_dir() {
35                        d
36                    } else {
37                        repo_path.to_path_buf()
38                    }
39                }
40            }
41        };
42
43        println!("  [{}]", scope);
44        let rel_path = scopes_map.get(scope).cloned().unwrap_or_else(|| {
45            if scope == "(root)" {
46                ".".to_string()
47            } else {
48                scope.clone()
49            }
50        });
51        println!("    路径:         {}", rel_path);
52        println!("    最新标签:     {}", tag);
53
54        let unreleased = count_unreleased_in_dir(repo_path, tag, &scope_dir);
55        println!("    未发布提交:   {}", unreleased);
56
57        if check_changelog(&scope_dir, ver) {
58            println!("    CHANGELOG:    ✅");
59        } else {
60            println!("    CHANGELOG:    ❌ 缺少 {} 条目", ver);
61        }
62
63        check_github_release(repo_path, tag, &scope_dir, ver);
64        check_all_configs(&scope_dir, &other_scope_dirs, ver);
65    }
66
67    if dirty {
68        println!("  工作区:       ❌ 有未提交变更");
69    } else {
70        println!("  工作区:       ✅ 干净");
71    }
72}
73
74/// 检查 GitHub Release 是否存在,以及 body 是否与 CHANGELOG 同步。
75fn check_github_release(repo_path: &Path, tag: &str, scope_dir: &Path, _version: &str) {
76    // 解析 GitHub 仓库
77    let repo = get_github_repo(repo_path);
78    let repo = match repo {
79        Some(r) => r,
80        None => return,
81    };
82
83    // 查询 Release
84    let out = std::process::Command::new("gh")
85        .args([
86            "release", "view", tag, "--repo", &repo, "--json", "body", "--jq", ".body",
87        ])
88        .output()
89        .ok();
90
91    let body = match out {
92        Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
93        _ => {
94            println!("    GitHub Release: ❌ 不存在");
95            return;
96        }
97    };
98
99    // 从 CHANGELOG 提取当前版本的 notes
100    let changelog_path = scope_dir.join("CHANGELOG.md");
101    let notes = super::util::extract_notes(tag, &changelog_path);
102    let notes = notes.unwrap_or_default();
103
104    if body == notes {
105        println!("    GitHub Release: ✅ body 与 CHANGELOG 一致");
106    } else if body.trim().is_empty() {
107        println!("    GitHub Release: ⚠️ body 为空");
108    } else if notes.is_empty() {
109        println!("    GitHub Release: ✅ 已创建 (CHANGELOG 无此版本条目)");
110    } else {
111        println!("    GitHub Release: ⚠️ body 与 CHANGELOG 不同步");
112    }
113}
114
115fn read_contract_scopes(repo_path: &Path) -> HashMap<String, String> {
116    let mut map = HashMap::new();
117    let content = std::fs::read_to_string(repo_path.join(".quanttide/devops/contract.yaml"))
118        .unwrap_or_default();
119    let mut in_scopes = false;
120    for line in content.lines() {
121        let t = line.trim();
122        if t == "scopes:" {
123            in_scopes = true;
124            continue;
125        }
126        if in_scopes {
127            if t.starts_with('#') || t.is_empty() {
128                continue;
129            }
130            if !t.starts_with('-') && t.contains(':') {
131                if let Some(idx) = t.find(':') {
132                    let key = t[..idx].trim().to_string();
133                    let val = t[idx + 1..].trim().to_string();
134                    if !key.is_empty() {
135                        map.insert(key, val);
136                    }
137                }
138            } else if !t.starts_with(' ') && !t.starts_with('-') {
139                break;
140            }
141        }
142    }
143    if !map.contains_key("(root)") {
144        map.insert("(root)".to_string(), ".".to_string());
145    }
146    map
147}
148
149fn get_latest_tags_by_scope(repo_path: &Path) -> Vec<(String, String)> {
150    let out = std::process::Command::new("git")
151        .args([
152            "-C",
153            &repo_path.to_string_lossy(),
154            "tag",
155            "--sort=-version:refname",
156        ])
157        .output()
158        .ok();
159    let out = match out {
160        Some(o) if o.status.success() => o,
161        _ => return vec![],
162    };
163    let all: Vec<&str> = std::str::from_utf8(&out.stdout)
164        .unwrap_or("")
165        .lines()
166        .collect();
167    let mut scopes: Vec<(String, String)> = Vec::new();
168    for t in all {
169        let scope = if t.contains('/') {
170            t.split('/').next().unwrap_or("").to_string()
171        } else {
172            "(root)".to_string()
173        };
174        let tag_only = t.split('/').last().unwrap_or(t);
175        let pre = tag_only.contains('-');
176        if let Some(pos) = scopes.iter().position(|(s, _)| s == &scope) {
177            let et = scopes[pos].1.split('/').last().unwrap_or(&scopes[pos].1);
178            if !pre && et.contains('-') {
179                scopes[pos] = (scope, t.to_string());
180            }
181        } else {
182            scopes.push((scope, t.to_string()));
183        }
184    }
185    scopes
186}
187
188fn count_unreleased_in_dir(repo_path: &Path, tag: &str, scope_dir: &Path) -> usize {
189    let range = format!("{}..HEAD", tag);
190    if scope_dir == repo_path {
191        let out = std::process::Command::new("git")
192            .args([
193                "-C",
194                &repo_path.to_string_lossy(),
195                "rev-list",
196                "--count",
197                &range,
198            ])
199            .output()
200            .ok();
201        return match out {
202            Some(o) if o.status.success() => std::str::from_utf8(&o.stdout)
203                .unwrap_or("0")
204                .trim()
205                .parse()
206                .unwrap_or(0),
207            _ => 0,
208        };
209    }
210    let rel = scope_dir.strip_prefix(repo_path).unwrap_or(scope_dir);
211    let rel_str = rel.to_string_lossy().trim_start_matches('/').to_string();
212    let out = std::process::Command::new("git")
213        .args([
214            "-C",
215            &repo_path.to_string_lossy(),
216            "rev-list",
217            "--count",
218            &range,
219            "--",
220            &rel_str,
221        ])
222        .output()
223        .ok();
224    match out {
225        Some(o) if o.status.success() => std::str::from_utf8(&o.stdout)
226            .unwrap_or("0")
227            .trim()
228            .parse()
229            .unwrap_or(0),
230        _ => 0,
231    }
232}
233
234fn get_github_repo(repo_path: &Path) -> Option<String> {
235    let out = std::process::Command::new("git")
236        .args([
237            "-C",
238            &repo_path.to_string_lossy(),
239            "remote",
240            "get-url",
241            "origin",
242        ])
243        .output()
244        .ok()?;
245    if !out.status.success() {
246        return None;
247    }
248    let url = std::str::from_utf8(&out.stdout).ok()?.trim().to_string();
249    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
250    let caps = re.captures(&url)?;
251    Some(caps.get(1)?.as_str().to_string())
252}
253
254fn check_all_configs(repo_path: &Path, other_scope_dirs: &[std::path::PathBuf], expected: &str) {
255    let checks: [(&str, fn(&str) -> Option<String>); 5] = [
256        ("Cargo.toml", |c| extract_kv(c, "version")),
257        ("pyproject.toml", |c| extract_kv(c, "version")),
258        ("package.json", extract_json_version),
259        ("pubspec.yaml", |c| extract_kv_yaml(c, "version")),
260        ("setup.cfg", |c| extract_kv(c, "version")),
261    ];
262    for (name, extract) in &checks {
263        let content = match std::fs::read_to_string(&repo_path.join(name)) {
264            Ok(c) => c,
265            Err(_) => continue,
266        };
267        match extract(&content) {
268            Some(v) if v == expected => println!("    {:<15} {} ✅", format!("{}:", name), v),
269            Some(v) => println!(
270                "    {:<15} {} ❌ (期望 {})",
271                format!("{}:", name),
272                v,
273                expected
274            ),
275            None => println!("    {:<15} (未找到版本字段)", format!("{}:", name)),
276        }
277    }
278    let vf = repo_path.join("VERSION");
279    if let Ok(c) = std::fs::read_to_string(&vf) {
280        let v = c.trim().to_string();
281        if !v.is_empty() {
282            if v == expected {
283                println!("    VERSION          {} ✅", v);
284            } else {
285                println!("    VERSION          {} ❌ (期望 {})", v, expected);
286            }
287        }
288    }
289    for p in find_go_files(repo_path, other_scope_dirs) {
290        let content = match std::fs::read_to_string(&p) {
291            Ok(c) => c,
292            Err(_) => continue,
293        };
294        for prefix in &[
295            "var Version = \"",
296            "var VERSION = \"",
297            "const Version = \"",
298            "const VERSION = \"",
299        ] {
300            for line in content.lines() {
301                let t = line.trim();
302                if let Some(rest) = t.strip_prefix(prefix) {
303                    if let Some(end) = rest.find('"') {
304                        let v = rest[..end].to_string();
305                        if !v.is_empty() {
306                            let rel = p.strip_prefix(repo_path).unwrap_or(&p);
307                            let name = rel.to_string_lossy();
308                            if v == expected {
309                                println!("    {:<15} {} ✅", format!("{}:", name), v);
310                            } else {
311                                println!(
312                                    "    {:<15} {} ❌ (期望 {})",
313                                    format!("{}:", name),
314                                    v,
315                                    expected
316                                );
317                            }
318                        }
319                    }
320                }
321            }
322        }
323    }
324}
325
326fn find_go_files(dir: &Path, excludes: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
327    let mut files = Vec::new();
328    let entries = match std::fs::read_dir(dir) {
329        Ok(e) => e,
330        Err(_) => return files,
331    };
332    for entry in entries.flatten() {
333        let p = entry.path();
334        if p.is_dir() {
335            if excludes.iter().any(|e| p == *e) {
336                continue;
337            }
338            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
339            if !name.starts_with('.')
340                && name != "node_modules"
341                && name != "target"
342                && name != "vendor"
343            {
344                files.extend(find_go_files(&p, excludes));
345            }
346        } else if p.extension().and_then(|e| e.to_str()) == Some("go") {
347            files.push(p);
348        }
349    }
350    files
351}
352
353fn extract_kv(content: &str, key: &str) -> Option<String> {
354    let p1 = format!("{} = \"", key);
355    let p2 = format!("{} = '", key);
356    for line in content.lines() {
357        let t = line.trim();
358        if let Some(r) = t.strip_prefix(&p1) {
359            if let Some(e) = r.find('"') {
360                let v = r[..e].to_string();
361                if !v.is_empty() {
362                    return Some(v);
363                }
364            }
365        }
366        if let Some(r) = t.strip_prefix(&p2) {
367            if let Some(e) = r.find('\'') {
368                let v = r[..e].to_string();
369                if !v.is_empty() {
370                    return Some(v);
371                }
372            }
373        }
374    }
375    None
376}
377
378fn extract_json_version(content: &str) -> Option<String> {
379    for line in content.lines() {
380        let t = line.trim();
381        if let Some(r) = t.strip_prefix("\"version\":") {
382            let v = r
383                .trim()
384                .trim_matches('"')
385                .trim_matches('\'')
386                .trim_matches(',');
387            if !v.is_empty() {
388                return Some(v.to_string());
389            }
390        }
391    }
392    None
393}
394
395fn extract_kv_yaml(content: &str, key: &str) -> Option<String> {
396    let p = format!("{}:", key);
397    for line in content.lines() {
398        let t = line.trim();
399        if let Some(r) = t.strip_prefix(&p) {
400            let v = r.trim();
401            if !v.is_empty() && !v.starts_with('#') {
402                return Some(v.to_string());
403            }
404        }
405    }
406    None
407}
408
409fn check_changelog(repo_path: &Path, version: &str) -> bool {
410    if version.is_empty() {
411        return false;
412    }
413    std::fs::read_to_string(repo_path.join("CHANGELOG.md"))
414        .unwrap_or_default()
415        .contains(&format!("[{}]", version))
416}
417
418fn is_dirty(repo_path: &Path) -> bool {
419    let out = std::process::Command::new("git")
420        .args(["-C", &repo_path.to_string_lossy(), "status", "--porcelain"])
421        .output()
422        .ok();
423    match out {
424        Some(o) => !o.stdout.is_empty(),
425        None => false,
426    }
427}