diff options
84 files changed, 1361 insertions, 839 deletions
diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml index 3a55d0f6747..e20f211bd33 100644 --- a/.gitlab/ci/global.gitlab-ci.yml +++ b/.gitlab/ci/global.gitlab-ci.yml @@ -264,7 +264,7 @@ .zoekt-services: services: - - name: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images:zoekt-ci-image-1.1 + - name: ${REGISTRY_HOST}/${REGISTRY_GROUP}/gitlab-build-images:zoekt-ci-image-1.2 alias: zoekt-ci-image .use-pg12: diff --git a/app/assets/javascripts/blob/line_highlighter.js b/app/assets/javascripts/blob/line_highlighter.js index 1ec204b4034..4258d16b69f 100644 --- a/app/assets/javascripts/blob/line_highlighter.js +++ b/app/assets/javascripts/blob/line_highlighter.js @@ -153,7 +153,7 @@ LineHighlighter.prototype.highlightRange = function (range) { const results = []; const ref = range[0] <= range[1] ? range : range.reverse(); - for (let lineNumber = ref[0]; lineNumber <= ref[1]; lineNumber += 1) { + for (let lineNumber = range[0]; lineNumber <= ref[1]; lineNumber += 1) { results.push(this.highlightLine(lineNumber)); } diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index eb73f8e0182..9febebf7e55 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -36,6 +36,7 @@ import { getParameterByName } from '~/lib/utils/url_utility'; import { OPERATORS_IS, OPERATORS_IS_NOT_OR, + OPERATORS_AFTER_BEFORE, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, @@ -44,6 +45,8 @@ import { TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_SEARCH_WITHIN, TOKEN_TITLE_TYPE, + TOKEN_TITLE_CREATED, + TOKEN_TITLE_CLOSED, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -52,6 +55,8 @@ import { TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, + TOKEN_TYPE_CREATED, + TOKEN_TYPE_CLOSED, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants'; @@ -63,6 +68,7 @@ const EmojiToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); const LabelToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'); +const DateToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/date_token.vue'); const MilestoneToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'); @@ -89,6 +95,7 @@ export default { 'emptyStateWithoutFilterSvgPath', 'hasBlockedIssuesFeature', 'hasIssuableHealthStatusFeature', + 'hasIssueDateFilterFeature', 'hasIssueWeightsFeature', 'hasScopedLabelsFeature', 'initialSort', @@ -318,6 +325,24 @@ export default { fetchEmojis: this.fetchEmojis, recentSuggestionsStorageKey: 'dashboard-issues-recent-tokens-my_reaction', }); + + if (this.hasIssueDateFilterFeature) { + tokens.push({ + type: TOKEN_TYPE_CREATED, + title: TOKEN_TITLE_CREATED, + icon: 'history', + token: DateToken, + operators: OPERATORS_AFTER_BEFORE, + }); + + tokens.push({ + type: TOKEN_TYPE_CLOSED, + title: TOKEN_TITLE_CLOSED, + icon: 'history', + token: DateToken, + operators: OPERATORS_AFTER_BEFORE, + }); + } } tokens.sort((a, b) => a.title.localeCompare(b.title)); diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql index 5c331fe95e2..51e38d44c85 100644 --- a/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues.query.graphql @@ -23,6 +23,10 @@ query getDashboardIssues( $beforeCursor: String $firstPageSize: Int $lastPageSize: Int + $createdAfter: Time + $createdBefore: Time + $closedAfter: Time + $closedBefore: Time ) { issues( search: $search @@ -44,6 +48,10 @@ query getDashboardIssues( before: $beforeCursor first: $firstPageSize last: $lastPageSize + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) @persist { nodes { __persist diff --git a/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql index b36f546e4ab..a91f15f0c04 100644 --- a/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues/dashboard/queries/get_issues_counts.query.graphql @@ -12,6 +12,10 @@ query getDashboardIssuesCount( $in: [IssuableSearchableField!] $not: NegatedIssueFilterInput $or: UnionedIssueFilterInput + $createdAfter: Time + $createdBefore: Time + $closedAfter: Time + $closedBefore: Time ) { openedIssues: issues( state: opened @@ -28,6 +32,10 @@ query getDashboardIssuesCount( in: $in not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -46,6 +54,10 @@ query getDashboardIssuesCount( in: $in not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -64,6 +76,10 @@ query getDashboardIssuesCount( in: $in not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index db3008c17a4..c4e906be94f 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -40,6 +40,7 @@ import { OPERATORS_IS, OPERATORS_IS_NOT, OPERATORS_IS_NOT_OR, + OPERATORS_AFTER_BEFORE, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, TOKEN_TITLE_CONFIDENTIAL, @@ -51,6 +52,8 @@ import { TOKEN_TITLE_RELEASE, TOKEN_TITLE_SEARCH_WITHIN, TOKEN_TITLE_TYPE, + TOKEN_TITLE_CREATED, + TOKEN_TITLE_CLOSED, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -62,6 +65,8 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, + TOKEN_TYPE_CREATED, + TOKEN_TYPE_CLOSED, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { DEFAULT_PAGE_SIZE, issuableListTabs } from '~/vue_shared/issuable/list/constants'; @@ -125,6 +130,7 @@ const CrmContactToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'); const CrmOrganizationToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'); +const DateToken = () => import('~/vue_shared/components/filtered_search_bar/tokens/date_token.vue'); export default { i18n, @@ -166,6 +172,7 @@ export default { 'hasAnyProjects', 'hasBlockedIssuesFeature', 'hasIssuableHealthStatusFeature', + 'hasIssueDateFilterFeature', 'hasIssueWeightsFeature', 'hasScopedLabelsFeature', 'initialEmail', @@ -460,6 +467,24 @@ export default { { icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo }, ], }); + + if (this.hasIssueDateFilterFeature) { + tokens.push({ + type: TOKEN_TYPE_CREATED, + title: TOKEN_TITLE_CREATED, + icon: 'history', + token: DateToken, + operators: OPERATORS_AFTER_BEFORE, + }); + + tokens.push({ + type: TOKEN_TYPE_CLOSED, + title: TOKEN_TITLE_CLOSED, + icon: 'history', + token: DateToken, + operators: OPERATORS_AFTER_BEFORE, + }); + } } if (this.canReadCrmContact) { diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index a7933803ed4..85e300b6474 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -9,6 +9,8 @@ import { OPERATOR_IS, OPERATOR_NOT, OPERATOR_OR, + OPERATOR_AFTER, + OPERATOR_BEFORE, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -24,6 +26,8 @@ import { TOKEN_TYPE_TYPE, TOKEN_TYPE_WEIGHT, TOKEN_TYPE_SEARCH_WITHIN, + TOKEN_TYPE_CREATED, + TOKEN_TYPE_CLOSED, } from '~/vue_shared/components/filtered_search_bar/constants'; import { WORK_ITEM_TYPE_ENUM_INCIDENT, @@ -416,4 +420,32 @@ export const filtersMap = { }, }, }, + [TOKEN_TYPE_CREATED]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'createdBefore', + [ALTERNATIVE_FILTER]: 'createdAfter', + }, + [URL_PARAM]: { + [OPERATOR_AFTER]: { + [ALTERNATIVE_FILTER]: 'created_after', + }, + [OPERATOR_BEFORE]: { + [NORMAL_FILTER]: 'created_before', + }, + }, + }, + [TOKEN_TYPE_CLOSED]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'closedBefore', + [ALTERNATIVE_FILTER]: 'closedAfter', + }, + [URL_PARAM]: { + [OPERATOR_AFTER]: { + [ALTERNATIVE_FILTER]: 'closed_after', + }, + [OPERATOR_BEFORE]: { + [NORMAL_FILTER]: 'closed_before', + }, + }, + }, }; diff --git a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql index 1018848fb53..23410ea0f81 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues.query.graphql @@ -30,6 +30,10 @@ query getIssues( $afterCursor: String $firstPageSize: Int $lastPageSize: Int + $createdAfter: Time + $createdBefore: Time + $closedAfter: Time + $closedBefore: Time ) { group(fullPath: $fullPath) @skip(if: $isProject) @persist { id @@ -57,6 +61,10 @@ query getIssues( after: $afterCursor first: $firstPageSize last: $lastPageSize + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { __persist pageInfo { @@ -96,6 +104,10 @@ query getIssues( after: $afterCursor first: $firstPageSize last: $lastPageSize + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { __persist pageInfo { diff --git a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql index fdb0eeb5970..7953dc423b6 100644 --- a/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql +++ b/app/assets/javascripts/issues/list/queries/get_issues_counts.query.graphql @@ -18,6 +18,10 @@ query getIssuesCount( $crmOrganizationId: String $not: NegatedIssueFilterInput $or: UnionedIssueFilterInput + $createdAfter: Time + $createdBefore: Time + $closedAfter: Time + $closedBefore: Time ) { group(fullPath: $fullPath) @skip(if: $isProject) { id @@ -39,6 +43,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -60,6 +68,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -81,6 +93,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -106,6 +122,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -128,6 +148,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } @@ -150,6 +174,10 @@ query getIssuesCount( crmOrganizationId: $crmOrganizationId not: $not or: $or + createdAfter: $createdAfter + createdBefore: $createdBefore + closedAfter: $closedAfter + closedBefore: $closedBefore ) { count } diff --git a/app/assets/javascripts/issues/list/utils.js b/app/assets/javascripts/issues/list/utils.js index 04ce9a4969b..37df0c8f9ff 100644 --- a/app/assets/javascripts/issues/list/utils.js +++ b/app/assets/javascripts/issues/list/utils.js @@ -6,6 +6,7 @@ import { FILTERED_SEARCH_TERM, OPERATOR_NOT, OPERATOR_OR, + OPERATOR_AFTER, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, TOKEN_TYPE_CONFIDENTIAL, @@ -246,8 +247,9 @@ export const isSpecialFilter = (type, data) => { const getFilterType = ({ type, value: { data, operator } }) => { const isUnionedAuthor = type === TOKEN_TYPE_AUTHOR && operator === OPERATOR_OR; const isUnionedLabel = type === TOKEN_TYPE_LABEL && operator === OPERATOR_OR; + const isAfter = operator === OPERATOR_AFTER; - if (isUnionedAuthor || isUnionedLabel) { + if (isUnionedAuthor || isUnionedLabel || isAfter) { return ALTERNATIVE_FILTER; } if (isSpecialFilter(type, data)) { diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue index 37a6ab0122b..4e26750efc2 100644 --- a/app/assets/javascripts/super_sidebar/components/menu_section.vue +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -43,6 +43,7 @@ export default { isExpanded: Boolean(this.expanded || this.item.is_active), isMouseOverSection: false, isMouseOverFlyout: false, + keepFlyoutClosed: false, }; }, computed: { @@ -77,12 +78,17 @@ export default { watch: { isExpanded(newIsExpanded) { this.$emit('collapse-toggle', newIsExpanded); + this.keepFlyoutClosed = !this.newIsExpanded; }, }, methods: { handlePointerover(e) { this.isMouseOverSection = e.pointerType === 'mouse'; }, + handlePointerleave() { + this.isMouseOverSection = false; + this.keepFlyoutClosed = false; + }, }, }; </script> @@ -99,7 +105,7 @@ export default { v-bind="buttonProps" @click="isExpanded = !isExpanded" @pointerover="handlePointerover" - @pointerleave="isMouseOverSection = false" + @pointerleave="handlePointerleave" > <span :class="[isActive ? 'gl-bg-blue-500' : 'gl-bg-transparent']" @@ -124,7 +130,7 @@ export default { <flyout-menu v-if="hasFlyout" - v-show="isMouseOver && !isExpanded" + v-show="isMouseOver && !isExpanded && !keepFlyoutClosed" :target-id="`menu-section-button-${itemId}`" :items="item.items" @mouseover="isMouseOverFlyout = true" diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue new file mode 100644 index 00000000000..a57034b5bb7 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue @@ -0,0 +1,158 @@ +<script> +import * as Sentry from '@sentry/browser'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/constants'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import axios from '~/lib/utils/axios_utils'; +import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue'; +import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; +import { i18n, codeQualityPrefixes } from './constants'; + +const translations = i18n; + +export default { + name: 'WidgetCodeQuality', + components: { + MrWidget, + }, + i18n: translations, + props: { + mr: { + type: Object, + required: true, + }, + }, + data() { + return { + pollingFinished: false, + hasError: false, + collapsedData: {}, + poll: null, + }; + }, + computed: { + summary() { + const { new_errors, resolved_errors } = this.collapsedData; + + if (!this.pollingFinished) { + return { title: i18n.loading }; + } else if (this.hasError) { + return { title: i18n.error }; + } else if ( + this.collapsedData?.new_errors?.length >= 1 && + this.collapsedData?.resolved_errors?.length >= 1 + ) { + return { + title: i18n.improvementAndDegradationCopy( + i18n.findings(resolved_errors, codeQualityPrefixes.fixed), + i18n.findings(new_errors, codeQualityPrefixes.new), + ), + }; + } else if (this.collapsedData?.resolved_errors?.length >= 1) { + return { + title: i18n.singularCopy(i18n.findings(resolved_errors, codeQualityPrefixes.fixed)), + }; + } else if (this.collapsedData?.new_errors?.length >= 1) { + return { title: i18n.singularCopy(i18n.findings(new_errors, codeQualityPrefixes.new)) }; + } + return { title: i18n.noChanges }; + }, + expandedData() { + const fullData = []; + this.collapsedData?.new_errors?.forEach((e) => { + fullData.push({ + text: e.check_name + ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}` + : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, + link: { + href: e.web_url, + text: `${i18n.prependText} ${e.file_path}:${e.line}`, + }, + icon: { + name: SEVERITY_ICONS_MR_WIDGET[e.severity], + }, + }); + }); + + this.collapsedData?.resolved_errors?.forEach((e) => { + fullData.push({ + text: e.check_name + ? `${capitalizeFirstCharacter(e.severity)} - ${e.check_name} - ${e.description}` + : `${capitalizeFirstCharacter(e.severity)} - ${e.description}`, + supportingText: `${i18n.prependText} ${e.file_path}:${e.line}`, + icon: { + name: SEVERITY_ICONS_MR_WIDGET[e.severity], + }, + badge: { + variant: 'neutral', + text: i18n.fixed, + }, + }); + }); + + return fullData; + }, + statusIcon() { + if (this.collapsedData?.new_errors?.length >= 1) { + return EXTENSION_ICONS.warning; + } else if (this.collapsedData?.resolved_errors?.length >= 1) { + return EXTENSION_ICONS.success; + } + return EXTENSION_ICONS.neutral; + }, + shouldCollapse() { + const { new_errors: newErrors, resolved_errors: resolvedErrors } = this.collapsedData; + + if ((newErrors?.length === 0 && resolvedErrors?.length === 0) || this.hasError) { + return false; + } + return true; + }, + apiCodeQualityPath() { + return this.mr.codequalityReportsPath; + }, + }, + methods: { + setCollapsedError(err) { + this.hasError = true; + + Sentry.captureException(err); + }, + fetchCodeQuality() { + return axios + .get(this.apiCodeQualityPath) + .then(({ data, headers = {}, status }) => { + if (status === HTTP_STATUS_OK) { + this.pollingFinished = true; + } + if (data) { + this.collapsedData = data; + } + return { + headers, + status, + data, + }; + }) + .catch((e) => { + return this.setCollapsedError(e); + }); + }, + }, +}; +</script> + +<template> + <mr-widget + :fetch-collapsed-data="fetchCodeQuality" + :error-text="$options.i18n.error" + :has-error="hasError" + :content="expandedData" + :loading-text="$options.i18n.loading" + data-testid="new-cq-widget" + :summary="summary" + :widget-name="$options.name" + :status-icon-name="statusIcon" + :is-collapsible="shouldCollapse" + /> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 52a2f42f8ec..db48e68e8f6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -56,7 +56,6 @@ import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variab import getStateQuery from './queries/get_state.query.graphql'; import getStateSubscription from './queries/get_state.subscription.graphql'; import accessibilityExtension from './extensions/accessibility'; -import codeQualityExtension from './extensions/code_quality'; import testReportExtension from './extensions/test_report'; import ReportWidgetContainer from './components/report_widget_container.vue'; import MrWidgetReadyToMerge from './components/states/new_ready_to_merge.vue'; @@ -215,9 +214,6 @@ export default { return !hasCI && mergeRequestAddCiConfigPath && !isDismissedSuggestPipeline; }, - shouldRenderCodeQuality() { - return this.mr?.codequalityReportsPath; - }, shouldRenderCollaborationStatus() { return this.mr.allowCollaboration && this.mr.isOpen; }, @@ -280,11 +276,6 @@ export default { this.initPostMergeDeploymentsPolling(); } }, - shouldRenderCodeQuality(newVal) { - if (newVal) { - this.registerCodeQualityExtension(); - } - }, shouldShowAccessibilityReport(newVal) { if (newVal) { this.registerAccessibilityExtension(); @@ -534,11 +525,6 @@ export default { registerExtension(accessibilityExtension); } }, - registerCodeQualityExtension() { - if (this.shouldRenderCodeQuality) { - registerExtension(codeQualityExtension); - } - }, registerTestReportExtension() { if (this.shouldRenderTestReport) { registerExtension(testReportExtension); diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 5b98af8c732..39fd3d62c3b 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -17,12 +17,19 @@ export const OPERATOR_NOT = '!='; export const OPERATOR_NOT_TEXT = __('is not one of'); export const OPERATOR_OR = '||'; export const OPERATOR_OR_TEXT = __('is one of'); +export const OPERATOR_AFTER = '≥'; +export const OPERATOR_AFTER_TEXT = __('on or after'); +export const OPERATOR_BEFORE = '<'; +export const OPERATOR_BEFORE_TEXT = __('before'); export const OPERATORS_IS = [{ value: OPERATOR_IS, description: OPERATOR_IS_TEXT }]; export const OPERATORS_NOT = [{ value: OPERATOR_NOT, description: OPERATOR_NOT_TEXT }]; export const OPERATORS_OR = [{ value: OPERATOR_OR, description: OPERATOR_OR_TEXT }]; +export const OPERATORS_AFTER = [{ value: OPERATOR_AFTER, description: OPERATOR_AFTER_TEXT }]; +export const OPERATORS_BEFORE = [{ value: OPERATOR_BEFORE, description: OPERATOR_BEFORE_TEXT }]; export const OPERATORS_IS_NOT = [...OPERATORS_IS, ...OPERATORS_NOT]; export const OPERATORS_IS_NOT_OR = [...OPERATORS_IS, ...OPERATORS_NOT, ...OPERATORS_OR]; +export const OPERATORS_AFTER_BEFORE = [...OPERATORS_AFTER, ...OPERATORS_BEFORE]; export const OPTION_NONE = { value: FILTER_NONE, text: __('None'), title: __('None') }; export const OPTION_ANY = { value: FILTER_ANY, text: __('Any'), title: __('Any') }; @@ -62,6 +69,8 @@ export const TOKEN_TITLE_STATUS = __('Status'); export const TOKEN_TITLE_TARGET_BRANCH = __('Target Branch'); export const TOKEN_TITLE_TYPE = __('Type'); export const TOKEN_TITLE_SEARCH_WITHIN = __('Search Within'); +export const TOKEN_TITLE_CREATED = __('Created date'); +export const TOKEN_TITLE_CLOSED = __('Closed date'); export const TOKEN_TYPE_APPROVED_BY = 'approved-by'; export const TOKEN_TYPE_ASSIGNEE = 'assignee'; @@ -88,3 +97,5 @@ export const TOKEN_TYPE_TARGET_BRANCH = 'target-branch'; export const TOKEN_TYPE_TYPE = 'type'; export const TOKEN_TYPE_WEIGHT = 'weight'; export const TOKEN_TYPE_SEARCH_WITHIN = 'in'; +export const TOKEN_TYPE_CREATED = 'created'; +export const TOKEN_TYPE_CLOSED = 'closed'; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue new file mode 100644 index 00000000000..4446886dc88 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/date_token.vue @@ -0,0 +1,73 @@ +<script> +import { GlDatepicker, GlFilteredSearchToken } from '@gitlab/ui'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +export default { + components: { + GlDatepicker, + GlFilteredSearchToken, + }, + props: { + active: { + type: Boolean, + required: true, + }, + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + selectedDate: null, + }; + }, + methods: { + selectValue(value) { + this.selectedDate = formatDate(value, 'yyyy-mm-dd'); + }, + close(submitValue) { + if (this.selectedDate == null) { + return; + } + + submitValue(this.selectedDate); + }, + handle() { + const listeners = { ...this.$listeners }; + // If we don't remove this, clicking the month/year in the datepicker will deactivate + delete listeners.deactivate; + return listeners; + }, + }, + dataSegmentInputAttributes: { + id: 'glfs-datepicker', + placeholder: 'YYYY-MM-DD', + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + :value="value" + :active="active" + :data-segment-input-attributes="$options.dataSegmentInputAttributes" + v-bind="{ ...$props, ...$attrs }" + v-on="handle()" + > + <template #before-data-segment-input="{ submitValue }"> + <gl-datepicker + class="gl-display-none!" + target="#glfs-datepicker" + :container="null" + @input="selectValue($event)" + @close="close(submitValue)" + /> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 756d9b42e99..79d14b5f2d0 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -9,6 +9,7 @@ import { } from '@gitlab/ui'; import { s__, __ } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; +import Tracking from '~/tracking'; import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue'; import { KEY_EDIT, KEY_WEB_IDE, KEY_GITPOD, KEY_PIPELINE_EDITOR } from './constants'; @@ -28,6 +29,8 @@ export const i18n = { toggleText: __('Edit'), }; +const TRACKING_ACTION_NAME = 'click_consolidated_edit'; + export default { name: 'CEWebIdeLink', components: { @@ -40,6 +43,7 @@ export default { ConfirmForkModal, }, i18n, + mixins: [Tracking.mixin()], props: { isFork: { type: Boolean, @@ -181,9 +185,9 @@ export default { key: KEY_EDIT, text: __('Edit single file'), secondaryText: __('Edit this file only.'), - attrs: { - 'data-track-action': 'click_consolidated_edit', - 'data-track-label': 'edit', + tracking: { + action: TRACKING_ACTION_NAME, + label: 'single_file', }, ...handleOptions, }; @@ -223,9 +227,9 @@ export default { key: KEY_WEB_IDE, text: this.webIdeActionText, secondaryText: this.$options.i18n.webIdeText, - attrs: { - 'data-track-action': 'click_consolidated_edit_ide', - 'data-track-label': 'web_ide', + tracking: { + action: TRACKING_ACTION_NAME, + label: 'web_ide', }, ...handleOptions, }; @@ -253,9 +257,9 @@ export default { text: __('Edit in pipeline editor'), secondaryText, href: this.pipelineEditorUrl, - attrs: { - 'data-track-action': 'click_consolidated_pipeline_editor', - 'data-track-label': 'pipeline_editor', + tracking: { + action: TRACKING_ACTION_NAME, + label: 'pipeline_editor', }, }; }, @@ -277,6 +281,10 @@ export default { key: KEY_GITPOD, text: this.gitpodActionText, secondaryText, + tracking: { + action: TRACKING_ACTION_NAME, + label: 'gitpod', + }, ...handleOptions, }; }, @@ -311,6 +319,7 @@ export default { this[dataKey] = true; }, executeAction(action) { + this.track(action.tracking.action, { label: action.tracking.label }); action.handle?.(); }, }, @@ -335,7 +344,6 @@ export default { <gl-disclosure-dropdown-item v-for="action in actions" :key="action.key" - v-bind="action.attrs" :item="action" :data-qa-selector="`${action.key}_menu_item`" @action="executeAction(action)" diff --git a/app/assets/javascripts/work_items/components/item_state.vue b/app/assets/javascripts/work_items/components/item_state.vue deleted file mode 100644 index 2100cc67c8c..00000000000 --- a/app/assets/javascripts/work_items/components/item_state.vue +++ /dev/null @@ -1,71 +0,0 @@ -<script> -import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { STATE_OPEN, STATE_CLOSED } from '../constants'; - -export default { - i18n: { - status: __('Status'), - }, - states: [ - { - value: STATE_OPEN, - text: __('Open'), - }, - { - value: STATE_CLOSED, - text: __('Closed'), - }, - ], - components: { - GlFormGroup, - GlFormSelect, - }, - props: { - state: { - type: String, - required: true, - }, - disabled: { - type: Boolean, - required: false, - default: false, - }, - }, - computed: { - currentState() { - return this.$options.states[this.state]; - }, - }, - methods: { - setState(newState) { - if (newState !== this.state) { - this.$emit('changed', newState); - } - }, - }, - labelId: 'work-item-state-select', -}; -</script> - -<template> - <gl-form-group - :label="$options.i18n.status" - :label-for="$options.labelId" - label-cols="3" - label-cols-lg="2" - label-class="gl-pb-0! gl-overflow-wrap-break work-item-field-label" - class="gl-align-items-center" - > - <gl-form-select - :id="$options.labelId" - :value="state" - :options="$options.states" - :disabled="disabled" - data-testid="work-item-state-select" - class="hide-unfocused-input-decoration work-item-field-value gl-w-auto gl-pl-4 gl-my-1" - :class="{ 'gl-bg-transparent! gl-cursor-text!': disabled }" - @change="setState" - /> - </gl-form-group> -</template> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index c330eccb186..66ad3d50287 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -265,6 +265,7 @@ export default { :comment-button-text="commentButtonText" @submitForm="updateWorkItem" @cancelEditing="cancelEditing" + @error="$emit('error', $event)" /> <textarea v-else diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue index c317ec48732..b143c529014 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -1,22 +1,13 @@ <script> import { GlButton, GlFormCheckbox, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { s__, __, sprintf } from '~/locale'; +import { s__, __ } from '~/locale'; import Tracking from '~/tracking'; -import { - I18N_WORK_ITEM_ERROR_UPDATING, - sprintfWorkItem, - STATE_OPEN, - STATE_EVENT_REOPEN, - STATE_EVENT_CLOSE, - TRACKING_CATEGORY_SHOW, - i18n, -} from '~/work_items/constants'; +import { STATE_OPEN, TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; export default { i18n: { @@ -25,6 +16,7 @@ export default { 'Notes|Internal notes are only visible to members with the role of Reporter or higher', ), addInternalNote: __('Add internal note'), + cancelButtonText: __('Cancel'), }, constantOptions: { markdownDocsPath: helpPagePath('user/markdown'), @@ -34,6 +26,7 @@ export default { MarkdownEditor, GlFormCheckbox, GlIcon, + WorkItemStateToggleButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -123,14 +116,6 @@ export default { isWorkItemOpen() { return this.workItemState === STATE_OPEN; }, - toggleWorkItemStateText() { - return this.isWorkItemOpen - ? sprintf(__('Close %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }) - : sprintf(__('Reopen %{workItemType}'), { workItemType: this.workItemType.toLowerCase() }); - }, - cancelButtonText() { - return this.isNewDiscussion ? this.toggleWorkItemStateText : __('Cancel'); - }, commentButtonTextComputed() { return this.isNoteInternal ? this.$options.i18n.addInternalNote : this.commentButtonText; }, @@ -166,48 +151,6 @@ export default { this.$emit('cancelEditing'); clearDraft(this.autosaveKey); }, - async toggleWorkItemState() { - const input = { - id: this.workItemId, - stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, - }; - - this.updateInProgress = true; - - try { - this.track('updated_state'); - - const { mutation, variables } = getUpdateWorkItemMutation({ - workItemParentId: this.workItemParentId, - input, - }); - - const { data } = await this.$apollo.mutate({ - mutation, - variables, - }); - - const errors = data.workItemUpdate?.errors; - - if (errors?.length) { - this.$emit('error', i18n.updateError); - } - } catch (error) { - const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType); - - this.$emit('error', msg); - Sentry.captureException(error); - } - - this.updateInProgress = false; - }, - cancelButtonAction() { - if (this.isNewDiscussion) { - this.toggleWorkItemState(); - } else { - this.cancelEditing(); - } - }, }, }; </script> @@ -257,13 +200,23 @@ export default { @click="$emit('submitForm', { commentText, isNoteInternal })" >{{ commentButtonTextComputed }} </gl-button> + <work-item-state-toggle-button + v-if="isNewDiscussion" + class="gl-ml-3" + :work-item-id="workItemId" + :work-item-state="workItemState" + :work-item-type="workItemType" + can-update + @error="$emit('error', $event)" + /> <gl-button + v-else data-testid="cancel-button" category="primary" class="gl-ml-3" :loading="updateInProgress" - @click="cancelButtonAction" - >{{ cancelButtonText }} + @click="cancelEditing" + >{{ $options.i18n.cancelButtonText }} </gl-button> </form> </div> diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index c727075eaac..139f0f7919c 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -11,7 +11,6 @@ import { WIDGET_TYPE_START_AND_DUE_DATE, WIDGET_TYPE_WEIGHT, } from '../constants'; -import WorkItemState from './work_item_state.vue'; import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; import WorkItemLabels from './work_item_labels.vue'; @@ -23,7 +22,6 @@ export default { WorkItemMilestone, WorkItemAssignees, WorkItemDueDate, - WorkItemState, WorkItemWeight: () => import('ee_component/work_items/components/work_item_weight.vue'), WorkItemProgress: () => import('ee_component/work_items/components/work_item_progress.vue'), WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'), @@ -97,12 +95,6 @@ export default { <template> <div class="work-item-attributes-wrapper"> - <work-item-state - :work-item="workItem" - :work-item-parent-id="workItemParentId" - :can-update="canUpdate" - @error="$emit('error', $event)" - /> <work-item-assignees v-if="workItemAssignees" :can-update="canUpdate" diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue index 78a86aa49a4..af5293ebe91 100644 --- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue +++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue @@ -2,6 +2,7 @@ import { GlAvatarLink, GlSprintf } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import WorkItemStateBadge from '~/work_items/components/work_item_state_badge.vue'; import workItemByIidQuery from '../graphql/work_item_by_iid.query.graphql'; export default { @@ -9,6 +10,7 @@ export default { GlAvatarLink, GlSprintf, TimeAgoTooltip, + WorkItemStateBadge, }, inject: ['fullPath'], props: { @@ -31,6 +33,9 @@ export default { authorId() { return getIdFromGraphQLId(this.author.id); }, + workItemState() { + return this.workItem?.state; + }, }, apollo: { workItem: { @@ -54,7 +59,8 @@ export default { <template> <div class="gl-mb-3"> - <span data-testid="work-item-created"> + <work-item-state-badge v-if="workItemState" :work-item-state="workItemState" /> + <span data-testid="work-item-created" class="gl-vertical-align-middle"> <gl-sprintf v-if="author.name" :message="__('Created %{timeAgo} by %{author}')"> <template #timeAgo> <time-ago-tooltip :time="createdAt" /> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index dc4065f9812..238df236b6b 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -49,6 +49,7 @@ import WorkItemDescription from './work_item_description.vue'; import WorkItemNotes from './work_item_notes.vue'; import WorkItemDetailModal from './work_item_detail_modal.vue'; import WorkItemAwardEmoji from './work_item_award_emoji.vue'; +import WorkItemStateToggleButton from './work_item_state_toggle_button.vue'; export default { i18n, @@ -57,6 +58,7 @@ export default { }, isLoggedIn: isLoggedIn(), components: { + WorkItemStateToggleButton, GlAlert, GlBadge, GlButton, @@ -445,6 +447,14 @@ export default { class="gl-mr-3 gl-cursor-help" >{{ __('Confidential') }}</gl-badge > + <work-item-state-toggle-button + v-if="canUpdate" + :work-item-id="workItem.id" + :work-item-state="workItem.state" + :work-item-parent-id="workItemParentId" + :work-item-type="workItemType" + @error="updateError = $event" + /> <work-item-todos v-if="showWorkItemCurrentUserTodos" :work-item-id="workItem.id" diff --git a/app/assets/javascripts/work_items/components/work_item_state_badge.vue b/app/assets/javascripts/work_items/components/work_item_state_badge.vue new file mode 100644 index 00000000000..1d1bc7352b1 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_state_badge.vue @@ -0,0 +1,41 @@ +<script> +import { GlBadge } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { STATE_OPEN } from '../constants'; + +export default { + components: { + GlBadge, + }, + props: { + workItemState: { + type: String, + required: true, + }, + }, + computed: { + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + stateText() { + return this.isWorkItemOpen ? __('Open') : __('Closed'); + }, + workItemStateIcon() { + return this.isWorkItemOpen ? 'issue-open-m' : 'issue-close'; + }, + workItemStateVariant() { + return this.isWorkItemOpen ? 'success' : 'info'; + }, + }, +}; +</script> + +<template> + <gl-badge + :icon="workItemStateIcon" + :variant="workItemStateVariant" + class="gl-mr-2 gl-vertical-align-middle" + > + {{ stateText }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_state.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue index 3880ae25c8c..0ea30845466 100644 --- a/app/assets/javascripts/work_items/components/work_item_state.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue @@ -1,26 +1,35 @@ <script> +import { GlButton } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import Tracking from '~/tracking'; +import { __, sprintf } from '~/locale'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_UPDATING, STATE_OPEN, - STATE_CLOSED, STATE_EVENT_CLOSE, STATE_EVENT_REOPEN, TRACKING_CATEGORY_SHOW, } from '../constants'; -import { getUpdateWorkItemMutation } from './update_work_item'; -import ItemState from './item_state.vue'; export default { components: { - ItemState, + GlButton, }, mixins: [Tracking.mixin()], props: { - workItem: { - type: Object, + workItemState: { + type: String, + required: true, + }, + workItemId: { + type: String, + required: true, + }, + workItemType: { + type: String, required: true, }, workItemParentId: { @@ -28,11 +37,6 @@ export default { required: false, default: null, }, - canUpdate: { - type: Boolean, - required: false, - default: false, - }, }, data() { return { @@ -40,8 +44,16 @@ export default { }; }, computed: { - workItemType() { - return this.workItem.workItemType?.name; + isWorkItemOpen() { + return this.workItemState === STATE_OPEN; + }, + toggleWorkItemStateText() { + const baseText = this.isWorkItemOpen + ? __('Close %{workItemType}') + : __('Reopen %{workItemType}'); + return capitalizeFirstCharacter( + sprintf(baseText, { workItemType: this.workItemType.toLowerCase() }), + ); }, tracking() { return { @@ -52,25 +64,10 @@ export default { }, }, methods: { - updateWorkItemState(newState) { - const stateEventMap = { - [STATE_OPEN]: STATE_EVENT_REOPEN, - [STATE_CLOSED]: STATE_EVENT_CLOSE, - }; - - const stateEvent = stateEventMap[newState]; - - this.updateWorkItem(stateEvent); - }, - - async updateWorkItem(updatedState) { - if (!updatedState) { - return; - } - + async updateWorkItem() { const input = { - id: this.workItem.id, - stateEvent: updatedState, + id: this.workItemId, + stateEvent: this.isWorkItemOpen ? STATE_EVENT_CLOSE : STATE_EVENT_REOPEN, }; this.updateInProgress = true; @@ -107,10 +104,10 @@ export default { </script> <template> - <item-state - v-if="workItem.state" - :state="workItem.state" - :disabled="updateInProgress || !canUpdate" - @changed="updateWorkItemState" - /> + <gl-button + :loading="updateInProgress" + data-testid="work-item-state-toggle" + @click="updateWorkItem" + >{{ toggleWorkItemStateText }}</gl-button + > </template> diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index bbd8d2dd0a9..6a58a453a19 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -195,7 +195,8 @@ module IssuesHelper is_signed_in: current_user.present?.to_s, jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'), rss_path: url_for(safe_params.merge(rss_url_options)), - sign_in_path: new_user_session_path + sign_in_path: new_user_session_path, + has_issue_date_filter_feature: Feature.enabled?(:issue_date_filter, namespace) } end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 77fb7238298..895ff937abe 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -4,6 +4,7 @@ module Ci class Bridge < Ci::Processable include Ci::Contextable include Ci::Metadatable + include Ci::Deployable include Importable include AfterCommitQueue include Ci::HasRef @@ -180,20 +181,6 @@ module Ci false end - def outdated_deployment? - false - end - - def expanded_environment_name - end - - def persisted_environment - end - - def deployment_job? - false - end - def execute_hooks raise NotImplementedError end diff --git a/app/presenters/packages/npm/package_presenter.rb b/app/presenters/packages/npm/package_presenter.rb deleted file mode 100644 index 42f61182ab8..00000000000 --- a/app/presenters/packages/npm/package_presenter.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Packages - module Npm - class PackagePresenter - def initialize(metadata) - @metadata = metadata - end - - def name - metadata[:name] - end - - def versions - metadata[:versions] - end - - def dist_tags - metadata[:dist_tags] - end - - private - - attr_reader :metadata - end - end -end diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb index e3cbba6de23..14ea09f17a0 100644 --- a/app/services/ci/retry_job_service.rb +++ b/app/services/ci/retry_job_service.rb @@ -39,7 +39,7 @@ module Ci ::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job) - ::Deployments::CreateForBuildService.new.execute(new_job) + ::Deployments::CreateForJobService.new.execute(new_job) ::MergeRequests::AddTodoWhenBuildFailsService .new(project: project) diff --git a/app/services/deployments/create_for_build_service.rb b/app/services/deployments/create_for_job_service.rb index b58aa50a66f..fe632794d99 100644 --- a/app/services/deployments/create_for_build_service.rb +++ b/app/services/deployments/create_for_job_service.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true module Deployments - # This class creates a deployment record for a build (a pipeline job). - class CreateForBuildService + # This class creates a deployment record for a pipeline job. + class CreateForJobService DeploymentCreationError = Class.new(StandardError) def execute(build) - return unless build.instance_of?(::Ci::Build) && build.persisted_environment.present? + return unless build.is_a?(::Ci::Processable) && build.persisted_environment.present? environment = build.actual_persisted_environment @@ -37,7 +37,7 @@ module Deployments # non-environment job. return unless deployment.valid? && deployment.environment.persisted? - if cluster = deployment.environment.deployment_platform&.cluster + if cluster = deployment.environment.deployment_platform&.cluster # rubocop: disable Lint/AssignmentInCondition # double write cluster_id until 12.9: https://gitlab.com/gitlab-org/gitlab/issues/202628 deployment.cluster_id = cluster.id deployment.deployment_cluster = ::DeploymentCluster.new( diff --git a/app/services/environments/create_for_build_service.rb b/app/services/environments/create_for_job_service.rb index ff4da212002..2a225c1ac91 100644 --- a/app/services/environments/create_for_build_service.rb +++ b/app/services/environments/create_for_job_service.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true module Environments - # This class creates an environment record for a build (a pipeline job). - class CreateForBuildService + # This class creates an environment record for a pipeline job. + class CreateForJobService def execute(build) - return unless build.instance_of?(::Ci::Build) && build.has_environment_keyword? + return unless build.is_a?(::Ci::Processable) && build.has_environment_keyword? environment = to_resource(build) diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb index 52a4f075cf0..067dbb7492f 100644 --- a/app/workers/ci/initial_pipeline_process_worker.rb +++ b/app/workers/ci/initial_pipeline_process_worker.rb @@ -32,7 +32,7 @@ module Ci end def create_deployment(build) - ::Deployments::CreateForBuildService.new.execute(build) + ::Deployments::CreateForJobService.new.execute(build) end end end diff --git a/config/events/20211215022206_default_web_ide_click_consolidated_edit_ide.yml b/config/events/20211215022206_default_web_ide_click_consolidated_edit_ide.yml deleted file mode 100644 index 45b621cb404..00000000000 --- a/config/events/20211215022206_default_web_ide_click_consolidated_edit_ide.yml +++ /dev/null @@ -1,20 +0,0 @@ -description: "Edit multiple files with Web IDE" -category: default -action: click_consolidated_edit_ide -label_description: "`web_ide`" -property_description: "" -value_description: "" -extra_properties: -identifiers: -product_section: dev -product_stage: create -product_group: group::editor -milestone: "14.1" -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64179 -distributions: -- ce -- ee -tiers: -- free -- premium -- ultimate diff --git a/config/events/20211215022206_default_edit_click_consolidated_edit.yml b/config/events/20230727180523_default_click_consolidated_edit.yml index ffa88b0a384..06da6ac1257 100644 --- a/config/events/20211215022206_default_edit_click_consolidated_edit.yml +++ b/config/events/20230727180523_default_click_consolidated_edit.yml @@ -1,16 +1,17 @@ -description: "Edit a single file" +--- +description: "Selects an editor in the Edit dropdown menu" category: default action: click_consolidated_edit -label_description: "`edit`" -property_description: "" -value_description: "" +label_description: "The editor selected in the Edit dropdown menu" +property_description: +value_description: extra_properties: identifiers: product_section: dev product_stage: create -product_group: group::editor -milestone: "14.1" -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64179 +product_group: group::ide +milestone: "16.3" +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/127163 distributions: - ce - ee @@ -18,3 +19,4 @@ tiers: - free - premium - ultimate + diff --git a/config/feature_flags/development/issue_date_filter.yml b/config/feature_flags/development/issue_date_filter.yml new file mode 100644 index 00000000000..1b6cb2c4bed --- /dev/null +++ b/config/feature_flags/development/issue_date_filter.yml @@ -0,0 +1,8 @@ +--- +name: issue_date_filter +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120160 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/420173 +milestone: '16.2' +type: development +group: group::project management +default_enabled: false diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 31b058e9e85..bc5a2ec1d24 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -565,6 +565,8 @@ - 1 - - search_wiki_elastic_delete_group_wiki - 1 +- - search_zoekt_delete_project + - 1 - - search_zoekt_namespace_indexer - 1 - - security_auto_fix diff --git a/db/post_migrate/20230724123547_cleanup_conversion_big_int_ci_build_needs_self_managed.rb b/db/post_migrate/20230724123547_cleanup_conversion_big_int_ci_build_needs_self_managed.rb new file mode 100644 index 00000000000..5fbb5bd47cd --- /dev/null +++ b/db/post_migrate/20230724123547_cleanup_conversion_big_int_ci_build_needs_self_managed.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CleanupConversionBigIntCiBuildNeedsSelfManaged < Gitlab::Database::Migration[2.1] + include Gitlab::Database::MigrationHelpers::ConvertToBigint + + enable_lock_retries! + + TABLE = :ci_build_needs + + def up + return if should_skip? + return unless column_exists?(TABLE, :id_convert_to_bigint) + + # rubocop:disable Migration/WithLockRetriesDisallowedMethod + with_lock_retries do + cleanup_conversion_of_integer_to_bigint(TABLE, :id) + end + # rubocop:enable Migration/WithLockRetriesDisallowedMethod + end + + def down + return if should_skip? + return if column_exists?(TABLE, :id_convert_to_bigint) + + restore_conversion_of_integer_to_bigint(TABLE, :id) + end + + def should_skip? + com_or_dev_or_test_but_not_jh? + end +end diff --git a/db/post_migrate/20230731100513_add_index_on_vulnerability_reads_for_filtering.rb b/db/post_migrate/20230731100513_add_index_on_vulnerability_reads_for_filtering.rb new file mode 100644 index 00000000000..bbed1e60d35 --- /dev/null +++ b/db/post_migrate/20230731100513_add_index_on_vulnerability_reads_for_filtering.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddIndexOnVulnerabilityReadsForFiltering < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + INDEX_NAME = "idx_vuln_reads_for_filtering" + + def up + add_concurrent_index( + :vulnerability_reads, + %i[project_id state dismissal_reason severity vulnerability_id], + order: { severity: :desc, vulnerability_id: "DESC NULLS LAST" }, + name: INDEX_NAME + ) + end + + def down + remove_concurrent_index_by_name( + :vulnerability_reads, + INDEX_NAME + ) + end +end diff --git a/db/schema_migrations/20230724123547 b/db/schema_migrations/20230724123547 new file mode 100644 index 00000000000..b29f625de7e --- /dev/null +++ b/db/schema_migrations/20230724123547 @@ -0,0 +1 @@ +658cb25d5add4ad4e26d7baef6759f8512fa0244dd347b0522ad75ac496c9965
\ No newline at end of file diff --git a/db/schema_migrations/20230731100513 b/db/schema_migrations/20230731100513 new file mode 100644 index 00000000000..4f83eaeb294 --- /dev/null +++ b/db/schema_migrations/20230731100513 @@ -0,0 +1 @@ +a85f3b493021cc27079dc07fe0ba5f11eeeca9798cf6ccdc60f7f7f7eae049af
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index ba104d2623d..2a297259356 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -30191,6 +30191,8 @@ CREATE UNIQUE INDEX idx_uniq_analytics_dashboards_pointers_on_project_id ON anal CREATE INDEX idx_user_details_on_provisioned_by_group_id_user_id ON user_details USING btree (provisioned_by_group_id, user_id); +CREATE INDEX idx_vuln_reads_for_filtering ON vulnerability_reads USING btree (project_id, state, dismissal_reason, severity DESC, vulnerability_id DESC NULLS LAST); + CREATE UNIQUE INDEX idx_vuln_signatures_uniqueness_signature_sha ON vulnerability_finding_signatures USING btree (finding_id, algorithm_type, signature_sha); CREATE INDEX idx_vulnerabilities_on_project_id_and_id_active_cis_dft_branch ON vulnerabilities USING btree (project_id, id) WHERE ((report_type = 7) AND (state = ANY (ARRAY[1, 4])) AND (present_on_default_branch IS TRUE)); diff --git a/doc/.vale/gitlab/CommandStringsQuoted.yml b/doc/.vale/gitlab/CommandStringsQuoted.yml new file mode 100644 index 00000000000..531595ed10d --- /dev/null +++ b/doc/.vale/gitlab/CommandStringsQuoted.yml @@ -0,0 +1,14 @@ +--- +# Error: gitlab.CommandStringsQuoted +# +# Ensures all code blocks wrap URL strings in quotation marks. +# +# For a list of all options, see https://vale.sh/docs/topics/styles/ +extends: existence +message: "For the command example, use double quotes around the URL: %s" +link: https://docs.gitlab.com/ee/development/documentation/restful_api_styleguide.html#curl-commands +level: error +scope: raw +nonword: true +tokens: + - '(curl|--url)[^"\]\n]+?https?:\/\/[^ \n]*' diff --git a/doc/.vale/gitlab/CurlStringsQuoted.yml b/doc/.vale/gitlab/CurlStringsQuoted.yml deleted file mode 100644 index efe7aa23832..00000000000 --- a/doc/.vale/gitlab/CurlStringsQuoted.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -# Error: gitlab.CurlStringsQuoted -# -# Ensures all code blocks using `curl` wrap URL strings in quotation marks. -# -# For a list of all options, see https://vale.sh/docs/topics/styles/ -extends: existence -message: "For the cURL example, use double quotes around the URL: %s" -link: https://docs.gitlab.com/ee/development/documentation/restful_api_styleguide.html#curl-commands -level: error -scope: code -raw: - - 'curl [^"]+://.*' diff --git a/doc/administration/settings/sign_up_restrictions.md b/doc/administration/settings/sign_up_restrictions.md index f213794eb75..719f205ba32 100644 --- a/doc/administration/settings/sign_up_restrictions.md +++ b/doc/administration/settings/sign_up_restrictions.md @@ -80,28 +80,32 @@ The following settings are available: ## User cap -> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/4315) in GitLab 13.7. -> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/292600) in GitLab 13.9. +The user cap is the maximum number of billable users who can sign up or be added to a subscription +without administrator approval. After the user cap is reached, users who sign up or are +added must be [approved](../../administration/moderate_users.md#approve-or-reject-a-user-sign-up) +by an administrator. Users can use their account only after they have been approved by an administrator. -When the number of billable users reaches the user cap, any user who is added or requests access must be -[approved](../../administration/moderate_users.md#approve-or-reject-a-user-sign-up) by an administrator before they can start using -their account. - -If an administrator [increases](#set-the-user-cap-number) or [removes](#remove-the-user-cap) the -user cap, the users in pending approval state are automatically approved in a background job. +If an administrator increases or removes the user cap, users pending approval are automatically approved. NOTE: -The amount of billable users [is updated once a day](../../subscriptions/self_managed/index.md#billable-users). -This means the user cap might apply only retrospectively after the cap has already been exceeded. -To ensure the cap is enabled immediately, set it to a low value below the current number of -billable users, for example: `1`. +For instances that use LDAP or OmniAuth, when [administrator approval for new sign-ups](#require-administrator-approval-for-new-sign-ups) +is enabled or disabled, downtime might occur due to changes in the Rails configuration. +You can set a user cap to enforce approvals for new users. To ensure the user cap applies immediately, set the cap to a value below the current number of billable users (for example, `1`). + +### Set a user cap + +Set a user cap to restrict the number of users who can sign up without administrator approval. -On instances that use LDAP or OmniAuth, enabling and disabling -[administrator approval for new sign ups](#require-administrator-approval-for-new-sign-ups) -involves changing the Rails configuration, and may require downtime. -User cap can be used instead. As noted above, set the cap to value that ensures it is enforced immediately. +The number of [billable users](../../subscriptions/self_managed/index.md#billable-users) is updated once a day. +The user cap might apply only retrospectively after the cap has already been exceeded. +To ensure the cap is enabled immediately, set the cap to a value below the current number of +billable users (for example, `1`). -### Set the user cap number +Prerequisite: + +- You must be an administrator. + +To set a user cap: 1. On the left sidebar, expand the top-most chevron (**{chevron-down}**). 1. Select **Admin Area**. @@ -110,9 +114,18 @@ User cap can be used instead. As noted above, set the cap to value that ensures 1. Enter a number in **User cap**. 1. Select **Save changes**. -New user sign ups are subject to the user cap restriction. +### Remove the user cap + +Remove the user cap so that the number of new users who can sign up without +administrator approval is not restricted. -## Remove the user cap +After you remove the user cap, users pending approval are automatically approved. + +Prerequisite: + +- You must be an administrator. + +To remove the user cap: 1. On the left sidebar, expand the top-most chevron (**{chevron-down}**). 1. Select **Admin Area**. @@ -121,9 +134,6 @@ New user sign ups are subject to the user cap restriction. 1. Remove the number from **User cap**. 1. Select **Save changes**. -New users sign ups are not subject to the user cap restriction. Users in pending approval state are -automatically approved in a background job. - ## Minimum password length limit > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/20661) in GitLab 12.6 diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 21c984a135f..a4f548d5e2a 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -783,6 +783,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | ---- | ---- | ----------- | | <a id="queryvulnerabilitiesclusteragentid"></a>`clusterAgentId` | [`[ClustersAgentID!]`](#clustersagentid) | Filter vulnerabilities by `cluster_agent_id`. Vulnerabilities with a `reportType` of `cluster_image_scanning` are only included with this filter. | | <a id="queryvulnerabilitiesclusterid"></a>`clusterId` | [`[ClustersClusterID!]`](#clustersclusterid) | Filter vulnerabilities by `cluster_id`. Vulnerabilities with a `reportType` of `cluster_image_scanning` are only included with this filter. | +| <a id="queryvulnerabilitiesdismissalreason"></a>`dismissalReason` | [`[VulnerabilityDismissalReason!]`](#vulnerabilitydismissalreason) | Filter by dismissal reason. Only dismissed Vulnerabilities will be included with the filter. | | <a id="queryvulnerabilitieshasissues"></a>`hasIssues` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have linked issues. | | <a id="queryvulnerabilitieshasresolution"></a>`hasResolution` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have been resolved on default branch. | | <a id="queryvulnerabilitiesimage"></a>`image` | [`[String!]`](#string) | Filter vulnerabilities by location image. When this filter is present, the response only matches entries for a `reportType` that includes `container_scanning`, `cluster_image_scanning`. | @@ -17249,6 +17250,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | ---- | ---- | ----------- | | <a id="groupvulnerabilitiesclusteragentid"></a>`clusterAgentId` | [`[ClustersAgentID!]`](#clustersagentid) | Filter vulnerabilities by `cluster_agent_id`. Vulnerabilities with a `reportType` of `cluster_image_scanning` are only included with this filter. | | <a id="groupvulnerabilitiesclusterid"></a>`clusterId` | [`[ClustersClusterID!]`](#clustersclusterid) | Filter vulnerabilities by `cluster_id`. Vulnerabilities with a `reportType` of `cluster_image_scanning` are only included with this filter. | +| <a id="groupvulnerabilitiesdismissalreason"></a>`dismissalReason` | [`[VulnerabilityDismissalReason!]`](#vulnerabilitydismissalreason) | Filter by dismissal reason. Only dismissed Vulnerabilities will be included with the filter. | | <a id="groupvulnerabilitieshasissues"></a>`hasIssues` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have linked issues. | | <a id="groupvulnerabilitieshasresolution"></a>`hasResolution` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have been resolved on default branch. | | <a id="groupvulnerabilitiesimage"></a>`image` | [`[String!]`](#string) | Filter vulnerabilities by location image. When this filter is present, the response only matches entries for a `reportType` that includes `container_scanning`, `cluster_image_scanning`. | @@ -22068,6 +22070,7 @@ four standard [pagination arguments](#connection-pagination-arguments): | ---- | ---- | ----------- | | <a id="projectvulnerabilitiesclusteragentid"></a>`clusterAgentId` | [`[ClustersAgentID!]`](#clustersagentid) | Filter vulnerabilities by `cluster_agent_id`. Vulnerabilities with a `reportType` of `cluster_image_scanning` are only included with this filter. | | <a id="projectvulnerabilitiesclusterid"></a>`clusterId` | [`[ClustersClusterID!]`](#clustersclusterid) | Filter vulnerabilities by `cluster_id`. Vulnerabilities with a `reportType` of `cluster_image_scanning` are only included with this filter. | +| <a id="projectvulnerabilitiesdismissalreason"></a>`dismissalReason` | [`[VulnerabilityDismissalReason!]`](#vulnerabilitydismissalreason) | Filter by dismissal reason. Only dismissed Vulnerabilities will be included with the filter. | | <a id="projectvulnerabilitieshasissues"></a>`hasIssues` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have linked issues. | | <a id="projectvulnerabilitieshasresolution"></a>`hasResolution` | [`Boolean`](#boolean) | Returns only the vulnerabilities which have been resolved on default branch. | | <a id="projectvulnerabilitiesimage"></a>`image` | [`[String!]`](#string) | Filter vulnerabilities by location image. When this filter is present, the response only matches entries for a `reportType` that includes `container_scanning`, `cluster_image_scanning`. | diff --git a/doc/api/projects.md b/doc/api/projects.md index f55ba5516f9..2ba1332e339 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1487,7 +1487,7 @@ curl --request POST --header "PRIVATE-TOKEN: <your-token>" \ --header "Content-Type: application/json" --data '{ "name": "new_project", "description": "New Project", "path": "new_project", "namespace_id": "42", "initialize_with_readme": "true"}' \ - --url 'https://gitlab.example.com/api/v4/projects/' + --url "https://gitlab.example.com/api/v4/projects/" ``` | Attribute | Type | Required | Description | @@ -1671,7 +1671,7 @@ For example, to toggle the setting for ```shell curl --request PUT --header "PRIVATE-TOKEN: <your-token>" \ - --url 'https://gitlab.com/api/v4/projects/<your-project-ID>' \ + --url "https://gitlab.com/api/v4/projects/<your-project-ID>" \ --data "shared_runners_enabled=true" # to turn off: "shared_runners_enabled=false" ``` diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 5e8f2b36620..97b4b6a428b 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -37,7 +37,7 @@ the Docker commands, but needs permission to do so. ```shell sudo gitlab-runner register -n \ - --url https://gitlab.com/ \ + --url "https://gitlab.com/" \ --registration-token REGISTRATION_TOKEN \ --executor shell \ --description "My Runner" @@ -117,7 +117,7 @@ To use Docker-in-Docker with TLS enabled: ```shell sudo gitlab-runner register -n \ - --url https://gitlab.com/ \ + --url "https://gitlab.com/" \ --registration-token REGISTRATION_TOKEN \ --executor docker \ --description "My Docker Runner" \ @@ -381,7 +381,7 @@ To mount `/var/run/docker.sock` while registering your runner, include the follo ```shell sudo gitlab-runner register -n \ - --url https://gitlab.com/ \ + --url "https://gitlab.com/" \ --registration-token REGISTRATION_TOKEN \ --executor docker \ --description "My Docker Runner" \ diff --git a/doc/development/database/database_lab.md b/doc/development/database/database_lab.md index 357133d8bca..7edb8ab4de5 100644 --- a/doc/development/database/database_lab.md +++ b/doc/development/database/database_lab.md @@ -146,13 +146,13 @@ the `pgai` Gem: 1. To get started, you need to gather some values from the [Postgres.ai instances page](https://console.postgres.ai/gitlab/instances): - 1. Navigate to the instance that you want to configure and the on right side of the screen. + 1. Go to the instance that you want to configure and the on right side of the screen. 1. Under **Connection**, select **Connect**. The menu might be collapsed. - A pop-up with everything that's needed for configuration appears, using this format: + A dialog with everything that's needed for configuration appears, using this format: ```shell - dblab init --url http://127.0.0.1:1234 --token TOKEN --environment-id <environment-id> + dblab init --url "http://127.0.0.1:1234" --token TOKEN --environment-id <environment-id> ``` ```shell diff --git a/doc/tutorials/install_gitlab_single_node/index.md b/doc/tutorials/install_gitlab_single_node/index.md index cdc05c1f1a9..5ed98ccbefc 100644 --- a/doc/tutorials/install_gitlab_single_node/index.md +++ b/doc/tutorials/install_gitlab_single_node/index.md @@ -238,7 +238,7 @@ Now that your server is set up, install GitLab: 1. Add the GitLab package repository and install the package: ```shell - curl https://packages.gitlab.com/install/repositories/gitlab/gitlab-ee/script.deb.sh | sudo bash + curl "https://packages.gitlab.com/install/repositories/gitlab/gitlab-ee/script.deb.sh" | sudo bash ``` To see the contents of the script, visit <https://packages.gitlab.com/gitlab/gitlab-ee/install>. diff --git a/doc/user/project/ml/experiment_tracking/index.md b/doc/user/project/ml/experiment_tracking/index.md index 4e9e736f067..4f4f05be126 100644 --- a/doc/user/project/ml/experiment_tracking/index.md +++ b/doc/user/project/ml/experiment_tracking/index.md @@ -71,7 +71,7 @@ on how to use GitLab as a backend for the MLflow Client. To list the current active experiments, either go to `https/-/ml/experiments` or: 1. On the left sidebar, at the top, select **Search GitLab** (**{search}**) to find your project. -1. Select **Deploy > Model experiments**. +1. Select **Analyze > Model experiments**. 1. To display all candidates that have been logged, along with their metrics, parameters, and metadata, select an experiment. 1. To display details for a candidate, select **Details**. diff --git a/lib/api/concerns/packages/npm_endpoints.rb b/lib/api/concerns/packages/npm_endpoints.rb index ec20440f013..a045a3d4828 100644 --- a/lib/api/concerns/packages/npm_endpoints.rb +++ b/lib/api/concerns/packages/npm_endpoints.rb @@ -96,9 +96,8 @@ module API track_package_event(:list_tags, :npm, project: project, namespace: project.namespace) - metadata = generate_metadata_service(packages).execute(only_dist_tags: true) - present ::Packages::Npm::PackagePresenter.new(metadata), - with: ::API::Entities::NpmPackageTag + metadata = generate_metadata_service(packages).execute(only_dist_tags: true).payload + present metadata, with: ::API::Entities::NpmPackageTag end params do @@ -229,8 +228,8 @@ module API enqueue_sync_metadata_cache_worker(project, package_name) end - present ::Packages::Npm::PackagePresenter.new(generate_metadata_service(packages).execute), - with: ::API::Entities::NpmPackage + metadata = generate_metadata_service(packages).execute.payload + present metadata, with: ::API::Entities::NpmPackage end end diff --git a/lib/gitlab/ci/pipeline/chain/ensure_environments.rb b/lib/gitlab/ci/pipeline/chain/ensure_environments.rb index ebea6a538ef..3ff9aff68ac 100644 --- a/lib/gitlab/ci/pipeline/chain/ensure_environments.rb +++ b/lib/gitlab/ci/pipeline/chain/ensure_environments.rb @@ -16,7 +16,7 @@ module Gitlab private def ensure_environment(build) - ::Environments::CreateForBuildService.new.execute(build) + ::Environments::CreateForJobService.new.execute(build) end end end diff --git a/lib/gitlab/redis/cache.rb b/lib/gitlab/redis/cache.rb index 60944268f91..6121555e435 100644 --- a/lib/gitlab/redis/cache.rb +++ b/lib/gitlab/redis/cache.rb @@ -8,9 +8,14 @@ module Gitlab class << self # Full list of options: # https://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html#method-c-new + # pool argument event not documented in the link above is handled by RedisCacheStore see: + # https://github.com/rails/rails/blob/593893c901f87b4ed205751f72df41519b4d2da3/activesupport/lib/active_support/cache/redis_cache_store.rb#L165 + # and + # https://github.com/rails/rails/blob/ad790cb2f6bc724a89e4266b505b3c57d5089dae/activesupport/lib/active_support/cache.rb#L206 def active_support_config { redis: pool, + pool: false, compress: Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_REDIS_CACHE_COMPRESSION', '1')), namespace: CACHE_NAMESPACE, expires_in: default_ttl_seconds diff --git a/lib/gitlab/redis/feature_flag.rb b/lib/gitlab/redis/feature_flag.rb index 441ff669035..395805792d7 100644 --- a/lib/gitlab/redis/feature_flag.rb +++ b/lib/gitlab/redis/feature_flag.rb @@ -14,6 +14,7 @@ module Gitlab def cache_store @cache_store ||= FeatureFlagStore.new( redis: pool, + pool: false, compress: Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_REDIS_CACHE_COMPRESSION', '1')), namespace: Cache::CACHE_NAMESPACE, expires_in: 1.hour diff --git a/lib/gitlab/redis/repository_cache.rb b/lib/gitlab/redis/repository_cache.rb index 966c6584aa5..6d0c35a6829 100644 --- a/lib/gitlab/redis/repository_cache.rb +++ b/lib/gitlab/redis/repository_cache.rb @@ -15,6 +15,7 @@ module Gitlab def cache_store @cache_store ||= RepositoryCacheStore.new( redis: pool, + pool: false, compress: Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_REDIS_CACHE_COMPRESSION', '1')), namespace: Cache::CACHE_NAMESPACE, expires_in: Cache.default_ttl_seconds diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 96fb9d44511..f714e09878c 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -49857,6 +49857,9 @@ msgstr "" msgid "UsageQuota|%{linkTitle} help link" msgstr "" +msgid "UsageQuota|%{percentageRemaining}%% namespace storage remaining." +msgstr "" + msgid "UsageQuota|%{storage_limit_link_start}A namespace storage limit%{link_end} will soon be enforced for the %{strong_start}%{namespace_name}%{strong_end} namespace. %{extra_message}" msgstr "" @@ -49968,9 +49971,6 @@ msgstr "" msgid "UsageQuota|Registry" msgstr "" -msgid "UsageQuota|Search" -msgstr "" - msgid "UsageQuota|Seats" msgstr "" @@ -54343,6 +54343,9 @@ msgstr "" msgid "banned user already exists" msgstr "" +msgid "before" +msgstr "" + msgid "beta" msgstr "" @@ -55886,6 +55889,9 @@ msgstr "" msgid "nounSeries|%{item}, and %{lastItem}" msgstr "" +msgid "on or after" +msgstr "" + msgid "only available on top-level groups." msgstr "" diff --git a/spec/factories/ci/bridge.rb b/spec/factories/ci/bridge.rb index 49ac74f6f86..c5c0634994d 100644 --- a/spec/factories/ci/bridge.rb +++ b/spec/factories/ci/bridge.rb @@ -1,7 +1,11 @@ # frozen_string_literal: true +require_relative 'deployable' + FactoryBot.define do factory :ci_bridge, class: 'Ci::Bridge', parent: :ci_processable do + instance_eval ::Factories::Ci::Deployable.traits + name { 'bridge' } created_at { '2013-10-29 09:50:00 CET' } status { :created } diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index dc75e17499c..7325ab30989 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -1,7 +1,11 @@ # frozen_string_literal: true +require_relative 'deployable' + FactoryBot.define do factory :ci_build, class: 'Ci::Build', parent: :ci_processable do + instance_eval ::Factories::Ci::Deployable.traits + name { 'test' } add_attribute(:protected) { false } created_at { 'Di 29. Okt 09:50:00 CET 2013' } @@ -137,122 +141,6 @@ FactoryBot.define do self.when { 'manual' } end - trait :teardown_environment do - environment { 'staging' } - options do - { - script: %w(ls), - environment: { name: 'staging', - action: 'stop', - url: 'http://staging.example.com/$CI_JOB_NAME' } - } - end - end - - trait :environment_with_deployment_tier do - environment { 'test_portal' } - options do - { - script: %w(ls), - environment: { name: 'test_portal', - action: 'start', - url: 'http://staging.example.com/$CI_JOB_NAME', - deployment_tier: 'testing' } - } - end - end - - trait :deploy_to_production do - environment { 'production' } - - options do - { - script: %w(ls), - environment: { name: 'production', - url: 'http://prd.example.com/$CI_JOB_NAME' } - } - end - end - - trait :start_review_app do - environment { 'review/$CI_COMMIT_REF_NAME' } - - options do - { - script: %w(ls), - environment: { name: 'review/$CI_COMMIT_REF_NAME', - url: 'http://staging.example.com/$CI_JOB_NAME', - on_stop: 'stop_review_app' } - } - end - end - - trait :stop_review_app do - name { 'stop_review_app' } - environment { 'review/$CI_COMMIT_REF_NAME' } - - options do - { - script: %w(ls), - environment: { name: 'review/$CI_COMMIT_REF_NAME', - url: 'http://staging.example.com/$CI_JOB_NAME', - action: 'stop' } - } - end - end - - trait :prepare_staging do - name { 'prepare staging' } - environment { 'staging' } - - options do - { - script: %w(ls), - environment: { name: 'staging', action: 'prepare' } - } - end - - set_expanded_environment_name - end - - trait :start_staging do - name { 'start staging' } - environment { 'staging' } - - options do - { - script: %w(ls), - environment: { name: 'staging', action: 'start' } - } - end - - set_expanded_environment_name - end - - trait :stop_staging do - name { 'stop staging' } - environment { 'staging' } - - options do - { - script: %w(ls), - environment: { name: 'staging', action: 'stop' } - } - end - - set_expanded_environment_name - end - - trait :set_expanded_environment_name do - after(:build) do |build, evaluator| - build.assign_attributes( - metadata_attributes: { - expanded_environment_name: build.expanded_environment_name - } - ) - end - end - trait :allowed_to_fail do allow_failure { true } end @@ -311,20 +199,6 @@ FactoryBot.define do trigger_request factory: :ci_trigger_request end - trait :with_deployment do - after(:build) do |build, evaluator| - ## - # Build deployment/environment relations if environment name is set - # to the job. If `build.deployment` has already been set, it doesn't - # build a new instance. - Environments::CreateForBuildService.new.execute(build) - end - - after(:create) do |build, evaluator| - Deployments::CreateForBuildService.new.execute(build) - end - end - trait :tag do tag { true } end diff --git a/spec/factories/ci/deployable.rb b/spec/factories/ci/deployable.rb new file mode 100644 index 00000000000..15b37e44e07 --- /dev/null +++ b/spec/factories/ci/deployable.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module Factories + module Ci + module Deployable + def self.traits + <<-RUBY + trait :teardown_environment do + environment { 'staging' } + options do + { + script: %w(ls), + environment: { name: 'staging', + action: 'stop', + url: 'http://staging.example.com/$CI_JOB_NAME' } + } + end + end + + trait :environment_with_deployment_tier do + environment { 'test_portal' } + options do + { + script: %w(ls), + environment: { name: 'test_portal', + action: 'start', + url: 'http://staging.example.com/$CI_JOB_NAME', + deployment_tier: 'testing' } + } + end + end + + trait :deploy_to_production do + environment { 'production' } + + options do + { + script: %w(ls), + environment: { name: 'production', + url: 'http://prd.example.com/$CI_JOB_NAME' } + } + end + end + + trait :start_review_app do + environment { 'review/$CI_COMMIT_REF_NAME' } + + options do + { + script: %w(ls), + environment: { name: 'review/$CI_COMMIT_REF_NAME', + url: 'http://staging.example.com/$CI_JOB_NAME', + on_stop: 'stop_review_app' } + } + end + end + + trait :stop_review_app do + name { 'stop_review_app' } + environment { 'review/$CI_COMMIT_REF_NAME' } + + options do + { + script: %w(ls), + environment: { name: 'review/$CI_COMMIT_REF_NAME', + url: 'http://staging.example.com/$CI_JOB_NAME', + action: 'stop' } + } + end + end + + trait :prepare_staging do + name { 'prepare staging' } + environment { 'staging' } + + options do + { + script: %w(ls), + environment: { name: 'staging', action: 'prepare' } + } + end + + set_expanded_environment_name + end + + trait :start_staging do + name { 'start staging' } + environment { 'staging' } + + options do + { + script: %w(ls), + environment: { name: 'staging', action: 'start' } + } + end + + set_expanded_environment_name + end + + trait :stop_staging do + name { 'stop staging' } + environment { 'staging' } + + options do + { + script: %w(ls), + environment: { name: 'staging', action: 'stop' } + } + end + + set_expanded_environment_name + end + + trait :set_expanded_environment_name do + after(:build) do |job, evaluator| + job.assign_attributes( + metadata_attributes: { + expanded_environment_name: job.expanded_environment_name + } + ) + end + end + + trait :with_deployment do + after(:build) do |job, evaluator| + ## + # Build deployment/environment relations if environment name is set + # to the job. If `job.deployment` has already been set, it doesn't + # build a new instance. + Environments::CreateForJobService.new.execute(job) + end + + after(:create) do |job, evaluator| + Deployments::CreateForJobService.new.execute(job) + end + end + RUBY + end + end + end +end diff --git a/spec/features/projects/work_items/work_item_spec.rb b/spec/features/projects/work_items/work_item_spec.rb index e996a76b1c5..618d3e2efd0 100644 --- a/spec/features/projects/work_items/work_item_spec.rb +++ b/spec/features/projects/work_items/work_item_spec.rb @@ -40,7 +40,7 @@ RSpec.describe 'Work item', :js, feature_category: :team_planning do end it_behaves_like 'work items title' - it_behaves_like 'work items status' + it_behaves_like 'work items toggle status button' it_behaves_like 'work items assignees' it_behaves_like 'work items labels' it_behaves_like 'work items comments', :issue diff --git a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js index 148c6230b9f..4686a4fe0c4 100644 --- a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js +++ b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js @@ -35,6 +35,8 @@ import { TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, + TOKEN_TYPE_CREATED, + TOKEN_TYPE_CLOSED, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { @@ -61,6 +63,7 @@ describe('IssuesDashboardApp component', () => { emptyStateWithFilterSvgPath: 'empty/state/with/filter/svg/path.svg', emptyStateWithoutFilterSvgPath: 'empty/state/with/filter/svg/path.svg', hasBlockedIssuesFeature: true, + hasIssueDateFilterFeature: true, hasIssuableHealthStatusFeature: true, hasIssueWeightsFeature: true, hasScopedLabelsFeature: true, @@ -365,7 +368,9 @@ describe('IssuesDashboardApp component', () => { expect(findIssuableList().props('searchTokens')).toMatchObject([ { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, { type: TOKEN_TYPE_AUTHOR, preloadedUsers }, + { type: TOKEN_TYPE_CLOSED }, { type: TOKEN_TYPE_CONFIDENTIAL }, + { type: TOKEN_TYPE_CREATED }, { type: TOKEN_TYPE_LABEL }, { type: TOKEN_TYPE_MILESTONE }, { type: TOKEN_TYPE_MY_REACTION }, diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index 2a0b002ae1f..de027a21c8f 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -69,6 +69,8 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, + TOKEN_TYPE_CREATED, + TOKEN_TYPE_CLOSED, } from '~/vue_shared/components/filtered_search_bar/constants'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import deleteWorkItemMutation from '~/work_items/graphql/delete_work_item.mutation.graphql'; @@ -109,6 +111,7 @@ describe('CE IssuesListApp component', () => { hasAnyIssues: true, hasAnyProjects: true, hasBlockedIssuesFeature: true, + hasIssueDateFilterFeature: true, hasIssuableHealthStatusFeature: true, hasIssueWeightsFeature: true, hasIterationsFeature: true, @@ -653,8 +656,10 @@ describe('CE IssuesListApp component', () => { expect(findIssuableList().props('searchTokens')).toMatchObject([ { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, { type: TOKEN_TYPE_AUTHOR, preloadedUsers }, + { type: TOKEN_TYPE_CLOSED }, { type: TOKEN_TYPE_CONFIDENTIAL }, { type: TOKEN_TYPE_CONTACT }, + { type: TOKEN_TYPE_CREATED }, { type: TOKEN_TYPE_LABEL }, { type: TOKEN_TYPE_MILESTONE }, { type: TOKEN_TYPE_MY_REACTION }, diff --git a/spec/frontend/super_sidebar/components/menu_section_spec.js b/spec/frontend/super_sidebar/components/menu_section_spec.js index dd729d8fd6a..288e317d4c6 100644 --- a/spec/frontend/super_sidebar/components/menu_section_spec.js +++ b/spec/frontend/super_sidebar/components/menu_section_spec.js @@ -14,7 +14,7 @@ describe('MenuSection component', () => { const findNavItems = () => wrapper.findAllComponents(NavItem); const createWrapper = (item, otherProps) => { wrapper = shallowMountExtended(MenuSection, { - propsData: { item, ...otherProps }, + propsData: { item: { items: [], ...item }, ...otherProps }, stubs: { GlCollapse: stubComponent(GlCollapse, { props: ['visible'], @@ -101,6 +101,25 @@ describe('MenuSection component', () => { }); }); }); + + describe('when section gets closed', () => { + beforeEach(async () => { + createWrapper({ title: 'Asdf' }, { expanded: true, 'has-flyout': true }); + await findButton().trigger('click'); + await findButton().trigger('pointerover', { pointerType: 'mouse' }); + }); + + it('shows the flyout only after section title gets hovered out and in again', async () => { + expect(findCollapse().props('visible')).toBe(false); + expect(findFlyout().isVisible()).toBe(false); + + await findButton().trigger('pointerleave'); + await findButton().trigger('pointerover', { pointerType: 'mouse' }); + + expect(findCollapse().props('visible')).toBe(false); + expect(findFlyout().isVisible()).toBe(true); + }); + }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js index 8d3bf3dd3be..d5e04c666e0 100644 --- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js @@ -4,9 +4,7 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import { trimText } from 'helpers/text_helper'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; -import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; -import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; -import codeQualityExtension from '~/vue_merge_request_widget/extensions/code_quality'; +import codeQualityExtension from '~/vue_merge_request_widget/extensions/code_quality/index.vue'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_NO_CONTENT, @@ -26,10 +24,7 @@ import { describe('Code Quality extension', () => { let wrapper; let mock; - - registerExtension(codeQualityExtension); - - const endpoint = '/root/repo/-/merge_requests/4/accessibility_reports.json'; + const endpoint = '/root/repo/-/merge_requests/4/codequality_reports.json'; const mockApi = (statusCode, data) => { mock.onGet(endpoint).reply(statusCode, data); @@ -43,10 +38,11 @@ describe('Code Quality extension', () => { const getSuccessIcon = () => wrapper.findByTestId('status-success-icon').exists(); const createComponent = () => { - wrapper = mountExtended(extensionsContainer, { + wrapper = mountExtended(codeQualityExtension, { propsData: { mr: { - codeQuality: endpoint, + codequality: endpoint, + codequalityReportsPath: endpoint, blobPath: { head_path: 'example/path', base_path: 'example/path', @@ -198,7 +194,7 @@ describe('Code Quality extension', () => { "Minor - Parsing error: 'return' outside of function in index.js:12", ); expect(text.resolvedError).toContain( - "Minor - Parsing error: 'return' outside of function Fixed in index.js:12", + "Minor - Parsing error: 'return' outside of function in index.js:12 Fixed", ); }); @@ -212,7 +208,7 @@ describe('Code Quality extension', () => { 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] in main.rb:3', ); expect(text.resolvedError).toContain( - 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] Fixed in main.rb:3', + 'Minor - Rubocop/Metrics/ParameterLists - Avoid parameter lists longer than 5 parameters. [12/5] in main.rb:3 Fixed', ); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js new file mode 100644 index 00000000000..56a59790210 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/date_token_spec.js @@ -0,0 +1,49 @@ +import { GlDatepicker, GlFilteredSearchToken } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import DateToken from '~/vue_shared/components/filtered_search_bar/tokens/date_token.vue'; + +const propsData = { + active: true, + config: {}, + value: { operator: '>', data: null }, +}; + +function createComponent() { + return mount(DateToken, { + propsData, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + termsAsTokens: () => false, + }, + }); +} + +describe('DateToken', () => { + let wrapper; + + const findGlFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchToken); + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders GlDatepicker', () => { + expect(findDatepicker().exists()).toBe(true); + }); + + it('renders GlFilteredSearchToken', () => { + expect(findGlFilteredSearchToken().exists()).toBe(true); + }); + + it('emits `complete` and `select` with the formatted date when a value is selected', () => { + findDatepicker().vm.$emit('input', new Date('October 13, 2014 11:13:00')); + findDatepicker().vm.$emit('close'); + + expect(findGlFilteredSearchToken().emitted()).toEqual({ + complete: [[]], + select: [['2014-10-13']], + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index 063f1d793ef..56d89d428f7 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -1,4 +1,5 @@ import { GlModal, GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; +import { omit } from 'lodash'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -6,13 +7,14 @@ import getWritableForksResponse from 'test_fixtures/graphql/vue_shared/component import WebIdeLink, { i18n } from '~/vue_shared/components/web_ide_link.vue'; import ConfirmForkModal from '~/vue_shared/components/web_ide/confirm_fork_modal.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { stubComponent } from 'helpers/stub_component'; +import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended, mountExtended, extendedWrapper, } from 'helpers/vue_test_utils_helper'; -import createMockApollo from 'helpers/mock_apollo_helper'; import { visitUrl } from '~/lib/utils/url_utility'; import getWritableForksQuery from '~/vue_shared/components/web_ide/get_writable_forks.query.graphql'; @@ -34,8 +36,10 @@ const ACTION_EDIT = { secondaryText: 'Edit this file only.', attrs: { 'data-qa-selector': 'edit_menu_item', - 'data-track-action': 'click_consolidated_edit', - 'data-track-label': 'edit', + }, + tracking: { + action: 'click_consolidated_edit', + label: 'single_file', }, }; const ACTION_EDIT_CONFIRM_FORK = { @@ -48,11 +52,13 @@ const ACTION_WEB_IDE = { text: 'Web IDE', attrs: { 'data-qa-selector': 'webide_menu_item', - 'data-track-action': 'click_consolidated_edit_ide', - 'data-track-label': 'web_ide', }, href: undefined, handle: expect.any(Function), + tracking: { + action: 'click_consolidated_edit', + label: 'web_ide', + }, }; const ACTION_WEB_IDE_CONFIRM_FORK = { ...ACTION_WEB_IDE, @@ -67,6 +73,10 @@ const ACTION_GITPOD = { attrs: { 'data-qa-selector': 'gitpod_menu_item', }, + tracking: { + action: 'click_consolidated_edit', + label: 'gitpod', + }, }; const ACTION_GITPOD_ENABLE = { ...ACTION_GITPOD, @@ -79,8 +89,10 @@ const ACTION_PIPELINE_EDITOR = { text: 'Edit in pipeline editor', attrs: { 'data-qa-selector': 'pipeline_editor_menu_item', - 'data-track-action': 'click_consolidated_pipeline_editor', - 'data-track-label': 'pipeline_editor', + }, + tracking: { + action: 'click_consolidated_edit', + label: 'pipeline_editor', }, }; @@ -88,6 +100,7 @@ describe('vue_shared/components/web_ide_link', () => { Vue.use(VueApollo); let wrapper; + let trackingSpy; function createComponent(props, { mountFn = shallowMountExtended, slots = {} } = {}) { const fakeApollo = createMockApollo([ @@ -116,6 +129,8 @@ describe('vue_shared/components/web_ide_link', () => { }, apolloProvider: fakeApollo, }); + + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); } const findDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown); @@ -135,13 +150,12 @@ describe('vue_shared/components/web_ide_link', () => { handle: props.item.handle, attrs: { 'data-qa-selector': attributes['data-qa-selector'], - 'data-track-action': attributes['data-track-action'], - 'data-track-label': attributes['data-track-label'], }, }; }); + const omitTrackingParams = (actions) => actions.map((action) => omit(action, 'tracking')); - it.each([ + describe.each([ { props: {}, expectedActions: [ACTION_WEB_IDE, ACTION_EDIT], @@ -231,10 +245,27 @@ describe('vue_shared/components/web_ide_link', () => { props: { showEditButton: false }, expectedActions: [ACTION_WEB_IDE], }, - ])('renders actions with appropriately for given props', ({ props, expectedActions }) => { - createComponent(props); + ])('for a set of props', ({ props, expectedActions }) => { + beforeEach(() => { + createComponent(props); + }); + + it('renders the appropiate actions', () => { + // omit tracking property because it is not included in the dropdown item + expect(getDropdownItemsAsData()).toEqual(omitTrackingParams(expectedActions)); + }); + + describe('when an action is clicked', () => { + it('tracks event', () => { + expectedActions.forEach((action, index) => { + findDisclosureDropdownItems().at(index).vm.$emit('action'); - expect(getDropdownItemsAsData()).toEqual(expectedActions); + expect(trackingSpy).toHaveBeenCalledWith(undefined, action.tracking.action, { + label: action.tracking.label, + }); + }); + }); + }); }); it('bubbles up shown and hidden events triggered by actions button component', () => { @@ -272,11 +303,9 @@ describe('vue_shared/components/web_ide_link', () => { }); it('displays Pipeline Editor as the first action', () => { - expect(getDropdownItemsAsData()).toEqual([ - ACTION_PIPELINE_EDITOR, - ACTION_WEB_IDE, - ACTION_GITPOD, - ]); + expect(getDropdownItemsAsData()).toEqual( + omitTrackingParams([ACTION_PIPELINE_EDITOR, ACTION_WEB_IDE, ACTION_GITPOD]), + ); }); it('when web ide button is clicked it opens in a new tab', async () => { diff --git a/spec/frontend/work_items/components/item_state_spec.js b/spec/frontend/work_items/components/item_state_spec.js deleted file mode 100644 index c3bdbfe030e..00000000000 --- a/spec/frontend/work_items/components/item_state_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { GlFormSelect } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants'; -import ItemState from '~/work_items/components/item_state.vue'; - -describe('ItemState', () => { - let wrapper; - - const findLabel = () => wrapper.find('label').text(); - const findFormSelect = () => wrapper.findComponent(GlFormSelect); - const selectedValue = () => wrapper.find('option:checked').element.value; - - const clickOpen = () => wrapper.findAll('option').at(0).setSelected(); - - const createComponent = ({ state = STATE_OPEN, disabled = false } = {}) => { - wrapper = mount(ItemState, { - propsData: { - state, - disabled, - }, - }); - }; - - it('renders label and dropdown', () => { - createComponent(); - - expect(findLabel()).toBe('Status'); - expect(selectedValue()).toBe(STATE_OPEN); - }); - - it('renders dropdown for closed', () => { - createComponent({ state: STATE_CLOSED }); - - expect(selectedValue()).toBe(STATE_CLOSED); - }); - - it('emits changed event', async () => { - createComponent({ state: STATE_CLOSED }); - - await clickOpen(); - - expect(wrapper.emitted('changed')).toEqual([[STATE_OPEN]]); - }); - - it('does not emits changed event if clicking selected value', async () => { - createComponent({ state: STATE_OPEN }); - - await clickOpen(); - - expect(wrapper.emitted('changed')).toBeUndefined(); - }); - - describe('form select disabled prop', () => { - describe.each` - description | disabled | value - ${'when not disabled'} | ${false} | ${undefined} - ${'when disabled'} | ${true} | ${'disabled'} - `('$description', ({ disabled, value }) => { - it(`renders form select component with disabled=${value}`, () => { - createComponent({ disabled }); - - expect(findFormSelect().attributes('disabled')).toBe(value); - }); - }); - }); -}); diff --git a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js index e6d20dcb0d9..4b1b7b27ad9 100644 --- a/spec/frontend/work_items/components/notes/work_item_add_note_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_add_note_spec.js @@ -247,6 +247,14 @@ describe('Work item add note', () => { expect(clearDraft).toHaveBeenCalledWith('gid://gitlab/WorkItem/1-comment'); }); + + it('emits error to parent when the comment form emits error', async () => { + await createComponent({ isEditing: true, signedIn: true }); + const error = 'error'; + findCommentForm().vm.$emit('error', error); + + expect(wrapper.emitted('error')).toEqual([[error]]); + }); }); }); diff --git a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js index 6c00d52aac5..dd88f34ae4f 100644 --- a/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js +++ b/spec/frontend/work_items/components/notes/work_item_comment_form_spec.js @@ -6,18 +6,11 @@ import { createMockDirective } from 'helpers/vue_mock_directive'; import waitForPromises from 'helpers/wait_for_promises'; import * as autosave from '~/lib/utils/autosave'; import { ESC_KEY, ENTER_KEY } from '~/lib/utils/keys'; -import { - STATE_OPEN, - STATE_CLOSED, - STATE_EVENT_REOPEN, - STATE_EVENT_CLOSE, -} from '~/work_items/constants'; +import { STATE_OPEN } from '~/work_items/constants'; import * as confirmViaGlModal from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import WorkItemCommentForm from '~/work_items/components/notes/work_item_comment_form.vue'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; -import { updateWorkItemMutationResponse, workItemQueryResponse } from 'jest/work_items/mock_data'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; Vue.use(VueApollo); @@ -44,8 +37,7 @@ describe('Work item comment form component', () => { const findConfirmButton = () => wrapper.find('[data-testid="confirm-button"]'); const findInternalNoteCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findInternalNoteTooltipIcon = () => wrapper.findComponent(GlIcon); - - const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + const findWorkItemToggleStateButton = () => wrapper.findComponent(WorkItemStateToggleButton); const createComponent = ({ isSubmitting = false, @@ -53,10 +45,8 @@ describe('Work item comment form component', () => { isNewDiscussion = false, workItemState = STATE_OPEN, workItemType = 'Task', - mutationHandler = mutationSuccessHandler, } = {}) => { wrapper = shallowMount(WorkItemCommentForm, { - apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), propsData: { workItemState, workItemId, @@ -205,61 +195,20 @@ describe('Work item comment form component', () => { }); describe('when used as a top level/is a new discussion', () => { - describe('cancel button text', () => { - it.each` - workItemState | workItemType | buttonText - ${STATE_OPEN} | ${'Task'} | ${'Close task'} - ${STATE_CLOSED} | ${'Task'} | ${'Reopen task'} - ${STATE_OPEN} | ${'Objective'} | ${'Close objective'} - ${STATE_CLOSED} | ${'Objective'} | ${'Reopen objective'} - ${STATE_OPEN} | ${'Key result'} | ${'Close key result'} - ${STATE_CLOSED} | ${'Key result'} | ${'Reopen key result'} - `( - 'is "$buttonText" when "$workItemType" state is "$workItemState"', - ({ workItemState, workItemType, buttonText }) => { - createComponent({ isNewDiscussion: true, workItemState, workItemType }); - - expect(findCancelButton().text()).toBe(buttonText); - }, - ); - }); - - describe('Close/reopen button click', () => { - it.each` - workItemState | stateEvent - ${STATE_OPEN} | ${STATE_EVENT_CLOSE} - ${STATE_CLOSED} | ${STATE_EVENT_REOPEN} - `( - 'calls mutation with "$stateEvent" when workItemState is "$workItemState"', - async ({ workItemState, stateEvent }) => { - createComponent({ isNewDiscussion: true, workItemState }); - - findCancelButton().vm.$emit('click'); - - await waitForPromises(); - - expect(mutationSuccessHandler).toHaveBeenCalledWith({ - input: { - id: workItemQueryResponse.data.workItem.id, - stateEvent, - }, - }); - }, + it('emits an error message when the mutation was unsuccessful', async () => { + createComponent({ + isNewDiscussion: true, + }); + findWorkItemToggleStateButton().vm.$emit( + 'error', + 'Something went wrong while updating the task. Please try again.', ); - it('emits an error message when the mutation was unsuccessful', async () => { - createComponent({ - isNewDiscussion: true, - mutationHandler: jest.fn().mockRejectedValue('Error!'), - }); - findCancelButton().vm.$emit('click'); - - await waitForPromises(); + await waitForPromises(); - expect(wrapper.emitted('error')).toEqual([ - ['Something went wrong while updating the task. Please try again.'], - ]); - }); + expect(wrapper.emitted('error')).toEqual([ + ['Something went wrong while updating the task. Please try again.'], + ]); }); }); diff --git a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js index ba9af7b2b68..8b7e04854af 100644 --- a/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js +++ b/spec/frontend/work_items/components/work_item_attributes_wrapper_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; import WorkItemDueDate from '~/work_items/components/work_item_due_date.vue'; -import WorkItemState from '~/work_items/components/work_item_state.vue'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; @@ -13,7 +12,6 @@ describe('WorkItemAttributesWrapper component', () => { const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true }); - const findWorkItemState = () => wrapper.findComponent(WorkItemState); const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate); const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); @@ -40,14 +38,6 @@ describe('WorkItemAttributesWrapper component', () => { }); }; - describe('work item state', () => { - it('renders the work item state', () => { - createComponent(); - - expect(findWorkItemState().exists()).toBe(true); - }); - }); - describe('assignees widget', () => { it('renders assignees component when widget is returned from the API', () => { createComponent(); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 7ceae935d2d..84d9dba93ae 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -24,6 +24,7 @@ import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; import WorkItemTodos from '~/work_items/components/work_item_todos.vue'; import { i18n } from '~/work_items/constants'; @@ -47,6 +48,10 @@ describe('WorkItemDetail component', () => { Vue.use(VueApollo); const workItemQueryResponse = workItemByIidResponseFactory({ canUpdate: true, canDelete: true }); + const workItemQueryResponseWithCannotUpdate = workItemByIidResponseFactory({ + canUpdate: false, + canDelete: false, + }); const workItemQueryResponseWithoutParent = workItemByIidResponseFactory({ parent: null, canUpdate: true, @@ -82,6 +87,7 @@ describe('WorkItemDetail component', () => { const findWorkItemTwoColumnViewContainer = () => wrapper.findByTestId('work-item-overview'); const findRightSidebar = () => wrapper.findByTestId('work-item-overview-right-sidebar'); const triggerPageScroll = () => findIntersectionObserver().vm.$emit('disappear'); + const findWorkItemStateToggleButton = () => wrapper.findComponent(WorkItemStateToggleButton); const createComponent = ({ isModal = false, @@ -194,6 +200,25 @@ describe('WorkItemDetail component', () => { }); }); + describe('work item state toggle button', () => { + describe.each` + description | canUpdate + ${'when user cannot update'} | ${false} + ${'when user can update'} | ${true} + `('$description', ({ canUpdate }) => { + it(`${canUpdate ? 'is rendered' : 'is not rendered'}`, async () => { + createComponent({ + handler: canUpdate + ? jest.fn().mockResolvedValue(workItemQueryResponse) + : jest.fn().mockResolvedValue(workItemQueryResponseWithCannotUpdate), + }); + await waitForPromises(); + + expect(findWorkItemStateToggleButton().exists()).toBe(canUpdate); + }); + }); + }); + describe('close button', () => { describe('when isModal prop is false', () => { it('does not render', async () => { diff --git a/spec/frontend/work_items/components/work_item_state_badge_spec.js b/spec/frontend/work_items/components/work_item_state_badge_spec.js new file mode 100644 index 00000000000..888d712cc5a --- /dev/null +++ b/spec/frontend/work_items/components/work_item_state_badge_spec.js @@ -0,0 +1,32 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { STATE_OPEN, STATE_CLOSED } from '~/work_items/constants'; +import WorkItemStateBadge from '~/work_items/components/work_item_state_badge.vue'; + +describe('WorkItemStateBadge', () => { + let wrapper; + + const createComponent = ({ workItemState = STATE_OPEN } = {}) => { + wrapper = shallowMount(WorkItemStateBadge, { + propsData: { + workItemState, + }, + }); + }; + const findStatusBadge = () => wrapper.findComponent(GlBadge); + + it.each` + state | icon | stateText | variant + ${STATE_OPEN} | ${'issue-open-m'} | ${'Open'} | ${'success'} + ${STATE_CLOSED} | ${'issue-close'} | ${'Closed'} | ${'info'} + `( + 'renders icon as "$icon" and text as "$stateText" when the work item state is "$state"', + ({ state, icon, stateText, variant }) => { + createComponent({ workItemState: state }); + + expect(findStatusBadge().props('icon')).toBe(icon); + expect(findStatusBadge().props('variant')).toBe(variant); + expect(findStatusBadge().text()).toBe(stateText); + }, + ); +}); diff --git a/spec/frontend/work_items/components/work_item_state_spec.js b/spec/frontend/work_items/components/work_item_state_toggle_button_spec.js index d1262057c73..c0b206e5da4 100644 --- a/spec/frontend/work_items/components/work_item_state_spec.js +++ b/spec/frontend/work_items/components/work_item_state_toggle_button_spec.js @@ -1,11 +1,11 @@ +import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import ItemState from '~/work_items/components/item_state.vue'; -import WorkItemState from '~/work_items/components/work_item_state.vue'; +import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; import { STATE_OPEN, STATE_CLOSED, @@ -16,59 +16,58 @@ import { import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; -describe('WorkItemState component', () => { +describe('Work Item State toggle button component', () => { let wrapper; Vue.use(VueApollo); const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); - const findItemState = () => wrapper.findComponent(ItemState); + const findStateToggleButton = () => wrapper.findComponent(GlButton); + + const { id } = workItemQueryResponse.data.workItem; const createComponent = ({ - state = STATE_OPEN, mutationHandler = mutationSuccessHandler, canUpdate = true, + workItemState = STATE_OPEN, + workItemType = 'Task', } = {}) => { - const { id, workItemType } = workItemQueryResponse.data.workItem; - wrapper = shallowMount(WorkItemState, { + wrapper = shallowMount(WorkItemStateToggleButton, { apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), propsData: { - workItem: { - id, - state, - workItemType, - }, + workItemId: id, + workItemState, + workItemType, canUpdate, }, }); }; - it('renders state', () => { - createComponent(); - - expect(findItemState().props('state')).toBe(workItemQueryResponse.data.workItem.state); - }); - - describe('item state disabled prop', () => { - describe.each` - description | canUpdate | value - ${'when cannot update'} | ${false} | ${true} - ${'when can update'} | ${true} | ${false} - `('$description', ({ canUpdate, value }) => { - it(`renders item state component with disabled=${value}`, () => { - createComponent({ canUpdate }); - - expect(findItemState().props('disabled')).toBe(value); - }); - }); + describe('work item State button text', () => { + it.each` + workItemState | workItemType | buttonText + ${STATE_OPEN} | ${'Task'} | ${'Close task'} + ${STATE_CLOSED} | ${'Task'} | ${'Reopen task'} + ${STATE_OPEN} | ${'Objective'} | ${'Close objective'} + ${STATE_CLOSED} | ${'Objective'} | ${'Reopen objective'} + ${STATE_OPEN} | ${'Key result'} | ${'Close key result'} + ${STATE_CLOSED} | ${'Key result'} | ${'Reopen key result'} + `( + 'is "$buttonText" when "$workItemType" state is "$workItemState"', + ({ workItemState, workItemType, buttonText }) => { + createComponent({ workItemState, workItemType }); + + expect(findStateToggleButton().text()).toBe(buttonText); + }, + ); }); describe('when updating the state', () => { it('calls a mutation', () => { createComponent(); - findItemState().vm.$emit('changed', STATE_CLOSED); + findStateToggleButton().vm.$emit('click'); expect(mutationSuccessHandler).toHaveBeenCalledWith({ input: { @@ -80,10 +79,10 @@ describe('WorkItemState component', () => { it('calls a mutation with REOPEN', () => { createComponent({ - state: STATE_CLOSED, + workItemState: STATE_CLOSED, }); - findItemState().vm.$emit('changed', STATE_OPEN); + findStateToggleButton().vm.$emit('click'); expect(mutationSuccessHandler).toHaveBeenCalledWith({ input: { @@ -96,7 +95,7 @@ describe('WorkItemState component', () => { it('emits an error message when the mutation was unsuccessful', async () => { createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') }); - findItemState().vm.$emit('changed', STATE_CLOSED); + findStateToggleButton().vm.$emit('click'); await waitForPromises(); expect(wrapper.emitted('error')).toEqual([ @@ -109,7 +108,7 @@ describe('WorkItemState component', () => { createComponent(); - findItemState().vm.$emit('changed', STATE_CLOSED); + findStateToggleButton().vm.$emit('click'); await waitForPromises(); expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_state', { diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 7dc969f274a..952f3bb6ad8 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -441,6 +441,7 @@ bridges: - needs - resource - sourced_pipeline +- deployment - resource_group - metadata - trigger_request diff --git a/spec/lib/gitlab/redis/cache_spec.rb b/spec/lib/gitlab/redis/cache_spec.rb index b7b4ba0eb2f..a48bde5e4ab 100644 --- a/spec/lib/gitlab/redis/cache_spec.rb +++ b/spec/lib/gitlab/redis/cache_spec.rb @@ -17,5 +17,9 @@ RSpec.describe Gitlab::Redis::Cache do expect(described_class.active_support_config[:expires_in]).to eq(1.day) end + + it 'has a pool set to false' do + expect(described_class.active_support_config[:pool]).to eq(false) + end end end diff --git a/spec/migrations/cleanup_conversion_big_int_ci_build_needs_self_managed_spec.rb b/spec/migrations/cleanup_conversion_big_int_ci_build_needs_self_managed_spec.rb new file mode 100644 index 00000000000..03a8356c721 --- /dev/null +++ b/spec/migrations/cleanup_conversion_big_int_ci_build_needs_self_managed_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe CleanupConversionBigIntCiBuildNeedsSelfManaged, feature_category: :database do + after do + connection = described_class.new.connection + connection.execute('ALTER TABLE ci_build_needs DROP COLUMN IF EXISTS id_convert_to_bigint') + end + + describe '#up' do + context 'when it is GitLab.com, dev, or test but not JiHu' do + before do + # As we call `schema_migrate_down!` before each example, and for this migration + # `#down` is same as `#up`, we need to ensure we start from the expected state. + connection = described_class.new.connection + connection.execute('ALTER TABLE ci_build_needs DROP COLUMN IF EXISTS id_convert_to_bigint') + end + + it 'does nothing' do + # rubocop: disable RSpec/AnyInstanceOf + allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(true) + # rubocop: enable RSpec/AnyInstanceOf + + ci_build_needs = table(:ci_build_needs) + + disable_migrations_output do + reversible_migration do |migration| + migration.before -> { + ci_build_needs.reset_column_information + + expect(ci_build_needs.columns.find { |c| c.name == 'id_convert_to_bigint' }).to be nil + } + + migration.after -> { + ci_build_needs.reset_column_information + + expect(ci_build_needs.columns.find { |c| c.name == 'id_convert_to_bigint' }).to be nil + } + end + end + end + end + + context 'when there is a self-managed instance with the temporary column already dropped' do + before do + # As we call `schema_migrate_down!` before each example, and for this migration + # `#down` is same as `#up`, we need to ensure we start from the expected state. + connection = described_class.new.connection + connection.execute('ALTER TABLE ci_build_needs ALTER COLUMN id TYPE bigint') + connection.execute('ALTER TABLE ci_build_needs DROP COLUMN IF EXISTS id_convert_to_bigint') + end + + it 'does nothing' do + # rubocop: disable RSpec/AnyInstanceOf + allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false) + # rubocop: enable RSpec/AnyInstanceOf + + ci_build_needs = table(:ci_build_needs) + + migrate! + + expect(ci_build_needs.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint') + expect(ci_build_needs.columns.find { |c| c.name == 'id_convert_to_bigint' }).to be nil + end + end + + context 'when there is a self-managed instance with the temporary columns' do + before do + # As we call `schema_migrate_down!` before each example, and for this migration + # `#down` is same as `#up`, we need to ensure we start from the expected state. + connection = described_class.new.connection + connection.execute('ALTER TABLE ci_build_needs ALTER COLUMN id TYPE bigint') + connection.execute('ALTER TABLE ci_build_needs ADD COLUMN IF NOT EXISTS id_convert_to_bigint integer') + end + + it 'drops the temporary column' do + # rubocop: disable RSpec/AnyInstanceOf + allow_any_instance_of(described_class).to receive(:com_or_dev_or_test_but_not_jh?).and_return(false) + # rubocop: enable RSpec/AnyInstanceOf + + ci_build_needs = table(:ci_build_needs) + + disable_migrations_output do + reversible_migration do |migration| + migration.before -> { + ci_build_needs.reset_column_information + + expect(ci_build_needs.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint') + expect(ci_build_needs.columns.find do |c| + c.name == 'id_convert_to_bigint' + end.sql_type).to eq('integer') + } + + migration.after -> { + ci_build_needs.reset_column_information + + expect(ci_build_needs.columns.find { |c| c.name == 'id' }.sql_type).to eq('bigint') + expect(ci_build_needs.columns.find { |c| c.name == 'id_convert_to_bigint' }).to be nil + } + end + end + end + end + end +end diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index b284cacf354..a5366a1a67a 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Ci::Bridge, feature_category: :continuous_integration do - let_it_be(:project) { create(:project, :in_group) } + let_it_be(:project, reload: true) { create(:project, :repository, :in_group) } let_it_be(:target_project) { create(:project, name: 'project', namespace: create(:namespace, name: 'my')) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } @@ -27,6 +27,10 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do it_behaves_like 'a retryable job' + it_behaves_like 'a deployable job' do + let(:job) { bridge } + end + it 'has one downstream pipeline' do expect(bridge).to have_one(:sourced_pipeline) expect(bridge).to have_one(:downstream_pipeline) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index bfd6360527f..351010fca18 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -100,7 +100,10 @@ RSpec.describe Ci::Build, feature_category: :continuous_integration, factory_def it_behaves_like 'has ID tokens', :ci_build it_behaves_like 'a retryable job' - it_behaves_like 'a deployable job' + + it_behaves_like 'a deployable job' do + let(:job) { build } + end describe '.manual_actions' do let!(:manual_but_created) { create(:ci_build, :manual, status: :created, pipeline: pipeline) } diff --git a/spec/presenters/packages/npm/package_presenter_spec.rb b/spec/presenters/packages/npm/package_presenter_spec.rb deleted file mode 100644 index fe4773a9cad..00000000000 --- a/spec/presenters/packages/npm/package_presenter_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Packages::Npm::PackagePresenter, feature_category: :package_registry do - let_it_be(:metadata) do - { - name: 'foo', - versions: { '1.0.0' => { 'dist' => { 'tarball' => 'http://localhost/tarball.tgz' } } }, - dist_tags: { 'latest' => '1.0.0' } - } - end - - subject { described_class.new(metadata) } - - describe '#name' do - it 'returns the name' do - expect(subject.name).to eq('foo') - end - end - - describe '#versions' do - it 'returns the versions' do - expect(subject.versions).to eq({ '1.0.0' => { 'dist' => { 'tarball' => 'http://localhost/tarball.tgz' } } }) - end - end - - describe '#dist_tags' do - it 'returns the dist_tags' do - expect(subject.dist_tags).to eq({ 'latest' => '1.0.0' }) - end - end -end diff --git a/spec/services/deployments/create_for_job_service_spec.rb b/spec/services/deployments/create_for_job_service_spec.rb new file mode 100644 index 00000000000..f5a30078b2d --- /dev/null +++ b/spec/services/deployments/create_for_job_service_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Deployments::CreateForJobService, feature_category: :continuous_delivery do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + let(:service) { described_class.new } + + it_behaves_like 'create deployment for job' do + let(:factory_type) { :ci_build } + end + + it_behaves_like 'create deployment for job' do + let(:factory_type) { :ci_bridge } + end +end diff --git a/spec/services/environments/create_for_job_service_spec.rb b/spec/services/environments/create_for_job_service_spec.rb new file mode 100644 index 00000000000..0d459301f19 --- /dev/null +++ b/spec/services/environments/create_for_job_service_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Environments::CreateForJobService, feature_category: :continuous_delivery do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + + let(:service) { described_class.new } + + it_behaves_like 'create environment for job' do + let(:factory_type) { :ci_build } + end + + it_behaves_like 'create environment for job' do + let(:factory_type) { :ci_bridge } + end +end diff --git a/spec/support/shared_examples/ci/deployable_shared_examples.rb b/spec/support/shared_examples/ci/deployable_shared_examples.rb index 682e408566b..367e939e69c 100644 --- a/spec/support/shared_examples/ci/deployable_shared_examples.rb +++ b/spec/support/shared_examples/ci/deployable_shared_examples.rb @@ -7,6 +7,8 @@ RSpec.shared_examples 'a deployable job' do shared_examples 'calling proper BuildFinishedWorker' do it 'calls Ci::BuildFinishedWorker' do + skip unless described_class == ::Ci::Build + expect(Ci::BuildFinishedWorker).to receive(:perform_async) subject @@ -14,12 +16,12 @@ RSpec.shared_examples 'a deployable job' do end describe '#outdated_deployment?' do - subject { build.outdated_deployment? } + subject { job.outdated_deployment? } - let(:build) { create(:ci_build, :created, :with_deployment, pipeline: pipeline, environment: 'production') } + let(:job) { create(factory_type, :created, :with_deployment, project: project, pipeline: pipeline, environment: 'production') } - context 'when build has no environment' do - let(:build) { create(:ci_build, :created, pipeline: pipeline, environment: nil) } + context 'when job has no environment' do + let(:job) { create(factory_type, :created, pipeline: pipeline, environment: nil) } it { expect(subject).to be_falsey } end @@ -32,27 +34,27 @@ RSpec.shared_examples 'a deployable job' do it { expect(subject).to be_falsey } end - context 'when build is not an outdated deployment' do + context 'when job is not an outdated deployment' do before do - allow(build.deployment).to receive(:older_than_last_successful_deployment?).and_return(false) + allow(job.deployment).to receive(:older_than_last_successful_deployment?).and_return(false) end it { expect(subject).to be_falsey } end - context 'when build is older than the latest deployment and still pending status' do + context 'when job is older than the latest deployment and still pending status' do before do - allow(build.deployment).to receive(:older_than_last_successful_deployment?).and_return(true) + allow(job.deployment).to receive(:older_than_last_successful_deployment?).and_return(true) end - it { expect(subject).to be_truthy } + it { expect(subject).to be_truthy} # rubocop: disable Layout/SpaceInsideBlockBraces end - context 'when build is older than the latest deployment but succeeded once' do - let(:build) { create(:ci_build, :success, :with_deployment, pipeline: pipeline, environment: 'production') } + context 'when job is older than the latest deployment but succeeded once' do + let(:job) { create(factory_type, :success, :with_deployment, pipeline: pipeline, environment: 'production') } before do - allow(build.deployment).to receive(:older_than_last_successful_deployment?).and_return(true) + allow(job.deployment).to receive(:older_than_last_successful_deployment?).and_return(true) end it 'returns false for allowing rollback' do @@ -72,10 +74,10 @@ RSpec.shared_examples 'a deployable job' do end describe 'state transition as a deployable' do - subject { build.send(event) } + subject { job.send(event) } - let!(:build) { create(:ci_build, :with_deployment, :start_review_app, pipeline: pipeline) } - let(:deployment) { build.deployment } + let!(:job) { create(factory_type, :with_deployment, :start_review_app, status: :pending, pipeline: pipeline) } + let(:deployment) { job.deployment } let(:environment) { deployment.environment } before do @@ -116,13 +118,13 @@ RSpec.shared_examples 'a deployable job' do context 'when deployment is already running state' do before do - build.deployment.success! + job.deployment.success! end it 'does not change deployment status and tracks an error' do expect(Gitlab::ErrorTracking) .to receive(:track_exception).with( - instance_of(Deployment::StatusSyncError), deployment_id: deployment.id, build_id: build.id) + instance_of(Deployment::StatusSyncError), deployment_id: deployment.id, build_id: job.id) with_cross_database_modification_prevented do expect { subject }.not_to change { deployment.reload.status } @@ -199,7 +201,7 @@ RSpec.shared_examples 'a deployable job' do # `needs + when:manual` scenario, see: https://gitlab.com/gitlab-org/gitlab/-/issues/347502 context 'when transits from skipped to created to running' do before do - build.skip! + job.skip! end context 'during skipped to created' do @@ -216,8 +218,8 @@ RSpec.shared_examples 'a deployable job' do let(:event) { :run! } before do - build.process! - build.enqueue! + job.process! + job.enqueue! end it 'transitions to running and calls webhook' do @@ -235,10 +237,10 @@ RSpec.shared_examples 'a deployable job' do end describe '#on_stop' do - subject { build.on_stop } + subject { job.on_stop } context 'when a job has a specification that it can be stopped from the other job' do - let(:build) { create(:ci_build, :start_review_app, pipeline: pipeline) } + let(:job) { create(factory_type, :start_review_app, pipeline: pipeline) } it 'returns the other job name' do is_expected.to eq('stop_review_app') @@ -246,7 +248,7 @@ RSpec.shared_examples 'a deployable job' do end context 'when a job does not have environment information' do - let(:build) { create(:ci_build, pipeline: pipeline) } + let(:job) { create(factory_type, pipeline: pipeline) } it 'returns nil' do is_expected.to be_nil @@ -255,9 +257,9 @@ RSpec.shared_examples 'a deployable job' do end describe '#environment_tier_from_options' do - subject { build.environment_tier_from_options } + subject { job.environment_tier_from_options } - let(:build) { Ci::Build.new(options: options) } + let(:job) { Ci::Build.new(options: options) } let(:options) { { environment: { deployment_tier: 'production' } } } it { is_expected.to eq('production') } @@ -270,11 +272,11 @@ RSpec.shared_examples 'a deployable job' do end describe '#environment_tier' do - subject { build.environment_tier } + subject { job.environment_tier } let(:options) { { environment: { deployment_tier: 'production' } } } let!(:environment) { create(:environment, name: 'production', tier: 'development', project: project) } - let(:build) { Ci::Build.new(options: options, environment: 'production', project: project) } + let(:job) { Ci::Build.new(options: options, environment: 'production', project: project) } it { is_expected.to eq('production') } @@ -295,11 +297,11 @@ RSpec.shared_examples 'a deployable job' do describe 'environment' do describe '#has_environment_keyword?' do - subject { build.has_environment_keyword? } + subject { job.has_environment_keyword? } context 'when environment is defined' do before do - build.update!(environment: 'review') + job.update!(environment: 'review') end it { is_expected.to be_truthy } @@ -307,7 +309,7 @@ RSpec.shared_examples 'a deployable job' do context 'when environment is not defined' do before do - build.update!(environment: nil) + job.update!(environment: nil) end it { is_expected.to be_falsey } @@ -315,12 +317,12 @@ RSpec.shared_examples 'a deployable job' do end describe '#expanded_environment_name' do - subject { build.expanded_environment_name } + subject { job.expanded_environment_name } context 'when environment uses $CI_COMMIT_REF_NAME' do - let(:build) do + let(:job) do create( - :ci_build, + factory_type, ref: 'master', environment: 'review/$CI_COMMIT_REF_NAME', pipeline: pipeline @@ -331,9 +333,9 @@ RSpec.shared_examples 'a deployable job' do end context 'when environment uses yaml_variables containing symbol keys' do - let(:build) do + let(:job) do create( - :ci_build, + factory_type, yaml_variables: [{ key: :APP_HOST, value: 'host' }], environment: 'review/$APP_HOST', pipeline: pipeline @@ -344,13 +346,13 @@ RSpec.shared_examples 'a deployable job' do is_expected.to eq('review/host') end - context 'when build metadata has already persisted the expanded environment name' do + context 'when job metadata has already persisted the expanded environment name' do before do - build.metadata.expanded_environment_name = 'review/foo' + job.metadata.expanded_environment_name = 'review/foo' end it 'returns a persisted expanded environment name without a list of variables' do - expect(build).not_to receive(:simple_variables) + expect(job).not_to receive(:simple_variables) is_expected.to eq('review/foo') end @@ -358,8 +360,8 @@ RSpec.shared_examples 'a deployable job' do end context 'when using persisted variables' do - let(:build) do - create(:ci_build, environment: 'review/x$CI_JOB_ID', pipeline: pipeline) + let(:job) do + create(factory_type, environment: 'review/x$CI_JOB_ID', pipeline: pipeline) end it { is_expected.to eq('review/x') } @@ -372,9 +374,9 @@ RSpec.shared_examples 'a deployable job' do ] end - let(:build) do + let(:job) do create( - :ci_build, + factory_type, ref: 'master', yaml_variables: yaml_variables, environment: 'review/$ENVIRONMENT_NAME', @@ -387,9 +389,9 @@ RSpec.shared_examples 'a deployable job' do end describe '#expanded_kubernetes_namespace' do - let(:build) { create(:ci_build, environment: environment, options: options, pipeline: pipeline) } + let(:job) { create(factory_type, environment: environment, options: options, pipeline: pipeline) } - subject { build.expanded_kubernetes_namespace } + subject { job.expanded_kubernetes_namespace } context 'environment and namespace are not set' do let(:environment) { nil } @@ -435,11 +437,11 @@ RSpec.shared_examples 'a deployable job' do end describe '#deployment_job?' do - subject { build.deployment_job? } + subject { job.deployment_job? } context 'when environment is defined' do before do - build.update!(environment: 'review') + job.update!(environment: 'review') end context 'no action is defined' do @@ -448,7 +450,7 @@ RSpec.shared_examples 'a deployable job' do context 'and start action is defined' do before do - build.update!(options: { environment: { action: 'start' } }) + job.update!(options: { environment: { action: 'start' } }) end it { is_expected.to be_truthy } @@ -457,7 +459,7 @@ RSpec.shared_examples 'a deployable job' do context 'when environment is not defined' do before do - build.update!(environment: nil) + job.update!(environment: nil) end it { is_expected.to be_falsey } @@ -465,11 +467,11 @@ RSpec.shared_examples 'a deployable job' do end describe '#stops_environment?' do - subject { build.stops_environment? } + subject { job.stops_environment? } context 'when environment is defined' do before do - build.update!(environment: 'review') + job.update!(environment: 'review') end context 'no action is defined' do @@ -478,7 +480,7 @@ RSpec.shared_examples 'a deployable job' do context 'and stop action is defined' do before do - build.update!(options: { environment: { action: 'stop' } }) + job.update!(options: { environment: { action: 'stop' } }) end it { is_expected.to be_truthy } @@ -487,7 +489,7 @@ RSpec.shared_examples 'a deployable job' do context 'when environment is not defined' do before do - build.update!(environment: nil) + job.update!(environment: nil) end it { is_expected.to be_falsey } @@ -500,19 +502,19 @@ RSpec.shared_examples 'a deployable job' do create(:environment, project: project, name: "foo-#{project.default_branch}") end - subject { build.persisted_environment } + subject { job.persisted_environment } context 'when referenced literally' do - let(:build) do - create(:ci_build, pipeline: pipeline, environment: "foo-#{project.default_branch}") + let(:job) do + create(factory_type, pipeline: pipeline, environment: "foo-#{project.default_branch}") end it { is_expected.to eq(environment) } end context 'when referenced with a variable' do - let(:build) do - create(:ci_build, pipeline: pipeline, environment: "foo-$CI_COMMIT_REF_NAME") + let(:job) do + create(factory_type, pipeline: pipeline, environment: "foo-$CI_COMMIT_REF_NAME") end it { is_expected.to eq(environment) } @@ -522,11 +524,11 @@ RSpec.shared_examples 'a deployable job' do it { is_expected.to be_nil } end - context 'when build has a stop environment' do - let(:build) { create(:ci_build, :stop_review_app, pipeline: pipeline, environment: "foo-#{project.default_branch}") } + context 'when job has a stop environment' do + let(:job) { create(factory_type, :stop_review_app, pipeline: pipeline, environment: "foo-#{project.default_branch}") } it 'expands environment name' do - expect(build).to receive(:expanded_environment_name).and_call_original + expect(job).to receive(:expanded_environment_name).and_call_original is_expected.to eq(environment) end @@ -538,39 +540,43 @@ RSpec.shared_examples 'a deployable job' do allow_any_instance_of(Ci::Build).to receive(:create_deployment) # rubocop:disable RSpec/AnyInstanceOf end - context 'when build is a last deployment' do - let(:build) { create(:ci_build, :success, environment: 'production', pipeline: pipeline) } - let(:environment) { create(:environment, name: 'production', project: build.project) } - let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: build) } + context 'when job is a last deployment' do + let(:job) { create(factory_type, :success, environment: 'production', pipeline: pipeline) } + let(:environment) { create(:environment, name: 'production', project: job.project) } + let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: job) } - it { expect(build.deployment_status).to eq(:last) } + it { expect(job.deployment_status).to eq(:last) } end - context 'when there is a newer build with deployment' do - let(:build) { create(:ci_build, :success, environment: 'production', pipeline: pipeline) } - let(:environment) { create(:environment, name: 'production', project: build.project) } - let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: build) } + context 'when there is a newer job with deployment' do + let(:job) { create(factory_type, :success, environment: 'production', pipeline: pipeline) } + let(:environment) { create(:environment, name: 'production', project: job.project) } + let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: job) } let!(:last_deployment) { create(:deployment, :success, environment: environment, project: environment.project) } - it { expect(build.deployment_status).to eq(:out_of_date) } + it { expect(job.deployment_status).to eq(:out_of_date) } end - context 'when build with deployment has failed' do - let(:build) { create(:ci_build, :failed, environment: 'production', pipeline: pipeline) } - let(:environment) { create(:environment, name: 'production', project: build.project) } - let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: build) } + context 'when job with deployment has failed' do + let(:job) { create(factory_type, :failed, environment: 'production', pipeline: pipeline) } + let(:environment) { create(:environment, name: 'production', project: job.project) } + let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: job) } - it { expect(build.deployment_status).to eq(:failed) } + it { expect(job.deployment_status).to eq(:failed) } end - context 'when build with deployment is running' do - let(:build) { create(:ci_build, environment: 'production', pipeline: pipeline) } - let(:environment) { create(:environment, name: 'production', project: build.project) } - let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: build) } + context 'when job with deployment is running' do + let(:job) { create(factory_type, environment: 'production', pipeline: pipeline) } + let(:environment) { create(:environment, name: 'production', project: job.project) } + let!(:deployment) { create(:deployment, :success, environment: environment, project: environment.project, deployable: job) } - it { expect(build.deployment_status).to eq(:creating) } + it { expect(job.deployment_status).to eq(:creating) } end end + + def factory_type + described_class.name.underscore.tr('/', '_') + end end # rubocop:enable Layout/LineLength # rubocop:enable RSpec/ContextWording diff --git a/spec/services/deployments/create_for_build_service_spec.rb b/spec/support/shared_examples/deployments/create_for_job_shared_examples.rb index c07fc07cfbf..52a44cdefef 100644 --- a/spec/services/deployments/create_for_build_service_spec.rb +++ b/spec/support/shared_examples/deployments/create_for_job_shared_examples.rb @@ -1,18 +1,11 @@ # frozen_string_literal: true -require 'spec_helper' - -RSpec.describe Deployments::CreateForBuildService, feature_category: :continuous_delivery do - let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { create(:user) } - - let(:service) { described_class.new } - +RSpec.shared_examples 'create deployment for job' do describe '#execute' do subject { service.execute(build) } context 'with a deployment job' do - let!(:build) { create(:ci_build, :start_review_app, project: project) } + let!(:build) { create(factory_type, :start_review_app, project: project) } let!(:environment) { create(:environment, project: project, name: build.expanded_environment_name) } it 'creates a deployment record' do @@ -43,7 +36,7 @@ RSpec.describe Deployments::CreateForBuildService, feature_category: :continuous end context 'when the corresponding environment does not exist' do - let!(:environment) {} + let!(:environment) {} # rubocop:disable Lint/EmptyBlock it 'does not create a deployment record' do expect { subject }.not_to change { Deployment.count } @@ -54,7 +47,7 @@ RSpec.describe Deployments::CreateForBuildService, feature_category: :continuous end context 'with a teardown job' do - let!(:build) { create(:ci_build, :stop_review_app, project: project) } + let!(:build) { create(factory_type, :stop_review_app, project: project) } let!(:environment) { create(:environment, name: build.expanded_environment_name) } it 'does not create a deployment record' do @@ -65,7 +58,7 @@ RSpec.describe Deployments::CreateForBuildService, feature_category: :continuous end context 'with a normal job' do - let!(:build) { create(:ci_build, project: project) } + let!(:build) { create(factory_type, project: project) } it 'does not create a deployment record' do expect { subject }.not_to change { Deployment.count } @@ -74,18 +67,10 @@ RSpec.describe Deployments::CreateForBuildService, feature_category: :continuous end end - context 'with a bridge' do - let!(:build) { create(:ci_bridge, project: project) } - - it 'does not create a deployment record' do - expect { subject }.not_to change { Deployment.count } - end - end - context 'when build has environment attribute' do let!(:build) do - create(:ci_build, environment: 'production', project: project, - options: { environment: { name: 'production', **kubernetes_options } }) + create(factory_type, environment: 'production', project: project, + options: { environment: { name: 'production', **kubernetes_options } }) # rubocop:disable Layout/ArgumentAlignment end let!(:environment) { create(:environment, project: project, name: build.expanded_environment_name) } @@ -129,8 +114,8 @@ RSpec.describe Deployments::CreateForBuildService, feature_category: :continuous end context 'when build already has deployment' do - let!(:build) { create(:ci_build, :with_deployment, project: project, environment: 'production') } - let!(:environment) {} + let!(:build) { create(factory_type, :with_deployment, project: project, environment: 'production') } + let!(:environment) {} # rubocop:disable Lint/EmptyBlock it 'returns the persisted deployment' do expect { subject }.not_to change { Deployment.count } @@ -147,8 +132,8 @@ RSpec.describe Deployments::CreateForBuildService, feature_category: :continuous with_them do let!(:build) do - create(:ci_build, environment: 'production', project: project, - options: { environment: { name: 'production', action: action } }) + create(factory_type, environment: 'production', project: project, + options: { environment: { name: 'production', action: action } }) # rubocop:disable Layout/ArgumentAlignment end it 'returns nothing' do @@ -158,7 +143,7 @@ RSpec.describe Deployments::CreateForBuildService, feature_category: :continuous end context 'when build does not have environment attribute' do - let!(:build) { create(:ci_build, project: project) } + let!(:build) { create(factory_type, project: project) } it 'returns nothing' do is_expected.to be_nil diff --git a/spec/services/environments/create_for_build_service_spec.rb b/spec/support/shared_examples/environments/create_for_job_shared_examples.rb index 223401a243d..3acdc8c142f 100644 --- a/spec/services/environments/create_for_build_service_spec.rb +++ b/spec/support/shared_examples/environments/create_for_job_shared_examples.rb @@ -1,15 +1,8 @@ # frozen_string_literal: true -require 'spec_helper' - -RSpec.describe Environments::CreateForBuildService, feature_category: :continuous_delivery do - let_it_be(:project) { create(:project, :repository) } - let_it_be(:user) { create(:user) } - let_it_be(:pipeline) { create(:ci_pipeline, project: project) } - - let!(:job) { build(:ci_build, project: project, pipeline: pipeline, **attributes) } - let(:service) { described_class.new } - let(:merge_request) {} +RSpec.shared_examples 'create environment for job' do + let!(:job) { build(factory_type, project: project, pipeline: pipeline, **attributes) } + let(:merge_request) {} # rubocop:disable Lint/EmptyBlock describe '#execute' do subject { service.execute(job) } @@ -218,7 +211,7 @@ RSpec.describe Environments::CreateForBuildService, feature_category: :continuou context 'when a pipeline contains a deployment job' do let(:pipeline) { create(:ci_pipeline, project: project, merge_request: merge_request) } - let!(:job) { build(:ci_build, :start_review_app, project: project, pipeline: pipeline) } + let!(:job) { build(factory_type, :start_review_app, project: project, pipeline: pipeline) } context 'and the environment does not exist' do it 'creates the environment specified by the job' do @@ -280,7 +273,7 @@ RSpec.describe Environments::CreateForBuildService, feature_category: :continuou end context 'when a pipeline contains a teardown job' do - let!(:job) { build(:ci_build, :stop_review_app, project: project) } + let!(:job) { build(factory_type, :stop_review_app, project: project) } it 'ensures environment existence for the job' do expect { subject }.to change { Environment.count }.by(1) @@ -292,7 +285,7 @@ RSpec.describe Environments::CreateForBuildService, feature_category: :continuou end context 'when a pipeline does not contain a deployment job' do - let!(:job) { build(:ci_build, project: project) } + let!(:job) { build(factory_type, project: project) } it 'does not create any environments' do expect { subject }.not_to change { Environment.count } diff --git a/spec/support/shared_examples/features/work_items_shared_examples.rb b/spec/support/shared_examples/features/work_items_shared_examples.rb index 4c15b682458..97407d93cb0 100644 --- a/spec/support/shared_examples/features/work_items_shared_examples.rb +++ b/spec/support/shared_examples/features/work_items_shared_examples.rb @@ -15,17 +15,17 @@ RSpec.shared_examples 'work items title' do end end -RSpec.shared_examples 'work items status' do - let(:state_selector) { '[data-testid="work-item-state-select"]' } +RSpec.shared_examples 'work items toggle status button' do + let(:state_button) { '[data-testid="work-item-state-toggle"]' } it 'successfully shows and changes the status of the work item' do - expect(find(state_selector)).to have_content 'Open' + expect(find(state_button, match: :first)).to have_content 'Close' - find(state_selector).select("Closed") + find(state_button, match: :first).click wait_for_requests - expect(find(state_selector)).to have_content 'Closed' + expect(find(state_button, match: :first)).to have_content 'Reopen' expect(work_item.reload.state).to eq('closed') end end |