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 条目。\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
90pub 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 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
125pub 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 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}