changelog 6.2 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
module ChangelogHelpers
  Abort = Class.new(StandardError)
  Done = Class.new(StandardError)

26 27
  MAX_FILENAME_LENGTH = 140 # ecryptfs has a limit of 140 characters

28 29 30 31 32 33 34 35 36 37 38
  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
39
class ChangelogOptionParser
40 41
  extend ChangelogHelpers

42 43 44 45 46 47 48 49
  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'),
50
    Type.new('performance', 'Performance improvement'),
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 88 89
    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
90
          raise Done.new
91 92
        end
      end
Robert Speicher's avatar
Robert Speicher committed
93

94
      parser.parse!(argv)
Robert Speicher's avatar
Robert Speicher committed
95

96 97
      # 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
98

99 100
      options
    end
101

102 103
    def read_type
      read_type_message
Robert Speicher's avatar
Robert Speicher committed
104

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

108 109 110 111
      type.name
    end

    private
Robert Speicher's avatar
Robert Speicher committed
112

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

120 121 122 123 124 125 126
    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
127

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

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

class ChangelogEntry
141 142
  include ChangelogHelpers

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

  def initialize(options)
    @options = options
147
  end
Robert Speicher's avatar
Robert Speicher committed
148

149
  def execute
Robert Speicher's avatar
Robert Speicher committed
150
    assert_feature_branch!
151
    assert_title! unless editor
152 153 154 155 156
    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
157 158 159

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

Robert Speicher's avatar
Robert Speicher committed
161 162 163 164
    unless options.dry_run
      write
      amend_commit if options.amend
    end
165 166 167 168

    if editor
      system("#{editor} '#{file_path}'")
    end
Robert Speicher's avatar
Robert Speicher committed
169 170
  end

171 172
  private

Robert Speicher's avatar
Robert Speicher committed
173
  def contents
174
    yaml_content = YAML.dump(
Robert Speicher's avatar
Robert Speicher committed
175 176
      'title'         => title,
      'merge_request' => options.merge_request,
177 178
      'author'        => options.author,
      'type'          => options.type
Robert Speicher's avatar
Robert Speicher committed
179
    )
180
    remove_trailing_whitespace(yaml_content)
Robert Speicher's avatar
Robert Speicher committed
181 182 183 184 185 186
  end

  def write
    File.write(file_path, contents)
  end

187 188 189 190
  def editor
    ENV['EDITOR']
  end

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

194
    Kernel.exec(*%w[git commit --amend])
Robert Speicher's avatar
Robert Speicher committed
195 196 197 198 199 200 201 202 203 204
  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)
205
    return if options.force
Robert Speicher's avatar
Robert Speicher committed
206

207
    fail_with "#{file_path} already exists! Use `--force` to overwrite."
Robert Speicher's avatar
Robert Speicher committed
208 209 210 211 212 213 214 215 216
  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

217 218 219 220 221 222
  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
223 224 225 226 227 228 229 230 231
  def title
    if options.title.empty?
      last_commit_subject
    else
      options.title
    end
  end

  def last_commit_subject
232
    capture_stdout(%w[git log --format=%s -1]).strip
Robert Speicher's avatar
Robert Speicher committed
233 234 235
  end

  def file_path
236
    base_path = File.join(
Robert Speicher's avatar
Robert Speicher committed
237
      unreleased_path,
238 239 240 241
      branch_name.gsub(/[^\w-]/, '-'))

    # Add padding for .yml extension
    base_path[0..MAX_FILENAME_LENGTH - 5] + '.yml'
Robert Speicher's avatar
Robert Speicher committed
242 243 244
  end

  def unreleased_path
245 246 247 248
    path = File.join('changelogs', 'unreleased')
    path = File.join('ee', path) if ee?

    path
Robert Speicher's avatar
Robert Speicher committed
249 250 251 252 253 254 255
  end

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

  def branch_name
256
    @branch_name ||= capture_stdout(%w[git symbolic-ref --short HEAD]).strip
Robert Speicher's avatar
Robert Speicher committed
257
  end
258 259 260 261

  def remove_trailing_whitespace(yaml_content)
    yaml_content.gsub(/ +$/, '')
  end
Robert Speicher's avatar
Robert Speicher committed
262 263 264
end

if $0 == __FILE__
265 266
  begin
    options = ChangelogOptionParser.parse(ARGV)
267
    ChangelogEntry.new(options).execute
268 269 270 271 272 273
  rescue ChangelogHelpers::Abort => ex
    $stderr.puts ex.message
    exit 1
  rescue ChangelogHelpers::Done
    exit
  end
Robert Speicher's avatar
Robert Speicher committed
274 275 276
end

# vim: ft=ruby