gitlab-experiment 0.4.2 → 0.4.7
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/README.md +34 -6
- data/lib/generators/gitlab/experiment/install/templates/initializer.rb.tt +3 -3
- data/lib/gitlab/experiment.rb +54 -33
- data/lib/gitlab/experiment/caching.rb +13 -7
- data/lib/gitlab/experiment/callbacks.rb +18 -15
- data/lib/gitlab/experiment/configuration.rb +2 -1
- data/lib/gitlab/experiment/context.rb +8 -0
- data/lib/gitlab/experiment/cookies.rb +1 -1
- data/lib/gitlab/experiment/rspec.rb +111 -0
- data/lib/gitlab/experiment/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a26425622afb7aa3f341ff403192963e276876b65902237018f852117168a19a
|
4
|
+
data.tar.gz: 0a5238c73a4b0217810b4f8161df6452b683b21a6573f1b657517302cef9cef0
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 567ca93e3f931c167bc1766490b07e1535db72e3b5da5986715412746354945ed799dab4c075e91c5015aa922bf5d7e5a82bdc940808aa8100c8897d5ee623b7
|
7
|
+
data.tar.gz: 3f053ed2ad8bdfc2201aacaef9c723332fba260f4f4cb54c65a92539d74ec100297540c34c97579c99723f0d98e98dc57cbe383a0731128d623b0a58b4786d7c
|
data/README.md
CHANGED
@@ -15,7 +15,7 @@ When we discuss the behavior of this gem, we'll use terms like experiment, conte
|
|
15
15
|
- `candidate` defines that there's one experimental code path.
|
16
16
|
- `variant(s)` is used when more than one experimental code path exists.
|
17
17
|
|
18
|
-
Candidate and variant are the same concept, but simplify how we speak about experimental paths.<br clear="all">
|
18
|
+
Candidate and variant are the same concept, but simplify how we speak about experimental paths -- this concept is also widely referred to as the "experiment group".<br clear="all">
|
19
19
|
|
20
20
|
[[_TOC_]]
|
21
21
|
|
@@ -100,6 +100,9 @@ Here are some examples of what you can introduce once you have a custom experime
|
|
100
100
|
|
101
101
|
```ruby
|
102
102
|
class NotificationToggleExperiment < ApplicationExperiment
|
103
|
+
# Exclude any users that aren't me.
|
104
|
+
exclude :all_but_me
|
105
|
+
|
103
106
|
# Segment any account less than 2 weeks old into the candidate, without
|
104
107
|
# asking the variant resolver to decide which variant to provide.
|
105
108
|
segment :account_age, variant: :candidate
|
@@ -107,17 +110,21 @@ class NotificationToggleExperiment < ApplicationExperiment
|
|
107
110
|
# Define the default control behavior, which can be overridden at
|
108
111
|
# experiment time.
|
109
112
|
def control_behavior
|
110
|
-
render_toggle
|
113
|
+
# render_toggle
|
111
114
|
end
|
112
115
|
|
113
116
|
# Define the default candidate behavior, which can be overridden
|
114
117
|
# at experiment time.
|
115
118
|
def candidate_behavior
|
116
|
-
render_button
|
119
|
+
# render_button
|
117
120
|
end
|
118
121
|
|
119
122
|
private
|
120
123
|
|
124
|
+
def all_but_me
|
125
|
+
context.actor&.username == 'jejacks0n'
|
126
|
+
end
|
127
|
+
|
121
128
|
def account_age
|
122
129
|
context.actor && context.actor.created_at < 2.weeks.ago
|
123
130
|
end
|
@@ -177,7 +184,7 @@ Generally, defining segmentation rules is a better way to approach routing into
|
|
177
184
|
experiment(:notification_toggle, :no_interface, actor: user) do |e|
|
178
185
|
e.use { render_toggle } # control
|
179
186
|
e.try { render_button } # candidate
|
180
|
-
e.try(:no_interface) { no_interface! } # variant
|
187
|
+
e.try(:no_interface) { no_interface! } # no_interface variant
|
181
188
|
end
|
182
189
|
```
|
183
190
|
|
@@ -222,6 +229,27 @@ When an experiment is run, the segmentation rules are executed in the order they
|
|
222
229
|
|
223
230
|
This means that any user with the name `'jejacks0n'`, regardless of account age, will always be provided the experience as defined in "variant_one".
|
224
231
|
|
232
|
+
### Exclusion rules
|
233
|
+
|
234
|
+
Exclusion rules are similar to segmentation rules, but are intended to determine if a context should even be considered as something we should track events towards. Exclusion means we don't care about the events in relation to the given context.
|
235
|
+
|
236
|
+
```ruby
|
237
|
+
class NotificationToggleExperiment < ApplicationExperiment
|
238
|
+
exclude { context.actor.username != 'jejacks0n' }
|
239
|
+
exclude { context.actor.created_at < 2.weeks.ago }
|
240
|
+
end
|
241
|
+
```
|
242
|
+
|
243
|
+
The previous examples will force all users with a username not matching `'jejacks0n'` and all newish users into the control. No events would be tracked for those.
|
244
|
+
|
245
|
+
You may need to check exclusion in custom tracking logic, by checking `excluded?` or wrapping your code in a callback block:
|
246
|
+
|
247
|
+
```ruby
|
248
|
+
run_callbacks(:exclusion_check) do
|
249
|
+
track(:my_event, value: expensive_method_call)
|
250
|
+
end
|
251
|
+
```
|
252
|
+
|
225
253
|
### Return value
|
226
254
|
|
227
255
|
By default the return value is a `Gitlab::Experiment` instance. In simple cases you may want only the results of the experiment though. You can call `run` within the block to get the return value of the assigned variant.
|
@@ -341,7 +369,7 @@ Gitlab::Experiment.configure do |config|
|
|
341
369
|
end
|
342
370
|
```
|
343
371
|
|
344
|
-
More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb).
|
372
|
+
More examples for configuration are available in the provided [rails initializer](lib/generators/gitlab/experiment/install/templates/initializer.rb.tt).
|
345
373
|
|
346
374
|
### Client layer / JavaScript
|
347
375
|
|
@@ -388,7 +416,7 @@ Each of these approaches could be desirable given the objectives of your experim
|
|
388
416
|
## Development
|
389
417
|
|
390
418
|
After checking out the repo, run `bundle install` to install dependencies.
|
391
|
-
Then, run `bundle exec
|
419
|
+
Then, run `bundle exec rake` to run the tests. You can also run `bundle exec pry` for an
|
392
420
|
interactive prompt that will allow you to experiment.
|
393
421
|
|
394
422
|
## Contributing
|
@@ -25,17 +25,17 @@ Gitlab::Experiment.configure do |config|
|
|
25
25
|
# Logic this project uses to resolve a variant for a given experiment.
|
26
26
|
#
|
27
27
|
# Should return a symbol or string that represents the variant that should
|
28
|
-
# be assigned.
|
28
|
+
# be assigned. Blank or nil values will be defaulted to the control.
|
29
29
|
#
|
30
30
|
# This block is executed within the scope of the experiment and so can access
|
31
31
|
# experiment methods, like `name`, `context`, and `signature`.
|
32
32
|
config.variant_resolver = lambda do |requested_variant|
|
33
33
|
# Run the control, unless a variant was requested in code:
|
34
|
-
requested_variant
|
34
|
+
requested_variant
|
35
35
|
|
36
36
|
# Run the candidate, unless a variant was requested, with a fallback:
|
37
37
|
#
|
38
|
-
# requested_variant || variant_names.first ||
|
38
|
+
# requested_variant || variant_names.first || nil
|
39
39
|
end
|
40
40
|
|
41
41
|
# Tracking behavior can be implemented to link an event to an experiment.
|
data/lib/gitlab/experiment.rb
CHANGED
@@ -30,7 +30,7 @@ module Gitlab
|
|
30
30
|
raise ArgumentError, 'name is required' if name.nil? && base?
|
31
31
|
|
32
32
|
instance = constantize(name).new(name, variant_name, **context, &block)
|
33
|
-
return instance unless
|
33
|
+
return instance unless block
|
34
34
|
|
35
35
|
instance.context.frozen? ? instance.run : instance.tap(&:run)
|
36
36
|
end
|
@@ -58,11 +58,9 @@ module Gitlab
|
|
58
58
|
raise ArgumentError, 'name is required' if name.blank? && self.class.base?
|
59
59
|
|
60
60
|
@name = self.class.experiment_name(name, suffix: false)
|
61
|
-
@
|
62
|
-
@
|
63
|
-
@context = Context.new(self, context)
|
61
|
+
@context = Context.new(self, **context)
|
62
|
+
@variant_name = cache_variant(variant_name) { nil } if variant_name.present?
|
64
63
|
|
65
|
-
exclude { !@context.trackable? }
|
66
64
|
compare { false }
|
67
65
|
|
68
66
|
yield self if block_given?
|
@@ -76,26 +74,31 @@ module Gitlab
|
|
76
74
|
end
|
77
75
|
|
78
76
|
def variant(value = nil)
|
77
|
+
if value.blank? && @variant_name || @resolving_variant
|
78
|
+
return Variant.new(name: (@variant_name || :unresolved).to_s)
|
79
|
+
end
|
80
|
+
|
79
81
|
@variant_name = value unless value.blank?
|
80
|
-
@variant_name ||= :control if excluded?
|
81
82
|
|
82
|
-
|
83
|
-
|
84
|
-
end
|
83
|
+
if enabled?
|
84
|
+
@variant_name ||= :control if excluded?
|
85
85
|
|
86
|
-
|
87
|
-
|
86
|
+
@resolving_variant = true
|
87
|
+
if (result = cache_variant(@variant_name) { resolve_variant_name }).present?
|
88
|
+
@variant_name = result.to_sym
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
Variant.new(name: (@variant_name || :control).to_s)
|
93
|
+
ensure
|
94
|
+
@resolving_variant = false
|
88
95
|
end
|
89
96
|
|
90
97
|
def run(variant_name = nil)
|
91
98
|
@result ||= begin
|
92
99
|
variant_name = variant(variant_name).name
|
93
|
-
run_callbacks(
|
94
|
-
|
95
|
-
behaviors[variant_name] ||= -> { send(behavior_name) } # rubocop:disable GitlabSecurity/PublicSend
|
96
|
-
end
|
97
|
-
|
98
|
-
super(@variant_name = variant_name)
|
100
|
+
run_callbacks(run_with_segmenting? ? :segmented_run : :unsegmented_run) do
|
101
|
+
super(@variant_name ||= variant_name)
|
99
102
|
end
|
100
103
|
end
|
101
104
|
end
|
@@ -105,7 +108,7 @@ module Gitlab
|
|
105
108
|
end
|
106
109
|
|
107
110
|
def track(action, **event_args)
|
108
|
-
return
|
111
|
+
return unless should_track?
|
109
112
|
|
110
113
|
instance_exec(action, event_args, &Configuration.tracking_behavior)
|
111
114
|
end
|
@@ -118,20 +121,22 @@ module Gitlab
|
|
118
121
|
@variant_names ||= behaviors.keys.map(&:to_sym) - [:control]
|
119
122
|
end
|
120
123
|
|
121
|
-
def
|
122
|
-
|
123
|
-
|
124
|
+
def behaviors
|
125
|
+
@behaviors ||= public_methods.each_with_object(super) do |name, behaviors|
|
126
|
+
next unless name.end_with?('_behavior')
|
124
127
|
|
125
|
-
|
126
|
-
|
128
|
+
behavior_name = name.to_s.sub(/_behavior$/, '')
|
129
|
+
behaviors[behavior_name] ||= -> { send(name) } # rubocop:disable GitlabSecurity/PublicSend
|
130
|
+
end
|
127
131
|
end
|
128
132
|
|
129
|
-
def
|
130
|
-
|
133
|
+
def try(name = nil, &block)
|
134
|
+
name = (name || 'candidate').to_s
|
135
|
+
behaviors[name] = block
|
131
136
|
end
|
132
137
|
|
133
|
-
def
|
134
|
-
|
138
|
+
def signature
|
139
|
+
{ variant: variant.name, experiment: name }.merge(context.signature)
|
135
140
|
end
|
136
141
|
|
137
142
|
def id
|
@@ -143,19 +148,35 @@ module Gitlab
|
|
143
148
|
"Experiment;#{id}"
|
144
149
|
end
|
145
150
|
|
151
|
+
def enabled?
|
152
|
+
true
|
153
|
+
end
|
154
|
+
|
155
|
+
def excluded?
|
156
|
+
@excluded ||= !@context.trackable? || # adhere to DNT headers
|
157
|
+
!run_callbacks(:exclusion_check) { :not_excluded } # didn't pass exclusion check
|
158
|
+
end
|
159
|
+
|
160
|
+
def should_track?
|
161
|
+
enabled? && !excluded?
|
162
|
+
end
|
163
|
+
|
146
164
|
def key_for(hash)
|
147
165
|
instance_exec(hash, &Configuration.context_hash_strategy)
|
148
166
|
end
|
149
167
|
|
150
168
|
protected
|
151
169
|
|
152
|
-
def
|
153
|
-
|
170
|
+
def run_with_segmenting?
|
171
|
+
!variant_assigned? && enabled? && !excluded?
|
172
|
+
end
|
173
|
+
|
174
|
+
def variant_assigned?
|
175
|
+
!@variant_name.nil?
|
176
|
+
end
|
154
177
|
|
155
|
-
|
156
|
-
|
157
|
-
@resolving = false
|
158
|
-
result
|
178
|
+
def resolve_variant_name
|
179
|
+
instance_exec(@variant_name, &Configuration.variant_resolver)
|
159
180
|
end
|
160
181
|
|
161
182
|
def generate_result(variant_name)
|
@@ -3,20 +3,26 @@
|
|
3
3
|
module Gitlab
|
4
4
|
class Experiment
|
5
5
|
module Caching
|
6
|
-
def
|
7
|
-
|
6
|
+
def cache_variant(specified = nil, &block)
|
7
|
+
cache = Configuration.cache
|
8
|
+
return (specified.presence || yield) unless cache
|
8
9
|
|
9
10
|
key, migrations = cache_strategy
|
10
|
-
migrated_cache(cache, migrations || [], key)
|
11
|
+
result = migrated_cache(cache, migrations || [], key) || cache.fetch(key, &block)
|
12
|
+
return result unless specified.present?
|
13
|
+
|
14
|
+
cache.write(cache_key, specified) if result != specified
|
15
|
+
specified
|
16
|
+
end
|
17
|
+
|
18
|
+
def cache_key(key = nil)
|
19
|
+
"#{name}:#{key || context.signature[:key]}"
|
11
20
|
end
|
12
21
|
|
13
22
|
private
|
14
23
|
|
15
24
|
def cache_strategy
|
16
|
-
[
|
17
|
-
"#{name}:#{context.signature[:key]}",
|
18
|
-
context.signature[:migration_keys]&.map { |key| "#{name}:#{key}" }
|
19
|
-
]
|
25
|
+
[cache_key, context.signature[:migration_keys]&.map { |key| cache_key(key) }]
|
20
26
|
end
|
21
27
|
|
22
28
|
def migrated_cache(cache, migrations, new_key)
|
@@ -7,31 +7,34 @@ module Gitlab
|
|
7
7
|
include ActiveSupport::Callbacks
|
8
8
|
|
9
9
|
included do
|
10
|
-
define_callbacks(
|
11
|
-
|
12
|
-
|
13
|
-
)
|
14
|
-
|
15
|
-
define_callbacks(
|
16
|
-
:segmented_run,
|
17
|
-
skip_after_callbacks_if_terminated: false,
|
18
|
-
terminator: lambda do |target, result_lambda|
|
19
|
-
result_lambda.call
|
20
|
-
target.variant_assigned?
|
21
|
-
end
|
22
|
-
)
|
10
|
+
define_callbacks(:unsegmented_run)
|
11
|
+
define_callbacks(:segmented_run)
|
12
|
+
define_callbacks(:exclusion_check, skip_after_callbacks_if_terminated: true)
|
23
13
|
end
|
24
14
|
|
25
15
|
class_methods do
|
16
|
+
def exclude(*filter_list, **options, &block)
|
17
|
+
filters = filter_list.unshift(block).compact.map do |filter|
|
18
|
+
result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
|
19
|
+
lambda do |target|
|
20
|
+
throw(:abort) if target.instance_variable_get(:'@excluded') || result_lambda.call(target, nil) == true
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
raise ArgumentError, 'no filters provided' if filters.empty?
|
25
|
+
|
26
|
+
set_callback(:exclusion_check, :before, *filters, options)
|
27
|
+
end
|
28
|
+
|
26
29
|
def segment(*filter_list, variant:, **options, &block)
|
27
30
|
filters = filter_list.unshift(block).compact.map do |filter|
|
28
31
|
result_lambda = ActiveSupport::Callbacks::CallTemplate.build(filter, self).make_lambda
|
29
|
-
->(target) { target.variant(variant) if result_lambda.call(target, nil) }
|
32
|
+
->(target) { target.variant(variant) if !target.variant_assigned? && result_lambda.call(target, nil) }
|
30
33
|
end
|
31
34
|
|
32
35
|
raise ArgumentError, 'no filters provided' if filters.empty?
|
33
36
|
|
34
|
-
set_callback(:segmented_run, :before, *filters, options
|
37
|
+
set_callback(:segmented_run, :before, *filters, options)
|
35
38
|
end
|
36
39
|
end
|
37
40
|
end
|
@@ -25,8 +25,9 @@ module Gitlab
|
|
25
25
|
@cookie_domain = :all
|
26
26
|
|
27
27
|
# Logic this project uses to resolve a variant for a given experiment.
|
28
|
+
# If no variant is determined, the control will be used.
|
28
29
|
@variant_resolver = lambda do |requested_variant|
|
29
|
-
requested_variant
|
30
|
+
requested_variant
|
30
31
|
end
|
31
32
|
|
32
33
|
# Tracking behavior can be implemented to link an event to an experiment.
|
@@ -42,6 +42,14 @@ module Gitlab
|
|
42
42
|
@signature ||= { key: @experiment.key_for(@value), migration_keys: migration_keys }.compact
|
43
43
|
end
|
44
44
|
|
45
|
+
def method_missing(method_name, *)
|
46
|
+
@value.include?(method_name.to_sym) ? @value[method_name.to_sym] : super
|
47
|
+
end
|
48
|
+
|
49
|
+
def respond_to_missing?(method_name, *)
|
50
|
+
@value.include?(method_name.to_sym) ? true : super
|
51
|
+
end
|
52
|
+
|
45
53
|
private
|
46
54
|
|
47
55
|
def process_migrations(value)
|
@@ -11,7 +11,7 @@ module Gitlab
|
|
11
11
|
return hash if cookie_jar.nil?
|
12
12
|
|
13
13
|
resolver = [hash, :actor, cookie_name, cookie_jar.signed[cookie_name]]
|
14
|
-
resolve_cookie(*resolver)
|
14
|
+
resolve_cookie(*resolver) || generate_cookie(*resolver)
|
15
15
|
end
|
16
16
|
|
17
17
|
def cookie_jar
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Gitlab
|
4
|
+
class Experiment
|
5
|
+
module RSpecHelpers
|
6
|
+
def stub_experiments(experiments)
|
7
|
+
experiments.each do |name, variant|
|
8
|
+
variant = :control if variant == false
|
9
|
+
raise ArgumentError, 'variant must be a symbol or false' unless variant.is_a?(Symbol)
|
10
|
+
|
11
|
+
klass = Gitlab::Experiment.send(:constantize, name) # rubocop:disable GitlabSecurity/PublicSend
|
12
|
+
allow(klass).to receive(:new).and_wrap_original do |new, *args, &block|
|
13
|
+
new.call(*args).tap do |instance|
|
14
|
+
allow(instance).to receive(:enabled?).and_return(true)
|
15
|
+
allow(instance).to receive(:resolve_variant_name).and_return(variant.to_s)
|
16
|
+
|
17
|
+
block.call(instance) if block.present?
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
module RSpecMatchers
|
25
|
+
extend RSpec::Matchers::DSL
|
26
|
+
|
27
|
+
matcher :exclude do |context|
|
28
|
+
ivar = :'@excluded'
|
29
|
+
|
30
|
+
match do |experiment|
|
31
|
+
experiment.context(context)
|
32
|
+
|
33
|
+
experiment.instance_variable_set(ivar, nil)
|
34
|
+
!experiment.run_callbacks(:exclusion_check) { :not_excluded }
|
35
|
+
end
|
36
|
+
|
37
|
+
failure_message do
|
38
|
+
%(expected #{context} to be excluded)
|
39
|
+
end
|
40
|
+
|
41
|
+
failure_message_when_negated do
|
42
|
+
%(expected #{context} not to be excluded)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
matcher :segment do |context|
|
47
|
+
ivar = :'@variant_name'
|
48
|
+
|
49
|
+
match do |experiment|
|
50
|
+
experiment.context(context)
|
51
|
+
|
52
|
+
experiment.instance_variable_set(ivar, nil)
|
53
|
+
experiment.run_callbacks(:segmented_run)
|
54
|
+
|
55
|
+
@actual = experiment.instance_variable_get(ivar)
|
56
|
+
@expected ? @actual.to_s == @expected.to_s : @actual.present?
|
57
|
+
end
|
58
|
+
|
59
|
+
chain :into do |expected|
|
60
|
+
raise ArgumentError, 'variant name must be provided' if expected.blank?
|
61
|
+
|
62
|
+
@expected = expected.to_s
|
63
|
+
end
|
64
|
+
|
65
|
+
failure_message do
|
66
|
+
%(expected #{context} to be segmented#{message_details})
|
67
|
+
end
|
68
|
+
|
69
|
+
failure_message_when_negated do
|
70
|
+
%(expected #{context} not to be segmented#{message_details})
|
71
|
+
end
|
72
|
+
|
73
|
+
def message_details
|
74
|
+
message = ''
|
75
|
+
message += %(\n into: #{@expected}) if @expected
|
76
|
+
message += %(\n actual: #{@actual}) if @actual
|
77
|
+
message
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
matcher :track do |event, *event_args|
|
82
|
+
match do |experiment|
|
83
|
+
wrapped(experiment) { |instance| expect(instance).to receive_with(event, *event_args) }
|
84
|
+
end
|
85
|
+
|
86
|
+
match_when_negated do |experiment|
|
87
|
+
wrapped(experiment) { |instance| expect(instance).not_to receive_with(event, *event_args) }
|
88
|
+
end
|
89
|
+
|
90
|
+
def wrapped(experiment, &block)
|
91
|
+
allow(experiment.class).to receive(:new).and_wrap_original do |new, *new_args|
|
92
|
+
new.call(*new_args).tap(&block)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def receive_with(*args)
|
97
|
+
receive(:track).with(*args) # rubocop:disable CodeReuse/ActiveRecord
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
RSpec.configure do |config|
|
105
|
+
config.include Gitlab::Experiment::RSpecHelpers
|
106
|
+
|
107
|
+
config.include Gitlab::Experiment::RSpecMatchers, :experiment
|
108
|
+
config.define_derived_metadata(file_path: %r{/spec/experiments/}) do |metadata|
|
109
|
+
metadata[:type] = :experiment
|
110
|
+
end
|
111
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: gitlab-experiment
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.4.
|
4
|
+
version: 0.4.7
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- GitLab
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-01-22 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activesupport
|
@@ -72,6 +72,7 @@ files:
|
|
72
72
|
- lib/gitlab/experiment/cookies.rb
|
73
73
|
- lib/gitlab/experiment/dsl.rb
|
74
74
|
- lib/gitlab/experiment/engine.rb
|
75
|
+
- lib/gitlab/experiment/rspec.rb
|
75
76
|
- lib/gitlab/experiment/variant.rb
|
76
77
|
- lib/gitlab/experiment/version.rb
|
77
78
|
homepage: https://gitlab.com/gitlab-org/gitlab-experiment
|