diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 15:11:17 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-03-16 15:11:17 +0000 |
commit | dad48b4af20204db430a6c62c4641283e24dd89a (patch) | |
tree | c8b4644cf30e2babe572f20b89257bcd8fa4b6d6 | |
parent | e2999d09ec050b12b6de9121d9aedc38c12477fd (diff) | |
download | gitlab-ce-dad48b4af20204db430a6c62c4641283e24dd89a.tar.gz |
Add latest changes from gitlab-org/gitlab@master
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 Binary files differdeleted file mode 100644 index e83f9d5cfdf..00000000000 --- a/doc/integration/img/enabled-oauth-sign-in-sources.png +++ /dev/null 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 Binary files differnew file mode 100644 index 00000000000..86f6402c168 --- /dev/null +++ b/doc/integration/img/enabled-oauth-sign-in-sources_v13_10.png 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 Binary files differnew file mode 100644 index 00000000000..493f95f52e0 --- /dev/null +++ b/doc/user/group/value_stream_analytics/img/vsa_path_nav_v13_10.png 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 |