class Caretaker

The main caretaker class

Constants

VERSION

Public Class Methods

new(options = {}) click to toggle source

initialize the class - called when Caretaker.new is called.

# File lib/caretaker.rb, line 20
def initialize(options = {})
    #
    # Global variables
    #
    @name = 'Caretaker'
    @url = 'https://github.com/DevelopersToolbox/caretaker'
    @executable = 'caretaker'
    @config_file = '.caretaker.yml'
    @default_category = 'Uncategorised:'
    @github_base_url = 'https://github.com'
    @spinner_format = :classic
    @header_file = 'HEADER.md'
    @version_file = 'VERSION.txt'
    @default_version = '0.1.0'
    @default_tag_prefix = 'v'

    @bump_major = false
    @bump_minor = false
    @bump_patch = false

    #
    # Check we are into a git repository - bail out if not
    #
    @repo_base_dir = execute_command('git rev-parse --show-toplevel')
    raise StandardError.new('Directory does not contain a git repository') if @repo_base_dir.nil?

    #
    # Set default values - Can be overridden by config file and/or command line options
    #
    @author = nil
    @enable_categories = false
    @min_words = 1
    @output_file = 'CHANGELOG.md'
    @remove_categories = false
    @silent = false
    @verify_urls = false
    #
    # Load the config if it exists
    #
    load_config

    #
    # Override the defaults and/or with command line options.
    #
    @author = options[:author] unless options[:author].nil?
    @enable_categories = options[:enable_categories] unless options[:enable_categories].nil?
    @min_words = options[:min_words].to_i unless options[:min_words].nil?
    @output_file = options[:output] unless options[:output].nil?
    @remove_categories = options[:remove_categories] unless options[:remove_categories].nil?
    @silent = options[:silent] unless options[:silent].nil?
    @verify_urls = options[:verify_urls] unless options[:verify_urls].nil?

    @bump_major = true unless options[:bump].nil? || options[:bump] != 'major'
    @bump_minor = true unless options[:bump].nil? || options[:bump] != 'minor'
    @bump_patch = true unless options[:bump].nil? || options[:bump] != 'patch'

    #
    # Work out the url for the git repository (unless for linking)
    #
    repo_url = execute_command('git config --get remote.origin.url')
    repo_url = repo_url.gsub(':', '/').gsub('git@', 'https://') if repo_url.start_with?('git@')
    uri = URI.parse(repo_url)
    @repository_remote_url = "#{uri.scheme}://#{uri.host}#{uri.path}"
    @repository_remote_url = @repository_remote_url.delete_suffix('.git') if @repository_remote_url.end_with?('.git')

    #
    # Global working variables - used to generate the changelog
    #
    @changelog = ''
    @last_tag = '0'
    @spinner = nil
    @tags = []
    @url_cache = {}
    @cache_hits = 0
    @cache_misses = 0

    #
    # The categories we use
    #
    @categories = {
        'New Features:'    => [ 'new feature:', 'new:', 'feature:' ],
        'Improvements:'    => [ 'improvement:' ],
        'Bug Fixes:'       => [ 'bug fix:', 'bug:', 'bugs:' ],
        'Security Fixes:'  => [ 'security: '],
        'Refactor:'        => [],
        'Style:'           => [],
        'Deprecated:'      => [],
        'Removed:'         => [],
        'Tests:'           => [ 'test:', 'testing:' ],
        'Documentation:'   => [ 'docs: ' ],
        'Chores:'          => [ 'chore:' ],
        'Experiments:'     => [ 'experiment:' ],
        'Miscellaneous:'   => [ 'misc:' ],
        'Uncategorised:'   => [],
        'Initial Commit:'  => [ 'initial' ],
        'Skip:'            => [ 'ignore' ]
    }
end

Public Instance Methods

bump_version() click to toggle source

Bump the version

# File lib/caretaker.rb, line 743
def bump_version
    first_version = false

    begin
        current = File.read(@version_file)
    rescue SystemCallError
        puts "failed to open #{@version_file} - Default to version #{@default_version} - will NOT bump version"
        current = @default_version
        first_version = true
    end

    puts "Current Version: #{current}"

    if current.start_with?(@default_tag_prefix)
        has_prefix = true
        current.slice!(@default_tag_prefix)
    end

    begin
        v = SemVersion.new(current)
    rescue ArgumentError
        puts "#{current} is not a valid Semantic Version string"
        return
    end

    if @bump_major && !first_version
        v.major += 1
        v.minor = 0
        v.patch = 0
    end

    if @bump_minor && !first_version
        v.minor += 1
        v.patch = 0
    end

    v.patch += 1 if @bump_patch && !first_version

    version = if has_prefix
                  "#{@default_tag_prefix}#{v}"
              else
                  v.to_s
              end

    puts "New Version: #{version}"

    begin
        File.write(@version_file, version)
    rescue SystemCallError
        puts "Count not write #{VERSION_FILE} - Aborting"
    end
end
cache_stats() click to toggle source

Display cache stats

# File lib/caretaker.rb, line 499
def cache_stats
    return unless @verify_urls

    total = @cache_hits + @cache_misses

    percentage = if total.positive?
                     (@cache_hits.to_f / total * 100.0).ceil if total.positive?
                 else
                     0
                 end
    puts "[Cache Stats] Total: #{total}, Hits: #{@cache_hits}, Misses: #{@cache_misses}, Hit Percentage: #{percentage}%" unless @silent
end
count_words(string) click to toggle source

Count the REAL words in a subject

# File lib/caretaker.rb, line 337
def count_words(string)
    string = string.gsub(/(\(|\[|\{).+(\)|\]|\})/, '')
    return string.split.count
end
execute_command(cmd) click to toggle source

Execute a command and collect the stdout

# File lib/caretaker.rb, line 122
def execute_command(cmd)
    Open3.popen3(cmd) do |_stdin, stdout, _stderr, wait_thr|
        return stdout.read.chomp if wait_thr.value.success?
    end
    return nil
end
extract_tag(refs, old_tag) click to toggle source

Extra the release tags from a commit reference

# File lib/caretaker.rb, line 224
def extract_tag(refs, old_tag)
    tag = old_tag
    if refs.include? 'tag: '
        refs = refs.gsub(/.*tag:/i, '')
        refs = refs.gsub(/,.*/i, '')
        tag = refs.gsub(/\).*/i, '')
    end
    return tag.strip
end
format_date(date_string) click to toggle source

Format a date in the format that we want it

# File lib/caretaker.rb, line 211
def format_date(date_string)
    d = Date.parse(date_string)

    day = d.strftime('%-d')
    ordinal = ordinal(day)
    month = d.strftime('%B')
    year = d.strftime('%Y')
    return "#{month}, #{day}#{ordinal} #{year}"
end
generate_changelog() click to toggle source

Generate the changelog

# File lib/caretaker.rb, line 515
def generate_changelog
    message = "#{@name} is generating your changelog ("

    message += if @enable_categories
                   'with categories'
               else
                   'without categories'
               end

    message += if @remove_categories
                   ', remove categories'
               else
                   ', retain categories'
               end

    message += if @verify_urls
                   ', verify urls'
               else
                   ', assume urls'
               end

    message += if @author.nil?
                   ', no author'
               else
                   ", author=#{@author}"
               end
    message += ')'

    puts "> #{@name} is generating your changeog #{message}" unless @silent

    start_spinner('Retreiving git log')
    results = log_to_hash

    start_spinner('Processing entries')
    processed = process_results(results)

    releases = 0
    start_spinner('Preparing output')
    output_changelog_header

    processed.each do |tag, entries|
        output_version_header(tag, releases)

        if @enable_categories
            if entries.count.positive?
                entries.each do |category, array|
                    next unless array.count.positive?

                    @changelog += "###### #{category}\n\n"

                    array.each do |row|
                        @changelog += "- #{row[:subject]}\n\n"
                    end
                end
            end
        else
            entries.each do |row|
                @changelog += "- #{row[:subject]}\n\n"
            end
        end
        releases += 1
    end
    start_spinner('Writing Changelog')

    write_file("#{@repo_base_dir}/#{@output_file}", @changelog)

    stop_spinner

    cache_stats
end
generate_config_file() click to toggle source

Generate the configuration file

# File lib/caretaker.rb, line 700
def generate_config_file
    puts "> #{@name} is creating your config file"
    start_spinner('Generating Config')

    content = "---\n"

    content += "author: #{@author}\n" unless @author.nil?

    content += if @enable_categories
                   "enable-categories: true\n"
               else
                   "enable-categories: false\n"
               end

    content += "min-words: #{@min_words}\n" unless @min_words.nil?
    content += "output-file: #{@output_file}\n" unless @output_file.nil?

    content += if @remove_categories
                   "remove-categories: true\n"
               else
                   "remove-categories: false\n"
               end

    content += if @silent
                   "silent: true\n"
               else
                   "silent: false\n"
               end

    content += if @verify_urls
                   "verify-urls: true\n"
               else
                   "verify-urls: false\n"
               end

    start_spinner('Writing config')
    write_file("#{@repo_base_dir}/#{@config_file}", content, 0o0644)
    stop_spinner
end
get_category(subject) click to toggle source

Work out what category a commit belongs to - return default if we cannot find one (or a matching one)

# File lib/caretaker.rb, line 237
def get_category(subject)
    @categories.each do |category, array|
        return category if subject.downcase.start_with?(category.downcase)

        next unless array.count.positive?

        array.each do |a|
            return category if subject.downcase.start_with?(a.downcase)
        end
    end
    return @default_category
end
get_child_messages(parent) click to toggle source

Get the commit messages for child commits (pull requests)

# File lib/caretaker.rb, line 253
def get_child_messages(parent)
    return execute_command "git --no-pager log --pretty=format:'%b' -n 1 #{parent}"
end
get_tag_date(search) click to toggle source

Work out the date of the tag/release

# File lib/caretaker.rb, line 467
def get_tag_date(search)
    @tags.each do |hash|
        return hash[search.to_s] if hash[search.to_s]
    end
    return 'Unknown'
end
init_repo() click to toggle source

Configure a repository to use Caretaker

# File lib/caretaker.rb, line 589
    def init_repo
        cmd = @executable.to_s

        cmd += " -a #{@author}" unless @author.nil?
        cmd += ' -e' if @enable_categories
        cmd += ' -r' if @remove_categories
        cmd += ' -s' if @silent
        cmd += ' -v' if @verify_urls
        cmd += " -w #{@min_words}" unless @min_words.nil?

        puts "> #{@name} is creating a custom post-commit hook" unless @silent
        start_spinner('Generating Hook')
        contents = <<~END_OF_SCRIPT
#!/usr/bin/env bash

#
# This script is automatically generated by caretaker by Wolf Software
#
REPO_ROOT=$(r=$(git rev-parse --git-dir) && r=$(cd "$r" && pwd)/ && cd "${r%%/.git/*}" && pwd)
LOCK_FILE="${REPO_ROOT}/.lock"

OUTPUT_FILE=#{@output_file}
VERSION_FILE=#{@version_file}

if [[ -f "${LOCK_FILE}" ]]; then
    exit
fi

touch "${LOCK_FILE}"

if [[ ! -f "${LOCK_FILE}" ]]; then
    echo "Failed to create lockfile - aborting"
    exit
fi

if [[ -f "${VERSION_FILE}" ]]; then
    RELEASE_VERSION=$(<"${VERSION_FILE}")
    TAG_NAME="v${RELEASE_VERSION}"

    if GIT_DIR="${REPO_ROOT}/.git" git tag --list | grep -Eq "^${TAG_NAME}$"; then
        unset RELEASE_VERSION
        unset TAG_NAME
    fi
fi

if [[ -n "${RELEASE_VERSION}" ]]; then
    git tag "${TAG_NAME}"
fi

#{cmd}

res=$(git status --porcelain | grep -c "${OUTPUT_FILE}")
if [[ "${res}" -gt 0 ]]; then

    git add "${OUTPUT_FILE}" >> /dev/null 2>&1
    git commit --amend --no-edit --no-verify >> /dev/null 2>&1

    if [[ -n "${RELEASE_VERSION}" ]]; then
        git tag -f "${TAG_NAME}"
    fi

fi

rm -f "${LOCK_FILE}"
END_OF_SCRIPT

        start_spinner('Writing Hook')
        write_file("#{@repo_base_dir}/.git/hooks/post-commit", contents, 0o0755)
        stop_spinner
    end
load_config() click to toggle source

Load the configuration if it exists

# File lib/caretaker.rb, line 663
def load_config
    locations = [ "#{@repo_base_dir}/#{@config_file}", ENV['HOME'] ]

    #
    # Traverse the entire directory path
    #
    dirs = Dir.getwd.split(File::SEPARATOR).map { |x| x == '' ? File::SEPARATOR : x }[1..-1]
    while dirs.length.positive?
        path = '/' + dirs.join('/')
        locations << path
        dirs.pop
    end
    locations << '/'

    locations.each do |loc|
        config = read_file(loc)

        next if config.nil?

        yaml_hash = YAML.safe_load(config)

        puts "Using config located in #{loc}" unless @silent

        @author = yaml_hash['author'] if yaml_hash['author']
        @enable_categories = true if yaml_hash['enable-categories']
        @min_words = yaml_hash['min-words'].to_i if yaml_hash['min-words']
        @output_file = yaml_hash['output-file'] if yaml_hash['output-file']
        @remove_categories = true if yaml_hash['remove-categories']
        @silent = true if yaml_hash['silent']
        @verify_urls = true if yaml_hash['verify-urls']
        break
    end
end
log_to_hash() click to toggle source

Convert the commit messages (git log) into a hash

# File lib/caretaker.rb, line 383
def log_to_hash
    docs = {}
    tag = '0'

    res = execute_command("git --no-pager log --first-parent --oneline --pretty=format:'%h|%H|%d|%s|%cd'")
    unless res.nil?
        res.each_line do |line|
            hash, hash_full, refs, subject, date = line.split('|')
            tag = extract_tag(refs, tag).to_s

            @last_tag = tag if @last_tag == '0' && tag != '0'

            (docs[tag.to_s] ||= []) << { :hash => hash, :hash_full => hash_full, :subject => subject }

            if tag != '0'
                @tags << { tag => format_date(date) } unless @tags.any? { |h| h[tag] }
            end
        end
        @tags = @tags.uniq
    end
    return docs
end
ordinal(number) click to toggle source

Add an ordinal to a date

# File lib/caretaker.rb, line 193
def ordinal(number)
    abs_number = number.to_i.abs

    if (11..13).include?(abs_number % 100)
        'th'
    else
        case abs_number % 10
            when 1 then 'st'
            when 2 then 'nd'
            when 3 then 'rd'
            else        'th'
        end
    end
end
output_changelog_header() click to toggle source

Generate the changelog header banner

# File lib/caretaker.rb, line 409
def output_changelog_header
    contents = nil

    locations = [ @header_file.to_s, "docs/#{@header_file}" ]

    locations.each do |loc|
        contents = read_file(loc)
        break unless contents.nil?
    end

    if contents.nil?
        @changelog += "# Changelog\n\n"
        @changelog += "All notable changes to this project will be documented in this file.\n\n"
    else
        @changelog += contents
    end

    @changelog += "\nThis changelog was automatically generated using [#{@name}](#{@url}) by [Wolf Software](https://github.com/WolfSoftware)\n\n"
end
output_version_header(tag, releases) click to toggle source

Write a version header and release date

# File lib/caretaker.rb, line 432
def output_version_header(tag, releases)
    num_tags = @tags.count
    tag_date = get_tag_date(tag)

    current_tag = if releases < num_tags
                      @tags[releases].keys.first
                  else
                      0
                  end

    previous_tag = if releases + 1 < num_tags
                       @tags[releases + 1].keys.first
                   else
                       0
                   end

    if tag == '0'
        @changelog += if num_tags != 0
                          "### [Unreleased](#{@repository_remote_url}/compare/#{@last_tag}...HEAD)\n\n"
                      else
                          "### [Unreleased](#{@repository_remote_url}/commits/master)\n\n"
                      end
    elsif current_tag != 0
        @changelog += if previous_tag != 0
                          "### [#{current_tag}](#{@repository_remote_url}/compare/#{previous_tag}...#{current_tag})\n\n"
                      else
                          "### [#{current_tag}](#{@repository_remote_url}/releases/#{current_tag})\n\n"
                      end
        @changelog += "> Released on #{tag_date}\n\n"
    end
end
process_issues(message) click to toggle source

See of the commit links to an issue and add a link if it does.

# File lib/caretaker.rb, line 277
def process_issues(message)
    if message.scan(/.*\(issue-(\d+)\).*/m).size.positive?
        m = message.match(/.*\(issue-(\d+)\).*/)

        issue_number = m[1]
        issue_link = "[`[##{issue_number}]`](#{@repository_remote_url}/issues/#{issue_number})"

        message = message.sub(/(\(issue-\d+\))/, issue_link).strip if valid_url "#{@repository_remote_url}/issues/#{issue_number}"
    end
    return message
end
process_results(results) click to toggle source

Process the hash containing the commit messages

# File lib/caretaker.rb, line 345
def process_results(results)
    processed = {}
    first = true

    results.each do |tag, array|
        if @enable_categories
            processed[tag.to_s] = {}

            @categories.each do |category|
                processed[tag.to_s][category.to_s] = []
            end
        else
            processed[tag.to_s] = []
        end
        array.each do |a|
            a[:subject] = process_subject(a[:subject], a[:hash], a[:hash_full], first)
            category = get_category(a[:subject]).to_s

            next if category == 'Skip:'

            a[:subject] = a[:subject].sub(/.*?:/, '').strip if (@enable_categories || @remove_categories) && (category != @default_category)

            next if count_words(a[:subject]) < @min_words

            if @enable_categories
                (processed[tag.to_s][category.to_s] ||= []) << a
            else
                (processed[tag.to_s] ||= []) << a
            end
            first = false
        end
    end
    return processed
end
process_subject(subject, hash, hash_full, first) click to toggle source

Controller function for processing the subject (body) of a commit messages

# File lib/caretaker.rb, line 292
def process_subject(subject, hash, hash_full, first)
    if subject.scan(/Merge pull request #(\d+).*/m).size.positive?
        m = subject.match(/Merge pull request #(\d+).*/)
        pr = m[1]

        child_message = get_child_messages hash
        child_message ||= subject

        message = child_message.to_s
        message += " [`[##{pr}]`](#{@repository_remote_url}/pull/#{pr})" if valid_url "#{@repository_remote_url}/pull/#{pr}"
        message = process_usernames(message)
    elsif subject.scan(/\.*\(#(\d+)\)*\)/m).size.positive?
        m = subject.match(/\.*\(#(\d+)\)*\)/)
        pr = m[1]

        child_message = get_child_messages hash

        subject = subject.sub(/\.*\(#(\d+)\)*\)/, '').strip
        message = subject.to_s
        message += " [`[##{pr}]`](#{@repository_remote_url}/pull/#{pr})" if valid_url "#{@repository_remote_url}/pull/#{pr}"
        message = process_usernames(message)
        unless child_message.empty?
            child_message = child_message.gsub(/[*]/i, '   *')
            message += "\n\n#{child_message}"
        end
    else
        message = subject.to_s
        if first
            url = "#{@repository_remote_url}/commit/"
            link = 'head'
        else
            url = "#{@repository_remote_url}/commit/#{hash_full}"
            link = hash.to_s
        end
        message += " [`[#{link}]`](#{url})" if valid_url(url.to_s)
    end
    message = process_usernames(message)
    message = process_issues(message)

    return message
end
process_usernames(message) click to toggle source

Process the username if we find out or if the @author variable is set

# File lib/caretaker.rb, line 260
def process_usernames(message)
    if message.scan(/.*(\{.*\}).*/m).size.positive?
        m = message.match(/.*(\{.*\}).*/)
        message = message.sub(/\{.*\}/, '').strip
        username = m[1].gsub(/[{}]/, '')

        message += " [`[#{username}]`](#{@github_base_url}/#{username})" if valid_url "#{@github_base_url}/#{username})"
    elsif valid_url "#{@github_base_url}/#{@author}"
        message += " [`[#{@author}]`](#{@github_base_url}/#{@author})" unless @author.nil?
    end

    return message.squeeze(' ')
end
read_file(filename, show_error = false) click to toggle source

Read a file fromthe repo and return the contents

# File lib/caretaker.rb, line 146
def read_file(filename, show_error = false)
    contents = nil

    begin
        File.open(filename, 'r') do |f|
            contents = f.read
        end
    rescue SystemCallError
        puts "Error reading file: #{filename}" unless show_error == false
    end
    return contents
end
start_spinner(message) click to toggle source

Start the spinner - we all like pretty output

# File lib/caretaker.rb, line 477
def start_spinner(message)
    return if @silent

    @spinner&.stop('Done!')

    @spinner = TTY::Spinner.new("[:spinner] #{message}", format: @spinner_format)
    @spinner.auto_spin
end
stop_spinner() click to toggle source

Stop the spinner

# File lib/caretaker.rb, line 489
def stop_spinner
    return if @silent

    @spinner.stop('Done!')
    @spinner = nil
end
valid_url(url) click to toggle source

Make sure a url is value - but only if verify_urls = true

# File lib/caretaker.rb, line 162
def valid_url(url)
    return true if @verify_urls == false

    url_hash = Digest::SHA2.hexdigest(url).to_s

    if @url_cache[url_hash.to_s]
        @cache_hits += 1
        return @url_cache[url_hash]
    end

    @cache_misses += 1

    url = URI.parse(url)
    req = Net::HTTP.new(url.host, url.port)
    req.use_ssl = true
    res = req.request_head(url.path)

    @url_cache[url_hash.to_s] = if res.code == '200'
                                    true
                                else
                                    false
                                end

    return true if res.code == '200'

    return false
end
write_file(filename, contents, permissions = 0o0644) click to toggle source

Write a file into the repo and set permissions on it

# File lib/caretaker.rb, line 132
def write_file(filename, contents, permissions = 0o0644)
    begin
        File.open(filename, 'w') do |f|
            f.puts contents
            f.chmod(permissions)
        end
    rescue SystemCallError
        raise StandardError.new("Failed to open file #{filename} for writing")
    end
end