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.("../../CHANGELOG.md", __dir__)
- OTHER_HEADER =
"**Other:**"- RELEASE_IMAGE =
""- 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
- .build_release_section(version, compare_from, grouped) ⇒ Object
- .clean_title(title) ⇒ Object
- .ensure_github_auth! ⇒ Object
- .fetch_merged_pulls(since_date) ⇒ Object
- .format_pr_line(pull_request) ⇒ Object
- .git_root ⇒ Object
- .github_get(uri) ⇒ Object
- .github_token ⇒ Object
- .resolve_compare_tag(version, previous_version) ⇒ Object
- .run!(changelog_path: CHANGELOG_PATH) ⇒ Object
- .sanitize_iso_date(value) ⇒ Object
- .tag_date(version) ⇒ Object
- .titleize(sentence) ⇒ Object
Class Method Details
.build_release_section(version, compare_from, grouped) ⇒ Object
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 |
# File 'lib/playbook/changelog_generator.rb', line 168 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
203 204 205 206 |
# File 'lib/playbook/changelog_generator.rb', line 203 def clean_title(title) cleaned = title.gsub(TICKET_REGEX, "").gsub(EMOJI_REGEX, "").strip.gsub(/\s+/, " ") titleize(cleaned) end |
.ensure_github_auth! ⇒ Object
67 68 69 70 71 72 73 74 |
# File 'lib/playbook/changelog_generator.rb', line 67 def ensure_github_auth! return if github_token abort <<~MSG GitHub authentication required. Set CHANGELOG_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN MSG end |
.fetch_merged_pulls(since_date) ⇒ Object
126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/playbook/changelog_generator.rb', line 126 def fetch_merged_pulls(since_date) safe_date = sanitize_iso_date(since_date) 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"] }.map do |item| { "number" => item["number"], "title" => item["title"], "user" => { "login" => item.dig("user", "login") }, "labels" => Array(item["labels"]), } end end |
.format_pr_line(pull_request) ⇒ Object
196 197 198 199 200 201 |
# File 'lib/playbook/changelog_generator.rb', line 196 def format_pr_line(pull_request) number = pull_request["number"] = pull_request.dig("user", "login") || "unknown" title = clean_title(pull_request["title"].to_s) "- #{title} [\\##{number}](https://github.com/#{REPO}/pull/#{number}) ([#{}](https://github.com/#{}))" end |
.git_root ⇒ Object
119 120 121 122 123 124 |
# File 'lib/playbook/changelog_generator.rb', line 119 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
154 155 156 157 158 159 160 161 162 163 164 165 166 |
# File 'lib/playbook/changelog_generator.rb', line 154 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_token ⇒ Object
76 77 78 79 80 81 82 |
# File 'lib/playbook/changelog_generator.rb', line 76 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
103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 |
# File 'lib/playbook/changelog_generator.rb', line 103 def resolve_compare_tag(version, previous_version) = `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 = .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 = .find { |tag| previous_candidates.include?(tag) } return previous_tag if previous_tag previous_version end |
.run!(changelog_path: CHANGELOG_PATH) ⇒ Object
40 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 |
# File 'lib/playbook/changelog_generator.rb', line 40 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_date = tag_date(previous_version) compare_from = resolve_compare_tag(version, previous_version) pulls = fetch_merged_pulls(since_date) 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, image, and description placeholders before publishing." end |
.sanitize_iso_date(value) ⇒ Object
96 97 98 99 100 101 |
# File 'lib/playbook/changelog_generator.rb', line 96 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_date(version) ⇒ Object
84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/playbook/changelog_generator.rb', line 84 def tag_date(version) [version, "v#{version}"].each do |tag| date = `git -C #{Shellwords.escape(git_root)} log -1 --format=%cI #{Shellwords.escape(tag)} 2>/dev/null`.strip next if date.empty? return sanitize_iso_date(Date.parse(date).iso8601) end warn "Could not resolve date for tag #{version}; using 30 days ago." sanitize_iso_date((Date.today - 30).iso8601) end |
.titleize(sentence) ⇒ Object
208 209 210 211 212 |
# File 'lib/playbook/changelog_generator.rb', line 208 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 |