Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-09-30 15:09:53 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-09-30 15:09:53 +0300
commit6aa5c04c74d2d70ee7d19ef3a155b2def9dd46de (patch)
tree81f7b81234bc5b889c57e71f87b94878ab286383
parent418c3b29009dcc0a2c6b4872557d0274ba0b8077 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop_todo.yml1
-rw-r--r--app/assets/javascripts/members.js28
-rw-r--r--app/assets/javascripts/registry/settings/components/registry_settings_app.vue50
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue96
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue32
-rw-r--r--app/assets/javascripts/registry/shared/utils.js22
-rw-r--r--app/assets/stylesheets/application.scss9
-rw-r--r--app/assets/stylesheets/pages/experimental_separate_sign_up.scss4
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss422
-rw-r--r--app/controllers/groups/group_links_controller.rb9
-rw-r--r--app/controllers/groups/registry/repositories_controller.rb4
-rw-r--r--app/controllers/invites_controller.rb2
-rw-r--r--app/controllers/projects/group_links_controller.rb13
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb2
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb6
-rw-r--r--app/controllers/projects/registry/tags_controller.rb9
-rw-r--r--app/graphql/resolvers/terraform/states_resolver.rb23
-rw-r--r--app/graphql/types/project_type.rb6
-rw-r--r--app/graphql/types/terraform/state_type.rb37
-rw-r--r--app/helpers/feature_flags_helper.rb19
-rw-r--r--app/helpers/packages_helper.rb5
-rw-r--r--app/helpers/system_note_helper.rb2
-rw-r--r--app/models/concerns/mentionable.rb16
-rw-r--r--app/models/event.rb6
-rw-r--r--app/models/packages/event.rb26
-rw-r--r--app/models/project.rb7
-rw-r--r--app/models/terraform/state.rb1
-rw-r--r--app/policies/terraform/state_policy.rb9
-rw-r--r--app/services/packages/create_event_service.rb37
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml18
-rw-r--r--app/views/invites/decline.html.haml8
-rw-r--r--app/views/projects/group_links/update.js.haml4
-rw-r--r--app/views/shared/members/_group.html.haml11
-rw-r--r--app/views/shared/members/_member.html.haml2
-rw-r--r--app/views/shared/snippets/_form.html.haml12
-rw-r--r--changelogs/unreleased/216571-terraform-states-graphql-endpoint.yml5
-rw-r--r--changelogs/unreleased/231306-approve-activity-in-core.yml5
-rw-r--r--changelogs/unreleased/233660-remove-bootstrap-alert-gcp.yml5
-rw-r--r--changelogs/unreleased/241129-retry-button-misaligned.yml5
-rw-r--r--changelogs/unreleased/246619-add-package-events.yml5
-rw-r--r--changelogs/unreleased/256121-add-schema-to-migration-helpers-that-query-the-pg-catalog.yml5
-rw-r--r--changelogs/unreleased/35223-drop-unused-user-id-column-on-cluster-providers-aws.yml5
-rw-r--r--changelogs/unreleased/debian_regexp.yml5
-rw-r--r--changelogs/unreleased/isolate-mentions-migration.yml5
-rw-r--r--changelogs/unreleased/reminder-emails-invitation-declined-screen.yml5
-rw-r--r--config/feature_flags/development/feature_flags_new_version.yml4
-rw-r--r--config/feature_flags/development/maintenance_mode.yml6
-rw-r--r--config/feature_flags/development/store_mentioned_users_to_db.yml7
-rw-r--r--config/feature_flags/development/unified_diff_lines.yml2
-rw-r--r--db/migrate/20200909040555_create_package_events.rb19
-rw-r--r--db/post_migrate/20200922231755_remove_created_by_user_id_from_cluster_providers_aws.rb26
-rw-r--r--db/schema_migrations/202009090405551
-rw-r--r--db/schema_migrations/202009222317551
-rw-r--r--db/structure.sql29
-rw-r--r--doc/administration/geo/replication/updating_the_geo_nodes.md4
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql92
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json278
-rw-r--r--doc/api/graphql/reference/index.md11
-rw-r--r--doc/api/services.md2
-rw-r--r--doc/user/application_security/sast/index.md5
-rw-r--r--lib/api/api.rb1
-rw-r--r--lib/api/composer_packages.rb2
-rw-r--r--lib/api/conan_package_endpoints.rb2
-rw-r--r--lib/api/debian_package_endpoints.rb10
-rw-r--r--lib/api/debian_project_packages.rb2
-rw-r--r--lib/api/entities/unleash_feature.rb32
-rw-r--r--lib/api/entities/unleash_gitlab_user_list_strategy.rb14
-rw-r--r--lib/api/entities/unleash_legacy_strategy.rb14
-rw-r--r--lib/api/entities/unleash_strategy.rb10
-rw-r--r--lib/api/group_container_repositories.rb4
-rw-r--r--lib/api/helpers/packages/conan/api_helpers.rb4
-rw-r--r--lib/api/helpers/packages_helpers.rb3
-rw-r--r--lib/api/maven_packages.rb8
-rw-r--r--lib/api/npm_packages.rb4
-rw-r--r--lib/api/nuget_packages.rb8
-rw-r--r--lib/api/project_container_repositories.rb11
-rw-r--r--lib/api/pypi_packages.rb6
-rw-r--r--lib/api/unleash.rb77
-rw-r--r--lib/gitlab/database/count/reltuples_count_strategy.rb3
-rw-r--r--lib/gitlab/database/migration_helpers.rb27
-rw-r--r--lib/gitlab/database/schema_helpers.rb13
-rw-r--r--lib/gitlab/redis/hll.rb4
-rw-r--r--lib/gitlab/regex.rb33
-rw-r--r--locale/gitlab.pot22
-rw-r--r--qa/qa/page/component/new_snippet.rb12
-rw-r--r--qa/qa/page/dashboard/snippet/edit.rb4
-rw-r--r--spec/controllers/groups/group_links_controller_spec.rb33
-rw-r--r--spec/controllers/groups/registry/repositories_controller_spec.rb2
-rw-r--r--spec/controllers/projects/group_links_controller_spec.rb51
-rw-r--r--spec/controllers/projects/registry/tags_controller_spec.rb2
-rw-r--r--spec/factories/events.rb11
-rw-r--r--spec/factories/projects.rb6
-rw-r--r--spec/features/groups/members/manage_groups_spec.rb113
-rw-r--r--spec/features/invites_spec.rb45
-rw-r--r--spec/features/projects/clusters/gcp_spec.rb2
-rw-r--r--spec/features/projects/members/groups_with_access_list_spec.rb41
-rw-r--r--spec/fixtures/api/schemas/unleash/unleash.json20
-rw-r--r--spec/fixtures/api/schemas/unleash/unleash_feature.json27
-rw-r--r--spec/fixtures/api/schemas/unleash/unleash_strategy.json24
-rw-r--r--spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap7
-rw-r--r--spec/frontend/registry/settings/components/registry_settings_app_spec.js88
-rw-r--r--spec/frontend/registry/settings/components/settings_form_spec.js293
-rw-r--r--spec/frontend/registry/settings/mock_data.js32
-rw-r--r--spec/frontend/registry/shared/__snapshots__/utils_spec.js.snap8
-rw-r--r--spec/frontend/registry/shared/components/expiration_policy_fields_spec.js20
-rw-r--r--spec/frontend/registry/shared/stubs.js11
-rw-r--r--spec/frontend/registry/shared/utils_spec.js16
-rw-r--r--spec/graphql/resolvers/terraform/states_resolver_spec.rb33
-rw-r--r--spec/graphql/types/project_type_spec.rb9
-rw-r--r--spec/graphql/types/terraform/state_type_spec.rb21
-rw-r--r--spec/helpers/feature_flags_helper_spec.rb21
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb42
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/lib/gitlab/regex_spec.rb134
-rw-r--r--spec/models/event_spec.rb50
-rw-r--r--spec/models/project_spec.rb1
-rw-r--r--spec/models/terraform/state_spec.rb17
-rw-r--r--spec/policies/terraform/state_policy_spec.rb33
-rw-r--r--spec/requests/api/group_container_repositories_spec.rb2
-rw-r--r--spec/requests/api/maven_packages_spec.rb4
-rw-r--r--spec/requests/api/npm_packages_spec.rb4
-rw-r--r--spec/requests/api/project_container_repositories_spec.rb8
-rw-r--r--spec/requests/api/unleash_spec.rb608
-rw-r--r--spec/services/packages/create_event_service_spec.rb54
-rw-r--r--spec/support/shared_examples/models/mentionable_shared_examples.rb23
-rw-r--r--spec/support/shared_examples/requests/api/composer_packages_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/container_repositories_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/nuget_packages_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/requests/api/packages_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/requests/api/pypi_packages_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/requests/api/tracking_shared_examples.rb9
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" } &times;
- .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?) }
+ &middot;
+ %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