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