Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ jobs:
with:
bundler-cache: true

- name: Restore API cache
if: |
(matrix.test_type == 'collections' && steps.collections.outputs.changed) ||
(matrix.test_type == 'all' && steps.all.outputs.changed)
uses: actions/cache@v4
with:
path: .api-cache.json
key: api-cache-${{ matrix.test_type }}-${{ github.run_id }}
restore-keys: |
api-cache-${{ matrix.test_type }}-

- name: Build and test with Rake
if: |
(matrix.test_type == 'topics' && steps.topics.outputs.changed) ||
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ vendor
.bundle
.idea
.tool-versions
.api-cache.json
86 changes: 86 additions & 0 deletions test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,89 @@ def add_message(type, file, line_number, message)
client.messages << "::#{type} file=#{file},line=#{line_number}::#{message}"
end

CACHE_FILE = File.expand_path("../.api-cache.json", __dir__)
CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours

def load_api_cache!
return unless File.exist?(CACHE_FILE)

data = JSON.parse(File.read(CACHE_FILE))
now = Time.now.to_i
ttl = CACHE_TTL_SECONDS

if data["repos"]
data["repos"].each do |key, entry|
cached_at = entry["cached_at"]
next unless cached_at
next if now - cached_at.to_i > ttl

result = entry["value"]
# Reconstruct a minimal object that responds to .full_name
cached = if result.nil?
nil
else
next unless result["full_name"]

Struct.new(:full_name).new(result["full_name"])
end
NewOctokit.class_variable_get(:@@repos)[key] = cached
end
end

if data["users"]
data["users"].each do |key, entry|
cached_at = entry["cached_at"]
next unless cached_at
next if now - cached_at.to_i > ttl

result = entry["value"]
cached = if result.nil?
nil
else
next unless result["login"]

Struct.new(:login).new(result["login"])
end
NewOctokit.class_variable_get(:@@users)[key] = cached
end
end
rescue JSON::ParserError, StandardError => e
warn "Failed to load API cache: #{e.message}"
end

def save_api_cache!
now = Time.now.to_i
repos_data = {}
users_data = {}

NewOctokit.class_variable_get(:@@repos).each do |key, value|
next if key == :skip_requests
next if value == true

repos_data[key.to_s] = {
"cached_at" => now,
"value" => value.nil? ? nil : { "full_name" => value.respond_to?(:full_name) ? value.full_name : value.to_s },
Comment on lines +332 to +334
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a cached entry has value.nil?, it's stored as nil in the JSON (line 325), and when loaded back (line 289-290), it's correctly reconstructed as nil. However, if a repository truly doesn't exist (NotFound), the code stores nil in the cache (line 92 in NewOctokit#repository), but this is indistinguishable from rate-limited entries where repos[item] = true is stored (line 90). When saving the cache, a rate-limited entry with value = true will be converted to { "full_name" => "true" } (line 325), which may cause issues when loading since true doesn't respond to .full_name correctly. Consider handling the true sentinel value explicitly.

This issue also appears on line 332 of the same file.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — added next if value == true before serializing both repos and users entries, so rate-limit sentinel values are skipped and won't produce corrupted cache data like { "full_name" => "true" }.

}
end

NewOctokit.class_variable_get(:@@users).each do |key, value|
next if key == :skip_requests
next if value == true

users_data[key.to_s] = {
"cached_at" => now,
"value" => value.nil? ? nil : { "login" => value.respond_to?(:login) ? value.login : value.to_s },
}
end

File.write(CACHE_FILE, JSON.pretty_generate({ "repos" => repos_data, "users" => users_data }))
rescue StandardError => e
warn "Failed to save API cache: #{e.message}"
end

# Load cached API results at startup
load_api_cache!

Minitest.after_run do
warn "Repo checks were rate limited during this CI run" if NewOctokit.repos_skipped?
warn "User checks were rate limited during this CI run" if NewOctokit.users_skipped?
Expand All @@ -279,4 +362,7 @@ def add_message(type, file, line_number, message)
NewOctokit.messages.each do |message|
puts message
end

# Persist cache for next CI run
save_api_cache!
end
Loading