diff --git a/cargo-soothfast/src/changelog_config.rs b/cargo-soothfast/src/changelog_config.rs index 3cf98ff..a377d60 100644 --- a/cargo-soothfast/src/changelog_config.rs +++ b/cargo-soothfast/src/changelog_config.rs @@ -18,6 +18,9 @@ pub struct ChangelogConfig { pub features: Option, /// Packages `-p` defaults to. pub packages: Vec, + /// Author whose commits are left out of the draft, for a repo that + /// renamed the bot's `bot-slug`. + pub bot_author: Option, } enum Table { @@ -83,6 +86,7 @@ fn set(cfg: &mut ChangelogConfig, key: &str, value: TomlValue) -> Result<(), Str match (key, value) { ("features", TomlValue::Str(s)) => cfg.features = Some(s), ("packages", TomlValue::StrArray(v)) => cfg.packages = v, + ("bot-author", TomlValue::Str(s)) => cfg.bot_author = Some(s), (k, _) => return Err(format!("unknown or mistyped `{k}` under [changelog]")), } Ok(()) @@ -135,6 +139,12 @@ mod tests { assert!(format!("{:?}", cfg.icons).contains('A')); } + #[test] + fn reads_a_renamed_bot_author() { + let cfg = parse("[changelog]\nbot-author = \"acme-bot[bot]\"\n").unwrap(); + assert_eq!(cfg.bot_author.as_deref(), Some("acme-bot[bot]")); + } + #[test] fn an_unknown_key_under_changelog_is_an_error() { let e = parse("[changelog]\nnope = 1\n").unwrap_err(); diff --git a/cargo-soothfast/src/main.rs b/cargo-soothfast/src/main.rs index dc4e7ca..0f66be4 100644 --- a/cargo-soothfast/src/main.rs +++ b/cargo-soothfast/src/main.rs @@ -91,6 +91,7 @@ commands: python publishes via `uv build` + `uv publish`) report render -p PKG [--out DIR] [--baseline NAME] [--features F] report changelog -p PKG [-p PKG ...] [--against-ref REF] [--features F] + [--bot-author NAME] (no --against-ref: first release, lists the surface it ships) mcp -p PKG [--baseline NAME] [--features F] (agent-facing server on stdio) diff --git a/cargo-soothfast/src/report.rs b/cargo-soothfast/src/report.rs index 838655e..160e9e3 100644 --- a/cargo-soothfast/src/report.rs +++ b/cargo-soothfast/src/report.rs @@ -31,6 +31,7 @@ struct ReportArgs { out: Option, against_ref: Option, features: Option, + bot_author: Option, } fn parse(args: &[String]) -> Result { @@ -40,6 +41,7 @@ fn parse(args: &[String]) -> Result { out: None, against_ref: None, features: None, + bot_author: None, }; let mut it = args.iter(); while let Some(arg) = it.next() { @@ -51,6 +53,7 @@ fn parse(args: &[String]) -> Result { "--out" => a.out = it.next().map(PathBuf::from), "--against-ref" => a.against_ref = it.next().cloned(), "--features" => a.features = it.next().cloned(), + "--bot-author" => a.bot_author = it.next().cloned(), other => return Err(format!("unknown report arg {other:?}")), } } @@ -301,8 +304,9 @@ fn changelog_cmd(args: &[String]) -> i32 { }, None => None, }; + let bot_author = resolve_bot_author(a.bot_author.take(), cfg.bot_author.as_deref()); let changes = match &a.against_ref { - Some(refname) => match merged_changes(refname) { + Some(refname) => match merged_changes(refname, &bot_author) { Ok(c) => c, Err(e) => return err(&e), }, @@ -343,13 +347,39 @@ fn resolve_features( cli.or_else(|| changelog.or(gate).map(str::to_string)) } -/// Conventional-commit subjects merged since `refname`, newest first. Read -/// from git rather than a forge API: every merge lands as a squash whose -/// subject already carries its pull request number. -fn merged_changes(refname: &str) -> Result, String> { +/// The author `report changelog` leaves out: the command line first, then +/// `[changelog] bot-author`, then the slug the action ships with. +fn resolve_bot_author(cli: Option, config: Option<&str>) -> String { + cli.unwrap_or_else(|| config.unwrap_or(DEFAULT_BOT_AUTHOR).to_string()) +} + +/// Author of the soothfast bot's own commits, as `action/land.sh` commits +/// them under the default `bot-slug`. +const DEFAULT_BOT_AUTHOR: &str = "soothfast-bot[bot]"; + +/// Separates author from subject in the log format below. A unit separator +/// occurs in neither field. +const LOG_FIELD_SEP: char = '\u{1f}'; + +/// Subjects from `%an%x1f%s` log lines, minus the ones `bot_author` wrote. +/// That one author and no other: `dependabot[bot]` writes real changelog +/// entries, and a rule matching every `[bot]` would delete them all. +fn subjects_excluding_author(log: &str, bot_author: &str) -> Vec { + log.lines() + .filter_map(|line| line.split_once(LOG_FIELD_SEP)) + .filter(|(author, _)| *author != bot_author) + .map(|(_, subject)| subject.to_string()) + .collect() +} + +/// Conventional-commit subjects merged since `refname`, newest first, with +/// the soothfast bot's own regeneration commits left out. Read from the log +/// rather than a forge API: every merge lands as a squash whose subject +/// already carries its pull request number, and whose author survives it. +fn merged_changes(refname: &str, bot_author: &str) -> Result, String> { let range = format!("{refname}..HEAD"); - let log = invoke::git(&["log", "--format=%s", &range]).map_err(|e| e.to_string())?; - let subjects: Vec = log.lines().map(str::to_string).collect(); + let log = invoke::git(&["log", "--format=%an%x1f%s", &range]).map_err(|e| e.to_string())?; + let subjects = subjects_excluding_author(&log, bot_author); let unparsed = changelog::unparsed_subjects(&subjects); if !unparsed.is_empty() { println!( @@ -509,7 +539,10 @@ fn err(msg: &str) -> i32 { #[cfg(test)] mod tests { - use super::{changelog_already_cut, merge_changelog, resolve_features}; + use super::{ + DEFAULT_BOT_AUTHOR, changelog_already_cut, merge_changelog, resolve_bot_author, + resolve_features, subjects_excluding_author, + }; #[test] fn a_release_pr_still_diffing_against_the_previous_tag_is_already_cut() { @@ -684,4 +717,61 @@ mod tests { ); assert_eq!(resolve_features(None, None, None), None); } + + #[test] + fn the_bot_s_own_regeneration_commits_are_left_out() { + let log = "soothfast-bot[bot]\u{1f}chore: regenerate soothfast outputs (#474)\n\ + Harvey Tseng\u{1f}feat: read gate features from soothfast.toml (#475)\n"; + assert_eq!( + subjects_excluding_author(log, DEFAULT_BOT_AUTHOR), + ["feat: read gate features from soothfast.toml (#475)"] + ); + } + + #[test] + fn dependabot_commits_survive_the_filter() { + let log = "dependabot[bot]\u{1f}chore(deps): bump serde from 1.0.2 to 1.0.3 (#476)\n\ + soothfast-bot[bot]\u{1f}docs: regenerate CHANGELOG.md (#477)\n"; + assert_eq!( + subjects_excluding_author(log, DEFAULT_BOT_AUTHOR), + ["chore(deps): bump serde from 1.0.2 to 1.0.3 (#476)"] + ); + } + + #[test] + fn a_renamed_bot_author_filters_in_place_of_the_default() { + let log = "acme-bot[bot]\u{1f}chore: regenerate soothfast outputs (#12)\n\ + soothfast-bot[bot]\u{1f}feat: ship a thing (#13)\n"; + assert_eq!( + subjects_excluding_author(log, "acme-bot[bot]"), + ["feat: ship a thing (#13)"] + ); + } + + #[test] + fn an_explicit_bot_author_flag_beats_the_table() { + assert_eq!( + resolve_bot_author(Some("cli[bot]".into()), Some("toml[bot]")), + "cli[bot]" + ); + } + + #[test] + fn the_table_fills_in_for_a_repo_that_renamed_the_slug() { + assert_eq!(resolve_bot_author(None, Some("toml[bot]")), "toml[bot]"); + } + + #[test] + fn the_shipped_slug_is_the_last_resort() { + assert_eq!(resolve_bot_author(None, None), DEFAULT_BOT_AUTHOR); + } + + #[test] + fn a_subject_the_bot_did_not_write_is_kept_verbatim() { + let log = "Harvey Tseng\u{1f}fix(server): drop a cache (#478)\n"; + assert_eq!( + subjects_excluding_author(log, DEFAULT_BOT_AUTHOR), + ["fix(server): drop a cache (#478)"] + ); + } } diff --git a/docs/reports.md b/docs/reports.md index 4112ec2..45d4c34 100644 --- a/docs/reports.md +++ b/docs/reports.md @@ -89,8 +89,8 @@ a prefix on the entry (`fix(server): drop a cache` lists as `server: Drop a cache`), a `deps` scope files the entry under Dependencies whatever its type, and a subject marked `!` leads the section list under Breaking changes. That comes from `git log`, not a forge API, since every merge lands as a squash -whose subject already holds the number. Release commits, and the bots that -regenerate derived artifacts, are dropped so a release does not list its own +whose subject already holds the number. Release commits are dropped, and so +is everything the soothfast bot authored, so a release does not list its own paperwork; any other subject that is not a conventional commit is printed when the draft is written, so an omission is visible rather than silent. @@ -111,6 +111,7 @@ every invocation: [changelog] features = "full" packages = ["core", "server"] +bot-author = "soothfast-bot[bot]" ``` `features` decides what the API surface diff can see: a public item behind a @@ -119,6 +120,14 @@ the diff with nothing to say it did. It falls back to `[gate] features` when absent. `packages` supplies `-p` when the command line gives none. An explicit `--features` or `-p` still wins over either. +`bot-author` names the commit author whose entries are dropped: the bot that +lands regenerated artifacts, whose commits are paperwork rather than changes. +It defaults to `soothfast-bot[bot]`, what the action commits as, so only a +repo that renamed `bot-slug` has to set it; `--bot-author NAME` overrides it +for one run. That one author is dropped and no other. Every other bot stays, +`dependabot[bot]` included: it writes the whole Dependencies section, and a +rule matching any `[bot]` author would delete it without a word. + Below a rule sit the derived sections, evidence rather than narrative: the public API diff against `v1.0`, and the measured movement past gate thresholds. A section with nothing to report is omitted rather than shipped