qtcloud_devops_cli/release/
changelog.rs1use std::path::Path;
2use std::process::Command;
3
4use quanttide_agent::{llm::CompleteOptions, Message, Settings, LLM};
5
6pub fn collect_git_log(repo_path: &Path) -> Result<String, String> {
8 let tag = get_latest_tag(repo_path);
9 let range = match tag {
10 Some(ref t) => format!("{}..HEAD", t),
11 None => "HEAD".to_string(),
12 };
13 let out = Command::new("git")
14 .args(["log", "--oneline", &range])
15 .current_dir(repo_path)
16 .output()
17 .map_err(|e| format!("git log 失败: {}", e))?;
18 if !out.status.success() {
19 let msg = String::from_utf8_lossy(&out.stderr).trim().to_string();
20 return Err(if msg.is_empty() {
21 "git log 失败".into()
22 } else {
23 msg
24 });
25 }
26 let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
27 if stdout.is_empty() {
28 return Err("没有新的提交记录".into());
29 }
30 Ok(stdout)
31}
32
33fn get_latest_tag(repo_path: &Path) -> Option<String> {
35 let out = std::process::Command::new("git")
36 .args(["tag", "--list"])
37 .current_dir(repo_path)
38 .output()
39 .ok()?;
40 if !out.status.success() {
41 return None;
42 }
43 let stdout = String::from_utf8_lossy(&out.stdout);
44 let mut tags: Vec<&str> = stdout.lines().collect();
45 tags.sort_by(|a, b| b.cmp(a));
46 tags.first().map(|s| s.to_string())
47}
48
49fn llm_changelog(git_log: &str, version: &str) -> Result<String, String> {
54 let hint = format!(
55 "根据以下 git 提交记录,为版本 {} 生成 CHANGELOG 条目。\n\n\
56 要求:\n\
57 1. 按 Added / Changed / Fixed / Removed 分类\n\
58 2. 同类提交合并为概括性条目,不要逐条罗列\n\
59 3. 用中文描述\n\
60 4. 每类不超过 5 条\n\
61 5. 仅输出内容,不要版本头部和日期\n\n\
62 提交记录:\n{git_log}",
63 version
64 );
65
66 let settings = Settings::from_env();
67 if settings.llm_api_key.is_empty() {
68 return Err(format!(
69 "LLM 未配置(LLM_API_KEY 未设置)。请将以下文本发送给 AI 生成 CHANGELOG:\n\n{hint}"
70 ));
71 }
72
73 let llm = LLM::new(
74 &settings.llm_model,
75 &settings.llm_base_url,
76 &settings.llm_api_key,
77 );
78 let messages = vec![
79 Message::new(
80 "system",
81 "你是一个帮助生成 CHANGELOG 的助手。\
82 将 git 提交记录按 Added / Changed / Fixed / Removed 分类\
83 并合并为概括性条目,用中文描述。不要逐条罗列 commit message。\
84 只输出分类后的条目内容,不要输出版本头部(## 行)和日期。",
85 ),
86 Message::new("user", &hint),
87 ];
88 let response = llm
89 .complete(&messages, CompleteOptions::default())
90 .map_err(|e| format!("LLM 调用失败: {}\n\n请手动生成 CHANGELOG:\n\n{hint}", e))?;
91
92 Ok(response.content.trim().to_string())
93}
94
95pub fn write_changelog(path: &Path, version: &str, content: &str) -> Result<(), String> {
97 let ver = super::util::normalize_version(version);
98 let entry = format!("\n## [{}] - {}\n\n{}\n", ver, today(), content);
99 let mut existing = if path.exists() {
100 std::fs::read_to_string(path).map_err(|e| format!("读取 CHANGELOG.md 失败: {}", e))?
101 } else {
102 "# CHANGELOG\n".to_string()
103 };
104 if let Some(pos) = existing.find("\n## ") {
105 existing.insert_str(pos, &entry);
106 } else {
107 existing.push_str(&entry);
108 }
109 std::fs::write(path, &existing).map_err(|e| format!("写入 CHANGELOG.md 失败: {}", e))?;
110 Ok(())
111}
112
113fn today() -> String {
114 let zoned = jiff::Zoned::now();
115 zoned.strftime("%Y-%m-%d").to_string()
116}
117
118pub fn ensure_changelog(repo_path: &Path, scope_dir: &Path, version: &str) -> Result<(), String> {
121 let changelog_path = scope_dir.join("CHANGELOG.md");
122 if changelog_path.exists() {
123 let content = std::fs::read_to_string(&changelog_path)
124 .map_err(|e| format!("读取 CHANGELOG.md 失败: {}", e))?;
125 let ver = super::util::normalize_version(version);
126 if content.contains(&format!("[{}]", ver)) || content.contains(&format!("[v{}]", ver)) {
127 return Ok(());
128 }
129 }
130 let git_log = collect_git_log(repo_path)?;
131 let changelog_content = llm_changelog(&git_log, version)?;
132 write_changelog(&changelog_path, version, &changelog_content)?;
133 println!("✓ CHANGELOG.md 已更新(版本 {})", version);
134
135 let ver = super::util::normalize_version(version);
137 let rel = changelog_path
139 .strip_prefix(repo_path)
140 .unwrap_or(&changelog_path)
141 .to_str()
142 .unwrap_or("CHANGELOG.md");
143 let add = std::process::Command::new("git")
144 .args(["add", rel])
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 fn git_init(path: &std::path::Path) {
170 let repo = git2::Repository::init(path).unwrap();
171 let mut cfg = repo.config().unwrap();
172 cfg.set_str("user.email", "t@t").unwrap();
173 cfg.set_str("user.name", "t").unwrap();
174 }
175
176 fn git_commit(path: &std::path::Path, msg: &str) {
177 std::fs::write(path.join("f"), msg).unwrap();
178 let repo = git2::Repository::open(path).unwrap();
179 let mut index = repo.index().unwrap();
180 index.add_path(std::path::Path::new("f")).unwrap();
181 index.write().unwrap();
182 let tree_id = index.write_tree().unwrap();
183 let tree = repo.find_tree(tree_id).unwrap();
184 let sig = repo.signature().unwrap();
185 let parent = repo.head().and_then(|h| h.peel_to_commit()).ok();
186 let parents: Vec<&git2::Commit> = parent.iter().collect();
187 repo.commit(Some("HEAD"), &sig, &sig, msg, &tree, &parents).unwrap();
188 }
189
190 use super::*;
191
192 #[test]
193 fn test_collect_git_log_with_commits() {
194 let d = tempfile::tempdir().unwrap();
195 git_init(d.path());
196 git_commit(d.path(), "feat: add foo");
197 git_commit(d.path(), "fix: bar");
198 let log = collect_git_log(d.path()).unwrap();
199 assert!(log.contains("feat: add foo"));
200 }
201
202 #[test]
203 fn test_collect_git_log_single_commit() {
204 let d = tempfile::tempdir().unwrap();
205 git_init(d.path());
206 git_commit(d.path(), "init");
207 let log = collect_git_log(d.path()).unwrap();
208 assert!(log.contains("init"));
209 }
210
211 #[test]
212 fn test_write_changelog_creates_file() {
213 let d = tempfile::tempdir().unwrap();
214 let path = d.path().join("CHANGELOG.md");
215 write_changelog(&path, "v0.1.0", "### Added\n- new feature").unwrap();
216 let content = std::fs::read_to_string(&path).unwrap();
217 assert!(content.contains("## [0.1.0]"));
218 }
219
220 #[test]
221 fn test_write_changelog_append() {
222 let d = tempfile::tempdir().unwrap();
223 let path = d.path().join("CHANGELOG.md");
224 std::fs::write(&path, "# CHANGELOG\n\n## [0.1.0]\n\nold\n").unwrap();
225 write_changelog(&path, "v0.2.0", "### Added\n- new").unwrap();
226 let content = std::fs::read_to_string(&path).unwrap();
227 assert!(content.contains("## [0.2.0]"));
228 assert!(content.contains("## [0.1.0]"));
229 }
230
231 #[test]
232 fn test_write_changelog_scope_version() {
233 let d = tempfile::tempdir().unwrap();
234 let path = d.path().join("CHANGELOG.md");
235 write_changelog(&path, "cli/v0.1.0", "### Added\n- feature").unwrap();
236 let content = std::fs::read_to_string(&path).unwrap();
237 assert!(content.contains("## [0.1.0]"));
238 assert!(!content.contains("## [cli/v0.1.0]"));
239 }
240
241 #[test]
242 fn test_ensure_changelog_skips_if_exists() {
243 let d = tempfile::tempdir().unwrap();
244 let path = d.path().join("CHANGELOG.md");
245 std::fs::write(&path, "# CHANGELOG\n\n## [0.1.0]\n\ncontent\n").unwrap();
246 assert!(ensure_changelog(d.path(), d.path(), "v0.1.0").is_ok());
247 }
248
249 #[test]
250 fn test_ensure_changelog_no_git_log() {
251 let d = tempfile::tempdir().unwrap();
252 let result = ensure_changelog(d.path(), d.path(), "v0.1.0");
253 assert!(result.is_err());
254 }
255
256 #[test]
257 fn test_ensure_changelog_skips_with_v_prefix() {
258 let d = tempfile::tempdir().unwrap();
259 git_init(d.path());
260 git_commit(d.path(), "init");
261 std::fs::write(
262 d.path().join("CHANGELOG.md"),
263 "# CHANGELOG\n\n## [v0.1.0]\n\ncontent\n",
264 )
265 .unwrap();
266 assert!(ensure_changelog(d.path(), d.path(), "v0.1.0").is_ok());
267 }
268
269 #[test]
270 fn test_latest_tag_empty_repo() {
271 let d = tempfile::tempdir().unwrap();
272 git_init(d.path());
273 git_commit(d.path(), "init");
274 assert!(get_latest_tag(d.path()).is_none());
275 }
276
277 #[test]
278 fn test_latest_tag_with_tags() {
279 let d = tempfile::tempdir().unwrap();
280 git_init(d.path());
281 git_commit(d.path(), "init");
282 std::process::Command::new("git")
283 .args(["tag", "v0.1.0"])
284 .current_dir(d.path())
285 .output()
286 .unwrap();
287 std::process::Command::new("git")
288 .args(["tag", "v0.2.0"])
289 .current_dir(d.path())
290 .output()
291 .unwrap();
292 assert_eq!(get_latest_tag(d.path()).as_deref(), Some("v0.2.0"));
293 }
294
295 #[test]
296 fn test_collect_git_log_with_tags_hides_older() {
297 let d = tempfile::tempdir().unwrap();
298 git_init(d.path());
299 git_commit(d.path(), "A");
300 std::process::Command::new("git")
301 .args(["tag", "v0.1.0"])
302 .current_dir(d.path())
303 .output()
304 .unwrap();
305 git_commit(d.path(), "B");
306 git_commit(d.path(), "C");
307 let log = collect_git_log(d.path()).unwrap();
308 assert!(log.contains("B"));
309 assert!(log.contains("C"));
310 }
311
312 #[test]
313 fn test_ensure_changelog_repo_path_differs_from_scope_dir() {
314 let d = tempfile::tempdir().unwrap();
315 let root_dir = d.path().join("repo");
316 std::fs::create_dir_all(&root_dir).unwrap();
317 git_init(&root_dir);
318 git_commit(&root_dir, "init");
319 let scope_dir = root_dir.join("sub/scope");
320 std::fs::create_dir_all(&scope_dir).unwrap();
321 std::fs::write(
322 scope_dir.join("CHANGELOG.md"),
323 "# CHANGELOG\n\n## [0.1.0]\n\ncontent\n",
324 )
325 .unwrap();
326 assert!(ensure_changelog(&root_dir, &scope_dir, "v0.2.0").is_ok());
327 let scoped_content = std::fs::read_to_string(scope_dir.join("CHANGELOG.md")).unwrap();
328 assert!(scoped_content.contains("[0.2.0]"));
329 assert!(!root_dir.join("CHANGELOG.md").exists());
330 }
331
332 #[test]
333 fn test_ensure_changelog_appends_new_version() {
334 let d = tempfile::tempdir().unwrap();
335 git_init(d.path());
336 git_commit(d.path(), "init");
337 std::fs::write(
338 d.path().join("CHANGELOG.md"),
339 "# CHANGELOG\n\n## [0.1.0]\n\n### Added\n- init\n",
340 )
341 .unwrap();
342 assert!(ensure_changelog(d.path(), d.path(), "v0.2.0").is_ok());
343 let content = std::fs::read_to_string(d.path().join("CHANGELOG.md")).unwrap();
344 assert!(content.contains("[0.1.0]"));
345 assert!(content.contains("[0.2.0]"));
346 }
347}