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.
- checksums.yaml +4 -4
- data/Gemfile +3 -0
- data/Gemfile.lock +4 -3
- data/README.md +159 -1
- data/lib/gitlab/triage/action/base.rb +62 -0
- data/lib/gitlab/triage/action/comment.rb +1 -9
- data/lib/gitlab/triage/action/comment_on_summary.rb +0 -6
- data/lib/gitlab/triage/action/issue.rb +31 -3
- data/lib/gitlab/triage/action/summarize.rb +21 -1
- data/lib/gitlab/triage/action/update.rb +432 -0
- data/lib/gitlab/triage/action/work_item.rb +0 -27
- data/lib/gitlab/triage/action.rb +18 -2
- data/lib/gitlab/triage/command_builders/cc_command_builder.rb +0 -4
- data/lib/gitlab/triage/entity_builders/issue_builder.rb +8 -0
- data/lib/gitlab/triage/entity_builders/issue_fields.rb +52 -0
- data/lib/gitlab/triage/entity_builders/summary_builder.rb +8 -0
- data/lib/gitlab/triage/graphql_network.rb +12 -2
- data/lib/gitlab/triage/network.rb +4 -0
- data/lib/gitlab/triage/network_adapters/httparty_adapter.rb +24 -0
- data/lib/gitlab/triage/network_adapters/test_adapter.rb +8 -0
- data/lib/gitlab/triage/policies/base_policy.rb +10 -2
- data/lib/gitlab/triage/policies/rule_policy.rb +18 -8
- data/lib/gitlab/triage/policies/summary_policy.rb +9 -0
- data/lib/gitlab/triage/rest_api_network.rb +22 -0
- data/lib/gitlab/triage/validators/policy_validator.rb +1 -3
- data/lib/gitlab/triage/version.rb +1 -1
- metadata +4 -6
- data/lib/gitlab/triage/command_builders/label_command_builder.rb +0 -42
- data/lib/gitlab/triage/command_builders/move_command_builder.rb +0 -21
- data/lib/gitlab/triage/command_builders/remove_label_command_builder.rb +0 -17
- 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
|
data/lib/gitlab/triage/action.rb
CHANGED
|
@@ -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).
|
|
18
|
-
|
|
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?],
|
|
@@ -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)
|