diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-30 15:09:53 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-09-30 15:09:53 +0300 |
commit | 6aa5c04c74d2d70ee7d19ef3a155b2def9dd46de (patch) | |
tree | 81f7b81234bc5b889c57e71f87b94878ab286383 | |
parent | 418c3b29009dcc0a2c6b4872557d0274ba0b8077 (diff) |
Add latest changes from gitlab-org/gitlab@master
133 files changed, 3079 insertions, 708 deletions
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 3a0390629a0..46f0bcffbf6 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -168,7 +168,6 @@ Performance/Detect: Exclude: - 'ee/spec/controllers/projects/dependencies_controller_spec.rb' - 'ee/spec/controllers/projects/feature_flags_controller_spec.rb' - - 'ee/spec/requests/api/unleash_spec.rb' - 'spec/lib/gitlab/git/tree_spec.rb' - 'spec/lib/gitlab/import_export/project/tree_restorer_spec.rb' - 'spec/models/event_spec.rb' diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index 68fca706ce5..252706b3647 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { Rails } from '~/lib/utils/rails_ujs'; import { disableButtonIfEmptyField } from '~/lib/utils/common_utils'; import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; +import { __, sprintf } from '~/locale'; export default class Members { constructor() { @@ -64,7 +65,28 @@ export default class Members { } formSuccess(e) { - const { $toggle, $dateInput } = this.getMemberListItems($(e.currentTarget).closest('.member')); + const { $toggle, $dateInput, $expiresIn, $expiresInText } = this.getMemberListItems( + $(e.currentTarget).closest('.js-member'), + ); + + const [data] = e.detail; + const expiresIn = data?.expires_in; + + if (expiresIn) { + $expiresIn.removeClass('gl-display-none'); + + $expiresInText.text(sprintf(__('Expires in %{expires_at}'), { expires_at: expiresIn })); + + const { expires_soon: expiresSoon } = data; + + if (expiresSoon) { + $expiresInText.addClass('text-warning'); + } else { + $expiresInText.removeClass('text-warning'); + } + } else { + $expiresIn.addClass('gl-display-none'); + } $toggle.enable(); $dateInput.enable(); @@ -72,10 +94,12 @@ export default class Members { // eslint-disable-next-line class-methods-use-this getMemberListItems($el) { - const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`); + const $memberListItem = $el.is('.js-member') ? $el : $(`#${$el.data('elId')}`); return { $memberListItem, + $expiresIn: $memberListItem.find('.js-expires-in'), + $expiresInText: $memberListItem.find('.js-expires-in-text'), $toggle: $memberListItem.find('.dropdown-menu-toggle'), $dateInput: $memberListItem.find('.js-access-expiration-date'), }; diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue index 2ee7bbef4c6..fcb86fd18f0 100644 --- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -1,7 +1,7 @@ <script> -import { mapActions, mapGetters, mapState } from 'vuex'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; - +import { isEqual } from 'lodash'; +import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants'; import SettingsForm from './settings_form.vue'; @@ -19,21 +19,39 @@ export default { GlSprintf, GlLink, }, + inject: ['projectPath', 'isAdmin', 'adminSettingsPath', 'enableHistoricEntries'], i18n: { UNAVAILABLE_FEATURE_TITLE, UNAVAILABLE_FEATURE_INTRO_TEXT, FETCH_SETTINGS_ERROR_MESSAGE, }, + apollo: { + containerExpirationPolicy: { + query: expirationPolicyQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: data => data.project?.containerExpirationPolicy, + result({ data }) { + this.workingCopy = { ...data.project?.containerExpirationPolicy }; + }, + error(e) { + this.fetchSettingsError = e; + }, + }, + }, data() { return { fetchSettingsError: false, + containerExpirationPolicy: null, + workingCopy: {}, }; }, computed: { - ...mapState(['isAdmin', 'adminSettingsPath']), - ...mapGetters({ isDisabled: 'getIsDisabled' }), - showSettingForm() { - return !this.isDisabled && !this.fetchSettingsError; + isDisabled() { + return !(this.containerExpirationPolicy || this.enableHistoricEntries); }, showDisabledFormMessage() { return this.isDisabled && !this.fetchSettingsError; @@ -41,21 +59,27 @@ export default { unavailableFeatureMessage() { return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT; }, - }, - mounted() { - this.fetchSettings().catch(() => { - this.fetchSettingsError = true; - }); + isEdited() { + return !isEqual(this.containerExpirationPolicy, this.workingCopy); + }, }, methods: { - ...mapActions(['fetchSettings']), + restoreOriginal() { + this.workingCopy = { ...this.containerExpirationPolicy }; + }, }, }; </script> <template> <div> - <settings-form v-if="showSettingForm" /> + <settings-form + v-if="containerExpirationPolicy" + v-model="workingCopy" + :is-loading="$apollo.queries.containerExpirationPolicy.loading" + :is-edited="isEdited" + @reset="restoreOriginal" + /> <template v-else> <gl-alert v-if="showDisabledFormMessage" diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index 25c88daa54d..7deb1f92686 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -1,28 +1,45 @@ <script> -import { get } from 'lodash'; -import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlCard, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlCard, GlButton } from '@gitlab/ui'; import Tracking from '~/tracking'; -import { mapComputed } from '~/vuex_shared/bindings'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '../../shared/constants'; import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue'; import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants'; +import { formOptionsGenerator } from '~/registry/shared/utils'; +import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql'; +import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update'; export default { components: { GlCard, GlButton, - GlLoadingIcon, ExpirationPolicyFields, }, mixins: [Tracking.mixin()], + inject: ['projectPath'], + props: { + value: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + isEdited: { + type: Boolean, + required: false, + default: false, + }, + }, labelsConfig: { cols: 3, align: 'right', }, + formOptions: formOptionsGenerator(), i18n: { CLEANUP_POLICY_CARD_HEADER, SET_CLEANUP_POLICY_BUTTON, @@ -34,49 +51,74 @@ export default { }, fieldsAreValid: true, apiErrors: null, + mutationLoading: false, }; }, computed: { - ...mapState(['formOptions', 'isLoading']), - ...mapGetters({ isEdited: 'getIsEdited' }), - ...mapComputed([{ key: 'settings', getter: 'getSettings' }], 'updateSettings'), + showLoadingIcon() { + return this.isLoading || this.mutationLoading; + }, isSubmitButtonDisabled() { - return !this.fieldsAreValid || this.isLoading; + return !this.fieldsAreValid || this.showLoadingIcon; }, isCancelButtonDisabled() { - return !this.isEdited || this.isLoading; + return !this.isEdited || this.isLoading || this.mutationLoading; + }, + mutationVariables() { + return { + projectPath: this.projectPath, + enabled: this.value.enabled, + cadence: this.value.cadence, + olderThan: this.value.olderThan, + keepN: this.value.keepN, + nameRegex: this.value.nameRegex, + nameRegexKeep: this.value.nameRegexKeep, + }; }, }, methods: { - ...mapActions(['resetSettings', 'saveSettings']), reset() { this.track('reset_form'); this.apiErrors = null; - this.resetSettings(); + this.$emit('reset'); }, setApiErrors(response) { - const messages = get(response, 'data.message', []); - - this.apiErrors = Object.keys(messages).reduce((acc, curr) => { - if (curr.startsWith('container_expiration_policy.')) { - const key = curr.replace('container_expiration_policy.', ''); - acc[key] = get(messages, [curr, 0], ''); - } + this.apiErrors = response.graphQLErrors.reduce((acc, curr) => { + curr.extensions.problems.forEach(item => { + acc[item.path[0]] = item.message; + }); return acc; }, {}); }, submit() { this.track('submit_form'); this.apiErrors = null; - this.saveSettings() - .then(() => this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' })) - .catch(({ response }) => { - this.setApiErrors(response); + this.mutationLoading = true; + return this.$apollo + .mutate({ + mutation: updateContainerExpirationPolicyMutation, + variables: { + input: this.mutationVariables, + }, + update: updateContainerExpirationPolicy(this.projectPath), + }) + .then(({ data }) => { + const errorMessage = data?.updateContainerExpirationPolicy?.errors[0]; + if (errorMessage) { + this.$toast.show(errorMessage, { type: 'error' }); + } + this.$toast.show(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success' }); + }) + .catch(error => { + this.setApiErrors(error); this.$toast.show(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error' }); + }) + .finally(() => { + this.mutationLoading = false; }); }, onModelChange(changePayload) { - this.settings = changePayload.newValue; + this.$emit('input', changePayload.newValue); if (this.apiErrors) { this.apiErrors[changePayload.modified] = undefined; } @@ -93,8 +135,8 @@ export default { </template> <template #default> <expiration-policy-fields - :value="settings" - :form-options="formOptions" + :value="value" + :form-options="$options.formOptions" :is-loading="isLoading" :api-errors="apiErrors" @validated="fieldsAreValid = true" @@ -115,12 +157,12 @@ export default { ref="save-button" type="submit" :disabled="isSubmitButtonDisabled" + :loading="showLoadingIcon" variant="success" category="primary" class="js-no-auto-disable" > {{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }} - <gl-loading-icon v-if="isLoading" class="gl-ml-3" /> </gl-button> </template> </gl-card> diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue index 1ff2f6f99e5..2b8e9f6ff64 100644 --- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue +++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue @@ -68,34 +68,31 @@ export default { { name: 'expiration-policy-interval', label: EXPIRATION_INTERVAL_LABEL, - model: 'older_than', - optionKey: 'olderThan', + model: 'olderThan', }, { name: 'expiration-policy-schedule', label: EXPIRATION_SCHEDULE_LABEL, model: 'cadence', - optionKey: 'cadence', }, { name: 'expiration-policy-latest', label: KEEP_N_LABEL, - model: 'keep_n', - optionKey: 'keepN', + model: 'keepN', }, ], textAreaList: [ { name: 'expiration-policy-name-matching', label: NAME_REGEX_LABEL, - model: 'name_regex', + model: 'nameRegex', placeholder: NAME_REGEX_PLACEHOLDER, description: NAME_REGEX_DESCRIPTION, }, { name: 'expiration-policy-keep-name', label: NAME_REGEX_KEEP_LABEL, - model: 'name_regex_keep', + model: 'nameRegexKeep', placeholder: NAME_REGEX_KEEP_PLACEHOLDER, description: NAME_REGEX_KEEP_DESCRIPTION, }, @@ -107,17 +104,16 @@ export default { }, computed: { ...mapComputedToEvent( - ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'], + ['enabled', 'cadence', 'olderThan', 'keepN', 'nameRegex', 'nameRegexKeep'], 'value', ), policyEnabledText() { return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; }, textAreaValidation() { - const nameRegexErrors = - this.apiErrors?.name_regex || this.validateRegexLength(this.name_regex); + const nameRegexErrors = this.apiErrors?.nameRegex || this.validateRegexLength(this.nameRegex); const nameKeepRegexErrors = - this.apiErrors?.name_regex_keep || this.validateRegexLength(this.name_regex_keep); + this.apiErrors?.nameRegexKeep || this.validateRegexLength(this.nameRegexKeep); return { /* @@ -127,11 +123,11 @@ export default { * false: red border, error message * So in this function we keep null if the are no message otherwise we 'invert' the error message */ - name_regex: { + nameRegex: { state: nameRegexErrors === null ? null : !nameRegexErrors, message: nameRegexErrors, }, - name_regex_keep: { + nameRegexKeep: { state: nameKeepRegexErrors === null ? null : !nameKeepRegexErrors, message: nameKeepRegexErrors, }, @@ -139,8 +135,8 @@ export default { }, fieldsValidity() { return ( - this.textAreaValidation.name_regex.state !== false && - this.textAreaValidation.name_regex_keep.state !== false + this.textAreaValidation.nameRegex.state !== false && + this.textAreaValidation.nameRegexKeep.state !== false ); }, isFormElementDisabled() { @@ -216,11 +212,7 @@ export default { :disabled="isFormElementDisabled" @input="updateModel($event, select.model)" > - <option - v-for="option in formOptions[select.optionKey]" - :key="option.key" - :value="option.key" - > + <option v-for="option in formOptions[select.model]" :key="option.key" :value="option.key"> {{ option.label }} </option> </gl-form-select> diff --git a/app/assets/javascripts/registry/shared/utils.js b/app/assets/javascripts/registry/shared/utils.js index f84325cd438..bdf1ab9507d 100644 --- a/app/assets/javascripts/registry/shared/utils.js +++ b/app/assets/javascripts/registry/shared/utils.js @@ -21,20 +21,26 @@ export const mapComputedToEvent = (list, root) => { return result; }; -export const optionLabelGenerator = (collection, singularSentence, pluralSentence) => +export const olderThanTranslationGenerator = variable => + n__( + '%d day until tags are automatically removed', + '%d days until tags are automatically removed', + variable, + ); + +export const keepNTranslationGenerator = variable => + n__('%d tag per image name', '%d tags per image name', variable); + +export const optionLabelGenerator = (collection, translationFn) => collection.map(option => ({ ...option, - label: n__(singularSentence, pluralSentence, option.variable), + label: translationFn(option.variable), })); export const formOptionsGenerator = () => { return { - olderThan: optionLabelGenerator( - OLDER_THAN_OPTIONS, - '%d days until tags are automatically removed', - '%d day until tags are automatically removed', - ), + olderThan: optionLabelGenerator(OLDER_THAN_OPTIONS, olderThanTranslationGenerator), cadence: CADENCE_OPTIONS, - keepN: optionLabelGenerator(KEEP_N_OPTIONS, '%d tag per image name', '%d tags per image name'), + keepN: optionLabelGenerator(KEEP_N_OPTIONS, keepNTranslationGenerator), }; }; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index f3442e437b4..cae886bf846 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -1,11 +1,3 @@ -/* - * This is a manifest file that'll automatically include all the stylesheets available in this directory - * and any sub-directories. You're free to add application-wide styles to this file and they'll appear at - * the top of the compiled file, but it's generally better to create a new file per style scope. - *= require_self - *= require cropper.css -*/ - // Welcome to GitLab css! // If you need to add or modify UI component that is common for many pages // like a table or typography then make changes in the framework/ directory. @@ -14,6 +6,7 @@ @import '@gitlab/at.js/dist/css/jquery.atwho'; @import 'dropzone/dist/basic'; @import 'select2'; +@import 'cropper'; // GitLab UI framework @import 'framework'; diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss index dfc56654229..415ff01bc33 100644 --- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss +++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss @@ -57,4 +57,8 @@ height: $default-icon-size; } } + + .decline-page { + width: 350px; + } } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index a88ebb34804..9ae54aa9cdf 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -386,271 +386,272 @@ } } } -} - -.pipeline-tab-content { - display: flex; - width: 100%; - min-height: $dropdown-max-height-lg; - background-color: $gray-light; - padding: $gl-padding 0; - overflow: auto; -} - -// Pipeline graph -.pipeline-graph { - white-space: nowrap; - transition: max-height 0.3s, padding 0.3s; - - .stage-column-list, - .builds-container > ul { - padding: 0; - } - a { - text-decoration: none; - color: $gl-text-color; - } - - svg { - vertical-align: middle; + .pipeline-tab-content { + display: flex; + width: 100%; + min-height: $dropdown-max-height-lg; + background-color: $gray-light; + padding: $gl-padding 0; + overflow: auto; } - .stage-column { - display: inline-block; - vertical-align: top; - - &.left-margin { - &:not(:first-child) { - margin-left: 44px; + // Pipeline graph, used at + // app/assets/javascripts/pipelines/components/graph/graph_component.vue + .pipeline-graph { + white-space: nowrap; + transition: max-height 0.3s, padding 0.3s; - .left-connector { - @include flat-connector-before; - } - } + .stage-column-list, + .builds-container > ul { + padding: 0; } - &.no-margin { - margin: 0; + a { + text-decoration: none; + color: $gl-text-color; } - li { - list-style: none; + svg { + vertical-align: middle; } - // when downstream pipelines are present, the last stage isn't the last column - &:last-child:not(.has-downstream) { - .build { - // Remove right connecting horizontal line from first build in last stage - &:first-child::after { - border: 0; - } - // Remove right curved connectors from all builds in last stage - &:not(:first-child)::after { - border: 0; - } - // Remove opposite curve - .curve::before { - display: none; - } - } - } + .stage-column { + display: inline-block; + vertical-align: top; - // when upstream pipelines are present, the first stage isn't the first column - &:first-child:not(.has-upstream) { - .build { - // Remove left curved connectors from all builds in first stage - &:not(:first-child)::before { - border: 0; - } - // Remove opposite curve - .curve::after { - display: none; + &.left-margin { + &:not(:first-child) { + margin-left: 44px; + + .left-connector { + @include flat-connector-before; + } } } - } - - // Curve first child connecting lines in opposite direction - .curve { - display: none; - &::before, - &::after { - content: ''; - width: 21px; - height: 25px; - position: absolute; - top: -31px; - border-top: 2px solid $border-color; + &.no-margin { + margin: 0; } - &::after { - left: -44px; - border-right: 2px solid $border-color; - border-radius: 0 20px; + li { + list-style: none; } - &::before { - right: -44px; - border-left: 2px solid $border-color; - border-radius: 20px 0 0; + // when downstream pipelines are present, the last stage isn't the last column + &:last-child:not(.has-downstream) { + .build { + // Remove right connecting horizontal line from first build in last stage + &:first-child::after { + border: 0; + } + // Remove right curved connectors from all builds in last stage + &:not(:first-child)::after { + border: 0; + } + // Remove opposite curve + .curve::before { + display: none; + } + } } - } - } - - .stage-name { - margin: 0 0 15px 10px; - font-weight: $gl-font-weight-bold; - width: 176px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - line-height: 2.2em; - } - .build { - position: relative; - width: 186px; - margin-bottom: 10px; - white-space: normal; - - .ci-job-dropdown-container { - // override dropdown.scss - .dropdown-menu li button { - padding: 0; - text-align: center; + // when upstream pipelines are present, the first stage isn't the first column + &:first-child:not(.has-upstream) { + .build { + // Remove left curved connectors from all builds in first stage + &:not(:first-child)::before { + border: 0; + } + // Remove opposite curve + .curve::after { + display: none; + } + } } - } - // ensure .build-content has hover style when action-icon is hovered - .ci-job-dropdown-container:hover .build-content { - @extend .build-content:hover; - } + // Curve first child connecting lines in opposite direction + .curve { + display: none; - .ci-status-icon svg { - height: 24px; - width: 24px; - } + &::before, + &::after { + content: ''; + width: 21px; + height: 25px; + position: absolute; + top: -31px; + border-top: 2px solid $border-color; + } - .dropdown-menu-toggle { - background-color: transparent; - border: 0; - padding: 0; + &::after { + left: -44px; + border-right: 2px solid $border-color; + border-radius: 0 20px; + } - &:focus { - outline: none; + &::before { + right: -44px; + border-left: 2px solid $border-color; + border-radius: 20px 0 0; + } } } - .build-content { - @include build-content(); + .stage-name { + margin: 0 0 15px 10px; + font-weight: $gl-font-weight-bold; + width: 176px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 2.2em; } - a.build-content:hover, - button.build-content:hover { - background-color: $gray-darker; - border: 1px solid $dropdown-toggle-active-border-color; - } + .build { + position: relative; + width: 186px; + margin-bottom: 10px; + white-space: normal; - // Connect first build in each stage with right horizontal line - &:first-child { - &::after { - content: ''; - position: absolute; - top: 48%; - right: -48px; - border-top: 2px solid $border-color; - width: 48px; - height: 1px; + .ci-job-dropdown-container { + // override dropdown.scss + .dropdown-menu li button { + padding: 0; + text-align: center; + } } - } - // Connect each build (except for first) with curved lines - &:not(:first-child) { - &::after, - &::before { - content: ''; - top: -49px; - position: absolute; - border-bottom: 2px solid $border-color; - width: 25px; - height: 69px; + // ensure .build-content has hover style when action-icon is hovered + .ci-job-dropdown-container:hover .build-content { + @extend .build-content:hover; } - // Right connecting curves - &::after { - right: -25px; - border-right: 2px solid $border-color; - border-radius: 0 0 20px; + .ci-status-icon svg { + height: 24px; + width: 24px; } - // Left connecting curves - &::before { - left: -25px; - border-left: 2px solid $border-color; - border-radius: 0 0 0 20px; + .dropdown-menu-toggle { + background-color: transparent; + border: 0; + padding: 0; + + &:focus { + outline: none; + } } - } - // Connect second build to first build with smaller curved line - &:nth-child(2) { - &::after, - &::before { - height: 29px; - top: -9px; + .build-content { + @include build-content(); } - .curve { - display: block; + a.build-content:hover, + button.build-content:hover { + background-color: $gray-darker; + border: 1px solid $dropdown-toggle-active-border-color; } - } - } - .ci-action-icon-container { - position: absolute; - right: 5px; - top: 50%; - transform: translateY(-50%); + // Connect first build in each stage with right horizontal line + &:first-child { + &::after { + content: ''; + position: absolute; + top: 48%; + right: -48px; + border-top: 2px solid $border-color; + width: 48px; + height: 1px; + } + } - // Action Icons in big pipeline-graph nodes - &.ci-action-icon-wrapper { - height: 30px; - width: 30px; - border-radius: 100%; - display: block; - padding: 0; - line-height: 0; + // Connect each build (except for first) with curved lines + &:not(:first-child) { + &::after, + &::before { + content: ''; + top: -49px; + position: absolute; + border-bottom: 2px solid $border-color; + width: 25px; + height: 69px; + } - svg { - fill: $gl-text-color-secondary; + // Right connecting curves + &::after { + right: -25px; + border-right: 2px solid $border-color; + border-radius: 0 0 20px; + } + + // Left connecting curves + &::before { + left: -25px; + border-left: 2px solid $border-color; + border-radius: 0 0 0 20px; + } } - .spinner { - top: 2px; + // Connect second build to first build with smaller curved line + &:nth-child(2) { + &::after, + &::before { + height: 29px; + top: -9px; + } + + .curve { + display: block; + } } + } + + .ci-action-icon-container { + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + + // Action Icons in big pipeline-graph nodes + &.ci-action-icon-wrapper { + height: 30px; + width: 30px; + border-radius: 100%; + display: block; + padding: 0; + line-height: 0; - &.play { svg { - left: 1px; - top: 1px; + fill: $gl-text-color-secondary; + } + + .spinner { + top: 2px; + } + + &.play { + svg { + left: 1px; + top: 1px; + } } } } - } - .stage-action svg { - left: 1px; - top: -2px; + .stage-action svg { + left: 1px; + top: -2px; + } } -} -// Triggers the dropdown in the big pipeline graph -.dropdown-counter-badge { - font-weight: 100; - font-size: 15px; - position: absolute; - right: 13px; - top: 8px; + // Triggers the dropdown in the big pipeline graph + .dropdown-counter-badge { + font-weight: 100; + font-size: 15px; + position: absolute; + right: 13px; + top: 8px; + } } .ci-build-text, @@ -777,6 +778,7 @@ button.mini-pipeline-graph-dropdown-toggle { .ci-action-icon-container { position: absolute; right: 8px; + top: 8px; &.ci-action-icon-wrapper { height: $ci-action-dropdown-button-size; @@ -798,6 +800,8 @@ button.mini-pipeline-graph-dropdown-toggle { width: $ci-action-dropdown-svg-size; height: $ci-action-dropdown-svg-size; fill: $gl-text-color-secondary; + position: relative; + top: 1px; vertical-align: initial; } } diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb index c395b93f4e7..06c793b5c4c 100644 --- a/app/controllers/groups/group_links_controller.rb +++ b/app/controllers/groups/group_links_controller.rb @@ -24,6 +24,15 @@ class Groups::GroupLinksController < Groups::ApplicationController def update Groups::GroupLinks::UpdateService.new(@group_link).execute(group_link_params) + + if @group_link.expires? + render json: { + expires_in: helpers.distance_of_time_in_words_to_now(@group_link.expires_at), + expires_soon: @group_link.expires_soon? + } + else + render json: {} + end end def destroy diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index 14651e0794a..87a62a8f9b0 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -2,6 +2,8 @@ module Groups module Registry class RepositoriesController < Groups::ApplicationController + include PackagesHelper + before_action :verify_container_registry_enabled! before_action :authorize_read_container_image! @@ -13,7 +15,7 @@ module Groups .execute .with_api_entity_associations - track_event(:list_repositories) + track_package_event(:list_repositories, :container) serializer = ContainerRepositoriesSerializer .new(current_user: current_user) diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index af5de684f32..591ded7630c 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -30,6 +30,8 @@ class InvitesController < ApplicationController def decline if member.decline_invite! + return render layout: 'devise_experimental_onboarding_issues' if !current_user && member.invite_to_unknown_user? && member.created_by + path = if current_user dashboard_projects_path diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index a30c455a7e4..f8ea6f834a3 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -21,8 +21,17 @@ class Projects::GroupLinksController < Projects::ApplicationController end def update - @group_link = @project.project_group_links.find(params[:id]) - Projects::GroupLinks::UpdateService.new(@group_link).execute(group_link_params) + group_link = @project.project_group_links.find(params[:id]) + Projects::GroupLinks::UpdateService.new(group_link).execute(group_link_params) + + if group_link.expires? + render json: { + expires_in: helpers.distance_of_time_in_words_to_now(group_link.expires_at), + expires_soon: group_link.expires_soon? + } + else + render json: {} + end end def destroy diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 8aacfdce094..28aef6f4328 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -64,7 +64,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic render: ->(partial, locals) { view_to_html_string(partial, locals) } } - options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project) ? "inline" : diff_view) + options = additional_attributes.merge(diff_view: Feature.enabled?(:unified_diff_lines, @merge_request.project, default_enabled: true) ? "inline" : diff_view) if @merge_request.project.context_commits_enabled? options[:context_commits] = @merge_request.recent_context_commits diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index c5fa92ebf32..48272a7c22b 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -37,7 +37,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true) push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true) push_frontend_feature_flag(:merge_request_widget_graphql, @project) - push_frontend_feature_flag(:unified_diff_lines, @project) + push_frontend_feature_flag(:unified_diff_lines, @project, default_enabled: true) push_frontend_feature_flag(:highlight_current_diff_row, @project) push_frontend_feature_flag(:default_merge_ref_for_diffs, @project) end diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 19d0cb9acdc..28a86ecc9f0 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -3,6 +3,8 @@ module Projects module Registry class RepositoriesController < ::Projects::Registry::ApplicationController + include PackagesHelper + before_action :authorize_update_container_image!, only: [:destroy] before_action :ensure_root_container_repository!, only: [:index] @@ -13,7 +15,7 @@ module Projects @images = ContainerRepositoriesFinder.new(user: current_user, subject: project, params: params.slice(:name)) .execute - track_event(:list_repositories) + track_package_event(:list_repositories, :container) serializer = ContainerRepositoriesSerializer .new(project: project, current_user: current_user) @@ -31,7 +33,7 @@ module Projects def destroy image.delete_scheduled! DeleteContainerRepositoryWorker.perform_async(current_user.id, image.id) # rubocop:disable CodeReuse/Worker - track_event(:delete_repository) + track_package_event(:delete_repository, :container) respond_to do |format| format.json { head :no_content } diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb index c42e3f6bdba..ebdb668207f 100644 --- a/app/controllers/projects/registry/tags_controller.rb +++ b/app/controllers/projects/registry/tags_controller.rb @@ -3,12 +3,15 @@ module Projects module Registry class TagsController < ::Projects::Registry::ApplicationController + include PackagesHelper + before_action :authorize_destroy_container_image!, only: [:destroy] LIMIT = 15 def index - track_event(:list_tags) + track_package_event(:list_tags, :tag) + respond_to do |format| format.json do render json: ContainerTagsSerializer @@ -23,7 +26,7 @@ module Projects result = Projects::ContainerRepository::DeleteTagsService .new(image.project, current_user, tags: [params[:id]]) .execute(image) - track_event(:delete_tag) + track_package_event(:delete_tag, :tag) respond_to do |format| format.json { head(result[:status] == :success ? :ok : bad_request) } @@ -40,7 +43,7 @@ module Projects result = Projects::ContainerRepository::DeleteTagsService .new(image.project, current_user, tags: tag_names) .execute(image) - track_event(:delete_tag_bulk) + track_package_event(:delete_tag_bulk, :tag) respond_to do |format| format.json { head(result[:status] == :success ? :no_content : :bad_request) } diff --git a/app/graphql/resolvers/terraform/states_resolver.rb b/app/graphql/resolvers/terraform/states_resolver.rb new file mode 100644 index 00000000000..38b26a948b1 --- /dev/null +++ b/app/graphql/resolvers/terraform/states_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + module Terraform + class StatesResolver < BaseResolver + type Types::Terraform::StateType, null: true + + alias_method :project, :object + + def resolve(**args) + return ::Terraform::State.none unless can_read_terraform_states? + + project.terraform_states.ordered_by_name + end + + private + + def can_read_terraform_states? + current_user.can?(:read_terraform_state, project) + end + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 0fd54af1538..0dff21cbd32 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -294,6 +294,12 @@ module Types description: 'Title of the label' end + field :terraform_states, + Types::Terraform::StateType.connection_type, + null: true, + description: 'Terraform states associated with the project', + resolver: Resolvers::Terraform::StatesResolver + def label(title:) BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args| LabelsFinder diff --git a/app/graphql/types/terraform/state_type.rb b/app/graphql/types/terraform/state_type.rb new file mode 100644 index 00000000000..f25f3a7789b --- /dev/null +++ b/app/graphql/types/terraform/state_type.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module Terraform + class StateType < BaseObject + graphql_name 'TerraformState' + + authorize :read_terraform_state + + field :id, GraphQL::ID_TYPE, + null: false, + description: 'ID of the Terraform state' + + field :name, GraphQL::STRING_TYPE, + null: false, + description: 'Name of the Terraform state' + + field :locked_by_user, Types::UserType, + null: true, + authorize: :read_user, + description: 'The user currently holding a lock on the Terraform state', + resolve: -> (state, _, _) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, state.locked_by_user_id).find } + + field :locked_at, Types::TimeType, + null: true, + description: 'Timestamp the Terraform state was locked' + + field :created_at, Types::TimeType, + null: false, + description: 'Timestamp the Terraform state was created' + + field :updated_at, Types::TimeType, + null: false, + description: 'Timestamp the Terraform state was updated' + end + end +end diff --git a/app/helpers/feature_flags_helper.rb b/app/helpers/feature_flags_helper.rb new file mode 100644 index 00000000000..e50191a471f --- /dev/null +++ b/app/helpers/feature_flags_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module FeatureFlagsHelper + include ::API::Helpers::RelatedResourcesHelpers + + def unleash_api_url(project) + expose_url(api_v4_feature_flags_unleash_path(project_id: project.id)) + end + + def unleash_api_instance_id(project) + project.feature_flags_client_token + end + + def feature_flag_issues_links_endpoint(_project, _feature_flag, _user) + '' + end +end + +FeatureFlagsHelper.prepend_if_ee('::EE::FeatureFlagsHelper') diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb index ce20442ff81..0a296b4e6ba 100644 --- a/app/helpers/packages_helper.rb +++ b/app/helpers/packages_helper.rb @@ -57,4 +57,9 @@ module PackagesHelper package_help_url: help_page_path('user/packages/index') } end + + def track_package_event(event_name, scope, **args) + ::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute + track_event(event_name, **args) + end end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index a19c3512f8d..79f4810e13a 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -2,6 +2,8 @@ module SystemNoteHelper ICON_NAMES_BY_ACTION = { + 'approved' => 'approval', + 'unapproved' => 'unapproval', 'cherry_pick' => 'cherry-pick-commit', 'commit' => 'commit', 'description' => 'pencil-square', diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 7b4485376d4..b10e8547e86 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -81,13 +81,6 @@ module Mentionable end def store_mentions! - # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded - # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be - # successful if mentionable.save is successful. - # - # This line will get removed when we remove the feature flag. - return true unless store_mentioned_users_to_db_enabled? - refs = all_references(self.author) references = {} @@ -253,15 +246,6 @@ module Mentionable def model_user_mention user_mentions.where(note_id: nil).first_or_initialize end - - # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level - # and not the project level as epics are defined at group level and we want to have epics store user mentions as well - # for the test period. - # During the test period the flag should be enabled at the group level. - def store_mentioned_users_to_db_enabled? - return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group, default_enabled: true) if self.respond_to?(:project) - return Feature.enabled?(:store_mentioned_users_to_db, self.group, default_enabled: true) if self.respond_to?(:group) - end end Mentionable.prepend_if_ee('EE::Mentionable') diff --git a/app/models/event.rb b/app/models/event.rb index 92609144576..318599052e4 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -242,6 +242,8 @@ class Event < ApplicationRecord target if note? end + # rubocop: disable Metrics/CyclomaticComplexity + # rubocop: disable Metrics/PerceivedComplexity def action_name if push_action? push_action_name @@ -267,10 +269,14 @@ class Event < ApplicationRecord 'updated' elsif created_project_action? created_project_action_name + elsif approved_action? + 'approved' else "opened" end end + # rubocop: enable Metrics/CyclomaticComplexity + # rubocop: enable Metrics/PerceivedComplexity def target_iid target.respond_to?(:iid) ? target.iid : target_id diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb new file mode 100644 index 00000000000..730ce267273 --- /dev/null +++ b/app/models/packages/event.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class Packages::Event < ApplicationRecord + belongs_to :package, optional: true + + # FIXME: Remove debian: 9 from here when it's added to the types in package.rb model + EVENT_SCOPES = ::Packages::Package.package_types.merge(debian: 9, container: 1000, tag: 1001).freeze + + enum event_scope: EVENT_SCOPES + + enum event_type: { + push_package: 0, + delete_package: 1, + pull_package: 2, + search_package: 3, + list_package: 4, + list_repositories: 5, + delete_repository: 6, + delete_tag: 7, + delete_tag_bulk: 8, + list_tags: 9, + cli_metadata: 10 + } + + enum originator_type: { user: 0, deploy_token: 1, guest: 2 } +end diff --git a/app/models/project.rb b/app/models/project.rb index 8934c09f7b1..ca3729e277a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -337,6 +337,8 @@ class Project < ApplicationRecord has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project has_many :reviews, inverse_of: :project + has_many :terraform_states, class_name: 'Terraform::State', inverse_of: :project + # GitLab Pages has_many :pages_domains has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project @@ -2506,6 +2508,11 @@ class Project < ApplicationRecord GroupDeployKey.for_groups(group.self_and_ancestors_ids) end + def feature_flags_client_token + instance = operations_feature_flags_client || create_operations_feature_flags_client! + instance.token + end + private def find_service(services, name) diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 419fffcb666..7a43350971a 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -15,6 +15,7 @@ module Terraform has_one :latest_version, -> { ordered_by_version_desc }, class_name: 'Terraform::StateVersion', foreign_key: :terraform_state_id scope :versioning_not_enabled, -> { where(versioning_enabled: false) } + scope :ordered_by_name, -> { order(:name) } validates :project_id, presence: true validates :uuid, presence: true, uniqueness: true, length: { is: UUID_LENGTH }, diff --git a/app/policies/terraform/state_policy.rb b/app/policies/terraform/state_policy.rb new file mode 100644 index 00000000000..ba6109e5975 --- /dev/null +++ b/app/policies/terraform/state_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Terraform + class StatePolicy < BasePolicy + alias_method :terraform_state, :subject + + delegate { terraform_state.project } + end +end diff --git a/app/services/packages/create_event_service.rb b/app/services/packages/create_event_service.rb new file mode 100644 index 00000000000..d009cba2812 --- /dev/null +++ b/app/services/packages/create_event_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Packages + class CreateEventService < BaseService + def execute + event_scope = scope.is_a?(::Packages::Package) ? scope.package_type : scope + + ::Packages::Event.create!( + event_type: event_name, + originator: current_user&.id, + originator_type: originator_type, + event_scope: event_scope + ) + end + + private + + def scope + params[:scope] + end + + def event_name + params[:event_name] + end + + def originator_type + case current_user + when User + :user + when DeployToken + :deploy_token + else + :guest + end + end + end +end diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 54f6fa91cf1..8c23fc7c590 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -1,11 +1,9 @@ - link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer') -.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.gl-mt-3.gl-mb-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } - %button.close.js-close{ type: "button" } × - .gcp-signup-offer--content - .gcp-signup-offer--icon.gl-mr-3 - = sprite_icon("information") - .gcp-signup-offer--copy - %h4= s_('ClusterIntegration|Did you know?') - %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } - %a.btn.btn-default{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } - = s_("ClusterIntegration|Apply for credit") +.gcp-signup-offer.gl-alert.gl-alert-info.gl-my-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + %h4.gl-alert-title= s_('ClusterIntegration|Did you know?') + %p.gl-alert-body= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } + %a.gl-button.btn-info{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } + = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/invites/decline.html.haml b/app/views/invites/decline.html.haml new file mode 100644 index 00000000000..4a57d70cb6e --- /dev/null +++ b/app/views/invites/decline.html.haml @@ -0,0 +1,8 @@ +- page_title _('Invitation declined') +.decline-page.gl-display-flex.gl-flex-direction-column.gl-mx-auto{ class: 'gl-xs-w-full!' } + .gl-align-self-center.gl-mb-4.gl-mt-7.gl-sm-mt-0= sprite_icon('check-circle', size: 48, css_class: 'gl-text-green-400') + %h2.gl-font-size-h2= _('You successfully declined the invitation') + %p + = html_escape(_('We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders.')) % { inviter: sanitize_name(@member.created_by.name) } + %p + = _('You can now close this window.') diff --git a/app/views/projects/group_links/update.js.haml b/app/views/projects/group_links/update.js.haml deleted file mode 100644 index 55520fda494..00000000000 --- a/app/views/projects/group_links/update.js.haml +++ /dev/null @@ -1,4 +0,0 @@ -:plain - var $listItem = $('#{escape_javascript(render('shared/members/group', group_link: @group_link))}'); - $("#group_member_#{@group_link.id} .list-item-name").replaceWith($listItem.find('.list-item-name')); - gl.utils.localTimeAgo($('.js-timeago'), $("#group_member_#{@group_link.id}")); diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 8e5763842d9..cd24942616c 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -6,17 +6,18 @@ -# Note this is just for groups. For individual members please see shared/members/_member -%li.member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } } +%li.member.js-member.group_member.py-2.px-3.d-flex.flex-column.flex-md-row{ id: dom_id, data: { qa_selector: 'group_row' } } %span.list-item-name.mb-2.m-md-0 = group_icon(group, class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '') .user-info = link_to group.full_name, group_path(group), class: 'member' .cgray Given access #{time_ago_with_tooltip(group_link.created_at)} - - if group_link.expires? - · - %span{ class: ('text-warning' if group_link.expires_soon?) } - = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) } + %span.js-expires-in{ class: ('gl-display-none' unless group_link.expires?) } + · + %span.js-expires-in-text{ class: ('text-warning' if group_link.expires_soon?) } + - if group_link.expires? + = _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) } .controls.member-controls.align-items-center = form_tag group_link_path, method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do = hidden_field_tag "group_link[group_access]", group_link.group_access diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 7573c2f6d56..679a460eeb3 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -8,7 +8,7 @@ -# Note this is just for individual members. For groups please see shared/members/_group -%li.member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } } +%li.member.js-member.py-2.px-3.d-flex.flex-column{ class: [dom_class(member), ("is-overridden" if override), ("flex-md-row" unless force_mobile_view)], id: dom_id(member), data: { qa_selector: 'member_row' } } %span.list-item-name.mb-2.m-md-0 - if user = image_tag avatar_icon_for_user(user, 40), class: "avatar s40 flex-shrink-0 flex-grow-0", alt: '' diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 0c26cb533a5..c325e8d4a16 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -10,7 +10,7 @@ .form-group = f.label :title, class: 'label-bold' - = f.text_field :title, class: 'form-control', required: true, autofocus: true, data: { qa_selector: 'snippet_title_field' } + = f.text_field :title, class: 'form-control', required: true, autofocus: true .form-group.js-description-input - description_placeholder = s_('Snippets|Optionally add a description about what your snippet does or how to use it...') @@ -18,17 +18,17 @@ = f.label :description, s_("Snippets|Description (optional)"), class: 'label-bold' .js-collapsible-input .js-collapsed{ class: ('d-none' if is_expanded) } - = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder, data: { qa_selector: 'description_placeholder' } + = text_field_tag nil, nil, class: 'form-control', placeholder: description_placeholder .js-expanded{ class: ('d-none' if !is_expanded) } = render layout: 'shared/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do - = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder, qa_selector: 'snippet_description_field' + = render 'shared/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: description_placeholder = render 'shared/notes/hints' .form-group.file-editor = f.label :file_name, s_('Snippets|File') .file-holder.snippet .js-file-title.file-title-flex-parent - = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name', data: { qa_selector: 'file_name_field' } + = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name' .file-content.code #editor{ data: { 'editor-loading': true } }< %pre.editor-loading-content= @snippet.content @@ -46,9 +46,9 @@ .form-actions - if @snippet.new_record? - = f.submit 'Create snippet', class: "btn-success btn gl-button", data: { qa_selector: 'submit_button' } + = f.submit 'Create snippet', class: "btn-success btn gl-button" - else - = f.submit 'Save changes', class: "btn-success btn gl-button", data: { qa_selector: 'submit_button' } + = f.submit 'Save changes', class: "btn-success btn gl-button" - if @snippet.project_id = link_to "Cancel", project_snippets_path(@project), class: "btn gl-button btn-default" diff --git a/changelogs/unreleased/216571-terraform-states-graphql-endpoint.yml b/changelogs/unreleased/216571-terraform-states-graphql-endpoint.yml new file mode 100644 index 00000000000..6b3c0dd0875 --- /dev/null +++ b/changelogs/unreleased/216571-terraform-states-graphql-endpoint.yml @@ -0,0 +1,5 @@ +--- +title: Add GraphQL endpoint for Terraform state metadata +merge_request: 43375 +author: +type: added diff --git a/changelogs/unreleased/231306-approve-activity-in-core.yml b/changelogs/unreleased/231306-approve-activity-in-core.yml new file mode 100644 index 00000000000..41e921821b5 --- /dev/null +++ b/changelogs/unreleased/231306-approve-activity-in-core.yml @@ -0,0 +1,5 @@ +--- +title: Fix issues with optional merge requests approval in CE +author: Pavel Kuznetsov +merge_request: 42119 +type: fixed diff --git a/changelogs/unreleased/233660-remove-bootstrap-alert-gcp.yml b/changelogs/unreleased/233660-remove-bootstrap-alert-gcp.yml new file mode 100644 index 00000000000..a0cf820cb4e --- /dev/null +++ b/changelogs/unreleased/233660-remove-bootstrap-alert-gcp.yml @@ -0,0 +1,5 @@ +--- +title: Remove bootrap alert from gcp offer +merge_request: 41814 +author: +type: other diff --git a/changelogs/unreleased/241129-retry-button-misaligned.yml b/changelogs/unreleased/241129-retry-button-misaligned.yml deleted file mode 100644 index 101bf8210a5..00000000000 --- a/changelogs/unreleased/241129-retry-button-misaligned.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Fix alignment of action buttons in pipelines -merge_request: 42971 -author: -type: fixed diff --git a/changelogs/unreleased/246619-add-package-events.yml b/changelogs/unreleased/246619-add-package-events.yml new file mode 100644 index 00000000000..26465bc4aaa --- /dev/null +++ b/changelogs/unreleased/246619-add-package-events.yml @@ -0,0 +1,5 @@ +--- +title: Adds package event tracking +merge_request: 41846 +author: +type: added diff --git a/changelogs/unreleased/256121-add-schema-to-migration-helpers-that-query-the-pg-catalog.yml b/changelogs/unreleased/256121-add-schema-to-migration-helpers-that-query-the-pg-catalog.yml new file mode 100644 index 00000000000..7fa4750c445 --- /dev/null +++ b/changelogs/unreleased/256121-add-schema-to-migration-helpers-that-query-the-pg-catalog.yml @@ -0,0 +1,5 @@ +--- +title: Update database helpers to set the current_schema +merge_request: 43568 +author: +type: fixed diff --git a/changelogs/unreleased/35223-drop-unused-user-id-column-on-cluster-providers-aws.yml b/changelogs/unreleased/35223-drop-unused-user-id-column-on-cluster-providers-aws.yml new file mode 100644 index 00000000000..dd27d6778fc --- /dev/null +++ b/changelogs/unreleased/35223-drop-unused-user-id-column-on-cluster-providers-aws.yml @@ -0,0 +1,5 @@ +--- +title: Remove unused cluster_providers_aws.created_by_user_id column +merge_request: 43064 +author: +type: other diff --git a/changelogs/unreleased/debian_regexp.yml b/changelogs/unreleased/debian_regexp.yml new file mode 100644 index 00000000000..fe8e06c9291 --- /dev/null +++ b/changelogs/unreleased/debian_regexp.yml @@ -0,0 +1,5 @@ +--- +title: Add Debian regexps +merge_request: 43259 +author: Mathieu Parent +type: added diff --git a/changelogs/unreleased/isolate-mentions-migration.yml b/changelogs/unreleased/isolate-mentions-migration.yml new file mode 100644 index 00000000000..ae12901ce90 --- /dev/null +++ b/changelogs/unreleased/isolate-mentions-migration.yml @@ -0,0 +1,5 @@ +--- +title: Store user mentions to DB +merge_request: 43393 +author: +type: added diff --git a/changelogs/unreleased/reminder-emails-invitation-declined-screen.yml b/changelogs/unreleased/reminder-emails-invitation-declined-screen.yml new file mode 100644 index 00000000000..35d3013f3c1 --- /dev/null +++ b/changelogs/unreleased/reminder-emails-invitation-declined-screen.yml @@ -0,0 +1,5 @@ +--- +title: Add invitation declined page +merge_request: 43305 +author: +type: changed diff --git a/config/feature_flags/development/feature_flags_new_version.yml b/config/feature_flags/development/feature_flags_new_version.yml index 9302ca2df4f..3a89816c482 100644 --- a/config/feature_flags/development/feature_flags_new_version.yml +++ b/config/feature_flags/development/feature_flags_new_version.yml @@ -1,7 +1,7 @@ --- name: feature_flags_new_version introduced_by_url: -rollout_issue_url: -group: +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258831 +group: group::progressive delivery type: development default_enabled: true diff --git a/config/feature_flags/development/maintenance_mode.yml b/config/feature_flags/development/maintenance_mode.yml index 8fba1216834..429e70b64a2 100644 --- a/config/feature_flags/development/maintenance_mode.yml +++ b/config/feature_flags/development/maintenance_mode.yml @@ -1,7 +1,7 @@ --- name: maintenance_mode -introduced_by_url: -rollout_issue_url: -group: +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28158 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/217895 +group: group::geo type: development default_enabled: false diff --git a/config/feature_flags/development/store_mentioned_users_to_db.yml b/config/feature_flags/development/store_mentioned_users_to_db.yml deleted file mode 100644 index 8b1e587b26b..00000000000 --- a/config/feature_flags/development/store_mentioned_users_to_db.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: store_mentioned_users_to_db -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/19088 -rollout_issue_url: -group: group::project management -type: development -default_enabled: true diff --git a/config/feature_flags/development/unified_diff_lines.yml b/config/feature_flags/development/unified_diff_lines.yml index a676f0732dd..d580ef65104 100644 --- a/config/feature_flags/development/unified_diff_lines.yml +++ b/config/feature_flags/development/unified_diff_lines.yml @@ -4,4 +4,4 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40131 rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/241188 group: group::source code type: development -default_enabled: false +default_enabled: true diff --git a/db/migrate/20200909040555_create_package_events.rb b/db/migrate/20200909040555_create_package_events.rb new file mode 100644 index 00000000000..000ff051a7c --- /dev/null +++ b/db/migrate/20200909040555_create_package_events.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreatePackageEvents < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + create_table :packages_events do |t| + t.integer :event_type, null: false, limit: 2 + t.integer :event_scope, null: false, limit: 2 + t.integer :originator_type, null: false, limit: 2 + t.bigint :originator + t.datetime_with_timezone :created_at, null: false + + t.references :package, primary_key: false, default: nil, index: true, foreign_key: { to_table: :packages_packages, on_delete: :nullify }, type: :bigint + end + end +end diff --git a/db/post_migrate/20200922231755_remove_created_by_user_id_from_cluster_providers_aws.rb b/db/post_migrate/20200922231755_remove_created_by_user_id_from_cluster_providers_aws.rb new file mode 100644 index 00000000000..02cc9676202 --- /dev/null +++ b/db/post_migrate/20200922231755_remove_created_by_user_id_from_cluster_providers_aws.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class RemoveCreatedByUserIdFromClusterProvidersAws < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + INDEX_NAME = 'index_cluster_providers_aws_on_created_by_user_id' + + disable_ddl_transaction! + + def up + with_lock_retries do + remove_column :cluster_providers_aws, :created_by_user_id + end + end + + def down + unless column_exists?(:cluster_providers_aws, :created_by_user_id) + add_column :cluster_providers_aws, :created_by_user_id, :integer + end + + add_concurrent_index :cluster_providers_aws, :created_by_user_id, name: INDEX_NAME + + add_concurrent_foreign_key :cluster_providers_aws, :users, column: :created_by_user_id, on_delete: :nullify + end +end diff --git a/db/schema_migrations/20200909040555 b/db/schema_migrations/20200909040555 new file mode 100644 index 00000000000..27855514146 --- /dev/null +++ b/db/schema_migrations/20200909040555 @@ -0,0 +1 @@ +f68d29be164299e5ccf73347841d27b17f028941e37e3510d3da9d513762a17f
\ No newline at end of file diff --git a/db/schema_migrations/20200922231755 b/db/schema_migrations/20200922231755 new file mode 100644 index 00000000000..504df45b957 --- /dev/null +++ b/db/schema_migrations/20200922231755 @@ -0,0 +1 @@ +0019105cd2112e138b9926dc000b0c54b41fca6dfb2c4f658900040e0ecb3b70
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index fba163f7417..841ed94abfa 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -14071,6 +14071,25 @@ CREATE SEQUENCE packages_dependency_links_id_seq ALTER SEQUENCE packages_dependency_links_id_seq OWNED BY packages_dependency_links.id; +CREATE TABLE packages_events ( + id bigint NOT NULL, + event_type smallint NOT NULL, + event_scope smallint NOT NULL, + originator_type smallint NOT NULL, + originator bigint, + created_at timestamp with time zone NOT NULL, + package_id bigint +); + +CREATE SEQUENCE packages_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE packages_events_id_seq OWNED BY packages_events.id; + CREATE TABLE packages_maven_metadata ( id bigint NOT NULL, package_id bigint NOT NULL, @@ -17540,6 +17559,8 @@ ALTER TABLE ONLY packages_dependencies ALTER COLUMN id SET DEFAULT nextval('pack ALTER TABLE ONLY packages_dependency_links ALTER COLUMN id SET DEFAULT nextval('packages_dependency_links_id_seq'::regclass); +ALTER TABLE ONLY packages_events ALTER COLUMN id SET DEFAULT nextval('packages_events_id_seq'::regclass); + ALTER TABLE ONLY packages_maven_metadata ALTER COLUMN id SET DEFAULT nextval('packages_maven_metadata_id_seq'::regclass); ALTER TABLE ONLY packages_package_files ALTER COLUMN id SET DEFAULT nextval('packages_package_files_id_seq'::regclass); @@ -18735,6 +18756,9 @@ ALTER TABLE ONLY packages_dependencies ALTER TABLE ONLY packages_dependency_links ADD CONSTRAINT packages_dependency_links_pkey PRIMARY KEY (id); +ALTER TABLE ONLY packages_events + ADD CONSTRAINT packages_events_pkey PRIMARY KEY (id); + ALTER TABLE ONLY packages_maven_metadata ADD CONSTRAINT packages_maven_metadata_pkey PRIMARY KEY (id); @@ -20727,6 +20751,8 @@ CREATE UNIQUE INDEX index_packages_dependencies_on_name_and_version_pattern ON p CREATE INDEX index_packages_dependency_links_on_dependency_id ON packages_dependency_links USING btree (dependency_id); +CREATE INDEX index_packages_events_on_package_id ON packages_events USING btree (package_id); + CREATE INDEX index_packages_maven_metadata_on_package_id_and_path ON packages_maven_metadata USING btree (package_id, path); CREATE INDEX index_packages_nuget_dl_metadata_on_dependency_link_id ON packages_nuget_dependency_link_metadata USING btree (dependency_link_id); @@ -23512,6 +23538,9 @@ ALTER TABLE ONLY merge_request_user_mentions ALTER TABLE ONLY ci_job_artifacts ADD CONSTRAINT fk_rails_c5137cb2c1 FOREIGN KEY (job_id) REFERENCES ci_builds(id) ON DELETE CASCADE; +ALTER TABLE ONLY packages_events + ADD CONSTRAINT fk_rails_c6c20d0094 FOREIGN KEY (package_id) REFERENCES packages_packages(id) ON DELETE SET NULL; + ALTER TABLE ONLY project_settings ADD CONSTRAINT fk_rails_c6df6e6328 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/doc/administration/geo/replication/updating_the_geo_nodes.md b/doc/administration/geo/replication/updating_the_geo_nodes.md index 9200014ee13..1af2b8d0b88 100644 --- a/doc/administration/geo/replication/updating_the_geo_nodes.md +++ b/doc/administration/geo/replication/updating_the_geo_nodes.md @@ -29,9 +29,9 @@ and all **secondary** nodes: 1. **Optional:** [Pause replication on each **secondary** node.](../index.md#pausing-and-resuming-replication) 1. Log into the **primary** node. -1. [Update GitLab on the **primary** node using Omnibus](https://docs.gitlab.com/omnibus/update/README.html). +1. [Update GitLab on the **primary** node using Omnibus's Geo-specific steps](https://docs.gitlab.com/omnibus/update/README.html#geo-deployment). 1. Log into each **secondary** node. -1. [Update GitLab on each **secondary** node using Omnibus](https://docs.gitlab.com/omnibus/update/README.html). +1. [Update GitLab on each **secondary** node using Omnibus's Geo-specific steps](https://docs.gitlab.com/omnibus/update/README.html#geo-deployment). 1. If you paused replication in step 1, [resume replication on each **secondary**](../index.md#pausing-and-resuming-replication) 1. [Test](#check-status-after-updating) **primary** and **secondary** nodes, and check version in each. diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 8fa5eb6e283..3dd897ad669 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -14089,6 +14089,31 @@ type Project { tagList: String """ + Terraform states associated with the project + """ + terraformStates( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + ): TerraformStateConnection + + """ Permissions for the current user on the resource """ userPermissions: ProjectPermissions! @@ -17632,6 +17657,73 @@ type TaskCompletionStatus { count: Int! } +type TerraformState { + """ + Timestamp the Terraform state was created + """ + createdAt: Time! + + """ + ID of the Terraform state + """ + id: ID! + + """ + Timestamp the Terraform state was locked + """ + lockedAt: Time + + """ + The user currently holding a lock on the Terraform state + """ + lockedByUser: User + + """ + Name of the Terraform state + """ + name: String! + + """ + Timestamp the Terraform state was updated + """ + updatedAt: Time! +} + +""" +The connection type for TerraformState. +""" +type TerraformStateConnection { + """ + A list of edges. + """ + edges: [TerraformStateEdge] + + """ + A list of nodes. + """ + nodes: [TerraformState] + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +""" +An edge in a connection. +""" +type TerraformStateEdge { + """ + A cursor for use in pagination. + """ + cursor: String! + + """ + The item at the end of the edge. + """ + node: TerraformState +} + """ Represents the Geo sync and verification state of a terraform state """ diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 2ab4e04a019..6460f89e2a8 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -40893,6 +40893,59 @@ "deprecationReason": null }, { + "name": "terraformStates", + "description": "Terraform states associated with the project", + "args": [ + { + "name": "after", + "description": "Returns the elements in the list that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements in the list that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "first", + "description": "Returns the first _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns the last _n_ elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "TerraformStateConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "userPermissions", "description": "Permissions for the current user on the resource", "args": [ @@ -51367,6 +51420,231 @@ }, { "kind": "OBJECT", + "name": "TerraformState", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": "Timestamp the Terraform state was created", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the Terraform state", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lockedAt", + "description": "Timestamp the Terraform state was locked", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lockedByUser", + "description": "The user currently holding a lock on the Terraform state", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "User", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "Name of the Terraform state", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": "Timestamp the Terraform state was updated", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Time", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TerraformStateConnection", + "description": "The connection type for TerraformState.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TerraformStateEdge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of nodes.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TerraformState", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TerraformStateEdge", + "description": "An edge in a connection.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of the edge.", + "args": [ + + ], + "type": { + "kind": "OBJECT", + "name": "TerraformState", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "TerraformStateRegistry", "description": "Represents the Geo sync and verification state of a terraform state", "fields": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 99cfc822709..041ab8587b2 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -2473,6 +2473,17 @@ Completion status of tasks. | `completedCount` | Int! | Number of completed tasks | | `count` | Int! | Number of total tasks | +### TerraformState + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `createdAt` | Time! | Timestamp the Terraform state was created | +| `id` | ID! | ID of the Terraform state | +| `lockedAt` | Time | Timestamp the Terraform state was locked | +| `lockedByUser` | User | The user currently holding a lock on the Terraform state | +| `name` | String! | Name of the Terraform state | +| `updatedAt` | Time! | Timestamp the Terraform state was updated | + ### TerraformStateRegistry Represents the Geo sync and verification state of a terraform state. diff --git a/doc/api/services.md b/doc/api/services.md index 61b75d3dcff..7c01e43a4d8 100644 --- a/doc/api/services.md +++ b/doc/api/services.md @@ -714,7 +714,7 @@ Parameters: | `merge_requests_events` | boolean | false | Enable notifications for merge request events | | `tag_push_events` | boolean | false | Enable notifications for tag push events | | `note_events` | boolean | false | Enable notifications for note events | -| `confidental_note_events` | boolean | false | Enable notifications for confidential note events | +| `confidential_note_events` | boolean | false | Enable notifications for confidential note events | | `pipeline_events` | boolean | false | Enable notifications for pipeline events | ### Delete HipChat service diff --git a/doc/user/application_security/sast/index.md b/doc/user/application_security/sast/index.md index 374861deae9..f950d48c6d3 100644 --- a/doc/user/application_security/sast/index.md +++ b/doc/user/application_security/sast/index.md @@ -147,6 +147,7 @@ always take the latest SAST artifact available. > - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/3659) in GitLab Ultimate 13.3. > - [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/232862) in GitLab Ultimate 13.4. +> - [Improved](https://gitlab.com/groups/gitlab-org/-/epics/3635) in GitLab Ultimate 13.5. You can enable and configure SAST with a basic configuration using the **SAST Configuration** page: @@ -154,9 +155,11 @@ page: 1. From the project's home page, go to **Security & Compliance** > **Configuration** in the left sidebar. 1. If the project does not have a `gitlab-ci.yml` file, click **Enable** in the Static Application Security Testing (SAST) row, otherwise click **Configure**. -1. Enter the custom SAST values, then click **Create Merge Request**. +1. Enter the custom SAST values. Custom values are stored in the `.gitlab-ci.yml` file. For variables not in the SAST Configuration page, their values are left unchanged. Default values are inherited from the GitLab SAST template. +1. Optionally, expand the **SAST analyzers** section, select individual [SAST analyzers](./analyzers.md) and enter custom analyzer values. +1. Click **Create Merge Request**. 1. Review and merge the merge request. ### Customizing the SAST settings diff --git a/lib/api/api.rb b/lib/api/api.rb index c77ef6616e3..233b5660109 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -239,6 +239,7 @@ module API mount ::API::Templates mount ::API::Todos mount ::API::Triggers + mount ::API::Unleash mount ::API::UsageData mount ::API::UserCounts mount ::API::Users diff --git a/lib/api/composer_packages.rb b/lib/api/composer_packages.rb index 31d097c4bea..69e44ffcaf9 100644 --- a/lib/api/composer_packages.rb +++ b/lib/api/composer_packages.rb @@ -123,7 +123,7 @@ module API bad_request! end - package_event('push_package') + track_package_event('push_package', :composer) ::Packages::Composer::CreatePackageService .new(authorized_user_project, current_user, declared_params) diff --git a/lib/api/conan_package_endpoints.rb b/lib/api/conan_package_endpoints.rb index 445447cfcd2..9b6867a328b 100644 --- a/lib/api/conan_package_endpoints.rb +++ b/lib/api/conan_package_endpoints.rb @@ -246,7 +246,7 @@ module API delete do authorize!(:destroy_package, project) - package_event('delete_package', category: 'API::ConanPackages') + track_package_event('delete_package', :conan, category: 'API::ConanPackages') package.destroy end diff --git a/lib/api/debian_package_endpoints.rb b/lib/api/debian_package_endpoints.rb index 79d0b6c2e26..168b3ca7a4f 100644 --- a/lib/api/debian_package_endpoints.rb +++ b/lib/api/debian_package_endpoints.rb @@ -44,7 +44,7 @@ module API end params do - requires :distribution, type: String, desc: 'The Debian Codename', file_path: true + requires :distribution, type: String, desc: 'The Debian Codename', regexp: Gitlab::Regex.debian_distribution_regex end namespace 'dists/*distribution', requirements: DISTRIBUTION_REQUIREMENTS do @@ -80,8 +80,8 @@ module API end params do - requires :component, type: String, desc: 'The Debian Component' - requires :architecture, type: String, desc: 'The Debian Architecture' + requires :component, type: String, desc: 'The Debian Component', regexp: Gitlab::Regex.debian_component_regex + requires :architecture, type: String, desc: 'The Debian Architecture', regexp: Gitlab::Regex.debian_architecture_regex end namespace ':component/binary-:architecture', requirements: COMPONENT_ARCHITECTURE_REQUIREMENTS do @@ -99,9 +99,9 @@ module API end params do - requires :component, type: String, desc: 'The Debian Component' + requires :component, type: String, desc: 'The Debian Component', regexp: Gitlab::Regex.debian_component_regex requires :letter, type: String, desc: 'The Debian Classification (first-letter or lib-first-letter)' - requires :source_package, type: String, desc: 'The Debian Source Package Name' + requires :source_package, type: String, desc: 'The Debian Source Package Name', regexp: Gitlab::Regex.debian_package_name_regex end namespace 'pool/:component/:letter/:source_package', requirements: COMPONENT_LETTER_SOURCE_PACKAGE_REQUIREMENTS do diff --git a/lib/api/debian_project_packages.rb b/lib/api/debian_project_packages.rb index 81f37ddd8da..7cd796aac2b 100644 --- a/lib/api/debian_project_packages.rb +++ b/lib/api/debian_project_packages.rb @@ -31,7 +31,7 @@ module API authorize_upload!(authorized_user_project) bad_request!('File is too large') if authorized_user_project.actual_limits.exceeded?(:debian_max_file_size, params[:file].size) - package_event('push_package') + track_package_event('push_package', :debian) created! rescue ObjectStorage::RemoteStoreError => e diff --git a/lib/api/entities/unleash_feature.rb b/lib/api/entities/unleash_feature.rb new file mode 100644 index 00000000000..8ee87d1fc11 --- /dev/null +++ b/lib/api/entities/unleash_feature.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module API + module Entities + class UnleashFeature < Grape::Entity + expose :name + expose :description, unless: ->(feature) { feature.description.nil? } + expose :active, as: :enabled + expose :strategies do |flag| + flag.strategies.map do |strategy| + if legacy_strategy?(strategy) + UnleashLegacyStrategy.represent(strategy) + elsif gitlab_user_list_strategy?(strategy) + UnleashGitlabUserListStrategy.represent(strategy) + else + UnleashStrategy.represent(strategy) + end + end + end + + private + + def legacy_strategy?(strategy) + !strategy.respond_to?(:name) + end + + def gitlab_user_list_strategy?(strategy) + strategy.name == ::Operations::FeatureFlags::Strategy::STRATEGY_GITLABUSERLIST + end + end + end +end diff --git a/lib/api/entities/unleash_gitlab_user_list_strategy.rb b/lib/api/entities/unleash_gitlab_user_list_strategy.rb new file mode 100644 index 00000000000..5617f8002d9 --- /dev/null +++ b/lib/api/entities/unleash_gitlab_user_list_strategy.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module API + module Entities + class UnleashGitlabUserListStrategy < Grape::Entity + expose :name do |_strategy| + ::Operations::FeatureFlags::Strategy::STRATEGY_USERWITHID + end + expose :parameters do |strategy| + { userIds: strategy.user_list.user_xids } + end + end + end +end diff --git a/lib/api/entities/unleash_legacy_strategy.rb b/lib/api/entities/unleash_legacy_strategy.rb new file mode 100644 index 00000000000..5d5954f8da0 --- /dev/null +++ b/lib/api/entities/unleash_legacy_strategy.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module API + module Entities + class UnleashLegacyStrategy < Grape::Entity + expose :name do |strategy| + strategy['name'] + end + expose :parameters do |strategy| + strategy['parameters'] + end + end + end +end diff --git a/lib/api/entities/unleash_strategy.rb b/lib/api/entities/unleash_strategy.rb new file mode 100644 index 00000000000..7627ce3873c --- /dev/null +++ b/lib/api/entities/unleash_strategy.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module API + module Entities + class UnleashStrategy < Grape::Entity + expose :name + expose :parameters + end + end +end diff --git a/lib/api/group_container_repositories.rb b/lib/api/group_container_repositories.rb index 25b3059f63b..5b6a3bd36cf 100644 --- a/lib/api/group_container_repositories.rb +++ b/lib/api/group_container_repositories.rb @@ -4,6 +4,8 @@ module API class GroupContainerRepositories < Grape::API::Instance include PaginationParams + helpers ::API::Helpers::PackagesHelpers + before { authorize_read_group_container_images! } REPOSITORY_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge( @@ -27,7 +29,7 @@ module API user: current_user, subject: user_group ).execute - track_event('list_repositories') + track_package_event('list_repositories', :container) present paginate(repositories), with: Entities::ContainerRegistry::Repository, tags: params[:tags], tags_count: params[:tags_count] end diff --git a/lib/api/helpers/packages/conan/api_helpers.rb b/lib/api/helpers/packages/conan/api_helpers.rb index dcbf933a4e1..934e18bdd0a 100644 --- a/lib/api/helpers/packages/conan/api_helpers.rb +++ b/lib/api/helpers/packages/conan/api_helpers.rb @@ -158,7 +158,7 @@ module API conan_package_reference: params[:conan_package_reference] ).execute! - package_event('pull_package', category: 'API::ConanPackages') if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY + track_package_event('pull_package', :conan, category: 'API::ConanPackages') if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY present_carrierwave_file!(package_file.file) end @@ -169,7 +169,7 @@ module API def track_push_package_event if params[:file_name] == ::Packages::Conan::FileMetadatum::PACKAGE_BINARY && params[:file].size > 0 # rubocop: disable Style/ZeroLengthPredicate - package_event('push_package', category: 'API::ConanPackages') + track_package_event('push_package', :conan, category: 'API::ConanPackages') end end diff --git a/lib/api/helpers/packages_helpers.rb b/lib/api/helpers/packages_helpers.rb index 403f5ea3851..c5134becb0c 100644 --- a/lib/api/helpers/packages_helpers.rb +++ b/lib/api/helpers/packages_helpers.rb @@ -48,7 +48,8 @@ module API require_gitlab_workhorse! end - def package_event(event_name, **args) + def track_package_event(event_name, scope, **args) + ::Packages::CreateEventService.new(nil, current_user, event_name: event_name, scope: scope).execute track_event(event_name, **args) end end diff --git a/lib/api/maven_packages.rb b/lib/api/maven_packages.rb index e6d9a9a7c20..d1dd3babb8b 100644 --- a/lib/api/maven_packages.rb +++ b/lib/api/maven_packages.rb @@ -107,7 +107,7 @@ module API when 'sha1' package_file.file_sha1 else - package_event('pull_package') if jar_file?(format) + track_package_event('pull_package', :maven) if jar_file?(format) present_carrierwave_file_with_head_support!(package_file.file) end end @@ -145,7 +145,7 @@ module API when 'sha1' package_file.file_sha1 else - package_event('pull_package') if jar_file?(format) + track_package_event('pull_package', :maven) if jar_file?(format) present_carrierwave_file_with_head_support!(package_file.file) end @@ -181,7 +181,7 @@ module API when 'sha1' package_file.file_sha1 else - package_event('pull_package') if jar_file?(format) + track_package_event('pull_package', :maven) if jar_file?(format) present_carrierwave_file_with_head_support!(package_file.file) end @@ -233,7 +233,7 @@ module API when 'md5' nil else - package_event('push_package') if jar_file?(format) + track_package_event('push_package', :maven) if jar_file?(format) file_params = { file: params[:file], diff --git a/lib/api/npm_packages.rb b/lib/api/npm_packages.rb index fca405b76b7..41238221aad 100644 --- a/lib/api/npm_packages.rb +++ b/lib/api/npm_packages.rb @@ -141,7 +141,7 @@ module API package_file = ::Packages::PackageFileFinder .new(package, params[:file_name]).execute! - package_event('pull_package') + track_package_event('pull_package', package) present_carrierwave_file!(package_file.file) end @@ -157,7 +157,7 @@ module API put ':id/packages/npm/:package_name', requirements: NPM_ENDPOINT_REQUIREMENTS do authorize_create_package!(user_project) - package_event('push_package') + track_package_event('push_package', :npm) created_package = ::Packages::Npm::CreatePackageService .new(user_project, current_user, params.merge(build: current_authenticated_job)).execute diff --git a/lib/api/nuget_packages.rb b/lib/api/nuget_packages.rb index f84a3acbe6d..a2b8eae7350 100644 --- a/lib/api/nuget_packages.rb +++ b/lib/api/nuget_packages.rb @@ -73,7 +73,7 @@ module API get 'index', format: :json do authorize_read_package!(authorized_user_project) - track_event('nuget_service_index') + track_package_event('cli_metadata', :nuget) present ::Packages::Nuget::ServiceIndexPresenter.new(authorized_user_project), with: ::API::Entities::Nuget::ServiceIndex @@ -105,7 +105,7 @@ module API package_file = ::Packages::CreatePackageFileService.new(package, file_params) .execute - package_event('push_package') + track_package_event('push_package', :nuget) ::Packages::Nuget::ExtractionWorker.perform_async(package_file.id) # rubocop:disable CodeReuse/Worker @@ -198,7 +198,7 @@ module API not_found!('Package') unless package_file - package_event('pull_package') + track_package_event('pull_package', :nuget) # nuget and dotnet don't support 302 Moved status codes, supports_direct_download has to be set to false present_carrierwave_file!(package_file.file, supports_direct_download: false) @@ -233,7 +233,7 @@ module API .new(authorized_user_project, params[:q], search_options) .execute - package_event('search_package') + track_package_event('search_package', :nuget) present ::Packages::Nuget::SearchResultsPresenter.new(search), with: ::API::Entities::Nuget::SearchResults diff --git a/lib/api/project_container_repositories.rb b/lib/api/project_container_repositories.rb index 8f2a62bc5a4..173e7799325 100644 --- a/lib/api/project_container_repositories.rb +++ b/lib/api/project_container_repositories.rb @@ -3,6 +3,7 @@ module API class ProjectContainerRepositories < Grape::API::Instance include PaginationParams + helpers ::API::Helpers::PackagesHelpers REPOSITORY_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS.merge( tag_name: API::NO_SLASH_URL_PART_REGEX) @@ -28,7 +29,7 @@ module API user: current_user, subject: user_project ).execute - track_event( 'list_repositories') + track_package_event('list_repositories', :container) present paginate(repositories), with: Entities::ContainerRegistry::Repository, tags: params[:tags], tags_count: params[:tags_count] end @@ -43,7 +44,7 @@ module API authorize_admin_container_image! DeleteContainerRepositoryWorker.perform_async(current_user.id, repository.id) # rubocop:disable CodeReuse/Worker - track_event('delete_repository') + track_package_event('delete_repository', :container) status :accepted end @@ -60,7 +61,7 @@ module API authorize_read_container_image! tags = Kaminari.paginate_array(repository.tags) - track_event('list_tags') + track_package_event('list_tags', :container) present paginate(tags), with: Entities::ContainerRegistry::Tag end @@ -89,7 +90,7 @@ module API declared_params.except(:repository_id).merge(container_expiration_policy: false)) # rubocop:enable CodeReuse/Worker - track_event('delete_tag_bulk') + track_package_event('delete_tag_bulk', :container) status :accepted end @@ -125,7 +126,7 @@ module API .execute(repository) if result[:status] == :success - track_event('delete_tag') + track_package_event('delete_tag', :container) status :ok else diff --git a/lib/api/pypi_packages.rb b/lib/api/pypi_packages.rb index c07db68f8a8..55cea075243 100644 --- a/lib/api/pypi_packages.rb +++ b/lib/api/pypi_packages.rb @@ -72,7 +72,7 @@ module API package = packages_finder(project).by_file_name_and_sha256(filename, params[:sha256]) package_file = ::Packages::PackageFileFinder.new(package, filename, with_file_name_like: false).execute - package_event('pull_package') + track_package_event('pull_package', :pypi) present_carrierwave_file!(package_file.file, supports_direct_download: true) end @@ -91,7 +91,7 @@ module API get 'simple/*package_name', format: :txt do authorize_read_package!(authorized_user_project) - package_event('list_package') + track_package_event('list_package', :pypi) packages = find_package_versions presenter = ::Packages::Pypi::PackagePresenter.new(packages, authorized_user_project) @@ -122,7 +122,7 @@ module API authorize_upload!(authorized_user_project) bad_request!('File is too large') if authorized_user_project.actual_limits.exceeded?(:pypi_max_file_size, params[:content].size) - package_event('push_package') + track_package_event('push_package', :pypi) ::Packages::Pypi::CreatePackageService .new(authorized_user_project, current_user, declared_params) diff --git a/lib/api/unleash.rb b/lib/api/unleash.rb new file mode 100644 index 00000000000..8db23c3aaec --- /dev/null +++ b/lib/api/unleash.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module API + class Unleash < Grape::API::Instance + include PaginationParams + + namespace :feature_flags do + resource :unleash, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do + params do + requires :project_id, type: String, desc: 'The ID of a project' + optional :instance_id, type: String, desc: 'The Instance ID of Unleash Client' + optional :app_name, type: String, desc: 'The Application Name of Unleash Client' + end + route_param :project_id do + before do + authorize_by_unleash_instance_id! + end + + get do + # not supported yet + status :ok + end + + desc 'Get a list of features (deprecated, v2 client support)' + get 'features' do + present :version, 1 + present :features, feature_flags, with: ::API::Entities::UnleashFeature + end + + desc 'Get a list of features' + get 'client/features' do + present :version, 1 + present :features, feature_flags, with: ::API::Entities::UnleashFeature + end + + post 'client/register' do + # not supported yet + status :ok + end + + post 'client/metrics' do + # not supported yet + status :ok + end + end + end + end + + helpers do + def project + @project ||= find_project(params[:project_id]) + end + + def unleash_instance_id + env['HTTP_UNLEASH_INSTANCEID'] || params[:instance_id] + end + + def unleash_app_name + env['HTTP_UNLEASH_APPNAME'] || params[:app_name] + end + + def authorize_by_unleash_instance_id! + unauthorized! unless Operations::FeatureFlagsClient + .find_for_project_and_token(project, unleash_instance_id) + end + + def feature_flags + return [] unless unleash_app_name.present? + + legacy_flags = Operations::FeatureFlagScope.for_unleash_client(project, unleash_app_name) + new_version_flags = Operations::FeatureFlag.for_unleash_client(project, unleash_app_name) + + legacy_flags + new_version_flags + end + end + end +end diff --git a/lib/gitlab/database/count/reltuples_count_strategy.rb b/lib/gitlab/database/count/reltuples_count_strategy.rb index e226ed7613a..89190320cf9 100644 --- a/lib/gitlab/database/count/reltuples_count_strategy.rb +++ b/lib/gitlab/database/count/reltuples_count_strategy.rb @@ -74,8 +74,9 @@ module Gitlab def get_statistics(table_names, check_statistics: true) time = 6.hours.ago - query = PgClass.joins("LEFT JOIN pg_stat_user_tables USING (relname)") + query = PgClass.joins("LEFT JOIN pg_stat_user_tables ON pg_stat_user_tables.relid = pg_class.oid") .where(relname: table_names) + .where('schemaname = current_schema()') .select('pg_class.relname AS table_name, reltuples::bigint AS estimate') if check_statistics diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 990b2cadb77..4e2e1eaf21c 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -994,10 +994,10 @@ into similar problems in the future (e.g. when new tables are created). def postgres_exists_by_name?(table, name) index_sql = <<~SQL SELECT COUNT(*) - FROM pg_index - JOIN pg_class i ON (indexrelid=i.oid) - JOIN pg_class t ON (indrelid=t.oid) - WHERE i.relname = '#{name}' AND t.relname = '#{table}' + FROM pg_catalog.pg_indexes + WHERE schemaname = #{connection.quote(current_schema)} + AND tablename = #{connection.quote(table)} + AND indexname = #{connection.quote(name)} SQL connection.select_value(index_sql).to_i > 0 @@ -1053,11 +1053,15 @@ into similar problems in the future (e.g. when new tables are created). # the table name in addition to using the constraint_name check_sql = <<~SQL SELECT COUNT(*) - FROM pg_constraint - JOIN pg_class ON pg_constraint.conrelid = pg_class.oid - WHERE pg_constraint.contype = 'c' - AND pg_constraint.conname = '#{constraint_name}' - AND pg_class.relname = '#{table}' + FROM pg_catalog.pg_constraint con + INNER JOIN pg_catalog.pg_class rel + ON rel.oid = con.conrelid + INNER JOIN pg_catalog.pg_namespace nsp + ON nsp.oid = con.connamespace + WHERE con.contype = 'c' + AND con.conname = #{connection.quote(constraint_name)} + AND nsp.nspname = #{connection.quote(current_schema)} + AND rel.relname = #{connection.quote(table)} SQL connection.select_value(check_sql) > 0 @@ -1284,8 +1288,9 @@ into similar problems in the future (e.g. when new tables are created). check_sql = <<~SQL SELECT c.is_nullable FROM information_schema.columns c - WHERE c.table_name = '#{table}' - AND c.column_name = '#{column}' + WHERE c.table_schema = #{connection.quote(current_schema)} + AND c.table_name = #{connection.quote(table)} + AND c.column_name = #{connection.quote(column)} SQL connection.select_value(check_sql) == 'YES' diff --git a/lib/gitlab/database/schema_helpers.rb b/lib/gitlab/database/schema_helpers.rb index bf8481e1a13..3d929c62933 100644 --- a/lib/gitlab/database/schema_helpers.rb +++ b/lib/gitlab/database/schema_helpers.rb @@ -32,11 +32,14 @@ module Gitlab def trigger_exists?(table_name, name) connection.select_value(<<~SQL) SELECT 1 - FROM pg_trigger - INNER JOIN pg_class - ON pg_trigger.tgrelid = pg_class.oid - WHERE pg_class.relname = '#{table_name}' - AND pg_trigger.tgname = '#{name}' + FROM pg_catalog.pg_trigger trgr + INNER JOIN pg_catalog.pg_class rel + ON trgr.tgrelid = rel.oid + INNER JOIN pg_catalog.pg_namespace nsp + ON nsp.oid = rel.relnamespace + WHERE nsp.nspname = #{connection.quote(current_schema)} + AND rel.relname = #{connection.quote(table_name)} + AND trgr.tgname = #{connection.quote(name)} SQL end diff --git a/lib/gitlab/redis/hll.rb b/lib/gitlab/redis/hll.rb index ff5754675e2..496018c88cb 100644 --- a/lib/gitlab/redis/hll.rb +++ b/lib/gitlab/redis/hll.rb @@ -7,11 +7,11 @@ module Gitlab KeyFormatError = Class.new(StandardError) def self.count(params) - self.new.count(params) + self.new.count(**params) end def self.add(params) - self.new.add(params) + self.new.add(**params) end def count(keys:) diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 4ea9afd7901..245621d20a8 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -61,6 +61,39 @@ module Gitlab )\z}xi.freeze end + def debian_package_name_regex + # See official parser + # https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/parsehelp.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n122 + # @debian_package_name_regex ||= %r{\A[a-z0-9][-+\._a-z0-9]*\z}i.freeze + # But we prefer a more strict version from Lintian + # https://salsa.debian.org/lintian/lintian/-/blob/5080c0068ffc4a9ddee92022a91d0c2ff53e56d1/lib/Lintian/Util.pm#L116 + @debian_package_name_regex ||= %r{\A[a-z0-9][-+\.a-z0-9]+\z}.freeze + end + + def debian_version_regex + # See official parser: https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/parsehelp.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n205 + @debian_version_regex ||= %r{ + \A(?: + (?:([0-9]{1,9}):)? (?# epoch) + ([0-9][0-9a-z\.+~-]*) (?# version) + (?:(-[0-0a-z\.+~]+))? (?# revision) + )\z}xi.freeze + end + + def debian_architecture_regex + # See official parser: https://git.dpkg.org/cgit/dpkg/dpkg.git/tree/lib/dpkg/arch.c?id=9e0c88ec09475f4d1addde9cdba1ad7849720356#n43 + # But we limit to lower case + @debian_architecture_regex ||= %r{\A[a-z0-9][-a-z0-9]*\z}.freeze + end + + def debian_distribution_regex + @debian_distribution_regex ||= %r{\A[a-z0-9][a-z0-9\.-]*\z}i.freeze + end + + def debian_component_regex + @debian_component_regex ||= %r{#{debian_distribution_regex}}.freeze + end + def unbounded_semver_regex # See the official regex: https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string diff --git a/locale/gitlab.pot b/locale/gitlab.pot index f7e0c185270..276a2a715c1 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -165,6 +165,11 @@ msgid_plural "%d days" msgstr[0] "" msgstr[1] "" +msgid "%d day until tags are automatically removed" +msgid_plural "%d days until tags are automatically removed" +msgstr[0] "" +msgstr[1] "" + msgid "%d error" msgid_plural "%d errors" msgstr[0] "" @@ -300,6 +305,11 @@ msgid_plural "%d tags" msgstr[0] "" msgstr[1] "" +msgid "%d tag per image name" +msgid_plural "%d tags per image name" +msgstr[0] "" +msgstr[1] "" + msgid "%d unassigned issue" msgid_plural "%d unassigned issues" msgstr[0] "" @@ -13918,6 +13928,9 @@ msgstr "" msgid "Invitation" msgstr "" +msgid "Invitation declined" +msgstr "" + msgid "Invite" msgstr "" @@ -28641,6 +28654,9 @@ msgstr "" msgid "We want to be sure it is you, please confirm you are not a robot." msgstr "" +msgid "We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders." +msgstr "" + msgid "We've found no vulnerabilities" msgstr "" @@ -29252,6 +29268,9 @@ msgstr "" msgid "You can notify the app / group or a project by sending them an email notification" msgstr "" +msgid "You can now close this window." +msgstr "" + msgid "You can now export your security dashboard to a CSV report." msgstr "" @@ -29501,6 +29520,9 @@ msgstr "" msgid "You reached %{usage_in_percent} of %{namespace_name}'s storage capacity (%{used_storage} of %{storage_limit})" msgstr "" +msgid "You successfully declined the invitation" +msgstr "" + msgid "You tried to fork %{link_to_the_project} but it failed for the following reason:" msgstr "" diff --git a/qa/qa/page/component/new_snippet.rb b/qa/qa/page/component/new_snippet.rb index d6957389e3d..ff9ece9ad10 100644 --- a/qa/qa/page/component/new_snippet.rb +++ b/qa/qa/page/component/new_snippet.rb @@ -24,18 +24,6 @@ module QA element :file_holder_container end - base.view 'app/views/shared/form_elements/_description.html.haml' do - element :issuable_form_description - end - - base.view 'app/views/shared/snippets/_form.html.haml' do - element :snippet_description_field - element :description_placeholder - element :snippet_title_field - element :file_name_field - element :submit_button - end - base.view 'app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue' do element :add_file_button end diff --git a/qa/qa/page/dashboard/snippet/edit.rb b/qa/qa/page/dashboard/snippet/edit.rb index 7802a680c25..31fc69a04cc 100644 --- a/qa/qa/page/dashboard/snippet/edit.rb +++ b/qa/qa/page/dashboard/snippet/edit.rb @@ -5,10 +5,6 @@ module QA module Dashboard module Snippet class Edit < Page::Base - view 'app/views/shared/snippets/_form.html.haml' do - element :submit_button - end - view 'app/assets/javascripts/snippets/components/edit.vue' do element :submit_button end diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb index 07299382230..d179e268748 100644 --- a/spec/controllers/groups/group_links_controller_spec.rb +++ b/spec/controllers/groups/group_links_controller_spec.rb @@ -136,10 +136,15 @@ RSpec.describe Groups::GroupLinksController do let(:expiry_date) { 1.month.from_now.to_date } subject do - post(:update, params: { group_id: shared_group, - id: link.id, - group_link: { group_access: Gitlab::Access::GUEST, - expires_at: expiry_date } }) + post( + :update, + params: { + group_id: shared_group, + id: link.id, + group_link: { group_access: Gitlab::Access::GUEST, expires_at: expiry_date } + }, + format: :json + ) end context 'when user has admin access to the shared group' do @@ -160,6 +165,26 @@ RSpec.describe Groups::GroupLinksController do expect(link.expires_at).to eq(expiry_date) end + context 'when `expires_at` is set' do + it 'returns correct json response' do + travel_to Time.now.utc.beginning_of_day + + subject + + expect(json_response).to eq({ "expires_in" => "about 1 month", "expires_soon" => false }) + end + end + + context 'when `expires_at` is not set' do + let(:expiry_date) { nil } + + it 'returns empty json response' do + subject + + expect(json_response).to be_empty + end + end + it 'updates project permissions' do expect { subject }.to change { group_member.can?(:create_release, project) }.from(true).to(false) end diff --git a/spec/controllers/groups/registry/repositories_controller_spec.rb b/spec/controllers/groups/registry/repositories_controller_spec.rb index ddac8fc5002..ae982b02a4f 100644 --- a/spec/controllers/groups/registry/repositories_controller_spec.rb +++ b/spec/controllers/groups/registry/repositories_controller_spec.rb @@ -87,7 +87,7 @@ RSpec.describe Groups::Registry::RepositoriesController do it_behaves_like 'with name parameter' - it_behaves_like 'a gitlab tracking event', described_class.name, 'list_repositories' + it_behaves_like 'a package tracking event', described_class.name, 'list_repositories' context 'with project in subgroup' do let_it_be(:test_group) { create(:group, parent: group ) } diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb index 762ef795f6e..3baadde46dc 100644 --- a/spec/controllers/projects/group_links_controller_spec.rb +++ b/spec/controllers/projects/group_links_controller_spec.rb @@ -3,10 +3,10 @@ require 'spec_helper' RSpec.describe Projects::GroupLinksController do - let(:group) { create(:group, :private) } - let(:group2) { create(:group, :private) } - let(:project) { create(:project, :private, group: group2) } - let(:user) { create(:user) } + let_it_be(:group) { create(:group, :private) } + let_it_be(:group2) { create(:group, :private) } + let_it_be(:project) { create(:project, :private, group: group2) } + let_it_be(:user) { create(:user) } before do project.add_maintainer(user) @@ -142,4 +142,47 @@ RSpec.describe Projects::GroupLinksController do end end end + + describe '#update' do + let_it_be(:link) do + create( + :project_group_link, + { + project: project, + group: group + } + ) + end + + let(:expiry_date) { 1.month.from_now.to_date } + + before do + travel_to Time.now.utc.beginning_of_day + + put( + :update, + params: { + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: link.id, + group_link: { group_access: Gitlab::Access::GUEST, expires_at: expiry_date } + }, + format: :json + ) + end + + context 'when `expires_at` is set' do + it 'returns correct json response' do + expect(json_response).to eq({ "expires_in" => "about 1 month", "expires_soon" => false }) + end + end + + context 'when `expires_at` is not set' do + let(:expiry_date) { nil } + + it 'returns empty json response' do + expect(json_response).to be_empty + end + end + end end diff --git a/spec/controllers/projects/registry/tags_controller_spec.rb b/spec/controllers/projects/registry/tags_controller_spec.rb index 6adee35b60a..59df9e78a3c 100644 --- a/spec/controllers/projects/registry/tags_controller_spec.rb +++ b/spec/controllers/projects/registry/tags_controller_spec.rb @@ -109,7 +109,7 @@ RSpec.describe Projects::Registry::TagsController do it 'tracks the event' do expect_delete_tags(%w[test.]) - expect(controller).to receive(:track_event).with(:delete_tag) + expect(controller).to receive(:track_event).with(:delete_tag, {}) destroy_tag('test.') end diff --git a/spec/factories/events.rb b/spec/factories/events.rb index ecbda5fbfd3..6c9f1ba0137 100644 --- a/spec/factories/events.rb +++ b/spec/factories/events.rb @@ -18,6 +18,7 @@ FactoryBot.define do trait(:destroyed) { action { :destroyed } } trait(:expired) { action { :expired } } trait(:archived) { action { :archived } } + trait(:approved) { action { :approved } } factory :closed_issue_event do action { :closed } @@ -55,6 +56,16 @@ FactoryBot.define do action { :created } target { design } end + + factory :project_created_event do + project factory: :project + action { :created } + end + + factory :project_imported_event do + project factory: [:project, :with_import_url] + action { :created } + end end factory :push_event, class: 'PushEvent' do diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index e3411e4f925..3b579f970cb 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -283,6 +283,12 @@ FactoryBot.define do end end + trait :with_import_url do + import_finished + + import_url { generate(:url) } + end + trait(:wiki_enabled) { wiki_access_level { ProjectFeature::ENABLED } } trait(:wiki_disabled) { wiki_access_level { ProjectFeature::DISABLED } } trait(:wiki_private) { wiki_access_level { ProjectFeature::PRIVATE } } diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb index e3bbbd4d73b..8a7d997d5ac 100644 --- a/spec/features/groups/members/manage_groups_spec.rb +++ b/spec/features/groups/members/manage_groups_spec.rb @@ -6,62 +6,115 @@ RSpec.describe 'Groups > Members > Manage groups', :js do include Select2Helper include Spec::Support::Helpers::Features::ListRowsHelpers - let(:user) { create(:user) } - let(:shared_with_group) { create(:group) } - let(:shared_group) { create(:group) } + let_it_be(:user) { create(:user) } before do stub_feature_flags(vue_group_members_list: false) - shared_group.add_owner(user) sign_in(user) end - it 'add group to group' do - visit group_group_members_path(shared_group) + context 'when group link does not exist' do + let_it_be(:group) { create(:group) } + let_it_be(:group_to_add) { create(:group) } - add_group(shared_with_group.id, 'Reporter') + before do + group.add_owner(user) + visit group_group_members_path(group) + end - click_groups_tab + it 'add group to group' do + add_group(group_to_add.id, 'Reporter') - page.within(first_row) do - expect(page).to have_content(shared_with_group.name) - expect(page).to have_content('Reporter') + click_groups_tab + + page.within(first_row) do + expect(page).to have_content(group_to_add.name) + expect(page).to have_content('Reporter') + end end end - it 'remove group from group' do - create(:group_group_link, shared_group: shared_group, - shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER) + context 'when group link exists' do + let_it_be(:shared_with_group) { create(:group) } + let_it_be(:shared_group) { create(:group) } - visit group_group_members_path(shared_group) + let(:additional_link_attrs) { {} } - click_groups_tab + let_it_be(:group_link, refind: true) do + create( + :group_group_link, + shared_group: shared_group, + shared_with_group: shared_with_group, + group_access: ::Gitlab::Access::DEVELOPER + ) + end - expect(page).to have_content(shared_with_group.name) + before do + travel_to Time.now.utc.beginning_of_day + group_link.update!(additional_link_attrs) - accept_confirm do - find(:css, '#tab-groups li', text: shared_with_group.name).find(:css, 'a.btn-remove').click + shared_group.add_owner(user) + visit group_group_members_path(shared_group) end - expect(page).not_to have_content(shared_with_group.name) - end + it 'remove group from group' do + click_groups_tab + + expect(page).to have_content(shared_with_group.name) + + accept_confirm do + find(:css, '#tab-groups li', text: shared_with_group.name).find(:css, 'a.btn-remove').click + end + + expect(page).not_to have_content(shared_with_group.name) + end - it 'update group to owner level' do - create(:group_group_link, shared_group: shared_group, - shared_with_group: shared_with_group, group_access: ::Gitlab::Access::DEVELOPER) + it 'update group to owner level' do + click_groups_tab - visit group_group_members_path(shared_group) + page.within(first_row) do + click_button('Developer') + click_link('Maintainer') - click_groups_tab + wait_for_requests - page.within(first_row) do - click_button('Developer') - click_link('Maintainer') + expect(page).to have_button('Maintainer') + end + end + + it 'updates expiry date' do + click_groups_tab + + expires_at_field = "member_expires_at_#{shared_with_group.id}" + fill_in "member_expires_at_#{shared_with_group.id}", with: 3.days.from_now.to_date + find_field(expires_at_field).native.send_keys :enter wait_for_requests - expect(page).to have_button('Maintainer') + page.within(find('li.group_member')) do + expect(page).to have_content('Expires in 3 days') + end + end + + context 'when expiry date is set' do + let(:additional_link_attrs) { { expires_at: 3.days.from_now.to_date } } + + it 'clears expiry date' do + click_groups_tab + + page.within(find('li.group_member')) do + expect(page).to have_content('Expires in 3 days') + + page.within(find('.js-edit-member-form')) do + find('.js-clear-input').click + end + + wait_for_requests + + expect(page).not_to have_content('Expires in') + end + end end end diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb index 3954de56eea..8a4c4cc1eca 100644 --- a/spec/features/invites_spec.rb +++ b/spec/features/invites_spec.rb @@ -81,10 +81,10 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do end end - context 'when inviting a user using their email address' do + context 'when inviting a user' do let(:new_user) { build_stubbed(:user) } let(:invite_email) { new_user.email } - let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email) } + let(:group_invite) { create(:group_member, :invited, group: group, invite_email: invite_email, created_by: owner) } let!(:project_invite) { create(:project_member, :invited, project: project, invite_email: invite_email) } context 'when user has not signed in yet' do @@ -210,30 +210,43 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do context 'when declining the invitation' do let(:send_email_confirmation) { true } - context 'when signed in' do - before do - sign_in(user) - visit invite_path(group_invite.raw_invite_token) + context 'as an existing user' do + let(:group_invite) { create(:group_member, user: user, group: group, created_by: owner) } + + context 'when signed in' do + before do + sign_in(user) + visit decline_invite_path(group_invite.raw_invite_token) + end + + it 'declines application and redirects to dashboard' do + expect(current_path).to eq(dashboard_projects_path) + expect(page).to have_content('You have declined the invitation to join group Owned.') + expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound + end end - it 'declines application and redirects to dashboard' do - page.click_link 'Decline' + context 'when signed out' do + before do + visit decline_invite_path(group_invite.raw_invite_token) + end - expect(current_path).to eq(dashboard_projects_path) - expect(page).to have_content('You have declined the invitation to join group Owned.') - expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound + it 'declines application and redirects to sign in page' do + expect(current_path).to eq(new_user_session_path) + expect(page).to have_content('You have declined the invitation to join group Owned.') + expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound + end end end - context 'when signed out' do + context 'as a non-existing user' do before do visit decline_invite_path(group_invite.raw_invite_token) end - it 'declines application and redirects to sign in page' do - expect(current_path).to eq(new_user_session_path) - - expect(page).to have_content('You have declined the invitation to join group Owned.') + it 'declines application and shows a decline page' do + expect(current_path).to eq(decline_invite_path(group_invite.raw_invite_token)) + expect(page).to have_content('You successfully declined the invitation') expect { group_invite.reload }.to raise_error ActiveRecord::RecordNotFound end end diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index 04339d20d77..b86a74469f7 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -192,7 +192,7 @@ RSpec.describe 'Gcp Cluster', :js, :do_not_mock_admin_mode do it 'user does not see offer after dismissing' do expect(page).to have_css('.gcp-signup-offer') - find('.gcp-signup-offer .close').click + find('.gcp-signup-offer .js-close').click wait_for_requests click_link 'Add Kubernetes cluster' diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb index 2ee6bc103e9..d652f6715db 100644 --- a/spec/features/projects/members/groups_with_access_list_spec.rb +++ b/spec/features/projects/members/groups_with_access_list_spec.rb @@ -3,20 +3,23 @@ require 'spec_helper' RSpec.describe 'Projects > Members > Groups with access list', :js do - let(:user) { create(:user) } - let(:group) { create(:group, :public) } - let(:project) { create(:project, :public) } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group, :public) } + let_it_be(:project) { create(:project, :public) } + + let(:additional_link_attrs) { {} } + let!(:group_link) { create(:project_group_link, project: project, group: group, **additional_link_attrs) } before do - project.add_maintainer(user) - @group_link = create(:project_group_link, project: project, group: group) + travel_to Time.now.utc.beginning_of_day + project.add_maintainer(user) sign_in(user) visit project_project_members_path(project) end it 'updates group access level' do - click_button @group_link.human_access + click_button group_link.human_access page.within '.dropdown-menu' do click_link 'Guest' @@ -30,14 +33,32 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do end it 'updates expiry date' do - tomorrow = Date.today + 3 + expires_at_field = "member_expires_at_#{group.id}" + fill_in expires_at_field, with: 3.days.from_now.to_date - fill_in "member_expires_at_#{group.id}", with: tomorrow.strftime("%F") - find('body').click + find_field(expires_at_field).native.send_keys :enter wait_for_requests page.within(find('li.group_member')) do - expect(page).to have_content('Expires in') + expect(page).to have_content('Expires in 3 days') + end + end + + context 'when link has expiry date set' do + let(:additional_link_attrs) { { expires_at: 3.days.from_now.to_date } } + + it 'clears expiry date' do + page.within(find('li.group_member')) do + expect(page).to have_content('Expires in 3 days') + + page.within(find('.js-edit-member-form')) do + find('.js-clear-input').click + end + + wait_for_requests + + expect(page).not_to have_content('Expires in') + end end end diff --git a/spec/fixtures/api/schemas/unleash/unleash.json b/spec/fixtures/api/schemas/unleash/unleash.json new file mode 100644 index 00000000000..6eaf316bb11 --- /dev/null +++ b/spec/fixtures/api/schemas/unleash/unleash.json @@ -0,0 +1,20 @@ +{ + "additionalProperties": false, + "properties": { + "features": { + "items": { + "$ref": "unleash_feature.json" + }, + "minItems": 0, + "type": "array" + }, + "version": { + "type": "integer" + } + }, + "required": [ + "version", + "features" + ], + "type": "object" +} diff --git a/spec/fixtures/api/schemas/unleash/unleash_feature.json b/spec/fixtures/api/schemas/unleash/unleash_feature.json new file mode 100644 index 00000000000..71d375a5371 --- /dev/null +++ b/spec/fixtures/api/schemas/unleash/unleash_feature.json @@ -0,0 +1,27 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "enabled", + "strategies" + ], + "properties": { + "name": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "strategies": { + "items": { + "$ref": "unleash_strategy.json" + }, + "minItems": 1, + "type": "array" + } + } +} diff --git a/spec/fixtures/api/schemas/unleash/unleash_strategy.json b/spec/fixtures/api/schemas/unleash/unleash_strategy.json new file mode 100644 index 00000000000..7b48038ad15 --- /dev/null +++ b/spec/fixtures/api/schemas/unleash/unleash_strategy.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "parameters": { + "type": "object", + "additionalProperties": false, + "properties": { + "groupId": { + "type": "string" + }, + "percentage": { + "type": "integer" + } + } + } + } +} diff --git a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap deleted file mode 100644 index 11393c89d06..00000000000 --- a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Registry Settings App renders 1`] = ` -<div> - <settings-form-stub /> -</div> -`; diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/registry/settings/components/registry_settings_app_spec.js index 9551ee72e51..01d6852e1e5 100644 --- a/spec/frontend/registry/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/registry/settings/components/registry_settings_app_spec.js @@ -1,28 +1,35 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; import component from '~/registry/settings/components/registry_settings_app.vue'; +import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql'; import SettingsForm from '~/registry/settings/components/settings_form.vue'; -import { createStore } from '~/registry/settings/store/'; -import { SET_SETTINGS, SET_INITIAL_STATE } from '~/registry/settings/store/mutation_types'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/registry/shared/constants'; import { UNAVAILABLE_FEATURE_INTRO_TEXT, UNAVAILABLE_USER_FEATURE_TEXT, } from '~/registry/settings/constants'; -import { stringifiedFormOptions } from '../../shared/mock_data'; +import { expirationPolicyPayload } from '../mock_data'; + +const localVue = createLocalVue(); describe('Registry Settings App', () => { let wrapper; - let store; + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + isAdmin: false, + adminSettingsPath: 'settingsPath', + enableHistoricEntries: false, + }; const findSettingsComponent = () => wrapper.find(SettingsForm); const findAlert = () => wrapper.find(GlAlert); - const mountComponent = ({ dispatchMock = 'mockResolvedValue' } = {}) => { - const dispatchSpy = jest.spyOn(store, 'dispatch'); - dispatchSpy[dispatchMock](); - + const mountComponent = (provide = defaultProvidedValues, config) => { wrapper = shallowMount(component, { stubs: { GlSprintf, @@ -32,71 +39,72 @@ describe('Registry Settings App', () => { show: jest.fn(), }, }, - store, + provide, + ...config, }); }; - beforeEach(() => { - store = createStore(); - }); + const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [[expirationPolicyQuery, resolver]]; + + fakeApollo = createMockApollo(requestHandlers); + mountComponent(provide, { + localVue, + apolloProvider: fakeApollo, + }); + + return requestHandlers.map(request => request[1]); + }; afterEach(() => { wrapper.destroy(); }); - it('renders', () => { - store.commit(SET_SETTINGS, { foo: 'bar' }); - mountComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('call the store function to load the data on mount', () => { - mountComponent(); - expect(store.dispatch).toHaveBeenCalledWith('fetchSettings'); - }); + it('renders the setting form', async () => { + const requests = mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()), + }); + await Promise.all(requests); - it('renders the setting form', () => { - store.commit(SET_SETTINGS, { foo: 'bar' }); - mountComponent(); expect(findSettingsComponent().exists()).toBe(true); }); describe('the form is disabled', () => { - beforeEach(() => { - store.commit(SET_SETTINGS, undefined); + it('the form is hidden', () => { mountComponent(); - }); - it('the form is hidden', () => { expect(findSettingsComponent().exists()).toBe(false); }); it('shows an alert', () => { + mountComponent(); + const text = findAlert().text(); expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT); expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT); }); describe('an admin is visiting the page', () => { - beforeEach(() => { - store.commit(SET_INITIAL_STATE, { - ...stringifiedFormOptions, - isAdmin: true, - adminSettingsPath: 'foo', - }); - }); - it('shows the admin part of the alert message', () => { + mountComponent({ ...defaultProvidedValues, isAdmin: true }); + const sprintf = findAlert().find(GlSprintf); expect(sprintf.text()).toBe('administration settings'); - expect(sprintf.find(GlLink).attributes('href')).toBe('foo'); + expect(sprintf.find(GlLink).attributes('href')).toBe( + defaultProvidedValues.adminSettingsPath, + ); }); }); }); describe('fetchSettingsError', () => { beforeEach(() => { - mountComponent({ dispatchMock: 'mockRejectedValue' }); + const requests = mountComponentWithApollo({ + resolver: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + return Promise.all(requests); }); it('the form is hidden', () => { diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 6f9518808db..77fd71a22fc 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -1,30 +1,37 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import Tracking from '~/tracking'; import component from '~/registry/settings/components/settings_form.vue'; import expirationPolicyFields from '~/registry/shared/components/expiration_policy_fields.vue'; -import { createStore } from '~/registry/settings/store/'; +import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.graphql'; +import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql'; import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '~/registry/shared/constants'; -import { stringifiedFormOptions } from '../../shared/mock_data'; +import { GlCard, GlLoadingIcon } from '../../shared/stubs'; +import { expirationPolicyPayload, expirationPolicyMutationPayload } from '../mock_data'; + +const localVue = createLocalVue(); describe('Settings Form', () => { let wrapper; - let store; - let dispatchSpy; - - const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' }; - const GlCard = { - name: 'gl-card-stub', - template: ` - <div> - <slot name="header"></slot> - <slot></slot> - <slot name="footer"></slot> - </div> - `, + let fakeApollo; + + const defaultProvidedValues = { + projectPath: 'path', + }; + + const { + data: { + project: { containerExpirationPolicy }, + }, + } = expirationPolicyPayload(); + + const defaultProps = { + value: { ...containerExpirationPolicy }, }; const trackingPayload = { @@ -35,14 +42,21 @@ describe('Settings Form', () => { const findFields = () => wrapper.find(expirationPolicyFields); const findCancelButton = () => wrapper.find({ ref: 'cancel-button' }); const findSaveButton = () => wrapper.find({ ref: 'save-button' }); - const findLoadingIcon = (parent = wrapper) => parent.find(GlLoadingIcon); - const mountComponent = (data = {}) => { + const mountComponent = ({ + props = defaultProps, + data, + config, + provide = defaultProvidedValues, + mocks, + } = {}) => { wrapper = shallowMount(component, { stubs: { GlCard, GlLoadingIcon, }, + propsData: { ...props }, + provide, data() { return { ...data, @@ -52,15 +66,42 @@ describe('Settings Form', () => { $toast: { show: jest.fn(), }, + ...mocks, }, - store, + ...config, }); }; + const mountComponentWithApollo = ({ provide = defaultProvidedValues, resolver } = {}) => { + localVue.use(VueApollo); + + const requestHandlers = [ + [updateContainerExpirationPolicyMutation, resolver], + [expirationPolicyQuery, jest.fn().mockResolvedValue(expirationPolicyPayload())], + ]; + + fakeApollo = createMockApollo(requestHandlers); + + fakeApollo.defaultClient.cache.writeQuery({ + query: expirationPolicyQuery, + variables: { + projectPath: provide.projectPath, + }, + ...expirationPolicyPayload(), + }); + + mountComponent({ + provide, + config: { + localVue, + apolloProvider: fakeApollo, + }, + }); + + return requestHandlers.map(resolvers => resolvers[1]); + }; + beforeEach(() => { - store = createStore(); - store.dispatch('setInitialState', stringifiedFormOptions); - dispatchSpy = jest.spyOn(store, 'dispatch'); jest.spyOn(Tracking, 'event'); }); @@ -72,12 +113,12 @@ describe('Settings Form', () => { it('v-model change update the settings property', () => { mountComponent(); findFields().vm.$emit('input', { newValue: 'foo' }); - expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' }); + expect(wrapper.emitted('input')).toEqual([['foo']]); }); it('v-model change update the api error property', () => { const apiErrors = { baz: 'bar' }; - mountComponent({ apiErrors }); + mountComponent({ data: { apiErrors } }); expect(findFields().props('apiErrors')).toEqual(apiErrors); findFields().vm.$emit('input', { newValue: 'foo', modified: 'baz' }); expect(findFields().props('apiErrors')).toEqual({}); @@ -85,19 +126,14 @@ describe('Settings Form', () => { }); describe('form', () => { - let form; - beforeEach(() => { - mountComponent(); - form = findForm(); - dispatchSpy.mockReturnValue(); - }); - describe('form reset event', () => { beforeEach(() => { - form.trigger('reset'); + mountComponent(); + + findForm().trigger('reset'); }); it('calls the appropriate function', () => { - expect(dispatchSpy).toHaveBeenCalledWith('resetSettings'); + expect(wrapper.emitted('reset')).toEqual([[]]); }); it('tracks the reset event', () => { @@ -108,54 +144,96 @@ describe('Settings Form', () => { describe('form submit event ', () => { it('save has type submit', () => { mountComponent(); + expect(findSaveButton().attributes('type')).toBe('submit'); }); - it('dispatches the saveSettings action', () => { - dispatchSpy.mockResolvedValue(); - form.trigger('submit'); - expect(dispatchSpy).toHaveBeenCalledWith('saveSettings'); + it('dispatches the correct apollo mutation', async () => { + const [expirationPolicyMutationResolver] = mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + await expirationPolicyMutationResolver(); + expect(expirationPolicyMutationResolver).toHaveBeenCalled(); }); it('tracks the submit event', () => { - dispatchSpy.mockResolvedValue(); - form.trigger('submit'); + mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload); }); it('show a success toast when submit succeed', async () => { - dispatchSpy.mockResolvedValue(); - form.trigger('submit'); - await waitForPromises(); + const handlers = mountComponentWithApollo({ + resolver: jest.fn().mockResolvedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + await Promise.all(handlers); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, { type: 'success', }); }); describe('when submit fails', () => { - it('shows an error', async () => { - dispatchSpy.mockRejectedValue({ response: {} }); - form.trigger('submit'); - await waitForPromises(); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, { - type: 'error', + describe('user recoverable errors', () => { + it('when there is an error is shown in a toast', async () => { + const handlers = mountComponentWithApollo({ + resolver: jest + .fn() + .mockResolvedValue(expirationPolicyMutationPayload({ errors: ['foo'] })), + }); + + findForm().trigger('submit'); + await Promise.all(handlers); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('foo', { + type: 'error', + }); }); }); + describe('global errors', () => { + it('shows an error', async () => { + const handlers = mountComponentWithApollo({ + resolver: jest.fn().mockRejectedValue(expirationPolicyMutationPayload()), + }); + + findForm().trigger('submit'); + await Promise.all(handlers); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); - it('parses the error messages', async () => { - dispatchSpy.mockRejectedValue({ - response: { - data: { - message: { - foo: 'bar', - 'container_expiration_policy.name': ['baz'], + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, { + type: 'error', + }); + }); + + it('parses the error messages', async () => { + const mutate = jest.fn().mockRejectedValue({ + graphQLErrors: [ + { + extensions: { + problems: [{ path: ['name'], message: 'baz' }], + }, }, - }, - }, + ], + }); + mountComponent({ mocks: { $apollo: { mutate } } }); + + findForm().trigger('submit'); + await waitForPromises(); + await wrapper.vm.$nextTick(); + + expect(findFields().props('apiErrors')).toEqual({ name: 'baz' }); }); - form.trigger('submit'); - await waitForPromises(); - expect(findFields().props('apiErrors')).toEqual({ name: 'baz' }); }); }); }); @@ -163,51 +241,78 @@ describe('Settings Form', () => { describe('form actions', () => { describe('cancel button', () => { - beforeEach(() => { - store.commit('SET_SETTINGS', { foo: 'bar' }); + it('has type reset', () => { mountComponent(); - }); - it('has type reset', () => { expect(findCancelButton().attributes('type')).toBe('reset'); }); - it('is disabled when isEdited is false', () => - wrapper.vm.$nextTick().then(() => { - expect(findCancelButton().attributes('disabled')).toBe('true'); - })); - - it('is disabled isLoading is true', () => { - store.commit('TOGGLE_LOADING'); - store.commit('UPDATE_SETTINGS', { settings: { foo: 'baz' } }); - return wrapper.vm.$nextTick().then(() => { - expect(findCancelButton().attributes('disabled')).toBe('true'); - store.commit('TOGGLE_LOADING'); - }); - }); + it.each` + isLoading | isEdited | mutationLoading | isDisabled + ${true} | ${true} | ${true} | ${true} + ${false} | ${true} | ${true} | ${true} + ${false} | ${false} | ${true} | ${true} + ${true} | ${false} | ${false} | ${true} + ${false} | ${false} | ${false} | ${true} + ${false} | ${true} | ${false} | ${false} + `( + 'when isLoading is $isLoading and isEdited is $isEdited and mutationLoading is $mutationLoading is $isDisabled that the is disabled', + ({ isEdited, isLoading, mutationLoading, isDisabled }) => { + mountComponent({ + props: { ...defaultProps, isEdited, isLoading }, + data: { mutationLoading }, + }); - it('is enabled when isLoading is false and isEdited is true', () => { - store.commit('UPDATE_SETTINGS', { settings: { foo: 'baz' } }); - return wrapper.vm.$nextTick().then(() => { - expect(findCancelButton().attributes('disabled')).toBe(undefined); - }); - }); + const expectation = isDisabled ? 'true' : undefined; + expect(findCancelButton().attributes('disabled')).toBe(expectation); + }, + ); }); - describe('when isLoading is true', () => { - beforeEach(() => { - store.commit('TOGGLE_LOADING'); + describe('submit button', () => { + it('has type submit', () => { mountComponent(); - }); - afterEach(() => { - store.commit('TOGGLE_LOADING'); - }); - it('submit button is disabled and shows a spinner', () => { - const button = findSaveButton(); - expect(button.attributes('disabled')).toBeTruthy(); - expect(findLoadingIcon(button).exists()).toBe(true); + expect(findSaveButton().attributes('type')).toBe('submit'); }); + it.each` + isLoading | fieldsAreValid | mutationLoading | isDisabled + ${true} | ${true} | ${true} | ${true} + ${false} | ${true} | ${true} | ${true} + ${false} | ${false} | ${true} | ${true} + ${true} | ${false} | ${false} | ${true} + ${false} | ${false} | ${false} | ${true} + ${false} | ${true} | ${false} | ${false} + `( + 'when isLoading is $isLoading and fieldsAreValid is $fieldsAreValid and mutationLoading is $mutationLoading is $isDisabled that the is disabled', + ({ fieldsAreValid, isLoading, mutationLoading, isDisabled }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading, fieldsAreValid }, + }); + + const expectation = isDisabled ? 'true' : undefined; + expect(findSaveButton().attributes('disabled')).toBe(expectation); + }, + ); + + it.each` + isLoading | mutationLoading | showLoading + ${true} | ${true} | ${true} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${false} | ${false} | ${false} + `( + 'when isLoading is $isLoading and mutationLoading is $mutationLoading is $showLoading that the loading icon is shown', + ({ isLoading, mutationLoading, showLoading }) => { + mountComponent({ + props: { ...defaultProps, isLoading }, + data: { mutationLoading }, + }); + + expect(findSaveButton().props('loading')).toBe(showLoading); + }, + ); }); }); }); diff --git a/spec/frontend/registry/settings/mock_data.js b/spec/frontend/registry/settings/mock_data.js new file mode 100644 index 00000000000..6a936785b7c --- /dev/null +++ b/spec/frontend/registry/settings/mock_data.js @@ -0,0 +1,32 @@ +export const expirationPolicyPayload = override => ({ + data: { + project: { + containerExpirationPolicy: { + cadence: 'EVERY_DAY', + enabled: true, + keepN: 'TEN_TAGS', + nameRegex: 'asdasdssssdfdf', + nameRegexKeep: 'sss', + olderThan: 'FOURTEEN_DAYS', + ...override, + }, + }, + }, +}); + +export const expirationPolicyMutationPayload = ({ override, errors = [] } = {}) => ({ + data: { + updateContainerExpirationPolicy: { + containerExpirationPolicy: { + cadence: 'EVERY_DAY', + enabled: true, + keepN: 'TEN_TAGS', + nameRegex: 'asdasdssssdfdf', + nameRegexKeep: 'sss', + olderThan: 'FOURTEEN_DAYS', + ...override, + }, + errors, + }, + }, +}); diff --git a/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap index 1c52249fbf7..032007bba51 100644 --- a/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap @@ -76,25 +76,25 @@ Array [ Object { "default": false, "key": "SEVEN_DAYS", - "label": "7 day until tags are automatically removed", + "label": "7 days until tags are automatically removed", "variable": 7, }, Object { "default": false, "key": "FOURTEEN_DAYS", - "label": "14 day until tags are automatically removed", + "label": "14 days until tags are automatically removed", "variable": 14, }, Object { "default": false, "key": "THIRTY_DAYS", - "label": "30 day until tags are automatically removed", + "label": "30 days until tags are automatically removed", "variable": 30, }, Object { "default": true, "key": "NINETY_DAYS", - "label": "90 day until tags are automatically removed", + "label": "90 days until tags are automatically removed", "variable": 90, }, ] diff --git a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js index ee765ffd1c0..bee9bca5369 100644 --- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js +++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js @@ -40,13 +40,13 @@ describe('Expiration Policy Form', () => { }); describe.each` - elementName | modelName | value | disabledByToggle - ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'} - ${'interval'} | ${'older_than'} | ${'foo'} | ${'disabled'} - ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'} - ${'latest'} | ${'keep_n'} | ${'foo'} | ${'disabled'} - ${'name-matching'} | ${'name_regex'} | ${'foo'} | ${'disabled'} - ${'keep-name'} | ${'name_regex_keep'} | ${'bar'} | ${'disabled'} + elementName | modelName | value | disabledByToggle + ${'toggle'} | ${'enabled'} | ${true} | ${'not disabled'} + ${'interval'} | ${'olderThan'} | ${'foo'} | ${'disabled'} + ${'schedule'} | ${'cadence'} | ${'foo'} | ${'disabled'} + ${'latest'} | ${'keepN'} | ${'foo'} | ${'disabled'} + ${'name-matching'} | ${'nameRegex'} | ${'foo'} | ${'disabled'} + ${'keep-name'} | ${'nameRegexKeep'} | ${'bar'} | ${'disabled'} `( `${FORM_ELEMENTS_ID_PREFIX}-$elementName form element`, ({ elementName, modelName, value, disabledByToggle }) => { @@ -128,9 +128,9 @@ describe('Expiration Policy Form', () => { }); describe.each` - modelName | elementName - ${'name_regex'} | ${'name-matching'} - ${'name_regex_keep'} | ${'keep-name'} + modelName | elementName + ${'nameRegex'} | ${'name-matching'} + ${'nameRegexKeep'} | ${'keep-name'} `('regex textarea validation', ({ modelName, elementName }) => { const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); diff --git a/spec/frontend/registry/shared/stubs.js b/spec/frontend/registry/shared/stubs.js new file mode 100644 index 00000000000..f6b88d70e49 --- /dev/null +++ b/spec/frontend/registry/shared/stubs.js @@ -0,0 +1,11 @@ +export const GlLoadingIcon = { name: 'gl-loading-icon-stub', template: '<svg></svg>' }; +export const GlCard = { + name: 'gl-card-stub', + template: ` +<div> + <slot name="header"></slot> + <slot></slot> + <slot name="footer"></slot> +</div> +`, +}; diff --git a/spec/frontend/registry/shared/utils_spec.js b/spec/frontend/registry/shared/utils_spec.js index a6133fa96d6..edb0c3261be 100644 --- a/spec/frontend/registry/shared/utils_spec.js +++ b/spec/frontend/registry/shared/utils_spec.js @@ -1,10 +1,20 @@ -import { formOptionsGenerator, optionLabelGenerator } from '~/registry/shared/utils'; +import { + formOptionsGenerator, + optionLabelGenerator, + olderThanTranslationGenerator, +} from '~/registry/shared/utils'; describe('Utils', () => { describe('optionLabelGenerator', () => { it('returns an array with a set label', () => { - const result = optionLabelGenerator([{ variable: 1 }, { variable: 2 }], '%d day', '%d days'); - expect(result).toEqual([{ variable: 1, label: '1 day' }, { variable: 2, label: '2 days' }]); + const result = optionLabelGenerator( + [{ variable: 1 }, { variable: 2 }], + olderThanTranslationGenerator, + ); + expect(result).toEqual([ + { variable: 1, label: '1 day until tags are automatically removed' }, + { variable: 2, label: '2 days until tags are automatically removed' }, + ]); }); }); diff --git a/spec/graphql/resolvers/terraform/states_resolver_spec.rb b/spec/graphql/resolvers/terraform/states_resolver_spec.rb new file mode 100644 index 00000000000..64b515528cd --- /dev/null +++ b/spec/graphql/resolvers/terraform/states_resolver_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Terraform::StatesResolver do + include GraphqlHelpers + + it { expect(described_class.type).to eq(Types::Terraform::StateType) } + it { expect(described_class.null).to be_truthy } + + describe '#resolve' do + let_it_be(:project) { create(:project) } + + let_it_be(:production_state) { create(:terraform_state, project: project) } + let_it_be(:staging_state) { create(:terraform_state, project: project) } + let_it_be(:other_state) { create(:terraform_state) } + + let(:ctx) { Hash(current_user: user) } + let(:user) { create(:user, developer_projects: [project]) } + + subject { resolve(described_class, obj: project, ctx: ctx) } + + it 'returns states associated with the agent' do + expect(subject).to contain_exactly(production_state, staging_state) + end + + context 'user does not have permission' do + let(:user) { create(:user) } + + it { is_expected.to be_empty } + end + end +end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index 44a89bfa35e..8aa9e1138cc 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -27,7 +27,7 @@ RSpec.describe GitlabSchema.types['Project'] do environment boards jira_import_status jira_imports services releases release alert_management_alerts alert_management_alert alert_management_alert_status_counts container_expiration_policy service_desk_enabled service_desk_address - issue_status_counts + issue_status_counts terraform_states ] expect(described_class).to include_graphql_fields(*expected_fields) @@ -154,5 +154,12 @@ RSpec.describe GitlabSchema.types['Project'] do it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) } end + describe 'terraform states field' do + subject { described_class.fields['terraformStates'] } + + it { is_expected.to have_graphql_type(Types::Terraform::StateType.connection_type) } + it { is_expected.to have_graphql_resolver(Resolvers::Terraform::StatesResolver) } + end + it_behaves_like 'a GraphQL type with labels' end diff --git a/spec/graphql/types/terraform/state_type_spec.rb b/spec/graphql/types/terraform/state_type_spec.rb new file mode 100644 index 00000000000..51508208046 --- /dev/null +++ b/spec/graphql/types/terraform/state_type_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['TerraformState'] do + it { expect(described_class.graphql_name).to eq('TerraformState') } + it { expect(described_class).to require_graphql_authorizations(:read_terraform_state) } + + describe 'fields' do + let(:fields) { %i[id name locked_by_user locked_at created_at updated_at] } + + it { expect(described_class).to have_graphql_fields(fields) } + + it { expect(described_class.fields['id'].type).to be_non_null } + it { expect(described_class.fields['name'].type).to be_non_null } + it { expect(described_class.fields['lockedByUser'].type).not_to be_non_null } + it { expect(described_class.fields['lockedAt'].type).not_to be_non_null } + it { expect(described_class.fields['createdAt'].type).to be_non_null } + it { expect(described_class.fields['updatedAt'].type).to be_non_null } + end +end diff --git a/spec/helpers/feature_flags_helper_spec.rb b/spec/helpers/feature_flags_helper_spec.rb new file mode 100644 index 00000000000..9a080736595 --- /dev/null +++ b/spec/helpers/feature_flags_helper_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe FeatureFlagsHelper do + let_it_be(:project) { create(:project) } + let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) } + let_it_be(:user) { create(:user) } + + describe '#unleash_api_url' do + subject { helper.unleash_api_url(project) } + + it { is_expected.to end_with("/api/v4/feature_flags/unleash/#{project.id}") } + end + + describe '#unleash_api_instance_id' do + subject { helper.unleash_api_instance_id(project) } + + it { is_expected.not_to be_empty } + end +end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index fc51fde1db8..727ad243349 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -1458,15 +1458,32 @@ RSpec.describe Gitlab::Database::MigrationHelpers do ) end - after do - 'DROP INDEX IF EXISTS test_index;' - end - it 'returns true if an index exists' do expect(model.index_exists_by_name?(:projects, 'test_index')) .to be_truthy end end + + context 'when an index exists for a table with the same name in another schema' do + before do + ActiveRecord::Base.connection.execute( + 'CREATE SCHEMA new_test_schema' + ) + + ActiveRecord::Base.connection.execute( + 'CREATE TABLE new_test_schema.projects (id integer, name character varying)' + ) + + ActiveRecord::Base.connection.execute( + 'CREATE INDEX test_index_on_name ON new_test_schema.projects (LOWER(name));' + ) + end + + it 'returns false if the index does not exist in the current schema' do + expect(model.index_exists_by_name?(:projects, 'test_index_on_name')) + .to be_falsy + end + end end describe '#create_or_update_plan_limit' do @@ -1921,11 +1938,17 @@ RSpec.describe Gitlab::Database::MigrationHelpers do ActiveRecord::Base.connection.execute( 'ALTER TABLE projects ADD CONSTRAINT check_1 CHECK (char_length(path) <= 5) NOT VALID' ) - end - after do ActiveRecord::Base.connection.execute( - 'ALTER TABLE projects DROP CONSTRAINT IF EXISTS check_1' + 'CREATE SCHEMA new_test_schema' + ) + + ActiveRecord::Base.connection.execute( + 'CREATE TABLE new_test_schema.projects (id integer, name character varying)' + ) + + ActiveRecord::Base.connection.execute( + 'ALTER TABLE new_test_schema.projects ADD CONSTRAINT check_2 CHECK (char_length(name) <= 5)' ) end @@ -1943,6 +1966,11 @@ RSpec.describe Gitlab::Database::MigrationHelpers do expect(model.check_constraint_exists?(:users, 'check_1')) .to be_falsy end + + it 'returns false if a constraint with the same name exists for the same table in another schema' do + expect(model.check_constraint_exists?(:projects, 'check_2')) + .to be_falsy + end end describe '#add_check_constraint' do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 57d9c12bad5..204fbff0c08 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -537,6 +537,7 @@ project: - vulnerability_historical_statistics - product_analytics_events - pipeline_artifacts +- terraform_states award_emoji: - awardable - user diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb index 1edd7fb0600..d1fde517488 100644 --- a/spec/lib/gitlab/regex_spec.rb +++ b/spec/lib/gitlab/regex_spec.rb @@ -414,6 +414,140 @@ RSpec.describe Gitlab::Regex do it { is_expected.not_to match('%2e%2e%2f1.2.3') } end + describe '.debian_package_name_regex' do + subject { described_class.debian_package_name_regex } + + it { is_expected.to match('0ad') } + it { is_expected.to match('g++') } + it { is_expected.to match('lua5.1') } + it { is_expected.to match('samba') } + + # may not be empty string + it { is_expected.not_to match('') } + # must start with an alphanumeric character + it { is_expected.not_to match('-a') } + it { is_expected.not_to match('+a') } + it { is_expected.not_to match('.a') } + it { is_expected.not_to match('_a') } + # only letters, digits and characters '-+._' + it { is_expected.not_to match('a~') } + it { is_expected.not_to match('aé') } + + # More strict Lintian regex + # at least 2 chars + it { is_expected.not_to match('a') } + # lowercase only + it { is_expected.not_to match('Aa') } + it { is_expected.not_to match('aA') } + # No underscore + it { is_expected.not_to match('a_b') } + end + + describe '.debian_version_regex' do + subject { described_class.debian_version_regex } + + context 'valid versions' do + it { is_expected.to match('1.0') } + it { is_expected.to match('1.0~alpha1') } + it { is_expected.to match('2:4.9.5+dfsg-5+deb10u1') } + end + + context 'dpkg errors' do + # version string is empty + it { is_expected.not_to match('') } + # version string has embedded spaces + it { is_expected.not_to match('1 0') } + # epoch in version is empty + it { is_expected.not_to match(':1.0') } + # epoch in version is not number + it { is_expected.not_to match('a:1.0') } + # epoch in version is negative + it { is_expected.not_to match('-1:1.0') } + # epoch in version is too big + it { is_expected.not_to match('9999999999:1.0') } + # nothing after colon in version number + it { is_expected.not_to match('2:') } + # revision number is empty + # Note: we are less strict here + # it { is_expected.not_to match('1.0-') } + # version number is empty + it { is_expected.not_to match('-1') } + it { is_expected.not_to match('2:-1') } + end + + context 'dpkg warnings' do + # version number does not start with digit + it { is_expected.not_to match('a') } + it { is_expected.not_to match('a1.0') } + # invalid character in version number + it { is_expected.not_to match('1_0') } + # invalid character in revision number + it { is_expected.not_to match('1.0-1_0') } + end + + context 'dpkg accepts' do + # dpkg accepts leading or trailing space + it { is_expected.not_to match(' 1.0') } + it { is_expected.not_to match('1.0 ') } + # dpkg accepts multiple colons + it { is_expected.not_to match('1:2:3') } + end + end + + describe '.debian_architecture_regex' do + subject { described_class.debian_architecture_regex } + + it { is_expected.to match('amd64') } + it { is_expected.to match('kfreebsd-i386') } + + # may not be empty string + it { is_expected.not_to match('') } + # must start with an alphanumeric + it { is_expected.not_to match('-a') } + it { is_expected.not_to match('+a') } + it { is_expected.not_to match('.a') } + it { is_expected.not_to match('_a') } + # only letters, digits and characters '-' + it { is_expected.not_to match('a+b') } + it { is_expected.not_to match('a.b') } + it { is_expected.not_to match('a_b') } + it { is_expected.not_to match('a~') } + it { is_expected.not_to match('aé') } + + # More strict + # Enforce lowercase + it { is_expected.not_to match('AMD64') } + it { is_expected.not_to match('Amd64') } + it { is_expected.not_to match('aMD64') } + end + + describe '.debian_distribution_regex' do + subject { described_class.debian_distribution_regex } + + it { is_expected.to match('buster') } + it { is_expected.to match('buster-updates') } + it { is_expected.to match('Debian10.5') } + + # Do not allow slash, even if this exists in the wild + it { is_expected.not_to match('jessie/updates') } + + # Do not allow Unicode + it { is_expected.not_to match('hé') } + end + + describe '.debian_component_regex' do + subject { described_class.debian_component_regex } + + it { is_expected.to match('main') } + it { is_expected.to match('non-free') } + + # Do not allow slash + it { is_expected.not_to match('non/free') } + + # Do not allow Unicode + it { is_expected.not_to match('hé') } + end + describe '.semver_regex' do subject { described_class.semver_regex } diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index bafcb7a3741..47492715c11 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -918,6 +918,56 @@ RSpec.describe Event do expect(destroyed).to eq('deleted') expect(archived).to eq('archived') end + + it 'handles correct push_action' do + project = create(:project) + user = create(:user) + project.add_developer(user) + push_event = create_push_event(project, user) + + expect(push_event.push_action?).to be true + expect(push_event.action_name).to eq('pushed to') + end + + context 'handles correct base actions' do + using RSpec::Parameterized::TableSyntax + + where(:trait, :action_name) do + :created | 'created' + :updated | 'opened' + :closed | 'closed' + :reopened | 'opened' + :commented | 'commented on' + :merged | 'accepted' + :joined | 'joined' + :left | 'left' + :destroyed | 'destroyed' + :expired | 'removed due to membership expiration from' + :approved | 'approved' + end + + with_them do + it 'with correct name and method' do + event = build(:event, trait) + + expect(event.action_name).to eq(action_name) + end + end + end + + context 'for created_project_action?' do + it 'returns created for created event' do + action = build(:project_created_event) + + expect(action.action_name).to eq('created') + end + + it 'returns imported for imported event' do + action = build(:project_imported_event) + + expect(action.action_name).to eq('imported') + end + end end def create_push_event(project, user) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index fe971832695..70bb590d885 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -123,6 +123,7 @@ RSpec.describe Project do it { is_expected.to have_many(:packages).class_name('Packages::Package') } it { is_expected.to have_many(:package_files).class_name('Packages::PackageFile') } it { is_expected.to have_many(:pipeline_artifacts) } + it { is_expected.to have_many(:terraform_states).class_name('Terraform::State').inverse_of(:project) } # GitLab Pages it { is_expected.to have_many(:pages_domains) } diff --git a/spec/models/terraform/state_spec.rb b/spec/models/terraform/state_spec.rb index 5c3958da7bf..1d99d103bb8 100644 --- a/spec/models/terraform/state_spec.rb +++ b/spec/models/terraform/state_spec.rb @@ -18,6 +18,23 @@ RSpec.describe Terraform::State do stub_terraform_state_object_storage end + describe 'scopes' do + describe '.ordered_by_name' do + let_it_be(:project) { create(:project) } + let(:names) { %w(state_d state_b state_a state_c) } + + subject { described_class.ordered_by_name } + + before do + names.each do |name| + create(:terraform_state, project: project, name: name) + end + end + + it { expect(subject.map(&:name)).to eq(names.sort) } + end + end + describe '#file' do context 'when a file exists' do it 'does not use the default file' do diff --git a/spec/policies/terraform/state_policy_spec.rb b/spec/policies/terraform/state_policy_spec.rb new file mode 100644 index 00000000000..82152920997 --- /dev/null +++ b/spec/policies/terraform/state_policy_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Terraform::StatePolicy do + let_it_be(:project) { create(:project) } + let_it_be(:terraform_state) { create(:terraform_state, project: project)} + + subject { described_class.new(user, terraform_state) } + + describe 'rules' do + context 'no access' do + let(:user) { create(:user) } + + it { is_expected.to be_disallowed(:read_terraform_state) } + it { is_expected.to be_disallowed(:admin_terraform_state) } + end + + context 'developer' do + let(:user) { create(:user, developer_projects: [project]) } + + it { is_expected.to be_allowed(:read_terraform_state) } + it { is_expected.to be_disallowed(:admin_terraform_state) } + end + + context 'maintainer' do + let(:user) { create(:user, maintainer_projects: [project]) } + + it { is_expected.to be_allowed(:read_terraform_state) } + it { is_expected.to be_allowed(:admin_terraform_state) } + end + end +end diff --git a/spec/requests/api/group_container_repositories_spec.rb b/spec/requests/api/group_container_repositories_spec.rb index 3128becae6d..4b97fad79dd 100644 --- a/spec/requests/api/group_container_repositories_spec.rb +++ b/spec/requests/api/group_container_repositories_spec.rb @@ -44,7 +44,7 @@ RSpec.describe API::GroupContainerRepositories do let(:object) { group } end - it_behaves_like 'a gitlab tracking event', described_class.name, 'list_repositories' + it_behaves_like 'a package tracking event', described_class.name, 'list_repositories' context 'with invalid group id' do let(:url) { "/groups/#{non_existing_record_id}/registry/repositories" } diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb index 0a23aed109b..a67bc157e5a 100644 --- a/spec/requests/api/maven_packages_spec.rb +++ b/spec/requests/api/maven_packages_spec.rb @@ -36,7 +36,7 @@ RSpec.describe API::MavenPackages do context 'with jar file' do let_it_be(:package_file) { jar_file } - it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + it_behaves_like 'a package tracking event', described_class.name, 'pull_package' end end @@ -571,7 +571,7 @@ RSpec.describe API::MavenPackages do context 'event tracking' do subject { upload_file_with_token(params) } - it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + it_behaves_like 'a package tracking event', described_class.name, 'push_package' end it 'creates package and stores package file' do diff --git a/spec/requests/api/npm_packages_spec.rb b/spec/requests/api/npm_packages_spec.rb index 0d65c91b763..8a3ccd7c6e3 100644 --- a/spec/requests/api/npm_packages_spec.rb +++ b/spec/requests/api/npm_packages_spec.rb @@ -197,7 +197,7 @@ RSpec.describe API::NpmPackages do expect(response.media_type).to eq('application/octet-stream') end - it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + it_behaves_like 'a package tracking event', described_class.name, 'pull_package' end context 'private project' do @@ -305,7 +305,7 @@ RSpec.describe API::NpmPackages do context 'with access token' do subject { upload_package_with_token(package_name, params) } - it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + it_behaves_like 'a package tracking event', described_class.name, 'push_package' it 'creates npm package with file' do expect { subject } diff --git a/spec/requests/api/project_container_repositories_spec.rb b/spec/requests/api/project_container_repositories_spec.rb index 6cf0619cde4..a0c310a448b 100644 --- a/spec/requests/api/project_container_repositories_spec.rb +++ b/spec/requests/api/project_container_repositories_spec.rb @@ -45,7 +45,7 @@ RSpec.describe API::ProjectContainerRepositories do it_behaves_like 'rejected container repository access', :guest, :forbidden it_behaves_like 'rejected container repository access', :anonymous, :not_found - it_behaves_like 'a gitlab tracking event', described_class.name, 'list_repositories' + it_behaves_like 'a package tracking event', described_class.name, 'list_repositories' it_behaves_like 'returns repositories for allowed users', :reporter, 'project' do let(:object) { project } @@ -57,7 +57,7 @@ RSpec.describe API::ProjectContainerRepositories do it_behaves_like 'rejected container repository access', :developer, :forbidden it_behaves_like 'rejected container repository access', :anonymous, :not_found - it_behaves_like 'a gitlab tracking event', described_class.name, 'delete_repository' + it_behaves_like 'a package tracking event', described_class.name, 'delete_repository' context 'for maintainer' do let(:api_user) { maintainer } @@ -86,7 +86,7 @@ RSpec.describe API::ProjectContainerRepositories do stub_container_registry_tags(repository: root_repository.path, tags: %w(rootA latest)) end - it_behaves_like 'a gitlab tracking event', described_class.name, 'list_tags' + it_behaves_like 'a package tracking event', described_class.name, 'list_tags' it 'returns a list of tags' do subject @@ -114,7 +114,7 @@ RSpec.describe API::ProjectContainerRepositories do it_behaves_like 'rejected container repository access', :developer, :forbidden it_behaves_like 'rejected container repository access', :anonymous, :not_found - it_behaves_like 'a gitlab tracking event', described_class.name, 'delete_tag_bulk' + it_behaves_like 'a package tracking event', described_class.name, 'delete_tag_bulk' end context 'for maintainer' do diff --git a/spec/requests/api/unleash_spec.rb b/spec/requests/api/unleash_spec.rb new file mode 100644 index 00000000000..0b70d62b093 --- /dev/null +++ b/spec/requests/api/unleash_spec.rb @@ -0,0 +1,608 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Unleash do + include FeatureFlagHelpers + + let_it_be(:project, refind: true) { create(:project) } + let(:project_id) { project.id } + let(:params) { } + let(:headers) { } + + shared_examples 'authenticated request' do + context 'when using instance id' do + let(:client) { create(:operations_feature_flags_client, project: project) } + let(:params) { { instance_id: client.token } } + + it 'responds with OK' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + + context 'when repository is disabled' do + before do + project.project_feature.update!( + repository_access_level: ::ProjectFeature::DISABLED, + merge_requests_access_level: ::ProjectFeature::DISABLED, + builds_access_level: ::ProjectFeature::DISABLED + ) + end + + it 'responds with forbidden' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when repository is private' do + before do + project.project_feature.update!( + repository_access_level: ::ProjectFeature::PRIVATE, + merge_requests_access_level: ::ProjectFeature::DISABLED, + builds_access_level: ::ProjectFeature::DISABLED + ) + end + + it 'responds with OK' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + end + + context 'when using header' do + let(:client) { create(:operations_feature_flags_client, project: project) } + let(:headers) { { "UNLEASH-INSTANCEID" => client.token }} + + it 'responds with OK' do + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when using bogus instance id' do + let(:params) { { instance_id: 'token' } } + + it 'responds with unauthorized' do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + + context 'when using not existing project' do + let(:project_id) { -5000 } + let(:params) { { instance_id: 'token' } } + + it 'responds with unauthorized' do + subject + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end + + shared_examples_for 'support multiple environments' do + let!(:client) { create(:operations_feature_flags_client, project: project) } + let!(:base_headers) { { "UNLEASH-INSTANCEID" => client.token } } + let!(:headers) { base_headers.merge({ "UNLEASH-APPNAME" => "test" }) } + + let!(:feature_flag_1) do + create(:operations_feature_flag, name: "feature_flag_1", project: project, active: true) + end + + let!(:feature_flag_2) do + create(:operations_feature_flag, name: "feature_flag_2", project: project, active: false) + end + + before do + create_scope(feature_flag_1, 'production', false) + create_scope(feature_flag_2, 'review/*', true) + end + + it 'does not have N+1 problem' do + control_count = ActiveRecord::QueryRecorder.new { get api(features_url), headers: headers }.count + + create(:operations_feature_flag, name: "feature_flag_3", project: project, active: true) + + expect { get api(features_url), headers: headers }.not_to exceed_query_limit(control_count) + end + + context 'when app name is staging' do + let(:headers) { base_headers.merge({ "UNLEASH-APPNAME" => "staging" }) } + + it 'returns correct active values' do + subject + + feature_flag_1 = json_response['features'].find { |f| f['name'] == 'feature_flag_1' } + feature_flag_2 = json_response['features'].find { |f| f['name'] == 'feature_flag_2' } + + expect(feature_flag_1['enabled']).to eq(true) + expect(feature_flag_2['enabled']).to eq(false) + end + end + + context 'when app name is production' do + let(:headers) { base_headers.merge({ "UNLEASH-APPNAME" => "production" }) } + + it 'returns correct active values' do + subject + + feature_flag_1 = json_response['features'].find { |f| f['name'] == 'feature_flag_1' } + feature_flag_2 = json_response['features'].find { |f| f['name'] == 'feature_flag_2' } + + expect(feature_flag_1['enabled']).to eq(false) + expect(feature_flag_2['enabled']).to eq(false) + end + end + + context 'when app name is review/patch-1' do + let(:headers) { base_headers.merge({ "UNLEASH-APPNAME" => "review/patch-1" }) } + + it 'returns correct active values' do + subject + + feature_flag_1 = json_response['features'].find { |f| f['name'] == 'feature_flag_1' } + feature_flag_2 = json_response['features'].find { |f| f['name'] == 'feature_flag_2' } + + expect(feature_flag_1['enabled']).to eq(true) + expect(feature_flag_2['enabled']).to eq(false) + end + end + + context 'when app name is empty' do + let(:headers) { base_headers } + + it 'returns empty list' do + subject + + expect(json_response['features'].count).to eq(0) + end + end + end + + %w(/feature_flags/unleash/:project_id/features /feature_flags/unleash/:project_id/client/features).each do |features_endpoint| + describe "GET #{features_endpoint}" do + let(:features_url) { features_endpoint.sub(':project_id', project_id.to_s) } + let(:client) { create(:operations_feature_flags_client, project: project) } + + subject { get api(features_url), params: params, headers: headers } + + it_behaves_like 'authenticated request' + + context 'with version 1 (legacy) feature flags' do + let(:feature_flag) { create(:operations_feature_flag, project: project, name: 'feature1', active: true, version: 1) } + + it_behaves_like 'support multiple environments' + + context 'with a list of feature flags' do + let(:headers) { { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "production" } } + let!(:enabled_feature_flag) { create(:operations_feature_flag, project: project, name: 'feature1', active: true, version: 1) } + let!(:disabled_feature_flag) { create(:operations_feature_flag, project: project, name: 'feature2', active: false, version: 1) } + + it 'responds with a list of features' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['version']).to eq(1) + expect(json_response['features']).not_to be_empty + expect(json_response['features'].map { |f| f['name'] }.sort).to eq(%w[feature1 feature2]) + expect(json_response['features'].sort_by {|f| f['name'] }.map { |f| f['enabled'] }).to eq([true, false]) + end + + it 'matches json schema' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('unleash/unleash') + end + end + + it 'returns a feature flag strategy' do + create(:operations_feature_flag_scope, + feature_flag: feature_flag, + environment_scope: 'sandbox', + active: true, + strategies: [{ name: "gradualRolloutUserId", + parameters: { groupId: "default", percentage: "50" } }]) + headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "sandbox" } + + get api(features_url), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features'].first['enabled']).to eq(true) + strategies = json_response['features'].first['strategies'] + expect(strategies).to eq([{ + "name" => "gradualRolloutUserId", + "parameters" => { + "percentage" => "50", + "groupId" => "default" + } + }]) + end + + it 'returns a default strategy for a scope' do + create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'sandbox', active: true) + headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "sandbox" } + + get api(features_url), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features'].first['enabled']).to eq(true) + strategies = json_response['features'].first['strategies'] + expect(strategies).to eq([{ "name" => "default", "parameters" => {} }]) + end + + it 'returns multiple strategies for a feature flag' do + create(:operations_feature_flag_scope, + feature_flag: feature_flag, + environment_scope: 'staging', + active: true, + strategies: [{ name: "userWithId", parameters: { userIds: "max,fred" } }, + { name: "gradualRolloutUserId", + parameters: { groupId: "default", percentage: "50" } }]) + headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "staging" } + + get api(features_url), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features'].first['enabled']).to eq(true) + strategies = json_response['features'].first['strategies'].sort_by { |s| s['name'] } + expect(strategies).to eq([{ + "name" => "gradualRolloutUserId", + "parameters" => { + "percentage" => "50", + "groupId" => "default" + } + }, { + "name" => "userWithId", + "parameters" => { + "userIds" => "max,fred" + } + }]) + end + + it 'returns a disabled feature when the flag is disabled' do + flag = create(:operations_feature_flag, project: project, name: 'test_feature', active: false, version: 1) + create(:operations_feature_flag_scope, feature_flag: flag, environment_scope: 'production', active: true) + headers = { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "production" } + + get api(features_url), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features'].first['enabled']).to eq(false) + end + + context "with an inactive scope" do + let!(:scope) { create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production', active: false, strategies: [{ name: "default", parameters: {} }]) } + let(:headers) { { "UNLEASH-INSTANCEID" => client.token, "UNLEASH-APPNAME" => "production" } } + + it 'returns a disabled feature' do + get api(features_url), headers: headers + + expect(response).to have_gitlab_http_status(:ok) + feature_json = json_response['features'].first + expect(feature_json['enabled']).to eq(false) + expect(feature_json['strategies']).to eq([{ 'name' => 'default', 'parameters' => {} }]) + end + end + end + + context 'with version 2 feature flags' do + it 'does not return a flag without any strategies' do + create(:operations_feature_flag, project: project, + name: 'feature1', active: true, version: 2) + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features']).to be_empty + end + + it 'returns a flag with a default strategy' do + feature_flag = create(:operations_feature_flag, project: project, + name: 'feature1', active: true, version: 2) + strategy = create(:operations_strategy, feature_flag: feature_flag, + name: 'default', parameters: {}) + create(:operations_scope, strategy: strategy, environment_scope: 'production') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features']).to eq([{ + 'name' => 'feature1', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'default', + 'parameters' => {} + }] + }]) + end + + it 'returns a flag with a userWithId strategy' do + feature_flag = create(:operations_feature_flag, project: project, + name: 'feature1', active: true, version: 2) + strategy = create(:operations_strategy, feature_flag: feature_flag, + name: 'userWithId', parameters: { userIds: 'user123,user456' }) + create(:operations_scope, strategy: strategy, environment_scope: 'production') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features']).to eq([{ + 'name' => 'feature1', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'userWithId', + 'parameters' => { 'userIds' => 'user123,user456' } + }] + }]) + end + + it 'returns a flag with multiple strategies' do + feature_flag = create(:operations_feature_flag, project: project, + name: 'feature1', active: true, version: 2) + strategy_a = create(:operations_strategy, feature_flag: feature_flag, + name: 'userWithId', parameters: { userIds: 'user_a,user_b' }) + strategy_b = create(:operations_strategy, feature_flag: feature_flag, + name: 'gradualRolloutUserId', parameters: { groupId: 'default', percentage: '45' }) + create(:operations_scope, strategy: strategy_a, environment_scope: 'production') + create(:operations_scope, strategy: strategy_b, environment_scope: 'production') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features'].map { |f| f['name'] }.sort).to eq(['feature1']) + features_json = json_response['features'].map do |feature| + feature.merge(feature.slice('strategies').transform_values { |v| v.sort_by { |s| s['name'] } }) + end + expect(features_json).to eq([{ + 'name' => 'feature1', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'gradualRolloutUserId', + 'parameters' => { 'groupId' => 'default', 'percentage' => '45' } + }, { + 'name' => 'userWithId', + 'parameters' => { 'userIds' => 'user_a,user_b' } + }] + }]) + end + + it 'returns only flags matching the environment scope' do + feature_flag_a = create(:operations_feature_flag, project: project, + name: 'feature1', active: true, version: 2) + strategy_a = create(:operations_strategy, feature_flag: feature_flag_a) + create(:operations_scope, strategy: strategy_a, environment_scope: 'production') + feature_flag_b = create(:operations_feature_flag, project: project, + name: 'feature2', active: true, version: 2) + strategy_b = create(:operations_strategy, feature_flag: feature_flag_b) + create(:operations_scope, strategy: strategy_b, environment_scope: 'staging') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'staging' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features'].map { |f| f['name'] }.sort).to eq(['feature2']) + expect(json_response['features']).to eq([{ + 'name' => 'feature2', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'default', + 'parameters' => {} + }] + }]) + end + + it 'returns only strategies matching the environment scope' do + feature_flag = create(:operations_feature_flag, project: project, + name: 'feature1', active: true, version: 2) + strategy_a = create(:operations_strategy, feature_flag: feature_flag, + name: 'userWithId', parameters: { userIds: 'user2,user8,user4' }) + create(:operations_scope, strategy: strategy_a, environment_scope: 'production') + strategy_b = create(:operations_strategy, feature_flag: feature_flag, + name: 'default', parameters: {}) + create(:operations_scope, strategy: strategy_b, environment_scope: 'staging') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features']).to eq([{ + 'name' => 'feature1', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'userWithId', + 'parameters' => { 'userIds' => 'user2,user8,user4' } + }] + }]) + end + + it 'returns only flags for the given project' do + project_b = create(:project) + feature_flag_a = create(:operations_feature_flag, project: project, name: 'feature_a', active: true, version: 2) + strategy_a = create(:operations_strategy, feature_flag: feature_flag_a) + create(:operations_scope, strategy: strategy_a, environment_scope: 'sandbox') + feature_flag_b = create(:operations_feature_flag, project: project_b, name: 'feature_b', active: true, version: 2) + strategy_b = create(:operations_strategy, feature_flag: feature_flag_b) + create(:operations_scope, strategy: strategy_b, environment_scope: 'sandbox') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'sandbox' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features']).to eq([{ + 'name' => 'feature_a', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'default', + 'parameters' => {} + }] + }]) + end + + it 'returns all strategies with a matching scope' do + feature_flag = create(:operations_feature_flag, project: project, + name: 'feature1', active: true, version: 2) + strategy_a = create(:operations_strategy, feature_flag: feature_flag, + name: 'userWithId', parameters: { userIds: 'user2,user8,user4' }) + create(:operations_scope, strategy: strategy_a, environment_scope: '*') + strategy_b = create(:operations_strategy, feature_flag: feature_flag, + name: 'default', parameters: {}) + create(:operations_scope, strategy: strategy_b, environment_scope: 'review/*') + strategy_c = create(:operations_strategy, feature_flag: feature_flag, + name: 'gradualRolloutUserId', parameters: { groupId: 'default', percentage: '15' }) + create(:operations_scope, strategy: strategy_c, environment_scope: 'review/patch-1') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'review/patch-1' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features'].first['strategies'].sort_by { |s| s['name'] }).to eq([{ + 'name' => 'default', + 'parameters' => {} + }, { + 'name' => 'gradualRolloutUserId', + 'parameters' => { 'groupId' => 'default', 'percentage' => '15' } + }, { + 'name' => 'userWithId', + 'parameters' => { 'userIds' => 'user2,user8,user4' } + }]) + end + + it 'returns a strategy with more than one matching scope' do + feature_flag = create(:operations_feature_flag, project: project, + name: 'feature1', active: true, version: 2) + strategy = create(:operations_strategy, feature_flag: feature_flag, + name: 'default', parameters: {}) + create(:operations_scope, strategy: strategy, environment_scope: 'production') + create(:operations_scope, strategy: strategy, environment_scope: '*') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features']).to eq([{ + 'name' => 'feature1', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'default', + 'parameters' => {} + }] + }]) + end + + it 'returns a disabled flag with a matching scope' do + feature_flag = create(:operations_feature_flag, project: project, + name: 'myfeature', active: false, version: 2) + strategy = create(:operations_strategy, feature_flag: feature_flag, + name: 'default', parameters: {}) + create(:operations_scope, strategy: strategy, environment_scope: 'production') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features']).to eq([{ + 'name' => 'myfeature', + 'enabled' => false, + 'strategies' => [{ + 'name' => 'default', + 'parameters' => {} + }] + }]) + end + + it 'returns a userWithId strategy for a gitlabUserList strategy' do + feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, + name: 'myfeature', active: true) + user_list = create(:operations_feature_flag_user_list, project: project, + name: 'My List', user_xids: 'user1,user2') + strategy = create(:operations_strategy, feature_flag: feature_flag, + name: 'gitlabUserList', parameters: {}, user_list: user_list) + create(:operations_scope, strategy: strategy, environment_scope: 'production') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'production' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features']).to eq([{ + 'name' => 'myfeature', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'userWithId', + 'parameters' => { 'userIds' => 'user1,user2' } + }] + }]) + end + end + + context 'when mixing version 1 and version 2 feature flags' do + it 'returns both types of flags when both match' do + feature_flag_a = create(:operations_feature_flag, project: project, + name: 'feature_a', active: true, version: 2) + strategy = create(:operations_strategy, feature_flag: feature_flag_a, + name: 'userWithId', parameters: { userIds: 'user8' }) + create(:operations_scope, strategy: strategy, environment_scope: 'staging') + feature_flag_b = create(:operations_feature_flag, project: project, + name: 'feature_b', active: true, version: 1) + create(:operations_feature_flag_scope, feature_flag: feature_flag_b, + active: true, strategies: [{ name: 'default', parameters: {} }], environment_scope: 'staging') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'staging' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features'].sort_by {|f| f['name']}).to eq([{ + 'name' => 'feature_a', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'userWithId', + 'parameters' => { 'userIds' => 'user8' } + }] + }, { + 'name' => 'feature_b', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'default', + 'parameters' => {} + }] + }]) + end + + it 'returns legacy flags when only legacy flags match' do + feature_flag_a = create(:operations_feature_flag, project: project, + name: 'feature_a', active: true, version: 2) + strategy = create(:operations_strategy, feature_flag: feature_flag_a, + name: 'userWithId', parameters: { userIds: 'user8' }) + create(:operations_scope, strategy: strategy, environment_scope: 'production') + feature_flag_b = create(:operations_feature_flag, project: project, + name: 'feature_b', active: true, version: 1) + create(:operations_feature_flag_scope, feature_flag: feature_flag_b, + active: true, strategies: [{ name: 'default', parameters: {} }], environment_scope: 'staging') + + get api(features_url), headers: { 'UNLEASH-INSTANCEID' => client.token, 'UNLEASH-APPNAME' => 'staging' } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['features']).to eq([{ + 'name' => 'feature_b', + 'enabled' => true, + 'strategies' => [{ + 'name' => 'default', + 'parameters' => {} + }] + }]) + end + end + end + end + + describe 'POST /feature_flags/unleash/:project_id/client/register' do + subject { post api("/feature_flags/unleash/#{project_id}/client/register"), params: params, headers: headers } + + it_behaves_like 'authenticated request' + end + + describe 'POST /feature_flags/unleash/:project_id/client/metrics' do + subject { post api("/feature_flags/unleash/#{project_id}/client/metrics"), params: params, headers: headers } + + it_behaves_like 'authenticated request' + end +end diff --git a/spec/services/packages/create_event_service_spec.rb b/spec/services/packages/create_event_service_spec.rb new file mode 100644 index 00000000000..7e66b430a8c --- /dev/null +++ b/spec/services/packages/create_event_service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe Packages::CreateEventService do + let(:scope) { 'container' } + let(:event_name) { 'push_package' } + + let(:params) do + { + scope: scope, + event_name: event_name + } + end + + subject { described_class.new(nil, user, params).execute } + + describe '#execute' do + shared_examples 'package event creation' do |originator_type, expected_scope| + it 'creates the event' do + expect { subject }.to change { Packages::Event.count }.by(1) + + expect(subject.originator_type).to eq(originator_type) + expect(subject.originator).to eq(user&.id) + expect(subject.event_scope).to eq(expected_scope) + expect(subject.event_type).to eq(event_name) + end + end + + context 'with a user' do + let(:user) { create(:user) } + + it_behaves_like 'package event creation', 'user', 'container' + end + + context 'with a deploy token' do + let(:user) { create(:deploy_token) } + + it_behaves_like 'package event creation', 'deploy_token', 'container' + end + + context 'with no user' do + let(:user) { nil } + + it_behaves_like 'package event creation', 'guest', 'container' + end + + context 'with a package as scope' do + let(:user) { nil } + let(:scope) { create(:npm_package) } + + it_behaves_like 'package event creation', 'guest', 'npm' + end + end +end diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb index 94c52bdaaa6..0ee0b7e6d88 100644 --- a/spec/support/shared_examples/models/mentionable_shared_examples.rb +++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb @@ -207,29 +207,8 @@ RSpec.shared_examples 'an editable mentionable' do end RSpec.shared_examples 'mentions in description' do |mentionable_type| - describe 'when store_mentioned_users_to_db feature disabled' do + describe 'when storing user mentions' do before do - stub_feature_flags(store_mentioned_users_to_db: false) - mentionable.store_mentions! - end - - context 'when mentionable description contains mentions' do - let(:user) { create(:user) } - let(:mentionable) { create(mentionable_type, description: "#{user.to_reference} some description") } - - it 'stores no mentions' do - expect(mentionable.user_mentions.count).to eq 0 - end - - it 'renders description_html correctly' do - expect(mentionable.description_html).to include("<a href=\"/#{user.username}\" data-user=\"#{user.id}\"") - end - end - end - - describe 'when store_mentioned_users_to_db feature enabled' do - before do - stub_feature_flags(store_mentioned_users_to_db: true) mentionable.store_mentions! end diff --git a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb index 5c122b4b5d6..4b5299cebec 100644 --- a/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb @@ -75,7 +75,7 @@ RSpec.shared_examples 'Composer package creation' do |user_type, status, add_mem expect(response).to have_gitlab_http_status(status) end - it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + it_behaves_like 'a package tracking event', described_class.name, 'push_package' end end diff --git a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb index 3e058838773..e776cf13217 100644 --- a/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb @@ -79,11 +79,3 @@ RSpec.shared_examples 'returns repositories for allowed users' do |user_type, sc end end end - -RSpec.shared_examples 'a gitlab tracking event' do |category, action| - it "creates a gitlab tracking event #{action}" do - expect(Gitlab::Tracking).to receive(:event).with(category, action, {}) - - subject - end -end diff --git a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb index 03d7ca71f1f..ec32cb4b2ff 100644 --- a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb @@ -128,7 +128,7 @@ RSpec.shared_examples 'Debian project repository PUT request' do |user_role, add expect(response.body).to eq(body) end end - it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + it_behaves_like 'a package tracking event', described_class.name, 'push_package' else it "returns #{status}#{and_body}" do subject diff --git a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb index 6aac51a5903..58e99776fd9 100644 --- a/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb @@ -26,7 +26,7 @@ RSpec.shared_examples 'process nuget service index request' do |user_type, statu it_behaves_like 'returning response status', status - it_behaves_like 'a gitlab tracking event', described_class.name, 'nuget_service_index' + it_behaves_like 'a package tracking event', described_class.name, 'cli_metadata' it 'returns a valid json response' do subject @@ -169,7 +169,7 @@ RSpec.shared_examples 'process nuget upload' do |user_type, status, add_member = context 'with correct params' do it_behaves_like 'package workhorse uploads' it_behaves_like 'creates nuget package files' - it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + it_behaves_like 'a package tracking event', described_class.name, 'push_package' end end @@ -286,7 +286,7 @@ RSpec.shared_examples 'process nuget download content request' do |user_type, st it_behaves_like 'returning response status', status - it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + it_behaves_like 'a package tracking event', described_class.name, 'pull_package' it 'returns a valid package archive' do subject @@ -336,7 +336,7 @@ RSpec.shared_examples 'process nuget search request' do |user_type, status, add_ it_behaves_like 'returns a valid json search response', status, 4, [1, 5, 5, 1] - it_behaves_like 'a gitlab tracking event', described_class.name, 'search_package' + it_behaves_like 'a package tracking event', described_class.name, 'search_package' context 'with skip set to 2' do let(:skip) { 2 } diff --git a/spec/support/shared_examples/requests/api/packages_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_shared_examples.rb index c9a33701161..d730ed53109 100644 --- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb @@ -126,3 +126,11 @@ RSpec.shared_examples 'job token for package uploads' do end end end + +RSpec.shared_examples 'a package tracking event' do |category, action| + it "creates a gitlab tracking event #{action}" do + expect(Gitlab::Tracking).to receive(:event).with(category, action, {}) + + expect { subject }.to change { Packages::Event.count }.by(1) + end +end diff --git a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb index 715c494840e..40bedc84366 100644 --- a/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb +++ b/spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb @@ -52,7 +52,7 @@ RSpec.shared_examples 'PyPi package creation' do |user_type, status, add_member context 'with correct params' do it_behaves_like 'package workhorse uploads' it_behaves_like 'creating pypi package files' - it_behaves_like 'a gitlab tracking event', described_class.name, 'push_package' + it_behaves_like 'a package tracking event', described_class.name, 'push_package' end end @@ -119,7 +119,7 @@ RSpec.shared_examples 'PyPi package versions' do |user_type, status, add_member end it_behaves_like 'returning response status', status - it_behaves_like 'a gitlab tracking event', described_class.name, 'list_package' + it_behaves_like 'a package tracking event', described_class.name, 'list_package' end end @@ -136,7 +136,7 @@ RSpec.shared_examples 'PyPi package download' do |user_type, status, add_member end it_behaves_like 'returning response status', status - it_behaves_like 'a gitlab tracking event', described_class.name, 'pull_package' + it_behaves_like 'a package tracking event', described_class.name, 'pull_package' end end diff --git a/spec/support/shared_examples/requests/api/tracking_shared_examples.rb b/spec/support/shared_examples/requests/api/tracking_shared_examples.rb new file mode 100644 index 00000000000..2e6feae3f98 --- /dev/null +++ b/spec/support/shared_examples/requests/api/tracking_shared_examples.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a gitlab tracking event' do |category, action| + it "creates a gitlab tracking event #{action}" do + expect(Gitlab::Tracking).to receive(:event).with(category, action, {}) + + subject + end +end |