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 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 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
47fn 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
56fn 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
102pub 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 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
137pub fn ensure_changelog(repo_path: &Path, scope_dir: &Path, version: &str) -> Result<(), String> {
140 let changelog_path = scope_dir.join("CHANGELOG.md");
141 if changelog_path.exists() {
142 let content = std::fs::read_to_string(&changelog_path)
143 .map_err(|e| format!("读取 CHANGELOG.md 失败: {}", e))?;
144 let ver = super::util::normalize_version(version);
145 if content.contains(&format!("[{}]", ver)) || content.contains(&format!("[v{}]", ver)) {
146 return Ok(());
147 }
148 }
149 let git_log = collect_git_log(repo_path)?;
150 let changelog_content = llm_changelog(&git_log, version)?;
151 write_changelog(&changelog_path, version, &changelog_content)?;
152 println!("✓ CHANGELOG.md 已更新(版本 {})", version);
153
154 let ver = super::util::normalize_version(version);
156 let rel = changelog_path
158 .strip_prefix(repo_path)
159 .unwrap_or(&changelog_path)
160 .to_str()
161 .unwrap_or("CHANGELOG.md");
162 let add = std::process::Command::new("git")
163 .args(["add", rel])
164 .current_dir(repo_path)
165 .output()
166 .map_err(|e| format!("git add 失败: {}", e))?;
167 if !add.status.success() {
168 return Err("git add CHANGELOG.md 失败".into());
169 }
170 let commit = std::process::Command::new("git")
171 .args([
172 "commit",
173 "-m",
174 &format!("chore: add CHANGELOG entry for {}", ver),
175 ])
176 .current_dir(repo_path)
177 .output()
178 .map_err(|e| format!("git commit 失败: {}", e))?;
179 if !commit.status.success() {
180 return Err("git commit CHANGELOG.md 失败".into());
181 }
182 println!("✓ CHANGELOG 修改已提交");
183 Ok(())
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 fn git_init(path: &Path) {
191 std::process::Command::new("git")
192 .args(["init", "-b", "main"])
193 .current_dir(path)
194 .output()
195 .unwrap();
196 std::process::Command::new("git")
197 .args(["config", "user.email", "t@t"])
198 .current_dir(path)
199 .output()
200 .unwrap();
201 std::process::Command::new("git")
202 .args(["config", "user.name", "t"])
203 .current_dir(path)
204 .output()
205 .unwrap();
206 }
207
208 fn git_commit(path: &Path, msg: &str) {
209 std::fs::write(path.join("f"), msg).unwrap();
210 std::process::Command::new("git")
211 .args(["add", "."])
212 .current_dir(path)
213 .output()
214 .unwrap();
215 std::process::Command::new("git")
216 .args(["commit", "-m", msg])
217 .current_dir(path)
218 .output()
219 .unwrap();
220 }
221
222 #[test]
223 fn test_collect_git_log_with_commits() {
224 let d = tempfile::tempdir().unwrap();
225 git_init(d.path());
226 git_commit(d.path(), "feat: add foo");
227 git_commit(d.path(), "fix: bar");
228 let log = collect_git_log(d.path()).unwrap();
229 assert!(log.contains("feat: add foo"));
230 }
231
232 #[test]
233 fn test_collect_git_log_single_commit() {
234 let d = tempfile::tempdir().unwrap();
235 git_init(d.path());
236 git_commit(d.path(), "init");
237 let log = collect_git_log(d.path()).unwrap();
238 assert!(log.contains("init"));
239 }
240
241 #[test]
242 fn test_write_changelog_creates_file() {
243 let d = tempfile::tempdir().unwrap();
244 let path = d.path().join("CHANGELOG.md");
245 write_changelog(&path, "v0.1.0", "### Added\n- new feature").unwrap();
246 let content = std::fs::read_to_string(&path).unwrap();
247 assert!(content.contains("## [0.1.0]"));
248 }
249
250 #[test]
251 fn test_write_changelog_append() {
252 let d = tempfile::tempdir().unwrap();
253 let path = d.path().join("CHANGELOG.md");
254 std::fs::write(&path, "# CHANGELOG\n\n## [0.1.0]\n\nold\n").unwrap();
255 write_changelog(&path, "v0.2.0", "### Added\n- new").unwrap();
256 let content = std::fs::read_to_string(&path).unwrap();
257 assert!(content.contains("## [0.2.0]"));
258 assert!(content.contains("## [0.1.0]"));
259 }
260
261 #[test]
262 fn test_write_changelog_scope_version() {
263 let d = tempfile::tempdir().unwrap();
264 let path = d.path().join("CHANGELOG.md");
265 write_changelog(&path, "cli/v0.1.0", "### Added\n- feature").unwrap();
266 let content = std::fs::read_to_string(&path).unwrap();
267 assert!(content.contains("## [0.1.0]"));
268 assert!(!content.contains("## [cli/v0.1.0]"));
269 }
270
271 #[test]
272 fn test_ensure_changelog_skips_if_exists() {
273 let d = tempfile::tempdir().unwrap();
274 let path = d.path().join("CHANGELOG.md");
275 std::fs::write(&path, "# CHANGELOG\n\n## [0.1.0]\n\ncontent\n").unwrap();
276 assert!(ensure_changelog(d.path(), d.path(), "v0.1.0").is_ok());
277 }
278
279 #[test]
280 fn test_ensure_changelog_no_git_log() {
281 let d = tempfile::tempdir().unwrap();
282 let result = ensure_changelog(d.path(), d.path(), "v0.1.0");
283 assert!(result.is_err());
284 }
285
286 #[test]
287 fn test_ensure_changelog_skips_with_v_prefix() {
288 let d = tempfile::tempdir().unwrap();
289 git_init(d.path());
290 git_commit(d.path(), "init");
291 std::fs::write(
292 d.path().join("CHANGELOG.md"),
293 "# CHANGELOG\n\n## [v0.1.0]\n\ncontent\n",
294 )
295 .unwrap();
296 assert!(ensure_changelog(d.path(), d.path(), "v0.1.0").is_ok());
297 }
298
299 #[test]
300 fn test_latest_tag_empty_repo() {
301 let d = tempfile::tempdir().unwrap();
302 git_init(d.path());
303 git_commit(d.path(), "init");
304 assert!(get_latest_tag(d.path()).is_none());
305 }
306
307 #[test]
308 fn test_latest_tag_with_tags() {
309 let d = tempfile::tempdir().unwrap();
310 git_init(d.path());
311 git_commit(d.path(), "init");
312 std::process::Command::new("git")
313 .args(["tag", "v0.1.0"])
314 .current_dir(d.path())
315 .output()
316 .unwrap();
317 std::process::Command::new("git")
318 .args(["tag", "v0.2.0"])
319 .current_dir(d.path())
320 .output()
321 .unwrap();
322 assert_eq!(get_latest_tag(d.path()).as_deref(), Some("v0.2.0"));
323 }
324
325 #[test]
326 fn test_collect_git_log_with_tags_hides_older() {
327 let d = tempfile::tempdir().unwrap();
328 git_init(d.path());
329 git_commit(d.path(), "A");
330 std::process::Command::new("git")
331 .args(["tag", "v0.1.0"])
332 .current_dir(d.path())
333 .output()
334 .unwrap();
335 git_commit(d.path(), "B");
336 git_commit(d.path(), "C");
337 let log = collect_git_log(d.path()).unwrap();
338 assert!(log.contains("B"));
339 assert!(log.contains("C"));
340 }
341
342 #[test]
343 fn test_ensure_changelog_repo_path_differs_from_scope_dir() {
344 let d = tempfile::tempdir().unwrap();
345 let root_dir = d.path().join("repo");
346 std::fs::create_dir_all(&root_dir).unwrap();
347 git_init(&root_dir);
348 git_commit(&root_dir, "init");
349 let scope_dir = root_dir.join("sub/scope");
350 std::fs::create_dir_all(&scope_dir).unwrap();
351 std::fs::write(
352 scope_dir.join("CHANGELOG.md"),
353 "# CHANGELOG\n\n## [0.1.0]\n\ncontent\n",
354 )
355 .unwrap();
356 assert!(ensure_changelog(&root_dir, &scope_dir, "v0.2.0").is_ok());
357 let scoped_content = std::fs::read_to_string(scope_dir.join("CHANGELOG.md")).unwrap();
358 assert!(scoped_content.contains("[0.2.0]"));
359 assert!(!root_dir.join("CHANGELOG.md").exists());
360 }
361
362 #[test]
363 fn test_ensure_changelog_appends_new_version() {
364 let d = tempfile::tempdir().unwrap();
365 git_init(d.path());
366 git_commit(d.path(), "init");
367 std::fs::write(
368 d.path().join("CHANGELOG.md"),
369 "# CHANGELOG\n\n## [0.1.0]\n\n### Added\n- init\n",
370 )
371 .unwrap();
372 assert!(ensure_changelog(d.path(), d.path(), "v0.2.0").is_ok());
373 let content = std::fs::read_to_string(d.path().join("CHANGELOG.md")).unwrap();
374 assert!(content.contains("[0.1.0]"));
375 assert!(content.contains("[0.2.0]"));
376 }
377}