#!/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_relative '../config/bundler_setup' require 'fileutils' require 'httparty' require 'json' require 'optparse' require 'readline' require 'shellwords' require 'uri' require 'yaml' require 'gitlab/utils/all' require_relative '../lib/feature/shared' unless defined?(Feature::Shared) require_relative '../lib/gitlab/popen' 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 WWW_GITLAB_COM_SITE = 'https://about.gitlab.com' WWW_GITLAB_COM_GROUPS_JSON = "#{WWW_GITLAB_COM_SITE}/groups.json".freeze FF_ROLLOUT_ISSUE_TEMPLATE = '.gitlab/issue_templates/Feature Flag Roll Out.md' COPY_COMMANDS = [ 'pbcopy', # macOS 'xclip -selection clipboard', # Linux 'xsel --clipboard --input', # Linux 'wl-copy' # Wayland ].freeze OPEN_COMMANDS = [ 'open', # macOS 'xdg-open' # Linux ].freeze Options = Struct.new( :name, :type, :group, :milestone, :ee, :amend, :dry_run, :force, :feature_issue_url, :introduced_by_url, :rollout_issue_url, :username, keyword_init: true ) 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('-a', '--feature-issue-url [string]', String, 'URL of the original feature issue') do |value| options.feature_issue_url = 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::project management`") do |value| options.group = value if group_labels.include?(value) 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.key?(value.to_sym) end opts.on('-u', '--username [string]', String, "The username of the feature flag DRI") do |value| options.username = value 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 groups @groups ||= fetch_json(WWW_GITLAB_COM_GROUPS_JSON) end def rollout_issue_template @rollout_issue_template ||= File.read(File.expand_path("../#{FF_ROLLOUT_ISSUE_TEMPLATE}", __dir__)) end def group_labels @group_labels ||= groups.map { |_, group| group['label'] }.sort end def find_group_by_label(label) groups.find { |_, group| group['label'] == label }[1] end def types_list list = [] TYPES.each_with_index do |(type, data), index| next if data[:deprecated] list << "#{index + 1}. #{type.to_s.rjust(17)} #{data[:description]}" end list end def group_list list = [] group_labels.each_with_index do |group_label, index| list << "#{index + 1}. #{group_label}" end list end def fzf_available? find_compatible_command(%w[fzf]) end def prompt_readline(prompt:) Readline.readline('?> ', false)&.strip end def prompt_fzf(list:, prompt:) arr = list.join("\n") selection = IO.popen("echo \"#{arr}\" | fzf --tac --prompt=\"#{prompt}\"") { |pipe| pipe.readlines }.join.strip selection.match(/(\d+)\./)[1] end def print_list(list) return if list.empty? $stdout.puts list.join("\n") end def print_prompt(prompt) $stdout.puts $stdout.puts ">> #{prompt}:" $stdout.puts end def prompt_list(prompt:, list: nil) if fzf_available? prompt_fzf(list: list, prompt: prompt) else prompt_readline(prompt: prompt) end end def fetch_json(json_url) json = with_retries { HTTParty.get(json_url, format: :plain) } JSON.parse(json) end def with_retries(attempts: 3) yield rescue Errno::ECONNRESET, OpenSSL::SSL::SSLError, Net::OpenTimeout retry if (attempts -= 1).positive? raise end def read_type prompt = 'Specify the feature flag type' unless fzf_available? print_prompt(prompt) print_list(types_list) end loop do type = prompt_list(prompt: prompt, list: types_list) type = TYPES.keys[type.to_i - 1] unless type.to_i.zero? type = type&.to_sym type_def = TYPES[type] if type_def && !type_def[:deprecated] $stdout.puts "You picked the type '#{type}'" return type else $stderr.puts "Invalid type specified '#{type}'" end end end def read_group prompt = "Specify the group label to which the feature flag belongs, from the following list" unless fzf_available? print_prompt(prompt) print_list(group_list) end loop do group = prompt_list(prompt: prompt, list: group_list) group = group_labels[group.to_i - 1] unless group.to_i.zero? if group_labels.include?(group) $stdout.puts "You picked the group '#{group}'" return group else $stderr.puts "The group label isn't in the above labels list" end end end def read_feature_issue_url read_url('URL of the original feature issue (enter to skip):') end def read_introduced_by_url read_url('URL of the MR introducing the feature flag (enter to skip and let Danger provide a suggestion directly in the MR):') end def read_rollout_issue_url(options) return unless TYPES.dig(options.type, :rollout_issue) issue_new_url = "https://gitlab.com/gitlab-com/gl-infra/production/-/issues/new" issue_title = "[Feature flag] Rollout of `#{options.name}`" issue_new_url = issue_new_url + "?" + URI.encode_www_form('issue[title]' => issue_title) group_name = find_group_by_label(options.group) template = rollout_issue_template if options.username template.gsub!('', options.username) else # Assign to current user by default template.gsub!('/assign @', "/assign me") end template.gsub!('', options.name) template.gsub!('', options.introduced_by_url) if options.introduced_by_url template.gsub!('', options.milestone) template.gsub!('', options.feature_issue_url) if options.feature_issue_url template.gsub!('', group_name['slack_channel']) if group_name&.key?('slack_channel') template.gsub!('', %Q(~"#{options.group}")) $stdout.puts $stdout.puts ">> Press any key and paste the issue content that we copied to your clipboard! 🚀" Readline.readline('?> ', false) copy_to_clipboard!(template) if open_url!(issue_new_url) != 0 $stdout.puts ">> Automatic opening of the new issue URL failed, so please visit #{issue_new_url} manually." end $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? || valid_url?(created_url) end end def read_milestone milestone = File.read('VERSION') milestone.gsub(/^(\d+\.\d+).*$/, '\1').chomp end def read_username $stdout.puts $stdout.puts ">> Username of the feature flag DRI (enter to skip):" loop do username = Readline.readline('?> ', false)&.strip return if username.empty? return username if valid_url?("https://gitlab.com/#{username}") end end def read_ee $stdout.puts $stdout.puts ">> Is this an EE only feature (enter to skip):" loop do ee = Readline.readline('?> ', false)&.strip return if ee.empty? return Gitlab::Utils.to_boolean(ee) end end def read_url(prompt) $stdout.puts $stdout.puts ">> #{prompt}" loop do url = Readline.readline('?> ', false)&.strip url = nil if url.empty? return url if url.nil? || valid_url?(url) end end def valid_url?(url) unless url.start_with?('https://') $stderr.puts "URL needs to start with https://" return false end response = HTTParty.head(url) return true if response.success? $stderr.puts "URL '#{url}' isn't valid!" end def open_url!(url) _, open_url_status = Gitlab::Popen.popen([open_command, url]) open_url_status end def copy_to_clipboard!(text) IO.popen(copy_to_clipboard_command.shellsplit, 'w') do |pipe| pipe.print(text) end end def copy_to_clipboard_command find_compatible_command(COPY_COMMANDS) end def open_command find_compatible_command(OPEN_COMMANDS) end def find_compatible_command(commands) commands.find do |command| Gitlab::Popen.popen(%W[which #{command.split(' ')[0]}])[1] == 0 end 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.group ||= FeatureFlagOptionParser.read_group options.feature_issue_url ||= FeatureFlagOptionParser.read_feature_issue_url options.introduced_by_url ||= FeatureFlagOptionParser.read_introduced_by_url options.milestone ||= FeatureFlagOptionParser.read_milestone options.username ||= FeatureFlagOptionParser.read_username options.ee ||= FeatureFlagOptionParser.read_ee options.rollout_issue_url ||= FeatureFlagOptionParser.read_rollout_issue_url(options) $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 config_hash.to_yaml end def config_hash { 'name' => options.name, 'feature_issue_url' => options.feature_issue_url, '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' => false } 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