schmersion 1.0.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/VERSION +1 -0
- data/bin/schmersion +20 -0
- data/cli/help.rb +21 -0
- data/cli/init.rb +49 -0
- data/cli/lint-prepare.rb +14 -0
- data/cli/lint-validate.rb +28 -0
- data/cli/log.rb +28 -0
- data/cli/pending.rb +49 -0
- data/cli/release.rb +40 -0
- data/cli/setup-linting.rb +22 -0
- data/cli/versions.rb +14 -0
- data/lib/schmersion.rb +4 -0
- data/lib/schmersion/commit.rb +23 -0
- data/lib/schmersion/commit_parser.rb +60 -0
- data/lib/schmersion/config.rb +67 -0
- data/lib/schmersion/error.rb +6 -0
- data/lib/schmersion/formatter.rb +30 -0
- data/lib/schmersion/formatters.rb +15 -0
- data/lib/schmersion/formatters/markdown.rb +108 -0
- data/lib/schmersion/formatters/yaml.rb +63 -0
- data/lib/schmersion/helpers.rb +32 -0
- data/lib/schmersion/host.rb +25 -0
- data/lib/schmersion/hosts.rb +25 -0
- data/lib/schmersion/hosts/github.rb +50 -0
- data/lib/schmersion/linter.rb +147 -0
- data/lib/schmersion/message.rb +95 -0
- data/lib/schmersion/message_validator.rb +67 -0
- data/lib/schmersion/releaser.rb +135 -0
- data/lib/schmersion/repo.rb +127 -0
- data/lib/schmersion/schmersion_version.rb +12 -0
- data/lib/schmersion/version.rb +32 -0
- data/lib/schmersion/version_calculator.rb +71 -0
- metadata +207 -0
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
class Formatter
|
5
|
+
|
6
|
+
attr_reader :filename
|
7
|
+
|
8
|
+
def initialize(filename, options = {})
|
9
|
+
@filename = filename
|
10
|
+
@options = options
|
11
|
+
end
|
12
|
+
|
13
|
+
# Generate the output which should be included in the export. This
|
14
|
+
# should return a string which will also be displayed when doing
|
15
|
+
# dry-runs of a release.
|
16
|
+
# rubocop:disable Lint/UnusedMethodArgument
|
17
|
+
def generate(repo, version)
|
18
|
+
''
|
19
|
+
end
|
20
|
+
# rubocop:enable Lint/UnusedMethodArgument
|
21
|
+
|
22
|
+
# Insert a given part into the given source file path
|
23
|
+
#
|
24
|
+
# @param part [String]
|
25
|
+
# @return [void]
|
26
|
+
def insert(part)
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'schmersion/formatter'
|
4
|
+
|
5
|
+
module Schmersion
|
6
|
+
module Formatters
|
7
|
+
class Markdown < Formatter
|
8
|
+
|
9
|
+
def generate(repo, version)
|
10
|
+
lines = []
|
11
|
+
lines << "## #{version.version}\n"
|
12
|
+
|
13
|
+
sections_for_commits(version.commits).each do |section, commits|
|
14
|
+
lines << "### #{section['title']}\n"
|
15
|
+
commits.each do |_, scope_commits|
|
16
|
+
scope_commits.sort_by { |c| c.message.description.upcase }.each do |commit|
|
17
|
+
lines << commit_line(repo, commit)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
lines << nil
|
21
|
+
end
|
22
|
+
lines.join("\n")
|
23
|
+
end
|
24
|
+
|
25
|
+
def insert(part)
|
26
|
+
text_to_insert = "#{header}\n#{part}"
|
27
|
+
|
28
|
+
unless File.file?(@filename)
|
29
|
+
File.write(@filename, text_to_insert)
|
30
|
+
return
|
31
|
+
end
|
32
|
+
|
33
|
+
# If we already have a changelog markdown file, we're going to remove
|
34
|
+
# everyting up to the first level 2 heading and replace it with the
|
35
|
+
# header and the new version's data.
|
36
|
+
contents = File.read(@filename)
|
37
|
+
contents_without_header = contents.sub(/\A.*?##/m, '##')
|
38
|
+
File.write(@filename, "#{text_to_insert}\n#{contents_without_header}")
|
39
|
+
|
40
|
+
true
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def header
|
46
|
+
lines = []
|
47
|
+
lines << "# #{@options['description']}\n"
|
48
|
+
lines << @options['description'] if @options['description']
|
49
|
+
lines << nil
|
50
|
+
lines.join("\n")
|
51
|
+
end
|
52
|
+
|
53
|
+
def sections_for_commits(commits)
|
54
|
+
return [] if @options['sections'].empty?
|
55
|
+
|
56
|
+
@options['sections'].each_with_object([]) do |section, array|
|
57
|
+
section_commits = commits.select do |commit|
|
58
|
+
section['types'].nil? ||
|
59
|
+
!section['types'].is_a?(Array) ||
|
60
|
+
section['types'].include?(commit.message.type)
|
61
|
+
end
|
62
|
+
|
63
|
+
next if section_commits.empty?
|
64
|
+
|
65
|
+
section_commits = section_commits.group_by do |c|
|
66
|
+
c.message.scope
|
67
|
+
end
|
68
|
+
|
69
|
+
section_commits = section_commits.sort_by do |s, _|
|
70
|
+
s&.upcase || 'ZZZZZZ'
|
71
|
+
end
|
72
|
+
|
73
|
+
array << [section, section_commits]
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def capitalize_as_required(string)
|
78
|
+
return string unless @options['capitalize_strings']
|
79
|
+
|
80
|
+
string.sub(/\A([a-z])/) { Regexp.last_match(1).upcase }
|
81
|
+
end
|
82
|
+
|
83
|
+
def commit_url_for(repo, ref)
|
84
|
+
if @options['urls']&.key?('commit')
|
85
|
+
template = @options.dig('urls', 'commit')
|
86
|
+
return nil if template.nil?
|
87
|
+
|
88
|
+
return template.gsub('$REF', ref)
|
89
|
+
end
|
90
|
+
|
91
|
+
repo.host&.url_for_commit(ref)
|
92
|
+
end
|
93
|
+
|
94
|
+
def commit_line(repo, commit)
|
95
|
+
first_line = '- '
|
96
|
+
first_line += "**#{commit.message.scope}:** " if commit.message.scope
|
97
|
+
first_line += capitalize_as_required(commit.message.description)
|
98
|
+
if url = commit_url_for(repo, commit.ref)
|
99
|
+
first_line += ' ('
|
100
|
+
first_line += "[#{commit.ref[0, 6]}](#{url})"
|
101
|
+
first_line += ')'
|
102
|
+
end
|
103
|
+
first_line
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
require 'schmersion/formatter'
|
5
|
+
|
6
|
+
module Schmersion
|
7
|
+
module Formatters
|
8
|
+
class YAML < Formatter
|
9
|
+
|
10
|
+
DEFAULT_STRUCTURE = [].to_yaml.freeze
|
11
|
+
|
12
|
+
def generate(_, version)
|
13
|
+
commits = version.commits.sort_by { |c| c.message.description.upcase }
|
14
|
+
|
15
|
+
commits = commits.each_with_object([]) do |commit, array|
|
16
|
+
next unless include_type?(commit.message.type)
|
17
|
+
|
18
|
+
array << commit_to_hash(commit)
|
19
|
+
end
|
20
|
+
|
21
|
+
{
|
22
|
+
'version' => version.version.to_s,
|
23
|
+
'commits' => commits
|
24
|
+
}.to_yaml
|
25
|
+
end
|
26
|
+
|
27
|
+
def insert(part)
|
28
|
+
unless File.file?(@filename)
|
29
|
+
File.write(@filename, DEFAULT_STRUCTURE)
|
30
|
+
end
|
31
|
+
|
32
|
+
part_as_hash = ::YAML.safe_load(part)
|
33
|
+
existing_yaml = ::YAML.load_file(@filename)
|
34
|
+
existing_yaml = [] unless existing_yaml.is_a?(Array)
|
35
|
+
existing_yaml.prepend(part_as_hash)
|
36
|
+
File.write(@filename, existing_yaml.to_yaml)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def commit_to_hash(commit)
|
42
|
+
{
|
43
|
+
'ref' => commit.ref,
|
44
|
+
'date' => commit.date.to_s,
|
45
|
+
'type' => commit.message.type,
|
46
|
+
'scope' => commit.message.scope,
|
47
|
+
'description' => commit.message.description,
|
48
|
+
'breaking_change' => commit.message.breaking_change?,
|
49
|
+
'breaking_changes' => commit.message.breaking_changes,
|
50
|
+
'pull_request_id' => commit.message.pull_request_id,
|
51
|
+
'footers' => commit.message.footers
|
52
|
+
}
|
53
|
+
end
|
54
|
+
|
55
|
+
def include_type?(type)
|
56
|
+
return true if @options[:types].nil?
|
57
|
+
|
58
|
+
@options[:types].include(type.to_s)
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
class Helpers
|
5
|
+
|
6
|
+
class << self
|
7
|
+
|
8
|
+
def print_commit_list(commits, prefix: '')
|
9
|
+
commits.each do |commit|
|
10
|
+
print prefix
|
11
|
+
print '['
|
12
|
+
print commit.ref[0, 10].blue
|
13
|
+
print ']'
|
14
|
+
print ' '
|
15
|
+
print commit.message.type.green
|
16
|
+
if commit.message.scope
|
17
|
+
print '('
|
18
|
+
print commit.message.scope.magenta
|
19
|
+
print ')'
|
20
|
+
end
|
21
|
+
if commit.message.breaking_change?
|
22
|
+
print ' ! '.white.on_red
|
23
|
+
end
|
24
|
+
print ': '
|
25
|
+
puts commit.message.description
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
class Host
|
5
|
+
|
6
|
+
def initialize(url)
|
7
|
+
@url = url
|
8
|
+
end
|
9
|
+
|
10
|
+
def url_for_commit(commit_ref)
|
11
|
+
end
|
12
|
+
|
13
|
+
def url_for_comparison(ref1, ref2)
|
14
|
+
end
|
15
|
+
|
16
|
+
class << self
|
17
|
+
|
18
|
+
def suitable?(_)
|
19
|
+
false
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'schmersion/hosts/github'
|
4
|
+
|
5
|
+
module Schmersion
|
6
|
+
module Hosts
|
7
|
+
|
8
|
+
HOSTS = [GitHub].freeze
|
9
|
+
|
10
|
+
class << self
|
11
|
+
|
12
|
+
def host_for_url(url)
|
13
|
+
HOSTS.each do |host|
|
14
|
+
if host.suitable?(url)
|
15
|
+
return host.new(url)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
nil
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'schmersion/host'
|
4
|
+
|
5
|
+
module Schmersion
|
6
|
+
module Hosts
|
7
|
+
class GitHub < Host
|
8
|
+
|
9
|
+
SSH_REGEXP = /\Agit@github\.com:([\w-]+)\/([\w-]+)(\.git)?\z/.freeze
|
10
|
+
HTTP_REGEXP = /\Ahttps:\/\/github\.com\/([\w-]+)\/([\w-]+)(\.git)?\z/.freeze
|
11
|
+
|
12
|
+
class << self
|
13
|
+
|
14
|
+
def suitable?(url)
|
15
|
+
!!(url.match(SSH_REGEXP) || url.match(HTTP_REGEXP))
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(url)
|
21
|
+
super
|
22
|
+
get_user_and_repo(url)
|
23
|
+
@base_url = "https://github.com/#{@user}/#{@repo}"
|
24
|
+
end
|
25
|
+
|
26
|
+
def url_for_commit(ref)
|
27
|
+
"#{@base_url}/commit/#{ref}"
|
28
|
+
end
|
29
|
+
|
30
|
+
def url_for_comparison(ref1, ref2)
|
31
|
+
"#{@base_url}/compare/#{ref1}..#{ref2}"
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def get_user_and_repo(url)
|
37
|
+
if m = url.match(SSH_REGEXP)
|
38
|
+
@user = m[1]
|
39
|
+
@repo = m[2]
|
40
|
+
elsif m = url.match(HTTP_REGEXP)
|
41
|
+
@user = m[1]
|
42
|
+
@repo = m[2]
|
43
|
+
else
|
44
|
+
raise Error, 'Could not determine appropriate details for repository from URL'
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'schmersion/message'
|
4
|
+
require 'schmersion/message_validator'
|
5
|
+
|
6
|
+
module Schmersion
|
7
|
+
class Linter
|
8
|
+
|
9
|
+
def initialize(repo)
|
10
|
+
@repo = repo
|
11
|
+
end
|
12
|
+
|
13
|
+
def prepare(path, source = nil)
|
14
|
+
previous_commit_message = read_previous_commit_message
|
15
|
+
|
16
|
+
return unless source.nil?
|
17
|
+
|
18
|
+
unless File.file?(path)
|
19
|
+
raise Error, "No commit message file at the given path (#{path})"
|
20
|
+
end
|
21
|
+
|
22
|
+
lines = []
|
23
|
+
|
24
|
+
if previous_commit_message
|
25
|
+
lines << previous_commit_message
|
26
|
+
lines << ''
|
27
|
+
else
|
28
|
+
lines << "\n"
|
29
|
+
end
|
30
|
+
|
31
|
+
lines << '# ====================================================================='
|
32
|
+
lines << '# Commit messages must conform to conventional commit formatting.'
|
33
|
+
lines << '# This means each commit message must be prefixed with an appropriate'
|
34
|
+
lines << '# type and, optionally, a scope. Your message will be validated before'
|
35
|
+
lines << '# the commit will be created. '
|
36
|
+
lines << '#'
|
37
|
+
|
38
|
+
add_list_for(lines, :types)
|
39
|
+
|
40
|
+
unless @repo.config.scopes.empty?
|
41
|
+
lines << '#'
|
42
|
+
add_list_for(lines, :scopes)
|
43
|
+
end
|
44
|
+
|
45
|
+
lines << '# ====================================================================='
|
46
|
+
|
47
|
+
lines = lines.join("\n")
|
48
|
+
|
49
|
+
contents = File.read(path)
|
50
|
+
File.write(path, "#{lines}\n#\n#{contents.strip}")
|
51
|
+
end
|
52
|
+
|
53
|
+
def validate_file(path)
|
54
|
+
unless File.file?(path)
|
55
|
+
raise Error, "No commit message file at the given path (#{path})"
|
56
|
+
end
|
57
|
+
|
58
|
+
contents = get_commit_message_from_file(File.read(path))
|
59
|
+
return [] if contents.length.zero?
|
60
|
+
|
61
|
+
message = Message.new(contents)
|
62
|
+
|
63
|
+
validator = MessageValidator.new(@repo.config, message)
|
64
|
+
|
65
|
+
unless validator.valid?
|
66
|
+
# Save the commit message to a file if its invalid.
|
67
|
+
save_commit_message(contents)
|
68
|
+
end
|
69
|
+
|
70
|
+
validator.errors
|
71
|
+
end
|
72
|
+
|
73
|
+
def setup(force: false)
|
74
|
+
create_hook('lint-prepare', 'prepare-commit-msg', force: force)
|
75
|
+
create_hook('lint-validate', 'commit-msg', force: force)
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def read_previous_commit_message
|
81
|
+
path = '.git/SCHMERSION_EDITMSG'
|
82
|
+
return unless File.file?(path)
|
83
|
+
|
84
|
+
contents = File.read(path)
|
85
|
+
FileUtils.rm(path)
|
86
|
+
contents
|
87
|
+
end
|
88
|
+
|
89
|
+
def save_commit_message(message)
|
90
|
+
File.write('.git/SCHMERSION_EDITMSG', message)
|
91
|
+
end
|
92
|
+
|
93
|
+
def add_list_for(lines, type)
|
94
|
+
lines << "# The following #{type.to_s.upcase} are available to choose from:"
|
95
|
+
lines << '#'
|
96
|
+
@repo.config.public_send(type).sort.each_slice(3) do |names|
|
97
|
+
types = names.map { |t| " * #{t.to_s.ljust(16)}" }.join
|
98
|
+
lines << "# #{types}".strip
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def get_commit_message_from_file(contents)
|
103
|
+
contents = contents.split('------------------------ >8 ------------------------', 2)[0]
|
104
|
+
contents.split("\n").reject { |l| l.start_with?('#') }.join("\n").strip
|
105
|
+
end
|
106
|
+
|
107
|
+
# Returns the path to schmersion for use in commit files.
|
108
|
+
# For production installs, we'll just call it `schmersion` and hope that it is
|
109
|
+
# included in the path. For development, we'll use the path to the binary in
|
110
|
+
# repository.
|
111
|
+
def path_to_schmersion
|
112
|
+
if development?
|
113
|
+
return File.join(schmersion_root, 'bin', 'schmersion')
|
114
|
+
end
|
115
|
+
|
116
|
+
'schmersion'
|
117
|
+
end
|
118
|
+
|
119
|
+
def development?
|
120
|
+
!File.file?(File.join(schmersion_root, 'VERSION'))
|
121
|
+
end
|
122
|
+
|
123
|
+
def schmersion_root
|
124
|
+
File.expand_path('../../', __dir__)
|
125
|
+
end
|
126
|
+
|
127
|
+
def hook_file_contents(command)
|
128
|
+
<<~FILE
|
129
|
+
#!/bin/bash
|
130
|
+
|
131
|
+
#{path_to_schmersion} #{command} $1 $2 $3
|
132
|
+
FILE
|
133
|
+
end
|
134
|
+
|
135
|
+
def create_hook(command, name, force: false)
|
136
|
+
hook_path = File.join(@repo.path, '.git', 'hooks', name)
|
137
|
+
if File.file?(hook_path) && !force
|
138
|
+
raise Error, "Cannot install hook into #{name} because a hook file already exists."
|
139
|
+
end
|
140
|
+
|
141
|
+
File.write(hook_path, hook_file_contents(command))
|
142
|
+
FileUtils.chmod('u+x', hook_path)
|
143
|
+
hook_path
|
144
|
+
end
|
145
|
+
|
146
|
+
end
|
147
|
+
end
|