schmersion 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|