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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +31 -3
- data/README.md +1 -1
- data/exe/kettle-changelog +33 -1
- data/lib/kettle/changelog/keyed_entry_upserter.rb +192 -0
- data/lib/kettle/changelog/transfer_merger.rb +227 -0
- data/lib/kettle/changelog/version.rb +1 -1
- data/lib/kettle/changelog.rb +2 -0
- data.tar.gz.sig +0 -0
- metadata +6 -4
- metadata.gz.sig +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af5e98644da17ca48d3b7e227af67da6a5a242e28e6c95d90e388f9584d5ee29
|
|
4
|
+
data.tar.gz: 202d219a65ec0b49398f87080bb705154e7651b2e69a90c49d044dbddcfc45a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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-
|
|
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.
|
|
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
|
data/lib/kettle/changelog.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
306
|
-
changelog_uri: https://github.com/kettle-dev/kettle-changelog/blob/v1.0.
|
|
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.
|
|
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
|