diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-06 21:09:57 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-08-06 21:09:57 +0300 |
commit | 7e75943bd8ade38611f7b953aa3b4e664bbcb7a8 (patch) | |
tree | 9b239cf93da408561c17b04bc80eac4f492832f8 | |
parent | 07e0fae35c51cff088d6b2cbc6d844f421e16617 (diff) |
Add latest changes from gitlab-org/gitlab@master
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 |