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