tefoji 1.0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -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