diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index 2818b6be176..30c4fc10f94 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -549,33 +549,36 @@ rspec-ee unit pg11 geo: - .rails:rules:ee-only-unit - .rspec-ee-unit-geo-parallel -rspec-ee unit pg11 geo minimal: - extends: - - rspec-ee unit pg11 geo - - .minimal-rspec-tests - - .rails:rules:ee-only-unit:minimal +# FIXME: Temporarily disable geo minimal rspec jobs https://gitlab.com/gitlab-org/gitlab/-/issues/294212 +#rspec-ee unit pg11 geo minimal: +# extends: +# - rspec-ee unit pg11 geo +# - .minimal-rspec-tests +# - .rails:rules:ee-only-unit:minimal rspec-ee integration pg11 geo: extends: - .rspec-ee-base-geo-pg11 - .rails:rules:ee-only-integration -rspec-ee integration pg11 geo minimal: - extends: - - rspec-ee integration pg11 geo - - .minimal-rspec-tests - - .rails:rules:ee-only-integration:minimal +# FIXME: Temporarily disable geo minimal rspec jobs https://gitlab.com/gitlab-org/gitlab/-/issues/294212 +#rspec-ee integration pg11 geo minimal: +# extends: +# - rspec-ee integration pg11 geo +# - .minimal-rspec-tests +# - .rails:rules:ee-only-integration:minimal rspec-ee system pg11 geo: extends: - .rspec-ee-base-geo-pg11 - .rails:rules:ee-only-system -rspec-ee system pg11 geo minimal: - extends: - - rspec-ee system pg11 geo - - .minimal-rspec-tests - - .rails:rules:ee-only-system:minimal +# FIXME: Temporarily disable geo minimal rspec jobs https://gitlab.com/gitlab-org/gitlab/-/issues/294212 +#rspec-ee system pg11 geo minimal: +# extends: +# - rspec-ee system pg11 geo +# - .minimal-rspec-tests +# - .rails:rules:ee-only-system:minimal db:rollback geo: extends: diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index d6f87872bde..8d1a3d17c6e 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -10,3 +10,12 @@ export const CONTENT_UPDATE_DEBOUNCE = 250; export const ERROR_INSTANCE_REQUIRED_FOR_EXTENSION = __( 'Editor Lite instance is required to set up an extension.', ); + +// +// EXTENSIONS' CONSTANTS +// + +// For CI config schemas the filename must match +// '*.gitlab-ci.yml' regardless of project configuration. +// https://gitlab.com/gitlab-org/gitlab/-/issues/293641 +export const EXTENSION_CI_SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml'; diff --git a/app/assets/javascripts/editor/editor_ci_schema_ext.js b/app/assets/javascripts/editor/editor_ci_schema_ext.js new file mode 100644 index 00000000000..1946be997a7 --- /dev/null +++ b/app/assets/javascripts/editor/editor_ci_schema_ext.js @@ -0,0 +1,34 @@ +import Api from '~/api'; +import { registerSchema } from '~/ide/utils'; +import { EditorLiteExtension } from './editor_lite_extension_base'; +import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from './constants'; + +export class CiSchemaExtension extends EditorLiteExtension { + /** + * Registers a syntax schema to the editor based on project + * identifier and commit. + * + * The schema is added to the file that is currently edited + * in the editor. + * + * @param {Object} opts + * @param {String} opts.projectNamespace + * @param {String} opts.projectPath + * @param {String?} opts.ref - Current ref. Defaults to master + */ + registerCiSchema({ projectNamespace, projectPath, ref = 'master' } = {}) { + const ciSchemaUri = Api.buildUrl(Api.projectFileSchemaPath) + .replace(':namespace_path', projectNamespace) + .replace(':project_path', projectPath) + .replace(':ref', ref) + .replace(':filename', EXTENSION_CI_SCHEMA_FILE_NAME_MATCH); + const modelFileName = this.getModel() + .uri.path.split('/') + .pop(); + + registerSchema({ + uri: ciSchemaUri, + fileMatch: [modelFileName], + }); + } +} diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index e693c3e90a4..9278e0c5859 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -832,21 +832,21 @@ UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) { }; UsersSelect.prototype.renderApprovalRules = function(elsClassName, approvalRules = []) { - if (!gon.features?.reviewerApprovalRules || !elsClassName?.includes('reviewer')) { + const count = approvalRules.length; + + if (!gon.features?.reviewerApprovalRules || !elsClassName?.includes('reviewer') || !count) { return ''; } - const count = approvalRules.length; const [rule] = approvalRules; const countText = sprintf(__('(+%{count} rules)'), { count }); const renderApprovalRulesCount = count > 1 ? `${countText}` : ''; + const ruleName = rule.rule_type === 'code_owner' ? __('Code Owner') : rule.name; - return count - ? `
- ${rule.name} - ${renderApprovalRulesCount} -
` - : ''; + return `
+ ${ruleName} + ${renderApprovalRulesCount} +
`; }; export default UsersSelect; diff --git a/app/assets/javascripts/vue_shared/components/editor_lite.vue b/app/assets/javascripts/vue_shared/components/editor_lite.vue index cfe3ce0a11c..7218b84cf8a 100644 --- a/app/assets/javascripts/vue_shared/components/editor_lite.vue +++ b/app/assets/javascripts/vue_shared/components/editor_lite.vue @@ -84,6 +84,9 @@ export default { onFileChange() { this.$emit('input', this.editor.getValue()); }, + getEditor() { + return this.editor; + }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue index 1ad0ca36bf8..02d89175ceb 100644 --- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/gfm_autocomplete.vue @@ -4,6 +4,7 @@ import { GfmAutocompleteType, tributeConfig, } from 'ee_else_ce/vue_shared/components/gfm_autocomplete/utils'; +import * as Emoji from '~/emoji'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -76,6 +77,14 @@ export default { return (inputText, processValues) => { if (this.cache[type]) { processValues(this.filterValues(type)); + } else if (type === GfmAutocompleteType.Emojis) { + Emoji.initEmojiMap() + .then(() => { + const emojis = Emoji.getValidEmojiNames(); + this.cache[type] = emojis; + processValues(emojis); + }) + .catch(() => createFlash({ message: this.$options.errorMessage })); } else if (this.dataSources[type]) { axios .get(this.dataSources[type]) diff --git a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js index 2581888b504..9fab8400ec4 100644 --- a/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js +++ b/app/assets/javascripts/vue_shared/components/gfm_autocomplete/utils.js @@ -1,4 +1,5 @@ import { escape, last } from 'lodash'; +import * as Emoji from '~/emoji'; import { spriteIcon } from '~/lib/utils/common_utils'; const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings @@ -6,6 +7,7 @@ const groupType = 'Group'; // eslint-disable-line @gitlab/require-i18n-strings const nonWordOrInteger = /\W|^\d+$/; export const GfmAutocompleteType = { + Emojis: 'emojis', Issues: 'issues', Labels: 'labels', Members: 'members', @@ -21,6 +23,15 @@ function doesCurrentLineStartWith(searchString, fullText, selectionStart) { } export const tributeConfig = { + [GfmAutocompleteType.Emojis]: { + config: { + trigger: ':', + lookup: value => value, + menuItemTemplate: ({ original }) => `${original} ${Emoji.glEmojiTag(original)}`, + selectTemplate: ({ original }) => `:${original}:`, + }, + }, + [GfmAutocompleteType.Issues]: { config: { trigger: '#', diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 232a3054cd0..d977a034002 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -169,7 +169,7 @@ export default { return new GLForm( $(this.$refs['gl-form']), { - emojis: this.enableAutocomplete, + emojis: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, mergeRequests: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, diff --git a/app/assets/stylesheets/pages/trials.scss b/app/assets/stylesheets/pages/trials.scss index 3fb9054b2b8..55f323b7df7 100644 --- a/app/assets/stylesheets/pages/trials.scss +++ b/app/assets/stylesheets/pages/trials.scss @@ -3,13 +3,13 @@ * MR link https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22716 */ .gl-select2-html5-required-fix div.select2-container+select.select2 { + @include gl-opacity-0; + @include gl-border-0; + @include gl-bg-none; + @include gl-bg-transparent; display: block !important; width: 1px; height: 1px; z-index: -1; - opacity: 0; margin: -3px auto 0; - background-image: none; - background-color: transparent; - border: 0; } diff --git a/changelogs/unreleased/keep_n_int.yml b/changelogs/unreleased/keep_n_int.yml new file mode 100644 index 00000000000..067308833a6 --- /dev/null +++ b/changelogs/unreleased/keep_n_int.yml @@ -0,0 +1,5 @@ +--- +title: Ensure container_expiration_policy keep_n is an integer +merge_request: 49805 +author: Mathieu Parent +type: changed diff --git a/config/feature_flags/experiment/null_hypothesis.yml b/config/feature_flags/experiment/null_hypothesis.yml deleted file mode 100644 index 716b0711ef1..00000000000 --- a/config/feature_flags/experiment/null_hypothesis.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: null_hypothesis -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45840 -rollout_issue_url: -type: experiment -group: group::adoption -default_enabled: false diff --git a/doc/api/projects.md b/doc/api/projects.md index b9f6448085d..319290c3e19 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1057,7 +1057,7 @@ POST /projects | `build_timeout` | integer | **{dotted-circle}** No | The maximum amount of time in minutes that a job is able run (in seconds). | | `builds_access_level` | string | **{dotted-circle}** No | One of `disabled`, `private`, or `enabled`. | | `ci_config_path` | string | **{dotted-circle}** No | The path to CI configuration file. | -| `container_expiration_policy_attributes` | hash | **{dotted-circle}** No | Update the image cleanup policy for this project. Accepts: `cadence` (string), `keep_n` (string), `older_than` (string), `name_regex` (string), `name_regex_delete` (string), `name_regex_keep` (string), `enabled` (boolean). | +| `container_expiration_policy_attributes` | hash | **{dotted-circle}** No | Update the image cleanup policy for this project. Accepts: `cadence` (string), `keep_n` (integer), `older_than` (string), `name_regex` (string), `name_regex_delete` (string), `name_regex_keep` (string), `enabled` (boolean). | | `container_registry_enabled` | boolean | **{dotted-circle}** No | Enable container registry for this project. | | `default_branch` | string | **{dotted-circle}** No | `master` by default. | | `description` | string | **{dotted-circle}** No | Short project description. | @@ -1206,7 +1206,7 @@ PUT /projects/:id | `ci_config_path` | string | **{dotted-circle}** No | The path to CI configuration file. | | `ci_default_git_depth` | integer | **{dotted-circle}** No | Default number of revisions for [shallow cloning](../ci/pipelines/settings.md#git-shallow-clone). | | `ci_forward_deployment_enabled` | boolean | **{dotted-circle}** No | When a new deployment job starts, [skip older deployment jobs](../ci/pipelines/settings.md#skip-outdated-deployment-jobs) that are still pending | -| `container_expiration_policy_attributes` | hash | **{dotted-circle}** No | Update the image cleanup policy for this project. Accepts: `cadence` (string), `keep_n` (string), `older_than` (string), `name_regex` (string), `name_regex_delete` (string), `name_regex_keep` (string), `enabled` (boolean). | +| `container_expiration_policy_attributes` | hash | **{dotted-circle}** No | Update the image cleanup policy for this project. Accepts: `cadence` (string), `keep_n` (integer), `older_than` (string), `name_regex` (string), `name_regex_delete` (string), `name_regex_keep` (string), `enabled` (boolean). | | `container_registry_enabled` | boolean | **{dotted-circle}** No | Enable container registry for this project. | | `default_branch` | string | **{dotted-circle}** No | `master` by default. | | `description` | string | **{dotted-circle}** No | Short project description. | diff --git a/doc/development/cicd/templates.md b/doc/development/cicd/templates.md index 1ab569ba0df..bb2ed11b4d4 100644 --- a/doc/development/cicd/templates.md +++ b/doc/development/cicd/templates.md @@ -65,6 +65,14 @@ users have to fix their `.gitlab-ci.yml` that could annoy their workflow. Please read [versioning](#versioning) section for introducing breaking change safely. +### Best practices + +- Avoid using [global keywords](../../ci/yaml/README.md#global-keywords), + such as `image`, `stages` and `variables` at top-level. + When a root `.gitlab-ci.yml` [includes](../../ci/yaml/README.md#include) + multiple templates, these global keywords could be overridden by the + others and cause an unexpected behavior. + ## Versioning Versioning allows you to introduce a new template without modifying the existing diff --git a/doc/user/admin_area/settings/user_and_ip_rate_limits.md b/doc/user/admin_area/settings/user_and_ip_rate_limits.md index 3f0d75dc682..3289a201890 100644 --- a/doc/user/admin_area/settings/user_and_ip_rate_limits.md +++ b/doc/user/admin_area/settings/user_and_ip_rate_limits.md @@ -20,6 +20,9 @@ IP rate limits**: These limits are disabled by default. +NOTE: +By default, all Git operations are first tried unathenticated. Because of this, HTTP Git operations may trigger the rate limits configured for unauthenticated requests. + ![user-and-ip-rate-limits](img/user_and_ip_rate_limits.png) ## Use an HTTP header to bypass rate limiting diff --git a/doc/user/project/repository/index.md b/doc/user/project/repository/index.md index e1d84baec4d..880e0f2f805 100644 --- a/doc/user/project/repository/index.md +++ b/doc/user/project/repository/index.md @@ -236,18 +236,24 @@ lock your files to prevent any conflicting changes. You can access your repositories via [repository API](../../../api/repositories.md). -## Clone in Apple Xcode +## Clone a repository -> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/45820) in GitLab 11.0 +Learn how to [clone a repository through the command line](../../../gitlab-basics/start-using-git.md#clone-a-repository). + +Alternatively, clone directly into a code editor as documented below. + +### Clone to Apple Xcode + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/45820) in GitLab 11.0. Projects that contain a `.xcodeproj` or `.xcworkspace` directory can now be cloned -in Xcode using the new **Open in Xcode** button, located next to the Git URL +into Xcode using the new **Open in Xcode** button, located next to the Git URL used for cloning your project. The button is only shown on macOS. ## Download Source Code -> Support for directory download was [introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/24704) in GitLab 11.11. -> Support for [including Git LFS blobs](../../../topics/git/lfs#lfs-objects-in-project-archives) was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15079) in GitLab 13.5. +> - Support for directory download was [introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/24704) in GitLab 11.11. +> - Support for [including Git LFS blobs](../../../topics/git/lfs#lfs-objects-in-project-archives) was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/15079) in GitLab 13.5. The source code stored in a repository can be downloaded from the UI. By clicking the download icon, a dropdown will open with links to download the following: diff --git a/lib/api/entities/basic_repository_storage_move.rb b/lib/api/entities/basic_repository_storage_move.rb new file mode 100644 index 00000000000..3ee112fb9a2 --- /dev/null +++ b/lib/api/entities/basic_repository_storage_move.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module API + module Entities + class BasicRepositoryStorageMove < Grape::Entity + expose :id + expose :created_at + expose :human_state_name, as: :state + expose :source_storage_name + expose :destination_storage_name + end + end +end diff --git a/lib/api/entities/basic_snippet.rb b/lib/api/entities/basic_snippet.rb new file mode 100644 index 00000000000..26297514798 --- /dev/null +++ b/lib/api/entities/basic_snippet.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module API + module Entities + class BasicSnippet < Grape::Entity + expose :id, :title, :description, :visibility + expose :updated_at, :created_at + expose :project_id + expose :web_url do |snippet| + Gitlab::UrlBuilder.build(snippet) + end + expose :raw_url do |snippet| + Gitlab::UrlBuilder.build(snippet, raw: true) + end + expose :ssh_url_to_repo, :http_url_to_repo, if: ->(snippet) { snippet.repository_exists? } + end + end +end diff --git a/lib/api/entities/project_repository_storage_move.rb b/lib/api/entities/project_repository_storage_move.rb index 25643651a14..191bbaf19d7 100644 --- a/lib/api/entities/project_repository_storage_move.rb +++ b/lib/api/entities/project_repository_storage_move.rb @@ -2,12 +2,7 @@ module API module Entities - class ProjectRepositoryStorageMove < Grape::Entity - expose :id - expose :created_at - expose :human_state_name, as: :state - expose :source_storage_name - expose :destination_storage_name + class ProjectRepositoryStorageMove < BasicRepositoryStorageMove expose :project, using: Entities::ProjectIdentity end end diff --git a/lib/api/entities/snippet.rb b/lib/api/entities/snippet.rb index 85148c03d18..f05e593a302 100644 --- a/lib/api/entities/snippet.rb +++ b/lib/api/entities/snippet.rb @@ -2,18 +2,8 @@ module API module Entities - class Snippet < Grape::Entity - expose :id, :title, :description, :visibility + class Snippet < BasicSnippet expose :author, using: Entities::UserBasic - expose :updated_at, :created_at - expose :project_id - expose :web_url do |snippet| - Gitlab::UrlBuilder.build(snippet) - end - expose :raw_url do |snippet| - Gitlab::UrlBuilder.build(snippet, raw: true) - end - expose :ssh_url_to_repo, :http_url_to_repo, if: ->(snippet) { snippet.repository_exists? } expose :file_name do |snippet| snippet.file_name_on_repo || snippet.file_name end diff --git a/lib/api/entities/snippet_repository_storage_move.rb b/lib/api/entities/snippet_repository_storage_move.rb new file mode 100644 index 00000000000..ee86816bd14 --- /dev/null +++ b/lib/api/entities/snippet_repository_storage_move.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module API + module Entities + class SnippetRepositoryStorageMove < BasicRepositoryStorageMove + expose :snippet, using: Entities::BasicSnippet + end + end +end diff --git a/lib/api/helpers/projects_helpers.rb b/lib/api/helpers/projects_helpers.rb index f5f45cf7351..3115e968e84 100644 --- a/lib/api/helpers/projects_helpers.rb +++ b/lib/api/helpers/projects_helpers.rb @@ -99,7 +99,7 @@ module API params :optional_container_expiration_policy_params do optional :cadence, type: String, desc: 'Container expiration policy cadence for recurring job' - optional :keep_n, type: String, desc: 'Container expiration policy number of images to keep' + optional :keep_n, type: Integer, desc: 'Container expiration policy number of images to keep' optional :older_than, type: String, desc: 'Container expiration policy remove images older than value' optional :name_regex, type: String, desc: 'Container expiration policy regex for image removal' optional :name_regex_keep, type: String, desc: 'Container expiration policy regex for image retention' diff --git a/lib/feature/shared.rb b/lib/feature/shared.rb index 17dfe26bd82..a3f02156d94 100644 --- a/lib/feature/shared.rb +++ b/lib/feature/shared.rb @@ -57,6 +57,8 @@ class Feature default_enabled: false, example: <<-EOS experiment(:my_experiment, project: project, actor: current_user) { ...variant code... } + # or + Gitlab::Experimentation.in_experiment_group?(:my_experiment, subject: current_user) EOS } }.freeze diff --git a/lib/gitlab/experimentation.rb b/lib/gitlab/experimentation.rb index 94523813662..191a1fb1b6f 100644 --- a/lib/gitlab/experimentation.rb +++ b/lib/gitlab/experimentation.rb @@ -87,6 +87,9 @@ module Gitlab }, invite_members_empty_project_version_a: { tracking_category: 'Growth::Expansion::Experiment::InviteMembersEmptyProjectVersionA' + }, + trial_during_signup: { + tracking_category: 'Growth::Conversion::Experiment::TrialDuringSignup' } }.freeze diff --git a/lib/gitlab/experimentation/experiment.rb b/lib/gitlab/experimentation/experiment.rb index e594c3bedeb..b9eda0cfc31 100644 --- a/lib/gitlab/experimentation/experiment.rb +++ b/lib/gitlab/experimentation/experiment.rb @@ -3,6 +3,8 @@ module Gitlab module Experimentation class Experiment + FEATURE_FLAG_SUFFIX = "_experiment_percentage" + attr_reader :key, :tracking_category, :use_backwards_compatible_subject_index def initialize(key, **params) @@ -10,7 +12,7 @@ module Gitlab @tracking_category = params[:tracking_category] @use_backwards_compatible_subject_index = params[:use_backwards_compatible_subject_index] - @experiment_percentage = Feature.get(:"#{key}_experiment_percentage").percentage_of_time_value # rubocop:disable Gitlab/AvoidFeatureGet + @experiment_percentage = Feature.get(:"#{key}#{FEATURE_FLAG_SUFFIX}").percentage_of_time_value # rubocop:disable Gitlab/AvoidFeatureGet end def active? diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0fd057100b0..cd622919149 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2820,9 +2820,6 @@ msgstr "" msgid "All changes are committed" msgstr "" -msgid "All default stages are currently visible" -msgstr "" - msgid "All email addresses will be used to identify your commits." msgstr "" @@ -6910,6 +6907,9 @@ msgstr "" msgid "Code Coverage|Couldn't fetch the code coverage data" msgstr "" +msgid "Code Owner" +msgstr "" + msgid "Code Owners" msgstr "" @@ -7329,6 +7329,9 @@ msgstr "" msgid "Congratulations! You have enabled Two-factor Authentication!" msgstr "" +msgid "Congratulations, your free trial is activated." +msgstr "" + msgid "Connect" msgstr "" @@ -8330,9 +8333,21 @@ msgstr "" msgid "CustomCycleAnalytics|Add stage" msgstr "" +msgid "CustomCycleAnalytics|All default stages are currently visible" +msgstr "" + +msgid "CustomCycleAnalytics|Default stages" +msgstr "" + msgid "CustomCycleAnalytics|Editing stage" msgstr "" +msgid "CustomCycleAnalytics|End event" +msgstr "" + +msgid "CustomCycleAnalytics|End event label" +msgstr "" + msgid "CustomCycleAnalytics|Enter a name for the stage" msgstr "" @@ -8345,10 +8360,13 @@ msgstr "" msgid "CustomCycleAnalytics|Please select a start event first" msgstr "" -msgid "CustomCycleAnalytics|Select start event" +msgid "CustomCycleAnalytics|Recover hidden stage" msgstr "" -msgid "CustomCycleAnalytics|Select stop event" +msgid "CustomCycleAnalytics|Select end event" +msgstr "" + +msgid "CustomCycleAnalytics|Select start event" msgstr "" msgid "CustomCycleAnalytics|Stage name already exists" @@ -8357,18 +8375,12 @@ msgstr "" msgid "CustomCycleAnalytics|Start event" msgstr "" -msgid "CustomCycleAnalytics|Start event changed, please select a valid stop event" +msgid "CustomCycleAnalytics|Start event changed, please select a valid end event" msgstr "" msgid "CustomCycleAnalytics|Start event label" msgstr "" -msgid "CustomCycleAnalytics|Stop event" -msgstr "" - -msgid "CustomCycleAnalytics|Stop event label" -msgstr "" - msgid "CustomCycleAnalytics|Update stage" msgstr "" @@ -8959,9 +8971,6 @@ msgstr "" msgid "Default projects limit" msgstr "" -msgid "Default stages" -msgstr "" - msgid "Default: Map a FogBugz account ID to a full name" msgstr "" @@ -12977,6 +12986,9 @@ msgstr "" msgid "GitLab Billing Team." msgstr "" +msgid "GitLab Gold trial (optional)" +msgstr "" + msgid "GitLab Group Runners can execute code for all the projects in this group." msgstr "" @@ -14223,6 +14235,9 @@ msgstr "" msgid "How many days need to pass between marking entity for deletion and actual removing it." msgstr "" +msgid "How many employees will use Gitlab?" +msgstr "" + msgid "How many replicas each Elasticsearch shard has." msgstr "" @@ -29419,6 +29434,12 @@ msgstr "" msgid "Trials|You won't get a free trial right now but you can always resume this process by clicking on your avatar and choosing 'Start a free trial'" msgstr "" +msgid "Trial|Dismiss" +msgstr "" + +msgid "Trial|Successful trial activation image" +msgstr "" + msgid "Trigger" msgstr "" @@ -31055,6 +31076,9 @@ msgstr "" msgid "We want to be sure it is you, please confirm you are not a robot." msgstr "" +msgid "We will activate your trial on your group once you complete this step. After 30 days, you can upgrade to any plan" +msgstr "" + msgid "We will notify %{inviter} that you declined their invitation to join GitLab. You will stop receiving reminders." msgstr "" diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index 07bf821a590..457af4a3e7c 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -426,36 +426,16 @@ RSpec.describe 'GFM autocomplete', :js do visit project_issue_path(project, issue) note = find('#note-body') - start_comment_with_emoji(note) + start_comment_with_emoji(note, '.atwho-view li') start_and_cancel_discussion note.fill_in(with: '') - start_comment_with_emoji(note) + start_comment_with_emoji(note, '.atwho-view li') note.native.send_keys(:enter) expect(note.value).to eql('Hello :100: ') end - - def start_comment_with_emoji(note) - note.native.send_keys('Hello :10') - - wait_for_requests - - find('.atwho-view li', text: '100') - end - - def start_and_cancel_discussion - click_button('Reply...') - - fill_in('note_note', with: 'Whoops!') - - page.accept_alert 'Are you sure you want to cancel creating this comment?' do - click_button('Cancel') - end - - wait_for_requests - end end shared_examples 'autocomplete suggestions' do @@ -599,6 +579,33 @@ RSpec.describe 'GFM autocomplete', :js do expect(page).not_to have_selector('.tribute-container', visible: true) end + it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do + note = find('#note-body') + + # Number. + page.within '.timeline-content-form' do + note.native.send_keys('7:') + end + + expect(page).not_to have_selector('.tribute-container', visible: true) + + # ASCII letter. + page.within '.timeline-content-form' do + note.set('') + note.native.send_keys('w:') + end + + expect(page).not_to have_selector('.tribute-container', visible: true) + + # Non-ASCII letter. + page.within '.timeline-content-form' do + note.set('') + note.native.send_keys('Ё:') + end + + expect(page).not_to have_selector('.tribute-container', visible: true) + end + it 'selects the first item for assignee dropdowns' do page.within '.timeline-content-form' do find('#note-body').native.send_keys('@') @@ -624,6 +631,16 @@ RSpec.describe 'GFM autocomplete', :js do expect(find('.tribute-container ul', visible: true)).to have_content(user.name) end + it 'selects the first item for non-assignee dropdowns if a query is entered' do + page.within '.timeline-content-form' do + find('#note-body').native.send_keys(':1') + end + + wait_for_requests + + expect(find('.tribute-container ul', visible: true)).to have_selector('.highlight:first-of-type') + end + context 'when autocompleting for groups' do it 'shows the group when searching for the name of the group' do page.within '.timeline-content-form' do @@ -687,6 +704,25 @@ RSpec.describe 'GFM autocomplete', :js do expect_to_wrap(false, user_item, note, user.username) end + it 'does not wrap for emoji values' do + note = find('#note-body') + page.within '.timeline-content-form' do + note.native.send_keys(":cartwheel_") + end + + emoji_item = first('.tribute-container li', text: 'cartwheel_tone1', visible: true) + + expect_to_wrap(false, emoji_item, note, 'cartwheel_tone1') + end + + it 'does not open autocomplete if there is no space before' do + page.within '.timeline-content-form' do + find('#note-body').native.send_keys("hello:#{user.username[0..2]}") + end + + expect(page).not_to have_selector('.tribute-container') + end + it 'triggers autocomplete after selecting a quick action' do note = find('#note-body') page.within '.timeline-content-form' do @@ -824,6 +860,26 @@ RSpec.describe 'GFM autocomplete', :js do end end + context 'when other notes are destroyed' do + let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) } + + # This is meant to protect against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/228729 + it 'keeps autocomplete key listeners' do + visit project_issue_path(project, issue) + note = find('#note-body') + + start_comment_with_emoji(note, '.tribute-container li') + + start_and_cancel_discussion + + note.fill_in(with: '') + start_comment_with_emoji(note, '.tribute-container li') + note.native.send_keys(:enter) + + expect(note.value).to eql('Hello :100: ') + end + end + shared_examples 'autocomplete suggestions' do it 'suggests objects correctly' do page.within '.timeline-content-form' do @@ -913,4 +969,24 @@ RSpec.describe 'GFM autocomplete', :js do note.native.send_keys(text) end end + + def start_comment_with_emoji(note, selector) + note.native.send_keys('Hello :10') + + wait_for_requests + + find(selector, text: '100') + end + + def start_and_cancel_discussion + click_button('Reply...') + + fill_in('note_note', with: 'Whoops!') + + page.accept_alert 'Are you sure you want to cancel creating this comment?' do + click_button('Cancel') + end + + wait_for_requests + end end diff --git a/spec/frontend/editor/editor_ci_schema_ext_spec.js b/spec/frontend/editor/editor_ci_schema_ext_spec.js new file mode 100644 index 00000000000..f9a1070f601 --- /dev/null +++ b/spec/frontend/editor/editor_ci_schema_ext_spec.js @@ -0,0 +1,96 @@ +import { languages } from 'monaco-editor'; +import EditorLite from '~/editor/editor_lite'; +import { CiSchemaExtension } from '~/editor/editor_ci_schema_ext'; +import { EXTENSION_CI_SCHEMA_FILE_NAME_MATCH } from '~/editor/constants'; + +describe('~/editor/editor_ci_config_ext', () => { + const defaultBlobPath = '.gitlab-ci.yml'; + + let editor; + let instance; + let editorEl; + + const createMockEditor = ({ blobPath = defaultBlobPath } = {}) => { + setFixtures('
'); + editorEl = document.getElementById('editor'); + editor = new EditorLite(); + instance = editor.createInstance({ + el: editorEl, + blobPath, + blobContent: '', + }); + instance.use(new CiSchemaExtension()); + }; + + beforeEach(() => { + createMockEditor(); + }); + + afterEach(() => { + instance.dispose(); + editorEl.remove(); + }); + + describe('registerCiSchema', () => { + beforeEach(() => { + jest.spyOn(languages.yaml.yamlDefaults, 'setDiagnosticsOptions'); + }); + + describe('register validations options with monaco for yaml language', () => { + const mockProjectNamespace = 'namespace1'; + const mockProjectPath = 'project1'; + + const getConfiguredYmlSchema = () => { + return languages.yaml.yamlDefaults.setDiagnosticsOptions.mock.calls[0][0].schemas[0]; + }; + + it('with expected basic validation configuration', () => { + instance.registerCiSchema({ + projectNamespace: mockProjectNamespace, + projectPath: mockProjectPath, + }); + + const expectedOptions = { + validate: true, + enableSchemaRequest: true, + hover: true, + completion: true, + }; + + expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledTimes(1); + expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledWith( + expect.objectContaining(expectedOptions), + ); + }); + + it('with an schema uri that contains project and ref', () => { + const mockRef = 'AABBCCDD'; + + instance.registerCiSchema({ + projectNamespace: mockProjectNamespace, + projectPath: mockProjectPath, + ref: mockRef, + }); + + expect(getConfiguredYmlSchema()).toEqual({ + uri: `/${mockProjectNamespace}/${mockProjectPath}/-/schema/${mockRef}/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`, + fileMatch: [defaultBlobPath], + }); + }); + + it('with an alternative file name match', () => { + createMockEditor({ blobPath: 'dir1/dir2/another-ci-filename.yml' }); + + instance.registerCiSchema({ + projectNamespace: mockProjectNamespace, + projectPath: mockProjectPath, + }); + + expect(getConfiguredYmlSchema()).toEqual({ + uri: `/${mockProjectNamespace}/${mockProjectPath}/-/schema/master/${EXTENSION_CI_SCHEMA_FILE_NAME_MATCH}`, + fileMatch: ['another-ci-filename.yml'], + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/editor_lite_spec.js b/spec/frontend/vue_shared/components/editor_lite_spec.js index 52502fcf64f..cc11f76d01f 100644 --- a/spec/frontend/vue_shared/components/editor_lite_spec.js +++ b/spec/frontend/vue_shared/components/editor_lite_spec.js @@ -7,20 +7,22 @@ jest.mock('~/editor/editor_lite'); describe('Editor Lite component', () => { let wrapper; - const onDidChangeModelContent = jest.fn(); - const updateModelLanguage = jest.fn(); - const getValue = jest.fn(); - const setValue = jest.fn(); + let mockInstance; + const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; const fileName = 'lorem.txt'; const fileGlobalId = 'snippet_777'; - const createInstanceMock = jest.fn().mockImplementation(() => ({ - onDidChangeModelContent, - updateModelLanguage, - getValue, - setValue, - dispose: jest.fn(), - })); + const createInstanceMock = jest.fn().mockImplementation(() => { + mockInstance = { + onDidChangeModelContent: jest.fn(), + updateModelLanguage: jest.fn(), + getValue: jest.fn(), + setValue: jest.fn(), + dispose: jest.fn(), + }; + return mockInstance; + }); + Editor.mockImplementation(() => { return { createInstance: createInstanceMock, @@ -46,8 +48,8 @@ describe('Editor Lite component', () => { }); const triggerChangeContent = val => { - getValue.mockReturnValue(val); - const [cb] = onDidChangeModelContent.mock.calls[0]; + mockInstance.getValue.mockReturnValue(val); + const [cb] = mockInstance.onDidChangeModelContent.mock.calls[0]; cb(); @@ -92,12 +94,12 @@ describe('Editor Lite component', () => { }); return nextTick().then(() => { - expect(updateModelLanguage).toHaveBeenCalledWith(newFileName); + expect(mockInstance.updateModelLanguage).toHaveBeenCalledWith(newFileName); }); }); it('registers callback with editor onChangeContent', () => { - expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); + expect(mockInstance.onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); }); it('emits input event when the blob content is changed', () => { @@ -117,6 +119,10 @@ describe('Editor Lite component', () => { expect(wrapper.emitted()['editor-ready']).toBeDefined(); }); + it('component API `getEditor()` returns the editor instance', () => { + expect(wrapper.vm.getEditor()).toBe(mockInstance); + }); + describe('reaction to the value update', () => { it('reacts to the changes in the passed value', async () => { const newValue = 'New Value'; @@ -126,7 +132,7 @@ describe('Editor Lite component', () => { }); await nextTick(); - expect(setValue).toHaveBeenCalledWith(newValue); + expect(mockInstance.setValue).toHaveBeenCalledWith(newValue); }); it("does not update value if the passed one is exactly the same as the editor's content", async () => { @@ -137,7 +143,7 @@ describe('Editor Lite component', () => { }); await nextTick(); - expect(setValue).not.toHaveBeenCalled(); + expect(mockInstance.setValue).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap b/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap index d0fa2086fdc..b8dba8e24e7 100644 --- a/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/vue_shared/components/gfm_autocomplete/__snapshots__/utils_spec.js.snap @@ -1,5 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`gfm_autocomplete/utils emojis config shows the emoji name and icon in the menu item 1`] = ` +"raised_hands + + " +`; + exports[`gfm_autocomplete/utils issues config shows the iid and title in the menu item within a project context 1`] = `"123456 Project context issue title <script>alert('hi')</script>"`; exports[`gfm_autocomplete/utils issues config shows the reference and title in the menu item within a group context 1`] = `"gitlab#987654 Group context issue title <script>alert('hi')</script>"`; diff --git a/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js index 647f8c6e000..a66a6d79c83 100644 --- a/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js +++ b/spec/frontend/vue_shared/components/gfm_autocomplete/utils_spec.js @@ -2,6 +2,27 @@ import { escape, last } from 'lodash'; import { GfmAutocompleteType, tributeConfig } from '~/vue_shared/components/gfm_autocomplete/utils'; describe('gfm_autocomplete/utils', () => { + describe('emojis config', () => { + const emojisConfig = tributeConfig[GfmAutocompleteType.Emojis].config; + const emoji = 'raised_hands'; + + it('uses : as the trigger', () => { + expect(emojisConfig.trigger).toBe(':'); + }); + + it('searches using the emoji name', () => { + expect(emojisConfig.lookup(emoji)).toBe(emoji); + }); + + it('shows the emoji name and icon in the menu item', () => { + expect(emojisConfig.menuItemTemplate({ original: emoji })).toMatchSnapshot(); + }); + + it('inserts the emoji name on autocomplete selection', () => { + expect(emojisConfig.selectTemplate({ original: emoji })).toBe(`:${emoji}:`); + }); + }); + describe('issues config', () => { const issuesConfig = tributeConfig[GfmAutocompleteType.Issues].config; const groupContextIssue = { diff --git a/spec/lib/api/entities/snippet_repository_storage_move_spec.rb b/spec/lib/api/entities/snippet_repository_storage_move_spec.rb new file mode 100644 index 00000000000..8086be3ffa7 --- /dev/null +++ b/spec/lib/api/entities/snippet_repository_storage_move_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::SnippetRepositoryStorageMove do + describe '#as_json' do + subject { entity.as_json } + + let(:default_storage) { 'default' } + let(:second_storage) { 'test_second_storage' } + let(:storage_move) { create(:snippet_repository_storage_move, :scheduled, destination_storage_name: second_storage) } + let(:entity) { described_class.new(storage_move) } + + it 'includes basic fields' do + allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(%W[#{default_storage} #{second_storage}]) + + is_expected.to include( + state: 'scheduled', + source_storage_name: default_storage, + destination_storage_name: second_storage, + snippet: a_kind_of(Hash) + ) + end + end +end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index ad5468fb54c..53c0e95c777 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -2711,6 +2711,22 @@ RSpec.describe API::Projects do expect(response).to have_gitlab_http_status(:bad_request) expect(json_response['message']['container_expiration_policy.name_regex_keep']).to contain_exactly('not valid RE2 syntax: missing ]: [') end + + it "doesn't update container_expiration_policy with invalid keep_n" do + project_param = { + container_expiration_policy_attributes: { + cadence: '1month', + enabled: true, + keep_n: 'not_int', + name_regex_keep: 'foo.*' + } + } + + put api("/projects/#{project3.id}", user4), params: project_param + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('container_expiration_policy_attributes[keep_n] is invalid') + end end context 'when authenticated as project developer' do diff --git a/spec/support/helpers/stub_experiments.rb b/spec/support/helpers/stub_experiments.rb index 247692d83ee..9188bc704c1 100644 --- a/spec/support/helpers/stub_experiments.rb +++ b/spec/support/helpers/stub_experiments.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module StubExperiments + SUFFIX = Gitlab::Experimentation::Experiment::FEATURE_FLAG_SUFFIX + # Stub Experiment with `key: true/false` # # @param [Hash] experiment where key is feature name and value is boolean whether active or not. @@ -11,6 +13,7 @@ module StubExperiments allow(Gitlab::Experimentation).to receive(:active?).and_call_original experiments.each do |experiment_key, enabled| + Feature.persist_used!("#{experiment_key}#{SUFFIX}") allow(Gitlab::Experimentation).to receive(:active?).with(experiment_key) { enabled } end end @@ -25,6 +28,7 @@ module StubExperiments allow(Gitlab::Experimentation).to receive(:in_experiment_group?).and_call_original experiments.each do |experiment_key, enabled| + Feature.persist_used!("#{experiment_key}#{SUFFIX}") allow(Gitlab::Experimentation).to receive(:in_experiment_group?).with(experiment_key, anything) { enabled } end end