Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-02-01 21:09:15 +00:00
parent d7774ee304
commit 3feea9b607
36 changed files with 366 additions and 214 deletions

View File

@ -1,12 +1,17 @@
<script> <script>
import { GlAvatarLink, GlAvatarLabeled, GlBadge } from '@gitlab/ui'; import { GlAvatarLink, GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { USER_AVATAR_SIZE } from '../constants'; import { truncate } from '~/lib/utils/text_utility';
import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from '../constants';
export default { export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: { components: {
GlAvatarLink, GlAvatarLink,
GlAvatarLabeled, GlAvatarLabeled,
GlBadge, GlBadge,
GlIcon,
}, },
props: { props: {
user: { user: {
@ -22,6 +27,9 @@ export default {
adminUserHref() { adminUserHref() {
return this.adminUserPath.replace('id', this.user.username); return this.adminUserPath.replace('id', this.user.username);
}, },
userNoteShort() {
return truncate(this.user.note, LENGTH_OF_USER_NOTE_TOOLTIP);
},
}, },
USER_AVATAR_SIZE, USER_AVATAR_SIZE,
}; };
@ -42,6 +50,9 @@ export default {
:sub-label="user.email" :sub-label="user.email"
> >
<template #meta> <template #meta>
<div v-if="user.note" class="gl-text-gray-500 gl-p-1">
<gl-icon v-gl-tooltip="userNoteShort" name="document" />
</div>
<div v-for="(badge, idx) in user.badges" :key="idx" class="gl-p-1"> <div v-for="(badge, idx) in user.badges" :key="idx" class="gl-p-1">
<gl-badge class="gl-display-flex!" size="sm" :variant="badge.variant">{{ <gl-badge class="gl-display-flex!" size="sm" :variant="badge.variant">{{
badge.text badge.text

View File

@ -1,3 +1,5 @@
export const USER_AVATAR_SIZE = 32; export const USER_AVATAR_SIZE = 32;
export const SHORT_DATE_FORMAT = 'd mmm, yyyy'; export const SHORT_DATE_FORMAT = 'd mmm, yyyy';
export const LENGTH_OF_USER_NOTE_TOOLTIP = 100;

View File

@ -1,7 +1,7 @@
import { isEmpty, isString } from 'lodash'; import { isEmpty, isString } from 'lodash';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
export const headerTime = (state) => (state.job.started ? state.job.started : state.job.created_at); export const headerTime = (state) => state.job.started ?? state.job.created_at;
export const hasForwardDeploymentFailure = (state) => export const hasForwardDeploymentFailure = (state) =>
state?.job?.failure_reason === 'forward_deployment_failure'; state?.job?.failure_reason === 'forward_deployment_failure';
@ -28,11 +28,9 @@ export const hasEnvironment = (state) => !isEmpty(state.job.deployment_status);
export const hasTrace = (state) => export const hasTrace = (state) =>
state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running'); state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running');
export const emptyStateIllustration = (state) => export const emptyStateIllustration = (state) => state?.job?.status?.illustration || {};
(state.job && state.job.status && state.job.status.illustration) || {};
export const emptyStateAction = (state) => export const emptyStateAction = (state) => state?.job?.status?.action || null;
(state.job && state.job.status && state.job.status.action) || null;
/** /**
* Shared runners limit is only rendered when * Shared runners limit is only rendered when
@ -48,4 +46,4 @@ export const shouldRenderSharedRunnerLimitWarning = (state) =>
export const isScrollingDown = (state) => isScrolledToBottom() && !state.isTraceComplete; export const isScrollingDown = (state) => isScrolledToBottom() && !state.isTraceComplete;
export const hasRunnersForProject = (state) => export const hasRunnersForProject = (state) =>
state.job.runners.available && !state.job.runners.online; state?.job?.runners?.available && !state?.job?.runners?.online;

View File

@ -15,7 +15,7 @@ module Registrations
if current_user.save if current_user.save
hide_advanced_issues hide_advanced_issues
if experiment_enabled?(:default_to_issues_board) && learn_gitlab.available? if learn_gitlab.available?
redirect_to namespace_project_board_path(params[:namespace_path], learn_gitlab.project, learn_gitlab.board) redirect_to namespace_project_board_path(params[:namespace_path], learn_gitlab.project, learn_gitlab.board)
else else
redirect_to group_path(params[:namespace_path]) redirect_to group_path(params[:namespace_path])

View File

@ -26,42 +26,23 @@ class UserRecentEventsFinder
@params = params @params = params
end end
# rubocop: disable CodeReuse/ActiveRecord
def execute def execute
return Event.none unless can?(current_user, :read_user_profile, target_user) return Event.none unless can?(current_user, :read_user_profile, target_user)
recent_events(params[:offset] || 0) target_events
.joins(:project)
.with_associations .with_associations
.limit_recent(limit, params[:offset]) .limit_recent(limit, params[:offset])
.order_created_desc
end end
# rubocop: enable CodeReuse/ActiveRecord
private private
# rubocop: disable CodeReuse/ActiveRecord
def recent_events(offset)
sql = <<~SQL
(#{projects}) AS projects_for_join
JOIN (#{target_events.to_sql}) AS #{Event.table_name}
ON #{Event.table_name}.project_id = projects_for_join.id
SQL
# Workaround for https://github.com/rails/rails/issues/24193
Event.from([Arel.sql(sql)])
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def target_events def target_events
Event.where(author: target_user) Event.where(author: target_user)
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
def projects
target_user.project_interactions.to_sql
end
def limit def limit
return DEFAULT_LIMIT unless params[:limit].present? return DEFAULT_LIMIT unless params[:limit].present?

View File

@ -7,19 +7,19 @@ class UserCallout < ApplicationRecord
gke_cluster_integration: 1, gke_cluster_integration: 1,
gcp_signup_offer: 2, gcp_signup_offer: 2,
cluster_security_warning: 3, cluster_security_warning: 3,
gold_trial: 4, # EE-only gold_trial: 4, # EE-only
geo_enable_hashed_storage: 5, # EE-only geo_enable_hashed_storage: 5, # EE-only
geo_migrate_hashed_storage: 6, # EE-only geo_migrate_hashed_storage: 6, # EE-only
canary_deployment: 7, # EE-only canary_deployment: 7, # EE-only
gold_trial_billings: 8, # EE-only gold_trial_billings: 8, # EE-only
suggest_popover_dismissed: 9, suggest_popover_dismissed: 9,
tabs_position_highlight: 10, tabs_position_highlight: 10,
threat_monitoring_info: 11, # EE-only threat_monitoring_info: 11, # EE-only
account_recovery_regular_check: 12, # EE-only account_recovery_regular_check: 12, # EE-only
webhooks_moved: 13, webhooks_moved: 13,
service_templates_deprecated: 14, service_templates_deprecated: 14,
admin_integrations_moved: 15, admin_integrations_moved: 15,
web_ide_alert_dismissed: 16, # no longer in use web_ide_alert_dismissed: 16, # no longer in use
active_user_count_threshold: 18, # EE-only active_user_count_threshold: 18, # EE-only
buy_pipeline_minutes_notification_dot: 19, # EE-only buy_pipeline_minutes_notification_dot: 19, # EE-only
personal_access_token_expiry: 21, # EE-only personal_access_token_expiry: 21, # EE-only
@ -27,8 +27,9 @@ class UserCallout < ApplicationRecord
customize_homepage: 23, customize_homepage: 23,
feature_flags_new_version: 24, feature_flags_new_version: 24,
registration_enabled_callout: 25, registration_enabled_callout: 25,
new_user_signups_cap_reached: 26, # EE-only new_user_signups_cap_reached: 26, # EE-only
unfinished_tag_cleanup_callout: 27 unfinished_tag_cleanup_callout: 27,
eoa_bronze_plan_banner: 28 # EE-only
} }
validates :user, presence: true validates :user, presence: true

View File

@ -10,6 +10,7 @@ module Admin
expose :email expose :email
expose :last_activity_on expose :last_activity_on
expose :avatar_url expose :avatar_url
expose :note
expose :badges do |user| expose :badges do |user|
user_badges_in_admin_section(user) user_badges_in_admin_section(user)
end end

View File

@ -2,6 +2,6 @@
module Admin module Admin
class UserSerializer < BaseSerializer class UserSerializer < BaseSerializer
entity UserEntity entity Admin::UserEntity
end end
end end

View File

@ -0,0 +1,5 @@
---
title: Display epic related events on user activity feed
merge_request: 52611
author:
type: added

View File

@ -1,8 +0,0 @@
---
name: default_to_issues_board_experiment_percentage
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43939
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/268298
milestone: '13.5'
type: experiment
group: group::conversion
default_enabled: true

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@ -376,7 +376,11 @@ from any device you're logged into.
## Suggest Changes ## Suggest Changes
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/18008) in GitLab 11.6. > - [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/18008) in GitLab 11.6.
> - Custom commit messages for suggestions were [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/25381) in GitLab 13.9.
> - Custom commit messages for suggestions is disabled on GitLab.com and not recommended for production use.
> - Custom commit messages for suggestions was deployed behind a [feature flag](../feature_flags.md), disabled by default.
> - To use custom commit messages for suggestions in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-custom-commit-messages-for-suggestions). **(FREE SELF)**
As a reviewer, you're able to suggest code changes with a simple As a reviewer, you're able to suggest code changes with a simple
Markdown syntax in Merge Request Diff threads. Then, the Markdown syntax in Merge Request Diff threads. Then, the
@ -388,24 +392,50 @@ the merge request authored by the user that applied them.
1. Choose a line of code to be changed, add a new comment, then click 1. Choose a line of code to be changed, add a new comment, then click
on the **Insert suggestion** icon in the toolbar: on the **Insert suggestion** icon in the toolbar:
![Add a new comment](img/suggestion_button_v12_7.png) ![Add a new comment](img/suggestion_button_v13_9.png)
1. In the comment, add your suggestion to the pre-populated code block: 1. In the comment, add your suggestion to the pre-populated code block:
![Add a suggestion into a code block tagged properly](img/make_suggestion_v12_7.png) ![Add a suggestion into a code block tagged properly](img/make_suggestion_v13_9.png)
1. Click either **Start a review** or **Add to review** to add your comment to a [review](#merge-request-reviews), or **Add comment now** to add the comment to the thread immediately. 1. Click either **Start a review** or **Add to review** to add your comment to a [review](#merge-request-reviews), or **Add comment now** to add the comment to the thread immediately.
The Suggestion in the comment can be applied by the merge request author The Suggestion in the comment can be applied by the merge request author
directly from the merge request: directly from the merge request:
![Apply suggestions](img/apply_suggestion_v12_7.png) ![Apply suggestions](img/apply_suggestion_v13_9.png)
1. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/25381) in GitLab 13.9,
you can opt to add a custom commit message to describe your change. If you don't
specify it, the default commit message will be used. Note that [this feature may not be available to you](#enable-or-disable-custom-commit-messages-for-suggestions).
Also, it is not supported for [batch suggestions](#batch-suggestions).
![Custom commit](img/custom_commit_v13_9.png)
After the author applies a Suggestion, it will be marked with the **Applied** label, After the author applies a Suggestion, it will be marked with the **Applied** label,
the thread will be automatically resolved, and GitLab will create a new commit the thread will be automatically resolved, and GitLab will create a new commit
and push the suggested change directly into the codebase in the merge request's and push the suggested change directly into the codebase in the merge request's
branch. [Developer permission](../permissions.md) is required to do so. branch. [Developer permission](../permissions.md) is required to do so.
### Enable or disable Custom commit messages for suggestions **(FREE SELF)**
Custom commit messages for suggestions is under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can enable it.
To enable custom commit messages for suggestions:
```ruby
Feature.enable(:suggestions_custom_commit)
```
To disable custom commit messages for suggestions:
```ruby
Feature.disable(:suggestions_custom_commit)
```
### Multi-line Suggestions ### Multi-line Suggestions
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/53310) in GitLab 11.10. > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/53310) in GitLab 11.10.

View File

@ -70,10 +70,6 @@ module Gitlab
tracking_category: 'Growth::Conversion::Experiment::GroupOnlyTrials', tracking_category: 'Growth::Conversion::Experiment::GroupOnlyTrials',
use_backwards_compatible_subject_index: true use_backwards_compatible_subject_index: true
}, },
default_to_issues_board: {
tracking_category: 'Growth::Conversion::Experiment::DefaultToIssuesBoard',
use_backwards_compatible_subject_index: true
},
jobs_empty_state: { jobs_empty_state: {
tracking_category: 'Growth::Activation::Experiment::JobsEmptyState' tracking_category: 'Growth::Activation::Experiment::JobsEmptyState'
}, },

View File

@ -12,6 +12,8 @@
# redis_usage_data { ::Gitlab::UsageCounters::PodLogs.usage_totals[:total] } # redis_usage_data { ::Gitlab::UsageCounters::PodLogs.usage_totals[:total] }
module Gitlab module Gitlab
class UsageData class UsageData
DEPRECATED_VALUE = -1000
CE_MEMOIZED_VALUES = %i( CE_MEMOIZED_VALUES = %i(
issue_minimum_id issue_minimum_id
issue_maximum_id issue_maximum_id
@ -584,26 +586,33 @@ module Gitlab
user_auth_by_provider: distinct_count_user_auth_by_provider(time_period), user_auth_by_provider: distinct_count_user_auth_by_provider(time_period),
unique_users_all_imports: unique_users_all_imports(time_period), unique_users_all_imports: unique_users_all_imports(time_period),
bulk_imports: { bulk_imports: {
gitlab: distinct_count(::BulkImport.where(time_period, source_type: :gitlab), :user_id) gitlab: DEPRECATED_VALUE,
gitlab_v1: count(::BulkImport.where(time_period, source_type: :gitlab))
}, },
project_imports: project_imports(time_period),
issue_imports: issue_imports(time_period),
group_imports: group_imports(time_period),
# Deprecated data to be removed
projects_imported: { projects_imported: {
total: distinct_count(::Project.where(time_period).where.not(import_type: nil), :creator_id), total: DEPRECATED_VALUE,
gitlab_project: projects_imported_count('gitlab_project', time_period), gitlab_project: DEPRECATED_VALUE,
gitlab: projects_imported_count('gitlab', time_period), gitlab: DEPRECATED_VALUE,
github: projects_imported_count('github', time_period), github: DEPRECATED_VALUE,
bitbucket: projects_imported_count('bitbucket', time_period), bitbucket: DEPRECATED_VALUE,
bitbucket_server: projects_imported_count('bitbucket_server', time_period), bitbucket_server: DEPRECATED_VALUE,
gitea: projects_imported_count('gitea', time_period), gitea: DEPRECATED_VALUE,
git: projects_imported_count('git', time_period), git: DEPRECATED_VALUE,
manifest: projects_imported_count('manifest', time_period) manifest: DEPRECATED_VALUE
}, },
issues_imported: { issues_imported: {
jira: distinct_count(::JiraImportState.where(time_period), :user_id), jira: DEPRECATED_VALUE,
fogbugz: projects_imported_count('fogbugz', time_period), fogbugz: DEPRECATED_VALUE,
phabricator: projects_imported_count('phabricator', time_period), phabricator: DEPRECATED_VALUE,
csv: distinct_count(Issues::CsvImport.where(time_period), :user_id) csv: DEPRECATED_VALUE
}, },
groups_imported: distinct_count(::GroupImportState.where(time_period), :user_id) groups_imported: DEPRECATED_VALUE
# End of deprecated keys
} }
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
@ -900,8 +909,38 @@ module Gitlab
count relation, start: deployment_minimum_id, finish: deployment_maximum_id count relation, start: deployment_minimum_id, finish: deployment_maximum_id
end end
def project_imports(time_period)
{
gitlab_project: projects_imported_count('gitlab_project', time_period),
gitlab: projects_imported_count('gitlab', time_period),
github: projects_imported_count('github', time_period),
bitbucket: projects_imported_count('bitbucket', time_period),
bitbucket_server: projects_imported_count('bitbucket_server', time_period),
gitea: projects_imported_count('gitea', time_period),
git: projects_imported_count('git', time_period),
manifest: projects_imported_count('manifest', time_period),
gitlab_migration: count(::BulkImports::Entity.where(time_period).project_entity) # rubocop: disable CodeReuse/ActiveRecord
}
end
def projects_imported_count(from, time_period) def projects_imported_count(from, time_period)
distinct_count(::Project.imported_from(from).where(time_period).where.not(import_type: nil), :creator_id) # rubocop: disable CodeReuse/ActiveRecord count(::Project.imported_from(from).where(time_period).where.not(import_type: nil)) # rubocop: disable CodeReuse/ActiveRecord
end
def issue_imports(time_period)
{
jira: count(::JiraImportState.where(time_period)), # rubocop: disable CodeReuse/ActiveRecord
fogbugz: projects_imported_count('fogbugz', time_period),
phabricator: projects_imported_count('phabricator', time_period),
csv: count(Issues::CsvImport.where(time_period)) # rubocop: disable CodeReuse/ActiveRecord
}
end
def group_imports(time_period)
{
group_import: count(::GroupImportState.where(time_period)), # rubocop: disable CodeReuse/ActiveRecord
gitlab_migration: count(::BulkImports::Entity.where(time_period).group_entity) # rubocop: disable CodeReuse/ActiveRecord
}
end end
# rubocop:disable CodeReuse/ActiveRecord # rubocop:disable CodeReuse/ActiveRecord

View File

@ -10125,9 +10125,6 @@ msgstr ""
msgid "DevopsAdoption|Filter by name" msgid "DevopsAdoption|Filter by name"
msgstr "" msgstr ""
msgid "DevopsAdoption|Group data pending until the start of next month"
msgstr ""
msgid "DevopsAdoption|Issues" msgid "DevopsAdoption|Issues"
msgstr "" msgstr ""

View File

@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Registrations::ExperienceLevelsController do RSpec.describe Registrations::ExperienceLevelsController do
include AfterNextHelpers
let_it_be(:namespace) { create(:group, path: 'group-path' ) } let_it_be(:namespace) { create(:group, path: 'group-path' ) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
@ -45,6 +47,9 @@ RSpec.describe Registrations::ExperienceLevelsController do
end end
context 'with an authenticated user' do context 'with an authenticated user' do
let_it_be(:project) { build(:project, namespace: namespace, creator: user, path: 'project-path') }
let_it_be(:issues_board) { build(:board, id: 123, project: project) }
before do before do
sign_in(user) sign_in(user)
stub_experiment_for_subject(onboarding_issues: true) stub_experiment_for_subject(onboarding_issues: true)
@ -85,91 +90,57 @@ RSpec.describe Registrations::ExperienceLevelsController do
end end
end end
describe 'redirection' do context 'when "Learn GitLab" project exists' do
let(:project) { build(:project, namespace: namespace, creator: user, path: 'project-path') } let(:learn_gitlab_available?) { true }
let(:issues_board) { build(:board, id: 123, project: project) }
before do before do
stub_experiment_for_subject(
onboarding_issues: true,
default_to_issues_board: default_to_issues_board_xp?
)
allow_next_instance_of(LearnGitlab) do |learn_gitlab| allow_next_instance_of(LearnGitlab) do |learn_gitlab|
allow(learn_gitlab).to receive(:available?).and_return(learn_gitlab_available?) allow(learn_gitlab).to receive(:available?).and_return(learn_gitlab_available?)
allow(learn_gitlab).to receive(:project).and_return(project) allow(learn_gitlab).to receive(:project).and_return(project)
allow(learn_gitlab).to receive(:board).and_return(issues_board) allow(learn_gitlab).to receive(:board).and_return(issues_board)
allow(learn_gitlab).to receive(:label).and_return(double(id: 1))
end end
end end
context 'when namespace_path param is missing' do context 'redirection' do
let(:params) { super().merge(namespace_path: nil) } context 'when namespace_path param is missing' do
let(:params) { super().merge(namespace_path: nil) }
where( where(
default_to_issues_board_xp?: [true, false], learn_gitlab_available?: [true, false]
learn_gitlab_available?: [true, false] )
)
with_them do with_them do
it { is_expected.to redirect_to('/') } it { is_expected.to redirect_to('/') }
end
end
context 'when we have a namespace_path param' do
using RSpec::Parameterized::TableSyntax
where(:default_to_issues_board_xp?, :learn_gitlab_available?, :path) do
true | true | '/group-path/project-path/-/boards/123'
true | false | '/group-path'
false | true | '/group-path'
false | false | '/group-path'
end
with_them do
it { is_expected.to redirect_to(path) }
end
end
end
describe 'applying the chosen level' do
context 'when a "Learn GitLab" project is available' do
before do
allow_next_instance_of(LearnGitlab) do |learn_gitlab|
allow(learn_gitlab).to receive(:available?).and_return(true)
allow(learn_gitlab).to receive(:label).and_return(double(id: 1))
end end
end end
context 'when novice' do context 'when we have a namespace_path param' do
let(:params) { super().merge(experience_level: :novice) } using RSpec::Parameterized::TableSyntax
it 'adds a BoardLabel' do where(:learn_gitlab_available?, :path) do
expect_next_instance_of(Boards::UpdateService) do |service| true | '/group-path/project-path/-/boards/123'
expect(service).to receive(:execute) false | '/group-path'
end
subject
end end
end
context 'when experienced' do with_them do
let(:params) { super().merge(experience_level: :experienced) } it { is_expected.to redirect_to(path) }
it 'does not add a BoardLabel' do
expect(Boards::UpdateService).not_to receive(:new)
subject
end end
end end
end end
context 'when no "Learn GitLab" project exists' do context 'when novice' do
let(:params) { super().merge(experience_level: :novice) } let(:params) { super().merge(experience_level: :novice) }
before do it 'adds a BoardLabel' do
allow_next_instance_of(LearnGitlab) do |learn_gitlab| expect_next(Boards::UpdateService).to receive(:execute)
allow(learn_gitlab).to receive(:available?).and_return(false)
end subject
end end
end
context 'when experienced' do
let(:params) { super().merge(experience_level: :experienced) }
it 'does not add a BoardLabel' do it 'does not add a BoardLabel' do
expect(Boards::UpdateService).not_to receive(:new) expect(Boards::UpdateService).not_to receive(:new)
@ -178,6 +149,20 @@ RSpec.describe Registrations::ExperienceLevelsController do
end end
end end
end end
context 'when no "Learn GitLab" project exists' do
let(:params) { super().merge(experience_level: :novice) }
before do
allow_next(LearnGitlab).to receive(:available?).and_return(false)
end
it 'does not add a BoardLabel' do
expect(Boards::UpdateService).not_to receive(:new)
subject
end
end
end end
context 'when user update fails' do context 'when user update fails' do

View File

@ -308,12 +308,6 @@ FactoryBot.define do
end end
end end
trait :codequality_report do
after(:build) do |build|
build.job_artifacts << create(:ci_job_artifact, :codequality, job: build)
end
end
trait :test_reports do trait :test_reports do
after(:build) do |build| after(:build) do |build|
build.job_artifacts << create(:ci_job_artifact, :junit, job: build) build.job_artifacts << create(:ci_job_artifact, :junit, job: build)

View File

@ -4,18 +4,30 @@ FactoryBot.define do
factory :ci_pipeline_artifact, class: 'Ci::PipelineArtifact' do factory :ci_pipeline_artifact, class: 'Ci::PipelineArtifact' do
pipeline factory: :ci_pipeline pipeline factory: :ci_pipeline
project { pipeline.project } project { pipeline.project }
file_type { :code_coverage }
file_format { :raw } file_format { :raw }
file_store { ObjectStorage::SUPPORTED_STORES.first } file_store { ObjectStorage::SUPPORTED_STORES.first }
size { 1.megabytes } size { 1.megabyte }
file_type { :code_coverage }
after(:build) do |artifact, _evaluator| after(:build) do |artifact, _evaluator|
artifact.file = fixture_file_upload( artifact.file = fixture_file_upload(
Rails.root.join('spec/fixtures/pipeline_artifacts/code_coverage.json'), 'application/json') Rails.root.join('spec/fixtures/pipeline_artifacts/code_coverage.json'), 'application/json')
end end
trait :with_multibyte_characters do trait :with_coverage_report do
file_type { :code_coverage }
after(:build) do |artifact, _evaluator|
artifact.file = fixture_file_upload(
Rails.root.join('spec/fixtures/pipeline_artifacts/code_coverage.json'), 'application/json')
end
size { file.size }
end
trait :with_coverage_multibyte_characters do
file_type { :code_coverage }
size { { "utf8" => "" }.to_json.bytesize } size { { "utf8" => "" }.to_json.bytesize }
after(:build) do |artifact, _evaluator| after(:build) do |artifact, _evaluator|
artifact.file = CarrierWaveStringFile.new_file( artifact.file = CarrierWaveStringFile.new_file(
file_content: { "utf8" => "" }.to_json, file_content: { "utf8" => "" }.to_json,
@ -26,23 +38,26 @@ FactoryBot.define do
end end
trait :with_code_coverage_with_multiple_files do trait :with_code_coverage_with_multiple_files do
file_type { :code_coverage }
after(:build) do |artifact, _evaluator| after(:build) do |artifact, _evaluator|
artifact.file = fixture_file_upload( artifact.file = fixture_file_upload(
Rails.root.join('spec/fixtures/pipeline_artifacts/code_coverage_with_multiple_files.json'), 'application/json' Rails.root.join('spec/fixtures/pipeline_artifacts/code_coverage_with_multiple_files.json'), 'application/json'
) )
end end
size { file.size } size { 1.megabyte }
end end
trait :codequality_report do trait :with_codequality_report do
file_type { :code_quality } file_type { :code_quality }
size { 2.megabytes }
after(:build) do |artifact, _evaluator| after(:build) do |artifact, _evaluator|
artifact.file = fixture_file_upload( artifact.file = fixture_file_upload(
Rails.root.join('spec/fixtures/pipeline_artifacts/code_quality.json'), 'application/json') Rails.root.join('spec/fixtures/pipeline_artifacts/code_quality.json'), 'application/json')
end end
size { file.size }
end end
end end
end end

View File

@ -93,14 +93,6 @@ FactoryBot.define do
end end
end end
trait :with_codequality_report do
status { :success }
after(:build) do |pipeline, evaluator|
pipeline.builds << build(:ci_build, :codequality_report, pipeline: pipeline, project: pipeline.project)
end
end
trait :with_test_reports do trait :with_test_reports do
status { :success } status { :success }
@ -159,13 +151,13 @@ FactoryBot.define do
trait :with_coverage_report_artifact do trait :with_coverage_report_artifact do
after(:build) do |pipeline, evaluator| after(:build) do |pipeline, evaluator|
pipeline.pipeline_artifacts << build(:ci_pipeline_artifact, pipeline: pipeline, project: pipeline.project) pipeline.pipeline_artifacts << build(:ci_pipeline_artifact, :with_coverage_report, pipeline: pipeline, project: pipeline.project)
end end
end end
trait :with_codequality_report_artifact do trait :with_quality_report_artifact do
after(:build) do |pipeline, evaluator| after(:build) do |pipeline, evaluator|
pipeline.pipeline_artifacts << build(:ci_pipeline_artifact, :codequality_report, pipeline: pipeline, project: pipeline.project) pipeline.pipeline_artifacts << build(:ci_pipeline_artifact, :with_codequality_report, pipeline: pipeline, project: pipeline.project)
end end
end end

View File

@ -51,6 +51,20 @@ RSpec.describe UserRecentEventsFinder do
end end
end end
describe 'issue activity events' do
let(:issue) { create(:issue, project: public_project) }
let(:note) { create(:note_on_issue, noteable: issue, project: public_project) }
let!(:event_a) { create(:event, :commented, target: note, author: project_owner) }
let!(:event_b) { create(:event, :closed, target: issue, author: project_owner) }
it 'includes all issue related events', :aggregate_failures do
events = finder.execute
expect(events).to include(event_a)
expect(events).to include(event_b)
end
end
context 'limits' do context 'limits' do
before do before do
stub_const("#{described_class}::DEFAULT_LIMIT", 1) stub_const("#{described_class}::DEFAULT_LIMIT", 1)

View File

@ -1,7 +1,10 @@
import { GlAvatarLink, GlAvatarLabeled, GlBadge } from '@gitlab/ui'; import { GlAvatarLink, GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import AdminUserAvatar from '~/admin/users/components/user_avatar.vue'; import AdminUserAvatar from '~/admin/users/components/user_avatar.vue';
import { LENGTH_OF_USER_NOTE_TOOLTIP } from '~/admin/users/constants';
import { truncate } from '~/lib/utils/text_utility';
import { users, paths } from '../mock_data'; import { users, paths } from '../mock_data';
describe('AdminUserAvatar component', () => { describe('AdminUserAvatar component', () => {
@ -9,17 +12,25 @@ describe('AdminUserAvatar component', () => {
const user = users[0]; const user = users[0];
const adminUserPath = paths.adminUser; const adminUserPath = paths.adminUser;
const findNote = () => wrapper.find(GlIcon);
const findAvatar = () => wrapper.find(GlAvatarLabeled); const findAvatar = () => wrapper.find(GlAvatarLabeled);
const findAvatarLink = () => wrapper.find(GlAvatarLink); const findAvatarLink = () => wrapper.find(GlAvatarLink);
const findAllBadges = () => wrapper.findAll(GlBadge); const findAllBadges = () => wrapper.findAll(GlBadge);
const findTooltip = () => getBinding(findNote().element, 'gl-tooltip');
const initComponent = (props = {}) => { const initComponent = (props = {}) => {
wrapper = mount(AdminUserAvatar, { wrapper = shallowMount(AdminUserAvatar, {
propsData: { propsData: {
user, user,
adminUserPath, adminUserPath,
...props, ...props,
}, },
directives: {
GlTooltip: createMockDirective(),
},
stubs: {
GlAvatarLabeled,
},
}); });
}; };
@ -53,11 +64,58 @@ describe('AdminUserAvatar component', () => {
expect(findAvatar().attributes('src')).toBe(user.avatarUrl); expect(findAvatar().attributes('src')).toBe(user.avatarUrl);
}); });
it('renders a user note icon', () => {
expect(findNote().exists()).toBe(true);
expect(findNote().props('name')).toBe('document');
});
it("renders the user's note tooltip", () => {
const tooltip = findTooltip();
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe(user.note);
});
it("renders the user's badges", () => { it("renders the user's badges", () => {
findAllBadges().wrappers.forEach((badge, idx) => { findAllBadges().wrappers.forEach((badge, idx) => {
expect(badge.text()).toBe(user.badges[idx].text); expect(badge.text()).toBe(user.badges[idx].text);
expect(badge.props('variant')).toBe(user.badges[idx].variant); expect(badge.props('variant')).toBe(user.badges[idx].variant);
}); });
}); });
describe('and the user note is very long', () => {
const noteText = new Array(LENGTH_OF_USER_NOTE_TOOLTIP + 1).join('a');
beforeEach(() => {
initComponent({
user: {
...user,
note: noteText,
},
});
});
it("renders a truncated user's note tooltip", () => {
const tooltip = findTooltip();
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe(truncate(noteText, LENGTH_OF_USER_NOTE_TOOLTIP));
});
});
describe('and the user does not have a note', () => {
beforeEach(() => {
initComponent({
user: {
...user,
note: null,
},
});
});
it('does not render a user note', () => {
expect(findNote().exists()).toBe(false);
});
});
}); });
}); });

View File

@ -14,6 +14,7 @@ export const users = [
], ],
projectsCount: 0, projectsCount: 0,
actions: [], actions: [],
note: 'Create per issue #999',
}, },
]; ];

View File

@ -15,8 +15,7 @@ RSpec.describe Gitlab::Experimentation::EXPERIMENTS do
:invite_members_empty_group_version_a, :invite_members_empty_group_version_a,
:contact_sales_btn_in_app, :contact_sales_btn_in_app,
:customize_homepage, :customize_homepage,
:group_only_trials, :group_only_trials
:default_to_issues_board
] ]
backwards_compatible_experiment_keys = described_class.filter { |_, v| v[:use_backwards_compatible_subject_index] }.keys backwards_compatible_experiment_keys = described_class.filter { |_, v| v[:use_backwards_compatible_subject_index] }.keys

View File

@ -255,8 +255,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
for_defined_days_back do for_defined_days_back do
user = create(:user) user = create(:user)
create(:bulk_import, user: user)
%w(gitlab_project gitlab github bitbucket bitbucket_server gitea git manifest fogbugz phabricator).each do |type| %w(gitlab_project gitlab github bitbucket bitbucket_server gitea git manifest fogbugz phabricator).each do |type|
create(:project, import_type: type, creator_id: user.id) create(:project, import_type: type, creator_id: user.id)
end end
@ -265,72 +263,113 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
create(:jira_import_state, :finished, project: jira_project) create(:jira_import_state, :finished, project: jira_project)
create(:issue_csv_import, user: user) create(:issue_csv_import, user: user)
group = create(:group)
group.add_owner(user)
create(:group_import_state, group: group, user: user)
bulk_import = create(:bulk_import, user: user)
create(:bulk_import_entity, :group_entity, bulk_import: bulk_import)
create(:bulk_import_entity, :project_entity, bulk_import: bulk_import)
end end
expect(described_class.usage_activity_by_stage_manage({})).to include( expect(described_class.usage_activity_by_stage_manage({})).to include(
{ {
bulk_imports: { bulk_imports: {
gitlab: 2 gitlab_v1: 2,
gitlab: Gitlab::UsageData::DEPRECATED_VALUE
}, },
projects_imported: { project_imports: {
total: 2,
gitlab_project: 2,
gitlab: 2,
github: 2,
bitbucket: 2, bitbucket: 2,
bitbucket_server: 2, bitbucket_server: 2,
gitea: 2,
git: 2, git: 2,
gitea: 2,
github: 2,
gitlab: 2,
gitlab_migration: 2,
gitlab_project: 2,
manifest: 2 manifest: 2
}, },
issues_imported: { issue_imports: {
jira: 2, jira: 2,
fogbugz: 2, fogbugz: 2,
phabricator: 2, phabricator: 2,
csv: 2 csv: 2
} },
group_imports: {
group_import: 2,
gitlab_migration: 2
},
projects_imported: {
total: Gitlab::UsageData::DEPRECATED_VALUE,
gitlab_project: Gitlab::UsageData::DEPRECATED_VALUE,
gitlab: Gitlab::UsageData::DEPRECATED_VALUE,
github: Gitlab::UsageData::DEPRECATED_VALUE,
bitbucket: Gitlab::UsageData::DEPRECATED_VALUE,
bitbucket_server: Gitlab::UsageData::DEPRECATED_VALUE,
gitea: Gitlab::UsageData::DEPRECATED_VALUE,
git: Gitlab::UsageData::DEPRECATED_VALUE,
manifest: Gitlab::UsageData::DEPRECATED_VALUE
},
issues_imported: {
jira: Gitlab::UsageData::DEPRECATED_VALUE,
fogbugz: Gitlab::UsageData::DEPRECATED_VALUE,
phabricator: Gitlab::UsageData::DEPRECATED_VALUE,
csv: Gitlab::UsageData::DEPRECATED_VALUE
},
groups_imported: Gitlab::UsageData::DEPRECATED_VALUE
} }
) )
expect(described_class.usage_activity_by_stage_manage(described_class.last_28_days_time_period)).to include( expect(described_class.usage_activity_by_stage_manage(described_class.last_28_days_time_period)).to include(
{ {
bulk_imports: { bulk_imports: {
gitlab: 1 gitlab_v1: 1,
gitlab: Gitlab::UsageData::DEPRECATED_VALUE
}, },
projects_imported: { project_imports: {
total: 1,
gitlab_project: 1,
gitlab: 1,
github: 1,
bitbucket: 1, bitbucket: 1,
bitbucket_server: 1, bitbucket_server: 1,
gitea: 1,
git: 1, git: 1,
gitea: 1,
github: 1,
gitlab: 1,
gitlab_migration: 1,
gitlab_project: 1,
manifest: 1 manifest: 1
}, },
issues_imported: { issue_imports: {
jira: 1, jira: 1,
fogbugz: 1, fogbugz: 1,
phabricator: 1, phabricator: 1,
csv: 1 csv: 1
} },
group_imports: {
group_import: 1,
gitlab_migration: 1
},
projects_imported: {
total: Gitlab::UsageData::DEPRECATED_VALUE,
gitlab_project: Gitlab::UsageData::DEPRECATED_VALUE,
gitlab: Gitlab::UsageData::DEPRECATED_VALUE,
github: Gitlab::UsageData::DEPRECATED_VALUE,
bitbucket: Gitlab::UsageData::DEPRECATED_VALUE,
bitbucket_server: Gitlab::UsageData::DEPRECATED_VALUE,
gitea: Gitlab::UsageData::DEPRECATED_VALUE,
git: Gitlab::UsageData::DEPRECATED_VALUE,
manifest: Gitlab::UsageData::DEPRECATED_VALUE
},
issues_imported: {
jira: Gitlab::UsageData::DEPRECATED_VALUE,
fogbugz: Gitlab::UsageData::DEPRECATED_VALUE,
phabricator: Gitlab::UsageData::DEPRECATED_VALUE,
csv: Gitlab::UsageData::DEPRECATED_VALUE
},
groups_imported: Gitlab::UsageData::DEPRECATED_VALUE
} }
) )
end end
it 'includes group imports usage data' do
for_defined_days_back do
user = create(:user)
group = create(:group)
group.add_owner(user)
create(:group_import_state, group: group, user: user)
end
expect(described_class.usage_activity_by_stage_manage({}))
.to include(groups_imported: 2)
expect(described_class.usage_activity_by_stage_manage(described_class.last_28_days_time_period))
.to include(groups_imported: 1)
end
def omniauth_providers def omniauth_providers
[ [
OpenStruct.new(name: 'google_oauth2'), OpenStruct.new(name: 'google_oauth2'),

View File

@ -3,7 +3,7 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Ci::PipelineArtifact, type: :model do RSpec.describe Ci::PipelineArtifact, type: :model do
let(:coverage_report) { create(:ci_pipeline_artifact) } let(:coverage_report) { create(:ci_pipeline_artifact, :with_coverage_report) }
describe 'associations' do describe 'associations' do
it { is_expected.to belong_to(:pipeline) } it { is_expected.to belong_to(:pipeline) }
@ -15,7 +15,7 @@ RSpec.describe Ci::PipelineArtifact, type: :model do
it_behaves_like 'UpdateProjectStatistics' do it_behaves_like 'UpdateProjectStatistics' do
let_it_be(:pipeline, reload: true) { create(:ci_pipeline) } let_it_be(:pipeline, reload: true) { create(:ci_pipeline) }
subject { build(:ci_pipeline_artifact, pipeline: pipeline) } subject { build(:ci_pipeline_artifact, :with_code_coverage_with_multiple_files, pipeline: pipeline) }
end end
describe 'validations' do describe 'validations' do
@ -51,7 +51,7 @@ RSpec.describe Ci::PipelineArtifact, type: :model do
end end
describe 'file is being stored' do describe 'file is being stored' do
subject { create(:ci_pipeline_artifact) } subject { create(:ci_pipeline_artifact, :with_coverage_report) }
context 'when existing object has local store' do context 'when existing object has local store' do
it_behaves_like 'mounted file in local store' it_behaves_like 'mounted file in local store'
@ -68,7 +68,7 @@ RSpec.describe Ci::PipelineArtifact, type: :model do
end end
context 'when file contains multi-byte characters' do context 'when file contains multi-byte characters' do
let(:coverage_report_multibyte) { create(:ci_pipeline_artifact, :with_multibyte_characters) } let(:coverage_report_multibyte) { create(:ci_pipeline_artifact, :with_coverage_multibyte_characters) }
it 'sets the size in bytesize' do it 'sets the size in bytesize' do
expect(coverage_report_multibyte.size).to eq(14) expect(coverage_report_multibyte.size).to eq(14)
@ -83,7 +83,7 @@ RSpec.describe Ci::PipelineArtifact, type: :model do
let(:file_type) { :code_coverage } let(:file_type) { :code_coverage }
context 'when pipeline artifact has a coverage report' do context 'when pipeline artifact has a coverage report' do
let!(:pipeline_artifact) { create(:ci_pipeline_artifact) } let!(:pipeline_artifact) { create(:ci_pipeline_artifact, :with_coverage_report) }
it 'returns true' do it 'returns true' do
expect(pipeline_artifact).to be_truthy expect(pipeline_artifact).to be_truthy
@ -101,7 +101,7 @@ RSpec.describe Ci::PipelineArtifact, type: :model do
let(:file_type) { :code_quality } let(:file_type) { :code_quality }
context 'when pipeline artifact has a quality report' do context 'when pipeline artifact has a quality report' do
let!(:pipeline_artifact) { create(:ci_pipeline_artifact, :codequality_report) } let!(:pipeline_artifact) { create(:ci_pipeline_artifact, :with_codequality_report) }
it 'returns true' do it 'returns true' do
expect(pipeline_artifact).to be_truthy expect(pipeline_artifact).to be_truthy
@ -131,7 +131,7 @@ RSpec.describe Ci::PipelineArtifact, type: :model do
let(:file_type) { :code_coverage } let(:file_type) { :code_coverage }
context 'when pipeline artifact has a coverage report' do context 'when pipeline artifact has a coverage report' do
let!(:coverage_report) { create(:ci_pipeline_artifact) } let!(:coverage_report) { create(:ci_pipeline_artifact, :with_coverage_report) }
it 'returns a pipeline artifact with a coverage report' do it 'returns a pipeline artifact with a coverage report' do
expect(pipeline_artifact.file_type).to eq('code_coverage') expect(pipeline_artifact.file_type).to eq('code_coverage')
@ -149,7 +149,7 @@ RSpec.describe Ci::PipelineArtifact, type: :model do
let(:file_type) { :code_quality } let(:file_type) { :code_quality }
context 'when pipeline artifact has a quality report' do context 'when pipeline artifact has a quality report' do
let!(:coverage_report) { create(:ci_pipeline_artifact, :codequality_report) } let!(:coverage_report) { create(:ci_pipeline_artifact, :with_codequality_report) }
it 'returns a pipeline artifact with a quality report' do it 'returns a pipeline artifact with a quality report' do
expect(pipeline_artifact.file_type).to eq('code_quality') expect(pipeline_artifact.file_type).to eq('code_quality')

View File

@ -3388,7 +3388,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
describe '#batch_lookup_report_artifact_for_file_type' do describe '#batch_lookup_report_artifact_for_file_type' do
context 'with code quality report artifact' do context 'with code quality report artifact' do
let(:pipeline) { create(:ci_pipeline, :with_codequality_report, project: project) } let(:pipeline) { create(:ci_pipeline, :with_codequality_reports, project: project) }
it "returns the code quality artifact" do it "returns the code quality artifact" do
expect(pipeline.batch_lookup_report_artifact_for_file_type(:codequality)).to eq(pipeline.job_artifacts.sample) expect(pipeline.batch_lookup_report_artifact_for_file_type(:codequality)).to eq(pipeline.job_artifacts.sample)
@ -3514,7 +3514,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
subject { pipeline.has_codequality_reports? } subject { pipeline.has_codequality_reports? }
context 'when pipeline has a codequality artifact' do context 'when pipeline has a codequality artifact' do
let(:pipeline) { create(:ci_pipeline, :with_codequality_report_artifact, :running, project: project) } let(:pipeline) { create(:ci_pipeline, :with_quality_report_artifact, :running, project: project) }
it { expect(subject).to be_truthy } it { expect(subject).to be_truthy }
end end

View File

@ -22,6 +22,7 @@ RSpec.describe Admin::UserEntity do
:username, :username,
:last_activity_on, :last_activity_on,
:avatar_url, :avatar_url,
:note,
:badges, :badges,
:projects_count, :projects_count,
:actions :actions

View File

@ -17,6 +17,7 @@ RSpec.describe Admin::UserSerializer do
:username, :username,
:last_activity_on, :last_activity_on,
:avatar_url, :avatar_url,
:note,
:badges, :badges,
:projects_count, :projects_count,
:actions :actions

View File

@ -77,7 +77,7 @@ RSpec.describe MergeRequestWidgetEntity do
end end
describe 'codequality report artifacts', :request_store do describe 'codequality report artifacts', :request_store do
let(:merge_base_pipeline) { create(:ci_pipeline, :with_codequality_report, project: project) } let(:merge_base_pipeline) { create(:ci_pipeline, :with_codequality_reports, project: project) }
before do before do
project.add_developer(user) project.add_developer(user)
@ -90,7 +90,7 @@ RSpec.describe MergeRequestWidgetEntity do
end end
context 'with report artifacts' do context 'with report artifacts' do
let(:pipeline) { create(:ci_pipeline, :with_codequality_report, project: project) } let(:pipeline) { create(:ci_pipeline, :with_codequality_reports, project: project) }
let(:generic_job_id) { pipeline.builds.first.id } let(:generic_job_id) { pipeline.builds.first.id }
let(:merge_base_job_id) { merge_base_pipeline.builds.first.id } let(:merge_base_job_id) { merge_base_pipeline.builds.first.id }
@ -100,7 +100,7 @@ RSpec.describe MergeRequestWidgetEntity do
end end
context 'on pipelines for merged results' do context 'on pipelines for merged results' do
let(:pipeline) { create(:ci_pipeline, :merged_result_pipeline, :with_codequality_report, project: project) } let(:pipeline) { create(:ci_pipeline, :merged_result_pipeline, :with_codequality_reports, project: project) }
it 'returns URLs from the head_pipeline and merge_base_pipeline' do it 'returns URLs from the head_pipeline and merge_base_pipeline' do
expect(subject[:codeclimate][:head_path]).to include("/jobs/#{generic_job_id}/artifacts/download?file_type=codequality") expect(subject[:codeclimate][:head_path]).to include("/jobs/#{generic_job_id}/artifacts/download?file_type=codequality")

View File

@ -7,7 +7,7 @@ RSpec.describe Ci::PipelineArtifacts::ExpireArtifactsWorker do
describe '#perform' do describe '#perform' do
let_it_be(:pipeline_artifact) do let_it_be(:pipeline_artifact) do
create(:ci_pipeline_artifact, expire_at: 1.week.ago) create(:ci_pipeline_artifact, :with_coverage_report, expire_at: 1.week.ago)
end end
it 'executes a service' do it 'executes a service' do