Class: Gitlab::Triage::Engine

Inherits:
Object
  • Object
show all
Defined in:
lib/gitlab/triage/engine.rb

Constant Summary collapse

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
MILESTONE_TIMEBOX_VALUES =
%w[none any upcoming started].freeze
ITERATION_SELECTION_VALUES =
%w[none any].freeze
EpicsTriagingForProjectImpossibleError =
Class.new(StandardError)

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(policies:, options:, network_adapter_class: DEFAULT_NETWORK_ADAPTER, graphql_network_adapter_class: DEFAULT_GRAPHQL_ADAPTER) ⇒ Engine

Returns a new instance of Engine.



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/gitlab/triage/engine.rb', line 47

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!
  require_ruby_files
end

Instance Attribute Details

#optionsObject (readonly)

Returns the value of attribute options.



35
36
37
# File 'lib/gitlab/triage/engine.rb', line 35

def options
  @options
end

#per_pageObject (readonly)

Returns the value of attribute per_page.



35
36
37
# File 'lib/gitlab/triage/engine.rb', line 35

def per_page
  @per_page
end

#policiesObject (readonly)

Returns the value of attribute policies.



35
36
37
# File 'lib/gitlab/triage/engine.rb', line 35

def policies
  @policies
end

Instance Method Details

#assert_all!Object (private)

Raises:

  • (ArgumentError)


103
104
105
106
# File 'lib/gitlab/triage/engine.rb', line 103

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

#assert_project_id!Object (private)

Raises:

  • (ArgumentError)


96
97
98
99
100
101
# File 'lib/gitlab/triage/engine.rb', line 96

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

#attach_resource_type(resources, resource_type) ⇒ Object (private)



312
313
314
# File 'lib/gitlab/triage/engine.rb', line 312

def attach_resource_type(resources, resource_type)
  resources.each { |resource| resource[:type] = resource_type }
end

#branches_resource_query(conditions) ⇒ Object (private)



483
484
485
486
487
# File 'lib/gitlab/triage/engine.rb', line 483

def branches_resource_query(conditions)
  [].tap do |condition_builders|
    condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('search', conditions[:name]) if conditions[:name]
  end
end

#build_get_url(resource_type, conditions) ⇒ Object (private)



387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/gitlab/triage/engine.rb', line 387

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 << milestone_condition_builder(resource_type, conditions[:milestone]) if conditions[:milestone]

  if conditions[:date] && APIQueryBuilders::DateQueryParamBuilder.applicable?(conditions[:date]) && resource_type&.to_sym != :branches
    condition_builders << APIQueryBuilders::DateQueryParamBuilder.new(conditions.delete(:date))
  end

  case resource_type&.to_sym
  when :issues
    condition_builders.concat(issues_resource_query(conditions))
  when :merge_requests
    condition_builders.concat(merge_requests_resource_query(conditions))
  when :branches
    condition_builders.concat(branches_resource_query(conditions))
  end

  condition_builders.compact.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

#build_graphql_query(resource_type, conditions, graphql_only = false) ⇒ Object (private)



504
505
506
507
# File 'lib/gitlab/triage/engine.rb', line 504

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

#decorate_resources_with_graphql_data(resources, graphql_resources) ⇒ Object (private)



316
317
318
319
320
321
# File 'lib/gitlab/triage/engine.rb', line 316

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

#draft_condition_builder(draft_condittion) ⇒ Object (private)



489
490
491
492
493
494
495
496
497
498
499
500
501
502
# File 'lib/gitlab/triage/engine.rb', line 489

def draft_condition_builder(draft_condittion)
  # Issues API only accepts 'yes' and 'no' as strings: https://docs.gitlab.com/ee/api/merge_requests.html
  wip =
    case draft_condittion
    when true
      'yes'
    when false
      'no'
    else
      raise ArgumentError, 'The "draft" condition only accepts true or false.'
    end

  APIQueryBuilders::SingleQueryParamBuilder.new('wip', wip)
end

#fetch_source_full_pathObject (private)

Raises:

  • (ArgumentError)


513
514
515
516
517
518
519
520
521
522
# File 'lib/gitlab/triage/engine.rb', line 513

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

#filter_resources(resources, conditions) ⇒ Object (private)

rubocop:disable Metrics/CyclomaticComplexity



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
# File 'lib/gitlab/triage/engine.rb', line 331

def filter_resources(resources, conditions) # rubocop:disable Metrics/CyclomaticComplexity
  resources.select do |resource|
    results = []

    # rubocop:disable Style/IfUnlessModifier
    if conditions[:date]
      case resource[:type]
      when 'branches'
        results << Filters::BranchDateFilter.new(resource, conditions[:date]).calculate
      when 'merge_requests'
        results << Filters::MergeRequestDateConditionsFilter.new(resource, conditions[:date]).calculate
      end
    end

    if resource[:type] == 'branches'
      results << Filters::BranchProtectedFilter.new(resource, conditions[:protected]).calculate
    end

    votes_condition = conditions[:votes] || conditions[:upvotes]
    if votes_condition
      results << Filters::VotesConditionsFilter.new(resource, votes_condition).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

#graphql_networkObject



90
91
92
# File 'lib/gitlab/triage/engine.rb', line 90

def graphql_network
  @graphql_network ||= GraphqlNetwork.new(graphql_network_adapter)
end

#graphql_network_adapterObject (private)



120
121
122
# File 'lib/gitlab/triage/engine.rb', line 120

def graphql_network_adapter
  @graphql_network_adapter ||= @graphql_network_adapter_class.new(options)
end

#issues_resource_query(conditions) ⇒ Object (private)



463
464
465
466
467
468
# File 'lib/gitlab/triage/engine.rb', line 463

def issues_resource_query(conditions)
  [].tap do |condition_builders|
    condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('weight', conditions[:weight]) if conditions[:weight]
    condition_builders << iteration_condition_builder(conditions[:iteration]) if conditions[:iteration]
  end
end

#iteration_condition_builder(iteration_value) ⇒ Object (private)



452
453
454
455
456
457
458
459
460
461
# File 'lib/gitlab/triage/engine.rb', line 452

def iteration_condition_builder(iteration_value)
  # Issues API should use the `iteration_id` param for timebox values, and `iteration_title` for iteration title
  args =
    if ITERATION_SELECTION_VALUES.include?(iteration_value.downcase)
      ['iteration_id', iteration_value.titleize] # The API only accepts titleized values.
    else
      ['iteration_title', iteration_value]
    end
  APIQueryBuilders::SingleQueryParamBuilder.new(*args)
end

#limit_resources(resources, limits) ⇒ Object (private)



379
380
381
382
383
384
385
# File 'lib/gitlab/triage/engine.rb', line 379

def limit_resources(resources, limits)
  if limits.empty?
    resources
  else
    Limiters::DateFieldLimiter.new(resources, limits).limit
  end
end

#merge_requests_resource_query(conditions) ⇒ Object (private)



470
471
472
473
474
475
476
477
478
479
480
481
# File 'lib/gitlab/triage/engine.rb', line 470

def merge_requests_resource_query(conditions)
  [].tap do |condition_builders|
    [
      :source_branch,
      :target_branch,
      :reviewer_id
    ].each do |key|
      condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new(key.to_s, conditions[key]) if conditions[key]
    end
    condition_builders << draft_condition_builder(conditions[:draft]) if conditions.key?(:draft)
  end
end

#milestone_condition_builder(resource_type, milestone_condition) ⇒ Object (private)



437
438
439
440
441
442
443
444
445
446
447
448
449
450
# File 'lib/gitlab/triage/engine.rb', line 437

def milestone_condition_builder(resource_type, milestone_condition)
  milestone_value = Array(milestone_condition)[0].to_s # back-compatibility
  return if milestone_value.empty?

  # Issues API should use the `milestone_id` param for timebox values, and `milestone` for milestone title
  args =
    if resource_type.to_sym == :issues && MILESTONE_TIMEBOX_VALUES.include?(milestone_value.downcase)
      ['milestone_id', milestone_value.titleize] # The API only accepts titleized values.
    else
      ['milestone', milestone_value]
    end

  APIQueryBuilders::SingleQueryParamBuilder.new(*args)
end

#networkObject



82
83
84
# File 'lib/gitlab/triage/engine.rb', line 82

def network
  @network ||= Network.new(restapi: restapi_network, graphql: graphql_network)
end

#network_adapterObject (private)



116
117
118
# File 'lib/gitlab/triage/engine.rb', line 116

def network_adapter
  @network_adapter ||= @network_adapter_class.new(options)
end

#performObject



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/gitlab/triage/engine.rb', line 63

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, policy_definition|
    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 summaries & rules for #{resource_type}", char: '-')
    puts

    process_summaries(resource_type, policy_definition[:summaries])
    process_rules(resource_type, policy_definition[:rules])
  end
end

#process_action(policy) ⇒ Object (private)



323
324
325
326
327
328
329
# File 'lib/gitlab/triage/engine.rb', line 323

def process_action(policy)
  Action.process(
    policy: policy,
    network: network,
    dry: options.dry_run)
  puts
end

#process_rules(resource_type, rule_definitions) ⇒ nil (private)

Process an array of rule_definitions.

Examples:

Example of an array of rule definitions.


[{ name: "New issues", conditions: { state: opened }, limits: { most_recent: 2 }, actions: { labels: ["needs attention"] } }]

Parameters:

  • rule_definitions (Array<Hash>)

    An array usually given as YAML in a triage policy file.

Returns:

  • (nil)


178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/gitlab/triage/engine.rb', line 178

def process_rules(resource_type, rule_definitions)
  return if rule_definitions.blank?

  rule_definitions.each do |rule_definition|
    resources_for_rule(resource_type, rule_definition) do |resources|
      policy = Policies::RulePolicy.new(
        resource_type, rule_definition, resources, network)

      process_action(policy)
    end
  end
end

#process_summaries(resource_type, summary_definitions) ⇒ nil (private)

Process an array of summary_definitions.

Examples:

Example of an array of summary definitions (shown as YAML for readability).


- name: Newest and oldest issues summary
  rules:
    - name: New issues
      conditions:
        state: opened
      limits:
        most_recent: 2
      actions:
        summarize:
          item: "- [ ] [{{title}}]({{web_url}}) {{labels}}"
          summary: |
            Please triage the following new {{type}}:
            {{items}}
  actions:
    summarize:
      title: "Newest and oldest {{type}} summary"
      summary: |
        Please triage the following {{type}}:
        {{items}}
        Please take care of them before the end of #{7.days.from_now.strftime('%Y-%m-%d')}
        /label ~"needs attention"

Parameters:

  • summary_definitions (Array<Hash>)

    An array usually given as YAML in a triage policy file.

Returns:

  • (nil)


161
162
163
164
165
166
167
# File 'lib/gitlab/triage/engine.rb', line 161

def process_summaries(resource_type, summary_definitions)
  return if summary_definitions.blank?

  summary_definitions.each do |summary_definition|
    process_summary(resource_type, summary_definition)
  end
end

#process_summary(resource_type, summary_definition) ⇒ nil (private)

Process a summary_definition.

Examples:

Example of a summary definition hash (shown as YAML for readability).


name: Newest and oldest issues summary
rules:
  - name: New issues
    conditions:
      state: opened
    limits:
      most_recent: 2
    actions:
      summarize:
        item: "- [ ] [{{title}}]({{web_url}}) {{labels}}"
        summary: |
          Please triage the following new {{type}}:
          {{items}}
actions:
  summarize:
    title: "Newest and oldest {{type}} summary"
    summary: |
      Please triage the following {{type}}:
      {{items}}
      Please take care of them before the end of #{7.days.from_now.strftime('%Y-%m-%d')}
      /label ~"needs attention"

Parameters:

  • resource_type (String)

    The resource type, e.g. issues or merge_requests.

  • summary_definition (Hash)

    A hash usually given as YAML in a triage policy file:

Returns:

  • (nil)


221
222
223
224
225
226
227
228
229
230
231
# File 'lib/gitlab/triage/engine.rb', line 221

def process_summary(resource_type, summary_definition)
  puts Gitlab::Triage::UI.header("Processing summary: **#{summary_definition[:name]}**", char: '~')
  puts

  summary_parts_for_rules(resource_type, summary_definition[:rules]) do |summary_resources|
    policy = Policies::SummaryPolicy.new(
      resource_type, summary_definition, summary_resources, network)

    process_action(policy)
  end
end

#require_ruby_filesObject (private)



108
109
110
# File 'lib/gitlab/triage/engine.rb', line 108

def require_ruby_files
  options.require_files.each(&method(:require))
end

#resource_rulesObject (private)



112
113
114
# File 'lib/gitlab/triage/engine.rb', line 112

def resource_rules
  @resource_rules ||= policies.delete(:resource_rules) { {} }
end

#resources_for_rule(resource_type, rule_definition) {|rule_resources, expanded_conditions| ... } ⇒ nil (private)

Transform a non-expanded rule_definition into a PoliciesResources::RuleResources.new(resources) object.

Examples:

Example of a rule definition hash.


{ name: "New issues", conditions: { state: opened }, limits: { most_recent: 2 }, actions: { labels: ["needs attention"] } }

Parameters:

  • resource_type (String)

    The resource type, e.g. issues or merge_requests.

  • rule_definition (Hash)

    A rule definition, e.g. { name: ‘Foo’, conditions: { milestone: ‘v1’ } }.

Yield Parameters:

  • rule_resources (PoliciesResources::RuleResources)

    An object which contains an array of resources.

  • expanded_conditions (Hash)

    A hash of expanded conditions.

Yield Returns:

  • (nil)

Returns:

  • (nil)


275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/gitlab/triage/engine.rb', line 275

def resources_for_rule(resource_type, rule_definition)
  puts Gitlab::Triage::UI.header("Gathering resources for rule: **#{rule_definition[:name]}**", char: '-')

  ExpandCondition.perform(rule_conditions(rule_definition)) do |expanded_conditions|
    # retrieving the resources for every rule is inefficient
    # however, previous rules may affect those upcoming
    resources = []

    if rule_definition[:api] == 'graphql'
      graphql_query = build_graphql_query(resource_type, expanded_conditions, true)
      resources = graphql_network.query(graphql_query, source: source_full_path)
    else
      resources = network.query_api(build_get_url(resource_type, expanded_conditions))
      iids = resources.pluck('iid').map(&:to_s)

      graphql_query = build_graphql_query(resource_type, expanded_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, expanded_conditions)
    puts "\n* Total after filtering: #{resources.count} resources"
    print "* Limiting resources..."
    resources = limit_resources(resources, rule_limits(rule_definition))
    puts "\n* Total after limiting: #{resources.count} resources"
    puts

    yield(PoliciesResources::RuleResources.new(resources), expanded_conditions)
  end
end

#restapi_networkObject



86
87
88
# File 'lib/gitlab/triage/engine.rb', line 86

def restapi_network
  @restapi_network ||= RestAPINetwork.new(network_adapter)
end

#rule_conditions(rule) ⇒ Object (private)



124
125
126
# File 'lib/gitlab/triage/engine.rb', line 124

def rule_conditions(rule)
  rule.fetch(:conditions) { {} }
end

#rule_limits(rule) ⇒ Object (private)



128
129
130
# File 'lib/gitlab/triage/engine.rb', line 128

def rule_limits(rule)
  rule.fetch(:limits) { {} }
end

#source_full_pathObject (private)



509
510
511
# File 'lib/gitlab/triage/engine.rb', line 509

def source_full_path
  @source_full_path ||= fetch_source_full_path
end

#summary_parts_for_rules(resource_type, rule_definitions) {|summary_resources| ... } ⇒ nil (private)

Transform an array of rule_definitions into a PoliciesResources::SummaryResources.new(rule => rule_resources) object.

Examples:

Example of an array of rule definitions.


[{ name: "New issues", conditions: { state: opened }, limits: { most_recent: 2 }, actions: { labels: ["needs attention"] } }]

Parameters:

  • resource_type (String)

    The resource type, e.g. issues or merge_requests.

  • rule_definitions (Array<Hash>)

    An array of rule definitions, e.g. [{ name: ‘Foo’, conditions: { milestone: ‘v1’ } }, { name: ‘Foo’, conditions: { state: ‘opened’ } }].

Yield Parameters:

Yield Returns:

  • (nil)

Returns:

  • (nil)


247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/gitlab/triage/engine.rb', line 247

def summary_parts_for_rules(resource_type, rule_definitions)
  # { summary_rule => resources }
  parts = rule_definitions.each_with_object({}) do |rule_definition, result|
    to_enum(:resources_for_rule, resource_type, rule_definition).each do |rule_resources, expanded_conditions|
      # We replace the non-expanded rule conditions with the expanded ones
      result.merge!(rule_definition.merge(conditions: expanded_conditions) => rule_resources)
    end

    result
  end

  yield(PoliciesResources::SummaryResources.new(parts))
end