px_github_changelog_generator 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +9 -0
  3. data/README.md +357 -0
  4. data/Rakefile +19 -0
  5. data/bin/git-generate-changelog +5 -0
  6. data/bin/github_changelog_generator +5 -0
  7. data/lib/github_changelog_generator/argv_parser.rb +225 -0
  8. data/lib/github_changelog_generator/file_parser_chooser.rb +27 -0
  9. data/lib/github_changelog_generator/generator/entry.rb +218 -0
  10. data/lib/github_changelog_generator/generator/generator.rb +177 -0
  11. data/lib/github_changelog_generator/generator/generator_fetcher.rb +202 -0
  12. data/lib/github_changelog_generator/generator/generator_processor.rb +237 -0
  13. data/lib/github_changelog_generator/generator/generator_tags.rb +210 -0
  14. data/lib/github_changelog_generator/generator/section.rb +129 -0
  15. data/lib/github_changelog_generator/helper.rb +37 -0
  16. data/lib/github_changelog_generator/octo_fetcher.rb +535 -0
  17. data/lib/github_changelog_generator/options.rb +159 -0
  18. data/lib/github_changelog_generator/parser.rb +89 -0
  19. data/lib/github_changelog_generator/parser_file.rb +101 -0
  20. data/lib/github_changelog_generator/reader.rb +88 -0
  21. data/lib/github_changelog_generator/ssl_certs/cacert.pem +3138 -0
  22. data/lib/github_changelog_generator/task.rb +68 -0
  23. data/lib/github_changelog_generator/version.rb +5 -0
  24. data/lib/github_changelog_generator.rb +49 -0
  25. data/man/git-generate-changelog.1 +393 -0
  26. data/man/git-generate-changelog.1.html +359 -0
  27. data/man/git-generate-changelog.html +270 -0
  28. data/man/git-generate-changelog.md +274 -0
  29. data/spec/files/angular.js.md +9395 -0
  30. data/spec/files/bundler.md +1911 -0
  31. data/spec/files/config_example +5 -0
  32. data/spec/files/github-changelog-generator.md +305 -0
  33. data/spec/github_changelog_generator_spec.rb +32 -0
  34. data/spec/install_gem_in_bundler.gemfile +5 -0
  35. data/spec/spec_helper.rb +74 -0
  36. data/spec/unit/generator/entry_spec.rb +766 -0
  37. data/spec/unit/generator/generator_processor_spec.rb +203 -0
  38. data/spec/unit/generator/generator_spec.rb +47 -0
  39. data/spec/unit/generator/generator_tags_spec.rb +337 -0
  40. data/spec/unit/generator/section_spec.rb +43 -0
  41. data/spec/unit/octo_fetcher_spec.rb +590 -0
  42. data/spec/unit/options_spec.rb +67 -0
  43. data/spec/unit/parser_file_spec.rb +94 -0
  44. data/spec/unit/parser_spec.rb +54 -0
  45. data/spec/unit/reader_spec.rb +120 -0
  46. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_commits/when_API_is_valid/returns_commits.json +1 -0
  47. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_commits_before/when_API_is_valid/returns_commits.json +1 -0
  48. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_issue_with_proper_key/values.json +1 -0
  49. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_issues.json +1 -0
  50. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_issues_with_labels.json +1 -0
  51. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_pull_request_with_proper_key/values.json +1 -0
  52. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_pull_requests_with_labels.json +1 -0
  53. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid.json +1 -0
  54. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_pull_requests/when_API_call_is_valid/returns_correct_pull_request_keys.json +1 -0
  55. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_pull_requests/when_API_call_is_valid/returns_pull_requests.json +1 -0
  56. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_pull_requests/when_API_call_is_valid.json +1 -0
  57. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_commit/when_API_call_is_valid/returns_commit.json +1 -0
  58. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_commit/when_API_call_is_valid.json +1 -0
  59. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_date_of_tag/when_API_call_is_valid/returns_date.json +1 -0
  60. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_date_of_tag/when_API_call_is_valid.json +1 -0
  61. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_events_async/when_API_call_is_valid/populates_issues.json +1 -0
  62. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_events_async/when_API_call_is_valid.json +1 -0
  63. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_API_call_is_valid/should_return_tags.json +1 -0
  64. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_API_call_is_valid/should_return_tags_count.json +1 -0
  65. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_API_call_is_valid.json +1 -0
  66. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_wrong_token_provided/should_raise_Unauthorized_error.json +1 -0
  67. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_wrong_token_provided.json +1 -0
  68. metadata +250 -0
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "github_changelog_generator/generator/section"
4
+
5
+ module GitHubChangelogGenerator
6
+ # This class generates the content for a single changelog entry. An entry is
7
+ # generally either for a specific tagged release or the collection of
8
+ # unreleased changes.
9
+ #
10
+ # An entry is comprised of header text followed by a series of sections
11
+ # relating to the entry.
12
+ #
13
+ # @see GitHubChangelogGenerator::Generator
14
+ # @see GitHubChangelogGenerator::Section
15
+ class Entry
16
+ attr_reader :content
17
+
18
+ def initialize(options = Options.new({}))
19
+ @content = ""
20
+ @options = Options.new(options)
21
+ end
22
+
23
+ # Generates log entry with header and body
24
+ #
25
+ # @param [Array] pull_requests List or PR's in new section
26
+ # @param [Array] issues List of issues in new section
27
+ # @param [String] newer_tag_name Name of the newer tag. Could be nil for `Unreleased` section.
28
+ # @param [String] newer_tag_link Name of the newer tag. Could be "HEAD" for `Unreleased` section.
29
+ # @param [Time] newer_tag_time Time of the newer tag
30
+ # @param [Hash, nil] older_tag_name Older tag, used for the links. Could be nil for last tag.
31
+ # @return [String] Ready and parsed section content.
32
+ def generate_entry_for_tag(pull_requests, issues, newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name) # rubocop:disable Metrics/ParameterLists
33
+ github_site = @options[:github_site] || "https://github.com"
34
+ project_url = "#{github_site}/#{@options[:user]}/#{@options[:project]}"
35
+
36
+ create_sections
37
+
38
+ @content = generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name, project_url)
39
+ @content += generate_body(pull_requests, issues)
40
+ @content
41
+ end
42
+
43
+ def line_labels_for(issue)
44
+ labels = if @options[:issue_line_labels] == ["ALL"]
45
+ issue["labels"]
46
+ else
47
+ issue["labels"].select { |label| @options[:issue_line_labels].include?(label["name"]) }
48
+ end
49
+ labels.map { |label| " \[[#{label['name']}](#{label['url'].sub('api.github.com/repos', 'github.com')})\]" }.join("")
50
+ end
51
+
52
+ private
53
+
54
+ # Creates section objects for this entry.
55
+ # @return [Nil]
56
+ def create_sections
57
+ @sections = if @options.configure_sections?
58
+ parse_sections(@options[:configure_sections])
59
+ elsif @options.add_sections?
60
+ default_sections.concat parse_sections(@options[:add_sections])
61
+ else
62
+ default_sections
63
+ end
64
+ nil
65
+ end
66
+
67
+ # Turns the argument from the commandline of --configure-sections or
68
+ # --add-sections into an array of Section objects.
69
+ #
70
+ # @param [String, Hash] sections_desc Either string or hash describing sections
71
+ # @return [Array] Parsed section objects.
72
+ def parse_sections(sections_desc)
73
+ require "json"
74
+
75
+ sections_desc = sections_desc.to_json if sections_desc.class == Hash
76
+
77
+ begin
78
+ sections_json = JSON.parse(sections_desc)
79
+ rescue JSON::ParserError => e
80
+ raise "There was a problem parsing your JSON string for sections: #{e}"
81
+ end
82
+
83
+ sections_json.collect do |name, v|
84
+ Section.new(name: name.to_s, prefix: v["prefix"], labels: v["labels"], body_only: v["body_only"], options: @options)
85
+ end
86
+ end
87
+
88
+ # Generates header text for an entry.
89
+ #
90
+ # @param [String] newer_tag_name The name of a newer tag
91
+ # @param [String] newer_tag_link Used for URL generation. Could be same as #newer_tag_name or some specific value, like HEAD
92
+ # @param [Time] newer_tag_time Time when the newer tag was created
93
+ # @param [String] older_tag_name The name of an older tag; used for URLs.
94
+ # @param [String] project_url URL for the current project.
95
+ # @return [String] Header text content.
96
+ def generate_header(newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name, project_url)
97
+ header = ""
98
+
99
+ # Generate date string:
100
+ time_string = newer_tag_time.strftime(@options[:date_format])
101
+
102
+ # Generate tag name and link
103
+ release_url = if @options[:release_url]
104
+ format(@options[:release_url], newer_tag_link)
105
+ else
106
+ "#{project_url}/tree/#{newer_tag_link}"
107
+ end
108
+ header += if newer_tag_name.equal?(@options[:unreleased_label])
109
+ "## [#{newer_tag_name}](#{release_url})\n\n"
110
+ else
111
+ "## [#{newer_tag_name}](#{release_url}) (#{time_string})\n\n"
112
+ end
113
+
114
+ if @options[:compare_link] && older_tag_name
115
+ # Generate compare link
116
+ header += "[Full Changelog](#{project_url}/compare/#{older_tag_name}...#{newer_tag_link})\n\n"
117
+ end
118
+
119
+ header
120
+ end
121
+
122
+ # Generates complete body text for a tag (without a header)
123
+ #
124
+ # @param [Array] pull_requests
125
+ # @param [Array] issues
126
+ # @return [String] Content generated from sections of sorted issues & PRs.
127
+ def generate_body(pull_requests, issues)
128
+ sort_into_sections(pull_requests, issues)
129
+ @sections.map(&:generate_content).join
130
+ end
131
+
132
+ # Default sections to used when --configure-sections is not set.
133
+ #
134
+ # @return [Array] Section objects.
135
+ def default_sections
136
+ [
137
+ Section.new(name: "summary", prefix: @options[:summary_prefix], labels: @options[:summary_labels], options: @options, body_only: true),
138
+ Section.new(name: "breaking", prefix: @options[:breaking_prefix], labels: @options[:breaking_labels], options: @options),
139
+ Section.new(name: "enhancements", prefix: @options[:enhancement_prefix], labels: @options[:enhancement_labels], options: @options),
140
+ Section.new(name: "bugs", prefix: @options[:bug_prefix], labels: @options[:bug_labels], options: @options),
141
+ Section.new(name: "deprecated", prefix: @options[:deprecated_prefix], labels: @options[:deprecated_labels], options: @options),
142
+ Section.new(name: "removed", prefix: @options[:removed_prefix], labels: @options[:removed_labels], options: @options),
143
+ Section.new(name: "security", prefix: @options[:security_prefix], labels: @options[:security_labels], options: @options)
144
+ ]
145
+ end
146
+
147
+ # Sorts issues and PRs into entry sections by labels and lack of labels.
148
+ #
149
+ # @param [Array] pull_requests
150
+ # @param [Array] issues
151
+ # @return [Nil]
152
+ def sort_into_sections(pull_requests, issues)
153
+ if @options[:issues]
154
+ unmapped_issues = sort_labeled_issues(issues)
155
+ add_unmapped_section(unmapped_issues)
156
+ end
157
+ if @options[:pulls]
158
+ unmapped_pull_requests = sort_labeled_issues(pull_requests)
159
+ add_unmapped_section(unmapped_pull_requests)
160
+ end
161
+ nil
162
+ end
163
+
164
+ # Iterates through sections and sorts labeled issues into them based on
165
+ # the label mapping. Returns any unmapped or unlabeled issues.
166
+ #
167
+ # @param [Array] issues Issues or pull requests.
168
+ # @return [Array] Issues that were not mapped into any sections.
169
+ def sort_labeled_issues(issues)
170
+ sorted_issues = []
171
+ issues.each do |issue|
172
+ label_names = issue["labels"].collect { |l| l["name"] }
173
+
174
+ # Add PRs in the order of the @sections array. This will either be the
175
+ # default sections followed by any --add-sections sections in
176
+ # user-defined order, or --configure-sections in user-defined order.
177
+ # Ignore the order of the issue labels from github which cannot be
178
+ # controlled by the user.
179
+ @sections.each do |section|
180
+ unless (section.labels & label_names).empty?
181
+ section.issues << issue
182
+ sorted_issues << issue
183
+ break
184
+ end
185
+ end
186
+ end
187
+ issues - sorted_issues
188
+ end
189
+
190
+ # Creates a section for issues/PRs with no labels or no mapped labels.
191
+ #
192
+ # @param [Array] issues
193
+ # @return [Nil]
194
+ def add_unmapped_section(issues)
195
+ unless issues.empty?
196
+ # Distinguish between issues and pull requests
197
+ if issues.first.key?("pull_request")
198
+ name = "merged"
199
+ prefix = @options[:merge_prefix]
200
+ add_wo_labels = @options[:add_pr_wo_labels]
201
+ else
202
+ name = "issues"
203
+ prefix = @options[:issue_prefix]
204
+ add_wo_labels = @options[:add_issues_wo_labels]
205
+ end
206
+ add_issues = if add_wo_labels
207
+ issues
208
+ else
209
+ # Only add unmapped issues
210
+ issues.select { |issue| issue["labels"].any? }
211
+ end
212
+ merged = Section.new(name: name, prefix: prefix, labels: [], issues: add_issues, options: @options) unless add_issues.empty?
213
+ @sections << merged
214
+ end
215
+ nil
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "github_changelog_generator/octo_fetcher"
4
+ require "github_changelog_generator/generator/generator_fetcher"
5
+ require "github_changelog_generator/generator/generator_processor"
6
+ require "github_changelog_generator/generator/generator_tags"
7
+ require "github_changelog_generator/generator/entry"
8
+ require "github_changelog_generator/generator/section"
9
+
10
+ module GitHubChangelogGenerator
11
+ # Default error for ChangelogGenerator
12
+ class ChangelogGeneratorError < StandardError
13
+ end
14
+
15
+ # This class is the high-level code for gathering issues and PRs for a github
16
+ # repository and generating a CHANGELOG.md file. A changelog is made up of a
17
+ # series of "entries" of all tagged releases, plus an extra entry for the
18
+ # unreleased changes. Entries are made up of various organizational
19
+ # "sections," and sections contain the github issues and PRs.
20
+ #
21
+ # So the changelog contains entries, entries contain sections, and sections
22
+ # contain issues and PRs.
23
+ #
24
+ # @see GitHubChangelogGenerator::Entry
25
+ # @see GitHubChangelogGenerator::Section
26
+ class Generator
27
+ attr_accessor :options, :filtered_tags, :tag_section_mapping, :sorted_tags
28
+
29
+ CREDIT_LINE = <<~CREDIT
30
+ \\* *[Test] This Changelog was automatically generated \
31
+ by [github_changelog_generator]\
32
+ (https://github.com/github-changelog-generator/github-changelog-generator)*
33
+ CREDIT
34
+
35
+ # A Generator responsible for all logic, related with changelog generation from ready-to-parse issues
36
+ #
37
+ # Example:
38
+ # generator = GitHubChangelogGenerator::Generator.new
39
+ # content = generator.compound_changelog
40
+ def initialize(options = {})
41
+ @options = options
42
+ @tag_times_hash = {}
43
+ @fetcher = GitHubChangelogGenerator::OctoFetcher.new(options)
44
+ @sections = []
45
+ end
46
+
47
+ # Main function to start changelog generation
48
+ #
49
+ # @return [String] Generated changelog file
50
+ def compound_changelog
51
+ @options.load_custom_ruby_files
52
+
53
+ Sync do
54
+ fetch_and_filter_tags
55
+ fetch_issues_and_pr
56
+
57
+ log = if @options[:unreleased_only]
58
+ generate_entry_between_tags(@filtered_tags[0], nil)
59
+ else
60
+ generate_entries_for_all_tags
61
+ end
62
+ log += File.read(@options[:base]) if File.file?(@options[:base])
63
+ log = remove_old_fixed_string(log)
64
+ log = insert_fixed_string(log)
65
+ @log = log
66
+ end
67
+ end
68
+
69
+ private
70
+
71
+ # Generate log only between 2 specified tags
72
+ # @param [String] older_tag all issues before this tag date will be excluded. May be nil, if it's first tag
73
+ # @param [String] newer_tag all issue after this tag will be excluded. May be nil for unreleased section
74
+ def generate_entry_between_tags(older_tag, newer_tag)
75
+ filtered_issues, filtered_pull_requests = filter_issues_for_tags(newer_tag, older_tag)
76
+
77
+ if newer_tag.nil? && filtered_issues.empty? && filtered_pull_requests.empty?
78
+ # do not generate empty unreleased section
79
+ return +""
80
+ end
81
+
82
+ newer_tag_link, newer_tag_name, newer_tag_time = detect_link_tag_time(newer_tag)
83
+
84
+ # If the older tag is nil, go back in time from the latest tag and find
85
+ # the SHA for the first commit.
86
+ older_tag_name =
87
+ if older_tag.nil?
88
+ @fetcher.oldest_commit["sha"]
89
+ else
90
+ older_tag["name"]
91
+ end
92
+
93
+ Entry.new(options).generate_entry_for_tag(filtered_pull_requests, filtered_issues, newer_tag_name, newer_tag_link, newer_tag_time, older_tag_name)
94
+ end
95
+
96
+ # Filters issues and pull requests based on, respectively, `actual_date`
97
+ # and `merged_at` timestamp fields. `actual_date` is the detected form of
98
+ # `closed_at` based on merge event SHA commit times.
99
+ #
100
+ # @return [Array] filtered issues and pull requests
101
+ def filter_issues_for_tags(newer_tag, older_tag)
102
+ filtered_pull_requests = filter_by_tag(@pull_requests, newer_tag)
103
+ filtered_issues = delete_by_time(@issues, "actual_date", older_tag, newer_tag)
104
+
105
+ newer_tag_name = newer_tag.nil? ? nil : newer_tag["name"]
106
+
107
+ if options[:filter_issues_by_milestone]
108
+ # delete excess irrelevant issues (according milestones). Issue #22.
109
+ filtered_issues = filter_by_milestone(filtered_issues, newer_tag_name, @issues)
110
+ filtered_pull_requests = filter_by_milestone(filtered_pull_requests, newer_tag_name, @pull_requests)
111
+ end
112
+ [filtered_issues, filtered_pull_requests]
113
+ end
114
+
115
+ # The full cycle of generation for whole project
116
+ # @return [String] All entries in the changelog
117
+ def generate_entries_for_all_tags
118
+ puts "Generating entry..." if options[:verbose]
119
+
120
+ entries = generate_unreleased_entry
121
+
122
+ @tag_section_mapping.each_pair do |_tag_section, left_right_tags|
123
+ older_tag, newer_tag = left_right_tags
124
+ entries += generate_entry_between_tags(older_tag, newer_tag)
125
+ end
126
+
127
+ entries
128
+ end
129
+
130
+ def generate_unreleased_entry
131
+ entry = +""
132
+ if options[:unreleased]
133
+ start_tag = @filtered_tags[0] || @sorted_tags.last
134
+ unreleased_entry = generate_entry_between_tags(start_tag, nil)
135
+ entry += unreleased_entry if unreleased_entry
136
+ end
137
+ entry
138
+ end
139
+
140
+ # Fetches @pull_requests and @issues and filters them based on options.
141
+ #
142
+ # @return [Nil] No return.
143
+ def fetch_issues_and_pr
144
+ issues, pull_requests = @fetcher.fetch_closed_issues_and_pr
145
+
146
+ @pull_requests = options[:pulls] ? get_filtered_pull_requests(pull_requests) : []
147
+
148
+ @issues = options[:issues] ? get_filtered_issues(issues) : []
149
+
150
+ fetch_events_for_issues_and_pr
151
+ detect_actual_closed_dates(@issues + @pull_requests)
152
+ add_first_occurring_tag_to_prs(@sorted_tags, @pull_requests)
153
+ nil
154
+ end
155
+
156
+ # Remove the previously assigned fixed message.
157
+ # @param log [String] Old lines are fixed
158
+ def remove_old_fixed_string(log)
159
+ log.gsub!(/#{Regexp.escape(@options[:frontmatter])}/, "") if @options[:frontmatter]
160
+ log.gsub!(/#{Regexp.escape(@options[:header])}\n{,2}/, "")
161
+ log.gsub!(/\n{,2}#{Regexp.escape(CREDIT_LINE)}/, "") # Remove old credit lines
162
+ log
163
+ end
164
+
165
+ # Add template messages to given string. Previously added
166
+ # messages of the same wording are removed.
167
+ # @param log [String]
168
+ def insert_fixed_string(log)
169
+ ins = +""
170
+ ins += @options[:frontmatter] if @options[:frontmatter]
171
+ ins += "#{@options[:header]}\n\n"
172
+ log.insert(0, ins)
173
+ log += "\n\n#{CREDIT_LINE}"
174
+ log
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitHubChangelogGenerator
4
+ class Generator
5
+ # Fetch event for issues and pull requests
6
+ # @return [Array] array of fetched issues
7
+ def fetch_events_for_issues_and_pr
8
+ print "Fetching events for issues and PR: 0/#{@issues.count + @pull_requests.count}\r" if options[:verbose]
9
+
10
+ # Async fetching events:
11
+ @fetcher.fetch_events_async(@issues + @pull_requests)
12
+ end
13
+
14
+ # Async fetching of all tags dates
15
+ def fetch_tags_dates(tags)
16
+ print "Fetching tag dates...\r" if options[:verbose]
17
+ i = 0
18
+ tags.each do |tag|
19
+ get_time_of_tag(tag)
20
+ i += 1
21
+ end
22
+ puts "Fetching tags dates: #{i}/#{tags.count}" if options[:verbose]
23
+ end
24
+
25
+ # Find correct closed dates, if issues was closed by commits
26
+ def detect_actual_closed_dates(issues)
27
+ print "Fetching closed dates for issues...\r" if options[:verbose]
28
+
29
+ i = 0
30
+ issues.each do |issue|
31
+ find_closed_date_by_commit(issue)
32
+ i += 1
33
+ end
34
+ puts "Fetching closed dates for issues: #{i}/#{issues.count}" if options[:verbose]
35
+ end
36
+
37
+ # Adds a key "first_occurring_tag" to each PR with a value of the oldest
38
+ # tag that a PR's merge commit occurs in in the git history. This should
39
+ # indicate the release of each PR by git's history regardless of dates and
40
+ # divergent branches.
41
+ #
42
+ # @param [Array] tags The tags sorted by time, newest to oldest.
43
+ # @param [Array] prs The PRs to discover the tags of.
44
+ # @return [Nil] No return; PRs are updated in-place.
45
+ def add_first_occurring_tag_to_prs(tags, prs)
46
+ total = prs.count
47
+
48
+ prs_left = associate_tagged_prs(tags, prs, total)
49
+ prs_left = associate_release_branch_prs(prs_left, total)
50
+ prs_left = associate_rebase_comment_prs(tags, prs_left, total) if prs_left.any?
51
+ # PRs in prs_left will be untagged, not in release branch, and not
52
+ # rebased. They should not be included in the changelog as they probably
53
+ # have been merged to a branch other than the release branch.
54
+ @pull_requests -= prs_left
55
+ Helper.log.info "Associating PRs with tags: #{total}/#{total}"
56
+ end
57
+
58
+ # Associate merged PRs by the merge SHA contained in each tag. If the
59
+ # merge_commit_sha is not found in any tag's history, skip association.
60
+ #
61
+ # @param [Array] tags The tags sorted by time, newest to oldest.
62
+ # @param [Array] prs The PRs to associate.
63
+ # @return [Array] PRs without their merge_commit_sha in a tag.
64
+ def associate_tagged_prs(tags, prs, total)
65
+ @fetcher.fetch_tag_shas(tags)
66
+
67
+ i = 0
68
+ prs.reject do |pr|
69
+ found = false
70
+ # XXX Wish I could use merge_commit_sha, but gcg doesn't currently
71
+ # fetch that. See
72
+ # https://developer.github.com/v3/pulls/#get-a-single-pull-request vs.
73
+ # https://developer.github.com/v3/pulls/#list-pull-requests
74
+ if pr["events"] && (event = pr["events"].find { |e| e["event"] == "merged" })
75
+ # Iterate tags.reverse (oldest to newest) to find first tag of each PR.
76
+ if (oldest_tag = tags.reverse.find { |tag| tag["shas_in_tag"].include?(event["commit_id"]) })
77
+ pr["first_occurring_tag"] = oldest_tag["name"]
78
+ found = true
79
+ i += 1
80
+ print("Associating PRs with tags: #{i}/#{total}\r") if @options[:verbose]
81
+ end
82
+ else
83
+ # Either there were no events or no merged event. GitHub's api can be
84
+ # weird like that apparently. Check for a rebased comment before erroring.
85
+ no_events_pr = associate_rebase_comment_prs(tags, [pr], total)
86
+ raise StandardError, "No merge sha found for PR #{pr['number']} via the GitHub API" unless no_events_pr.empty?
87
+
88
+ found = true
89
+ i += 1
90
+ print("Associating PRs with tags: #{i}/#{total}\r") if @options[:verbose]
91
+ end
92
+ found
93
+ end
94
+ end
95
+
96
+ # Associate merged PRs by the HEAD of the release branch. If no
97
+ # --release-branch was specified, then the github default branch is used.
98
+ #
99
+ # @param [Array] prs_left PRs not associated with any tag.
100
+ # @param [Integer] total The total number of PRs to associate; used for verbose printing.
101
+ # @return [Array] PRs without their merge_commit_sha in the branch.
102
+ def associate_release_branch_prs(prs_left, total)
103
+ if prs_left.any?
104
+ i = total - prs_left.count
105
+ prs_left.reject do |pr|
106
+ found = false
107
+ if pr["events"] && (event = pr["events"].find { |e| e["event"] == "merged" }) && sha_in_release_branch?(event["commit_id"])
108
+ found = true
109
+ i += 1
110
+ print("Associating PRs with tags: #{i}/#{total}\r") if @options[:verbose]
111
+ end
112
+ found
113
+ end
114
+ else
115
+ prs_left
116
+ end
117
+ end
118
+
119
+ # Associate merged PRs by the SHA detected in github comments of the form
120
+ # "rebased commit: <sha>". For use when the merge_commit_sha is not in the
121
+ # actual git history due to rebase.
122
+ #
123
+ # @param [Array] tags The tags sorted by time, newest to oldest.
124
+ # @param [Array] prs_left The PRs not yet associated with any tag or branch.
125
+ # @return [Array] PRs without rebase comments.
126
+ def associate_rebase_comment_prs(tags, prs_left, total)
127
+ i = total - prs_left.count
128
+ # Any remaining PRs were not found in the list of tags by their merge
129
+ # commit and not found in any specified release branch. Fallback to
130
+ # rebased commit comment.
131
+ @fetcher.fetch_comments_async(prs_left)
132
+ prs_left.reject do |pr|
133
+ found = false
134
+ if pr["comments"] && (rebased_comment = pr["comments"].reverse.find { |c| c["body"].match(%r{rebased commit: ([0-9a-f]{40})}i) })
135
+ rebased_sha = rebased_comment["body"].match(%r{rebased commit: ([0-9a-f]{40})}i)[1]
136
+ if (oldest_tag = tags.reverse.find { |tag| tag["shas_in_tag"].include?(rebased_sha) })
137
+ pr["first_occurring_tag"] = oldest_tag["name"]
138
+ found = true
139
+ i += 1
140
+ elsif sha_in_release_branch?(rebased_sha)
141
+ found = true
142
+ i += 1
143
+ else
144
+ raise StandardError, "PR #{pr['number']} has a rebased SHA comment but that SHA was not found in the release branch or any tags"
145
+ end
146
+ print("Associating PRs with tags: #{i}/#{total}\r") if @options[:verbose]
147
+ else
148
+ 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"
149
+ end
150
+ found
151
+ end
152
+ end
153
+
154
+ # Fill :actual_date parameter of specified issue by closed date of the commit, if it was closed by commit.
155
+ # @param [Hash] issue
156
+ def find_closed_date_by_commit(issue)
157
+ return if issue["events"].nil?
158
+
159
+ # if it's PR -> then find "merged event", in case of usual issue -> found closed date
160
+ compare_string = issue["merged_at"].nil? ? "closed" : "merged"
161
+ # reverse! - to find latest closed event. (event goes in date order)
162
+ issue["events"].reverse!.each do |event|
163
+ if event["event"] == compare_string
164
+ set_date_from_event(event, issue)
165
+ break
166
+ end
167
+ end
168
+ # TODO: assert issues, that remain without 'actual_date' hash for some reason.
169
+ end
170
+
171
+ # Set closed date from this issue
172
+ #
173
+ # @param [Hash] event
174
+ # @param [Hash] issue
175
+ def set_date_from_event(event, issue)
176
+ if event["commit_id"].nil?
177
+ issue["actual_date"] = issue["closed_at"]
178
+ return
179
+ end
180
+
181
+ commit = @fetcher.fetch_commit(event["commit_id"])
182
+ issue["actual_date"] = commit["commit"]["author"]["date"]
183
+
184
+ # issue['actual_date'] = commit['author']['date']
185
+ rescue StandardError
186
+ puts "Warning: Can't fetch commit #{event['commit_id']}. It is probably referenced from another repo."
187
+ issue["actual_date"] = issue["closed_at"]
188
+ end
189
+
190
+ private
191
+
192
+ # Detect if a sha occurs in the --release-branch. Uses the github repo
193
+ # default branch if not specified.
194
+ #
195
+ # @param [String] sha SHA to check.
196
+ # @return [Boolean] True if SHA is in the branch git history.
197
+ def sha_in_release_branch?(sha)
198
+ branch = @options[:release_branch] || @fetcher.default_branch
199
+ @fetcher.commits_in_branch(branch).include?(sha)
200
+ end
201
+ end
202
+ end