schmersion 1.0.0

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