Commit e3b7bfd9 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 6b15cc43 06ab148d
...@@ -555,6 +555,7 @@ ...@@ -555,6 +555,7 @@
when: never when: never
- <<: *if-merge-request - <<: *if-merge-request
changes: *db-patterns changes: *db-patterns
when: manual
.rails:rules:ee-and-foss-unit: .rails:rules:ee-and-foss-unit:
rules: rules:
......
workhorse:verify: workhorse:verify:
extends: .workhorse:rules:workhorse extends: .workhorse:rules:workhorse
image: ${GITLAB_DEPENDENCY_PROXY}golang:1.15 image: ${GITLAB_DEPENDENCY_PROXY}golang:1.16
stage: test stage: test
needs: [] needs: []
script: script:
...@@ -23,14 +23,10 @@ workhorse:verify: ...@@ -23,14 +23,10 @@ workhorse:verify:
- apt-get update && apt-get -y install libimage-exiftool-perl - apt-get update && apt-get -y install libimage-exiftool-perl
- make -C workhorse test - 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: workhorse:test using go 1.15:
extends: .workhorse:test extends: .workhorse:test
image: ${GITLAB_DEPENDENCY_PROXY}golang:1.15 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: ...@@ -431,8 +431,6 @@ RSpec/EmptyLineAfterFinalLetItBe:
- ee/spec/controllers/projects/merge_requests_controller_spec.rb - ee/spec/controllers/projects/merge_requests_controller_spec.rb
- ee/spec/controllers/projects/mirrors_controller_spec.rb - ee/spec/controllers/projects/mirrors_controller_spec.rb
- ee/spec/controllers/projects/threat_monitoring_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/controllers/subscriptions_controller_spec.rb
- ee/spec/features/boards/group_boards/multiple_boards_spec.rb - ee/spec/features/boards/group_boards/multiple_boards_spec.rb
- ee/spec/features/ci_shared_runner_warnings_spec.rb - ee/spec/features/ci_shared_runner_warnings_spec.rb
...@@ -932,9 +930,6 @@ RSpec/EmptyLineAfterFinalLetItBe: ...@@ -932,9 +930,6 @@ RSpec/EmptyLineAfterFinalLetItBe:
- spec/services/audit_event_service_spec.rb - spec/services/audit_event_service_spec.rb
- spec/services/auth/dependency_proxy_authentication_service_spec.rb - spec/services/auth/dependency_proxy_authentication_service_spec.rb
- spec/services/auto_merge_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/destroy_service_spec.rb
- spec/services/boards/issues/move_service_spec.rb - spec/services/boards/issues/move_service_spec.rb
- spec/services/bulk_create_integration_service_spec.rb - spec/services/bulk_create_integration_service_spec.rb
...@@ -968,9 +963,6 @@ RSpec/EmptyLineAfterFinalLetItBe: ...@@ -968,9 +963,6 @@ RSpec/EmptyLineAfterFinalLetItBe:
- spec/services/design_management/save_designs_service_spec.rb - spec/services/design_management/save_designs_service_spec.rb
- spec/services/discussions/resolve_service_spec.rb - spec/services/discussions/resolve_service_spec.rb
- spec/services/discussions/unresolve_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/create_service_spec.rb
- spec/services/feature_flags/destroy_service_spec.rb - spec/services/feature_flags/destroy_service_spec.rb
- spec/services/feature_flags/disable_service_spec.rb - spec/services/feature_flags/disable_service_spec.rb
......
...@@ -7,6 +7,7 @@ const PERSISTENT_USER_CALLOUTS = [ ...@@ -7,6 +7,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-buy-pipeline-minutes-notification-callout', '.js-buy-pipeline-minutes-notification-callout',
'.js-token-expiry-callout', '.js-token-expiry-callout',
'.js-registration-enabled-callout', '.js-registration-enabled-callout',
'.js-service-templates-deprecated-callout',
'.js-new-user-signups-cap-reached', '.js-new-user-signups-cap-reached',
'.js-eoa-bronze-plan-banner', '.js-eoa-bronze-plan-banner',
]; ];
......
...@@ -190,12 +190,8 @@ module DiffHelper ...@@ -190,12 +190,8 @@ module DiffHelper
def render_overflow_warning?(diffs_collection) def render_overflow_warning?(diffs_collection)
diff_files = diffs_collection.raw_diff_files 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| diff_files.overflow?.tap do |overflown|
Gitlab::Metrics.add_event(:diffs_overflow_collection_limits) if overflown log_overflow_limits(diff_files)
end end
end end
...@@ -286,4 +282,18 @@ module DiffHelper ...@@ -286,4 +282,18 @@ module DiffHelper
conflicts_service.conflicts.files.index_by(&:our_path) conflicts_service.conflicts.files.index_by(&:our_path)
end 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 end
...@@ -5,7 +5,7 @@ module UserCalloutsHelper ...@@ -5,7 +5,7 @@ module UserCalloutsHelper
GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration' GKE_CLUSTER_INTEGRATION = 'gke_cluster_integration'
GCP_SIGNUP_OFFER = 'gcp_signup_offer' GCP_SIGNUP_OFFER = 'gcp_signup_offer'
SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed' 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' TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
WEBHOOKS_MOVED = 'webhooks_moved' WEBHOOKS_MOVED = 'webhooks_moved'
CUSTOMIZE_HOMEPAGE = 'customize_homepage' CUSTOMIZE_HOMEPAGE = 'customize_homepage'
...@@ -41,8 +41,11 @@ module UserCalloutsHelper ...@@ -41,8 +41,11 @@ module UserCalloutsHelper
!user_dismissed?(SUGGEST_POPOVER_DISMISSED) !user_dismissed?(SUGGEST_POPOVER_DISMISSED)
end end
def show_service_templates_deprecated? def show_service_templates_deprecated_callout?
!user_dismissed?(SERVICE_TEMPLATES_DEPRECATED) !Gitlab.com? &&
current_user&.admin? &&
Service.for_template.active.exists? &&
!user_dismissed?(SERVICE_TEMPLATES_DEPRECATED_CALLOUT)
end end
def show_webhooks_moved_alert? def show_webhooks_moved_alert?
......
...@@ -179,18 +179,6 @@ class CommitStatus < ApplicationRecord ...@@ -179,18 +179,6 @@ class CommitStatus < ApplicationRecord
ExpireJobCacheWorker.perform_async(id) ExpireJobCacheWorker.perform_async(id)
end end
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 end
def self.names def self.names
......
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
require "discordrb/webhooks" require "discordrb/webhooks"
class DiscordService < ChatNotificationService class DiscordService < ChatNotificationService
include ActionView::Helpers::UrlHelper
ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze
def title def title
...@@ -10,7 +12,7 @@ class DiscordService < ChatNotificationService ...@@ -10,7 +12,7 @@ class DiscordService < ChatNotificationService
end end
def description def description
s_("DiscordService|Receive event notifications in Discord") s_("DiscordService|Send notifications about project events to a Discord channel.")
end end
def self.to_param def self.to_param
...@@ -18,13 +20,8 @@ class DiscordService < ChatNotificationService ...@@ -18,13 +20,8 @@ class DiscordService < ChatNotificationService
end end
def help def help
"This service sends notifications about project events to Discord channels.<br /> 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'
To set up this service: s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
<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>"
end end
def event_field(event) def event_field(event)
...@@ -36,13 +33,12 @@ class DiscordService < ChatNotificationService ...@@ -36,13 +33,12 @@ class DiscordService < ChatNotificationService
end end
def self.supported_events def self.supported_events
%w[push issue confidential_issue merge_request note confidential_note tag_push %w[push issue confidential_issue merge_request note confidential_note tag_push pipeline wiki_page]
pipeline wiki_page]
end end
def default_fields 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: "checkbox", name: "notify_only_broken_pipelines" },
{ type: 'select', name: 'branches_to_be_notified', choices: branch_choices } { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
] ]
......
...@@ -17,7 +17,7 @@ class UserCallout < ApplicationRecord ...@@ -17,7 +17,7 @@ class UserCallout < ApplicationRecord
threat_monitoring_info: 11, # EE-only threat_monitoring_info: 11, # EE-only
account_recovery_regular_check: 12, # EE-only account_recovery_regular_check: 12, # EE-only
webhooks_moved: 13, webhooks_moved: 13,
service_templates_deprecated: 14, service_templates_deprecated_callout: 14,
admin_integrations_moved: 15, admin_integrations_moved: 15,
web_ide_alert_dismissed: 16, # no longer in use web_ide_alert_dismissed: 16, # no longer in use
active_user_count_threshold: 18, # EE-only active_user_count_threshold: 18, # EE-only
......
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
= render "layouts/broadcast" = render "layouts/broadcast"
= render "layouts/header/read_only_banner" = render "layouts/header/read_only_banner"
= render "layouts/header/registration_enabled_callout" = render "layouts/header/registration_enabled_callout"
= render "layouts/header/service_templates_deprecation_callout"
= render "layouts/nav/classification_level_banner" = render "layouts/nav/classification_level_banner"
= yield :flash_message = yield :flash_message
= render "shared/ping_consent" = 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 ...@@ -37,7 +37,7 @@ class BuildFinishedWorker # rubocop:disable Scalability/IdempotentWorker
ExpirePipelineCacheWorker.perform_async(build.pipeline_id) ExpirePipelineCacheWorker.perform_async(build.pipeline_id)
ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat? 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) ::Ci::MergeRequests::AddTodoWhenBuildFailsWorker.perform_async(build.id)
end 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 @@ ...@@ -220,6 +220,8 @@
- 1 - 1
- - merge_requests_resolve_todos - - merge_requests_resolve_todos
- 1 - 1
- - merge_requests_sync_code_owner_approval_rules
- 1
- - metrics_dashboard_prune_old_annotations - - metrics_dashboard_prune_old_annotations
- 1 - 1
- - metrics_dashboard_sync_dashboards - - metrics_dashboard_sync_dashboards
......
...@@ -250,10 +250,11 @@ configuration option in `gitlab.yml`. These metrics are served from the ...@@ -250,10 +250,11 @@ configuration option in `gitlab.yml`. These metrics are served from the
The following metrics are available: The following metrics are available:
| Metric | Type | Since | Description | | 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 | | `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)** ## Database partitioning metrics **(PREMIUM SELF)**
The following metrics are available: The following metrics are available:
......
...@@ -63,6 +63,12 @@ Returns [`ContainerRepositoryDetails`](#containerrepositorydetails). ...@@ -63,6 +63,12 @@ Returns [`ContainerRepositoryDetails`](#containerrepositorydetails).
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| `id` | [`ContainerRepositoryID!`](#containerrepositoryid) | The global ID of the container repository. | | `id` | [`ContainerRepositoryID!`](#containerrepositoryid) | The global ID of the container repository. |
### `currentLicense`
Fields related to the current license.
Returns [`CurrentLicense`](#currentlicense).
### `currentUser` ### `currentUser`
Get information about current user. Get information about current user.
...@@ -181,6 +187,21 @@ Returns [`Iteration`](#iteration). ...@@ -181,6 +187,21 @@ Returns [`Iteration`](#iteration).
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| `id` | [`IterationID!`](#iterationid) | Find an iteration by its ID. | | `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`
Metadata about GitLab. Metadata about GitLab.
...@@ -1872,6 +1893,27 @@ Autogenerated return type of CreateTestCase. ...@@ -1872,6 +1893,27 @@ Autogenerated return type of CreateTestCase.
| `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| `testCase` | [`Issue`](#issue) | The test case created. | | `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` ### `CustomEmoji`
A custom emoji uploaded by user. A custom emoji uploaded by user.
...@@ -3874,6 +3916,42 @@ An edge in a connection. ...@@ -3874,6 +3916,42 @@ An edge in a connection.
| `cursor` | [`String!`](#string) | A cursor for use in pagination. | | `cursor` | [`String!`](#string) | A cursor for use in pagination. |
| `node` | [`Label`](#label) | The item at the end of the edge. | | `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` ### `MarkAsSpamSnippetPayload`
Autogenerated return type of MarkAsSpamSnippet. Autogenerated return type of MarkAsSpamSnippet.
...@@ -8416,7 +8494,7 @@ Name of the feature that the callout is for. ...@@ -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. | | `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. | | `PERSONAL_ACCESS_TOKEN_EXPIRY` | Callout feature name for personal_access_token_expiry. |
| `REGISTRATION_ENABLED_CALLOUT` | Callout feature name for registration_enabled_callout. | | `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_PIPELINE` | Callout feature name for suggest_pipeline. |
| `SUGGEST_POPOVER_DISMISSED` | Callout feature name for suggest_popover_dismissed. | | `SUGGEST_POPOVER_DISMISSED` | Callout feature name for suggest_popover_dismissed. |
| `TABS_POSITION_HIGHLIGHT` | Callout feature name for tabs_position_highlight. | | `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 ...@@ -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. 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 ## 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'; ...@@ -3,14 +3,24 @@ import { GlSprintf } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex'; import { mapActions, mapState } from 'vuex';
import { n__, s__, __ } from '~/locale'; import { n__, s__, __ } from '~/locale';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue'; import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import { RULE_TYPE_EXTERNAL_APPROVAL } from '../constants';
const i18n = { const i18n = {
cancelButtonText: __('Cancel'), cancelButtonText: __('Cancel'),
primaryButtonText: __('Remove approvers'), regularRule: {
modalTitle: __('Remove approvers?'), primaryButtonText: __('Remove approvers'),
removeWarningText: s__( modalTitle: __('Remove approvers?'),
'ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}.', 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 { export default {
...@@ -28,6 +38,9 @@ export default { ...@@ -28,6 +38,9 @@ export default {
...mapState('deleteModal', { ...mapState('deleteModal', {
rule: 'data', rule: 'data',
}), }),
isExternalApprovalRule() {
return this.rule?.ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
membersText() { membersText() {
return n__( return n__(
'ApprovalRuleRemove|%d member', 'ApprovalRuleRemove|%d member',
...@@ -42,24 +55,38 @@ export default { ...@@ -42,24 +55,38 @@ export default {
this.rule.approvers.length, this.rule.approvers.length,
); );
}, },
modalTitle() {
return this.isExternalApprovalRule
? i18n.externalRule.modalTitle
: i18n.regularRule.modalTitle;
},
modalText() { 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: { methods: {
...mapActions(['deleteRule']), ...mapActions(['deleteRule', 'deleteExternalApprovalRule']),
submit() { submit() {
this.deleteRule(this.rule.id); if (this.rule.externalUrl) {
this.deleteExternalApprovalRule(this.rule.id);
} else {
this.deleteRule(this.rule.id);
}
}, },
}, },
buttonActions: { cancelButtonProps: {
primary: { text: i18n.cancelButtonText,
text: i18n.primaryButtonText,
attributes: [{ variant: 'danger' }],
},
cancel: {
text: i18n.cancelButtonText,
},
}, },
i18n, i18n,
}; };
...@@ -69,9 +96,9 @@ export default { ...@@ -69,9 +96,9 @@ export default {
<gl-modal-vuex <gl-modal-vuex
modal-module="deleteModal" modal-module="deleteModal"
:modal-id="modalId" :modal-id="modalId"
:title="$options.i18n.modalTitle" :title="modalTitle"
:action-primary="$options.buttonActions.primary" :action-primary="primaryButtonProps"
:action-cancel="$options.buttonActions.cancel" :action-cancel="$options.cancelButtonProps"
@ok.prevent="submit" @ok.prevent="submit"
> >
<p v-if="rule"> <p v-if="rule">
...@@ -82,9 +109,6 @@ export default { ...@@ -82,9 +109,6 @@ export default {
<template #nMembers> <template #nMembers>
<strong>{{ membersText }}</strong> <strong>{{ membersText }}</strong>
</template> </template>
<template #revokeWarning>
{{ revokeWarningText }}
</template>
</gl-sprintf> </gl-sprintf>
</p> </p>
</gl-modal-vuex> </gl-modal-vuex>
......
...@@ -4,8 +4,13 @@ import RuleName from 'ee/approvals/components/rule_name.vue'; ...@@ -4,8 +4,13 @@ import RuleName from 'ee/approvals/components/rule_name.vue';
import { n__, sprintf } from '~/locale'; import { n__, sprintf } from '~/locale';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; 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 EmptyRule from '../empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue'; import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue'; import RuleBranches from '../rule_branches.vue';
...@@ -15,6 +20,7 @@ import UnconfiguredSecurityRules from '../security_configuration/unconfigured_se ...@@ -15,6 +20,7 @@ import UnconfiguredSecurityRules from '../security_configuration/unconfigured_se
export default { export default {
components: { components: {
ApprovalGateIcon,
RuleControls, RuleControls,
Rules, Rules,
UserAvatarList, UserAvatarList,
...@@ -95,6 +101,9 @@ export default { ...@@ -95,6 +101,9 @@ export default {
return canEdit && (!allowMultiRule || !rule.hasSource); return canEdit && (!allowMultiRule || !rule.hasSource);
}, },
isExternalApprovalRule({ ruleType }) {
return ruleType === RULE_TYPE_EXTERNAL_APPROVAL;
},
}, },
}; };
</script> </script>
...@@ -132,13 +141,14 @@ export default { ...@@ -132,13 +141,14 @@ export default {
class="js-members" class="js-members"
:class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null" :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>
<td v-if="settings.allowMultiRule" class="js-branches"> <td v-if="settings.allowMultiRule" class="js-branches">
<rule-branches :rule="rule" /> <rule-branches :rule="rule" />
</td> </td>
<td class="js-approvals-required"> <td class="js-approvals-required">
<rule-input :rule="rule" /> <rule-input v-if="!isExternalApprovalRule(rule)" :rule="rule" />
</td> </td>
<td class="text-nowrap px-2 w-0 js-controls"> <td class="text-nowrap px-2 w-0 js-controls">
<rule-controls v-if="canEdit(rule)" :rule="rule" /> <rule-controls v-if="canEdit(rule)" :rule="rule" />
......
<script> <script>
import { groupBy, isNumber } from 'lodash'; import { groupBy, isNumber } from 'lodash';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { sprintf, __ } from '~/locale'; import { isSafeURL } from '~/lib/utils/url_utility';
import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants'; 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 ApproversList from './approvers_list.vue';
import ApproversSelect from './approvers_select.vue'; import ApproversSelect from './approvers_select.vue';
import BranchesSelect from './branches_select.vue'; import BranchesSelect from './branches_select.vue';
...@@ -21,7 +30,9 @@ export default { ...@@ -21,7 +30,9 @@ export default {
ApproversList, ApproversList,
ApproversSelect, ApproversSelect,
BranchesSelect, BranchesSelect,
ApproverTypeSelect,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
initRule: { initRule: {
type: Object, type: Object,
...@@ -44,6 +55,7 @@ export default { ...@@ -44,6 +55,7 @@ export default {
name: this.defaultRuleName, name: this.defaultRuleName,
approvalsRequired: 1, approvalsRequired: 1,
minApprovalsRequired: 0, minApprovalsRequired: 0,
externalUrl: null,
approvers: [], approvers: [],
approversToAdd: [], approversToAdd: [],
branches: [], branches: [],
...@@ -52,6 +64,7 @@ export default { ...@@ -52,6 +64,7 @@ export default {
isFallback: false, isFallback: false,
containsHiddenGroups: false, containsHiddenGroups: false,
serverValidationErrors: [], serverValidationErrors: [],
ruleType: null,
...this.getInitialData(), ...this.getInitialData(),
}; };
...@@ -59,6 +72,17 @@ export default { ...@@ -59,6 +72,17 @@ export default {
}, },
computed: { computed: {
...mapState(['settings']), ...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() { rule() {
// If we are creating a new rule with a suggested approval name // If we are creating a new rule with a suggested approval name
return this.defaultRuleName ? null : this.initRule; return this.defaultRuleName ? null : this.initRule;
...@@ -85,16 +109,32 @@ export default { ...@@ -85,16 +109,32 @@ export default {
const invalidObject = { const invalidObject = {
name: this.invalidName, name: this.invalidName,
approvalsRequired: this.invalidApprovalsRequired,
approvers: this.invalidApprovers,
}; };
if (!this.isMrEdit) { if (!this.isMrEdit) {
invalidObject.branches = this.invalidBranches; invalidObject.branches = this.invalidBranches;
} }
if (this.isExternalApprovalRule) {
invalidObject.externalUrl = this.invalidApprovalGateUrl;
} else {
invalidObject.approvers = this.invalidApprovers;
invalidObject.approvalsRequired = this.invalidApprovalsRequired;
}
return invalidObject; 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() { invalidName() {
let error = ''; let error = '';
...@@ -175,9 +215,24 @@ export default { ...@@ -175,9 +215,24 @@ export default {
protectedBranchIds: this.branches, protectedBranchIds: this.branches,
}; };
}, },
isEditing() {
return Boolean(this.initRule);
},
externalRuleSubmissionData() {
const { id, name, protectedBranchIds } = this.submissionData;
return {
id,
name,
protectedBranchIds,
externalUrl: this.externalUrl,
};
},
showProtectedBranch() { showProtectedBranch() {
return !this.isMrEdit && this.settings.allowMultiRule; return !this.isMrEdit && this.settings.allowMultiRule;
}, },
approvalGateLabel() {
return this.isEditing ? this.$options.i18n.approvalGate : this.$options.i18n.addApprovalGate;
},
}, },
watch: { watch: {
approversToAdd(value) { approversToAdd(value) {
...@@ -188,7 +243,15 @@ export default { ...@@ -188,7 +243,15 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['putFallbackRule', 'postRule', 'putRule', 'deleteRule', 'postRegularRule']), ...mapActions([
'putFallbackRule',
'putExternalApprovalRule',
'postExternalApprovalRule',
'postRule',
'putRule',
'deleteRule',
'postRegularRule',
]),
addSelection() { addSelection() {
if (!this.approversToAdd.length) { if (!this.approversToAdd.length) {
return; return;
...@@ -219,9 +282,13 @@ export default { ...@@ -219,9 +282,13 @@ export default {
} }
submission.catch((failureResponse) => { submission.catch((failureResponse) => {
this.serverValidationErrors = mapServerResponseToValidationErrors( if (this.isExternalApprovalRule) {
failureResponse?.response?.data?.message || {}, this.serverValidationErrors = failureResponse?.response?.data?.message || [];
); } else {
this.serverValidationErrors = mapServerResponseToValidationErrors(
failureResponse?.response?.data?.message || {},
);
}
}); });
return submission; return submission;
...@@ -230,12 +297,14 @@ export default { ...@@ -230,12 +297,14 @@ export default {
* Submit the rule, by either put-ing or post-ing. * Submit the rule, by either put-ing or post-ing.
*/ */
submitRule() { submitRule() {
if (this.isExternalApprovalRule) {
const data = this.externalRuleSubmissionData;
return data.id ? this.putExternalApprovalRule(data) : this.postExternalApprovalRule(data);
}
const data = this.submissionData; const data = this.submissionData;
if (!this.settings.allowMultiRule && this.settings.prefix === 'mr-edit') { 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.postRegularRule(data);
} }
return data.id ? this.putRule(data) : this.postRule(data); return data.id ? this.putRule(data) : this.postRule(data);
}, },
/** /**
...@@ -248,7 +317,7 @@ export default { ...@@ -248,7 +317,7 @@ export default {
* Submit as a single rule. This is determined by the settings. * Submit as a single rule. This is determined by the settings.
*/ */
submitSingleRule() { submitSingleRule() {
if (!this.approvers.length) { if (!this.approvers.length && !this.isExternalApprovalRule) {
return this.submitEmptySingleRule(); return this.submitEmptySingleRule();
} }
...@@ -280,6 +349,16 @@ export default { ...@@ -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 { containsHiddenGroups = false, removeHiddenGroups = false } = this.initRule;
const users = this.initRule.users.map((x) => ({ ...x, type: TYPE_USER })); const users = this.initRule.users.map((x) => ({ ...x, type: TYPE_USER }));
...@@ -290,6 +369,7 @@ export default { ...@@ -290,6 +369,7 @@ export default {
name: this.initRule.name || '', name: this.initRule.name || '',
approvalsRequired: this.initRule.approvalsRequired || 0, approvalsRequired: this.initRule.approvalsRequired || 0,
minApprovalsRequired: this.initRule.minApprovalsRequired || 0, minApprovalsRequired: this.initRule.minApprovalsRequired || 0,
ruleType: this.initRule.ruleType,
containsHiddenGroups, containsHiddenGroups,
approvers: groups approvers: groups
.concat(users) .concat(users)
...@@ -300,6 +380,14 @@ export default { ...@@ -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> </script>
...@@ -334,7 +422,14 @@ export default { ...@@ -334,7 +422,14 @@ export default {
{{ __('Apply this approval rule to any branch or a specific protected branch.') }} {{ __('Apply this approval rule to any branch or a specific protected branch.') }}
</small> </small>
</div> </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> <label class="col-form-label">{{ s__('ApprovalRule|Approvals required') }}</label>
<input <input
v-model.number="approvalsRequired" v-model.number="approvalsRequired"
...@@ -347,7 +442,7 @@ export default { ...@@ -347,7 +442,7 @@ export default {
/> />
<span class="invalid-feedback">{{ validation.approvalsRequired }}</span> <span class="invalid-feedback">{{ validation.approvalsRequired }}</span>
</div> </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> <label class="col-form-label">{{ s__('ApprovalRule|Add approvers') }}</label>
<approvers-select <approvers-select
v-model="approversToAdd" v-model="approversToAdd"
...@@ -359,7 +454,22 @@ export default { ...@@ -359,7 +454,22 @@ export default {
/> />
<span class="invalid-feedback">{{ validation.approvers }}</span> <span class="invalid-feedback">{{ validation.approvers }}</span>
</div> </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" /> <approvers-list v-model="approvers" />
</div> </div>
</form> </form>
......
...@@ -17,6 +17,7 @@ export const RULE_TYPE_CODE_OWNER = 'code_owner'; ...@@ -17,6 +17,7 @@ export const RULE_TYPE_CODE_OWNER = 'code_owner';
export const RULE_TYPE_ANY_APPROVER = 'any_approver'; export const RULE_TYPE_ANY_APPROVER = 'any_approver';
export const RULE_TYPE_EXTERNAL_APPROVAL = 'external_approval'; export const RULE_TYPE_EXTERNAL_APPROVAL = 'external_approval';
export const RULE_NAME_ANY_APPROVER = 'All Members'; 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 VULNERABILITY_CHECK_NAME = 'Vulnerability-Check';
export const LICENSE_CHECK_NAME = 'License-Check'; export const LICENSE_CHECK_NAME = 'License-Check';
......
...@@ -70,7 +70,7 @@ export const mapExternalApprovalRuleResponse = (res) => ({ ...@@ -70,7 +70,7 @@ export const mapExternalApprovalRuleResponse = (res) => ({
}); });
export const mapExternalApprovalResponse = (res) => ({ export const mapExternalApprovalResponse = (res) => ({
rules: withDefaultEmptyRule(res.map(mapExternalApprovalRuleResponse)), rules: res.map(mapExternalApprovalRuleResponse),
}); });
export const mapApprovalSettingsResponse = (res) => ({ export const mapApprovalSettingsResponse = (res) => ({
......
...@@ -200,7 +200,7 @@ export default { ...@@ -200,7 +200,7 @@ export default {
@filter="handleFilterIssues" @filter="handleFilterIssues"
> >
<template #nav-actions> <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" >{{ s__('Integrations|Create new issue in Jira') }}<gl-icon name="external-link"
/></gl-button> /></gl-button>
</template> </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 @@ ...@@ -2,7 +2,6 @@
import { GlCollapse, GlIcon } from '@gitlab/ui'; import { GlCollapse, GlIcon } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ReportItem from './report_item.vue'; import ReportItem from './report_item.vue';
import ReportRow from './report_row.vue';
import { filterTypesAndLimitListDepth } from './types/utils'; import { filterTypesAndLimitListDepth } from './types/utils';
const NESTED_LISTS_MAX_DEPTH = 4; const NESTED_LISTS_MAX_DEPTH = 4;
...@@ -15,7 +14,6 @@ export default { ...@@ -15,7 +14,6 @@ export default {
GlCollapse, GlCollapse,
GlIcon, GlIcon,
ReportItem, ReportItem,
ReportRow,
}, },
props: { props: {
details: { details: {
...@@ -57,11 +55,14 @@ export default { ...@@ -57,11 +55,14 @@ export default {
</h3> </h3>
</header> </header>
<gl-collapse :visible="showSection"> <gl-collapse :visible="showSection">
<div data-testid="reports"> <div class="generic-report-container" data-testid="reports">
<template v-for="[label, item] in detailsEntries"> <template v-for="[label, item] in detailsEntries">
<report-row :key="label" :label="item.name" :data-testid="`report-row-${label}`"> <div :key="label" class="generic-report-row" :data-testid="`report-row-${label}`">
<report-item :item="item" /> <strong class="generic-report-column">{{ item.name }}</strong>
</report-row> <div class="generic-report-column" data-testid="reportContent">
<report-item :item="item" :data-testid="`report-item-${label}`" />
</div>
</div>
</template> </template>
</div> </div>
</gl-collapse> </gl-collapse>
......
...@@ -120,15 +120,32 @@ $selection-summary-with-error-height: 118px; ...@@ -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 { .generic-report-row {
grid-template-columns: minmax(150px, 1fr) 3fr; display: contents;
grid-column-gap: $gl-spacing-scale-5;
&:last-child { &:last-child .generic-report-column {
@include gl-border-b-0; @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 { .generic-report-list {
li { li {
@include gl-ml-0; @include gl-ml-0;
...@@ -140,4 +157,3 @@ $selection-summary-with-error-height: 118px; ...@@ -140,4 +157,3 @@ $selection-summary-with-error-height: 118px;
list-style-type: disc; list-style-type: disc;
} }
} }
...@@ -10,6 +10,10 @@ module EE ...@@ -10,6 +10,10 @@ module EE
before_action :log_archive_audit_event, only: [:archive] before_action :log_archive_audit_event, only: [:archive]
before_action :log_unarchive_audit_event, only: [:unarchive] 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 before_action only: :show do
push_frontend_feature_flag(:cve_id_request_button, project) push_frontend_feature_flag(:cve_id_request_button, project)
end end
......
...@@ -61,6 +61,16 @@ module EE ...@@ -61,6 +61,16 @@ module EE
null: true, null: true,
description: 'Get configured DevOps adoption segments on the instance.', description: 'Get configured DevOps adoption segments on the instance.',
resolver: ::Resolvers::Admin::Analytics::DevopsAdoption::SegmentsResolver 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 end
def vulnerability(id:) 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 ...@@ -8,6 +8,7 @@ class License < ApplicationRecord
PREMIUM_PLAN = 'premium' PREMIUM_PLAN = 'premium'
ULTIMATE_PLAN = 'ultimate' ULTIMATE_PLAN = 'ultimate'
CLOUD_LICENSE_TYPE = 'cloud' CLOUD_LICENSE_TYPE = 'cloud'
LEGACY_LICENSE_TYPE = 'legacy'
ALLOWED_PERCENTAGE_OF_USERS_OVERAGE = (10 / 100.0) ALLOWED_PERCENTAGE_OF_USERS_OVERAGE = (10 / 100.0)
EE_ALL_PLANS = [STARTER_PLAN, PREMIUM_PLAN, ULTIMATE_PLAN].freeze EE_ALL_PLANS = [STARTER_PLAN, PREMIUM_PLAN, ULTIMATE_PLAN].freeze
...@@ -237,6 +238,8 @@ class License < ApplicationRecord ...@@ -237,6 +238,8 @@ class License < ApplicationRecord
{ range: (1000..nil), percentage: true, value: 5 } { range: (1000..nil), percentage: true, value: 5 }
].freeze ].freeze
LICENSEE_ATTRIBUTES = %w[Name Email Company].freeze
validate :valid_license validate :valid_license
validate :check_users_limit, if: :new_record?, unless: :validate_with_trueup? validate :check_users_limit, if: :new_record?, unless: :validate_with_trueup?
validate :check_trueup, unless: :persisted?, if: :validate_with_trueup? validate :check_trueup, unless: :persisted?, if: :validate_with_trueup?
...@@ -550,6 +553,10 @@ class License < ApplicationRecord ...@@ -550,6 +553,10 @@ class License < ApplicationRecord
license&.type == CLOUD_LICENSE_TYPE license&.type == CLOUD_LICENSE_TYPE
end end
def license_type
cloud? ? CLOUD_LICENSE_TYPE : LEGACY_LICENSE_TYPE
end
def auto_renew def auto_renew
false false
end end
...@@ -576,6 +583,12 @@ class License < ApplicationRecord ...@@ -576,6 +583,12 @@ class License < ApplicationRecord
restricted_user_count - daily_billable_users_count restricted_user_count - daily_billable_users_count
end end
LICENSEE_ATTRIBUTES.each do |attribute|
define_method "licensee_#{attribute.downcase}" do
licensee[attribute]
end
end
private private
def restricted_attr(name, default = nil) def restricted_attr(name, default = nil)
......
...@@ -34,8 +34,8 @@ module Integrations ...@@ -34,8 +34,8 @@ module Integrations
{ {
title: name, title: name,
name: name, name: name,
color: '#EBECF0', color: '#0052CC',
text_color: '#283856' text_color: '#FFFFFF'
} }
end end
end end
......
...@@ -39,7 +39,9 @@ module EE ...@@ -39,7 +39,9 @@ module EE
def after_update(merge_request) def after_update(merge_request)
super super
::MergeRequests::SyncCodeOwnerApprovalRules.new(merge_request).execute merge_request.run_after_commit do
::MergeRequests::SyncCodeOwnerApprovalRulesWorker.perform_async(merge_request)
end
end end
override :create_branch_change_note override :create_branch_change_note
......
...@@ -867,6 +867,14 @@ ...@@ -867,6 +867,14 @@
:weight: 1 :weight: 1
:idempotent: :idempotent:
:tags: [] :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 - :name: new_epic
:feature_category: :epics :feature_category: :epics
:has_external_dependencies: :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 ...@@ -9,11 +9,13 @@ module EE
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
DB_LOAD_BALANCING_COUNTERS = %i{ DB_LOAD_BALANCING_COUNTERS = %i{
db_replica_count db_replica_cached_count db_replica_count db_replica_cached_count db_replica_wal_count
db_primary_count db_primary_cached_count db_primary_count db_primary_cached_count db_primary_wal_count
}.freeze }.freeze
DB_LOAD_BALANCING_DURATIONS = %i{db_primary_duration_s db_replica_duration_s}.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 class_methods do
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
...@@ -55,9 +57,14 @@ module EE ...@@ -55,9 +57,14 @@ module EE
private private
def wal_command?(payload)
payload[:sql].match(SQL_WAL_LOCATION_REGEX)
end
def increment_db_role_counters(db_role, payload) def increment_db_role_counters(db_role, payload)
increment("db_#{db_role}_count".to_sym) increment("db_#{db_role}_count".to_sym)
increment("db_#{db_role}_cached_count".to_sym) if cached_query?(payload) 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 end
def observe_db_role_duration(db_role, event) 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 ...@@ -19,6 +19,9 @@ module Gitlab
return unless worker_class return unless worker_class
return unless worker_class.include?(::ApplicationWorker) return unless worker_class.include?(::ApplicationWorker)
return unless worker_class.get_data_consistency_feature_flag_enabled? 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 return if worker_class.get_data_consistency == :always
if Session.current.performed_write? if Session.current.performed_write?
......
...@@ -25,18 +25,18 @@ module Gitlab ...@@ -25,18 +25,18 @@ module Gitlab
def requires_primary?(worker_class, job) def requires_primary?(worker_class, job)
return true unless worker_class.include?(::ApplicationWorker) 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 if worker_class.get_data_consistency == :always
return true unless worker_class.get_data_consistency_feature_flag_enabled? 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 false
elsif worker_class.get_data_consistency == :delayed && job['retry_count'].to_i == 0 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."\ raise JobReplicaNotUpToDate, "Sidekiq job #{worker_class} JID-#{job['jid']} couldn't use the replica."\
" Replica was not up to date." " Replica was not up to date."
else else
job[:database_chosen] = 'primary'
true true
end end
end end
......
...@@ -78,6 +78,7 @@ RSpec.describe Registrations::GroupsController do ...@@ -78,6 +78,7 @@ RSpec.describe Registrations::GroupsController do
let_it_be(:trial_form_params) { { trial: 'false' } } let_it_be(:trial_form_params) { { trial: 'false' } }
let_it_be(:trial_onboarding_issues_enabled) { false } let_it_be(:trial_onboarding_issues_enabled) { false }
let_it_be(:trial_onboarding_flow_params) { {} } let_it_be(:trial_onboarding_flow_params) { {} }
let(:signup_onboarding_enabled) { true } let(:signup_onboarding_enabled) { true }
let(:group_params) { { name: 'Group name', path: 'group-path', visibility_level: Gitlab::VisibilityLevel::PRIVATE, emails: ['', ''] } } let(:group_params) { { name: 'Group name', path: 'group-path', visibility_level: Gitlab::VisibilityLevel::PRIVATE, emails: ['', ''] } }
let(:params) do let(:params) do
......
...@@ -54,6 +54,7 @@ RSpec.describe Registrations::ProjectsController do ...@@ -54,6 +54,7 @@ RSpec.describe Registrations::ProjectsController do
subject { post :create, params: { project: params }.merge(trial_onboarding_flow_params) } subject { post :create, params: { project: params }.merge(trial_onboarding_flow_params) }
let_it_be(: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(:params) { { namespace_id: namespace.id, name: 'New project', path: 'project-path', visibility_level: Gitlab::VisibilityLevel::PRIVATE } }
let(:signup_onboarding_enabled) { true } let(:signup_onboarding_enabled) { true }
......
...@@ -5,15 +5,16 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do ...@@ -5,15 +5,16 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
include GitlabRoutingHelper include GitlabRoutingHelper
include FeatureApprovalHelper include FeatureApprovalHelper
let(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:project) { create(:project) } let_it_be(:project) { create(:project) }
let(:group) { create(:group) } let_it_be(:group) { create(:group) }
let(:group_member) { create(:user) } let_it_be(:group_member) { create(:user) }
let(:non_member) { create(:user) } let_it_be(:non_member) { create(:user) }
let!(:config_selector) { '.js-approval-rules' } let_it_be(:config_selector) { '.js-approval-rules' }
let!(:modal_selector) { '#project-settings-approvals-create-modal' } let_it_be(:modal_selector) { '#project-settings-approvals-create-modal' }
before do before do
stub_licensed_features(compliance_approval_gates: true)
sign_in(user) sign_in(user)
project.add_maintainer(user) project.add_maintainer(user)
group.add_developer(user) group.add_developer(user)
...@@ -69,8 +70,8 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do ...@@ -69,8 +70,8 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end end
context 'with an approver group' do context 'with an approver group' do
let(:non_group_approver) { create(:user) } let_it_be(:non_group_approver) { create(:user) }
let!(:rule) { create(:approval_project_rule, project: project, groups: [group], users: [non_group_approver]) } let_it_be(:rule) { create(:approval_project_rule, project: project, groups: [group], users: [non_group_approver]) }
before do before do
project.add_developer(non_group_approver) project.add_developer(non_group_approver)
...@@ -90,6 +91,64 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do ...@@ -90,6 +91,64 @@ RSpec.describe 'Project settings > [EE] Merge Requests', :js do
end end
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 context 'issuable default templates feature not available' do
before do before do
stub_licensed_features(issuable_default_templates: false) stub_licensed_features(issuable_default_templates: false)
......
// Jest Snapshot v1, https://goo.gl/fbAQLP // 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 <div
title="Remove approvers?" title="Remove approvers?"
> >
...@@ -18,7 +32,7 @@ exports[`Approvals ModalRuleRemove shows message 1`] = ` ...@@ -18,7 +32,7 @@ exports[`Approvals ModalRuleRemove shows message 1`] = `
</div> </div>
`; `;
exports[`Approvals ModalRuleRemove shows singular message 1`] = ` exports[`Approvals ModalRuleRemove matches the snapshot for singular approver 1`] = `
<div <div
title="Remove approvers?" 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'; ...@@ -4,6 +4,7 @@ import Vuex from 'vuex';
import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue'; import ModalRuleRemove from 'ee/approvals/components/modal_rule_remove.vue';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue'; import GlModalVuex from '~/vue_shared/components/gl_modal_vuex.vue';
import { createExternalRule } from '../mocks';
const MODAL_MODULE = 'deleteModal'; const MODAL_MODULE = 'deleteModal';
const TEST_MODAL_ID = 'test-delete-modal-id'; const TEST_MODAL_ID = 'test-delete-modal-id';
...@@ -14,6 +15,11 @@ const TEST_RULE = { ...@@ -14,6 +15,11 @@ const TEST_RULE = {
.fill(1) .fill(1)
.map((x, id) => ({ id })), .map((x, id) => ({ id })),
}; };
const SINGLE_APPROVER = {
...TEST_RULE,
approvers: [{ id: 1 }],
};
const EXTERNAL_RULE = createExternalRule();
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -61,6 +67,7 @@ describe('Approvals ModalRuleRemove', () => { ...@@ -61,6 +67,7 @@ describe('Approvals ModalRuleRemove', () => {
}; };
actions = { actions = {
deleteRule: jest.fn(), deleteRule: jest.fn(),
deleteExternalApprovalRule: jest.fn(),
}; };
}); });
...@@ -83,30 +90,31 @@ describe('Approvals ModalRuleRemove', () => { ...@@ -83,30 +90,31 @@ describe('Approvals ModalRuleRemove', () => {
); );
}); });
it('shows message', () => { it.each`
factory(); type | rule
${'multiple approvers'} | ${TEST_RULE}
expect(findModal().element).toMatchSnapshot(); ${'singular approver'} | ${SINGLE_APPROVER}
}); ${'external approval'} | ${EXTERNAL_RULE}
`('matches the snapshot for $type', ({ rule }) => {
it('shows singular message', () => { deleteModalState.data = rule;
deleteModalState.data = {
...TEST_RULE,
approvers: [{ id: 1 }],
};
factory(); factory();
expect(findModal().element).toMatchSnapshot(); 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(); factory();
expect(actions.deleteRule).not.toHaveBeenCalled(); expect(actions[action]).not.toHaveBeenCalled();
const modal = findModal(); const modal = findModal();
modal.vm.$emit('ok', new Event('submit')); 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 { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex'; 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 RuleInput from 'ee/approvals/components/mr_edit/rule_input.vue';
import ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue'; import ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue';
import RuleName from 'ee/approvals/components/rule_name.vue'; import RuleName from 'ee/approvals/components/rule_name.vue';
...@@ -8,7 +9,7 @@ import UnconfiguredSecurityRules from 'ee/approvals/components/security_configur ...@@ -8,7 +9,7 @@ import UnconfiguredSecurityRules from 'ee/approvals/components/security_configur
import { createStoreOptions } from 'ee/approvals/stores'; import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings'; import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; 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(); const TEST_RULES = createProjectRules();
...@@ -149,4 +150,26 @@ describe('Approvals ProjectRules', () => { ...@@ -149,4 +150,26 @@ describe('Approvals ProjectRules', () => {
expect(wrapper.find(UnconfiguredSecurityRules).exists()).toBe(true); 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 { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import ApproverTypeSelect from 'ee/approvals/components/approver_type_select.vue';
import ApproversList from 'ee/approvals/components/approvers_list.vue'; import ApproversList from 'ee/approvals/components/approvers_list.vue';
import ApproversSelect from 'ee/approvals/components/approvers_select.vue'; import ApproversSelect from 'ee/approvals/components/approvers_select.vue';
import BranchesSelect from 'ee/approvals/components/branches_select.vue'; import BranchesSelect from 'ee/approvals/components/branches_select.vue';
import RuleForm from 'ee/approvals/components/rule_form.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 { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings'; 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_PROJECT_ID = '7';
const TEST_RULE = { const TEST_RULE = {
...@@ -27,6 +36,10 @@ const TEST_FALLBACK_RULE = { ...@@ -27,6 +36,10 @@ const TEST_FALLBACK_RULE = {
approvalsRequired: 1, approvalsRequired: 1,
isFallback: true, isFallback: true,
}; };
const TEST_EXTERNAL_APPROVAL_RULE = {
...createExternalRule(),
protectedBranches: TEST_PROTECTED_BRANCHES,
};
const TEST_LOCKED_RULE_NAME = 'LOCKED_RULE'; const TEST_LOCKED_RULE_NAME = 'LOCKED_RULE';
const nameTakenError = { const nameTakenError = {
response: { response: {
...@@ -37,6 +50,13 @@ const nameTakenError = { ...@@ -37,6 +50,13 @@ const nameTakenError = {
}, },
}, },
}; };
const urlTakenError = {
response: {
data: {
message: ['External url has already been taken'],
},
},
};
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
...@@ -54,7 +74,11 @@ describe('EE Approvals RuleForm', () => { ...@@ -54,7 +74,11 @@ describe('EE Approvals RuleForm', () => {
store: new Vuex.Store(store), store: new Vuex.Store(store),
localVue, localVue,
provide: { provide: {
glFeatures: { scopedApprovalRules: true, ...options.provide?.glFeatures }, glFeatures: {
ffComplianceApprovalGates: true,
scopedApprovalRules: true,
...options.provide?.glFeatures,
},
}, },
}); });
}; };
...@@ -71,6 +95,9 @@ describe('EE Approvals RuleForm', () => { ...@@ -71,6 +95,9 @@ describe('EE Approvals RuleForm', () => {
const findApproversValidation = () => findValidation(findApproversSelect(), true); const findApproversValidation = () => findValidation(findApproversSelect(), true);
const findApproversList = () => wrapper.find(ApproversList); const findApproversList = () => wrapper.find(ApproversList);
const findBranchesSelect = () => wrapper.find(BranchesSelect); 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 findBranchesValidation = () => findValidation(findBranchesSelect(), true);
const findValidations = () => [ const findValidations = () => [
findNameValidation(), findNameValidation(),
...@@ -85,12 +112,20 @@ describe('EE Approvals RuleForm', () => { ...@@ -85,12 +112,20 @@ describe('EE Approvals RuleForm', () => {
findBranchesValidation(), findBranchesValidation(),
]; ];
const findValidationForExternal = () => [
findNameValidation(),
findExternalUrlValidation(),
findBranchesValidation(),
];
beforeEach(() => { beforeEach(() => {
store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID }); store = createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID });
['postRule', 'putRule', 'deleteRule', 'putFallbackRule'].forEach((actionName) => { ['postRule', 'putRule', 'deleteRule', 'putFallbackRule', 'postExternalApprovalRule'].forEach(
jest.spyOn(store.modules.approvals.actions, actionName).mockImplementation(() => {}); (actionName) => {
}); jest.spyOn(store.modules.approvals.actions, actionName).mockImplementation(() => {});
},
);
({ actions } = store.modules.approvals); ({ actions } = store.modules.approvals);
}); });
...@@ -181,6 +216,119 @@ describe('EE Approvals RuleForm', () => { ...@@ -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', () => { describe('without initRule', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -536,16 +684,17 @@ describe('EE Approvals RuleForm', () => { ...@@ -536,16 +684,17 @@ describe('EE Approvals RuleForm', () => {
describe('with approval suggestions', () => { describe('with approval suggestions', () => {
describe.each` describe.each`
defaultRuleName | expectedDisabledAttribute defaultRuleName | expectedDisabledAttribute | approverTypeSelect
${'Vulnerability-Check'} | ${'disabled'} ${'Vulnerability-Check'} | ${'disabled'} | ${false}
${'License-Check'} | ${'disabled'} ${'License-Check'} | ${'disabled'} | ${false}
${'Foo Bar Baz'} | ${undefined} ${'Foo Bar Baz'} | ${undefined} | ${true}
`( `(
'with defaultRuleName set to $defaultRuleName', 'with defaultRuleName set to $defaultRuleName',
({ defaultRuleName, expectedDisabledAttribute }) => { ({ defaultRuleName, expectedDisabledAttribute, approverTypeSelect }) => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
initRule: null, initRule: null,
isMrEdit: false,
defaultRuleName, defaultRuleName,
}); });
}); });
...@@ -555,6 +704,12 @@ describe('EE Approvals RuleForm', () => { ...@@ -555,6 +704,12 @@ describe('EE Approvals RuleForm', () => {
} the name text field`, () => { } the name text field`, () => {
expect(findNameInput().attributes('disabled')).toBe(expectedDisabledAttribute); 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', () => { ...@@ -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 = () => [ export const createProjectRules = () => [
{ {
id: 1, id: 1,
......
...@@ -18,8 +18,8 @@ export const mockJiraIssue1 = { ...@@ -18,8 +18,8 @@ export const mockJiraIssue1 = {
labels: [ labels: [
{ {
name: 'backend', name: 'backend',
color: '#EBECF0', color: '#0052CC',
text_color: '#283856', text_color: '#FFFFFF',
}, },
], ],
author: { author: {
......
...@@ -20,8 +20,8 @@ export const mockJiraIssue = { ...@@ -20,8 +20,8 @@ export const mockJiraIssue = {
{ {
title: 'In Progress', title: 'In Progress',
description: 'Work that is still in progress', description: 'Work that is still in progress',
color: '#EBECF0', color: '#0052CC',
text_color: '#283856', text_color: '#FFFFFF',
}, },
], ],
references: { 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 { within, fireEvent } from '@testing-library/dom';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { nextTick } from 'vue'; 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 ReportSection from 'ee/vulnerabilities/components/generic_report/report_section.vue';
import { REPORT_TYPE_URL } from 'ee/vulnerabilities/components/generic_report/types/constants'; import { REPORT_TYPE_URL } from 'ee/vulnerabilities/components/generic_report/types/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
...@@ -47,9 +45,9 @@ describe('ee/vulnerabilities/components/generic_report/report_section.vue', () = ...@@ -47,9 +45,9 @@ describe('ee/vulnerabilities/components/generic_report/report_section.vue', () =
name: /evidence/i, name: /evidence/i,
}); });
const findReportsSection = () => wrapper.findByTestId('reports'); 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 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); const supportedReportTypesLabels = Object.keys(TEST_DATA.supportedTypes);
describe('with supported report types', () => { describe('with supported report types', () => {
...@@ -77,20 +75,21 @@ describe('ee/vulnerabilities/components/generic_report/report_section.vue', () = ...@@ -77,20 +75,21 @@ describe('ee/vulnerabilities/components/generic_report/report_section.vue', () =
expect(findAllReportRows()).toHaveLength(supportedReportTypesLabels.length); expect(findAllReportRows()).toHaveLength(supportedReportTypesLabels.length);
}); });
it.each(supportedReportTypesLabels)('passes the correct props to report row: %s', (label) => { it.each(supportedReportTypesLabels)(
expect(findReportRowByLabel(label).props()).toMatchObject({ 'renders the correct label for report row: %s',
label: TEST_DATA.supportedTypes[label].name, (label) => {
}); expect(within(findReportRowByLabel(label).element).getByText(label)).toBeInstanceOf(
}); HTMLElement,
);
},
);
}); });
describe('report items', () => { describe('report items', () => {
it.each(supportedReportTypesLabels)( it.each(supportedReportTypesLabels)(
'passes the correct props to item for row: %s', 'passes the correct props to item for row: %s',
(label) => { (label) => {
const row = findReportRowByLabel(label); expect(findReportItemByLabel(label).props()).toMatchObject({
expect(findItemWithinRow(row).props()).toMatchObject({
item: TEST_DATA.supportedTypes[label], 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 ...@@ -10,7 +10,9 @@ RSpec.describe GitlabSchema.types['Query'] do
:vulnerabilities, :vulnerabilities,
:vulnerability, :vulnerability,
:instance_security_dashboard, :instance_security_dashboard,
:vulnerabilities_count_by_day_and_severity :vulnerabilities_count_by_day_and_severity,
:current_license,
:license_history_entries
).at_least ).at_least
end end
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 ...@@ -21,7 +21,9 @@ RSpec.describe Gitlab::InstrumentationHelper do
expect(payload).to include(db_replica_count: 0, expect(payload).to include(db_replica_count: 0,
db_replica_cached_count: 0, db_replica_cached_count: 0,
db_primary_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
end end
...@@ -30,13 +32,15 @@ RSpec.describe Gitlab::InstrumentationHelper do ...@@ -30,13 +32,15 @@ RSpec.describe Gitlab::InstrumentationHelper do
allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false) allow(Gitlab::Database::LoadBalancing).to receive(:enable?).and_return(false)
end end
it 'includes DB counts' do it 'does not include DB counts' do
subject subject
expect(payload).not_to include(db_replica_count: 0, expect(payload).not_to include(db_replica_count: 0,
db_replica_cached_count: 0, db_replica_cached_count: 0,
db_primary_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
end end
......
...@@ -29,18 +29,20 @@ RSpec.describe ::Gitlab::Metrics::Subscribers::ActiveRecord do ...@@ -29,18 +29,20 @@ RSpec.describe ::Gitlab::Metrics::Subscribers::ActiveRecord do
end end
shared_examples 'track sql events for each role' do shared_examples 'track sql events for each role' do
where(:name, :sql_query, :record_query, :record_write_query, :record_cached_query) do 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 '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 '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 '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 '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 '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 '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 'SQL' | 'UPDATE users SET admin = true WHERE id = 10' | true | true | false | false
'CACHE' | 'SELECT * FROM users WHERE id = 10' | true | false | true 'SQL' | 'SELECT pg_current_wal_insert_lsn()::text AS location' | true | false | 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 'SQL' | 'SELECT pg_last_wal_replay_lsn()::text AS location' | true | false | false | true
nil | 'BEGIN' | false | false | false 'CACHE' | 'SELECT * FROM users WHERE id = 10' | true | false | true | false
nil | 'COMMIT' | false | false | 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 end
with_them do with_them do
......
...@@ -76,7 +76,9 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do ...@@ -76,7 +76,9 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
'db_replica_count' => 0, 'db_replica_count' => 0,
'db_replica_cached_count' => 0, 'db_replica_cached_count' => 0,
'db_primary_count' => a_value >= 1, '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 end
...@@ -94,7 +96,9 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do ...@@ -94,7 +96,9 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
'db_replica_count' => 0, 'db_replica_count' => 0,
'db_replica_cached_count' => 0, 'db_replica_cached_count' => 0,
'db_primary_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
......
...@@ -9,7 +9,9 @@ RSpec.describe Gitlab::SidekiqMiddleware::InstrumentationLogger do ...@@ -9,7 +9,9 @@ RSpec.describe Gitlab::SidekiqMiddleware::InstrumentationLogger do
:db_replica_count, :db_replica_count,
:db_replica_cached_count, :db_replica_cached_count,
:db_primary_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) expect(described_class.keys).to include(*expected_keys)
......
...@@ -1411,6 +1411,20 @@ RSpec.describe License do ...@@ -1411,6 +1411,20 @@ RSpec.describe License do
end end
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 describe '#auto_renew' do
it 'is false' do it 'is false' do
expect(license.auto_renew).to be false expect(license.auto_renew).to be false
...@@ -1485,4 +1499,28 @@ RSpec.describe License do ...@@ -1485,4 +1499,28 @@ RSpec.describe License do
it { is_expected.to eq(result) } it { is_expected.to eq(result) }
end end
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 end
...@@ -88,8 +88,8 @@ RSpec.describe Integrations::Jira::IssueDetailEntity do ...@@ -88,8 +88,8 @@ RSpec.describe Integrations::Jira::IssueDetailEntity do
{ {
title: 'backend', title: 'backend',
name: 'backend', name: 'backend',
color: '#EBECF0', color: '#0052CC',
text_color: '#283856' text_color: '#FFFFFF'
} }
], ],
author: hash_including( author: hash_including(
......
...@@ -55,8 +55,8 @@ RSpec.describe Integrations::Jira::IssueEntity do ...@@ -55,8 +55,8 @@ RSpec.describe Integrations::Jira::IssueEntity do
{ {
title: 'backend', title: 'backend',
name: 'backend', name: 'backend',
color: '#EBECF0', color: '#0052CC',
text_color: '#283856' text_color: '#FFFFFF'
} }
], ],
author: hash_including( author: hash_including(
......
...@@ -302,12 +302,13 @@ RSpec.describe MergeRequests::UpdateService, :mailer do ...@@ -302,12 +302,13 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end end
end end
it 'updates code owner approval rules' do context 'when called inside an ActiveRecord transaction' do
expect_next_instance_of(::MergeRequests::SyncCodeOwnerApprovalRules) do |instance| it 'does not attempt to update code owner approval rules' do
expect(instance).to receive(:execute) allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(true)
end expect(::MergeRequests::SyncCodeOwnerApprovalRulesWorker).not_to receive(:perform_async)
update_merge_request(title: 'Title') update_merge_request(title: 'Title')
end
end end
context 'updating reviewers_ids' do 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 ...@@ -82,6 +82,30 @@ module Gitlab
!!@overflow !!@overflow
end 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 def size
@size ||= count # forces a loop using each method @size ||= count # forces a loop using each method
end end
...@@ -121,7 +145,15 @@ module Gitlab ...@@ -121,7 +145,15 @@ module Gitlab
end end
def over_safe_limits?(files) 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 end
def expand_diff? def expand_diff?
...@@ -154,6 +186,7 @@ module Gitlab ...@@ -154,6 +186,7 @@ module Gitlab
if @enforce_limits && i >= max_files if @enforce_limits && i >= max_files
@overflow = true @overflow = true
@overflow_max_files = true
break break
end end
...@@ -166,10 +199,19 @@ module Gitlab ...@@ -166,10 +199,19 @@ module Gitlab
@line_count += diff.line_count @line_count += diff.line_count
@byte_count += diff.diff.bytesize @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 # This last Diff instance pushes us over the lines limit. We stop and
# discard it. # discard it.
@overflow = true @overflow = true
@overflow_max_bytes = true
break break
end end
......
...@@ -13,7 +13,6 @@ module Gitlab ...@@ -13,7 +13,6 @@ module Gitlab
:elasticsearch_calls, :elasticsearch_calls,
:elasticsearch_duration_s, :elasticsearch_duration_s,
:elasticsearch_timed_out_count, :elasticsearch_timed_out_count,
:worker_data_consistency,
*::Gitlab::Memory::Instrumentation::KEY_MAPPING.values, *::Gitlab::Memory::Instrumentation::KEY_MAPPING.values,
*::Gitlab::Instrumentation::Redis.known_payload_keys, *::Gitlab::Instrumentation::Redis.known_payload_keys,
*::Gitlab::Metrics::Subscribers::ActiveRecord.known_payload_keys, *::Gitlab::Metrics::Subscribers::ActiveRecord.known_payload_keys,
......
...@@ -21,6 +21,16 @@ module Gitlab ...@@ -21,6 +21,16 @@ module Gitlab
Thread.current.name ||= Gitlab::Metrics::Samplers::ThreadsSampler::SIDEKIQ_WORKER_THREAD_NAME Thread.current.name ||= Gitlab::Metrics::Samplers::ThreadsSampler::SIDEKIQ_WORKER_THREAD_NAME
labels = create_labels(worker.class, queue, job) 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) queue_duration = ::Gitlab::InstrumentationHelper.queue_duration_for_job(job)
@metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration @metrics[:sidekiq_jobs_queue_duration_seconds].observe(labels, queue_duration) if queue_duration
...@@ -62,8 +72,6 @@ module Gitlab ...@@ -62,8 +72,6 @@ module Gitlab
end end
end end
private
def init_metrics 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), 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 ...@@ -82,6 +90,8 @@ module Gitlab
} }
end end
private
def get_thread_cputime def get_thread_cputime
defined?(Process::CLOCK_THREAD_CPUTIME_ID) ? Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID) : 0 defined?(Process::CLOCK_THREAD_CPUTIME_ID) ? Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID) : 0
end end
...@@ -108,3 +118,5 @@ module Gitlab ...@@ -108,3 +118,5 @@ module Gitlab
end end
end end
end end
Gitlab::SidekiqMiddleware::ServerMetrics.prepend_if_ee('EE::Gitlab::SidekiqMiddleware::ServerMetrics')
...@@ -2327,6 +2327,9 @@ msgstr "" ...@@ -2327,6 +2327,9 @@ msgstr ""
msgid "AdminSettings|Required pipeline configuration" msgid "AdminSettings|Required pipeline configuration"
msgstr "" msgstr ""
msgid "AdminSettings|See affected service templates"
msgstr ""
msgid "AdminSettings|Select a pipeline configuration file" msgid "AdminSettings|Select a pipeline configuration file"
msgstr "" msgstr ""
...@@ -2363,6 +2366,9 @@ 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}." 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 "" 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" msgid "AdminStatistics|Active Users"
msgstr "" msgstr ""
...@@ -3982,6 +3988,9 @@ msgstr "" ...@@ -3982,6 +3988,9 @@ msgstr ""
msgid "Applying suggestions..." msgid "Applying suggestions..."
msgstr "" msgstr ""
msgid "Approval Gate"
msgstr ""
msgid "Approval Status" msgid "Approval Status"
msgstr "" msgstr ""
...@@ -4004,6 +4013,15 @@ msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked." ...@@ -4004,6 +4013,15 @@ msgid_plural "ApprovalRuleRemove|Approvals from these members are not revoked."
msgstr[0] "" msgstr[0] ""
msgstr[1] "" 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}." msgid "ApprovalRuleRemove|You are about to remove the %{name} approver group which has %{nMembers}."
msgstr "" msgstr ""
...@@ -4017,21 +4035,36 @@ msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCoun ...@@ -4017,21 +4035,36 @@ msgid_plural "ApprovalRuleSummary|%{count} approvals required from %{membersCoun
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "ApprovalRule|Add approvel gate"
msgstr ""
msgid "ApprovalRule|Add approvers" msgid "ApprovalRule|Add approvers"
msgstr "" msgstr ""
msgid "ApprovalRule|Approval rules" msgid "ApprovalRule|Approval rules"
msgstr "" msgstr ""
msgid "ApprovalRule|Approval service API"
msgstr ""
msgid "ApprovalRule|Approvals required" msgid "ApprovalRule|Approvals required"
msgstr "" msgstr ""
msgid "ApprovalRule|Approvel gate"
msgstr ""
msgid "ApprovalRule|Approver Type"
msgstr ""
msgid "ApprovalRule|Approvers" msgid "ApprovalRule|Approvers"
msgstr "" msgstr ""
msgid "ApprovalRule|Examples: QA, Security." msgid "ApprovalRule|Examples: QA, Security."
msgstr "" msgstr ""
msgid "ApprovalRule|Invoke an external API as part of the approvals"
msgstr ""
msgid "ApprovalRule|Name" msgid "ApprovalRule|Name"
msgstr "" msgstr ""
...@@ -4041,6 +4074,9 @@ msgstr "" ...@@ -4041,6 +4074,9 @@ msgstr ""
msgid "ApprovalRule|Target branch" msgid "ApprovalRule|Target branch"
msgstr "" msgstr ""
msgid "ApprovalRule|Users or groups"
msgstr ""
msgid "ApprovalStatusTooltip|Adheres to separation of duties" msgid "ApprovalStatusTooltip|Adheres to separation of duties"
msgstr "" msgstr ""
...@@ -11256,7 +11292,7 @@ msgstr "" ...@@ -11256,7 +11292,7 @@ msgstr ""
msgid "DiscordService|Discord Notifications" msgid "DiscordService|Discord Notifications"
msgstr "" msgstr ""
msgid "DiscordService|Receive event notifications in Discord" msgid "DiscordService|Send notifications about project events to a Discord channel."
msgstr "" msgstr ""
msgid "Discover GitLab Geo" msgid "Discover GitLab Geo"
...@@ -13013,6 +13049,9 @@ msgstr "" ...@@ -13013,6 +13049,9 @@ msgstr ""
msgid "External storage authentication token" msgid "External storage authentication token"
msgstr "" msgstr ""
msgid "External url has already been taken"
msgstr ""
msgid "ExternalAuthorizationService|Classification label" msgid "ExternalAuthorizationService|Classification label"
msgstr "" msgstr ""
...@@ -18672,6 +18711,9 @@ msgstr "" ...@@ -18672,6 +18711,9 @@ msgstr ""
msgid "Leave edit mode? All unsaved changes will be lost." msgid "Leave edit mode? All unsaved changes will be lost."
msgstr "" msgstr ""
msgid "Leave feedback"
msgstr ""
msgid "Leave group" msgid "Leave group"
msgstr "" msgstr ""
...@@ -28298,6 +28340,9 @@ msgstr "" ...@@ -28298,6 +28340,9 @@ msgstr ""
msgid "Send notifications about project events to Mattermost channels. %{docs_link}" msgid "Send notifications about project events to Mattermost channels. %{docs_link}"
msgstr "" msgstr ""
msgid "Send notifications about project events to a Discord channel. %{docs_link}"
msgstr ""
msgid "Send report" msgid "Send report"
msgstr "" 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 ...@@ -291,6 +291,8 @@ RSpec.describe DiffHelper do
end end
describe '#render_overflow_warning?' do describe '#render_overflow_warning?' do
using RSpec::Parameterized::TableSyntax
let(:diffs_collection) { instance_double(Gitlab::Diff::FileCollection::MergeRequestDiff, raw_diff_files: diff_files) } let(:diffs_collection) { instance_double(Gitlab::Diff::FileCollection::MergeRequestDiff, raw_diff_files: diff_files) }
let(:diff_files) { Gitlab::Git::DiffCollection.new(files) } let(:diff_files) { Gitlab::Git::DiffCollection.new(files) }
let(:safe_file) { { too_large: false, diff: '' } } let(:safe_file) { { too_large: false, diff: '' } }
...@@ -299,13 +301,42 @@ RSpec.describe DiffHelper do ...@@ -299,13 +301,42 @@ RSpec.describe DiffHelper do
before do before do
allow(diff_files).to receive(:overflow?).and_return(false) 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 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 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_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_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 expect(render_overflow_warning?(diffs_collection)).to be false
end end
end end
...@@ -315,9 +346,8 @@ RSpec.describe DiffHelper do ...@@ -315,9 +346,8 @@ RSpec.describe DiffHelper do
allow(diff_files).to receive(:overflow?).and_return(true) allow(diff_files).to receive(:overflow?).and_return(true)
end end
it 'returns false and only logs collection overflow event' do 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).exactly(:once) expect(Gitlab::Metrics).to receive(:add_event).with(:diffs_overflow_collection_limits).once
expect(Gitlab::Metrics).not_to receive(:add_event).with(:diffs_overflow_single_file_limits)
expect(render_overflow_warning?(diffs_collection)).to be true expect(render_overflow_warning?(diffs_collection)).to be true
end end
......
...@@ -81,23 +81,31 @@ RSpec.describe UserCalloutsHelper do ...@@ -81,23 +81,31 @@ RSpec.describe UserCalloutsHelper do
end end
end end
describe '.show_service_templates_deprecated?' do describe '.show_service_templates_deprecated_callout?' do
subject { helper.show_service_templates_deprecated? } using RSpec::Parameterized::TableSyntax
context 'when user has not dismissed' do let_it_be(:admin) { create(:user, :admin) }
before do let_it_be(:non_admin) { create(:user) }
allow(helper).to receive(:user_dismissed?).with(described_class::SERVICE_TEMPLATES_DEPRECATED) { false }
end
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 end
context 'when user dismissed' do with_them do
before 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 end
it { is_expected.to be false } it { is_expected.to be should_show_callout }
end end
end end
......
...@@ -31,6 +31,19 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -31,6 +31,19 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
end end
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 subject do
Gitlab::Git::DiffCollection.new( Gitlab::Git::DiffCollection.new(
iterator, iterator,
...@@ -76,12 +89,19 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -76,12 +89,19 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
end end
context 'overflow handling' do context 'overflow handling' do
subject { super() }
let(:collapsed_safe_files) { false }
let(:collapsed_safe_lines) { false }
context 'adding few enough files' do context 'adding few enough files' do
let(:file_count) { 3 } let(:file_count) { 3 }
context 'and few enough lines' do context 'and few enough lines' do
let(:line_count) { 10 } let(:line_count) { 10 }
it_behaves_like 'overflow stuff'
describe '#overflow?' do describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -117,6 +137,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -117,6 +137,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do context 'when limiting is disabled' do
let(:limits) { false } 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 describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -155,6 +180,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -155,6 +180,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'and too many lines' do context 'and too many lines' do
let(:line_count) { 1000 } let(:line_count) { 1000 }
let(:overflow_max_lines) { true }
it_behaves_like 'overflow stuff'
describe '#overflow?' do describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -184,6 +212,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -184,6 +212,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do context 'when limiting is disabled' do
let(:limits) { false } 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 describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -216,10 +249,13 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -216,10 +249,13 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'adding too many files' do context 'adding too many files' do
let(:file_count) { 11 } let(:file_count) { 11 }
let(:overflow_max_files) { true }
context 'and few enough lines' do context 'and few enough lines' do
let(:line_count) { 1 } let(:line_count) { 1 }
it_behaves_like 'overflow stuff'
describe '#overflow?' do describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -248,6 +284,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -248,6 +284,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do context 'when limiting is disabled' do
let(:limits) { false } 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 describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -279,6 +320,10 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -279,6 +320,10 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'and too many lines' do context 'and too many lines' do
let(:line_count) { 30 } let(:line_count) { 30 }
let(:overflow_max_lines) { true }
let(:overflow_max_files) { false }
it_behaves_like 'overflow stuff'
describe '#overflow?' do describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -308,6 +353,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -308,6 +353,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do context 'when limiting is disabled' do
let(:limits) { false } 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 describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -344,6 +394,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -344,6 +394,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'and few enough lines' do context 'and few enough lines' do
let(:line_count) { 1 } let(:line_count) { 1 }
it_behaves_like 'overflow stuff'
describe '#overflow?' do describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -375,6 +427,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -375,6 +427,9 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'adding too many bytes' do context 'adding too many bytes' do
let(:file_count) { 10 } let(:file_count) { 10 }
let(:line_length) { 5200 } let(:line_length) { 5200 }
let(:overflow_max_bytes) { true }
it_behaves_like 'overflow stuff'
describe '#overflow?' do describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -404,6 +459,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -404,6 +459,11 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
context 'when limiting is disabled' do context 'when limiting is disabled' do
let(:limits) { false } 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 describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -437,6 +497,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -437,6 +497,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
describe 'empty collection' do describe 'empty collection' do
subject { Gitlab::Git::DiffCollection.new([]) } subject { Gitlab::Git::DiffCollection.new([]) }
it_behaves_like 'overflow stuff'
describe '#overflow?' do describe '#overflow?' do
subject { super().overflow? } subject { super().overflow? }
...@@ -555,7 +617,7 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -555,7 +617,7 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
.and_return({ max_files: 2, max_lines: max_lines }) .and_return({ max_files: 2, max_lines: max_lines })
end 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| subject.each_with_index do |d, i|
if i < 2 if i < 2
expect(d.diff).not_to eq('') expect(d.diff).not_to eq('')
...@@ -563,6 +625,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -563,6 +625,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
expect(d.diff).to eq('') expect(d.diff).to eq('')
end end
end end
expect(subject.collapsed_safe_files?).to eq(true)
end end
end end
...@@ -582,7 +646,7 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -582,7 +646,7 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
.and_return({ max_files: max_files, max_lines: 80 }) .and_return({ max_files: max_files, max_lines: 80 })
end 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| subject.each_with_index do |d, i|
if i < 2 if i < 2
expect(d.diff).not_to eq('') expect(d.diff).not_to eq('')
...@@ -590,26 +654,30 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -590,26 +654,30 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
expect(d.diff).to eq('') expect(d.diff).to eq('')
end end
end end
expect(subject.collapsed_safe_lines?).to eq(true)
end end
end end
context 'when go over safe limits on bytes' do context 'when go over safe limits on bytes' do
let(:iterator) do let(:iterator) do
[ [
fake_diff(1, 45), fake_diff(5, 10),
fake_diff(1, 45), fake_diff(5000, 10),
fake_diff(1, 20480), fake_diff(5, 10),
fake_diff(1, 1) fake_diff(5, 10)
] ]
end end
before do before do
allow(Gitlab::CurrentSettings).to receive(:diff_max_patch_bytes).and_return(1.megabyte)
allow(Gitlab::Git::DiffCollection) allow(Gitlab::Git::DiffCollection)
.to receive(:default_limits) .to receive(:default_limits)
.and_return({ max_files: max_files, max_lines: 80 }) .and_return({ max_files: 4, max_lines: 3000 })
end 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| subject.each_with_index do |d, i|
if i < 2 if i < 2
expect(d.diff).not_to eq('') expect(d.diff).not_to eq('')
...@@ -617,6 +685,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do ...@@ -617,6 +685,8 @@ RSpec.describe Gitlab::Git::DiffCollection, :seed_helper do
expect(d.diff).to eq('') expect(d.diff).to eq('')
end end
end end
expect(subject.collapsed_safe_bytes?).to eq(true)
end end
end end
end end
......
...@@ -124,6 +124,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do ...@@ -124,6 +124,7 @@ RSpec.describe Gitlab::Metrics::Subscribers::ActiveRecord do
with_them do with_them do
let(:payload) { { name: name, sql: sql(sql_query, comments: comments), connection: connection } } 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 it 'marks the current thread as using the database' do
# since it would already have been toggled by other specs # since it would already have been toggled by other specs
......
...@@ -3,156 +3,33 @@ ...@@ -3,156 +3,33 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Gitlab::SidekiqMiddleware::ClientMetrics do RSpec.describe Gitlab::SidekiqMiddleware::ClientMetrics do
context "with worker attribution" do shared_examples "a metrics middleware" do
subject { described_class.new } 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 before do
stub_const('TestNonAttributedWorker', Class.new) allow(Gitlab::Metrics).to receive(:counter).with(described_class::ENQUEUED, anything).and_return(enqueued_jobs_metric)
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
end end
context "no urgency" do describe '#call' do
it_behaves_like "a metrics client middleware" do it 'yields block' do
let(:urgency) { :throttled } expect { |b| subject.call(worker_class, job, :test, double, &b) }.to yield_control.once
let(:labels) { default_labels.merge(urgency: "throttled") }
end end
end
context "external dependencies" do it 'increments enqueued jobs metric with correct labels when worker is a string of the class' do
it_behaves_like "a metrics client middleware" do expect(enqueued_jobs_metric).to receive(:increment).with(labels, 1)
let(:external_dependencies) { true }
let(:labels) { default_labels.merge(external_dependencies: "yes") }
end
end
context "cpu boundary" do subject.call(worker_class.to_s, job, :test, double) { nil }
it_behaves_like "a metrics client middleware" do
let(:resource_boundary) { :cpu }
let(:labels) { default_labels.merge(boundary: "cpu") }
end end
end
context "memory boundary" do it 'increments enqueued jobs metric with correct labels' do
it_behaves_like "a metrics client middleware" do expect(enqueued_jobs_metric).to receive(:increment).with(labels, 1)
let(:resource_boundary) { :memory }
let(:labels) { default_labels.merge(boundary: "memory") }
end
end
context "feature category" do subject.call(worker_class, job, :test, double) { nil }
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") }
end end
end end
end end
end end
it_behaves_like 'metrics middleware with worker attribution'
end end
...@@ -35,7 +35,6 @@ RSpec.describe Gitlab::SidekiqMiddleware::InstrumentationLogger do ...@@ -35,7 +35,6 @@ RSpec.describe Gitlab::SidekiqMiddleware::InstrumentationLogger do
:elasticsearch_calls, :elasticsearch_calls,
:elasticsearch_duration_s, :elasticsearch_duration_s,
:elasticsearch_timed_out_count, :elasticsearch_timed_out_count,
:worker_data_consistency,
:mem_objects, :mem_objects,
:mem_bytes, :mem_bytes,
:mem_mallocs, :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