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:
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue199
-rw-r--r--app/assets/javascripts/alerts_settings/constants.js13
-rw-r--r--app/assets/javascripts/alerts_settings/services/index.js9
-rw-r--r--app/assets/javascripts/ide/components/ide.vue9
-rw-r--r--app/assets/javascripts/issuables_list/components/issuable.vue39
-rw-r--r--app/assets/javascripts/issuables_list/components/issuables_list_app.vue30
-rw-r--r--app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js5
-rw-r--r--app/assets/stylesheets/pages/issues/issues_list.scss5
-rw-r--r--app/assets/stylesheets/utilities.scss5
-rw-r--r--app/finders/ci/variables_finder.rb31
-rw-r--r--app/helpers/operations_helper.rb2
-rw-r--r--app/models/ci/instance_variable.rb8
-rw-r--r--app/models/ci/variable.rb2
-rw-r--r--app/models/concerns/ci/contextable.rb2
-rw-r--r--app/models/concerns/update_project_statistics.rb12
-rw-r--r--app/models/project_statistics.rb2
-rw-r--r--app/models/snippet.rb4
-rw-r--r--app/models/snippet_statistics.rb36
-rw-r--r--app/services/snippets/destroy_service.rb5
-rw-r--r--app/services/snippets/update_statistics_service.rb5
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/triggers/_trigger.html.haml2
-rw-r--r--changelogs/unreleased/217362-move-release-stage-usage-activity-to-ce.yml5
-rw-r--r--changelogs/unreleased/9912-api-env-variables-update-with-env-scope.yml5
-rw-r--r--changelogs/unreleased/fj-rethink-snippet-storage-callbacks.yml5
-rw-r--r--doc/api/instance_level_ci_variables.md24
-rw-r--r--doc/api/project_level_variables.md35
-rw-r--r--doc/ci/variables/README.md3
-rw-r--r--doc/development/telemetry/usage_ping.md10
-rw-r--r--lib/api/variables.rb26
-rw-r--r--lib/gitlab/ci/features.rb9
-rw-r--r--lib/gitlab/usage_data.rb11
-rw-r--r--locale/gitlab.pot33
-rw-r--r--spec/factories/snippet_statistics.rb25
-rw-r--r--spec/finders/ci/variables_finder_spec.rb44
-rw-r--r--spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap21
-rw-r--r--spec/frontend/alert_settings/alert_settings_form_spec.js61
-rw-r--r--spec/frontend/issuables_list/components/issuable_spec.js18
-rw-r--r--spec/frontend/issuables_list/components/issuables_list_app_spec.js88
-rw-r--r--spec/helpers/operations_helper_spec.rb31
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb32
-rw-r--r--spec/models/ci/build_spec.rb11
-rw-r--r--spec/models/ci/instance_variable_spec.rb15
-rw-r--r--spec/models/snippet_spec.rb2
-rw-r--r--spec/models/snippet_statistics_spec.rb60
-rw-r--r--spec/requests/api/variables_spec.rb149
-rw-r--r--spec/services/snippets/destroy_service_spec.rb25
-rw-r--r--spec/services/snippets/update_statistics_service_spec.rb27
48 files changed, 966 insertions, 236 deletions
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index 7dde9edbd27..63f3b3bc1f0 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -5,18 +5,21 @@ import {
GlForm,
GlFormGroup,
GlFormInput,
+ GlFormInputGroup,
+ GlFormTextarea,
GlLink,
GlModal,
GlModalDirective,
GlSprintf,
GlFormSelect,
} from '@gitlab/ui';
+import { debounce } from 'lodash';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import csrf from '~/lib/utils/csrf';
import service from '../services';
-import { i18n, serviceOptions } from '../constants';
+import { i18n, serviceOptions, JSON_VALIDATE_DELAY } from '../constants';
export default {
i18n,
@@ -27,7 +30,9 @@ export default {
GlForm,
GlFormGroup,
GlFormInput,
+ GlFormInputGroup,
GlFormSelect,
+ GlFormTextarea,
GlLink,
GlModal,
GlSprintf,
@@ -73,6 +78,11 @@ export default {
feedbackMessage: null,
isFeedbackDismissed: false,
},
+ testAlert: {
+ json: null,
+ error: null,
+ },
+ canSaveForm: false,
};
},
computed: {
@@ -109,12 +119,32 @@ export default {
showFeedbackMsg() {
return this.feedback.feedbackMessage && !this.isFeedbackDismissed;
},
+ showAlertSave() {
+ return (
+ this.feedback.feedbackMessage === this.$options.i18n.testAlertFailed &&
+ !this.isFeedbackDismissed
+ );
+ },
prometheusInfo() {
return !this.isGeneric ? this.$options.i18n.prometheusInfo : '';
},
prometheusFeatureEnabled() {
return !this.isGeneric && this.glFeatures.alertIntegrationsDropdown;
},
+ jsonIsValid() {
+ return this.testAlert.error === null;
+ },
+ canTestAlert() {
+ return this.selectedService.active && this.testAlert.json !== null;
+ },
+ canSaveConfig() {
+ return !this.loading && this.canSaveForm;
+ },
+ },
+ watch: {
+ 'testAlert.json': debounce(function debouncedJsonValidate() {
+ this.validateJson();
+ }, JSON_VALIDATE_DELAY),
},
created() {
if (this.glFeatures.alertIntegrationsDropdown) {
@@ -126,6 +156,9 @@ export default {
}
},
methods: {
+ clearJson() {
+ this.testAlert.json = null;
+ },
dismissFeedback() {
this.feedback = { ...this.feedback, feedbackMessage: null };
this.isFeedbackDismissed = false;
@@ -135,6 +168,7 @@ export default {
.updateGenericKey({ endpoint: this.generic.formPath, params: { service: { token: '' } } })
.then(({ data: { token } }) => {
this.authorizationKey.generic = token;
+ this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' });
})
.catch(() => {
this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' });
@@ -145,11 +179,24 @@ export default {
.updatePrometheusKey({ endpoint: this.prometheus.prometheusResetKeyPath })
.then(({ data: { token } }) => {
this.authorizationKey.prometheus = token;
+ this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' });
})
.catch(() => {
this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' });
});
},
+ toggleService(value) {
+ this.canSaveForm = true;
+ if (!this.glFeatures.alertIntegrationsDropdown) {
+ this.toggleActivated(value);
+ }
+
+ if (this.isGeneric) {
+ this.activated.generic = value;
+ } else {
+ this.activated.prometheus = value;
+ }
+ },
toggleActivated(value) {
return this.isGeneric
? this.toggleGenericActivated(value)
@@ -164,15 +211,14 @@ export default {
})
.then(() => {
this.activated.generic = value;
-
- if (value) {
- this.setFeedback({
- feedbackMessage: this.$options.i18n.endPointActivated,
- variant: 'success',
- });
- }
+ this.toggleSuccess(value);
+ })
+ .catch(() => {
+ this.setFeedback({
+ feedbackMessage: this.$options.i18n.errorMsg,
+ variant: 'danger',
+ });
})
- .catch(() => {})
.finally(() => {
this.loading = false;
});
@@ -191,12 +237,7 @@ export default {
})
.then(() => {
this.activated.prometheus = value;
- if (value) {
- this.setFeedback({
- feedbackMessage: this.$options.i18n.endPointActivated,
- variant: 'success',
- });
- }
+ this.toggleSuccess(value);
})
.catch(() => {
this.setFeedback({
@@ -208,16 +249,61 @@ export default {
this.loading = false;
});
},
+ toggleSuccess(value) {
+ if (value) {
+ this.setFeedback({
+ feedbackMessage: this.$options.i18n.endPointActivated,
+ variant: 'info',
+ });
+ } else {
+ this.setFeedback({
+ feedbackMessage: this.$options.i18n.changesSaved,
+ variant: 'info',
+ });
+ }
+ },
setFeedback({ feedbackMessage, variant }) {
this.feedback = { feedbackMessage, variant };
},
- onSubmit(evt) {
- // TODO: Add form submit as part of https://gitlab.com/gitlab-org/gitlab/-/issues/215356
- evt.preventDefault();
+ validateJson() {
+ this.testAlert.error = null;
+ try {
+ JSON.parse(this.testAlert.json);
+ } catch (e) {
+ this.testAlert.error = JSON.stringify(e.message);
+ }
},
- onReset(evt) {
- // TODO: Add form reset as part of https://gitlab.com/gitlab-org/gitlab/-/issues/215356
- evt.preventDefault();
+ validateTestAlert() {
+ this.loading = true;
+ this.validateJson();
+ return service
+ .updateTestAlert({
+ endpoint: this.selectedService.url,
+ data: this.testAlert.json,
+ authKey: this.selectedService.authKey,
+ })
+ .then(() => {
+ this.setFeedback({
+ feedbackMessage: this.$options.i18n.testAlertSuccess,
+ variant: 'success',
+ });
+ })
+ .catch(() => {
+ this.setFeedback({
+ feedbackMessage: this.$options.i18n.testAlertFailed,
+ variant: 'danger',
+ });
+ })
+ .finally(() => {
+ this.loading = false;
+ });
+ },
+ onSubmit() {
+ this.toggleActivated(this.selectedService.active);
+ },
+ onReset() {
+ this.testAlert.json = null;
+ this.dismissFeedback();
},
},
};
@@ -227,6 +313,15 @@ export default {
<div>
<gl-alert v-if="showFeedbackMsg" :variant="feedback.variant" @dismiss="dismissFeedback">
{{ feedback.feedbackMessage }}
+ <gl-button
+ v-if="showAlertSave"
+ variant="danger"
+ category="primary"
+ class="gl-display-block gl-mt-3"
+ @click="toggleActivated(selectedService.active)"
+ >
+ {{ __('Save anyway') }}
+ </gl-button>
</gl-alert>
<div data-testid="alert-settings-description" class="gl-mt-5">
<p v-for="section in sections" :key="section.text">
@@ -237,7 +332,7 @@ export default {
</gl-sprintf>
</p>
</div>
- <gl-form @submit="onSubmit" @reset="onReset">
+ <gl-form @submit.prevent="onSubmit" @reset.prevent="onReset">
<gl-form-group
v-if="glFeatures.alertIntegrationsDropdown"
:label="$options.i18n.integrationsLabel"
@@ -248,6 +343,7 @@ export default {
v-model="selectedEndpoint"
:options="options"
data-testid="alert-settings-select"
+ @change="clearJson"
/>
<span class="gl-text-gray-400">
<gl-sprintf :message="$options.i18n.integrationsInfo">
@@ -272,7 +368,7 @@ export default {
:disabled-input="loading"
:is-loading="loading"
:value="selectedService.active"
- @change="toggleActivated"
+ @change="toggleService"
/>
</gl-form-group>
<gl-form-group
@@ -293,12 +389,15 @@ export default {
</span>
</gl-form-group>
<gl-form-group :label="$options.i18n.urlLabel" label-for="url" label-class="label-bold">
- <div class="input-group">
- <gl-form-input id="url" :readonly="true" :value="selectedService.url" />
- <span class="input-group-append">
- <clipboard-button :text="selectedService.url" :title="$options.i18n.copyToClipboard" />
- </span>
- </div>
+ <gl-form-input-group id="url" :readonly="true" :value="selectedService.url">
+ <template #append>
+ <clipboard-button
+ :text="selectedService.url"
+ :title="$options.i18n.copyToClipboard"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
<span class="gl-text-gray-400">
{{ prometheusInfo }}
</span>
@@ -308,15 +407,20 @@ export default {
label-for="authorization-key"
label-class="label-bold"
>
- <div class="input-group">
- <gl-form-input id="authorization-key" :readonly="true" :value="selectedService.authKey" />
- <span class="input-group-append">
+ <gl-form-input-group
+ id="authorization-key"
+ class="gl-mb-2"
+ :readonly="true"
+ :value="selectedService.authKey"
+ >
+ <template #append>
<clipboard-button
:text="selectedService.authKey"
:title="$options.i18n.copyToClipboard"
+ class="gl-m-0!"
/>
- </span>
- </div>
+ </template>
+ </gl-form-input-group>
<gl-button v-gl-modal.authKeyModal class="gl-mt-3">{{ $options.i18n.resetKey }}</gl-button>
<gl-modal
modal-id="authKeyModal"
@@ -328,11 +432,32 @@ export default {
{{ $options.i18n.restKeyInfo }}
</gl-modal>
</gl-form-group>
+ <gl-form-group
+ v-if="glFeatures.alertIntegrationsDropdown"
+ :label="$options.i18n.alertJson"
+ label-for="alert-json"
+ label-class="label-bold"
+ :invalid-feedback="testAlert.error"
+ >
+ <gl-form-textarea
+ id="alert-json"
+ v-model.trim="testAlert.json"
+ :disabled="!selectedService.active"
+ :state="jsonIsValid"
+ :placeholder="$options.i18n.alertJsonPlaceholder"
+ rows="6"
+ max-rows="10"
+ />
+ </gl-form-group>
+ <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{
+ $options.i18n.testAlertInfo
+ }}</gl-button>
<div
- class="footer-block row-content-block gl-display-flex gl-justify-content-space-between d-none"
+ v-if="glFeatures.alertIntegrationsDropdown"
+ class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"
>
- <gl-button type="submit" variant="success" category="primary">
- {{ __('Save and test changes') }}
+ <gl-button type="submit" variant="success" category="primary" :disabled="!canSaveConfig">
+ {{ __('Save changes') }}
</gl-button>
<gl-button type="reset" variant="default" category="primary">
{{ __('Cancel') }}
diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js
index 15618978145..0a022a07352 100644
--- a/app/assets/javascripts/alerts_settings/constants.js
+++ b/app/assets/javascripts/alerts_settings/constants.js
@@ -21,6 +21,7 @@ export const i18n = {
'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
),
endPointActivated: s__('AlertSettings|Alerts endpoint successfully activated.'),
+ changesSaved: s__('AlertSettings|Your changes were successfully updated.'),
prometheusInfo: s__('AlertSettings|Add URL and auth key to your Prometheus config file'),
integrationsInfo: s__(
'AlertSettings|Learn more about our %{linkStart}upcoming integrations%{linkEnd}',
@@ -32,10 +33,20 @@ export const i18n = {
authKeyLabel: s__('AlertSettings|Authorization key'),
urlLabel: s__('AlertSettings|Webhook URL'),
activeLabel: s__('AlertSettings|Active'),
- apiBaseUrlHelpText: s__(' AlertSettings|URL cannot be blank and must start with http or https'),
+ apiBaseUrlHelpText: s__('AlertSettings|URL cannot be blank and must start with http or https'),
+ testAlertInfo: s__('AlertSettings|Test alert payload'),
+ alertJson: s__('AlertSettings|Alert test payload'),
+ alertJsonPlaceholder: s__('AlertSettings|Enter test alert JSON....'),
+ testAlertFailed: s__('AlertSettings|Test failed. Do you still want to save your changes anyway?'),
+ testAlertSuccess: s__(
+ 'AlertSettings|Test alert sent successfully. If you have made other changes, please save them now.',
+ ),
+ authKeyRest: s__('AlertSettings|Authorization key has been successfully reset'),
};
export const serviceOptions = [
{ value: 'generic', text: s__('AlertSettings|Generic') },
{ value: 'prometheus', text: s__('AlertSettings|External Prometheus') },
];
+
+export const JSON_VALIDATE_DELAY = 250;
diff --git a/app/assets/javascripts/alerts_settings/services/index.js b/app/assets/javascripts/alerts_settings/services/index.js
index 669c40bc86b..c49992d4f57 100644
--- a/app/assets/javascripts/alerts_settings/services/index.js
+++ b/app/assets/javascripts/alerts_settings/services/index.js
@@ -1,3 +1,4 @@
+/* eslint-disable @gitlab/require-i18n-strings */
import axios from '~/lib/utils/axios_utils';
export default {
@@ -24,4 +25,12 @@ export default {
},
});
},
+ updateTestAlert({ endpoint, data, authKey }) {
+ return axios.post(endpoint, data, {
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${authKey}`,
+ },
+ });
+ },
};
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index e9f84eb8648..55b3eaf9737 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import { modalTypes } from '../constants';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
@@ -24,7 +24,7 @@ export default {
FindFile,
ErrorMessage,
CommitEditorHeader,
- GlDeprecatedButton,
+ GlButton,
GlLoadingIcon,
RightPane,
},
@@ -121,15 +121,16 @@ export default {
)
}}
</p>
- <gl-deprecated-button
+ <gl-button
variant="success"
+ category="primary"
:title="__('New file')"
:aria-label="__('New file')"
data-qa-selector="first_file_button"
@click="createNewFile()"
>
{{ __('New file') }}
- </gl-deprecated-button>
+ </gl-button>
</template>
<gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="md" />
<p v-else>
diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue
index b36a83c4974..9af1887ef12 100644
--- a/app/assets/javascripts/issuables_list/components/issuable.vue
+++ b/app/assets/javascripts/issuables_list/components/issuable.vue
@@ -7,6 +7,7 @@
// TODO: need to move this component to graphql - https://gitlab.com/gitlab-org/gitlab/-/issues/221246
import { escape, isNumber } from 'lodash';
import { GlLink, GlTooltipDirective as GlTooltip, GlSprintf, GlLabel, GlIcon } from '@gitlab/ui';
+import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg';
import {
dateInWords,
formatDate,
@@ -25,6 +26,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
i18n: {
openedAgo: __('opened %{timeAgoString} by %{user}'),
+ openedAgoJira: __('opened %{timeAgoString} by %{user} in Jira'),
},
components: {
IssueAssignees,
@@ -60,6 +62,11 @@ export default {
},
},
},
+ data() {
+ return {
+ jiraLogo,
+ };
+ },
computed: {
milestoneLink() {
const { title } = this.issuable.milestone;
@@ -87,6 +94,9 @@ export default {
isClosed() {
return this.issuable.state === 'closed';
},
+ isJiraIssue() {
+ return this.issuable.external_tracker === 'jira';
+ },
issueCreatedToday() {
return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1;
},
@@ -223,7 +233,18 @@ export default {
:title="$options.confidentialTooltipText"
:aria-label="$options.confidentialTooltipText"
></i>
- <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link>
+ <gl-link
+ :href="issuable.web_url"
+ :target="isJiraIssue ? '_blank' : null"
+ data-testid="issuable-title"
+ >
+ {{ issuable.title }}
+ <gl-icon
+ v-if="isJiraIssue"
+ name="external-link"
+ class="gl-vertical-align-text-bottom"
+ />
+ </gl-link>
</span>
<span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block">
{{ issuable.task_status }}
@@ -231,11 +252,21 @@ export default {
</div>
<div class="issuable-info">
- <span class="js-ref-path">{{ referencePath }}</span>
+ <span class="js-ref-path">
+ <span
+ v-if="isJiraIssue"
+ class="svg-container jira-logo-container"
+ data-testid="jira-logo"
+ v-html="jiraLogo"
+ ></span>
+ {{ referencePath }}
+ </span>
<span data-testid="openedByMessage" class="d-none d-sm-inline-block mr-1">
&middot;
- <gl-sprintf :message="$options.i18n.openedAgo">
+ <gl-sprintf
+ :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo"
+ >
<template #timeAgoString>
<span>{{ issuableCreatedAt }}</span>
</template>
@@ -302,6 +333,7 @@ export default {
<!-- Issuable meta -->
<div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center">
<div class="controls d-flex">
+ <span v-if="isJiraIssue">&nbsp;</span>
<span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span>
<issue-assignees
@@ -326,6 +358,7 @@ export default {
</template>
<gl-link
+ v-if="!isJiraIssue"
v-gl-tooltip
class="ml-2 js-notes"
:href="`${issuable.web_url}#notes`"
diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
index 1c395fd9795..db18bcbce09 100644
--- a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
+++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
@@ -118,6 +118,29 @@ export default {
baseUrl() {
return window.location.href.replace(/(\?.*)?(#.*)?$/, '');
},
+ paginationNext() {
+ return this.page + 1;
+ },
+ paginationPrev() {
+ return this.page - 1;
+ },
+ paginationProps() {
+ const paginationProps = { value: this.page };
+
+ if (this.totalItems) {
+ return {
+ ...paginationProps,
+ perPage: this.itemsPerPage,
+ totalItems: this.totalItems,
+ };
+ }
+
+ return {
+ ...paginationProps,
+ prevPage: this.paginationPrev,
+ nextPage: this.paginationNext,
+ };
+ },
},
watch: {
selection() {
@@ -272,11 +295,8 @@ export default {
</ul>
<div class="mt-3">
<gl-pagination
- v-if="totalItems"
- :value="page"
- :per-page="itemsPerPage"
- :total-items="totalItems"
- class="justify-content-center"
+ v-bind="paginationProps"
+ class="gl-justify-content-center"
@input="onPaginate"
/>
</div>
diff --git a/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js
new file mode 100644
index 00000000000..260ba69b4bc
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/integrations/jira/issues/index/index.js
@@ -0,0 +1,5 @@
+import initIssuablesList from '~/issuables_list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initIssuablesList();
+});
diff --git a/app/assets/stylesheets/pages/issues/issues_list.scss b/app/assets/stylesheets/pages/issues/issues_list.scss
new file mode 100644
index 00000000000..c0af7a6af6d
--- /dev/null
+++ b/app/assets/stylesheets/pages/issues/issues_list.scss
@@ -0,0 +1,5 @@
+.svg-container.jira-logo-container {
+ svg {
+ vertical-align: text-bottom;
+ }
+}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index e02b4eff9f0..94af1df2ccb 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -117,8 +117,3 @@
.gl-border-b-2 {
border-bottom-width: $gl-border-size-2;
}
-
-// Remove once this MR has been merged in GitLab UI > https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1539
-.gl-min-w-full {
- min-width: 100%;
-}
diff --git a/app/finders/ci/variables_finder.rb b/app/finders/ci/variables_finder.rb
new file mode 100644
index 00000000000..d933643ffb2
--- /dev/null
+++ b/app/finders/ci/variables_finder.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ class VariablesFinder
+ attr_reader :project, :params
+
+ def initialize(project, params)
+ @project, @params = project, params
+
+ raise ArgumentError, 'Please provide params[:key]' if params[:key].blank?
+ end
+
+ def execute
+ variables = project.variables
+ variables = by_key(variables)
+ variables = by_environment_scope(variables)
+ variables
+ end
+
+ private
+
+ def by_key(variables)
+ variables.by_key(params[:key])
+ end
+
+ def by_environment_scope(variables)
+ environment_scope = params.dig(:filter, :environment_scope)
+ environment_scope.present? ? variables.by_environment_scope(environment_scope) : variables
+ end
+ end
+end
diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb
index 7500f7b1ff4..419fdd521c2 100644
--- a/app/helpers/operations_helper.rb
+++ b/app/helpers/operations_helper.rb
@@ -17,7 +17,7 @@ module OperationsHelper
def alerts_settings_data
{
- 'prometheus_activated' => prometheus_service.activated?.to_s,
+ 'prometheus_activated' => prometheus_service.manual_configuration?.to_s,
'activated' => alerts_service.activated?.to_s,
'prometheus_form_path' => scoped_integration_path(prometheus_service),
'form_path' => scoped_integration_path(alerts_service),
diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb
index 8245729a884..628749b32cb 100644
--- a/app/models/ci/instance_variable.rb
+++ b/app/models/ci/instance_variable.rb
@@ -45,13 +45,5 @@ module Ci
end
end
end
-
- private
-
- def validate_plan_limit_not_exceeded
- if Gitlab::Ci::Features.instance_level_variables_limit_enabled?
- super
- end
- end
end
end
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index 08d39595c61..13358b95a47 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -18,5 +18,7 @@ module Ci
}
scope :unprotected, -> { where(protected: false) }
+ scope :by_key, -> (key) { where(key: key) }
+ scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) }
end
end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index 7ea5382a4fa..10df5e1a8dc 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -84,8 +84,6 @@ module Ci
end
def secret_instance_variables
- return [] unless ::Feature.enabled?(:ci_instance_level_variables, project, default_enabled: true)
-
project.ci_instance_variables_for(ref: git_ref)
end
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index 6cf012680d8..c0fa14d3369 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -35,8 +35,8 @@ module UpdateProjectStatistics
@project_statistics_name = project_statistics_name
@statistic_attribute = statistic_attribute
- after_save(:update_project_statistics_after_save, if: :update_project_statistics_attribute_changed?)
- after_destroy(:update_project_statistics_after_destroy, unless: :project_destroyed?)
+ after_save(:update_project_statistics_after_save, if: :update_project_statistics_after_save?)
+ after_destroy(:update_project_statistics_after_destroy, if: :update_project_statistics_after_destroy?)
end
private :update_project_statistics
@@ -45,6 +45,14 @@ module UpdateProjectStatistics
included do
private
+ def update_project_statistics_after_save?
+ update_project_statistics_attribute_changed?
+ end
+
+ def update_project_statistics_after_destroy?
+ !project_destroyed?
+ end
+
def update_project_statistics_after_save
attr = self.class.statistic_attribute
delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 55c7c6ab682..f153bfe3f5b 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -12,7 +12,7 @@ class ProjectStatistics < ApplicationRecord
before_save :update_storage_size
COLUMNS_TO_REFRESH = [:repository_size, :wiki_size, :lfs_objects_size, :commit_count, :snippets_size].freeze
- INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze
+ INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size], snippets_size: %i[storage_size] }.freeze
NAMESPACE_RELATABLE_COLUMNS = [:repository_size, :wiki_size, :lfs_objects_size].freeze
scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) }
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 5f45407c05e..eb3960ff12b 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -44,7 +44,9 @@ class Snippet < ApplicationRecord
has_many :notes, as: :noteable, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :user_mentions, class_name: "SnippetUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_one :snippet_repository, inverse_of: :snippet
- has_one :statistics, class_name: 'SnippetStatistics'
+
+ # We need to add the `dependent` in order to call the after_destroy callback
+ has_one :statistics, class_name: 'SnippetStatistics', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
delegate :name, :email, to: :author, prefix: true, allow_nil: true
diff --git a/app/models/snippet_statistics.rb b/app/models/snippet_statistics.rb
index 8030328ebe4..7439f98d114 100644
--- a/app/models/snippet_statistics.rb
+++ b/app/models/snippet_statistics.rb
@@ -1,11 +1,19 @@
# frozen_string_literal: true
class SnippetStatistics < ApplicationRecord
+ include AfterCommitQueue
+ include UpdateProjectStatistics
+
belongs_to :snippet
validates :snippet, presence: true
- delegate :repository, to: :snippet
+ update_project_statistics project_statistics_name: :snippets_size, statistic_attribute: :repository_size
+
+ delegate :repository, :project, :project_id, to: :snippet
+
+ after_save :update_author_root_storage_statistics, if: :update_author_root_storage_statistics?
+ after_destroy :update_author_root_storage_statistics, unless: :project_snippet?
def update_commit_count
self.commit_count = repository.commit_count
@@ -32,4 +40,30 @@ class SnippetStatistics < ApplicationRecord
save!
end
+
+ private
+
+ alias_method :original_update_project_statistics_after_save?, :update_project_statistics_after_save?
+ def update_project_statistics_after_save?
+ project_snippet? && original_update_project_statistics_after_save?
+ end
+
+ alias_method :original_update_project_statistics_after_destroy?, :update_project_statistics_after_destroy?
+ def update_project_statistics_after_destroy?
+ project_snippet? && original_update_project_statistics_after_destroy?
+ end
+
+ def update_author_root_storage_statistics?
+ !project_snippet? && saved_change_to_repository_size?
+ end
+
+ def update_author_root_storage_statistics
+ run_after_commit do
+ Namespaces::ScheduleAggregationWorker.perform_async(snippet.author.namespace_id)
+ end
+ end
+
+ def project_snippet?
+ snippet.is_a?(ProjectSnippet)
+ end
end
diff --git a/app/services/snippets/destroy_service.rb b/app/services/snippets/destroy_service.rb
index 146a0b53fc1..977626fcf17 100644
--- a/app/services/snippets/destroy_service.rb
+++ b/app/services/snippets/destroy_service.rb
@@ -27,11 +27,6 @@ module Snippets
attempt_destroy!
- # Update project statistics if the snippet is a Project one
- if snippet.project_id
- ProjectCacheWorker.perform_async(snippet.project_id, [], [:snippets_size])
- end
-
ServiceResponse.success(message: 'Snippet was deleted.')
rescue DestroyError
service_response_error('Failed to remove snippet repository.', 400)
diff --git a/app/services/snippets/update_statistics_service.rb b/app/services/snippets/update_statistics_service.rb
index 61fa43e7755..295cb963ccc 100644
--- a/app/services/snippets/update_statistics_service.rb
+++ b/app/services/snippets/update_statistics_service.rb
@@ -16,11 +16,6 @@ module Snippets
snippet.repository.expire_statistics_caches
statistics.refresh!
- # Update project statistics if the snippet is a Project one
- if snippet.project_id
- ProjectCacheWorker.perform_async(snippet.project_id, [], [:snippets_size])
- end
-
ServiceResponse.success(message: 'Snippet statistics successfully updated.')
end
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index d65563a6eba..737e4f66dd2 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -24,7 +24,7 @@
.control
= form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do
- = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control search-text-input input-short gl-mt-3 mt-sm-0 gl-min-w-full', spellcheck: false }
+ = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control search-text-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false }
.control.d-none.d-md-block
= link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do
= icon("rss")
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index d80248f7e80..3036e918160 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -31,7 +31,7 @@
- revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?"
- if can?(current_user, :admin_trigger, trigger)
= link_to edit_project_trigger_path(@project, trigger), method: :get, title: "Edit", class: "btn btn-default btn-sm" do
- %i.fa.fa-pencil
+ = sprite_icon('pencil')
- if can?(current_user, :manage_trigger, trigger)
= link_to project_trigger_path(@project, trigger), data: { confirm: revoke_trigger_confirmation }, method: :delete, title: "Revoke", class: "btn btn-default btn-warning btn-sm btn-trigger-revoke" do
%i.fa.fa-trash
diff --git a/changelogs/unreleased/217362-move-release-stage-usage-activity-to-ce.yml b/changelogs/unreleased/217362-move-release-stage-usage-activity-to-ce.yml
new file mode 100644
index 00000000000..c253c88d762
--- /dev/null
+++ b/changelogs/unreleased/217362-move-release-stage-usage-activity-to-ce.yml
@@ -0,0 +1,5 @@
+---
+title: Move release stage usage activity to CE
+merge_request: 36083
+author:
+type: changed
diff --git a/changelogs/unreleased/9912-api-env-variables-update-with-env-scope.yml b/changelogs/unreleased/9912-api-env-variables-update-with-env-scope.yml
new file mode 100644
index 00000000000..2c1a95e57bd
--- /dev/null
+++ b/changelogs/unreleased/9912-api-env-variables-update-with-env-scope.yml
@@ -0,0 +1,5 @@
+---
+title: Add environment_scope filter to ci-variables API
+merge_request: 34490
+author:
+type: fixed
diff --git a/changelogs/unreleased/fj-rethink-snippet-storage-callbacks.yml b/changelogs/unreleased/fj-rethink-snippet-storage-callbacks.yml
new file mode 100644
index 00000000000..59510f45585
--- /dev/null
+++ b/changelogs/unreleased/fj-rethink-snippet-storage-callbacks.yml
@@ -0,0 +1,5 @@
+---
+title: Update namespace statistics after personal snippet update/removal
+merge_request: 36031
+author:
+type: changed
diff --git a/doc/api/instance_level_ci_variables.md b/doc/api/instance_level_ci_variables.md
index 72d20109fbd..ceaf7e30c48 100644
--- a/doc/api/instance_level_ci_variables.md
+++ b/doc/api/instance_level_ci_variables.md
@@ -1,10 +1,7 @@
# Instance-level CI/CD variables API
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/14108) in GitLab 13.0
-> - It's deployed behind a feature flag, enabled by default.
-> - It's enabled on GitLab.com.
-> - It's recommended for production use.
-> - For GitLab self-managed instances, GitLab administrators can opt to [disable it](#enable-or-disable-instance-level-cicd-variables-core-only). **(CORE ONLY)**
+> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/218249) in GitLab 13.2.
## List all instance variables
@@ -140,22 +137,3 @@ DELETE /admin/ci/variables/:key
```shell
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/admin/ci/variables/VARIABLE_1"
```
-
-### Enable or disable instance-level CI/CD variables **(CORE ONLY)**
-
-Instance-level CI/CD variables is under development but ready for production use.
-It is deployed behind a feature flag that is **enabled by default**.
-[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
-can opt to disable it for your instance.
-
-To disable it:
-
-```ruby
-Feature.disable(:ci_instance_level_variables)
-```
-
-To enable it:
-
-```ruby
-Feature.enable(:ci_instance_level_variables)
-```
diff --git a/doc/api/project_level_variables.md b/doc/api/project_level_variables.md
index fbeba9d6c7d..407e506e082 100644
--- a/doc/api/project_level_variables.md
+++ b/doc/api/project_level_variables.md
@@ -43,6 +43,7 @@ GET /projects/:id/variables/:key
|-----------|---------|----------|-----------------------|
| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `key` | string | yes | The `key` of a variable |
+| `filter` | hash | no | Available filters: `[environment_scope]`. See the [`filter` parameter details](#the-filter-parameter). |
```shell
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/variables/TEST_VARIABLE_1"
@@ -108,6 +109,7 @@ PUT /projects/:id/variables/:key
| `protected` | boolean | no | Whether the variable is protected |
| `masked` | boolean | no | Whether the variable is masked |
| `environment_scope` | string | no | The `environment_scope` of the variable |
+| `filter` | hash | no | Available filters: `[environment_scope]`. See the [`filter` parameter details](#the-filter-parameter). |
```shell
curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/variables/NEW_VARIABLE" --form "value=updated value"
@@ -136,7 +138,40 @@ DELETE /projects/:id/variables/:key
|-----------|---------|----------|-------------------------|
| `id` | integer/string | yes | The ID of a project or [urlencoded NAMESPACE/PROJECT_NAME of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `key` | string | yes | The `key` of a variable |
+| `filter` | hash | no | Available filters: `[environment_scope]`. See the [`filter` parameter details](#the-filter-parameter). |
```shell
curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/variables/VARIABLE_1"
```
+
+## The `filter` parameter
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34490) in GitLab 13.2.
+> - It's deployed behind a feature flag, disabled by default.
+> - It's disabled on GitLab.com.
+> - To use it in GitLab self-managed instances, ask a GitLab administrator to enable it.
+
+This parameter is used for filtering by attributes, such as `environment_scope`.
+
+Example usage:
+
+```shell
+curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/variables/VARIABLE_1?filter[environment_scope]=production"
+```
+
+### Enable or disable
+
+[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
+can enable it for your instance.
+
+To enable it:
+
+```ruby
+Feature.enable(:ci_variables_api_filter_environment_scope)
+```
+
+To disable it:
+
+```ruby
+Feature.disable(:ci_variables_api_filter_environment_scope)
+```
diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md
index 42a238148e9..a14c2a4f098 100644
--- a/doc/ci/variables/README.md
+++ b/doc/ci/variables/README.md
@@ -458,9 +458,6 @@ The UI interface for Instance-level CI/CD variables is under development but rea
It is deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md) can opt to disable it for your instance.
-NOTE: **Note:**
-This feature will not work if the [instance-level CI/CD variables API feature flag is disabled](../../api/instance_level_ci_variables.md#enable-or-disable-instance-level-cicd-variables-core-only).
-
To disable it:
```ruby
diff --git a/doc/development/telemetry/usage_ping.md b/doc/development/telemetry/usage_ping.md
index bb226d91923..75b5c593070 100644
--- a/doc/development/telemetry/usage_ping.md
+++ b/doc/development/telemetry/usage_ping.md
@@ -672,11 +672,11 @@ appear to be associated to any of the services running, since they all appear to
| `service_desk_enabled_projects` | `usage_activity_by_stage` | `plan` | | | |
| `service_desk_issues` | `usage_activity_by_stage` | `plan` | | | |
| `todos: 0` | `usage_activity_by_stage` | `plan` | | | |
-| `deployments` | `usage_activity_by_stage` | `release` | | | Total deployments |
-| `failed_deployments` | `usage_activity_by_stage` | `release` | | | Total failed deployments |
-| `projects_mirrored_with_pipelines_enabled` | `usage_activity_by_stage` | `release` | | | Projects with repository mirroring enabled |
-| `releases` | `usage_activity_by_stage` | `release` | | | Unique release tags in project |
-| `successful_deployments: 0` | `usage_activity_by_stage` | `release` | | | Total successful deployments |
+| `deployments` | `usage_activity_by_stage` | `release` | | CE+EE | Total deployments |
+| `failed_deployments` | `usage_activity_by_stage` | `release` | | CE+EE | Total failed deployments |
+| `projects_mirrored_with_pipelines_enabled` | `usage_activity_by_stage` | `release` | | EE | Projects with repository mirroring enabled |
+| `releases` | `usage_activity_by_stage` | `release` | | CE+EE | Unique release tags in project |
+| `successful_deployments` | `usage_activity_by_stage` | `release` | | CE+EE | Total successful deployments |
| `user_preferences_group_overview_security_dashboard: 0` | `usage_activity_by_stage` | `secure` | | | |
| `ci_builds` | `usage_activity_by_stage` | `verify` | | | Unique builds in project |
| `ci_external_pipelines` | `usage_activity_by_stage` | `verify` | | | Total pipelines in external repositories |
diff --git a/lib/api/variables.rb b/lib/api/variables.rb
index 2a051c2adae..50d137ec7c1 100644
--- a/lib/api/variables.rb
+++ b/lib/api/variables.rb
@@ -13,6 +13,15 @@ module API
# parameters, without having to modify the source code directly.
params
end
+
+ def find_variable(params)
+ variables = ::Ci::VariablesFinder.new(user_project, params).execute.to_a
+
+ return variables.first unless ::Gitlab::Ci::Features.variables_api_filter_environment_scope?
+ return variables.first unless variables.many? # rubocop: disable CodeReuse/ActiveRecord
+
+ conflict!("There are multiple variables with provided parameters. Please use 'filter[environment_scope]'")
+ end
end
params do
@@ -39,10 +48,8 @@ module API
end
# rubocop: disable CodeReuse/ActiveRecord
get ':id/variables/:key' do
- key = params[:key]
- variable = user_project.variables.find_by(key: key)
-
- break not_found!('Variable') unless variable
+ variable = find_variable(params)
+ not_found!('Variable') unless variable
present variable, with: Entities::Variable
end
@@ -82,14 +89,14 @@ module API
optional :masked, type: Boolean, desc: 'Whether the variable is masked'
optional :variable_type, type: String, values: ::Ci::Variable.variable_types.keys, desc: 'The type of variable, must be one of env_var or file'
optional :environment_scope, type: String, desc: 'The environment_scope of the variable'
+ optional :filter, type: Hash, desc: 'Available filters: [environment_scope]. Example: filter[environment_scope]=production'
end
# rubocop: disable CodeReuse/ActiveRecord
put ':id/variables/:key' do
- variable = user_project.variables.find_by(key: params[:key])
-
- break not_found!('Variable') unless variable
+ variable = find_variable(params)
+ not_found!('Variable') unless variable
- variable_params = declared_params(include_missing: false).except(:key)
+ variable_params = declared_params(include_missing: false).except(:key, :filter)
variable_params = filter_variable_parameters(variable_params)
if variable.update(variable_params)
@@ -105,10 +112,11 @@ module API
end
params do
requires :key, type: String, desc: 'The key of the variable'
+ optional :filter, type: Hash, desc: 'Available filters: [environment_scope]. Example: filter[environment_scope]=production'
end
# rubocop: disable CodeReuse/ActiveRecord
delete ':id/variables/:key' do
- variable = user_project.variables.find_by(key: params[:key])
+ variable = find_variable(params)
not_found!('Variable') unless variable
# Variables don't have a timestamp. Therefore, destroy unconditionally.
diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb
index a240ec4aa30..69fd86aed46 100644
--- a/lib/gitlab/ci/features.rb
+++ b/lib/gitlab/ci/features.rb
@@ -18,10 +18,6 @@ module Gitlab
::Feature.enabled?(:ci_job_heartbeats_runner, project, default_enabled: true)
end
- def self.instance_level_variables_limit_enabled?
- ::Feature.enabled?(:ci_instance_level_variables_limit, default_enabled: true)
- end
-
def self.pipeline_fixed_notifications?
::Feature.enabled?(:ci_pipeline_fixed_notifications, default_enabled: true)
end
@@ -50,6 +46,11 @@ module Gitlab
def self.store_pipeline_messages?(project)
::Feature.enabled?(:ci_store_pipeline_messages, project, default_enabled: true)
end
+
+ # Remove in https://gitlab.com/gitlab-org/gitlab/-/issues/227052
+ def self.variables_api_filter_environment_scope?
+ ::Feature.enabled?(:ci_variables_api_filter_environment_scope, default_enabled: false)
+ end
end
end
end
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index ba5eafc2b88..ffdc99d2fa4 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -520,9 +520,16 @@ module Gitlab
end
# Omitted because no user, creator or author associated: `environments`, `feature_flags`, `in_review_folder`, `pages_domains`
+ # rubocop: disable CodeReuse/ActiveRecord
def usage_activity_by_stage_release(time_period)
- {}
+ {
+ deployments: distinct_count(::Deployment.where(time_period), :user_id),
+ failed_deployments: distinct_count(::Deployment.failed.where(time_period), :user_id),
+ releases: distinct_count(::Release.where(time_period), :author_id),
+ successful_deployments: distinct_count(::Deployment.success.where(time_period), :user_id)
+ }
end
+ # rubocop: enable CodeReuse/ActiveRecord
# Omitted because no user, creator or author associated: `ci_runners`
def usage_activity_by_stage_verify(time_period)
@@ -588,6 +595,8 @@ module Gitlab
end
def clear_memoized
+ clear_memoization(:issue_minimum_id)
+ clear_memoization(:issue_maximum_id)
clear_memoization(:user_minimum_id)
clear_memoization(:user_maximum_id)
clear_memoization(:unique_visit_service)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index cbf29779c11..58f2d7845cf 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -16,9 +16,6 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
-msgid " AlertSettings|URL cannot be blank and must start with http or https"
-msgstr ""
-
msgid " %{start} to %{end}"
msgstr ""
@@ -2086,15 +2083,24 @@ msgstr ""
msgid "AlertSettings|Add URL and auth key to your Prometheus config file"
msgstr ""
+msgid "AlertSettings|Alert test payload"
+msgstr ""
+
msgid "AlertSettings|Alerts endpoint successfully activated."
msgstr ""
msgid "AlertSettings|Authorization key"
msgstr ""
+msgid "AlertSettings|Authorization key has been successfully reset"
+msgstr ""
+
msgid "AlertSettings|Copy"
msgstr ""
+msgid "AlertSettings|Enter test alert JSON...."
+msgstr ""
+
msgid "AlertSettings|External Prometheus"
msgstr ""
@@ -2119,6 +2125,15 @@ msgstr ""
msgid "AlertSettings|Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint."
msgstr ""
+msgid "AlertSettings|Test alert payload"
+msgstr ""
+
+msgid "AlertSettings|Test alert sent successfully. If you have made other changes, please save them now."
+msgstr ""
+
+msgid "AlertSettings|Test failed. Do you still want to save your changes anyway?"
+msgstr ""
+
msgid "AlertSettings|There was an error updating the the alert settings. Please refresh the page to try again."
msgstr ""
@@ -2128,6 +2143,9 @@ msgstr ""
msgid "AlertSettings|There was an error while trying to reset the key. Please refresh the page to try again."
msgstr ""
+msgid "AlertSettings|URL cannot be blank and must start with http or https"
+msgstr ""
+
msgid "AlertSettings|Webhook URL"
msgstr ""
@@ -2137,6 +2155,9 @@ msgstr ""
msgid "AlertSettings|You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page."
msgstr ""
+msgid "AlertSettings|Your changes were successfully updated."
+msgstr ""
+
msgid "AlertSettings|http://prometheus.example.com/"
msgstr ""
@@ -19939,9 +19960,6 @@ msgstr ""
msgid "Save Changes"
msgstr ""
-msgid "Save and test changes"
-msgstr ""
-
msgid "Save anyway"
msgstr ""
@@ -27941,6 +27959,9 @@ msgstr ""
msgid "opened %{timeAgoString} by %{user}"
msgstr ""
+msgid "opened %{timeAgoString} by %{user} in Jira"
+msgstr ""
+
msgid "opened %{timeAgo}"
msgstr ""
diff --git a/spec/factories/snippet_statistics.rb b/spec/factories/snippet_statistics.rb
new file mode 100644
index 00000000000..ab2d9525466
--- /dev/null
+++ b/spec/factories/snippet_statistics.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :snippet_statistics do
+ snippet
+
+ initialize_with do
+ # statistics are automatically created when a snippet is created
+ snippet&.statistics || new
+ end
+
+ transient do
+ with_data { false }
+ size_multiplier { 1 }
+ end
+
+ after(:build) do |snippet_statistics, evaluator|
+ if evaluator.with_data
+ snippet_statistics.repository_size = evaluator.size_multiplier
+ snippet_statistics.commit_count = evaluator.size_multiplier * 2
+ snippet_statistics.file_count = evaluator.size_multiplier * 3
+ end
+ end
+ end
+end
diff --git a/spec/finders/ci/variables_finder_spec.rb b/spec/finders/ci/variables_finder_spec.rb
new file mode 100644
index 00000000000..cd5f950ca8e
--- /dev/null
+++ b/spec/finders/ci/variables_finder_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::VariablesFinder do
+ let!(:project) { create(:project) }
+ let!(:params) { {} }
+
+ let!(:var1) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'staging') }
+ let!(:var2) { create(:ci_variable, project: project, key: 'key2', environment_scope: 'staging') }
+ let!(:var3) { create(:ci_variable, project: project, key: 'key2', environment_scope: 'production') }
+
+ describe '#initialize' do
+ subject { described_class.new(project, params) }
+
+ context 'without key filter' do
+ let!(:params) { {} }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(ArgumentError, 'Please provide params[:key]')
+ end
+ end
+ end
+
+ describe '#execute' do
+ subject { described_class.new(project.reload, params).execute }
+
+ context 'with key filter' do
+ let!(:params) { { key: 'key1' } }
+
+ it 'returns var1' do
+ expect(subject).to contain_exactly(var1)
+ end
+ end
+
+ context 'with key and environment_scope filter' do
+ let!(:params) { { key: 'key2', filter: { environment_scope: 'staging' } } }
+
+ it 'returns var2' do
+ expect(subject).to contain_exactly(var2)
+ end
+ end
+ end
+end
diff --git a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
index ea7f6441866..50e6502104a 100644
--- a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
+++ b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
@@ -1,7 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`AlertsSettingsForm prometheus is active renders a valid "select" 1`] = `"<gl-form-select-stub options=\\"[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"prometheus\\"></gl-form-select-stub>"`;
-
exports[`AlertsSettingsForm with default values renders the initial template 1`] = `
"<div>
<!---->
@@ -20,29 +18,20 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`]
</gl-form-group-stub>
<!---->
<gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\" label-class=\\"label-bold\\">
- <div class=\\"input-group\\">
- <gl-form-input-stub id=\\"url\\" readonly=\\"true\\" value=\\"/alerts/notify.json\\"></gl-form-input-stub> <span class=\\"input-group-append\\"><clipboard-button-stub text=\\"/alerts/notify.json\\" title=\\"Copy\\" tooltipplacement=\\"top\\" cssclass=\\"btn-default\\"></clipboard-button-stub></span>
- </div> <span class=\\"gl-text-gray-400\\">
+ <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"true\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-400\\">
</span>
</gl-form-group-stub>
<gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\" label-class=\\"label-bold\\">
- <div class=\\"input-group\\">
- <gl-form-input-stub id=\\"authorization-key\\" readonly=\\"true\\" value=\\"abcedfg123\\"></gl-form-input-stub> <span class=\\"input-group-append\\"><clipboard-button-stub text=\\"abcedfg123\\" title=\\"Copy\\" tooltipplacement=\\"top\\" cssclass=\\"btn-default\\"></clipboard-button-stub></span>
- </div>
+ <gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"true\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub>
<gl-button-stub category=\\"tertiary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
<gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\">
Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.
</gl-modal-stub>
</gl-form-group-stub>
- <div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between d-none\\">
- <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" type=\\"submit\\">
- Save and test changes
- </gl-button-stub>
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" type=\\"reset\\">
- Cancel
- </gl-button-stub>
- </div>
+ <!---->
+ <gl-button-stub category=\\"tertiary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub>
+ <!---->
</gl-form-stub>
</div>"
`;
diff --git a/spec/frontend/alert_settings/alert_settings_form_spec.js b/spec/frontend/alert_settings/alert_settings_form_spec.js
index 8fa2e920c6f..04eb18f06a3 100644
--- a/spec/frontend/alert_settings/alert_settings_form_spec.js
+++ b/spec/frontend/alert_settings/alert_settings_form_spec.js
@@ -36,8 +36,12 @@ describe('AlertsSettingsForm', () => {
props = defaultProps,
{ methods } = {},
alertIntegrationsDropdown = false,
+ data,
) => {
wrapper = shallowMount(AlertsSettingsForm, {
+ data() {
+ return { ...data };
+ },
propsData: {
...defaultProps,
...props,
@@ -52,6 +56,7 @@ describe('AlertsSettingsForm', () => {
};
const findSelect = () => wrapper.find('[data-testid="alert-settings-select"]');
+ const findJsonInput = () => wrapper.find('#alert-json');
const findUrl = () => wrapper.find('#url');
const findAuthorizationKey = () => wrapper.find('#authorization-key');
const findApiUrl = () => wrapper.find('#api-url');
@@ -115,13 +120,13 @@ describe('AlertsSettingsForm', () => {
describe('activate toggle', () => {
it('triggers toggleActivated method', () => {
- const toggleActivated = jest.fn();
- const methods = { toggleActivated };
+ const toggleService = jest.fn();
+ const methods = { toggleService };
createComponent(defaultProps, { methods });
wrapper.find(ToggleButton).vm.$emit('change', true);
- expect(toggleActivated).toHaveBeenCalled();
+ expect(toggleService).toHaveBeenCalled();
});
describe('error is encountered', () => {
@@ -149,7 +154,7 @@ describe('AlertsSettingsForm', () => {
});
it('renders a valid "select"', () => {
- expect(findSelect().html()).toMatchSnapshot();
+ expect(findSelect().exists()).toBe(true);
});
it('shows the API URL input', () => {
@@ -160,9 +165,53 @@ describe('AlertsSettingsForm', () => {
expect(findUrl().exists()).toBe(true);
expect(findUrl().attributes('value')).toBe(PROMETHEUS_URL);
});
+ });
+
+ describe('trigger test alert', () => {
+ beforeEach(() => {
+ createComponent({ generic: { ...defaultProps.generic, initialActivated: true } }, {}, true);
+ });
+
+ it('should enable the JSON input', () => {
+ expect(findJsonInput().exists()).toBe(true);
+ expect(findJsonInput().props('value')).toBe(null);
+ });
+
+ it('should validate JSON input', () => {
+ createComponent({ generic: { ...defaultProps.generic } }, {}, true, {
+ testAlertJson: '{ "value": "test" }',
+ });
+
+ findJsonInput().vm.$emit('change');
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findJsonInput().attributes('state')).toBe('true');
+ });
+ });
- it('should not show a footer block', () => {
- expect(wrapper.find('.footer-block').classes('d-none')).toBe(true);
+ describe('alert service is toggled', () => {
+ it('should show a info alert if successful', () => {
+ const formPath = 'some/path';
+ const toggleService = true;
+ mockAxios.onPut(formPath).replyOnce(200);
+
+ createComponent({ generic: { ...defaultProps.generic, formPath } });
+
+ return wrapper.vm.toggleGenericActivated(toggleService).then(() => {
+ expect(wrapper.find(GlAlert).attributes('variant')).toBe('info');
+ });
+ });
+
+ it('should show a error alert if failed', () => {
+ const formPath = 'some/path';
+ const toggleService = true;
+ mockAxios.onPut(formPath).replyOnce(404);
+
+ createComponent({ generic: { ...defaultProps.generic, formPath } });
+
+ return wrapper.vm.toggleGenericActivated(toggleService).then(() => {
+ expect(wrapper.find(GlAlert).attributes('variant')).toBe('danger');
+ });
+ });
});
});
});
diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issuables_list/components/issuable_spec.js
index acbb1d325b8..244207a6582 100644
--- a/spec/frontend/issuables_list/components/issuable_spec.js
+++ b/spec/frontend/issuables_list/components/issuable_spec.js
@@ -91,6 +91,8 @@ describe('Issuable component', () => {
const findBulkCheckbox = () => wrapper.find('input.selected-issuable');
const findScopedLabels = () => findLabels().filter(w => isScopedLabel({ title: w.text() }));
const findUnscopedLabels = () => findLabels().filter(w => !isScopedLabel({ title: w.text() }));
+ const findIssuableTitle = () => wrapper.find('[data-testid="issuable-title"]');
+ const containsJiraLogo = () => wrapper.contains('[data-testid="jira-logo"]');
describe('when mounted', () => {
it('initializes user popovers', () => {
@@ -217,6 +219,22 @@ describe('Issuable component', () => {
});
});
+ describe('with Jira issuable', () => {
+ beforeEach(() => {
+ issuable.external_tracker = 'jira';
+
+ factory({ issuable });
+ });
+
+ it('renders the Jira icon', () => {
+ expect(containsJiraLogo()).toBe(true);
+ });
+
+ it('opens issuable in a new tab', () => {
+ expect(findIssuableTitle().props('target')).toBe('_blank');
+ });
+ });
+
describe('with task status', () => {
beforeEach(() => {
Object.assign(issuable, {
diff --git a/spec/frontend/issuables_list/components/issuables_list_app_spec.js b/spec/frontend/issuables_list/components/issuables_list_app_spec.js
index 6b680af354e..9bd4a994f0a 100644
--- a/spec/frontend/issuables_list/components/issuables_list_app_spec.js
+++ b/spec/frontend/issuables_list/components/issuables_list_app_spec.js
@@ -454,43 +454,73 @@ describe('Issuables list component', () => {
describe('when paginates', () => {
const newPage = 3;
- beforeEach(() => {
- window.history.pushState = jest.fn();
- setupApiMock(() => [
- 200,
- MOCK_ISSUES.slice(0, PAGE_SIZE),
- {
- 'x-total': 100,
- 'x-page': 2,
- },
- ]);
+ describe('when total-items is defined in response headers', () => {
+ beforeEach(() => {
+ window.history.pushState = jest.fn();
+ setupApiMock(() => [
+ 200,
+ MOCK_ISSUES.slice(0, PAGE_SIZE),
+ {
+ 'x-total': 100,
+ 'x-page': 2,
+ },
+ ]);
- factory();
+ factory();
- return waitForPromises();
- });
+ return waitForPromises();
+ });
- afterEach(() => {
- // reset to original value
- window.history.pushState.mockRestore();
- });
+ afterEach(() => {
+ // reset to original value
+ window.history.pushState.mockRestore();
+ });
+
+ it('calls window.history.pushState one time', () => {
+ // Trigger pagination
+ wrapper.find(GlPagination).vm.$emit('input', newPage);
+
+ expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ });
- it('calls window.history.pushState one time', () => {
- // Trigger pagination
- wrapper.find(GlPagination).vm.$emit('input', newPage);
+ it('sets params in the url', () => {
+ // Trigger pagination
+ wrapper.find(GlPagination).vm.$emit('input', newPage);
- expect(window.history.pushState).toHaveBeenCalledTimes(1);
+ expect(window.history.pushState).toHaveBeenCalledWith(
+ {},
+ '',
+ `${TEST_LOCATION}?state=opened&order_by=priority&sort=asc&page=${newPage}`,
+ );
+ });
});
- it('sets params in the url', () => {
- // Trigger pagination
- wrapper.find(GlPagination).vm.$emit('input', newPage);
+ describe('when total-items is not defined in the headers', () => {
+ const page = 2;
+ const prevPage = page - 1;
+ const nextPage = page + 1;
- expect(window.history.pushState).toHaveBeenCalledWith(
- {},
- '',
- `${TEST_LOCATION}?state=opened&order_by=priority&sort=asc&page=${newPage}`,
- );
+ beforeEach(() => {
+ setupApiMock(() => [
+ 200,
+ MOCK_ISSUES.slice(0, PAGE_SIZE),
+ {
+ 'x-page': page,
+ },
+ ]);
+
+ factory();
+
+ return waitForPromises();
+ });
+
+ it('finds the correct props applied to GlPagination', () => {
+ expect(wrapper.find(GlPagination).props()).toMatchObject({
+ nextPage,
+ prevPage,
+ value: page,
+ });
+ });
});
});
});
diff --git a/spec/helpers/operations_helper_spec.rb b/spec/helpers/operations_helper_spec.rb
index aba67a5ecdc..40f92c538e0 100644
--- a/spec/helpers/operations_helper_spec.rb
+++ b/spec/helpers/operations_helper_spec.rb
@@ -45,7 +45,9 @@ RSpec.describe OperationsHelper do
end
context 'with external Prometheus configured' do
- let_it_be(:prometheus_service, reload: true) { create(:prometheus_service, project: project) }
+ let_it_be(:prometheus_service, reload: true) do
+ create(:prometheus_service, project: project)
+ end
context 'with external Prometheus enabled' do
it 'returns the correct values' do
@@ -57,16 +59,31 @@ RSpec.describe OperationsHelper do
end
context 'with external Prometheus disabled' do
+ shared_examples 'Prometheus is disabled' do
+ it 'returns the correct values' do
+ expect(subject).to include(
+ 'prometheus_activated' => 'false',
+ 'prometheus_api_url' => prometheus_service.api_url
+ )
+ end
+ end
+
+ let(:cluster_managed) { false }
+
before do
- # Prometheus services uses manual_configuration as an alias for active, beware
+ allow(prometheus_service)
+ .to receive(:prometheus_available?)
+ .and_return(cluster_managed)
+
prometheus_service.update!(manual_configuration: false)
end
- it 'returns the correct values' do
- expect(subject).to include(
- 'prometheus_activated' => 'false',
- 'prometheus_api_url' => prometheus_service.api_url
- )
+ include_examples 'Prometheus is disabled'
+
+ context 'when cluster managed' do
+ let(:cluster_managed) { true }
+
+ include_examples 'Prometheus is disabled'
end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index ab59a79b3e5..73975874d1c 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -17,6 +17,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(described_class.uncached_data).to include(:usage_activity_by_stage_monthly)
end
+ it 'clears memoized values' do
+ %i(issue_minimum_id issue_maximum_id user_minimum_id user_maximum_id unique_visit_service).each do |key|
+ expect(described_class).to receive(:clear_memoization).with(key)
+ end
+
+ described_class.uncached_data
+ end
+
context 'for configure' do
it 'includes accurate usage_activity_by_stage data' do
for_defined_days_back do
@@ -151,6 +159,30 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
+ context 'for release' do
+ it 'includes accurate usage_activity_by_stage data' do
+ for_defined_days_back do
+ user = create(:user)
+ create(:deployment, :failed, user: user)
+ create(:release, author: user)
+ create(:deployment, :success, user: user)
+ end
+
+ expect(described_class.uncached_data[:usage_activity_by_stage][:release]).to include(
+ deployments: 2,
+ failed_deployments: 2,
+ releases: 2,
+ successful_deployments: 2
+ )
+ expect(described_class.uncached_data[:usage_activity_by_stage_monthly][:release]).to include(
+ deployments: 1,
+ failed_deployments: 1,
+ releases: 1,
+ successful_deployments: 1
+ )
+ end
+ end
+
it 'ensures recorded_at is set before any other usage data calculation' do
%i(alt_usage_data redis_usage_data distinct_count count).each do |method|
expect(described_class).not_to receive(method)
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index 99a38a1c8c1..857b238981b 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -3301,17 +3301,6 @@ RSpec.describe Ci::Build do
expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1')
end
end
-
- context 'when CI instance variables are disabled' do
- before do
- create(:ci_instance_variable, key: 'MY_VAR', value: 'my value 1')
- stub_feature_flags(ci_instance_level_variables: false)
- end
-
- it 'does not include instance level variables' do
- expect(build.scoped_variables_hash).not_to include('MY_VAR': 'my value 1')
- end
- end
end
describe '#any_unmet_prerequisites?' do
diff --git a/spec/models/ci/instance_variable_spec.rb b/spec/models/ci/instance_variable_spec.rb
index 8ee2c1b2c8d..344ba5bfafd 100644
--- a/spec/models/ci/instance_variable_spec.rb
+++ b/spec/models/ci/instance_variable_spec.rb
@@ -15,21 +15,6 @@ RSpec.describe Ci::InstanceVariable do
subject { build(:ci_instance_variable) }
end
- context 'with instance level variable feature flag disabled' do
- let(:plan_limits) { create(:plan_limits, :default_plan) }
-
- before do
- stub_feature_flags(ci_instance_level_variables_limit: false)
- plan_limits.update(described_class.limit_name => 1)
- create(:ci_instance_variable)
- end
-
- it 'can create new models exceeding the plan limits', :aggregate_failures do
- expect { subject.save }.to change { described_class.count }
- expect(subject.errors[:base]).to be_empty
- end
- end
-
describe '.unprotected' do
subject { described_class.unprotected }
diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb
index 844f62d7404..3f9c6981de1 100644
--- a/spec/models/snippet_spec.rb
+++ b/spec/models/snippet_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe Snippet do
it { is_expected.to have_many(:award_emoji).dependent(:destroy) }
it { is_expected.to have_many(:user_mentions).class_name("SnippetUserMention") }
it { is_expected.to have_one(:snippet_repository) }
- it { is_expected.to have_one(:statistics).class_name('SnippetStatistics') }
+ it { is_expected.to have_one(:statistics).class_name('SnippetStatistics').dependent(:destroy) }
end
describe 'validation' do
diff --git a/spec/models/snippet_statistics_spec.rb b/spec/models/snippet_statistics_spec.rb
index 57a3f844a8a..ad25bd7b3be 100644
--- a/spec/models/snippet_statistics_spec.rb
+++ b/spec/models/snippet_statistics_spec.rb
@@ -86,4 +86,64 @@ RSpec.describe SnippetStatistics do
subject
end
end
+
+ context 'with a PersonalSnippet' do
+ let!(:snippet) { create(:personal_snippet, :repository) }
+
+ shared_examples 'personal snippet statistics updates' do
+ it 'schedules a namespace statistics worker' do
+ expect(Namespaces::ScheduleAggregationWorker)
+ .to receive(:perform_async).once
+
+ statistics.save!
+ end
+
+ it 'does not try to update project stats' do
+ expect(statistics).not_to receive(:schedule_update_project_statistic)
+
+ statistics.save!
+ end
+ end
+
+ context 'when creating' do
+ let(:statistics) { build(:snippet_statistics, snippet_id: snippet.id, with_data: true) }
+
+ before do
+ snippet.statistics.delete
+ end
+
+ it_behaves_like 'personal snippet statistics updates'
+ end
+
+ context 'when updating' do
+ let(:statistics) { snippet.statistics }
+
+ before do
+ snippet.statistics.repository_size = 123
+ end
+
+ it_behaves_like 'personal snippet statistics updates'
+ end
+ end
+
+ context 'with a ProjectSnippet' do
+ let!(:snippet) { create(:project_snippet) }
+
+ it_behaves_like 'UpdateProjectStatistics' do
+ subject { build(:snippet_statistics, snippet: snippet, id: snippet.id, with_data: true) }
+
+ before do
+ # The shared examples requires the snippet statistics not to be present
+ snippet.statistics.delete
+ snippet.reload
+ end
+ end
+
+ it 'does not call personal snippet callbacks' do
+ expect(snippet.statistics).not_to receive(:update_author_root_storage_statistics)
+ expect(snippet.statistics).to receive(:schedule_update_project_statistic)
+
+ snippet.statistics.update!(repository_size: 123)
+ end
+ end
end
diff --git a/spec/requests/api/variables_spec.rb b/spec/requests/api/variables_spec.rb
index 0cedfe578e3..7bb73e9664b 100644
--- a/spec/requests/api/variables_spec.rb
+++ b/spec/requests/api/variables_spec.rb
@@ -54,6 +54,59 @@ RSpec.describe API::Variables do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ context 'when there are two variables with the same key on different env' do
+ let!(:var1) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'staging') }
+ let!(:var2) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'production') }
+
+ context 'when filter[environment_scope] is not passed' do
+ context 'FF ci_variables_api_filter_environment_scope is enabled' do
+ it 'returns 409' do
+ get api("/projects/#{project.id}/variables/key1", user)
+
+ expect(response).to have_gitlab_http_status(:conflict)
+ end
+ end
+
+ context 'FF ci_variables_api_filter_environment_scope is disabled' do
+ before do
+ stub_feature_flags(ci_variables_api_filter_environment_scope: false)
+ end
+
+ it 'returns random one' do
+ get api("/projects/#{project.id}/variables/key1", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['key']).to eq('key1')
+ end
+ end
+ end
+
+ context 'when filter[environment_scope] is passed' do
+ it 'returns the variable' do
+ get api("/projects/#{project.id}/variables/key1", user), params: { 'filter[environment_scope]': 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['value']).to eq(var2.value)
+ end
+ end
+
+ context 'when wrong filter[environment_scope] is passed' do
+ it 'returns not_found' do
+ get api("/projects/#{project.id}/variables/key1", user), params: { 'filter[environment_scope]': 'invalid' }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'when there is only one variable with provided key' do
+ it 'returns not_found' do
+ get api("/projects/#{project.id}/variables/#{variable.key}", user), params: { 'filter[environment_scope]': 'invalid' }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
end
context 'authorized user with invalid permissions' do
@@ -173,6 +226,52 @@ RSpec.describe API::Variables do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ context 'when there are two variables with the same key on different env' do
+ let!(:var1) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'staging') }
+ let!(:var2) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'production') }
+
+ context 'when filter[environment_scope] is not passed' do
+ context 'FF ci_variables_api_filter_environment_scope is enabled' do
+ it 'returns 409' do
+ get api("/projects/#{project.id}/variables/key1", user)
+
+ expect(response).to have_gitlab_http_status(:conflict)
+ end
+ end
+
+ context 'FF ci_variables_api_filter_environment_scope is disabled' do
+ before do
+ stub_feature_flags(ci_variables_api_filter_environment_scope: false)
+ end
+
+ it 'updates random one' do
+ put api("/projects/#{project.id}/variables/key1", user), params: { value: 'new_val' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['value']).to eq('new_val')
+ end
+ end
+ end
+
+ context 'when filter[environment_scope] is passed' do
+ it 'updates the variable' do
+ put api("/projects/#{project.id}/variables/key1", user), params: { value: 'new_val', 'filter[environment_scope]': 'production' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(var1.reload.value).not_to eq('new_val')
+ expect(var2.reload.value).to eq('new_val')
+ end
+ end
+
+ context 'when wrong filter[environment_scope] is passed' do
+ it 'returns not_found' do
+ put api("/projects/#{project.id}/variables/key1", user), params: { value: 'new_val', 'filter[environment_scope]': 'invalid' }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
end
context 'authorized user with invalid permissions' do
@@ -207,6 +306,56 @@ RSpec.describe API::Variables do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ context 'when there are two variables with the same key on different env' do
+ let!(:var1) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'staging') }
+ let!(:var2) { create(:ci_variable, project: project, key: 'key1', environment_scope: 'production') }
+
+ context 'when filter[environment_scope] is not passed' do
+ context 'FF ci_variables_api_filter_environment_scope is enabled' do
+ it 'returns 409' do
+ get api("/projects/#{project.id}/variables/key1", user)
+
+ expect(response).to have_gitlab_http_status(:conflict)
+ end
+ end
+
+ context 'FF ci_variables_api_filter_environment_scope is disabled' do
+ before do
+ stub_feature_flags(ci_variables_api_filter_environment_scope: false)
+ end
+
+ it 'deletes random one' do
+ expect do
+ delete api("/projects/#{project.id}/variables/key1", user), params: { 'filter[environment_scope]': 'production' }
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end.to change {project.variables.count}.by(-1)
+ end
+ end
+ end
+
+ context 'when filter[environment_scope] is passed' do
+ it 'deletes the variable' do
+ expect do
+ delete api("/projects/#{project.id}/variables/key1", user), params: { 'filter[environment_scope]': 'production' }
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end.to change {project.variables.count}.by(-1)
+
+ expect(var1.reload).to be_present
+ expect { var2.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ context 'when wrong filter[environment_scope] is passed' do
+ it 'returns not_found' do
+ delete api("/projects/#{project.id}/variables/key1", user), params: { 'filter[environment_scope]': 'invalid' }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
end
context 'authorized user with invalid permissions' do
diff --git a/spec/services/snippets/destroy_service_spec.rb b/spec/services/snippets/destroy_service_spec.rb
index 70862e0be17..e53d00b9ca1 100644
--- a/spec/services/snippets/destroy_service_spec.rb
+++ b/spec/services/snippets/destroy_service_spec.rb
@@ -106,11 +106,24 @@ RSpec.describe Snippets::DestroyService do
it_behaves_like 'a successful destroy'
it_behaves_like 'deletes the snippet repository'
- it 'schedules a project cache update for snippet_size' do
- expect(ProjectCacheWorker).to receive(:perform_async)
- .with(snippet.project_id, [], [:snippets_size])
+ context 'project statistics' do
+ before do
+ snippet.statistics.refresh!
+ end
- subject
+ it 'updates stats after deletion' do
+ expect(project.reload.statistics.snippets_size).not_to be_zero
+
+ subject
+
+ expect(project.reload.statistics.snippets_size).to be_zero
+ end
+
+ it 'schedules a namespace statistics update' do
+ expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(project.namespace_id).once
+
+ subject
+ end
end
end
@@ -130,8 +143,8 @@ RSpec.describe Snippets::DestroyService do
it_behaves_like 'a successful destroy'
it_behaves_like 'deletes the snippet repository'
- it 'does not schedule a project cache update' do
- expect(ProjectCacheWorker).not_to receive(:perform_async)
+ it 'schedules a namespace statistics update' do
+ expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(author.namespace_id)
subject
end
diff --git a/spec/services/snippets/update_statistics_service_spec.rb b/spec/services/snippets/update_statistics_service_spec.rb
index b4c4b067c51..27ae054676a 100644
--- a/spec/services/snippets/update_statistics_service_spec.rb
+++ b/spec/services/snippets/update_statistics_service_spec.rb
@@ -17,17 +17,6 @@ RSpec.describe Snippets::UpdateStatisticsService do
subject
end
- it 'schedules project cache worker based on type' do
- if snippet.project_id
- expect(ProjectCacheWorker).to receive(:perform_async)
- .with(snippet.project_id, [], [:snippets_size])
- else
- expect(ProjectCacheWorker).not_to receive(:perform_async)
- end
-
- subject
- end
-
context 'when snippet statistics does not exist' do
it 'creates snippet statistics' do
snippet.statistics.delete
@@ -64,6 +53,13 @@ RSpec.describe Snippets::UpdateStatisticsService do
expect(subject).to be_error
end
end
+
+ it 'schedules a namespace storage statistics update' do
+ expect(Namespaces::ScheduleAggregationWorker)
+ .to receive(:perform_async).once
+
+ subject
+ end
end
context 'with PersonalSnippet' do
@@ -74,8 +70,17 @@ RSpec.describe Snippets::UpdateStatisticsService do
context 'with ProjectSnippet' do
let!(:snippet) { create(:project_snippet, :repository) }
+ let(:project_statistics) { snippet.project.statistics }
it_behaves_like 'updates statistics'
+
+ it 'updates projects statistics "snippets_size"' do
+ expect(project_statistics.snippets_size).to be_zero
+
+ subject
+
+ expect(snippet.reload.statistics.repository_size).to eq project_statistics.reload.snippets_size
+ end
end
end
end