Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-07-31 15:11:19 +00:00
parent 74ecf758e3
commit 7f98cf51aa
84 changed files with 1362 additions and 840 deletions

View File

@ -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:

View File

@ -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));
}

View File

@ -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));

View File

@ -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

View File

@ -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
}

View File

@ -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) {

View File

@ -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',
},
},
},
};

View File

@ -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 {

View File

@ -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
}

View File

@ -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)) {

View File

@ -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"

View File

@ -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>

View File

@ -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);

View File

@ -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';

View File

@ -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>

View File

@ -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)"

View File

@ -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>

View File

@ -265,6 +265,7 @@ export default {
:comment-button-text="commentButtonText"
@submitForm="updateWorkItem"
@cancelEditing="cancelEditing"
@error="$emit('error', $event)"
/>
<textarea
v-else

View File

@ -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>

View File

@ -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"

View File

@ -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" />

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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(

View File

@ -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)

View File

@ -32,7 +32,7 @@ module Ci
end
def create_deployment(build)
::Deployments::CreateForBuildService.new.execute(build)
::Deployments::CreateForJobService.new.execute(build)
end
end
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
658cb25d5add4ad4e26d7baef6759f8512fa0244dd347b0522ad75ac496c9965

View File

@ -0,0 +1 @@
a85f3b493021cc27079dc07fe0ba5f11eeeca9798cf6ccdc60f7f7f7eae049af

View File

@ -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));

View File

@ -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]*'

View File

@ -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 [^"]+://.*'

View File

@ -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`).
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.
### Set a user cap
### Set the user cap number
Set a user cap to restrict the number of users who can sign up without administrator approval.
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`).
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
Remove the user cap so that the number of new users who can sign up without
administrator approval is not restricted.
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

View File

@ -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`. |

View File

@ -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"
```

View File

@ -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" \

View File

@ -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

View File

@ -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>.

View File

@ -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**.

View File

@ -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

View File

@ -16,7 +16,7 @@ module Gitlab
private
def ensure_environment(build)
::Environments::CreateForBuildService.new.execute(build)
::Environments::CreateForJobService.new.execute(build)
end
end
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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 }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 },

View File

@ -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 },

View File

@ -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);
});
});
});
});

View File

@ -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',
);
});

View File

@ -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']],
});
});
});

View File

@ -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);
});
expect(getDropdownItemsAsData()).toEqual(expectedActions);
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(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 () => {

View File

@ -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);
});
});
});
});

View File

@ -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]]);
});
});
});

View File

@ -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,
mutationHandler: jest.fn().mockRejectedValue('Error!'),
});
findCancelButton().vm.$emit('click');
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([
['Something went wrong while updating the task. Please try again.'],
]);
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.',
);
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([
['Something went wrong while updating the task. Please try again.'],
]);
});
});

View File

@ -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();

View File

@ -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 () => {

View File

@ -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);
},
);
});

View File

@ -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();
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(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);
});
});
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', {

View File

@ -441,6 +441,7 @@ bridges:
- needs
- resource
- sourced_pipeline
- deployment
- resource_group
- metadata
- trigger_request

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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