qtcloud_devops_cli/release/
status.rs1use 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
76fn check_github_release(repo_path: &Path, tag: &str, scope_dir: &Path, _version: &str) {
78 let repo = get_github_repo(repo_path);
80 let repo = match repo {
81 Some(r) => r,
82 None => return,
83 };
84
85 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 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
117fn 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}