tefoji 1.0.7

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.
@@ -0,0 +1,430 @@
1
+ require 'base64'
2
+ require 'io/console'
3
+ require 'json'
4
+ require 'rest-client'
5
+
6
+ module Tefoji
7
+ ## An interface to send API request to Jira using the rest-client gem.
8
+ ## Contained here are the bits of knowledge necessary to form API calls to Jira.
9
+
10
+ class JiraApi
11
+ attr_reader :jira_username, :jira_base_url, :jira_base_rest_url, :log_level
12
+ attr_accessor :logger
13
+
14
+ include Logging
15
+
16
+ ## Jira field keys
17
+ FIELD_ID = 'id'
18
+ FIELD_KEY = 'key'
19
+ FIELD_NAME = 'name'
20
+ FIELD_VALUE = 'value'
21
+
22
+ ## Issue type constants
23
+ ISSUE_EPIC = 'Epic'
24
+ ISSUE_FEATURE = 'Feature'
25
+ ISSUE_NEW_FEATURE = 'New Feature'
26
+ ISSUE_SUB_TASK = 'Sub-task'
27
+ ISSUE_TASK = 'Task'
28
+
29
+ def log_level=(level)
30
+ @log_level = level
31
+ # When debugging, turn on the RestClient @logger
32
+ RestClient.log = @logger if @log_level == Logger::DEBUG
33
+ end
34
+
35
+ # Depending on how user specified their authorization we want to
36
+ # derive and store the username and the Base64-encoded
37
+ # 'username:password' string.
38
+ def authenticate(jira_base_url, requested_user = nil, jira_auth_string = nil)
39
+ @jira_base_url = jira_base_url
40
+ @jira_base_rest_url = "#{jira_base_url}/rest/api/2"
41
+
42
+ @logger.info "Using Jira instance: \"#{jira_base_url}\""
43
+
44
+ # If we found an auth string, try to use it. Allow the requested_user
45
+ # to override and send us to prompt
46
+ decoded_auth = nil
47
+ unless jira_auth_string.nil?
48
+ auth_string_user, auth_string_password = Base64.decode64(jira_auth_string).split(':')
49
+ # Only set decoded auth if the user name in the auth string matches the
50
+ # requested user name
51
+ unless requested_user && requested_user != auth_string_user
52
+ decoded_auth = [auth_string_user, auth_string_password]
53
+ end
54
+ end
55
+
56
+ # If there was no auth string or the requested user didn't match the auth string
57
+ # prompt for details as needed.
58
+ if decoded_auth.nil?
59
+ decoded_auth = []
60
+ decoded_auth[0] = requested_user
61
+ if requested_user.nil?
62
+ print 'Enter jira user name: '
63
+ decoded_auth[0] = $stdin.gets.chomp
64
+ end
65
+ decoded_auth[1] = IO.console.getpass("Enter jira password for #{requested_user}: ")
66
+ end
67
+
68
+ @jira_username = decoded_auth[0]
69
+
70
+ # HTTP doesn't like linefeeds in the auth string, hence #strict_encode64
71
+ @jira_auth_string = Base64.strict_encode64(decoded_auth.join(':'))
72
+ @authentication_header = { 'Authorization' => "Basic #{@jira_auth_string}" }
73
+ end
74
+
75
+ # Do this so we can inform the user quickly that their credentials didn't work
76
+ # There may be a better test, but this is the one the original Winston uses
77
+ def test_authentication
78
+ get_username
79
+ rescue RestClient::Forbidden
80
+ fatal 'Forbidden: either the authentication is incorrect or ' \
81
+ 'Jira might be requiring a CAPTCHA response from the web interface.'
82
+ end
83
+
84
+ def get_username
85
+ jira_get("user?username=#{@jira_username}")
86
+ end
87
+
88
+ # Save authentication YAML to the a file for reuse.
89
+ def save_authentication(save_file_name)
90
+ backup_file_name = "#{save_file_name}.bak"
91
+
92
+ if File.readable?(save_file_name)
93
+ FileUtils.cp(save_file_name, backup_file_name)
94
+ @logger.info "Saved #{save_file_name} to #{backup_file_name}"
95
+ end
96
+
97
+ File.open(save_file_name, 'w') do |f|
98
+ f.puts "jira-auth: #{@jira_auth_string}"
99
+ end
100
+ File.chmod(0o600, save_file_name)
101
+
102
+ @logger.info "Saved Jira authentication to #{save_file_name}"
103
+ end
104
+
105
+ # Create a Jira issue
106
+ def create_issue(issue_data)
107
+ request_path = 'issue'
108
+ jira_fields = issue_data_to_jira_fields(issue_data)
109
+ jira_post(request_path, jira_fields)
110
+ rescue RestClient::Forbidden
111
+ fatal "Forbidden: could not send #{request_path} request to Jira. "\
112
+ 'Jira may not be accepting requests.'
113
+ rescue RestClient::BadRequest => e
114
+ error_json = JSON.parse(e.response.body)
115
+ errors = error_json['errors'].values.join("\n")
116
+ fatal "Bad Request: something went wrong with this #{request_path} request: "\
117
+ "#{request_path}, #{jira_fields}\n" \
118
+ "Errors were: #{errors}"
119
+ rescue RestClient::Unauthorized
120
+ fatal "Unauthorized: could not send #{request_path} request to Jira. "\
121
+ 'Did you use the correct credentials?'
122
+ end
123
+
124
+ # Use greehopper to add an issue to an epic. See the comments around the #greenhopper_put
125
+ # method for more explanation.
126
+ def add_issue_to_epic(epic_issue, issue_to_add)
127
+ epic_key = epic_issue[FIELD_KEY]
128
+ issue_key = issue_to_add[FIELD_KEY]
129
+ request_path = "epics/#{epic_key}/add"
130
+ request_data = {
131
+ 'ignoreEpics' => true,
132
+ 'issueKeys' => [issue_key]
133
+ }
134
+ greenhopper_put(request_path, request_data)
135
+ end
136
+
137
+ def retrieve_issue(issue_url)
138
+ jira_get(issue_url)
139
+ end
140
+
141
+ def link_issues(inward_issue, outward_issue, type = 'Blocks')
142
+ request_path = 'issueLink'
143
+ request_data = {
144
+ 'type' => { 'name' => type },
145
+ 'inwardIssue' => { 'key' => inward_issue },
146
+ 'outwardIssue' => { 'key' => outward_issue }
147
+ }
148
+ jira_post(request_path, request_data)
149
+ end
150
+
151
+ # Used to set issue items related to transitions.
152
+ # Right now, only used for 'status' transitions.
153
+ def transition(issue_key, status)
154
+ request_path = "issue/#{issue_key}/transitions"
155
+ request_data = {
156
+ 'transition' => { 'id' => status }
157
+ }
158
+ jira_post(request_path, request_data)
159
+ end
160
+
161
+ # https://www.youtube.com/watch?v=JsntlJZ9h1U
162
+ def add_watcher(issue_key, watcher)
163
+ request_path = "issue/#{issue_key}/watchers"
164
+ request_data = watcher
165
+ jira_post(request_path, request_data)
166
+ end
167
+
168
+ private
169
+
170
+ def jira_get(jira_request_path)
171
+ # Jira likes to send complete URLs for responses. Handle
172
+ # the case where we've received a 'self' query from Jira with
173
+ # fully formed url
174
+ url = jira_request_path
175
+ unless url.start_with? @jira_base_rest_url
176
+ url = "#{@jira_base_rest_url}/#{jira_request_path}"
177
+ end
178
+ headers = @authentication_header
179
+
180
+ begin
181
+ response = RestClient.get(url, headers)
182
+ rescue RestClient::MovedPermanently,
183
+ RestClient::Found,
184
+ RestClient::TemporaryRedirect => e
185
+ e.response.follow_redirection
186
+ rescue RestClient::Unauthorized
187
+ fatal "'Unauthorized' response from #{@jira_base_rest_url}. " \
188
+ 'Did you use the correct credentials?'
189
+ rescue RestClient::ServiceUnavailable
190
+ fatal "Cannot connect to #{@jira_base_rest_url}: Service Unavailable"
191
+ rescue RestClient::NotFound,
192
+ SocketError,
193
+ Errno::ECONNREFUSED => e
194
+ fatal "Cannot connect to #{@jira_base_rest_url}: #{e.message}"
195
+ end
196
+
197
+ return JSON.parse(response.body)
198
+ end
199
+
200
+ def jira_post(jira_request_path, payload)
201
+ url = "#{@jira_base_rest_url}/#{jira_request_path}/"
202
+ json_header = { 'Content-Type' => 'application/json' }
203
+ headers = @authentication_header.merge(json_header)
204
+
205
+ begin
206
+ response = RestClient.post(url, payload.to_json, headers)
207
+ rescue RestClient::MovedPermanently,
208
+ RestClient::Found,
209
+ RestClient::TemporaryRedirect => e
210
+ e.response.follow_redirection
211
+ end
212
+
213
+ return JSON.parse(response.body) unless response.body.empty?
214
+ end
215
+
216
+ # Using JIRA's REST API to update the Epic Link field returns "204 No Content",
217
+ # even if the request is successful. As a workaround, use JIRA's "greenhopper" API.
218
+ # See the following for reference:
219
+ # https://jira.atlassian.com/browse/JRA-43437
220
+ # https://jira.atlassian.com/browse/JSW-7017
221
+ # https://confluence.atlassian.com/jirakb/set-the-epic-link-via-rest-call-779158620.html
222
+ #
223
+ # Special-cased here for hopeful eventual removal
224
+ def greenhopper_put(request_path, payload)
225
+ greenhopper_base_rest_url = "#{@jira_base_url}:443/rest/greenhopper/1.0"
226
+ url = "#{greenhopper_base_rest_url}/#{request_path}/"
227
+ json_header = { 'Content-Type' => 'application/json' }
228
+ headers = @authentication_header.merge(json_header)
229
+
230
+ begin
231
+ response = RestClient.put(url, payload.to_json, headers)
232
+ rescue RestClient::MovedPermanently,
233
+ RestClient::Found,
234
+ RestClient::TemporaryRedirect => e
235
+ e.response.follow_redirection
236
+ end
237
+
238
+ return JSON.parse(response.body) unless response.body.empty?
239
+ end
240
+
241
+ # Provide the needed translation of user-created data to required format
242
+ # of Jira requests. This is mostly a list of fussing with the Jira field
243
+ # names.
244
+ def issue_data_to_jira_fields(issue_data)
245
+ # Check to ensure we have what we need to create a issue
246
+
247
+ unless issue_data['summary']
248
+ @logger.error "this issue is missing a required summary:\n\n#{issue_data}\n"
249
+ exit 1
250
+ end
251
+ unless issue_data['project']
252
+ @logger.error "this issue is missing a required project:\n\n#{issue_data}\n"
253
+ exit 1
254
+ end
255
+
256
+ # build the jira_fields hash describing the issue
257
+
258
+ # These are required for all issues
259
+ jira_fields = {
260
+ 'summary' => issue_data['summary'],
261
+ 'project' => { 'key' => issue_data['project'] }
262
+ }
263
+
264
+ # The following are optional
265
+ if issue_data['description']
266
+ jira_fields['description'] = issue_data['description']
267
+ end
268
+ if issue_data['assignee']
269
+ jira_fields['assignee'] = { FIELD_NAME => issue_data['assignee'] }
270
+ end
271
+ if issue_data['story_points']
272
+ jira_fields['customfield_10002'] = issue_data['story_points'].to_i
273
+ end
274
+ if issue_data['team']
275
+ jira_fields['customfield_14200'] = { FIELD_VALUE => issue_data['team'] }
276
+ end
277
+ if issue_data['teams']
278
+ teams = issue_data['teams'].to_a.flatten.reject { |t| t =~ /^\s*$/ }
279
+ unless teams.empty?
280
+ jira_fields['customfield_14201'] = teams.map { |team| { FIELD_VALUE => team } }
281
+ end
282
+ end
283
+ if issue_data['subteam']
284
+ jira_fields['customfield_11700'] = [issue_data['subteam']]
285
+ end
286
+ if issue_data['sprint']
287
+ jira_fields['customfield_10005'] = issue_data['sprint'].to_i
288
+ end
289
+ if issue_data['acceptance']
290
+ jira_fields['customfield_11501'] = issue_data['acceptance']
291
+ end
292
+ if issue_data['labels']
293
+ labels = issue_data['labels'].to_a.flatten.reject { |t| t =~ /^\s*$/ }
294
+ jira_fields['labels'] = labels unless labels.empty?
295
+ end
296
+ if issue_data['duedate']
297
+ jira_fields['duedate'] = issue_data['duedate']
298
+ end
299
+ if issue_data['fix_version']
300
+ jira_fields['fixVersions'] = [issue_data['fix_version']]
301
+ end
302
+ if issue_data['release_notes']
303
+ jira_fields['customfield_11100'] = { FIELD_VALUE => issue_data['release_notes'] }
304
+ end
305
+
306
+ if issue_data['components']
307
+ components = issue_data[:components].to_a.flatten.reject { |t| t =~ /^\s*$/ }
308
+ unless components.empty?
309
+ jira_fields['components'] = components.map { |component| { FIELD_NAME => component } }
310
+ end
311
+ end
312
+
313
+ # Default issue type to ISSUE_TASK if it isn't already set
314
+ jira_fields['issuetype'] = { FIELD_NAME => ISSUE_TASK }
315
+
316
+ if issue_data['type']
317
+ jira_fields['issuetype'] = { FIELD_NAME => issue_data['type'] }
318
+ # If this is an epic, we need to add an epic name
319
+ if issue_data['type'].casecmp?(ISSUE_EPIC)
320
+ jira_fields['customfield_10007'] = issue_data['epic_name'] || issue_data['summary']
321
+ end
322
+ end
323
+
324
+ # If a issue has a specified parent issue, prefer that. The parent issue *should* already
325
+ # be linked to the main epic. Otherwise, we need to set it to have an epic_parent. This can
326
+ # either be an epic linked to the main epic or the main epic itself.
327
+
328
+ if issue_data['parent']
329
+ unless issue_data['type'].casecmp?(ISSUE_SUB_TASK) || !issue_data['type']
330
+ @logger.fatal "A issue with a parent must be classified as a Sub-issue\n\n#{issue_data}"
331
+ exit 1
332
+ end
333
+ jira_fields['issuetype'] = { FIELD_NAME => ISSUE_SUB_TASK }
334
+ jira_fields['parent'] = { FIELD_KEY => issue_data['parent'] }
335
+ elsif issue_data['epic_parent']
336
+ if issue_data['type'].casecmp?(ISSUE_SUB_TASK)
337
+ @logger.fatal "This issue cannot be a subtask of an epic\n\n#{issue_data}"
338
+ exit 1
339
+ end
340
+ jira_fields['customfield_10006'] = issue_data['epic_parent']
341
+ end
342
+
343
+ security = ENV['SECURITY'] || issue_data['security']
344
+ if security
345
+ case security.downcase
346
+ when 'confidential'
347
+ jira_fields['security'] = { FIELD_ID => '10002' }
348
+ when 'internal'
349
+ jira_fields['security'] = { FIELD_ID => '10001' }
350
+ when 'public'
351
+ # Nothing to do here - public is default
352
+ else
353
+ @logger.fatal "Unknown security type: #{security}"
354
+ exit 1
355
+ end
356
+ end
357
+
358
+ return { 'fields' => jira_fields }
359
+ end
360
+ end
361
+
362
+ class JiraMockApi < JiraApi
363
+ def initialize(log_level = Logger::ERROR)
364
+ super
365
+ @key_count = 0
366
+ @summary_count = 0
367
+ end
368
+
369
+ def authenticate(jira_base_url, username = nil, jira_auth_string = nil)
370
+ @jira_base_url = jira_base_url
371
+ @jira_base_rest_url = "#{jira_base_url}/rest/api/2"
372
+
373
+ @logger.info "Using mock of Jira instance: \"#{jira_base_url}\""
374
+
375
+ if jira_auth_string.nil?
376
+ if username.nil?
377
+ print 'Enter jira user name: '
378
+ username = $stdin.gets.chomp
379
+ end
380
+ else
381
+ plain_auth_string = Base64.decode64(jira_auth_string)
382
+ username = plain_auth_string.split(':')[0]
383
+ end
384
+
385
+ @jira_username = username
386
+ end
387
+
388
+ def jira_get(jira_request_path)
389
+ url = "#{@jira_base_rest_url}/#{jira_request_path}"
390
+ header = { 'Authorization' => 'Basic mocked-auth-header' }
391
+ puts "\n RestClient.get #{url} #{header}\n"
392
+ mocked_response
393
+ end
394
+
395
+ def jira_post(jira_request_path, payload)
396
+ url = "#{@jira_base_rest_url}/#{jira_request_path}/"
397
+ # json_header = { 'Content-Type' => 'application/json' }
398
+ # headers = { 'Authorization' => 'Basic mocked-auth-header' }.merge(json_header)
399
+
400
+ puts "\n RestClient.post #{url}\n #{pretty(payload)}\n\n"
401
+ mocked_response
402
+ end
403
+
404
+ def greenhopper_put(request_path, payload)
405
+ greenhopper_base_rest_url = "#{@jira_base_url}:443/rest/greenhopper/1.0"
406
+ url = "#{greenhopper_base_rest_url}/#{request_path}/"
407
+ # json_header = { 'Content-Type' => 'application/json' }
408
+ # headers = { 'Authorization' => 'Basic mocked-auth-header' }.merge(json_header)
409
+
410
+ puts "\n RestClient.put #{url}\n #{pretty(payload)}\n\n"
411
+ mocked_response
412
+ end
413
+
414
+ def pretty(issue_data)
415
+ JSON.pretty_generate(issue_data)
416
+ end
417
+
418
+ def mocked_response
419
+ @key_count += 1
420
+ @summary_count += 1
421
+
422
+ return {
423
+ 'key' => "MOCKED-#{@key_count}",
424
+ 'fields' => {
425
+ 'summary' => "Mocked issue summary #{@summary_count}"
426
+ }
427
+ }
428
+ end
429
+ end
430
+ end