summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-07-17 18:09:20 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-07-17 18:09:20 +0000
commitec72da1833d94bb1556af94193ccf2a93c9cb939 (patch)
tree6227669a11aaf8370186a7aa6591d5fa9d853bb0
parent283fb71e02992b6687e3264d53bbc718b7567109 (diff)
downloadgitlab-ce-ec72da1833d94bb1556af94193ccf2a93c9cb939.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/api.js13
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue147
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_settings.vue18
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue84
-rw-r--r--app/assets/javascripts/deploy_freeze/components/deploy_freeze_timezone_dropdown.vue107
-rw-r--r--app/assets/javascripts/deploy_freeze/constants.js5
-rw-r--r--app/assets/javascripts/deploy_freeze/index.js22
-rw-r--r--app/assets/javascripts/deploy_freeze/store/actions.js77
-rw-r--r--app/assets/javascripts/deploy_freeze/store/index.js14
-rw-r--r--app/assets/javascripts/deploy_freeze/store/mutation_types.js12
-rw-r--r--app/assets/javascripts/deploy_freeze/store/mutations.js45
-rw-r--r--app/assets/javascripts/deploy_freeze/store/state.js17
-rw-r--r--app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js2
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue2
-rw-r--r--app/assets/stylesheets/pages/settings.scss3
-rw-r--r--app/controllers/concerns/graceful_timeout_handling.rb15
-rw-r--r--app/controllers/projects/cycle_analytics/events_controller.rb1
-rw-r--r--app/controllers/projects/cycle_analytics_controller.rb1
-rw-r--r--app/finders/members_finder.rb8
-rw-r--r--app/graphql/mutations/snippets/create.rb8
-rw-r--r--app/graphql/mutations/snippets/update.rb8
-rw-r--r--app/graphql/types/snippets/blob_action_enum.rb (renamed from app/graphql/types/snippets/file_input_action_enum.rb)6
-rw-r--r--app/graphql/types/snippets/blob_action_input_type.rb (renamed from app/graphql/types/snippets/file_input_type.rb)6
-rw-r--r--app/models/ci/pipeline_enums.rb4
-rw-r--r--app/models/member.rb1
-rw-r--r--app/services/notification_service.rb10
-rw-r--r--app/views/ci/deploy_freeze/_index.html.haml2
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml19
-rw-r--r--changelogs/unreleased/207226-child-pipelines-and-their-jobs-are-not-found-by-api-endpoints.yml5
-rw-r--r--changelogs/unreleased/225405-graceful-timeout-handling-for-analytics.yml5
-rw-r--r--changelogs/unreleased/24295-set-a-deploy-freeze-in-the-ui.yml5
-rw-r--r--changelogs/unreleased/fj-228828-rename-graphql-files-to-blob-actions.yml5
-rw-r--r--changelogs/unreleased/sh-limit-project-moved-emails.yml5
-rw-r--r--db/migrate/20200715202659_add_index_on_package_files_file_store.rb17
-rw-r--r--db/structure.sql3
-rw-r--r--doc/administration/auth/ldap/index.md2
-rw-r--r--doc/administration/geo/replication/datatypes.md2
-rw-r--r--doc/administration/geo/replication/geo_validation_tests.md2
-rw-r--r--doc/administration/server_hooks.md10
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql90
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json200
-rw-r--r--doc/api/jobs.md3
-rw-r--r--doc/api/pipelines.md8
-rw-r--r--doc/ci/runners/README.md12
-rw-r--r--doc/ci/yaml/README.md9
-rw-r--r--doc/development/fe_guide/vuex.md79
-rw-r--r--doc/development/multi_version_compatibility.md15
-rw-r--r--doc/user/admin_area/settings/account_and_limit_settings.md6
-rw-r--r--doc/user/project/clusters/securing.md11
-rw-r--r--doc/user/project/releases/img/deploy_freeze_v13_2.pngbin0 -> 33428 bytes
-rw-r--r--doc/user/project/releases/index.md18
-rw-r--r--doc/user/todos.md9
-rw-r--r--lib/api/ci/pipelines.rb2
-rw-r--r--lib/api/jobs.rb2
-rw-r--r--locale/gitlab.pot57
-rw-r--r--package.json1
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb15
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb26
-rw-r--r--spec/controllers/concerns/graceful_timeout_handling_spec.rb42
-rw-r--r--spec/controllers/projects/cycle_analytics/events_controller_spec.rb2
-rw-r--r--spec/controllers/projects/cycle_analytics_controller_spec.rb2
-rw-r--r--spec/finders/members_finder_spec.rb25
-rw-r--r--spec/frontend/api_spec.js49
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js91
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js43
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js69
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_timezone_dropdown_spec.js88
-rw-r--r--spec/frontend/deploy_freeze/mock_data.js186
-rw-r--r--spec/frontend/deploy_freeze/store/actions_spec.js122
-rw-r--r--spec/frontend/deploy_freeze/store/mutations_spec.js62
-rw-r--r--spec/frontend/snippets/components/edit_spec.js2
-rw-r--r--spec/graphql/types/snippets/blob_action_enum_spec.rb (renamed from spec/graphql/types/snippets/file_input_action_enum_spec.rb)4
-rw-r--r--spec/graphql/types/snippets/blob_action_input_type_spec.rb (renamed from spec/graphql/types/snippets/file_input_type_spec.rb)8
-rw-r--r--spec/requests/api/ci/pipelines_spec.rb16
-rw-r--r--spec/requests/api/graphql/mutations/snippets/create_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/snippets/update_spec.rb2
-rw-r--r--spec/requests/api/jobs_spec.rb12
-rw-r--r--spec/services/notification_service_spec.rb56
-rw-r--r--spec/support/shared_examples/controllers/concerns/graceful_timeout_handling_shared_examples.rb7
-rw-r--r--yarn.lock5
80 files changed, 1910 insertions, 266 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index c84e73ccdb4..246231d969b 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -59,6 +59,7 @@ const Api = {
rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw',
issuePath: '/api/:version/projects/:id/issues/:issue_iid',
tagsPath: '/api/:version/projects/:id/repository/tags',
+ freezePeriodsPath: '/api/:version/projects/:id/freeze_periods',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -616,6 +617,18 @@ const Api = {
});
},
+ freezePeriods(id) {
+ const url = Api.buildUrl(this.freezePeriodsPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url);
+ },
+
+ createFreezePeriod(id, freezePeriod = {}) {
+ const url = Api.buildUrl(this.freezePeriodsPath).replace(':id', encodeURIComponent(id));
+
+ return axios.post(url, freezePeriod);
+ },
+
buildUrl(url) {
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
new file mode 100644
index 00000000000..05ee77b932a
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue
@@ -0,0 +1,147 @@
+<script>
+import { GlFormGroup, GlFormInput, GlModal, GlSprintf, GlLink } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { mapComputed } from '~/vuex_shared/bindings';
+import { __ } from '~/locale';
+import { MODAL_ID } from '../constants';
+import DeployFreezeTimezoneDropdown from './deploy_freeze_timezone_dropdown.vue';
+import { isValidCron } from 'cron-validator';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlModal,
+ GlSprintf,
+ GlLink,
+ DeployFreezeTimezoneDropdown,
+ },
+ modalOptions: {
+ ref: 'modal',
+ modalId: MODAL_ID,
+ title: __('Add deploy freeze'),
+ actionCancel: {
+ text: __('Cancel'),
+ },
+ static: true,
+ lazy: true,
+ },
+ translations: {
+ cronPlaceholder: __('* * * * *'),
+ cronSyntaxInstructions: __(
+ 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}',
+ ),
+ },
+ computed: {
+ ...mapState([
+ 'projectId',
+ 'selectedTimezone',
+ 'timezoneData',
+ 'freezeStartCron',
+ 'freezeEndCron',
+ 'selectedTimezone',
+ ]),
+ ...mapComputed([
+ { key: 'freezeStartCron', updateFn: 'setFreezeStartCron' },
+ { key: 'freezeEndCron', updateFn: 'setFreezeEndCron' },
+ ]),
+ addDeployFreezeButton() {
+ return {
+ text: __('Add deploy freeze'),
+ attributes: [
+ { variant: 'success' },
+ {
+ disabled:
+ !isValidCron(this.freezeStartCron) ||
+ !isValidCron(this.freezeEndCron) ||
+ !this.selectedTimezone,
+ },
+ ],
+ };
+ },
+ invalidFreezeStartCron() {
+ return this.invalidCronMessage(this.freezeStartCronState);
+ },
+ freezeStartCronState() {
+ return Boolean(!this.freezeStartCron || isValidCron(this.freezeStartCron));
+ },
+ invalidFreezeEndCron() {
+ return this.invalidCronMessage(this.freezeEndCronState);
+ },
+ freezeEndCronState() {
+ return Boolean(!this.freezeEndCron || isValidCron(this.freezeEndCron));
+ },
+ },
+ methods: {
+ ...mapActions(['addFreezePeriod', 'setSelectedTimezone', 'resetModal']),
+ resetModalHandler() {
+ this.resetModal();
+ },
+ invalidCronMessage(validCronState) {
+ if (!validCronState) {
+ return __('This Cron pattern is invalid');
+ }
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ v-bind="$options.modalOptions"
+ :action-primary="addDeployFreezeButton"
+ @primary="addFreezePeriod"
+ @canceled="resetModalHandler"
+ >
+ <p>
+ <gl-sprintf :message="$options.translations.cronSyntaxInstructions">
+ <template #cronSyntax="{ content }">
+ <gl-link href="https://crontab.guru/" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <gl-form-group
+ :label="__('Freeze start')"
+ label-for="deploy-freeze-start"
+ :invalid-feedback="invalidFreezeStartCron"
+ :state="freezeStartCronState"
+ >
+ <gl-form-input
+ id="deploy-freeze-start"
+ v-model="freezeStartCron"
+ class="gl-font-monospace!"
+ data-qa-selector="deploy_freeze_start_field"
+ :placeholder="this.$options.translations.cronPlaceholder"
+ :state="freezeStartCronState"
+ trim
+ />
+ </gl-form-group>
+
+ <gl-form-group
+ :label="__('Freeze end')"
+ label-for="deploy-freeze-end"
+ :invalid-feedback="invalidFreezeEndCron"
+ :state="freezeEndCronState"
+ >
+ <gl-form-input
+ id="deploy-freeze-end"
+ v-model="freezeEndCron"
+ class="gl-font-monospace!"
+ data-qa-selector="deploy_freeze_end_field"
+ :placeholder="this.$options.translations.cronPlaceholder"
+ :state="freezeEndCronState"
+ trim
+ />
+ </gl-form-group>
+
+ <gl-form-group :label="__('Cron time zone')" label-for="cron-time-zone-dropdown">
+ <deploy-freeze-timezone-dropdown
+ :timezone-data="timezoneData"
+ :value="selectedTimezone"
+ @selectTimezone="setSelectedTimezone"
+ />
+ </gl-form-group>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_settings.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_settings.vue
new file mode 100644
index 00000000000..fc2ed10f3ca
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_settings.vue
@@ -0,0 +1,18 @@
+<script>
+import DeployFreezeTable from './deploy_freeze_table.vue';
+import DeployFreezeModal from './deploy_freeze_modal.vue';
+
+export default {
+ components: {
+ DeployFreezeTable,
+ DeployFreezeModal,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <deploy-freeze-table />
+ <deploy-freeze-modal />
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
new file mode 100644
index 00000000000..b80df5d4f1e
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue
@@ -0,0 +1,84 @@
+<script>
+import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { s__, __ } from '~/locale';
+import { mapState, mapActions } from 'vuex';
+import { MODAL_ID } from '../constants';
+
+export default {
+ modalId: MODAL_ID,
+ fields: [
+ {
+ key: 'freezeStart',
+ label: s__('DeployFreeze|Freeze start'),
+ },
+ {
+ key: 'freezeEnd',
+ label: s__('DeployFreeze|Freeze end'),
+ },
+ {
+ key: 'cronTimezone',
+ label: s__('DeployFreeze|Time zone'),
+ },
+ ],
+ translations: {
+ addDeployFreeze: __('Add deploy freeze'),
+ },
+ components: {
+ GlTable,
+ GlButton,
+ GlSprintf,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ computed: {
+ ...mapState(['freezePeriods']),
+ tableIsNotEmpty() {
+ return this.freezePeriods?.length > 0;
+ },
+ },
+ mounted() {
+ this.fetchFreezePeriods();
+ },
+ methods: {
+ ...mapActions(['fetchFreezePeriods']),
+ },
+};
+</script>
+
+<template>
+ <div class="deploy-freeze-table">
+ <gl-table
+ data-testid="deploy-freeze-table"
+ :items="freezePeriods"
+ :fields="$options.fields"
+ show-empty
+ >
+ <template #empty>
+ <p data-testid="empty-freeze-periods" class="gl-text-center text-plain">
+ <gl-sprintf
+ :message="
+ s__(
+ 'DeployFreeze|No deploy freezes exist for this project. To add one, click %{strongStart}Add deploy freeze%{strongEnd}',
+ )
+ "
+ >
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-table>
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-button
+ v-gl-modal-directive="$options.modalId"
+ data-testid="add-deploy-freeze"
+ category="primary"
+ variant="success"
+ >
+ {{ $options.translations.addDeployFreeze }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_timezone_dropdown.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_timezone_dropdown.vue
new file mode 100644
index 00000000000..09f6d9460ea
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_timezone_dropdown.vue
@@ -0,0 +1,107 @@
+<script>
+import { GlNewDropdown, GlDropdownItem, GlSearchBoxByType, GlIcon } from '@gitlab/ui';
+import { __ } from '~/locale';
+import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
+
+export default {
+ name: 'DeployFreezeTimezoneDropdown',
+ components: {
+ GlNewDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlIcon,
+ },
+ directives: {
+ autofocusonshow,
+ },
+ props: {
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ timezoneData: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ searchTerm: this.value || '',
+ };
+ },
+ tranlations: {
+ noResultsText: __('No matching results'),
+ },
+ computed: {
+ timezones() {
+ return this.timezoneData.map(timezone => ({
+ formattedTimezone: this.formatTimezone(timezone),
+ identifier: timezone.identifier,
+ }));
+ },
+ filteredResults() {
+ const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
+ return this.timezones.filter(timezone =>
+ timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm),
+ );
+ },
+ selectTimezoneLabel() {
+ return this.value || __('Select timezone');
+ },
+ },
+ watch: {
+ value(newVal) {
+ this.searchTerm = newVal;
+ },
+ },
+ methods: {
+ selectTimezone(selected) {
+ this.$emit('selectTimezone', selected);
+ this.searchTerm = '';
+ },
+ isSelected(timezone) {
+ return this.value === timezone.formattedTimezone;
+ },
+ formatUtcOffset(offset) {
+ const parsed = parseInt(offset, 10);
+ if (Number.isNaN(parsed) || parsed === 0) {
+ return `0`;
+ }
+ const prefix = offset > 0 ? '+' : '-';
+ return `${prefix}${Math.abs(offset / 3600)}`;
+ },
+ formatTimezone(item) {
+ return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
+ },
+ },
+};
+</script>
+<template>
+ <gl-new-dropdown :text="value" block lazy menu-class="gl-w-full!">
+ <template #button-content>
+ <span ref="buttonText" class="gl-flex-grow-1" :class="{ 'gl-text-gray-500': !value }">{{
+ selectTimezoneLabel
+ }}</span>
+ <gl-icon name="chevron-down" />
+ </template>
+
+ <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus class="gl-m-3" />
+ <gl-dropdown-item
+ v-for="timezone in filteredResults"
+ :key="timezone.formattedTimezone"
+ @click="selectTimezone(timezone)"
+ >
+ <gl-icon
+ :class="{ invisible: !isSelected(timezone) }"
+ name="mobile-issue-close"
+ class="gl-vertical-align-middle"
+ />
+ {{ timezone.formattedTimezone }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">
+ {{ $options.tranlations.noResultsText }}
+ </gl-dropdown-item>
+ </gl-new-dropdown>
+</template>
diff --git a/app/assets/javascripts/deploy_freeze/constants.js b/app/assets/javascripts/deploy_freeze/constants.js
new file mode 100644
index 00000000000..79e556e0b55
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/constants.js
@@ -0,0 +1,5 @@
+export const MODAL_ID = 'deploy-freeze-modal';
+
+export default {
+ MODAL_ID,
+};
diff --git a/app/assets/javascripts/deploy_freeze/index.js b/app/assets/javascripts/deploy_freeze/index.js
new file mode 100644
index 00000000000..fd3f52b6da1
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/index.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import DeployFreezeSettings from './components/deploy_freeze_settings.vue';
+import createStore from './store';
+
+export default () => {
+ const el = document.getElementById('js-deploy-freeze-table');
+
+ const { projectId, timezoneData } = el.dataset;
+
+ const store = createStore({
+ projectId,
+ timezoneData: JSON.parse(timezoneData),
+ });
+
+ return new Vue({
+ el,
+ store,
+ render(createElement) {
+ return createElement(DeployFreezeSettings);
+ },
+ });
+};
diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js
new file mode 100644
index 00000000000..e4c649ac4c3
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/store/actions.js
@@ -0,0 +1,77 @@
+import * as types from './mutation_types';
+import Api from '~/api';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export const requestAddFreezePeriod = ({ commit }) => {
+ commit(types.REQUEST_ADD_FREEZE_PERIOD);
+};
+
+export const receiveAddFreezePeriodSuccess = ({ commit }) => {
+ commit(types.RECEIVE_ADD_FREEZE_PERIOD_SUCCESS);
+};
+
+export const receiveAddFreezePeriodError = ({ commit }, error) => {
+ commit(types.RECEIVE_ADD_FREEZE_PERIOD_ERROR, error);
+};
+
+export const addFreezePeriod = ({ state, dispatch, commit }) => {
+ dispatch('requestAddFreezePeriod');
+
+ return Api.createFreezePeriod(state.projectId, {
+ freeze_start: state.freezeStartCron,
+ freeze_end: state.freezeEndCron,
+ cron_timezone: state.selectedTimezoneIdentifier,
+ })
+ .then(() => {
+ dispatch('receiveAddFreezePeriodSuccess');
+ commit(types.RESET_MODAL);
+ dispatch('fetchFreezePeriods');
+ })
+ .catch(error => {
+ createFlash(__('Error: Unable to create deploy freeze'));
+ dispatch('receiveAddFreezePeriodError', error);
+ });
+};
+
+export const requestFreezePeriods = ({ commit }) => {
+ commit(types.REQUEST_FREEZE_PERIODS);
+};
+export const receiveFreezePeriodsSuccess = ({ state, commit }, freezePeriods) => {
+ const addTimezoneIdentifier = freezePeriod =>
+ convertObjectPropsToCamelCase({
+ ...freezePeriod,
+ cron_timezone: state.timezoneData.find(tz => tz.identifier === freezePeriod.cron_timezone)
+ ?.name,
+ });
+
+ commit(types.RECEIVE_FREEZE_PERIODS_SUCCESS, freezePeriods.map(addTimezoneIdentifier));
+};
+
+export const fetchFreezePeriods = ({ dispatch, state }) => {
+ dispatch('requestFreezePeriods');
+
+ return Api.freezePeriods(state.projectId)
+ .then(({ data }) => {
+ dispatch('receiveFreezePeriodsSuccess', convertObjectPropsToCamelCase(data));
+ })
+ .catch(() => {
+ createFlash(__('There was an error fetching the deploy freezes.'));
+ });
+};
+
+export const setSelectedTimezone = ({ commit }, timezone) => {
+ commit(types.SET_SELECTED_TIMEZONE, timezone);
+};
+export const setFreezeStartCron = ({ commit }, { freezeStartCron }) => {
+ commit(types.SET_FREEZE_START_CRON, freezeStartCron);
+};
+
+export const setFreezeEndCron = ({ commit }, { freezeEndCron }) => {
+ commit(types.SET_FREEZE_END_CRON, freezeEndCron);
+};
+
+export const resetModal = ({ commit }) => {
+ commit(types.RESET_MODAL);
+};
diff --git a/app/assets/javascripts/deploy_freeze/store/index.js b/app/assets/javascripts/deploy_freeze/store/index.js
new file mode 100644
index 00000000000..ca7ea8c783c
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export default initialState =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: createState(initialState),
+ });
diff --git a/app/assets/javascripts/deploy_freeze/store/mutation_types.js b/app/assets/javascripts/deploy_freeze/store/mutation_types.js
new file mode 100644
index 00000000000..47a4874a5cf
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/store/mutation_types.js
@@ -0,0 +1,12 @@
+export const REQUEST_FREEZE_PERIODS = 'REQUEST_FREEZE_PERIODS';
+export const RECEIVE_FREEZE_PERIODS_SUCCESS = 'RECEIVE_FREEZE_PERIODS_SUCCESS';
+
+export const REQUEST_ADD_FREEZE_PERIOD = 'REQUEST_ADD_FREEZE_PERIOD';
+export const RECEIVE_ADD_FREEZE_PERIOD_SUCCESS = 'RECEIVE_ADD_FREEZE_PERIOD_SUCCESS';
+export const RECEIVE_ADD_FREEZE_PERIOD_ERROR = 'RECEIVE_ADD_FREEZE_PERIOD_ERROR';
+
+export const SET_SELECTED_TIMEZONE = 'SET_SELECTED_TIMEZONE';
+export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON';
+export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON';
+
+export const RESET_MODAL = 'RESET_MODAL';
diff --git a/app/assets/javascripts/deploy_freeze/store/mutations.js b/app/assets/javascripts/deploy_freeze/store/mutations.js
new file mode 100644
index 00000000000..57b4b226b16
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/store/mutations.js
@@ -0,0 +1,45 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_FREEZE_PERIODS](state) {
+ state.isLoading = true;
+ },
+
+ [types.RECEIVE_FREEZE_PERIODS_SUCCESS](state, freezePeriods) {
+ state.isLoading = false;
+ state.freezePeriods = freezePeriods;
+ },
+
+ [types.REQUEST_ADD_FREEZE_PERIOD](state) {
+ state.isLoading = true;
+ },
+
+ [types.RECEIVE_ADD_FREEZE_PERIOD_SUCCESS](state) {
+ state.isLoading = false;
+ },
+
+ [types.RECEIVE_ADD_FREEZE_PERIOD_ERROR](state, error) {
+ state.isLoading = false;
+ state.error = error;
+ },
+
+ [types.SET_SELECTED_TIMEZONE](state, timezone) {
+ state.selectedTimezone = timezone.formattedTimezone;
+ state.selectedTimezoneIdentifier = timezone.identifier;
+ },
+
+ [types.SET_FREEZE_START_CRON](state, freezeStartCron) {
+ state.freezeStartCron = freezeStartCron;
+ },
+
+ [types.SET_FREEZE_END_CRON](state, freezeEndCron) {
+ state.freezeEndCron = freezeEndCron;
+ },
+
+ [types.RESET_MODAL](state) {
+ state.freezeStartCron = '';
+ state.freezeEndCron = '';
+ state.selectedTimezone = '';
+ state.selectedTimezoneIdentifier = '';
+ },
+};
diff --git a/app/assets/javascripts/deploy_freeze/store/state.js b/app/assets/javascripts/deploy_freeze/store/state.js
new file mode 100644
index 00000000000..4cc38c097b6
--- /dev/null
+++ b/app/assets/javascripts/deploy_freeze/store/state.js
@@ -0,0 +1,17 @@
+export default ({
+ projectId,
+ freezePeriods = [],
+ timezoneData = [],
+ selectedTimezone = '',
+ selectedTimezoneIdentifier = '',
+ freezeStartCron = '',
+ freezeEndCron = '',
+}) => ({
+ projectId,
+ freezePeriods,
+ timezoneData,
+ selectedTimezone,
+ selectedTimezoneIdentifier,
+ freezeStartCron,
+ freezeEndCron,
+});
diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
index e08d0407245..ab2a7c099c4 100644
--- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js
@@ -3,6 +3,7 @@ import SecretValues from '~/behaviors/secret_values';
import AjaxVariableList from '~/ci_variable_list/ajax_variable_list';
import registrySettingsApp from '~/registry/settings/registry_settings_bundle';
import initVariableList from '~/ci_variable_list';
+import initDeployFreeze from '~/deploy_freeze';
document.addEventListener('DOMContentLoaded', () => {
// Initialize expandable settings panels
@@ -40,4 +41,5 @@ document.addEventListener('DOMContentLoaded', () => {
});
registrySettingsApp();
+ initDeployFreeze();
});
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index c01f9524ca8..69593dc77f8 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -84,7 +84,7 @@ export default {
title: this.snippet.title,
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
- files: this.getActionsEntries.filter(entry => entry.action !== ''),
+ blobActions: this.getActionsEntries.filter(entry => entry.action !== ''),
};
},
saveButtonLabel() {
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index f1df9099d82..c5f52377752 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -366,7 +366,8 @@
margin-top: 1em;
}
-.ci-variable-table {
+.ci-variable-table,
+.deploy-freeze-table {
table {
thead {
border-bottom: 1px solid $white-normal;
diff --git a/app/controllers/concerns/graceful_timeout_handling.rb b/app/controllers/concerns/graceful_timeout_handling.rb
new file mode 100644
index 00000000000..490c0ec3b1d
--- /dev/null
+++ b/app/controllers/concerns/graceful_timeout_handling.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module GracefulTimeoutHandling
+ extend ActiveSupport::Concern
+
+ included do
+ rescue_from ActiveRecord::QueryCanceled do |exception|
+ raise exception unless request.format.json?
+
+ log_exception(exception)
+
+ render json: { error: _('There is too much data to calculate. Please change your selection.') }
+ end
+ end
+end
diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb
index 673f53c221b..c69bf029c73 100644
--- a/app/controllers/projects/cycle_analytics/events_controller.rb
+++ b/app/controllers/projects/cycle_analytics/events_controller.rb
@@ -4,6 +4,7 @@ module Projects
module CycleAnalytics
class EventsController < Projects::ApplicationController
include CycleAnalyticsParams
+ include GracefulTimeoutHandling
before_action :authorize_read_cycle_analytics!
before_action :authorize_read_build!, only: [:test, :staging]
diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb
index 898d888c978..ef97bc795f9 100644
--- a/app/controllers/projects/cycle_analytics_controller.rb
+++ b/app/controllers/projects/cycle_analytics_controller.rb
@@ -5,6 +5,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController
include ActionView::Helpers::TextHelper
include CycleAnalyticsParams
include Analytics::UniqueVisitsHelper
+ include GracefulTimeoutHandling
before_action :whitelist_query_limiting, only: [:show]
before_action :authorize_read_cycle_analytics!
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index e08ed737ca6..ce9137f91bb 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -29,7 +29,12 @@ class MembersFinder
def find_members(include_relations)
project_members = project.project_members
- project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
+
+ if params[:active_without_invites_and_requests].present?
+ project_members = project_members.active_without_invites_and_requests
+ else
+ project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
+ end
return project_members if include_relations == [:direct]
@@ -44,6 +49,7 @@ class MembersFinder
def filter_members(members)
members = members.search(params[:search]) if params[:search].present?
members = members.sort_by_attribute(params[:sort]) if params[:sort].present?
+ members = members.owners_and_maintainers if params[:owners_and_maintainers].present?
members
end
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index 89c21486a74..a068fd806f5 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -40,8 +40,8 @@ module Mutations
required: false,
description: 'The paths to files uploaded in the snippet description'
- argument :files, [Types::Snippets::FileInputType],
- description: "The snippet files to create",
+ argument :blob_actions, [Types::Snippets::BlobActionInputType],
+ description: 'Actions to perform over the snippet repository and blobs',
required: false
def resolve(args)
@@ -85,9 +85,9 @@ module Mutations
def create_params(args)
args.tap do |create_args|
- # We need to rename `files` into `snippet_actions` because
+ # We need to rename `blob_actions` into `snippet_actions` because
# it's the expected key param
- create_args[:snippet_actions] = create_args.delete(:files)&.map(&:to_h)
+ create_args[:snippet_actions] = create_args.delete(:blob_actions)&.map(&:to_h)
# We need to rename `uploaded_files` into `files` because
# it's the expected key param
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index 8890158b0df..6ff632ec008 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -30,8 +30,8 @@ module Mutations
description: 'The visibility level of the snippet',
required: false
- argument :files, [Types::Snippets::FileInputType],
- description: 'The snippet files to update',
+ argument :blob_actions, [Types::Snippets::BlobActionInputType],
+ description: 'Actions to perform over the snippet repository and blobs',
required: false
def resolve(args)
@@ -56,9 +56,9 @@ module Mutations
def update_params(args)
args.tap do |update_args|
- # We need to rename `files` into `snippet_actions` because
+ # We need to rename `blob_actions` into `snippet_actions` because
# it's the expected key param
- update_args[:snippet_actions] = update_args.delete(:files)&.map(&:to_h)
+ update_args[:snippet_actions] = update_args.delete(:blob_actions)&.map(&:to_h)
end
end
end
diff --git a/app/graphql/types/snippets/file_input_action_enum.rb b/app/graphql/types/snippets/blob_action_enum.rb
index 7785853f3a8..e3f89920f16 100644
--- a/app/graphql/types/snippets/file_input_action_enum.rb
+++ b/app/graphql/types/snippets/blob_action_enum.rb
@@ -2,9 +2,9 @@
module Types
module Snippets
- class FileInputActionEnum < BaseEnum
- graphql_name 'SnippetFileInputActionEnum'
- description 'Type of a snippet file input action'
+ class BlobActionEnum < BaseEnum
+ graphql_name 'SnippetBlobActionEnum'
+ description 'Type of a snippet blob input action'
value 'create', value: :create
value 'update', value: :update
diff --git a/app/graphql/types/snippets/file_input_type.rb b/app/graphql/types/snippets/blob_action_input_type.rb
index 85a02c8f493..ccb6ae3f2c1 100644
--- a/app/graphql/types/snippets/file_input_type.rb
+++ b/app/graphql/types/snippets/blob_action_input_type.rb
@@ -2,11 +2,11 @@
module Types
module Snippets
- class FileInputType < BaseInputObject # rubocop:disable Graphql/AuthorizeTypes
- graphql_name 'SnippetFileInputType'
+ class BlobActionInputType < BaseInputObject # rubocop:disable Graphql/AuthorizeTypes
+ graphql_name 'SnippetBlobActionInputType'
description 'Represents an action to perform over a snippet file'
- argument :action, Types::Snippets::FileInputActionEnum,
+ argument :action, Types::Snippets::BlobActionEnum,
description: 'Type of input action',
required: true
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index 352dc56aac7..9d108ff0fa4 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -63,6 +63,10 @@ module Ci
def self.ci_config_sources_values
ci_config_sources.values
end
+
+ def self.non_ci_config_source_values
+ config_sources.values - ci_config_sources.values
+ end
end
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 36f9741ce01..2c62ea55785 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -86,6 +86,7 @@ class Member < ApplicationRecord
scope :owners, -> { active.where(access_level: OWNER) }
scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
scope :with_user, -> (user) { where(user: user) }
+ scope :preload_user_and_notification_settings, -> { preload(user: :notification_settings) }
scope :with_source_id, ->(source_id) { where(source_id: source_id) }
scope :including_source, -> { includes(:source) }
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index a4e935a8cf5..f14aa81d454 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -424,7 +424,7 @@ class NotificationService
end
def project_was_moved(project, old_path_with_namespace)
- recipients = project.private? ? project.team.members_in_project_and_ancestors : project.team.members
+ recipients = project_moved_recipients(project)
recipients = notifiable_users(recipients, :mention, project: project)
recipients.each do |recipient|
@@ -705,6 +705,14 @@ class NotificationService
recipients
end
+ def project_moved_recipients(project)
+ finder = MembersFinder.new(project, nil, params: {
+ active_without_invites_and_requests: true,
+ owners_and_maintainers: true
+ })
+ finder.execute.preload_user_and_notification_settings.map(&:user)
+ end
+
def project_maintainers_recipients(target, action:)
NotificationRecipients::BuildService.build_project_maintainers_recipients(target, action: action)
end
diff --git a/app/views/ci/deploy_freeze/_index.html.haml b/app/views/ci/deploy_freeze/_index.html.haml
new file mode 100644
index 00000000000..fa4b3d5684e
--- /dev/null
+++ b/app/views/ci/deploy_freeze/_index.html.haml
@@ -0,0 +1,2 @@
+#js-deploy-freeze-table{ data: { project_id: @project.id, timezone_data: timezone_data.to_json } }
+
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index b5452fcca55..ff17a2c259c 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -74,3 +74,22 @@
= link_to _('More information'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer')
.settings-content
= render 'projects/registry/settings/index'
+
+- if can?(current_user, :create_freeze_period, @project)
+ %section.settings.no-animate#js-deploy-freeze-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _("Deploy freezes")
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ - freeze_period_docs = help_page_path('user/project/releases/index', anchor: 'prevent-unintentional-releases-by-setting-a-deploy-freeze')
+ - freeze_period_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: freeze_period_docs }
+ = s_('DeployFreeze|Specify times when deployments are not allowed for an environment. The <code>gitlab-ci.yml</code> file must be updated to make deployment jobs aware of the %{freeze_period_link_start}freeze period%{freeze_period_link_end}.').html_safe % { freeze_period_link_start: freeze_period_link_start, freeze_period_link_end: '</a>'.html_safe }
+
+ - cron_syntax_url = 'https://crontab.guru/'
+ - cron_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: cron_syntax_url }
+ = s_('DeployFreeze|You can specify deploy freezes using only %{cron_syntax_link_start}cron syntax%{cron_syntax_link_end}.').html_safe % { cron_syntax_link_start: cron_syntax_link_start, cron_syntax_link_end: "</a>".html_safe }
+
+ .settings-content
+ = render 'ci/deploy_freeze/index'
diff --git a/changelogs/unreleased/207226-child-pipelines-and-their-jobs-are-not-found-by-api-endpoints.yml b/changelogs/unreleased/207226-child-pipelines-and-their-jobs-are-not-found-by-api-endpoints.yml
new file mode 100644
index 00000000000..a5914aed3bc
--- /dev/null
+++ b/changelogs/unreleased/207226-child-pipelines-and-their-jobs-are-not-found-by-api-endpoints.yml
@@ -0,0 +1,5 @@
+---
+title: 'Bug Fix: Child pipelines are not found by API endpoints'
+merge_request: 36494
+author:
+type: fixed
diff --git a/changelogs/unreleased/225405-graceful-timeout-handling-for-analytics.yml b/changelogs/unreleased/225405-graceful-timeout-handling-for-analytics.yml
new file mode 100644
index 00000000000..b0a46eecdd1
--- /dev/null
+++ b/changelogs/unreleased/225405-graceful-timeout-handling-for-analytics.yml
@@ -0,0 +1,5 @@
+---
+title: Add graceful timeout handling for analytics
+merge_request: 36811
+author:
+type: fixed
diff --git a/changelogs/unreleased/24295-set-a-deploy-freeze-in-the-ui.yml b/changelogs/unreleased/24295-set-a-deploy-freeze-in-the-ui.yml
new file mode 100644
index 00000000000..ac9644939b9
--- /dev/null
+++ b/changelogs/unreleased/24295-set-a-deploy-freeze-in-the-ui.yml
@@ -0,0 +1,5 @@
+---
+title: Resolve Set a deploy freeze in the UI
+merge_request: 35163
+author:
+type: added
diff --git a/changelogs/unreleased/fj-228828-rename-graphql-files-to-blob-actions.yml b/changelogs/unreleased/fj-228828-rename-graphql-files-to-blob-actions.yml
new file mode 100644
index 00000000000..21a3d431f99
--- /dev/null
+++ b/changelogs/unreleased/fj-228828-rename-graphql-files-to-blob-actions.yml
@@ -0,0 +1,5 @@
+---
+title: Rename snippet GraphQL files field to blob_actions
+merge_request: 36852
+author:
+type: changed
diff --git a/changelogs/unreleased/sh-limit-project-moved-emails.yml b/changelogs/unreleased/sh-limit-project-moved-emails.yml
new file mode 100644
index 00000000000..4e206554405
--- /dev/null
+++ b/changelogs/unreleased/sh-limit-project-moved-emails.yml
@@ -0,0 +1,5 @@
+---
+title: Limit project moved e-mails to maintainers/owners
+merge_request: 36665
+author:
+type: other
diff --git a/db/migrate/20200715202659_add_index_on_package_files_file_store.rb b/db/migrate/20200715202659_add_index_on_package_files_file_store.rb
new file mode 100644
index 00000000000..92e8364d22c
--- /dev/null
+++ b/db/migrate/20200715202659_add_index_on_package_files_file_store.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class AddIndexOnPackageFilesFileStore < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :packages_package_files, :file_store
+ end
+
+ def down
+ remove_concurrent_index :packages_package_files, :file_store
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 96d3dd5bbc1..64fac69d4e5 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -19853,6 +19853,8 @@ CREATE INDEX index_packages_maven_metadata_on_package_id_and_path ON public.pack
CREATE INDEX index_packages_nuget_dl_metadata_on_dependency_link_id ON public.packages_nuget_dependency_link_metadata USING btree (dependency_link_id);
+CREATE INDEX index_packages_package_files_on_file_store ON public.packages_package_files USING btree (file_store);
+
CREATE INDEX index_packages_package_files_on_package_id_and_file_name ON public.packages_package_files USING btree (package_id, file_name);
CREATE INDEX index_packages_packages_on_name_trigram ON public.packages_packages USING gin (name public.gin_trgm_ops);
@@ -23864,6 +23866,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200713071042
20200713141854
20200713152443
+20200715202659
20200716044023
20200716120419
\.
diff --git a/doc/administration/auth/ldap/index.md b/doc/administration/auth/ldap/index.md
index aef6c70ff92..7f66be3b055 100644
--- a/doc/administration/auth/ldap/index.md
+++ b/doc/administration/auth/ldap/index.md
@@ -596,6 +596,8 @@ group, as opposed to the full DN.
### Global group memberships lock **(STARTER ONLY)**
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/1793) in GitLab 12.0.
+
"Lock memberships to LDAP synchronization" setting allows instance administrators
to lock down user abilities to invite new members to a group.
diff --git a/doc/administration/geo/replication/datatypes.md b/doc/administration/geo/replication/datatypes.md
index 5636ff79189..1345c1bb7eb 100644
--- a/doc/administration/geo/replication/datatypes.md
+++ b/doc/administration/geo/replication/datatypes.md
@@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: howto
---
-# Geo data types support
+# Geo data types support **(PREMIUM ONLY)**
A Geo data type is a specific class of data that is required by one or more GitLab features to
store relevant information.
diff --git a/doc/administration/geo/replication/geo_validation_tests.md b/doc/administration/geo/replication/geo_validation_tests.md
index 0255e5c9883..5842d0d209b 100644
--- a/doc/administration/geo/replication/geo_validation_tests.md
+++ b/doc/administration/geo/replication/geo_validation_tests.md
@@ -5,7 +5,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
type: howto
---
-# Geo validation tests
+# Geo validation tests **(PREMIUM ONLY)**
The Geo team performs manual testing and validation on common deployment configurations to ensure
that Geo works when upgrading between minor GitLab versions and major PostgreSQL database versions.
diff --git a/doc/administration/server_hooks.md b/doc/administration/server_hooks.md
index 2fea000b799..ab808fc28d8 100644
--- a/doc/administration/server_hooks.md
+++ b/doc/administration/server_hooks.md
@@ -163,13 +163,13 @@ them as they can change.
The following server hooks have been re-implemented in Go:
- `pre-receive`, with the Go implementation used by default. To use the Ruby implementation instead,
- [disable](../operations/feature_flags.md#enable-or-disable-feature-flag-strategies) the
- `:gitaly_go_preceive_hook` feature flag.
+ [disable](feature_flags.md#enable-or-disable-the-feature) the `:gitaly_go_preceive_hook` feature
+ flag.
- `update`, with the Go implementation used by default. To use the Ruby implementation instead,
- [disable](../operations/feature_flags.md#enable-or-disable-feature-flag-strategies) the
- `:gitaly_go_update_hook` feature flag.
+ [disable](feature_flags.md#enable-or-disable-the-feature) the `:gitaly_go_update_hook` feature
+ flag.
- `post-receive`, however the Ruby implementation is used by default. To use the Go implementation
- instead, [enable](../operations/feature_flags.md#enable-or-disable-feature-flag-strategies) the
+ instead, [enable](feature_flags.md#enable-or-disable-the-feature) the
`:gitaly_go_postreceive_hook` feature flag.
## Custom error messages
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index 2ed6bec104d..1ecf01ad2ea 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -2072,6 +2072,11 @@ Autogenerated input type of CreateSnippet
"""
input CreateSnippetInput {
"""
+ Actions to perform over the snippet repository and blobs
+ """
+ blobActions: [SnippetBlobActionInputType!]
+
+ """
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
@@ -2092,11 +2097,6 @@ input CreateSnippetInput {
fileName: String
"""
- The snippet files to create
- """
- files: [SnippetFileInputType!]
-
- """
The project full path the snippet is associated with
"""
projectPath: ID
@@ -12832,6 +12832,41 @@ type SnippetBlob {
}
"""
+Type of a snippet blob input action
+"""
+enum SnippetBlobActionEnum {
+ create
+ delete
+ move
+ update
+}
+
+"""
+Represents an action to perform over a snippet file
+"""
+input SnippetBlobActionInputType {
+ """
+ Type of input action
+ """
+ action: SnippetBlobActionEnum!
+
+ """
+ Snippet file content
+ """
+ content: String
+
+ """
+ Path of the snippet file
+ """
+ filePath: String!
+
+ """
+ Previous path of the snippet file
+ """
+ previousPath: String
+}
+
+"""
Represents how the blob content should be displayed
"""
type SnippetBlobViewer {
@@ -12906,41 +12941,6 @@ type SnippetEdge {
node: Snippet
}
-"""
-Type of a snippet file input action
-"""
-enum SnippetFileInputActionEnum {
- create
- delete
- move
- update
-}
-
-"""
-Represents an action to perform over a snippet file
-"""
-input SnippetFileInputType {
- """
- Type of input action
- """
- action: SnippetFileInputActionEnum!
-
- """
- Snippet file content
- """
- content: String
-
- """
- Path of the snippet file
- """
- filePath: String!
-
- """
- Previous path of the snippet file
- """
- previousPath: String
-}
-
type SnippetPermissions {
"""
Indicates the user can perform `admin_snippet` on this resource
@@ -14211,6 +14211,11 @@ Autogenerated input type of UpdateSnippet
"""
input UpdateSnippetInput {
"""
+ Actions to perform over the snippet repository and blobs
+ """
+ blobActions: [SnippetBlobActionInputType!]
+
+ """
A unique identifier for the client performing the mutation.
"""
clientMutationId: String
@@ -14231,11 +14236,6 @@ input UpdateSnippetInput {
fileName: String
"""
- The snippet files to update
- """
- files: [SnippetFileInputType!]
-
- """
The global id of the snippet to update
"""
id: ID!
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 80aaa4aa949..6d9f102e7c4 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -5623,8 +5623,8 @@
"defaultValue": null
},
{
- "name": "files",
- "description": "The snippet files to create",
+ "name": "blobActions",
+ "description": "Actions to perform over the snippet repository and blobs",
"type": {
"kind": "LIST",
"name": null,
@@ -5633,7 +5633,7 @@
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
- "name": "SnippetFileInputType",
+ "name": "SnippetBlobActionInputType",
"ofType": null
}
}
@@ -37841,6 +37841,100 @@
"possibleTypes": null
},
{
+ "kind": "ENUM",
+ "name": "SnippetBlobActionEnum",
+ "description": "Type of a snippet blob input action",
+ "fields": null,
+ "inputFields": null,
+ "interfaces": null,
+ "enumValues": [
+ {
+ "name": "create",
+ "description": null,
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "update",
+ "description": null,
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "delete",
+ "description": null,
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "move",
+ "description": null,
+ "isDeprecated": false,
+ "deprecationReason": null
+ }
+ ],
+ "possibleTypes": null
+ },
+ {
+ "kind": "INPUT_OBJECT",
+ "name": "SnippetBlobActionInputType",
+ "description": "Represents an action to perform over a snippet file",
+ "fields": null,
+ "inputFields": [
+ {
+ "name": "action",
+ "description": "Type of input action",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "ENUM",
+ "name": "SnippetBlobActionEnum",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "previousPath",
+ "description": "Previous path of the snippet file",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "filePath",
+ "description": "Path of the snippet file",
+ "type": {
+ "kind": "NON_NULL",
+ "name": null,
+ "ofType": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ }
+ },
+ "defaultValue": null
+ },
+ {
+ "name": "content",
+ "description": "Snippet file content",
+ "type": {
+ "kind": "SCALAR",
+ "name": "String",
+ "ofType": null
+ },
+ "defaultValue": null
+ }
+ ],
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "OBJECT",
"name": "SnippetBlobViewer",
"description": "Represents how the blob content should be displayed",
@@ -38088,100 +38182,6 @@
"possibleTypes": null
},
{
- "kind": "ENUM",
- "name": "SnippetFileInputActionEnum",
- "description": "Type of a snippet file input action",
- "fields": null,
- "inputFields": null,
- "interfaces": null,
- "enumValues": [
- {
- "name": "create",
- "description": null,
- "isDeprecated": false,
- "deprecationReason": null
- },
- {
- "name": "update",
- "description": null,
- "isDeprecated": false,
- "deprecationReason": null
- },
- {
- "name": "delete",
- "description": null,
- "isDeprecated": false,
- "deprecationReason": null
- },
- {
- "name": "move",
- "description": null,
- "isDeprecated": false,
- "deprecationReason": null
- }
- ],
- "possibleTypes": null
- },
- {
- "kind": "INPUT_OBJECT",
- "name": "SnippetFileInputType",
- "description": "Represents an action to perform over a snippet file",
- "fields": null,
- "inputFields": [
- {
- "name": "action",
- "description": "Type of input action",
- "type": {
- "kind": "NON_NULL",
- "name": null,
- "ofType": {
- "kind": "ENUM",
- "name": "SnippetFileInputActionEnum",
- "ofType": null
- }
- },
- "defaultValue": null
- },
- {
- "name": "previousPath",
- "description": "Previous path of the snippet file",
- "type": {
- "kind": "SCALAR",
- "name": "String",
- "ofType": null
- },
- "defaultValue": null
- },
- {
- "name": "filePath",
- "description": "Path of the snippet file",
- "type": {
- "kind": "NON_NULL",
- "name": null,
- "ofType": {
- "kind": "SCALAR",
- "name": "String",
- "ofType": null
- }
- },
- "defaultValue": null
- },
- {
- "name": "content",
- "description": "Snippet file content",
- "type": {
- "kind": "SCALAR",
- "name": "String",
- "ofType": null
- },
- "defaultValue": null
- }
- ],
- "interfaces": null,
- "enumValues": null,
- "possibleTypes": null
- },
- {
"kind": "OBJECT",
"name": "SnippetPermissions",
"description": null,
@@ -41968,8 +41968,8 @@
"defaultValue": null
},
{
- "name": "files",
- "description": "The snippet files to update",
+ "name": "blobActions",
+ "description": "Actions to perform over the snippet repository and blobs",
"type": {
"kind": "LIST",
"name": null,
@@ -41978,7 +41978,7 @@
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
- "name": "SnippetFileInputType",
+ "name": "SnippetBlobActionInputType",
"ofType": null
}
}
diff --git a/doc/api/jobs.md b/doc/api/jobs.md
index 4dc29fc897d..089fd565291 100644
--- a/doc/api/jobs.md
+++ b/doc/api/jobs.md
@@ -269,6 +269,9 @@ Example of response
]
```
+Since GitLab 13.2, this endpoint [returns data for any pipeline](pipelines.md#single-pipeline-requests)
+including [child pipelines](../ci/parent_child_pipelines.md).
+
## List pipeline bridges
Get a list of bridge jobs for a pipeline.
diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md
index 563829b8192..5e58d4ea64a 100644
--- a/doc/api/pipelines.md
+++ b/doc/api/pipelines.md
@@ -1,5 +1,13 @@
# Pipelines API
+## Single Pipeline Requests
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36494) in GitLab 13.2.
+
+Endpoints that request information about a single pipeline return data for any pipeline.
+Before 13.2, requests for [child pipelines](../ci/parent_child_pipelines.md) returned
+a 404 error.
+
## Pipelines pagination
By default, `GET` requests return 20 results at a time because the API results
diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md
index 21c99f928d8..a9d66aecd6b 100644
--- a/doc/ci/runners/README.md
+++ b/doc/ci/runners/README.md
@@ -159,9 +159,9 @@ To create a group Runner:
1. Note the URL and token.
1. [Register the Runner](https://docs.gitlab.com/runner/register/).
-<!-- #### View and manage group Runners
+#### View and manage group Runners
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/37366/) in GitLab 13.3.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/37366/) in GitLab 13.2.
You can view and manage all Runners for a group, its subgroups, and projects.
You can do this for your self-managed GitLab instance or for GitLab.com.
@@ -183,7 +183,7 @@ You must have [Owner permissions](../../user/permissions.md#group-members-permis
| Tags | Tags associated with the Runner |
| Last contact | Timestamp indicating when the GitLab instance last contacted the Runner |
-From this page, you can edit, pause, and remove Runners from the group, its subgroups, and projects. -->
+From this page, you can edit, pause, and remove Runners from the group, its subgroups, and projects.
#### Pause or remove a group Runner
@@ -193,9 +193,9 @@ You must have [Owner permissions](../../user/permissions.md#group-members-permis
1. Go to the group you want to remove or pause the Runner for.
1. Go to **{settings}** **Settings > CI/CD** and expand the **Runners** section.
1. Click **Pause** or **Remove Runner**.
-<!-- - If you pause a group Runner that is used by multiple projects, the Runner pauses for all projects. -->
-<!-- - From the group view, you cannot remove a Runner that is assigned to more than one project. -->
-<!-- You must remove it from each project first. -->
+ - If you pause a group Runner that is used by multiple projects, the Runner pauses for all projects.
+ - From the group view, you cannot remove a Runner that is assigned to more than one project.
+ You must remove it from each project first.
1. On the confirmation dialog, click **OK**.
### Specific Runners
diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md
index e1d1d27efed..43b321382f6 100644
--- a/doc/ci/yaml/README.md
+++ b/doc/ci/yaml/README.md
@@ -2427,8 +2427,13 @@ Read the `environment:action` section for an example.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/22191) in GitLab 8.13.
-The `action` keyword is to be used in conjunction with `on_stop` and is defined
-in the job that is called to close the environment.
+The `action` keyword can be used to specify jobs that prepare, start, or stop environments.
+
+| **Value** | **Description** |
+|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| start | Default value. Indicates that job starts the environment. Deployment will be created after job starts. |
+| prepare | Indicates that job is only preparing the environment. Does not affect deployments. [Read more about environments](../environments/index.md#prepare-an-environment) |
+| stop | Indicates that job stops deployment. See the example below. |
Take for instance:
diff --git a/doc/development/fe_guide/vuex.md b/doc/development/fe_guide/vuex.md
index 02387c15951..afba5b67af3 100644
--- a/doc/development/fe_guide/vuex.md
+++ b/doc/development/fe_guide/vuex.md
@@ -40,24 +40,25 @@ The following example shows an application that lists and adds users to the stat
This is the entry point for our store. You can use the following as a guide:
```javascript
-import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
-Vue.use(Vuex);
-
-export const createStore = () => new Vuex.Store({
- actions,
- getters,
- mutations,
- state,
-});
-export default createStore();
+export const createStore = () =>
+ new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state,
+ });
```
+_Note:_ Until this
+[RFC](https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20) is implemented,
+the above will need to disable the `import/prefer-default-export` ESLint rule.
+
### `state.js`
The first thing you should do before writing any code is to design the state.
@@ -316,9 +317,12 @@ function when mounting your Vue component:
// in the Vue app's initialization script (e.g. mount_show.js)
import Vue from 'vue';
-import createStore from './stores';
+import Vuex from 'vuex';
+import { createStore } from './stores';
import AwesomeVueApp from './components/awesome_vue_app.vue'
+Vue.use(Vuex);
+
export default () => {
const el = document.getElementById('js-awesome-vue-app');
@@ -398,10 +402,8 @@ discussion](https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/56#note_3025148
```javascript
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
-import store from './store';
export default {
- store,
computed: {
...mapGetters([
'getUsersWithPets'
@@ -417,12 +419,10 @@ export default {
'fetchUsers',
'addUser',
]),
-
onClickAddUser(data) {
this.addUser(data);
}
},
-
created() {
this.fetchUsers()
}
@@ -485,55 +485,50 @@ In order to write unit tests for those components, we need to include the store
```javascript
//component_spec.js
import Vue from 'vue';
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
import { createStore } from './store';
-import component from './component.vue'
+import Component from './component.vue'
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
describe('component', () => {
let store;
- let vm;
- let Component;
+ let wrapper;
+
+ const createComponent = () => {
+ store = createStore();
+
+ wrapper = mount(Component, {
+ localVue,
+ store,
+ });
+ };
beforeEach(() => {
- Component = Vue.extend(issueActions);
+ createComponent();
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ wrapper = null;
});
- it('should show a user', () => {
+ it('should show a user', async () => {
const user = {
name: 'Foo',
age: '30',
};
- store = createStore();
-
// populate the store
- store.dispatch('addUser', user);
+ await store.dispatch('addUser', user);
- vm = new Component({
- store,
- propsData: props,
- }).$mount();
+ expect(wrapper.text()).toContain(user.name);
});
});
```
-#### Testing Vuex actions and getters
-
-Because we're currently using [`babel-plugin-rewire`](https://github.com/speedskater/babel-plugin-rewire), you may encounter the following error when testing your Vuex actions and getters:
-`[vuex] actions should be function or object with "handler" function`
-
-To prevent this error from happening, you need to export an empty function as `default`:
-
-```javascript
-// getters.js or actions.js
-
-// prevent babel-plugin-rewire from generating an invalid default during karma tests
-export default () => {};
-```
-
### Two way data binding
When storing form data in Vuex, it is sometimes necessary to update the value stored. The store should never be mutated directly, and an action should be used instead.
diff --git a/doc/development/multi_version_compatibility.md b/doc/development/multi_version_compatibility.md
index aedd5c1ffb7..ce6cc6610f4 100644
--- a/doc/development/multi_version_compatibility.md
+++ b/doc/development/multi_version_compatibility.md
@@ -60,3 +60,18 @@ We added a `NOT NULL` constraint to a column and marked it as a `NOT VALID` cons
But even with that, this was still a problem because the old servers were still inserting new rows with null values.
For more information, see [the relevant issue](https://gitlab.com/gitlab-com/gl-infra/production/-/issues/1944).
+
+### Downtime on release features between canary and production deployment
+
+To address the issue, we added a new column to an existing table with a `NOT NULL` constraint without
+specifying a default value. In other words, this requires the application to set a value to the column.
+
+The older version of the application didn't set the `NOT NULL` constraint since the entity/concept didn't
+exist before.
+
+The problem starts right after the canary deployment is complete. At that moment,
+the database migration (to add the column) has successfully run and canary instance starts using
+the new application code, hence QA was successful. Unfortunately, the production
+instance still uses the older code, so it started failing to insert a new release entry.
+
+For more information, see [this issue related to the Releases API](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/64151).
diff --git a/doc/user/admin_area/settings/account_and_limit_settings.md b/doc/user/admin_area/settings/account_and_limit_settings.md
index 167016f1cb5..d0fb1d85df4 100644
--- a/doc/user/admin_area/settings/account_and_limit_settings.md
+++ b/doc/user/admin_area/settings/account_and_limit_settings.md
@@ -159,19 +159,19 @@ To do this:
### Enable or disable optional enforcement of Personal Access Token expiry Feature **(CORE ONLY)**
-Optional Enforcement of Personal Access Token Expiry is under development and not ready for production use. It is deployed behind a feature flag that is **disabled by default**.
+Optional Enforcement of Personal Access Token Expiry is deployed behind a feature flag and is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md) can enable it for your instance from the [rails console](../../../administration/feature_flags.md#start-the-gitlab-rails-console).
To enable it:
```ruby
-Feature.enable(:enforce_personal_access_token_expiration)
+Feature.enable(:enforce_pat_expiration)
```
To disable it:
```ruby
-Feature.disable(:enforce_personal_access_token_expiration)
+Feature.disable(:enforce_pat_expiration)
```
## Disabling user profile name changes **(PREMIUM ONLY)**
diff --git a/doc/user/project/clusters/securing.md b/doc/user/project/clusters/securing.md
index b4c20cb8dbc..12836d9153b 100644
--- a/doc/user/project/clusters/securing.md
+++ b/doc/user/project/clusters/securing.md
@@ -40,6 +40,10 @@ Minimum requirements (depending on the GitLab Manage Application you want to ins
### Understanding how GitLab Managed Apps are installed
+NOTE: **Note:**
+These diagrams use the term _Kubernetes_ for simplicity. In practice, Sidekiq connects to a Helm
+Tiller daemon running in a pod in the cluster.
+
You install GitLab Managed Apps from the GitLab web interface with a one-click setup process. GitLab
uses Sidekiq (a background processing service) to facilitate this.
@@ -52,10 +56,6 @@ uses Sidekiq (a background processing service) to facilitate this.
Sidekiq-->>-GitLab: Refresh UI
```
-NOTE: **Note:**
-This diagram uses the term _Kubernetes_ for simplicity. In practice, Sidekiq connects to a Helm
-Tiller daemon running in a pod in the cluster.
-
Although this installation method is easier because it's a point-and-click action in the user
interface, it's inflexible and hard to debug. When something goes wrong, you can't see the
deployment logs. The Web Application Firewall feature uses this installation method.
@@ -151,4 +151,5 @@ falco:
installed: true
```
-[Read more] about configuring Container Host Security.
+[Read more](../../clusters/applications.md#install-falco-using-gitlab-cicd)
+about configuring Container Host Security.
diff --git a/doc/user/project/releases/img/deploy_freeze_v13_2.png b/doc/user/project/releases/img/deploy_freeze_v13_2.png
new file mode 100644
index 00000000000..27d3a6044a1
--- /dev/null
+++ b/doc/user/project/releases/img/deploy_freeze_v13_2.png
Binary files differ
diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md
index 258601574ca..ae5c047aefa 100644
--- a/doc/user/project/releases/index.md
+++ b/doc/user/project/releases/index.md
@@ -176,7 +176,7 @@ Prevent unintended production releases during a period of time you specify by
setting a [*deploy freeze* period](../../../ci/environments/deployment_safety.md).
Deploy freezes help reduce uncertainty and risk when automating deployments.
-Use the [Freeze Periods API](../../../api/freeze_periods.md) to set a `freeze_start` and a `freeze_end`, which
+A maintainer can set a deploy freeze window in the user interface or by using the [Freeze Periods API](../../../api/freeze_periods.md) to set a `freeze_start` and a `freeze_end`, which
are defined as [crontab](https://crontab.guru/) entries.
If the job that's executing is within a freeze period, GitLab CI/CD creates an environment
@@ -193,6 +193,22 @@ deploy_to_production:
- if: $CI_DEPLOY_FREEZE == null
```
+To set a deploy freeze window in the UI, complete these steps:
+
+1. Sign in to GitLab as a user with project Maintainer [permissions](../../permissions.md).
+1. Navigate to **Project overview**.
+1. In the left navigation menu, navigate to **{settings}** **Settings > CI / CD**.
+1. Scroll to **Deploy freezes**.
+1. Click **Expand** to see the deploy freeze table.
+1. Click **Add deploy freeze** to open the deploy freeze modal.
+1. Enter the start time, end time, and timezone of the desired deploy freeze period.
+1. Click **Add deploy freeze** in the modal.
+
+![Deploy freeze modal for setting a deploy freeze period](img/deploy_freeze_v13_2.png)
+
+CAUTION: **Caution:**
+To edit or delete a deploy freeze, use the [Freeze Periods API](../../../api/freeze_periods.md).
+
If a project contains multiple freeze periods, all periods apply. If they overlap, the freeze covers the
complete overlapping period.
diff --git a/doc/user/todos.md b/doc/user/todos.md
index ef0e75bc197..f8cada1883d 100644
--- a/doc/user/todos.md
+++ b/doc/user/todos.md
@@ -43,9 +43,12 @@ A To Do appears on your To-Do List when:
- Commit
- Design
- The CI/CD pipeline for your merge request failed
-- An open merge request becomes unmergeable due to conflict, and you are either:
- - The author
- - Have set it to automatically merge once the pipeline succeeds
+- An open merge request becomes unmergeable due to conflict, and one of the following is true:
+ - You are the author
+ - You are the user that set it to automatically merge once the pipeline succeeds
+- [Since GitLab 13.2](https://gitlab.com/gitlab-org/gitlab/-/issues/12136), a merge request
+ is removed from a [merge train](../ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md)
+ and you are the user that added it. **(PREMIUM)**
To-do triggers are not affected by [GitLab Notification Email settings](profile/notifications.md).
diff --git a/lib/api/ci/pipelines.rb b/lib/api/ci/pipelines.rb
index 33bb8b38d92..4fb301f0260 100644
--- a/lib/api/ci/pipelines.rb
+++ b/lib/api/ci/pipelines.rb
@@ -174,7 +174,7 @@ module API
helpers do
def pipeline
strong_memoize(:pipeline) do
- user_project.ci_pipelines.find(params[:pipeline_id])
+ user_project.all_pipelines.find(params[:pipeline_id])
end
end
diff --git a/lib/api/jobs.rb b/lib/api/jobs.rb
index bcc00429dd6..9fab722b72e 100644
--- a/lib/api/jobs.rb
+++ b/lib/api/jobs.rb
@@ -59,7 +59,7 @@ module API
# rubocop: disable CodeReuse/ActiveRecord
get ':id/pipelines/:pipeline_id/jobs' do
authorize!(:read_pipeline, user_project)
- pipeline = user_project.ci_pipelines.find(params[:pipeline_id])
+ pipeline = user_project.all_pipelines.find(params[:pipeline_id])
authorize!(:read_build, pipeline)
builds = pipeline.builds
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index bfa1aa2060c..7ca273d78c2 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -791,6 +791,9 @@ msgstr ""
msgid "(revoked)"
msgstr ""
+msgid "* * * * *"
+msgstr ""
+
msgid "+ %{amount} more"
msgstr ""
@@ -1456,6 +1459,9 @@ msgstr ""
msgid "Add comment now"
msgstr ""
+msgid "Add deploy freeze"
+msgstr ""
+
msgid "Add domain"
msgstr ""
@@ -7079,6 +7085,9 @@ msgstr ""
msgid "Cron Timezone"
msgstr ""
+msgid "Cron time zone"
+msgstr ""
+
msgid "Crossplane"
msgstr ""
@@ -7555,6 +7564,9 @@ msgstr ""
msgid "DefaultBranchLabel|default"
msgstr ""
+msgid "Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}"
+msgstr ""
+
msgid "Define a custom pattern with cron syntax"
msgstr ""
@@ -7802,6 +7814,9 @@ msgstr[1] ""
msgid "Deploy Keys"
msgstr ""
+msgid "Deploy freezes"
+msgstr ""
+
msgid "Deploy key was successfully updated."
msgstr ""
@@ -7817,6 +7832,24 @@ msgstr ""
msgid "DeployBoard|Matching on the %{appLabel} label has been removed for deploy boards. To see all instances on your board, you must update your chart and redeploy."
msgstr ""
+msgid "DeployFreeze|Freeze end"
+msgstr ""
+
+msgid "DeployFreeze|Freeze start"
+msgstr ""
+
+msgid "DeployFreeze|No deploy freezes exist for this project. To add one, click %{strongStart}Add deploy freeze%{strongEnd}"
+msgstr ""
+
+msgid "DeployFreeze|Specify times when deployments are not allowed for an environment. The <code>gitlab-ci.yml</code> file must be updated to make deployment jobs aware of the %{freeze_period_link_start}freeze period%{freeze_period_link_end}."
+msgstr ""
+
+msgid "DeployFreeze|Time zone"
+msgstr ""
+
+msgid "DeployFreeze|You can specify deploy freezes using only %{cron_syntax_link_start}cron syntax%{cron_syntax_link_end}."
+msgstr ""
+
msgid "DeployKeys|+%{count} others"
msgstr ""
@@ -9505,6 +9538,9 @@ msgstr ""
msgid "Error: %{error_message}"
msgstr ""
+msgid "Error: Unable to create deploy freeze"
+msgstr ""
+
msgid "ErrorTracking|Active"
msgstr ""
@@ -10564,6 +10600,12 @@ msgstr ""
msgid "Free Trial of GitLab.com Gold"
msgstr ""
+msgid "Freeze end"
+msgstr ""
+
+msgid "Freeze start"
+msgstr ""
+
msgid "Frequency"
msgstr ""
@@ -11656,6 +11698,9 @@ msgstr ""
msgid "GroupRoadmap|%{startDateInWords} – %{endDateInWords}"
msgstr ""
+msgid "GroupRoadmap|No start and end date"
+msgstr ""
+
msgid "GroupRoadmap|No start date – %{dateWord}"
msgstr ""
@@ -21165,6 +21210,9 @@ msgstr ""
msgid "Select timeframe"
msgstr ""
+msgid "Select timezone"
+msgstr ""
+
msgid "Select user"
msgstr ""
@@ -23744,6 +23792,9 @@ msgstr ""
msgid "There is no data available. Please change your selection."
msgstr ""
+msgid "There is too much data to calculate. Please change your selection."
+msgstr ""
+
msgid "There was a problem communicating with your device."
msgstr ""
@@ -23813,6 +23864,9 @@ msgstr ""
msgid "There was an error fetching the Node's Groups"
msgstr ""
+msgid "There was an error fetching the deploy freezes."
+msgstr ""
+
msgid "There was an error fetching the environments information."
msgstr ""
@@ -23948,6 +24002,9 @@ msgstr ""
msgid "This %{viewer} could not be displayed because %{reason}. You can %{options} instead."
msgstr ""
+msgid "This Cron pattern is invalid"
+msgstr ""
+
msgid "This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area."
msgstr ""
diff --git a/package.json b/package.json
index 2e92c00ce11..55bfd58b68d 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
"compression-webpack-plugin": "^3.0.1",
"copy-webpack-plugin": "^5.0.5",
"core-js": "^3.6.4",
+ "cron-validator": "^1.1.1",
"cropper": "^2.3.0",
"css-loader": "^2.1.1",
"d3": "^5.16.0",
diff --git a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb
index 3717bc8a9ff..a334731386a 100644
--- a/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb
+++ b/qa/qa/specs/features/browser_ui/2_plan/email/trigger_email_notification_spec.rb
@@ -26,7 +26,7 @@ module QA
mailhog_items = mailhog_json.dig('items')
- expect(mailhog_items).to include(an_object_satisfying { |o| /project was granted/ === o.dig('Content', 'Headers', 'Subject', 0) })
+ expect(mailhog_items).to include(an_object_satisfying { |o| /project was granted/ === mailhog_item_subject(o) })
end
private
@@ -38,11 +38,22 @@ module QA
mailhog_response = get QA::Runtime::MailHog.api_messages_url
mailhog_data = JSON.parse(mailhog_response.body)
+ total = mailhog_data.dig('total')
+ subjects = mailhog_data.dig('items')
+ .map(&method(:mailhog_item_subject))
+ .join("\n")
+
+ Runtime::Logger.debug(%Q[Total number of emails: #{total}])
+ Runtime::Logger.debug(%Q[Subjects:\n#{subjects}])
# Expect at least two invitation messages: group and project
- mailhog_data if mailhog_data.dig('total') >= 2
+ mailhog_data if total >= 2
end
end
+
+ def mailhog_item_subject(item)
+ item.dig('Content', 'Headers', 'Subject', 0)
+ end
end
end
end
diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb
index 784f474a7d5..05631fe1ab5 100644
--- a/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb
+++ b/qa/qa/specs/features/browser_ui/2_plan/issue/check_mentions_for_xss_spec.rb
@@ -2,35 +2,41 @@
module QA
RSpec.describe 'Plan', :reliable do
+ let(:user) do
+ Resource::User.fabricate_via_api! do |user|
+ user.name = "eve <img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;"
+ user.password = "test1234"
+ end
+ end
+
+ let(:project) do
+ Resource::Project.fabricate_via_api! do |project|
+ project.name = 'xss-test-for-mentions-project'
+ end
+ end
+
describe 'check xss occurence in @mentions in issues', :requires_admin do
- it 'mentions a user in a comment' do
+ before do
QA::Runtime::Env.personal_access_token = QA::Runtime::Env.admin_personal_access_token
unless QA::Runtime::Env.personal_access_token
Flow::Login.sign_in_as_admin
end
- user = Resource::User.fabricate_via_api! do |user|
- user.name = "eve <img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;"
- user.password = "test1234"
- end
-
QA::Runtime::Env.personal_access_token = nil
Page::Main::Menu.perform(&:sign_out) if Page::Main::Menu.perform { |p| p.has_personal_area?(wait: 0) }
Flow::Login.sign_in
- project = Resource::Project.fabricate_via_api! do |project|
- project.name = 'xss-test-for-mentions-project'
- end
-
Flow::Project.add_member(project: project, username: user.username)
Resource::Issue.fabricate_via_api! do |issue|
issue.project = project
end.visit!
+ end
+ it 'mentions a user in a comment' do
Page::Project::Issue::Show.perform do |show|
show.select_all_activities_filter
show.comment("cc-ing you here @#{user.username}")
diff --git a/spec/controllers/concerns/graceful_timeout_handling_spec.rb b/spec/controllers/concerns/graceful_timeout_handling_spec.rb
new file mode 100644
index 00000000000..cece36f06b2
--- /dev/null
+++ b/spec/controllers/concerns/graceful_timeout_handling_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GracefulTimeoutHandling, type: :controller do
+ controller(ApplicationController) do
+ include GracefulTimeoutHandling
+
+ skip_before_action :authenticate_user!
+
+ def index
+ raise ActiveRecord::QueryCanceled.new
+ end
+ end
+
+ context 'for json request' do
+ subject { get :index, format: :json }
+
+ it 'renders graceful error message' do
+ subject
+
+ expect(json_response['error']).to eq(_('There is too much data to calculate. Please change your selection.'))
+ expect(response.code).to eq '200'
+ end
+
+ it 'logs exception' do
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(kind_of(ActiveRecord::QueryCanceled))
+
+ subject
+ end
+ end
+
+ context 'for html request' do
+ subject { get :index, format: :html }
+
+ it 'has no effect' do
+ expect do
+ subject
+ end.to raise_error(ActiveRecord::QueryCanceled)
+ end
+ end
+end
diff --git a/spec/controllers/projects/cycle_analytics/events_controller_spec.rb b/spec/controllers/projects/cycle_analytics/events_controller_spec.rb
index 408ce51d34b..c5b72ff2b3b 100644
--- a/spec/controllers/projects/cycle_analytics/events_controller_spec.rb
+++ b/spec/controllers/projects/cycle_analytics/events_controller_spec.rb
@@ -57,6 +57,8 @@ RSpec.describe Projects::CycleAnalytics::EventsController do
end
end
+ include_examples GracefulTimeoutHandling
+
def get_issue(additional_params: {})
params = additional_params.merge(namespace_id: project.namespace, project_id: project)
get(:issue, params: params, format: :json)
diff --git a/spec/controllers/projects/cycle_analytics_controller_spec.rb b/spec/controllers/projects/cycle_analytics_controller_spec.rb
index ec853b74b9b..e956065972f 100644
--- a/spec/controllers/projects/cycle_analytics_controller_spec.rb
+++ b/spec/controllers/projects/cycle_analytics_controller_spec.rb
@@ -67,4 +67,6 @@ RSpec.describe Projects::CycleAnalyticsController do
end
end
end
+
+ include_examples GracefulTimeoutHandling
end
diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb
index b14ad84a96e..3ef8d6a01aa 100644
--- a/spec/finders/members_finder_spec.rb
+++ b/spec/finders/members_finder_spec.rb
@@ -10,16 +10,39 @@ RSpec.describe MembersFinder, '#execute' do
let_it_be(:user2) { create(:user) }
let_it_be(:user3) { create(:user) }
let_it_be(:user4) { create(:user) }
+ let_it_be(:blocked_user) { create(:user, :blocked) }
it 'returns members for project and parent groups' do
nested_group.request_access(user1)
member1 = group.add_maintainer(user2)
member2 = nested_group.add_maintainer(user3)
member3 = project.add_maintainer(user4)
+ blocked_member = project.add_maintainer(blocked_user)
result = described_class.new(project, user2).execute
- expect(result).to contain_exactly(member1, member2, member3)
+ expect(result).to contain_exactly(member1, member2, member3, blocked_member)
+ end
+
+ it 'returns owners and maintainers' do
+ member1 = group.add_owner(user1)
+ group.add_developer(user2)
+ member3 = project.add_maintainer(user3)
+ project.add_developer(user4)
+
+ result = described_class.new(project, user2, params: { owners_and_maintainers: true }).execute
+
+ expect(result).to contain_exactly(member1, member3)
+ end
+
+ it 'returns active users and excludes invited users' do
+ member1 = project.add_maintainer(user2)
+ create(:project_member, :invited, project: project, invite_email: create(:user).email)
+ project.add_maintainer(blocked_user)
+
+ result = described_class.new(project, user2, params: { active_without_invites_and_requests: true }).execute
+
+ expect(result).to contain_exactly(member1)
end
it 'includes only non-invite members if user do not have amdin permissions on project' do
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index c94637e04af..b4e25867fad 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -842,4 +842,53 @@ describe('Api', () => {
.catch(done.fail);
});
});
+
+ describe('freezePeriods', () => {
+ it('fetches freezePeriods', () => {
+ const projectId = 8;
+ const freezePeriod = {
+ id: 3,
+ freeze_start: '5 4 * * *',
+ freeze_end: '5 9 * 8 *',
+ cron_timezone: 'America/New_York',
+ created_at: '2020-07-10T05:10:35.122Z',
+ updated_at: '2020-07-10T05:10:35.122Z',
+ };
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/freeze_periods`;
+ mock.onGet(expectedUrl).reply(200, [freezePeriod]);
+
+ return Api.freezePeriods(projectId).then(({ data }) => {
+ expect(data[0]).toStrictEqual(freezePeriod);
+ });
+ });
+ });
+
+ describe('createFreezePeriod', () => {
+ const projectId = 8;
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/freeze_periods`;
+ const options = {
+ freeze_start: '* * * * *',
+ freeze_end: '* * * * *',
+ cron_timezone: 'America/Juneau',
+ };
+
+ const expectedResult = {
+ id: 10,
+ freeze_start: '* * * * *',
+ freeze_end: '* * * * *',
+ cron_timezone: 'America/Juneau',
+ created_at: '2020-07-11T07:04:50.153Z',
+ updated_at: '2020-07-11T07:04:50.153Z',
+ };
+
+ describe('when the freeze period is successfully created', () => {
+ it('resolves the Promise', () => {
+ mock.onPost(expectedUrl, options).replyOnce(201, expectedResult);
+
+ return Api.createFreezePeriod(projectId, options).then(({ data }) => {
+ expect(data).toStrictEqual(expectedResult);
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js
new file mode 100644
index 00000000000..7f41cc87a6b
--- /dev/null
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js
@@ -0,0 +1,91 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlButton, GlModal } from '@gitlab/ui';
+import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue';
+import DeployFreezeTimezoneDropdown from '~/deploy_freeze/components/deploy_freeze_timezone_dropdown.vue';
+import createStore from '~/deploy_freeze/store';
+import { mockDeployFreezePayload, mockTimezoneData } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Deploy freeze modal', () => {
+ let wrapper;
+ let store;
+
+ beforeEach(() => {
+ store = createStore({
+ projectId: '8',
+ timezoneData: mockTimezoneData,
+ });
+ wrapper = shallowMount(DeployFreezeModal, {
+ attachToDocument: true,
+ stubs: {
+ GlModal,
+ },
+ localVue,
+ store,
+ });
+ });
+
+ const findModal = () => wrapper.find(GlModal);
+ const addDeployFreezeButton = () =>
+ findModal()
+ .findAll(GlButton)
+ .at(1);
+
+ const setInput = (freezeStartCron, freezeEndCron, selectedTimezone) => {
+ store.state.freezeStartCron = freezeStartCron;
+ store.state.freezeEndCron = freezeEndCron;
+ store.state.selectedTimezone = selectedTimezone;
+
+ wrapper.find('#deploy-freeze-start').trigger('input');
+ wrapper.find('#deploy-freeze-end').trigger('input');
+ wrapper.find(DeployFreezeTimezoneDropdown).trigger('input');
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('Basic interactions', () => {
+ it('button is disabled when freeze period is invalid', () => {
+ expect(addDeployFreezeButton().attributes('disabled')).toBeTruthy();
+ });
+ });
+
+ describe('Adding a new deploy freeze', () => {
+ beforeEach(() => {
+ const { freeze_start, freeze_end, cron_timezone } = mockDeployFreezePayload;
+ setInput(freeze_start, freeze_end, cron_timezone);
+ });
+
+ it('button is enabled when valid freeze period settings are present', () => {
+ expect(addDeployFreezeButton().attributes('disabled')).toBeUndefined();
+ });
+ });
+
+ describe('Validations', () => {
+ describe('when the cron state is invalid', () => {
+ beforeEach(() => {
+ setInput('invalid cron', 'invalid cron', 'invalid timezone');
+ });
+
+ it('disables the add deploy freeze button', () => {
+ expect(addDeployFreezeButton().attributes('disabled')).toBeTruthy();
+ });
+ });
+
+ describe('when the cron state is valid', () => {
+ beforeEach(() => {
+ const { freeze_start, freeze_end, cron_timezone } = mockDeployFreezePayload;
+ setInput(freeze_start, freeze_end, cron_timezone);
+ });
+
+ it('does not disable the submit button', () => {
+ expect(addDeployFreezeButton().attributes('disabled')).toBeFalsy();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js
new file mode 100644
index 00000000000..0c0f3401811
--- /dev/null
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js
@@ -0,0 +1,43 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import DeployFreezeSettings from '~/deploy_freeze/components/deploy_freeze_settings.vue';
+import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
+import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue';
+
+import createStore from '~/deploy_freeze/store';
+import { mockTimezoneData } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Deploy freeze settings', () => {
+ let wrapper;
+ let store;
+
+ beforeEach(() => {
+ store = createStore({
+ projectId: '8',
+ timezoneData: mockTimezoneData,
+ });
+ jest.spyOn(store, 'dispatch').mockImplementation();
+ wrapper = shallowMount(DeployFreezeSettings, {
+ localVue,
+ store,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('Deploy freeze table contains components', () => {
+ it('contains deploy freeze table', () => {
+ expect(wrapper.find(DeployFreezeTable).exists()).toBe(true);
+ });
+
+ it('contains deploy freeze modal', () => {
+ expect(wrapper.find(DeployFreezeModal).exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
new file mode 100644
index 00000000000..9a07224ec63
--- /dev/null
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
@@ -0,0 +1,69 @@
+import Vuex from 'vuex';
+import { createLocalVue, mount } from '@vue/test-utils';
+import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
+import createStore from '~/deploy_freeze/store';
+import { mockFreezePeriods, mockTimezoneData } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Deploy freeze table', () => {
+ let wrapper;
+ let store;
+
+ const createComponent = () => {
+ store = createStore({
+ projectId: '8',
+ timezoneData: mockTimezoneData,
+ });
+ jest.spyOn(store, 'dispatch').mockImplementation();
+ wrapper = mount(DeployFreezeTable, {
+ attachToDocument: true,
+ localVue,
+ store,
+ });
+ };
+
+ const findEmptyFreezePeriods = () => wrapper.find('[data-testid="empty-freeze-periods"]');
+ const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]');
+ const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('dispatches fetchFreezePeriods when mounted', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('fetchFreezePeriods');
+ });
+
+ describe('Renders correct data', () => {
+ it('displays empty', () => {
+ expect(findEmptyFreezePeriods().exists()).toBe(true);
+ expect(findEmptyFreezePeriods().text()).toBe(
+ 'No deploy freezes exist for this project. To add one, click Add deploy freeze',
+ );
+ });
+
+ it('displays data', () => {
+ store.state.freezePeriods = mockFreezePeriods;
+
+ return wrapper.vm.$nextTick(() => {
+ const tableRows = findDeployFreezeTable().findAll('tbody tr');
+ expect(tableRows.length).toBe(mockFreezePeriods.length);
+ expect(findEmptyFreezePeriods().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Table click actions', () => {
+ it('displays add deploy freeze button', () => {
+ expect(findAddDeployFreezeButton().exists()).toBe(true);
+ expect(findAddDeployFreezeButton().text()).toBe('Add deploy freeze');
+ });
+ });
+});
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_timezone_dropdown_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_timezone_dropdown_spec.js
new file mode 100644
index 00000000000..9563de4b920
--- /dev/null
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_timezone_dropdown_spec.js
@@ -0,0 +1,88 @@
+import Vuex from 'vuex';
+import DeployFreezeTimezoneDropdown from '~/deploy_freeze/components/deploy_freeze_timezone_dropdown.vue';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import createStore from '~/deploy_freeze/store';
+import { mockTimezoneData } from '../mock_data';
+
+import { GlDropdownItem } from '@gitlab/ui';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Deploy freeze timezone dropdown', () => {
+ let wrapper;
+ let store;
+
+ const createComponent = term => {
+ store = createStore({
+ projectId: '8',
+ timezoneData: mockTimezoneData,
+ });
+ store.state.timezoneData = mockTimezoneData;
+ wrapper = shallowMount(DeployFreezeTimezoneDropdown, {
+ store,
+ localVue,
+ propsData: {
+ value: term,
+ timezoneData: mockTimezoneData,
+ },
+ });
+ };
+
+ const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem);
+ const findDropdownItemByIndex = index => wrapper.findAll(GlDropdownItem).at(index);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('No enviroments found', () => {
+ beforeEach(() => {
+ createComponent('UTC timezone');
+ });
+
+ it('renders empty results message', () => {
+ expect(findDropdownItemByIndex(0).text()).toBe('No matching results');
+ });
+ });
+
+ describe('Search term is empty', () => {
+ beforeEach(() => {
+ createComponent('');
+ });
+
+ it('renders all timezones when search term is empty', () => {
+ expect(findAllDropdownItems()).toHaveLength(mockTimezoneData.length);
+ });
+ });
+
+ describe('Time zones found', () => {
+ beforeEach(() => {
+ createComponent('Alaska');
+ });
+
+ it('renders only the time zone searched for', () => {
+ expect(findAllDropdownItems()).toHaveLength(1);
+ expect(findDropdownItemByIndex(0).text()).toBe('[UTC -8] Alaska');
+ });
+
+ it('should not display empty results message', () => {
+ expect(wrapper.find({ ref: 'noMatchingResults' }).exists()).toBe(false);
+ });
+
+ describe('Custom events', () => {
+ it('should emit selectTimezone if an environment is clicked', () => {
+ findDropdownItemByIndex(0).vm.$emit('click');
+ expect(wrapper.emitted('selectTimezone')).toEqual([
+ [
+ {
+ formattedTimezone: '[UTC -8] Alaska',
+ identifier: 'America/Juneau',
+ },
+ ],
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/deploy_freeze/mock_data.js b/spec/frontend/deploy_freeze/mock_data.js
new file mode 100644
index 00000000000..56230388cda
--- /dev/null
+++ b/spec/frontend/deploy_freeze/mock_data.js
@@ -0,0 +1,186 @@
+export const mockDeployFreezePayload = {
+ freeze_start: '0 12 * * 1-5',
+ freeze_end: '0 12 * * 6',
+ cron_timezone: 'Etc/UTC',
+};
+
+export const mockFreezePeriods = [
+ {
+ id: 3,
+ freeze_start: '5 4 * * *',
+ freeze_end: '5 9 * 8 *',
+ cron_timezone: 'Eastern Time (US & Canada)',
+ created_at: '2020-07-10T05:10:35.122Z',
+ updated_at: '2020-07-10T05:10:35.122Z',
+ },
+ {
+ id: 8,
+ freeze_start: '0 12 * * 1-5',
+ freeze_end: '0 1 5 * *',
+ cron_timezone: 'Mountain Time (US & Canada)',
+ created_at: '2020-07-10T19:27:57.378Z',
+ updated_at: '2020-07-10T19:27:57.378Z',
+ },
+ {
+ id: 9,
+ freeze_start: '0 12 * * 1-5',
+ freeze_end: '0 16 * * 6',
+ cron_timezone: 'Central Time (US & Canada)',
+ created_at: '2020-07-10T19:29:15.240Z',
+ updated_at: '2020-07-10T19:29:15.240Z',
+ },
+];
+
+export const mockTimezoneData = [
+ { name: 'International Date Line West', offset: -43200, identifier: 'Etc/GMT+12' },
+ { name: 'American Samoa', offset: -39600, identifier: 'Pacific/Pago_Pago' },
+ { name: 'Midway Island', offset: -39600, identifier: 'Pacific/Midway' },
+ { name: 'Hawaii', offset: -36000, identifier: 'Pacific/Honolulu' },
+ { name: 'Alaska', offset: -28800, identifier: 'America/Juneau' },
+ { name: 'Pacific Time (US & Canada)', offset: -25200, identifier: 'America/Los_Angeles' },
+ { name: 'Tijuana', offset: -25200, identifier: 'America/Tijuana' },
+ { name: 'Arizona', offset: -25200, identifier: 'America/Phoenix' },
+ { name: 'Chihuahua', offset: -21600, identifier: 'America/Chihuahua' },
+ { name: 'Mazatlan', offset: -21600, identifier: 'America/Mazatlan' },
+ { name: 'Mountain Time (US & Canada)', offset: -21600, identifier: 'America/Denver' },
+ { name: 'Central America', offset: -21600, identifier: 'America/Guatemala' },
+ { name: 'Central Time (US & Canada)', offset: -18000, identifier: 'America/Chicago' },
+ { name: 'Guadalajara', offset: -18000, identifier: 'America/Mexico_City' },
+ { name: 'Mexico City', offset: -18000, identifier: 'America/Mexico_City' },
+ { name: 'Monterrey', offset: -18000, identifier: 'America/Monterrey' },
+ { name: 'Saskatchewan', offset: -21600, identifier: 'America/Regina' },
+ { name: 'Bogota', offset: -18000, identifier: 'America/Bogota' },
+ { name: 'Eastern Time (US & Canada)', offset: -14400, identifier: 'America/New_York' },
+ { name: 'Indiana (East)', offset: -14400, identifier: 'America/Indiana/Indianapolis' },
+ { name: 'Lima', offset: -18000, identifier: 'America/Lima' },
+ { name: 'Quito', offset: -18000, identifier: 'America/Lima' },
+ { name: 'Atlantic Time (Canada)', offset: -10800, identifier: 'America/Halifax' },
+ { name: 'Caracas', offset: -14400, identifier: 'America/Caracas' },
+ { name: 'Georgetown', offset: -14400, identifier: 'America/Guyana' },
+ { name: 'La Paz', offset: -14400, identifier: 'America/La_Paz' },
+ { name: 'Puerto Rico', offset: -14400, identifier: 'America/Puerto_Rico' },
+ { name: 'Santiago', offset: -14400, identifier: 'America/Santiago' },
+ { name: 'Newfoundland', offset: -9000, identifier: 'America/St_Johns' },
+ { name: 'Brasilia', offset: -10800, identifier: 'America/Sao_Paulo' },
+ { name: 'Buenos Aires', offset: -10800, identifier: 'America/Argentina/Buenos_Aires' },
+ { name: 'Greenland', offset: -7200, identifier: 'America/Godthab' },
+ { name: 'Montevideo', offset: -10800, identifier: 'America/Montevideo' },
+ { name: 'Mid-Atlantic', offset: -7200, identifier: 'Atlantic/South_Georgia' },
+ { name: 'Azores', offset: 0, identifier: 'Atlantic/Azores' },
+ { name: 'Cape Verde Is.', offset: -3600, identifier: 'Atlantic/Cape_Verde' },
+ { name: 'Casablanca', offset: 3600, identifier: 'Africa/Casablanca' },
+ { name: 'Dublin', offset: 3600, identifier: 'Europe/Dublin' },
+ { name: 'Edinburgh', offset: 3600, identifier: 'Europe/London' },
+ { name: 'Lisbon', offset: 3600, identifier: 'Europe/Lisbon' },
+ { name: 'London', offset: 3600, identifier: 'Europe/London' },
+ { name: 'Monrovia', offset: 0, identifier: 'Africa/Monrovia' },
+ { name: 'UTC', offset: 0, identifier: 'Etc/UTC' },
+ { name: 'Amsterdam', offset: 7200, identifier: 'Europe/Amsterdam' },
+ { name: 'Belgrade', offset: 7200, identifier: 'Europe/Belgrade' },
+ { name: 'Berlin', offset: 7200, identifier: 'Europe/Berlin' },
+ { name: 'Bern', offset: 7200, identifier: 'Europe/Zurich' },
+ { name: 'Bratislava', offset: 7200, identifier: 'Europe/Bratislava' },
+ { name: 'Brussels', offset: 7200, identifier: 'Europe/Brussels' },
+ { name: 'Budapest', offset: 7200, identifier: 'Europe/Budapest' },
+ { name: 'Copenhagen', offset: 7200, identifier: 'Europe/Copenhagen' },
+ { name: 'Ljubljana', offset: 7200, identifier: 'Europe/Ljubljana' },
+ { name: 'Madrid', offset: 7200, identifier: 'Europe/Madrid' },
+ { name: 'Paris', offset: 7200, identifier: 'Europe/Paris' },
+ { name: 'Prague', offset: 7200, identifier: 'Europe/Prague' },
+ { name: 'Rome', offset: 7200, identifier: 'Europe/Rome' },
+ { name: 'Sarajevo', offset: 7200, identifier: 'Europe/Sarajevo' },
+ { name: 'Skopje', offset: 7200, identifier: 'Europe/Skopje' },
+ { name: 'Stockholm', offset: 7200, identifier: 'Europe/Stockholm' },
+ { name: 'Vienna', offset: 7200, identifier: 'Europe/Vienna' },
+ { name: 'Warsaw', offset: 7200, identifier: 'Europe/Warsaw' },
+ { name: 'West Central Africa', offset: 3600, identifier: 'Africa/Algiers' },
+ { name: 'Zagreb', offset: 7200, identifier: 'Europe/Zagreb' },
+ { name: 'Zurich', offset: 7200, identifier: 'Europe/Zurich' },
+ { name: 'Athens', offset: 10800, identifier: 'Europe/Athens' },
+ { name: 'Bucharest', offset: 10800, identifier: 'Europe/Bucharest' },
+ { name: 'Cairo', offset: 7200, identifier: 'Africa/Cairo' },
+ { name: 'Harare', offset: 7200, identifier: 'Africa/Harare' },
+ { name: 'Helsinki', offset: 10800, identifier: 'Europe/Helsinki' },
+ { name: 'Jerusalem', offset: 10800, identifier: 'Asia/Jerusalem' },
+ { name: 'Kaliningrad', offset: 7200, identifier: 'Europe/Kaliningrad' },
+ { name: 'Kyiv', offset: 10800, identifier: 'Europe/Kiev' },
+ { name: 'Pretoria', offset: 7200, identifier: 'Africa/Johannesburg' },
+ { name: 'Riga', offset: 10800, identifier: 'Europe/Riga' },
+ { name: 'Sofia', offset: 10800, identifier: 'Europe/Sofia' },
+ { name: 'Tallinn', offset: 10800, identifier: 'Europe/Tallinn' },
+ { name: 'Vilnius', offset: 10800, identifier: 'Europe/Vilnius' },
+ { name: 'Baghdad', offset: 10800, identifier: 'Asia/Baghdad' },
+ { name: 'Istanbul', offset: 10800, identifier: 'Europe/Istanbul' },
+ { name: 'Kuwait', offset: 10800, identifier: 'Asia/Kuwait' },
+ { name: 'Minsk', offset: 10800, identifier: 'Europe/Minsk' },
+ { name: 'Moscow', offset: 10800, identifier: 'Europe/Moscow' },
+ { name: 'Nairobi', offset: 10800, identifier: 'Africa/Nairobi' },
+ { name: 'Riyadh', offset: 10800, identifier: 'Asia/Riyadh' },
+ { name: 'St. Petersburg', offset: 10800, identifier: 'Europe/Moscow' },
+ { name: 'Tehran', offset: 16200, identifier: 'Asia/Tehran' },
+ { name: 'Abu Dhabi', offset: 14400, identifier: 'Asia/Muscat' },
+ { name: 'Baku', offset: 14400, identifier: 'Asia/Baku' },
+ { name: 'Muscat', offset: 14400, identifier: 'Asia/Muscat' },
+ { name: 'Samara', offset: 14400, identifier: 'Europe/Samara' },
+ { name: 'Tbilisi', offset: 14400, identifier: 'Asia/Tbilisi' },
+ { name: 'Volgograd', offset: 14400, identifier: 'Europe/Volgograd' },
+ { name: 'Yerevan', offset: 14400, identifier: 'Asia/Yerevan' },
+ { name: 'Kabul', offset: 16200, identifier: 'Asia/Kabul' },
+ { name: 'Ekaterinburg', offset: 18000, identifier: 'Asia/Yekaterinburg' },
+ { name: 'Islamabad', offset: 18000, identifier: 'Asia/Karachi' },
+ { name: 'Karachi', offset: 18000, identifier: 'Asia/Karachi' },
+ { name: 'Tashkent', offset: 18000, identifier: 'Asia/Tashkent' },
+ { name: 'Chennai', offset: 19800, identifier: 'Asia/Kolkata' },
+ { name: 'Kolkata', offset: 19800, identifier: 'Asia/Kolkata' },
+ { name: 'Mumbai', offset: 19800, identifier: 'Asia/Kolkata' },
+ { name: 'New Delhi', offset: 19800, identifier: 'Asia/Kolkata' },
+ { name: 'Sri Jayawardenepura', offset: 19800, identifier: 'Asia/Colombo' },
+ { name: 'Kathmandu', offset: 20700, identifier: 'Asia/Kathmandu' },
+ { name: 'Almaty', offset: 21600, identifier: 'Asia/Almaty' },
+ { name: 'Astana', offset: 21600, identifier: 'Asia/Dhaka' },
+ { name: 'Dhaka', offset: 21600, identifier: 'Asia/Dhaka' },
+ { name: 'Urumqi', offset: 21600, identifier: 'Asia/Urumqi' },
+ { name: 'Rangoon', offset: 23400, identifier: 'Asia/Rangoon' },
+ { name: 'Bangkok', offset: 25200, identifier: 'Asia/Bangkok' },
+ { name: 'Hanoi', offset: 25200, identifier: 'Asia/Bangkok' },
+ { name: 'Jakarta', offset: 25200, identifier: 'Asia/Jakarta' },
+ { name: 'Krasnoyarsk', offset: 25200, identifier: 'Asia/Krasnoyarsk' },
+ { name: 'Novosibirsk', offset: 25200, identifier: 'Asia/Novosibirsk' },
+ { name: 'Beijing', offset: 28800, identifier: 'Asia/Shanghai' },
+ { name: 'Chongqing', offset: 28800, identifier: 'Asia/Chongqing' },
+ { name: 'Hong Kong', offset: 28800, identifier: 'Asia/Hong_Kong' },
+ { name: 'Irkutsk', offset: 28800, identifier: 'Asia/Irkutsk' },
+ { name: 'Kuala Lumpur', offset: 28800, identifier: 'Asia/Kuala_Lumpur' },
+ { name: 'Perth', offset: 28800, identifier: 'Australia/Perth' },
+ { name: 'Singapore', offset: 28800, identifier: 'Asia/Singapore' },
+ { name: 'Taipei', offset: 28800, identifier: 'Asia/Taipei' },
+ { name: 'Ulaanbaatar', offset: 28800, identifier: 'Asia/Ulaanbaatar' },
+ { name: 'Osaka', offset: 32400, identifier: 'Asia/Tokyo' },
+ { name: 'Sapporo', offset: 32400, identifier: 'Asia/Tokyo' },
+ { name: 'Seoul', offset: 32400, identifier: 'Asia/Seoul' },
+ { name: 'Tokyo', offset: 32400, identifier: 'Asia/Tokyo' },
+ { name: 'Yakutsk', offset: 32400, identifier: 'Asia/Yakutsk' },
+ { name: 'Adelaide', offset: 34200, identifier: 'Australia/Adelaide' },
+ { name: 'Darwin', offset: 34200, identifier: 'Australia/Darwin' },
+ { name: 'Brisbane', offset: 36000, identifier: 'Australia/Brisbane' },
+ { name: 'Canberra', offset: 36000, identifier: 'Australia/Melbourne' },
+ { name: 'Guam', offset: 36000, identifier: 'Pacific/Guam' },
+ { name: 'Hobart', offset: 36000, identifier: 'Australia/Hobart' },
+ { name: 'Melbourne', offset: 36000, identifier: 'Australia/Melbourne' },
+ { name: 'Port Moresby', offset: 36000, identifier: 'Pacific/Port_Moresby' },
+ { name: 'Sydney', offset: 36000, identifier: 'Australia/Sydney' },
+ { name: 'Vladivostok', offset: 36000, identifier: 'Asia/Vladivostok' },
+ { name: 'Magadan', offset: 39600, identifier: 'Asia/Magadan' },
+ { name: 'New Caledonia', offset: 39600, identifier: 'Pacific/Noumea' },
+ { name: 'Solomon Is.', offset: 39600, identifier: 'Pacific/Guadalcanal' },
+ { name: 'Srednekolymsk', offset: 39600, identifier: 'Asia/Srednekolymsk' },
+ { name: 'Auckland', offset: 43200, identifier: 'Pacific/Auckland' },
+ { name: 'Fiji', offset: 43200, identifier: 'Pacific/Fiji' },
+ { name: 'Kamchatka', offset: 43200, identifier: 'Asia/Kamchatka' },
+ { name: 'Marshall Is.', offset: 43200, identifier: 'Pacific/Majuro' },
+ { name: 'Wellington', offset: 43200, identifier: 'Pacific/Auckland' },
+ { name: 'Chatham Is.', offset: 45900, identifier: 'Pacific/Chatham' },
+ { name: "Nuku'alofa", offset: 46800, identifier: 'Pacific/Tongatapu' },
+ { name: 'Samoa', offset: 46800, identifier: 'Pacific/Apia' },
+ { name: 'Tokelau Is.', offset: 46800, identifier: 'Pacific/Fakaofo' },
+];
diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js
new file mode 100644
index 00000000000..ad33dfee39f
--- /dev/null
+++ b/spec/frontend/deploy_freeze/store/actions_spec.js
@@ -0,0 +1,122 @@
+import Api from '~/api';
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import getInitialState from '~/deploy_freeze/store/state';
+import * as actions from '~/deploy_freeze/store/actions';
+import * as types from '~/deploy_freeze/store/mutation_types';
+import { mockTimezoneData, mockFreezePeriods } from '../mock_data';
+
+jest.mock('~/api.js');
+jest.mock('~/flash.js');
+
+describe('deploy freeze store actions', () => {
+ let mock;
+ let state;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state = getInitialState({
+ projectId: '8',
+ timezoneData: mockTimezoneData,
+ });
+ Api.freezePeriods.mockResolvedValue({ data: mockFreezePeriods });
+ Api.createFreezePeriod.mockResolvedValue();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('setSelectedTimezone', () => {
+ it('commits SET_SELECTED_TIMEZONE mutation', () => {
+ testAction(actions.setSelectedTimezone, {}, {}, [
+ {
+ payload: {},
+ type: types.SET_SELECTED_TIMEZONE,
+ },
+ ]);
+ });
+ });
+
+ describe('setFreezeStartCron', () => {
+ it('commits SET_FREEZE_START_CRON mutation', () => {
+ testAction(actions.setFreezeStartCron, {}, {}, [
+ {
+ type: types.SET_FREEZE_START_CRON,
+ },
+ ]);
+ });
+ });
+
+ describe('setFreezeEndCron', () => {
+ it('commits SET_FREEZE_END_CRON mutation', () => {
+ testAction(actions.setFreezeEndCron, {}, {}, [
+ {
+ type: types.SET_FREEZE_END_CRON,
+ },
+ ]);
+ });
+ });
+
+ describe('addFreezePeriod', () => {
+ it('dispatch correct actions on adding a freeze period', () => {
+ testAction(
+ actions.addFreezePeriod,
+ {},
+ state,
+ [{ type: 'RESET_MODAL' }],
+ [
+ { type: 'requestAddFreezePeriod' },
+ { type: 'receiveAddFreezePeriodSuccess' },
+ { type: 'fetchFreezePeriods' },
+ ],
+ );
+ });
+
+ it('should show flash error and set error in state on add failure', () => {
+ Api.createFreezePeriod.mockRejectedValue();
+
+ testAction(
+ actions.addFreezePeriod,
+ {},
+ state,
+ [],
+ [{ type: 'requestAddFreezePeriod' }, { type: 'receiveAddFreezePeriodError' }],
+ () => expect(createFlash).toHaveBeenCalled(),
+ );
+ });
+ });
+
+ describe('fetchFreezePeriods', () => {
+ it('dispatch correct actions on fetchFreezePeriods', () => {
+ testAction(
+ actions.fetchFreezePeriods,
+ {},
+ state,
+ [],
+ [
+ { type: 'requestFreezePeriods' },
+ { type: 'receiveFreezePeriodsSuccess', payload: mockFreezePeriods },
+ ],
+ );
+ });
+
+ it('should show flash error and set error in state on fetch variables failure', () => {
+ Api.freezePeriods.mockRejectedValue();
+
+ testAction(
+ actions.fetchFreezePeriods,
+ {},
+ state,
+ [],
+ [{ type: 'requestFreezePeriods' }],
+ () =>
+ expect(createFlash).toHaveBeenCalledWith(
+ 'There was an error fetching the deploy freezes.',
+ ),
+ );
+ });
+ });
+});
diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js
new file mode 100644
index 00000000000..85ed0a84156
--- /dev/null
+++ b/spec/frontend/deploy_freeze/store/mutations_spec.js
@@ -0,0 +1,62 @@
+import state from '~/deploy_freeze/store/state';
+import mutations from '~/deploy_freeze/store/mutations';
+import * as types from '~/deploy_freeze/store/mutation_types';
+import { mockFreezePeriods, mockTimezoneData } from '../mock_data';
+
+describe('CI variable list mutations', () => {
+ let stateCopy;
+ beforeEach(() => {
+ stateCopy = state({
+ projectId: '8',
+ timezoneData: mockTimezoneData,
+ });
+ });
+
+ describe('RESET_MODAL', () => {
+ it('should reset modal state', () => {
+ mutations[types.RESET_MODAL](stateCopy);
+
+ expect(stateCopy.freezeStartCron).toBe('');
+ expect(stateCopy.freezeEndCron).toBe('');
+ expect(stateCopy.selectedTimezone).toBe('');
+ expect(stateCopy.selectedTimezoneIdentifier).toBe('');
+ });
+ });
+
+ describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => {
+ it('should set environments', () => {
+ mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, mockFreezePeriods);
+
+ expect(stateCopy.freezePeriods).toEqual(mockFreezePeriods);
+ });
+ });
+
+ describe('SET_SELECTED_TIMEZONE', () => {
+ it('should set the cron timezone', () => {
+ const timezone = {
+ formattedTimezone: '[UTC -7] Pacific Time (US & Canada)',
+ identifier: 'America/Los_Angeles',
+ };
+ mutations[types.SET_SELECTED_TIMEZONE](stateCopy, timezone);
+
+ expect(stateCopy.selectedTimezone).toEqual(timezone.formattedTimezone);
+ expect(stateCopy.selectedTimezoneIdentifier).toEqual(timezone.identifier);
+ });
+ });
+
+ describe('SET_FREEZE_START_CRON', () => {
+ it('should set freezeStartCron', () => {
+ mutations[types.SET_FREEZE_START_CRON](stateCopy, '5 0 * 8 *');
+
+ expect(stateCopy.freezeStartCron).toBe('5 0 * 8 *');
+ });
+ });
+
+ describe('SET_FREEZE_ENDT_CRON', () => {
+ it('should set freezeEndCron', () => {
+ mutations[types.SET_FREEZE_END_CRON](stateCopy, '5 0 * 8 *');
+
+ expect(stateCopy.freezeEndCron).toBe('5 0 * 8 *');
+ });
+ });
+});
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index d2265dfd506..6149ecbf00c 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -218,7 +218,7 @@ describe('Snippet Edit app', () => {
return waitForPromises().then(() => {
expect(resolveMutate).toHaveBeenCalledWith(
- expect.objectContaining({ variables: { input: { files: [bar] } } }),
+ expect.objectContaining({ variables: { input: { blobActions: [bar] } } }),
);
});
});
diff --git a/spec/graphql/types/snippets/file_input_action_enum_spec.rb b/spec/graphql/types/snippets/blob_action_enum_spec.rb
index ff9b706240b..9c641bd5446 100644
--- a/spec/graphql/types/snippets/file_input_action_enum_spec.rb
+++ b/spec/graphql/types/snippets/blob_action_enum_spec.rb
@@ -2,8 +2,8 @@
require 'spec_helper'
-RSpec.describe Types::Snippets::FileInputActionEnum do
- specify { expect(described_class.graphql_name).to eq('SnippetFileInputActionEnum') }
+RSpec.describe Types::Snippets::BlobActionEnum do
+ specify { expect(described_class.graphql_name).to eq('SnippetBlobActionEnum') }
it 'exposes all file input action types' do
expect(described_class.values.keys).to eq(%w[create update delete move])
diff --git a/spec/graphql/types/snippets/file_input_type_spec.rb b/spec/graphql/types/snippets/blob_action_input_type_spec.rb
index c7d4909b542..5d6bd81fb77 100644
--- a/spec/graphql/types/snippets/file_input_type_spec.rb
+++ b/spec/graphql/types/snippets/blob_action_input_type_spec.rb
@@ -2,14 +2,14 @@
require 'spec_helper'
-RSpec.describe Types::Snippets::FileInputType do
- specify { expect(described_class.graphql_name).to eq('SnippetFileInputType') }
+RSpec.describe Types::Snippets::BlobActionInputType do
+ specify { expect(described_class.graphql_name).to eq('SnippetBlobActionInputType') }
it 'has the correct arguments' do
expect(described_class.arguments.keys).to match_array(%w[filePath action previousPath content])
end
- it 'sets the type of action argument to FileInputActionEnum' do
- expect(described_class.arguments['action'].type.of_type).to eq(Types::Snippets::FileInputActionEnum)
+ it 'sets the type of action argument to BlobActionEnum' do
+ expect(described_class.arguments['action'].type.of_type).to eq(Types::Snippets::BlobActionEnum)
end
end
diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb
index c9ca806e2c4..6ab00f96092 100644
--- a/spec/requests/api/ci/pipelines_spec.rb
+++ b/spec/requests/api/ci/pipelines_spec.rb
@@ -438,7 +438,7 @@ RSpec.describe API::Ci::Pipelines do
expect(response).to match_response_schema('public_api/v4/pipeline/detail')
end
- it 'returns project pipelines' do
+ it 'returns project pipeline' do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}", user)
expect(response).to have_gitlab_http_status(:ok)
@@ -475,6 +475,20 @@ RSpec.describe API::Ci::Pipelines do
expect(json_response['id']).to be nil
end
end
+
+ context 'when config source is not ci' do
+ let(:non_ci_config_source) { ::Ci::PipelineEnums.non_ci_config_source_values.first }
+ let(:pipeline_not_ci) do
+ create(:ci_pipeline, config_source: non_ci_config_source, project: project)
+ end
+
+ it 'returns the specified pipeline' do
+ get api("/projects/#{project.id}/pipelines/#{pipeline_not_ci.id}", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['sha']).to eq(pipeline_not_ci.sha)
+ end
+ end
end
describe 'GET /projects/:id/pipelines/latest' do
diff --git a/spec/requests/api/graphql/mutations/snippets/create_spec.rb b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
index e2474e1bcce..56a5f4907c1 100644
--- a/spec/requests/api/graphql/mutations/snippets/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/create_spec.rb
@@ -149,7 +149,7 @@ RSpec.describe 'Creating a Snippet' do
visibility_level: visibility_level,
project_path: project_path,
title: title,
- files: actions
+ blob_actions: actions
}
end
diff --git a/spec/requests/api/graphql/mutations/snippets/update_spec.rb b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
index 3b2f9dc0f19..9101acdafbb 100644
--- a/spec/requests/api/graphql/mutations/snippets/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/update_spec.rb
@@ -157,7 +157,7 @@ RSpec.describe 'Updating a Snippet' do
let(:mutation_vars) do
{
id: snippet_gid,
- files: [
+ blob_actions: [
{ action: :update, filePath: updated_file, content: updated_content },
{ action: :delete, filePath: deleted_file }
]
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index 53c57931d36..77d5a4f26a8 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -239,6 +239,18 @@ RSpec.describe API::Jobs do
end
end
+ context 'when config source not ci' do
+ let(:non_ci_config_source) { ::Ci::PipelineEnums.non_ci_config_source_values.first }
+ let(:pipeline) do
+ create(:ci_pipeline, config_source: non_ci_config_source, project: project)
+ end
+
+ it 'returns the specified pipeline' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response[0]['pipeline']['sha']).to eq(pipeline.sha.to_s)
+ end
+ end
+
it 'avoids N+1 queries' do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index 2fe7a46de4b..0ab1e816379 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -2054,16 +2054,54 @@ RSpec.describe NotificationService, :mailer do
end
describe '#project_was_moved' do
- it 'notifies the expected users' do
- notification.project_was_moved(project, "gitlab/gitlab")
+ context 'with users at both project and group level' do
+ let(:maintainer) { create(:user) }
+ let(:developer) { create(:user) }
+ let(:group_owner) { create(:user) }
+ let(:group_maintainer) { create(:user) }
+ let(:group_developer) { create(:user) }
+ let(:blocked_user) { create(:user, :blocked) }
+ let(:invited_user) { create(:user) }
- should_email(@u_watcher)
- should_email(@u_participating)
- should_email(@u_lazy_participant)
- should_email(@u_custom_global)
- should_not_email(@u_guest_watcher)
- should_not_email(@u_guest_custom)
- should_not_email(@u_disabled)
+ let!(:group) do
+ create(:group, :public) do |group|
+ project.group = group
+ project.save!
+
+ group.add_owner(group_owner)
+ group.add_maintainer(group_maintainer)
+ group.add_developer(group_developer)
+ # This is to check for dupes
+ group.add_maintainer(maintainer)
+ group.add_maintainer(blocked_user)
+ end
+ end
+
+ before do
+ project.add_maintainer(maintainer)
+ project.add_developer(developer)
+ project.add_maintainer(blocked_user)
+ reset_delivered_emails!
+ end
+
+ it 'notifies the expected users' do
+ notification.project_was_moved(project, "gitlab/gitlab")
+
+ should_email(@u_watcher)
+ should_email(@u_participating)
+ should_email(@u_lazy_participant)
+ should_email(@u_custom_global)
+ should_not_email(@u_guest_watcher)
+ should_not_email(@u_guest_custom)
+ should_not_email(@u_disabled)
+
+ should_email(maintainer)
+ should_email(group_owner)
+ should_email(group_maintainer)
+ should_not_email(group_developer)
+ should_not_email(developer)
+ should_not_email(blocked_user)
+ end
end
it_behaves_like 'project emails are disabled' do
diff --git a/spec/support/shared_examples/controllers/concerns/graceful_timeout_handling_shared_examples.rb b/spec/support/shared_examples/controllers/concerns/graceful_timeout_handling_shared_examples.rb
new file mode 100644
index 00000000000..ea002776eeb
--- /dev/null
+++ b/spec/support/shared_examples/controllers/concerns/graceful_timeout_handling_shared_examples.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples GracefulTimeoutHandling do
+ it 'includes GracefulTimeoutHandling' do
+ expect(controller).to be_a(GracefulTimeoutHandling)
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index d4be37c3349..fbc14f2e392 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3314,6 +3314,11 @@ create-hmac@^1.1.0, create-hmac@^1.1.2, create-hmac@^1.1.4:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
+cron-validator@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/cron-validator/-/cron-validator-1.1.1.tgz#0a27bb75508c7bc03c8b840d2d9f170eeacb5615"
+ integrity sha512-vfZb05w/wezuwPZBDvdIBmJp2BvuJExHeyKRa5oBqD2ZDXR61hb3QgPc/3ZhBEQJlAy8Jlnn5XC/JCT3IDqxwg==
+
cropper@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/cropper/-/cropper-2.3.0.tgz#607461d4e7aa7a7fe15a26834b14b7f0c2801562"