require 'active_support/all' require 'active_support/inflector' require_relative 'expand_condition' require_relative 'filters/merge_request_date_conditions_filter' require_relative 'filters/votes_conditions_filter' require_relative 'filters/no_additional_labels_conditions_filter' require_relative 'filters/author_member_conditions_filter' require_relative 'filters/assignee_member_conditions_filter' require_relative 'filters/discussions_conditions_filter' require_relative 'filters/ruby_conditions_filter' require_relative 'limiters/date_field_limiter' require_relative 'action' require_relative 'policies/rule_policy' require_relative 'policies/summary_policy' require_relative 'policies_resources/rule_resources' require_relative 'policies_resources/summary_resources' require_relative 'api_query_builders/date_query_param_builder' require_relative 'api_query_builders/single_query_param_builder' require_relative 'api_query_builders/multi_query_param_builder' require_relative 'url_builders/url_builder' require_relative 'network' require_relative 'graphql_network' require_relative 'network_adapters/httparty_adapter' require_relative 'network_adapters/graphql_adapter' require_relative 'graphql_queries/query_builder' require_relative 'ui' module Gitlab module Triage class Engine attr_reader :per_page, :policies, :options DEFAULT_NETWORK_ADAPTER = Gitlab::Triage::NetworkAdapters::HttpartyAdapter DEFAULT_GRAPHQL_ADAPTER = Gitlab::Triage::NetworkAdapters::GraphqlAdapter ALLOWED_STATE_VALUES = { issues: %w[opened closed], merge_requests: %w[opened closed merged] }.with_indifferent_access.freeze EpicsTriagingForProjectImpossibleError = Class.new(StandardError) def initialize(policies:, options:, network_adapter_class: DEFAULT_NETWORK_ADAPTER, graphql_network_adapter_class: DEFAULT_GRAPHQL_ADAPTER) options.host_url = policies.delete(:host_url) { options.host_url } options.api_version = policies.delete(:api_version) { 'v4' } options.dry_run = ENV['TEST'] == 'true' if options.dry_run.nil? @per_page = policies.delete(:per_page) { 100 } @policies = policies @options = options @network_adapter_class = network_adapter_class @graphql_network_adapter_class = graphql_network_adapter_class assert_all! assert_project_id! assert_token! require_ruby_files end def perform puts "Performing a dry run.\n\n" if options.dry_run puts Gitlab::Triage::UI.header("Triaging the `#{options.source_id}` #{options.source.to_s.singularize}", char: '=') puts resource_rules.each do |resource_type, resource| if resource_type == 'epics' && options.source != :groups raise(EpicsTriagingForProjectImpossibleError, "Epics can only be triaged at the group level. Please set the `--source groups` option.") end puts Gitlab::Triage::UI.header("Processing rules for #{resource_type}", char: '-') puts process_summaries(resource_type, resource[:summaries]) process_rules(resource_type, resource[:rules]) end end def network @network ||= Network.new(network_adapter) end def graphql_network @graphql_network ||= GraphqlNetwork.new(graphql_network_adapter) end private def assert_project_id! return if options.source_id return if options.all raise ArgumentError, 'A project_id is needed (pass it with the `--source-id` option)!' end def assert_token! return if options.token raise ArgumentError, 'A token is needed (pass it with the `--token` option)!' end def assert_all! raise ArgumentError, '--all-projects option cannot be used in conjunction with --source and --source-id option!' if options.all && (options.source || options.source_id) end def require_ruby_files options.require_files.each(&method(:require)) end def resource_rules @resource_rules ||= policies.delete(:resource_rules) { {} } end def network_adapter @network_adapter ||= @network_adapter_class.new(options) end def graphql_network_adapter @graphql_network_adapter ||= @graphql_network_adapter_class.new(options) end def rule_conditions(rule) rule.fetch(:conditions) { {} } end def rule_limits(rule) rule.fetch(:limits) { {} } end def process_summaries(resource_type, summaries) return if summaries.blank? summaries.each do |summary| process_summary(resource_type, summary) end end def process_rules(resource_type, rules) return if rules.blank? rules.each do |rule| resources_for_rule(resource_type, rule) do |resources| policy = Policies::RulePolicy.new( resource_type, rule, resources, network) process_action(policy) end end end def process_summary(resource_type, summary) puts Gitlab::Triage::UI.header("Processing summary: **#{summary[:name]}**", char: '~') puts summary_parts_for_rules(resource_type, summary[:rules]) do |resources| policy = Policies::SummaryPolicy.new( resource_type, summary, resources, network) process_action(policy) end end def summary_parts_for_rules(resource_type, rules) # { summary_rule => resources } parts = rules.inject({}) do |result, rule| resources_and_conditions = to_enum(:resources_for_rule, resource_type, rule) resources_and_conditions .inject(result) do |result, (resources, conditions)| # { expanded_summary_rule => resources } result.merge(rule.merge(conditions: conditions) => resources) end end yield(PoliciesResources::SummaryResources.new(parts)) end def resources_for_rule(resource_type, rule) puts Gitlab::Triage::UI.header("Gathering resources for rule: **#{rule[:name]}**", char: '-') ExpandCondition.perform(rule_conditions(rule)) do |conditions| # retrieving the resources for every rule is inefficient # however, previous rules may affect those upcoming resources = [] if rule[:api] == 'graphql' graphql_query = build_graphql_query(resource_type, conditions, true) resources = graphql_network.query(graphql_query, source: source_full_path) else resources = network.query_api(build_get_url(resource_type, conditions)) iids = resources.pluck('iid').map(&:to_s) graphql_query = build_graphql_query(resource_type, conditions) graphql_resources = graphql_network.query(graphql_query, source: source_full_path, iids: iids) if graphql_query.any? decorate_resources_with_graphql_data(resources, graphql_resources) end # In some filters/actions we want to know which resource type it is attach_resource_type(resources, resource_type) puts "\n\n* Found #{resources.count} resources..." print "* Filtering resources..." resources = filter_resources(resources, conditions) puts "\n* Total after filtering: #{resources.count} resources" print "* Limiting resources..." resources = limit_resources(resources, rule_limits(rule)) puts "\n* Total after limiting: #{resources.count} resources" puts yield(PoliciesResources::RuleResources.new(resources), conditions) end end def attach_resource_type(resources, resource_type) resources.each { |resource| resource[:type] = resource_type } # TODO: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # We should not overwrite the attribute here, but we need to # fix it first. We should instead use something like # gitlab_triage_resource_type so it won't conflict with the # existing fields. # And we need to retain the backward compatibility that using # {{type}} will give us this value, rather than from the REST API, # which will give us ISSUE from: # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59648 end def decorate_resources_with_graphql_data(resources, graphql_resources) return if graphql_resources.nil? graphql_resources_by_id = graphql_resources.to_h { |resource| [resource[:id], resource] } resources.each { |resource| resource.merge!(graphql_resources_by_id[resource[:id]].to_h) } end def process_action(policy) Action.process( policy: policy, network: network, dry: options.dry_run) puts end def filter_resources(resources, conditions) resources.select do |resource| results = [] # rubocop:disable Style/IfUnlessModifier if conditions[:date] results << Filters::MergeRequestDateConditionsFilter.new(resource, conditions[:date]).calculate end if conditions[:upvotes] results << Filters::VotesConditionsFilter.new(resource, conditions[:upvotes]).calculate end if conditions[:no_additional_labels] results << Filters::NoAdditionalLabelsConditionsFilter.new(resource, conditions.fetch(:labels) { [] }).calculate end if conditions[:author_member] results << Filters::AuthorMemberConditionsFilter.new(resource, conditions[:author_member], network).calculate end if conditions[:assignee_member] results << Filters::AssigneeMemberConditionsFilter.new(resource, conditions[:assignee_member], network).calculate end if conditions[:discussions] results << Filters::DiscussionsConditionsFilter.new(resource, conditions[:discussions]).calculate end if conditions[:ruby] results << Filters::RubyConditionsFilter.new(resource, conditions, network).calculate end # rubocop:enable Style/IfUnlessModifier results.all? end end def limit_resources(resources, limits) if limits.empty? resources else Limiters::DateFieldLimiter.new(resources, limits).limit end end def build_get_url(resource_type, conditions) # Example issues query with state and labels # https://gitlab.com/api/v4/projects/test-triage%2Fissue-project/issues?state=open&labels=project%20label%20with%20spaces,group_label_no_spaces params = { per_page: per_page } condition_builders = [] condition_builders << APIQueryBuilders::MultiQueryParamBuilder.new('labels', conditions[:labels], ',') if conditions[:labels] if conditions[:forbidden_labels] condition_builders << APIQueryBuilders::MultiQueryParamBuilder.new('not[labels]', conditions[:forbidden_labels], ',') end if conditions[:state] condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new( 'state', conditions[:state], allowed_values: ALLOWED_STATE_VALUES[resource_type]) end condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('milestone', Array(conditions[:milestone])[0]) if conditions[:milestone] condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('source_branch', conditions[:source_branch]) if conditions[:source_branch] condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('target_branch', conditions[:target_branch]) if conditions[:target_branch] if conditions[:date] && APIQueryBuilders::DateQueryParamBuilder.applicable?(conditions[:date]) condition_builders << APIQueryBuilders::DateQueryParamBuilder.new(conditions.delete(:date)) end if conditions[:weight] && resource_type.to_sym == :issues condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('weight', conditions[:weight]) end condition_builders.each do |condition_builder| params[condition_builder.param_name] = condition_builder.param_content end UrlBuilders::UrlBuilder.new( network_options: options, all: options.all, source: options.source, source_id: options.source_id, resource_type: resource_type, params: params ).build end def build_graphql_query(resource_type, conditions, graphql_only = false) Gitlab::Triage::GraphqlQueries::QueryBuilder .new(options.source, resource_type, conditions, graphql_only: graphql_only) end def source_full_path @source_full_path ||= fetch_source_full_path end def fetch_source_full_path return options.source_id unless /\A\d+\z/.match?(options.source_id) source_details = network.query_api(build_get_url(nil, {})).first full_path = source_details['full_path'] || source_details['path_with_namespace'] raise ArgumentError, 'A source with given source_id was not found!' if full_path.blank? full_path end end end end