Skip to content

Instantly share code, notes, and snippets.

@wojtha
Last active March 9, 2020 12:38
Changelog generator. Install to some $PATH location and make it executable.
#!/usr/bin/env ruby
# Example usage:
#
# script/changelog --init
#
# script/changelog v2.23.0 v2.23.1
#
# script/changelog v2.23.0 HEAD -r v2.23.1
#
# script/changelog master dev
require 'optparse'
require 'ostruct'
require 'yaml'
module ChangelogGenerator
class Generator
BULLET = "-".freeze
LINE_BREAK = "\n".freeze
LINE_PARSER_REGEX = /^(\w+): ?(.+)/
LINE_SKIP_REGEX = /^v\d+\.\d+\.\d+/
FEATURE = 'feat'.freeze
HOTFIX = 'hotfix'.freeze
BUGFIX = 'fix'.freeze
TASK = 'task'.freeze
SPECS = 'spec'.freeze
REFACTOR = 'refactor'.freeze
CONTENT = 'content'.freeze
INTERNAL = 'int'.freeze
UNKNOWN = 'uknown'.freeze
TAGS = {
FEATURE => "Feature",
BUGFIX => "Fix",
HOTFIX => "Hotfix",
TASK => "Task",
CONTENT => "Content",
INTERNAL => "Internal",
SPECS => "Specs",
REFACTOR => "Refactor",
UNKNOWN => "Unknown",
}
.freeze
Line = Struct.new(:tag, :text)
def initialize(project_name:, github_url:, release:, from_ref:, to_ref: 'HEAD', format: 'text')
@project_name = project_name
@github_url = github_url
@release = release
@from_ref = from_ref
@to_ref = to_ref
@formatter = formatter_from_format(format)
@feature_list = []
@bugfix_list = []
@task_list = []
@content_list = []
@other_list = []
end
attr_reader :project_name,
:github_url,
:release,
:from_ref,
:to_ref,
:feature_list,
:bugfix_list,
:task_list,
:content_list,
:other_list
def compare_url
"#{github_url}/compare/#{from_ref}...#{release}"
end
def issues_url(issue_id = nil)
"#{github_url}/issues/#{issue_id}"
end
def call
command = "git log --pretty=format:'%s' --abbrev-commit #{from_ref}..#{to_ref} | sort"
result = `#{command}`
StringIO.new(result).each_line do |raw_line|
next if skip_line?(raw_line)
line = parse_line(raw_line)
case line.tag
when FEATURE
feature_list << line
when BUGFIX, HOTFIX
bugfix_list << line
when TASK
task_list << line
when CONTENT
content_list << line
else
other_list << line
end
end
formatter.new(self).format
end
private
attr_reader :formatter
def formatter_from_format(format)
case format
when 'm', 'md', 'markdown'
MarkdownFormatter
when 's', 'sl', 'slack'
SlackFormatter
else
TextFormatter
end
end
def skip_line?(line)
LINE_SKIP_REGEX.match(line)
end
def parse_line(line)
if matches = LINE_PARSER_REGEX.match(line)
Line.new(matches[1], normalize_text(matches[2]))
else
Line.new(UNKNOWN, line.strip)
end
end
def normalize_text(text)
capitalize_first_letter(text.strip)
end
def capitalize_first_letter(text)
"#{text[0].upcase}#{text[1..-1]}"
end
class TextFormatter
def initialize(changelog)
@changelog = changelog
end
def format
out = render_header(changelog)
out += render_section('Features', render_list(changelog.feature_list)) if changelog.feature_list.any?
out += render_section('Bug fixes', render_list(changelog.bugfix_list)) if changelog.bugfix_list.any?
out += render_section('Tasks', render_list(changelog.task_list)) if changelog.task_list.any?
out += render_section('Content', render_list(changelog.content_list)) if changelog.content_list.any?
out += render_section('Other', render_tagged_list(changelog.other_list)) if changelog.other_list.any?
out
end
protected
attr_reader :changelog
def render_header(changelog)
<<~TEXT
CHANGELOG
#{changelog.release} - (#{changelog.compare_url}) (#{render_date})
TEXT
end
def render_section(label, body)
<<~TEXT
#{label}
#{body}
TEXT
end
def render_list(lines)
if lines.any?
lines.map { |line| "#{BULLET} #{line.text}" }.join(LINE_BREAK)
end
end
def render_tagged_list(lines)
if lines.any?
lines.map { |line| "#{BULLET} #{tag(line.tag)} #{line.text}" }.join(LINE_BREAK)
end
end
def render_date
Time.now.strftime("%Y-%m-%d") # rubocop:disable Rails/TimeZone
end
def sort_lines(lines)
lines.sort_by { |line| [line.tag, line.text] }
end
def tag(tag)
"[#{tag}]"
end
def pretty_tag(tag)
"[#{TAGS[tag] || tag}]"
end
end
class SlackFormatter < TextFormatter
protected
def render_header(changelog)
<<~SLACK
:rocket: *New #{changelog.project_name} release #{changelog.release} (#{render_date})*
Changes since #{changelog.from_ref}:
SLACK
end
def render_section(label, body)
<<~SLACK
*#{label}*
#{body}
SLACK
end
end
class MarkdownFormatter < TextFormatter
protected
def render_header(changelog)
<<~MARKDOWN
# Changelog
## [#{changelog.release}](#{changelog.compare_url}) (#{render_date})
MARKDOWN
end
def render_section(label, body)
<<~MARKDOWN
#### #{label}
#{body}
MARKDOWN
end
def render_list(lines)
if lines.any?
lines.map { |line| "#{BULLET} #{decorate_issue_numbers(line.text)}" }.join(LINE_BREAK)
end
end
def render_tagged_list(lines)
if lines.any?
lines.map { |line| "#{BULLET} #{tag(line.tag)} #{decorate_issue_numbers(line.text)}" }.join(LINE_BREAK)
end
end
def decorate_issue_numbers(text)
text.gsub(/#(\d+)/, "[#\\1](#{changelog.issues_url}\\1)")
end
end
end
class Application
CONFIG_PATH = '.changelog.yml'.freeze
DUMMY_PROJECT_NAME = 'Dummy project name'.freeze
DUMMY_GITHUB_URL = 'https://github.com/InlineManual/dummy'.freeze
DEFAULT_FORMAT = 'text'.freeze
DEFAULT_OPTIONS = {
format: DEFAULT_FORMAT,
}.freeze
CONFIG_TEMPLATE = { project_name: DUMMY_PROJECT_NAME, github_url: DUMMY_GITHUB_URL, format: DEFAULT_FORMAT }.freeze
ConfigFileMissingError = Class.new(StandardError)
def run(argv)
cli_options = parse_cli_options(argv)
config_options = parse_config_file
options = OpenStruct.new(DEFAULT_OPTIONS.merge(**config_options.to_h, **cli_options.to_h))
generator = Generator.new(
project_name: options.project_name,
github_url: options.github_url,
release: options.release,
format: options.format,
from_ref: options.from,
to_ref: options.to
)
output = generator.call
$stdout.puts output
end
private
def parse_config_file
File.exist?(CONFIG_PATH) or raise ConfigFileMissingError.new("#{CONFIG_PATH} file is missing. Please run changelog --init first.")
yaml = YAML.load_file(CONFIG_PATH)
options = OpenStruct.new
options.project_name = yaml.fetch(:project_name)
options.github_url = yaml.fetch(:github_url)
options.format = yaml.fetch(:format, DEFAULT_FORMAT)
warn "WARNING! Option :project_name in #{CONFIG_PATH} is set to dummy value" if options.project_name == DUMMY_PROJECT_NAME
warn "WARNING! Option :github_url in #{CONFIG_PATH} is set to dummy value" if options.github_url == DUMMY_GITHUB_URL
options
end
def parse_cli_options(argv)
options = OpenStruct.new
opt_parser = OptionParser.new do |opts|
opts.banner = <<~BANNER
Usage: changelog [options] [FROM] [TO]
BANNER
opts.separator ""
opts.separator "Aggregation options:"
opts.on("-f", "--format FORMAT", String, "Define format (text, markdown, slack)") do |format|
options.format = format
end
opts.on("-r", "--release NAME", String, "Release name, TO reference is used by default") do |release|
options.release = release
end
opts.separator ""
opts.separator "Common options:"
opts.on_tail("-h", "--help", "Show this message") do
$stdout.puts opts.inspect
exit
end
opts.separator ""
opts.separator "Generator:"
opts.on_tail("--init", "Initialize .changelog.yml file") do
File.write(CONFIG_PATH, CONFIG_TEMPLATE.to_yaml)
$stdout.puts "Writing default config to: #{CONFIG_PATH}"
$stdout.puts CONFIG_TEMPLATE.to_yaml
exit
end
end
opt_parser.parse!(argv)
options.from = argv[0]
options.to = argv[1] || 'HEAD'
options.release ||= options.to
options
end
end
end
app = ChangelogGenerator::Application.new
app.run(ARGV)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment