#!/usr/bin/env ruby # # Generate a feature flag 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' require 'fileutils' require 'uri' require 'readline' require_relative '../lib/feature/shared' unless defined?(Feature::Shared) module FeatureFlagHelpers 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 class FeatureFlagOptionParser extend FeatureFlagHelpers extend ::Feature::Shared Options = Struct.new( :name, :type, :group, :milestone, :ee, :amend, :dry_run, :force, :introduced_by_url, :rollout_issue_url ) class << self def parse(argv) options = Options.new parser = OptionParser.new do |opts| opts.banner = "Usage: #{__FILE__} [options] \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', '--introduced-by-url [string]', String, 'URL of merge request introducing the Feature Flag') do |value| options.introduced_by_url = value end opts.on('-M', '--milestone [string]', String, 'Milestone in which the Feature Flag was introduced') do |value| options.milestone = value end opts.on('-i', '--rollout-issue-url [string]', String, 'URL of Issue rolling out the Feature Flag') do |value| options.rollout_issue_url = value end opts.on('-n', '--dry-run', "Don't actually write anything, just print") do |value| options.dry_run = value end opts.on('-g', '--group [string]', String, "The group introducing a feature flag, like: `group::apm`") do |value| options.group = value if value.start_with?('group::') end opts.on('-t', '--type [string]', String, "The category of the feature flag, valid options are: #{TYPES.keys.map(&:to_s).join(', ')}") do |value| options.type = value.to_sym if TYPES[value.to_sym] end opts.on('-e', '--ee', 'Generate a feature flag entry for GitLab EE') do |value| options.ee = value end opts.on('-h', '--help', 'Print help message') do $stdout.puts opts raise Done.new end end parser.parse!(argv) unless argv.one? $stdout.puts parser.help $stdout.puts raise Abort, 'Feature flag name is required' end # Name is a first name options.name = argv.first.downcase.gsub(/-/, '_') options end def read_group $stdout.puts $stdout.puts ">> Specify the group introducing the feature flag, like `group::apm`:" loop do group = Readline.readline('?> ', false)&.strip group = nil if group.empty? return group if group.nil? || group.start_with?('group::') $stderr.puts "The group needs to include `group::`" end end def read_type # if there's only one type, do not ask, return return TYPES.first.first if TYPES.one? $stdout.puts $stdout.puts ">> Specify the feature flag type:" $stdout.puts TYPES.each do |type, data| next if data[:deprecated] $stdout.puts "#{type.to_s.rjust(15)}#{' '*6}#{data[:description]}" end loop do type = Readline.readline('?> ', false)&.strip&.to_sym return type if TYPES[type] && !TYPES[type][:deprecated] $stderr.puts "Invalid type specified '#{type}'" end end def read_introduced_by_url $stdout.puts $stdout.puts ">> URL of the MR introducing the feature flag (enter to skip):" loop do introduced_by_url = Readline.readline('?> ', false)&.strip introduced_by_url = nil if introduced_by_url.empty? return introduced_by_url if introduced_by_url.nil? || introduced_by_url.start_with?('https://') $stderr.puts "URL needs to start with https://" end end def read_ee_only(options) TYPES.dig(options.type, :ee_only) end def read_rollout_issue_url(options) return unless TYPES.dig(options.type, :rollout_issue) url = "https://gitlab.com/gitlab-org/gitlab/-/issues/new" title = "[Feature flag] Rollout of `#{options.name}`" params = { 'issue[title]' => "[Feature flag] Rollout of `#{options.name}`", 'issuable_template' => 'Feature Flag Roll Out', } issue_new_url = url + "?" + URI.encode_www_form(params) $stdout.puts $stdout.puts ">> Open this URL and fill in the rest of the details:" $stdout.puts issue_new_url $stdout.puts $stdout.puts ">> URL of the rollout issue (enter to skip):" loop do created_url = Readline.readline('?> ', false)&.strip created_url = nil if created_url.empty? return created_url if created_url.nil? || created_url.start_with?('https://') $stderr.puts "URL needs to start with https://" end end def read_milestone milestone = File.read('VERSION') milestone.gsub(/^(\d+\.\d+).*$/, '\1').chomp end def read_default_enabled(options) TYPES.dig(options.type, :default_enabled) end end end class FeatureFlagCreator include FeatureFlagHelpers attr_reader :options def initialize(options) @options = options end def execute assert_feature_branch! assert_name! assert_existing_feature_flag! # Read type from stdin unless is already set options.type ||= FeatureFlagOptionParser.read_type options.ee ||= FeatureFlagOptionParser.read_ee_only(options) options.group ||= FeatureFlagOptionParser.read_group options.introduced_by_url ||= FeatureFlagOptionParser.read_introduced_by_url options.rollout_issue_url ||= FeatureFlagOptionParser.read_rollout_issue_url(options) options.milestone ||= FeatureFlagOptionParser.read_milestone $stdout.puts "\e[32mcreate\e[0m #{file_path}" $stdout.puts contents unless options.dry_run write amend_commit if options.amend end if editor system("#{editor} '#{file_path}'") end end private def contents # Slice is used to ensure that YAML keys # are always ordered in a predictable way config_hash.slice( *::Feature::Shared::PARAMS.map(&:to_s) ).to_yaml end def config_hash { 'name' => options.name, 'introduced_by_url' => options.introduced_by_url, 'rollout_issue_url' => options.rollout_issue_url, 'milestone' => options.milestone, 'group' => options.group, 'type' => options.type.to_s, 'default_enabled' => FeatureFlagOptionParser.read_default_enabled(options) } end def write FileUtils.mkdir_p(File.dirname(file_path)) File.write(file_path, contents) end def editor ENV['EDITOR'] end def amend_commit fail_with "git add failed" unless system(*%W[git add #{file_path}]) Kernel.exec(*%w[git commit --amend]) end def assert_feature_branch! return unless branch_name == 'master' fail_with "Create a branch first!" end def assert_existing_feature_flag! existing_path = all_feature_flag_names[options.name] return unless existing_path return if options.force fail_with "#{existing_path} already exists! Use `--force` to overwrite." end def assert_name! return if options.name.match(/\A[a-z0-9_-]+\Z/) fail_with "Provide a name for the feature flag that is [a-z0-9_-]" end def file_path feature_flags_paths.last .sub('**', options.type.to_s) .sub('*.yml', options.name + '.yml') end def all_feature_flag_names @all_feature_flag_names ||= feature_flags_paths.map do |glob_path| Dir.glob(glob_path).map do |path| [File.basename(path, '.yml'), path] end end.flatten(1).to_h end def feature_flags_paths paths = [] paths << File.join('config', 'feature_flags', '**', '*.yml') paths << File.join('ee', 'config', 'feature_flags', '**', '*.yml') if ee? paths end def ee? options.ee end def branch_name @branch_name ||= capture_stdout(%w[git symbolic-ref --short HEAD]).strip end end if $0 == __FILE__ begin options = FeatureFlagOptionParser.parse(ARGV) FeatureFlagCreator.new(options).execute rescue FeatureFlagHelpers::Abort => ex $stderr.puts ex.message exit 1 rescue FeatureFlagHelpers::Done exit end end # vim: ft=ruby