# 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 = if rule["section"] == "codeowners" "`#{rule["name"]}`" else rule["section"] end.to_sym spin = Spin.new(category, nil, maintainer, :reviewer) new(category, spin) end end def prepare_categories(changes_keys) categories = Set.new(changes_keys) # Ensure to spin for database reviewer/maintainer when ~database is applied (e.g. to review SQL queries) categories << :database if labels.include?("database") # Ensure to spin for Analytics Instrumentation reviewer when ~"analytics instrumentation::review pending" is applied categories << :analytics_instrumentation if labels.include?("analytics instrumentation::review pending") # Skip Analytics Instrumentation reviews for growth experiment MRs categories.delete(:analytics_instrumentation) if labels.include?("growth experiment") prepare_ux_category!(categories) if labels.include?("UX") # Remove disabled categories categories.subtract(helper.config.disabled_roulette_categories) categories end def assign_pedroms_for_ux_wider_community_contribution(spins) # We want at least a UX reviewer who can review any wider community # contribution even without a team designer. We assign this to Pedro. ux_spin = look_for_fallback_designer_for_ux_wider_community_contribution?(spins) ux_spin && ux_spin.reviewer = teammate_pedroms 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] # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity def spin(project = nil, categories = [:none], timezone_experiment: false) project = (project || config_project_name).downcase categories = categories.map { |category| category&.downcase || :none } categories.reject! { |category| import_and_integrate_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 && team_mr_author.capabilities(project).any? { |capability| capability.end_with?("qa") }) 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 :analytics_instrumentation 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 :import_integrate_be, :import_integrate_fe spin.optional_role = :maintainer when :ux spin.optional_role = :maintainer end end spins end # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 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| spin_for_approval_rule?(rule) && Approval.from_approval_rule(rule, spin_for_approver(rule)) end end def warnings @warnings ||= [] end def teammate_pedroms @teammate_pedroms ||= find_member("pedroms") end private def spin_for_approval_rule?(rule) rule["rule_type"] == "code_owner" && rule["approvals_required"] > 0 && # Exclude generic codeowners rule, which should be covered by others already !generic_codeowners_rule?(rule) end def generic_codeowners_rule?(rule) rule["section"] == "codeowners" && rule["name"] == "*" end # 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 import_and_integrate_reject_category?(category, project) # Reject Import and Integrate categories if the MR author has reviewing abilities for the category. team_mr_author&.import_integrate_be?(project, category, labels) || team_mr_author&.import_integrate_fe?(project, category, 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, labels) 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) approvers = rule["eligible_approvers"].filter_map do |approver| find_member(approver["username"], project: config_project_name.downcase) 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 def prepare_ux_category!(categories) # Ensure to spin for UX reviewer when ~UX is applied (e.g. to review changes to the UI) if labels.include?("Community contribution") categories << :ux else begin # We only want to spin a reviewer for merge requests which has a # designer for the team. There's no easy way to tell this, so we # pretend this is a community contribution, in which case we only # pick the team designer. If there's no one got picked, it means # there's no designer for this team. labels << "Community contribution" ux_spin = spin(nil, [:ux]).first categories << :ux if ux_spin.reviewer || ux_spin.maintainer ensure # Make sure we delete the label afterward labels.delete("Community contribution") end end end def look_for_fallback_designer_for_ux_wider_community_contribution?(spins) labels.include?("Community contribution") && spins.find do |spin| spin.category == :ux && spin.reviewer.nil? && spin.maintainer.nil? end end # Fetches the given +url+ and parse its response as JSON. # # @param [String] url # # @return [Hash, Array, NilClass] def http_get_json(url) rsp = Net::HTTP.get_response(URI.parse(url)) if rsp.is_a?(Net::HTTPRedirection) if (uri = URI.parse(rsp.header["location"])) uri.query = nil end warnings << "Redirection detected: #{uri}." return nil end unless rsp.is_a?(Net::HTTPOK) message = rsp.message[0, 30] warnings << "HTTPError: Failed to read #{url}: #{rsp.code} #{message}." return nil 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 warnings << "Failed to parse JSON response from #{ROULETTE_DATA_URL}" [] end end def find_member(username, project: nil) company_members.find do |member| member.username == username && if project member.in_project?(project) else true end end end # Return the configured project name # # @return [String] def config_project_name helper.config.project_name end # Return the labels from the merge requests. This is cached. # # @return [String] def labels @labels ||= helper.mr_labels 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) end rescue => err warn("Reviewer roulette failed to load team data: #{err.message}") [] end end end