puppetlabs/facter

View on GitHub
scripts/generate_changelog.rb

Summary

Maintainability
A
2 hrs
Test Coverage
#!/usr/bin/env ruby
# frozen_string_literal: true

require 'octokit'

class ChangelogGenerator
  attr_reader :version, :entries, :unlabeled_prs

  def initialize(version)
    unless version
      warn 'Usage: generate_changelog.rb VERSION'
      exit 1
    end

    @version = version
    @entries = {
      'feature' => { name: 'Added', entries: {} },
      'bugfix' => { name: 'Fixed', entries: {} },
      'backwards-incompatible' => { name: 'Changed', entries: {} }
    }

    @unlabeled_prs = []

    # Setting the changelog path early lets us check that it exists
    # before we spend time making API calls
    changelog
  end

  def labels
    @entries.keys
  end

  def client
    unless @client
      unless ENV['GITHUB_TOKEN']
        warn 'Missing GitHub personal access token. Set $GITHUB_TOKEN with a '\
             'personal access token to use this script.'
        exit 1
      end

      Octokit.configure do |c|
        c.auto_paginate = true
      end

      @client = Octokit::Client.new(access_token: ENV['GITHUB_TOKEN'])
    end

    @client
  end

  def latest
    @latest ||= client.latest_release('puppetlabs/facter').tag_name
  end

  def commits
    @commits ||= client.compare('puppetlabs/facter', latest, 'main').commits
  end

  def changelog
    unless @changelog
      @changelog = File.expand_path('CHANGELOG.md', Dir.pwd)

      unless File.file?(@changelog)
        warn "Unable to find changelog at #{@changelog}"
        exit 1
      end
    end

    @changelog
  end

  # Parses individual commits by scanning the commit message for valid release notes
  # and adding them to the list of entries. Entries include extra information about
  # the author and whether it was an internal or external contribution so we can give
  # kudos.
  def parse_commit(commit)
    prs = client.commit_pulls('puppetlabs/facter', commit.sha, { accept: 'application/vnd.github.groot-preview+json' })

    prs.each do |pr|
      next if pr[:state] != 'closed' && pr[:merged_at].nil?

      if (pr[:labels].nil? || pr[:labels].empty?) && !unlabeled_prs.include?(pr[:html_url])
        unlabeled_prs << pr[:html_url]
      end

      pr[:labels].each do |label|
        next unless entries.key?(label[:name])

        entries[label[:name]][:entries][pr[:html_url]] = {
          title: pr[:title],
          number: pr[:number],
          url: pr[:html_url],
          author: pr[:user][:login],
          profile: pr[:user][:html_url]
        }
      end
    end
  end

  def update_changelog
    old_lines = File.read(changelog).split("\n")

    new_lines = [
      "## [#{version}](https://github.com/puppetlabs/facter/tree/#{version}) (#{Time.now.strftime '%Y-%m-%d'})\n",
      "[Full Changelog](https://github.com/puppetlabs/facter/compare/#{latest}...#{version})"
    ]

    entries.each_value do |type|
      next unless type[:entries].any?

      new_lines << "\n### #{type[:name]}\n"

      type[:entries].each_value do |entry|
        new_lines << "- #{entry[:title].strip} [\##{entry[:number]}](#{entry[:url]})" \
                     " ([#{entry[:author]}](#{entry[:profile]}))"
      end
    end

    new_lines = check_unlabeled_prs(new_lines)

    content = (new_lines + ["\n"] + old_lines).join("\n")

    if File.write(changelog, content)
      puts "Successfully wrote entries to #{changelog}"
    else
      warn "Unable to write entries to #{changelog}"
      exit 1
    end
  end

  def check_unlabeled_prs(content)
    return content unless unlabeled_prs.any?

    content << "\n### Unlabeled PRs:\n"
    unlabeled_prs.each do |pr|
      content << "- #{pr}"
    end

    content
  end

  def generate
    puts "Loading and parsing commits for #{latest}..main"

    commits.each do |commit|
      parse_commit(commit)
    end

    if entries.each_value.all? { |type| type[:entries].empty? }
      warn "No release notes for #{latest}..main"
      exit 0
    end

    update_changelog
  end
end

ChangelogGenerator.new(ARGV.first).generate if $PROGRAM_NAME == __FILE__