Commit f5ccac0d authored by Nicolas Dular's avatar Nicolas Dular

Add experiment for pipeline empty state

This adds an experiment where we change the text onthe pipeline zero
state page (when there is no pipeline created yet).

We want to know if there is an overall increase in pipeline creation,
that's why we also track the pipelines created.
parent d06e9696
<script> <script>
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { isExperimentEnabled } from '~/lib/utils/experimentation';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
export default { export default {
i18n: {
control: {
infoMessage: s__(`Pipelines|Continuous Integration can help
catch bugs by running your tests automatically,
while Continuous Deployment can help you deliver
code to your product environment.`),
buttonMessage: s__('Pipelines|Get started with Pipelines'),
},
experiment: {
infoMessage: s__(`Pipelines|GitLab CI/CD can automatically build,
test, and deploy your code. Let GitLab take care of time
consuming tasks, so you can spend more time creating.`),
buttonMessage: s__('Pipelines|Get started with CI/CD'),
},
},
name: 'PipelinesEmptyState', name: 'PipelinesEmptyState',
components: { components: {
GlButton, GlButton,
...@@ -20,6 +38,23 @@ export default { ...@@ -20,6 +38,23 @@ export default {
required: true, required: true,
}, },
}, },
mounted() {
this.track('viewed');
},
methods: {
track(action) {
if (!gon.tracking_data) {
return;
}
const { category, value, label, property } = gon.tracking_data;
Tracking.event(category, action, { value, label, property });
},
isExperimentEnabled() {
return isExperimentEnabled('pipelinesEmptyState');
},
},
}; };
</script> </script>
<template> <template>
...@@ -29,18 +64,16 @@ export default { ...@@ -29,18 +64,16 @@ export default {
</div> </div>
<div class="col-12"> <div class="col-12">
<div class="gl-text-content"> <div class="text-content">
<template v-if="canSetCi"> <template v-if="canSetCi">
<h4 class="gl-text-center" data-testid="header-text"> <h4 data-testid="header-text" class="gl-text-center">
{{ s__('Pipelines|Build with confidence') }} {{ s__('Pipelines|Build with confidence') }}
</h4> </h4>
<p data-testid="info-text"> <p data-testid="info-text">
{{ {{
s__(`Pipelines|Continuous Integration can help isExperimentEnabled()
catch bugs by running your tests automatically, ? $options.i18n.experiment.infoMessage
while Continuous Deployment can help you deliver : $options.i18n.control.infoMessage
code to your product environment.`)
}} }}
</p> </p>
...@@ -50,8 +83,13 @@ export default { ...@@ -50,8 +83,13 @@ export default {
variant="info" variant="info"
category="primary" category="primary"
data-testid="get-started-pipelines" data-testid="get-started-pipelines"
@click="track('documentation_clicked')"
> >
{{ s__('Pipelines|Get started with Pipelines') }} {{
isExperimentEnabled()
? $options.i18n.experiment.buttonMessage
: $options.i18n.control.buttonMessage
}}
</gl-button> </gl-button>
</div> </div>
</template> </template>
......
...@@ -21,6 +21,7 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -21,6 +21,7 @@ class Projects::PipelinesController < Projects::ApplicationController
push_frontend_feature_flag(:new_pipeline_form_prefilled_vars, project, type: :development, default_enabled: true) push_frontend_feature_flag(:new_pipeline_form_prefilled_vars, project, type: :development, default_enabled: true)
end end
before_action :ensure_pipeline, only: [:show] before_action :ensure_pipeline, only: [:show]
before_action :push_experiment_to_gon, only: :index, if: :html_request?
# Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596 # Will be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/225596
before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? } before_action :redirect_for_legacy_scope_filter, only: [:index], if: -> { request.format.html? }
...@@ -45,7 +46,11 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -45,7 +46,11 @@ class Projects::PipelinesController < Projects::ApplicationController
@pipelines_count = limited_pipelines_count(project) @pipelines_count = limited_pipelines_count(project)
respond_to do |format| respond_to do |format|
format.html format.html do
record_empty_pipeline_experiment
render :index
end
format.json do format.json do
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
...@@ -313,6 +318,20 @@ class Projects::PipelinesController < Projects::ApplicationController ...@@ -313,6 +318,20 @@ class Projects::PipelinesController < Projects::ApplicationController
def index_params def index_params
params.permit(:scope, :username, :ref, :status) params.permit(:scope, :username, :ref, :status)
end end
def record_empty_pipeline_experiment
return unless @pipelines_count.to_i == 0
return if helpers.has_gitlab_ci?(@project)
record_experiment_user(:pipelines_empty_state)
end
def push_experiment_to_gon
return unless current_user
push_frontend_experiment(:pipelines_empty_state, subject: current_user)
frontend_experimentation_tracking_data(:pipelines_empty_state, 'view', project.namespace_id, subject: current_user)
end
end end
Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController') Projects::PipelinesController.prepend_if_ee('EE::Projects::PipelinesController')
...@@ -26,6 +26,10 @@ module Ci ...@@ -26,6 +26,10 @@ module Ci
_("%{message} showing first %{warnings_displayed}") % { message: message, warnings_displayed: MAX_LIMIT } _("%{message} showing first %{warnings_displayed}") % { message: message, warnings_displayed: MAX_LIMIT }
end end
def has_gitlab_ci?(project)
project.has_ci? && project.builds_enabled?
end
private private
def warning_markdown(pipeline) def warning_markdown(pipeline)
......
...@@ -121,6 +121,7 @@ module Ci ...@@ -121,6 +121,7 @@ module Ci
def record_conversion_event def record_conversion_event
Experiments::RecordConversionEventWorker.perform_async(:ci_syntax_templates, current_user.id) Experiments::RecordConversionEventWorker.perform_async(:ci_syntax_templates, current_user.id)
Experiments::RecordConversionEventWorker.perform_async(:pipelines_empty_state, current_user.id)
end end
def extra_options(content: nil, dry_run: false) def extra_options(content: nil, dry_run: false)
......
...@@ -17,4 +17,4 @@ ...@@ -17,4 +17,4 @@
"new-pipeline-path" => can?(current_user, :create_pipeline, @project) && new_project_pipeline_path(@project), "new-pipeline-path" => can?(current_user, :create_pipeline, @project) && new_project_pipeline_path(@project),
"ci-lint-path" => can?(current_user, :create_pipeline, @project) && project_ci_lint_path(@project), "ci-lint-path" => can?(current_user, :create_pipeline, @project) && project_ci_lint_path(@project),
"reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project) , "reset-cache-path" => can?(current_user, :admin_pipeline, @project) && reset_cache_project_settings_ci_cd_path(@project) ,
"has-gitlab-ci" => (@project.has_ci? && @project.builds_enabled?).to_s } } "has-gitlab-ci" => has_gitlab_ci?(@project).to_s } }
...@@ -93,6 +93,9 @@ module Gitlab ...@@ -93,6 +93,9 @@ module Gitlab
}, },
ci_syntax_templates: { ci_syntax_templates: {
tracking_category: 'Growth::Activation::Experiment::CiSyntaxTemplates' tracking_category: 'Growth::Activation::Experiment::CiSyntaxTemplates'
},
pipelines_empty_state: {
tracking_category: 'Growth::Activation::Experiment::PipelinesEmptyState'
} }
}.freeze }.freeze
......
...@@ -20458,9 +20458,15 @@ msgstr "" ...@@ -20458,9 +20458,15 @@ msgstr ""
msgid "Pipelines|Editor" msgid "Pipelines|Editor"
msgstr "" msgstr ""
msgid "Pipelines|Get started with CI/CD"
msgstr ""
msgid "Pipelines|Get started with Pipelines" msgid "Pipelines|Get started with Pipelines"
msgstr "" msgstr ""
msgid "Pipelines|GitLab CI/CD can automatically build, test, and deploy your code. Let GitLab take care of time consuming tasks, so you can spend more time creating."
msgstr ""
msgid "Pipelines|Group %{namespace_name} has %{percentage}%% or less Shared Runner Pipeline minutes remaining. Once it runs out, no new jobs or pipelines in its projects will run." msgid "Pipelines|Group %{namespace_name} has %{percentage}%% or less Shared Runner Pipeline minutes remaining. Once it runs out, no new jobs or pipelines in its projects will run."
msgstr "" msgstr ""
......
...@@ -272,6 +272,72 @@ RSpec.describe Projects::PipelinesController do ...@@ -272,6 +272,72 @@ RSpec.describe Projects::PipelinesController do
end end
end end
describe 'GET #index' do
subject(:request) { get :index, params: { namespace_id: project.namespace, project_id: project } }
context 'experiment not active' do
it 'does not push tracking_data to gon' do
request
expect(Gon.tracking_data).to be_nil
end
it 'does not record experiment_user' do
expect { request }.not_to change(ExperimentUser, :count)
end
end
context 'when experiment active' do
before do
stub_experiment(pipelines_empty_state: true)
stub_experiment_for_subject(pipelines_empty_state: true)
end
it 'pushes tracking_data to Gon' do
request
expect(Gon.experiments["pipelinesEmptyState"]).to eq(true)
expect(Gon.tracking_data).to match(
{
category: 'Growth::Activation::Experiment::PipelinesEmptyState',
action: 'view',
label: anything,
property: 'experimental_group',
value: anything
}
)
end
context 'no pipelines created an no CI set up' do
before do
stub_application_setting(auto_devops_enabled: false)
end
it 'records experiment_user' do
expect { request }.to change(ExperimentUser, :count).by(1)
end
end
context 'CI set up' do
it 'does not record experiment_user' do
expect { request }.not_to change(ExperimentUser, :count)
end
end
context 'pipelines created' do
let!(:pipeline) { create(:ci_pipeline, project: project) }
before do
stub_application_setting(auto_devops_enabled: false)
end
it 'does not record experiment_user' do
expect { request }.not_to change(ExperimentUser, :count)
end
end
end
end
describe 'GET show.json' do describe 'GET show.json' do
let(:pipeline) { create(:ci_pipeline, project: project) } let(:pipeline) { create(:ci_pipeline, project: project) }
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { withGonExperiment } from 'helpers/experimentation_helper';
import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue'; import EmptyState from '~/pipelines/components/pipelines_list/empty_state.vue';
import Tracking from '~/tracking';
describe('Pipelines Empty State', () => { describe('Pipelines Empty State', () => {
let wrapper; let wrapper;
...@@ -38,15 +40,104 @@ describe('Pipelines Empty State', () => { ...@@ -38,15 +40,104 @@ describe('Pipelines Empty State', () => {
expect(findGetStartedButton().attributes('href')).toBe('foo'); expect(findGetStartedButton().attributes('href')).toBe('foo');
}); });
it('should render empty state information', () => { describe('when in control group', () => {
expect(findInfoText()).toContain( it('should render empty state information', () => {
'Continuous Integration can help catch bugs by running your tests automatically', expect(findInfoText()).toContain(
'while Continuous Deployment can help you deliver code to your product environment', 'Continuous Integration can help catch bugs by running your tests automatically',
); 'while Continuous Deployment can help you deliver code to your product environment',
);
});
it('should render a button', () => {
expect(findGetStartedButton().text()).toBe('Get started with Pipelines');
});
});
describe('when in experiment group', () => {
withGonExperiment('pipelinesEmptyState');
beforeEach(() => {
createWrapper();
});
it('should render empty state information', () => {
expect(findInfoText()).toContain(
'GitLab CI/CD can automatically build, test, and deploy your code. Let GitLab take care of time',
'consuming tasks, so you can spend more time creating',
);
});
it('should render button text', () => {
expect(findGetStartedButton().text()).toBe('Get started with CI/CD');
});
}); });
it('should render a button', () => { describe('tracking', () => {
expect(findGetStartedButton().text()).toBe('Get started with Pipelines'); let origGon;
describe('when data is set', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event').mockImplementation(() => {});
origGon = window.gon;
window.gon = {
tracking_data: {
category: 'Growth::Activation::Experiment::PipelinesEmptyState',
value: 1,
property: 'experimental_group',
label: 'label',
},
};
createWrapper();
});
afterEach(() => {
window.gon = origGon;
});
it('tracks when mounted', () => {
expect(Tracking.event).toHaveBeenCalledWith(
'Growth::Activation::Experiment::PipelinesEmptyState',
'viewed',
{
value: 1,
label: 'label',
property: 'experimental_group',
},
);
});
it('tracks when button is clicked', () => {
findGetStartedButton().vm.$emit('click');
expect(Tracking.event).toHaveBeenCalledWith(
'Growth::Activation::Experiment::PipelinesEmptyState',
'documentation_clicked',
{
value: 1,
label: 'label',
property: 'experimental_group',
},
);
});
});
describe('when no data is defined', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event').mockImplementation(() => {});
createWrapper();
});
it('does not track on view', () => {
expect(Tracking.event).not.toHaveBeenCalled();
});
it('does not track when button is clicked', () => {
findGetStartedButton().vm.$emit('click');
expect(Tracking.event).not.toHaveBeenCalled();
});
});
}); });
}); });
}); });
...@@ -52,4 +52,23 @@ RSpec.describe Ci::PipelinesHelper do ...@@ -52,4 +52,23 @@ RSpec.describe Ci::PipelinesHelper do
end end
end end
end end
describe 'has_gitlab_ci?' do
using RSpec::Parameterized::TableSyntax
subject(:has_gitlab_ci?) { helper.has_gitlab_ci?(project) }
let(:project) { double(:project, has_ci?: has_ci?, builds_enabled?: builds_enabled?) }
where(:builds_enabled?, :has_ci?, :result) do
true | true | true
true | false | false
false | true | false
false | false | false
end
with_them do
it { expect(has_gitlab_ci?).to eq(result) }
end
end
end end
...@@ -94,6 +94,7 @@ RSpec.describe Ci::CreatePipelineService do ...@@ -94,6 +94,7 @@ RSpec.describe Ci::CreatePipelineService do
describe 'recording a conversion event' do describe 'recording a conversion event' do
it 'schedules a record conversion event worker' do it 'schedules a record conversion event worker' do
expect(Experiments::RecordConversionEventWorker).to receive(:perform_async).with(:ci_syntax_templates, user.id) expect(Experiments::RecordConversionEventWorker).to receive(:perform_async).with(:ci_syntax_templates, user.id)
expect(Experiments::RecordConversionEventWorker).to receive(:perform_async).with(:pipelines_empty_state, user.id)
pipeline pipeline
end end
......
...@@ -12,10 +12,14 @@ RSpec.describe Experiments::RecordConversionEventWorker, '#perform' do ...@@ -12,10 +12,14 @@ RSpec.describe Experiments::RecordConversionEventWorker, '#perform' do
context 'when the experiment is active' do context 'when the experiment is active' do
let(:experiment_active) { true } let(:experiment_active) { true }
it 'records the event' do include_examples 'an idempotent worker' do
expect(Experiment).to receive(:record_conversion_event).with(:experiment_key, 1234) subject { perform }
perform it 'records the event' do
expect(Experiment).to receive(:record_conversion_event).with(:experiment_key, 1234)
perform
end
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment