summaryrefslogtreecommitdiff
path: root/tooling
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 18:18:33 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 18:18:33 +0000
commitf64a639bcfa1fc2bc89ca7db268f594306edfd7c (patch)
treea2c3c2ebcc3b45e596949db485d6ed18ffaacfa1 /tooling
parentbfbc3e0d6583ea1a91f627528bedc3d65ba4b10f (diff)
downloadgitlab-ce-f64a639bcfa1fc2bc89ca7db268f594306edfd7c.tar.gz
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc40
Diffstat (limited to 'tooling')
-rw-r--r--tooling/danger/base_linter.rb96
-rw-r--r--tooling/danger/changelog.rb43
-rw-r--r--tooling/danger/commit_linter.rb150
-rw-r--r--tooling/danger/emoji_checker.rb45
-rw-r--r--tooling/danger/helper.rb294
-rw-r--r--tooling/danger/merge_request_linter.rb30
-rw-r--r--tooling/danger/project_helper.rb181
-rw-r--r--tooling/danger/request_helper.rb23
-rw-r--r--tooling/danger/roulette.rb169
-rw-r--r--tooling/danger/teammate.rb121
-rw-r--r--tooling/danger/title_linting.rb38
-rw-r--r--tooling/danger/weightage.rb10
-rw-r--r--tooling/danger/weightage/maintainers.rb33
-rw-r--r--tooling/danger/weightage/reviewers.rb65
-rw-r--r--tooling/gitlab_danger.rb59
-rw-r--r--tooling/overcommit/Gemfile1
-rw-r--r--tooling/overcommit/Gemfile.lock14
-rw-r--r--tooling/rspec_flaky/config.rb27
-rw-r--r--tooling/rspec_flaky/example.rb53
-rw-r--r--tooling/rspec_flaky/flaky_example.rb40
-rw-r--r--tooling/rspec_flaky/flaky_examples_collection.rb38
-rw-r--r--tooling/rspec_flaky/listener.rb67
-rw-r--r--tooling/rspec_flaky/report.rb56
23 files changed, 488 insertions, 1165 deletions
diff --git a/tooling/danger/base_linter.rb b/tooling/danger/base_linter.rb
deleted file mode 100644
index c58f2d84dc8..00000000000
--- a/tooling/danger/base_linter.rb
+++ /dev/null
@@ -1,96 +0,0 @@
-# frozen_string_literal: true
-
-require_relative 'title_linting'
-
-module Tooling
- module Danger
- class BaseLinter
- MIN_SUBJECT_WORDS_COUNT = 3
- MAX_LINE_LENGTH = 72
-
- attr_reader :commit, :problems
-
- def self.problems_mapping
- {
- subject_too_short: "The %s must contain at least #{MIN_SUBJECT_WORDS_COUNT} words",
- subject_too_long: "The %s may not be longer than #{MAX_LINE_LENGTH} characters",
- subject_starts_with_lowercase: "The %s must start with a capital letter",
- subject_ends_with_a_period: "The %s must not end with a period"
- }
- end
-
- def self.subject_description
- 'commit subject'
- end
-
- def initialize(commit)
- @commit = commit
- @problems = {}
- end
-
- def failed?
- problems.any?
- end
-
- def add_problem(problem_key, *args)
- @problems[problem_key] = sprintf(self.class.problems_mapping[problem_key], *args)
- end
-
- def lint_subject
- if subject_too_short?
- add_problem(:subject_too_short, self.class.subject_description)
- end
-
- if subject_too_long?
- add_problem(:subject_too_long, self.class.subject_description)
- end
-
- if subject_starts_with_lowercase?
- add_problem(:subject_starts_with_lowercase, self.class.subject_description)
- end
-
- if subject_ends_with_a_period?
- add_problem(:subject_ends_with_a_period, self.class.subject_description)
- end
-
- self
- end
-
- private
-
- def subject
- TitleLinting.remove_draft_flag(message_parts[0])
- end
-
- def subject_too_short?
- subject.split(' ').length < MIN_SUBJECT_WORDS_COUNT
- end
-
- def subject_too_long?
- line_too_long?(subject)
- end
-
- def line_too_long?(line)
- line.length > MAX_LINE_LENGTH
- end
-
- def subject_starts_with_lowercase?
- return false if ('A'..'Z').cover?(subject[0])
-
- first_char = subject.sub(/\A(\[.+\]|\w+:)\s/, '')[0]
- first_char_downcased = first_char.downcase
- return true unless ('a'..'z').cover?(first_char_downcased)
-
- first_char.downcase == first_char
- end
-
- def subject_ends_with_a_period?
- subject.end_with?('.')
- end
-
- def message_parts
- @message_parts ||= commit.message.split("\n", 3)
- end
- end
- end
-end
diff --git a/tooling/danger/changelog.rb b/tooling/danger/changelog.rb
index f7f505f51a6..672d23d58e4 100644
--- a/tooling/danger/changelog.rb
+++ b/tooling/danger/changelog.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-require_relative 'title_linting'
+require 'gitlab/dangerfiles/title_linting'
module Tooling
module Danger
@@ -30,25 +30,35 @@ module Tooling
If this merge request [doesn't need a CHANGELOG entry](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry), feel free to ignore this message.
MSG
+ REQUIRED_CHANGELOG_REASONS = {
+ db_changes: 'introduces a database migration',
+ feature_flag_removed: 'removes a feature flag'
+ }.freeze
REQUIRED_CHANGELOG_MESSAGE = <<~MSG
To create a changelog entry, run the following:
#{CREATE_CHANGELOG_COMMAND}
- This merge request requires a changelog entry because it [introduces a database migration](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry).
+ This merge request requires a changelog entry because it [%<reason>s](https://docs.gitlab.com/ee/development/changelog.html#what-warrants-a-changelog-entry).
MSG
+ def required_reasons
+ [].tap do |reasons|
+ reasons << :db_changes if project_helper.changes.added.has_category?(:migration)
+ reasons << :feature_flag_removed if project_helper.changes.deleted.has_category?(:feature_flag)
+ end
+ end
+
def required?
- git.added_files.any? { |path| path =~ %r{\Adb/(migrate|post_migrate)/} }
+ required_reasons.any?
end
- alias_method :db_changes?, :required?
def optional?
categories_need_changelog? && without_no_changelog_label?
end
def found
- @found ||= git.added_files.find { |path| path =~ %r{\A(ee/)?(changelogs/unreleased)(-ee)?/} }
+ @found ||= project_helper.changes.added.by_category(:changelog).files.first
end
def ee_changelog?
@@ -57,35 +67,34 @@ module Tooling
def modified_text
CHANGELOG_MODIFIED_URL_TEXT +
- format(OPTIONAL_CHANGELOG_MESSAGE, mr_iid: mr_iid, mr_title: sanitized_mr_title)
+ format(OPTIONAL_CHANGELOG_MESSAGE, mr_iid: helper.mr_iid, mr_title: sanitized_mr_title)
end
- def required_text
- CHANGELOG_MISSING_URL_TEXT +
- format(REQUIRED_CHANGELOG_MESSAGE, mr_iid: mr_iid, mr_title: sanitized_mr_title)
+ def required_texts
+ required_reasons.each_with_object({}) do |required_reason, memo|
+ memo[required_reason] =
+ CHANGELOG_MISSING_URL_TEXT +
+ format(REQUIRED_CHANGELOG_MESSAGE, reason: REQUIRED_CHANGELOG_REASONS.fetch(required_reason), mr_iid: helper.mr_iid, mr_title: sanitized_mr_title)
+ end
end
def optional_text
CHANGELOG_MISSING_URL_TEXT +
- format(OPTIONAL_CHANGELOG_MESSAGE, mr_iid: mr_iid, mr_title: sanitized_mr_title)
+ format(OPTIONAL_CHANGELOG_MESSAGE, mr_iid: helper.mr_iid, mr_title: sanitized_mr_title)
end
private
- def mr_iid
- gitlab.mr_json["iid"]
- end
-
def sanitized_mr_title
- TitleLinting.sanitize_mr_title(gitlab.mr_json["title"])
+ Gitlab::Dangerfiles::TitleLinting.sanitize_mr_title(helper.mr_title)
end
def categories_need_changelog?
- (helper.changes_by_category.keys - NO_CHANGELOG_CATEGORIES).any?
+ (project_helper.changes.categories - NO_CHANGELOG_CATEGORIES).any?
end
def without_no_changelog_label?
- (gitlab.mr_labels & NO_CHANGELOG_LABELS).empty?
+ (helper.mr_labels & NO_CHANGELOG_LABELS).empty?
end
end
end
diff --git a/tooling/danger/commit_linter.rb b/tooling/danger/commit_linter.rb
deleted file mode 100644
index 905031ec881..00000000000
--- a/tooling/danger/commit_linter.rb
+++ /dev/null
@@ -1,150 +0,0 @@
-# frozen_string_literal: true
-
-require_relative 'base_linter'
-require_relative 'emoji_checker'
-
-module Tooling
- module Danger
- class CommitLinter < BaseLinter
- MAX_CHANGED_FILES_IN_COMMIT = 3
- MAX_CHANGED_LINES_IN_COMMIT = 30
- SHORT_REFERENCE_REGEX = %r{([\w\-\/]+)?(?<!`)(#|!|&|%)\d+(?<!`)}.freeze
-
- def self.problems_mapping
- super.merge(
- {
- separator_missing: "The commit subject and body must be separated by a blank line",
- details_too_many_changes: "Commits that change #{MAX_CHANGED_LINES_IN_COMMIT} or more lines across " \
- "at least #{MAX_CHANGED_FILES_IN_COMMIT} files must describe these changes in the commit body",
- details_line_too_long: "The commit body should not contain more than #{MAX_LINE_LENGTH} characters per line",
- message_contains_text_emoji: "Avoid the use of Markdown Emoji such as `:+1:`. These add limited value " \
- "to the commit message, and are displayed as plain text outside of GitLab",
- message_contains_unicode_emoji: "Avoid the use of Unicode Emoji. These add no value to the commit " \
- "message, and may not be displayed properly everywhere",
- message_contains_short_reference: "Use full URLs instead of short references (`gitlab-org/gitlab#123` or " \
- "`!123`), as short references are displayed as plain text outside of GitLab"
- }
- )
- end
-
- def initialize(commit)
- super
-
- @linted = false
- end
-
- def fixup?
- commit.message.start_with?('fixup!', 'squash!')
- end
-
- def suggestion?
- commit.message.start_with?('Apply suggestion to')
- end
-
- def merge?
- commit.message.start_with?('Merge branch')
- end
-
- def revert?
- commit.message.start_with?('Revert "')
- end
-
- def multi_line?
- !details.nil? && !details.empty?
- end
-
- def lint
- return self if @linted
-
- @linted = true
- lint_subject
- lint_separator
- lint_details
- lint_message
-
- self
- end
-
- private
-
- def lint_separator
- return self unless separator && !separator.empty?
-
- add_problem(:separator_missing)
-
- self
- end
-
- def lint_details
- if !multi_line? && many_changes?
- add_problem(:details_too_many_changes)
- end
-
- details&.each_line do |line|
- line_without_urls = line.strip.gsub(%r{https?://\S+}, '')
-
- # If the line includes a URL, we'll allow it to exceed MAX_LINE_LENGTH characters, but
- # only if the line _without_ the URL does not exceed this limit.
- next unless line_too_long?(line_without_urls)
-
- add_problem(:details_line_too_long)
- break
- end
-
- self
- end
-
- def lint_message
- if message_contains_text_emoji?
- add_problem(:message_contains_text_emoji)
- end
-
- if message_contains_unicode_emoji?
- add_problem(:message_contains_unicode_emoji)
- end
-
- if message_contains_short_reference?
- add_problem(:message_contains_short_reference)
- end
-
- self
- end
-
- def files_changed
- commit.diff_parent.stats[:total][:files]
- end
-
- def lines_changed
- commit.diff_parent.stats[:total][:lines]
- end
-
- def many_changes?
- files_changed > MAX_CHANGED_FILES_IN_COMMIT && lines_changed > MAX_CHANGED_LINES_IN_COMMIT
- end
-
- def separator
- message_parts[1]
- end
-
- def details
- message_parts[2]&.gsub(/^Signed-off-by.*$/, '')
- end
-
- def message_contains_text_emoji?
- emoji_checker.includes_text_emoji?(commit.message)
- end
-
- def message_contains_unicode_emoji?
- emoji_checker.includes_unicode_emoji?(commit.message)
- end
-
- def message_contains_short_reference?
- commit.message.match?(SHORT_REFERENCE_REGEX)
- end
-
- def emoji_checker
- @emoji_checker ||= Tooling::Danger::EmojiChecker.new
- end
- end
- end
-end
diff --git a/tooling/danger/emoji_checker.rb b/tooling/danger/emoji_checker.rb
deleted file mode 100644
index 9d8ff93037c..00000000000
--- a/tooling/danger/emoji_checker.rb
+++ /dev/null
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-require 'json'
-
-module Tooling
- module Danger
- class EmojiChecker
- DIGESTS = File.expand_path('../../fixtures/emojis/digests.json', __dir__)
- ALIASES = File.expand_path('../../fixtures/emojis/aliases.json', __dir__)
-
- # A regex that indicates a piece of text _might_ include an Emoji. The regex
- # alone is not enough, as we'd match `:foo:bar:baz`. Instead, we use this
- # regex to save us from having to check for all possible emoji names when we
- # know one definitely is not included.
- LIKELY_EMOJI = /:[\+a-z0-9_\-]+:/.freeze
-
- UNICODE_EMOJI_REGEX = %r{(
- [\u{1F300}-\u{1F5FF}] |
- [\u{1F1E6}-\u{1F1FF}] |
- [\u{2700}-\u{27BF}] |
- [\u{1F900}-\u{1F9FF}] |
- [\u{1F600}-\u{1F64F}] |
- [\u{1F680}-\u{1F6FF}] |
- [\u{2600}-\u{26FF}]
- )}x.freeze
-
- def initialize
- names = JSON.parse(File.read(DIGESTS)).keys +
- JSON.parse(File.read(ALIASES)).keys
-
- @emoji = names.map { |name| ":#{name}:" }
- end
-
- def includes_text_emoji?(text)
- return false unless text.match?(LIKELY_EMOJI)
-
- @emoji.any? { |emoji| text.include?(emoji) }
- end
-
- def includes_unicode_emoji?(text)
- text.match?(UNICODE_EMOJI_REGEX)
- end
- end
- end
-end
diff --git a/tooling/danger/helper.rb b/tooling/danger/helper.rb
deleted file mode 100644
index 60026ee9c70..00000000000
--- a/tooling/danger/helper.rb
+++ /dev/null
@@ -1,294 +0,0 @@
-# frozen_string_literal: true
-
-require_relative 'teammate'
-require_relative 'title_linting'
-
-module Tooling
- module Danger
- module Helper
- RELEASE_TOOLS_BOT = 'gitlab-release-tools-bot'
-
- # Returns a list of all files that have been added, modified or renamed.
- # `git.modified_files` might contain paths that already have been renamed,
- # so we need to remove them from the list.
- #
- # Considering these changes:
- #
- # - A new_file.rb
- # - D deleted_file.rb
- # - M modified_file.rb
- # - R renamed_file_before.rb -> renamed_file_after.rb
- #
- # it will return
- # ```
- # [ 'new_file.rb', 'modified_file.rb', 'renamed_file_after.rb' ]
- # ```
- #
- # @return [Array<String>]
- def all_changed_files
- Set.new
- .merge(git.added_files.to_a)
- .merge(git.modified_files.to_a)
- .merge(git.renamed_files.map { |x| x[:after] })
- .subtract(git.renamed_files.map { |x| x[:before] })
- .to_a
- .sort
- end
-
- # Returns a string containing changed lines as git diff
- #
- # Considering changing a line in lib/gitlab/usage_data.rb it will return:
- #
- # [ "--- a/lib/gitlab/usage_data.rb",
- # "+++ b/lib/gitlab/usage_data.rb",
- # "+ # Test change",
- # "- # Old change" ]
- def changed_lines(changed_file)
- diff = git.diff_for_file(changed_file)
- return [] unless diff
-
- diff.patch.split("\n").select { |line| %r{^[+-]}.match?(line) }
- end
-
- def all_ee_changes
- all_changed_files.grep(%r{\Aee/})
- end
-
- def ee?
- # Support former project name for `dev` and support local Danger run
- %w[gitlab gitlab-ee].include?(ENV['CI_PROJECT_NAME']) || Dir.exist?(File.expand_path('../../../ee', __dir__))
- end
-
- def gitlab_helper
- # Unfortunately the following does not work:
- # - respond_to?(:gitlab)
- # - respond_to?(:gitlab, true)
- gitlab
- rescue NameError
- nil
- end
-
- def release_automation?
- gitlab_helper&.mr_author == RELEASE_TOOLS_BOT
- end
-
- def project_name
- ee? ? 'gitlab' : 'gitlab-foss'
- end
-
- def markdown_list(items)
- list = items.map { |item| "* `#{item}`" }.join("\n")
-
- if items.size > 10
- "\n<details>\n\n#{list}\n\n</details>\n"
- else
- list
- end
- end
-
- # @return [Hash<String,Array<String>>]
- def changes_by_category
- all_changed_files.each_with_object(Hash.new { |h, k| h[k] = [] }) do |file, hash|
- categories_for_file(file).each { |category| hash[category] << file }
- end
- end
-
- # Determines the categories a file is in, e.g., `[:frontend]`, `[:backend]`, or `%i[frontend engineering_productivity]`
- # using filename regex and specific change regex if given.
- #
- # @return Array<Symbol>
- def categories_for_file(file)
- _, categories = CATEGORIES.find do |key, _|
- filename_regex, changes_regex = Array(key)
-
- found = filename_regex.match?(file)
- found &&= changed_lines(file).any? { |changed_line| changes_regex.match?(changed_line) } if changes_regex
-
- found
- end
-
- Array(categories || :unknown)
- end
-
- # Returns the GFM for a category label, making its best guess if it's not
- # a category we know about.
- #
- # @return[String]
- def label_for_category(category)
- CATEGORY_LABELS.fetch(category, "~#{category}")
- end
-
- CATEGORY_LABELS = {
- docs: "~documentation", # Docs are reviewed along DevOps stages, so don't need roulette for now.
- none: "",
- qa: "~QA",
- test: "~test ~Quality for `spec/features/*`",
- engineering_productivity: '~"Engineering Productivity" for CI, Danger',
- ci_template: '~"ci::templates"'
- }.freeze
- # First-match win, so be sure to put more specific regex at the top...
- CATEGORIES = {
- [%r{usage_data\.rb}, %r{^(\+|-).*\s+(count|distinct_count|estimate_batch_distinct_count)\(.*\)(.*)$}] => [:database, :backend],
-
- %r{\Adoc/.*(\.(md|png|gif|jpg))\z} => :docs,
- %r{\A(CONTRIBUTING|LICENSE|MAINTENANCE|PHILOSOPHY|PROCESS|README)(\.md)?\z} => :docs,
-
- %r{\A(ee/)?app/(assets|views)/} => :frontend,
- %r{\A(ee/)?public/} => :frontend,
- %r{\A(ee/)?spec/(javascripts|frontend)/} => :frontend,
- %r{\A(ee/)?vendor/assets/} => :frontend,
- %r{\A(ee/)?scripts/frontend/} => :frontend,
- %r{(\A|/)(
- \.babelrc |
- \.eslintignore |
- \.eslintrc(\.yml)? |
- \.nvmrc |
- \.prettierignore |
- \.prettierrc |
- \.scss-lint.yml |
- \.stylelintrc |
- \.haml-lint.yml |
- \.haml-lint_todo.yml |
- babel\.config\.js |
- jest\.config\.js |
- package\.json |
- yarn\.lock |
- config/.+\.js
- )\z}x => :frontend,
-
- %r{(\A|/)(
- \.gitlab/ci/frontend\.gitlab-ci\.yml
- )\z}x => %i[frontend engineering_productivity],
-
- %r{\A(ee/)?db/(?!fixtures)[^/]+} => :database,
- %r{\A(ee/)?lib/gitlab/(database|background_migration|sql|github_import)(/|\.rb)} => :database,
- %r{\A(app/models/project_authorization|app/services/users/refresh_authorized_projects_service)(/|\.rb)} => :database,
- %r{\A(ee/)?app/finders/} => :database,
- %r{\Arubocop/cop/migration(/|\.rb)} => :database,
-
- %r{\A(\.gitlab-ci\.yml\z|\.gitlab\/ci)} => :engineering_productivity,
- %r{\A\.codeclimate\.yml\z} => :engineering_productivity,
- %r{\Alefthook.yml\z} => :engineering_productivity,
- %r{\A\.editorconfig\z} => :engineering_productivity,
- %r{Dangerfile\z} => :engineering_productivity,
- %r{\A(ee/)?(danger/|tooling/danger/)} => :engineering_productivity,
- %r{\A(ee/)?scripts/} => :engineering_productivity,
- %r{\Atooling/} => :engineering_productivity,
- %r{(CODEOWNERS)} => :engineering_productivity,
- %r{(tests.yml)} => :engineering_productivity,
-
- %r{\Alib/gitlab/ci/templates} => :ci_template,
-
- %r{\A(ee/)?spec/features/} => :test,
- %r{\A(ee/)?spec/support/shared_examples/features/} => :test,
- %r{\A(ee/)?spec/support/shared_contexts/features/} => :test,
- %r{\A(ee/)?spec/support/helpers/features/} => :test,
-
- %r{\A(ee/)?app/(?!assets|views)[^/]+} => :backend,
- %r{\A(ee/)?(bin|config|generator_templates|lib|rubocop)/} => :backend,
- %r{\A(ee/)?spec/} => :backend,
- %r{\A(ee/)?vendor/} => :backend,
- %r{\A(Gemfile|Gemfile.lock|Rakefile)\z} => :backend,
- %r{\A[A-Z_]+_VERSION\z} => :backend,
- %r{\A\.rubocop((_manual)?_todo)?\.yml\z} => :backend,
- %r{\Afile_hooks/} => :backend,
-
- %r{\A(ee/)?qa/} => :qa,
-
- # Files that don't fit into any category are marked with :none
- %r{\A(ee/)?changelogs/} => :none,
- %r{\Alocale/gitlab\.pot\z} => :none,
-
- # GraphQL auto generated doc files and schema
- %r{\Adoc/api/graphql/reference/} => :backend,
-
- # Fallbacks in case the above patterns miss anything
- %r{\.rb\z} => :backend,
- %r{(
- \.(md|txt)\z |
- \.markdownlint\.json
- )}x => :none, # To reinstate roulette for documentation, set to `:docs`.
- %r{\.js\z} => :frontend
- }.freeze
-
- def new_teammates(usernames)
- usernames.map { |u| Tooling::Danger::Teammate.new('username' => u) }
- end
-
- def mr_title
- return '' unless gitlab_helper
-
- gitlab_helper.mr_json['title']
- end
-
- def mr_web_url
- return '' unless gitlab_helper
-
- gitlab_helper.mr_json['web_url']
- end
-
- def mr_target_branch
- return '' unless gitlab_helper
-
- gitlab_helper.mr_json['target_branch']
- end
-
- def draft_mr?
- TitleLinting.has_draft_flag?(mr_title)
- end
-
- def security_mr?
- mr_web_url.include?('/gitlab-org/security/')
- end
-
- def cherry_pick_mr?
- TitleLinting.has_cherry_pick_flag?(mr_title)
- end
-
- def run_all_rspec_mr?
- TitleLinting.has_run_all_rspec_flag?(mr_title)
- end
-
- def run_as_if_foss_mr?
- TitleLinting.has_run_as_if_foss_flag?(mr_title)
- end
-
- def stable_branch?
- /\A\d+-\d+-stable-ee/i.match?(mr_target_branch)
- end
-
- def mr_has_labels?(*labels)
- return false unless gitlab_helper
-
- labels = labels.flatten.uniq
- (labels & gitlab_helper.mr_labels) == labels
- end
-
- def labels_list(labels, sep: ', ')
- labels.map { |label| %Q{~"#{label}"} }.join(sep)
- end
-
- def prepare_labels_for_mr(labels)
- return '' unless labels.any?
-
- "/label #{labels_list(labels, sep: ' ')}"
- end
-
- def changed_files(regex)
- all_changed_files.grep(regex)
- end
-
- def has_database_scoped_labels?(labels)
- labels.any? { |label| label.start_with?('database::') }
- end
-
- def has_ci_changes?
- changed_files(%r{\A(\.gitlab-ci\.yml|\.gitlab/ci/)}).any?
- end
-
- def group_label(labels)
- labels.find { |label| label.start_with?('group::') }
- end
- end
- end
-end
diff --git a/tooling/danger/merge_request_linter.rb b/tooling/danger/merge_request_linter.rb
deleted file mode 100644
index ddeb9cc2981..00000000000
--- a/tooling/danger/merge_request_linter.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-require_relative 'base_linter'
-
-module Tooling
- module Danger
- class MergeRequestLinter < BaseLinter
- alias_method :lint, :lint_subject
-
- def self.subject_description
- 'merge request title'
- end
-
- def self.mr_run_options_regex
- [
- 'RUN AS-IF-FOSS',
- 'UPDATE CACHE',
- 'RUN ALL RSPEC',
- 'SKIP RSPEC FAIL-FAST'
- ].join('|')
- end
-
- private
-
- def subject
- super.gsub(/\[?(#{self.class.mr_run_options_regex})\]?/, '').strip
- end
- end
- end
-end
diff --git a/tooling/danger/project_helper.rb b/tooling/danger/project_helper.rb
new file mode 100644
index 00000000000..4458000261f
--- /dev/null
+++ b/tooling/danger/project_helper.rb
@@ -0,0 +1,181 @@
+# frozen_string_literal: true
+
+module Tooling
+ module Danger
+ module ProjectHelper
+ LOCAL_RULES ||= %w[
+ changes_size
+ commit_messages
+ database
+ documentation
+ duplicate_yarn_dependencies
+ eslint
+ karma
+ pajamas
+ pipeline
+ prettier
+ product_intelligence
+ utility_css
+ ].freeze
+
+ CI_ONLY_RULES ||= %w[
+ ce_ee_vue_templates
+ changelog
+ ci_templates
+ metadata
+ feature_flag
+ roulette
+ sidekiq_queues
+ specialization_labels
+ specs
+ ].freeze
+
+ MESSAGE_PREFIX = '==>'.freeze
+
+ # First-match win, so be sure to put more specific regex at the top...
+ CATEGORIES = {
+ [%r{usage_data\.rb}, %r{^(\+|-).*\s+(count|distinct_count|estimate_batch_distinct_count)\(.*\)(.*)$}] => [:database, :backend],
+
+ %r{\A(ee/)?config/feature_flags/} => :feature_flag,
+
+ %r{\A(ee/)?(changelogs/unreleased)(-ee)?/} => :changelog,
+
+ %r{\Adoc/.*(\.(md|png|gif|jpg))\z} => :docs,
+ %r{\A(CONTRIBUTING|LICENSE|MAINTENANCE|PHILOSOPHY|PROCESS|README)(\.md)?\z} => :docs,
+ %r{\Adata/whats_new/} => :docs,
+
+ %r{\A(ee/)?app/(assets|views)/} => :frontend,
+ %r{\A(ee/)?public/} => :frontend,
+ %r{\A(ee/)?spec/(javascripts|frontend)/} => :frontend,
+ %r{\A(ee/)?vendor/assets/} => :frontend,
+ %r{\A(ee/)?scripts/frontend/} => :frontend,
+ %r{(\A|/)(
+ \.babelrc |
+ \.eslintignore |
+ \.eslintrc(\.yml)? |
+ \.nvmrc |
+ \.prettierignore |
+ \.prettierrc |
+ \.stylelintrc |
+ \.haml-lint.yml |
+ \.haml-lint_todo.yml |
+ babel\.config\.js |
+ jest\.config\.js |
+ package\.json |
+ yarn\.lock |
+ config/.+\.js
+ )\z}x => :frontend,
+
+ %r{(\A|/)(
+ \.gitlab/ci/frontend\.gitlab-ci\.yml
+ )\z}x => %i[frontend engineering_productivity],
+
+ %r{\A(ee/)?db/(geo/)?(migrate|post_migrate)/} => [:database, :migration],
+ %r{\A(ee/)?db/(?!fixtures)[^/]+} => :database,
+ %r{\A(ee/)?lib/gitlab/(database|background_migration|sql|github_import)(/|\.rb)} => :database,
+ %r{\A(app/models/project_authorization|app/services/users/refresh_authorized_projects_service)(/|\.rb)} => :database,
+ %r{\A(ee/)?app/finders/} => :database,
+ %r{\Arubocop/cop/migration(/|\.rb)} => :database,
+
+ %r{\A(\.gitlab-ci\.yml\z|\.gitlab\/ci)} => :engineering_productivity,
+ %r{\A\.codeclimate\.yml\z} => :engineering_productivity,
+ %r{\Alefthook.yml\z} => :engineering_productivity,
+ %r{\A\.editorconfig\z} => :engineering_productivity,
+ %r{Dangerfile\z} => :engineering_productivity,
+ %r{\A(ee/)?(danger/|tooling/danger/)} => :engineering_productivity,
+ %r{\A(ee/)?scripts/} => :engineering_productivity,
+ %r{\Atooling/} => :engineering_productivity,
+ %r{(CODEOWNERS)} => :engineering_productivity,
+ %r{(tests.yml)} => :engineering_productivity,
+
+ %r{\Alib/gitlab/ci/templates} => :ci_template,
+
+ %r{\A(ee/)?spec/features/} => :test,
+ %r{\A(ee/)?spec/support/shared_examples/features/} => :test,
+ %r{\A(ee/)?spec/support/shared_contexts/features/} => :test,
+ %r{\A(ee/)?spec/support/helpers/features/} => :test,
+
+ %r{\A(ee/)?app/(?!assets|views)[^/]+} => :backend,
+ %r{\A(ee/)?(bin|config|generator_templates|lib|rubocop)/} => :backend,
+ %r{\A(ee/)?spec/} => :backend,
+ %r{\A(ee/)?vendor/} => :backend,
+ %r{\A(Gemfile|Gemfile.lock|Rakefile)\z} => :backend,
+ %r{\A[A-Z_]+_VERSION\z} => :backend,
+ %r{\A\.rubocop((_manual)?_todo)?\.yml\z} => :backend,
+ %r{\Afile_hooks/} => :backend,
+
+ %r{\A(ee/)?qa/} => :qa,
+
+ # Files that don't fit into any category are marked with :none
+ %r{\A(ee/)?changelogs/} => :none,
+ %r{\Alocale/gitlab\.pot\z} => :none,
+
+ # GraphQL auto generated doc files and schema
+ %r{\Adoc/api/graphql/reference/} => :backend,
+
+ # Fallbacks in case the above patterns miss anything
+ %r{\.rb\z} => :backend,
+ %r{(
+ \.(md|txt)\z |
+ \.markdownlint\.json
+ )}x => :none, # To reinstate roulette for documentation, set to `:docs`.
+ %r{\.js\z} => :frontend
+ }.freeze
+
+ def changes_by_category
+ helper.changes_by_category(CATEGORIES)
+ end
+
+ def changes
+ helper.changes(CATEGORIES)
+ end
+
+ def categories_for_file(file)
+ helper.categories_for_file(file, CATEGORIES)
+ end
+
+ def local_warning_message
+ "#{MESSAGE_PREFIX} Only the following Danger rules can be run locally: #{LOCAL_RULES.join(', ')}"
+ end
+ module_function :local_warning_message # rubocop:disable Style/AccessModifierDeclarations
+
+ def success_message
+ "#{MESSAGE_PREFIX} No Danger rule violations!"
+ end
+ module_function :success_message # rubocop:disable Style/AccessModifierDeclarations
+
+ def rule_names
+ helper.ci? ? LOCAL_RULES | CI_ONLY_RULES : LOCAL_RULES
+ end
+
+ def all_ee_changes
+ helper.all_changed_files.grep(%r{\Aee/})
+ end
+
+ def project_name
+ ee? ? 'gitlab' : 'gitlab-foss'
+ end
+
+ def missing_database_labels(current_mr_labels)
+ labels = if has_database_scoped_labels?(current_mr_labels)
+ ['database']
+ else
+ ['database', 'database::review pending']
+ end
+
+ labels - current_mr_labels
+ end
+
+ private
+
+ def ee?
+ # Support former project name for `dev` and support local Danger run
+ %w[gitlab gitlab-ee].include?(ENV['CI_PROJECT_NAME']) || Dir.exist?(File.expand_path('../../../ee', __dir__))
+ end
+
+ def has_database_scoped_labels?(current_mr_labels)
+ current_mr_labels.any? { |label| label.start_with?('database::') }
+ end
+ end
+ end
+end
diff --git a/tooling/danger/request_helper.rb b/tooling/danger/request_helper.rb
deleted file mode 100644
index d6b99f562f9..00000000000
--- a/tooling/danger/request_helper.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-require 'net/http'
-require 'json'
-
-module Tooling
- module Danger
- module RequestHelper
- HTTPError = Class.new(RuntimeError)
-
- # @param [String] url
- def self.http_get_json(url)
- rsp = Net::HTTP.get_response(URI.parse(url))
-
- unless rsp.is_a?(Net::HTTPOK)
- raise HTTPError, "Failed to read #{url}: #{rsp.code} #{rsp.message}"
- end
-
- JSON.parse(rsp.body)
- end
- end
- end
-end
diff --git a/tooling/danger/roulette.rb b/tooling/danger/roulette.rb
deleted file mode 100644
index c928fb2b655..00000000000
--- a/tooling/danger/roulette.rb
+++ /dev/null
@@ -1,169 +0,0 @@
-# frozen_string_literal: true
-
-require_relative 'teammate'
-require_relative 'request_helper'
-require_relative 'weightage/reviewers'
-require_relative 'weightage/maintainers'
-
-module Tooling
- module Danger
- module Roulette
- ROULETTE_DATA_URL = 'https://gitlab-org.gitlab.io/gitlab-roulette/roulette.json'
- HOURS_WHEN_PERSON_CAN_BE_PICKED = (6..14).freeze
-
- INCLUDE_TIMEZONE_FOR_CATEGORY = {
- database: false
- }.freeze
-
- Spin = Struct.new(:category, :reviewer, :maintainer, :optional_role, :timezone_experiment)
-
- def team_mr_author
- team.find { |person| person.username == mr_author_username }
- end
-
- # Assigns GitLab team members to be reviewer and maintainer
- # for each change category that a Merge Request contains.
- #
- # @return [Array<Spin>]
- def spin(project, categories, timezone_experiment: false)
- spins = categories.sort.map do |category|
- including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(category, timezone_experiment)
-
- spin_for_category(project, category, timezone_experiment: including_timezone)
- end
-
- backend_spin = spins.find { |spin| spin.category == :backend }
-
- spins.each do |spin|
- including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(spin.category, timezone_experiment)
- case spin.category
- when :qa
- # MR includes QA changes, but also other changes, and author isn't an SET
- if categories.size > 1 && !team_mr_author&.any_capability?(project, spin.category)
- spin.optional_role = :maintainer
- end
- when :test
- spin.optional_role = :maintainer
-
- if spin.reviewer.nil?
- # Fetch an already picked backend reviewer, or pick one otherwise
- spin.reviewer = backend_spin&.reviewer || spin_for_category(project, :backend, timezone_experiment: including_timezone).reviewer
- end
- when :engineering_productivity
- if spin.maintainer.nil?
- # Fetch an already picked backend maintainer, or pick one otherwise
- spin.maintainer = backend_spin&.maintainer || spin_for_category(project, :backend, timezone_experiment: including_timezone).maintainer
- end
- when :ci_template
- if spin.maintainer.nil?
- # Fetch an already picked backend maintainer, or pick one otherwise
- spin.maintainer = backend_spin&.maintainer || spin_for_category(project, :backend, timezone_experiment: including_timezone).maintainer
- end
- end
- end
-
- spins
- end
-
- # Looks up the current list of GitLab team members and parses it into a
- # useful form
- #
- # @return [Array<Teammate>]
- def team
- @team ||=
- begin
- data = Tooling::Danger::RequestHelper.http_get_json(ROULETTE_DATA_URL)
- data.map { |hash| ::Tooling::Danger::Teammate.new(hash) }
- rescue JSON::ParserError
- raise "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
- end
- end
-
- # Like +team+, but only returns teammates in the current project, based on
- # project_name.
- #
- # @return [Array<Teammate>]
- def project_team(project_name)
- team.select { |member| member.in_project?(project_name) }
- rescue => err
- warn("Reviewer roulette failed to load team data: #{err.message}")
- []
- end
-
- # Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
- # selection will change on next spin
- # @param [Array<Teammate>] people
- def spin_for_person(people, random:, timezone_experiment: false)
- shuffled_people = people.shuffle(random: random)
-
- if timezone_experiment
- shuffled_people.find(&method(:valid_person_with_timezone?))
- else
- shuffled_people.find(&method(:valid_person?))
- end
- end
-
- private
-
- # @param [Teammate] person
- # @return [Boolean]
- def valid_person?(person)
- !mr_author?(person) && person.available
- end
-
- # @param [Teammate] person
- # @return [Boolean]
- def valid_person_with_timezone?(person)
- valid_person?(person) && HOURS_WHEN_PERSON_CAN_BE_PICKED.cover?(person.local_hour)
- end
-
- # @param [Teammate] person
- # @return [Boolean]
- def mr_author?(person)
- person.username == mr_author_username
- end
-
- def mr_author_username
- helper.gitlab_helper&.mr_author || `whoami`
- end
-
- def mr_source_branch
- return `git rev-parse --abbrev-ref HEAD` unless helper.gitlab_helper&.mr_json
-
- helper.gitlab_helper.mr_json['source_branch']
- end
-
- def mr_labels
- helper.gitlab_helper&.mr_labels || []
- end
-
- def new_random(seed)
- Random.new(Digest::MD5.hexdigest(seed).to_i(16))
- end
-
- def spin_role_for_category(team, role, project, category)
- team.select do |member|
- member.public_send("#{role}?", project, category, mr_labels) # rubocop:disable GitlabSecurity/PublicSend
- end
- end
-
- def spin_for_category(project, category, timezone_experiment: false)
- team = project_team(project)
- reviewers, traintainers, maintainers =
- %i[reviewer traintainer maintainer].map do |role|
- spin_role_for_category(team, role, project, category)
- end
-
- random = new_random(mr_source_branch)
-
- weighted_reviewers = Weightage::Reviewers.new(reviewers, traintainers).execute
- weighted_maintainers = Weightage::Maintainers.new(maintainers).execute
-
- reviewer = spin_for_person(weighted_reviewers, random: random, timezone_experiment: timezone_experiment)
- maintainer = spin_for_person(weighted_maintainers, random: random, timezone_experiment: timezone_experiment)
-
- Spin.new(category, reviewer, maintainer, false, timezone_experiment)
- end
- end
- end
-end
diff --git a/tooling/danger/teammate.rb b/tooling/danger/teammate.rb
deleted file mode 100644
index bcd33bebdc9..00000000000
--- a/tooling/danger/teammate.rb
+++ /dev/null
@@ -1,121 +0,0 @@
-# frozen_string_literal: true
-
-module Tooling
- module Danger
- class Teammate
- attr_reader :options, :username, :name, :role, :projects, :available, :hungry, :reduced_capacity, :tz_offset_hours
-
- # The options data are produced by https://gitlab.com/gitlab-org/gitlab-roulette/-/blob/master/lib/team_member.rb
- def initialize(options = {})
- @options = options
- @username = options['username']
- @name = options['name']
- @markdown_name = options['markdown_name']
- @role = options['role']
- @projects = options['projects']
- @available = options['available']
- @hungry = options['hungry']
- @reduced_capacity = options['reduced_capacity']
- @tz_offset_hours = options['tz_offset_hours']
- end
-
- def to_h
- options
- end
-
- def ==(other)
- return false unless other.respond_to?(:username)
-
- other.username == username
- end
-
- def in_project?(name)
- projects&.has_key?(name)
- end
-
- def any_capability?(project, category)
- capabilities(project).any? { |capability| capability.end_with?(category.to_s) }
- end
-
- def reviewer?(project, category, labels)
- has_capability?(project, category, :reviewer, labels)
- end
-
- def traintainer?(project, category, labels)
- has_capability?(project, category, :trainee_maintainer, labels)
- end
-
- def maintainer?(project, category, labels)
- has_capability?(project, category, :maintainer, labels)
- end
-
- def markdown_name(author: nil)
- "#{@markdown_name} (#{utc_offset_text(author)})"
- end
-
- def local_hour
- (Time.now.utc + tz_offset_hours * 3600).hour
- end
-
- protected
-
- def floored_offset_hours
- floored_offset = tz_offset_hours.floor(0)
-
- floored_offset == tz_offset_hours ? floored_offset : tz_offset_hours
- end
-
- private
-
- def utc_offset_text(author = nil)
- offset_text =
- if floored_offset_hours >= 0
- "UTC+#{floored_offset_hours}"
- else
- "UTC#{floored_offset_hours}"
- end
-
- return offset_text unless author
-
- "#{offset_text}, #{offset_diff_compared_to_author(author)}"
- end
-
- def offset_diff_compared_to_author(author)
- diff = floored_offset_hours - author.floored_offset_hours
- return "same timezone as `@#{author.username}`" if diff == 0
-
- ahead_or_behind = diff < 0 ? 'behind' : 'ahead of'
- pluralized_hours = pluralize(diff.abs, 'hour', 'hours')
-
- "#{pluralized_hours} #{ahead_or_behind} `@#{author.username}`"
- end
-
- def has_capability?(project, category, kind, labels)
- case category
- when :test
- area = role[/Software Engineer in Test(?:.*?, (\w+))/, 1]
-
- area && labels.any?("devops::#{area.downcase}") if kind == :reviewer
- when :engineering_productivity
- return false unless role[/Engineering Productivity/]
- return true if kind == :reviewer
- return true if capabilities(project).include?("#{kind} engineering_productivity")
-
- capabilities(project).include?("#{kind} backend")
- else
- capabilities(project).include?("#{kind} #{category}")
- end
- end
-
- def capabilities(project)
- Array(projects.fetch(project, []))
- end
-
- def pluralize(count, singular, plural)
- word = count == 1 || count.to_s =~ /^1(\.0+)?$/ ? singular : plural
-
- "#{count || 0} #{word}"
- end
- end
- end
-end
diff --git a/tooling/danger/title_linting.rb b/tooling/danger/title_linting.rb
deleted file mode 100644
index dcd83df7d93..00000000000
--- a/tooling/danger/title_linting.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-module Tooling
- module Danger
- module TitleLinting
- DRAFT_REGEX = /\A*#{Regexp.union(/(?i)(\[WIP\]\s*|WIP:\s*|WIP$)/, /(?i)(\[draft\]|\(draft\)|draft:|draft\s\-\s|draft$)/)}+\s*/i.freeze
- CHERRY_PICK_REGEX = /cherry[\s-]*pick/i.freeze
- RUN_ALL_RSPEC_REGEX = /RUN ALL RSPEC/i.freeze
- RUN_AS_IF_FOSS_REGEX = /RUN AS-IF-FOSS/i.freeze
-
- module_function
-
- def sanitize_mr_title(title)
- remove_draft_flag(title).gsub(/`/, '\\\`')
- end
-
- def remove_draft_flag(title)
- title.gsub(DRAFT_REGEX, '')
- end
-
- def has_draft_flag?(title)
- DRAFT_REGEX.match?(title)
- end
-
- def has_cherry_pick_flag?(title)
- CHERRY_PICK_REGEX.match?(title)
- end
-
- def has_run_all_rspec_flag?(title)
- RUN_ALL_RSPEC_REGEX.match?(title)
- end
-
- def has_run_as_if_foss_flag?(title)
- RUN_AS_IF_FOSS_REGEX.match?(title)
- end
- end
- end
-end
diff --git a/tooling/danger/weightage.rb b/tooling/danger/weightage.rb
deleted file mode 100644
index cf8d17410dc..00000000000
--- a/tooling/danger/weightage.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-module Tooling
- module Danger
- module Weightage
- CAPACITY_MULTIPLIER = 2 # change this number to change what it means to be a reduced capacity reviewer 1/this number
- BASE_REVIEWER_WEIGHT = 1
- end
- end
-end
diff --git a/tooling/danger/weightage/maintainers.rb b/tooling/danger/weightage/maintainers.rb
deleted file mode 100644
index 068b24e7913..00000000000
--- a/tooling/danger/weightage/maintainers.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../weightage'
-
-module Tooling
- module Danger
- module Weightage
- class Maintainers
- def initialize(maintainers)
- @maintainers = maintainers
- end
-
- def execute
- maintainers.each_with_object([]) do |maintainer, weighted_maintainers|
- add_weighted_reviewer(weighted_maintainers, maintainer, BASE_REVIEWER_WEIGHT)
- end
- end
-
- private
-
- attr_reader :maintainers
-
- def add_weighted_reviewer(reviewers, reviewer, weight)
- if reviewer.reduced_capacity
- reviewers.fill(reviewer, reviewers.size, weight)
- else
- reviewers.fill(reviewer, reviewers.size, weight * CAPACITY_MULTIPLIER)
- end
- end
- end
- end
- end
-end
diff --git a/tooling/danger/weightage/reviewers.rb b/tooling/danger/weightage/reviewers.rb
deleted file mode 100644
index e74fce37187..00000000000
--- a/tooling/danger/weightage/reviewers.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-# frozen_string_literal: true
-
-require_relative '../weightage'
-
-module Tooling
- module Danger
- module Weightage
- # Weights after (current multiplier of 2)
- #
- # +------------------------------+--------------------------------+
- # | reviewer type | weight(times in reviewer pool) |
- # +------------------------------+--------------------------------+
- # | reduced capacity reviewer | 1 |
- # | reviewer | 2 |
- # | hungry reviewer | 4 |
- # | reduced capacity traintainer | 3 |
- # | traintainer | 6 |
- # | hungry traintainer | 8 |
- # +------------------------------+--------------------------------+
- #
- class Reviewers
- DEFAULT_REVIEWER_WEIGHT = CAPACITY_MULTIPLIER * BASE_REVIEWER_WEIGHT
- TRAINTAINER_WEIGHT = 3
-
- def initialize(reviewers, traintainers)
- @reviewers = reviewers
- @traintainers = traintainers
- end
-
- def execute
- # TODO: take CODEOWNERS into account?
- # https://gitlab.com/gitlab-org/gitlab/issues/26723
-
- weighted_reviewers + weighted_traintainers
- end
-
- private
-
- attr_reader :reviewers, :traintainers
-
- def weighted_reviewers
- reviewers.each_with_object([]) do |reviewer, total_reviewers|
- add_weighted_reviewer(total_reviewers, reviewer, BASE_REVIEWER_WEIGHT)
- end
- end
-
- def weighted_traintainers
- traintainers.each_with_object([]) do |reviewer, total_traintainers|
- add_weighted_reviewer(total_traintainers, reviewer, TRAINTAINER_WEIGHT)
- end
- end
-
- def add_weighted_reviewer(reviewers, reviewer, weight)
- if reviewer.reduced_capacity
- reviewers.fill(reviewer, reviewers.size, weight)
- elsif reviewer.hungry
- reviewers.fill(reviewer, reviewers.size, weight * CAPACITY_MULTIPLIER + DEFAULT_REVIEWER_WEIGHT)
- else
- reviewers.fill(reviewer, reviewers.size, weight * CAPACITY_MULTIPLIER)
- end
- end
- end
- end
- end
-end
diff --git a/tooling/gitlab_danger.rb b/tooling/gitlab_danger.rb
deleted file mode 100644
index d20d3499641..00000000000
--- a/tooling/gitlab_danger.rb
+++ /dev/null
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-
-# rubocop:todo Gitlab/NamespacedClass
-class GitlabDanger
- LOCAL_RULES ||= %w[
- changes_size
- commit_messages
- database
- documentation
- duplicate_yarn_dependencies
- eslint
- karma
- pajamas
- pipeline
- prettier
- product_intelligence
- utility_css
- ].freeze
-
- CI_ONLY_RULES ||= %w[
- ce_ee_vue_templates
- changelog
- ci_templates
- metadata
- feature_flag
- roulette
- sidekiq_queues
- specialization_labels
- specs
- ].freeze
-
- MESSAGE_PREFIX = '==>'.freeze
-
- attr_reader :gitlab_danger_helper
-
- def initialize(gitlab_danger_helper)
- @gitlab_danger_helper = gitlab_danger_helper
- end
-
- def self.local_warning_message
- "#{MESSAGE_PREFIX} Only the following Danger rules can be run locally: #{LOCAL_RULES.join(', ')}"
- end
-
- def self.success_message
- "#{MESSAGE_PREFIX} No Danger rule violations!"
- end
-
- def rule_names
- ci? ? LOCAL_RULES | CI_ONLY_RULES : LOCAL_RULES
- end
-
- def html_link(str)
- self.ci? ? gitlab_danger_helper.html_link(str) : str
- end
-
- def ci?
- !gitlab_danger_helper.nil?
- end
-end
diff --git a/tooling/overcommit/Gemfile b/tooling/overcommit/Gemfile
index 6d2c5bce29a..26dad738bab 100644
--- a/tooling/overcommit/Gemfile
+++ b/tooling/overcommit/Gemfile
@@ -5,5 +5,4 @@ source 'https://rubygems.org'
gem 'overcommit'
gem 'gitlab-styles', '~> 5.4.0', require: false
-gem 'scss_lint', '~> 0.56.0', require: false
gem 'haml_lint', '~> 0.34.0', require: false
diff --git a/tooling/overcommit/Gemfile.lock b/tooling/overcommit/Gemfile.lock
index f1d701b3e4b..13c611439b6 100644
--- a/tooling/overcommit/Gemfile.lock
+++ b/tooling/overcommit/Gemfile.lock
@@ -10,7 +10,6 @@ GEM
ast (2.4.1)
childprocess (3.0.0)
concurrent-ruby (1.1.7)
- ffi (1.12.2)
gitlab-styles (5.4.0)
rubocop (~> 0.89.1)
rubocop-gitlab-security (~> 0.1.0)
@@ -37,10 +36,6 @@ GEM
ast (~> 2.4.1)
rack (2.2.3)
rainbow (3.0.0)
- rake (12.3.3)
- rb-fsevent (0.10.2)
- rb-inotify (0.9.10)
- ffi (>= 0.5.0, < 2)
regexp_parser (1.8.2)
rexml (3.2.4)
rubocop (0.89.1)
@@ -67,14 +62,6 @@ GEM
rubocop (~> 0.87)
rubocop-ast (>= 0.7.1)
ruby-progressbar (1.10.1)
- sass (3.5.5)
- sass-listen (~> 4.0.0)
- sass-listen (4.0.0)
- rb-fsevent (~> 0.9, >= 0.9.4)
- rb-inotify (~> 0.9, >= 0.9.7)
- scss_lint (0.56.0)
- rake (>= 0.9, < 13)
- sass (~> 3.5.3)
sysexits (1.2.0)
temple (0.8.2)
thread_safe (0.3.6)
@@ -91,7 +78,6 @@ DEPENDENCIES
gitlab-styles (~> 5.4.0)
haml_lint (~> 0.34.0)
overcommit
- scss_lint (~> 0.56.0)
BUNDLED WITH
2.1.4
diff --git a/tooling/rspec_flaky/config.rb b/tooling/rspec_flaky/config.rb
new file mode 100644
index 00000000000..ea18a601c11
--- /dev/null
+++ b/tooling/rspec_flaky/config.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module RspecFlaky
+ class Config
+ def self.generate_report?
+ !!(ENV['FLAKY_RSPEC_GENERATE_REPORT'] =~ /1|true/)
+ end
+
+ def self.suite_flaky_examples_report_path
+ ENV['SUITE_FLAKY_RSPEC_REPORT_PATH'] || rails_path("rspec_flaky/suite-report.json")
+ end
+
+ def self.flaky_examples_report_path
+ ENV['FLAKY_RSPEC_REPORT_PATH'] || rails_path("rspec_flaky/report.json")
+ end
+
+ def self.new_flaky_examples_report_path
+ ENV['NEW_FLAKY_RSPEC_REPORT_PATH'] || rails_path("rspec_flaky/new-report.json")
+ end
+
+ def self.rails_path(path)
+ return path unless defined?(Rails)
+
+ Rails.root.join(path)
+ end
+ end
+end
diff --git a/tooling/rspec_flaky/example.rb b/tooling/rspec_flaky/example.rb
new file mode 100644
index 00000000000..18f8c5acc1c
--- /dev/null
+++ b/tooling/rspec_flaky/example.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'forwardable'
+require 'digest'
+
+module RspecFlaky
+ # This is a wrapper class for RSpec::Core::Example
+ class Example
+ extend Forwardable
+
+ def_delegators :execution_result, :status, :exception
+
+ def initialize(rspec_example)
+ @rspec_example = rspec_example.respond_to?(:example) ? rspec_example.example : rspec_example
+ end
+
+ def uid
+ @uid ||= Digest::MD5.hexdigest("#{description}-#{file}")
+ end
+
+ def example_id
+ rspec_example.id
+ end
+
+ def file
+ metadata[:file_path]
+ end
+
+ def line
+ metadata[:line_number]
+ end
+
+ def description
+ metadata[:full_description]
+ end
+
+ def attempts
+ rspec_example.respond_to?(:attempts) ? rspec_example.attempts : 1
+ end
+
+ private
+
+ attr_reader :rspec_example
+
+ def metadata
+ rspec_example.metadata
+ end
+
+ def execution_result
+ rspec_example.execution_result
+ end
+ end
+end
diff --git a/tooling/rspec_flaky/flaky_example.rb b/tooling/rspec_flaky/flaky_example.rb
new file mode 100644
index 00000000000..4f3688dbeed
--- /dev/null
+++ b/tooling/rspec_flaky/flaky_example.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'ostruct'
+
+module RspecFlaky
+ # This represents a flaky RSpec example and is mainly meant to be saved in a JSON file
+ class FlakyExample < OpenStruct
+ def initialize(example)
+ if example.respond_to?(:example_id)
+ super(
+ example_id: example.example_id,
+ file: example.file,
+ line: example.line,
+ description: example.description,
+ last_attempts_count: example.attempts,
+ flaky_reports: 0)
+ else
+ super
+ end
+ end
+
+ def update_flakiness!(last_attempts_count: nil)
+ self.first_flaky_at ||= Time.now
+ self.last_flaky_at = Time.now
+ self.flaky_reports += 1
+ self.last_attempts_count = last_attempts_count if last_attempts_count
+
+ if ENV['CI_PROJECT_URL'] && ENV['CI_JOB_ID']
+ self.last_flaky_job = "#{ENV['CI_PROJECT_URL']}/-/jobs/#{ENV['CI_JOB_ID']}"
+ end
+ end
+
+ def to_h
+ super.merge(
+ first_flaky_at: first_flaky_at,
+ last_flaky_at: last_flaky_at,
+ last_flaky_job: last_flaky_job)
+ end
+ end
+end
diff --git a/tooling/rspec_flaky/flaky_examples_collection.rb b/tooling/rspec_flaky/flaky_examples_collection.rb
new file mode 100644
index 00000000000..acbfb411873
--- /dev/null
+++ b/tooling/rspec_flaky/flaky_examples_collection.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'active_support/hash_with_indifferent_access'
+require 'delegate'
+
+require_relative 'flaky_example'
+
+module RspecFlaky
+ class FlakyExamplesCollection < SimpleDelegator
+ def initialize(collection = {})
+ unless collection.is_a?(Hash)
+ raise ArgumentError, "`collection` must be a Hash, #{collection.class} given!"
+ end
+
+ collection_of_flaky_examples =
+ collection.map do |uid, example|
+ [
+ uid,
+ example.is_a?(RspecFlaky::FlakyExample) ? example : RspecFlaky::FlakyExample.new(example)
+ ]
+ end
+
+ super(Hash[collection_of_flaky_examples])
+ end
+
+ def to_h
+ transform_values { |example| example.to_h }.deep_symbolize_keys
+ end
+
+ def -(other)
+ unless other.respond_to?(:key)
+ raise ArgumentError, "`other` must respond to `#key?`, #{other.class} does not!"
+ end
+
+ self.class.new(reject { |uid, _| other.key?(uid) })
+ end
+ end
+end
diff --git a/tooling/rspec_flaky/listener.rb b/tooling/rspec_flaky/listener.rb
new file mode 100644
index 00000000000..a5c68d830db
--- /dev/null
+++ b/tooling/rspec_flaky/listener.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'json'
+
+require_relative 'config'
+require_relative 'example'
+require_relative 'flaky_example'
+require_relative 'flaky_examples_collection'
+require_relative 'report'
+
+module RspecFlaky
+ class Listener
+ # - suite_flaky_examples: contains all the currently tracked flacky example
+ # for the whole RSpec suite
+ # - flaky_examples: contains the examples detected as flaky during the
+ # current RSpec run
+ attr_reader :suite_flaky_examples, :flaky_examples
+
+ def initialize(suite_flaky_examples_json = nil)
+ @flaky_examples = RspecFlaky::FlakyExamplesCollection.new
+ @suite_flaky_examples = init_suite_flaky_examples(suite_flaky_examples_json)
+ end
+
+ def example_passed(notification)
+ current_example = RspecFlaky::Example.new(notification.example)
+
+ return unless current_example.attempts > 1
+
+ flaky_example = suite_flaky_examples.fetch(current_example.uid) { RspecFlaky::FlakyExample.new(current_example) }
+ flaky_example.update_flakiness!(last_attempts_count: current_example.attempts)
+
+ flaky_examples[current_example.uid] = flaky_example
+ end
+
+ def dump_summary(_)
+ RspecFlaky::Report.new(flaky_examples).write(RspecFlaky::Config.flaky_examples_report_path)
+ # write_report_file(flaky_examples, RspecFlaky::Config.flaky_examples_report_path)
+
+ new_flaky_examples = flaky_examples - suite_flaky_examples
+ if new_flaky_examples.any?
+ rails_logger_warn("\nNew flaky examples detected:\n")
+ rails_logger_warn(JSON.pretty_generate(new_flaky_examples.to_h)) # rubocop:disable Gitlab/Json
+
+ RspecFlaky::Report.new(new_flaky_examples).write(RspecFlaky::Config.new_flaky_examples_report_path)
+ # write_report_file(new_flaky_examples, RspecFlaky::Config.new_flaky_examples_report_path)
+ end
+ end
+
+ private
+
+ def init_suite_flaky_examples(suite_flaky_examples_json = nil)
+ if suite_flaky_examples_json
+ RspecFlaky::Report.load_json(suite_flaky_examples_json).flaky_examples
+ else
+ return {} unless File.exist?(RspecFlaky::Config.suite_flaky_examples_report_path)
+
+ RspecFlaky::Report.load(RspecFlaky::Config.suite_flaky_examples_report_path).flaky_examples
+ end
+ end
+
+ def rails_logger_warn(text)
+ target = defined?(Rails) ? Rails.logger : Kernel
+
+ target.warn(text)
+ end
+ end
+end
diff --git a/tooling/rspec_flaky/report.rb b/tooling/rspec_flaky/report.rb
new file mode 100644
index 00000000000..3acfe7d2125
--- /dev/null
+++ b/tooling/rspec_flaky/report.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'json'
+require 'time'
+
+require_relative 'config'
+require_relative 'flaky_examples_collection'
+
+module RspecFlaky
+ # This class is responsible for loading/saving JSON reports, and pruning
+ # outdated examples.
+ class Report < SimpleDelegator
+ OUTDATED_DAYS_THRESHOLD = 7
+
+ attr_reader :flaky_examples
+
+ def self.load(file_path)
+ load_json(File.read(file_path))
+ end
+
+ def self.load_json(json)
+ new(RspecFlaky::FlakyExamplesCollection.new(JSON.parse(json)))
+ end
+
+ def initialize(flaky_examples)
+ unless flaky_examples.is_a?(RspecFlaky::FlakyExamplesCollection)
+ raise ArgumentError, "`flaky_examples` must be a RspecFlaky::FlakyExamplesCollection, #{flaky_examples.class} given!"
+ end
+
+ @flaky_examples = flaky_examples
+ super(flaky_examples)
+ end
+
+ def write(file_path)
+ unless RspecFlaky::Config.generate_report?
+ Kernel.warn "! Generating reports is disabled. To enable it, please set the `FLAKY_RSPEC_GENERATE_REPORT=1` !"
+ return
+ end
+
+ report_path_dir = File.dirname(file_path)
+ FileUtils.mkdir_p(report_path_dir) unless Dir.exist?(report_path_dir)
+
+ File.write(file_path, JSON.pretty_generate(flaky_examples.to_h))
+ end
+
+ def prune_outdated(days: OUTDATED_DAYS_THRESHOLD)
+ outdated_date_threshold = Time.now - (3600 * 24 * days)
+ updated_hash = flaky_examples.dup
+ .delete_if do |uid, hash|
+ hash[:last_flaky_at] && Time.parse(hash[:last_flaky_at]).to_i < outdated_date_threshold.to_i
+ end
+
+ self.class.new(RspecFlaky::FlakyExamplesCollection.new(updated_hash))
+ end
+ end
+end