提交 21341457 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 0a319374
......@@ -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') }}
......
......@@ -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;
/* 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}`,
},
});
},
};
<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>
......
......@@ -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`"
......
......@@ -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>
......
import initIssuablesList from '~/issuables_list';
document.addEventListener('DOMContentLoaded', () => {
initIssuablesList();
});
.svg-container.jira-logo-container {
svg {
vertical-align: text-bottom;
}
}
......@@ -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%;
}
# 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
......@@ -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),
......
......@@ -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
......@@ -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
......@@ -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
......
......@@ -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
......
......@@ -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) }
......
......@@ -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
......
# 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
......@@ -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)
......
......@@ -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
......
......@@ -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")
......
......@@ -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
---
title: Move release stage usage activity to CE
merge_request: 36083
author:
type: changed
---
title: Add environment_scope filter to ci-variables API
merge_request: 34490
author:
type: fixed
---
title: Update namespace statistics after personal snippet update/removal
merge_request: 36031
author:
type: changed
# 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)
```
......@@ -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)
```
......@@ -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
......
......@@ -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 |
......
......@@ -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.
......
......@@ -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
......
......@@ -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)
......
......@@ -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 ""
......
# 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
# 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
// 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>"
`;
......@@ -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');
});
});
});
});
});
......@@ -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, {
......
......@@ -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,
});
});
});
});
});
......@@ -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
......
......@@ -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)
......
......@@ -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
......
......@@ -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 }
......
......@@ -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
......
......@@ -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
......@@ -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
......
......@@ -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
......
......@@ -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
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册