Skip to main content

qtcloud_devops_cli/release/
changelog.rs

1use std::path::Path;
2
3use quanttide_agent::{llm::CompleteOptions, Message, Settings, LLM};
4
5/// 收集上个 tag 到当前 HEAD 之间的 git 提交记录。
6pub fn collect_git_log(repo_path: &Path) -> Result<String, String> {
7    let repo = git2::Repository::open(repo_path).map_err(|e| format!("打开仓库失败: {}", e))?;
8    let head_oid = repo
9        .head()
10        .and_then(|h| {
11            h.target()
12                .ok_or_else(|| git2::Error::from_str("HEAD 无目标"))
13        })
14        .map_err(|_| "无法获取 HEAD".to_string())?;
15
16    let mut revwalk = repo
17        .revwalk()
18        .map_err(|e| format!("创建 revwalk 失败: {}", e))?;
19    revwalk.push(head_oid).ok();
20
21    // 有 tag 时隐藏 tag 之前的提交,无 tag 时步行所有(等价 --all)
22    if let Some(tag) = get_latest_tag(repo_path) {
23        if let Ok(r) = repo.find_reference(&format!("refs/tags/{}", tag)) {
24            if let Some(oid) = r.target() {
25                revwalk.hide(oid).ok();
26            }
27        }
28    }
29
30    let mut log_lines: Vec<String> = Vec::new();
31    for oid in revwalk {
32        let oid = oid.map_err(|_| "revwalk 迭代失败".to_string())?;
33        let commit = repo
34            .find_commit(oid)
35            .map_err(|_| "找不到 commit".to_string())?;
36        let short = &oid.to_string()[..7];
37        let summary = commit.summary().unwrap_or("").to_string();
38        log_lines.push(format!("{} {}", short, summary));
39    }
40
41    if log_lines.is_empty() {
42        return Err("没有新的提交记录".into());
43    }
44    Ok(log_lines.join("\n"))
45}
46
47/// 获取仓库中最新版本 tag(按版本排序取第一个)。
48fn get_latest_tag(repo_path: &Path) -> Option<String> {
49    let repo = git2::Repository::open(repo_path).ok()?;
50    let tag_names = repo.tag_names(None).ok()?;
51    let mut tags: Vec<&str> = tag_names.iter().flatten().collect();
52    tags.sort_by(|a, b| b.cmp(a));
53    tags.first().map(|s| s.to_string())
54}
55
56/// 调用 LLM 生成 CHANGELOG 条目。
57///
58/// 通过 quanttide-agent 调用 LLM。如果 LLM 未配置(LLM_API_KEY 为空),
59/// 返回提示文本让用户手动发送给 AI(不阻塞发布流程)。
60fn llm_changelog(git_log: &str, version: &str) -> Result<String, String> {
61    let hint = format!(
62        "根据以下 git 提交记录,为版本 {} 生成 CHANGELOG 条目。\n\n\
63         要求:\n\
64         1. 按 Added / Changed / Fixed / Removed 分类\n\
65         2. 同类提交合并为概括性条目,不要逐条罗列\n\
66         3. 用中文描述\n\
67         4. 每类不超过 5 条\n\
68         5. 仅输出内容,不要版本头部和日期\n\n\
69         提交记录:\n{git_log}",
70        version
71    );
72
73    let settings = Settings::from_env();
74    if settings.llm_api_key.is_empty() {
75        return Err(format!(
76            "LLM 未配置(LLM_API_KEY 未设置)。请将以下文本发送给 AI 生成 CHANGELOG:\n\n{hint}"
77        ));
78    }
79
80    let llm = LLM::new(
81        &settings.llm_model,
82        &settings.llm_base_url,
83        &settings.llm_api_key,
84    );
85    let messages = vec![
86        Message::new(
87            "system",
88            "你是一个帮助生成 CHANGELOG 的助手。\
89             将 git 提交记录按 Added / Changed / Fixed / Removed 分类\
90             并合并为概括性条目,用中文描述。不要逐条罗列 commit message。\
91             只输出分类后的条目内容,不要输出版本头部(## 行)和日期。",
92        ),
93        Message::new("user", &hint),
94    ];
95    let response = llm
96        .complete(&messages, CompleteOptions::default())
97        .map_err(|e| format!("LLM 调用失败: {}\n\n请手动生成 CHANGELOG:\n\n{hint}", e))?;
98
99    Ok(response.content.trim().to_string())
100}
101
102/// 将生成的 CHANGELOG 条目写入文件。
103pub fn write_changelog(path: &Path, version: &str, content: &str) -> Result<(), String> {
104    let ver = super::util::normalize_version(version);
105    let entry = format!("\n## [{}] - {}\n\n{}\n", ver, today(), content);
106    let mut existing = if path.exists() {
107        std::fs::read_to_string(path).map_err(|e| format!("读取 CHANGELOG.md 失败: {}", e))?
108    } else {
109        "# CHANGELOG\n".to_string()
110    };
111    if let Some(pos) = existing.find("\n## ") {
112        existing.insert_str(pos, &entry);
113    } else {
114        existing.push_str(&entry);
115    }
116    std::fs::write(path, &existing).map_err(|e| format!("写入 CHANGELOG.md 失败: {}", e))?;
117    Ok(())
118}
119
120fn today() -> String {
121    // 不使用 chrono,用 git 的方式获取当前日期
122    let output = std::process::Command::new("date")
123        .args(["+%Y-%m-%d"])
124        .output()
125        .ok();
126    output
127        .and_then(|o| {
128            if o.status.success() {
129                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
130            } else {
131                None
132            }
133        })
134        .unwrap_or_else(|| "unknown".to_string())
135}
136
137/// 如果 CHANGELOG.md 不包含当前版本,则自动生成并写入。
138pub fn ensure_changelog(repo_path: &Path, version: &str) -> Result<(), String> {
139    let changelog_path = repo_path.join("CHANGELOG.md");
140    if changelog_path.exists() {
141        let content = std::fs::read_to_string(&changelog_path)
142            .map_err(|e| format!("读取 CHANGELOG.md 失败: {}", e))?;
143        let ver = super::util::normalize_version(version);
144        if content.contains(&format!("[{}]", ver)) || content.contains(&format!("[v{}]", ver)) {
145            return Ok(());
146        }
147    }
148    let git_log = collect_git_log(repo_path)?;
149    let changelog_content = llm_changelog(&git_log, version)?;
150    write_changelog(&changelog_path, version, &changelog_content)?;
151    println!("✓ CHANGELOG.md 已更新(版本 {})", version);
152
153    // 提交 CHANGELOG 修改,确保后续标签包含它
154    let ver = super::util::normalize_version(version);
155    let add = std::process::Command::new("git")
156        .args(["add", "CHANGELOG.md"])
157        .current_dir(repo_path)
158        .output()
159        .map_err(|e| format!("git add 失败: {}", e))?;
160    if !add.status.success() {
161        return Err("git add CHANGELOG.md 失败".into());
162    }
163    let commit = std::process::Command::new("git")
164        .args([
165            "commit",
166            "-m",
167            &format!("chore: add CHANGELOG entry for {}", ver),
168        ])
169        .current_dir(repo_path)
170        .output()
171        .map_err(|e| format!("git commit 失败: {}", e))?;
172    if !commit.status.success() {
173        return Err("git commit CHANGELOG.md 失败".into());
174    }
175    println!("✓ CHANGELOG 修改已提交");
176    Ok(())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn git_init(path: &Path) {
184        std::process::Command::new("git")
185            .args(["init", "-b", "main"])
186            .current_dir(path)
187            .output()
188            .unwrap();
189        std::process::Command::new("git")
190            .args(["config", "user.email", "t@t"])
191            .current_dir(path)
192            .output()
193            .unwrap();
194        std::process::Command::new("git")
195            .args(["config", "user.name", "t"])
196            .current_dir(path)
197            .output()
198            .unwrap();
199    }
200
201    fn git_commit(path: &Path, msg: &str) {
202        std::fs::write(path.join("f"), msg).unwrap();
203        std::process::Command::new("git")
204            .args(["add", "."])
205            .current_dir(path)
206            .output()
207            .unwrap();
208        std::process::Command::new("git")
209            .args(["commit", "-m", msg])
210            .current_dir(path)
211            .output()
212            .unwrap();
213    }
214
215    #[test]
216    fn test_collect_git_log_with_commits() {
217        let d = tempfile::tempdir().unwrap();
218        git_init(d.path());
219        git_commit(d.path(), "feat: add foo");
220        git_commit(d.path(), "fix: bar");
221        let log = collect_git_log(d.path()).unwrap();
222        assert!(log.contains("feat: add foo"));
223    }
224
225    #[test]
226    fn test_collect_git_log_single_commit() {
227        let d = tempfile::tempdir().unwrap();
228        git_init(d.path());
229        git_commit(d.path(), "init");
230        let log = collect_git_log(d.path()).unwrap();
231        assert!(log.contains("init"));
232    }
233
234    #[test]
235    fn test_write_changelog_creates_file() {
236        let d = tempfile::tempdir().unwrap();
237        let path = d.path().join("CHANGELOG.md");
238        write_changelog(&path, "v0.1.0", "### Added\n- new feature").unwrap();
239        let content = std::fs::read_to_string(&path).unwrap();
240        assert!(content.contains("## [0.1.0]"));
241    }
242
243    #[test]
244    fn test_write_changelog_append() {
245        let d = tempfile::tempdir().unwrap();
246        let path = d.path().join("CHANGELOG.md");
247        std::fs::write(&path, "# CHANGELOG\n\n## [0.1.0]\n\nold\n").unwrap();
248        write_changelog(&path, "v0.2.0", "### Added\n- new").unwrap();
249        let content = std::fs::read_to_string(&path).unwrap();
250        assert!(content.contains("## [0.2.0]"));
251        assert!(content.contains("## [0.1.0]"));
252    }
253
254    #[test]
255    fn test_write_changelog_scope_version() {
256        let d = tempfile::tempdir().unwrap();
257        let path = d.path().join("CHANGELOG.md");
258        write_changelog(&path, "cli/v0.1.0", "### Added\n- feature").unwrap();
259        let content = std::fs::read_to_string(&path).unwrap();
260        assert!(content.contains("## [0.1.0]"));
261        assert!(!content.contains("## [cli/v0.1.0]"));
262    }
263
264    #[test]
265    fn test_ensure_changelog_skips_if_exists() {
266        let d = tempfile::tempdir().unwrap();
267        let path = d.path().join("CHANGELOG.md");
268        std::fs::write(&path, "# CHANGELOG\n\n## [0.1.0]\n\ncontent\n").unwrap();
269        assert!(ensure_changelog(d.path(), "v0.1.0").is_ok());
270    }
271
272    #[test]
273    fn test_ensure_changelog_no_git_log() {
274        let d = tempfile::tempdir().unwrap();
275        let result = ensure_changelog(d.path(), "v0.1.0");
276        assert!(result.is_err());
277    }
278
279    #[test]
280    fn test_ensure_changelog_skips_with_v_prefix() {
281        let d = tempfile::tempdir().unwrap();
282        git_init(d.path());
283        git_commit(d.path(), "init");
284        std::fs::write(
285            d.path().join("CHANGELOG.md"),
286            "# CHANGELOG\n\n## [v0.1.0]\n\ncontent\n",
287        )
288        .unwrap();
289        assert!(ensure_changelog(d.path(), "v0.1.0").is_ok());
290    }
291
292    #[test]
293    fn test_latest_tag_empty_repo() {
294        let d = tempfile::tempdir().unwrap();
295        git_init(d.path());
296        git_commit(d.path(), "init");
297        assert!(get_latest_tag(d.path()).is_none());
298    }
299
300    #[test]
301    fn test_latest_tag_with_tags() {
302        let d = tempfile::tempdir().unwrap();
303        git_init(d.path());
304        git_commit(d.path(), "init");
305        std::process::Command::new("git")
306            .args(["tag", "v0.1.0"])
307            .current_dir(d.path())
308            .output()
309            .unwrap();
310        std::process::Command::new("git")
311            .args(["tag", "v0.2.0"])
312            .current_dir(d.path())
313            .output()
314            .unwrap();
315        assert_eq!(get_latest_tag(d.path()).as_deref(), Some("v0.2.0"));
316    }
317}