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 条目,按 Added / Changed / Fixed / Removed 分类:\n\n\
51         提交记录:\n{git_log}\n\n版本号:{version}"
52    );
53
54    let settings = Settings::from_env();
55    if settings.llm_api_key.is_empty() {
56        return Err(format!(
57            "LLM 未配置(LLM_API_KEY 未设置)。请将以下文本发送给 AI 生成 CHANGELOG:\n\n{hint}"
58        ));
59    }
60
61    let llm = LLM::new(
62        &settings.llm_model,
63        &settings.llm_base_url,
64        &settings.llm_api_key,
65    );
66    let messages = vec![
67        Message::new(
68            "system",
69            "你是一个帮助生成 CHANGELOG 的助手。\
70             严格按 Keep a Changelog 格式输出,分类为 Added / Changed / Fixed / Removed。\
71             只输出内容,不要包含任何解释。",
72        ),
73        Message::new("user", &hint),
74    ];
75    let response = llm
76        .complete(&messages, CompleteOptions::default())
77        .map_err(|e| format!("LLM 调用失败: {}\n\n请手动生成 CHANGELOG:\n\n{hint}", e))?;
78
79    Ok(response.content.trim().to_string())
80}
81
82/// 将生成的 CHANGELOG 条目写入文件。
83pub fn write_changelog(path: &Path, version: &str, content: &str) -> Result<(), String> {
84    let ver = super::util::normalize_version(version);
85    let entry = format!("\n## [{}] - {}\n\n{}\n", ver, today(), content);
86    let mut existing = if path.exists() {
87        std::fs::read_to_string(path).map_err(|e| format!("读取 CHANGELOG.md 失败: {}", e))?
88    } else {
89        "# CHANGELOG\n".to_string()
90    };
91    if let Some(pos) = existing.find("\n## ") {
92        existing.insert_str(pos, &entry);
93    } else {
94        existing.push_str(&entry);
95    }
96    std::fs::write(path, &existing).map_err(|e| format!("写入 CHANGELOG.md 失败: {}", e))?;
97    Ok(())
98}
99
100fn today() -> String {
101    // 不使用 chrono,用 git 的方式获取当前日期
102    let output = std::process::Command::new("date")
103        .args(["+%Y-%m-%d"])
104        .output()
105        .ok();
106    output
107        .and_then(|o| {
108            if o.status.success() {
109                Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
110            } else {
111                None
112            }
113        })
114        .unwrap_or_else(|| "unknown".to_string())
115}
116
117/// 如果 CHANGELOG.md 不包含当前版本,则自动生成并写入。
118pub fn ensure_changelog(repo_path: &Path, version: &str) -> Result<(), String> {
119    let changelog_path = repo_path.join("CHANGELOG.md");
120    if changelog_path.exists() {
121        let content = std::fs::read_to_string(&changelog_path)
122            .map_err(|e| format!("读取 CHANGELOG.md 失败: {}", e))?;
123        if content.contains(&format!("[{}]", super::util::normalize_version(version))) {
124            return Ok(());
125        }
126    }
127    let git_log = collect_git_log(repo_path)?;
128    let changelog_content = llm_changelog(&git_log, version)?;
129    write_changelog(&changelog_path, version, &changelog_content)?;
130    println!("✓ CHANGELOG.md 已更新(版本 {})", version);
131
132    // 提交 CHANGELOG 修改,确保后续标签包含它
133    let ver = super::util::normalize_version(version);
134    let add = std::process::Command::new("git")
135        .args(["add", "CHANGELOG.md"])
136        .current_dir(repo_path)
137        .output()
138        .map_err(|e| format!("git add 失败: {}", e))?;
139    if !add.status.success() {
140        return Err("git add CHANGELOG.md 失败".into());
141    }
142    let commit = std::process::Command::new("git")
143        .args([
144            "commit",
145            "-m",
146            &format!("chore: add CHANGELOG entry for {}", ver),
147        ])
148        .current_dir(repo_path)
149        .output()
150        .map_err(|e| format!("git commit 失败: {}", e))?;
151    if !commit.status.success() {
152        return Err("git commit CHANGELOG.md 失败".into());
153    }
154    println!("✓ CHANGELOG 修改已提交");
155    Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn git_init(path: &Path) {
163        std::process::Command::new("git")
164            .args(["init", "-b", "main"])
165            .current_dir(path)
166            .output()
167            .unwrap();
168        std::process::Command::new("git")
169            .args(["config", "user.email", "t@t"])
170            .current_dir(path)
171            .output()
172            .unwrap();
173        std::process::Command::new("git")
174            .args(["config", "user.name", "t"])
175            .current_dir(path)
176            .output()
177            .unwrap();
178    }
179
180    fn git_commit(path: &Path, msg: &str) {
181        std::fs::write(path.join("f"), msg).unwrap();
182        std::process::Command::new("git")
183            .args(["add", "."])
184            .current_dir(path)
185            .output()
186            .unwrap();
187        std::process::Command::new("git")
188            .args(["commit", "-m", msg])
189            .current_dir(path)
190            .output()
191            .unwrap();
192    }
193
194    #[test]
195    fn test_collect_git_log_with_commits() {
196        let d = tempfile::tempdir().unwrap();
197        git_init(d.path());
198        git_commit(d.path(), "feat: add foo");
199        git_commit(d.path(), "fix: bar");
200        let log = collect_git_log(d.path()).unwrap();
201        assert!(log.contains("feat: add foo"));
202    }
203
204    #[test]
205    fn test_collect_git_log_single_commit() {
206        let d = tempfile::tempdir().unwrap();
207        git_init(d.path());
208        git_commit(d.path(), "init");
209        let log = collect_git_log(d.path()).unwrap();
210        assert!(log.contains("init"));
211    }
212
213    #[test]
214    fn test_write_changelog_creates_file() {
215        let d = tempfile::tempdir().unwrap();
216        let path = d.path().join("CHANGELOG.md");
217        write_changelog(&path, "v0.1.0", "### Added\n- new feature").unwrap();
218        let content = std::fs::read_to_string(&path).unwrap();
219        assert!(content.contains("## [0.1.0]"));
220    }
221
222    #[test]
223    fn test_write_changelog_append() {
224        let d = tempfile::tempdir().unwrap();
225        let path = d.path().join("CHANGELOG.md");
226        std::fs::write(&path, "# CHANGELOG\n\n## [0.1.0]\n\nold\n").unwrap();
227        write_changelog(&path, "v0.2.0", "### Added\n- new").unwrap();
228        let content = std::fs::read_to_string(&path).unwrap();
229        assert!(content.contains("## [0.2.0]"));
230        assert!(content.contains("## [0.1.0]"));
231    }
232
233    #[test]
234    fn test_ensure_changelog_skips_if_exists() {
235        let d = tempfile::tempdir().unwrap();
236        let path = d.path().join("CHANGELOG.md");
237        std::fs::write(&path, "# CHANGELOG\n\n## [0.1.0]\n\ncontent\n").unwrap();
238        assert!(ensure_changelog(d.path(), "v0.1.0").is_ok());
239    }
240
241    #[test]
242    fn test_ensure_changelog_no_git_log() {
243        let d = tempfile::tempdir().unwrap();
244        let result = ensure_changelog(d.path(), "v0.1.0");
245        assert!(result.is_err());
246    }
247
248    #[test]
249    fn test_latest_tag_empty_repo() {
250        let d = tempfile::tempdir().unwrap();
251        git_init(d.path());
252        git_commit(d.path(), "init");
253        assert!(get_latest_tag(d.path()).is_none());
254    }
255
256    #[test]
257    fn test_latest_tag_with_tags() {
258        let d = tempfile::tempdir().unwrap();
259        git_init(d.path());
260        git_commit(d.path(), "init");
261        std::process::Command::new("git")
262            .args(["tag", "v0.1.0"])
263            .current_dir(d.path())
264            .output()
265            .unwrap();
266        std::process::Command::new("git")
267            .args(["tag", "v0.2.0"])
268            .current_dir(d.path())
269            .output()
270            .unwrap();
271        assert_eq!(get_latest_tag(d.path()).as_deref(), Some("v0.2.0"));
272    }
273}