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