kettle-changelog 1.0.1 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8737c49a9337bff6a8234bea1914fd8a054ca66a9fe1107b020cc80ef52205d2
4
- data.tar.gz: e15afde1f514972d8417785a657c1a0b5c618fd3edca8a4e5be3c4f59aa7ff75
3
+ metadata.gz: af5e98644da17ca48d3b7e227af67da6a5a242e28e6c95d90e388f9584d5ee29
4
+ data.tar.gz: 202d219a65ec0b49398f87080bb705154e7651b2e69a90c49d044dbddcfc45a0
5
5
  SHA512:
6
- metadata.gz: 8afab816b2675e3ca97449dd50176be20d4ec15723fa4107ccb98f6a6d0c8779467b823f5f472c569d9b856ba6229217c2e5ca7b5e2d300058453a1d79182197
7
- data.tar.gz: fd565677b152771afcc28db39a541c061fe58d026c3dd9f571d97cab4801dd8d7b9365678c7fc8d23f5c5340882cce55c977c69e58ff5c109e1bbb3f85f19e28
6
+ metadata.gz: c269f93480ff1102fbf2b4972ee97d71724302f9585f3f6b7dd051dcb83db491500aaf7f967a083cdbe4a854db225e538e866b366ab42f0f53d34dd6fa80f1a0
7
+ data.tar.gz: 728692d54483daf73738e4c033419ba76ebd0713c10830900b07a498eb2ba22f93cae4eb383764ac9cc359088b3ad9175e1529fa5bf41d4be0d64be4bb429052
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,17 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [1.0.2] - 2026-08-12
34
+
35
+ - TAG: [v1.0.2][1.0.2t]
36
+ - COVERAGE: 94.03% -- 1055/1122 lines in 6 files
37
+ - BRANCH COVERAGE: 76.79% -- 364/474 branches in 6 files
38
+ - 38.71% documented
39
+
40
+ ### Changed
41
+
42
+ - Add structural [kc]-keyed upserts for maintenance changelog entries.
43
+
33
44
  ## [1.0.1] - 2026-08-11
34
45
 
35
46
  - TAG: [v1.0.1][1.0.1t]
@@ -127,7 +138,9 @@ Please file a bug if you notice a violation of semantic versioning.
127
138
 
128
139
  - Initial release
129
140
 
130
- [Unreleased]: https://github.com/kettle-dev/kettle-changelog/compare/v1.0.1...HEAD
141
+ [Unreleased]: https://github.com/kettle-dev/kettle-changelog/compare/v1.0.2...HEAD
142
+ [1.0.2]: https://github.com/kettle-dev/kettle-changelog/compare/v1.0.1...v1.0.2
143
+ [1.0.2t]: https://github.com/kettle-dev/kettle-changelog/releases/tag/v1.0.2
131
144
  [1.0.1]: https://github.com/kettle-dev/kettle-changelog/compare/v1.0.0...v1.0.1
132
145
  [1.0.1t]: https://github.com/kettle-dev/kettle-changelog/releases/tag/v1.0.1
133
146
  [1.0.0]: https://github.com/kettle-dev/kettle-changelog/compare/v0.1.0...v1.0.0
data/README.md CHANGED
@@ -395,7 +395,7 @@ Thanks for RTFM. ☺️
395
395
  [📌gitmoji]: https://gitmoji.dev
396
396
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
397
397
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
398
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.009-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
398
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-1.122-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
399
399
  [🔐security]: https://github.com/kettle-dev/kettle-changelog/blob/main/SECURITY.md
400
400
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
401
401
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
data/exe/kettle-changelog CHANGED
@@ -63,7 +63,7 @@ end
63
63
 
64
64
  if ARGV.include?("-h") || ARGV.include?("--help")
65
65
  puts <<~USAGE
66
- Usage: kettle-changelog [--version VERSION] [--yes] [--update-prep|--reformat|--backfill-released-version VERSION] [--pending-release] [--release-state] [--refresh-cache] [--add-unreleased-entry --section SECTION --entry TEXT] [--json|--events[=TYPES]] [--no-strict] [--no-coverage-threshold]
66
+ Usage: kettle-changelog [--version VERSION] [--yes] [--update-prep|--reformat|--backfill-released-version VERSION] [--pending-release] [--release-state] [--refresh-cache] [--add-unreleased-entry --section SECTION --entry TEXT] [--upsert-unreleased-entry --section SECTION --key KEY --entry TEXT] [--json|--events[=TYPES]] [--no-strict] [--no-coverage-threshold]
67
67
 
68
68
  Detects the current version from lib/**/version.rb, the latest live release, and
69
69
  the most recent CHANGELOG.md release section, then prompts to confirm the selected plan:
@@ -84,8 +84,11 @@ if ARGV.include?("-h") || ARGV.include?("--help")
84
84
  --release-status Print changelog release state, including latest published release and pending sources
85
85
  --refresh-cache Bypass cached gem.coop version data for this invocation
86
86
  --add-unreleased-entry Add one entry to an existing section under ## [Unreleased]
87
+ --upsert-unreleased-entry Insert or replace one [kc]-keyed entry under ## [Unreleased]
87
88
  --section SECTION Unreleased section to receive the entry (Added, Changed, Deprecated, Removed, Fixed, Security)
89
+ --key KEY Stable kettle-changelog key for --upsert-unreleased-entry
88
90
  --entry TEXT Changelog entry text; "- " is added when omitted
91
+ --legacy-prefix TEXT Legacy entry prefix to replace during a keyed upsert; may be repeated
89
92
  --json Print query output as JSON
90
93
  --events[=TYPES] Print newline-delimited JSON changelog events
91
94
  --no-strict Allow missing coverage and yard data (warnings only, no errors)
@@ -168,6 +171,7 @@ begin
168
171
  release_state_query = ARGV.delete("--release-state") || ARGV.delete("--release-status")
169
172
  refresh_cache = ARGV.delete("--refresh-cache")
170
173
  add_unreleased_entry = ARGV.delete("--add-unreleased-entry") || ARGV.delete("--add-changelog-entry")
174
+ upsert_unreleased_entry = ARGV.delete("--upsert-unreleased-entry") || ARGV.delete("--upsert-changelog-entry")
171
175
  json_output = ARGV.delete("--json")
172
176
  events_output, event_types = extract_events_arg!(ARGV)
173
177
  Kettle::Dev::ExitAdapter.abort("--events cannot be combined with --json") if events_output && json_output
@@ -193,8 +197,36 @@ begin
193
197
  value
194
198
  end
195
199
 
200
+ def extract_option_args!(argv, name)
201
+ values = []
202
+ while (value = extract_option_arg!(argv, name))
203
+ values << value
204
+ end
205
+ values
206
+ end
207
+
196
208
  historical_backfill_version = extract_option_arg!(ARGV, "--backfill-released-version")
197
209
 
210
+ if upsert_unreleased_entry
211
+ section = extract_option_arg!(ARGV, "--section")
212
+ key = extract_option_arg!(ARGV, "--key")
213
+ entry = extract_option_arg!(ARGV, "--entry")
214
+ legacy_prefixes = extract_option_args!(ARGV, "--legacy-prefix")
215
+ Kettle::Dev::ExitAdapter.abort("--section is required with --upsert-unreleased-entry") if section.to_s.empty?
216
+ Kettle::Dev::ExitAdapter.abort("--key is required with --upsert-unreleased-entry") if key.to_s.empty?
217
+ Kettle::Dev::ExitAdapter.abort("--entry is required with --upsert-unreleased-entry") if entry.to_s.empty?
218
+
219
+ result = Kettle::Changelog::KeyedEntryUpserter.new(
220
+ section: section,
221
+ key: key,
222
+ entry: entry,
223
+ legacy_prefixes: legacy_prefixes
224
+ ).run
225
+ message = result.fetch(:changed) ? "CHANGELOG.md keyed entry updated under Unreleased #{section}." : "CHANGELOG.md keyed entry already matches under Unreleased #{section}."
226
+ puts(json_output ? JSON.pretty_generate(result) : message)
227
+ exit(0)
228
+ end
229
+
198
230
  if add_unreleased_entry
199
231
  section = extract_option_arg!(ARGV, "--section")
200
232
  entry = extract_option_arg!(ARGV, "--entry")
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Changelog
5
+ class KeyedEntryUpserter
6
+ SECTIONS = %w[Added Changed Deprecated Removed Fixed Security].freeze
7
+ KEY_PREFIX = "[kc]"
8
+
9
+ def initialize(section:, key:, entry:, legacy_prefixes: [], root: Kettle::Dev::CIHelpers.project_root)
10
+ @root = root
11
+ @section = section.to_s
12
+ @key = key.to_s.strip
13
+ @entry = entry
14
+ @legacy_prefixes = Array(legacy_prefixes).map { |prefix| prefix.to_s.strip }.reject(&:empty?)
15
+ @changelog_path = ENV.fetch("K_CHANGELOG_PATH", File.join(@root, "CHANGELOG.md"))
16
+ end
17
+
18
+ def run
19
+ validate!
20
+ source = read_source!
21
+ matches = find_matches(source)
22
+ if matches.count { |match| match.fetch(:kind) == :keyed } > 1
23
+ raise Error, "expected at most one #{key_label} entry in CHANGELOG.md"
24
+ end
25
+ if matches.any? { |match| match.fetch(:kind) == :keyed } && matches.any? { |match| match.fetch(:kind) == :legacy }
26
+ raise Error, "found both keyed and legacy entries for #{key_label} in CHANGELOG.md"
27
+ end
28
+
29
+ body = @entry.respond_to?(:call) ? @entry.call(matches) : @entry
30
+ rendered = render_entry(body)
31
+ changed = matches.empty? || matches.any? { |match| normalize_source(match.fetch(:source)) != normalize_source(rendered) } || matches.length > 1
32
+ write_updated_source(source, rendered, matches) if changed
33
+
34
+ matched = if matches.empty?
35
+ "inserted"
36
+ elsif matches.first.fetch(:kind) == :legacy
37
+ "legacy"
38
+ else
39
+ "keyed"
40
+ end
41
+
42
+ {
43
+ changed: changed,
44
+ key: @key,
45
+ section: @section,
46
+ entry: rendered,
47
+ matched: matched
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def validate!
54
+ raise Error, "unsupported changelog section #{@section.inspect}" unless SECTIONS.include?(@section)
55
+ raise Error, "changelog key must not be empty" if @key.empty?
56
+ raise Error, "changelog key must not contain whitespace" if @key.match?(/\s/)
57
+ raise Error, "missing CHANGELOG.md in #{Kettle::Dev.display_path(@root)}" unless File.file?(@changelog_path)
58
+ end
59
+
60
+ def read_source!
61
+ File.read(@changelog_path)
62
+ end
63
+
64
+ def require_markly!
65
+ require "ast/crispr/markdown/markly"
66
+ rescue LoadError => error
67
+ raise Error, "kettle-changelog keyed entry updates require ast-crispr-markdown-markly (#{error.message})"
68
+ end
69
+
70
+ def find_matches(source)
71
+ require_markly!
72
+ context = Ast::Crispr::Markdown::Markly.document_context(content: source, source_label: @changelog_path)
73
+ sections = context.structural_owners(owner_scope: :heading_sections)
74
+ unreleased = find_unreleased_heading!(sections)
75
+ target = find_unreleased_section!(sections, unreleased)
76
+ items = context.structural_owners(owner_scope: :list_items).select do |item|
77
+ item.depth == 1 && within?(item, target)
78
+ end
79
+
80
+ items.filter_map do |item|
81
+ kind = if keyed_item?(item.source)
82
+ :keyed
83
+ elsif legacy_item?(item.source)
84
+ :legacy
85
+ end
86
+ next unless kind
87
+
88
+ {
89
+ kind: kind,
90
+ source: item.source,
91
+ start_line: item.location.start_line,
92
+ end_line: item.location.end_line
93
+ }
94
+ end
95
+ end
96
+
97
+ def find_unreleased_heading!(sections)
98
+ matches = sections.select do |owner|
99
+ owner.level == 2 && owner.heading_source.to_s.strip == "## [Unreleased]"
100
+ end
101
+ raise Error, "expected exactly one ## [Unreleased] section in CHANGELOG.md, found #{matches.length}" unless matches.length == 1
102
+
103
+ matches.first
104
+ end
105
+
106
+ def find_unreleased_section!(sections, unreleased)
107
+ matches = sections.select do |owner|
108
+ owner.heading_text.to_s.strip == @section &&
109
+ owner.level == 3 &&
110
+ owner.location.start_line > unreleased.location.start_line &&
111
+ owner.location.end_line <= unreleased.location.end_line
112
+ end
113
+ raise Error, "expected at least one ### #{@section} section under ## [Unreleased] in CHANGELOG.md, found none" if matches.empty?
114
+
115
+ matches.first
116
+ end
117
+
118
+ def within?(item, section)
119
+ item.location.start_line > section.location.start_line &&
120
+ item.location.end_line <= section.location.end_line
121
+ end
122
+
123
+ def keyed_item?(source)
124
+ first_line(source).match?(/\A[-*]\s+\[kc\]\s+#{Regexp.escape(@key)}(?:\s|:)/)
125
+ end
126
+
127
+ def legacy_item?(source)
128
+ return false if @legacy_prefixes.empty?
129
+
130
+ text = first_line(source).sub(/\A[-*]\s+/, "").strip
131
+ @legacy_prefixes.any? { |prefix| text.start_with?(prefix) }
132
+ end
133
+
134
+ def first_line(source)
135
+ source.to_s.lines.first.to_s.strip
136
+ end
137
+
138
+ def key_label
139
+ "#{KEY_PREFIX} #{@key}"
140
+ end
141
+
142
+ def render_entry(body)
143
+ text = body.to_s.strip
144
+ raise Error, "changelog entry must not be empty" if text.empty?
145
+ raise Error, "keyed changelog entry body must not include a list marker" if text.lines.first.to_s.match?(/\A[-*]\s+/)
146
+
147
+ first, *rest = text.lines.map(&:rstrip)
148
+ (["- #{key_label}: #{first}"] + rest).join("\n") + "\n\n"
149
+ end
150
+
151
+ def normalize_source(source)
152
+ source.to_s.strip
153
+ end
154
+
155
+ def write_updated_source(source, rendered, matches)
156
+ if matches.empty?
157
+ File.write(@changelog_path, insert_entry(source, rendered))
158
+ return
159
+ end
160
+
161
+ lines = source.lines
162
+ matches.sort_by { |match| match.fetch(:start_line) }.reverse_each.with_index do |match, index|
163
+ start_index = match.fetch(:start_line) - 1
164
+ end_index = match.fetch(:end_line)
165
+ replacement = index.zero? ? rendered.lines : []
166
+ lines[start_index...end_index] = replacement
167
+ end
168
+ File.write(@changelog_path, lines.join)
169
+ end
170
+
171
+ def insert_entry(source, rendered)
172
+ require_markly!
173
+ context = Ast::Crispr::Markdown::Markly.document_context(content: source, source_label: @changelog_path)
174
+ sections = context.structural_owners(owner_scope: :heading_sections)
175
+ unreleased = find_unreleased_heading!(sections)
176
+ target = find_unreleased_section!(sections, unreleased)
177
+ lines = source.lines
178
+ index = target.location.end_line
179
+ while index > target.location.start_line && lines.fetch(index - 1).strip.empty?
180
+ index -= 1
181
+ end
182
+
183
+ previous_line = lines[index - 1].to_s
184
+ next_line = lines[index].to_s
185
+ before = previous_line.strip.empty? ? "" : "\n"
186
+ after = (next_line.empty? || next_line.strip.empty?) ? "" : "\n"
187
+ lines.insert(index, "#{before}#{rendered.rstrip}\n#{after}")
188
+ lines.join
189
+ end
190
+ end
191
+ end
192
+ end
@@ -5,7 +5,7 @@ module Kettle
5
5
  # Version namespace for this gem.
6
6
  module Version
7
7
  # Current gem version.
8
- VERSION = "1.0.1"
8
+ VERSION = "1.0.2"
9
9
  end
10
10
  # Current gem version exposed at the traditional constant location.
11
11
  VERSION = Version::VERSION # Traditional Constant Location
@@ -10,6 +10,7 @@ module Kettle
10
10
 
11
11
  autoload :CLI, "kettle/changelog/cli"
12
12
  autoload :EntryAdder, "kettle/changelog/entry_adder"
13
+ autoload :KeyedEntryUpserter, "kettle/changelog/keyed_entry_upserter"
13
14
  autoload :TransferMerger, "kettle/changelog/transfer_merger"
14
15
  end
15
16
  end
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-changelog
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -295,6 +295,7 @@ files:
295
295
  - lib/kettle/changelog.rb
296
296
  - lib/kettle/changelog/cli.rb
297
297
  - lib/kettle/changelog/entry_adder.rb
298
+ - lib/kettle/changelog/keyed_entry_upserter.rb
298
299
  - lib/kettle/changelog/transfer_merger.rb
299
300
  - lib/kettle/changelog/version.rb
300
301
  - sig/kettle/changelog.rbs
@@ -303,10 +304,10 @@ licenses:
303
304
  - AGPL-3.0-only
304
305
  metadata:
305
306
  homepage_uri: https://kettle-changelog.galtzo.com
306
- source_code_uri: https://github.com/kettle-dev/kettle-changelog/tree/v1.0.1
307
- changelog_uri: https://github.com/kettle-dev/kettle-changelog/blob/v1.0.1/CHANGELOG.md
307
+ source_code_uri: https://github.com/kettle-dev/kettle-changelog/tree/v1.0.2
308
+ changelog_uri: https://github.com/kettle-dev/kettle-changelog/blob/v1.0.2/CHANGELOG.md
308
309
  bug_tracker_uri: https://github.com/kettle-dev/kettle-changelog/issues
309
- documentation_uri: https://www.rubydoc.info/gems/kettle-changelog/1.0.1
310
+ documentation_uri: https://www.rubydoc.info/gems/kettle-changelog/1.0.2
310
311
  funding_uri: https://github.com/sponsors/pboling
311
312
  wiki_uri: https://github.com/kettle-dev/kettle-changelog/wiki
312
313
  news_uri: https://www.railsbling.com/tags/kettle-changelog
metadata.gz.sig CHANGED
Binary file