standard_automation_library 0.2.1.pre.temp

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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/lib/standard_automation_library/api_clients/app_center.rb +104 -0
  3. data/lib/standard_automation_library/api_clients/bugsnag.rb +94 -0
  4. data/lib/standard_automation_library/api_clients/github.rb +371 -0
  5. data/lib/standard_automation_library/api_clients/jira.rb +326 -0
  6. data/lib/standard_automation_library/api_clients/pagerduty.rb +101 -0
  7. data/lib/standard_automation_library/api_clients/slack.rb +499 -0
  8. data/lib/standard_automation_library/danger/danger_jira.rb +169 -0
  9. data/lib/standard_automation_library/errors/slack_api_error.rb +6 -0
  10. data/lib/standard_automation_library/personnel/release_management_team.rb +85 -0
  11. data/lib/standard_automation_library/personnel/team.rb +41 -0
  12. data/lib/standard_automation_library/personnel/user.rb +68 -0
  13. data/lib/standard_automation_library/services/bugsnag_service.rb +251 -0
  14. data/lib/standard_automation_library/services/jira_service.rb +64 -0
  15. data/lib/standard_automation_library/services/merge_driver_service.rb +48 -0
  16. data/lib/standard_automation_library/services/mobile_tech_debt_logging_service.rb +176 -0
  17. data/lib/standard_automation_library/services/monorepo_platform_service.rb +18 -0
  18. data/lib/standard_automation_library/services/perf_tracker_logging_service.rb +87 -0
  19. data/lib/standard_automation_library/services/platform_service.rb +34 -0
  20. data/lib/standard_automation_library/services/repo_service.rb +17 -0
  21. data/lib/standard_automation_library/services/slack_service.rb +383 -0
  22. data/lib/standard_automation_library/util/automerge_configuration.rb +134 -0
  23. data/lib/standard_automation_library/util/bundler.rb +18 -0
  24. data/lib/standard_automation_library/util/datetime_helper.rb +23 -0
  25. data/lib/standard_automation_library/util/file_content.rb +15 -0
  26. data/lib/standard_automation_library/util/git.rb +235 -0
  27. data/lib/standard_automation_library/util/git_merge_error_message_cleaner.rb +27 -0
  28. data/lib/standard_automation_library/util/network.rb +39 -0
  29. data/lib/standard_automation_library/util/path_container.rb +17 -0
  30. data/lib/standard_automation_library/util/platform_picker.rb +150 -0
  31. data/lib/standard_automation_library/util/shared_constants.rb +27 -0
  32. data/lib/standard_automation_library/util/shell_helper.rb +54 -0
  33. data/lib/standard_automation_library/util/slack_constants.rb +40 -0
  34. data/lib/standard_automation_library/util/version.rb +31 -0
  35. data/lib/standard_automation_library/version.rb +5 -0
  36. data/lib/standard_automation_library.rb +8 -0
  37. metadata +296 -0
@@ -0,0 +1,499 @@
1
+ require 'slack-ruby-client'
2
+ require 'yaml'
3
+ require 'set'
4
+
5
+ require_relative '../util/path_container'
6
+ require_relative '../errors/slack_api_error'
7
+
8
+ # Slack client to do what fastlane can't. Client used to send
9
+ # messages so they can subsequently be updated.
10
+ class SlackClient
11
+ NOT_FOUND = 'NOT_FOUND'.freeze
12
+
13
+ def initialize(api_token, userid_mapping_file_path_container = nil, valid_email_suffixes = [], is_dry_run: false)
14
+ @client = Slack::Web::Client.new(token: api_token)
15
+ @userid_mapping_file_path_container = userid_mapping_file_path_container
16
+ @valid_email_suffixes = valid_email_suffixes
17
+ @is_dry_run = is_dry_run
18
+ @team_domain = nil
19
+ @channel_name_id_cache = {}
20
+ @user_id_cache = {}
21
+ end
22
+
23
+ attr_accessor :client
24
+
25
+ def team_domain
26
+ @team_domain ||= @client.team_info['team']['domain']
27
+ @team_domain
28
+ end
29
+
30
+ # Attempt to find user_ids from a list of names or display names.
31
+ # This method will return a list of the same size with the
32
+ # resolved user_ids in the same index position(s) in the supplied list.
33
+ # Slack IDs that could not be found or are ambiguous will have a nil output in the returned lists.
34
+ # Entries from the userid_mapping_file will take precedence.
35
+ #
36
+ # This method uses a cache and tries very hard to only iterate through every user in the workspace
37
+ # a only single time and only if necessary. Iterating through all users in a workspace is very slow.
38
+ # Use user_ids_from_emails for faster performance if possible.
39
+ def user_ids_from_names(names)
40
+ load_cache_from_userid_map
41
+
42
+ output_user_ids = Array.new(names.size)
43
+
44
+ normalized_names = names.map { |name| safe_unicode_normalize(name).downcase }
45
+
46
+ # Get the list of names that aren't in the cache.
47
+ # Put the user_ids that are in the cache into their final places in the output array.
48
+ cache_miss_names = normalized_names.each_with_index.map do |name, index|
49
+ cached_user_id = @user_id_cache[name]
50
+ if cached_user_id
51
+ output_user_ids[index] = cached_user_id
52
+ nil
53
+ else
54
+ name
55
+ end
56
+ end.compact
57
+
58
+ unless cache_miss_names.empty?
59
+ cache_miss_names_hash = cache_miss_names.each_with_object({}) { |element, hash| hash[element] = nil }
60
+
61
+ @client.users_list(limit: 1000) do |response_page|
62
+ raise "Error getting slack user list: #{response_page['error']}" unless response_page['ok']
63
+
64
+ response_page['members'].each do |member_json|
65
+ next if member_json['deleted']
66
+
67
+ profile_json = member_json['profile']
68
+
69
+ # If the name of the person we're interating on is one of interest then attempt to put the id in the hash
70
+ # If the id has already been added then we know we've found an ambiguous name so we mark that with a value
71
+ # of -1
72
+ real_name = profile_json['real_name'].downcase
73
+ display_name = profile_json['display_name'].downcase
74
+ if cache_miss_names_hash.key?(real_name)
75
+ cache_miss_names_hash[real_name] = if cache_miss_names_hash[real_name].nil?
76
+ member_json['id']
77
+ else
78
+ -1
79
+ end
80
+ elsif cache_miss_names_hash.key?(display_name)
81
+ current_cache_value = cache_miss_names_hash[display_name]
82
+ cache_miss_names_hash[display_name] = if current_cache_value.nil?
83
+ member_json['id']
84
+ else
85
+ -1
86
+ end
87
+ end
88
+ end
89
+ end
90
+
91
+ cache_miss_names_hash.each do |key, value|
92
+ next if value.nil? || value == -1
93
+
94
+ # indexes of matching elements in the input list
95
+ indexes = normalized_names.map.with_index { |_val, i| i }.select do |i|
96
+ normalized_names[i] == key
97
+ end
98
+ indexes.each { |i| output_user_ids[i] = value }
99
+ @user_id_cache[key] = value
100
+ end
101
+ end
102
+
103
+ output_user_ids
104
+ end
105
+
106
+ # This method expects the userid_mapping_file_path be a yaml file with a certain structure.
107
+ # A toplevel map where each key is the domain name of the workspace. The value of that is
108
+ # another map where the keys are identifiers (names, email addresses, etc) and the values
109
+ # are slack userids.
110
+ def load_cache_from_userid_map
111
+ userid_mapping_file_path = @userid_mapping_file_path_container&.path
112
+ return if userid_mapping_file_path.nil?
113
+
114
+ all_slack_workspaces = File.open(userid_mapping_file_path) { |f| YAML.safe_load(f) }
115
+ mapping_for_team = all_slack_workspaces.fetch(team_domain, [])
116
+ mapping_for_team.each do |key, value|
117
+ @user_id_cache[key.downcase] = value
118
+ end
119
+ end
120
+
121
+ # Finds user id's via email lookups. Much faster than searching for someone
122
+ # by name. Returns an array of the same length. This array contains resolved
123
+ # user_ids or nil in the same index of the array for the input email address.
124
+ def user_ids_from_emails(email_addresses)
125
+ load_cache_from_userid_map
126
+ email_addresses.map do |email_address|
127
+ cache_key = email_address.downcase
128
+ if @user_id_cache[cache_key]
129
+ @user_id_cache[cache_key]
130
+ else
131
+ begin
132
+ user_id = @client.users_lookupByEmail(email: email_address)['user']['id']
133
+ @user_id_cache[cache_key] = user_id
134
+ user_id
135
+ rescue StandardError => e
136
+ raise e unless e.message.include?('users_not_found')
137
+
138
+ nil
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ # This is the preferred entry point for finding user ids. The input is a list of names or email addresses.
145
+ # This method will return a list the same size as the input list with all possible user_ids resolved.
146
+ # Unresolved user identifiers in the input list will have a nil value in the returned list. Optionally raise an
147
+ # error if the input list does not have the same amount of non-nil outputs as it does inputs.
148
+ #
149
+ # Process input email addresses by searching for both the input email address and emails constructed by
150
+ # appending the valid_suffixes on them. If we get back a single user_id then we found a match.
151
+ #
152
+ # Process non-email addresses by first forming them into email addresses and searching as emails. This is
153
+ # much faster than searching over all users so do this first.
154
+ #
155
+ # If there are still unidentified users then search over all slack users for a match. Only unambiguous matches
156
+ # are returned.
157
+ def user_ids(user_identifiers, error_if_not_found: false)
158
+ load_cache_from_userid_map
159
+
160
+ output_list = Array.new(user_identifiers.size)
161
+
162
+ user_identifiers_downcase = user_identifiers.compact.map(&:downcase)
163
+
164
+ names_email_miss = []
165
+ user_identifiers_downcase.each_with_index do |user_identifier, index|
166
+ if @user_id_cache[user_identifier]
167
+ output_list[index] = @user_id_cache[user_identifier]
168
+ elsif user_identifier.include?('@')
169
+ # process email addresses separately.
170
+ fuzzed_email_addresses = @valid_email_suffixes.map do |email_suffix|
171
+ "#{user_identifier.split('@')[0]}@#{email_suffix}"
172
+ end
173
+ fuzzed_email_addresses << user_identifier
174
+ email_user_ids = user_ids_from_emails(fuzzed_email_addresses.uniq).compact
175
+ output_list[index] = email_user_ids[0] if email_user_ids.size == 1
176
+ else
177
+ email_prefix = name_to_email_prefix(user_identifier)
178
+ name_email_addresses = @valid_email_suffixes.map { |email_suffix| "#{email_prefix}@#{email_suffix}" }
179
+ name_email_user_ids = user_ids_from_emails(name_email_addresses.uniq).compact
180
+ if name_email_user_ids.size == 1
181
+ output_list[index] = name_email_user_ids[0]
182
+ else
183
+ names_email_miss << user_identifier
184
+ end
185
+ end
186
+ end
187
+
188
+ names_email_miss_uniq = names_email_miss.uniq
189
+ name_user_ids = user_ids_from_names(names_email_miss_uniq)
190
+ name_user_ids.each_with_index do |user_id, index|
191
+ next if user_id.nil?
192
+
193
+ name = names_email_miss_uniq[index]
194
+ indexes = user_identifiers_downcase.map.with_index { |_val, i| i }.select do |i|
195
+ user_identifiers_downcase[i] == name
196
+ end
197
+ indexes.each { |i| output_list[i] = user_id }
198
+ end
199
+
200
+ if error_if_not_found
201
+ users_not_found = output_list.each_with_index.map do |user, index|
202
+ user_identifiers_downcase[index] if user.nil?
203
+ end.compact.uniq
204
+ raise "Not all slack user_ids found for input list: #{users_not_found}" unless users_not_found.empty?
205
+ end
206
+ output_list
207
+ end
208
+
209
+ # Format name into an email address prefix and convert special characters to their ascii
210
+ # equivalents since characters like á (which becomes a) don't show up up in email addresses.
211
+ # Also be sure to remove anything that is not lowercase letters and periods from the email
212
+ # prefix.
213
+ def name_to_email_prefix(name)
214
+ normalized_name = safe_unicode_normalize(name, :nfkd).encode('ASCII', replace: '')
215
+ email_prefix = normalized_name.strip.split.map(&:downcase).join('.')
216
+ email_prefix.gsub(/[^a-z.]/, '')
217
+ end
218
+
219
+ # update slack channel topic
220
+ def set_channel_topic(channel_name, new_topic)
221
+ channel_ids = unique_channel_ids([channel_name])
222
+ raise "Cannot find channel with name #{channel_name}" if channel_ids.empty?
223
+
224
+ channel_id = channel_ids[0]
225
+ if @is_dry_run
226
+ puts "Would have set topic of channel #{channel_id} to '#{new_topic}'"
227
+ else
228
+ @client.conversations_setTopic(channel: channel_id, topic: new_topic)
229
+ end
230
+ end
231
+
232
+ # Get a channel's current topic
233
+ def channel_topic(channel_name)
234
+ channel_ids = unique_channel_ids([channel_name])
235
+ raise "Cannot find channel with name #{channel_name}" if channel_ids.empty?
236
+
237
+ channel_id = channel_ids[0]
238
+ response = @client.conversations_info(channel: channel_id)
239
+ response['channel']['topic']['value']
240
+ end
241
+
242
+ def channel_members(channel)
243
+ channel_id = unique_channel_ids([channel])[0]
244
+ raise "Cannot find channel with name #{channel}" if channel_id.nil?
245
+
246
+ all_members = []
247
+ @client.conversations_members(channel: channel_id) do |response_page|
248
+ raise "Error getting slack conversation members: #{response_page['error']}" unless response_page['ok']
249
+
250
+ all_members += response_page['members']
251
+ end
252
+ all_members
253
+ end
254
+
255
+ def invite_to_channel_if_needed(channel, users_to_invite)
256
+ return if users_to_invite.empty?
257
+
258
+ channel_id = unique_channel_ids([channel])[0]
259
+ raise "Cannot find channel with name #{channel}" if channel_id.nil?
260
+
261
+ current_member_ids = channel_members(channel_id)
262
+ member_id_set = Set.new(current_member_ids)
263
+
264
+ users_to_invite.reject! { |user_id| member_id_set.include?(user_id) }
265
+ return if users_to_invite.empty?
266
+
267
+ if @is_dry_run
268
+ puts "Would have invited users #{users_to_invite} to channel #{channel}"
269
+ else
270
+ @client.conversations_invite(channel: channel_id, users: users_to_invite.join(','))
271
+ end
272
+ end
273
+
274
+ # Send a message to a slack channel or user_id.
275
+ # Get user_id from matching_user_ids, user_id_from_email or slack_user_ids in slack_service.
276
+ def send_message(channel_name: nil, user_id: nil, message: nil, attachments: nil, icon_url: nil)
277
+ raise 'Must supply one of new_message or attachments to send a slack message' if message.nil? && attachments.nil?
278
+
279
+ raise 'Must supply one of channel_name or user_id' if channel_name.nil? && user_id.nil?
280
+
281
+ options = {}
282
+ if user_id
283
+ options[:channel] = user_id
284
+ elsif channel_name
285
+ channel_ids = unique_channel_ids([channel_name])
286
+ raise "Cannot find channel with name #{channel_name}" if channel_ids.empty?
287
+
288
+ options[:channel] = channel_ids[0]
289
+ end
290
+ options[:text] = message unless message.nil?
291
+ options[:attachments] = attachments unless attachments.nil?
292
+ options[:icon_url] = icon_url unless icon_url.nil?
293
+
294
+ if @is_dry_run
295
+ puts "Would have sent message with options: #{options}"
296
+ else
297
+ response_json = @client.chat_postMessage(options)
298
+ raise SlackAPIError, "Error sending message: #{response_json['error']}" unless response_json['ok']
299
+ end
300
+ end
301
+
302
+ # Sends an initial message and makes it a thread for the additional messages formatted as Slack API blocks
303
+ # For an example of formatted_reply_blocks see bugsnag_service.make_slack_block_from_bugsnag_error()
304
+ def send_messages_in_thread(channel_name, initial_message, formatted_reply_blocks)
305
+ channel_ids = unique_channel_ids([channel_name])
306
+ raise "Cannot find channel with name #{channel_name}" if channel_ids.empty?
307
+
308
+ initial_options = { channel: channel_ids[0], text: initial_message }
309
+ if @is_dry_run
310
+ puts "Would have sent message with options: #{initial_options}"
311
+ response_json = { ts: 'dry_run' }
312
+ else
313
+ response_json = @client.chat_postMessage(initial_options)
314
+ raise SlackAPIError, "Error sending initial thread message: #{response_json['error']}" unless response_json['ok']
315
+ end
316
+
317
+ formatted_reply_blocks.each do |reply|
318
+ reply_options = {
319
+ channel: channel_name,
320
+ thread_ts: response_json['ts'],
321
+ blocks: reply
322
+ }
323
+
324
+ if @is_dry_run
325
+ puts "Would have sent reply with options: #{reply_options}"
326
+ else
327
+ reply_json = @client.chat_postMessage(reply_options)
328
+ raise "Error sending thread reply: #{reply_json['error']}" unless reply_json['ok']
329
+ end
330
+ end
331
+ end
332
+
333
+ # update previously sent message in a channel.
334
+ # Send new message if previous one isn't found or fail if fail_if_message_not_found.
335
+ def update_or_send_message(
336
+ channel_name,
337
+ message_identifying_content,
338
+ new_message: nil,
339
+ attachments: nil,
340
+ icon_url: nil,
341
+ fail_if_message_not_found: false,
342
+ historical_lookup_count: 100
343
+ )
344
+ error_message = 'Must supply one of new_message or attachments to update a slack messages'
345
+ raise error_message if new_message.nil? && attachments.nil?
346
+
347
+ channel_ids = unique_channel_ids([channel_name])
348
+ channel_id = channel_ids[0]
349
+
350
+ raise "Cannot find channel #{channel_name}" if channel_id.nil?
351
+
352
+ timestamp = old_message_timestamp(
353
+ channel_id,
354
+ message_identifying_content,
355
+ options: { limit: historical_lookup_count }
356
+ )
357
+
358
+ options = {
359
+ channel: channel_id
360
+ }
361
+ options[:text] = new_message unless new_message.nil?
362
+ options[:attachments] = attachments unless attachments.nil?
363
+ error_message = "Slack message with identifying content not found: '#{message_identifying_content}'"
364
+ raise error_message if timestamp.nil? && fail_if_message_not_found
365
+
366
+ if timestamp.nil?
367
+ options[:icon_url] = icon_url unless icon_url.nil?
368
+ if @is_dry_run
369
+ puts "Would have sent message with options: #{options}"
370
+ response_json = { 'ok' => true }
371
+ else
372
+ response_json = @client.chat_postMessage(options)
373
+ end
374
+ else
375
+ options[:ts] = timestamp
376
+ if @is_dry_run
377
+ puts "Would have updated message with options: #{options}"
378
+ response_json = { 'ok' => true }
379
+ else
380
+ response_json = @client.chat_update(options)
381
+ end
382
+ end
383
+ raise SlackAPIError, "Error updating message: #{response_json['error']}" unless response_json['ok']
384
+ end
385
+
386
+ # Given a list of channel strings, a mix of channel names and channel ID's,
387
+ # we want to find all the unique channel ID's that all of those strings represent.
388
+ # First, look in the cache for any precomputed ID's and add those to the final list.
389
+ # Divide non cached entries into channel ID's and channel names. Hitting the conversations_info
390
+ # API tells us quickly if that channel ID exists or not. Finally, we iterate through all
391
+ # channels once to find all of the channel ID's that match any channel names supplied.
392
+ # Iterating through all channels is by far the slowest part so only do this if necessary.
393
+ # This method will only return valid channel ID's so the returned list may be shorter
394
+ # than the input list of channel strings.
395
+ def unique_channel_ids(channel_strings)
396
+ unique_channel_ids = []
397
+ channel_ids = []
398
+ channel_names = []
399
+
400
+ channel_strings.each do |channel_string|
401
+ cache_result = @channel_name_id_cache[channel_string]
402
+ unless cache_result.nil?
403
+ unique_channel_ids << cache_result unless cache_result == NOT_FOUND
404
+ next
405
+ end
406
+
407
+ if channel_string =~ /^[A-Z0-9]+$/
408
+ channel_ids << channel_string
409
+ else
410
+ channel_names << channel_string
411
+ end
412
+ end
413
+
414
+ channel_ids.each do |channel_id|
415
+ # if the channel id doesn't exist then conversations_info will
416
+ # throw an exception
417
+ @client.conversations_info(channel: channel_id)
418
+ unique_channel_ids << channel_id
419
+ @channel_name_id_cache[channel_id] = channel_id
420
+ rescue StandardError => e
421
+ raise e unless e.message.include?('channel_not_found')
422
+
423
+ @channel_name_id_cache[channel_id] = NOT_FOUND
424
+ end
425
+
426
+ unless channel_names.empty?
427
+ channels_found = []
428
+ @client.conversations_list(
429
+ types: 'public_channel,private_channel',
430
+ limit: 1000,
431
+ exclude_archived: true
432
+ ) do |response_page|
433
+ raise "Error getting slack channel list: #{response_page['error']}" unless response_page['ok']
434
+
435
+ response_page['channels'].each do |channel_json|
436
+ channel_names.each do |channel_name|
437
+ next if channel_json['name'] != channel_name
438
+
439
+ @channel_name_id_cache[channel_name] = channel_json['id']
440
+ unique_channel_ids << channel_json['id']
441
+ channels_found << channel_name
442
+ end
443
+ end
444
+ # don't continue iterating if we've found all the channel names
445
+ break if channels_found.size == channel_names.size
446
+ end
447
+
448
+ channels_names_not_found = channel_names - channels_found
449
+ channels_names_not_found.each do |not_found_channel|
450
+ @channel_name_id_cache[not_found_channel] = NOT_FOUND
451
+ end
452
+ end
453
+ unique_channel_ids.uniq
454
+ end
455
+
456
+ # Find the timestamp of a message in a given channel containing the message_identifying text
457
+ # Return the timestamp of the first message encountered satisfying the criteria starting from
458
+ # the most recent message in the channel. Only look at the most recent 100 messages by default. If no
459
+ # message is found return nil.
460
+ def old_message_timestamp(channel_identifier, message_identifying_content, options: {})
461
+ channel_ids = unique_channel_ids([channel_identifier])
462
+ channel_id = channel_ids[0]
463
+ raise "Cannot find channel #{channel_identifier}" if channel_id.nil?
464
+
465
+ response_json = @client.conversations_history({ channel: channel_id }.merge(options))
466
+ error_message = "Error getting slack channel history for channel id #{channel_id}: #{response_json['error']}"
467
+ raise error_message unless response_json['ok']
468
+
469
+ response_json['messages'].each do |message_json|
470
+ next unless message_json['type'] == 'message'
471
+
472
+ return message_json['ts'] if message_includes_content(message_json, message_identifying_content)
473
+ end
474
+ nil
475
+ end
476
+
477
+ # determine if a message contains a certain snippet of content.
478
+ # A message has a text field but it can also have multiple attachments.
479
+ # An attachment can have text in the fallback, pretext, title, and text fields
480
+ # so check all of those fields for each attachment in addition to the main message
481
+ # text field. Return true if the message identifying content appears in
482
+ # any of those fields in a message.
483
+ def message_includes_content(message_json, message_identifying_content)
484
+ message_json.fetch('text', '').include?(message_identifying_content) ||
485
+ message_json.fetch('attachments', []).flat_map do |attachment|
486
+ %w[pretext fallback text title].map do |key|
487
+ attachment.fetch(key, '').include?(message_identifying_content)
488
+ end
489
+ end.any?
490
+ end
491
+
492
+ def safe_unicode_normalize(string, normalize_options = :nfc)
493
+ string.unicode_normalize(normalize_options)
494
+ rescue StandardError => e
495
+ raise e unless e.message.include?('Unicode Normalization not appropriate')
496
+
497
+ string
498
+ end
499
+ end
@@ -0,0 +1,169 @@
1
+ require 'danger'
2
+
3
+ require_relative '../util/shared_constants'
4
+
5
+ NEXT_GEN = 'next-gen'.freeze
6
+
7
+ module DangerChecks
8
+ # JIRA related Danger checks
9
+ class JIRA
10
+ def initialize(
11
+ dangerfile,
12
+ jira_service,
13
+ platform_service,
14
+ slack_channels = [],
15
+ slack_service = nil,
16
+ max_number_of_issues = 10
17
+ )
18
+ @dangerfile = dangerfile
19
+ @jira_service = jira_service
20
+ @platform_service = platform_service
21
+ @slack_channels = slack_channels
22
+ @slack_service = slack_service
23
+ @max_number_of_issues = max_number_of_issues
24
+ end
25
+
26
+ # Attempts to modify all JIRA issues referenced in the commit messages of a pull request.
27
+ # Only pull requests targeting whitelisted_branches will be operated on. Merge commit messages
28
+ # are ignored. Add a fix version to the ticket which is the version of the app in the target branch.
29
+ # Also, modify the `OS Platform` field to the platform of the app as returned by platform_service.platform.
30
+ #
31
+ # @param whitelisted_target_branches [List]. List of whitelisted target branches that determine if this automation
32
+ # is run.
33
+ def execute(whitelisted_target_branches)
34
+ source_branch = @dangerfile.github.pr_json['head']['ref']
35
+ target_branch = @dangerfile.github.pr_json['base']['ref']
36
+ starts_with_automerge_prefix = source_branch.start_with?(SharedConstants::AUTOMERGE_BRANCH_PREFIX)
37
+ is_not_valid_target_branch = !whitelisted_target_branches.include?(target_branch)
38
+ if starts_with_automerge_prefix || is_not_valid_target_branch
39
+ puts 'The source and target branches of this PR do not meet the criteria for JIRA automation.'
40
+ return
41
+ end
42
+
43
+ filtered_project_keys = @jira_service.filtered_project_keys
44
+ if filtered_project_keys.empty?
45
+ @dangerfile.warn('Cannot connect to JIRA server. Skipping issue automation.')
46
+ return
47
+ end
48
+ all_git_commits = @dangerfile.git.commits
49
+ pr_number = @dangerfile.github.pr_json['number']
50
+ puts "ALL NON-MERGE COMMITS in PR #{pr_number}"
51
+ non_merge_commits = all_git_commits.map do |commit|
52
+ next if commit.parents.size > 1 # Don't process merge commits.
53
+
54
+ puts "#{commit}: #{commit.message}"
55
+ commit
56
+ end.compact
57
+
58
+ return if @dangerfile.github.pr_json['title'].match?(/\b(wip)\b/i)
59
+
60
+ jira_issue_references = non_merge_commits.flat_map do |commit|
61
+ @jira_service.find_issue_references_in_text(commit.message, filtered_project_keys)
62
+ end.uniq.compact.reject { |issue| commit.message.include?("!#{issue}") || commit.message.match?(/\b(wip)\b/i) }
63
+ puts "All JIRA issue references: #{jira_issue_references}"
64
+
65
+ if jira_issue_references.size > @max_number_of_issues
66
+ message = "More than #{@max_number_of_issues} JIRA issue references detected by danger in PR #{pr_number}. " \
67
+ 'Not modifying JIRA issues.'
68
+ @dangerfile.warn(message)
69
+ if !@slack_service.nil? && !@slack_channels.empty?
70
+ @slack_service.send_message(
71
+ channel_strings: @slack_channels,
72
+ message: message + " #{@dangerfile.github.pr_json['html_url']}"
73
+ )
74
+ end
75
+ return
76
+ end
77
+
78
+ fix_version = @platform_service.app_version(@platform_service.remote_branch(@dangerfile.github.branch_for_base))
79
+
80
+ jira_issue_references.each do |jira_issue_reference|
81
+ jira_issue = @jira_service.issue(jira_issue_reference)
82
+ if jira_issue.nil?
83
+ @dangerfile.warn("JIRA issue #{jira_issue_reference} does not exist.")
84
+ next
85
+ end
86
+
87
+ project_key = jira_issue.project_key
88
+ unless filtered_project_keys.include?(project_key)
89
+ puts "Project key #{project_key} is not valid. Skipping issue #{jira_issue_reference}"
90
+ next
91
+ end
92
+
93
+ manage_platform(jira_issue, @platform_service.platform_name)
94
+
95
+ jira_project = @jira_service.project(jira_issue.project_key)
96
+ if jira_project.nil?
97
+ @dangerfile.warn("Cannot find JIRA project with key #{jira_issue.project_key}")
98
+ next
99
+ end
100
+ manage_fix_version(jira_issue, jira_project, fix_version)
101
+ end
102
+ end
103
+
104
+ def manage_fix_version(jira_issue, jira_project, fix_version)
105
+ # Skip if issue already has fix version
106
+ if jira_issue.fix_version?(fix_version)
107
+ puts "Issue #{jira_issue.key} already has fix version #{fix_version}"
108
+ return
109
+ end
110
+
111
+ if jira_project.style == NEXT_GEN
112
+ @dangerfile.warn("Fix version #{fix_version} cannot be added to JIRA issue #{jira_issue.key} " \
113
+ "because project #{jira_project.key} is a next-gen project and does not have fix versions.")
114
+ return
115
+ end
116
+
117
+ # if the project doesn't have the fix version add it to the project
118
+ if !jira_project.fix_version?(fix_version) && !jira_project.add_fix_version(fix_version)
119
+ @dangerfile.warn(
120
+ "Failed to add fix version #{fix_version} to JIRA project #{jira_project.key}. " \
121
+ "Ensure that `#{@jira_service.username}` is an administrator of that project."
122
+ )
123
+ return
124
+ end
125
+
126
+ preexisting_fix_versions = jira_issue.fix_versions
127
+
128
+ # then add the fix version to the issue.
129
+ unless jira_issue.add_fix_version(fix_version)
130
+ @dangerfile.warn("Failed to add fix version #{fix_version} to JIRA issue #{jira_issue.key}.")
131
+ return
132
+ end
133
+
134
+ return if preexisting_fix_versions.empty?
135
+
136
+ preexisting_fix_versions << fix_version
137
+ sorted_versions = preexisting_fix_versions.sort
138
+ @dangerfile.warn("JIRA issue #{jira_issue.key} has multiple fix versions: #{sorted_versions.join(', ')}")
139
+ end
140
+
141
+ def manage_platform(jira_issue, platform_name)
142
+ # we want to manage both the OS Platform field, which we're moving away from, and
143
+ # the Platform field. Do not warn if the OS Platform field does not exist on an issue.
144
+ { 'Platform' => 'array', 'OS Platform' => 'option' }.each do |field_name, field_type|
145
+ platform_data = jira_issue.custom_field_data(field_name)
146
+ platform_already_added = false
147
+ if platform_data
148
+ platform_already_added = if field_type == 'array'
149
+ platform_data.any? { |item| item['value'] == platform_name }
150
+ else
151
+ platform_data['value'] == platform_name
152
+ end
153
+ end
154
+
155
+ if platform_already_added
156
+ puts "Issue #{jira_issue.key} already has '#{field_name}' #{platform_name}"
157
+ else
158
+ failed_to_modify_platform = "Failed to modify '#{field_name}' field for JIRA " \
159
+ "issue #{jira_issue.key} to #{platform_name}."
160
+ @dangerfile.warn(failed_to_modify_platform) unless jira_issue.update_custom_field(field_name, platform_name)
161
+ end
162
+ rescue StandardError => e
163
+ puts e.message
164
+ do_not_warn = e.message.include?('No such field exists') && field_name == 'OS Platform'
165
+ @dangerfile.warn(e.message) unless do_not_warn
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,6 @@
1
+ # Error created to identify Slack API problems
2
+ class SlackAPIError < StandardError
3
+ def initialize(message = 'There was an error with the Slack API')
4
+ super
5
+ end
6
+ end