From 587557e1acb0eb6743d18d113687f996d4ba9e25 Mon Sep 17 00:00:00 2001 From: Eric Putnam Date: Mon, 6 Nov 2017 21:19:49 -0800 Subject: [PATCH] Refactor generation code and allow custom sections There's a lot in this PR. - Added a Section class to more easily make the other changes and hopefully add flexibility for the future - Added an option called `configure_sections` that allows you create your own custom sections. It blows away all other sections and uses only the ones you give it. - Added an option called `add_sections` that allows you to add_sections to the default section set - Added an option called `include_merged` that can be used when configure_sections is defined. Configure sections blows away any and all default sections so to get this one back, you have to set this option. - Added tests for this stuff @HAIL9000 was a co-author. Because of a little git snafu, I accidentally squashed all of our work into one so it looks like it was just me. --- Refactor details: Before this change, the code in generator.rb and generator_generation.rb was conflated and method call flow went back and forth between the two files seemingly randomly. They also both defined the exact same class, which is un-ruby-ish. I tried to separate methods used for the whole changelog generation from methods used for specific parts of the changelog and move them into specific classes. I reasoned that a changelog is a series of "entries" of all tagged releases plus an extra entry for the unreleased entry. Each entry is comprised of a header and a series of "sections" for that entry. Each section is comprized of a list of issues and/or pull requests for that entry. So the log contains entries, entries contain sections, and sections contain issues & prs. I have structured the classes around this idea. - lib/github_changelog_generator/generator/generator.rb is for code related to generating the entire changelog. - lib/github_changelog_generator/generator/entry.rb is for code related to generating entries. - lib/github_changelog_generator/generator/section.rb is for code relating to geneating entry sections. Issues and PRs are already special objects, so it doesn't make sense to break those out into their own class. --- .../generator/entry.rb | 237 ++++++++++++ .../generator/generator.rb | 253 ++++++------ .../generator/generator_generation.rb | 181 --------- .../generator/section.rb | 83 ++++ lib/github_changelog_generator/options.rb | 12 + lib/github_changelog_generator/parser.rb | 8 + lib/github_changelog_generator/task.rb | 3 +- man/git-generate-changelog.1 | 14 +- man/git-generate-changelog.1.html | 18 +- man/git-generate-changelog.html | 12 + man/git-generate-changelog.md | 16 +- spec/unit/generator/entry_spec.rb | 363 ++++++++++++++++++ .../generator/generator_generation_spec.rb | 73 ---- 13 files changed, 866 insertions(+), 407 deletions(-) create mode 100644 lib/github_changelog_generator/generator/entry.rb delete mode 100644 lib/github_changelog_generator/generator/generator_generation.rb create mode 100644 lib/github_changelog_generator/generator/section.rb create mode 100644 spec/unit/generator/entry_spec.rb delete mode 100644 spec/unit/generator/generator_generation_spec.rb diff --git a/lib/github_changelog_generator/generator/entry.rb b/lib/github_changelog_generator/generator/entry.rb new file mode 100644 index 0000000..cbc3e06 --- /dev/null +++ b/lib/github_changelog_generator/generator/entry.rb @@ -0,0 +1,237 @@ +require "github_changelog_generator/generator/section" + +module GitHubChangelogGenerator + # This class generates the content for a single changelog entry. An entry is + # generally either for a specific tagged release or the collection of + # unreleased changes. + # + # An entry is comprised of header text followed by a series of sections + # relating to the entry. + # + # @see GitHubChangelogGenerator::Generator + # @see GitHubChangelogGenerator::Section + class Entry + attr_reader :content + + def initialize(options = Options.new({})) + @content = "" + @options = Options.new(options) + end + + # Generates log entry with header and body + # + # @param [Array] pull_requests List or PR's in new section + # @param [Array] issues List of issues in new section + # @param [String] newer_tag_name Name of the newer tag. Could be nil for `Unreleased` section. + # @param [String] newer_tag_link Name of the newer tag. Could be "HEAD" for `Unreleased` section. + # @param [Time] newer_tag_time Time of the newer tag + # @param [Hash, nil] older_tag Older tag, used for the links. Could be nil for last tag. + # @return [String] Ready and parsed section + def create_entry_for_tag(pull_requests, issues, newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name) # rubocop:disable Metrics/ParameterLists + github_site = @options[:github_site] || "https://github.com" + project_url = "#{github_site}/#{@options[:user]}/#{@options[:project]}" + + set_sections_and_maps + + @content = generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name, project_url) + + @content += generate_body(pull_requests, issues) + + @content + end + + private + + # Creates section objects and the label and section maps needed for + # sorting + def set_sections_and_maps + @sections = if @options.configure_sections? + parse_sections(@options[:configure_sections]) + elsif @options.add_sections? + default_sections.concat parse_sections(@options[:add_sections]) + else + default_sections + end + + @lmap = label_map + @smap = section_map + end + + # Turns a string from the commandline into an array of Section objects + # + # @param [String, Hash] either string or hash describing sections + # @return [Array] array of Section objects + def parse_sections(sections_desc) + require "json" + + sections_desc = sections_desc.to_json if sections_desc.class == Hash + + begin + sections_json = JSON.parse(sections_desc) + rescue JSON::ParserError => e + raise "There was a problem parsing your JSON string for sections: #{e}" + end + + sections_json.collect do |name, v| + Section.new(name: name.to_s, prefix: v["prefix"], labels: v["labels"], options: @options) + end + end + + # Creates a hash map of labels => section objects + # + # @return [Hash] map of labels => section objects + def label_map + @sections.each_with_object({}) do |section_obj, memo| + section_obj.labels.each do |label| + memo[label] = section_obj.name + end + end + end + + # Creates a hash map of 'section name' => section object + # + # @return [Hash] map of 'section name' => section object + def section_map + @sections.each_with_object({}) do |section, memo| + memo[section.name] = section + end + end + + # It generates header text for an entry with specific parameters. + # + # @param [String] newer_tag_name - name of newer tag + # @param [String] newer_tag_link - used for links. Could be same as #newer_tag_name or some specific value, like HEAD + # @param [Time] newer_tag_time - time, when newer tag created + # @param [String] older_tag_name - tag name, used for links. + # @param [String] project_url - url for current project. + # @return [String] - Header text for a changelog entry. + def generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name, project_url) + header = "" + + # Generate date string: + time_string = newer_tag_time.strftime(@options[:date_format]) + + # Generate tag name and link + release_url = if @options[:release_url] + format(@options[:release_url], newer_tag_link) + else + "#{project_url}/tree/#{newer_tag_link}" + end + header += if newer_tag_name.equal?(@options[:unreleased_label]) + "## [#{newer_tag_name}](#{release_url})\n\n" + else + "## [#{newer_tag_name}](#{release_url}) (#{time_string})\n\n" + end + + if @options[:compare_link] && older_tag_name + # Generate compare link + header += "[Full Changelog](#{project_url}/compare/#{older_tag_name}...#{newer_tag_link})\n\n" + end + + header + end + + # Generates complete body text for a tag (without a header) + # + # @param [Array] pull_requests + # @param [Array] issues + # @returns [String] ready-to-go tag body + def generate_body(pull_requests, issues) + body = "" + body += main_sections_to_log(pull_requests, issues) + body += merged_section_to_log(pull_requests) if @options[:pulls] && @options[:add_pr_wo_labels] + body + end + + # Generates main sections for a tag + # + # @param [Array] pull_requests + # @param [Array] issues + # @return [string] ready-to-go sub-sections + def main_sections_to_log(pull_requests, issues) + if @options[:issues] + sections_to_log = parse_by_sections(pull_requests, issues) + + sections_to_log.map(&:generate_content).join + end + end + + # Generates section for prs with no labels (for a tag) + # + # @param [Array] pull_requests + # @return [string] ready-to-go sub-section + def merged_section_to_log(pull_requests) + merged = Section.new(name: "merged", prefix: @options[:merge_prefix], labels: [], issues: pull_requests, options: @options) + @sections << merged unless @sections.find { |section| section.name == "merged" } + merged.generate_content + end + + # Set of default sections for backwards-compatibility/defaults + # + # @return [Array] array of Section objects + def default_sections + [ + Section.new(name: "breaking", prefix: @options[:breaking_prefix], labels: @options[:breaking_labels], options: @options), + Section.new(name: "enhancements", prefix: @options[:enhancement_prefix], labels: @options[:enhancement_labels], options: @options), + Section.new(name: "bugs", prefix: @options[:bug_prefix], labels: @options[:bug_labels], options: @options), + Section.new(name: "issues", prefix: @options[:issue_prefix], labels: @options[:issue_labels], options: @options) + ] + end + + # This method sorts issues by types + # (bugs, features, or just closed issues) by labels + # + # @param [Array] pull_requests + # @param [Array] issues + # @return [Hash] Mapping of filtered arrays: (Bugs, Enhancements, Breaking stuff, Issues) + def parse_by_sections(pull_requests, issues) + issues.each do |dict| + added = false + + dict["labels"].each do |label| + break if @lmap[label["name"]].nil? + @smap[@lmap[label["name"]]].issues << dict + added = true + + break if added + end + if @smap["issues"] + @sections.find { |sect| sect.name == "issues" }.issues << dict unless added + end + end + sort_pull_requests(pull_requests) + end + + # This method iterates through PRs and sorts them into sections + # + # @param [Array] pull_requests + # @param [Hash] sections + # @return [Hash] sections + def sort_pull_requests(pull_requests) + added_pull_requests = [] + pull_requests.each do |pr| + added = false + + pr["labels"].each do |label| + break if @lmap[label["name"]].nil? + @smap[@lmap[label["name"]]].issues << pr + added_pull_requests << pr + added = true + + break if added + end + end + added_pull_requests.each { |req| pull_requests.delete(req) } + @sections + end + + def line_labels_for(issue) + labels = if @options[:issue_line_labels] == ["ALL"] + issue["labels"] + else + issue["labels"].select { |label| @options[:issue_line_labels].include?(label["name"]) } + end + labels.map { |label| " \[[#{label['name']}](#{label['url'].sub('api.github.com/repos', 'github.com')})\]" }.join("") + end + end +end diff --git a/lib/github_changelog_generator/generator/generator.rb b/lib/github_changelog_generator/generator/generator.rb index fe5c908..dbd48d2 100644 --- a/lib/github_changelog_generator/generator/generator.rb +++ b/lib/github_changelog_generator/generator/generator.rb @@ -1,18 +1,30 @@ # frozen_string_literal: true require "github_changelog_generator/octo_fetcher" -require "github_changelog_generator/generator/generator_generation" require "github_changelog_generator/generator/generator_fetcher" require "github_changelog_generator/generator/generator_processor" require "github_changelog_generator/generator/generator_tags" +require "github_changelog_generator/generator/entry" +require "github_changelog_generator/generator/section" module GitHubChangelogGenerator # Default error for ChangelogGenerator class ChangelogGeneratorError < StandardError end + # This class is the high-level code for gathering issues and PRs for a github + # repository and generating a CHANGELOG.md file. A changelog is made up of a + # series of "entries" of all tagged releases, plus an extra entry for the + # unreleased changes. Entries are made up of various organizational + # "sections," and sections contain the github issues and PRs. + # + # So the changelog contains entries, entries contain sections, and sections + # contain issues and PRs. + # + # @see GitHubChangelogGenerator::Entry + # @see GitHubChangelogGenerator::Section class Generator - attr_accessor :options, :filtered_tags, :github, :tag_section_mapping, :sorted_tags + attr_accessor :options, :filtered_tags, :tag_section_mapping, :sorted_tags # A Generator responsible for all logic, related with changelog generation from ready-to-parse issues # @@ -23,6 +35,104 @@ module GitHubChangelogGenerator @options = options @tag_times_hash = {} @fetcher = GitHubChangelogGenerator::OctoFetcher.new(options) + @sections = [] + end + + # Main function to start changelog generation + # + # @return [String] Generated changelog file + def compound_changelog + options.load_custom_ruby_files + fetch_and_filter_tags + fetch_issues_and_pr + + log = "" + log += options[:frontmatter] if options[:frontmatter] + log += "#{options[:header]}\n\n" + + log += if options[:unreleased_only] + generate_entry_between_tags(filtered_tags[0], nil) + else + generate_entries_for_all_tags + end + + log += File.read(options[:base]) if File.file?(options[:base]) + + credit_line = "\n\n\\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)*" + log.gsub!(credit_line, "") # Remove old credit lines + log += credit_line + + @log = log + end + + private + + # Generate log only between 2 specified tags + # @param [String] older_tag all issues before this tag date will be excluded. May be nil, if it's first tag + # @param [String] newer_tag all issue after this tag will be excluded. May be nil for unreleased section + def generate_entry_between_tags(older_tag, newer_tag) + filtered_issues, filtered_pull_requests = filter_issues_for_tags(newer_tag, older_tag) + + if newer_tag.nil? && filtered_issues.empty? && filtered_pull_requests.empty? + # do not generate empty unreleased section + return "" + end + + newer_tag_link, newer_tag_name, newer_tag_time = detect_link_tag_time(newer_tag) + + # If the older tag is nil, go back in time from the latest tag and find + # the SHA for the first commit. + older_tag_name = + if older_tag.nil? + @fetcher.commits_before(newer_tag_time).last["sha"] + else + older_tag["name"] + end + + Entry.new(options).create_entry_for_tag(filtered_pull_requests, filtered_issues, newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name) + end + + # Filters issues and pull requests based on, respectively, `closed_at` and `merged_at` + # timestamp fields. + # + # @return [Array] filtered issues and pull requests + def filter_issues_for_tags(newer_tag, older_tag) + filtered_pull_requests = delete_by_time(@pull_requests, "merged_at", older_tag, newer_tag) + filtered_issues = delete_by_time(@issues, "closed_at", older_tag, newer_tag) + + newer_tag_name = newer_tag.nil? ? nil : newer_tag["name"] + + if options[:filter_issues_by_milestone] + # delete excess irrelevant issues (according milestones). Issue #22. + filtered_issues = filter_by_milestone(filtered_issues, newer_tag_name, @issues) + filtered_pull_requests = filter_by_milestone(filtered_pull_requests, newer_tag_name, @pull_requests) + end + [filtered_issues, filtered_pull_requests] + end + + # The full cycle of generation for whole project + # @return [String] All entries in the changelog + def generate_entries_for_all_tags + puts "Generating entry..." if options[:verbose] + + entries = generate_unreleased_entry + + @tag_section_mapping.each_pair do |_tag_section, left_right_tags| + older_tag, newer_tag = left_right_tags + entries += generate_entry_between_tags(older_tag, newer_tag) + end + + entries + end + + def generate_unreleased_entry + entry = "" + if options[:unreleased] + start_tag = filtered_tags[0] || sorted_tags.last + unreleased_entry = generate_entry_between_tags(start_tag, nil) + entry += unreleased_entry if unreleased_entry + end + entry end def fetch_issues_and_pr @@ -35,144 +145,5 @@ module GitHubChangelogGenerator fetch_events_for_issues_and_pr detect_actual_closed_dates(@issues + @pull_requests) end - - ENCAPSULATED_CHARACTERS = %w(< > * _ \( \) [ ] #) - - # Encapsulate characters to make Markdown look as expected. - # - # @param [String] string - # @return [String] encapsulated input string - def encapsulate_string(string) - string = string.gsub('\\', '\\\\') - - ENCAPSULATED_CHARACTERS.each do |char| - string = string.gsub(char, "\\#{char}") - end - - string - end - - # Generates log for section with header and body - # - # @param [Array] pull_requests List or PR's in new section - # @param [Array] issues List of issues in new section - # @param [String] newer_tag Name of the newer tag. Could be nil for `Unreleased` section - # @param [Hash, nil] older_tag Older tag, used for the links. Could be nil for last tag. - # @return [String] Ready and parsed section - def create_log_for_tag(pull_requests, issues, newer_tag, older_tag = nil) - newer_tag_link, newer_tag_name, newer_tag_time = detect_link_tag_time(newer_tag) - - github_site = options[:github_site] || "https://github.com" - project_url = "#{github_site}/#{options[:user]}/#{options[:project]}" - - # If the older tag is nil, go back in time from the latest tag and find - # the SHA for the first commit. - older_tag_name = - if older_tag.nil? - @fetcher.commits_before(newer_tag_time).last["sha"] - else - older_tag["name"] - end - - log = generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name, project_url) - - if options[:issues] - # Generate issues: - log += issues_to_log(issues, pull_requests) - end - - if options[:pulls] && options[:add_pr_wo_labels] - # Generate pull requests: - log += generate_sub_section(pull_requests, options[:merge_prefix]) - end - - log - end - - # Generate ready-to-paste log from list of issues and pull requests. - # - # @param [Array] issues - # @param [Array] pull_requests - # @return [String] generated log for issues - def issues_to_log(issues, pull_requests) - sections = parse_by_sections(issues, pull_requests) - - log = "" - log += generate_sub_section(sections[:breaking], options[:breaking_prefix]) - log += generate_sub_section(sections[:enhancements], options[:enhancement_prefix]) - log += generate_sub_section(sections[:bugs], options[:bug_prefix]) - log += generate_sub_section(sections[:issues], options[:issue_prefix]) - log - end - - # This method sort issues by types - # (bugs, features, or just closed issues) by labels - # - # @param [Array] issues - # @param [Array] pull_requests - # @return [Hash] Mapping of filtered arrays: (Bugs, Enhancements, Breaking stuff, Issues) - def parse_by_sections(issues, pull_requests) - sections = { - issues: [], - enhancements: [], - bugs: [], - breaking: [] - } - - issues.each do |dict| - added = false - - dict["labels"].each do |label| - if options[:bug_labels].include?(label["name"]) - sections[:bugs] << dict - added = true - elsif options[:enhancement_labels].include?(label["name"]) - sections[:enhancements] << dict - added = true - elsif options[:breaking_labels].include?(label["name"]) - sections[:breaking] << dict - added = true - end - - break if added - end - - sections[:issues] << dict unless added - end - - sort_pull_requests(pull_requests, sections) - end - - # This method iterates through PRs and sorts them into sections - # - # @param [Array] pull_requests - # @param [Hash] sections - # @return [Hash] sections - def sort_pull_requests(pull_requests, sections) - added_pull_requests = [] - pull_requests.each do |pr| - added = false - - pr["labels"].each do |label| - if options[:bug_labels].include?(label["name"]) - sections[:bugs] << pr - added_pull_requests << pr - added = true - elsif options[:enhancement_labels].include?(label["name"]) - sections[:enhancements] << pr - added_pull_requests << pr - added = true - elsif options[:breaking_labels].include?(label["name"]) - sections[:breaking] << pr - added_pull_requests << pr - added = true - end - - break if added - end - end - added_pull_requests.each { |p| pull_requests.delete(p) } - sections - end end end diff --git a/lib/github_changelog_generator/generator/generator_generation.rb b/lib/github_changelog_generator/generator/generator_generation.rb deleted file mode 100644 index b6f2610..0000000 --- a/lib/github_changelog_generator/generator/generator_generation.rb +++ /dev/null @@ -1,181 +0,0 @@ -# frozen_string_literal: true - -module GitHubChangelogGenerator - class Generator - # Main function to start changelog generation - # - # @return [String] Generated changelog file - def compound_changelog - options.load_custom_ruby_files - fetch_and_filter_tags - fetch_issues_and_pr - - log = "" - log += options[:frontmatter] if options[:frontmatter] - log += "#{options[:header]}\n\n" - - log += if options[:unreleased_only] - generate_log_between_tags(filtered_tags[0], nil) - else - generate_log_for_all_tags - end - - log += File.read(options[:base]) if File.file?(options[:base]) - - credit_line = "\n\n\\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)*" - log.gsub!(credit_line, "") # Remove old credit lines - log += credit_line - - @log = log - end - - # @param [Array] issues List of issues on sub-section - # @param [String] prefix Name of sub-section - # @return [String] Generate ready-to-go sub-section - def generate_sub_section(issues, prefix) - log = "" - - if issues.any? - log += "#{prefix}\n\n" unless options[:simple_list] - issues.each do |issue| - merge_string = get_string_for_issue(issue) - log += "- #{merge_string}\n" - end - log += "\n" - end - log - end - - # It generate one header for section with specific parameters. - # - # @param [String] newer_tag_name - name of newer tag - # @param [String] newer_tag_link - used for links. Could be same as #newer_tag_name or some specific value, like HEAD - # @param [Time] newer_tag_time - time, when newer tag created - # @param [String] older_tag_link - tag name, used for links. - # @param [String] project_url - url for current project. - # @return [String] - Generate one ready-to-add section. - def generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_link, project_url) - log = "" - - # Generate date string: - time_string = newer_tag_time.strftime(options[:date_format]) - - # Generate tag name and link - release_url = if options[:release_url] - format(options[:release_url], newer_tag_link) - else - "#{project_url}/tree/#{newer_tag_link}" - end - log += if newer_tag_name.equal?(options[:unreleased_label]) - "## [#{newer_tag_name}](#{release_url})\n\n" - else - "## [#{newer_tag_name}](#{release_url}) (#{time_string})\n\n" - end - - if options[:compare_link] && older_tag_link - # Generate compare link - log += "[Full Changelog](#{project_url}/compare/#{older_tag_link}...#{newer_tag_link})\n\n" - end - - log - end - - # Generate log only between 2 specified tags - # @param [String] older_tag all issues before this tag date will be excluded. May be nil, if it's first tag - # @param [String] newer_tag all issue after this tag will be excluded. May be nil for unreleased section - def generate_log_between_tags(older_tag, newer_tag) - filtered_issues, filtered_pull_requests = filter_issues_for_tags(newer_tag, older_tag) - - if newer_tag.nil? && filtered_issues.empty? && filtered_pull_requests.empty? - # do not generate empty unreleased section - return "" - end - - create_log_for_tag(filtered_pull_requests, filtered_issues, newer_tag, older_tag) - end - - # Filters issues and pull requests based on, respectively, `closed_at` and `merged_at` - # timestamp fields. - # - # @return [Array] filtered issues and pull requests - def filter_issues_for_tags(newer_tag, older_tag) - filtered_pull_requests = delete_by_time(@pull_requests, "merged_at", older_tag, newer_tag) - filtered_issues = delete_by_time(@issues, "closed_at", older_tag, newer_tag) - - newer_tag_name = newer_tag.nil? ? nil : newer_tag["name"] - - if options[:filter_issues_by_milestone] - # delete excess irrelevant issues (according milestones). Issue #22. - filtered_issues = filter_by_milestone(filtered_issues, newer_tag_name, @issues) - filtered_pull_requests = filter_by_milestone(filtered_pull_requests, newer_tag_name, @pull_requests) - end - [filtered_issues, filtered_pull_requests] - end - - # The full cycle of generation for whole project - # @return [String] The complete changelog - def generate_log_for_all_tags - puts "Generating log..." if options[:verbose] - - log = generate_unreleased_section - - @tag_section_mapping.each_pair do |_tag_section, left_right_tags| - older_tag, newer_tag = left_right_tags - log += generate_log_between_tags(older_tag, newer_tag) - end - - log - end - - def generate_unreleased_section - log = "" - if options[:unreleased] - start_tag = filtered_tags[0] || sorted_tags.last - unreleased_log = generate_log_between_tags(start_tag, nil) - log += unreleased_log if unreleased_log - end - log - end - - # Parse issue and generate single line formatted issue line. - # - # Example output: - # - Add coveralls integration [\#223](https://github.com/skywinder/github-changelog-generator/pull/223) (@skywinder) - # - # @param [Hash] issue Fetched issue from GitHub - # @return [String] Markdown-formatted single issue - def get_string_for_issue(issue) - encapsulated_title = encapsulate_string issue["title"] - - title_with_number = "#{encapsulated_title} [\\##{issue['number']}](#{issue['html_url']})" - if options[:issue_line_labels].present? - title_with_number = "#{title_with_number}#{line_labels_for(issue)}" - end - issue_line_with_user(title_with_number, issue) - end - - private - - def line_labels_for(issue) - labels = if options[:issue_line_labels] == ["ALL"] - issue["labels"] - else - issue["labels"].select { |label| options[:issue_line_labels].include?(label["name"]) } - end - labels.map { |label| " \[[#{label['name']}](#{label['url'].sub('api.github.com/repos', 'github.com')})\]" }.join("") - end - - def issue_line_with_user(line, issue) - return line if !options[:author] || issue["pull_request"].nil? - - user = issue["user"] - return "#{line} ({Null user})" unless user - - if options[:usernames_as_github_logins] - "#{line} (@#{user['login']})" - else - "#{line} ([#{user['login']}](#{user['html_url']}))" - end - end - end -end diff --git a/lib/github_changelog_generator/generator/section.rb b/lib/github_changelog_generator/generator/section.rb new file mode 100644 index 0000000..71c7ed3 --- /dev/null +++ b/lib/github_changelog_generator/generator/section.rb @@ -0,0 +1,83 @@ +module GitHubChangelogGenerator + # This class generates the content for a single section of a changelog entry. + # It turns the tagged issues and PRs into a well-formatted list of changes to + # be later incorporated into a changelog entry. + # + # @see GitHubChangelogGenerator::Entry + class Section + attr_accessor :name, :prefix, :issues, :labels + + def initialize(opts = {}) + @name = opts[:name] + @prefix = opts[:prefix] + @labels = opts[:labels] || [] + @issues = opts[:issues] || [] + @options = opts[:options] || Options.new({}) + end + + # @param [Array] issues List of issues on sub-section + # @param [String] prefix Name of sub-section + # @return [String] Generate section content + def generate_content + content = "" + + if @issues.any? + content += "#{@prefix}\n\n" unless @options[:simple_list] + @issues.each do |issue| + merge_string = get_string_for_issue(issue) + content += "- #{merge_string}\n" + end + content += "\n" + end + content + end + + private + + # Parse issue and generate single line formatted issue line. + # + # Example output: + # - Add coveralls integration [\#223](https://github.com/skywinder/github-changelog-generator/pull/223) (@skywinder) + # + # @param [Hash] issue Fetched issue from GitHub + # @return [String] Markdown-formatted single issue + def get_string_for_issue(issue) + encapsulated_title = encapsulate_string issue["title"] + + title_with_number = "#{encapsulated_title} [\\##{issue['number']}](#{issue['html_url']})" + if @options[:issue_line_labels].present? + title_with_number = "#{title_with_number}#{line_labels_for(issue)}" + end + issue_line_with_user(title_with_number, issue) + end + + def issue_line_with_user(line, issue) + return line if !@options[:author] || issue["pull_request"].nil? + + user = issue["user"] + return "#{line} ({Null user})" unless user + + if @options[:usernames_as_github_logins] + "#{line} (@#{user['login']})" + else + "#{line} ([#{user['login']}](#{user['html_url']}))" + end + end + + ENCAPSULATED_CHARACTERS = %w(< > * _ \( \) [ ] #) + + # Encapsulate characters to make Markdown look as expected. + # + # @param [String] string + # @return [String] encapsulated input string + def encapsulate_string(string) + string = string.gsub('\\', '\\\\') + + ENCAPSULATED_CHARACTERS.each do |char| + string = string.gsub(char, "\\#{char}") + end + + string + end + end +end diff --git a/lib/github_changelog_generator/options.rb b/lib/github_changelog_generator/options.rb index b68b5da..1a998df 100644 --- a/lib/github_changelog_generator/options.rb +++ b/lib/github_changelog_generator/options.rb @@ -15,6 +15,7 @@ module GitHubChangelogGenerator KNOWN_OPTIONS = %i[ add_issues_wo_labels add_pr_wo_labels + add_sections author base between_tags @@ -29,6 +30,7 @@ module GitHubChangelogGenerator enhancement_prefix breaking_labels breaking_prefix + configure_sections exclude_labels exclude_tags exclude_tags_regex @@ -100,6 +102,16 @@ module GitHubChangelogGenerator puts "" end + # Boolean method for whether the user is using configure_sections + def configure_sections? + !self[:configure_sections].nil? && !self[:configure_sections].empty? + end + + # Boolean method for whether the user is using add_sections + def add_sections? + !self[:add_sections].nil? && !self[:add_sections].empty? + end + private def values diff --git a/lib/github_changelog_generator/parser.rb b/lib/github_changelog_generator/parser.rb index c72c9f4..51efda4 100755 --- a/lib/github_changelog_generator/parser.rb +++ b/lib/github_changelog_generator/parser.rb @@ -71,6 +71,12 @@ module GitHubChangelogGenerator opts.on("--header-label [LABEL]", "Setup custom header label. Default is \"# Changelog\"") do |v| options[:header] = v end + opts.on("--configure-sections [Hash, String]", "Define your own set of sections which overrides all default sections") do |v| + options[:configure_sections] = v + end + opts.on("--add-sections [Hash, String]", "Add new sections but keep the default sections") do |v| + options[:add_sections] = v + end opts.on("--front-matter [JSON]", "Add YAML front matter. Formatted as JSON because it's easier to add on the command line") do |v| options[:frontmatter] = JSON.parse(v).to_yaml + "---\n" end @@ -209,6 +215,8 @@ module GitHubChangelogGenerator bug_labels: ["bug", "Bug", "Type: Bug"], exclude_labels: ["duplicate", "question", "invalid", "wontfix", "Duplicate", "Question", "Invalid", "Wontfix", "Meta: Exclude From Changelog"], breaking_labels: %w[backwards-incompatible breaking], + configure_sections: {}, + add_sections: {}, issue_line_labels: [], max_issues: nil, simple_list: false, diff --git a/lib/github_changelog_generator/task.rb b/lib/github_changelog_generator/task.rb index c82c078..57a5a19 100644 --- a/lib/github_changelog_generator/task.rb +++ b/lib/github_changelog_generator/task.rb @@ -10,7 +10,6 @@ module GitHubChangelogGenerator OPTIONS = %w[ user project token date_format output bug_prefix enhancement_prefix issue_prefix - breaking_labels issue_line_labels header merge_prefix issues add_issues_wo_labels add_pr_wo_labels pulls filter_issues_by_milestone author @@ -20,7 +19,7 @@ module GitHubChangelogGenerator between_tags exclude_tags exclude_tags_regex since_tag max_issues github_site github_endpoint simple_list future_release release_branch verbose release_url - base ] + base configure_sections add_sections] OPTIONS.each do |o| attr_accessor o.to_sym diff --git a/man/git-generate-changelog.1 b/man/git-generate-changelog.1 index f54f494..ecca559 100644 --- a/man/git-generate-changelog.1 +++ b/man/git-generate-changelog.1 @@ -1,7 +1,7 @@ .\" generated with Ronn/v0.7.3 .\" http://github.com/rtomayko/ronn/tree/0.7.3 . -.TH "GIT\-GENERATE\-CHANGELOG" "1" "October 2017" "" "" +.TH "GIT\-GENERATE\-CHANGELOG" "1" "December 2017" "" "" . .SH "NAME" \fBgit\-generate\-changelog\fR \- Generate changelog from github @@ -284,6 +284,18 @@ Paths to Ruby file(s) to require before generating changelog\. Run verbosely\. Default is true . .P +\-\-configure\-sections [HASH, STRING] +. +.P +Define your own set of sections which overrides all default sections") do |v| +. +.P +\-\-add\-sections [HASH, STRING] +. +.P +Add new sections but keep the default sections" +. +.P \-v, \-\-version . .P diff --git a/man/git-generate-changelog.1.html b/man/git-generate-changelog.1.html index 7dcea26..7155fb0 100644 --- a/man/git-generate-changelog.1.html +++ b/man/git-generate-changelog.1.html @@ -261,13 +261,21 @@

Run verbosely. Default is true

-

-v, --version

+

--configure-sections [HASH, STRING]

-

Print version number

+

Define your own set of sections which overrides all default sections") do |v|

-

-h, --help

+

--add-sections [HASH, STRING]

-

Displays Help

+

Add new sections but keep the default sections"

+ +

-v, --version

+ +

Print version number

+ +

-h, --help

+ +

Displays Help

EXAMPLES

@@ -286,7 +294,7 @@
  1. -
  2. October 2017
  3. +
  4. December 2017
  5. git-generate-changelog(1)
diff --git a/man/git-generate-changelog.html b/man/git-generate-changelog.html index cc28f6b..a1c19f0 100644 --- a/man/git-generate-changelog.html +++ b/man/git-generate-changelog.html @@ -220,6 +220,18 @@

Put the unreleased changes in the specified release number.

+

--configure-sections [HASH, STRING]

+ +

Define your own set of sections which overrides all default sections") do |v|

+ +

--add-sections [HASH, STRING]

+ +

Add new sections but keep the default sections"

+ +

--include-merged

+ +

If configure_sections is set, use this to restore the merged pull requests sections

+

--[no-]verbose

Run verbosely. Default is true

diff --git a/man/git-generate-changelog.md b/man/git-generate-changelog.md index 3e94597..3f5c3d8 100644 --- a/man/git-generate-changelog.md +++ b/man/git-generate-changelog.md @@ -187,13 +187,21 @@ Automatically generate changelog from your tags, issues, labels and pull request Run verbosely. Default is true - -v, --version + --configure-sections [HASH, STRING] - Print version number + Define your own set of sections which overrides all default sections") do |v| - -h, --help + --add-sections [HASH, STRING] - Displays Help + Add new sections but keep the default sections" + + -v, --version + + Print version number + + -h, --help + + Displays Help ## EXAMPLES diff --git a/spec/unit/generator/entry_spec.rb b/spec/unit/generator/entry_spec.rb new file mode 100644 index 0000000..f1ffe4b --- /dev/null +++ b/spec/unit/generator/entry_spec.rb @@ -0,0 +1,363 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/ModuleLength +module GitHubChangelogGenerator + RSpec.describe Entry do + def label(name) + { "name" => name } + end + + def issue(title, labels, number = "1", user = { "login" => "user" }) + { + "title" => "issue #{title}", + "labels" => labels.map { |l| label(l) }, + "number" => number, + "html_url" => "https://github.com/owner/repo/issue/#{number}", + "user" => user + } + end + + def pr(title, labels, number = "1", user = { "login" => "user" }) + { + "pull_request" => true, + "title" => "pr #{title}", + "labels" => labels.map { |l| label(l) }, + "number" => number, + "html_url" => "https://github.com/owner/repo/pull/#{number}", + "user" => user.merge("html_url" => "https://github.com/#{user['login']}") + } + end + + def titles_for(issues) + issues.map { |issue| issue["title"] } + end + + def default_sections + %w[enhancements bugs breaking issues] + end + + describe "#create_entry_for_tag" do + let(:options) do + Parser.default_options.merge( + user: "owner", + project: "repo", + bug_labels: ["bug"], + enhancement_labels: ["enhancement"], + breaking_labels: ["breaking"] + ) + end + + let(:issues) do + [ + issue("no labels", [], "5", "login" => "user1"), + issue("enhancement", ["enhancement"], "6", "login" => "user2"), + issue("bug", ["bug"], "7", "login" => "user1"), + issue("breaking", ["breaking"], "8", "login" => "user5"), + issue("all the labels", %w[enhancement bug breaking], "9", "login" => "user9") + ] + end + + let(:pull_requests) do + [ + pr("no labels", [], "10", "login" => "user1"), + pr("enhancement", ["enhancement"], "11", "login" => "user5"), + pr("bug", ["bug"], "12", "login" => "user5"), + pr("breaking", ["breaking"], "13", "login" => "user5"), + pr("all the labels", %w[enhancement bug breaking], "14", "login" => "user5") + ] + end + + subject { described_class.new(options) } + + it "generates a header and body" do + expect(subject.create_entry_for_tag(pull_requests, issues, "1.0.1", "1.0.1", Time.new(2017, 12, 4), "1.0.0")).to eq(<<-CHANGELOG.gsub(/^ {8}/, "") + ## [1.0.1](https://github.com/owner/repo/tree/1.0.1) (2017-12-04) + + [Full Changelog](https://github.com/owner/repo/compare/1.0.0...1.0.1) + + **Breaking changes:** + + - issue breaking [\\#8](https://github.com/owner/repo/issue/8) + - pr breaking [\\#13](https://github.com/owner/repo/pull/13) ([user5](https://github.com/user5)) + + **Implemented enhancements:** + + - issue enhancement [\\#6](https://github.com/owner/repo/issue/6) + - issue all the labels [\\#9](https://github.com/owner/repo/issue/9) + - pr enhancement [\\#11](https://github.com/owner/repo/pull/11) ([user5](https://github.com/user5)) + - pr all the labels [\\#14](https://github.com/owner/repo/pull/14) ([user5](https://github.com/user5)) + + **Fixed bugs:** + + - issue bug [\\#7](https://github.com/owner/repo/issue/7) + - pr bug [\\#12](https://github.com/owner/repo/pull/12) ([user5](https://github.com/user5)) + + **Closed issues:** + + - issue no labels [\\#5](https://github.com/owner/repo/issue/5) + + **Merged pull requests:** + + - pr no labels [\\#10](https://github.com/owner/repo/pull/10) ([user1](https://github.com/user1)) + + CHANGELOG + ) + end + end + describe "#parse_sections" do + before do + subject { described_class.new } + end + context "valid json" do + let(:sections_string) { "{ \"foo\": { \"prefix\": \"foofix\", \"labels\": [\"test1\", \"test2\"]}, \"bar\": { \"prefix\": \"barfix\", \"labels\": [\"test3\", \"test4\"]}}" } + + let(:sections_array) do + [ + Section.new(name: "foo", prefix: "foofix", labels: %w[test1 test2]), + Section.new(name: "bar", prefix: "barfix", labels: %w[test3 test4]) + ] + end + + it "returns an array with 2 objects" do + arr = subject.send(:parse_sections, sections_string) + expect(arr.size).to eq 2 + arr.each { |section| expect(section).to be_an_instance_of Section } + end + + it "returns correctly constructed sections" do + require "json" + + sections_json = JSON.parse(sections_string) + sections_array.each_index do |i| + aggregate_failures "checks each component" do + expect(sections_array[i].name).to eq sections_json.first[0] + expect(sections_array[i].prefix).to eq sections_json.first[1]["prefix"] + expect(sections_array[i].labels).to eq sections_json.first[1]["labels"] + expect(sections_array[i].issues).to eq [] + end + sections_json.shift + end + end + end + context "hash" do + let(:sections_hash) do + { + enhancements: { + prefix: "**Enhancements**", + labels: %w[feature enhancement] + }, + breaking: { + prefix: "**Breaking**", + labels: ["breaking"] + }, + bugs: { + prefix: "**Bugs**", + labels: ["bug"] + } + } + end + + let(:sections_array) do + [ + Section.new(name: "enhancements", prefix: "**Enhancements**", labels: %w[feature enhancement]), + Section.new(name: "breaking", prefix: "**Breaking**", labels: ["breaking"]), + Section.new(name: "bugs", prefix: "**Bugs**", labels: ["bug"]) + ] + end + + it "returns an array with 3 objects" do + arr = subject.send(:parse_sections, sections_hash) + expect(arr.size).to eq 3 + arr.each { |section| expect(section).to be_an_instance_of Section } + end + + it "returns correctly constructed sections" do + sections_array.each_index do |i| + aggregate_failures "checks each component" do + expect(sections_array[i].name).to eq sections_hash.first[0].to_s + expect(sections_array[i].prefix).to eq sections_hash.first[1][:prefix] + expect(sections_array[i].labels).to eq sections_hash.first[1][:labels] + expect(sections_array[i].issues).to eq [] + end + sections_hash.shift + end + end + end + end + + describe "#parse_by_sections" do + context "default sections" do + let(:options) do + { + bug_labels: ["bug"], + enhancement_labels: ["enhancement"], + breaking_labels: ["breaking"] + } + end + + let(:issues) do + [ + issue("no labels", []), + issue("enhancement", ["enhancement"]), + issue("bug", ["bug"]), + issue("breaking", ["breaking"]), + issue("all the labels", %w[enhancement bug breaking]) + ] + end + + let(:pull_requests) do + [ + pr("no labels", []), + pr("enhancement", ["enhancement"]), + pr("bug", ["bug"]), + pr("breaking", ["breaking"]), + pr("all the labels", %w[enhancement bug breaking]) + ] + end + + subject { described_class.new(options) } + + before do + subject.send(:set_sections_and_maps) + @arr = subject.send(:parse_by_sections, pull_requests, issues) + end + + it "returns 4 sections" do + expect(@arr.size).to eq 4 + end + + it "returns default sections" do + default_sections.each { |default_section| expect(@arr.select { |section| section.name == default_section }.size).to eq 1 } + end + + it "assigns issues to the correct sections" do + breaking_section = @arr.select { |section| section.name == "breaking" }[0] + enhancement_section = @arr.select { |section| section.name == "enhancements" }[0] + issue_section = @arr.select { |section| section.name == "issues" }[0] + bug_section = @arr.select { |section| section.name == "bugs" }[0] + + expect(titles_for(breaking_section.issues)).to eq(["issue breaking", "pr breaking"]) + expect(titles_for(enhancement_section.issues)).to eq(["issue enhancement", "issue all the labels", "pr enhancement", "pr all the labels"]) + expect(titles_for(issue_section.issues)).to eq(["issue no labels"]) + expect(titles_for(bug_section.issues)).to eq(["issue bug", "pr bug"]) + expect(titles_for(pull_requests)).to eq(["pr no labels"]) + end + end + context "configure sections" do + let(:options) do + { + configure_sections: "{ \"foo\": { \"prefix\": \"foofix\", \"labels\": [\"test1\", \"test2\"]}, \"bar\": { \"prefix\": \"barfix\", \"labels\": [\"test3\", \"test4\"]}}" + } + end + + let(:issues) do + [ + issue("no labels", []), + issue("test1", ["test1"]), + issue("test3", ["test3"]), + issue("test4", ["test4"]), + issue("all the labels", %w[test1 test2 test3 test4]) + ] + end + + let(:pull_requests) do + [ + pr("no labels", []), + pr("test1", ["test1"]), + pr("test3", ["test3"]), + pr("test4", ["test4"]), + pr("all the labels", %w[test1 test2 test3 test4]) + ] + end + + subject { described_class.new(options) } + + before do + subject.send(:set_sections_and_maps) + @arr = subject.send(:parse_by_sections, pull_requests, issues) + end + + it "returns 2 sections" do + expect(@arr.size).to eq 2 + end + + it "returns only configured sections" do + expect(@arr.select { |section| section.name == "foo" }.size).to eq 1 + expect(@arr.select { |section| section.name == "bar" }.size).to eq 1 + end + + it "assigns issues to the correct sections" do + foo_section = @arr.select { |section| section.name == "foo" }[0] + bar_section = @arr.select { |section| section.name == "bar" }[0] + + aggregate_failures "checks all sections" do + expect(titles_for(foo_section.issues)).to eq(["issue test1", "issue all the labels", "pr test1", "pr all the labels"]) + expect(titles_for(bar_section.issues)).to eq(["issue test3", "issue test4", "pr test3", "pr test4"]) + expect(titles_for(pull_requests)).to eq(["pr no labels"]) + end + end + end + context "add sections" do + let(:options) do + { + bug_labels: ["bug"], + enhancement_labels: ["enhancement"], + breaking_labels: ["breaking"], + add_sections: "{ \"foo\": { \"prefix\": \"foofix\", \"labels\": [\"test1\", \"test2\"]}}" + } + end + + let(:issues) do + [ + issue("no labels", []), + issue("test1", ["test1"]), + issue("bugaboo", ["bug"]), + issue("all the labels", %w[test1 test2 enhancement bug]) + ] + end + + let(:pull_requests) do + [ + pr("no labels", []), + pr("test1", ["test1"]), + pr("enhance", ["enhancement"]), + pr("all the labels", %w[test1 test2 enhancement bug]) + ] + end + + subject { described_class.new(options) } + + before do + subject.send(:set_sections_and_maps) + @arr = subject.send(:parse_by_sections, pull_requests, issues) + end + + it "returns 5 sections" do + expect(@arr.size).to eq 5 + end + + it "returns default sections" do + default_sections.each { |default_section| expect(@arr.select { |section| section.name == default_section }.size).to eq 1 } + end + + it "returns added section" do + expect(@arr.select { |section| section.name == "foo" }.size).to eq 1 + end + + it "assigns issues to the correct sections" do + foo_section = @arr.select { |section| section.name == "foo" }[0] + enhancement_section = @arr.select { |section| section.name == "enhancements" }[0] + bug_section = @arr.select { |section| section.name == "bugs" }[0] + + aggregate_failures "checks all sections" do + expect(titles_for(foo_section.issues)).to eq(["issue test1", "issue all the labels", "pr test1", "pr all the labels"]) + expect(titles_for(enhancement_section.issues)).to eq(["pr enhance"]) + expect(titles_for(bug_section.issues)).to eq(["issue bugaboo"]) + expect(titles_for(pull_requests)).to eq(["pr no labels"]) + end + end + end + end + end +end +# rubocop:enable Metrics/ModuleLength diff --git a/spec/unit/generator/generator_generation_spec.rb b/spec/unit/generator/generator_generation_spec.rb deleted file mode 100644 index 5a1b089..0000000 --- a/spec/unit/generator/generator_generation_spec.rb +++ /dev/null @@ -1,73 +0,0 @@ -# frozen_string_literal: true - -module GitHubChangelogGenerator - describe Generator do - describe "#get_string_for_issue" do - let(:issue) do - { "title" => "Bug in code" } - end - - it "formats an issue according to options" do - expect do - described_class.new.get_string_for_issue(issue) - end.not_to raise_error - end - end - - describe "#parse_by_sections" do - def label(name) - { "name" => name } - end - - def issue(title, labels) - { "title" => "issue #{title}", "labels" => labels.map { |l| label(l) } } - end - - def pr(title, labels) - { "title" => "pr #{title}", "labels" => labels.map { |l| label(l) } } - end - - def get_titles(issues) - issues.map { |issue| issue["title"] } - end - - let(:options) do - { - bug_labels: ["bug"], - enhancement_labels: ["enhancement"], - breaking_labels: ["breaking"] - } - end - - let(:issues) do - [ - issue("no labels", []), - issue("enhancement", ["enhancement"]), - issue("bug", ["bug"]), - issue("breaking", ["breaking"]), - issue("all the labels", %w[enhancement bug breaking]) - ] - end - - let(:pull_requests) do - [ - pr("no labels", []), - pr("enhancement", ["enhancement"]), - pr("bug", ["bug"]), - pr("breaking", ["breaking"]), - pr("all the labels", %w[enhancement bug breaking]) - ] - end - - it "works" do - sections = described_class.new(options).parse_by_sections(issues, pull_requests) - - expect(get_titles(sections[:issues])).to eq(["issue no labels"]) - expect(get_titles(sections[:enhancements])).to eq(["issue enhancement", "issue all the labels", "pr enhancement", "pr all the labels"]) - expect(get_titles(sections[:bugs])).to eq(["issue bug", "pr bug"]) - expect(get_titles(sections[:breaking])).to eq(["issue breaking", "pr breaking"]) - expect(get_titles(pull_requests)).to eq(["pr no labels"]) - end - end - end -end