class GitHubChangelogGenerator::Generator
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
Constants
- CREDIT_LINE
Attributes
Public Class Methods
A Generator
responsible for all logic, related with changelog generation from ready-to-parse issues
Example:
generator = GitHubChangelogGenerator::Generator.new content = generator.compound_changelog
# File lib/github_changelog_generator/generator/generator.rb, line 40 def initialize(options = {}) @options = options @tag_times_hash = {} @fetcher = GitHubChangelogGenerator::OctoFetcher.new(options) @sections = [] end
Public Instance Methods
Adds a key “first_occurring_tag” to each PR with a value of the oldest tag that a PR's merge commit occurs in in the git history. This should indicate the release of each PR by git's history regardless of dates and divergent branches.
@param [Array] tags The tags sorted by time, newest to oldest. @param [Array] prs The PRs to discover the tags of. @return [Nil] No return; PRs are updated in-place.
# File lib/github_changelog_generator/generator/generator_fetcher.rb, line 45 def add_first_occurring_tag_to_prs(tags, prs) total = prs.count prs_left = associate_tagged_prs(tags, prs, total) prs_left = associate_release_branch_prs(prs_left, total) prs_left = associate_rebase_comment_prs(tags, prs_left, total) if prs_left.any? # PRs in prs_left will be untagged, not in release branch, and not # rebased. They should not be included in the changelog as they probably # have been merged to a branch other than the release branch. @pull_requests -= prs_left Helper.log.info "Associating PRs with tags: #{total}/#{total}" end
Associate merged PRs by the SHA detected in github comments of the form “rebased commit: <sha>”. For use when the merge_commit_sha is not in the actual git history due to rebase.
@param [Array] tags The tags sorted by time, newest to oldest. @param [Array] prs_left The PRs not yet associated with any tag or branch. @return [Array] PRs without rebase comments.
# File lib/github_changelog_generator/generator/generator_fetcher.rb, line 126 def associate_rebase_comment_prs(tags, prs_left, total) i = total - prs_left.count # Any remaining PRs were not found in the list of tags by their merge # commit and not found in any specified release branch. Fallback to # rebased commit comment. @fetcher.fetch_comments_async(prs_left) prs_left.reject do |pr| found = false if pr["comments"] && (rebased_comment = pr["comments"].reverse.find { |c| c["body"].match(%r{rebased commit: ([0-9a-f]{40})}i) }) rebased_sha = rebased_comment["body"].match(%r{rebased commit: ([0-9a-f]{40})}i)[1] if (oldest_tag = tags.reverse.find { |tag| tag["shas_in_tag"].include?(rebased_sha) }) pr["first_occurring_tag"] = oldest_tag["name"] found = true i += 1 elsif sha_in_release_branch?(rebased_sha) found = true i += 1 else raise StandardError, "PR #{pr['number']} has a rebased SHA comment but that SHA was not found in the release branch or any tags" end print("Associating PRs with tags: #{i}/#{total}\r") if @options[:verbose] else puts "Warning: PR #{pr['number']} merge commit was not found in the release branch or tagged git history and no rebased SHA comment was found" end found end end
Associate merged PRs by the HEAD of the release branch. If no –release-branch was specified, then the github default branch is used.
@param [Array] prs_left PRs not associated with any tag. @param [Integer] total The total number of PRs to associate; used for verbose printing. @return [Array] PRs without their merge_commit_sha in the branch.
# File lib/github_changelog_generator/generator/generator_fetcher.rb, line 102 def associate_release_branch_prs(prs_left, total) if prs_left.any? i = total - prs_left.count prs_left.reject do |pr| found = false if pr["events"] && (event = pr["events"].find { |e| e["event"] == "merged" }) && sha_in_release_branch?(event["commit_id"]) found = true i += 1 print("Associating PRs with tags: #{i}/#{total}\r") if @options[:verbose] end found end else prs_left end end
Associate merged PRs by the merge SHA contained in each tag. If the merge_commit_sha is not found in any tag's history, skip association.
@param [Array] tags The tags sorted by time, newest to oldest. @param [Array] prs The PRs to associate. @return [Array] PRs without their merge_commit_sha in a tag.
# File lib/github_changelog_generator/generator/generator_fetcher.rb, line 64 def associate_tagged_prs(tags, prs, total) @fetcher.fetch_tag_shas(tags) i = 0 prs.reject do |pr| found = false # XXX Wish I could use merge_commit_sha, but gcg doesn't currently # fetch that. See # https://developer.github.com/v3/pulls/#get-a-single-pull-request vs. # https://developer.github.com/v3/pulls/#list-pull-requests if pr["events"] && (event = pr["events"].find { |e| e["event"] == "merged" }) # Iterate tags.reverse (oldest to newest) to find first tag of each PR. if (oldest_tag = tags.reverse.find { |tag| tag["shas_in_tag"].include?(event["commit_id"]) }) pr["first_occurring_tag"] = oldest_tag["name"] found = true i += 1 print("Associating PRs with tags: #{i}/#{total}\r") if @options[:verbose] end else # Either there were no events or no merged event. Github's api can be # weird like that apparently. Check for a rebased comment before erroring. no_events_pr = associate_rebase_comment_prs(tags, [pr], total) raise StandardError, "No merge sha found for PR #{pr['number']} via the GitHub API" unless no_events_pr.empty? found = true i += 1 print("Associating PRs with tags: #{i}/#{total}\r") if @options[:verbose] end found end end
@param [Array] section_tags are the tags that need a subsection output @param [Array] filtered_tags
is the list of filtered tags ordered from newest -> oldest @return [Hash] key is the tag to output, value is an array of [Left Tag, Right Tag] PRs to include in this section will be >= [Left Tag Date] and <= [Right Tag Date] rubocop:disable Style/For - for allows us to be more concise
# File lib/github_changelog_generator/generator/generator_tags.rb, line 27 def build_tag_section_mapping(section_tags, filtered_tags) tag_mapping = {} for i in 0..(section_tags.length - 1) tag = section_tags[i] # Don't create section header for the "since" tag next if since_tag && tag["name"] == since_tag # Don't create a section header for the first tag in between_tags next if options[:between_tags] && tag == section_tags.last # Don't create a section header for excluded tags next unless filtered_tags.include?(tag) older_tag = section_tags[i + 1] tag_mapping[tag] = [older_tag, tag] end tag_mapping end
Main function to start changelog generation
@return [String] Generated changelog file
# File lib/github_changelog_generator/generator/generator.rb, line 50 def compound_changelog @options.load_custom_ruby_files Sync do fetch_and_filter_tags fetch_issues_and_pr 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]) log = remove_old_fixed_string(log) log = insert_fixed_string(log) @log = log end end
Method filter issues, that belong only specified tag range @param [Array] issues issues to filter @param [Symbol] hash_key key of date value default is :actual_date @param [Hash, Nil] older_tag all issues before this tag date will be excluded. May be nil, if it's first tag @param [Hash, Nil] newer_tag all issue after this tag will be excluded. May be nil for unreleased section @return [Array] filtered issues
# File lib/github_changelog_generator/generator/generator_processor.rb, line 99 def delete_by_time(issues, hash_key = "actual_date", older_tag = nil, newer_tag = nil) # in case if not tags specified - return unchanged array return issues if older_tag.nil? && newer_tag.nil? older_tag = ensure_older_tag(older_tag, newer_tag) newer_tag_time = newer_tag && get_time_of_tag(newer_tag) older_tag_time = older_tag && get_time_of_tag(older_tag) issues.select do |issue| if issue[hash_key] time = Time.parse(issue[hash_key].to_s).utc tag_in_range_old = tag_newer_old_tag?(older_tag_time, time) tag_in_range_new = tag_older_new_tag?(newer_tag_time, time) tag_in_range = tag_in_range_old && tag_in_range_new tag_in_range else false end end end
Find correct closed dates, if issues was closed by commits
# File lib/github_changelog_generator/generator/generator_fetcher.rb, line 26 def detect_actual_closed_dates(issues) print "Fetching closed dates for issues...\r" if options[:verbose] i = 0 issues.each do |issue| find_closed_date_by_commit(issue) i += 1 end puts "Fetching closed dates for issues: #{i}/#{issues.count}" if options[:verbose] end
Detect link, name and time for specified tag.
@param [Hash] newer_tag newer tag. Can be nil, if it's Unreleased section. @return [Array] link, name and time of the tag
# File lib/github_changelog_generator/generator/generator_tags.rb, line 79 def detect_link_tag_time(newer_tag) # if tag is nil - set current time newer_tag_time = newer_tag.nil? ? Time.new.getutc : get_time_of_tag(newer_tag) # if it's future release tag - set this value if newer_tag.nil? && options[:future_release] newer_tag_name = options[:future_release] newer_tag_link = options[:future_release] else # put unreleased label if there is no name for the tag newer_tag_name = newer_tag.nil? ? options[:unreleased_label] : newer_tag["name"] newer_tag_link = newer_tag.nil? ? "HEAD" : newer_tag_name end [newer_tag_link, newer_tag_name, newer_tag_time] end
# File lib/github_changelog_generator/generator/generator_tags.rb, line 100 def due_tag @due_tag ||= options.fetch(:due_tag, nil) end
# File lib/github_changelog_generator/generator/generator_processor.rb, line 125 def ensure_older_tag(older_tag, newer_tag) return older_tag if older_tag idx = sorted_tags.index { |t| t["name"] == newer_tag["name"] } # skip if we are already at the oldest element return if idx == sorted_tags.size - 1 sorted_tags[idx - 1] end
delete all issues with labels from options array @param [Array] issues @return [Array] filtered array
# File lib/github_changelog_generator/generator/generator_processor.rb, line 8 def exclude_issues_by_labels(issues) return issues if !options[:exclude_labels] || options[:exclude_labels].empty? issues.reject do |issue| labels = issue["labels"].map { |l| l["name"] } (labels & options[:exclude_labels]).any? end end
Only include issues without labels if options @param [Array] issues @return [Array] filtered array
# File lib/github_changelog_generator/generator/generator_processor.rb, line 20 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
Fetch event for issues and pull requests @return [Array] array of fetched issues
# File lib/github_changelog_generator/generator/generator_fetcher.rb, line 7 def fetch_events_for_issues_and_pr print "Fetching events for issues and PR: 0/#{@issues.count + @pull_requests.count}\r" if options[:verbose] # Async fetching events: @fetcher.fetch_events_async(@issues + @pull_requests) end
General filtered function
@param [Array] all_issues PRs or issues @return [Array] filtered issues
# File lib/github_changelog_generator/generator/generator_processor.rb, line 189 def filter_array_by_labels(all_issues) filtered_issues = include_issues_by_labels(all_issues) filtered_issues = exclude_issues_by_labels(filtered_issues) exclude_issues_without_labels(filtered_issues) end
@todo Document this @param [Object] issues
# File lib/github_changelog_generator/generator/generator_processor.rb, line 174 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? || issue["labels"].empty? end end end
@return [Array] filtered issues accourding milestone
# File lib/github_changelog_generator/generator/generator_processor.rb, line 31 def filter_by_milestone(filtered_issues, tag_name, all_issues) remove_issues_in_milestones(filtered_issues) unless tag_name.nil? # add missed issues (according milestones) issues_to_add = find_issues_to_add(all_issues, tag_name) filtered_issues |= issues_to_add end filtered_issues end
Method filter issues, that belong only specified tag range
@param [Array] issues issues to filter @param [Hash, Nil] newer_tag Tag to find PRs of. May be nil for unreleased section @return [Array] filtered issues
# File lib/github_changelog_generator/generator/generator_processor.rb, line 87 def filter_by_tag(issues, newer_tag = nil) issues.select do |issue| issue["first_occurring_tag"] == (newer_tag.nil? ? nil : newer_tag["name"]) end end
@param [Array] all_tags all tags @return [Array] filtered tags according :due_tag option
# File lib/github_changelog_generator/generator/generator_tags.rb, line 141 def filter_due_tag(all_tags) filtered_tags = all_tags tag = due_tag if tag if all_tags.any? && all_tags.map { |t| t["name"] }.include?(tag) idx = all_tags.index { |t| t["name"] == tag } filtered_tags = if idx > 0 all_tags[(idx + 1)..-1] else [] end else raise ChangelogGeneratorError, "Error: can't find tag #{tag}, specified with --due-tag option." end end filtered_tags end
This method filter only merged PR and fetch missing required attributes for pull requests :merged_at - is a date, when issue PR was merged. More correct to use merged date, rather than closed date.
# File lib/github_changelog_generator/generator/generator_processor.rb, line 218 def filter_merged_pull_requests(pull_requests) print "Fetching merged dates...\r" if options[:verbose] closed_pull_requests = @fetcher.fetch_closed_pull_requests pull_requests.each do |pr| fetched_pr = closed_pull_requests.find do |fpr| fpr["number"] == pr["number"] end if fetched_pr pr["merged_at"] = fetched_pr["merged_at"] closed_pull_requests.delete(fetched_pr) end end pull_requests.reject! do |pr| pr["merged_at"].nil? end pull_requests end
@param [Array] all_tags all tags @return [Array] filtered tags according :since_tag option
# File lib/github_changelog_generator/generator/generator_tags.rb, line 121 def filter_since_tag(all_tags) filtered_tags = all_tags tag = since_tag if tag if all_tags.map { |t| t["name"] }.include? tag idx = all_tags.index { |t| t["name"] == tag } filtered_tags = if idx all_tags[0..idx] else [] end else raise ChangelogGeneratorError, "Error: can't find tag #{tag}, specified with --since-tag option." end end filtered_tags end
@param [Array] items 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
# File lib/github_changelog_generator/generator/generator_processor.rb, line 162 def filter_wo_labels(items) if items.any? && items.first.key?("pull_request") return items if options[:add_pr_wo_labels] elsif options[:add_issues_wo_labels] return items end # The default is to filter items without labels items.select { |item| item["labels"].map { |l| l["name"] }.any? } end
Fill :actual_date parameter of specified issue by closed date of the commit, if it was closed by commit. @param [Hash] issue
# File lib/github_changelog_generator/generator/generator_fetcher.rb, line 156 def find_closed_date_by_commit(issue) unless issue["events"].nil? # if it's PR -> then find "merged event", in case of usual issue -> fond closed date compare_string = issue["merged_at"].nil? ? "closed" : "merged" # reverse! - to find latest closed event. (event goes in date order) issue["events"].reverse!.each do |event| if event["event"].eql? compare_string set_date_from_event(event, issue) break end end end # TODO: assert issues, that remain without 'actual_date' hash for some reason. end
Add all issues, that should be in that tag, according milestone
@param [Array] all_issues @param [String] tag_name @return [Array] issues with milestone tag_name
# File lib/github_changelog_generator/generator/generator_processor.rb, line 47 def find_issues_to_add(all_issues, tag_name) all_issues.select do |issue| if issue["milestone"].nil? false else # check, that this milestone in tag list: milestone_is_tag = @filtered_tags.find do |tag| tag["name"] == issue["milestone"]["title"] end if milestone_is_tag.nil? false else issue["milestone"]["title"] == tag_name end end end end
Filter issues according labels @return [Array] Filtered issues
# File lib/github_changelog_generator/generator/generator_processor.rb, line 197 def get_filtered_issues(issues) issues = filter_array_by_labels(issues) puts "Filtered issues: #{issues.count}" if options[:verbose] issues end
This method fetches missing params for PR and filter them by specified options It include add all PR's with labels from options array And exclude all from :exclude_labels array. @return [Array] filtered PR's
# File lib/github_changelog_generator/generator/generator_processor.rb, line 207 def get_filtered_pull_requests(pull_requests) pull_requests = filter_array_by_labels(pull_requests) pull_requests = filter_merged_pull_requests(pull_requests) puts "Filtered pull requests: #{pull_requests.count}" if options[:verbose] pull_requests end
Returns date for given GitHub Tag hash
Memoize the date by tag name.
@param [Hash] tag_name
@return [Time] time of specified tag
# File lib/github_changelog_generator/generator/generator_tags.rb, line 63 def get_time_of_tag(tag_name) raise ChangelogGeneratorError, "tag_name is nil" if tag_name.nil? name_of_tag = tag_name.fetch("name") time_for_tag_name = @tag_times_hash[name_of_tag] return time_for_tag_name if time_for_tag_name @fetcher.fetch_date_of_tag(tag_name).tap do |time_string| @tag_times_hash[name_of_tag] = time_string end end
Include issues with labels, specified in :include_labels @param [Array] issues to filter @return [Array] filtered array of issues
# File lib/github_changelog_generator/generator/generator_processor.rb, line 154 def include_issues_by_labels(issues) filtered_issues = filter_by_include_labels(issues) filter_wo_labels(filtered_issues) end
@return [Array] array with removed issues, that contain milestones with same name as a tag
# File lib/github_changelog_generator/generator/generator_processor.rb, line 67 def remove_issues_in_milestones(filtered_issues) filtered_issues.select! do |issue| # leave issues without milestones if issue["milestone"].nil? true # remove issues of open milestones if option is set elsif issue["milestone"]["state"] == "open" @options[:issues_of_open_milestones] else # check, that this milestone in tag list: @filtered_tags.find { |tag| tag["name"] == issue["milestone"]["title"] }.nil? end end end
Set closed date from this issue
@param [Hash] event @param [Hash] issue
# File lib/github_changelog_generator/generator/generator_fetcher.rb, line 175 def set_date_from_event(event, issue) if event["commit_id"].nil? issue["actual_date"] = issue["closed_at"] else begin commit = @fetcher.fetch_commit(event["commit_id"]) issue["actual_date"] = commit["commit"]["author"]["date"] # issue['actual_date'] = commit['author']['date'] rescue StandardError puts "Warning: Can't fetch commit #{event['commit_id']}. It is probably referenced from another repo." issue["actual_date"] = issue["closed_at"] end end end
@return [Object] try to find newest tag using #Reader and :base option if specified otherwise returns nil
# File lib/github_changelog_generator/generator/generator_tags.rb, line 96 def since_tag @since_tag ||= options.fetch(:since_tag) { version_of_first_item } end
# File lib/github_changelog_generator/generator/generator_processor.rb, line 143 def tag_newer_old_tag?(older_tag_time, time) if older_tag_time.nil? true else time > older_tag_time end end
# File lib/github_changelog_generator/generator/generator_processor.rb, line 135 def tag_older_new_tag?(newer_tag_time, time) if newer_tag_time.nil? true else time <= newer_tag_time end end
# File lib/github_changelog_generator/generator/generator_tags.rb, line 104 def version_of_first_item return unless File.file?(options[:base].to_s) sections = GitHubChangelogGenerator::Reader.new.read(options[:base]) sections.first["version"] if sections && sections.any? end
Private Instance Methods
Fetches @pull_requests and @issues and filters them based on options.
@return [Nil] No return.
# File lib/github_changelog_generator/generator/generator.rb, line 143 def fetch_issues_and_pr issues, pull_requests = @fetcher.fetch_closed_issues_and_pr @pull_requests = options[:pulls] ? get_filtered_pull_requests(pull_requests) : [] @issues = options[:issues] ? get_filtered_issues(issues) : [] fetch_events_for_issues_and_pr detect_actual_closed_dates(@issues + @pull_requests) add_first_occurring_tag_to_prs(@sorted_tags, @pull_requests) nil end
# File lib/github_changelog_generator/generator/generator.rb, line 130 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
Add template messages to given string. Previously added messages of the same wording are removed. @param log [String]
# File lib/github_changelog_generator/generator/generator.rb, line 168 def insert_fixed_string(log) ins = "" ins += @options[:frontmatter] if @options[:frontmatter] ins += "#{@options[:header]}\n\n" log.insert(0, ins) log += "\n\n#{CREDIT_LINE}" log end
Remove the previously assigned fixed message. @param log [String] Old lines are fixed
# File lib/github_changelog_generator/generator/generator.rb, line 158 def remove_old_fixed_string(log) log.gsub!(/#{Regexp.escape(@options[:frontmatter])}/, "") if @options[:frontmatter] log.gsub!(/#{Regexp.escape(@options[:header])}\n{,2}/, "") log.gsub!(/\n{,2}#{Regexp.escape(CREDIT_LINE)}/, "") # Remove old credit lines log end
Detect if a sha occurs in the –release-branch. Uses the github repo default branch if not specified.
@param [String] sha SHA to check. @return [Boolean] True if SHA is in the branch git history.
# File lib/github_changelog_generator/generator/generator_fetcher.rb, line 198 def sha_in_release_branch?(sha) branch = @options[:release_branch] || @fetcher.default_branch @fetcher.commits_in_branch(branch).include?(sha) end
# File lib/github_changelog_generator/generator/generator_tags.rb, line 209 def warn_if_nonmatching_regex(all_tags, regex, regex_option_name) unless all_tags.map { |t| t["name"] }.any? { |t| regex =~ t } Helper.log.warn "Warning: unable to reject any tag, using regex "\ "#{regex.inspect} in #{regex_option_name} option." end end
# File lib/github_changelog_generator/generator/generator_tags.rb, line 216 def warn_if_tag_not_found(all_tags, tag) Helper.log.warn("Warning: can't find tag #{tag}, specified with --exclude-tags option.") unless all_tags.map { |t| t["name"] }.include?(tag) end