Commit 018e58eb authored by Lin Jen-Shin's avatar Lin Jen-Shin

Merge branch...

Merge branch 'team-tasks-571-consider-creating-a-new-gitlab-danger-gem-to-factorize-the-common-dangerfiles-we-have-accross' into 'master'

Use the gitlab-dangerfiles gem

See merge request gitlab-org/gitlab!36464
parents 08b19204 95a3801f
......@@ -49,6 +49,14 @@
- vendor/ruby/
policy: pull
.danger-review-cache:
cache:
key: "danger-review-v1"
paths:
- vendor/ruby/
- node_modules/
policy: pull
.qa-cache:
cache:
key: "qa-v2"
......
......@@ -225,12 +225,22 @@ parallel-spec-reports:
danger-review:
extends:
- .default-retry
- .yarn-cache
- .danger-review-cache
- .review:rules:danger
image: registry.gitlab.com/gitlab-org/gitlab-build-images:danger
stage: test
needs: []
script:
before_script:
- source ./scripts/utils.sh
- run_timed_command "bundle install --jobs=$(nproc) --path=vendor --retry=3 --quiet --with danger"
- run_timed_command "retry yarn install --frozen-lockfile"
- danger --fail-on-errors=true --verbose
script:
- run_timed_command "bundle exec danger --fail-on-errors=true --verbose"
update-danger-review-cache:
extends:
- danger-review
- .shared:rules:update-cache
stage: prepare
script: echo 'Cache is fresh!'
cache:
policy: push # We want to rebuild the cache from scratch to ensure stale dependencies are cleaned up.
# frozen_string_literal: true
require_relative 'tooling/gitlab_danger'
require_relative 'tooling/danger/request_helper'
require 'gitlab-dangerfiles'
Dir["danger/plugins/*.rb"].sort.each { |f| danger.import_plugin(f) }
Gitlab::Dangerfiles.import_plugins(danger)
danger.import_plugin('danger/plugins/*.rb')
return if helper.release_automation?
gitlab_danger = GitlabDanger.new(helper.gitlab_helper)
gitlab_danger.rule_names.each do |file|
danger.import_dangerfile(path: File.join('danger', file))
project_helper.rule_names.each do |rule|
danger.import_dangerfile(path: File.join('danger', rule))
end
anything_to_post = status_report.values.any? { |data| data.any? }
if gitlab_danger.ci? && anything_to_post
if helper.ci? && anything_to_post
markdown("**If needed, you can retry the [`danger-review` job](#{ENV['CI_JOB_URL']}) that generated this comment.**")
end
......@@ -344,7 +344,6 @@ end
group :development do
gem 'brakeman', '~> 4.2', require: false
gem 'danger', '~> 8.0.6', require: false
gem 'lefthook', '~> 0.7', require: false
gem 'letter_opener_web', '~> 1.3.4'
......@@ -399,6 +398,11 @@ group :development, :test do
gem 'rblineprof', '~> 0.3.6', platform: :mri, require: false
end
group :development, :test, :danger do
gem 'danger-gitlab', '~> 8.0', require: false
gem 'gitlab-dangerfiles', '~> 0.8.0', require: false
end
group :development, :test, :coverage do
gem 'simplecov', '~> 0.18.5', require: false
gem 'simplecov-cobertura', '~> 1.3.1', require: false
......
......@@ -216,7 +216,7 @@ GEM
css_parser (1.7.0)
addressable
daemons (1.3.1)
danger (8.0.6)
danger (8.2.3)
claide (~> 1.0)
claide-plugins (>= 0.9.2)
colored2 (~> 3.1)
......@@ -228,7 +228,10 @@ GEM
kramdown-parser-gfm (~> 1.0)
no_proxy_fix
octokit (~> 4.7)
terminal-table (~> 1)
terminal-table (>= 1, < 4)
danger-gitlab (8.0.0)
danger
gitlab (~> 4.2, >= 4.2.0)
database_cleaner (1.7.0)
debugger-ruby_core_source (1.3.8)
deckar01-task_list (2.3.1)
......@@ -428,8 +431,13 @@ GEM
gitaly (13.9.0.pre.rc1)
grpc (~> 1.0)
github-markup (1.7.0)
gitlab (4.16.1)
httparty (~> 0.14, >= 0.14.0)
terminal-table (~> 1.5, >= 1.5.1)
gitlab-chronic (0.10.5)
numerizer (~> 0.2)
gitlab-dangerfiles (0.8.0)
danger
gitlab-experiment (0.5.0)
activesupport (>= 3.0)
scientist (~> 1.5, >= 1.5.0)
......@@ -1367,7 +1375,7 @@ DEPENDENCIES
countries (~> 3.0)
creole (~> 0.5.0)
crystalball (~> 0.7.0)
danger (~> 8.0.6)
danger-gitlab (~> 8.0)
database_cleaner (~> 1.7.0)
deckar01-task_list (= 2.3.1)
default_value_for (~> 3.4.0)
......@@ -1413,6 +1421,7 @@ DEPENDENCIES
gitaly (~> 13.9.0.pre.rc1)
github-markup (~> 1.7.0)
gitlab-chronic (~> 0.10.5)
gitlab-dangerfiles (~> 0.8.0)
gitlab-experiment (~> 0.5.0)
gitlab-fog-azure-rm (~> 1.0.1)
gitlab-fog-google (~> 1.13)
......
......@@ -17,8 +17,8 @@ def check_changelog_yaml(path)
raw_file = File.read(path)
yaml = YAML.safe_load(raw_file)
fail "`title` should be set, in #{gitlab.html_link(path)}! #{SEE_DOC}" if yaml["title"].nil?
fail "`type` should be set, in #{gitlab.html_link(path)}! #{SEE_DOC}" if yaml["type"].nil?
fail "`title` should be set, in #{helper.html_link(path)}! #{SEE_DOC}" if yaml["title"].nil?
fail "`type` should be set, in #{helper.html_link(path)}! #{SEE_DOC}" if yaml["type"].nil?
return if helper.security_mr?
......@@ -30,20 +30,20 @@ def check_changelog_yaml(path)
if mr_line
markdown(format(SUGGEST_MR_COMMENT, mr_iid: gitlab.mr_json["iid"]), file: path, line: mr_line.succ)
else
message "Consider setting `merge_request` to #{gitlab.mr_json["iid"]} in #{gitlab.html_link(path)}. #{SEE_DOC}"
message "Consider setting `merge_request` to #{gitlab.mr_json["iid"]} in #{helper.html_link(path)}. #{SEE_DOC}"
end
elsif yaml["merge_request"] != gitlab.mr_json["iid"] && !cherry_pick_against_stable_branch
fail "Merge request ID was not set to #{gitlab.mr_json["iid"]}! #{SEE_DOC}"
end
rescue Psych::Exception
# YAML could not be parsed, fail the build.
fail "#{gitlab.html_link(path)} isn't valid YAML! #{SEE_DOC}"
fail "#{helper.html_link(path)} isn't valid YAML! #{SEE_DOC}"
rescue StandardError => e
warn "There was a problem trying to check the Changelog. Exception: #{e.class.name} - #{e.message}"
end
def check_changelog_path(path)
ee_changes = helper.all_ee_changes.dup
ee_changes = project_helper.all_ee_changes.dup
ee_changes.delete(path)
if ee_changes.any? && !changelog.ee_changelog? && !changelog.required?
......
# frozen_string_literal: true
gitlab_danger = GitlabDanger.new(helper.gitlab_helper)
TEMPLATE_MESSAGE = <<~MSG
This merge request requires a CI/CD Template review. To make sure these
changes are reviewed, take the following steps:
......@@ -17,9 +15,9 @@ TEMPLATE_FILES_MESSAGE = <<~MSG
The following files require a review from the CI/CD Templates maintainers:
MSG
return unless gitlab_danger.ci?
return unless helper.ci?
template_paths_to_review = helper.changes_by_category[:ci_template]
template_paths_to_review = project_helper.changes_by_category[:ci_template]
if gitlab.mr_labels.include?('ci::templates') || template_paths_to_review.any?
message 'This merge request adds or changes files that require a ' \
......
# frozen_string_literal: true
require_relative File.expand_path('../../tooling/danger/commit_linter', __dir__)
require_relative File.expand_path('../../tooling/danger/merge_request_linter', __dir__)
require 'gitlab/dangerfiles/commit_linter'
require 'gitlab/dangerfiles/merge_request_linter'
COMMIT_MESSAGE_GUIDELINES = "https://docs.gitlab.com/ee/development/contributing/merge_request_workflow.html#commit-messages-guidelines"
MORE_INFO = "For more information, take a look at our [Commit message guidelines](#{COMMIT_MESSAGE_GUIDELINES})."
......@@ -18,10 +18,6 @@ This merge request includes more than %<max_commits_count>d commits. Each commit
If this merge request contains commits that do not meet this criteria and/or contains intermediate work, please rebase these commits into a smaller number of commits or split this merge request into multiple smaller merge requests.
MSG
def gitlab_danger
@gitlab_danger ||= GitlabDanger.new(helper.gitlab_helper)
end
def fail_commit(commit, message, more_info: true)
self.fail(build_message(commit, message, more_info: more_info))
end
......@@ -39,22 +35,22 @@ end
def squash_mr?
# Locally, we assume the MR is set to be squashed so that the user only sees warnings instead of errors.
gitlab_danger.ci? ? gitlab.mr_json['squash'] : true
helper.ci? ? gitlab.mr_json['squash'] : true
end
def wip_mr?
gitlab_danger.ci? ? gitlab.mr_json['work_in_progress'] : false
helper.ci? ? gitlab.mr_json['work_in_progress'] : false
end
def danger_job_link
gitlab_danger.ci? ? "[#{THE_DANGER_JOB_TEXT}](#{ENV['CI_JOB_URL']})" : THE_DANGER_JOB_TEXT
helper.ci? ? "[#{THE_DANGER_JOB_TEXT}](#{ENV['CI_JOB_URL']})" : THE_DANGER_JOB_TEXT
end
# Perform various checks against commits. We're not using
# https://github.com/jonallured/danger-commit_lint because its output is not
# very helpful, and it doesn't offer the means of ignoring merge commits.
def lint_commit(commit)
linter = Tooling::Danger::CommitLinter.new(commit)
linter = Gitlab::Dangerfiles::CommitLinter.new(commit)
# For now we'll ignore merge commits, as getting rid of those is a problem
# separate from enforcing good commit messages.
......@@ -93,7 +89,7 @@ end
def lint_mr_title(mr_title)
commit = Struct.new(:message, :sha).new(mr_title)
Tooling::Danger::MergeRequestLinter.new(commit).lint
Gitlab::Dangerfiles::MergeRequestLinter.new(commit).lint
end
def count_non_fixup_commits(commit_linters)
......@@ -109,7 +105,7 @@ def lint_commits(commits)
if multi_line_commit_linter && multi_line_commit_linter.failed?
warn_or_fail_commits(multi_line_commit_linter)
commit_linters.delete(multi_line_commit_linter) # Don't show an error (here) and a warning (below)
elsif gitlab_danger.ci? # We don't have access to the MR title locally
elsif helper.ci? # We don't have access to the MR title locally
title_linter = lint_mr_title(gitlab.mr_json['title'])
if title_linter.failed?
warn_or_fail_commits(title_linter)
......
# frozen_string_literal: true
gitlab_danger = GitlabDanger.new(helper.gitlab_helper)
SCHEMA_NOT_UPDATED_MESSAGE_SHORT = "New %<migrations>s added but %<schema>s wasn't updated"
SCHEMA_NOT_UPDATED_MESSAGE_FULL = <<~MSG
......@@ -35,20 +33,20 @@ geo_db_schema_updated = !git.modified_files.grep(%r{\Aee/db/geo/schema\.rb}).emp
non_geo_migration_created = !git.added_files.grep(%r{\A(db/(post_)?migrate)/}).empty?
geo_migration_created = !git.added_files.grep(%r{\Aee/db/geo/(post_)?migrate/}).empty?
format_str = gitlab_danger.ci? ? SCHEMA_NOT_UPDATED_MESSAGE_FULL : SCHEMA_NOT_UPDATED_MESSAGE_SHORT
format_str = helper.ci? ? SCHEMA_NOT_UPDATED_MESSAGE_FULL : SCHEMA_NOT_UPDATED_MESSAGE_SHORT
if non_geo_migration_created && !non_geo_db_schema_updated
warn format(format_str, migrations: 'migrations', schema: gitlab_danger.html_link("db/structure.sql"))
warn format(format_str, migrations: 'migrations', schema: helper.html_link("db/structure.sql"))
end
if geo_migration_created && !geo_db_schema_updated
warn format(format_str, migrations: 'Geo migrations', schema: gitlab_danger.html_link("ee/db/geo/schema.rb"))
warn format(format_str, migrations: 'Geo migrations', schema: helper.html_link("ee/db/geo/schema.rb"))
end
return unless gitlab_danger.ci?
return unless helper.ci?
return if gitlab.mr_labels.include?(DATABASE_APPROVED_LABEL)
db_paths_to_review = helper.changes_by_category[:database]
db_paths_to_review = project_helper.changes_by_category[:database]
if gitlab.mr_labels.include?('database') || db_paths_to_review.any?
message 'This merge request adds or changes files that require a ' \
......
# frozen_string_literal: true
def gitlab_danger
@gitlab_danger ||= GitlabDanger.new(helper.gitlab_helper)
end
def feature_mr?
return false unless helper.gitlab_helper&.mr_labels
(helper.gitlab_helper.mr_labels & %w[feature::addition feature::enhancement]).any?
(helper.mr_labels & %w[feature::addition feature::enhancement]).any?
end
DOCUMENTATION_UPDATE_MISSING = <<~MSG
......@@ -19,7 +13,7 @@ For more information, see:
- The [definition of done](https://docs.gitlab.com/ee/development/contributing/merge_request_workflow.html#definition-of-done) documentation.
MSG
docs_paths_to_review = helper.changes_by_category[:docs]
docs_paths_to_review = project_helper.changes_by_category[:docs]
# Documentation should be updated for feature::addition and feature::enhancement
if docs_paths_to_review.empty?
......@@ -30,7 +24,7 @@ end
message 'This merge request adds or changes documentation files. A review from the Technical Writing team before you merge is **recommended**. Reviews can happen after you merge.'
return unless gitlab_danger.ci?
return unless helper.ci?
markdown(<<~MARKDOWN)
## Documentation review
......
......@@ -11,7 +11,7 @@ return if duplicate.empty?
warn 'This merge request has introduced duplicated yarn dependencies.'
if GitlabDanger.new(helper.gitlab_helper).ci?
if helper.ci?
markdown(<<~MARKDOWN)
## Duplicate yarn dependencies
......
......@@ -13,7 +13,7 @@ return if eslint_candidates.empty?
warn 'This merge request changed files with disabled eslint rules. Please consider fixing them.'
if GitlabDanger.new(helper.gitlab_helper).ci?
if helper.ci?
markdown(<<~MARKDOWN)
## Disabled eslint rules
......
......@@ -13,7 +13,7 @@ new_karma_files = get_karma_files(git.added_files.to_a)
unless new_karma_files.empty?
if GitlabDanger.new(helper.gitlab_helper).ci?
if helper.ci?
markdown(<<~MARKDOWN)
## New karma spec file
......@@ -36,7 +36,7 @@ return if changed_karma_files.empty?
warn 'You have edited karma spec files. Please consider migrating them to jest.'
if GitlabDanger.new(helper.gitlab_helper).ci?
if helper.ci?
markdown(<<~MARKDOWN)
## Edited karma files
......
......@@ -3,7 +3,7 @@
require_relative '../../tooling/danger/changelog'
module Danger
class Changelog < Plugin
class Changelog < ::Danger::Plugin
# Put the helper code somewhere it can be tested
include Tooling::Danger::Changelog
end
......
# frozen_string_literal: true
require_relative '../../tooling/danger/helper'
require_relative '../../tooling/danger/project_helper'
module Danger
# Common helper functions for our danger scripts. See Tooling::Danger::Helper
# Common helper functions for our danger scripts. See Tooling::Danger::ProjectHelper
# for more details
class Helper < Plugin
class ProjectHelper < ::Danger::Plugin
# Put the helper code somewhere it can be tested
include Tooling::Danger::Helper
include Tooling::Danger::ProjectHelper
end
end
# frozen_string_literal: true
require_relative '../../tooling/danger/roulette'
module Danger
class Roulette < Plugin
# Put the helper code somewhere it can be tested
include Tooling::Danger::Roulette
end
end
......@@ -3,7 +3,7 @@
require_relative '../../tooling/danger/sidekiq_queues'
module Danger
class SidekiqQueues < Plugin
class SidekiqQueues < ::Danger::Plugin
# Put the helper code somewhere it can be tested
include Tooling::Danger::SidekiqQueues
end
......
......@@ -19,7 +19,7 @@ return if unpretty.empty?
warn 'This merge request changed frontend files without pretty printing them.'
if GitlabDanger.new(helper.gitlab_helper).ci?
if helper.ci?
markdown(<<~MARKDOWN)
## Pretty print Frontend files
......
......@@ -35,7 +35,7 @@ UNKNOWN_FILES_MESSAGE = <<MARKDOWN
These files couldn't be categorised, so Danger was unable to suggest a reviewer.
Please consider creating a merge request to
[add support](https://gitlab.com/gitlab-org/gitlab/blob/master/tooling/danger/helper.rb)
[add support](https://gitlab.com/gitlab-org/gitlab/blob/master/tooling/danger/project_helper.rb)
for them.
MARKDOWN
......@@ -67,7 +67,7 @@ def markdown_row_for_spins(category, spins_array)
"| #{helper.label_for_category(category)} | #{reviewer_note} | #{maintainer_note} |"
end
changes = helper.changes_by_category
changes = project_helper.changes_by_category
# Ignore any files that are known but uncategorized. Prompt for any unknown files
changes.delete(:none)
......@@ -76,10 +76,10 @@ changes.delete(:docs)
categories = changes.keys - [:unknown]
# Ensure to spin for database reviewer/maintainer when ~database is applied (e.g. to review SQL queries)
categories << :database if helper.gitlab_helper&.mr_labels&.include?('database') && !categories.include?(:database)
categories << :database if helper.mr_labels.include?('database') && !categories.include?(:database)
if changes.any?
project = helper.project_name
project = project_helper.project_name
random_roulette_spins = roulette.spin(project, categories, timezone_experiment: false)
......
# frozen_string_literal: true
gitlab_danger = GitlabDanger.new(helper.gitlab_helper)
return unless gitlab_danger.ci?
return unless helper.ci?
SPECIALIZATIONS = {
database: 'database',
......@@ -14,7 +12,7 @@ SPECIALIZATIONS = {
ci_template: 'ci::templates'
}.freeze
labels_to_add = helper.changes_by_category.each_with_object([]) do |(category, _changes), memo|
labels_to_add = project_helper.changes_by_category.each_with_object([]) do |(category, _changes), memo|
label = SPECIALIZATIONS[category]
memo << label if label && !gitlab.mr_labels.include?(label)
......
......@@ -2,16 +2,16 @@
desc 'Run local Danger rules'
task :danger_local do
require_relative '../../tooling/gitlab_danger'
require_relative '../../tooling/danger/project_helper'
require 'gitlab/popen'
puts("#{GitlabDanger.local_warning_message}\n")
puts("#{Tooling::Danger::ProjectHelper.local_warning_message}\n")
# _status will _always_ be 0, regardless of failure or success :(
output, _status = Gitlab::Popen.popen(%w{danger dry_run})
if output.empty?
puts(GitlabDanger.success_message)
puts(Tooling::Danger::ProjectHelper.success_message)
else
puts(output)
exit(1)
......
# frozen_string_literal: true
require 'rspec-parameterized'
require_relative 'danger_spec_helper'
require_relative '../../../tooling/danger/base_linter'
RSpec.describe Tooling::Danger::BaseLinter do
let(:commit_class) do
Struct.new(:message, :sha, :diff_parent)
end
let(:commit_message) { 'A commit message' }
let(:commit) { commit_class.new(commit_message, anything, anything) }
subject(:commit_linter) { described_class.new(commit) }
describe '#failed?' do
context 'with no failures' do
it { expect(commit_linter).not_to be_failed }
end
context 'with failures' do
before do
commit_linter.add_problem(:subject_too_long, described_class.subject_description)
end
it { expect(commit_linter).to be_failed }
end
end
describe '#add_problem' do
it 'stores messages in #failures' do
commit_linter.add_problem(:subject_too_long, '%s')
expect(commit_linter.problems).to eq({ subject_too_long: described_class.problems_mapping[:subject_too_long] })
end
end
shared_examples 'a valid commit' do
it 'does not have any problem' do
commit_linter.lint_subject
expect(commit_linter.problems).to be_empty
end
end
describe '#lint_subject' do
context 'when subject valid' do
it_behaves_like 'a valid commit'
end
context 'when subject is too short' do
let(:commit_message) { 'A B' }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:subject_too_short, described_class.subject_description)
commit_linter.lint_subject
end
end
context 'when subject is too long' do
let(:commit_message) { 'A B ' + 'C' * described_class::MAX_LINE_LENGTH }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:subject_too_long, described_class.subject_description)
commit_linter.lint_subject
end
end
context 'when ignoring length issues for subject having not-ready wording' do
using RSpec::Parameterized::TableSyntax
let(:final_message) { 'A B C' }
context 'when used as prefix' do
where(prefix: [
'WIP: ',
'WIP:',
'wIp:',
'[WIP] ',
'[WIP]',
'[draft]',
'[draft] ',
'(draft)',
'(draft) ',
'draft - ',
'draft: ',
'draft:',
'DRAFT:'
])
with_them do
it 'does not have any problems' do
commit_message = prefix + final_message + 'D' * (described_class::MAX_LINE_LENGTH - final_message.size)
commit = commit_class.new(commit_message, anything, anything)
linter = described_class.new(commit).lint_subject
expect(linter.problems).to be_empty
end
end
end
context 'when used as suffix' do
where(suffix: %w[WIP draft])
with_them do
it 'does not have any problems' do
commit_message = final_message + 'D' * (described_class::MAX_LINE_LENGTH - final_message.size) + suffix
commit = commit_class.new(commit_message, anything, anything)
linter = described_class.new(commit).lint_subject
expect(linter.problems).to be_empty
end
end
end
end
context 'when subject does not have enough words and is too long' do
let(:commit_message) { 'A ' + 'B' * described_class::MAX_LINE_LENGTH }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:subject_too_short, described_class.subject_description)
expect(commit_linter).to receive(:add_problem).with(:subject_too_long, described_class.subject_description)
commit_linter.lint_subject
end
end
context 'when subject starts with lowercase' do
let(:commit_message) { 'a B C' }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:subject_starts_with_lowercase, described_class.subject_description)
commit_linter.lint_subject
end
end
[
'[ci skip] A commit message',
'[Ci skip] A commit message',
'[API] A commit message',
'api: A commit message',
'API: A commit message',
'API: a commit message',
'API: a commit message'
].each do |message|
context "when subject is '#{message}'" do
let(:commit_message) { message }
it 'does not add a problem' do
expect(commit_linter).not_to receive(:add_problem)
commit_linter.lint_subject
end
end
end
[
'[ci skip]A commit message',
'[Ci skip] A commit message',
'[ci skip] a commit message',
'api: a commit message',
'! A commit message'
].each do |message|
context "when subject is '#{message}'" do
let(:commit_message) { message }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:subject_starts_with_lowercase, described_class.subject_description)
commit_linter.lint_subject
end
end
end
context 'when subject ends with a period' do
let(:commit_message) { 'A B C.' }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:subject_ends_with_a_period, described_class.subject_description)
commit_linter.lint_subject
end
end
end
end
# frozen_string_literal: true
require_relative 'danger_spec_helper'
require 'gitlab-dangerfiles'
require 'gitlab/dangerfiles/spec_helper'
require_relative '../../../tooling/danger/helper'
require_relative '../../../tooling/danger/changelog'
require_relative '../../../tooling/danger/project_helper'
RSpec.describe Tooling::Danger::Changelog do
include DangerSpecHelper
include_context "with dangerfile"
let(:change_class) { Tooling::Danger::Helper::Change }
let(:changes_class) { Tooling::Danger::Helper::Changes }
let(:changes) { changes_class.new([]) }
let(:mr_labels) { [] }
let(:sanitize_mr_title) { 'Fake Title' }
let(:fake_helper) { double('fake-helper', changes: changes, mr_iid: 1234, mr_title: sanitize_mr_title, mr_labels: mr_labels) }
let(:fake_danger) { new_fake_danger.include(described_class) }
let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
let(:fake_project_helper) { double('fake-project-helper', helper: fake_helper).tap { |h| h.class.include(Tooling::Danger::ProjectHelper) } }
subject(:changelog) { fake_danger.new(helper: fake_helper) }
before do
allow(changelog).to receive(:project_helper).and_return(fake_project_helper)
end
describe '#required_reasons' do
subject { changelog.required_reasons }
......@@ -165,7 +162,7 @@ RSpec.describe Tooling::Danger::Changelog do
subject { changelog.modified_text }
context "when title is not changed from sanitization", :aggregate_failures do
let(:sanitize_mr_title) { 'Fake Title' }
let(:mr_title) { 'Fake Title' }
specify do
expect(subject).to include('CHANGELOG.md was edited')
......@@ -175,7 +172,7 @@ RSpec.describe Tooling::Danger::Changelog do
end
context "when title needs sanitization", :aggregate_failures do
let(:sanitize_mr_title) { 'DRAFT: Fake Title' }
let(:mr_title) { 'DRAFT: Fake Title' }
specify do
expect(subject).to include('CHANGELOG.md was edited')
......@@ -186,7 +183,7 @@ RSpec.describe Tooling::Danger::Changelog do
end
describe '#required_texts' do
let(:sanitize_mr_title) { 'Fake Title' }
let(:mr_title) { 'Fake Title' }
subject { changelog.required_texts }
......@@ -207,7 +204,7 @@ RSpec.describe Tooling::Danger::Changelog do
end
context "when title needs sanitization", :aggregate_failures do
let(:sanitize_mr_title) { 'DRAFT: Fake Title' }
let(:mr_title) { 'DRAFT: Fake Title' }
it_behaves_like 'changelog required text', :db_changes
end
......@@ -224,7 +221,7 @@ RSpec.describe Tooling::Danger::Changelog do
subject { changelog.optional_text }
context "when title is not changed from sanitization", :aggregate_failures do
let(:sanitize_mr_title) { 'Fake Title' }
let(:mr_title) { 'Fake Title' }
specify do
expect(subject).to include('CHANGELOG missing')
......@@ -234,7 +231,7 @@ RSpec.describe Tooling::Danger::Changelog do
end
context "when title needs sanitization", :aggregate_failures do
let(:sanitize_mr_title) { 'DRAFT: Fake Title' }
let(:mr_title) { 'DRAFT: Fake Title' }
specify do
expect(subject).to include('CHANGELOG missing')
......
# frozen_string_literal: true
require 'rspec-parameterized'
require_relative 'danger_spec_helper'
require_relative '../../../tooling/danger/commit_linter'
RSpec.describe Tooling::Danger::CommitLinter do
using RSpec::Parameterized::TableSyntax
let(:total_files_changed) { 2 }
let(:total_lines_changed) { 10 }
let(:stats) { { total: { files: total_files_changed, lines: total_lines_changed } } }
let(:diff_parent) { Struct.new(:stats).new(stats) }
let(:commit_class) do
Struct.new(:message, :sha, :diff_parent)
end
let(:commit_message) { 'A commit message' }
let(:commit_sha) { 'abcd1234' }
let(:commit) { commit_class.new(commit_message, commit_sha, diff_parent) }
subject(:commit_linter) { described_class.new(commit) }
describe '#fixup?' do
where(:commit_message, :is_fixup) do
'A commit message' | false
'fixup!' | true
'fixup! A commit message' | true
'squash!' | true
'squash! A commit message' | true
end
with_them do
it 'is true when commit message starts with "fixup!" or "squash!"' do
expect(commit_linter.fixup?).to be(is_fixup)
end
end
end
describe '#suggestion?' do
where(:commit_message, :is_suggestion) do
'A commit message' | false
'Apply suggestion to' | true
'Apply suggestion to "A commit message"' | true
end
with_them do
it 'is true when commit message starts with "Apply suggestion to"' do
expect(commit_linter.suggestion?).to be(is_suggestion)
end
end
end
describe '#merge?' do
where(:commit_message, :is_merge) do
'A commit message' | false
'Merge branch' | true
'Merge branch "A commit message"' | true
end
with_them do
it 'is true when commit message starts with "Merge branch"' do
expect(commit_linter.merge?).to be(is_merge)
end
end
end
describe '#revert?' do
where(:commit_message, :is_revert) do
'A commit message' | false
'Revert' | false
'Revert "' | true
'Revert "A commit message"' | true
end
with_them do
it 'is true when commit message starts with "Revert \""' do
expect(commit_linter.revert?).to be(is_revert)
end
end
end
describe '#multi_line?' do
where(:commit_message, :is_multi_line) do
"A commit message" | false
"A commit message\n" | false
"A commit message\n\n" | false
"A commit message\n\nSigned-off-by: User Name <user@name.me>" | false
"A commit message\n\nWith details" | true
end
with_them do
it 'is true when commit message contains details' do
expect(commit_linter.multi_line?).to be(is_multi_line)
end
end
end
shared_examples 'a valid commit' do
it 'does not have any problem' do
commit_linter.lint
expect(commit_linter.problems).to be_empty
end
end
describe '#lint' do
describe 'separator' do
context 'when separator is missing' do
let(:commit_message) { "A B C\n" }
it_behaves_like 'a valid commit'
end
context 'when separator is a blank line' do
let(:commit_message) { "A B C\n\nMore details." }
it_behaves_like 'a valid commit'
end
context 'when separator is missing' do
let(:commit_message) { "A B C\nMore details." }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:separator_missing)
commit_linter.lint
end
end
end
describe 'details' do
context 'when details are valid' do
let(:commit_message) { "A B C\n\nMore details." }
it_behaves_like 'a valid commit'
end
context 'when no details are given and many files are changed' do
let(:total_files_changed) { described_class::MAX_CHANGED_FILES_IN_COMMIT + 1 }
it_behaves_like 'a valid commit'
end
context 'when no details are given and many lines are changed' do
let(:total_lines_changed) { described_class::MAX_CHANGED_LINES_IN_COMMIT + 1 }
it_behaves_like 'a valid commit'
end
context 'when no details are given and many files and lines are changed' do
let(:total_files_changed) { described_class::MAX_CHANGED_FILES_IN_COMMIT + 1 }
let(:total_lines_changed) { described_class::MAX_CHANGED_LINES_IN_COMMIT + 1 }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:details_too_many_changes)
commit_linter.lint
end
end
context 'when details exceeds the max line length' do
let(:commit_message) { "A B C\n\n" + 'D' * (described_class::MAX_LINE_LENGTH + 1) }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:details_line_too_long)
commit_linter.lint
end
end
context 'when details exceeds the max line length including URLs' do
let(:commit_message) do
"A B C\n\nsome message with https://example.com and https://gitlab.com" + 'D' * described_class::MAX_LINE_LENGTH
end
it_behaves_like 'a valid commit'
end
end
describe 'message' do
context 'when message includes a text emoji' do
let(:commit_message) { "A commit message :+1:" }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:message_contains_text_emoji)
commit_linter.lint
end
end
context 'when message includes a unicode emoji' do
let(:commit_message) { "A commit message 🚀" }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:message_contains_unicode_emoji)
commit_linter.lint
end
end
context 'when message includes a value that is surrounded by backticks' do
let(:commit_message) { "A commit message `%20`" }
it 'does not add a problem' do
expect(commit_linter).not_to receive(:add_problem)
commit_linter.lint
end
end
context 'when message includes a short reference' do
[
'A commit message to fix #1234',
'A commit message to fix !1234',
'A commit message to fix &1234',
'A commit message to fix %1234',
'A commit message to fix gitlab#1234',
'A commit message to fix gitlab!1234',
'A commit message to fix gitlab&1234',
'A commit message to fix gitlab%1234',
'A commit message to fix gitlab-org/gitlab#1234',
'A commit message to fix gitlab-org/gitlab!1234',
'A commit message to fix gitlab-org/gitlab&1234',
'A commit message to fix gitlab-org/gitlab%1234',
'A commit message to fix "gitlab-org/gitlab%1234"',
'A commit message to fix `gitlab-org/gitlab%1234'
].each do |message|
let(:commit_message) { message }
it 'adds a problem' do
expect(commit_linter).to receive(:add_problem).with(:message_contains_short_reference)
commit_linter.lint
end
end
end
end
end
end
# frozen_string_literal: true
module DangerSpecHelper
def new_fake_danger
Class.new do
attr_reader :git, :gitlab, :helper
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def initialize(git: nil, gitlab: nil, helper: nil)
@git = git
@gitlab = gitlab
@helper = helper
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
end
end
# frozen_string_literal: true
require 'rspec-parameterized'
require_relative '../../../tooling/danger/emoji_checker'
RSpec.describe Tooling::Danger::EmojiChecker do
using RSpec::Parameterized::TableSyntax
describe '#includes_text_emoji?' do
where(:text, :includes_emoji) do
'Hello World!' | false
':+1:' | true
'Hello World! :+1:' | true
end
with_them do
it 'is true when text includes a text emoji' do
expect(subject.includes_text_emoji?(text)).to be(includes_emoji)
end
end
end
describe '#includes_unicode_emoji?' do
where(:text, :includes_emoji) do
'Hello World!' | false
'🚀' | true
'Hello World! 🚀' | true
end
with_them do
it 'is true when text includes a text emoji' do
expect(subject.includes_unicode_emoji?(text)).to be(includes_emoji)
end
end
end
end
# frozen_string_literal: true
require_relative 'danger_spec_helper'
require 'gitlab-dangerfiles'
require 'gitlab/dangerfiles/spec_helper'
require_relative '../../../tooling/danger/feature_flag'
RSpec.describe Tooling::Danger::FeatureFlag do
include DangerSpecHelper
include_context "with dangerfile"
let(:added_files) { nil }
let(:modified_files) { nil }
let(:deleted_files) { nil }
let(:fake_git) { double('fake-git', added_files: added_files, modified_files: modified_files, deleted_files: deleted_files) }
let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
let(:mr_labels) { nil }
let(:mr_json) { nil }
let(:fake_gitlab) { double('fake-gitlab', mr_labels: mr_labels, mr_json: mr_json) }
let(:changes_by_category) { nil }
let(:sanitize_mr_title) { nil }
let(:ee?) { false }
let(:fake_helper) { double('fake-helper', changes_by_category: changes_by_category, sanitize_mr_title: sanitize_mr_title, ee?: ee?) }
let(:fake_danger) { new_fake_danger.include(described_class) }
subject(:feature_flag) { fake_danger.new(git: fake_git, gitlab: fake_gitlab, helper: fake_helper) }
subject(:feature_flag) { fake_danger.new(git: fake_git) }
describe '#feature_flag_files' do
let(:feature_flag_files) do
......
# frozen_string_literal: true
require 'rspec-parameterized'
require_relative 'danger_spec_helper'
require_relative '../../../tooling/danger/merge_request_linter'
RSpec.describe Tooling::Danger::MergeRequestLinter do
using RSpec::Parameterized::TableSyntax
let(:mr_class) do
Struct.new(:message, :sha, :diff_parent)
end
let(:mr_title) { 'A B ' + 'C' }
let(:merge_request) { mr_class.new(mr_title, anything, anything) }
describe '#lint_subject' do
subject(:mr_linter) { described_class.new(merge_request) }
shared_examples 'a valid mr title' do
it 'does not have any problem' do
mr_linter.lint
expect(mr_linter.problems).to be_empty
end
end
context 'when subject valid' do
it_behaves_like 'a valid mr title'
end
context 'when it is too long' do
let(:mr_title) { 'A B ' + 'C' * described_class::MAX_LINE_LENGTH }
it 'adds a problem' do
expect(mr_linter).to receive(:add_problem).with(:subject_too_long, described_class.subject_description)
mr_linter.lint
end
end
describe 'using magic mr run options' do
where(run_option: described_class.mr_run_options_regex.split('|') +
described_class.mr_run_options_regex.split('|').map! { |x| "[#{x}]" })
with_them do
let(:mr_title) { run_option + ' A B ' + 'C' * (described_class::MAX_LINE_LENGTH - 5) }
it_behaves_like 'a valid mr title'
end
end
end
end
# frozen_string_literal: true
require 'fast_spec_helper'
require 'rspec-parameterized'
require_relative 'danger_spec_helper'
require 'gitlab-dangerfiles'
require 'danger/helper'
require 'gitlab/dangerfiles/spec_helper'
require_relative '../../../tooling/danger/helper'
require_relative '../../../danger/plugins/project_helper'
RSpec.describe Tooling::Danger::Helper do
using RSpec::Parameterized::TableSyntax
include DangerSpecHelper
let(:mr_author) { nil }
let(:fake_gitlab) { double('fake-gitlab', mr_author: mr_author) }
let(:fake_danger) { new_fake_danger.include(described_class) }
let(:added_files) { %w[added1] }
let(:modified_files) { %w[modified1] }
let(:deleted_files) { %w[deleted1] }
let(:renamed_before_file) { 'renamed_before' }
let(:renamed_after_file) { 'renamed_after' }
let(:renamed_files) { [{ before: renamed_before_file, after: renamed_after_file }] }
let(:fake_git) { double('fake-git') }
before do
allow(fake_git).to receive(:added_files) { added_files }
allow(fake_git).to receive(:modified_files) { modified_files }
allow(fake_git).to receive(:deleted_files) { deleted_files }
allow(fake_git).to receive(:renamed_files).at_least(:twice) { renamed_files }
end
subject(:helper) { fake_danger.new(git: fake_git, gitlab: fake_gitlab) }
describe '#gitlab_helper' do
context 'when gitlab helper is not available' do
let(:fake_gitlab) { nil }
it 'returns nil' do
expect(helper.gitlab_helper).to be_nil
end
end
context 'when gitlab helper is available' do
it 'returns the gitlab helper' do
expect(helper.gitlab_helper).to eq(fake_gitlab)
end
end
context 'when danger gitlab plugin is not available' do
it 'returns nil' do
invalid_danger = Class.new do
include Tooling::Danger::Helper
end.new
expect(invalid_danger.gitlab_helper).to be_nil
end
end
end
describe '#release_automation?' do
context 'when gitlab helper is not available' do
it 'returns false' do
expect(helper.release_automation?).to be_falsey
end
end
context 'when gitlab helper is available' do
context "but the MR author isn't the RELEASE_TOOLS_BOT" do
let(:mr_author) { 'johnmarston' }
it 'returns false' do
expect(helper.release_automation?).to be_falsey
end
end
context 'and the MR author is the RELEASE_TOOLS_BOT' do
let(:mr_author) { described_class::RELEASE_TOOLS_BOT }
it 'returns true' do
expect(helper.release_automation?).to be_truthy
end
end
end
end
describe '#all_changed_files' do
subject { helper.all_changed_files }
RSpec.describe Tooling::Danger::ProjectHelper do
include_context "with dangerfile"
it 'interprets a list of changes from the danger git plugin' do
expect(fake_git).to receive(:added_files) { %w[a b c.old] }
expect(fake_git).to receive(:modified_files) { %w[d e] }
expect(fake_git)
.to receive(:renamed_files)
.at_least(:once)
.and_return([{ before: 'c.old', after: 'c.new' }])
let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
let(:fake_helper) { Danger::Helper.new(project_helper) }
is_expected.to contain_exactly('a', 'b', 'c.new', 'd', 'e')
end
end
describe '#changed_lines' do
subject { helper.changed_lines('changed_file.rb') }
subject(:project_helper) { fake_danger.new(git: fake_git) }
before do
allow(fake_git).to receive(:diff_for_file).with('changed_file.rb').and_return(diff)
end
context 'when file has diff' do
let(:diff) { double(:diff, patch: "+ # New change here\n+ # New change there") }
it 'returns file changes' do
is_expected.to eq(['+ # New change here', '+ # New change there'])
end
end
context 'when file has no diff (renamed without changes)' do
let(:diff) { nil }
it 'returns a blank array' do
is_expected.to eq([])
end
end
end
describe "changed_files" do
it 'returns list of changed files matching given regex' do
expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb usage_data.rb])
expect(helper.changed_files(/usage_data/)).to contain_exactly('usage_data.rb')
end
end
describe '#all_ee_changes' do
subject { helper.all_ee_changes }
it 'returns all changed files starting with ee/' do
expect(helper).to receive(:all_changed_files).and_return(%w[fr/ee/beer.rb ee/wine.rb ee/lib/ido.rb ee.k])
is_expected.to match_array(%w[ee/wine.rb ee/lib/ido.rb])
end
end
describe '#ee?' do
subject { helper.ee? }
it 'returns true if CI_PROJECT_NAME if set to gitlab' do
stub_env('CI_PROJECT_NAME', 'gitlab')
expect(Dir).not_to receive(:exist?)
is_expected.to be_truthy
end
it 'delegates to CHANGELOG-EE.md existence if CI_PROJECT_NAME is set to something else' do
stub_env('CI_PROJECT_NAME', 'something else')
expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { true }
is_expected.to be_truthy
end
it 'returns true if ee exists' do
stub_env('CI_PROJECT_NAME', nil)
expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { true }
is_expected.to be_truthy
end
it "returns false if ee doesn't exist" do
stub_env('CI_PROJECT_NAME', nil)
expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { false }
is_expected.to be_falsy
end
end
describe '#project_name' do
subject { helper.project_name }
it 'returns gitlab if ee? returns true' do
expect(helper).to receive(:ee?) { true }
is_expected.to eq('gitlab')
end
it 'returns gitlab-ce if ee? returns false' do
expect(helper).to receive(:ee?) { false }
is_expected.to eq('gitlab-foss')
end
end
describe '#markdown_list' do
it 'creates a markdown list of items' do
items = %w[a b]
expect(helper.markdown_list(items)).to eq("* `a`\n* `b`")
end
it 'wraps items in <details> when there are more than 10 items' do
items = ('a'..'k').to_a
expect(helper.markdown_list(items)).to match(%r{<details>[^<]+</details>})
end
end
describe '#changes_by_category' do
let(:added_files) { %w[foo foo.md foo.rb foo.js] }
let(:modified_files) { %w[db/migrate/foo lib/gitlab/database/foo.rb] }
let(:renamed_files) { [{ before: '', after: 'qa/foo' }, { before: '', after: 'ee/changelogs/foo.yml' }] }
it 'categorizes changed files' do
expect(helper.changes_by_category).to eq(
backend: %w[foo.rb],
database: %w[db/migrate/foo lib/gitlab/database/foo.rb],
frontend: %w[foo.js],
migration: %w[db/migrate/foo],
none: %w[ee/changelogs/foo.yml foo.md],
qa: %w[qa/foo],
unknown: %w[foo]
)
end
end
describe 'Tooling::Danger::Helper::Changes', :aggregate_failures do
let(:added_files) { %w[db/migrate/foo ee/changelogs/unreleased/foo.yml] }
describe '#has_category?' do
it 'returns true when changes include given category, false otherwise' do
changes = helper.changes
expect(changes.has_category?(:migration)).to eq(true)
expect(changes.has_category?(:changelog)).to eq(true)
expect(changes.has_category?(:backend)).to eq(false)
end
end
describe '#by_category' do
it 'returns an array of Change objects' do
expect(helper.changes.by_category(:migration)).to all(be_an(described_class::Change))
end
it 'returns an array of Change objects with the given category' do
changes = helper.changes
expect(changes.by_category(:migration).files).to eq(['db/migrate/foo'])
expect(changes.by_category(:changelog).files).to eq(['ee/changelogs/unreleased/foo.yml'])
expect(changes.by_category(:backend)).to be_empty
end
end
describe '#categories' do
it 'returns an array of category symbols' do
expect(helper.changes.categories).to contain_exactly(:database, :migration, :changelog, :unknown)
end
end
describe '#files' do
it 'returns an array of files' do
expect(helper.changes.files).to include(*added_files)
end
end
allow(project_helper).to receive(:helper).and_return(fake_helper)
end
describe '#changes' do
it 'returns an array of Change objects' do
expect(helper.changes).to all(be_an(described_class::Change))
expect(project_helper.changes).to all(be_an(Gitlab::Dangerfiles::Change))
end
it 'groups changes by change type' do
changes = helper.changes
changes = project_helper.changes
expect(changes.added.files).to eq(added_files)
expect(changes.modified.files).to eq(modified_files)
......@@ -279,6 +36,8 @@ RSpec.describe Tooling::Danger::Helper do
end
describe '#categories_for_file' do
using RSpec::Parameterized::TableSyntax
before do
allow(fake_git).to receive(:diff_for_file).with('usage_data.rb') { double(:diff, patch: "+ count(User.active)") }
end
......@@ -412,7 +171,7 @@ RSpec.describe Tooling::Danger::Helper do
end
with_them do
subject { helper.categories_for_file(path) }
subject { project_helper.categories_for_file(path) }
it { is_expected.to eq(expected_categories) }
end
......@@ -432,350 +191,70 @@ RSpec.describe Tooling::Danger::Helper do
changed_files.each do |file|
allow(fake_git).to receive(:diff_for_file).with(file) { double(:diff, patch: patch) }
expect(helper.categories_for_file(file)).to eq(expected_categories)
expect(project_helper.categories_for_file(file)).to eq(expected_categories)
end
end
end
end
end
describe '#label_for_category' do
where(:category, :expected_label) do
:backend | '~backend'
:database | '~database'
:docs | '~documentation'
:foo | '~foo'
:frontend | '~frontend'
:none | ''
:qa | '~QA'
:engineering_productivity | '~"Engineering Productivity" for CI, Danger'
:ci_template | '~"ci::templates"'
end
with_them do
subject { helper.label_for_category(category) }
it { is_expected.to eq(expected_label) }
describe '.local_warning_message' do
it 'returns an informational message with rules that can run' do
expect(described_class.local_warning_message).to eq('==> Only the following Danger rules can be run locally: changes_size, commit_messages, database, documentation, duplicate_yarn_dependencies, eslint, karma, pajamas, pipeline, prettier, product_intelligence, utility_css')
end
end
describe '#new_teammates' do
it 'returns an array of Teammate' do
usernames = %w[filipa iamphil]
teammates = helper.new_teammates(usernames)
expect(teammates.map(&:username)).to eq(usernames)
describe '.success_message' do
it 'returns an informational success message' do
expect(described_class.success_message).to eq('==> No Danger rule violations!')
end
end
describe '#mr_iid' do
it 'returns "" when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
expect(helper.mr_iid).to eq('')
end
it 'returns the MR IID when `gitlab_helper` is available' do
mr_iid = '1234'
expect(fake_gitlab).to receive(:mr_json)
.and_return('iid' => mr_iid)
expect(helper.mr_iid).to eq(mr_iid)
end
end
describe '#mr_title' do
it 'returns "" when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
expect(helper.mr_title).to eq('')
end
it 'returns the MR title when `gitlab_helper` is available' do
mr_title = 'My MR title'
expect(fake_gitlab).to receive(:mr_json)
.and_return('title' => mr_title)
expect(helper.mr_title).to eq(mr_title)
end
end
describe '#mr_web_url' do
it 'returns "" when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
expect(helper.mr_web_url).to eq('')
end
it 'returns the MR web_url when `gitlab_helper` is available' do
mr_web_url = 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1'
expect(fake_gitlab).to receive(:mr_json)
.and_return('web_url' => mr_web_url)
expect(helper.mr_web_url).to eq(mr_web_url)
end
end
describe '#mr_labels' do
it 'returns "" when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
expect(helper.mr_labels).to eq([])
end
it 'returns the MR labels when `gitlab_helper` is available' do
mr_labels = %w[foo bar baz]
expect(fake_gitlab).to receive(:mr_labels)
.and_return(mr_labels)
expect(helper.mr_labels).to eq(mr_labels)
end
end
describe '#mr_target_branch' do
it 'returns "" when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
expect(helper.mr_target_branch).to eq('')
end
it 'returns the MR web_url when `gitlab_helper` is available' do
mr_target_branch = 'main'
expect(fake_gitlab).to receive(:mr_json)
.and_return('target_branch' => mr_target_branch)
expect(helper.mr_target_branch).to eq(mr_target_branch)
end
end
describe '#security_mr?' do
it 'returns false when on a normal merge request' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('web_url' => 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/1')
expect(helper).not_to be_security_mr
end
it 'returns true when on a security merge request' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('web_url' => 'https://gitlab.com/gitlab-org/security/gitlab/-/merge_requests/1')
expect(helper).to be_security_mr
end
end
describe '#draft_mr?' do
it 'returns true for a draft MR' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('title' => 'Draft: My MR title')
expect(helper).to be_draft_mr
end
it 'returns false for non draft MR' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('title' => 'My MR title')
expect(helper).not_to be_draft_mr
end
end
describe '#cherry_pick_mr?' do
context 'when MR title does not mention a cherry-pick' do
it 'returns false' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('title' => 'Add feature xyz')
expect(helper).not_to be_cherry_pick_mr
end
end
context 'when MR title mentions a cherry-pick' do
[
'Cherry Pick !1234',
'cherry-pick !1234',
'CherryPick !1234'
].each do |mr_title|
it 'returns true' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('title' => mr_title)
expect(helper).to be_cherry_pick_mr
end
end
end
end
describe '#run_all_rspec_mr?' do
context 'when MR title does not mention RUN ALL RSPEC' do
it 'returns false' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('title' => 'Add feature xyz')
expect(helper).not_to be_run_all_rspec_mr
end
end
context 'when MR title mentions RUN ALL RSPEC' do
it 'returns true' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('title' => 'Add feature xyz RUN ALL RSPEC')
expect(helper).to be_run_all_rspec_mr
end
end
end
describe '#run_as_if_foss_mr?' do
context 'when MR title does not mention RUN AS-IF-FOSS' do
it 'returns false' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('title' => 'Add feature xyz')
expect(helper).not_to be_run_as_if_foss_mr
end
end
context 'when MR title mentions RUN AS-IF-FOSS' do
it 'returns true' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('title' => 'Add feature xyz RUN AS-IF-FOSS')
expect(helper).to be_run_as_if_foss_mr
end
end
end
describe '#stable_branch?' do
it 'returns false when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
expect(helper).not_to be_stable_branch
end
context 'when MR target branch is not a stable branch' do
it 'returns false' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('target_branch' => 'my-feature-branch')
expect(helper).not_to be_stable_branch
end
end
context 'when MR target branch is a stable branch' do
%w[
13-1-stable-ee
13-1-stable-ee-patch-1
].each do |target_branch|
it 'returns true' do
expect(fake_gitlab).to receive(:mr_json)
.and_return('target_branch' => target_branch)
expect(helper).to be_stable_branch
end
end
end
end
describe '#mr_has_label?' do
it 'returns false when `gitlab_helper` is unavailable' do
expect(helper).to receive(:gitlab_helper).and_return(nil)
expect(helper.mr_has_labels?('telemetry')).to be_falsey
end
context 'when mr has labels' do
describe '#rule_names' do
context 'when running locally' do
before do
mr_labels = ['telemetry', 'telemetry::reviewed']
expect(fake_gitlab).to receive(:mr_labels).and_return(mr_labels)
expect(fake_helper).to receive(:ci?).and_return(false)
end
it 'returns true with a matched label' do
expect(helper.mr_has_labels?('telemetry')).to be_truthy
it 'returns local only rules' do
expect(project_helper.rule_names).to match_array(described_class::LOCAL_RULES)
end
it 'returns false with unmatched label' do
expect(helper.mr_has_labels?('database')).to be_falsey
end
it 'returns true with an array of labels' do
expect(helper.mr_has_labels?(['telemetry', 'telemetry::reviewed'])).to be_truthy
end
it 'returns true with multi arguments with matched labels' do
expect(helper.mr_has_labels?('telemetry', 'telemetry::reviewed')).to be_truthy
end
it 'returns false with multi arguments with unmatched labels' do
expect(helper.mr_has_labels?('telemetry', 'telemetry::non existing')).to be_falsey
end
end
end
describe '#labels_list' do
let(:labels) { ['telemetry', 'telemetry::reviewed'] }
it 'composes the labels string' do
expect(helper.labels_list(labels)).to eq('~"telemetry", ~"telemetry::reviewed"')
context 'when running under CI' do
before do
expect(fake_helper).to receive(:ci?).and_return(true)
end
context 'when passing a separator' do
it 'composes the labels string with the given separator' do
expect(helper.labels_list(labels, sep: ' ')).to eq('~"telemetry" ~"telemetry::reviewed"')
end
it 'returns all rules' do
expect(project_helper.rule_names).to eq(described_class::LOCAL_RULES | described_class::CI_ONLY_RULES)
end
it 'returns empty string for empty array' do
expect(helper.labels_list([])).to eq('')
end
end
describe '#prepare_labels_for_mr' do
it 'composes the labels string' do
mr_labels = ['telemetry', 'telemetry::reviewed']
expect(helper.prepare_labels_for_mr(mr_labels)).to eq('/label ~"telemetry" ~"telemetry::reviewed"')
end
it 'returns empty string for empty array' do
expect(helper.prepare_labels_for_mr([])).to eq('')
end
end
describe '#all_ee_changes' do
subject { project_helper.all_ee_changes }
describe '#has_ci_changes?' do
context 'when .gitlab/ci is changed' do
it 'returns true' do
expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb .gitlab/ci/test.yml])
it 'returns all changed files starting with ee/' do
expect(project_helper).to receive(:all_changed_files).and_return(%w[fr/ee/beer.rb ee/wine.rb ee/lib/ido.rb ee.k])
expect(helper.has_ci_changes?).to be_truthy
is_expected.to match_array(%w[ee/wine.rb ee/lib/ido.rb])
end
end
context 'when .gitlab-ci.yml is changed' do
it 'returns true' do
expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb .gitlab-ci.yml])
expect(helper.has_ci_changes?).to be_truthy
end
end
describe '#project_name' do
subject { project_helper.project_name }
context 'when neither .gitlab/ci/ or .gitlab-ci.yml is changed' do
it 'returns false' do
expect(helper).to receive(:all_changed_files).and_return(%w[migration.rb nested/.gitlab-ci.yml])
it 'returns gitlab if ee? returns true' do
expect(project_helper).to receive(:ee?) { true }
expect(helper.has_ci_changes?).to be_falsey
end
end
is_expected.to eq('gitlab')
end
describe '#group_label' do
it 'returns nil when no group label is present' do
expect(helper.group_label(%w[foo bar])).to be_nil
end
it 'returns gitlab-ce if ee? returns false' do
expect(project_helper).to receive(:ee?) { false }
it 'returns the group label when a group label is present' do
expect(helper.group_label(['foo', 'group::source code', 'bar'])).to eq('group::source code')
is_expected.to eq('gitlab-foss')
end
end
end
# frozen_string_literal: true
require 'webmock/rspec'
require 'timecop'
require_relative '../../../tooling/danger/roulette'
require 'active_support/testing/time_helpers'
RSpec.describe Tooling::Danger::Roulette do
include ActiveSupport::Testing::TimeHelpers
around do |example|
travel_to(Time.utc(2020, 06, 22, 10)) { example.run }
end
let(:backend_available) { true }
let(:backend_tz_offset_hours) { 2.0 }
let(:backend_maintainer) do
Tooling::Danger::Teammate.new(
'username' => 'backend-maintainer',
'name' => 'Backend maintainer',
'role' => 'Backend engineer',
'projects' => { 'gitlab' => 'maintainer backend' },
'available' => backend_available,
'tz_offset_hours' => backend_tz_offset_hours
)
end
let(:frontend_reviewer) do
Tooling::Danger::Teammate.new(
'username' => 'frontend-reviewer',
'name' => 'Frontend reviewer',
'role' => 'Frontend engineer',
'projects' => { 'gitlab' => 'reviewer frontend' },
'available' => true,
'tz_offset_hours' => 2.0
)
end
let(:frontend_maintainer) do
Tooling::Danger::Teammate.new(
'username' => 'frontend-maintainer',
'name' => 'Frontend maintainer',
'role' => 'Frontend engineer',
'projects' => { 'gitlab' => "maintainer frontend" },
'available' => true,
'tz_offset_hours' => 2.0
)
end
let(:software_engineer_in_test) do
Tooling::Danger::Teammate.new(
'username' => 'software-engineer-in-test',
'name' => 'Software Engineer in Test',
'role' => 'Software Engineer in Test, Create:Source Code',
'projects' => { 'gitlab' => 'maintainer qa', 'gitlab-qa' => 'maintainer' },
'available' => true,
'tz_offset_hours' => 2.0
)
end
let(:engineering_productivity_reviewer) do
Tooling::Danger::Teammate.new(
'username' => 'eng-prod-reviewer',
'name' => 'EP engineer',
'role' => 'Engineering Productivity',
'projects' => { 'gitlab' => 'reviewer backend' },
'available' => true,
'tz_offset_hours' => 2.0
)
end
let(:ci_template_reviewer) do
Tooling::Danger::Teammate.new(
'username' => 'ci-template-maintainer',
'name' => 'CI Template engineer',
'role' => '~"ci::templates"',
'projects' => { 'gitlab' => 'reviewer ci_template' },
'available' => true,
'tz_offset_hours' => 2.0
)
end
let(:teammates) do
[
backend_maintainer.to_h,
frontend_maintainer.to_h,
frontend_reviewer.to_h,
software_engineer_in_test.to_h,
engineering_productivity_reviewer.to_h,
ci_template_reviewer.to_h
]
end
let(:teammate_json) do
teammates.to_json
end
subject(:roulette) { Object.new.extend(described_class) }
describe 'Spin#==' do
it 'compares Spin attributes' do
spin1 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, false, false)
spin2 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, false, false)
spin3 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, false, true)
spin4 = described_class::Spin.new(:backend, frontend_reviewer, frontend_maintainer, true, false)
spin5 = described_class::Spin.new(:backend, frontend_reviewer, backend_maintainer, false, false)
spin6 = described_class::Spin.new(:backend, backend_maintainer, frontend_maintainer, false, false)
spin7 = described_class::Spin.new(:frontend, frontend_reviewer, frontend_maintainer, false, false)
expect(spin1).to eq(spin2)
expect(spin1).not_to eq(spin3)
expect(spin1).not_to eq(spin4)
expect(spin1).not_to eq(spin5)
expect(spin1).not_to eq(spin6)
expect(spin1).not_to eq(spin7)
end
end
describe '#spin' do
let!(:project) { 'gitlab' }
let!(:mr_source_branch) { 'a-branch' }
let!(:mr_labels) { ['backend', 'devops::create'] }
let!(:author) { Tooling::Danger::Teammate.new('username' => 'johndoe') }
let(:timezone_experiment) { false }
let(:spins) do
# Stub the request at the latest time so that we can modify the raw data, e.g. available fields.
WebMock
.stub_request(:get, described_class::ROULETTE_DATA_URL)
.to_return(body: teammate_json)
subject.spin(project, categories, timezone_experiment: timezone_experiment)
end
before do
allow(subject).to receive(:mr_author_username).and_return(author.username)
allow(subject).to receive(:mr_labels).and_return(mr_labels)
allow(subject).to receive(:mr_source_branch).and_return(mr_source_branch)
end
context 'when timezone_experiment == false' do
context 'when change contains backend category' do
let(:categories) { [:backend] }
it 'assigns backend reviewer and maintainer' do
expect(spins[0].reviewer).to eq(engineering_productivity_reviewer)
expect(spins[0].maintainer).to eq(backend_maintainer)
expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false)])
end
context 'when teammate is not available' do
let(:backend_available) { false }
it 'assigns backend reviewer and no maintainer' do
expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, nil, false, false)])
end
end
end
context 'when change contains frontend category' do
let(:categories) { [:frontend] }
it 'assigns frontend reviewer and maintainer' do
expect(spins).to eq([described_class::Spin.new(:frontend, frontend_reviewer, frontend_maintainer, false, false)])
end
end
context 'when change contains many categories' do
let(:categories) { [:frontend, :test, :qa, :engineering_productivity, :ci_template, :backend] }
it 'has a deterministic sorting order' do
expect(spins.map(&:category)).to eq categories.sort
end
end
context 'when change contains QA category' do
let(:categories) { [:qa] }
it 'assigns QA maintainer' do
expect(spins).to eq([described_class::Spin.new(:qa, nil, software_engineer_in_test, false, false)])
end
end
context 'when change contains QA category and another category' do
let(:categories) { [:backend, :qa] }
it 'assigns QA maintainer' do
expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false), described_class::Spin.new(:qa, nil, software_engineer_in_test, :maintainer, false)])
end
context 'and author is an SET' do
let!(:author) { Tooling::Danger::Teammate.new('username' => software_engineer_in_test.username) }
it 'assigns QA reviewer' do
expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false), described_class::Spin.new(:qa, nil, nil, false, false)])
end
end
end
context 'when change contains Engineering Productivity category' do
let(:categories) { [:engineering_productivity] }
it 'assigns Engineering Productivity reviewer and fallback to backend maintainer' do
expect(spins).to eq([described_class::Spin.new(:engineering_productivity, engineering_productivity_reviewer, backend_maintainer, false, false)])
end
end
context 'when change contains CI/CD Template category' do
let(:categories) { [:ci_template] }
it 'assigns CI/CD Template reviewer and fallback to backend maintainer' do
expect(spins).to eq([described_class::Spin.new(:ci_template, ci_template_reviewer, backend_maintainer, false, false)])
end
end
context 'when change contains test category' do
let(:categories) { [:test] }
it 'assigns corresponding SET' do
expect(spins).to eq([described_class::Spin.new(:test, software_engineer_in_test, nil, :maintainer, false)])
end
end
end
context 'when timezone_experiment == true' do
let(:timezone_experiment) { true }
context 'when change contains backend category' do
let(:categories) { [:backend] }
it 'assigns backend reviewer and maintainer' do
expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, true)])
end
context 'when teammate is not in a good timezone' do
let(:backend_tz_offset_hours) { 5.0 }
it 'assigns backend reviewer and no maintainer' do
expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, nil, false, true)])
end
end
end
context 'when change includes a category with timezone disabled' do
let(:categories) { [:backend] }
before do
stub_const("#{described_class}::INCLUDE_TIMEZONE_FOR_CATEGORY", backend: false)
end
it 'assigns backend reviewer and maintainer' do
expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false)])
end
context 'when teammate is not in a good timezone' do
let(:backend_tz_offset_hours) { 5.0 }
it 'assigns backend reviewer and maintainer' do
expect(spins).to eq([described_class::Spin.new(:backend, engineering_productivity_reviewer, backend_maintainer, false, false)])
end
end
end
end
end
RSpec::Matchers.define :match_teammates do |expected|
match do |actual|
expected.each do |expected_person|
actual_person_found = actual.find { |actual_person| actual_person.name == expected_person.username }
actual_person_found &&
actual_person_found.name == expected_person.name &&
actual_person_found.role == expected_person.role &&
actual_person_found.projects == expected_person.projects
end
end
end
describe '#team' do
subject(:team) { roulette.team }
context 'HTTP failure' do
before do
WebMock
.stub_request(:get, described_class::ROULETTE_DATA_URL)
.to_return(status: 404)
end
it 'raises a pretty error' do
expect { team }.to raise_error(/Failed to read/)
end
end
context 'JSON failure' do
before do
WebMock
.stub_request(:get, described_class::ROULETTE_DATA_URL)
.to_return(body: 'INVALID JSON')
end
it 'raises a pretty error' do
expect { team }.to raise_error(/Failed to parse/)
end
end
context 'success' do
before do
WebMock
.stub_request(:get, described_class::ROULETTE_DATA_URL)
.to_return(body: teammate_json)
end
it 'returns an array of teammates' do
is_expected.to match_teammates([
backend_maintainer,
frontend_reviewer,
frontend_maintainer,
software_engineer_in_test,
engineering_productivity_reviewer,
ci_template_reviewer
])
end
it 'memoizes the result' do
expect(team.object_id).to eq(roulette.team.object_id)
end
end
end
describe '#project_team' do
subject { roulette.project_team('gitlab-qa') }
before do
WebMock
.stub_request(:get, described_class::ROULETTE_DATA_URL)
.to_return(body: teammate_json)
end
it 'filters team by project_name' do
is_expected.to match_teammates([
software_engineer_in_test
])
end
end
describe '#spin_for_person' do
let(:person_tz_offset_hours) { 0.0 }
let(:person1) do
Tooling::Danger::Teammate.new(
'username' => 'user1',
'available' => true,
'tz_offset_hours' => person_tz_offset_hours
)
end
let(:person2) do
Tooling::Danger::Teammate.new(
'username' => 'user2',
'available' => true,
'tz_offset_hours' => person_tz_offset_hours)
end
let(:author) do
Tooling::Danger::Teammate.new(
'username' => 'johndoe',
'available' => true,
'tz_offset_hours' => 0.0)
end
let(:unavailable) do
Tooling::Danger::Teammate.new(
'username' => 'janedoe',
'available' => false,
'tz_offset_hours' => 0.0)
end
before do
allow(subject).to receive(:mr_author_username).and_return(author.username)
end
(-4..4).each do |utc_offset|
context "when local hour for person is #{10 + utc_offset} (offset: #{utc_offset})" do
let(:person_tz_offset_hours) { utc_offset }
[false, true].each do |timezone_experiment|
context "with timezone_experiment == #{timezone_experiment}" do
it 'returns a random person' do
persons = [person1, person2]
selected = subject.spin_for_person(persons, random: Random.new, timezone_experiment: timezone_experiment)
expect(persons.map(&:username)).to include(selected.username)
end
end
end
end
end
((-12..-5).to_a + (5..12).to_a).each do |utc_offset|
context "when local hour for person is #{10 + utc_offset} (offset: #{utc_offset})" do
let(:person_tz_offset_hours) { utc_offset }
[false, true].each do |timezone_experiment|
context "with timezone_experiment == #{timezone_experiment}" do
it 'returns a random person or nil' do
persons = [person1, person2]
selected = subject.spin_for_person(persons, random: Random.new, timezone_experiment: timezone_experiment)
if timezone_experiment
expect(selected).to be_nil
else
expect(persons.map(&:username)).to include(selected.username)
end
end
end
end
end
end
it 'excludes unavailable persons' do
expect(subject.spin_for_person([unavailable], random: Random.new)).to be_nil
end
it 'excludes mr.author' do
expect(subject.spin_for_person([author], random: Random.new)).to be_nil
end
end
end
# frozen_string_literal: true
require 'rspec-parameterized'
require_relative 'danger_spec_helper'
require 'gitlab-dangerfiles'
require 'gitlab/dangerfiles/spec_helper'
require_relative '../../../tooling/danger/sidekiq_queues'
RSpec.describe Tooling::Danger::SidekiqQueues do
using RSpec::Parameterized::TableSyntax
include DangerSpecHelper
include_context "with dangerfile"
let(:fake_git) { double('fake-git') }
let(:fake_danger) { new_fake_danger.include(described_class) }
let(:fake_danger) { DangerSpecHelper.fake_danger.include(described_class) }
subject(:sidekiq_queues) { fake_danger.new(git: fake_git) }
describe '#changed_queue_files' do
using RSpec::Parameterized::TableSyntax
where(:modified_files, :changed_queue_files) do
%w(app/workers/all_queues.yml ee/app/workers/all_queues.yml foo) | %w(app/workers/all_queues.yml ee/app/workers/all_queues.yml)
%w(app/workers/all_queues.yml ee/app/workers/all_queues.yml) | %w(app/workers/all_queues.yml ee/app/workers/all_queues.yml)
......
# frozen_string_literal: true
require_relative '../../../tooling/danger/teammate'
require 'active_support/testing/time_helpers'
require 'rspec-parameterized'
RSpec.describe Tooling::Danger::Teammate do
using RSpec::Parameterized::TableSyntax
subject { described_class.new(options) }
let(:tz_offset_hours) { 2.0 }
let(:options) do
{
'username' => 'luigi',
'projects' => projects,
'role' => role,
'markdown_name' => '[Luigi](https://gitlab.com/luigi) (`@luigi`)',
'tz_offset_hours' => tz_offset_hours
}
end
let(:capabilities) { ['reviewer backend'] }
let(:projects) { { project => capabilities } }
let(:role) { 'Engineer, Manage' }
let(:labels) { [] }
let(:project) { double }
describe '#==' do
it 'compares Teammate username' do
joe1 = described_class.new('username' => 'joe', 'projects' => projects)
joe2 = described_class.new('username' => 'joe', 'projects' => [])
jane1 = described_class.new('username' => 'jane', 'projects' => projects)
jane2 = described_class.new('username' => 'jane', 'projects' => [])
expect(joe1).to eq(joe2)
expect(jane1).to eq(jane2)
expect(jane1).not_to eq(nil)
expect(described_class.new('username' => nil)).not_to eq(nil)
end
end
describe '#to_h' do
it 'returns the given options' do
expect(subject.to_h).to eq(options)
end
end
context 'when having multiple capabilities' do
let(:capabilities) { ['reviewer backend', 'maintainer frontend', 'trainee_maintainer qa'] }
it '#any_capability? returns true if the person has any capability for the category in the given project' do
expect(subject.any_capability?(project, :backend)).to be_truthy
expect(subject.any_capability?(project, :frontend)).to be_truthy
expect(subject.any_capability?(project, :qa)).to be_truthy
expect(subject.any_capability?(project, :engineering_productivity)).to be_falsey
end
it '#reviewer? supports multiple roles per project' do
expect(subject.reviewer?(project, :backend, labels)).to be_truthy
end
it '#traintainer? supports multiple roles per project' do
expect(subject.traintainer?(project, :qa, labels)).to be_truthy
end
it '#maintainer? supports multiple roles per project' do
expect(subject.maintainer?(project, :frontend, labels)).to be_truthy
end
context 'when labels contain devops::create and the category is test' do
let(:labels) { ['devops::create'] }
context 'when role is Software Engineer in Test, Create' do
let(:role) { 'Software Engineer in Test, Create' }
it '#reviewer? returns true' do
expect(subject.reviewer?(project, :test, labels)).to be_truthy
end
it '#maintainer? returns false' do
expect(subject.maintainer?(project, :test, labels)).to be_falsey
end
context 'when hyperlink is mangled in the role' do
let(:role) { '<a href="#">Software Engineer in Test</a>, Create' }
it '#reviewer? returns true' do
expect(subject.reviewer?(project, :test, labels)).to be_truthy
end
end
end
context 'when role is Software Engineer in Test' do
let(:role) { 'Software Engineer in Test' }
it '#reviewer? returns false' do
expect(subject.reviewer?(project, :test, labels)).to be_falsey
end
end
context 'when role is Software Engineer in Test, Manage' do
let(:role) { 'Software Engineer in Test, Manage' }
it '#reviewer? returns false' do
expect(subject.reviewer?(project, :test, labels)).to be_falsey
end
end
context 'when role is Backend Engineer, Engineering Productivity' do
let(:role) { 'Backend Engineer, Engineering Productivity' }
it '#reviewer? returns true' do
expect(subject.reviewer?(project, :engineering_productivity, labels)).to be_truthy
end
it '#maintainer? returns false' do
expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_falsey
end
context 'when capabilities include maintainer backend' do
let(:capabilities) { ['maintainer backend'] }
it '#maintainer? returns true' do
expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_truthy
end
end
context 'when capabilities include maintainer engineering productivity' do
let(:capabilities) { ['maintainer engineering_productivity'] }
it '#maintainer? returns true' do
expect(subject.maintainer?(project, :engineering_productivity, labels)).to be_truthy
end
end
context 'when capabilities include trainee_maintainer backend' do
let(:capabilities) { ['trainee_maintainer backend'] }
it '#traintainer? returns true' do
expect(subject.traintainer?(project, :engineering_productivity, labels)).to be_truthy
end
end
end
end
end
context 'when having single capability' do
let(:capabilities) { 'reviewer backend' }
it '#reviewer? supports one role per project' do
expect(subject.reviewer?(project, :backend, labels)).to be_truthy
end
it '#traintainer? supports one role per project' do
expect(subject.traintainer?(project, :database, labels)).to be_falsey
end
it '#maintainer? supports one role per project' do
expect(subject.maintainer?(project, :frontend, labels)).to be_falsey
end
end
describe '#local_hour' do
include ActiveSupport::Testing::TimeHelpers
around do |example|
travel_to(Time.utc(2020, 6, 23, 10)) { example.run }
end
context 'when author is given' do
where(:tz_offset_hours, :expected_local_hour) do
-12 | 22
-10 | 0
2 | 12
4 | 14
12 | 22
end
with_them do
it 'returns the correct local_hour' do
expect(subject.local_hour).to eq(expected_local_hour)
end
end
end
end
describe '#markdown_name' do
it 'returns markdown name with timezone info' do
expect(subject.markdown_name).to eq("#{options['markdown_name']} (UTC+2)")
end
context 'when offset is 1.5' do
let(:tz_offset_hours) { 1.5 }
it 'returns markdown name with timezone info, not truncated' do
expect(subject.markdown_name).to eq("#{options['markdown_name']} (UTC+1.5)")
end
end
context 'when author is given' do
where(:tz_offset_hours, :author_offset, :diff_text) do
-12 | -10 | "2 hours behind `@mario`"
-10 | -12 | "2 hours ahead of `@mario`"
-10 | 2 | "12 hours behind `@mario`"
2 | 4 | "2 hours behind `@mario`"
4 | 2 | "2 hours ahead of `@mario`"
2 | 3 | "1 hour behind `@mario`"
3 | 2 | "1 hour ahead of `@mario`"
2 | 2 | "same timezone as `@mario`"
end
with_them do
it 'returns markdown name with timezone info' do
author = described_class.new(options.merge('username' => 'mario', 'tz_offset_hours' => author_offset))
floored_offset_hours = subject.__send__(:floored_offset_hours)
utc_offset = floored_offset_hours >= 0 ? "+#{floored_offset_hours}" : floored_offset_hours
expect(subject.markdown_name(author: author)).to eq("#{options['markdown_name']} (UTC#{utc_offset}, #{diff_text})")
end
end
end
end
end
# frozen_string_literal: true
require 'rspec-parameterized'
require_relative '../../../tooling/danger/title_linting'
RSpec.describe Tooling::Danger::TitleLinting do
using RSpec::Parameterized::TableSyntax
describe '#sanitize_mr_title' do
where(:mr_title, :expected_mr_title) do
'`My MR title`' | "\\`My MR title\\`"
'WIP: My MR title' | 'My MR title'
'Draft: My MR title' | 'My MR title'
'(Draft) My MR title' | 'My MR title'
'[Draft] My MR title' | 'My MR title'
'[DRAFT] My MR title' | 'My MR title'
'DRAFT: My MR title' | 'My MR title'
'DRAFT: `My MR title`' | "\\`My MR title\\`"
end
with_them do
subject { described_class.sanitize_mr_title(mr_title) }
it { is_expected.to eq(expected_mr_title) }
end
end
describe '#remove_draft_flag' do
where(:mr_title, :expected_mr_title) do
'WIP: My MR title' | 'My MR title'
'Draft: My MR title' | 'My MR title'
'(Draft) My MR title' | 'My MR title'
'[Draft] My MR title' | 'My MR title'
'[DRAFT] My MR title' | 'My MR title'
'DRAFT: My MR title' | 'My MR title'
end
with_them do
subject { described_class.remove_draft_flag(mr_title) }
it { is_expected.to eq(expected_mr_title) }
end
end
describe '#has_draft_flag?' do
it 'returns true for a draft title' do
expect(described_class.has_draft_flag?('Draft: My MR title')).to be true
end
it 'returns false for non draft title' do
expect(described_class.has_draft_flag?('My MR title')).to be false
end
end
describe '#has_cherry_pick_flag?' do
[
'Cherry Pick !1234',
'cherry-pick !1234',
'CherryPick !1234'
].each do |mr_title|
it 'returns true for cherry-pick title' do
expect(described_class.has_cherry_pick_flag?(mr_title)).to be true
end
end
it 'returns false for non cherry-pick title' do
expect(described_class.has_cherry_pick_flag?('My MR title')).to be false
end
end
describe '#has_run_all_rspec_flag?' do
it 'returns true for a title that includes RUN ALL RSPEC' do
expect(described_class.has_run_all_rspec_flag?('My MR title RUN ALL RSPEC')).to be true
end
it 'returns true for a title that does not include RUN ALL RSPEC' do
expect(described_class.has_run_all_rspec_flag?('My MR title')).to be false
end
end
describe '#has_run_as_if_foss_flag?' do
it 'returns true for a title that includes RUN AS-IF-FOSS' do
expect(described_class.has_run_as_if_foss_flag?('My MR title RUN AS-IF-FOSS')).to be true
end
it 'returns true for a title that does not include RUN AS-IF-FOSS' do
expect(described_class.has_run_as_if_foss_flag?('My MR title')).to be false
end
end
end
# frozen_string_literal: true
require_relative '../../../../tooling/danger/weightage/maintainers'
RSpec.describe Tooling::Danger::Weightage::Maintainers do
let(:multiplier) { Tooling::Danger::Weightage::CAPACITY_MULTIPLIER }
let(:regular_maintainer) { double('Teammate', reduced_capacity: false) }
let(:reduced_capacity_maintainer) { double('Teammate', reduced_capacity: true) }
let(:maintainers) do
[
regular_maintainer,
reduced_capacity_maintainer
]
end
let(:maintainer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * multiplier }
let(:reduced_capacity_maintainer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT }
subject(:weighted_maintainers) { described_class.new(maintainers).execute }
describe '#execute' do
it 'weights the maintainers overall' do
expect(weighted_maintainers.count).to eq maintainer_count + reduced_capacity_maintainer_count
end
it 'has total count of regular maintainers' do
expect(weighted_maintainers.count { |r| r.object_id == regular_maintainer.object_id }).to eq maintainer_count
end
it 'has count of reduced capacity maintainers' do
expect(weighted_maintainers.count { |r| r.object_id == reduced_capacity_maintainer.object_id }).to eq reduced_capacity_maintainer_count
end
end
end
# frozen_string_literal: true
require_relative '../../../../tooling/danger/weightage/reviewers'
RSpec.describe Tooling::Danger::Weightage::Reviewers do
let(:multiplier) { Tooling::Danger::Weightage::CAPACITY_MULTIPLIER }
let(:regular_reviewer) { double('Teammate', hungry: false, reduced_capacity: false) }
let(:hungry_reviewer) { double('Teammate', hungry: true, reduced_capacity: false) }
let(:reduced_capacity_reviewer) { double('Teammate', hungry: false, reduced_capacity: true) }
let(:reviewers) do
[
hungry_reviewer,
regular_reviewer,
reduced_capacity_reviewer
]
end
let(:regular_traintainer) { double('Teammate', hungry: false, reduced_capacity: false) }
let(:hungry_traintainer) { double('Teammate', hungry: true, reduced_capacity: false) }
let(:reduced_capacity_traintainer) { double('Teammate', hungry: false, reduced_capacity: true) }
let(:traintainers) do
[
hungry_traintainer,
regular_traintainer,
reduced_capacity_traintainer
]
end
let(:hungry_reviewer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * multiplier + described_class::DEFAULT_REVIEWER_WEIGHT }
let(:hungry_traintainer_count) { described_class::TRAINTAINER_WEIGHT * multiplier + described_class::DEFAULT_REVIEWER_WEIGHT }
let(:reviewer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * multiplier }
let(:traintainer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT * described_class::TRAINTAINER_WEIGHT * multiplier }
let(:reduced_capacity_reviewer_count) { Tooling::Danger::Weightage::BASE_REVIEWER_WEIGHT }
let(:reduced_capacity_traintainer_count) { described_class::TRAINTAINER_WEIGHT }
subject(:weighted_reviewers) { described_class.new(reviewers, traintainers).execute }
describe '#execute', :aggregate_failures do
it 'weights the reviewers overall' do
reviewers_count = hungry_reviewer_count + reviewer_count + reduced_capacity_reviewer_count
traintainers_count = hungry_traintainer_count + traintainer_count + reduced_capacity_traintainer_count
expect(weighted_reviewers.count).to eq reviewers_count + traintainers_count
end
it 'has total count of hungry reviewers and traintainers' do
expect(weighted_reviewers.count(&:hungry)).to eq hungry_reviewer_count + hungry_traintainer_count
expect(weighted_reviewers.count { |r| r.object_id == hungry_reviewer.object_id }).to eq hungry_reviewer_count
expect(weighted_reviewers.count { |r| r.object_id == hungry_traintainer.object_id }).to eq hungry_traintainer_count
end
it 'has total count of regular reviewers and traintainers' do
expect(weighted_reviewers.count { |r| r.object_id == regular_reviewer.object_id }).to eq reviewer_count
expect(weighted_reviewers.count { |r| r.object_id == regular_traintainer.object_id }).to eq traintainer_count
end
it 'has count of reduced capacity reviewers' do
expect(weighted_reviewers.count(&:reduced_capacity)).to eq reduced_capacity_reviewer_count + reduced_capacity_traintainer_count
expect(weighted_reviewers.count { |r| r.object_id == reduced_capacity_reviewer.object_id }).to eq reduced_capacity_reviewer_count
expect(weighted_reviewers.count { |r| r.object_id == reduced_capacity_traintainer.object_id }).to eq reduced_capacity_traintainer_count
end
end
end
# frozen_string_literal: true
require_relative '../../tooling/gitlab_danger'
RSpec.describe GitlabDanger do
let(:gitlab_danger_helper) { nil }
subject { described_class.new(gitlab_danger_helper) }
describe '.local_warning_message' do
it 'returns an informational message with rules that can run' do
expect(described_class.local_warning_message).to eq("==> Only the following Danger rules can be run locally: #{described_class::LOCAL_RULES.join(', ')}")
end
end
describe '.success_message' do
it 'returns an informational success message' do
expect(described_class.success_message).to eq('==> No Danger rule violations!')
end
end
describe '#rule_names' do
context 'when running locally' do
it 'returns local only rules' do
expect(subject.rule_names).to eq(described_class::LOCAL_RULES)
end
end
context 'when running under CI' do
let(:gitlab_danger_helper) { double('danger_gitlab_helper') }
it 'returns all rules' do
expect(subject.rule_names).to eq(described_class::LOCAL_RULES | described_class::CI_ONLY_RULES)
end
end
end
describe '#html_link' do
context 'when running locally' do
it 'returns the same string' do
str = 'something'
expect(subject.html_link(str)).to eq(str)
end
end
context 'when running under CI' do
let(:gitlab_danger_helper) { double('danger_gitlab_helper') }
it 'returns a HTML link formatted version of the string' do
str = 'something'
html_formatted_str = %Q{<a href="#{str}">#{str}</a>}
expect(gitlab_danger_helper).to receive(:html_link).with(str).and_return(html_formatted_str)
expect(subject.html_link(str)).to eq(html_formatted_str)
end
end
end
describe '#ci?' do
context 'when gitlab_danger_helper is not available' do
it 'returns false' do
expect(subject.ci?).to be_falsey
end
end
context 'when gitlab_danger_helper is available' do
let(:gitlab_danger_helper) { double('danger_gitlab_helper') }
it 'returns true' do
expect(subject.ci?).to be_truthy
end
end
end
end
# 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
# frozen_string_literal: true
require_relative 'title_linting'
require 'gitlab/dangerfiles/title_linting'
module Tooling
module Danger
......@@ -44,8 +44,8 @@ module Tooling
def required_reasons
[].tap do |reasons|
reasons << :db_changes if helper.changes.added.has_category?(:migration)
reasons << :feature_flag_removed if helper.changes.deleted.has_category?(:feature_flag)
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
......@@ -58,7 +58,7 @@ module Tooling
end
def found
@found ||= helper.changes.added.by_category(:changelog).files.first
@found ||= project_helper.changes.added.by_category(:changelog).files.first
end
def ee_changelog?
......@@ -86,11 +86,11 @@ module Tooling
private
def sanitized_mr_title
TitleLinting.sanitize_mr_title(helper.mr_title)
Gitlab::Dangerfiles::TitleLinting.sanitize_mr_title(helper.mr_title)
end
def categories_need_changelog?
(helper.changes.categories - NO_CHANGELOG_CATEGORIES).any?
(project_helper.changes.categories - NO_CHANGELOG_CATEGORIES).any?
end
def without_no_changelog_label?
......
# 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
# 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
# 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
# frozen_string_literal: true
require 'delegate'
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
Change = Struct.new(:file, :change_type, :category)
class Changes < ::SimpleDelegator
def added
select_by_change_type(:added)
end
def modified
select_by_change_type(:modified)
end
def deleted
select_by_change_type(:deleted)
end
def renamed_before
select_by_change_type(:renamed_before)
end
def renamed_after
select_by_change_type(:renamed_after)
end
def has_category?(category)
any? { |change| change.category == category }
end
def by_category(category)
Changes.new(select { |change| change.category == category })
end
def categories
map(&:category).uniq
end
def files
map(&:file)
end
private
def select_by_change_type(change_type)
Changes.new(select { |change| change.change_type == change_type })
end
end
# @return [Hash<Symbol,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
# @return [Changes]
def changes
Changes.new([]).tap do |changes|
git.added_files.each do |file|
categories_for_file(file).each { |category| changes << Change.new(file, :added, category) }
end
git.modified_files.each do |file|
categories_for_file(file).each { |category| changes << Change.new(file, :modified, category) }
end
git.deleted_files.each do |file|
categories_for_file(file).each { |category| changes << Change.new(file, :deleted, category) }
end
git.renamed_files.map { |x| x[:before] }.each do |file|
categories_for_file(file).each { |category| changes << Change.new(file, :renamed_before, category) }
end
git.renamed_files.map { |x| x[:after] }.each do |file|
categories_for_file(file).each { |category| changes << Change.new(file, :renamed_after, category) }
end
end
end
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
# 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],
......@@ -289,94 +122,59 @@ module Tooling
%r{\.js\z} => :frontend
}.freeze
def new_teammates(usernames)
usernames.map { |u| Tooling::Danger::Teammate.new('username' => u) }
end
def mr_iid
return '' unless gitlab_helper
gitlab_helper.mr_json['iid']
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_labels
return [] unless gitlab_helper
gitlab_helper.mr_labels
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)
def changes_by_category
helper.changes_by_category(CATEGORIES)
end
def security_mr?
mr_web_url.include?('/gitlab-org/security/')
def changes
helper.changes(CATEGORIES)
end
def cherry_pick_mr?
TitleLinting.has_cherry_pick_flag?(mr_title)
def categories_for_file(file)
helper.categories_for_file(file, CATEGORIES)
end
def run_all_rspec_mr?
TitleLinting.has_run_all_rspec_flag?(mr_title)
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 run_as_if_foss_mr?
TitleLinting.has_run_as_if_foss_flag?(mr_title)
def success_message
"#{MESSAGE_PREFIX} No Danger rule violations!"
end
module_function :success_message # rubocop:disable Style/AccessModifierDeclarations
def stable_branch?
/\A\d+-\d+-stable-ee/i.match?(mr_target_branch)
def rule_names
helper.ci? ? LOCAL_RULES | CI_ONLY_RULES : LOCAL_RULES
end
def mr_has_labels?(*labels)
labels = labels.flatten.uniq
(labels & mr_labels) == labels
def all_ee_changes
all_changed_files.grep(%r{\Aee/})
end
def labels_list(labels, sep: ', ')
labels.map { |label| %Q{~"#{label}"} }.join(sep)
def project_name
ee? ? 'gitlab' : 'gitlab-foss'
end
def prepare_labels_for_mr(labels)
return '' unless labels.any?
"/label #{labels_list(labels, sep: ' ')}"
def missing_database_labels(current_mr_labels)
labels = if has_database_scoped_labels?(current_mr_labels)
['database']
else
['database', 'database::review pending']
end
def changed_files(regex)
all_changed_files.grep(regex)
labels - current_mr_labels
end
def has_database_scoped_labels?(labels)
labels.any? { |label| label.start_with?('database::') }
end
private
def has_ci_changes?
changed_files(%r{\A(\.gitlab-ci\.yml|\.gitlab/ci/)}).any?
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 group_label(labels)
labels.find { |label| label.start_with?('group::') }
def has_database_scoped_labels?(current_mr_labels)
current_mr_labels.any? { |label| label.start_with?('database::') }
end
end
end
......
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment