Skip to main content

qtcloud_devops_cli/release/
changelog.rs

1use std::path::Path;
2use std::process::Command;
3
4use quanttide_agent::{llm::CompleteOptions, Message, Settings, LLM};
5
6/// 收集上个 tag 到当前 HEAD 之间的 git 提交记录。
7pub 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
33/// 获取仓库中最新版本 tag(按版本排序取第一个)。
34fn 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
49/// 调用 LLM 生成 CHANGELOG 条目。
50///
51/// 通过 quanttide-agent 调用 LLM。如果 LLM 未配置(LLM_API_KEY 为空),
52/// 返回提示文本让用户手动发送给 AI(不阻塞发布流程)。
53fn 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
95/// 将生成的 CHANGELOG 条目写入文件。
96pub 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
118/// 如果 CHANGELOG.md 不包含当前版本,则自动生成并写入。
119/// `repo_path` 用于 git 操作,`scope_dir` 用于文件操作。
120pub 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    // 提交 CHANGELOG 修改,确保后续标签包含它
136    let ver = super::util::normalize_version(version);
137    // changelog_path 相对于 repo_path 的路径
138    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}