Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-08-06 21:09:57 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-08-06 21:09:57 +0300
commit7e75943bd8ade38611f7b953aa3b4e664bbcb7a8 (patch)
tree9b239cf93da408561c17b04bc80eac4f492832f8
parent07e0fae35c51cff088d6b2cbc6d844f421e16617 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.projections.json.example16
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/api/analytics_api.js12
-rw-r--r--app/assets/javascripts/cycle_analytics/components/base.vue14
-rw-r--r--app/assets/javascripts/cycle_analytics/components/path_navigation.vue7
-rw-r--r--app/assets/javascripts/cycle_analytics/store/actions.js34
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/cycle_analytics/store/mutations.js15
-rw-r--r--app/assets/javascripts/cycle_analytics/store/state.js1
-rw-r--r--app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue77
-rw-r--r--app/controllers/invites_controller.rb6
-rw-r--r--app/controllers/registrations_controller.rb2
-rw-r--r--app/graphql/types/user_interface.rb4
-rw-r--r--app/helpers/emails_helper.rb34
-rw-r--r--app/services/environments/auto_stop_service.rb2
-rw-r--r--app/services/environments/stop_service.rb (renamed from app/services/ci/stop_environments_service.rb)23
-rw-r--r--app/services/git/branch_push_service.rb2
-rw-r--r--app/services/merge_requests/base_service.rb4
-rw-r--r--app/views/layouts/_mailer.html.haml1
-rw-r--r--app/views/notify/member_invited_email.html.haml18
-rw-r--r--config/feature_flags/experiment/invite_email_preview_text.yml8
-rw-r--r--doc/api/graphql/reference/index.md4
-rw-r--r--doc/api/members.md2
-rw-r--r--doc/user/application_security/dependency_scanning/index.md20
-rw-r--r--doc/user/packages/helm_repository/index.md4
-rw-r--r--doc/user/permissions.md2
-rw-r--r--locale/gitlab.pot12
-rw-r--r--spec/controllers/invites_controller_spec.rb31
-rw-r--r--spec/controllers/registrations_controller_spec.rb37
-rw-r--r--spec/features/invites_spec.rb17
-rw-r--r--spec/frontend/cycle_analytics/base_spec.js11
-rw-r--r--spec/frontend/cycle_analytics/mock_data.js24
-rw-r--r--spec/frontend/cycle_analytics/store/actions_spec.js61
-rw-r--r--spec/frontend/cycle_analytics/store/getters_spec.js3
-rw-r--r--spec/frontend/cycle_analytics/store/mutations_spec.js5
-rw-r--r--spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js8
-rw-r--r--spec/graphql/types/merge_requests/reviewer_type_spec.rb1
-rw-r--r--spec/graphql/types/user_type_spec.rb1
-rw-r--r--spec/mailers/notify_spec.rb10
-rw-r--r--spec/requests/api/graphql/current_user_query_spec.rb11
-rw-r--r--spec/services/environments/stop_service_spec.rb (renamed from spec/services/ci/stop_environments_service_spec.rb)74
-rw-r--r--spec/services/git/branch_push_service_spec.rb8
-rw-r--r--spec/services/merge_requests/close_service_spec.rb2
-rw-r--r--spec/services/merge_requests/post_merge_service_spec.rb2
44 files changed, 514 insertions, 122 deletions
diff --git a/.projections.json.example b/.projections.json.example
index a7bcb00f83a..326e9544392 100644
--- a/.projections.json.example
+++ b/.projections.json.example
@@ -15,6 +15,22 @@
"alternate": "lib/{}.rb",
"type": "test"
},
+ "lib/api/*.rb": {
+ "alternate": "spec/requests/api/{}_spec.rb",
+ "type": "source"
+ },
+ "spec/requests/api/*_spec.rb": {
+ "alternate": "lib/api/{}.rb",
+ "type": "test"
+ },
+ "ee/lib/api/*.rb": {
+ "alternate": "ee/spec/requests/api/{}_spec.rb",
+ "type": "source"
+ },
+ "ee/spec/requests/api/*_spec.rb": {
+ "alternate": "ee/lib/api/{}.rb",
+ "type": "test"
+ },
"ee/app/*.rb": {
"alternate": "ee/spec/{}_spec.rb",
"type": "source"
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 0fcdcbd4e2a..f5c85a086ff 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-70f304398220d95c05dc109e122ff5e806640303
+3af862d452b894f08d1651944d763834a5c35ab8
diff --git a/app/assets/javascripts/api/analytics_api.js b/app/assets/javascripts/api/analytics_api.js
index 11786f6c365..e10439f699d 100644
--- a/app/assets/javascripts/api/analytics_api.js
+++ b/app/assets/javascripts/api/analytics_api.js
@@ -1,4 +1,5 @@
import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
import { buildApiUrl } from './api_utils';
const PROJECT_VSA_PATH_BASE = '/:request_path/-/analytics/value_stream_analytics/value_streams';
@@ -33,7 +34,7 @@ export const getProjectValueStreamStages = (requestPath, valueStreamId) => {
// NOTE: legacy VSA request use a different path
// the `requestPath` provides a full url for the request
export const getProjectValueStreamStageData = ({ requestPath, stageId, params }) =>
- axios.get(`${requestPath}/events/${stageId}`, { params });
+ axios.get(joinPaths(requestPath, 'events', stageId), { params });
export const getProjectValueStreamMetrics = (requestPath, params) =>
axios.get(requestPath, { params });
@@ -46,7 +47,7 @@ export const getProjectValueStreamMetrics = (requestPath, params) =>
export const getValueStreamStageMedian = ({ requestPath, valueStreamId, stageId }, params = {}) => {
const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
- return axios.get(`${stageBase}/median`, { params });
+ return axios.get(joinPaths(stageBase, 'median'), { params });
};
export const getValueStreamStageRecords = (
@@ -54,5 +55,10 @@ export const getValueStreamStageRecords = (
params = {},
) => {
const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
- return axios.get(`${stageBase}/records`, { params });
+ return axios.get(joinPaths(stageBase, 'records'), { params });
+};
+
+export const getValueStreamStageCounts = ({ requestPath, valueStreamId, stageId }, params = {}) => {
+ const stageBase = buildValueStreamStageDataPath({ requestPath, valueStreamId, stageId });
+ return axios.get(joinPaths(stageBase, 'count'), { params });
};
diff --git a/app/assets/javascripts/cycle_analytics/components/base.vue b/app/assets/javascripts/cycle_analytics/components/base.vue
index 0dc221abb61..3763b228470 100644
--- a/app/assets/javascripts/cycle_analytics/components/base.vue
+++ b/app/assets/javascripts/cycle_analytics/components/base.vue
@@ -44,6 +44,7 @@ export default {
'summary',
'daysInPast',
'permissions',
+ 'stageCounts',
]),
...mapGetters(['pathNavigationData']),
displayStageEvents() {
@@ -77,6 +78,16 @@ export default {
? this.selectedStage?.emptyStageText
: '';
},
+ selectedStageCount() {
+ if (this.selectedStage) {
+ const {
+ stageCounts,
+ selectedStage: { id },
+ } = this;
+ return stageCounts[id];
+ }
+ return 0;
+ },
},
methods: {
...mapActions([
@@ -117,7 +128,6 @@ export default {
:loading="isLoading || isLoadingStage"
:stages="pathNavigationData"
:selected-stage="selectedStage"
- :with-stage-counts="false"
@selected="onSelectStage"
/>
<gl-loading-icon v-if="isLoading" size="lg" />
@@ -162,7 +172,7 @@ export default {
:is-loading="isLoading || isLoadingStage"
:stage-events="selectedStageEvents"
:selected-stage="selectedStage"
- :stage-count="null"
+ :stage-count="selectedStageCount"
:empty-state-title="emptyStageTitle"
:empty-state-message="emptyStageText"
:no-data-svg-path="noDataSvgPath"
diff --git a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
index 5ae2e979308..f8f89772fd6 100644
--- a/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
+++ b/app/assets/javascripts/cycle_analytics/components/path_navigation.vue
@@ -36,11 +36,6 @@ export default {
required: false,
default: () => ({}),
},
- withStageCounts: {
- type: Boolean,
- required: false,
- default: true,
- },
},
methods: {
showPopover({ id }) {
@@ -81,7 +76,7 @@ export default {
<div class="gl-pb-4 gl-font-weight-bold">{{ pathItem.metric }}</div>
</div>
</div>
- <div v-if="withStageCounts" class="gl-px-4">
+ <div class="gl-px-4">
<div class="gl-display-flex gl-justify-content-space-between">
<div class="gl-pr-4 gl-pb-4">
{{ s__('ValueStreamEvent|Items in stage') }}
diff --git a/app/assets/javascripts/cycle_analytics/store/actions.js b/app/assets/javascripts/cycle_analytics/store/actions.js
index fd606109151..a7a2c8ea9d3 100644
--- a/app/assets/javascripts/cycle_analytics/store/actions.js
+++ b/app/assets/javascripts/cycle_analytics/store/actions.js
@@ -4,6 +4,7 @@ import {
getProjectValueStreamMetrics,
getValueStreamStageMedian,
getValueStreamStageRecords,
+ getValueStreamStageCounts,
} from '~/api/analytics_api';
import createFlash from '~/flash';
import { __ } from '~/locale';
@@ -44,7 +45,7 @@ export const fetchValueStreams = ({ commit, dispatch, state }) => {
} = state;
commit(types.REQUEST_VALUE_STREAMS);
- const stageRequests = ['setSelectedStage', 'fetchStageMedians'];
+ const stageRequests = ['setSelectedStage', 'fetchStageMedians', 'fetchStageCountValues'];
return getProjectValueStreams(fullPath)
.then(({ data }) => dispatch('receiveValueStreamsSuccess', data))
.then(() => Promise.all(stageRequests.map((r) => dispatch(r))))
@@ -115,6 +116,37 @@ export const fetchStageMedians = ({
});
};
+const getStageCounts = ({ stageId, vsaParams, filterParams = {} }) => {
+ return getValueStreamStageCounts({ ...vsaParams, stageId }, filterParams).then(({ data }) => ({
+ id: stageId,
+ ...data,
+ }));
+};
+
+export const fetchStageCountValues = ({
+ state: { stages },
+ getters: { requestParams: vsaParams, filterParams },
+ commit,
+}) => {
+ commit(types.REQUEST_STAGE_COUNTS);
+ return Promise.all(
+ stages.map(({ id: stageId }) =>
+ getStageCounts({
+ vsaParams,
+ stageId,
+ filterParams,
+ }),
+ ),
+ )
+ .then((data) => commit(types.RECEIVE_STAGE_COUNTS_SUCCESS, data))
+ .catch((error) => {
+ commit(types.RECEIVE_STAGE_COUNTS_ERROR, error);
+ createFlash({
+ message: __('There was an error fetching stage total counts'),
+ });
+ });
+};
+
export const setSelectedStage = ({ dispatch, commit, state: { stages } }, selectedStage = null) => {
const stage = selectedStage || stages[0];
commit(types.SET_SELECTED_STAGE, stage);
diff --git a/app/assets/javascripts/cycle_analytics/store/mutation_types.js b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
index 11ed62a4081..0d94aad2ca5 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutation_types.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutation_types.js
@@ -24,3 +24,7 @@ export const RECEIVE_STAGE_DATA_ERROR = 'RECEIVE_STAGE_DATA_ERROR';
export const REQUEST_STAGE_MEDIANS = 'REQUEST_STAGE_MEDIANS';
export const RECEIVE_STAGE_MEDIANS_SUCCESS = 'RECEIVE_STAGE_MEDIANS_SUCCESS';
export const RECEIVE_STAGE_MEDIANS_ERROR = 'RECEIVE_STAGE_MEDIANS_ERROR';
+
+export const REQUEST_STAGE_COUNTS = 'REQUEST_STAGE_COUNTS';
+export const RECEIVE_STAGE_COUNTS_SUCCESS = 'RECEIVE_STAGE_COUNTS_SUCCESS';
+export const RECEIVE_STAGE_COUNTS_ERROR = 'RECEIVE_STAGE_COUNTS_ERROR';
diff --git a/app/assets/javascripts/cycle_analytics/store/mutations.js b/app/assets/javascripts/cycle_analytics/store/mutations.js
index 65035c0ebb8..2d49af947fa 100644
--- a/app/assets/javascripts/cycle_analytics/store/mutations.js
+++ b/app/assets/javascripts/cycle_analytics/store/mutations.js
@@ -87,4 +87,19 @@ export default {
[types.RECEIVE_STAGE_MEDIANS_ERROR](state) {
state.medians = {};
},
+ [types.REQUEST_STAGE_COUNTS](state) {
+ state.stageCounts = {};
+ },
+ [types.RECEIVE_STAGE_COUNTS_SUCCESS](state, stageCounts = []) {
+ state.stageCounts = stageCounts.reduce(
+ (acc, { id, count }) => ({
+ ...acc,
+ [id]: count,
+ }),
+ {},
+ );
+ },
+ [types.RECEIVE_STAGE_COUNTS_ERROR](state) {
+ state.stageCounts = {};
+ },
};
diff --git a/app/assets/javascripts/cycle_analytics/store/state.js b/app/assets/javascripts/cycle_analytics/store/state.js
index 562b5d0a743..b1b26039d41 100644
--- a/app/assets/javascripts/cycle_analytics/store/state.js
+++ b/app/assets/javascripts/cycle_analytics/store/state.js
@@ -16,6 +16,7 @@ export default () => ({
selectedStageEvents: [],
selectedStageError: '',
medians: {},
+ stageCounts: {},
hasError: false,
isLoading: false,
isLoadingStage: false,
diff --git a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
index 6af3361e7e6..0f971573507 100644
--- a/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
+++ b/app/assets/javascripts/pipeline_editor/components/header/pipeline_status.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
@@ -19,12 +19,14 @@ export const i18n = {
pipelineInfo: s__(
`Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`,
),
+ viewBtn: s__('Pipeline|View pipeline'),
};
export default {
i18n,
components: {
CiIcon,
+ GlButton,
GlIcon,
GlLink,
GlLoadingIcon,
@@ -98,7 +100,9 @@ export default {
</script>
<template>
- <div class="gl-white-space-nowrap gl-max-w-full">
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-white-space-nowrap gl-max-w-full"
+ >
<template v-if="showLoadingState">
<gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" />
<span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span>
@@ -108,34 +112,47 @@ export default {
<span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span>
</template>
<template v-else>
- <a :href="status.detailsPath" class="gl-mr-auto">
- <ci-icon :status="status" :size="16" />
- </a>
- <span class="gl-font-weight-bold">
- <gl-sprintf :message="$options.i18n.pipelineInfo">
- <template #id="{ content }">
- <gl-link
- :href="status.detailsPath"
- class="pipeline-id gl-font-weight-normal pipeline-number"
- target="_blank"
- data-testid="pipeline-id"
- >
- {{ content }}{{ pipelineId }}</gl-link
- >
- </template>
- <template #status>{{ status.text }}</template>
- <template #commit>
- <gl-link
- :href="pipeline.commitPath"
- class="commit-sha gl-font-weight-normal"
- target="_blank"
- data-testid="pipeline-commit"
- >
- {{ shortSha }}
- </gl-link>
- </template>
- </gl-sprintf>
- </span>
+ <div>
+ <a :href="status.detailsPath" class="gl-mr-auto">
+ <ci-icon :status="status" :size="16" />
+ </a>
+ <span class="gl-font-weight-bold">
+ <gl-sprintf :message="$options.i18n.pipelineInfo">
+ <template #id="{ content }">
+ <gl-link
+ :href="status.detailsPath"
+ class="pipeline-id gl-font-weight-normal pipeline-number"
+ target="_blank"
+ data-testid="pipeline-id"
+ >
+ {{ content }}{{ pipelineId }}</gl-link
+ >
+ </template>
+ <template #status>{{ status.text }}</template>
+ <template #commit>
+ <gl-link
+ :href="pipeline.commitPath"
+ class="commit-sha gl-font-weight-normal"
+ target="_blank"
+ data-testid="pipeline-commit"
+ >
+ {{ shortSha }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </div>
+ <div>
+ <gl-button
+ target="_blank"
+ category="secondary"
+ variant="confirm"
+ :href="status.detailsPath"
+ data-testid="pipeline-view-btn"
+ >
+ {{ $options.i18n.viewBtn }}
+ </gl-button>
+ </div>
</template>
</div>
</template>
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index e2b7f691e01..7f5750d2011 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -75,7 +75,10 @@ class InvitesController < ApplicationController
end
def track_invite_join_click
- Gitlab::Tracking.event(self.class.name, 'join_clicked', label: 'invite_email', property: member.id.to_s) if member && initial_invite_email?
+ return unless member && initial_invite_email?
+
+ experiment(:invite_email_preview_text, actor: member).track(:join_clicked) if params[:experiment_name] == 'invite_email_preview_text'
+ Gitlab::Tracking.event(self.class.name, 'join_clicked', label: 'invite_email', property: member.id.to_s)
end
def authenticate_user!
@@ -96,6 +99,7 @@ class InvitesController < ApplicationController
session[:invite_email] = member.invite_email
session[:originating_member_id] = member.id if initial_invite_email?
+ session[:invite_email_experiment_name] = params[:experiment_name] if initial_invite_email? && params[:experiment_name]
end
def initial_invite_email?
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index fcb977f5ee9..cc985e84542 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -199,6 +199,8 @@ class RegistrationsController < Devise::RegistrationsController
return unless member
+ experiment_name = session.delete(:invite_email_experiment_name)
+ experiment(:invite_email_preview_text, actor: member).track(:accepted) if experiment_name == 'invite_email_preview_text'
Gitlab::Tracking.event(self.class.name, 'accepted', label: 'invite_email', property: member.id.to_s)
end
diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb
index d77ecd8b19d..03af50e36be 100644
--- a/app/graphql/types/user_interface.rb
+++ b/app/graphql/types/user_interface.rb
@@ -77,6 +77,10 @@ module Types
field :starred_projects,
description: 'Projects starred by the user.',
resolver: Resolvers::UserStarredProjectsResolver
+ field :namespace,
+ type: Types::NamespaceType,
+ null: true,
+ description: 'Personal namespace of the user.'
field :todos, resolver: Resolvers::TodoResolver, description: 'To-do items of the user.' do
extension(::Gitlab::Graphql::TodosProjectPermissionPreloader::FieldExtension)
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index 0b1bdb68e50..b804efb9561 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -7,21 +7,9 @@ module EmailsHelper
# https://developers.google.com/gmail/markup/reference/go-to-action
def email_action(url)
name = action_title(url)
- if name
- data = {
- "@context" => "http://schema.org",
- "@type" => "EmailMessage",
- "action" => {
- "@type" => "ViewAction",
- "name" => name,
- "url" => url
- }
- }
-
- content_tag :script, type: 'application/ld+json' do
- data.to_json.html_safe
- end
- end
+ return unless name
+
+ gmail_goto_action(name, url)
end
def action_title(url)
@@ -36,6 +24,22 @@ module EmailsHelper
nil
end
+ def gmail_goto_action(name, url)
+ data = {
+ "@context" => "http://schema.org",
+ "@type" => "EmailMessage",
+ "action" => {
+ "@type" => "ViewAction",
+ "name" => name,
+ "url" => url
+ }
+ }
+
+ content_tag :script, type: 'application/ld+json' do
+ data.to_json.html_safe
+ end
+ end
+
def sanitize_name(name)
if name =~ URI::DEFAULT_PARSER.regexp[:URI_REF]
name.tr('.', '_')
diff --git a/app/services/environments/auto_stop_service.rb b/app/services/environments/auto_stop_service.rb
index bde598abf66..4e3aec64283 100644
--- a/app/services/environments/auto_stop_service.rb
+++ b/app/services/environments/auto_stop_service.rb
@@ -32,7 +32,7 @@ module Environments
return false unless environments.exists?
- Ci::StopEnvironmentsService.execute_in_batch(environments)
+ Environments::StopService.execute_in_batch(environments)
end
end
end
diff --git a/app/services/ci/stop_environments_service.rb b/app/services/environments/stop_service.rb
index 7c9fc44e7f4..089aea11296 100644
--- a/app/services/ci/stop_environments_service.rb
+++ b/app/services/environments/stop_service.rb
@@ -1,19 +1,25 @@
# frozen_string_literal: true
-module Ci
- class StopEnvironmentsService < BaseService
+module Environments
+ class StopService < BaseService
attr_reader :ref
- def execute(branch_name)
+ def execute(environment)
+ return unless can?(current_user, :stop_environment, environment)
+
+ environment.stop_with_action!(current_user)
+ end
+
+ def execute_for_branch(branch_name)
@ref = branch_name
return unless @ref.present?
- environments.each { |environment| stop(environment) }
+ environments.each { |environment| execute(environment) }
end
def execute_for_merge_request(merge_request)
- merge_request.environments.each { |environment| stop(environment) }
+ merge_request.environments.each { |environment| execute(environment) }
end
##
@@ -39,12 +45,5 @@ module Ci
.new(project, current_user, ref: @ref, recently_updated: true)
.execute
end
-
- def stop(environment)
- return unless environment.stop_action_available?
- return unless can?(current_user, :stop_environment, environment)
-
- environment.stop_with_action!(current_user)
- end
end
end
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
index 5dcc2de456c..5bf39d98fa3 100644
--- a/app/services/git/branch_push_service.rb
+++ b/app/services/git/branch_push_service.rb
@@ -58,7 +58,7 @@ module Git
def stop_environments
return unless removing_branch?
- Ci::StopEnvironmentsService.new(project, current_user).execute(branch_name)
+ Environments::StopService.new(project, current_user).execute_for_branch(branch_name)
end
def unlock_artifacts
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 099ab1d26e9..0a652c58aab 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -61,8 +61,8 @@ module MergeRequests
end
def cleanup_environments(merge_request)
- Ci::StopEnvironmentsService.new(merge_request.source_project, current_user)
- .execute_for_merge_request(merge_request)
+ Environments::StopService.new(merge_request.source_project, current_user)
+ .execute_for_merge_request(merge_request)
end
def cancel_review_app_jobs!(merge_request)
diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml
index 74d05be7f95..95ebe09a2e6 100644
--- a/app/views/layouts/_mailer.html.haml
+++ b/app/views/layouts/_mailer.html.haml
@@ -14,6 +14,7 @@
= stylesheet_link_tag 'mailer.css'
%body
+ = yield :preview_text
%table#body{ border: "0", cellpadding: "0", cellspacing: "0" }
%tbody
%tr.line
diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml
index 7ddc620e618..843a820bd1b 100644
--- a/app/views/notify/member_invited_email.html.haml
+++ b/app/views/notify/member_invited_email.html.haml
@@ -4,17 +4,31 @@
project_or_group: member_source.model_name.singular,
br_tag: '<br/>'.html_safe,
role: member.human_access.downcase }
+- join_text = s_('InviteEmail|Join now')
+- join_url = invite_url(@token, invite_type: Emails::Members::INITIAL_INVITE, experiment_name: 'invite_email_preview_text')
+- inviter_name = member.created_by.name if member.created_by
+
+- experiment(:invite_email_preview_text, actor: member) do |experiment_instance|
+ - experiment_instance.use {}
+ - experiment_instance.candidate do
+ = content_for :preview_text do
+ %div{ style: "display:none;font-size:1px;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;" }
+ - if member.created_by
+ = s_('InviteEmail|Join your team on GitLab! %{inviter} invited you to %{project_or_group_name}') % { inviter: inviter_name, project_or_group_name: placeholders[:project_or_group_name] }
+ - else
+ = s_('InviteEmail|Join your team on GitLab! You are invited to %{project_or_group_name}') % { project_or_group_name: placeholders[:project_or_group_name] }
+ = gmail_goto_action(join_text, join_url)
%tr
%td.text-content{ colspan: 2 }
%img.mail-avatar{ height: "60", src: avatar_icon_for_user(member.created_by, 60, only_path: false), width: "60", alt: "" }
%p
- if member.created_by
- = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to member.created_by.name, user_url(member.created_by)).html_safe })
+ = html_escape(s_("InviteEmail|%{inviter} invited you to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders.merge({ inviter: (link_to inviter_name, user_url(member.created_by)).html_safe })
- else
= html_escape(s_("InviteEmail|You are invited to join the %{strong_start}%{project_or_group_name}%{strong_end}%{br_tag}%{project_or_group} as a %{role}")) % placeholders
%p.invite-actions
- = link_to s_('InviteEmail|Join now'), invite_url(@token, invite_type: Emails::Members::INITIAL_INVITE), class: 'invite-btn-join'
+ = link_to join_text, join_url, class: 'invite-btn-join'
%tr.border-top
%td.text-content.mailer-align-left.half-width
%h4
diff --git a/config/feature_flags/experiment/invite_email_preview_text.yml b/config/feature_flags/experiment/invite_email_preview_text.yml
new file mode 100644
index 00000000000..fcb4cda0b14
--- /dev/null
+++ b/config/feature_flags/experiment/invite_email_preview_text.yml
@@ -0,0 +1,8 @@
+---
+name: invite_email_preview_text
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67236
+rollout_issue_url: https://gitlab.com/gitlab-org/growth/team-tasks/-/issues/421
+milestone: '14.2'
+type: experiment
+group: group::expansion
+default_enabled: false
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index b52f560dc48..d95590e5410 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -10677,6 +10677,7 @@ A user assigned to a merge request.
| <a id="mergerequestassigneelocation"></a>`location` | [`String`](#string) | The location of the user. |
| <a id="mergerequestassigneemergerequestinteraction"></a>`mergeRequestInteraction` | [`UserMergeRequestInteraction`](#usermergerequestinteraction) | Details of this user's interactions with the merge request. |
| <a id="mergerequestassigneename"></a>`name` | [`String!`](#string) | Human-readable name of the user. |
+| <a id="mergerequestassigneenamespace"></a>`namespace` | [`Namespace`](#namespace) | Personal namespace of the user. |
| <a id="mergerequestassigneeprojectmemberships"></a>`projectMemberships` | [`ProjectMemberConnection`](#projectmemberconnection) | Project memberships of the user. (see [Connections](#connections)) |
| <a id="mergerequestassigneepublicemail"></a>`publicEmail` | [`String`](#string) | User's public email. |
| <a id="mergerequestassigneestate"></a>`state` | [`UserState!`](#userstate) | State of the user. |
@@ -10883,6 +10884,7 @@ A user assigned to a merge request as a reviewer.
| <a id="mergerequestreviewerlocation"></a>`location` | [`String`](#string) | The location of the user. |
| <a id="mergerequestreviewermergerequestinteraction"></a>`mergeRequestInteraction` | [`UserMergeRequestInteraction`](#usermergerequestinteraction) | Details of this user's interactions with the merge request. |
| <a id="mergerequestreviewername"></a>`name` | [`String!`](#string) | Human-readable name of the user. |
+| <a id="mergerequestreviewernamespace"></a>`namespace` | [`Namespace`](#namespace) | Personal namespace of the user. |
| <a id="mergerequestreviewerprojectmemberships"></a>`projectMemberships` | [`ProjectMemberConnection`](#projectmemberconnection) | Project memberships of the user. (see [Connections](#connections)) |
| <a id="mergerequestreviewerpublicemail"></a>`publicEmail` | [`String`](#string) | User's public email. |
| <a id="mergerequestreviewerstate"></a>`state` | [`UserState!`](#userstate) | State of the user. |
@@ -13658,6 +13660,7 @@ Core represention of a GitLab user.
| <a id="usercoreid"></a>`id` | [`ID!`](#id) | ID of the user. |
| <a id="usercorelocation"></a>`location` | [`String`](#string) | The location of the user. |
| <a id="usercorename"></a>`name` | [`String!`](#string) | Human-readable name of the user. |
+| <a id="usercorenamespace"></a>`namespace` | [`Namespace`](#namespace) | Personal namespace of the user. |
| <a id="usercoreprojectmemberships"></a>`projectMemberships` | [`ProjectMemberConnection`](#projectmemberconnection) | Project memberships of the user. (see [Connections](#connections)) |
| <a id="usercorepublicemail"></a>`publicEmail` | [`String`](#string) | User's public email. |
| <a id="usercorestate"></a>`state` | [`UserState!`](#userstate) | State of the user. |
@@ -16510,6 +16513,7 @@ Implementations:
| <a id="userid"></a>`id` | [`ID!`](#id) | ID of the user. |
| <a id="userlocation"></a>`location` | [`String`](#string) | The location of the user. |
| <a id="username"></a>`name` | [`String!`](#string) | Human-readable name of the user. |
+| <a id="usernamespace"></a>`namespace` | [`Namespace`](#namespace) | Personal namespace of the user. |
| <a id="userprojectmemberships"></a>`projectMemberships` | [`ProjectMemberConnection`](#projectmemberconnection) | Project memberships of the user. (see [Connections](#connections)) |
| <a id="userpublicemail"></a>`publicEmail` | [`String`](#string) | User's public email. |
| <a id="userstate"></a>`state` | [`UserState!`](#userstate) | State of the user. |
diff --git a/doc/api/members.md b/doc/api/members.md
index 8a6d97343e3..4b383efd792 100644
--- a/doc/api/members.md
+++ b/doc/api/members.md
@@ -27,7 +27,7 @@ for owner.
The `group_saml_identity` attribute is only visible to a group owner for [SSO enabled groups](../user/group/saml_sso/index.md).
-The `email` attribute is only visible to a group owner who manages the user through [Group Managed Accounts](../user/group/saml_sso/group_managed_accounts.md).
+The `email` attribute is only visible for users with public emails.
## List all members of a group or project
diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md
index 02545607c9c..48465250b3a 100644
--- a/doc/user/application_security/dependency_scanning/index.md
+++ b/doc/user/application_security/dependency_scanning/index.md
@@ -7,8 +7,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Dependency Scanning **(ULTIMATE)**
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/5105) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.7.
-
The Dependency Scanning feature can automatically find security vulnerabilities in your
dependencies while you're developing and testing your applications. For example, dependency scanning
lets you know if your application uses an external (open source) library that is known to be
@@ -29,19 +27,11 @@ either:
GitLab checks the dependency scanning report, compares the found vulnerabilities
between the source and target branches, and shows the information on the
-merge request.
+merge request. The results are sorted by the [severity](../vulnerabilities/severities.md) of the
+vulnerability.
![Dependency scanning Widget](img/dependency_scanning_v13_2.png)
-The results are sorted by the severity of the vulnerability:
-
-1. Critical
-1. High
-1. Medium
-1. Low
-1. Unknown
-1. Everything else
-
## Requirements
To run dependency scanning jobs, by default, you need GitLab Runner with the
@@ -73,8 +63,8 @@ The following languages and dependency managers are supported:
| [npm](https://www.npmjs.com/), [yarn](https://classic.yarnpkg.com/en/) 1.x | JavaScript | `package-lock.json`, `npm-shrinkwrap.json`, `yarn.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium) |
| [npm](https://www.npmjs.com/) (7 and earlier), [yarn](https://classic.yarnpkg.com/en/) 1.x | JavaScript | `package.json` | [Retire.js](https://retirejs.github.io/retire.js/) |
| [NuGet](https://www.nuget.org/) 4.9+ | .NET, C# | [`packages.lock.json`](https://docs.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#enabling-lock-file) | [Gemnasium](https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium) |
-| [`setuptools`](https://setuptools.readthedocs.io/en/latest/), [pip](https://pip.pypa.io/en/stable/), [Pipenv](https://pipenv.pypa.io/en/latest/) (*1*) | Python | `setup.py`, `requirements.txt`, `requirements.pip`, `requires.txt`, `Pipfile`, `Pipfile.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium) |
-| [sbt](https://www.scala-sbt.org/) (*2*) | Scala | `build.sbt` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium) |
+| [`setuptools`](https://setuptools.readthedocs.io/en/latest/), [pip](https://pip.pypa.io/en/stable/), [Pipenv](https://pipenv.pypa.io/en/latest/) <sup>1</sup> | Python | `setup.py`, `requirements.txt`, `requirements.pip`, `requires.txt`, `Pipfile`, `Pipfile.lock` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium) |
+| [sbt](https://www.scala-sbt.org/) <sup>2</sup> | Scala | `build.sbt` | [Gemnasium](https://gitlab.com/gitlab-org/security-products/analyzers/gemnasium) |
1. [Pipenv](https://pipenv.pypa.io/en/latest/) projects are scanned when a `Pipfile` is present.
1. Support for [sbt](https://www.scala-sbt.org/) 1.3 and above was added in GitLab 13.9.
@@ -627,7 +617,7 @@ Generally, the approach is the following:
1. Add [`dependencies: [<your-converter-job>]`](../../../ci/yaml/index.md#dependencies)
to your `dependency_scanning` job to make use of the converted definitions files.
-For example, the currently unsupported `poetry.lock` file can be
+For example, the unsupported `poetry.lock` file can be
[converted](https://python-poetry.org/docs/cli/#export)
to the supported `requirements.txt` as follows.
diff --git a/doc/user/packages/helm_repository/index.md b/doc/user/packages/helm_repository/index.md
index 55a1c0d98f1..87c8afc7826 100644
--- a/doc/user/packages/helm_repository/index.md
+++ b/doc/user/packages/helm_repository/index.md
@@ -31,6 +31,10 @@ To authenticate to the Helm repository, you need either:
## Publish a package
+NOTE:
+You can publish Helm charts with duplicate names or versions. If duplicates exist, GitLab always
+returns the chart with the latest version.
+
Once built, a chart can be uploaded to the `stable` channel with `curl` or `helm-push`:
- With `curl`:
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index e6071b1f87c..982eac38663 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -301,6 +301,8 @@ The following table lists group permissions available for each role:
| Create/edit/delete iterations | | | ✓ | ✓ | ✓ |
| Create/edit/delete metrics dashboard annotations | | | ✓ | ✓ | ✓ |
| Enable/disable a dependency proxy | | | ✓ | ✓ | ✓ |
+| Pull a container image using the dependency proxy | | ✓ | ✓ | ✓ | ✓ |
+| Purge the dependency proxy for a group | | | | | ✓ |
| Publish [packages](packages/index.md) | | | ✓ | ✓ | ✓ |
| Use security dashboard **(ULTIMATE)** | | | ✓ | ✓ | ✓ |
| View group Audit Events | | | ✓ (7) | ✓ (7) | ✓ |
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 84373a7d8df..f062dd4714a 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -18074,6 +18074,12 @@ msgstr ""
msgid "InviteEmail|Join now"
msgstr ""
+msgid "InviteEmail|Join your team on GitLab! %{inviter} invited you to %{project_or_group_name}"
+msgstr ""
+
+msgid "InviteEmail|Join your team on GitLab! You are invited to %{project_or_group_name}"
+msgstr ""
+
msgid "InviteEmail|Projects are used to host and collaborate on code, track issues, and continuously build, test, and deploy your app with built-in GitLab CI/CD."
msgstr ""
@@ -24648,6 +24654,9 @@ msgstr ""
msgid "Pipeline|Variables"
msgstr ""
+msgid "Pipeline|View pipeline"
+msgstr ""
+
msgid "Pipeline|We are currently unable to fetch pipeline data"
msgstr ""
@@ -33532,6 +33541,9 @@ msgstr ""
msgid "There was an error fetching projects"
msgstr ""
+msgid "There was an error fetching stage total counts"
+msgstr ""
+
msgid "There was an error fetching the %{replicableType}"
msgstr ""
diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb
index c5e693e3489..dc1fb0454df 100644
--- a/spec/controllers/invites_controller_spec.rb
+++ b/spec/controllers/invites_controller_spec.rb
@@ -97,6 +97,29 @@ RSpec.describe InvitesController do
)
end
+ context 'when it is part of the invite_email_preview_text experiment' do
+ let(:extra_params) { { invite_type: 'initial_email', experiment_name: 'invite_email_preview_text' } }
+
+ it 'tracks the initial join click from email' do
+ experiment = double(track: true)
+ allow(controller).to receive(:experiment).with(:invite_email_preview_text, actor: member).and_return(experiment)
+
+ request
+
+ expect(experiment).to have_received(:track).with(:join_clicked)
+ end
+
+ context 'when member does not exist' do
+ let(:raw_invite_token) { '_bogus_token_' }
+
+ it 'does not track the experiment' do
+ expect(controller).not_to receive(:experiment).with(:invite_email_preview_text, actor: member)
+
+ request
+ end
+ end
+ end
+
context 'when member does not exist' do
let(:raw_invite_token) { '_bogus_token_' }
@@ -122,6 +145,14 @@ RSpec.describe InvitesController do
label: 'invite_email'
)
end
+
+ context 'when it is not part of our invite email experiment' do
+ it 'does not track via experiment' do
+ expect(controller).not_to receive(:experiment).with(:invite_email_preview_text, actor: member)
+
+ request
+ end
+ end
end
context 'when not logged in' do
diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb
index 4c91d3e890a..301c60e89c8 100644
--- a/spec/controllers/registrations_controller_spec.rb
+++ b/spec/controllers/registrations_controller_spec.rb
@@ -159,11 +159,12 @@ RSpec.describe RegistrationsController do
let_it_be(:member) { create(:project_member, :invited, invite_email: user_params.dig(:user, :email)) }
let(:originating_member_id) { member.id }
+ let(:extra_session_params) { {} }
let(:session_params) do
{
invite_email: user_params.dig(:user, :email),
originating_member_id: originating_member_id
- }
+ }.merge extra_session_params
end
context 'when member exists from the session key value' do
@@ -192,6 +193,40 @@ RSpec.describe RegistrationsController do
)
end
end
+
+ context 'with the invite_email_preview_text experiment', :experiment do
+ let(:extra_session_params) { { invite_email_experiment_name: 'invite_email_preview_text' } }
+
+ context 'when member and invite_email_experiment_name exists from the session key value' do
+ it 'tracks the invite acceptance' do
+ expect(experiment(:invite_email_preview_text)).to track(:accepted)
+ .with_context(actor: member)
+ .on_next_instance
+
+ subject
+ end
+ end
+
+ context 'when member does not exist from the session key value' do
+ let(:originating_member_id) { -1 }
+
+ it 'does not track invite acceptance' do
+ expect(experiment(:invite_email_preview_text)).not_to track(:accepted)
+
+ subject
+ end
+ end
+
+ context 'when invite_email_experiment_name does not exist from the session key value' do
+ let(:extra_session_params) { {} }
+
+ it 'does not track invite acceptance' do
+ expect(experiment(:invite_email_preview_text)).not_to track(:accepted)
+
+ subject
+ end
+ end
+ end
end
context 'when invite email matches email used on registration' do
diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb
index fdd822ef25b..d56bedd4852 100644
--- a/spec/features/invites_spec.rb
+++ b/spec/features/invites_spec.rb
@@ -141,6 +141,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
let(:invite_email) { new_user.email }
let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email, created_by: owner) }
let(:send_email_confirmation) { true }
+ let(:extra_params) { { invite_type: Emails::Members::INITIAL_INVITE } }
before do
stub_application_setting(send_user_confirmation_email: send_email_confirmation)
@@ -148,7 +149,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
context 'when registering using invitation email' do
before do
- visit invite_path(group_invite.raw_invite_token, invite_type: Emails::Members::INITIAL_INVITE)
+ visit invite_path(group_invite.raw_invite_token, extra_params)
end
context 'with admin approval required enabled' do
@@ -201,6 +202,20 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
end
+ context 'with invite email acceptance for the invite_email_preview_text experiment', :experiment do
+ let(:extra_params) do
+ { invite_type: Emails::Members::INITIAL_INVITE, experiment_name: 'invite_email_preview_text' }
+ end
+
+ it 'tracks the accepted invite' do
+ expect(experiment(:invite_email_preview_text)).to track(:accepted)
+ .with_context(actor: group_invite)
+ .on_next_instance
+
+ fill_in_sign_up_form(new_user)
+ end
+ end
+
it 'signs up and redirects to the group activity page with all the project/groups invitation automatically accepted' do
fill_in_sign_up_form(new_user)
fill_in_welcome_form
diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/cycle_analytics/base_spec.js
index c2c6b2a5d06..0b1a4f7ad1c 100644
--- a/spec/frontend/cycle_analytics/base_spec.js
+++ b/spec/frontend/cycle_analytics/base_spec.js
@@ -16,11 +16,13 @@ import {
createdBefore,
createdAfter,
currentGroup,
+ stageCounts,
} from './mock_data';
const selectedStageEvents = issueEvents.events;
const noDataSvgPath = 'path/to/no/data';
const noAccessSvgPath = 'path/to/no/access';
+const selectedStageCount = stageCounts[selectedStage.id];
Vue.use(Vuex);
@@ -31,6 +33,7 @@ const defaultState = {
currentGroup,
createdBefore,
createdAfter,
+ stageCounts,
};
function createStore({ initialState = {}, initialGetters = {} }) {
@@ -83,6 +86,10 @@ describe('Value stream analytics component', () => {
expect(findPathNavigation().exists()).toBe(true);
});
+ it('receives the stages formatted for the path navigation', () => {
+ expect(findPathNavigation().props('stages')).toBe(transformedProjectStagePathData);
+ });
+
it('renders the overview metrics', () => {
expect(findOverviewMetrics().exists()).toBe(true);
});
@@ -91,6 +98,10 @@ describe('Value stream analytics component', () => {
expect(findStageTable().exists()).toBe(true);
});
+ it('passes the selected stage count to the stage table', () => {
+ expect(findStageTable().props('stageCount')).toBe(selectedStageCount);
+ });
+
it('renders the stage table events', () => {
expect(findStageEvents()).toEqual(selectedStageEvents);
});
diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/cycle_analytics/mock_data.js
index cedc31a3273..71bb9fc63ed 100644
--- a/spec/frontend/cycle_analytics/mock_data.js
+++ b/spec/frontend/cycle_analytics/mock_data.js
@@ -137,6 +137,24 @@ export const stagingEvents = deepCamelCase(stageFixtures.staging);
export const pathNavIssueMetric = 172800;
+export const rawStageCounts = [
+ { id: 'issue', count: 6 },
+ { id: 'plan', count: 6 },
+ { id: 'code', count: 1 },
+ { id: 'test', count: 5 },
+ { id: 'review', count: 12 },
+ { id: 'staging', count: 3 },
+];
+
+export const stageCounts = {
+ code: 1,
+ issue: 6,
+ plan: 6,
+ review: 12,
+ staging: 3,
+ test: 5,
+};
+
export const rawStageMedians = [
{ id: 'issue', value: 172800 },
{ id: 'plan', value: 86400 },
@@ -170,7 +188,7 @@ export const transformedProjectStagePathData = [
{
metric: 172800,
selected: true,
- stageCount: undefined,
+ stageCount: 6,
icon: null,
id: 'issue',
title: 'Issue',
@@ -182,7 +200,7 @@ export const transformedProjectStagePathData = [
{
metric: 86400,
selected: false,
- stageCount: undefined,
+ stageCount: 6,
icon: null,
id: 'plan',
title: 'Plan',
@@ -194,7 +212,7 @@ export const transformedProjectStagePathData = [
{
metric: 129600,
selected: false,
- stageCount: undefined,
+ stageCount: 1,
icon: null,
id: 'code',
title: 'Code',
diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/cycle_analytics/store/actions_spec.js
index 28715aa87e8..915a828ff19 100644
--- a/spec/frontend/cycle_analytics/store/actions_spec.js
+++ b/spec/frontend/cycle_analytics/store/actions_spec.js
@@ -216,6 +216,7 @@ describe('Project Value Stream Analytics actions', () => {
{ type: 'receiveValueStreamsSuccess' },
{ type: 'setSelectedStage' },
{ type: 'fetchStageMedians' },
+ { type: 'fetchStageCountValues' },
],
}));
@@ -364,4 +365,64 @@ describe('Project Value Stream Analytics actions', () => {
}));
});
});
+
+ describe('fetchStageCountValues', () => {
+ const mockValueStreamPath = /count/;
+ const stageCountsPayload = [
+ { id: 'issue', count: 1 },
+ { id: 'plan', count: 2 },
+ { id: 'code', count: 3 },
+ ];
+
+ const stageCountError = new Error(
+ `Request failed with status code ${httpStatusCodes.BAD_REQUEST}`,
+ );
+
+ beforeEach(() => {
+ state = {
+ fullPath: mockFullPath,
+ selectedValueStream,
+ stages: allowedStages,
+ };
+ mock = new MockAdapter(axios);
+ mock
+ .onGet(mockValueStreamPath)
+ .replyOnce(httpStatusCodes.OK, { count: 1 })
+ .onGet(mockValueStreamPath)
+ .replyOnce(httpStatusCodes.OK, { count: 2 })
+ .onGet(mockValueStreamPath)
+ .replyOnce(httpStatusCodes.OK, { count: 3 });
+ });
+
+ it(`commits the 'REQUEST_STAGE_COUNTS' and 'RECEIVE_STAGE_COUNTS_SUCCESS' mutations`, () =>
+ testAction({
+ action: actions.fetchStageCountValues,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_COUNTS' },
+ { type: 'RECEIVE_STAGE_COUNTS_SUCCESS', payload: stageCountsPayload },
+ ],
+ expectedActions: [],
+ }));
+
+ describe('with a failing request', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ mock.onGet(mockValueStreamPath).reply(httpStatusCodes.BAD_REQUEST);
+ });
+
+ it(`commits the 'RECEIVE_STAGE_COUNTS_ERROR' mutation`, () =>
+ testAction({
+ action: actions.fetchStageCountValues,
+ state,
+ payload: {},
+ expectedMutations: [
+ { type: 'REQUEST_STAGE_COUNTS' },
+ { type: 'RECEIVE_STAGE_COUNTS_ERROR', payload: stageCountError },
+ ],
+ expectedActions: [],
+ }));
+ });
+ });
});
diff --git a/spec/frontend/cycle_analytics/store/getters_spec.js b/spec/frontend/cycle_analytics/store/getters_spec.js
index 5745e9d7902..c47a30a5f79 100644
--- a/spec/frontend/cycle_analytics/store/getters_spec.js
+++ b/spec/frontend/cycle_analytics/store/getters_spec.js
@@ -4,12 +4,13 @@ import {
stageMedians,
transformedProjectStagePathData,
selectedStage,
+ stageCounts,
} from '../mock_data';
describe('Value stream analytics getters', () => {
describe('pathNavigationData', () => {
it('returns the transformed data', () => {
- const state = { stages: allowedStages, medians: stageMedians, selectedStage };
+ const state = { stages: allowedStages, medians: stageMedians, selectedStage, stageCounts };
expect(getters.pathNavigationData(state)).toEqual(transformedProjectStagePathData);
});
});
diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/cycle_analytics/store/mutations_spec.js
index dcbc2369983..3d4a1ac672e 100644
--- a/spec/frontend/cycle_analytics/store/mutations_spec.js
+++ b/spec/frontend/cycle_analytics/store/mutations_spec.js
@@ -13,6 +13,8 @@ import {
valueStreamStages,
rawStageMedians,
formattedStageMedians,
+ rawStageCounts,
+ stageCounts,
} from '../mock_data';
let state;
@@ -57,6 +59,8 @@ describe('Project Value Stream Analytics mutations', () => {
${types.RECEIVE_STAGE_DATA_ERROR} | ${'isEmptyStage'} | ${true}
${types.REQUEST_STAGE_MEDIANS} | ${'medians'} | ${{}}
${types.RECEIVE_STAGE_MEDIANS_ERROR} | ${'medians'} | ${{}}
+ ${types.REQUEST_STAGE_COUNTS} | ${'stageCounts'} | ${{}}
+ ${types.RECEIVE_STAGE_COUNTS_ERROR} | ${'stageCounts'} | ${{}}
`('$mutation will set $stateKey to $value', ({ mutation, stateKey, value }) => {
mutations[mutation](state);
@@ -97,6 +101,7 @@ describe('Project Value Stream Analytics mutations', () => {
${types.RECEIVE_VALUE_STREAMS_SUCCESS} | ${[selectedValueStream]} | ${'valueStreams'} | ${[selectedValueStream]}
${types.RECEIVE_VALUE_STREAM_STAGES_SUCCESS} | ${{ stages: rawValueStreamStages }} | ${'stages'} | ${valueStreamStages}
${types.RECEIVE_STAGE_MEDIANS_SUCCESS} | ${rawStageMedians} | ${'medians'} | ${formattedStageMedians}
+ ${types.RECEIVE_STAGE_COUNTS_SUCCESS} | ${rawStageCounts} | ${'stageCounts'} | ${stageCounts}
`(
'$mutation with $payload will set $stateKey to $value',
({ mutation, payload, stateKey, value }) => {
diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
index b6d49d0d0f8..a95921359cc 100644
--- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
+++ b/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js
@@ -44,6 +44,7 @@ describe('Pipeline Status', () => {
const findPipelineCommit = () => wrapper.find('[data-testid="pipeline-commit"]');
const findPipelineErrorMsg = () => wrapper.find('[data-testid="pipeline-error-msg"]');
const findPipelineLoadingMsg = () => wrapper.find('[data-testid="pipeline-loading-msg"]');
+ const findPipelineViewBtn = () => wrapper.find('[data-testid="pipeline-view-btn"]');
beforeEach(() => {
mockPipelineQuery = jest.fn();
@@ -96,11 +97,15 @@ describe('Pipeline Status', () => {
});
it('renders pipeline data', () => {
- const { id } = mockProjectPipeline.pipeline;
+ const {
+ id,
+ detailedStatus: { detailsPath },
+ } = mockProjectPipeline.pipeline;
expect(findCiIcon().exists()).toBe(true);
expect(findPipelineId().text()).toBe(`#${id.match(/\d+/g)[0]}`);
expect(findPipelineCommit().text()).toBe(mockCommitSha);
+ expect(findPipelineViewBtn().attributes('href')).toBe(detailsPath);
});
});
@@ -121,6 +126,7 @@ describe('Pipeline Status', () => {
expect(findCiIcon().exists()).toBe(false);
expect(findPipelineId().exists()).toBe(false);
expect(findPipelineCommit().exists()).toBe(false);
+ expect(findPipelineViewBtn().exists()).toBe(false);
});
});
});
diff --git a/spec/graphql/types/merge_requests/reviewer_type_spec.rb b/spec/graphql/types/merge_requests/reviewer_type_spec.rb
index c2182e9968c..8e207cab406 100644
--- a/spec/graphql/types/merge_requests/reviewer_type_spec.rb
+++ b/spec/graphql/types/merge_requests/reviewer_type_spec.rb
@@ -31,6 +31,7 @@ RSpec.describe GitlabSchema.types['MergeRequestReviewer'] do
starredProjects
callouts
merge_request_interaction
+ namespace
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index 7d73727b041..6642632ffb0 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -36,6 +36,7 @@ RSpec.describe GitlabSchema.types['User'] do
projectMemberships
starredProjects
callouts
+ namespace
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 240abfc5c53..8272b5d64c1 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -798,7 +798,10 @@ RSpec.describe Notify do
is_expected.to have_body_text project.full_name
is_expected.to have_body_text project_member.human_access.downcase
is_expected.to have_body_text project_member.invite_token
- is_expected.to have_link('Join now', href: invite_url(project_member.invite_token, invite_type: Emails::Members::INITIAL_INVITE))
+ is_expected.to have_link('Join now',
+ href: invite_url(project_member.invite_token,
+ invite_type: Emails::Members::INITIAL_INVITE,
+ experiment_name: 'invite_email_preview_text'))
is_expected.to have_content("#{inviter.name} invited you to join the")
is_expected.to have_content('Project details')
is_expected.to have_content("What's it about?")
@@ -813,7 +816,10 @@ RSpec.describe Notify do
is_expected.to have_body_text project.full_name
is_expected.to have_body_text project_member.human_access.downcase
is_expected.to have_body_text project_member.invite_token
- is_expected.to have_link('Join now', href: invite_url(project_member.invite_token, invite_type: Emails::Members::INITIAL_INVITE))
+ is_expected.to have_link('Join now',
+ href: invite_url(project_member.invite_token,
+ invite_type: Emails::Members::INITIAL_INVITE,
+ experiment_name: 'invite_email_preview_text'))
is_expected.to have_content('Project details')
is_expected.to have_content("What's it about?")
end
diff --git a/spec/requests/api/graphql/current_user_query_spec.rb b/spec/requests/api/graphql/current_user_query_spec.rb
index dc832b42fa5..086a57094ca 100644
--- a/spec/requests/api/graphql/current_user_query_spec.rb
+++ b/spec/requests/api/graphql/current_user_query_spec.rb
@@ -5,8 +5,15 @@ require 'spec_helper'
RSpec.describe 'getting project information' do
include GraphqlHelpers
+ let(:fields) do
+ <<~GRAPHQL
+ name
+ namespace { id }
+ GRAPHQL
+ end
+
let(:query) do
- graphql_query_for('currentUser', {}, 'name')
+ graphql_query_for('currentUser', {}, fields)
end
subject { graphql_data['currentUser'] }
@@ -20,7 +27,7 @@ RSpec.describe 'getting project information' do
it_behaves_like 'a working graphql query'
- it { is_expected.to include('name' => current_user.name) }
+ it { is_expected.to include('name' => current_user.name, 'namespace' => { 'id' => current_user.namespace.to_global_id.to_s }) }
end
context 'when there is no current_user' do
diff --git a/spec/services/ci/stop_environments_service_spec.rb b/spec/services/environments/stop_service_spec.rb
index d5ef67c871c..52be512612d 100644
--- a/spec/services/ci/stop_environments_service_spec.rb
+++ b/spec/services/environments/stop_service_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Ci::StopEnvironmentsService do
+RSpec.describe Environments::StopService do
include CreateEnvironmentsHelpers
let(:project) { create(:project, :private, :repository) }
@@ -11,6 +11,59 @@ RSpec.describe Ci::StopEnvironmentsService do
let(:service) { described_class.new(project, user) }
describe '#execute' do
+ subject { service.execute(environment) }
+
+ let_it_be(:project) { create(:project, :private, :repository) }
+ let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
+ let_it_be(:reporter) { create(:user).tap { |u| project.add_reporter(u) } }
+
+ let(:user) { developer }
+
+ context 'with a deployment' do
+ let!(:environment) { review_job.persisted_environment }
+ let!(:pipeline) { create(:ci_pipeline, project: project) }
+ let!(:review_job) { create(:ci_build, :with_deployment, :start_review_app, pipeline: pipeline, project: project) }
+ let!(:stop_review_job) { create(:ci_build, :with_deployment, :stop_review_app, :manual, pipeline: pipeline, project: project) }
+
+ before do
+ review_job.success!
+ end
+
+ it 'stops the environment' do
+ expect { subject }.to change { environment.reload.state }.from('available').to('stopped')
+ end
+
+ it 'plays the stop action' do
+ expect { subject }.to change { stop_review_job.reload.status }.from('manual').to('pending')
+ end
+
+ context 'when an environment has already been stopped' do
+ let!(:environment) { create(:environment, :stopped, project: project) }
+
+ it 'does not play the stop action' do
+ expect { subject }.not_to change { stop_review_job.reload.status }
+ end
+ end
+ end
+
+ context 'without a deployment' do
+ let!(:environment) { create(:environment, project: project) }
+
+ it 'stops the environment' do
+ expect { subject }.to change { environment.reload.state }.from('available').to('stopped')
+ end
+
+ context 'when the actor is a reporter' do
+ let(:user) { reporter }
+
+ it 'does not stop the environment' do
+ expect { subject }.not_to change { environment.reload.state }
+ end
+ end
+ end
+ end
+
+ describe '#execute_for_branch' do
context 'when environment with review app exists' do
before do
create(:environment, :with_review_app, project: project,
@@ -48,8 +101,9 @@ RSpec.describe Ci::StopEnvironmentsService do
context 'when environment is not stopped' do
before do
- allow_any_instance_of(Environment)
- .to receive(:state).and_return(:stopped)
+ allow_next_found_instance_of(Environment) do |environment|
+ allow(environment).to receive(:state).and_return(:stopped)
+ end
end
it 'does not stop environment' do
@@ -101,7 +155,7 @@ RSpec.describe Ci::StopEnvironmentsService do
context 'when environment does not exist' do
it 'does not raise error' do
- expect { service.execute('master') }
+ expect { service.execute_for_branch('master') }
.not_to raise_error
end
end
@@ -238,16 +292,12 @@ RSpec.describe Ci::StopEnvironmentsService do
end
def expect_environment_stopped_on(branch)
- expect_any_instance_of(Environment)
- .to receive(:stop!)
-
- service.execute(branch)
+ expect { service.execute_for_branch(branch) }
+ .to change { Environment.last.state }.from('available').to('stopped')
end
def expect_environment_not_stopped_on(branch)
- expect_any_instance_of(Environment)
- .not_to receive(:stop!)
-
- service.execute(branch)
+ expect { service.execute_for_branch(branch) }
+ .not_to change { Environment.last.state }
end
end
diff --git a/spec/services/git/branch_push_service_spec.rb b/spec/services/git/branch_push_service_spec.rb
index fc629fe583d..cc691833ef3 100644
--- a/spec/services/git/branch_push_service_spec.rb
+++ b/spec/services/git/branch_push_service_spec.rb
@@ -597,7 +597,7 @@ RSpec.describe Git::BranchPushService, services: true do
let(:oldrev) { blankrev }
it 'does nothing' do
- expect(::Ci::StopEnvironmentsService).not_to receive(:new)
+ expect(::Environments::StopService).not_to receive(:new)
execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
end
@@ -605,7 +605,7 @@ RSpec.describe Git::BranchPushService, services: true do
context 'update branch' do
it 'does nothing' do
- expect(::Ci::StopEnvironmentsService).not_to receive(:new)
+ expect(::Environments::StopService).not_to receive(:new)
execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
end
@@ -615,10 +615,10 @@ RSpec.describe Git::BranchPushService, services: true do
let(:newrev) { blankrev }
it 'stops environments' do
- expect_next_instance_of(::Ci::StopEnvironmentsService) do |stop_service|
+ expect_next_instance_of(::Environments::StopService) do |stop_service|
expect(stop_service.project).to eq(project)
expect(stop_service.current_user).to eq(user)
- expect(stop_service).to receive(:execute).with(branch)
+ expect(stop_service).to receive(:execute_for_branch).with(branch)
end
execute_service(project, user, oldrev: oldrev, newrev: newrev, ref: ref)
diff --git a/spec/services/merge_requests/close_service_spec.rb b/spec/services/merge_requests/close_service_spec.rb
index f6336a85a25..86d972bc516 100644
--- a/spec/services/merge_requests/close_service_spec.rb
+++ b/spec/services/merge_requests/close_service_spec.rb
@@ -92,7 +92,7 @@ RSpec.describe MergeRequests::CloseService do
end
it 'clean up environments for the merge request' do
- expect_next_instance_of(Ci::StopEnvironmentsService) do |service|
+ expect_next_instance_of(::Environments::StopService) do |service|
expect(service).to receive(:execute_for_merge_request).with(merge_request)
end
diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb
index 14804aa33d4..8d9a32c3e9e 100644
--- a/spec/services/merge_requests/post_merge_service_spec.rb
+++ b/spec/services/merge_requests/post_merge_service_spec.rb
@@ -75,7 +75,7 @@ RSpec.describe MergeRequests::PostMergeService do
end
it 'clean up environments for the merge request' do
- expect_next_instance_of(Ci::StopEnvironmentsService) do |stop_environment_service|
+ expect_next_instance_of(::Environments::StopService) do |stop_environment_service|
expect(stop_environment_service).to receive(:execute_for_merge_request).with(merge_request)
end