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.
- checksums.yaml +7 -0
- data/exe/tefoji +5 -0
- data/lib/mixins/logging.rb +9 -0
- data/lib/mixins/user_functions.rb +319 -0
- data/lib/tefoji/cli.rb +113 -0
- data/lib/tefoji/declared_value.rb +51 -0
- data/lib/tefoji/jira_api.rb +430 -0
- data/lib/tefoji.rb +830 -0
- metadata +210 -0
@@ -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
|