github_changelog_generator 1.15.0 → 1.16.3

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +32 -54
  3. data/Rakefile +1 -1
  4. data/lib/github_changelog_generator/argv_parser.rb +225 -0
  5. data/lib/github_changelog_generator/generator/entry.rb +10 -10
  6. data/lib/github_changelog_generator/generator/generator.rb +41 -19
  7. data/lib/github_changelog_generator/generator/generator_fetcher.rb +5 -9
  8. data/lib/github_changelog_generator/generator/generator_processor.rb +23 -20
  9. data/lib/github_changelog_generator/generator/generator_tags.rb +15 -9
  10. data/lib/github_changelog_generator/generator/section.rb +27 -7
  11. data/lib/github_changelog_generator/helper.rb +1 -1
  12. data/lib/github_changelog_generator/octo_fetcher.rb +190 -126
  13. data/lib/github_changelog_generator/options.rb +4 -0
  14. data/lib/github_changelog_generator/parser.rb +70 -248
  15. data/lib/github_changelog_generator/parser_file.rb +29 -14
  16. data/lib/github_changelog_generator/reader.rb +2 -2
  17. data/lib/github_changelog_generator/ssl_certs/cacert.pem +851 -1680
  18. data/lib/github_changelog_generator/task.rb +3 -2
  19. data/lib/github_changelog_generator/version.rb +1 -1
  20. data/man/git-generate-changelog.1 +46 -34
  21. data/man/git-generate-changelog.1.html +39 -31
  22. data/man/git-generate-changelog.html +19 -19
  23. data/man/git-generate-changelog.md +39 -31
  24. data/spec/files/config_example +5 -0
  25. data/spec/spec_helper.rb +1 -1
  26. data/spec/unit/generator/entry_spec.rb +37 -31
  27. data/spec/unit/generator/generator_processor_spec.rb +99 -44
  28. data/spec/unit/generator/generator_spec.rb +47 -0
  29. data/spec/unit/generator/generator_tags_spec.rb +46 -3
  30. data/spec/unit/generator/section_spec.rb +34 -0
  31. data/spec/unit/octo_fetcher_spec.rb +45 -2
  32. data/spec/unit/parser_spec.rb +50 -0
  33. metadata +45 -9
@@ -2,8 +2,6 @@
2
2
 
3
3
  module GitHubChangelogGenerator
4
4
  class Generator
5
- MAX_THREAD_NUMBER = 25
6
-
7
5
  # Fetch event for issues and pull requests
8
6
  # @return [Array] array of fetched issues
9
7
  def fetch_events_for_issues_and_pr
@@ -64,7 +62,7 @@ module GitHubChangelogGenerator
64
62
  # @param [Array] prs The PRs to associate.
65
63
  # @return [Array] PRs without their merge_commit_sha in a tag.
66
64
  def associate_tagged_prs(tags, prs, total)
67
- @fetcher.fetch_tag_shas_async(tags)
65
+ @fetcher.fetch_tag_shas(tags)
68
66
 
69
67
  i = 0
70
68
  prs.reject do |pr|
@@ -106,7 +104,7 @@ module GitHubChangelogGenerator
106
104
  i = total - prs_left.count
107
105
  prs_left.reject do |pr|
108
106
  found = false
109
- if pr["events"] && (event = pr["events"].find { |e| e["event"] == "merged" }) && sha_in_release_branch(event["commit_id"])
107
+ if pr["events"] && (event = pr["events"].find { |e| e["event"] == "merged" }) && sha_in_release_branch?(event["commit_id"])
110
108
  found = true
111
109
  i += 1
112
110
  print("Associating PRs with tags: #{i}/#{total}\r") if @options[:verbose]
@@ -139,7 +137,7 @@ module GitHubChangelogGenerator
139
137
  pr["first_occurring_tag"] = oldest_tag["name"]
140
138
  found = true
141
139
  i += 1
142
- elsif sha_in_release_branch(rebased_sha)
140
+ elsif sha_in_release_branch?(rebased_sha)
143
141
  found = true
144
142
  i += 1
145
143
  else
@@ -197,11 +195,9 @@ module GitHubChangelogGenerator
197
195
  #
198
196
  # @param [String] sha SHA to check.
199
197
  # @return [Boolean] True if SHA is in the branch git history.
200
- def sha_in_release_branch(sha)
198
+ def sha_in_release_branch?(sha)
201
199
  branch = @options[:release_branch] || @fetcher.default_branch
202
- commits_in_branch = @fetcher.fetch_compare(@fetcher.oldest_commit["sha"], branch)
203
- shas_in_branch = commits_in_branch["commits"].collect { |commit| commit["sha"] }
204
- shas_in_branch.include?(sha)
200
+ @fetcher.commits_in_branch(branch).include?(sha)
205
201
  end
206
202
  end
207
203
  end
@@ -69,6 +69,9 @@ module GitHubChangelogGenerator
69
69
  # leave issues without milestones
70
70
  if issue["milestone"].nil?
71
71
  true
72
+ # remove issues of open milestones if option is set
73
+ elsif issue["milestone"]["state"] == "open"
74
+ @options[:issues_of_open_milestones]
72
75
  else
73
76
  # check, that this milestone in tag list:
74
77
  @filtered_tags.find { |tag| tag["name"] == issue["milestone"]["title"] }.nil?
@@ -130,21 +133,19 @@ module GitHubChangelogGenerator
130
133
  end
131
134
 
132
135
  def tag_older_new_tag?(newer_tag_time, time)
133
- tag_in_range_new = if newer_tag_time.nil?
134
- true
135
- else
136
- time <= newer_tag_time
137
- end
138
- tag_in_range_new
136
+ if newer_tag_time.nil?
137
+ true
138
+ else
139
+ time <= newer_tag_time
140
+ end
139
141
  end
140
142
 
141
143
  def tag_newer_old_tag?(older_tag_time, time)
142
- tag_in_range_old = if older_tag_time.nil?
143
- true
144
- else
145
- time > older_tag_time
146
- end
147
- tag_in_range_old
144
+ if older_tag_time.nil?
145
+ true
146
+ else
147
+ time > older_tag_time
148
+ end
148
149
  end
149
150
 
150
151
  # Include issues with labels, specified in :include_labels
@@ -152,22 +153,24 @@ module GitHubChangelogGenerator
152
153
  # @return [Array] filtered array of issues
153
154
  def include_issues_by_labels(issues)
154
155
  filtered_issues = filter_by_include_labels(issues)
155
- filtered_issues = filter_wo_labels(filtered_issues)
156
- filtered_issues
156
+ filter_wo_labels(filtered_issues)
157
157
  end
158
158
 
159
- # @param [Array] issues Issues & PRs to filter when without labels
159
+ # @param [Array] items Issues & PRs to filter when without labels
160
160
  # @return [Array] Issues & PRs without labels or empty array if
161
161
  # add_issues_wo_labels or add_pr_wo_labels are false
162
- def filter_wo_labels(issues)
163
- if (!issues.empty? && issues.first.key?("pull_requests") && options[:add_pr_wo_labels]) || options[:add_issues_wo_labels]
164
- issues
165
- else
166
- issues.select { |issue| issue["labels"].map { |l| l["name"] }.any? }
162
+ def filter_wo_labels(items)
163
+ if items.any? && items.first.key?("pull_request")
164
+ return items if options[:add_pr_wo_labels]
165
+ elsif options[:add_issues_wo_labels]
166
+ return items
167
167
  end
168
+ # The default is to filter items without labels
169
+ items.select { |item| item["labels"].map { |l| l["name"] }.any? }
168
170
  end
169
171
 
170
172
  # @todo Document this
173
+ # @param [Object] issues
171
174
  def filter_by_include_labels(issues)
172
175
  if options[:include_labels].nil?
173
176
  issues
@@ -11,15 +11,10 @@ module GitHubChangelogGenerator
11
11
  fetch_tags_dates(all_tags) # Creates a Hash @tag_times_hash
12
12
  all_sorted_tags = sort_tags_by_date(all_tags)
13
13
 
14
- @sorted_tags = filter_excluded_tags(all_sorted_tags)
14
+ @sorted_tags = filter_included_tags(all_sorted_tags)
15
+ @sorted_tags = filter_excluded_tags(@sorted_tags)
15
16
  @filtered_tags = get_filtered_tags(@sorted_tags)
16
-
17
- # Because we need to properly create compare links, we need a sorted list
18
- # of all filtered tags (including the excluded ones). We'll exclude those
19
- # tags from section headers inside the mapping function.
20
- section_tags = get_filtered_tags(all_sorted_tags)
21
-
22
- @tag_section_mapping = build_tag_section_mapping(section_tags, @filtered_tags)
17
+ @tag_section_mapping = build_tag_section_mapping(@filtered_tags, @filtered_tags)
23
18
 
24
19
  @filtered_tags
25
20
  end
@@ -83,7 +78,7 @@ module GitHubChangelogGenerator
83
78
  # @return [Array] link, name and time of the tag
84
79
  def detect_link_tag_time(newer_tag)
85
80
  # if tag is nil - set current time
86
- newer_tag_time = newer_tag.nil? ? Time.new : get_time_of_tag(newer_tag)
81
+ newer_tag_time = newer_tag.nil? ? Time.new.getutc : get_time_of_tag(newer_tag)
87
82
 
88
83
  # if it's future release tag - set this value
89
84
  if newer_tag.nil? && options[:future_release]
@@ -161,6 +156,17 @@ module GitHubChangelogGenerator
161
156
  filtered_tags
162
157
  end
163
158
 
159
+ # @param [Array] all_tags all tags
160
+ # @return [Array] filtered tags according to :include_tags_regex option
161
+ def filter_included_tags(all_tags)
162
+ if options[:include_tags_regex]
163
+ regex = Regexp.new(options[:include_tags_regex])
164
+ all_tags.select { |tag| regex =~ tag["name"] }
165
+ else
166
+ all_tags
167
+ end
168
+ end
169
+
164
170
  # @param [Array] all_tags all tags
165
171
  # @return [Array] filtered tags according :exclude_tags or :exclude_tags_regex option
166
172
  def filter_excluded_tags(all_tags)
@@ -7,7 +7,23 @@ module GitHubChangelogGenerator
7
7
  #
8
8
  # @see GitHubChangelogGenerator::Entry
9
9
  class Section
10
- attr_accessor :name, :prefix, :issues, :labels, :body_only
10
+ # @return [String]
11
+ attr_accessor :name
12
+
13
+ # @return [String] a merge prefix, or an issue prefix
14
+ attr_reader :prefix
15
+
16
+ # @return [Array<Hash>]
17
+ attr_reader :issues
18
+
19
+ # @return [Array<String>]
20
+ attr_reader :labels
21
+
22
+ # @return [Boolean]
23
+ attr_reader :body_only
24
+
25
+ # @return [Options]
26
+ attr_reader :options
11
27
 
12
28
  def initialize(opts = {})
13
29
  @name = opts[:name]
@@ -16,11 +32,12 @@ module GitHubChangelogGenerator
16
32
  @issues = opts[:issues] || []
17
33
  @options = opts[:options] || Options.new({})
18
34
  @body_only = opts[:body_only] || false
35
+ @entry = Entry.new(options)
19
36
  end
20
37
 
21
38
  # Returns the content of a section.
22
39
  #
23
- # @return [String] Generate section content
40
+ # @return [String] Generated section content
24
41
  def generate_content
25
42
  content = ""
26
43
 
@@ -49,7 +66,7 @@ module GitHubChangelogGenerator
49
66
  encapsulated_title = encapsulate_string issue["title"]
50
67
 
51
68
  title_with_number = "#{encapsulated_title} [\\##{issue['number']}](#{issue['html_url']})"
52
- title_with_number = "#{title_with_number}#{line_labels_for(issue)}" if @options[:issue_line_labels].present?
69
+ title_with_number = "#{title_with_number}#{@entry.line_labels_for(issue)}" if @options[:issue_line_labels].present?
53
70
  line = issue_line_with_user(title_with_number, issue)
54
71
  issue_line_with_body(line, issue)
55
72
  end
@@ -60,16 +77,16 @@ module GitHubChangelogGenerator
60
77
 
61
78
  # get issue body till first line break
62
79
  body_paragraph = body_till_first_break(issue["body"])
63
- # remove spaces from begining and end of the string
80
+ # remove spaces from beginning of the string
64
81
  body_paragraph.rstrip!
65
82
  # encapsulate to md
66
- encapsulated_body = "\s\s\n" + encapsulate_string(body_paragraph)
83
+ encapsulated_body = " \n#{encapsulate_string(body_paragraph)}"
67
84
 
68
85
  "**#{line}** #{encapsulated_body}"
69
86
  end
70
87
 
71
88
  def body_till_first_break(body)
72
- body.split(/\n/).first
89
+ body.split(/\n/, 2).first
73
90
  end
74
91
 
75
92
  def issue_line_with_user(line, issue)
@@ -95,7 +112,10 @@ module GitHubChangelogGenerator
95
112
  string = string.gsub('\\', '\\\\')
96
113
 
97
114
  ENCAPSULATED_CHARACTERS.each do |char|
98
- string = string.gsub(char, "\\#{char}")
115
+ # Only replace char with escaped version if it isn't inside backticks (markdown inline code).
116
+ # This relies on each opening '`' being closed (ie an even number in total).
117
+ # A char is *outside* backticks if there is an even number of backticks following it.
118
+ string = string.gsub(%r{#{Regexp.escape(char)}(?=([^`]*`[^`]*`)*[^`]*$)}, "\\#{char}")
99
119
  end
100
120
 
101
121
  string
@@ -14,7 +14,7 @@ module GitHubChangelogGenerator
14
14
  @log ||= if test?
15
15
  Logger.new(nil) # don't show any logs when running tests
16
16
  else
17
- Logger.new(STDOUT)
17
+ Logger.new($stdout)
18
18
  end
19
19
  @log.formatter = proc do |severity, _datetime, _progname, msg|
20
20
  string = "#{msg}\n"
@@ -2,6 +2,12 @@
2
2
 
3
3
  require "tmpdir"
4
4
  require "retriable"
5
+ require "set"
6
+ require "async"
7
+ require "async/barrier"
8
+ require "async/semaphore"
9
+ require "async/http/faraday"
10
+
5
11
  module GitHubChangelogGenerator
6
12
  # A Fetcher responsible for all requests to GitHub and all basic manipulation with related data
7
13
  # (such as filtering, validating, e.t.c)
@@ -9,14 +15,14 @@ module GitHubChangelogGenerator
9
15
  # Example:
10
16
  # fetcher = GitHubChangelogGenerator::OctoFetcher.new(options)
11
17
  class OctoFetcher
12
- PER_PAGE_NUMBER = 100
13
- MAX_THREAD_NUMBER = 10
18
+ PER_PAGE_NUMBER = 100
19
+ MAXIMUM_CONNECTIONS = 50
14
20
  MAX_FORBIDDEN_RETRIES = 100
15
21
  CHANGELOG_GITHUB_TOKEN = "CHANGELOG_GITHUB_TOKEN"
16
22
  GH_RATE_LIMIT_EXCEEDED_MSG = "Warning: Can't finish operation: GitHub API rate limit exceeded, changelog may be " \
17
23
  "missing some issues. You can limit the number of issues fetched using the `--max-issues NUM` argument."
18
24
  NO_TOKEN_PROVIDED = "Warning: No token provided (-t option) and variable $CHANGELOG_GITHUB_TOKEN was not found. " \
19
- "This script can make only 50 requests to GitHub API per hour without token!"
25
+ "This script can make only 50 requests to GitHub API per hour without a token!"
20
26
 
21
27
  # @param options [Hash] Options passed in
22
28
  # @option options [String] :user GitHub username
@@ -31,47 +37,58 @@ module GitHubChangelogGenerator
31
37
  @project = @options[:project]
32
38
  @since = @options[:since]
33
39
  @http_cache = @options[:http_cache]
34
- @cache_file = nil
35
- @cache_log = nil
36
40
  @commits = []
37
- @compares = {}
38
- prepare_cache
39
- configure_octokit_ssl
40
- @client = Octokit::Client.new(github_options)
41
+ @branches = nil
42
+ @graph = nil
43
+ @client = nil
44
+ @commits_in_tag_cache = {}
45
+ end
46
+
47
+ def middleware
48
+ Faraday::RackBuilder.new do |builder|
49
+ if @http_cache
50
+ cache_file = @options.fetch(:cache_file) { File.join(Dir.tmpdir, "github-changelog-http-cache") }
51
+ cache_log = @options.fetch(:cache_log) { File.join(Dir.tmpdir, "github-changelog-logger.log") }
52
+
53
+ builder.use(
54
+ Faraday::HttpCache,
55
+ serializer: Marshal,
56
+ store: ActiveSupport::Cache::FileStore.new(cache_file),
57
+ logger: Logger.new(cache_log),
58
+ shared_cache: false
59
+ )
60
+ end
61
+
62
+ builder.use Octokit::Response::RaiseError
63
+ builder.adapter :async_http
64
+ end
41
65
  end
42
66
 
43
- def prepare_cache
44
- return unless @http_cache
67
+ def connection_options
68
+ ca_file = @options[:ssl_ca_file] || ENV["SSL_CA_FILE"] || File.expand_path("ssl_certs/cacert.pem", __dir__)
45
69
 
46
- @cache_file = @options.fetch(:cache_file) { File.join(Dir.tmpdir, "github-changelog-http-cache") }
47
- @cache_log = @options.fetch(:cache_log) { File.join(Dir.tmpdir, "github-changelog-logger.log") }
48
- init_cache
70
+ Octokit.connection_options.merge({ ssl: { ca_file: ca_file } })
49
71
  end
50
72
 
51
- def github_options
52
- result = {}
53
- github_token = fetch_github_token
54
- result[:access_token] = github_token if github_token
55
- endpoint = @options[:github_endpoint]
56
- result[:api_endpoint] = endpoint if endpoint
57
- result
58
- end
73
+ def client_options
74
+ options = {
75
+ middleware: middleware,
76
+ connection_options: connection_options
77
+ }
59
78
 
60
- def configure_octokit_ssl
61
- ca_file = @options[:ssl_ca_file] || ENV["SSL_CA_FILE"] || File.expand_path("ssl_certs/cacert.pem", __dir__)
62
- Octokit.connection_options = { ssl: { ca_file: ca_file } }
63
- end
79
+ if (github_token = fetch_github_token)
80
+ options[:access_token] = github_token
81
+ end
64
82
 
65
- def init_cache
66
- Octokit.middleware = Faraday::RackBuilder.new do |builder|
67
- builder.use(Faraday::HttpCache, serializer: Marshal,
68
- store: ActiveSupport::Cache::FileStore.new(@cache_file),
69
- logger: Logger.new(@cache_log),
70
- shared_cache: false)
71
- builder.use Octokit::Response::RaiseError
72
- builder.adapter Faraday.default_adapter
73
- # builder.response :logger
83
+ if (endpoint = @options[:github_endpoint])
84
+ options[:api_endpoint] = endpoint
74
85
  end
86
+
87
+ options
88
+ end
89
+
90
+ def client
91
+ @client ||= Octokit::Client.new(client_options)
75
92
  end
76
93
 
77
94
  DEFAULT_REQUEST_OPTIONS = { per_page: PER_PAGE_NUMBER }
@@ -88,6 +105,9 @@ module GitHubChangelogGenerator
88
105
  # Returns the number of pages for a API call
89
106
  #
90
107
  # @return [Integer] number of pages for this API call in total
108
+ # @param [Object] request_options
109
+ # @param [Object] method
110
+ # @param [Object] client
91
111
  def calculate_pages(client, method, request_options)
92
112
  # Makes the first API call so that we can call last_response
93
113
  check_github_response do
@@ -107,11 +127,11 @@ module GitHubChangelogGenerator
107
127
  #
108
128
  # @return [Array <Hash>] array of tags in repo
109
129
  def github_fetch_tags
110
- tags = []
111
- page_i = 0
112
- count_pages = calculate_pages(@client, "tags", {})
130
+ tags = []
131
+ page_i = 0
132
+ count_pages = calculate_pages(client, "tags", {})
113
133
 
114
- iterate_pages(@client, "tags") do |new_tags|
134
+ iterate_pages(client, "tags") do |new_tags|
115
135
  page_i += PER_PAGE_NUMBER
116
136
  print_in_same_line("Fetching tags... #{page_i}/#{count_pages * PER_PAGE_NUMBER}")
117
137
  tags.concat(new_tags)
@@ -142,9 +162,9 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
142
162
  print "Fetching closed issues...\r" if @options[:verbose]
143
163
  issues = []
144
164
  page_i = 0
145
- count_pages = calculate_pages(@client, "issues", closed_pr_options)
165
+ count_pages = calculate_pages(client, "issues", closed_pr_options)
146
166
 
147
- iterate_pages(@client, "issues", closed_pr_options) do |new_issues|
167
+ iterate_pages(client, "issues", **closed_pr_options) do |new_issues|
148
168
  page_i += PER_PAGE_NUMBER
149
169
  print_in_same_line("Fetching issues... #{page_i}/#{count_pages * PER_PAGE_NUMBER}")
150
170
  issues.concat(new_issues)
@@ -165,10 +185,10 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
165
185
  pull_requests = []
166
186
  options = { state: "closed" }
167
187
 
168
- page_i = 0
169
- count_pages = calculate_pages(@client, "pull_requests", options)
188
+ page_i = 0
189
+ count_pages = calculate_pages(client, "pull_requests", options)
170
190
 
171
- iterate_pages(@client, "pull_requests", options) do |new_pr|
191
+ iterate_pages(client, "pull_requests", **options) do |new_pr|
172
192
  page_i += PER_PAGE_NUMBER
173
193
  log_string = "Fetching merged dates... #{page_i}/#{count_pages * PER_PAGE_NUMBER}"
174
194
  print_in_same_line(log_string)
@@ -185,14 +205,20 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
185
205
  # @param [Array] issues
186
206
  # @return [Void]
187
207
  def fetch_events_async(issues)
188
- i = 0
189
- threads = []
208
+ i = 0
209
+ # Add accept option explicitly for disabling the warning of preview API.
210
+ preview = { accept: Octokit::Preview::PREVIEW_TYPES[:project_card_events] }
211
+
212
+ barrier = Async::Barrier.new
213
+ semaphore = Async::Semaphore.new(MAXIMUM_CONNECTIONS, parent: barrier)
214
+
215
+ Sync do
216
+ client = self.client
190
217
 
191
- issues.each_slice(MAX_THREAD_NUMBER) do |issues_slice|
192
- issues_slice.each do |issue|
193
- threads << Thread.new do
218
+ issues.each do |issue|
219
+ semaphore.async do
194
220
  issue["events"] = []
195
- iterate_pages(@client, "issue_events", issue["number"]) do |new_event|
221
+ iterate_pages(client, "issue_events", issue["number"], **preview) do |new_event|
196
222
  issue["events"].concat(new_event)
197
223
  end
198
224
  issue["events"] = issue["events"].map { |event| stringify_keys_deep(event.to_hash) }
@@ -200,12 +226,12 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
200
226
  i += 1
201
227
  end
202
228
  end
203
- threads.each(&:join)
204
- threads = []
205
- end
206
229
 
207
- # to clear line from prev print
208
- print_empty_line
230
+ barrier.wait
231
+
232
+ # to clear line from prev print
233
+ print_empty_line
234
+ end
209
235
 
210
236
  Helper.log.info "Fetching events for issues and PR: #{i}"
211
237
  end
@@ -215,21 +241,25 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
215
241
  # @param [Array] prs The array of PRs.
216
242
  # @return [Void] No return; PRs are updated in-place.
217
243
  def fetch_comments_async(prs)
218
- threads = []
244
+ barrier = Async::Barrier.new
245
+ semaphore = Async::Semaphore.new(MAXIMUM_CONNECTIONS, parent: barrier)
219
246
 
220
- prs.each_slice(MAX_THREAD_NUMBER) do |prs_slice|
221
- prs_slice.each do |pr|
222
- threads << Thread.new do
247
+ Sync do
248
+ client = self.client
249
+
250
+ prs.each do |pr|
251
+ semaphore.async do
223
252
  pr["comments"] = []
224
- iterate_pages(@client, "issue_comments", pr["number"]) do |new_comment|
253
+ iterate_pages(client, "issue_comments", pr["number"]) do |new_comment|
225
254
  pr["comments"].concat(new_comment)
226
255
  end
227
256
  pr["comments"] = pr["comments"].map { |comment| stringify_keys_deep(comment.to_hash) }
228
257
  end
229
258
  end
230
- threads.each(&:join)
231
- threads = []
259
+
260
+ barrier.wait
232
261
  end
262
+
233
263
  nil
234
264
  end
235
265
 
@@ -245,21 +275,6 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
245
275
  commit_data["commit"]["committer"]["date"]
246
276
  end
247
277
 
248
- # Fetch and cache comparison between two github refs
249
- #
250
- # @param [String] older The older sha/tag/branch.
251
- # @param [String] newer The newer sha/tag/branch.
252
- # @return [Hash] Github api response for comparison.
253
- def fetch_compare(older, newer)
254
- unless @compares["#{older}...#{newer}"]
255
- compare_data = check_github_response { @client.compare(user_project, older, newer || "HEAD") }
256
- raise StandardError, "Sha #{older} and sha #{newer} are not related; please file a github-changelog-generator issues and describe how to replicate this issue." if compare_data["status"] == "diverged"
257
-
258
- @compares["#{older}...#{newer}"] = stringify_keys_deep(compare_data.to_hash)
259
- end
260
- @compares["#{older}...#{newer}"]
261
- end
262
-
263
278
  # Fetch commit for specified event
264
279
  #
265
280
  # @param [String] commit_id the SHA of a commit to fetch
@@ -271,9 +286,11 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
271
286
  if found
272
287
  stringify_keys_deep(found.to_hash)
273
288
  else
289
+ client = self.client
290
+
274
291
  # cache miss; don't add to @commits because unsure of order.
275
292
  check_github_response do
276
- commit = @client.commit(user_project, commit_id)
293
+ commit = client.commit(user_project, commit_id)
277
294
  commit = stringify_keys_deep(commit.to_hash)
278
295
  commit
279
296
  end
@@ -285,8 +302,25 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
285
302
  # @return [Array] Commits in a repo.
286
303
  def commits
287
304
  if @commits.empty?
288
- iterate_pages(@client, "commits") do |new_commits|
289
- @commits.concat(new_commits)
305
+ Sync do
306
+ barrier = Async::Barrier.new
307
+ semaphore = Async::Semaphore.new(MAXIMUM_CONNECTIONS, parent: barrier)
308
+
309
+ if (since_commit = @options[:since_commit])
310
+ iterate_pages(client, "commits_since", since_commit, parent: semaphore) do |new_commits|
311
+ @commits.concat(new_commits)
312
+ end
313
+ else
314
+ iterate_pages(client, "commits", parent: semaphore) do |new_commits|
315
+ @commits.concat(new_commits)
316
+ end
317
+ end
318
+
319
+ barrier.wait
320
+
321
+ @commits.sort! do |b, a|
322
+ a[:commit][:author][:date] <=> b[:commit][:author][:date]
323
+ end
290
324
  end
291
325
  end
292
326
  @commits
@@ -301,42 +335,63 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
301
335
 
302
336
  # @return [String] Default branch of the repo
303
337
  def default_branch
304
- @default_branch ||= @client.repository(user_project)[:default_branch]
338
+ @default_branch ||= client.repository(user_project)[:default_branch]
339
+ end
340
+
341
+ # @param [String] name
342
+ # @return [Array<String>]
343
+ def commits_in_branch(name)
344
+ @branches ||= client.branches(user_project).map { |branch| [branch[:name], branch] }.to_h
345
+
346
+ if (branch = @branches[name])
347
+ commits_in_tag(branch[:commit][:sha])
348
+ else
349
+ []
350
+ end
305
351
  end
306
352
 
307
353
  # Fetch all SHAs occurring in or before a given tag and add them to
308
354
  # "shas_in_tag"
309
355
  #
310
356
  # @param [Array] tags The array of tags.
311
- # @return [Nil] No return; tags are updated in-place.
312
- def fetch_tag_shas_async(tags)
313
- i = 0
314
- threads = []
315
- print_in_same_line("Fetching SHAs for tags: #{i}/#{tags.count}\r") if @options[:verbose]
316
-
317
- tags.each_slice(MAX_THREAD_NUMBER) do |tags_slice|
318
- tags_slice.each do |tag|
319
- threads << Thread.new do
320
- # Use oldest commit because comparing two arbitrary tags may be diverged
321
- commits_in_tag = fetch_compare(oldest_commit["sha"], tag["name"])
322
- tag["shas_in_tag"] = commits_in_tag["commits"].collect { |commit| commit["sha"] }
323
- print_in_same_line("Fetching SHAs for tags: #{i + 1}/#{tags.count}") if @options[:verbose]
324
- i += 1
357
+ # @return void
358
+ def fetch_tag_shas(tags)
359
+ # Reverse the tags array to gain max benefit from the @commits_in_tag_cache
360
+ tags.reverse_each do |tag|
361
+ tag["shas_in_tag"] = commits_in_tag(tag["commit"]["sha"])
362
+ end
363
+ end
364
+
365
+ private
366
+
367
+ # @param [Set] shas
368
+ # @param [Object] sha
369
+ def commits_in_tag(sha, shas = Set.new)
370
+ # Reduce multiple runs for the same tag
371
+ return @commits_in_tag_cache[sha] if @commits_in_tag_cache.key?(sha)
372
+
373
+ @graph ||= commits.map { |commit| [commit[:sha], commit] }.to_h
374
+ return shas unless (current = @graph[sha])
375
+
376
+ queue = [current]
377
+ while queue.any?
378
+ commit = queue.shift
379
+ # If we've already processed this sha, just grab it's parents from the cache
380
+ if @commits_in_tag_cache.key?(commit[:sha])
381
+ shas.merge(@commits_in_tag_cache[commit[:sha]])
382
+ else
383
+ shas.add(commit[:sha])
384
+ commit[:parents].each do |p|
385
+ queue.push(@graph[p[:sha]]) unless shas.include?(p[:sha])
325
386
  end
326
387
  end
327
- threads.each(&:join)
328
- threads = []
329
388
  end
330
389
 
331
- # to clear line from prev print
332
- print_empty_line
333
-
334
- Helper.log.info "Fetching SHAs for tags: #{i}"
335
- nil
390
+ @commits_in_tag_cache[sha] = shas
391
+ shas
336
392
  end
337
393
 
338
- private
339
-
394
+ # @param [Object] indata
340
395
  def stringify_keys_deep(indata)
341
396
  case indata
342
397
  when Array
@@ -360,43 +415,49 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
360
415
  #
361
416
  # @param [Octokit::Client] client
362
417
  # @param [String] method (eg. 'tags')
418
+ # @param [Array] arguments
419
+ # @param [Async::Semaphore] parent
363
420
  #
364
421
  # @yield [Sawyer::Resource] An OctoKit-provided response (which can be empty)
365
422
  #
366
423
  # @return [void]
367
- def iterate_pages(client, method, *args)
368
- args << DEFAULT_REQUEST_OPTIONS.merge(extract_request_args(args))
424
+ # @param [Hash] options
425
+ def iterate_pages(client, method, *arguments, parent: nil, **options)
426
+ options = DEFAULT_REQUEST_OPTIONS.merge(options)
369
427
 
370
- check_github_response { client.send(method, user_project, *args) }
428
+ check_github_response { client.send(method, user_project, *arguments, **options) }
371
429
  last_response = client.last_response.tap do |response|
372
430
  raise(MovedPermanentlyError, response.data[:url]) if response.status == 301
373
431
  end
374
432
 
375
433
  yield(last_response.data)
376
434
 
377
- until (next_one = last_response.rels[:next]).nil?
378
- last_response = check_github_response { next_one.get }
379
- yield(last_response.data)
380
- end
381
- end
382
-
383
- def extract_request_args(args)
384
- if args.size == 1 && args.first.is_a?(Hash)
385
- args.delete_at(0)
386
- elsif args.size > 1 && args.last.is_a?(Hash)
387
- args.delete_at(args.length - 1)
388
- else
389
- {}
435
+ if parent.nil?
436
+ # The snail visits one leaf at a time:
437
+ until (next_one = last_response.rels[:next]).nil?
438
+ last_response = check_github_response { next_one.get }
439
+ yield(last_response.data)
440
+ end
441
+ elsif (last = last_response.rels[:last])
442
+ # OR we bring out the gatling gun:
443
+ parameters = querystring_as_hash(last.href)
444
+ last_page = Integer(parameters["page"])
445
+
446
+ (2..last_page).each do |page|
447
+ parent.async do
448
+ data = check_github_response { client.send(method, user_project, *arguments, page: page, **options) }
449
+ yield data
450
+ end
451
+ end
390
452
  end
391
453
  end
392
454
 
393
455
  # This is wrapper with rescue block
394
456
  #
395
457
  # @return [Object] returns exactly the same, what you put in the block, but wrap it with begin-rescue block
396
- def check_github_response
397
- Retriable.retriable(retry_options) do
398
- yield
399
- end
458
+ # @param [Proc] block
459
+ def check_github_response(&block)
460
+ Retriable.retriable(retry_options, &block)
400
461
  rescue MovedPermanentlyError => e
401
462
  fail_with_message(e, "The repository has moved, update your configuration")
402
463
  rescue Octokit::Forbidden => e
@@ -406,6 +467,8 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
406
467
  end
407
468
 
408
469
  # Presents the exception, and the aborts with the message.
470
+ # @param [Object] message
471
+ # @param [Object] error
409
472
  def fail_with_message(error, message)
410
473
  Helper.log.error("#{error.class}: #{error.message}")
411
474
  sys_abort(message)
@@ -432,10 +495,11 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
432
495
  Helper.log.warn("RETRY - #{exception.class}: '#{exception.message}'")
433
496
  Helper.log.warn("#{try} tries in #{elapsed_time} seconds and #{next_interval} seconds until the next try")
434
497
  Helper.log.warn GH_RATE_LIMIT_EXCEEDED_MSG
435
- Helper.log.warn @client.rate_limit
498
+ Helper.log.warn(client.rate_limit)
436
499
  end
437
500
  end
438
501
 
502
+ # @param [Object] msg
439
503
  def sys_abort(msg)
440
504
  abort(msg)
441
505
  end
@@ -444,7 +508,7 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
444
508
  #
445
509
  # @param [String] log_string
446
510
  def print_in_same_line(log_string)
447
- print log_string + "\r"
511
+ print "#{log_string}\r"
448
512
  end
449
513
 
450
514
  # Print long line with spaces on same line to clear prev message