gitlab-triage 1.53.1 → 1.54.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -0
  3. data/Gemfile.lock +4 -3
  4. data/README.md +159 -1
  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 +31 -3
  9. data/lib/gitlab/triage/action/summarize.rb +21 -1
  10. data/lib/gitlab/triage/action/update.rb +432 -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/entity_builders/issue_builder.rb +8 -0
  15. data/lib/gitlab/triage/entity_builders/issue_fields.rb +52 -0
  16. data/lib/gitlab/triage/entity_builders/summary_builder.rb +8 -0
  17. data/lib/gitlab/triage/graphql_network.rb +12 -2
  18. data/lib/gitlab/triage/network.rb +4 -0
  19. data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +24 -0
  20. data/lib/gitlab/triage/network_adapters/test_adapter.rb +8 -0
  21. data/lib/gitlab/triage/policies/base_policy.rb +10 -2
  22. data/lib/gitlab/triage/policies/rule_policy.rb +18 -8
  23. data/lib/gitlab/triage/policies/summary_policy.rb +9 -0
  24. data/lib/gitlab/triage/rest_api_network.rb +22 -0
  25. data/lib/gitlab/triage/validators/policy_validator.rb +1 -3
  26. data/lib/gitlab/triage/version.rb +1 -1
  27. metadata +4 -6
  28. data/lib/gitlab/triage/command_builders/label_command_builder.rb +0 -42
  29. data/lib/gitlab/triage/command_builders/move_command_builder.rb +0 -21
  30. data/lib/gitlab/triage/command_builders/remove_label_command_builder.rb +0 -17
  31. data/lib/gitlab/triage/command_builders/status_command_builder.rb +0 -25
@@ -0,0 +1,432 @@
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 an empty string, so a policy can make a
318
+ # label conditional on the resource.
319
+ def eval_labels(labels, resource)
320
+ Array(labels).map { |label| eval_value(label, resource) }.reject(&:empty?)
321
+ end
322
+
323
+ def join_labels(labels)
324
+ labels.join(',')
325
+ end
326
+
327
+ # An entry that interpolates to nothing leaves the field alone, the way
328
+ # labels and usernames already do. The quick action this replaces ignored
329
+ # an empty value, while an empty PUT param clears the field instead.
330
+ def eval_present_value(key, resource)
331
+ return unless actions[key]
332
+
333
+ value = eval_value(actions[key], resource)
334
+ value unless value.empty?
335
+ end
336
+
337
+ # Without a resource there is nothing to interpolate against, so the raw
338
+ # template is returned. Dry-run output shows it verbatim.
339
+ def eval_value(value, resource)
340
+ return value unless resource
341
+
342
+ # rubocop:disable Style/DocumentDynamicEvalDefinition
343
+ Resource::Context.build(
344
+ resource,
345
+ network: network,
346
+ redact_confidentials: actions.fetch(:redact_confidential_resources, true)
347
+ ).eval("%Q{#{value}}")
348
+ # rubocop:enable Style/DocumentDynamicEvalDefinition
349
+ end
350
+
351
+ def ensure_labels_exist!(labels, resource)
352
+ source_id_key = resource.key?(:group_id) ? :group_id : :project_id
353
+
354
+ super(labels, resource[source_id_key], source_id_key: source_id_key)
355
+ end
356
+
357
+ def move_resource(resource)
358
+ unless issues_only?
359
+ puts Gitlab::Triage::UI.warn "move is only available for issues. It will NOT be performed\n\n"
360
+ return
361
+ end
362
+
363
+ destination = eval_value(actions[:move], resource)
364
+ destination_id = resolve_project_id(destination)
365
+ network.post_api(resource_url(resource, sub_resource_type: 'move'), { to_project_id: destination_id })
366
+ end
367
+
368
+ def resolve_project_id(path)
369
+ options = network.options
370
+ url = "#{options.host_url}/api/#{options.api_version}/projects/#{CGI.escape(path)}"
371
+ project = network.query_api_cached(url).first
372
+ raise "Project `#{path}` doesn't exist!" unless project&.dig(:id)
373
+
374
+ project[:id]
375
+ end
376
+
377
+ def resource_url(resource, sub_resource_type: nil)
378
+ UrlBuilders::UrlBuilder.new(
379
+ network_options: network.options,
380
+ source: policy.source,
381
+ source_id: resource[policy.source_id_sym],
382
+ resource_type: policy.type,
383
+ resource_id: resource['iid'],
384
+ sub_resource_type: sub_resource_type
385
+ ).build
386
+ end
387
+
388
+ # The endpoint can return success without persisting anything when the token
389
+ # lacks permission, so the echoed resource is checked against what was asked for.
390
+ def verify_applied!(resource, response)
391
+ return unless response.is_a?(Hash)
392
+
393
+ verify_state!(resource, response) if actions[:status]
394
+ verify_labels_applied!(resource, response) if actions[:labels]
395
+ verify_assignees_applied!(resource, response) if actions[:assignees]
396
+ end
397
+
398
+ def verify_labels_applied!(resource, response)
399
+ return if response[:labels].nil?
400
+
401
+ missing = eval_labels(actions[:labels], resource) - response[:labels]
402
+ return if missing.empty?
403
+
404
+ raise UpdateNotAppliedError,
405
+ "Labels #{missing.join(', ')} were not applied to #{resource[:web_url]}; " \
406
+ "the token may lack permission."
407
+ end
408
+
409
+ def verify_assignees_applied!(resource, response)
410
+ return if response[:assignees].nil?
411
+
412
+ missing = eval_usernames(actions[:assignees], resource) - response[:assignees].map { |user| user.fetch(:username) }
413
+ return if missing.empty?
414
+
415
+ raise UpdateNotAppliedError,
416
+ "Assignees #{missing.join(', ')} were not assigned to #{resource[:web_url]}; " \
417
+ "the token may lack permission."
418
+ end
419
+
420
+ def verify_state!(resource, response)
421
+ expected = actions[:status] == 'close' ? 'closed' : 'opened'
422
+ actual = response[:state]
423
+ return if actual.nil? || actual == expected
424
+
425
+ raise UpdateNotAppliedError,
426
+ "State was not updated to '#{expected}' for #{resource[:web_url]}; " \
427
+ "the token may lack permission."
428
+ end
429
+ end
430
+ end
431
+ end
432
+ 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
@@ -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,52 @@
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
+ private
24
+
25
+ # Returns the entries the policy author wrote with their templates
26
+ # interpolated, dropping any that came out empty. The caller treats an
27
+ # empty result as "the policy named nothing here", so a conditional entry
28
+ # leaves the field off the request rather than clearing it.
29
+ def evaluate_entries(templates)
30
+ Array(templates).map { |template| evaluate(template) }.reject(&:empty?)
31
+ end
32
+
33
+ def init_issue_fields(action)
34
+ @labels = action[:labels]
35
+ @assignee_usernames = action[:assignees]
36
+ @due_date = action[:due_date]
37
+ @milestone_title = action[:milestone]
38
+ end
39
+
40
+ # An empty string is truthy, so a template that interpolates to nothing
41
+ # would pass the caller's guard and write an empty value. Nil leaves the
42
+ # field alone instead.
43
+ def evaluate_present(template)
44
+ return unless template
45
+
46
+ value = evaluate(template)
47
+ value unless value.empty?
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -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 SummaryBuilder
10
+ include IssueFields
11
+
9
12
  def initialize(
10
13
  type:, action:, resources:, network:,
11
14
  policy_spec: {}, separator: "\n")
@@ -21,6 +24,7 @@ module Gitlab
21
24
  @network = network
22
25
  @separator = separator
23
26
  @is_custom = policy_spec[:type] == 'custom'
27
+ init_issue_fields(action)
24
28
  end
25
29
 
26
30
  def title
@@ -53,6 +57,10 @@ module Gitlab
53
57
 
54
58
  private
55
59
 
60
+ def evaluate(template)
61
+ build_text(title_resource, template)
62
+ end
63
+
56
64
  def title_present?
57
65
  /\S+/.match?(title)
58
66
  end
@@ -76,9 +76,19 @@ module Gitlab
76
76
  .slice(:iid, :title, :state, :author, :merged_at, :user_notes_count, :user_discussions_count, :upvotes, :downvotes, :project_id, :web_url)
77
77
  .merge(
78
78
  id: extract_id_from_global_id(resource[:id]),
79
- labels: [*resource.dig(:labels, :nodes)].pluck(:title),
80
- assignees: [*resource.dig(:assignees, :nodes)]
79
+ labels: [*resource.dig(:labels, :nodes)].pluck(:title)
81
80
  )
81
+ .merge(normalize_assignees(resource))
82
+ end
83
+
84
+ # The assignees are only queried for an assignee_member condition, and the
85
+ # caller merges what comes back over the REST resource. An empty list here
86
+ # would wipe the assignees REST already returned.
87
+ def normalize_assignees(resource)
88
+ nodes = resource.dig(:assignees, :nodes)
89
+ return {} unless nodes
90
+
91
+ { assignees: nodes.map { |node| node.merge(id: extract_id_from_global_id(node[:id])) } }
82
92
  end
83
93
 
84
94
  def extract_id_from_global_id(global_id)
@@ -41,6 +41,10 @@ module Gitlab
41
41
  restapi.patch_api(...)
42
42
  end
43
43
 
44
+ def put_api(...)
45
+ restapi.put_api(...)
46
+ end
47
+
44
48
  def delete_api(...)
45
49
  restapi.delete_api(...)
46
50
  end