Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
e2999d09ec
commit
dad48b4af2
|
@ -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:
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -61,55 +61,6 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
|
|||
.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
|
||||
};
|
||||
|
||||
/*
|
||||
* 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 = 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
|
||||
|
@ -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,
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1 +1,3 @@
|
|||
export const SEARCH_DELAY = 200;
|
||||
|
||||
export const INVITE_MEMBERS_IN_COMMENT = 'invite_members_in_comment';
|
||||
|
|
|
@ -67,7 +67,7 @@ export const isReadyToCommit = (state) => {
|
|||
}
|
||||
}
|
||||
|
||||
return !state.isSubmitting && hasCommitMessage && !unresolved;
|
||||
return Boolean(!state.isSubmitting && hasCommitMessage && !unresolved);
|
||||
};
|
||||
|
||||
export const getCommitButtonText = (state) => {
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
module GitlabRoutingHelper
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include ::ProjectsHelper
|
||||
include ::ApplicationSettingsHelper
|
||||
include API::Helpers::RelatedResourcesHelpers
|
||||
included do
|
||||
Gitlab::Routing.includes_helpers(self)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]}")
|
||||
|
|
|
@ -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!(
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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') } }
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Support daily DORA metrics API
|
||||
merge_request: 56080
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Document how to use custom omniauth button icon
|
||||
merge_request: 55388
|
||||
author: Diego Louzán
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix bug in Gollum Tags filter
|
||||
merge_request: 56638
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add JavaScript, TypeScript, and React support to the semgrep analyzer.
|
||||
merge_request: 55257
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Resolve nested variable values sent to the runner
|
||||
merge_request: 48627
|
||||
author:
|
||||
type: added
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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 }
|
||||
]
|
||||
```
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
```
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 13 KiB |
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
|
@ -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>'
|
||||
...
|
||||
}
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)**
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
|
@ -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)**
|
||||
|
||||
Stages are visually depicted as a horizontal process flow. Selecting a stage will update the
|
||||
the content below the value stream.
|
||||
![Value stream path navigation](img/vsa_path_nav_v13_10.png "Value stream path navigation")
|
||||
|
||||
This is disabled by default. If you have a self-managed instance, an
|
||||
Stages are visually depicted as a horizontal process flow. Selecting a stage updates the content below the value stream.
|
||||
|
||||
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
|
||||
|
|
|
@ -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)** | | ✓ | ✓ | ✓ | ✓ |
|
||||
|
|
18
lefthook.yml
18
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -303,6 +303,10 @@ semgrep-sast:
|
|||
$SAST_EXPERIMENTAL_FEATURES == 'true'
|
||||
exists:
|
||||
- '**/*.py'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
- '**/*.ts'
|
||||
- '**/*.tsx'
|
||||
|
||||
sobelow-sast:
|
||||
extends: .sast-analyzer
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -5,7 +5,7 @@ FactoryBot.define do
|
|||
skip_create
|
||||
|
||||
transient do
|
||||
author { create(:user) }
|
||||
author { association(:user) }
|
||||
end
|
||||
|
||||
sequence(:message) { |n| "Commit message #{n}" }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 } }
|
||||
|
|
|
@ -2,6 +2,6 @@
|
|||
|
||||
FactoryBot.define do
|
||||
factory :import_export_upload do
|
||||
project { create(:project) }
|
||||
project { association(:project) }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = createComponent();
|
||||
});
|
||||
|
||||
it('includes the correct displayText for the button', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findButton().text()).toBe(displayText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clicking the link', () => {
|
||||
let spy;
|
||||
|
||||
beforeEach(() => {
|
||||
spy = jest.spyOn(eventHub, '$emit');
|
||||
});
|
||||
|
||||
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_');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
const downloadEmailPatchesEl = wrapper.find('.js-download-email-patches');
|
||||
const downloadPlainDiffEl = wrapper.find('.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',
|
||||
);
|
||||
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 });
|
||||
|
||||
const link = vm.$el.querySelector('.js-web-ide');
|
||||
await nextTick();
|
||||
|
||||
expect(link.classList.contains('disabled')).toBe(true);
|
||||
expect(link.getAttribute('href')).toBeNull();
|
||||
const link = wrapper.find('.js-web-ide');
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
||||
let(:build) { create(:ci_build) }
|
||||
|
||||
it 'returns a Collection' do
|
||||
is_expected.to be_an_instance_of(Gitlab::Ci::Variables::Collection)
|
||||
end
|
||||
end
|
||||
|
||||
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) }
|
||||
|
||||
it 'returns an array' do
|
||||
is_expected.to be_an_instance_of(Array)
|
||||
before do
|
||||
stub_feature_flags(variable_inside_variable: [project_with_flag_enabled])
|
||||
end
|
||||
|
||||
it 'returns the expected variables' do
|
||||
is_expected.to eq(presenter.variables.to_runner_variables)
|
||||
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_behaves_like 'returns an array with the expected variables'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#runner_variables subset' do
|
||||
subject { presenter.runner_variables.select { |v| %w[A B C].include?(v.fetch(:key)) } }
|
||||
|
||||
let(:build) { create(:ci_build) }
|
||||
|
||||
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 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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
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")
|
||||
it 'returns error if pages_migration_mark_as_not_deployed is disabled' do
|
||||
stub_feature_flags(pages_migration_mark_as_not_deployed: false)
|
||||
|
||||
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
|
||||
|
||||
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 public directory is a symlink' do
|
||||
before do
|
||||
create_dir('target')
|
||||
create_file('./target/index.html', 'hello')
|
||||
create_link("public", "./target")
|
||||
end
|
||||
|
||||
include_examples 'handles invalid public directory'
|
||||
end
|
||||
|
||||
context 'when there is a public directory' do
|
||||
|
|
Loading…
Reference in New Issue