From 3172f3eeaec15c50fe48043ac52029a43e6290e1 Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 27 Aug 2026 18:20:53 +0100 Subject: [PATCH 1/3] fix some issues with the repair command. --- CHANGELOG.md | 8 ++++ Directory.Build.props | 2 +- src/Ritten/Changelogs/ChangelogEntry.cs | 47 +++++++++++++++++++ src/Ritten/Changelogs/Steps/DecideVersion.cs | 2 +- .../Changelogs/Steps/PrepareChangelog.cs | 24 +++++++--- src/Ritten/Releases/ReleaseState.cs | 5 ++ .../Changelogs/ChangelogEntryTests.cs | 40 ++++++++++++++++ .../Changelogs/DecideVersionTests.cs | 22 ++++++++- .../Changelogs/PrepareChangelogTests.cs | 19 ++++++++ 9 files changed, 159 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c787a..938424a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.1] - 2026-08-27 + +### Fixed + +- **`prepare` no longer describes one version twice.** A version whose packages had shipped in part was read as one that had never shipped at all, so `prepare` staged it a second time. +- **Notes written after a `prepare` join the entry they belong to.** Preparing a version the changelog already has an entry for now adds the unreleased notes to that entry rather than giving the version a second heading. + ## [0.10.0] - 2026-08-23 ### Added @@ -209,6 +216,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Initial release. +[0.10.1]: https://github.com/ritten-org/Ritten/compare/v0.10.0...v0.10.1 [0.10.0]: https://github.com/ritten-org/Ritten/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/ritten-org/Ritten/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/ritten-org/Ritten/compare/v0.7.0...v0.8.0 diff --git a/Directory.Build.props b/Directory.Build.props index 6540020..05150e2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Copyright © 2026 Tom Wolfe - 0.10.0 + 0.10.1 $(Version.Split('-')[0]) $(Version.Split('-')[0]) icon.png diff --git a/src/Ritten/Changelogs/ChangelogEntry.cs b/src/Ritten/Changelogs/ChangelogEntry.cs index 48437d8..55d1fcc 100644 --- a/src/Ritten/Changelogs/ChangelogEntry.cs +++ b/src/Ritten/Changelogs/ChangelogEntry.cs @@ -58,6 +58,32 @@ public record ChangelogEntry /// public IReadOnlyCollection Security { get; init; } = []; + /// + /// Takes on another entry's notes, keeping this entry's own first. + /// + /// The entry whose notes are being taken on. + public ChangelogEntry Merge(ChangelogEntry other) + { + var merged = this with + { + Preamble = Join(Preamble, other.Preamble), + Added = [.. Added, .. other.Added], + Changed = [.. Changed, .. other.Changed], + Deprecated = [.. Deprecated, .. other.Deprecated], + Removed = [.. Removed, .. other.Removed], + Fixed = [.. Fixed, .. other.Fixed], + Security = [.. Security, .. other.Security] + }; + + // Where the sections account for both bodies in full, dropping the bodies lets the entry + // render as one set of sections rather than two — the same fixes under a single "Fixed". + // Where they don't, the body is the only view that holds a heading the format doesn't + // define, so the bodies are joined verbatim and an untidy repeat is the lesser loss. + return IsStructured && other.IsStructured + ? merged with { Body = "" } + : merged with { Body = Join(Body, other.Body) }; + } + /// /// What these notes do to what already shipped. /// @@ -81,4 +107,25 @@ public record ChangelogEntry && Security.Count == 0 && string.IsNullOrWhiteSpace(Preamble) && string.IsNullOrWhiteSpace(Body); + + /// + /// Whether the sections account for the whole body, so nothing is lost by rebuilding it from them. + /// + /// + /// Compared line by line rather than as text, because the two differences a rebuild does make — + /// the order the author wrote their sections in, and the blank lines between them — are + /// formatting the format itself prescribes. A heading the structured view can't hold, and the + /// notes under it, are lines that go missing, which is the loss this is looking for. + /// + private bool IsStructured => Lines(ChangelogRenderer.RenderEntry(this with { Body = "" })).SetEquals(Lines(Body)); + + private static HashSet Lines(string text) => + [.. text.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0)]; + + private static string Join(string first, string second) => (first.Trim('\n'), second.Trim('\n')) switch + { + ("", var only) => only, + (var only, "") => only, + var (a, b) => $"{a}\n\n{b}" + }; } diff --git a/src/Ritten/Changelogs/Steps/DecideVersion.cs b/src/Ritten/Changelogs/Steps/DecideVersion.cs index 6af0bf7..5a8dc09 100644 --- a/src/Ritten/Changelogs/Steps/DecideVersion.cs +++ b/src/Ritten/Changelogs/Steps/DecideVersion.cs @@ -37,7 +37,7 @@ public async Task> Run(Project project, Changelog ch // An unpublished version is already the next one: the project was bumped and never shipped, // so preparing again would skip a version nobody released. - if (!release.Published) + if (!release.AnyPublished) { log.Detail($"Preparing {project.Version}, which the project already declares and hasn't published."); return new PreparedRelease(project.Version, false, "already declared, not yet published"); diff --git a/src/Ritten/Changelogs/Steps/PrepareChangelog.cs b/src/Ritten/Changelogs/Steps/PrepareChangelog.cs index 96fb025..772e7af 100644 --- a/src/Ritten/Changelogs/Steps/PrepareChangelog.cs +++ b/src/Ritten/Changelogs/Steps/PrepareChangelog.cs @@ -36,6 +36,7 @@ TimeProvider time /// A token to monitor for cancellation requests. public async Task Run(Changelog changelog, Project project, PreparedRelease release, CancellationToken ct = default) { + var hasChangelog = changelog.Entry(release.Version) is not null; var rolled = WithRelease(changelog, release, out var entry); var linked = WithLinks(rolled, project); @@ -54,6 +55,7 @@ public async Task Run(Changelog changelog, Project project, Prepared log.Detail(entry switch { null => $"Updated the version links in {options.Value.File}.", + _ when hasChangelog => $"Added the unreleased notes to the entry {release.Version} already had in {options.Value.File}.", _ => $"Rolled the unreleased notes into {release.Version} in {options.Value.File}." }); @@ -61,8 +63,9 @@ public async Task Run(Changelog changelog, Project project, Prepared } /// - /// Dates the unreleased entry and gives it its version, leaving every other entry alone. - /// The body renders verbatim, so nobody's prose is reformatted on the way through. + /// Dates the unreleased entry and gives it its version — joining the entry that version already + /// has, when it has one — and leaves every other entry alone. The body renders verbatim, so + /// nobody's prose is reformatted on the way through. /// private Changelog WithRelease(Changelog changelog, PreparedRelease release, out ChangelogEntry? rolled) { @@ -80,13 +83,20 @@ private Changelog WithRelease(Changelog changelog, PreparedRelease release, out return changelog; } - rolled = unreleased with + var today = DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime); + var entries = changelog.Entries.ToList(); + + // The version may already have an entry: prepared once before and not yet shipped. + // Its notes join the ones already under that heading. + if (changelog.Entry(release.Version) is { } existing) { - Version = release.Version, - Date = DateOnly.FromDateTime(time.GetUtcNow().UtcDateTime) - }; + rolled = existing.Merge(unreleased) with { Date = today }; + entries[entries.IndexOf(existing)] = rolled; + entries.Remove(unreleased); + return changelog with { Entries = entries }; + } - var entries = changelog.Entries.ToList(); + rolled = unreleased with { Version = release.Version, Date = today }; entries[entries.IndexOf(unreleased)] = rolled; return changelog with { Entries = entries }; } diff --git a/src/Ritten/Releases/ReleaseState.cs b/src/Ritten/Releases/ReleaseState.cs index ac7c295..d206985 100644 --- a/src/Ritten/Releases/ReleaseState.cs +++ b/src/Ritten/Releases/ReleaseState.cs @@ -16,6 +16,11 @@ public sealed record ReleaseState(bool Published, bool LatestInLine, NuGetVersio /// public bool OnLatestLine => LatestVersionInLine == LatestVersion; + /// + /// Whether any one package in the build has this version on the feed. + /// + public bool AnyPublished => Published || Packages.Any(p => p.Published); + /// /// Where each shipped package stands individually. /// diff --git a/tests/Ritten.Tests/Changelogs/ChangelogEntryTests.cs b/tests/Ritten.Tests/Changelogs/ChangelogEntryTests.cs index 8573d2c..3df9771 100644 --- a/tests/Ritten.Tests/Changelogs/ChangelogEntryTests.cs +++ b/tests/Ritten.Tests/Changelogs/ChangelogEntryTests.cs @@ -44,4 +44,44 @@ public void EmptyNotesReleaseNothing() { new ChangelogEntry().ReleaseKind.ShouldBe(ReleaseKind.None); } + + [Fact] + public void MergingGathersTheNotesUnderOneSetOfSections() + { + var existing = ChangelogParser.ParseEntry("### Added\n\n- A shipped thing.\n\n### Fixed\n\n- An old fix."); + var later = ChangelogParser.ParseEntry("### Fixed\n\n- A later fix.\n\n### Added\n\n- A later thing."); + + var merged = existing.Merge(later); + + merged.Added.ShouldBe(["A shipped thing.", "A later thing."]); + merged.Fixed.ShouldBe(["An old fix.", "A later fix."]); + + // The sections held every line of both bodies, so the entry renders as one set of them — + // the order the author wrote their own sections in is not a reason to keep two. + ChangelogRenderer.RenderEntry(merged).ShouldBe("### Added\n\n- A shipped thing.\n- A later thing.\n\n### Fixed\n\n- An old fix.\n- A later fix."); + } + + [Fact] + public void MergingKeepsBothBodiesWhenTheSectionsCannotHoldThem() + { + // "Notes" is not one of the six, so it lives on the body alone: rebuilding from the + // sections would drop the heading and everything under it. + var existing = ChangelogParser.ParseEntry("### Notes\n\n- Something the format has no section for."); + var later = ChangelogParser.ParseEntry("### Fixed\n\n- A later fix."); + + var rendered = ChangelogRenderer.RenderEntry(existing.Merge(later)); + + rendered.ShouldContain("### Notes"); + rendered.ShouldContain("- Something the format has no section for."); + rendered.ShouldContain("- A later fix."); + } + + [Fact] + public void MergingIntoAnEntryWithNothingInItKeepsTheNotesArriving() + { + var merged = new ChangelogEntry().Merge(ChangelogParser.ParseEntry("### Fixed\n\n- A later fix.")); + + merged.Fixed.ShouldBe(["A later fix."]); + ChangelogRenderer.RenderEntry(merged).ShouldBe("### Fixed\n\n- A later fix."); + } } diff --git a/tests/Ritten.Tests/Changelogs/DecideVersionTests.cs b/tests/Ritten.Tests/Changelogs/DecideVersionTests.cs index 3f64cbb..303bcf5 100644 --- a/tests/Ritten.Tests/Changelogs/DecideVersionTests.cs +++ b/tests/Ritten.Tests/Changelogs/DecideVersionTests.cs @@ -39,6 +39,17 @@ public async Task KeepsAVersionThatIsDeclaredButNotPublished() await _prompt.DidNotReceive().Confirm(Arg.Any(), Arg.Any()); } + [Fact] + public async Task MovesPastAVersionThatShippedInPart() + { + // One package of 1.2.0 reached the feed and another didn't: the version is out in the + // world, so what's left of it is deploy's to finish and prepare must move past it. + var result = await Step().Run(Project("1.2.0"), Changelog(new ChangelogEntry { Fixed = ["A thing."] }), PartlyPublished(), TestContext.Current.CancellationToken); + + result.Value.ShouldNotBeNull().Version.ShouldBe(NuGetVersion.Parse("1.2.1")); + result.Value.Bumped.ShouldBeTrue(); + } + [Fact] public async Task DerivesFromTheUnreleasedNotesAndConfirms() { @@ -87,7 +98,16 @@ private static Changelog Changelog(ChangelogEntry? unreleased = null) => new() { Entries = unreleased is null ? [] : [unreleased] }; private static ReleaseState Published() => - new(Published: true, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0")); + new(Published: true, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0")) + { + Packages = [new PackagePublication("My.Package", true)] + }; + + private static ReleaseState PartlyPublished() => + new(Published: false, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0")) + { + Packages = [new PackagePublication("My.Package", true), new PackagePublication("My.Package.Core", false)] + }; private static ReleaseState Unpublished() => new(Published: false, LatestInLine: true, NuGetVersion.Parse("1.2.0"), NuGetVersion.Parse("1.2.0")); diff --git a/tests/Ritten.Tests/Changelogs/PrepareChangelogTests.cs b/tests/Ritten.Tests/Changelogs/PrepareChangelogTests.cs index 123961d..349e1ca 100644 --- a/tests/Ritten.Tests/Changelogs/PrepareChangelogTests.cs +++ b/tests/Ritten.Tests/Changelogs/PrepareChangelogTests.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.RegularExpressions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Microsoft.Extensions.Time.Testing; @@ -101,6 +102,24 @@ public async Task WritesNothingWhenTheChangelogAlreadySaysIt() file.DidNotReceive().OpenWrite(); } + [Fact] + public async Task MergesIntoTheEntryTheVersionAlreadyHas() + { + // The version was prepared before and never shipped, so notes written since belong in the + // entry it already has: a second heading for the same version describes it twice. + SetChangelog(Existing); + + var result = await Step().Run(Changelog(Existing), Project(), Prepared("1.2.0", bumped: false), TestContext.Current.CancellationToken); + + result.IsFailure.ShouldBeFalse(); + var written = Written(); + Regex.Matches(written, "^## \\[1\\.2\\.0\\]", RegexOptions.Multiline).Count.ShouldBe(1); + written.ShouldContain("## [1.2.0] - 2026-08-21"); + written.ShouldContain("- **A new thing.** It does something."); + written.ShouldContain("- **An old thing.** It was broken."); + written.ShouldNotContain("## [Unreleased]"); + } + [Fact] public async Task LeavesTheEntriesAloneWhenThereIsNothingUnreleased() { From d2cd99795024d1d58777c59cb70622da6024fb07 Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 27 Aug 2026 18:36:16 +0100 Subject: [PATCH 2/3] add autocomplete --- CHANGELOG.md | 6 +++++- Directory.Packages.props | 5 +++-- src/Ritten/Program.cs | 6 +++++- src/Ritten/Ritten.csproj | 1 + 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 938424a..6041464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.10.1] - 2026-08-27 +## [0.11.0] - 2026-08-27 + +### Added + +- Added shell auto-completion support through the `completion` command. ### Fixed diff --git a/Directory.Packages.props b/Directory.Packages.props index ddc28b9..96e5f84 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,8 +22,9 @@ - + + - + \ No newline at end of file diff --git a/src/Ritten/Program.cs b/src/Ritten/Program.cs index 2706f7b..9c67aa1 100644 --- a/src/Ritten/Program.cs +++ b/src/Ritten/Program.cs @@ -6,6 +6,8 @@ using Ritten.Workflows.DotNet; using Ritten.Workflows.DotNetPackage; using Ritten.Workflows.DotNetTool; +using Wolfe.CommandLine; +using Wolfe.CommandLine.Completions; var builder = WorkflowApplication.CreateBuilder(); @@ -23,7 +25,9 @@ return ExitCode.ConfigurationError; } -var root = new RootCommand("The Ritten build workflow."); +var root = new RootCommand("The Ritten build workflow.") + .AddCompletions("ritten"); await root.InstallRitten(built.Value); +await CompletionAutoInstall.Run("ritten", args); return await root.Parse(args).InvokeAsync(); diff --git a/src/Ritten/Ritten.csproj b/src/Ritten/Ritten.csproj index 22be2e6..41da659 100644 --- a/src/Ritten/Ritten.csproj +++ b/src/Ritten/Ritten.csproj @@ -37,6 +37,7 @@ + From 280efa36e7ba964e701bd62e96e35f5a4c79ab2d Mon Sep 17 00:00:00 2001 From: Tom Wolfe Date: Thu, 27 Aug 2026 18:37:52 +0100 Subject: [PATCH 3/3] bump version. --- CHANGELOG.md | 2 +- Directory.Build.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6041464..09b182c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -220,7 +220,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), Initial release. -[0.10.1]: https://github.com/ritten-org/Ritten/compare/v0.10.0...v0.10.1 +[0.11.0]: https://github.com/ritten-org/Ritten/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/ritten-org/Ritten/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/ritten-org/Ritten/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/ritten-org/Ritten/compare/v0.7.0...v0.8.0 diff --git a/Directory.Build.props b/Directory.Build.props index 05150e2..b50cdca 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Copyright © 2026 Tom Wolfe - 0.10.1 + 0.11.0 $(Version.Split('-')[0]) $(Version.Split('-')[0]) icon.png