# 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) Approval = Struct.new(:category, :spin) do def self.from_approval_rule(rule, maintainer) category = rule["section"].to_sym spin = Spin.new(category, nil, maintainer, :reviewer) new(category, spin) end end # Finds the +Gitlab::Dangerfiles::Teammate+ object whose username matches the MR author username. # # @return [Gitlab::Dangerfiles::Teammate] def team_mr_author @team_mr_author ||= find_member(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 = nil, categories = [nil], timezone_experiment: false) project = (project || helper.config.project_name).downcase categories = categories.map { |category| category&.downcase } categories.reject! { |category| integrations_reject_category?(category, project) } 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 when :ux spin.optional_role = :maintainer end end spins end def required_approvals approval_rules = helper.mr_approval_state["rules"] return [] unless approval_rules required_approval_rules = unique_approval_rules(approval_rules) required_approval_rules.filter_map do |rule| rule["rule_type"] == "code_owner" && rule["approvals_required"] > 0 && Approval.from_approval_rule(rule, spin_for_approver(rule)) end end private # Returns an array containing all unique approval rules, based on on the section and eligible_approvers of the rules # # @param [Array] approval rules # @return [Array] def unique_approval_rules(approval_rules) approval_rules.uniq do |rule| section = rule["section"] approvers = rule["eligible_approvers"].map do |approver| approver["username"] end [section, approvers] end end # @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 # @param [String] category name # @return [Boolean] def integrations_reject_category?(category, project) # Reject integrations categories if the MR author has reviewing abilities for the category. team_mr_author&.integrations_be?(project, category, helper.mr_labels) || team_mr_author&.integrations_fe?(project, category, helper.mr_labels) end def random @random ||= Random.new(Digest::MD5.hexdigest(helper.mr_source_branch).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, 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 # Spin a reviewer for a particular approval rule # # @param [Hash] rule of approval # # @return [Gitlab::Dangerfiles::Teammate] def spin_for_approver(rule) # This will filter out approvers who are not even reviewers who # don't show up in roulette data we're relying on. # That's why `filter_map` is used. approvers = rule["eligible_approvers"].filter_map do |approver| find_member(approver["username"]) end spin_for_person(approvers) || spin_for_approver_fallback(rule) end # It can be possible that we don't have a valid reviewer for approval. # In this case, we sample again without considering: # # * If they're available # * If they're an actual reviewer from roulette data # # We do this because we strictly require an approval from the approvers. # # @param [Hash] rule of approval # # @return [Gitlab::Dangerfiles::Teammate] def spin_for_approver_fallback(rule) fallback_approvers = rule["eligible_approvers"].map do |approver| find_member(approver["username"]) || Gitlab::Dangerfiles::Teammate.new(approver) end # Intentionally not using `spin_for_person` to skip `valid_person?`. # This should strictly return someone so we don't filter anything, # and it's a fallback mechanism which should not happen often that # deserves a complex algorithm. fallback_approvers.sample(random: random) 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 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, timezone_experiment: timezone_experiment) maintainer = spin_for_person(weighted_maintainers, 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)) if rsp.is_a?(Net::HTTPRedirection) raise "Redirection detected: #{rsp.header["location"]}" end 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 def find_member(username) company_members.find { |person| person.username == username } 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 do |member| member.in_project?(project_name) || member.in_project?("gitlab") # Used for universal reviewer end rescue => err warn("Reviewer roulette failed to load team data: #{err.message}") [] end end end