fbe 0.48.5 → 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.
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[:fb] ||=
35
- begin
36
- rules = Dir.glob(File.join(File.join(__dir__, '../../rules'), '*.fe')).map { |f| File.read(f) }
37
- fbe = Factbase::Rules.new(
38
- fb,
39
- "(and \n#{rules.join("\n")}\n)",
40
- uid: '_id'
41
- )
42
- fbe =
43
- Factbase::Pre.new(fbe) do |f, fbt|
44
- max = fbt.query('(max _id)').one
45
- f._id = (max.nil? ? 0 : max) + 1
46
- f._time = Time.now
47
- f._version = [Factbase::VERSION, Judges::VERSION, options.action_version].compact.join('/')
48
- f._job = Integer(options.job_id.to_s, 10) if options.job_id
49
- end
50
- Factbase::Impatient.new(
51
- Factbase::Logged.new(
52
- Factbase::SyncFactbase.new(
53
- Factbase::IndexedFactbase.new(
54
- Factbase::CachedFactbase.new(
55
- fbe
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
- loog
60
- ),
61
- timeout: 60
62
- )
63
- end
63
+ timeout: 60
64
+ )
65
+ end
66
+ end
64
67
  end
@@ -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[:github_graph] ||=
19
- if options.testing.nil?
20
- Fbe::Graph.new(token: options.github_token || ENV.fetch('GITHUB_TOKEN', nil))
21
- else
22
- loog.debug('The connection to GitHub GraphQL API is mocked')
23
- Fbe::Graph::Fake.new
24
- end
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
 
@@ -487,11 +493,12 @@ class Fbe::Graph # rubocop:disable Metrics/ClassLength
487
493
  total = 0
488
494
  cursor = nil
489
495
  loop do
496
+ after = "after: \"#{cursor}\", " unless cursor.nil?
490
497
  result = query(
491
498
  <<~GRAPHQL
492
499
  {
493
500
  repository(owner: "#{owner}", name: "#{name}") {
494
- releases(first: 25, after: "#{cursor}", orderBy: { field: CREATED_AT, direction: DESC }) {
501
+ releases(#{after}first: 25, orderBy: { field: CREATED_AT, direction: DESC }) {
495
502
  nodes {
496
503
  isDraft
497
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('_id', '_time', '_version').map do |k, v|
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
@@ -148,6 +148,24 @@ class Fbe::Iterate
148
148
  @repeats = repeats
149
149
  end
150
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
+
151
169
  # Sets the query to execute for each iteration.
152
170
  #
153
171
  # The query can use two special variables:
@@ -254,19 +272,14 @@ class Fbe::Iterate
254
272
  ).map { |n| oct.repo_id_by_name(n) }
255
273
  started = Time.now
256
274
  restarted = []
257
- before =
258
- repos.to_h do |repo|
259
- [
260
- repo,
261
- @fb.query(
262
- "(agg (and
263
- (eq what 'iterate')
264
- (eq where 'github')
265
- (eq repository #{repo}))
266
- (first #{@label}))"
267
- ).one&.first || @since
268
- ]
269
- 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] }
270
283
  starts = before.dup
271
284
  values = {}
272
285
  loop do # rubocop:disable Metrics/BlockLength
@@ -342,7 +355,7 @@ class Fbe::Iterate
342
355
  defined?(before) && !before.nil? &&
343
356
  defined?(starts) && !starts.nil?
344
357
  repos.each do |repo|
345
- next if before[repo] == starts[repo] || before[repo] == @since
358
+ next if before[repo] == starts[repo]
346
359
  f =
347
360
  Fbe.if_absent(fb: @fb, always: true) do |n|
348
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
- @map[k[0..-2].to_sym] = args[1]
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('_id', '_time', '_version').map do |k, v|
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
@@ -19,5 +19,6 @@ def Fbe.kill_if(facts, fb: Fbe.fb, fid: '_id')
19
19
  end
20
20
  ids << f[fid].first
21
21
  end
22
+ return 0 if ids.empty?
22
23
  fb.query("(or #{ids.map { |id| "(eq #{fid} #{id})" }.join})").delete!
23
24
  end
@@ -68,17 +68,18 @@ class Fbe::Middleware::Formatter < Faraday::Logging::Formatter
68
68
  # @note Special handling for 403 JSON responses to show compact error message
69
69
  def response(http) # rubocop:disable Metrics/AbcSize
70
70
  return if http.status < 400
71
- if http.status == 403 && http.response_headers['content-type'].start_with?('application/json')
72
- warn(
73
- [
74
- "#{@req.method.upcase} #{apply_filters(@req.url.to_s)}",
75
- '->',
76
- http.status,
77
- '/',
78
- JSON.parse(http.response_body)['message']
79
- ].join(' ')
80
- )
81
- return
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
82
83
  end
83
84
  if http.status >= 500 && http.response_headers['content-type']&.start_with?('text')
84
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.dup
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
- response = @cached.dup
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
- begin
85
- body = JSON.parse(body)
86
- rescue JSON::ParserError
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
- # Updates the remaining count in the response body.
130
+ # Extracts the search-resource remaining count from the response body.
95
131
  #
96
- # @param [Faraday::Response] response The cached response to update
97
- def update_remaining_count(response)
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
- stringed = body.is_a?(String)
100
- if stringed
101
- begin
102
- body = JSON.parse(body)
103
- rescue JSON::ParserError
104
- return
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
- end
107
- return unless body.is_a?(Hash) && body['rate']
108
- body['rate']['remaining'] = @remaining
109
- return unless stringed
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
- # Creates a response environment for the cached response.
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 [Hash] Response environment hash
170
+ # @return [Faraday::Env] Response env ready to wrap in Faraday::Response
118
171
  def response_env(env, response)
119
- headers = response.headers.dup
120
- headers['x-ratelimit-remaining'] = @remaining.to_s if @remaining
121
- {
122
- method: env.method,
123
- url: env.url,
124
- request_headers: env.request_headers,
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 decompress cached value for key: #{key}, error: #{e.message}, the key will be deleted")
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