Last active
July 31, 2025 06:06
-
-
Save GrantBirki/450213036f8eed204d45a348d32d3e39 to your computer and use it in GitHub Desktop.
GitHub App Authentication with Octokit (Ruby)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-----BEGIN RSA PRIVATE KEY----- | |
MIIEpQIBAAKCAQEArCtJrQ+P59A1Pjaf12EfJqltszDpqO0ufsk7N0WRXUeoYZKF | |
AihwLMIaHbTc1Jn/QEX8WmZGPKIcRlJ9pk2MxQbXRqxM35n61Cb8mYMne+6VXNyl | |
ZILvRTMXLGIVy/OTmszafTP8Lws9w8vKKLKCax4kEzbfjiRoCgspO7YudFUCxQ6Q | |
UUxlLPd4/yUaw5A8nUdWZrvPa3CYXG2355yhigRpnOyawoOsvAHUvQruh+z3k6NZ | |
g6f3eMjsrpeID84TPw1kRs+T+XNsSP21ZUFKYs54bdxlLPphT4iPKNkRwoP0SqJm | |
97W98B8k7+mtFPZllYGgiHrA4Egnasg9ULiXCwIDAQABAoIBACMoZ9AuWF2nN+gv | |
cW6jB6B2gs9P0rdLT+5WG4CK9UdOJcVfDUhGh7msHXcpgtrrY6N1ZzXyoq8pD4sQ | |
t1XpijCF2Bo3fy8+G2mNWJHkpYB6VQf0itW+oyvHZhkLIpZWdDLtWESvA/V7Xy6H | |
hA3Rfi5vpkBCOV6mcpRyeQYXit74UzDvojXSf8idsjuVDoIXuaoIJMPwyxTWJY+A | |
8sQ92ecHJ3rQLspxSmXlZYpRtHTNaZlAv1qx605DP/JlV/6bAA3IIWQtrd9Z0Cw/ | |
rwNtwcP0vB/w6Yq9o8EmJHn7rQuC4NN6lMi/VkwaNCqcPfnRWW7OCtgOhfGAP+kr | |
vKU5kEECgYEA4hOWtUC4624E4r9NYNOeYcCj9meR5p3FY8vtPLMvpU9r4m3vPldz | |
ofq/I/Tpo418gWuy02/c4VpHo/8QUBW33yik0YVfL/XWczZwvhabufSTRL3HtIxb | |
NY5qbgV7yJea1mUuTkiM2obl4x+bR6haHPnHVu+QI480zOzkroF9P2ECgYEAwvUX | |
dpKebLxzod/UeQLvZFhFXG/qvvAJ7FwxtDRu22znQJR5YVdqd3XDZkfd+64PjDLf | |
46WMktqu2DclO9eFbKyuLfD0F1OO5z5IHd5dim39/QYo0sJ+y6y6edEM7Of2IxLH | |
5PkSJLVKAju5t2PJMXDBZBa4HVhNdW+lDHKlCesCgYEAzDvQCVw38g/JACK8P33N | |
dhe2x9IWv1TGTnqajhx+LYQLPVn9KL+OKcXBSTVmoCcgVDa8LUDANSD+2UuCLCcC | |
neo0w0cOj+Ax5JFI1qDL+/jT1eTwdc3aVA6dXVk80yEKcyai53upK310TnNuLxUK | |
m2SWzZXMDCPCGmLj0DYQtOECgYEAs3oIsKsH19ihpxs1MnZGRp2QtSl+9Wpr6EFz | |
rI88oxqdxfEp0Tg1lmY+jbGJpYI3Y/0N6jfksulJX1ldGLsvZL2P2FFjlPniq/XF | |
VGH6wU7DLSV3fZd6PSz1uuF+QbbF/MH0blHxpwOSb33mWfMuLCq+jtLvimxZWsx+ | |
KHh+gSMCgYEA22osS9G3tGpG+x3nZRLXc0lOdKQ51sAo955U1BFscZusP/qQ+b12 | |
KTHhoUd264C6Nb42XXs9qIkiKWaVblglJPhui61/iWw4s0k4Q9Yf/0KxREmlLbL0 | |
mm1m2wenfA97PYoheO1esGsatSXNtxPL+C8ywebu38A6hOClQi1BHd4= | |
-----END RSA PRIVATE KEY----- |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
# This class provides a comprehensive wrapper around the Octokit client for GitHub App authentication. | |
# It handles token generation and refreshing, built-in retry logic, rate limiting, and delegates method calls to the Octokit client. | |
# Helpful: https://github.com/octokit/handbook?tab=readme-ov-file#github-app-authentication-json-web-token | |
# Why? In some cases, you may not want to have a static long lived token like a GitHub PAT when authenticating... | |
# with octokit.rb. | |
# Most importantly, this class will handle automatic token refreshing, retries, and rate limiting for you out-of-the-box. | |
# Simply provide the correct environment variables, call `GitHub.new`, and then use the returned object as you would an Octokit client. | |
# Note: Environment variables have the `GH_` prefix because in GitHub Actions, you cannot use `GITHUB_` for secrets | |
require "octokit" | |
require "jwt" | |
require "redacting_logger" | |
class GitHub | |
TOKEN_EXPIRATION_TIME = 2700 # 45 minutes | |
JWT_EXPIRATION_TIME = 600 # 10 minutes | |
def initialize(log: nil, app_id: nil, installation_id: nil, app_key: nil, app_algo: nil) | |
@log = log || create_default_logger | |
# app ids are found on the App's settings page | |
@app_id = app_id || fetch_env_var("GH_APP_ID").to_i | |
# installation ids look like this: | |
# https://github.com/organizations/<org>/settings/installations/<8_digit_id> | |
@installation_id = installation_id || fetch_env_var("GH_APP_INSTALLATION_ID").to_i | |
# app keys are found on the App's settings page and can be downloaded | |
# format: "-----BEGIN...key\n...END-----\n" | |
# make sure this key in your env is a single line string with newlines as "\n" | |
@app_key = resolve_app_key(app_key) | |
@app_algo = app_algo || ENV.fetch("GH_APP_ALGO", "RS256") | |
@client = nil | |
@token_refresh_time = nil | |
@rate_limit_all = nil | |
setup_retry_config! | |
end | |
# A helper method to check the client's current rate limit status before making a request | |
# NOTE: This method will sleep for the remaining time until the rate limit resets if the rate limit is hit | |
# :param: type [Symbol] the type of rate limit to check (core, search, graphql, etc) - default: :core | |
# :return: nil (nothing) - this method will block until the rate limit is reset for the given type | |
def wait_for_rate_limit!(type = :core) | |
@log.debug("checking rate limit status for type: #{type}") | |
# make a request to get the comprehensive rate limit status | |
# note: checking the rate limit status does not count against the rate limit in any way | |
fetch_rate_limit if @rate_limit_all.nil? | |
details = rate_limit_details(type) | |
rate_limit = details[:rate_limit] | |
resets_at = details[:resets_at] | |
@log.debug( | |
"rate_limit remaining: #{rate_limit[:remaining]} - " \ | |
"used: #{rate_limit[:used]} - " \ | |
"resets_at: #{resets_at} - " \ | |
"current time: #{Time.now}" | |
) | |
# exit early if the rate limit is not hit (we have remaining requests) | |
unless rate_limit[:remaining].zero? | |
update_rate_limit(type) | |
return | |
end | |
# if we make it here, we (probably) have hit the rate limit | |
# fetch the rate limit again if we are at zero or if the rate limit reset time is in the past | |
fetch_rate_limit if rate_limit[:remaining].zero? || rate_limit[:remaining] < 0 || resets_at < Time.now | |
details = rate_limit_details(type) | |
rate_limit = details[:rate_limit] | |
resets_at = details[:resets_at] | |
# exit early if the rate limit is not actually hit (we have remaining requests) | |
unless rate_limit[:remaining].zero? | |
@log.debug("rate_limit not hit - remaining: #{rate_limit[:remaining]}") | |
update_rate_limit(type) | |
return | |
end | |
# calculate the sleep duration - ex: reset time - current time | |
sleep_duration = resets_at - Time.now | |
@log.debug("sleep_duration: #{sleep_duration}") | |
sleep_duration = [sleep_duration, 0].max # ensure sleep duration is not negative | |
sleep_duration_and_a_little_more = sleep_duration.ceil + 2 # sleep a little more than the rate limit reset time | |
# log the sleep duration and begin the blocking sleep call | |
@log.info("github rate_limit hit: sleeping for: #{sleep_duration_and_a_little_more} seconds") | |
sleep(sleep_duration_and_a_little_more) | |
@log.info("github rate_limit sleep complete - Time.now: #{Time.now}") | |
end | |
private | |
# Creates a default logger if none is provided | |
# @return [RedactingLogger] A new logger instance | |
def create_default_logger | |
RedactingLogger.new($stdout, level: ENV.fetch("GH_APP_LOG_LEVEL", "INFO").upcase) | |
end | |
# Sets up retry configuration for handling API errors | |
# Should the number of retries be reached without success, the last exception will be raised | |
def setup_retry_config! | |
@retry_sleep = ENV.fetch("GH_APP_SLEEP", 3).to_i | |
@retry_tries = ENV.fetch("GH_APP_RETRIES", 10).to_i | |
@retry_exponential_backoff = ENV.fetch("GH_APP_EXPONENTIAL_BACKOFF", "false").downcase == "true" | |
end | |
# Custom retry logic with optional exponential backoff and logging | |
# @param retries [Integer] Number of retries to attempt | |
# @param sleep_time [Integer] Base sleep time between retries | |
# @param block [Proc] The block to execute with retry logic | |
# @return [Object] The result of the block execution | |
# When exponential backoff is enabled (default is disabled): | |
# 1st retry: 3 seconds | |
# 2nd retry: 6 seconds | |
# 3rd retry: 12 seconds | |
# 4th retry: 24 seconds | |
# When exponential backoff is disabled: | |
# All retries: 3 seconds (fixed rate) | |
def retry_request(retries: @retry_tries, sleep_time: @retry_sleep, &block) | |
attempt = 0 | |
begin | |
attempt += 1 | |
yield | |
rescue StandardError => e | |
if attempt < retries | |
if @retry_exponential_backoff | |
backoff_time = sleep_time * (2**(attempt - 1)) # Exponential backoff | |
else | |
backoff_time = sleep_time # Fixed rate | |
end | |
@log.debug("[retry ##{attempt}] #{e.class}: #{e.message} - sleeping #{backoff_time}s before retry") | |
sleep(backoff_time) | |
retry | |
else | |
@log.debug("[retry ##{attempt}] #{e.class}: #{e.message} - max retries exceeded") | |
raise e | |
end | |
end | |
end | |
def fetch_rate_limit | |
@rate_limit_all = retry_request do | |
client.get("rate_limit") | |
end | |
end | |
# Update the in-memory "cached" rate limit value for the given rate limit type | |
def update_rate_limit(type) | |
@rate_limit_all[:resources][type][:remaining] -= 1 | |
end | |
def rate_limit_details(type) | |
# fetch the provided rate limit type | |
# rate_limit resulting structure: {:limit=>5000, :used=>15, :remaining=>4985, :reset=>1713897293} | |
rate_limit = @rate_limit_all[:resources][type] | |
# calculate the time the rate limit will reset | |
resets_at = Time.at(rate_limit[:reset]).utc | |
return { | |
rate_limit: rate_limit, | |
resets_at: resets_at, | |
} | |
end | |
private | |
# Fetches the value of an environment variable and raises an error if it is not set. | |
# @param key [String] The name of the environment variable. | |
# @return [String] The value of the environment variable. | |
def fetch_env_var(key) | |
ENV.fetch(key) { raise "environment variable #{key} is not set" } | |
end | |
# Resolves the app key from various sources | |
# @param app_key [String, nil] The app key parameter | |
# @return [String] The resolved app key content | |
def resolve_app_key(app_key) | |
# If app_key is provided as a parameter | |
if app_key | |
# Check if it's a file path (ends with .pem) | |
if app_key.end_with?(".pem") | |
unless File.exist?(app_key) | |
raise "App key file not found: #{app_key}" | |
end | |
@log.debug("Loading app key from file: #{app_key}") | |
key_content = File.read(app_key) | |
if key_content.strip.empty? | |
raise "App key file is empty: #{app_key}" | |
end | |
@log.debug("Successfully loaded app key from file (#{key_content.length} characters)") | |
return key_content | |
else | |
# It's a key string, process escape sequences | |
@log.debug("Using provided app key string") | |
return normalize_key_string(app_key) | |
end | |
end | |
# Fall back to environment variable | |
@log.debug("Loading app key from environment variable") | |
env_key = fetch_env_var("GH_APP_KEY") | |
normalize_key_string(env_key) | |
end | |
# Normalizes escape sequences in key strings safely | |
# @param key_string [String] The key string to normalize | |
# @return [String] The normalized key string | |
def normalize_key_string(key_string) | |
# Use simple string replacement to avoid ReDoS vulnerability | |
# This handles both single \n and multiple consecutive \\n sequences | |
key_string.gsub('\\n', "\n") | |
end | |
# Caches the octokit client if it is not nil and the token has not expired | |
# If it is nil or the token has expired, it creates a new client | |
# @return [Octokit::Client] The octokit client | |
def client | |
if @client.nil? || token_expired? | |
@client = create_client | |
end | |
@client | |
end | |
# A helper method for generating a JWT token for the GitHub App | |
# @return [String] The JWT token | |
def jwt_token | |
private_key = OpenSSL::PKey::RSA.new(@app_key) | |
payload = {}.tap do |opts| | |
opts[:iat] = Time.now.to_i - 60 # issued at time, 60 seconds in the past to allow for clock drift | |
opts[:exp] = opts[:iat] + JWT_EXPIRATION_TIME # JWT expiration time (10 minute maximum) | |
opts[:iss] = @app_id # GitHub App ID | |
end | |
JWT.encode(payload, private_key, @app_algo) | |
end | |
# Creates a new octokit client and fetches a new installation access token | |
# @return [Octokit::Client] The octokit client | |
def create_client | |
client = ::Octokit::Client.new(bearer_token: jwt_token) | |
access_token = client.create_app_installation_access_token(@installation_id)[:token] | |
client = ::Octokit::Client.new(access_token:) | |
client.auto_paginate = true | |
client.per_page = 100 | |
@token_refresh_time = Time.now | |
client | |
end | |
# GitHub App installation access tokens expire after 1h | |
# This method checks if the token has expired and returns true if it has | |
# It is very cautious and expires tokens at 45 minutes to account for clock drift | |
# @return [Boolean] True if the token has expired, false otherwise | |
def token_expired? | |
@token_refresh_time.nil? || (Time.now - @token_refresh_time) > TOKEN_EXPIRATION_TIME | |
end | |
# This method is called when a method is called on the GitHub class that does not exist. | |
# It delegates the method call to the Octokit client with built-in retry logic and rate limiting. | |
# @param method [Symbol] The name of the method being called. | |
# @param args [Array] The arguments passed to the method. | |
# @param block [Proc] An optional block passed to the method. | |
# @return [Object] The result of the method call on the Octokit client. | |
def method_missing(method, *args, **kwargs, &block) | |
# Check if retry is explicitly disabled for this call | |
disable_retry = kwargs.delete(:disable_retry) || false | |
# Determine the rate limit type based on the method name and arguments | |
rate_limit_type = case method.to_s | |
when /search_/ | |
:search | |
when /graphql/ | |
# :nocov: | |
:graphql # I don't actually know of any endpoints that match this method sig yet | |
# :nocov: | |
else | |
# Check if this is a GraphQL call via POST | |
if method.to_s == "post" && args.first&.include?("/graphql") | |
:graphql | |
else | |
:core | |
end | |
end | |
# Handle special case for search_issues which can hit secondary rate limits | |
if method.to_s == "search_issues" | |
request_proc = proc do | |
wait_for_rate_limit!(rate_limit_type) | |
client.send(method, *args, **kwargs, &block) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod | |
end | |
begin | |
if disable_retry | |
request_proc.call | |
else | |
retry_request(&request_proc) | |
end | |
rescue StandardError => e | |
# re-raise the error but if its a secondary rate limit error, just sleep for a minute | |
if e.message.include?("exceeded a secondary rate limit") | |
@log.warn("GitHub secondary rate limit hit, sleeping for 60 seconds") | |
sleep(60) | |
end | |
raise e | |
end | |
else | |
# For all other methods, use standard retry and rate limiting | |
request_proc = proc do | |
wait_for_rate_limit!(rate_limit_type) | |
client.send(method, *args, **kwargs, &block) # rubocop:disable GitHub/AvoidObjectSendWithDynamicMethod | |
end | |
if disable_retry | |
request_proc.call | |
else | |
retry_request(&request_proc) | |
end | |
end | |
end | |
# This method is called to check if the GitHub class responds to a method. | |
# It checks if the Octokit client responds to the method. | |
# @param method [Symbol] The name of the method being checked. | |
# @param include_private [Boolean] Whether to include private methods in the check. | |
# @return [Boolean] True if the Octokit client responds to the method, false otherwise. | |
def respond_to_missing?(method, include_private = false) | |
client.respond_to?(method, include_private) || super | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
# Here is a complete rspec unit test file for github.rb | |
# It will give you 100% test coverage! | |
# Just make sure to change the require_relative path to point to your actual github.rb file | |
# You will also need to commit a fake private key for your unit tests and one can be found in this Gist. | |
require "spec_helper" | |
require_relative "path/to/file/github" | |
describe GitHub do | |
let(:app_id) { 123 } | |
let(:installation_id) { 456 } | |
let(:app_key) { File.read("spec/fixtures/fake_private_key.pem") } | |
let(:jwt_token) { "mocked_jwt_token" } | |
let(:access_token) { "mocked_access_token" } | |
let(:mock_client) { instance_double(Octokit::Client) } | |
let(:mock_logger) { instance_double(RedactingLogger) } | |
let(:default_rate_limit_response) do | |
{ | |
resources: { | |
core: { remaining: 5000, used: 0, limit: 5000, reset: (Time.now + 3600).to_i }, | |
search: { remaining: 30, used: 0, limit: 30, reset: (Time.now + 3600).to_i }, | |
graphql: { remaining: 5000, used: 0, limit: 5000, reset: (Time.now + 3600).to_i } | |
} | |
} | |
end | |
let(:rate_limit_hit_response) do | |
{ | |
resources: { | |
core: { remaining: 0, used: 5000, limit: 5000, reset: (Time.now + 10).to_i } | |
} | |
} | |
end | |
def stub_environment_vars | |
allow(ENV).to receive(:fetch).and_call_original | |
%w[GH_APP_ID GH_APP_INSTALLATION_ID GH_APP_KEY GH_APP_LOG_LEVEL | |
GH_APP_SLEEP GH_APP_RETRIES GH_APP_EXPONENTIAL_BACKOFF GH_APP_ALGO].each do |var| | |
default_value = case var | |
when "GH_APP_ID" then app_id.to_s | |
when "GH_APP_INSTALLATION_ID" then installation_id.to_s | |
when "GH_APP_KEY" then app_key | |
when "GH_APP_LOG_LEVEL" then "INFO" | |
when "GH_APP_SLEEP" then "3" | |
when "GH_APP_RETRIES" then "10" | |
when "GH_APP_EXPONENTIAL_BACKOFF" then "false" | |
when "GH_APP_ALGO" then "RS256" | |
end | |
allow(ENV).to receive(:fetch).with(var, anything).and_return(default_value) | |
allow(ENV).to receive(:fetch).with(var).and_return(default_value) | |
end | |
end | |
def stub_dependencies | |
# Stub logger | |
[:debug, :info, :warn, :error].each { |method| allow(mock_logger).to receive(method) } | |
allow(RedactingLogger).to receive(:new).and_return(mock_logger) | |
# Stub Octokit client | |
allow(Octokit::Client).to receive(:new).and_return(mock_client) | |
allow(mock_client).to receive(:auto_paginate=).with(true) | |
allow(mock_client).to receive(:per_page=).with(100) | |
allow(mock_client).to receive(:create_app_installation_access_token) | |
.with(installation_id).and_return(token: access_token) | |
# Stub JWT and Time | |
allow(JWT).to receive(:encode).and_return(jwt_token) | |
allow(Time).to receive(:now).and_return(Time.at(1640995200)) | |
end | |
before do | |
stub_environment_vars | |
stub_dependencies | |
end | |
describe "#initialize" do | |
it "initializes with environment variables" do | |
github = GitHub.new | |
expect(github.instance_variable_get(:@app_id)).to eq(app_id) | |
expect(github.instance_variable_get(:@installation_id)).to eq(installation_id) | |
expect(github.instance_variable_get(:@app_key)).to eq(app_key.gsub('\\n', "\n")) | |
expect(github.instance_variable_get(:@app_algo)).to eq("RS256") | |
end | |
it "initializes with provided parameters" do | |
custom_logger = instance_double(RedactingLogger) | |
allow(custom_logger).to receive(:debug) | |
github = GitHub.new(log: custom_logger, app_id: 999, installation_id: 888, | |
app_key: app_key, app_algo: "RS512") | |
expect(github.instance_variable_get(:@log)).to eq(custom_logger) | |
expect(github.instance_variable_get(:@app_id)).to eq(999) | |
expect(github.instance_variable_get(:@installation_id)).to eq(888) | |
expect(github.instance_variable_get(:@app_key)).to eq(app_key.gsub('\\n', "\n")) | |
expect(github.instance_variable_get(:@app_algo)).to eq("RS512") | |
end | |
it "loads app key from .pem file path" do | |
pem_file_path = "spec/fixtures/fake_private_key.pem" | |
github = GitHub.new(app_id: 999, installation_id: 888, app_key: pem_file_path) | |
expected_key = File.read(pem_file_path) | |
expect(github.instance_variable_get(:@app_key)).to eq(expected_key) | |
end | |
context "error handling" do | |
it "raises error when app key file doesn't exist" do | |
expect { | |
GitHub.new(app_id: 999, installation_id: 888, app_key: "nonexistent_file.pem") | |
}.to raise_error("App key file not found: nonexistent_file.pem") | |
end | |
it "raises error when app key file is empty" do | |
empty_file_path = "spec/fixtures/empty_key.pem" | |
File.write(empty_file_path, "") | |
begin | |
expect { | |
GitHub.new(app_id: 999, installation_id: 888, app_key: empty_file_path) | |
}.to raise_error("App key file is empty: #{empty_file_path}") | |
ensure | |
File.delete(empty_file_path) if File.exist?(empty_file_path) | |
end | |
end | |
it "raises error when environment variable is missing" do | |
allow(ENV).to receive(:fetch).with("GH_APP_KEY") { raise "environment variable GH_APP_KEY is not set" } | |
expect { | |
GitHub.new(app_id: 999, installation_id: 888) | |
}.to raise_error(/environment variable GH_APP_KEY is not set/) | |
end | |
end | |
it "processes escape sequences in app key string" do | |
key_with_escapes = "-----BEGIN RSA PRIVATE KEY-----\\nsome\\nkey\\ndata\\n-----END RSA PRIVATE KEY-----" | |
github = GitHub.new(app_id: 999, installation_id: 888, app_key: key_with_escapes) | |
expected_key = key_with_escapes.gsub('\\n', "\n") | |
expect(github.instance_variable_get(:@app_key)).to eq(expected_key) | |
end | |
it "falls back to environment variables when parameters are not provided" do | |
github = GitHub.new(app_id: 999, installation_id: 888) | |
expect(github.instance_variable_get(:@app_key)).to eq(app_key.gsub('\\n', "\n")) | |
end | |
it "creates default logger when none provided" do | |
github = GitHub.new | |
expect(github.instance_variable_get(:@log)).to eq(mock_logger) | |
end | |
end | |
describe "private methods" do | |
let(:github) { GitHub.new } | |
describe "#client" do | |
it "creates a new client when client is nil" do | |
expect(github.send(:client)).to eq(mock_client) | |
expect(mock_client).to have_received(:create_app_installation_access_token).with(installation_id) | |
end | |
it "creates a new client when token is expired" do | |
github.instance_variable_set(:@token_refresh_time, Time.now - GitHub::TOKEN_EXPIRATION_TIME - 1) | |
expect(github.send(:client)).to eq(mock_client) | |
end | |
it "returns cached client when token is not expired" do | |
github.instance_variable_set(:@client, mock_client) | |
github.instance_variable_set(:@token_refresh_time, Time.now) | |
expect(Octokit::Client).not_to receive(:new) | |
expect(github.send(:client)).to eq(mock_client) | |
end | |
end | |
describe "#jwt_token" do | |
it "generates a JWT token with correct payload" do | |
expect(JWT).to receive(:encode).with( | |
hash_including(iat: kind_of(Integer), exp: kind_of(Integer), iss: app_id), | |
kind_of(OpenSSL::PKey::RSA), "RS256" | |
).and_return(jwt_token) | |
result = github.send(:jwt_token) | |
expect(result).to eq(jwt_token) | |
end | |
it "raises OpenSSL error for invalid RSA private key" do | |
invalid_github = GitHub.new(app_id: 123, app_key: "invalid-key-content") | |
allow(JWT).to receive(:encode).and_call_original | |
expect { invalid_github.send(:jwt_token) }.to raise_error(OpenSSL::PKey::RSAError) | |
end | |
end | |
describe "#token_expired?" do | |
it "returns true when token_refresh_time is nil" do | |
github.instance_variable_set(:@token_refresh_time, nil) | |
expect(github.send(:token_expired?)).to be true | |
end | |
it "returns true when token has expired" do | |
github.instance_variable_set(:@token_refresh_time, Time.now - GitHub::TOKEN_EXPIRATION_TIME - 1) | |
expect(github.send(:token_expired?)).to be true | |
end | |
it "returns false when token has not expired" do | |
github.instance_variable_set(:@token_refresh_time, Time.now) | |
expect(github.send(:token_expired?)).to be false | |
end | |
end | |
end | |
describe "#wait_for_rate_limit!" do | |
let(:github) { GitHub.new } | |
before do | |
allow(github).to receive(:client).and_return(mock_client) | |
end | |
it "fetches rate limit when not cached" do | |
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response) | |
github.wait_for_rate_limit!(:core) | |
expect(mock_client).to have_received(:get).with("rate_limit") | |
end | |
it "exits early when rate limit is not hit" do | |
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response) | |
expect(github).not_to receive(:sleep) | |
github.wait_for_rate_limit!(:core) | |
end | |
it "sleeps when rate limit is hit" do | |
allow(mock_client).to receive(:get).with("rate_limit").and_return(rate_limit_hit_response) | |
allow(github).to receive(:sleep) | |
github.wait_for_rate_limit!(:core) | |
expect(github).to have_received(:sleep) | |
expect(mock_logger).to have_received(:info).with(/github rate_limit hit: sleeping for:/) | |
end | |
it "handles rate limit that resets after refresh" do | |
first_response = { | |
resources: { core: { remaining: 0, used: 5000, limit: 5000, reset: (Time.now - 10).to_i } } | |
} | |
second_response = { | |
resources: { core: { remaining: 5000, used: 0, limit: 5000, reset: (Time.now + 3600).to_i } } | |
} | |
allow(mock_client).to receive(:get).with("rate_limit").and_return(first_response, second_response) | |
expect { github.wait_for_rate_limit!(:core) }.not_to raise_error | |
end | |
it "updates rate limit count for different types" do | |
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response) | |
github.wait_for_rate_limit!(:search) | |
rate_limit_all = github.instance_variable_get(:@rate_limit_all) | |
expect(rate_limit_all[:resources][:search][:remaining]).to eq(29) # 30 - 1 | |
end | |
end | |
describe "#method_missing" do | |
let(:github) { GitHub.new } | |
before do | |
allow(github).to receive(:client).and_return(mock_client) | |
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response) | |
end | |
it "delegates method calls to the Octokit client with appropriate rate limit types" do | |
# Core rate limit | |
allow(mock_client).to receive(:rate_limit).and_return("mocked_response") | |
result = github.rate_limit | |
expect(result).to eq("mocked_response") | |
# Search rate limit | |
allow(mock_client).to receive(:search_users).with("test").and_return("search_result") | |
result = github.search_users("test") | |
expect(result).to eq("search_result") | |
# GraphQL rate limit | |
allow(mock_client).to receive(:post).with("/graphql", { query: "test" }).and_return("graphql_result") | |
result = github.post("/graphql", { query: "test" }) | |
expect(result).to eq("graphql_result") | |
end | |
it "handles POST with various arguments" do | |
allow(mock_client).to receive(:post).with("/some/endpoint", { data: "test" }).and_return("post_result") | |
result = github.post("/some/endpoint", { data: "test" }) | |
expect(result).to eq("post_result") | |
allow(mock_client).to receive(:post).with(nil).and_return("nil_post_result") | |
result = github.post(nil) | |
expect(result).to eq("nil_post_result") | |
end | |
it "handles search_issues with secondary rate limit error" do | |
secondary_rate_limit_error = StandardError.new("You have exceeded a secondary rate limit") | |
allow(mock_client).to receive(:search_issues).with("test").and_raise(secondary_rate_limit_error) | |
allow(github).to receive(:sleep) | |
expect { | |
github.search_issues("test") | |
}.to raise_error(StandardError, /exceeded a secondary rate limit/) | |
expect(mock_logger).to have_received(:warn).with(/GitHub secondary rate limit hit, sleeping for 60 seconds/) | |
expect(github).to have_received(:sleep).with(60) | |
end | |
it "handles search_issues successful call" do | |
allow(mock_client).to receive(:search_issues).with("test").and_return("search_issues_result") | |
result = github.search_issues("test") | |
expect(result).to eq("search_issues_result") | |
end | |
context "retry behavior" do | |
it "retries failed requests" do | |
allow(mock_client).to receive(:user).and_raise(StandardError.new("Network error")).once | |
allow(mock_client).to receive(:user).and_return("user_data") | |
result = github.user | |
expect(result).to eq("user_data") | |
end | |
it "retries with fixed rate (default behavior)" do | |
error_count = 0 | |
allow(mock_client).to receive(:repositories) do | |
error_count += 1 | |
if error_count < 3 | |
raise StandardError.new("Temporary error") | |
else | |
"repos_data" | |
end | |
end | |
allow(github).to receive(:sleep) | |
result = github.repositories | |
expect(result).to eq("repos_data") | |
expect(github).to have_received(:sleep).twice | |
end | |
it "retries with exponential backoff when enabled" do | |
allow(ENV).to receive(:fetch).with("GH_APP_EXPONENTIAL_BACKOFF", "false").and_return("true") | |
github_exponential = GitHub.new | |
allow(github_exponential).to receive(:client).and_return(mock_client) | |
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response) | |
error_count = 0 | |
allow(mock_client).to receive(:organizations) do | |
error_count += 1 | |
error_count < 3 ? raise(StandardError.new("Temporary error")) : "orgs_data" | |
end | |
allow(github_exponential).to receive(:sleep) | |
result = github_exponential.organizations | |
expect(result).to eq("orgs_data") | |
expect(github_exponential).to have_received(:sleep).twice | |
end | |
it "gives up after max retries" do | |
allow(mock_client).to receive(:organizations).and_raise(StandardError.new("Persistent error")) | |
allow(github).to receive(:sleep) | |
expect { | |
github.organizations | |
}.to raise_error(StandardError, "Persistent error") | |
expect(github).to have_received(:sleep).exactly(9).times | |
end | |
it "bypasses retry logic when disable_retry is true for search_issues" do | |
allow(mock_client).to receive(:search_issues).with("test").and_raise(StandardError.new("Network error")) | |
allow(github).to receive(:sleep) | |
expect { | |
github.search_issues("test", disable_retry: true) | |
}.to raise_error(StandardError, "Network error") | |
expect(github).not_to have_received(:sleep) | |
end | |
it "bypasses retry logic when disable_retry is true for other methods" do | |
allow(mock_client).to receive(:user).and_raise(StandardError.new("Network error")) | |
allow(github).to receive(:sleep) | |
expect { | |
github.user(disable_retry: true) | |
}.to raise_error(StandardError, "Network error") | |
expect(github).not_to have_received(:sleep) | |
end | |
end | |
end | |
describe "#respond_to_missing?" do | |
let(:github) { GitHub.new } | |
before do | |
allow(github).to receive(:client).and_return(mock_client) | |
end | |
it "returns true when Octokit client responds to method" do | |
allow(mock_client).to receive(:respond_to?).with(:rate_limit, false).and_return(true) | |
expect(github.respond_to?(:rate_limit)).to be true | |
end | |
it "returns false when Octokit client does not respond to method" do | |
allow(mock_client).to receive(:respond_to?).with(:nonexistent_method, false).and_return(false) | |
expect(github.respond_to?(:nonexistent_method)).to be false | |
end | |
it "includes private methods when specified" do | |
allow(mock_client).to receive(:respond_to?).with(:private_method, true).and_return(true) | |
expect(github.respond_to?(:private_method, true)).to be true | |
end | |
end | |
describe "integration scenarios" do | |
let(:github) { GitHub.new } | |
before do | |
allow(github).to receive(:client).and_return(mock_client) | |
allow(mock_client).to receive(:get).with("rate_limit").and_return(default_rate_limit_response) | |
end | |
it "handles complete workflow: rate limit check, API call, response" do | |
allow(mock_client).to receive(:user).and_return({ login: "testuser" }) | |
result = github.user | |
expect(result).to eq({ login: "testuser" }) | |
expect(mock_client).to have_received(:get).with("rate_limit") | |
expect(mock_client).to have_received(:user) | |
end | |
it "handles authentication failure gracefully" do | |
auth_error = StandardError.new("Authentication failed") | |
allow(mock_client).to receive(:user).and_raise(auth_error) | |
expect { github.user }.to raise_error(StandardError, "Authentication failed") | |
end | |
it "properly initializes retry configuration" do | |
retry_sleep = github.instance_variable_get(:@retry_sleep) | |
retry_tries = github.instance_variable_get(:@retry_tries) | |
retry_exponential_backoff = github.instance_variable_get(:@retry_exponential_backoff) | |
expect(retry_sleep).to eq(3) | |
expect(retry_tries).to eq(10) | |
expect(retry_exponential_backoff).to be false | |
end | |
it "allows enabling exponential backoff" do | |
allow(ENV).to receive(:fetch).with("GH_APP_EXPONENTIAL_BACKOFF", "false").and_return("true") | |
github = GitHub.new | |
retry_exponential_backoff = github.instance_variable_get(:@retry_exponential_backoff) | |
expect(retry_exponential_backoff).to be true | |
end | |
end | |
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
# Here is a snippet of what your spec_helper might look like when including this github.rb code in your project | |
# The key bits to note is really just around '# Globally capture all sleep calls' since the github.rb client | |
# calls out to sleep methods when doing retry logic | |
require "simplecov" | |
require "rspec" | |
require "simplecov-erb" | |
TIME_MOCK = "2025-01-01T00:00:00Z" | |
COV_DIR = File.expand_path("../coverage", File.dirname(__FILE__)) | |
SimpleCov.root File.expand_path("..", File.dirname(__FILE__)) | |
SimpleCov.coverage_dir COV_DIR | |
SimpleCov.formatters = [ | |
SimpleCov::Formatter::HTMLFormatter, | |
SimpleCov::Formatter::ERBFormatter | |
] | |
SimpleCov.minimum_coverage 100 | |
SimpleCov.at_exit do | |
File.write("#{COV_DIR}/total-coverage.txt", SimpleCov.result.covered_percent) | |
SimpleCov.result.format! | |
end | |
SimpleCov.start do | |
add_filter "spec/" | |
add_filter "vendor/gems/" | |
end | |
# Globally capture all sleep calls | |
RSpec.configure do |config| | |
config.before(:each) do | |
fake_time = Time.parse(TIME_MOCK) | |
allow(Time).to receive(:now).and_return(fake_time) | |
allow(Time).to receive(:new).and_return(fake_time) | |
allow(Time).to receive(:iso8601).and_return(fake_time) | |
allow(fake_time).to receive(:utc).and_return(fake_time) | |
allow(fake_time).to receive(:iso8601).and_return(TIME_MOCK) | |
allow(Kernel).to receive(:sleep) | |
allow_any_instance_of(Kernel).to receive(:sleep) | |
allow_any_instance_of(Object).to receive(:sleep) | |
end | |
end |
use this!
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Corresponding examples for implementation in crystal lang can be found here: