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 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    // 有 tag 时隐藏 tag 之前的提交,无 tag 时步行所有(等价 --all)
22    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
47/// 获取仓库中最新版本 tag(按版本排序取第一个)。
48fn 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
56/// 调用 LLM 生成 CHANGELOG 条目。
57///
58/// 通过 quanttide-agent 调用 LLM。如果 LLM 未配置(LLM_API_KEY 为空),
59/// 返回提示文本让用户手动发送给 AI(不阻塞发布流程)。
60fn 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
102/// 将生成的 CHANGELOG 条目写入文件。
103pub 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    // 不使用 chrono,用 git 的方式获取当前日期
122    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
137/// 如果 CHANGELOG.md 不包含当前版本,则自动生成并写入。
138/// `repo_path` 用于 git 操作,`scope_dir` 用于文件操作。
139pub 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    // 提交 CHANGELOG 修改,确保后续标签包含它
155    let ver = super::util::normalize_version(version);
156    // changelog_path 相对于 repo_path 的路径
157    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}