class KeepAChangelogManager::Changelog

Handles all things related to a CHANGELOG.md

Attributes

repo[RW]

@return KeepAChangelog::Repo the repository

Public Class Methods

new(repo) click to toggle source

@param path String path to CHANGELOG.md (or where it should be)

# File lib/keepachangelog_manager/changelog.rb, line 128
def initialize(repo)
  @repo = repo
end

Public Instance Methods

array_chomp(lines) click to toggle source

Removes empty entries from the end of an array

Works very much like string chomp

@param lines Array<String> @return Array<String>

# File lib/keepachangelog_manager/changelog.rb, line 138
def array_chomp(lines)
  return [] if lines.empty?
  return [] if lines.all?(&:empty?)

  last_entry = lines.rindex { |l| !l.strip.empty? }
  lines[0..last_entry]
end
bare() click to toggle source

content of a fresh (empty) changelog

@return String

# File lib/keepachangelog_manager/changelog.rb, line 180
def bare
  render(ChangeData.bare)
end
create(force = false) click to toggle source

Create an empty CHANGELOG.md for this repo

keepachangelog.com CHANGELOG.md syntax assumes git and github.com so use that to our advantage: assume git repo exists.

@return String

# File lib/keepachangelog_manager/changelog.rb, line 159
def create(force = false)
  return if File.exist(@repo.changelog_path) && !force

  File.open(@repo.changelog_path, 'w') { |file| file.write(bare_changelog) }
end
exist?() click to toggle source

whether the changelog exists in its supposed path

@return boolean

# File lib/keepachangelog_manager/changelog.rb, line 149
def exist?
  File.exist? @repo.changelog_path
end
parse(changelog_text) click to toggle source

Parse an existing changelog (delivered as a string)

@param changelog_text String input @return ChangeData

# File lib/keepachangelog_manager/changelog.rb, line 274
def parse(changelog_text)
  # allowable transitions
  next_states = {
    initial: [:header],
    header: [:header, :release],
    release: [:section],
    section: [:section, :section_body, :release, :links],
    section_body: [:section_body, :release, :links]
  }
  state = :initial

  # signals for transitions, plus capture groups for params
  transitions = {
    header: /^# Change/,
    release: /^## \[([^\]]+)\]( - (.*))?/,
    section: /^### ([A-Z][a-z]+)/,
    links: /^\[Unreleased\]: https:\/\/github\.com\//,
  }

  # parser state data
  header_lines = []
  releases = {}
  last_version = nil
  last_section = nil

  changelog_text.lines.each do |l|
    # find the regex that matches, no transition if no match
    want_state, regex = transitions.find(proc { [nil, nil] }) { |_s, re| re.match(l) }
    good_transition = want_state.nil? || next_states[state].include?(want_state)
    raise ChangelogParseFail, "Changing to #{want_state} from #{state}" unless good_transition

    want_param = regex.match(l) unless regex.nil?

    # do any pre-transition bookkeeping
    case want_state
    when :initial
      raise ChangelogParseFail, "Tried to transition back to initial state"
    when :header
      # nothing to do
    when :release
      version = want_param[1]
      date = want_param[3]
      releases[version] = { sections: {}, date: date }
      last_version = version
      last_section = nil
    when :section
      section_name = want_param[1]
      section = SECTION_NAME.key(section_name)
      raise ChangelogParseFail, "Unknown section name: '#{section_name}'" if section.nil?

      releases[last_version][:sections][section] = []
      last_section = section
    when :links
      break
    else
      # line is just a normal line.  decide where we are and start appending

      case state
      when :header
        header_lines << l.chomp
      when :section
        releases[last_version][:sections][last_section] << l.chomp
      end
    end
    state = want_state unless want_state.nil?
  end

  releases.each do |version, release|
    release[:sections].each do |section, lines|
      releases[version][:sections][section] = array_chomp(lines)
    end
  end

  ChangeData.new(array_chomp(header_lines), releases)
end
parse_file(path) click to toggle source

Parse an existing changelog (by path)

@param changelog_text String path @return ChangeData

# File lib/keepachangelog_manager/changelog.rb, line 266
def parse_file(path)
  parse(File.open(path, "r").read)
end
render(data) click to toggle source

Render the data structure to text

@param data ChangeData @return String

# File lib/keepachangelog_manager/changelog.rb, line 203
def render(data)
  render_lines(data).join("\n") + "\n"
end
render_lines(data) click to toggle source

Render the data structure to an array of strings

Output Structure of a Changelog document:

  • Header (“Change Log” and the text under it, up to the first '## ')

  • Release versions, reverse chronological, starting with 'unreleased'

    • Version (or unreleased) as a link to the diff

    • Date (optional)

    • Sections

      • Added

      • Changed

      • Deprecated

      • Removed

      • Fixed

      • Security

  • Diff URLs

Assumes the “Unreleased” section is fleshed out, even if blank

@param data ChangeData @return Array<String>

# File lib/keepachangelog_manager/changelog.rb, line 227
def render_lines(data)
  # header
  out_lines = ["# Change Log"] + data.header
  out_lines << ""
  out_lines << ""

  # releases
  versions = version_order(data.releases)
  versions.each do |v|
    release = data.releases[v]
    out_lines << "## [#{v}]" + (v == UNRELEASED || release[:date].nil? ? "" : " - #{release[:date]}")

    SECTION_ORDER.each do |s|
      next unless release[:sections].key? s
      next if release[:sections][s].empty? && v != UNRELEASED

      section = release[:sections][s]
      out_lines << "### #{SECTION_NAME[s]}"
      out_lines += section
      out_lines << ""
    end
    out_lines << ""
  end

  # links.  unreleased will come first and may be the only one
  versions.each_with_index do |v, i|
    next_index = i + 1
    this_version = v == UNRELEASED ? "HEAD" : "v#{v}"
    prev_version = next_index < versions.length ? versions[next_index] : DEFAULT_VERSION
    out_lines << "[#{v}]: https://github.com/#{@repo.owner}/#{@repo.name}/compare/v#{prev_version}...#{this_version}"
  end

  out_lines
end
update(**kwargs) click to toggle source

Update a changelog in-place by transforming Unreleased into a release

@see ChangeData.update @return String the new version

# File lib/keepachangelog_manager/changelog.rb, line 169
def update(**kwargs)
  content = File.open(@repo.changelog_path, "r").read
  data = parse(content)
  new_version = data.update(**kwargs)
  File.open(@repo.changelog_path, 'w') { |file| file.write(render(data)) }
  new_version
end
version_order(releases) click to toggle source

Sort section versions into a reverse-chronological array, unreleased first

@param sections Hash the input @return Array<String> the sorted version strings

# File lib/keepachangelog_manager/changelog.rb, line 188
def version_order(releases)
  # order sections in reverse chronological, unreleased on top
  releases.keys.sort do |a, b|
    next  0 if a == b
    next -1 if a == UNRELEASED
    next  1 if b == UNRELEASED

    SemVer.parse(b) <=> SemVer.parse(a)
  end
end