Commit e3b7bfd9 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 6b15cc43 06ab148d
......@@ -555,6 +555,7 @@
when: never
- <<: *if-merge-request
changes: *db-patterns
when: manual
.rails:rules:ee-and-foss-unit:
rules:
......
workhorse:verify:
extends: .workhorse:rules:workhorse
image: ${GITLAB_DEPENDENCY_PROXY}golang:1.15
image: ${GITLAB_DEPENDENCY_PROXY}golang:1.16
stage: test
needs: []
script:
......@@ -23,14 +23,10 @@ workhorse:verify:
- apt-get update && apt-get -y install libimage-exiftool-perl
- make -C workhorse test
workhorse:test using go 1.13:
extends: .workhorse:test
image: ${GITLAB_DEPENDENCY_PROXY}golang:1.13
workhorse:test using go 1.14:
extends: .workhorse:test
image: ${GITLAB_DEPENDENCY_PROXY}golang:1.14
workhorse:test using go 1.15:
extends: .workhorse:test
image: ${GITLAB_DEPENDENCY_PROXY}golang:1.15
workhorse:test using go 1.16:
extends: .workhorse:test
image: ${GITLAB_DEPENDENCY_PROXY}golang:1.16
......@@ -431,8 +431,6 @@ RSpec/EmptyLineAfterFinalLetItBe:
- ee/spec/controllers/projects/merge_requests_controller_spec.rb
- ee/spec/controllers/projects/mirrors_controller_spec.rb
- ee/spec/controllers/projects/threat_monitoring_controller_spec.rb
- ee/spec/controllers/registrations/groups_controller_spec.rb
- ee/spec/controllers/registrations/projects_controller_spec.rb
- ee/spec/controllers/subscriptions_controller_spec.rb
- ee/spec/features/boards/group_boards/multiple_boards_spec.rb
- ee/spec/features/ci_shared_runner_warnings_spec.rb
......@@ -932,9 +930,6 @@ RSpec/EmptyLineAfterFinalLetItBe:
- spec/services/audit_event_service_spec.rb
- spec/services/auth/dependency_proxy_authentication_service_spec.rb
- spec/services/auto_merge_service_spec.rb
- spec/services/award_emojis/add_service_spec.rb
- spec/services/award_emojis/destroy_service_spec.rb
- spec/services/award_emojis/toggle_service_spec.rb
- spec/services/boards/destroy_service_spec.rb
- spec/services/boards/issues/move_service_spec.rb
- spec/services/bulk_create_integration_service_spec.rb
......@@ -968,9 +963,6 @@ RSpec/EmptyLineAfterFinalLetItBe:
- spec/services/design_management/save_designs_service_spec.rb
- spec/services/discussions/resolve_service_spec.rb
- spec/services/discussions/unresolve_service_spec.rb
- spec/services/environments/auto_stop_service_spec.rb
- spec/services/environments/canary_ingress/update_service_spec.rb
- spec/services/environments/reset_auto_stop_service_spec.rb
- spec/services/feature_flags/create_service_spec.rb
- spec/services/feature_flags/destroy_service_spec.rb
- spec/services/feature_flags/disable_service_spec.rb
......
......@@ -7,6 +7,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-buy-pipeline-minutes-notification-callout',
'.js-token-expiry-callout',
'.js-registration-enabled-callout',
'.js-service-templates-deprecated-callout',
'.js-new-user-signups-cap-reached',
'.js-eoa-bronze-plan-banner',
];
......
......@@ -190,12 +190,8 @@ module DiffHelper
def render_overflow_warning?(diffs_collection)
diff_files = diffs_collection.raw_diff_files
if diff_files.any?(&:too_large?)
Gitlab::Metrics.add_event(:diffs_overflow_single_file_limits)
end
diff_files.overflow?.tap do |overflown|
Gitlab::Metrics.add_event(:diffs_overflow_collection_limits) if overflown
log_overflow_limits(diff_files)
end
end
......@@ -286,4 +282,18 @@ module DiffHelper
conflicts_service.conflicts.files.index_by(&:our_path)
end
def log_overflow_limits(diff_files)
if diff_files.any?(&:too_large?)
Gitlab::Metrics.add_event(:diffs_overflow_single_file_limits)
end
Gitlab::Metrics.add_event(:diffs_overflow_collection_limits) if diff_files.overflow?
Gitlab::Metrics.add_event(:diffs_overflow_max_bytes_limits) if diff_files.overflow_max_bytes?
Gitlab::Metrics.add_event(:diffs_overflow_max_files_limits) if diff_files.overflow_max_files?
Gitlab::Metrics.add_event(:diffs_overflow_max_lines_limits) if diff_files.overflow_max_lines?
Gitlab::Metrics.add_event(:diffs_overflow_collapsed_bytes_limits) if diff_files.collapsed_safe_bytes?
Gitlab::Metrics.add_event(:diffs_overflow_collapsed_files_limits) if diff_files.collapsed_safe_files?
Gitlab::Metrics.add_event(:diffs_overflow_collapsed_lines_limits) if diff_files.collapsed_safe_lines?
end
end
......@@ -5,7 +5,7 @@ module UserCalloutsHelper
GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'
GCP_SIGNUP_OFFER = 'gcp_signup_offer'
SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed'
SERVICE_TEMPLATES_DEPRECATED = 'service_templates_deprecated'
SERVICE_TEMPLATES_DEPRECATED_CALLOUT = 'service_templates_deprecated_callout'
TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
WEBHOOKS_MOVED = 'webhooks_moved'
CUSTOMIZE_HOMEPAGE = 'customize_homepage'
......@@ -41,8 +41,11 @@ module UserCalloutsHelper
!user_dismissed?(SUGGEST_POPOVER_DISMISSED)
end
def show_service_templates_deprecated?
!user_dismissed?(SERVICE_TEMPLATES_DEPRECATED)
def show_service_templates_deprecated_callout?
!Gitlab.com? &&
current_user&.admin? &&
Service.for_template.active.exists? &&
!user_dismissed?(SERVICE_TEMPLATES_DEPRECATED_CALLOUT)
end
def show_webhooks_moved_alert?
......
......@@ -179,18 +179,6 @@ class CommitStatus < ApplicationRecord
ExpireJobCacheWorker.perform_async(id)
end
end
after_transition any => :failed do |commit_status|
next if Feature.enabled?(:async_add_build_failure_todo, commit_status.project, default_enabled: :yaml)
next unless commit_status.project
# rubocop: disable CodeReuse/ServiceClass
commit_status.run_after_commit do
MergeRequests::AddTodoWhenBuildFailsService
.new(project, nil).execute(self)
end
# rubocop: enable CodeReuse/ServiceClass
end
end
def self.names
......
......@@ -3,6 +3,8 @@
require "discordrb/webhooks"
class DiscordService < ChatNotificationService
include ActionView::Helpers::UrlHelper
ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze
def title
......@@ -10,7 +12,7 @@ class DiscordService < ChatNotificationService
end
def description
s_("DiscordService|Receive event notifications in Discord")
s_("DiscordService|Send notifications about project events to a Discord channel.")
end
def self.to_param
......@@ -18,13 +20,8 @@ class DiscordService < ChatNotificationService
end
def help
"This service sends notifications about project events to Discord channels.<br />
To set up this service:
<ol>
<li><a href='https://support.discordapp.com/hc/en-us/articles/228383668-Intro-to-Webhooks'>Setup a custom Incoming Webhook</a>.</li>
<li>Paste the <strong>Webhook URL</strong> into the field below.</li>
<li>Select events below to enable notifications.</li>
</ol>"
docs_link = link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer'
s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
def event_field(event)
......@@ -36,13 +33,12 @@ class DiscordService < ChatNotificationService
end
def self.supported_events
%w[push issue confidential_issue merge_request note confidential_note tag_push
pipeline wiki_page]
%w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page]
end
def default_fields
[
{ type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" },
{ type: "text", name: "webhook", placeholder: "https://discordapp.com/api/webhooks/…", help: "URL to the webhook for the Discord channel." },
{ type: "checkbox", name: "notify_only_broken_pipelines" },
{ type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
]
......
......@@ -17,7 +17,7 @@ class UserCallout < ApplicationRecord
threat_monitoring_info: 11, # EE-only
account_recovery_regular_check: 12, # EE-only
webhooks_moved: 13,
service_templates_deprecated: 14,
service_templates_deprecated_callout: 14,
admin_integrations_moved: 15,
web_ide_alert_dismissed: 16, # no longer in use
active_user_count_threshold: 18, # EE-only
......
......@@ -11,6 +11,7 @@
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
= render "layouts/header/registration_enabled_callout"
= render "layouts/header/service_templates_deprecation_callout"
= render "layouts/nav/classification_level_banner"
= yield :flash_message
= render "shared/ping_consent"
......
- return unless show_service_templates_deprecated_callout?
- doc_link_start = "<a href=\"#{integrations_help_page_path}\" target='_blank' rel='noopener noreferrer'>".html_safe
- settings_link_start = "<a href=\"#{integrations_admin_application_settings_path}\">".html_safe
%div{ class: [container_class, @content_class, 'gl-pt-5!'] }
.gl-alert.gl-alert-warning.js-service-templates-deprecated-callout{ role: 'alert', data: { feature_id: UserCalloutsHelper::SERVICE_TEMPLATES_DEPRECATED_CALLOUT, dismiss_endpoint: user_callouts_path } }
= sprite_icon('warning', size: 16, css_class: 'gl-alert-icon')
%button.gl-alert-dismiss.js-close{ type: 'button', aria: { label: _('Close') }, data: { testid: 'close-service-templates-deprecated-callout' } }
= sprite_icon('close', size: 16)
.gl-alert-title
= s_('AdminSettings|Service templates are deprecated and will be removed in GitLab 14.0.')
.gl-alert-body
= html_escape_once(s_('AdminSettings|You should migrate to %{doc_link_start}Project integration management%{link_end}, available at %{settings_link_start}Settings &gt; Integrations.%{link_end}')).html_safe % { doc_link_start: doc_link_start, settings_link_start: settings_link_start, link_end: '</a>'.html_safe }
.gl-alert-actions
= link_to admin_application_settings_services_path, class: 'btn gl-alert-action btn-info btn-md gl-button' do
%span.gl-button-text
= s_('AdminSettings|See affected service templates')
= link_to "https://gitlab.com/gitlab-org/gitlab/-/issues/325905", class: 'btn gl-alert-action btn-default btn-md gl-button', target: '_blank', rel: 'noopener noreferrer' do
%span.gl-button-text
= _('Leave feedback')
......@@ -37,7 +37,7 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
ExpirePipelineCacheWorker.perform_async(build.pipeline_id)
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
if build.failed? && Feature.enabled?(:async_add_build_failure_todo, build.project, default_enabled: :yaml)
if build.failed?
::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id)
end
......
---
title: Track the different overflows for diff collections
merge_request: 57790
author:
type: other
---
title: Add global callout for Service template deprecation
merge_request: 58613
author:
type: changed
---
title: Add new MergeRequests::SyncCodeOwnerApprovalRulesWorker
merge_request: 58512
author:
type: performance
---
title: Improve performance by moving TODO creation out of the jobs/request path
merge_request: 59022
author:
type: performance
---
title: Fix EmptyLineAfterFinalLetItBe offenses in spec/services/award_emojis
merge_request: 58407
author: Huzaifa Iftikhar @huzaifaiftikhar
type: fixed
---
title: Fix EmptyLineAfterFinalLetItBe offenses in spec/services/environments
merge_request: 58418
author: Huzaifa Iftikhar @huzaifaiftikhar
type: fixed
---
title: Bump minimum required Go version for workhorse to 1.15
merge_request: 59347
author:
type: other
---
title: Update Discord integration UI text
merge_request: 58842
author:
type: other
---
name: async_add_build_failure_todo
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57490/diffs
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/326726
milestone: '13.11'
type: development
group: group::continuous integration
default_enabled: true
......@@ -220,6 +220,8 @@
- 1
- - merge_requests_resolve_todos
- 1
- - merge_requests_sync_code_owner_approval_rules
- 1
- - metrics_dashboard_prune_old_annotations
- 1
- - metrics_dashboard_sync_dashboards
......
......@@ -250,10 +250,11 @@ configuration option in `gitlab.yml`. These metrics are served from the
The following metrics are available:
| Metric | Type | Since | Description |
|:--------------------------------- |:--------- |:------------------------------------------------------------- |:-------------------------------------- |
| `db_load_balancing_hosts` | Gauge | [12.3](https://gitlab.com/gitlab-org/gitlab/-/issues/13630) | Current number of load balancing hosts |
| Metric | Type | Since | Description | Labels |
|:--------------------------------- |:--------- |:------------------------------------------------------------- |:-------------------------------------- |:--------------------------------------------------------- |
| `db_load_balancing_hosts` | Gauge | [12.3](https://gitlab.com/gitlab-org/gitlab/-/issues/13630) | Current number of load balancing hosts | |
| `sidekiq_load_balancing_count` | Counter | 13.11 | Sidekiq jobs using load balancing with data consistency set to :sticky or :delayed | `queue`, `boundary`, `external_dependencies`, `feature_category`, `job_status`, `urgency`, `data_consistency`, `database_chosen` |
## Database partitioning metrics **(PREMIUM SELF)**
The following metrics are available:
......
......@@ -63,6 +63,12 @@ Returns [`ContainerRepositoryDetails`](#containerrepositorydetails).
| ---- | ---- | ----------- |
| `id` | [`ContainerRepositoryID!`](#containerrepositoryid) | The global ID of the container repository. |
### `currentLicense`
Fields related to the current license.
Returns [`CurrentLicense`](#currentlicense).
### `currentUser`
Get information about current user.
......@@ -181,6 +187,21 @@ Returns [`Iteration`](#iteration).
| ---- | ---- | ----------- |
| `id` | [`IterationID!`](#iterationid) | Find an iteration by its ID. |
### `licenseHistoryEntries`
Fields related to entries in the license history.
Returns [`LicenseHistoryEntryConnection`](#licensehistoryentryconnection).
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| `after` | [`String`](#string) | Returns the elements in the list that come after the specified cursor. |
| `before` | [`String`](#string) | Returns the elements in the list that come before the specified cursor. |
| `first` | [`Int`](#int) | Returns the first _n_ elements from the list. |
| `last` | [`Int`](#int) | Returns the last _n_ elements from the list. |
### `metadata`
Metadata about GitLab.
......@@ -1872,6 +1893,27 @@ Autogenerated return type of CreateTestCase.
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `testCase` | [`Issue`](#issue) | The test case created. |
### `CurrentLicense`
Represents the current license.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `activatedAt` | [`Date`](#date) | Date when the license was activated. |
| `billableUsersCount` | [`Int`](#int) | Number of billable users on the system. |
| `company` | [`String`](#string) | Company of the licensee. |
| `email` | [`String`](#string) | Email of the licensee. |
| `expiresAt` | [`Date`](#date) | Date when the license expires. |
| `id` | [`ID!`](#id) | ID of the license. |
| `lastSync` | [`Time`](#time) | Date when the license was last synced. |
| `maximumUserCount` | [`Int`](#int) | Highest number of billable users on the system during the term of the current license. |
| `name` | [`String`](#string) | Name of the licensee. |
| `plan` | [`String!`](#string) | Name of the subscription plan. |
| `startsAt` | [`Date`](#date) | Date when the license started. |
| `type` | [`String!`](#string) | Type of the license. |
| `usersInLicenseCount` | [`Int`](#int) | Number of paid users in the license. |
| `usersOverLicenseCount` | [`Int`](#int) | Number of users over the paid users in the license. |
### `CustomEmoji`
A custom emoji uploaded by user.
......@@ -3874,6 +3916,42 @@ An edge in a connection.
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`Label`](#label) | The item at the end of the edge. |
### `LicenseHistoryEntry`
Represents an entry from the Cloud License history.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `activatedAt` | [`Date`](#date) | Date when the license was activated. |
| `company` | [`String`](#string) | Company of the licensee. |
| `email` | [`String`](#string) | Email of the licensee. |
| `expiresAt` | [`Date`](#date) | Date when the license expires. |
| `id` | [`ID!`](#id) | ID of the license. |
| `name` | [`String`](#string) | Name of the licensee. |
| `plan` | [`String!`](#string) | Name of the subscription plan. |
| `startsAt` | [`Date`](#date) | Date when the license started. |
| `type` | [`String!`](#string) | Type of the license. |
| `usersInLicenseCount` | [`Int`](#int) | Number of paid users in the license. |
### `LicenseHistoryEntryConnection`
The connection type for LicenseHistoryEntry.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `edges` | [`[LicenseHistoryEntryEdge]`](#licensehistoryentryedge) | A list of edges. |
| `nodes` | [`[LicenseHistoryEntry]`](#licensehistoryentry) | A list of nodes. |
| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
### `LicenseHistoryEntryEdge`
An edge in a connection.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`LicenseHistoryEntry`](#licensehistoryentry) | The item at the end of the edge. |
### `MarkAsSpamSnippetPayload`
Autogenerated return type of MarkAsSpamSnippet.
......@@ -8416,7 +8494,7 @@ Name of the feature that the callout is for.
| `NEW_USER_SIGNUPS_CAP_REACHED` | Callout feature name for new_user_signups_cap_reached. |
| `PERSONAL_ACCESS_TOKEN_EXPIRY` | Callout feature name for personal_access_token_expiry. |
| `REGISTRATION_ENABLED_CALLOUT` | Callout feature name for registration_enabled_callout. |
| `SERVICE_TEMPLATES_DEPRECATED` | Callout feature name for service_templates_deprecated. |
| `SERVICE_TEMPLATES_DEPRECATED_CALLOUT` | Callout feature name for service_templates_deprecated_callout. |
| `SUGGEST_PIPELINE` | Callout feature name for suggest_pipeline. |
| `SUGGEST_POPOVER_DISMISSED` | Callout feature name for suggest_popover_dismissed. |
| `TABS_POSITION_HIGHLIGHT` | Callout feature name for tabs_position_highlight. |
......
......@@ -10,7 +10,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w
The Discord Notifications service sends event notifications from GitLab to the channel for which the webhook was created.
To send GitLab event notifications to a Discord channel, create a webhook in Discord and configure it in GitLab.
To send GitLab event notifications to a Discord channel, [create a webhook in Discord](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks)
and configure it in GitLab.
## Create webhook
......
<script>
import { GlIcon, GlPopover } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
export default {
components: {
GlIcon,
GlPopover,
},
props: {
url: {
type: String,
required: true,
},
},
computed: {
iconId() {
return uniqueId('approval-icon-');
},
containerId() {
return uniqueId('approva-icon-container-');
},
},
i18n: {
title: __('Approval Gate'),
},
};
</script>
<template>
<div :id="containerId">
<gl-icon :id="iconId" name="api" />
<gl-popover
:target="iconId"
:container="containerId"
placement="top"
:title="$options.i18n.title"
triggers="hover focus"
:content="url"
/>
</div>
</template>
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
export default {
components: {
GlDropdown,
GlDropdownItem,
},
props: {
approverTypeOptions: {
type: Array,
required: true,
},
},
data() {
return {
selected: null,
};
},
computed: {
dropdownText() {
return this.selected.text;
},
},
created() {
const [firstOption] = this.approverTypeOptions;
this.onSelect(firstOption);
},
methods: {
isSelectedType(type) {
return this.selected.type === type;
},
onSelect(option) {
this.selected = option;
this.$emit('input', option.type);
},
},
};
</script>
<template>
<gl-dropdown class="gl-w-full gl-dropdown-menu-full-width" :text="dropdownText">
<gl-dropdown-item
v-for="option in approverTypeOptions"
:key="option.type"
:is-check-item="true"
:is-checked="isSelectedType(option.type)"
@click="onSelect(option)"
>
<span>{{ option.text }}</span>
</gl-dropdown-item>
</gl-dropdown>
</template>
......@@ -3,14 +3,24 @@ import { GlSprintf } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { n__, s__, __ } from '~/locale';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import { RULE_TYPE_EXTERNAL_APPROVAL } from '../constants';
const i18n = {
cancelButtonText: __('Cancel'),
primaryButtonText: __('Remove approvers'),
modalTitle: __('Remove approvers?'),
removeWarningText: s__(
'ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.',
),
regularRule: {
primaryButtonText: __('Remove approvers'),
modalTitle: __('Remove approvers?'),
removeWarningText: s__(
'ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.',
),
},
externalRule: {
primaryButtonText: s__('ApprovalRuleRemove|Remove approval gate'),
modalTitle: s__('ApprovalRuleRemove|Remove approval gate?'),
removeWarningText: s__(
'ApprovalRuleRemove|You are about to remove the %{name} approval gate. Approval from this service is not revoked.',
),
},
};
export default {
......@@ -28,6 +38,9 @@ export default {
...mapState('deleteModal', {
rule: 'data',
}),
isExternalApprovalRule() {
return this.rule?.ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
membersText() {
return n__(
'ApprovalRuleRemove|%d member',
......@@ -42,24 +55,38 @@ export default {
this.rule.approvers.length,
);
},
modalTitle() {
return this.isExternalApprovalRule
? i18n.externalRule.modalTitle
: i18n.regularRule.modalTitle;
},
modalText() {
return `${i18n.removeWarningText} ${this.revokeWarningText}`;
return this.isExternalApprovalRule
? i18n.externalRule.removeWarningText
: `${i18n.regularRule.removeWarningText} ${this.revokeWarningText}`;
},
primaryButtonProps() {
const text = this.isExternalApprovalRule
? i18n.externalRule.primaryButtonText
: i18n.regularRule.primaryButtonText;
return {
text,
attributes: [{ variant: 'danger' }],
};
},
},
methods: {
...mapActions(['deleteRule']),
...mapActions(['deleteRule', 'deleteExternalApprovalRule']),
submit() {
this.deleteRule(this.rule.id);
if (this.rule.externalUrl) {
this.deleteExternalApprovalRule(this.rule.id);
} else {
this.deleteRule(this.rule.id);
}
},
},
buttonActions: {
primary: {
text: i18n.primaryButtonText,
attributes: [{ variant: 'danger' }],
},
cancel: {
text: i18n.cancelButtonText,
},
cancelButtonProps: {
text: i18n.cancelButtonText,
},
i18n,
};
......@@ -69,9 +96,9 @@ export default {
<gl-modal-vuex
modal-module="deleteModal"
:modal-id="modalId"
:title="$options.i18n.modalTitle"
:action-primary="$options.buttonActions.primary"
:action-cancel="$options.buttonActions.cancel"
:title="modalTitle"
:action-primary="primaryButtonProps"
:action-cancel="$options.cancelButtonProps"
@ok.prevent="submit"
>
<p v-if="rule">
......@@ -82,9 +109,6 @@ export default {
<template #nMembers>
<strong>{{ membersText }}</strong>
</template>
<template #revokeWarning>
{{ revokeWarningText }}
</template>
</gl-sprintf>
</p>
</gl-modal-vuex>
......
......@@ -4,8 +4,13 @@ import RuleName from 'ee/approvals/components/rule_name.vue';
import { n__, sprintf } from '~/locale';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { RULE_TYPE_ANY_APPROVER, RULE_TYPE_REGULAR } from '../../constants';
import {
RULE_TYPE_EXTERNAL_APPROVAL,
RULE_TYPE_ANY_APPROVER,
RULE_TYPE_REGULAR,
} from '../../constants';
import ApprovalGateIcon from '../approval_gate_icon.vue';
import EmptyRule from '../empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue';
......@@ -15,6 +20,7 @@ import UnconfiguredSecurityRules from '../security_configuration/unconfigured_se
export default {
components: {
ApprovalGateIcon,
RuleControls,
Rules,
UserAvatarList,
......@@ -95,6 +101,9 @@ export default {
return canEdit && (!allowMultiRule || !rule.hasSource);
},
isExternalApprovalRule({ ruleType }) {
return ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
},
};
</script>
......@@ -132,13 +141,14 @@ export default {
class="js-members"
:class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null"
>
<user-avatar-list :items="rule.approvers" :img-size="24" empty-text="" />
<approval-gate-icon v-if="isExternalApprovalRule(rule)" :url="rule.externalUrl" />
<user-avatar-list v-else :items="rule.approvers" :img-size="24" empty-text="" />
</td>
<td v-if="settings.allowMultiRule" class="js-branches">
<rule-branches :rule="rule" />
</td>
<td class="js-approvals-required">
<rule-input :rule="rule" />
<rule-input v-if="!isExternalApprovalRule(rule)" :rule="rule" />
</td>
<td class="text-nowrap px-2 w-0 js-controls">
<rule-controls v-if="canEdit(rule)" :rule="rule" />
......
<script>
import { groupBy, isNumber } from 'lodash';
import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants';
import { isSafeURL } from '~/lib/utils/url_utility';
import { sprintf, __, s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
TYPE_USER,
TYPE_GROUP,
TYPE_HIDDEN_GROUPS,
RULE_TYPE_EXTERNAL_APPROVAL,
RULE_TYPE_USER_OR_GROUP_APPROVER,
} from '../constants';
import ApproverTypeSelect from './approver_type_select.vue';
import ApproversList from './approvers_list.vue';
import ApproversSelect from './approvers_select.vue';
import BranchesSelect from './branches_select.vue';
......@@ -21,7 +30,9 @@ export default {
ApproversList,
ApproversSelect,
BranchesSelect,
ApproverTypeSelect,
},
mixins: [glFeatureFlagsMixin()],
props: {
initRule: {
type: Object,
......@@ -44,6 +55,7 @@ export default {
name: this.defaultRuleName,
approvalsRequired: 1,
minApprovalsRequired: 0,
externalUrl: null,
approvers: [],
approversToAdd: [],
branches: [],
......@@ -52,6 +64,7 @@ export default {
isFallback: false,
containsHiddenGroups: false,
serverValidationErrors: [],
ruleType: null,
...this.getInitialData(),
};
......@@ -59,6 +72,17 @@ export default {
},
computed: {
...mapState(['settings']),
showApproverTypeSelect() {
return (
this.glFeatures.ffComplianceApprovalGates &&
!this.isEditing &&
!this.isMrEdit &&
!READONLY_NAMES.includes(this.name)
);
},
isExternalApprovalRule() {
return this.ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
rule() {
// If we are creating a new rule with a suggested approval name
return this.defaultRuleName ? null : this.initRule;
......@@ -85,16 +109,32 @@ export default {
const invalidObject = {
name: this.invalidName,
approvalsRequired: this.invalidApprovalsRequired,
approvers: this.invalidApprovers,
};
if (!this.isMrEdit) {
invalidObject.branches = this.invalidBranches;
}
if (this.isExternalApprovalRule) {
invalidObject.externalUrl = this.invalidApprovalGateUrl;
} else {
invalidObject.approvers = this.invalidApprovers;
invalidObject.approvalsRequired = this.invalidApprovalsRequired;
}
return invalidObject;
},
invalidApprovalGateUrl() {
let error = '';
if (this.serverValidationErrors.includes('External url has already been taken')) {
error = __('External url has already been taken');
} else if (!this.externalUrl || !isSafeURL(this.externalUrl)) {
error = __('Please provide a valid URL');
}
return error;
},
invalidName() {
let error = '';
......@@ -175,9 +215,24 @@ export default {
protectedBranchIds: this.branches,
};
},
isEditing() {
return Boolean(this.initRule);
},
externalRuleSubmissionData() {
const { id, name, protectedBranchIds } = this.submissionData;
return {
id,
name,
protectedBranchIds,
externalUrl: this.externalUrl,
};
},
showProtectedBranch() {
return !this.isMrEdit && this.settings.allowMultiRule;
},
approvalGateLabel() {
return this.isEditing ? this.$options.i18n.approvalGate : this.$options.i18n.addApprovalGate;
},
},
watch: {
approversToAdd(value) {
......@@ -188,7 +243,15 @@ export default {
},
},
methods: {
...mapActions(['putFallbackRule', 'postRule', 'putRule', 'deleteRule', 'postRegularRule']),
...mapActions([
'putFallbackRule',
'putExternalApprovalRule',
'postExternalApprovalRule',
'postRule',
'putRule',
'deleteRule',
'postRegularRule',
]),
addSelection() {
if (!this.approversToAdd.length) {
return;
......@@ -219,9 +282,13 @@ export default {
}
submission.catch((failureResponse) => {
this.serverValidationErrors = mapServerResponseToValidationErrors(
failureResponse?.response?.data?.message || {},
);
if (this.isExternalApprovalRule) {
this.serverValidationErrors = failureResponse?.response?.data?.message || [];
} else {
this.serverValidationErrors = mapServerResponseToValidationErrors(
failureResponse?.response?.data?.message || {},
);
}
});
return submission;
......@@ -230,12 +297,14 @@ export default {
* Submit the rule, by either put-ing or post-ing.
*/
submitRule() {
if (this.isExternalApprovalRule) {
const data = this.externalRuleSubmissionData;
return data.id ? this.putExternalApprovalRule(data) : this.postExternalApprovalRule(data);
}
const data = this.submissionData;
if (!this.settings.allowMultiRule && this.settings.prefix === 'mr-edit') {
return data.id ? this.putRule(data) : this.postRegularRule(data);
}
return data.id ? this.putRule(data) : this.postRule(data);
},
/**
......@@ -248,7 +317,7 @@ export default {
* Submit as a single rule. This is determined by the settings.
*/
submitSingleRule() {
if (!this.approvers.length) {
if (!this.approvers.length && !this.isExternalApprovalRule) {
return this.submitEmptySingleRule();
}
......@@ -280,6 +349,16 @@ export default {
};
}
if (this.initRule.ruleType === RULE_TYPE_EXTERNAL_APPROVAL) {
return {
name: this.initRule.name || '',
externalUrl: this.initRule.externalUrl,
branches: this.initRule.protectedBranches?.map((x) => x.id) || [],
ruleType: this.initRule.ruleType,
approvers: [],
};
}
const { containsHiddenGroups = false, removeHiddenGroups = false } = this.initRule;
const users = this.initRule.users.map((x) => ({ ...x, type: TYPE_USER }));
......@@ -290,6 +369,7 @@ export default {
name: this.initRule.name || '',
approvalsRequired: this.initRule.approvalsRequired || 0,
minApprovalsRequired: this.initRule.minApprovalsRequired || 0,
ruleType: this.initRule.ruleType,
containsHiddenGroups,
approvers: groups
.concat(users)
......@@ -300,6 +380,14 @@ export default {
};
},
},
i18n: {
approvalGate: s__('ApprovalRule|Approvel gate'),
addApprovalGate: s__('ApprovalRule|Add approvel gate'),
},
approverTypeOptions: [
{ type: RULE_TYPE_USER_OR_GROUP_APPROVER, text: s__('ApprovalRule|Users or groups') },
{ type: RULE_TYPE_EXTERNAL_APPROVAL, text: s__('ApprovalRule|Approval service API') },
],
};
</script>
......@@ -334,7 +422,14 @@ export default {
{{ __('Apply this approval rule to any branch or a specific protected branch.') }}
</small>
</div>
<div class="form-group gl-form-group">
<div v-if="showApproverTypeSelect" class="form-group gl-form-group">
<label class="col-form-label">{{ s__('ApprovalRule|Approver Type') }}</label>
<approver-type-select
v-model="ruleType"
:approver-type-options="$options.approverTypeOptions"
/>
</div>
<div v-if="!isExternalApprovalRule" class="form-group gl-form-group">
<label class="col-form-label">{{ s__('ApprovalRule|Approvals required') }}</label>
<input
v-model.number="approvalsRequired"
......@@ -347,7 +442,7 @@ export default {
/>
<span class="invalid-feedback">{{ validation.approvalsRequired }}</span>
</div>
<div class="form-group gl-form-group">
<div v-if="!isExternalApprovalRule" class="form-group gl-form-group">
<label class="col-form-label">{{ s__('ApprovalRule|Add approvers') }}</label>
<approvers-select
v-model="approversToAdd"
......@@ -359,7 +454,22 @@ export default {
/>
<span class="invalid-feedback">{{ validation.approvers }}</span>
</div>
<div class="bordered-box overflow-auto h-12em">
<div v-if="isExternalApprovalRule" class="form-group gl-form-group">
<label class="col-form-label">{{ approvalGateLabel }}</label>
<input
v-model="externalUrl"
:class="{ 'is-invalid': validation.externalUrl }"
class="gl-form-input form-control"
name="approval_gate_url"
type="url"
data-qa-selector="external_url_field"
/>
<span class="invalid-feedback">{{ validation.externalUrl }}</span>
<small class="form-text text-gl-muted">
{{ s__('ApprovalRule|Invoke an external API as part of the approvals') }}
</small>
</div>
<div v-if="!isExternalApprovalRule" class="bordered-box overflow-auto h-12em">
<approvers-list v-model="approvers" />
</div>
</form>
......
......@@ -17,6 +17,7 @@ export const RULE_TYPE_CODE_OWNER = 'code_owner';
export const RULE_TYPE_ANY_APPROVER = 'any_approver';
export const RULE_TYPE_EXTERNAL_APPROVAL = 'external_approval';
export const RULE_NAME_ANY_APPROVER = 'All Members';
export const RULE_TYPE_USER_OR_GROUP_APPROVER = 'user_or_group';
export const VULNERABILITY_CHECK_NAME = 'Vulnerability-Check';
export const LICENSE_CHECK_NAME = 'License-Check';
......
......@@ -70,7 +70,7 @@ export const mapExternalApprovalRuleResponse = (res) => ({
});
export const mapExternalApprovalResponse = (res) => ({
rules: withDefaultEmptyRule(res.map(mapExternalApprovalRuleResponse)),
rules: res.map(mapExternalApprovalRuleResponse),
});
export const mapApprovalSettingsResponse = (res) => ({
......
......@@ -200,7 +200,7 @@ export default {
@filter="handleFilterIssues"
>
<template #nav-actions>
<gl-button :href="issueCreateUrl" target="_blank"
<gl-button :href="issueCreateUrl" target="_blank" class="gl-my-5"
>{{ s__('Integrations|Create new issue in Jira') }}<gl-icon name="external-link"
/></gl-button>
</template>
......
<script>
export default {
props: {
label: {
type: String,
required: true,
},
},
};
</script>
<template>
<div
class="generic-report-row gl-display-grid gl-px-3 gl-py-5 gl-border-b-1 gl-border-b-solid gl-border-gray-100"
>
<strong>{{ label }}</strong>
<div data-testid="reportContent">
<slot></slot>
</div>
</div>
</template>
......@@ -2,7 +2,6 @@
import { GlCollapse, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale';
import ReportItem from './report_item.vue';
import ReportRow from './report_row.vue';
import { filterTypesAndLimitListDepth } from './types/utils';
const NESTED_LISTS_MAX_DEPTH = 4;
......@@ -15,7 +14,6 @@ export default {
GlCollapse,
GlIcon,
ReportItem,
ReportRow,
},
props: {
details: {
......@@ -57,11 +55,14 @@ export default {
</h3>
</header>
<gl-collapse :visible="showSection">
<div data-testid="reports">
<div class="generic-report-container" data-testid="reports">
<template v-for="[label, item] in detailsEntries">
<report-row :key="label" :label="item.name" :data-testid="`report-row-${label}`">
<report-item :item="item" />
</report-row>
<div :key="label" class="generic-report-row" :data-testid="`report-row-${label}`">
<strong class="generic-report-column">{{ item.name }}</strong>
<div class="generic-report-column" data-testid="reportContent">
<report-item :item="item" :data-testid="`report-item-${label}`" />
</div>
</div>
</template>
</div>
</gl-collapse>
......
......@@ -120,15 +120,32 @@ $selection-summary-with-error-height: 118px;
}
}
.generic-report-container {
@include gl-display-grid;
grid-template-columns: max-content auto;
}
.generic-report-row {
grid-template-columns: minmax(150px, 1fr) 3fr;
grid-column-gap: $gl-spacing-scale-5;
display: contents;
&:last-child {
&:last-child .generic-report-column {
@include gl-border-b-0;
}
}
.generic-report-column {
@include gl-px-3;
@include gl-py-5;
@include gl-border-b-1;
@include gl-border-b-solid;
@include gl-border-gray-100;
&:first-child {
max-width: 15rem;
}
}
.generic-report-list {
li {
@include gl-ml-0;
......@@ -140,4 +157,3 @@ $selection-summary-with-error-height: 118px;
list-style-type: disc;
}
}
......@@ -10,6 +10,10 @@ module EE
before_action :log_archive_audit_event, only: [:archive]
before_action :log_unarchive_audit_event, only: [:unarchive]
before_action only: :edit do
push_frontend_feature_flag(:ff_compliance_approval_gates, project, default_enabled: :yaml)
end
before_action only: :show do
push_frontend_feature_flag(:cve_id_request_button, project)
end
......
......@@ -61,6 +61,16 @@ module EE
null: true,
description: 'Get configured DevOps adoption segments on the instance.',
resolver: ::Resolvers::Admin::Analytics::DevopsAdoption::SegmentsResolver
field :current_license, ::Types::Admin::CloudLicenses::CurrentLicenseType,
null: true,
resolver: ::Resolvers::Admin::CloudLicenses::CurrentLicenseResolver,
description: 'Fields related to the current license.'
field :license_history_entries, ::Types::Admin::CloudLicenses::LicenseHistoryEntryType.connection_type,
null: true,
resolver: ::Resolvers::Admin::CloudLicenses::LicenseHistoryEntriesResolver,
description: 'Fields related to entries in the license history.'
end
def vulnerability(id:)
......
# frozen_string_literal: true
module Resolvers
module Admin
module CloudLicenses
class CurrentLicenseResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
include ::Admin::LicenseRequest
type ::Types::Admin::CloudLicenses::CurrentLicenseType, null: true
def resolve
return unless application_settings.cloud_license_enabled?
authorize!
license
end
private
def application_settings
Gitlab::CurrentSettings.current_application_settings
end
def authorize!
Ability.allowed?(context[:current_user], :read_licenses) || raise_resource_not_available_error!
end
end
end
end
end
# frozen_string_literal: true
module Resolvers
module Admin
module CloudLicenses
class LicenseHistoryEntriesResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type [::Types::Admin::CloudLicenses::LicenseHistoryEntryType], null: true
def resolve
return unless application_settings.cloud_license_enabled?
authorize!
License.history
end
private
def application_settings
Gitlab::CurrentSettings.current_application_settings
end
def authorize!
Ability.allowed?(context[:current_user], :read_licenses) || raise_resource_not_available_error!
end
end
end
end
end
# frozen_string_literal: true
module Types
module Admin
module CloudLicenses
# rubocop: disable Graphql/AuthorizeTypes
class CurrentLicenseType < BaseObject
include ::Types::Admin::CloudLicenses::LicenseType
graphql_name 'CurrentLicense'
description 'Represents the current license'
field :last_sync, ::Types::TimeType, null: true,
description: 'Date when the license was last synced.',
method: :last_synced_at
field :billable_users_count, GraphQL::INT_TYPE, null: true,
description: 'Number of billable users on the system.',
method: :daily_billable_users_count
field :maximum_user_count, GraphQL::INT_TYPE, null: true,
description: 'Highest number of billable users on the system during the term of the current license.',
method: :maximum_user_count
field :users_over_license_count, GraphQL::INT_TYPE, null: true,
description: 'Number of users over the paid users in the license.'
def users_over_license_count
return 0 if object.trial?
[object.overage_with_historical_max, 0].max
end
end
end
end
end
# frozen_string_literal: true
module Types
module Admin
module CloudLicenses
# rubocop: disable Graphql/AuthorizeTypes
class LicenseHistoryEntryType < BaseObject
include ::Types::Admin::CloudLicenses::LicenseType
graphql_name 'LicenseHistoryEntry'
description 'Represents an entry from the Cloud License history'
end
end
end
end
# frozen_string_literal: true
module Types
module Admin
module CloudLicenses
module LicenseType
extend ActiveSupport::Concern
included do
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the license.',
method: :license_id
field :type, GraphQL::STRING_TYPE, null: false,
description: 'Type of the license.',
method: :license_type
field :plan, GraphQL::STRING_TYPE, null: false,
description: 'Name of the subscription plan.'
field :name, GraphQL::STRING_TYPE, null: true,
description: 'Name of the licensee.',
method: :licensee_name
field :email, GraphQL::STRING_TYPE, null: true,
description: 'Email of the licensee.',
method: :licensee_email
field :company, GraphQL::STRING_TYPE, null: true,
description: 'Company of the licensee.',
method: :licensee_company
field :starts_at, ::Types::DateType, null: true,
description: 'Date when the license started.'
field :expires_at, ::Types::DateType, null: true,
description: 'Date when the license expires.'
field :activated_at, ::Types::DateType, null: true,
description: 'Date when the license was activated.',
method: :created_at
field :users_in_license_count, GraphQL::INT_TYPE, null: true,
description: 'Number of paid users in the license.',
method: :restricted_user_count
end
end
end
end
end
......@@ -8,6 +8,7 @@ class License < ApplicationRecord
PREMIUM_PLAN = 'premium'
ULTIMATE_PLAN = 'ultimate'
CLOUD_LICENSE_TYPE = 'cloud'
LEGACY_LICENSE_TYPE = 'legacy'
ALLOWED_PERCENTAGE_OF_USERS_OVERAGE = (10 / 100.0)
EE_ALL_PLANS = [STARTER_PLAN, PREMIUM_PLAN, ULTIMATE_PLAN].freeze
......@@ -237,6 +238,8 @@ class License < ApplicationRecord
{ range: (1000..nil), percentage: true, value: 5 }
].freeze
LICENSEE_ATTRIBUTES = %w[Name Email Company].freeze
validate :valid_license
validate :check_users_limit, if: :new_record?, unless: :validate_with_trueup?
validate :check_trueup, unless: :persisted?, if: :validate_with_trueup?
......@@ -550,6 +553,10 @@ class License < ApplicationRecord
license&.type == CLOUD_LICENSE_TYPE
end
def license_type
cloud? ? CLOUD_LICENSE_TYPE : LEGACY_LICENSE_TYPE
end
def auto_renew
false
end
......@@ -576,6 +583,12 @@ class License < ApplicationRecord
restricted_user_count - daily_billable_users_count
end
LICENSEE_ATTRIBUTES.each do |attribute|
define_method "licensee_#{attribute.downcase}" do
licensee[attribute]
end
end
private
def restricted_attr(name, default = nil)
......
......@@ -34,8 +34,8 @@ module Integrations
{
title: name,
name: name,
color: '#EBECF0',
text_color: '#283856'
color: '#0052CC',
text_color: '#FFFFFF'
}
end
end
......
......@@ -39,7 +39,9 @@ module EE
def after_update(merge_request)
super
::MergeRequests::SyncCodeOwnerApprovalRules.new(merge_request).execute
merge_request.run_after_commit do
::MergeRequests::SyncCodeOwnerApprovalRulesWorker.perform_async(merge_request)
end
end
override :create_branch_change_note
......
......@@ -867,6 +867,14 @@
:weight: 1
:idempotent:
:tags: []
- :name: merge_requests_sync_code_owner_approval_rules
:feature_category: :source_code_management
:has_external_dependencies:
:urgency: :high
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: new_epic
:feature_category: :epics
:has_external_dependencies:
......
# frozen_string_literal: true
module MergeRequests
class SyncCodeOwnerApprovalRulesWorker
include ApplicationWorker
feature_category :source_code_management
urgency :high
deduplicate :until_executed
idempotent!
def perform(merge_request_id)
merge_request = MergeRequest.find_by_id(merge_request_id)
return unless merge_request
::MergeRequests::SyncCodeOwnerApprovalRules.new(merge_request).execute
end
end
end
---
title: Add metric to track querying write ahead log on Request level
merge_request: 58673
author:
type: other
---
title: Add load balancing Sidekiq metrics
merge_request: 58473
author:
type: other
---
title: Update label color on Jira issues pages
merge_request: 59226
author:
type: changed
---
title: 'Generic vulnerability reports: Remove extra margin between report columns'
merge_request: 58720
author:
type: other
---
title: Fix RSpec/EmptyLineAfterFinalLetItBe rubocop offenses in ee/spec/controllers/registrations
merge_request: 58408
author: Abdul Wadood @abdulwd
type: fixed
......@@ -9,11 +9,13 @@ module EE
extend ::Gitlab::Utils::Override
DB_LOAD_BALANCING_COUNTERS = %i{
db_replica_count db_replica_cached_count
db_primary_count db_primary_cached_count
db_replica_count db_replica_cached_count db_replica_wal_count
db_primary_count db_primary_cached_count db_primary_wal_count
}.freeze
DB_LOAD_BALANCING_DURATIONS = %i{db_primary_duration_s db_replica_duration_s}.freeze
SQL_WAL_LOCATION_REGEX = /(pg_current_wal_insert_lsn\(\)::text|pg_last_wal_replay_lsn\(\)::text)/.freeze
class_methods do
extend ::Gitlab::Utils::Override
......@@ -55,9 +57,14 @@ module EE
private
def wal_command?(payload)
payload[:sql].match(SQL_WAL_LOCATION_REGEX)
end
def increment_db_role_counters(db_role, payload)
increment("db_#{db_role}_count".to_sym)
increment("db_#{db_role}_cached_count".to_sym) if cached_query?(payload)
increment("db_#{db_role}_wal_count".to_sym) if !cached_query?(payload) && wal_command?(payload)
end
def observe_db_role_duration(db_role, event)
......
# frozen_string_literal: true
module EE
module Gitlab
module SidekiqMiddleware
module ServerMetrics
extend ::Gitlab::Utils::Override
protected
override :init_metrics
def init_metrics
super.merge(init_load_balancing_metrics)
end
override :instrument
def instrument(job, labels)
super
ensure
record_load_balancing(job, labels)
end
private
def init_load_balancing_metrics
return {} unless ::Gitlab::Database::LoadBalancing.enable?
{
sidekiq_load_balancing_count: ::Gitlab::Metrics.counter(:sidekiq_load_balancing_count, 'Sidekiq jobs with load balancing')
}
end
def record_load_balancing(job, labels)
return unless ::Gitlab::Database::LoadBalancing.enable?
return unless job[:database_chosen]
load_balancing_labels = {
database_chosen: job[:database_chosen],
data_consistency: job[:data_consistency]
}
metrics[:sidekiq_load_balancing_count].increment(labels.merge(load_balancing_labels), 1)
end
end
end
end
end
......@@ -19,6 +19,9 @@ module Gitlab
return unless worker_class
return unless worker_class.include?(::ApplicationWorker)
return unless worker_class.get_data_consistency_feature_flag_enabled?
job['worker_data_consistency'] = worker_class.get_data_consistency
return if worker_class.get_data_consistency == :always
if Session.current.performed_write?
......
......@@ -25,18 +25,18 @@ module Gitlab
def requires_primary?(worker_class, job)
return true unless worker_class.include?(::ApplicationWorker)
job[:worker_data_consistency] = worker_class.get_data_consistency
return true if worker_class.get_data_consistency == :always
return true unless worker_class.get_data_consistency_feature_flag_enabled?
if job['database_replica_location'] || replica_caught_up?(job['database_write_location'] )
if job['database_replica_location'] || replica_caught_up?(job['database_write_location'])
job[:database_chosen] = 'replica'
false
elsif worker_class.get_data_consistency == :delayed && job['retry_count'].to_i == 0
job[:database_chosen] = 'retry'
raise JobReplicaNotUpToDate, "Sidekiq job #{worker_class} JID-#{job['jid']} couldn't use the replica."\
" Replica was not up to date."
else
job[:database_chosen] = 'primary'
true
end
end
......
......@@ -78,6 +78,7 @@ RSpec.describe Registrations::GroupsController do
let_it_be(:trial_form_params) { { trial: 'false' } }
let_it_be(:trial_onboarding_issues_enabled) { false }
let_it_be(:trial_onboarding_flow_params) { {} }
let(:signup_onboarding_enabled) { true }
let(:group_params) { { name: 'Group name', path: 'group-path', visibility_level: Gitlab::VisibilityLevel::PRIVATE, emails: ['', ''] } }
let(:params) do
......
......@@ -54,6 +54,7 @@ RSpec.describe Registrations::ProjectsController do
subject { post :create, params: { project: params }.merge(trial_onboarding_flow_params) }
let_it_be(:trial_onboarding_flow_params) { {} }
let(:params) { { namespace_id: namespace.id, name: 'New project', path: 'project-path', visibility_level: Gitlab::VisibilityLevel::PRIVATE } }
let(:signup_onboarding_enabled) { true }
......
......@@ -5,15 +5,16 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
include GitlabRoutingHelper
include FeatureApprovalHelper
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:group) { create(:group) }
let(:group_member) { create(:user) }
let(:non_member) { create(:user) }
let!(:config_selector) { '.js-approval-rules' }
let!(:modal_selector) { '#project-settings-approvals-create-modal' }
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
let_it_be(:group_member) { create(:user) }
let_it_be(:non_member) { create(:user) }
let_it_be(:config_selector) { '.js-approval-rules' }
let_it_be(:modal_selector) { '#project-settings-approvals-create-modal' }
before do
stub_licensed_features(compliance_approval_gates: true)
sign_in(user)
project.add_maintainer(user)
group.add_developer(user)
......@@ -69,8 +70,8 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end
context 'with an approver group' do
let(:non_group_approver) { create(:user) }
let!(:rule) { create(:approval_project_rule, project: project, groups: [group], users: [non_group_approver]) }
let_it_be(:non_group_approver) { create(:user) }
let_it_be(:rule) { create(:approval_project_rule, project: project, groups: [group], users: [non_group_approver]) }
before do
project.add_developer(non_group_approver)
......@@ -90,6 +91,64 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end
end
it 'adds an approval gate' do
visit edit_project_path(project)
open_modal(text: 'Add approval rule', expand: false)
within('.modal-content') do
find('button', text: "Users or groups").click
find('button', text: "Approval service API").click
find('[data-qa-selector="rule_name_field"]').set('My new rule')
find('[data-qa-selector="external_url_field"]').set('https://api.gitlab.com')
click_button 'Add approval rule'
end
wait_for_requests
expect(first('.js-name')).to have_content('My new rule')
end
context 'with an approval gate' do
let_it_be(:rule) { create(:external_approval_rule, project: project) }
it 'updates the approval gate' do
visit edit_project_path(project)
expect(first('.js-name')).to have_content(rule.name)
open_modal(text: 'Edit', expand: false)
within('.modal-content') do
find('[data-qa-selector="rule_name_field"]').set('Something new')
click_button 'Update approval rule'
end
wait_for_requests
expect(first('.js-name')).to have_content('Something new')
end
it 'removes the approval gate' do
visit edit_project_path(project)
expect(first('.js-name')).to have_content(rule.name)
first('.js-controls').find('[data-testid="remove-icon"]').click
within('.modal-content') do
click_button 'Remove approval gate'
end
wait_for_requests
expect(first('.js-name')).not_to have_content(rule.name)
end
end
context 'issuable default templates feature not available' do
before do
stub_licensed_features(issuable_default_templates: false)
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Approvals ModalRuleRemove shows message 1`] = `
exports[`Approvals ModalRuleRemove matches the snapshot for external approval 1`] = `
<div
title="Remove approval gate?"
>
<p>
You are about to remove the
<strong>
API Gate
</strong>
approval gate. Approval from this service is not revoked.
</p>
</div>
`;
exports[`Approvals ModalRuleRemove matches the snapshot for multiple approvers 1`] = `
<div
title="Remove approvers?"
>
......@@ -18,7 +32,7 @@ exports[`Approvals ModalRuleRemove shows message 1`] = `
</div>
`;
exports[`Approvals ModalRuleRemove shows singular message 1`] = `
exports[`Approvals ModalRuleRemove matches the snapshot for singular approver 1`] = `
<div
title="Remove approvers?"
>
......
import { GlPopover, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ApprovalGateIcon from 'ee/approvals/components/approval_gate_icon.vue';
jest.mock('lodash/uniqueId', () => (id) => `${id}mock`);
describe('ApprovalGateIcon', () => {
let wrapper;
const findPopover = () => wrapper.findComponent(GlPopover);
const findIcon = () => wrapper.findComponent(GlIcon);
const createComponent = () => {
return shallowMount(ApprovalGateIcon, {
propsData: {
url: 'https://gitlab.com/',
},
});
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
wrapper = createComponent();
});
it('renders the icon', () => {
expect(findIcon().props('name')).toBe('api');
expect(findIcon().attributes('id')).toBe('approval-icon-mock');
});
it('renders the popover with the URL for the icon', () => {
expect(findPopover().exists()).toBe(true);
expect(findPopover().attributes()).toMatchObject({
content: 'https://gitlab.com/',
title: 'Approval Gate',
target: 'approval-icon-mock',
});
});
});
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import ApprovalTypeSelect from 'ee/approvals/components/approver_type_select.vue';
jest.mock('lodash/uniqueId', () => (id) => `${id}mock`);
const OPTIONS = [
{ type: 'x', text: 'foo' },
{ type: 'y', text: 'bar' },
];
describe('ApprovalTypeSelect', () => {
let wrapper;
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
const createComponent = () => {
return shallowMount(ApprovalTypeSelect, {
propsData: {
approverTypeOptions: OPTIONS,
},
});
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
wrapper = createComponent();
});
it('should select the first option by default', () => {
expect(findDropdownItems().at(0).props('isChecked')).toBe(true);
});
it('renders the dropdown with the selected text', () => {
expect(findDropdown().props('text')).toBe(OPTIONS[0].text);
});
it('renders a dropdown item for each option', () => {
OPTIONS.forEach((option, idx) => {
expect(findDropdownItems().at(idx).text()).toBe(option.text);
});
});
it('should select an item when clicked', async () => {
const item = findDropdownItems().at(1);
expect(item.props('isChecked')).toBe(false);
item.vm.$emit('click');
await nextTick();
expect(item.props('isChecked')).toBe(true);
});
});
......@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue';
import { stubComponent } from 'helpers/stub_component';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import { createExternalRule } from '../mocks';
const MODAL_MODULE = 'deleteModal';
const TEST_MODAL_ID = 'test-delete-modal-id';
......@@ -14,6 +15,11 @@ const TEST_RULE = {
.fill(1)
.map((x, id) => ({ id })),
};
const SINGLE_APPROVER = {
...TEST_RULE,
approvers: [{ id: 1 }],
};
const EXTERNAL_RULE = createExternalRule();
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -61,6 +67,7 @@ describe('Approvals ModalRuleRemove', () => {
};
actions = {
deleteRule: jest.fn(),
deleteExternalApprovalRule: jest.fn(),
};
});
......@@ -83,30 +90,31 @@ describe('Approvals ModalRuleRemove', () => {
);
});
it('shows message', () => {
factory();
expect(findModal().element).toMatchSnapshot();
});
it('shows singular message', () => {
deleteModalState.data = {
...TEST_RULE,
approvers: [{ id: 1 }],
};
it.each`
type | rule
${'multiple approvers'} | ${TEST_RULE}
${'singular approver'} | ${SINGLE_APPROVER}
${'external approval'} | ${EXTERNAL_RULE}
`('matches the snapshot for $type', ({ rule }) => {
deleteModalState.data = rule;
factory();
expect(findModal().element).toMatchSnapshot();
});
it('deletes rule when modal is submitted', () => {
it.each`
typeType | action | rule
${'regular'} | ${'deleteRule'} | ${TEST_RULE}
${'external'} | ${'deleteExternalApprovalRule'} | ${EXTERNAL_RULE}
`('calls $action when the modal is submitted for a $typeType rule', ({ action, rule }) => {
deleteModalState.data = rule;
factory();
expect(actions.deleteRule).not.toHaveBeenCalled();
expect(actions[action]).not.toHaveBeenCalled();
const modal = findModal();
modal.vm.$emit('ok', new Event('submit'));
expect(actions.deleteRule).toHaveBeenCalledWith(expect.anything(), TEST_RULE.id);
expect(actions[action]).toHaveBeenCalledWith(expect.anything(), rule.id);
});
});
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ApprovalGateIcon from 'ee/approvals/components/approval_gate_icon.vue';
import RuleInput from 'ee/approvals/components/mr_edit/rule_input.vue';
import ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue';
import RuleName from 'ee/approvals/components/rule_name.vue';
......@@ -8,7 +9,7 @@ import UnconfiguredSecurityRules from 'ee/approvals/components/security_configur
import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import { createProjectRules } from '../../mocks';
import { createProjectRules, createExternalRule } from '../../mocks';
const TEST_RULES = createProjectRules();
......@@ -149,4 +150,26 @@ describe('Approvals ProjectRules', () => {
expect(wrapper.find(UnconfiguredSecurityRules).exists()).toBe(true);
});
});
describe('when the rule is external', () => {
const rule = createExternalRule();
beforeEach(() => {
store.modules.approvals.state.rules = [rule];
factory();
});
it('renders the approval gate component with URL', () => {
expect(wrapper.findComponent(ApprovalGateIcon).props('url')).toBe(rule.externalUrl);
});
it('does not render a user avatar component', () => {
expect(wrapper.findComponent(UserAvatarList).exists()).toBe(false);
});
it('does not render the approvals required input', () => {
expect(wrapper.findComponent(RuleInput).exists()).toBe(false);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex';
import ApproverTypeSelect from 'ee/approvals/components/approver_type_select.vue';
import ApproversList from 'ee/approvals/components/approvers_list.vue';
import ApproversSelect from 'ee/approvals/components/approvers_select.vue';
import BranchesSelect from 'ee/approvals/components/branches_select.vue';
import RuleForm from 'ee/approvals/components/rule_form.vue';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from 'ee/approvals/constants';
import {
TYPE_USER,
TYPE_GROUP,
TYPE_HIDDEN_GROUPS,
RULE_TYPE_EXTERNAL_APPROVAL,
} from 'ee/approvals/constants';
import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import waitForPromises from 'helpers/wait_for_promises';
import { createExternalRule } from '../mocks';
const TEST_PROJECT_ID = '7';
const TEST_RULE = {
......@@ -27,6 +36,10 @@ const TEST_FALLBACK_RULE = {
approvalsRequired: 1,
isFallback: true,
};
const TEST_EXTERNAL_APPROVAL_RULE = {
...createExternalRule(),
protectedBranches: TEST_PROTECTED_BRANCHES,
};
const TEST_LOCKED_RULE_NAME = 'LOCKED_RULE';
const nameTakenError = {
response: {
......@@ -37,6 +50,13 @@ const nameTakenError = {
},
},
};
const urlTakenError = {
response: {
data: {
message: ['External url has already been taken'],
},
},
};
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -54,7 +74,11 @@ describe('EE Approvals RuleForm', () => {
store: new Vuex.Store(store),
localVue,
provide: {
glFeatures: { scopedApprovalRules: true, ...options.provide?.glFeatures },
glFeatures: {
ffComplianceApprovalGates: true,
scopedApprovalRules: true,
...options.provide?.glFeatures,
},
},
});
};
......@@ -71,6 +95,9 @@ describe('EE Approvals RuleForm', () => {
const findApproversValidation = () => findValidation(findApproversSelect(), true);
const findApproversList = () => wrapper.find(ApproversList);
const findBranchesSelect = () => wrapper.find(BranchesSelect);
const findApproverTypeSelect = () => wrapper.findComponent(ApproverTypeSelect);
const findExternalUrlInput = () => wrapper.find('input[name=approval_gate_url');
const findExternalUrlValidation = () => findValidation(findExternalUrlInput(), false);
const findBranchesValidation = () => findValidation(findBranchesSelect(), true);
const findValidations = () => [
findNameValidation(),
......@@ -85,12 +112,20 @@ describe('EE Approvals RuleForm', () => {
findBranchesValidation(),
];
const findValidationForExternal = () => [
findNameValidation(),
findExternalUrlValidation(),
findBranchesValidation(),
];
beforeEach(() => {
store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID });
['postRule', 'putRule', 'deleteRule', 'putFallbackRule'].forEach((actionName) => {
jest.spyOn(store.modules.approvals.actions, actionName).mockImplementation(() => {});
});
['postRule', 'putRule', 'deleteRule', 'putFallbackRule', 'postExternalApprovalRule'].forEach(
(actionName) => {
jest.spyOn(store.modules.approvals.actions, actionName).mockImplementation(() => {});
},
);
({ actions } = store.modules.approvals);
});
......@@ -181,6 +216,119 @@ describe('EE Approvals RuleForm', () => {
});
});
describe('when the rule is an external rule', () => {
describe('with initial rule', () => {
beforeEach(() => {
createComponent({
isMrEdit: false,
initRule: TEST_EXTERNAL_APPROVAL_RULE,
});
});
it('does not render the approver type select input', () => {
expect(findApproverTypeSelect().exists()).toBe(false);
});
it('on load, it populates the external URL', () => {
expect(findExternalUrlInput().element.value).toBe(
TEST_EXTERNAL_APPROVAL_RULE.externalUrl,
);
});
});
describe('without an initial rule', () => {
beforeEach(() => {
createComponent({
isMrEdit: false,
});
findApproverTypeSelect().vm.$emit('input', RULE_TYPE_EXTERNAL_APPROVAL);
});
it('renders the approver type select input', () => {
expect(findApproverTypeSelect().exists()).toBe(true);
});
it('renders the inputs for external rules', () => {
expect(findNameInput().exists()).toBe(true);
expect(findExternalUrlInput().exists()).toBe(true);
expect(findBranchesSelect().exists()).toBe(true);
});
it('does not render the user and group input fields', () => {
expect(findApprovalsRequiredInput().exists()).toBe(false);
expect(findApproversList().exists()).toBe(false);
expect(findApproversSelect().exists()).toBe(false);
});
it('at first, shows no validation', () => {
const inputs = findValidationForExternal();
const invalidInputs = inputs.filter((x) => !x.isValid);
const feedbacks = inputs.map((x) => x.feedback);
expect(invalidInputs.length).toBe(0);
expect(feedbacks.every((str) => !str.length)).toBe(true);
});
it('on submit, does not dispatch action', () => {
wrapper.vm.submit();
expect(actions.postExternalApprovalRule).not.toHaveBeenCalled();
});
it('on submit, shows name validation', async () => {
findExternalUrlInput().setValue('');
wrapper.vm.submit();
await nextTick();
expect(findExternalUrlValidation()).toEqual({
isValid: false,
feedback: 'Please provide a valid URL',
});
});
describe('with valid data', () => {
const branches = TEST_PROTECTED_BRANCHES.map((x) => x.id);
const expected = {
id: null,
name: 'Lorem',
externalUrl: 'https://gitlab.com/',
protectedBranchIds: branches,
};
beforeEach(() => {
findNameInput().setValue(expected.name);
findExternalUrlInput().setValue(expected.externalUrl);
wrapper.vm.branches = expected.protectedBranchIds;
});
it('on submit, posts external approval rule', () => {
wrapper.vm.submit();
expect(actions.postExternalApprovalRule).toHaveBeenCalledWith(
expect.anything(),
expected,
);
});
it('when submitted with a duplicate external URL, shows the "url already taken" validation', async () => {
store.state.settings.prefix = 'project-settings';
jest.spyOn(wrapper.vm, 'postExternalApprovalRule').mockRejectedValueOnce(urlTakenError);
wrapper.vm.submit();
await waitForPromises();
expect(findExternalUrlValidation()).toEqual({
isValid: false,
feedback: 'External url has already been taken',
});
});
});
});
});
describe('without initRule', () => {
beforeEach(() => {
createComponent();
......@@ -536,16 +684,17 @@ describe('EE Approvals RuleForm', () => {
describe('with approval suggestions', () => {
describe.each`
defaultRuleName | expectedDisabledAttribute
${'Vulnerability-Check'} | ${'disabled'}
${'License-Check'} | ${'disabled'}
${'Foo Bar Baz'} | ${undefined}
defaultRuleName | expectedDisabledAttribute | approverTypeSelect
${'Vulnerability-Check'} | ${'disabled'} | ${false}
${'License-Check'} | ${'disabled'} | ${false}
${'Foo Bar Baz'} | ${undefined} | ${true}
`(
'with defaultRuleName set to $defaultRuleName',
({ defaultRuleName, expectedDisabledAttribute }) => {
({ defaultRuleName, expectedDisabledAttribute, approverTypeSelect }) => {
beforeEach(() => {
createComponent({
initRule: null,
isMrEdit: false,
defaultRuleName,
});
});
......@@ -555,6 +704,12 @@ describe('EE Approvals RuleForm', () => {
} the name text field`, () => {
expect(findNameInput().attributes('disabled')).toBe(expectedDisabledAttribute);
});
it(`${
approverTypeSelect ? 'renders' : 'does not render'
} the approver type select`, () => {
expect(findApproverTypeSelect().exists()).toBe(approverTypeSelect);
});
},
);
});
......@@ -727,4 +882,23 @@ describe('EE Approvals RuleForm', () => {
});
});
});
describe('when the approval gates feature is disabled', () => {
it('does not render the approver type select input', async () => {
createComponent(
{ isMrEdit: false },
{
provide: {
glFeatures: {
ffComplianceApprovalGates: false,
},
},
},
);
await nextTick();
expect(findApproverTypeSelect().exists()).toBe(false);
});
});
});
export const createExternalRule = () => ({
id: 9,
name: 'API Gate',
externalUrl: 'https://gitlab.com',
ruleType: 'external_approval',
});
export const createProjectRules = () => [
{
id: 1,
......
......@@ -18,8 +18,8 @@ export const mockJiraIssue1 = {
labels: [
{
name: 'backend',
color: '#EBECF0',
text_color: '#283856',
color: '#0052CC',
text_color: '#FFFFFF',
},
],
author: {
......
......@@ -20,8 +20,8 @@ export const mockJiraIssue = {
{
title: 'In Progress',
description: 'Work that is still in progress',
color: '#EBECF0',
text_color: '#283856',
color: '#0052CC',
text_color: '#FFFFFF',
},
],
references: {
......
import { shallowMount } from '@vue/test-utils';
import ReportRow from 'ee/vulnerabilities/components/generic_report/report_row.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
describe('ee/vulnerabilities/components/generic_report/report_row.vue', () => {
let wrapper;
const createWrapper = ({ ...options } = {}) =>
extendedWrapper(
shallowMount(ReportRow, {
propsData: {
label: 'Foo',
},
...options,
}),
);
it('renders the default slot', () => {
const slotContent = 'foo bar';
wrapper = createWrapper({ slots: { default: slotContent } });
expect(wrapper.findByTestId('reportContent').text()).toBe(slotContent);
});
});
import { within, fireEvent } from '@testing-library/dom';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import ReportItem from 'ee/vulnerabilities/components/generic_report/report_item.vue';
import ReportRow from 'ee/vulnerabilities/components/generic_report/report_row.vue';
import ReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import { REPORT_TYPE_URL } from 'ee/vulnerabilities/components/generic_report/types/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
......@@ -47,9 +45,9 @@ describe('ee/vulnerabilities/components/generic_report/report_section.vue', () =
name: /evidence/i,
});
const findReportsSection = () => wrapper.findByTestId('reports');
const findAllReportRows = () => wrapper.findAllComponents(ReportRow);
const findAllReportRows = () => wrapper.findAll('[data-testid*="report-row"]');
const findReportRowByLabel = (label) => wrapper.findByTestId(`report-row-${label}`);
const findItemWithinRow = (row) => row.findComponent(ReportItem);
const findReportItemByLabel = (label) => wrapper.findByTestId(`report-item-${label}`);
const supportedReportTypesLabels = Object.keys(TEST_DATA.supportedTypes);
describe('with supported report types', () => {
......@@ -77,20 +75,21 @@ describe('ee/vulnerabilities/components/generic_report/report_section.vue', () =
expect(findAllReportRows()).toHaveLength(supportedReportTypesLabels.length);
});
it.each(supportedReportTypesLabels)('passes the correct props to report row: %s', (label) => {
expect(findReportRowByLabel(label).props()).toMatchObject({
label: TEST_DATA.supportedTypes[label].name,
});
});
it.each(supportedReportTypesLabels)(
'renders the correct label for report row: %s',
(label) => {
expect(within(findReportRowByLabel(label).element).getByText(label)).toBeInstanceOf(
HTMLElement,
);
},
);
});
describe('report items', () => {
it.each(supportedReportTypesLabels)(
'passes the correct props to item for row: %s',
(label) => {
const row = findReportRowByLabel(label);
expect(findItemWithinRow(row).props()).toMatchObject({
expect(findReportItemByLabel(label).props()).toMatchObject({
item: TEST_DATA.supportedTypes[label],
});
},
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Admin::CloudLicenses::CurrentLicenseResolver do
include GraphqlHelpers
specify do
expect(described_class).to have_nullable_graphql_type(::Types::Admin::CloudLicenses::CurrentLicenseType)
end
describe '#resolve' do
subject(:result) { resolve_current_license }
let_it_be(:admin) { create(:admin) }
let_it_be(:license) { create_current_license }
def resolve_current_license(current_user: admin)
resolve(described_class, ctx: { current_user: current_user })
end
before do
stub_application_setting(cloud_license_enabled: true)
end
context 'when application setting for cloud license is disabled', :enable_admin_mode do
it 'returns nil' do
stub_application_setting(cloud_license_enabled: false)
expect(result).to be_nil
end
end
context 'when current user is unauthorized' do
it 'raises error' do
unauthorized_user = create(:user)
expect do
resolve_current_license(current_user: unauthorized_user)
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when there is no current license', :enable_admin_mode do
it 'returns nil' do
License.delete_all # delete existing license
expect(result).to be_nil
end
end
it 'returns the current license', :enable_admin_mode do
expect(result).to eq(license)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Admin::CloudLicenses::LicenseHistoryEntriesResolver do
include GraphqlHelpers
describe '#resolve' do
subject(:result) { resolve_license_history_entries }
let_it_be(:admin) { create(:admin) }
def create_license(data: {}, license_options: { created_at: Time.current })
gl_license = create(:gitlab_license, data)
create(:license, license_options.merge(data: gl_license.export))
end
def resolve_license_history_entries(current_user: admin)
resolve(described_class, ctx: { current_user: current_user })
end
before do
stub_application_setting(cloud_license_enabled: true)
end
context 'when application setting for cloud license is disabled', :enable_admin_mode do
it 'returns nil' do
stub_application_setting(cloud_license_enabled: false)
expect(result).to be_nil
end
end
context 'when current user is unauthorized' do
it 'raises error' do
unauthorized_user = create(:user)
expect do
resolve_license_history_entries(current_user: unauthorized_user)
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when no licenses exist' do
it 'returns an empty array', :enable_admin_mode do
License.delete_all # delete license created with ee/spec/support/test_license.rb
expect(result).to eq([])
end
end
it 'returns the license history entries', :enable_admin_mode do
today = Date.current
type = License::CLOUD_LICENSE_TYPE
past_license = create_license(
data: { starts_at: today - 1.month, expires_at: today + 11.months },
license_options: { created_at: Time.current - 1.month }
)
expired_license = create_license(data: { starts_at: today - 1.year, expires_at: today - 1.month })
another_license = create_license(data: { starts_at: today - 1.month, expires_at: today + 1.year })
future_license = create_license(data: { starts_at: today + 1.month, expires_at: today + 13.months, type: type })
current_license = create_license(data: { starts_at: today - 15.days, expires_at: today + 11.months, type: type })
expect(result).to eq(
[
future_license,
current_license,
another_license,
past_license,
expired_license,
License.first # created with ee/spec/support/test_license.rb
]
)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['CurrentLicense'], :enable_admin_mode do
let_it_be(:admin) { create(:admin) }
let_it_be(:licensee) do
{
'Name' => 'User Example',
'Email' => 'user@example.com',
'Company' => 'Example Inc.'
}
end
let_it_be(:license) { create_current_license(licensee: licensee, type: License::CLOUD_LICENSE_TYPE) }
let(:fields) do
%w[last_sync billable_users_count maximum_user_count users_over_license_count]
end
def query(field_name)
%(
{
currentLicense {
#{field_name}
}
}
)
end
def query_field(field_name)
GitlabSchema.execute(query(field_name), context: { current_user: admin }).as_json
end
before do
stub_application_setting(cloud_license_enabled: true)
end
it { expect(described_class.graphql_name).to eq('CurrentLicense') }
it { expect(described_class).to include_graphql_fields(*fields) }
include_examples 'license type fields', %w[data currentLicense]
describe "#users_over_license_count" do
context 'when license is for a trial' do
it 'returns 0' do
create_current_license(licensee: licensee, restrictions: { trial: true })
result_as_json = query_field('usersOverLicenseCount')
expect(result_as_json['data']['currentLicense']['usersOverLicenseCount']).to eq(0)
end
end
it 'returns the number of users over the paid users in the license' do
create(:historical_data, active_user_count: 15)
create_current_license(licensee: licensee, restrictions: { active_user_count: 10 })
result_as_json = query_field('usersOverLicenseCount')
expect(result_as_json['data']['currentLicense']['usersOverLicenseCount']).to eq(5)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['LicenseHistoryEntry'], :enable_admin_mode do
let_it_be(:admin) { create(:admin) }
let_it_be(:licensee) do
{
'Name' => 'User Example',
'Email' => 'user@example.com',
'Company' => 'Example Inc.'
}
end
let_it_be(:license) { create_current_license(licensee: licensee, type: License::CLOUD_LICENSE_TYPE) }
def query(field_name)
%(
{
licenseHistoryEntries {
nodes {
#{field_name}
}
}
}
)
end
def query_field(field_name)
GitlabSchema.execute(query(field_name), context: { current_user: admin }).as_json
end
before do
stub_application_setting(cloud_license_enabled: true)
end
it { expect(described_class.graphql_name).to eq('LicenseHistoryEntry') }
include_examples 'license type fields', ['data', 'licenseHistoryEntries', 'nodes', -1]
end
......@@ -10,7 +10,9 @@ RSpec.describe GitlabSchema.types['Query'] do
:vulnerabilities,
:vulnerability,
:instance_security_dashboard,
:vulnerabilities_count_by_day_and_severity
:vulnerabilities_count_by_day_and_severity,
:current_license,
:license_history_entries
).at_least
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'lograge', type: :request do
context 'with a log subscriber' do
include_context 'parsed logs'
include_context 'clear DB Load Balancing configuration'
let(:subscriber) { Lograge::LogSubscribers::ActionController.new }
let(:event) do
ActiveSupport::Notifications::Event.new(
'process_action.action_controller',
Time.now,
Time.now,
2,
status: 200,
controller: 'HomeController',
action: 'index',
format: 'application/json',
method: 'GET',
path: '/home?foo=bar',
params: {},
db_runtime: 0.02,
view_runtime: 0.01
)
end
let(:logging_keys) do
%w[db_primary_wal_count
db_replica_wal_count
db_replica_count
db_replica_cached_count
db_primary_count
db_primary_cached_count
db_primary_duration_s
db_replica_duration_s]
end
context 'when load balancing is enabled' do
before do
allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true)
end
context 'with db payload' do
context 'when RequestStore is enabled', :request_store do
it 'includes db counters' do
subscriber.process_action(event)
expect(log_data).to include(*logging_keys)
end
end
context 'when RequestStore is disabled' do
it 'does not include db counters' do
subscriber.process_action(event)
expect(log_data).not_to include(*logging_keys)
end
end
end
end
context 'when load balancing is disabled' do
before do
allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false)
end
it 'does not include db counters' do
subscriber.process_action(event)
expect(log_data).not_to include(*logging_keys)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
# rubocop: disable RSpec/MultipleMemoizedHelpers
RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do
using RSpec::Parameterized::TableSyntax
subject { described_class.new }
let(:queue) { :test }
let(:worker_class) { worker.class }
let(:job) { {} }
let(:job_status) { :done }
let(:labels_with_job_status) { default_labels.merge(job_status: job_status.to_s) }
let(:default_labels) do
{ queue: queue.to_s,
worker: worker_class.to_s,
boundary: "",
external_dependencies: "no",
feature_category: "",
urgency: "low" }
end
before do
stub_const('TestWorker', Class.new)
TestWorker.class_eval do
include Sidekiq::Worker
include WorkerAttributes
end
end
let(:worker) { TestWorker.new }
include_context 'server metrics with mocked prometheus'
context 'when load_balancing is enabled' do
let(:load_balancing_metric) { double('load balancing metric') }
include_context 'clear DB Load Balancing configuration'
before do
allow(::Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(true)
allow(Gitlab::Metrics).to receive(:counter).with(:sidekiq_load_balancing_count, anything).and_return(load_balancing_metric)
end
describe '#initialize' do
it 'sets load_balancing metrics' do
expect(Gitlab::Metrics).to receive(:counter).with(:sidekiq_load_balancing_count, anything).and_return(load_balancing_metric)
subject
end
end
describe '#call' do
include_context 'server metrics call'
context 'when :database_chosen is provided' do
where(:database_chosen) do
%w[primary retry replica]
end
with_them do
context "when #{params[:database_chosen]} is used" do
let(:labels_with_load_balancing) do
labels_with_job_status.merge(database_chosen: database_chosen, data_consistency: 'delayed')
end
before do
job[:database_chosen] = database_chosen
job[:data_consistency] = 'delayed'
allow(load_balancing_metric).to receive(:increment)
end
it 'increment sidekiq_load_balancing_count' do
expect(load_balancing_metric).to receive(:increment).with(labels_with_load_balancing, 1)
described_class.new.call(worker, job, :test) { nil }
end
end
end
end
context 'when :database_chosen is not provided' do
it 'does not increment sidekiq_load_balancing_count' do
expect(load_balancing_metric).not_to receive(:increment)
described_class.new.call(worker, job, :test) { nil }
end
end
end
end
context 'when load_balancing is disabled' do
include_context 'clear DB Load Balancing configuration'
before do
allow(::Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false)
end
describe '#initialize' do
it 'doesnt set load_balancing metrics' do
expect(Gitlab::Metrics).not_to receive(:counter).with(:sidekiq_load_balancing_count, anything)
subject
end
end
end
end
......@@ -21,7 +21,9 @@ RSpec.describe Gitlab::InstrumentationHelper do
expect(payload).to include(db_replica_count: 0,
db_replica_cached_count: 0,
db_primary_count: 0,
db_primary_cached_count: 0)
db_primary_cached_count: 0,
db_primary_wal_count: 0,
db_replica_wal_count: 0)
end
end
......@@ -30,13 +32,15 @@ RSpec.describe Gitlab::InstrumentationHelper do
allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false)
end
it 'includes DB counts' do
it 'does not include DB counts' do
subject
expect(payload).not_to include(db_replica_count: 0,
db_replica_cached_count: 0,
db_primary_count: 0,
db_primary_cached_count: 0)
db_primary_cached_count: 0,
db_primary_wal_count: 0,
db_replica_wal_count: 0)
end
end
......
......@@ -29,18 +29,20 @@ RSpec.describe ::Gitlab::Metrics::Subscribers::ActiveRecord do
end
shared_examples 'track sql events for each role' do
where(:name, :sql_query, :record_query, :record_write_query, :record_cached_query) do
'SQL' | 'SELECT * FROM users WHERE id = 10' | true | false | false
'SQL' | 'WITH active_milestones AS (SELECT COUNT(*), state FROM milestones GROUP BY state) SELECT * FROM active_milestones' | true | false | false
'SQL' | 'SELECT * FROM users WHERE id = 10 FOR UPDATE' | true | true | false
'SQL' | 'WITH archived_rows AS (SELECT * FROM users WHERE archived = true) INSERT INTO products_log SELECT * FROM archived_rows' | true | true | false
'SQL' | 'DELETE FROM users where id = 10' | true | true | false
'SQL' | 'INSERT INTO project_ci_cd_settings (project_id) SELECT id FROM projects' | true | true | false
'SQL' | 'UPDATE users SET admin = true WHERE id = 10' | true | true | false
'CACHE' | 'SELECT * FROM users WHERE id = 10' | true | false | true
'SCHEMA' | "SELECT attr.attname FROM pg_attribute attr INNER JOIN pg_constraint cons ON attr.attrelid = cons.conrelid AND attr.attnum = any(cons.conkey) WHERE cons.contype = 'p' AND cons.conrelid = '\"projects\"'::regclass" | false | false | false
nil | 'BEGIN' | false | false | false
nil | 'COMMIT' | false | false | false
where(:name, :sql_query, :record_query, :record_write_query, :record_cached_query, :record_wal_query) do
'SQL' | 'SELECT * FROM users WHERE id = 10' | true | false | false | false
'SQL' | 'WITH active_milestones AS (SELECT COUNT(*), state FROM milestones GROUP BY state) SELECT * FROM active_milestones' | true | false | false | false
'SQL' | 'SELECT * FROM users WHERE id = 10 FOR UPDATE' | true | true | false | false
'SQL' | 'WITH archived_rows AS (SELECT * FROM users WHERE archived = true) INSERT INTO products_log SELECT * FROM archived_rows' | true | true | false | false
'SQL' | 'DELETE FROM users where id = 10' | true | true | false | false
'SQL' | 'INSERT INTO project_ci_cd_settings (project_id) SELECT id FROM projects' | true | true | false | false
'SQL' | 'UPDATE users SET admin = true WHERE id = 10' | true | true | false | false
'SQL' | 'SELECT pg_current_wal_insert_lsn()::text AS location' | true | false | false | true
'SQL' | 'SELECT pg_last_wal_replay_lsn()::text AS location' | true | false | false | true
'CACHE' | 'SELECT * FROM users WHERE id = 10' | true | false | true | false
'SCHEMA' | "SELECT attr.attname FROM pg_attribute attr INNER JOIN pg_constraint cons ON attr.attrelid = cons.conrelid AND attr.attnum = any(cons.conkey) WHERE cons.contype = 'p' AND cons.conrelid = '\"projects\"'::regclass" | false | false | false | false
nil | 'BEGIN' | false | false | false | false
nil | 'COMMIT' | false | false | false | false
end
with_them do
......
......@@ -76,7 +76,9 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
'db_replica_count' => 0,
'db_replica_cached_count' => 0,
'db_primary_count' => a_value >= 1,
'db_primary_cached_count' => 0
'db_primary_cached_count' => 0,
'db_primary_wal_count' => 0,
'db_replica_wal_count' => 0
)
end
......@@ -94,7 +96,9 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
'db_replica_count' => 0,
'db_replica_cached_count' => 0,
'db_primary_count' => 0,
'db_primary_cached_count' => 0
'db_primary_cached_count' => 0,
'db_primary_wal_count' => 0,
'db_replica_wal_count' => 0
)
end
......
......@@ -9,7 +9,9 @@ RSpec.describe Gitlab::SidekiqMiddleware::InstrumentationLogger do
:db_replica_count,
:db_replica_cached_count,
:db_primary_count,
:db_primary_cached_count
:db_primary_cached_count,
:db_primary_wal_count,
:db_replica_wal_count
]
expect(described_class.keys).to include(*expected_keys)
......
......@@ -1411,6 +1411,20 @@ RSpec.describe License do
end
end
describe '#license_type' do
subject { license.license_type }
context 'when the license is not a cloud license' do
it { is_expected.to eq(described_class::LEGACY_LICENSE_TYPE) }
end
context 'when the license is a cloud license' do
let(:gl_license) { build(:gitlab_license, type: described_class::CLOUD_LICENSE_TYPE) }
it { is_expected.to eq(described_class::CLOUD_LICENSE_TYPE) }
end
end
describe '#auto_renew' do
it 'is false' do
expect(license.auto_renew).to be false
......@@ -1485,4 +1499,28 @@ RSpec.describe License do
it { is_expected.to eq(result) }
end
end
describe '#licensee_name' do
subject { license.licensee_name }
let(:gl_license) { build(:gitlab_license, licensee: { 'Name' => 'User Example' }) }
it { is_expected.to eq('User Example') }
end
describe '#licensee_email' do
subject { license.licensee_email }
let(:gl_license) { build(:gitlab_license, licensee: { 'Email' => 'user@example.com' }) }
it { is_expected.to eq('user@example.com') }
end
describe '#licensee_company' do
subject { license.licensee_company }
let(:gl_license) { build(:gitlab_license, licensee: { 'Company' => 'Example Inc.' }) }
it { is_expected.to eq('Example Inc.') }
end
end
......@@ -88,8 +88,8 @@ RSpec.describe Integrations::Jira::IssueDetailEntity do
{
title: 'backend',
name: 'backend',
color: '#EBECF0',
text_color: '#283856'
color: '#0052CC',
text_color: '#FFFFFF'
}
],
author: hash_including(
......
......@@ -55,8 +55,8 @@ RSpec.describe Integrations::Jira::IssueEntity do
{
title: 'backend',
name: 'backend',
color: '#EBECF0',
text_color: '#283856'
color: '#0052CC',
text_color: '#FFFFFF'
}
],
author: hash_including(
......
......@@ -302,12 +302,13 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
end
it 'updates code owner approval rules' do
expect_next_instance_of(::MergeRequests::SyncCodeOwnerApprovalRules) do |instance|
expect(instance).to receive(:execute)
end
context 'when called inside an ActiveRecord transaction' do
it 'does not attempt to update code owner approval rules' do
allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(true)
expect(::MergeRequests::SyncCodeOwnerApprovalRulesWorker).not_to receive(:perform_async)
update_merge_request(title: 'Title')
update_merge_request(title: 'Title')
end
end
context 'updating reviewers_ids' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Milestones::DestroyService do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:milestone) { create(:milestone, title: 'Milestone v1.0', project: project) }
before do
project.add_maintainer(user)
end
def service
described_class.new(project, user, {})
end
describe '#execute' do
context 'with an existing merge request' do
let!(:issue) { create(:issue, project: project, milestone: milestone) }
let!(:merge_request) { create(:merge_request, source_project: project, milestone: milestone) }
it 'manually queues MergeRequests::SyncCodeOwnerApprovalRulesWorker jobs' do
expect(::MergeRequests::SyncCodeOwnerApprovalRulesWorker).to receive(:perform_async)
service.execute(milestone)
end
end
end
end
# frozen_string_literal: true
RSpec.shared_examples_for 'license type fields' do |keys|
context 'with license type fields' do
let(:license_fields) do
%w[id type plan name email company starts_at expires_at activated_at users_in_license_count]
end
it { expect(described_class).to include_graphql_fields(*license_fields) }
end
end
# frozen_string_literal: true
require "spec_helper"
RSpec.describe MergeRequests::SyncCodeOwnerApprovalRulesWorker do
let_it_be(:merge_request) { create(:merge_request) }
subject { described_class.new }
describe "#perform" do
it_behaves_like 'an idempotent worker' do
let(:job_args) { [merge_request.id] }
end
context "when merge request is not found" do
it "returns without attempting to sync code owner rules" do
expect(MergeRequests::SyncCodeOwnerApprovalRules).not_to receive(:new)
subject.perform(non_existing_record_id)
end
end
context "when merge request is found" do
it "attempts to sync code owner rules" do
expect_next_instance_of(::MergeRequests::SyncCodeOwnerApprovalRules) do |instance|
expect(instance).to receive(:execute)
end
subject.perform(merge_request.id)
end
end
end
end
......@@ -82,6 +82,30 @@ module Gitlab
!!@overflow
end
def overflow_max_lines?
!!@overflow_max_lines
end
def overflow_max_bytes?
!!@overflow_max_bytes
end
def overflow_max_files?
!!@overflow_max_files
end
def collapsed_safe_lines?
!!@collapsed_safe_lines
end
def collapsed_safe_files?
!!@collapsed_safe_files
end
def collapsed_safe_bytes?
!!@collapsed_safe_bytes
end
def size
@size ||= count # forces a loop using each method
end
......@@ -121,7 +145,15 @@ module Gitlab
end
def over_safe_limits?(files)
files >= safe_max_files || @line_count > safe_max_lines || @byte_count >= safe_max_bytes
if files >= safe_max_files
@collapsed_safe_files = true
elsif @line_count > safe_max_lines
@collapsed_safe_lines = true
elsif @byte_count >= safe_max_bytes
@collapsed_safe_bytes = true
end
@collapsed_safe_files || @collapsed_safe_lines || @collapsed_safe_bytes
end
def expand_diff?
......@@ -154,6 +186,7 @@ module Gitlab
if @enforce_limits && i >= max_files
@overflow = true
@overflow_max_files = true
break
end
......@@ -166,10 +199,19 @@ module Gitlab
@line_count += diff.line_count
@byte_count += diff.diff.bytesize
if @enforce_limits && (@line_count >= max_lines || @byte_count >= max_bytes)
if @enforce_limits && @line_count >= max_lines
# This last Diff instance pushes us over the lines limit. We stop and
# discard it.
@overflow = true
@overflow_max_lines = true
break
end
if @enforce_limits && @byte_count >= max_bytes
# This last Diff instance pushes us over the lines limit. We stop and
# discard it.
@overflow = true
@overflow_max_bytes = true
break
end
......
......@@ -13,7 +13,6 @@ module Gitlab
:elasticsearch_calls,
:elasticsearch_duration_s,
:elasticsearch_timed_out_count,
:worker_data_consistency,
*::Gitlab::Memory::Instrumentation::KEY_MAPPING.values,
*::Gitlab::Instrumentation::Redis.known_payload_keys,
*::Gitlab::Metrics::Subscribers::ActiveRecord.known_payload_keys,
......
......@@ -21,6 +21,16 @@ module Gitlab
Thread.current.name ||= Gitlab::Metrics::Samplers::ThreadsSampler::SIDEKIQ_WORKER_THREAD_NAME
labels = create_labels(worker.class, queue, job)
instrument(job, labels) do
yield
end
end
protected
attr_reader :metrics
def instrument(job, labels)
queue_duration = ::Gitlab::InstrumentationHelper.queue_duration_for_job(job)
@metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration
......@@ -62,8 +72,6 @@ module Gitlab
end
end
private
def init_metrics
{
sidekiq_jobs_cpu_seconds: ::Gitlab::Metrics.histogram(:sidekiq_jobs_cpu_seconds, 'Seconds of cpu time to run Sidekiq job', {}, SIDEKIQ_LATENCY_BUCKETS),
......@@ -82,6 +90,8 @@ module Gitlab
}
end
private
def get_thread_cputime
defined?(Process::CLOCK_THREAD_CPUTIME_ID) ? Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID) : 0
end
......@@ -108,3 +118,5 @@ module Gitlab
end
end
end
Gitlab::SidekiqMiddleware::ServerMetrics.prepend_if_ee('EE::Gitlab::SidekiqMiddleware::ServerMetrics')
......@@ -2327,6 +2327,9 @@ msgstr ""
msgid "AdminSettings|Required pipeline configuration"
msgstr ""
msgid "AdminSettings|See affected service templates"
msgstr ""
msgid "AdminSettings|Select a pipeline configuration file"
msgstr ""
......@@ -2363,6 +2366,9 @@ msgstr ""
msgid "AdminSettings|You can't add new templates. To migrate or remove a Service template, create a new integration at %{settings_link_start}Settings &gt; Integrations%{link_end}. Learn more about %{doc_link_start}Project integration management%{link_end}."
msgstr ""
msgid "AdminSettings|You should migrate to %{doc_link_start}Project integration management%{link_end}, available at %{settings_link_start}Settings &gt; Integrations.%{link_end}"
msgstr ""
msgid "AdminStatistics|Active Users"
msgstr ""
......@@ -3982,6 +3988,9 @@ msgstr ""
msgid "Applying suggestions..."
msgstr ""
msgid "Approval Gate"
msgstr ""
msgid "Approval Status"
msgstr ""
......@@ -4004,6 +4013,15 @@ msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked."
msgstr[0] ""
msgstr[1] ""
msgid "ApprovalRuleRemove|Remove approval gate"
msgstr ""
msgid "ApprovalRuleRemove|Remove approval gate?"
msgstr ""
msgid "ApprovalRuleRemove|You are about to remove the %{name} approval gate. Approval from this service is not revoked."
msgstr ""
msgid "ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}."
msgstr ""
......@@ -4017,21 +4035,36 @@ msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCoun
msgstr[0] ""
msgstr[1] ""
msgid "ApprovalRule|Add approvel gate"
msgstr ""
msgid "ApprovalRule|Add approvers"
msgstr ""
msgid "ApprovalRule|Approval rules"
msgstr ""
msgid "ApprovalRule|Approval service API"
msgstr ""
msgid "ApprovalRule|Approvals required"
msgstr ""
msgid "ApprovalRule|Approvel gate"
msgstr ""
msgid "ApprovalRule|Approver Type"
msgstr ""
msgid "ApprovalRule|Approvers"
msgstr ""
msgid "ApprovalRule|Examples: QA, Security."
msgstr ""
msgid "ApprovalRule|Invoke an external API as part of the approvals"
msgstr ""
msgid "ApprovalRule|Name"
msgstr ""
......@@ -4041,6 +4074,9 @@ msgstr ""
msgid "ApprovalRule|Target branch"
msgstr ""
msgid "ApprovalRule|Users or groups"
msgstr ""
msgid "ApprovalStatusTooltip|Adheres to separation of duties"
msgstr ""
......@@ -11256,7 +11292,7 @@ msgstr ""
msgid "DiscordService|Discord Notifications"
msgstr ""
msgid "DiscordService|Receive event notifications in Discord"
msgid "DiscordService|Send notifications about project events to a Discord channel."
msgstr ""
msgid "Discover GitLab Geo"
......@@ -13013,6 +13049,9 @@ msgstr ""
msgid "External storage authentication token"
msgstr ""
msgid "External url has already been taken"
msgstr ""
msgid "ExternalAuthorizationService|Classification label"
msgstr ""
......@@ -18672,6 +18711,9 @@ msgstr ""
msgid "Leave edit mode? All unsaved changes will be lost."
msgstr ""
msgid "Leave feedback"
msgstr ""
msgid "Leave group"
msgstr ""
......@@ -28298,6 +28340,9 @@ msgstr ""
msgid "Send notifications about project events to Mattermost channels. %{docs_link}"
msgstr ""
msgid "Send notifications about project events to a Discord channel. %{docs_link}"
msgstr ""
msgid "Send report"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Service templates deprecation callout' do
let_it_be(:admin) { create(:admin) }
let_it_be(:non_admin) { create(:user) }
let_it_be(:callout_content) { 'Service templates are deprecated and will be removed in GitLab 14.0.' }
context 'when a non-admin is logged in' do
before do
sign_in(non_admin)
visit root_dashboard_path
end
it 'does not display callout' do
expect(page).not_to have_content callout_content
end
end
context 'when an admin is logged in' do
before do
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
visit root_dashboard_path
end
context 'with no active service templates' do
it 'does not display callout' do
expect(page).not_to have_content callout_content
end
end
context 'with active service template' do
before do
create(:service, :template, type: 'MattermostService', active: true)
visit root_dashboard_path
end
it 'displays callout' do
expect(page).to have_content callout_content
expect(page).to have_link 'See affected service templates', href: admin_application_settings_services_path
end
context 'when callout is dismissed', :js do
before do
find('[data-testid="close-service-templates-deprecated-callout"]').click
visit root_dashboard_path
end
it 'does not display callout' do
expect(page).not_to have_content callout_content
end
end
end
end
end
......@@ -291,6 +291,8 @@ RSpec.describe DiffHelper do
end
describe '#render_overflow_warning?' do
using RSpec::Parameterized::TableSyntax
let(:diffs_collection) { instance_double(Gitlab::Diff::FileCollection::MergeRequestDiff, raw_diff_files: diff_files) }
let(:diff_files) { Gitlab::Git::DiffCollection.new(files) }
let(:safe_file) { { too_large: false, diff: '' } }
......@@ -299,13 +301,42 @@ RSpec.describe DiffHelper do
before do
allow(diff_files).to receive(:overflow?).and_return(false)
allow(diff_files).to receive(:overflow_max_bytes?).and_return(false)
allow(diff_files).to receive(:overflow_max_files?).and_return(false)
allow(diff_files).to receive(:overflow_max_lines?).and_return(false)
allow(diff_files).to receive(:collapsed_safe_bytes?).and_return(false)
allow(diff_files).to receive(:collapsed_safe_files?).and_return(false)
allow(diff_files).to receive(:collapsed_safe_lines?).and_return(false)
end
context 'when neither collection nor individual file hit the limit' do
context 'when no limits are hit' do
it 'returns false and does not log any overflow events' do
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_collection_limits)
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_single_file_limits)
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_max_bytes_limits)
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_max_files_limits)
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_max_lines_limits)
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_collapsed_bytes_limits)
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_collapsed_files_limits)
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_collapsed_lines_limits)
expect(render_overflow_warning?(diffs_collection)).to be false
end
end
where(:overflow_method, :event_name) do
:overflow_max_bytes? | :diffs_overflow_max_bytes_limits
:overflow_max_files? | :diffs_overflow_max_files_limits
:overflow_max_lines? | :diffs_overflow_max_lines_limits
:collapsed_safe_bytes? | :diffs_overflow_collapsed_bytes_limits
:collapsed_safe_files? | :diffs_overflow_collapsed_files_limits
:collapsed_safe_lines? | :diffs_overflow_collapsed_lines_limits
end
with_them do
it 'returns false and only logs the correct collection overflow event' do
allow(diff_files).to receive(overflow_method).and_return(true)
expect(Gitlab::Metrics).to receive(:add_event).with(event_name).once
expect(render_overflow_warning?(diffs_collection)).to be false
end
end
......@@ -315,9 +346,8 @@ RSpec.describe DiffHelper do
allow(diff_files).to receive(:overflow?).and_return(true)
end
it 'returns false and only logs collection overflow event' do
expect(Gitlab::Metrics).to receive(:add_event).with(:diffs_overflow_collection_limits).exactly(:once)
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_single_file_limits)
it 'returns true and only logs all the correct collection overflow event' do
expect(Gitlab::Metrics).to receive(:add_event).with(:diffs_overflow_collection_limits).once
expect(render_overflow_warning?(diffs_collection)).to be true
end
......
......@@ -81,23 +81,31 @@ RSpec.describe UserCalloutsHelper do
end
end
describe '.show_service_templates_deprecated?' do
subject { helper.show_service_templates_deprecated? }
describe '.show_service_templates_deprecated_callout?' do
using RSpec::Parameterized::TableSyntax
context 'when user has not dismissed' do
before do
allow(helper).to receive(:user_dismissed?).with(described_class::SERVICE_TEMPLATES_DEPRECATED) { false }
end
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:non_admin) { create(:user) }
it { is_expected.to be true }
subject { helper.show_service_templates_deprecated_callout? }
where(:self_managed, :is_admin_user, :has_active_service_template, :callout_dismissed, :should_show_callout) do
true | true | true | false | true
true | true | true | true | false
true | false | true | false | false
false | true | true | false | false
true | true | false | false | false
end
context 'when user dismissed' do
with_them do
before do
allow(helper).to receive(:user_dismissed?).with(described_class::SERVICE_TEMPLATES_DEPRECATED) { true }
allow(::Gitlab).to receive(:com?).and_return(!self_managed)
allow(helper).to receive(:current_user).and_return(is_admin_user ? admin : non_admin)
allow(helper).to receive(:user_dismissed?).with(described_class::SERVICE_TEMPLATES_DEPRECATED_CALLOUT) { callout_dismissed }
create(:service, :template, type: 'MattermostService', active: has_active_service_template)
end
it { is_expected.to be false }
it { is_expected.to be should_show_callout }
end
end
......
......@@ -31,6 +31,19 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
end
end
let(:overflow_max_bytes) { false }
let(:overflow_max_files) { false }
let(:overflow_max_lines) { false }
shared_examples 'overflow stuff' do
it 'returns the expected overflow values' do
subject.overflow?
expect(subject.overflow_max_bytes?).to eq(overflow_max_bytes)
expect(subject.overflow_max_files?).to eq(overflow_max_files)
expect(subject.overflow_max_lines?).to eq(overflow_max_lines)
end
end
subject do
Gitlab::Git::DiffCollection.new(
iterator,
......@@ -76,12 +89,19 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
end
context 'overflow handling' do
subject { super() }
let(:collapsed_safe_files) { false }
let(:collapsed_safe_lines) { false }
context 'adding few enough files' do
let(:file_count) { 3 }
context 'and few enough lines' do
let(:line_count) { 10 }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -117,6 +137,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do
let(:limits) { false }
let(:overflow_max_bytes) { false }
let(:overflow_max_files) { false }
let(:overflow_max_lines) { false }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -155,6 +180,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'and too many lines' do
let(:line_count) { 1000 }
let(:overflow_max_lines) { true }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -184,6 +212,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do
let(:limits) { false }
let(:overflow_max_bytes) { false }
let(:overflow_max_files) { false }
let(:overflow_max_lines) { false }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -216,10 +249,13 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'adding too many files' do
let(:file_count) { 11 }
let(:overflow_max_files) { true }
context 'and few enough lines' do
let(:line_count) { 1 }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -248,6 +284,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do
let(:limits) { false }
let(:overflow_max_bytes) { false }
let(:overflow_max_files) { false }
let(:overflow_max_lines) { false }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -279,6 +320,10 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'and too many lines' do
let(:line_count) { 30 }
let(:overflow_max_lines) { true }
let(:overflow_max_files) { false }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -308,6 +353,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do
let(:limits) { false }
let(:overflow_max_bytes) { false }
let(:overflow_max_files) { false }
let(:overflow_max_lines) { false }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -344,6 +394,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'and few enough lines' do
let(:line_count) { 1 }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -375,6 +427,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'adding too many bytes' do
let(:file_count) { 10 }
let(:line_length) { 5200 }
let(:overflow_max_bytes) { true }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -404,6 +459,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do
let(:limits) { false }
let(:overflow_max_bytes) { false }
let(:overflow_max_files) { false }
let(:overflow_max_lines) { false }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -437,6 +497,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
describe 'empty collection' do
subject { Gitlab::Git::DiffCollection.new([]) }
it_behaves_like 'overflow stuff'
describe '#overflow?' do
subject { super().overflow? }
......@@ -555,7 +617,7 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
.and_return({ max_files: 2, max_lines: max_lines })
end
it 'prunes diffs by default even little ones' do
it 'prunes diffs by default even little ones and sets collapsed_safe_files true' do
subject.each_with_index do |d, i|
if i < 2
expect(d.diff).not_to eq('')
......@@ -563,6 +625,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
expect(d.diff).to eq('')
end
end
expect(subject.collapsed_safe_files?).to eq(true)
end
end
......@@ -582,7 +646,7 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
.and_return({ max_files: max_files, max_lines: 80 })
end
it 'prunes diffs by default even little ones' do
it 'prunes diffs by default even little ones and sets collapsed_safe_lines true' do
subject.each_with_index do |d, i|
if i < 2
expect(d.diff).not_to eq('')
......@@ -590,26 +654,30 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
expect(d.diff).to eq('')
end
end
expect(subject.collapsed_safe_lines?).to eq(true)
end
end
context 'when go over safe limits on bytes' do
let(:iterator) do
[
fake_diff(1, 45),
fake_diff(1, 45),
fake_diff(1, 20480),
fake_diff(1, 1)
fake_diff(5, 10),
fake_diff(5000, 10),
fake_diff(5, 10),
fake_diff(5, 10)
]
end
before do
allow(Gitlab::CurrentSettings).to receive(:diff_max_patch_bytes).and_return(1.megabyte)
allow(Gitlab::Git::DiffCollection)
.to receive(:default_limits)
.and_return({ max_files: max_files, max_lines: 80 })
.and_return({ max_files: 4, max_lines: 3000 })
end
it 'prunes diffs by default even little ones' do
it 'prunes diffs by default even little ones and sets collapsed_safe_bytes true' do
subject.each_with_index do |d, i|
if i < 2
expect(d.diff).not_to eq('')
......@@ -617,6 +685,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
expect(d.diff).to eq('')
end
end
expect(subject.collapsed_safe_bytes?).to eq(true)
end
end
end
......
......@@ -124,6 +124,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
with_them do
let(:payload) { { name: name, sql: sql(sql_query, comments: comments), connection: connection } }
let(:record_wal_query) { false }
it 'marks the current thread as using the database' do
# since it would already have been toggled by other specs
......
......@@ -3,156 +3,33 @@
require 'spec_helper'
RSpec.describe Gitlab::SidekiqMiddleware::ClientMetrics do
context "with worker attribution" do
subject { described_class.new }
shared_examples "a metrics middleware" do
context "with mocked prometheus" do
let(:enqueued_jobs_metric) { double('enqueued jobs metric', increment: true) }
let(:queue) { :test }
let(:worker_class) { worker.class }
let(:job) { {} }
let(:default_labels) do
{ queue: queue.to_s,
worker: worker_class.to_s,
boundary: "",
external_dependencies: "no",
feature_category: "",
urgency: "low" }
end
shared_examples "a metrics client middleware" do
context "with mocked prometheus" do
let(:enqueued_jobs_metric) { double('enqueued jobs metric', increment: true) }
before do
allow(Gitlab::Metrics).to receive(:counter).with(described_class::ENQUEUED, anything).and_return(enqueued_jobs_metric)
end
describe '#call' do
it 'yields block' do
expect { |b| subject.call(worker_class, job, :test, double, &b) }.to yield_control.once
end
it 'increments enqueued jobs metric with correct labels when worker is a string of the class' do
expect(enqueued_jobs_metric).to receive(:increment).with(labels, 1)
subject.call(worker_class.to_s, job, :test, double) { nil }
end
it 'increments enqueued jobs metric with correct labels' do
expect(enqueued_jobs_metric).to receive(:increment).with(labels, 1)
subject.call(worker_class, job, :test, double) { nil }
end
end
end
end
context "when workers are not attributed" do
before do
stub_const('TestNonAttributedWorker', Class.new)
TestNonAttributedWorker.class_eval do
include Sidekiq::Worker
end
end
it_behaves_like "a metrics client middleware" do
let(:worker) { TestNonAttributedWorker.new }
let(:labels) { default_labels.merge(urgency: "") }
end
end
context "when a worker is wrapped into ActiveJob" do
before do
stub_const('TestWrappedWorker', Class.new)
TestWrappedWorker.class_eval do
include Sidekiq::Worker
end
end
it_behaves_like "a metrics client middleware" do
let(:job) do
{
"class" => ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper,
"wrapped" => TestWrappedWorker
}
end
let(:worker) { TestWrappedWorker.new }
let(:labels) { default_labels.merge(urgency: "") }
end
end
context "when workers are attributed" do
def create_attributed_worker_class(urgency, external_dependencies, resource_boundary, category)
klass = Class.new do
include Sidekiq::Worker
include WorkerAttributes
urgency urgency if urgency
worker_has_external_dependencies! if external_dependencies
worker_resource_boundary resource_boundary unless resource_boundary == :unknown
feature_category category unless category.nil?
end
stub_const("TestAttributedWorker", klass)
end
let(:urgency) { nil }
let(:external_dependencies) { false }
let(:resource_boundary) { :unknown }
let(:feature_category) { nil }
let(:worker_class) { create_attributed_worker_class(urgency, external_dependencies, resource_boundary, feature_category) }
let(:worker) { worker_class.new }
context "high urgency" do
it_behaves_like "a metrics client middleware" do
let(:urgency) { :high }
let(:labels) { default_labels.merge(urgency: "high") }
end
allow(Gitlab::Metrics).to receive(:counter).with(described_class::ENQUEUED, anything).and_return(enqueued_jobs_metric)
end
context "no urgency" do
it_behaves_like "a metrics client middleware" do
let(:urgency) { :throttled }
let(:labels) { default_labels.merge(urgency: "throttled") }
describe '#call' do
it 'yields block' do
expect { |b| subject.call(worker_class, job, :test, double, &b) }.to yield_control.once
end
end
context "external dependencies" do
it_behaves_like "a metrics client middleware" do
let(:external_dependencies) { true }
let(:labels) { default_labels.merge(external_dependencies: "yes") }
end
end
it 'increments enqueued jobs metric with correct labels when worker is a string of the class' do
expect(enqueued_jobs_metric).to receive(:increment).with(labels, 1)
context "cpu boundary" do
it_behaves_like "a metrics client middleware" do
let(:resource_boundary) { :cpu }
let(:labels) { default_labels.merge(boundary: "cpu") }
subject.call(worker_class.to_s, job, :test, double) { nil }
end
end
context "memory boundary" do
it_behaves_like "a metrics client middleware" do
let(:resource_boundary) { :memory }
let(:labels) { default_labels.merge(boundary: "memory") }
end
end
it 'increments enqueued jobs metric with correct labels' do
expect(enqueued_jobs_metric).to receive(:increment).with(labels, 1)
context "feature category" do
it_behaves_like "a metrics client middleware" do
let(:feature_category) { :authentication }
let(:labels) { default_labels.merge(feature_category: "authentication") }
end
end
context "combined" do
it_behaves_like "a metrics client middleware" do
let(:urgency) { :high }
let(:external_dependencies) { true }
let(:resource_boundary) { :cpu }
let(:feature_category) { :authentication }
let(:labels) { default_labels.merge(urgency: "high", external_dependencies: "yes", boundary: "cpu", feature_category: "authentication") }
subject.call(worker_class, job, :test, double) { nil }
end
end
end
end
it_behaves_like 'metrics middleware with worker attribution'
end
......@@ -35,7 +35,6 @@ RSpec.describe Gitlab::SidekiqMiddleware::InstrumentationLogger do
:elasticsearch_calls,
:elasticsearch_duration_s,
:elasticsearch_timed_out_count,
:worker_data_consistency,
:mem_objects,
:mem_bytes,
:mem_mallocs,
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment