dalexhd/SteamSpeak

View on GitHub
scripts/release-prepare.rb

Summary

Maintainability
C
7 hrs
Test Coverage
#!/usr/bin/env ruby

# release-meta.rb
#
# SUMMARY
#
#   A script that prepares the release .meta/releases/vX.X.X.toml file.
#   Afterwards, the `make generate` command should be used to refresh
#   the generated files against the new release metadata.

#
# Setup
#

require "time"
require_relative "setup"

#
# Constants
#

TYPES = ["chore", "docs", "feat", "fix", "enhancement", "perf"]
TYPES_THAT_REQUIRE_SCOPES = ["feat", "enhancement", "fix"]

#
# Functions
#
# Sorted alphabetically.
#

# Determines if a commit message is a breaking change as defined by the
# Convetional Commits specification:
#
# https://www.conventionalcommits.org
def breaking_change?(commit)
  !commit.fetch("message").match(/^[a-z]*!/).nil?
end

# Creates and updates the new release meta file located at
#
#   /.meta/releases/X.X.X.toml
#
# This file is created from outstanding commits since the last release.
# It's meant to be a starting point. The resulting file should be reviewed
# and edited by a human.
def create_release_meta_file!(current_commits, new_version)
  release_meta_path = "#{RELEASE_META_DIR}/#{new_version}.toml"

  # Grab all existing commits
  existing_commits = get_existing_commits!

  # Ensure this release does not include duplicate commits. Notice that we
  # check the parsed PR numbers. This is necessary to ensure we do not include
  # cherry-picked commits made available in other releases.
  #
  # For example, if we cherry pick a commit from `master` to the `0.8` branch
  # it will have a different commit sha. Without checking something besides the
  # sha, this commit would also show up in the next release.
  new_commits =
    current_commits.select do |current_commit|
      existing_commits.select do |existing_commit|
        existing_commit.fetch("sha") == current_commit.fetch("sha") ||
          existing_commit.fetch("pr_number") == current_commit.fetch("pr_number")
      end
    end
  if new_commits.any?
    if File.exists?(release_meta_path)
      words =
        <<~EOF
        I found #{new_commits.length} new commits since you last generated:

            #{release_meta_path}

        So I don't erase any other work in that file, please manually add the
        following commit lines:

        #{new_commits.to_toml.indent(4)}

        Done? Ready to proceed?
        EOF

      if Printer.get(words, ["y", "n"]) == "n"
        Printer.error!("Ok, re-run this command when you're ready.")
      end
    else
      File.open(release_meta_path, 'w+') do |file|
        file.write(
          <<~EOF
          [releases."#{new_version}"]
          date = #{Time.now.utc.to_date.to_toml}
          commits = #{new_commits.to_toml}
          EOF
        )
      end

      words =
        <<~EOF
        I've created a release meta file here:

          #{release_meta_path}

        I recommend reviewing the commits and fixing any mistakes.

        Ready to proceed?
        EOF

      if Printer.get(words, ["y", "n"]) == "n"
        Printer.error!("Ok, re-run this command when you're ready.")
      end
    end
  end

  true
end

# Gets the commit log from the last version. This is used to determine
# the outstanding commits that should be included in this release.
# Notice the specificed format, this allow us to parse the lines into
# structured data.
def get_commit_log(last_version)
  range = "v#{last_version}..."
  `git log #{range} --no-merges --pretty=format:'%H\t%s\t%aN\t%ad'`.chomp
end

def get_commits_since(last_version)
  commit_log = get_commit_log(last_version)
  commit_lines = commit_log.split("\n").reverse

  commit_lines.collect do |commit_line|
    parse_commit_line!(commit_line)
  end
end

# This is used for the `files_count`, `insertions_count`, and `deletions_count`
# attributes. It helps to communicate stats and the depth of changes in our
# release notes.
def get_commit_stats(sha)
  `git show --shortstat --oneline #{sha}`.split("\n").last
end

# Grabs all existing commits that are included in the `.meta/releases/*.toml`
# files. We grab _all_ commits to ensure we do not include duplicate commits
# in the new release.
def get_existing_commits!
  release_meta_paths = Dir.glob("#{RELEASE_META_DIR}/*.toml").to_a

  release_meta_paths.collect do |release_meta_path|
    contents = File.read(release_meta_path)
    parsed_contents = TomlRB.parse(contents)
    release_hash = parsed_contents.fetch("releases").values.fetch(0)
    release_hash.fetch("commits").collect do |c|
      message_data = parse_commit_message!(c.fetch("message"))

      {
        "sha" => c.fetch("sha"),
        "message" => c.fetch("message"),
        "author" => c.fetch("author"),
        "date" => c.fetch("date"),
        "pr_number" => message_data.fetch("pr_number"),
        "files_count" => c["files_count"],
        "insertions_count" => c["insertions_count"],
        "deletions_count" => c["deletions_count"]
      }
    end
  end.flatten
end

def get_new_version(last_version, commits)
  next_version =
    if commits.any? { |c| breaking_change?(c) }
      next_version = "#{last_version.major + 1}.0.0"

      words = "It looks like the new commits contain breaking changes. " +
        "Would you like to use the recommended version #{next_version} for " +
        "this release?"

      if Printer.get(words, ["y", "n"]) == "y"
        next_version
      else
        nil
      end
    elsif commits.any? { |c| new_feature?(c) }
      next_version = "#{last_version.major}.#{last_version.minor + 1}.0"

      words = "It looks like this release contains commits with new features. " +
        "Would you like to use the recommended version #{next_version} for " +
        "this release?"

      if Printer.get(words, ["y", "n"]) == "y"
        next_version
      else
        nil
      end
    elsif commits.any? { |c| fix?(c) }
      next_version = "#{last_version.major}.#{last_version.minor}.#{last_version.patch + 1}"

      words = "It looks like this release contains commits with bug fixes. " +
        "Would you like to use the recommended version #{next_version} for " +
        "this release?"

      if Printer.get(words, ["y", "n"]) == "y"
        next_version
      else
        nil
      end
    end

  version_string = next_version || Printer.get("What is the next version you are releasing? (current version is #{last_version})")

  version =
    begin
      Version.new(version_string)
    rescue ArgumentError => e
      Printer.invalid("It looks like the version you entered is invalid: #{e.message}")
      get_new_version(last_version, commits)
    end

  if last_version.bump_type(version).nil?
    Printer.invalid("The version you entered must be a single patch, minor, or major bump")
    get_new_version(last_version, commits)
  else
    version
  end
end

def new_feature?(commit)
  !commit.fetch("message").match(/^feat/).nil?
end

def fix?(commit)
  !commit.fetch("message").match(/^fix/).nil?
end

# Parses the commit line from `#get_commit_log`.
def parse_commit_line!(commit_line)
  # Parse the full commit line
  line_parts = commit_line.split("\t")
  sha = line_parts.fetch(0)
  message = line_parts.fetch(1)
  author = line_parts.fetch(2)
  date = Time.parse(line_parts.fetch(3))
  message_data = parse_commit_message!(message)
  pr_number = message_data.fetch("pr_number")

  attributes =
    {
      "sha" =>  sha,
      "message" => message,
      "author" => author,
      "date" => date,
      "pr_number" => pr_number
    }

  # Parse the stats
  stats = get_commit_stats(attributes.fetch("sha"))
  if /^\W*\p{Digit}+ files? changed,/.match(stats)
    stats_attributes = parse_commit_stats!(stats)
    attributes.merge!(stats_attributes)
  end

  attributes
end

# Parses the commit message. This is used to extra other information that is
# helpful in deduping commits across releases.
def parse_commit_message!(message)
  match = message.match(/ \(#(?<pr_number>[0-9]*)\)?$/)
  {
    "pr_number" => match && match[:pr_number] ? match[:pr_number].to_i : nil
  }
end

# Parses the data from `#get_commit_stats`.
def parse_commit_stats!(stats)
  attributes = {}

  stats.split(", ").each do |stats_part|
    stats_part.strip!

    key =
      case stats_part
      when /insertions?/
        "insertions_count"
      when /deletions?/
        "deletions_count"
      when /files? changed/
        "files_count"
      else
        raise "Invalid commit stat: #{stats_part}"
      end

    count = stats_part.match(/^(?<count>[0-9]*) /)[:count].to_i
    attributes[key] = count
  end

  attributes
end

#
# Execute
#

Printer.title("Creating release meta file...")

last_tag = `git describe --tags --abbrev=0`.chomp
last_version = Version.new(last_tag.gsub(/^v/, ''))
commits = get_commits_since(last_version)
new_version = get_new_version(last_version, commits)
create_release_meta_file!(commits, new_version)