diff options
Diffstat (limited to 'tooling/danger/roulette.rb')
-rw-r--r-- | tooling/danger/roulette.rb | 169 |
1 files changed, 169 insertions, 0 deletions
diff --git a/tooling/danger/roulette.rb b/tooling/danger/roulette.rb new file mode 100644 index 00000000000..c928fb2b655 --- /dev/null +++ b/tooling/danger/roulette.rb @@ -0,0 +1,169 @@ +# 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 |