Skip to main content

qtcloud_devops_cli/commands/
release.rs

1use std::path::Path;
2use std::process::Command;
3
4use crate::model::release::{FileStorage, ReleaseRecord, ReleaseStatus, Storage, TransitionError};
5
6// ===== utility functions =====
7
8pub fn validate_version(version: &str) -> bool {
9    let re = regex::Regex::new(
10        r"^(v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?|[a-zA-Z0-9_.-]+/v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?)$",
11    ).unwrap();
12    re.is_match(version)
13}
14
15fn normalize_version(version: &str) -> String {
16    let s = version.strip_prefix('v').unwrap_or(version);
17    s.split("/v").last().unwrap_or(s).to_string()
18}
19
20pub fn precheck_version_changelog(version: &str, changelog_path: &Path) -> Vec<String> {
21    let mut errors = Vec::new();
22    if !validate_version(version) {
23        errors.push(format!("版本号格式错误: {}", version));
24    }
25    if changelog_path.exists() {
26        let content = std::fs::read_to_string(changelog_path).unwrap_or_default();
27        let ver = normalize_version(version);
28        let marker = format!("## [{}]", ver);
29        if !content.contains(&marker) {
30            errors.push(format!("CHANGELOG.md 未找到 {} 版本记录", ver));
31        }
32    } else {
33        errors.push(format!("CHANGELOG.md 不存在: {}", changelog_path.display()));
34    }
35    errors
36}
37
38pub fn extract_notes(version: &str, changelog_path: &Path) -> Option<String> {
39    let content = std::fs::read_to_string(changelog_path).ok()?;
40    let ver = normalize_version(version);
41    let start_marker = format!("## [{}]", ver);
42    let mut capture = false;
43    let mut notes: Vec<&str> = Vec::new();
44    for line in content.lines() {
45        if line.trim().starts_with(&start_marker) {
46            capture = true;
47            continue;
48        }
49        if capture {
50            if line.starts_with("## [") {
51                break;
52            }
53            notes.push(line);
54        }
55    }
56    let text = notes.join("\n").trim().to_string();
57    if text.is_empty() { None } else { Some(text) }
58}
59
60pub fn confirm_release(version: &str, yes: bool) -> bool {
61    if yes { return true; }
62    use std::io::Write;
63    println!("\n发布版本: {}", version);
64    print!("确认发布? (y/N): ");
65    std::io::stdout().flush().ok();
66    let mut input = String::new();
67    std::io::stdin().read_line(&mut input).ok();
68    let input = input.trim().to_lowercase();
69    input == "y" || input == "yes"
70}
71
72fn git_args(args: &[&str], repo_path: &Path) -> Command {
73    let mut cmd = Command::new("git");
74    cmd.arg("-C");
75    cmd.arg(repo_path);
76    cmd.args(args);
77    cmd
78}
79
80pub fn create_tag(version: &str, repo_path: &Path) -> bool {
81    match git_args(&["tag", version], repo_path).output() {
82        Ok(out) if out.status.success() => true,
83        Ok(out) => { eprintln!("创建标签失败: {}", String::from_utf8_lossy(&out.stderr).trim()); false }
84        Err(e) => { eprintln!("创建标签失败: {}", e); false }
85    }
86}
87
88pub fn push_tag(version: &str, repo_path: &Path) -> bool {
89    match git_args(&["push", "origin", version], repo_path).output() {
90        Ok(out) if out.status.success() => true,
91        Ok(out) => { eprintln!("推送标签失败: {}", String::from_utf8_lossy(&out.stderr).trim()); false }
92        Err(e) => { eprintln!("推送标签失败: {}", e); false }
93    }
94}
95
96pub fn get_remote_repo(repo_path: &Path) -> Option<String> {
97    let result = git_args(&["remote", "get-url", "origin"], repo_path).output().ok()?;
98    if !result.status.success() { return None; }
99    let url = String::from_utf8_lossy(&result.stdout).trim().to_string();
100    parse_github_repo(&url)
101}
102
103pub fn parse_github_repo(url: &str) -> Option<String> {
104    let re = regex::Regex::new(r"github\.com[/:]([^/]+/[^/]+?)(?:\.git)?$").ok()?;
105    let caps = re.captures(url)?;
106    Some(caps.get(1)?.as_str().to_string())
107}
108
109pub fn create_release(version: &str, notes: &str, repo: &str) -> bool {
110    match Command::new("gh")
111        .args(["release", "create", version, "--title", version, "--notes", notes, "--repo", repo])
112        .output()
113    {
114        Ok(out) if out.status.success() => true,
115        Ok(out) => { eprintln!("创建 Release 失败: {}", String::from_utf8_lossy(&out.stderr).trim()); false }
116        Err(e) => { eprintln!("创建 Release 失败: {}", e); false }
117    }
118}
119
120pub fn rollback_tag(version: &str, repo_path: &Path) {
121    git_args(&["tag", "-d", version], repo_path).output().ok();
122    git_args(&["push", "origin", "--delete", version], repo_path).output().ok();
123    println!("↻ 标签 {} 已回滚", version);
124}
125
126// ===== stage =====
127
128pub fn stage(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
129    if !validate_version(version) {
130        return Err(format!("版本号格式错误: {}", version).into());
131    }
132    let mut storage = FileStorage::new(repo_path);
133    if let Some(existing) = storage.load(version) {
134        match existing.status {
135            ReleaseStatus::Published => return Err(format!("版本 {} 已发布,不可重复 stage", version).into()),
136            ReleaseStatus::Staged => {
137                let now = std::time::SystemTime::now()
138                    .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
139                let mut updated = existing.clone();
140                updated.updated_at = now;
141                storage.save(&updated)?;
142                return Ok(updated.id);
143            }
144            ReleaseStatus::Cancelled => {}
145            ReleaseStatus::Retired => return Err(format!("版本 {} 已退役,不可重复 stage", version).into()),
146        }
147    }
148    let record = ReleaseRecord::new_staged(version);
149    storage.save(&record)?;
150    println!("✓ 版本 {} 已进入 Staged 状态 (发布尝试 ID: {})", version, record.id);
151    Ok(record.id)
152}
153
154// ===== publish =====
155
156pub fn publish(version: &str, repo_path: &Path, yes: bool) -> Result<String, Box<dyn std::error::Error>> {
157    let mut storage = FileStorage::new(repo_path);
158    let mut record = storage
159        .load(version)
160        .ok_or_else(|| format!("版本 {} 不存在,请先执行 stage", version))?;
161    if record.status != ReleaseStatus::Staged {
162        return Err(format!("版本 {} 不处于 Staged 状态 (当前: {:?})", version, record.status).into());
163    }
164    if !confirm_release(version, yes) {
165        return Err("已取消发布".into());
166    }
167    if !create_tag(version, repo_path) {
168        return Err(format!("创建标签 {} 失败", version).into());
169    }
170    if !push_tag(version, repo_path) {
171        rollback_tag(version, repo_path);
172        return Err(format!("推送标签 {} 失败", version).into());
173    }
174    println!("✓ 标签 {} 已创建并推送", version);
175
176    let changelog_path = repo_path.join("CHANGELOG.md");
177    let notes = extract_notes(version, &changelog_path);
178    if let Some(repo) = get_remote_repo(repo_path) {
179        if !create_release(version, notes.as_deref().unwrap_or(""), &repo) {
180            rollback_tag(version, repo_path);
181            return Err("创建 GitHub Release 失败".into());
182        }
183        println!("✓ GitHub Release {} 已创建", version);
184        println!("  https://github.com/{}/releases/tag/{}", repo, version);
185    }
186
187    record.status = ReleaseStatus::Published;
188    record.updated_at = std::time::SystemTime::now()
189        .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
190    storage.save(&record)?;
191    let id = record.id.clone();
192    println!("✓ 版本 {} 已发布 (发布尝试 ID: {})", version, id);
193    Ok(id)
194}
195
196// ===== cancel =====
197
198pub fn cancel(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
199    let mut storage = FileStorage::new(repo_path);
200    let mut record = storage
201        .load(version)
202        .ok_or_else(|| format!("版本 {} 不存在", version))?;
203    if record.status != ReleaseStatus::Staged {
204        return Err(Box::new(TransitionError::NotStaged(version.to_string())));
205    }
206    rollback_tag(version, repo_path);
207    if let Some(repo) = get_remote_repo(repo_path) {
208        std::process::Command::new("gh")
209            .args(["release", "delete", version, "--repo", &repo, "--yes"])
210            .output().ok();
211        println!("✓ GitHub Release {} 已删除", version);
212    }
213    record.status = ReleaseStatus::Cancelled;
214    record.updated_at = std::time::SystemTime::now()
215        .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
216    storage.save(&record)?;
217    let id = record.id.clone();
218    println!("✓ 版本 {} 已取消 (发布尝试 ID: {})", version, id);
219    Ok(id)
220}
221
222// ===== retire =====
223
224pub fn retire(version: &str, repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
225    let mut storage = FileStorage::new(repo_path);
226    let mut record = storage
227        .load(version)
228        .ok_or_else(|| format!("版本 {} 不存在", version))?;
229    if record.status != ReleaseStatus::Published {
230        return Err(Box::new(TransitionError::NotPublished(version.to_string())));
231    }
232    record.status = ReleaseStatus::Retired;
233    record.updated_at = std::time::SystemTime::now()
234        .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
235    storage.save(&record)?;
236    let id = record.id.clone();
237    println!("✓ 版本 {} 已退役 (发布尝试 ID: {})", version, id);
238    Ok(id)
239}
240
241// ===== release_status =====
242
243pub fn release_status(repo_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
244    let storage = FileStorage::new(repo_path);
245    let mut records = storage.list();
246    if records.is_empty() {
247        println!("当前无发布记录");
248        return Ok(String::new());
249    }
250
251    records.sort_by(|a, b| b.created_at.cmp(&a.created_at));
252
253    let staged: Vec<&ReleaseRecord> = records.iter().filter(|r| r.status == ReleaseStatus::Staged).collect();
254    let published: Vec<&ReleaseRecord> = records.iter().filter(|r| r.status == ReleaseStatus::Published).collect();
255
256    println!("发布状态报告");
257    println!("{}", "-".repeat(40));
258    println!("待发布: {}", staged.len());
259    for r in &staged {
260        println!("  {} (尝试: {})", r.version, &r.id[..8]);
261    }
262    println!("已发布: {}", published.len());
263    for r in &published {
264        println!("  {} (尝试: {})", r.version, &r.id[..8]);
265    }
266    println!();
267
268    println!("最新发布:");
269    for r in records.iter().take(5) {
270        let status_str = match r.status {
271            ReleaseStatus::Staged => "Staged",
272            ReleaseStatus::Published => "Published",
273            ReleaseStatus::Cancelled => "Cancelled",
274            ReleaseStatus::Retired => "Retired",
275        };
276        println!("  {:<25} {:<12} {}", r.version, status_str, r.updated_at);
277    }
278
279    Ok(records.len().to_string())
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    fn make_record(version: &str, status: ReleaseStatus) -> ReleaseRecord {
287        let now = std::time::SystemTime::now()
288            .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string();
289        ReleaseRecord {
290            id: uuid::Uuid::new_v4().to_string(),
291            version: version.to_string(),
292            status,
293            created_at: now.clone(),
294            updated_at: now,
295        }
296    }
297
298    // validate_version
299
300    #[test]
301    fn test_validate_version_v_prefix() { assert!(validate_version("v1.2.3")); }
302    #[test]
303    fn test_validate_version_with_suffix() { assert!(validate_version("v1.2.3-alpha.1")); assert!(validate_version("v1.2.3-rc1")); }
304    #[test]
305    fn test_validate_version_pkg() { assert!(validate_version("pkg/v1.2.3")); assert!(validate_version("cli/v0.1.0")); }
306    #[test]
307    fn test_validate_version_invalid() { assert!(!validate_version("1.2.3")); assert!(!validate_version("v1.2")); assert!(!validate_version("abc")); }
308
309    // parse_github_repo
310
311    #[test]
312    fn test_parse_github_repo_https() { assert_eq!(parse_github_repo("https://github.com/owner/repo.git"), Some("owner/repo".into())); }
313    #[test]
314    fn test_parse_github_repo_ssh() { assert_eq!(parse_github_repo("git@github.com:owner/repo.git"), Some("owner/repo".into())); }
315    #[test]
316    fn test_parse_github_repo_not_github() { assert_eq!(parse_github_repo("https://gitlab.com/owner/repo.git"), None); }
317
318    // extract_notes
319
320    #[test]
321    fn test_extract_notes_found() {
322        let dir = tempfile::tempdir().unwrap();
323        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
324        assert!(extract_notes("v1.0.0", &dir.path().join("CHANGELOG.md")).is_some());
325    }
326
327    #[test]
328    fn test_extract_notes_not_found() {
329        let dir = tempfile::tempdir().unwrap();
330        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
331        assert!(extract_notes("v2.0.0", &dir.path().join("CHANGELOG.md")).is_none());
332    }
333
334    // confirm_release
335
336    #[test]
337    fn test_confirm_release_yes_flag() { assert!(confirm_release("v1.0.0", true)); }
338
339    // precheck_changelog
340
341    #[test]
342    fn test_precheck_changelog_no_errors() {
343        let dir = tempfile::tempdir().unwrap();
344        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
345        assert!(precheck_version_changelog("v1.0.0", &dir.path().join("CHANGELOG.md")).is_empty());
346    }
347
348    #[test]
349    fn test_precheck_changelog_missing_entry() {
350        let dir = tempfile::tempdir().unwrap();
351        std::fs::write(dir.path().join("CHANGELOG.md"), "## [1.0.0]\n\ncontent").unwrap();
352        assert!(precheck_version_changelog("v2.0.0", &dir.path().join("CHANGELOG.md")).iter().any(|e| e.contains("未找到")));
353    }
354
355    // stage
356
357    #[test]
358    fn test_stage_new_version() {
359        let dir = tempfile::tempdir().unwrap();
360        let id = stage("v1.0.0", dir.path()).unwrap();
361        assert!(!id.is_empty());
362        let s = FileStorage::new(dir.path());
363        assert_eq!(s.load("v1.0.0").unwrap().status, ReleaseStatus::Staged);
364    }
365
366    #[test]
367    fn test_stage_invalid_version() { assert!(stage("bad", tempfile::tempdir().unwrap().path()).is_err()); }
368
369    #[test]
370    fn test_stage_published_rejected() {
371        let dir = tempfile::tempdir().unwrap();
372        let mut s = FileStorage::new(dir.path());
373        s.save(&make_record("v1.0.0", ReleaseStatus::Published)).unwrap();
374        assert!(stage("v1.0.0", dir.path()).unwrap_err().to_string().contains("已发布"));
375    }
376
377    #[test]
378    fn test_stage_cancelled_restage() {
379        let dir = tempfile::tempdir().unwrap();
380        let old_id;
381        {
382            let mut s = FileStorage::new(dir.path());
383            let r = make_record("v1.0.0", ReleaseStatus::Cancelled);
384            old_id = r.id.clone();
385            s.save(&r).unwrap();
386        }
387        let id = stage("v1.0.0", dir.path()).unwrap();
388        assert_ne!(id, old_id);
389    }
390
391    #[test]
392    fn test_stage_retired_rejected() {
393        let dir = tempfile::tempdir().unwrap();
394        let mut s = FileStorage::new(dir.path());
395        s.save(&make_record("v1.0.0", ReleaseStatus::Retired)).unwrap();
396        assert!(stage("v1.0.0", dir.path()).unwrap_err().to_string().contains("退役"));
397    }
398
399    #[test]
400    fn test_stage_idempotent() {
401        let dir = tempfile::tempdir().unwrap();
402        assert_eq!(stage("v1.0.0", dir.path()).unwrap(), stage("v1.0.0", dir.path()).unwrap());
403    }
404
405    // publish
406
407    #[test]
408    fn test_publish_not_found() {
409        let dir = tempfile::tempdir().unwrap();
410        assert!(publish("v1.0.0", dir.path(), true).unwrap_err().to_string().contains("请先执行 stage"));
411    }
412
413    #[test]
414    fn test_publish_not_staged() {
415        let dir = tempfile::tempdir().unwrap();
416        let mut s = FileStorage::new(dir.path());
417        let mut r = ReleaseRecord::new_staged("v1.0.0");
418        r.status = ReleaseStatus::Cancelled;
419        s.save(&r).unwrap();
420        assert!(publish("v1.0.0", dir.path(), true).is_err());
421    }
422
423    // cancel
424
425    #[test]
426    fn test_cancel_nonexistent() { assert!(cancel("v9.9.9", tempfile::tempdir().unwrap().path()).is_err()); }
427
428    #[test]
429    fn test_cancel_not_staged() {
430        let dir = tempfile::tempdir().unwrap();
431        let mut s = FileStorage::new(dir.path());
432        s.save(&make_record("v1.0.0", ReleaseStatus::Published)).unwrap();
433        assert!(cancel("v1.0.0", dir.path()).is_err());
434    }
435
436    #[test]
437    fn test_cancel_happy_path() {
438        let dir = tempfile::tempdir().unwrap();
439        let record_id;
440        {
441            let mut s = FileStorage::new(dir.path());
442            let r = ReleaseRecord::new_staged("v1.0.0");
443            record_id = r.id.clone();
444            s.save(&r).unwrap();
445        }
446        cancel("v1.0.0", dir.path()).unwrap();
447        assert_eq!(FileStorage::new(dir.path()).load("v1.0.0").unwrap().status, ReleaseStatus::Cancelled);
448        assert_eq!(FileStorage::new(dir.path()).load("v1.0.0").unwrap().id, record_id);
449    }
450
451    // retire
452
453    #[test]
454    fn test_retire_nonexistent() { assert!(retire("v9.9.9", tempfile::tempdir().unwrap().path()).is_err()); }
455
456    #[test]
457    fn test_retire_not_published() {
458        let dir = tempfile::tempdir().unwrap();
459        let mut s = FileStorage::new(dir.path());
460        s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
461        assert!(retire("v1.0.0", dir.path()).is_err());
462    }
463
464    #[test]
465    fn test_retire_from_published() {
466        let dir = tempfile::tempdir().unwrap();
467        {
468            let mut s = FileStorage::new(dir.path());
469            s.save(&make_record("v1.0.0", ReleaseStatus::Published)).unwrap();
470        }
471        retire("v1.0.0", dir.path()).unwrap();
472        assert_eq!(FileStorage::new(dir.path()).load("v1.0.0").unwrap().status, ReleaseStatus::Retired);
473    }
474
475    // release_status
476
477    #[test]
478    fn test_release_status_empty() {
479        let dir = tempfile::tempdir().unwrap();
480        assert_eq!(release_status(dir.path()).unwrap(), "");
481    }
482
483    #[test]
484    fn test_release_status_with_records() {
485        let dir = tempfile::tempdir().unwrap();
486        let mut s = FileStorage::new(dir.path());
487        s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
488        s.save(&make_record("v2.0.0", ReleaseStatus::Published)).unwrap();
489        assert_eq!(release_status(dir.path()).unwrap(), "2");
490    }
491
492    #[test]
493    fn test_release_status_multiple_staged() {
494        let dir = tempfile::tempdir().unwrap();
495        let mut s = FileStorage::new(dir.path());
496        s.save(&make_record("v1.0.0", ReleaseStatus::Staged)).unwrap();
497        s.save(&make_record("v2.0.0", ReleaseStatus::Staged)).unwrap();
498        s.save(&make_record("v3.0.0", ReleaseStatus::Published)).unwrap();
499        assert_eq!(release_status(dir.path()).unwrap(), "3");
500    }
501}