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,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
class Message
|
5
|
+
|
6
|
+
REGEX = /^([\w-]+)(?:\(([\w-]+)\))?(!)?:\s(.*?)(?:\(#(\d+)\))?$/.freeze
|
7
|
+
FOOTER_COLON_REGEX = /^([\w-]+:|BREAKING CHANGE:)\s*(.*)$/.freeze
|
8
|
+
FOOTER_TICKET_REGEX = /^([\w-]+)\s+(#.*)$/.freeze
|
9
|
+
|
10
|
+
attr_reader :header
|
11
|
+
attr_reader :footers
|
12
|
+
attr_reader :type
|
13
|
+
attr_reader :scope
|
14
|
+
attr_reader :description
|
15
|
+
attr_reader :body
|
16
|
+
attr_reader :pull_request_id
|
17
|
+
attr_reader :breaking_changes
|
18
|
+
|
19
|
+
def initialize(raw_message)
|
20
|
+
@raw_message = clean(raw_message)
|
21
|
+
|
22
|
+
parts = @raw_message.split("\n\n").map(&:strip)
|
23
|
+
|
24
|
+
@header = parts.shift
|
25
|
+
@paragraphs = parts
|
26
|
+
@breaking_changes = []
|
27
|
+
@footers = []
|
28
|
+
|
29
|
+
parse_header
|
30
|
+
parse_footers
|
31
|
+
|
32
|
+
@body = @paragraphs.join("\n\n")
|
33
|
+
end
|
34
|
+
|
35
|
+
def valid?
|
36
|
+
!@type.nil?
|
37
|
+
end
|
38
|
+
|
39
|
+
def breaking_change?
|
40
|
+
@breaking_change == true ||
|
41
|
+
@breaking_changes.size.positive?
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
def parse_header
|
47
|
+
match = @header.match(REGEX)
|
48
|
+
return unless match
|
49
|
+
|
50
|
+
@type = match[1]
|
51
|
+
@scope = match[2]
|
52
|
+
@description = match[4].strip
|
53
|
+
@pull_request_id = match[5]
|
54
|
+
|
55
|
+
@breaking_change = true if match[3] == '!'
|
56
|
+
|
57
|
+
match
|
58
|
+
end
|
59
|
+
|
60
|
+
def parse_footers
|
61
|
+
footer = []
|
62
|
+
@paragraphs.last&.each_line do |line|
|
63
|
+
case line.strip
|
64
|
+
when FOOTER_COLON_REGEX, FOOTER_TICKET_REGEX
|
65
|
+
if footer.any?
|
66
|
+
handle_footer(footer)
|
67
|
+
footer = []
|
68
|
+
end
|
69
|
+
footer << "#{Regexp.last_match(1)} #{Regexp.last_match(2)}"
|
70
|
+
else
|
71
|
+
footer << line.strip
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
handle_footer(footer) if footer.any?
|
76
|
+
|
77
|
+
@paragraphs.pop if @footers.any? || @breaking_changes.any?
|
78
|
+
end
|
79
|
+
|
80
|
+
def handle_footer(footer_lines = [])
|
81
|
+
footer = footer_lines.join("\n")
|
82
|
+
|
83
|
+
if footer.start_with?('BREAKING CHANGE:')
|
84
|
+
@breaking_changes << footer.delete_prefix('BREAKING CHANGE:').strip
|
85
|
+
else
|
86
|
+
@footers << footer
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def clean(message)
|
91
|
+
message.sub(/.*-----END PGP SIGNATURE-----\n\n/m, '')
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
95
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
class MessageValidator
|
5
|
+
|
6
|
+
attr_reader :errors
|
7
|
+
|
8
|
+
def initialize(config, message)
|
9
|
+
@config = config
|
10
|
+
@message = message
|
11
|
+
@errors = []
|
12
|
+
validate_all
|
13
|
+
end
|
14
|
+
|
15
|
+
def valid?
|
16
|
+
@errors.empty?
|
17
|
+
end
|
18
|
+
|
19
|
+
def validate_all
|
20
|
+
validate_valid_format
|
21
|
+
return unless @message.valid?
|
22
|
+
|
23
|
+
validate_type
|
24
|
+
validate_scope
|
25
|
+
validate_description_presence
|
26
|
+
validate_description_length
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def validate_valid_format
|
32
|
+
return if @message.valid?
|
33
|
+
|
34
|
+
errors << 'The commit message is not in a valid format'
|
35
|
+
end
|
36
|
+
|
37
|
+
def validate_type
|
38
|
+
return if @message.type.nil?
|
39
|
+
return if @config.valid_type?(@message.type)
|
40
|
+
|
41
|
+
errors << "Type (#{@message.type}) is not valid"
|
42
|
+
end
|
43
|
+
|
44
|
+
def validate_scope
|
45
|
+
return if @message.scope.nil?
|
46
|
+
return if @config.valid_scope?(@message.scope)
|
47
|
+
|
48
|
+
errors << "Scope (#{@message.scope}) is not valid"
|
49
|
+
end
|
50
|
+
|
51
|
+
def validate_description_presence
|
52
|
+
return if @message.description&.size&.positive?
|
53
|
+
|
54
|
+
errors << 'A description (text after the type) must be provided'
|
55
|
+
end
|
56
|
+
|
57
|
+
def validate_description_length
|
58
|
+
return if @message.description.nil?
|
59
|
+
return if @message.description.size.zero?
|
60
|
+
return if @message.description.size <= @config.linting[:max_description_length]
|
61
|
+
|
62
|
+
errors << "Commit description must be less than #{@config.linting[:max_description_length]} " \
|
63
|
+
"characters (currently #{@message.description.size})"
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'schmersion/formatters'
|
4
|
+
|
5
|
+
module Schmersion
|
6
|
+
class Releaser
|
7
|
+
|
8
|
+
COLORS = [:blue, :red, :yellow, :magenta, :green, :cyan].freeze
|
9
|
+
|
10
|
+
def initialize(repo, **options)
|
11
|
+
@repo = repo
|
12
|
+
@options = options
|
13
|
+
@exports = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def release
|
17
|
+
check_for_for_existing_version
|
18
|
+
generate_exports
|
19
|
+
preview_exports
|
20
|
+
save_exports
|
21
|
+
commit
|
22
|
+
tag
|
23
|
+
display_prompt
|
24
|
+
end
|
25
|
+
|
26
|
+
private
|
27
|
+
|
28
|
+
def check_for_for_existing_version
|
29
|
+
if @repo.version?(version.version)
|
30
|
+
raise Error, "#{version.version} already exists in this repository"
|
31
|
+
end
|
32
|
+
|
33
|
+
true
|
34
|
+
end
|
35
|
+
|
36
|
+
def generate_exports
|
37
|
+
return if skip?(:export)
|
38
|
+
|
39
|
+
@exports = {}
|
40
|
+
@repo.config.exports.each do |formatter|
|
41
|
+
output = formatter.generate(@repo, version)
|
42
|
+
@exports[formatter] = output
|
43
|
+
end
|
44
|
+
@exports
|
45
|
+
end
|
46
|
+
|
47
|
+
def preview_exports
|
48
|
+
return if skip?(:export)
|
49
|
+
return unless dry_run?
|
50
|
+
|
51
|
+
@exports.each_with_index do |(formatter, output), index|
|
52
|
+
color = COLORS[index % COLORS.size]
|
53
|
+
puts formatter.filename.colorize(color: :white, background: color)
|
54
|
+
output.split("\n").each do |line|
|
55
|
+
print '> '.colorize(color: color)
|
56
|
+
puts line
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def save_exports
|
62
|
+
return if skip?(:export)
|
63
|
+
|
64
|
+
@exports.each do |formatter, output|
|
65
|
+
action 'save', formatter.filename do
|
66
|
+
formatter.insert(output)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def commit
|
72
|
+
return if skip?(:commit)
|
73
|
+
|
74
|
+
action 'commit', version.commit_message do
|
75
|
+
@repo.repo.reset
|
76
|
+
@exports.each_key do |formatter|
|
77
|
+
@repo.repo.add(formatter.filename)
|
78
|
+
end
|
79
|
+
@repo.repo.commit(version.commit_message, allow_empty: true)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def tag
|
84
|
+
return if skip?(:tag)
|
85
|
+
|
86
|
+
action 'tag', version.version.to_s do
|
87
|
+
@repo.repo.add_tag(version.version.to_s)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def display_prompt
|
92
|
+
puts
|
93
|
+
puts "Release of #{version.version} completed".white.on_green
|
94
|
+
|
95
|
+
return if skip?(:tag) && skip?(:commit)
|
96
|
+
|
97
|
+
print 'Now run '
|
98
|
+
print "git push --follow-tags origin #{@repo.current_branch}".cyan
|
99
|
+
puts ' to publish'
|
100
|
+
end
|
101
|
+
|
102
|
+
def version
|
103
|
+
@version ||= begin
|
104
|
+
@repo.pending_version(
|
105
|
+
override_version: @options[:version],
|
106
|
+
version_options: {
|
107
|
+
pre: @options[:pre],
|
108
|
+
breaking_change_not_major: @repo.config.version_options['breaking_change_not_major']
|
109
|
+
}
|
110
|
+
)[1]
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def dry_run?
|
115
|
+
@options[:dry_run] == true
|
116
|
+
end
|
117
|
+
|
118
|
+
def action_color
|
119
|
+
dry_run? ? :magenta : :green
|
120
|
+
end
|
121
|
+
|
122
|
+
def action(action, text)
|
123
|
+
yield unless dry_run?
|
124
|
+
print "#{action}: ".colorize(action_color)
|
125
|
+
puts text
|
126
|
+
end
|
127
|
+
|
128
|
+
def skip?(type)
|
129
|
+
return false if @options[:skips].nil?
|
130
|
+
|
131
|
+
@options[:skips].include?(type)
|
132
|
+
end
|
133
|
+
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,127 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'yaml'
|
4
|
+
require 'git'
|
5
|
+
require 'semantic'
|
6
|
+
require 'schmersion/commit_parser'
|
7
|
+
require 'schmersion/commit'
|
8
|
+
require 'schmersion/version'
|
9
|
+
require 'schmersion/config'
|
10
|
+
require 'schmersion/hosts'
|
11
|
+
|
12
|
+
module Schmersion
|
13
|
+
class Repo
|
14
|
+
|
15
|
+
attr_reader :path
|
16
|
+
attr_reader :repo
|
17
|
+
|
18
|
+
def initialize(path)
|
19
|
+
@path = path
|
20
|
+
@repo = Git.open(path)
|
21
|
+
rescue ArgumentError => e
|
22
|
+
if e.message =~ /path does not exist/
|
23
|
+
raise Error, "No git repository found at #{path}"
|
24
|
+
end
|
25
|
+
|
26
|
+
raise
|
27
|
+
end
|
28
|
+
|
29
|
+
def path_for(*join)
|
30
|
+
File.join(@path, *join)
|
31
|
+
end
|
32
|
+
|
33
|
+
def config
|
34
|
+
@config ||= load_config(['.schmersion.yaml', '.schmersion.yml'])
|
35
|
+
end
|
36
|
+
|
37
|
+
def origin
|
38
|
+
@origin ||= @repo.remotes.find { |r| r.name == 'origin' }&.url
|
39
|
+
end
|
40
|
+
|
41
|
+
def host
|
42
|
+
return nil if origin.nil?
|
43
|
+
|
44
|
+
Hosts.host_for_url(origin)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Get the pending version for the currently checked out branch
|
48
|
+
# for the repository.
|
49
|
+
def pending_version(from: nil, to: 'HEAD', **options)
|
50
|
+
options[:version_options] ||= {}
|
51
|
+
|
52
|
+
if from.nil?
|
53
|
+
from_version = versions.last
|
54
|
+
else
|
55
|
+
from_version = versions.find { |v, _| v.to_s == from }
|
56
|
+
if from_version.nil?
|
57
|
+
raise Error, "Could not find existing version named #{from}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
if from_version
|
62
|
+
previous_version, previous_version_commit = from_version
|
63
|
+
end
|
64
|
+
|
65
|
+
parser = CommitParser.new(@repo, previous_version_commit&.ref || :start, to)
|
66
|
+
if v = options[:override_version]
|
67
|
+
begin
|
68
|
+
next_version = Semantic::Version.new(v)
|
69
|
+
rescue ArgumentError => e
|
70
|
+
if e.message =~ /not a valid SemVer/
|
71
|
+
raise Error, "'#{v}' is not a valid version"
|
72
|
+
end
|
73
|
+
|
74
|
+
raise
|
75
|
+
end
|
76
|
+
else
|
77
|
+
next_version = parser.next_version_after(previous_version, **options[:version_options])
|
78
|
+
end
|
79
|
+
|
80
|
+
[
|
81
|
+
previous_version,
|
82
|
+
Version.new(self, next_version, parser)
|
83
|
+
]
|
84
|
+
end
|
85
|
+
|
86
|
+
def commits(start_commit, end_commit, **options)
|
87
|
+
parser = CommitParser.new(@repo, start_commit, end_commit, **options)
|
88
|
+
parser.commits
|
89
|
+
end
|
90
|
+
|
91
|
+
def current_branch
|
92
|
+
@repo.branch.name
|
93
|
+
end
|
94
|
+
|
95
|
+
def version?(version)
|
96
|
+
@repo.tag(version.to_s).is_a?(Git::Object::Tag)
|
97
|
+
rescue Git::GitTagNameDoesNotExist
|
98
|
+
false
|
99
|
+
end
|
100
|
+
|
101
|
+
def versions
|
102
|
+
versions = @repo.tags.each_with_object([]) do |tag, array|
|
103
|
+
commit = @repo.gcommit(tag.sha)
|
104
|
+
version = Semantic::Version.new(tag.name)
|
105
|
+
array << [version, Commit.new(commit)]
|
106
|
+
rescue ArgumentError => e
|
107
|
+
raise unless e.message =~ /not a valid SemVer/
|
108
|
+
end
|
109
|
+
versions.sort_by { |_, c| c.date }
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def load_config(filenames)
|
115
|
+
filenames.each do |filename|
|
116
|
+
path = path_for(filename)
|
117
|
+
if File.file?(path)
|
118
|
+
return Config.new(::YAML.load_file(path))
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
warn 'No config file was found, using defaults'
|
123
|
+
Config.new({})
|
124
|
+
end
|
125
|
+
|
126
|
+
end
|
127
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
|
5
|
+
VERSION_FILE_ROOT = File.expand_path('../../VERSION', __dir__)
|
6
|
+
if File.file?(VERSION_FILE_ROOT)
|
7
|
+
VERSION = File.read(VERSION_FILE_ROOT).strip.sub(/\Av/, '')
|
8
|
+
else
|
9
|
+
VERSION = '0.0.0.dev'
|
10
|
+
end
|
11
|
+
|
12
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Schmersion
|
4
|
+
class Version
|
5
|
+
|
6
|
+
attr_reader :version
|
7
|
+
attr_reader :commit_parser
|
8
|
+
|
9
|
+
def initialize(repo, version, commit_parser)
|
10
|
+
@repo = repo
|
11
|
+
@version = version
|
12
|
+
@commit_parser = commit_parser
|
13
|
+
end
|
14
|
+
|
15
|
+
def commits
|
16
|
+
@commit_parser.commits
|
17
|
+
end
|
18
|
+
|
19
|
+
def start_commit
|
20
|
+
@commit_parser.start_commit
|
21
|
+
end
|
22
|
+
|
23
|
+
def end_commit
|
24
|
+
@commit_parser.end_commit
|
25
|
+
end
|
26
|
+
|
27
|
+
def commit_message
|
28
|
+
"chore(release): #{version}"
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
32
|
+
end
|