From a4d2441b0a99d6fa42a62a9dc49b7fd1b371896c Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Wed, 26 Nov 2025 21:30:34 +0100 Subject: [PATCH] changelog: added changelog --- Cargo.toml | 3 + src/changelog.rs | 343 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 20 ++- 3 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 src/changelog.rs diff --git a/Cargo.toml b/Cargo.toml index 88380d9..396cb97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,9 +12,12 @@ serde = { version = "1.0.228", features = ["derive"] } csv = "1.3.0" reqwest = { version = "0.12.9", features = ["blocking", "json"] } git2 = "0.19.0" +regex = "1" +chrono = "0.4" tokio = { version = "1.41.1", features = ["full"] } sha2 = "0.10.8" hex = "0.4.3" +tracing = "0.1.42" [dev-dependencies] tempfile = "3.10.1" diff --git a/src/changelog.rs b/src/changelog.rs new file mode 100644 index 0000000..5f311b2 --- /dev/null +++ b/src/changelog.rs @@ -0,0 +1,343 @@ +use std::fs::File; +use std::io::{self, BufRead, Read, Write}; +use std::path::Path; +use regex::Regex; +use chrono::Local; +use git2::{Repository, Sort, Oid}; + +/* +* Automatically generate a changelog entry from a commit history and previous changelog +*/ +pub fn generate_entry(changelog_file: &str, cwd: Option<&Path>, user_version: Option<&str>) -> Result<(), Box> { + let changelog_path = if let Some(path) = cwd { + path.join(changelog_file) + } else { + Path::new(changelog_file).to_path_buf() + }; + + // 1. Parse existing changelog to get current (old) version + let (package, old_version, series) = parse_changelog_header(&changelog_path)?; + println!("Found package: {}, version: {}", package, old_version); + + // 2. Open git repo + let repo_path = if let Some(path) = cwd { + path.to_path_buf() + } else { + std::env::current_dir()? + }; + let repo = Repository::open(&repo_path)?; + + // 3. Find commits since the tag corresponding to the version + // We assume the tag format is "debian/" or just "" or "v" + // But usually for Debian packages it might be "debian/" + // Let's try to find a tag that matches. + + // For now, let's assume the tag is simply the version string, or debian/version + // If we can't find a tag, we might have to error out or take all commits? + // Let's try to find the tag. + + // Actually, usually we want to generate an entry for a NEW version based on changes since the OLD version. + // The `changelog_file` passed here is the EXISTING changelog. + // So `version` is the PREVIOUS version. + // We want to find commits since `version`. + + let commits = get_commits_since_version(&repo, &old_version)?; + + if commits.is_empty() { + println!("No new commits found since version {}", old_version); + // return Ok(()); + } + + // 4. Format the new entry + // We don't know the NEW version yet, so we might use "UNRELEASED" or increment the version. + // For now, let's use "UNRELEASED" and let the user edit it, or maybe we can try to increment it. + // The requirement says "Automatically generate a changelog entry". + // Usually tools like `dch` add a new entry. + + // Let's create a new entry with "UNRELEASED" distribution and incremented version? + // Or just append to the top. + + // Let's assume we want to output the new entry to stdout or prepend to file? + // The function signature returns (), so maybe it modifies the file. + + // Let's prepend to the file. + + // Compute new version if needed, or use user-supplied one + let new_version = if let Some(version) = user_version { + version.to_string() + } else { + // TODO: Pass these flags from CLI + compute_new_version(&old_version, false, false, false) + }; + + let new_entry = format_entry(&package, &new_version, &series, &commits, "Test Test", "test@test"); + + prepend_to_file(&changelog_path, &new_entry)?; + + println!("Added new changelog entry to {}", changelog_path.display()); + + Ok(()) +} + +/* +* Compute the next (most probable) version number of a package, from old version and +* conditions on changes (is ubuntu upload, is a no change rebuild, is a non-maintainer upload) +*/ +fn compute_new_version(old_version: &str, is_ubuntu: bool, is_rebuild: bool, is_nmu: bool) -> String { + if is_ubuntu { + return increment_suffix(old_version, "ubuntu"); + } + if is_rebuild { + return increment_suffix(old_version, "build"); + } + if is_nmu { + if !old_version.contains('-') { + return increment_suffix(old_version, "+nmu"); + } else { + return increment_suffix(old_version, "."); + } + } + increment_suffix(old_version, "") +} + +/* +* Increment a version number by 1, for a given suffix +*/ +fn increment_suffix(version: &str, suffix: &str) -> String { + // If suffix is empty, we just look for trailing digits + // If suffix is not empty, we look for suffix followed by digits + + let pattern = if suffix.is_empty() { + r"(\d+)$".to_string() + } else { + format!(r"{}(\d+)$", regex::escape(suffix)) + }; + + let re = Regex::new(&pattern).unwrap(); + + if let Some(caps) = re.captures(version) { + let num_str = caps.get(1).unwrap().as_str(); + let num: u32 = num_str.parse().unwrap(); + let range = caps.get(1).unwrap().range(); + let mut new_ver = version.to_string(); + new_ver.replace_range(range, &(num + 1).to_string()); + return new_ver; + } + + // If pattern not found, append suffix + "1" + // But if suffix is empty, we default to appending "-1" (standard Debian revision start) + if suffix.is_empty() { + format!("{}-1", version) + } else { + format!("{}{}{}", version, suffix, 1) + } +} + +/* +* Parse a changelog file first entry header, to obtain (package, version, series) +*/ +fn parse_changelog_header(path: &Path) -> Result<(String, String, String), Box> { + let file = File::open(path)?; + let mut reader = io::BufReader::new(file); + let mut first_line = String::new(); + reader.read_line(&mut first_line)?; + + // Format: package (version) series; urgency=urgency + let re = Regex::new(r"^(\S+) \(([^)]+)\) (.*); .*")?; + if let Some(caps) = re.captures(&first_line) { + let package = caps.get(1).map_or("", |m| m.as_str()).to_string(); + let version = caps.get(2).map_or("", |m| m.as_str()).to_string(); + let series = caps.get(3).map_or("", |m| m.as_str()).to_string(); + Ok((package, version, series)) + } else { + Err(format!("Invalid changelog header format in {}", path.display()).into()) + } +} + +/* +* Obtain all commit messages as a list since a tagged version in a git repository +*/ +fn get_commits_since_version(repo: &Repository, version: &str) -> Result, Box> { + let mut revwalk = repo.revwalk()?; + revwalk.set_sorting(Sort::TIME)?; + + // We are looking for debian version numbers + // Depending on the repo, they could be tagged with the following formats + let tag_names = vec![ + format!("debian/{}", version), + format!("debian/v{}", version), + format!("v{}", version), + version.to_string(), + ]; + + // Look for the different tag formats in the git repository + let mut tag_id: Option = None; + for tag_name in tag_names { + if let Ok(r) = repo.revparse_single(&tag_name) { + // If we found either a commit or a tag matching the name, + // we have found our version + if let Some(commit) = r.as_commit() { + tag_id = Some(commit.id()); + break; + } else if let Some(tag) = r.as_tag() { + tag_id = Some(tag.target_id()); + break; + } + } + } + + if let Some(tid) = tag_id { + revwalk.push_head()?; + revwalk.hide(tid)?; + } else { + // Tag not found... + // We return an empty list. + // TODO: Can we do better? + return Ok(Vec::new()); + } + + // Add all commit messages from that tagged version to head in the list + let mut commits = Vec::new(); + for id in revwalk { + let id = id?; + let commit = repo.find_commit(id)?; + let message = commit.message().unwrap_or("").trim(); + let summary = message.lines().next().unwrap_or("").to_string(); + if !summary.is_empty() { + commits.push(summary); + } + } + + return Ok(commits); +} + +/* +* Create a changelog entry from information, i.e. format that information +* into a changelog entry +*/ +fn format_entry(package: &str, version: &str, series: &str, changes: &[String], maintainer_name: &str, maintainer_email: &str) -> String { + let mut entry = String::new(); + + // Header: package, version and distribution series + entry.push_str(&format!("{} ({}) {}; urgency=medium\n\n", package, version, series)); + + // Changes + for change in changes { + entry.push_str(&format!(" * {}\n", change)); + } + if changes.is_empty() { + entry.push_str(" * \n"); + } + + // Footer: date, maintainer + let date = Local::now().format("%a, %d %b %Y %H:%M:%S %z").to_string(); + entry.push_str(&format!("\n -- {} <{}> {}\n\n", maintainer_name, maintainer_email, date)); + + return entry; +} + +/* +* Add content to the beginning of a file +*/ +fn prepend_to_file(path: &Path, content: &str) -> Result<(), Box> { + let mut file = File::open(path)?; + let mut existing_content = String::new(); + file.read_to_string(&mut existing_content)?; + + let mut file = File::create(path)?; + file.write_all(content.as_bytes())?; + file.write_all(existing_content.as_bytes())?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + use tempfile::TempDir; + + fn setup_repo(dir: &Path) { + Command::new("git").arg("init").current_dir(dir).output().unwrap(); + Command::new("git").arg("config").arg("user.email").arg("you@example.com").current_dir(dir).output().unwrap(); + Command::new("git").arg("config").arg("user.name").arg("Your Name").current_dir(dir).output().unwrap(); + } + + fn commit(dir: &Path, message: &str) { + Command::new("git").arg("commit").arg("--allow-empty").arg("-m").arg(message).current_dir(dir).output().unwrap(); + } + + fn tag(dir: &Path, name: &str) { + Command::new("git").arg("tag").arg(name).current_dir(dir).output().unwrap(); + } + + #[test] + fn test_generate_entry() { + let temp_dir = TempDir::new().unwrap(); + let repo_dir = temp_dir.path(); + setup_repo(repo_dir); + + // Create initial changelog + let changelog_path = repo_dir.join("debian/changelog"); + std::fs::create_dir_all(repo_dir.join("debian")).unwrap(); + let initial_content = "mypackage (0.1.0-1) unstable; urgency=medium\n\n * Initial release\n\n -- Maintainer Wed, 01 Jan 2020 00:00:00 +0000\n"; + std::fs::write(&changelog_path, initial_content).unwrap(); + + // Commit and tag + Command::new("git").arg("add").arg(".").current_dir(repo_dir).output().unwrap(); + commit(repo_dir, "Initial commit"); + tag(repo_dir, "debian/0.1.0-1"); + + // New commits + commit(repo_dir, "Fix bug A"); + commit(repo_dir, "Add feature B"); + + // Generate entry + generate_entry("debian/changelog", Some(repo_dir), None).unwrap(); + + // Verify content + let content = std::fs::read_to_string(&changelog_path).unwrap(); + println!("{}", content); + + assert!(content.contains("mypackage (0.1.0-2) unstable; urgency=medium")); + assert!(content.contains("* Fix bug A")); + assert!(content.contains("* Add feature B")); + // Should still contain old content + assert!(content.contains("mypackage (0.1.0-1) unstable; urgency=medium")); + } + + #[test] + fn test_compute_new_version() { + // Debian upload + assert_eq!(compute_new_version("15.2.0-8", false, false, false), "15.2.0-9"); + assert_eq!(compute_new_version("15.2.0-9", false, false, false), "15.2.0-10"); + + // Ubuntu upload + assert_eq!(compute_new_version("15.2.0-9", true, false, false), "15.2.0-9ubuntu1"); + assert_eq!(compute_new_version("15.2.0-9ubuntu1", true, false, false), "15.2.0-9ubuntu2"); + + // No change rebuild + assert_eq!(compute_new_version("15.2.0-9", false, true, false), "15.2.0-9build1"); + assert_eq!(compute_new_version("15.2.0-9build1", false, true, false), "15.2.0-9build2"); + + // Rebuild of Ubuntu version + assert_eq!(compute_new_version("15.2.0-9ubuntu1", false, true, false), "15.2.0-9ubuntu1build1"); + + // NMU + // Native + assert_eq!(compute_new_version("1.0", false, false, true), "1.0+nmu1"); + assert_eq!(compute_new_version("1.0+nmu1", false, false, true), "1.0+nmu2"); + + // Non-native + assert_eq!(compute_new_version("1.0-1", false, false, true), "1.0-1.1"); + assert_eq!(compute_new_version("1.0-1.1", false, false, true), "1.0-1.2"); + + // NMU of NMU? + assert_eq!(compute_new_version("1.0-1.2", false, false, true), "1.0-1.3"); + + // Native package uploads + assert_eq!(compute_new_version("1.0", false, false, false), "1.1"); + assert_eq!(compute_new_version("1.0.5", false, false, false), "1.0.6"); + assert_eq!(compute_new_version("20241126", false, false, false), "20241127"); + } +} diff --git a/src/main.rs b/src/main.rs index e4fc72a..96b3ed6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,9 @@ use cmd_lib::{run_cmd}; mod get; use get::get; +mod changelog; +use changelog::generate_entry; + fn main() { let rt = tokio::runtime::Runtime::new().unwrap(); let matches = command!() @@ -39,7 +42,7 @@ fn main() { .arg(arg!( "Target package")) ) .subcommand( - Command::new("changelog") + Command::new("chlog") .about("Auto-generate changelog entry, editing it, committing it afterwards") .arg(arg!(-s --series "Target distribution series").required(false)) .arg(arg!(--backport "This changelog is for a backport entry").required(false)) @@ -61,7 +64,20 @@ fn main() { std::process::exit(1); } }, - Some(("changelog", _sub_matches)) => { + Some(("chlog", sub_matches)) => { + let cwd = std::env::current_dir().unwrap(); + let version = sub_matches.get_one::("version").map(|s| s.as_str()); + + if let Err(e) = generate_entry("debian/changelog", Some(&cwd), version) { + eprintln!("Error: {}", e); + std::process::exit(1); + } + + let editor = std::env::var("EDITOR").unwrap(); + let _status = std::process::Command::new(editor) + .current_dir(&cwd) + .args(&["debian/changelog"]) + .status(); }, _ => unreachable!("Exhausted list of subcommands and subcommand_required prevents `None`"), }