require 'caretaker/version' require 'date' require 'open3' require 'tty-spinner' require 'uri' require 'yaml' require 'net/http' # # The main caretaker class # class Caretaker # # initialize the class - called when Caretaker.new is called. # def initialize(options = {}) # # Global variables # @name = 'Caretaker' @executable = 'caretaker' @config_file = '.caretaker.yml' @default_category = 'Uncategorised:' @github_base_url = 'https://github.com' @spinner_format = :classic @header_file = 'HEADER.md' # # 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? # # Work out the url for the git repository (unless for linking) # uri = URI.parse(execute_command('git config --get remote.origin.url')) @repository_remote_url = "#{uri.scheme}://#{uri.host}#{uri.path}" # # 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 # # Execute a command and collect the stdout # 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 # # Write a file into the repo and set permissions on it # 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 # # Read a file fromthe repo and return the contents # 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 # # Make sure a url is value - but only if verify_urls = true # def valid_url(url, first = false) return true if @verify_urls == false || first == true 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 # # Add an ordinal to a date # 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 # # Format a date in the format that we want it # 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 # # Extra the release tags from a commit reference # 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 # # Work out what category a commit belongs to - return default if we cannot find one (or a matching one) # 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 the commit messages for child commits (pull requests) # def get_child_messages(parent) return execute_command "git log --pretty=format:'%b' -n 1 #{parent}" end # # Process the username if we find out or if the @author variable is set # 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 # # See of the commit links to an issue and add a link if it does. # 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 # # Controller function for processing the subject (body) of a commit messages # 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 message += " [`[#{hash}]`](#{@repository_remote_url}/commit/#{hash_full})" if valid_url("#{@repository_remote_url}/commit/#{hash_full}", first) end message = process_usernames(message) message = process_issues(message) return message end # # Count the REAL words in a subject # def count_words(string) string = string.gsub(/(\(|\[|\{).+(\)|\]|\})/, '') return string.split.count end # # Process the hash containing the commit messages # 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:' if @enable_categories || @remove_categories a[:subject] = a[:subject].sub(/.*?:/, '').strip if category != @default_category end 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 # # Convert the commit messages (git log) into a hash # def log_to_hash docs = {} tag = '0' old_parent = '' res = execute_command("git log --oneline --pretty=format:'%h|%H|%P|%d|%s|%cd'") unless res.nil? res.each_line do |line| hash, hash_full, parent, refs, subject, date = line.split('|') parent = parent.split(' ')[0] tag = extract_tag(refs, tag).to_s @last_tag = tag if @last_tag == '0' && tag != '0' if parent != old_parent (docs[tag.to_s] ||= []) << { :hash => hash, :hash_full => hash_full, :parent => parent, :subject => subject } if tag != 0 @tags << { tag => format_date(date) } unless @tags.any? { |h| h[tag] } end end old_parent = parent end @tags = @tags.uniq end return docs end # # Generate the changelog header banner # 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}](#{@repository_remote_url}) by [Wolf Software](https://github.com/WolfSoftware)\n\n" end # # Write a version header and release date # 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 # # Work out the date of the tag/release # def get_tag_date(search) @tags.each do |hash| return hash[search.to_s] if hash[search.to_s] end return 'Unknown' end # # Start the spinner - we all like pretty output # def start_spinner(message) return if @silent @spinner&.stop('Done!') @spinner = TTY::Spinner.new("[:spinner] #{message}", format: @spinner_format) @spinner.auto_spin end # # Stop the spinner # def stop_spinner return if @silent @spinner.stop('Done!') @spinner = nil end # # Display cache stats # 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 # # Generate the changelog # 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 # # Configure a repository to use Caretaker # def init_repo cmd = @executable.to_s cmd += " -a #{@author}" unless @author.nil? cmd += ' -e' if @enable_categories cmd += " -m #{@min_words}" unless @min_words.nil? cmd += ' -r' if @remove_categories cmd += ' -s' if @silent cmd += ' -v' if @verify_urls puts "> #{@name} is creating a custom post-commit hook" unless @silent start_spinner('Generating Hook') contents = <<~END_OF_SCRIPT #!/usr/bin/env bash LOCKFULE="#{@repo_base_dir}/.lock" if [[ -f "${LOCKFILE}" ]]; then exit fi touch "${LOCKFILE}" OUTPUT_FILE="#{@output_file}" #{cmd} res=$(git status --porcelain | grep "${OUTPUT_FILE}" | wc -l) if [[ "${res}" -gt 0 ]]; then git add "${OUTPUT_FILE}" >> /dev/null 2>&1 git commit --amend --no-edit >> /dev/null 2>&1 fi rm -f "${LOCKFILE}" END_OF_SCRIPT start_spinner('Writing Hook') write_file("#{@repo_base_dir}/.git/hooks/post-commit", contents, 0o0755) stop_spinner end # # Load the configuration if it exists # 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 # # Generate the configuration file # 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 end