diff --git a/.rubocop_todo/gitlab/strong_memoize_attr.yml b/.rubocop_todo/gitlab/strong_memoize_attr.yml
index 8cf4cf5655d..7309e1d9753 100644
--- a/.rubocop_todo/gitlab/strong_memoize_attr.yml
+++ b/.rubocop_todo/gitlab/strong_memoize_attr.yml
@@ -2,19 +2,7 @@
# Cop supports --autocorrect.
Gitlab/StrongMemoizeAttr:
Exclude:
- - 'app/components/pajamas/avatar_component.rb'
- - 'app/controllers/application_controller.rb'
- - 'app/controllers/concerns/boards_actions.rb'
- - 'app/controllers/concerns/creates_commit.rb'
- - 'app/controllers/concerns/find_snippet.rb'
- - 'app/controllers/concerns/impersonation.rb'
- - 'app/controllers/concerns/issuable_actions.rb'
- - 'app/controllers/concerns/issuable_collections.rb'
- - 'app/controllers/concerns/known_sign_in.rb'
- 'app/controllers/concerns/wiki_actions.rb'
- - 'app/controllers/ide_controller.rb'
- - 'app/controllers/import/github_controller.rb'
- - 'app/controllers/invites_controller.rb'
- 'app/controllers/jira_connect/application_controller.rb'
- 'app/controllers/jwt_controller.rb'
- 'app/controllers/oauth/authorizations_controller.rb'
diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml
index 07a492e1de6..0ef26bb32cf 100644
--- a/.rubocop_todo/layout/line_length.yml
+++ b/.rubocop_todo/layout/line_length.yml
@@ -122,22 +122,7 @@ Layout/LineLength:
- 'app/graphql/types/issue_sort_enum.rb'
- 'app/graphql/types/issue_type.rb'
- 'app/graphql/types/member_interface.rb'
- - 'app/graphql/types/merge_request_type.rb'
- - 'app/graphql/types/milestone_sort_enum.rb'
- - 'app/graphql/types/milestone_type.rb'
- - 'app/graphql/types/namespace/package_settings_type.rb'
- 'app/graphql/types/notes/diff_position_input_type.rb'
- - 'app/graphql/types/notes/noteable_interface.rb'
- - 'app/graphql/types/packages/composer/metadatum_type.rb'
- - 'app/graphql/types/packages/conan/file_metadatum_type.rb'
- - 'app/graphql/types/packages/helm/dependency_type.rb'
- - 'app/graphql/types/packages/helm/metadata_type.rb'
- - 'app/graphql/types/packages/nuget/dependency_link_metadatum_type.rb'
- - 'app/graphql/types/packages/package_dependency_link_type.rb'
- - 'app/graphql/types/packages/package_details_type.rb'
- - 'app/graphql/types/packages/package_type_enum.rb'
- - 'app/graphql/types/packages/pypi/metadatum_type.rb'
- - 'app/graphql/types/project_type.rb'
- 'app/graphql/types/query_type.rb'
- 'app/graphql/types/repository/blob_type.rb'
- 'app/graphql/types/repository_type.rb'
diff --git a/.rubocop_todo/lint/useless_numeric_operation.yml b/.rubocop_todo/lint/useless_numeric_operation.yml
deleted file mode 100644
index a45a71a1989..00000000000
--- a/.rubocop_todo/lint/useless_numeric_operation.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-# Cop supports --autocorrect.
-Lint/UselessNumericOperation:
- Details: grace period
- Exclude:
- - 'ee/spec/lib/gitlab/geo_spec.rb'
diff --git a/GITLAB_KAS_VERSION b/GITLAB_KAS_VERSION
index 8e3ba8944b0..385b9bcc0b6 100644
--- a/GITLAB_KAS_VERSION
+++ b/GITLAB_KAS_VERSION
@@ -1 +1 @@
-e8ecda472f3b2d7ea74fda18e64d9bf77e2e1487
+2bb6448d821d9436d6b74b064a4cb48965603182
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index a01c3a05a71..f272c5375d5 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -302,7 +302,7 @@ export default {
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 61eb9c55ff4..faeec1764f7 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -824,6 +824,7 @@ export default {
:can-current-user-fork="canCurrentUserFork"
:view-diffs-file-by-file="viewDiffsFileByFile"
:active="active"
+ :is-diff-view-active="currentDiffFileId === item.file_hash"
/>
@@ -844,6 +845,7 @@ export default {
:help-page-path="helpPagePath"
:can-current-user-fork="canCurrentUserFork"
:view-diffs-file-by-file="viewDiffsFileByFile"
+ :is-diff-view-active="currentDiffFileId === file.file_hash"
/>
-
+
diff --git a/app/assets/javascripts/work_items/components/rich_timestamp_tooltip.stories.js b/app/assets/javascripts/work_items/components/rich_timestamp_tooltip.stories.js
new file mode 100644
index 00000000000..71740f1a0f7
--- /dev/null
+++ b/app/assets/javascripts/work_items/components/rich_timestamp_tooltip.stories.js
@@ -0,0 +1,23 @@
+import RichTimestampTooltip from './rich_timestamp_tooltip.vue';
+
+export default {
+ component: RichTimestampTooltip,
+ title: 'work_items/rich_timestamp_tooltip',
+};
+
+const Template = (args, { argTypes }) => ({
+ components: { RichTimestampTooltip },
+ props: Object.keys(argTypes),
+
+ template: `
+
+ example text
+
+
`,
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ rawTimestamp: '2023-10-26T14:32:12.000Z',
+ timestampTypeText: 'Created',
+};
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index f15baf284cf..35b21d34bcb 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -316,11 +316,6 @@ li.note {
}
}
-.bordered-box {
- border: 1px solid $border-color;
- border-radius: $gl-border-radius-base;
-}
-
.tooltip {
.tooltip-inner {
word-wrap: break-word;
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 27caf17e439..f48ba248b35 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -41,7 +41,7 @@ table {
}
th {
- background-color: $gray-10;
+ @apply gl-bg-subtle;
border-bottom: 0;
&.wide {
@@ -53,7 +53,7 @@ table {
.thead-white {
th {
- color: $gl-text-color-secondary;
+ @apply gl-text-subtle;
border-top: 0;
}
}
diff --git a/app/assets/stylesheets/page_bundles/design_management.scss b/app/assets/stylesheets/page_bundles/design_management.scss
index 04919971a52..e861dbda482 100644
--- a/app/assets/stylesheets/page_bundles/design_management.scss
+++ b/app/assets/stylesheets/page_bundles/design_management.scss
@@ -178,7 +178,7 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
.design-note {
padding: $gl-padding-8;
list-style: none;
- border-top-left-radius: $gl-border-radius-base; // same border radius used by .bordered-box
+ border-top-left-radius: $gl-border-radius-base;
border-top-right-radius: $gl-border-radius-base;
@apply gl-transition-background;
diff --git a/app/assets/stylesheets/page_bundles/tree.scss b/app/assets/stylesheets/page_bundles/tree.scss
index 30a9e2d402e..0c4def48dfe 100644
--- a/app/assets/stylesheets/page_bundles/tree.scss
+++ b/app/assets/stylesheets/page_bundles/tree.scss
@@ -92,9 +92,10 @@
table.tree-table {
margin-bottom: 0;
+ @apply dark:gl-bg-gray-50;
tr {
- border-bottom: 1px solid var(--gray-50, $gray-50);
+ @apply gl-border-b gl-border-b-section;
td,
th {
@@ -102,28 +103,51 @@
}
th {
+ @apply gl-bg-subtle dark:gl-bg-gray-100;
border: 0;
}
td {
- border-color: var(--gl-border-color-default);
+ @apply gl-border-section;
}
&:hover:not(.tree-truncated-warning) {
td {
- background-color: var(--blue-50, $blue-50);
+ @apply gl-bg-blue-50 dark:gl-bg-gray-100;
background-clip: padding-box;
- border-top: 1px solid var(--blue-200, $blue-200);
- border-bottom: 1px solid var(--blue-200, $blue-200);
+ border-top: 1px solid var(--gl-action-selected-border-color-default);
+ border-bottom: 1px solid var(--gl-action-selected-border-color-default);
cursor: pointer;
+
+ &:first-of-type {
+ box-shadow: inset 1px 0 0 0 var(--gl-action-selected-border-color-default);
+ }
+
+ &:last-of-type {
+ box-shadow: inset -1px 0 0 0 var(--gl-action-selected-border-color-default);
+ }
+ }
+
+ &:last-of-type td {
+ box-shadow: inset 0 -1px 0 0 var(--gl-action-selected-border-color-default);
+
+ &:first-of-type {
+ box-shadow: inset 1px 0 0 0 var(--gl-action-selected-border-color-default),
+ inset 0 -1px 0 0 var(--gl-action-selected-border-color-default);
+ border-bottom-left-radius: calc(#{$gl-border-radius-base} - 1px);
+ }
+
+ &:last-of-type {
+ box-shadow: inset -1px 0 0 0 var(--gl-action-selected-border-color-default),
+ inset 0 -1px 0 0 var(--gl-action-selected-border-color-default);
+ border-bottom-right-radius: calc(#{$gl-border-radius-base} - 1px);
+ }
}
}
&.selected {
td {
- background: var(--gray-50, $gray-50);
- border-top: 1px solid var(--gl-border-color-default);
- border-bottom: 1px solid var(--gl-border-color-default);
+ @apply gl-bg-strong dark:gl-bg-gray-100 gl-border-t gl-border-b;
}
}
@@ -151,7 +175,7 @@
i,
a {
- color: var(--gl-text-color-default);
+ @apply gl-text-default;
}
img {
diff --git a/app/components/pajamas/avatar_component.rb b/app/components/pajamas/avatar_component.rb
index 91aef8a98b2..d37e0135b58 100644
--- a/app/components/pajamas/avatar_component.rb
+++ b/app/components/pajamas/avatar_component.rb
@@ -43,26 +43,25 @@ module Pajamas
end
def src
- strong_memoize(:src) do
- if @item.is_a?(String)
- @item
- elsif @item.is_a?(User)
- # Users show a gravatar instead of an identicon. Also avatars of
- # blocked users are only shown if the current_user is an admin.
- # To not duplicate this logic, we are using existing helpers here.
- current_user = begin
- helpers.current_user
- rescue StandardError
- nil
- end
- helpers.avatar_icon_for_user(@item, @size, current_user: current_user)
- elsif @item.is_a?(AvatarEmail)
- helpers.avatar_icon_for_email(@item.email, @size)
- elsif @item.try(:avatar_url)
- "#{@item.avatar_url}?width=#{@size}"
+ if @item.is_a?(String)
+ @item
+ elsif @item.is_a?(User)
+ # Users show a gravatar instead of an identicon. Also avatars of
+ # blocked users are only shown if the current_user is an admin.
+ # To not duplicate this logic, we are using existing helpers here.
+ current_user = begin
+ helpers.current_user
+ rescue StandardError
+ nil
end
+ helpers.avatar_icon_for_user(@item, @size, current_user: current_user)
+ elsif @item.is_a?(AvatarEmail)
+ helpers.avatar_icon_for_email(@item.email, @size)
+ elsif @item.try(:avatar_url)
+ "#{@item.avatar_url}?width=#{@size}"
end
end
+ strong_memoize_attr :src
def srcset
return unless src
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 54fed1d04a9..05b77ac0fa2 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -180,14 +180,13 @@ class ApplicationController < BaseActionController
# (e.g. tokens) to authenticate the user, whereas Devise sets current_user.
#
def auth_user
- strong_memoize(:auth_user) do
- if user_signed_in?
- current_user
- else
- try(:authenticated_user)
- end
+ if user_signed_in?
+ current_user
+ else
+ try(:authenticated_user)
end
end
+ strong_memoize_attr :auth_user
# Devise defines current_user to be:
#
diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb
index 188e77c8168..7252698ca6c 100644
--- a/app/controllers/concerns/boards_actions.rb
+++ b/app/controllers/concerns/boards_actions.rb
@@ -39,20 +39,18 @@ module BoardsActions
def push_licensed_features; end
def board
- strong_memoize(:board) do
- board_finder.execute.first
- end
+ board_finder.execute.first
end
+ strong_memoize_attr :board
def board_visit_service
Boards::Visits::CreateService
end
def parent
- strong_memoize(:parent) do
- group? ? group : project
- end
+ group? ? group : project
end
+ strong_memoize_attr :parent
def board_path(board)
if group?
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index f3f83306c04..c3e01de4cdd 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -127,16 +127,15 @@ module CreatesCommit
# rubocop:disable Gitlab/ModuleWithInstanceVariables
# rubocop: disable CodeReuse/ActiveRecord
def merge_request_exists?
- strong_memoize(:merge_request) do
- MergeRequestsFinder.new(current_user, project_id: @project.id)
+ MergeRequestsFinder.new(current_user, project_id: @project.id)
.execute
.opened
.find_by(
source_project_id: @project_to_commit_into,
source_branch: @branch_name,
target_branch: @start_branch)
- end
end
+ strong_memoize_attr :merge_request_exists?
# rubocop: enable CodeReuse/ActiveRecord
# rubocop:enable Gitlab/ModuleWithInstanceVariables
diff --git a/app/controllers/concerns/find_snippet.rb b/app/controllers/concerns/find_snippet.rb
index 8a4adbb608f..1fdbf02430f 100644
--- a/app/controllers/concerns/find_snippet.rb
+++ b/app/controllers/concerns/find_snippet.rb
@@ -8,10 +8,9 @@ module FindSnippet
# rubocop:disable CodeReuse/ActiveRecord
def snippet
- strong_memoize(:snippet) do
- snippet_klass.inc_relations_for_view.find_by(snippet_find_params)
- end
+ snippet_klass.inc_relations_for_view.find_by(snippet_find_params)
end
+ strong_memoize_attr :snippet
# rubocop:enable CodeReuse/ActiveRecord
def snippet_klass
diff --git a/app/controllers/concerns/impersonation.rb b/app/controllers/concerns/impersonation.rb
index aac55af0bac..558d61e9b0f 100644
--- a/app/controllers/concerns/impersonation.rb
+++ b/app/controllers/concerns/impersonation.rb
@@ -53,8 +53,7 @@ module Impersonation
end
def impersonator
- strong_memoize(:impersonator) do
- User.find(session[:impersonator_id]) if session[:impersonator_id]
- end
+ User.find(session[:impersonator_id]) if session[:impersonator_id]
end
+ strong_memoize_attr :impersonator
end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index 47cc5a57da4..deeac362a82 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -166,20 +166,19 @@ module IssuableActions
private
def notes_filter
- strong_memoize(:notes_filter) do
- notes_filter_param = params[:notes_filter]&.to_i
+ notes_filter_param = params[:notes_filter]&.to_i
- # GitLab Geo does not expect database UPDATE or INSERT statements to happen
- # on GET requests.
- # This is just a fail-safe in case notes_filter is sent via GET request in GitLab Geo.
- # In some cases, we also force the filter to not be persisted with the `persist_filter` param
- if Gitlab::Database.read_only? || params[:persist_filter] == 'false'
- notes_filter_param || current_user&.notes_filter_for(issuable)
- else
- current_user&.set_notes_filter(notes_filter_param, issuable) || notes_filter_param
- end
+ # GitLab Geo does not expect database UPDATE or INSERT statements to happen
+ # on GET requests.
+ # This is just a fail-safe in case notes_filter is sent via GET request in GitLab Geo.
+ # In some cases, we also force the filter to not be persisted with the `persist_filter` param
+ if Gitlab::Database.read_only? || params[:persist_filter] == 'false'
+ notes_filter_param || current_user&.notes_filter_for(issuable)
+ else
+ current_user&.set_notes_filter(notes_filter_param, issuable) || notes_filter_param
end
end
+ strong_memoize_attr :notes_filter
def discussion_cache_context
[current_user&.cache_key, project.team.human_max_access(current_user&.id), 'v2'].join(':')
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 2cbdce1af54..c604e343dbd 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -59,37 +59,36 @@ module IssuableCollections
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def finder_options
- strong_memoize(:finder_options) do
- params[:state] = default_state if params[:state].blank?
+ params[:state] = default_state if params[:state].blank?
- options = {
- scope: params[:scope],
- state: params[:state],
- confidential: Gitlab::Utils.to_boolean(params[:confidential]),
- sort: set_sort_order
- }
+ options = {
+ scope: params[:scope],
+ state: params[:state],
+ confidential: Gitlab::Utils.to_boolean(params[:confidential]),
+ sort: set_sort_order
+ }
- # Used by view to highlight active option
- @sort = options[:sort]
+ # Used by view to highlight active option
+ @sort = options[:sort]
- # When a user looks for an exact iid, we do not filter by search but only by iid
- if params[:search] =~ /^#(?\d+)\z/
- options[:iids] = Regexp.last_match[:iid]
- params[:search] = nil
- end
-
- if @project
- options[:project_id] = @project.id
- options[:attempt_project_search_optimizations] = true
- elsif @group
- options[:group_id] = @group.id
- options[:include_subgroups] = true
- options[:attempt_group_search_optimizations] = true
- end
-
- params.permit(finder_type.valid_params).merge(options)
+ # When a user looks for an exact iid, we do not filter by search but only by iid
+ if params[:search] =~ /^#(?\d+)\z/
+ options[:iids] = Regexp.last_match[:iid]
+ params[:search] = nil
end
+
+ if @project
+ options[:project_id] = @project.id
+ options[:attempt_project_search_optimizations] = true
+ elsif @group
+ options[:group_id] = @group.id
+ options[:include_subgroups] = true
+ options[:attempt_group_search_optimizations] = true
+ end
+
+ params.permit(finder_type.valid_params).merge(options)
end
+ strong_memoize_attr :finder_options
# rubocop:enable Gitlab/ModuleWithInstanceVariables
def default_state
diff --git a/app/controllers/concerns/known_sign_in.rb b/app/controllers/concerns/known_sign_in.rb
index 6bbdde19f23..957f868b3f8 100644
--- a/app/controllers/concerns/known_sign_in.rb
+++ b/app/controllers/concerns/known_sign_in.rb
@@ -36,10 +36,9 @@ module KnownSignIn
end
def sessions
- strong_memoize(:session) do
- ActiveSession.list(current_user).reject(&:is_impersonated)
- end
+ ActiveSession.list(current_user).reject(&:is_impersonated)
end
+ strong_memoize_attr :sessions
def known_ip_addresses
[current_user.last_sign_in_ip, sessions.map(&:ip_address)].flatten
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index 1861f0692d8..92162844361 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
class IdeController < ApplicationController
+ include Gitlab::Utils::StrongMemoize
include WebIdeCSP
include StaticObjectExternalStorageCSP
- include Gitlab::Utils::StrongMemoize
include ProductAnalyticsTracking
before_action :authorize_read_project!, only: [:index]
@@ -63,12 +63,11 @@ class IdeController < ApplicationController
end
def project
- strong_memoize(:project) do
- next unless params[:project_id].present?
+ return unless params[:project_id].present?
- Project.find_by_full_path(params[:project_id])
- end
+ Project.find_by_full_path(params[:project_id])
end
+ strong_memoize_attr :project
def tracking_namespace_source
project.namespace
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index 1973e03f8a4..7a826c421a7 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -154,10 +154,9 @@ class Import::GithubController < Import::BaseController
override :provider_url
def provider_url
- strong_memoize(:provider_url) do
- oauth_config&.dig('url').presence || 'https://github.com'
- end
+ oauth_config&.dig('url').presence || 'https://github.com'
end
+ strong_memoize_attr :provider_url
private
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 5922406ab33..16cd35c3643 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -56,17 +56,15 @@ class InvitesController < ApplicationController
end
def member?
- strong_memoize(:is_member) do
- @member.source.has_user?(current_user)
- end
+ @member.source.has_user?(current_user)
end
+ strong_memoize_attr :member?
def member
- strong_memoize(:member) do
- @token = params[:id].to_s
- Member.find_by_invite_token(@token)
- end
+ @token = params[:id].to_s
+ Member.find_by_invite_token(@token)
end
+ strong_memoize_attr :member
def ensure_member_exists
return if member
diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb
index 7ae0f14fedd..c17b2ebc859 100644
--- a/app/controllers/oauth/authorizations_controller.rb
+++ b/app/controllers/oauth/authorizations_controller.rb
@@ -82,8 +82,9 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController
end
def doorkeeper_application
- strong_memoize(:doorkeeper_application) { ::Doorkeeper::OAuth::Client.find(params['client_id'].to_s)&.application }
+ ::Doorkeeper::OAuth::Client.find(params['client_id'].to_s)&.application
end
+ strong_memoize_attr :doorkeeper_application
def application_has_read_user_scope?
doorkeeper_application&.includes_scope?(Gitlab::Auth::READ_USER_SCOPE)
diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb
index 16165d3ca73..2cb8dda7df5 100644
--- a/app/finders/autocomplete/users_finder.rb
+++ b/app/finders/autocomplete/users_finder.rb
@@ -34,7 +34,7 @@ module Autocomplete
# Include current user if available to filter by "Me"
items.unshift(current_user) if prepend_current_user?
- items.unshift(author) if prepend_author? && author&.active?
+ items.unshift(author) if prepend_author? && author&.active? && !author&.import_user? && !author&.placeholder?
end
items = filter_users_by_push_ability(items)
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index dacdb9eee3e..67b6a254bc5 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -119,9 +119,9 @@ module Types
alpha: { milestone: '16.5' },
calls_gitaly: true
- field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true,
- calls_gitaly: true,
- description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged.'
+ field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true, calls_gitaly: true,
+ description: 'Indicates if all discussions in the merge request have been resolved, ' \
+ 'allowing the merge request to be merged.'
field :rebase_commit_sha, GraphQL::Types::String, null: true,
description: 'Rebase commit SHA of the merge request.'
field :rebase_in_progress, GraphQL::Types::Boolean, method: :rebase_in_progress?, null: false, calls_gitaly: true,
@@ -163,7 +163,8 @@ module Types
description: 'Pipeline running on the branch HEAD of the merge request.'
field :pipelines,
null: true,
- description: 'Pipelines for the merge request. Note: for performance reasons, no more than the most recent 500 pipelines will be returned.',
+ description: 'Pipelines for the merge request. Note: for performance reasons, ' \
+ 'no more than the most recent 500 pipelines will be returned.',
resolver: Resolvers::MergeRequestPipelinesResolver
field :assignees,
@@ -193,8 +194,12 @@ module Types
description: 'Indicates if the merge request has conflicts.'
field :milestone, Types::MilestoneType, null: true,
description: 'Milestone of the merge request.'
- field :participants, Types::MergeRequests::ParticipantType.connection_type, null: true, complexity: 15,
- description: 'Participants in the merge request. This includes the author, assignees, reviewers, and users mentioned in notes.',
+ field :participants,
+ Types::MergeRequests::ParticipantType.connection_type,
+ null: true,
+ complexity: 15,
+ description: 'Participants in the merge request. This includes the author, ' \
+ 'assignees, reviewers, and users mentioned in notes.',
resolver: Resolvers::Users::ParticipantsResolver
field :reference, GraphQL::Types::String, null: false, method: :to_reference,
description: 'Internal reference of the merge request. Returned in shortened format by default.' do
@@ -240,11 +245,22 @@ module Types
description: 'User who merged this merge request or set it to auto-merge.'
field :mergeable, GraphQL::Types::Boolean, null: false, method: :mergeable?, calls_gitaly: true,
description: 'Indicates if the merge request is mergeable.'
- field :security_auto_fix, GraphQL::Types::Boolean, null: true,
- description: 'Indicates if the merge request is created by @GitLab-Security-Bot.', deprecated: { reason: 'Security Auto Fix experiment feature was removed. It was always hidden behind `security_auto_fix` feature flag', milestone: '16.11' }
+ field :security_auto_fix,
+ GraphQL::Types::Boolean,
+ null: true,
+ description: 'Indicates if the merge request is created by @GitLab-Security-Bot.',
+ deprecated: {
+ reason: 'Security Auto Fix experiment feature was removed. ' \
+ 'It was always hidden behind `security_auto_fix` feature flag',
+ milestone: '16.11'
+ }
field :squash, GraphQL::Types::Boolean, null: false,
- description: 'Indicates if the merge request is set to be squashed when merged. [Project settings](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#configure-squash-options-for-a-project) may override this value. Use `squash_on_merge` instead to take project squash options into account.'
+ description: <<~HEREDOC.squish
+ Indicates if the merge request is set to be squashed when merged.
+ [Project settings](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html#configure-squash-options-for-a-project)
+ may override this value. Use `squash_on_merge` instead to take project squash options into account.
+ HEREDOC
field :squash_on_merge, GraphQL::Types::Boolean, null: false, method: :squash_on_merge?,
description: 'Indicates if the merge request will be squashed when merged.'
field :timelogs, Types::TimelogType.connection_type, null: false,
@@ -296,7 +312,11 @@ module Types
def user_discussions_count
BatchLoader::GraphQL.for(object.id).batch(key: :merge_request_user_discussions_count) do |ids, loader, args|
- counts = Note.count_for_collection(ids, 'MergeRequest', 'COUNT(DISTINCT discussion_id) as count').index_by(&:noteable_id)
+ counts = Note.count_for_collection(
+ ids,
+ 'MergeRequest',
+ 'COUNT(DISTINCT discussion_id) as count'
+ ).index_by(&:noteable_id)
ids.each do |id|
loader.call(id, counts[id]&.count || 0)
diff --git a/app/graphql/types/milestone_sort_enum.rb b/app/graphql/types/milestone_sort_enum.rb
index 9f7dedb4c4c..e70bd07f791 100644
--- a/app/graphql/types/milestone_sort_enum.rb
+++ b/app/graphql/types/milestone_sort_enum.rb
@@ -7,7 +7,13 @@ module Types
value 'DUE_DATE_ASC', 'Milestone due date by ascending order.', value: :due_date_asc
value 'DUE_DATE_DESC', 'Milestone due date by descending order.', value: :due_date_desc
- value 'EXPIRED_LAST_DUE_DATE_ASC', 'Group milestones in this order: non-expired milestones with due dates, non-expired milestones without due dates and expired milestones then sort by due date in ascending order.', value: :expired_last_due_date_asc
- value 'EXPIRED_LAST_DUE_DATE_DESC', 'Group milestones in this order: non-expired milestones with due dates, non-expired milestones without due dates and expired milestones then sort by due date in descending order.', value: :expired_last_due_date_desc
+ value 'EXPIRED_LAST_DUE_DATE_ASC',
+ 'Group milestones in this order: non-expired milestones with due dates, non-expired milestones ' \
+ 'without due dates and expired milestones then sort by due date in ascending order.',
+ value: :expired_last_due_date_asc
+ value 'EXPIRED_LAST_DUE_DATE_DESC',
+ 'Group milestones in this order: non-expired milestones with due dates, non-expired milestones ' \
+ 'without due dates and expired milestones then sort by due date in descending order.',
+ value: :expired_last_due_date_desc
end
end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index 36c7ee286e6..68a9299b387 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -27,10 +27,12 @@ module Types
description: 'State of the milestone.'
field :expired, GraphQL::Types::Boolean, null: false,
- description: 'Expired state of the milestone (a milestone is expired when the due date is past the current date). Defaults to `false` when due date has not been set.'
+ description: 'Expired state of the milestone (a milestone is expired when the due date is past the current ' \
+ 'date). Defaults to `false` when due date has not been set.'
field :upcoming, GraphQL::Types::Boolean, null: false,
- description: 'Upcoming state of the milestone (a milestone is upcoming when the start date is in the future). Defaults to `false` when start date has not been set.'
+ description: 'Upcoming state of the milestone (a milestone is upcoming when the start date is in the future). ' \
+ 'Defaults to `false` when start date has not been set.'
field :web_path, GraphQL::Types::String, null: false, method: :milestone_path,
description: 'Web path of the milestone.'
diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb
index 621cb091019..9a3ebc6d49e 100644
--- a/app/graphql/types/namespace/package_settings_type.rb
+++ b/app/graphql/types/namespace/package_settings_type.rb
@@ -10,13 +10,15 @@ module Types
field :generic_duplicate_exception_regex, Types::UntrustedRegexp,
null: true,
- description: 'When generic_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.'
+ description: 'When generic_duplicates_allowed is false, you can publish duplicate packages with names that ' \
+ 'match this regex. Otherwise, this setting has no effect.'
field :generic_duplicates_allowed, GraphQL::Types::Boolean,
null: false,
description: 'Indicates whether duplicate generic packages are allowed for this namespace.'
field :maven_duplicate_exception_regex, Types::UntrustedRegexp,
null: true,
- description: 'When maven_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.'
+ description: 'When maven_duplicates_allowed is false, you can publish duplicate packages with names that ' \
+ 'match this regex. Otherwise, this setting has no effect.'
field :maven_duplicates_allowed, GraphQL::Types::Boolean,
null: false,
description: 'Indicates whether duplicate Maven packages are allowed for this namespace.'
@@ -28,7 +30,8 @@ module Types
description: 'Indicates whether npm package forwarding is allowed for this namespace.'
field :nuget_duplicate_exception_regex, Types::UntrustedRegexp,
null: true,
- description: 'When nuget_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect. '
+ description: 'When nuget_duplicates_allowed is false, you can publish duplicate packages with names that ' \
+ 'match this regex. Otherwise, this setting has no effect. '
field :nuget_duplicates_allowed, GraphQL::Types::Boolean,
null: false,
description: 'Indicates whether duplicate NuGet packages are allowed for this namespace.'
@@ -37,7 +40,8 @@ module Types
description: 'Indicates whether PyPI package forwarding is allowed for this namespace.'
field :terraform_module_duplicate_exception_regex, Types::UntrustedRegexp,
null: true,
- description: 'When terraform_module_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect.'
+ description: 'When terraform_module_duplicates_allowed is false, you can publish duplicate packages with ' \
+ 'names that match this regex. Otherwise, this setting has no effect.'
field :terraform_module_duplicates_allowed, GraphQL::Types::Boolean,
null: false,
description: 'Indicates whether duplicate Terraform packages are allowed for this namespace.'
diff --git a/app/graphql/types/notes/noteable_interface.rb b/app/graphql/types/notes/noteable_interface.rb
index 3879f7be08f..e04da811b38 100644
--- a/app/graphql/types/notes/noteable_interface.rb
+++ b/app/graphql/types/notes/noteable_interface.rb
@@ -5,8 +5,10 @@ module Types
module NoteableInterface
include Types::BaseInterface
- field :notes, resolver: Resolvers::Noteable::NotesResolver, null: false, description: "All notes on this noteable."
- field :discussions, Types::Notes::DiscussionType.connection_type, null: false, description: "All discussions on this noteable."
+ field :notes, resolver: Resolvers::Noteable::NotesResolver, null: false,
+ description: "All notes on this noteable."
+ field :discussions, Types::Notes::DiscussionType.connection_type, null: false,
+ description: "All discussions on this noteable."
field :commenters, Types::UserType.connection_type, null: false, description: "All commenters on this noteable."
def self.resolve_type(object, context)
diff --git a/app/graphql/types/packages/composer/metadatum_type.rb b/app/graphql/types/packages/composer/metadatum_type.rb
index d28ee87b878..6da37617942 100644
--- a/app/graphql/types/packages/composer/metadatum_type.rb
+++ b/app/graphql/types/packages/composer/metadatum_type.rb
@@ -9,7 +9,8 @@ module Types
authorize :read_package
- field :composer_json, Types::Packages::Composer::JsonType, null: false, description: 'Data of the Composer JSON file.'
+ field :composer_json, Types::Packages::Composer::JsonType, null: false,
+ description: 'Data of the Composer JSON file.'
field :target_sha, GraphQL::Types::String, null: false, description: 'Target SHA of the package.'
end
end
diff --git a/app/graphql/types/packages/conan/file_metadatum_type.rb b/app/graphql/types/packages/conan/file_metadatum_type.rb
index 3f6c4837796..fcc538ab245 100644
--- a/app/graphql/types/packages/conan/file_metadatum_type.rb
+++ b/app/graphql/types/packages/conan/file_metadatum_type.rb
@@ -11,11 +11,16 @@ module Types
authorize :read_package
- field :conan_file_type, ::Types::Packages::Conan::MetadatumFileTypeEnum, null: false, description: 'Type of the Conan file.'
- field :conan_package_reference, GraphQL::Types::String, null: true, description: 'Reference of the Conan package.'
- field :id, ::Types::GlobalIDType[::Packages::Conan::FileMetadatum], null: false, description: 'ID of the metadatum.'
- field :package_revision, GraphQL::Types::String, null: true, description: 'Revision of the package.', method: :package_revision_value
- field :recipe_revision, GraphQL::Types::String, null: false, description: 'Revision of the Conan recipe.', method: :recipe_revision_value
+ field :conan_file_type, ::Types::Packages::Conan::MetadatumFileTypeEnum, null: false,
+ description: 'Type of the Conan file.'
+ field :conan_package_reference, GraphQL::Types::String, null: true,
+ description: 'Reference of the Conan package.'
+ field :id, ::Types::GlobalIDType[::Packages::Conan::FileMetadatum], null: false,
+ description: 'ID of the metadatum.'
+ field :package_revision, GraphQL::Types::String, null: true, description: 'Revision of the package.',
+ method: :package_revision_value
+ field :recipe_revision, GraphQL::Types::String, null: false, description: 'Revision of the Conan recipe.',
+ method: :recipe_revision_value
end
end
end
diff --git a/app/graphql/types/packages/helm/dependency_type.rb b/app/graphql/types/packages/helm/dependency_type.rb
index 9bf12d92004..dc513867c7e 100644
--- a/app/graphql/types/packages/helm/dependency_type.rb
+++ b/app/graphql/types/packages/helm/dependency_type.rb
@@ -9,10 +9,15 @@ module Types
description 'Represents a Helm dependency'
# Need to be synced with app/validators/json_schemas/helm_metadata.json#dependencies
- field :alias, GraphQL::Types::String, null: true, description: 'Alias of the dependency.', resolver_method: :resolve_alias
+ field :alias,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Alias of the dependency.',
+ resolver_method: :resolve_alias
field :condition, GraphQL::Types::String, null: true, description: 'Condition of the dependency.'
field :enabled, GraphQL::Types::Boolean, null: true, description: 'Indicates the dependency is enabled.'
- field :import_values, [GraphQL::Types::JSON], null: true, description: 'Import-values of the dependency.', hash_key: :'import-values'
+ field :import_values, [GraphQL::Types::JSON], null: true, description: 'Import-values of the dependency.',
+ hash_key: :'import-values'
field :name, GraphQL::Types::String, null: true, description: 'Name of the dependency.'
field :repository, GraphQL::Types::String, null: true, description: 'Repository of the dependency.'
field :tags, [GraphQL::Types::String], null: true, description: 'Tags of the dependency.'
diff --git a/app/graphql/types/packages/helm/metadata_type.rb b/app/graphql/types/packages/helm/metadata_type.rb
index 77062a48bc3..7d769a9ef5f 100644
--- a/app/graphql/types/packages/helm/metadata_type.rb
+++ b/app/graphql/types/packages/helm/metadata_type.rb
@@ -10,17 +10,31 @@ module Types
# Need to be synced with app/validators/json_schemas/helm_metadata.json
field :annotations, GraphQL::Types::JSON, null: true, description: 'Annotations for the chart.' # rubocop:disable Graphql/JSONType
- field :api_version, GraphQL::Types::String, null: false, description: 'API version of the chart.', hash_key: :apiVersion
- field :app_version, GraphQL::Types::String, null: true, description: 'App version of the chart.', hash_key: :appVersion
+ field :api_version,
+ GraphQL::Types::String,
+ null: false,
+ description: 'API version of the chart.',
+ hash_key: :apiVersion
+ field :app_version,
+ GraphQL::Types::String,
+ null: true,
+ description: 'App version of the chart.',
+ hash_key: :appVersion
field :condition, GraphQL::Types::String, null: true, description: 'Condition for the chart.'
- field :dependencies, [Types::Packages::Helm::DependencyType], null: true, description: 'Dependencies of the chart.'
+ field :dependencies, [Types::Packages::Helm::DependencyType], null: true,
+ description: 'Dependencies of the chart.'
field :deprecated, GraphQL::Types::Boolean, null: true, description: 'Indicates if the chart is deprecated.'
field :description, GraphQL::Types::String, null: true, description: 'Description of the chart.'
field :home, GraphQL::Types::String, null: true, description: 'URL of the home page.'
field :icon, GraphQL::Types::String, null: true, description: 'URL to an SVG or PNG image for the chart.'
field :keywords, [GraphQL::Types::String], null: true, description: 'Keywords for the chart.'
- field :kube_version, GraphQL::Types::String, null: true, description: 'Kubernetes versions for the chart.', hash_key: :kubeVersion
- field :maintainers, [Types::Packages::Helm::MaintainerType], null: true, description: 'Maintainers of the chart.'
+ field :kube_version,
+ GraphQL::Types::String,
+ null: true,
+ description: 'Kubernetes versions for the chart.',
+ hash_key: :kubeVersion
+ field :maintainers, [Types::Packages::Helm::MaintainerType], null: true,
+ description: 'Maintainers of the chart.'
field :name, GraphQL::Types::String, null: false, description: 'Name of the chart.'
field :sources, [GraphQL::Types::String], null: true, description: 'URLs of the source code for the chart.'
field :tags, GraphQL::Types::String, null: true, description: 'Tags for the chart.'
diff --git a/app/graphql/types/packages/nuget/dependency_link_metadatum_type.rb b/app/graphql/types/packages/nuget/dependency_link_metadatum_type.rb
index f410e62b56a..a51f78ad294 100644
--- a/app/graphql/types/packages/nuget/dependency_link_metadatum_type.rb
+++ b/app/graphql/types/packages/nuget/dependency_link_metadatum_type.rb
@@ -9,8 +9,10 @@ module Types
authorize :read_package
- field :id, ::Types::GlobalIDType[::Packages::Nuget::DependencyLinkMetadatum], null: false, description: 'ID of the metadatum.'
- field :target_framework, GraphQL::Types::String, null: false, description: 'Target framework of the dependency link package.'
+ field :id, ::Types::GlobalIDType[::Packages::Nuget::DependencyLinkMetadatum], null: false,
+ description: 'ID of the metadatum.'
+ field :target_framework, GraphQL::Types::String, null: false,
+ description: 'Target framework of the dependency link package.'
end
end
end
diff --git a/app/graphql/types/packages/package_dependency_link_type.rb b/app/graphql/types/packages/package_dependency_link_type.rb
index 8b1d4abf3ba..d49c467a53d 100644
--- a/app/graphql/types/packages/package_dependency_link_type.rb
+++ b/app/graphql/types/packages/package_dependency_link_type.rb
@@ -9,7 +9,8 @@ module Types
field :dependency, Types::Packages::PackageDependencyType, null: true, description: 'Dependency.'
field :dependency_type, Types::Packages::PackageDependencyTypeEnum, null: false, description: 'Dependency type.'
- field :id, ::Types::GlobalIDType[::Packages::DependencyLink], null: false, description: 'ID of the dependency link.'
+ field :id, ::Types::GlobalIDType[::Packages::DependencyLink], null: false,
+ description: 'ID of the dependency link.'
field :metadata, Types::Packages::DependencyLinkMetadataType, null: true, description: 'Dependency link metadata.'
# NOTE: This method must be kept in sync with the union
diff --git a/app/graphql/types/packages/package_details_type.rb b/app/graphql/types/packages/package_details_type.rb
index 8b2600e10ff..a90b39b8f00 100644
--- a/app/graphql/types/packages/package_details_type.rb
+++ b/app/graphql/types/packages/package_details_type.rb
@@ -13,11 +13,17 @@ module Types
field :versions, ::Types::Packages::PackageBaseType.connection_type, null: true,
description: 'Other versions of the package.'
- field :package_files, Types::Packages::PackageFileType.connection_type, null: true, method: :installable_package_files, description: 'Package files.'
+ field :package_files,
+ Types::Packages::PackageFileType.connection_type,
+ null: true,
+ method: :installable_package_files,
+ description: 'Package files.'
- field :dependency_links, Types::Packages::PackageDependencyLinkType.connection_type, null: true, description: 'Dependency link.'
+ field :dependency_links, Types::Packages::PackageDependencyLinkType.connection_type, null: true,
+ description: 'Dependency link.'
- field :composer_config_repository_url, GraphQL::Types::String, null: true, description: 'Url of the Composer setup endpoint.'
+ field :composer_config_repository_url, GraphQL::Types::String, null: true,
+ description: 'Url of the Composer setup endpoint.'
field :composer_url, GraphQL::Types::String, null: true, description: 'Url of the Composer endpoint.'
field :conan_url, GraphQL::Types::String, null: true, description: 'Url of the Conan project endpoint.'
field :maven_url, GraphQL::Types::String, null: true, description: 'Url of the Maven project endpoint.'
@@ -26,9 +32,11 @@ module Types
field :pypi_setup_url, GraphQL::Types::String, null: true, description: 'Url of the PyPi project setup endpoint.'
field :pypi_url, GraphQL::Types::String, null: true, description: 'Url of the PyPi project endpoint.'
- field :last_downloaded_at, Types::TimeType, null: true, description: 'Last time that a file of this package was downloaded.'
+ field :last_downloaded_at, Types::TimeType, null: true,
+ description: 'Last time that a file of this package was downloaded.'
- field :public_package, GraphQL::Types::Boolean, null: true, description: 'Indicates if there is public access to the package.'
+ field :public_package, GraphQL::Types::Boolean, null: true,
+ description: 'Indicates if there is public access to the package.'
def composer_config_repository_url
composer_config_repository_name(object.project.group&.id)
diff --git a/app/graphql/types/packages/package_type_enum.rb b/app/graphql/types/packages/package_type_enum.rb
index 17145d8e000..8f0637aa6a4 100644
--- a/app/graphql/types/packages/package_type_enum.rb
+++ b/app/graphql/types/packages/package_type_enum.rb
@@ -11,7 +11,9 @@ module Types
::Packages::Package.package_types.keys.each do |package_type|
type_name = PACKAGE_TYPE_NAMES.fetch(package_type.to_sym, package_type.capitalize)
- value package_type.to_s.upcase, description: "Packages from the #{type_name} package manager", value: package_type.to_s
+ value package_type.to_s.upcase,
+ description: "Packages from the #{type_name} package manager",
+ value: package_type.to_s
end
end
end
diff --git a/app/graphql/types/packages/pypi/metadatum_type.rb b/app/graphql/types/packages/pypi/metadatum_type.rb
index 8ccdb592c52..34500c06172 100644
--- a/app/graphql/types/packages/pypi/metadatum_type.rb
+++ b/app/graphql/types/packages/pypi/metadatum_type.rb
@@ -18,7 +18,8 @@ module Types
field :id, ::Types::GlobalIDType[::Packages::Pypi::Metadatum], null: false, description: 'ID of the metadatum.'
field :keywords, GraphQL::Types::String, null: true, description: 'List of keywords, separated by commas.'
field :metadata_version, GraphQL::Types::String, null: true, description: 'Metadata version.'
- field :required_python, GraphQL::Types::String, null: true, description: 'Required Python version of the Pypi package.'
+ field :required_python, GraphQL::Types::String, null: true,
+ description: 'Required Python version of the Pypi package.'
field :summary, GraphQL::Types::String, null: true, description: 'One-line summary of the description.'
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 1271060c5b2..1367c600c13 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -190,7 +190,8 @@ module Types
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::Types::Boolean,
null: true,
- description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved.'
+ description: 'Indicates if merge requests of the project can only be merged ' \
+ 'when all the discussions are resolved.'
field :only_allow_merge_if_pipeline_succeeds, GraphQL::Types::Boolean,
null: true,
@@ -658,7 +659,8 @@ module Types
field :data_transfer, Types::DataTransfer::ProjectDataTransferType,
null: true, # disallow null once data_transfer_monitoring feature flag is rolled-out! https://gitlab.com/gitlab-org/gitlab/-/issues/391682
resolver: Resolvers::DataTransfer::ProjectDataTransferResolver,
- description: 'Data transfer data point for a specific period. This is mocked data under a development feature flag.'
+ description: 'Data transfer data point for a specific period. ' \
+ 'This is mocked data under a development feature flag.'
field :visible_forks, Types::ProjectType.connection_type,
null: true,
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 14e6d868f37..cb13332bb1d 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -336,7 +336,13 @@ module SearchHelper
# Autocomplete results for the current user's groups
def groups_autocomplete(term, limit = 5)
- current_user.authorized_groups.order_id_desc.search(term, use_minimum_char_limit: false).limit(limit).map do |group|
+ scope = if Feature.enabled?(:autocomplete_group_search_optimization, current_user)
+ current_user.search_on_authorized_groups(term, use_minimum_char_limit: false)
+ else
+ current_user.authorized_groups.search(term, use_minimum_char_limit: false)
+ end
+
+ scope.order_id_desc.limit(limit).map do |group|
{
category: "Groups",
id: group.id,
diff --git a/app/models/concerns/integrations/base/integration.rb b/app/models/concerns/integrations/base/integration.rb
new file mode 100644
index 00000000000..61d0cf62e89
--- /dev/null
+++ b/app/models/concerns/integrations/base/integration.rb
@@ -0,0 +1,821 @@
+# frozen_string_literal: true
+
+module Integrations
+ module Base
+ module Integration
+ extend ActiveSupport::Concern
+
+ UnknownType = Class.new(StandardError)
+
+ INTEGRATION_NAMES = %w[
+ asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker
+ datadog diffblue_cover discord drone_ci emails_on_push ewm external_wiki
+ gitlab_slack_application hangouts_chat harbor irker jira matrix
+ mattermost mattermost_slash_commands microsoft_teams packagist phorge pipelines_email
+ pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity telegram
+ unify_circuit webex_teams youtrack zentao
+ ].freeze
+
+ # Integrations that can only be enabled on the instance-level
+ INSTANCE_LEVEL_ONLY_INTEGRATION_NAMES = %w[
+ beyond_identity
+ ].freeze
+
+ # Integrations that can only be enabled on the project-level
+ PROJECT_LEVEL_ONLY_INTEGRATION_NAMES = %w[
+ apple_app_store google_play jenkins
+ ].freeze
+
+ # Integrations that cannot be enabled on the instance-level
+ PROJECT_AND_GROUP_LEVEL_ONLY_INTEGRATION_NAMES = %w[
+ jira_cloud_app
+ ].freeze
+
+ # Fake integrations to help with local development.
+ DEV_INTEGRATION_NAMES = %w[
+ mock_ci mock_monitoring
+ ].freeze
+
+ # Base classes which aren't actual integrations.
+ BASE_CLASSES = %w[
+ Integrations::BaseChatNotification
+ Integrations::BaseCi
+ Integrations::BaseIssueTracker
+ Integrations::BaseMonitoring
+ Integrations::BaseSlackNotification
+ Integrations::BaseSlashCommands
+ Integrations::BaseThirdPartyWiki
+ ].freeze
+
+ BASE_ATTRIBUTES = %w[id instance project_id group_id created_at updated_at
+ encrypted_properties encrypted_properties_iv properties].freeze
+
+ SECTION_TYPE_CONFIGURATION = 'configuration'
+ SECTION_TYPE_CONNECTION = 'connection'
+ SECTION_TYPE_TRIGGER = 'trigger'
+
+ SNOWPLOW_EVENT_ACTION = 'perform_integrations_action'
+ SNOWPLOW_EVENT_LABEL = 'redis_hll_counters.ecosystem.ecosystem_total_unique_counts_monthly'
+
+ included do
+ include Sortable
+ include Importable
+ include Integrations::Loggable
+ include Integrations::HasDataFields
+ include Integrations::ResetSecretFields
+ include FromUnion
+ include EachBatch
+ extend SafeFormatHelper
+ extend ::Gitlab::Utils::Override
+
+ self.allow_legacy_sti_class = true
+ self.inheritance_column = :type_new # rubocop:disable Database/AvoidInheritanceColumn -- existing code moved as is
+
+ attr_encrypted :properties,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ marshal: true,
+ marshaler: ::Gitlab::Json,
+ encode: false,
+ encode_iv: false
+
+ alias_attribute :name, :title
+ # Handle assignment of props with symbol keys.
+ # To do this correctly, we need to call the method generated by attr_encrypted.
+ alias_method :attr_encrypted_props=, :properties=
+ private :attr_encrypted_props=
+
+ def properties=(props)
+ self.attr_encrypted_props = props&.with_indifferent_access&.freeze
+ end
+
+ alias_attribute :type, :type_new
+
+ attribute :active, default: false
+ attribute :alert_events, default: true
+ attribute :incident_events, default: false
+ attribute :category, default: 'common'
+ attribute :commit_events, default: true
+ attribute :confidential_issues_events, default: true
+ attribute :confidential_note_events, default: true
+ attribute :deployment_events, default: false
+ attribute :issues_events, default: true
+ attribute :job_events, default: true
+ attribute :merge_requests_events, default: true
+ attribute :note_events, default: true
+ attribute :pipeline_events, default: true
+ attribute :push_events, default: true
+ attribute :tag_push_events, default: true
+ attribute :wiki_page_events, default: true
+ attribute :group_mention_events, default: false
+ attribute :group_confidential_mention_events, default: false
+
+ after_initialize :initialize_properties
+
+ after_commit :reset_updated_properties
+
+ belongs_to :project, inverse_of: :integrations
+ belongs_to :group, inverse_of: :integrations
+
+ validates :project_id, presence: true, unless: -> { instance_level? || group_level? }
+ validates :group_id, presence: true, unless: -> { instance_level? || project_level? }
+ validates :project_id, :group_id, absence: true, if: -> { instance_level? }
+ validates :type, presence: true, exclusion: BASE_CLASSES
+ validates :type, uniqueness: { scope: :instance }, if: :instance_level?
+ validates :type, uniqueness: { scope: :project_id }, if: :project_level?
+ validates :type, uniqueness: { scope: :group_id }, if: :group_level?
+ validate :validate_belongs_to_project_or_group
+
+ scope :external_issue_trackers, -> { where(category: 'issue_tracker').active }
+ scope :third_party_wikis, -> { where(category: 'third_party_wiki').active }
+ scope :by_name, ->(name) { by_type(integration_name_to_type(name)) }
+ scope :external_wikis, -> { by_name(:external_wiki).active }
+ scope :active, -> { where(active: true) }
+ scope :by_type, ->(type) { where(type: type) } # INTERNAL USE ONLY: use by_name instead
+ scope :by_active_flag, ->(flag) { where(active: flag) }
+ scope :inherit_from_id, ->(id) { where(inherit_from_id: id) }
+ scope :with_default_settings, -> { where.not(inherit_from_id: nil) }
+ scope :with_custom_settings, -> { where(inherit_from_id: nil) }
+ scope :for_group, ->(group) {
+ types = available_integration_types(include_project_specific: false)
+ where(group_id: group, type: types)
+ }
+
+ scope :for_instance, -> {
+ types = available_integration_types(include_project_specific: false, include_group_specific: false)
+ where(instance: true, type: types)
+ }
+
+ scope :push_hooks, -> { where(push_events: true, active: true) }
+ scope :tag_push_hooks, -> { where(tag_push_events: true, active: true) }
+ scope :issue_hooks, -> { where(issues_events: true, active: true) }
+ scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) }
+ scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) }
+ scope :note_hooks, -> { where(note_events: true, active: true) }
+ scope :confidential_note_hooks, -> { where(confidential_note_events: true, active: true) }
+ scope :job_hooks, -> { where(job_events: true, active: true) }
+ scope :archive_trace_hooks, -> { where(archive_trace_events: true, active: true) }
+ scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) }
+ scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
+ scope :deployment_hooks, -> { where(deployment_events: true, active: true) }
+ scope :alert_hooks, -> { where(alert_events: true, active: true) }
+ scope :incident_hooks, -> { where(incident_events: true, active: true) }
+ scope :deployment, -> { where(category: 'deployment') }
+ scope :group_mention_hooks, -> { where(group_mention_events: true, active: true) }
+ scope :group_confidential_mention_hooks, -> { where(group_confidential_mention_events: true, active: true) }
+ scope :exclusions_for_project, ->(project) { where(project: project, active: false) }
+
+ class << self
+ private
+
+ attr_writer :field_storage
+
+ def field_storage
+ @field_storage || :properties
+ end
+ end
+
+ # rubocop:disable Gitlab/NoCodeCoverageComment -- existing code moved as is
+ # :nocov: Tested on subclasses.
+ def self.field(name, storage: field_storage, **attrs)
+ fields << ::Integrations::Field.new(name: name, integration_class: self, **attrs)
+
+ case storage
+ when :attribute
+ # noop
+ when :properties
+ prop_accessor(name)
+ when :data_fields
+ data_field(name)
+ else
+ raise ArgumentError, "Unknown field storage: #{storage}"
+ end
+
+ boolean_accessor(name) if attrs[:type] == :checkbox && storage != :attribute
+ end
+ # :nocov:
+ # rubocop:enable Gitlab/NoCodeCoverageComment
+
+ def self.fields
+ @fields ||= []
+ end
+
+ def fields
+ self.class.fields.dup
+ end
+
+ # Provide convenient accessor methods for each serialized property.
+ # Also keep track of updated properties in a similar way as ActiveModel::Dirty
+ def self.prop_accessor(*args)
+ args.each do |arg|
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
+ unless method_defined?(arg)
+ def #{arg}
+ properties['#{arg}'] if properties.present?
+ end
+ end
+
+ def #{arg}=(value)
+ self.properties ||= {}
+ updated_properties['#{arg}'] = #{arg} unless #{arg}_changed?
+ self.properties = self.properties.merge('#{arg}' => value)
+ end
+
+ def #{arg}_changed?
+ #{arg}_touched? && #{arg} != #{arg}_was
+ end
+
+ def #{arg}_touched?
+ updated_properties.include?('#{arg}')
+ end
+
+ def #{arg}_was
+ updated_properties['#{arg}']
+ end
+ RUBY
+ end
+ end
+
+ # Provide convenient boolean accessor methods for each serialized property.
+ # Also keep track of updated properties in a similar way as ActiveModel::Dirty
+ def self.boolean_accessor(*args)
+ args.each do |arg|
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
+ # Make the original getter available as a private method.
+ alias_method :#{arg}_before_type_cast, :#{arg}
+ private(:#{arg}_before_type_cast)
+
+ def #{arg}
+ Gitlab::Utils.to_boolean(#{arg}_before_type_cast)
+ end
+
+ def #{arg}?
+ # '!!' is used because nil or empty string is converted to nil
+ !!#{arg}
+ end
+ RUBY
+ end
+ end
+ private_class_method :boolean_accessor
+
+ def self.title
+ raise NotImplementedError
+ end
+
+ def self.description
+ raise NotImplementedError
+ end
+
+ def self.help
+ # no-op
+ end
+
+ def self.to_param
+ raise NotImplementedError
+ end
+
+ def self.attribution_notice
+ # no-op
+ end
+
+ def self.event_names
+ supported_events.map { |event| IntegrationsHelper.integration_event_field_name(event) }
+ end
+
+ def self.supported_events
+ %w[commit push tag_push issue confidential_issue merge_request wiki_page]
+ end
+
+ def self.default_test_event
+ 'push'
+ end
+
+ def self.event_description(event)
+ IntegrationsHelper.integration_event_description(event)
+ end
+
+ def self.find_or_initialize_non_project_specific_integration(name, instance: false, group_id: nil)
+ return unless name.in?(available_integration_names(
+ include_project_specific: false,
+ include_group_specific: group_id.present?,
+ include_instance_specific: instance))
+
+ integration_name_to_model(name).find_or_initialize_by(instance: instance, group_id: group_id)
+ end
+
+ def self.find_or_initialize_all_non_project_specific(scope, include_instance_specific: false)
+ scope + build_nonexistent_integrations_for(scope,
+ include_group_specific: !include_instance_specific,
+ include_instance_specific: include_instance_specific)
+ end
+
+ def self.build_nonexistent_integrations_for(...)
+ nonexistent_integration_types_for(...).map do |type|
+ integration_type_to_model(type).new
+ end
+ end
+ private_class_method :build_nonexistent_integrations_for
+
+ # Returns a list of integration types that do not exist in the given scope.
+ # Example: ["AsanaService", ...]
+ def self.nonexistent_integration_types_for(
+ scope,
+ include_group_specific: false,
+ include_instance_specific: false)
+ # Using #map instead of #pluck to save one query count. This is because
+ # ActiveRecord loaded the object here, so we don't need to query again later.
+ available_integration_types(
+ include_project_specific: false,
+ include_group_specific: include_group_specific,
+ include_instance_specific: include_instance_specific
+ ) - scope.map(&:type)
+ end
+ private_class_method :nonexistent_integration_types_for
+
+ # Returns a list of available integration names.
+ # Example: ["asana", ...]
+ def self.available_integration_names(
+ include_project_specific: true,
+ include_group_specific: true,
+ include_instance_specific: true,
+ include_dev: true,
+ include_disabled: false)
+ names = integration_names.dup
+ names.concat(project_specific_integration_names) if include_project_specific
+ names.concat(dev_integration_names) if include_dev
+ names.concat(instance_specific_integration_names) if include_instance_specific
+
+ if include_project_specific || include_group_specific
+ names.concat(project_and_group_specific_integration_names)
+ end
+
+ names -= disabled_integration_names unless include_disabled
+
+ names.sort_by(&:downcase)
+ end
+
+ def self.integration_names
+ names = INTEGRATION_NAMES.dup
+
+ unless Feature.enabled?(:gitlab_for_slack_app_instance_and_group_level, type: :beta) && # rubocop:disable Gitlab/FeatureFlagWithoutActor -- existing code moved as is
+ (Gitlab::CurrentSettings.slack_app_enabled || Gitlab.dev_or_test_env?)
+ names.delete('gitlab_slack_application')
+ end
+
+ names
+ end
+
+ def self.instance_specific_integration_names
+ INSTANCE_LEVEL_ONLY_INTEGRATION_NAMES
+ end
+
+ def self.instance_specific_integration_types
+ instance_specific_integration_names.map { |name| integration_name_to_type(name) }
+ end
+
+ def self.dev_integration_names
+ return [] unless Gitlab.dev_or_test_env?
+
+ DEV_INTEGRATION_NAMES
+ end
+
+ def self.project_specific_integration_names
+ names = PROJECT_LEVEL_ONLY_INTEGRATION_NAMES.dup
+
+ if Feature.disabled?(:gitlab_for_slack_app_instance_and_group_level, type: :beta) && # rubocop:disable Gitlab/FeatureFlagWithoutActor -- existing code moved as is
+ (Gitlab::CurrentSettings.slack_app_enabled || Gitlab.dev_or_test_env?)
+ names << 'gitlab_slack_application'
+ end
+
+ names
+ end
+
+ def self.project_and_group_specific_integration_names
+ PROJECT_AND_GROUP_LEVEL_ONLY_INTEGRATION_NAMES.dup
+ end
+ private_class_method :project_and_group_specific_integration_names
+
+ # Returns a list of available integration types.
+ # Example: ["Integrations::Asana", ...]
+ def self.available_integration_types(...)
+ available_integration_names(...).map do # rubocop:disable Style/NumberedParameters -- existing code moved as is
+ integration_name_to_type(_1)
+ end
+ end
+
+ # Returns a list of disabled integration names.
+ # Example: ["gitlab_slack_application", ...]
+ def self.disabled_integration_names
+ # The GitLab for Slack app integration is only available when enabled through settings.
+ # The Slack Slash Commands integration is only available for customers
+ # who cannot use the GitLab for Slack app.
+ disabled = Gitlab::CurrentSettings.slack_app_enabled ? ['slack_slash_commands'] : ['gitlab_slack_application']
+ disabled += ['jira_cloud_app'] unless Gitlab::CurrentSettings.jira_connect_application_key.present?
+ disabled
+ end
+ private_class_method :disabled_integration_names
+
+ # Returns the model for the given integration name.
+ # Example: :asana => Integrations::Asana
+ def self.integration_name_to_model(name)
+ type = integration_name_to_type(name)
+ integration_type_to_model(type)
+ end
+
+ # Returns the STI type for the given integration name.
+ # Example: "asana" => "Integrations::Asana"
+ def self.integration_name_to_type(name)
+ name = name.to_s
+ if available_integration_names(include_disabled: true).exclude?(name)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(UnknownType.new(name.inspect))
+ else
+ "Integrations::#{name.camelize}"
+ end
+ end
+
+ # Returns the model for the given STI type.
+ # Example: "Integrations::Asana" => Integrations::Asana
+ def self.integration_type_to_model(type)
+ type.constantize
+ end
+ private_class_method :integration_type_to_model
+
+ def self.build_from_integration(integration, project_id: nil, group_id: nil)
+ new_integration = integration.dup
+
+ new_integration.instance = false
+ new_integration.project_id = project_id
+ new_integration.group_id = group_id
+ new_integration.inherit_from_id = integration.id if integration.inheritable?
+ new_integration
+ end
+
+ # Duplicating an integration also duplicates the data fields. Duped records have different ciphertexts.
+ override :dup
+ def dup
+ new_integration = super
+ new_integration.assign_attributes(reencrypt_properties)
+
+ if supports_data_fields?
+ fields = data_fields.dup
+ fields.integration = new_integration
+ end
+
+ new_integration
+ end
+
+ def inheritable?
+ instance_level? || group_level?
+ end
+
+ def self.instance_exists_for?(type)
+ exists?(instance: true, type: type)
+ end
+
+ def self.default_integration(type, scope)
+ closest_group_integration(type, scope) || instance_level_integration(type)
+ end
+
+ def self.closest_group_integration(type, scope)
+ group_ids = scope.ancestors(hierarchy_order: :asc).reselect(:id)
+ array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
+
+ where(type: type, group_id: group_ids, inherit_from_id: nil)
+ .order(Arel.sql("array_position(#{array}::bigint[], #{table_name}.group_id)"))
+ .first
+ end
+ private_class_method :closest_group_integration
+
+ def self.instance_level_integration(type)
+ find_by(type: type, instance: true)
+ end
+ private_class_method :instance_level_integration
+
+ def self.default_integrations(owner, scope)
+ group_ids = sorted_ancestors(owner).select(:id)
+ array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
+ order = Arel.sql("type_new ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")
+ from_union([scope.where(instance: true), scope.where(group_id: group_ids, inherit_from_id: nil)])
+ .order(order)
+ .group_by(&:type)
+ .transform_values(&:first)
+ end
+ private_class_method :default_integrations
+
+ def self.create_from_default_integrations(owner, association)
+ active_default_count = create_from_active_default_integrations(owner, association)
+ default_instance_specific_count = create_from_default_instance_specific_integrations(owner, association)
+ active_default_count + default_instance_specific_count
+ end
+
+ # Returns the number of successfully saved integrations
+ # Duplicate integrations are excluded from this count by their validations.
+ def self.create_from_active_default_integrations(owner, association)
+ default_integrations(
+ owner,
+ active.where.not(type: instance_specific_integration_types)
+ ).count { |_type, integration| build_from_integration(integration, association => owner.id).save }
+ end
+
+ def self.create_from_default_instance_specific_integrations(owner, association)
+ default_integrations(
+ owner,
+ where(type: instance_specific_integration_types)
+ ).count { |_type, integration| build_from_integration(integration, association => owner.id).save }
+ end
+
+ def self.descendants_from_self_or_ancestors_from(integration)
+ scope = where(type: integration.type)
+ from_union([
+ scope.where(group: integration.group.descendants),
+ scope.where(project: Project.in_namespace(integration.group.self_and_descendants))
+ ])
+ end
+
+ def self.inherited_descendants_from_self_or_ancestors_from(integration)
+ inherit_from_ids =
+ where(type: integration.type, group: integration.group.self_and_ancestors)
+ .or(where(type: integration.type, instance: true)).select(:id)
+
+ from_union([
+ where(type: integration.type, inherit_from_id: inherit_from_ids, group: integration.group.descendants),
+ where(
+ type: integration.type,
+ inherit_from_id: inherit_from_ids,
+ project: Project.in_namespace(integration.group.self_and_descendants)
+ )
+ ])
+ end
+
+ def activated?
+ active
+ end
+
+ def operating?
+ active && persisted?
+ end
+
+ def manual_activation?
+ true
+ end
+
+ def editable?
+ true
+ end
+
+ def activate_disabled_reason
+ nil
+ end
+
+ def category
+ read_attribute(:category).to_sym
+ end
+
+ def initialize_properties
+ self.properties = {} if has_attribute?(:encrypted_properties) && encrypted_properties.nil?
+ end
+
+ def title
+ self.class.title
+ end
+
+ def description
+ self.class.description
+ end
+
+ def help
+ self.class.help
+ end
+
+ def to_param
+ self.class.to_param
+ end
+
+ def attribution_notice
+ self.class.attribution_notice
+ end
+
+ def sections
+ []
+ end
+
+ def secret_fields
+ fields.select(&:secret?).pluck(:name) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- existing code moved as is
+ end
+
+ # Expose a list of fields in the JSON endpoint.
+ #
+ # This list is used in `Integration#as_json(only: json_fields)`.
+ def json_fields
+ %w[active]
+ end
+
+ # properties is always nil - ignore it.
+ override :attributes
+ def attributes
+ super.except('properties')
+ end
+
+ # Returns a hash of attributes (columns => values) used for inserting into the database.
+ def to_database_hash
+ column = self.class.attribute_aliases.fetch('type', 'type')
+
+ attributes_for_database.except(*BASE_ATTRIBUTES)
+ .merge(column => type)
+ .merge(reencrypt_properties)
+ end
+
+ def reencrypt_properties
+ unless properties.nil? || properties.empty?
+ alg = self.class.attr_encrypted_attributes[:properties][:algorithm]
+ iv = generate_iv(alg)
+ ep = self.class.attr_encrypt(:properties, properties, { iv: iv })
+ end
+
+ { 'encrypted_properties' => ep, 'encrypted_properties_iv' => iv }
+ end
+
+ def event_channel_names
+ []
+ end
+
+ def event_names
+ self.class.event_names
+ end
+
+ def api_field_names
+ fields # rubocop:disable Style/NumberedParameters -- existing code moved as is
+ .reject { _1[:type] == :password || _1[:name] == 'webhook' || (_1.key?(:if) && _1[:if] != true) } # rubocop:disable Style/NumberedParameters -- existing code moved as is
+ .pluck(:name) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- existing code moved as is
+ end
+
+ def self.api_arguments
+ fields.filter_map do |field|
+ next if field.if != true
+
+ {
+ required: field.required?,
+ name: field.name.to_sym,
+ type: field.api_type,
+ desc: field.description
+ }
+ end
+ end
+
+ def self.instance_specific?
+ false
+ end
+
+ def self.pluck_group_id
+ pluck(:group_id) # rubocop:disable Database/AvoidUsingPluckWithoutLimit -- existing code moved as is
+ end
+
+ def form_fields
+ fields.reject { _1[:api_only] == true || (_1.key?(:if) && _1[:if] != true) } # rubocop:disable Style/NumberedParameters -- existing code moved as is
+ end
+
+ def configurable_events
+ events = supported_events
+
+ # No need to disable individual triggers when there is only one
+ if events.count == 1
+ []
+ else
+ events
+ end
+ end
+
+ def supported_events
+ self.class.supported_events
+ end
+
+ def default_test_event
+ self.class.default_test_event
+ end
+
+ def execute(data)
+ # implement inside child
+ end
+
+ def test(data)
+ # default implementation
+ result = execute(data)
+ { success: result.present?, result: result }
+ end
+
+ # Disable test for instance-level and group-level integrations.
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/213138
+ def testable?
+ project_level?
+ end
+
+ def project_level?
+ project_id.present?
+ end
+
+ def group_level?
+ group_id.present?
+ end
+
+ def instance_level?
+ instance?
+ end
+
+ def parent
+ project || group
+ end
+
+ # Returns a hash of the properties that have been assigned a new value since last save,
+ # indicating their original values (attr => original value).
+ # ActiveRecord does not provide a mechanism to track changes in serialized keys,
+ # so we need a specific implementation for integration properties.
+ # This allows to track changes to properties set with the accessor methods,
+ # but not direct manipulation of properties hash.
+ def updated_properties
+ @updated_properties ||= ActiveSupport::HashWithIndifferentAccess.new
+ end
+
+ def reset_updated_properties
+ @updated_properties = nil
+ end
+
+ def async_execute(data)
+ return if ::Gitlab::SilentMode.enabled?
+
+ # Temporarily log when we return within this method to gather data for
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/382999
+ unless supported_events.include?(data[:object_kind])
+ log_info(
+ 'async_execute did nothing due to event not being supported',
+ event: data[:object_kind]
+ )
+ return
+ end
+
+ Integrations::ExecuteWorker.perform_async(id, data.deep_stringify_keys)
+ end
+
+ # override if needed
+ def supports_data_fields?
+ false
+ end
+
+ def chat?
+ category == :chat
+ end
+
+ def ci?
+ category == :ci
+ end
+
+ def deactivate!
+ update(active: false)
+ end
+
+ def activate!
+ update(active: true)
+ end
+
+ def toggle!
+ active? ? deactivate! : activate!
+ end
+
+ private
+
+ def self.build_help_page_url(url_path, help_text, link_text = _("Learn More"), options = {})
+ docs_link = ActionController::Base.helpers.link_to(
+ '',
+ Rails.application.routes.url_helpers.help_page_url(url_path, **options), # rubocop:disable Gitlab/DocumentationLinks/Link: -- existing code moved as is
+ target: '_blank',
+ rel: 'noopener noreferrer'
+ )
+ tag_pair_docs_link = tag_pair(docs_link, :link_start, :link_end)
+
+ safe_format(help_text + " %{link_start}#{link_text}%{link_end}.", tag_pair_docs_link)
+ end
+
+ # Ancestors sorted by hierarchy depth in bottom-top order.
+ def self.sorted_ancestors(scope)
+ if scope.root_ancestor.use_traversal_ids?
+ Namespace.from(scope.ancestors(hierarchy_order: :asc))
+ else
+ scope.ancestors
+ end
+ end
+
+ def validate_belongs_to_project_or_group
+ return unless project_level? && group_level?
+
+ errors.add(:project_id, 'The integration cannot belong to both a project and a group')
+ end
+
+ def validate_recipients?
+ activated? && !importing?
+ end
+ end
+ end
+ end
+end
+
+Integration.prepend_mod_with('Integration')
diff --git a/app/models/integration.rb b/app/models/integration.rb
index 3a9a017f74d..c0b897fd5ce 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -3,796 +3,5 @@
# To add new integration you should build a class inherited from Integration
# and implement a set of methods
class Integration < ApplicationRecord
- include Sortable
- include Importable
- include Integrations::Loggable
- include Integrations::HasDataFields
- include Integrations::ResetSecretFields
- include FromUnion
- include EachBatch
- extend SafeFormatHelper
- extend ::Gitlab::Utils::Override
-
- UnknownType = Class.new(StandardError)
-
- self.allow_legacy_sti_class = true
- self.inheritance_column = :type_new
-
- INTEGRATION_NAMES = %w[
- asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker
- datadog diffblue_cover discord drone_ci emails_on_push ewm external_wiki
- gitlab_slack_application hangouts_chat harbor irker jira matrix
- mattermost mattermost_slash_commands microsoft_teams packagist phorge pipelines_email
- pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity telegram
- unify_circuit webex_teams youtrack zentao
- ].freeze
-
- # Integrations that can only be enabled on the instance-level
- INSTANCE_LEVEL_ONLY_INTEGRATION_NAMES = %w[
- beyond_identity
- ].freeze
-
- # Integrations that can only be enabled on the project-level
- PROJECT_LEVEL_ONLY_INTEGRATION_NAMES = %w[
- apple_app_store google_play jenkins
- ].freeze
-
- # Integrations that cannot be enabled on the instance-level
- PROJECT_AND_GROUP_LEVEL_ONLY_INTEGRATION_NAMES = %w[
- jira_cloud_app
- ].freeze
-
- # Fake integrations to help with local development.
- DEV_INTEGRATION_NAMES = %w[
- mock_ci mock_monitoring
- ].freeze
-
- # Base classes which aren't actual integrations.
- BASE_CLASSES = %w[
- Integrations::BaseChatNotification
- Integrations::BaseCi
- Integrations::BaseIssueTracker
- Integrations::BaseMonitoring
- Integrations::BaseSlackNotification
- Integrations::BaseSlashCommands
- Integrations::BaseThirdPartyWiki
- ].freeze
-
- BASE_ATTRIBUTES = %w[id instance project_id group_id created_at updated_at
- encrypted_properties encrypted_properties_iv properties].freeze
-
- SECTION_TYPE_CONFIGURATION = 'configuration'
- SECTION_TYPE_CONNECTION = 'connection'
- SECTION_TYPE_TRIGGER = 'trigger'
-
- SNOWPLOW_EVENT_ACTION = 'perform_integrations_action'
- SNOWPLOW_EVENT_LABEL = 'redis_hll_counters.ecosystem.ecosystem_total_unique_counts_monthly'
-
- attr_encrypted :properties,
- mode: :per_attribute_iv,
- key: Settings.attr_encrypted_db_key_base_32,
- algorithm: 'aes-256-gcm',
- marshal: true,
- marshaler: ::Gitlab::Json,
- encode: false,
- encode_iv: false
-
- alias_attribute :name, :title
- # Handle assignment of props with symbol keys.
- # To do this correctly, we need to call the method generated by attr_encrypted.
- alias_method :attr_encrypted_props=, :properties=
- private :attr_encrypted_props=
-
- def properties=(props)
- self.attr_encrypted_props = props&.with_indifferent_access&.freeze
- end
-
- alias_attribute :type, :type_new
-
- attribute :active, default: false
- attribute :alert_events, default: true
- attribute :incident_events, default: false
- attribute :category, default: 'common'
- attribute :commit_events, default: true
- attribute :confidential_issues_events, default: true
- attribute :confidential_note_events, default: true
- attribute :deployment_events, default: false
- attribute :issues_events, default: true
- attribute :job_events, default: true
- attribute :merge_requests_events, default: true
- attribute :note_events, default: true
- attribute :pipeline_events, default: true
- attribute :push_events, default: true
- attribute :tag_push_events, default: true
- attribute :wiki_page_events, default: true
- attribute :group_mention_events, default: false
- attribute :group_confidential_mention_events, default: false
-
- after_initialize :initialize_properties
-
- after_commit :reset_updated_properties
-
- belongs_to :project, inverse_of: :integrations
- belongs_to :group, inverse_of: :integrations
-
- validates :project_id, presence: true, unless: -> { instance_level? || group_level? }
- validates :group_id, presence: true, unless: -> { instance_level? || project_level? }
- validates :project_id, :group_id, absence: true, if: -> { instance_level? }
- validates :type, presence: true, exclusion: BASE_CLASSES
- validates :type, uniqueness: { scope: :instance }, if: :instance_level?
- validates :type, uniqueness: { scope: :project_id }, if: :project_level?
- validates :type, uniqueness: { scope: :group_id }, if: :group_level?
- validate :validate_belongs_to_project_or_group
-
- scope :external_issue_trackers, -> { where(category: 'issue_tracker').active }
- scope :third_party_wikis, -> { where(category: 'third_party_wiki').active }
- scope :by_name, ->(name) { by_type(integration_name_to_type(name)) }
- scope :external_wikis, -> { by_name(:external_wiki).active }
- scope :active, -> { where(active: true) }
- scope :by_type, ->(type) { where(type: type) } # INTERNAL USE ONLY: use by_name instead
- scope :by_active_flag, ->(flag) { where(active: flag) }
- scope :inherit_from_id, ->(id) { where(inherit_from_id: id) }
- scope :with_default_settings, -> { where.not(inherit_from_id: nil) }
- scope :with_custom_settings, -> { where(inherit_from_id: nil) }
- scope :for_group, ->(group) {
- types = available_integration_types(include_project_specific: false)
- where(group_id: group, type: types)
- }
-
- scope :for_instance, -> {
- types = available_integration_types(include_project_specific: false, include_group_specific: false)
- where(instance: true, type: types)
- }
-
- scope :push_hooks, -> { where(push_events: true, active: true) }
- scope :tag_push_hooks, -> { where(tag_push_events: true, active: true) }
- scope :issue_hooks, -> { where(issues_events: true, active: true) }
- scope :confidential_issue_hooks, -> { where(confidential_issues_events: true, active: true) }
- scope :merge_request_hooks, -> { where(merge_requests_events: true, active: true) }
- scope :note_hooks, -> { where(note_events: true, active: true) }
- scope :confidential_note_hooks, -> { where(confidential_note_events: true, active: true) }
- scope :job_hooks, -> { where(job_events: true, active: true) }
- scope :archive_trace_hooks, -> { where(archive_trace_events: true, active: true) }
- scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) }
- scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
- scope :deployment_hooks, -> { where(deployment_events: true, active: true) }
- scope :alert_hooks, -> { where(alert_events: true, active: true) }
- scope :incident_hooks, -> { where(incident_events: true, active: true) }
- scope :deployment, -> { where(category: 'deployment') }
- scope :group_mention_hooks, -> { where(group_mention_events: true, active: true) }
- scope :group_confidential_mention_hooks, -> { where(group_confidential_mention_events: true, active: true) }
- scope :exclusions_for_project, ->(project) { where(project: project, active: false) }
-
- class << self
- private
-
- attr_writer :field_storage
-
- def field_storage
- @field_storage || :properties
- end
- end
-
- # :nocov: Tested on subclasses.
- def self.field(name, storage: field_storage, **attrs)
- fields << ::Integrations::Field.new(name: name, integration_class: self, **attrs)
-
- case storage
- when :attribute
- # noop
- when :properties
- prop_accessor(name)
- when :data_fields
- data_field(name)
- else
- raise ArgumentError, "Unknown field storage: #{storage}"
- end
-
- boolean_accessor(name) if attrs[:type] == :checkbox && storage != :attribute
- end
- # :nocov:
-
- def self.fields
- @fields ||= []
- end
-
- def fields
- self.class.fields.dup
- end
-
- # Provide convenient accessor methods for each serialized property.
- # Also keep track of updated properties in a similar way as ActiveModel::Dirty
- def self.prop_accessor(*args)
- args.each do |arg|
- class_eval <<~RUBY, __FILE__, __LINE__ + 1
- unless method_defined?(arg)
- def #{arg}
- properties['#{arg}'] if properties.present?
- end
- end
-
- def #{arg}=(value)
- self.properties ||= {}
- updated_properties['#{arg}'] = #{arg} unless #{arg}_changed?
- self.properties = self.properties.merge('#{arg}' => value)
- end
-
- def #{arg}_changed?
- #{arg}_touched? && #{arg} != #{arg}_was
- end
-
- def #{arg}_touched?
- updated_properties.include?('#{arg}')
- end
-
- def #{arg}_was
- updated_properties['#{arg}']
- end
- RUBY
- end
- end
-
- # Provide convenient boolean accessor methods for each serialized property.
- # Also keep track of updated properties in a similar way as ActiveModel::Dirty
- def self.boolean_accessor(*args)
- args.each do |arg|
- class_eval <<~RUBY, __FILE__, __LINE__ + 1
- # Make the original getter available as a private method.
- alias_method :#{arg}_before_type_cast, :#{arg}
- private(:#{arg}_before_type_cast)
-
- def #{arg}
- Gitlab::Utils.to_boolean(#{arg}_before_type_cast)
- end
-
- def #{arg}?
- # '!!' is used because nil or empty string is converted to nil
- !!#{arg}
- end
- RUBY
- end
- end
- private_class_method :boolean_accessor
-
- def self.title
- raise NotImplementedError
- end
-
- def self.description
- raise NotImplementedError
- end
-
- def self.help
- # no-op
- end
-
- def self.to_param
- raise NotImplementedError
- end
-
- def self.attribution_notice
- # no-op
- end
-
- def self.event_names
- supported_events.map { |event| IntegrationsHelper.integration_event_field_name(event) }
- end
-
- def self.supported_events
- %w[commit push tag_push issue confidential_issue merge_request wiki_page]
- end
-
- def self.default_test_event
- 'push'
- end
-
- def self.event_description(event)
- IntegrationsHelper.integration_event_description(event)
- end
-
- def self.find_or_initialize_non_project_specific_integration(name, instance: false, group_id: nil)
- return unless name.in?(available_integration_names(
- include_project_specific: false,
- include_group_specific: group_id.present?,
- include_instance_specific: instance))
-
- integration_name_to_model(name).find_or_initialize_by(instance: instance, group_id: group_id)
- end
-
- def self.find_or_initialize_all_non_project_specific(scope, include_instance_specific: false)
- scope + build_nonexistent_integrations_for(scope,
- include_group_specific: !include_instance_specific,
- include_instance_specific: include_instance_specific)
- end
-
- def self.build_nonexistent_integrations_for(...)
- nonexistent_integration_types_for(...).map do |type|
- integration_type_to_model(type).new
- end
- end
- private_class_method :build_nonexistent_integrations_for
-
- # Returns a list of integration types that do not exist in the given scope.
- # Example: ["AsanaService", ...]
- def self.nonexistent_integration_types_for(scope, include_group_specific: false, include_instance_specific: false)
- # Using #map instead of #pluck to save one query count. This is because
- # ActiveRecord loaded the object here, so we don't need to query again later.
- available_integration_types(
- include_project_specific: false,
- include_group_specific: include_group_specific,
- include_instance_specific: include_instance_specific
- ) - scope.map(&:type)
- end
- private_class_method :nonexistent_integration_types_for
-
- # Returns a list of available integration names.
- # Example: ["asana", ...]
- def self.available_integration_names(
- include_project_specific: true, include_group_specific: true, include_instance_specific: true, include_dev: true,
- include_disabled: false
- )
- names = integration_names.dup
- names.concat(project_specific_integration_names) if include_project_specific
- names.concat(dev_integration_names) if include_dev
- names.concat(instance_specific_integration_names) if include_instance_specific
- names.concat(project_and_group_specific_integration_names) if include_project_specific || include_group_specific
- names -= disabled_integration_names unless include_disabled
-
- names.sort_by(&:downcase)
- end
-
- def self.integration_names
- names = INTEGRATION_NAMES.dup
-
- unless Feature.enabled?(:gitlab_for_slack_app_instance_and_group_level, type: :beta) &&
- (Gitlab::CurrentSettings.slack_app_enabled || Gitlab.dev_or_test_env?)
- names.delete('gitlab_slack_application')
- end
-
- names
- end
-
- def self.instance_specific_integration_names
- INSTANCE_LEVEL_ONLY_INTEGRATION_NAMES
- end
-
- def self.instance_specific_integration_types
- instance_specific_integration_names.map { |name| integration_name_to_type(name) }
- end
-
- def self.dev_integration_names
- return [] unless Gitlab.dev_or_test_env?
-
- DEV_INTEGRATION_NAMES
- end
-
- def self.project_specific_integration_names
- names = PROJECT_LEVEL_ONLY_INTEGRATION_NAMES.dup
-
- if Feature.disabled?(:gitlab_for_slack_app_instance_and_group_level, type: :beta) &&
- (Gitlab::CurrentSettings.slack_app_enabled || Gitlab.dev_or_test_env?)
- names << 'gitlab_slack_application'
- end
-
- names
- end
-
- def self.project_and_group_specific_integration_names
- PROJECT_AND_GROUP_LEVEL_ONLY_INTEGRATION_NAMES.dup
- end
- private_class_method :project_and_group_specific_integration_names
-
- # Returns a list of available integration types.
- # Example: ["Integrations::Asana", ...]
- def self.available_integration_types(...)
- available_integration_names(...).map do
- integration_name_to_type(_1)
- end
- end
-
- # Returns a list of disabled integration names.
- # Example: ["gitlab_slack_application", ...]
- def self.disabled_integration_names
- # The GitLab for Slack app integration is only available when enabled through settings.
- # The Slack Slash Commands integration is only available for customers who cannot use the GitLab for Slack app.
- disabled = Gitlab::CurrentSettings.slack_app_enabled ? ['slack_slash_commands'] : ['gitlab_slack_application']
- disabled += ['jira_cloud_app'] unless Gitlab::CurrentSettings.jira_connect_application_key.present?
- disabled
- end
- private_class_method :disabled_integration_names
-
- # Returns the model for the given integration name.
- # Example: :asana => Integrations::Asana
- def self.integration_name_to_model(name)
- type = integration_name_to_type(name)
- integration_type_to_model(type)
- end
-
- # Returns the STI type for the given integration name.
- # Example: "asana" => "Integrations::Asana"
- def self.integration_name_to_type(name)
- name = name.to_s
- if available_integration_names(include_disabled: true).exclude?(name)
- Gitlab::ErrorTracking.track_and_raise_for_dev_exception(UnknownType.new(name.inspect))
- else
- "Integrations::#{name.camelize}"
- end
- end
-
- # Returns the model for the given STI type.
- # Example: "Integrations::Asana" => Integrations::Asana
- def self.integration_type_to_model(type)
- type.constantize
- end
- private_class_method :integration_type_to_model
-
- def self.build_from_integration(integration, project_id: nil, group_id: nil)
- new_integration = integration.dup
-
- new_integration.instance = false
- new_integration.project_id = project_id
- new_integration.group_id = group_id
- new_integration.inherit_from_id = integration.id if integration.inheritable?
- new_integration
- end
-
- # Duplicating an integration also duplicates the data fields. Duped records have different ciphertexts.
- override :dup
- def dup
- new_integration = super
- new_integration.assign_attributes(reencrypt_properties)
-
- if supports_data_fields?
- fields = data_fields.dup
- fields.integration = new_integration
- end
-
- new_integration
- end
-
- def inheritable?
- instance_level? || group_level?
- end
-
- def self.instance_exists_for?(type)
- exists?(instance: true, type: type)
- end
-
- def self.default_integration(type, scope)
- closest_group_integration(type, scope) || instance_level_integration(type)
- end
-
- def self.closest_group_integration(type, scope)
- group_ids = scope.ancestors(hierarchy_order: :asc).reselect(:id)
- array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
-
- where(type: type, group_id: group_ids, inherit_from_id: nil)
- .order(Arel.sql("array_position(#{array}::bigint[], #{table_name}.group_id)"))
- .first
- end
- private_class_method :closest_group_integration
-
- def self.instance_level_integration(type)
- find_by(type: type, instance: true)
- end
- private_class_method :instance_level_integration
-
- def self.default_integrations(owner, scope)
- group_ids = sorted_ancestors(owner).select(:id)
- array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]'
- order = Arel.sql("type_new ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")
- from_union([scope.where(instance: true), scope.where(group_id: group_ids, inherit_from_id: nil)])
- .order(order)
- .group_by(&:type)
- .transform_values(&:first)
- end
- private_class_method :default_integrations
-
- def self.create_from_default_integrations(owner, association)
- active_default_count = create_from_active_default_integrations(owner, association)
- default_instance_specific_count = create_from_default_instance_specific_integrations(owner, association)
- active_default_count + default_instance_specific_count
- end
-
- # Returns the number of successfully saved integrations
- # Duplicate integrations are excluded from this count by their validations.
- def self.create_from_active_default_integrations(owner, association)
- default_integrations(
- owner,
- active.where.not(type: instance_specific_integration_types)
- ).count { |_type, integration| build_from_integration(integration, association => owner.id).save }
- end
-
- def self.create_from_default_instance_specific_integrations(owner, association)
- default_integrations(
- owner,
- where(type: instance_specific_integration_types)
- ).count { |_type, integration| build_from_integration(integration, association => owner.id).save }
- end
-
- def self.descendants_from_self_or_ancestors_from(integration)
- scope = where(type: integration.type)
- from_union([
- scope.where(group: integration.group.descendants),
- scope.where(project: Project.in_namespace(integration.group.self_and_descendants))
- ])
- end
-
- def self.inherited_descendants_from_self_or_ancestors_from(integration)
- inherit_from_ids =
- where(type: integration.type, group: integration.group.self_and_ancestors)
- .or(where(type: integration.type, instance: true)).select(:id)
-
- from_union([
- where(type: integration.type, inherit_from_id: inherit_from_ids, group: integration.group.descendants),
- where(type: integration.type, inherit_from_id: inherit_from_ids,
- project: Project.in_namespace(integration.group.self_and_descendants))
- ])
- end
-
- def activated?
- active
- end
-
- def operating?
- active && persisted?
- end
-
- def manual_activation?
- true
- end
-
- def editable?
- true
- end
-
- def activate_disabled_reason
- nil
- end
-
- def category
- read_attribute(:category).to_sym
- end
-
- def initialize_properties
- self.properties = {} if has_attribute?(:encrypted_properties) && encrypted_properties.nil?
- end
-
- def title
- self.class.title
- end
-
- def description
- self.class.description
- end
-
- def help
- self.class.help
- end
-
- def to_param
- self.class.to_param
- end
-
- def attribution_notice
- self.class.attribution_notice
- end
-
- def sections
- []
- end
-
- def secret_fields
- fields.select(&:secret?).pluck(:name)
- end
-
- # Expose a list of fields in the JSON endpoint.
- #
- # This list is used in `Integration#as_json(only: json_fields)`.
- def json_fields
- %w[active]
- end
-
- # properties is always nil - ignore it.
- override :attributes
- def attributes
- super.except('properties')
- end
-
- # Returns a hash of attributes (columns => values) used for inserting into the database.
- def to_database_hash
- column = self.class.attribute_aliases.fetch('type', 'type')
-
- attributes_for_database.except(*BASE_ATTRIBUTES)
- .merge(column => type)
- .merge(reencrypt_properties)
- end
-
- def reencrypt_properties
- unless properties.nil? || properties.empty?
- alg = self.class.attr_encrypted_attributes[:properties][:algorithm]
- iv = generate_iv(alg)
- ep = self.class.attr_encrypt(:properties, properties, { iv: iv })
- end
-
- { 'encrypted_properties' => ep, 'encrypted_properties_iv' => iv }
- end
-
- def event_channel_names
- []
- end
-
- def event_names
- self.class.event_names
- end
-
- def api_field_names
- fields.reject { _1[:type] == :password || _1[:name] == 'webhook' || (_1.key?(:if) && _1[:if] != true) }.pluck(:name)
- end
-
- def self.api_arguments
- fields.filter_map do |field|
- next if field.if != true
-
- {
- required: field.required?,
- name: field.name.to_sym,
- type: field.api_type,
- desc: field.description
- }
- end
- end
-
- def self.instance_specific?
- false
- end
-
- def self.pluck_group_id
- pluck(:group_id)
- end
-
- def form_fields
- fields.reject { _1[:api_only] == true || (_1.key?(:if) && _1[:if] != true) }
- end
-
- def configurable_events
- events = supported_events
-
- # No need to disable individual triggers when there is only one
- if events.count == 1
- []
- else
- events
- end
- end
-
- def supported_events
- self.class.supported_events
- end
-
- def default_test_event
- self.class.default_test_event
- end
-
- def execute(data)
- # implement inside child
- end
-
- def test(data)
- # default implementation
- result = execute(data)
- { success: result.present?, result: result }
- end
-
- # Disable test for instance-level and group-level integrations.
- # https://gitlab.com/gitlab-org/gitlab/-/issues/213138
- def testable?
- project_level?
- end
-
- def project_level?
- project_id.present?
- end
-
- def group_level?
- group_id.present?
- end
-
- def instance_level?
- instance?
- end
-
- def parent
- project || group
- end
-
- # Returns a hash of the properties that have been assigned a new value since last save,
- # indicating their original values (attr => original value).
- # ActiveRecord does not provide a mechanism to track changes in serialized keys,
- # so we need a specific implementation for integration properties.
- # This allows to track changes to properties set with the accessor methods,
- # but not direct manipulation of properties hash.
- def updated_properties
- @updated_properties ||= ActiveSupport::HashWithIndifferentAccess.new
- end
-
- def reset_updated_properties
- @updated_properties = nil
- end
-
- def async_execute(data)
- return if ::Gitlab::SilentMode.enabled?
-
- # Temporarily log when we return within this method to gather data for
- # https://gitlab.com/gitlab-org/gitlab/-/issues/382999
- unless supported_events.include?(data[:object_kind])
- log_info(
- 'async_execute did nothing due to event not being supported',
- event: data[:object_kind]
- )
- return
- end
-
- Integrations::ExecuteWorker.perform_async(id, data.deep_stringify_keys)
- end
-
- # override if needed
- def supports_data_fields?
- false
- end
-
- def chat?
- category == :chat
- end
-
- def ci?
- category == :ci
- end
-
- def deactivate!
- update(active: false)
- end
-
- def activate!
- update(active: true)
- end
-
- def toggle!
- active? ? deactivate! : activate!
- end
-
- private
-
- def self.build_help_page_url(url_path, help_text, link_text = _("Learn More"), options = {})
- docs_link = ActionController::Base.helpers.link_to(
- '',
- Rails.application.routes.url_helpers.help_page_url(url_path, **options),
- target: '_blank',
- rel: 'noopener noreferrer'
- )
- tag_pair_docs_link = tag_pair(docs_link, :link_start, :link_end)
-
- safe_format(help_text + " %{link_start}#{link_text}%{link_end}.", tag_pair_docs_link)
- end
-
- # Ancestors sorted by hierarchy depth in bottom-top order.
- def self.sorted_ancestors(scope)
- if scope.root_ancestor.use_traversal_ids?
- Namespace.from(scope.ancestors(hierarchy_order: :asc))
- else
- scope.ancestors
- end
- end
-
- def validate_belongs_to_project_or_group
- return unless project_level? && group_level?
-
- errors.add(:project_id, 'The integration cannot belong to both a project and a group')
- end
-
- def validate_recipients?
- activated? && !importing?
- end
+ include Integrations::Base::Integration
end
-
-Integration.prepend_mod_with('Integration')
diff --git a/app/models/integrations/instance/integration.rb b/app/models/integrations/instance/integration.rb
new file mode 100644
index 00000000000..7c63c5758c8
--- /dev/null
+++ b/app/models/integrations/instance/integration.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Integrations
+ module Instance
+ class Integration < ApplicationRecord
+ include IgnorableColumns
+ include Integrations::Base::Integration
+
+ self.table_name = 'instance_integrations'
+ self.inheritance_column = :type_new # rubocop:disable Database/AvoidInheritanceColumn -- supporting instance integrations migration
+
+ ignore_column :type, remove_with: '17.7', remove_after: '2024-12-02'
+
+ def instance_level?
+ true
+ end
+
+ def group_level?
+ false
+ end
+
+ def project_level?
+ false
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/instance_integration.rb b/app/models/integrations/instance_integration.rb
deleted file mode 100644
index bca4e11e09d..00000000000
--- a/app/models/integrations/instance_integration.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-module Integrations
- class InstanceIntegration < Integration
- include IgnorableColumns
-
- self.table_name = 'instance_integrations'
- self.inheritance_column = :type_new # rubocop:disable Database/AvoidInheritanceColumn -- supporting instance integrations migration
-
- ignore_column :type, remove_with: '17.7', remove_after: '2024-12-02'
- end
-end
diff --git a/app/models/user.rb b/app/models/user.rb
index 6e4a39dadba..e6633a3e9ac 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -29,6 +29,7 @@ class User < ApplicationRecord
include RestrictedSignup
include StripAttribute
include EachBatch
+ include IgnorableColumns
include CrossDatabaseIgnoredTables
include UseSqlFunctionForPrimaryKeyLookups
@@ -45,6 +46,8 @@ class User < ApplicationRecord
on: :destroy
)
+ ignore_column :last_access_from_pipl_country_at, remove_after: '2024-11-17', remove_with: '17.7'
+
DEFAULT_NOTIFICATION_LEVEL = :participating
INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10
@@ -1305,6 +1308,16 @@ class User < ApplicationRecord
end
end
+ # Used to search on the user's authorized_groups effeciently by using a CTE
+ def search_on_authorized_groups(query, use_minimum_char_limit: true)
+ authorized_groups_cte = Gitlab::SQL::CTE.new(:authorized_groups, authorized_groups)
+ authorized_groups_cte_alias = authorized_groups_cte.table.alias(Group.table_name)
+ Group
+ .with(authorized_groups_cte.to_arel)
+ .from(authorized_groups_cte_alias)
+ .search(query, use_minimum_char_limit: use_minimum_char_limit)
+ end
+
# Returns the groups a user is a member of, either directly or through a parent group
def membership_groups
groups.self_and_descendants
diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb
index e18b7f5923f..a8e7d238414 100644
--- a/app/services/concerns/users/participable_service.rb
+++ b/app/services/concerns/users/participable_service.rb
@@ -15,6 +15,7 @@ module Users
def noteable_owner
return [] unless noteable && noteable.author.present?
+ return [] if noteable.author.placeholder? || noteable.author.import_user?
[noteable.author].tap do |users|
preload_status(users)
@@ -25,6 +26,7 @@ module Users
return [] unless noteable
users = noteable.participants(current_user)
+ users = users.reject { |user| user.placeholder? || user.import_user? }
sorted(users)
end
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 2689a6916e0..a67c22148b1 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -21,6 +21,7 @@
.alert-wrapper.alert-wrapper-top-space.gl-flex.gl-flex-col.gl-gap-3.container-fluid{ class: alert_class }
= dispensable_render 'shared/outdated_browser'
= dispensable_render_if_exists "layouts/header/licensed_user_count_threshold"
+ = dispensable_render_if_exists "layouts/header/licensed_user_count_threshold_block_seat_overages"
= dispensable_render_if_exists "layouts/header/token_expiry_notification"
-# This is needed by [GitLab JH](https://gitlab.com/gitlab-jh/jh-team/gitlab/-/issues/13)
= dispensable_render_if_exists "layouts/header/account_notification"
diff --git a/config/feature_flags/gitlab_com_derisk/autocomplete_group_search_optimization.yml b/config/feature_flags/gitlab_com_derisk/autocomplete_group_search_optimization.yml
new file mode 100644
index 00000000000..df4a791a08a
--- /dev/null
+++ b/config/feature_flags/gitlab_com_derisk/autocomplete_group_search_optimization.yml
@@ -0,0 +1,9 @@
+---
+name: autocomplete_group_search_optimization
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/472011
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/170948
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/501202
+milestone: '17.6'
+group: group::global search
+type: gitlab_com_derisk
+default_enabled: false
diff --git a/config/initializers/0_license.rb b/config/initializers/0_license.rb
new file mode 100644
index 00000000000..e6b17a522f9
--- /dev/null
+++ b/config/initializers/0_license.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+load_license = ->(dir:, license_name:) do
+ begin
+ public_key_file = File.read(Rails.root.join(dir, ".license_encryption_key.pub"))
+ public_key = OpenSSL::PKey::RSA.new(public_key_file)
+ Gitlab::License.encryption_key = public_key
+ rescue StandardError
+ warn "WARNING: No valid #{license_name} encryption key provided."
+ end
+
+ begin
+ if Rails.env.development? || Rails.env.test? || ENV['GITLAB_LICENSE_MODE'] == 'test'
+ fallback_key_file = File.read(Rails.root.join(dir, ".test_license_encryption_key.pub"))
+ fallback_key = OpenSSL::PKey::RSA.new(fallback_key_file)
+ Gitlab::License.fallback_decryption_keys = [fallback_key]
+ end
+ rescue StandardError
+ warn "WARNING: No fallback #{license_name} decryption key provided."
+ end
+end
+
+Gitlab.ee do
+ load_license.call(dir: '.', license_name: 'license')
+end
+
+Gitlab.jh do
+ load_license.call(dir: 'jh', license_name: 'JH license')
+end
diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml
index f59744997d2..9a941f85c0b 100644
--- a/config/sidekiq_queues.yml
+++ b/config/sidekiq_queues.yml
@@ -233,6 +233,10 @@
- 1
- - compliance_management_standards_gitlab_at_least_two_approvals_group
- 1
+- - compliance_management_standards_gitlab_dast
+ - 1
+- - compliance_management_standards_gitlab_dast_group
+ - 1
- - compliance_management_standards_gitlab_prevent_approval_by_author
- 1
- - compliance_management_standards_gitlab_prevent_approval_by_author_group
diff --git a/db/docs/instance_integrations.yml b/db/docs/instance_integrations.yml
index b799f7a5a5b..cb5047f72de 100644
--- a/db/docs/instance_integrations.yml
+++ b/db/docs/instance_integrations.yml
@@ -1,7 +1,7 @@
---
table_name: instance_integrations
classes:
-- Integrations::InstanceIntegration
+- Integrations::Instance::Integration
feature_categories:
- integrations
description: Support 3rd party instance-wide integrations
diff --git a/db/docs/pipl_users.yml b/db/docs/pipl_users.yml
new file mode 100644
index 00000000000..57fc67b0875
--- /dev/null
+++ b/db/docs/pipl_users.yml
@@ -0,0 +1,11 @@
+---
+table_name: pipl_users
+classes:
+ - ComplianceManagement::PiplUser
+feature_categories:
+ - compliance_management
+description: Stores user accesses and notifications from PIPL countries
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/169769
+milestone: '17.6'
+gitlab_schema: gitlab_main_cell
+exempt_from_sharding: true # Not related to projects or groups
diff --git a/db/migrate/20241016095329_create_pipl_users_table.rb b/db/migrate/20241016095329_create_pipl_users_table.rb
new file mode 100644
index 00000000000..f3f59da6281
--- /dev/null
+++ b/db/migrate/20241016095329_create_pipl_users_table.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class CreatePiplUsersTable < Gitlab::Database::Migration[2.2]
+ milestone '17.6'
+
+ def up
+ create_table :pipl_users, id: false do |t|
+ t.references :user, primary_key: true, default: nil, index: false, foreign_key: { on_delete: :cascade }
+
+ t.timestamps_with_timezone null: false
+ t.datetime_with_timezone :initial_email_sent_at, index: true
+ t.datetime_with_timezone :last_access_from_pipl_country_at, null: false
+ end
+ end
+
+ def down
+ drop_table :pipl_users
+ end
+end
diff --git a/db/schema_migrations/20241016095329 b/db/schema_migrations/20241016095329
new file mode 100644
index 00000000000..cd887c1990d
--- /dev/null
+++ b/db/schema_migrations/20241016095329
@@ -0,0 +1 @@
+a90f86f8f7ccc8bd9d1032a0ae161a413b6a4d6f2bd85e4198b224dca8483a68
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 0159c21f62f..6ba199f264b 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -16584,6 +16584,14 @@ CREATE SEQUENCE personal_access_tokens_id_seq
ALTER SEQUENCE personal_access_tokens_id_seq OWNED BY personal_access_tokens.id;
+CREATE TABLE pipl_users (
+ user_id bigint NOT NULL,
+ created_at timestamp with time zone NOT NULL,
+ updated_at timestamp with time zone NOT NULL,
+ initial_email_sent_at timestamp with time zone,
+ last_access_from_pipl_country_at timestamp with time zone NOT NULL
+);
+
CREATE TABLE plan_limits (
id bigint NOT NULL,
plan_id bigint NOT NULL,
@@ -25820,6 +25828,9 @@ ALTER TABLE ONLY path_locks
ALTER TABLE ONLY personal_access_tokens
ADD CONSTRAINT personal_access_tokens_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY pipl_users
+ ADD CONSTRAINT pipl_users_pkey PRIMARY KEY (user_id);
+
ALTER TABLE ONLY plan_limits
ADD CONSTRAINT plan_limits_pkey PRIMARY KEY (id);
@@ -31072,6 +31083,8 @@ CREATE UNIQUE INDEX p_ci_pipeline_variables_pipeline_id_key_partition_id_idx ON
CREATE UNIQUE INDEX index_pipeline_variables_on_pipeline_id_key_partition_id_unique ON ci_pipeline_variables USING btree (pipeline_id, key, partition_id);
+CREATE INDEX index_pipl_users_on_initial_email_sent_at ON pipl_users USING btree (initial_email_sent_at);
+
CREATE UNIQUE INDEX index_plan_limits_on_plan_id ON plan_limits USING btree (plan_id);
CREATE UNIQUE INDEX index_plans_on_name ON plans USING btree (name);
@@ -37798,6 +37811,9 @@ ALTER TABLE p_ci_build_trace_metadata
ALTER TABLE ONLY bulk_import_trackers
ADD CONSTRAINT fk_rails_aed566d3f3 FOREIGN KEY (bulk_import_entity_id) REFERENCES bulk_import_entities(id) ON DELETE CASCADE;
+ALTER TABLE ONLY pipl_users
+ ADD CONSTRAINT fk_rails_af0eb8b205 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY pool_repositories
ADD CONSTRAINT fk_rails_af3f8c5d62 FOREIGN KEY (shard_id) REFERENCES shards(id) ON DELETE RESTRICT;
diff --git a/doc/administration/issue_closing_pattern.md b/doc/administration/issue_closing_pattern.md
index df9a9ed205d..f20e8011abe 100644
--- a/doc/administration/issue_closing_pattern.md
+++ b/doc/administration/issue_closing_pattern.md
@@ -16,16 +16,15 @@ This page explains how an administrator can configure issue closing patterns.
For user documentation about the feature, see
[Closing issues automatically](../user/project/issues/managing_issues.md#closing-issues-automatically).
-When a commit or merge request resolves one or more issues, it is possible to
-automatically close these issues when the commit or merge request lands
-in the project's default branch.
+When a commit or merge request resolves one or more issues, GitLab can close those issues when the
+commit or merge request lands in the project's default branch.
## Change the issue closing pattern
The [default issue closing pattern](../user/project/issues/managing_issues.md#default-closing-pattern)
-covers a wide range of words. You can change the pattern to suit your needs.
+covers a wide range of words.
-To change the default issue closing pattern:
+To change the default issue closing pattern to suit your needs:
::Tabs
@@ -108,6 +107,6 @@ To change the default issue closing pattern:
::EndTabs
-To test the issue closing pattern, use .
-However, Rubular doesn't understand `%{issue_ref}`. When testing your patterns,
+To test the issue closing pattern, use [Rubular](https://rubular.com).
+Rubular does not understand `%{issue_ref}`. When you test your patterns,
replace this string with `#\d+`, which matches only local issue references like `#123`.
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index db6e74ee445..7e6e872a96b 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -37582,6 +37582,7 @@ Name of the check for the compliance standard.
| ----- | ----------- |
| `AT_LEAST_ONE_NON_AUTHOR_APPROVAL` | At least one non author approval. |
| `AT_LEAST_TWO_APPROVALS` | At least two approvals. |
+| `DAST` | Dast. |
| `PREVENT_APPROVAL_BY_MERGE_REQUEST_AUTHOR` | Prevent approval by merge request author. |
| `PREVENT_APPROVAL_BY_MERGE_REQUEST_COMMITTERS` | Prevent approval by merge request committers. |
| `SAST` | Sast. |
diff --git a/doc/development/sidekiq/idempotent_jobs.md b/doc/development/sidekiq/idempotent_jobs.md
index fd9f6e467f5..1bea3083b64 100644
--- a/doc/development/sidekiq/idempotent_jobs.md
+++ b/doc/development/sidekiq/idempotent_jobs.md
@@ -46,8 +46,8 @@ specific to the worker in the shared examples block.
```ruby
it_behaves_like 'an idempotent worker' do
it 'checks the side-effects for multiple calls' do
- # `subject` will call the job's perform method 2 times
- subject
+ # `perform_idempotent_work` will call the job's perform method 2 times
+ perform_idempotent_work
expect(model.state).to eq('state')
end
diff --git a/doc/subscriptions/gitlab_com/index.md b/doc/subscriptions/gitlab_com/index.md
index 520cf7df438..f965514470b 100644
--- a/doc/subscriptions/gitlab_com/index.md
+++ b/doc/subscriptions/gitlab_com/index.md
@@ -158,7 +158,8 @@ Billable users count toward the number of subscription seats purchased in your s
A user is not counted as a billable user if:
- They are pending approval.
-- They have the [Guest role on an Ultimate subscription](#free-guest-users).
+- They have only the [Guest role on an Ultimate subscription](#free-guest-users).
+- They have only the [Minimal Access role](../../user/permissions.md#users-with-minimal-access) for any GitLab.com subscriptions.
- They are a [banned member](../../user/group/moderate_users.md#ban-a-user).
- They are a [blocked user](../../administration/moderate_users.md#block-a-user).
- The account is a GitLab-created service account:
diff --git a/doc/subscriptions/self_managed/index.md b/doc/subscriptions/self_managed/index.md
index 51a62b3a5b1..2e869e36b4d 100644
--- a/doc/subscriptions/self_managed/index.md
+++ b/doc/subscriptions/self_managed/index.md
@@ -51,8 +51,8 @@ A user is not counted as a billable user if:
- They are [deactivated](../../administration/moderate_users.md#deactivate-a-user) or
[blocked](../../administration/moderate_users.md#block-a-user).
- They are [pending approval](../../administration/moderate_users.md#users-pending-approval).
-- They have only the [Minimal Access role](../../user/permissions.md#users-with-minimal-access) on self-managed Ultimate subscriptions or any GitLab.com subscriptions.
-- They have only the [Guest or Minimal Access roles on an Ultimate subscription](#free-guest-users).
+- They have only the [Minimal Access role](../../user/permissions.md#users-with-minimal-access) on self-managed Ultimate subscriptions.
+- They have only the [Guest role on an Ultimate subscription](#free-guest-users).
- They do not have project or group memberships on an Ultimate subscription.
- The account is a GitLab-created account:
- [Ghost User](../../user/profile/account/delete_account.md#associated-records).
diff --git a/doc/user/project/merge_requests/dependencies.md b/doc/user/project/merge_requests/dependencies.md
index 8dd98176549..602ebc388a8 100644
--- a/doc/user/project/merge_requests/dependencies.md
+++ b/doc/user/project/merge_requests/dependencies.md
@@ -18,16 +18,16 @@ A single feature can span several merge requests, spread out across multiple pro
and the order in which the work merges can be significant. Use merge request dependencies
when it's important to merge work in a specific order. Some examples:
-- Ensure changes to a required library are merged before changes to a project that
+- Ensure changes to a required library merge before changes to a project that
imports the library.
- Prevent a documentation-only merge request from merging before the feature work
is itself merged.
- Require a merge request updating a permissions matrix to merge, before merging work
- from someone who hasn't yet been granted permissions.
+ from someone who doesn't yet have the correct permissions.
If your project `me/myexample` imports a library from `myfriend/library`,
-you might want to update your project to use a new feature in `myfriend/library`.
-However, if you merge changes to your project before the external library adds the
+you should update your project when `myfriend/library` releases a new feature.
+If you merge your changes to `me/myexample` before `myfriend/library` adds the
new feature, you would break the default branch in your project. A merge request
dependency prevents your work from merging too soon:
@@ -47,23 +47,23 @@ graph TB
C-.->|blocks| D
```
-You could mark your `me/myexample` merge request as a [draft](drafts.md)
-and explain why in the comments. However, this approach is manual and does not scale, especially
-if your merge request relies on several others in multiple projects. Instead,
-use the draft (or ready) state to track the readiness of an individual
-merge request, and a merge request dependency to enforce merge order.
+It's possible to mark your `me/myexample` merge request as a [draft](drafts.md)
+and explain why in the comments. This approach is manual and does not scale, especially
+if your merge request relies on several others in different projects. Instead, you should:
-NOTE:
-Merge request dependencies are a **PREMIUM** feature, but this restriction is
-enforced only for the dependent merge request. A merge request in a **PREMIUM**
-project can depend on a merge request in a **FREE** project, but a merge request
-in a **FREE** project cannot be marked as dependent.
+- Track the readiness of an individual merge request with **Draft** or **Ready** status.
+- Enforce the order merge requests merge with a merge request dependency.
+
+Merge request dependencies are a **PREMIUM** feature, but GitLab enforces this restriction
+only for the *dependent* merge request:
+
+- A **PREMIUM** project's merge request can depend on any other merge request, even in a **FREE** project.
+- A **FREE** project's merge request cannot depend on other merge requests.
## Nested dependencies
-Indirect, nested dependencies are supported in GitLab 16.7 and later.
-A single merge request can be blocked by up to 10 merge requests, and,
-in turn, can block up to 10 merge requests. In this example, `myfriend/library!10`
+GitLab versions 16.7 and later support indirect, nested dependencies. A merge request can have up to 10 blockers,
+and in turn it can block up to 10 other merge requests. In this example, `myfriend/library!10`
depends on `herfriend/another-lib!1`, which in turn depends on `mycorp/example!100`:
```mermaid
@@ -76,12 +76,11 @@ graph LR;
B-->|depends on| C[mycorp/example!100]
```
-Nested dependencies do not display in the GitLab UI, but UI support has
-been proposed in [epic 5308](https://gitlab.com/groups/gitlab-org/-/epics/5308).
+Nested dependencies do not display in the GitLab UI, but UI support is
+proposed in [epic 5308](https://gitlab.com/groups/gitlab-org/-/epics/5308).
NOTE:
-A merge request can't be made dependent on itself (self-referential), but
-it's possible to create circular dependencies.
+A merge request cannot depend on itself (self-referential), but it's possible to create circular dependencies.
## View dependencies for a merge request
@@ -100,26 +99,23 @@ To view dependency information on a merge request:
1. Select **Expand** to view the title, milestone, assignee, and pipeline status
of each dependency.
-Until your merge request's dependencies all merge, your merge request
-cannot be merged. The message
+Until your merge request's dependencies all merge, your merge request cannot merge. The message
**Merge blocked: you can only merge after the above items are resolved** displays.
### Closed merge requests
-Closed merge requests still prevent their dependents from being merged, because
-a merge request can close regardless of whether or not the planned work actually merged.
-
-If a merge request closes and the dependency is no longer relevant,
+Closed merge requests still prevent their dependents from merging, because a merge request can close
+without merging its planned work. If a merge request closes and the dependency is no longer relevant,
remove it as a dependency to unblock the dependent merge request.
## Create a new dependent merge request
When you create a new merge request, you can prevent it from merging until after
-other specific work merges, even if the merge request is in a different project.
+other specific work merges. This dependency works even if the merge request is in a different project.
Prerequisites:
-- You must have at least the Developer role or be allowed to create merge requests in the project.
+- You must have at least the Developer role, or have permission to create merge requests in the project.
- The dependent merge request must be in a project in the Premium or Ultimate tier.
To create a new merge request and mark it as dependent on another:
@@ -136,7 +132,7 @@ You can edit an existing merge request and mark it as dependent on another.
Prerequisites:
-- You must have at least the Developer role or be allowed to edit merge requests in the project.
+- You must have at least the Developer role or have permission to edit merge requests in the project.
To do this:
@@ -162,13 +158,13 @@ Prerequisites:
for each dependency you want to remove.
NOTE:
- Dependencies for merge requests you don't have access to are displayed as
- **1 inaccessible merge request**, and can be removed the same way.
+ Merge request dependencies you do not have permission to view are shown as
+ **1 inaccessible merge request**. You can still remove the dependency.
1. Select **Save changes**.
## Troubleshooting
-### Preserving dependencies on project import or export
+### Preserve dependencies on project import or export
-Dependencies are not preserved when projects are imported or exported. For more
-information, read [issue #12549](https://gitlab.com/gitlab-org/gitlab/-/issues/12549).
+Dependencies are not preserved when you import or export a project. For more
+information, see [issue #12549](https://gitlab.com/gitlab-org/gitlab/-/issues/12549).
diff --git a/doc/user/project/merge_requests/manage.md b/doc/user/project/merge_requests/manage.md
index d0375f9ea17..036da6418b9 100644
--- a/doc/user/project/merge_requests/manage.md
+++ b/doc/user/project/merge_requests/manage.md
@@ -25,9 +25,9 @@ To delete a merge request:
1. Select **Edit**.
1. Scroll to the bottom of the page, and select **Delete merge request**.
-## Bulk edit merge requests at the project level
+## Bulk edit merge requests in a project
-You can update these attributes on multiple merge requests at the same time:
+These attributes are editable when bulk editing merge requests:
- Status (open/closed)
- Assignee
@@ -48,13 +48,13 @@ To do this:
1. Select the appropriate fields and their values from the sidebar.
1. Select **Update selected**.
-## Bulk edit merge requests at the group level
+## Bulk edit merge requests in a group
DETAILS:
**Tier:** Premium, Ultimate
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
-When bulk editing merge requests in a group, you can edit the following attributes:
+These attributes are editable when you bulk edit merge requests for a group:
- Milestone
- Labels
diff --git a/doc/user/project/merge_requests/reviews/img/multiline-comment-saved_v13_3.png b/doc/user/project/merge_requests/reviews/img/multiline-comment-saved_v13_3.png
deleted file mode 100644
index cceab36e62b..00000000000
Binary files a/doc/user/project/merge_requests/reviews/img/multiline-comment-saved_v13_3.png and /dev/null differ
diff --git a/doc/user/project/merge_requests/reviews/img/multiline-comment-saved_v17_5.png b/doc/user/project/merge_requests/reviews/img/multiline-comment-saved_v17_5.png
new file mode 100644
index 00000000000..0d82a74bfd6
Binary files /dev/null and b/doc/user/project/merge_requests/reviews/img/multiline-comment-saved_v17_5.png differ
diff --git a/doc/user/project/merge_requests/reviews/index.md b/doc/user/project/merge_requests/reviews/index.md
index 93a9952ff7e..bcbba872d1f 100644
--- a/doc/user/project/merge_requests/reviews/index.md
+++ b/doc/user/project/merge_requests/reviews/index.md
@@ -37,8 +37,7 @@ To do this:
1. Select **Code > Merge requests** and find your merge request.
1. Select the title of the merge request to view it.
1. Scroll to the [merge request widget](../widgets.md) to see the mergeability and
- approval status for the merge request. For example, this merge request is blocked
- because it hasn't received the approvals it needs:
+ approval status for the merge request. For example, the lack of required approvals blocks this merge request:

@@ -134,7 +133,7 @@ To resolve or unresolve a thread when replying to a comment:
1. Select or clear **Resolve thread**.
1. Select **Add comment now** or **Add to review**.
-Pending comments display information about the actions that are delayed until comment is published:
+Pending comments display information about delayed actions. GitLab does not perform these actions until you publish the comment:
- **{check-circle-filled}** Thread is resolved.
- **{check-circle}** Thread stays unresolved.
@@ -179,9 +178,8 @@ the reviewer who requested changes should [re-review and approve](#re-request-a-
### Bypass a request for changes
-If the user who requested changes is unable to re-review or provide an approval,
-another user with permission to merge the merge request can override this check in the
-merge request reports area by selecting **Bypass**:
+If the user who requested changes is unavailable to re-review or approve,
+another user with permission to merge the merge request can override this check:
1. On the left sidebar, select **Search or go to** and find your project.
1. Select **Code > Merge requests** and find your merge request.
@@ -191,8 +189,8 @@ merge request reports area by selecting **Bypass**:

-1. The merge reports area shows `Merge with caution: Override added`. To see which check
- was bypassed, select **Expand merge checks** (**{chevron-lg-down}**) and find the
+1. The merge reports area shows `Merge with caution: Override added`. To see which check a user
+ bypassed, select **Expand merge checks** (**{chevron-lg-down}**) and find the
check that contains a warning (**{status_warning}**) icon. In this example, the
author bypassed **The change requests must be completed or resolved**:
@@ -209,7 +207,7 @@ subject matter experts for the changes you're making. To decrease the number of
review cycles for your merge request, consider requesting reviews from users
listed in the project's approval rules.
-When you edit the **Reviewers** field in a new or existing merge request, GitLab shows you
+When you edit the **Reviewers** field in a merge request, GitLab shows you
the matching [approval rule](../approvals/rules.md) below the name of each reviewer.
[Code Owners](../../codeowners/index.md) display as `Codeowner` without any group detail.
@@ -313,7 +311,7 @@ For more information, see [Data usage in Suggested Reviewers](data_usage.md).
Enabling Suggested Reviewers triggers GitLab to create the machine learning model your
project uses to generate reviewers. The larger your project, the longer
-this process can take. Usually, the model is ready to generate suggestions
+this process can take. The model is usually ready to generate suggestions
after a few hours.
Prerequisites:
diff --git a/doc/user/project/merge_requests/reviews/suggestions.md b/doc/user/project/merge_requests/reviews/suggestions.md
index 2b02f9f0961..8ae3e1d0271 100644
--- a/doc/user/project/merge_requests/reviews/suggestions.md
+++ b/doc/user/project/merge_requests/reviews/suggestions.md
@@ -24,7 +24,7 @@ merge request, authored by the user who suggested the changes.
1. Find the lines of code you want to change.
- To select a single line, hover over the line number and
select **Add a comment to this line** (**{comment}**).
- - To select multiple lines:
+ - To select more lines:
1. Hover over the line number, and select **Add a comment to this line** (**{comment}**):

1. Select and drag your selection to include all desired lines. To
@@ -73,13 +73,13 @@ When applied, the suggestion replaces from 2 lines above to 2 lines below the co

-Suggestions for multiple lines are limited to 100 lines _above_ and 100
+GitLab limits multi-line suggestions to 100 lines _above_ and 100
lines _below_ the commented diff line. This allows for up to 201 changed lines per
suggestion.
Multiline comments display the comment's line numbers above the body of the comment:
-
+
#### Using the rich text editor
@@ -87,8 +87,7 @@ Multiline comments display the comment's line numbers above the body of the comm
> - [Enabled on GitLab.com and self-managed](https://gitlab.com/gitlab-org/gitlab/-/issues/375172) in GitLab 16.2.
> - Feature flag `content_editor_on_issues` removed in GitLab 16.5.
-When you insert suggestions, you can use the WYSIWYG
-[rich text editor](../../../rich_text_editor.md) to move
+When you insert suggestions, use the WYSIWYG [rich text editor](../../../rich_text_editor.md) to move
up and down the source file's line numbers in the UI.
To add or subtract changed lines, next to **From line**, select **+** or **-**.
diff --git a/doc/user/project/merge_requests/widgets.md b/doc/user/project/merge_requests/widgets.md
index 6dc280695d0..7f519ecac7e 100644
--- a/doc/user/project/merge_requests/widgets.md
+++ b/doc/user/project/merge_requests/widgets.md
@@ -31,7 +31,7 @@ If an application is successfully deployed to an
NOTE:
When the pipeline fails in a merge request but it can still merge,
-GitLab shows the **Merge** button in red.
+GitLab shows **Merge** in red.
## Post-merge pipeline status
diff --git a/lib/gitlab/middleware/go.rb b/lib/gitlab/middleware/go.rb
index 04b03aea08e..b312f209307 100644
--- a/lib/gitlab/middleware/go.rb
+++ b/lib/gitlab/middleware/go.rb
@@ -31,7 +31,7 @@ module Gitlab
)
Rack::Response.new(e.message, 403).finish
rescue Gitlab::Auth::MissingPersonalAccessTokenError
- Rack::Response.new('', 401).finish
+ not_found_response
end
private
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 975e5335411..35b30b7460d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -64271,6 +64271,9 @@ msgid_plural "Your free group is now limited to %d members"
msgstr[0] ""
msgstr[1] ""
+msgid "Your instance has %{remaining_user_count} users remaining of the %{total_user_count} in your subscription. When there are no more seats, users cannot be invited or added to the instance."
+msgstr ""
+
msgid "Your instance has %{remaining_user_count} users remaining of the %{total_user_count} included in your subscription. You can add more users than the number included in your license, and we will include the overage in your next bill."
msgstr ""
diff --git a/package.json b/package.json
index b626fa3dd66..2245a47da22 100644
--- a/package.json
+++ b/package.json
@@ -80,7 +80,7 @@
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
"@rails/actioncable": "7.0.8-4",
"@rails/ujs": "7.0.8-4",
- "@sentry/browser": "8.35.0",
+ "@sentry/browser": "8.36.0",
"@snowplow/browser-plugin-client-hints": "^3.24.2",
"@snowplow/browser-plugin-form-tracking": "^3.24.2",
"@snowplow/browser-plugin-ga-cookies": "^3.24.2",
diff --git a/spec/factories/integrations/instance/integrations.rb b/spec/factories/integrations/instance/integrations.rb
new file mode 100644
index 00000000000..28bff0b89ac
--- /dev/null
+++ b/spec/factories/integrations/instance/integrations.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :instance_integration, class: 'Integrations::Instance::Integration' do
+ type { 'Integrations::Instance::Integration' }
+ end
+end
diff --git a/spec/factories/integrations/instance_integrations.rb b/spec/factories/integrations/instance_integrations.rb
deleted file mode 100644
index d70b7ecf889..00000000000
--- a/spec/factories/integrations/instance_integrations.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-FactoryBot.define do
- factory :instance_integration, class: 'Integrations::InstanceIntegration' do
- type { 'Integrations::InstanceIntegration' }
- end
-end
diff --git a/spec/finders/autocomplete/users_finder_spec.rb b/spec/finders/autocomplete/users_finder_spec.rb
index e4337e52306..64ca17ba278 100644
--- a/spec/finders/autocomplete/users_finder_spec.rb
+++ b/spec/finders/autocomplete/users_finder_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe Autocomplete::UsersFinder do
describe '#execute' do
let_it_be(:user1) { create(:user, name: 'zzzzzname', username: 'johndoe') }
let_it_be(:blocked_user) { create(:user, :blocked, username: 'blocked_user') }
+ let_it_be(:import_user) { create(:user, :import_user, username: 'import_user') }
+ let_it_be(:placeholder_user) { create(:user, :placeholder, username: 'placeholder_user') }
let_it_be(:banned_user) { create(:user, :banned, username: 'banned_user') }
let_it_be(:external_user) { create(:user, :external) }
let_it_be(:omniauth_user) { create(:omniauth_user, provider: 'twitter', extern_uid: '123456') }
@@ -50,6 +52,18 @@ RSpec.describe Autocomplete::UsersFinder do
it { is_expected.to match_array([project.first_owner]) }
end
+
+ context 'and author is a placeholder user' do
+ let(:params) { { author_id: placeholder_user.id } }
+
+ it { is_expected.to match_array([project.first_owner]) }
+ end
+
+ context 'and author is a import_user' do
+ let(:params) { { author_id: import_user.id } }
+
+ it { is_expected.to match_array([project.first_owner]) }
+ end
end
context 'searching with less than 3 characters' do
diff --git a/spec/frontend/notes/store/legacy_notes/mutation_spec.js b/spec/frontend/notes/store/legacy_notes/mutation_spec.js
index 8c35b8e12b1..943b62dff88 100644
--- a/spec/frontend/notes/store/legacy_notes/mutation_spec.js
+++ b/spec/frontend/notes/store/legacy_notes/mutation_spec.js
@@ -1,5 +1,7 @@
-import { DISCUSSION_NOTE, ASC, DESC } from '~/notes/constants';
-import mutations from '~/notes/stores/mutations';
+import { createPinia, setActivePinia } from 'pinia';
+import { DISCUSSION_NOTE, DESC } from '~/notes/constants';
+import * as types from '~/notes/stores/mutation_types';
+import { useNotes } from '~/notes/store/legacy_notes';
import {
note,
discussionMock,
@@ -16,17 +18,18 @@ const UNRESOLVED_NOTE = { resolvable: true, resolved: false };
const SYSTEM_NOTE = { resolvable: false, resolved: false };
const WEIRD_NOTE = { resolvable: false, resolved: true };
-// eslint-disable-next-line jest/no-disabled-tests
-describe.skip('Notes Store mutations', () => {
+describe('Notes Store mutations', () => {
+ let store;
+
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ store = useNotes();
+ });
+
describe('ADD_NEW_NOTE', () => {
- let state;
let noteData;
beforeEach(() => {
- state = {
- discussions: [],
- discussionSortOrder: ASC,
- };
noteData = {
expanded: true,
id: note.discussion_id,
@@ -37,63 +40,60 @@ describe.skip('Notes Store mutations', () => {
});
it('should add a new note to an array of notes', () => {
- mutations.ADD_NEW_NOTE(state, note);
- expect(state).toEqual(expect.objectContaining({ discussions: [noteData] }));
-
- expect(state.discussions.length).toBe(1);
+ store[types.ADD_NEW_NOTE](note);
+ expect(store.discussions).toStrictEqual([noteData]);
+ expect(store.discussions.length).toBe(1);
});
it('should not add the same note to the notes array', () => {
- mutations.ADD_NEW_NOTE(state, note);
- mutations.ADD_NEW_NOTE(state, note);
+ store[types.ADD_NEW_NOTE](note);
+ store[types.ADD_NEW_NOTE](note);
- expect(state.discussions.length).toBe(1);
+ expect(store.discussions.length).toBe(1);
});
it('trims first character from truncated_diff_lines', () => {
- mutations.ADD_NEW_NOTE(state, {
+ store[types.ADD_NEW_NOTE]({
discussion: {
notes: [{ ...note }],
truncated_diff_lines: [{ text: '+a', rich_text: '+a' }],
},
});
- expect(state.discussions[0].truncated_diff_lines).toEqual([{ rich_text: 'a' }]);
+ expect(store.discussions[0].truncated_diff_lines).toEqual([{ rich_text: 'a' }]);
});
});
describe('ADD_NEW_REPLY_TO_DISCUSSION', () => {
const newReply = { ...note, discussion_id: discussionMock.id };
- let state;
-
beforeEach(() => {
- state = { discussions: [{ ...discussionMock }] };
+ store.discussions = [{ ...discussionMock }];
});
it('should add a reply to a specific discussion', () => {
- mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply);
+ store[types.ADD_NEW_REPLY_TO_DISCUSSION](newReply);
- expect(state.discussions[0].notes.length).toEqual(4);
+ expect(store.discussions[0].notes.length).toEqual(4);
});
it('should not add the note if it already exists in the discussion', () => {
- mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply);
- mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply);
+ store[types.ADD_NEW_REPLY_TO_DISCUSSION](newReply);
+ store[types.ADD_NEW_REPLY_TO_DISCUSSION](newReply);
- expect(state.discussions[0].notes.length).toEqual(4);
+ expect(store.discussions[0].notes.length).toEqual(4);
});
});
describe('DELETE_NOTE', () => {
it('should delete a note', () => {
- const state = { discussions: [discussionMock] };
+ store.$patch({ discussions: [discussionMock] });
const toDelete = discussionMock.notes[0];
const lengthBefore = discussionMock.notes.length;
- mutations.DELETE_NOTE(state, toDelete);
+ store[types.DELETE_NOTE](toDelete);
- expect(state.discussions[0].notes.length).toEqual(lengthBefore - 1);
+ expect(store.discussions[0].notes.length).toEqual(lengthBefore - 1);
});
});
@@ -101,13 +101,13 @@ describe.skip('Notes Store mutations', () => {
it('should expand a collapsed discussion', () => {
const discussion = { ...discussionMock, expanded: false };
- const state = {
+ store.$patch({
discussions: [discussion],
- };
+ });
- mutations.EXPAND_DISCUSSION(state, { discussionId: discussion.id });
+ store[types.EXPAND_DISCUSSION]({ discussionId: discussion.id });
- expect(state.discussions[0].expanded).toEqual(true);
+ expect(store.discussions[0].expanded).toEqual(true);
});
});
@@ -115,24 +115,24 @@ describe.skip('Notes Store mutations', () => {
it('should collapse an expanded discussion', () => {
const discussion = { ...discussionMock, expanded: true };
- const state = {
+ store.$patch({
discussions: [discussion],
- };
+ });
- mutations.COLLAPSE_DISCUSSION(state, { discussionId: discussion.id });
+ store[types.COLLAPSE_DISCUSSION]({ discussionId: discussion.id });
- expect(state.discussions[0].expanded).toEqual(false);
+ expect(store.discussions[0].expanded).toEqual(false);
});
});
describe('REMOVE_PLACEHOLDER_NOTES', () => {
it('should remove all placeholder individual notes', () => {
const placeholderNote = { ...individualNote, isPlaceholderNote: true };
- const state = { discussions: [placeholderNote] };
+ store.$patch({ discussions: [placeholderNote] });
- mutations.REMOVE_PLACEHOLDER_NOTES(state);
+ store[types.REMOVE_PLACEHOLDER_NOTES]();
- expect(state.discussions).toEqual([]);
+ expect(store.discussions).toEqual([]);
});
it.each`
@@ -145,69 +145,69 @@ describe.skip('Notes Store mutations', () => {
const placeholderNote = { ...individualNote, isPlaceholderNote: true };
discussion.notes.push(placeholderNote);
- const state = {
+ store.$patch({
discussions: [discussion],
- };
+ });
- mutations.REMOVE_PLACEHOLDER_NOTES(state);
+ store[types.REMOVE_PLACEHOLDER_NOTES]();
- expect(state.discussions[0].notes.length).toEqual(lengthBefore);
+ expect(store.discussions[0].notes.length).toEqual(lengthBefore);
});
});
describe('SET_NOTES_DATA', () => {
it('should set an object with notesData', () => {
- const state = {
+ store.$patch({
notesData: {},
- };
+ });
- mutations.SET_NOTES_DATA(state, notesDataMock);
+ store[types.SET_NOTES_DATA](notesDataMock);
- expect(state.notesData).toEqual(notesDataMock);
+ expect(store.notesData).toEqual(notesDataMock);
});
});
describe('SET_NOTEABLE_DATA', () => {
it('should set the issue data', () => {
- const state = {
+ store.$patch({
noteableData: {},
- };
+ });
- mutations.SET_NOTEABLE_DATA(state, noteableDataMock);
+ store[types.SET_NOTEABLE_DATA](noteableDataMock);
- expect(state.noteableData).toEqual(noteableDataMock);
+ expect(store.noteableData).toEqual(noteableDataMock);
});
});
describe('SET_USER_DATA', () => {
it('should set the user data', () => {
- const state = {
+ store.$patch({
userData: {},
- };
+ });
- mutations.SET_USER_DATA(state, userDataMock);
+ store[types.SET_USER_DATA](userDataMock);
- expect(state.userData).toEqual(userDataMock);
+ expect(store.userData).toEqual(userDataMock);
});
});
describe('CLEAR_DISCUSSIONS', () => {
it('should set discussions to an empty array', () => {
- const state = {
+ store.$patch({
discussions: [discussionMock],
- };
+ });
- mutations.CLEAR_DISCUSSIONS(state);
+ store[types.CLEAR_DISCUSSIONS]();
- expect(state.discussions).toEqual([]);
+ expect(store.discussions).toEqual([]);
});
});
describe('ADD_OR_UPDATE_DISCUSSIONS', () => {
it('should set the initial notes received', () => {
- const state = {
+ store.$patch({
discussions: [],
- };
+ });
const legacyNote = {
id: 2,
individual_note: true,
@@ -223,20 +223,20 @@ describe.skip('Notes Store mutations', () => {
],
};
- mutations.ADD_OR_UPDATE_DISCUSSIONS(state, [note, legacyNote]);
+ store[types.ADD_OR_UPDATE_DISCUSSIONS]([note, legacyNote]);
- expect(state.discussions[0].id).toEqual(note.id);
- expect(state.discussions[1].notes[0].note).toBe(legacyNote.notes[0].note);
- expect(state.discussions[2].notes[0].note).toBe(legacyNote.notes[1].note);
- expect(state.discussions.length).toEqual(3);
+ expect(store.discussions[0].id).toEqual(note.id);
+ expect(store.discussions[1].notes[0].note).toBe(legacyNote.notes[0].note);
+ expect(store.discussions[2].notes[0].note).toBe(legacyNote.notes[1].note);
+ expect(store.discussions.length).toEqual(3);
});
it('adds truncated_diff_lines if discussion is a diffFile', () => {
- const state = {
+ store.$patch({
discussions: [],
- };
+ });
- mutations.ADD_OR_UPDATE_DISCUSSIONS(state, [
+ store[types.ADD_OR_UPDATE_DISCUSSIONS]([
{
...note,
diff_file: {
@@ -246,15 +246,15 @@ describe.skip('Notes Store mutations', () => {
},
]);
- expect(state.discussions[0].truncated_diff_lines).toEqual([{ rich_text: 'a' }]);
+ expect(store.discussions[0].truncated_diff_lines).toEqual([{ rich_text: 'a' }]);
});
it('adds empty truncated_diff_lines when not in discussion', () => {
- const state = {
+ store.$patch({
discussions: [],
- };
+ });
- mutations.ADD_OR_UPDATE_DISCUSSIONS(state, [
+ store[types.ADD_OR_UPDATE_DISCUSSIONS]([
{
...note,
diff_file: {
@@ -263,83 +263,83 @@ describe.skip('Notes Store mutations', () => {
},
]);
- expect(state.discussions[0].truncated_diff_lines).toEqual([]);
+ expect(store.discussions[0].truncated_diff_lines).toEqual([]);
});
});
describe('SET_LAST_FETCHED_AT', () => {
it('should set timestamp', () => {
- const state = {
+ store.$patch({
lastFetchedAt: [],
- };
+ });
- mutations.SET_LAST_FETCHED_AT(state, 'timestamp');
+ store[types.SET_LAST_FETCHED_AT]('timestamp');
- expect(state.lastFetchedAt).toEqual('timestamp');
+ expect(store.lastFetchedAt).toEqual('timestamp');
});
});
describe('SET_TARGET_NOTE_HASH', () => {
it('should set the note hash', () => {
- const state = {
+ store.$patch({
targetNoteHash: [],
- };
+ });
- mutations.SET_TARGET_NOTE_HASH(state, 'hash');
+ store[types.SET_TARGET_NOTE_HASH]('hash');
- expect(state.targetNoteHash).toEqual('hash');
+ expect(store.targetNoteHash).toEqual('hash');
});
});
describe('SHOW_PLACEHOLDER_NOTE', () => {
it('should set a placeholder note', () => {
- const state = {
+ store.$patch({
discussions: [],
- };
- mutations.SHOW_PLACEHOLDER_NOTE(state, note);
+ });
+ store[types.SHOW_PLACEHOLDER_NOTE](note);
- expect(state.discussions[0].isPlaceholderNote).toEqual(true);
+ expect(store.discussions[0].isPlaceholderNote).toEqual(true);
});
});
describe('TOGGLE_AWARD', () => {
it('should add award if user has not reacted yet', () => {
- const state = {
+ store.$patch({
discussions: [note],
userData: userDataMock,
- };
+ });
const data = {
note,
awardName: 'cartwheel',
};
- mutations.TOGGLE_AWARD(state, data);
- const lastIndex = state.discussions[0].award_emoji.length - 1;
+ store[types.TOGGLE_AWARD](data);
+ const lastIndex = store.discussions[0].award_emoji.length - 1;
- expect(state.discussions[0].award_emoji[lastIndex]).toEqual({
+ expect(store.discussions[0].award_emoji[lastIndex]).toEqual({
name: 'cartwheel',
user: { id: userDataMock.id, name: userDataMock.name, username: userDataMock.username },
});
});
it('should remove award if user already reacted', () => {
- const state = {
+ store.$patch({
discussions: [note],
userData: {
id: 1,
name: 'Administrator',
username: 'root',
},
- };
+ });
const data = {
note,
awardName: 'bath_tone3',
};
- mutations.TOGGLE_AWARD(state, data);
+ store[types.TOGGLE_AWARD](data);
- expect(state.discussions[0].award_emoji.length).toEqual(2);
+ expect(store.discussions[0].award_emoji.length).toEqual(2);
});
});
@@ -347,51 +347,47 @@ describe.skip('Notes Store mutations', () => {
it('should open a closed discussion', () => {
const discussion = { ...discussionMock, expanded: false };
- const state = {
+ store.$patch({
discussions: [discussion],
- };
+ });
- mutations.TOGGLE_DISCUSSION(state, { discussionId: discussion.id });
+ store[types.TOGGLE_DISCUSSION]({ discussionId: discussion.id });
- expect(state.discussions[0].expanded).toEqual(true);
+ expect(store.discussions[0].expanded).toEqual(true);
});
it('should close a opened discussion', () => {
- const state = {
+ store.$patch({
discussions: [discussionMock],
- };
+ });
- mutations.TOGGLE_DISCUSSION(state, { discussionId: discussionMock.id });
+ store[types.TOGGLE_DISCUSSION]({ discussionId: discussionMock.id });
- expect(state.discussions[0].expanded).toEqual(false);
+ expect(store.discussions[0].expanded).toEqual(false);
});
it('forces a discussions expanded state', () => {
- const state = {
+ store.$patch({
discussions: [{ ...discussionMock, expanded: false }],
- };
+ });
- mutations.TOGGLE_DISCUSSION(state, { discussionId: discussionMock.id, forceExpanded: true });
+ store[types.TOGGLE_DISCUSSION]({ discussionId: discussionMock.id, forceExpanded: true });
- expect(state.discussions[0].expanded).toEqual(true);
+ expect(store.discussions[0].expanded).toEqual(true);
});
});
describe('SET_EXPAND_DISCUSSIONS', () => {
it('should succeed when discussions are null', () => {
- const state = {};
-
- mutations.SET_EXPAND_DISCUSSIONS(state, { discussionIds: null, expanded: true });
-
- expect(state).toEqual({});
+ expect(() =>
+ store[types.SET_EXPAND_DISCUSSIONS]({ discussionIds: null, expanded: true }),
+ ).not.toThrow();
});
it('should succeed when discussions are empty', () => {
- const state = {};
+ store[types.SET_EXPAND_DISCUSSIONS]({ discussionIds: [], expanded: true });
- mutations.SET_EXPAND_DISCUSSIONS(state, { discussionIds: [], expanded: true });
-
- expect(state).toEqual({});
+ expect(store.discussions).toEqual([]);
});
it('should open all closed discussions', () => {
@@ -399,11 +395,11 @@ describe.skip('Notes Store mutations', () => {
const discussion2 = { ...discussionMock, id: 1, expanded: true };
const discussionIds = [discussion1.id, discussion2.id];
- const state = { discussions: [discussion1, discussion2] };
+ store.$patch({ discussions: [discussion1, discussion2] });
- mutations.SET_EXPAND_DISCUSSIONS(state, { discussionIds, expanded: true });
+ store[types.SET_EXPAND_DISCUSSIONS]({ discussionIds, expanded: true });
- state.discussions.forEach((discussion) => {
+ store.discussions.forEach((discussion) => {
expect(discussion.expanded).toEqual(true);
});
});
@@ -413,11 +409,11 @@ describe.skip('Notes Store mutations', () => {
const discussion2 = { ...discussionMock, id: 1, expanded: true };
const discussionIds = [discussion1.id, discussion2.id];
- const state = { discussions: [discussion1, discussion2] };
+ store.$patch({ discussions: [discussion1, discussion2] });
- mutations.SET_EXPAND_DISCUSSIONS(state, { discussionIds, expanded: false });
+ store[types.SET_EXPAND_DISCUSSIONS]({ discussionIds, expanded: false });
- state.discussions.forEach((discussion) => {
+ store.discussions.forEach((discussion) => {
expect(discussion.expanded).toEqual(false);
});
});
@@ -425,44 +421,42 @@ describe.skip('Notes Store mutations', () => {
describe('SET_RESOLVING_DISCUSSION', () => {
it('should set resolving discussion state', () => {
- const state = {};
+ store[types.SET_RESOLVING_DISCUSSION](true);
- mutations.SET_RESOLVING_DISCUSSION(state, true);
-
- expect(state.isResolvingDiscussion).toEqual(true);
+ expect(store.isResolvingDiscussion).toEqual(true);
});
});
describe('UPDATE_NOTE', () => {
it('should update a note', () => {
- const state = {
+ store.$patch({
discussions: [individualNote],
- };
+ });
const updated = { ...individualNote.notes[0], note: 'Foo' };
- mutations.UPDATE_NOTE(state, updated);
+ store[types.UPDATE_NOTE](updated);
- expect(state.discussions[0].notes[0].note).toEqual('Foo');
+ expect(store.discussions[0].notes[0].note).toEqual('Foo');
});
it('does not update existing note if it matches', () => {
- const state = {
- discussions: [{ ...individualNote, individual_note: false }],
- };
- jest.spyOn(state.discussions[0].notes, 'splice');
+ const originalNote = { ...individualNote, individual_note: false };
+ store.$patch({
+ discussions: [originalNote],
+ });
const updated = individualNote.notes[0];
- mutations.UPDATE_NOTE(state, updated);
+ store[types.UPDATE_NOTE](updated);
- expect(state.discussions[0].notes.splice).not.toHaveBeenCalled();
+ expect(store.discussions[0]).toStrictEqual(originalNote);
});
it('transforms an individual note to discussion', () => {
- const state = {
+ store.$patch({
discussions: [individualNote],
- };
+ });
const transformedNote = {
...individualNote.notes[0],
@@ -470,14 +464,14 @@ describe.skip('Notes Store mutations', () => {
resolvable: true,
};
- mutations.UPDATE_NOTE(state, transformedNote);
+ store[types.UPDATE_NOTE](transformedNote);
- expect(state.discussions[0].individual_note).toEqual(false);
- expect(state.discussions[0].resolvable).toEqual(true);
+ expect(store.discussions[0].individual_note).toEqual(false);
+ expect(store.discussions[0].resolvable).toEqual(true);
});
it('copies resolve state to discussion', () => {
- const state = { discussions: [{ ...discussionMock }] };
+ store.$patch({ discussions: [{ ...discussionMock }] });
const resolvedNote = {
...discussionMock.notes[0],
@@ -488,18 +482,18 @@ describe.skip('Notes Store mutations', () => {
resolved_by_push: false,
};
- mutations.UPDATE_NOTE(state, resolvedNote);
+ store[types.UPDATE_NOTE](resolvedNote);
- expect(state.discussions[0].resolved).toEqual(resolvedNote.resolved);
- expect(state.discussions[0].resolved_at).toEqual(resolvedNote.resolved_at);
- expect(state.discussions[0].resolved_by).toEqual(resolvedNote.resolved_by);
- expect(state.discussions[0].resolved_by_push).toEqual(resolvedNote.resolved_by_push);
+ expect(store.discussions[0].resolved).toEqual(resolvedNote.resolved);
+ expect(store.discussions[0].resolved_at).toEqual(resolvedNote.resolved_at);
+ expect(store.discussions[0].resolved_by).toEqual(resolvedNote.resolved_by);
+ expect(store.discussions[0].resolved_by_push).toEqual(resolvedNote.resolved_by_push);
});
});
describe('CLOSE_ISSUE', () => {
it('should set issue as closed', () => {
- const state = {
+ store.$patch({
discussions: [],
targetNoteHash: null,
lastFetchedAt: null,
@@ -507,17 +501,17 @@ describe.skip('Notes Store mutations', () => {
notesData: {},
userData: {},
noteableData: {},
- };
+ });
- mutations.CLOSE_ISSUE(state);
+ store[types.CLOSE_ISSUE]();
- expect(state.noteableData.state).toEqual('closed');
+ expect(store.noteableData.state).toEqual('closed');
});
});
describe('REOPEN_ISSUE', () => {
it('should set issue as closed', () => {
- const state = {
+ store.$patch({
discussions: [],
targetNoteHash: null,
lastFetchedAt: null,
@@ -525,17 +519,17 @@ describe.skip('Notes Store mutations', () => {
notesData: {},
userData: {},
noteableData: {},
- };
+ });
- mutations.REOPEN_ISSUE(state);
+ store[types.REOPEN_ISSUE]();
- expect(state.noteableData.state).toEqual('reopened');
+ expect(store.noteableData.state).toEqual('reopened');
});
});
describe('TOGGLE_STATE_BUTTON_LOADING', () => {
it('should set isToggleStateButtonLoading as true', () => {
- const state = {
+ store.$patch({
discussions: [],
targetNoteHash: null,
lastFetchedAt: null,
@@ -543,15 +537,15 @@ describe.skip('Notes Store mutations', () => {
notesData: {},
userData: {},
noteableData: {},
- };
+ });
- mutations.TOGGLE_STATE_BUTTON_LOADING(state, true);
+ store[types.TOGGLE_STATE_BUTTON_LOADING](true);
- expect(state.isToggleStateButtonLoading).toEqual(true);
+ expect(store.isToggleStateButtonLoading).toEqual(true);
});
it('should set isToggleStateButtonLoading as false', () => {
- const state = {
+ store.$patch({
discussions: [],
targetNoteHash: null,
lastFetchedAt: null,
@@ -559,110 +553,104 @@ describe.skip('Notes Store mutations', () => {
notesData: {},
userData: {},
noteableData: {},
- };
+ });
- mutations.TOGGLE_STATE_BUTTON_LOADING(state, false);
+ store[types.TOGGLE_STATE_BUTTON_LOADING](false);
- expect(state.isToggleStateButtonLoading).toEqual(false);
+ expect(store.isToggleStateButtonLoading).toEqual(false);
});
});
describe('SET_NOTES_FETCHED_STATE', () => {
it('should set the given state', () => {
- const state = {
+ store.$patch({
isNotesFetched: false,
- };
+ });
- mutations.SET_NOTES_FETCHED_STATE(state, true);
+ store[types.SET_NOTES_FETCHED_STATE](true);
- expect(state.isNotesFetched).toEqual(true);
+ expect(store.isNotesFetched).toEqual(true);
});
});
describe('SET_DISCUSSION_DIFF_LINES', () => {
it('sets truncated_diff_lines', () => {
- const state = {
+ store.$patch({
discussions: [
{
id: 1,
},
],
- };
+ });
- mutations.SET_DISCUSSION_DIFF_LINES(state, {
+ store[types.SET_DISCUSSION_DIFF_LINES]({
discussionId: 1,
diffLines: [{ text: '+a', rich_text: '+a' }],
});
- expect(state.discussions[0].truncated_diff_lines).toEqual([{ rich_text: 'a' }]);
+ expect(store.discussions[0].truncated_diff_lines).toEqual([{ rich_text: 'a' }]);
});
it('keeps reactivity of discussion', () => {
- const state = {
+ store.$patch({
discussions: [
{
id: 1,
expanded: false,
},
],
- };
+ });
- const discussion = state.discussions[0];
+ const discussion = store.discussions[0];
- mutations.SET_DISCUSSION_DIFF_LINES(state, {
+ store[types.SET_DISCUSSION_DIFF_LINES]({
discussionId: 1,
diffLines: [{ rich_text: 'a' }],
});
discussion.expanded = true;
- expect(state.discussions[0].expanded).toBe(true);
+ expect(store.discussions[0].expanded).toBe(true);
});
});
describe('SET_SELECTED_COMMENT_POSITION', () => {
it('should set comment position state', () => {
- const state = {};
+ store[types.SET_SELECTED_COMMENT_POSITION]({});
- mutations.SET_SELECTED_COMMENT_POSITION(state, {});
-
- expect(state.selectedCommentPosition).toEqual({});
+ expect(store.selectedCommentPosition).toEqual({});
});
});
describe('SET_SELECTED_COMMENT_POSITION_HOVER', () => {
it('should set comment hover position state', () => {
- const state = {};
+ store[types.SET_SELECTED_COMMENT_POSITION_HOVER]({});
- mutations.SET_SELECTED_COMMENT_POSITION_HOVER(state, {});
-
- expect(state.selectedCommentPositionHover).toEqual({});
+ expect(store.selectedCommentPositionHover).toEqual({});
});
});
describe('DISABLE_COMMENTS', () => {
it('should set comments disabled state', () => {
- const state = {};
+ store[types.DISABLE_COMMENTS](true);
- mutations.DISABLE_COMMENTS(state, true);
-
- expect(state.commentsDisabled).toEqual(true);
+ expect(store.commentsDisabled).toEqual(true);
});
});
describe('UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS', () => {
it('with unresolvable discussions, updates state', () => {
- const state = {
+ store.$patch({
discussions: [
{ individual_note: false, resolvable: true, notes: [UNRESOLVED_NOTE] },
{ individual_note: true, resolvable: true, notes: [UNRESOLVED_NOTE] },
{ individual_note: false, resolvable: false, notes: [UNRESOLVED_NOTE] },
],
- };
+ });
- mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state);
+ store[types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS]();
- expect(state).toEqual(
+ expect(store).toEqual(
expect.objectContaining({
resolvableDiscussionsCount: 1,
unresolvedDiscussionsCount: 1,
@@ -671,7 +659,7 @@ describe.skip('Notes Store mutations', () => {
});
it('with resolvable discussions, updates state', () => {
- const state = {
+ store.$patch({
discussions: [
{
individual_note: false,
@@ -694,11 +682,11 @@ describe.skip('Notes Store mutations', () => {
notes: [UNRESOLVED_NOTE],
},
],
- };
+ });
- mutations.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS(state);
+ store[types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS]();
- expect(state).toEqual(
+ expect(store).toEqual(
expect.objectContaining({
resolvableDiscussionsCount: 4,
unresolvedDiscussionsCount: 2,
@@ -709,77 +697,70 @@ describe.skip('Notes Store mutations', () => {
describe('CONVERT_TO_DISCUSSION', () => {
let discussion;
- let state;
beforeEach(() => {
discussion = {
id: 42,
individual_note: true,
};
- state = { convertedDisscussionIds: [] };
});
it('adds a discussion to convertedDisscussionIds', () => {
- mutations.CONVERT_TO_DISCUSSION(state, discussion.id);
+ store[types.CONVERT_TO_DISCUSSION](discussion.id);
- expect(state.convertedDisscussionIds).toContain(discussion.id);
+ expect(store.convertedDisscussionIds).toContain(discussion.id);
});
});
describe('REMOVE_CONVERTED_DISCUSSION', () => {
let discussion;
- let state;
beforeEach(() => {
discussion = {
id: 42,
individual_note: true,
};
- state = { convertedDisscussionIds: [41, 42] };
+ store.$patch({ convertedDisscussionIds: [41, 42] });
});
it('removes a discussion from convertedDisscussionIds', () => {
- mutations.REMOVE_CONVERTED_DISCUSSION(state, discussion.id);
+ store[types.REMOVE_CONVERTED_DISCUSSION](discussion.id);
- expect(state.convertedDisscussionIds).not.toContain(discussion.id);
+ expect(store.convertedDisscussionIds).not.toContain(discussion.id);
});
});
describe('RECEIVE_DESCRIPTION_VERSION', () => {
const descriptionVersion = notesWithDescriptionChanges[0].notes[0].note;
const versionId = notesWithDescriptionChanges[0].notes[0].id;
- const state = {};
it('adds a descriptionVersion', () => {
- mutations.RECEIVE_DESCRIPTION_VERSION(state, { descriptionVersion, versionId });
- expect(state.descriptionVersions[versionId]).toBe(descriptionVersion);
+ store[types.RECEIVE_DESCRIPTION_VERSION]({ descriptionVersion, versionId });
+ expect(store.descriptionVersions[versionId]).toBe(descriptionVersion);
});
});
describe('RECEIVE_DELETE_DESCRIPTION_VERSION', () => {
const descriptionVersion = notesWithDescriptionChanges[0].notes[0].note;
const versionId = notesWithDescriptionChanges[0].notes[0].id;
- const state = { descriptionVersions: { [versionId]: descriptionVersion } };
const deleted = 'Deleted';
+ beforeEach(() => {
+ store.$patch({ descriptionVersions: { [versionId]: descriptionVersion } });
+ });
+
it('updates descriptionVersion to "Deleted"', () => {
- mutations.RECEIVE_DELETE_DESCRIPTION_VERSION(state, { [versionId]: deleted });
- expect(state.descriptionVersions[versionId]).toBe(deleted);
+ store[types.RECEIVE_DELETE_DESCRIPTION_VERSION]({ [versionId]: deleted });
+ expect(store.descriptionVersions[versionId]).toBe(deleted);
});
});
describe('SET_DISCUSSIONS_SORT', () => {
- let state;
-
- beforeEach(() => {
- state = { discussionSortOrder: ASC };
- });
-
it('sets sort order', () => {
- mutations.SET_DISCUSSIONS_SORT(state, { direction: DESC, persist: false });
+ store[types.SET_DISCUSSIONS_SORT]({ direction: DESC, persist: false });
- expect(state.discussionSortOrder).toBe(DESC);
- expect(state.persistSortOrder).toBe(false);
+ expect(store.discussionSortOrder).toBe(DESC);
+ expect(store.persistSortOrder).toBe(false);
});
});
@@ -798,7 +779,6 @@ describe.skip('Notes Store mutations', () => {
}));
};
- let state;
let batchedSuggestionInfo;
let discussions;
let suggestions;
@@ -807,14 +787,14 @@ describe.skip('Notes Store mutations', () => {
[batchedSuggestionInfo] = batchSuggestionsInfoMock;
suggestions = batchSuggestionsInfoMock.map(({ suggestionId }) => ({ id: suggestionId }));
discussions = buildDiscussions(batchSuggestionsInfoMock);
- state = {
+ store.$patch({
batchSuggestionsInfo: [batchedSuggestionInfo],
discussions,
- };
+ });
});
it('sets is_applying_batch to a boolean value for all batched suggestions', () => {
- mutations.SET_APPLYING_BATCH_STATE(state, true);
+ store[types.SET_APPLYING_BATCH_STATE](true);
const updatedSuggestion = {
...suggestions[0],
@@ -823,7 +803,7 @@ describe.skip('Notes Store mutations', () => {
const expectedSuggestions = [updatedSuggestion, suggestions[1]];
- const actualSuggestions = state.discussions
+ const actualSuggestions = store.discussions
.map((discussion) => discussion.notes.map((n) => n.suggestions))
.flat(2);
@@ -832,12 +812,6 @@ describe.skip('Notes Store mutations', () => {
});
describe('ADD_SUGGESTION_TO_BATCH', () => {
- let state;
-
- beforeEach(() => {
- state = { batchSuggestionsInfo: [] };
- });
-
it("adds a suggestion's info to a batch", () => {
const suggestionInfo = {
suggestionId: 'a123',
@@ -845,85 +819,78 @@ describe.skip('Notes Store mutations', () => {
discussionId: 'c789',
};
- mutations.ADD_SUGGESTION_TO_BATCH(state, suggestionInfo);
+ store[types.ADD_SUGGESTION_TO_BATCH](suggestionInfo);
- expect(state.batchSuggestionsInfo).toEqual([suggestionInfo]);
+ expect(store.batchSuggestionsInfo).toEqual([suggestionInfo]);
});
});
describe('REMOVE_SUGGESTION_FROM_BATCH', () => {
- let state;
let suggestionInfo1;
let suggestionInfo2;
beforeEach(() => {
[suggestionInfo1, suggestionInfo2] = batchSuggestionsInfoMock;
- state = {
+ store.$patch({
batchSuggestionsInfo: [suggestionInfo1, suggestionInfo2],
- };
+ });
});
it("removes a suggestion's info from a batch", () => {
- mutations.REMOVE_SUGGESTION_FROM_BATCH(state, suggestionInfo1.suggestionId);
+ store[types.REMOVE_SUGGESTION_FROM_BATCH](suggestionInfo1.suggestionId);
- expect(state.batchSuggestionsInfo).toEqual([suggestionInfo2]);
+ expect(store.batchSuggestionsInfo).toEqual([suggestionInfo2]);
});
});
describe('CLEAR_SUGGESTION_BATCH', () => {
- let state;
-
beforeEach(() => {
- state = {
+ store.$patch({
batchSuggestionsInfo: batchSuggestionsInfoMock,
- };
+ });
});
it('removes info for all suggestions from a batch', () => {
- mutations.CLEAR_SUGGESTION_BATCH(state);
+ store[types.CLEAR_SUGGESTION_BATCH]();
- expect(state.batchSuggestionsInfo.length).toEqual(0);
+ expect(store.batchSuggestionsInfo.length).toEqual(0);
});
});
describe('SET_ISSUE_CONFIDENTIAL', () => {
- let state;
-
beforeEach(() => {
- state = { noteableData: { confidential: false } };
+ store.$patch({ noteableData: { confidential: false } });
});
it('should set issuable as confidential', () => {
- mutations.SET_ISSUE_CONFIDENTIAL(state, true);
+ store[types.SET_ISSUE_CONFIDENTIAL](true);
- expect(state.noteableData.confidential).toBe(true);
+ expect(store.noteableData.confidential).toBe(true);
});
});
describe('SET_ISSUABLE_LOCK', () => {
- let state;
-
beforeEach(() => {
- state = { noteableData: { discussion_locked: false } };
+ store.$patch({ noteableData: { discussion_locked: false } });
});
it('should set issuable as locked', () => {
- mutations.SET_ISSUABLE_LOCK(state, true);
+ store[types.SET_ISSUABLE_LOCK](true);
- expect(state.noteableData.discussion_locked).toBe(true);
+ expect(store.noteableData.discussion_locked).toBe(true);
});
});
describe('UPDATE_ASSIGNEES', () => {
it('should update assignees', () => {
- const state = {
+ store.$patch({
noteableData: noteableDataMock,
- };
+ });
- mutations.UPDATE_ASSIGNEES(state, [userDataMock.id]);
+ store[types.UPDATE_ASSIGNEES]([userDataMock.id]);
- expect(state.noteableData.assignees).toEqual([userDataMock.id]);
+ expect(store.noteableData.assignees).toEqual([userDataMock.id]);
});
});
@@ -932,38 +899,38 @@ describe.skip('Notes Store mutations', () => {
const discussion1 = { id: 1, position: { line_code: 'abc_1_1' } };
const discussion2 = { id: 2, position: { line_code: 'abc_2_2' } };
const discussion3 = { id: 3, position: { line_code: 'abc_3_3' } };
- const state = {
+ store.$patch({
discussions: [discussion1, discussion2, discussion3],
- };
+ });
const discussion1Position = { ...discussion1.position };
const position = { ...discussion1Position, test: true };
- mutations.UPDATE_DISCUSSION_POSITION(state, { discussionId: discussion1.id, position });
- expect(state.discussions[0].position).toEqual(position);
+ store[types.UPDATE_DISCUSSION_POSITION]({ discussionId: discussion1.id, position });
+ expect(store.discussions[0].position).toEqual(position);
});
});
describe('SET_DONE_FETCHING_BATCH_DISCUSSIONS', () => {
it('should set doneFetchingBatchDiscussions', () => {
- const state = {
+ store.$patch({
doneFetchingBatchDiscussions: false,
- };
+ });
- mutations.SET_DONE_FETCHING_BATCH_DISCUSSIONS(state, true);
+ store[types.SET_DONE_FETCHING_BATCH_DISCUSSIONS](true);
- expect(state.doneFetchingBatchDiscussions).toEqual(true);
+ expect(store.doneFetchingBatchDiscussions).toEqual(true);
});
});
describe('SET_EXPAND_ALL_DISCUSSIONS', () => {
it('should set expanded for every discussion', () => {
- const state = {
+ store.$patch({
discussions: [{ expanded: false }, { expanded: false }],
- };
+ });
- mutations.SET_EXPAND_ALL_DISCUSSIONS(state, true);
+ store[types.SET_EXPAND_ALL_DISCUSSIONS](true);
- expect(state.discussions).toStrictEqual([{ expanded: true }, { expanded: true }]);
+ expect(store.discussions).toStrictEqual([{ expanded: true }, { expanded: true }]);
});
});
});
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index 270cb7d09b8..30c68b65da8 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -536,6 +536,71 @@ RSpec.describe SearchHelper, feature_category: :global_search do
end
end
+ describe 'groups_autocomplete' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group_1) { create(:group, name: 'test 1') }
+ let_it_be(:group_2) { create(:group, name: 'test 2') }
+ let(:search_term) { 'test' }
+
+ before do
+ allow(self).to receive(:current_user).and_return(user)
+ end
+
+ context 'when the user does not have access to groups' do
+ it 'does not return any results' do
+ expect(groups_autocomplete(search_term)).to eq([])
+ end
+ end
+
+ context 'when the user has access to one group' do
+ before do
+ group_2.add_developer(user)
+ end
+
+ it 'returns the group' do
+ expect(groups_autocomplete(search_term).pluck(:id)).to eq([group_2.id])
+ end
+
+ context 'when the search term is Gitlab::Search::Params::MIN_TERM_LENGTH characters long' do
+ let(:search_term) { 'te' }
+
+ it 'returns the group' do
+ expect(groups_autocomplete(search_term).pluck(:id)).to eq([group_2.id])
+ end
+ end
+ end
+
+ context 'with feature flag autocomplete_group_search_optimization disabled' do
+ before do
+ stub_feature_flags(autocomplete_group_search_optimization: false)
+ end
+
+ context 'when the user does not have access to groups' do
+ it 'does not return any results' do
+ expect(groups_autocomplete(search_term)).to eq([])
+ end
+ end
+
+ context 'when the user has access to one group' do
+ before do
+ group_2.add_developer(user)
+ end
+
+ it 'returns the group' do
+ expect(groups_autocomplete(search_term).pluck(:id)).to eq([group_2.id])
+ end
+
+ context 'when the search term is Gitlab::Search::Params::MIN_TERM_LENGTH characters long' do
+ let(:search_term) { 'te' }
+
+ it 'returns the group' do
+ expect(groups_autocomplete(search_term).pluck(:id)).to eq([group_2.id])
+ end
+ end
+ end
+ end
+ end
+
describe 'projects_autocomplete' do
let_it_be(:user) { create(:user) }
let_it_be(:project_1) { create(:project, name: 'test 1') }
diff --git a/spec/lib/gitlab/middleware/go_spec.rb b/spec/lib/gitlab/middleware/go_spec.rb
index 055f2528584..2f38c83bb1f 100644
--- a/spec/lib/gitlab/middleware/go_spec.rb
+++ b/spec/lib/gitlab/middleware/go_spec.rb
@@ -205,13 +205,10 @@ RSpec.describe Gitlab::Middleware::Go, feature_category: :source_code_management
env['HTTP_AUTHORIZATION'] = ActionController::HttpAuthentication::Basic.encode_credentials(current_user.username, 'dummy_password')
end
- it 'returns unauthorized' do
+ it 'returns 404' do
expect(Gitlab::Auth).to receive(:find_for_git_client).and_raise(Gitlab::Auth::MissingPersonalAccessTokenError)
- response = go
- expect(response[0]).to eq(401)
- expect(response[1]['Content-Length']).to be_nil
- expect(response[2]).to eq([''])
+ expect_404_response(go)
end
end
end
diff --git a/spec/models/integrations/instance/integration_spec.rb b/spec/models/integrations/instance/integration_spec.rb
new file mode 100644
index 00000000000..83049fe6688
--- /dev/null
+++ b/spec/models/integrations/instance/integration_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Integrations::Instance::Integration, feature_category: :integrations do
+ subject(:instance_integration) { build(:instance_integration) }
+
+ describe '#instance_level?' do
+ it 'returns true' do
+ expect(instance_integration.instance_level?).to be(true)
+ end
+ end
+
+ describe '#group_level?' do
+ it 'returns false' do
+ expect(instance_integration.group_level?).to be(false)
+ end
+ end
+
+ describe '#project_level?' do
+ it 'returns false' do
+ expect(instance_integration.project_level?).to be(false)
+ end
+ end
+
+ describe '.table_name' do
+ it 'returns instance_integrations' do
+ expect(described_class.table_name).to eq('instance_integrations')
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 6542d02b953..0ffefb3fbc3 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -5025,6 +5025,59 @@ RSpec.describe User, feature_category: :user_profile do
end
end
+ describe '#search_on_authorized_groups' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group_1) { create(:group, name: 'test', path: 'blah') }
+ let_it_be(:group_2) { create(:group, name: 'blah', path: 'test') }
+ let(:search_term) { 'test' }
+
+ subject { user.search_on_authorized_groups(search_term) }
+
+ context 'when the user does not have any authorized groups' do
+ before do
+ allow(user).to receive(:authorized_groups).and_return(Group.none)
+ end
+
+ it 'does not return anything' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when the user has two authorized groups with name or path matching the search term' do
+ before do
+ allow(user).to receive(:authorized_groups).and_return(Group.id_in([group_1.id, group_2.id]))
+ end
+
+ it 'returns the groups' do
+ expect(subject).to match_array([group_1, group_2])
+ end
+
+ context 'if the search term does not match on name or path' do
+ let(:search_term) { 'unknown' }
+
+ it 'does not return anything' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'if the search term is less than MIN_CHARS_FOR_PARTIAL_MATCHING' do
+ let(:search_term) { 'te' }
+
+ it 'does not return anything' do
+ expect(subject).to be_empty
+ end
+
+ context 'if use_minimum_char_limit is false' do
+ subject { user.search_on_authorized_groups(search_term, use_minimum_char_limit: false) }
+
+ it 'returns the groups' do
+ expect(subject).to match_array([group_1, group_2])
+ end
+ end
+ end
+ end
+ end
+
describe '#membership_groups' do
let_it_be(:user) { create(:user) }
diff --git a/spec/scripts/internal_events/server_spec.rb b/spec/scripts/internal_events/server_spec.rb
index f75936e0c73..b3b1693591a 100644
--- a/spec/scripts/internal_events/server_spec.rb
+++ b/spec/scripts/internal_events/server_spec.rb
@@ -162,7 +162,7 @@ RSpec.describe Server, feature_category: :service_ping do
}
end
- it 'successfully parses event' do
+ it 'successfully parses event', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/498773' do
expect(response.code).to eq('200')
expect(events).to contain_exactly(expected_event)
end
@@ -206,7 +206,7 @@ RSpec.describe Server, feature_category: :service_ping do
await { Net::HTTP.get url_for("/i?#{query_params}") }
end
- it 'successfully returns tracked events' do
+ it 'successfully returns tracked events', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/498778' do
expect(response.code).to eq('200')
expect(response.body).to eq([{
event: {
diff --git a/spec/services/projects/participants_service_spec.rb b/spec/services/projects/participants_service_spec.rb
index 226caf50936..ffaa955df67 100644
--- a/spec/services/projects/participants_service_spec.rb
+++ b/spec/services/projects/participants_service_spec.rb
@@ -73,6 +73,20 @@ RSpec.describe Projects::ParticipantsService, feature_category: :groups_and_proj
expect(participants.count { |p| p[:username] == noteable.author.username }).to eq 1
end
+ context 'when noteable.participants contains placeholder or import users' do
+ let(:placeholder_user) { create(:user, :placeholder) }
+ let(:import_user) { create(:user, :import_user) }
+
+ it 'does not return the placeholder and import users' do
+ allow(noteable).to receive(:participants).and_return([user, placeholder_user, import_user])
+
+ participant_usernames = run_service.map { |user| user[:username] }
+
+ expect(participant_usernames).not_to include(placeholder_user.username, import_user.username)
+ expect(participant_usernames).to include(user.username)
+ end
+ end
+
describe 'group items' do
subject(:group_items) { run_service.select { |hash| hash[:type].eql?('Group') } }
diff --git a/spec/support/shared_examples/workers/idempotency_shared_examples.rb b/spec/support/shared_examples/workers/idempotency_shared_examples.rb
index 88a72d1cba9..2f7e8dc06c2 100644
--- a/spec/support/shared_examples/workers/idempotency_shared_examples.rb
+++ b/spec/support/shared_examples/workers/idempotency_shared_examples.rb
@@ -8,7 +8,7 @@
# it_behaves_like 'an idempotent worker' do
# it 'checks the side-effects for multiple calls' do
# # it'll call the job's perform method 2 times
-# subject
+# perform_idempotent_work
#
# expect(model.state).to eq('state')
# end
@@ -31,7 +31,7 @@ RSpec.shared_examples 'an idempotent worker' do
end
it 'performs multiple times sequentially without raising an exception' do
- expect { subject }.not_to raise_error
+ expect { perform_idempotent_work }.not_to raise_error
end
def event_worker
diff --git a/yarn.lock b/yarn.lock
index b14e5c3b90a..49b51c2bebe 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2466,76 +2466,76 @@
resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8"
integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==
-"@sentry-internal/browser-utils@8.35.0":
- version "8.35.0"
- resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.35.0.tgz#92602f8dd2bb777af2994eb446cb3cf71bf0cfad"
- integrity sha512-uj9nwERm7HIS13f/Q52hF/NUS5Al8Ma6jkgpfYGeppYvU0uSjPkwMogtqoJQNbOoZg973tV8qUScbcWY616wNA==
+"@sentry-internal/browser-utils@8.36.0":
+ version "8.36.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/browser-utils/-/browser-utils-8.36.0.tgz#c6a1b6d7eb2ccfc2f9c189504d94b3996367ccbf"
+ integrity sha512-AVJ9GmQW7jYxaal6hjQnnktsDNype01ajVC4q1RyOn1SfzSnXg6mXwj4xm4ovuJV+aBI7fAZJ55vEX5ASuP0ZA==
dependencies:
- "@sentry/core" "8.35.0"
- "@sentry/types" "8.35.0"
- "@sentry/utils" "8.35.0"
+ "@sentry/core" "8.36.0"
+ "@sentry/types" "8.36.0"
+ "@sentry/utils" "8.36.0"
-"@sentry-internal/feedback@8.35.0":
- version "8.35.0"
- resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.35.0.tgz#b31fb7fbec8ecd9cc683948a0d1af2b87731b0a1"
- integrity sha512-7bjSaUhL0bDArozre6EiIhhdWdT/1AWNWBC1Wc5w1IxEi5xF7nvF/FfvjQYrONQzZAI3HRxc45J2qhLUzHBmoQ==
+"@sentry-internal/feedback@8.36.0":
+ version "8.36.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-8.36.0.tgz#2cda799f921543522aa5cbf8f9c3300209b54825"
+ integrity sha512-aAMTm3uDBj8Ta7FwoohpLmJOpWzpWXvvtTbtmSgkeCtPJLUS8DZDCTZ9uCILUkpuYrv2savRUHsdPkxNjgL8FA==
dependencies:
- "@sentry/core" "8.35.0"
- "@sentry/types" "8.35.0"
- "@sentry/utils" "8.35.0"
+ "@sentry/core" "8.36.0"
+ "@sentry/types" "8.36.0"
+ "@sentry/utils" "8.36.0"
-"@sentry-internal/replay-canvas@8.35.0":
- version "8.35.0"
- resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.35.0.tgz#de7849e0d4212ee37a9225b1fc346188d9b05072"
- integrity sha512-TUrH6Piv19kvHIiRyIuapLdnuwxk/Un/l1WDCQfq7mK9p1Pac0FkQ7Uufjp6zY3lyhDDZQ8qvCS4ioCMibCwQg==
+"@sentry-internal/replay-canvas@8.36.0":
+ version "8.36.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/replay-canvas/-/replay-canvas-8.36.0.tgz#557c8677f6c386cf79b5f8ab7485b31f4347a6cb"
+ integrity sha512-KJPLf+qYdrQdmouoAqIPZ2KeapIBlHWbzNdQqNxJFWLHFFjpLUtt0b+87ruvbA/q3NYy2fDwD7EB0tGS1RHBaA==
dependencies:
- "@sentry-internal/replay" "8.35.0"
- "@sentry/core" "8.35.0"
- "@sentry/types" "8.35.0"
- "@sentry/utils" "8.35.0"
+ "@sentry-internal/replay" "8.36.0"
+ "@sentry/core" "8.36.0"
+ "@sentry/types" "8.36.0"
+ "@sentry/utils" "8.36.0"
-"@sentry-internal/replay@8.35.0":
- version "8.35.0"
- resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.35.0.tgz#f71abae95cb492a54b43885386adbc5c639486c7"
- integrity sha512-3wkW03vXYMyWtTLxl9yrtkV+qxbnKFgfASdoGWhXzfLjycgT6o4/04eb3Gn71q9aXqRwH17ISVQbVswnRqMcmA==
+"@sentry-internal/replay@8.36.0":
+ version "8.36.0"
+ resolved "https://registry.yarnpkg.com/@sentry-internal/replay/-/replay-8.36.0.tgz#8f5dccab0bcc3429650a461c46a01085f2f8b782"
+ integrity sha512-lbic98GsSkDeinQDix54tBFEgHUlmBtO+HjXECk9jIE0vOzR4As20/s5ta46t1rKMLlnxOtJuT5jKXeUYogBUw==
dependencies:
- "@sentry-internal/browser-utils" "8.35.0"
- "@sentry/core" "8.35.0"
- "@sentry/types" "8.35.0"
- "@sentry/utils" "8.35.0"
+ "@sentry-internal/browser-utils" "8.36.0"
+ "@sentry/core" "8.36.0"
+ "@sentry/types" "8.36.0"
+ "@sentry/utils" "8.36.0"
-"@sentry/browser@8.35.0":
- version "8.35.0"
- resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.35.0.tgz#67820951fd092ef72ee1a4897464bc7c8d317d77"
- integrity sha512-WHfI+NoZzpCsmIvtr6ChOe7yWPLQyMchPnVhY3Z4UeC70bkYNdKcoj/4XZbX3m0D8+71JAsm0mJ9s9OC3Ue6MQ==
+"@sentry/browser@8.36.0":
+ version "8.36.0"
+ resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-8.36.0.tgz#da8977b37e56e5436cf3ea2c2e4a098f480ab115"
+ integrity sha512-bLrQNe+wD4DkCfB8OD5TF3Rr8KA2+aTo5wF3t3Bf6KVn8//iX1ia1hhtptYiRnbRkG/0AEPxlqL6XfPZYVPQ5A==
dependencies:
- "@sentry-internal/browser-utils" "8.35.0"
- "@sentry-internal/feedback" "8.35.0"
- "@sentry-internal/replay" "8.35.0"
- "@sentry-internal/replay-canvas" "8.35.0"
- "@sentry/core" "8.35.0"
- "@sentry/types" "8.35.0"
- "@sentry/utils" "8.35.0"
+ "@sentry-internal/browser-utils" "8.36.0"
+ "@sentry-internal/feedback" "8.36.0"
+ "@sentry-internal/replay" "8.36.0"
+ "@sentry-internal/replay-canvas" "8.36.0"
+ "@sentry/core" "8.36.0"
+ "@sentry/types" "8.36.0"
+ "@sentry/utils" "8.36.0"
-"@sentry/core@8.35.0":
- version "8.35.0"
- resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.35.0.tgz#17090f4d2d3bb983d9d99ecd2d27f4e9e107e0b0"
- integrity sha512-Ci0Nmtw5ETWLqQJGY4dyF+iWh7PWKy6k303fCEoEmqj2czDrKJCp7yHBNV0XYbo00prj2ZTbCr6I7albYiyONA==
+"@sentry/core@8.36.0":
+ version "8.36.0"
+ resolved "https://registry.yarnpkg.com/@sentry/core/-/core-8.36.0.tgz#34276354f0cd2298803c2f8d86ba571473435ff1"
+ integrity sha512-cbq1WQyRqc/+YpPhjwQxfniUM3ZxmO3Pm1oisTB8dw6mlbgQfGD6aznEIjXWWJY6k6acewJlMUx09N7DnprtBw==
dependencies:
- "@sentry/types" "8.35.0"
- "@sentry/utils" "8.35.0"
+ "@sentry/types" "8.36.0"
+ "@sentry/utils" "8.36.0"
-"@sentry/types@8.35.0":
- version "8.35.0"
- resolved "https://registry.yarnpkg.com/@sentry/types/-/types-8.35.0.tgz#535c807800f7e378f61416f30177c0ef81b95012"
- integrity sha512-AVEZjb16MlYPifiDDvJ19dPQyDn0jlrtC1PHs6ZKO+Rzyz+2EX2BRdszvanqArldexPoU1p5Bn2w81XZNXThBA==
+"@sentry/types@8.36.0":
+ version "8.36.0"
+ resolved "https://registry.yarnpkg.com/@sentry/types/-/types-8.36.0.tgz#b58397eb672d896b65b06103feb59dba74da9d39"
+ integrity sha512-K1pVFfdGHw115RzGHpwSOqoEPeayn4N1F9IfM0kxrYpQSbFT1X29eak88GBfC8gPiLEF0iFGlSaQ4ERmF7oRcA==
-"@sentry/utils@8.35.0":
- version "8.35.0"
- resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-8.35.0.tgz#1e099fcbc60040091c79f028a83226c145d588ee"
- integrity sha512-MdMb6+uXjqND7qIPWhulubpSeHzia6HtxeJa8jYI09OCvIcmNGPydv/Gx/LZBwosfMHrLdTWcFH7Y7aCxrq7cg==
+"@sentry/utils@8.36.0":
+ version "8.36.0"
+ resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-8.36.0.tgz#e733042ae231fdeeafe6970e49283dcd9ac9700f"
+ integrity sha512-oJ3EDPj0I00z+AwC3EWBpSidXYUoKW0Id8MfMQP5Hflniz3gif7UEReblT+FJgPEVo6+6uNzAncY0MuNMxmDKQ==
dependencies:
- "@sentry/types" "8.35.0"
+ "@sentry/types" "8.36.0"
"@sinclair/typebox@^0.27.8":
version "0.27.8"