tefoji 1.0.7

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