更新
更旧
#!/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 'fileutils'
require 'httparty'
require 'json'
require 'optparse'
require 'shellwords'
require 'uri'
require 'yaml'
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,
: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] <feature-flag>\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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
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_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
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
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-org/gitlab/-/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!('<gitlab-username-of-dri>', options.username)
else
# Assign to current user by default
template.gsub!('/assign @<gitlab-username-of-dri>', "/assign me")
end
template.gsub!('<feature-flag-name>', options.name)
template.gsub!('<merge-request-url>', options.introduced_by_url) if options.introduced_by_url
template.gsub!('<milestone>', options.milestone)
template.gsub!('<feature-issue-link>', options.feature_issue_url) if options.feature_issue_url
template.gsub!('<slack-channel-of-dri-team>', group_name['slack_channel']) if group_name&.key?('slack_channel')
template.gsub!('<group-label>', %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
class FeatureFlagCreator
include FeatureFlagHelpers
attr_reader :options
def initialize(options)
@options = options
end
def execute
assert_clipboard_command!
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
'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,
'type' => options.type.to_s,
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_clipboard_command!
return if FeatureFlagOptionParser.copy_to_clipboard_command
fail_with "Could not find a copy to clipboard command. On Linux, please install xclip or xsel (or wl-clipboard under Wayland). On macOS, make sure pbcopy is available."
end
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
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