payping-gitlab-triage 0.1.1

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 (108) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +19 -0
  3. data/.gitignore +15 -0
  4. data/.gitlab/CODEOWNERS +2 -0
  5. data/.gitlab/changelog_config.yml +13 -0
  6. data/.gitlab/issue_templates/Default.md +13 -0
  7. data/.gitlab/merge_request_templates/Default.md +11 -0
  8. data/.gitlab/merge_request_templates/Release.md +13 -0
  9. data/.gitlab-ci.yml +146 -0
  10. data/.rubocop.yml +21 -0
  11. data/.rubocop_todo.yml +145 -0
  12. data/.ruby-version +1 -0
  13. data/.tool-versions +1 -0
  14. data/.yardopts +4 -0
  15. data/CONTRIBUTING.md +31 -0
  16. data/Dangerfile +5 -0
  17. data/Gemfile +15 -0
  18. data/Guardfile +70 -0
  19. data/LICENSE.md +25 -0
  20. data/README.md +1480 -0
  21. data/Rakefile +6 -0
  22. data/bin/gitlab-triage +19 -0
  23. data/gitlab-triage.gemspec +41 -0
  24. data/lib/gitlab/triage/action/base.rb +14 -0
  25. data/lib/gitlab/triage/action/comment.rb +104 -0
  26. data/lib/gitlab/triage/action/comment_on_summary.rb +83 -0
  27. data/lib/gitlab/triage/action/delete.rb +56 -0
  28. data/lib/gitlab/triage/action/issue.rb +64 -0
  29. data/lib/gitlab/triage/action/summarize.rb +82 -0
  30. data/lib/gitlab/triage/action.rb +36 -0
  31. data/lib/gitlab/triage/api_query_builders/base_query_param_builder.rb +27 -0
  32. data/lib/gitlab/triage/api_query_builders/date_query_param_builder.rb +42 -0
  33. data/lib/gitlab/triage/api_query_builders/multi_query_param_builder.rb +28 -0
  34. data/lib/gitlab/triage/api_query_builders/single_query_param_builder.rb +13 -0
  35. data/lib/gitlab/triage/command_builders/base_command_builder.rb +40 -0
  36. data/lib/gitlab/triage/command_builders/cc_command_builder.rb +19 -0
  37. data/lib/gitlab/triage/command_builders/comment_command_builder.rb +19 -0
  38. data/lib/gitlab/triage/command_builders/label_command_builder.rb +40 -0
  39. data/lib/gitlab/triage/command_builders/move_command_builder.rb +19 -0
  40. data/lib/gitlab/triage/command_builders/remove_label_command_builder.rb +15 -0
  41. data/lib/gitlab/triage/command_builders/status_command_builder.rb +23 -0
  42. data/lib/gitlab/triage/command_builders/text_content_builder.rb +138 -0
  43. data/lib/gitlab/triage/engine.rb +635 -0
  44. data/lib/gitlab/triage/entity_builders/issue_builder.rb +54 -0
  45. data/lib/gitlab/triage/entity_builders/summary_builder.rb +82 -0
  46. data/lib/gitlab/triage/errors/network.rb +11 -0
  47. data/lib/gitlab/triage/errors.rb +1 -0
  48. data/lib/gitlab/triage/expand_condition/expansion.rb +203 -0
  49. data/lib/gitlab/triage/expand_condition/list.rb +25 -0
  50. data/lib/gitlab/triage/expand_condition/sequence.rb +25 -0
  51. data/lib/gitlab/triage/expand_condition.rb +23 -0
  52. data/lib/gitlab/triage/filters/assignee_member_conditions_filter.rb +13 -0
  53. data/lib/gitlab/triage/filters/author_member_conditions_filter.rb +13 -0
  54. data/lib/gitlab/triage/filters/base_conditions_filter.rb +58 -0
  55. data/lib/gitlab/triage/filters/branch_date_filter.rb +73 -0
  56. data/lib/gitlab/triage/filters/branch_protected_filter.rb +26 -0
  57. data/lib/gitlab/triage/filters/discussions_conditions_filter.rb +58 -0
  58. data/lib/gitlab/triage/filters/issue_date_conditions_filter.rb +78 -0
  59. data/lib/gitlab/triage/filters/member_conditions_filter.rb +84 -0
  60. data/lib/gitlab/triage/filters/merge_request_date_conditions_filter.rb +13 -0
  61. data/lib/gitlab/triage/filters/name_conditions_filter.rb +26 -0
  62. data/lib/gitlab/triage/filters/no_additional_labels_conditions_filter.rb +30 -0
  63. data/lib/gitlab/triage/filters/ruby_conditions_filter.rb +33 -0
  64. data/lib/gitlab/triage/filters/votes_conditions_filter.rb +54 -0
  65. data/lib/gitlab/triage/graphql_network.rb +81 -0
  66. data/lib/gitlab/triage/graphql_queries/query_builder.rb +158 -0
  67. data/lib/gitlab/triage/graphql_queries/query_param_builders/base_param_builder.rb +30 -0
  68. data/lib/gitlab/triage/graphql_queries/query_param_builders/date_param_builder.rb +35 -0
  69. data/lib/gitlab/triage/graphql_queries/query_param_builders/labels_param_builder.rb +18 -0
  70. data/lib/gitlab/triage/limiters/base_limiter.rb +35 -0
  71. data/lib/gitlab/triage/limiters/date_field_limiter.rb +45 -0
  72. data/lib/gitlab/triage/network.rb +39 -0
  73. data/lib/gitlab/triage/network_adapters/base_adapter.rb +17 -0
  74. data/lib/gitlab/triage/network_adapters/graphql_adapter.rb +92 -0
  75. data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +116 -0
  76. data/lib/gitlab/triage/network_adapters/test_adapter.rb +39 -0
  77. data/lib/gitlab/triage/option_parser.rb +105 -0
  78. data/lib/gitlab/triage/options.rb +30 -0
  79. data/lib/gitlab/triage/param_builders/date_param_builder.rb +64 -0
  80. data/lib/gitlab/triage/policies/base_policy.rb +80 -0
  81. data/lib/gitlab/triage/policies/rule_policy.rb +36 -0
  82. data/lib/gitlab/triage/policies/summary_policy.rb +29 -0
  83. data/lib/gitlab/triage/policies_resources/rule_resources.rb +11 -0
  84. data/lib/gitlab/triage/policies_resources/summary_resources.rb +11 -0
  85. data/lib/gitlab/triage/resource/base.rb +102 -0
  86. data/lib/gitlab/triage/resource/branch.rb +13 -0
  87. data/lib/gitlab/triage/resource/context.rb +47 -0
  88. data/lib/gitlab/triage/resource/epic.rb +20 -0
  89. data/lib/gitlab/triage/resource/instance_version.rb +35 -0
  90. data/lib/gitlab/triage/resource/issue.rb +52 -0
  91. data/lib/gitlab/triage/resource/label.rb +56 -0
  92. data/lib/gitlab/triage/resource/label_event.rb +48 -0
  93. data/lib/gitlab/triage/resource/linked_issue.rb +15 -0
  94. data/lib/gitlab/triage/resource/merge_request.rb +23 -0
  95. data/lib/gitlab/triage/resource/milestone.rb +98 -0
  96. data/lib/gitlab/triage/resource/shared/issuable.rb +119 -0
  97. data/lib/gitlab/triage/rest_api_network.rb +125 -0
  98. data/lib/gitlab/triage/retryable.rb +33 -0
  99. data/lib/gitlab/triage/ui.rb +23 -0
  100. data/lib/gitlab/triage/url_builders/url_builder.rb +54 -0
  101. data/lib/gitlab/triage/utils.rb +13 -0
  102. data/lib/gitlab/triage/validators/limiter_validator.rb +21 -0
  103. data/lib/gitlab/triage/validators/params_validator.rb +43 -0
  104. data/lib/gitlab/triage/version.rb +7 -0
  105. data/lib/gitlab/triage.rb +6 -0
  106. data/support/.gitlab-ci.example.yml +22 -0
  107. data/support/.triage-policies.example.yml +51 -0
  108. metadata +280 -0
@@ -0,0 +1,635 @@
1
+ require 'active_support/all'
2
+ require 'active_support/inflector'
3
+
4
+ require_relative 'expand_condition'
5
+ require_relative 'filters/issue_date_conditions_filter'
6
+ require_relative 'filters/merge_request_date_conditions_filter'
7
+ require_relative 'filters/branch_date_filter'
8
+ require_relative 'filters/branch_protected_filter'
9
+ require_relative 'filters/votes_conditions_filter'
10
+ require_relative 'filters/no_additional_labels_conditions_filter'
11
+ require_relative 'filters/author_member_conditions_filter'
12
+ require_relative 'filters/assignee_member_conditions_filter'
13
+ require_relative 'filters/discussions_conditions_filter'
14
+ require_relative 'filters/ruby_conditions_filter'
15
+ require_relative 'limiters/date_field_limiter'
16
+ require_relative 'action'
17
+ require_relative 'policies/rule_policy'
18
+ require_relative 'policies/summary_policy'
19
+ require_relative 'policies_resources/rule_resources'
20
+ require_relative 'policies_resources/summary_resources'
21
+ require_relative 'api_query_builders/date_query_param_builder'
22
+ require_relative 'api_query_builders/single_query_param_builder'
23
+ require_relative 'api_query_builders/multi_query_param_builder'
24
+ require_relative 'url_builders/url_builder'
25
+ require_relative 'network'
26
+ require_relative 'graphql_network'
27
+ require_relative 'rest_api_network'
28
+ require_relative 'network_adapters/httparty_adapter'
29
+ require_relative 'network_adapters/graphql_adapter'
30
+ require_relative 'graphql_queries/query_builder'
31
+ require_relative 'ui'
32
+
33
+ module Gitlab
34
+ module Triage
35
+ class Engine
36
+ attr_reader :per_page, :policies, :options
37
+
38
+ DEFAULT_NETWORK_ADAPTER = Gitlab::Triage::NetworkAdapters::HttpartyAdapter
39
+ DEFAULT_GRAPHQL_ADAPTER = Gitlab::Triage::NetworkAdapters::GraphqlAdapter
40
+ ALLOWED_STATE_VALUES = {
41
+ issues: %w[opened closed],
42
+ merge_requests: %w[opened closed merged]
43
+ }.with_indifferent_access.freeze
44
+ MILESTONE_TIMEBOX_VALUES = %w[none any upcoming started].freeze
45
+ ITERATION_SELECTION_VALUES = %w[none any].freeze
46
+ EpicsTriagingForProjectImpossibleError = Class.new(StandardError)
47
+ MultiPolicyInInjectionModeError = Class.new(StandardError)
48
+
49
+ def initialize(policies:, options:, network_adapter_class: DEFAULT_NETWORK_ADAPTER, graphql_network_adapter_class: DEFAULT_GRAPHQL_ADAPTER)
50
+ options.host_url = policies.delete(:host_url) { options.host_url }
51
+ options.api_version = policies.delete(:api_version) { 'v4' }
52
+ options.dry_run = ENV['TEST'] == 'true' if options.dry_run.nil?
53
+
54
+ @per_page = policies.delete(:per_page) { 100 }
55
+ @policies = policies
56
+ @options = options
57
+ @network_adapter_class = network_adapter_class
58
+ @graphql_network_adapter_class = graphql_network_adapter_class
59
+
60
+ assert_options!
61
+
62
+ @options.source = @options.source.to_s
63
+
64
+ require_ruby_files
65
+ end
66
+
67
+ def perform
68
+ puts "Performing a dry run.\n\n" if options.dry_run
69
+
70
+ puts Gitlab::Triage::UI.header("Triaging the `#{options.source_id}` #{options.source.singularize}", char: '=')
71
+ puts
72
+
73
+ resource_rules.each do |resource_type, policy_definition|
74
+ next unless right_resource_type_for_resource_option?(resource_type)
75
+
76
+ assert_epic_rule!(resource_type)
77
+
78
+ puts Gitlab::Triage::UI.header("Processing summaries & rules for #{resource_type}", char: '-')
79
+ puts
80
+
81
+ process_summaries(resource_type, policy_definition[:summaries])
82
+ process_rules(resource_type, policy_definition[:rules])
83
+ end
84
+ end
85
+
86
+ def network
87
+ @network ||= Network.new(restapi: restapi_network, graphql: graphql_network)
88
+ end
89
+
90
+ def restapi_network
91
+ @restapi_network ||= RestAPINetwork.new(network_adapter)
92
+ end
93
+
94
+ def graphql_network
95
+ @graphql_network ||= GraphqlNetwork.new(graphql_network_adapter)
96
+ end
97
+
98
+ private
99
+
100
+ def assert_options!
101
+ assert_all!
102
+ assert_source!
103
+ assert_source_id!
104
+ assert_resource_reference!
105
+ end
106
+
107
+ # rubocop:disable Style/IfUnlessModifier
108
+ def assert_all!
109
+ return unless options.all
110
+
111
+ if options.source
112
+ raise ArgumentError, '--all-projects option cannot be used in conjunction with --source option!'
113
+ end
114
+
115
+ if options.source_id
116
+ raise ArgumentError, '--all-projects option cannot be used in conjunction with --source-id option!'
117
+ end
118
+
119
+ if options.resource_reference # rubocop:disable Style/GuardClause
120
+ raise ArgumentError, '--all-projects option cannot be used in conjunction with --resource-reference option!'
121
+ end
122
+ end
123
+ # rubocop:enable Style/IfUnlessModifier
124
+
125
+ def assert_source!
126
+ return if options.source
127
+ return if options.all
128
+
129
+ raise ArgumentError, 'A source is needed (pass it with the `--source` option)!'
130
+ end
131
+
132
+ def assert_source_id!
133
+ return if options.source_id
134
+ return if options.all
135
+
136
+ raise ArgumentError, 'A project or group ID is needed (pass it with the `--source-id` option)!'
137
+ end
138
+
139
+ def assert_resource_reference!
140
+ return unless options.resource_reference
141
+
142
+ if options.source == 'groups' && !options.resource_reference.start_with?('&')
143
+ raise ArgumentError, "--resource-reference can only start with '&' when --source=groups is passed ('#{options.resource_reference}' passed)!"
144
+ end
145
+
146
+ if options.source == 'projects' && !options.resource_reference.start_with?('#', '!') # rubocop:disable Style/GuardClause
147
+ raise(
148
+ ArgumentError,
149
+ "--resource-reference can only start with '#' or '!' when --source=projects is passed " \
150
+ "('#{options.resource_reference}' passed)!"
151
+ )
152
+ end
153
+ end
154
+
155
+ def require_ruby_files
156
+ options.require_files.each(&method(:require))
157
+ end
158
+
159
+ def right_resource_type_for_resource_option?(resource_type)
160
+ return true unless options.resource_reference
161
+
162
+ resource_reference = options.resource_reference
163
+
164
+ case resource_type
165
+ when 'issues'
166
+ resource_reference.start_with?('#')
167
+ when 'merge_requests'
168
+ resource_reference.start_with?('!')
169
+ when 'epics'
170
+ resource_reference.start_with?('&')
171
+ end
172
+ end
173
+
174
+ def assert_epic_rule!(resource_type)
175
+ return if resource_type != 'epics' || options.source == 'groups'
176
+
177
+ raise EpicsTriagingForProjectImpossibleError, "Epics can only be triaged at the group level. Please set the `--source groups` option."
178
+ end
179
+
180
+ def resource_rules
181
+ @resource_rules ||= policies.delete(:resource_rules) { {} }
182
+ end
183
+
184
+ def network_adapter
185
+ @network_adapter ||= @network_adapter_class.new(options)
186
+ end
187
+
188
+ def graphql_network_adapter
189
+ @graphql_network_adapter ||= @graphql_network_adapter_class.new(options)
190
+ end
191
+
192
+ def rule_conditions(rule)
193
+ rule.fetch(:conditions) { {} }
194
+ end
195
+
196
+ def rule_limits(rule)
197
+ rule.fetch(:limits) { {} }
198
+ end
199
+
200
+ # Process an array of +summary_definitions+.
201
+ #
202
+ # @example Example of an array of summary definitions (shown as YAML for readability).
203
+ #
204
+ # - name: Newest and oldest issues summary
205
+ # rules:
206
+ # - name: New issues
207
+ # conditions:
208
+ # state: opened
209
+ # limits:
210
+ # most_recent: 2
211
+ # actions:
212
+ # summarize:
213
+ # item: "- [ ] [{{title}}]({{web_url}}) {{labels}}"
214
+ # summary: |
215
+ # Please triage the following new {{type}}:
216
+ # {{items}}
217
+ # actions:
218
+ # summarize:
219
+ # title: "Newest and oldest {{type}} summary"
220
+ # summary: |
221
+ # Please triage the following {{type}}:
222
+ # {{items}}
223
+ # Please take care of them before the end of #{7.days.from_now.strftime('%Y-%m-%d')}
224
+ # /label ~"needs attention"
225
+ #
226
+ # @param summary_definitions [Array<Hash>] An array usually given as YAML in a triage policy file.
227
+ #
228
+ # @return [nil]
229
+ def process_summaries(resource_type, summary_definitions)
230
+ return if summary_definitions.blank?
231
+
232
+ summary_definitions.each do |summary_definition|
233
+ process_summary(resource_type, summary_definition)
234
+ end
235
+ end
236
+
237
+ # Process an array of +rule_definitions+.
238
+ #
239
+ # @example Example of an array of rule definitions.
240
+ #
241
+ # [{ name: "New issues", conditions: { state: opened }, limits: { most_recent: 2 }, actions: { labels: ["needs attention"] } }]
242
+ #
243
+ # @param rule_definitions [Array<Hash>] An array usually given as YAML in a triage policy file.
244
+ #
245
+ # @return [nil]
246
+ def process_rules(resource_type, rule_definitions)
247
+ return if rule_definitions.blank?
248
+
249
+ rule_definitions.each do |rule_definition|
250
+ resources_for_rule(resource_type, rule_definition) do |resources|
251
+ policy = Policies::RulePolicy.new(
252
+ resource_type, rule_definition, resources, network)
253
+
254
+ process_action(policy)
255
+ end
256
+ end
257
+ end
258
+
259
+ # Process a +summary_definition+.
260
+ #
261
+ # @example Example of a summary definition hash (shown as YAML for readability).
262
+ #
263
+ # name: Newest and oldest issues summary
264
+ # rules:
265
+ # - name: New issues
266
+ # conditions:
267
+ # state: opened
268
+ # limits:
269
+ # most_recent: 2
270
+ # actions:
271
+ # summarize:
272
+ # item: "- [ ] [{{title}}]({{web_url}}) {{labels}}"
273
+ # summary: |
274
+ # Please triage the following new {{type}}:
275
+ # {{items}}
276
+ # actions:
277
+ # summarize:
278
+ # title: "Newest and oldest {{type}} summary"
279
+ # summary: |
280
+ # Please triage the following {{type}}:
281
+ # {{items}}
282
+ # Please take care of them before the end of #{7.days.from_now.strftime('%Y-%m-%d')}
283
+ # /label ~"needs attention"
284
+ #
285
+ # @param resource_type [String] The resource type, e.g. +issues+ or +merge_requests+.
286
+ # @param summary_definition [Hash] A hash usually given as YAML in a triage policy file:
287
+ #
288
+ # @return [nil]
289
+ def process_summary(resource_type, summary_definition)
290
+ puts Gitlab::Triage::UI.header("Processing summary: **#{summary_definition[:name]}**", char: '~')
291
+ puts
292
+
293
+ summary_parts_for_rules(resource_type, summary_definition[:rules]) do |summary_resources|
294
+ policy = Policies::SummaryPolicy.new(
295
+ resource_type, summary_definition, summary_resources, network)
296
+
297
+ process_action(policy)
298
+ end
299
+ end
300
+
301
+ # Transform an array of +rule_definitions+ into a +PoliciesResources::SummaryResources.new(rule => rule_resources)+ object.
302
+ #
303
+ # @example Example of an array of rule definitions.
304
+ #
305
+ # [{ name: "New issues", conditions: { state: opened }, limits: { most_recent: 2 }, actions: { labels: ["needs attention"] } }]
306
+ #
307
+ # @param resource_type [String] The resource type, e.g. +issues+ or +merge_requests+.
308
+ # @param rule_definitions [Array<Hash>] An array of rule definitions, e.g.
309
+ # +[{ name: 'Foo', conditions: { milestone: 'v1' } }, { name: 'Foo', conditions: { state: 'opened' } }]+.
310
+ #
311
+ # @yieldparam summary_resources [PoliciesResources::SummaryResources] An object which contains a +{ rule_definition => resources }+ hash.
312
+ # @yieldreturn [nil]
313
+ #
314
+ # @return [nil]
315
+ def summary_parts_for_rules(resource_type, rule_definitions)
316
+ # { summary_rule => resources }
317
+ parts = rule_definitions.each_with_object({}) do |rule_definition, result|
318
+ to_enum(:resources_for_rule, resource_type, rule_definition).each do |rule_resources, expanded_conditions|
319
+ # We replace the non-expanded rule conditions with the expanded ones
320
+ result.merge!(rule_definition.merge(conditions: expanded_conditions) => rule_resources)
321
+ end
322
+
323
+ result
324
+ end
325
+
326
+ yield(PoliciesResources::SummaryResources.new(parts))
327
+ end
328
+
329
+ # Transform a non-expanded +rule_definition+ into a +PoliciesResources::RuleResources.new(resources)+ object.
330
+ #
331
+ # @example Example of a rule definition hash.
332
+ #
333
+ # { name: "New issues", conditions: { state: opened }, limits: { most_recent: 2 }, actions: { labels: ["needs attention"] } }
334
+ #
335
+ # @param resource_type [String] The resource type, e.g. +issues+ or +merge_requests+.
336
+ # @param rule_definition [Hash] A rule definition, e.g. +{ name: 'Foo', conditions: { milestone: 'v1' } }+.
337
+ #
338
+ # @yieldparam rule_resources [PoliciesResources::RuleResources] An object which contains an array of resources.
339
+ # @yieldparam expanded_conditions [Hash] A hash of expanded conditions.
340
+ # @yieldreturn [nil]
341
+ #
342
+ # @return [nil]
343
+ def resources_for_rule(resource_type, rule_definition)
344
+ puts Gitlab::Triage::UI.header("Gathering resources for rule: **#{rule_definition[:name]}**", char: '-')
345
+
346
+ ExpandCondition.perform(rule_conditions(rule_definition)) do |expanded_conditions|
347
+ # retrieving the resources for every rule is inefficient
348
+ # however, previous rules may affect those upcoming
349
+ resources = options.resources ||
350
+ fetch_resources(resource_type, expanded_conditions, rule_definition)
351
+
352
+ # In some filters/actions we want to know which resource type it is
353
+ attach_resource_type(resources, resource_type)
354
+
355
+ puts "\n\n* Found #{resources.count} resources..."
356
+ print "* Filtering resources..."
357
+ resources = filter_resources(resources, expanded_conditions)
358
+ puts "\n* Total after filtering: #{resources.count} resources"
359
+ print "* Limiting resources..."
360
+ resources = limit_resources(resources, rule_limits(rule_definition))
361
+ puts "\n* Total after limiting: #{resources.count} resources"
362
+ puts
363
+
364
+ yield(PoliciesResources::RuleResources.new(resources), expanded_conditions)
365
+ end
366
+ end
367
+
368
+ def fetch_resources(resource_type, expanded_conditions, rule_definition)
369
+ resources = []
370
+
371
+ if rule_definition[:api] == 'graphql'
372
+ graphql_query_options = { source: source_full_path }
373
+
374
+ if options.resource_reference
375
+ expanded_conditions[:iids] = options.resource_reference[1..]
376
+ graphql_query_options[:iids] = [expanded_conditions[:iids]]
377
+ end
378
+
379
+ graphql_query = build_graphql_query(resource_type, expanded_conditions, true)
380
+
381
+ resources = graphql_network.query(graphql_query, **graphql_query_options)
382
+ else
383
+ # FIXME: Epics listing endpoint doesn't support filtering by `iids`, so instead we
384
+ # get a single epic when `--resource-reference` is given for epics.
385
+ # Because of that, the query could return a single epic, so we make sure we get an array.
386
+ resources = Array(network.query_api(build_get_url(resource_type, expanded_conditions)))
387
+
388
+ iids = resources.pluck('iid').map(&:to_s)
389
+ expanded_conditions[:iids] = iids
390
+
391
+ graphql_query = build_graphql_query(resource_type, expanded_conditions)
392
+ graphql_resources = graphql_network.query(graphql_query, source: source_full_path, iids: iids) if graphql_query.any?
393
+
394
+ decorate_resources_with_graphql_data(resources, graphql_resources)
395
+ end
396
+
397
+ resources
398
+ end
399
+
400
+ def attach_resource_type(resources, resource_type)
401
+ resources.each { |resource| resource[:type] = resource_type }
402
+ end
403
+
404
+ def decorate_resources_with_graphql_data(resources, graphql_resources)
405
+ return if graphql_resources.nil?
406
+
407
+ graphql_resources_by_id = graphql_resources.to_h { |resource| [resource[:id], resource] }
408
+ resources.each { |resource| resource.merge!(graphql_resources_by_id[resource[:id]].to_h) }
409
+ end
410
+
411
+ def process_action(policy)
412
+ Action.process(
413
+ policy: policy,
414
+ network: network,
415
+ dry: options.dry_run)
416
+ puts
417
+ end
418
+
419
+ def filter_resources(resources, conditions) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
420
+ resources.select do |resource|
421
+ results = []
422
+
423
+ # rubocop:disable Style/IfUnlessModifier
424
+ if conditions[:date]
425
+ case resource[:type]
426
+ when 'branches'
427
+ results << Filters::BranchDateFilter.new(resource, conditions[:date]).calculate
428
+ when 'issues'
429
+ if conditions.dig(:date, :filter_in_ruby)
430
+ results << Filters::IssueDateConditionsFilter.new(resource, conditions[:date]).calculate
431
+ end
432
+ when 'merge_requests'
433
+ if conditions.dig(:date, :filter_in_ruby) ||
434
+ # REST API does not support filtering with merged_at,
435
+ # so we have to filter it in Ruby
436
+ conditions.dig(:date, :attribute) == 'merged_at'
437
+ results << Filters::MergeRequestDateConditionsFilter.new(resource, conditions[:date]).calculate
438
+ end
439
+ end
440
+ end
441
+
442
+ if resource[:type] == 'branches'
443
+ results << Filters::BranchProtectedFilter.new(resource, conditions[:protected]).calculate
444
+ end
445
+
446
+ votes_condition = conditions[:votes] || conditions[:upvotes]
447
+ if votes_condition
448
+ results << Filters::VotesConditionsFilter.new(resource, votes_condition).calculate
449
+ end
450
+
451
+ if conditions[:no_additional_labels]
452
+ results << Filters::NoAdditionalLabelsConditionsFilter.new(resource, conditions.fetch(:labels) { [] }).calculate
453
+ end
454
+
455
+ if conditions[:author_member]
456
+ results << Filters::AuthorMemberConditionsFilter.new(resource, conditions[:author_member], network).calculate
457
+ end
458
+
459
+ if conditions[:assignee_member]
460
+ results << Filters::AssigneeMemberConditionsFilter.new(resource, conditions[:assignee_member], network).calculate
461
+ end
462
+
463
+ if conditions[:discussions]
464
+ results << Filters::DiscussionsConditionsFilter.new(resource, conditions[:discussions]).calculate
465
+ end
466
+
467
+ if conditions[:ruby]
468
+ results << Filters::RubyConditionsFilter.new(resource, conditions, network).calculate
469
+ end
470
+ # rubocop:enable Style/IfUnlessModifier
471
+
472
+ results.all?
473
+ end
474
+ end
475
+
476
+ def limit_resources(resources, limits)
477
+ if limits.empty?
478
+ resources
479
+ else
480
+ Limiters::DateFieldLimiter.new(resources, limits).limit
481
+ end
482
+ end
483
+
484
+ # rubocop:disable Metrics/AbcSize
485
+ # rubocop:disable Metrics/CyclomaticComplexity
486
+ def build_get_url(resource_type, conditions)
487
+ # Example issues query with state and labels
488
+ # https://gitlab.com/api/v4/projects/test-triage%2Fissue-project/issues?state=open&labels=project%20label%20with%20spaces,group_label_no_spaces
489
+ params = {
490
+ per_page: per_page
491
+ }
492
+
493
+ condition_builders = []
494
+ condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('iids', options.resource_reference[1..]) if options.resource_reference
495
+
496
+ condition_builders << APIQueryBuilders::MultiQueryParamBuilder.new('labels', conditions[:labels], ',') if conditions[:labels]
497
+
498
+ if conditions[:forbidden_labels]
499
+ condition_builders << APIQueryBuilders::MultiQueryParamBuilder.new('not[labels]', conditions[:forbidden_labels], ',')
500
+ end
501
+
502
+ if conditions[:state]
503
+ condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new(
504
+ 'state',
505
+ conditions[:state],
506
+ allowed_values: ALLOWED_STATE_VALUES[resource_type])
507
+ end
508
+
509
+ condition_builders << milestone_condition_builder(resource_type, conditions[:milestone]) if conditions[:milestone]
510
+
511
+ if conditions[:date] && APIQueryBuilders::DateQueryParamBuilder.applicable?(conditions[:date]) && resource_type&.to_sym != :branches
512
+ condition_builders << APIQueryBuilders::DateQueryParamBuilder.new(conditions.delete(:date))
513
+ end
514
+
515
+ case resource_type&.to_sym
516
+ when :issues
517
+ condition_builders.concat(issues_resource_query(conditions))
518
+ when :merge_requests
519
+ condition_builders.concat(merge_requests_resource_query(conditions))
520
+ when :branches
521
+ condition_builders.concat(branches_resource_query(conditions))
522
+ end
523
+
524
+ condition_builders.compact.each do |condition_builder|
525
+ params[condition_builder.param_name] = condition_builder.param_content
526
+ end
527
+
528
+ url_builder_options = {
529
+ network_options: options,
530
+ all: options.all,
531
+ source: options.source,
532
+ source_id: options.source_id,
533
+ resource_type: resource_type,
534
+ params: params
535
+ }
536
+
537
+ # FIXME: Epics listing endpoint doesn't support filtering by `iids`, so instead we
538
+ # get a single epic when `--resource-reference` is given for epics.
539
+ url_builder_options[:resource_id] = options.resource_reference[1..] if options.resource_reference && resource_type == 'epics'
540
+
541
+ UrlBuilders::UrlBuilder.new(url_builder_options).build
542
+ end
543
+ # rubocop:enable Metrics/AbcSize
544
+ # rubocop:enable Metrics/CyclomaticComplexity
545
+
546
+ def milestone_condition_builder(resource_type, milestone_condition)
547
+ milestone_value = Array(milestone_condition)[0].to_s # back-compatibility
548
+ return if milestone_value.empty?
549
+
550
+ # Issues API should use the `milestone_id` param for timebox values, and `milestone` for milestone title
551
+ args =
552
+ if resource_type.to_sym == :issues && MILESTONE_TIMEBOX_VALUES.include?(milestone_value.downcase)
553
+ ['milestone_id', milestone_value.titleize] # The API only accepts titleized values.
554
+ else
555
+ ['milestone', milestone_value]
556
+ end
557
+
558
+ APIQueryBuilders::SingleQueryParamBuilder.new(*args)
559
+ end
560
+
561
+ def iteration_condition_builder(iteration_value)
562
+ # Issues API should use the `iteration_id` param for timebox values, and `iteration_title` for iteration title
563
+ args =
564
+ if ITERATION_SELECTION_VALUES.include?(iteration_value.downcase)
565
+ ['iteration_id', iteration_value.titleize] # The API only accepts titleized values.
566
+ else
567
+ ['iteration_title', iteration_value]
568
+ end
569
+ APIQueryBuilders::SingleQueryParamBuilder.new(*args)
570
+ end
571
+
572
+ def issues_resource_query(conditions)
573
+ [].tap do |condition_builders|
574
+ condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('weight', conditions[:weight]) if conditions[:weight]
575
+ condition_builders << iteration_condition_builder(conditions[:iteration]) if conditions[:iteration]
576
+ condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('health_status', conditions[:health_status]) if conditions[:health_status]
577
+ end
578
+ end
579
+
580
+ def merge_requests_resource_query(conditions)
581
+ [].tap do |condition_builders|
582
+ [
583
+ :source_branch,
584
+ :target_branch,
585
+ :reviewer_id
586
+ ].each do |key|
587
+ condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new(key.to_s, conditions[key]) if conditions[key]
588
+ end
589
+ condition_builders << draft_condition_builder(conditions[:draft]) if conditions.key?(:draft)
590
+ end
591
+ end
592
+
593
+ def branches_resource_query(conditions)
594
+ [].tap do |condition_builders|
595
+ condition_builders << APIQueryBuilders::SingleQueryParamBuilder.new('search', conditions[:name]) if conditions[:name]
596
+ end
597
+ end
598
+
599
+ def draft_condition_builder(draft_condittion)
600
+ # Issues API only accepts 'yes' and 'no' as strings: https://docs.gitlab.com/ee/api/merge_requests.html
601
+ wip =
602
+ case draft_condittion
603
+ when true
604
+ 'yes'
605
+ when false
606
+ 'no'
607
+ else
608
+ raise ArgumentError, 'The "draft" condition only accepts true or false.'
609
+ end
610
+
611
+ APIQueryBuilders::SingleQueryParamBuilder.new('wip', wip)
612
+ end
613
+
614
+ def build_graphql_query(resource_type, conditions, graphql_only = false)
615
+ Gitlab::Triage::GraphqlQueries::QueryBuilder
616
+ .new(options.source, resource_type, conditions, graphql_only: graphql_only)
617
+ end
618
+
619
+ def source_full_path
620
+ @source_full_path ||= fetch_source_full_path
621
+ end
622
+
623
+ def fetch_source_full_path
624
+ return options.source_id unless /\A\d+\z/.match?(options.source_id)
625
+
626
+ source_details = network.query_api(build_get_url(nil, {})).first
627
+ full_path = source_details['full_path'] || source_details['path_with_namespace']
628
+
629
+ raise ArgumentError, 'A source with given source_id was not found!' if full_path.blank?
630
+
631
+ full_path
632
+ end
633
+ end
634
+ end
635
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../command_builders/text_content_builder'
4
+
5
+ module Gitlab
6
+ module Triage
7
+ module EntityBuilders
8
+ class IssueBuilder
9
+ attr_reader :destination
10
+
11
+ def initialize(
12
+ type:, action:, resource:, network:,
13
+ policy_spec: {}, separator: "\n")
14
+ @type = type
15
+ @policy_spec = policy_spec
16
+ @item_template = action[:item]
17
+ @title_template = action[:title]
18
+ @description_template = action[:description]
19
+ @destination = action[:destination]
20
+ @redact_confidentials =
21
+ action[:redact_confidential_resources] != false
22
+ @resource = resource
23
+ @network = network
24
+ @separator = separator
25
+ end
26
+
27
+ def title
28
+ @title ||= build_text(@title_template)
29
+ end
30
+
31
+ def description
32
+ @description ||= build_text(@description_template)
33
+ end
34
+
35
+ def valid?
36
+ title =~ /\S+/
37
+ end
38
+
39
+ private
40
+
41
+ def build_text(template)
42
+ return '' unless template
43
+
44
+ CommandBuilders::TextContentBuilder.new(
45
+ template,
46
+ resource: @resource,
47
+ network: @network,
48
+ redact_confidentials: @redact_confidentials)
49
+ .build_command.chomp
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end