summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 15:11:17 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2021-03-16 15:11:17 +0000
commitdad48b4af20204db430a6c62c4641283e24dd89a (patch)
treec8b4644cf30e2babe572f20b89257bcd8fa4b6d6
parente2999d09ec050b12b6de9121d9aedc38c12477fd (diff)
downloadgitlab-ce-dad48b4af20204db430a6c62c4641283e24dd89a.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop_manual_todo.yml10
-rw-r--r--app/assets/javascripts/boards/components/board_content_sidebar.vue12
-rw-r--r--app/assets/javascripts/flash.js74
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue13
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_trigger.vue22
-rw-r--r--app/assets/javascripts/invite_members/constants.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/store/getters.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue24
-rw-r--r--app/controllers/projects/issues_controller.rb9
-rw-r--r--app/controllers/projects/merge_requests_controller.rb9
-rw-r--r--app/helpers/gitlab_routing_helper.rb2
-rw-r--r--app/models/project.rb4
-rw-r--r--app/presenters/ci/build_runner_presenter.rb6
-rw-r--r--app/services/ci/register_job_service.rb10
-rw-r--r--app/services/pages/migrate_from_legacy_storage_service.rb2
-rw-r--r--app/services/pages/migrate_legacy_storage_to_deployment_service.rb12
-rw-r--r--app/services/pages/zip_directory_service.rb4
-rw-r--r--app/views/projects/issues/show.html.haml1
-rw-r--r--app/views/projects/merge_requests/show.html.haml3
-rw-r--r--app/views/shared/issuable/_invite_members_trigger.html.haml8
-rw-r--r--changelogs/unreleased/docs-daily-dora-metrics.yml5
-rw-r--r--changelogs/unreleased/docs-omniauth-providers-icon.yml5
-rw-r--r--changelogs/unreleased/fj-fix-bug-gollum-tag-filter.yml5
-rw-r--r--changelogs/unreleased/js-semgrep.yml5
-rw-r--r--changelogs/unreleased/pedropombeiro-variable_inside_variable.yml5
-rw-r--r--config/feature_flags/development/ci_register_job_service_one_by_one.yml2
-rw-r--r--config/feature_flags/development/dora_daily_metrics.yml8
-rw-r--r--config/feature_flags/development/gitlab_ci_builds_queue_limit.yml2
-rw-r--r--config/feature_flags/development/variable_inside_variable.yml6
-rw-r--r--config/feature_flags/experiment/invite_members_in_comment.yml8
-rw-r--r--doc/api/discussions.md4
-rw-r--r--doc/api/dora/metrics.md90
-rw-r--r--doc/ci/variables/where_variables_can_be_used.md67
-rw-r--r--doc/development/emails.md9
-rw-r--r--doc/development/fe_guide/troubleshooting.md26
-rw-r--r--doc/integration/img/enabled-oauth-sign-in-sources.pngbin13303 -> 0 bytes
-rw-r--r--doc/integration/img/enabled-oauth-sign-in-sources_v13_10.pngbin0 -> 47979 bytes
-rw-r--r--doc/integration/omniauth.md33
-rw-r--r--doc/operations/index.md4
-rw-r--r--doc/user/analytics/ci_cd_analytics.md20
-rw-r--r--doc/user/application_security/sast/index.md55
-rw-r--r--doc/user/group/value_stream_analytics/img/vsa_path_nav_v13_10.pngbin0 -> 33148 bytes
-rw-r--r--doc/user/group/value_stream_analytics/index.md16
-rw-r--r--doc/user/permissions.md1
-rw-r--r--lefthook.yml18
-rw-r--r--lib/banzai/filter/gollum_tags_filter.rb13
-rw-r--r--lib/banzai/pipeline/wiki_pipeline.rb2
-rw-r--r--lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml4
-rw-r--r--locale/gitlab.pot3
-rw-r--r--package.json3
-rwxr-xr-xscripts/static-analysis2
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb26
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb26
-rw-r--r--spec/factories/atlassian_identities.rb2
-rw-r--r--spec/factories/events.rb11
-rw-r--r--spec/factories/git_wiki_commit_details.rb2
-rw-r--r--spec/factories/gitaly/commit.rb4
-rw-r--r--spec/factories/group_group_links.rb4
-rw-r--r--spec/factories/import_export_uploads.rb2
-rw-r--r--spec/features/issues/user_invites_from_a_comment_spec.rb25
-rw-r--r--spec/features/merge_request/user_invites_from_a_comment_spec.rb25
-rw-r--r--spec/frontend/boards/components/board_content_sidebar_spec.js23
-rw-r--r--spec/frontend/flash_spec.js62
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js46
-rw-r--r--spec/frontend/invite_members/components/invite_members_trigger_spec.js55
-rw-r--r--spec/frontend/merge_conflicts/store/actions_spec.js125
-rw-r--r--spec/frontend/merge_conflicts/store/getters_spec.js187
-rw-r--r--spec/frontend/merge_conflicts/store/mutations_spec.js99
-rw-r--r--spec/frontend/merge_conflicts/utils_spec.js106
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js156
-rw-r--r--spec/frontend/vue_shared/components/markdown/toolbar_spec.js64
-rw-r--r--spec/graphql/mutations/release_asset_links/update_spec.rb2
-rw-r--r--spec/lib/banzai/filter/gollum_tags_filter_spec.rb4
-rw-r--r--spec/lib/banzai/pipeline/wiki_pipeline_spec.rb25
-rw-r--r--spec/models/ci/build_spec.rb39
-rw-r--r--spec/models/project_spec.rb13
-rw-r--r--spec/presenters/ci/build_runner_presenter_spec.rb83
-rw-r--r--spec/services/pages/migrate_from_legacy_storage_service_spec.rb4
-rw-r--r--spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb10
-rw-r--r--spec/services/pages/zip_directory_service_spec.rb51
82 files changed, 1544 insertions, 391 deletions
diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml
index 9b20af55520..97217081e29 100644
--- a/.rubocop_manual_todo.yml
+++ b/.rubocop_manual_todo.yml
@@ -41,16 +41,6 @@ Graphql/Descriptions:
- 'ee/app/graphql/types/vulnerability_severity_enum.rb'
- 'ee/app/graphql/types/vulnerability_state_enum.rb'
-# WIP See https://gitlab.com/gitlab-org/gitlab/-/issues/267606
-FactoryBot/InlineAssociation:
- Exclude:
- - 'spec/factories/atlassian_identities.rb'
- - 'spec/factories/events.rb'
- - 'spec/factories/git_wiki_commit_details.rb'
- - 'spec/factories/gitaly/commit.rb'
- - 'spec/factories/group_group_links.rb'
- - 'spec/factories/import_export_uploads.rb'
-
# WIP: See https://gitlab.com/gitlab-org/gitlab/-/issues/220040
Rails/SaveBang:
Exclude:
diff --git a/app/assets/javascripts/boards/components/board_content_sidebar.vue b/app/assets/javascripts/boards/components/board_content_sidebar.vue
index fb969b9855e..8e84f76ee60 100644
--- a/app/assets/javascripts/boards/components/board_content_sidebar.vue
+++ b/app/assets/javascripts/boards/components/board_content_sidebar.vue
@@ -1,9 +1,6 @@
<script>
import { GlDrawer } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
-import BoardSidebarEpicSelect from 'ee_component/boards/components/sidebar/board_sidebar_epic_select.vue';
-import BoardSidebarWeightInput from 'ee_component/boards/components/sidebar/board_sidebar_weight_input.vue';
-import SidebarIterationWidget from 'ee_component/sidebar/components/sidebar_iteration_widget.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarIssueTitle from '~/boards/components/sidebar/board_sidebar_issue_title.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
@@ -26,9 +23,12 @@ export default {
BoardSidebarDueDate,
BoardSidebarSubscription,
BoardSidebarMilestoneSelect,
- BoardSidebarEpicSelect,
- SidebarIterationWidget,
- BoardSidebarWeightInput,
+ BoardSidebarEpicSelect: () =>
+ import('ee_component/boards/components/sidebar/board_sidebar_epic_select.vue'),
+ BoardSidebarWeightInput: () =>
+ import('ee_component/boards/components/sidebar/board_sidebar_weight_input.vue'),
+ SidebarIterationWidget: () =>
+ import('ee_component/sidebar/components/sidebar_iteration_widget.vue'),
},
mixins: [glFeatureFlagsMixin()],
computed: {
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index d26a6bc5f6b..2bec39ff4d8 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -66,55 +66,6 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
* along with ability to provide actionConfig which can be used to show
* additional action or link on banner next to message
*
- * @param {String} message Flash message text
- * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
- * @param {Object} parent Reference to parent element under which Flash needs to appear
- * @param {Object} actionConfig Map of config to show action on banner
- * @param {String} href URL to which action config should point to (default: '#')
- * @param {String} title Title of action
- * @param {Function} clickHandler Method to call when action is clicked on
- * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
- */
-const deprecatedCreateFlash = function deprecatedCreateFlash(
- message,
- type = FLASH_TYPES.ALERT,
- parent = document,
- actionConfig = null,
- fadeTransition = true,
- addBodyClass = false,
-) {
- const flashContainer = parent.querySelector('.flash-container');
-
- if (!flashContainer) return null;
-
- flashContainer.innerHTML = createFlashEl(message, type);
-
- const flashEl = flashContainer.querySelector(`.flash-${type}`);
-
- if (actionConfig) {
- flashEl.innerHTML += createAction(actionConfig);
-
- if (actionConfig.clickHandler) {
- flashEl
- .querySelector('.flash-action')
- .addEventListener('click', (e) => actionConfig.clickHandler(e));
- }
- }
-
- removeFlashClickListener(flashEl, fadeTransition);
-
- flashContainer.style.display = 'block';
-
- if (addBodyClass) document.body.classList.add('flash-shown');
-
- return flashContainer;
-};
-
-/*
- * Flash banner supports different types of Flash configurations
- * along with ability to provide actionConfig which can be used to show
- * additional action or link on banner next to message
- *
* @param {Object} options Options to control the flash message
* @param {String} options.message Flash message text
* @param {String} options.type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
@@ -166,6 +117,31 @@ const createFlash = function createFlash({
return flashContainer;
};
+/*
+ * Flash banner supports different types of Flash configurations
+ * along with ability to provide actionConfig which can be used to show
+ * additional action or link on banner next to message
+ *
+ * @param {String} message Flash message text
+ * @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
+ * @param {Object} parent Reference to parent element under which Flash needs to appear
+ * @param {Object} actionConfig Map of config to show action on banner
+ * @param {String} href URL to which action config should point to (default: '#')
+ * @param {String} title Title of action
+ * @param {Function} clickHandler Method to call when action is clicked on
+ * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out
+ */
+const deprecatedCreateFlash = function deprecatedCreateFlash(
+ message,
+ type,
+ parent,
+ actionConfig,
+ fadeTransition,
+ addBodyClass,
+) {
+ return createFlash({ message, type, parent, actionConfig, fadeTransition, addBodyClass });
+};
+
export {
createFlash as default,
deprecatedCreateFlash,
diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
index 47f1405c980..906965f4395 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue
@@ -11,10 +11,12 @@ import {
} from '@gitlab/ui';
import { partition, isString } from 'lodash';
import Api from '~/api';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
import GroupSelect from '~/invite_members/components/group_select.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
+import { INVITE_MEMBERS_IN_COMMENT } from '../constants';
import eventHub from '../event_hub';
export default {
@@ -122,8 +124,9 @@ export default {
usersToAddById.map((user) => user.id).join(','),
];
},
- openModal({ inviteeType }) {
+ openModal({ inviteeType, source }) {
this.inviteeType = inviteeType;
+ this.source = source;
this.$root.$emit(BV_SHOW_MODAL, this.modalId);
},
@@ -138,6 +141,12 @@ export default {
}
this.closeModal();
},
+ trackInvite() {
+ if (this.source === INVITE_MEMBERS_IN_COMMENT) {
+ const tracking = new ExperimentTracking(INVITE_MEMBERS_IN_COMMENT);
+ tracking.event('comment_invite_success');
+ }
+ },
cancelInvite() {
this.selectedAccessLevel = this.defaultAccessLevel;
this.selectedDate = undefined;
@@ -177,6 +186,8 @@ export default {
promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById)));
}
+ this.trackInvite();
+
Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError);
},
inviteByEmailPostData(usersToInviteByEmail) {
diff --git a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
index 666693e934f..f526a108b20 100644
--- a/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
+++ b/app/assets/javascripts/invite_members/components/invite_members_trigger.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton } from '@gitlab/ui';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
import { s__ } from '~/locale';
import eventHub from '../event_hub';
@@ -26,10 +27,29 @@ export default {
required: false,
default: undefined,
},
+ triggerSource: {
+ type: String,
+ required: false,
+ default: 'unknown',
+ },
+ trackExperiment: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ mounted() {
+ this.trackExperimentOnShow();
},
methods: {
openModal() {
- eventHub.$emit('openModal', { inviteeType: 'members' });
+ eventHub.$emit('openModal', { inviteeType: 'members', source: this.triggerSource });
+ },
+ trackExperimentOnShow() {
+ if (this.trackExperiment) {
+ const tracking = new ExperimentTracking(this.trackExperiment);
+ tracking.event('comment_invite_shown');
+ }
},
},
};
diff --git a/app/assets/javascripts/invite_members/constants.js b/app/assets/javascripts/invite_members/constants.js
index 2044dad896f..a651b81c60e 100644
--- a/app/assets/javascripts/invite_members/constants.js
+++ b/app/assets/javascripts/invite_members/constants.js
@@ -1 +1,3 @@
export const SEARCH_DELAY = 200;
+
+export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';
diff --git a/app/assets/javascripts/merge_conflicts/store/getters.js b/app/assets/javascripts/merge_conflicts/store/getters.js
index 03e425fb478..54f3d6ec4bc 100644
--- a/app/assets/javascripts/merge_conflicts/store/getters.js
+++ b/app/assets/javascripts/merge_conflicts/store/getters.js
@@ -67,7 +67,7 @@ export const isReadyToCommit = (state) => {
}
}
- return !state.isSubmitting && hasCommitMessage && !unresolved;
+ return Boolean(!state.isSubmitting && hasCommitMessage && !unresolved);
};
export const getCommitButtonText = (state) => {
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 992bf3c54ff..a29082245d3 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -3,6 +3,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import { IssuableType } from '~/issuable_show/constants';
import Issue from '~/issue';
import '~/notes/index';
@@ -34,6 +35,7 @@ export default function initShowIssue() {
initIssueHeaderActions(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
+ initInviteMembersModal();
import(/* webpackChunkName: 'design_management' */ '~/design_management')
.then((module) => module.default())
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index d4d5e9f2711..c132394412f 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -5,6 +5,7 @@ import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initIssuableSidebar from '~/init_issuable_sidebar';
import initInviteMemberModal from '~/invite_member/init_invite_member_modal';
import initInviteMemberTrigger from '~/invite_member/init_invite_member_trigger';
+import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import { handleLocationHash } from '~/lib/utils/common_utils';
import StatusBox from '~/merge_request/components/status_box.vue';
import initSourcegraph from '~/sourcegraph';
@@ -20,6 +21,7 @@ export default function initMergeRequestShow() {
loadAwardsHandler();
initInviteMemberModal();
initInviteMemberTrigger();
+ initInviteMembersModal();
const el = document.querySelector('.js-mr-status-box');
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 387b100a04f..7393a8791b7 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,13 +1,18 @@
<script>
import { GlButton, GlLink, GlLoadingIcon, GlSprintf, GlIcon } from '@gitlab/ui';
+import { isExperimentVariant } from '~/experimentation/utils';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
export default {
+ inviteMembersInComment: INVITE_MEMBERS_IN_COMMENT,
components: {
GlButton,
GlLink,
GlLoadingIcon,
GlSprintf,
GlIcon,
+ InviteMembersTrigger,
},
props: {
markdownDocsPath: {
@@ -29,6 +34,9 @@ export default {
hasQuickActionsDocsPath() {
return this.quickActionsDocsPath !== '';
},
+ inviteCommentEnabled() {
+ return isExperimentVariant(INVITE_MEMBERS_IN_COMMENT, 'invite_member_link');
+ },
},
};
</script>
@@ -37,9 +45,9 @@ export default {
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank">{{
- __('Markdown is supported')
- }}</gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank">
+ {{ __('Markdown is supported') }}
+ </gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
<gl-sprintf
@@ -59,6 +67,16 @@ export default {
</template>
</div>
<span v-if="canAttachFile" class="uploading-container">
+ <invite-members-trigger
+ v-if="inviteCommentEnabled"
+ classes="gl-mr-3 gl-vertical-align-text-bottom"
+ :display-text="s__('InviteMember|Invite Member')"
+ icon="assignee"
+ variant="link"
+ :track-experiment="$options.inviteMembersInComment"
+ :trigger-source="$options.inviteMembersInComment"
+ data-track-event="comment_invite_click"
+ />
<span class="uploading-progress-container hide">
<gl-icon name="media" />
<span class="attaching-file-message"></span>
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index c454ae6eaf4..b63cb075ce8 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -55,6 +55,15 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
record_experiment_user(:invite_members_version_b)
+
+ experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance|
+ experiment_instance.exclude! unless helpers.can_import_members?
+
+ experiment_instance.use {}
+ experiment_instance.try(:invite_member_link) {}
+
+ experiment_instance.track(:view, property: @project.root_ancestor.id.to_s)
+ end
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 2c6d5f62b4e..ff7781f16e9 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -45,6 +45,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:new_pipelines_table, @project, default_enabled: :yaml)
record_experiment_user(:invite_members_version_b)
+
+ experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance|
+ experiment_instance.exclude! unless helpers.can_import_members?
+
+ experiment_instance.use {}
+ experiment_instance.try(:invite_member_link) {}
+
+ experiment_instance.track(:view, property: @project.root_ancestor.id.to_s)
+ end
end
before_action do
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 6dcdc018a20..48af4793fb0 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -4,6 +4,8 @@
module GitlabRoutingHelper
extend ActiveSupport::Concern
+ include ::ProjectsHelper
+ include ::ApplicationSettingsHelper
include API::Helpers::RelatedResourcesHelpers
included do
Gitlab::Routing.includes_helpers(self)
diff --git a/app/models/project.rb b/app/models/project.rb
index 274dae8fd65..fa3752bdffd 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -36,6 +36,8 @@ class Project < ApplicationRecord
include Integration
include Repositories::CanHousekeepRepository
include EachBatch
+ include GitlabRoutingHelper
+
extend Gitlab::Cache::RequestCache
extend Gitlab::Utils::Override
@@ -1848,7 +1850,7 @@ class Project < ApplicationRecord
# where().update_all to perform update in the single transaction with check for null
ProjectPagesMetadatum
.where(project_id: id, pages_deployment_id: nil)
- .update_all(pages_deployment_id: deployment.id)
+ .update_all(deployed: deployment.present?, pages_deployment_id: deployment&.id)
end
def write_repository_config(gl_full_path: full_path)
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 769b793ee75..297dc503294 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -33,7 +33,11 @@ module Ci
end
def runner_variables
- variables.to_runner_variables
+ if Feature.enabled?(:variable_inside_variable, project)
+ variables.sort_and_expand_all(project, keep_undefined: true).to_runner_variables
+ else
+ variables.to_runner_variables
+ end
end
def refspecs
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index ed9e44d60f1..7f42ec0b750 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -10,7 +10,11 @@ module Ci
Result = Struct.new(:build, :build_json, :valid?)
- MAX_QUEUE_DEPTH = 50
+ ##
+ # The queue depth limit number has been determined by observing 95
+ # percentile of effective queue depth on gitlab.com. This is only likely to
+ # affect 5% of the worst case scenarios.
+ MAX_QUEUE_DEPTH = 45
def initialize(runner)
@runner = runner
@@ -105,7 +109,7 @@ module Ci
builds = builds.queued_before(params[:job_age].seconds.ago)
end
- if Feature.enabled?(:ci_register_job_service_one_by_one, runner)
+ if Feature.enabled?(:ci_register_job_service_one_by_one, runner, default_enabled: true)
build_ids = builds.pluck(:id)
@metrics.observe_queue_size(-> { build_ids.size })
@@ -171,7 +175,7 @@ module Ci
def max_queue_depth
@max_queue_depth ||= begin
- if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: false)
+ if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: true)
MAX_QUEUE_DEPTH
else
::Gitlab::Database::MAX_INT_VALUE
diff --git a/app/services/pages/migrate_from_legacy_storage_service.rb b/app/services/pages/migrate_from_legacy_storage_service.rb
index 9b36b3f11b4..37e701ce5ba 100644
--- a/app/services/pages/migrate_from_legacy_storage_service.rb
+++ b/app/services/pages/migrate_from_legacy_storage_service.rb
@@ -64,7 +64,7 @@ module Pages
end
if result[:status] == :success
- @logger.info("project_id: #{project.id} #{project.pages_path} has been migrated in #{time.round(2)} seconds")
+ @logger.info("project_id: #{project.id} #{project.pages_path} has been migrated in #{time.round(2)} seconds: #{result[:message]}")
@counters_lock.synchronize { @migrated += 1 }
else
@logger.error("project_id: #{project.id} #{project.pages_path} failed to be migrated in #{time.round(2)} seconds: #{result[:message]}")
diff --git a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb b/app/services/pages/migrate_legacy_storage_to_deployment_service.rb
index 63410b9fe4a..3bffed4caf6 100644
--- a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb
+++ b/app/services/pages/migrate_legacy_storage_to_deployment_service.rb
@@ -30,16 +30,18 @@ module Pages
zip_result = ::Pages::ZipDirectoryService.new(project.pages_path, ignore_invalid_entries: @ignore_invalid_entries).execute
if zip_result[:status] == :error
- if !project.pages_metadatum&.reload&.pages_deployment &&
- Feature.enabled?(:pages_migration_mark_as_not_deployed, project)
- project.mark_pages_as_not_deployed
- end
-
return error("Can't create zip archive: #{zip_result[:message]}")
end
archive_path = zip_result[:archive_path]
+ unless archive_path
+ project.set_first_pages_deployment!(nil)
+
+ return success(
+ message: "Archive not created. Missing public directory in #{project.pages_path} ? Marked project as not deployed")
+ end
+
deployment = nil
File.open(archive_path) do |file|
deployment = project.pages_deployments.create!(
diff --git a/app/services/pages/zip_directory_service.rb b/app/services/pages/zip_directory_service.rb
index ae08d40ee37..2f4995899a1 100644
--- a/app/services/pages/zip_directory_service.rb
+++ b/app/services/pages/zip_directory_service.rb
@@ -19,6 +19,10 @@ module Pages
def execute
unless resolve_public_dir
+ if Feature.enabled?(:pages_migration_mark_as_not_deployed)
+ return success
+ end
+
return error("Can not find valid public dir in #{@input_dir}")
end
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index c3949a83e3f..a94862c75a6 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -4,3 +4,4 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
= render 'projects/issuable/show', issuable: @issue
+= render 'shared/issuable/invite_members_trigger', project: @project
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index d664ee709dd..f0dcaf24e07 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -108,3 +108,6 @@
= render "projects/commit/change", type: 'cherry-pick', commit: @merge_request.merge_commit
#js-review-bar
+
+= render 'shared/issuable/invite_members_trigger', project: @project
+
diff --git a/app/views/shared/issuable/_invite_members_trigger.html.haml b/app/views/shared/issuable/_invite_members_trigger.html.haml
new file mode 100644
index 00000000000..5dd6ec0addf
--- /dev/null
+++ b/app/views/shared/issuable/_invite_members_trigger.html.haml
@@ -0,0 +1,8 @@
+- return unless can_import_members?
+
+.js-invite-members-modal{ data: { id: project.id,
+ name: project.name,
+ is_project: 'true',
+ access_levels: ProjectMember.access_level_roles.to_json,
+ default_access_level: Gitlab::Access::GUEST,
+ help_link: help_page_url('user/permissions') } }
diff --git a/changelogs/unreleased/docs-daily-dora-metrics.yml b/changelogs/unreleased/docs-daily-dora-metrics.yml
new file mode 100644
index 00000000000..5d3a90c15ef
--- /dev/null
+++ b/changelogs/unreleased/docs-daily-dora-metrics.yml
@@ -0,0 +1,5 @@
+---
+title: Support daily DORA metrics API
+merge_request: 56080
+author:
+type: added
diff --git a/changelogs/unreleased/docs-omniauth-providers-icon.yml b/changelogs/unreleased/docs-omniauth-providers-icon.yml
new file mode 100644
index 00000000000..36f1e0d01c5
--- /dev/null
+++ b/changelogs/unreleased/docs-omniauth-providers-icon.yml
@@ -0,0 +1,5 @@
+---
+title: Document how to use custom omniauth button icon
+merge_request: 55388
+author: Diego Louzán
+type: other
diff --git a/changelogs/unreleased/fj-fix-bug-gollum-tag-filter.yml b/changelogs/unreleased/fj-fix-bug-gollum-tag-filter.yml
new file mode 100644
index 00000000000..fc2c57cac61
--- /dev/null
+++ b/changelogs/unreleased/fj-fix-bug-gollum-tag-filter.yml
@@ -0,0 +1,5 @@
+---
+title: Fix bug in Gollum Tags filter
+merge_request: 56638
+author:
+type: fixed
diff --git a/changelogs/unreleased/js-semgrep.yml b/changelogs/unreleased/js-semgrep.yml
new file mode 100644
index 00000000000..36f374e3ab0
--- /dev/null
+++ b/changelogs/unreleased/js-semgrep.yml
@@ -0,0 +1,5 @@
+---
+title: Add JavaScript, TypeScript, and React support to the semgrep analyzer.
+merge_request: 55257
+author:
+type: added
diff --git a/changelogs/unreleased/pedropombeiro-variable_inside_variable.yml b/changelogs/unreleased/pedropombeiro-variable_inside_variable.yml
new file mode 100644
index 00000000000..c06f43f86b0
--- /dev/null
+++ b/changelogs/unreleased/pedropombeiro-variable_inside_variable.yml
@@ -0,0 +1,5 @@
+---
+title: Resolve nested variable values sent to the runner
+merge_request: 48627
+author:
+type: added
diff --git a/config/feature_flags/development/ci_register_job_service_one_by_one.yml b/config/feature_flags/development/ci_register_job_service_one_by_one.yml
index 7ce58d06bdc..8f691a01605 100644
--- a/config/feature_flags/development/ci_register_job_service_one_by_one.yml
+++ b/config/feature_flags/development/ci_register_job_service_one_by_one.yml
@@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323177
milestone: '13.10'
type: development
group: group::memory
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/dora_daily_metrics.yml b/config/feature_flags/development/dora_daily_metrics.yml
deleted file mode 100644
index 7ca3cf66ea4..00000000000
--- a/config/feature_flags/development/dora_daily_metrics.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: dora_daily_metrics
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/55473
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/291746
-milestone: '13.10'
-type: development
-group: group::release
-default_enabled: false
diff --git a/config/feature_flags/development/gitlab_ci_builds_queue_limit.yml b/config/feature_flags/development/gitlab_ci_builds_queue_limit.yml
index 42310def889..cef1fc98f52 100644
--- a/config/feature_flags/development/gitlab_ci_builds_queue_limit.yml
+++ b/config/feature_flags/development/gitlab_ci_builds_queue_limit.yml
@@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/323201
milestone: '13.10'
type: development
group: group::continuous integration
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/variable_inside_variable.yml b/config/feature_flags/development/variable_inside_variable.yml
index 1e75576a97a..2060958590f 100644
--- a/config/feature_flags/development/variable_inside_variable.yml
+++ b/config/feature_flags/development/variable_inside_variable.yml
@@ -1,8 +1,8 @@
---
name: variable_inside_variable
-introduced_by_url:
-rollout_issue_url:
-milestone: '13.7'
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50156
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/297382
+milestone: '13.11'
type: development
group: group::runner
default_enabled: false
diff --git a/config/feature_flags/experiment/invite_members_in_comment.yml b/config/feature_flags/experiment/invite_members_in_comment.yml
new file mode 100644
index 00000000000..521574ad71b
--- /dev/null
+++ b/config/feature_flags/experiment/invite_members_in_comment.yml
@@ -0,0 +1,8 @@
+---
+name: invite_members_in_comment
+introduced_by_url: 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/51400'
+rollout_issue_url: 'https://gitlab.com/gitlab-org/growth/team-tasks/-/issues/300'
+milestone: '13.10'
+type: experiment
+group: group::expansion
+default_enabled: false
diff --git a/doc/api/discussions.md b/doc/api/discussions.md
index 6d0c5afa35d..828370c3386 100644
--- a/doc/api/discussions.md
+++ b/doc/api/discussions.md
@@ -860,8 +860,8 @@ Parameters for all comments:
| `position[line_range]` | hash | no | Line range for a multi-line diff note |
| `position[width]` | integer | no | Width of the image (for `image` diff notes) |
| `position[height]` | integer | no | Height of the image (for `image` diff notes) |
-| `position[x]` | integer | no | X coordinate (for `image` diff notes) |
-| `position[y]` | integer | no | Y coordinate (for `image` diff notes) |
+| `position[x]` | float | no | X coordinate (for `image` diff notes) |
+| `position[y]` | float | no | Y coordinate (for `image` diff notes) |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions?body=comment"
diff --git a/doc/api/dora/metrics.md b/doc/api/dora/metrics.md
new file mode 100644
index 00000000000..e04e1fe27b4
--- /dev/null
+++ b/doc/api/dora/metrics.md
@@ -0,0 +1,90 @@
+---
+stage: Release
+group: Release
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
+type: reference, api
+---
+
+# DevOps Research and Assessment (DORA) key metrics API **(ULTIMATE)**
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/279039) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.10.
+
+All methods require [reporter permissions and above](../../user/permissions.md).
+
+## Get project-level DORA metrics
+
+Get project-level DORA metrics.
+
+```plaintext
+GET /projects/:id/dora/metrics
+```
+
+| Attribute | Type | Required | Description |
+|-------------- |-------- |----------|----------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../README.md#namespaced-path-encoding) can be accessed by the authenticated user. |
+| `metric` | string | yes | The [metric name](../../user/analytics/ci_cd_analytics.md#supported-metrics-in-gitlab). One of `deployment_frequency` or `lead_time_for_changes`. |
+| `start_date` | string | no | Date range to start from. ISO 8601 Date format, for example `2021-03-01`. Default is 3 months ago. |
+| `end_date` | string | no | Date range to end at. ISO 8601 Date format, for example `2021-03-01`. Default is the current date. |
+| `interval` | string | no | The bucketing interval. One of `all`, `monthly` or `daily`. Default is `daily`. |
+| `environment_tier` | string | no | The [tier of the environment](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. |
+
+Example request:
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/dora/metrics?metric=deployment_frequency"
+```
+
+Example response:
+
+```json
+[
+ { "2021-03-01": 3 },
+ { "2021-03-02": 6 },
+ { "2021-03-03": 0 },
+ { "2021-03-04": 0 },
+ { "2021-03-05": 0 },
+ { "2021-03-06": 0 },
+ { "2021-03-07": 0 },
+ { "2021-03-08": 4 }
+]
+```
+
+## Get group-level DORA metrics
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/279039) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.10.
+
+Get group-level DORA metrics.
+
+```plaintext
+GET /groups/:id/dora/metrics
+```
+
+| Attribute | Type | Required | Description |
+|-------------- |-------- |----------|----------------------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](../README.md#namespaced-path-encoding) can be accessed by the authenticated user. |
+| `metric` | string | yes | The [metric name](../../user/analytics/ci_cd_analytics.md#supported-metrics-in-gitlab). One of `deployment_frequency` or `lead_time_for_changes`. |
+| `start_date` | string | no | Date range to start from. ISO 8601 Date format, for example `2021-03-01`. Default is 3 months ago. |
+| `end_date` | string | no | Date range to end at. ISO 8601 Date format, for example `2021-03-01`. Default is the current date. |
+| `interval` | string | no | The bucketing interval. One of `all`, `monthly` or `daily`. Default is `daily`. |
+| `environment_tier` | string | no | The [tier of the environment](../../ci/environments/index.md#deployment-tier-of-environments). Default is `production`. |
+
+Example request:
+
+```shell
+curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/1/dora/metrics?metric=deployment_frequency"
+```
+
+Example response:
+
+```json
+[
+ { "2021-03-01": 3 },
+ { "2021-03-02": 6 },
+ { "2021-03-03": 0 },
+ { "2021-03-04": 0 },
+ { "2021-03-05": 0 },
+ { "2021-03-06": 0 },
+ { "2021-03-07": 0 },
+ { "2021-03-08": 4 }
+]
+```
diff --git a/doc/ci/variables/where_variables_can_be_used.md b/doc/ci/variables/where_variables_can_be_used.md
index 16c02b3482b..5f84e1cc44a 100644
--- a/doc/ci/variables/where_variables_can_be_used.md
+++ b/doc/ci/variables/where_variables_can_be_used.md
@@ -28,7 +28,7 @@ There are two places defined variables can be used. On the:
| `environment:name` | yes | GitLab | Similar to `environment:url`, but the variables expansion doesn't support the following:<br/><br/>- Variables that are based on the environment's name (`CI_ENVIRONMENT_NAME`, `CI_ENVIRONMENT_SLUG`).<br/>- Any other variables related to environment (currently only `CI_ENVIRONMENT_URL`).<br/>- [Persisted variables](#persisted-variables). |
| `resource_group` | yes | GitLab | Similar to `environment:url`, but the variables expansion doesn't support the following:<br/><br/>- Variables that are based on the environment's name (`CI_ENVIRONMENT_NAME`, `CI_ENVIRONMENT_SLUG`).<br/>- Any other variables related to environment (currently only `CI_ENVIRONMENT_URL`).<br/>- [Persisted variables](#persisted-variables). |
| `include` | yes | GitLab | The variable expansion is made by the [internal variable expansion mechanism](#gitlab-internal-variable-expansion-mechanism) in GitLab. <br/><br/>Predefined project variables are supported: `GITLAB_FEATURES`, `CI_DEFAULT_BRANCH`, and all variables that start with `CI_PROJECT_` (for example `CI_PROJECT_NAME`). |
-| `variables` | yes | Runner | The variable expansion is made by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism) |
+| `variables` | yes | GitLab/Runner | The variable expansion is first made by the [internal variable expansion mechanism](#gitlab-internal-variable-expansion-mechanism) in GitLab, and then any unrecognized or unavailable variables are expanded by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism). |
| `image` | yes | Runner | The variable expansion is made by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism) |
| `services:[]` | yes | Runner | The variable expansion is made by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism) |
| `services:[]:name` | yes | Runner | The variable expansion is made by GitLab Runner's [internal variable expansion mechanism](#gitlab-runner-internal-variable-expansion-mechanism) |
@@ -61,6 +61,54 @@ The expanded part needs to be in a form of `$variable`, or `${variable}` or `%va
Each form is handled in the same way, no matter which OS/shell handles the job,
because the expansion is done in GitLab before any runner gets the job.
+#### Nested variable expansion
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48627) in GitLab 13.10.
+> - It's [deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
+> - It can be enabled or disabled for a single project.
+> - It's disabled on GitLab.com.
+> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enabling-the-nested-variable-expansion-feature). **(FREE SELF)**
+
+GitLab expands job variable values recursively before sending them to the runner. For example:
+
+```yaml
+- BUILD_ROOT_DIR: '${CI_BUILDS_DIR}'
+- OUT_PATH: '${BUILD_ROOT_DIR}/out'
+- PACKAGE_PATH: '${OUT_PATH}/pkg'
+```
+
+If nested variable expansion is:
+
+- **Disabled**: the runner receives `${BUILD_ROOT_DIR}/out/pkg`. This is not a valid path.
+- **Enabled**: the runner receives a valid, fully-formed path. For example, if `${CI_BUILDS_DIR}` is `/output`, then `PACKAGE_PATH` would be `/output/out/pkg`.
+
+References to unavailable variables are left intact. In this case, the runner
+[attempts to expand the variable value](#gitlab-runner-internal-variable-expansion-mechanism) at runtime.
+For example, a variable like `CI_BUILDS_DIR` is known by the runner only at runtime.
+
+##### Enabling the nested variable expansion feature **(FREE SELF)**
+
+This feature comes with the `:variable_inside_variable` feature flag disabled by default.
+
+To enable this feature, ask a GitLab administrator with [Rails console access](../../administration/feature_flags.md#how-to-enable-and-disable-features-behind-flags) to run the
+following command:
+
+```ruby
+# For the instance
+Feature.enable(:variable_inside_variable)
+# For a single project
+Feature.enable(:variable_inside_variable, Project.find(<project id>))
+```
+
+To disable it:
+
+```ruby
+# For the instance
+Feature.disable(:variable_inside_variable)
+# For a single project
+Feature.disable(:variable_inside_variable, Project.find(<project id>))
+```
+
### GitLab Runner internal variable expansion mechanism
- Supported: project/group variables, `.gitlab-ci.yml` variables, `config.toml` variables, and
@@ -70,16 +118,17 @@ because the expansion is done in GitLab before any runner gets the job.
The runner uses Go's `os.Expand()` method for variable expansion. It means that it handles
only variables defined as `$variable` and `${variable}`. What's also important, is that
the expansion is done only once, so nested variables may or may not work, depending on the
-ordering of variables definitions.
+ordering of variables definitions, and whether [nested variable expansion](#nested-variable-expansion)
+is enabled in GitLab.
### Execution shell environment
-This is an expansion that takes place during the `script` execution.
-How it works depends on the used shell (`bash`, `sh`, `cmd`, PowerShell). For example, if the job's
+This is an expansion phase that takes place during the `script` execution.
+Its behavior depends on the shell used (`bash`, `sh`, `cmd`, PowerShell). For example, if the job's
`script` contains a line `echo $MY_VARIABLE-${MY_VARIABLE_2}`, it should be properly handled
by bash/sh (leaving empty strings or some values depending whether the variables were
defined or not), but don't work with Windows' `cmd` or PowerShell, since these shells
-are using a different variables syntax.
+use a different variables syntax.
Supported:
@@ -88,10 +137,10 @@ Supported:
`.gitlab-ci.yml` variables, `config.toml` variables, and variables from triggers and pipeline schedules).
- The `script` may also use all variables defined in the lines before. So, for example, if you define
a variable `export MY_VARIABLE="test"`:
- - In `before_script`, it works in the following lines of `before_script` and
+ - In `before_script`, it works in the subsequent lines of `before_script` and
all lines of the related `script`.
- - In `script`, it works in the following lines of `script`.
- - In `after_script`, it works in following lines of `after_script`.
+ - In `script`, it works in the subsequent lines of `script`.
+ - In `after_script`, it works in subsequent lines of `after_script`.
In the case of `after_script` scripts, they can:
@@ -99,7 +148,7 @@ In the case of `after_script` scripts, they can:
section.
- Not use variables defined in `before_script` and `script`.
-These restrictions are because `after_script` scripts are executed in a
+These restrictions exist because `after_script` scripts are executed in a
[separated shell context](../yaml/README.md#after_script).
## Persisted variables
diff --git a/doc/development/emails.md b/doc/development/emails.md
index 1de1da33dc2..3e651a6efb8 100644
--- a/doc/development/emails.md
+++ b/doc/development/emails.md
@@ -54,9 +54,12 @@ See the [Rails guides](https://guides.rubyonrails.org/action_mailer_basics.html#
incoming_email:
enabled: true
- # The email address including the %{key} placeholder that will be replaced to reference the item being replied to. This %{key} should be included in its entirety within the email address and not replaced by another value.
- # For example: emailadress+%key@gmail.com.
- # The placeholder can be omitted but if present, it must appear in the "user" part of the address (before the `@`).
+ # The email address including the %{key} placeholder that will be replaced to reference the
+ # item being replied to. This %{key} should be included in its entirety within the email
+ # address and not replaced by another value.
+ # For example: emailadress+%{key}@gmail.com.
+ # The placeholder must appear in the "user" part of the address (before the `@`). It can be omitted but some features,
+ # including Service Desk, may not work properly.
address: "gitlab-incoming+%{key}@gmail.com"
# Email account username
diff --git a/doc/development/fe_guide/troubleshooting.md b/doc/development/fe_guide/troubleshooting.md
index 250fe5106d3..1b3991ee80d 100644
--- a/doc/development/fe_guide/troubleshooting.md
+++ b/doc/development/fe_guide/troubleshooting.md
@@ -66,3 +66,29 @@ TypeError: $ is not a function
```
**Remedy - Try moving the script into a separate repository and point to it to files in the GitLab repository**
+
+## Using Vue component issues
+
+### When rendering a component that uses GlFilteredSearch and the component or its parent uses Vue Apollo
+
+When trying to render our component GlFilteredSearch, you might get an error in the component's `provide` function:
+
+`cannot read suggestionsListClass of undefined`
+
+Currently, `vue-apollo` tries to [manually call a component's `provide()` in the `beforeCreate` part](https://github.com/vuejs/vue-apollo/blob/35e27ec398d844869e1bbbde73c6068b8aabe78a/packages/vue-apollo/src/mixin.js#L149) of the component lifecycle. This means that when a `provide()` references props, which aren't actually setup until after `created`, it will blow up.
+
+See this [closed MR](https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/2019#note_514671251) for more context.
+
+**Remedy - try providing `apolloProvider` to the top-level Vue instance options**
+
+VueApollo will skip manually running `provide()` if it sees that an `apolloProvider` is provided in the `$options`.
+
+```patch
+ new Vue(
+ el,
++ apolloProvider: {},
+ render(h) {
+ return h(App);
+ },
+ );
+```
diff --git a/doc/integration/img/enabled-oauth-sign-in-sources.png b/doc/integration/img/enabled-oauth-sign-in-sources.png
deleted file mode 100644
index e83f9d5cfdf..00000000000
--- a/doc/integration/img/enabled-oauth-sign-in-sources.png
+++ /dev/null
Binary files differ
diff --git a/doc/integration/img/enabled-oauth-sign-in-sources_v13_10.png b/doc/integration/img/enabled-oauth-sign-in-sources_v13_10.png
new file mode 100644
index 00000000000..86f6402c168
--- /dev/null
+++ b/doc/integration/img/enabled-oauth-sign-in-sources_v13_10.png
Binary files differ
diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md
index e3b18c0b82b..fd6d2c7f893 100644
--- a/doc/integration/omniauth.md
+++ b/doc/integration/omniauth.md
@@ -257,9 +257,9 @@ To enable/disable an OmniAuth provider:
1. In the top navigation bar, go to **Admin Area**.
1. In the left sidebar, go to **Settings**.
1. Scroll to the **Sign-in Restrictions** section, and click **Expand**.
-1. Next to **Enabled OAuth Sign-In sources**, select the check box for each provider you want to enable or disable.
+1. Below **Enabled OAuth Sign-In sources**, select the check box for each provider you want to enable or disable.
- ![Enabled OAuth Sign-In sources](img/enabled-oauth-sign-in-sources.png)
+ ![Enabled OAuth Sign-In sources](img/enabled-oauth-sign-in-sources_v13_10.png)
## Disabling OmniAuth
@@ -356,3 +356,32 @@ You may also bypass the auto sign in feature by browsing to
The [Generated passwords for users created through integrated authentication](../security/passwords_for_integrated_authentication_methods.md)
guide provides an overview about how GitLab generates and sets passwords for
users created with OmniAuth.
+
+## Custom OmniAuth provider icon
+
+Most supported providers include a built-in icon for the rendered sign-in button.
+After you ensure your image is optimized for rendering at 64 x 64 pixels,
+you can override this icon in one of two ways:
+
+- **Provide a custom image path**:
+ 1. *If you are hosting the image outside of your GitLab server domain,* ensure
+ your [content security policies](https://docs.gitlab.com/omnibus/settings/configuration.html#content-security-policy)
+ are configured to allow access to the image file.
+ 1. Depending on your method of installing GitLab, add a custom `icon` parameter
+ to your GitLab configuration file. Read [OpenID Connect OmniAuth provider](../administration/auth/oidc.md)
+ for an example for the OpenID Connect provider.
+- **Directly embed an image in a configuration file**: This example creates a Base64-encoded
+ version of your image you can serve through a
+ [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs):
+ 1. Encode your image file with GNU `base64` command (such as `base64 -w 0 <logo.png>`)
+ which returns a single-line `<base64-data>` string.
+ 1. Add the Base64-encoded data to a custom `icon` parameter in your GitLab configuration file:
+
+ ```yaml
+ omniauth:
+ providers:
+ - { name: '...'
+ icon: 'data:image/png;base64,<base64-data>'
+ ...
+ }
+ ```
diff --git a/doc/operations/index.md b/doc/operations/index.md
index 4427dd66f3d..8d0aaaf7cb2 100644
--- a/doc/operations/index.md
+++ b/doc/operations/index.md
@@ -51,7 +51,7 @@ and the work required to fix them - all without leaving GitLab.
- Discover and view errors generated by your applications with
[Error Tracking](error_tracking.md).
-## Trace application health and performance **(ULTIMATE)**
+## Trace application health and performance
Application tracing in GitLab is a way to measure an application's performance and
health while it's running. After configuring your application to enable tracing, you
@@ -63,7 +63,7 @@ GitLab integrates with [Jaeger](https://www.jaegertracing.io/) - an open-source,
end-to-end distributed tracing system tool used for monitoring and troubleshooting
microservices-based distributed systems - and displays results within GitLab.
-- [Trace the performance and health](tracing.md) of a deployed application. **(ULTIMATE)**
+- [Trace the performance and health](tracing.md) of a deployed application.
## Aggregate and store logs
diff --git a/doc/user/analytics/ci_cd_analytics.md b/doc/user/analytics/ci_cd_analytics.md
index 0f19998749d..0182576a86f 100644
--- a/doc/user/analytics/ci_cd_analytics.md
+++ b/doc/user/analytics/ci_cd_analytics.md
@@ -22,7 +22,10 @@ View pipeline duration history:
![Pipeline duration](img/pipelines_duration_chart.png)
-## DORA4 Metrics
+## DevOps Research and Assessment (DORA) key metrics **(ULTIMATE)**
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/275991) in GitLab 13.7.
+> - Added support for [lead time for changes](https://gitlab.com/gitlab-org/gitlab/-/issues/291746) in GitLab 13.10.
Customer experience is a key metric. Users want to measure platform stability and other
post-deployment performance KPIs, and set targets for customer behavior, experience, and financial
@@ -41,9 +44,18 @@ performance indicators for software development teams:
- Time to restore service: How long it takes an organization to recover from a failure in
production.
-GitLab plans to add support for all the DORA4 metrics at the project and group levels. GitLab added
-the first metric, deployment frequency, at the project and group scopes for [CI/CD charts](ci_cd_analytics.md#deployment-frequency-charts),
-the [Project API]( ../../api/dora4_project_analytics.md), and the [Group API]( ../../api/dora4_group_analytics.md).
+### Supported metrics in GitLab
+
+The following table shows the supported metrics, at which level they are supported, and which GitLab version (API and UI) they were introduced:
+
+| Metric | Level | API version | Chart (UI) version | Comments |
+| --------------- | ----------- | --------------- | ---------- | ------- |
+| `deployment_frequency` | Project-level | [13.7+](../../api/dora/metrics.md) | [13.8+](#deployment-frequency-charts) | The [old API endopint](../../api/dora4_project_analytics.md) was [deprecated](https://gitlab.com/gitlab-org/gitlab/-/issues/323713) in 13.10. |
+| `deployment_frequency` | Group-level | [13.10+](../../api/dora/metrics.md) | To be supported | |
+| `lead_time_for_changes` | Project-level | [13.10+](../../api/dora/metrics.md) | To be supported | Unit in seconds. Aggregation method is median. |
+| `lead_time_for_changes` | Group-level | [13.10+](../../api/dora/metrics.md) | To be supported | Unit in seconds. Aggregation method is median. |
+| `change_failure_rate` | Project/Group-level | To be supported | To be supported | |
+| `time_to_restore_service` | Project/Group-level | To be supported | To be supported | |
## Deployment frequency charts **(ULTIMATE)**
diff --git a/doc/user/application_security/sast/index.md b/doc/user/application_security/sast/index.md
index b71cefbc7fe..e27a94ec3df 100644
--- a/doc/user/application_security/sast/index.md
+++ b/doc/user/application_security/sast/index.md
@@ -64,32 +64,35 @@ GitLab SAST supports a variety of languages, package managers, and frameworks. O
You can also [view our language roadmap](https://about.gitlab.com/direction/secure/static-analysis/sast/#language-support) and [request other language support by opening an issue](https://gitlab.com/groups/gitlab-org/-/epics/297).
-| Language (package managers) / framework | Scan tool | Introduced in GitLab Version |
-|--------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| .NET Core | [Security Code Scan](https://security-code-scan.github.io) | 11.0 |
-| .NET Framework | [Security Code Scan](https://security-code-scan.github.io) | 13.0 |
-| Apex (Salesforce) | [PMD](https://pmd.github.io/pmd/index.html) | 12.1 |
-| C/C++ | [Flawfinder](https://github.com/david-a-wheeler/flawfinder) | 10.7 |
-| Elixir (Phoenix) | [Sobelow](https://github.com/nccgroup/sobelow) | 11.1 |
-| Go | [Gosec](https://github.com/securego/gosec) | 10.7 |
-| Groovy ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.3 (Gradle) & 11.9 (Ant, Maven, SBT) |
-| Helm Charts | [Kubesec](https://github.com/controlplaneio/kubesec) | 13.1 |
-| Java ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 10.6 (Maven), 10.8 (Gradle) & 11.9 (Ant, SBT) |
-| Java (Android) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
-| JavaScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.8 |
-| Kotlin (Android) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
-| Kubernetes manifests | [Kubesec](https://github.com/controlplaneio/kubesec) | 12.6 |
-| Node.js | [NodeJsScan](https://github.com/ajinabraham/NodeJsScan) | 11.1 |
-| Objective-C (iOS) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
-| PHP | [phpcs-security-audit](https://github.com/FloeDesignTechnologies/phpcs-security-audit) | 10.8 |
-| Python ([pip](https://pip.pypa.io/en/stable/)) | [bandit](https://github.com/PyCQA/bandit) | 10.3 |
-| Python | [Semgrep](https://semgrep.dev) | 13.9 |
-| React | [ESLint react plugin](https://github.com/yannickcr/eslint-plugin-react) | 12.5 |
-| Ruby | [brakeman](https://brakemanscanner.org) | 13.9 |
-| Ruby on Rails | [brakeman](https://brakemanscanner.org) | 10.3 |
-| Scala ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.0 (SBT) & 11.9 (Ant, Gradle, Maven) |
-| Swift (iOS) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
-| TypeScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.9, [merged](https://gitlab.com/gitlab-org/gitlab/-/issues/36059) with ESLint in 13.2 |
+| Language (package managers) / framework | Scan tool | Introduced in GitLab Version |
+|---------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------|
+| .NET Core | [Security Code Scan](https://security-code-scan.github.io) | 11.0 |
+| .NET Framework | [Security Code Scan](https://security-code-scan.github.io) | 13.0 |
+| Apex (Salesforce) | [PMD](https://pmd.github.io/pmd/index.html) | 12.1 |
+| C/C++ | [Flawfinder](https://github.com/david-a-wheeler/flawfinder) | 10.7 |
+| Elixir (Phoenix) | [Sobelow](https://github.com/nccgroup/sobelow) | 11.1 |
+| Go | [Gosec](https://github.com/securego/gosec) | 10.7 |
+| Groovy ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.3 (Gradle) & 11.9 (Ant, Maven, SBT) |
+| Helm Charts | [Kubesec](https://github.com/controlplaneio/kubesec) | 13.1 |
+| Java ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 10.6 (Maven), 10.8 (Gradle) & 11.9 (Ant, SBT) |
+| Java (Android) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
+| JavaScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.8 |
+| JavaScript | [Semgrep](https://semgrep.dev) | 13.10 |
+| Kotlin (Android) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
+| Kubernetes manifests | [Kubesec](https://github.com/controlplaneio/kubesec) | 12.6 |
+| Node.js | [NodeJsScan](https://github.com/ajinabraham/NodeJsScan) | 11.1 |
+| Objective-C (iOS) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
+| PHP | [phpcs-security-audit](https://github.com/FloeDesignTechnologies/phpcs-security-audit) | 10.8 |
+| Python ([pip](https://pip.pypa.io/en/stable/)) | [bandit](https://github.com/PyCQA/bandit) | 10.3 |
+| Python | [Semgrep](https://semgrep.dev) | 13.9 |
+| React | [ESLint react plugin](https://github.com/yannickcr/eslint-plugin-react) | 12.5 |
+| React | [Semgrep](https://semgrep.dev) | 13.10 |
+| Ruby | [brakeman](https://brakemanscanner.org) | 13.9 |
+| Ruby on Rails | [brakeman](https://brakemanscanner.org) | 10.3 |
+| Scala ([Ant](https://ant.apache.org/), [Gradle](https://gradle.org/), [Maven](https://maven.apache.org/), and [SBT](https://www.scala-sbt.org/)) | [SpotBugs](https://spotbugs.github.io/) with the [find-sec-bugs](https://find-sec-bugs.github.io/) plugin | 11.0 (SBT) & 11.9 (Ant, Gradle, Maven) |
+| Swift (iOS) | [MobSF (beta)](https://github.com/MobSF/Mobile-Security-Framework-MobSF) | 13.5 |
+| TypeScript | [ESLint security plugin](https://github.com/nodesecurity/eslint-plugin-security) | 11.9, [merged](https://gitlab.com/gitlab-org/gitlab/-/issues/36059) with ESLint in 13.2 |
+| TypeScript | [Semgrep](https://semgrep.dev) | 13.10 |
Note that the Java analyzers can also be used for variants like the
[Gradle wrapper](https://docs.gradle.org/current/userguide/gradle_wrapper.html),
diff --git a/doc/user/group/value_stream_analytics/img/vsa_path_nav_v13_10.png b/doc/user/group/value_stream_analytics/img/vsa_path_nav_v13_10.png
new file mode 100644
index 00000000000..493f95f52e0
--- /dev/null
+++ b/doc/user/group/value_stream_analytics/img/vsa_path_nav_v13_10.png
Binary files differ
diff --git a/doc/user/group/value_stream_analytics/index.md b/doc/user/group/value_stream_analytics/index.md
index 52cf51d85a4..96eecfb2759 100644
--- a/doc/user/group/value_stream_analytics/index.md
+++ b/doc/user/group/value_stream_analytics/index.md
@@ -193,17 +193,21 @@ GitLab allows users to create multiple value streams, hide default stages and cr
### Stage path
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210315) in GitLab 13.0.
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/210315) in GitLab 13.0.
+> - It's [deployed behind a feature flag](../../feature_flags.md), enabled by default.
+> - It's enabled on GitLab.com.
+> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](../../../administration/feature_flags.md). **(FREE SELF)**
+
+![Value stream path navigation](img/vsa_path_nav_v13_10.png "Value stream path navigation")
-Stages are visually depicted as a horizontal process flow. Selecting a stage will update the
-the content below the value stream.
+Stages are visually depicted as a horizontal process flow. Selecting a stage updates the content below the value stream.
-This is disabled by default. If you have a self-managed instance, an
+This is enabled by default. If you have a self-managed instance, an
administrator can [open a Rails console](../../../administration/troubleshooting/navigating_gitlab_via_rails_console.md)
-and enable it with the following command:
+and disable it with the following command:
```ruby
-Feature.enable(:value_stream_analytics_path_navigation)
+Feature.disable(:value_stream_analytics_path_navigation)
```
### Adding a stage
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index aad1d7edfda..74c23769cc1 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -87,6 +87,7 @@ The following table depicts the various user permission levels in a project.
| See a commit status | | ✓ | ✓ | ✓ | ✓ |
| See a container registry | | ✓ | ✓ | ✓ | ✓ |
| See environments | | ✓ | ✓ | ✓ | ✓ |
+| See [DORA metrics](analytics/ci_cd_analytics.md) | | ✓ | ✓ | ✓ | ✓ |
| See a list of merge requests | | ✓ | ✓ | ✓ | ✓ |
| View CI/CD analytics | | ✓ | ✓ | ✓ | ✓ |
| View Code Review analytics **(STARTER)** | | ✓ | ✓ | ✓ | ✓ |
diff --git a/lefthook.yml b/lefthook.yml
index 9284b872e7f..503ed8f8bb6 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -6,35 +6,35 @@ pre-push:
eslint:
tags: frontend style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
- glob: "*.{js,vue}"
+ glob: '*.{js,vue}'
run: yarn run lint:eslint {files}
haml-lint:
tags: view haml style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
- glob: "*.html.haml"
+ glob: '*.html.haml'
run: bundle exec haml-lint --config .haml-lint.yml {files}
markdownlint:
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
- glob: "doc/*.md"
+ glob: 'doc/*.md'
run: yarn markdownlint {files}
stylelint:
tags: stylesheet css style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
- glob: "*.scss{,.css}"
- run: yarn stylelint -q {files}
+ glob: '*.scss{,.css}'
+ run: yarn stylelint {files}
prettier:
tags: frontend style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
- glob: "*.{js,vue,graphql}"
+ glob: '*.{js,vue,graphql}'
run: yarn run prettier --check {files}
rubocop:
tags: backend style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
- glob: "*.rb"
+ glob: '*.rb'
run: bundle exec rubocop --parallel --force-exclusion {files}
- vale: # Requires Vale: https://docs.gitlab.com/ee/development/documentation/#install-linters
+ vale: # Requires Vale: https://docs.gitlab.com/ee/development/documentation/#install-linters
tags: documentation style
files: git diff --name-only --diff-filter=d $(git merge-base origin/master HEAD)..HEAD
- glob: "doc/*.md"
+ glob: 'doc/*.md'
run: if command -v vale 2> /dev/null; then vale --config .vale.ini --minAlertLevel error {files}; else echo "Vale not found. Install Vale"; fi
diff --git a/lib/banzai/filter/gollum_tags_filter.rb b/lib/banzai/filter/gollum_tags_filter.rb
index 8a7d3c49ffb..6de9f2b86f6 100644
--- a/lib/banzai/filter/gollum_tags_filter.rb
+++ b/lib/banzai/filter/gollum_tags_filter.rb
@@ -98,14 +98,15 @@ module Banzai
return unless image?(content)
- if url?(content)
- path = content
- elsif file = wiki.find_file(content, load_content: false)
- path = ::File.join(wiki_base_path, file.path)
- end
+ path =
+ if url?(content)
+ content
+ elsif file = wiki.find_file(content, load_content: false)
+ file.path
+ end
if path
- content_tag(:img, nil, data: { src: path }, class: 'gfm')
+ content_tag(:img, nil, src: path, class: 'gfm')
end
end
diff --git a/lib/banzai/pipeline/wiki_pipeline.rb b/lib/banzai/pipeline/wiki_pipeline.rb
index 97a03895ff3..caba9570ab9 100644
--- a/lib/banzai/pipeline/wiki_pipeline.rb
+++ b/lib/banzai/pipeline/wiki_pipeline.rb
@@ -5,7 +5,7 @@ module Banzai
class WikiPipeline < FullPipeline
def self.filters
@filters ||= begin
- super.insert_after(Filter::TableOfContentsFilter, Filter::GollumTagsFilter)
+ super.insert_before(Filter::ImageLazyLoadFilter, Filter::GollumTagsFilter)
.insert_before(Filter::TaskListFilter, Filter::WikiLinkFilter)
end
end
diff --git a/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml
index 9693a4fbca2..90dc80a3fc0 100644
--- a/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Security/SAST.gitlab-ci.yml
@@ -303,6 +303,10 @@ semgrep-sast:
$SAST_EXPERIMENTAL_FEATURES == 'true'
exists:
- '**/*.py'
+ - '**/*.js'
+ - '**/*.jsx'
+ - '**/*.ts'
+ - '**/*.tsx'
sobelow-sast:
extends: .sast-analyzer
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 635cc8d9ddf..2c97671a2e0 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -16875,6 +16875,9 @@ msgstr ""
msgid "InviteMember|Don't worry, you can always invite teammates later"
msgstr ""
+msgid "InviteMember|Invite Member"
+msgstr ""
+
msgid "InviteMember|Invite Members (optional)"
msgstr ""
diff --git a/package.json b/package.json
index 978b9079aaa..9f0b3124b75 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"file-coverage": "scripts/frontend/file_test_coverage.js",
"lint-docs": "scripts/lint-doc.sh",
"internal:eslint": "eslint --cache --max-warnings 0 --report-unused-disable-directives --ext .js,.vue",
+ "internal:stylelint": "stylelint -q '{ee/,}app/assets/stylesheets/**/*.{css,scss}'",
"prejest": "yarn check-dependencies",
"jest": "jest --config jest.config.js",
"jest-debug": "node --inspect-brk node_modules/.bin/jest --runInBand",
@@ -32,7 +33,7 @@
"lint:prettier:fix": "yarn run prettier --write '**/*.{graphql,js,vue}'",
"lint:prettier:staged": "scripts/frontend/execute-on-staged-files.sh prettier '(graphql|js|vue)' --check",
"lint:prettier:staged:fix": "scripts/frontend/execute-on-staged-files.sh prettier '(graphql|js|vue)' --write",
- "lint:stylelint": "stylelint -q '{ee/,}app/assets/stylesheets/**/*.{css,scss}'",
+ "lint:stylelint": "stylelint '{ee/,}app/assets/stylesheets/**/*.{css,scss}'",
"lint:stylelint:fix": "yarn run lint:stylelint --fix",
"lint:stylelint:staged": "scripts/frontend/execute-on-staged-files.sh stylelint '(css|scss)' -q",
"lint:stylelint:staged:fix": "yarn run lint:stylelint:staged --fix",
diff --git a/scripts/static-analysis b/scripts/static-analysis
index 2442455e630..136b2966244 100755
--- a/scripts/static-analysis
+++ b/scripts/static-analysis
@@ -33,7 +33,7 @@ class StaticAnalysis
%w[bin/rake gitlab:sidekiq:all_queues_yml:check] => 13,
(Gitlab.ee? ? %w[bin/rake gitlab:sidekiq:sidekiq_queues_yml:check] : nil) => 13,
%w[bin/rake config_lint] => 11,
- %w[yarn run lint:stylelint] => 9,
+ %w[yarn run internal:stylelint] => 9,
%w[scripts/lint-conflicts.sh] => 0.59,
%w[yarn run block-dependencies] => 0.35,
%w[scripts/lint-rugged] => 0.23,
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 74062038248..6b06e224189 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -209,6 +209,32 @@ RSpec.describe Projects::IssuesController do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['issue_email_participants']).to contain_exactly({ "email" => participants[0].email }, { "email" => participants[1].email })
end
+
+ context 'with the invite_members_in_comment experiment', :experiment do
+ context 'when user can invite' do
+ before do
+ stub_experiments(invite_members_in_comment: :invite_member_link)
+ project.add_maintainer(user)
+ end
+
+ it 'assigns the candidate experience and tracks the event' do
+ expect(experiment(:invite_member_link)).to track(:view, property: project.root_ancestor.id.to_s)
+ .on_any_instance
+ .for(:invite_member_link)
+ .with_context(namespace: project.root_ancestor)
+
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: issue.iid }
+ end
+ end
+
+ context 'when user can not invite' do
+ it 'does not track the event' do
+ expect(experiment(:invite_member_link)).not_to track(:view)
+
+ get :show, params: { namespace_id: project.namespace, project_id: project, id: issue.iid }
+ end
+ end
+ end
end
describe 'GET #new' do
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 93d5e7eff6c..5d26597c29d 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -40,6 +40,32 @@ RSpec.describe Projects::MergeRequestsController do
get :show, params: params.merge(extra_params)
end
+ context 'with the invite_members_in_comment experiment', :experiment do
+ context 'when user can invite' do
+ before do
+ stub_experiments(invite_members_in_comment: :invite_member_link)
+ project.add_maintainer(user)
+ end
+
+ it 'assigns the candidate experience and tracks the event' do
+ expect(experiment(:invite_member_link)).to track(:view, property: project.root_ancestor.id.to_s)
+ .on_any_instance
+ .for(:invite_member_link)
+ .with_context(namespace: project.root_ancestor)
+
+ go
+ end
+ end
+
+ context 'when user can not invite' do
+ it 'does not track the event' do
+ expect(experiment(:invite_member_link)).not_to track(:view)
+
+ go
+ end
+ end
+ end
+
context 'with view param' do
before do
go(view: 'parallel')
diff --git a/spec/factories/atlassian_identities.rb b/spec/factories/atlassian_identities.rb
index 698cf4ae7ad..80420e335a9 100644
--- a/spec/factories/atlassian_identities.rb
+++ b/spec/factories/atlassian_identities.rb
@@ -3,7 +3,7 @@
FactoryBot.define do
factory :atlassian_identity, class: 'Atlassian::Identity' do
extern_uid { generate(:username) }
- user { create(:user) }
+ user { association(:user) }
expires_at { 2.weeks.from_now }
token { SecureRandom.alphanumeric(1254) }
refresh_token { SecureRandom.alphanumeric(45) }
diff --git a/spec/factories/events.rb b/spec/factories/events.rb
index 6c9f1ba0137..c9e4ada3ffa 100644
--- a/spec/factories/events.rb
+++ b/spec/factories/events.rb
@@ -27,17 +27,20 @@ FactoryBot.define do
factory :wiki_page_event do
action { :created }
+ # rubocop: disable FactoryBot/InlineAssociation
+ # A persistent project is needed to have a wiki page being created properly.
project { @overrides[:wiki_page]&.container || create(:project, :wiki_repo) }
- target { create(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
+ # rubocop: enable FactoryBot/InlineAssociation
+ target { association(:wiki_page_meta, :for_wiki_page, wiki_page: wiki_page) }
transient do
- wiki_page { create(:wiki_page, container: project) }
+ wiki_page { association(:wiki_page, container: project) }
end
end
trait :has_design do
transient do
- design { create(:design, issue: create(:issue, project: project)) }
+ design { association(:design, issue: association(:issue, project: project)) }
end
end
@@ -45,7 +48,7 @@ FactoryBot.define do
has_design
transient do
- note { create(:note, author: author, project: project, noteable: design) }
+ note { association(:note, author: author, project: project, noteable: design) }
end
action { :commented }
diff --git a/spec/factories/git_wiki_commit_details.rb b/spec/factories/git_wiki_commit_details.rb
index b35f102fd4d..fb3f2954b12 100644
--- a/spec/factories/git_wiki_commit_details.rb
+++ b/spec/factories/git_wiki_commit_details.rb
@@ -5,7 +5,7 @@ FactoryBot.define do
skip_create
transient do
- author { create(:user) }
+ author { association(:user) }
end
sequence(:message) { |n| "Commit message #{n}" }
diff --git a/spec/factories/gitaly/commit.rb b/spec/factories/gitaly/commit.rb
index 2ed201e9aac..4e8220e449a 100644
--- a/spec/factories/gitaly/commit.rb
+++ b/spec/factories/gitaly/commit.rb
@@ -14,7 +14,7 @@ FactoryBot.define do
subject { "My commit" }
body { subject + "\nMy body" }
- author { build(:gitaly_commit_author) }
- committer { build(:gitaly_commit_author) }
+ author { association(:gitaly_commit_author) }
+ committer { association(:gitaly_commit_author) }
end
end
diff --git a/spec/factories/group_group_links.rb b/spec/factories/group_group_links.rb
index 6f98886faff..2a582d8525b 100644
--- a/spec/factories/group_group_links.rb
+++ b/spec/factories/group_group_links.rb
@@ -2,8 +2,8 @@
FactoryBot.define do
factory :group_group_link do
- shared_group { create(:group) }
- shared_with_group { create(:group) }
+ shared_group { association(:group) }
+ shared_with_group { association(:group) }
group_access { Gitlab::Access::DEVELOPER }
trait(:guest) { group_access { Gitlab::Access::GUEST } }
diff --git a/spec/factories/import_export_uploads.rb b/spec/factories/import_export_uploads.rb
index 8521411e0e8..e1dd0c10ff2 100644
--- a/spec/factories/import_export_uploads.rb
+++ b/spec/factories/import_export_uploads.rb
@@ -2,6 +2,6 @@
FactoryBot.define do
factory :import_export_upload do
- project { create(:project) }
+ project { association(:project) }
end
end
diff --git a/spec/features/issues/user_invites_from_a_comment_spec.rb b/spec/features/issues/user_invites_from_a_comment_spec.rb
new file mode 100644
index 00000000000..82061f6ed79
--- /dev/null
+++ b/spec/features/issues/user_invites_from_a_comment_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "User invites from a comment", :js do
+ let_it_be(:project) { create(:project_empty_repo, :public) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:user) { project.owner }
+
+ before do
+ sign_in(user)
+ end
+
+ it "launches the invite modal from invite link on a comment" do
+ stub_experiments(invite_members_in_comment: :invite_member_link)
+
+ visit project_issue_path(project, issue)
+
+ page.within(".new-note") do
+ click_button 'Invite Member'
+ end
+
+ expect(page).to have_content("You're inviting members to the")
+ end
+end
diff --git a/spec/features/merge_request/user_invites_from_a_comment_spec.rb b/spec/features/merge_request/user_invites_from_a_comment_spec.rb
new file mode 100644
index 00000000000..79865094fd0
--- /dev/null
+++ b/spec/features/merge_request/user_invites_from_a_comment_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "User invites from a comment", :js do
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+ let_it_be(:user) { project.owner }
+
+ before do
+ sign_in(user)
+ end
+
+ it "launches the invite modal from invite link on a comment" do
+ stub_experiments(invite_members_in_comment: :invite_member_link)
+
+ visit project_merge_request_path(project, merge_request)
+
+ page.within(".new-note") do
+ click_button 'Invite Member'
+ end
+
+ expect(page).to have_content("You're inviting members to the")
+ end
+end
diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js
index b4a01c78e6b..ec61cab08aa 100644
--- a/spec/frontend/boards/components/board_content_sidebar_spec.js
+++ b/spec/frontend/boards/components/board_content_sidebar_spec.js
@@ -19,12 +19,14 @@ describe('BoardContentSidebar', () => {
store = new Vuex.Store({
state: {
sidebarType: ISSUABLE,
- issues: { [mockIssue.id]: mockIssue },
+ issues: { [mockIssue.id]: { ...mockIssue, epic: null } },
activeId: mockIssue.id,
issuableType: 'issue',
},
getters: {
- activeIssue: () => mockIssue,
+ activeIssue: () => {
+ return { ...mockIssue, epic: null };
+ },
groupPathForActiveIssue: () => mockIssueGroupPath,
projectPathForActiveIssue: () => mockIssueProjectPath,
isSidebarOpen: () => true,
@@ -35,11 +37,18 @@ describe('BoardContentSidebar', () => {
};
const createComponent = () => {
+ /*
+ Dynamically imported components (in our case ee imports)
+ aren't stubbed automatically in VTU v1:
+ https://github.com/vuejs/vue-test-utils/issues/1279.
+
+ This requires us to additionally mock apollo or vuex stores.
+ */
wrapper = shallowMount(BoardContentSidebar, {
provide: {
canUpdate: true,
rootPath: '/',
- groupId: '#',
+ groupId: 1,
},
store,
stubs: {
@@ -53,6 +62,12 @@ describe('BoardContentSidebar', () => {
participants: {
loading: false,
},
+ currentIteration: {
+ loading: false,
+ },
+ iterations: {
+ loading: false,
+ },
},
},
},
@@ -117,7 +132,7 @@ describe('BoardContentSidebar', () => {
expect(toggleBoardItem).toHaveBeenCalledTimes(1);
expect(toggleBoardItem).toHaveBeenCalledWith(expect.any(Object), {
- boardItem: mockIssue,
+ boardItem: { ...mockIssue, epic: null },
sidebarType: ISSUABLE,
});
});
diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js
index 228c897ab00..6d482e5814d 100644
--- a/spec/frontend/flash_spec.js
+++ b/spec/frontend/flash_spec.js
@@ -126,9 +126,17 @@ describe('Flash', () => {
});
describe('deprecatedCreateFlash', () => {
+ const message = 'test';
+ const type = 'alert';
+ const parent = document;
+ const actionConfig = null;
+ const fadeTransition = false;
+ const addBodyClass = true;
+ const defaultParams = [message, type, parent, actionConfig, fadeTransition, addBodyClass];
+
describe('no flash-container', () => {
it('does not add to the DOM', () => {
- const flashEl = deprecatedCreateFlash('testing');
+ const flashEl = deprecatedCreateFlash(message);
expect(flashEl).toBeNull();
@@ -138,11 +146,9 @@ describe('Flash', () => {
describe('with flash-container', () => {
beforeEach(() => {
- document.body.innerHTML += `
- <div class="content-wrapper js-content-wrapper">
- <div class="flash-container"></div>
- </div>
- `;
+ setFixtures(
+ '<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>',
+ );
});
afterEach(() => {
@@ -150,7 +156,7 @@ describe('Flash', () => {
});
it('adds flash element into container', () => {
- deprecatedCreateFlash('test', 'alert', document, null, false, true);
+ deprecatedCreateFlash(...defaultParams);
expect(document.querySelector('.flash-alert')).not.toBeNull();
@@ -158,26 +164,35 @@ describe('Flash', () => {
});
it('adds flash into specified parent', () => {
- deprecatedCreateFlash('test', 'alert', document.querySelector('.content-wrapper'));
+ deprecatedCreateFlash(
+ message,
+ type,
+ document.querySelector('.content-wrapper'),
+ actionConfig,
+ fadeTransition,
+ addBodyClass,
+ );
expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull();
+ expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message);
});
it('adds container classes when inside content-wrapper', () => {
- deprecatedCreateFlash('test');
+ deprecatedCreateFlash(...defaultParams);
expect(document.querySelector('.flash-text').className).toBe('flash-text');
+ expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message);
});
it('does not add container when outside of content-wrapper', () => {
document.querySelector('.content-wrapper').className = 'js-content-wrapper';
- deprecatedCreateFlash('test');
+ deprecatedCreateFlash(...defaultParams);
expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text');
});
it('removes element after clicking', () => {
- deprecatedCreateFlash('test', 'alert', document, null, false, true);
+ deprecatedCreateFlash(...defaultParams);
document.querySelector('.flash-alert .js-close-icon').click();
@@ -188,24 +203,37 @@ describe('Flash', () => {
describe('with actionConfig', () => {
it('adds action link', () => {
- deprecatedCreateFlash('test', 'alert', document, {
- title: 'test',
- });
+ const newActionConfig = { title: 'test' };
+ deprecatedCreateFlash(
+ message,
+ type,
+ parent,
+ newActionConfig,
+ fadeTransition,
+ addBodyClass,
+ );
expect(document.querySelector('.flash-action')).not.toBeNull();
});
it('calls actionConfig clickHandler on click', () => {
- const actionConfig = {
+ const newActionConfig = {
title: 'test',
clickHandler: jest.fn(),
};
- deprecatedCreateFlash('test', 'alert', document, actionConfig);
+ deprecatedCreateFlash(
+ message,
+ type,
+ parent,
+ newActionConfig,
+ fadeTransition,
+ addBodyClass,
+ );
document.querySelector('.flash-action').click();
- expect(actionConfig.clickHandler).toHaveBeenCalled();
+ expect(newActionConfig.clickHandler).toHaveBeenCalled();
});
});
});
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
index 5ca5d855038..7b2ea890d35 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -3,7 +3,11 @@ import { shallowMount } from '@vue/test-utils';
import { stubComponent } from 'helpers/stub_component';
import waitForPromises from 'helpers/wait_for_promises';
import Api from '~/api';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
+import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
+
+jest.mock('~/experimentation/experiment_tracking');
const id = '1';
const name = 'test name';
@@ -303,6 +307,7 @@ describe('InviteMembersModal', () => {
jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData });
jest.spyOn(wrapper.vm, 'showToastMessageSuccess');
+ jest.spyOn(wrapper.vm, 'trackInvite');
clickInviteButton();
});
@@ -396,5 +401,46 @@ describe('InviteMembersModal', () => {
});
});
});
+
+ describe('tracking', () => {
+ const postData = {
+ user_id: '1',
+ access_level: defaultAccessLevel,
+ expires_at: undefined,
+ format: 'json',
+ };
+
+ beforeEach(() => {
+ wrapper = createComponent({ newUsersToInvite: [user3] });
+
+ wrapper.vm.$toast = { show: jest.fn() };
+ jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData });
+ });
+
+ it('tracks the invite', () => {
+ wrapper.vm.openModal({ inviteeType: 'members', source: INVITE_MEMBERS_IN_COMMENT });
+
+ clickInviteButton();
+
+ expect(ExperimentTracking).toHaveBeenCalledWith(INVITE_MEMBERS_IN_COMMENT);
+ expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_success');
+ });
+
+ it('does not track invite for unknown source', () => {
+ wrapper.vm.openModal({ inviteeType: 'members', source: 'unknown' });
+
+ clickInviteButton();
+
+ expect(ExperimentTracking).not.toHaveBeenCalled();
+ });
+
+ it('does not track invite undefined source', () => {
+ wrapper.vm.openModal({ inviteeType: 'members' });
+
+ clickInviteButton();
+
+ expect(ExperimentTracking).not.toHaveBeenCalled();
+ });
+ });
});
});
diff --git a/spec/frontend/invite_members/components/invite_members_trigger_spec.js b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
index f362aace1df..b3f327f2a9a 100644
--- a/spec/frontend/invite_members/components/invite_members_trigger_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_trigger_spec.js
@@ -1,11 +1,16 @@
import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import eventHub from '~/invite_members/event_hub';
+
+jest.mock('~/experimentation/experiment_tracking');
const displayText = 'Invite team members';
+let wrapper;
const createComponent = (props = {}) => {
- return shallowMount(InviteMembersTrigger, {
+ wrapper = shallowMount(InviteMembersTrigger, {
propsData: {
displayText,
...props,
@@ -14,7 +19,7 @@ const createComponent = (props = {}) => {
};
describe('InviteMembersTrigger', () => {
- let wrapper;
+ const findButton = () => wrapper.findComponent(GlButton);
afterEach(() => {
wrapper.destroy();
@@ -22,14 +27,52 @@ describe('InviteMembersTrigger', () => {
});
describe('displayText', () => {
- const findButton = () => wrapper.findComponent(GlButton);
+ it('includes the correct displayText for the button', () => {
+ createComponent();
+
+ expect(findButton().text()).toBe(displayText);
+ });
+ });
+
+ describe('clicking the link', () => {
+ let spy;
beforeEach(() => {
- wrapper = createComponent();
+ spy = jest.spyOn(eventHub, '$emit');
});
- it('includes the correct displayText for the button', () => {
- expect(findButton().text()).toBe(displayText);
+ it('emits openModal from an unknown source', () => {
+ createComponent();
+
+ findButton().vm.$emit('click');
+
+ expect(spy).toHaveBeenCalledWith('openModal', { inviteeType: 'members', source: 'unknown' });
+ });
+
+ it('emits openModal from a named source', () => {
+ createComponent({ triggerSource: '_trigger_source_' });
+
+ findButton().vm.$emit('click');
+
+ expect(spy).toHaveBeenCalledWith('openModal', {
+ inviteeType: 'members',
+ source: '_trigger_source_',
+ });
+ });
+ });
+
+ describe('tracking', () => {
+ it('tracks on mounting', () => {
+ createComponent({ trackExperiment: '_track_experiment_' });
+
+ expect(ExperimentTracking).toHaveBeenCalledWith('_track_experiment_');
+ expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith('comment_invite_shown');
+ });
+
+ it('does not track on mounting', () => {
+ createComponent();
+
+ expect(ExperimentTracking).not.toHaveBeenCalledWith('_track_experiment_');
});
});
});
diff --git a/spec/frontend/merge_conflicts/store/actions_spec.js b/spec/frontend/merge_conflicts/store/actions_spec.js
index 352f1783b87..8fa8765a9f9 100644
--- a/spec/frontend/merge_conflicts/store/actions_spec.js
+++ b/spec/frontend/merge_conflicts/store/actions_spec.js
@@ -1,5 +1,6 @@
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import Cookies from 'js-cookie';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
@@ -10,6 +11,7 @@ import { restoreFileLinesState, markLine, decorateFiles } from '~/merge_conflict
jest.mock('~/flash.js');
jest.mock('~/merge_conflicts/utils');
+jest.mock('js-cookie');
describe('merge conflicts actions', () => {
let mock;
@@ -80,6 +82,25 @@ describe('merge conflicts actions', () => {
});
});
+ describe('setConflictsData', () => {
+ it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
+ decorateFiles.mockReturnValue([{ bar: 'baz' }]);
+ testAction(
+ actions.setConflictsData,
+ { files, foo: 'bar' },
+ {},
+ [
+ {
+ type: types.SET_CONFLICTS_DATA,
+ payload: { foo: 'bar', files: [{ bar: 'baz' }] },
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
describe('submitResolvedConflicts', () => {
useMockLocationHelper();
const resolveConflictsPath = 'resolve/conflicts/path/mock';
@@ -120,21 +141,109 @@ describe('merge conflicts actions', () => {
});
});
- describe('setConflictsData', () => {
- it('INTERACTIVE_RESOLVE_MODE updates the correct file ', (done) => {
- decorateFiles.mockReturnValue([{ bar: 'baz' }]);
+ describe('setLoadingState', () => {
+ it('commits the right mutation', () => {
testAction(
- actions.setConflictsData,
- { files, foo: 'bar' },
+ actions.setLoadingState,
+ true,
{},
[
{
- type: types.SET_CONFLICTS_DATA,
- payload: { foo: 'bar', files: [{ bar: 'baz' }] },
+ type: types.SET_LOADING_STATE,
+ payload: true,
+ },
+ ],
+ [],
+ );
+ });
+ });
+
+ describe('setErrorState', () => {
+ it('commits the right mutation', () => {
+ testAction(
+ actions.setErrorState,
+ true,
+ {},
+ [
+ {
+ type: types.SET_ERROR_STATE,
+ payload: true,
+ },
+ ],
+ [],
+ );
+ });
+ });
+
+ describe('setFailedRequest', () => {
+ it('commits the right mutation', () => {
+ testAction(
+ actions.setFailedRequest,
+ 'errors in the request',
+ {},
+ [
+ {
+ type: types.SET_FAILED_REQUEST,
+ payload: 'errors in the request',
+ },
+ ],
+ [],
+ );
+ });
+ });
+
+ describe('setViewType', () => {
+ it('commits the right mutation', (done) => {
+ const payload = 'viewType';
+ testAction(
+ actions.setViewType,
+ payload,
+ {},
+ [
+ {
+ type: types.SET_VIEW_TYPE,
+ payload,
+ },
+ ],
+ [],
+ () => {
+ expect(Cookies.set).toHaveBeenCalledWith('diff_view', payload);
+ done();
+ },
+ );
+ });
+ });
+
+ describe('setSubmitState', () => {
+ it('commits the right mutation', () => {
+ testAction(
+ actions.setSubmitState,
+ true,
+ {},
+ [
+ {
+ type: types.SET_SUBMIT_STATE,
+ payload: true,
+ },
+ ],
+ [],
+ );
+ });
+ });
+
+ describe('updateCommitMessage', () => {
+ it('commits the right mutation', () => {
+ testAction(
+ actions.updateCommitMessage,
+ 'some message',
+ {},
+ [
+ {
+ type: types.UPDATE_CONFLICTS_DATA,
+ payload: { commitMessage: 'some message' },
},
],
[],
- done,
);
});
});
diff --git a/spec/frontend/merge_conflicts/store/getters_spec.js b/spec/frontend/merge_conflicts/store/getters_spec.js
new file mode 100644
index 00000000000..7a26a2bba6a
--- /dev/null
+++ b/spec/frontend/merge_conflicts/store/getters_spec.js
@@ -0,0 +1,187 @@
+import {
+ CONFLICT_TYPES,
+ EDIT_RESOLVE_MODE,
+ INTERACTIVE_RESOLVE_MODE,
+} from '~/merge_conflicts/constants';
+import * as getters from '~/merge_conflicts/store/getters';
+import realState from '~/merge_conflicts/store/state';
+
+describe('Merge Conflicts getters', () => {
+ let state;
+
+ beforeEach(() => {
+ state = realState();
+ });
+
+ describe('getConflictsCount', () => {
+ it('returns zero when there are no files', () => {
+ state.conflictsData.files = [];
+
+ expect(getters.getConflictsCount(state)).toBe(0);
+ });
+
+ it(`counts the number of sections in files of type ${CONFLICT_TYPES.TEXT}`, () => {
+ state.conflictsData.files = [
+ { sections: [{ conflict: true }], type: CONFLICT_TYPES.TEXT },
+ { sections: [{ conflict: true }, { conflict: true }], type: CONFLICT_TYPES.TEXT },
+ ];
+ expect(getters.getConflictsCount(state)).toBe(3);
+ });
+
+ it(`counts the number of file in files not of type ${CONFLICT_TYPES.TEXT}`, () => {
+ state.conflictsData.files = [
+ { sections: [{ conflict: true }], type: '' },
+ { sections: [{ conflict: true }, { conflict: true }], type: '' },
+ ];
+ expect(getters.getConflictsCount(state)).toBe(2);
+ });
+ });
+
+ describe('getConflictsCountText', () => {
+ it('with one conflicts', () => {
+ const getConflictsCount = 1;
+
+ expect(getters.getConflictsCountText(state, { getConflictsCount })).toBe('1 conflict');
+ });
+
+ it('with more than one conflicts', () => {
+ const getConflictsCount = 3;
+
+ expect(getters.getConflictsCountText(state, { getConflictsCount })).toBe('3 conflicts');
+ });
+ });
+
+ describe('isReadyToCommit', () => {
+ it('return false when isSubmitting is true', () => {
+ state.conflictsData.files = [];
+ state.isSubmitting = true;
+ state.conflictsData.commitMessage = 'foo';
+
+ expect(getters.isReadyToCommit(state)).toBe(false);
+ });
+
+ it('returns false when has no commit message', () => {
+ state.conflictsData.files = [];
+ state.isSubmitting = false;
+ state.conflictsData.commitMessage = '';
+
+ expect(getters.isReadyToCommit(state)).toBe(false);
+ });
+
+ it('returns true when all conflicts are resolved and is not submitting and we have a commitMessage', () => {
+ state.conflictsData.files = [
+ {
+ resolveMode: INTERACTIVE_RESOLVE_MODE,
+ type: CONFLICT_TYPES.TEXT,
+ sections: [{ conflict: true }],
+ resolutionData: { foo: 'bar' },
+ },
+ ];
+ state.isSubmitting = false;
+ state.conflictsData.commitMessage = 'foo';
+
+ expect(getters.isReadyToCommit(state)).toBe(true);
+ });
+
+ describe('unresolved', () => {
+ it(`files with resolvedMode set to ${EDIT_RESOLVE_MODE} and empty count as unresolved`, () => {
+ state.conflictsData.files = [
+ { content: '', resolveMode: EDIT_RESOLVE_MODE },
+ { content: 'foo' },
+ ];
+ state.isSubmitting = false;
+ state.conflictsData.commitMessage = 'foo';
+
+ expect(getters.isReadyToCommit(state)).toBe(false);
+ });
+
+ it(`in files with resolvedMode = ${INTERACTIVE_RESOLVE_MODE} we count resolvedConflicts vs unresolved ones`, () => {
+ state.conflictsData.files = [
+ {
+ resolveMode: INTERACTIVE_RESOLVE_MODE,
+ type: CONFLICT_TYPES.TEXT,
+ sections: [{ conflict: true }],
+ resolutionData: {},
+ },
+ ];
+ state.isSubmitting = false;
+ state.conflictsData.commitMessage = 'foo';
+
+ expect(getters.isReadyToCommit(state)).toBe(false);
+ });
+ });
+ });
+
+ describe('getCommitButtonText', () => {
+ it('when is submitting', () => {
+ state.isSubmitting = true;
+ expect(getters.getCommitButtonText(state)).toBe('Committing...');
+ });
+
+ it('when is not submitting', () => {
+ expect(getters.getCommitButtonText(state)).toBe('Commit to source branch');
+ });
+ });
+
+ describe('getCommitData', () => {
+ it('returns commit data', () => {
+ const baseFile = {
+ new_path: 'new_path',
+ old_path: 'new_path',
+ };
+
+ state.conflictsData.commitMessage = 'foo';
+ state.conflictsData.files = [
+ {
+ ...baseFile,
+ resolveMode: INTERACTIVE_RESOLVE_MODE,
+ type: CONFLICT_TYPES.TEXT,
+ sections: [{ conflict: true }],
+ resolutionData: { bar: 'baz' },
+ },
+ {
+ ...baseFile,
+ resolveMode: EDIT_RESOLVE_MODE,
+ type: CONFLICT_TYPES.TEXT,
+ content: 'resolve_mode_content',
+ },
+ {
+ ...baseFile,
+ type: CONFLICT_TYPES.TEXT_EDITOR,
+ content: 'text_editor_content',
+ },
+ ];
+
+ expect(getters.getCommitData(state)).toStrictEqual({
+ commit_message: 'foo',
+ files: [
+ { ...baseFile, sections: { bar: 'baz' } },
+ { ...baseFile, content: 'resolve_mode_content' },
+ { ...baseFile, content: 'text_editor_content' },
+ ],
+ });
+ });
+ });
+
+ describe('fileTextTypePresent', () => {
+ it(`returns true if there is a file with type ${CONFLICT_TYPES.TEXT}`, () => {
+ state.conflictsData.files = [{ type: CONFLICT_TYPES.TEXT }];
+
+ expect(getters.fileTextTypePresent(state)).toBe(true);
+ });
+ it(`returns false if there is no file with type ${CONFLICT_TYPES.TEXT}`, () => {
+ state.conflictsData.files = [{ type: CONFLICT_TYPES.TEXT_EDITOR }];
+
+ expect(getters.fileTextTypePresent(state)).toBe(false);
+ });
+ });
+
+ describe('getFileIndex', () => {
+ it(`returns the index of a file from it's blob path`, () => {
+ const blobPath = 'blobPath/foo';
+ state.conflictsData.files = [{ foo: 'bar' }, { baz: 'foo', blobPath }];
+
+ expect(getters.getFileIndex(state)({ blobPath })).toBe(1);
+ });
+ });
+});
diff --git a/spec/frontend/merge_conflicts/store/mutations_spec.js b/spec/frontend/merge_conflicts/store/mutations_spec.js
new file mode 100644
index 00000000000..1476f0c5369
--- /dev/null
+++ b/spec/frontend/merge_conflicts/store/mutations_spec.js
@@ -0,0 +1,99 @@
+import { VIEW_TYPES } from '~/merge_conflicts/constants';
+import * as types from '~/merge_conflicts/store/mutation_types';
+import mutations from '~/merge_conflicts/store/mutations';
+import realState from '~/merge_conflicts/store/state';
+
+describe('Mutations merge conflicts store', () => {
+ let mockState;
+
+ beforeEach(() => {
+ mockState = realState();
+ });
+
+ describe('SET_LOADING_STATE', () => {
+ it('should set loading', () => {
+ mutations[types.SET_LOADING_STATE](mockState, true);
+
+ expect(mockState.isLoading).toBe(true);
+ });
+ });
+
+ describe('SET_ERROR_STATE', () => {
+ it('should set hasError', () => {
+ mutations[types.SET_ERROR_STATE](mockState, true);
+
+ expect(mockState.hasError).toBe(true);
+ });
+ });
+
+ describe('SET_FAILED_REQUEST', () => {
+ it('should set hasError and errorMessage', () => {
+ const payload = 'message';
+ mutations[types.SET_FAILED_REQUEST](mockState, payload);
+
+ expect(mockState.hasError).toBe(true);
+ expect(mockState.conflictsData.errorMessage).toBe(payload);
+ });
+ });
+
+ describe('SET_VIEW_TYPE', () => {
+ it('should set diffView', () => {
+ mutations[types.SET_VIEW_TYPE](mockState, VIEW_TYPES.INLINE);
+
+ expect(mockState.diffView).toBe(VIEW_TYPES.INLINE);
+ });
+
+ it(`if payload is ${VIEW_TYPES.PARALLEL} sets isParallel`, () => {
+ mutations[types.SET_VIEW_TYPE](mockState, VIEW_TYPES.PARALLEL);
+
+ expect(mockState.isParallel).toBe(true);
+ });
+ });
+
+ describe('SET_SUBMIT_STATE', () => {
+ it('should set isSubmitting', () => {
+ mutations[types.SET_SUBMIT_STATE](mockState, true);
+
+ expect(mockState.isSubmitting).toBe(true);
+ });
+ });
+
+ describe('SET_CONFLICTS_DATA', () => {
+ it('should set conflictsData', () => {
+ mutations[types.SET_CONFLICTS_DATA](mockState, {
+ files: [],
+ commit_message: 'foo',
+ source_branch: 'bar',
+ target_branch: 'baz',
+ commit_sha: '123456789',
+ });
+
+ expect(mockState.conflictsData).toStrictEqual({
+ files: [],
+ commitMessage: 'foo',
+ sourceBranch: 'bar',
+ targetBranch: 'baz',
+ shortCommitSha: '1234567',
+ });
+ });
+ });
+
+ describe('UPDATE_CONFLICTS_DATA', () => {
+ it('should update existing conflicts data', () => {
+ const payload = { foo: 'bar' };
+ mutations[types.UPDATE_CONFLICTS_DATA](mockState, payload);
+
+ expect(mockState.conflictsData).toStrictEqual(payload);
+ });
+ });
+
+ describe('UPDATE_FILE', () => {
+ it('should update a file based on its index', () => {
+ mockState.conflictsData.files = [{ foo: 'bar' }, { baz: 'bar' }];
+
+ mutations[types.UPDATE_FILE](mockState, { file: { new: 'one' }, index: 1 });
+
+ expect(mockState.conflictsData.files).toStrictEqual([{ foo: 'bar' }, { new: 'one' }]);
+ });
+ });
+});
diff --git a/spec/frontend/merge_conflicts/utils_spec.js b/spec/frontend/merge_conflicts/utils_spec.js
new file mode 100644
index 00000000000..5bf7ecf8cfe
--- /dev/null
+++ b/spec/frontend/merge_conflicts/utils_spec.js
@@ -0,0 +1,106 @@
+import * as utils from '~/merge_conflicts/utils';
+
+describe('merge conflicts utils', () => {
+ describe('getFilePath', () => {
+ it('returns new path if they are the same', () => {
+ expect(utils.getFilePath({ new_path: 'a', old_path: 'a' })).toBe('a');
+ });
+
+ it('returns concatenated paths if they are different', () => {
+ expect(utils.getFilePath({ new_path: 'b', old_path: 'a' })).toBe('a → b');
+ });
+ });
+
+ describe('checkLineLengths', () => {
+ it('add empty lines to the left when right has more lines', () => {
+ const result = utils.checkLineLengths({ left: [1], right: [1, 2] });
+
+ expect(result.left).toHaveLength(result.right.length);
+ expect(result.left).toStrictEqual([1, { lineType: 'emptyLine', richText: '' }]);
+ });
+
+ it('add empty lines to the right when left has more lines', () => {
+ const result = utils.checkLineLengths({ left: [1, 2], right: [1] });
+
+ expect(result.right).toHaveLength(result.left.length);
+ expect(result.right).toStrictEqual([1, { lineType: 'emptyLine', richText: '' }]);
+ });
+ });
+
+ describe('getHeadHeaderLine', () => {
+ it('decorates the id', () => {
+ expect(utils.getHeadHeaderLine(1)).toStrictEqual({
+ buttonTitle: 'Use ours',
+ id: 1,
+ isHead: true,
+ isHeader: true,
+ isSelected: false,
+ isUnselected: false,
+ richText: 'HEAD//our changes',
+ section: 'head',
+ type: 'new',
+ });
+ });
+ });
+
+ describe('decorateLineForInlineView', () => {
+ it.each`
+ type | truthyProp
+ ${'new'} | ${'isHead'}
+ ${'old'} | ${'isOrigin'}
+ ${'match'} | ${'hasMatch'}
+ `(
+ 'when the type is $type decorates the line with $truthyProp set as true',
+ ({ type, truthyProp }) => {
+ expect(utils.decorateLineForInlineView({ type, rich_text: 'rich' }, 1, true)).toStrictEqual(
+ {
+ id: 1,
+ hasConflict: true,
+ isHead: false,
+ isOrigin: false,
+ hasMatch: false,
+ richText: 'rich',
+ isSelected: false,
+ isUnselected: false,
+ [truthyProp]: true,
+ },
+ );
+ },
+ );
+ });
+
+ describe('getLineForParallelView', () => {
+ it.todo('should return a proper value');
+ });
+
+ describe('getOriginHeaderLine', () => {
+ it('decorates the id', () => {
+ expect(utils.getOriginHeaderLine(1)).toStrictEqual({
+ buttonTitle: 'Use theirs',
+ id: 1,
+ isHeader: true,
+ isOrigin: true,
+ isSelected: false,
+ isUnselected: false,
+ richText: 'origin//their changes',
+ section: 'origin',
+ type: 'old',
+ });
+ });
+ });
+ describe('setInlineLine', () => {
+ it.todo('should return a proper value');
+ });
+ describe('setParallelLine', () => {
+ it.todo('should return a proper value');
+ });
+ describe('decorateFiles', () => {
+ it.todo('should return a proper value');
+ });
+ describe('restoreFileLinesState', () => {
+ it.todo('should return a proper value');
+ });
+ describe('markLine', () => {
+ it.todo('should return a proper value');
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
index db884dfe015..eadf07e54fb 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
@@ -1,38 +1,35 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import Header from '~/vue_merge_request_widget/components/mr_widget_header.vue';
describe('MRWidgetHeader', () => {
- let vm;
- let Component;
+ let wrapper;
- beforeEach(() => {
- Component = Vue.extend(headerComponent);
- });
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(Header, {
+ propsData,
+ });
+ };
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
gon.relative_url_root = '';
});
const expectDownloadDropdownItems = () => {
- const downloadEmailPatchesEl = vm.$el.querySelector('.js-download-email-patches');
- const downloadPlainDiffEl = vm.$el.querySelector('.js-download-plain-diff');
-
- expect(downloadEmailPatchesEl.innerText.trim()).toEqual('Email patches');
- expect(downloadEmailPatchesEl.querySelector('a').getAttribute('href')).toEqual(
- '/mr/email-patches',
- );
- expect(downloadPlainDiffEl.innerText.trim()).toEqual('Plain diff');
- expect(downloadPlainDiffEl.querySelector('a').getAttribute('href')).toEqual(
- '/mr/plainDiffPath',
- );
+ const downloadEmailPatchesEl = wrapper.find('.js-download-email-patches');
+ const downloadPlainDiffEl = wrapper.find('.js-download-plain-diff');
+
+ expect(downloadEmailPatchesEl.text().trim()).toBe('Email patches');
+ expect(downloadEmailPatchesEl.attributes('href')).toBe('/mr/email-patches');
+ expect(downloadPlainDiffEl.text().trim()).toBe('Plain diff');
+ expect(downloadPlainDiffEl.attributes('href')).toBe('/mr/plainDiffPath');
};
describe('computed', () => {
describe('shouldShowCommitsBehindText', () => {
it('return true when there are divergedCommitsCount', () => {
- vm = mountComponent(Component, {
+ createComponent({
mr: {
divergedCommitsCount: 12,
sourceBranch: 'mr-widget-refactor',
@@ -42,11 +39,11 @@ describe('MRWidgetHeader', () => {
},
});
- expect(vm.shouldShowCommitsBehindText).toEqual(true);
+ expect(wrapper.vm.shouldShowCommitsBehindText).toBe(true);
});
it('returns false where there are no divergedComits count', () => {
- vm = mountComponent(Component, {
+ createComponent({
mr: {
divergedCommitsCount: 0,
sourceBranch: 'mr-widget-refactor',
@@ -56,13 +53,13 @@ describe('MRWidgetHeader', () => {
},
});
- expect(vm.shouldShowCommitsBehindText).toEqual(false);
+ expect(wrapper.vm.shouldShowCommitsBehindText).toBe(false);
});
});
describe('commitsBehindText', () => {
it('returns singular when there is one commit', () => {
- vm = mountComponent(Component, {
+ createComponent({
mr: {
divergedCommitsCount: 1,
sourceBranch: 'mr-widget-refactor',
@@ -73,13 +70,13 @@ describe('MRWidgetHeader', () => {
},
});
- expect(vm.commitsBehindText).toEqual(
+ expect(wrapper.vm.commitsBehindText).toBe(
'The source branch is <a href="/foo/bar/master">1 commit behind</a> the target branch',
);
});
it('returns plural when there is more than one commit', () => {
- vm = mountComponent(Component, {
+ createComponent({
mr: {
divergedCommitsCount: 2,
sourceBranch: 'mr-widget-refactor',
@@ -90,7 +87,7 @@ describe('MRWidgetHeader', () => {
},
});
- expect(vm.commitsBehindText).toEqual(
+ expect(wrapper.vm.commitsBehindText).toBe(
'The source branch is <a href="/foo/bar/master">2 commits behind</a> the target branch',
);
});
@@ -100,7 +97,7 @@ describe('MRWidgetHeader', () => {
describe('template', () => {
describe('common elements', () => {
beforeEach(() => {
- vm = mountComponent(Component, {
+ createComponent({
mr: {
divergedCommitsCount: 12,
sourceBranch: 'mr-widget-refactor',
@@ -118,17 +115,17 @@ describe('MRWidgetHeader', () => {
});
it('renders source branch link', () => {
- expect(vm.$el.querySelector('.js-source-branch').innerHTML).toEqual(
+ expect(wrapper.find('.js-source-branch').html()).toContain(
'<a href="/foo/bar/mr-widget-refactor">mr-widget-refactor</a>',
);
});
it('renders clipboard button', () => {
- expect(vm.$el.querySelector('[data-testid="mr-widget-copy-clipboard"]')).not.toEqual(null);
+ expect(wrapper.find('[data-testid="mr-widget-copy-clipboard"]')).not.toBe(null);
});
it('renders target branch', () => {
- expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master');
+ expect(wrapper.find('.js-target-branch').text().trim()).toBe('master');
});
});
@@ -151,71 +148,68 @@ describe('MRWidgetHeader', () => {
targetProjectFullPath: 'gitlab-org/gitlab-ce',
};
- afterEach(() => {
- vm.$destroy();
- });
-
beforeEach(() => {
- vm = mountComponent(Component, {
+ createComponent({
mr: { ...mrDefaultOptions },
});
});
it('renders checkout branch button with modal trigger', () => {
- const button = vm.$el.querySelector('.js-check-out-branch');
+ const button = wrapper.find('.js-check-out-branch');
- expect(button.textContent.trim()).toBe('Check out branch');
+ expect(button.text().trim()).toBe('Check out branch');
});
- it('renders web ide button', () => {
- const button = vm.$el.querySelector('.js-web-ide');
+ it('renders web ide button', async () => {
+ const button = wrapper.find('.js-web-ide');
- expect(button.textContent.trim()).toEqual('Open in Web IDE');
- expect(button.classList.contains('disabled')).toBe(false);
- expect(button.getAttribute('href')).toEqual(
+ await nextTick();
+
+ expect(button.text().trim()).toBe('Open in Web IDE');
+ expect(button.classes('disabled')).toBe(false);
+ expect(button.attributes('href')).toBe(
'/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=gitlab-org%2Fgitlab-ce',
);
});
- it('renders web ide button in disabled state with no href', () => {
+ it('renders web ide button in disabled state with no href', async () => {
const mr = { ...mrDefaultOptions, canPushToSourceBranch: false };
- vm = mountComponent(Component, { mr });
+ createComponent({ mr });
+
+ await nextTick();
- const link = vm.$el.querySelector('.js-web-ide');
+ const link = wrapper.find('.js-web-ide');
- expect(link.classList.contains('disabled')).toBe(true);
- expect(link.getAttribute('href')).toBeNull();
+ expect(link.attributes('disabled')).toBe('true');
+ expect(link.attributes('href')).toBeUndefined();
});
- it('renders web ide button with blank query string if target & source project branch', (done) => {
- vm.mr.targetProjectFullPath = 'root/gitlab-ce';
+ it('renders web ide button with blank query string if target & source project branch', async () => {
+ createComponent({ mr: { ...mrDefaultOptions, targetProjectFullPath: 'root/gitlab-ce' } });
- vm.$nextTick(() => {
- const button = vm.$el.querySelector('.js-web-ide');
+ await nextTick();
- expect(button.textContent.trim()).toEqual('Open in Web IDE');
- expect(button.getAttribute('href')).toEqual(
- '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=',
- );
+ const button = wrapper.find('.js-web-ide');
- done();
- });
+ expect(button.text().trim()).toBe('Open in Web IDE');
+ expect(button.attributes('href')).toBe(
+ '/-/ide/project/root/gitlab-ce/merge_requests/1?target_project=',
+ );
});
- it('renders web ide button with relative URL', (done) => {
+ it('renders web ide button with relative URL', async () => {
gon.relative_url_root = '/gitlab';
- vm.mr.iid = 2;
- vm.$nextTick(() => {
- const button = vm.$el.querySelector('.js-web-ide');
+ createComponent({ mr: { ...mrDefaultOptions, iid: 2 } });
- expect(button.textContent.trim()).toEqual('Open in Web IDE');
- expect(button.getAttribute('href')).toEqual(
- '/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce',
- );
+ await nextTick();
- done();
- });
+ const button = wrapper.find('.js-web-ide');
+
+ expect(button.text().trim()).toBe('Open in Web IDE');
+ expect(button.attributes('href')).toBe(
+ '/gitlab/-/ide/project/root/gitlab-ce/merge_requests/2?target_project=gitlab-org%2Fgitlab-ce',
+ );
});
it('renders download dropdown with links', () => {
@@ -225,7 +219,7 @@ describe('MRWidgetHeader', () => {
describe('with a closed merge request', () => {
beforeEach(() => {
- vm = mountComponent(Component, {
+ createComponent({
mr: {
divergedCommitsCount: 12,
sourceBranch: 'mr-widget-refactor',
@@ -243,9 +237,9 @@ describe('MRWidgetHeader', () => {
});
it('does not render checkout branch button with modal trigger', () => {
- const button = vm.$el.querySelector('.js-check-out-branch');
+ const button = wrapper.find('.js-check-out-branch');
- expect(button).toEqual(null);
+ expect(button.exists()).toBe(false);
});
it('renders download dropdown with links', () => {
@@ -255,7 +249,7 @@ describe('MRWidgetHeader', () => {
describe('without diverged commits', () => {
beforeEach(() => {
- vm = mountComponent(Component, {
+ createComponent({
mr: {
divergedCommitsCount: 0,
sourceBranch: 'mr-widget-refactor',
@@ -273,13 +267,13 @@ describe('MRWidgetHeader', () => {
});
it('does not render diverged commits info', () => {
- expect(vm.$el.querySelector('.diverged-commits-count')).toEqual(null);
+ expect(wrapper.find('.diverged-commits-count').exists()).toBe(false);
});
});
describe('with diverged commits', () => {
beforeEach(() => {
- vm = mountComponent(Component, {
+ createComponent({
mr: {
divergedCommitsCount: 12,
sourceBranch: 'mr-widget-refactor',
@@ -297,17 +291,13 @@ describe('MRWidgetHeader', () => {
});
it('renders diverged commits info', () => {
- expect(vm.$el.querySelector('.diverged-commits-count').textContent).toEqual(
+ expect(wrapper.find('.diverged-commits-count').text().trim()).toBe(
'The source branch is 12 commits behind the target branch',
);
- expect(vm.$el.querySelector('.diverged-commits-count a').textContent).toEqual(
- '12 commits behind',
- );
-
- expect(vm.$el.querySelector('.diverged-commits-count a')).toHaveAttr(
- 'href',
- vm.mr.targetBranchPath,
+ expect(wrapper.find('.diverged-commits-count a').text().trim()).toBe('12 commits behind');
+ expect(wrapper.find('.diverged-commits-count a').attributes('href')).toBe(
+ wrapper.vm.mr.targetBranchPath,
);
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
index e7c31014bfc..eddc4033a65 100644
--- a/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/toolbar_spec.js
@@ -1,35 +1,75 @@
-import Vue from 'vue';
-import mountComponent from 'helpers/vue_mount_component_helper';
-import toolbar from '~/vue_shared/components/markdown/toolbar.vue';
+import { mount } from '@vue/test-utils';
+import { isExperimentVariant } from '~/experimentation/utils';
+import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue';
+import { INVITE_MEMBERS_IN_COMMENT } from '~/invite_members/constants';
+import Toolbar from '~/vue_shared/components/markdown/toolbar.vue';
+
+jest.mock('~/experimentation/utils', () => ({ isExperimentVariant: jest.fn() }));
describe('toolbar', () => {
- let vm;
- const Toolbar = Vue.extend(toolbar);
- const props = {
- markdownDocsPath: '',
+ let wrapper;
+
+ const createMountedWrapper = (props = {}) => {
+ wrapper = mount(Toolbar, {
+ propsData: { markdownDocsPath: '', ...props },
+ stubs: { 'invite-members-trigger': true },
+ });
};
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ isExperimentVariant.mockReset();
});
describe('user can attach file', () => {
beforeEach(() => {
- vm = mountComponent(Toolbar, props);
+ createMountedWrapper();
});
it('should render uploading-container', () => {
- expect(vm.$el.querySelector('.uploading-container')).not.toBeNull();
+ expect(wrapper.vm.$el.querySelector('.uploading-container')).not.toBeNull();
});
});
describe('user cannot attach file', () => {
beforeEach(() => {
- vm = mountComponent(Toolbar, { ...props, canAttachFile: false });
+ createMountedWrapper({ canAttachFile: false });
});
it('should not render uploading-container', () => {
- expect(vm.$el.querySelector('.uploading-container')).toBeNull();
+ expect(wrapper.vm.$el.querySelector('.uploading-container')).toBeNull();
+ });
+ });
+
+ describe('user can invite member', () => {
+ const findInviteLink = () => wrapper.find(InviteMembersTrigger);
+
+ beforeEach(() => {
+ isExperimentVariant.mockReturnValue(true);
+ createMountedWrapper();
+ });
+
+ it('should render the invite members trigger', () => {
+ expect(findInviteLink().exists()).toBe(true);
+ });
+
+ it('should have correct props', () => {
+ expect(findInviteLink().props().displayText).toBe('Invite Member');
+ expect(findInviteLink().props().trackExperiment).toBe(INVITE_MEMBERS_IN_COMMENT);
+ expect(findInviteLink().props().triggerSource).toBe(INVITE_MEMBERS_IN_COMMENT);
+ });
+ });
+
+ describe('user can not invite member', () => {
+ const findInviteLink = () => wrapper.find(InviteMembersTrigger);
+
+ beforeEach(() => {
+ isExperimentVariant.mockReturnValue(false);
+ createMountedWrapper();
+ });
+
+ it('should render the invite members trigger', () => {
+ expect(findInviteLink().exists()).toBe(false);
});
});
});
diff --git a/spec/graphql/mutations/release_asset_links/update_spec.rb b/spec/graphql/mutations/release_asset_links/update_spec.rb
index 065089066f1..20c1c8b581c 100644
--- a/spec/graphql/mutations/release_asset_links/update_spec.rb
+++ b/spec/graphql/mutations/release_asset_links/update_spec.rb
@@ -166,7 +166,7 @@ RSpec.describe Mutations::ReleaseAssetLinks::Update do
end
context "when the link doesn't exist" do
- let(:mutation_arguments) { super().merge(id: 'gid://gitlab/Releases::Link/999999') }
+ let(:mutation_arguments) { super().merge(id: "gid://gitlab/Releases::Link/#{non_existing_record_id}") }
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
diff --git a/spec/lib/banzai/filter/gollum_tags_filter_spec.rb b/spec/lib/banzai/filter/gollum_tags_filter_spec.rb
index ec17bb26346..b0136ce1fef 100644
--- a/spec/lib/banzai/filter/gollum_tags_filter_spec.rb
+++ b/spec/lib/banzai/filter/gollum_tags_filter_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe Banzai::Filter::GollumTagsFilter do
tag = '[[images/image.jpg]]'
doc = filter("See #{tag}", wiki: wiki)
- expect(doc.at_css('img')['data-src']).to eq "#{wiki.wiki_base_path}/images/image.jpg"
+ expect(doc.at_css('img')['src']).to eq 'images/image.jpg'
end
it 'does not creates img tag if image does not exist' do
@@ -45,7 +45,7 @@ RSpec.describe Banzai::Filter::GollumTagsFilter do
tag = '[[http://example.com/image.jpg]]'
doc = filter("See #{tag}", wiki: wiki)
- expect(doc.at_css('img')['data-src']).to eq "http://example.com/image.jpg"
+ expect(doc.at_css('img')['src']).to eq "http://example.com/image.jpg"
end
it 'does not creates img tag for invalid URL' do
diff --git a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
index b102de24041..ab6093e9198 100644
--- a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
+++ b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb
@@ -289,4 +289,29 @@ RSpec.describe Banzai::Pipeline::WikiPipeline do
expect(output).to include('<audio src="/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/audio%20file%20name.wav"')
end
end
+
+ describe 'gollum tag filters' do
+ context 'when local image file exists' do
+ it 'sets the proper attributes for the image' do
+ gollum_file_double = double('Gollum::File',
+ mime_type: 'image/jpeg',
+ name: 'images/image.jpg',
+ path: 'images/image.jpg',
+ raw_data: '')
+
+ wiki_file = Gitlab::Git::WikiFile.new(gollum_file_double)
+ markdown = "[[#{wiki_file.path}]]"
+
+ expect(wiki).to receive(:find_file).with(wiki_file.path, load_content: false).and_return(wiki_file)
+
+ output = described_class.to_html(markdown, project: project, wiki: wiki, page_slug: page.slug)
+ doc = Nokogiri::HTML::DocumentFragment.parse(output)
+
+ full_path = "/wiki_link_ns/wiki_link_project/-/wikis/nested/twice/#{wiki_file.path}"
+ expect(doc.css('a')[0].attr('href')).to eq(full_path)
+ expect(doc.css('img')[0].attr('class')).to eq('gfm lazy')
+ expect(doc.css('img')[0].attr('data-src')).to eq(full_path)
+ end
+ end
+ end
end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 5b07bd8923f..160faf18db8 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -821,45 +821,6 @@ RSpec.describe Ci::Build do
{ cache: [{ key: "key", paths: ["public"], policy: "pull-push" }] }
end
- context 'with multiple_cache_per_job FF disabled' do
- before do
- stub_feature_flags(multiple_cache_per_job: false)
- end
- let(:options) { { cache: { key: "key", paths: ["public"], policy: "pull-push" } } }
-
- subject { build.cache }
-
- context 'when build has cache' do
- before do
- allow(build).to receive(:options).and_return(options)
- end
-
- context 'when project has jobs_cache_index' do
- before do
- allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(1)
- end
-
- it { is_expected.to be_an(Array).and all(include(key: "key-1")) }
- end
-
- context 'when project does not have jobs_cache_index' do
- before do
- allow_any_instance_of(Project).to receive(:jobs_cache_index).and_return(nil)
- end
-
- it { is_expected.to eq([options[:cache]]) }
- end
- end
-
- context 'when build does not have cache' do
- before do
- allow(build).to receive(:options).and_return({})
- end
-
- it { is_expected.to eq([]) }
- end
- end
-
subject { build.cache }
context 'when build has cache' do
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 1cee494989d..b59f9b7fed1 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -6016,12 +6016,15 @@ RSpec.describe Project, factory_default: :keep do
project.set_first_pages_deployment!(deployment)
expect(project.pages_metadatum.reload.pages_deployment).to eq(deployment)
+ expect(project.pages_metadatum.reload.deployed).to eq(true)
end
it "updates the existing metadara record with deployment" do
expect do
project.set_first_pages_deployment!(deployment)
end.to change { project.pages_metadatum.reload.pages_deployment }.from(nil).to(deployment)
+
+ expect(project.pages_metadatum.reload.deployed).to eq(true)
end
it 'only updates metadata for this project' do
@@ -6030,6 +6033,8 @@ RSpec.describe Project, factory_default: :keep do
expect do
project.set_first_pages_deployment!(deployment)
end.not_to change { other_project.pages_metadatum.reload.pages_deployment }.from(nil)
+
+ expect(other_project.pages_metadatum.reload.deployed).to eq(false)
end
it 'does nothing if metadata already references some deployment' do
@@ -6040,6 +6045,14 @@ RSpec.describe Project, factory_default: :keep do
project.set_first_pages_deployment!(deployment)
end.not_to change { project.pages_metadatum.reload.pages_deployment }.from(existing_deployment)
end
+
+ it 'marks project as not deployed if deployment is nil' do
+ project.mark_pages_as_deployed
+
+ expect do
+ project.set_first_pages_deployment!(nil)
+ end.to change { project.pages_metadatum.reload.deployed }.from(true).to(false)
+ end
end
describe '#has_pool_repsitory?' do
diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb
index 1eecc9d1ce6..bc09b9db5f1 100644
--- a/spec/presenters/ci/build_runner_presenter_spec.rb
+++ b/spec/presenters/ci/build_runner_presenter_spec.rb
@@ -85,7 +85,7 @@ RSpec.describe Ci::BuildRunnerPresenter do
Ci::JobArtifact::DEFAULT_FILE_NAMES.each do |file_type, filename|
context file_type.to_s do
let(:report) { { "#{file_type}": [filename] } }
- let(:build) { create(:ci_build, options: { artifacts: { reports: report } } ) }
+ let(:build) { create(:ci_build, options: { artifacts: { reports: report } }) }
let(:report_expectation) do
{
@@ -106,7 +106,7 @@ RSpec.describe Ci::BuildRunnerPresenter do
context "when option has both archive and reports specification" do
let(:report) { { junit: ['junit.xml'] } }
- let(:build) { create(:ci_build, options: { script: 'echo', artifacts: { **archive, reports: report } } ) }
+ let(:build) { create(:ci_build, options: { script: 'echo', artifacts: { **archive, reports: report } }) }
let(:report_expectation) do
{
@@ -272,27 +272,82 @@ RSpec.describe Ci::BuildRunnerPresenter do
end
end
- describe '#variables' do
- subject { presenter.variables }
+ describe '#runner_variables' do
+ subject { presenter.runner_variables }
- let(:build) { create(:ci_build) }
+ let_it_be(:project_with_flag_disabled) { create(:project, :repository) }
+ let_it_be(:project_with_flag_enabled) { create(:project, :repository) }
+
+ before do
+ stub_feature_flags(variable_inside_variable: [project_with_flag_enabled])
+ end
+
+ shared_examples 'returns an array with the expected variables' do
+ it 'returns an array' do
+ is_expected.to be_an_instance_of(Array)
+ end
+
+ it 'returns the expected variables' do
+ is_expected.to eq(presenter.variables.to_runner_variables)
+ end
+ end
+
+ context 'when FF :variable_inside_variable is disabled' do
+ let(:sha) { project_with_flag_disabled.repository.commit.sha }
+ let(:pipeline) { create(:ci_pipeline, sha: sha, project: project_with_flag_disabled) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
+
+ it_behaves_like 'returns an array with the expected variables'
+ end
+
+ context 'when FF :variable_inside_variable is enabled' do
+ let(:sha) { project_with_flag_enabled.repository.commit.sha }
+ let(:pipeline) { create(:ci_pipeline, sha: sha, project: project_with_flag_enabled) }
+ let(:build) { create(:ci_build, pipeline: pipeline) }
- it 'returns a Collection' do
- is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection)
+ it_behaves_like 'returns an array with the expected variables'
end
end
- describe '#runner_variables' do
- subject { presenter.runner_variables }
+ describe '#runner_variables subset' do
+ subject { presenter.runner_variables.select { |v| %w[A B C].include?(v.fetch(:key)) } }
let(:build) { create(:ci_build) }
- it 'returns an array' do
- is_expected.to be_an_instance_of(Array)
- end
+ context 'with references in pipeline variables' do
+ before do
+ create(:ci_pipeline_variable, key: 'A', value: 'refA-$B', pipeline: build.pipeline)
+ create(:ci_pipeline_variable, key: 'B', value: 'refB-$C-$D', pipeline: build.pipeline)
+ create(:ci_pipeline_variable, key: 'C', value: 'value', pipeline: build.pipeline)
+ end
+
+ context 'when FF :variable_inside_variable is disabled' do
+ before do
+ stub_feature_flags(variable_inside_variable: false)
+ end
- it 'returns the expected variables' do
- is_expected.to eq(presenter.variables.to_runner_variables)
+ it 'returns non-expanded variables' do
+ is_expected.to eq [
+ { key: 'A', value: 'refA-$B', public: false, masked: false },
+ { key: 'B', value: 'refB-$C-$D', public: false, masked: false },
+ { key: 'C', value: 'value', public: false, masked: false }
+ ]
+ end
+ end
+
+ context 'when FF :variable_inside_variable is enabled' do
+ before do
+ stub_feature_flags(variable_inside_variable: [build.project])
+ end
+
+ it 'returns expanded and sorted variables' do
+ is_expected.to eq [
+ { key: 'C', value: 'value', public: false, masked: false },
+ { key: 'B', value: 'refB-value-$D', public: false, masked: false },
+ { key: 'A', value: 'refA-refB-value-$D', public: false, masked: false }
+ ]
+ end
+ end
end
end
end
diff --git a/spec/services/pages/migrate_from_legacy_storage_service_spec.rb b/spec/services/pages/migrate_from_legacy_storage_service_spec.rb
index 4ec57044912..b3328d540e7 100644
--- a/spec/services/pages/migrate_from_legacy_storage_service_spec.rb
+++ b/spec/services/pages/migrate_from_legacy_storage_service_spec.rb
@@ -48,12 +48,12 @@ RSpec.describe Pages::MigrateFromLegacyStorageService do
end
context 'when pages directory does not exist' do
- it 'tries to migrate the project, but does not crash' do
+ it 'counts project as migrated' do
expect_next_instance_of(::Pages::MigrateLegacyStorageToDeploymentService, project, ignore_invalid_entries: false) do |service|
expect(service).to receive(:execute).and_call_original
end
- expect(service.execute).to eq(migrated: 0, errored: 1)
+ expect(service.execute).to eq(migrated: 1, errored: 0)
end
end
diff --git a/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb b/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb
index d95303c3e85..be9dd69ffd3 100644
--- a/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb
+++ b/spec/services/pages/migrate_legacy_storage_to_deployment_service_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Pages::MigrateLegacyStorageToDeploymentService do
expect(zip_service).to receive(:execute).and_call_original
end
- expect(described_class.new(project, ignore_invalid_entries: true).execute[:status]).to eq(:error)
+ expect(described_class.new(project, ignore_invalid_entries: true).execute[:status]).to eq(:success)
end
it 'marks pages as not deployed if public directory is absent' do
@@ -20,8 +20,8 @@ RSpec.describe Pages::MigrateLegacyStorageToDeploymentService do
expect(project.pages_metadatum.reload.deployed).to eq(true)
expect(service.execute).to(
- eq(status: :error,
- message: "Can't create zip archive: Can not find valid public dir in #{project.pages_path}")
+ eq(status: :success,
+ message: "Archive not created. Missing public directory in #{project.pages_path} ? Marked project as not deployed")
)
expect(project.pages_metadatum.reload.deployed).to eq(false)
@@ -35,8 +35,8 @@ RSpec.describe Pages::MigrateLegacyStorageToDeploymentService do
expect(project.pages_metadatum.reload.deployed).to eq(true)
expect(service.execute).to(
- eq(status: :error,
- message: "Can't create zip archive: Can not find valid public dir in #{project.pages_path}")
+ eq(status: :success,
+ message: "Archive not created. Missing public directory in #{project.pages_path} ? Marked project as not deployed")
)
expect(project.pages_metadatum.reload.deployed).to eq(true)
diff --git a/spec/services/pages/zip_directory_service_spec.rb b/spec/services/pages/zip_directory_service_spec.rb
index 9de68dd62bb..a34583413d2 100644
--- a/spec/services/pages/zip_directory_service_spec.rb
+++ b/spec/services/pages/zip_directory_service_spec.rb
@@ -12,8 +12,10 @@ RSpec.describe Pages::ZipDirectoryService do
let(:ignore_invalid_entries) { false }
+ let(:service_directory) { @work_dir }
+
let(:service) do
- described_class.new(@work_dir, ignore_invalid_entries: ignore_invalid_entries)
+ described_class.new(service_directory, ignore_invalid_entries: ignore_invalid_entries)
end
let(:result) do
@@ -25,32 +27,41 @@ RSpec.describe Pages::ZipDirectoryService do
let(:archive) { result[:archive_path] }
let(:entries_count) { result[:entries_count] }
- it 'returns error if project pages dir does not exist' do
- expect(Gitlab::ErrorTracking).not_to receive(:track_exception)
+ shared_examples 'handles invalid public directory' do
+ it 'returns success' do
+ expect(status).to eq(:success)
+ expect(archive).to be_nil
+ expect(entries_count).to be_nil
+ end
+
+ it 'returns error if pages_migration_mark_as_not_deployed is disabled' do
+ stub_feature_flags(pages_migration_mark_as_not_deployed: false)
- expect(
- described_class.new("/tmp/not/existing/dir").execute
- ).to eq(status: :error, message: "Can not find valid public dir in /tmp/not/existing/dir")
+ expect(status).to eq(:error)
+ expect(message).to eq("Can not find valid public dir in #{service_directory}")
+ expect(archive).to be_nil
+ expect(entries_count).to be_nil
+ end
end
- it 'returns nils if there is no public directory and does not leave archive' do
- expect(status).to eq(:error)
- expect(message).to eq("Can not find valid public dir in #{@work_dir}")
- expect(archive).to eq(nil)
- expect(entries_count).to eq(nil)
+ context "when work direcotry doesn't exist" do
+ let(:service_directory) { "/tmp/not/existing/dir" }
- expect(File.exist?(File.join(@work_dir, '@migrated.zip'))).to eq(false)
+ include_examples 'handles invalid public directory'
end
- it 'returns nils if public directory is a symlink' do
- create_dir('target')
- create_file('./target/index.html', 'hello')
- create_link("public", "./target")
+ context 'when public directory is absent' do
+ include_examples 'handles invalid public directory'
+ end
+
+ context 'when public directory is a symlink' do
+ before do
+ create_dir('target')
+ create_file('./target/index.html', 'hello')
+ create_link("public", "./target")
+ end
- expect(status).to eq(:error)
- expect(message).to eq("Can not find valid public dir in #{@work_dir}")
- expect(archive).to eq(nil)
- expect(entries_count).to eq(nil)
+ include_examples 'handles invalid public directory'
end
context 'when there is a public directory' do