kettle-changelog 1.0.0 → 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: 470b4a1221d494c5197d12fa669c5c2b554cbc71a66c2bc15d4d46a1ccaaef8a
4
- data.tar.gz: 59f8f87524474affe14bb3c4a4907268af1ce1205a79679e521bd5e6a225b624
3
+ metadata.gz: af5e98644da17ca48d3b7e227af67da6a5a242e28e6c95d90e388f9584d5ee29
4
+ data.tar.gz: 202d219a65ec0b49398f87080bb705154e7651b2e69a90c49d044dbddcfc45a0
5
5
  SHA512:
6
- metadata.gz: ea215da6524469a17c0ead84eb652377de8ecd31eac9382b4c8012381d60a62b254b34719a03c02d96eedb6184785c1510d5a246cc3fe3b44b4439edba396cbf
7
- data.tar.gz: 17ba27fb850c28d715b553740636a3f6a4db3ea7fd5a0bfa225867d7336c5f26342b8a486f6ff23f857f6da46616f8e8f751b8d29d49d6edf54bbac0e1c78351
6
+ metadata.gz: c269f93480ff1102fbf2b4972ee97d71724302f9585f3f6b7dd051dcb83db491500aaf7f967a083cdbe4a854db225e538e866b366ab42f0f53d34dd6fa80f1a0
7
+ data.tar.gz: 728692d54483daf73738e4c033419ba76ebd0713c10830900b07a498eb2ba22f93cae4eb383764ac9cc359088b3ad9175e1529fa5bf41d4be0d64be4bb429052
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -28,10 +28,34 @@ Please file a bug if you notice a violation of semantic versioning.
28
28
 
29
29
  ### Fixed
30
30
 
31
- - Preserve family-local dependency wiring during aggregate coverage runs.
32
-
33
31
  ### Security
34
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
+
44
+ ## [1.0.1] - 2026-08-11
45
+
46
+ - TAG: [v1.0.1][1.0.1t]
47
+ - COVERAGE: 93.56% -- 944/1009 lines in 5 files
48
+ - BRANCH COVERAGE: 77.39% -- 332/429 branches in 5 files
49
+ - 42.31% documented
50
+
51
+ ### Added
52
+
53
+ - Expose structural transfer-entry merging for consumers that replay kettle-jem template history without duplicating Markdown traversal.
54
+
55
+ ### Fixed
56
+
57
+ - Preserve family-local dependency wiring during aggregate coverage runs.
58
+
35
59
  ## [1.0.0] - 2026-08-10
36
60
 
37
61
  - TAG: [v1.0.0][1.0.0t]
@@ -114,6 +138,10 @@ Please file a bug if you notice a violation of semantic versioning.
114
138
 
115
139
  - Initial release
116
140
 
117
- [Unreleased]: https://github.com/kettle-dev/kettle-changelog/compare/v1.0.0...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
144
+ [1.0.1]: https://github.com/kettle-dev/kettle-changelog/compare/v1.0.0...v1.0.1
145
+ [1.0.1t]: https://github.com/kettle-dev/kettle-changelog/releases/tag/v1.0.1
118
146
  [1.0.0]: https://github.com/kettle-dev/kettle-changelog/compare/v0.1.0...v1.0.0
119
147
  [1.0.0t]: https://github.com/kettle-dev/kettle-changelog/releases/tag/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-0.866-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
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kettle
4
+ module Changelog
5
+ class TransferMerger
6
+ STANDARD_HEADINGS = %w[Added Changed Deprecated Removed Fixed Security].freeze
7
+
8
+ def self.apply(content:, entries:, excluded_keys: [])
9
+ new(content: content, entries: entries, excluded_keys: excluded_keys).run
10
+ end
11
+
12
+ def initialize(content:, entries:, excluded_keys: [])
13
+ @content = content.to_s
14
+ @entries = Array(entries).filter_map do |entry|
15
+ next unless entry.is_a?(Hash)
16
+
17
+ key = entry_value(entry, :key).to_s
18
+ lines = Array(entry_value(entry, :lines))
19
+ next if key.empty? || lines.empty?
20
+
21
+ {
22
+ key: key,
23
+ section: entry_value(entry, :section),
24
+ lines: lines
25
+ }
26
+ end
27
+ @excluded_keys = Array(excluded_keys).map(&:to_s).reject(&:empty?).to_set
28
+ end
29
+
30
+ def run
31
+ normalized = remove_excluded_entries(@content)
32
+ existing_keys = transfer_occurrences(normalized).map { |occurrence| occurrence.fetch(:key) }.to_set
33
+ missing = @entries.reject { |entry| existing_keys.include?(entry.fetch(:key).to_s) }
34
+ return ensure_trailing_newline(normalized) if missing.empty?
35
+
36
+ lines = source_lines(normalized)
37
+ unreleased = unreleased_line_index(lines)
38
+ return ensure_trailing_newline(normalized) unless unreleased
39
+
40
+ destination_end = unreleased_end_index(lines, unreleased)
41
+ destination_body = lines[(unreleased + 1)...destination_end] || []
42
+ items = changelog_items(destination_body)
43
+ missing.group_by { |entry| entry.fetch(:section, "### Changed") }.each do |section, section_entries|
44
+ section = STANDARD_HEADINGS.include?(section.to_s.delete_prefix("### ")) ? "### #{section.to_s.delete_prefix("### ").strip}" : "### Changed"
45
+ items[section] ||= []
46
+ items[section].pop while items[section].any? && items[section].last.to_s.strip.empty?
47
+ items[section] << "" if items[section].any?
48
+ section_entries.each do |entry|
49
+ items[section].concat(Array(entry.fetch(:lines)).map(&:rstrip))
50
+ end
51
+ end
52
+
53
+ merged_lines = lines[0...unreleased] +
54
+ build_unreleased_section(lines.fetch(unreleased), items) +
55
+ lines[destination_end..].to_a
56
+ ensure_trailing_newline(merged_lines.join("\n").gsub(/\n{3,}/, "\n\n"))
57
+ end
58
+
59
+ private
60
+
61
+ def remove_excluded_entries(content)
62
+ return content if @excluded_keys.empty?
63
+
64
+ lines = source_lines(content)
65
+ removals = transfer_occurrences(content).filter_map do |occurrence|
66
+ next unless @excluded_keys.include?(occurrence.fetch(:key).to_s)
67
+
68
+ (occurrence.fetch(:start_line)..occurrence.fetch(:end_line))
69
+ end
70
+ return content if removals.empty?
71
+
72
+ lines.each_with_index.reject do |_line, index|
73
+ removals.any? { |range| range.cover?(index + 1) }
74
+ end.map(&:first).join("\n").then { |text| ensure_trailing_newline(text.gsub(/\n{3,}/, "\n\n")) }
75
+ end
76
+
77
+ def transfer_occurrences(content)
78
+ context = markdown_context(content)
79
+ release_sections = context.structural_owners(owner_scope: :heading_sections)
80
+ .select { |owner| owner.level == 2 }
81
+ .sort_by { |owner| owner.location.start_line }
82
+ list_items = context.structural_owners(owner_scope: :list_items)
83
+ list_items.filter_map do |item|
84
+ next unless item.depth == 1
85
+
86
+ key = transfer_key(item.source)
87
+ next unless key
88
+
89
+ release = release_section_for(release_sections, item)
90
+ next unless release
91
+
92
+ {
93
+ key: key,
94
+ release_heading: "## #{release.heading_text}",
95
+ start_line: item.location.start_line,
96
+ end_line: item.location.end_line
97
+ }
98
+ end
99
+ end
100
+
101
+ def transfer_key(source)
102
+ source.to_s.lines.first.to_s.strip[/\A[-*] +((?:kettle-jem-template)-\d{8}-\d{3})\b/, 1]
103
+ end
104
+
105
+ def release_section_for(sections, item)
106
+ sections.each_with_index.find do |section, index|
107
+ next_section = sections[index + 1]
108
+ section_end_line = next_section ? next_section.location.start_line - 1 : section.location.end_line
109
+ item.location.start_line > section.location.start_line && item.location.end_line <= section_end_line
110
+ end&.first
111
+ end
112
+
113
+ def changelog_items(body_lines)
114
+ items = {}
115
+ heading = nil
116
+ index = 0
117
+ while index < body_lines.length
118
+ line = body_lines.fetch(index)
119
+ if line.start_with?("### ")
120
+ heading = line.strip
121
+ items[heading] ||= []
122
+ index += 1
123
+ next
124
+ end
125
+
126
+ if bullet_line?(line)
127
+ collected, index = collect_list_item(body_lines, index)
128
+ items[heading] ||= []
129
+ items[heading].concat(collected)
130
+ next
131
+ end
132
+
133
+ index += 1
134
+ end
135
+ items
136
+ end
137
+
138
+ def build_unreleased_section(heading, items)
139
+ lines = [heading]
140
+ STANDARD_HEADINGS.each do |standard_heading|
141
+ heading_line = "### #{standard_heading}"
142
+ lines << ""
143
+ lines << heading_line
144
+ lines << ""
145
+ section_items = items[heading_line].to_a.dup
146
+ section_items.pop while section_items.any? && section_items.last.to_s.strip.empty?
147
+ lines.concat(section_items) if section_items.any?
148
+ end
149
+ lines << ""
150
+ end
151
+
152
+ def source_lines(content)
153
+ content.to_s.split("\n")
154
+ end
155
+
156
+ def unreleased_line_index(lines)
157
+ lines.index { |line| line.to_s.strip == "## [Unreleased]" }
158
+ end
159
+
160
+ def unreleased_end_index(lines, unreleased_index)
161
+ index = unreleased_index + 1
162
+ while index < lines.length
163
+ line = lines.fetch(index)
164
+ return index if link_reference_line?(line)
165
+ return index if line.start_with?("# ") || (line.start_with?("## ") && line.to_s.strip != "## [Unreleased]")
166
+
167
+ index += 1
168
+ end
169
+ lines.length
170
+ end
171
+
172
+ def link_reference_line?(line)
173
+ line.to_s.lstrip.start_with?("[") && line.to_s.include?("]:")
174
+ end
175
+
176
+ def entry_value(entry, key)
177
+ entry[key] || entry[key.to_s]
178
+ end
179
+
180
+ def bullet_line?(line)
181
+ line.to_s.lstrip.start_with?("- ", "* ")
182
+ end
183
+
184
+ def collect_list_item(lines, start_index)
185
+ line = lines.fetch(start_index).to_s
186
+ base_indent = line.length - line.lstrip.length
187
+ item_lines = [line.rstrip]
188
+ index = start_index + 1
189
+ in_fence = false
190
+ while index < lines.length
191
+ current = lines.fetch(index).to_s
192
+ current_indent = current.length - current.lstrip.length
193
+ break if !in_fence && heading_line?(current)
194
+ break if !in_fence && bullet_line?(current) && current_indent <= base_indent
195
+
196
+ if current.lstrip.start_with?("```")
197
+ in_fence = !in_fence
198
+ item_lines << current.rstrip
199
+ index += 1
200
+ next
201
+ end
202
+
203
+ break unless in_fence || current.strip.empty? || current_indent > base_indent
204
+
205
+ item_lines << current.rstrip
206
+ index += 1
207
+ end
208
+ [item_lines, index]
209
+ end
210
+
211
+ def heading_line?(line)
212
+ line.to_s.lstrip.start_with?("#")
213
+ end
214
+
215
+ def markdown_context(content)
216
+ require "ast/crispr/markdown/markly"
217
+ Ast::Crispr::Markdown::Markly.document_context(content: content, source_label: "CHANGELOG.md")
218
+ rescue LoadError => error
219
+ raise Error, "kettle-changelog transfer merging requires ast-crispr-markdown-markly (#{error.message})"
220
+ end
221
+
222
+ def ensure_trailing_newline(content)
223
+ "#{content.to_s.rstrip}\n"
224
+ end
225
+ end
226
+ end
227
+ 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.0"
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,8 @@ 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"
14
+ autoload :TransferMerger, "kettle/changelog/transfer_merger"
13
15
  end
14
16
  end
15
17
 
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.0
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -295,6 +295,8 @@ 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
299
+ - lib/kettle/changelog/transfer_merger.rb
298
300
  - lib/kettle/changelog/version.rb
299
301
  - sig/kettle/changelog.rbs
300
302
  homepage: https://github.com/kettle-dev/kettle-changelog
@@ -302,10 +304,10 @@ licenses:
302
304
  - AGPL-3.0-only
303
305
  metadata:
304
306
  homepage_uri: https://kettle-changelog.galtzo.com
305
- source_code_uri: https://github.com/kettle-dev/kettle-changelog/tree/v1.0.0
306
- changelog_uri: https://github.com/kettle-dev/kettle-changelog/blob/v1.0.0/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
307
309
  bug_tracker_uri: https://github.com/kettle-dev/kettle-changelog/issues
308
- documentation_uri: https://www.rubydoc.info/gems/kettle-changelog/1.0.0
310
+ documentation_uri: https://www.rubydoc.info/gems/kettle-changelog/1.0.2
309
311
  funding_uri: https://github.com/sponsors/pboling
310
312
  wiki_uri: https://github.com/kettle-dev/kettle-changelog/wiki
311
313
  news_uri: https://www.railsbling.com/tags/kettle-changelog
metadata.gz.sig CHANGED
Binary file