eight_ball 3.2.0 → 3.3.2
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/CHANGELOG.md +14 -0
- data/Gemfile.lock +1 -1
- data/README.md +20 -0
- data/lib/eight_ball/conditions/base.rb +17 -0
- data/lib/eight_ball/conditions/conditions.rb +3 -0
- data/lib/eight_ball/conditions/list.rb +4 -0
- data/lib/eight_ball/conditions/opaque.rb +30 -0
- data/lib/eight_ball/conditions/percentage.rb +61 -0
- data/lib/eight_ball/conditions/range.rb +4 -0
- data/lib/eight_ball/feature.rb +37 -5
- data/lib/eight_ball/marshallers/json.rb +46 -25
- data/lib/eight_ball/version.rb +1 -1
- data/lib/eight_ball.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b9dcfc459f280769679dbe39a36be388579403e1ea81700ecdb1f78ad42db30
|
|
4
|
+
data.tar.gz: e819abeae5c7d236f7c08b05c10069d0bbeb76210da389077d2621d0cb558227
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b0d24c2ec843bfa235da786579a36fadff617cfbe3abbb0be7085b5b26b7f445a98c81e3a3b1e1f6747698130a894ad418134d41193f94edfd4fa1dfde1ab200
|
|
7
|
+
data.tar.gz: 1629b60c02126692d6a2c63fe0fd58d66b2d1e5e99e65f117b0e989852d2def11fc996f6e9f16848f8133ffb6691642606a53e86396e584e66b6c1d82381d41c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [3.3.2]
|
|
4
|
+
|
|
5
|
+
- Fix the `tag-and-release` workflow to create the release tag with the `rewind-community-tagger` GitHub App token (`TAGGER_APP_ID`/`TAGGER_PRIVATE_KEY`) instead of the default `GITHUB_TOKEN`, which the organization `rewind-tag` ruleset does not permit to create `v*` tags (the previous release failed with `Reference update failed`).
|
|
6
|
+
|
|
7
|
+
## [3.3.1]
|
|
8
|
+
|
|
9
|
+
- Fix the `tag-and-release` workflow: grant the release job `contents: write` so tag and GitHub Release creation succeed. The repository's default GitHub Actions token permission had been changed to read-only, which broke the release step with `Resource not accessible by integration`.
|
|
10
|
+
|
|
11
|
+
## [3.3.0]
|
|
12
|
+
|
|
13
|
+
- Add `percentage` condition for sticky A/B bucketing. Canonical algorithm: `bucket = Integer(SHA256("<flagName>:<value>")[0,8], 16) % 100`, satisfied iff `bucket < percentage`; salted by flag name; buckets on a caller-supplied parameter.
|
|
14
|
+
- Harden JSON unmarshalling: a flag containing an unknown or malformed condition (unknown `type`, or a known type with invalid params) now evaluates to `false` (OFF) and logs a warning, instead of raising and blacking out the entire feature set. Other flags in the blob are unaffected.
|
|
15
|
+
- Add optional top-level `metadata` (`type`, `owner`, `expires_at`) to `Feature`, preserved through marshall/unmarshall and ignored during evaluation.
|
|
16
|
+
|
|
3
17
|
## [3.2.0]
|
|
4
18
|
|
|
5
19
|
- Upgrade Ruby to 3.4.5
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -81,6 +81,26 @@ A Condition must either be `true` or `false`. It describes when a Feature is ena
|
|
|
81
81
|
- [List](lib/eight_ball/conditions/list.rb): This condition is satisfied if the given value belongs to its list of accepted values.
|
|
82
82
|
- [Never](lib/eight_ball/conditions/never.rb): This condition is never satisfied.
|
|
83
83
|
- [Range](lib/eight_ball/conditions/range.rb): This condition is satisfied if the given value is within the specified range (inclusive).
|
|
84
|
+
- [Percentage](lib/eight_ball/conditions/percentage.rb): This condition is satisfied for a deterministic, sticky subset of subjects sized to `percentage` percent. Bucketing is salted by the flag name so a subject is decorrelated across flags. Wire form: `{"type":"percentage","parameter":"account_id","percentage":<0..100>}`.
|
|
85
|
+
|
|
86
|
+
#### Sticky percentage bucketing
|
|
87
|
+
|
|
88
|
+
The `percentage` condition buckets a subject deterministically:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
bucket = Integer(Digest::SHA256.hexdigest("#{flagName}:#{value}")[0, 8], 16) % 100
|
|
92
|
+
satisfied iff bucket < percentage
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The salt is the **flag name**, so the same `account_id` lands in independent buckets across different flags. `value` is stringified before hashing. Any reimplementation must reproduce it exactly to produce the same buckets.
|
|
96
|
+
|
|
97
|
+
**Re-randomizing requires a rename.** Because the salt is the flag name, an experiment cannot be re-randomized on the same flag. The salt is stable for the life of the name (this is what decorrelates a subject across flags), so a subject's bucket for a given flag is fixed forever. Changing `percentage` only moves the threshold (it does not reshuffle buckets); to draw fresh buckets, rename the flag.
|
|
98
|
+
|
|
99
|
+
**Requires the bucketing parameter.** Like every parameterized condition, `percentage` requires its bucketing value (the `parameter` it was configured with) in the evaluation bag. If it is absent, evaluation raises `ArgumentError`; supply it or rescue.
|
|
100
|
+
|
|
101
|
+
### Metadata
|
|
102
|
+
|
|
103
|
+
A Feature may carry an optional, eval-agnostic `metadata` object (`type`, `owner`, `expires_at`, all optional strings). It is preserved through marshall/unmarshall and ignored during evaluation. Its keys go through the same case conversion as the rest of the wire format (e.g. `expires_at` serializes as `expiresAt`) and come back symbol-keyed after unmarshall.
|
|
84
104
|
|
|
85
105
|
### Provider
|
|
86
106
|
|
|
@@ -21,6 +21,23 @@ module EightBall::Conditions
|
|
|
21
21
|
state.hash
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
# The wire-format hash for this condition. Subclasses declare their fields via
|
|
25
|
+
# {wire_fields}; the type is the lower-cased class name, which must match a
|
|
26
|
+
# {EightBall::Conditions.by_name} key so the condition round-trips.
|
|
27
|
+
def to_wire
|
|
28
|
+
wire = { type: self.class.name.split('::').last.downcase }
|
|
29
|
+
wire_fields.each do |field|
|
|
30
|
+
value = public_send(field)
|
|
31
|
+
wire[field.to_s] = value unless value.nil?
|
|
32
|
+
end
|
|
33
|
+
wire
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The attributes this condition serializes, in output order (default: none).
|
|
37
|
+
def wire_fields
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
|
|
24
41
|
protected
|
|
25
42
|
|
|
26
43
|
def state
|
|
@@ -5,10 +5,13 @@ module EightBall::Conditions
|
|
|
5
5
|
# @param [String] name The case insensitive name to find the Condition for
|
|
6
6
|
# @return [EightBall::Conditions] the Condition class represented by the given name
|
|
7
7
|
def self.by_name(name)
|
|
8
|
+
return nil unless name.is_a?(String)
|
|
9
|
+
|
|
8
10
|
mappings = {
|
|
9
11
|
always: EightBall::Conditions::Always,
|
|
10
12
|
list: EightBall::Conditions::List,
|
|
11
13
|
never: EightBall::Conditions::Never,
|
|
14
|
+
percentage: EightBall::Conditions::Percentage,
|
|
12
15
|
range: EightBall::Conditions::Range
|
|
13
16
|
}
|
|
14
17
|
mappings[name.downcase.to_sym]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EightBall::Conditions
|
|
4
|
+
# Wraps a condition entry that could not be built (unknown type, invalid params,
|
|
5
|
+
# or a non-object entry). It is never satisfiable and re-serializes to its
|
|
6
|
+
# original raw input, so a Feature holding one is failed closed yet round-trips
|
|
7
|
+
# verbatim. Not registered in {by_name}; produced only as a parse fallback.
|
|
8
|
+
class Opaque < Base
|
|
9
|
+
attr_reader :raw
|
|
10
|
+
|
|
11
|
+
def initialize(raw)
|
|
12
|
+
@raw = raw
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def satisfied?(*)
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Re-emit the original entry unchanged.
|
|
20
|
+
def to_wire
|
|
21
|
+
@raw
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
protected
|
|
25
|
+
|
|
26
|
+
def state
|
|
27
|
+
[@raw]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module EightBall::Conditions
|
|
6
|
+
# The Percentage Condition is satisfied for a deterministic, sticky subset of
|
|
7
|
+
# values sized to +percentage+ percent. Bucketing is salted by the owning flag's
|
|
8
|
+
# name (injected by {EightBall::Feature}), so a value buckets independently per
|
|
9
|
+
# flag. See the README for the bucket algorithm.
|
|
10
|
+
class Percentage < Base
|
|
11
|
+
attr_reader :percentage, :flag_name
|
|
12
|
+
|
|
13
|
+
# @param [Hash] options
|
|
14
|
+
# @option options [Integer] :percentage Integer 0..100. 0 is never
|
|
15
|
+
# satisfied; 100 is always satisfied.
|
|
16
|
+
# @option options [String] :parameter The name of the parameter this Condition
|
|
17
|
+
# was created for (eg. "account_id").
|
|
18
|
+
def initialize(options = {})
|
|
19
|
+
options ||= {}
|
|
20
|
+
|
|
21
|
+
raise ArgumentError, 'Missing value for percentage' if options[:percentage].nil?
|
|
22
|
+
|
|
23
|
+
percentage = options[:percentage]
|
|
24
|
+
percentage = percentage.to_i if percentage.is_a?(Float) && percentage.to_i == percentage
|
|
25
|
+
unless percentage.is_a?(Integer) && percentage >= 0 && percentage <= 100
|
|
26
|
+
raise ArgumentError, 'percentage must be an integer between 0 and 100'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@percentage = percentage
|
|
30
|
+
@flag_name = nil
|
|
31
|
+
|
|
32
|
+
self.parameter = options[:parameter]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Injected by {EightBall::Feature} so the bucket can be salted by flag name.
|
|
36
|
+
def flag_name=(name)
|
|
37
|
+
@flag_name = name
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @param value The value of {parameter} for the subject being evaluated.
|
|
41
|
+
# @return [Boolean] whether the subject falls within the bucket.
|
|
42
|
+
# @raise [ArgumentError] if {flag_name} was never injected.
|
|
43
|
+
def satisfied?(value)
|
|
44
|
+
raise ArgumentError, 'flag_name has not been set on Percentage condition' if @flag_name.nil?
|
|
45
|
+
|
|
46
|
+
bucket = Integer(Digest::SHA256.hexdigest("#{@flag_name}:#{value}")[0, 8], 16) % 100
|
|
47
|
+
bucket < @percentage
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def wire_fields
|
|
51
|
+
%i[percentage parameter]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
protected
|
|
55
|
+
|
|
56
|
+
# flag_name is a runtime injection, excluded from equality and the wire form.
|
|
57
|
+
def state
|
|
58
|
+
super + [@percentage]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/eight_ball/feature.rb
CHANGED
|
@@ -4,7 +4,7 @@ module EightBall
|
|
|
4
4
|
# A Feature is an element of your application that can be enabled or disabled
|
|
5
5
|
# based on various {EightBall::Conditions}.
|
|
6
6
|
class Feature
|
|
7
|
-
attr_reader :name, :enabled_for, :disabled_for
|
|
7
|
+
attr_reader :name, :enabled_for, :disabled_for, :metadata
|
|
8
8
|
|
|
9
9
|
# Creates a new instance of an Interval RefreshPolicy.
|
|
10
10
|
#
|
|
@@ -13,13 +13,17 @@ module EightBall
|
|
|
13
13
|
# The Condition(s) that need to be satisfied for the Feature to be enabled.
|
|
14
14
|
# @param disabled_for [Array<EightBall::Conditions>, EightBall::Conditions]
|
|
15
15
|
# The Condition(s) that need to be satisfied for the Feature to be disabled.
|
|
16
|
+
# @param metadata [Hash, nil] Optional eval-agnostic metadata
|
|
17
|
+
# (e.g. { "type" => ..., "owner" => ..., "expires_at" => ... }).
|
|
16
18
|
#
|
|
17
19
|
# @example A Feature which is always enabled
|
|
18
20
|
# feature = EightBall::Feature.new 'feature1', EightBall::Conditions::Always
|
|
19
|
-
def initialize(name, enabled_for = [], disabled_for = [])
|
|
21
|
+
def initialize(name, enabled_for = [], disabled_for = [], metadata: nil)
|
|
20
22
|
@name = name
|
|
21
|
-
@enabled_for = Array
|
|
22
|
-
@disabled_for = Array
|
|
23
|
+
@enabled_for = inject_flag_name(Array(enabled_for))
|
|
24
|
+
@disabled_for = inject_flag_name(Array(disabled_for))
|
|
25
|
+
@metadata = metadata
|
|
26
|
+
@un_evaluable = false
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
# "EightBall, is this Feature enabled?"
|
|
@@ -42,18 +46,35 @@ module EightBall
|
|
|
42
46
|
# @example The Feature's {EightBall::Conditions} require an account ID
|
|
43
47
|
# feature.enabled? account_id: 123
|
|
44
48
|
def enabled?(parameters = {})
|
|
49
|
+
return false if @un_evaluable
|
|
50
|
+
|
|
45
51
|
return true if @enabled_for.empty? && @disabled_for.empty?
|
|
46
52
|
return true if @enabled_for.empty? && !any_satisfied?(@disabled_for, parameters)
|
|
47
53
|
|
|
48
54
|
any_satisfied?(@enabled_for, parameters) && !any_satisfied?(@disabled_for, parameters)
|
|
49
55
|
end
|
|
50
56
|
|
|
57
|
+
# Marks this Feature un-evaluable: {enabled?} always returns +false+ and its
|
|
58
|
+
# Conditions are never inspected. Used by Marshallers to fail a bad flag closed.
|
|
59
|
+
#
|
|
60
|
+
# @return [nil]
|
|
61
|
+
def un_evaluable!
|
|
62
|
+
@un_evaluable = true
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean] whether this Feature has been marked un-evaluable.
|
|
67
|
+
def un_evaluable?
|
|
68
|
+
@un_evaluable
|
|
69
|
+
end
|
|
70
|
+
|
|
51
71
|
def ==(other)
|
|
52
72
|
name == other.name &&
|
|
53
73
|
enabled_for.size == other.enabled_for.size &&
|
|
54
74
|
enabled_for.all? { |condition| other.enabled_for.any? { |other_condition| condition == other_condition } } &&
|
|
55
75
|
disabled_for.size == other.disabled_for.size &&
|
|
56
|
-
disabled_for.all? { |condition| other.disabled_for.any? { |other_condition| condition == other_condition } }
|
|
76
|
+
disabled_for.all? { |condition| other.disabled_for.any? { |other_condition| condition == other_condition } } &&
|
|
77
|
+
metadata == other.metadata
|
|
57
78
|
end
|
|
58
79
|
alias eql? ==
|
|
59
80
|
|
|
@@ -69,5 +90,16 @@ module EightBall
|
|
|
69
90
|
condition.satisfied? value
|
|
70
91
|
end
|
|
71
92
|
end
|
|
93
|
+
|
|
94
|
+
# Percentage conditions bucket on a salt of the flag name. Give any condition
|
|
95
|
+
# that accepts it the owning name, duping first so a condition shared across
|
|
96
|
+
# Features is not re-salted in place. Others pass through untouched.
|
|
97
|
+
def inject_flag_name(conditions)
|
|
98
|
+
conditions.map do |condition|
|
|
99
|
+
next condition unless condition.respond_to?(:flag_name=)
|
|
100
|
+
|
|
101
|
+
condition.dup.tap { |copy| copy.flag_name = @name }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
72
104
|
end
|
|
73
105
|
end
|
|
@@ -62,54 +62,75 @@ module EightBall::Marshallers
|
|
|
62
62
|
# marshaller = EightBall::Marshallers::Json.new
|
|
63
63
|
# marshaller.unmarshall json_string => [Features]
|
|
64
64
|
def unmarshall(json)
|
|
65
|
-
parsed = JSON.parse(json, symbolize_names: true)
|
|
65
|
+
parsed = JSON.parse(json, symbolize_names: true)
|
|
66
66
|
|
|
67
67
|
raise ArgumentError, 'JSON input was not an array' unless parsed.is_a? Array
|
|
68
68
|
|
|
69
|
-
parsed.
|
|
70
|
-
|
|
71
|
-
disabled_for = create_conditions_from_json feature[:disabled_for]
|
|
72
|
-
|
|
73
|
-
EightBall::Feature.new feature[:name], enabled_for, disabled_for
|
|
69
|
+
parsed.to_snake_keys.filter_map do |feature|
|
|
70
|
+
build_feature feature
|
|
74
71
|
end
|
|
75
72
|
rescue JSON::ParserError => e
|
|
76
|
-
EightBall.logger.error { "Failed to parse JSON: #{e.message}" }
|
|
73
|
+
EightBall.logger.error { "Failed to parse JSON: #{e.message.inspect}" }
|
|
77
74
|
[]
|
|
78
75
|
end
|
|
79
76
|
|
|
80
77
|
private
|
|
81
78
|
|
|
82
|
-
def
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
def build_feature(feature)
|
|
80
|
+
unless feature.is_a?(Hash)
|
|
81
|
+
EightBall.logger.warn { "Skipping non-object feature entry: #{feature.inspect}" }
|
|
82
|
+
return nil
|
|
83
|
+
end
|
|
86
84
|
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
enabled_for = create_conditions_from_json feature[:enabled_for]
|
|
86
|
+
disabled_for = create_conditions_from_json feature[:disabled_for]
|
|
87
|
+
built = EightBall::Feature.new feature[:name], enabled_for, disabled_for, metadata: feature[:metadata]
|
|
89
88
|
|
|
90
|
-
|
|
89
|
+
unparseable = (enabled_for + disabled_for).select { |condition| condition.is_a?(EightBall::Conditions::Opaque) }
|
|
90
|
+
|
|
91
|
+
# A nameless flag can't be looked up, and its flag-name-salted conditions raise at eval; fail it closed.
|
|
92
|
+
if feature[:name].nil?
|
|
93
|
+
EightBall.logger.warn { 'Feature entry has no name; marking it un-evaluable (OFF)' }
|
|
94
|
+
built.un_evaluable!
|
|
95
|
+
elsif !unparseable.empty?
|
|
96
|
+
EightBall.logger.warn { "Feature #{feature[:name].inspect} has unparseable condition(s) #{unparseable.map(&:raw).inspect}; marking it un-evaluable (OFF)" }
|
|
97
|
+
built.un_evaluable!
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
built
|
|
91
101
|
end
|
|
92
102
|
|
|
93
|
-
def
|
|
103
|
+
def feature_to_hash(feature)
|
|
94
104
|
hash = {
|
|
95
|
-
|
|
105
|
+
name: feature.name
|
|
96
106
|
}
|
|
97
|
-
condition.instance_variables.each do |var|
|
|
98
|
-
next unless condition.instance_variable_get(var)
|
|
99
107
|
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
hash[:enabled_for] = feature.enabled_for.map(&:to_wire) unless feature.enabled_for.empty?
|
|
109
|
+
hash[:disabled_for] = feature.disabled_for.map(&:to_wire) unless feature.disabled_for.empty?
|
|
110
|
+
hash[:metadata] = feature.metadata unless feature.metadata.nil?
|
|
102
111
|
|
|
103
112
|
hash
|
|
104
113
|
end
|
|
105
114
|
|
|
106
115
|
def create_conditions_from_json(json_conditions)
|
|
107
|
-
return []
|
|
116
|
+
return [] if json_conditions.nil?
|
|
117
|
+
return [EightBall::Conditions::Opaque.new(json_conditions)] unless json_conditions.is_a?(Array)
|
|
108
118
|
|
|
109
|
-
json_conditions.map
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
119
|
+
json_conditions.map { |condition| build_condition condition }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Build one condition, or wrap anything unbuildable (non-object, unknown type,
|
|
123
|
+
# invalid params) in an Opaque condition so a single bad entry fails only its
|
|
124
|
+
# flag closed instead of aborting the whole parse.
|
|
125
|
+
def build_condition(condition)
|
|
126
|
+
return EightBall::Conditions::Opaque.new(condition) unless condition.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
condition_class = EightBall::Conditions.by_name condition[:type]
|
|
129
|
+
return EightBall::Conditions::Opaque.new(condition) if condition_class.nil?
|
|
130
|
+
|
|
131
|
+
condition_class.new condition
|
|
132
|
+
rescue StandardError
|
|
133
|
+
EightBall::Conditions::Opaque.new(condition)
|
|
113
134
|
end
|
|
114
135
|
end
|
|
115
136
|
end
|
data/lib/eight_ball/version.rb
CHANGED
data/lib/eight_ball.rb
CHANGED
|
@@ -11,6 +11,8 @@ require 'eight_ball/conditions/base'
|
|
|
11
11
|
require 'eight_ball/conditions/always'
|
|
12
12
|
require 'eight_ball/conditions/list'
|
|
13
13
|
require 'eight_ball/conditions/never'
|
|
14
|
+
require 'eight_ball/conditions/opaque'
|
|
15
|
+
require 'eight_ball/conditions/percentage'
|
|
14
16
|
require 'eight_ball/conditions/range'
|
|
15
17
|
|
|
16
18
|
require 'eight_ball/marshallers/json'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: eight_ball
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.2
|
|
4
|
+
version: 3.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rewind.io
|
|
@@ -174,6 +174,8 @@ files:
|
|
|
174
174
|
- lib/eight_ball/conditions/conditions.rb
|
|
175
175
|
- lib/eight_ball/conditions/list.rb
|
|
176
176
|
- lib/eight_ball/conditions/never.rb
|
|
177
|
+
- lib/eight_ball/conditions/opaque.rb
|
|
178
|
+
- lib/eight_ball/conditions/percentage.rb
|
|
177
179
|
- lib/eight_ball/conditions/range.rb
|
|
178
180
|
- lib/eight_ball/configuration_error.rb
|
|
179
181
|
- lib/eight_ball/feature.rb
|