# frozen_string_literal: true module BuildTools # A utility class for managing a CHANGELOG file. class Changelog # @option options [String] :path Path to the CHANGELOG. def initialize(options) @path = options.fetch(:path) end # @return [String] attr_reader :path # Inserts an "Unreleased Changes" section at the top of the CHANGELOG. def add_unreleased_changes_section lines = [] lines << "Unreleased Changes\n" lines << "------------------\n" lines << "\n" write(lines.join + read) end # @option options [required, String] :version The version number to apply # to the unreleased changes section at the top of the CHANGELOG. # Should be formatted like "1.0.0". # # @option options [optional, String] :date Defaults to the # current date. # def version_unreleased_changes(options) version = options.fetch(:version) date = options.fetch(:date, Time.now.strftime('%Y-%m-%d')) lines = read.lines.to_a lines[0] = "#{version} (#{date})\n" write(lines.join) add_unreleased_changes_section end # @option options [required, Symbol] :type # Must be one of the following: # # * :feature # * :issue # # @option options [required, String] :text # A description of the change. This should be formatted as markdown. # # @option options [optional, Integer] :issue A related GitHub issue number. # # @option options [optional, Integer] :pull A related GitHub pull request number. # def add_entry(options) add_unreleased_changes_section unless read.lines.to_a[0].strip == 'Unreleased Changes' lines = read.lines.to_a lines = lines[0..2] + Entry.new(options).lines + lines[3..-1] write(lines.join) end # @return [ChangeSet, nil] def unreleased_changes most_recent_changes = change_sets.first if most_recent_changes.name == 'Unreleased Changes' most_recent_changes else add_unreleased_changes_section unreleased_changes end end # @return [Enumerable] def change_sets Parser.new.parse(read) end private def read if File.exist?(path) File.open(path, 'r', encoding: 'UTF-8') { |f| f.read } else '' end end def write(changelog) File.open(path, 'w', encoding: 'UTF-8') { |f| f.write(changelog) } end class ChangeSet # @option options [required, String] :name # @option options [required, Array] :entries def initialize(options) @name = options.fetch(:name) if @name.match(/^(\d+\.\d+\.\d+) \(\d{4}-\d{2}-\d{2}\)$/) @version = $1 @date = $2 else @version = nil @date = nil end @entries = options.fetch(:entries) end # @return [String] attr_reader :name # @return [String, nil] attr_reader :version # @return [String, nil] attr_reader :date # @return [Array] attr_reader :entries def to_str "#{@name}\n#{'-' * @name.length}\n\n#{entries.map(&:to_s).join}" end alias to_s to_str end class Entry def initialize(options) @type = options[:type] case @type when :feature, :issue else raise ArgumentError, "expected :feature or :issue" end @text = options.fetch(:text).strip @issue = options.fetch(:issue, nil) @pull = options.fetch(:pull, nil) end # @return [Symbol] attr_reader :type # @return [String] attr_reader :text # @return [Integer, nil] attr_reader :issue # @return [Integer, nil] attr_reader :pull # @return [Boolean] def point? case @type when :feature then true when :issue then false else raise "unhandled entry type `#{@type.inspect}'" end end def patch? !point? end def lines lines = @text.lines.each.with_index.map do |line, n| if n == 0 "* #{uc_first(@type)} - #{line}" else " #{line}" end end lines << "\n" lines << issue_reference if issue lines << pull_reference if pull lines << "\n" end def to_str lines.join end alias to_s to_str private def uc_first(str) str[0].upcase + str[1..-1] end def issue_reference "\n See related [GitHub issue ##{issue}](https://github.com/aws/aws-sdk-ruby/issues/#{issue}).\n" end def pull_reference "\n See related [GitHub pull request ##{pull}](https://github.com/aws/aws-sdk-ruby/issues/#{pull}).\n" end end class Parser def parse(changelog) Enumerator.new do |y| changes = nil changelog.lines.each do |line| if line.match(/^\d+\.\d+\.\d+/) || line.match(/^Unreleased Changes$/) y.yield(close_change_set(changes)) if changes changes = { name: line.strip, entries: [], current_entry: nil } elsif line.match(/^-+$/) # diving line of dashes, e.g. "------------------" next elsif line.match(/^\* (Feature|Issue) - (.*\n)$/) # start of the next entry close_entry(changes) changes[:current_entry] = { type: $1 == 'Feature' ? :feature : :issue, text: [$2] } elsif line.match(/^ See related \[GitHub (issue|pull request) #(\d+)/) # Reference to github issue or pull request reference = $1 == 'issue' ? :issue : :pull changes[:current_entry][reference] = $2.to_i else changes[:current_entry][:text] << line.lstrip if changes[:current_entry] end end y.yield(close_change_set(changes)) end end def close_change_set(changes) close_entry(changes) ChangeSet.new(changes) end def close_entry(changes) if changes[:current_entry] entry = changes.delete(:current_entry) entry[:text] = entry[:text].join changes[:entries] << Entry.new(entry) end end end end end