diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index b72ff7b..d033c94 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,68 +1,46 @@ # This configuration was generated by `rubocop --auto-gen-config` -# on 2015-05-14 16:51:06 +0300 using RuboCop version 0.31.0. +# on 2015-05-25 12:59:32 +0300 using RuboCop version 0.31.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 15 +# Offense count: 16 Metrics/AbcSize: - Enabled: false + Max: 68 -# Offense count: 2 -Metrics/BlockNesting: - Max: 4 - -# Offense count: 3 +# Offense count: 4 # Configuration parameters: CountComments. Metrics/ClassLength: - Max: 337 + Enabled: false -# Offense count: 5 +# Offense count: 3 Metrics/CyclomaticComplexity: - Max: 15 + Max: 9 # Offense count: 22 # Configuration parameters: CountComments. Metrics/MethodLength: - Enabled: false - -# Offense count: 5 -Metrics/PerceivedComplexity: - Max: 18 + Max: 117 # Offense count: 4 +Metrics/PerceivedComplexity: + Max: 12 + +# Offense count: 2 Style/AccessorMethodName: Enabled: false -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles. -Style/AndOr: - Enabled: false - -# Offense count: 19 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, SupportedStyles, ProceduralMethods, FunctionalMethods, IgnoredMethods. -Style/BlockDelimiters: - Enabled: false - -# Offense count: 4 +# Offense count: 6 Style/Documentation: Enabled: false -# Offense count: 5 +# Offense count: 1 # Configuration parameters: MinBodyLength. Style/GuardClause: Enabled: false -# Offense count: 15 -# Cop supports --auto-correct. -# Configuration parameters: MaxLineLength. -Style/IfUnlessModifier: - Enabled: false - -# Offense count: 2 +# Offense count: 1 # Configuration parameters: EnforcedStyle, MinBodyLength, SupportedStyles. Style/Next: Enabled: false diff --git a/bin/github_changelog_generator b/bin/github_changelog_generator index f0c5fd4..5d0d48a 100755 --- a/bin/github_changelog_generator +++ b/bin/github_changelog_generator @@ -1,4 +1,4 @@ #! /usr/bin/env ruby require_relative "../lib/github_changelog_generator" -GitHubChangelogGenerator::ChangelogGenerator.new.compound_changelog +GitHubChangelogGenerator::ChangelogGenerator.new.run diff --git a/lib/CHANGELOG.md b/lib/CHANGELOG.md index a261d8f..3c6e8bd 100644 --- a/lib/CHANGELOG.md +++ b/lib/CHANGELOG.md @@ -1,8 +1,12 @@ # Change Log -## [Unreleased](https://github.com/skywinder/changelog_test/tree/HEAD) +## [0.0.4](https://github.com/skywinder/changelog_test/tree/0.0.4) (2015-05-22) -[Full Changelog](https://github.com/skywinder/changelog_test/compare/v0.0.3...HEAD) +[Full Changelog](https://github.com/skywinder/changelog_test/compare/v0.0.3...0.0.4) + +**Closed issues:** + +- Test issue, that should appear in 0.0.4 [\#3](https://github.com/skywinder/changelog_test/issues/3) **Merged pull requests:** diff --git a/lib/github_changelog_generator.rb b/lib/github_changelog_generator.rb index 8a02b9a..c35db79 100755 --- a/lib/github_changelog_generator.rb +++ b/lib/github_changelog_generator.rb @@ -6,517 +6,34 @@ require "colorize" require "benchmark" require_relative "github_changelog_generator/parser" -require_relative "github_changelog_generator/generator" +require_relative "github_changelog_generator/generator/generator" require_relative "github_changelog_generator/version" require_relative "github_changelog_generator/reader" -require_relative "github_changelog_generator/fetcher" +# The main module, where placed all classes (now, at least) module GitHubChangelogGenerator - # Default error for ChangelogGenerator - class ChangelogGeneratorError < StandardError - end - # Main class and entry point for this script. class ChangelogGenerator - attr_accessor :options, :all_tags, :github - # Class, responsible for whole change log generation cycle # @return initialised instance of ChangelogGenerator def initialize @options = Parser.parse_options - - @fetcher = GitHubChangelogGenerator::Fetcher.new @options - @generator = Generator.new @options - - # @all_tags = get_filtered_tags - @all_tags = @fetcher.get_all_tags - - # TODO: refactor this double asssign of @issues and @pull_requests and move all logic in one method - @issues, @pull_requests = @fetcher.fetch_closed_issues_and_pr - - @pull_requests = @options[:pulls] ? get_filtered_pull_requests : [] - - @issues = @options[:issues] ? get_filtered_issues : [] - - fetch_event_for_issues_and_pr - detect_actual_closed_dates - end - - # Return tags after filtering tags in lists provided by option: --between-tags & --exclude-tags - # - # @return [Array] - def get_filtered_tags - all_tags = @fetcher.get_all_tags - filtered_tags = [] - if @options[:between_tags] - @options[:between_tags].each do |tag| - unless all_tags.include? tag - puts "Warning: can't find tag #{tag}, specified with --between-tags option.".yellow - end - end - filtered_tags = all_tags.select { |tag| @options[:between_tags].include? tag } - end - filtered_tags - end - - def detect_actual_closed_dates - if @options[:verbose] - print "Fetching closed dates for issues...\r" - end - - threads = [] - - @issues.each { |issue| - threads << Thread.new { - find_closed_date_by_commit(issue) - } - } - - @pull_requests.each { |pull_request| - threads << Thread.new { - find_closed_date_by_commit(pull_request) - } - } - threads.each(&:join) - - if @options[:verbose] - puts "Fetching closed dates for issues: Done!" - end - end - - # Fill :actual_date parameter of specified issue by closed date of the commit, it it was closed by commit. - # @param [Hash] issue - 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 { |event| - if event[:event].eql? compare_string - if event[:commit_id].nil? - issue[:actual_date] = issue[:closed_at] - else - begin - commit = @fetcher.fetch_commit(event) - issue[:actual_date] = commit[:author][:date] - rescue - puts "Warning: Can't fetch commit #{event[:commit_id]}. It is probably referenced from another repo.".yellow - issue[:actual_date] = issue[:closed_at] - end - end - break - end - } - end - # TODO: assert issues, that remain without 'actual_date' hash for some reason. - end - - def print_json(json) - puts JSON.pretty_generate(json) - end - - # This method fetches missing params for PR and filter them by specified options - # It include add all PR's with labels from @options[:include_labels] array - # And exclude all from :exclude_labels array. - # @return [Array] filtered PR's - def get_filtered_pull_requests - filter_merged_pull_requests - - filtered_pull_requests = include_issues_by_labels(@pull_requests) - - filtered_pull_requests = exclude_issues_by_labels(filtered_pull_requests) - - if @options[:verbose] - puts "Filtered pull requests: #{filtered_pull_requests.count}" - end - - filtered_pull_requests - 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. - def filter_merged_pull_requests - if @options[:verbose] - print "Fetching merged dates...\r" - end - pull_requests = @fetcher.fetch_closed_pull_requests - - @pull_requests.each { |pr| - fetched_pr = pull_requests.find { |fpr| - fpr.number == pr.number - } - pr[:merged_at] = fetched_pr[:merged_at] - pull_requests.delete(fetched_pr) - } - - @pull_requests.select! do |pr| - !pr[:merged_at].nil? - end - end - - # Include issues with labels, specified in :include_labels - # @param [Array] issues to filter - # @return [Array] filtered array of issues - def include_issues_by_labels(issues) - filtered_issues = @options[:include_labels].nil? ? issues : issues.select { |issue| (issue.labels.map(&:name) & @options[:include_labels]).any? } - - if @options[:add_issues_wo_labels] - issues_wo_labels = issues.select { |issue| - !issue.labels.map(&:name).any? - } - filtered_issues |= issues_wo_labels - end - filtered_issues - end - - # delete all labels with labels from @options[:exclude_labels] array - # @param [Array] issues - # @return [Array] filtered array - def exclude_issues_by_labels(issues) - unless @options[:exclude_labels].nil? - issues = issues.select { |issue| - !(issue.labels.map(&:name) & @options[:exclude_labels]).any? - } - end - issues end # The entry point of this script to generate change log # @raise (ChangelogGeneratorError) Is thrown when one of specified tags was not found in list of tags. - def compound_changelog - log = "# Change Log\n\n" - - if @options[:unreleased_only] - log += generate_log_between_tags(all_tags[0], nil) - elsif @options[:tag1] and @options[:tag2] - tag1 = @options[:tag1] - tag2 = @options[:tag2] - tags_strings = [] - all_tags.each { |x| tags_strings.push(x["name"]) } - - if tags_strings.include?(tag1) - if tags_strings.include?(tag2) - to_a = tags_strings.map.with_index.to_a - hash = Hash[to_a] - index1 = hash[tag1] - index2 = hash[tag2] - log += generate_log_between_tags(all_tags[index1], all_tags[index2]) - else - fail ChangelogGeneratorError, "Can't find tag #{tag2} -> exit".red - end - else - fail ChangelogGeneratorError, "Can't find tag #{tag1} -> exit".red - end - else - log += generate_log_for_all_tags - end - - log += "\n\n\\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)*" + def run + log = @generator.compound_changelog output_filename = "#{@options[:output]}" File.open(output_filename, "w") { |file| file.write(log) } puts "Done!" puts "Generated log placed in #{Dir.pwd}/#{output_filename}" end - - # The full cycle of generation for whole project - # @return [String] The complete change log - def generate_log_for_all_tags - fetch_tags_dates - - if @options[:verbose] - puts "Sorting tags..." - end - - @all_tags.sort_by! { |x| @fetcher.get_time_of_tag(x) }.reverse! - - if @options[:verbose] - puts "Generating log..." - end - - log = "" - - if @options[:unreleased] && @all_tags.count != 0 - unreleased_log = generate_log_between_tags(all_tags[0], nil) - if unreleased_log - log += unreleased_log - end - end - - (1...all_tags.size).each { |index| - log += generate_log_between_tags(all_tags[index], all_tags[index - 1]) - } - if @all_tags.count != 0 - log += generate_log_between_tags(nil, all_tags.last) - end - - log - end - - # Async fetching of all tags dates - def fetch_tags_dates - if @options[:verbose] - print "Fetching tag dates...\r" - end - - # Async fetching tags: - threads = [] - i = 0 - all = @all_tags.count - @all_tags.each { |tag| - threads << Thread.new { - @fetcher.get_time_of_tag(tag) - if @options[:verbose] - print "Fetching tags dates: #{i + 1}/#{all}\r" - i += 1 - end - } - } - - print " \r" - - threads.each(&:join) - - if @options[:verbose] - puts "Fetching tags dates: #{i}" - end - 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_pull_requests = delete_by_time(@pull_requests, :actual_date, older_tag, newer_tag) - filtered_issues = delete_by_time(@issues, :actual_date, older_tag, newer_tag) - - newer_tag_name = newer_tag.nil? ? nil : newer_tag["name"] - older_tag_name = older_tag.nil? ? nil : older_tag["name"] - - if @options[:filter_issues_by_milestone] - # delete excess irrelevant issues (according milestones) - 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 - - if filtered_issues.empty? && filtered_pull_requests.empty? && newer_tag.nil? - # do not generate empty unreleased section - return "" - end - - create_log(filtered_pull_requests, filtered_issues, newer_tag, older_tag_name) - end - - def filter_by_milestone(filtered_issues, newer_tag_name, src_array) - filtered_issues.select! { |issue| - # leave issues without milestones - if issue.milestone.nil? - true - else - # check, that this milestone in tag list: - @all_tags.find { |tag| tag.name == issue.milestone.title }.nil? - end - } - unless newer_tag_name.nil? - - # add missed issues (according milestones) - issues_to_add = src_array.select { |issue| - if issue.milestone.nil? - false - else - # check, that this milestone in tag list: - milestone_is_tag = @all_tags.find { |tag| - tag.name == issue.milestone.title - } - - if milestone_is_tag.nil? - false - else - issue.milestone.title == newer_tag_name - end - end - } - - filtered_issues |= issues_to_add - end - filtered_issues - end - - # Method filter issues, that belong only specified tag range - # @param [Array] array of issues to filter - # @param [Symbol] hash_key key of date value default is :actual_date - # @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 - # @return [Array] filtered issues - def delete_by_time(array, hash_key = :actual_date, older_tag = nil, newer_tag = nil) - fail ChangelogGeneratorError, "At least one of the tags should be not nil!".red if older_tag.nil? && newer_tag.nil? - - newer_tag_time = newer_tag && @fetcher.get_time_of_tag(newer_tag) - older_tag_time = older_tag && @fetcher.get_time_of_tag(older_tag) - - array.select { |req| - if req[hash_key] - t = Time.parse(req[hash_key]).utc - - if older_tag_time.nil? - tag_in_range_old = true - else - tag_in_range_old = t > older_tag_time - end - - if newer_tag_time.nil? - tag_in_range_new = true - else - tag_in_range_new = t <= newer_tag_time - end - - tag_in_range = (tag_in_range_old) && (tag_in_range_new) - - tag_in_range - else - false - end - } - 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 [String] older_tag_name Older tag, used for the links. Could be nil for last tag. - # @return [String] Ready and parsed section - def create_log(pull_requests, issues, newer_tag, older_tag_name = nil) - newer_tag_time = newer_tag.nil? ? Time.new : @fetcher.get_time_of_tag(newer_tag) - if newer_tag.nil? && @options[:future_release] - newer_tag_name = @options[:future_release] - newer_tag_link = @options[:future_release] - else - newer_tag_name = newer_tag.nil? ? @options[:unreleased_label] : newer_tag["name"] - newer_tag_link = newer_tag.nil? ? "HEAD" : newer_tag_name - end - - github_site = options[:github_site] || "https://github.com" - project_url = "#{github_site}/#{@options[:user]}/#{@options[:project]}" - - log = generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name, project_url) - - if @options[:issues] - # Generate issues: - issues_a = [] - enhancement_a = [] - bugs_a = [] - - issues.each { |dict| - added = false - dict.labels.each { |label| - if label.name == "bug" - bugs_a.push dict - added = true - next - end - if label.name == "enhancement" - enhancement_a.push dict - added = true - next - end - } - unless added - issues_a.push dict - end - } - - log += generate_sub_section(enhancement_a, @options[:enhancement_prefix]) - log += generate_sub_section(bugs_a, @options[:bug_prefix]) - log += generate_sub_section(issues_a, @options[:issue_prefix]) - end - - if @options[:pulls] - # Generate pull requests: - log += generate_sub_section(pull_requests, @options[:merge_prefix]) - end - - log - end - - # @param [Array] issues List of issues on sub-section - # @param [String] prefix Nae of sub-section - # @return [String] Generate ready-to-go sub-section - def generate_sub_section(issues, prefix) - log = "" - - if options[:simple_list] != true && issues.any? - log += "#{prefix}\n\n" - end - - if issues.any? - issues.each { |issue| - merge_string = @generator.get_string_for_issue(issue) - log += "- #{merge_string}\n\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[:dateformat] - - # Generate tag name and link - if newer_tag_name.equal? @options[:unreleased_label] - log += "## [#{newer_tag_name}](#{project_url}/tree/#{newer_tag_link})\n\n" - else - log += "## [#{newer_tag_name}](#{project_url}/tree/#{newer_tag_link}) (#{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 - - # Filter issues according labels - # @return [Array] Filtered issues - def get_filtered_issues - filtered_issues = include_issues_by_labels(@issues) - - filtered_issues = exclude_issues_by_labels(filtered_issues) - - if @options[:verbose] - puts "Filtered issues: #{filtered_issues.count}" - end - - filtered_issues - end - - # Fetch event for issues and pull requests - # @return [Array] array of fetched issues - def fetch_event_for_issues_and_pr - if @options[:verbose] - print "Fetching events for issues and PR: 0/#{@issues.count + @pull_requests.count}\r" - end - - # Async fetching events: - - @fetcher.fetch_events_async(@issues + @pull_requests) - end end if __FILE__ == $PROGRAM_NAME - GitHubChangelogGenerator::ChangelogGenerator.new.compound_changelog + GitHubChangelogGenerator::ChangelogGenerator.new.run end end diff --git a/lib/github_changelog_generator/fetcher.rb b/lib/github_changelog_generator/fetcher.rb index 863a422..5e08ff9 100644 --- a/lib/github_changelog_generator/fetcher.rb +++ b/lib/github_changelog_generator/fetcher.rb @@ -41,9 +41,7 @@ module GitHubChangelogGenerator def fetch_github_token env_var = @options[:token] ? @options[:token] : (ENV.fetch CHANGELOG_GITHUB_TOKEN, nil) - unless env_var - @logger.warn NO_TOKEN_PROVIDED.yellow - end + @logger.warn NO_TOKEN_PROVIDED.yellow unless env_var env_var end @@ -51,9 +49,7 @@ module GitHubChangelogGenerator # Fetch all tags from repo # @return [Array] array of tags def get_all_tags - if @options[:verbose] - print "Fetching tags...\r" - end + print "Fetching tags...\r" if @options[:verbose] tags = [] @@ -98,9 +94,7 @@ Make sure, that you push tags to remote repo via 'git push --tags'".yellow # (pull request is kind of issue in term of GitHub) # @return [Tuple] with (issues, pull-requests) def fetch_closed_issues_and_pr - if @options[:verbose] - print "Fetching closed issues...\r" - end + print "Fetching closed issues...\r" if @options[:verbose] issues = [] begin diff --git a/lib/github_changelog_generator/generator.rb b/lib/github_changelog_generator/generator.rb deleted file mode 100644 index f076f4b..0000000 --- a/lib/github_changelog_generator/generator.rb +++ /dev/null @@ -1,42 +0,0 @@ -module GitHubChangelogGenerator - class Generator - def initialize(options = nil) - @options = options - 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](https://github.com/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})" - - unless issue.pull_request.nil? - if @options[:author] - if issue.user.nil? - title_with_number += " ({Null user})" - else - title_with_number += " ([#{issue.user.login}](#{issue.user.html_url}))" - end - end - end - title_with_number - end - - def encapsulate_string(string) - string.gsub! '\\', '\\\\' - - encpas_chars = %w(> * _ \( \) [ ] #) - encpas_chars.each do |char| - string.gsub! char, "\\#{char}" - end - - string - end - end -end diff --git a/lib/github_changelog_generator/generator/generator.rb b/lib/github_changelog_generator/generator/generator.rb new file mode 100644 index 0000000..5b4285f --- /dev/null +++ b/lib/github_changelog_generator/generator/generator.rb @@ -0,0 +1,137 @@ +require "github_changelog_generator/fetcher" +require_relative "generator_generation" +require_relative "generator_fetcher" +require_relative "generator_processor" + +module GitHubChangelogGenerator + # Default error for ChangelogGenerator + class ChangelogGeneratorError < StandardError + end + + class Generator + attr_accessor :options, :all_tags, :github + + # A Generator responsible for all logic, related with change log generation from ready-to-parse issues + # + # Example: + # generator = GitHubChangelogGenerator::Generator.new + # content = generator.compound_changelog + def initialize(options = nil) + @options = options + + @fetcher = GitHubChangelogGenerator::Fetcher.new @options + + fetch_tags + + fetch_issues_and_pr + end + + 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) + end + + def fetch_tags + # @all_tags = get_filtered_tags + @all_tags = @fetcher.get_all_tags + + fetch_tags_dates + sort_tags_by_date + end + + # Sort all tags by date + def sort_tags_by_date + puts "Sorting tags..." if @options[:verbose] + @all_tags.sort_by! { |x| @fetcher.get_time_of_tag(x) }.reverse! + end + + # Encapsulate characters to make markdown look as expected. + # + # @param [String] string + # @return [String] encapsulated input string + def encapsulate_string(string) + string.gsub! '\\', '\\\\' + + encpas_chars = %w(> * _ \( \) [ ] #) + encpas_chars.each do |char| + 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 [String] older_tag_name Older tag, used for the links. Could be nil for last tag. + # @return [String] Ready and parsed section + def create_log(pull_requests, issues, newer_tag, older_tag_name = nil) + newer_tag_time = newer_tag.nil? ? Time.new : @fetcher.get_time_of_tag(newer_tag) + if newer_tag.nil? && @options[:future_release] + newer_tag_name = @options[:future_release] + newer_tag_link = @options[:future_release] + else + newer_tag_name = newer_tag.nil? ? @options[:unreleased_label] : newer_tag["name"] + newer_tag_link = newer_tag.nil? ? "HEAD" : newer_tag_name + end + + github_site = options[:github_site] || "https://github.com" + project_url = "#{github_site}/#{@options[:user]}/#{@options[:project]}" + + log = generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name, project_url) + + if @options[:issues] + # Generate issues: + bugs_a, enhancement_a, issues_a = parse_by_sections(issues) + + log += generate_sub_section(enhancement_a, @options[:enhancement_prefix]) + log += generate_sub_section(bugs_a, @options[:bug_prefix]) + log += generate_sub_section(issues_a, @options[:issue_prefix]) + end + + if @options[:pulls] + # Generate pull requests: + log += generate_sub_section(pull_requests, @options[:merge_prefix]) + end + + log + end + + # This method sort issues by types + # (bugs, features, or just closed issues) by labels + # + # @param [Array] issues + # @return [Array] tuple of filtered arrays: (Bugs, Enhancements Issues) + def parse_by_sections(issues) + issues_a = [] + enhancement_a = [] + bugs_a = [] + + issues.each do |dict| + added = false + dict.labels.each do |label| + if label.name == "bug" + bugs_a.push dict + added = true + next + end + if label.name == "enhancement" + enhancement_a.push dict + added = true + next + end + end + issues_a.push dict unless added + end + [bugs_a, enhancement_a, issues_a] + end + end +end diff --git a/lib/github_changelog_generator/generator/generator_fetcher.rb b/lib/github_changelog_generator/generator/generator_fetcher.rb new file mode 100644 index 0000000..7cb3fd4 --- /dev/null +++ b/lib/github_changelog_generator/generator/generator_fetcher.rb @@ -0,0 +1,89 @@ +module GitHubChangelogGenerator + class Generator + # Fetch event for issues and pull requests + # @return [Array] array of fetched issues + def fetch_events_for_issues_and_pr + if @options[:verbose] + print "Fetching events for issues and PR: 0/#{@issues.count + @pull_requests.count}\r" + end + + # Async fetching events: + @fetcher.fetch_events_async(@issues + @pull_requests) + end + + # Async fetching of all tags dates + def fetch_tags_dates + print "Fetching tag dates...\r" if @options[:verbose] + + # Async fetching tags: + threads = [] + i = 0 + all = @all_tags.count + @all_tags.each do |tag| + threads << Thread.new do + @fetcher.get_time_of_tag(tag) + if @options[:verbose] + print "Fetching tags dates: #{i + 1}/#{all}\r" + i += 1 + end + end + end + + print " \r" + + threads.each(&:join) + + puts "Fetching tags dates: #{i}" if @options[:verbose] + end + + # Find correct closed dates, if issues was closed by commits + def detect_actual_closed_dates(issues) + print "Fetching closed dates for issues...\r" if @options[:verbose] + + max_thread_number = 50 + issues.each_slice(max_thread_number) do |issues_slice| + threads = [] + issues_slice.each do |issue| + threads << Thread.new { find_closed_date_by_commit(issue) } + end + threads.each(&:join) + puts "Fetching closed dates for issues: Done!" if @options[:verbose] + end + end + + # Fill :actual_date parameter of specified issue by closed date of the commit, if it was closed by commit. + # @param [Hash] issue + 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 + + # Set closed date from this issue + # + # @param [Hash] event + # @param [Hash] issue + 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) + issue[:actual_date] = commit[:author][:date] + rescue + puts "Warning: Can't fetch commit #{event[:commit_id]}. It is probably referenced from another repo.".yellow + issue[:actual_date] = issue[:closed_at] + end + end + end + end +end diff --git a/lib/github_changelog_generator/generator/generator_generation.rb b/lib/github_changelog_generator/generator/generator_generation.rb new file mode 100644 index 0000000..f5e6b8f --- /dev/null +++ b/lib/github_changelog_generator/generator/generator_generation.rb @@ -0,0 +1,159 @@ +module GitHubChangelogGenerator + class Generator + # Main function to start change log generation + # + # @return [String] Generated change log file + def compound_changelog + log = "# Change Log\n\n" + + if @options[:unreleased_only] + log += generate_log_between_tags(all_tags[0], nil) + elsif @options[:tag1] && @options[:tag2] + tag1 = @options[:tag1] + tag2 = @options[:tag2] + tags_strings = [] + all_tags.each { |x| tags_strings.push(x["name"]) } + + if tags_strings.include?(tag1) + if tags_strings.include?(tag2) + to_a = tags_strings.map.with_index.to_a + hash = Hash[to_a] + index1 = hash[tag1] + index2 = hash[tag2] + log += generate_log_between_tags(all_tags[index1], all_tags[index2]) + else + fail ChangelogGeneratorError, "Can't find tag #{tag2} -> exit".red + end + else + fail ChangelogGeneratorError, "Can't find tag #{tag1} -> exit".red + end + else + log += generate_log_for_all_tags + end + + log += "\n\n\\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)*" + @log = log + @log = log + end + + # @param [Array] issues List of issues on sub-section + # @param [String] prefix Nae of sub-section + # @return [String] Generate ready-to-go sub-section + def generate_sub_section(issues, prefix) + log = "" + + log += "#{prefix}\n\n" if options[:simple_list] != true && issues.any? + + if issues.any? + issues.each do |issue| + merge_string = get_string_for_issue(issue) + log += "- #{merge_string}\n\n" + end + 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 + if newer_tag_name.equal? @options[:unreleased_label] + log += "## [#{newer_tag_name}](#{project_url}/tree/#{newer_tag_link})\n\n" + else + log += "## [#{newer_tag_name}](#{project_url}/tree/#{newer_tag_link}) (#{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_pull_requests = delete_by_time(@pull_requests, :actual_date, older_tag, newer_tag) + filtered_issues = delete_by_time(@issues, :actual_date, older_tag, newer_tag) + + newer_tag_name = newer_tag.nil? ? nil : newer_tag["name"] + older_tag_name = older_tag.nil? ? nil : older_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 + + if newer_tag.nil? && filtered_issues.empty? && filtered_pull_requests.empty? + # do not generate empty unreleased section + return "" + end + + create_log(filtered_pull_requests, filtered_issues, newer_tag, older_tag_name) + end + + # The full cycle of generation for whole project + # @return [String] The complete change log + def generate_log_for_all_tags + puts "Generating log..." if @options[:verbose] + + log = generate_unreleased_section + + (1...all_tags.size).each do |index| + log += generate_log_between_tags(all_tags[index], all_tags[index - 1]) + end + if @all_tags.count != 0 + log += generate_log_between_tags(nil, all_tags.last) + end + + log + end + + def generate_unreleased_section + log = "" + if @options[:unreleased] && @all_tags.count != 0 + unreleased_log = generate_log_between_tags(all_tags[0], 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](https://github.com/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})" + + unless issue.pull_request.nil? + if @options[:author] + if issue.user.nil? + title_with_number += " ({Null user})" + else + title_with_number += " ([#{issue.user.login}](#{issue.user.html_url}))" + end + end + end + title_with_number + end + end +end diff --git a/lib/github_changelog_generator/generator/generator_processor.rb b/lib/github_changelog_generator/generator/generator_processor.rb new file mode 100644 index 0000000..cbbaa7f --- /dev/null +++ b/lib/github_changelog_generator/generator/generator_processor.rb @@ -0,0 +1,174 @@ +module GitHubChangelogGenerator + class Generator + # delete all labels with labels from @options[:exclude_labels] array + # @param [Array] issues + # @return [Array] filtered array + def exclude_issues_by_labels(issues) + unless @options[:exclude_labels].nil? + issues = issues.select do |issue| + var = issue.labels.map(&:name) & @options[:exclude_labels] + !(var).any? + end + end + issues + end + + def filter_by_milestone(filtered_issues, newer_tag_name, src_array) + filtered_issues.select! do |issue| + # leave issues without milestones + if issue.milestone.nil? + true + else + # check, that this milestone in tag list: + @all_tags.find { |tag| tag.name == issue.milestone.title }.nil? + end + end + unless newer_tag_name.nil? + + # add missed issues (according milestones) + issues_to_add = src_array.select do |issue| + if issue.milestone.nil? + false + else + # check, that this milestone in tag list: + milestone_is_tag = @all_tags.find do |tag| + tag.name == issue.milestone.title + end + + if milestone_is_tag.nil? + false + else + issue.milestone.title == newer_tag_name + end + end + end + + filtered_issues |= issues_to_add + end + filtered_issues + end + + # Method filter issues, that belong only specified tag range + # @param [Array] array of issues to filter + # @param [Symbol] hash_key key of date value default is :actual_date + # @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 + # @return [Array] filtered issues + def delete_by_time(array, hash_key = :actual_date, older_tag = nil, newer_tag = nil) + fail ChangelogGeneratorError, "At least one of the tags should be not nil!".red if older_tag.nil? && newer_tag.nil? + + newer_tag_time = newer_tag && @fetcher.get_time_of_tag(newer_tag) + older_tag_time = older_tag && @fetcher.get_time_of_tag(older_tag) + + array.select do |req| + if req[hash_key] + t = Time.parse(req[hash_key]).utc + + if older_tag_time.nil? + tag_in_range_old = true + else + tag_in_range_old = t > older_tag_time + end + + if newer_tag_time.nil? + tag_in_range_new = true + else + tag_in_range_new = t <= newer_tag_time + end + + tag_in_range = (tag_in_range_old) && (tag_in_range_new) + + tag_in_range + else + false + end + end + end + + # Include issues with labels, specified in :include_labels + # @param [Array] issues to filter + # @return [Array] filtered array of issues + def include_issues_by_labels(issues) + filtered_issues = @options[:include_labels].nil? ? issues : issues.select do |issue| + labels = issue.labels.map(&:name) & @options[:include_labels] + (labels).any? + end + + if @options[:add_issues_wo_labels] + issues_wo_labels = issues.select do |issue| + !issue.labels.map(&:name).any? + end + filtered_issues |= issues_wo_labels + end + filtered_issues + end + + # Return tags after filtering tags in lists provided by option: --between-tags & --exclude-tags + # + # @return [Array] + def get_filtered_tags + all_tags = @fetcher.get_all_tags + filtered_tags = [] + if @options[:between_tags] + @options[:between_tags].each do |tag| + unless all_tags.include? tag + puts "Warning: can't find tag #{tag}, specified with --between-tags option.".yellow + end + end + filtered_tags = all_tags.select { |tag| @options[:between_tags].include? tag } + end + filtered_tags + end + + # General filtered function + # + # @param [Array] all_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) + end + + # Filter issues according labels + # @return [Array] Filtered issues + 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[:include_labels] array + # And exclude all from :exclude_labels array. + # @return [Array] filtered PR's + 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 + + # 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. + 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 + pr[:merged_at] = fetched_pr[:merged_at] + closed_pull_requests.delete(fetched_pr) + end + + pull_requests.select! do |pr| + !pr[:merged_at].nil? + end + + pull_requests + end + end +end diff --git a/lib/github_changelog_generator/parser.rb b/lib/github_changelog_generator/parser.rb index 891fb9f..6e87f7d 100644 --- a/lib/github_changelog_generator/parser.rb +++ b/lib/github_changelog_generator/parser.rb @@ -9,7 +9,7 @@ module GitHubChangelogGenerator options = { tag1: nil, tag2: nil, - dateformat: "%Y-%m-%d", + date_format: "%Y-%m-%d", output: "CHANGELOG.md", issues: true, add_issues_wo_labels: true, @@ -30,7 +30,7 @@ module GitHubChangelogGenerator issue_prefix: "**Closed issues:**", bug_prefix: "**Fixed bugs:**", enhancement_prefix: "**Implemented enhancements:**", - branch: "origin" + git_remote: "origin" } parser = OptionParser.new do |opts| @@ -45,7 +45,7 @@ module GitHubChangelogGenerator options[:token] = last end opts.on("-f", "--date-format [FORMAT]", "Date format. Default is %Y-%m-%d") do |last| - options[:dateformat] = last + options[:date_format] = last end opts.on("-o", "--output [NAME]", "Output file. Default is CHANGELOG.md") do |last| options[:output] = last @@ -123,11 +123,6 @@ module GitHubChangelogGenerator exit end - if ARGV[1] - options[:tag1] = ARGV[0] - options[:tag2] = ARGV[1] - end - if options[:verbose] puts "Performing task with options:" pp options @@ -138,54 +133,83 @@ module GitHubChangelogGenerator end def self.detect_user_and_project(options) - if ARGV[0] && !ARGV[1] - github_site = options[:github_site] ? options[:github_site] : "github.com" + options[:user], options[:project] = user_project_from_option(ARGV[0], ARGV[1], options[:github_site]) + if !options[:user] || !options[:project] + if ENV["RUBYLIB"] =~ /ruby-debug-ide/ + options[:user] = "skywinder" + options[:project] = "changelog_test" + else + remote = `git config --get remote.#{options[:git_remote]}.url` + options[:user], options[:project] = user_project_from_remote(remote) + end + end + end + + # Try to find user and project name from git remote output + # + # @param [String] output of git remote command + # @return [Array] user and project + def self.user_project_from_option(arg0, arg2, github_site = "github.com") + user = nil + project = nil + + if arg0 && !arg2 # this match should parse strings such "https://github.com/skywinder/Github-Changelog-Generator" or "skywinder/Github-Changelog-Generator" to user and name - match = /(?:.+#{Regexp.escape(github_site)}\/)?(.+)\/(.+)/.match(ARGV[0]) + match = /(?:.+#{Regexp.escape(github_site)}\/)?(.+)\/(.+)/.match(arg0) begin param = match[2].nil? rescue - puts "Can't detect user and name from first parameter: '#{ARGV[0]}' -> exit'" + puts "Can't detect user and name from first parameter: '#{arg0}' -> exit'" exit end if param exit else - options[:user] = match[1] - options[:project] = match[2] - end - - end - - if !options[:user] && !options[:project] - if ENV["RUBYLIB"] =~ /ruby-debug-ide/ - options[:user] = "skywinder" - options[:project] = "changelog_test" - else - remote = `git config --get remote.#{options[:branch]}.url` - # try to find repo in format: - # origin git@github.com:skywinder/Github-Changelog-Generator.git (fetch) - # git@github.com:skywinder/Github-Changelog-Generator.git - match = /.*(?:[:\/])((?:-|\w|\.)*)\/((?:-|\w|\.)*)(?:\.git).*/.match(remote) - - if match && match[1] && match[2] - puts "Detected user:#{match[1]}, project:#{match[2]}" - options[:user] = match[1] - options[:project] = match[2] - else - # try to find repo in format: - # origin https://github.com/skywinder/ChangelogMerger (fetch) - # https://github.com/skywinder/ChangelogMerger - match = /.*\/((?:-|\w|\.)*)\/((?:-|\w|\.)*).*/.match(remote) - if match && match[1] && match[2] - puts "Detected user:#{match[1]}, project:#{match[2]}" - options[:user] = match[1] - options[:project] = match[2] - end - end + user = match[1] + project = match[2] end end + [user, project] + end + + # Try to find user and project name from git remote output + # + # @param [String] output of git remote command + # @return [Array] user and project + def self.user_project_from_remote(remote) + # try to find repo in format: + # origin git@github.com:skywinder/Github-Changelog-Generator.git (fetch) + # git@github.com:skywinder/Github-Changelog-Generator.git + regex1 = /.*(?:[:\/])((?:-|\w|\.)*)\/((?:-|\w|\.)*)(?:\.git).*/ + + # try to find repo in format: + # origin https://github.com/skywinder/ChangelogMerger (fetch) + # https://github.com/skywinder/ChangelogMerger + regex2 = /.*\/((?:-|\w|\.)*)\/((?:-|\w|\.)*).*/ + + remote_structures = [regex1, regex2] + + user = nil + project = nil + remote_structures.each do |regex| + matches = Regexp.new(regex).match(remote) + + if matches && matches[1] && matches[2] + puts "Detected user:#{matches[1]}, project:#{matches[2]}" + user = matches[1] + project = matches[2] + end + + break unless matches.nil? + end + + [user, project] end end + + if __FILE__ == $PROGRAM_NAME + remote = "invalid reference to project" + p user_project_from_option(ARGV[0], ARGV[1], remote) + end end diff --git a/spec/unit/parser_spec.rb b/spec/unit/parser_spec.rb new file mode 100644 index 0000000..6f80a6e --- /dev/null +++ b/spec/unit/parser_spec.rb @@ -0,0 +1,45 @@ +describe GitHubChangelogGenerator::Parser do + describe "#self.user_project_from_remote" do + context "when remote is 1" do + subject { GitHubChangelogGenerator::Parser.user_project_from_remote("origin https://github.com/skywinder/ActionSheetPicker-3.0 (fetch)") } + it { is_expected.to be_a(Array) } + it { is_expected.to match_array(["skywinder", "ActionSheetPicker-3.0"]) } + end + context "when remote is 2" do + subject { GitHubChangelogGenerator::Parser.user_project_from_remote("https://github.com/skywinder/ActionSheetPicker-3.0") } + it { is_expected.to be_a(Array) } + it { is_expected.to match_array(["skywinder", "ActionSheetPicker-3.0"]) } + end + context "when remote is 3" do + subject { GitHubChangelogGenerator::Parser.user_project_from_remote("https://github.com/skywinder/ActionSheetPicker-3.0") } + it { is_expected.to be_a(Array) } + it { is_expected.to match_array(["skywinder", "ActionSheetPicker-3.0"]) } + end + context "when remote is 4" do + subject { GitHubChangelogGenerator::Parser.user_project_from_remote("origin git@github.com:skywinder/ActionSheetPicker-3.0.git (fetch)") } + it { is_expected.to be_a(Array) } + it { is_expected.to match_array(["skywinder", "ActionSheetPicker-3.0"]) } + end + context "when remote is invalid" do + subject { GitHubChangelogGenerator::Parser.user_project_from_remote("some invalid text") } + it { is_expected.to be_a(Array) } + it { is_expected.to match_array([nil, nil]) } + end + end + describe "#self.user_project_from_option" do + # context "when option is invalid" do + # it("should exit") { expect { GitHubChangelogGenerator::Parser.user_project_from_option("blah", nil) }.to raise_error(SystemExit) } + # end + + context "when option is valid" do + subject { GitHubChangelogGenerator::Parser.user_project_from_option("skywinder/ActionSheetPicker-3.0", nil) } + it { is_expected.to be_a(Array) } + it { is_expected.to match_array(["skywinder", "ActionSheetPicker-3.0"]) } + end + context "when option nil" do + subject { GitHubChangelogGenerator::Parser.user_project_from_option(nil, nil) } + it { is_expected.to be_a(Array) } + it { is_expected.to match_array([nil, nil]) } + end + end +end