Skip to content

Instantly share code, notes, and snippets.

@ttscoff
Last active January 4, 2025 07:21
Show Gist options
  • Save ttscoff/17fbce4f229609082b45681bba7a9967 to your computer and use it in GitHub Desktop.
Save ttscoff/17fbce4f229609082b45681bba7a9967 to your computer and use it in GitHub Desktop.
Generate release notes from git commit messages
#!/usr/bin/ruby -W1
# frozen_string_literal: true
require 'optparse'
require 'shellwords'
# A script to automate changelog generation from Git commit messages
#
# For use with a git-flow workflow, it will take changes from the last tagged
# release where commit messages contain NEW, FIXED, CHANGED, and IMPROVED
# keywords and sort and fromat them into a Markdown release note list.
#
# The script takes version information from a version.rb files, the macOS
# command agvtool (bases the product name on the first matching Xcode Info.plist
# found) or from a plain text VERSION file.
#
# Format commit messages with markers
#
# Commit message
# - NEW: New feature description
# - FIXED: Fix description
#
# OR with @format:
#
# Commit message
# @new New feature
# @changed application feature change
# @breaking breaking change
#
# Single-line commit messages formatted like a line from the notes above will
# also be recognized.
# Constant CL_STRINGS: Strings for section titles and keywords
# :title is what is displayed in output
# :rx is the regex search to match each type, case sensitive
CL_STRINGS = {
changed: { title: 'CHANGED', rx: '(CHANGED?|BREAK(ING)?)' },
new: { title: 'NEW', rx: '(NEW|ADD(ED)?)' },
improved: { title: 'IMPROVED', rx: '(IMP(ROV(MENT|ED)?)?|UPD(ATED?)?)' },
fixed: { title: 'FIXED', rx: 'FIX(ED)?' },
deprecated: { title: 'REMOVED', rx: '(DEP(RECATED)?|REM(OVED?)?)' }
}.freeze
##
## @brief String helpers
##
class ::String
# Destructive version of #cap_first
def cap_first!
replace cap_first
end
# Capitalize first letter
def cap_first
sub(/^([a-z])(.*)$/) do
m = Regexp.last_match
m[1].upcase << m[2]
end
end
# Remove marker strings like @fix and "- NEW:"
#
# @return [String] cleaned string
#
def clean_entry
rx = CL_STRINGS.map do |_, v|
"(?:@#{v[:rx].downcase}|- #{v[:rx]}:)"
end
rx = format('(?:%<rx>s)', rx: rx.join('|'))
sub(/^#{rx} */, '').cap_first
end
end
##
## @brief Changelog Item
##
class Change < Hash
attr_accessor :githash, :date, :changes
def initialize(githash, date, changes)
@githash = githash
@date = date
@changes = changes
super()
end
end
##
## @brief Changelog Set
##
class ChangeSet
attr_accessor :changed, :new, :improved, :fixed
def initialize
@changed = []
@new = []
@improved = []
@fixed = []
end
def add(type, change)
case type
when :changed
@changed.push change
when :new
@new.push change
when :improved
@improved.push change
when :fixed
@fixed.push change
end
end
def get(type)
case type
when :changed
return @changed
when :new
return @new
when :improved
return @improved
when :fixed
return @fixed
end
nil
end
end
# Array of changes
class ChangeLog < Array
def initialize(*change_array)
super(change_array)
end
# returns array of :changes values
def changes
res = []
each do |v|
chgs = v.changes.strip.split(/\n/)
chgs.delete_if { |e| e.strip.empty? }
res.concat(chgs)
end
res
end
def changes!
replace changes
end
end
# Main class
class ChangeLogger
attr_reader :changes, :version
def initialize(app_title = nil, options: {})
@options = options
@changes = ChangeSet.new
@log = gitlog
@app_title = app_title || nil
sort_changes
end
def to_s
types = CL_STRINGS.select { |k, v| @options[:types].include?(k) }
case @options[:format]
when :def_list
output = []
res = {}
types.each do |k, _v|
res[k] = @changes.get(k)
end
res.each do |_k, v|
v.each do |item|
output.push(": #{item}")
end
end
"#{header(:def_list)}#{output.join("\n")}"
when :bunch
output = []
res = {}
types.each { |k, _v| res[k] = @changes.get(k) }
res.each do |k, v|
icon = case k.downcase
when /^fix/
'fix'
when /^(cha|imp)/
'imp'
when /^new/
'new'
end
v.each do |item|
item.gsub!(%r{https://bunchapp.co}, '{{ site.baseurl }}')
ico = "{% icon #{icon} %}"
ico += '{% icon breaking %}' if item =~ /BREAKING/
output.push(": #{ico} #{item}")
end
end
"#{header(:bunch)}\n#{output.join("\n")}\n\n{% endavailable %}"
else
output = ''
res = {}
types.each do |k, _v|
res[k] = @changes.get(k)
end
res.each do |k, v|
next if v.empty?
output += "#### #{CL_STRINGS[k][:title]}\n\n"
output += "- #{v.join("\n- ")}\n\n"
end
header(@options[:format]) + output
end
end
private
def format_header(build, fmt)
case fmt
when :def_list
"#{build}\n"
when :git
"#{build}\n\n"
when :bunch
"{% available #{build} %}\n\n---\n\n#{build}"
else
"### #{build}\n\n#{Time.now.strftime('%F %R')}\n\n"
end
end
def header(fmt = :markdown)
return '' if @options[:no_version]
header_out = ''
# Forced version
if @options[:version]
@version = @options[:version]
header_out = format_header(@options[:version], fmt)
# Gem version
elsif Dir.glob('lib/**/version.rb').length.positive?
spec = Dir.glob('*.gemspec')[0]
if spec
@app_title = File.basename(spec, '.gemspec')
version_file = Dir.glob('lib/*/version.rb')[0]
if version_file
build = begin
IO.read(version_file).match(/VERSION *= *(['"])(.*?)\1/)[2]
rescue StandardError
nil
end
@version = build
header_out = format_header(build, fmt)
else
warn 'Failed to parse version'
Process.exit 1
end
else
warn 'Failed to find app name'
Process.exit 1
end
# nvUltra handling
elsif Dir.pwd =~ %r{/Code/nvultra}
@app_title = 'nvUltra (mark 2) 1.0.0'
@version = `git ver`
header_out = "#{@app_title} (#{@version})\n-------------------------\n\n"
# PopClip extension handling
elsif Dir.pwd =~ %r{/Code/popclipextensions}
@app_title = 'Brett\'s PopClip Extensions'
@version = `git semnext`
header_out = format_header(@version, fmt)
# NiftyMenu handling
elsif Dir.pwd =~ %r{/Code/niftymenu}
@app_title = 'NiftyMenu'
@version = `git semnext`
header_out = format_header(@version, fmt)
# Bunch and xcode project handling
elsif %i[def_list bunch].include?(fmt)
parts = `agvtool mvers -terse`.match(%r{"(.*?)\.xcodeproj.*[^/]+?\i.plist"=(\d+\.\d+\.\d+)})[1, 2]
@version = parts[1].strip.to_s
build = `agvtool vers -terse`.strip
@version += " (#{build})"
header_out = fmt == :bunch ? "{% available #{build} %}\n\n#{@version}" : @version
# Swift project handling
elsif File.exist?('Package.swift')
content = IO.read('Package.swift')
@app_title = content.match(/Package\(.*?name: *"(.*?)"/m)[1]
@version = `git semnext`
header_out = "## #{@version}\n\n"
# version.rb handling
elsif `ag --depth 0 'VERSION *='`.strip.length.positive?
build = nil
Dir.glob('*').each do |f|
next unless `file "#{f}"` =~ /ruby/i
content = IO.read(f)
if content =~ /VERSION *= *['"]([\d.]+(\w+)?)["']/
build = Regexp.last_match(1)
break
end
end
raise 'Detected a ruby project but no VERSION line was found' unless build
@version = build
header_out = format_header(build, fmt)
else
# Test for VERSION file
%w[VERSION VERSION.txt VERSION.md].each do |filename|
next unless File.exist?(filename)
@app_title = File.basename(File.expand_path('.'))
@version = IO.read(filename)
header_out = "#{@app_title} #{@version}"
header_out += %(-------------------------\n\n)
end
unless @version
begin
parts = `agvtool mvers -terse`.match(%r{"(.*?)\.xcodeproj.*[^/]+?\.plist"=(\d+\.\d+\.\d+)})[1, 2]
rescue NoMethodError
warn 'Not a project directory'
Process.exit 1
end
@app_title ||= parts[0].strip
@app_title.sub!(/^(notnvalt|nvultra)$/i, 'nvUltra')
@version = parts[1].strip
header_out = "#{@app_title} #{@version}"
build = `agvtool vers -terse`.strip
@version += " (#{build})"
header_out += %{ (#{build})\n-------------------------\n\n}
end
end
header_out
end
def revision
if @options[:select]
tags = `git tag -n0 -l`.strip
selection = `echo #{Shellwords.escape(tags)} | fzf --tac`.strip
raise 'No selection' if selection.empty?
`git log -1 --format=format:"%H" #{selection}`
else
`git rev-list --tags --max-count=1`
end
end
# returns ChangeLog
def gitlog
since = `git show -s --format=%ad #{revision}`
log = `git log --pretty=format:'===%h%n%ci%n%s%n%b' --reverse --since="#{since}"`.strip
if @options[:file]
content = IO.read(File.expand_path(@options[:file])).strip
log = ['===XXXXXXX', Time.now.strftime('+%F %T %z'), content, "\n", log].join("\n")
end
if log && !log.empty?
cl = ChangeLog.new
log.split(/^===/).each do |entry|
e = split_gitlog(entry.strip)
cl.push(e) if e&.githash
end
return cl
else
warn 'No new entries'
Process.exit 1
end
raise 'Error reading log items'
end
def gen_rx
format('(%<b>s)', b: CL_STRINGS.map { |_, v| "(?:@#{v[:rx].downcase}|- #{v[:rx]}:)" }.join('|'))
end
def split_gitlog(entry)
# Joins entry lines that got wrapped
lines = entry.force_encoding('utf-8').gsub(/^((.*?)(?:@|- )#{gen_rx}:?[\s\S]*?)(?=\n\2|\n{2,}|\Z)/s) do |m|
m.gsub(/\n/, ' ')
end.split(/\n/)
loghash = lines.shift
date = lines.shift
return nil if lines[0] =~ /^Merge (branch|tag)/
changes = lines.delete_if { |l| l.strip.empty? }.join("\n")
Change.new(loghash, date, changes)
end
def sort_changes
chgs = []
@log.changes.each do |l|
chgs.concat(l.split("\n").delete_if { |ch| ch !~ /#{gen_rx}/ })
end
chgs.each do |change|
CL_STRINGS.each do |k, v|
@changes.add(k, change.clean_entry) if change =~ /(?:@#{v[:rx].downcase}|- #{v[:rx]}:)/
end
end
end
end
# Main class
class App
LOG_FORMATS = %i[def_list bunch markdown].freeze
def initialize(args)
top = `git rev-parse --show-toplevel`.strip
Dir.chdir(top)
options = {
select: false,
file: nil,
format: nil,
copy: false,
update: nil,
version: nil,
no_version: false,
types: %i[changed new improved fixed]
}
optparse = OptionParser.new do |opts|
opts.banner = %(Usage: #{File.basename(__FILE__)} [options] [CHANGELOG_FILE] [APP_NAME]
Gets git log entries since last tag containing #{CL_STRINGS.map { |_, v| v[:title] }.join(', ')})
opts.on('-c', '--copy', 'Copy results to clipboard') do
options[:copy] = true
end
opts.on('-f', '--format FORMAT', "Output format (#{LOG_FORMATS.map(&:to_s).join('|')})") do |fmt|
unless fmt =~ /^[dbm]/
puts "Invalid change type: #{fmt}. Available types: #{CL_STRINGS.keys.join(', ')}"
Process.exit 1
end
options[:format] = case fmt
when /^d/
:def_list
when /^b/
:bunch
when /^m/
:markdown
end
end
opts.on('-o', '--only TYPES', "Only output changes of type (#{CL_STRINGS.keys.join(', ')})") do |arg|
types = arg.split(/ *, */).map(&:downcase)
options[:types] = []
types.each do |t|
unless t =~ /^[cnfi]/
puts "Invalid change type: #{fmt}. Available types: #{CL_STRINGS.keys.join(', ')}"
Process.exit 1
end
options[:types].push case t
when /^c/
:changed
when /^n/
:new
when /^i/
:improved
when /^f/
:fixed
end
end
end
opts.on('--file PATH', 'File to read additional commit messages from (for commit-msg hooks)') do |path|
options[:file] = path
end
opts.on('-s', '--select', 'Choose "since" tag') do
options[:select] = true
end
opts.on('-u', '--update [FILE]', 'Update changelog file') do |file|
raise 'Can\'t skip version check when updating changelog file' if options[:no_version]
if file
if File.exist?(File.expand_path(file))
options[:update] = File.expand_path(file)
else
args.unshift(file)
end
else
options[:update] = find_changelog
end
end
opts.on('-v', '--version=VER', 'Force version (skips version detection)') do |ver|
raise 'Invalid version string' unless ver && ver =~ /\d+\.\d+(\.\d+)?(\w+)?( *\([.\d]+\))?/
options[:version] = ver
end
opts.on('-n', '--no_version', 'Skip version check (prevents header output)') do
raise 'Can\'t skip version check when updating changelog file' if options[:update]
options[:no_version] = true
end
opts.on('-h', '--help', 'Display this screen') do
puts opts
exit
end
end
optparse.parse!
if options[:format].nil?
file = if options[:update] && File.exist?(options[:update])
options[:update]
else
find_changelog
end
if file
fmt = detect_changelog_type(file)
options[:format] = fmt.to_sym
warn "Parsed #{file}, detected format: #{fmt}"
end
end
apptitle = nil
apptitle = args[0] if args.length
cl = ChangeLogger.new(apptitle, options: options)
if options[:copy]
`echo #{Shellwords.escape(cl.to_s)}|pbcopy`
warn 'Changelog in clipboard'
elsif options[:update]
update_changelog(options[:update], cl.to_s, cl.version)
else
$stdout.puts cl.to_s
end
end
def update_changelog(file, changes, version)
git_ver = `git ver`.strip
if git_ver.gsub(/[^0-9.]/, '') == version.gsub(/[^0-9.]/, '')
raise 'Git version matches new version, bump project before running'
end
fmt = detect_changelog_type(file)
input = IO.read(file)
version_rx = version.gsub(/\./, '\.')
if input =~ /^(#+ ?)?#{version_rx} *$/
warn 'Found existing version record'
# changes.sub!(/^(#+ ?)?#{version_rx} *$/, '').strip!
input.sub!(/(?mi)(?<=\n|\A)(#+ ?)?#{version_rx} *.*?\n+(?=(#* *\d+\.\d+\.\d+|---))/, changes)
else
case fmt
when :bunch
warn 'Updating Bunch changelog'
input.sub!(/{% docdiff %}/, "{% docdiff %}\n\n---\n\n#{changes.strip}\n")
when :def_list
input.sub!(/^(\d+\.\d+\.\d+.*?\n+:)/, "#{changes.strip}\n\n\\1")
else
input = "#{changes.strip}\n\n#{input}"
end
end
File.open(file, 'w') do |f|
f.puts input
end
end
# Searches for a file named 'changelog' in the current
# directory. It returns the first file it finds that
# matches the pattern, or nil if no files are found. It
# ignores any files that are executable.
#
# @return [String] First matching file
#
def find_changelog
files = Dir.glob('changelog*')
files.delete_if { |f| File.executable?(f) }
files.count.positive? ? files.first : nil
end
##
## Takes in a file as an argument and returns a symbol
## representing the type of changelog the file contains.
## It does this by reading the file and checking for
## certain patterns that indicate the type of changelog.
## If the file contains a pattern that matches the
## markdown format, it will return :markdown. If the file
## contains a pattern that matches the bunch format, it
## will return :bunch. If the file contains a pattern that
## matches the definition list format, it will return
## :def_list. If the file does not contain any of these
## patterns, it will return :standard.
##
## @param file [String] The file
##
def detect_changelog_type(file)
case IO.read(File.expand_path(file)).strip
when /^\#{2,} \d+\.\d+/
:markdown
when /\{% icon (new|fix|imp) %\}/
:bunch
when /\d+\.\d+\.\d+(.*?)[\n\s]+:/
:def_list
else
:markdown
end
end
end
App.new(ARGV)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment