changelog 5.9 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 23 24 25 26 27 28 29 30 31 32 33 34 35 36
module ChangelogHelpers
  Abort = Class.new(StandardError)
  Done = Class.new(StandardError)

  def capture_stdout(cmd)
    output = IO.popen(cmd, &:read)
    fail_with "command failed: #{cmd.join(' ')}" unless $?.success?
    output
  end

  def fail_with(message)
    raise Abort, "\e[31merror\e[0m #{message}"
  end
end

Robert Speicher's avatar
Robert Speicher committed
37
class ChangelogOptionParser
38 39
  extend ChangelogHelpers

40 41 42 43 44 45 46 47
  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'),
48
    Type.new('performance', 'Performance improvement'),
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
    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
88
          raise Done.new
89 90
        end
      end
Robert Speicher's avatar
Robert Speicher committed
91

92
      parser.parse!(argv)
Robert Speicher's avatar
Robert Speicher committed
93

94 95
      # 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
96

97 98
      options
    end
99

100 101
    def read_type
      read_type_message
Robert Speicher's avatar
Robert Speicher committed
102

103 104
      type = TYPES[$stdin.getc.to_i - TYPES_OFFSET]
      assert_valid_type!(type)
Robert Speicher's avatar
Robert Speicher committed
105

106 107 108 109
      type.name
    end

    private
Robert Speicher's avatar
Robert Speicher committed
110

111 112 113
    def parse_type(name)
      type_found = TYPES.find do |type|
        type.name == name
Robert Speicher's avatar
Robert Speicher committed
114
      end
115
      type_found ? type_found.name : INVALID_TYPE
Robert Speicher's avatar
Robert Speicher committed
116 117
    end

118 119 120 121 122 123 124
    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
125

126 127
    def assert_valid_type!(type)
      unless type
128
        raise Abort, "Invalid category index, please select an index between 1 and #{TYPES.length}"
129 130
      end
    end
Robert Speicher's avatar
Robert Speicher committed
131

132
    def git_user_name
133
      capture_stdout(%w[git config user.name]).strip
134
    end
Robert Speicher's avatar
Robert Speicher committed
135 136 137 138
  end
end

class ChangelogEntry
139 140
  include ChangelogHelpers

Robert Speicher's avatar
Robert Speicher committed
141 142 143 144 145 146 147
  attr_reader :options

  def initialize(options)
    @options = options

    assert_feature_branch!
    assert_title!
148 149 150 151 152
    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
153 154 155

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

Robert Speicher's avatar
Robert Speicher committed
157 158 159 160 161 162
    unless options.dry_run
      write
      amend_commit if options.amend
    end
  end

163 164
  private

Robert Speicher's avatar
Robert Speicher committed
165
  def contents
166
    yaml_content = YAML.dump(
Robert Speicher's avatar
Robert Speicher committed
167 168
      'title'         => title,
      'merge_request' => options.merge_request,
169 170
      'author'        => options.author,
      'type'          => options.type
Robert Speicher's avatar
Robert Speicher committed
171
    )
172
    remove_trailing_whitespace(yaml_content)
Robert Speicher's avatar
Robert Speicher committed
173 174 175 176 177 178 179
  end

  def write
    File.write(file_path, contents)
  end

  def amend_commit
180
    fail_with "git add failed" unless system(*%W[git add #{file_path}])
Robert Speicher's avatar
Robert Speicher committed
181

182
    Kernel.exec(*%w[git commit --amend])
Robert Speicher's avatar
Robert Speicher committed
183 184 185 186 187 188 189 190 191 192
  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)
193
    return if options.force
Robert Speicher's avatar
Robert Speicher committed
194

195
    fail_with "#{file_path} already exists! Use `--force` to overwrite."
Robert Speicher's avatar
Robert Speicher committed
196 197 198 199 200 201 202 203 204
  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

205 206 207 208 209 210
  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
211 212 213 214 215 216 217 218 219
  def title
    if options.title.empty?
      last_commit_subject
    else
      options.title
    end
  end

  def last_commit_subject
220
    capture_stdout(%w[git log --format=%s -1]).strip
Robert Speicher's avatar
Robert Speicher committed
221 222 223 224 225 226 227 228 229 230
  end

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

  def unreleased_path
231 232 233 234
    path = File.join('changelogs', 'unreleased')
    path = File.join('ee', path) if ee?

    path
Robert Speicher's avatar
Robert Speicher committed
235 236 237 238 239 240 241
  end

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

  def branch_name
242
    @branch_name ||= capture_stdout(%w[git symbolic-ref --short HEAD]).strip
Robert Speicher's avatar
Robert Speicher committed
243
  end
244 245 246 247

  def remove_trailing_whitespace(yaml_content)
    yaml_content.gsub(/ +$/, '')
  end
Robert Speicher's avatar
Robert Speicher committed
248 249 250
end

if $0 == __FILE__
251 252 253 254 255 256 257 258 259
  begin
    options = ChangelogOptionParser.parse(ARGV)
    ChangelogEntry.new(options)
  rescue ChangelogHelpers::Abort => ex
    $stderr.puts ex.message
    exit 1
  rescue ChangelogHelpers::Done
    exit
  end
Robert Speicher's avatar
Robert Speicher committed
260 261 262
end

# vim: ft=ruby