diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 18:18:33 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 18:18:33 +0000 |
commit | f64a639bcfa1fc2bc89ca7db268f594306edfd7c (patch) | |
tree | a2c3c2ebcc3b45e596949db485d6ed18ffaacfa1 /tooling | |
parent | bfbc3e0d6583ea1a91f627528bedc3d65ba4b10f (diff) | |
download | gitlab-ce-f64a639bcfa1fc2bc89ca7db268f594306edfd7c.tar.gz |
Add latest changes from gitlab-org/gitlab@13-10-stable-eev13.10.0-rc40
Diffstat (limited to 'tooling')
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 |