Skip to content

Fix unlabeled, mixed labels, and unmapped labels handling #618

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 83 additions & 108 deletions lib/github_changelog_generator/generator/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,42 +25,39 @@ def initialize(options = Options.new({}))
# @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
# @param [Hash, nil] older_tag_name Older tag, used for the links. Could be nil for last tag.
# @return [String] Ready and parsed section content.
def generate_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
create_sections
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it worth changing this method name to generate_sections? it would match better with everything around it.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the code, my personal take is “create_” reads easier.

We could move to that naming instead? But to me, that’s not a merge blocker for this change.

Thanks for keeping sharp eyes on This developing PR, @eputnam!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is about filling the @sections array with the Section.new objects to be filled out later on. I tried to make any generate_* methods be ones that return generated string content for the changelog.


@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
# Creates section objects for this entry.
# @return [Nil]
def create_sections
@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
nil
end

# Turns a string from the commandline into an array of Section objects
# Turns the argument from the commandline of --configure-sections or
# --add-sections into an array of Section objects.
#
# @param [String, Hash] either string or hash describing sections
# @return [Array] array of Section objects
# @param [String, Hash] sections_desc Either string or hash describing sections
# @return [Array] Parsed section objects.
def parse_sections(sections_desc)
require "json"

Expand All @@ -77,34 +74,14 @@ def parse_sections(sections_desc)
end
end

# Creates a hash map of labels => section objects
# Generates header text for an entry.
#
# @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.
# @param [String] newer_tag_name The name of a newer tag
# @param [String] newer_tag_link Used for URL generation. Could be same as #newer_tag_name or some specific value, like HEAD
# @param [Time] newer_tag_time Time when the newer tag was created
# @param [String] older_tag_name The name of an older tag; used for URLs.
# @param [String] project_url URL for the current project.
# @return [String] Header text content.
def generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name, project_url)
header = ""

Expand Down Expand Up @@ -135,94 +112,92 @@ def generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_na
#
# @param [Array] pull_requests
# @param [Array] issues
# @returns [String] ready-to-go tag body
# @return [String] Content generated from sections of sorted issues & PRs.
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
sort_into_sections(pull_requests, issues)
@sections.map(&:generate_content).join
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i just learned what Array.map(&:foo) does. slick!

end

# Generates main sections for a tag
# Default sections to used when --configure-sections is not set.
#
# @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
# @return [Array] 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)
Section.new(name: "bugs", prefix: @options[:bug_prefix], labels: @options[:bug_labels], options: @options)
]
end

# This method sorts issues by types
# (bugs, features, or just closed issues) by labels
# Sorts issues and PRs into entry sections by labels and lack of 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
# @return [Nil]
def sort_into_sections(pull_requests, issues)
if @options[:issues]
unmapped_issues = sort_labeled_issues(issues)
add_unmapped_section(unmapped_issues)
end
if @options[:pulls]
unmapped_pull_requests = sort_labeled_issues(pull_requests)
add_unmapped_section(unmapped_pull_requests)
end
nil
end

break if added
end
if @smap["issues"]
@sections.find { |sect| sect.name == "issues" }.issues << dict unless added
# Iterates through sections and sorts labeled issues into them based on
# the label mapping. Returns any unmapped or unlabeled issues.
#
# @param [Array] issues Issues or pull requests.
# @return [Array] Issues that were not mapped into any sections.
def sort_labeled_issues(issues)
sorted_issues = []
issues.each do |issue|
label_names = issue["labels"].collect { |l| l["name"] }

# Add PRs in the order of the @sections array. This will either be the
# default sections followed by any --add-sections sections in
# user-defined order, or --configure-sections in user-defined order.
# Ignore the order of the issue labels from github which cannot be
# controled by the user.
@sections.each do |section|
unless (section.labels & label_names).empty?
section.issues << issue
sorted_issues << issue
break
end
end
end
sort_pull_requests(pull_requests)
issues - sorted_issues
end

# This method iterates through PRs and sorts them into sections
# Creates a section for issues/PRs with no labels or no mapped labels.
#
# @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
# @param [Array] issues
# @return [Nil]
def add_unmapped_section(issues)
unless issues.empty?
# Distinguish between issues and pull requests
if issues.first.key?("pull_request")
name = "merged"
prefix = @options[:merge_prefix]
add_wo_labels = @options[:add_pr_wo_labels]
else
name = "issues"
prefix = @options[:issue_prefix]
add_wo_labels = @options[:add_issues_wo_labels]
end
add_issues = if add_wo_labels
issues
else
# Only add unmapped issues
issues.select { |issue| issue["labels"].any? }
end
merged = Section.new(name: name, prefix: prefix, labels: [], issues: add_issues, options: @options) unless add_issues.empty?
@sections << merged
end
added_pull_requests.each { |req| pull_requests.delete(req) }
@sections
nil
end

def line_labels_for(issue)
Expand Down
2 changes: 1 addition & 1 deletion lib/github_changelog_generator/generator/generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def generate_entry_between_tags(older_tag, newer_tag)
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)
Entry.new(options).generate_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`
Expand Down
29 changes: 23 additions & 6 deletions lib/github_changelog_generator/generator/generator_processor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module GitHubChangelogGenerator
class Generator
# delete all labels with labels from options[:exclude_labels] array
# delete all issues with labels from options[:exclude_labels] array
# @param [Array] issues
# @return [Array] filtered array
def exclude_issues_by_labels(issues)
Expand All @@ -14,6 +14,19 @@ def exclude_issues_by_labels(issues)
end
end

# Only include issues without labels if options[:add_issues_wo_labels]
# @param [Array] issues
# @return [Array] filtered array
def exclude_issues_without_labels(issues)
return issues if issues.empty?
return issues if issues.first.key?("pull_request") && options[:add_pr_wo_labels]
return issues if !issues.first.key?("pull_request") && options[:add_issues_wo_labels]

issues.reject do |issue|
issue["labels"].empty?
end
end

# @return [Array] filtered issues accourding milestone
def filter_by_milestone(filtered_issues, tag_name, all_issues)
remove_issues_in_milestones(filtered_issues)
Expand Down Expand Up @@ -130,33 +143,37 @@ def include_issues_by_labels(issues)
filtered_issues
end

# @return [Array] issues without labels or empty array if add_issues_wo_labels is false
# @param [Array] issues Issues & PRs to filter when without labels
# @return [Array] Issues & PRs without labels or empty array if
# add_issues_wo_labels or add_pr_wo_labels are false
def filter_wo_labels(issues)
if options[:add_issues_wo_labels]
if (!issues.empty? && issues.first.key?("pull_requests") && options[:add_pr_wo_labels]) || options[:add_issues_wo_labels]
issues
else
issues.select { |issue| issue["labels"].map { |l| l["name"] }.any? }
end
end

# @todo Document this
def filter_by_include_labels(issues)
if options[:include_labels].nil?
issues
else
issues.select do |issue|
labels = issue["labels"].map { |l| l["name"] } & options[:include_labels]
labels.any?
labels.any? || issue["labels"].empty?
end
end
end

# General filtered function
#
# @param [Array] all_issues
# @param [Array] all_issues PRs or issues
# @return [Array] filtered issues
def filter_array_by_labels(all_issues)
filtered_issues = include_issues_by_labels(all_issues)
exclude_issues_by_labels(filtered_issues)
filtered_issues = exclude_issues_by_labels(filtered_issues)
exclude_issues_without_labels(filtered_issues)
end

# Filter issues according labels
Expand Down
4 changes: 2 additions & 2 deletions lib/github_changelog_generator/generator/section.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ def initialize(opts = {})
@options = opts[:options] || Options.new({})
end

# @param [Array] issues List of issues on sub-section
# @param [String] prefix Name of sub-section
# Returns the content of a section.
#
# @return [String] Generate section content
def generate_content
content = ""
Expand Down
4 changes: 2 additions & 2 deletions lib/github_changelog_generator/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,10 @@ def self.setup_parser(options)
opts.on("--[no-]compare-link", "Include compare link (Full Changelog) between older version and newer version. Default is true") do |v|
options[:compare_link] = v
end
opts.on("--include-labels x,y,z", Array, "Only issues with the specified labels will be included in the changelog.") do |list|
opts.on("--include-labels x,y,z", Array, "Of the labeled issues, only include the ones with the given labels.") do |list|
options[:include_labels] = list
end
opts.on("--exclude-labels x,y,z", Array, "Issues with the specified labels will be always excluded from changelog. Default is 'duplicate,question,invalid,wontfix'") do |list|
opts.on("--exclude-labels x,y,z", Array, "Issues with the specified labels will be excluded from changelog. Default is 'duplicate,question,invalid,wontfix'") do |list|
options[:exclude_labels] = list
end
opts.on("--bug-labels x,y,z", Array, 'Issues with the specified labels will be always added to "Fixed bugs" section. Default is \'bug,Bug\'') do |list|
Expand Down
Loading