Commit 4458217a authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch...

Merge branch '228853-reviewer-roulette-default-to-previous-roulette-if-no-timezone-reviewer-maintainer-found' into 'master'

Fallback to random-based roulette when no one is picked otherwise

Closes #228853

See merge request gitlab-org/gitlab!36948
parents ae287984 11af9e44
...@@ -42,23 +42,29 @@ OPTIONAL_REVIEW_TEMPLATE = "%{role} review is optional for %{category}".freeze ...@@ -42,23 +42,29 @@ OPTIONAL_REVIEW_TEMPLATE = "%{role} review is optional for %{category}".freeze
NOT_AVAILABLE_TEMPLATE = 'No %{role} available'.freeze NOT_AVAILABLE_TEMPLATE = 'No %{role} available'.freeze
TIMEZONE_EXPERIMENT = true TIMEZONE_EXPERIMENT = true
def mr_author def note_for_spins_role(spins, role)
roulette.team.find { |person| person.username == gitlab.mr_author } spins.each do |spin|
note = note_for_spin_role(spin, role)
return note if note
end
NOT_AVAILABLE_TEMPLATE % { role: role }
end end
def note_for_category_role(spin, role) def note_for_spin_role(spin, role)
if spin.optional_role == role if spin.optional_role == role
return OPTIONAL_REVIEW_TEMPLATE % { role: role.capitalize, category: helper.label_for_category(spin.category) } return OPTIONAL_REVIEW_TEMPLATE % { role: role.capitalize, category: helper.label_for_category(spin.category) }
end end
spin.public_send(role)&.markdown_name(timezone_experiment: TIMEZONE_EXPERIMENT, author: mr_author) || NOT_AVAILABLE_TEMPLATE % { role: role } # rubocop:disable GitlabSecurity/PublicSend spin.public_send(role)&.markdown_name(timezone_experiment: spin.timezone_experiment, author: roulette.team_mr_author) # rubocop:disable GitlabSecurity/PublicSend
end end
def markdown_row_for_spin(spin) def markdown_row_for_spins(category, spins_array)
reviewer_note = note_for_category_role(spin, :reviewer) reviewer_note = note_for_spins_role(spins_array, :reviewer)
maintainer_note = note_for_category_role(spin, :maintainer) maintainer_note = note_for_spins_role(spins_array, :maintainer)
"| #{helper.label_for_category(spin.category)} | #{reviewer_note} | #{maintainer_note} |" "| #{helper.label_for_category(category)} | #{reviewer_note} | #{maintainer_note} |"
end end
changes = helper.changes_by_category changes = helper.changes_by_category
...@@ -70,26 +76,20 @@ changes.delete(:docs) ...@@ -70,26 +76,20 @@ changes.delete(:docs)
categories = changes.keys - [:unknown] categories = changes.keys - [:unknown]
# Ensure to spin for database reviewer/maintainer when ~database is applied (e.g. to review SQL queries) # Ensure to spin for database reviewer/maintainer when ~database is applied (e.g. to review SQL queries)
categories << :database if gitlab.mr_labels.include?('database') && !categories.include?(:database) categories << :database if helper.gitlab_helper&.mr_labels&.include?('database') && !categories.include?(:database)
if changes.any? if changes.any?
project = helper.project_name project = helper.project_name
branch_name = gitlab.mr_json['source_branch']
markdown(MESSAGE) timezone_roulette_spins = roulette.spin(project, categories, timezone_experiment: true)
random_roulette_spins = roulette.spin(project, categories, timezone_experiment: false)
roulette_spins = roulette.spin(project, categories, branch_name, timezone_experiment: TIMEZONE_EXPERIMENT) rows = timezone_roulette_spins.map do |spin|
rows = roulette_spins.map do |spin| fallback_spin = random_roulette_spins.find { |random_roulette_spins| random_roulette_spins.category == spin.category }
# MR includes QA changes, but also other changes, and author isn't an SET markdown_row_for_spins(spin.category, [spin, fallback_spin])
if spin.category == :qa && categories.size > 1 && mr_author && !mr_author.reviewer?(project, spin.category, [])
spin.optional_role = :maintainer
end
spin.optional_role = :maintainer if spin.category == :test
markdown_row_for_spin(spin)
end end
markdown(MESSAGE)
markdown(CATEGORY_TABLE_HEADER + rows.join("\n")) unless rows.empty? markdown(CATEGORY_TABLE_HEADER + rows.join("\n")) unless rows.empty?
unknown = changes.fetch(:unknown, []) unknown = changes.fetch(:unknown, [])
......
...@@ -53,7 +53,7 @@ module Gitlab ...@@ -53,7 +53,7 @@ module Gitlab
def ee? def ee?
# Support former project name for `dev` and support local Danger run # Support former project name for `dev` and support local Danger run
%w[gitlab gitlab-ee].include?(ENV['CI_PROJECT_NAME']) || Dir.exist?('../../ee') %w[gitlab gitlab-ee].include?(ENV['CI_PROJECT_NAME']) || Dir.exist?(File.expand_path('../../../ee', __dir__))
end end
def gitlab_helper def gitlab_helper
......
# frozen_string_literal: true # frozen_string_literal: true
require_relative 'teammate' require_relative 'teammate'
require_relative 'request_helper'
module Gitlab module Gitlab
module Danger module Danger
...@@ -12,45 +13,49 @@ module Gitlab ...@@ -12,45 +13,49 @@ module Gitlab
database: false database: false
}.freeze }.freeze
Spin = Struct.new(:category, :reviewer, :maintainer, :optional_role) 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 # Assigns GitLab team members to be reviewer and maintainer
# for each change category that a Merge Request contains. # for each change category that a Merge Request contains.
# #
# @return [Array<Spin>] # @return [Array<Spin>]
def spin(project, categories, branch_name, timezone_experiment: false) def spin(project, categories, timezone_experiment: false)
team = spins = categories.map do |category|
begin
project_team(project)
rescue => err
warn("Reviewer roulette failed to load team data: #{err.message}")
[]
end
canonical_branch_name = canonical_branch_name(branch_name)
spin_per_category = categories.each_with_object({}) do |category, memo|
including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(category, timezone_experiment) including_timezone = INCLUDE_TIMEZONE_FOR_CATEGORY.fetch(category, timezone_experiment)
memo[category] = spin_for_category(team, project, category, canonical_branch_name, timezone_experiment: including_timezone) spin_for_category(project, category, timezone_experiment: including_timezone)
end end
spin_per_category.map do |category, spin| backend_spin = spins.find { |spin| spin.category == :backend }
case category
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&.reviewer?(project, spin.category, [])
spin.optional_role = :maintainer
end
when :test when :test
spin.optional_role = :maintainer
if spin.reviewer.nil? if spin.reviewer.nil?
# Fetch an already picked backend reviewer, or pick one otherwise # Fetch an already picked backend reviewer, or pick one otherwise
spin.reviewer = spin_per_category[:backend]&.reviewer || spin_for_category(team, project, :backend, canonical_branch_name).reviewer spin.reviewer = backend_spin&.reviewer || spin_for_category(project, :backend, timezone_experiment: including_timezone).reviewer
end end
when :engineering_productivity when :engineering_productivity
if spin.maintainer.nil? if spin.maintainer.nil?
# Fetch an already picked backend maintainer, or pick one otherwise # Fetch an already picked backend maintainer, or pick one otherwise
spin.maintainer = spin_per_category[:backend]&.maintainer || spin_for_category(team, project, :backend, canonical_branch_name).maintainer spin.maintainer = backend_spin&.maintainer || spin_for_category(project, :backend, timezone_experiment: including_timezone).maintainer
end end
end end
spin
end end
spins
end end
# Looks up the current list of GitLab team members and parses it into a # Looks up the current list of GitLab team members and parses it into a
...@@ -73,14 +78,9 @@ module Gitlab ...@@ -73,14 +78,9 @@ module Gitlab
# @return [Array<Teammate>] # @return [Array<Teammate>]
def project_team(project_name) def project_team(project_name)
team.select { |member| member.in_project?(project_name) } team.select { |member| member.in_project?(project_name) }
end rescue => err
warn("Reviewer roulette failed to load team data: #{err.message}")
def canonical_branch_name(branch_name) []
branch_name.gsub(/^[ce]e-|-[ce]e$/, '')
end
def new_random(seed)
Random.new(Digest::MD5.hexdigest(seed).to_i(16))
end end
# Known issue: If someone is rejected due to OOO, and then becomes not OOO, the # Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
...@@ -113,16 +113,35 @@ module Gitlab ...@@ -113,16 +113,35 @@ module Gitlab
# @param [Teammate] person # @param [Teammate] person
# @return [Boolean] # @return [Boolean]
def mr_author?(person) def mr_author?(person)
person.username == gitlab.mr_author 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 end
def spin_role_for_category(team, role, project, category) def spin_role_for_category(team, role, project, category)
team.select do |member| team.select do |member|
member.public_send("#{role}?", project, category, gitlab.mr_labels) # rubocop:disable GitlabSecurity/PublicSend member.public_send("#{role}?", project, category, mr_labels) # rubocop:disable GitlabSecurity/PublicSend
end end
end end
def spin_for_category(team, project, category, branch_name, timezone_experiment: false) def spin_for_category(project, category, timezone_experiment: false)
team = project_team(project)
reviewers, traintainers, maintainers = reviewers, traintainers, maintainers =
%i[reviewer traintainer maintainer].map do |role| %i[reviewer traintainer maintainer].map do |role|
spin_role_for_category(team, role, project, category) spin_role_for_category(team, role, project, category)
...@@ -132,11 +151,11 @@ module Gitlab ...@@ -132,11 +151,11 @@ module Gitlab
# https://gitlab.com/gitlab-org/gitlab/issues/26723 # https://gitlab.com/gitlab-org/gitlab/issues/26723
# Make traintainers have triple the chance to be picked as a reviewer # Make traintainers have triple the chance to be picked as a reviewer
random = new_random(branch_name) random = new_random(mr_source_branch)
reviewer = spin_for_person(reviewers + traintainers + traintainers, random: random, timezone_experiment: timezone_experiment) reviewer = spin_for_person(reviewers + traintainers + traintainers, random: random, timezone_experiment: timezone_experiment)
maintainer = spin_for_person(maintainers, random: random, timezone_experiment: timezone_experiment) maintainer = spin_for_person(maintainers, random: random, timezone_experiment: timezone_experiment)
Spin.new(category, reviewer, maintainer) Spin.new(category, reviewer, maintainer, false, timezone_experiment)
end end
end end
end end
......
...@@ -3,10 +3,11 @@ ...@@ -3,10 +3,11 @@
module Gitlab module Gitlab
module Danger module Danger
class Teammate class Teammate
attr_reader :username, :name, :role, :projects, :available, :tz_offset_hours attr_reader :options, :username, :name, :role, :projects, :available, :tz_offset_hours
# The options data are produced by https://gitlab.com/gitlab-org/gitlab-roulette/-/blob/master/lib/team_member.rb # The options data are produced by https://gitlab.com/gitlab-org/gitlab-roulette/-/blob/master/lib/team_member.rb
def initialize(options = {}) def initialize(options = {})
@options = options
@username = options['username'] @username = options['username']
@name = options['name'] @name = options['name']
@markdown_name = options['markdown_name'] @markdown_name = options['markdown_name']
...@@ -16,6 +17,16 @@ module Gitlab ...@@ -16,6 +17,16 @@ module Gitlab
@tz_offset_hours = options['tz_offset_hours'] @tz_offset_hours = options['tz_offset_hours']
end end
def to_h
options
end
def ==(other)
return false unless other.respond_to?(:username)
other.username == username
end
def in_project?(name) def in_project?(name)
projects&.has_key?(name) projects&.has_key?(name)
end end
......
...@@ -98,21 +98,21 @@ RSpec.describe Gitlab::Danger::Helper do ...@@ -98,21 +98,21 @@ RSpec.describe Gitlab::Danger::Helper do
it 'delegates to CHANGELOG-EE.md existence if CI_PROJECT_NAME is set to something else' do it 'delegates to CHANGELOG-EE.md existence if CI_PROJECT_NAME is set to something else' do
stub_env('CI_PROJECT_NAME', 'something else') stub_env('CI_PROJECT_NAME', 'something else')
expect(Dir).to receive(:exist?).with('../../ee') { true } expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { true }
is_expected.to be_truthy is_expected.to be_truthy
end end
it 'returns true if ee exists' do it 'returns true if ee exists' do
stub_env('CI_PROJECT_NAME', nil) stub_env('CI_PROJECT_NAME', nil)
expect(Dir).to receive(:exist?).with('../../ee') { true } expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { true }
is_expected.to be_truthy is_expected.to be_truthy
end end
it "returns false if ee doesn't exist" do it "returns false if ee doesn't exist" do
stub_env('CI_PROJECT_NAME', nil) stub_env('CI_PROJECT_NAME', nil)
expect(Dir).to receive(:exist?).with('../../ee') { false } expect(Dir).to receive(:exist?).with(File.expand_path('../../../../ee', __dir__)) { false }
is_expected.to be_falsy is_expected.to be_falsy
end end
......
This diff is collapsed.
# frozen_string_literal: true # frozen_string_literal: true
require 'fast_spec_helper'
require 'timecop' require 'timecop'
require 'rspec-parameterized' require 'rspec-parameterized'
...@@ -10,16 +8,16 @@ require 'gitlab/danger/teammate' ...@@ -10,16 +8,16 @@ require 'gitlab/danger/teammate'
RSpec.describe Gitlab::Danger::Teammate do RSpec.describe Gitlab::Danger::Teammate do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
subject { described_class.new(options.stringify_keys) } subject { described_class.new(options) }
let(:tz_offset_hours) { 2.0 } let(:tz_offset_hours) { 2.0 }
let(:options) do let(:options) do
{ {
username: 'luigi', 'username' => 'luigi',
projects: projects, 'projects' => projects,
role: role, 'role' => role,
markdown_name: '[Luigi](https://gitlab.com/luigi) (`@luigi`)', 'markdown_name' => '[Luigi](https://gitlab.com/luigi) (`@luigi`)',
tz_offset_hours: tz_offset_hours 'tz_offset_hours' => tz_offset_hours
} }
end end
let(:capabilities) { ['reviewer backend'] } let(:capabilities) { ['reviewer backend'] }
...@@ -28,6 +26,26 @@ RSpec.describe Gitlab::Danger::Teammate do ...@@ -28,6 +26,26 @@ RSpec.describe Gitlab::Danger::Teammate do
let(:labels) { [] } let(:labels) { [] }
let(:project) { double } 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 context 'when having multiple capabilities' do
let(:capabilities) { ['reviewer backend', 'maintainer frontend', 'trainee_maintainer qa'] } let(:capabilities) { ['reviewer backend', 'maintainer frontend', 'trainee_maintainer qa'] }
...@@ -153,21 +171,21 @@ RSpec.describe Gitlab::Danger::Teammate do ...@@ -153,21 +171,21 @@ RSpec.describe Gitlab::Danger::Teammate do
describe '#markdown_name' do describe '#markdown_name' do
context 'when timezone_experiment == false' do context 'when timezone_experiment == false' do
it 'returns markdown name as-is' do it 'returns markdown name as-is' do
expect(subject.markdown_name).to eq(options[:markdown_name]) expect(subject.markdown_name).to eq(options['markdown_name'])
expect(subject.markdown_name(timezone_experiment: false)).to eq(options[:markdown_name]) expect(subject.markdown_name(timezone_experiment: false)).to eq(options['markdown_name'])
end end
end end
context 'when timezone_experiment == true' do context 'when timezone_experiment == true' do
it 'returns markdown name with timezone info' do it 'returns markdown name with timezone info' do
expect(subject.markdown_name(timezone_experiment: true)).to eq("#{options[:markdown_name]} (UTC+2)") expect(subject.markdown_name(timezone_experiment: true)).to eq("#{options['markdown_name']} (UTC+2)")
end end
context 'when offset is 1.5' do context 'when offset is 1.5' do
let(:tz_offset_hours) { 1.5 } let(:tz_offset_hours) { 1.5 }
it 'returns markdown name with timezone info, not truncated' do it 'returns markdown name with timezone info, not truncated' do
expect(subject.markdown_name(timezone_experiment: true)).to eq("#{options[:markdown_name]} (UTC+1.5)") expect(subject.markdown_name(timezone_experiment: true)).to eq("#{options['markdown_name']} (UTC+1.5)")
end end
end end
...@@ -185,12 +203,12 @@ RSpec.describe Gitlab::Danger::Teammate do ...@@ -185,12 +203,12 @@ RSpec.describe Gitlab::Danger::Teammate do
with_them do with_them do
it 'returns markdown name with timezone info' do it 'returns markdown name with timezone info' do
author = described_class.new(options.merge(username: 'mario', tz_offset_hours: author_offset).stringify_keys) author = described_class.new(options.merge('username' => 'mario', 'tz_offset_hours' => author_offset))
floored_offset_hours = subject.__send__(:floored_offset_hours) floored_offset_hours = subject.__send__(:floored_offset_hours)
utc_offset = floored_offset_hours >= 0 ? "+#{floored_offset_hours}" : floored_offset_hours utc_offset = floored_offset_hours >= 0 ? "+#{floored_offset_hours}" : floored_offset_hours
expect(subject.markdown_name(timezone_experiment: true, author: author)).to eq("#{options[:markdown_name]} (UTC#{utc_offset}, #{diff_text})") expect(subject.markdown_name(timezone_experiment: true, author: author)).to eq("#{options['markdown_name']} (UTC#{utc_offset}, #{diff_text})")
end end
end end
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