feature_flagger 0.7.2 → 2.0.0
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 +5 -5
- data/README.md +33 -10
- data/lib/feature_flagger.rb +8 -0
- data/lib/feature_flagger/configuration.rb +31 -1
- data/lib/feature_flagger/control.rb +25 -22
- data/lib/feature_flagger/core_ext.rb +20 -0
- data/lib/feature_flagger/feature.rb +22 -16
- data/lib/feature_flagger/key_decomposer.rb +12 -0
- data/lib/feature_flagger/key_resolver.rb +19 -0
- data/lib/feature_flagger/manager.rb +28 -0
- data/lib/feature_flagger/model.rb +78 -32
- data/lib/feature_flagger/model_settings.rb +38 -0
- data/lib/feature_flagger/railtie.rb +11 -0
- data/lib/feature_flagger/storage/feature_keys_migration.rb +54 -0
- data/lib/feature_flagger/storage/redis.rb +68 -10
- data/lib/feature_flagger/version.rb +3 -1
- data/lib/tasks/feature_flagger.rake +56 -0
- metadata +42 -22
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 33983c979f6382f528796cbab4e51e80000c6855f50702602f451e2fb9cad666
|
|
4
|
+
data.tar.gz: 766cc624063a0a2516a2509c70f6bf6b397cc4ffcda6ee2333f36ac45d74f99a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3ea819eac2381595cab508f5d8e83a2e20c3b2516ad413819875ce12392cf61d67b7a1876ffe9a5345e2dc174205b6cf3b72eba8c12fd007baa7045fffefdfc4
|
|
7
|
+
data.tar.gz: fadce1676c47be30ed5a4fe8845b198448395b75a4a575276fea62eb5f6565318ea9dc27770b21d43418552372c0ec19757f460fefd77a9a15cd017aa386a468
|
data/README.md
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
Partially release your features.
|
|
6
6
|
|
|
7
|
+
## Working with Docker
|
|
8
|
+
|
|
9
|
+
Open IRB
|
|
10
|
+
`docker-compose run feature_flagger`
|
|
11
|
+
|
|
12
|
+
Running tests
|
|
13
|
+
`docker-compose run feature_flagger rspec`
|
|
14
|
+
|
|
7
15
|
## Installation
|
|
8
16
|
|
|
9
17
|
Add this line to your application's Gemfile:
|
|
@@ -24,20 +32,19 @@ Or install it yourself as:
|
|
|
24
32
|
## Configuration
|
|
25
33
|
|
|
26
34
|
By default, feature_flagger uses the REDIS_URL env var to setup it's storage.
|
|
27
|
-
You can
|
|
28
|
-
|
|
29
|
-
1. In a initializer file (e.g. `config/initializers/feature_flagger.rb`):
|
|
35
|
+
You can set up FeatureFlagger by creating a file called ```config/initializers/feature_flagger``` with the following lines:
|
|
30
36
|
```ruby
|
|
31
37
|
require 'redis-namespace'
|
|
32
38
|
require 'feature_flagger'
|
|
33
39
|
|
|
34
40
|
FeatureFlagger.configure do |config|
|
|
35
|
-
|
|
41
|
+
redis = Redis.new(host: ENV['REDIS_URL'])
|
|
42
|
+
namespaced = Redis::Namespace.new('feature_flagger', redis: redis)
|
|
36
43
|
config.storage = FeatureFlagger::Storage::Redis.new(namespaced)
|
|
37
44
|
end
|
|
38
45
|
```
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
1. Create a `rollout.yml` in _config_ path and declare a rollout:
|
|
41
48
|
```yml
|
|
42
49
|
account: # model name
|
|
43
50
|
email_marketing: # namespace (optional)
|
|
@@ -46,7 +53,7 @@ account: # model name
|
|
|
46
53
|
@dispatch team uses this rollout to introduce a new email flow for certains users. Read more at [link]
|
|
47
54
|
```
|
|
48
55
|
|
|
49
|
-
|
|
56
|
+
2. Adds rollout funcionality to your model:
|
|
50
57
|
```ruby
|
|
51
58
|
class Account < ActiveRecord::Base
|
|
52
59
|
include FeatureFlagger::Model
|
|
@@ -64,21 +71,29 @@ account.release(:email_marketing, :new_email_flow)
|
|
|
64
71
|
#=> true
|
|
65
72
|
|
|
66
73
|
# Check feature for a given account
|
|
67
|
-
account.
|
|
74
|
+
account.released?(:email_marketing, :new_email_flow)
|
|
68
75
|
#=> true
|
|
69
76
|
|
|
77
|
+
# Get an array with all features for a specific account id
|
|
78
|
+
account.releases
|
|
79
|
+
=> ['email_marketing:new_email_flow']
|
|
80
|
+
|
|
70
81
|
# Remove feature for given account
|
|
71
82
|
account.unrelease(:email_marketing, :new_email_flow)
|
|
72
83
|
#=> true
|
|
73
84
|
|
|
74
85
|
# If you try to check an inexistent rollout key it will raise an error.
|
|
75
|
-
account.
|
|
86
|
+
account.released?(:email_marketing, :new_email_flow)
|
|
76
87
|
FeatureFlagger::KeyNotFoundError: ["account", "email_marketing", "new_email_flo"]
|
|
77
88
|
|
|
78
89
|
# Check feature for a specific account id
|
|
79
90
|
Account.released_id?(42, :email_marketing, :new_email_flow)
|
|
80
91
|
#=> true
|
|
81
92
|
|
|
93
|
+
# Release a feature for a specific account id
|
|
94
|
+
Account.release_id(42, :email_marketing, :new_email_flow)
|
|
95
|
+
#=> true
|
|
96
|
+
|
|
82
97
|
# Get an array with all released Account ids
|
|
83
98
|
Account.all_released_ids_for(:email_marketing, :new_email_flow)
|
|
84
99
|
|
|
@@ -92,7 +107,15 @@ Account.unrelease_to_all(:email_marketing, :new_email_flow)
|
|
|
92
107
|
Account.released_features_to_all
|
|
93
108
|
```
|
|
94
109
|
|
|
110
|
+
## Clean up action
|
|
111
|
+
|
|
112
|
+
By default when a key is removed from `rollout.yml` file, its data still in the storage.
|
|
113
|
+
|
|
114
|
+
To clean it up, execute or schedule the rake:
|
|
115
|
+
|
|
116
|
+
$ bundle exec rake feature_flagger:cleanup_removed_rollouts
|
|
117
|
+
|
|
95
118
|
## Contributing
|
|
96
119
|
|
|
97
|
-
Bug reports and pull requests are welcome
|
|
98
|
-
|
|
120
|
+
Bug reports and pull requests are welcome!
|
|
121
|
+
Please take a look at our guidelines [here](CONTRIBUTING.md).
|
data/lib/feature_flagger.rb
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'yaml'
|
|
2
4
|
|
|
3
5
|
require 'feature_flagger/version'
|
|
4
6
|
require 'feature_flagger/storage/redis'
|
|
5
7
|
require 'feature_flagger/control'
|
|
6
8
|
require 'feature_flagger/model'
|
|
9
|
+
require 'feature_flagger/model_settings'
|
|
7
10
|
require 'feature_flagger/feature'
|
|
11
|
+
require 'feature_flagger/key_resolver'
|
|
12
|
+
require 'feature_flagger/key_decomposer'
|
|
8
13
|
require 'feature_flagger/configuration'
|
|
14
|
+
require 'feature_flagger/manager'
|
|
15
|
+
require 'feature_flagger/storage/feature_keys_migration'
|
|
16
|
+
require 'feature_flagger/railtie'
|
|
9
17
|
|
|
10
18
|
module FeatureFlagger
|
|
11
19
|
class << self
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module FeatureFlagger
|
|
2
4
|
class Configuration
|
|
3
5
|
attr_accessor :storage, :yaml_filepath
|
|
@@ -8,7 +10,16 @@ module FeatureFlagger
|
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
def info
|
|
11
|
-
|
|
13
|
+
raise 'Missing configuration file.' unless yaml_filepath
|
|
14
|
+
|
|
15
|
+
@info ||= YAML.load_file(yaml_filepath)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def mapped_feature_keys(resource_name = nil)
|
|
19
|
+
info_filtered = resource_name ? info[resource_name] : info
|
|
20
|
+
[].tap do |keys|
|
|
21
|
+
make_keys_recursively(info_filtered).each { |key| keys.push(join_key(resource_name, key)) }
|
|
22
|
+
end
|
|
12
23
|
end
|
|
13
24
|
|
|
14
25
|
private
|
|
@@ -16,5 +27,24 @@ module FeatureFlagger
|
|
|
16
27
|
def default_yaml_filepath
|
|
17
28
|
"#{Rails.root}/config/rollout.yml" if defined?(Rails)
|
|
18
29
|
end
|
|
30
|
+
|
|
31
|
+
def make_keys_recursively(hash, keys = [], composed_key = [])
|
|
32
|
+
unless hash.values[0].is_a?(Hash)
|
|
33
|
+
keys.push(composed_key)
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
hash.each do |key, value|
|
|
38
|
+
composed_key_cloned = composed_key.clone
|
|
39
|
+
composed_key_cloned.push(key.to_sym)
|
|
40
|
+
make_keys_recursively(value, keys, composed_key_cloned)
|
|
41
|
+
end
|
|
42
|
+
keys
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def join_key(resource_name, key)
|
|
46
|
+
key.unshift resource_name if resource_name
|
|
47
|
+
key.join(':')
|
|
48
|
+
end
|
|
19
49
|
end
|
|
20
50
|
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module FeatureFlagger
|
|
2
4
|
class Control
|
|
3
5
|
attr_reader :storage
|
|
@@ -8,44 +10,45 @@ module FeatureFlagger
|
|
|
8
10
|
@storage = storage
|
|
9
11
|
end
|
|
10
12
|
|
|
11
|
-
def
|
|
12
|
-
@storage.has_value?(
|
|
13
|
+
def released?(feature_key, resource_name, resource_id)
|
|
14
|
+
@storage.has_value?(feature_key, resource_name, RELEASED_FEATURES) ||
|
|
15
|
+
@storage.has_value?(feature_key, resource_name, resource_id)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def release(feature_key, resource_name, resource_id)
|
|
19
|
+
@storage.add(feature_key, resource_name, resource_id)
|
|
13
20
|
end
|
|
14
21
|
|
|
15
|
-
def
|
|
16
|
-
@storage.
|
|
22
|
+
def release_to_all(feature_key, resource_name)
|
|
23
|
+
@storage.add_all(RELEASED_FEATURES, feature_key, resource_name)
|
|
17
24
|
end
|
|
18
25
|
|
|
19
|
-
def
|
|
20
|
-
@storage.
|
|
26
|
+
def all_feature_keys(resource_name, resource_id)
|
|
27
|
+
@storage.all_feature_keys(RELEASED_FEATURES, resource_name, resource_id)
|
|
21
28
|
end
|
|
22
29
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
warn "[DEPRECATION] `release!` is deprecated. Please use `release` instead."
|
|
26
|
-
release(feature_key, resource_id)
|
|
30
|
+
def unrelease(feature_key, resource_name, resource_id)
|
|
31
|
+
@storage.remove(feature_key, resource_name, resource_id)
|
|
27
32
|
end
|
|
28
33
|
|
|
29
|
-
def
|
|
30
|
-
@storage.
|
|
34
|
+
def unrelease_to_all(feature_key, resource_name)
|
|
35
|
+
@storage.remove_all(feature_key, resource_name)
|
|
31
36
|
end
|
|
32
37
|
|
|
33
|
-
def
|
|
34
|
-
@storage.
|
|
38
|
+
def resource_ids(feature_key, resource_name)
|
|
39
|
+
@storage.all_values(feature_key, resource_name)
|
|
35
40
|
end
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
warn "[DEPRECATION] `unrelease!` is deprecated. Please use `unrelease` instead."
|
|
40
|
-
unrelease(feature_key, resource_id)
|
|
42
|
+
def released_features_to_all(resource_name)
|
|
43
|
+
@storage.all_feature_keys(RELEASED_FEATURES, resource_name)
|
|
41
44
|
end
|
|
42
45
|
|
|
43
|
-
def
|
|
44
|
-
@storage.
|
|
46
|
+
def released_to_all?(feature_key, resource_name)
|
|
47
|
+
@storage.has_value?(feature_key, resource_name, RELEASED_FEATURES)
|
|
45
48
|
end
|
|
46
49
|
|
|
47
|
-
def
|
|
48
|
-
@storage.
|
|
50
|
+
def search_keys(query)
|
|
51
|
+
@storage.search_keys(query)
|
|
49
52
|
end
|
|
50
53
|
end
|
|
51
54
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require 'active_support/core_ext/string/inflections'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
unless ''.respond_to?(:constantize)
|
|
7
|
+
class String
|
|
8
|
+
def constantize
|
|
9
|
+
names = split('::')
|
|
10
|
+
names.shift if names.empty? || names.first.empty?
|
|
11
|
+
|
|
12
|
+
constant = Object
|
|
13
|
+
names.each do |name|
|
|
14
|
+
constant = constant.const_defined?(name) ? constant.const_get(name) : constant.const_missing(name)
|
|
15
|
+
end
|
|
16
|
+
constant
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module FeatureFlagger
|
|
4
|
+
class KeyNotFoundError < StandardError; end
|
|
5
|
+
|
|
2
6
|
class Feature
|
|
3
|
-
def initialize(feature_key, resource_name
|
|
4
|
-
@
|
|
5
|
-
|
|
7
|
+
def initialize(feature_key, resource_name)
|
|
8
|
+
@key_resolver = KeyResolver.new(feature_key, resource_name.to_s)
|
|
9
|
+
|
|
6
10
|
fetch_data
|
|
7
11
|
end
|
|
8
12
|
|
|
@@ -11,33 +15,35 @@ module FeatureFlagger
|
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def key
|
|
14
|
-
@
|
|
18
|
+
@key_resolver.normalized_key.join(':')
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
private
|
|
18
22
|
|
|
19
|
-
def
|
|
20
|
-
|
|
21
|
-
key.insert(0, resource_name) if resource_name
|
|
22
|
-
key.map(&:to_s)
|
|
23
|
+
def config_info
|
|
24
|
+
FeatureFlagger.config.info
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def fetch_data
|
|
26
|
-
@data ||= find_value(
|
|
27
|
-
|
|
28
|
+
@data ||= find_value(config_info, *@key_resolver.normalized_key_with_name)
|
|
29
|
+
|
|
30
|
+
if @data.nil? || @data["description"].nil?
|
|
31
|
+
raise FeatureFlagger::KeyNotFoundError, @feature_key
|
|
32
|
+
end
|
|
33
|
+
|
|
28
34
|
@data
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
def find_value(hash, key, *tail)
|
|
38
|
+
return nil if hash.nil?
|
|
39
|
+
|
|
32
40
|
value = hash[key]
|
|
33
41
|
|
|
34
|
-
if
|
|
35
|
-
value
|
|
36
|
-
else
|
|
37
|
-
find_value(value, *tail)
|
|
42
|
+
if tail.any?
|
|
43
|
+
return find_value(value, *tail)
|
|
38
44
|
end
|
|
45
|
+
|
|
46
|
+
value
|
|
39
47
|
end
|
|
40
48
|
end
|
|
41
49
|
end
|
|
42
|
-
|
|
43
|
-
class FeatureFlagger::KeyNotFoundError < StandardError ; end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FeatureFlagger
|
|
4
|
+
class KeyDecomposer
|
|
5
|
+
def self.decompose(complete_feature_key)
|
|
6
|
+
decomposed_key = complete_feature_key.split(':')
|
|
7
|
+
resource_name = decomposed_key.shift
|
|
8
|
+
|
|
9
|
+
[resource_name, decomposed_key]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FeatureFlagger
|
|
4
|
+
class KeyResolver
|
|
5
|
+
def initialize(feature_key, resource_name)
|
|
6
|
+
@feature_key = feature_key
|
|
7
|
+
@resource_name = resource_name
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def normalized_key
|
|
11
|
+
@normalized_key ||= Array(@feature_key).flatten
|
|
12
|
+
.map(&:to_s)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def normalized_key_with_name
|
|
16
|
+
@normalized_key_with_name ||= [@resource_name] + normalized_key
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FeatureFlagger
|
|
4
|
+
class Manager
|
|
5
|
+
def self.detached_feature_keys(resource_name)
|
|
6
|
+
keys = FeatureFlagger.control.search_keys("#{resource_name}:*")
|
|
7
|
+
|
|
8
|
+
persisted_features = keys.flat_map do |key|
|
|
9
|
+
FeatureFlagger.control.all_feature_keys(resource_name, key.sub("#{resource_name}:", ''))
|
|
10
|
+
end.sort.uniq
|
|
11
|
+
|
|
12
|
+
mapped_feature_keys = FeatureFlagger.config.mapped_feature_keys(resource_name).map do |feature|
|
|
13
|
+
feature.sub("#{resource_name}:", '')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
persisted_features - mapped_feature_keys
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.cleanup_detached(resource_name, *feature_key)
|
|
20
|
+
feature = Feature.new(feature_key, resource_name)
|
|
21
|
+
raise 'key is still mapped'
|
|
22
|
+
rescue FeatureFlagger::KeyNotFoundError => _e
|
|
23
|
+
# This means the keys is not present in config file anymore
|
|
24
|
+
key_resolver = KeyResolver.new(feature_key, resource_name)
|
|
25
|
+
FeatureFlagger.control.unrelease_to_all(key_resolver.normalized_key, resource_name)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module FeatureFlagger
|
|
2
4
|
# Model provides convinient methods for Rails Models
|
|
3
5
|
# class Account
|
|
@@ -12,69 +14,113 @@ module FeatureFlagger
|
|
|
12
14
|
base.extend ClassMethods
|
|
13
15
|
end
|
|
14
16
|
|
|
15
|
-
def
|
|
16
|
-
self.class.released_id?(
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# <b>DEPRECATED:</b> Please use <tt>release</tt> instead.
|
|
20
|
-
def release!(*feature_key)
|
|
21
|
-
warn "[DEPRECATION] `release!` is deprecated. Please use `release` instead."
|
|
22
|
-
release(*feature_key)
|
|
17
|
+
def released?(*feature_key)
|
|
18
|
+
self.class.released_id?(feature_flagger_identifier, feature_key)
|
|
23
19
|
end
|
|
24
20
|
|
|
25
21
|
def release(*feature_key)
|
|
26
|
-
|
|
27
|
-
feature = Feature.new(feature_key, resource_name)
|
|
28
|
-
FeatureFlagger.control.release(feature.key, id)
|
|
22
|
+
self.class.release_id(feature_flagger_identifier, *feature_key)
|
|
29
23
|
end
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
warn "[DEPRECATION] `unrelease!` is deprecated. Please use `unrelease` instead."
|
|
34
|
-
unrelease(*feature_key)
|
|
25
|
+
def releases
|
|
26
|
+
self.class.release_keys(feature_flagger_identifier)
|
|
35
27
|
end
|
|
36
28
|
|
|
37
29
|
def unrelease(*feature_key)
|
|
38
|
-
|
|
39
|
-
feature
|
|
40
|
-
|
|
30
|
+
feature = Feature.new(feature_key, feature_flagger_name)
|
|
31
|
+
FeatureFlagger.control.unrelease(feature.key, feature_flagger_name, id)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def feature_flagger_identifier
|
|
37
|
+
public_send(self.class.feature_flagger_model_settings.identifier_field)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def feature_flagger_name
|
|
41
|
+
self.class.feature_flagger_model_settings.entity_name
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
module ClassMethods
|
|
45
|
+
def feature_flagger
|
|
46
|
+
raise ArgumentError unless block_given?
|
|
47
|
+
|
|
48
|
+
yield feature_flagger_model_settings
|
|
49
|
+
end
|
|
50
|
+
|
|
44
51
|
def released_id?(resource_id, *feature_key)
|
|
45
|
-
feature = Feature.new(feature_key,
|
|
46
|
-
FeatureFlagger.control.
|
|
52
|
+
feature = Feature.new(feature_key, feature_flagger_name)
|
|
53
|
+
FeatureFlagger.control.released?(feature.key, feature_flagger_name, resource_id)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def release_id(resource_id, *feature_key)
|
|
57
|
+
feature = Feature.new(feature_key, feature_flagger_name)
|
|
58
|
+
FeatureFlagger.control.release(feature.key, feature_flagger_name, resource_id)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def release_keys(resource_id)
|
|
62
|
+
FeatureFlagger.control.all_feature_keys(feature_flagger_name, resource_id)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def unrelease_id(resource_id, *feature_key)
|
|
66
|
+
feature = Feature.new(feature_key, feature_flagger_name)
|
|
67
|
+
FeatureFlagger.control.unrelease(feature.key, feature_flagger_name, resource_id)
|
|
47
68
|
end
|
|
48
69
|
|
|
49
70
|
def all_released_ids_for(*feature_key)
|
|
50
|
-
feature_key
|
|
51
|
-
feature
|
|
52
|
-
FeatureFlagger.control.resource_ids(feature.key)
|
|
71
|
+
feature = Feature.new(feature_key, feature_flagger_name)
|
|
72
|
+
FeatureFlagger.control.resource_ids(feature.key, feature_flagger_name)
|
|
53
73
|
end
|
|
54
74
|
|
|
55
75
|
def release_to_all(*feature_key)
|
|
56
|
-
feature = Feature.new(feature_key,
|
|
57
|
-
FeatureFlagger.control.release_to_all(feature.key)
|
|
76
|
+
feature = Feature.new(feature_key, feature_flagger_name)
|
|
77
|
+
FeatureFlagger.control.release_to_all(feature.key, feature_flagger_name)
|
|
58
78
|
end
|
|
59
79
|
|
|
60
80
|
def unrelease_to_all(*feature_key)
|
|
61
|
-
feature = Feature.new(feature_key,
|
|
62
|
-
FeatureFlagger.control.unrelease_to_all(feature.key)
|
|
81
|
+
feature = Feature.new(feature_key, feature_flagger_name)
|
|
82
|
+
FeatureFlagger.control.unrelease_to_all(feature.key, feature_flagger_name)
|
|
63
83
|
end
|
|
64
84
|
|
|
65
85
|
def released_features_to_all
|
|
66
|
-
FeatureFlagger.control.released_features_to_all
|
|
86
|
+
FeatureFlagger.control.released_features_to_all(feature_flagger_name)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def released_to_all?(*feature_key)
|
|
90
|
+
feature = Feature.new(feature_key, feature_flagger_name)
|
|
91
|
+
FeatureFlagger.control.released_to_all?(feature.key, feature_flagger_name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def detached_feature_keys
|
|
95
|
+
Manager.detached_feature_keys(feature_flagger_name)
|
|
67
96
|
end
|
|
68
97
|
|
|
98
|
+
def cleanup_detached(*feature_key)
|
|
99
|
+
Manager.cleanup_detached(feature_flagger_name, feature_key)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def feature_flagger_model_settings
|
|
103
|
+
@feature_flagger_model_settings ||= FeatureFlagger::ModelSettings.new(
|
|
104
|
+
identifier_field: :id,
|
|
105
|
+
entity_name: rollout_resource_name
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
69
111
|
def rollout_resource_name
|
|
70
|
-
klass_name =
|
|
112
|
+
klass_name = to_s
|
|
71
113
|
klass_name.gsub!(/::/, '_')
|
|
72
|
-
klass_name.gsub!(/([A-Z\d]+)([A-Z][a-z])/,'\1_\2')
|
|
73
|
-
klass_name.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
|
|
74
|
-
klass_name.tr!(
|
|
114
|
+
klass_name.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
115
|
+
klass_name.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
|
|
116
|
+
klass_name.tr!('-', '_')
|
|
75
117
|
klass_name.downcase!
|
|
76
118
|
klass_name
|
|
77
119
|
end
|
|
120
|
+
|
|
121
|
+
def feature_flagger_name
|
|
122
|
+
feature_flagger_model_settings.entity_name
|
|
123
|
+
end
|
|
78
124
|
end
|
|
79
125
|
end
|
|
80
126
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FeatureFlagger
|
|
4
|
+
class ModelSettings
|
|
5
|
+
def initialize(arguments)
|
|
6
|
+
arguments.each do |field, value|
|
|
7
|
+
public_send("#{field}=", value)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Public: identifier_field Refers to which field must represent the unique model
|
|
12
|
+
# id.
|
|
13
|
+
attr_accessor :identifier_field
|
|
14
|
+
|
|
15
|
+
# Public: entity_name to which entity the model is targeting.
|
|
16
|
+
# Take this yaml file as example:
|
|
17
|
+
#
|
|
18
|
+
# account:
|
|
19
|
+
# email_marketing:
|
|
20
|
+
# whitelabel:
|
|
21
|
+
# description: a rollout
|
|
22
|
+
# owner: core
|
|
23
|
+
# account_in_migration:
|
|
24
|
+
# email_marketing:
|
|
25
|
+
# whitelabel:
|
|
26
|
+
# description: a rollout
|
|
27
|
+
# owner: core
|
|
28
|
+
#
|
|
29
|
+
# class Account < ActiveRecord::Base
|
|
30
|
+
# include FeatureFlagger::Model
|
|
31
|
+
#
|
|
32
|
+
# feature_flagger do |config|
|
|
33
|
+
# config.identifier_field = :cdp_tenant_id
|
|
34
|
+
# config.entity_name = :account_in_migration
|
|
35
|
+
# end
|
|
36
|
+
attr_accessor :entity_name
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FeatureFlagger
|
|
4
|
+
module Storage
|
|
5
|
+
class FeatureKeysMigration
|
|
6
|
+
def initialize(from_redis, to_control)
|
|
7
|
+
@from_redis = from_redis
|
|
8
|
+
@to_control = to_control
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
@from_redis.keys('*').map { |key| migrate_key(key) }.flatten
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def migrate_key(key)
|
|
18
|
+
return migrate_release_to_all(key) if feature_released_to_all?(key)
|
|
19
|
+
|
|
20
|
+
migrate_release(key)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def migrate_release_to_all(key)
|
|
24
|
+
features = @from_redis.smembers(key)
|
|
25
|
+
|
|
26
|
+
features.map do |feature|
|
|
27
|
+
resource_name, feature_key = KeyDecomposer.decompose(feature)
|
|
28
|
+
feature = Feature.new(feature_key, resource_name)
|
|
29
|
+
|
|
30
|
+
@to_control.release_to_all(feature.key, resource_name)
|
|
31
|
+
rescue KeyNotFoundError => _e
|
|
32
|
+
next
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def feature_released_to_all?(key)
|
|
37
|
+
FeatureFlagger::Control::RELEASED_FEATURES == key
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def migrate_release(key)
|
|
41
|
+
return false if key =~ /(\d+).*/
|
|
42
|
+
|
|
43
|
+
resource_ids = @from_redis.smembers(key)
|
|
44
|
+
|
|
45
|
+
resource_name, feature_key = KeyDecomposer.decompose(key)
|
|
46
|
+
feature = Feature.new(feature_key, resource_name)
|
|
47
|
+
|
|
48
|
+
@to_control.release(feature.key, resource_name, resource_ids)
|
|
49
|
+
rescue KeyNotFoundError => _e
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'redis'
|
|
2
4
|
require 'redis-namespace'
|
|
3
5
|
|
|
4
6
|
module FeatureFlagger
|
|
5
7
|
module Storage
|
|
6
8
|
class Redis
|
|
7
|
-
|
|
8
9
|
DEFAULT_NAMESPACE = :feature_flagger
|
|
9
10
|
|
|
10
11
|
def initialize(redis)
|
|
@@ -13,24 +14,81 @@ module FeatureFlagger
|
|
|
13
14
|
|
|
14
15
|
def self.default_client
|
|
15
16
|
redis = ::Redis.new(url: ENV['REDIS_URL'])
|
|
16
|
-
ns = ::Redis::Namespace.new(DEFAULT_NAMESPACE, :
|
|
17
|
+
ns = ::Redis::Namespace.new(DEFAULT_NAMESPACE, redis: redis)
|
|
17
18
|
new(ns)
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
def has_value?(
|
|
21
|
-
@redis.sismember(
|
|
21
|
+
def has_value?(feature_key, resource_name, resource_id)
|
|
22
|
+
@redis.sismember("#{resource_name}:#{resource_id}", feature_key)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add(feature_key, resource_name, resource_ids)
|
|
26
|
+
@redis.multi do |redis|
|
|
27
|
+
Array(resource_ids).each do |resource_id|
|
|
28
|
+
redis.sadd("#{resource_name}:#{resource_id}", feature_key)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def remove(feature_key, resource_name, resource_ids)
|
|
34
|
+
@redis.multi do |redis|
|
|
35
|
+
Array(resource_ids).each do |resource_id|
|
|
36
|
+
redis.srem("#{resource_name}:#{resource_id}", feature_key)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def all_feature_keys(global_features_key, resource_name, resource_id = nil)
|
|
42
|
+
if resource_id
|
|
43
|
+
return @redis.sunion(
|
|
44
|
+
"#{resource_name}:#{global_features_key}",
|
|
45
|
+
"#{resource_name}:#{resource_id}"
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@redis.smembers("#{resource_name}:#{global_features_key}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def remove_all(feature_key, resource_name)
|
|
53
|
+
keys = search_keys("#{resource_name}:*")
|
|
54
|
+
|
|
55
|
+
@redis.multi do |redis|
|
|
56
|
+
keys.map do |key|
|
|
57
|
+
redis.srem(key, feature_key)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
22
60
|
end
|
|
23
61
|
|
|
24
|
-
def
|
|
25
|
-
|
|
62
|
+
def add_all(global_features_key, feature_key, resource_name)
|
|
63
|
+
keys = search_keys("#{resource_name}:*") - ["#{resource_name}:#{global_features_key}"]
|
|
64
|
+
|
|
65
|
+
add(feature_key, resource_name, global_features_key)
|
|
66
|
+
|
|
67
|
+
@redis.multi do |redis|
|
|
68
|
+
keys.map do |key|
|
|
69
|
+
redis.srem(key.to_s, feature_key)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
26
72
|
end
|
|
27
73
|
|
|
28
|
-
def
|
|
29
|
-
|
|
74
|
+
def all_values(feature_key, resource_name)
|
|
75
|
+
keys = search_keys("#{resource_name}:*")
|
|
76
|
+
query = {}
|
|
77
|
+
@redis.pipelined do |redis|
|
|
78
|
+
keys.map do |key|
|
|
79
|
+
query[key] = redis.sismember(key, feature_key)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
query.map do |key, in_redis|
|
|
84
|
+
next unless in_redis.value == true
|
|
85
|
+
|
|
86
|
+
key.gsub("#{resource_name}:", '')
|
|
87
|
+
end.compact
|
|
30
88
|
end
|
|
31
89
|
|
|
32
|
-
def
|
|
33
|
-
@redis.
|
|
90
|
+
def search_keys(pattern)
|
|
91
|
+
@redis.keys(pattern)
|
|
34
92
|
end
|
|
35
93
|
end
|
|
36
94
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
namespace :feature_flagger do
|
|
4
|
+
desc "cleaning up keys from storage that are no longer in the rollout.yml file, Usage: `$ bundle exec rake feature_flagger:cleanup_removed_rollouts\[Account\] `"
|
|
5
|
+
task :cleanup_removed_rollouts, %i[entity_name] => :environment do
|
|
6
|
+
resource_name = args.entity_name.constantize
|
|
7
|
+
feature_keys = FeatureFlagger::Manager.detached_feature_keys(resource_name)
|
|
8
|
+
puts "Found keys to remove: #{feature_keys}"
|
|
9
|
+
feature_keys.each do |feature_key|
|
|
10
|
+
FeatureFlagger::Manager.cleanup_detached(resource_name, feature_key)
|
|
11
|
+
rescue RuntimeError, 'key is still mapped'
|
|
12
|
+
next
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
namespace :storage do
|
|
17
|
+
namespace :redis do
|
|
18
|
+
desc 'Migrate the old key format to the new one, Usage: `$ bundle exec rake feature_flagger:storage:redis:migrate`'
|
|
19
|
+
task :migrate => :environment do |_, _args|
|
|
20
|
+
redis = ::Redis::Namespace.new(
|
|
21
|
+
FeatureFlagger::Storage::Redis::DEFAULT_NAMESPACE,
|
|
22
|
+
redis: ::Redis.new(url: ENV['REDIS_URL'])
|
|
23
|
+
)
|
|
24
|
+
control = FeatureFlagger.control
|
|
25
|
+
|
|
26
|
+
FeatureFlagger::Storage::FeatureKeysMigration.new(redis, control).call
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
desc "Release feature to given identifiers, Usage: `$ bundle exec rake feature_flagger:release\[Account,email_marketing:whitelabel,1,2,3,4\]`"
|
|
32
|
+
task :release, %i[entity_name feature_key] => :environment do |_, args|
|
|
33
|
+
entity = args.entity_name.constantize
|
|
34
|
+
entity_ids = args.extras
|
|
35
|
+
entity.release_id(entity_ids, *args.feature_key.split(':'))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
desc "Unrelease feature to given identifiers, Usage: `$ bundle exec rake feature_flagger:unrelease\[Account,email_marketing:whitelabel,1,2,3,4\]`"
|
|
39
|
+
task :unrelease, %i[entity_name feature_key] => :environment do |_, args|
|
|
40
|
+
entity = args.entity_name.constantize
|
|
41
|
+
entity_ids = args.extras
|
|
42
|
+
entity.unrelease_id(entity_ids, *args.feature_key.split(':'))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
desc "Release one feature to all entity ids, Usage: `$ bundle exec rake feature_flagger:release_to_all\[Account,email_marketing:whitelabel\]`"
|
|
46
|
+
task :release_to_all, %i[entity_name feature_key] => :environment do |_, args|
|
|
47
|
+
entity = args.entity_name.constantize
|
|
48
|
+
entity.release_to_all(*args.feature_key.split(':'))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
desc "Unrelease one feature to all entity ids, Usage: `$ bundle exec rake feature_flagger:unrelease_to_all\[Account,email_marketing:whitelabel\]`"
|
|
52
|
+
task :unrelease_to_all, %i[entity_name feature_key] => :environment do |_, args|
|
|
53
|
+
entity = args.entity_name.constantize
|
|
54
|
+
entity.unrelease_to_all(*args.feature_key.split(':'))
|
|
55
|
+
end
|
|
56
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: feature_flagger
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 2.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nando Sousa
|
|
@@ -9,64 +9,78 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: bin
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date:
|
|
12
|
+
date: 2020-06-15 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: redis
|
|
16
16
|
requirement: !ruby/object:Gem::Requirement
|
|
17
17
|
requirements:
|
|
18
|
-
- - "
|
|
18
|
+
- - ">"
|
|
19
19
|
- !ruby/object:Gem::Version
|
|
20
20
|
version: '3.2'
|
|
21
21
|
type: :runtime
|
|
22
22
|
prerelease: false
|
|
23
23
|
version_requirements: !ruby/object:Gem::Requirement
|
|
24
24
|
requirements:
|
|
25
|
-
- - "
|
|
25
|
+
- - ">"
|
|
26
26
|
- !ruby/object:Gem::Version
|
|
27
27
|
version: '3.2'
|
|
28
28
|
- !ruby/object:Gem::Dependency
|
|
29
29
|
name: redis-namespace
|
|
30
30
|
requirement: !ruby/object:Gem::Requirement
|
|
31
31
|
requirements:
|
|
32
|
-
- - "
|
|
32
|
+
- - ">"
|
|
33
33
|
- !ruby/object:Gem::Version
|
|
34
34
|
version: '1.3'
|
|
35
35
|
type: :runtime
|
|
36
36
|
prerelease: false
|
|
37
37
|
version_requirements: !ruby/object:Gem::Requirement
|
|
38
38
|
requirements:
|
|
39
|
-
- - "
|
|
39
|
+
- - ">"
|
|
40
40
|
- !ruby/object:Gem::Version
|
|
41
41
|
version: '1.3'
|
|
42
42
|
- !ruby/object:Gem::Dependency
|
|
43
43
|
name: bundler
|
|
44
44
|
requirement: !ruby/object:Gem::Requirement
|
|
45
45
|
requirements:
|
|
46
|
-
- - "
|
|
46
|
+
- - ">="
|
|
47
47
|
- !ruby/object:Gem::Version
|
|
48
|
-
version: '
|
|
48
|
+
version: '0'
|
|
49
49
|
type: :development
|
|
50
50
|
prerelease: false
|
|
51
51
|
version_requirements: !ruby/object:Gem::Requirement
|
|
52
52
|
requirements:
|
|
53
|
-
- - "
|
|
53
|
+
- - ">="
|
|
54
54
|
- !ruby/object:Gem::Version
|
|
55
|
-
version: '
|
|
55
|
+
version: '0'
|
|
56
|
+
- !ruby/object:Gem::Dependency
|
|
57
|
+
name: fakeredis
|
|
58
|
+
requirement: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - '='
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: 0.8.0
|
|
63
|
+
type: :development
|
|
64
|
+
prerelease: false
|
|
65
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - '='
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: 0.8.0
|
|
56
70
|
- !ruby/object:Gem::Dependency
|
|
57
71
|
name: rake
|
|
58
72
|
requirement: !ruby/object:Gem::Requirement
|
|
59
73
|
requirements:
|
|
60
74
|
- - "~>"
|
|
61
75
|
- !ruby/object:Gem::Version
|
|
62
|
-
version: '
|
|
76
|
+
version: '13.0'
|
|
63
77
|
type: :development
|
|
64
78
|
prerelease: false
|
|
65
79
|
version_requirements: !ruby/object:Gem::Requirement
|
|
66
80
|
requirements:
|
|
67
81
|
- - "~>"
|
|
68
82
|
- !ruby/object:Gem::Version
|
|
69
|
-
version: '
|
|
83
|
+
version: '13.0'
|
|
70
84
|
- !ruby/object:Gem::Dependency
|
|
71
85
|
name: rspec
|
|
72
86
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -82,19 +96,19 @@ dependencies:
|
|
|
82
96
|
- !ruby/object:Gem::Version
|
|
83
97
|
version: '3.0'
|
|
84
98
|
- !ruby/object:Gem::Dependency
|
|
85
|
-
name:
|
|
99
|
+
name: simplecov
|
|
86
100
|
requirement: !ruby/object:Gem::Requirement
|
|
87
101
|
requirements:
|
|
88
|
-
- -
|
|
102
|
+
- - '='
|
|
89
103
|
- !ruby/object:Gem::Version
|
|
90
|
-
version: '0'
|
|
104
|
+
version: '0.17'
|
|
91
105
|
type: :development
|
|
92
106
|
prerelease: false
|
|
93
107
|
version_requirements: !ruby/object:Gem::Requirement
|
|
94
108
|
requirements:
|
|
95
|
-
- -
|
|
109
|
+
- - '='
|
|
96
110
|
- !ruby/object:Gem::Version
|
|
97
|
-
version: '0'
|
|
111
|
+
version: '0.17'
|
|
98
112
|
description: Management tool to make it easier rollouting features to customers.
|
|
99
113
|
email:
|
|
100
114
|
- nandosousafr@gmail.com
|
|
@@ -108,10 +122,18 @@ files:
|
|
|
108
122
|
- lib/feature_flagger.rb
|
|
109
123
|
- lib/feature_flagger/configuration.rb
|
|
110
124
|
- lib/feature_flagger/control.rb
|
|
125
|
+
- lib/feature_flagger/core_ext.rb
|
|
111
126
|
- lib/feature_flagger/feature.rb
|
|
127
|
+
- lib/feature_flagger/key_decomposer.rb
|
|
128
|
+
- lib/feature_flagger/key_resolver.rb
|
|
129
|
+
- lib/feature_flagger/manager.rb
|
|
112
130
|
- lib/feature_flagger/model.rb
|
|
131
|
+
- lib/feature_flagger/model_settings.rb
|
|
132
|
+
- lib/feature_flagger/railtie.rb
|
|
133
|
+
- lib/feature_flagger/storage/feature_keys_migration.rb
|
|
113
134
|
- lib/feature_flagger/storage/redis.rb
|
|
114
135
|
- lib/feature_flagger/version.rb
|
|
136
|
+
- lib/tasks/feature_flagger.rake
|
|
115
137
|
homepage: http://github.com/ResultadosDigitais/feature_flagger
|
|
116
138
|
licenses:
|
|
117
139
|
- MIT
|
|
@@ -124,17 +146,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
124
146
|
requirements:
|
|
125
147
|
- - ">="
|
|
126
148
|
- !ruby/object:Gem::Version
|
|
127
|
-
version: '
|
|
149
|
+
version: '2.5'
|
|
128
150
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
129
151
|
requirements:
|
|
130
152
|
- - ">="
|
|
131
153
|
- !ruby/object:Gem::Version
|
|
132
|
-
version:
|
|
154
|
+
version: 2.0.0
|
|
133
155
|
requirements: []
|
|
134
|
-
|
|
135
|
-
rubygems_version: 2.4.8
|
|
156
|
+
rubygems_version: 3.1.2
|
|
136
157
|
signing_key:
|
|
137
158
|
specification_version: 4
|
|
138
159
|
summary: Partial release your features.
|
|
139
160
|
test_files: []
|
|
140
|
-
has_rdoc:
|