From 31abc46043ca2be7702e7264269b5ec7f689a83e Mon Sep 17 00:00:00 2001 From: "Daniel (dB.) Doubrovkine" Date: Wed, 11 Nov 2026 08:46:40 -0500 Subject: [PATCH] Support categorized Intridea changelogs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/changelog/intridea.md | 8 +++- lib/ruby-gem-release/changelog/intridea.rb | 37 ++++++++++++-- .../changelog/intridea_spec.rb | 48 +++++++++++++------ 4 files changed, 73 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24bd387..f478acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.5.2 (Next) +- [#51](https://github.com/dblock/ruby-gem-release/pull/51): Carry changelog subsections into the next release and remove multiple contribution placeholders - [@dblock](https://github.com/dblock). - Your contribution here. ## 0.5.1 (2026/11/10) diff --git a/docs/changelog/intridea.md b/docs/changelog/intridea.md index 74ca68c..2d15fec 100644 --- a/docs/changelog/intridea.md +++ b/docs/changelog/intridea.md @@ -18,11 +18,15 @@ changelog = RubyGemRelease::Changelog::Intridea.new(path: 'CHANGELOG.md') `placeholder:` defaults to `'- Your contribution here.'`. Set `placeholder: '* Your contribution here.'` for the alternative bullet. It must be a single nonempty line, configured identically for both phases. +## Sections + +When the released section has headings one level below its release heading, `prepare_next!` carries those headings into the new Next section and adds the configured placeholder under each one. With no subsections, it creates the usual single placeholder. Old subsection notes are not copied. + ## Release -During `release:validate`, placeholder-only or structural-only notes fail unless the selected release already has a heading. Invalid headings and missing or duplicate placeholders also fail. +During `release:validate`, placeholder-only or structural-only notes fail unless the selected release already has a heading. Invalid headings and missing placeholders also fail; multiple matching placeholders are allowed. -`changelog.release!(release)` replaces the single Next heading with the [Release](../release.md) version and date, removing exactly one placeholder from that section. Missing or duplicate headings and placeholders, or an existing release heading, fail without writing the file. +`changelog.release!(release)` replaces the Next heading with the [Release](../release.md) version and date, removing every matching placeholder from that section. Missing or duplicate headings, a missing placeholder, or an existing release heading fail without writing the file. Placeholder-only sections and sections containing only headings, comments, or reference definitions also fail without writing. Add actual release notes to the Next section before retrying; older releases do not count. There is no empty-release opt-out. See [Release Notes](base.md#release) for what counts as content. diff --git a/lib/ruby-gem-release/changelog/intridea.rb b/lib/ruby-gem-release/changelog/intridea.rb index 4947893..24590d5 100644 --- a/lib/ruby-gem-release/changelog/intridea.rb +++ b/lib/ruby-gem-release/changelog/intridea.rb @@ -43,8 +43,12 @@ def update_next(version:, released_version:) return if heading_positions(content, /\A\#{1,6} #{Regexp.escape(version)} \(Next\)\z/).one? check_absent(content, UNRELEASED, 'unreleased heading already exists') + write_next_section(content, position, prefix, version, separator) + end - File.write(@path, content.insert(position, ["#{prefix} #{version} (Next)", '', @placeholder, '', ''].join(separator))) + def write_next_section(content, position, prefix, version, separator) + subsections = subsection_headings(content, position, prefix.length) + File.write(@path, content.insert(position, build_next_section(prefix, version, subsections, separator))) end def check_release_absent(content, version) @@ -62,13 +66,38 @@ def release_notes(section, separator, version) def remove_placeholder(section, separator) positions = [] editable_lines(section) { |line, offset| positions << offset if line.chomp == @placeholder } - raise ArgumentError, "expected exactly one placeholder in #{@path}" unless positions.one? + raise ArgumentError, "expected at least one placeholder in #{@path}" if positions.empty? - position = positions.first - section.slice!(position, placeholder_length(section, position, separator)) + positions.reverse_each { |position| section.slice!(position, placeholder_length(section, position, separator)) } section end + def subsection_headings(content, position, level) + section_end = section_end_position(content, position, level) + lines = content.lines + offsets = line_offsets(lines) + subsection_heading_lines(content, level).filter_map do |index| + lines[index].chomp if (position...section_end).cover?(offsets[index]) + end + end + + def subsection_heading_lines(content, level) + document(content).each.filter_map do |node| + node.source_position[:start_line] - 1 if node.type == :heading && node.header_level == level + 1 + end + end + + def build_next_section(prefix, version, subsections, separator) + lines = ["#{prefix} #{version} (Next)"] + if subsections.empty? + lines.push('', @placeholder, '', '') + else + subsections.each { |subsection| lines.push('', subsection, '', @placeholder) } + lines.push('', '') + end + lines.join(separator) + end + def placeholder_length(section, position, separator) line_end = section.index("\n", position) length = (line_end ? line_end + 1 : section.length) - position diff --git a/spec/ruby_gem_release/changelog/intridea_spec.rb b/spec/ruby_gem_release/changelog/intridea_spec.rb index 67285b7..9d4fdd5 100644 --- a/spec/ruby_gem_release/changelog/intridea_spec.rb +++ b/spec/ruby_gem_release/changelog/intridea_spec.rb @@ -24,13 +24,19 @@ expect(changelog.pending?).to be(true) end - it 'rejects missing and duplicate placeholders during validation rather than skipping' do - ['', "- Your contribution here.\n- Your contribution here.\n"].each do |notes| - content = "## 1.0.0 (Next)\n\n#{notes}" - File.write(path, content) - expect { described_class.new(path: path).pending? }.to raise_error(ArgumentError, /placeholder/) - expect(File.read(path)).to eq(content) - end + it 'ignores multiple placeholders during pending-note validation' do + content = "### 1.0.0 (Next)\n\n#### Features\n\n* Your contribution here.\n\n#### Fixes\n\n* Your contribution here.\n" + File.write(path, content) + changelog = described_class.new(path: path, placeholder: '* Your contribution here.') + expect(changelog.pending?).to be(false) + expect(File.read(path)).to eq(content) + end + + it 'rejects a missing placeholder during pending-note validation' do + content = "## 1.0.0 (Next)\n\n- Change.\n" + File.write(path, content) + expect { described_class.new(path: path).pending? }.to raise_error(ArgumentError, /placeholder/) + expect(File.read(path)).to eq(content) end [1, 3, 4, 5, 6].each do |level| @@ -73,6 +79,20 @@ expect(File.read(path)).to include("## 1.0.1 (Next)\n\n- Your contribution here.\n\n## 1.0.0 (2026/11/07)") end + it 'removes multiple placeholders and carries subsections into the next section', :aggregate_failures do + content = "### 1.0.0 (Next)\n\n#### Features\n\n* Feature.\n* Your contribution here.\n\n#### Fixes\n\n* Fix.\n* Your contribution here.\n\n### 0.9.0 (2026/11/06)\n\n* Older change.\n" + File.write(path, content) + updater = described_class.new(path: path, placeholder: '* Your contribution here.') + + updater.release!(release) + updater.release!(release) + expect(File.read(path)).to eq("### 1.0.0 (2026/11/07)\n\n#### Features\n\n* Feature.\n\n#### Fixes\n\n* Fix.\n\n### 0.9.0 (2026/11/06)\n\n* Older change.\n") + + updater.prepare_next!(next_release) + updater.prepare_next!(next_release) + expect(File.read(path)).to eq("### 1.0.1 (Next)\n\n#### Features\n\n* Your contribution here.\n\n#### Fixes\n\n* Your contribution here.\n\n### 1.0.0 (2026/11/07)\n\n#### Features\n\n* Feature.\n\n#### Fixes\n\n* Fix.\n\n### 0.9.0 (2026/11/06)\n\n* Older change.\n") + end + it 'ignores fenced headings and placeholders while releasing an Intridea section' do content = "## 1.0.0 (Next)\n\n```\n## Example\n- Your contribution here.\n```\n\n- Your contribution here.\n" File.write(path, content) @@ -103,13 +123,11 @@ expect(File.binread(path)).to eq(content) end - it 'rejects missing or duplicate placeholders without modifying the file', :aggregate_failures do - ['', "- Your contribution here.\n- Your contribution here.\n"].each do |section| - content = "## 1.0.0 (Next)\n\n#{section}" - File.write(path, content) - expect { described_class.new(path: path).release!(release) } - .to raise_error(ArgumentError, /placeholder/) - expect(File.read(path)).to eq(content) - end + it 'rejects a missing placeholder without modifying the file' do + content = "## 1.0.0 (Next)\n\n- Change.\n" + File.write(path, content) + expect { described_class.new(path: path).release!(release) } + .to raise_error(ArgumentError, /placeholder/) + expect(File.read(path)).to eq(content) end end