Last active
January 4, 2025 07:21
-
-
Save ttscoff/17fbce4f229609082b45681bba7a9967 to your computer and use it in GitHub Desktop.
Generate release notes from git commit messages
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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