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
data/lib/tefoji.rb
ADDED
@@ -0,0 +1,830 @@
|
|
1
|
+
require 'date'
|
2
|
+
require 'digest'
|
3
|
+
require 'fileutils'
|
4
|
+
require 'net/http'
|
5
|
+
require 'json'
|
6
|
+
|
7
|
+
require 'mixins/logging'
|
8
|
+
require 'mixins/user_functions'
|
9
|
+
|
10
|
+
require 'tefoji/cli'
|
11
|
+
require 'tefoji/declared_value'
|
12
|
+
require 'tefoji/jira_api'
|
13
|
+
|
14
|
+
module Tefoji
|
15
|
+
class Tefoji
|
16
|
+
# Class for the main Tefoji class responsible for driving the issue creation.
|
17
|
+
|
18
|
+
include Logging
|
19
|
+
include UserFunctions
|
20
|
+
|
21
|
+
# @param [Hash] user_options options provided by the user through the CLI.
|
22
|
+
# @see CLI
|
23
|
+
def initialize(user_options = {}, log_location = $stderr)
|
24
|
+
@log_level = Logger::ERROR
|
25
|
+
@jira_api = nil
|
26
|
+
@jira_url = user_options['--jira']
|
27
|
+
@jira_user = user_options['--jira-user']
|
28
|
+
@jira_auth_string = user_options['jira-auth-string']
|
29
|
+
@jira_auth_file = user_options['jira-auth-file']
|
30
|
+
@jira_mock = user_options['--jira-mock']
|
31
|
+
|
32
|
+
@no_notes = user_options['--no-notes']
|
33
|
+
@template_data = {}
|
34
|
+
|
35
|
+
# For display purposes only. For displaying a tidy issue count as they
|
36
|
+
# are generated.
|
37
|
+
@issue_counter = 1
|
38
|
+
|
39
|
+
# Changable internal states
|
40
|
+
@feature_issue = nil
|
41
|
+
@default_target_epic = nil
|
42
|
+
@epic_security = nil
|
43
|
+
|
44
|
+
# Logging
|
45
|
+
@log_level = Logger::INFO
|
46
|
+
@log_level = Logger::WARN if user_options['--quiet']
|
47
|
+
@log_level = Logger::DEBUG if user_options['--debug']
|
48
|
+
|
49
|
+
@logger = logger_initialize(log_location)
|
50
|
+
end
|
51
|
+
|
52
|
+
# Main method for generating issues from template_data
|
53
|
+
# @param main_template [Hash]
|
54
|
+
# uri: URI of the template
|
55
|
+
# data: parsed template data
|
56
|
+
# @return [Hash] details of all the generated issues
|
57
|
+
def run(main_template)
|
58
|
+
main_template_data = main_template[:data]
|
59
|
+
|
60
|
+
# Print any notes.
|
61
|
+
if main_template_data.key?('notes') && !@no_notes
|
62
|
+
puts main_template_data['notes']
|
63
|
+
end
|
64
|
+
|
65
|
+
# Separate out any formats.
|
66
|
+
@formats = {}
|
67
|
+
@formats = main_template_data['formats'] if main_template_data['formats']
|
68
|
+
|
69
|
+
@declarations = Hash.new(DeclaredValue.new)
|
70
|
+
jira_predeclared_variables.each do |k, v|
|
71
|
+
@declarations[k] = DeclaredValue.new(v)
|
72
|
+
end
|
73
|
+
|
74
|
+
resolve_variables(main_template_data['declare'])
|
75
|
+
@logger.debug "Declarations: #{@declarations}"
|
76
|
+
|
77
|
+
# Process 'before' templates
|
78
|
+
main_template.dig(:includes, :before)&.each do |before|
|
79
|
+
default_epic_saved = @default_target_epic
|
80
|
+
@formats = before.dig(:data, 'formats') if before.dig(:data, 'formats')
|
81
|
+
process_template(before[:template_name], before[:data], before[:conditional])
|
82
|
+
@default_target_epic = default_epic_saved
|
83
|
+
end
|
84
|
+
|
85
|
+
# Process main template
|
86
|
+
main_template_name = File.basename(main_template[:uri], File.extname(main_template[:uri]))
|
87
|
+
@formats = main_template_data['formats'] if main_template_data['formats']
|
88
|
+
process_template(main_template_name, main_template_data)
|
89
|
+
|
90
|
+
# Process 'after' templates
|
91
|
+
main_template.dig(:includes, :after)&.each do |after|
|
92
|
+
default_epic_saved = @default_target_epic
|
93
|
+
@formats = after.dig(:data, 'formats') if after.dig(:data, 'formats')
|
94
|
+
process_template(after[:template_name], after[:data], after[:conditional])
|
95
|
+
@default_target_epic = default_epic_saved
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def process_template(template_name, template_data, conditional = 'true')
|
100
|
+
unless template_data
|
101
|
+
@logger.info "Template \"#{template_name}\" has no tefoji data. Skipping."
|
102
|
+
return
|
103
|
+
end
|
104
|
+
|
105
|
+
if expand_right_value(conditional).falsey?
|
106
|
+
@logger.info "Not processing \"#{template_name}\"; template conditional evaluates to 'false'"
|
107
|
+
return
|
108
|
+
end
|
109
|
+
|
110
|
+
@logger.info "- Processing \"#{template_name}\" template"
|
111
|
+
@template_data = template_data
|
112
|
+
|
113
|
+
if @template_data.key?('feature')
|
114
|
+
generate_feature_with_issues
|
115
|
+
elsif @template_data.key?('epics')
|
116
|
+
generate_epics_with_issues('epics')
|
117
|
+
elsif @template_data.key?('epic')
|
118
|
+
generate_epics_with_issues('epic')
|
119
|
+
elsif @template_data.key?('issues')
|
120
|
+
generate_ordinary_issues
|
121
|
+
else
|
122
|
+
fatal 'Internal error finding keywords in issue template'
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# @return [Array]
|
127
|
+
# 0: Number of errors found
|
128
|
+
# 1: Array of hashes [uri:, loaded-data:] of succesfully parsed templates
|
129
|
+
def yaml_syntax_check(template_uris)
|
130
|
+
errors = 0
|
131
|
+
checked_templates = []
|
132
|
+
template_uris.each do |template_uri|
|
133
|
+
@logger.debug "YAML syntax-check: #{template_uri}"
|
134
|
+
raw_yaml = if template_uri.start_with?('http://', 'https://')
|
135
|
+
get_remote_yaml(template_uri)
|
136
|
+
else
|
137
|
+
File.open(template_uri)
|
138
|
+
end
|
139
|
+
checked_templates << {
|
140
|
+
uri: template_uri,
|
141
|
+
data: YAML.safe_load(raw_yaml, filename: template_uri)
|
142
|
+
}
|
143
|
+
rescue Psych::SyntaxError, Errno::ENOENT => e
|
144
|
+
@logger.error e.message
|
145
|
+
errors += 1
|
146
|
+
end
|
147
|
+
|
148
|
+
return [errors, checked_templates]
|
149
|
+
end
|
150
|
+
|
151
|
+
def get_remote_yaml(issue_template_uri)
|
152
|
+
response = Net::HTTP.get_response(URI.parse(issue_template_uri))
|
153
|
+
case response
|
154
|
+
when Net::HTTPSuccess
|
155
|
+
return response.body
|
156
|
+
when Net::HTTPServerError
|
157
|
+
warn 'tefoji: Error Remote server not responding.'
|
158
|
+
exit 1
|
159
|
+
else
|
160
|
+
warn "tefoji: Error #{issue_template_uri}: #{response.message}"
|
161
|
+
exit 1
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
# @return [Hash]
|
166
|
+
# before: [Array] of Hashes [template_name:, conditional:, loaded-data] templates
|
167
|
+
# to be included before
|
168
|
+
# after: [Array] of Hashes [template_name:, conditional:, loaded-data] templates
|
169
|
+
# to be included after
|
170
|
+
def includes(template)
|
171
|
+
before = {}
|
172
|
+
after = {}
|
173
|
+
|
174
|
+
before_section = template[:data].dig('includes', 'before')
|
175
|
+
after_section = template[:data].dig('includes', 'after')
|
176
|
+
|
177
|
+
if before_section
|
178
|
+
@logger.debug 'Found .includes.before, processing'
|
179
|
+
before = find_includes(template[:uri], before_section)
|
180
|
+
end
|
181
|
+
|
182
|
+
if after_section
|
183
|
+
@logger.debug 'Found .includes.after, processing'
|
184
|
+
after = find_includes(template[:uri], after_section)
|
185
|
+
end
|
186
|
+
|
187
|
+
return { before: before, after: after }
|
188
|
+
end
|
189
|
+
|
190
|
+
def find_includes(main_template_path, items_to_include)
|
191
|
+
template_directory = File.dirname(main_template_path)
|
192
|
+
|
193
|
+
# Included files can be just a template name 'foo' or a uri 'https://foo.com/bar.yaml'
|
194
|
+
# Consider the four cases:
|
195
|
+
# 1. main_template_path is URI, included template is a template name
|
196
|
+
# 2. main_template_path is URI, included template is a URI
|
197
|
+
# 3. main_template_path is local file, included template is template name
|
198
|
+
# 4. main_template_path is local file, included template is a URI
|
199
|
+
|
200
|
+
# Also, template_directory can be an ordinary local directory or an URL directory
|
201
|
+
|
202
|
+
items_to_include.map do |include|
|
203
|
+
unless include['template']
|
204
|
+
fatal "Include request \"#{include}\" is missing \"template\" field"
|
205
|
+
end
|
206
|
+
template_file_name = include['template']
|
207
|
+
template_file_name += '.yaml' unless template_file_name.end_with?('.yaml')
|
208
|
+
|
209
|
+
conditional = true
|
210
|
+
conditional = include['conditional'] if include.key?('conditional')
|
211
|
+
|
212
|
+
include_epics = false
|
213
|
+
include_epics = include['include_epics'] if include.key?('include_epics')
|
214
|
+
|
215
|
+
presumed_path = File.join(template_directory, template_file_name)
|
216
|
+
included_template_path = nil
|
217
|
+
|
218
|
+
if template_file_name.start_with?('http://', 'https://') || File.exist?(template_file_name)
|
219
|
+
# Provided URL or full viable directory path
|
220
|
+
included_template_path = template_file_name
|
221
|
+
elsif presumed_path.start_with?('http://', 'https://') || File.exist?(presumed_path)
|
222
|
+
# Constructed path from main template directory name
|
223
|
+
included_template_path = presumed_path
|
224
|
+
end
|
225
|
+
|
226
|
+
fatal "Cannot locate template: \"#{template_file_name}\"" if included_template_path.nil?
|
227
|
+
|
228
|
+
raw_yaml = if included_template_path.start_with?('http://', 'https://')
|
229
|
+
get_remote_yaml(included_template_path)
|
230
|
+
else
|
231
|
+
File.open(included_template_path)
|
232
|
+
end
|
233
|
+
|
234
|
+
yaml_data = YAML.safe_load(raw_yaml, filename: included_template_path)
|
235
|
+
|
236
|
+
# In included templates, we don't include features.
|
237
|
+
yaml_data.delete('feature')
|
238
|
+
|
239
|
+
# In included templates, we don't include epics unless they are specifically kept.
|
240
|
+
unless include_epics
|
241
|
+
yaml_data.delete('epics')
|
242
|
+
yaml_data.delete('epic')
|
243
|
+
end
|
244
|
+
|
245
|
+
@logger.debug "conditional: #{conditional}; yaml_data: #{yaml_data}"
|
246
|
+
{ template_name: include['template'], conditional: conditional, data: yaml_data }
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
# Do a quick check of the templates to avoid getting too far along with
|
251
|
+
# detectable errors.
|
252
|
+
# @param [Array] templates An array of pairs of template URIs and parsed YAML data
|
253
|
+
# @return [Boolean] true if valid
|
254
|
+
def valid_templates?(templates)
|
255
|
+
errors = false
|
256
|
+
|
257
|
+
templates.each do |uri, template|
|
258
|
+
@logger.debug "Checking #{uri} for basic validity"
|
259
|
+
result = template_errors?(uri, template)
|
260
|
+
errors ||= result
|
261
|
+
end
|
262
|
+
|
263
|
+
return !errors
|
264
|
+
end
|
265
|
+
|
266
|
+
# Authenticate to the Jira instance
|
267
|
+
def authenticate
|
268
|
+
@jira_api = @jira_mock ? JiraMockApi.new : JiraApi.new
|
269
|
+
@jira_api.log_level = @log_level
|
270
|
+
@jira_api.logger = @logger
|
271
|
+
|
272
|
+
@jira_api.authenticate(@jira_url, @jira_user, @jira_auth_string)
|
273
|
+
@jira_api.test_authentication unless @jira_mock
|
274
|
+
end
|
275
|
+
|
276
|
+
# Save Jira auth data
|
277
|
+
def save_authentication
|
278
|
+
authenticate unless @jira_api
|
279
|
+
@jira_api.save_authentication(@jira_auth_file)
|
280
|
+
end
|
281
|
+
|
282
|
+
private
|
283
|
+
|
284
|
+
# Generate a feature, a list of epics, and associated issues
|
285
|
+
def generate_feature_with_issues
|
286
|
+
@feature_issue = generate_feature
|
287
|
+
generate_epics('epics').each do |epic|
|
288
|
+
@default_target_epic = epic
|
289
|
+
generate_ordinary_issues
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
# Generate a 'feature' issue
|
294
|
+
def generate_feature
|
295
|
+
feature_to_do = @template_data['feature']
|
296
|
+
|
297
|
+
feature = variable_substitute(feature_to_do)
|
298
|
+
feature['type'] = JiraApi::ISSUE_FEATURE
|
299
|
+
@feature_issue = @jira_api.create_issue(feature)
|
300
|
+
@logger.info "Feature issue: #{@feature_issue['key']}"
|
301
|
+
@feature_issue
|
302
|
+
end
|
303
|
+
|
304
|
+
def generate_epics_with_issues(epic_key)
|
305
|
+
epics = generate_epics(epic_key)
|
306
|
+
epics.each do |epic|
|
307
|
+
@default_target_epic = epic
|
308
|
+
generate_ordinary_issues
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
# @return [Array] an array of responses from Jira containing a hash of the epic fields.
|
313
|
+
def generate_epics(epic_key)
|
314
|
+
epic_to_do_list = [@template_data[epic_key]].flatten
|
315
|
+
@logger.debug "Generating data: #{@template_data}"
|
316
|
+
|
317
|
+
epic_to_do_list.map do |epic|
|
318
|
+
@epic_security = nil
|
319
|
+
epic = variable_substitute(epic)
|
320
|
+
|
321
|
+
# Check for a 'conditional' tag in the data, skip issue generation if falsey
|
322
|
+
if conflicting_conditionals?(epic)
|
323
|
+
fatal "Epic \"#{short_name}\" has conflicting conditionals."
|
324
|
+
end
|
325
|
+
if skip_issue?(epic)
|
326
|
+
@logger.info 'Epic: %16s [%s]' % ['(SKIPPED)', epic['summary']]
|
327
|
+
next
|
328
|
+
end
|
329
|
+
short_name = epic['short_name'] || epic['summary']
|
330
|
+
unset_variables = list_unset_variables(epic)
|
331
|
+
unless unset_variables.empty?
|
332
|
+
fatal "Epic \"#{short_name}\" has unset variables in these fields: " +
|
333
|
+
unset_variables.join(', ')
|
334
|
+
end
|
335
|
+
|
336
|
+
epic['type'] = JiraApi::ISSUE_EPIC
|
337
|
+
if private_release?(epic)
|
338
|
+
@epic_security = 'internal'
|
339
|
+
|
340
|
+
private_description = ''
|
341
|
+
private_repos = epic['private_release'].value['repos']
|
342
|
+
|
343
|
+
if private_repos
|
344
|
+
private_description << private_release_note
|
345
|
+
private_description << private_repos.value.map do |repo|
|
346
|
+
repo.map { |k, v| "* #{k.upcase}: #{v.value}" }
|
347
|
+
end.join("\n")
|
348
|
+
private_description << "\n\n"
|
349
|
+
end
|
350
|
+
|
351
|
+
private_leads = epic['private_release'].value['leads']
|
352
|
+
if private_leads
|
353
|
+
leads = private_leads.value.map { |lead| "[~#{lead}]" }.join(' ')
|
354
|
+
private_description << "/cc #{leads} \n\n"
|
355
|
+
end
|
356
|
+
|
357
|
+
epic['description'].prepend(private_description)
|
358
|
+
epic['security'] = 'internal'
|
359
|
+
end
|
360
|
+
|
361
|
+
epic_issue = @jira_api.create_issue(epic)
|
362
|
+
epic_issue['short_name'] = short_name
|
363
|
+
@logger.info 'Epic: %16s [%s]' % [epic_issue['key'], short_name]
|
364
|
+
@jira_api.link_issues(@feature_issue['key'], epic_issue['key']) if @feature_issue
|
365
|
+
epic_issue
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
def private_release?(template)
|
370
|
+
if template['private_release']
|
371
|
+
template['private_release'].value['conditional'].truthy?
|
372
|
+
else
|
373
|
+
false
|
374
|
+
end
|
375
|
+
end
|
376
|
+
|
377
|
+
def private_release_note
|
378
|
+
"NOTE: THIS IS A PRIVATE RELEASE. WORK WAS DONE IN THESE PRIVATE REPOS:\n"
|
379
|
+
end
|
380
|
+
|
381
|
+
# Iterate through all issues in the template, creating each one in Jira.
|
382
|
+
# Link the issues back to their epic, if required
|
383
|
+
def generate_ordinary_issues
|
384
|
+
# Need to keep a map of tefoji data and associated responses from Jira to perform
|
385
|
+
# deferred updates, like blocked_by or watchers
|
386
|
+
deferral_data = {}
|
387
|
+
|
388
|
+
issue_defaults = {}
|
389
|
+
if @template_data.key?('issue_defaults')
|
390
|
+
issue_defaults = variable_substitute(@template_data['issue_defaults'])
|
391
|
+
end
|
392
|
+
|
393
|
+
if @default_target_epic
|
394
|
+
@logger.info "- Creating issues for \"#{@default_target_epic['short_name']}\" epic"
|
395
|
+
end
|
396
|
+
|
397
|
+
@template_data['issues'].each do |issue|
|
398
|
+
jira_ready_data, raw_issue_data = prepare_jira_ready_data(issue, issue_defaults)
|
399
|
+
next if jira_ready_data.nil? || raw_issue_data.nil?
|
400
|
+
|
401
|
+
response_data = @jira_api.create_issue(jira_ready_data)
|
402
|
+
jira_issue = @jira_api.retrieve_issue(response_data['self'])
|
403
|
+
jira_issue['short_name'] = raw_issue_data['short_name']
|
404
|
+
|
405
|
+
@logger.info 'Issue %4d: %10s [%s]' % [
|
406
|
+
@issue_counter,
|
407
|
+
jira_issue['key'],
|
408
|
+
jira_issue['fields']['summary']
|
409
|
+
]
|
410
|
+
@issue_counter += 1
|
411
|
+
|
412
|
+
@jira_api.add_issue_to_epic(@default_target_epic, jira_issue) if @default_target_epic
|
413
|
+
|
414
|
+
# deferral_data is kept to match the jira raw request data to the jira response data.
|
415
|
+
# This is needed so that deferred actions (like 'blocked_by') can be correctly mapped.
|
416
|
+
deferral_data[raw_issue_data['short_name'].to_s] = {
|
417
|
+
'jira' => jira_issue,
|
418
|
+
'raw' => raw_issue_data
|
419
|
+
}
|
420
|
+
end
|
421
|
+
process_deferred_updates(deferral_data)
|
422
|
+
end
|
423
|
+
|
424
|
+
# Transform the tefoji issue data and tefoji issue_defaults into a hash that is ready to
|
425
|
+
# send to Jira:
|
426
|
+
#
|
427
|
+
# Perform variable substition
|
428
|
+
# Calculate conditionals
|
429
|
+
# Extract and save deferrals
|
430
|
+
# Apply summary and description formats
|
431
|
+
# Make any security settings
|
432
|
+
#
|
433
|
+
# Return the fully prepared Jira hash as well as the 'raw' variable-substituted one;
|
434
|
+
# The latter will be needed when processing deferrals.
|
435
|
+
def prepare_jira_ready_data(issue, issue_defaults)
|
436
|
+
raw_issue_data = variable_substitute(issue_defaults.merge(issue), except: %w[summary_format])
|
437
|
+
error_name = raw_issue_data['short_name']
|
438
|
+
|
439
|
+
# If the user didn't provide a short_name, make one with a hexdigest.
|
440
|
+
# However, errors should have a more recognizable tag. So, let's use the summary.
|
441
|
+
unless raw_issue_data.key?('short_name')
|
442
|
+
error_name = raw_issue_data['summary']
|
443
|
+
# Used as an id for each of the issues. If the user does not provide one,
|
444
|
+
# make one up with hexdigest
|
445
|
+
raw_issue_data['short_name'] = DeclaredValue.new(
|
446
|
+
Digest::MD5.hexdigest(raw_issue_data['summary'].value)
|
447
|
+
)
|
448
|
+
end
|
449
|
+
|
450
|
+
# For multi-epic templates, skip this issue if it is not part of this epic
|
451
|
+
if raw_issue_data.key?('epic_link') &&
|
452
|
+
raw_issue_data['epic_link'].value != @default_target_epic['short_name'].value
|
453
|
+
@logger.debug 'Issue %s "%s" does not have "%s" as epic_link, skipping' % [
|
454
|
+
raw_issue_data['short_name'].value,
|
455
|
+
raw_issue_data['epic_link'].value,
|
456
|
+
@default_target_epic['short_name'].value
|
457
|
+
]
|
458
|
+
return nil
|
459
|
+
end
|
460
|
+
|
461
|
+
return nil if raw_issue_data.key?('epic_link') &&
|
462
|
+
raw_issue_data['epic_link'].value != @default_target_epic['short_name'].value
|
463
|
+
|
464
|
+
if conflicting_conditionals?(raw_issue_data)
|
465
|
+
fatal "Issue \"#{error_name}\" has conflicting conditionals."
|
466
|
+
end
|
467
|
+
|
468
|
+
# Check for a 'conditional' tag in the data, skip issue generation if falsey
|
469
|
+
if skip_issue?(raw_issue_data)
|
470
|
+
@logger.info 'Issue %14s [%s]' % ['(SKIPPED)', raw_issue_data['summary']]
|
471
|
+
return nil
|
472
|
+
end
|
473
|
+
|
474
|
+
# If 'unless' condition is still unset, it is false.
|
475
|
+
if raw_issue_data.key?('unless') && raw_issue_data['unless'].unset?
|
476
|
+
raw_issue_data['unless'] = DeclaredValue.new(false)
|
477
|
+
end
|
478
|
+
|
479
|
+
@logger.debug "raw_issue_data == #{raw_issue_data}"
|
480
|
+
|
481
|
+
unset_variables = list_unset_variables(raw_issue_data)
|
482
|
+
unless unset_variables.empty?
|
483
|
+
fatal "\"#{error_name}\" has unset variables in these fields: " +
|
484
|
+
unset_variables.join(', ')
|
485
|
+
end
|
486
|
+
|
487
|
+
# raw_issue_data can include some keys that we don't want to use during the
|
488
|
+
# issue creation (like 'blocked_by'). We'll defer these until after
|
489
|
+
# all the issues have been created.
|
490
|
+
jira_ready_data = raw_issue_data.reject { |key, _| deferred_tags.include?(key) }
|
491
|
+
|
492
|
+
# Use summary_format and description_format, should they exist, to create
|
493
|
+
# summary and description fields
|
494
|
+
jira_ready_data['summary'] = special_format(raw_issue_data, 'summary')
|
495
|
+
jira_ready_data['description'] = special_format(raw_issue_data, 'description')
|
496
|
+
|
497
|
+
# If the epic has set the 'security' field, set it here too.
|
498
|
+
jira_ready_data['security'] = @epic_security unless @epic_security.nil?
|
499
|
+
|
500
|
+
@logger.debug "jira_ready_data: #{jira_ready_data}"
|
501
|
+
|
502
|
+
return jira_ready_data, raw_issue_data
|
503
|
+
end
|
504
|
+
|
505
|
+
# Apply a defined format. If no format is defined, just return back the original string.
|
506
|
+
# Also, we sneak in allowing 'short_name' as issue-local variable in formats.
|
507
|
+
def special_format(raw_issue_data, field)
|
508
|
+
format_key = "#{field}_format"
|
509
|
+
|
510
|
+
# Return the unformatted version unless we've defined a format AND are using it here.
|
511
|
+
unless @formats.key?(raw_issue_data[format_key]&.value) && raw_issue_data.key?(format_key)
|
512
|
+
return raw_issue_data[field]
|
513
|
+
end
|
514
|
+
|
515
|
+
format = @formats[raw_issue_data[format_key].value] || raw_issue_data[format_key].value
|
516
|
+
|
517
|
+
# Special-case the 'summary', 'description', and 'short_name' expansions
|
518
|
+
formatted = format.gsub('%{summary}', raw_issue_data['summary'].value)
|
519
|
+
.gsub('%{description}', raw_issue_data['description'].value)
|
520
|
+
.gsub('%{short_name}', raw_issue_data['short_name'].value)
|
521
|
+
|
522
|
+
# Now do generic expansion of anything else
|
523
|
+
DeclaredValue.new(expand_string(formatted))
|
524
|
+
end
|
525
|
+
|
526
|
+
# These tags must not be in the issue creation because they need to be
|
527
|
+
# resolved after the issue is created in Jira.
|
528
|
+
def deferred_tags
|
529
|
+
%w[blocked_by status watchers]
|
530
|
+
end
|
531
|
+
|
532
|
+
# For items in issues that need to be deferred until all the issues are created.
|
533
|
+
# For example, 'blocked_by'
|
534
|
+
#
|
535
|
+
# @param deferral_data [Hash] raw template and jira response data
|
536
|
+
# @see generate_ordinary_issues
|
537
|
+
def process_deferred_updates(deferral_data)
|
538
|
+
## Find deferred tags in the jira_epic_issues and do the required work
|
539
|
+
deferred_tags.each do |deferred_tag|
|
540
|
+
case deferred_tag
|
541
|
+
when 'blocked_by'
|
542
|
+
process_blocked_by(deferral_data)
|
543
|
+
when 'status'
|
544
|
+
process_status(deferral_data)
|
545
|
+
when 'watchers'
|
546
|
+
process_watchers(deferral_data)
|
547
|
+
else
|
548
|
+
raise "Unimplemented deferred_tag: #{deferred_tag}"
|
549
|
+
end
|
550
|
+
end
|
551
|
+
end
|
552
|
+
|
553
|
+
## The process_* methods have repeated code. This could be cleaned up
|
554
|
+
## with some well-thought-through functional techniques. Since the list
|
555
|
+
## of methods is short, I'll pass on that for now.
|
556
|
+
def process_blocked_by(deferral_data)
|
557
|
+
deferred_tag = 'blocked_by'
|
558
|
+
|
559
|
+
deferral_data.each_value do |deferral_hash|
|
560
|
+
raw_issue_data = deferral_hash['raw']
|
561
|
+
jira_issue_data = deferral_hash['jira']
|
562
|
+
next unless raw_issue_data.key?(deferred_tag)
|
563
|
+
|
564
|
+
target_issues = [raw_issue_data[deferred_tag].value].flatten
|
565
|
+
target_issues.each do |target_issue_name|
|
566
|
+
this_issue_key = jira_issue_data['key']
|
567
|
+
|
568
|
+
# If we skipped creating the target issue because of a 'conditional',
|
569
|
+
# don't try to link to it.
|
570
|
+
target_issue_key = deferral_data.dig(target_issue_name, 'jira', 'key')
|
571
|
+
next if target_issue_key.nil?
|
572
|
+
|
573
|
+
@jira_api.link_issues(target_issue_key, this_issue_key)
|
574
|
+
@logger.info '%14s: blocked by %s' % [this_issue_key, target_issue_key]
|
575
|
+
end
|
576
|
+
end
|
577
|
+
end
|
578
|
+
|
579
|
+
def process_status(deferral_data)
|
580
|
+
deferred_tag = 'status'
|
581
|
+
|
582
|
+
deferral_data.each_value do |deferral_hash|
|
583
|
+
raw_issue_data = deferral_hash['raw']
|
584
|
+
jira_issue_data = deferral_hash['jira']
|
585
|
+
next unless raw_issue_data.key?(deferred_tag)
|
586
|
+
|
587
|
+
issue_key = jira_issue_data['key']
|
588
|
+
new_status = raw_issue_data[deferred_tag].value
|
589
|
+
begin
|
590
|
+
@jira_api.transition(issue_key, new_status)
|
591
|
+
rescue RestClient::BadRequest => e
|
592
|
+
fatal "setting the status on #{issue_key} to #{new_status} failed: #{e}"
|
593
|
+
end
|
594
|
+
status_human_readable = jira_statuses.key(new_status.to_i)
|
595
|
+
@logger.info '%14s: status set to %s' % [issue_key, status_human_readable]
|
596
|
+
end
|
597
|
+
end
|
598
|
+
|
599
|
+
def process_watchers(deferral_data)
|
600
|
+
deferred_tag = 'watchers'
|
601
|
+
|
602
|
+
deferral_data.each_value do |deferral_hash|
|
603
|
+
raw_issue_data = deferral_hash['raw']
|
604
|
+
jira_issue_data = deferral_hash['jira']
|
605
|
+
|
606
|
+
next unless raw_issue_data.key?(deferred_tag)
|
607
|
+
|
608
|
+
issue_key = jira_issue_data['key']
|
609
|
+
watchers = raw_issue_data[deferred_tag].value
|
610
|
+
watchers.each do |watcher|
|
611
|
+
@jira_api.add_watcher(issue_key, watcher)
|
612
|
+
@logger.info '%14s: watched by %s' % [issue_key, watcher]
|
613
|
+
end
|
614
|
+
end
|
615
|
+
end
|
616
|
+
|
617
|
+
# Handle user function calls
|
618
|
+
# Instead of a straight assignment, the issue template can have function calls:
|
619
|
+
#
|
620
|
+
# <some_variable>:
|
621
|
+
# function:
|
622
|
+
# name: <function_name>
|
623
|
+
# argument: <argument>
|
624
|
+
# OR
|
625
|
+
# function:
|
626
|
+
# name: <function_name>
|
627
|
+
# arguments:
|
628
|
+
# - <arg1>
|
629
|
+
# - <arg2>
|
630
|
+
# [ etc ]
|
631
|
+
#
|
632
|
+
# Function calls are intended to be simple, hopefully pure, functions, and free
|
633
|
+
# of conditionals. They are intended to help the user reduce repetition in the
|
634
|
+
# templates and allow calculation of simple formula.
|
635
|
+
#
|
636
|
+
# They should not be allowed to grow into a programming language.
|
637
|
+
#
|
638
|
+
# The action user function implementations are kept in a seperate file
|
639
|
+
# (mixins/user_functions.rb) for clarity and documentation ease.
|
640
|
+
|
641
|
+
def user_function_call(function_definition)
|
642
|
+
@logger.debug "function_definition is: #{function_definition}"
|
643
|
+
function_name = function_definition['name']
|
644
|
+
if function_definition.key?('arguments')
|
645
|
+
function_arguments = function_definition['arguments'].map do |a|
|
646
|
+
expand_right_value(a).value
|
647
|
+
end
|
648
|
+
elsif function_definition.key?('argument')
|
649
|
+
function_arguments = [expand_right_value(function_definition['argument']).value]
|
650
|
+
else
|
651
|
+
fatal("No arguments supplied to function \"#{function_name}\"")
|
652
|
+
end
|
653
|
+
|
654
|
+
unless user_function_allowlist.include?(function_name)
|
655
|
+
fatal "Unknown function call: \"#{function_name}\""
|
656
|
+
end
|
657
|
+
|
658
|
+
return_value = send(function_name, function_arguments)
|
659
|
+
@logger.debug "send(#{function_name}, #{function_arguments}) => #{return_value}"
|
660
|
+
DeclaredValue.new(return_value)
|
661
|
+
end
|
662
|
+
|
663
|
+
# Descend through the 'declare:' section, compiling the @declarations dictionary
|
664
|
+
# from it.
|
665
|
+
def resolve_variables(declarations)
|
666
|
+
environment_keys = ENV.keys
|
667
|
+
declarations&.each do |key, value|
|
668
|
+
right_value = expand_right_value(value)
|
669
|
+
|
670
|
+
# Try to resolve unset variables from the environment. Fall through if we
|
671
|
+
# cannot.
|
672
|
+
if right_value.unset?
|
673
|
+
# Convert %{FOO} to FOO
|
674
|
+
environment_variable = value.gsub(/^%{(.+?)}/, '\1')
|
675
|
+
if environment_keys.include?(environment_variable)
|
676
|
+
@logger.info "Setting \"#{key}\" to \"#{ENV[environment_variable]}\" "\
|
677
|
+
'from environment variable.'
|
678
|
+
@declarations[key.to_sym] = DeclaredValue.new(ENV[environment_variable])
|
679
|
+
next
|
680
|
+
end
|
681
|
+
end
|
682
|
+
@declarations[key.to_sym] = right_value
|
683
|
+
end
|
684
|
+
end
|
685
|
+
|
686
|
+
# Perform '%{variable}' substition from the template_data structure on all items in
|
687
|
+
# the template_data with some allowed exceptions
|
688
|
+
def variable_substitute(template_data, except: [])
|
689
|
+
template_data.map do |key, value|
|
690
|
+
substituted = if except.include?(key)
|
691
|
+
DeclaredValue.new(value)
|
692
|
+
else
|
693
|
+
expand_right_value(value)
|
694
|
+
end
|
695
|
+
[key, substituted]
|
696
|
+
end.to_h
|
697
|
+
end
|
698
|
+
|
699
|
+
# Return a value from a key/value pair, substituting variables & function calls
|
700
|
+
# with the evaluated result.
|
701
|
+
def expand_right_value(value)
|
702
|
+
# Some simple cases
|
703
|
+
return value if value.is_a?(DeclaredValue)
|
704
|
+
return DeclaredValue.new(value) if value.is_a?(Numeric)
|
705
|
+
return DeclaredValue.new(expand_string(value)) if value.is_a?(String)
|
706
|
+
|
707
|
+
# Do a user-function call
|
708
|
+
if value.is_a?(Hash) && value.key?('function')
|
709
|
+
return DeclaredValue.new(user_function_call(value['function']))
|
710
|
+
end
|
711
|
+
|
712
|
+
# Return the original hash with the values variable-expanded
|
713
|
+
if value.is_a?(Hash)
|
714
|
+
expanded_hash = value.inject({}) do |hash2, (key2, value2)|
|
715
|
+
expanded_key = expand_right_value(key2)
|
716
|
+
expanded_value = expand_right_value(value2)
|
717
|
+
hash2[expanded_key.value] = expanded_value
|
718
|
+
hash2
|
719
|
+
end
|
720
|
+
return DeclaredValue.new(expanded_hash)
|
721
|
+
end
|
722
|
+
|
723
|
+
# Expand every value element in an array
|
724
|
+
if value.is_a?(Array)
|
725
|
+
return DeclaredValue.new(value.map { |element| expand_right_value(element).value })
|
726
|
+
end
|
727
|
+
|
728
|
+
# Convert booleans to strings
|
729
|
+
# rubocop:disable Style/DoubleNegation
|
730
|
+
return DeclaredValue.new(value.to_s) if !!value == value
|
731
|
+
# rubocop:enable Style/DoubleNegation
|
732
|
+
|
733
|
+
raise "Unimplemented condition in expand_right_value: (#{value.class}) #{value}"
|
734
|
+
end
|
735
|
+
|
736
|
+
def expand_string(value)
|
737
|
+
# Special-case where 'foo: "%{bar}"'; this allows for passing around hashes and
|
738
|
+
# arrays without interpolation into strings
|
739
|
+
single_value = /\A%{(.+?)}\Z/.match(value)
|
740
|
+
return @declarations[single_value[1].to_sym] if single_value
|
741
|
+
|
742
|
+
return value % @declarations
|
743
|
+
end
|
744
|
+
|
745
|
+
# Return a list of all the unset variables in a template
|
746
|
+
def list_unset_variables(template)
|
747
|
+
unset_variables = []
|
748
|
+
template.each do |key, value|
|
749
|
+
if value.is_a?(Hash)
|
750
|
+
unset_variables << list_unset_variables(value)
|
751
|
+
next
|
752
|
+
end
|
753
|
+
if value.unset?
|
754
|
+
unset_variables << key
|
755
|
+
end
|
756
|
+
end
|
757
|
+
unset_variables.flatten
|
758
|
+
end
|
759
|
+
|
760
|
+
def conflicting_conditionals?(template)
|
761
|
+
if template.key?('unless') && (template.key?('conditional') || template.key?('if'))
|
762
|
+
true
|
763
|
+
elsif template.key?('if') && template.key?('conditional')
|
764
|
+
true
|
765
|
+
else
|
766
|
+
false
|
767
|
+
end
|
768
|
+
end
|
769
|
+
|
770
|
+
# Try to find reasons to skip making an issue
|
771
|
+
def skip_issue?(template)
|
772
|
+
if template.key?('conditional')
|
773
|
+
conditional = template['conditional']
|
774
|
+
@logger.debug "Conditional 'conditional' found, value is #{conditional.value}"
|
775
|
+
return conditional.falsey?
|
776
|
+
end
|
777
|
+
|
778
|
+
if template.key?('if')
|
779
|
+
conditional = template['if']
|
780
|
+
@logger.debug "Conditional 'if' found, value is #{conditional.value}"
|
781
|
+
return conditional.falsey?
|
782
|
+
end
|
783
|
+
|
784
|
+
if template.key?('unless')
|
785
|
+
conditional = template['unless']
|
786
|
+
@logger.debug "Conditional 'unless' found, value is #{conditional}"
|
787
|
+
return conditional.truthy?
|
788
|
+
end
|
789
|
+
|
790
|
+
return false
|
791
|
+
end
|
792
|
+
|
793
|
+
# Error-check the expanded template. This section is likely to grow and may want
|
794
|
+
# it's own class.
|
795
|
+
def template_errors?(template_uri, template)
|
796
|
+
return true if nil_template?(template_uri, template) ||
|
797
|
+
missing_expected_keys?(template_uri, template)
|
798
|
+
|
799
|
+
return false
|
800
|
+
end
|
801
|
+
|
802
|
+
def nil_template?(template_uri, template)
|
803
|
+
return false unless template.nil?
|
804
|
+
|
805
|
+
@logger.error "Cannot find any known template keywords in \"#{template_uri}\""
|
806
|
+
return true
|
807
|
+
end
|
808
|
+
|
809
|
+
def missing_expected_keys?(template_uri, template)
|
810
|
+
expected_keys = %w[epic epics feature issues]
|
811
|
+
return false if (template.keys & expected_keys).any?
|
812
|
+
|
813
|
+
@logger.error "Cannot find any known template keywords in \"#{template_uri}\""
|
814
|
+
return true
|
815
|
+
end
|
816
|
+
|
817
|
+
def logger_initialize(log_location)
|
818
|
+
logger = Logger.new(log_location, progname: 'tefoji', level: @log_level)
|
819
|
+
logger.formatter = proc do |severity, _, script_name, message|
|
820
|
+
if severity == 'INFO'
|
821
|
+
"#{message}\n"
|
822
|
+
else
|
823
|
+
"#{script_name}: #{severity} #{message}\n"
|
824
|
+
end
|
825
|
+
end
|
826
|
+
|
827
|
+
logger
|
828
|
+
end
|
829
|
+
end
|
830
|
+
end
|