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