Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
		
							parent
							
								
									3c2841692e
								
							
						
					
					
						commit
						6aab18704a
					
				| 
						 | 
				
			
			@ -357,7 +357,6 @@ linters:
 | 
			
		|||
      - "ee/app/views/notify/unapproved_merge_request_email.html.haml"
 | 
			
		||||
      - "ee/app/views/oauth/geo_auth/error.html.haml"
 | 
			
		||||
      - "ee/app/views/projects/commits/_mirror_status.html.haml"
 | 
			
		||||
      - "ee/app/views/projects/jobs/_shared_runner_limit_warning.html.haml"
 | 
			
		||||
      - "ee/app/views/projects/merge_requests/_approvals_count.html.haml"
 | 
			
		||||
      - "ee/app/views/projects/merge_requests/widget/open/_geo.html.haml"
 | 
			
		||||
      - "ee/app/views/projects/mirrors/_mirrored_repositories_count.html.haml"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,7 +6,7 @@ $brand-info: $blue-500;
 | 
			
		|||
$brand-warning: $orange-500;
 | 
			
		||||
$brand-danger: $red-500;
 | 
			
		||||
 | 
			
		||||
$border-radius-base: 3px !default;
 | 
			
		||||
$border-radius-base: $gl-border-radius-base;
 | 
			
		||||
 | 
			
		||||
$modal-body-bg: $white;
 | 
			
		||||
$input-border: $border-color;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,4 +5,6 @@
 | 
			
		|||
- content_for :page_specific_javascripts do
 | 
			
		||||
  = stylesheet_link_tag 'page_bundles/xterm'
 | 
			
		||||
 | 
			
		||||
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
 | 
			
		||||
 | 
			
		||||
#js-job-vue-app{ data: jobs_data }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,291 @@
 | 
			
		|||
#!/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 'cgi'
 | 
			
		||||
 | 
			
		||||
require_relative '../lib/feature/shared' unless defined?(Feature::Shared)
 | 
			
		||||
 | 
			
		||||
Options = Struct.new(
 | 
			
		||||
  :name,
 | 
			
		||||
  :type,
 | 
			
		||||
  :group,
 | 
			
		||||
  :ee,
 | 
			
		||||
  :amend,
 | 
			
		||||
  :dry_run,
 | 
			
		||||
  :force,
 | 
			
		||||
  :introduced_by_url,
 | 
			
		||||
  :rollout_issue_url
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
  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('-m', '--introduced-by-url [string]', String, 'URL to Merge Request introducing Feature Flag') do |value|
 | 
			
		||||
          options.introduced_by_url = value
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        opts.on('-i', '--rollout-issue-url [string]', String, 'URL to Issue rolling out 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
 | 
			
		||||
 | 
			
		||||
      options
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def read_group
 | 
			
		||||
      $stdout.puts ">> Please specify the group introducing feature flag, like `group::apm`:"
 | 
			
		||||
 | 
			
		||||
      loop do
 | 
			
		||||
        $stdout.print "\n?> "
 | 
			
		||||
        group = $stdin.gets.strip
 | 
			
		||||
        group = nil if group.empty?
 | 
			
		||||
        return group if group.nil? || group.start_with?('group::')
 | 
			
		||||
 | 
			
		||||
        $stderr.puts "Group needs to include `group::`"
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def read_type
 | 
			
		||||
      $stdout.puts ">> Please specify the type of your feature flag:"
 | 
			
		||||
      $stdout.puts
 | 
			
		||||
      TYPES.each do |type, data|
 | 
			
		||||
        $stdout.puts "#{type.to_s.rjust(15)}#{' '*6}#{data[:description]}"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      loop do
 | 
			
		||||
        $stdout.print "\n?> "
 | 
			
		||||
 | 
			
		||||
        type = $stdin.gets.strip.to_sym
 | 
			
		||||
        return type if TYPES[type]
 | 
			
		||||
 | 
			
		||||
        $stderr.puts "Invalid type specified '#{type}'"
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def read_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}`"
 | 
			
		||||
      description = File.read('.gitlab/issue_templates/Feature Flag Roll Out.md')
 | 
			
		||||
      description.sub!(':feature_name', options.name)
 | 
			
		||||
 | 
			
		||||
      issue_new_url = url + "?" +
 | 
			
		||||
        "issue[title]=" + CGI.escape(title) + "&"
 | 
			
		||||
        # TODO: We should be able to pick `issueable_template`
 | 
			
		||||
        # + "issue[description]=" + CGI.escape(description)
 | 
			
		||||
 | 
			
		||||
      $stdout.puts ">> Open this URL and fill the rest of details:"
 | 
			
		||||
      $stdout.puts issue_new_url
 | 
			
		||||
      $stdout.puts
 | 
			
		||||
 | 
			
		||||
      $stdout.puts ">> Paste URL here, or enter to skip:"
 | 
			
		||||
 | 
			
		||||
      loop do
 | 
			
		||||
        $stdout.print "\n?> "
 | 
			
		||||
        created_url = $stdin.gets.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
 | 
			
		||||
  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.rollout_issue_url ||= FeatureFlagOptionParser.read_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
 | 
			
		||||
    YAML.dump(
 | 
			
		||||
      'name'              => options.name,
 | 
			
		||||
      'introduced_by_url' => options.introduced_by_url,
 | 
			
		||||
      'rollout_issue_url' => options.rollout_issue_url,
 | 
			
		||||
      'group'             => options.group.to_s,
 | 
			
		||||
      'type'              => options.type.to_s,
 | 
			
		||||
      'default_enabled'   => false
 | 
			
		||||
    ).strip
 | 
			
		||||
  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
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
---
 | 
			
		||||
title: Fix border-radius-base SCSS value
 | 
			
		||||
merge_request: 35740
 | 
			
		||||
author:
 | 
			
		||||
type: fixed
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
---
 | 
			
		||||
title: Update snippet statistics after project import
 | 
			
		||||
merge_request: 35730
 | 
			
		||||
author:
 | 
			
		||||
type: changed
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
---
 | 
			
		||||
title: Move merge_requests_users metric to stage section
 | 
			
		||||
merge_request: 35593
 | 
			
		||||
author:
 | 
			
		||||
type: changed
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
# This needs to be loaded after
 | 
			
		||||
# config/initializers/0_inject_enterprise_edition_module.rb
 | 
			
		||||
 | 
			
		||||
Feature.register_feature_groups
 | 
			
		||||
Feature.register_definitions
 | 
			
		||||
| 
						 | 
				
			
			@ -1 +0,0 @@
 | 
			
		|||
Feature.register_feature_groups
 | 
			
		||||
| 
						 | 
				
			
			@ -908,6 +908,21 @@ result as you did at the start. For example:
 | 
			
		|||
 | 
			
		||||
Note that `enforced="true"` means that authentication is being enforced.
 | 
			
		||||
 | 
			
		||||
## Direct Git access bypassing Gitaly
 | 
			
		||||
 | 
			
		||||
While it is possible to access Gitaly repositories stored on disk directly with a Git client,
 | 
			
		||||
it is not advisable because Gitaly is being continuously improved and changed. Theses improvements may invalidate assumptions, resulting in performance degradation, instability, and even data loss.
 | 
			
		||||
 | 
			
		||||
Gitaly has optimizations, such as the
 | 
			
		||||
[`info/refs` advertisement cache](https://gitlab.com/gitlab-org/gitaly/blob/master/doc/design_diskcache.md),
 | 
			
		||||
that rely on Gitaly controlling and monitoring access to repositories via the
 | 
			
		||||
official gRPC interface. Likewise, Praefect has optimizations, such as fault
 | 
			
		||||
tolerance and distributed reads, that depend on the gRPC interface and
 | 
			
		||||
database to determine repository state.
 | 
			
		||||
 | 
			
		||||
For these reasons, **accessing repositories directly is done at your own risk
 | 
			
		||||
and is not supported**.
 | 
			
		||||
 | 
			
		||||
## Direct access to Git in GitLab
 | 
			
		||||
 | 
			
		||||
Direct access to Git uses code in GitLab known as the "Rugged patches".
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -32,7 +32,6 @@ pipelines for merge requests take precedence over the other regular pipelines.
 | 
			
		|||
 | 
			
		||||
To enable pipelines for merge requests:
 | 
			
		||||
 | 
			
		||||
- You must have maintainer [permissions](../../user/permissions.md).
 | 
			
		||||
- Your repository must be a GitLab repository, not an
 | 
			
		||||
  [external repository](../ci_cd_for_external_repos/index.md).
 | 
			
		||||
- [In GitLab 11.10 and later](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/25504),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2465,8 +2465,6 @@ The `stop_review_app` job is **required** to have the following keywords defined
 | 
			
		|||
- `when` - [reference](#when)
 | 
			
		||||
- `environment:name`
 | 
			
		||||
- `environment:action`
 | 
			
		||||
- `stage` should be the same as the `review_app` in order for the environment
 | 
			
		||||
  to stop automatically when the branch is deleted
 | 
			
		||||
 | 
			
		||||
Additionally, both jobs should have matching [`rules`](../yaml/README.md#onlyexcept-basic)
 | 
			
		||||
or [`only/except`](../yaml/README.md#onlyexcept-basic) configuration. In the example
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -453,6 +453,28 @@ are:
 | 
			
		|||
To reduce unnecessary differences between two distribution methods, Omnibus and
 | 
			
		||||
CNG **should always use the same Go version**.
 | 
			
		||||
 | 
			
		||||
### Supporting multiple Go versions
 | 
			
		||||
 | 
			
		||||
Individual Golang-projects need to support multiple Go versions for the following reasons:
 | 
			
		||||
 | 
			
		||||
1. When a new Go release is out, we should start integrating it into the CI pipelines to verify compatibility with the new compiler.
 | 
			
		||||
1. We must support the [Omnibus official Go version](#updating-go-version), which may be behind the latest minor release.
 | 
			
		||||
1. When Omnibus switches Go version, we still may need to support the old one for security backports.
 | 
			
		||||
 | 
			
		||||
These 3 requirements may easily be satisfied by keeping support for the 3 latest minor versions of Go.
 | 
			
		||||
 | 
			
		||||
It's ok to drop support for the oldest Go version and support only 2 latest releases,
 | 
			
		||||
if this is enough to support backports to the last 3 GitLab minor releases.
 | 
			
		||||
 | 
			
		||||
Example:
 | 
			
		||||
 | 
			
		||||
In case we want to drop support for `go 1.11` in GitLab `12.10`, we need to verify which Go versions we are using in `12.9`, `12.8`, and `12.7`.
 | 
			
		||||
 | 
			
		||||
We will not consider the active milestone, `12.10`, because a backport for `12.7` will be required in case of a critical security release.
 | 
			
		||||
 | 
			
		||||
1. If both [Omnibus and CNG](#updating-go-version) were using Go `1.12` since GitLab `12.7`, then we safely drop support for `1.11`.
 | 
			
		||||
1. If Omnibus or CNG were using `1.11` in GitLab `12.7`, then we still need to keep support for Go `1.11` for easier backporting of security fixes.
 | 
			
		||||
 | 
			
		||||
## Secure Team standards and style guidelines
 | 
			
		||||
 | 
			
		||||
The following are some style guidelines that are specific to the Secure Team.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -175,9 +175,9 @@ Jobs can have an `urgency` attribute set, which can be `:high`,
 | 
			
		|||
 | 
			
		||||
| **Urgency**  | **Queue Scheduling Target** | **Execution Latency Requirement**  |
 | 
			
		||||
|--------------|-----------------------------|------------------------------------|
 | 
			
		||||
| `:high`      | 100 milliseconds            | p50 of 1 second, p99 of 10 seconds |
 | 
			
		||||
| `:low`       | 1 minute                    | Maximum run time of 1 hour         |
 | 
			
		||||
| `:throttled` | None                        | Maximum run time of 1 hour         |
 | 
			
		||||
| `:high`      | 10 seconds                  | p50 of 1 second, p99 of 10 seconds |
 | 
			
		||||
| `:low`       | 1 minute                    | Maximum run time of 5 minutes      |
 | 
			
		||||
| `:throttled` | None                        | Maximum run time of 5 minutes      |
 | 
			
		||||
 | 
			
		||||
To set a job's urgency, use the `urgency` class method:
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -665,6 +665,7 @@ appear to be associated to any of the services running, since they all appear to
 | 
			
		|||
| `ci_triggers`                                             | `usage_activity_by_stage`            | `verify`      |                  |         | Triggers enabled                                                           |
 | 
			
		||||
| `clusters_applications_runner`                            | `usage_activity_by_stage`            | `verify`      |                  |         | Unique clusters with Runner enabled                                        |
 | 
			
		||||
| `projects_reporting_ci_cd_back_to_github: 0`              | `usage_activity_by_stage`            | `verify`      |                  |         | Unique projects with a GitHub pipeline enabled                             |
 | 
			
		||||
| `merge_requests_users`                                    | `usage_activity_by_stage_monthly`    | `create`      |                  |         | Unique count of users who used a merge request                             |
 | 
			
		||||
| `nodes`                                                   | `topology`                           | `enablement`  |                  |         | The list of server nodes on which GitLab components are running            |
 | 
			
		||||
| `duration_s`                                              | `topology`                           | `enablement`  |                  |         | Time it took to collect topology data                                      |
 | 
			
		||||
| `node_memory_total_bytes`                                 | `topology > nodes`                   | `enablement`  |                  |         | The total available memory of this node                                    |
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -54,12 +54,14 @@ class Feature
 | 
			
		|||
    # unless set explicitly.  The default is `disabled`
 | 
			
		||||
    # TODO: remove the `default_enabled:` and read it from the `defintion_yaml`
 | 
			
		||||
    # check: https://gitlab.com/gitlab-org/gitlab/-/issues/30228
 | 
			
		||||
    def enabled?(key, thing = nil, default_enabled: false)
 | 
			
		||||
    def enabled?(key, thing = nil, type: :development, default_enabled: false)
 | 
			
		||||
      if check_feature_flags_definition?
 | 
			
		||||
        if thing && !thing.respond_to?(:flipper_id)
 | 
			
		||||
          raise InvalidFeatureFlagError,
 | 
			
		||||
            "The thing '#{thing.class.name}' for feature flag '#{key}' needs to include `FeatureGate` or implement `flipper_id`"
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        Feature::Definition.valid_usage!(key, type: type, default_enabled: default_enabled)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      # During setup the database does not exist yet. So we haven't stored a value
 | 
			
		||||
| 
						 | 
				
			
			@ -75,9 +77,9 @@ class Feature
 | 
			
		|||
      !default_enabled || Feature.persisted_name?(feature.name) ? feature.enabled?(thing) : true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def disabled?(key, thing = nil, default_enabled: false)
 | 
			
		||||
    def disabled?(key, thing = nil, type: :development, default_enabled: false)
 | 
			
		||||
      # we need to make different method calls to make it easy to mock / define expectations in test mode
 | 
			
		||||
      thing.nil? ? !enabled?(key, default_enabled: default_enabled) : !enabled?(key, thing, default_enabled: default_enabled)
 | 
			
		||||
      thing.nil? ? !enabled?(key, type: type, default_enabled: default_enabled) : !enabled?(key, thing, type: type, default_enabled: default_enabled)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def enable(key, thing = true)
 | 
			
		||||
| 
						 | 
				
			
			@ -129,6 +131,12 @@ class Feature
 | 
			
		|||
    def register_feature_groups
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def register_definitions
 | 
			
		||||
      return unless check_feature_flags_definition?
 | 
			
		||||
 | 
			
		||||
      Feature::Definition.load_all!
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def flipper
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,137 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Feature
 | 
			
		||||
  class Definition
 | 
			
		||||
    include ::Feature::Shared
 | 
			
		||||
 | 
			
		||||
    attr_reader :path
 | 
			
		||||
    attr_reader :attributes
 | 
			
		||||
 | 
			
		||||
    PARAMS.each do |param|
 | 
			
		||||
      define_method(param) do
 | 
			
		||||
        attributes[param]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def initialize(path, opts = {})
 | 
			
		||||
      @path = path
 | 
			
		||||
      @attributes = {}
 | 
			
		||||
 | 
			
		||||
      # assign nil, for all unknown opts
 | 
			
		||||
      PARAMS.each do |param|
 | 
			
		||||
        @attributes[param] = opts[param]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def key
 | 
			
		||||
      name.to_sym
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def validate!
 | 
			
		||||
      unless name.present?
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "Feature flag is missing name"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      unless path.present?
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' is missing path"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      unless type.present?
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' is missing type. Ensure to update #{path}"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      unless Definition::TYPES.include?(type.to_sym)
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' type '#{type}' is invalid. Ensure to update #{path}"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      unless File.basename(path, ".yml") == name
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' has an invalid path: '#{path}'. Ensure to update #{path}"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      unless File.basename(File.dirname(path)) == type
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' has an invalid type: '#{path}'. Ensure to update #{path}"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      if default_enabled.nil?
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "Feature flag '#{name}' is missing default_enabled. Ensure to update #{path}"
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def valid_usage!(type_in_code:, default_enabled_in_code:)
 | 
			
		||||
      unless Array(type).include?(type_in_code.to_s)
 | 
			
		||||
        # Raise exception in test and dev
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "The `type:` of `#{key}` is not equal to config: " \
 | 
			
		||||
          "#{type_in_code} vs #{type}. Ensure to use valid type in #{path} or ensure that you use " \
 | 
			
		||||
          "a valid syntax: #{TYPES.dig(type, :example)}"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      # We accept an array of defaults as some features are undefined
 | 
			
		||||
      # and have `default_enabled: true/false`
 | 
			
		||||
      unless Array(default_enabled).include?(default_enabled_in_code)
 | 
			
		||||
        # Raise exception in test and dev
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "The `default_enabled:` of `#{key}` is not equal to config: " \
 | 
			
		||||
          "#{default_enabled_in_code} vs #{default_enabled}. Ensure to update #{path}"
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def to_h
 | 
			
		||||
      attributes
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    class << self
 | 
			
		||||
      def paths
 | 
			
		||||
        @paths ||= [Rails.root.join('config', 'feature_flags', '**', '*.yml')]
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def definitions
 | 
			
		||||
        @definitions ||= {}
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def load_all!
 | 
			
		||||
        definitions.clear
 | 
			
		||||
 | 
			
		||||
        paths.each do |glob_path|
 | 
			
		||||
          load_all_from_path!(glob_path)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        definitions
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def valid_usage!(key, type:, default_enabled:)
 | 
			
		||||
        if definition = definitions[key.to_sym]
 | 
			
		||||
          definition.valid_usage!(type_in_code: type, default_enabled_in_code: default_enabled)
 | 
			
		||||
        elsif type_definition = self::TYPES[type]
 | 
			
		||||
          raise InvalidFeatureFlagError, "Missing feature definition for `#{key}`" unless type_definition[:optional]
 | 
			
		||||
        else
 | 
			
		||||
          raise InvalidFeatureFlagError, "Unknown feature flag type used: `#{type}`"
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      private
 | 
			
		||||
 | 
			
		||||
      def load_from_file(path)
 | 
			
		||||
        definition = File.read(path)
 | 
			
		||||
        definition = YAML.safe_load(definition)
 | 
			
		||||
        definition.deep_symbolize_keys!
 | 
			
		||||
 | 
			
		||||
        self.new(path, definition).tap(&:validate!)
 | 
			
		||||
      rescue => e
 | 
			
		||||
        raise Feature::InvalidFeatureFlagError, "Invalid definition for `#{path}`: #{e.message}"
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def load_all_from_path!(glob_path)
 | 
			
		||||
        Dir.glob(glob_path).each do |path|
 | 
			
		||||
          definition = load_from_file(path)
 | 
			
		||||
 | 
			
		||||
          if previous = definitions[definition.key]
 | 
			
		||||
            raise InvalidFeatureFlagError, "Feature flag '#{definition.key}' is already defined in '#{previous.path}'"
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          definitions[definition.key] = definition
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
Feature::Definition.prepend_if_ee('EE::Feature::Definition')
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
# This file can contain only simple constructs as it is shared between:
 | 
			
		||||
# 1. `Pure Ruby`: `bin/feature-flag`
 | 
			
		||||
# 2. `GitLab Rails`: `lib/feature/definition.rb`
 | 
			
		||||
 | 
			
		||||
class Feature
 | 
			
		||||
  module Shared
 | 
			
		||||
    # optional: defines if a on-disk definition is required for this feature flag type
 | 
			
		||||
    # rollout_issue: defines if `bin/feature-flag` asks for rollout issue
 | 
			
		||||
    # example: usage being shown when exception is raised
 | 
			
		||||
    TYPES = {
 | 
			
		||||
      development: {
 | 
			
		||||
        description: 'Short lived, used to enable unfinished code to be deployed',
 | 
			
		||||
        optional: true,
 | 
			
		||||
        rollout_issue: true,
 | 
			
		||||
        example: <<-EOS
 | 
			
		||||
          Feature.enabled?(:my_feature_flag)
 | 
			
		||||
          Feature.enabled?(:my_feature_flag, type: :development)
 | 
			
		||||
        EOS
 | 
			
		||||
      }
 | 
			
		||||
    }.freeze
 | 
			
		||||
 | 
			
		||||
    PARAMS = %i[
 | 
			
		||||
      name
 | 
			
		||||
      default_enabled
 | 
			
		||||
      type
 | 
			
		||||
      introduced_by_url
 | 
			
		||||
      rollout_issue_url
 | 
			
		||||
      group
 | 
			
		||||
    ].freeze
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -42,6 +42,8 @@ module Gitlab
 | 
			
		|||
          snippet.repository.expire_exists_cache
 | 
			
		||||
 | 
			
		||||
          raise SnippetRepositoryError, _("Invalid repository bundle for snippet with id %{snippet_id}") % { snippet_id: snippet.id }
 | 
			
		||||
        else
 | 
			
		||||
          Snippets::UpdateStatisticsService.new(snippet).execute
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -159,8 +159,7 @@ module Gitlab
 | 
			
		|||
            usage_counters,
 | 
			
		||||
            user_preferences_usage,
 | 
			
		||||
            ingress_modsecurity_usage,
 | 
			
		||||
            container_expiration_policies_usage,
 | 
			
		||||
            merge_requests_usage(last_28_days_time_period)
 | 
			
		||||
            container_expiration_policies_usage
 | 
			
		||||
          ).tap do |data|
 | 
			
		||||
            data[:snippets] = data[:personal_snippets] + data[:project_snippets]
 | 
			
		||||
          end
 | 
			
		||||
| 
						 | 
				
			
			@ -405,23 +404,19 @@ module Gitlab
 | 
			
		|||
      end
 | 
			
		||||
 | 
			
		||||
      # rubocop: disable CodeReuse/ActiveRecord
 | 
			
		||||
      def merge_requests_usage(time_period)
 | 
			
		||||
      def merge_requests_users(time_period)
 | 
			
		||||
        query =
 | 
			
		||||
          Event
 | 
			
		||||
            .where(target_type: Event::TARGET_TYPES[:merge_request].to_s)
 | 
			
		||||
            .where(time_period)
 | 
			
		||||
 | 
			
		||||
        merge_request_users = distinct_count(
 | 
			
		||||
        distinct_count(
 | 
			
		||||
          query,
 | 
			
		||||
          :author_id,
 | 
			
		||||
          batch_size: 5_000, # Based on query performance, this is the optimal batch size.
 | 
			
		||||
          start: User.minimum(:id),
 | 
			
		||||
          finish: User.maximum(:id)
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        {
 | 
			
		||||
          merge_requests_users: merge_request_users
 | 
			
		||||
        }
 | 
			
		||||
      end
 | 
			
		||||
      # rubocop: enable CodeReuse/ActiveRecord
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -477,9 +472,10 @@ module Gitlab
 | 
			
		|||
      end
 | 
			
		||||
      # rubocop: enable CodeReuse/ActiveRecord
 | 
			
		||||
 | 
			
		||||
      # Omitted because no user, creator or author associated: `lfs_objects`, `pool_repositories`, `web_hooks`
 | 
			
		||||
      def usage_activity_by_stage_create(time_period)
 | 
			
		||||
        {}
 | 
			
		||||
        {}.tap do |h|
 | 
			
		||||
          h[:merge_requests_users] = merge_requests_users(time_period) if time_period.present?
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      # Omitted because no user, creator or author associated: `campaigns_imported_from_github`, `ldap_group_links`
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19552,6 +19552,9 @@ msgstr ""
 | 
			
		|||
msgid "Revoke"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Revoked"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Revoked impersonation token %{token_name}!"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,191 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'spec_helper'
 | 
			
		||||
 | 
			
		||||
load File.expand_path('../../bin/feature-flag', __dir__)
 | 
			
		||||
 | 
			
		||||
RSpec.describe 'bin/feature-flag' do
 | 
			
		||||
  using RSpec::Parameterized::TableSyntax
 | 
			
		||||
 | 
			
		||||
  describe FeatureFlagCreator do
 | 
			
		||||
    let(:argv) { %w[feature-flag-name -t development -g group::memory -i https://url] }
 | 
			
		||||
    let(:options) { FeatureFlagOptionParser.parse(argv) }
 | 
			
		||||
    let(:creator) { described_class.new(options) }
 | 
			
		||||
    let(:existing_flag) { File.join('config', 'feature_flags', 'development', 'existing-feature-flag.yml') }
 | 
			
		||||
 | 
			
		||||
    before do
 | 
			
		||||
      # create a dummy feature flag
 | 
			
		||||
      FileUtils.mkdir_p(File.dirname(existing_flag))
 | 
			
		||||
      File.write(existing_flag, '{}')
 | 
			
		||||
 | 
			
		||||
      # ignore writes
 | 
			
		||||
      allow(File).to receive(:write).and_return(true)
 | 
			
		||||
 | 
			
		||||
      # ignore stdin
 | 
			
		||||
      allow($stdin).to receive(:gets).and_raise('EOF')
 | 
			
		||||
 | 
			
		||||
      # ignore Git commands
 | 
			
		||||
      allow(creator).to receive(:branch_name) { 'feature-branch' }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    after do
 | 
			
		||||
      FileUtils.rm_f(existing_flag)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    subject { creator.execute }
 | 
			
		||||
 | 
			
		||||
    it 'properly creates a feature flag' do
 | 
			
		||||
      expect(File).to receive(:write).with(
 | 
			
		||||
        File.join('config', 'feature_flags', 'development', 'feature-flag-name.yml'),
 | 
			
		||||
        anything)
 | 
			
		||||
 | 
			
		||||
      expect do
 | 
			
		||||
        subject
 | 
			
		||||
      end.to output(/name: feature-flag-name/).to_stdout
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when running on master' do
 | 
			
		||||
      it 'requires feature branch' do
 | 
			
		||||
        expect(creator).to receive(:branch_name) { 'master' }
 | 
			
		||||
 | 
			
		||||
        expect { subject }.to raise_error(FeatureFlagHelpers::Abort, /Create a branch first/)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'validates feature flag name' do
 | 
			
		||||
      where(:argv, :ex) do
 | 
			
		||||
        %w[.invalid.feature.flag] | /Provide a name for the feature flag that is/
 | 
			
		||||
        %w[existing-feature-flag] | /already exists!/
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      with_them do
 | 
			
		||||
        it do
 | 
			
		||||
          expect { subject }.to raise_error(ex)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe FeatureFlagOptionParser do
 | 
			
		||||
    describe '.parse' do
 | 
			
		||||
      where(:param, :argv, :result) do
 | 
			
		||||
        :name              | %w[foo]                                 | 'foo'
 | 
			
		||||
        :amend             | %w[foo --amend]                         | true
 | 
			
		||||
        :force             | %w[foo -f]                              | true
 | 
			
		||||
        :force             | %w[foo --force]                         | true
 | 
			
		||||
        :ee                | %w[foo -e]                              | true
 | 
			
		||||
        :ee                | %w[foo --ee]                            | true
 | 
			
		||||
        :introduced_by_url | %w[foo -m https://url]                  | 'https://url'
 | 
			
		||||
        :introduced_by_url | %w[foo --introduced-by-url https://url] | 'https://url'
 | 
			
		||||
        :rollout_issue_url | %w[foo -i https://url]                  | 'https://url'
 | 
			
		||||
        :rollout_issue_url | %w[foo --rollout-issue-url https://url] | 'https://url'
 | 
			
		||||
        :dry_run           | %w[foo -n]                              | true
 | 
			
		||||
        :dry_run           | %w[foo --dry-run]                       | true
 | 
			
		||||
        :type              | %w[foo -t development]                  | :development
 | 
			
		||||
        :type              | %w[foo --type development]              | :development
 | 
			
		||||
        :type              | %w[foo -t invalid]                      | nil
 | 
			
		||||
        :type              | %w[foo --type invalid]                  | nil
 | 
			
		||||
        :group             | %w[foo -g group::memory]                | 'group::memory'
 | 
			
		||||
        :group             | %w[foo --group group::memory]           | 'group::memory'
 | 
			
		||||
        :group             | %w[foo -g invalid]                      | nil
 | 
			
		||||
        :group             | %w[foo --group invalid]                 | nil
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      with_them do
 | 
			
		||||
        it do
 | 
			
		||||
          options = described_class.parse(Array(argv))
 | 
			
		||||
 | 
			
		||||
          expect(options.public_send(param)).to eq(result)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'missing feature flag name' do
 | 
			
		||||
        expect do
 | 
			
		||||
          expect { described_class.parse(%w[--amend]) }.to output(/Feature flag name is required/).to_stdout
 | 
			
		||||
        end.to raise_error(FeatureFlagHelpers::Abort)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'parses -h' do
 | 
			
		||||
        expect do
 | 
			
		||||
          expect { described_class.parse(%w[foo -h]) }.to output(/Usage:/).to_stdout
 | 
			
		||||
        end.to raise_error(FeatureFlagHelpers::Done)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    describe '.read_type' do
 | 
			
		||||
      let(:type) { 'development' }
 | 
			
		||||
 | 
			
		||||
      it 'reads type from $stdin' do
 | 
			
		||||
        expect($stdin).to receive(:gets).and_return(type)
 | 
			
		||||
        expect do
 | 
			
		||||
          expect(described_class.read_type).to eq(:development)
 | 
			
		||||
        end.to output(/specify the type/).to_stdout
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'invalid type given' do
 | 
			
		||||
        let(:type) { 'invalid' }
 | 
			
		||||
 | 
			
		||||
        it 'shows error message and retries' do
 | 
			
		||||
          expect($stdin).to receive(:gets).and_return(type)
 | 
			
		||||
          expect($stdin).to receive(:gets).and_raise('EOF')
 | 
			
		||||
 | 
			
		||||
          expect do
 | 
			
		||||
            expect { described_class.read_type }.to raise_error(/EOF/)
 | 
			
		||||
          end.to output(/specify the type/).to_stdout
 | 
			
		||||
            .and output(/Invalid type specified/).to_stderr
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    describe '.read_group' do
 | 
			
		||||
      let(:group) { 'group::memory' }
 | 
			
		||||
 | 
			
		||||
      it 'reads type from $stdin' do
 | 
			
		||||
        expect($stdin).to receive(:gets).and_return(group)
 | 
			
		||||
        expect do
 | 
			
		||||
          expect(described_class.read_group).to eq('group::memory')
 | 
			
		||||
        end.to output(/specify the group/).to_stdout
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'invalid group given' do
 | 
			
		||||
        let(:type) { 'invalid' }
 | 
			
		||||
 | 
			
		||||
        it 'shows error message and retries' do
 | 
			
		||||
          expect($stdin).to receive(:gets).and_return(type)
 | 
			
		||||
          expect($stdin).to receive(:gets).and_raise('EOF')
 | 
			
		||||
 | 
			
		||||
          expect do
 | 
			
		||||
            expect { described_class.read_group }.to raise_error(/EOF/)
 | 
			
		||||
          end.to output(/specify the group/).to_stdout
 | 
			
		||||
            .and output(/Group needs to include/).to_stderr
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    describe '.rollout_issue_url' do
 | 
			
		||||
      let(:options) { OpenStruct.new(name: 'foo', type: :development) }
 | 
			
		||||
      let(:url) { 'https://issue' }
 | 
			
		||||
 | 
			
		||||
      it 'reads type from $stdin' do
 | 
			
		||||
        expect($stdin).to receive(:gets).and_return(url)
 | 
			
		||||
        expect do
 | 
			
		||||
          expect(described_class.read_issue_url(options)).to eq('https://issue')
 | 
			
		||||
        end.to output(/Paste URL here/).to_stdout
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'invalid URL given' do
 | 
			
		||||
        let(:type) { 'invalid' }
 | 
			
		||||
 | 
			
		||||
        it 'shows error message and retries' do
 | 
			
		||||
          expect($stdin).to receive(:gets).and_return(type)
 | 
			
		||||
          expect($stdin).to receive(:gets).and_raise('EOF')
 | 
			
		||||
 | 
			
		||||
          expect do
 | 
			
		||||
            expect { described_class.read_issue_url(options) }.to raise_error(/EOF/)
 | 
			
		||||
          end.to output(/Paste URL here/).to_stdout
 | 
			
		||||
            .and output(/URL needs to start/).to_stderr
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -63,11 +63,17 @@ describe('Monitoring store actions', () => {
 | 
			
		|||
  let store;
 | 
			
		||||
  let state;
 | 
			
		||||
 | 
			
		||||
  let dispatch;
 | 
			
		||||
  let commit;
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    store = createStore({ getters });
 | 
			
		||||
    state = store.state.monitoringDashboard;
 | 
			
		||||
    mock = new MockAdapter(axios);
 | 
			
		||||
 | 
			
		||||
    commit = jest.fn();
 | 
			
		||||
    dispatch = jest.fn();
 | 
			
		||||
 | 
			
		||||
    jest.spyOn(commonUtils, 'backOff').mockImplementation(callback => {
 | 
			
		||||
      const q = new Promise((resolve, reject) => {
 | 
			
		||||
        const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg));
 | 
			
		||||
| 
						 | 
				
			
			@ -200,12 +206,8 @@ describe('Monitoring store actions', () => {
 | 
			
		|||
  // Metrics dashboard
 | 
			
		||||
 | 
			
		||||
  describe('fetchDashboard', () => {
 | 
			
		||||
    let dispatch;
 | 
			
		||||
    let commit;
 | 
			
		||||
    const response = metricsDashboardResponse;
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      dispatch = jest.fn();
 | 
			
		||||
      commit = jest.fn();
 | 
			
		||||
      state.dashboardEndpoint = '/dashboard';
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -292,14 +294,6 @@ describe('Monitoring store actions', () => {
 | 
			
		|||
  });
 | 
			
		||||
 | 
			
		||||
  describe('receiveMetricsDashboardSuccess', () => {
 | 
			
		||||
    let commit;
 | 
			
		||||
    let dispatch;
 | 
			
		||||
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      commit = jest.fn();
 | 
			
		||||
      dispatch = jest.fn();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('stores groups', () => {
 | 
			
		||||
      const response = metricsDashboardResponse;
 | 
			
		||||
      receiveMetricsDashboardSuccess({ state, commit, dispatch }, { response });
 | 
			
		||||
| 
						 | 
				
			
			@ -359,13 +353,8 @@ describe('Monitoring store actions', () => {
 | 
			
		|||
  // Metrics
 | 
			
		||||
 | 
			
		||||
  describe('fetchDashboardData', () => {
 | 
			
		||||
    let commit;
 | 
			
		||||
    let dispatch;
 | 
			
		||||
 | 
			
		||||
    beforeEach(() => {
 | 
			
		||||
      jest.spyOn(Tracking, 'event');
 | 
			
		||||
      commit = jest.fn();
 | 
			
		||||
      dispatch = jest.fn();
 | 
			
		||||
 | 
			
		||||
      state.timeRange = defaultTimeRange;
 | 
			
		||||
    });
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,209 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'spec_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Feature::Definition do
 | 
			
		||||
  let(:attributes) do
 | 
			
		||||
    { name: 'feature_flag',
 | 
			
		||||
      type: 'development',
 | 
			
		||||
      default_enabled: true }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  let(:path) { File.join('development', 'feature_flag.yml') }
 | 
			
		||||
  let(:definition) { described_class.new(path, attributes) }
 | 
			
		||||
  let(:yaml_content) { attributes.deep_stringify_keys.to_yaml }
 | 
			
		||||
 | 
			
		||||
  describe '#key' do
 | 
			
		||||
    subject { definition.key }
 | 
			
		||||
 | 
			
		||||
    it 'returns a symbol from name' do
 | 
			
		||||
      is_expected.to eq(:feature_flag)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#validate!' do
 | 
			
		||||
    using RSpec::Parameterized::TableSyntax
 | 
			
		||||
 | 
			
		||||
    where(:param, :value, :result) do
 | 
			
		||||
      :name            | nil                        | /Feature flag is missing name/
 | 
			
		||||
      :path            | nil                        | /Feature flag 'feature_flag' is missing path/
 | 
			
		||||
      :type            | nil                        | /Feature flag 'feature_flag' is missing type/
 | 
			
		||||
      :type            | 'invalid'                  | /Feature flag 'feature_flag' type 'invalid' is invalid/
 | 
			
		||||
      :path            | 'development/invalid.yml'  | /Feature flag 'feature_flag' has an invalid path/
 | 
			
		||||
      :path            | 'invalid/feature_flag.yml' | /Feature flag 'feature_flag' has an invalid type/
 | 
			
		||||
      :default_enabled | nil                        | /Feature flag 'feature_flag' is missing default_enabled/
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    with_them do
 | 
			
		||||
      let(:params) { attributes.merge(path: path) }
 | 
			
		||||
 | 
			
		||||
      before do
 | 
			
		||||
        params[param] = value
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it do
 | 
			
		||||
        expect do
 | 
			
		||||
          described_class.new(
 | 
			
		||||
            params[:path], params.except(:path)
 | 
			
		||||
          ).validate!
 | 
			
		||||
        end.to raise_error(result)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#valid_usage!' do
 | 
			
		||||
    context 'validates type' do
 | 
			
		||||
      it 'raises exception for invalid type' do
 | 
			
		||||
        expect { definition.valid_usage!(type_in_code: :invalid, default_enabled_in_code: false) }
 | 
			
		||||
          .to raise_error(/The `type:` of `feature_flag` is not equal to config/)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'validates default enabled' do
 | 
			
		||||
      it 'raises exception for different value' do
 | 
			
		||||
        expect { definition.valid_usage!(type_in_code: :development, default_enabled_in_code: false) }
 | 
			
		||||
          .to raise_error(/The `default_enabled:` of `feature_flag` is not equal to config/)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '.paths' do
 | 
			
		||||
    it 'returns at least one path' do
 | 
			
		||||
      expect(described_class.paths).not_to be_empty
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '.load_from_file' do
 | 
			
		||||
    it 'properly loads a definition from file' do
 | 
			
		||||
      expect(File).to receive(:read).with(path) { yaml_content }
 | 
			
		||||
 | 
			
		||||
      expect(described_class.send(:load_from_file, path).attributes)
 | 
			
		||||
        .to eq(definition.attributes)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'for missing file' do
 | 
			
		||||
      let(:path) { 'missing/feature-flag/file.yml' }
 | 
			
		||||
 | 
			
		||||
      it 'raises exception' do
 | 
			
		||||
        expect do
 | 
			
		||||
          described_class.send(:load_from_file, path)
 | 
			
		||||
        end.to raise_error(/Invalid definition for/)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'for invalid definition' do
 | 
			
		||||
      it 'raises exception' do
 | 
			
		||||
        expect(File).to receive(:read).with(path) { '{}' }
 | 
			
		||||
 | 
			
		||||
        expect do
 | 
			
		||||
          described_class.send(:load_from_file, path)
 | 
			
		||||
        end.to raise_error(/Feature flag is missing name/)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '.load_all!' do
 | 
			
		||||
    let(:store1) { Dir.mktmpdir('path1') }
 | 
			
		||||
    let(:store2) { Dir.mktmpdir('path2') }
 | 
			
		||||
 | 
			
		||||
    before do
 | 
			
		||||
      allow(described_class).to receive(:paths).and_return(
 | 
			
		||||
        [
 | 
			
		||||
          File.join(store1, '**', '*.yml'),
 | 
			
		||||
          File.join(store2, '**', '*.yml')
 | 
			
		||||
        ]
 | 
			
		||||
      )
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it "when there's no feature flags a list of definitions is empty" do
 | 
			
		||||
      expect(described_class.load_all!).to be_empty
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it "when there's a single feature flag it properly loads them" do
 | 
			
		||||
      write_feature_flag(store1, path, yaml_content)
 | 
			
		||||
 | 
			
		||||
      expect(described_class.load_all!).to be_one
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it "when the same feature flag is stored multiple times raises exception" do
 | 
			
		||||
      write_feature_flag(store1, path, yaml_content)
 | 
			
		||||
      write_feature_flag(store2, path, yaml_content)
 | 
			
		||||
 | 
			
		||||
      expect { described_class.load_all! }
 | 
			
		||||
        .to raise_error(/Feature flag 'feature_flag' is already defined/)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it "when one of the YAMLs is invalid it does raise exception" do
 | 
			
		||||
      write_feature_flag(store1, path, '{}')
 | 
			
		||||
 | 
			
		||||
      expect { described_class.load_all! }
 | 
			
		||||
        .to raise_error(/Feature flag is missing name/)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    after do
 | 
			
		||||
      FileUtils.rm_rf(store1)
 | 
			
		||||
      FileUtils.rm_rf(store2)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def write_feature_flag(store, path, content)
 | 
			
		||||
      path = File.join(store, path)
 | 
			
		||||
      dir = File.dirname(path)
 | 
			
		||||
      FileUtils.mkdir_p(dir)
 | 
			
		||||
      File.write(path, content)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '.valid_usage!' do
 | 
			
		||||
    before do
 | 
			
		||||
      allow(described_class).to receive(:definitions) do
 | 
			
		||||
        { definition.key => definition }
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when a known feature flag is used' do
 | 
			
		||||
      it 'validates it usage' do
 | 
			
		||||
        expect(definition).to receive(:valid_usage!)
 | 
			
		||||
 | 
			
		||||
        described_class.valid_usage!(:feature_flag, type: :development, default_enabled: false)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when an unknown feature flag is used' do
 | 
			
		||||
      context 'for a type that is required to have all feature flags registered' do
 | 
			
		||||
        before do
 | 
			
		||||
          stub_const('Feature::Shared::TYPES', {
 | 
			
		||||
            development: { optional: false }
 | 
			
		||||
          })
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'raises exception' do
 | 
			
		||||
          expect do
 | 
			
		||||
            described_class.valid_usage!(:unknown_feature_flag, type: :development, default_enabled: false)
 | 
			
		||||
          end.to raise_error(/Missing feature definition for `unknown_feature_flag`/)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'for a type that is optional' do
 | 
			
		||||
        before do
 | 
			
		||||
          stub_const('Feature::Shared::TYPES', {
 | 
			
		||||
            development: { optional: true }
 | 
			
		||||
          })
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'does not raise exception' do
 | 
			
		||||
          expect do
 | 
			
		||||
            described_class.valid_usage!(:unknown_feature_flag, type: :development, default_enabled: false)
 | 
			
		||||
          end.not_to raise_error
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'for an unknown type' do
 | 
			
		||||
        it 'raises exception' do
 | 
			
		||||
          expect do
 | 
			
		||||
            described_class.valid_usage!(:unknown_feature_flag, type: :unknown_type, default_enabled: false)
 | 
			
		||||
          end.to raise_error(/Unknown feature flag type used: `unknown_type`/)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -242,6 +242,36 @@ RSpec.describe Feature, stub_feature_flags: false do
 | 
			
		|||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'validates usage of feature flag with YAML definition' do
 | 
			
		||||
      let(:definition) do
 | 
			
		||||
        Feature::Definition.new('development/my_feature_flag.yml',
 | 
			
		||||
          name: 'my_feature_flag',
 | 
			
		||||
          type: 'development',
 | 
			
		||||
          default_enabled: false
 | 
			
		||||
        ).tap(&:validate!)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      before do
 | 
			
		||||
        allow(Feature::Definition).to receive(:definitions) do
 | 
			
		||||
          { definition.key => definition }
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'when usage is correct' do
 | 
			
		||||
        expect { described_class.enabled?(:my_feature_flag) }.not_to raise_error
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'when invalid type is used' do
 | 
			
		||||
        expect { described_class.enabled?(:my_feature_flag, type: :licensed) }
 | 
			
		||||
          .to raise_error(/The `type:` of/)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'when invalid default_enabled is used' do
 | 
			
		||||
        expect { described_class.enabled?(:my_feature_flag, default_enabled: true) }
 | 
			
		||||
          .to raise_error(/The `default_enabled:` of/)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '.disable?' do
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -35,6 +35,12 @@ RSpec.describe Gitlab::ImportExport::SnippetRepoRestorer do
 | 
			
		|||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'does not call snippet update statistics service' do
 | 
			
		||||
      expect(Snippets::UpdateStatisticsService).not_to receive(:new).with(snippet)
 | 
			
		||||
 | 
			
		||||
      restorer.restore
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when the repository creation fails' do
 | 
			
		||||
      it 'returns false' do
 | 
			
		||||
        allow_any_instance_of(Gitlab::BackgroundMigration::BackfillSnippetRepositories).to receive(:perform_by_ids).and_return(nil)
 | 
			
		||||
| 
						 | 
				
			
			@ -66,6 +72,10 @@ RSpec.describe Gitlab::ImportExport::SnippetRepoRestorer do
 | 
			
		|||
 | 
			
		||||
    before do
 | 
			
		||||
      expect(exporter.save).to be_truthy
 | 
			
		||||
 | 
			
		||||
      allow_next_instance_of(Snippets::RepositoryValidationService) do |instance|
 | 
			
		||||
        allow(instance).to receive(:execute).and_return(ServiceResponse.success)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when it is valid' do
 | 
			
		||||
| 
						 | 
				
			
			@ -115,5 +125,19 @@ RSpec.describe Gitlab::ImportExport::SnippetRepoRestorer do
 | 
			
		|||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'refreshes snippet statistics' do
 | 
			
		||||
      expect(snippet.statistics.commit_count).to be_zero
 | 
			
		||||
      expect(snippet.statistics.file_count).to be_zero
 | 
			
		||||
      expect(snippet.statistics.repository_size).to be_zero
 | 
			
		||||
 | 
			
		||||
      expect(Snippets::UpdateStatisticsService).to receive(:new).with(snippet).and_call_original
 | 
			
		||||
 | 
			
		||||
      restorer.restore
 | 
			
		||||
 | 
			
		||||
      expect(snippet.statistics.commit_count).not_to be_zero
 | 
			
		||||
      expect(snippet.statistics.file_count).not_to be_zero
 | 
			
		||||
      expect(snippet.statistics.repository_size).not_to be_zero
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -77,6 +77,22 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
 | 
			
		|||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'for create' do
 | 
			
		||||
      it 'include usage_activity_by_stage data' do
 | 
			
		||||
        expect(described_class.uncached_data[:usage_activity_by_stage][:create])
 | 
			
		||||
          .not_to include(
 | 
			
		||||
            :merge_requests_users
 | 
			
		||||
          )
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'includes monthly usage_activity_by_stage data' do
 | 
			
		||||
        expect(described_class.uncached_data[:usage_activity_by_stage_monthly][:create])
 | 
			
		||||
          .to include(
 | 
			
		||||
            :merge_requests_users
 | 
			
		||||
          )
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'ensures recorded_at is set before any other usage data calculation' do
 | 
			
		||||
      %i(alt_usage_data redis_usage_data distinct_count count).each do |method|
 | 
			
		||||
        expect(described_class).not_to receive(method)
 | 
			
		||||
| 
						 | 
				
			
			@ -662,7 +678,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '.merge_requests_usage' do
 | 
			
		||||
  describe '.merge_requests_users' do
 | 
			
		||||
    let(:time_period) { { created_at: 2.days.ago..Time.current } }
 | 
			
		||||
    let(:merge_request) { create(:merge_request) }
 | 
			
		||||
    let(:other_user) { create(:user) }
 | 
			
		||||
| 
						 | 
				
			
			@ -679,9 +695,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns the distinct count of users using merge requests (via events table) within the specified time period' do
 | 
			
		||||
      expect(described_class.merge_requests_usage(time_period)).to eq(
 | 
			
		||||
        merge_requests_users: 2
 | 
			
		||||
      )
 | 
			
		||||
      expect(described_class.merge_requests_users(time_period)).to eq(2)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -155,6 +155,9 @@ RSpec.configure do |config|
 | 
			
		|||
  config.before(:suite) do
 | 
			
		||||
    Timecop.safe_mode = true
 | 
			
		||||
    TestEnv.init
 | 
			
		||||
 | 
			
		||||
    # Reload all feature flags definitions
 | 
			
		||||
    Feature.register_definitions
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  config.after(:all) do
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -78,7 +78,6 @@ module UsageDataHelpers
 | 
			
		|||
      labels
 | 
			
		||||
      lfs_objects
 | 
			
		||||
      merge_requests
 | 
			
		||||
      merge_requests_users
 | 
			
		||||
      milestone_lists
 | 
			
		||||
      milestones
 | 
			
		||||
      notes
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue