Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-09-10 06:10:59 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-09-10 06:10:59 +0300
commitdb6b854ea711b395c17827a5047f54dc29b518f9 (patch)
tree5c853b091a3a4068a3a091a33929128900c1a47b
parent8f8e342720899033f06747430414d2d2e3e6527a (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/header_search/components/app.vue42
-rw-r--r--app/assets/javascripts/header_search/components/header_search_default_items.vue42
-rw-r--r--app/assets/javascripts/header_search/constants.js11
-rw-r--r--app/assets/javascripts/header_search/index.js6
-rw-r--r--app/assets/javascripts/header_search/store/getters.js50
-rw-r--r--app/assets/javascripts/header_search/store/index.js14
-rw-r--r--app/assets/javascripts/header_search/store/state.js6
-rw-r--r--app/assets/stylesheets/pages/search.scss9
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb2
-rw-r--r--app/models/internal_id.rb166
-rw-r--r--app/models/repository.rb8
-rw-r--r--app/views/layouts/header/_default.html.haml4
-rw-r--r--config/feature_flags/development/ensure_verified_primary_email_for_2fa.yml2
-rw-r--r--config/feature_flags/development/generate_iids_without_explicit_locking.yml8
-rw-r--r--config/feature_flags/development/preload_repo_cache.yml8
-rw-r--r--doc/ci/environments/index.md26
-rw-r--r--doc/ci/review_apps/index.md2
-rw-r--r--doc/ci/yaml/includes.md185
-rw-r--r--doc/ci/yaml/index.md10
-rw-r--r--doc/user/profile/account/two_factor_authentication.md15
-rw-r--r--doc/user/project/merge_requests/fail_fast_testing.md2
-rw-r--r--doc/user/project/merge_requests/load_performance_testing.md2
-rw-r--r--lib/api/entities/project.rb4
-rw-r--r--lib/api/projects_relation_builder.rb16
-rw-r--r--lib/gitlab/repository_cache/preloader.rb40
-rw-r--r--lib/gitlab/repository_cache_adapter.rb4
-rw-r--r--locale/gitlab.pot18
-rw-r--r--spec/frontend/header_search/components/app_spec.js72
-rw-r--r--spec/frontend/header_search/components/header_search_default_items_spec.js81
-rw-r--r--spec/frontend/header_search/mock_data.js43
-rw-r--r--spec/frontend/header_search/store/getters_spec.js92
-rw-r--r--spec/lib/gitlab/repository_cache/preloader_spec.rb54
-rw-r--r--spec/models/internal_id_spec.rb311
33 files changed, 861 insertions, 494 deletions
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index 0c4df9cf522..cdec3d8eec3 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -1,20 +1,56 @@
<script>
-import { GlSearchBoxByType } from '@gitlab/ui';
+import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import { __ } from '~/locale';
+import HeaderSearchDefaultItems from './header_search_default_items.vue';
export default {
name: 'HeaderSearchApp',
i18n: {
searchPlaceholder: __('Search or jump to...'),
},
+ directives: { Outside },
components: {
GlSearchBoxByType,
+ HeaderSearchDefaultItems,
+ },
+ data() {
+ return {
+ showDropdown: false,
+ };
+ },
+ computed: {
+ showSearchDropdown() {
+ return this.showDropdown && gon?.current_username;
+ },
+ },
+ methods: {
+ openDropdown() {
+ this.showDropdown = true;
+ },
+ closeDropdown() {
+ this.showDropdown = false;
+ },
},
};
</script>
<template>
- <section class="header-search">
- <gl-search-box-by-type autocomplete="off" :placeholder="$options.i18n.searchPlaceholder" />
+ <section v-outside="closeDropdown" class="header-search gl-relative">
+ <gl-search-box-by-type
+ autocomplete="off"
+ :placeholder="$options.i18n.searchPlaceholder"
+ @focus="openDropdown"
+ @click="openDropdown"
+ @keydown.esc="closeDropdown"
+ />
+ <div
+ v-if="showSearchDropdown"
+ data-testid="header-search-dropdown-menu"
+ class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-left-0 gl-z-index-1 gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
+ >
+ <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2">
+ <header-search-default-items />
+ </div>
+ </div>
</section>
</template>
diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue
new file mode 100644
index 00000000000..2871937ed3a
--- /dev/null
+++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { mapState, mapGetters } from 'vuex';
+import { __ } from '~/locale';
+
+export default {
+ name: 'HeaderSearchDefaultItems',
+ i18n: {
+ allGitLab: __('All GitLab'),
+ },
+ components: {
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ },
+ computed: {
+ ...mapState(['searchContext']),
+ ...mapGetters(['defaultSearchOptions']),
+ sectionHeader() {
+ return (
+ this.searchContext.project?.name ||
+ this.searchContext.group?.name ||
+ this.$options.i18n.allGitLab
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header>
+ <gl-dropdown-item
+ v-for="(option, index) in defaultSearchOptions"
+ :id="`default-${index}`"
+ :key="index"
+ tabindex="-1"
+ :href="option.url"
+ >
+ {{ option.title }}
+ </gl-dropdown-item>
+ </div>
+</template>
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
new file mode 100644
index 00000000000..64e56156c2f
--- /dev/null
+++ b/app/assets/javascripts/header_search/constants.js
@@ -0,0 +1,11 @@
+import { __ } from '~/locale';
+
+export const MSG_ISSUES_ASSIGNED_TO_ME = __('Issues assigned to me');
+
+export const MSG_ISSUES_IVE_CREATED = __("Issues I've created");
+
+export const MSG_MR_ASSIGNED_TO_ME = __('Merge requests assigned to me');
+
+export const MSG_MR_IM_REVIEWER = __("Merge requests that I'm a reviewer");
+
+export const MSG_MR_IVE_CREATED = __("Merge requests I've created");
diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js
index fa1ac71655c..0881db16be3 100644
--- a/app/assets/javascripts/header_search/index.js
+++ b/app/assets/javascripts/header_search/index.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import HeaderSearchApp from './components/app.vue';
+import createStore from './store';
Vue.use(Translate);
@@ -11,8 +12,13 @@ export const initHeaderSearchApp = () => {
return false;
}
+ const { issuesPath, mrPath } = el.dataset;
+ let { searchContext } = el.dataset;
+ searchContext = JSON.parse(searchContext);
+
return new Vue({
el,
+ store: createStore({ issuesPath, mrPath, searchContext }),
render(createElement) {
return createElement(HeaderSearchApp);
},
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
new file mode 100644
index 00000000000..1feb0e519ba
--- /dev/null
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -0,0 +1,50 @@
+import {
+ MSG_ISSUES_ASSIGNED_TO_ME,
+ MSG_ISSUES_IVE_CREATED,
+ MSG_MR_ASSIGNED_TO_ME,
+ MSG_MR_IM_REVIEWER,
+ MSG_MR_IVE_CREATED,
+} from '../constants';
+
+export const scopedIssuesPath = (state) => {
+ return (
+ state.searchContext.project_metadata?.issues_path ||
+ state.searchContext.group_metadata?.issues_path ||
+ state.issuesPath
+ );
+};
+
+export const scopedMRPath = (state) => {
+ return (
+ state.searchContext.project_metadata?.mr_path ||
+ state.searchContext.group_metadata?.mr_path ||
+ state.mrPath
+ );
+};
+
+export const defaultSearchOptions = (state, getters) => {
+ const userName = gon.current_username;
+
+ return [
+ {
+ title: MSG_ISSUES_ASSIGNED_TO_ME,
+ url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
+ },
+ {
+ title: MSG_ISSUES_IVE_CREATED,
+ url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
+ },
+ {
+ title: MSG_MR_ASSIGNED_TO_ME,
+ url: `${getters.scopedMRPath}/?assignee_username=${userName}`,
+ },
+ {
+ title: MSG_MR_IM_REVIEWER,
+ url: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
+ },
+ {
+ title: MSG_MR_IVE_CREATED,
+ url: `${getters.scopedMRPath}/?author_username=${userName}`,
+ },
+ ];
+};
diff --git a/app/assets/javascripts/header_search/store/index.js b/app/assets/javascripts/header_search/store/index.js
new file mode 100644
index 00000000000..066e02aed9f
--- /dev/null
+++ b/app/assets/javascripts/header_search/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as getters from './getters';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export const getStoreConfig = ({ issuesPath, mrPath, searchContext }) => ({
+ getters,
+ state: createState({ issuesPath, mrPath, searchContext }),
+});
+
+const createStore = (config) => new Vuex.Store(getStoreConfig(config));
+export default createStore;
diff --git a/app/assets/javascripts/header_search/store/state.js b/app/assets/javascripts/header_search/store/state.js
new file mode 100644
index 00000000000..94a238a24ee
--- /dev/null
+++ b/app/assets/javascripts/header_search/store/state.js
@@ -0,0 +1,6 @@
+const createState = ({ issuesPath, mrPath, searchContext }) => ({
+ issuesPath,
+ mrPath,
+ searchContext,
+});
+export default createState;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 889043b320f..2e6c6a021f8 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -47,6 +47,15 @@ input[type='checkbox']:hover {
}
}
+.header-search-dropdown-menu {
+ max-height: $dropdown-max-height;
+ top: $header-height;
+}
+
+.header-search-dropdown-content {
+ max-height: $dropdown-max-height;
+}
+
.search {
margin: 0 8px;
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index 54ecb146364..5eb46421583 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -221,7 +221,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
end
def ensure_verified_primary_email
- return unless Feature.enabled?(:ensure_verified_primary_email_for_2fa)
+ return unless Feature.enabled?(:ensure_verified_primary_email_for_2fa, default_enabled: :yaml)
unless current_user.two_factor_enabled? || current_user.primary_email_verified?
redirect_to profile_emails_path, notice: s_('You need to verify your primary email first before enabling Two-Factor Authentication.')
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index 1e0e4f3d4c1..10d24ab50b2 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -4,7 +4,7 @@
# generated for a given scope and usage.
#
# The monotone sequence may be broken if an ID is explicitly provided
-# to `.track_greatest_and_save!` or `#track_greatest`.
+# to `#track_greatest`.
#
# For example, issues use their project to scope internal ids:
# In that sense, scope is "project" and usage is "issues".
@@ -29,32 +29,6 @@ class InternalId < ApplicationRecord
where(**scope, usage: usage)
end
- # Increments #last_value and saves the record
- #
- # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
- # As such, the increment is atomic and safe to be called concurrently.
- def increment_and_save!
- update_and_save { self.last_value = (last_value || 0) + 1 }
- end
-
- # Increments #last_value with new_value if it is greater than the current,
- # and saves the record
- #
- # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL).
- # As such, the increment is atomic and safe to be called concurrently.
- def track_greatest_and_save!(new_value)
- update_and_save { self.last_value = [last_value || 0, new_value].max }
- end
-
- private
-
- def update_and_save(&block)
- lock!
- yield
- save!
- last_value
- end
-
class << self
def track_greatest(subject, scope, usage, new_value, init)
build_generator(subject, scope, usage, init).track_greatest(new_value)
@@ -99,143 +73,7 @@ class InternalId < ApplicationRecord
private
def build_generator(subject, scope, usage, init = nil)
- if Feature.enabled?(:generate_iids_without_explicit_locking)
- ImplicitlyLockingInternalIdGenerator.new(subject, scope, usage, init)
- else
- InternalIdGenerator.new(subject, scope, usage, init)
- end
- end
- end
-
- class InternalIdGenerator
- # Generate next internal id for a given scope and usage.
- #
- # For currently supported usages, see #usage enum.
- #
- # The method implements a locking scheme that has the following properties:
- # 1) Generated sequence of internal ids is unique per (scope and usage)
- # 2) The method is thread-safe and may be used in concurrent threads/processes.
- # 3) The generated sequence is gapless.
- # 4) In the absence of a record in the internal_ids table, one will be created
- # and last_value will be calculated on the fly.
- #
- # subject: The instance or class we're generating an internal id for.
- # scope: Attributes that define the scope for id generation.
- # Valid keys are `project/project_id` and `namespace/namespace_id`.
- # usage: Symbol to define the usage of the internal id, see InternalId.usages
- # init: Proc that accepts the subject and the scope and returns Integer|NilClass
- attr_reader :subject, :scope, :scope_attrs, :usage, :init
-
- def initialize(subject, scope, usage, init = nil)
- @subject = subject
- @scope = scope
- @usage = usage
- @init = init
-
- raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
-
- unless InternalId.usages.has_key?(usage.to_s)
- raise ArgumentError, "Usage '#{usage}' is unknown. Supported values are #{InternalId.usages.keys} from InternalId.usages"
- end
- end
-
- # Generates next internal id and returns it
- # init: Block that gets called to initialize InternalId record if not present
- # Make sure to not throw exceptions in the absence of records (if this is expected).
- def generate
- InternalId.internal_id_transactions_increment(operation: :generate, usage: usage)
-
- subject.transaction do
- # Create a record in internal_ids if one does not yet exist
- # and increment its last value
- #
- # Note this will acquire a ROW SHARE lock on the InternalId record
- record.increment_and_save!
- end
- end
-
- # Reset tries to rewind to `value-1`. This will only succeed,
- # if `value` stored in database is equal to `last_value`.
- # value: The expected last_value to decrement
- def reset(value)
- return false unless value
-
- InternalId.internal_id_transactions_increment(operation: :reset, usage: usage)
-
- updated =
- InternalId
- .where(**scope, usage: usage_value)
- .where(last_value: value)
- .update_all('last_value = last_value - 1')
-
- updated > 0
- end
-
- # Create a record in internal_ids if one does not yet exist
- # and set its new_value if it is higher than the current last_value
- #
- # Note this will acquire a ROW SHARE lock on the InternalId record
-
- def track_greatest(new_value)
- InternalId.internal_id_transactions_increment(operation: :track_greatest, usage: usage)
-
- subject.transaction do
- record.track_greatest_and_save!(new_value)
- end
- end
-
- def record
- @record ||= (lookup || create_record)
- end
-
- def with_lock(&block)
- InternalId.internal_id_transactions_increment(operation: :with_lock, usage: usage)
-
- record.with_lock(&block)
- end
-
- private
-
- # Retrieve InternalId record for (project, usage) combination, if it exists
- def lookup
- InternalId.find_by(**scope, usage: usage_value)
- end
-
- def initial_value(subject, scope)
- raise ArgumentError, 'Cannot initialize without init!' unless init
-
- # `init` computes the maximum based on actual records. We use the
- # primary to make sure we have up to date results
- Gitlab::Database::LoadBalancing::Session.current.use_primary do
- instance = subject.is_a?(::Class) ? nil : subject
-
- init.call(instance, scope) || 0
- end
- end
-
- def usage_value
- @usage_value ||= InternalId.usages[usage.to_s]
- end
-
- # Create InternalId record for (scope, usage) combination, if it doesn't exist
- #
- # We blindly insert ignoring conflicts on the unique key constraint.
- # If another process was faster in doing this, we'll end up with that record
- # when we do the lookup after the insert.
- def create_record
- scope[:project].save! if scope[:project] && !scope[:project].persisted?
- scope[:namespace].save! if scope[:namespace] && !scope[:namespace].persisted?
-
- attributes = {
- project_id: scope[:project]&.id || scope[:project_id],
- namespace_id: scope[:namespace]&.id || scope[:namespace_id],
- usage: usage_value,
- last_value: initial_value(subject, scope)
- }
-
- InternalId.insert(attributes)
-
- lookup
+ ImplicitlyLockingInternalIdGenerator.new(subject, scope, usage, init)
end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 07d9bcb9368..5c5ea8af22b 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1132,6 +1132,10 @@ class Repository
end
end
+ def cache
+ @cache ||= Gitlab::RepositoryCache.new(self)
+ end
+
private
# TODO Genericize finder, later split this on finders by Ref or Oid
@@ -1146,10 +1150,6 @@ class Repository
::Commit.new(commit, container) if commit
end
- def cache
- @cache ||= Gitlab::RepositoryCache.new(self)
- end
-
def redis_set_cache
@redis_set_cache ||= Gitlab::RepositorySetCache.new(self)
end
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index fef381d3414..335e997c205 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -31,7 +31,9 @@
%li.nav-item.d-none.d-lg-block.m-auto
- unless current_controller?(:search)
- if Feature.enabled?(:new_header_search)
- #js-header-search.header-search{ }
+ #js-header-search.header-search{ data: { 'search-context' => search_context.to_json,
+ 'issues-path' => issues_dashboard_path,
+ 'mr-path' => merge_requests_dashboard_path } }
%input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input' }
- else
= render 'layouts/search'
diff --git a/config/feature_flags/development/ensure_verified_primary_email_for_2fa.yml b/config/feature_flags/development/ensure_verified_primary_email_for_2fa.yml
index 65c2253585c..7a52486d356 100644
--- a/config/feature_flags/development/ensure_verified_primary_email_for_2fa.yml
+++ b/config/feature_flags/development/ensure_verified_primary_email_for_2fa.yml
@@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/340151
milestone: '14.3'
type: development
group: group::access
-default_enabled: false
+default_enabled: true
diff --git a/config/feature_flags/development/generate_iids_without_explicit_locking.yml b/config/feature_flags/development/generate_iids_without_explicit_locking.yml
deleted file mode 100644
index d2a7aeb8619..00000000000
--- a/config/feature_flags/development/generate_iids_without_explicit_locking.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: generate_iids_without_explicit_locking
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/65590
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/335431
-milestone: '14.2'
-type: development
-group: group::database
-default_enabled: false
diff --git a/config/feature_flags/development/preload_repo_cache.yml b/config/feature_flags/development/preload_repo_cache.yml
new file mode 100644
index 00000000000..42f0ac7dacd
--- /dev/null
+++ b/config/feature_flags/development/preload_repo_cache.yml
@@ -0,0 +1,8 @@
+---
+name: preload_repo_cache
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69627
+rollout_issue_url:
+milestone: '14.3'
+type: development
+group: group::project management
+default_enabled: false
diff --git a/doc/ci/environments/index.md b/doc/ci/environments/index.md
index f0204180d8a..d9cf1b75d67 100644
--- a/doc/ci/environments/index.md
+++ b/doc/ci/environments/index.md
@@ -99,7 +99,7 @@ deploy_review:
script:
- echo "Deploy a review app"
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_ENVIRONMENT_SLUG.example.com
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
@@ -109,9 +109,9 @@ deploy_review:
In this example:
-- The `name` is `review/$CI_COMMIT_REF_NAME`. Because the [environment name](../yaml/index.md#environmentname)
+- The `name` is `review/$CI_COMMIT_REF_SLUG`. Because the [environment name](../yaml/index.md#environmentname)
can contain slashes (`/`), you can use this pattern to distinguish between dynamic and static environments.
-- For the `url`, you could use `$CI_COMMIT_REF_NAME`, but because this value
+- For the `url`, you could use `$CI_COMMIT_REF_SLUG`, but because this value
may contain a `/` or other characters that would not be valid in a domain name or URL,
use `$CI_ENVIRONMENT_SLUG` instead. The `$CI_ENVIRONMENT_SLUG` variable is guaranteed to be unique.
@@ -385,7 +385,7 @@ deploy_review:
script:
- echo "Deploy a review app"
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_ENVIRONMENT_SLUG.example.com
on_stop: stop_review
rules:
@@ -396,7 +396,7 @@ stop_review:
script:
- echo "Remove review app"
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
action: stop
rules:
- if: $CI_MERGE_REQUEST_ID
@@ -440,7 +440,7 @@ is created or updated. The environment runs until `stop_review_app` is executed:
review_app:
script: deploy-review-app
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
on_stop: stop_review_app
auto_stop_in: 1 week
rules:
@@ -449,7 +449,7 @@ review_app:
stop_review_app:
script: stop-review-app
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
action: stop
rules:
- if: $CI_MERGE_REQUEST_ID
@@ -538,7 +538,7 @@ then in the UI, the environments are grouped under that heading:
![Environment groups](img/environments_dynamic_groups_v13_10.png)
The following example shows how to start your environment names with `review`.
-The `$CI_COMMIT_REF_NAME` variable is populated with the branch name at runtime:
+The `$CI_COMMIT_REF_SLUG` variable is populated with the branch name at runtime:
```yaml
deploy_review:
@@ -546,7 +546,7 @@ deploy_review:
script:
- echo "Deploy a review app"
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
```
### Environment incident management
@@ -776,14 +776,14 @@ To ensure the `action: stop` can always run when needed, you can:
deploy_review:
stage: deploy
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_ENVIRONMENT_SLUG.example.com
on_stop: stop_review
stop_review:
stage: deploy
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
action: stop
when: manual
```
@@ -803,7 +803,7 @@ To ensure the `action: stop` can always run when needed, you can:
deploy_review:
stage: deploy
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_ENVIRONMENT_SLUG.example.com
on_stop: stop_review
@@ -812,7 +812,7 @@ To ensure the `action: stop` can always run when needed, you can:
needs:
- deploy_review
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
action: stop
when: manual
```
diff --git a/doc/ci/review_apps/index.md b/doc/ci/review_apps/index.md
index 8af04388f92..1b926d506c4 100644
--- a/doc/ci/review_apps/index.md
+++ b/doc/ci/review_apps/index.md
@@ -57,7 +57,7 @@ The process of configuring Review Apps is as follows:
1. Set up the infrastructure to host and deploy the Review Apps (check the [examples](#review-apps-examples) below).
1. [Install](https://docs.gitlab.com/runner/install/) and [configure](https://docs.gitlab.com/runner/commands/) a runner to do deployment.
-1. Set up a job in `.gitlab-ci.yml` that uses the [predefined CI/CD variable](../variables/index.md) `${CI_COMMIT_REF_NAME}`
+1. Set up a job in `.gitlab-ci.yml` that uses the [predefined CI/CD variable](../variables/index.md) `${CI_COMMIT_REF_SLUG}`
to create dynamic environments and restrict it to run only on branches.
Alternatively, you can get a YML template for this job by [enabling review apps](#enable-review-apps-button) for your project.
1. Optionally, set a job that [manually stops](../environments/index.md#stop-an-environment) the Review Apps.
diff --git a/doc/ci/yaml/includes.md b/doc/ci/yaml/includes.md
index d98f3264bec..92bf44cca7f 100644
--- a/doc/ci/yaml/includes.md
+++ b/doc/ci/yaml/includes.md
@@ -7,70 +7,75 @@ type: reference
# GitLab CI/CD include examples **(FREE)**
-In addition to the [`includes` examples](index.md#include) listed in the
-[GitLab CI YAML reference](index.md), this page lists more variations of `include`
-usage.
+You can use [`include`](index.md#include) to include external YAML files in your CI/CD jobs.
-## Single string or array of multiple values
+## Include a single configuration file
-You can include your extra YAML file(s) either as a single string or
-an array of multiple values. The following examples are all valid.
+To include a single configuration file, use either of these syntax options:
-Single string with the `include:local` method implied:
+- `include` by itself with a single file, which is the same as
+ [`include:local`](index.md#includelocal):
-```yaml
-include: '/templates/.after-script-template.yml'
-```
+ ```yaml
+ include: '/templates/.after-script-template.yml'
+ ```
-Array with `include` method implied:
+- `include` with a single file, and you specify the `include` type:
-```yaml
-include:
- - 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
- - '/templates/.after-script-template.yml'
-```
+ ```yaml
+ include:
+ remote: 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
+ ```
-Single string with `include` method specified explicitly:
+## Include an array of configuration files
-```yaml
-include:
- remote: 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
-```
+You can include an array of configuration files:
-Array with `include:remote` being the single item:
+- If you do not specify an `include` type, the type defaults to [`include:local`](index.md#includelocal):
-```yaml
-include:
- - remote: 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
-```
+ ```yaml
+ include:
+ - 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
+ - '/templates/.after-script-template.yml'
+ ```
-Array with multiple `include` methods specified explicitly:
+- You can define a single item array:
-```yaml
-include:
- - remote: 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
- - local: '/templates/.after-script-template.yml'
- - template: Auto-DevOps.gitlab-ci.yml
-```
+ ```yaml
+ include:
+ - remote: 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
+ ```
-Array mixed syntax:
+- You can define an array and explicitly specify multiple `include` types:
-```yaml
-include:
- - 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
- - '/templates/.after-script-template.yml'
- - template: Auto-DevOps.gitlab-ci.yml
- - project: 'my-group/my-project'
- ref: main
- file: '/templates/.gitlab-ci-template.yml'
-```
+ ```yaml
+ include:
+ - remote: 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
+ - local: '/templates/.after-script-template.yml'
+ - template: Auto-DevOps.gitlab-ci.yml
+ ```
+
+- You can define an array that combines both default and specific `include` type:
+
+ ```yaml
+ include:
+ - 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
+ - '/templates/.after-script-template.yml'
+ - template: Auto-DevOps.gitlab-ci.yml
+ - project: 'my-group/my-project'
+ ref: main
+ file: '/templates/.gitlab-ci-template.yml'
+ ```
-## Re-using a `before_script` template
+## Use `default` configuration from an included configuration file
-In the following example, the content of `.before-script-template.yml` is
-automatically fetched and evaluated along with the content of `.gitlab-ci.yml`.
+You can define a [`default`](index.md#custom-default-keyword-values) section in a
+configuration file. When you use a `default` section with the `include` keyword, the defaults apply to
+all jobs in the pipeline.
-Content of `https://gitlab.com/awesome-project/raw/main/.before-script-template.yml`:
+For example, you can use a `default` section with [`before_script`](index.md#before_script).
+
+Content of a custom configuration file named `/templates/.before-script-template.yml`:
```yaml
default:
@@ -83,19 +88,29 @@ default:
Content of `.gitlab-ci.yml`:
```yaml
-include: 'https://gitlab.com/awesome-project/raw/main/.before-script-template.yml'
+include: '/templates/.before-script-template.yml'
-rspec:
+rspec1:
+ script:
+ - bundle exec rspec
+
+rspec2:
script:
- bundle exec rspec
```
-## Overriding external template values
+The default `before_script` commands execute in both `rspec` jobs, before the `script` commands.
+
+## Override included configuration values
-The following example shows specific YAML-defined variables and details of the
-`production` job from an include file being customized in `.gitlab-ci.yml`.
+When you use the `include` keyword, you can override the included configuration values to adapt them
+to your pipeline requirements.
-Content of `https://company.com/autodevops-template.yml`:
+The following example shows an `include` file that is customized in the
+`.gitlab-ci.yml` file. Specific YAML-defined variables and details of the
+`production` job are overridden.
+
+Content of a custom configuration file named `autodevops-template.yml`:
```yaml
variables:
@@ -136,17 +151,18 @@ production:
url: https://domain.com
```
-In this case, the variables `POSTGRES_USER` and `POSTGRES_PASSWORD` along
-with the environment URL of the `production` job defined in
-`autodevops-template.yml` have been overridden by new values defined in
-`.gitlab-ci.yml`.
+The `POSTGRES_USER` and `POSTGRES_PASSWORD` variables
+and the `environment:url` of the `production` job defined in the `.gitlab-ci.yml` file
+override the values defined in the `autodevops-template.yml` file. The other keywords
+do not change. This method is called *merging*.
+
+## Override included configuration arrays
-The merging lets you extend and override dictionary mappings, but
-you cannot add or modify items to an included array. For example, to add
-an additional item to the production job script, you must repeat the
-existing script items:
+You can use merging to extend and override configuration in an included template, but
+you cannot add or modify individual items in an array. For example, to add
+an additional `notify_owner` command to the extended `production` job's `script` array:
-Content of `https://company.com/autodevops-template.yml`:
+Content of `autodevops-template.yml`:
```yaml
production:
@@ -159,7 +175,7 @@ production:
Content of `.gitlab-ci.yml`:
```yaml
-include: 'https://company.com/autodevops-template.yml'
+include: 'autodevops-template.yml'
stages:
- production
@@ -171,51 +187,32 @@ production:
- notify_owner
```
-In this case, if `install_dependencies` and `deploy` were not repeated in
-`.gitlab-ci.yml`, they would not be part of the script for the `production`
-job in the combined CI configuration.
+If `install_dependencies` and `deploy` are not repeated in
+the `.gitlab-ci.yml` file, the `production` job would have only `notify_owner` in the script.
-## Using nested includes
+## Use nested includes
-The examples below show how includes can be nested from different sources
-using a combination of different methods.
+You can nest `include` sections in configuration files that are then included
+in another configuration. For example, for `include` keywords nested three deep:
-In this example, `.gitlab-ci.yml` includes local the file `/.gitlab-ci/another-config.yml`:
+Content of `.gitlab-ci.yml`:
```yaml
include:
- local: /.gitlab-ci/another-config.yml
```
-The `/.gitlab-ci/another-config.yml` includes a template and the `/templates/docker-workflow.yml` file
-from another project:
+Content of `/.gitlab-ci/another-config.yml`:
```yaml
include:
- - template: Bash.gitlab-ci.yml
- - project: group/my-project
- file: /templates/docker-workflow.yml
+ - local: /.gitlab-ci/config-defaults.yml
```
-The `/templates/docker-workflow.yml` present in `group/my-project` includes two local files
-of the `group/my-project`:
+Content of `/.gitlab-ci/config-defaults.yml`:
```yaml
-include:
- - local: /templates/docker-build.yml
- - local: /templates/docker-testing.yml
-```
-
-Our `/templates/docker-build.yml` present in `group/my-project` adds a `docker-build` job:
-
-```yaml
-docker-build:
- script: docker build -t my-image .
-```
-
-Our second `/templates/docker-test.yml` present in `group/my-project` adds a `docker-test` job:
-
-```yaml
-docker-test:
- script: docker run my-image /run/tests.sh
+default:
+ after_script:
+ - echo "Job complete."
```
diff --git a/doc/ci/yaml/index.md b/doc/ci/yaml/index.md
index b6af235cd60..958c347410a 100644
--- a/doc/ci/yaml/index.md
+++ b/doc/ci/yaml/index.md
@@ -2136,7 +2136,7 @@ review_app:
stage: deploy
script: make deploy-app
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_ENVIRONMENT_SLUG.example.com
on_stop: stop_review_app
@@ -2147,7 +2147,7 @@ stop_review_app:
script: make delete-app
when: manual
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
action: stop
```
@@ -2197,7 +2197,7 @@ For example,
review_app:
script: deploy-review-app
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
auto_stop_in: 1 day
```
@@ -2267,12 +2267,12 @@ deploy as review app:
stage: deploy
script: make deploy
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
url: https://$CI_ENVIRONMENT_SLUG.example.com/
```
The `deploy as review app` job is marked as a deployment to dynamically
-create the `review/$CI_COMMIT_REF_NAME` environment. `$CI_COMMIT_REF_NAME`
+create the `review/$CI_COMMIT_REF_SLUG` environment. `$CI_COMMIT_REF_SLUG`
is a [CI/CD variable](../variables/index.md) set by the runner. The
`$CI_ENVIRONMENT_SLUG` variable is based on the environment name, but suitable
for inclusion in URLs. If the `deploy as review app` job runs in a branch named
diff --git a/doc/user/profile/account/two_factor_authentication.md b/doc/user/profile/account/two_factor_authentication.md
index 9df6da9d6ef..091bbcf81dc 100644
--- a/doc/user/profile/account/two_factor_authentication.md
+++ b/doc/user/profile/account/two_factor_authentication.md
@@ -35,8 +35,19 @@ still access your account if you lose your U2F / WebAuthn device.
## Enabling 2FA
-There are multiple ways to enable two-factor authentication: by using a one-time
-password authenticator or a U2F / WebAuthn device.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/35102) in GitLab 14.3, account email confirmation required.
+
+There are multiple ways to enable two-factor authentication (2FA):
+
+- Using a one-time password authenticator.
+- Using a U2F / WebAuthn device.
+
+In GitLab 14.3 and later, your account email must be confirmed to enable two-factor authentication.
+
+FLAG:
+On self-managed GitLab, account email confirmation requirement is enabled. To disable this
+restriction, ask an administrator to
+[disable the `ensure_verified_primary_email_for_2fa` flag](../../../administration/feature_flags.md).
### One-time password
diff --git a/doc/user/project/merge_requests/fail_fast_testing.md b/doc/user/project/merge_requests/fail_fast_testing.md
index 6d8a128c39f..0d87a04461b 100644
--- a/doc/user/project/merge_requests/fail_fast_testing.md
+++ b/doc/user/project/merge_requests/fail_fast_testing.md
@@ -45,7 +45,7 @@ This template requires:
- Use [Pipelines for merge requests](../../../ci/pipelines/merge_request_pipelines.md#configure-pipelines-for-merge-requests)
- [Pipelines for Merged Results](../../../ci/pipelines/pipelines_for_merged_results.md#enable-pipelines-for-merged-results)
enabled in the project settings.
-- A Docker image with Ruby available. The template uses `image: ruby:2.6` by default, but you [can override](../../../ci/yaml/includes.md#overriding-external-template-values) this.
+- A Docker image with Ruby available. The template uses `image: ruby:2.6` by default, but you [can override](../../../ci/yaml/includes.md#override-included-configuration-values) this.
## Configuring Fast RSpec Failure
diff --git a/doc/user/project/merge_requests/load_performance_testing.md b/doc/user/project/merge_requests/load_performance_testing.md
index 7ea785c00ea..1d892a3c2e1 100644
--- a/doc/user/project/merge_requests/load_performance_testing.md
+++ b/doc/user/project/merge_requests/load_performance_testing.md
@@ -181,7 +181,7 @@ include:
review:
stage: deploy
environment:
- name: review/$CI_COMMIT_REF_NAME
+ name: review/$CI_COMMIT_REF_SLUG
url: http://$CI_ENVIRONMENT_SLUG.example.com
script:
- run_deploy_script
diff --git a/lib/api/entities/project.rb b/lib/api/entities/project.rb
index b638adfd3bd..1e1fb032d8d 100644
--- a/lib/api/entities/project.rb
+++ b/lib/api/entities/project.rb
@@ -152,6 +152,10 @@ module API
super
end
+
+ def self.repositories_for_preload(projects_relation)
+ super + projects_relation.map(&:forked_from_project).compact.map(&:repository)
+ end
end
end
end
diff --git a/lib/api/projects_relation_builder.rb b/lib/api/projects_relation_builder.rb
index 61d1b7b31e1..d7c7a2d59b1 100644
--- a/lib/api/projects_relation_builder.rb
+++ b/lib/api/projects_relation_builder.rb
@@ -10,6 +10,8 @@ module API
execute_batch_counting(projects_relation)
+ preload_repository_cache(projects_relation)
+
projects_relation
end
@@ -23,6 +25,20 @@ module API
# batch load certain counts
def execute_batch_counting(projects_relation)
end
+
+ def preload_repository_cache(projects_relation)
+ return unless Feature.enabled?(:preload_repo_cache, default_enabled: :yaml)
+
+ repositories = repositories_for_preload(projects_relation)
+
+ Gitlab::RepositoryCache::Preloader.new(repositories).preload( # rubocop:disable CodeReuse/ActiveRecord
+ %i[exists? root_ref has_visible_content? avatar readme_path]
+ )
+ end
+
+ def repositories_for_preload(projects_relation)
+ projects_relation.map(&:repository)
+ end
end
end
end
diff --git a/lib/gitlab/repository_cache/preloader.rb b/lib/gitlab/repository_cache/preloader.rb
new file mode 100644
index 00000000000..726dde4e0ca
--- /dev/null
+++ b/lib/gitlab/repository_cache/preloader.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Gitlab
+ class RepositoryCache
+ class Preloader
+ def initialize(repositories)
+ @repositories = repositories
+ end
+
+ def preload(methods)
+ return if @repositories.empty?
+
+ cache_keys = []
+
+ sources_by_cache_key = @repositories.each_with_object({}) do |repository, hash|
+ methods.each do |method|
+ cache_key = repository.cache.cache_key(method)
+
+ hash[cache_key] = { repository: repository, method: method }
+ cache_keys << cache_key
+ end
+ end
+
+ Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+ backend.read_multi(*cache_keys).each do |cache_key, value|
+ source = sources_by_cache_key[cache_key]
+
+ source[:repository].memoize_method_cache_value(source[:method], value)
+ end
+ end
+ end
+
+ private
+
+ def backend
+ @repositories.first.cache.backend
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/repository_cache_adapter.rb b/lib/gitlab/repository_cache_adapter.rb
index d0230c035cc..c096c870f2a 100644
--- a/lib/gitlab/repository_cache_adapter.rb
+++ b/lib/gitlab/repository_cache_adapter.rb
@@ -217,6 +217,10 @@ module Gitlab
fallback
end
+ def memoize_method_cache_value(method, value)
+ strong_memoize(memoizable_name(method)) { value }
+ end
+
# Expires the caches of a specific set of methods
def expire_method_caches(methods)
methods.each do |name|
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 765f7e67a79..e13803067b0 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -3271,6 +3271,9 @@ msgstr ""
msgid "All (default)"
msgstr ""
+msgid "All GitLab"
+msgstr ""
+
msgid "All Members"
msgstr ""
@@ -18760,12 +18763,18 @@ msgstr ""
msgid "Issues"
msgstr ""
+msgid "Issues I've created"
+msgstr ""
+
msgid "Issues Rate Limits"
msgstr ""
msgid "Issues and merge requests"
msgstr ""
+msgid "Issues assigned to me"
+msgstr ""
+
msgid "Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable."
msgstr ""
@@ -21107,12 +21116,21 @@ msgstr ""
msgid "Merge requests"
msgstr ""
+msgid "Merge requests I've created"
+msgstr ""
+
msgid "Merge requests are a place to propose changes you've made to a project and discuss those changes with others"
msgstr ""
msgid "Merge requests are read-only in a secondary Geo node"
msgstr ""
+msgid "Merge requests assigned to me"
+msgstr ""
+
+msgid "Merge requests that I'm a reviewer"
+msgstr ""
+
msgid "Merge the branch and fix any conflicts that come up"
msgstr ""
diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js
index 200c823e592..523d3bd7b23 100644
--- a/spec/frontend/header_search/components/app_spec.js
+++ b/spec/frontend/header_search/components/app_spec.js
@@ -1,12 +1,14 @@
import { GlSearchBoxByType } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import HeaderSearchApp from '~/header_search/components/app.vue';
+import { ESC_KEY } from '~/lib/utils/keys';
+import { MOCK_USERNAME } from '../mock_data';
describe('HeaderSearchApp', () => {
let wrapper;
const createComponent = () => {
- wrapper = shallowMount(HeaderSearchApp);
+ wrapper = shallowMountExtended(HeaderSearchApp);
};
afterEach(() => {
@@ -14,14 +16,76 @@ describe('HeaderSearchApp', () => {
});
const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
+ const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
describe('template', () => {
+ it('always renders Header Search Input', () => {
+ createComponent();
+ expect(findHeaderSearchInput().exists()).toBe(true);
+ });
+
+ describe.each`
+ showDropdown | username | showSearchDropdown
+ ${false} | ${null} | ${false}
+ ${false} | ${MOCK_USERNAME} | ${false}
+ ${true} | ${null} | ${false}
+ ${true} | ${MOCK_USERNAME} | ${true}
+ `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => {
+ describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => {
+ beforeEach(() => {
+ createComponent();
+ window.gon.current_username = username;
+ wrapper.setData({ showDropdown });
+ });
+
+ it(`should${showSearchDropdown ? '' : ' not'} render`, () => {
+ expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown);
+ });
+ });
+ });
+ });
+
+ describe('events', () => {
beforeEach(() => {
createComponent();
+ window.gon.current_username = MOCK_USERNAME;
});
- it('renders Header Search Input always', () => {
- expect(findHeaderSearchInput().exists()).toBe(true);
+ describe('Header Search Input', () => {
+ describe('when dropdown is closed', () => {
+ it('onFocus opens dropdown', async () => {
+ expect(findHeaderSearchDropdown().exists()).toBe(false);
+ findHeaderSearchInput().vm.$emit('focus');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findHeaderSearchDropdown().exists()).toBe(true);
+ });
+
+ it('onClick opens dropdown', async () => {
+ expect(findHeaderSearchDropdown().exists()).toBe(false);
+ findHeaderSearchInput().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findHeaderSearchDropdown().exists()).toBe(true);
+ });
+ });
+
+ describe('when dropdown is opened', () => {
+ beforeEach(() => {
+ wrapper.setData({ showDropdown: true });
+ });
+
+ it('onKey-Escape closes dropdown', async () => {
+ expect(findHeaderSearchDropdown().exists()).toBe(true);
+ findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ESC_KEY }));
+
+ await wrapper.vm.$nextTick();
+
+ expect(findHeaderSearchDropdown().exists()).toBe(false);
+ });
+ });
});
});
});
diff --git a/spec/frontend/header_search/components/header_search_default_items_spec.js b/spec/frontend/header_search/components/header_search_default_items_spec.js
new file mode 100644
index 00000000000..ce083d0df72
--- /dev/null
+++ b/spec/frontend/header_search/components/header_search_default_items_spec.js
@@ -0,0 +1,81 @@
+import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Vue from 'vue';
+import Vuex from 'vuex';
+import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
+import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data';
+
+Vue.use(Vuex);
+
+describe('HeaderSearchDefaultItems', () => {
+ let wrapper;
+
+ const createComponent = (initialState) => {
+ const store = new Vuex.Store({
+ state: {
+ searchContext: MOCK_SEARCH_CONTEXT,
+ ...initialState,
+ },
+ getters: {
+ defaultSearchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
+ },
+ });
+
+ wrapper = shallowMount(HeaderSearchDefaultItems, {
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader);
+ const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+ const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text());
+ const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
+
+ describe('template', () => {
+ describe('Dropdown items', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders item for each option in defaultSearchOptions', () => {
+ expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length);
+ });
+
+ it('renders titles correctly', () => {
+ const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title);
+ expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
+ });
+
+ it('renders links correctly', () => {
+ const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url);
+ expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
+ });
+ });
+
+ describe.each`
+ group | project | dropdownTitle
+ ${null} | ${null} | ${'All GitLab'}
+ ${{ name: 'Test Group' }} | ${null} | ${'Test Group'}
+ ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'}
+ `('Dropdown Header', ({ group, project, dropdownTitle }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createComponent({
+ searchContext: {
+ group,
+ project,
+ },
+ });
+ });
+
+ it(`should render as ${dropdownTitle}`, () => {
+ expect(findDropdownHeader().text()).toBe(dropdownTitle);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js
new file mode 100644
index 00000000000..680b6522d98
--- /dev/null
+++ b/spec/frontend/header_search/mock_data.js
@@ -0,0 +1,43 @@
+import {
+ MSG_ISSUES_ASSIGNED_TO_ME,
+ MSG_ISSUES_IVE_CREATED,
+ MSG_MR_ASSIGNED_TO_ME,
+ MSG_MR_IM_REVIEWER,
+ MSG_MR_IVE_CREATED,
+} from '~/header_search/constants';
+
+export const MOCK_USERNAME = 'anyone';
+
+export const MOCK_ISSUE_PATH = '/dashboard/issues';
+
+export const MOCK_MR_PATH = '/dashboard/merge_requests';
+
+export const MOCK_SEARCH_CONTEXT = {
+ project: null,
+ project_metadata: {},
+ group: null,
+ group_metadata: {},
+};
+
+export const MOCK_DEFAULT_SEARCH_OPTIONS = [
+ {
+ title: MSG_ISSUES_ASSIGNED_TO_ME,
+ url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`,
+ },
+ {
+ title: MSG_ISSUES_IVE_CREATED,
+ url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`,
+ },
+ {
+ title: MSG_MR_ASSIGNED_TO_ME,
+ url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`,
+ },
+ {
+ title: MSG_MR_IM_REVIEWER,
+ url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`,
+ },
+ {
+ title: MSG_MR_IVE_CREATED,
+ url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`,
+ },
+];
diff --git a/spec/frontend/header_search/store/getters_spec.js b/spec/frontend/header_search/store/getters_spec.js
new file mode 100644
index 00000000000..f87a58a0560
--- /dev/null
+++ b/spec/frontend/header_search/store/getters_spec.js
@@ -0,0 +1,92 @@
+import * as getters from '~/header_search/store/getters';
+import initState from '~/header_search/store/state';
+import {
+ MOCK_USERNAME,
+ MOCK_ISSUE_PATH,
+ MOCK_MR_PATH,
+ MOCK_SEARCH_CONTEXT,
+ MOCK_DEFAULT_SEARCH_OPTIONS,
+} from '../mock_data';
+
+describe('Header Search Store Getters', () => {
+ let state;
+
+ const createState = (initialState) => {
+ state = initState({
+ issuesPath: MOCK_ISSUE_PATH,
+ mrPath: MOCK_MR_PATH,
+ searchContext: MOCK_SEARCH_CONTEXT,
+ ...initialState,
+ });
+ };
+
+ afterEach(() => {
+ state = null;
+ });
+
+ describe.each`
+ group | group_metadata | project | project_metadata | expectedPath
+ ${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH}
+ ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${null} | ${null} | ${'group/path'}
+ ${{ name: 'Test Group' }} | ${{ issues_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ issues_path: 'project/path' }} | ${'project/path'}
+ `('scopedIssuesPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ group_metadata,
+ project,
+ project_metadata,
+ },
+ });
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.scopedIssuesPath(state)).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe.each`
+ group | group_metadata | project | project_metadata | expectedPath
+ ${null} | ${null} | ${null} | ${null} | ${MOCK_MR_PATH}
+ ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${null} | ${null} | ${'group/path'}
+ ${{ name: 'Test Group' }} | ${{ mr_path: 'group/path' }} | ${{ name: 'Test Project' }} | ${{ mr_path: 'project/path' }} | ${'project/path'}
+ `('scopedMRPath', ({ group, group_metadata, project, project_metadata, expectedPath }) => {
+ describe(`when group is ${group?.name} and project is ${project?.name}`, () => {
+ beforeEach(() => {
+ createState({
+ searchContext: {
+ group,
+ group_metadata,
+ project,
+ project_metadata,
+ },
+ });
+ });
+
+ it(`should return ${expectedPath}`, () => {
+ expect(getters.scopedMRPath(state)).toBe(expectedPath);
+ });
+ });
+ });
+
+ describe('defaultSearchOptions', () => {
+ const mockGetters = {
+ scopedIssuesPath: MOCK_ISSUE_PATH,
+ scopedMRPath: MOCK_MR_PATH,
+ };
+
+ beforeEach(() => {
+ createState();
+ window.gon.current_username = MOCK_USERNAME;
+ });
+
+ it('returns the correct array', () => {
+ expect(getters.defaultSearchOptions(state, mockGetters)).toStrictEqual(
+ MOCK_DEFAULT_SEARCH_OPTIONS,
+ );
+ });
+ });
+});
diff --git a/spec/lib/gitlab/repository_cache/preloader_spec.rb b/spec/lib/gitlab/repository_cache/preloader_spec.rb
new file mode 100644
index 00000000000..8c6618c9f8f
--- /dev/null
+++ b/spec/lib/gitlab/repository_cache/preloader_spec.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::RepositoryCache::Preloader, :use_clean_rails_redis_caching do
+ let(:projects) { create_list(:project, 2, :repository) }
+ let(:repositories) { projects.map(&:repository) }
+
+ describe '#preload' do
+ context 'when the values are already cached' do
+ before do
+ # Warm the cache but use a different model so they are not memoized
+ repos = Project.id_in(projects).order(:id).map(&:repository)
+
+ allow(repos[0].head_tree).to receive(:readme_path).and_return('README.txt')
+ allow(repos[1].head_tree).to receive(:readme_path).and_return('README.md')
+
+ repos.map(&:exists?)
+ repos.map(&:readme_path)
+ end
+
+ it 'prevents individual cache reads for cached methods' do
+ expect(Rails.cache).to receive(:read_multi).once.and_call_original
+
+ described_class.new(repositories).preload(
+ %i[exists? readme_path]
+ )
+
+ expect(Rails.cache).not_to receive(:read)
+ expect(Rails.cache).not_to receive(:write)
+
+ expect(repositories[0].exists?).to eq(true)
+ expect(repositories[0].readme_path).to eq('README.txt')
+
+ expect(repositories[1].exists?).to eq(true)
+ expect(repositories[1].readme_path).to eq('README.md')
+ end
+ end
+
+ context 'when values are not cached' do
+ it 'reads and writes from cache individually' do
+ described_class.new(repositories).preload(
+ %i[exists? has_visible_content?]
+ )
+
+ expect(Rails.cache).to receive(:read).exactly(4).times
+ expect(Rails.cache).to receive(:write).exactly(4).times
+
+ repositories.each(&:exists?)
+ repositories.each(&:has_visible_content?)
+ end
+ end
+ end
+end
diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb
index 6364520ebb6..51b27151ba2 100644
--- a/spec/models/internal_id_spec.rb
+++ b/spec/models/internal_id_spec.rb
@@ -39,270 +39,199 @@ RSpec.describe InternalId do
end
end
- shared_examples_for 'a monotonically increasing id generator' do
- describe '.generate_next' do
- subject { described_class.generate_next(id_subject, scope, usage, init) }
+ describe '.generate_next' do
+ subject { described_class.generate_next(id_subject, scope, usage, init) }
- context 'in the absence of a record' do
- it 'creates a record if not yet present' do
- expect { subject }.to change { described_class.count }.from(0).to(1)
- end
-
- it 'stores record attributes' do
- subject
-
- described_class.first.tap do |record|
- expect(record.project).to eq(project)
- expect(record.usage).to eq(usage.to_s)
- end
- end
-
- context 'with existing issues' do
- before do
- create_list(:issue, 2, project: project)
- described_class.delete_all
- end
-
- it 'calculates last_value values automatically' do
- expect(subject).to eq(project.issues.size + 1)
- end
- end
- end
-
- it 'generates a strictly monotone, gapless sequence' do
- seq = Array.new(10).map do
- described_class.generate_next(issue, scope, usage, init)
- end
- normalized = seq.map { |i| i - seq.min }
-
- expect(normalized).to eq((0..seq.size - 1).to_a)
+ context 'in the absence of a record' do
+ it 'creates a record if not yet present' do
+ expect { subject }.to change { described_class.count }.from(0).to(1)
end
- context 'there are no instances to pass in' do
- let(:id_subject) { Issue }
+ it 'stores record attributes' do
+ subject
- it 'accepts classes instead' do
- expect(subject).to eq(1)
+ described_class.first.tap do |record|
+ expect(record.project).to eq(project)
+ expect(record.usage).to eq(usage.to_s)
end
end
- context 'when executed outside of transaction' do
- it 'increments counter with in_transaction: "false"' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
-
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :generate, usage: 'issues', in_transaction: 'false').and_call_original
-
- subject
+ context 'with existing issues' do
+ before do
+ create_list(:issue, 2, project: project)
+ described_class.delete_all
end
- end
- context 'when executed within transaction' do
- it 'increments counter with in_transaction: "true"' do
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :generate, usage: 'issues', in_transaction: 'true').and_call_original
-
- InternalId.transaction { subject }
+ it 'calculates last_value values automatically' do
+ expect(subject).to eq(project.issues.size + 1)
end
end
end
- describe '.reset' do
- subject { described_class.reset(issue, scope, usage, value) }
-
- context 'in the absence of a record' do
- let(:value) { 2 }
-
- it 'does not revert back the value' do
- expect { subject }.not_to change { described_class.count }
- expect(subject).to be_falsey
- end
+ it 'generates a strictly monotone, gapless sequence' do
+ seq = Array.new(10).map do
+ described_class.generate_next(issue, scope, usage, init)
end
+ normalized = seq.map { |i| i - seq.min }
- context 'when valid iid is used to reset' do
- let!(:value) { generate_next }
-
- context 'and iid is a latest one' do
- it 'does rewind and next generated value is the same' do
- expect(subject).to be_truthy
- expect(generate_next).to eq(value)
- end
- end
+ expect(normalized).to eq((0..seq.size - 1).to_a)
+ end
- context 'and iid is not a latest one' do
- it 'does not rewind' do
- generate_next
+ context 'there are no instances to pass in' do
+ let(:id_subject) { Issue }
- expect(subject).to be_falsey
- expect(generate_next).to be > value
- end
- end
-
- def generate_next
- described_class.generate_next(issue, scope, usage, init)
- end
+ it 'accepts classes instead' do
+ expect(subject).to eq(1)
end
+ end
- context 'when executed outside of transaction' do
- let(:value) { 2 }
-
- it 'increments counter with in_transaction: "false"' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
+ context 'when executed outside of transaction' do
+ it 'increments counter with in_transaction: "false"' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :reset, usage: 'issues', in_transaction: 'false').and_call_original
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :generate, usage: 'issues', in_transaction: 'false').and_call_original
- subject
- end
+ subject
end
+ end
- context 'when executed within transaction' do
- let(:value) { 2 }
-
- it 'increments counter with in_transaction: "true"' do
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :reset, usage: 'issues', in_transaction: 'true').and_call_original
+ context 'when executed within transaction' do
+ it 'increments counter with in_transaction: "true"' do
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :generate, usage: 'issues', in_transaction: 'true').and_call_original
- InternalId.transaction { subject }
- end
+ InternalId.transaction { subject }
end
end
+ end
- describe '.track_greatest' do
- let(:value) { 9001 }
+ describe '.reset' do
+ subject { described_class.reset(issue, scope, usage, value) }
- subject { described_class.track_greatest(id_subject, scope, usage, value, init) }
+ context 'in the absence of a record' do
+ let(:value) { 2 }
- context 'in the absence of a record' do
- it 'creates a record if not yet present' do
- expect { subject }.to change { described_class.count }.from(0).to(1)
- end
- end
-
- it 'stores record attributes' do
- subject
-
- described_class.first.tap do |record|
- expect(record.project).to eq(project)
- expect(record.usage).to eq(usage.to_s)
- expect(record.last_value).to eq(value)
- end
+ it 'does not revert back the value' do
+ expect { subject }.not_to change { described_class.count }
+ expect(subject).to be_falsey
end
+ end
- context 'with existing issues' do
- before do
- create(:issue, project: project)
- described_class.delete_all
- end
+ context 'when valid iid is used to reset' do
+ let!(:value) { generate_next }
- it 'still returns the last value to that of the given value' do
- expect(subject).to eq(value)
+ context 'and iid is a latest one' do
+ it 'does rewind and next generated value is the same' do
+ expect(subject).to be_truthy
+ expect(generate_next).to eq(value)
end
end
- context 'when value is less than the current last_value' do
- it 'returns the current last_value' do
- described_class.create!(**scope, usage: usage, last_value: 10_001)
+ context 'and iid is not a latest one' do
+ it 'does not rewind' do
+ generate_next
- expect(subject).to eq 10_001
+ expect(subject).to be_falsey
+ expect(generate_next).to be > value
end
end
- context 'there are no instances to pass in' do
- let(:id_subject) { Issue }
-
- it 'accepts classes instead' do
- expect(subject).to eq(value)
- end
+ def generate_next
+ described_class.generate_next(issue, scope, usage, init)
end
+ end
- context 'when executed outside of transaction' do
- it 'increments counter with in_transaction: "false"' do
- allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
-
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :track_greatest, usage: 'issues', in_transaction: 'false').and_call_original
+ context 'when executed outside of transaction' do
+ let(:value) { 2 }
- subject
- end
- end
+ it 'increments counter with in_transaction: "false"' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
- context 'when executed within transaction' do
- it 'increments counter with in_transaction: "true"' do
- expect(InternalId.internal_id_transactions_total).to receive(:increment)
- .with(operation: :track_greatest, usage: 'issues', in_transaction: 'true').and_call_original
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :reset, usage: 'issues', in_transaction: 'false').and_call_original
- InternalId.transaction { subject }
- end
+ subject
end
end
- end
- context 'when the explicit locking feature flag is disabled' do
- before do
- stub_feature_flags(generate_iids_without_explicit_locking: false)
- end
+ context 'when executed within transaction' do
+ let(:value) { 2 }
- it_behaves_like 'a monotonically increasing id generator'
- end
+ it 'increments counter with in_transaction: "true"' do
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :reset, usage: 'issues', in_transaction: 'true').and_call_original
- context 'when the explicit locking feature flag is enabled' do
- before do
- stub_feature_flags(generate_iids_without_explicit_locking: true)
+ InternalId.transaction { subject }
+ end
end
-
- it_behaves_like 'a monotonically increasing id generator'
end
- describe '#increment_and_save!' do
- let(:id) { create(:internal_id) }
-
- subject { id.increment_and_save! }
+ describe '.track_greatest' do
+ let(:value) { 9001 }
- it 'returns incremented iid' do
- value = id.last_value
+ subject { described_class.track_greatest(id_subject, scope, usage, value, init) }
- expect(subject).to eq(value + 1)
+ context 'in the absence of a record' do
+ it 'creates a record if not yet present' do
+ expect { subject }.to change { described_class.count }.from(0).to(1)
+ end
end
- it 'saves the record' do
+ it 'stores record attributes' do
subject
- expect(id.changed?).to be_falsey
+ described_class.first.tap do |record|
+ expect(record.project).to eq(project)
+ expect(record.usage).to eq(usage.to_s)
+ expect(record.last_value).to eq(value)
+ end
end
- context 'with last_value=nil' do
- let(:id) { build(:internal_id, last_value: nil) }
+ context 'with existing issues' do
+ before do
+ create(:issue, project: project)
+ described_class.delete_all
+ end
- it 'returns 1' do
- expect(subject).to eq(1)
+ it 'still returns the last value to that of the given value' do
+ expect(subject).to eq(value)
end
end
- end
-
- describe '#track_greatest_and_save!' do
- let(:id) { create(:internal_id) }
- let(:new_last_value) { 9001 }
- subject { id.track_greatest_and_save!(new_last_value) }
+ context 'when value is less than the current last_value' do
+ it 'returns the current last_value' do
+ described_class.create!(**scope, usage: usage, last_value: 10_001)
- it 'returns new last value' do
- expect(subject).to eq new_last_value
+ expect(subject).to eq 10_001
+ end
end
- it 'saves the record' do
- subject
+ context 'there are no instances to pass in' do
+ let(:id_subject) { Issue }
- expect(id.changed?).to be_falsey
+ it 'accepts classes instead' do
+ expect(subject).to eq(value)
+ end
end
- context 'when new last value is lower than the max' do
- it 'does not update the last value' do
- id.update!(last_value: 10_001)
+ context 'when executed outside of transaction' do
+ it 'increments counter with in_transaction: "false"' do
+ allow(ActiveRecord::Base.connection).to receive(:transaction_open?) { false } # rubocop: disable Database/MultipleDatabases
+
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :track_greatest, usage: 'issues', in_transaction: 'false').and_call_original
subject
+ end
+ end
+
+ context 'when executed within transaction' do
+ it 'increments counter with in_transaction: "true"' do
+ expect(InternalId.internal_id_transactions_total).to receive(:increment)
+ .with(operation: :track_greatest, usage: 'issues', in_transaction: 'true').and_call_original
- expect(id.reload.last_value).to eq 10_001
+ InternalId.transaction { subject }
end
end
end