fbe 0.48.4 → 0.48.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/actionlint.yml +1 -7
- data/.github/workflows/codecov.yml +2 -2
- data/.github/workflows/markdown-lint.yml +1 -1
- data/.github/workflows/rake.yml +1 -2
- data/.github/workflows/typos.yml +1 -1
- data/.gitignore +2 -0
- data/.rubocop.yml +18 -2
- data/.ruby-version +1 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +32 -32
- data/README.md +2 -2
- data/REUSE.toml +1 -0
- data/assets/bylaws/bad-branch-name-was-punished.fe.liquid +1 -1
- data/fbe.gemspec +1 -1
- data/lib/fbe/award.rb +1 -1
- data/lib/fbe/conclude.rb +16 -9
- data/lib/fbe/consider.rb +5 -1
- data/lib/fbe/delete.rb +7 -1
- data/lib/fbe/delete_one.rb +11 -2
- data/lib/fbe/fake_octokit.rb +1797 -0
- data/lib/fbe/fb.rb +31 -28
- data/lib/fbe/github_graph.rb +28 -19
- data/lib/fbe/if_absent.rb +1 -1
- data/lib/fbe/iterate.rb +42 -24
- data/lib/fbe/just_one.rb +5 -2
- data/lib/fbe/kill_if.rb +1 -0
- data/lib/fbe/middleware/formatter.rb +25 -11
- data/lib/fbe/middleware/rate_limit.rb +96 -48
- data/lib/fbe/middleware/sqlite_store.rb +12 -4
- data/lib/fbe/octo.rb +187 -1944
- data/lib/fbe/over.rb +7 -3
- data/lib/fbe/overwrite.rb +31 -2
- data/lib/fbe/pmp.rb +56 -34
- data/lib/fbe/regularly.rb +3 -3
- data/lib/fbe/repeatedly.rb +2 -2
- data/lib/fbe/sec.rb +4 -5
- data/lib/fbe/tombstone.rb +1 -1
- data/lib/fbe/unmask_repos.rb +1 -2
- data/lib/fbe.rb +1 -1
- metadata +4 -2
data/lib/fbe/fb.rb
CHANGED
|
@@ -31,34 +31,37 @@ def Fbe.fb(fb: $fb, global: $global, options: $options, loog: $loog)
|
|
|
31
31
|
raise(Fbe::Error, 'The $global is not set') if global.nil?
|
|
32
32
|
raise(Fbe::Error, 'The $options is not set') if options.nil?
|
|
33
33
|
raise(Fbe::Error, 'The $loog is not set') if loog.nil?
|
|
34
|
-
global[:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
34
|
+
global[:mutex] ||= Mutex.new
|
|
35
|
+
global[:mutex].synchronize do
|
|
36
|
+
global[:fb] ||=
|
|
37
|
+
begin
|
|
38
|
+
rules = Dir.glob(File.join(File.join(__dir__, '../../rules'), '*.fe')).map { |f| File.read(f) }
|
|
39
|
+
fbe = Factbase::Rules.new(
|
|
40
|
+
fb,
|
|
41
|
+
"(and \n#{rules.join("\n")}\n)",
|
|
42
|
+
uid: '_id'
|
|
43
|
+
)
|
|
44
|
+
fbe =
|
|
45
|
+
Factbase::Pre.new(fbe) do |f, fbt|
|
|
46
|
+
max = fbt.query('(max _id)').one
|
|
47
|
+
f._id = (max.nil? ? 0 : max) + 1
|
|
48
|
+
f._time = Time.now
|
|
49
|
+
f._version = [Factbase::VERSION, Judges::VERSION, options.action_version].compact.join('/')
|
|
50
|
+
f._job = Integer(options.job_id.to_s, 10) if options.job_id
|
|
51
|
+
end
|
|
52
|
+
Factbase::Impatient.new(
|
|
53
|
+
Factbase::Logged.new(
|
|
54
|
+
Factbase::SyncFactbase.new(
|
|
55
|
+
Factbase::IndexedFactbase.new(
|
|
56
|
+
Factbase::CachedFactbase.new(
|
|
57
|
+
fbe
|
|
58
|
+
)
|
|
56
59
|
)
|
|
57
|
-
)
|
|
60
|
+
),
|
|
61
|
+
loog
|
|
58
62
|
),
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
end
|
|
63
|
+
timeout: 60
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
64
67
|
end
|
data/lib/fbe/github_graph.rb
CHANGED
|
@@ -15,13 +15,16 @@ require_relative '../fbe'
|
|
|
15
15
|
# @param [Loog] loog Logging facility
|
|
16
16
|
# @return [Fbe::Graph] The instance of the class
|
|
17
17
|
def Fbe.github_graph(options: $options, global: $global, loog: $loog)
|
|
18
|
-
global[:
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
global[:mutex] ||= Mutex.new
|
|
19
|
+
global[:mutex].synchronize do
|
|
20
|
+
global[:github_graph] ||=
|
|
21
|
+
if options.testing.nil?
|
|
22
|
+
Fbe::Graph.new(token: options.github_token || ENV.fetch('GITHUB_TOKEN', nil))
|
|
23
|
+
else
|
|
24
|
+
loog.debug('The connection to GitHub GraphQL API is mocked')
|
|
25
|
+
Fbe::Graph::Fake.new
|
|
26
|
+
end
|
|
27
|
+
end
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
# A client to GitHub GraphQL.
|
|
@@ -45,6 +48,9 @@ class Fbe::Graph # rubocop:disable Metrics/ClassLength
|
|
|
45
48
|
# puts result.viewer.login #=> "octocat"
|
|
46
49
|
def query(qry)
|
|
47
50
|
result = client.query(client.parse(qry))
|
|
51
|
+
unless result.errors.empty?
|
|
52
|
+
raise(Fbe::Error, "GitHub GraphQL query failed: #{result.errors.messages.values.flatten.join('; ')}")
|
|
53
|
+
end
|
|
48
54
|
result.data
|
|
49
55
|
end
|
|
50
56
|
|
|
@@ -287,11 +293,12 @@ class Fbe::Graph # rubocop:disable Metrics/ClassLength
|
|
|
287
293
|
# cursor = json['next_cursor']
|
|
288
294
|
# end
|
|
289
295
|
def pull_requests_with_reviews(owner, name, since, cursor: nil)
|
|
296
|
+
after = "after: \"#{cursor}\", " unless cursor.nil?
|
|
290
297
|
result = query(
|
|
291
298
|
<<~GRAPHQL
|
|
292
299
|
{
|
|
293
300
|
repository(owner: "#{owner}", name: "#{name}") {
|
|
294
|
-
pullRequests(first: 100
|
|
301
|
+
pullRequests(#{after}first: 100) {
|
|
295
302
|
nodes {
|
|
296
303
|
id
|
|
297
304
|
number
|
|
@@ -310,16 +317,16 @@ class Fbe::Graph # rubocop:disable Metrics/ClassLength
|
|
|
310
317
|
}
|
|
311
318
|
GRAPHQL
|
|
312
319
|
).to_h
|
|
320
|
+
nodes = result.dig('repository', 'pullRequests', 'nodes')
|
|
321
|
+
raise(Fbe::Error, "Repository '#{owner}/#{name}' not found") if nodes.nil?
|
|
313
322
|
{
|
|
314
|
-
'pulls_with_reviews' =>
|
|
315
|
-
.dig('
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
end,
|
|
323
|
+
'pulls_with_reviews' => nodes.filter_map do |pull|
|
|
324
|
+
next if pull.dig('timelineItems', 'nodes').empty?
|
|
325
|
+
{
|
|
326
|
+
'id' => pull['id'],
|
|
327
|
+
'number' => pull['number']
|
|
328
|
+
}
|
|
329
|
+
end,
|
|
323
330
|
'has_next_page' => result.dig('repository', 'pullRequests', 'pageInfo', 'hasNextPage'),
|
|
324
331
|
'next_cursor' => result.dig('repository', 'pullRequests', 'pageInfo', 'endCursor')
|
|
325
332
|
}
|
|
@@ -349,11 +356,12 @@ class Fbe::Graph # rubocop:disable Metrics/ClassLength
|
|
|
349
356
|
def pull_request_reviews(owner, name, pulls: [])
|
|
350
357
|
requests =
|
|
351
358
|
pulls.map do |number, cursor|
|
|
359
|
+
after = "after: \"#{cursor}\", " unless cursor.nil?
|
|
352
360
|
<<~GRAPHQL
|
|
353
361
|
pr_#{number}: pullRequest(number: #{number}) {
|
|
354
362
|
id
|
|
355
363
|
number
|
|
356
|
-
reviews(first: 100
|
|
364
|
+
reviews(#{after}first: 100) {
|
|
357
365
|
nodes {
|
|
358
366
|
id
|
|
359
367
|
submittedAt
|
|
@@ -485,11 +493,12 @@ class Fbe::Graph # rubocop:disable Metrics/ClassLength
|
|
|
485
493
|
total = 0
|
|
486
494
|
cursor = nil
|
|
487
495
|
loop do
|
|
496
|
+
after = "after: \"#{cursor}\", " unless cursor.nil?
|
|
488
497
|
result = query(
|
|
489
498
|
<<~GRAPHQL
|
|
490
499
|
{
|
|
491
500
|
repository(owner: "#{owner}", name: "#{name}") {
|
|
492
|
-
releases(first: 25,
|
|
501
|
+
releases(#{after}first: 25, orderBy: { field: CREATED_AT, direction: DESC }) {
|
|
493
502
|
nodes {
|
|
494
503
|
isDraft
|
|
495
504
|
publishedAt
|
data/lib/fbe/if_absent.rb
CHANGED
|
@@ -62,7 +62,7 @@ def Fbe.if_absent(fb: Fbe.fb, always: false)
|
|
|
62
62
|
end
|
|
63
63
|
end
|
|
64
64
|
yield(f)
|
|
65
|
-
q = attrs.except(
|
|
65
|
+
q = attrs.except(:_id, :_time, :_version).map do |k, v|
|
|
66
66
|
vv = v.to_s
|
|
67
67
|
if v.is_a?(String)
|
|
68
68
|
vv = "'#{vv.gsub('"', '\\\\"').gsub("'", "\\\\'")}'"
|
data/lib/fbe/iterate.rb
CHANGED
|
@@ -105,28 +105,27 @@ class Fbe::Iterate
|
|
|
105
105
|
@timeout = true
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
-
# Makes the iterator
|
|
108
|
+
# Makes the iterator unaware of GitHub API quota limits.
|
|
109
109
|
#
|
|
110
|
-
# When
|
|
111
|
-
# each repository and gracefully stop when the quota is exhausted.
|
|
112
|
-
# This prevents API errors and allows for resuming later.
|
|
110
|
+
# When disabled, the iterator will not check quota status before processing
|
|
111
|
+
# each repository and will not gracefully stop when the quota is exhausted.
|
|
113
112
|
#
|
|
114
113
|
# @return [nil] Nothing is returned
|
|
115
|
-
# @example
|
|
116
|
-
# iterator.
|
|
117
|
-
# iterator.over { |repo, item| ... } # Will stop
|
|
114
|
+
# @example Disable quota awareness
|
|
115
|
+
# iterator.quota_unaware
|
|
116
|
+
# iterator.over { |repo, item| ... } # Will not stop on quota exhaustion
|
|
118
117
|
def quota_unaware
|
|
119
118
|
@quota = false
|
|
120
119
|
end
|
|
121
120
|
|
|
122
|
-
# Makes the iterator
|
|
121
|
+
# Makes the iterator unaware of lifetime limits.
|
|
123
122
|
#
|
|
124
123
|
# @return [nil] Nothing is returned
|
|
125
124
|
def lifetime_unaware
|
|
126
125
|
@lifetime = false
|
|
127
126
|
end
|
|
128
127
|
|
|
129
|
-
# Makes the iterator
|
|
128
|
+
# Makes the iterator unaware of timeout limits.
|
|
130
129
|
#
|
|
131
130
|
# @return [nil] Nothing is returned
|
|
132
131
|
def timeout_unaware
|
|
@@ -149,6 +148,24 @@ class Fbe::Iterate
|
|
|
149
148
|
@repeats = repeats
|
|
150
149
|
end
|
|
151
150
|
|
|
151
|
+
# Sets the initial value for iteration tracking.
|
|
152
|
+
#
|
|
153
|
+
# This value is used as the starting point when no marker exists in the
|
|
154
|
+
# factbase for the current iteration label. It is also used as the
|
|
155
|
+
# restart value when a query returns nil, causing the iteration for
|
|
156
|
+
# that repository to begin again from this value.
|
|
157
|
+
#
|
|
158
|
+
# @param [Integer] value The initial value for iteration tracking
|
|
159
|
+
# @return [nil] Nothing is returned
|
|
160
|
+
# @raise [RuntimeError] If value is nil or not an Integer
|
|
161
|
+
# @example Start iteration from issue number 100
|
|
162
|
+
# iterator.since!(100)
|
|
163
|
+
def since!(value)
|
|
164
|
+
raise(Fbe::Error, 'Cannot set "since" to nil') if value.nil?
|
|
165
|
+
raise(Fbe::Error, 'The "since" must be an Integer') unless value.is_a?(Integer)
|
|
166
|
+
@since = value
|
|
167
|
+
end
|
|
168
|
+
|
|
152
169
|
# Sets the query to execute for each iteration.
|
|
153
170
|
#
|
|
154
171
|
# The query can use two special variables:
|
|
@@ -255,19 +272,14 @@ class Fbe::Iterate
|
|
|
255
272
|
).map { |n| oct.repo_id_by_name(n) }
|
|
256
273
|
started = Time.now
|
|
257
274
|
restarted = []
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
(eq repository #{repo}))
|
|
267
|
-
(first #{@label}))"
|
|
268
|
-
).one&.first || @since
|
|
269
|
-
]
|
|
270
|
-
end
|
|
275
|
+
markers = {}
|
|
276
|
+
@fb.query("(and (eq what 'iterate') (eq where 'github'))").each do |f|
|
|
277
|
+
r = f['repository']&.first
|
|
278
|
+
next if r.nil? || markers.key?(r)
|
|
279
|
+
v = f[@label.to_s]&.first
|
|
280
|
+
markers[r] = v unless v.nil?
|
|
281
|
+
end
|
|
282
|
+
before = repos.to_h { |repo| [repo, markers[repo] || @since] }
|
|
271
283
|
starts = before.dup
|
|
272
284
|
values = {}
|
|
273
285
|
loop do # rubocop:disable Metrics/BlockLength
|
|
@@ -313,7 +325,13 @@ class Fbe::Iterate
|
|
|
313
325
|
@since
|
|
314
326
|
else
|
|
315
327
|
@loog.debug("Next is ##{nxt}, starting from it")
|
|
316
|
-
|
|
328
|
+
begin
|
|
329
|
+
yield(repo, nxt)
|
|
330
|
+
rescue Fbe::OffQuota
|
|
331
|
+
raise
|
|
332
|
+
rescue StandardError => e
|
|
333
|
+
raise(e.class, "Failure in repository ##{repo} at ##{nxt}: #{e.message}")
|
|
334
|
+
end
|
|
317
335
|
end
|
|
318
336
|
unless before[repo].is_a?(Integer)
|
|
319
337
|
raise(Fbe::Error, "Iterator must return an Integer, but #{before[repo].class} was returned")
|
|
@@ -337,7 +355,7 @@ class Fbe::Iterate
|
|
|
337
355
|
defined?(before) && !before.nil? &&
|
|
338
356
|
defined?(starts) && !starts.nil?
|
|
339
357
|
repos.each do |repo|
|
|
340
|
-
next if before[repo] == starts[repo]
|
|
358
|
+
next if before[repo] == starts[repo]
|
|
341
359
|
f =
|
|
342
360
|
Fbe.if_absent(fb: @fb, always: true) do |n|
|
|
343
361
|
n.what = 'iterate'
|
data/lib/fbe/just_one.rb
CHANGED
|
@@ -38,13 +38,16 @@ def Fbe.just_one(fb: Fbe.fb)
|
|
|
38
38
|
others(map: attrs) do |*args|
|
|
39
39
|
k = args[0]
|
|
40
40
|
if k.end_with?('=')
|
|
41
|
-
|
|
41
|
+
v = args[1]
|
|
42
|
+
raise(Fbe::Error, "Can't set #{k[0..-2]} to nil") if v.nil?
|
|
43
|
+
raise(Fbe::Error, "Can't set #{k[0..-2]} to empty string") if v.is_a?(String) && v.empty?
|
|
44
|
+
@map[k[0..-2].to_sym] = v
|
|
42
45
|
else
|
|
43
46
|
@map[k.to_sym]
|
|
44
47
|
end
|
|
45
48
|
end
|
|
46
49
|
yield(f)
|
|
47
|
-
q = attrs.except(
|
|
50
|
+
q = attrs.except(:_id, :_time, :_version).map do |k, v|
|
|
48
51
|
vv = v.to_s
|
|
49
52
|
if v.is_a?(String)
|
|
50
53
|
vv = "'#{vv.gsub('"', '\\\\"').gsub("'", "\\\\'")}'"
|
data/lib/fbe/kill_if.rb
CHANGED
|
@@ -39,6 +39,19 @@ require_relative '../../fbe/middleware'
|
|
|
39
39
|
# Copyright:: Copyright (c) 2024-2026 Zerocracy
|
|
40
40
|
# License:: MIT
|
|
41
41
|
class Fbe::Middleware::Formatter < Faraday::Logging::Formatter
|
|
42
|
+
# Registers a filter that masks the credential portion of an
|
|
43
|
+
# `Authorization` header so live tokens never reach the log.
|
|
44
|
+
# Matches any auth scheme: `Authorization: "Bearer xxx"`,
|
|
45
|
+
# `Authorization: "Basic yyy"`, etc. The scheme is preserved,
|
|
46
|
+
# the credential is replaced with `[FILTERED]`.
|
|
47
|
+
#
|
|
48
|
+
# @param [Logger] logger The Faraday-supplied logger
|
|
49
|
+
# @param [Hash] options Faraday formatter options
|
|
50
|
+
def initialize(logger:, options:)
|
|
51
|
+
super
|
|
52
|
+
filter(/(Authorization:\s*"?\s*\S+\s+)[^"\s]+/i, '\1[FILTERED]')
|
|
53
|
+
end
|
|
54
|
+
|
|
42
55
|
# Captures HTTP request details for later use in error logging.
|
|
43
56
|
#
|
|
44
57
|
# @param [Hash] http Request data including method, url, headers, and body
|
|
@@ -55,17 +68,18 @@ class Fbe::Middleware::Formatter < Faraday::Logging::Formatter
|
|
|
55
68
|
# @note Special handling for 403 JSON responses to show compact error message
|
|
56
69
|
def response(http) # rubocop:disable Metrics/AbcSize
|
|
57
70
|
return if http.status < 400
|
|
58
|
-
if http.status == 403 && http.response_headers['content-type']
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
'
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
if http.status == 403 && http.response_headers['content-type']&.start_with?('application/json')
|
|
72
|
+
msg =
|
|
73
|
+
begin
|
|
74
|
+
parsed = JSON.parse(http.response_body)
|
|
75
|
+
parsed['message'] if parsed.is_a?(Hash)
|
|
76
|
+
rescue JSON::ParserError, TypeError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
unless msg.nil?
|
|
80
|
+
warn(["#{@req.method.upcase} #{apply_filters(@req.url.to_s)}", '->', http.status, '/', msg].join(' '))
|
|
81
|
+
return
|
|
82
|
+
end
|
|
69
83
|
end
|
|
70
84
|
if http.status >= 500 && http.response_headers['content-type']&.start_with?('text')
|
|
71
85
|
error(
|
|
@@ -27,11 +27,14 @@ class Fbe::Middleware::RateLimit < Faraday::Middleware
|
|
|
27
27
|
# Initializes the rate limit middleware.
|
|
28
28
|
#
|
|
29
29
|
# @param [Object] app The next middleware in the stack
|
|
30
|
-
def initialize(app)
|
|
31
|
-
super
|
|
30
|
+
def initialize(app, tracker = nil)
|
|
31
|
+
super(app)
|
|
32
32
|
@cached = nil
|
|
33
33
|
@remaining = nil
|
|
34
|
+
@searchleft = nil
|
|
34
35
|
@counter = 0
|
|
36
|
+
@lock = Mutex.new
|
|
37
|
+
tracker[:rate_limit] = self unless tracker.nil?
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
# Processes the HTTP request and handles rate limit caching.
|
|
@@ -40,10 +43,22 @@ class Fbe::Middleware::RateLimit < Faraday::Middleware
|
|
|
40
43
|
# @return [Faraday::Response] The response from cache or the next middleware
|
|
41
44
|
def call(env)
|
|
42
45
|
if env.url.path == '/rate_limit'
|
|
43
|
-
handle_rate_limit_request(env)
|
|
46
|
+
@lock.synchronize { handle_rate_limit_request(env) }
|
|
44
47
|
else
|
|
45
|
-
track_request
|
|
46
|
-
@app.call(env)
|
|
48
|
+
@lock.synchronize { track_request(env.url.path) }
|
|
49
|
+
@app.call(env).on_complete do |response_env|
|
|
50
|
+
@lock.synchronize { sync(response_env, env.url.path) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns the remaining requests count tracked by this middleware.
|
|
56
|
+
#
|
|
57
|
+
# @param [Symbol] resource The GitHub API resource (:core or :search)
|
|
58
|
+
# @return [Integer, nil] The remaining count, or nil when the resource is absent
|
|
59
|
+
def remaining(resource = :core)
|
|
60
|
+
@lock.synchronize do
|
|
61
|
+
resource == :search ? @searchleft : @remaining
|
|
47
62
|
end
|
|
48
63
|
end
|
|
49
64
|
|
|
@@ -56,22 +71,48 @@ class Fbe::Middleware::RateLimit < Faraday::Middleware
|
|
|
56
71
|
def handle_rate_limit_request(env)
|
|
57
72
|
if @cached.nil? || @counter >= 100
|
|
58
73
|
response = @app.call(env)
|
|
59
|
-
@cached = response
|
|
74
|
+
@cached = response
|
|
60
75
|
@remaining = extract_remaining_count(response)
|
|
76
|
+
@searchleft = extract_search_remaining_count(response)
|
|
61
77
|
@counter = 0
|
|
62
78
|
response
|
|
63
79
|
else
|
|
64
|
-
|
|
65
|
-
update_remaining_count(response)
|
|
66
|
-
Faraday::Response.new(response_env(env, response))
|
|
80
|
+
Faraday::Response.new(response_env(env, @cached))
|
|
67
81
|
end
|
|
68
82
|
end
|
|
69
83
|
|
|
70
84
|
# Tracks non-rate_limit requests and decrements counter.
|
|
71
|
-
def track_request
|
|
72
|
-
return if @remaining.nil?
|
|
73
|
-
@remaining -= 1 if @remaining.positive?
|
|
85
|
+
def track_request(path = nil)
|
|
74
86
|
@counter += 1
|
|
87
|
+
if path&.start_with?('/search/')
|
|
88
|
+
@searchleft -= 1 if @searchleft&.positive?
|
|
89
|
+
elsif @remaining&.positive?
|
|
90
|
+
@remaining -= 1
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Syncs the internal remaining count from a real API response header.
|
|
95
|
+
#
|
|
96
|
+
# When the response was served by Faraday::HttpCache from cache
|
|
97
|
+
# (indicated by +http_cache_trace+ containing +:fresh+), the
|
|
98
|
+
# +x-ratelimit-remaining+ header is stale, so we keep our
|
|
99
|
+
# decremented count. When the API was actually contacted,
|
|
100
|
+
# we seed unknown counters from headers, but avoid raising
|
|
101
|
+
# a counter already decremented by this middleware.
|
|
102
|
+
#
|
|
103
|
+
# @param [Faraday::Env] response_env The response environment
|
|
104
|
+
def sync(response_env, path = nil)
|
|
105
|
+
return if response_env[:http_cache_trace]&.include?(:fresh)
|
|
106
|
+
headers = response_env.response_headers
|
|
107
|
+
return unless headers
|
|
108
|
+
remaining = headers['x-ratelimit-remaining']
|
|
109
|
+
return unless remaining
|
|
110
|
+
count = Integer(remaining)
|
|
111
|
+
if path&.start_with?('/search/')
|
|
112
|
+
@searchleft = @searchleft.nil? ? count : [@searchleft, count].min
|
|
113
|
+
else
|
|
114
|
+
@remaining = @remaining.nil? ? count : [@remaining, count].min
|
|
115
|
+
end
|
|
75
116
|
end
|
|
76
117
|
|
|
77
118
|
# Extracts the remaining count from the response body.
|
|
@@ -80,52 +121,59 @@ class Fbe::Middleware::RateLimit < Faraday::Middleware
|
|
|
80
121
|
# @return [Integer] The remaining requests count
|
|
81
122
|
def extract_remaining_count(response)
|
|
82
123
|
body = response.body
|
|
83
|
-
if body.is_a?(String)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return 0
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
return 0 unless body.is_a?(Hash)
|
|
91
|
-
body.dig('rate', 'remaining') || 0
|
|
124
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
125
|
+
value = body.dig('rate', 'remaining') if body.is_a?(Hash)
|
|
126
|
+
value ||= response.headers['x-ratelimit-remaining']
|
|
127
|
+
Integer(value || 0)
|
|
92
128
|
end
|
|
93
129
|
|
|
94
|
-
#
|
|
130
|
+
# Extracts the search-resource remaining count from the response body.
|
|
95
131
|
#
|
|
96
|
-
# @param [Faraday::Response] response The
|
|
97
|
-
|
|
132
|
+
# @param [Faraday::Response] response The API response
|
|
133
|
+
# @return [Integer, nil] The remaining search-API requests count
|
|
134
|
+
def extract_search_remaining_count(response)
|
|
98
135
|
body = response.body
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
136
|
+
body = JSON.parse(body) if body.is_a?(String)
|
|
137
|
+
return nil unless body.is_a?(Hash)
|
|
138
|
+
value = body.dig('resources', 'search', 'remaining')
|
|
139
|
+
value.nil? ? nil : Integer(value)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Builds a fresh body with the current remaining counts written in,
|
|
143
|
+
# without mutating the cached response. Uses a JSON round-trip for
|
|
144
|
+
# the deep copy so we only handle JSON-shaped data.
|
|
145
|
+
#
|
|
146
|
+
# @param [Object] original The cached response body (Hash or JSON String)
|
|
147
|
+
# @return [Object] A new body of the same type with remaining counts updated
|
|
148
|
+
def patched_body(original)
|
|
149
|
+
stringed = original.is_a?(String)
|
|
150
|
+
body =
|
|
151
|
+
if stringed
|
|
152
|
+
JSON.parse(original)
|
|
153
|
+
elsif original.is_a?(Hash)
|
|
154
|
+
JSON.parse(original.to_json)
|
|
155
|
+
else
|
|
156
|
+
return original
|
|
105
157
|
end
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
body
|
|
109
|
-
|
|
110
|
-
response.instance_variable_set(:@body, body.to_json)
|
|
158
|
+
body['rate']['remaining'] = @remaining if body['rate'] && !@remaining.nil?
|
|
159
|
+
body.dig('resources', 'core')&.[]=('remaining', @remaining) unless @remaining.nil?
|
|
160
|
+
body.dig('resources', 'search')&.[]=('remaining', @searchleft) unless @searchleft.nil?
|
|
161
|
+
stringed ? body.to_json : body
|
|
111
162
|
end
|
|
112
163
|
|
|
113
|
-
#
|
|
164
|
+
# Builds a response environment that mirrors the cached response,
|
|
165
|
+
# preserving Faraday::Env invariants by dup-ing the original env
|
|
166
|
+
# and only overriding body and rate-limit headers.
|
|
114
167
|
#
|
|
115
168
|
# @param [Faraday::Env] env The original request environment
|
|
116
169
|
# @param [Faraday::Response] response The cached response
|
|
117
|
-
# @return [
|
|
170
|
+
# @return [Faraday::Env] Response env ready to wrap in Faraday::Response
|
|
118
171
|
def response_env(env, response)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
request_body: env.request_body,
|
|
126
|
-
status: response.status,
|
|
127
|
-
response_headers: headers,
|
|
128
|
-
body: response.body
|
|
129
|
-
}
|
|
172
|
+
served = response.env.dup
|
|
173
|
+
served.request_headers = env.request_headers
|
|
174
|
+
served.body = patched_body(response.body)
|
|
175
|
+
served.response_headers = response.headers.dup
|
|
176
|
+
served.response_headers['x-ratelimit-remaining'] = @remaining.to_s unless @remaining.nil?
|
|
177
|
+
served
|
|
130
178
|
end
|
|
131
179
|
end
|
|
@@ -86,8 +86,8 @@ class Fbe::Middleware::SqliteStore
|
|
|
86
86
|
return unless value
|
|
87
87
|
begin
|
|
88
88
|
JSON.parse(Zlib::Inflate.inflate(value))
|
|
89
|
-
rescue Zlib::Error => e
|
|
90
|
-
@loog.info("Failed to
|
|
89
|
+
rescue Zlib::Error, JSON::ParserError, TypeError => e
|
|
90
|
+
@loog.info("Failed to decode cached value for key: #{key}, error: #{e.message}, the key will be deleted")
|
|
91
91
|
delete(key)
|
|
92
92
|
end
|
|
93
93
|
end
|
|
@@ -106,7 +106,7 @@ class Fbe::Middleware::SqliteStore
|
|
|
106
106
|
# @return [nil]
|
|
107
107
|
# @note Values larger than 10KB are not cached
|
|
108
108
|
# @note Non-GET requests and URLs with query parameters are not cached
|
|
109
|
-
def write(key, value) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
109
|
+
def write(key, value) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
|
|
110
110
|
return if value.is_a?(Array) && value.any? do |vv|
|
|
111
111
|
req = JSON.parse(vv[0])
|
|
112
112
|
req['method'] != 'get'
|
|
@@ -129,6 +129,8 @@ class Fbe::Middleware::SqliteStore
|
|
|
129
129
|
end
|
|
130
130
|
end
|
|
131
131
|
resp['response_headers']['cache-control'] = control
|
|
132
|
+
value = value.dup
|
|
133
|
+
value[0] = value[0].dup
|
|
132
134
|
value[0][1] = JSON.dump(resp)
|
|
133
135
|
end
|
|
134
136
|
end
|
|
@@ -159,6 +161,13 @@ class Fbe::Middleware::SqliteStore
|
|
|
159
161
|
perform { _1.execute('SELECT key, value FROM cache') }
|
|
160
162
|
end
|
|
161
163
|
|
|
164
|
+
# Close the database connection explicitly.
|
|
165
|
+
# @return [nil]
|
|
166
|
+
def close
|
|
167
|
+
@db&.close
|
|
168
|
+
@db = nil
|
|
169
|
+
end
|
|
170
|
+
|
|
162
171
|
private
|
|
163
172
|
|
|
164
173
|
def perform(&) # rubocop:disable Metrics/AbcSize
|
|
@@ -248,7 +257,6 @@ class Fbe::Middleware::SqliteStore
|
|
|
248
257
|
"new file size: #{Filesize.from(File.size(@path).to_s).pretty} bytes"
|
|
249
258
|
)
|
|
250
259
|
end
|
|
251
|
-
at_exit { @db&.close }
|
|
252
260
|
end
|
|
253
261
|
@db.transaction(&)
|
|
254
262
|
end
|