class KeepAChangelogManager::Changelog
Handles all things related to a CHANGELOG.md
Attributes
@return KeepAChangelog::Repo the repository
Public Class Methods
@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
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
content of a fresh (empty) changelog
@return String
# File lib/keepachangelog_manager/changelog.rb, line 180 def bare render(ChangeData.bare) end
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
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 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 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 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 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 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
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