github_changelog_generator 1.15.0.pre.rc → 1.16.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. checksums.yaml +5 -5
  2. data/LICENSE +1 -1
  3. data/README.md +134 -81
  4. data/Rakefile +1 -1
  5. data/bin/git-generate-changelog +1 -1
  6. data/lib/github_changelog_generator.rb +10 -6
  7. data/lib/github_changelog_generator/argv_parser.rb +224 -0
  8. data/lib/github_changelog_generator/generator/entry.rb +218 -0
  9. data/lib/github_changelog_generator/generator/generator.rb +120 -121
  10. data/lib/github_changelog_generator/generator/generator_fetcher.rb +138 -23
  11. data/lib/github_changelog_generator/generator/generator_processor.rb +60 -27
  12. data/lib/github_changelog_generator/generator/generator_tags.rb +25 -21
  13. data/lib/github_changelog_generator/generator/section.rb +124 -0
  14. data/lib/github_changelog_generator/helper.rb +1 -1
  15. data/lib/github_changelog_generator/octo_fetcher.rb +250 -96
  16. data/lib/github_changelog_generator/options.rb +39 -4
  17. data/lib/github_changelog_generator/parser.rb +70 -209
  18. data/lib/github_changelog_generator/parser_file.rb +35 -16
  19. data/lib/github_changelog_generator/reader.rb +2 -2
  20. data/lib/github_changelog_generator/ssl_certs/cacert.pem +851 -1680
  21. data/lib/github_changelog_generator/task.rb +4 -4
  22. data/lib/github_changelog_generator/version.rb +1 -1
  23. data/man/git-generate-changelog.1 +160 -74
  24. data/man/git-generate-changelog.1.html +159 -95
  25. data/man/git-generate-changelog.html +36 -24
  26. data/man/git-generate-changelog.md +156 -93
  27. data/spec/files/config_example +5 -0
  28. data/spec/files/github-changelog-generator.md +114 -114
  29. data/spec/{install-gem-in-bundler.gemfile → install_gem_in_bundler.gemfile} +2 -0
  30. data/spec/spec_helper.rb +2 -6
  31. data/spec/unit/generator/entry_spec.rb +766 -0
  32. data/spec/unit/generator/generator_processor_spec.rb +103 -41
  33. data/spec/unit/generator/generator_spec.rb +47 -0
  34. data/spec/unit/generator/generator_tags_spec.rb +51 -24
  35. data/spec/unit/generator/section_spec.rb +34 -0
  36. data/spec/unit/octo_fetcher_spec.rb +247 -197
  37. data/spec/unit/options_spec.rb +24 -0
  38. data/spec/unit/parse_file_spec.rb +2 -2
  39. data/spec/unit/parser_spec.rb +50 -0
  40. data/spec/unit/reader_spec.rb +4 -4
  41. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_commits/when_API_is_valid/returns_commits.json +1 -0
  42. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_commits_before/when_API_is_valid/returns_commits.json +1 -1
  43. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid.json +1 -1
  44. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_issue_with_proper_key/values.json +1 -1
  45. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_issues.json +1 -1
  46. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_issues_with_labels.json +1 -1
  47. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_pull_request_with_proper_key/values.json +1 -1
  48. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_pull_requests_with_labels.json +1 -1
  49. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_pull_requests/when_API_call_is_valid.json +1 -1
  50. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_pull_requests/when_API_call_is_valid/returns_correct_pull_request_keys.json +1 -1
  51. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_pull_requests/when_API_call_is_valid/returns_pull_requests.json +1 -1
  52. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_commit/when_API_call_is_valid.json +1 -1
  53. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_commit/when_API_call_is_valid/returns_commit.json +1 -1
  54. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_date_of_tag/when_API_call_is_valid.json +1 -1
  55. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_date_of_tag/when_API_call_is_valid/returns_date.json +1 -1
  56. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_events_async/when_API_call_is_valid.json +1 -1
  57. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_events_async/when_API_call_is_valid/populates_issues.json +1 -1
  58. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_API_call_is_valid.json +1 -1
  59. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_API_call_is_valid/should_return_tags.json +1 -1
  60. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_API_call_is_valid/should_return_tags_count.json +1 -1
  61. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_wrong_token_provided.json +1 -1
  62. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_wrong_token_provided/should_raise_Unauthorized_error.json +1 -1
  63. metadata +56 -20
  64. data/bin/ghclgen +0 -5
  65. data/lib/github_changelog_generator/generator/generator_generation.rb +0 -181
  66. data/spec/unit/generator/generator_generation_spec.rb +0 -73
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GitHubChangelogGenerator
4
+ # This class generates the content for a single section of a changelog entry.
5
+ # It turns the tagged issues and PRs into a well-formatted list of changes to
6
+ # be later incorporated into a changelog entry.
7
+ #
8
+ # @see GitHubChangelogGenerator::Entry
9
+ class Section
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
27
+
28
+ def initialize(opts = {})
29
+ @name = opts[:name]
30
+ @prefix = opts[:prefix]
31
+ @labels = opts[:labels] || []
32
+ @issues = opts[:issues] || []
33
+ @options = opts[:options] || Options.new({})
34
+ @body_only = opts[:body_only] || false
35
+ @entry = Entry.new(options)
36
+ end
37
+
38
+ # Returns the content of a section.
39
+ #
40
+ # @return [String] Generated section content
41
+ def generate_content
42
+ content = ""
43
+
44
+ if @issues.any?
45
+ content += "#{@prefix}\n\n" unless @options[:simple_list] || @prefix.blank?
46
+ @issues.each do |issue|
47
+ merge_string = get_string_for_issue(issue)
48
+ content += "- " unless @body_only
49
+ content += "#{merge_string}\n"
50
+ end
51
+ content += "\n"
52
+ end
53
+ content
54
+ end
55
+
56
+ private
57
+
58
+ # Parse issue and generate single line formatted issue line.
59
+ #
60
+ # Example output:
61
+ # - Add coveralls integration [\#223](https://github.com/github-changelog-generator/github-changelog-generator/pull/223) (@github-changelog-generator)
62
+ #
63
+ # @param [Hash] issue Fetched issue from GitHub
64
+ # @return [String] Markdown-formatted single issue
65
+ def get_string_for_issue(issue)
66
+ encapsulated_title = encapsulate_string issue["title"]
67
+
68
+ title_with_number = "#{encapsulated_title} [\\##{issue['number']}](#{issue['html_url']})"
69
+ title_with_number = "#{title_with_number}#{@entry.line_labels_for(issue)}" if @options[:issue_line_labels].present?
70
+ line = issue_line_with_user(title_with_number, issue)
71
+ issue_line_with_body(line, issue)
72
+ end
73
+
74
+ def issue_line_with_body(line, issue)
75
+ return issue["body"] if @body_only && issue["body"].present?
76
+ return line if !@options[:issue_line_body] || issue["body"].blank?
77
+
78
+ # get issue body till first line break
79
+ body_paragraph = body_till_first_break(issue["body"])
80
+ # remove spaces from beginning of the string
81
+ body_paragraph.rstrip!
82
+ # encapsulate to md
83
+ encapsulated_body = " \n#{encapsulate_string(body_paragraph)}"
84
+
85
+ "**#{line}** #{encapsulated_body}"
86
+ end
87
+
88
+ def body_till_first_break(body)
89
+ body.split(/\n/, 2).first
90
+ end
91
+
92
+ def issue_line_with_user(line, issue)
93
+ return line if !@options[:author] || issue["pull_request"].nil?
94
+
95
+ user = issue["user"]
96
+ return "#{line} ({Null user})" unless user
97
+
98
+ if @options[:usernames_as_github_logins]
99
+ "#{line} (@#{user['login']})"
100
+ else
101
+ "#{line} ([#{user['login']}](#{user['html_url']}))"
102
+ end
103
+ end
104
+
105
+ ENCAPSULATED_CHARACTERS = %w(< > * _ \( \) [ ] #)
106
+
107
+ # Encapsulate characters to make Markdown look as expected.
108
+ #
109
+ # @param [String] string
110
+ # @return [String] encapsulated input string
111
+ def encapsulate_string(string)
112
+ string = string.gsub('\\', '\\\\')
113
+
114
+ ENCAPSULATED_CHARACTERS.each do |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}")
119
+ end
120
+
121
+ string
122
+ end
123
+ end
124
+ end
@@ -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 = 25
18
+ PER_PAGE_NUMBER = 100
19
+ MAXIMUM_CONNECTIONS = 50
14
20
  MAX_FORBIDDEN_RETRIES = 100
15
21
  CHANGELOG_GITHUB_TOKEN = "CHANGELOG_GITHUB_TOKEN"
16
- GH_RATE_LIMIT_EXCEEDED_MSG = "Warning: Can't finish operation: GitHub API rate limit exceeded, change log may be " \
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,44 +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
- prepare_cache
37
- configure_octokit_ssl
38
- @client = Octokit::Client.new(github_options)
40
+ @commits = []
41
+ @branches = nil
42
+ @graph = nil
43
+ @client = nil
44
+ @commits_in_tag_cache = {}
39
45
  end
40
46
 
41
- def prepare_cache
42
- return unless @http_cache
43
- @cache_file = @options.fetch(:cache_file) { File.join(Dir.tmpdir, "github-changelog-http-cache") }
44
- @cache_log = @options.fetch(:cache_log) { File.join(Dir.tmpdir, "github-changelog-logger.log") }
45
- init_cache
46
- end
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
47
61
 
48
- def github_options
49
- result = {}
50
- github_token = fetch_github_token
51
- result[:access_token] = github_token if github_token
52
- endpoint = @options[:github_endpoint]
53
- result[:api_endpoint] = endpoint if endpoint
54
- result
62
+ builder.use Octokit::Response::RaiseError
63
+ builder.adapter :async_http
64
+ end
55
65
  end
56
66
 
57
- def configure_octokit_ssl
58
- ca_file = @options[:ssl_ca_file] || ENV["SSL_CA_FILE"] || File.expand_path("../ssl_certs/cacert.pem", __FILE__)
59
- Octokit.connection_options = { ssl: { ca_file: ca_file } }
67
+ def connection_options
68
+ ca_file = @options[:ssl_ca_file] || ENV["SSL_CA_FILE"] || File.expand_path("ssl_certs/cacert.pem", __dir__)
69
+
70
+ Octokit.connection_options.merge({ ssl: { ca_file: ca_file } })
60
71
  end
61
72
 
62
- def init_cache
63
- Octokit.middleware = Faraday::RackBuilder.new do |builder|
64
- builder.use(Faraday::HttpCache, serializer: Marshal,
65
- store: ActiveSupport::Cache::FileStore.new(@cache_file),
66
- logger: Logger.new(@cache_log),
67
- shared_cache: false)
68
- builder.use Octokit::Response::RaiseError
69
- builder.adapter Faraday.default_adapter
70
- # builder.response :logger
73
+ def client_options
74
+ options = {
75
+ middleware: middleware,
76
+ connection_options: connection_options
77
+ }
78
+
79
+ if (github_token = fetch_github_token)
80
+ options[:access_token] = github_token
71
81
  end
82
+
83
+ if (endpoint = @options[:github_endpoint])
84
+ options[:api_endpoint] = endpoint
85
+ end
86
+
87
+ options
88
+ end
89
+
90
+ def client
91
+ @client ||= Octokit::Client.new(client_options)
72
92
  end
73
93
 
74
94
  DEFAULT_REQUEST_OPTIONS = { per_page: PER_PAGE_NUMBER }
@@ -85,6 +105,9 @@ module GitHubChangelogGenerator
85
105
  # Returns the number of pages for a API call
86
106
  #
87
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
88
111
  def calculate_pages(client, method, request_options)
89
112
  # Makes the first API call so that we can call last_response
90
113
  check_github_response do
@@ -104,11 +127,11 @@ module GitHubChangelogGenerator
104
127
  #
105
128
  # @return [Array <Hash>] array of tags in repo
106
129
  def github_fetch_tags
107
- tags = []
108
- page_i = 0
109
- count_pages = calculate_pages(@client, "tags", {})
130
+ tags = []
131
+ page_i = 0
132
+ count_pages = calculate_pages(client, "tags", {})
110
133
 
111
- iterate_pages(@client, "tags") do |new_tags|
134
+ iterate_pages(client, "tags") do |new_tags|
112
135
  page_i += PER_PAGE_NUMBER
113
136
  print_in_same_line("Fetching tags... #{page_i}/#{count_pages * PER_PAGE_NUMBER}")
114
137
  tags.concat(new_tags)
@@ -138,10 +161,10 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
138
161
  def fetch_closed_issues_and_pr
139
162
  print "Fetching closed issues...\r" if @options[:verbose]
140
163
  issues = []
141
- page_i = 0
142
- count_pages = calculate_pages(@client, "issues", closed_pr_options)
164
+ page_i = 0
165
+ count_pages = calculate_pages(client, "issues", closed_pr_options)
143
166
 
144
- iterate_pages(@client, "issues", closed_pr_options) do |new_issues|
167
+ iterate_pages(client, "issues", **closed_pr_options) do |new_issues|
145
168
  page_i += PER_PAGE_NUMBER
146
169
  print_in_same_line("Fetching issues... #{page_i}/#{count_pages * PER_PAGE_NUMBER}")
147
170
  issues.concat(new_issues)
@@ -162,14 +185,10 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
162
185
  pull_requests = []
163
186
  options = { state: "closed" }
164
187
 
165
- unless @options[:release_branch].nil?
166
- options[:base] = @options[:release_branch]
167
- end
168
-
169
- page_i = 0
170
- count_pages = calculate_pages(@client, "pull_requests", options)
188
+ page_i = 0
189
+ count_pages = calculate_pages(client, "pull_requests", options)
171
190
 
172
- iterate_pages(@client, "pull_requests", options) do |new_pr|
191
+ iterate_pages(client, "pull_requests", **options) do |new_pr|
173
192
  page_i += PER_PAGE_NUMBER
174
193
  log_string = "Fetching merged dates... #{page_i}/#{count_pages * PER_PAGE_NUMBER}"
175
194
  print_in_same_line(log_string)
@@ -186,14 +205,20 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
186
205
  # @param [Array] issues
187
206
  # @return [Void]
188
207
  def fetch_events_async(issues)
189
- i = 0
190
- 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
191
217
 
192
- issues.each_slice(MAX_THREAD_NUMBER) do |issues_slice|
193
- issues_slice.each do |issue|
194
- threads << Thread.new do
218
+ issues.each do |issue|
219
+ semaphore.async do
195
220
  issue["events"] = []
196
- iterate_pages(@client, "issue_events", issue["number"]) do |new_event|
221
+ iterate_pages(client, "issue_events", issue["number"], **preview) do |new_event|
197
222
  issue["events"].concat(new_event)
198
223
  end
199
224
  issue["events"] = issue["events"].map { |event| stringify_keys_deep(event.to_hash) }
@@ -201,23 +226,50 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
201
226
  i += 1
202
227
  end
203
228
  end
204
- threads.each(&:join)
205
- threads = []
206
- end
207
229
 
208
- # to clear line from prev print
209
- print_empty_line
230
+ barrier.wait
231
+
232
+ # to clear line from prev print
233
+ print_empty_line
234
+ end
210
235
 
211
236
  Helper.log.info "Fetching events for issues and PR: #{i}"
212
237
  end
213
238
 
239
+ # Fetch comments for PRs and add them to "comments"
240
+ #
241
+ # @param [Array] prs The array of PRs.
242
+ # @return [Void] No return; PRs are updated in-place.
243
+ def fetch_comments_async(prs)
244
+ barrier = Async::Barrier.new
245
+ semaphore = Async::Semaphore.new(MAXIMUM_CONNECTIONS, parent: barrier)
246
+
247
+ Sync do
248
+ client = self.client
249
+
250
+ prs.each do |pr|
251
+ semaphore.async do
252
+ pr["comments"] = []
253
+ iterate_pages(client, "issue_comments", pr["number"]) do |new_comment|
254
+ pr["comments"].concat(new_comment)
255
+ end
256
+ pr["comments"] = pr["comments"].map { |comment| stringify_keys_deep(comment.to_hash) }
257
+ end
258
+ end
259
+
260
+ barrier.wait
261
+ end
262
+
263
+ nil
264
+ end
265
+
214
266
  # Fetch tag time from repo
215
267
  #
216
268
  # @param [Hash] tag GitHub data item about a Tag
217
269
  #
218
270
  # @return [Time] time of specified tag
219
271
  def fetch_date_of_tag(tag)
220
- commit_data = check_github_response { @client.commit(user_project, tag["commit"]["sha"]) }
272
+ commit_data = fetch_commit(tag["commit"]["sha"])
221
273
  commit_data = stringify_keys_deep(commit_data.to_hash)
222
274
 
223
275
  commit_data["commit"]["committer"]["date"]
@@ -225,28 +277,121 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
225
277
 
226
278
  # Fetch commit for specified event
227
279
  #
280
+ # @param [String] commit_id the SHA of a commit to fetch
228
281
  # @return [Hash]
229
- def fetch_commit(event)
230
- check_github_response do
231
- commit = @client.commit(user_project, event["commit_id"])
232
- commit = stringify_keys_deep(commit.to_hash)
233
- commit
282
+ def fetch_commit(commit_id)
283
+ found = commits.find do |commit|
284
+ commit["sha"] == commit_id
285
+ end
286
+ if found
287
+ stringify_keys_deep(found.to_hash)
288
+ else
289
+ client = self.client
290
+
291
+ # cache miss; don't add to @commits because unsure of order.
292
+ check_github_response do
293
+ commit = client.commit(user_project, commit_id)
294
+ commit = stringify_keys_deep(commit.to_hash)
295
+ commit
296
+ end
234
297
  end
235
298
  end
236
299
 
237
- # Fetch all commits before certain point
300
+ # Fetch all commits
238
301
  #
239
- # @return [String]
240
- def commits_before(start_time)
241
- commits = []
242
- iterate_pages(@client, "commits_before", start_time.to_datetime.to_s) do |new_commits|
243
- commits.concat(new_commits)
302
+ # @return [Array] Commits in a repo.
303
+ def commits
304
+ if @commits.empty?
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
324
+ end
325
+ end
326
+ @commits
327
+ end
328
+
329
+ # Return the oldest commit in a repo
330
+ #
331
+ # @return [Hash] Oldest commit in the github git history.
332
+ def oldest_commit
333
+ commits.last
334
+ end
335
+
336
+ # @return [String] Default branch of the repo
337
+ def 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
351
+ end
352
+
353
+ # Fetch all SHAs occurring in or before a given tag and add them to
354
+ # "shas_in_tag"
355
+ #
356
+ # @param [Array] tags The array of tags.
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"])
244
362
  end
245
- commits
246
363
  end
247
364
 
248
365
  private
249
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])
386
+ end
387
+ end
388
+ end
389
+
390
+ @commits_in_tag_cache[sha] = shas
391
+ shas
392
+ end
393
+
394
+ # @param [Object] indata
250
395
  def stringify_keys_deep(indata)
251
396
  case indata
252
397
  when Array
@@ -270,43 +415,49 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
270
415
  #
271
416
  # @param [Octokit::Client] client
272
417
  # @param [String] method (eg. 'tags')
418
+ # @param [Array] arguments
419
+ # @param [Async::Semaphore] parent
273
420
  #
274
421
  # @yield [Sawyer::Resource] An OctoKit-provided response (which can be empty)
275
422
  #
276
423
  # @return [void]
277
- def iterate_pages(client, method, *args)
278
- 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)
279
427
 
280
- check_github_response { client.send(method, user_project, *args) }
428
+ check_github_response { client.send(method, user_project, *arguments, **options) }
281
429
  last_response = client.last_response.tap do |response|
282
430
  raise(MovedPermanentlyError, response.data[:url]) if response.status == 301
283
431
  end
284
432
 
285
433
  yield(last_response.data)
286
434
 
287
- until (next_one = last_response.rels[:next]).nil?
288
- last_response = check_github_response { next_one.get }
289
- yield(last_response.data)
290
- end
291
- end
292
-
293
- def extract_request_args(args)
294
- if args.size == 1 && args.first.is_a?(Hash)
295
- args.delete_at(0)
296
- elsif args.size > 1 && args.last.is_a?(Hash)
297
- args.delete_at(args.length - 1)
298
- else
299
- {}
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
300
452
  end
301
453
  end
302
454
 
303
455
  # This is wrapper with rescue block
304
456
  #
305
457
  # @return [Object] returns exactly the same, what you put in the block, but wrap it with begin-rescue block
306
- def check_github_response
307
- Retriable.retriable(retry_options) do
308
- yield
309
- end
458
+ # @param [Proc] block
459
+ def check_github_response(&block)
460
+ Retriable.retriable(retry_options, &block)
310
461
  rescue MovedPermanentlyError => e
311
462
  fail_with_message(e, "The repository has moved, update your configuration")
312
463
  rescue Octokit::Forbidden => e
@@ -316,8 +467,10 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
316
467
  end
317
468
 
318
469
  # Presents the exception, and the aborts with the message.
319
- def fail_with_message(e, message)
320
- Helper.log.error("#{e.class}: #{e.message}")
470
+ # @param [Object] message
471
+ # @param [Object] error
472
+ def fail_with_message(error, message)
473
+ Helper.log.error("#{error.class}: #{error.message}")
321
474
  sys_abort(message)
322
475
  end
323
476
 
@@ -342,10 +495,11 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
342
495
  Helper.log.warn("RETRY - #{exception.class}: '#{exception.message}'")
343
496
  Helper.log.warn("#{try} tries in #{elapsed_time} seconds and #{next_interval} seconds until the next try")
344
497
  Helper.log.warn GH_RATE_LIMIT_EXCEEDED_MSG
345
- Helper.log.warn @client.rate_limit
498
+ Helper.log.warn(client.rate_limit)
346
499
  end
347
500
  end
348
501
 
502
+ # @param [Object] msg
349
503
  def sys_abort(msg)
350
504
  abort(msg)
351
505
  end
@@ -354,7 +508,7 @@ Make sure, that you push tags to remote repo via 'git push --tags'"
354
508
  #
355
509
  # @param [String] log_string
356
510
  def print_in_same_line(log_string)
357
- print log_string + "\r"
511
+ print "#{log_string}\r"
358
512
  end
359
513
 
360
514
  # Print long line with spaces on same line to clear prev message