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.
- checksums.yaml +7 -0
- data/.gitignore +22 -0
- data/.rspec +3 -0
- data/.rubocop.yml +80 -0
- data/.travis.yml +64 -0
- data/CHANGELOG.md +65 -0
- data/CODEOWNERS +7 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +3 -0
- data/Gemfile +7 -0
- data/LICENSE.md +25 -0
- data/README.md +445 -0
- data/Rakefile +6 -0
- data/VERSION.txt +1 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/caretaker.gemspec +34 -0
- data/exe/caretaker +138 -0
- data/lib/caretaker.rb +782 -0
- data/lib/caretaker/version.rb +3 -0
- data/spec/caretaker_spec.rb +5 -0
- data/spec/spec_helper.rb +14 -0
- data/testing/caretaker +141 -0
- metadata +192 -0
data/Rakefile
ADDED
data/VERSION.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.8.0
|
data/bin/console
ADDED
|
@@ -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__)
|
data/bin/setup
ADDED
data/caretaker.gemspec
ADDED
|
@@ -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
|
data/exe/caretaker
ADDED
|
@@ -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
|
+
# -------------------------------------------------------------------------------- #
|
data/lib/caretaker.rb
ADDED
|
@@ -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
|