diff options
93 files changed, 1884 insertions, 568 deletions
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 753e6941c43..ef7bfae02a2 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -11,6 +11,11 @@ export default { BoardListHeader, BoardList, }, + inject: { + boardId: { + default: '', + }, + }, props: { list: { type: Object, @@ -27,11 +32,6 @@ export default { default: false, }, }, - inject: { - boardId: { - default: '', - }, - }, data() { return { detailIssue: boardsStore.detail, diff --git a/app/assets/javascripts/boards/components/board_column_new.vue b/app/assets/javascripts/boards/components/board_column_new.vue index 7839f45c48b..ad49936567d 100644 --- a/app/assets/javascripts/boards/components/board_column_new.vue +++ b/app/assets/javascripts/boards/components/board_column_new.vue @@ -9,6 +9,11 @@ export default { BoardListHeader, BoardList, }, + inject: { + boardId: { + default: '', + }, + }, props: { list: { type: Object, @@ -25,11 +30,6 @@ export default { default: false, }, }, - inject: { - boardId: { - default: '', - }, - }, computed: { ...mapState(['filterParams']), ...mapGetters(['getIssuesByList']), diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 50782781538..4bce0de7c12 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -49,6 +49,14 @@ export default { GlModal, BoardConfigurationOptions, }, + inject: { + fullPath: { + default: '', + }, + rootPath: { + default: '', + }, + }, props: { canAdminBoard: { type: Boolean, @@ -92,14 +100,6 @@ export default { required: true, }, }, - inject: { - fullPath: { - default: '', - }, - rootPath: { - default: '', - }, - }, data() { return { board: { ...boardDefaults, ...this.currentBoard }, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 3db5c2e0830..814d261e808 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -31,6 +31,11 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: { + boardId: { + default: '', + }, + }, props: { list: { type: Object, @@ -47,11 +52,6 @@ export default { default: false, }, }, - inject: { - boardId: { - default: '', - }, - }, data() { return { weightFeatureAvailable: false, diff --git a/app/assets/javascripts/boards/components/board_list_header_new.vue b/app/assets/javascripts/boards/components/board_list_header_new.vue index 44eb2aa34c2..06f39eceb08 100644 --- a/app/assets/javascripts/boards/components/board_list_header_new.vue +++ b/app/assets/javascripts/boards/components/board_list_header_new.vue @@ -37,6 +37,20 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + inject: { + boardId: { + default: '', + }, + weightFeatureAvailable: { + default: false, + }, + scopedLabelsAvailable: { + default: false, + }, + currentUserId: { + default: null, + }, + }, props: { list: { type: Object, @@ -53,20 +67,6 @@ export default { default: false, }, }, - inject: { - boardId: { - default: '', - }, - weightFeatureAvailable: { - default: false, - }, - scopedLabelsAvailable: { - default: false, - }, - currentUserId: { - default: null, - }, - }, computed: { ...mapState(['activeId']), isLoggedIn() { diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index a9e6d768656..2b0ddbed7b3 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -16,13 +16,13 @@ export default { GlButton, }, mixins: [glFeatureFlagMixin()], + inject: ['groupId'], props: { list: { type: Object, required: true, }, }, - inject: ['groupId'], data() { return { title: '', diff --git a/app/assets/javascripts/boards/components/board_new_issue_new.vue b/app/assets/javascripts/boards/components/board_new_issue_new.vue index 5766484a3ff..674a49e01ef 100644 --- a/app/assets/javascripts/boards/components/board_new_issue_new.vue +++ b/app/assets/javascripts/boards/components/board_new_issue_new.vue @@ -18,13 +18,13 @@ export default { GlButton, }, mixins: [glFeatureFlagMixin()], + inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], props: { list: { type: Object, required: true, }, }, - inject: ['groupId', 'weightFeatureAvailable', 'boardWeight'], data() { return { title: '', diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index a635eb012cc..457d0d4dcd6 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -27,6 +27,7 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [issueCardInner], + inject: ['groupId', 'rootPath', 'scopedLabelsAvailable'], props: { issue: { type: Object, @@ -43,7 +44,6 @@ export default { default: false, }, }, - inject: ['groupId', 'rootPath', 'scopedLabelsAvailable'], data() { return { limitBeforeCounter: 2, diff --git a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue index ac0b16914ef..75cf1f0b9e1 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner_deprecated.vue @@ -25,6 +25,7 @@ export default { GlTooltip: GlTooltipDirective, }, mixins: [issueCardInner], + inject: ['groupId', 'rootPath'], props: { issue: { type: Object, @@ -41,7 +42,6 @@ export default { default: false, }, }, - inject: ['groupId', 'rootPath'], data() { return { limitBeforeCounter: 2, diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index 6c16005e7f4..f6b00b695da 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -11,13 +11,13 @@ export default { GlIcon, GlTooltip, }, + inject: ['timeTrackingLimitToHours'], props: { estimate: { type: Number, required: true, }, }, - inject: ['timeTrackingLimitToHours'], computed: { title() { return stringifyTime( diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 446023e1072..aecb2125e04 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -33,13 +33,13 @@ export default { GlDropdownText, GlSearchBoxByType, }, + inject: ['groupId'], props: { list: { type: Object, required: true, }, }, - inject: ['groupId'], data() { return { initialLoading: true, diff --git a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue index b4fe16de695..61863bbe2a9 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_editable_item.vue @@ -3,6 +3,7 @@ import { GlButton, GlLoadingIcon } from '@gitlab/ui'; export default { components: { GlButton, GlLoadingIcon }, + inject: ['canUpdate'], props: { title: { type: String, @@ -25,7 +26,6 @@ export default { default: true, }, }, - inject: ['canUpdate'], data() { return { edit: false, diff --git a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue index d540ee5f3c7..dcf769e6fe5 100644 --- a/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue +++ b/app/assets/javascripts/boards/components/sidebar/board_sidebar_labels_select.vue @@ -14,12 +14,12 @@ export default { LabelsSelect, GlLabel, }, + inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], data() { return { loading: false, }; }, - inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'], computed: { ...mapGetters(['activeIssue', 'projectPathForActiveIssue']), selectedLabels() { diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index 17a12e84a37..738c8fb927e 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -18,6 +18,10 @@ export default (params = {}) => { BoardsSelector, }, apolloProvider, + provide: { + fullPath: params.fullPath, + rootPath: params.rootPath, + }, data() { const { dataset } = boardsSwitcherElement; @@ -35,10 +39,6 @@ export default (params = {}) => { return { boardsSelectorProps }; }, - provide: { - fullPath: params.fullPath, - rootPath: params.rootPath, - }, render(createElement) { return createElement(BoardsSelector, { props: this.boardsSelectorProps, diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index 2880d649075..9217466a0b5 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -1,5 +1,5 @@ <script> -import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui'; import { __, s__, sprintf } from '~/locale'; import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -28,12 +28,13 @@ const LOAD_FAILURE_UNKNOWN = 'LOAD_FAILURE_UNKNOWN'; export default { components: { - CommitForm, CiLint, + CommitForm, EditorTab, GlAlert, GlLoadingIcon, GlTabs, + GlTab, PipelineGraph, TextEditor, ValidationSegment, @@ -317,16 +318,15 @@ export default { :commit-sha="lastCommitSha" /> </editor-tab> - <editor-tab + <gl-tab v-if="glFeatures.ciConfigVisualizationTab" :lazy="true" :title="$options.i18n.tabGraph" - :title-link-attributes="{ 'data-testid': 'visualization-tab-btn' }" data-testid="visualization-tab" > <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> <pipeline-graph v-else :pipeline-data="ciConfigData" /> - </editor-tab> + </gl-tab> <editor-tab :title="$options.i18n.tabLint"> <gl-loading-icon v-if="isCiConfigDataLoading" size="lg" class="gl-m-3" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index 17a880036e7..20ac8f5a467 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -1,21 +1,37 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge'; +import autoMergeEnabledQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { deprecatedCreateFlash as Flash } from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import MrWidgetAuthor from '../mr_widget_author.vue'; import eventHub from '../../event_hub'; import { AUTO_MERGE_STRATEGIES } from '../../constants'; import { __ } from '~/locale'; +import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; export default { name: 'MRWidgetAutoMergeEnabled', + apollo: { + state: { + query: autoMergeEnabledQuery, + skip() { + return !this.glFeatures.mergeRequestWidgetGraphql; + }, + variables() { + return this.mergeRequestQueryVariables; + }, + update: (data) => data.project?.mergeRequest, + }, + }, components: { MrWidgetAuthor, statusIcon, GlLoadingIcon, + GlSkeletonLoader, }, - mixins: [autoMergeMixin], + mixins: [autoMergeMixin, glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], props: { mr: { type: Object, @@ -30,20 +46,47 @@ export default { }, data() { return { + state: {}, isCancellingAutoMerge: false, isRemovingSourceBranch: false, }; }, computed: { + loading() { + return this.glFeatures.mergeRequestWidgetGraphql && this.$apollo.queries.state.loading; + }, + mergeUser() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + return this.state.mergeUser; + } + + return this.mr.setToAutoMergeBy; + }, + targetBranch() { + return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).targetBranch; + }, + shouldRemoveSourceBranch() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + return this.state.shouldRemoveSourceBranch || this.state.forceRemoveSourceBranch; + } + + return this.mr.shouldRemoveSourceBranch; + }, + autoMergeStrategy() { + return (this.glFeatures.mergeRequestWidgetGraphql ? this.state : this.mr).autoMergeStrategy; + }, canRemoveSourceBranch() { - const { - shouldRemoveSourceBranch, - canRemoveSourceBranch, - mergeUserId, - currentUserId, - } = this.mr; + const { currentUserId } = this.mr; + const mergeUserId = this.glFeatures.mergeRequestWidgetGraphql + ? this.state.mergeUser?.id + : this.mr.mergeUserId; + const canRemoveSourceBranch = this.glFeatures.mergeRequestWidgetGraphql + ? this.state.userPermissions.removeSourceBranch + : this.mr.canRemoveSourceBranch; - return !shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId; + return ( + !this.shouldRemoveSourceBranch && canRemoveSourceBranch && mergeUserId === currentUserId + ); }, }, methods: { @@ -63,7 +106,7 @@ export default { removeSourceBranch() { const options = { sha: this.mr.sha, - auto_merge_strategy: this.mr.autoMergeStrategy, + auto_merge_strategy: this.autoMergeStrategy, should_remove_source_branch: true, }; @@ -86,49 +129,64 @@ export default { </script> <template> <div class="mr-widget-body media"> - <status-icon status="success" /> - <div class="media-body"> - <h4 class="d-flex align-items-start"> - <span class="gl-mr-3"> - <span class="js-status-text-before-author">{{ statusTextBeforeAuthor }}</span> - <mr-widget-author :author="mr.setToAutoMergeBy" /> - <span class="js-status-text-after-author">{{ statusTextAfterAuthor }}</span> - </span> - <a - v-if="mr.canCancelAutomaticMerge" - :disabled="isCancellingAutoMerge" - role="button" - href="#" - class="btn btn-sm btn-default js-cancel-auto-merge" - @click.prevent="cancelAutomaticMerge" - > - <gl-loading-icon v-if="isCancellingAutoMerge" inline class="gl-mr-1" /> - {{ cancelButtonText }} - </a> - </h4> - <section class="mr-info-list"> - <p> - {{ s__('mrWidget|The changes will be merged into') }} - <a :href="mr.targetBranchPath" class="label-branch">{{ mr.targetBranch }}</a> - </p> - <p v-if="mr.shouldRemoveSourceBranch"> - {{ s__('mrWidget|The source branch will be deleted') }} - </p> - <p v-else class="d-flex align-items-start"> - <span class="gl-mr-3">{{ s__('mrWidget|The source branch will not be deleted') }}</span> + <div v-if="loading" class="gl-w-full mr-conflict-loader"> + <gl-skeleton-loader :width="334" :height="30"> + <rect x="0" y="3" width="24" height="24" rx="4" /> + <rect x="32" y="7" width="150" height="16" rx="4" /> + <rect x="190" y="7" width="144" height="16" rx="4" /> + </gl-skeleton-loader> + </div> + <template v-else> + <status-icon status="success" /> + <div class="media-body"> + <h4 class="gl-display-flex"> + <span class="gl-mr-3"> + <span class="js-status-text-before-author" data-testid="beforeStatusText">{{ + statusTextBeforeAuthor + }}</span> + <mr-widget-author :author="mergeUser" /> + <span class="js-status-text-after-author" data-testid="afterStatusText">{{ + statusTextAfterAuthor + }}</span> + </span> <a - v-if="canRemoveSourceBranch" - :disabled="isRemovingSourceBranch" + v-if="mr.canCancelAutomaticMerge" + :disabled="isCancellingAutoMerge" role="button" - class="btn btn-sm btn-default js-remove-source-branch" href="#" - @click.prevent="removeSourceBranch" + class="btn btn-sm btn-default js-cancel-auto-merge" + data-testid="cancelAutomaticMergeButton" + @click.prevent="cancelAutomaticMerge" > - <gl-loading-icon v-if="isRemovingSourceBranch" inline class="gl-mr-1" /> - {{ s__('mrWidget|Delete source branch') }} + <gl-loading-icon v-if="isCancellingAutoMerge" inline class="gl-mr-1" /> + {{ cancelButtonText }} </a> - </p> - </section> - </div> + </h4> + <section class="mr-info-list"> + <p> + {{ s__('mrWidget|The changes will be merged into') }} + <a :href="mr.targetBranchPath" class="label-branch">{{ targetBranch }}</a> + </p> + <p v-if="shouldRemoveSourceBranch"> + {{ s__('mrWidget|The source branch will be deleted') }} + </p> + <p v-else class="gl-display-flex"> + <span class="gl-mr-3">{{ s__('mrWidget|The source branch will not be deleted') }}</span> + <a + v-if="canRemoveSourceBranch" + :disabled="isRemovingSourceBranch" + role="button" + class="btn btn-sm btn-default js-remove-source-branch" + href="#" + data-testid="removeSourceBranchButton" + @click.prevent="removeSourceBranch" + > + <gl-loading-icon v-if="isRemovingSourceBranch" inline class="gl-mr-1" /> + {{ s__('mrWidget|Delete source branch') }} + </a> + </p> + </section> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue index 30da9947859..a2771bc4bfb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue @@ -1,7 +1,10 @@ <script> import { GlLoadingIcon, GlButton } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; +import autoMergeFailedQuery from '../../queries/states/auto_merge_failed.query.graphql'; +import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; export default { name: 'MRWidgetAutoMergeFailed', @@ -10,6 +13,19 @@ export default { GlLoadingIcon, GlButton, }, + mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], + apollo: { + mergeError: { + query: autoMergeFailedQuery, + skip() { + return !this.glFeatures.mergeRequestWidgetGraphql; + }, + variables() { + return this.mergeRequestQueryVariables; + }, + update: (data) => data.project?.mergeRequest?.mergeError, + }, + }, props: { mr: { type: Object, @@ -18,6 +34,7 @@ export default { }, data() { return { + mergeError: this.glFeatures.mergeRequestWidgetGraphql ? null : this.mr.mergeError, isRefreshing: false, }; }, @@ -36,7 +53,7 @@ export default { <status-icon status="warning" /> <div class="media-body space-children gl-display-flex gl-flex-wrap gl-align-items-center"> <span class="bold"> - <template v-if="mr.mergeError">{{ mr.mergeError }}</template> + <template v-if="mergeError">{{ mergeError }}</template> {{ s__('mrWidget|This merge request failed to be merged automatically') }} </span> <gl-button diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql new file mode 100644 index 00000000000..64cd70fcf42 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.fragment.graphql @@ -0,0 +1,15 @@ +fragment autoMergeEnabled on MergeRequest { + autoMergeStrategy + mergeUser { + name + username + webUrl + avatarUrl + } + targetBranch + shouldRemoveSourceBranch + forceRemoveSourceBranch + userPermissions { + removeSourceBranch + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql new file mode 100644 index 00000000000..bdcb7a8206b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_enabled.query.graphql @@ -0,0 +1,10 @@ +#import "./auto_merge_enabled.fragment.graphql" + +query autoMergeEnabledQuery($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + ...autoMergeEnabled + mergeTrainsCount + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql new file mode 100644 index 00000000000..2fe0d174b67 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/auto_merge_failed.query.graphql @@ -0,0 +1,7 @@ +query autoMergeFailedQuery($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + mergeError + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js index 4949a73d372..ab0fe21cb99 100644 --- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js @@ -4,6 +4,9 @@ import { spriteIcon } from '~/lib/utils/common_utils'; const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings +// Number of users to show in the autocomplete menu to avoid doing a mass fetch of 100+ avatars +const memberLimit = 10; + const nonWordOrInteger = /\W|^\d+$/; export const GfmAutocompleteType = { @@ -74,6 +77,7 @@ export const tributeConfig = { fillAttr: 'username', lookup: (value) => value.type === groupType ? last(value.name.split(' / ')) : `${value.name}${value.username}`, + menuItemLimit: memberLimit, menuItemTemplate: ({ original }) => { const commonClasses = 'gl-avatar gl-avatar-s24 gl-flex-shrink-0'; const noAvatarClasses = `${commonClasses} gl-rounded-small diff --git a/app/assets/stylesheets/page_bundles/error_tracking_index.scss b/app/assets/stylesheets/page_bundles/error_tracking_index.scss index 65bddfb7890..5c49bcc0348 100644 --- a/app/assets/stylesheets/page_bundles/error_tracking_index.scss +++ b/app/assets/stylesheets/page_bundles/error_tracking_index.scss @@ -5,6 +5,10 @@ min-width: auto; } + .filtered-search-box .form-control { + min-width: unset; + } + .sort-control { .btn { padding-right: 2rem; diff --git a/app/graphql/types/alert_management/domain_filter_enum.rb b/app/graphql/types/alert_management/domain_filter_enum.rb index 58dbc8bb2cf..a798cfb9ee9 100644 --- a/app/graphql/types/alert_management/domain_filter_enum.rb +++ b/app/graphql/types/alert_management/domain_filter_enum.rb @@ -6,7 +6,7 @@ module Types graphql_name 'AlertManagementDomainFilter' description 'Filters the alerts based on given domain' - value 'operations', description: 'Alerts for operations domain ' + value 'operations', description: 'Alerts for operations domain' value 'threat_monitoring', description: 'Alerts for threat monitoring domain' end end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 9f5ded0d2f0..ee7d5780f7a 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -175,6 +175,10 @@ module Types calls_gitaly: true, description: 'Merge request commits excluding merge commits' field :security_auto_fix, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if the merge request is created by @GitLab-Security-Bot.' + field :auto_merge_strategy, GraphQL::STRING_TYPE, null: true, + description: 'Selected auto merge strategy' + field :merge_user, Types::UserType, null: true, + description: 'User who merged this merge request' def approved_by object.approved_by_users diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb index 2c34bd9edcc..a2064e020b3 100644 --- a/app/models/namespace/package_setting.rb +++ b/app/models/namespace/package_setting.rb @@ -4,9 +4,25 @@ class Namespace::PackageSetting < ApplicationRecord self.primary_key = :namespace_id self.table_name = 'namespace_package_settings' + PackageSettingNotImplemented = Class.new(StandardError) + + PACKAGES_WITH_SETTINGS = %w[maven].freeze + belongs_to :namespace, inverse_of: :package_setting_relation validates :namespace, presence: true validates :maven_duplicates_allowed, inclusion: { in: [true, false] } validates :maven_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } + + class << self + def duplicates_allowed?(package) + return true unless package + raise PackageSettingNotImplemented unless PACKAGES_WITH_SETTINGS.include?(package.package_type) + + duplicates_allowed = package.package_settings["#{package.package_type}_duplicates_allowed"] + regex = ::Gitlab::UntrustedRegexp.new("\\A#{package.package_settings["#{package.package_type}_duplicate_exception_regex"]}\\z") + + duplicates_allowed || regex.match?(package.name) + end + end end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index 32c43616877..2067a800ad5 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -200,6 +200,12 @@ class Packages::Package < ApplicationRecord debian? && !version.nil? end + def package_settings + strong_memoize(:package_settings) do + project.namespace.package_settings + end + end + private def composer_tag_version? diff --git a/app/models/project.rb b/app/models/project.rb index fdb16640cb4..8939b2ee84d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1333,19 +1333,11 @@ class Project < ApplicationRecord end def external_wiki - if has_external_wiki.nil? - cache_has_external_wiki - end + cache_has_external_wiki if has_external_wiki.nil? - if has_external_wiki - @external_wiki ||= services.external_wikis.first - else - nil - end - end + return unless has_external_wiki? - def cache_has_external_wiki - update_column(:has_external_wiki, services.external_wikis.any?) if Gitlab::Database.read_write? + @external_wiki ||= services.external_wikis.first end def find_or_initialize_services @@ -2707,6 +2699,10 @@ class Project < ApplicationRecord objects.each_batch { |relation| out.concat(relation.pluck(:oid)) } end end + + def cache_has_external_wiki + update_column(:has_external_wiki, services.external_wikis.any?) if Gitlab::Database.read_write? + end end Project.prepend_if_ee('EE::Project') diff --git a/app/models/service.rb b/app/models/service.rb index 57c099d6f04..9f17279d0a3 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -48,7 +48,6 @@ class Service < ApplicationRecord after_commit :reset_updated_properties after_commit :cache_project_has_external_issue_tracker - after_commit :cache_project_has_external_wiki belongs_to :project, inverse_of: :services belongs_to :group, inverse_of: :services @@ -469,12 +468,6 @@ class Service < ApplicationRecord end end - def cache_project_has_external_wiki - if project && !project.destroyed? - project.cache_has_external_wiki - end - end - def valid_recipients? activated? && !importing? end diff --git a/app/services/bulk_create_integration_service.rb b/app/services/bulk_create_integration_service.rb index 61c5565db60..df78c3645c7 100644 --- a/app/services/bulk_create_integration_service.rb +++ b/app/services/bulk_create_integration_service.rb @@ -38,10 +38,6 @@ class BulkCreateIntegrationService if integration.external_issue_tracker? Project.where(id: batch.select(:id)).update_all(has_external_issue_tracker: true) end - - if integration.external_wiki? - Project.where(id: batch.select(:id)).update_all(has_external_wiki: true) - end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb index 4107f8c6731..bce685cacdf 100644 --- a/app/services/packages/maven/find_or_create_package_service.rb +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -10,6 +10,10 @@ module Packages ::Packages::Maven::PackageFinder.new(params[:path], current_user, project: project) .execute + unless Namespace::PackageSetting.duplicates_allowed?(package) + return ServiceResponse.error(message: 'Duplicate package is not allowed') + end + unless package # Maven uploads several files during `mvn deploy` in next order: # - my-company/my-app/1.0-SNAPSHOT/my-app.jar @@ -48,7 +52,7 @@ module Packages package.build_infos.safe_find_or_create_by!(pipeline: params[:build].pipeline) if params[:build].present? - package + ServiceResponse.success(payload: { package: package }) end end end diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 8f5fac1a40b..dc4172e2f09 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -4,7 +4,7 @@ %li{ class: "branch-item js-branch-item js-branch-#{branch.name}", data: { name: branch.name } } .branch-info .branch-title - = sprite_icon('fork', size: 12) + = sprite_icon('fork', size: 12, css_class: 'gl-flex-shrink-0') = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name gl-ml-3 qa-branch-name' do = branch.name - if branch.name == @repository.root_ref diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 9c319c8e906..05a41fceafe 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1434,6 +1434,14 @@ :tags: [] - :name: bulk_import :feature_category: :importers + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] +- :name: bulk_imports_entity + :feature_category: :importers :has_external_dependencies: true :urgency: :low :resource_boundary: :unknown diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index 7828d046036..81099d4e5f7 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -7,9 +7,58 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker sidekiq_options retry: false, dead: false - worker_has_external_dependencies! + PERFORM_DELAY = 5.seconds + DEFAULT_BATCH_SIZE = 5 def perform(bulk_import_id) - BulkImports::Importers::GroupsImporter.new(bulk_import_id).execute + @bulk_import = BulkImport.find_by_id(bulk_import_id) + + return unless @bulk_import + return if @bulk_import.finished? + return @bulk_import.finish! if all_entities_processed? && @bulk_import.started? + return re_enqueue if max_batch_size_exceeded? # Do not start more jobs if max allowed are already running + + @bulk_import.start! if @bulk_import.created? + + created_entities.first(next_batch_size).each do |entity| + entity.start! + + BulkImports::EntityWorker.perform_async(entity.id) + end + + re_enqueue + end + + private + + def entities + @entities ||= @bulk_import.entities + end + + def started_entities + entities.with_status(:started) + end + + def created_entities + entities.with_status(:created) + end + + def all_entities_processed? + entities.all? { |entity| entity.finished? || entity.failed? } + end + + def max_batch_size_exceeded? + started_entities.count >= DEFAULT_BATCH_SIZE + end + + def next_batch_size + [DEFAULT_BATCH_SIZE - started_entities.count, 0].max + end + + # A new BulkImportWorker job is enqueued to either + # - Process the new BulkImports::Entity created during import (e.g. for the subgroups) + # - Or to mark the `bulk_import` as finished + def re_enqueue + BulkImportWorker.perform_in(PERFORM_DELAY, @bulk_import.id) end end diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb new file mode 100644 index 00000000000..9b29ad8f326 --- /dev/null +++ b/app/workers/bulk_imports/entity_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module BulkImports + class EntityWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + feature_category :importers + + sidekiq_options retry: false, dead: false + + worker_has_external_dependencies! + + def perform(entity_id) + entity = BulkImports::Entity.with_status(:started).find_by_id(entity_id) + + if entity + entity.update!(jid: jid) + + BulkImports::Importers::GroupImporter.new(entity).execute + end + end + end +end diff --git a/changelogs/unreleased/276882-use-package-settings-maven.yml b/changelogs/unreleased/276882-use-package-settings-maven.yml new file mode 100644 index 00000000000..2e38081bb9a --- /dev/null +++ b/changelogs/unreleased/276882-use-package-settings-maven.yml @@ -0,0 +1,5 @@ +--- +title: Check namespace package settings when creating Maven packages +merge_request: 50691 +author: +type: changed diff --git a/changelogs/unreleased/277030-fix-error-tracking-mobile-layout.yml b/changelogs/unreleased/277030-fix-error-tracking-mobile-layout.yml new file mode 100644 index 00000000000..66248123b56 --- /dev/null +++ b/changelogs/unreleased/277030-fix-error-tracking-mobile-layout.yml @@ -0,0 +1,5 @@ +--- +title: Fix mobile layout Error Tracking details page +merge_request: 50970 +author: Kev @KevSlashNull +type: fixed diff --git a/changelogs/unreleased/290715-has_external_wiki_trigger.yml b/changelogs/unreleased/290715-has_external_wiki_trigger.yml new file mode 100644 index 00000000000..fcc635e53f9 --- /dev/null +++ b/changelogs/unreleased/290715-has_external_wiki_trigger.yml @@ -0,0 +1,5 @@ +--- +title: Add PostgreSQL trigger to maintain projects.has_external_wiki +merge_request: 49916 +author: +type: changed diff --git a/changelogs/unreleased/295263-fork-icon-shrinks-if-branch-name-is-very-long.yml b/changelogs/unreleased/295263-fork-icon-shrinks-if-branch-name-is-very-long.yml new file mode 100644 index 00000000000..1c73ef105c5 --- /dev/null +++ b/changelogs/unreleased/295263-fork-icon-shrinks-if-branch-name-is-very-long.yml @@ -0,0 +1,5 @@ +--- +title: Fix fork icon shrinks if branch name is very long +merge_request: 50915 +author: Kev @KevSlashNull +type: fixed diff --git a/changelogs/unreleased/board_api.yml b/changelogs/unreleased/board_api.yml new file mode 100644 index 00000000000..9c0b2e88878 --- /dev/null +++ b/changelogs/unreleased/board_api.yml @@ -0,0 +1,5 @@ +--- +title: Expose hide_backlog_list and hide_closed_list to project and group boards REST API +merge_request: 49815 +author: Mathieu Parent +type: added diff --git a/changelogs/unreleased/georgekoltsov-bulk-import-entity-worker.yml b/changelogs/unreleased/georgekoltsov-bulk-import-entity-worker.yml new file mode 100644 index 00000000000..aea74fe9248 --- /dev/null +++ b/changelogs/unreleased/georgekoltsov-bulk-import-entity-worker.yml @@ -0,0 +1,5 @@ +--- +title: Move Group Migration entities import to individual sidekiq jobs +merge_request: 50781 +author: +type: changed diff --git a/changelogs/unreleased/secret-detection-no-run-on-tag.yml b/changelogs/unreleased/secret-detection-no-run-on-tag.yml new file mode 100644 index 00000000000..f9de2c7a826 --- /dev/null +++ b/changelogs/unreleased/secret-detection-no-run-on-tag.yml @@ -0,0 +1,5 @@ +--- +title: Skip secret_detection on tags +merge_request: 51129 +author: +type: changed diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index b2da6ac93bc..d5b3925131e 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -50,6 +50,8 @@ - 1 - - bulk_import - 1 +- - bulk_imports_entity + - 1 - - chaos - 2 - - chat_notification diff --git a/data/whats_new/202009300001_13_04.yml b/data/whats_new/202009300001_13_04.yml index 0c5642152ae..dc48b2fbf26 100644 --- a/data/whats_new/202009300001_13_04.yml +++ b/data/whats_new/202009300001_13_04.yml @@ -63,7 +63,7 @@ stage: Release self-managed: true gitlab-com: true - packages: [starter, premium, ultimate] + packages: [Starter, Premium, Ultimate] url: https://www.youtube.com/embed/1FBRaBQTQZk image_url: https://img.youtube.com/vi/1FBRaBQTQZk/hqdefault.jpg published_at: 2020-09-22 diff --git a/data/whats_new/202012160001_13_07.yml b/data/whats_new/202012160001_13_07.yml index 63f8f857d71..14ed04aa0f7 100644 --- a/data/whats_new/202012160001_13_07.yml +++ b/data/whats_new/202012160001_13_07.yml @@ -47,4 +47,5 @@ stage: Verify body: | Available today is the GitLab Runner container image for the [Red Hat OpenShift Container Platform](https://www.openshift.com/products/container-platform). To install the runner on OpenShift, you can use the new [GitLab Runner Operator](https://gitlab.com/gitlab-org/gl-openshift/gitlab-runner-operator) available from the beta channel in Red Hat's Operator Hub - a web console for OpenShift cluster administrators to discover and select Operators to install on their cluster. Operator Hub is deployed by default in the OpenShift Container Platform. We plan to transition the GitLab Runner Operator to the stable channel, and by extension [GA](https://gitlab.com/gitlab-org/gl-openshift/gitlab-runner-operator/-/issues/6), in early 2021. Finally, we are also developing an operator for GitLab, so stay tuned to future release posts for those announcements. - + published_at: 2020-12-22 + release: 13.7 diff --git a/db/migrate/20201214032220_add_has_external_wiki_trigger.rb b/db/migrate/20201214032220_add_has_external_wiki_trigger.rb new file mode 100644 index 00000000000..f6e066b75da --- /dev/null +++ b/db/migrate/20201214032220_add_has_external_wiki_trigger.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class AddHasExternalWikiTrigger < ActiveRecord::Migration[6.0] + include Gitlab::Database::SchemaHelpers + + DOWNTIME = false + FUNCTION_NAME = 'set_has_external_wiki'.freeze + TRIGGER_ON_INSERT_NAME = 'trigger_has_external_wiki_on_insert'.freeze + TRIGGER_ON_UPDATE_NAME = 'trigger_has_external_wiki_on_update'.freeze + TRIGGER_ON_DELETE_NAME = 'trigger_has_external_wiki_on_delete'.freeze + + def up + create_trigger_function(FUNCTION_NAME, replace: true) do + <<~SQL + UPDATE projects SET has_external_wiki = COALESCE(NEW.active, FALSE) + WHERE projects.id = COALESCE(NEW.project_id, OLD.project_id); + RETURN NULL; + SQL + end + + execute(<<~SQL) + CREATE TRIGGER #{TRIGGER_ON_INSERT_NAME} + AFTER INSERT ON services + FOR EACH ROW + WHEN (NEW.active = TRUE AND NEW.type = 'ExternalWikiService' AND NEW.project_id IS NOT NULL) + EXECUTE FUNCTION #{FUNCTION_NAME}(); + SQL + + execute(<<~SQL) + CREATE TRIGGER #{TRIGGER_ON_UPDATE_NAME} + AFTER UPDATE ON services + FOR EACH ROW + WHEN (NEW.type = 'ExternalWikiService' AND OLD.active != NEW.active AND NEW.project_id IS NOT NULL) + EXECUTE FUNCTION #{FUNCTION_NAME}(); + SQL + + execute(<<~SQL) + CREATE TRIGGER #{TRIGGER_ON_DELETE_NAME} + AFTER DELETE ON services + FOR EACH ROW + WHEN (OLD.type = 'ExternalWikiService' AND OLD.project_id IS NOT NULL) + EXECUTE FUNCTION #{FUNCTION_NAME}(); + SQL + end + + def down + drop_trigger(:services, TRIGGER_ON_INSERT_NAME) + drop_trigger(:services, TRIGGER_ON_UPDATE_NAME) + drop_trigger(:services, TRIGGER_ON_DELETE_NAME) + drop_function(FUNCTION_NAME) + end +end diff --git a/db/schema_migrations/20201214032220 b/db/schema_migrations/20201214032220 new file mode 100644 index 00000000000..ec14b260583 --- /dev/null +++ b/db/schema_migrations/20201214032220 @@ -0,0 +1 @@ +db23b5315386ad5d5fec5a14958769cc1e62a0a89ec3246edb9fc024607e917b
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 315cb9fe5c3..4d1c265a05f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10,6 +10,17 @@ CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE FUNCTION set_has_external_wiki() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN +UPDATE projects SET has_external_wiki = COALESCE(NEW.active, FALSE) +WHERE projects.id = COALESCE(NEW.project_id, OLD.project_id); +RETURN NULL; + +END +$$; + CREATE FUNCTION table_sync_function_2be879775d() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -23559,6 +23570,12 @@ ALTER INDEX product_analytics_events_experimental_pkey ATTACH PARTITION gitlab_p CREATE TRIGGER table_sync_trigger_ee39a25f9d AFTER INSERT OR DELETE OR UPDATE ON audit_events FOR EACH ROW EXECUTE PROCEDURE table_sync_function_2be879775d(); +CREATE TRIGGER trigger_has_external_wiki_on_delete AFTER DELETE ON services FOR EACH ROW WHEN ((((old.type)::text = 'ExternalWikiService'::text) AND (old.project_id IS NOT NULL))) EXECUTE PROCEDURE set_has_external_wiki(); + +CREATE TRIGGER trigger_has_external_wiki_on_insert AFTER INSERT ON services FOR EACH ROW WHEN (((new.active = true) AND ((new.type)::text = 'ExternalWikiService'::text) AND (new.project_id IS NOT NULL))) EXECUTE PROCEDURE set_has_external_wiki(); + +CREATE TRIGGER trigger_has_external_wiki_on_update AFTER UPDATE ON services FOR EACH ROW WHEN ((((new.type)::text = 'ExternalWikiService'::text) AND (old.active <> new.active) AND (new.project_id IS NOT NULL))) EXECUTE PROCEDURE set_has_external_wiki(); + ALTER TABLE ONLY chat_names ADD CONSTRAINT fk_00797a2bf9 FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE; diff --git a/doc/administration/raketasks/github_import.md b/doc/administration/raketasks/github_import.md index 5c0bc721a9a..630570cb81f 100644 --- a/doc/administration/raketasks/github_import.md +++ b/doc/administration/raketasks/github_import.md @@ -19,7 +19,7 @@ before/after the brackets. Also, some shells (for example, `zsh`) can interpret ## Caveats -If the GitHub [rate limit](https://developer.github.com/v3/#rate-limiting) is reached while importing, +If the GitHub [rate limit](https://docs.github.com/v3/#rate-limiting) is reached while importing, the importing process waits (`sleep()`) until it can continue importing. ## Importing multiple projects diff --git a/doc/administration/reference_architectures/index.md b/doc/administration/reference_architectures/index.md index 221ee430a51..b90b8d67b68 100644 --- a/doc/administration/reference_architectures/index.md +++ b/doc/administration/reference_architectures/index.md @@ -121,6 +121,9 @@ to the default installation: - Enable zero-downtime upgrades. - Increase availability. +For more details on how to configure a traffic load balancer with GitLab, you can refer +to any of the [available reference architectures](#available-reference-architectures) with more than 1,000 users. + ### Zero downtime updates **(STARTER ONLY)** > - Level of complexity: **Medium** diff --git a/doc/administration/troubleshooting/postgresql.md b/doc/administration/troubleshooting/postgresql.md index 7052b68370c..4ccae10e5b3 100644 --- a/doc/administration/troubleshooting/postgresql.md +++ b/doc/administration/troubleshooting/postgresql.md @@ -157,7 +157,7 @@ See current settings with: ```shell sudo gitlab-rails runner "c = ApplicationRecord.connection ; puts c.execute('SHOW statement_timeout').to_a ; -puts c.execute('SHOW lock_timeout').to_a ; +puts c.execute('SHOW deadlock_timeout').to_a ; puts c.execute('SHOW idle_in_transaction_session_timeout').to_a ;" ``` @@ -165,9 +165,19 @@ It may take a little while to respond. ```ruby {"statement_timeout"=>"1min"} -{"lock_timeout"=>"0"} +{"deadlock_timeout"=>"0"} {"idle_in_transaction_session_timeout"=>"1min"} ``` +These settings can be updated in `/etc/gitlab/gitlab.rb` with: + +```ruby +postgresql['deadlock_timeout'] = '5s' +postgresql['statement_timeout'] = '15s' +postgresql['idle_in_transaction_session_timeout'] = '60s' +``` + +Once saved, [reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure) for the changes to take effect. + NOTE: These are Omnibus GitLab settings. If an external database, such as a customer's PostgreSQL installation or Amazon RDS is being used, these values don't get set, and would have to be set externally. diff --git a/doc/administration/troubleshooting/tracing_correlation_id.md b/doc/administration/troubleshooting/tracing_correlation_id.md index 2981b9e1368..ad2b8586b8b 100644 --- a/doc/administration/troubleshooting/tracing_correlation_id.md +++ b/doc/administration/troubleshooting/tracing_correlation_id.md @@ -29,7 +29,7 @@ documentation for some popular browsers. - [Network Monitor - Firefox Developer Tools](https://developer.mozilla.org/en-US/docs/Tools/Network_Monitor) - [Inspect Network Activity In Chrome DevTools](https://developers.google.com/web/tools/chrome-devtools/network/) - [Safari Web Development Tools](https://developer.apple.com/safari/tools/) -- [Microsoft Edge Network panel](https://docs.microsoft.com/en-us/microsoft-edge/devtools-guide/network#request-details) +- [Microsoft Edge Network panel](https://docs.microsoft.com/en-us/microsoft-edge/devtools-guide-chromium/network/) To locate a relevant request and view its correlation ID: diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 5f71ec4cec6..368bedd1fca 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -600,7 +600,7 @@ Filters the alerts based on given domain """ enum AlertManagementDomainFilter { """ - Alerts for operations domain + Alerts for operations domain """ operations @@ -13837,6 +13837,11 @@ type MergeRequest implements CurrentUserTodos & Noteable { autoMergeEnabled: Boolean! """ + Selected auto merge strategy + """ + autoMergeStrategy: String + + """ Array of available auto merge strategies """ availableAutoMergeStrategies: [String!] @@ -14076,6 +14081,11 @@ type MergeRequest implements CurrentUserTodos & Noteable { mergeTrainsCount: Int """ + User who merged this merge request + """ + mergeUser: User + + """ Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS) """ mergeWhenPipelineSucceeds: Boolean diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 1c479311d5e..6b9f80abee3 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -1500,7 +1500,7 @@ "enumValues": [ { "name": "operations", - "description": "Alerts for operations domain ", + "description": "Alerts for operations domain", "isDeprecated": false, "deprecationReason": null }, @@ -37995,6 +37995,20 @@ "deprecationReason": null }, { + "name": "autoMergeStrategy", + "description": "Selected auto merge strategy", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "availableAutoMergeStrategies", "description": "Array of available auto merge strategies", "args": [ @@ -38646,6 +38660,20 @@ "deprecationReason": null }, { + "name": "mergeUser", + "description": "User who merged this merge request", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "User", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "mergeWhenPipelineSucceeds", "description": "Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS)", "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 74ca092295a..79888ca5792 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2095,6 +2095,7 @@ Autogenerated return type of MarkAsSpamSnippet. | `assignees` | UserConnection | Assignees of the merge request | | `author` | User | User who created this merge request | | `autoMergeEnabled` | Boolean! | Indicates if auto merge is enabled for the merge request | +| `autoMergeStrategy` | String | Selected auto merge strategy | | `availableAutoMergeStrategies` | String! => Array | Array of available auto merge strategies | | `commitCount` | Int | Number of commits in the merge request | | `commitsWithoutMergeCommits` | CommitConnection | Merge request commits excluding merge commits | @@ -2125,6 +2126,7 @@ Autogenerated return type of MarkAsSpamSnippet. | `mergeOngoing` | Boolean! | Indicates if a merge is currently occurring | | `mergeStatus` | String | Status of the merge request | | `mergeTrainsCount` | Int | | +| `mergeUser` | User | User who merged this merge request | | `mergeWhenPipelineSucceeds` | Boolean | Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS) | | `mergeable` | Boolean! | Indicates if the merge request is mergeable | | `mergeableDiscussionsState` | Boolean | Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged | @@ -4162,7 +4164,7 @@ Filters the alerts based on given domain. | Value | Description | | ----- | ----------- | -| `operations` | Alerts for operations domain | +| `operations` | Alerts for operations domain | | `threat_monitoring` | Alerts for threat monitoring domain | ### AlertManagementIntegrationType diff --git a/doc/api/group_boards.md b/doc/api/group_boards.md index f982dad7962..722f3a76267 100644 --- a/doc/api/group_boards.md +++ b/doc/api/group_boards.md @@ -279,7 +279,7 @@ Example response: } ``` -## Update a group issue board **(PREMIUM)** +## Update a group issue board > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/5954) in GitLab 11.1. @@ -289,15 +289,17 @@ Updates a Group Issue Board. PUT /groups/:id/boards/:board_id ``` -| Attribute | Type | Required | Description | -| ------------------- | -------------- | -------- | ----------- | -| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | -| `board_id` | integer | yes | The ID of a board | -| `name` | string | no | The new name of the board | -| `assignee_id` | integer | no | The assignee the board should be scoped to | -| `milestone_id` | integer | no | The milestone the board should be scoped to | -| `labels` | string | no | Comma-separated list of label names which the board should be scoped to | -| `weight` | integer | no | The weight range from 0 to 9, to which the board should be scoped to | +| Attribute | Type | Required | Description | +| ---------------------------- | -------------- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `board_id` | integer | yes | The ID of a board | +| `name` | string | no | The new name of the board | +| `hide_backlog_list` | boolean | no | Hide the Open list | +| `hide_closed_list` | boolean | no | Hide the Closed list | +| `assignee_id` **(PREMIUM)** | integer | no | The assignee the board should be scoped to | +| `milestone_id` **(PREMIUM)** | integer | no | The milestone the board should be scoped to | +| `labels` **(PREMIUM)** | string | no | Comma-separated list of label names which the board should be scoped to | +| `weight` **(PREMIUM)** | integer | no | The weight range from 0 to 9, to which the board should be scoped to | ```shell curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/boards/1?name=new_name&milestone_id=44&assignee_id=1&labels=GroupLabel&weight=4" diff --git a/lib/api/boards.rb b/lib/api/boards.rb index e2d30dd7c2b..5fd4ca3546c 100644 --- a/lib/api/boards.rb +++ b/lib/api/boards.rb @@ -7,10 +7,10 @@ module API prepend_if_ee('EE::API::BoardsResponses') # rubocop: disable Cop/InjectEnterpriseEditionModule - before { authenticate! } - feature_category :boards + before { authenticate! } + helpers do def board_parent user_project diff --git a/lib/api/boards_responses.rb b/lib/api/boards_responses.rb index 89355c84401..5a30de1f766 100644 --- a/lib/api/boards_responses.rb +++ b/lib/api/boards_responses.rb @@ -80,10 +80,20 @@ module API requires :label_id, type: Integer, desc: 'The ID of an existing label' end - params :update_params do + params :update_params_ce do + optional :name, type: String, desc: 'The board name' + optional :hide_backlog_list, type: Grape::API::Boolean, desc: 'Hide the Open list' + optional :hide_closed_list, type: Grape::API::Boolean, desc: 'Hide the Closed list' + end + + params :update_params_ee do # Configurable issue boards are not available in CE/EE Core. # https://docs.gitlab.com/ee/user/project/issue_board.html#configurable-issue-boards - optional :name, type: String, desc: 'The board name' + end + + params :update_params do + use :update_params_ce + use :update_params_ee end end end diff --git a/lib/api/entities/board.rb b/lib/api/entities/board.rb index b7a50408313..fe0182ad772 100644 --- a/lib/api/entities/board.rb +++ b/lib/api/entities/board.rb @@ -5,6 +5,8 @@ module API class Board < Grape::Entity expose :id expose :name + expose :hide_backlog_list + expose :hide_closed_list expose :project, using: Entities::BasicProjectDetails expose :lists, using: Entities::List do |board| diff --git a/lib/api/group_boards.rb b/lib/api/group_boards.rb index 2bfd98a5b69..7425e1bd145 100644 --- a/lib/api/group_boards.rb +++ b/lib/api/group_boards.rb @@ -9,9 +9,7 @@ module API feature_category :boards - before do - authenticate! - end + before { authenticate! } helpers do def board_parent @@ -22,28 +20,40 @@ module API params do requires :id, type: String, desc: 'The ID of a group' end - resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do segment ':id/boards' do + desc 'Get all group boards' do + detail 'This feature was introduced in 10.6' + success Entities::Board + end + params do + use :pagination + end + get '/' do + authorize!(:read_board, user_group) + present paginate(board_parent.boards.with_associations), with: Entities::Board + end + desc 'Find a group board' do detail 'This feature was introduced in 10.6' - success ::API::Entities::Board + success Entities::Board end get '/:board_id' do authorize!(:read_board, user_group) - present board, with: ::API::Entities::Board + present board, with: Entities::Board end - desc 'Get all group boards' do - detail 'This feature was introduced in 10.6' + desc 'Update a group board' do + detail 'This feature was introduced in 11.0' success Entities::Board end params do - use :pagination + use :update_params end - get '/' do - authorize!(:read_board, user_group) - present paginate(board_parent.boards.with_associations), with: Entities::Board + put '/:board_id' do + authorize!(:admin_board, board_parent) + + update_board end end diff --git a/lib/api/maven_packages.rb b/lib/api/maven_packages.rb index 7b4e52d18e8..4a5b2ead163 100644 --- a/lib/api/maven_packages.rb +++ b/lib/api/maven_packages.rb @@ -220,9 +220,13 @@ module API file_name, format = extract_format(params[:file_name]) - package = ::Packages::Maven::FindOrCreatePackageService + result = ::Packages::Maven::FindOrCreatePackageService .new(user_project, current_user, params.merge(build: current_authenticated_job)).execute + bad_request!(result.errors.first) if result.error? + + package = result.payload[:package] + case format when 'sha1' # After uploading a file, Maven tries to upload a sha1 and md5 version of it. diff --git a/lib/bulk_imports/importers/group_importer.rb b/lib/bulk_imports/importers/group_importer.rb index 3dbcba18c94..6e1b86e9515 100644 --- a/lib/bulk_imports/importers/group_importer.rb +++ b/lib/bulk_imports/importers/group_importer.rb @@ -8,7 +8,6 @@ module BulkImports end def execute - entity.start! bulk_import = entity.bulk_import configuration = bulk_import.configuration diff --git a/lib/bulk_imports/importers/groups_importer.rb b/lib/bulk_imports/importers/groups_importer.rb deleted file mode 100644 index 8641577ff47..00000000000 --- a/lib/bulk_imports/importers/groups_importer.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module BulkImports - module Importers - class GroupsImporter - def initialize(bulk_import_id) - @bulk_import = BulkImport.find(bulk_import_id) - end - - def execute - bulk_import.start! unless bulk_import.started? - - if entities_to_import.empty? - bulk_import.finish! - else - entities_to_import.each do |entity| - BulkImports::Importers::GroupImporter.new(entity).execute - end - - # A new BulkImportWorker job is enqueued to either - # - Process the new BulkImports::Entity created for the subgroups - # - Or to mark the `bulk_import` as finished. - BulkImportWorker.perform_async(bulk_import.id) - end - end - - private - - attr_reader :bulk_import - - def entities_to_import - @entities_to_import ||= bulk_import.entities.with_status(:created) - end - end - end -end diff --git a/lib/gitlab/ci/templates/Security/Secret-Detection.gitlab-ci.yml b/lib/gitlab/ci/templates/Security/Secret-Detection.gitlab-ci.yml index 8ca1d2e08ba..b4604afb213 100644 --- a/lib/gitlab/ci/templates/Security/Secret-Detection.gitlab-ci.yml +++ b/lib/gitlab/ci/templates/Security/Secret-Detection.gitlab-ci.yml @@ -37,6 +37,7 @@ secret_detection: when: never - if: $CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH script: + - if [[ $CI_COMMIT_TAG ]]; echo "Skipping Secret Detection for tags. No code changes have occurred."; then exit 0; fi - git fetch origin $CI_DEFAULT_BRANCH $CI_COMMIT_REF_NAME - git log --left-right --cherry-pick --pretty=format:"%H" refs/remotes/origin/$CI_DEFAULT_BRANCH...refs/remotes/origin/$CI_COMMIT_REF_NAME > "$CI_COMMIT_SHA"_commit_list.txt - export SECRET_DETECTION_COMMITS_FILE="$CI_COMMIT_SHA"_commit_list.txt diff --git a/lib/gitlab/database/reindexing/grafana_notifier.rb b/lib/gitlab/database/reindexing/grafana_notifier.rb index 3635dffa8d0..b1e5ecb9ade 100644 --- a/lib/gitlab/database/reindexing/grafana_notifier.rb +++ b/lib/gitlab/database/reindexing/grafana_notifier.rb @@ -5,9 +5,10 @@ module Gitlab module Reindexing # This can be used to send annotations for reindexing to a Grafana API class GrafanaNotifier - def initialize(api_key = ENV['GITLAB_GRAFANA_API_KEY'], api_url = ENV['GITLAB_GRAFANA_API_URL']) + def initialize(api_key = ENV['GITLAB_GRAFANA_API_KEY'], api_url = ENV['GITLAB_GRAFANA_API_URL'], additional_tag = ENV['GITLAB_REINDEXING_GRAFANA_TAG'] || Rails.env) @api_key = api_key @api_url = api_url + @additional_tag = additional_tag end def notify_start(action) @@ -37,7 +38,7 @@ module Gitlab def base_payload(action) { time: (action.action_start.utc.to_f * 1000).to_i, - tags: ['reindex', action.index.tablename, action.index.name] + tags: ['reindex', @additional_tag, action.index.tablename, action.index.name].compact } end diff --git a/lib/release_highlights/validator.rb b/lib/release_highlights/validator.rb new file mode 100644 index 00000000000..6f3f90b5f30 --- /dev/null +++ b/lib/release_highlights/validator.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module ReleaseHighlights + class Validator + attr_reader :errors, :file + + def initialize(file:) + @file = file + @errors = [] + end + + def valid? + document = YAML.parse(File.read(file)) + + document.root.children.each do |entry| + entry = ReleaseHighlights::Validator::Entry.new(entry) + + errors.push(entry.errors.full_messages) unless entry.valid? + end + + errors.none? + end + + def self.validate_all! + @all_errors = [] + + ReleaseHighlight.file_paths.each do |file_path| + instance = self.new(file: file_path) + + @all_errors.push([instance.errors, instance.file]) unless instance.valid? + end + + @all_errors.none? + end + + def self.error_message + io = StringIO.new + + @all_errors.each do |errors, file| + message = "Validation failed for #{file}" + line = -> { io.puts "-" * message.length } + + line.call + io.puts message + line.call + + errors.flatten.each { |error| io.puts "* #{error}" } + io.puts + end + + io.string + end + end +end diff --git a/lib/release_highlights/validator/entry.rb b/lib/release_highlights/validator/entry.rb new file mode 100644 index 00000000000..0dbe0cdf882 --- /dev/null +++ b/lib/release_highlights/validator/entry.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module ReleaseHighlights + class Validator::Entry + include ActiveModel::Validations + include ActiveModel::Validations::Callbacks + + PACKAGES = %w(Core Starter Premium Ultimate).freeze + + attr_reader :entry + + validates :title, :body, :stage, presence: true + validates :'self-managed', :'gitlab-com', inclusion: { in: [true, false], message: "must be a boolean" } + validates :url, :image_url, format: { with: URI::DEFAULT_PARSER.make_regexp, message: 'must be a URL' } + validates :release, numericality: true + validate :validate_published_at + validate :validate_packages + + after_validation :add_line_numbers_to_errors! + + def initialize(entry) + @entry = entry + end + + def validate_published_at + published_at = value_for('published_at') + + return if published_at.is_a?(Date) + + errors.add(:published_at, 'must be valid Date') + end + + def validate_packages + packages = value_for('packages') + + if !packages.is_a?(Array) || packages.empty? || packages.any? { |p| PACKAGES.exclude?(p) } + errors.add(:packages, "must be one of #{PACKAGES}") + end + end + + def read_attribute_for_validation(key) + value_for(key) + end + + private + + def add_line_numbers_to_errors! + errors.messages.each do |attribute, messages| + messages.map! { |m| "#{m} (line #{line_number_for(attribute)})" } + end + end + + def line_number_for(key) + node = find_node(key) + + (node&.start_line || @entry.start_line) + 1 + end + + def value_for(key) + node = find_node(key) + + return if node.nil? + + index = entry.children.find_index(node) + + next_node = entry.children[index + 1] + next_node&.to_ruby + end + + def find_node(key) + entry.children.find {|node| node.try(:value) == key.to_s } + end + end +end diff --git a/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb b/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb index 4504deae488..6308acb41f5 100644 --- a/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb +++ b/spec/features/projects/files/gitlab_ci_syntax_yml_dropdown_spec.rb @@ -34,7 +34,8 @@ RSpec.describe 'Projects > Files > User wants to add a .gitlab-ci.yml file' do let(:experiment_active) { true } let(:in_experiment_group) { true } - it 'allows the user to pick a "Learn CI/CD syntax" template from the dropdown', :js do + it 'allows the user to pick a "Learn CI/CD syntax" template from the dropdown', :js, + { quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297347' } } do expect(page).to have_css('.gitlab-ci-syntax-yml-selector') find('.js-gitlab-ci-syntax-yml-selector').click diff --git a/spec/fixtures/whats_new/blank.yml b/spec/fixtures/whats_new/blank.yml new file mode 100644 index 00000000000..4628cae2ecc --- /dev/null +++ b/spec/fixtures/whats_new/blank.yml @@ -0,0 +1,9 @@ +- title: + body: + stage: + self-managed: + gitlab-com: + url: + image_url: + published_at: + release: diff --git a/spec/fixtures/whats_new/invalid.yml b/spec/fixtures/whats_new/invalid.yml new file mode 100644 index 00000000000..0e588efaf8f --- /dev/null +++ b/spec/fixtures/whats_new/invalid.yml @@ -0,0 +1,20 @@ +- title: Create and view requirements in GitLab + body: The first step towards managing requirements from within GitLab is here! This initial release allows users to create and view requirements at a project level. As Requirements Management evolves in GitLab, stay tuned for support for traceability between all artifacts, creating a seamless workflow to visually demonstrate completeness and compliance. + stage: Plan + self-managed: true + gitlab-com: true + packages: [ALL] + url: https://docs.gitlab.com/ee/user/project/requirements/index.html + image_url: https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png + published_at: 2020-04-22 + release: 12.10 +- title: Retrieve CI/CD secrets from HashiCorp Vault + body: In this release, GitLab adds support for lightweight JSON Web Token (JWT) authentication to integrate with your existing HashiCorp Vault. Now, you can seamlessly provide secrets to CI/CD jobs by taking advantage of HashiCorp's JWT authentication method rather than manually having to provide secrets as a variable in GitLab. + stage: Release + self-managed: true + gitlab-com: true + packages: [Starter] + url: https://docs.gitlab.com/ee/ci/examples/authenticating-with-hashicorp-vault/index.html + image_url: https://about.gitlab.com/images/12_10/jwt-vault-1.png + published_at: 2020-04-22 + release: 12.10 diff --git a/spec/fixtures/whats_new/valid.yml b/spec/fixtures/whats_new/valid.yml new file mode 100644 index 00000000000..cbe9d666357 --- /dev/null +++ b/spec/fixtures/whats_new/valid.yml @@ -0,0 +1,20 @@ +- title: Create and view requirements in GitLab + body: The first step towards managing requirements from within GitLab is here! This initial release allows users to create and view requirements at a project level. As Requirements Management evolves in GitLab, stay tuned for support for traceability between all artifacts, creating a seamless workflow to visually demonstrate completeness and compliance. + stage: Plan + self-managed: true + gitlab-com: true + packages: [Ultimate] + url: https://docs.gitlab.com/ee/user/project/requirements/index.html + image_url: https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png + published_at: 2020-04-22 + release: 12.10 +- title: Retrieve CI/CD secrets from HashiCorp Vault + body: In this release, GitLab adds support for lightweight JSON Web Token (JWT) authentication to integrate with your existing HashiCorp Vault. Now, you can seamlessly provide secrets to CI/CD jobs by taking advantage of HashiCorp's JWT authentication method rather than manually having to provide secrets as a variable in GitLab. + stage: Release + self-managed: true + gitlab-com: true + packages: [Starter] + url: https://docs.gitlab.com/ee/ci/examples/authenticating-with-hashicorp-vault/index.html + image_url: https://about.gitlab.com/images/12_10/jwt-vault-1.png + published_at: 2020-04-22 + release: 12.10 diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js index 6f59803d78f..00db3553ea5 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -198,21 +198,6 @@ describe('~/pipeline_editor/pipeline_editor_app.vue', () => { expect(findLoadingIcon().exists()).toBe(true); expect(findPipelineGraph().exists()).toBe(false); }); - - it('displays the graph only after the tab is mounted and selected', async () => { - createComponent({ mountFn: mount }); - - expect(findTabAt(1).find(PipelineGraph).exists()).toBe(false); - - await nextTick(); - - // Select visualization tab - wrapper.find('[data-testid="visualization-tab-btn"]').trigger('click'); - - await nextTick(); - - expect(findTabAt(1).find(PipelineGraph).exists()).toBe(true); - }); }); describe('with feature flag off', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap new file mode 100644 index 00000000000..c425a3a86a9 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap @@ -0,0 +1,183 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MRWidgetAutoMergeEnabled when graphql is disabled template should have correct elements 1`] = ` +<div + class="mr-widget-body media" +> + <status-icon-stub + status="success" + /> + + <div + class="media-body" + > + <h4 + class="gl-display-flex" + > + <span + class="gl-mr-3" + > + <span + class="js-status-text-before-author" + data-testid="beforeStatusText" + > + Set by + </span> + + <mr-widget-author-stub + author="[object Object]" + showauthorname="true" + /> + + <span + class="js-status-text-after-author" + data-testid="afterStatusText" + > + to be merged automatically when the pipeline succeeds + </span> + </span> + + <a + class="btn btn-sm btn-default js-cancel-auto-merge" + data-testid="cancelAutomaticMergeButton" + href="#" + role="button" + > + <!----> + + Cancel automatic merge + + </a> + </h4> + + <section + class="mr-info-list" + > + <p> + + The changes will be merged into + + <a + class="label-branch" + href="/foo/bar" + > + foo + </a> + </p> + + <p + class="gl-display-flex" + > + <span + class="gl-mr-3" + > + The source branch will not be deleted + </span> + + <a + class="btn btn-sm btn-default js-remove-source-branch" + data-testid="removeSourceBranchButton" + href="#" + role="button" + > + <!----> + + Delete source branch + + </a> + </p> + </section> + </div> +</div> +`; + +exports[`MRWidgetAutoMergeEnabled when graphql is enabled template should have correct elements 1`] = ` +<div + class="mr-widget-body media" +> + <status-icon-stub + status="success" + /> + + <div + class="media-body" + > + <h4 + class="gl-display-flex" + > + <span + class="gl-mr-3" + > + <span + class="js-status-text-before-author" + data-testid="beforeStatusText" + > + Set by + </span> + + <mr-widget-author-stub + author="[object Object]" + showauthorname="true" + /> + + <span + class="js-status-text-after-author" + data-testid="afterStatusText" + > + to be merged automatically when the pipeline succeeds + </span> + </span> + + <a + class="btn btn-sm btn-default js-cancel-auto-merge" + data-testid="cancelAutomaticMergeButton" + href="#" + role="button" + > + <!----> + + Cancel automatic merge + + </a> + </h4> + + <section + class="mr-info-list" + > + <p> + + The changes will be merged into + + <a + class="label-branch" + href="/foo/bar" + > + foo + </a> + </p> + + <p + class="gl-display-flex" + > + <span + class="gl-mr-3" + > + The source branch will not be deleted + </span> + + <a + class="btn btn-sm btn-default js-remove-source-branch" + data-testid="removeSourceBranchButton" + href="#" + role="button" + > + <!----> + + Delete source branch + + </a> + </p> + </section> + </div> +</div> +`; diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js index 608c6363799..7f57a6ed8fa 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_enabled_spec.js @@ -1,20 +1,81 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; +import { extendedWrapper } from 'jest/helpers/vue_test_utils_helper'; import autoMergeEnabledComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue'; import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; import eventHub from '~/vue_merge_request_widget/event_hub'; import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants'; +let wrapper; +let mergeRequestWidgetGraphqlEnabled = false; + +function convertPropsToGraphqlState(props) { + return { + autoMergeStrategy: props.autoMergeStrategy, + cancelAutoMergePath: 'http://text.com', + mergeUser: { + id: props.mergeUserId, + ...props.setToAutoMergeBy, + }, + targetBranch: props.targetBranch, + targetBranchCommitsPath: props.targetBranchPath, + shouldRemoveSourceBranch: props.shouldRemoveSourceBranch, + forceRemoveSourceBranch: props.shouldRemoveSourceBranch, + userPermissions: { + removeSourceBranch: props.canRemoveSourceBranch, + }, + }; +} + +function factory(propsData) { + let state = {}; + + if (mergeRequestWidgetGraphqlEnabled) { + state = convertPropsToGraphqlState(propsData); + } + + wrapper = extendedWrapper( + shallowMount(autoMergeEnabledComponent, { + propsData: { + mr: propsData, + service: new MRWidgetService({}), + }, + data() { + return { state }; + }, + provide: { glFeatures: { mergeRequestWidgetGraphql: mergeRequestWidgetGraphqlEnabled } }, + mocks: { + $apollo: { + queries: { + state: { loading: false }, + }, + }, + }, + }), + ); +} + +const targetBranchPath = '/foo/bar'; +const targetBranch = 'foo'; +const sha = '1EA2EZ34'; +const defaultMrProps = () => ({ + shouldRemoveSourceBranch: false, + canRemoveSourceBranch: true, + canCancelAutomaticMerge: true, + mergeUserId: 1, + currentUserId: 1, + setToAutoMergeBy: {}, + sha, + targetBranchPath, + targetBranch, + autoMergeStrategy: MWPS_MERGE_STRATEGY, +}); + describe('MRWidgetAutoMergeEnabled', () => { - let vm; let oldWindowGl; - const targetBranchPath = '/foo/bar'; - const targetBranch = 'foo'; - const sha = '1EA2EZ34'; beforeEach(() => { - const Component = Vue.extend(autoMergeEnabledComponent); jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); oldWindowGl = window.gl; @@ -23,216 +84,234 @@ describe('MRWidgetAutoMergeEnabled', () => { defaultAvatarUrl: 'no_avatar.png', }, }; - - vm = mountComponent(Component, { - mr: { - shouldRemoveSourceBranch: false, - canRemoveSourceBranch: true, - canCancelAutomaticMerge: true, - mergeUserId: 1, - currentUserId: 1, - setToAutoMergeBy: {}, - sha, - targetBranchPath, - targetBranch, - autoMergeStrategy: MWPS_MERGE_STRATEGY, - }, - service: new MRWidgetService({}), - }); }); afterEach(() => { - vm.$destroy(); window.gl = oldWindowGl; + wrapper.destroy(); + wrapper = null; }); - describe('computed', () => { - describe('canRemoveSourceBranch', () => { - it('should return true when user is able to remove source branch', () => { - expect(vm.canRemoveSourceBranch).toBeTruthy(); + [true, false].forEach((mergeRequestWidgetGraphql) => { + describe(`when graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'disabled'}`, () => { + beforeEach(() => { + mergeRequestWidgetGraphqlEnabled = mergeRequestWidgetGraphql; }); - it('should return false when user id is not the same with who set the MWPS', () => { - vm.mr.mergeUserId = 2; - - expect(vm.canRemoveSourceBranch).toBeFalsy(); - - vm.mr.currentUserId = 2; - - expect(vm.canRemoveSourceBranch).toBeTruthy(); + describe('computed', () => { + describe('canRemoveSourceBranch', () => { + it('should return true when user is able to remove source branch', () => { + factory({ + ...defaultMrProps(), + }); - vm.mr.currentUserId = 3; + expect(wrapper.findByTestId('removeSourceBranchButton').exists()).toBe(true); + }); - expect(vm.canRemoveSourceBranch).toBeFalsy(); - }); + it.each` + mergeUserId | currentUserId + ${2} | ${1} + ${1} | ${2} + `( + 'should return false when user id is not the same with who set the MWPS', + ({ mergeUserId, currentUserId }) => { + factory({ + ...defaultMrProps(), + mergeUserId, + currentUserId, + }); + + expect(wrapper.findByTestId('removeSourceBranchButton').exists()).toBe(false); + }, + ); - it('should return false when shouldRemoveSourceBranch set to false', () => { - vm.mr.shouldRemoveSourceBranch = true; + it('should return false when shouldRemoveSourceBranch set to false', () => { + factory({ + ...defaultMrProps(), + shouldRemoveSourceBranch: true, + }); - expect(vm.canRemoveSourceBranch).toBeFalsy(); - }); + expect(wrapper.findByTestId('removeSourceBranchButton').exists()).toBe(false); + }); - it('should return false if user is not able to remove the source branch', () => { - vm.mr.canRemoveSourceBranch = false; + it('should return false if user is not able to remove the source branch', () => { + factory({ + ...defaultMrProps(), + canRemoveSourceBranch: false, + }); - expect(vm.canRemoveSourceBranch).toBeFalsy(); - }); - }); + expect(wrapper.findByTestId('removeSourceBranchButton').exists()).toBe(false); + }); + }); - describe('statusTextBeforeAuthor', () => { - it('should return "Set by" if the MWPS is selected', () => { - Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY); + describe('statusTextBeforeAuthor', () => { + it('should return "Set by" if the MWPS is selected', () => { + factory({ + ...defaultMrProps(), + autoMergeStrategy: MWPS_MERGE_STRATEGY, + }); - expect(vm.statusTextBeforeAuthor).toBe('Set by'); - }); - }); + expect(wrapper.findByTestId('beforeStatusText').text()).toBe('Set by'); + }); + }); - describe('statusTextAfterAuthor', () => { - it('should return "to be merged automatically..." if MWPS is selected', () => { - Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY); + describe('statusTextAfterAuthor', () => { + it('should return "to be merged automatically..." if MWPS is selected', () => { + factory({ + ...defaultMrProps(), + autoMergeStrategy: MWPS_MERGE_STRATEGY, + }); - expect(vm.statusTextAfterAuthor).toBe( - 'to be merged automatically when the pipeline succeeds', - ); - }); - }); + expect(wrapper.findByTestId('afterStatusText').text()).toBe( + 'to be merged automatically when the pipeline succeeds', + ); + }); + }); - describe('cancelButtonText', () => { - it('should return "Cancel automatic merge" if MWPS is selected', () => { - Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY); + describe('cancelButtonText', () => { + it('should return "Cancel automatic merge" if MWPS is selected', () => { + factory({ + ...defaultMrProps(), + autoMergeStrategy: MWPS_MERGE_STRATEGY, + }); - expect(vm.cancelButtonText).toBe('Cancel automatic merge'); + expect(wrapper.findByTestId('cancelAutomaticMergeButton').text()).toBe( + 'Cancel automatic merge', + ); + }); + }); }); - }); - }); - describe('methods', () => { - describe('cancelAutomaticMerge', () => { - it('should set flag and call service then tell main component to update the widget with data', (done) => { - const mrObj = { - is_new_mr_data: true, - }; - jest.spyOn(vm.service, 'cancelAutomaticMerge').mockReturnValue( - new Promise((resolve) => { - resolve({ - data: mrObj, + describe('methods', () => { + describe('cancelAutomaticMerge', () => { + it('should set flag and call service then tell main component to update the widget with data', (done) => { + factory({ + ...defaultMrProps(), }); - }), - ); - - vm.cancelAutomaticMerge(); - setImmediate(() => { - expect(vm.isCancellingAutoMerge).toBeTruthy(); - expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); - done(); + const mrObj = { + is_new_mr_data: true, + }; + jest.spyOn(wrapper.vm.service, 'cancelAutomaticMerge').mockReturnValue( + new Promise((resolve) => { + resolve({ + data: mrObj, + }); + }), + ); + + wrapper.vm.cancelAutomaticMerge(); + setImmediate(() => { + expect(wrapper.vm.isCancellingAutoMerge).toBeTruthy(); + expect(eventHub.$emit).toHaveBeenCalledWith('UpdateWidgetData', mrObj); + done(); + }); + }); }); - }); - }); - describe('removeSourceBranch', () => { - it('should set flag and call service then request main component to update the widget', (done) => { - jest.spyOn(vm.service, 'merge').mockReturnValue( - Promise.resolve({ - data: { - status: MWPS_MERGE_STRATEGY, - }, - }), - ); - - vm.removeSourceBranch(); - setImmediate(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); - expect(vm.service.merge).toHaveBeenCalledWith({ - sha, - auto_merge_strategy: MWPS_MERGE_STRATEGY, - should_remove_source_branch: true, - }); - done(); + describe('removeSourceBranch', () => { + it('should set flag and call service then request main component to update the widget', (done) => { + factory({ + ...defaultMrProps(), + }); + jest.spyOn(wrapper.vm.service, 'merge').mockReturnValue( + Promise.resolve({ + data: { + status: MWPS_MERGE_STRATEGY, + }, + }), + ); + + wrapper.vm.removeSourceBranch(); + setImmediate(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + expect(wrapper.vm.service.merge).toHaveBeenCalledWith({ + sha, + auto_merge_strategy: MWPS_MERGE_STRATEGY, + should_remove_source_branch: true, + }); + done(); + }); + }); }); }); - }); - }); - describe('template', () => { - it('should have correct elements', () => { - expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(vm.$el.innerText).toContain('to be merged automatically when the pipeline succeeds'); + describe('template', () => { + it('should have correct elements', () => { + factory({ + ...defaultMrProps(), + }); - expect(vm.$el.innerText).toContain('The changes will be merged into'); - expect(vm.$el.innerText).toContain(targetBranch); - expect(vm.$el.innerText).toContain('The source branch will not be deleted'); - expect(vm.$el.querySelector('.js-cancel-auto-merge').innerText).toContain( - 'Cancel automatic merge', - ); + expect(wrapper.element).toMatchSnapshot(); + }); - expect(vm.$el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeFalsy(); - expect(vm.$el.querySelector('.js-remove-source-branch').innerText).toContain( - 'Delete source branch', - ); + it('should disable cancel auto merge button when the action is in progress', async () => { + factory({ + ...defaultMrProps(), + }); + wrapper.setData({ + isCancellingAutoMerge: true, + }); - expect(vm.$el.querySelector('.js-remove-source-branch').getAttribute('disabled')).toBeFalsy(); - }); + await nextTick(); - it('should disable cancel auto merge button when the action is in progress', (done) => { - vm.isCancellingAutoMerge = true; + expect(wrapper.find('.js-cancel-auto-merge').attributes('disabled')).toBe('disabled'); + }); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.js-cancel-auto-merge').getAttribute('disabled')).toBeTruthy(); - done(); - }); - }); + it('should show source branch will be deleted text when it source branch set to remove', () => { + factory({ + ...defaultMrProps(), + shouldRemoveSourceBranch: true, + }); - it('should show source branch will be deleted text when it source branch set to remove', (done) => { - vm.mr.shouldRemoveSourceBranch = true; + const normalizedText = wrapper.text().replace(/\s+/g, ' '); - Vue.nextTick(() => { - const normalizedText = vm.$el.innerText.replace(/\s+/g, ' '); + expect(normalizedText).toContain('The source branch will be deleted'); + expect(normalizedText).not.toContain('The source branch will not be deleted'); + }); - expect(normalizedText).toContain('The source branch will be deleted'); - expect(normalizedText).not.toContain('The source branch will not be deleted'); - done(); - }); - }); + it('should not show delete source branch button when user not able to delete source branch', () => { + factory({ + ...defaultMrProps(), + currentUserId: 4, + }); - it('should not show delete source branch button when user not able to delete source branch', (done) => { - vm.mr.currentUserId = 4; + expect(wrapper.find('.js-remove-source-branch').exists()).toBe(false); + }); - Vue.nextTick(() => { - expect(vm.$el.querySelector('.js-remove-source-branch')).toEqual(null); - done(); - }); - }); + it('should disable delete source branch button when the action is in progress', async () => { + factory({ + ...defaultMrProps(), + }); + wrapper.setData({ + isRemovingSourceBranch: true, + }); - it('should disable delete source branch button when the action is in progress', (done) => { - vm.isRemovingSourceBranch = true; + await nextTick(); - Vue.nextTick(() => { - expect( - vm.$el.querySelector('.js-remove-source-branch').getAttribute('disabled'), - ).toBeTruthy(); - done(); - }); - }); + expect(wrapper.find('.js-remove-source-branch').attributes('disabled')).toBe('disabled'); + }); - it('should render the status text as "...to merged automatically" if MWPS is selected', (done) => { - Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY); + it('should render the status text as "...to merged automatically" if MWPS is selected', () => { + factory({ + ...defaultMrProps(), + autoMergeStrategy: MWPS_MERGE_STRATEGY, + }); - Vue.nextTick(() => { - const statusText = trimText(vm.$el.querySelector('.js-status-text-after-author').innerText); + const statusText = trimText(wrapper.find('.js-status-text-after-author').text()); - expect(statusText).toBe('to be merged automatically when the pipeline succeeds'); - done(); - }); - }); + expect(statusText).toBe('to be merged automatically when the pipeline succeeds'); + }); - it('should render the cancel button as "Cancel automatic merge" if MWPS is selected', (done) => { - Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY); + it('should render the cancel button as "Cancel automatic merge" if MWPS is selected', () => { + factory({ + ...defaultMrProps(), + autoMergeStrategy: MWPS_MERGE_STRATEGY, + }); - Vue.nextTick(() => { - const cancelButtonText = trimText(vm.$el.querySelector('.js-cancel-auto-merge').innerText); + const cancelButtonText = trimText(wrapper.find('.js-cancel-auto-merge').text()); - expect(cancelButtonText).toBe('Cancel automatic merge'); - done(); + expect(cancelButtonText).toBe('Cancel automatic merge'); + }); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js index aae9b8660e2..dca3798f7ea 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_auto_merge_failed_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon, GlButton } from '@gitlab/ui'; import AutoMergeFailedComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_failed.vue'; @@ -8,43 +9,60 @@ describe('MRWidgetAutoMergeFailed', () => { const mergeError = 'This is the merge error'; const findButton = () => wrapper.find(GlButton); - const createComponent = (props = {}) => { + const createComponent = (props = {}, mergeRequestWidgetGraphql = false) => { wrapper = shallowMount(AutoMergeFailedComponent, { propsData: { ...props }, - }); - }; + data() { + if (mergeRequestWidgetGraphql) { + return { mergeError: props.mr?.mergeError }; + } - beforeEach(() => { - createComponent({ - mr: { mergeError }, + return {}; + }, + provide: { + glFeatures: { mergeRequestWidgetGraphql }, + }, }); - }); + }; afterEach(() => { wrapper.destroy(); }); - it('renders failed message', () => { - expect(wrapper.text()).toContain('This merge request failed to be merged automatically'); - }); + [true, false].forEach((mergeRequestWidgetGraphql) => { + describe(`when graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'dislabed'}`, () => { + beforeEach(() => { + createComponent( + { + mr: { mergeError }, + }, + mergeRequestWidgetGraphql, + ); + }); - it('renders merge error provided', () => { - expect(wrapper.text()).toContain(mergeError); - }); + it('renders failed message', () => { + expect(wrapper.text()).toContain('This merge request failed to be merged automatically'); + }); - it('render refresh button', () => { - expect(findButton().text()).toEqual('Refresh'); - }); + it('renders merge error provided', () => { + expect(wrapper.text()).toContain(mergeError); + }); + + it('render refresh button', () => { + expect(findButton().text()).toBe('Refresh'); + }); + + it('emits event and shows loading icon when button is clicked', async () => { + jest.spyOn(eventHub, '$emit'); + findButton().vm.$emit('click'); - it('emits event and shows loading icon when button is clicked', () => { - jest.spyOn(eventHub, '$emit'); - findButton().vm.$emit('click'); + expect(eventHub.$emit.mock.calls[0][0]).toBe('MRWidgetUpdateRequested'); - expect(eventHub.$emit.mock.calls[0][0]).toBe('MRWidgetUpdateRequested'); + await nextTick(); - return wrapper.vm.$nextTick(() => { - expect(findButton().attributes('disabled')).toBe('true'); - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(findButton().attributes('disabled')).toBe('true'); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js index 916430cc132..65ab4fc44a5 100644 --- a/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js +++ b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js @@ -201,6 +201,10 @@ describe('gfm_autocomplete/utils', () => { expect(membersConfig.lookup(groupMember)).toBe(last(groupMember.name.split(' / '))); }); + it('limits the items in the autocomplete menu to 10', () => { + expect(membersConfig.menuItemLimit).toBe(10); + }); + it('shows the avatar, name and username in the menu item for a user', () => { expect(membersConfig.menuItemTemplate({ original: userMember })).toMatchSnapshot(); }); diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb index a9493a7b97c..63d288934e5 100644 --- a/spec/graphql/types/merge_request_type_spec.rb +++ b/spec/graphql/types/merge_request_type_spec.rb @@ -30,6 +30,7 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do conflicts auto_merge_enabled approved_by source_branch_protected default_merge_commit_message_with_description squash_on_merge available_auto_merge_strategies has_ci mergeable commits_without_merge_commits squash security_auto_fix default_squash_commit_message + auto_merge_strategy merge_user ] expect(described_class).to have_graphql_fields(*expected_fields).at_least diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 6bf2c4f8a29..b920e2e5600 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -433,6 +433,7 @@ RSpec.describe ProjectsHelper do context 'when project has external wiki' do it 'includes external wiki tab' do project.create_external_wiki_service(active: true, properties: { 'external_wiki_url' => 'https://gitlab.com' }) + project.reload is_expected.to include(:external_wiki) end diff --git a/spec/lib/bulk_imports/importers/group_importer_spec.rb b/spec/lib/bulk_imports/importers/group_importer_spec.rb index 81732496bc1..87baf1b8026 100644 --- a/spec/lib/bulk_imports/importers/group_importer_spec.rb +++ b/spec/lib/bulk_imports/importers/group_importer_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe BulkImports::Importers::GroupImporter do let(:user) { create(:user) } let(:bulk_import) { create(:bulk_import) } - let(:bulk_import_entity) { create(:bulk_import_entity, bulk_import: bulk_import) } + let(:bulk_import_entity) { create(:bulk_import_entity, :started, bulk_import: bulk_import) } let(:bulk_import_configuration) { create(:bulk_import_configuration, bulk_import: bulk_import) } let(:context) do BulkImports::Pipeline::Context.new( @@ -23,7 +23,6 @@ RSpec.describe BulkImports::Importers::GroupImporter do describe '#execute' do it 'starts the entity and run its pipelines' do - expect(bulk_import_entity).to receive(:start!).and_call_original expect_to_run_pipeline BulkImports::Groups::Pipelines::GroupPipeline, context: context expect_to_run_pipeline('EE::BulkImports::Groups::Pipelines::EpicsPipeline'.constantize, context: context) if Gitlab.ee? expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context diff --git a/spec/lib/bulk_imports/importers/groups_importer_spec.rb b/spec/lib/bulk_imports/importers/groups_importer_spec.rb deleted file mode 100644 index 4865034b0cd..00000000000 --- a/spec/lib/bulk_imports/importers/groups_importer_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe BulkImports::Importers::GroupsImporter do - let_it_be(:bulk_import) { create(:bulk_import) } - - subject { described_class.new(bulk_import.id) } - - describe '#execute' do - context "when there is entities to be imported" do - let!(:bulk_import_entity) { create(:bulk_import_entity, bulk_import: bulk_import) } - - it "starts the bulk_import and imports its entities" do - expect(BulkImports::Importers::GroupImporter).to receive(:new) - .with(bulk_import_entity).and_return(double(execute: true)) - expect(BulkImportWorker).to receive(:perform_async).with(bulk_import.id) - - subject.execute - - expect(bulk_import.reload).to be_started - end - end - - context "when there is no entities to be imported" do - it "starts the bulk_import and imports its entities" do - expect(BulkImports::Importers::GroupImporter).not_to receive(:new) - expect(BulkImportWorker).not_to receive(:perform_async) - - subject.execute - - expect(bulk_import.reload).to be_finished - end - end - end -end diff --git a/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb b/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb index 601867122f3..e76718fe48a 100644 --- a/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb +++ b/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb @@ -7,6 +7,7 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do let(:api_key) { "foo" } let(:api_url) { "http://bar"} + let(:additional_tag) { "some-tag" } let(:action) { create(:reindex_action) } @@ -73,32 +74,66 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do end describe '#notify_start' do - subject { described_class.new(api_key, api_url).notify_start(action) } - - let(:payload) do - { - time: (action.action_start.utc.to_f * 1000).to_i, - tags: ['reindex', action.index.tablename, action.index.name], - text: "Started reindexing of #{action.index.name} on #{action.index.tablename}" - } + context 'additional tag is nil' do + subject { described_class.new(api_key, api_url, nil).notify_start(action) } + + let(:payload) do + { + time: (action.action_start.utc.to_f * 1000).to_i, + tags: ['reindex', action.index.tablename, action.index.name], + text: "Started reindexing of #{action.index.name} on #{action.index.tablename}" + } + end + + it_behaves_like 'interacting with Grafana annotations API' end - it_behaves_like 'interacting with Grafana annotations API' + context 'additional tag is not nil' do + subject { described_class.new(api_key, api_url, additional_tag).notify_start(action) } + + let(:payload) do + { + time: (action.action_start.utc.to_f * 1000).to_i, + tags: ['reindex', additional_tag, action.index.tablename, action.index.name], + text: "Started reindexing of #{action.index.name} on #{action.index.tablename}" + } + end + + it_behaves_like 'interacting with Grafana annotations API' + end end describe '#notify_end' do - subject { described_class.new(api_key, api_url).notify_end(action) } - - let(:payload) do - { - time: (action.action_start.utc.to_f * 1000).to_i, - tags: ['reindex', action.index.tablename, action.index.name], - text: "Finished reindexing of #{action.index.name} on #{action.index.tablename} (#{action.state})", - timeEnd: (action.action_end.utc.to_f * 1000).to_i, - isRegion: true - } + context 'additional tag is nil' do + subject { described_class.new(api_key, api_url, nil).notify_end(action) } + + let(:payload) do + { + time: (action.action_start.utc.to_f * 1000).to_i, + tags: ['reindex', action.index.tablename, action.index.name], + text: "Finished reindexing of #{action.index.name} on #{action.index.tablename} (#{action.state})", + timeEnd: (action.action_end.utc.to_f * 1000).to_i, + isRegion: true + } + end + + it_behaves_like 'interacting with Grafana annotations API' end - it_behaves_like 'interacting with Grafana annotations API' + context 'additional tag is not nil' do + subject { described_class.new(api_key, api_url, additional_tag).notify_end(action) } + + let(:payload) do + { + time: (action.action_start.utc.to_f * 1000).to_i, + tags: ['reindex', additional_tag, action.index.tablename, action.index.name], + text: "Finished reindexing of #{action.index.name} on #{action.index.tablename} (#{action.state})", + timeEnd: (action.action_end.utc.to_f * 1000).to_i, + isRegion: true + } + end + + it_behaves_like 'interacting with Grafana annotations API' + end end end diff --git a/spec/lib/release_highlights/validator/entry_spec.rb b/spec/lib/release_highlights/validator/entry_spec.rb new file mode 100644 index 00000000000..648356e63ba --- /dev/null +++ b/spec/lib/release_highlights/validator/entry_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ReleaseHighlights::Validator::Entry do + subject(:entry) { described_class.new(document.root.children.first) } + + let(:document) { YAML.parse(File.read(yaml_path)) } + let(:yaml_path) { 'spec/fixtures/whats_new/blank.yml' } + + describe 'validations' do + before do + allow(entry).to receive(:value_for).and_call_original + end + + context 'with a valid entry' do + let(:yaml_path) { 'spec/fixtures/whats_new/valid.yml' } + + it { is_expected.to be_valid } + end + + context 'with an invalid entry' do + let(:yaml_path) { 'spec/fixtures/whats_new/invalid.yml' } + + it 'returns line numbers in errors' do + subject.valid? + + expect(entry.errors[:packages].first).to match('(line 6)') + end + end + + context 'with a blank entry' do + it 'validate presence of title, body and stage' do + subject.valid? + + expect(subject.errors[:title]).not_to be_empty + expect(subject.errors[:body]).not_to be_empty + expect(subject.errors[:stage]).not_to be_empty + expect(subject.errors[:packages]).not_to be_empty + end + + it 'validates boolean value of "self-managed" and "gitlab-com"' do + allow(entry).to receive(:value_for).with('self-managed').and_return('nope') + allow(entry).to receive(:value_for).with('gitlab-com').and_return('yerp') + + subject.valid? + + expect(subject.errors[:'self-managed']).to include(/must be a boolean/) + expect(subject.errors[:'gitlab-com']).to include(/must be a boolean/) + end + + it 'validates URI of "url" and "image_url"' do + allow(entry).to receive(:value_for).with('image_url').and_return('imgur/gitlab_feature.gif') + allow(entry).to receive(:value_for).with('url').and_return('gitlab/newest_release.html') + + subject.valid? + + expect(subject.errors[:url]).to include(/must be a URL/) + expect(subject.errors[:image_url]).to include(/must be a URL/) + end + + it 'validates release is numerical' do + allow(entry).to receive(:value_for).with('release').and_return('one') + + subject.valid? + + expect(subject.errors[:release]).to include(/is not a number/) + end + + it 'validates published_at is a date' do + allow(entry).to receive(:value_for).with('published_at').and_return('christmas day') + + subject.valid? + + expect(subject.errors[:published_at]).to include(/must be valid Date/) + end + + it 'validates packages are included in list' do + allow(entry).to receive(:value_for).with('packages').and_return(['ALL']) + + subject.valid? + + expect(subject.errors[:packages].first).to include("must be one of", "Core", "Starter", "Premium", "Ultimate") + end + end + end +end diff --git a/spec/lib/release_highlights/validator_spec.rb b/spec/lib/release_highlights/validator_spec.rb new file mode 100644 index 00000000000..e68d9145dcd --- /dev/null +++ b/spec/lib/release_highlights/validator_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ReleaseHighlights::Validator do + let(:validator) { described_class.new(file: yaml_path) } + let(:yaml_path) { 'spec/fixtures/whats_new/valid.yml' } + let(:invalid_yaml_path) { 'spec/fixtures/whats_new/invalid.yml' } + + describe '#valid?' do + subject { validator.valid? } + + context 'with a valid file' do + it 'passes entries to entry validator and returns true' do + expect(ReleaseHighlights::Validator::Entry).to receive(:new).exactly(:twice).and_call_original + expect(subject).to be true + expect(validator.errors).to be_empty + end + end + + context 'with invalid file' do + let(:yaml_path) { invalid_yaml_path } + + it 'returns false and has errors' do + expect(subject).to be false + expect(validator.errors).not_to be_empty + end + end + end + + describe '.validate_all!' do + subject { described_class.validate_all! } + + before do + allow(ReleaseHighlight).to receive(:file_paths).and_return(yaml_paths) + end + + context 'with valid files' do + let(:yaml_paths) { [yaml_path, yaml_path] } + + it { is_expected.to be true } + end + + context 'with an invalid file' do + let(:yaml_paths) { [invalid_yaml_path, yaml_path] } + + it { is_expected.to be false } + end + end + + describe '.error_message' do + subject do + described_class.validate_all! + described_class.error_message + end + + before do + allow(ReleaseHighlight).to receive(:file_paths).and_return([yaml_path]) + end + + context 'with a valid file' do + it { is_expected.to be_empty } + end + + context 'with an invalid file' do + let(:yaml_path) { invalid_yaml_path } + + it 'returns a nice error message' do + expect(subject).to eq(<<-MESSAGE.strip_heredoc) + --------------------------------------------------------- + Validation failed for spec/fixtures/whats_new/invalid.yml + --------------------------------------------------------- + * Packages must be one of ["Core", "Starter", "Premium", "Ultimate"] (line 6) + + MESSAGE + end + end + end + + describe 'when validating all files' do + it 'they should have no errors' do + expect(described_class.validate_all!).to be_truthy, described_class.error_message + end + end +end diff --git a/spec/migrations/add_has_external_wiki_trigger_spec.rb b/spec/migrations/add_has_external_wiki_trigger_spec.rb new file mode 100644 index 00000000000..10c6888c87e --- /dev/null +++ b/spec/migrations/add_has_external_wiki_trigger_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require_migration! + +RSpec.describe AddHasExternalWikiTrigger do + let(:migration) { described_class.new } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:services) { table(:services) } + + before do + @namespace = namespaces.create!(name: 'foo', path: 'foo') + @project = projects.create!(namespace_id: @namespace.id) + end + + describe '#up' do + before do + migrate! + end + + describe 'INSERT trigger' do + it 'sets `has_external_wiki` to true when active `ExternalWikiService` is inserted' do + expect do + services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) + end.to change { @project.reload.has_external_wiki }.to(true) + end + + it 'does not set `has_external_wiki` to true when service is for a different project' do + different_project = projects.create!(namespace_id: @namespace.id) + + expect do + services.create!(type: 'ExternalWikiService', active: true, project_id: different_project.id) + end.not_to change { @project.reload.has_external_wiki } + end + + it 'does not set `has_external_wiki` to true when inactive `ExternalWikiService` is inserted' do + expect do + services.create!(type: 'ExternalWikiService', active: false, project_id: @project.id) + end.not_to change { @project.reload.has_external_wiki } + end + + it 'does not set `has_external_wiki` to true when active other service is inserted' do + expect do + services.create!(type: 'MyService', active: true, project_id: @project.id) + end.not_to change { @project.reload.has_external_wiki } + end + end + + describe 'UPDATE trigger' do + it 'sets `has_external_wiki` to true when `ExternalWikiService` is made active' do + service = services.create!(type: 'ExternalWikiService', active: false, project_id: @project.id) + + expect do + service.update!(active: true) + end.to change { @project.reload.has_external_wiki }.to(true) + end + + it 'sets `has_external_wiki` to false when `ExternalWikiService` is made inactive' do + service = services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) + + expect do + service.update!(active: false) + end.to change { @project.reload.has_external_wiki }.to(false) + end + + it 'does not change `has_external_wiki` when service is for a different project' do + different_project = projects.create!(namespace_id: @namespace.id) + service = services.create!(type: 'ExternalWikiService', active: false, project_id: different_project.id) + + expect do + service.update!(active: true) + end.not_to change { @project.reload.has_external_wiki } + end + end + + describe 'DELETE trigger' do + it 'sets `has_external_wiki` to false when `ExternalWikiService` is deleted' do + service = services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) + + expect do + service.delete + end.to change { @project.reload.has_external_wiki }.to(false) + end + + it 'does not change `has_external_wiki` when service is for a different project' do + different_project = projects.create!(namespace_id: @namespace.id) + service = services.create!(type: 'ExternalWikiService', active: true, project_id: different_project.id) + + expect do + service.delete + end.not_to change { @project.reload.has_external_wiki } + end + end + end + + describe '#down' do + before do + migration.up + migration.down + end + + it 'drops the INSERT trigger' do + expect do + services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) + end.not_to change { @project.reload.has_external_wiki } + end + + it 'drops the UPDATE trigger' do + service = services.create!(type: 'ExternalWikiService', active: false, project_id: @project.id) + @project.update!(has_external_wiki: false) + + expect do + service.update!(active: true) + end.not_to change { @project.reload.has_external_wiki } + end + + it 'drops the DELETE trigger' do + service = services.create!(type: 'ExternalWikiService', active: true, project_id: @project.id) + @project.update!(has_external_wiki: true) + + expect do + service.delete + end.not_to change { @project.reload.has_external_wiki } + end + end +end diff --git a/spec/models/namespace/package_setting_spec.rb b/spec/models/namespace/package_setting_spec.rb index d5653273951..097cef8ef3b 100644 --- a/spec/models/namespace/package_setting_spec.rb +++ b/spec/models/namespace/package_setting_spec.rb @@ -33,4 +33,49 @@ RSpec.describe Namespace::PackageSetting do end end end + + describe '#duplicates_allowed?' do + using RSpec::Parameterized::TableSyntax + + subject { described_class.duplicates_allowed?(package) } + + context 'package types with package_settings' do + # As more package types gain settings they will be added to this list + [:maven_package].each do |format| + let_it_be(:package) { create(format) } # rubocop:disable Rails/SaveBang + let_it_be(:package_type) { package.package_type } + let_it_be(:package_setting) { package.project.namespace.package_settings } + + where(:duplicates_allowed, :duplicate_exception_regex, :result) do + true | '' | true + false | '' | false + false | '.*' | true + end + + with_them do + context "for #{format}" do + before do + package_setting.update!( + "#{package_type}_duplicates_allowed" => duplicates_allowed, + "#{package_type}_duplicate_exception_regex" => duplicate_exception_regex + ) + end + + it { is_expected.to be(result) } + end + end + end + end + + context 'package types without package_settings' do + [:npm_package, :conan_package, :nuget_package, :pypi_package, :composer_package, :generic_package, :golang_package, :debian_package].each do |format| + let_it_be(:package) { create(format) } # rubocop:disable Rails/SaveBang + let_it_be(:package_setting) { package.project.namespace.package_settings } + + it 'raises an error' do + expect { subject }.to raise_error(Namespace::PackageSetting::PackageSettingNotImplemented) + end + end + end + end end diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb index 56ff13762f9..6645db33503 100644 --- a/spec/models/packages/package_spec.rb +++ b/spec/models/packages/package_spec.rb @@ -745,4 +745,14 @@ RSpec.describe Packages::Package, type: :model do end end end + + describe '#package_settings' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:package) { create(:maven_package, project: project) } + + it 'returns the namespace package_settings' do + expect(package.package_settings).to eq(group.package_settings) + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 7c3eed14bcf..b6875e3de6e 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1067,36 +1067,6 @@ RSpec.describe Project, factory_default: :keep do end end - describe '#cache_has_external_wiki' do - let_it_be(:project) { create(:project, has_external_wiki: nil) } - - it 'stores true if there is any external_wikis' do - services = double(:service, external_wikis: [ExternalWikiService.new]) - expect(project).to receive(:services).and_return(services) - - expect do - project.cache_has_external_wiki - end.to change { project.has_external_wiki}.to(true) - end - - it 'stores false if there is no external_wikis' do - services = double(:service, external_wikis: []) - expect(project).to receive(:services).and_return(services) - - expect do - project.cache_has_external_wiki - end.to change { project.has_external_wiki}.to(false) - end - - it 'does not cache data when in a read-only GitLab instance' do - allow(Gitlab::Database).to receive(:read_only?) { true } - - expect do - project.cache_has_external_wiki - end.not_to change { project.has_external_wiki } - end - end - describe '#has_wiki?' do let(:no_wiki_project) { create(:project, :wiki_disabled, has_external_wiki: false) } let(:wiki_enabled_project) { create(:project) } @@ -1136,52 +1106,64 @@ RSpec.describe Project, factory_default: :keep do describe '#external_wiki' do let_it_be(:project) { create(:project) } - context 'with an active external wiki' do - before do - create(:service, project: project, type: 'ExternalWikiService', active: true) - project.external_wiki - end + def subject + project.reload.external_wiki + end - it 'sets :has_external_wiki as true' do - expect(project.has_external_wiki).to be(true) - end + it 'returns an active external wiki' do + create(:service, project: project, type: 'ExternalWikiService', active: true) - it 'sets :has_external_wiki as false if an external wiki service is destroyed later' do - expect(project.has_external_wiki).to be(true) + is_expected.to be_kind_of(ExternalWikiService) + end - project.services.external_wikis.first.destroy + it 'does not return an inactive external wiki' do + create(:service, project: project, type: 'ExternalWikiService', active: false) - expect(project.has_external_wiki).to be(false) - end + is_expected.to eq(nil) end - context 'with an inactive external wiki' do - before do - create(:service, project: project, type: 'ExternalWikiService', active: false) - end + it 'sets Project#has_external_wiki when it is nil' do + create(:service, project: project, type: 'ExternalWikiService', active: true) + project.update_column(:has_external_wiki, nil) - it 'sets :has_external_wiki as false' do - expect(project.has_external_wiki).to be(false) - end + expect { subject }.to change { project.has_external_wiki }.from(nil).to(true) end + end - context 'with no external wiki' do - before do - project.external_wiki - end + describe '#has_external_wiki' do + let_it_be(:project) { create(:project) } - it 'sets :has_external_wiki as false' do - expect(project.has_external_wiki).to be(false) - end + def subject + project.reload.has_external_wiki + end - it 'sets :has_external_wiki as true if an external wiki service is created later' do - expect(project.has_external_wiki).to be(false) + specify { is_expected.to eq(false) } + context 'when there is an active external wiki service' do + let!(:service) do create(:service, project: project, type: 'ExternalWikiService', active: true) + end + + specify { is_expected.to eq(true) } + + it 'becomes false if the external wiki service is destroyed' do + expect do + Service.find(service.id).delete + end.to change { subject }.to(false) + end - expect(project.has_external_wiki).to be(true) + it 'becomes false if the external wiki service becomes inactive' do + expect do + service.update_column(:active, false) + end.to change { subject }.to(false) end end + + it 'is false when external wiki service is not active' do + create(:service, project: project, type: 'ExternalWikiService', active: false) + + is_expected.to eq(false) + end end describe '#star_count' do diff --git a/spec/models/release_highlight_spec.rb b/spec/models/release_highlight_spec.rb index ac252e6d6cf..749b9b8e1ab 100644 --- a/spec/models/release_highlight_spec.rb +++ b/spec/models/release_highlight_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe ReleaseHighlight do - let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) } + let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')).grep(/\d*\_(\d*\_\d*)\.yml$/) } before do allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob) diff --git a/spec/requests/api/boards_spec.rb b/spec/requests/api/boards_spec.rb index 36fc6101b84..ca6492396cd 100644 --- a/spec/requests/api/boards_spec.rb +++ b/spec/requests/api/boards_spec.rb @@ -53,17 +53,6 @@ RSpec.describe API::Boards do end end - describe "PUT /projects/:id/boards/:board_id" do - let(:url) { "/projects/#{board_parent.id}/boards/#{board.id}" } - - it 'updates the issue board' do - put api(url, user), params: { name: 'changed board name' } - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['name']).to eq('changed board name') - end - end - describe "DELETE /projects/:id/boards/:board_id" do let(:url) { "/projects/#{board_parent.id}/boards/#{board.id}" } diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb index f9ba819c9aa..13d8e0a023a 100644 --- a/spec/requests/api/maven_packages_spec.rb +++ b/spec/requests/api/maven_packages_spec.rb @@ -4,7 +4,8 @@ require 'spec_helper' RSpec.describe API::MavenPackages do include WorkhorseHelpers - let_it_be(:group) { create(:group) } + let_it_be_with_refind(:package_settings) { create(:namespace_package_setting, :group) } + let_it_be(:group) { package_settings.namespace } let_it_be(:user) { create(:user) } let_it_be(:project, reload: true) { create(:project, :public, namespace: group) } let_it_be(:package, reload: true) { create(:maven_package, project: project, name: project.full_path) } @@ -18,6 +19,7 @@ RSpec.describe API::MavenPackages do let_it_be(:deploy_token_for_group) { create(:deploy_token, :group, read_package_registry: true, write_package_registry: true) } let_it_be(:group_deploy_token) { create(:group_deploy_token, deploy_token: deploy_token_for_group, group: group) } + let(:package_name) { 'com/example/my-app' } let(:workhorse_token) { JWT.encode({ 'iss' => 'gitlab-workhorse' }, Gitlab::Workhorse.secret, 'HS256') } let(:headers) { { 'GitLab-Workhorse' => '1.0', Gitlab::Workhorse::INTERNAL_API_REQUEST_HEADER => workhorse_token } } let(:headers_with_token) { headers.merge('Private-Token' => personal_access_token.token) } @@ -669,6 +671,35 @@ RSpec.describe API::MavenPackages do end end + context 'when package duplicates are not allowed' do + let(:package_name) { package.name } + let(:version) { package.version } + + before do + package_settings.update!(maven_duplicates_allowed: false) + end + + it 'rejects the request', :aggregate_failures do + expect { upload_file_with_token(params: params) }.not_to change { package.package_files.count } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']).to include('Duplicate package is not allowed') + end + + context 'when the package name matches the exception regex' do + before do + package_settings.update!(maven_duplicate_exception_regex: '.*') + end + + it 'stores the package file', :aggregate_failures do + expect { upload_file_with_token(params: params) }.to change { package.package_files.count }.by(1) + + expect(response).to have_gitlab_http_status(:ok) + expect(jar_file.file_name).to eq(file_upload.original_filename) + end + end + end + context 'for sha1 file' do let(:dummy_package) { double(Packages::Package) } @@ -698,7 +729,7 @@ RSpec.describe API::MavenPackages do end def upload_file(params: {}, request_headers: headers, file_extension: 'jar') - url = "/projects/#{project.id}/packages/maven/com/example/my-app/#{version}/my-app-1.0-20180724.124855-1.#{file_extension}" + url = "/projects/#{project.id}/packages/maven/#{package_name}/#{version}/my-app-1.0-20180724.124855-1.#{file_extension}" workhorse_finalize( api(url), method: :put, diff --git a/spec/services/packages/maven/find_or_create_package_service_spec.rb b/spec/services/packages/maven/find_or_create_package_service_spec.rb index 2eaad7db445..6b852bcbba0 100644 --- a/spec/services/packages/maven/find_or_create_package_service_spec.rb +++ b/spec/services/packages/maven/find_or_create_package_service_spec.rb @@ -11,29 +11,36 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do let(:file_name) { 'test.jar' } let(:param_path) { "#{path}/#{version}" } let(:params) { { path: param_path, file_name: file_name } } + let(:service) { described_class.new(project, user, params) } describe '#execute' do using RSpec::Parameterized::TableSyntax - subject { described_class.new(project, user, params).execute } + subject { service.execute } - RSpec.shared_examples 'reuse existing package' do - it { expect { subject}.not_to change { Packages::Package.count } } + shared_examples 'reuse existing package' do + it { expect { subject }.not_to change { Packages::Package.count } } - it { is_expected.to eq(existing_package) } + it 'returns the existing package' do + expect(subject.payload).to eq(package: existing_package) + end end - RSpec.shared_examples 'create package' do + shared_examples 'create package' do it { expect { subject }.to change { Packages::Package.count }.by(1) } - it 'sets the proper name and version' do - pkg = subject + it 'sets the proper name and version', :aggregate_failures do + pkg = subject.payload[:package] expect(pkg.name).to eq(path) expect(pkg.version).to eq(version) end - it_behaves_like 'assigns build to package' + context 'with a build' do + subject { service.execute.payload[:package] } + + it_behaves_like 'assigns build to package' + end end context 'path with version' do @@ -90,5 +97,27 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do expect { subject }.to change { Packages::BuildInfo.count }.by(1) end end + + context 'when package duplicates are not allowed' do + let_it_be_with_refind(:package_settings) { create(:namespace_package_setting, :group, maven_duplicates_allowed: false) } + let_it_be_with_refind(:group) { package_settings.namespace } + let_it_be_with_refind(:project) { create(:project, group: group) } + let!(:existing_package) { create(:maven_package, name: path, version: version, project: project) } + + it { expect { subject }.not_to change { project.package_files.count } } + + it 'returns an error', :aggregate_failures do + expect(subject.payload).to be_empty + expect(subject.errors).to include('Duplicate package is not allowed') + end + + context 'when the package name matches the exception regex' do + before do + package_settings.update!(maven_duplicate_exception_regex: '.*') + end + + it_behaves_like 'reuse existing package' + end + end end end diff --git a/spec/support/shared_examples/requests/api/boards_shared_examples.rb b/spec/support/shared_examples/requests/api/boards_shared_examples.rb index 0096aab55e3..8e8edd61ef9 100644 --- a/spec/support/shared_examples/requests/api/boards_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/boards_shared_examples.rb @@ -44,16 +44,35 @@ RSpec.shared_examples 'group and project boards' do |route_definition, ee = fals expect_schema_match_for(response, 'public_api/v4/boards', ee) end + end + end - describe "GET #{route_definition}/:board_id" do - let(:url) { "#{root_url}/#{board.id}" } + describe "GET #{route_definition}/:board_id" do + let(:url) { "#{root_url}/#{board.id}" } - it 'get a single board by id' do - get api(url, user) + it 'get a single board by id' do + get api(url, user) - expect_schema_match_for(response, 'public_api/v4/board', ee) - end - end + expect_schema_match_for(response, 'public_api/v4/board', ee) + end + end + + describe "PUT #{route_definition}/:board_id" do + let(:url) { "#{root_url}/#{board.id}" } + + it 'updates the board name' do + put api(url, user), params: { name: 'changed board name' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('changed board name') + end + + it 'updates the issue board booleans' do + put api(url, user), params: { hide_backlog_list: true, hide_closed_list: true } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['hide_backlog_list']).to eq(true) + expect(json_response['hide_closed_list']).to eq(true) end end diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb index 12783f40528..d3a4144d606 100644 --- a/spec/workers/bulk_import_worker_spec.rb +++ b/spec/workers/bulk_import_worker_spec.rb @@ -4,13 +4,74 @@ require 'spec_helper' RSpec.describe BulkImportWorker do describe '#perform' do - it 'executes Group Importer' do - bulk_import_id = 1 + before do + stub_const("#{described_class}::DEFAULT_BATCH_SIZE", 1) + end + + context 'when no bulk import is found' do + it 'does nothing' do + expect(described_class).not_to receive(:perform_in) + + subject.perform(non_existing_record_id) + end + end + + context 'when bulk import is finished' do + it 'does nothing' do + bulk_import = create(:bulk_import, :finished) + + expect(described_class).not_to receive(:perform_in) + + subject.perform(bulk_import.id) + end + end + + context 'when all entities are processed' do + it 'marks bulk import as finished' do + bulk_import = create(:bulk_import, :started) + create(:bulk_import_entity, :finished, bulk_import: bulk_import) + create(:bulk_import_entity, :failed, bulk_import: bulk_import) + + subject.perform(bulk_import.id) + + expect(bulk_import.reload.finished?).to eq(true) + end + end + + context 'when maximum allowed number of import entities in progress' do + it 'reenqueues itself' do + bulk_import = create(:bulk_import, :started) + (described_class::DEFAULT_BATCH_SIZE + 1).times { |_| create(:bulk_import_entity, :started, bulk_import: bulk_import) } + + expect(described_class).to receive(:perform_in).with(described_class::PERFORM_DELAY, bulk_import.id) + + subject.perform(bulk_import.id) + end + end + + context 'when bulk import is created' do + it 'marks bulk import as started' do + bulk_import = create(:bulk_import, :created) + create(:bulk_import_entity, :created, bulk_import: bulk_import) + + subject.perform(bulk_import.id) + + expect(bulk_import.reload.started?).to eq(true) + end + + context 'when there are created entities to process' do + it 'marks a batch of entities as started, enqueues BulkImports::EntityWorker and reenqueues' do + bulk_import = create(:bulk_import, :created) + (described_class::DEFAULT_BATCH_SIZE + 1).times { |_| create(:bulk_import_entity, :created, bulk_import: bulk_import) } + + expect(described_class).to receive(:perform_in).with(described_class::PERFORM_DELAY, bulk_import.id) + expect(BulkImports::EntityWorker).to receive(:perform_async) - expect(BulkImports::Importers::GroupsImporter) - .to receive(:new).with(bulk_import_id).and_return(double(execute: true)) + subject.perform(bulk_import.id) - described_class.new.perform(bulk_import_id) + expect(bulk_import.entities.map(&:status_name)).to contain_exactly(:created, :started) + end + end end end end diff --git a/spec/workers/bulk_imports/entity_worker_spec.rb b/spec/workers/bulk_imports/entity_worker_spec.rb new file mode 100644 index 00000000000..31515b31947 --- /dev/null +++ b/spec/workers/bulk_imports/entity_worker_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::EntityWorker do + describe '#execute' do + let(:bulk_import) { create(:bulk_import) } + + context 'when started entity exists' do + let(:entity) { create(:bulk_import_entity, :started, bulk_import: bulk_import) } + + it 'executes BulkImports::Importers::GroupImporter' do + expect(BulkImports::Importers::GroupImporter).to receive(:new).with(entity).and_call_original + + subject.perform(entity.id) + end + + it 'sets jid' do + jid = 'jid' + + allow(subject).to receive(:jid).and_return(jid) + + subject.perform(entity.id) + + expect(entity.reload.jid).to eq(jid) + end + end + + context 'when started entity does not exist' do + it 'does not execute BulkImports::Importers::GroupImporter' do + entity = create(:bulk_import_entity, bulk_import: bulk_import) + + expect(BulkImports::Importers::GroupImporter).not_to receive(:new) + + subject.perform(entity.id) + end + end + end +end |