qtcloud_devops_cli/release/
changelog.rs1use std::path::Path;
2
3use quanttide_agent::{llm::CompleteOptions, Message, Settings, LLM};
4
5pub 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
27fn 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
44fn 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
82pub 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 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
117pub 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 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}