Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-02-23 21:10:44 +00:00
parent c37dd28c4a
commit 5e450e9022
101 changed files with 1260 additions and 660 deletions

View file

@ -2,6 +2,30 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 13.9.1 (2021-02-23)
### Fixed (6 changes, 1 of them is from the community)
- Send SIGINT instead of SIGQUIT to puma. !54446 (Jörg Behrmann @behrmann)
- Reset description template names cache key to reload an updated templates structure. !54614
- Restore missing horizontal scrollbar on issue boards. !54634
- Fix keep latest artifacts checkbox being always disabled. !54669
- Fix Metric tab not showing up on operations page. !54736
- Fix S3 object storage failing when endpoint is not specified. !54868
### Changed (1 change)
- Updates authorization for linting endpoint. !54492
### Performance (1 change)
- Fix N+1 SQL regression in exporting issues to CSV. !54287
### Other (1 change)
- Fix creating the idx_on_issues_where_service_desk_reply_to_is_not_null index before the post migration. !54346
## 13.9.0 (2021-02-22)
### Security (1 change)

View file

@ -1 +1 @@
3f702fcbdb3481eade8c18815a61f97d01886cef
40d46031c0a188ece3ed2574559321baf316a48c

View file

@ -7,15 +7,12 @@ import {
GlSearchBoxByType,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { cloneDeep, isEqual } from 'lodash';
import Vue from 'vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale';
import {
getMappingData,
getPayloadFields,
transformForSave,
} from '../utils/mapping_transformations';
import { mappingFields } from '../constants';
import { getMappingData, transformForSave } from '../utils/mapping_transformations';
export const i18n = {
columns: {
@ -33,6 +30,7 @@ export const i18n = {
export default {
i18n,
mappingFields,
components: {
GlIcon,
GlFormInput,
@ -73,18 +71,15 @@ export default {
};
},
computed: {
payloadFields() {
return getPayloadFields(this.parsedPayload);
},
mappingData() {
return getMappingData(this.gitlabFields, this.payloadFields, this.savedMapping);
return getMappingData(this.gitlabFields, this.parsedPayload, this.savedMapping);
},
hasFallbackColumn() {
return this.gitlabFields.some(({ numberOfFallbacks }) => Boolean(numberOfFallbacks));
},
},
methods: {
setMapping(gitlabKey, mappingKey, valueKey) {
setMapping(gitlabKey, mappingKey, valueKey = mappingFields.mapping) {
const fieldIndex = this.gitlabFields.findIndex((field) => field.name === gitlabKey);
const updatedField = { ...this.gitlabFields[fieldIndex], ...{ [valueKey]: mappingKey } };
Vue.set(this.gitlabFields, fieldIndex, updatedField);
@ -100,11 +95,11 @@ export default {
return fields.filter((field) => field.label.toLowerCase().includes(search));
},
isSelected(fieldValue, mapping) {
return fieldValue === mapping;
return isEqual(fieldValue, mapping);
},
selectedValue(name) {
selectedValue(mapping) {
return (
this.payloadFields.find((item) => item.name === name)?.label ||
this.parsedPayload.find((item) => isEqual(item.path, mapping))?.label ||
this.$options.i18n.makeSelection
);
},
@ -150,7 +145,7 @@ export default {
:key="gitlabField.name"
class="gl-display-table-row"
>
<div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle">
<div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-form-input
aria-labelledby="gitlabFieldsHeader"
disabled
@ -164,7 +159,7 @@ export default {
</div>
</div>
<div class="gl-display-table-cell gl-py-3 gl-pr-3 w-30p gl-vertical-align-middle">
<div class="gl-display-table-cell gl-py-3 gl-pr-3 gl-w-30p gl-vertical-align-middle">
<gl-dropdown
:disabled="!gitlabField.mappingFields.length"
aria-labelledby="parsedFieldsHeader"
@ -175,10 +170,10 @@ export default {
<gl-search-box-by-type @input="setSearchTerm($event, 'searchTerm', gitlabField.name)" />
<gl-dropdown-item
v-for="mappingField in filterFields(gitlabField.searchTerm, gitlabField.mappingFields)"
:key="`${mappingField.name}__mapping`"
:is-checked="isSelected(gitlabField.mapping, mappingField.name)"
:key="`${mappingField.path}__mapping`"
:is-checked="isSelected(gitlabField.mapping, mappingField.path)"
is-check-item
@click="setMapping(gitlabField.name, mappingField.name, 'mapping')"
@click="setMapping(gitlabField.name, mappingField.path)"
>
{{ mappingField.label }}
</gl-dropdown-item>
@ -188,7 +183,7 @@ export default {
</gl-dropdown>
</div>
<div class="gl-display-table-cell gl-py-3 w-30p">
<div class="gl-display-table-cell gl-py-3 gl-w-30p">
<gl-dropdown
v-if="Boolean(gitlabField.numberOfFallbacks)"
:disabled="!gitlabField.mappingFields.length"
@ -205,10 +200,12 @@ export default {
gitlabField.fallbackSearchTerm,
gitlabField.mappingFields,
)"
:key="`${mappingField.name}__fallback`"
:is-checked="isSelected(gitlabField.fallback, mappingField.name)"
:key="`${mappingField.path}__fallback`"
:is-checked="isSelected(gitlabField.fallback, mappingField.path)"
is-check-item
@click="setMapping(gitlabField.name, mappingField.name, 'fallback')"
@click="
setMapping(gitlabField.name, mappingField.path, $options.mappingFields.fallback)
"
>
{{ mappingField.label }}
</gl-dropdown-item>

View file

@ -120,14 +120,17 @@ export default {
const { category, action } = trackAlertIntegrationsViewsOptions;
Tracking.event(category, action);
},
setIntegrationToDelete({ name, id }) {
this.integrationToDelete.id = id;
this.integrationToDelete.name = name;
setIntegrationToDelete(integration) {
this.integrationToDelete = integration;
},
deleteIntegration() {
this.$emit('delete-integration', { id: this.integrationToDelete.id });
const { id, type } = this.integrationToDelete;
this.$emit('delete-integration', { id, type });
this.integrationToDelete = { ...integrationToDeleteDefault };
},
editIntegration({ id, type }) {
this.$emit('edit-integration', { id, type });
},
},
};
</script>
@ -169,7 +172,7 @@ export default {
<template #cell(actions)="{ item }">
<gl-button-group class="gl-ml-3">
<gl-button icon="pencil" @click="$emit('edit-integration', { id: item.id })" />
<gl-button icon="pencil" @click="editIntegration(item)" />
<gl-button
v-gl-modal.deleteIntegration
:disabled="item.type === $options.typeSet.prometheus"

View file

@ -12,6 +12,8 @@ import {
GlModalDirective,
GlToggle,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { isEmpty, omit } from 'lodash';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@ -22,12 +24,9 @@ import {
typeSet,
} from '../constants';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
import parseSamplePayloadQuery from '../graphql/queries/parse_sample_payload.query.graphql';
import MappingBuilder from './alert_mapping_builder.vue';
import AlertSettingsFormHelpBlock from './alert_settings_form_help_block.vue';
// Mocks will be removed when integrating with BE is ready
// data format is defined and will be the same as mocked (maybe with some minor changes)
// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171
import mockedCustomMapping from './mocks/parsedMapping.json';
export const i18n = {
integrationFormSteps: {
@ -92,7 +91,6 @@ export const i18n = {
};
export default {
integrationTypes,
placeholders: {
prometheus: targetPrometheusUrlPlaceholder,
},
@ -128,6 +126,9 @@ export default {
multiIntegrations: {
default: false,
},
projectPath: {
default: '',
},
},
props: {
loading: {
@ -151,18 +152,19 @@ export default {
},
data() {
return {
selectedIntegration: integrationTypes[0].value,
integrationTypesOptions: Object.values(integrationTypes),
selectedIntegration: integrationTypes.none.value,
active: false,
formVisible: false,
integrationTestPayload: {
json: null,
error: null,
},
resetSamplePayloadConfirmed: false,
customMapping: null,
resetPayloadAndMappingConfirmed: false,
mapping: [],
parsingPayload: false,
currentIntegration: null,
parsedPayload: [],
};
},
computed: {
@ -210,17 +212,11 @@ export default {
this.alertFields?.length
);
},
parsedSamplePayload() {
return this.customMapping?.samplePayload?.payloadAlerFields?.nodes;
},
savedMapping() {
return this.customMapping?.storedMapping?.nodes;
},
hasSamplePayload() {
return Boolean(this.customMapping?.samplePayload);
return this.isValidNonEmptyJSON(this.currentIntegration?.payloadExample);
},
canEditPayload() {
return this.hasSamplePayload && !this.resetSamplePayloadConfirmed;
return this.hasSamplePayload && !this.resetPayloadAndMappingConfirmed;
},
isResetAuthKeyDisabled() {
return !this.active && !this.integrationForm.token !== '';
@ -240,25 +236,52 @@ export default {
isSelectDisabled() {
return this.currentIntegration !== null || !this.canAddIntegration;
},
savedMapping() {
return this.mapping;
},
},
watch: {
currentIntegration(val) {
if (val === null) {
return this.reset();
this.reset();
return;
}
this.selectedIntegration = val.type;
this.active = val.active;
if (val.type === typeSet.http && this.showMappingBuilder) this.getIntegrationMapping(val.id);
return this.integrationTypeSelect();
const { type, active, payloadExample, payloadAlertFields, payloadAttributeMappings } = val;
this.selectedIntegration = type;
this.active = active;
if (type === typeSet.prometheus) {
this.integrationTestPayload.json = null;
}
if (type === typeSet.http && this.showMappingBuilder) {
this.parsedPayload = payloadAlertFields;
this.integrationTestPayload.json = this.isValidNonEmptyJSON(payloadExample)
? payloadExample
: null;
const mapping = payloadAttributeMappings.map((mappingItem) =>
omit(mappingItem, '__typename'),
);
this.updateMapping(mapping);
}
this.toggleFormVisibility();
},
},
methods: {
integrationTypeSelect() {
if (this.selectedIntegration === integrationTypes[0].value) {
this.formVisible = false;
} else {
this.formVisible = true;
isValidNonEmptyJSON(JSONString) {
if (JSONString) {
let parsed;
try {
parsed = JSON.parse(JSONString);
} catch (error) {
Sentry.captureException(error);
}
if (parsed) return !isEmpty(parsed);
}
return false;
},
toggleFormVisibility() {
this.formVisible = this.selectedIntegration !== integrationTypes.none.value;
},
submitWithTestPayload() {
this.$emit('set-test-alert-payload', this.testAlertPayload);
@ -269,20 +292,15 @@ export default {
const customMappingVariables = this.glFeatures.multipleHttpIntegrationsCustomMapping
? {
payloadAttributeMappings: this.mapping,
payloadExample: this.integrationTestPayload.json,
payloadExample: this.integrationTestPayload.json || '{}',
}
: {};
const variables =
this.selectedIntegration === typeSet.http
? {
name,
active: this.active,
...customMappingVariables,
}
? { name, active: this.active, ...customMappingVariables }
: { apiUrl, active: this.active };
const integrationPayload = { type: this.selectedIntegration, variables };
if (this.currentIntegration) {
return this.$emit('update-integration', integrationPayload);
}
@ -291,11 +309,12 @@ export default {
return this.$emit('create-new-integration', integrationPayload);
},
reset() {
this.selectedIntegration = integrationTypes[0].value;
this.integrationTypeSelect();
this.selectedIntegration = integrationTypes.none.value;
this.toggleFormVisibility();
this.resetPayloadAndMapping();
if (this.currentIntegration) {
return this.$emit('clear-current-integration');
return this.$emit('clear-current-integration', { type: this.currentIntegration.type });
}
return this.resetFormValues();
@ -332,35 +351,40 @@ export default {
}
},
parseMapping() {
// TODO: replace with real BE mutation when ready;
this.parsingPayload = true;
return new Promise((resolve) => {
setTimeout(() => resolve(mockedCustomMapping), 1000);
})
.then((res) => {
const mapping = { ...res };
delete mapping.storedMapping;
this.customMapping = res;
this.integrationTestPayload.json = res?.samplePayload.body;
this.resetSamplePayloadConfirmed = false;
return this.$apollo
.query({
query: parseSamplePayloadQuery,
variables: { projectPath: this.projectPath, payload: this.integrationTestPayload.json },
})
.then(
({
data: {
project: { alertManagementPayloadFields },
},
}) => {
this.parsedPayload = alertManagementPayloadFields;
this.resetPayloadAndMappingConfirmed = false;
this.$toast.show(this.$options.i18n.integrationFormSteps.step4.payloadParsedSucessMsg);
this.$toast.show(this.$options.i18n.integrationFormSteps.step4.payloadParsedSucessMsg);
},
)
.catch(({ message }) => {
this.integrationTestPayload.error = message;
})
.finally(() => {
this.parsingPayload = false;
});
},
getIntegrationMapping() {
// TODO: replace with real BE mutation when ready;
return Promise.resolve(mockedCustomMapping).then((res) => {
this.customMapping = res;
this.integrationTestPayload.json = res?.samplePayload.body;
});
},
updateMapping(mapping) {
this.mapping = mapping;
},
resetPayloadAndMapping() {
this.resetPayloadAndMappingConfirmed = true;
this.parsedPayload = [];
this.updateMapping([]);
},
},
};
</script>
@ -377,8 +401,8 @@ export default {
v-model="selectedIntegration"
:disabled="isSelectDisabled"
class="mw-100"
:options="$options.integrationTypes"
@change="integrationTypeSelect"
:options="integrationTypesOptions"
@change="toggleFormVisibility"
/>
<div v-if="!canAddIntegration" class="gl-my-4" data-testid="multi-integrations-not-supported">
@ -551,7 +575,7 @@ export default {
:title="$options.i18n.integrationFormSteps.step4.resetHeader"
:ok-title="$options.i18n.integrationFormSteps.step4.resetOk"
ok-variant="danger"
@ok="resetSamplePayloadConfirmed = true"
@ok="resetPayloadAndMapping"
>
{{ $options.i18n.integrationFormSteps.step4.resetBody }}
</gl-modal>
@ -566,7 +590,7 @@ export default {
>
<span>{{ $options.i18n.integrationFormSteps.step5.intro }}</span>
<mapping-builder
:parsed-payload="parsedSamplePayload"
:parsed-payload="parsedPayload"
:saved-mapping="savedMapping"
:alert-fields="alertFields"
@onMappingUpdate="updateMapping"

View file

@ -8,15 +8,18 @@ import createPrometheusIntegrationMutation from '../graphql/mutations/create_pro
import destroyHttpIntegrationMutation from '../graphql/mutations/destroy_http_integration.mutation.graphql';
import resetHttpTokenMutation from '../graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '../graphql/mutations/reset_prometheus_token.mutation.graphql';
import updateCurrentIntergrationMutation from '../graphql/mutations/update_current_intergration.mutation.graphql';
import updateCurrentHttpIntegrationMutation from '../graphql/mutations/update_current_http_integration.mutation.graphql';
import updateCurrentPrometheusIntegrationMutation from '../graphql/mutations/update_current_prometheus_integration.mutation.graphql';
import updateHttpIntegrationMutation from '../graphql/mutations/update_http_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '../graphql/mutations/update_prometheus_integration.mutation.graphql';
import getCurrentIntegrationQuery from '../graphql/queries/get_current_integration.query.graphql';
import getHttpIntegrationsQuery from '../graphql/queries/get_http_integrations.query.graphql';
import getIntegrationsQuery from '../graphql/queries/get_integrations.query.graphql';
import service from '../services';
import {
updateStoreAfterIntegrationDelete,
updateStoreAfterIntegrationAdd,
updateStoreAfterHttpIntegrationAdd,
} from '../utils/cache_updates';
import {
DELETE_INTEGRATION_ERROR,
@ -84,6 +87,28 @@ export default {
createFlash({ message: err });
},
},
// TODO: we'll need to update the logic to request specific http integration by its id on edit
// when BE adds support for it https://gitlab.com/gitlab-org/gitlab/-/issues/321674
// currently the request for ALL http integrations is made and on specific integration edit we search it in the list
httpIntegrations: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getHttpIntegrationsQuery,
variables() {
return {
projectPath: this.projectPath,
};
},
update(data) {
const { alertManagementHttpIntegrations: { nodes: list = [] } = {} } = data.project || {};
return {
list,
};
},
error(err) {
createFlash({ message: err });
},
},
currentIntegration: {
query: getCurrentIntegrationQuery,
},
@ -93,6 +118,7 @@ export default {
isUpdating: false,
testAlertPayload: null,
integrations: {},
httpIntegrations: {},
currentIntegration: null,
};
},
@ -105,22 +131,28 @@ export default {
},
},
methods: {
isHttp(type) {
return type === typeSet.http;
},
createNewIntegration({ type, variables }) {
const { projectPath } = this;
const isHttp = this.isHttp(type);
this.isUpdating = true;
this.$apollo
.mutate({
mutation:
type === this.$options.typeSet.http
? createHttpIntegrationMutation
: createPrometheusIntegrationMutation,
mutation: isHttp ? createHttpIntegrationMutation : createPrometheusIntegrationMutation,
variables: {
...variables,
projectPath,
},
update(store, { data }) {
updateStoreAfterIntegrationAdd(store, getIntegrationsQuery, data, { projectPath });
if (isHttp) {
updateStoreAfterHttpIntegrationAdd(store, getHttpIntegrationsQuery, data, {
projectPath,
});
}
},
})
.then(({ data: { httpIntegrationCreate, prometheusIntegrationCreate } = {} } = {}) => {
@ -157,10 +189,9 @@ export default {
this.isUpdating = true;
this.$apollo
.mutate({
mutation:
type === this.$options.typeSet.http
? updateHttpIntegrationMutation
: updatePrometheusIntegrationMutation,
mutation: this.isHttp(type)
? updateHttpIntegrationMutation
: updatePrometheusIntegrationMutation,
variables: {
...variables,
id: this.currentIntegration.id,
@ -176,7 +207,7 @@ export default {
return this.validateAlertPayload();
}
this.clearCurrentIntegration();
this.clearCurrentIntegration({ type });
return createFlash({
message: this.$options.i18n.changesSaved,
@ -195,16 +226,13 @@ export default {
this.isUpdating = true;
this.$apollo
.mutate({
mutation:
type === this.$options.typeSet.http
? resetHttpTokenMutation
: resetPrometheusTokenMutation,
mutation: this.isHttp(type) ? resetHttpTokenMutation : resetPrometheusTokenMutation,
variables,
})
.then(
({ data: { httpIntegrationResetToken, prometheusIntegrationResetToken } = {} } = {}) => {
const error =
httpIntegrationResetToken?.errors[0] || prometheusIntegrationResetToken?.errors[0];
const [error] =
httpIntegrationResetToken?.errors || prometheusIntegrationResetToken?.errors;
if (error) {
return createFlash({ message: error });
}
@ -214,10 +242,10 @@ export default {
prometheusIntegrationResetToken?.integration;
this.$apollo.mutate({
mutation: updateCurrentIntergrationMutation,
variables: {
...integration,
},
mutation: this.isHttp(type)
? updateCurrentHttpIntegrationMutation
: updateCurrentPrometheusIntegrationMutation,
variables: integration,
});
return createFlash({
@ -233,33 +261,30 @@ export default {
this.isUpdating = false;
});
},
editIntegration({ id }) {
const currentIntegration = this.integrations.list.find(
(integration) => integration.id === id,
);
editIntegration({ id, type }) {
let currentIntegration = this.integrations.list.find((integration) => integration.id === id);
if (this.isHttp(type)) {
const httpIntegrationMappingData = this.httpIntegrations.list.find(
(integration) => integration.id === id,
);
currentIntegration = { ...currentIntegration, ...httpIntegrationMappingData };
}
this.$apollo.mutate({
mutation: updateCurrentIntergrationMutation,
variables: {
id: currentIntegration.id,
name: currentIntegration.name,
active: currentIntegration.active,
token: currentIntegration.token,
type: currentIntegration.type,
url: currentIntegration.url,
apiUrl: currentIntegration.apiUrl,
},
mutation: this.isHttp(type)
? updateCurrentHttpIntegrationMutation
: updateCurrentPrometheusIntegrationMutation,
variables: currentIntegration,
});
},
deleteIntegration({ id }) {
deleteIntegration({ id, type }) {
const { projectPath } = this;
this.isUpdating = true;
this.$apollo
.mutate({
mutation: destroyHttpIntegrationMutation,
variables: {
id,
},
variables: { id },
update(store, { data }) {
updateStoreAfterIntegrationDelete(store, getIntegrationsQuery, data, { projectPath });
},
@ -269,7 +294,7 @@ export default {
if (error) {
return createFlash({ message: error });
}
this.clearCurrentIntegration();
this.clearCurrentIntegration({ type });
return createFlash({
message: this.$options.i18n.integrationRemoved,
type: FLASH_TYPES.SUCCESS,
@ -282,9 +307,11 @@ export default {
this.isUpdating = false;
});
},
clearCurrentIntegration() {
clearCurrentIntegration({ type }) {
this.$apollo.mutate({
mutation: updateCurrentIntergrationMutation,
mutation: this.isHttp(type)
? updateCurrentHttpIntegrationMutation
: updateCurrentPrometheusIntegrationMutation,
variables: {},
});
},

View file

@ -1,95 +0,0 @@
{
"samplePayload": {
"body": "{\n \"dashboardId\":1,\n \"evalMatches\":[\n {\n \"value\":1,\n \"metric\":\"Count\",\n \"tags\":{}\n }\n ],\n \"imageUrl\":\"https://grafana.com/static/assets/img/blog/mixed_styles.png\",\n \"message\":\"Notification Message\",\n \"orgId\":1,\n \"panelId\":2,\n \"ruleId\":1,\n \"ruleName\":\"Panel Title alert\",\n \"ruleUrl\":\"http://localhost:3000/d/hZ7BuVbWz/test-dashboard?fullscreen\\u0026edit\\u0026tab=alert\\u0026panelId=2\\u0026orgId=1\",\n \"state\":\"alerting\",\n \"tags\":{\n \"tag name\":\"tag value\"\n },\n \"title\":\"[Alerting] Panel Title alert\"\n}\n",
"payloadAlerFields": {
"nodes": [
{
"path": ["dashboardId"],
"label": "Dashboard Id",
"type": "string"
},
{
"path": ["evalMatches"],
"label": "Eval Matches",
"type": "array"
},
{
"path": ["createdAt"],
"label": "Created At",
"type": "datetime"
},
{
"path": ["imageUrl"],
"label": "Image Url",
"type": "string"
},
{
"path": ["message"],
"label": "Message",
"type": "string"
},
{
"path": ["orgId"],
"label": "Org Id",
"type": "string"
},
{
"path": ["panelId"],
"label": "Panel Id",
"type": "string"
},
{
"path": ["ruleId"],
"label": "Rule Id",
"type": "string"
},
{
"path": ["ruleName"],
"label": "Rule Name",
"type": "string"
},
{
"path": ["ruleUrl"],
"label": "Rule Url",
"type": "string"
},
{
"path": ["state"],
"label": "State",
"type": "string"
},
{
"path": ["title"],
"label": "Title",
"type": "string"
},
{
"path": ["tags", "tag"],
"label": "Tags",
"type": "string"
}
]
}
},
"storedMapping": {
"nodes": [
{
"alertFieldName": "title",
"payloadAlertPaths": "title",
"fallbackAlertPaths": "ruleUrl"
},
{
"alertFieldName": "description",
"payloadAlertPaths": "message"
},
{
"alertFieldName": "hosts",
"payloadAlertPaths": "evalMatches"
},
{
"alertFieldName": "startTime",
"payloadAlertPaths": "createdAt"
}
]
}
}

View file

@ -40,11 +40,11 @@ export const i18n = {
integration: s__('AlertSettings|Integration'),
};
export const integrationTypes = [
{ value: '', text: s__('AlertSettings|Select integration type') },
{ value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
{ value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
];
export const integrationTypes = {
none: { value: '', text: s__('AlertSettings|Select integration type') },
http: { value: 'HTTP', text: s__('AlertSettings|HTTP Endpoint') },
prometheus: { value: 'PROMETHEUS', text: s__('AlertSettings|External Prometheus') },
};
export const typeSet = {
http: 'HTTP',
@ -68,3 +68,8 @@ export const trackAlertIntegrationsViewsOptions = {
category: 'Alert Integrations',
action: 'view_alert_integrations_list',
};
export const mappingFields = {
mapping: 'mapping',
fallback: 'fallback',
};

View file

@ -10,7 +10,18 @@ const resolvers = {
Mutation: {
updateCurrentIntegration: (
_,
{ id = null, name, active, token, type, url, apiUrl },
{
id = null,
name,
active,
token,
type,
url,
apiUrl,
payloadExample,
payloadAttributeMappings,
payloadAlertFields,
},
{ cache },
) => {
const sourceData = cache.readQuery({ query: getCurrentIntegrationQuery });
@ -28,6 +39,9 @@ const resolvers = {
type,
url,
apiUrl,
payloadExample,
payloadAttributeMappings,
payloadAlertFields,
};
}
});

View file

@ -0,0 +1,7 @@
#import "./integration_item.fragment.graphql"
#import "./http_integration_payload_data.fragment.graphql"
fragment HttpIntegrationItem on AlertManagementHttpIntegration {
...IntegrationItem
...HttpIntegrationPayloadData
}

View file

@ -0,0 +1,14 @@
fragment HttpIntegrationPayloadData on AlertManagementHttpIntegration {
payloadExample
payloadAttributeMappings {
fieldName
path
type
label
}
payloadAlertFields {
path
type
label
}
}

View file

@ -1,4 +1,4 @@
#import "../fragments/integration_item.fragment.graphql"
#import "../fragments/http_integration_item.fragment.graphql"
mutation createHttpIntegration(
$projectPath: ID!
@ -18,7 +18,7 @@ mutation createHttpIntegration(
) {
errors
integration {
...IntegrationItem
...HttpIntegrationItem
}
}
}

View file

@ -1,10 +1,10 @@
#import "../fragments/integration_item.fragment.graphql"
#import "../fragments/http_integration_item.fragment.graphql"
mutation destroyHttpIntegration($id: ID!) {
httpIntegrationDestroy(input: { id: $id }) {
errors
integration {
...IntegrationItem
...HttpIntegrationItem
}
}
}

View file

@ -1,10 +1,10 @@
#import "../fragments/integration_item.fragment.graphql"
#import "../fragments/http_integration_item.fragment.graphql"
mutation resetHttpIntegrationToken($id: ID!) {
httpIntegrationResetToken(input: { id: $id }) {
errors
integration {
...IntegrationItem
...HttpIntegrationItem
}
}
}

View file

@ -0,0 +1,25 @@
mutation updateCurrentHttpIntegration(
$id: String
$name: String
$active: Boolean
$token: String
$type: String
$url: String
$apiUrl: String
$payloadExample: JsonString
$payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!]
$payloadAlertFields: [AlertManagementPayloadAlertField!]
) {
updateCurrentIntegration(
id: $id
name: $name
active: $active
token: $token
type: $type
url: $url
apiUrl: $apiUrl
payloadExample: $payloadExample
payloadAttributeMappings: $payloadAttributeMappings
payloadAlertFields: $payloadAlertFields
) @client
}

View file

@ -1,4 +1,4 @@
mutation updateCurrentIntegration(
mutation updateCurrentPrometheusIntegration(
$id: String
$name: String
$active: Boolean
@ -6,6 +6,7 @@ mutation updateCurrentIntegration(
$type: String
$url: String
$apiUrl: String
$samplePayload: String
) {
updateCurrentIntegration(
id: $id
@ -15,5 +16,6 @@ mutation updateCurrentIntegration(
type: $type
url: $url
apiUrl: $apiUrl
samplePayload: $samplePayload
) @client
}

View file

@ -1,10 +1,24 @@
#import "../fragments/integration_item.fragment.graphql"
#import "../fragments/http_integration_item.fragment.graphql"
mutation updateHttpIntegration($id: ID!, $name: String!, $active: Boolean!) {
httpIntegrationUpdate(input: { id: $id, name: $name, active: $active }) {
mutation updateHttpIntegration(
$id: ID!
$name: String!
$active: Boolean!
$payloadExample: JsonString
$payloadAttributeMappings: [AlertManagementPayloadAlertFieldInput!]
) {
httpIntegrationUpdate(
input: {
id: $id
name: $name
active: $active
payloadExample: $payloadExample
payloadAttributeMappings: $payloadAttributeMappings
}
) {
errors
integration {
...IntegrationItem
...HttpIntegrationItem
}
}
}

View file

@ -0,0 +1,13 @@
#import "../fragments/http_integration_payload_data.fragment.graphql"
# TODO: this query need to accept http integration id to request a sepcific integration
query getHttpIntegrations($projectPath: ID!) {
project(fullPath: $projectPath) {
alertManagementHttpIntegrations {
nodes {
id
...HttpIntegrationPayloadData
}
}
}
}

View file

@ -0,0 +1,9 @@
query parsePayloadFields($projectPath: ID!, $payload: String!) {
project(fullPath: $projectPath) {
alertManagementPayloadFields(payloadExample: $payload) {
path
label
type
}
}
}

View file

@ -60,6 +60,32 @@ const addIntegrationToStore = (
});
};
const addHttpIntegrationToStore = (store, query, { httpIntegrationCreate }, variables) => {
const integration = httpIntegrationCreate?.integration;
if (!integration) {
return;
}
const sourceData = store.readQuery({
query,
variables,
});
const data = produce(sourceData, (draftData) => {
// eslint-disable-next-line no-param-reassign
draftData.project.alertManagementHttpIntegrations.nodes = [
integration,
...draftData.project.alertManagementHttpIntegrations.nodes,
];
});
store.writeQuery({
query,
variables,
data,
});
};
const onError = (data, message) => {
createFlash({ message });
throw new Error(data.errors);
@ -82,3 +108,11 @@ export const updateStoreAfterIntegrationAdd = (store, query, data, variables) =>
addIntegrationToStore(store, query, data, variables);
}
};
export const updateStoreAfterHttpIntegrationAdd = (store, query, data, variables) => {
if (hasErrors(data)) {
onError(data, ADD_INTEGRATION_ERROR);
} else {
addHttpIntegrationToStore(store, query, data, variables);
}
};

View file

@ -1,3 +1,4 @@
import { isEqual } from 'lodash';
/**
* Given data for GitLab alert fields, parsed payload fields data and previously stored mapping (if any)
* creates an object in a form convenient to build UI && interact with it
@ -10,16 +11,19 @@
export const getMappingData = (gitlabFields, payloadFields, savedMapping) => {
return gitlabFields.map((gitlabField) => {
// find fields from payload that match gitlab alert field by type
const mappingFields = payloadFields.filter(({ type }) => gitlabField.types.includes(type));
const mappingFields = payloadFields.filter(({ type }) =>
gitlabField.types.includes(type.toLowerCase()),
);
// find the mapping that was previously stored
const foundMapping = savedMapping.find(({ fieldName }) => fieldName === gitlabField.name);
const { fallbackAlertPaths, payloadAlertPaths } = foundMapping || {};
const foundMapping = savedMapping.find(
({ fieldName }) => fieldName.toLowerCase() === gitlabField.name,
);
const { path: mapping, fallbackPath: fallback } = foundMapping || {};
return {
mapping: payloadAlertPaths,
fallback: fallbackAlertPaths,
mapping,
fallback,
searchTerm: '',
fallbackSearchTerm: '',
mappingFields,
@ -36,7 +40,7 @@ export const getMappingData = (gitlabFields, payloadFields, savedMapping) => {
*/
export const transformForSave = (mappingData) => {
return mappingData.reduce((acc, field) => {
const mapped = field.mappingFields.find(({ name }) => name === field.mapping);
const mapped = field.mappingFields.find(({ path }) => isEqual(path, field.mapping));
if (mapped) {
const { path, type, label } = mapped;
acc.push({
@ -49,13 +53,3 @@ export const transformForSave = (mappingData) => {
return acc;
}, []);
};
/**
* Adds `name` prop to each provided by BE parsed payload field
* @param {Object} payload - parsed sample payload
*
* @return {Object} same as input with an extra `name` property which basically serves as a key to make a match
*/
export const getPayloadFields = (payload) => {
return payload.map((field) => ({ ...field, name: field.path.join('_') }));
};

View file

@ -123,7 +123,7 @@ export default {
<gl-button
data-testid="action-delete"
icon="remove"
category="primary"
category="secondary"
variant="danger"
:title="s__('PackageRegistry|Remove package')"
:aria-label="s__('PackageRegistry|Remove package')"

View file

@ -0,0 +1,31 @@
<script>
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
export default {
components: {
GlAlert,
GlLink,
GlSprintf,
},
props: {
error: {
type: String,
required: true,
},
wikiPagePath: {
type: String,
required: true,
},
},
};
</script>
<template>
<gl-alert variant="danger" :dismissible="false">
<gl-sprintf :message="error">
<template #wikiLink="{ content }">
<gl-link :href="wikiPagePath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
</template>

View file

@ -6,9 +6,10 @@ import Translate from '~/vue_shared/translate';
import GLForm from '../../../gl_form';
import ZenMode from '../../../zen_mode';
import deleteWikiModal from './components/delete_wiki_modal.vue';
import wikiAlert from './components/wiki_alert.vue';
import Wikis from './wikis';
export default () => {
const createModalVueApp = () => {
new Wikis(); // eslint-disable-line no-new
new ShortcutsWiki(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
@ -39,3 +40,28 @@ export default () => {
});
}
};
const createAlertVueApp = () => {
const el = document.getElementById('js-wiki-error');
if (el) {
const { error, wikiPagePath } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
render(createElement) {
return createElement(wikiAlert, {
props: {
error,
wikiPagePath,
},
});
},
});
}
};
export default () => {
createModalVueApp();
createAlertVueApp();
};

View file

@ -3,7 +3,6 @@ import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
import { __ } from '~/locale';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import { PIPELINES_TABLE } from '../../constants';
import eventHub from '../../event_hub';
import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
@ -57,7 +56,6 @@ export default {
default: null,
},
},
pipelinesTable: PIPELINES_TABLE,
data() {
return {
isRetrying: false,
@ -173,6 +171,10 @@ export default {
this.isRetrying = true;
eventHub.$emit('retryPipeline', this.pipeline.retry_path);
},
handlePipelineActionRequestComplete() {
// warn the pipelines table to update
eventHub.$emit('refreshPipelinesTable');
},
},
};
</script>
@ -220,9 +222,9 @@ export default {
data-testid="widget-mini-pipeline-graph"
>
<pipeline-stage
:type="$options.pipelinesTable"
:stage="stage"
:update-dropdown="updateGraphDropdown"
@pipelineActionRequestComplete="handlePipelineActionRequestComplete"
/>
</div>
</template>

View file

@ -15,7 +15,6 @@ import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/u
import { deprecatedCreateFlash as Flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { PIPELINES_TABLE } from '../../constants';
import eventHub from '../../event_hub';
import JobItem from '../graph/job_item.vue';
@ -39,11 +38,6 @@ export default {
required: false,
default: false,
},
type: {
type: String,
required: false,
default: '',
},
},
data() {
return {
@ -90,13 +84,11 @@ export default {
return this.$el.classList.contains('show');
},
pipelineActionRequestComplete() {
if (this.type === PIPELINES_TABLE) {
// warn the pipelines table to update
eventHub.$emit('refreshPipelinesTable');
return;
}
// close the dropdown in MR widget
this.$refs.stageGlDropdown.hide();
// warn the pipelines table to update
this.$emit('pipelineActionRequestComplete');
},
},
};

View file

@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300;
export const FILTER_PIPELINES_SEARCH_DELAY = 200;
export const ANY_TRIGGER_AUTHOR = 'Any';

View file

@ -48,6 +48,7 @@ export default {
:title="title"
:aria-label="title"
variant="danger"
category="secondary"
icon="remove"
@click="$emit('delete')"
/>

View file

@ -112,10 +112,11 @@ module WikiActions
wiki_page_path(wiki, page)
)
else
@error = response.message
render 'shared/wikis/edit'
end
rescue WikiPage::PageChangedError, WikiPage::PageRenameError => e
@error = e
@error = e.message
render 'shared/wikis/edit'
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables

View file

@ -50,24 +50,6 @@ module WikiHelper
end
end
def wiki_page_errors(error)
return unless error
content_tag(:div, class: 'alert alert-danger') do
case error
when WikiPage::PageChangedError
page_link = link_to s_("WikiPageConflictMessage|the page"), wiki_page_path(@wiki, @page), target: "_blank"
concat(
(s_("WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs.") % { page_link: page_link }).html_safe
)
when WikiPage::PageRenameError
s_("WikiEdit|There is already a page with the same title in that path.")
else
error.message
end
end
end
def wiki_attachment_upload_url
case @wiki.container
when Project

View file

@ -13,6 +13,7 @@ class Iteration < ApplicationRecord
}.with_indifferent_access.freeze
include AtomicInternalId
include Timebox
belongs_to :project
belongs_to :group
@ -23,22 +24,22 @@ class Iteration < ApplicationRecord
validates :start_date, presence: true
validates :due_date, presence: true
validates :iterations_cadence, presence: true, unless: -> { project_id.present? }
validate :dates_do_not_overlap, if: :start_or_due_dates_changed?
validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation
validate :no_project, unless: :skip_project_validation
validate :validate_group
before_create :set_iterations_cadence
before_validation :set_iterations_cadence, unless: -> { project_id.present? }
before_create :set_past_iteration_state
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
scope :closed, -> { with_state(:closed) }
scope :within_timeframe, -> (start_date, end_date) do
where('start_date IS NOT NULL OR due_date IS NOT NULL')
.where('start_date IS NULL OR start_date <= ?', end_date)
.where('due_date IS NULL OR due_date >= ?', start_date)
where('start_date <= ?', end_date).where('due_date >= ?', start_date)
end
scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) }
@ -106,30 +107,20 @@ class Iteration < ApplicationRecord
start_date_changed? || due_date_changed?
end
# ensure dates do not overlap with other Iterations in the same group/project tree
# ensure dates do not overlap with other Iterations in the same cadence tree
def dates_do_not_overlap
iterations = if parent_group.present? && resource_parent.is_a?(Project)
Iteration.where(group: parent_group.self_and_ancestors).or(project.iterations)
elsif parent_group.present?
Iteration.where(group: parent_group.self_and_ancestors)
else
project.iterations
end
return unless iterations_cadence
return unless iterations_cadence.iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
return unless iterations.where.not(id: self.id).within_timeframe(start_date, due_date).exists?
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations"))
# for now we only have a single default cadence within a group just to wrap the iterations into a set.
# once we introduce multiple cadences per group we need to change this message.
# related issue: https://gitlab.com/gitlab-org/gitlab/-/issues/299312
errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations within this group"))
end
# ensure dates are in the future
def future_date
if start_date_changed?
errors.add(:start_date, s_("Iteration|cannot be in the past")) if start_date < Date.current
if start_or_due_dates_changed?
errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now
end
if due_date_changed?
errors.add(:due_date, s_("Iteration|cannot be in the past")) if due_date < Date.current
errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now
end
end
@ -140,6 +131,12 @@ class Iteration < ApplicationRecord
errors.add(:project_id, s_("is not allowed. We do not currently support project-level iterations"))
end
def set_past_iteration_state
# if we create an iteration in the past, we set the state to closed right away,
# no need to wait for IterationsUpdateStatusWorker to do so.
self.state = :closed if due_date < Date.current
end
# TODO: this method should be removed as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296099
def set_iterations_cadence
return if iterations_cadence
@ -147,13 +144,20 @@ class Iteration < ApplicationRecord
# issue to clarify project iterations: https://gitlab.com/gitlab-org/gitlab/-/issues/299864
return unless group
self.iterations_cadence = group.iterations_cadences.first || create_default_cadence
# we need this as we use the cadence to validate the dates overlap for this iteration,
# so in the case this runs before background migration we need to first set all iterations
# in this group to a cadence before we can validate the dates overlap.
default_cadence = find_or_create_default_cadence
group.iterations.where(iterations_cadence_id: nil).update_all(iterations_cadence_id: default_cadence.id)
self.iterations_cadence = default_cadence
end
def create_default_cadence
def find_or_create_default_cadence
cadence_title = "#{group.name} Iterations"
start_date = self.start_date || Date.today
Iterations::Cadence.create!(group: group, title: cadence_title, start_date: start_date)
::Iterations::Cadence.create_with(title: cadence_title, start_date: start_date).safe_find_or_create_by!(group: group)
end
# TODO: remove this as part of https://gitlab.com/gitlab-org/gitlab/-/issues/296100

View file

@ -82,6 +82,8 @@ class Namespace < ApplicationRecord
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
before_save :ensure_delayed_project_removal_assigned_to_namespace_settings, if: :delayed_project_removal_changed?
scope :for_user, -> { where('type IS NULL') }
scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) }
scope :include_route, -> { includes(:route) }
@ -408,6 +410,13 @@ class Namespace < ApplicationRecord
private
def ensure_delayed_project_removal_assigned_to_namespace_settings
return if Feature.disabled?(:migrate_delayed_project_removal, default_enabled: true)
self.namespace_settings || build_namespace_settings
namespace_settings.delayed_project_removal = delayed_project_removal
end
def all_projects_with_pages
if all_projects.pages_metadata_not_migrated.exists?
Gitlab::BackgroundMigration::MigratePagesMetadata.new.perform_on_relation(

View file

@ -205,14 +205,15 @@ class WikiPage
last_commit_sha = attrs.delete(:last_commit_sha)
if last_commit_sha && last_commit_sha != self.last_commit_sha
raise PageChangedError
raise PageChangedError, s_(
'WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{wikiLinkStart}the page%{wikiLinkEnd} and make sure your changes will not unintentionally remove theirs.')
end
update_attributes(attrs)
if title.present? && title_changed? && wiki.find_page(title).present?
attributes[:title] = page.title
raise PageRenameError
raise PageRenameError, s_('WikiEdit|There is already a page with the same title in that path.')
end
save do

View file

@ -33,7 +33,7 @@ module Groups
Group.transaction do
if @group.save
@group.add_owner(current_user)
@group.create_namespace_settings
@group.create_namespace_settings unless @group.namespace_settings
Service.create_from_active_default_integrations(@group, :group_id)
OnboardingProgress.onboard(@group)
end

View file

@ -1,7 +1,8 @@
- wiki_page_title @page, @page.persisted? ? _('Edit') : _('New')
- add_page_specific_style 'page_bundles/wiki'
= wiki_page_errors(@error)
- if @error
#js-wiki-error{ data: { error: @error, wiki_page_path: wiki_page_path(@wiki, @page) } }
.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row
= wiki_sidebar_toggle_button

View file

@ -0,0 +1,5 @@
---
title: Move wiki helper alert to Vue
merge_request: 54517
author:
type: other

View file

@ -0,0 +1,5 @@
---
title: 'Registry: make delete icon buttons secondary'
merge_request: 54545
author:
type: changed

View file

@ -1,5 +0,0 @@
---
title: Fix Metric tab not showing up on operations page
merge_request: 54736
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: Migrate namespaces delayed_project_removal to namespace_settings
merge_request: 53916
author:
type: changed

View file

@ -1,6 +0,0 @@
---
title: Fix creating the idx_on_issues_where_service_desk_reply_to_is_not_null index
before the post migration
merge_request: 54346
author:
type: other

View file

@ -1,5 +0,0 @@
---
title: Fix keep latest artifacts checkbox being always disabled
merge_request: 54669
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: Remove backup_labels table
merge_request: 54856
author:
type: other

View file

@ -0,0 +1,5 @@
---
title: Allow overlapping iteration dates with ancestor group iterations and restrict dates overlapping for iterations within same group
merge_request: 52403
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Allow creation of iterations in the past
merge_request: 52403
author:
type: changed

View file

@ -1,5 +0,0 @@
---
title: Updates authorization for linting endpoint
merge_request: 54492
author:
type: changed

View file

@ -1,5 +0,0 @@
---
title: Restore missing horizontal scrollbar on issue boards
merge_request: 54634
author:
type: fixed

View file

@ -1,5 +0,0 @@
---
title: Send SIGINT instead of SIGQUIT to puma
merge_request: 54446
author: Jörg Behrmann @behrmann
type: fixed

View file

@ -1,5 +0,0 @@
---
title: Reset description template names cache key to reload an updated templates structure
merge_request: 54614
author:
type: fixed

View file

@ -1,5 +0,0 @@
---
title: Fix S3 object storage failing when endpoint is not specified
merge_request: 54868
author:
type: fixed

View file

@ -1,5 +0,0 @@
---
title: Fix N+1 SQL regression in exporting issues to CSV
merge_request: 54287
author:
type: performance

View file

@ -0,0 +1,8 @@
---
name: migrate_delayed_project_removal
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53916
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/300207
milestone: '13.9'
type: development
group: group::access
default_enabled: true

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
class AddIterationsCadenceDateRangeConstraint < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
execute <<~SQL
ALTER TABLE sprints
ADD CONSTRAINT iteration_start_and_due_date_iterations_cadence_id_constraint
EXCLUDE USING gist
( iterations_cadence_id WITH =,
daterange(start_date, due_date, '[]') WITH &&
)
WHERE (group_id IS NOT NULL)
SQL
end
end
def down
with_lock_retries do
execute <<~SQL
ALTER TABLE sprints
DROP CONSTRAINT IF EXISTS iteration_start_and_due_date_iterations_cadence_id_constraint
SQL
end
end
end

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
class RemoveIterationGroupDateRangeConstraint < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
execute <<~SQL
ALTER TABLE sprints
DROP CONSTRAINT IF EXISTS iteration_start_and_due_daterange_group_id_constraint
SQL
end
end
def down
with_lock_retries do
execute <<~SQL
ALTER TABLE sprints
ADD CONSTRAINT iteration_start_and_due_daterange_group_id_constraint
EXCLUDE USING gist
( group_id WITH =,
daterange(start_date, due_date, '[]') WITH &&
)
WHERE (group_id IS NOT NULL)
SQL
end
end
end

View file

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddDelayedProjectRemovalToNamespaceSettings < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :namespace_settings, :delayed_project_removal, :boolean, default: false, null: false
end
end

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class AddIndexToNamespacesDelayedProjectRemoval < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'tmp_idx_on_namespaces_delayed_project_removal'
disable_ddl_transaction!
def up
add_concurrent_index :namespaces, :id, name: INDEX_NAME, where: 'delayed_project_removal = TRUE'
end
def down
remove_concurrent_index_by_name :namespaces, INDEX_NAME
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddSprintsStartDateNotNullCheckConstraint < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_not_null_constraint(:sprints, :start_date, validate: false)
end
def down
remove_not_null_constraint(:sprints, :start_date)
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class AddSprintsDueDateNotNullCheckConstraint < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_not_null_constraint(:sprints, :due_date, validate: false)
end
def down
remove_not_null_constraint(:sprints, :due_date)
end
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
class MigrateDelayedProjectRemovalFromNamespacesToNamespaceSettings < ActiveRecord::Migration[6.0]
DOWNTIME = false
class Namespace < ActiveRecord::Base
self.table_name = 'namespaces'
include ::EachBatch
end
def up
Namespace.select(:id).where(delayed_project_removal: true).each_batch do |batch|
values = batch.map { |record| "(#{record.id}, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)" }
execute <<-EOF.strip_heredoc
INSERT INTO namespace_settings (namespace_id, delayed_project_removal, created_at, updated_at)
VALUES #{values.join(', ')}
ON CONFLICT (namespace_id) DO UPDATE
SET delayed_project_removal = TRUE
EOF
end
end
def down
# no-op
end
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
class RemoveBackupLabelsForeignKeys < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
with_lock_retries do
remove_foreign_key_if_exists(:backup_labels, :projects)
remove_foreign_key_if_exists(:backup_labels, :namespaces)
end
end
def down
add_concurrent_foreign_key(:backup_labels, :projects, column: :project_id, on_delete: :cascade)
add_concurrent_foreign_key(:backup_labels, :namespaces, column: :group_id, on_delete: :cascade)
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
class RemoveBackupLabelsTable < ActiveRecord::Migration[6.0]
DOWNTIME = false
def up
drop_table :backup_labels
end
def down
create_table :backup_labels, id: false do |t|
t.integer :id, null: false
t.string :title
t.string :color
t.integer :project_id
t.timestamps null: true # rubocop:disable Migration/Timestamps
t.boolean :template, default: false
t.string :description
t.text :description_html
t.string :type
t.integer :group_id
t.integer :cached_markdown_version
t.integer :restore_action
t.string :new_title
end
execute 'ALTER TABLE backup_labels ADD PRIMARY KEY (id)'
add_index :backup_labels, [:group_id, :project_id, :title], name: 'backup_labels_group_id_project_id_title_idx', unique: true
add_index :backup_labels, [:group_id, :title], where: 'project_id = NULL::integer', name: 'backup_labels_group_id_title_idx'
add_index :backup_labels, :project_id, name: 'backup_labels_project_id_idx'
add_index :backup_labels, :template, name: 'backup_labels_template_idx', where: 'template'
add_index :backup_labels, :title, name: 'backup_labels_title_idx'
add_index :backup_labels, [:type, :project_id], name: 'backup_labels_type_project_id_idx'
end
end

View file

@ -0,0 +1 @@
32f636ffad4d2c6a002129d6e3eaeaf5d8f420dcc1273665129dc4d23f2e0dbe

View file

@ -0,0 +1 @@
951f46f88c1b07505e0b560e802a8bd701db7d379342d97b0bff3ad90e81fb02

View file

@ -0,0 +1 @@
8c1da1c7edba16993da93d9075ad2a3624b8c12ccf73a241e1a166014a99e254

View file

@ -0,0 +1 @@
7678d97de752e7a9a571d80febc74eb44c699c7b1967690d9a2391036caea5d2

View file

@ -0,0 +1 @@
25820a3d060826a082565f12a3ac96deafbbde750f5756d71e34d14801ec6148

View file

@ -0,0 +1 @@
545747e86481c74832a6df55764ab97ecfefc4446df9cc2366a8ce9d9c400ea4

View file

@ -0,0 +1 @@
91969bfc791cd7bc78b940aa6fed345b13a3186db0b89828428b798aa4f7949e

View file

@ -0,0 +1 @@
0bccf1ff356a4b9c08d472e8b63070b497f331c2dfaded1bdb2cf01860df8903

View file

@ -0,0 +1 @@
b2508d46edbfbba24df65731f6e285886acbb6352a900dd1c6a985a686252ef0

View file

@ -9747,23 +9747,6 @@ CREATE SEQUENCE background_migration_jobs_id_seq
ALTER SEQUENCE background_migration_jobs_id_seq OWNED BY background_migration_jobs.id;
CREATE TABLE backup_labels (
id integer NOT NULL,
title character varying,
color character varying,
project_id integer,
created_at timestamp without time zone,
updated_at timestamp without time zone,
template boolean DEFAULT false,
description character varying,
description_html text,
type character varying,
group_id integer,
cached_markdown_version integer,
restore_action integer,
new_title character varying
);
CREATE TABLE badges (
id integer NOT NULL,
link_url character varying NOT NULL,
@ -14370,6 +14353,7 @@ CREATE TABLE namespace_settings (
allow_mfa_for_subgroups boolean DEFAULT true NOT NULL,
default_branch_name text,
repository_read_only boolean DEFAULT false NOT NULL,
delayed_project_removal boolean DEFAULT false NOT NULL,
CONSTRAINT check_0ba93c78c7 CHECK ((char_length(default_branch_name) <= 255))
);
@ -19823,9 +19807,6 @@ ALTER TABLE ONLY aws_roles
ALTER TABLE ONLY background_migration_jobs
ADD CONSTRAINT background_migration_jobs_pkey PRIMARY KEY (id);
ALTER TABLE ONLY backup_labels
ADD CONSTRAINT backup_labels_pkey PRIMARY KEY (id);
ALTER TABLE ONLY badges
ADD CONSTRAINT badges_pkey PRIMARY KEY (id);
@ -19889,9 +19870,15 @@ ALTER TABLE ONLY chat_teams
ALTER TABLE vulnerability_scanners
ADD CONSTRAINT check_37608c9db5 CHECK ((char_length(vendor) <= 255)) NOT VALID;
ALTER TABLE sprints
ADD CONSTRAINT check_ccd8a1eae0 CHECK ((start_date IS NOT NULL)) NOT VALID;
ALTER TABLE group_import_states
ADD CONSTRAINT check_cda75c7c3f CHECK ((user_id IS NOT NULL)) NOT VALID;
ALTER TABLE sprints
ADD CONSTRAINT check_df3816aed7 CHECK ((due_date IS NOT NULL)) NOT VALID;
ALTER TABLE ONLY ci_build_needs
ADD CONSTRAINT ci_build_needs_pkey PRIMARY KEY (id);
@ -20400,7 +20387,7 @@ ALTER TABLE ONLY issues_self_managed_prometheus_alert_events
ADD CONSTRAINT issues_self_managed_prometheus_alert_events_pkey PRIMARY KEY (issue_id, self_managed_prometheus_alert_event_id);
ALTER TABLE ONLY sprints
ADD CONSTRAINT iteration_start_and_due_daterange_group_id_constraint EXCLUDE USING gist (group_id WITH =, daterange(start_date, due_date, '[]'::text) WITH &&) WHERE ((group_id IS NOT NULL));
ADD CONSTRAINT iteration_start_and_due_date_iterations_cadence_id_constraint EXCLUDE USING gist (iterations_cadence_id WITH =, daterange(start_date, due_date, '[]'::text) WITH &&) WHERE ((group_id IS NOT NULL));
ALTER TABLE ONLY sprints
ADD CONSTRAINT iteration_start_and_due_daterange_project_id_constraint EXCLUDE USING gist (project_id WITH =, daterange(start_date, due_date, '[]'::text) WITH &&) WHERE ((project_id IS NOT NULL));
@ -21271,18 +21258,6 @@ CREATE UNIQUE INDEX any_approver_project_rule_type_unique_index ON approval_proj
CREATE INDEX approval_mr_rule_index_merge_request_id ON approval_merge_request_rules USING btree (merge_request_id);
CREATE UNIQUE INDEX backup_labels_group_id_project_id_title_idx ON backup_labels USING btree (group_id, project_id, title);
CREATE INDEX backup_labels_group_id_title_idx ON backup_labels USING btree (group_id, title) WHERE (project_id = NULL::integer);
CREATE INDEX backup_labels_project_id_idx ON backup_labels USING btree (project_id);
CREATE INDEX backup_labels_template_idx ON backup_labels USING btree (template) WHERE template;
CREATE INDEX backup_labels_title_idx ON backup_labels USING btree (title);
CREATE INDEX backup_labels_type_project_id_idx ON backup_labels USING btree (type, project_id);
CREATE UNIQUE INDEX bulk_import_trackers_uniq_relation_by_entity ON bulk_import_trackers USING btree (bulk_import_entity_id, relation);
CREATE INDEX ci_builds_gitlab_monitor_metrics ON ci_builds USING btree (status, created_at, project_id) WHERE ((type)::text = 'Ci::Build'::text);
@ -23869,6 +23844,8 @@ CREATE UNIQUE INDEX term_agreements_unique_index ON term_agreements USING btree
CREATE INDEX tmp_idx_deduplicate_vulnerability_occurrences ON vulnerability_occurrences USING btree (project_id, report_type, location_fingerprint, primary_identifier_id, id);
CREATE INDEX tmp_idx_on_namespaces_delayed_project_removal ON namespaces USING btree (id) WHERE (delayed_project_removal = true);
CREATE INDEX tmp_index_on_security_findings_scan_id ON security_findings USING btree (scan_id) WHERE (uuid IS NULL);
CREATE INDEX tmp_index_on_vulnerabilities_non_dismissed ON vulnerabilities USING btree (id) WHERE (state <> 2);
@ -24461,9 +24438,6 @@ ALTER TABLE ONLY vulnerabilities
ALTER TABLE ONLY labels
ADD CONSTRAINT fk_7de4989a69 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY backup_labels
ADD CONSTRAINT fk_7de4989a69 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY merge_requests
ADD CONSTRAINT fk_7e85395a64 FOREIGN KEY (sprint_id) REFERENCES sprints(id) ON DELETE CASCADE;
@ -25979,9 +25953,6 @@ ALTER TABLE ONLY serverless_domain_cluster
ALTER TABLE ONLY labels
ADD CONSTRAINT fk_rails_c1ac5161d8 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE ONLY backup_labels
ADD CONSTRAINT fk_rails_c1ac5161d8 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE ONLY project_feature_usages
ADD CONSTRAINT fk_rails_c22a50024b FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;

View file

@ -26,7 +26,7 @@ Read more on [pagination](README.md#pagination).
Get all Merge Trains of the requested project:
```txt
```shell
GET /projects/:id/merge_trains
GET /projects/:id/merge_trains?scope=complete
```

View file

@ -10,41 +10,20 @@ disqus_identifier: 'https://docs.gitlab.com/ee/ci/environments.html'
> Introduced in GitLab 8.9.
Environments allow control of the continuous deployment of your software,
all within GitLab.
Environments describe where code is deployed.
## Introduction
There are many stages required in the software development process before the software is ready
for public consumption.
For example:
1. Develop your code.
1. Test your code.
1. Deploy your code into a testing or staging environment before you release it to the public.
This helps find bugs in your software, and also in the deployment process as well.
GitLab CI/CD is capable of not only testing or building your projects, but also
deploying them in your infrastructure, with the added benefit of giving you a
way to track your deployments. In other words, you always know what is
currently being deployed or has been deployed on your servers.
It's important to know that:
- Environments are like tags for your CI jobs, describing where code gets deployed.
- Deployments are created when [GitLab CI/CD](../yaml/README.md) is used to deploy versions of code to environments.
Each time [GitLab CI/CD](../yaml/README.md) deploys a version of code to an environment,
a deployment is created.
GitLab:
- Provides a full history of your deployments for each environment.
- Keeps track of your deployments, so you always know what is currently being deployed on your
- Provides a full history of deployments to each environment.
- Tracks your deployments, so you always know what is currently deployed on your
servers.
If you have a deployment service such as [Kubernetes](../../user/project/clusters/index.md)
associated with your project, you can use it to assist with your deployments, and
can even access a [web terminal](#web-terminals) for your environment from within GitLab!
If you have a deployment service like [Kubernetes](../../user/project/clusters/index.md)
associated with your project, you can use it to assist with your deployments.
You can even access a [web terminal](#web-terminals) for your environment from within GitLab.
## Configuring environments
@ -69,6 +48,10 @@ In the scenario:
### Defining environments
You can create environments manually in the web interface, but we recommend
that you define your environments in the `.gitlab-ci.yml` file. After the first
deploy, the environments are automatically created.
Let's consider the following `.gitlab-ci.yml` example:
```yaml
@ -539,8 +522,8 @@ So now, every branch:
- Gets its own environment.
- Is deployed to its own unique location, with the added benefit of:
- Having a [history of deployments](#viewing-deployment-history).
- Being able to [rollback changes](#retrying-and-rolling-back) if needed.
- Having a [history of deployments](#view-the-deployment-history).
- Being able to [roll back changes](#retry-or-roll-back-a-deployment) if needed.
For more information, see [Using the environment URL](#using-the-environment-url).
@ -555,72 +538,46 @@ For more information, see [Protected environments](protected_environments.md).
Once environments are configured, GitLab provides many features for working with them,
as documented below.
### Viewing environments and deployments
### View environments and deployments
A list of environments and deployment statuses is available on each project's **Operations > Environments** page.
Prerequisites:
For example:
- You must have a minimum of [Reporter permission](../../user/permissions.md#project-members-permissions).
![Environment view](../img/environments_available_13_7.png)
To view a list of environments and deployment statuses:
This example shows:
- Go to the project's **Operations > Environments** page.
- The environment's name with a link to its deployments.
- The last deployment ID number and who performed it.
- The job ID of the last deployment with its respective job name.
- The commit information of the last deployment, such as who committed it, to what
branch, and the Git SHA of the commit.
- The exact time the last deployment was performed.
- The upcoming deployment, if a deployment for the environment is in progress.
- When the environment stops automatically.
- A button that takes you to the URL that you defined under the `environment` keyword
in `.gitlab-ci.yml`.
- A number of deployment actions, including:
- Prevent the environment from [stopping automatically](#automatically-stopping-an-environment).
- [Open the live environment](#using-the-environment-url).
- Trigger [a manual deployment to a different environment](#configuring-manual-deployments).
- [Retry the deployment](#retrying-and-rolling-back).
- [Stop the environment](#stopping-an-environment).
The **Environments** page shows the latest deployments.
The information shown in the **Environments** page is limited to the latest
deployments, but an environment can have multiple deployments.
- An environment can have multiple deployments. Some deployments may not be listed on the page.
- Only deploys that happen after your `.gitlab-ci.yml` is properly configured
show up in the **Environment** and **Last deployment** lists.
> **Notes:**
>
> - While you can create environments manually in the web interface, we recommend
> that you define your environments in `.gitlab-ci.yml` first. They will
> be automatically created for you after the first deploy.
> - The environments page can only be viewed by users with [Reporter permission](../../user/permissions.md#project-members-permissions)
> and above. For more information on permissions, see the [permissions documentation](../../user/permissions.md).
> - Only deploys that happen after your `.gitlab-ci.yml` is properly configured
> show up in the **Environment** and **Last deployment** lists.
### View the deployment history
### Viewing deployment history
GitLab tracks your deployments, so you:
GitLab keeps track of your deployments, so you:
- Always know what is currently deployed on your servers.
- Have the full history of your deployments for every environment.
- Always know what is currently being deployed on your servers.
- Can have the full history of your deployments for every environment.
Clicking on an environment shows the history of its deployments. Here's an example **Environments** page
with multiple deployments:
- Go to the project's **Operations > Environments** page.
![Deployments](../img/deployments_view.png)
This view is similar to the **Environments** page, but all deployments are shown. Also in this view
is a **Rollback** button. For more information, see [Retrying and rolling back](#retrying-and-rolling-back).
This view is similar to the **Environments** page, but all deployments are shown.
### Retrying and rolling back
### Retry or roll back a deployment
If there is a problem with a deployment, you can retry it or roll it back.
To retry or rollback a deployment:
1. Navigate to **Operations > Environments**.
1. Click on the environment.
1. In the deployment history list for the environment, click the:
- **Retry** button next to the last deployment, to retry that deployment.
- **Rollback** button next to a previously successful deployment, to roll back to that deployment.
1. Go to the project's **Operations > Environments**.
1. Select the environment.
1. In the deployment history list for the environment:
- To retry a deployment, select **Retry**.
- to roll back to a deployment, next to a previously successful deployment, select **Rollback**.
#### What to expect with a rollback
@ -662,7 +619,7 @@ from source files to public pages in the environment set for Review Apps.
Stopping an environment:
- Moves it from the list of **Available** environments to the list of **Stopped**
environments on the [**Environments** page](#viewing-environments-and-deployments).
environments on the [**Environments** page](#view-environments-and-deployments).
- Executes an [`on_stop` action](../yaml/README.md#environmenton_stop), if defined.
This is often used when multiple developers are working on a project at the same time,
@ -819,7 +776,7 @@ Environments can also be deleted by using the [Environments API](../../api/envir
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/208655) in GitLab 13.2.
By default, GitLab creates a [deployment](#viewing-deployment-history) every time a
By default, GitLab creates a [deployment](#view-the-deployment-history) every time a
build with the specified environment runs. Newer deployments can also
[cancel older ones](deployment_safety.md#skip-outdated-deployment-jobs).
@ -897,7 +854,7 @@ severity is shown, so you can identify which environments need immediate attenti
When the issue that triggered the alert is resolved, it is removed and is no
longer visible on the environment page.
If the alert requires a [rollback](#retrying-and-rolling-back), you can select the
If the alert requires a [rollback](#retry-or-roll-back-a-deployment), you can select the
deployment tab from the environment page and select which deployment to roll back to.
#### Auto Rollback **(ULTIMATE)**

View file

@ -187,7 +187,7 @@ For example, if you start rolling out new code and:
- Users do not experience trouble, GitLab can automatically complete the deployment from 0% to 100%.
- Users experience trouble with the new code, you can stop the timed incremental rollout by canceling the pipeline
and [rolling](../environments/index.md#retrying-and-rolling-back) back to the last stable version.
and [rolling](../environments/index.md#retry-or-roll-back-a-deployment) back to the last stable version.
![Pipelines example](img/pipeline_incremental_rollout.png)

View file

@ -84,7 +84,7 @@ displayed by GitLab:
![pipeline status](img/pipeline_status.png)
If anything goes wrong, you can
[roll back](../environments/index.md#retrying-and-rolling-back) the changes:
[roll back](../environments/index.md#retry-or-roll-back-a-deployment) the changes:
![rollback button](img/rollback.png)

View file

@ -593,7 +593,7 @@ required to go from `10%` to `100%`, you can jump to whatever job you want.
You can also scale down by running a lower percentage job, just before hitting
`100%`. Once you get to `100%`, you can't scale down, and you'd have to roll
back by redeploying the old version using the
[rollback button](../../ci/environments/index.md#retrying-and-rolling-back) in the
[rollback button](../../ci/environments/index.md#retry-or-roll-back-a-deployment) in the
environment page.
Below, you can see how the pipeline appears if the rollout or staging

View file

@ -222,7 +222,7 @@ you to common environment tasks:
- **Terminal** (**{terminal}**) - Opens a [web terminal](../../ci/environments/index.md#web-terminals)
session inside the container where the application is running
- **Re-deploy to environment** (**{repeat}**) - For more information, see
[Retrying and rolling back](../../ci/environments/index.md#retrying-and-rolling-back)
[Retrying and rolling back](../../ci/environments/index.md#retry-or-roll-back-a-deployment)
- **Stop environment** (**{stop}**) - For more information, see
[Stopping an environment](../../ci/environments/index.md#stopping-an-environment)

View file

@ -475,7 +475,7 @@ URLs to scan can be specified by either of the following methods:
To define the URLs to scan in a file, create a plain text file with one path per line.
```txt
```plaintext
page1.html
/page2.html
category/shoes/page1.html

View file

@ -11,7 +11,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
Custom project templates are useful for organizations that need to create many similar types of [projects](../project/index.md) and want to start from the same jumping-off point.
## Setting up Group-level Project Templates
## Setting up group-level project templates
To use a custom project template for a new project you need to:
@ -30,7 +30,7 @@ To use a custom project template for a new project you need to:
Here is a sample group/project structure for a hypothetical "Acme Co" for project templates:
```txt
```plaintext
# GitLab instance and group
gitlab.com/acmeco/
# Subgroups

View file

@ -362,6 +362,11 @@ the user gets the highest access level from the groups. For example, if one grou
is linked as `Guest` and another `Maintainer`, a user in both groups gets `Maintainer`
access.
Users who are not members of any mapped SAML groups are removed from the GitLab group.
You can prevent accidental member removal. For example, if you have a SAML group link for `Owner` level access
in a top-level group, you should also set up a group link for all other members.
## Glossary
| Term | Description |

View file

@ -16849,10 +16849,7 @@ msgstr ""
msgid "Iterations"
msgstr ""
msgid "Iteration|Dates cannot overlap with other existing Iterations"
msgstr ""
msgid "Iteration|cannot be in the past"
msgid "Iteration|Dates cannot overlap with other existing Iterations within this group"
msgstr ""
msgid "Iteration|cannot be more than 500 years in the future"
@ -33642,10 +33639,7 @@ msgstr ""
msgid "WikiPageConfirmDelete|Delete page %{pageTitle}?"
msgstr ""
msgid "WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{page_link} and make sure your changes will not unintentionally remove theirs."
msgstr ""
msgid "WikiPageConflictMessage|the page"
msgid "WikiPageConflictMessage|Someone edited the page the same time you did. Please check out %{wikiLinkStart}the page%{wikiLinkEnd} and make sure your changes will not unintentionally remove theirs."
msgstr ""
msgid "WikiPageCreate|Create %{pageTitle}"

View file

@ -5,16 +5,6 @@ module QA
module Project
module_function
def add_member(project:, username:)
project.visit!
Page::Project::Menu.perform(&:click_members)
Page::Project::Members.perform do |member_settings|
member_settings.add_member(username)
end
end
def go_to_create_project_from_template
if Page::Project::NewExperiment.perform(&:shown?)
Page::Project::NewExperiment.perform(&:click_create_from_template_link)

View file

@ -15,7 +15,7 @@ FactoryBot.define do
raise "Don't set owner for groups, use `group.add_owner(user)` instead"
end
create(:namespace_settings, namespace: group)
create(:namespace_settings, namespace: group) unless group.namespace_settings
end
trait :public do

View file

@ -1,10 +1,10 @@
import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue';
import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json';
import * as transformationUtils from '~/alerts_settings/utils/mapping_transformations';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import alertFields from '../mocks/alertFields.json';
import alertFields from '../mocks/alert_fields.json';
import parsedMapping from '../mocks/parsed_mapping.json';
describe('AlertMappingBuilder', () => {
let wrapper;
@ -12,8 +12,8 @@ describe('AlertMappingBuilder', () => {
function mountComponent() {
wrapper = shallowMount(AlertMappingBuilder, {
propsData: {
parsedPayload: parsedMapping.samplePayload.payloadAlerFields.nodes,
savedMapping: parsedMapping.storedMapping.nodes,
parsedPayload: parsedMapping.payloadAlerFields,
savedMapping: parsedMapping.payloadAttributeMappings,
alertFields,
},
});
@ -33,6 +33,15 @@ describe('AlertMappingBuilder', () => {
const findColumnInRow = (row, column) =>
wrapper.findAll('.gl-display-table-row').at(row).findAll('.gl-display-table-cell ').at(column);
const getDropdownContent = (dropdown, types) => {
const searchBox = dropdown.findComponent(GlSearchBoxByType);
const dropdownItems = dropdown.findAllComponents(GlDropdownItem);
const mappingOptions = parsedMapping.payloadAlerFields.filter(({ type }) =>
types.includes(type),
);
return { searchBox, dropdownItems, mappingOptions };
};
it('renders column captions', () => {
expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle);
expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle);
@ -63,10 +72,7 @@ describe('AlertMappingBuilder', () => {
it('renders mapping dropdown for each field', () => {
alertFields.forEach(({ types }, index) => {
const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown);
const searchBox = dropdown.findComponent(GlSearchBoxByType);
const dropdownItems = dropdown.findAllComponents(GlDropdownItem);
const { nodes } = parsedMapping.samplePayload.payloadAlerFields;
const mappingOptions = nodes.filter(({ type }) => types.includes(type));
const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types);
expect(dropdown.exists()).toBe(true);
expect(searchBox.exists()).toBe(true);
@ -80,11 +86,7 @@ describe('AlertMappingBuilder', () => {
expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks));
if (numberOfFallbacks) {
const searchBox = dropdown.findComponent(GlSearchBoxByType);
const dropdownItems = dropdown.findAllComponents(GlDropdownItem);
const { nodes } = parsedMapping.samplePayload.payloadAlerFields;
const mappingOptions = nodes.filter(({ type }) => types.includes(type));
const { searchBox, dropdownItems, mappingOptions } = getDropdownContent(dropdown, types);
expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks));
expect(dropdownItems).toHaveLength(mappingOptions.length);
}

View file

@ -11,7 +11,8 @@ import waitForPromises from 'helpers/wait_for_promises';
import MappingBuilder from '~/alerts_settings/components/alert_mapping_builder.vue';
import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue';
import { typeSet } from '~/alerts_settings/constants';
import alertFields from '../mocks/alertFields.json';
import alertFields from '../mocks/alert_fields.json';
import parsedMapping from '../mocks/parsed_mapping.json';
import { defaultAlertSettingsConfig } from './util';
describe('AlertsSettingsForm', () => {
@ -39,6 +40,9 @@ describe('AlertsSettingsForm', () => {
multiIntegrations,
},
mocks: {
$apollo: {
query: jest.fn(),
},
$toast: {
show: mockToastShow,
},
@ -146,7 +150,7 @@ describe('AlertsSettingsForm', () => {
enableIntegration(0, integrationName);
const sampleMapping = { field: 'test' };
const sampleMapping = parsedMapping.payloadAttributeMappings;
findMappingBuilder().vm.$emit('onMappingUpdate', sampleMapping);
findForm().trigger('submit');
@ -157,7 +161,7 @@ describe('AlertsSettingsForm', () => {
name: integrationName,
active: true,
payloadAttributeMappings: sampleMapping,
payloadExample: null,
payloadExample: '{}',
},
},
]);
@ -275,34 +279,47 @@ describe('AlertsSettingsForm', () => {
});
describe('Test payload section for HTTP integration', () => {
const validSamplePayload = JSON.stringify(alertFields);
const emptySamplePayload = '{}';
beforeEach(() => {
createComponent({
multipleHttpIntegrationsCustomMapping: true,
props: {
data: {
currentIntegration: {
type: typeSet.http,
payloadExample: validSamplePayload,
payloadAttributeMappings: [],
},
alertFields,
active: false,
resetPayloadAndMappingConfirmed: false,
},
props: { alertFields },
});
});
describe.each`
active | resetSamplePayloadConfirmed | disabled
${true} | ${true} | ${undefined}
${false} | ${true} | ${'disabled'}
${true} | ${false} | ${'disabled'}
${false} | ${false} | ${'disabled'}
`('', ({ active, resetSamplePayloadConfirmed, disabled }) => {
const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed';
active | resetPayloadAndMappingConfirmed | disabled
${true} | ${true} | ${undefined}
${false} | ${true} | ${'disabled'}
${true} | ${false} | ${'disabled'}
${false} | ${false} | ${'disabled'}
`('', ({ active, resetPayloadAndMappingConfirmed, disabled }) => {
const payloadResetMsg = resetPayloadAndMappingConfirmed
? 'was confirmed'
: 'was not confirmed';
const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled';
const activeState = active ? 'active' : 'not active';
it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => {
wrapper.setData({
customMapping: { samplePayload: true },
currentIntegration: {
type: typeSet.http,
payloadExample: validSamplePayload,
payloadAttributeMappings: [],
},
active,
resetSamplePayloadConfirmed,
resetPayloadAndMappingConfirmed,
});
await wrapper.vm.$nextTick();
expect(findTestPayloadSection().find(GlFormTextarea).attributes('disabled')).toBe(disabled);
@ -311,20 +328,27 @@ describe('AlertsSettingsForm', () => {
describe('action buttons for sample payload', () => {
describe.each`
resetSamplePayloadConfirmed | samplePayload | caption
${false} | ${true} | ${'Edit payload'}
${true} | ${false} | ${'Submit payload'}
${true} | ${true} | ${'Submit payload'}
${false} | ${false} | ${'Submit payload'}
`('', ({ resetSamplePayloadConfirmed, samplePayload, caption }) => {
const samplePayloadMsg = samplePayload ? 'was provided' : 'was not provided';
const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed';
resetPayloadAndMappingConfirmed | payloadExample | caption
${false} | ${validSamplePayload} | ${'Edit payload'}
${true} | ${emptySamplePayload} | ${'Submit payload'}
${true} | ${validSamplePayload} | ${'Submit payload'}
${false} | ${emptySamplePayload} | ${'Submit payload'}
`('', ({ resetPayloadAndMappingConfirmed, payloadExample, caption }) => {
const samplePayloadMsg = payloadExample ? 'was provided' : 'was not provided';
const payloadResetMsg = resetPayloadAndMappingConfirmed
? 'was confirmed'
: 'was not confirmed';
it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => {
wrapper.setData({
selectedIntegration: typeSet.http,
customMapping: { samplePayload },
resetSamplePayloadConfirmed,
currentIntegration: {
payloadExample,
type: typeSet.http,
active: true,
payloadAttributeMappings: [],
},
resetPayloadAndMappingConfirmed,
});
await wrapper.vm.$nextTick();
expect(findActionBtn().text()).toBe(caption);
@ -333,16 +357,20 @@ describe('AlertsSettingsForm', () => {
});
describe('Parsing payload', () => {
it('displays a toast message on successful parse', async () => {
jest.useFakeTimers();
beforeEach(() => {
wrapper.setData({
selectedIntegration: typeSet.http,
customMapping: { samplePayload: false },
resetPayloadAndMappingConfirmed: true,
});
await wrapper.vm.$nextTick();
});
it('displays a toast message on successful parse', async () => {
jest.spyOn(wrapper.vm.$apollo, 'query').mockResolvedValue({
data: {
project: { alertManagementPayloadFields: [] },
},
});
findActionBtn().vm.$emit('click');
jest.advanceTimersByTime(1000);
await waitForPromises();
@ -350,6 +378,16 @@ describe('AlertsSettingsForm', () => {
'Sample payload has been parsed. You can now map the fields.',
);
});
it('displays an error message under payload field on unsuccessful parse', async () => {
const errorMessage = 'Error parsing paylod';
jest.spyOn(wrapper.vm.$apollo, 'query').mockRejectedValue({ message: errorMessage });
findActionBtn().vm.$emit('click');
await waitForPromises();
expect(findTestPayloadSection().find('.invalid-feedback').text()).toBe(errorMessage);
});
});
});

View file

@ -14,6 +14,8 @@ import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutat
import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql';
import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql';
import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql';
import updateCurrentHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_http_integration.mutation.graphql';
import updateCurrentPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_current_prometheus_integration.mutation.graphql';
import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql';
import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql';
@ -31,7 +33,8 @@ import {
updateHttpVariables,
createPrometheusVariables,
updatePrometheusVariables,
ID,
HTTP_ID,
PROMETHEUS_ID,
errorMsg,
getIntegrationsQueryResponse,
destroyIntegrationResponse,
@ -50,8 +53,30 @@ describe('AlertsSettingsWrapper', () => {
let fakeApollo;
let destroyIntegrationHandler;
useMockIntersectionObserver();
const httpMappingData = {
payloadExample: '{"test: : "field"}',
payloadAttributeMappings: [],
payloadAlertFields: [],
};
const httpIntegrations = {
list: [
{
id: mockIntegrations[0].id,
...httpMappingData,
},
{
id: mockIntegrations[1].id,
...httpMappingData,
},
{
id: mockIntegrations[2].id,
httpMappingData,
},
],
};
const findLoader = () => wrapper.find(IntegrationsList).find(GlLoadingIcon);
const findLoader = () => wrapper.findComponent(IntegrationsList).findComponent(GlLoadingIcon);
const findIntegrationsList = () => wrapper.findComponent(IntegrationsList);
const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr');
async function destroyHttpIntegration(localWrapper) {
@ -197,13 +222,13 @@ describe('AlertsSettingsWrapper', () => {
});
wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {
type: typeSet.http,
variables: { id: ID },
variables: { id: HTTP_ID },
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: resetHttpTokenMutation,
variables: {
id: ID,
id: HTTP_ID,
},
});
});
@ -232,7 +257,7 @@ describe('AlertsSettingsWrapper', () => {
it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => {
createComponent({
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[3] },
loading: false,
});
@ -261,13 +286,13 @@ describe('AlertsSettingsWrapper', () => {
});
wrapper.find(AlertsSettingsForm).vm.$emit('reset-token', {
type: typeSet.prometheus,
variables: { id: ID },
variables: { id: PROMETHEUS_ID },
});
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: resetPrometheusTokenMutation,
variables: {
id: ID,
id: PROMETHEUS_ID,
},
});
});
@ -328,6 +353,42 @@ describe('AlertsSettingsWrapper', () => {
mock.restore();
});
});
it('calls `$apollo.mutate` with `updateCurrentHttpIntegrationMutation` on HTTP integration edit', () => {
createComponent({
data: {
integrations: { list: mockIntegrations },
currentIntegration: mockIntegrations[0],
httpIntegrations,
},
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate');
findIntegrationsList().vm.$emit('edit-integration', updateHttpVariables);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateCurrentHttpIntegrationMutation,
variables: { ...mockIntegrations[0], ...httpMappingData },
});
});
it('calls `$apollo.mutate` with `updateCurrentPrometheusIntegrationMutation` on PROMETHEUS integration edit', () => {
createComponent({
data: {
integrations: { list: mockIntegrations },
currentIntegration: mockIntegrations[3],
httpIntegrations,
},
loading: false,
});
jest.spyOn(wrapper.vm.$apollo, 'mutate');
findIntegrationsList().vm.$emit('edit-integration', updatePrometheusVariables);
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: updateCurrentPrometheusIntegrationMutation,
variables: mockIntegrations[3],
});
});
});
describe('with mocked Apollo client', () => {

View file

@ -1,29 +1,34 @@
const projectPath = '';
export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7';
export const HTTP_ID = 'gid://gitlab/AlertManagement::HttpIntegration/7';
export const PROMETHEUS_ID = 'gid://gitlab/PrometheusService/12';
export const errorMsg = 'Something went wrong';
export const createHttpVariables = {
name: 'Test Pre',
active: true,
projectPath,
type: 'HTTP',
};
export const updateHttpVariables = {
name: 'Test Pre',
active: true,
id: ID,
id: HTTP_ID,
type: 'HTTP',
};
export const createPrometheusVariables = {
apiUrl: 'https://test-pre.com',
active: true,
projectPath,
type: 'PROMETHEUS',
};
export const updatePrometheusVariables = {
apiUrl: 'https://test-pre.com',
active: true,
id: ID,
id: PROMETHEUS_ID,
type: 'PROMETHEUS',
};
export const getIntegrationsQueryResponse = {
@ -99,6 +104,9 @@ export const destroyIntegrationResponse = {
'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
token: '89eb01df471d990ff5162a1c640408cf',
apiUrl: null,
payloadExample: '{"field": "value"}',
payloadAttributeMappings: [],
payloadAlertFields: [],
},
},
},
@ -117,6 +125,9 @@ export const destroyIntegrationResponseWithErrors = {
'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
token: '89eb01df471d990ff5162a1c640408cf',
apiUrl: null,
payloadExample: '{"field": "value"}',
payloadAttributeMappings: [],
payloadAlertFields: [],
},
},
},

View file

@ -0,0 +1,122 @@
{
"payloadAlerFields": [
{
"path": [
"dashboardId"
],
"label": "Dashboard Id",
"type": "string"
},
{
"path": [
"evalMatches"
],
"label": "Eval Matches",
"type": "array"
},
{
"path": [
"createdAt"
],
"label": "Created At",
"type": "datetime"
},
{
"path": [
"imageUrl"
],
"label": "Image Url",
"type": "string"
},
{
"path": [
"message"
],
"label": "Message",
"type": "string"
},
{
"path": [
"orgId"
],
"label": "Org Id",
"type": "string"
},
{
"path": [
"panelId"
],
"label": "Panel Id",
"type": "string"
},
{
"path": [
"ruleId"
],
"label": "Rule Id",
"type": "string"
},
{
"path": [
"ruleName"
],
"label": "Rule Name",
"type": "string"
},
{
"path": [
"ruleUrl"
],
"label": "Rule Url",
"type": "string"
},
{
"path": [
"state"
],
"label": "State",
"type": "string"
},
{
"path": [
"title"
],
"label": "Title",
"type": "string"
},
{
"path": [
"tags",
"tag"
],
"label": "Tags",
"type": "string"
}
],
"payloadAttributeMappings": [
{
"fieldName": "title",
"label": "Title",
"type": "STRING",
"path": ["title"]
},
{
"fieldName": "description",
"label": "description",
"type": "STRING",
"path": ["description"]
},
{
"fieldName": "hosts",
"label": "Host",
"type": "ARRAY",
"path": ["hosts", "host"]
},
{
"fieldName": "startTime",
"label": "Created Atd",
"type": "STRING",
"path": ["time", "createdAt"]
}
]
}

View file

@ -1,29 +1,25 @@
import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json';
import {
getMappingData,
getPayloadFields,
transformForSave,
} from '~/alerts_settings/utils/mapping_transformations';
import alertFields from '../mocks/alertFields.json';
import { getMappingData, transformForSave } from '~/alerts_settings/utils/mapping_transformations';
import alertFields from '../mocks/alert_fields.json';
import parsedMapping from '../mocks/parsed_mapping.json';
describe('Mapping Transformation Utilities', () => {
const nameField = {
label: 'Name',
path: ['alert', 'name'],
type: 'string',
type: 'STRING',
};
const dashboardField = {
label: 'Dashboard Id',
path: ['alert', 'dashboardId'],
type: 'string',
type: 'STRING',
};
describe('getMappingData', () => {
it('should return mapping data', () => {
const result = getMappingData(
alertFields,
getPayloadFields(parsedMapping.samplePayload.payloadAlerFields.nodes.slice(0, 3)),
parsedMapping.storedMapping.nodes.slice(0, 3),
parsedMapping.payloadAlerFields.slice(0, 3),
parsedMapping.payloadAttributeMappings.slice(0, 3),
);
result.forEach((data, index) => {
@ -44,8 +40,8 @@ describe('Mapping Transformation Utilities', () => {
const mockMappingData = [
{
name: fieldName,
mapping: 'alert_name',
mappingFields: getPayloadFields([dashboardField, nameField]),
mapping: ['alert', 'name'],
mappingFields: [dashboardField, nameField],
},
];
const result = transformForSave(mockMappingData);
@ -61,21 +57,11 @@ describe('Mapping Transformation Utilities', () => {
{
name: fieldName,
mapping: null,
mappingFields: getPayloadFields([nameField, dashboardField]),
mappingFields: [nameField, dashboardField],
},
];
const result = transformForSave(mockMappingData);
expect(result).toEqual([]);
});
});
describe('getPayloadFields', () => {
it('should add name field to each payload field', () => {
const result = getPayloadFields([nameField, dashboardField]);
expect(result).toEqual([
{ ...nameField, name: 'alert_name' },
{ ...dashboardField, name: 'alert_dashboardId' },
]);
});
});
});

View file

@ -102,7 +102,7 @@ exports[`packages_list_row renders 1`] = `
<gl-button-stub
aria-label="Remove package"
buttontextclasses=""
category="primary"
category="secondary"
data-testid="action-delete"
icon="remove"
size="medium"

View file

@ -60,11 +60,9 @@ describe('packages_list_row', () => {
});
describe('when is is group', () => {
beforeEach(() => {
mountComponent({ isGroup: true });
});
it('has a package path component', () => {
mountComponent({ isGroup: true });
expect(findPackagePath().exists()).toBe(true);
expect(findPackagePath().props()).toMatchObject({ path: 'foo/bar/baz' });
});
@ -92,10 +90,22 @@ describe('packages_list_row', () => {
});
});
describe('delete event', () => {
beforeEach(() => mountComponent({ packageEntity: packageWithoutTags }));
describe('delete button', () => {
it('exists and has the correct props', () => {
mountComponent({ packageEntity: packageWithoutTags });
expect(findDeleteButton().exists()).toBe(true);
expect(findDeleteButton().attributes()).toMatchObject({
icon: 'remove',
category: 'secondary',
variant: 'danger',
title: 'Remove package',
});
});
it('emits the packageToDelete event when the delete button is clicked', async () => {
mountComponent({ packageEntity: packageWithoutTags });
findDeleteButton().vm.$emit('click');
await wrapper.vm.$nextTick();

View file

@ -0,0 +1,40 @@
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import WikiAlert from '~/pages/shared/wikis/components/wiki_alert.vue';
describe('WikiAlert', () => {
let wrapper;
const ERROR = 'There is already a page with the same title in that path.';
const ERROR_WITH_LINK = 'Before text %{wikiLinkStart}the page%{wikiLinkEnd} after text.';
const PATH = '/test';
function createWrapper(propsData = {}, stubs = {}) {
wrapper = shallowMount(WikiAlert, {
propsData: { wikiPagePath: PATH, ...propsData },
stubs,
});
}
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGlAlert = () => wrapper.findComponent(GlAlert);
const findGlLink = () => wrapper.findComponent(GlLink);
const findGlSprintf = () => wrapper.findComponent(GlSprintf);
describe('Wiki Alert', () => {
it('shows an alert when there is an error', () => {
createWrapper({ error: ERROR });
expect(findGlAlert().exists()).toBe(true);
expect(findGlSprintf().exists()).toBe(true);
expect(findGlSprintf().attributes('message')).toBe(ERROR);
});
it('shows a the link to the help path', () => {
createWrapper({ error: ERROR_WITH_LINK }, { GlAlert, GlSprintf });
expect(findGlLink().attributes('href')).toBe(PATH);
});
});
});

View file

@ -142,6 +142,8 @@ describe('Pipelines stage component', () => {
beforeEach(() => {
mock.onGet(dropdownPath).reply(200, stageReply);
mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200);
createComponent();
});
const clickCiAction = async () => {
@ -152,34 +154,22 @@ describe('Pipelines stage component', () => {
await axios.waitForAll();
};
describe('within pipeline table', () => {
beforeEach(() => {
createComponent({ type: 'PIPELINES_TABLE' });
});
it('closes dropdown when job item action is clicked', async () => {
const hidden = jest.fn();
it('emits `refreshPipelinesTable` event when `pipelineActionRequestComplete` is triggered', async () => {
await clickCiAction();
wrapper.vm.$root.$on('bv::dropdown::hide', hidden);
expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable');
});
expect(hidden).toHaveBeenCalledTimes(0);
await clickCiAction();
expect(hidden).toHaveBeenCalledTimes(1);
});
describe('in MR widget', () => {
beforeEach(() => {
createComponent();
});
it('emits `pipelineActionRequestComplete` when job item action is clicked', async () => {
await clickCiAction();
it('closes the dropdown when `pipelineActionRequestComplete` is triggered', async () => {
const hidden = jest.fn();
wrapper.vm.$root.$on('bv::dropdown::hide', hidden);
expect(hidden).toHaveBeenCalledTimes(0);
await clickCiAction();
expect(hidden).toHaveBeenCalledTimes(1);
});
expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(1);
});
});
});

View file

@ -58,6 +58,7 @@ describe('delete_button', () => {
title: 'Foo title',
variant: 'danger',
disabled: 'true',
category: 'secondary',
});
});

View file

@ -58,12 +58,10 @@ RSpec.describe Gitlab::BackgroundMigration::SetDefaultIterationCadences, schema:
context 'when an iteration cadence exists for a group' do
let!(:group) { namespaces.create!(name: 'group', path: 'group') }
let!(:iterations_cadence_1) { iterations_cadences.create!(group_id: group.id, start_date: 5.days.ago, title: 'Cadence 1') }
let!(:iterations_cadence_2) { iterations_cadences.create!(group_id: group.id, start_date: 2.days.ago, title: 'Cadence 2') }
let!(:iterations_cadence_1) { iterations_cadences.create!(group_id: group.id, start_date: 2.days.ago, title: 'Cadence 1') }
let!(:iteration_1) { iterations.create!(group_id: group.id, iid: 1, title: 'Iteration 1', start_date: 10.days.ago, due_date: 8.days.ago) }
let!(:iteration_2) { iterations.create!(group_id: group.id, iterations_cadence_id: iterations_cadence_1.id, iid: 2, title: 'Iteration 2', start_date: 5.days.ago, due_date: 3.days.ago) }
let!(:iteration_3) { iterations.create!(group_id: group.id, iterations_cadence_id: iterations_cadence_2.id, iid: 3, title: 'Iteration 3', start_date: 2.days.ago, due_date: 1.day.ago) }
subject { described_class.new.perform(group.id) }
@ -76,7 +74,6 @@ RSpec.describe Gitlab::BackgroundMigration::SetDefaultIterationCadences, schema:
expect(iteration_1.reload.iterations_cadence_id).to eq(iterations_cadence_1.id)
expect(iteration_2.reload.iterations_cadence_id).to eq(iterations_cadence_1.id)
expect(iteration_3.reload.iterations_cadence_id).to eq(iterations_cadence_2.id)
end
end
end

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'spec_helper'
require Rails.root.join('db', 'post_migrate', '20210215095328_migrate_delayed_project_removal_from_namespaces_to_namespace_settings.rb')
RSpec.describe MigrateDelayedProjectRemovalFromNamespacesToNamespaceSettings, :migration do
let(:namespaces) { table(:namespaces) }
let(:namespace_settings) { table(:namespace_settings) }
let!(:namespace_wo_settings) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: true) }
let!(:namespace_wo_settings_delay_false) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: false) }
let!(:namespace_w_settings_delay_true) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: true) }
let!(:namespace_w_settings_delay_false) { namespaces.create!(name: generate(:name), path: generate(:name), delayed_project_removal: false) }
let!(:namespace_settings_delay_true) { namespace_settings.create!(namespace_id: namespace_w_settings_delay_true.id, delayed_project_removal: false, created_at: DateTime.now, updated_at: DateTime.now) }
let!(:namespace_settings_delay_false) { namespace_settings.create!(namespace_id: namespace_w_settings_delay_false.id, delayed_project_removal: false, created_at: DateTime.now, updated_at: DateTime.now) }
it 'migrates delayed_project_removal to namespace_settings' do
disable_migrations_output { migrate! }
expect(namespace_settings.count).to eq(3)
expect(namespace_settings.find_by(namespace_id: namespace_wo_settings.id).delayed_project_removal).to eq(true)
expect(namespace_settings.find_by(namespace_id: namespace_wo_settings_delay_false.id)).to be_nil
expect(namespace_settings_delay_true.reload.delayed_project_removal).to eq(true)
expect(namespace_settings_delay_false.reload.delayed_project_removal).to eq(false)
end
end

View file

@ -16,13 +16,13 @@ RSpec.describe RescheduleSetDefaultIterationCadences do
let(:group_7) { namespaces.create!(name: 'test_7', path: 'test_7') }
let(:group_8) { namespaces.create!(name: 'test_8', path: 'test_8') }
let!(:iteration_1) { iterations.create!(iid: 1, title: 'iteration 1', group_id: group_1.id) }
let!(:iteration_2) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_3.id) }
let!(:iteration_3) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_4.id) }
let!(:iteration_4) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_5.id) }
let!(:iteration_5) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_6.id) }
let!(:iteration_6) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_7.id) }
let!(:iteration_7) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_8.id) }
let!(:iteration_1) { iterations.create!(iid: 1, title: 'iteration 1', group_id: group_1.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let!(:iteration_2) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_3.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let!(:iteration_3) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_4.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let!(:iteration_4) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_5.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let!(:iteration_5) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_6.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let!(:iteration_6) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_7.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let!(:iteration_7) { iterations.create!(iid: 1, title: 'iteration 2', group_id: group_8.id, start_date: 2.days.from_now, due_date: 3.days.from_now) }
around do |example|
freeze_time { Sidekiq::Testing.fake! { example.run } }

View file

@ -3,10 +3,11 @@
require 'spec_helper'
RSpec.describe Iteration do
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
let(:set_cadence) { nil }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:group) }
@ -67,7 +68,7 @@ RSpec.describe Iteration do
expect { iteration.save! }.to change { Iterations::Cadence.count }.by(1)
end
it 'sets the newly created iterations_cadence to the reecord' do
it 'sets the newly created iterations_cadence to the record' do
iteration.save!
expect(iteration.iterations_cadence).to eq(Iterations::Cadence.last)
@ -148,7 +149,7 @@ RSpec.describe Iteration do
context 'Validations' do
subject { build(:iteration, group: group, start_date: start_date, due_date: due_date) }
describe '#not_belonging_to_project' do
describe 'when iteration belongs to project' do
subject { build(:iteration, project: project, start_date: Time.current, due_date: 1.day.from_now) }
it 'is invalid' do
@ -180,13 +181,13 @@ RSpec.describe Iteration do
let(:due_date) { 6.days.from_now }
shared_examples_for 'overlapping dates' do |skip_constraint_test: false|
context 'when start_date is in range' do
context 'when start_date overlaps' do
let(:start_date) { 5.days.from_now }
let(:due_date) { 3.weeks.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations within this group')
end
unless skip_constraint_test
@ -197,13 +198,13 @@ RSpec.describe Iteration do
end
end
context 'when end_date is in range' do
context 'when due_date overlaps' do
let(:start_date) { Time.current }
let(:due_date) { 6.days.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations within this group')
end
unless skip_constraint_test
@ -217,7 +218,7 @@ RSpec.describe Iteration do
context 'when both overlap' do
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations')
expect(subject.errors[:base]).to include('Dates cannot overlap with other existing Iterations within this group')
end
unless skip_constraint_test
@ -231,7 +232,7 @@ RSpec.describe Iteration do
context 'group' do
it_behaves_like 'overlapping dates' do
let(:constraint_name) { 'iteration_start_and_due_daterange_group_id_constraint' }
let(:constraint_name) { 'iteration_start_and_due_date_iterations_cadence_id_constraint' }
end
context 'different group' do
@ -249,11 +250,12 @@ RSpec.describe Iteration do
subject { build(:iteration, group: subgroup, start_date: start_date, due_date: due_date) }
it_behaves_like 'overlapping dates', skip_constraint_test: true
it { is_expected.to be_valid }
end
end
context 'project' do
# Skipped. Pending https://gitlab.com/gitlab-org/gitlab/-/issues/299864
xcontext 'project' do
let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
@ -283,16 +285,16 @@ RSpec.describe Iteration do
expect { subject.save! }.not_to raise_exception
end
end
end
context 'project in a group' do
let_it_be(:project) { create(:project, group: create(:group)) }
let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
context 'project in a group' do
let_it_be(:project) { create(:project, group: create(:group)) }
let_it_be(:existing_iteration) { create(:iteration, :skip_project_validation, project: project, start_date: 4.days.from_now, due_date: 1.week.from_now) }
subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
subject { build(:iteration, :skip_project_validation, project: project, start_date: start_date, due_date: due_date) }
it_behaves_like 'overlapping dates' do
let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
it_behaves_like 'overlapping dates' do
let(:constraint_name) { 'iteration_start_and_due_daterange_project_id_constraint' }
end
end
end
end
@ -310,19 +312,23 @@ RSpec.describe Iteration do
let(:start_date) { 1.week.ago }
let(:due_date) { 1.week.from_now }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:start_date]).to include('cannot be in the past')
end
it { is_expected.to be_valid }
end
context 'when due_date is in the past' do
let(:start_date) { 2.weeks.ago }
let(:due_date) { 1.week.ago }
it { is_expected.to be_valid }
end
context 'when due_date is before start date' do
let(:start_date) { Time.current }
let(:due_date) { 1.week.ago }
it 'is not valid' do
expect(subject).not_to be_valid
expect(subject.errors[:due_date]).to include('cannot be in the past')
expect(subject.errors[:due_date]).to include('must be greater than start date')
end
end

View file

@ -6,7 +6,7 @@ RSpec.describe Namespace do
include ProjectForksHelper
include GitHelpers
let!(:namespace) { create(:namespace) }
let!(:namespace) { create(:namespace, :with_namespace_settings) }
let(:gitlab_shell) { Gitlab::Shell.new }
let(:repository_storage) { 'default' }
@ -116,6 +116,28 @@ RSpec.describe Namespace do
it { is_expected.to include_module(Namespaces::Traversal::Recursive) }
end
describe 'callbacks' do
describe 'before_save :ensure_delayed_project_removal_assigned_to_namespace_settings' do
it 'sets the matching value in namespace_settings' do
expect { namespace.update!(delayed_project_removal: true) }.to change {
namespace.namespace_settings.delayed_project_removal
}.from(false).to(true)
end
context 'when the feature flag is disabled' do
before do
stub_feature_flags(migrate_delayed_project_removal: false)
end
it 'does not set the matching value in namespace_settings' do
expect { namespace.update!(delayed_project_removal: true) }.not_to change {
namespace.namespace_settings.delayed_project_removal
}
end
end
end
end
describe '#visibility_level_field' do
it { expect(namespace.visibility_level_field).to eq(:visibility_level) }
end

View file

@ -48,6 +48,6 @@ RSpec.shared_examples 'a mutation that returns errors in the response' do |error
it do
post_graphql_mutation(mutation, current_user: current_user)
expect(mutation_response['errors']).to eq(errors)
expect(mutation_response['errors']).to match_array(errors)
end
end

Some files were not shown because too many files have changed in this diff Show more