gitlab-triage 1.53.1 → 1.55.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/Gemfile.lock +4 -3
  4. data/README.md +173 -5
  5. data/lib/gitlab/triage/action/base.rb +62 -0
  6. data/lib/gitlab/triage/action/comment.rb +1 -9
  7. data/lib/gitlab/triage/action/comment_on_summary.rb +0 -6
  8. data/lib/gitlab/triage/action/issue.rb +36 -3
  9. data/lib/gitlab/triage/action/summarize.rb +26 -1
  10. data/lib/gitlab/triage/action/update.rb +454 -0
  11. data/lib/gitlab/triage/action/work_item.rb +0 -27
  12. data/lib/gitlab/triage/action.rb +18 -2
  13. data/lib/gitlab/triage/command_builders/cc_command_builder.rb +0 -4
  14. data/lib/gitlab/triage/engine.rb +13 -4
  15. data/lib/gitlab/triage/entity_builders/issue_builder.rb +8 -0
  16. data/lib/gitlab/triage/entity_builders/issue_fields.rb +65 -0
  17. data/lib/gitlab/triage/entity_builders/summary_builder.rb +8 -0
  18. data/lib/gitlab/triage/graphql_network.rb +22 -5
  19. data/lib/gitlab/triage/graphql_queries/query_builder.rb +46 -3
  20. data/lib/gitlab/triage/network.rb +4 -0
  21. data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +24 -0
  22. data/lib/gitlab/triage/network_adapters/test_adapter.rb +8 -0
  23. data/lib/gitlab/triage/policies/base_policy.rb +10 -2
  24. data/lib/gitlab/triage/policies/rule_policy.rb +18 -8
  25. data/lib/gitlab/triage/policies/summary_policy.rb +9 -0
  26. data/lib/gitlab/triage/rest_api_network.rb +22 -0
  27. data/lib/gitlab/triage/validators/policy_validator.rb +1 -3
  28. data/lib/gitlab/triage/version.rb +1 -1
  29. metadata +4 -6
  30. data/lib/gitlab/triage/command_builders/label_command_builder.rb +0 -42
  31. data/lib/gitlab/triage/command_builders/move_command_builder.rb +0 -21
  32. data/lib/gitlab/triage/command_builders/remove_label_command_builder.rb +0 -17
  33. data/lib/gitlab/triage/command_builders/status_command_builder.rb +0 -25
@@ -0,0 +1,454 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../url_builders/url_builder'
5
+ require_relative '../resource/label'
6
+ require_relative '../resource/context'
7
+ require_relative '../policies/base_policy'
8
+
9
+ module Gitlab
10
+ module Triage
11
+ module Action
12
+ # Updates issues, merge_requests, and epics via direct REST API calls (PUT).
13
+ # Values for labels:, remove_labels:, due_date:, milestone:, and title:
14
+ # support Ruby string interpolation in the resource context (#{}), matching comment: bodies.
15
+ # Missing labels, usernames, or milestone titles raise rather than being silently dropped.
16
+ class Update < Base
17
+ ReviewerDoesntExistError = Class.new(StandardError)
18
+ UpdateNotAppliedError = Class.new(StandardError)
19
+
20
+ VALID_STATUS_VALUES = %w[close reopen].freeze
21
+ LABEL_PARAMS = { labels: :add_labels, remove_labels: :remove_labels }.freeze
22
+ USER_PARAMS = {
23
+ assignees: [:assignee_ids, AssigneeDoesntExistError],
24
+ reviewers: [:reviewer_ids, ReviewerDoesntExistError]
25
+ }.freeze
26
+ REMOVE_USER_PARAMS = {
27
+ remove_assignees: [:assignee_ids, :assignees],
28
+ remove_reviewers: [:reviewer_ids, :reviewers]
29
+ }.freeze
30
+
31
+ # The dry run prints the intended changes without touching the network.
32
+ class Dry < Update
33
+ def act
34
+ puts "The following resources would be updated for the rule **#{policy.name}**:\n\n"
35
+ super
36
+ end
37
+
38
+ private
39
+
40
+ def perform(resource)
41
+ puts "# #{resource[:web_url]}"
42
+ dry_run_lines(resource).each { |line| puts line }
43
+ puts "\n"
44
+ end
45
+
46
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
47
+ def dry_run_lines(resource)
48
+ [
49
+ ("Would add labels: #{join_labels(eval_labels(actions[:labels], resource))}" if actions[:labels]),
50
+ ("Would remove labels: #{join_labels(eval_labels(actions[:remove_labels], resource))}" if actions[:remove_labels]),
51
+ ("Would assign: #{eval_usernames(actions[:assignees], resource).join(', ')}" if actions[:assignees]),
52
+ ("Would set reviewers: #{eval_usernames(actions[:reviewers], resource).join(', ')}" if actions[:reviewers]),
53
+ ("Would unassign: #{eval_usernames(actions[:remove_assignees], resource).join(', ')}" if actions[:remove_assignees]),
54
+ ("Would remove reviewers: #{eval_usernames(actions[:remove_reviewers], resource).join(', ')}" if actions[:remove_reviewers]),
55
+ interpolated_line(:milestone, resource),
56
+ interpolated_line(:due_date, resource),
57
+ ("Would set weight: #{actions[:weight]}" if actions.key?(:weight) && issues_only?),
58
+ ("Would set health_status: #{actions[:health_status]}" if actions.key?(:health_status) && issues_only?),
59
+ interpolated_line(:title, resource),
60
+ ("Would set status: #{actions[:status]}" if actions[:status]),
61
+ ("Would set discussion_locked: #{actions[:discussion_locked]}" if actions.key?(:discussion_locked)),
62
+ ("Would move to: #{eval_value(actions[:move], resource)}" if actions[:move] && issues_only?)
63
+ ].compact
64
+ end
65
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
66
+
67
+ def interpolated_line(key, resource)
68
+ value = eval_present_value(key, resource)
69
+ "Would set #{key}: #{value}" if value
70
+ end
71
+ end
72
+
73
+ # Action.process calls this before any action writes, so a rule naming a
74
+ # label, user, milestone, or move destination that no longer exists fails
75
+ # before Comment posts rather than after. The lookups go through
76
+ # query_api_cached, so the PUT resolves the same values for free.
77
+ # The dry run relies on this too, so a typo fails review rather than the
78
+ # next scheduled run.
79
+ def verify!
80
+ return unless update_requested?
81
+ return if policy.type == 'branches'
82
+
83
+ validate_status! if actions[:status]
84
+
85
+ policy.resources.each do |resource|
86
+ verify_resource_labels!(resource)
87
+ verify_resource_users!(resource)
88
+ verify_resource_milestone!(resource)
89
+ verify_resource_destination!(resource)
90
+ end
91
+ end
92
+
93
+ def act
94
+ return unless update_requested?
95
+
96
+ if policy.type == 'branches'
97
+ puts Gitlab::Triage::UI.warn "Update actions are not available for branches. They will NOT be performed\n\n"
98
+ return
99
+ end
100
+
101
+ policy.resources.each do |resource|
102
+ perform(resource)
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def perform(resource)
109
+ update_health_status(resource) if actions.key?(:health_status)
110
+
111
+ body = build_body(resource)
112
+
113
+ if body.any?
114
+ response = network.put_api(resource_url(resource), body)
115
+ verify_applied!(resource, response)
116
+ end
117
+
118
+ # Moving last keeps the other keys addressing the resource at its original
119
+ # location. A move gives it a new iid, which would stale the PUT URL.
120
+ move_resource(resource) if actions[:move]
121
+ end
122
+
123
+ # Health status is not an accepted parameter on PUT /projects/:id/issues/:iid,
124
+ # so it goes through the work item GraphQL widget instead.
125
+ def update_health_status(resource)
126
+ unless issues_only?
127
+ puts Gitlab::Triage::UI.warn "health_status is only available for issues. It will NOT be set\n\n"
128
+ return
129
+ end
130
+
131
+ mutation = <<~GRAPHQL
132
+ mutation($input: WorkItemUpdateInput!) {
133
+ workItemUpdate(input: $input) {
134
+ errors
135
+ }
136
+ }
137
+ GRAPHQL
138
+
139
+ variables = {
140
+ input: {
141
+ id: "gid://gitlab/WorkItem/#{resource[:id]}",
142
+ healthStatusWidget: { healthStatus: graphql_health_status }
143
+ }
144
+ }
145
+
146
+ errors = network.mutate_graphql(mutation, variables)&.dig(:work_item_update, :errors)
147
+ return if errors.nil? || errors.empty?
148
+
149
+ raise UpdateNotAppliedError,
150
+ "Health status was not set for #{resource[:web_url]}: #{errors.join(', ')}"
151
+ end
152
+
153
+ # The GraphQL enum is camelCase (onTrack), the policy value matches the
154
+ # quick action and the REST filter param (on_track).
155
+ def graphql_health_status
156
+ return if actions[:health_status].nil?
157
+
158
+ actions[:health_status].to_s.gsub(/_(\w)/) { Regexp.last_match(1).upcase }
159
+ end
160
+
161
+ def build_body(resource)
162
+ {}.tap do |body|
163
+ apply_labels!(body, resource)
164
+ apply_users!(body, resource)
165
+ apply_user_removals!(body, resource)
166
+ apply_milestone!(body, resource)
167
+ apply_simple_fields!(body, resource)
168
+ end
169
+ end
170
+
171
+ def apply_labels!(body, resource)
172
+ LABEL_PARAMS.each do |action_key, param|
173
+ labels = requested_labels(action_key, resource)
174
+ next if labels.empty?
175
+
176
+ ensure_labels_exist!(labels, resource)
177
+ body[param] = join_labels(labels)
178
+ end
179
+ end
180
+
181
+ def verify_resource_labels!(resource)
182
+ LABEL_PARAMS.each_key do |action_key|
183
+ ensure_labels_exist!(requested_labels(action_key, resource), resource)
184
+ end
185
+ end
186
+
187
+ # Removals take the ids straight off the resource, so only the set
188
+ # actions need a lookup here.
189
+ def verify_resource_users!(resource)
190
+ USER_PARAMS.each do |action_key, (_param, error_class)|
191
+ next unless actions[action_key]
192
+
193
+ resolve_user_ids(eval_usernames(actions[action_key], resource), error_class)
194
+ end
195
+ end
196
+
197
+ def verify_resource_milestone!(resource)
198
+ title = eval_present_value(:milestone, resource)
199
+ return unless title
200
+
201
+ resolve_milestone_id(resource[policy.source_id_sym], title, source: policy.source)
202
+ end
203
+
204
+ # A skipped move warns instead of writing, so there is nothing to check.
205
+ def verify_resource_destination!(resource)
206
+ return unless actions[:move] && issues_only?
207
+
208
+ resolve_project_id(eval_value(actions[:move], resource))
209
+ end
210
+
211
+ def requested_labels(action_key, resource)
212
+ return [] unless actions[action_key]
213
+
214
+ eval_labels(actions[action_key], resource)
215
+ end
216
+
217
+ def apply_users!(body, resource)
218
+ USER_PARAMS.each do |action_key, (param, error_class)|
219
+ next unless actions[action_key]
220
+
221
+ usernames = eval_usernames(actions[action_key], resource)
222
+ next if usernames.empty? && Array(actions[action_key]).any?
223
+
224
+ body[param] = resolve_user_ids(usernames, error_class)
225
+ end
226
+ end
227
+
228
+ # Removals take the ids straight off the resource, so a conditional entry that
229
+ # evaluates to nothing removes nobody instead of clearing the whole list.
230
+ # A removal naming nobody on the resource writes nothing at all, rather
231
+ # than PUTting the list back unchanged.
232
+ def apply_user_removals!(body, resource)
233
+ REMOVE_USER_PARAMS.each do |action_key, (param, resource_key)|
234
+ next unless actions[action_key]
235
+
236
+ # assignees: and reviewers: write the whole list, so when the rule
237
+ # carries one of those too it has already said who ends up on the
238
+ # resource and there is nothing left to take off.
239
+ next if body.key?(param)
240
+
241
+ current = Array(resource[resource_key])
242
+ next if current.empty?
243
+
244
+ removed = eval_usernames(actions[action_key], resource)
245
+ remaining = current.reject { |user| removed.include?(user[:username]) }
246
+ next if remaining.size == current.size
247
+
248
+ body[param] = remaining.map { |user| user.fetch(:id) }
249
+ end
250
+ end
251
+
252
+ # An entry may interpolate to a comma-separated list, so a policy can compute
253
+ # the whole list from the resource rather than spelling out every username.
254
+ def eval_usernames(usernames, resource)
255
+ Array(usernames)
256
+ .flat_map { |username| eval_value(username, resource).split(',') }
257
+ .map(&:strip)
258
+ .reject(&:empty?)
259
+ end
260
+
261
+ def apply_milestone!(body, resource)
262
+ title = eval_present_value(:milestone, resource)
263
+ return unless title
264
+
265
+ body[:milestone_id] = resolve_milestone_id(
266
+ resource[policy.source_id_sym],
267
+ title,
268
+ source: policy.source
269
+ )
270
+ end
271
+
272
+ def apply_simple_fields!(body, resource)
273
+ apply_interpolated_field!(body, :due_date, resource)
274
+ apply_interpolated_field!(body, :title, resource)
275
+ apply_weight!(body)
276
+ body[:state_event] = actions[:status] if actions[:status]
277
+ body[:discussion_locked] = actions[:discussion_locked] if actions.key?(:discussion_locked)
278
+ end
279
+
280
+ def apply_weight!(body)
281
+ return unless actions.key?(:weight)
282
+
283
+ unless issues_only?
284
+ puts Gitlab::Triage::UI.warn "weight is only available for issues. It will NOT be set\n\n"
285
+ return
286
+ end
287
+
288
+ body[:weight] = actions[:weight]
289
+ end
290
+
291
+ def apply_interpolated_field!(body, key, resource)
292
+ value = eval_present_value(key, resource)
293
+ body[key] = value if value
294
+ end
295
+
296
+ def actions
297
+ policy.actions
298
+ end
299
+
300
+ # weight, health_status and move have no counterpart on merge requests or epics.
301
+ def issues_only?
302
+ policy.type == 'issues'
303
+ end
304
+
305
+ def update_requested?
306
+ Policies::BasePolicy::UPDATE_ACTION_KEYS.any? { |k| actions.key?(k) }
307
+ end
308
+
309
+ def validate_status!
310
+ return if VALID_STATUS_VALUES.include?(actions[:status].to_s)
311
+
312
+ raise ArgumentError,
313
+ "Invalid status value '#{actions[:status]}'. " \
314
+ "Supported: #{VALID_STATUS_VALUES.join(', ')}."
315
+ end
316
+
317
+ # An entry may interpolate to a comma-separated list, or to nothing so a
318
+ # policy can make a label conditional on the resource.
319
+ def eval_labels(labels, resource)
320
+ Array(labels)
321
+ .flat_map { |label| eval_value(label, resource).split(',') }
322
+ .map(&:strip)
323
+ .reject(&:empty?)
324
+ end
325
+
326
+ def join_labels(labels)
327
+ labels.join(',')
328
+ end
329
+
330
+ # An entry that interpolates to nothing leaves the field alone, the way
331
+ # labels and usernames already do. The quick action this replaces ignored
332
+ # an empty value, while an empty PUT param clears the field instead.
333
+ def eval_present_value(key, resource)
334
+ return unless actions[key]
335
+
336
+ value = eval_value(actions[key], resource)
337
+ value unless value.empty?
338
+ end
339
+
340
+ # Without a resource there is nothing to interpolate against, so the raw
341
+ # template is returned. Dry-run output shows it verbatim.
342
+ def eval_value(value, resource)
343
+ return value unless resource
344
+
345
+ # rubocop:disable Style/DocumentDynamicEvalDefinition
346
+ Resource::Context.build(
347
+ resource,
348
+ network: network,
349
+ redact_confidentials: actions.fetch(:redact_confidential_resources, true)
350
+ ).eval("%Q{#{value}}")
351
+ # rubocop:enable Style/DocumentDynamicEvalDefinition
352
+ end
353
+
354
+ def ensure_labels_exist!(labels, resource)
355
+ source_id_key = resource.key?(:group_id) ? :group_id : :project_id
356
+
357
+ super(labels, resource[source_id_key], source_id_key: source_id_key)
358
+ end
359
+
360
+ def move_resource(resource)
361
+ unless issues_only?
362
+ puts Gitlab::Triage::UI.warn "move is only available for issues. It will NOT be performed\n\n"
363
+ return
364
+ end
365
+
366
+ destination = eval_value(actions[:move], resource)
367
+ destination_id = resolve_project_id(destination)
368
+ network.post_api(resource_url(resource, sub_resource_type: 'move'), { to_project_id: destination_id })
369
+ end
370
+
371
+ def resolve_project_id(path)
372
+ options = network.options
373
+ url = "#{options.host_url}/api/#{options.api_version}/projects/#{CGI.escape(path)}"
374
+ project = network.query_api_cached(url).first
375
+ raise "Project `#{path}` doesn't exist!" unless project&.dig(:id)
376
+
377
+ project[:id]
378
+ end
379
+
380
+ def resource_url(resource, sub_resource_type: nil)
381
+ UrlBuilders::UrlBuilder.new(
382
+ network_options: network.options,
383
+ source: policy.source,
384
+ source_id: resource[policy.source_id_sym],
385
+ resource_type: policy.type,
386
+ resource_id: resource['iid'],
387
+ sub_resource_type: sub_resource_type
388
+ ).build
389
+ end
390
+
391
+ # The endpoint can return success without persisting anything when the token
392
+ # lacks permission, so the echoed resource is checked against what was asked for.
393
+ def verify_applied!(resource, response)
394
+ return unless response.is_a?(Hash)
395
+
396
+ verify_state!(resource, response) if actions[:status]
397
+ verify_labels_applied!(resource, response) if actions[:labels]
398
+ verify_assignees_applied!(resource, response) if actions[:assignees]
399
+ verify_reviewers_applied!(resource, response) if actions[:reviewers]
400
+ end
401
+
402
+ def verify_labels_applied!(resource, response)
403
+ return if response[:labels].nil?
404
+
405
+ missing = eval_labels(actions[:labels], resource) - response[:labels]
406
+ return if missing.empty?
407
+
408
+ raise UpdateNotAppliedError,
409
+ "Labels #{missing.join(', ')} were not applied to #{resource[:web_url]}; " \
410
+ "the token may lack permission."
411
+ end
412
+
413
+ def verify_assignees_applied!(resource, response)
414
+ return if response[:assignees].nil?
415
+
416
+ missing = missing_usernames(actions[:assignees], resource, response[:assignees])
417
+ return if missing.empty?
418
+
419
+ raise UpdateNotAppliedError,
420
+ "Assignees #{missing.join(', ')} were not assigned to #{resource[:web_url]}; " \
421
+ "the token may lack permission."
422
+ end
423
+
424
+ def verify_reviewers_applied!(resource, response)
425
+ return if response[:reviewers].nil?
426
+
427
+ missing = missing_usernames(actions[:reviewers], resource, response[:reviewers])
428
+ return if missing.empty?
429
+
430
+ raise UpdateNotAppliedError,
431
+ "Reviewers #{missing.join(', ')} were not requested on #{resource[:web_url]}; " \
432
+ "the token may lack permission, or the users may not have access to it."
433
+ end
434
+
435
+ # The users lookup ignores case, so the response can spell a username
436
+ # differently from the rule.
437
+ def missing_usernames(requested, resource, returned_users)
438
+ returned = returned_users.map { |user| user.fetch(:username).downcase }
439
+ eval_usernames(requested, resource).reject { |username| returned.include?(username.downcase) }
440
+ end
441
+
442
+ def verify_state!(resource, response)
443
+ expected = actions[:status] == 'close' ? 'closed' : 'opened'
444
+ actual = response[:state]
445
+ return if actual.nil? || actual == expected
446
+
447
+ raise UpdateNotAppliedError,
448
+ "State was not updated to '#{expected}' for #{resource[:web_url]}; " \
449
+ "the token may lack permission."
450
+ end
451
+ end
452
+ end
453
+ end
454
+ end
@@ -19,7 +19,6 @@ module Gitlab
19
19
  # applying nothing - which is especially important because clearing the
20
20
  # assignees array would unassign everyone.
21
21
  class WorkItem < Base
22
- AssigneeDoesntExistError = Class.new(StandardError)
23
22
  UpdateNotAppliedError = Class.new(StandardError)
24
23
 
25
24
  # The dry run must not touch the network. It prints the requested
@@ -113,26 +112,6 @@ module Gitlab
113
112
  end
114
113
  end
115
114
 
116
- # Resolve every requested username to an id. A missing username raises
117
- # rather than being dropped, because an empty assignee_ids array would
118
- # clear all assignees on the work item.
119
- def resolve_assignee_ids(usernames)
120
- return [] if usernames.empty?
121
-
122
- usernames.map do |username|
123
- id = resolve_user_id(username)
124
- raise AssigneeDoesntExistError, "User `#{username}` doesn't exist!" unless id
125
-
126
- id
127
- end
128
- end
129
-
130
- def resolve_user_id(username)
131
- users = network.query_api(users_url(username))
132
-
133
- users.first&.dig(:id)
134
- end
135
-
136
115
  # The Work Items REST update endpoint can return success without
137
116
  # persisting a change when the token lacks permission. The response
138
117
  # echoes the resulting widgets, so confirm the requested ids landed and
@@ -171,12 +150,6 @@ module Gitlab
171
150
  params: { per_page: 100 }
172
151
  ).build
173
152
  end
174
-
175
- def users_url(username)
176
- options = network.options
177
-
178
- "#{options.host_url}/api/#{options.api_version}/users?username=#{CGI.escape(username)}"
179
- end
180
153
  end
181
154
  end
182
155
  end
@@ -7,6 +7,7 @@ require_relative 'action/issue'
7
7
  require_relative 'action/delete'
8
8
  require_relative 'action/work_item_status'
9
9
  require_relative 'action/work_item'
10
+ require_relative 'action/update'
10
11
 
11
12
  module Gitlab
12
13
  module Triage
@@ -14,8 +15,22 @@ module Gitlab
14
15
  def self.process(policy:, **args)
15
16
  policy.validate!
16
17
 
17
- actions_for(policy).each do |action, active|
18
- act(action: action, policy: policy, **args) if active
18
+ actions = actions_for(policy).select { |_action, active| active }.map(&:first)
19
+
20
+ verify!(actions, policy: policy, network: args[:network])
21
+
22
+ actions.each do |action|
23
+ act(action: action, policy: policy, **args)
24
+ end
25
+ end
26
+
27
+ # Comment runs before the actions that write labels, so without this a
28
+ # rule naming a label, user, milestone, or move destination that no
29
+ # longer exists posts its note and only then raises, and the next
30
+ # scheduled run posts it again.
31
+ def self.verify!(actions, policy:, network:)
32
+ actions.each do |action|
33
+ action.new(policy: policy, network: network).verify!
19
34
  end
20
35
  end
21
36
 
@@ -34,6 +49,7 @@ module Gitlab
34
49
  [
35
50
  [Summarize, policy.summarize?],
36
51
  [Comment, policy.comment?],
52
+ [Update, policy.update?],
37
53
  [CommentOnSummary, policy.comment_on_summary?],
38
54
  [Issue, policy.issue?],
39
55
  [Delete, policy.delete?],
@@ -8,10 +8,6 @@ module Gitlab
8
8
  class CcCommandBuilder < BaseCommandBuilder
9
9
  private
10
10
 
11
- def slash_command_string
12
- "/cc"
13
- end
14
-
15
11
  def format_item(item)
16
12
  "@#{item}"
17
13
  end
@@ -240,6 +240,10 @@ module Gitlab
240
240
  rule.fetch(:conditions) { {} }
241
241
  end
242
242
 
243
+ def rule_actions(rule)
244
+ rule.fetch(:actions) { {} }
245
+ end
246
+
243
247
  def rule_limits(rule)
244
248
  rule.fetch(:limits) { {} }
245
249
  end
@@ -456,7 +460,7 @@ module Gitlab
456
460
  graphql_query_options[:iids] = expanded_conditions[:iids]
457
461
  end
458
462
 
459
- graphql_query = build_graphql_query(resource_type, expanded_conditions, true)
463
+ graphql_query = build_graphql_query(resource_type, expanded_conditions, rule_definition, true)
460
464
 
461
465
  resources = graphql_network.query(graphql_query, **graphql_query_options)
462
466
  else
@@ -482,7 +486,7 @@ module Gitlab
482
486
  iids = resources.pluck('iid').map(&:to_s)
483
487
  expanded_conditions[:iids] = iids
484
488
 
485
- graphql_query = build_graphql_query(resource_type, expanded_conditions)
489
+ graphql_query = build_graphql_query(resource_type, expanded_conditions, rule_definition)
486
490
  graphql_resources = graphql_network.query(graphql_query, source: source_full_path, iids: iids) if graphql_query.any?
487
491
 
488
492
  decorate_resources_with_graphql_data(resources, graphql_resources)
@@ -879,9 +883,14 @@ module Gitlab
879
883
  status_by_iid
880
884
  end
881
885
 
882
- def build_graphql_query(resource_type, conditions, graphql_only = false)
886
+ def build_graphql_query(resource_type, conditions, rule_definition, graphql_only = false)
883
887
  Gitlab::Triage::GraphqlQueries::QueryBuilder
884
- .new(options.source, resource_type, conditions, graphql_only: graphql_only)
888
+ .new(
889
+ options.source,
890
+ resource_type,
891
+ conditions,
892
+ graphql_only: graphql_only,
893
+ actions: rule_actions(rule_definition))
885
894
  end
886
895
 
887
896
  def source_full_path
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../command_builders/text_content_builder'
4
+ require_relative 'issue_fields'
4
5
 
5
6
  module Gitlab
6
7
  module Triage
7
8
  module EntityBuilders
8
9
  class IssueBuilder
10
+ include IssueFields
11
+
9
12
  attr_reader :destination
10
13
 
11
14
  def initialize(
@@ -22,6 +25,7 @@ module Gitlab
22
25
  @resource = resource
23
26
  @network = network
24
27
  @separator = separator
28
+ init_issue_fields(action)
25
29
  end
26
30
 
27
31
  def title
@@ -38,6 +42,10 @@ module Gitlab
38
42
 
39
43
  private
40
44
 
45
+ def evaluate(template)
46
+ build_text(template)
47
+ end
48
+
41
49
  def title_present?
42
50
  /\S+/.match?(title)
43
51
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitlab
4
+ module Triage
5
+ module EntityBuilders
6
+ module IssueFields
7
+ def labels
8
+ evaluate_entries(@labels)
9
+ end
10
+
11
+ def assignee_usernames
12
+ evaluate_entries(@assignee_usernames)
13
+ end
14
+
15
+ def due_date
16
+ evaluate_present(@due_date)
17
+ end
18
+
19
+ def milestone_title
20
+ evaluate_present(@milestone_title)
21
+ end
22
+
23
+ def confidential?
24
+ return @confidential if @confidential == true || @confidential == false
25
+ return false unless @confidential
26
+
27
+ evaluate(@confidential.to_s).strip == 'true'
28
+ end
29
+
30
+ private
31
+
32
+ # Returns one value per label or username. An entry may interpolate to
33
+ # a comma-separated list so a policy can compute a list whose length
34
+ # varies, and an entry that comes out empty is dropped. The caller
35
+ # treats an empty result as "the policy named nothing here", so a
36
+ # conditional entry leaves the field off the request rather than
37
+ # clearing it.
38
+ def evaluate_entries(templates)
39
+ Array(templates)
40
+ .flat_map { |template| evaluate(template).split(',') }
41
+ .map(&:strip)
42
+ .reject(&:empty?)
43
+ end
44
+
45
+ def init_issue_fields(action)
46
+ @labels = action[:labels]
47
+ @assignee_usernames = action[:assignees]
48
+ @due_date = action[:due_date]
49
+ @milestone_title = action[:milestone]
50
+ @confidential = action[:confidential]
51
+ end
52
+
53
+ # An empty string is truthy, so a template that interpolates to nothing
54
+ # would pass the caller's guard and write an empty value. Nil leaves the
55
+ # field alone instead.
56
+ def evaluate_present(template)
57
+ return unless template
58
+
59
+ value = evaluate(template)
60
+ value unless value.empty?
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end