issue-db 0.1.2 → 1.1.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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/issue-db.gemspec +2 -3
- data/lib/issue_db/authentication.rb +6 -3
- data/lib/issue_db/cache.rb +10 -14
- data/lib/issue_db/database.rb +35 -25
- data/lib/issue_db/utils/github.rb +344 -0
- data/lib/issue_db/utils/init.rb +3 -2
- data/lib/issue_db.rb +0 -2
- data/lib/version.rb +1 -1
- metadata +17 -37
- data/lib/issue_db/utils/github_app.rb +0 -108
- data/lib/issue_db/utils/retry.rb +0 -31
- data/lib/issue_db/utils/throttle.rb +0 -83
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6f4349ba13a8c9f2971aae3c557c6bcd8d0b1b3fc51ecb4a901e3db2a30538db
|
4
|
+
data.tar.gz: a59e1724a2f9068bd4596a311b051224ad48a8df158e0607f5dc51374f096033
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e64935039edab751270a2fe8d924d32fff52035364baaa0a6455cf2fd0931fd4c51d39164b283435646c44cde628d3fb5cd98908673d8a1c0138a028855750d3
|
7
|
+
data.tar.gz: 28e242676837588f0dbadf5dc831569bc26e703d2533938daebe62e0eb45d59c3d243d8678d4f91dd03baeba371f40cc3272a4290d23af6b6bafd88aac8b2976
|
data/README.md
CHANGED
@@ -186,8 +186,10 @@ This section will go into detail around how you can configure the `issue-db` gem
|
|
186
186
|
| `LOG_LEVEL` | The log level to use for the `issue-db` gem. Can be one of `DEBUG`, `INFO`, `WARN`, `ERROR`, or `FATAL` | `INFO` |
|
187
187
|
| `ISSUE_DB_LABEL` | The label to use for the issues that are used as records in the database. This value is required and it is what this gem uses to scan a repo for the records it is aware of. | `issue-db` |
|
188
188
|
| `ISSUE_DB_CACHE_EXPIRY` | The number of seconds to cache the database in memory. The database is cached in memory to avoid making a request to the GitHub API for every operation. The default value is 60 seconds. | `60` |
|
189
|
-
| `
|
190
|
-
| `
|
189
|
+
| `GH_APP_SLEEP` | The number of seconds to sleep between requests to the GitHub API in the event of an error | `3` |
|
190
|
+
| `GH_APP_RETRIES` | The number of retries to make when there is an error making a request to the GitHub API | `10` |
|
191
|
+
| `GH_APP_EXPONENTIAL_BACKOFF` | Whether to use exponential backoff for retries. When `true`, sleep time doubles with each retry. When `false`, uses fixed sleep time. | `false` |
|
192
|
+
| `GH_APP_ALGO` | The algo to use for your GitHub App if providing a private key | `RS256` |
|
191
193
|
| `ISSUE_DB_GITHUB_TOKEN` | The GitHub personal access token to use for authenticating with the GitHub API. You can also use a GitHub app or pass in your own authenticated Octokit.rb instance | `nil` |
|
192
194
|
|
193
195
|
## Authentication 🔒
|
data/issue-db.gemspec
CHANGED
@@ -21,10 +21,9 @@ Gem::Specification.new do |spec|
|
|
21
21
|
}
|
22
22
|
|
23
23
|
spec.add_dependency "redacting-logger", "~> 1.4"
|
24
|
-
spec.add_dependency "
|
25
|
-
spec.add_dependency "octokit", "~> 9.2"
|
24
|
+
spec.add_dependency "octokit", ">= 9.2", "< 11.0"
|
26
25
|
spec.add_dependency "faraday-retry", "~> 2.2", ">= 2.2.1"
|
27
|
-
spec.add_dependency "jwt", "
|
26
|
+
spec.add_dependency "jwt", ">= 2.9.3", "< 4.0"
|
28
27
|
|
29
28
|
spec.required_ruby_version = Gem::Requirement.new(">= 3.0.0")
|
30
29
|
|
@@ -1,7 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "octokit"
|
4
|
-
require_relative "utils/
|
4
|
+
require_relative "utils/github"
|
5
5
|
|
6
6
|
class AuthenticationError < StandardError; end
|
7
7
|
|
@@ -16,9 +16,12 @@ module Authentication
|
|
16
16
|
# if the client is nil, check for GitHub App env vars first
|
17
17
|
# first, check if all three of the following env vars are set and have values
|
18
18
|
# ISSUE_DB_GITHUB_APP_ID, ISSUE_DB_GITHUB_APP_INSTALLATION_ID, ISSUE_DB_GITHUB_APP_KEY
|
19
|
-
|
19
|
+
app_id = ENV.fetch("ISSUE_DB_GITHUB_APP_ID", nil)
|
20
|
+
installation_id = ENV.fetch("ISSUE_DB_GITHUB_APP_INSTALLATION_ID", nil)
|
21
|
+
app_key = ENV.fetch("ISSUE_DB_GITHUB_APP_KEY", nil)
|
22
|
+
if app_id && installation_id && app_key
|
20
23
|
log.debug("using github app authentication") if log
|
21
|
-
return
|
24
|
+
return GitHub.new(log:, app_id:, installation_id:, app_key:)
|
22
25
|
end
|
23
26
|
|
24
27
|
# if the client is nil and no GitHub App env vars were found, check for the ISSUE_DB_GITHUB_TOKEN
|
data/lib/issue_db/cache.rb
CHANGED
@@ -11,22 +11,18 @@ module Cache
|
|
11
11
|
|
12
12
|
search_response = nil
|
13
13
|
begin
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
begin
|
18
|
-
# issues structure: { "total_count": 0, "incomplete_results": false, "items": [<issues>] }
|
19
|
-
search_response = @client.search_issues(query)
|
20
|
-
rescue StandardError => e
|
21
|
-
# re-raise the error but if its a secondary rate limit error, just sleep for minute (oof)
|
22
|
-
sleep(60) if e.message.include?("exceeded a secondary rate limit")
|
23
|
-
raise e
|
24
|
-
end
|
25
|
-
end
|
14
|
+
# issues structure: { "total_count": 0, "incomplete_results": false, "items": [<issues>] }
|
15
|
+
search_response = @client.search_issues(query)
|
26
16
|
rescue StandardError => e
|
27
|
-
retry_err_msg = "error search_issues() call: #{e.message}
|
17
|
+
retry_err_msg = "error search_issues() call: #{e.message}"
|
28
18
|
@log.error(retry_err_msg)
|
29
|
-
raise retry_err_msg
|
19
|
+
raise StandardError, retry_err_msg
|
20
|
+
end
|
21
|
+
|
22
|
+
# Safety check to ensure search_response and items are not nil
|
23
|
+
if search_response.nil? || search_response.items.nil?
|
24
|
+
@log.error("search_issues returned nil response or nil items")
|
25
|
+
raise StandardError, "search_issues returned invalid response"
|
30
26
|
end
|
31
27
|
|
32
28
|
@log.debug("issue cache updated - cached #{search_response.total_count} issues")
|
data/lib/issue_db/database.rb
CHANGED
@@ -1,7 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require_relative "cache"
|
4
|
-
require_relative "utils/throttle"
|
5
4
|
require_relative "models/record"
|
6
5
|
require_relative "utils/generate"
|
7
6
|
|
@@ -9,7 +8,6 @@ class RecordNotFound < StandardError; end
|
|
9
8
|
|
10
9
|
class Database
|
11
10
|
include Cache
|
12
|
-
include Throttle
|
13
11
|
include Generate
|
14
12
|
|
15
13
|
# :param: log [Logger] a logger object to use for logging
|
@@ -24,7 +22,6 @@ class Database
|
|
24
22
|
@repo = repo
|
25
23
|
@label = label
|
26
24
|
@cache_expiry = cache_expiry
|
27
|
-
@rate_limit_all = nil
|
28
25
|
@issues = nil
|
29
26
|
@issues_last_updated = nil
|
30
27
|
end
|
@@ -52,13 +49,13 @@ class Database
|
|
52
49
|
body = generate(data, body_before: options[:body_before], body_after: options[:body_after])
|
53
50
|
|
54
51
|
# if we make it here, no existing issues were found so we can safely create one
|
55
|
-
issue =
|
56
|
-
wait_for_rate_limit!
|
57
|
-
@client.create_issue(@repo.full_name, key, body, { labels: @label })
|
58
|
-
end
|
52
|
+
issue = @client.create_issue(@repo.full_name, key, body, { labels: @label })
|
59
53
|
|
60
|
-
#
|
61
|
-
|
54
|
+
# ensure the cache is initialized before appending and handle race conditions
|
55
|
+
current_issues = issues
|
56
|
+
if current_issues && !current_issues.include?(issue)
|
57
|
+
@issues << issue
|
58
|
+
end
|
62
59
|
|
63
60
|
@log.debug("issue created: #{key}")
|
64
61
|
return Record.new(issue)
|
@@ -92,13 +89,17 @@ class Database
|
|
92
89
|
|
93
90
|
body = generate(data, body_before: options[:body_before], body_after: options[:body_after])
|
94
91
|
|
95
|
-
updated_issue =
|
96
|
-
wait_for_rate_limit!
|
97
|
-
@client.update_issue(@repo.full_name, issue.number, key, body)
|
98
|
-
end
|
92
|
+
updated_issue = @client.update_issue(@repo.full_name, issue.number, key, body)
|
99
93
|
|
100
94
|
# update the issue in the cache using the reference we have
|
101
|
-
@issues
|
95
|
+
index = @issues.index(issue)
|
96
|
+
if index
|
97
|
+
@issues[index] = updated_issue
|
98
|
+
else
|
99
|
+
@log.warn("issue not found in cache during update: #{key}")
|
100
|
+
# Force a cache refresh to ensure consistency
|
101
|
+
update_issue_cache!
|
102
|
+
end
|
102
103
|
|
103
104
|
@log.debug("issue updated: #{key}")
|
104
105
|
return Record.new(updated_issue)
|
@@ -112,13 +113,17 @@ class Database
|
|
112
113
|
@log.debug("attempting to delete: #{key}")
|
113
114
|
issue = find_issue_by_key(key, options)
|
114
115
|
|
115
|
-
deleted_issue =
|
116
|
-
wait_for_rate_limit!
|
117
|
-
@client.close_issue(@repo.full_name, issue.number)
|
118
|
-
end
|
116
|
+
deleted_issue = @client.close_issue(@repo.full_name, issue.number)
|
119
117
|
|
120
|
-
#
|
121
|
-
@issues.
|
118
|
+
# update the issue in the cache using the reference we have
|
119
|
+
index = @issues.index(issue)
|
120
|
+
if index
|
121
|
+
@issues[index] = deleted_issue
|
122
|
+
else
|
123
|
+
@log.warn("issue not found in cache during delete: #{key}")
|
124
|
+
# Force a cache refresh to ensure consistency
|
125
|
+
update_issue_cache!
|
126
|
+
end
|
122
127
|
|
123
128
|
# return the deleted issue as a Record object as it may contain useful data
|
124
129
|
return Record.new(deleted_issue)
|
@@ -132,7 +137,10 @@ class Database
|
|
132
137
|
# options = {include_closed: true}
|
133
138
|
# keys = db.list_keys(options)
|
134
139
|
def list_keys(options = {})
|
135
|
-
|
140
|
+
current_issues = issues
|
141
|
+
return [] if current_issues.nil?
|
142
|
+
|
143
|
+
keys = current_issues.select do |issue|
|
136
144
|
options[:include_closed] || issue[:state] == "open"
|
137
145
|
end.map do |issue|
|
138
146
|
issue[:title]
|
@@ -149,7 +157,10 @@ class Database
|
|
149
157
|
# options = {include_closed: true}
|
150
158
|
# records = db.list(options)
|
151
159
|
def list(options = {})
|
152
|
-
|
160
|
+
current_issues = issues
|
161
|
+
return [] if current_issues.nil?
|
162
|
+
|
163
|
+
records = current_issues.select do |issue|
|
153
164
|
options[:include_closed] || issue[:state] == "open"
|
154
165
|
end.map do |issue|
|
155
166
|
Record.new(issue)
|
@@ -199,9 +210,8 @@ class Database
|
|
199
210
|
# update the issues cache if it is nil
|
200
211
|
update_issue_cache! if @issues.nil?
|
201
212
|
|
202
|
-
# update the cache if it has expired
|
203
|
-
|
204
|
-
if issues_cache_expired
|
213
|
+
# update the cache if it has expired (with nil safety)
|
214
|
+
if !@issues_last_updated.nil? && (Time.now - @issues_last_updated) > @cache_expiry
|
205
215
|
@log.debug("issue cache expired - last updated: #{@issues_last_updated} - refreshing now")
|
206
216
|
update_issue_cache!
|
207
217
|
end
|
@@ -0,0 +1,344 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# This class provides a comprehensive wrapper around the Octokit client for GitHub App authentication.
|
4
|
+
# It handles token generation and refreshing, built-in retry logic, rate limiting, and delegates method calls to the Octokit client.
|
5
|
+
# Helpful: https://github.com/octokit/handbook?tab=readme-ov-file#github-app-authentication-json-web-token
|
6
|
+
|
7
|
+
# Why? In some cases, you may not want to have a static long lived token like a GitHub PAT when authenticating...
|
8
|
+
# with octokit.rb.
|
9
|
+
# Most importantly, this class will handle automatic token refreshing, retries, and rate limiting for you out-of-the-box.
|
10
|
+
# Simply provide the correct environment variables, call `GitHub.new`, and then use the returned object as you would an Octokit client.
|
11
|
+
|
12
|
+
# Note: Environment variables have the `GH_` prefix because in GitHub Actions, you cannot use `GITHUB_` for secrets
|
13
|
+
|
14
|
+
require "octokit"
|
15
|
+
require "jwt"
|
16
|
+
require "redacting_logger"
|
17
|
+
|
18
|
+
class GitHub
|
19
|
+
TOKEN_EXPIRATION_TIME = 2700 # 45 minutes
|
20
|
+
JWT_EXPIRATION_TIME = 600 # 10 minutes
|
21
|
+
|
22
|
+
def initialize(log: nil, app_id: nil, installation_id: nil, app_key: nil, app_algo: nil)
|
23
|
+
@log = log || create_default_logger
|
24
|
+
|
25
|
+
# app ids are found on the App's settings page
|
26
|
+
@app_id = app_id || fetch_env_var("GH_APP_ID").to_i
|
27
|
+
|
28
|
+
# installation ids look like this:
|
29
|
+
# https://github.com/organizations/<org>/settings/installations/<8_digit_id>
|
30
|
+
@installation_id = installation_id || fetch_env_var("GH_APP_INSTALLATION_ID").to_i
|
31
|
+
|
32
|
+
# app keys are found on the App's settings page and can be downloaded
|
33
|
+
# format: "-----BEGIN...key\n...END-----\n"
|
34
|
+
# make sure this key in your env is a single line string with newlines as "\n"
|
35
|
+
@app_key = resolve_app_key(app_key)
|
36
|
+
|
37
|
+
@app_algo = app_algo || ENV.fetch("GH_APP_ALGO", "RS256")
|
38
|
+
|
39
|
+
@client = nil
|
40
|
+
@token_refresh_time = nil
|
41
|
+
@rate_limit_all = nil
|
42
|
+
|
43
|
+
setup_retry_config!
|
44
|
+
end
|
45
|
+
|
46
|
+
# A helper method to check the client's current rate limit status before making a request
|
47
|
+
# NOTE: This method will sleep for the remaining time until the rate limit resets if the rate limit is hit
|
48
|
+
# :param: type [Symbol] the type of rate limit to check (core, search, graphql, etc) - default: :core
|
49
|
+
# :return: nil (nothing) - this method will block until the rate limit is reset for the given type
|
50
|
+
def wait_for_rate_limit!(type = :core)
|
51
|
+
@log.debug("checking rate limit status for type: #{type}")
|
52
|
+
# make a request to get the comprehensive rate limit status
|
53
|
+
# note: checking the rate limit status does not count against the rate limit in any way
|
54
|
+
fetch_rate_limit if @rate_limit_all.nil?
|
55
|
+
|
56
|
+
details = rate_limit_details(type)
|
57
|
+
rate_limit = details[:rate_limit]
|
58
|
+
resets_at = details[:resets_at]
|
59
|
+
|
60
|
+
@log.debug(
|
61
|
+
"rate_limit remaining: #{rate_limit[:remaining]} - " \
|
62
|
+
"used: #{rate_limit[:used]} - " \
|
63
|
+
"resets_at: #{resets_at} - " \
|
64
|
+
"current time: #{Time.now}"
|
65
|
+
)
|
66
|
+
|
67
|
+
# exit early if the rate limit is not hit (we have remaining requests)
|
68
|
+
unless rate_limit[:remaining].zero?
|
69
|
+
update_rate_limit(type)
|
70
|
+
return
|
71
|
+
end
|
72
|
+
|
73
|
+
# if we make it here, we (probably) have hit the rate limit
|
74
|
+
# fetch the rate limit again if we are at zero or if the rate limit reset time is in the past
|
75
|
+
fetch_rate_limit if rate_limit[:remaining].zero? || rate_limit[:remaining] < 0 || resets_at < Time.now
|
76
|
+
|
77
|
+
details = rate_limit_details(type)
|
78
|
+
rate_limit = details[:rate_limit]
|
79
|
+
resets_at = details[:resets_at]
|
80
|
+
|
81
|
+
# exit early if the rate limit is not actually hit (we have remaining requests)
|
82
|
+
unless rate_limit[:remaining].zero?
|
83
|
+
@log.debug("rate_limit not hit - remaining: #{rate_limit[:remaining]}")
|
84
|
+
update_rate_limit(type)
|
85
|
+
return
|
86
|
+
end
|
87
|
+
|
88
|
+
# calculate the sleep duration - ex: reset time - current time
|
89
|
+
sleep_duration = resets_at - Time.now
|
90
|
+
@log.debug("sleep_duration: #{sleep_duration}")
|
91
|
+
sleep_duration = [sleep_duration, 0].max # ensure sleep duration is not negative
|
92
|
+
sleep_duration_and_a_little_more = sleep_duration.ceil + 2 # sleep a little more than the rate limit reset time
|
93
|
+
|
94
|
+
# log the sleep duration and begin the blocking sleep call
|
95
|
+
@log.info("github rate_limit hit: sleeping for: #{sleep_duration_and_a_little_more} seconds")
|
96
|
+
sleep(sleep_duration_and_a_little_more)
|
97
|
+
|
98
|
+
@log.info("github rate_limit sleep complete - Time.now: #{Time.now}")
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
# Creates a default logger if none is provided
|
104
|
+
# @return [RedactingLogger] A new logger instance
|
105
|
+
def create_default_logger
|
106
|
+
RedactingLogger.new($stdout, level: ENV.fetch("GH_APP_LOG_LEVEL", "INFO").upcase)
|
107
|
+
end
|
108
|
+
|
109
|
+
# Sets up retry configuration for handling API errors
|
110
|
+
# Should the number of retries be reached without success, the last exception will be raised
|
111
|
+
def setup_retry_config!
|
112
|
+
@retry_sleep = ENV.fetch("GH_APP_SLEEP", 3).to_i
|
113
|
+
@retry_tries = ENV.fetch("GH_APP_RETRIES", 10).to_i
|
114
|
+
@retry_exponential_backoff = ENV.fetch("GH_APP_EXPONENTIAL_BACKOFF", "false").downcase == "true"
|
115
|
+
end
|
116
|
+
|
117
|
+
# Custom retry logic with optional exponential backoff and logging
|
118
|
+
# @param retries [Integer] Number of retries to attempt
|
119
|
+
# @param sleep_time [Integer] Base sleep time between retries
|
120
|
+
# @param block [Proc] The block to execute with retry logic
|
121
|
+
# @return [Object] The result of the block execution
|
122
|
+
# When exponential backoff is enabled (default is disabled):
|
123
|
+
# 1st retry: 3 seconds
|
124
|
+
# 2nd retry: 6 seconds
|
125
|
+
# 3rd retry: 12 seconds
|
126
|
+
# 4th retry: 24 seconds
|
127
|
+
# When exponential backoff is disabled:
|
128
|
+
# All retries: 3 seconds (fixed rate)
|
129
|
+
def retry_request(retries: @retry_tries, sleep_time: @retry_sleep, &block)
|
130
|
+
attempt = 0
|
131
|
+
begin
|
132
|
+
attempt += 1
|
133
|
+
yield
|
134
|
+
rescue StandardError => e
|
135
|
+
if attempt < retries
|
136
|
+
if @retry_exponential_backoff
|
137
|
+
backoff_time = sleep_time * (2**(attempt - 1)) # Exponential backoff
|
138
|
+
else
|
139
|
+
backoff_time = sleep_time # Fixed rate
|
140
|
+
end
|
141
|
+
@log.debug("[retry ##{attempt}] #{e.class}: #{e.message} - sleeping #{backoff_time}s before retry")
|
142
|
+
sleep(backoff_time)
|
143
|
+
retry
|
144
|
+
else
|
145
|
+
@log.debug("[retry ##{attempt}] #{e.class}: #{e.message} - max retries exceeded")
|
146
|
+
raise e
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def fetch_rate_limit
|
152
|
+
@rate_limit_all = retry_request do
|
153
|
+
client.get("rate_limit")
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# Update the in-memory "cached" rate limit value for the given rate limit type
|
158
|
+
def update_rate_limit(type)
|
159
|
+
@rate_limit_all[:resources][type][:remaining] -= 1
|
160
|
+
end
|
161
|
+
|
162
|
+
def rate_limit_details(type)
|
163
|
+
# fetch the provided rate limit type
|
164
|
+
# rate_limit resulting structure: {:limit=>5000, :used=>15, :remaining=>4985, :reset=>1713897293}
|
165
|
+
rate_limit = @rate_limit_all[:resources][type]
|
166
|
+
|
167
|
+
# calculate the time the rate limit will reset
|
168
|
+
resets_at = Time.at(rate_limit[:reset]).utc
|
169
|
+
|
170
|
+
return {
|
171
|
+
rate_limit: rate_limit,
|
172
|
+
resets_at: resets_at,
|
173
|
+
}
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
|
178
|
+
# Fetches the value of an environment variable and raises an error if it is not set.
|
179
|
+
# @param key [String] The name of the environment variable.
|
180
|
+
# @return [String] The value of the environment variable.
|
181
|
+
def fetch_env_var(key)
|
182
|
+
ENV.fetch(key) { raise "environment variable #{key} is not set" }
|
183
|
+
end
|
184
|
+
|
185
|
+
# Resolves the app key from various sources
|
186
|
+
# @param app_key [String, nil] The app key parameter
|
187
|
+
# @return [String] The resolved app key content
|
188
|
+
def resolve_app_key(app_key)
|
189
|
+
# If app_key is provided as a parameter
|
190
|
+
if app_key
|
191
|
+
# Check if it's a file path (ends with .pem)
|
192
|
+
if app_key.end_with?(".pem")
|
193
|
+
unless File.exist?(app_key)
|
194
|
+
raise "App key file not found: #{app_key}"
|
195
|
+
end
|
196
|
+
|
197
|
+
@log.debug("Loading app key from file: #{app_key}")
|
198
|
+
key_content = File.read(app_key)
|
199
|
+
|
200
|
+
if key_content.strip.empty?
|
201
|
+
raise "App key file is empty: #{app_key}"
|
202
|
+
end
|
203
|
+
|
204
|
+
@log.debug("Successfully loaded app key from file (#{key_content.length} characters)")
|
205
|
+
return key_content
|
206
|
+
else
|
207
|
+
# It's a key string, process escape sequences
|
208
|
+
@log.debug("Using provided app key string")
|
209
|
+
return normalize_key_string(app_key)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Fall back to environment variable
|
214
|
+
@log.debug("Loading app key from environment variable")
|
215
|
+
env_key = fetch_env_var("GH_APP_KEY")
|
216
|
+
normalize_key_string(env_key)
|
217
|
+
end
|
218
|
+
|
219
|
+
# Normalizes escape sequences in key strings safely
|
220
|
+
# @param key_string [String] The key string to normalize
|
221
|
+
# @return [String] The normalized key string
|
222
|
+
def normalize_key_string(key_string)
|
223
|
+
# Use simple string replacement to avoid ReDoS vulnerability
|
224
|
+
# This handles both single \n and multiple consecutive \\n sequences
|
225
|
+
key_string.gsub('\\n', "\n")
|
226
|
+
end
|
227
|
+
|
228
|
+
# Caches the octokit client if it is not nil and the token has not expired
|
229
|
+
# If it is nil or the token has expired, it creates a new client
|
230
|
+
# @return [Octokit::Client] The octokit client
|
231
|
+
def client
|
232
|
+
if @client.nil? || token_expired?
|
233
|
+
@client = create_client
|
234
|
+
end
|
235
|
+
|
236
|
+
@client
|
237
|
+
end
|
238
|
+
|
239
|
+
# A helper method for generating a JWT token for the GitHub App
|
240
|
+
# @return [String] The JWT token
|
241
|
+
def jwt_token
|
242
|
+
private_key = OpenSSL::PKey::RSA.new(@app_key)
|
243
|
+
|
244
|
+
payload = {}.tap do |opts|
|
245
|
+
opts[:iat] = Time.now.to_i - 60 # issued at time, 60 seconds in the past to allow for clock drift
|
246
|
+
opts[:exp] = opts[:iat] + JWT_EXPIRATION_TIME # JWT expiration time (10 minute maximum)
|
247
|
+
opts[:iss] = @app_id # GitHub App ID
|
248
|
+
end
|
249
|
+
|
250
|
+
JWT.encode(payload, private_key, @app_algo)
|
251
|
+
end
|
252
|
+
|
253
|
+
# Creates a new octokit client and fetches a new installation access token
|
254
|
+
# @return [Octokit::Client] The octokit client
|
255
|
+
def create_client
|
256
|
+
client = ::Octokit::Client.new(bearer_token: jwt_token)
|
257
|
+
access_token = client.create_app_installation_access_token(@installation_id)[:token]
|
258
|
+
client = ::Octokit::Client.new(access_token:)
|
259
|
+
client.auto_paginate = true
|
260
|
+
client.per_page = 100
|
261
|
+
@token_refresh_time = Time.now
|
262
|
+
client
|
263
|
+
end
|
264
|
+
|
265
|
+
# GitHub App installation access tokens expire after 1h
|
266
|
+
# This method checks if the token has expired and returns true if it has
|
267
|
+
# It is very cautious and expires tokens at 45 minutes to account for clock drift
|
268
|
+
# @return [Boolean] True if the token has expired, false otherwise
|
269
|
+
def token_expired?
|
270
|
+
@token_refresh_time.nil? || (Time.now - @token_refresh_time) > TOKEN_EXPIRATION_TIME
|
271
|
+
end
|
272
|
+
|
273
|
+
# This method is called when a method is called on the GitHub class that does not exist.
|
274
|
+
# It delegates the method call to the Octokit client with built-in retry logic and rate limiting.
|
275
|
+
# @param method [Symbol] The name of the method being called.
|
276
|
+
# @param args [Array] The arguments passed to the method.
|
277
|
+
# @param block [Proc] An optional block passed to the method.
|
278
|
+
# @return [Object] The result of the method call on the Octokit client.
|
279
|
+
def method_missing(method, *args, **kwargs, &block)
|
280
|
+
# Check if retry is explicitly disabled for this call
|
281
|
+
disable_retry = kwargs.delete(:disable_retry) || false
|
282
|
+
|
283
|
+
# Determine the rate limit type based on the method name and arguments
|
284
|
+
rate_limit_type = case method.to_s
|
285
|
+
when /search_/
|
286
|
+
:search
|
287
|
+
when /graphql/
|
288
|
+
# :nocov:
|
289
|
+
:graphql # I don't actually know of any endpoints that match this method sig yet
|
290
|
+
# :nocov:
|
291
|
+
else
|
292
|
+
# Check if this is a GraphQL call via POST
|
293
|
+
if method.to_s == "post" && args.first&.include?("/graphql")
|
294
|
+
:graphql
|
295
|
+
else
|
296
|
+
:core
|
297
|
+
end
|
298
|
+
end
|
299
|
+
|
300
|
+
# Handle special case for search_issues which can hit secondary rate limits
|
301
|
+
if method.to_s == "search_issues"
|
302
|
+
request_proc = proc do
|
303
|
+
wait_for_rate_limit!(rate_limit_type)
|
304
|
+
client.send(method, *args, **kwargs, &block) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod
|
305
|
+
end
|
306
|
+
|
307
|
+
begin
|
308
|
+
if disable_retry
|
309
|
+
request_proc.call
|
310
|
+
else
|
311
|
+
retry_request(&request_proc)
|
312
|
+
end
|
313
|
+
rescue StandardError => e
|
314
|
+
# re-raise the error but if its a secondary rate limit error, just sleep for a minute
|
315
|
+
if e.message.include?("exceeded a secondary rate limit")
|
316
|
+
@log.warn("GitHub secondary rate limit hit, sleeping for 60 seconds")
|
317
|
+
sleep(60)
|
318
|
+
end
|
319
|
+
raise e
|
320
|
+
end
|
321
|
+
else
|
322
|
+
# For all other methods, use standard retry and rate limiting
|
323
|
+
request_proc = proc do
|
324
|
+
wait_for_rate_limit!(rate_limit_type)
|
325
|
+
client.send(method, *args, **kwargs, &block) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod
|
326
|
+
end
|
327
|
+
|
328
|
+
if disable_retry
|
329
|
+
request_proc.call
|
330
|
+
else
|
331
|
+
retry_request(&request_proc)
|
332
|
+
end
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
# This method is called to check if the GitHub class responds to a method.
|
337
|
+
# It checks if the Octokit client responds to the method.
|
338
|
+
# @param method [Symbol] The name of the method being checked.
|
339
|
+
# @param include_private [Boolean] Whether to include private methods in the check.
|
340
|
+
# @return [Boolean] True if the Octokit client responds to the method, false otherwise.
|
341
|
+
def respond_to_missing?(method, include_private = false)
|
342
|
+
client.respond_to?(method, include_private) || super
|
343
|
+
end
|
344
|
+
end
|
data/lib/issue_db/utils/init.rb
CHANGED
@@ -9,10 +9,11 @@ module Init
|
|
9
9
|
@repo.full_name,
|
10
10
|
@label,
|
11
11
|
"000000",
|
12
|
-
{ description: "This issue is managed by the issue-db Ruby library. Please do not remove this label." }
|
12
|
+
{ description: "This issue is managed by the issue-db Ruby library. Please do not remove this label." },
|
13
|
+
disable_retry: true
|
13
14
|
)
|
14
15
|
rescue StandardError => e
|
15
|
-
if e.message.include?("
|
16
|
+
if e.message.include?("already_exists")
|
16
17
|
@log.debug("label #{@label} already exists")
|
17
18
|
else
|
18
19
|
@log.error("error creating label: #{e.message}") unless ENV.fetch("ENV", nil) == "acceptance"
|
data/lib/issue_db.rb
CHANGED
@@ -3,7 +3,6 @@
|
|
3
3
|
require "redacting_logger"
|
4
4
|
|
5
5
|
require_relative "version"
|
6
|
-
require_relative "issue_db/utils/retry"
|
7
6
|
require_relative "issue_db/utils/init"
|
8
7
|
require_relative "issue_db/authentication"
|
9
8
|
require_relative "issue_db/models/repository"
|
@@ -27,7 +26,6 @@ class IssueDB
|
|
27
26
|
# :return: A new IssueDB object
|
28
27
|
def initialize(repo, log: nil, octokit_client: nil, label: nil, cache_expiry: nil, init: true)
|
29
28
|
@log = log || RedactingLogger.new($stdout, level: ENV.fetch("LOG_LEVEL", "INFO").upcase)
|
30
|
-
Retry.setup!(log: @log)
|
31
29
|
@version = VERSION
|
32
30
|
@client = Authentication.login(octokit_client, @log)
|
33
31
|
@repo = Repository.new(repo)
|
data/lib/version.rb
CHANGED
metadata
CHANGED
@@ -1,15 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: issue-db
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 1.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- runwaylab
|
8
8
|
- GrantBirki
|
9
|
-
autorequire:
|
10
9
|
bindir: bin
|
11
10
|
cert_chain: []
|
12
|
-
date:
|
11
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
13
12
|
dependencies:
|
14
13
|
- !ruby/object:Gem::Dependency
|
15
14
|
name: redacting-logger
|
@@ -26,39 +25,25 @@ dependencies:
|
|
26
25
|
- !ruby/object:Gem::Version
|
27
26
|
version: '1.4'
|
28
27
|
- !ruby/object:Gem::Dependency
|
29
|
-
name:
|
28
|
+
name: octokit
|
30
29
|
requirement: !ruby/object:Gem::Requirement
|
31
30
|
requirements:
|
32
|
-
- - "~>"
|
33
|
-
- !ruby/object:Gem::Version
|
34
|
-
version: '3.0'
|
35
31
|
- - ">="
|
36
32
|
- !ruby/object:Gem::Version
|
37
|
-
version:
|
33
|
+
version: '9.2'
|
34
|
+
- - "<"
|
35
|
+
- !ruby/object:Gem::Version
|
36
|
+
version: '11.0'
|
38
37
|
type: :runtime
|
39
38
|
prerelease: false
|
40
39
|
version_requirements: !ruby/object:Gem::Requirement
|
41
40
|
requirements:
|
42
|
-
- - "~>"
|
43
|
-
- !ruby/object:Gem::Version
|
44
|
-
version: '3.0'
|
45
41
|
- - ">="
|
46
|
-
- !ruby/object:Gem::Version
|
47
|
-
version: 3.0.5
|
48
|
-
- !ruby/object:Gem::Dependency
|
49
|
-
name: octokit
|
50
|
-
requirement: !ruby/object:Gem::Requirement
|
51
|
-
requirements:
|
52
|
-
- - "~>"
|
53
42
|
- !ruby/object:Gem::Version
|
54
43
|
version: '9.2'
|
55
|
-
|
56
|
-
prerelease: false
|
57
|
-
version_requirements: !ruby/object:Gem::Requirement
|
58
|
-
requirements:
|
59
|
-
- - "~>"
|
44
|
+
- - "<"
|
60
45
|
- !ruby/object:Gem::Version
|
61
|
-
version: '
|
46
|
+
version: '11.0'
|
62
47
|
- !ruby/object:Gem::Dependency
|
63
48
|
name: faraday-retry
|
64
49
|
requirement: !ruby/object:Gem::Requirement
|
@@ -83,26 +68,25 @@ dependencies:
|
|
83
68
|
name: jwt
|
84
69
|
requirement: !ruby/object:Gem::Requirement
|
85
70
|
requirements:
|
86
|
-
- - "~>"
|
87
|
-
- !ruby/object:Gem::Version
|
88
|
-
version: '2.9'
|
89
71
|
- - ">="
|
90
72
|
- !ruby/object:Gem::Version
|
91
73
|
version: 2.9.3
|
74
|
+
- - "<"
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '4.0'
|
92
77
|
type: :runtime
|
93
78
|
prerelease: false
|
94
79
|
version_requirements: !ruby/object:Gem::Requirement
|
95
80
|
requirements:
|
96
|
-
- - "~>"
|
97
|
-
- !ruby/object:Gem::Version
|
98
|
-
version: '2.9'
|
99
81
|
- - ">="
|
100
82
|
- !ruby/object:Gem::Version
|
101
83
|
version: 2.9.3
|
84
|
+
- - "<"
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '4.0'
|
102
87
|
description: 'A Ruby Gem to use GitHub Issues as a NoSQL JSON document db
|
103
88
|
|
104
89
|
'
|
105
|
-
email:
|
106
90
|
executables: []
|
107
91
|
extensions: []
|
108
92
|
extra_rdoc_files: []
|
@@ -117,11 +101,9 @@ files:
|
|
117
101
|
- lib/issue_db/models/record.rb
|
118
102
|
- lib/issue_db/models/repository.rb
|
119
103
|
- lib/issue_db/utils/generate.rb
|
120
|
-
- lib/issue_db/utils/
|
104
|
+
- lib/issue_db/utils/github.rb
|
121
105
|
- lib/issue_db/utils/init.rb
|
122
106
|
- lib/issue_db/utils/parse.rb
|
123
|
-
- lib/issue_db/utils/retry.rb
|
124
|
-
- lib/issue_db/utils/throttle.rb
|
125
107
|
- lib/version.rb
|
126
108
|
homepage: https://github.com/runwaylab/issue-db
|
127
109
|
licenses:
|
@@ -130,7 +112,6 @@ metadata:
|
|
130
112
|
source_code_uri: https://github.com/runwaylab/issue-db
|
131
113
|
documentation_uri: https://github.com/runwaylab/issue-db
|
132
114
|
bug_tracker_uri: https://github.com/runwaylab/issue-db/issues
|
133
|
-
post_install_message:
|
134
115
|
rdoc_options: []
|
135
116
|
require_paths:
|
136
117
|
- lib
|
@@ -145,8 +126,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
145
126
|
- !ruby/object:Gem::Version
|
146
127
|
version: '0'
|
147
128
|
requirements: []
|
148
|
-
rubygems_version: 3.
|
149
|
-
signing_key:
|
129
|
+
rubygems_version: 3.6.9
|
150
130
|
specification_version: 4
|
151
131
|
summary: A Ruby Gem to use GitHub Issues as a NoSQL JSON document db
|
152
132
|
test_files: []
|
@@ -1,108 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
# This class provides a wrapper around the Octokit client for GitHub App authentication.
|
4
|
-
# It handles token generation and refreshing, and delegates method calls to the Octokit client.
|
5
|
-
# Helpful: https://github.com/octokit/handbook?tab=readme-ov-file#github-app-authentication-json-web-token
|
6
|
-
|
7
|
-
# Why? In some cases, you may not want to have a static long lived token like a GitHub PAT when authenticating...
|
8
|
-
# with octokit.rb.
|
9
|
-
# Most importantly, this class will handle automatic token refreshing for you out-of-the-box. Simply provide the...
|
10
|
-
# correct environment variables, call `GitHubApp.new`, and then use the returned object as you would an Octokit client.
|
11
|
-
|
12
|
-
require "octokit"
|
13
|
-
require "jwt"
|
14
|
-
|
15
|
-
class GitHubApp
|
16
|
-
TOKEN_EXPIRATION_TIME = 2700 # 45 minutes
|
17
|
-
JWT_EXPIRATION_TIME = 600 # 10 minutes
|
18
|
-
|
19
|
-
def initialize
|
20
|
-
# app ids are found on the App's settings page
|
21
|
-
@app_id = fetch_env_var("ISSUE_DB_GITHUB_APP_ID").to_i
|
22
|
-
|
23
|
-
# installation ids look like this:
|
24
|
-
# https://github.com/organizations/<org>/settings/installations/<8_digit_id>
|
25
|
-
@installation_id = fetch_env_var("ISSUE_DB_GITHUB_APP_INSTALLATION_ID").to_i
|
26
|
-
|
27
|
-
# app keys are found on the App's settings page and can be downloaded
|
28
|
-
# format: "-----BEGIN...key\n...END-----\n"
|
29
|
-
# make sure this key in your env is a single line string with newlines as "\n"
|
30
|
-
@app_key = fetch_env_var("ISSUE_DB_GITHUB_APP_KEY").gsub(/\\+n/, "\n")
|
31
|
-
|
32
|
-
@client = nil
|
33
|
-
@token_refresh_time = nil
|
34
|
-
end
|
35
|
-
|
36
|
-
private
|
37
|
-
|
38
|
-
# Fetches the value of an environment variable and raises an error if it is not set.
|
39
|
-
# @param key [String] The name of the environment variable.
|
40
|
-
# @return [String] The value of the environment variable.
|
41
|
-
def fetch_env_var(key)
|
42
|
-
ENV.fetch(key) { raise "environment variable #{key} is not set" }
|
43
|
-
end
|
44
|
-
|
45
|
-
# Caches the octokit client if it is not nil and the token has not expired
|
46
|
-
# If it is nil or the token has expired, it creates a new client
|
47
|
-
# @return [Octokit::Client] The octokit client
|
48
|
-
def client
|
49
|
-
if @client.nil? || token_expired?
|
50
|
-
@client = create_client
|
51
|
-
end
|
52
|
-
|
53
|
-
@client
|
54
|
-
end
|
55
|
-
|
56
|
-
# A helper method for generating a JWT token for the GitHub App
|
57
|
-
# @return [String] The JWT token
|
58
|
-
def jwt_token
|
59
|
-
private_key = OpenSSL::PKey::RSA.new(@app_key)
|
60
|
-
|
61
|
-
payload = {}.tap do |opts|
|
62
|
-
opts[:iat] = Time.now.to_i - 60 # issued at time, 60 seconds in the past to allow for clock drift
|
63
|
-
opts[:exp] = opts[:iat] + JWT_EXPIRATION_TIME # JWT expiration time (10 minute maximum)
|
64
|
-
opts[:iss] = @app_id # GitHub App ID
|
65
|
-
end
|
66
|
-
|
67
|
-
JWT.encode(payload, private_key, "RS256")
|
68
|
-
end
|
69
|
-
|
70
|
-
# Creates a new octokit client and fetches a new installation access token
|
71
|
-
# @return [Octokit::Client] The octokit client
|
72
|
-
def create_client
|
73
|
-
client = ::Octokit::Client.new(bearer_token: jwt_token)
|
74
|
-
access_token = client.create_app_installation_access_token(@installation_id)[:token]
|
75
|
-
client = ::Octokit::Client.new(access_token:)
|
76
|
-
client.auto_paginate = true
|
77
|
-
client.per_page = 100
|
78
|
-
@token_refresh_time = Time.now
|
79
|
-
client
|
80
|
-
end
|
81
|
-
|
82
|
-
# GitHub App installation access tokens expire after 1h
|
83
|
-
# This method checks if the token has expired and returns true if it has
|
84
|
-
# It is very cautious and expires tokens at 45 minutes to account for clock drift
|
85
|
-
# @return [Boolean] True if the token has expired, false otherwise
|
86
|
-
def token_expired?
|
87
|
-
@token_refresh_time.nil? || (Time.now - @token_refresh_time) > TOKEN_EXPIRATION_TIME
|
88
|
-
end
|
89
|
-
|
90
|
-
# This method is called when a method is called on the GitHub class that does not exist.
|
91
|
-
# It delegates the method call to the Octokit client.
|
92
|
-
# @param method [Symbol] The name of the method being called.
|
93
|
-
# @param args [Array] The arguments passed to the method.
|
94
|
-
# @param block [Proc] An optional block passed to the method.
|
95
|
-
# @return [Object] The result of the method call on the Octokit client.
|
96
|
-
def method_missing(method, *args, &block)
|
97
|
-
client.send(method, *args, &block) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod
|
98
|
-
end
|
99
|
-
|
100
|
-
# This method is called to check if the GitHub class responds to a method.
|
101
|
-
# It checks if the Octokit client responds to the method.
|
102
|
-
# @param method [Symbol] The name of the method being checked.
|
103
|
-
# @param include_private [Boolean] Whether to include private methods in the check.
|
104
|
-
# @return [Boolean] True if the Octokit client responds to the method, false otherwise.
|
105
|
-
def respond_to_missing?(method, include_private = false)
|
106
|
-
client.respond_to?(method, include_private) || super
|
107
|
-
end
|
108
|
-
end
|
data/lib/issue_db/utils/retry.rb
DELETED
@@ -1,31 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
require "retryable"
|
4
|
-
|
5
|
-
module Retry
|
6
|
-
# This method should be called as early as possible in the startup of your application
|
7
|
-
# It sets up the Retryable gem with custom contexts and passes through a few options
|
8
|
-
# Should the number of retries be reached without success, the last exception will be raised
|
9
|
-
# :param log: the logger to use for retryable logging
|
10
|
-
def self.setup!(log: nil)
|
11
|
-
raise ArgumentError, "a logger must be provided" if log.nil?
|
12
|
-
|
13
|
-
log_method = lambda do |retries, exception|
|
14
|
-
# :nocov:
|
15
|
-
log.debug("[retry ##{retries}] #{exception.class}: #{exception.message} - #{exception.backtrace.join("\n")}")
|
16
|
-
# :nocov:
|
17
|
-
end
|
18
|
-
|
19
|
-
######## Retryable Configuration ########
|
20
|
-
# All defaults available here:
|
21
|
-
# https://github.com/nfedyashev/retryable/blob/6a04027e61607de559e15e48f281f3ccaa9750e8/lib/retryable/configuration.rb#L22-L33
|
22
|
-
Retryable.configure do |config|
|
23
|
-
config.contexts[:default] = {
|
24
|
-
on: [StandardError],
|
25
|
-
sleep: ENV.fetch("ISSUE_DB_SLEEP", 3),
|
26
|
-
tries: ENV.fetch("ISSUE_DB_RETRIES", 10),
|
27
|
-
log_method:
|
28
|
-
}
|
29
|
-
end
|
30
|
-
end
|
31
|
-
end
|
@@ -1,83 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Throttle
|
4
|
-
def fetch_rate_limit
|
5
|
-
@rate_limit_all = Retryable.with_context(:default) do
|
6
|
-
@client.get("rate_limit")
|
7
|
-
end
|
8
|
-
end
|
9
|
-
|
10
|
-
# Update the in-memory "cached" rate limit value for the given rate limit type
|
11
|
-
def update_rate_limit(type)
|
12
|
-
@rate_limit_all[:resources][type][:remaining] -= 1
|
13
|
-
end
|
14
|
-
|
15
|
-
def rate_limit_details(type)
|
16
|
-
# fetch the provided rate limit type
|
17
|
-
# rate_limit resulting structure: {:limit=>5000, :used=>15, :remaining=>4985, :reset=>1713897293}
|
18
|
-
rate_limit = @rate_limit_all[:resources][type]
|
19
|
-
|
20
|
-
# calculate the time the rate limit will reset
|
21
|
-
resets_at = Time.at(rate_limit[:reset]).utc
|
22
|
-
|
23
|
-
return {
|
24
|
-
rate_limit: rate_limit,
|
25
|
-
resets_at: resets_at,
|
26
|
-
}
|
27
|
-
end
|
28
|
-
|
29
|
-
# A helper method to check the client's current rate limit status before making a request
|
30
|
-
# NOTE: This method will sleep for the remaining time until the rate limit resets if the rate limit is hit
|
31
|
-
# :param: type [Symbol] the type of rate limit to check (core, search, graphql, etc) - default: :core
|
32
|
-
# :return: nil (nothing) - this method will block until the rate limit is reset for the given type
|
33
|
-
def wait_for_rate_limit!(type = :core)
|
34
|
-
@log.debug("checking rate limit status for type: #{type}")
|
35
|
-
# make a request to get the comprehensive rate limit status
|
36
|
-
# note: checking the rate limit status does not count against the rate limit in any way
|
37
|
-
fetch_rate_limit if @rate_limit_all.nil?
|
38
|
-
|
39
|
-
details = rate_limit_details(type)
|
40
|
-
rate_limit = details[:rate_limit]
|
41
|
-
resets_at = details[:resets_at]
|
42
|
-
|
43
|
-
@log.debug(
|
44
|
-
"rate_limit remaining: #{rate_limit.remaining} - " \
|
45
|
-
"used: #{rate_limit.used} - " \
|
46
|
-
"resets_at: #{resets_at} - " \
|
47
|
-
"current time: #{Time.now}"
|
48
|
-
)
|
49
|
-
|
50
|
-
# exit early if the rate limit is not hit (we have remaining requests)
|
51
|
-
unless rate_limit.remaining.zero?
|
52
|
-
update_rate_limit(type)
|
53
|
-
return
|
54
|
-
end
|
55
|
-
|
56
|
-
# if we make it here, we (probably) have hit the rate limit
|
57
|
-
# fetch the rate limit again if we are at zero or if the rate limit reset time is in the past
|
58
|
-
fetch_rate_limit if rate_limit.remaining.zero? || rate_limit.remaining < 0 || resets_at < Time.now
|
59
|
-
|
60
|
-
details = rate_limit_details(type)
|
61
|
-
rate_limit = details[:rate_limit]
|
62
|
-
resets_at = details[:resets_at]
|
63
|
-
|
64
|
-
# exit early if the rate limit is not actually hit (we have remaining requests)
|
65
|
-
unless rate_limit.remaining.zero?
|
66
|
-
@log.debug("rate_limit not hit - remaining: #{rate_limit.remaining}")
|
67
|
-
update_rate_limit(type)
|
68
|
-
return
|
69
|
-
end
|
70
|
-
|
71
|
-
# calculate the sleep duration - ex: reset time - current time
|
72
|
-
sleep_duration = resets_at - Time.now
|
73
|
-
@log.debug("sleep_duration: #{sleep_duration}")
|
74
|
-
sleep_duration = [sleep_duration, 0].max # ensure sleep duration is not negative
|
75
|
-
sleep_duration_and_a_little_more = sleep_duration.ceil + 2 # sleep a little more than the rate limit reset time
|
76
|
-
|
77
|
-
# log the sleep duration and begin the blocking sleep call
|
78
|
-
@log.info("github rate_limit hit: sleeping for: #{sleep_duration_and_a_little_more} seconds")
|
79
|
-
sleep(sleep_duration_and_a_little_more)
|
80
|
-
|
81
|
-
@log.info("github rate_limit sleep complete - Time.now: #{Time.now}")
|
82
|
-
end
|
83
|
-
end
|