github_changelog_generator 1.13.2 → 1.14.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/github_changelog_generator.rb +6 -1
  4. data/lib/github_changelog_generator/generator/generator.rb +28 -28
  5. data/lib/github_changelog_generator/generator/generator_fetcher.rb +23 -20
  6. data/lib/github_changelog_generator/generator/generator_generation.rb +40 -53
  7. data/lib/github_changelog_generator/generator/generator_processor.rb +32 -22
  8. data/lib/github_changelog_generator/generator/generator_tags.rb +77 -46
  9. data/lib/github_changelog_generator/octo_fetcher.rb +363 -0
  10. data/lib/github_changelog_generator/options.rb +92 -0
  11. data/lib/github_changelog_generator/parser.rb +21 -5
  12. data/lib/github_changelog_generator/parser_file.rb +2 -2
  13. data/lib/github_changelog_generator/version.rb +1 -1
  14. data/man/git-generate-changelog.1 +44 -2
  15. data/man/git-generate-changelog.1.html +290 -0
  16. data/man/git-generate-changelog.md +29 -1
  17. data/spec/spec_helper.rb +21 -0
  18. data/spec/unit/generator/generator_processor_spec.rb +4 -4
  19. data/spec/unit/generator/generator_tags_spec.rb +43 -40
  20. data/spec/unit/octo_fetcher_spec.rb +528 -0
  21. data/spec/unit/options_spec.rb +42 -0
  22. data/spec/unit/reader_spec.rb +0 -4
  23. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid.json +1 -0
  24. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_issue_with_proper_key/values.json +1 -0
  25. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_issues.json +1 -0
  26. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_issues_with_labels.json +1 -0
  27. 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
  28. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_issues_and_pr/when_API_call_is_valid/returns_pull_requests_with_labels.json +1 -0
  29. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_pull_requests/when_API_call_is_valid.json +1 -0
  30. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_pull_requests/when_API_call_is_valid/returns_correct_pull_request_keys.json +1 -0
  31. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_closed_pull_requests/when_API_call_is_valid/returns_pull_requests.json +1 -0
  32. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_commit/when_API_call_is_valid.json +1 -0
  33. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_commit/when_API_call_is_valid/returns_commit.json +1 -0
  34. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_date_of_tag/when_API_call_is_valid.json +1 -0
  35. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_date_of_tag/when_API_call_is_valid/returns_date.json +1 -0
  36. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_events_async/when_API_call_is_valid.json +1 -0
  37. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_fetch_events_async/when_API_call_is_valid/populates_issues.json +1 -0
  38. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_API_call_is_valid.json +1 -0
  39. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_API_call_is_valid/should_return_tags.json +1 -0
  40. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_API_call_is_valid/should_return_tags_count.json +1 -0
  41. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_wrong_token_provided.json +1 -0
  42. data/spec/vcr/GitHubChangelogGenerator_OctoFetcher/_github_fetch_tags/when_wrong_token_provided/should_raise_Unauthorized_error.json +1 -0
  43. metadata +97 -12
  44. data/lib/CHANGELOG.md +0 -58
  45. data/lib/github_changelog_generator/fetcher.rb +0 -226
  46. data/spec/unit/fetcher_spec.rb +0 -60
@@ -3,33 +3,59 @@ module GitHubChangelogGenerator
3
3
  class Generator
4
4
  # fetch, filter tags, fetch dates and sort them in time order
5
5
  def fetch_and_filter_tags
6
- @filtered_tags = get_filtered_tags(@fetcher.get_all_tags)
7
- fetch_tags_dates
6
+ detect_since_tag
7
+ detect_due_tag
8
+
9
+ all_tags = @fetcher.get_all_tags
10
+ included_tags = filter_excluded_tags(all_tags)
11
+
12
+ fetch_tags_dates(all_tags) # Creates a Hash @tag_times_hash
13
+ @sorted_tags = sort_tags_by_date(included_tags)
14
+ @filtered_tags = get_filtered_tags(included_tags)
15
+
16
+ @tag_section_mapping = build_tag_section_mapping(@filtered_tags, sorted_tags)
17
+
18
+ @filtered_tags
8
19
  end
9
20
 
10
- # Sort all tags by date
21
+ # @param [Array] filtered_tags are the tags that need a subsection output
22
+ # @param [Array] all_tags is the list of all tags ordered from newest -> oldest
23
+ # @return [Hash] key is the tag to output, value is an array of [Left Tag, Right Tag]
24
+ # PRs to include in this section will be >= [Left Tag Date] and <= [Right Tag Date]
25
+ def build_tag_section_mapping(filtered_tags, all_tags)
26
+ tag_mapping = {}
27
+ filtered_tags.each do |tag|
28
+ older_tag_idx = all_tags.index(tag) + 1
29
+ older_tag = all_tags[older_tag_idx]
30
+ tag_mapping[tag] = [older_tag, tag]
31
+ end
32
+ tag_mapping
33
+ end
34
+
35
+ # Sort all tags by date, newest to oldest
11
36
  def sort_tags_by_date(tags)
12
- puts "Sorting tags..." if @options[:verbose]
37
+ puts "Sorting tags..." if options[:verbose]
13
38
  tags.sort_by! do |x|
14
39
  get_time_of_tag(x)
15
40
  end.reverse!
16
41
  end
17
42
 
18
- # Try to find tag date in local hash.
19
- # Otherwise fFetch tag time and put it to local hash file.
20
- # @param [Hash] tag_name name of the tag
43
+ # Returns date for given GitHub Tag hash
44
+ #
45
+ # Memoize the date by tag name.
46
+ #
47
+ # @param [Hash] tag_name
48
+ #
21
49
  # @return [Time] time of specified tag
22
50
  def get_time_of_tag(tag_name)
23
51
  raise ChangelogGeneratorError, "tag_name is nil" if tag_name.nil?
24
52
 
25
- name_of_tag = tag_name["name"]
26
- time_for_name = @tag_times_hash[name_of_tag]
27
- if !time_for_name.nil?
28
- time_for_name
29
- else
30
- time_string = @fetcher.fetch_date_of_tag tag_name
53
+ name_of_tag = tag_name.fetch("name")
54
+ time_for_tag_name = @tag_times_hash[name_of_tag]
55
+ return time_for_tag_name if time_for_tag_name
56
+
57
+ @fetcher.fetch_date_of_tag(tag_name).tap do |time_string|
31
58
  @tag_times_hash[name_of_tag] = time_string
32
- time_string
33
59
  end
34
60
  end
35
61
 
@@ -42,12 +68,12 @@ module GitHubChangelogGenerator
42
68
  newer_tag_time = newer_tag.nil? ? Time.new : get_time_of_tag(newer_tag)
43
69
 
44
70
  # if it's future release tag - set this value
45
- if newer_tag.nil? && @options[:future_release]
46
- newer_tag_name = @options[:future_release]
47
- newer_tag_link = @options[:future_release]
71
+ if newer_tag.nil? && options[:future_release]
72
+ newer_tag_name = options[:future_release]
73
+ newer_tag_link = options[:future_release]
48
74
  else
49
75
  # put unreleased label if there is no name for the tag
50
- newer_tag_name = newer_tag.nil? ? @options[:unreleased_label] : newer_tag["name"]
76
+ newer_tag_name = newer_tag.nil? ? options[:unreleased_label] : newer_tag["name"]
51
77
  newer_tag_link = newer_tag.nil? ? "HEAD" : newer_tag_name
52
78
  end
53
79
  [newer_tag_link, newer_tag_name, newer_tag_time]
@@ -55,13 +81,17 @@ module GitHubChangelogGenerator
55
81
 
56
82
  # @return [Object] try to find newest tag using #Reader and :base option if specified otherwise returns nil
57
83
  def detect_since_tag
58
- @since_tag ||= @options.fetch(:since_tag) { version_of_first_item }
84
+ @since_tag ||= options.fetch(:since_tag) { version_of_first_item }
85
+ end
86
+
87
+ def detect_due_tag
88
+ @due_tag ||= options.fetch(:due_tag, nil)
59
89
  end
60
90
 
61
91
  def version_of_first_item
62
- return unless File.file?(@options[:base].to_s)
92
+ return unless File.file?(options[:base].to_s)
63
93
 
64
- sections = GitHubChangelogGenerator::Reader.new.read(@options[:base])
94
+ sections = GitHubChangelogGenerator::Reader.new.read(options[:base])
65
95
  sections.first["version"] if sections && sections.any?
66
96
  end
67
97
 
@@ -70,8 +100,8 @@ module GitHubChangelogGenerator
70
100
  # @return [Array]
71
101
  def get_filtered_tags(all_tags)
72
102
  filtered_tags = filter_since_tag(all_tags)
73
- filtered_tags = filter_between_tags(filtered_tags)
74
- filter_excluded_tags(filtered_tags)
103
+ filtered_tags = filter_due_tag(filtered_tags)
104
+ filter_between_tags(filtered_tags)
75
105
  end
76
106
 
77
107
  # @param [Array] all_tags all tags
@@ -80,8 +110,8 @@ module GitHubChangelogGenerator
80
110
  filtered_tags = all_tags
81
111
  tag = detect_since_tag
82
112
  if tag
83
- if all_tags.map(&:name).include? tag
84
- idx = all_tags.index { |t| t.name == tag }
113
+ if all_tags.map { |t| t["name"] }.include? tag
114
+ idx = all_tags.index { |t| t["name"] == tag }
85
115
  filtered_tags = if idx > 0
86
116
  all_tags[0..idx - 1]
87
117
  else
@@ -98,13 +128,12 @@ module GitHubChangelogGenerator
98
128
  # @return [Array] filtered tags according :due_tag option
99
129
  def filter_due_tag(all_tags)
100
130
  filtered_tags = all_tags
101
- tag = @options[:due_tag]
131
+ tag = detect_due_tag
102
132
  if tag
103
- if all_tags.any? && all_tags.map(&:name).include?(tag)
104
- idx = all_tags.index { |t| t.name == tag }
105
- last_index = all_tags.count - 1
106
- filtered_tags = if idx > 0 && idx < last_index
107
- all_tags[idx + 1..last_index]
133
+ if all_tags.any? && all_tags.map { |t| t["name"] }.include?(tag)
134
+ idx = all_tags.index { |t| t["name"] == tag }
135
+ filtered_tags = if idx > 0
136
+ all_tags[(idx + 1)..-1]
108
137
  else
109
138
  []
110
139
  end
@@ -119,13 +148,15 @@ module GitHubChangelogGenerator
119
148
  # @return [Array] filtered tags according :between_tags option
120
149
  def filter_between_tags(all_tags)
121
150
  filtered_tags = all_tags
122
- if @options[:between_tags]
123
- @options[:between_tags].each do |tag|
124
- unless all_tags.map(&:name).include? tag
151
+ tag_names = filtered_tags.map { |ft| ft["name"] }
152
+
153
+ if options[:between_tags]
154
+ options[:between_tags].each do |tag|
155
+ unless tag_names.include?(tag)
125
156
  Helper.log.warn "Warning: can't find tag #{tag}, specified with --between-tags option."
126
157
  end
127
158
  end
128
- filtered_tags = all_tags.select { |tag| @options[:between_tags].include? tag.name }
159
+ filtered_tags = all_tags.select { |tag| options[:between_tags].include?(tag["name"]) }
129
160
  end
130
161
  filtered_tags
131
162
  end
@@ -133,9 +164,9 @@ module GitHubChangelogGenerator
133
164
  # @param [Array] all_tags all tags
134
165
  # @return [Array] filtered tags according :exclude_tags or :exclude_tags_regex option
135
166
  def filter_excluded_tags(all_tags)
136
- if @options[:exclude_tags]
167
+ if options[:exclude_tags]
137
168
  apply_exclude_tags(all_tags)
138
- elsif @options[:exclude_tags_regex]
169
+ elsif options[:exclude_tags_regex]
139
170
  apply_exclude_tags_regex(all_tags)
140
171
  else
141
172
  all_tags
@@ -145,39 +176,39 @@ module GitHubChangelogGenerator
145
176
  private
146
177
 
147
178
  def apply_exclude_tags(all_tags)
148
- if @options[:exclude_tags].is_a?(Regexp)
149
- filter_tags_with_regex(all_tags, @options[:exclude_tags])
179
+ if options[:exclude_tags].is_a?(Regexp)
180
+ filter_tags_with_regex(all_tags, options[:exclude_tags])
150
181
  else
151
182
  filter_exact_tags(all_tags)
152
183
  end
153
184
  end
154
185
 
155
186
  def apply_exclude_tags_regex(all_tags)
156
- filter_tags_with_regex(all_tags, Regexp.new(@options[:exclude_tags_regex]))
187
+ filter_tags_with_regex(all_tags, Regexp.new(options[:exclude_tags_regex]))
157
188
  end
158
189
 
159
190
  def filter_tags_with_regex(all_tags, regex)
160
191
  warn_if_nonmatching_regex(all_tags)
161
- all_tags.reject { |tag| regex =~ tag.name }
192
+ all_tags.reject { |tag| regex =~ tag["name"] }
162
193
  end
163
194
 
164
195
  def filter_exact_tags(all_tags)
165
- @options[:exclude_tags].each do |tag|
196
+ options[:exclude_tags].each do |tag|
166
197
  warn_if_tag_not_found(all_tags, tag)
167
198
  end
168
- all_tags.reject { |tag| @options[:exclude_tags].include? tag.name }
199
+ all_tags.reject { |tag| options[:exclude_tags].include?(tag["name"]) }
169
200
  end
170
201
 
171
202
  def warn_if_nonmatching_regex(all_tags)
172
- unless all_tags.map(&:name).any? { |t| @options[:exclude_tags] =~ t }
203
+ unless all_tags.map { |t| t["name"] }.any? { |t| options[:exclude_tags] =~ t }
173
204
  Helper.log.warn "Warning: unable to reject any tag, using regex "\
174
- "#{@options[:exclude_tags].inspect} in --exclude-tags "\
205
+ "#{options[:exclude_tags].inspect} in --exclude-tags "\
175
206
  "option."
176
207
  end
177
208
  end
178
209
 
179
210
  def warn_if_tag_not_found(all_tags, tag)
180
- unless all_tags.map(&:name).include? tag
211
+ unless all_tags.map { |t| t["name"] }.include?(tag)
181
212
  Helper.log.warn "Warning: can't find tag #{tag}, specified with --exclude-tags option."
182
213
  end
183
214
  end
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+ require "retriable"
3
+ module GitHubChangelogGenerator
4
+ # A Fetcher responsible for all requests to GitHub and all basic manipulation with related data
5
+ # (such as filtering, validating, e.t.c)
6
+ #
7
+ # Example:
8
+ # fetcher = GitHubChangelogGenerator::OctoFetcher.new(options)
9
+ class OctoFetcher
10
+ PER_PAGE_NUMBER = 100
11
+ MAX_THREAD_NUMBER = 25
12
+ MAX_FORBIDDEN_RETRIES = 100
13
+ CHANGELOG_GITHUB_TOKEN = "CHANGELOG_GITHUB_TOKEN"
14
+ GH_RATE_LIMIT_EXCEEDED_MSG = "Warning: Can't finish operation: GitHub API rate limit exceeded, change log may be " \
15
+ "missing some issues. You can limit the number of issues fetched using the `--max-issues NUM` argument."
16
+ NO_TOKEN_PROVIDED = "Warning: No token provided (-t option) and variable $CHANGELOG_GITHUB_TOKEN was not found. " \
17
+ "This script can make only 50 requests to GitHub API per hour without token!"
18
+
19
+ # @param options [Hash] Options passed in
20
+ # @option options [String] :user GitHub username
21
+ # @option options [String] :project GitHub project
22
+ # @option options [String] :since Only issues updated at or after this time are returned. This is a timestamp in ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ. eg. Time.parse("2016-01-01 10:00:00").iso8601
23
+ # @option options [Boolean] :http_cache Use ActiveSupport::Cache::FileStore to cache http requests
24
+ # @option options [Boolean] :cache_file If using http_cache, this is the cache file path
25
+ # @option options [Boolean] :cache_log If using http_cache, this is the cache log file path
26
+ def initialize(options = {}) # rubocop:disable Metrics/CyclomaticComplexity
27
+ @options = options || {}
28
+ @user = @options[:user]
29
+ @project = @options[:project]
30
+ @since = @options[:since]
31
+ @http_cache = @options[:http_cache]
32
+ @cache_file = @options.fetch(:cache_file, "/tmp/github-changelog-http-cache") if @http_cache
33
+ @cache_log = @options.fetch(:cache_log, "/tmp/github-changelog-logger.log") if @http_cache
34
+ init_cache if @http_cache
35
+
36
+ @github_token = fetch_github_token
37
+
38
+ @request_options = { per_page: PER_PAGE_NUMBER }
39
+ @github_options = {}
40
+ @github_options[:access_token] = @github_token unless @github_token.nil?
41
+ @github_options[:api_endpoint] = @options[:github_endpoint] unless @options[:github_endpoint].nil?
42
+
43
+ client_type = @options[:github_endpoint].nil? ? Octokit::Client : Octokit::EnterpriseAdminClient
44
+ @client = client_type.new(@github_options)
45
+ end
46
+
47
+ def init_cache
48
+ middleware_opts = {
49
+ serializer: Marshal,
50
+ store: ActiveSupport::Cache::FileStore.new(@cache_file),
51
+ logger: Logger.new(@cache_log),
52
+ shared_cache: false
53
+ }
54
+ stack = Faraday::RackBuilder.new do |builder|
55
+ builder.use Faraday::HttpCache, middleware_opts
56
+ builder.use Octokit::Response::RaiseError
57
+ builder.adapter Faraday.default_adapter
58
+ # builder.response :logger
59
+ end
60
+ Octokit.middleware = stack
61
+ end
62
+
63
+ # Fetch all tags from repo
64
+ #
65
+ # @return [Array <Hash>] array of tags
66
+ def get_all_tags
67
+ print "Fetching tags...\r" if @options[:verbose]
68
+
69
+ check_github_response { github_fetch_tags }
70
+ end
71
+
72
+ # Returns the number of pages for a API call
73
+ #
74
+ # @return [Integer] number of pages for this API call in total
75
+ def calculate_pages(client, method, request_options)
76
+ # Makes the first API call so that we can call last_response
77
+ check_github_response do
78
+ client.send(method, user_project, @request_options.merge(request_options))
79
+ end
80
+
81
+ last_response = client.last_response
82
+
83
+ if (last_pg = last_response.rels[:last])
84
+ querystring_as_hash(last_pg.href)["page"].to_i
85
+ else
86
+ 1
87
+ end
88
+ end
89
+
90
+ # Fill input array with tags
91
+ #
92
+ # @return [Array <Hash>] array of tags in repo
93
+ def github_fetch_tags
94
+ tags = []
95
+ page_i = 0
96
+ count_pages = calculate_pages(@client, "tags", {})
97
+
98
+ iterate_pages(@client, "tags", {}) do |new_tags|
99
+ page_i += PER_PAGE_NUMBER
100
+ print_in_same_line("Fetching tags... #{page_i}/#{count_pages * PER_PAGE_NUMBER}")
101
+ tags.concat(new_tags)
102
+ end
103
+ print_empty_line
104
+
105
+ if tags.count == 0
106
+ Helper.log.warn "Warning: Can't find any tags in repo.\
107
+ Make sure, that you push tags to remote repo via 'git push --tags'"
108
+ else
109
+ Helper.log.info "Found #{tags.count} tags"
110
+ end
111
+ # tags are a Sawyer::Resource. Convert to hash
112
+ tags = tags.map { |h| stringify_keys_deep(h.to_hash) }
113
+ tags
114
+ end
115
+
116
+ # This method fetch all closed issues and separate them to pull requests and pure issues
117
+ # (pull request is kind of issue in term of GitHub)
118
+ #
119
+ # @return [Tuple] with (issues [Array <Hash>], pull-requests [Array <Hash>])
120
+ def fetch_closed_issues_and_pr
121
+ print "Fetching closed issues...\r" if @options[:verbose]
122
+ issues = []
123
+ options = {
124
+ state: "closed",
125
+ filter: "all",
126
+ labels: nil
127
+ }
128
+ options[:since] = @since unless @since.nil?
129
+
130
+ page_i = 0
131
+ count_pages = calculate_pages(@client, "issues", options)
132
+
133
+ iterate_pages(@client, "issues", options) do |new_issues|
134
+ page_i += PER_PAGE_NUMBER
135
+ print_in_same_line("Fetching issues... #{page_i}/#{count_pages * PER_PAGE_NUMBER}")
136
+ issues.concat(new_issues)
137
+ break if @options[:max_issues] && issues.length >= @options[:max_issues]
138
+ end
139
+ print_empty_line
140
+ Helper.log.info "Received issues: #{issues.count}"
141
+
142
+ issues = issues.map { |h| stringify_keys_deep(h.to_hash) }
143
+
144
+ # separate arrays of issues and pull requests:
145
+ issues.partition do |x|
146
+ x["pull_request"].nil?
147
+ end
148
+ end
149
+
150
+ # Fetch all pull requests. We need them to detect :merged_at parameter
151
+ #
152
+ # @return [Array <Hash>] all pull requests
153
+ def fetch_closed_pull_requests
154
+ pull_requests = []
155
+ options = { state: "closed" }
156
+
157
+ unless @options[:release_branch].nil?
158
+ options[:base] = @options[:release_branch]
159
+ end
160
+
161
+ page_i = 0
162
+ count_pages = calculate_pages(@client, "pull_requests", options)
163
+
164
+ iterate_pages(@client, "pull_requests", options) do |new_pr|
165
+ page_i += PER_PAGE_NUMBER
166
+ log_string = "Fetching merged dates... #{page_i}/#{count_pages * PER_PAGE_NUMBER}"
167
+ print_in_same_line(log_string)
168
+ pull_requests.concat(new_pr)
169
+ end
170
+ print_empty_line
171
+
172
+ Helper.log.info "Pull Request count: #{pull_requests.count}"
173
+ pull_requests = pull_requests.map { |h| stringify_keys_deep(h.to_hash) }
174
+ pull_requests
175
+ end
176
+
177
+ # Fetch event for all issues and add them to 'events'
178
+ #
179
+ # @param [Array] issues
180
+ # @return [Void]
181
+ def fetch_events_async(issues)
182
+ i = 0
183
+ threads = []
184
+
185
+ issues.each_slice(MAX_THREAD_NUMBER) do |issues_slice|
186
+ issues_slice.each do |issue|
187
+ threads << Thread.new do
188
+ issue["events"] = []
189
+ iterate_pages(@client, "issue_events", issue["number"], {}) do |new_event|
190
+ issue["events"].concat(new_event)
191
+ end
192
+ issue["events"] = issue["events"].map { |h| stringify_keys_deep(h.to_hash) }
193
+ print_in_same_line("Fetching events for issues and PR: #{i + 1}/#{issues.count}")
194
+ i += 1
195
+ end
196
+ end
197
+ threads.each(&:join)
198
+ threads = []
199
+ end
200
+
201
+ # to clear line from prev print
202
+ print_empty_line
203
+
204
+ Helper.log.info "Fetching events for issues and PR: #{i}"
205
+ end
206
+
207
+ # Fetch tag time from repo
208
+ #
209
+ # @param [Hash] tag GitHub data item about a Tag
210
+ #
211
+ # @return [Time] time of specified tag
212
+ def fetch_date_of_tag(tag)
213
+ commit_data = check_github_response { @client.commit(user_project, tag["commit"]["sha"]) }
214
+ commit_data = stringify_keys_deep(commit_data.to_hash)
215
+
216
+ commit_data["commit"]["committer"]["date"]
217
+ end
218
+
219
+ # Fetch commit for specified event
220
+ #
221
+ # @return [Hash]
222
+ def fetch_commit(event)
223
+ check_github_response do
224
+ commit = @client.commit(user_project, event["commit_id"])
225
+ commit = stringify_keys_deep(commit.to_hash)
226
+ commit
227
+ end
228
+ end
229
+
230
+ private
231
+
232
+ def stringify_keys_deep(indata)
233
+ case indata
234
+ when Array
235
+ indata.map do |value|
236
+ stringify_keys_deep(value)
237
+ end
238
+ when Hash
239
+ indata.each_with_object({}) do |(k, v), output|
240
+ output[k.to_s] = stringify_keys_deep(v)
241
+ end
242
+ else
243
+ indata
244
+ end
245
+ end
246
+
247
+ # Iterates through all pages until there are no more :next pages to follow
248
+ # yields the result per page
249
+ #
250
+ # @param [Octokit::Client] client
251
+ # @param [String] method (eg. 'tags')
252
+ # @return [Integer] total number of pages
253
+ def iterate_pages(client, method, *args)
254
+ if args.size == 1 && args.first.is_a?(Hash)
255
+ request_options = args.delete_at(0)
256
+ elsif args.size > 1 && args.last.is_a?(Hash)
257
+ request_options = args.delete_at(args.length - 1)
258
+ end
259
+
260
+ args.push(@request_options.merge(request_options))
261
+
262
+ pages = 1
263
+
264
+ check_github_response do
265
+ client.send(method, user_project, *args)
266
+ end
267
+ last_response = client.last_response
268
+
269
+ yield last_response.data
270
+
271
+ until (next_one = last_response.rels[:next]).nil?
272
+ pages += 1
273
+
274
+ last_response = check_github_response { next_one.get }
275
+ yield last_response.data
276
+ end
277
+
278
+ pages
279
+ end
280
+
281
+ # This is wrapper with rescue block
282
+ #
283
+ # @return [Object] returns exactly the same, what you put in the block, but wrap it with begin-rescue block
284
+ def check_github_response
285
+ Retriable.retriable(retry_options) do
286
+ yield
287
+ end
288
+
289
+ rescue Octokit::Forbidden => e
290
+ Helper.log.error("#{e.class}: #{e.message}")
291
+ sys_abort("Exceeded retry limit")
292
+ rescue Octokit::Unauthorized => e
293
+ Helper.log.error("#{e.class}: #{e.message}")
294
+ sys_abort("Error: wrong GitHub token")
295
+ end
296
+
297
+ # Exponential backoff
298
+ def retry_options
299
+ {
300
+ on: [Octokit::Forbidden],
301
+ tries: MAX_FORBIDDEN_RETRIES,
302
+ base_interval: sleep_base_interval,
303
+ multiplier: 1.0,
304
+ rand_factor: 0.0,
305
+ on_retry: retry_callback
306
+ }
307
+ end
308
+
309
+ def sleep_base_interval
310
+ 1.0
311
+ end
312
+
313
+ def retry_callback
314
+ proc do |exception, try, elapsed_time, next_interval|
315
+ Helper.log.warn("RETRY - #{exception.class}: '#{exception.message}'")
316
+ Helper.log.warn("#{try} tries in #{elapsed_time} seconds and #{next_interval} seconds until the next try")
317
+ Helper.log.warn GH_RATE_LIMIT_EXCEEDED_MSG
318
+ Helper.log.warn @client.rate_limit
319
+ end
320
+ end
321
+
322
+ def sys_abort(msg)
323
+ abort(msg)
324
+ end
325
+
326
+ # Print specified line on the same string
327
+ #
328
+ # @param [String] log_string
329
+ def print_in_same_line(log_string)
330
+ print log_string + "\r"
331
+ end
332
+
333
+ # Print long line with spaces on same line to clear prev message
334
+ def print_empty_line
335
+ print_in_same_line(" ")
336
+ end
337
+
338
+ # Returns GitHub token. First try to use variable, provided by --token option,
339
+ # otherwise try to fetch it from CHANGELOG_GITHUB_TOKEN env variable.
340
+ #
341
+ # @return [String]
342
+ def fetch_github_token
343
+ env_var = @options[:token] ? @options[:token] : (ENV.fetch CHANGELOG_GITHUB_TOKEN, nil)
344
+
345
+ Helper.log.warn NO_TOKEN_PROVIDED unless env_var
346
+
347
+ env_var
348
+ end
349
+
350
+ # @return [String] helper to return Github "user/project"
351
+ def user_project
352
+ "#{@options[:user]}/#{@options[:project]}"
353
+ end
354
+
355
+ # Returns Hash of all querystring variables in given URI.
356
+ #
357
+ # @param [String] uri eg. https://api.github.com/repositories/43914960/tags?page=37&foo=1
358
+ # @return [Hash] of all GET variables. eg. { 'page' => 37, 'foo' => 1 }
359
+ def querystring_as_hash(uri)
360
+ Hash[URI.decode_www_form(URI(uri).query || "")]
361
+ end
362
+ end
363
+ end