Skip to content
代码片段 群组 项目
feature-flag 14.4 KB
更新 更旧
#!/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 '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,
    :ee,
    :amend,
    :dry_run,
    :force,
    :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] <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|
        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(/-/, '_')
    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

      prompt = 'Specify the feature flag type'
      unless fzf_available?
        print_prompt(prompt)
        print_list(types_list)
        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
    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 ">> 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):"
        created_url = Readline.readline('?> ', false)&.strip
        created_url = nil if created_url.empty?
        return created_url if created_url.nil? || valid_url?(created_url)
    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
      '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,
      '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_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

  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