caretaker 0.8.0

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.
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1 @@
1
+ 0.8.0
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'caretaker'
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require 'irb'
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,34 @@
1
+ lib = File.expand_path('lib', __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require 'caretaker/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'caretaker'
7
+ spec.version = Caretaker::VERSION
8
+ spec.authors = ['Tim Gurney aka Wolf']
9
+ spec.email = ['wolf@tgwolf.com']
10
+
11
+ spec.summary = %q{An automated changelog generator.}
12
+ spec.description = %q{A gem for automatically generating a CHANGELOG.md from git log.}
13
+ spec.homepage = 'https://github.com/WolfSoftware/caretaker'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+
18
+ spec.bindir = 'exe'
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ['lib']
21
+
22
+ spec.required_ruby_version = '>= 2.5'
23
+
24
+ spec.add_development_dependency 'bundler', '~> 2'
25
+ spec.add_development_dependency 'date', '~> 2.0.0'
26
+ spec.add_development_dependency 'rake', '~> 12.3.3'
27
+ spec.add_development_dependency 'rspec', '~> 3.0'
28
+ spec.add_development_dependency 'sem_version', '~> 2.0.1'
29
+ spec.add_development_dependency 'tty-spinner', '~> 0.9.0'
30
+
31
+ spec.add_runtime_dependency 'date', '~> 2.0.0'
32
+ spec.add_runtime_dependency 'sem_version', '~> 2.0.1'
33
+ spec.add_runtime_dependency 'tty-spinner', '~> 0.9.0'
34
+ end
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'caretaker'
5
+
6
+ # -------------------------------------------------------------------------------- #
7
+ # Send Mssage to Slack #
8
+ # -------------------------------------------------------------------------------- #
9
+ # This function will take the input arguments and then send the message. #
10
+ # -------------------------------------------------------------------------------- #
11
+
12
+ def send_message_to_caretaker(options)
13
+ begin
14
+ caretaker = Caretaker.new(options)
15
+
16
+ caretaker.init_repo if options[:init]
17
+ caretaker.generate_config_file if options[:config]
18
+ caretaker.generate_changelog if options[:generate]
19
+ caretaker.bump_version if options[:bump]
20
+ rescue StandardError => e
21
+ puts "Error: #{e}"
22
+ puts e.backtrace
23
+ exit(1)
24
+ end
25
+ end
26
+
27
+ # -------------------------------------------------------------------------------- #
28
+ # Process Arguments #
29
+ # -------------------------------------------------------------------------------- #
30
+ # This function will process the input from the command line and work out what it #
31
+ # is that the user wants to see. #
32
+ # #
33
+ # This is the main processing function where all the processing logic is handled. #
34
+ # -------------------------------------------------------------------------------- #
35
+
36
+ def process_arguments
37
+ options = { :generate => true, :init => false, :config => false, :enable_categories => false, :verbose => false }
38
+ # Enforce the presence of
39
+ mandatory = %I[]
40
+
41
+ optparse = OptionParser.new do |opts|
42
+ opts.banner = "Usage: #{$PROGRAM_NAME}"
43
+
44
+ opts.on('-h', '--help', 'Display this screen') do
45
+ puts opts
46
+ exit(1)
47
+ end
48
+
49
+ opts.on('-a', '--author string', 'Specify a default author name to use for commits (author name should be your Github username)') do |author|
50
+ options[:author] = author
51
+ end
52
+
53
+ opts.on('-b', '--bump string', 'Which part of the version string to bump. (Options: major, minor, patch)') do |bump|
54
+ valid_bumps = ['major', 'minor', 'patch']
55
+
56
+ if valid_bumps.include? bump
57
+ options[:bump] = bump
58
+ options[:config] = false
59
+ options[:generate] = false
60
+ options[:init] = false
61
+ else
62
+ puts "Invalid bump option: #{bump}"
63
+ abort
64
+ end
65
+ end
66
+
67
+ opts.on('-c', '--config', 'Generate a .caretaker.cfg config file. [default: false]') do
68
+ options[:bump] = false
69
+ options[:config] = true
70
+ options[:generate] = false
71
+ options[:init] = false
72
+ end
73
+
74
+ opts.on('-e', '--enable-categories', 'Enable the splitting of commit messages into categories. [default: false]') do
75
+ options[:enable_categories] = true
76
+ options[:remove_categories] = true
77
+ end
78
+
79
+ opts.on('-i', '--init', 'Initialise the repo to use Caretaker') do
80
+ options[:bump] = false
81
+ options[:config] = false
82
+ options[:generate] = false
83
+ options[:init] = true
84
+ end
85
+
86
+ opts.on('-o', '--output string', 'Set the name of the output file. [default: CHANGELOG.md]') do |output|
87
+ options[:output] = output
88
+ end
89
+
90
+ opts.on('-r', '--remove-categories', 'Remove categories from commit messages. --enable-categories sets this to true') do
91
+ options[:remove_categories] = true
92
+ end
93
+
94
+ opts.on('-s', '--silent', 'Turn off all output from Caretaker, aka Silent Mode') do
95
+ options[:silent] = true
96
+ end
97
+
98
+ opts.on('-u', '--url-verification', 'Verify each url to ensure that the links are valid, skip any links that are not') do
99
+ options[:verify_urls] = true
100
+ end
101
+
102
+ opts.on('-w', '--words number', 'Minimum number of words needed to include a commit. [default: 1]') do |words|
103
+ options[:min_words] = words
104
+ end
105
+ end
106
+
107
+ begin
108
+ optparse.parse!
109
+ missing = mandatory.select { |param| options[param].nil? }
110
+ raise OptionParser::MissingArgument.new(missing.join(', ')) unless missing.empty?
111
+ rescue OptionParser::InvalidOption, OptionParser::MissingArgument => e
112
+ puts e.to_s
113
+ puts optparse
114
+ exit
115
+ end
116
+
117
+ exit 0 if send_message_to_caretaker(options)
118
+
119
+ exit 1
120
+ end
121
+
122
+ # -------------------------------------------------------------------------------- #
123
+ # Main() #
124
+ # -------------------------------------------------------------------------------- #
125
+ # The main function where all of the heavy lifting and script config is done. #
126
+ # -------------------------------------------------------------------------------- #
127
+
128
+ def main
129
+ process_arguments
130
+ end
131
+
132
+ main
133
+
134
+ # -------------------------------------------------------------------------------- #
135
+ # End of Script #
136
+ # -------------------------------------------------------------------------------- #
137
+ # This is the end - nothing more to see here. #
138
+ # -------------------------------------------------------------------------------- #
@@ -0,0 +1,782 @@
1
+ require 'caretaker/version'
2
+
3
+ require 'date'
4
+ require 'open3'
5
+ require 'tty-spinner'
6
+ require 'uri'
7
+ require 'yaml'
8
+ require 'net/http'
9
+
10
+ require 'sem_version'
11
+ require 'sem_version/core_ext'
12
+
13
+ #
14
+ # The main caretaker class
15
+ #
16
+ class Caretaker
17
+ #
18
+ # initialize the class - called when Caretaker.new is called.
19
+ #
20
+ def initialize(options = {})
21
+ #
22
+ # Global variables
23
+ #
24
+ @name = 'Caretaker'
25
+ @executable = 'caretaker'
26
+ @config_file = '.caretaker.yml'
27
+ @default_category = 'Uncategorised:'
28
+ @github_base_url = 'https://github.com'
29
+ @spinner_format = :classic
30
+ @header_file = 'HEADER.md'
31
+ @version_file = 'VERSION.txt'
32
+ @default_version = '0.1.0'
33
+ @default_tag_prefix = 'v'
34
+
35
+ @bump_major = false
36
+ @bump_minor = false
37
+ @bump_patch = false
38
+
39
+ #
40
+ # Check we are into a git repository - bail out if not
41
+ #
42
+ @repo_base_dir = execute_command('git rev-parse --show-toplevel')
43
+ raise StandardError.new('Directory does not contain a git repository') if @repo_base_dir.nil?
44
+
45
+ #
46
+ # Set default values - Can be overridden by config file and/or command line options
47
+ #
48
+ @author = nil
49
+ @enable_categories = false
50
+ @min_words = 1
51
+ @output_file = 'CHANGELOG.md'
52
+ @remove_categories = false
53
+ @silent = false
54
+ @verify_urls = false
55
+ #
56
+ # Load the config if it exists
57
+ #
58
+ load_config
59
+
60
+ #
61
+ # Override the defaults and/or with command line options.
62
+ #
63
+ @author = options[:author] unless options[:author].nil?
64
+ @enable_categories = options[:enable_categories] unless options[:enable_categories].nil?
65
+ @min_words = options[:min_words].to_i unless options[:min_words].nil?
66
+ @output_file = options[:output] unless options[:output].nil?
67
+ @remove_categories = options[:remove_categories] unless options[:remove_categories].nil?
68
+ @silent = options[:silent] unless options[:silent].nil?
69
+ @verify_urls = options[:verify_urls] unless options[:verify_urls].nil?
70
+
71
+ @bump_major = true unless options[:bump].nil? || options[:bump] != 'major'
72
+ @bump_minor = true unless options[:bump].nil? || options[:bump] != 'minor'
73
+ @bump_patch = true unless options[:bump].nil? || options[:bump] != 'patch'
74
+ #
75
+ # Work out the url for the git repository (unless for linking)
76
+ #
77
+ repo_url = execute_command('git config --get remote.origin.url')
78
+ repo_url = repo_url.gsub(':', '/').gsub('git@', 'https://') if repo_url.start_with?('git@')
79
+ uri = URI.parse(repo_url)
80
+ @repository_remote_url = "#{uri.scheme}://#{uri.host}#{uri.path}"
81
+
82
+ #
83
+ # Global working variables - used to generate the changelog
84
+ #
85
+ @changelog = ''
86
+ @last_tag = '0'
87
+ @spinner = nil
88
+ @tags = []
89
+ @url_cache = {}
90
+ @cache_hits = 0
91
+ @cache_misses = 0
92
+
93
+ #
94
+ # The categories we use
95
+ #
96
+ @categories = {
97
+ 'New Features:' => [ 'new feature:', 'new:', 'feature:' ],
98
+ 'Improvements:' => [ 'improvement:' ],
99
+ 'Bug Fixes:' => [ 'bug fix:', 'bug:', 'bugs:' ],
100
+ 'Security Fixes:' => [ 'security: '],
101
+ 'Refactor:' => [],
102
+ 'Style:' => [],
103
+ 'Deprecated:' => [],
104
+ 'Removed:' => [],
105
+ 'Tests:' => [ 'test:', 'testing:' ],
106
+ 'Documentation:' => [ 'docs: ' ],
107
+ 'Chores:' => [ 'chore:' ],
108
+ 'Experiments:' => [ 'experiment:' ],
109
+ 'Miscellaneous:' => [ 'misc:' ],
110
+ 'Uncategorised:' => [],
111
+ 'Initial Commit:' => [ 'initial' ],
112
+ 'Skip:' => [ 'ignore' ]
113
+ }
114
+ end
115
+
116
+ #
117
+ # Execute a command and collect the stdout
118
+ #
119
+ def execute_command(cmd)
120
+ Open3.popen3(cmd) do |_stdin, stdout, _stderr, wait_thr|
121
+ return stdout.read.chomp if wait_thr.value.success?
122
+ end
123
+ return nil
124
+ end
125
+
126
+ #
127
+ # Write a file into the repo and set permissions on it
128
+ #
129
+ def write_file(filename, contents, permissions = 0o0644)
130
+ begin
131
+ File.open(filename, 'w') do |f|
132
+ f.puts contents
133
+ f.chmod(permissions)
134
+ end
135
+ rescue SystemCallError
136
+ raise StandardError.new("Failed to open file #{filename} for writing")
137
+ end
138
+ end
139
+
140
+ #
141
+ # Read a file fromthe repo and return the contents
142
+ #
143
+ def read_file(filename, show_error = false)
144
+ contents = nil
145
+
146
+ begin
147
+ File.open(filename, 'r') do |f|
148
+ contents = f.read
149
+ end
150
+ rescue SystemCallError
151
+ puts "Error reading file: #{filename}" unless show_error == false
152
+ end
153
+ return contents
154
+ end
155
+
156
+ #
157
+ # Make sure a url is value - but only if verify_urls = true
158
+ #
159
+ def valid_url(url, first = false)
160
+ return true if @verify_urls == false || first == true
161
+
162
+ url_hash = Digest::SHA2.hexdigest(url).to_s
163
+
164
+ if @url_cache[url_hash.to_s]
165
+ @cache_hits += 1
166
+ return @url_cache[url_hash]
167
+ end
168
+
169
+ @cache_misses += 1
170
+
171
+ url = URI.parse(url)
172
+ req = Net::HTTP.new(url.host, url.port)
173
+ req.use_ssl = true
174
+ res = req.request_head(url.path)
175
+
176
+ @url_cache[url_hash.to_s] = if res.code == '200'
177
+ true
178
+ else
179
+ false
180
+ end
181
+
182
+ return true if res.code == '200'
183
+
184
+ return false
185
+ end
186
+
187
+ #
188
+ # Add an ordinal to a date
189
+ #
190
+ def ordinal(number)
191
+ abs_number = number.to_i.abs
192
+
193
+ if (11..13).include?(abs_number % 100)
194
+ 'th'
195
+ else
196
+ case abs_number % 10
197
+ when 1 then 'st'
198
+ when 2 then 'nd'
199
+ when 3 then 'rd'
200
+ else 'th'
201
+ end
202
+ end
203
+ end
204
+
205
+ #
206
+ # Format a date in the format that we want it
207
+ #
208
+ def format_date(date_string)
209
+ d = Date.parse(date_string)
210
+
211
+ day = d.strftime('%-d')
212
+ ordinal = ordinal(day)
213
+ month = d.strftime('%B')
214
+ year = d.strftime('%Y')
215
+ return "#{month}, #{day}#{ordinal} #{year}"
216
+ end
217
+
218
+ #
219
+ # Extra the release tags from a commit reference
220
+ #
221
+ def extract_tag(refs, old_tag)
222
+ tag = old_tag
223
+ if refs.include? 'tag: '
224
+ refs = refs.gsub(/.*tag:/i, '')
225
+ refs = refs.gsub(/,.*/i, '')
226
+ tag = refs.gsub(/\).*/i, '')
227
+ end
228
+ return tag.strip
229
+ end
230
+
231
+ #
232
+ # Work out what category a commit belongs to - return default if we cannot find one (or a matching one)
233
+ #
234
+ def get_category(subject)
235
+ @categories.each do |category, array|
236
+ return category if subject.downcase.start_with?(category.downcase)
237
+
238
+ next unless array.count.positive?
239
+
240
+ array.each do |a|
241
+ return category if subject.downcase.start_with?(a.downcase)
242
+ end
243
+ end
244
+ return @default_category
245
+ end
246
+
247
+ #
248
+ # Get the commit messages for child commits (pull requests)
249
+ #
250
+ def get_child_messages(parent)
251
+ return execute_command "git log --pretty=format:'%b' -n 1 #{parent}"
252
+ end
253
+
254
+ #
255
+ # Process the username if we find out or if the @author variable is set
256
+ #
257
+ def process_usernames(message)
258
+ if message.scan(/.*(\{.*\}).*/m).size.positive?
259
+ m = message.match(/.*(\{.*\}).*/)
260
+ message = message.sub(/\{.*\}/, '').strip
261
+ username = m[1].gsub(/[{}]/, '')
262
+
263
+ message += " [`[#{username}]`](#{@github_base_url}/#{username})" if valid_url "#{@github_base_url}/#{username})"
264
+ elsif valid_url "#{@github_base_url}/#{@author}"
265
+ message += " [`[#{@author}]`](#{@github_base_url}/#{@author})" unless @author.nil?
266
+ end
267
+
268
+ return message.squeeze(' ')
269
+ end
270
+
271
+ #
272
+ # See of the commit links to an issue and add a link if it does.
273
+ #
274
+ def process_issues(message)
275
+ if message.scan(/.*\(issue-(\d+)\).*/m).size.positive?
276
+ m = message.match(/.*\(issue-(\d+)\).*/)
277
+
278
+ issue_number = m[1]
279
+ issue_link = "[`[##{issue_number}]`](#{@repository_remote_url}/issues/#{issue_number})"
280
+
281
+ message = message.sub(/(\(issue-\d+\))/, issue_link).strip if valid_url "#{@repository_remote_url}/issues/#{issue_number}"
282
+ end
283
+ return message
284
+ end
285
+
286
+ #
287
+ # Controller function for processing the subject (body) of a commit messages
288
+ #
289
+ def process_subject(subject, hash, hash_full, first)
290
+ if subject.scan(/Merge pull request #(\d+).*/m).size.positive?
291
+ m = subject.match(/Merge pull request #(\d+).*/)
292
+ pr = m[1]
293
+
294
+ child_message = get_child_messages hash
295
+ child_message ||= subject
296
+
297
+ message = child_message.to_s
298
+ message += " [`[##{pr}]`](#{@repository_remote_url}/pull/#{pr})" if valid_url "#{@repository_remote_url}/pull/#{pr}"
299
+ message = process_usernames(message)
300
+ elsif subject.scan(/\.*\(#(\d+)\)*\)/m).size.positive?
301
+ m = subject.match(/\.*\(#(\d+)\)*\)/)
302
+ pr = m[1]
303
+
304
+ child_message = get_child_messages hash
305
+
306
+ subject = subject.sub(/\.*\(#(\d+)\)*\)/, '').strip
307
+ message = subject.to_s
308
+ message += " [`[##{pr}]`](#{@repository_remote_url}/pull/#{pr})" if valid_url "#{@repository_remote_url}/pull/#{pr}"
309
+ message = process_usernames(message)
310
+ unless child_message.empty?
311
+ child_message = child_message.gsub(/[*]/i, ' *')
312
+ message += "\n\n#{child_message}"
313
+ end
314
+ else
315
+ message = subject.to_s
316
+ message += " [`[#{hash}]`](#{@repository_remote_url}/commit/#{hash_full})" if valid_url("#{@repository_remote_url}/commit/#{hash_full}", first)
317
+ end
318
+ message = process_usernames(message)
319
+ message = process_issues(message)
320
+
321
+ return message
322
+ end
323
+
324
+ #
325
+ # Count the REAL words in a subject
326
+ #
327
+ def count_words(string)
328
+ string = string.gsub(/(\(|\[|\{).+(\)|\]|\})/, '')
329
+ return string.split.count
330
+ end
331
+
332
+ #
333
+ # Process the hash containing the commit messages
334
+ #
335
+ def process_results(results)
336
+ processed = {}
337
+ first = true
338
+
339
+ results.each do |tag, array|
340
+ if @enable_categories
341
+ processed[tag.to_s] = {}
342
+
343
+ @categories.each do |category|
344
+ processed[tag.to_s][category.to_s] = []
345
+ end
346
+ else
347
+ processed[tag.to_s] = []
348
+ end
349
+ array.each do |a|
350
+ a[:subject] = process_subject(a[:subject], a[:hash], a[:hash_full], first)
351
+ category = get_category(a[:subject]).to_s
352
+
353
+ next if category == 'Skip:'
354
+
355
+ if @enable_categories || @remove_categories
356
+ a[:subject] = a[:subject].sub(/.*?:/, '').strip if category != @default_category
357
+ end
358
+
359
+ next if count_words(a[:subject]) < @min_words
360
+
361
+ if @enable_categories
362
+ (processed[tag.to_s][category.to_s] ||= []) << a
363
+ else
364
+ (processed[tag.to_s] ||= []) << a
365
+ end
366
+ first = false
367
+ end
368
+ end
369
+ return processed
370
+ end
371
+
372
+ #
373
+ # Convert the commit messages (git log) into a hash
374
+ #
375
+ def log_to_hash
376
+ docs = {}
377
+ tag = '0'
378
+ old_parent = ''
379
+
380
+ res = execute_command("git log --oneline --pretty=format:'%h|%H|%P|%d|%s|%cd'")
381
+ unless res.nil?
382
+ res.each_line do |line|
383
+ hash, hash_full, parent, refs, subject, date = line.split('|')
384
+ parent = parent.split(' ')[0]
385
+ tag = extract_tag(refs, tag).to_s
386
+
387
+ @last_tag = tag if @last_tag == '0' && tag != '0'
388
+
389
+ if parent != old_parent
390
+ (docs[tag.to_s] ||= []) << { :hash => hash, :hash_full => hash_full, :parent => parent, :subject => subject }
391
+
392
+ if tag != 0
393
+ @tags << { tag => format_date(date) } unless @tags.any? { |h| h[tag] }
394
+ end
395
+ end
396
+ old_parent = parent
397
+ end
398
+ @tags = @tags.uniq
399
+ end
400
+ return docs
401
+ end
402
+
403
+ #
404
+ # Generate the changelog header banner
405
+ #
406
+ def output_changelog_header
407
+ contents = nil
408
+
409
+ locations = [ @header_file.to_s, "docs/#{@header_file}" ]
410
+
411
+ locations.each do |loc|
412
+ contents = read_file(loc)
413
+ break unless contents.nil?
414
+ end
415
+
416
+ if contents.nil?
417
+ @changelog += "# Changelog\n\n"
418
+ @changelog += "All notable changes to this project will be documented in this file.\n\n"
419
+ else
420
+ @changelog += contents
421
+ end
422
+
423
+ @changelog += "\nThis changelog was automatically generated using [#{@name}](#{@repository_remote_url}) by [Wolf Software](https://github.com/WolfSoftware)\n\n"
424
+ end
425
+
426
+ #
427
+ # Write a version header and release date
428
+ #
429
+ def output_version_header(tag, releases)
430
+ num_tags = @tags.count
431
+ tag_date = get_tag_date(tag)
432
+
433
+ current_tag = if releases < num_tags
434
+ @tags[releases].keys.first
435
+ else
436
+ 0
437
+ end
438
+
439
+ previous_tag = if releases + 1 < num_tags
440
+ @tags[releases + 1].keys.first
441
+ else
442
+ 0
443
+ end
444
+
445
+ if tag == '0'
446
+ @changelog += if num_tags != 0
447
+ "### [Unreleased](#{@repository_remote_url}/compare/#{@last_tag}...HEAD)\n\n"
448
+ else
449
+ "### [Unreleased](#{@repository_remote_url}/commits/master)\n\n"
450
+ end
451
+ elsif current_tag != 0
452
+ @changelog += if previous_tag != 0
453
+ "### [#{current_tag}](#{@repository_remote_url}/compare/#{previous_tag}...#{current_tag})\n\n"
454
+ else
455
+ "### [#{current_tag}](#{@repository_remote_url}/releases/#{current_tag})\n\n"
456
+ end
457
+ @changelog += "> Released on #{tag_date}\n\n"
458
+ end
459
+ end
460
+
461
+ #
462
+ # Work out the date of the tag/release
463
+ #
464
+ def get_tag_date(search)
465
+ @tags.each do |hash|
466
+ return hash[search.to_s] if hash[search.to_s]
467
+ end
468
+ return 'Unknown'
469
+ end
470
+
471
+ #
472
+ # Start the spinner - we all like pretty output
473
+ #
474
+ def start_spinner(message)
475
+ return if @silent
476
+
477
+ @spinner&.stop('Done!')
478
+
479
+ @spinner = TTY::Spinner.new("[:spinner] #{message}", format: @spinner_format)
480
+ @spinner.auto_spin
481
+ end
482
+
483
+ #
484
+ # Stop the spinner
485
+ #
486
+ def stop_spinner
487
+ return if @silent
488
+
489
+ @spinner.stop('Done!')
490
+ @spinner = nil
491
+ end
492
+
493
+ #
494
+ # Display cache stats
495
+ #
496
+ def cache_stats
497
+ return unless @verify_urls
498
+
499
+ total = @cache_hits + @cache_misses
500
+
501
+ percentage = if total.positive?
502
+ (@cache_hits.to_f / total * 100.0).ceil if total.positive?
503
+ else
504
+ 0
505
+ end
506
+ puts "[Cache Stats] Total: #{total}, Hits: #{@cache_hits}, Misses: #{@cache_misses}, Hit Percentage: #{percentage}%" unless @silent
507
+ end
508
+
509
+ #
510
+ # Generate the changelog
511
+ #
512
+ def generate_changelog
513
+ message = "#{@name} is generating your changelog ("
514
+
515
+ message += if @enable_categories
516
+ 'with categories'
517
+ else
518
+ 'without categories'
519
+ end
520
+
521
+ message += if @remove_categories
522
+ ', remove categories'
523
+ else
524
+ ', retain categories'
525
+ end
526
+
527
+ message += if @verify_urls
528
+ ', verify urls'
529
+ else
530
+ ', assume urls'
531
+ end
532
+
533
+ message += if @author.nil?
534
+ ', no author'
535
+ else
536
+ ", author=#{@author}"
537
+ end
538
+ message += ')'
539
+
540
+ puts "> #{@name} is generating your changeog #{message}" unless @silent
541
+
542
+ start_spinner('Retreiving git log')
543
+ results = log_to_hash
544
+
545
+ start_spinner('Processing entries')
546
+ processed = process_results(results)
547
+
548
+ releases = 0
549
+ start_spinner('Preparing output')
550
+ output_changelog_header
551
+
552
+ processed.each do |tag, entries|
553
+ output_version_header(tag, releases)
554
+
555
+ if @enable_categories
556
+ if entries.count.positive?
557
+ entries.each do |category, array|
558
+ next unless array.count.positive?
559
+
560
+ @changelog += "###### #{category}\n\n"
561
+
562
+ array.each do |row|
563
+ @changelog += "- #{row[:subject]}\n\n"
564
+ end
565
+ end
566
+ end
567
+ else
568
+ entries.each do |row|
569
+ @changelog += "- #{row[:subject]}\n\n"
570
+ end
571
+ end
572
+ releases += 1
573
+ end
574
+ start_spinner('Writing Changelog')
575
+
576
+ write_file("#{@repo_base_dir}/#{@output_file}", @changelog)
577
+
578
+ stop_spinner
579
+
580
+ cache_stats
581
+ end
582
+
583
+ #
584
+ # Configure a repository to use Caretaker
585
+ #
586
+ def init_repo
587
+ cmd = @executable.to_s
588
+
589
+ cmd += " -a #{@author}" unless @author.nil?
590
+ cmd += ' -e' if @enable_categories
591
+ cmd += ' -r' if @remove_categories
592
+ cmd += ' -s' if @silent
593
+ cmd += ' -v' if @verify_urls
594
+ cmd += " -w #{@min_words}" unless @min_words.nil?
595
+
596
+ puts "> #{@name} is creating a custom post-commit hook" unless @silent
597
+ start_spinner('Generating Hook')
598
+ contents = <<~END_OF_SCRIPT
599
+ #!/usr/bin/env bash
600
+
601
+ LOCK_FILE="#{@repo_base_dir}/.lock"
602
+ OUTPUT_FILE=#{@output_file}
603
+ VERSION_FILE=#{@version_file}
604
+
605
+ if [[ -f "${LOCK_FILE}" ]]; then
606
+ exit
607
+ fi
608
+
609
+ touch "${LOCK_FILE}"
610
+
611
+ if [[ -f "${VERSION_FILE}" ]]; then
612
+ RELEASE_VERSION=$(<"${VERSION_FILE}")
613
+ TAG_NAME="v${RELEASE_VERSION}"
614
+
615
+ if GIT_DIR="#{@repo_base_dir}/.git" git tag --list | grep -Eq "^${TAG_NAME}$"; then
616
+ unset RELEASE_VERSION
617
+ unset TAG_NAME
618
+ fi
619
+ fi
620
+
621
+ if [[ -n "${RELEASE_VERSION}" ]]; then
622
+ git tag "${TAG_NAME}"
623
+ fi
624
+
625
+ #{cmd}
626
+
627
+ res=$(git status --porcelain | grep -c "${OUTPUT_FILE}")
628
+ if [[ "${res}" -gt 0 ]]; then
629
+
630
+ git add "${OUTPUT_FILE}" >> /dev/null 2>&1
631
+ git commit --amend --no-edit >> /dev/null 2>&1
632
+
633
+ if [[ -n "${RELEASE_VERSION}" ]]; then
634
+ git tag -f "${TAG_NAME}"
635
+ fi
636
+
637
+ fi
638
+
639
+ rm -f "${LOCK_FILE}"
640
+ END_OF_SCRIPT
641
+
642
+ start_spinner('Writing Hook')
643
+ write_file("#{@repo_base_dir}/.git/hooks/post-commit", contents, 0o0755)
644
+ stop_spinner
645
+ end
646
+
647
+ #
648
+ # Load the configuration if it exists
649
+ #
650
+ def load_config
651
+ locations = [ "#{@repo_base_dir}/#{@config_file}", ENV['HOME'] ]
652
+
653
+ #
654
+ # Traverse the entire directory path
655
+ #
656
+ dirs = Dir.getwd.split(File::SEPARATOR).map { |x| x == '' ? File::SEPARATOR : x }[1..-1]
657
+ while dirs.length.positive?
658
+ path = '/' + dirs.join('/')
659
+ locations << path
660
+ dirs.pop
661
+ end
662
+ locations << '/'
663
+
664
+ locations.each do |loc|
665
+ config = read_file(loc)
666
+
667
+ next if config.nil?
668
+
669
+ yaml_hash = YAML.safe_load(config)
670
+
671
+ puts "Using config located in #{loc}" unless @silent
672
+
673
+ @author = yaml_hash['author'] if yaml_hash['author']
674
+ @enable_categories = true if yaml_hash['enable-categories']
675
+ @min_words = yaml_hash['min-words'].to_i if yaml_hash['min-words']
676
+ @output_file = yaml_hash['output-file'] if yaml_hash['output-file']
677
+ @remove_categories = true if yaml_hash['remove-categories']
678
+ @silent = true if yaml_hash['silent']
679
+ @verify_urls = true if yaml_hash['verify-urls']
680
+ break
681
+ end
682
+ end
683
+
684
+ #
685
+ # Generate the configuration file
686
+ #
687
+ def generate_config_file
688
+ puts "> #{@name} is creating your config file"
689
+ start_spinner('Generating Config')
690
+
691
+ content = "---\n"
692
+
693
+ content += "author: #{@author}\n" unless @author.nil?
694
+
695
+ content += if @enable_categories
696
+ "enable-categories: true\n"
697
+ else
698
+ "enable-categories: false\n"
699
+ end
700
+
701
+ content += "min-words: #{@min_words}\n" unless @min_words.nil?
702
+ content += "output-file: #{@output_file}\n" unless @output_file.nil?
703
+
704
+ content += if @remove_categories
705
+ "remove-categories: true\n"
706
+ else
707
+ "remove-categories: false\n"
708
+ end
709
+
710
+ content += if @silent
711
+ "silent: true\n"
712
+ else
713
+ "silent: false\n"
714
+ end
715
+
716
+ content += if @verify_urls
717
+ "verify-urls: true\n"
718
+ else
719
+ "verify-urls: false\n"
720
+ end
721
+
722
+ start_spinner('Writing config')
723
+ write_file("#{@repo_base_dir}/#{@config_file}", content, 0o0644)
724
+ stop_spinner
725
+ end
726
+
727
+ #
728
+ # Bump the version
729
+ #
730
+ def bump_version
731
+ first_version = false
732
+
733
+ begin
734
+ current = File.read(@version_file)
735
+ rescue SystemCallError
736
+ puts "failed to open #{@version_file} - Default to version #{@default_version} - will NOT bump version"
737
+ current = @default_version
738
+ first_version = true
739
+ end
740
+
741
+ puts "Current Version: #{current}"
742
+
743
+ if current.start_with?(@default_tag_prefix)
744
+ has_prefix = true
745
+ current.slice!(@default_tag_prefix)
746
+ end
747
+
748
+ begin
749
+ v = SemVersion.new(current)
750
+ rescue ArgumentError
751
+ puts "#{current} is not a valid Semantic Version string"
752
+ return
753
+ end
754
+
755
+ if @bump_major && !first_version
756
+ v.major += 1
757
+ v.minor = 0
758
+ v.patch = 0
759
+ end
760
+
761
+ if @bump_minor && !first_version
762
+ v.minor += 1
763
+ v.patch = 0
764
+ end
765
+
766
+ v.patch += 1 if @bump_patch && !first_version
767
+
768
+ version = if has_prefix
769
+ "#{@default_tag_prefix}#{v}"
770
+ else
771
+ v.to_s
772
+ end
773
+
774
+ puts "New Version: #{version}"
775
+
776
+ begin
777
+ File.write(@version_file, version)
778
+ rescue SystemCallError
779
+ puts "Count not write #{VERSION_FILE} - Aborting"
780
+ end
781
+ end
782
+ end