toys-release 0.1.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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +11 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.md +21 -0
  5. data/README.md +87 -0
  6. data/docs/guide.md +7 -0
  7. data/lib/toys/release/version.rb +11 -0
  8. data/lib/toys-release.rb +23 -0
  9. data/toys/.data/templates/gh-pages-404.html.erb +25 -0
  10. data/toys/.data/templates/gh-pages-empty.html.erb +11 -0
  11. data/toys/.data/templates/gh-pages-gitignore.erb +1 -0
  12. data/toys/.data/templates/gh-pages-index.html.erb +15 -0
  13. data/toys/.data/templates/release-hook-on-closed.yml.erb +34 -0
  14. data/toys/.data/templates/release-hook-on-open.yml.erb +30 -0
  15. data/toys/.data/templates/release-hook-on-push.yml.erb +32 -0
  16. data/toys/.data/templates/release-perform.yml.erb +46 -0
  17. data/toys/.data/templates/release-request.yml.erb +37 -0
  18. data/toys/.data/templates/release-retry.yml.erb +42 -0
  19. data/toys/.lib/toys/release/artifact_dir.rb +70 -0
  20. data/toys/.lib/toys/release/change_set.rb +259 -0
  21. data/toys/.lib/toys/release/changelog_file.rb +136 -0
  22. data/toys/.lib/toys/release/component.rb +388 -0
  23. data/toys/.lib/toys/release/environment_utils.rb +246 -0
  24. data/toys/.lib/toys/release/performer.rb +346 -0
  25. data/toys/.lib/toys/release/pull_request.rb +154 -0
  26. data/toys/.lib/toys/release/repo_settings.rb +855 -0
  27. data/toys/.lib/toys/release/repository.rb +661 -0
  28. data/toys/.lib/toys/release/request_logic.rb +217 -0
  29. data/toys/.lib/toys/release/request_spec.rb +188 -0
  30. data/toys/.lib/toys/release/semver.rb +112 -0
  31. data/toys/.lib/toys/release/steps.rb +580 -0
  32. data/toys/.lib/toys/release/version_rb_file.rb +91 -0
  33. data/toys/.toys.rb +5 -0
  34. data/toys/_onclosed.rb +113 -0
  35. data/toys/_onopen.rb +158 -0
  36. data/toys/_onpush.rb +57 -0
  37. data/toys/create-labels.rb +115 -0
  38. data/toys/gen-gh-pages.rb +146 -0
  39. data/toys/gen-settings.rb +46 -0
  40. data/toys/gen-workflows.rb +70 -0
  41. data/toys/perform.rb +152 -0
  42. data/toys/request.rb +162 -0
  43. data/toys/retry.rb +133 -0
  44. metadata +106 -0
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "semver"
4
+
5
+ module Toys
6
+ module Release
7
+ ##
8
+ # Represents a set of changes from commit messages.
9
+ #
10
+ # Organizes the change commit messages into groups, and computes the semver
11
+ # release type.
12
+ #
13
+ class ChangeSet
14
+ ##
15
+ # Create a new ChangeSet
16
+ #
17
+ # @param settings [RepoSettings] the repo settings
18
+ #
19
+ def initialize(settings)
20
+ @release_commit_tags = settings.release_commit_tags
21
+ @breaking_change_header = settings.breaking_change_header
22
+ @no_significant_updates_notice = settings.no_significant_updates_notice
23
+ @semver = Semver::NONE
24
+ @change_groups = nil
25
+ @inputs = []
26
+ end
27
+
28
+ ##
29
+ # Add a commit.
30
+ #
31
+ # @param sha [String] The SHA for the commit.
32
+ # @param message [String] The commit message.
33
+ #
34
+ def add_message(sha, message)
35
+ raise "ChangeSet locked" if finished?
36
+ lines = message.split("\n")
37
+ return if lines.empty?
38
+ input = Input.new(sha)
39
+ lines.each { |line| analyze_line(line, input) }
40
+ @inputs << input
41
+ self
42
+ end
43
+
44
+ ##
45
+ # Finish constructing a change set. After this method, new commit messages
46
+ # cannot be added.
47
+ #
48
+ def finish # rubocop:disable Metrics/AbcSize
49
+ raise "ChangeSet locked" if finished?
50
+ @semver = Semver::NONE
51
+ change_groups = {breaking: Group.new(@breaking_change_header)}
52
+ @release_commit_tags.each_value do |tag_info|
53
+ tag_info.all_headers.each { |header| change_groups[header] = Group.new(header) }
54
+ end
55
+ @inputs.each do |input|
56
+ @semver = input.semver if input.semver > @semver
57
+ input.changes.each do |(header, change)|
58
+ change_groups.fetch(header, nil)&.add([change])
59
+ end
60
+ change_groups[:breaking].add(input.breaks)
61
+ end
62
+ @change_groups = change_groups.values.find_all { |group| !group.empty? }
63
+ if @change_groups.empty? && @semver != Semver::NONE
64
+ @change_groups << Group.new(nil).add(@no_significant_updates_notice)
65
+ end
66
+ @inputs = nil
67
+ self
68
+ end
69
+
70
+ ##
71
+ # Force a non-empty changeset even if there are no significant updates.
72
+ # May be called only on a finished changeset.
73
+ #
74
+ def force_release!
75
+ raise "ChangeSet not finished" unless finished?
76
+ if @change_groups.empty?
77
+ @semver = Semver::PATCH
78
+ @change_groups << Group.new(nil).add(@no_significant_updates_notice)
79
+ end
80
+ self
81
+ end
82
+
83
+ ##
84
+ # @return [boolean] Whether this change set is finished.
85
+ #
86
+ def finished?
87
+ @inputs.nil?
88
+ end
89
+
90
+ ##
91
+ # @return [boolean] Whether this change set is empty.
92
+ #
93
+ def empty?
94
+ @change_groups.empty?
95
+ end
96
+
97
+ ##
98
+ # @return [Integer] The semver change.
99
+ #
100
+ attr_reader :semver
101
+
102
+ ##
103
+ # @return [Array<Group>] An array of change groups, in order. Returns nil
104
+ # if the change set is not finished.
105
+ #
106
+ attr_reader :change_groups
107
+
108
+ ##
109
+ # Suggest a next version based on the changeset's changes.
110
+ #
111
+ # @param last [::Gem::Version,nil] The last released version, or nil if
112
+ # no releases have happened yet.
113
+ # @return [::Gem::Version,nil] Suggested next version, or nil for none.
114
+ #
115
+ def suggested_version(last)
116
+ raise "ChangeSet not finished" unless finished?
117
+ return nil unless semver.significant?
118
+ semver.bump(last)
119
+ end
120
+
121
+ ##
122
+ # A group of changes with the same header.
123
+ #
124
+ # These changes should be rendered together in a changelog, either as a
125
+ # list of changes under a heading, or as a list of changes, each preceded
126
+ # by the header as a prefix.
127
+ #
128
+ class Group
129
+ def initialize(header)
130
+ @header = header
131
+ @changes = []
132
+ @prefixed_changes = nil
133
+ end
134
+
135
+ ##
136
+ # @return [String] Header/prefix for changes in this group. May be nil
137
+ # for no header.
138
+ #
139
+ attr_reader :header
140
+
141
+ ##
142
+ # @return [Array<String>] Array of individual changes, in order.
143
+ #
144
+ attr_reader :changes
145
+
146
+ ##
147
+ # @return [Array<String>] Array of changes prefixed by the header.
148
+ #
149
+ def prefixed_changes
150
+ @prefixed_changes ||= changes.map { |change| header ? "#{header}: #{change}" : change }
151
+ end
152
+
153
+ ##
154
+ # @return [boolean] Whether this group is empty.
155
+ #
156
+ def empty?
157
+ changes.empty?
158
+ end
159
+
160
+ # @private
161
+ def add(chs)
162
+ @changes.concat(Array(chs))
163
+ self
164
+ end
165
+
166
+ # @private
167
+ def to_s
168
+ prefixed_changes.join("\n")
169
+ end
170
+ end
171
+
172
+ # @private
173
+ def to_s
174
+ (["Semver: #{semver}"] + change_groups).join("\n")
175
+ end
176
+
177
+ private
178
+
179
+ def analyze_line(line, input)
180
+ match = /^(?<tag>[\w-]+|BREAKING CHANGE)(?:\((?<scope>[^()]+)\))?(?<bang>!?):\s+(?<content>.*)$/.match(line)
181
+ return unless match
182
+ case match[:tag]
183
+ when /^BREAKING[\s_-]CHANGE$/
184
+ input.apply_breaking_change(match[:content])
185
+ when /^semver-change$/i
186
+ input.apply_semver_change(match[:content].split.first)
187
+ when /^revert-commit$/i
188
+ @inputs.delete_if { |elem| elem.sha.start_with?(match[:content].split.first) }
189
+ else
190
+ tag_info = @release_commit_tags[match[:tag]]
191
+ input.apply_commit(tag_info, match[:scope], match[:bang], match[:content])
192
+ end
193
+ end
194
+
195
+ ##
196
+ # @private
197
+ # Analyzed commit info
198
+ #
199
+ class Input
200
+ # @private
201
+ def initialize(sha)
202
+ @sha = sha
203
+ @changes = []
204
+ @breaks = []
205
+ @semver = Semver::NONE
206
+ @semver_locked = false
207
+ end
208
+
209
+ attr_reader :sha
210
+ attr_reader :changes
211
+ attr_reader :breaks
212
+ attr_reader :semver
213
+ attr_reader :semver_locked
214
+
215
+ # @private
216
+ def apply_breaking_change(value)
217
+ @semver = Semver::MAJOR unless semver_locked
218
+ @breaks << normalize_description(value, delete_pr_number: true)
219
+ self
220
+ end
221
+
222
+ # @private
223
+ def apply_semver_change(value)
224
+ semver = Semver.for_name(value)
225
+ if semver
226
+ @semver = semver
227
+ @semver_locked = true
228
+ end
229
+ self
230
+ end
231
+
232
+ # @private
233
+ def apply_commit(tag_info, scope, bang, description)
234
+ description = normalize_description(description, delete_pr_number: true)
235
+ if tag_info
236
+ commit_header = tag_info.header(scope)
237
+ commit_semver = tag_info.semver(scope)
238
+ @changes << [commit_header, description] if commit_header
239
+ @semver = commit_semver if !@semver_locked && (commit_semver > @semver)
240
+ end
241
+ if bang == "!"
242
+ @semver = Semver::MAJOR unless @semver_locked
243
+ @breaks << description
244
+ end
245
+ self
246
+ end
247
+
248
+ private
249
+
250
+ def normalize_description(description, delete_pr_number: false)
251
+ match = /^([a-z])(.*)$/.match(description)
252
+ description = match[1].upcase + match[2] if match
253
+ description = description.gsub(/\s*\(#\d+\)$/, "") if delete_pr_number
254
+ description
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Toys
4
+ module Release
5
+ ##
6
+ # Represents a changelog read from a file.
7
+ #
8
+ class ChangelogFile
9
+ ##
10
+ # @return [String] The default header used when there is no changelog.
11
+ #
12
+ DEFAULT_HEADER = "# Changelog\n"
13
+
14
+ ##
15
+ # Create a changelog file object given a file path
16
+ #
17
+ # @param path [String] File path
18
+ # @param environment_utils [Toys::Release::EnvironmentUtils]
19
+ #
20
+ def initialize(path, environment_utils)
21
+ @path = path
22
+ @utils = environment_utils
23
+ end
24
+
25
+ ##
26
+ # @return [String] Path to the changelog file
27
+ #
28
+ attr_reader :path
29
+
30
+ ##
31
+ # @return [boolean] Whether the file exists
32
+ #
33
+ def exists?
34
+ ::File.file?(path)
35
+ end
36
+
37
+ ##
38
+ # @return [String] Current contents of the changelog
39
+ # @return [nil] if the changelog file doesn't exist
40
+ #
41
+ def content
42
+ ::File.file?(path) ? ::File.read(path) : nil
43
+ end
44
+
45
+ ##
46
+ # @return [::Gem::Version,nil] Current latest version from the changelog
47
+ #
48
+ def current_version
49
+ ChangelogFile.current_version_from_content(content)
50
+ end
51
+
52
+ ##
53
+ # Reads the latest changelog entry and verifies that it accurately
54
+ # reflects the given version.
55
+ #
56
+ # @param version [String,::Gem::Version] Release version to verify.
57
+ # @return [String] The multiline changelog entry, or the empty string if
58
+ # there are no entries.
59
+ #
60
+ def read_and_verify_latest_entry(version) # rubocop:disable Metrics/MethodLength
61
+ version = version.to_s
62
+ @utils.log("Verifying #{path} changelog content...")
63
+ today = ::Time.now.strftime("%Y-%m-%d")
64
+ entry = []
65
+ state = :start
66
+ ::File.readlines(@path).each do |line|
67
+ case state
68
+ when :start
69
+ case line
70
+ when %r{^### v#{::Regexp.escape(version)} / \d\d\d\d-\d\d-\d\d\n$}
71
+ entry << line
72
+ state = :during
73
+ when /^### /
74
+ @utils.error("The first changelog entry in #{path} isn't for version #{version}.",
75
+ "It should start with:",
76
+ "### v#{version} / #{today}",
77
+ "But it actually starts with:",
78
+ line)
79
+ entry << line
80
+ state = :during
81
+ end
82
+ when :during
83
+ break if line =~ /^### /
84
+ entry << line
85
+ end
86
+ end
87
+ if entry.empty?
88
+ @utils.error("The changelog #{path} doesn't have any entries.",
89
+ "The first changelog entry should start with:",
90
+ "### v#{version} / #{today}")
91
+ else
92
+ @utils.log("Changelog OK")
93
+ end
94
+ entry.join
95
+ end
96
+
97
+ ##
98
+ # Append a new entry to the changelog.
99
+ #
100
+ # @param changeset [ChangeSet] The changeset.
101
+ # @param version [String] The release version.
102
+ # @param date [String] The date. If not provided, uses the current UTC.
103
+ #
104
+ def append(changeset, version, date: nil)
105
+ date ||= ::Time.now.utc
106
+ date = date.strftime("%Y-%m-%d") if date.respond_to?(:strftime)
107
+ new_entry = [
108
+ "### v#{version} / #{date}",
109
+ "",
110
+ ]
111
+ changeset.change_groups.each do |group|
112
+ new_entry.concat(group.prefixed_changes.map { |line| "* #{line}" })
113
+ end
114
+ new_entry = new_entry.join("\n")
115
+ old_content = content || DEFAULT_HEADER
116
+ new_content = old_content.sub(%r{^(### v\S+ / \d\d\d\d-\d\d-\d\d)$}, "#{new_entry}\n\n\\1")
117
+ if new_content == old_content
118
+ new_content = old_content.sub(/\n+\z/, "\n\n#{new_entry}\n")
119
+ end
120
+ ::File.write(path, new_content)
121
+ self
122
+ end
123
+
124
+ ##
125
+ # Returns the current version from the given file content
126
+ #
127
+ # @param content [String] File contents
128
+ # @return [::Gem::Version] Latest version in the changelog
129
+ #
130
+ def self.current_version_from_content(content)
131
+ match = %r{### v(\d+(?:\.[a-zA-Z0-9]+)+) / \d\d\d\d-\d\d-\d\d}.match(content)
132
+ match ? ::Gem::Version.new(match[1]) : nil
133
+ end
134
+ end
135
+ end
136
+ end