issue-db 0.1.2 → 1.0.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: c7e786c1b79b72e6ef52d8347d5d647f5275c714fd72699fd187032034ee0aae
4
+ data.tar.gz: 6f18ea1176291ab1a6da20e90bc7e95d7ca3f727097c012a31bf3adb50faf1e3
5
5
  SHA512:
6
- metadata.gz: 55d3dc07637b3e87aa43ce0dd33aaa1e05be8ddf4c9e4cf9d8a4628146fd2ac3538fee93c2345149c89ed9bb4545285eaa394bb4a407802905a75dc5d4c3691d
7
- data.tar.gz: e0e3efa9d9120b0e139953a51ba7931db990f52cc05e4eb573a84fcb3bf93fb0536c6447ee638a9cdafbff52c3513f50e429e1cc68a154f141ece1f6dd5b5a17
6
+ metadata.gz: 4379608b58b9cc396deeabfed41e01753e927792483000f3047e4c600eb0b2cf02e49ee53c3b8c34a7de494713899f9abc944013d344263961a20e13197a2cab
7
+ data.tar.gz: 11da329ed7e764483a38934d552ad87dddbec88b9be0136de249fa45d5519604b176b9a3fe2f33d5a13c61e37c64c1af374fc2e8cdbe774e45065bae28cf8d89
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,20 +11,10 @@ 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
19
  raise retry_err_msg
30
20
  end
@@ -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,10 +49,7 @@ 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
54
  # append the newly created issue to the issues cache
61
55
  @issues << issue
@@ -92,10 +86,7 @@ class Database
92
86
 
93
87
  body = generate(data, body_before: options[:body_before], body_after: options[:body_after])
94
88
 
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
89
+ updated_issue = @client.update_issue(@repo.full_name, issue.number, key, body)
99
90
 
100
91
  # update the issue in the cache using the reference we have
101
92
  @issues[@issues.index(issue)] = updated_issue
@@ -112,10 +103,7 @@ class Database
112
103
  @log.debug("attempting to delete: #{key}")
113
104
  issue = find_issue_by_key(key, options)
114
105
 
115
- deleted_issue = Retryable.with_context(:default) do
116
- wait_for_rate_limit!
117
- @client.close_issue(@repo.full_name, issue.number)
118
- end
106
+ deleted_issue = @client.close_issue(@repo.full_name, issue.number)
119
107
 
120
108
  # remove the issue from the cache
121
109
  @issues.delete(issue)
@@ -0,0 +1,329 @@
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, &block)
280
+ # Determine the rate limit type based on the method name and arguments
281
+ rate_limit_type = case method.to_s
282
+ when /search_/
283
+ :search
284
+ when /graphql/
285
+ # :nocov:
286
+ :graphql # I don't actually know of any endpoints that match this method sig yet
287
+ # :nocov:
288
+ else
289
+ # Check if this is a GraphQL call via POST
290
+ if method.to_s == "post" && args.first&.include?("/graphql")
291
+ :graphql
292
+ else
293
+ :core
294
+ end
295
+ end
296
+
297
+ # Handle special case for search_issues which can hit secondary rate limits
298
+ if method.to_s == "search_issues"
299
+ begin
300
+ retry_request do
301
+ wait_for_rate_limit!(rate_limit_type)
302
+ client.send(method, *args, &block) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod
303
+ end
304
+ rescue StandardError => e
305
+ # re-raise the error but if its a secondary rate limit error, just sleep for a minute
306
+ if e.message.include?("exceeded a secondary rate limit")
307
+ @log.warn("GitHub secondary rate limit hit, sleeping for 60 seconds")
308
+ sleep(60)
309
+ end
310
+ raise e
311
+ end
312
+ else
313
+ # For all other methods, use standard retry and rate limiting
314
+ retry_request do
315
+ wait_for_rate_limit!(rate_limit_type)
316
+ client.send(method, *args, &block) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod
317
+ end
318
+ end
319
+ end
320
+
321
+ # This method is called to check if the GitHub class responds to a method.
322
+ # It checks if the Octokit client responds to the method.
323
+ # @param method [Symbol] The name of the method being checked.
324
+ # @param include_private [Boolean] Whether to include private methods in the check.
325
+ # @return [Boolean] True if the Octokit client responds to the method, false otherwise.
326
+ def respond_to_missing?(method, include_private = false)
327
+ client.respond_to?(method, include_private) || super
328
+ end
329
+ end
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.0.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.0.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