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.
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schmersion
4
+ class Error < StandardError
5
+ end
6
+ end
@@ -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,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'schmersion/formatters/markdown'
4
+ require 'schmersion/formatters/yaml'
5
+
6
+ module Schmersion
7
+ module Formatters
8
+
9
+ FORMATTERS = {
10
+ yaml: YAML,
11
+ markdown: Markdown
12
+ }.freeze
13
+
14
+ end
15
+ 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