changelog-parser 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a52b0f653d48af02c20cc4f1287dad6a76bcdc66efb802ba80470b21659a027e
4
- data.tar.gz: f77abb93586e09bdf5b11e9e8022ab5d86656ec07ccbfad74fbd73c7f9cbed82
3
+ metadata.gz: 91646e7a7752f0f7c0766b6f488c9cf551f9ffd6b23f850ddf0b0598ff059c23
4
+ data.tar.gz: cd34eeb7f5033457acb042bf34e1130169de8384a2445a77672f99fa22780c66
5
5
  SHA512:
6
- metadata.gz: 1fb1a622fe5b843d14f092acd112824221478ed0ec316b603d99f83e2dede43a31df04833fc226aa00a5574dd509731f3d88a62f19b91eed5cfa298740b6e9cc
7
- data.tar.gz: 9d7c22d58a1520b85215c578778754d68d797fee125ee4cedd0e64cdb5caa6534d9935c9c3d19efb49a567b458ce6fa86393046f366cd7c8e0f2649593d80976
6
+ metadata.gz: ea798c67a91d6393a2c56a82373e7ac268952989e68185dcf51690b4a65212992b69266b326a2dd8bf9e669a9496fb2ed5528ec4a05e15e516ea6c9e89b9ea7d
7
+ data.tar.gz: 86e2a23610383116b309d8face4db4d8a6d99c201bfdaf162025b0297286c82a434f4f545b06a8844815d35a8b77311c54ba522c337bd29f6a6f96fe9bc8d37a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2025-12-23
4
+
5
+ ### Added
6
+
7
+ - `between(old_version, new_version)` method to extract content between versions
8
+ - `line_for_version(version)` method with Dependabot-style pattern matching
9
+ - `find_changelog(directory)` class method to locate changelog files
10
+ - `find_and_parse(directory)` class method for one-step discovery and parsing
11
+ - `to_html` method for markdown to HTML conversion (requires optional markdown gem)
12
+ - Vandamme compatibility layer (`require "changelog/parser/vandamme"`)
13
+ - CLI now accepts directories and auto-finds changelog files
14
+ - CLI `between` command for extracting content between versions
15
+ - CLI `validate` command for checking Keep a Changelog format compliance
16
+
17
+ ### Changed
18
+
19
+ - CLI now uses subcommands (`parse`, `list`, `show`, `between`) instead of flags
20
+ - Version matching now uses negative lookahead to avoid substring matches (e.g., won't match 1.0.1 when looking for 1.0.10)
21
+
3
22
  ## [0.1.0] - 2025-12-23
4
23
 
5
24
  ### Added
data/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Changelog::Parser
2
2
 
3
- A Ruby gem for parsing changelog files into structured data. Supports the Keep a Changelog format, markdown headers, and custom patterns. Zero runtime dependencies.
3
+ [![Gem Version](https://badge.fury.io/rb/changelog-parser.svg)](https://rubygems.org/gems/changelog-parser)
4
+
5
+ A Ruby gem for parsing changelog files into structured data. Supports the Keep a Changelog format, markdown headers, and custom patterns.
4
6
 
5
7
  Inspired by [vandamme](https://github.com/tech-angels/vandamme).
6
8
 
@@ -94,6 +96,35 @@ Changelog::Parser.new(content, format: :markdown)
94
96
  Changelog::Parser.new(content, format: :underline)
95
97
  ```
96
98
 
99
+ ### Extracting content between versions
100
+
101
+ Extract changelog content between two versions (like Dependabot does for PR descriptions):
102
+
103
+ ```ruby
104
+ parser = Changelog::Parser.new(changelog)
105
+
106
+ # Get content between old and new version
107
+ parser.between("1.0.0", "2.0.0")
108
+
109
+ # Get content from a version to the end
110
+ parser.between(nil, "1.5.0")
111
+
112
+ # Get content from start to a version
113
+ parser.between("1.5.0", nil)
114
+ ```
115
+
116
+ ### Finding changelog files
117
+
118
+ Automatically find and parse changelog files in a directory:
119
+
120
+ ```ruby
121
+ # Find changelog file (searches for CHANGELOG.md, NEWS, HISTORY, etc.)
122
+ path = Changelog::Parser.find_changelog("/path/to/project")
123
+
124
+ # Find and parse in one step
125
+ result = Changelog::Parser.find_and_parse("/path/to/project")
126
+ ```
127
+
97
128
  ### Custom patterns
98
129
 
99
130
  For changelogs with non-standard formats, provide a custom regex:
@@ -112,42 +143,52 @@ The gem includes a command-line interface:
112
143
 
113
144
  ```bash
114
145
  # Parse a changelog and output JSON
115
- changelog-parser CHANGELOG.md
146
+ changelog-parser parse CHANGELOG.md
116
147
 
117
- # Pretty print JSON
118
- changelog-parser --pretty CHANGELOG.md
148
+ # Parse from a directory (auto-finds CHANGELOG.md, NEWS, HISTORY, etc.)
149
+ changelog-parser parse /path/to/project
119
150
 
120
151
  # List versions only
121
- changelog-parser --list CHANGELOG.md
152
+ changelog-parser list CHANGELOG.md
122
153
 
123
- # Get a specific version
124
- changelog-parser --version 1.0.0 CHANGELOG.md
154
+ # Show content for a specific version
155
+ changelog-parser show 1.0.0 CHANGELOG.md
125
156
 
126
- # Get content only for a version
127
- changelog-parser --version 1.0.0 --content CHANGELOG.md
157
+ # Show content between two versions (for PR descriptions)
158
+ changelog-parser between 1.0.0 2.0.0 CHANGELOG.md
128
159
 
129
- # Specify format
130
- changelog-parser --format keep_a_changelog CHANGELOG.md
160
+ # Validate against Keep a Changelog format
161
+ changelog-parser validate CHANGELOG.md
162
+
163
+ # Pretty print JSON
164
+ changelog-parser parse --pretty CHANGELOG.md
131
165
 
132
166
  # Read from stdin
133
- cat CHANGELOG.md | changelog-parser -
167
+ cat CHANGELOG.md | changelog-parser parse -
134
168
 
135
169
  # Custom regex pattern
136
- changelog-parser --pattern "^## v([\d.]+)" CHANGELOG.md
170
+ changelog-parser parse --pattern "^## v([\d.]+)" CHANGELOG.md
171
+ ```
172
+
173
+ ### Commands
174
+
175
+ ```
176
+ parse Parse changelog and output JSON (default)
177
+ list List version numbers only
178
+ show Show content for a specific version
179
+ between Show content between two versions
180
+ validate Validate changelog against Keep a Changelog format
137
181
  ```
138
182
 
139
- ### CLI options
183
+ ### Options
140
184
 
141
185
  ```
142
186
  -f, --format FORMAT Changelog format (keep_a_changelog, markdown, underline)
143
- -v, --version VERSION Show only a specific version
144
- -l, --list List versions only
145
- -c, --content Output content only (requires --version)
146
187
  -p, --pattern REGEX Custom version header regex pattern
147
188
  -m, --match-group N Regex capture group for version (default: 1)
148
189
  --pretty Pretty print JSON output
149
190
  -h, --help Show help message
150
- --gem-version Show gem version
191
+ --version Show gem version
151
192
  ```
152
193
 
153
194
  ## Supported formats
data/exe/changelog-parser CHANGED
@@ -5,16 +5,55 @@ require "optparse"
5
5
  require "json"
6
6
  require_relative "../lib/changelog/parser"
7
7
 
8
- options = {
9
- format: nil,
10
- output: :json,
11
- version: nil
12
- }
13
-
14
- parser = OptionParser.new do |opts|
15
- opts.banner = "Usage: changelog-parser [options] [file]"
8
+ def load_changelog(path, options)
9
+ if path.nil? || path == "-"
10
+ $stdin.read
11
+ elsif File.directory?(path)
12
+ changelog_path = Changelog::Parser.find_changelog(path)
13
+ unless changelog_path
14
+ warn "Error: No changelog found in directory: #{path}"
15
+ exit 1
16
+ end
17
+ File.read(changelog_path)
18
+ elsif File.exist?(path)
19
+ File.read(path)
20
+ else
21
+ warn "Error: File or directory not found: #{path}"
22
+ exit 1
23
+ end
24
+ end
25
+
26
+ def build_parser(content, options)
27
+ Changelog::Parser.new(
28
+ content,
29
+ format: options[:format],
30
+ version_pattern: options[:pattern],
31
+ match_group: options[:match_group] || 1
32
+ )
33
+ end
34
+
35
+ def output_json(data, pretty: false)
36
+ json_data = data.transform_values do |entry|
37
+ entry.transform_values { |v| v.is_a?(Date) ? v.to_s : v }
38
+ end
39
+
40
+ if pretty
41
+ puts JSON.pretty_generate(json_data)
42
+ else
43
+ puts JSON.generate(json_data)
44
+ end
45
+ end
46
+
47
+ options = {}
48
+ global_opts = OptionParser.new do |opts|
49
+ opts.banner = "Usage: changelog-parser <command> [options] [file_or_directory]"
16
50
  opts.separator ""
17
- opts.separator "Parse changelog files and extract version information."
51
+ opts.separator "Commands:"
52
+ opts.separator " parse Parse changelog and output JSON (default)"
53
+ opts.separator " list List version numbers only"
54
+ opts.separator " show Show content for a specific version"
55
+ opts.separator " between Show content between two versions"
56
+ opts.separator " validate Validate changelog against Keep a Changelog format"
18
57
  opts.separator ""
19
58
  opts.separator "Options:"
20
59
 
@@ -23,18 +62,6 @@ parser = OptionParser.new do |opts|
23
62
  options[:format] = f
24
63
  end
25
64
 
26
- opts.on("-v", "--version VERSION", "Show only a specific version") do |v|
27
- options[:version] = v
28
- end
29
-
30
- opts.on("-l", "--list", "List versions only") do
31
- options[:output] = :list
32
- end
33
-
34
- opts.on("-c", "--content", "Output content only (requires --version)") do
35
- options[:output] = :content
36
- end
37
-
38
65
  opts.on("-p", "--pattern REGEX", "Custom version header regex pattern") do |p|
39
66
  options[:pattern] = Regexp.new(p)
40
67
  end
@@ -47,86 +74,161 @@ parser = OptionParser.new do |opts|
47
74
  options[:pretty] = true
48
75
  end
49
76
 
50
- opts.on_tail("-h", "--help", "Show this help message") do
77
+ opts.on("-h", "--help", "Show this help message") do
51
78
  puts opts
79
+ puts ""
80
+ puts "Examples:"
81
+ puts " changelog-parser parse CHANGELOG.md"
82
+ puts " changelog-parser list ."
83
+ puts " changelog-parser show 1.0.0 CHANGELOG.md"
84
+ puts " changelog-parser between 1.0.0 2.0.0 CHANGELOG.md"
85
+ puts " cat CHANGELOG.md | changelog-parser parse -"
52
86
  exit
53
87
  end
54
88
 
55
- opts.on_tail("--gem-version", "Show gem version") do
89
+ opts.on("--version", "Show gem version") do
56
90
  puts Changelog::Parser::VERSION
57
91
  exit
58
92
  end
59
93
  end
60
94
 
61
95
  begin
62
- parser.parse!
63
- rescue OptionParser::InvalidArgument, OptionParser::InvalidOption => e
96
+ global_opts.parse!
97
+ rescue OptionParser::InvalidOption => e
64
98
  warn "Error: #{e.message}"
65
- warn parser
99
+ warn global_opts
66
100
  exit 1
67
101
  end
68
102
 
69
- input = if ARGV.empty? || ARGV.first == "-"
70
- $stdin.read
71
- else
72
- file = ARGV.first
73
- unless File.exist?(file)
74
- warn "Error: File not found: #{file}"
103
+ command = ARGV.shift || "parse"
104
+
105
+ case command
106
+ when "parse"
107
+ path = ARGV.shift
108
+ content = load_changelog(path, options)
109
+ parser = build_parser(content, options)
110
+ output_json(parser.parse, pretty: options[:pretty])
111
+
112
+ when "list"
113
+ path = ARGV.shift
114
+ content = load_changelog(path, options)
115
+ parser = build_parser(content, options)
116
+ puts parser.versions.join("\n")
117
+
118
+ when "show"
119
+ version = ARGV.shift
120
+ path = ARGV.shift
121
+
122
+ # Check if version looks like a file path (missing version argument)
123
+ if version && File.exist?(version) && !path
124
+ warn "Error: show requires a version argument"
125
+ warn "Usage: changelog-parser show <version> [file_or_directory]"
75
126
  exit 1
76
127
  end
77
- File.read(file)
78
- end
79
128
 
80
- if input.nil? || input.empty?
81
- warn "Error: No input provided"
82
- warn parser
83
- exit 1
84
- end
129
+ unless version
130
+ warn "Error: show requires a version argument"
131
+ warn "Usage: changelog-parser show <version> [file_or_directory]"
132
+ exit 1
133
+ end
85
134
 
86
- parsed = Changelog::Parser.new(
87
- input,
88
- format: options[:format],
89
- version_pattern: options[:pattern],
90
- match_group: options[:match_group] || 1
91
- )
92
-
93
- result = parsed.parse
94
-
95
- case options[:output]
96
- when :list
97
- puts result.keys.join("\n")
98
- when :content
99
- unless options[:version]
100
- warn "Error: --content requires --version"
135
+ content = load_changelog(path, options)
136
+ parser = build_parser(content, options)
137
+ entry = parser[version]
138
+
139
+ unless entry
140
+ warn "Error: Version not found: #{version}"
141
+ exit 1
142
+ end
143
+
144
+ puts entry[:content]
145
+
146
+ when "between"
147
+ old_version = ARGV.shift
148
+ new_version = ARGV.shift
149
+ path = ARGV.shift
150
+
151
+ # Check if new_version looks like a file path (missing second version)
152
+ if new_version && File.exist?(new_version) && !path
153
+ warn "Error: between requires two version arguments"
154
+ warn "Usage: changelog-parser between <old_version> <new_version> [file_or_directory]"
155
+ exit 1
156
+ end
157
+
158
+ unless old_version && new_version
159
+ warn "Error: between requires two version arguments"
160
+ warn "Usage: changelog-parser between <old_version> <new_version> [file_or_directory]"
101
161
  exit 1
102
162
  end
103
- entry = result[options[:version]]
104
- if entry
105
- puts entry[:content]
163
+
164
+ content = load_changelog(path, options)
165
+ parser = build_parser(content, options)
166
+ result = parser.between(old_version, new_version)
167
+
168
+ if result
169
+ puts result
106
170
  else
107
- warn "Version not found: #{options[:version]}"
171
+ warn "Error: Could not find versions in changelog"
108
172
  exit 1
109
173
  end
110
- when :json
111
- output = if options[:version]
112
- entry = result[options[:version]]
113
- unless entry
114
- warn "Version not found: #{options[:version]}"
115
- exit 1
174
+
175
+ when "validate"
176
+ path = ARGV.shift
177
+ content = load_changelog(path, options)
178
+ parser = build_parser(content, options)
179
+ result = parser.parse
180
+ errors = []
181
+ warnings = []
182
+
183
+ # Check if any versions were found
184
+ if result.empty?
185
+ errors << "No versions found in changelog"
186
+ end
187
+
188
+ # Check for Unreleased section
189
+ unless result.key?("Unreleased")
190
+ warnings << "No [Unreleased] section found"
191
+ end
192
+
193
+ # Check for empty versions (Unreleased can be empty)
194
+ result.each do |version, entry|
195
+ next if version == "Unreleased"
196
+ if entry[:content].nil? || entry[:content].strip.empty?
197
+ warnings << "Version #{version} has no content"
116
198
  end
117
- { options[:version] => entry }
118
- else
119
- result
120
199
  end
121
200
 
122
- # Convert dates to strings for JSON
123
- json_output = output.transform_values do |entry|
124
- entry.transform_values { |v| v.is_a?(Date) ? v.to_s : v }
201
+ # Check date ordering (should be descending)
202
+ dated_versions = result.reject { |_, e| e[:date].nil? }
203
+ dates = dated_versions.values.map { |e| e[:date] }
204
+ unless dates == dates.sort.reverse
205
+ warnings << "Dates are not in descending order"
125
206
  end
126
207
 
127
- if options[:pretty]
128
- puts JSON.pretty_generate(json_output)
129
- else
130
- puts JSON.generate(json_output)
208
+ # Check for missing dates
209
+ result.each do |version, entry|
210
+ next if version == "Unreleased"
211
+ if entry[:date].nil?
212
+ warnings << "Version #{version} has no date"
213
+ end
131
214
  end
215
+
216
+ # Output results
217
+ if errors.empty? && warnings.empty?
218
+ puts "Valid changelog with #{result.size} version(s)"
219
+ exit 0
220
+ end
221
+
222
+ errors.each { |e| warn "Error: #{e}" }
223
+ warnings.each { |w| warn "Warning: #{w}" }
224
+
225
+ exit 1 if errors.any?
226
+ exit 0
227
+
228
+ else
229
+ # Treat unknown command as a path (backwards compatibility)
230
+ path = command
231
+ content = load_changelog(path, options)
232
+ parser = build_parser(content, options)
233
+ output_json(parser.parse, pretty: options[:pretty])
132
234
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Changelog
4
4
  class Parser
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -22,6 +22,17 @@ module Changelog
22
22
  underline: UNDERLINE_HEADER
23
23
  }.freeze
24
24
 
25
+ # Common changelog filenames in priority order (from Dependabot)
26
+ CHANGELOG_FILENAMES = %w[
27
+ changelog
28
+ news
29
+ changes
30
+ history
31
+ release
32
+ whatsnew
33
+ releases
34
+ ].freeze
35
+
25
36
  attr_reader :changelog, :version_pattern, :match_group
26
37
 
27
38
  def initialize(changelog, format: nil, version_pattern: nil, match_group: 1)
@@ -91,6 +102,46 @@ module Changelog
91
102
  end
92
103
  end
93
104
 
105
+ def between(old_version, new_version)
106
+ old_line = line_for_version(old_version)
107
+ new_line = line_for_version(new_version)
108
+ lines = changelog.split("\n")
109
+
110
+ range = if old_line && new_line
111
+ old_line < new_line ? (old_line..-1) : (new_line..old_line - 1)
112
+ elsif old_line
113
+ old_line.zero? ? nil : (0..old_line - 1)
114
+ elsif new_line
115
+ (new_line..-1)
116
+ end
117
+
118
+ return nil unless range
119
+
120
+ lines[range]&.join("\n")&.rstrip
121
+ end
122
+
123
+ def line_for_version(version)
124
+ return nil unless version
125
+
126
+ version = version.to_s.gsub(/^v/i, "")
127
+ escaped = Regexp.escape(version)
128
+ lines = changelog.split("\n")
129
+
130
+ lines.find_index.with_index do |line, index|
131
+ next false unless line.match?(/(?<!\.)#{escaped}(?![.\-\w])/)
132
+ next false if line.match?(/#{escaped}\.\./)
133
+
134
+ next true if line.start_with?("#", "!", "==")
135
+ next true if line.match?(/^v?#{escaped}:?\s/)
136
+ next true if line.match?(/^\[#{escaped}\]/)
137
+ next true if line.match?(/^[\+\*\-]\s+(version\s+)?#{escaped}/i)
138
+ next true if line.match?(/^\d{4}-\d{2}-\d{2}/)
139
+ next true if lines[index + 1]&.match?(/^[=\-\+]{3,}\s*$/)
140
+
141
+ false
142
+ end
143
+ end
144
+
94
145
  def self.parse(changelog, **options)
95
146
  new(changelog, **options).parse
96
147
  end
@@ -100,6 +151,35 @@ module Changelog
100
151
  new(content, **options).parse
101
152
  end
102
153
 
154
+ def self.find_changelog(directory = ".")
155
+ files = Dir.entries(directory).select { |f| File.file?(File.join(directory, f)) }
156
+
157
+ CHANGELOG_FILENAMES.each do |name|
158
+ pattern = /\A#{name}(\.(md|txt|rst|rdoc|markdown))?\z/i
159
+ candidates = files.select { |f| f.match?(pattern) }
160
+ candidates = candidates.reject { |f| f.end_with?(".sh") }
161
+
162
+ return File.join(directory, candidates.first) if candidates.one?
163
+
164
+ candidates.each do |candidate|
165
+ path = File.join(directory, candidate)
166
+ size = File.size(path)
167
+ next if size > 1_000_000 || size < 100
168
+
169
+ return path
170
+ end
171
+ end
172
+
173
+ nil
174
+ end
175
+
176
+ def self.find_and_parse(directory = ".", **options)
177
+ path = find_changelog(directory)
178
+ return nil unless path
179
+
180
+ parse_file(path, **options)
181
+ end
182
+
103
183
  def resolve_pattern(format, custom_pattern)
104
184
  return custom_pattern if custom_pattern
105
185
  return FORMATS.fetch(format) if format
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: changelog-parser
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Nesbitt
@@ -10,7 +10,7 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: A Ruby gem for parsing changelog files. Supports Keep a Changelog format,
13
- markdown headers, and custom patterns. Zero runtime dependencies.
13
+ markdown headers, and custom patterns.
14
14
  email:
15
15
  - andrewnez@gmail.com
16
16
  executables: