diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-10 06:10:59 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-10 06:10:59 +0300 |
commit | db6b854ea711b395c17827a5047f54dc29b518f9 (patch) | |
tree | 5c853b091a3a4068a3a091a33929128900c1a47b | |
parent | 8f8e342720899033f06747430414d2d2e3e6527a (diff) |
Add latest changes from gitlab-org/gitlab@master
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 |