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.
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