changelog: added changelog
This commit is contained in:
@@ -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"
|
||||
|
||||
343
src/changelog.rs
Normal file
343
src/changelog.rs
Normal file
@@ -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<dyn std::error::Error>> {
|
||||
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/<version>" or just "<version>" or "v<version>"
|
||||
// But usually for Debian packages it might be "debian/<version>"
|
||||
// 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<dyn std::error::Error>> {
|
||||
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<Vec<String>, Box<dyn std::error::Error>> {
|
||||
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<Oid> = 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<dyn std::error::Error>> {
|
||||
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 <m@e.com> 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");
|
||||
}
|
||||
}
|
||||
20
src/main.rs
20
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!(<package> "Target package"))
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("changelog")
|
||||
Command::new("chlog")
|
||||
.about("Auto-generate changelog entry, editing it, committing it afterwards")
|
||||
.arg(arg!(-s --series <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::<String>("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`"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user