Module: Playbook::ChangelogGenerator

Defined in:
lib/playbook/changelog_generator.rb

Overview

Builds a website-ready release section from labeled GitHub PRs and prepends it to CHANGELOG.md.

Constant Summary collapse

REPO =
"powerhome/playbook"
MAX_PER_SECTION =
25
CHANGELOG_PATH =
File.expand_path("../../CHANGELOG.md", __dir__)
OTHER_HEADER =
"**Other:**"
RELEASE_IMAGE =
"![release_image](https://github.com/user-attachments/assets/db119637-25e9-4157-9091-c5f7fdf034fc)"
SECTIONS =
[
  { header: "**New Kits:**", labels: ["new kit"] },
  { header: "**Kit Enhancements:**", labels: ["enhancement"] },
  { header: "**Improvements:**", labels: ["improvement"] },
  { header: "**Fixed Bugs:**", labels: ["bug"] },
  { header: "**Breaking Changes:**", labels: ["breaking"] },
  { header: OTHER_HEADER, labels: [] },
].freeze
LABEL_TO_SECTION =
SECTIONS.each_with_object({}) do |section, map|
  section[:labels].each { |label| map[label.downcase] = section[:header] }
end.freeze
EMOJI_REGEX =
/[\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/
TICKET_REGEX =
/\\\[\w+-\d+\\\]\s?|\[\w+-\d+\]\s?/
LOWERCASE_WORDS =
%w[a an the and but or for nor on at to from by].freeze

Class Method Summary collapse

Class Method Details

.build_release_section(version, compare_from, grouped) ⇒ Object



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/playbook/changelog_generator.rb', line 183

def build_release_section(version, compare_from, grouped)
  lines = []
  lines << "# Awesome Release Title Here!"
  lines << ""
  lines << "##### #{Date.today.strftime('%B %d, %Y')}"
  lines << ""
  lines << RELEASE_IMAGE
  lines << ""
  lines << "Your feature description goes here."
  lines << ""
  lines << "[#{version}](https://github.com/#{REPO}/tree/#{version}) full list of changes:"
  lines << ""

  SECTIONS.each do |section|
    prs = grouped[section[:header]]
    next if prs.nil? || prs.empty?

    lines << section[:header]
    lines << ""
    prs.each { |pull_request| lines << format_pr_line(pull_request) }
    lines << ""
  end

  lines << "[Full Changelog](https://github.com/#{REPO}/compare/#{compare_from}...#{version})"
  lines << ""
  lines.join("\n")
end

.clean_title(title) ⇒ Object



218
219
220
221
# File 'lib/playbook/changelog_generator.rb', line 218

def clean_title(title)
  cleaned = title.gsub(TICKET_REGEX, "").gsub(EMOJI_REGEX, "").strip.gsub(/\s+/, " ")
  titleize(cleaned)
end

.ensure_github_auth!Object



69
70
71
72
73
74
75
76
# File 'lib/playbook/changelog_generator.rb', line 69

def ensure_github_auth!
  return if github_token

  abort <<~MSG
    GitHub authentication required.
    Set CHANGELOG_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN
  MSG
end

.existing_pr_numbers(changelog_content) ⇒ Object



105
106
107
# File 'lib/playbook/changelog_generator.rb', line 105

def existing_pr_numbers(changelog_content)
  changelog_content.scan(/\[\\?#(\d+)\]/).flatten.map(&:to_i).to_set
end

.fetch_merged_pulls(since_time, already_listed) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/playbook/changelog_generator.rb', line 132

def fetch_merged_pulls(since_time, already_listed)
  safe_date = sanitize_iso_date(since_time.utc.to_date.iso8601)
  query = URI.encode_www_form_component("repo:#{REPO} is:pr is:merged merged:>=#{safe_date}")
  items = []
  page = 1

  loop do
    uri = URI("https://api.github.com/search/issues?q=#{query}&per_page=100&page=#{page}&sort=created&order=desc")
    abort "Unexpected GitHub API host: #{uri.host}" unless uri.host == "api.github.com"

    body = JSON.parse(github_get(uri))
    batch = Array(body["items"])
    items.concat(batch)
    break if batch.size < 100 || page >= 10

    page += 1
  end

  items.select { |item| item["pull_request"] }.filter_map do |item|
    number = item["number"].to_i
    next if already_listed.include?(number)

    closed_at = item["closed_at"].to_s
    next if closed_at.empty?

    merged_at = Time.iso8601(closed_at)
    next if merged_at <= since_time

    {
      "number" => number,
      "title" => item["title"],
      "user" => { "login" => item.dig("user", "login") },
      "labels" => Array(item["labels"]),
    }
  end
end

.format_pr_line(pull_request) ⇒ Object



211
212
213
214
215
216
# File 'lib/playbook/changelog_generator.rb', line 211

def format_pr_line(pull_request)
  number = pull_request["number"]
  author = pull_request.dig("user", "login") || "unknown"
  title = clean_title(pull_request["title"].to_s)
  "- #{title} [\\##{number}](https://github.com/#{REPO}/pull/#{number}) ([#{author}](https://github.com/#{author}))"
end

.git_rootObject



125
126
127
128
129
130
# File 'lib/playbook/changelog_generator.rb', line 125

def git_root
  @git_root ||= begin
    root = `git -C #{Shellwords.escape(File.dirname(CHANGELOG_PATH))} rev-parse --show-toplevel 2>/dev/null`.strip
    root.empty? ? File.dirname(CHANGELOG_PATH) : root
  end
end

.github_get(uri) ⇒ Object



169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/playbook/changelog_generator.rb', line 169

def github_get(uri)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  request = Net::HTTP::Get.new(uri)
  request["Accept"] = "application/vnd.github+json"
  request["User-Agent"] = "playbook-changelog"
  request["Authorization"] = "Bearer #{github_token}"

  response = http.request(request)
  abort "GitHub API error #{response.code}" unless response.is_a?(Net::HTTPSuccess)

  response.body
end

.github_tokenObject



78
79
80
81
82
83
84
# File 'lib/playbook/changelog_generator.rb', line 78

def github_token
  %w[CHANGELOG_GITHUB_TOKEN GH_TOKEN GITHUB_TOKEN].each do |key|
    value = ENV[key].to_s.strip
    return value unless value.empty?
  end
  nil
end

.resolve_compare_tag(version, previous_version) ⇒ Object



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/playbook/changelog_generator.rb', line 109

def resolve_compare_tag(version, previous_version)
  tags = `git -C #{Shellwords.escape(git_root)} tag --sort=-creatordate`.split("\n").map(&:strip).reject(&:empty?)
  rc_pattern = /\Av?#{Regexp.escape(version)}-rc\.\d+\z/

  # Prefer the newest RC tag for this VERSION (e.g. v16.10.0-rc.3...16.10.0).
  newest_rc = tags.find { |tag| tag.match?(rc_pattern) }
  return newest_rc if newest_rc

  # Otherwise use the previous release tag if present.
  previous_candidates = [previous_version, "v#{previous_version}"]
  previous_tag = tags.find { |tag| previous_candidates.include?(tag) }
  return previous_tag if previous_tag

  previous_version
end

.run!(changelog_path: CHANGELOG_PATH) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/playbook/changelog_generator.rb', line 41

def run!(changelog_path: CHANGELOG_PATH)
  version = Playbook::VERSION
  previous_version = Playbook::PREVIOUS_VERSION
  existing = File.read(changelog_path)

  version_link = %r{\[#{Regexp.escape(version)}\]\(https://github\.com/powerhome/playbook/tree/#{Regexp.escape(version)}\)}
  abort "CHANGELOG.md already has a section for #{version}. Aborting." if existing.match?(version_link)

  ensure_github_auth!
  since_time = tag_time(previous_version)
  compare_from = resolve_compare_tag(version, previous_version)
  already_listed = existing_pr_numbers(existing)
  pulls = fetch_merged_pulls(since_time, already_listed)

  grouped = Hash.new { |hash, key| hash[key] = [] }

  pulls.each do |pull_request|
    labels = Array(pull_request["labels"]).map { |label| (label.is_a?(Hash) ? label["name"] : label).to_s.downcase }
    header = labels.map { |label| LABEL_TO_SECTION[label] }.compact.first || OTHER_HEADER
    grouped[header] << pull_request if grouped[header].size < MAX_PER_SECTION
  end

  section = build_release_section(version, compare_from, grouped)
  File.write(changelog_path, "#{section}\n\n#{existing.lstrip}")
  puts "Prepended #{version} release section to CHANGELOG.md"
  puts "Edit the title and description placeholders before publishing."
end

.sanitize_iso_date(value) ⇒ Object



98
99
100
101
102
103
# File 'lib/playbook/changelog_generator.rb', line 98

def sanitize_iso_date(value)
  date = value.to_s.strip
  abort "Invalid date for GitHub search: #{date.inspect}" unless date.match?(/\A\d{4}-\d{2}-\d{2}\z/)

  date
end

.tag_time(version) ⇒ Object



86
87
88
89
90
91
92
93
94
95
96
# File 'lib/playbook/changelog_generator.rb', line 86

def tag_time(version)
  [version, "v#{version}"].each do |tag|
    timestamp = `git -C #{Shellwords.escape(git_root)} log -1 --format=%cI #{Shellwords.escape(tag)} 2>/dev/null`.strip
    next if timestamp.empty?

    return Time.iso8601(timestamp)
  end

  warn "Could not resolve time for tag #{version}; using 30 days ago."
  Time.now - (30 * 24 * 60 * 60)
end

.titleize(sentence) ⇒ Object



223
224
225
226
227
# File 'lib/playbook/changelog_generator.rb', line 223

def titleize(sentence)
  sentence.split.each_with_index.map do |word, index|
    LOWERCASE_WORDS.include?(word.downcase) && index.positive? ? word : word.capitalize
  end.join(" ")
end