gitlab-triage 1.14.1 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.gitlab/merge_request_templates/Release.md +1 -1
  3. data/README.md +75 -4
  4. data/bin/gitlab-triage +5 -2
  5. data/lib/gitlab/triage/action.rb +8 -4
  6. data/lib/gitlab/triage/action/comment.rb +7 -4
  7. data/lib/gitlab/triage/action/comment_on_summary.rb +83 -0
  8. data/lib/gitlab/triage/action/summarize.rb +7 -1
  9. data/lib/gitlab/triage/api_query_builders/base_query_param_builder.rb +11 -2
  10. data/lib/gitlab/triage/api_query_builders/date_query_param_builder.rb +13 -50
  11. data/lib/gitlab/triage/api_query_builders/multi_query_param_builder.rb +10 -2
  12. data/lib/gitlab/triage/engine.rb +52 -11
  13. data/lib/gitlab/triage/errors/network.rb +2 -0
  14. data/lib/gitlab/triage/filters/discussions_conditions_filter.rb +1 -3
  15. data/lib/gitlab/triage/graphql_network.rb +28 -1
  16. data/lib/gitlab/triage/graphql_queries/query_builder.rb +72 -16
  17. data/lib/gitlab/triage/graphql_queries/query_param_builders/base_param_builder.rb +25 -0
  18. data/lib/gitlab/triage/graphql_queries/query_param_builders/date_param_builder.rb +35 -0
  19. data/lib/gitlab/triage/graphql_queries/query_param_builders/labels_param_builder.rb +18 -0
  20. data/lib/gitlab/triage/network.rb +9 -3
  21. data/lib/gitlab/triage/network_adapters/graphql_adapter.rb +33 -10
  22. data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +10 -0
  23. data/lib/gitlab/triage/option_parser.rb +2 -0
  24. data/lib/gitlab/triage/param_builders/date_param_builder.rb +58 -0
  25. data/lib/gitlab/triage/policies/base_policy.rb +30 -1
  26. data/lib/gitlab/triage/resource/context.rb +1 -0
  27. data/lib/gitlab/triage/resource/epic.rb +24 -0
  28. data/lib/gitlab/triage/retryable.rb +7 -5
  29. data/lib/gitlab/triage/utils.rb +13 -0
  30. data/lib/gitlab/triage/validators/params_validator.rb +1 -1
  31. data/lib/gitlab/triage/version.rb +1 -1
  32. metadata +10 -5
  33. data/lib/gitlab/triage/graphql_queries/threads_query.rb +0 -31
  34. data/lib/gitlab/triage/graphql_queries/user_notes_query.rb +0 -23
@@ -33,6 +33,10 @@ module Gitlab
33
33
 
34
34
  DEFAULT_NETWORK_ADAPTER = Gitlab::Triage::NetworkAdapters::HttpartyAdapter
35
35
  DEFAULT_GRAPHQL_ADAPTER = Gitlab::Triage::NetworkAdapters::GraphqlAdapter
36
+ ALLOWED_STATE_VALUES = {
37
+ issues: %w[opened closed],
38
+ merge_requests: %w[opened closed merged]
39
+ }.with_indifferent_access.freeze
36
40
 
37
41
  def initialize(policies:, options:, network_adapter_class: DEFAULT_NETWORK_ADAPTER, graphql_network_adapter_class: DEFAULT_GRAPHQL_ADAPTER)
38
42
  options.host_url = policies.delete(:host_url) { options.host_url }
@@ -173,14 +177,23 @@ module Gitlab
173
177
  ExpandCondition.perform(rule_conditions(rule)) do |conditions|
174
178
  # retrieving the resources for every rule is inefficient
175
179
  # however, previous rules may affect those upcoming
176
- resources = network.query_api(build_get_url(resource_type, conditions))
177
- iids = resources.pluck('iid').map(&:to_s)
180
+ resources = []
181
+
182
+ if rule[:api] == 'graphql'
183
+ graphql_query = build_graphql_query(resource_type, conditions, true)
184
+ resources = graphql_network.query(graphql_query, source: source_full_path)
185
+ else
186
+ resources = network.query_api(build_get_url(resource_type, conditions))
187
+ iids = resources.pluck('iid').map(&:to_s)
188
+
189
+ graphql_query = build_graphql_query(resource_type, conditions)
190
+ graphql_resources = graphql_network.query(graphql_query, source: source_full_path, iids: iids) if graphql_query.any?
191
+
192
+ decorate_resources_with_graphql_data(resources, graphql_resources)
193
+ end
178
194
 
179
- graphql_query = build_graphql_query(resource_type, conditions)
180
- graphql_resources = graphql_network.query(graphql_query, source: options.source_id, iids: iids) if graphql_query.present?
181
195
  # In some filters/actions we want to know which resource type it is
182
196
  attach_resource_type(resources, resource_type)
183
- decorate_resources_with_graphql_data(resources, graphql_resources)
184
197
 
185
198
  puts "\n\n* Found #{resources.count} resources..."
186
199
  print "* Filtering resources..."
@@ -195,10 +208,17 @@ module Gitlab
195
208
  end
196
209
  end
197
210
 
198
- # We don't have to do this once the response will contain the type
199
- # of the resource. For now let's just attach it.
200
211
  def attach_resource_type(resources, resource_type)
201
- resources.each { |resource| resource[:type] ||= resource_type }
212
+ resources.each { |resource| resource[:type] = resource_type }
213
+ # TODO: ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
214
+ # We should not overwrite the attribute here, but we need to
215
+ # fix it first. We should instead use something like
216
+ # gitlab_triage_resource_type so it won't conflict with the
217
+ # existing fields.
218
+ # And we need to retain the backward compatibility that using
219
+ # {{type}} will give us this value, rather than from the REST API,
220
+ # which will give us ISSUE from:
221
+ # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/59648
202
222
  end
203
223
 
204
224
  def decorate_resources_with_graphql_data(resources, graphql_resources)
@@ -276,7 +296,13 @@ module Gitlab
276
296
  condition_builders << APIQueryBuilders::MultiQueryParamBuilder.new('not[labels]', conditions[:forbidden_labels], ',')
277
297
  end
278
298
 
279
- condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('state', conditions[:state]) if conditions[:state]
299
+ if conditions[:state]
300
+ condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new(
301
+ 'state',
302
+ conditions[:state],
303
+ allowed_values: ALLOWED_STATE_VALUES[resource_type])
304
+ end
305
+
280
306
  condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('milestone', Array(conditions[:milestone])[0]) if conditions[:milestone]
281
307
  condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('source_branch', conditions[:source_branch]) if conditions[:source_branch]
282
308
  condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('target_branch', conditions[:target_branch]) if conditions[:target_branch]
@@ -303,9 +329,24 @@ module Gitlab
303
329
  ).build
304
330
  end
305
331
 
306
- def build_graphql_query(resource_type, conditions)
332
+ def build_graphql_query(resource_type, conditions, graphql_only = false)
307
333
  Gitlab::Triage::GraphqlQueries::QueryBuilder
308
- .new(options.source, resource_type, conditions)
334
+ .new(options.source, resource_type, conditions, graphql_only: graphql_only)
335
+ end
336
+
337
+ def source_full_path
338
+ @source_full_path ||= fetch_source_full_path
339
+ end
340
+
341
+ def fetch_source_full_path
342
+ return options.source_id unless /\A\d+\z/.match?(options.source_id)
343
+
344
+ source_details = network.query_api(build_get_url(nil, {})).first
345
+ full_path = source_details['full_path'] || source_details['path_with_namespace']
346
+
347
+ raise ArgumentError, 'A source with given source_id was not found!' if full_path.blank?
348
+
349
+ full_path
309
350
  end
310
351
  end
311
352
  end
@@ -3,6 +3,8 @@ module Gitlab
3
3
  module Errors
4
4
  module Network
5
5
  InternalServerError = Class.new(StandardError)
6
+ TooManyRequests = Class.new(StandardError)
7
+ UnexpectedResponse = Class.new(StandardError)
6
8
  end
7
9
  end
8
10
  end
@@ -36,9 +36,7 @@ module Gitlab
36
36
  if @attribute == :notes
37
37
  @resource[:user_notes_count]
38
38
  else
39
- @resource.dig(:discussions, :nodes)&.count do |node|
40
- !node&.dig(:notes, :nodes, 0, :system)
41
- end
39
+ @resource[:user_discussions_count]
42
40
  end
43
41
  end
44
42
 
@@ -11,6 +11,8 @@ module Gitlab
11
11
  class GraphqlNetwork
12
12
  attr_reader :options, :adapter
13
13
 
14
+ MINIMUM_RATE_LIMIT = 25
15
+
14
16
  def initialize(adapter)
15
17
  @adapter = adapter
16
18
  @options = adapter.options
@@ -33,22 +35,47 @@ module Gitlab
33
35
  variables: variables.merge(after: response.delete(:end_cursor))
34
36
  )
35
37
 
38
+ rate_limit_debug(response) if options.debug
39
+ rate_limit_wait(response)
40
+
36
41
  resources.concat(Array.wrap(response.delete(:results)))
37
42
  end while response.delete(:more_pages)
38
43
 
39
44
  resources
40
45
  .map { |resource| resource.deep_transform_keys(&:underscore) }
41
46
  .map(&:with_indifferent_access)
42
- .map { |resource| resource.merge(id: extract_id_from_global_id(resource[:id])) }
47
+ .map { |resource| normalize(resource) }
43
48
  end
44
49
 
45
50
  private
46
51
 
52
+ def normalize(resource)
53
+ resource
54
+ .slice(:iid, :state, :author, :merged_at, :user_notes_count, :user_discussions_count, :upvotes, :downvotes, :project_id, :web_url)
55
+ .merge(
56
+ id: extract_id_from_global_id(resource[:id]),
57
+ labels: [*resource.dig(:labels, :nodes)].pluck(:title),
58
+ assignees: [*resource.dig(:assignees, :nodes)]
59
+ )
60
+ end
61
+
47
62
  def extract_id_from_global_id(global_id)
48
63
  return if global_id.blank?
49
64
 
50
65
  GlobalID.parse(global_id).model_id.to_i
51
66
  end
67
+
68
+ def rate_limit_debug(response)
69
+ rate_limit_infos = "Rate limit remaining: #{response[:ratelimit_remaining]} (reset at #{response[:ratelimit_reset_at]})"
70
+ puts Gitlab::Triage::UI.debug "rate_limit_infos: #{rate_limit_infos}"
71
+ end
72
+
73
+ def rate_limit_wait(response)
74
+ return unless response.delete(:ratelimit_remaining) < MINIMUM_RATE_LIMIT
75
+
76
+ puts Gitlab::Triage::UI.debug "Rate limit almost exceeded, sleeping for #{response[:ratelimit_reset_at] - Time.now} seconds" if options.debug
77
+ sleep(1) until Time.now >= response[:ratelimit_reset_at]
78
+ end
52
79
  end
53
80
  end
54
81
  end
@@ -1,14 +1,16 @@
1
- require_relative 'user_notes_query'
2
- require_relative 'threads_query'
1
+ require_relative 'query_param_builders/base_param_builder'
2
+ require_relative 'query_param_builders/date_param_builder'
3
+ require_relative 'query_param_builders/labels_param_builder'
3
4
 
4
5
  module Gitlab
5
6
  module Triage
6
7
  module GraphqlQueries
7
8
  class QueryBuilder
8
- def initialize(source_type, resource_type, conditions)
9
+ def initialize(source_type, resource_type, conditions, graphql_only: false)
9
10
  @source_type = source_type.to_s.singularize
10
11
  @resource_type = resource_type
11
12
  @conditions = conditions
13
+ @graphql_only = graphql_only
12
14
  end
13
15
 
14
16
  def resource_path
@@ -16,30 +18,84 @@ module Gitlab
16
18
  end
17
19
 
18
20
  def query
19
- return if query_template.nil?
21
+ return if resource_fields.empty?
20
22
 
21
- format(query_template, source_type: source_type, resource_type: resource_type.to_s.camelize(:lower), group_query: group_query)
23
+ format(
24
+ BASE_QUERY,
25
+ source_type: source_type,
26
+ resource_type: resource_type.to_s.camelize(:lower),
27
+ resource_fields: resource_fields.join(' '),
28
+ resource_query: resource_query,
29
+ iids_declaration: graphql_only ? nil : ', $iids: [String!]',
30
+ iids_query: graphql_only ? nil : ', iids: $iids'
31
+ )
22
32
  end
23
33
 
24
- delegate :present?, to: :query_template
34
+ delegate :any?, to: :resource_fields
25
35
 
26
36
  private
27
37
 
28
- attr_reader :source_type, :resource_type, :conditions
38
+ attr_reader :source_type, :resource_type, :conditions, :graphql_only
29
39
 
30
- def query_template
31
- case conditions.dig(:discussions, :attribute).to_s
32
- when 'notes'
33
- UserNotesQuery
34
- when 'threads'
35
- ThreadsQuery
40
+ BASE_QUERY = <<~GRAPHQL.freeze
41
+ query($source: ID!, $after: String%{iids_declaration}) {
42
+ %{source_type}(fullPath: $source) {
43
+ id
44
+ %{resource_type}(after: $after%{iids_query}%{resource_query}) {
45
+ pageInfo {
46
+ hasNextPage
47
+ endCursor
48
+ }
49
+ nodes {
50
+ id iid title updatedAt createdAt webUrl projectId %{resource_fields}
51
+ }
52
+ }
53
+ }
54
+ }
55
+ GRAPHQL
56
+
57
+ def resource_fields
58
+ fields = []
59
+
60
+ fields << 'userNotesCount' if conditions.dig(:discussions, :attribute).to_s == 'notes'
61
+ fields << 'userDiscussionsCount' if conditions.dig(:discussions, :attribute).to_s == 'threads'
62
+
63
+ if graphql_only
64
+ fields << 'labels { nodes { title } }'
65
+ fields << 'author { id name username }'
66
+ fields << 'assignees { nodes { id name username } }' if conditions.key?(:assignee_member)
67
+ fields << 'upvotes' if conditions.dig(:upvotes, :attribute).to_s == 'upvotes'
68
+ fields << 'downvotes' if conditions.dig(:upvotes, :attribute).to_s == 'downvotes'
69
+ fields << 'mergedAt' if resource_type == 'merge_requests'
36
70
  end
71
+
72
+ fields
37
73
  end
38
74
 
39
- def group_query
40
- return if source_type != 'group'
75
+ def resource_query
76
+ condition_queries = []
77
+
78
+ condition_queries << QueryParamBuilders::BaseParamBuilder.new('includeSubgroups', true, with_quotes: false) if source_type == 'group'
79
+
80
+ conditions.each do |condition, condition_params|
81
+ condition_queries << QueryParamBuilders::DateParamBuilder.new(condition_params) if condition.to_s == 'date'
82
+ condition_queries << QueryParamBuilders::BaseParamBuilder.new('milestoneTitle', condition_params) if condition.to_s == 'milestone'
83
+ condition_queries << QueryParamBuilders::BaseParamBuilder.new('state', condition_params, with_quotes: false) if condition.to_s == 'state'
84
+
85
+ if resource_type == 'merge_requests'
86
+ condition_queries << QueryParamBuilders::LabelsParamBuilder.new('labels', condition_params) if condition.to_s == 'labels'
87
+ condition_queries << QueryParamBuilders::BaseParamBuilder.new('sourceBranch', condition_params) if condition.to_s == 'source_branch'
88
+ condition_queries << QueryParamBuilders::BaseParamBuilder.new('targetBranch', condition_params) if condition.to_s == 'target_branch'
89
+ end
90
+
91
+ if resource_type == 'issues'
92
+ condition_queries << QueryParamBuilders::LabelsParamBuilder.new('labelName', condition_params) if condition.to_s == 'labels'
93
+ end
94
+ end
41
95
 
42
- ', includeSubgroups: true'
96
+ condition_queries
97
+ .map(&:build_param)
98
+ .join
43
99
  end
44
100
  end
45
101
  end
@@ -0,0 +1,25 @@
1
+ require_relative '../../utils'
2
+
3
+ module Gitlab
4
+ module Triage
5
+ module GraphqlQueries
6
+ module QueryParamBuilders
7
+ class BaseParamBuilder
8
+ attr_reader :param_name, :param_contents, :with_quotes
9
+
10
+ def initialize(param_name, param_contents, with_quotes: true)
11
+ @param_name = param_name
12
+ @param_contents = param_contents.to_s.strip
13
+ @with_quotes = with_quotes
14
+ end
15
+
16
+ def build_param
17
+ contents = with_quotes ? Utils.graphql_quote(param_contents) : param_contents
18
+
19
+ ", #{param_name}: #{contents}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,35 @@
1
+ require_relative '../../param_builders/date_param_builder'
2
+ require_relative 'base_param_builder'
3
+
4
+ module Gitlab
5
+ module Triage
6
+ module GraphqlQueries
7
+ module QueryParamBuilders
8
+ class DateParamBuilder < BaseParamBuilder
9
+ ATTRIBUTES = %w[updated_at created_at merged_at].freeze
10
+
11
+ def initialize(condition_hash)
12
+ date_param_builder = ParamBuilders::DateParamBuilder.new(ATTRIBUTES, condition_hash)
13
+
14
+ super(build_param_name(condition_hash), date_param_builder.param_content)
15
+ end
16
+
17
+ private
18
+
19
+ def build_param_name(condition_hash)
20
+ prefix = condition_hash[:attribute].to_s.sub(/_at\z/, '')
21
+ suffix =
22
+ case condition_hash[:condition].to_sym
23
+ when :older_than
24
+ 'Before'
25
+ when :newer_than
26
+ 'After'
27
+ end
28
+
29
+ "#{prefix}#{suffix}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ require_relative '../../utils'
2
+ require_relative 'base_param_builder'
3
+
4
+ module Gitlab
5
+ module Triage
6
+ module GraphqlQueries
7
+ module QueryParamBuilders
8
+ class LabelsParamBuilder < BaseParamBuilder
9
+ def initialize(param_name, labels)
10
+ label_param_content = labels.map { |label| Utils.graphql_quote(label) }.join(', ').then { |content| "[#{content}]" }
11
+
12
+ super(param_name, label_param_content, with_quotes: false)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -33,7 +33,9 @@ module Gitlab
33
33
  print '.'
34
34
  url = response.fetch(:next_page_url) { url }
35
35
 
36
- response = execute_with_retry([Net::ReadTimeout, Errors::Network::InternalServerError]) do
36
+ response = execute_with_retry(
37
+ exception_types: [Net::ReadTimeout, Errors::Network::InternalServerError],
38
+ backoff_exceptions: Errors::Network::TooManyRequests) do
37
39
  puts Gitlab::Triage::UI.debug "query_api: #{url}" if options.debug
38
40
 
39
41
  @adapter.get(token, url)
@@ -44,8 +46,10 @@ module Gitlab
44
46
  case results
45
47
  when Array
46
48
  resources.concat(results)
47
- else
49
+ when Hash
48
50
  resources << results
51
+ else
52
+ raise Errors::Network::UnexpectedResponse, "Unexpected response: #{results.inspect}"
49
53
  end
50
54
 
51
55
  rate_limit_debug(response) if options.debug
@@ -58,7 +62,9 @@ module Gitlab
58
62
  end
59
63
 
60
64
  def post_api(url, body)
61
- response = execute_with_retry(Net::ReadTimeout) do
65
+ response = execute_with_retry(
66
+ exception_types: Net::ReadTimeout,
67
+ backoff_exceptions: Errors::Network::TooManyRequests) do
62
68
  puts Gitlab::Triage::UI.debug "post_api: #{url}" if options.debug
63
69
 
64
70
  @adapter.post(token, url, body)
@@ -1,3 +1,4 @@
1
+ require 'httparty'
1
2
  require 'graphql/client'
2
3
  require 'graphql/client/http'
3
4
 
@@ -17,16 +18,22 @@ module Gitlab
17
18
  raise_on_error!(response)
18
19
 
19
20
  parsed_response = parse_response(response, resource_path)
21
+ headers = response.extensions.fetch('headers', {})
20
22
 
21
- return { results: {} } if parsed_response.nil?
22
- return { results: parsed_response.map(&:to_h) } if parsed_response.is_a?(Client::List)
23
- return { results: parsed_response.to_h } unless parsed_response.nodes?
23
+ graphql_response = {
24
+ ratelimit_remaining: headers['ratelimit-remaining'].to_i,
25
+ ratelimit_reset_at: Time.at(headers['ratelimit-reset'].to_i)
26
+ }
27
+
28
+ return graphql_response.merge(results: {}) if parsed_response.nil?
29
+ return graphql_response.merge(results: parsed_response.map(&:to_h)) if parsed_response.is_a?(Client::List)
30
+ return graphql_response.merge(results: parsed_response.to_h) unless parsed_response.nodes?
24
31
 
25
- {
32
+ graphql_response.merge(
26
33
  more_pages: parsed_response.page_info.has_next_page,
27
34
  end_cursor: parsed_response.page_info.end_cursor,
28
35
  results: parsed_response.nodes.map(&:to_h)
29
- }
36
+ )
30
37
  end
31
38
 
32
39
  delegate :parse, to: :client
@@ -47,11 +54,27 @@ module Gitlab
47
54
 
48
55
  def http_client
49
56
  Client::HTTP.new("#{options.host_url}/api/graphql") do
50
- def headers(context) # rubocop:disable Lint/NestedMethodDefinition
51
- {
52
- 'Content-type' => 'application/json',
53
- 'PRIVATE-TOKEN' => context[:token]
54
- }
57
+ def execute(document:, operation_name: nil, variables: {}, context: {}) # rubocop:disable Lint/NestedMethodDefinition
58
+ body = {}
59
+ body['query'] = document.to_query_string
60
+ body['variables'] = variables if variables.any?
61
+ body['operationName'] = operation_name if operation_name
62
+
63
+ response = HTTParty.post(
64
+ uri,
65
+ body: body.to_json,
66
+ headers: {
67
+ 'Content-type' => 'application/json',
68
+ 'PRIVATE-TOKEN' => context[:token]
69
+ }
70
+ )
71
+
72
+ case response.code
73
+ when 200, 400
74
+ JSON.parse(response.body).merge('extensions' => { 'headers' => response.headers })
75
+ else
76
+ { 'errors' => [{ 'message' => "#{response.code} #{response.message}" }] }
77
+ end
55
78
  end
56
79
  end
57
80
  end