gem_kit-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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d42874e3ef31b2b7ecdad0cdd3acd28a62a2d38c7b3a6476c5a5d449dc27311b
4
+ data.tar.gz: 639dc539b8171640bd2f2f91d193d8c2f2278724d20a1594f6dafa050bfb9942
5
+ SHA512:
6
+ metadata.gz: 29e0b353656610e5adcbc79395753e4f7b2476218681da112d73070bd9dd2db2d53f7fb657225c5c11cd0760ca6a77f8942bda25f0931c7ba5cc3d32612207c4
7
+ data.tar.gz: b8a4daca35ca2dd5c5de72d2b1ff2d288ced5989125368049376aaee6df9ef5e31b5aedb7457667f6d43c248d0cf2337db23071d225b2a5b11633e5288975b59
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module GemKit
6
+ module Release
7
+ # A parser and linter for CHANGELOG.md, which follows
8
+ # [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
9
+ #
10
+ # The changelog is the one release artefact nothing else can regenerate, so
11
+ # it is the one most easily forgotten. Making it machine-checkable turns
12
+ # "did anyone write the changelog?" into a gate: `gem_kit-release check`
13
+ # validates the format, and refuses to release a version that has no
14
+ # section of its own.
15
+ #
16
+ # changelog = GemKit::Release::Changelog.load
17
+ # changelog.problems # => [] when the format is clean
18
+ # changelog.release_problems("4.1.0")
19
+ #
20
+ # The shape it expects:
21
+ #
22
+ # # Changelog
23
+ #
24
+ # ## [Unreleased]
25
+ #
26
+ # ## [4.1.0] - 2026-08-20
27
+ #
28
+ # ### Added
29
+ #
30
+ # - Something that happened.
31
+ #
32
+ class Changelog
33
+ # The six change types Keep a Changelog defines. Anything else under a
34
+ # version is a typo or an invention, and both are worth catching.
35
+ SECTIONS = %w[Added Changed Deprecated Removed Fixed Security].freeze
36
+
37
+ UNRELEASED = "Unreleased"
38
+ HEADING = /\A##\s+\[([^\]]+)\](?:\s+-\s+(.*))?\s*\z/
39
+ SUBHEADING = /\A###\s+(.*?)\s*\z/
40
+ BULLET = /\A[-*]\s+\S/
41
+ DATE = /\A\d{4}-\d{2}-\d{2}\z/
42
+
43
+ # One `## [...]` section of the file.
44
+ Release = Struct.new(:version, :date, :line, :subsections, keyword_init: true) do
45
+ def unreleased? = version == UNRELEASED
46
+ def empty? = subsections.empty? || subsections.values.all?(&:empty?)
47
+ def to_s = unreleased? ? "[#{version}]" : "[#{version}] - #{date}"
48
+ end
49
+
50
+ # Load the changelog sitting beside the gem (../../CHANGELOG.md).
51
+ def self.load(path = File.join(Dir.pwd, "CHANGELOG.md"))
52
+ new(File.exist?(path) ? File.read(path) : nil, path: path)
53
+ end
54
+
55
+ attr_reader :path, :releases
56
+
57
+ # @parameter text [String, nil] the file's contents; nil means "no file".
58
+ def initialize(text, path: "CHANGELOG.md")
59
+ @path = path
60
+ @text = text
61
+ @releases = text ? parse(text) : []
62
+ end
63
+
64
+ def missing? = @text.nil?
65
+
66
+ def unreleased = releases.find(&:unreleased?)
67
+
68
+ def released = releases.reject(&:unreleased?)
69
+
70
+ def find(version)
71
+ target = Gem::Version.new(version.to_s)
72
+ released.find { |release| Gem::Version.new(release.version) == target rescue false }
73
+ end
74
+
75
+ # Everything wrong with the file's *format*, as a list of human-readable
76
+ # problems. Empty means it lints clean.
77
+ def problems
78
+ return ["#{path} does not exist"] if missing?
79
+
80
+ [*header_problems, *heading_problems, *ordering_problems, *content_problems]
81
+ end
82
+
83
+ # Everything standing between this changelog and releasing `version`.
84
+ # Format problems count: a file nobody can parse is not documentation.
85
+ def release_problems(version)
86
+ return problems unless problems.empty?
87
+
88
+ release = find(version)
89
+ return ["#{path} has no section for #{version} — run gem_kit-release changelog"] if release.nil?
90
+ return ["#{path} section for #{version} is empty"] if release.empty?
91
+
92
+ newest = released.first
93
+ if newest && newest.version != release.version
94
+ return ["#{path} lists #{newest.version} above #{version}; the release being cut must come first"]
95
+ end
96
+
97
+ []
98
+ end
99
+
100
+ private
101
+
102
+ # Split the file into `## [...]` sections, recording each one's
103
+ # `### Type` subsections and their top-level bullets.
104
+ def parse(text)
105
+ found = []
106
+ current = nil
107
+ heading = nil
108
+
109
+ text.each_line.with_index(1) do |line, number|
110
+ case line
111
+ when HEADING
112
+ heading = nil
113
+ current = Release.new(version: $1, date: $2&.strip, line: number, subsections: {})
114
+ found << current
115
+ when SUBHEADING
116
+ next unless current
117
+
118
+ heading = $1
119
+ (current.subsections[heading] ||= [])
120
+ when BULLET
121
+ current.subsections[heading] << line.strip if current && heading
122
+ end
123
+ end
124
+
125
+ found
126
+ end
127
+
128
+ def header_problems
129
+ first = @text.each_line.find { |line| !line.strip.empty? }
130
+ return [] if first&.strip == "# Changelog"
131
+
132
+ ["#{path}:1 must start with the title `# Changelog`"]
133
+ end
134
+
135
+ def heading_problems
136
+ problems = []
137
+
138
+ @text.each_line.with_index(1) do |line, number|
139
+ next unless line.start_with?("## ") && !line.start_with?("###")
140
+
141
+ unless line =~ HEADING
142
+ problems << "#{path}:#{number} malformed version heading: #{line.strip.inspect} " \
143
+ "(expected `## [Unreleased]` or `## [1.2.3] - YYYY-MM-DD`)"
144
+ next
145
+ end
146
+
147
+ version, date = $1, $2&.strip
148
+ next if version == UNRELEASED
149
+
150
+ problems << "#{path}:#{number} #{version} has no date (expected `## [#{version}] - YYYY-MM-DD`)" if date.nil? || date.empty?
151
+ problems << "#{path}:#{number} #{version} has a malformed date: #{date.inspect}" if date && !date.empty? && date !~ DATE
152
+ problems << "#{path}:#{number} #{version} is not a valid version number" unless valid_version?(version)
153
+ end
154
+
155
+ problems + subheading_problems
156
+ end
157
+
158
+ def subheading_problems
159
+ @text.each_line.with_index(1).filter_map do |line, number|
160
+ next unless line.start_with?("### ")
161
+ next if line =~ SUBHEADING && SECTIONS.include?($1)
162
+
163
+ "#{path}:#{number} unknown section #{line.sub("###", "").strip.inspect} " \
164
+ "(expected one of: #{SECTIONS.join(", ")})"
165
+ end
166
+ end
167
+
168
+ def ordering_problems
169
+ problems = []
170
+
171
+ if unreleased && releases.first&.unreleased? == false
172
+ problems << "#{path}:#{unreleased.line} [Unreleased] must be the first section"
173
+ end
174
+
175
+ seen = {}
176
+ released.each do |release|
177
+ if (first = seen[release.version])
178
+ problems << "#{path}:#{release.line} duplicate section for #{release.version} (also at line #{first})"
179
+ end
180
+ seen[release.version] ||= release.line
181
+ end
182
+
183
+ versions = released.select { |release| valid_version?(release.version) }
184
+ versions.each_cons(2) do |newer, older|
185
+ next if Gem::Version.new(newer.version) > Gem::Version.new(older.version)
186
+
187
+ problems << "#{path}:#{older.line} #{older.version} is listed below #{newer.version}; " \
188
+ "releases must run newest to oldest"
189
+ end
190
+
191
+ problems
192
+ end
193
+
194
+ def content_problems
195
+ released.flat_map do |release|
196
+ if release.subsections.empty?
197
+ ["#{path}:#{release.line} #{release} has no #{SECTIONS.join("/")} section"]
198
+ else
199
+ release.subsections.filter_map do |heading, bullets|
200
+ "#{path}:#{release.line} #{release} has an empty `### #{heading}` section" if bullets.empty?
201
+ end
202
+ end
203
+ end
204
+ end
205
+
206
+ def valid_version?(version)
207
+ Gem::Version.correct?(version)
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ __END__
214
+
215
+ describe "gem_kit/release/changelog" do
216
+ # Build a changelog from a body, with the standard title already in place.
217
+ changelog = lambda do |body|
218
+ GemKit::Release::Changelog.new("# Changelog\n\n#{body}")
219
+ end
220
+
221
+ good = <<~MD
222
+ ## [Unreleased]
223
+
224
+ ## [4.1.0] - 2026-08-20
225
+
226
+ ### Added
227
+
228
+ - A thing.
229
+
230
+ ## [4.0.0] - 2026-08-01
231
+
232
+ ### Removed
233
+
234
+ - An older thing.
235
+ MD
236
+
237
+ it "parses sections, dates and bullets" do
238
+ log = changelog.call(good)
239
+
240
+ log.releases.map(&:version).should == ["Unreleased", "4.1.0", "4.0.0"]
241
+ log.unreleased.should.be.kind_of?(GemKit::Release::Changelog::Release)
242
+ log.released.first.date.should == "2026-08-20"
243
+ log.released.first.subsections["Added"].should == ["- A thing."]
244
+ log.find("4.0.0").subsections["Removed"].size.should == 1
245
+ end
246
+
247
+ it "lints a well-formed file clean and clears it for release" do
248
+ log = changelog.call(good)
249
+
250
+ log.problems.should == []
251
+ log.release_problems("4.1.0").should == []
252
+ end
253
+
254
+ it "requires the `# Changelog` title" do
255
+ GemKit::Release::Changelog.new("## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n")
256
+ .problems.first.should.match(/must start with the title/)
257
+ end
258
+
259
+ it "reports a missing file" do
260
+ log = GemKit::Release::Changelog.new(nil, path: "nope.md")
261
+ log.missing?.should.be.true
262
+ log.problems.first.should.match(/does not exist/)
263
+ end
264
+
265
+ it "rejects a malformed version heading" do
266
+ changelog.call("## 1.0.0\n").problems.first.should.match(/malformed version heading/)
267
+ end
268
+
269
+ it "rejects an undated or badly dated release" do
270
+ changelog.call("## [1.0.0]\n\n### Added\n\n- x\n").problems.first.should.match(/has no date/)
271
+ changelog.call("## [1.0.0] - 20260101\n\n### Added\n\n- x\n").problems.first.should.match(/malformed date/)
272
+ end
273
+
274
+ it "rejects an unknown section type" do
275
+ changelog.call("## [1.0.0] - 2026-01-01\n\n### Improved\n\n- x\n")
276
+ .problems.first.should.match(/unknown section "Improved"/)
277
+ end
278
+
279
+ it "rejects an empty release and an empty subsection" do
280
+ changelog.call("## [1.0.0] - 2026-01-01\n").problems.first.should.match(/has no Added/)
281
+ changelog.call("## [1.0.0] - 2026-01-01\n\n### Added\n\n## [0.9.0] - 2026-01-01\n\n### Added\n\n- x\n")
282
+ .problems.first.should.match(/empty `### Added` section/)
283
+ end
284
+
285
+ it "rejects duplicates and out-of-order releases" do
286
+ body = "## [1.0.0] - 2026-01-02\n\n### Added\n\n- x\n\n## [1.0.0] - 2026-01-01\n\n### Added\n\n- y\n"
287
+ changelog.call(body).problems.first.should.match(/duplicate section for 1\.0\.0/)
288
+
289
+ body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [2.0.0] - 2026-01-02\n\n### Added\n\n- y\n"
290
+ changelog.call(body).problems.first.should.match(/must run newest to oldest/)
291
+ end
292
+
293
+ it "requires [Unreleased] to come first" do
294
+ body = "## [1.0.0] - 2026-01-01\n\n### Added\n\n- x\n\n## [Unreleased]\n"
295
+ changelog.call(body).problems.first.should.match(/\[Unreleased\] must be the first section/)
296
+ end
297
+
298
+ it "blocks a release with no section of its own" do
299
+ changelog.call(good).release_problems("9.9.9").first.should.match(/no section for 9\.9\.9/)
300
+ end
301
+
302
+ it "blocks a release that is not the newest section" do
303
+ changelog.call(good).release_problems("4.0.0").first.should.match(/lists 4\.1\.0 above 4\.0\.0/)
304
+ end
305
+
306
+ it "reports format problems ahead of release problems" do
307
+ changelog.call("## nonsense\n").release_problems("4.1.0").first.should.match(/malformed version heading/)
308
+ end
309
+
310
+ it "loads the repo's own CHANGELOG.md" do
311
+ GemKit::Release::Changelog.load.missing?.should.be.false
312
+ end
313
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+
5
+ module GemKit
6
+ module Release
7
+ module CLI
8
+ # The deadline check lives here rather than at release time because the
9
+ # bump is the last moment anyone can be stopped: once the version file
10
+ # says 5.0.0, every promise to disappear "in 5.0" is already broken.
11
+ class Bump < Command
12
+ desc "Move the gem version, refusing to bump onto a deprecation deadline"
13
+
14
+ argument :segment,
15
+ type: :string, required: false, default: "patch",
16
+ values: %w[major minor patch],
17
+ desc: "Which segment of the version to move"
18
+
19
+ option :force,
20
+ type: :boolean, default: false,
21
+ desc: "Bump even when a deprecation comes due"
22
+
23
+ example [
24
+ "minor # 4.1.0 -> 4.2.0",
25
+ "major # refused while a 5.0 deprecation is still in the tree",
26
+ "major --force # bump anyway, saying what it overrode",
27
+ ]
28
+
29
+ def call(segment: "patch", **options)
30
+ current = project.version.to_s
31
+
32
+ begin
33
+ target = VersionFile.bump(current, segment)
34
+ rescue VersionFile::Error => error
35
+ fail_with(error.message)
36
+ end
37
+
38
+ problems = gate.bump_problems(target)
39
+ unless problems.empty?
40
+ unless options[:force]
41
+ refuse("Refusing to bump #{current} -> #{target}:",
42
+ problems + ["", "Remove them, then bump. Override with --force."])
43
+ end
44
+
45
+ say("--force given; bumping past #{problems.size} deprecation(s) anyway.")
46
+ end
47
+
48
+ version_file.write(target)
49
+ say("#{current} -> #{target}")
50
+
51
+ upcoming = gate.upcoming_deprecations(target)
52
+ unless upcoming.empty?
53
+ say("#{upcoming.size} deprecation(s) still outstanding (none due in #{target}).")
54
+ end
55
+
56
+ # The changelog is the one release artefact nothing regenerates, and
57
+ # the one most easily forgotten — so say so loudly.
58
+ say
59
+ say("#{RED}now run: gem kit changelog --write#{RESET}")
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ __END__
67
+
68
+ describe "gem_kit/release/cli/bump" do
69
+ require_relative "../../../../spec/support/gem_kit_release_spec"
70
+ extend GemKitReleaseSpec
71
+
72
+ it "moves the version and points at the changelog" do
73
+ with_gem do |dir|
74
+ status, out, _err = invoke(["bump", "minor"], dir)
75
+
76
+ status.should == 0
77
+ out.should.match(/1\.2\.3 -> 1\.3\.0/)
78
+ out.should.match(/now run: gem kit changelog --write/)
79
+ File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"1\.3\.0"/)
80
+ end
81
+ end
82
+
83
+ it "defaults to a patch bump" do
84
+ with_gem do |dir|
85
+ invoke(["bump"], dir)
86
+ File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"1\.2\.4"/)
87
+ end
88
+ end
89
+
90
+ it "refuses to bump onto a deadline, leaving the version file alone" do
91
+ with_gem do |dir|
92
+ status, _out, err = invoke(["bump", "major"], dir, deprecations: [["Old", "New", "2.0"]])
93
+
94
+ status.should == 1
95
+ err.should.match(/Refusing to bump 1\.2\.3 -> 2\.0\.0/)
96
+ err.should.match(/Old -> New/)
97
+ File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"1\.2\.3"/)
98
+ end
99
+ end
100
+
101
+ it "--force bumps past a deadline and says so" do
102
+ with_gem do |dir|
103
+ status, out, _err = invoke(["bump", "major", "--force"], dir, deprecations: [["Old", "New", "2.0"]])
104
+
105
+ status.should == 0
106
+ out.should.match(/--force given; bumping past 1 deprecation/)
107
+ File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"2\.0\.0"/)
108
+ end
109
+ end
110
+
111
+ it "mentions deprecations outstanding but not yet due" do
112
+ with_gem do |dir|
113
+ _status, out, _err = invoke(["bump", "minor"], dir, deprecations: [["Old", "New", "9.0"]])
114
+ out.should.match(/1 deprecation\(s\) still outstanding \(none due in 1\.3\.0\)/)
115
+ end
116
+ end
117
+
118
+ it "rejects a segment that is not major, minor or patch" do
119
+ with_gem do |dir|
120
+ status, _out, _err = invoke(["bump", "epoch"], dir)
121
+ status.should.not == 0
122
+ File.read(File.join(dir, "lib/demo/version.rb")).should.match(/"1\.2\.3"/)
123
+ end
124
+ end
125
+
126
+ it "reports a directory with no gemspec" do
127
+ Dir.mktmpdir do |dir|
128
+ status, _out, err = invoke(["bump", "minor"], dir)
129
+ status.should == 1
130
+ err.should.match(/no \.gemspec/)
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+
5
+ module GemKit
6
+ module Release
7
+ module CLI
8
+ # Linting is the default because it is the safe, read-only half. Writing
9
+ # edits a file and costs money, so it asks for the flag.
10
+ class Changelog < Command
11
+ desc "Lint CHANGELOG.md, or have an AI CLI write this version's entry"
12
+
13
+ argument :version,
14
+ type: :string, required: false,
15
+ desc: "Check that this version is ready to release (default: format only)"
16
+
17
+ option :write,
18
+ type: :boolean, default: false,
19
+ desc: "Ask the configured AI CLI to write this version's entry"
20
+
21
+ example [
22
+ " # lint the format",
23
+ "4.2.0 # ...and check 4.2.0 is ready to release",
24
+ "--write # hand the entry to the configured AI CLI",
25
+ ]
26
+
27
+ def call(version: nil, **options)
28
+ return write_entry if options[:write]
29
+
30
+ problems = version ? gate.release_problems(version) : gate.changelog_problems
31
+
32
+ if problems.empty?
33
+ say("#{relative(project.changelog_path)} is #{version ? "ready to release #{version}" : "clean"}.")
34
+ return
35
+ end
36
+
37
+ refuse("#{problems.size} problem(s) in #{relative(project.changelog_path)}:", problems)
38
+ end
39
+
40
+ private
41
+
42
+ def write_entry
43
+ writer = project.changelog_writer
44
+ unless system("command -v #{writer} >/dev/null 2>&1")
45
+ fail_with("#{writer} is not on PATH (set config.changelog_writer)")
46
+ end
47
+
48
+ say("Writing #{relative(project.changelog_path)} for #{project.version}…")
49
+ fail_with("#{writer} failed") unless system(writer, prompt)
50
+ end
51
+
52
+ def prompt
53
+ <<~PROMPT
54
+ Update #{File.basename(project.changelog_path)} for the release of version #{project.version}.
55
+
56
+ The changes to document are the commits since the last tag plus any
57
+ uncommitted work. Read `git log`, `git status` and `git diff` first.
58
+
59
+ Rules:
60
+ - Follow Keep a Changelog (https://keepachangelog.com/en/1.1.0/) exactly.
61
+ The file must pass `gem kit changelog #{project.version}`; run it when you
62
+ are done and fix anything it reports.
63
+ - Add one `## [#{project.version}] - #{Time.now.strftime("%Y-%m-%d")}` section
64
+ directly below `## [Unreleased]`, and move anything already under
65
+ [Unreleased] that shipped in this version into it. Leave [Unreleased] in
66
+ place, empty.
67
+ - Group entries only under: Added, Changed, Deprecated, Removed, Fixed,
68
+ Security. Omit the groups with nothing in them.
69
+ - Write for someone upgrading the gem: what changed in the public API, what
70
+ they must now do differently. Name the constants and methods involved.
71
+ Skip internal refactors, test-only changes and typo fixes.
72
+ - Anything deprecated this cycle goes under Deprecated, naming the
73
+ replacement and the version it will be removed in (see
74
+ `gem kit deprecations`).
75
+ - Match the voice and level of detail of the existing entries.
76
+ - Edit #{File.basename(project.changelog_path)} only. Do not touch any other
77
+ file, and do not commit.
78
+ PROMPT
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ __END__
86
+
87
+ describe "gem_kit/release/cli/changelog" do
88
+ require_relative "../../../../spec/support/gem_kit_release_spec"
89
+ extend GemKitReleaseSpec
90
+
91
+ it "lints the format when given no version" do
92
+ with_gem do |dir|
93
+ status, out, _err = invoke(["changelog"], dir)
94
+ status.should == 0
95
+ out.should.match(/CHANGELOG\.md is clean/)
96
+ end
97
+ end
98
+
99
+ it "checks a version is ready to release" do
100
+ with_gem do |dir|
101
+ invoke(["changelog", "1.2.3"], dir).first.should == 0
102
+
103
+ status, _out, err = invoke(["changelog", "9.9.9"], dir)
104
+ status.should == 1
105
+ err.should.match(/no section for 9\.9\.9/)
106
+ end
107
+ end
108
+
109
+ it "reports a malformed changelog with line numbers" do
110
+ with_gem(changelog: :none) do |dir|
111
+ File.write(File.join(dir, "CHANGELOG.md"), "# Changelog\n\n## 1.0.0\n")
112
+
113
+ status, _out, err = invoke(["changelog"], dir)
114
+ status.should == 1
115
+ err.should.match(/malformed version heading/)
116
+ err.should.match(/CHANGELOG\.md:3/)
117
+ end
118
+ end
119
+
120
+ it "reports a changelog that does not exist" do
121
+ with_gem(changelog: :none) do |dir|
122
+ status, _out, err = invoke(["changelog"], dir)
123
+ status.should == 1
124
+ err.should.match(/does not exist/)
125
+ end
126
+ end
127
+
128
+ it "--write fails clearly when the configured writer is not on PATH" do
129
+ with_gem do |dir|
130
+ GemKit::Release.configure { |config| config.changelog_writer = "definitely-not-a-command" }
131
+ begin
132
+ status, _out, err = invoke(["changelog", "--write"], dir)
133
+ status.should == 1
134
+ err.should.match(/definitely-not-a-command is not on PATH/)
135
+ ensure
136
+ GemKit::Release.reset!
137
+ end
138
+ end
139
+ end
140
+
141
+ it "--write invokes the configured writer" do
142
+ with_gem do |dir|
143
+ # `true` accepts and ignores its argument, so this exercises the whole
144
+ # path without spending anything.
145
+ GemKit::Release.configure { |config| config.changelog_writer = "true" }
146
+ begin
147
+ status, out, _err = invoke(["changelog", "--write"], dir)
148
+ status.should == 0
149
+ out.should.match(/Writing CHANGELOG\.md for 1\.2\.3/)
150
+ ensure
151
+ GemKit::Release.reset!
152
+ end
153
+ end
154
+ end
155
+
156
+ it "is reachable by its alias" do
157
+ with_gem do |dir|
158
+ invoke(["log"], dir).first.should == 0
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ require_relative "../../release"
6
+
7
+ module GemKit
8
+ module Release
9
+ module CLI
10
+ # Raised instead of exiting, so the caller decides what a failure means:
11
+ # the `gem kit` bridge turns it into terminate_interaction, and the specs
12
+ # turn it into a status.
13
+ class Failure < StandardError; end
14
+
15
+ # Shared base for the subcommands. Each is thin — declare arguments and
16
+ # options so dry-cli can render the help page, ask a library object a
17
+ # question, report. The work stays in Gate, VersionFile, Changelog and
18
+ # Deprecate.
19
+ class Command < Dry::CLI::Command
20
+ RED = "\e[0;31m"
21
+ RESET = "\e[0m"
22
+
23
+ # The gem in the working directory. Everything is inferred from its
24
+ # .gemspec, so there is nothing to configure in the common case.
25
+ def project
26
+ @project ||= Project.detect
27
+ rescue Project::NotFound => error
28
+ fail_with(error.message)
29
+ end
30
+
31
+ def gate
32
+ @gate ||= Gate.new(project)
33
+ end
34
+
35
+ def version_file
36
+ VersionFile.new(project.version_file, template: project.version_template)
37
+ end
38
+
39
+ def say(message = "") = $stdout.puts(message)
40
+
41
+ def fail_with(message)
42
+ raise Failure, message
43
+ end
44
+
45
+ # Report a problem list under a heading and stop. One message — a
46
+ # refusal is not half output and half diagnostics.
47
+ def refuse(heading, problems)
48
+ fail_with([heading, "", *problems.map { |problem| " #{problem}" }].join("\n"))
49
+ end
50
+
51
+ def relative(path) = path.sub("#{project.root}/", "")
52
+
53
+ def render(entries)
54
+ lines(entries).each { |line| say(" #{line}") }
55
+ end
56
+
57
+ # Deprecation entries as display lines: the deadline, the rename, and
58
+ # where it was declared.
59
+ def lines(entries)
60
+ entries.sort_by { |entry| [entry.removed_in, entry.name] }.flat_map do |entry|
61
+ line = "#{entry.removed_in.to_s.ljust(8)} #{entry}"
62
+ entry.declared_at ? [line, "#{" " * 8} #{entry.declared_at}"] : [line]
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end