changelog 5.33 KB
Newer Older
Robert Speicher's avatar
Robert Speicher committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14
#!/usr/bin/env ruby
#
# Generate a changelog entry file in the correct location.
#
# Automatically stages the file and amends the previous commit if the `--amend`
# argument is used.

require 'optparse'
require 'yaml'

Options = Struct.new(
  :amend,
  :author,
  :dry_run,
15
  :force,
Robert Speicher's avatar
Robert Speicher committed
16
  :merge_request,
17 18
  :title,
  :type
Robert Speicher's avatar
Robert Speicher committed
19
)
20
INVALID_TYPE = -1
Robert Speicher's avatar
Robert Speicher committed
21 22

class ChangelogOptionParser
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72
  Type = Struct.new(:name, :description)
  TYPES = [
    Type.new('added', 'New feature'),
    Type.new('fixed', 'Bug fix'),
    Type.new('changed', 'Feature change'),
    Type.new('deprecated', 'New deprecation'),
    Type.new('removed', 'Feature removal'),
    Type.new('security', 'Security fix'),
    Type.new('other', 'Other')
  ].freeze
  TYPES_OFFSET = 1

  class << self
    def parse(argv)
      options = Options.new

      parser = OptionParser.new do |opts|
        opts.banner = "Usage: #{__FILE__} [options] [title]\n\n"

        # Note: We do not provide a shorthand for this in order to match the `git
        # commit` interface
        opts.on('--amend', 'Amend the previous commit') do |value|
          options.amend = value
        end

        opts.on('-f', '--force', 'Overwrite an existing entry') do |value|
          options.force = value
        end

        opts.on('-m', '--merge-request [integer]', Integer, 'Merge Request ID') do |value|
          options.merge_request = value
        end

        opts.on('-n', '--dry-run', "Don't actually write anything, just print") do |value|
          options.dry_run = value
        end

        opts.on('-u', '--git-username', 'Use Git user.name configuration as the author') do |value|
          options.author = git_user_name if value
        end

        opts.on('-t', '--type [string]', String, "The category of the change, valid options are: #{TYPES.map(&:name).join(', ')}") do |value|
          options.type = parse_type(value)
        end

        opts.on('-h', '--help', 'Print help message') do
          $stdout.puts opts
          exit
        end
      end
Robert Speicher's avatar
Robert Speicher committed
73

74
      parser.parse!(argv)
Robert Speicher's avatar
Robert Speicher committed
75

76 77
      # Title is everything that remains, but let's clean it up a bit
      options.title = argv.join(' ').strip.squeeze(' ').tr("\r\n", '')
Robert Speicher's avatar
Robert Speicher committed
78

79 80
      options
    end
81

82 83
    def read_type
      read_type_message
Robert Speicher's avatar
Robert Speicher committed
84

85 86
      type = TYPES[$stdin.getc.to_i - TYPES_OFFSET]
      assert_valid_type!(type)
Robert Speicher's avatar
Robert Speicher committed
87

88 89 90 91
      type.name
    end

    private
Robert Speicher's avatar
Robert Speicher committed
92

93 94 95
    def parse_type(name)
      type_found = TYPES.find do |type|
        type.name == name
Robert Speicher's avatar
Robert Speicher committed
96
      end
97
      type_found ? type_found.name : INVALID_TYPE
Robert Speicher's avatar
Robert Speicher committed
98 99
    end

100 101 102 103 104 105 106
    def read_type_message
      $stdout.puts "\n>> Please specify the index for the category of your change:"
      TYPES.each_with_index do |type, index|
        $stdout.puts "#{index + TYPES_OFFSET}. #{type.description}"
      end
      $stdout.print "\n?> "
    end
Robert Speicher's avatar
Robert Speicher committed
107

108 109 110 111 112 113
    def assert_valid_type!(type)
      unless type
        $stderr.puts "Invalid category index, please select an index between 1 and #{TYPES.length}"
        exit 1
      end
    end
Robert Speicher's avatar
Robert Speicher committed
114

115 116 117
    def git_user_name
      %x{git config user.name}.strip
    end
Robert Speicher's avatar
Robert Speicher committed
118 119 120 121 122 123 124 125 126 127 128
  end
end

class ChangelogEntry
  attr_reader :options

  def initialize(options)
    @options = options

    assert_feature_branch!
    assert_title!
129 130 131 132 133
    assert_new_file!

    # Read type from $stdin unless is already set
    options.type ||= ChangelogOptionParser.read_type
    assert_valid_type!
Robert Speicher's avatar
Robert Speicher committed
134 135 136

    $stdout.puts "\e[32mcreate\e[0m #{file_path}"
    $stdout.puts contents
137

Robert Speicher's avatar
Robert Speicher committed
138 139 140 141 142 143
    unless options.dry_run
      write
      amend_commit if options.amend
    end
  end

144 145
  private

Robert Speicher's avatar
Robert Speicher committed
146
  def contents
147
    yaml_content = YAML.dump(
Robert Speicher's avatar
Robert Speicher committed
148 149
      'title'         => title,
      'merge_request' => options.merge_request,
150 151
      'author'        => options.author,
      'type'          => options.type
Robert Speicher's avatar
Robert Speicher committed
152
    )
153
    remove_trailing_whitespace(yaml_content)
Robert Speicher's avatar
Robert Speicher committed
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
  end

  def write
    File.write(file_path, contents)
  end

  def amend_commit
    %x{git add #{file_path}}
    exec("git commit --amend")
  end

  def fail_with(message)
    $stderr.puts "\e[31merror\e[0m #{message}"
    exit 1
  end

  def assert_feature_branch!
    return unless branch_name == 'master'

    fail_with "Create a branch first!"
  end

  def assert_new_file!
    return unless File.exist?(file_path)
178
    return if options.force
Robert Speicher's avatar
Robert Speicher committed
179

180
    fail_with "#{file_path} already exists! Use `--force` to overwrite."
Robert Speicher's avatar
Robert Speicher committed
181 182 183 184 185 186 187 188 189
  end

  def assert_title!
    return if options.title.length > 0 || options.amend

    fail_with "Provide a title for the changelog entry or use `--amend`" \
      " to use the title from the previous commit."
  end

190 191 192 193 194 195
  def assert_valid_type!
    return unless options.type && options.type == INVALID_TYPE

    fail_with 'Invalid category given!'
  end

Robert Speicher's avatar
Robert Speicher committed
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225
  def title
    if options.title.empty?
      last_commit_subject
    else
      options.title
    end
  end

  def last_commit_subject
    %x{git log --format="%s" -1}.strip
  end

  def file_path
    File.join(
      unreleased_path,
      branch_name.gsub(/[^\w-]/, '-') << '.yml'
    )
  end

  def unreleased_path
    File.join('changelogs', 'unreleased').tap do |path|
      path << '-ee' if ee?
    end
  end

  def ee?
    @ee ||= File.exist?(File.expand_path('../CHANGELOG-EE.md', __dir__))
  end

  def branch_name
226
    @branch_name ||= %x{git symbolic-ref --short HEAD}.strip
Robert Speicher's avatar
Robert Speicher committed
227
  end
228 229 230 231

  def remove_trailing_whitespace(yaml_content)
    yaml_content.gsub(/ +$/, '')
  end
Robert Speicher's avatar
Robert Speicher committed
232 233 234 235 236 237 238 239
end

if $0 == __FILE__
  options = ChangelogOptionParser.parse(ARGV)
  ChangelogEntry.new(options)
end

# vim: ft=ruby