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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 582972c64b6a5a2eb81cec43a2de4c02dffaa18ce6c7b95e533ff2e70d7d2eca
4
- data.tar.gz: 1d9bca31c37ae4327d3c36a8f955b63a26bc80e38841b770c710b61906b58f1d
3
+ metadata.gz: 6f4349ba13a8c9f2971aae3c557c6bcd8d0b1b3fc51ecb4a901e3db2a30538db
4
+ data.tar.gz: a59e1724a2f9068bd4596a311b051224ad48a8df158e0607f5dc51374f096033
5
5
  SHA512:
6
- metadata.gz: 55d3dc07637b3e87aa43ce0dd33aaa1e05be8ddf4c9e4cf9d8a4628146fd2ac3538fee93c2345149c89ed9bb4545285eaa394bb4a407802905a75dc5d4c3691d
7
- data.tar.gz: e0e3efa9d9120b0e139953a51ba7931db990f52cc05e4eb573a84fcb3bf93fb0536c6447ee638a9cdafbff52c3513f50e429e1cc68a154f141ece1f6dd5b5a17
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
- | `ISSUE_DB_SLEEP` | The number of seconds to sleep between requests to the GitHub API in the event of an error | `3` |
190
- | `ISSUE_DB_RETRIES` | The number of retries to make when there is an error making a request to the GitHub API | `10` |
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 "retryable", "~> 3.0", ">= 3.0.5"
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", "~> 2.9", ">= 2.9.3"
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/github_app"
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
- if ENV.fetch("ISSUE_DB_GITHUB_APP_ID", nil) && ENV.fetch("ISSUE_DB_GITHUB_APP_INSTALLATION_ID", nil) && ENV.fetch("ISSUE_DB_GITHUB_APP_KEY", nil)
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 GitHubApp.new
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
@@ -11,22 +11,18 @@ module Cache
11
11
 
12
12
  search_response = nil
13
13
  begin
14
- Retryable.with_context(:default) do
15
- wait_for_rate_limit!(:search) # specifically wait for the search rate limit as it is much lower
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} - ran out of retries"
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")
@@ -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 = Retryable.with_context(:default) do
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
- # append the newly created issue to the issues cache
61
- @issues << issue
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 = Retryable.with_context(:default) do
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[@issues.index(issue)] = updated_issue
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 = Retryable.with_context(:default) do
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
- # remove the issue from the cache
121
- @issues.delete(issue)
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
- keys = issues.select do |issue|
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
- records = issues.select do |issue|
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
- issues_cache_expired = (Time.now - @issues_last_updated) > @cache_expiry
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
@@ -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?("code: already_exists")
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Version
4
- VERSION = "0.1.2"
4
+ VERSION = "1.1.0"
5
5
  end
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: 0.1.2
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: 2025-04-17 00:00:00.000000000 Z
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: retryable
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: 3.0.5
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
- type: :runtime
56
- prerelease: false
57
- version_requirements: !ruby/object:Gem::Requirement
58
- requirements:
59
- - - "~>"
44
+ - - "<"
60
45
  - !ruby/object:Gem::Version
61
- version: '9.2'
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/github_app.rb
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.5.11
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
@@ -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