# frozen_string_literal: true require_relative "../../gitlab/dangerfiles/teammate" require_relative "../../gitlab/dangerfiles/weightage/maintainers" require_relative "../../gitlab/dangerfiles/weightage/reviewers" module Danger # Common helper functions for our danger scripts. See Danger::Helper # for more details class Roulette < Danger::Plugin 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) HTTPError = Class.new(StandardError) # Finds the +Gitlab::Dangerfiles::Teammate+ object whose username matches the MR author username. # # @return [Gitlab::Dangerfiles::Teammate] def team_mr_author company_members.find { |person| person.username == helper.mr_author } end # Assigns GitLab team members to be reviewer and maintainer # for the given +categories+. # # @param project [String] A project path. # @param categories [Array] An array of categories symbols. # @param timezone_experiment [Boolean] Whether to select reviewers based in timezone or not. # # @return [Array] def spin(project, categories = [nil], timezone_experiment: false) project = project.downcase categories = categories.map { |category| category&.downcase } spins = categories.sort_by(&:to_s).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 } frontend_spin = spins.find { |spin| spin.category == :frontend } 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 :tooling, :engineering_productivity # Deprecated as of 2.3.0 in favor of tooling 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 when :product_intelligence spin.optional_role = :maintainer if spin.maintainer.nil? # Fetch an already picked maintainer, or pick one otherwise spin.maintainer = backend_spin&.maintainer || frontend_spin&.maintainer || spin_for_category(project, :backend, timezone_experiment: including_timezone).maintainer end when :integrations_be, :integrations_fe spin.optional_role = :maintainer end end spins end private # @param [Gitlab::Dangerfiles::Teammate] person # @return [Boolean] def valid_person?(person) !mr_author?(person) && person.available end # @param [Gitlab::Dangerfiles::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 [Gitlab::Dangerfiles::Teammate] person # @return [Boolean] def mr_author?(person) person.username == helper.mr_author 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, helper.mr_labels) # rubocop:disable GitlabSecurity/PublicSend end end # Known issue: If someone is rejected due to OOO, and then becomes not OOO, the # selection will change on next spin. # # @param [Array] people # # @return [Gitlab::Dangerfiles::Teammate] 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 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(helper.mr_source_branch) weighted_reviewers = Gitlab::Dangerfiles::Weightage::Reviewers.new(reviewers, traintainers).execute weighted_maintainers = Gitlab::Dangerfiles::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 # Fetches the given +url+ and parse its response as JSON. # # @param [String] url # # @return [Hash, Array] def 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 # Looks up the current list of GitLab team members and parses it into a # useful form. # # @return [Array] def company_members @company_members ||= begin data = http_get_json(ROULETTE_DATA_URL) data.map { |hash| Gitlab::Dangerfiles::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] def project_team(project_name) company_members.select { |member| member.in_project?(project_name) } rescue => err warn("Reviewer roulette failed to load team data: #{err.message}") [] end end end