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.
- checksums.yaml +7 -0
- data/lib/standard_automation_library/api_clients/app_center.rb +104 -0
- data/lib/standard_automation_library/api_clients/bugsnag.rb +94 -0
- data/lib/standard_automation_library/api_clients/github.rb +371 -0
- data/lib/standard_automation_library/api_clients/jira.rb +326 -0
- data/lib/standard_automation_library/api_clients/pagerduty.rb +101 -0
- data/lib/standard_automation_library/api_clients/slack.rb +499 -0
- data/lib/standard_automation_library/danger/danger_jira.rb +169 -0
- data/lib/standard_automation_library/errors/slack_api_error.rb +6 -0
- data/lib/standard_automation_library/personnel/release_management_team.rb +85 -0
- data/lib/standard_automation_library/personnel/team.rb +41 -0
- data/lib/standard_automation_library/personnel/user.rb +68 -0
- data/lib/standard_automation_library/services/bugsnag_service.rb +251 -0
- data/lib/standard_automation_library/services/jira_service.rb +64 -0
- data/lib/standard_automation_library/services/merge_driver_service.rb +48 -0
- data/lib/standard_automation_library/services/mobile_tech_debt_logging_service.rb +176 -0
- data/lib/standard_automation_library/services/monorepo_platform_service.rb +18 -0
- data/lib/standard_automation_library/services/perf_tracker_logging_service.rb +87 -0
- data/lib/standard_automation_library/services/platform_service.rb +34 -0
- data/lib/standard_automation_library/services/repo_service.rb +17 -0
- data/lib/standard_automation_library/services/slack_service.rb +383 -0
- data/lib/standard_automation_library/util/automerge_configuration.rb +134 -0
- data/lib/standard_automation_library/util/bundler.rb +18 -0
- data/lib/standard_automation_library/util/datetime_helper.rb +23 -0
- data/lib/standard_automation_library/util/file_content.rb +15 -0
- data/lib/standard_automation_library/util/git.rb +235 -0
- data/lib/standard_automation_library/util/git_merge_error_message_cleaner.rb +27 -0
- data/lib/standard_automation_library/util/network.rb +39 -0
- data/lib/standard_automation_library/util/path_container.rb +17 -0
- data/lib/standard_automation_library/util/platform_picker.rb +150 -0
- data/lib/standard_automation_library/util/shared_constants.rb +27 -0
- data/lib/standard_automation_library/util/shell_helper.rb +54 -0
- data/lib/standard_automation_library/util/slack_constants.rb +40 -0
- data/lib/standard_automation_library/util/version.rb +31 -0
- data/lib/standard_automation_library/version.rb +5 -0
- data/lib/standard_automation_library.rb +8 -0
- 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
|