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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-10-07 15:09:12 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-10-07 15:09:12 +0300
commitcf37ae7acd7e3868f632c37a508fe9c5a220a9ba (patch)
treeb1ca4075bc89c4981ece17681993d5bf52e5ce25
parent419f9c0ac3ae842964cc191932cab795463b259c (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop_todo.yml3
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js7
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue2
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js13
-rw-r--r--app/assets/javascripts/ide/lib/languages/hcl.js192
-rw-r--r--app/assets/javascripts/ide/lib/languages/index.js3
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue2
-rw-r--r--app/assets/javascripts/notes/components/discussion_reply_placeholder.vue15
-rw-r--r--app/assets/javascripts/packages/details/components/additional_metadata.vue6
-rw-r--r--app/assets/javascripts/packages/details/store/getters.js5
-rw-r--r--app/assets/javascripts/packages/details/utils.js13
-rw-r--r--app/assets/javascripts/pages/projects/packages/packages/index/index.js6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue2
-rw-r--r--app/assets/stylesheets/framework/buttons.scss9
-rw-r--r--app/assets/stylesheets/page_bundles/pipelines.scss66
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss94
-rw-r--r--app/controllers/projects/feature_flags_clients_controller.rb27
-rw-r--r--app/controllers/projects/feature_flags_controller.rb172
-rw-r--r--app/controllers/projects/feature_flags_user_lists_controller.rb21
-rw-r--r--app/helpers/user_callouts_helper.rb5
-rw-r--r--app/models/blob_viewer/markup.rb10
-rw-r--r--app/presenters/packages/detail/package_presenter.rb6
-rw-r--r--app/services/ci/create_downstream_pipeline_service.rb2
-rw-r--r--app/services/ci/create_pipeline_service.rb2
-rw-r--r--app/services/notification_recipients/build_service.rb8
-rw-r--r--app/views/projects/blob/viewers/_markup.html.haml3
-rw-r--r--app/views/projects/commit/pipelines.html.haml1
-rw-r--r--app/views/projects/feature_flags/edit.html.haml2
-rw-r--r--app/views/projects/feature_flags/new.html.haml2
-rw-r--r--app/views/projects/merge_requests/creations/new.html.haml1
-rw-r--r--app/views/projects/merge_requests/show.html.haml1
-rw-r--r--app/views/projects/pipelines/index.html.haml1
-rw-r--r--changelogs/unreleased/229727-drop-type-on-audit-events.yml5
-rw-r--r--changelogs/unreleased/233627-fj-restore-snippets-in-backups.yml5
-rw-r--r--changelogs/unreleased/239130-package-presenter-conan.yml5
-rw-r--r--changelogs/unreleased/241058-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml6
-rw-r--r--changelogs/unreleased/262051-design-thumbnail-image-is-not-updated-after-uploading-an-image-wit.yml5
-rw-r--r--changelogs/unreleased/42782-move-beforescript-into-script.yml5
-rw-r--r--changelogs/unreleased/adding-hcl.yml5
-rw-r--r--changelogs/unreleased/id-required-sections.yml5
-rw-r--r--config/application.rb1
-rw-r--r--config/feature_flags/development/cached_markdown_blob.yml7
-rw-r--r--config/feature_flags/development/ci_dynamic_child_pipeline.yml6
-rw-r--r--config/feature_flags/development/ci_lint_creates_pipeline_with_dry_run.yml6
-rw-r--r--config/feature_flags/development/ci_raise_job_rules_without_workflow_rules_warning.yml6
-rw-r--r--config/feature_flags/development/ci_store_pipeline_messages.yml6
-rw-r--r--config/feature_flags/development/ci_yaml_limit_size.yml6
-rw-r--r--config/feature_flags/development/efficient_counter_attribute.yml6
-rw-r--r--config/feature_flags/development/feature_flag_api.yml7
-rw-r--r--config/feature_flags/development/feature_flag_permissions.yml7
-rw-r--r--config/feature_flags/development/feature_flags_legacy_read_only.yml7
-rw-r--r--config/feature_flags/development/feature_flags_legacy_read_only_override.yml7
-rw-r--r--config/routes/project.rb4
-rw-r--r--db/migrate/20200928131934_create_required_code_owners_sections.rb26
-rw-r--r--db/migrate/20200928164807_add_index_on_vulnerabilities_state_case.rb21
-rw-r--r--db/post_migrate/20200929113254_remove_type_from_audit_events.rb125
-rw-r--r--db/schema_migrations/202009281319341
-rw-r--r--db/schema_migrations/202009281648071
-rw-r--r--db/schema_migrations/202009291132541
-rw-r--r--db/structure.sql35
-rw-r--r--doc/administration/geo/disaster_recovery/index.md2
-rw-r--r--doc/administration/raketasks/check.md4
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql17
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json32
-rw-r--r--doc/api/graphql/reference/index.md5
-rw-r--r--doc/development/migration_style_guide.md12
-rw-r--r--doc/gitlab-basics/add-file.md12
-rw-r--r--lib/api/api.rb3
-rw-r--r--lib/api/feature_flag_scopes.rb158
-rw-r--r--lib/api/feature_flags.rb266
-rw-r--r--lib/api/feature_flags_user_lists.rb100
-rw-r--r--lib/backup/repositories.rb4
-rw-r--r--lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml3
-rw-r--r--lib/gitlab/graphql/pagination/keyset/order_info.rb9
-rw-r--r--lib/google_api/cloud_platform/client.rb2
-rw-r--r--locale/gitlab.pot40
-rw-r--r--rubocop/migration_helpers.rb2
-rw-r--r--rubocop/rubocop-migrations.yml3
-rw-r--r--spec/controllers/projects/feature_flags_clients_controller_spec.rb57
-rw-r--r--spec/controllers/projects/feature_flags_controller_spec.rb1604
-rw-r--r--spec/controllers/projects/feature_flags_user_lists_controller_spec.rb113
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb2
-rw-r--r--spec/features/merge_request/batch_comments_spec.rb2
-rw-r--r--spec/features/merge_request/user_posts_diff_notes_spec.rb2
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb10
-rw-r--r--spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb6
-rw-r--r--spec/features/merge_request/user_sees_discussions_spec.rb2
-rw-r--r--spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb2
-rw-r--r--spec/features/projects/commit/builds_spec.rb2
-rw-r--r--spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb63
-rw-r--r--spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb21
-rw-r--r--spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb21
-rw-r--r--spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb200
-rw-r--r--spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb31
-rw-r--r--spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb147
-rw-r--r--spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb195
-rw-r--r--spec/features/projects/releases/user_views_edit_release_spec.rb2
-rw-r--r--spec/fixtures/api/schemas/feature_flag.json23
-rw-r--r--spec/fixtures/api/schemas/feature_flag_scope.json18
-rw-r--r--spec/fixtures/api/schemas/feature_flag_strategy.json13
-rw-r--r--spec/fixtures/api/schemas/feature_flags.json13
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag.json15
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag_detailed_scopes.json22
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag_scope.json17
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag_scopes.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flag_strategy.json13
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/feature_flags.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/operations/scope.json9
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/operations/strategy.json14
-rw-r--r--spec/fixtures/lib/backup/personal_snippet_repo.bundlebin0 -> 686 bytes
-rw-r--r--spec/fixtures/lib/backup/project_snippet_repo.bundlebin0 -> 696 bytes
-rw-r--r--spec/frontend/ide/lib/languages/hcl_spec.js290
-rw-r--r--spec/frontend/notes/components/discussion_reply_placeholder_spec.js7
-rw-r--r--spec/frontend/packages/details/store/getters_spec.js4
-rw-r--r--spec/frontend/packages/details/utils_spec.js24
-rw-r--r--spec/frontend/packages/mock_data.js4
-rw-r--r--spec/helpers/user_callouts_helper_spec.rb22
-rw-r--r--spec/lib/backup/repositories_spec.rb12
-rw-r--r--spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb14
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml1
-rw-r--r--spec/models/blob_viewer/markup_spec.rb38
-rw-r--r--spec/presenters/packages/detail/package_presenter_spec.rb2
-rw-r--r--spec/requests/api/feature_flag_scopes_spec.rb319
-rw-r--r--spec/requests/api/feature_flags_spec.rb1130
-rw-r--r--spec/requests/api/feature_flags_user_lists_spec.rb371
-rw-r--r--spec/services/clusters/gcp/finalize_creation_service_spec.rb5
-rw-r--r--spec/support/google_api/cloud_platform_helpers.rb14
128 files changed, 6334 insertions, 289 deletions
diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml
index 062225a75ff..1d730d42ac0 100644
--- a/.rubocop_todo.yml
+++ b/.rubocop_todo.yml
@@ -154,8 +154,6 @@ Performance/Count:
- 'app/helpers/groups_helper.rb'
- 'app/services/merge_requests/add_context_service.rb'
- 'ee/lib/gitlab/graphql/aggregations/epics/epic_node.rb'
- - 'ee/spec/controllers/projects/feature_flags_controller_spec.rb'
- - 'ee/spec/requests/api/feature_flags_spec.rb'
- 'lib/gitlab/sidekiq_status.rb'
- 'spec/lib/gitlab/conflict/file_spec.rb'
- 'spec/lib/gitlab/git/tree_spec.rb'
@@ -167,7 +165,6 @@ Performance/Count:
Performance/Detect:
Exclude:
- 'ee/spec/controllers/projects/dependencies_controller_spec.rb'
- - 'ee/spec/controllers/projects/feature_flags_controller_spec.rb'
- 'spec/lib/gitlab/git/tree_spec.rb'
- 'spec/lib/gitlab/import_export/project/tree_restorer_spec.rb'
- 'spec/models/event_spec.rb'
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index 340a93e4e66..c8168afbcb0 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -3,9 +3,10 @@ import commitPipelinesTable from './pipelines_table.vue';
/**
* Used in:
- * - Commit details View > Pipelines Tab > Pipelines Table.
- * - Merge Request details View > Pipelines Tab > Pipelines Table.
- * - New Merge Request View > Pipelines Tab > Pipelines Table.
+ * - Project Pipelines List (projects:pipelines:index)
+ * - Commit details View > Pipelines Tab > Pipelines Table (projects:commit:pipelines)
+ * - Merge Request details View > Pipelines Tab > Pipelines Table (projects:merge_requests:show)
+ * - New Merge Request View > Pipelines Tab > Pipelines Table (projects:merge_requests:creations:new)
*/
const CommitPipelinesTable = Vue.extend(commitPipelinesTable);
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index 188d958ba86..fe32868e6d8 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -193,7 +193,7 @@ export default {
"
/>
- <div v-else-if="shouldRenderTable" class="table-holder">
+ <div v-else-if="shouldRenderTable">
<gl-button
v-if="canRenderPipelineButton"
block
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index 6c64f05c973..cf6fba95115 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -1,6 +1,6 @@
/* eslint-disable @gitlab/require-i18n-strings */
-import { groupBy } from 'lodash';
+import { differenceBy } from 'lodash';
import produce from 'immer';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { extractCurrentDiscussion, extractDesign, extractDesigns } from './design_management_utils';
@@ -132,10 +132,13 @@ const addNewDesignToStore = (store, designManagementUpload, query) => {
const data = produce(sourceData, draftData => {
const currentDesigns = extractDesigns(draftData);
- const existingDesigns = groupBy(currentDesigns, 'filename');
- const newDesigns = currentDesigns.concat(
- designManagementUpload.designs.filter(d => !existingDesigns[d.filename]),
- );
+ const difference = differenceBy(designManagementUpload.designs, currentDesigns, 'filename');
+
+ const newDesigns = currentDesigns
+ .map(design => {
+ return designManagementUpload.designs[design.filename] || design;
+ })
+ .concat(difference);
let newVersionNode;
const findNewVersions = designManagementUpload.designs.find(design => design.versions);
diff --git a/app/assets/javascripts/ide/lib/languages/hcl.js b/app/assets/javascripts/ide/lib/languages/hcl.js
new file mode 100644
index 00000000000..4539719b1f2
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/hcl.js
@@ -0,0 +1,192 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md
+ *--------------------------------------------------------------------------------------------*/
+
+/* eslint-disable no-useless-escape */
+/* eslint-disable @gitlab/require-i18n-strings */
+
+const conf = {
+ comments: {
+ lineComment: '//',
+ blockComment: ['/*', '*/'],
+ },
+ brackets: [['{', '}'], ['[', ']'], ['(', ')']],
+ autoClosingPairs: [
+ { open: '{', close: '}' },
+ { open: '[', close: ']' },
+ { open: '(', close: ')' },
+ { open: '"', close: '"', notIn: ['string'] },
+ ],
+ surroundingPairs: [
+ { open: '{', close: '}' },
+ { open: '[', close: ']' },
+ { open: '(', close: ')' },
+ { open: '"', close: '"' },
+ ],
+};
+
+const language = {
+ defaultToken: '',
+ tokenPostfix: '.hcl',
+
+ keywords: [
+ 'var',
+ 'local',
+ 'path',
+ 'for_each',
+ 'any',
+ 'string',
+ 'number',
+ 'bool',
+ 'true',
+ 'false',
+ 'null',
+ 'if ',
+ 'else ',
+ 'endif ',
+ 'for ',
+ 'in',
+ 'endfor',
+ ],
+
+ operators: [
+ '=',
+ '>=',
+ '<=',
+ '==',
+ '!=',
+ '+',
+ '-',
+ '*',
+ '/',
+ '%',
+ '&&',
+ '||',
+ '!',
+ '<',
+ '>',
+ '?',
+ '...',
+ ':',
+ ],
+
+ symbols: /[=><!~?:&|+\-*\/\^%]+/,
+ escapes: /\\(?:[abfnrtv\\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
+ terraformFunctions: /(abs|ceil|floor|log|max|min|pow|signum|chomp|format|formatlist|indent|join|lower|regex|regexall|replace|split|strrev|substr|title|trimspace|upper|chunklist|coalesce|coalescelist|compact|concat|contains|distinct|element|flatten|index|keys|length|list|lookup|map|matchkeys|merge|range|reverse|setintersection|setproduct|setunion|slice|sort|transpose|values|zipmap|base64decode|base64encode|base64gzip|csvdecode|jsondecode|jsonencode|urlencode|yamldecode|yamlencode|abspath|dirname|pathexpand|basename|file|fileexists|fileset|filebase64|templatefile|formatdate|timeadd|timestamp|base64sha256|base64sha512|bcrypt|filebase64sha256|filebase64sha512|filemd5|filemd1|filesha256|filesha512|md5|rsadecrypt|sha1|sha256|sha512|uuid|uuidv5|cidrhost|cidrnetmask|cidrsubnet|tobool|tolist|tomap|tonumber|toset|tostring)/,
+ terraformMainBlocks: /(module|data|terraform|resource|provider|variable|output|locals)/,
+ tokenizer: {
+ root: [
+ // highlight main blocks
+ [
+ /^@terraformMainBlocks([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)(\{)/,
+ ['type', '', 'string', '', 'string', '', '@brackets'],
+ ],
+ // highlight all the remaining blocks
+ [
+ /(\w+[ \t]+)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)(\{)/,
+ ['identifier', '', 'string', '', 'string', '', '@brackets'],
+ ],
+ // highlight block
+ [
+ /(\w+[ \t]+)([ \t]*)([\w-]+|"[\w-]+"|)([ \t]*)([\w-]+|"[\w-]+"|)(=)(\{)/,
+ ['identifier', '', 'string', '', 'operator', '', '@brackets'],
+ ],
+ // terraform general highlight - shared with expressions
+ { include: '@terraform' },
+ ],
+ terraform: [
+ // highlight terraform functions
+ [/@terraformFunctions(\()/, ['type', '@brackets']],
+ // all other words are variables or keywords
+ [
+ /[a-zA-Z_]\w*-*/, // must work with variables such as foo-bar and also with negative numbers
+ {
+ cases: {
+ '@keywords': { token: 'keyword.$0' },
+ '@default': 'variable',
+ },
+ },
+ ],
+ { include: '@whitespace' },
+ { include: '@heredoc' },
+ // delimiters and operators
+ [/[{}()\[\]]/, '@brackets'],
+ [/[<>](?!@symbols)/, '@brackets'],
+ [
+ /@symbols/,
+ {
+ cases: {
+ '@operators': 'operator',
+ '@default': '',
+ },
+ },
+ ],
+ // numbers
+ [/\d*\d+[eE]([\-+]?\d+)?/, 'number.float'],
+ [/\d*\.\d+([eE][\-+]?\d+)?/, 'number.float'],
+ [/\d[\d']*/, 'number'],
+ [/\d/, 'number'],
+ [/[;,.]/, 'delimiter'], // delimiter: after number because of .\d floats
+ // strings
+ [/"/, 'string', '@string'], // this will include expressions
+ [/'/, 'invalid'],
+ ],
+ heredoc: [
+ [
+ /<<[-]*\s*["]?([\w\-]+)["]?/,
+ { token: 'string.heredoc.delimiter', next: '@heredocBody.$1' },
+ ],
+ ],
+ heredocBody: [
+ [
+ /^([\w\-]+)$/,
+ {
+ cases: {
+ '$1==$S2': [
+ {
+ token: 'string.heredoc.delimiter',
+ next: '@popall',
+ },
+ ],
+ '@default': 'string.heredoc',
+ },
+ },
+ ],
+ [/./, 'string.heredoc'],
+ ],
+ whitespace: [
+ [/[ \t\r\n]+/, ''],
+ [/\/\*/, 'comment', '@comment'],
+ [/\/\/.*$/, 'comment'],
+ [/#.*$/, 'comment'],
+ ],
+ comment: [[/[^\/*]+/, 'comment'], [/\*\//, 'comment', '@pop'], [/[\/*]/, 'comment']],
+ string: [
+ [/\$\{/, { token: 'delimiter', next: '@stringExpression' }],
+ [/[^\\"\$]+/, 'string'],
+ [/@escapes/, 'string.escape'],
+ [/\\./, 'string.escape.invalid'],
+ [/"/, 'string', '@popall'],
+ ],
+ stringInsideExpression: [
+ [/[^\\"]+/, 'string'],
+ [/@escapes/, 'string.escape'],
+ [/\\./, 'string.escape.invalid'],
+ [/"/, 'string', '@pop'],
+ ],
+ stringExpression: [
+ [/\}/, { token: 'delimiter', next: '@pop' }],
+ [/"/, 'string', '@stringInsideExpression'],
+ { include: '@terraform' },
+ ],
+ },
+};
+
+export default {
+ id: 'hcl',
+ extensions: ['.tf', '.tfvars', '.hcl'],
+ aliases: ['Terraform', 'tf', 'HCL', 'hcl'],
+ conf,
+ language,
+};
diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js
index 0c85a1104fc..580ad820bf9 100644
--- a/app/assets/javascripts/ide/lib/languages/index.js
+++ b/app/assets/javascripts/ide/lib/languages/index.js
@@ -1,5 +1,6 @@
import vue from './vue';
+import hcl from './hcl';
-const languages = [vue];
+const languages = [vue, hcl];
export default languages;
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 3d6c9e6b297..878a748e99a 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -55,7 +55,7 @@ export default {
<div class="discussion-with-resolve-btn clearfix">
<reply-placeholder
data-qa-selector="discussion_reply_tab"
- :button-text="s__('MergeRequests|Reply')"
+ :button-text="s__('MergeRequests|Reply...')"
@onClick="$emit('showReplyForm')"
/>
diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
index ab00ccdc09b..0204169214b 100644
--- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
+++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
@@ -1,11 +1,6 @@
<script>
-import { GlButton } from '@gitlab/ui';
-
export default {
name: 'ReplyPlaceholder',
- components: {
- GlButton,
- },
props: {
buttonText: {
type: String,
@@ -16,13 +11,13 @@ export default {
</script>
<template>
- <gl-button
- category="primary"
- variant="success"
- class="js-vue-discussion-reply"
+ <button
+ ref="button"
+ type="button"
+ class="js-vue-discussion-reply btn btn-text-field"
:title="s__('MergeRequests|Add a reply')"
@click="$emit('onClick')"
>
{{ buttonText }}
- </gl-button>
+ </button>
</template>
diff --git a/app/assets/javascripts/packages/details/components/additional_metadata.vue b/app/assets/javascripts/packages/details/components/additional_metadata.vue
index 76e0976ac05..4e99099b0a1 100644
--- a/app/assets/javascripts/packages/details/components/additional_metadata.vue
+++ b/app/assets/javascripts/packages/details/components/additional_metadata.vue
@@ -2,7 +2,6 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { s__ } from '~/locale';
import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
-import { generateConanRecipe } from '../utils';
import { PackageType } from '../../shared/constants';
export default {
@@ -25,9 +24,6 @@ export default {
},
},
computed: {
- conanRecipe() {
- return generateConanRecipe(this.packageEntity);
- },
showMetadata() {
const visibilityConditions = {
[PackageType.NUGET]: this.packageEntity.nuget_metadatum,
@@ -73,7 +69,7 @@ export default {
data-testid="conan-recipe"
>
<gl-sprintf :message="$options.i18n.recipeText">
- <template #recipe>{{ conanRecipe }}</template>
+ <template #recipe>{{ packageEntity.name }}</template>
</gl-sprintf>
</details-row>
diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js
index 04f75fc8333..bb0ae3e9ab7 100644
--- a/app/assets/javascripts/packages/details/store/getters.js
+++ b/app/assets/javascripts/packages/details/store/getters.js
@@ -1,4 +1,3 @@
-import { generateConanRecipe } from '../utils';
import { PackageType } from '../../shared/constants';
import { getPackageTypeLabel } from '../../shared/utils';
import { NpmManager } from '../constants';
@@ -20,10 +19,8 @@ export const packageIcon = ({ packageEntity }) => {
};
export const conanInstallationCommand = ({ packageEntity }) => {
- const recipe = generateConanRecipe(packageEntity);
-
// eslint-disable-next-line @gitlab/require-i18n-strings
- return `conan install ${recipe} --remote=gitlab`;
+ return `conan install ${packageEntity.name} --remote=gitlab`;
};
export const conanSetupCommand = ({ conanPath }) =>
diff --git a/app/assets/javascripts/packages/details/utils.js b/app/assets/javascripts/packages/details/utils.js
index 454c83c9ccd..27cc95566d3 100644
--- a/app/assets/javascripts/packages/details/utils.js
+++ b/app/assets/javascripts/packages/details/utils.js
@@ -8,16 +8,3 @@ export const trackInstallationTabChange = {
},
},
};
-
-export function generateConanRecipe(packageEntity = {}) {
- const {
- name = '',
- version = '',
- conan_metadatum: {
- package_username: packageUsername = '',
- package_channel: packageChannel = '',
- } = {},
- } = packageEntity;
-
- return `${name}/${version}@${packageUsername}/${packageChannel}`;
-}
diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
index 4836900aa28..c94782fdf1b 100644
--- a/app/assets/javascripts/pages/projects/packages/packages/index/index.js
+++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js
@@ -1,7 +1,3 @@
import initPackageList from '~/packages/list/packages_list_app_bundle';
-document.addEventListener('DOMContentLoaded', () => {
- if (document.getElementById('js-vue-packages-list')) {
- initPackageList();
- }
-});
+initPackageList();
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
index 1bdb7d18f04..423bf3b32bd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
@@ -321,7 +321,11 @@ export default {
</div>
</div>
- <pipelines-timeago :duration="pipelineDuration" :finished-time="pipelineFinishedAt" />
+ <pipelines-timeago
+ class="gl-text-right"
+ :duration="pipelineDuration"
+ :finished-time="pipelineFinishedAt"
+ />
<div
v-if="displayPipelineActions"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index 8de18aef639..d43b3f93aef 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -50,7 +50,7 @@ export default {
};
</script>
<template>
- <div class="table-section section-15 pipelines-time-ago">
+ <div class="table-section section-15">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Duration') }}</div>
<div class="table-mobile-content">
<p v-if="hasDuration" class="duration">
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 94d0f7c999f..a8cc685d880 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -244,15 +244,20 @@
}
&.btn-text-field {
- color: $gray-500;
- justify-content: start;
width: 100%;
text-align: left;
+ padding: 6px 16px;
+ border-color: $border-color;
+ color: $gray-darkest;
+ background-color: $white;
&:hover,
&:active,
&:focus {
cursor: text;
+ box-shadow: none;
+ border-color: lighten($blue-300, 20%);
+ color: $gray-darkest;
}
}
diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss
new file mode 100644
index 00000000000..8fcfde6b32b
--- /dev/null
+++ b/app/assets/stylesheets/page_bundles/pipelines.scss
@@ -0,0 +1,66 @@
+@import 'mixins_and_variables_and_functions';
+
+/**
+ * Pipelines Bundle
+ *
+ * Styles of pipeline lists
+ *
+ * Should affect pipelines table components rendered by:
+ * app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+ */
+
+.pipelines {
+ .badge {
+ margin-bottom: 3px;
+ }
+
+ .pipeline-actions {
+ min-width: 170px; //Guarantees buttons don't break in several lines.
+
+ .btn-default {
+ color: $gl-text-color-secondary;
+ }
+
+ .btn.btn-retry:hover,
+ .btn.btn-retry:focus {
+ border-color: $dropdown-toggle-active-border-color;
+ background-color: $white-normal;
+ }
+
+ svg path {
+ fill: $gl-text-color-secondary;
+ }
+
+ .dropdown-menu {
+ max-height: $dropdown-max-height;
+ overflow-y: auto;
+ }
+
+ .dropdown-toggle,
+ .dropdown-menu {
+ color: $gl-text-color-secondary;
+
+ .fa {
+ color: $gl-text-color-secondary;
+ font-size: 14px;
+ }
+ }
+
+ .btn-group.open .btn-default {
+ background-color: $white-normal;
+ border-color: $border-white-normal;
+ }
+
+ .btn .text-center {
+ display: inline;
+ }
+
+ .tooltip {
+ white-space: nowrap;
+ }
+ }
+
+ .pipeline-tags .label-container {
+ white-space: normal;
+ }
+}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 48d37ead8e2..0f68c393187 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -1,92 +1,3 @@
-.pipelines {
- .stage {
- max-width: 90px;
- width: 90px;
- text-align: center;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .table-holder {
- overflow: unset;
- width: 100%;
- }
-
- .commit-title {
- margin: 0;
- white-space: normal;
-
- @include media-breakpoint-down(sm) {
- justify-content: flex-end;
- }
- }
-
- .ci-table {
- .badge {
- margin-bottom: 3px;
- }
-
- .pipeline-id {
- color: $black;
- }
-
- .pipelines-time-ago {
- text-align: right;
- }
-
- .pipeline-actions {
- min-width: 170px; //Guarantees buttons don't break in several lines.
-
- .btn-default {
- color: $gl-text-color-secondary;
- }
-
- .btn.btn-retry:hover,
- .btn.btn-retry:focus {
- border-color: $dropdown-toggle-active-border-color;
- background-color: $white-normal;
- }
-
- svg path {
- fill: $gl-text-color-secondary;
- }
-
- .dropdown-menu {
- max-height: $dropdown-max-height;
- overflow-y: auto;
- }
-
- .dropdown-toggle,
- .dropdown-menu {
- color: $gl-text-color-secondary;
-
- .fa {
- color: $gl-text-color-secondary;
- font-size: 14px;
- }
- }
-
- .btn-group.open .btn-default {
- background-color: $white-normal;
- border-color: $border-white-normal;
- }
-
- .btn .text-center {
- display: inline;
- }
-
- .tooltip {
- white-space: nowrap;
- }
- }
-
- .pipeline-tags .label-container {
- white-space: normal;
- }
- }
-}
-
@include media-breakpoint-down(md) {
.content-list {
&.builds-content-list {
@@ -246,11 +157,6 @@
}
}
-// Pipeline visualization
-.pipeline-actions {
- border-bottom: 0;
-}
-
.ci-build-text,
.ci-status-text {
font-weight: 200;
diff --git a/app/controllers/projects/feature_flags_clients_controller.rb b/app/controllers/projects/feature_flags_clients_controller.rb
new file mode 100644
index 00000000000..02c9d9ab8fb
--- /dev/null
+++ b/app/controllers/projects/feature_flags_clients_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class Projects::FeatureFlagsClientsController < Projects::ApplicationController
+ before_action :authorize_admin_feature_flags_client!
+ before_action :feature_flags_client
+
+ def reset_token
+ feature_flags_client.reset_token!
+
+ respond_to do |format|
+ format.json do
+ render json: feature_flags_client_token_json, status: :ok
+ end
+ end
+ end
+
+ private
+
+ def feature_flags_client
+ project.operations_feature_flags_client || not_found
+ end
+
+ def feature_flags_client_token_json
+ FeatureFlagsClientSerializer.new
+ .represent_token(feature_flags_client)
+ end
+end
diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb
new file mode 100644
index 00000000000..4452b61508b
--- /dev/null
+++ b/app/controllers/projects/feature_flags_controller.rb
@@ -0,0 +1,172 @@
+# frozen_string_literal: true
+
+class Projects::FeatureFlagsController < Projects::ApplicationController
+ respond_to :html
+
+ before_action :authorize_read_feature_flag!
+ before_action :authorize_create_feature_flag!, only: [:new, :create]
+ before_action :authorize_update_feature_flag!, only: [:edit, :update]
+ before_action :authorize_destroy_feature_flag!, only: [:destroy]
+
+ before_action :feature_flag, only: [:edit, :update, :destroy]
+
+ before_action :ensure_legacy_flags_writable!, only: [:update]
+
+ before_action do
+ push_frontend_feature_flag(:feature_flag_permissions)
+ push_frontend_feature_flag(:feature_flags_new_version, project, default_enabled: true)
+ push_frontend_feature_flag(:feature_flags_legacy_read_only, project, default_enabled: true)
+ push_frontend_feature_flag(:feature_flags_legacy_read_only_override, project)
+ end
+
+ def index
+ @feature_flags = FeatureFlagsFinder
+ .new(project, current_user, scope: params[:scope])
+ .execute
+ .page(params[:page])
+ .per(30)
+
+ respond_to do |format|
+ format.html
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render json: { feature_flags: feature_flags_json }.merge(summary_json)
+ end
+ end
+ end
+
+ def new
+ end
+
+ def show
+ respond_to do |format|
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ render_success_json(feature_flag)
+ end
+ end
+ end
+
+ def create
+ result = FeatureFlags::CreateService.new(project, current_user, create_params).execute
+
+ if result[:status] == :success
+ respond_to do |format|
+ format.json { render_success_json(result[:feature_flag]) }
+ end
+ else
+ respond_to do |format|
+ format.json { render_error_json(result[:message]) }
+ end
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ result = FeatureFlags::UpdateService.new(project, current_user, update_params).execute(feature_flag)
+
+ if result[:status] == :success
+ respond_to do |format|
+ format.json { render_success_json(result[:feature_flag]) }
+ end
+ else
+ respond_to do |format|
+ format.json { render_error_json(result[:message]) }
+ end
+ end
+ end
+
+ def destroy
+ result = FeatureFlags::DestroyService.new(project, current_user).execute(feature_flag)
+
+ if result[:status] == :success
+ respond_to do |format|
+ format.html { redirect_to_index(notice: _('Feature flag was successfully removed.')) }
+ format.json { render_success_json(feature_flag) }
+ end
+ else
+ respond_to do |format|
+ format.html { redirect_to_index(alert: _('Feature flag was not removed.')) }
+ format.json { render_error_json(result[:message]) }
+ end
+ end
+ end
+
+ protected
+
+ def feature_flag
+ @feature_flag ||= @noteable = if new_version_feature_flags_enabled?
+ project.operations_feature_flags.find_by_iid!(params[:iid])
+ else
+ project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid])
+ end
+ end
+
+ def new_version_feature_flags_enabled?
+ ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
+ end
+
+ def ensure_legacy_flags_writable!
+ if ::Feature.enabled?(:feature_flags_legacy_read_only, project, default_enabled: true) &&
+ ::Feature.disabled?(:feature_flags_legacy_read_only_override, project) &&
+ feature_flag.legacy_flag?
+ render_error_json(['Legacy feature flags are read-only'])
+ end
+ end
+
+ def create_params
+ params.require(:operations_feature_flag)
+ .permit(:name, :description, :active, :version,
+ scopes_attributes: [:environment_scope, :active,
+ strategies: [:name, parameters: [:groupId, :percentage, :userIds]]],
+ strategies_attributes: [:name, :user_list_id,
+ parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
+ scopes_attributes: [:environment_scope]])
+ end
+
+ def update_params
+ params.require(:operations_feature_flag)
+ .permit(:name, :description, :active,
+ scopes_attributes: [:id, :environment_scope, :active, :_destroy,
+ strategies: [:name, parameters: [:groupId, :percentage, :userIds]]],
+ strategies_attributes: [:id, :name, :user_list_id, :_destroy,
+ parameters: [:groupId, :percentage, :userIds, :rollout, :stickiness],
+ scopes_attributes: [:id, :environment_scope, :_destroy]])
+ end
+
+ def feature_flag_json(feature_flag)
+ FeatureFlagSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(feature_flag)
+ end
+
+ def feature_flags_json
+ FeatureFlagSerializer
+ .new(project: @project, current_user: @current_user)
+ .with_pagination(request, response)
+ .represent(@feature_flags)
+ end
+
+ def summary_json
+ FeatureFlagSummarySerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(@project)
+ end
+
+ def redirect_to_index(**args)
+ redirect_to project_feature_flags_path(@project), status: :found, **args
+ end
+
+ def render_success_json(feature_flag)
+ render json: feature_flag_json(feature_flag), status: :ok
+ end
+
+ def render_error_json(messages)
+ render json: { message: messages },
+ status: :bad_request
+ end
+end
diff --git a/app/controllers/projects/feature_flags_user_lists_controller.rb b/app/controllers/projects/feature_flags_user_lists_controller.rb
new file mode 100644
index 00000000000..5427a892bff
--- /dev/null
+++ b/app/controllers/projects/feature_flags_user_lists_controller.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Projects::FeatureFlagsUserListsController < Projects::ApplicationController
+ before_action :authorize_admin_feature_flags_user_lists!
+ before_action :user_list, only: [:edit, :show]
+
+ def new
+ end
+
+ def edit
+ end
+
+ def show
+ end
+
+ private
+
+ def user_list
+ @user_list = project.operations_feature_flags_user_lists.find_by_iid!(params[:iid])
+ end
+end
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index b0cfda67ad4..0cdf53d6174 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -9,6 +9,7 @@ module UserCalloutsHelper
TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
WEBHOOKS_MOVED = 'webhooks_moved'
CUSTOMIZE_HOMEPAGE = 'customize_homepage'
+ FEATURE_FLAGS_NEW_VERSION = 'feature_flags_new_version'
def show_admin_integrations_moved?
!user_dismissed?(ADMIN_INTEGRATIONS_MOVED)
@@ -50,6 +51,10 @@ module UserCalloutsHelper
customize_homepage && !user_dismissed?(CUSTOMIZE_HOMEPAGE)
end
+ def show_feature_flags_new_version?
+ !user_dismissed?(FEATURE_FLAGS_NEW_VERSION)
+ end
+
private
def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb
index f525180048e..c899ac514d3 100644
--- a/app/models/blob_viewer/markup.rb
+++ b/app/models/blob_viewer/markup.rb
@@ -9,5 +9,15 @@ module BlobViewer
self.extensions = Gitlab::MarkupHelper::EXTENSIONS
self.file_types = %i(readme)
self.binary = false
+
+ def banzai_render_context
+ {}.tap do |h|
+ h[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
+
+ if Feature.enabled?(:cached_markdown_blob, blob.project)
+ h[:cache_key] = ['blob', blob.id, 'commit', blob.commit_id]
+ end
+ end
+ end
end
end
diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb
index bdb2e34854e..e8223d6498b 100644
--- a/app/presenters/packages/detail/package_presenter.rb
+++ b/app/presenters/packages/detail/package_presenter.rb
@@ -8,10 +8,13 @@ module Packages
end
def detail_view
+ name = @package.name
+ name = @package.conan_recipe if @package.conan?
+
package_detail = {
id: @package.id,
created_at: @package.created_at,
- name: @package.name,
+ name: name,
package_files: @package.package_files.map { |pf| build_package_file_view(pf) },
package_type: @package.package_type,
project_id: @package.project_id,
@@ -20,6 +23,7 @@ module Packages
version: @package.version
}
+ package_detail[:conan_package_name] = @package.name if @package.conan?
package_detail[:maven_metadatum] = @package.maven_metadatum if @package.maven_metadatum
package_detail[:nuget_metadatum] = @package.nuget_metadatum if @package.nuget_metadatum
package_detail[:composer_metadatum] = @package.composer_metadatum if @package.composer_metadatum
diff --git a/app/services/ci/create_downstream_pipeline_service.rb b/app/services/ci/create_downstream_pipeline_service.rb
index 0394cfb6119..809c478b8c7 100644
--- a/app/services/ci/create_downstream_pipeline_service.rb
+++ b/app/services/ci/create_downstream_pipeline_service.rb
@@ -33,7 +33,7 @@ module Ci
pipeline_params.fetch(:target_revision))
downstream_pipeline = service.execute(
- pipeline_params.fetch(:source), pipeline_params[:execute_params]) do |pipeline|
+ pipeline_params.fetch(:source), **pipeline_params[:execute_params]) do |pipeline|
pipeline.variables.build(@bridge.downstream_variables)
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 3f1a2d1350d..e7ede98fea4 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -70,7 +70,7 @@ module Ci
push_options: params[:push_options] || {},
chat_data: params[:chat_data],
bridge: bridge,
- **extra_options(options))
+ **extra_options(**options))
# Ensure we never persist the pipeline when dry_run: true
@pipeline.readonly! if command.dry_run?
diff --git a/app/services/notification_recipients/build_service.rb b/app/services/notification_recipients/build_service.rb
index 0fe0d26d7b2..040ecc29d3a 100644
--- a/app/services/notification_recipients/build_service.rb
+++ b/app/services/notification_recipients/build_service.rb
@@ -13,8 +13,8 @@ module NotificationRecipients
NotificationRecipient.new(user, *args).notifiable?
end
- def self.build_recipients(*args)
- ::NotificationRecipients::Builder::Default.new(*args).notification_recipients
+ def self.build_recipients(target, current_user, **args)
+ ::NotificationRecipients::Builder::Default.new(target, current_user, **args).notification_recipients
end
def self.build_new_note_recipients(*args)
@@ -25,8 +25,8 @@ module NotificationRecipients
::NotificationRecipients::Builder::MergeRequestUnmergeable.new(*args).notification_recipients
end
- def self.build_project_maintainers_recipients(*args)
- ::NotificationRecipients::Builder::ProjectMaintainers.new(*args).notification_recipients
+ def self.build_project_maintainers_recipients(target, **args)
+ ::NotificationRecipients::Builder::ProjectMaintainers.new(target, **args).notification_recipients
end
def self.build_new_release_recipients(*args)
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
index 8134adcbc32..703ffa8896e 100644
--- a/app/views/projects/blob/viewers/_markup.html.haml
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -1,4 +1,3 @@
- blob = viewer.blob
-- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {}
.file-content.md
- = markup(blob.name, blob.data, context)
+ = markup(blob.name, blob.data, viewer.banzai_render_context)
diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml
index f8c27f4c026..0dbd6e53212 100644
--- a/app/views/projects/commit/pipelines.html.haml
+++ b/app/views/projects/commit/pipelines.html.haml
@@ -1,4 +1,5 @@
- page_title _('Pipelines'), "#{@commit.title} (#{@commit.short_id})", _('Commits')
+- add_page_specific_style 'page_bundles/pipelines'
= render 'commit_box'
= render 'ci_menu'
diff --git a/app/views/projects/feature_flags/edit.html.haml b/app/views/projects/feature_flags/edit.html.haml
index 67b1a8398d3..028595aba0b 100644
--- a/app/views/projects/feature_flags/edit.html.haml
+++ b/app/views/projects/feature_flags/edit.html.haml
@@ -9,7 +9,7 @@
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
user_callouts_path: user_callouts_path,
- user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERISION,
+ user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION,
show_user_callout: show_feature_flags_new_version?.to_s,
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
environments_scope_docs_path: help_page_path('ci/environments', anchor: 'scoping-environments-with-specs'),
diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml
index a7388361da5..3bad1d9773c 100644
--- a/app/views/projects/feature_flags/new.html.haml
+++ b/app/views/projects/feature_flags/new.html.haml
@@ -7,7 +7,7 @@
feature_flags_path: project_feature_flags_path(@project),
environments_endpoint: search_project_environments_path(@project, format: :json),
user_callouts_path: user_callouts_path,
- user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERISION,
+ user_callout_id: UserCalloutsHelper::FEATURE_FLAGS_NEW_VERSION,
show_user_callout: show_feature_flags_new_version?.to_s,
strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'),
environments_scope_docs_path: help_page_path('ci/environments', anchor: 'scoping-environments-with-specs'),
diff --git a/app/views/projects/merge_requests/creations/new.html.haml b/app/views/projects/merge_requests/creations/new.html.haml
index ad4980fa57f..4c968c8e8eb 100644
--- a/app/views/projects/merge_requests/creations/new.html.haml
+++ b/app/views/projects/merge_requests/creations/new.html.haml
@@ -1,6 +1,7 @@
- add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project)
- breadcrumb_title _("New")
- page_title _("New Merge Request")
+- add_page_specific_style 'page_bundles/pipelines'
- if @merge_request.can_be_created && !params[:change_branches]
= render 'new_submit'
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 84b108d69ad..06513d56221 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -8,6 +8,7 @@
- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes')
- number_of_pipelines = @pipelines.size
- mr_action = j(params[:tab].presence || 'show')
+- add_page_specific_style 'page_bundles/pipelines'
.merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } }
= render "projects/merge_requests/mr_title"
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index 05f8a126a02..ca07f33136b 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -1,4 +1,5 @@
- page_title _('Pipelines')
+- add_page_specific_style 'page_bundles/pipelines'
= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
diff --git a/changelogs/unreleased/229727-drop-type-on-audit-events.yml b/changelogs/unreleased/229727-drop-type-on-audit-events.yml
new file mode 100644
index 00000000000..0abfc00444b
--- /dev/null
+++ b/changelogs/unreleased/229727-drop-type-on-audit-events.yml
@@ -0,0 +1,5 @@
+---
+title: Remove type column on audit_events table
+merge_request: 43703
+author:
+type: other
diff --git a/changelogs/unreleased/233627-fj-restore-snippets-in-backups.yml b/changelogs/unreleased/233627-fj-restore-snippets-in-backups.yml
new file mode 100644
index 00000000000..0737cec3d89
--- /dev/null
+++ b/changelogs/unreleased/233627-fj-restore-snippets-in-backups.yml
@@ -0,0 +1,5 @@
+---
+title: Restore snippet repositories from backups
+merge_request: 43696
+author:
+type: changed
diff --git a/changelogs/unreleased/239130-package-presenter-conan.yml b/changelogs/unreleased/239130-package-presenter-conan.yml
new file mode 100644
index 00000000000..8f8ad152966
--- /dev/null
+++ b/changelogs/unreleased/239130-package-presenter-conan.yml
@@ -0,0 +1,5 @@
+---
+title: Display conan recipe as package name on package detail page
+merge_request: 44294
+author:
+type: changed
diff --git a/changelogs/unreleased/241058-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml b/changelogs/unreleased/241058-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml
deleted file mode 100644
index cb99207fffe..00000000000
--- a/changelogs/unreleased/241058-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml
+++ /dev/null
@@ -1,6 +0,0 @@
----
-title: Replacing deprecated Bootstrap button with GlButton and updating btn-text-field
- class to align with styles
-merge_request: 41430
-author:
-type: other
diff --git a/changelogs/unreleased/262051-design-thumbnail-image-is-not-updated-after-uploading-an-image-wit.yml b/changelogs/unreleased/262051-design-thumbnail-image-is-not-updated-after-uploading-an-image-wit.yml
new file mode 100644
index 00000000000..b6e597bfc97
--- /dev/null
+++ b/changelogs/unreleased/262051-design-thumbnail-image-is-not-updated-after-uploading-an-image-wit.yml
@@ -0,0 +1,5 @@
+---
+title: Update Design thumbnail after uploading an image with the same filename
+merge_request: 44305
+author:
+type: fixed
diff --git a/changelogs/unreleased/42782-move-beforescript-into-script.yml b/changelogs/unreleased/42782-move-beforescript-into-script.yml
new file mode 100644
index 00000000000..5d649838a8c
--- /dev/null
+++ b/changelogs/unreleased/42782-move-beforescript-into-script.yml
@@ -0,0 +1,5 @@
+---
+title: Move before_script into script for CQ template
+merge_request: 42782
+author: Vicken Simonian @vicken.papaya
+type: fixed
diff --git a/changelogs/unreleased/adding-hcl.yml b/changelogs/unreleased/adding-hcl.yml
new file mode 100644
index 00000000000..4b0d750474f
--- /dev/null
+++ b/changelogs/unreleased/adding-hcl.yml
@@ -0,0 +1,5 @@
+---
+title: IDE editor - Adding syntax highlighting for terraform / hcl
+merge_request: 44056
+author:
+type: added
diff --git a/changelogs/unreleased/id-required-sections.yml b/changelogs/unreleased/id-required-sections.yml
new file mode 100644
index 00000000000..b24baa719c0
--- /dev/null
+++ b/changelogs/unreleased/id-required-sections.yml
@@ -0,0 +1,5 @@
+---
+title: Introduce required_code_owners_sections table
+merge_request: 43573
+author:
+type: added
diff --git a/config/application.rb b/config/application.rb
index 411c136e7d4..798deada81b 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -182,6 +182,7 @@ module Gitlab
config.assets.precompile << "page_bundles/merge_conflicts.css"
config.assets.precompile << "page_bundles/milestone.css"
config.assets.precompile << "page_bundles/pipeline.css"
+ config.assets.precompile << "page_bundles/pipelines.css"
config.assets.precompile << "page_bundles/todos.css"
config.assets.precompile << "page_bundles/xterm.css"
config.assets.precompile << "lazy_bundles/cropper.css"
diff --git a/config/feature_flags/development/cached_markdown_blob.yml b/config/feature_flags/development/cached_markdown_blob.yml
new file mode 100644
index 00000000000..f125f598698
--- /dev/null
+++ b/config/feature_flags/development/cached_markdown_blob.yml
@@ -0,0 +1,7 @@
+---
+name: cached_markdown_blob
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44300
+rollout_issue_url:
+type: development
+group: group::source code
+default_enabled: false
diff --git a/config/feature_flags/development/ci_dynamic_child_pipeline.yml b/config/feature_flags/development/ci_dynamic_child_pipeline.yml
index ac2afe77743..c568e9392b2 100644
--- a/config/feature_flags/development/ci_dynamic_child_pipeline.yml
+++ b/config/feature_flags/development/ci_dynamic_child_pipeline.yml
@@ -1,7 +1,7 @@
---
name: ci_dynamic_child_pipeline
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23790
+rollout_issue_url:
+group: group::continuous integration
type: development
default_enabled: true
diff --git a/config/feature_flags/development/ci_lint_creates_pipeline_with_dry_run.yml b/config/feature_flags/development/ci_lint_creates_pipeline_with_dry_run.yml
index 8abb52486b6..5f23d038998 100644
--- a/config/feature_flags/development/ci_lint_creates_pipeline_with_dry_run.yml
+++ b/config/feature_flags/development/ci_lint_creates_pipeline_with_dry_run.yml
@@ -1,7 +1,7 @@
---
name: ci_lint_creates_pipeline_with_dry_run
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37828
+rollout_issue_url:
+group: group::continuous integration
type: development
default_enabled: true
diff --git a/config/feature_flags/development/ci_raise_job_rules_without_workflow_rules_warning.yml b/config/feature_flags/development/ci_raise_job_rules_without_workflow_rules_warning.yml
index d2e25e7bf11..c2cd1d62734 100644
--- a/config/feature_flags/development/ci_raise_job_rules_without_workflow_rules_warning.yml
+++ b/config/feature_flags/development/ci_raise_job_rules_without_workflow_rules_warning.yml
@@ -1,7 +1,7 @@
---
name: ci_raise_job_rules_without_workflow_rules_warning
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38387
+rollout_issue_url:
+group: group::continuous integration
type: development
default_enabled: true
diff --git a/config/feature_flags/development/ci_store_pipeline_messages.yml b/config/feature_flags/development/ci_store_pipeline_messages.yml
index c7235ab2196..35cbfad0efa 100644
--- a/config/feature_flags/development/ci_store_pipeline_messages.yml
+++ b/config/feature_flags/development/ci_store_pipeline_messages.yml
@@ -1,7 +1,7 @@
---
name: ci_store_pipeline_messages
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::continuous integration
type: development
default_enabled: true
diff --git a/config/feature_flags/development/ci_yaml_limit_size.yml b/config/feature_flags/development/ci_yaml_limit_size.yml
index 06229c08af5..0ebd29d0ba5 100644
--- a/config/feature_flags/development/ci_yaml_limit_size.yml
+++ b/config/feature_flags/development/ci_yaml_limit_size.yml
@@ -1,7 +1,7 @@
---
name: ci_yaml_limit_size
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url:
+rollout_issue_url:
+group: group::continuous integration
type: development
default_enabled: true
diff --git a/config/feature_flags/development/efficient_counter_attribute.yml b/config/feature_flags/development/efficient_counter_attribute.yml
index a1b16be7ce8..1b12c166c53 100644
--- a/config/feature_flags/development/efficient_counter_attribute.yml
+++ b/config/feature_flags/development/efficient_counter_attribute.yml
@@ -1,7 +1,7 @@
---
name: efficient_counter_attribute
-introduced_by_url:
-rollout_issue_url:
-group:
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/35878
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238535
+group: group::continuous integration
type: development
default_enabled: false
diff --git a/config/feature_flags/development/feature_flag_api.yml b/config/feature_flags/development/feature_flag_api.yml
new file mode 100644
index 00000000000..326cfa83433
--- /dev/null
+++ b/config/feature_flags/development/feature_flag_api.yml
@@ -0,0 +1,7 @@
+---
+name: feature_flag_api
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18198
+rollout_issue_url:
+group: group::progressive delivery
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/feature_flag_permissions.yml b/config/feature_flags/development/feature_flag_permissions.yml
new file mode 100644
index 00000000000..2eb5b513743
--- /dev/null
+++ b/config/feature_flags/development/feature_flag_permissions.yml
@@ -0,0 +1,7 @@
+---
+name: feature_flag_permissions
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/10096
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/254981
+group: group::progressive delivery
+type: development
+default_enabled: false
diff --git a/config/feature_flags/development/feature_flags_legacy_read_only.yml b/config/feature_flags/development/feature_flags_legacy_read_only.yml
new file mode 100644
index 00000000000..b790e466093
--- /dev/null
+++ b/config/feature_flags/development/feature_flags_legacy_read_only.yml
@@ -0,0 +1,7 @@
+---
+name: feature_flags_legacy_read_only
+introduced_by_url:
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/240985
+group: group::progressive delivery
+type: development
+default_enabled: true
diff --git a/config/feature_flags/development/feature_flags_legacy_read_only_override.yml b/config/feature_flags/development/feature_flags_legacy_read_only_override.yml
new file mode 100644
index 00000000000..14acde1b8fc
--- /dev/null
+++ b/config/feature_flags/development/feature_flags_legacy_read_only_override.yml
@@ -0,0 +1,7 @@
+---
+name: feature_flags_legacy_read_only_override
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40431
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/240985
+group: group::progressive delivery
+type: development
+default_enabled: false
diff --git a/config/routes/project.rb b/config/routes/project.rb
index c8fd5dc7e9e..2c681b3cbe7 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -373,9 +373,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
- resources :feature_flags, param: :iid do
- resources :feature_flag_issues, only: [:index, :create, :destroy], as: 'issues', path: 'issues'
- end
+ resources :feature_flags, param: :iid
resource :feature_flags_client, only: [] do
post :reset_token
end
diff --git a/db/migrate/20200928131934_create_required_code_owners_sections.rb b/db/migrate/20200928131934_create_required_code_owners_sections.rb
new file mode 100644
index 00000000000..f2dfd4007e5
--- /dev/null
+++ b/db/migrate/20200928131934_create_required_code_owners_sections.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class CreateRequiredCodeOwnersSections < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ disable_ddl_transaction!
+
+ def up
+ with_lock_retries do
+ create_table :required_code_owners_sections, if_not_exists: true do |t|
+ t.references :protected_branch, null: false, foreign_key: { on_delete: :cascade }
+ t.text :name, null: false
+ end
+ end
+
+ add_text_limit :required_code_owners_sections, :name, 1024
+ end
+
+ def down
+ with_lock_retries do
+ drop_table :required_code_owners_sections, if_exists: true
+ end
+ end
+end
diff --git a/db/migrate/20200928164807_add_index_on_vulnerabilities_state_case.rb b/db/migrate/20200928164807_add_index_on_vulnerabilities_state_case.rb
new file mode 100644
index 00000000000..7bfae7377d7
--- /dev/null
+++ b/db/migrate/20200928164807_add_index_on_vulnerabilities_state_case.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class AddIndexOnVulnerabilitiesStateCase < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_vulnerabilities_on_state_case_id'
+ STATE_ORDER_ARRAY_POSITION = 'ARRAY_POSITION(ARRAY[1, 4, 3, 2]::smallint[], state)'
+
+ disable_ddl_transaction!
+
+ def up
+ add_concurrent_index :vulnerabilities, "#{STATE_ORDER_ARRAY_POSITION}, id DESC", name: INDEX_NAME
+ add_concurrent_index :vulnerabilities, "#{STATE_ORDER_ARRAY_POSITION} DESC, id DESC", name: "#{INDEX_NAME}_desc"
+ end
+
+ def down
+ remove_concurrent_index_by_name :vulnerabilities, "#{INDEX_NAME}_desc"
+ remove_concurrent_index_by_name :vulnerabilities, INDEX_NAME
+ end
+end
diff --git a/db/post_migrate/20200929113254_remove_type_from_audit_events.rb b/db/post_migrate/20200929113254_remove_type_from_audit_events.rb
new file mode 100644
index 00000000000..000dc0d2865
--- /dev/null
+++ b/db/post_migrate/20200929113254_remove_type_from_audit_events.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+class RemoveTypeFromAuditEvents < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::SchemaHelpers
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ SOURCE_TABLE_NAME = 'audit_events'
+ PARTITIONED_TABLE_NAME = 'audit_events_part_5fc467ac26'
+ TRIGGER_FUNCTION_NAME = 'table_sync_function_2be879775d'
+
+ def up
+ with_lock_retries do
+ remove_column SOURCE_TABLE_NAME, :type
+
+ create_trigger_function(TRIGGER_FUNCTION_NAME, replace: true) do
+ <<~SQL
+ IF (TG_OP = 'DELETE') THEN
+ DELETE FROM #{PARTITIONED_TABLE_NAME} where id = OLD.id;
+ ELSIF (TG_OP = 'UPDATE') THEN
+ UPDATE #{PARTITIONED_TABLE_NAME}
+ SET author_id = NEW.author_id,
+ entity_id = NEW.entity_id,
+ entity_type = NEW.entity_type,
+ details = NEW.details,
+ ip_address = NEW.ip_address,
+ author_name = NEW.author_name,
+ entity_path = NEW.entity_path,
+ target_details = NEW.target_details,
+ target_type = NEW.target_type,
+ target_id = NEW.target_id,
+ created_at = NEW.created_at
+ WHERE #{PARTITIONED_TABLE_NAME}.id = NEW.id;
+ ELSIF (TG_OP = 'INSERT') THEN
+ INSERT INTO #{PARTITIONED_TABLE_NAME} (id,
+ author_id,
+ entity_id,
+ entity_type,
+ details,
+ ip_address,
+ author_name,
+ entity_path,
+ target_details,
+ target_type,
+ target_id,
+ created_at)
+ VALUES (NEW.id,
+ NEW.author_id,
+ NEW.entity_id,
+ NEW.entity_type,
+ NEW.details,
+ NEW.ip_address,
+ NEW.author_name,
+ NEW.entity_path,
+ NEW.target_details,
+ NEW.target_type,
+ NEW.target_id,
+ NEW.created_at);
+ END IF;
+ RETURN NULL;
+ SQL
+ end
+
+ remove_column PARTITIONED_TABLE_NAME, :type
+ end
+ end
+
+ def down
+ with_lock_retries do
+ add_column SOURCE_TABLE_NAME, :type, :string
+ add_column PARTITIONED_TABLE_NAME, :type, :string
+
+ create_trigger_function(TRIGGER_FUNCTION_NAME, replace: true) do
+ <<~SQL
+ IF (TG_OP = 'DELETE') THEN
+ DELETE FROM #{PARTITIONED_TABLE_NAME} where id = OLD.id;
+ ELSIF (TG_OP = 'UPDATE') THEN
+ UPDATE #{PARTITIONED_TABLE_NAME}
+ SET author_id = NEW.author_id,
+ type = NEW.type,
+ entity_id = NEW.entity_id,
+ entity_type = NEW.entity_type,
+ details = NEW.details,
+ ip_address = NEW.ip_address,
+ author_name = NEW.author_name,
+ entity_path = NEW.entity_path,
+ target_details = NEW.target_details,
+ target_type = NEW.target_type,
+ target_id = NEW.target_id,
+ created_at = NEW.created_at
+ WHERE #{PARTITIONED_TABLE_NAME}.id = NEW.id;
+ ELSIF (TG_OP = 'INSERT') THEN
+ INSERT INTO #{PARTITIONED_TABLE_NAME} (id,
+ author_id,
+ type,
+ entity_id,
+ entity_type,
+ details,
+ ip_address,
+ author_name,
+ entity_path,
+ target_details,
+ target_type,
+ target_id,
+ created_at)
+ VALUES (NEW.id,
+ NEW.author_id,
+ NEW.type,
+ NEW.entity_id,
+ NEW.entity_type,
+ NEW.details,
+ NEW.ip_address,
+ NEW.author_name,
+ NEW.entity_path,
+ NEW.target_details,
+ NEW.target_type,
+ NEW.target_id,
+ NEW.created_at);
+ END IF;
+ RETURN NULL;
+ SQL
+ end
+ end
+ end
+end
diff --git a/db/schema_migrations/20200928131934 b/db/schema_migrations/20200928131934
new file mode 100644
index 00000000000..952e2121d35
--- /dev/null
+++ b/db/schema_migrations/20200928131934
@@ -0,0 +1 @@
+106757b0f30d3c89fcafa13be92271090fa107831fd538ee087d7ce212842492 \ No newline at end of file
diff --git a/db/schema_migrations/20200928164807 b/db/schema_migrations/20200928164807
new file mode 100644
index 00000000000..3efd3c56402
--- /dev/null
+++ b/db/schema_migrations/20200928164807
@@ -0,0 +1 @@
+346d0e913212d6e84528d47228ba7e6d0cf4a396e7fc921f7c684acfaaeeedb8 \ No newline at end of file
diff --git a/db/schema_migrations/20200929113254 b/db/schema_migrations/20200929113254
new file mode 100644
index 00000000000..172a6eabd66
--- /dev/null
+++ b/db/schema_migrations/20200929113254
@@ -0,0 +1 @@
+260f392c3ff257960dc7b198473056e7bf9b9a668403d2f05391d2b7989cf83c \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 038fcd4120e..f027deb56bc 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -19,7 +19,6 @@ IF (TG_OP = 'DELETE') THEN
ELSIF (TG_OP = 'UPDATE') THEN
UPDATE audit_events_part_5fc467ac26
SET author_id = NEW.author_id,
- type = NEW.type,
entity_id = NEW.entity_id,
entity_type = NEW.entity_type,
details = NEW.details,
@@ -34,7 +33,6 @@ ELSIF (TG_OP = 'UPDATE') THEN
ELSIF (TG_OP = 'INSERT') THEN
INSERT INTO audit_events_part_5fc467ac26 (id,
author_id,
- type,
entity_id,
entity_type,
details,
@@ -47,7 +45,6 @@ ELSIF (TG_OP = 'INSERT') THEN
created_at)
VALUES (NEW.id,
NEW.author_id,
- NEW.type,
NEW.entity_id,
NEW.entity_type,
NEW.details,
@@ -69,7 +66,6 @@ COMMENT ON FUNCTION table_sync_function_2be879775d() IS 'Partitioning migration:
CREATE TABLE audit_events_part_5fc467ac26 (
id bigint NOT NULL,
author_id integer NOT NULL,
- type character varying,
entity_id integer NOT NULL,
entity_type character varying NOT NULL,
details text,
@@ -9541,7 +9537,6 @@ ALTER SEQUENCE atlassian_identities_user_id_seq OWNED BY atlassian_identities.us
CREATE TABLE audit_events (
id integer NOT NULL,
author_id integer NOT NULL,
- type character varying,
entity_id integer NOT NULL,
entity_type character varying NOT NULL,
details text,
@@ -15451,6 +15446,22 @@ CREATE TABLE repository_languages (
share double precision NOT NULL
);
+CREATE TABLE required_code_owners_sections (
+ id bigint NOT NULL,
+ protected_branch_id bigint NOT NULL,
+ name text NOT NULL,
+ CONSTRAINT check_e58d53741e CHECK ((char_length(name) <= 1024))
+);
+
+CREATE SEQUENCE required_code_owners_sections_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+ALTER SEQUENCE required_code_owners_sections_id_seq OWNED BY required_code_owners_sections.id;
+
CREATE TABLE requirements (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
@@ -17749,6 +17760,8 @@ ALTER TABLE ONLY releases ALTER COLUMN id SET DEFAULT nextval('releases_id_seq':
ALTER TABLE ONLY remote_mirrors ALTER COLUMN id SET DEFAULT nextval('remote_mirrors_id_seq'::regclass);
+ALTER TABLE ONLY required_code_owners_sections ALTER COLUMN id SET DEFAULT nextval('required_code_owners_sections_id_seq'::regclass);
+
ALTER TABLE ONLY requirements ALTER COLUMN id SET DEFAULT nextval('requirements_id_seq'::regclass);
ALTER TABLE ONLY requirements_management_test_reports ALTER COLUMN id SET DEFAULT nextval('requirements_management_test_reports_id_seq'::regclass);
@@ -19030,6 +19043,9 @@ ALTER TABLE ONLY releases
ALTER TABLE ONLY remote_mirrors
ADD CONSTRAINT remote_mirrors_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY required_code_owners_sections
+ ADD CONSTRAINT required_code_owners_sections_pkey PRIMARY KEY (id);
+
ALTER TABLE ONLY requirements_management_test_reports
ADD CONSTRAINT requirements_management_test_reports_pkey PRIMARY KEY (id);
@@ -21207,6 +21223,8 @@ CREATE INDEX index_remote_mirrors_on_project_id ON remote_mirrors USING btree (p
CREATE UNIQUE INDEX index_repository_languages_on_project_and_languages_id ON repository_languages USING btree (project_id, programming_language_id);
+CREATE INDEX index_required_code_owners_sections_on_protected_branch_id ON required_code_owners_sections USING btree (protected_branch_id);
+
CREATE INDEX index_requirements_management_test_reports_on_author_id ON requirements_management_test_reports USING btree (author_id);
CREATE INDEX index_requirements_management_test_reports_on_build_id ON requirements_management_test_reports USING btree (build_id);
@@ -21603,6 +21621,10 @@ CREATE INDEX index_vulnerabilities_on_resolved_by_id ON vulnerabilities USING bt
CREATE INDEX index_vulnerabilities_on_start_date_sourcing_milestone_id ON vulnerabilities USING btree (start_date_sourcing_milestone_id);
+CREATE INDEX index_vulnerabilities_on_state_case_id ON vulnerabilities USING btree (array_position(ARRAY[(1)::smallint, (4)::smallint, (3)::smallint, (2)::smallint], state), id DESC);
+
+CREATE INDEX index_vulnerabilities_on_state_case_id_desc ON vulnerabilities USING btree (array_position(ARRAY[(1)::smallint, (4)::smallint, (3)::smallint, (2)::smallint], state) DESC, id DESC);
+
CREATE INDEX index_vulnerabilities_on_updated_by_id ON vulnerabilities USING btree (updated_by_id);
CREATE INDEX index_vulnerability_exports_on_author_id ON vulnerability_exports USING btree (author_id);
@@ -23310,6 +23332,9 @@ ALTER TABLE ONLY clusters_kubernetes_namespaces
ALTER TABLE ONLY approval_merge_request_rules_users
ADD CONSTRAINT fk_rails_80e6801803 FOREIGN KEY (approval_merge_request_rule_id) REFERENCES approval_merge_request_rules(id) ON DELETE CASCADE;
+ALTER TABLE ONLY required_code_owners_sections
+ ADD CONSTRAINT fk_rails_817708cf2d FOREIGN KEY (protected_branch_id) REFERENCES protected_branches(id) ON DELETE CASCADE;
+
ALTER TABLE ONLY dast_site_profiles
ADD CONSTRAINT fk_rails_83e309d69e FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
diff --git a/doc/administration/geo/disaster_recovery/index.md b/doc/administration/geo/disaster_recovery/index.md
index dc46c0756db..c2a59ade7e4 100644
--- a/doc/administration/geo/disaster_recovery/index.md
+++ b/doc/administration/geo/disaster_recovery/index.md
@@ -142,7 +142,7 @@ In GitLab 13.2 and later versions, promoting a secondary node to a primary while
If you have already run the [preflight checks](planned_failover.md#preflight-checks) separately or don't want to run them, you can skip preflight checks with:
```shell
- gitlab-ctl promote-to-primary-node --skip-preflight-check
+ gitlab-ctl promote-to-primary-node --skip-preflight-checks
```
You can also promote the secondary node to primary **without any further confirmation**, even when preflight checks fail:
diff --git a/doc/administration/raketasks/check.md b/doc/administration/raketasks/check.md
index d13e6328b2f..f4c8bc9e989 100644
--- a/doc/administration/raketasks/check.md
+++ b/doc/administration/raketasks/check.md
@@ -171,9 +171,7 @@ Checking integrity of Uploads
Done!
```
-To delete these references to remote uploads that were deleted externally, open the [GitLab Rails Console](../troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session)
-and run:
-[Rails Console](../troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session):
+To delete these references to remote uploads that were deleted externally, open the [GitLab Rails Console](../troubleshooting/navigating_gitlab_via_rails_console.md#starting-a-rails-console-session) and run:
```ruby
uploads_deleted=0
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index 4866cd9f4a0..4fcd608af5c 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -15889,6 +15889,11 @@ type Requirement {
iid: ID!
"""
+ Indicates if latest test report was created by user
+ """
+ lastTestReportManuallyCreated: Boolean
+
+ """
Latest requirement test report state
"""
lastTestReportState: TestReportState
@@ -20153,7 +20158,7 @@ type Vulnerability implements Noteable {
severity: VulnerabilitySeverity
"""
- State of the vulnerability (DETECTED, DISMISSED, RESOLVED, CONFIRMED)
+ State of the vulnerability (DETECTED, CONFIRMED, RESOLVED, DISMISSED)
"""
state: VulnerabilityState
@@ -20816,6 +20821,16 @@ enum VulnerabilitySort {
severity_desc
"""
+ State in ascending order
+ """
+ state_asc
+
+ """
+ State in descending order
+ """
+ state_desc
+
+ """
Title in ascending order
"""
title_asc
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index ec247a09c97..7c96df4ad19 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -45945,6 +45945,20 @@
"deprecationReason": null
},
{
+ "name": "lastTestReportManuallyCreated",
+ "description": "Indicates if latest test report was created by user",
+ "args": [
+
+ ],
+ "type": {
+ "kind": "SCALAR",
+ "name": "Boolean",
+ "ofType": null
+ },
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
"name": "lastTestReportState",
"description": "Latest requirement test report state",
"args": [
@@ -58489,7 +58503,7 @@
},
{
"name": "state",
- "description": "State of the vulnerability (DETECTED, DISMISSED, RESOLVED, CONFIRMED)",
+ "description": "State of the vulnerability (DETECTED, CONFIRMED, RESOLVED, DISMISSED)",
"args": [
],
@@ -60468,6 +60482,18 @@
"description": "Report Type in ascending order",
"isDeprecated": false,
"deprecationReason": null
+ },
+ {
+ "name": "state_desc",
+ "description": "State in descending order",
+ "isDeprecated": false,
+ "deprecationReason": null
+ },
+ {
+ "name": "state_asc",
+ "description": "State in ascending order",
+ "isDeprecated": false,
+ "deprecationReason": null
}
],
"possibleTypes": null
@@ -60487,7 +60513,7 @@
"deprecationReason": null
},
{
- "name": "DISMISSED",
+ "name": "CONFIRMED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
@@ -60499,7 +60525,7 @@
"deprecationReason": null
},
{
- "name": "CONFIRMED",
+ "name": "DISMISSED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 77387a180b2..0f7769c44ec 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -2135,6 +2135,7 @@ Represents a requirement.
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
| `id` | ID! | ID of the requirement |
| `iid` | ID! | Internal ID of the requirement |
+| `lastTestReportManuallyCreated` | Boolean | Indicates if latest test report was created by user |
| `lastTestReportState` | TestReportState | Latest requirement test report state |
| `project` | Project! | Project to which the requirement belongs |
| `state` | RequirementState! | State of the requirement |
@@ -2820,7 +2821,7 @@ Represents a vulnerability.
| `resolvedOnDefaultBranch` | Boolean! | Indicates whether the vulnerability is fixed on the default branch or not |
| `scanner` | VulnerabilityScanner | Scanner metadata for the vulnerability. |
| `severity` | VulnerabilitySeverity | Severity of the vulnerability (INFO, UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL) |
-| `state` | VulnerabilityState | State of the vulnerability (DETECTED, DISMISSED, RESOLVED, CONFIRMED) |
+| `state` | VulnerabilityState | State of the vulnerability (DETECTED, CONFIRMED, RESOLVED, DISMISSED) |
| `title` | String | Title of the vulnerability |
| `userNotesCount` | Int! | Number of user notes attached to the vulnerability |
| `userPermissions` | VulnerabilityPermissions! | Permissions for the current user on the resource |
@@ -3766,6 +3767,8 @@ Vulnerability sort values.
| `report_type_desc` | Report Type in descending order |
| `severity_asc` | Severity in ascending order |
| `severity_desc` | Severity in descending order |
+| `state_asc` | State in ascending order |
+| `state_desc` | State in descending order |
| `title_asc` | Title in ascending order |
| `title_desc` | Title in descending order |
diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md
index 268770193f9..ae01571ae4b 100644
--- a/doc/development/migration_style_guide.md
+++ b/doc/development/migration_style_guide.md
@@ -364,17 +364,7 @@ standard Rails migration helper methods. Calling more than one migration
helper is not a problem if they're executed on the same table.
Using the `with_lock_retries` helper method is advised when a database
-migration involves one of the high-traffic tables:
-
-- `users`
-- `projects`
-- `namespaces`
-- `gitlab_subscriptions`
-- `issues`
-- `merge_requests`
-- `ci_pipelines`
-- `ci_builds`
-- `notes`
+migration involves one of the [high-traffic tables](https://gitlab.com/gitlab-org/gitlab/-/blob/master/rubocop/rubocop-migrations.yml#L3).
Example changes:
diff --git a/doc/gitlab-basics/add-file.md b/doc/gitlab-basics/add-file.md
index c5b57d4623d..659cab299aa 100644
--- a/doc/gitlab-basics/add-file.md
+++ b/doc/gitlab-basics/add-file.md
@@ -29,16 +29,16 @@ to the desired destination:
cd <destination folder>
```
-[Create a branch](create-branch.md) to add your file to, before it is added to the master
-(main) branch of the project. It is not strictly necessary, but working directly in
-the `master` branch is not recommended unless your project is very small, and you are
+[Create a branch](create-branch.md) to add your file to, before it's added to the master
+(main) branch of the project. It's not strictly necessary, but working directly in
+the `master` branch is not recommended unless your project is very small, and you're
the only person working on it. You can [switch to an existing branch](start-using-git.md#work-on-an-existing-branch),
-if you have one already.
+if you've one already.
Using your standard tool for copying files (for example, Finder in macOS, or File Explorer
in Windows), put the file into a directory within the GitLab project.
-Check if your file is actually present in the directory (if you are in Windows,
+Check if your file is actually present in the directory (if you're in Windows,
use `dir` instead):
```shell
@@ -79,7 +79,7 @@ Now you can push (send) your changes (in the branch `<branch-name>`) to GitLab
git push origin <branch-name>
```
-Your image will be added to your branch in your repository in GitLab.
+Your image is added to your branch in your repository in GitLab.
<!-- ## Troubleshooting
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 546d726243e..417d4d66aca 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -153,6 +153,9 @@ module API
mount ::API::Environments
mount ::API::ErrorTracking
mount ::API::Events
+ mount ::API::FeatureFlags
+ mount ::API::FeatureFlagScopes
+ mount ::API::FeatureFlagsUserLists
mount ::API::Features
mount ::API::Files
mount ::API::FreezePeriods
diff --git a/lib/api/feature_flag_scopes.rb b/lib/api/feature_flag_scopes.rb
new file mode 100644
index 00000000000..4a42dbc1aea
--- /dev/null
+++ b/lib/api/feature_flag_scopes.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+module API
+ class FeatureFlagScopes < Grape::API::Instance
+ include PaginationParams
+
+ ENVIRONMENT_SCOPE_ENDPOINT_REQUIREMENTS = FeatureFlags::FEATURE_FLAG_ENDPOINT_REQUIREMENTS
+ .merge(environment_scope: API::NO_SLASH_URL_PART_REGEX)
+
+ before do
+ authorize_read_feature_flags!
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource 'projects/:id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ resource :feature_flag_scopes do
+ desc 'Get all effective feature flags under the environment' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag::DetailedLegacyScope
+ end
+ params do
+ requires :environment, type: String, desc: 'The environment name'
+ end
+ get do
+ present scopes_for_environment, with: ::API::Entities::FeatureFlag::DetailedLegacyScope
+ end
+ end
+
+ params do
+ requires :name, type: String, desc: 'The name of the feature flag'
+ end
+ resource 'feature_flags/:name', requirements: FeatureFlags::FEATURE_FLAG_ENDPOINT_REQUIREMENTS do
+ resource :scopes do
+ desc 'Get all scopes of a feature flag' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag::LegacyScope
+ end
+ params do
+ use :pagination
+ end
+ get do
+ present paginate(feature_flag.scopes), with: ::API::Entities::FeatureFlag::LegacyScope
+ end
+
+ desc 'Create a scope of a feature flag' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag::LegacyScope
+ end
+ params do
+ requires :environment_scope, type: String, desc: 'The environment scope of the scope'
+ requires :active, type: Boolean, desc: 'Whether the scope is active'
+ requires :strategies, type: JSON, desc: 'The strategies of the scope'
+ end
+ post do
+ authorize_update_feature_flag!
+
+ result = ::FeatureFlags::UpdateService
+ .new(user_project, current_user, scopes_attributes: [declared_params])
+ .execute(feature_flag)
+
+ if result[:status] == :success
+ present scope, with: ::API::Entities::FeatureFlag::LegacyScope
+ else
+ render_api_error!(result[:message], result[:http_status])
+ end
+ end
+
+ params do
+ requires :environment_scope, type: String, desc: 'URL-encoded environment scope'
+ end
+ resource ':environment_scope', requirements: ENVIRONMENT_SCOPE_ENDPOINT_REQUIREMENTS do
+ desc 'Get a scope of a feature flag' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag::LegacyScope
+ end
+ get do
+ present scope, with: ::API::Entities::FeatureFlag::LegacyScope
+ end
+
+ desc 'Update a scope of a feature flag' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag::LegacyScope
+ end
+ params do
+ optional :active, type: Boolean, desc: 'Whether the scope is active'
+ optional :strategies, type: JSON, desc: 'The strategies of the scope'
+ end
+ put do
+ authorize_update_feature_flag!
+
+ scope_attributes = declared_params.merge(id: scope.id)
+
+ result = ::FeatureFlags::UpdateService
+ .new(user_project, current_user, scopes_attributes: [scope_attributes])
+ .execute(feature_flag)
+
+ if result[:status] == :success
+ updated_scope = result[:feature_flag].scopes
+ .find { |scope| scope.environment_scope == params[:environment_scope] }
+
+ present updated_scope, with: ::API::Entities::FeatureFlag::LegacyScope
+ else
+ render_api_error!(result[:message], result[:http_status])
+ end
+ end
+
+ desc 'Delete a scope from a feature flag' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag::LegacyScope
+ end
+ delete do
+ authorize_update_feature_flag!
+
+ param = { scopes_attributes: [{ id: scope.id, _destroy: true }] }
+
+ result = ::FeatureFlags::UpdateService
+ .new(user_project, current_user, param)
+ .execute(feature_flag)
+
+ if result[:status] == :success
+ status :no_content
+ else
+ render_api_error!(result[:message], result[:http_status])
+ end
+ end
+ end
+ end
+ end
+ end
+
+ helpers do
+ def authorize_read_feature_flags!
+ authorize! :read_feature_flag, user_project
+ end
+
+ def authorize_update_feature_flag!
+ authorize! :update_feature_flag, feature_flag
+ end
+
+ def feature_flag
+ @feature_flag ||= user_project.operations_feature_flags
+ .find_by_name!(params[:name])
+ end
+
+ def scope
+ @scope ||= feature_flag.scopes
+ .find_by_environment_scope!(CGI.unescape(params[:environment_scope]))
+ end
+
+ def scopes_for_environment
+ Operations::FeatureFlagScope
+ .for_unleash_client(user_project, params[:environment])
+ end
+ end
+ end
+end
diff --git a/lib/api/feature_flags.rb b/lib/api/feature_flags.rb
new file mode 100644
index 00000000000..9e2be67f0de
--- /dev/null
+++ b/lib/api/feature_flags.rb
@@ -0,0 +1,266 @@
+# frozen_string_literal: true
+
+module API
+ class FeatureFlags < Grape::API::Instance
+ include PaginationParams
+
+ FEATURE_FLAG_ENDPOINT_REQUIREMENTS = API::NAMESPACE_OR_PROJECT_REQUIREMENTS
+ .merge(name: API::NO_SLASH_URL_PART_REGEX)
+
+ before do
+ authorize_read_feature_flags!
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource 'projects/:id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ resource :feature_flags do
+ desc 'Get all feature flags of a project' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag
+ end
+ params do
+ optional :scope, type: String, desc: 'The scope of feature flags',
+ values: %w[enabled disabled]
+ use :pagination
+ end
+ get do
+ feature_flags = ::FeatureFlagsFinder
+ .new(user_project, current_user, declared_params(include_missing: false))
+ .execute
+
+ present_entity(paginate(feature_flags))
+ end
+
+ desc 'Create a new feature flag' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag
+ end
+ params do
+ requires :name, type: String, desc: 'The name of feature flag'
+ optional :description, type: String, desc: 'The description of the feature flag'
+ optional :active, type: Boolean, desc: 'Active/inactive value of the flag'
+ optional :version, type: String, desc: 'The version of the feature flag'
+ optional :scopes, type: Array do
+ requires :environment_scope, type: String, desc: 'The environment scope of the scope'
+ requires :active, type: Boolean, desc: 'Active/inactive of the scope'
+ requires :strategies, type: JSON, desc: 'The strategies of the scope'
+ end
+ optional :strategies, type: Array do
+ requires :name, type: String, desc: 'The strategy name'
+ requires :parameters, type: JSON, desc: 'The strategy parameters'
+ optional :scopes, type: Array do
+ requires :environment_scope, type: String, desc: 'The environment scope of the scope'
+ end
+ end
+ end
+ post do
+ authorize_create_feature_flag!
+
+ attrs = declared_params(include_missing: false)
+
+ ensure_post_version_2_flags_enabled! if attrs[:version] == 'new_version_flag'
+
+ rename_key(attrs, :scopes, :scopes_attributes)
+ rename_key(attrs, :strategies, :strategies_attributes)
+ update_value(attrs, :strategies_attributes) do |strategies|
+ strategies.map { |s| rename_key(s, :scopes, :scopes_attributes) }
+ end
+
+ result = ::FeatureFlags::CreateService
+ .new(user_project, current_user, attrs)
+ .execute
+
+ if result[:status] == :success
+ present_entity(result[:feature_flag])
+ else
+ render_api_error!(result[:message], result[:http_status])
+ end
+ end
+ end
+
+ params do
+ requires :feature_flag_name, type: String, desc: 'The name of the feature flag'
+ end
+ resource 'feature_flags/:feature_flag_name', requirements: FEATURE_FLAG_ENDPOINT_REQUIREMENTS do
+ desc 'Get a feature flag of a project' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag
+ end
+ get do
+ authorize_read_feature_flag!
+
+ present_entity(feature_flag)
+ end
+
+ desc 'Enable a strategy for a feature flag on an environment' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag
+ end
+ params do
+ requires :environment_scope, type: String, desc: 'The environment scope of the feature flag'
+ requires :strategy, type: JSON, desc: 'The strategy to be enabled on the scope'
+ end
+ post :enable do
+ not_found! unless Feature.enabled?(:feature_flag_api, user_project)
+ render_api_error!('Version 2 flags not supported', :unprocessable_entity) if new_version_flag_present?
+
+ result = ::FeatureFlags::EnableService
+ .new(user_project, current_user, params).execute
+
+ if result[:status] == :success
+ status :ok
+ present_entity(result[:feature_flag])
+ else
+ render_api_error!(result[:message], result[:http_status])
+ end
+ end
+
+ desc 'Disable a strategy for a feature flag on an environment' do
+ detail 'This feature is going to be introduced in GitLab 12.5 if `feature_flag_api` feature flag is removed'
+ success ::API::Entities::FeatureFlag
+ end
+ params do
+ requires :environment_scope, type: String, desc: 'The environment scope of the feature flag'
+ requires :strategy, type: JSON, desc: 'The strategy to be disabled on the scope'
+ end
+ post :disable do
+ not_found! unless Feature.enabled?(:feature_flag_api, user_project)
+ render_api_error!('Version 2 flags not supported', :unprocessable_entity) if feature_flag.new_version_flag?
+
+ result = ::FeatureFlags::DisableService
+ .new(user_project, current_user, params).execute
+
+ if result[:status] == :success
+ status :ok
+ present_entity(result[:feature_flag])
+ else
+ render_api_error!(result[:message], result[:http_status])
+ end
+ end
+
+ desc 'Update a feature flag' do
+ detail 'This feature will be introduced in GitLab 13.1 if feature_flags_new_version feature flag is removed'
+ success ::API::Entities::FeatureFlag
+ end
+ params do
+ optional :name, type: String, desc: 'The name of the feature flag'
+ optional :description, type: String, desc: 'The description of the feature flag'
+ optional :active, type: Boolean, desc: 'Active/inactive value of the flag'
+ optional :strategies, type: Array do
+ optional :id, type: Integer, desc: 'The strategy id'
+ optional :name, type: String, desc: 'The strategy type'
+ optional :parameters, type: JSON, desc: 'The strategy parameters'
+ optional :_destroy, type: Boolean, desc: 'Delete the strategy when true'
+ optional :scopes, type: Array do
+ optional :id, type: Integer, desc: 'The environment scope id'
+ optional :environment_scope, type: String, desc: 'The environment scope of the scope'
+ optional :_destroy, type: Boolean, desc: 'Delete the scope when true'
+ end
+ end
+ end
+ put do
+ not_found! unless feature_flags_new_version_enabled?
+ authorize_update_feature_flag!
+ render_api_error!('PUT operations are not supported for legacy feature flags', :unprocessable_entity) if feature_flag.legacy_flag?
+
+ attrs = declared_params(include_missing: false)
+
+ rename_key(attrs, :strategies, :strategies_attributes)
+ update_value(attrs, :strategies_attributes) do |strategies|
+ strategies.map { |s| rename_key(s, :scopes, :scopes_attributes) }
+ end
+
+ result = ::FeatureFlags::UpdateService
+ .new(user_project, current_user, attrs)
+ .execute(feature_flag)
+
+ if result[:status] == :success
+ present_entity(result[:feature_flag])
+ else
+ render_api_error!(result[:message], result[:http_status])
+ end
+ end
+
+ desc 'Delete a feature flag' do
+ detail 'This feature was introduced in GitLab 12.5'
+ success ::API::Entities::FeatureFlag
+ end
+ delete do
+ authorize_destroy_feature_flag!
+
+ result = ::FeatureFlags::DestroyService
+ .new(user_project, current_user, declared_params(include_missing: false))
+ .execute(feature_flag)
+
+ if result[:status] == :success
+ present_entity(result[:feature_flag])
+ else
+ render_api_error!(result[:message], result[:http_status])
+ end
+ end
+ end
+ end
+
+ helpers do
+ def authorize_read_feature_flags!
+ authorize! :read_feature_flag, user_project
+ end
+
+ def authorize_read_feature_flag!
+ authorize! :read_feature_flag, feature_flag
+ end
+
+ def authorize_create_feature_flag!
+ authorize! :create_feature_flag, user_project
+ end
+
+ def authorize_update_feature_flag!
+ authorize! :update_feature_flag, feature_flag
+ end
+
+ def authorize_destroy_feature_flag!
+ authorize! :destroy_feature_flag, feature_flag
+ end
+
+ def present_entity(result)
+ present result,
+ with: ::API::Entities::FeatureFlag,
+ feature_flags_new_version_enabled: feature_flags_new_version_enabled?
+ end
+
+ def ensure_post_version_2_flags_enabled!
+ unless feature_flags_new_version_enabled?
+ render_api_error!('Version 2 flags are not enabled for this project', :unprocessable_entity)
+ end
+ end
+
+ def feature_flag
+ @feature_flag ||= if feature_flags_new_version_enabled?
+ user_project.operations_feature_flags.find_by_name!(params[:feature_flag_name])
+ else
+ user_project.operations_feature_flags.legacy_flag.find_by_name!(params[:feature_flag_name])
+ end
+ end
+
+ def new_version_flag_present?
+ user_project.operations_feature_flags.new_version_flag.find_by_name(params[:name]).present?
+ end
+
+ def feature_flags_new_version_enabled?
+ Feature.enabled?(:feature_flags_new_version, user_project, default_enabled: true)
+ end
+
+ def rename_key(hash, old_key, new_key)
+ hash[new_key] = hash.delete(old_key) if hash.key?(old_key)
+ hash
+ end
+
+ def update_value(hash, key)
+ hash[key] = yield(hash[key]) if hash.key?(key)
+ hash
+ end
+ end
+ end
+end
diff --git a/lib/api/feature_flags_user_lists.rb b/lib/api/feature_flags_user_lists.rb
new file mode 100644
index 00000000000..67aee3993f1
--- /dev/null
+++ b/lib/api/feature_flags_user_lists.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+module API
+ class FeatureFlagsUserLists < Grape::API::Instance
+ include PaginationParams
+
+ error_formatter :json, -> (message, _backtrace, _options, _env, _original_exception) {
+ message.is_a?(String) ? { message: message }.to_json : message.to_json
+ }
+
+ before do
+ authorize_admin_feature_flags_user_lists!
+ end
+
+ params do
+ requires :id, type: String, desc: 'The ID of a project'
+ end
+ resource 'projects/:id', requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
+ resource :feature_flags_user_lists do
+ desc 'Get all feature flags user lists of a project' do
+ detail 'This feature was introduced in GitLab 12.10'
+ success ::API::Entities::FeatureFlag::UserList
+ end
+ params do
+ use :pagination
+ end
+ get do
+ present paginate(user_project.operations_feature_flags_user_lists),
+ with: ::API::Entities::FeatureFlag::UserList
+ end
+
+ desc 'Create a feature flags user list for a project' do
+ detail 'This feature was introduced in GitLab 12.10'
+ success ::API::Entities::FeatureFlag::UserList
+ end
+ params do
+ requires :name, type: String, desc: 'The name of the list'
+ requires :user_xids, type: String, desc: 'A comma separated list of external user ids'
+ end
+ post do
+ list = user_project.operations_feature_flags_user_lists.create(declared_params)
+
+ if list.save
+ present list, with: ::API::Entities::FeatureFlag::UserList
+ else
+ render_api_error!(list.errors.full_messages, :bad_request)
+ end
+ end
+ end
+
+ params do
+ requires :iid, type: String, desc: 'The internal id of the user list'
+ end
+ resource 'feature_flags_user_lists/:iid' do
+ desc 'Get a single feature flag user list belonging to a project' do
+ detail 'This feature was introduced in GitLab 12.10'
+ success ::API::Entities::FeatureFlag::UserList
+ end
+ get do
+ present user_project.operations_feature_flags_user_lists.find_by_iid!(params[:iid]),
+ with: ::API::Entities::FeatureFlag::UserList
+ end
+
+ desc 'Update a feature flag user list' do
+ detail 'This feature was introduced in GitLab 12.10'
+ success ::API::Entities::FeatureFlag::UserList
+ end
+ params do
+ optional :name, type: String, desc: 'The name of the list'
+ optional :user_xids, type: String, desc: 'A comma separated list of external user ids'
+ end
+ put do
+ list = user_project.operations_feature_flags_user_lists.find_by_iid!(params[:iid])
+
+ if list.update(declared_params(include_missing: false))
+ present list, with: ::API::Entities::FeatureFlag::UserList
+ else
+ render_api_error!(list.errors.full_messages, :bad_request)
+ end
+ end
+
+ desc 'Delete a feature flag user list' do
+ detail 'This feature was introduced in GitLab 12.10'
+ end
+ delete do
+ list = user_project.operations_feature_flags_user_lists.find_by_iid!(params[:iid])
+ unless list.destroy
+ render_api_error!(list.errors.full_messages, :conflict)
+ end
+ end
+ end
+ end
+
+ helpers do
+ def authorize_admin_feature_flags_user_lists!
+ authorize! :admin_feature_flags_user_lists, user_project
+ end
+ end
+ end
+end
diff --git a/lib/backup/repositories.rb b/lib/backup/repositories.rb
index fc5b5e59e07..c8268a14bfe 100644
--- a/lib/backup/repositories.rb
+++ b/lib/backup/repositories.rb
@@ -46,6 +46,10 @@ module Backup
restore_repository(project, Gitlab::GlRepository::DESIGN)
end
+ Snippet.find_each(batch_size: 1000) do |snippet|
+ restore_repository(snippet, Gitlab::GlRepository::SNIPPET)
+ end
+
restore_object_pools
end
diff --git a/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml
index 568ceceeaa2..ec33020205b 100644
--- a/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Jobs/Code-Quality.gitlab-ci.yml
@@ -9,9 +9,8 @@ code_quality:
DOCKER_TLS_CERTDIR: ""
CODE_QUALITY_IMAGE: "registry.gitlab.com/gitlab-org/ci-cd/codequality:0.85.10-gitlab.1"
needs: []
- before_script:
- - export SOURCE_CODE=$PWD
script:
+ - export SOURCE_CODE=$PWD
- |
if ! docker info &>/dev/null; then
if [ -z "$DOCKER_HOST" -a "$KUBERNETES_PORT" ]; then
diff --git a/lib/gitlab/graphql/pagination/keyset/order_info.rb b/lib/gitlab/graphql/pagination/keyset/order_info.rb
index 577f59911f5..f3ce3a10703 100644
--- a/lib/gitlab/graphql/pagination/keyset/order_info.rb
+++ b/lib/gitlab/graphql/pagination/keyset/order_info.rb
@@ -95,7 +95,9 @@ module Gitlab
elsif ordering_by_similarity?(order_value)
['similarity', order_value.direction, order_value.expr]
elsif ordering_by_case?(order_value)
- [order_value.expr.case.name.to_s, order_value.direction, order_value.expr]
+ ['case_order_value', order_value.direction, order_value.expr]
+ elsif ordering_by_array_position?(order_value)
+ ['array_position', order_value.direction, order_value.expr]
else
[order_value.expr.name, order_value.direction, nil]
end
@@ -106,6 +108,11 @@ module Gitlab
order_value.expr.is_a?(Arel::Nodes::NamedFunction) && order_value.expr&.name&.downcase == 'lower'
end
+ # determine if ordering using ARRAY_POSITION, eg. "ORDER BY ARRAY_POSITION(Array[4,3,1,2]::smallint, state)"
+ def ordering_by_array_position?(order_value)
+ order_value.expr.is_a?(Arel::Nodes::NamedFunction) && order_value.expr&.name&.downcase == 'array_position'
+ end
+
# determine if ordering using SIMILARITY scoring based on Gitlab::Database::SimilarityScore
def ordering_by_similarity?(order_value)
Gitlab::Database::SimilarityScore.order_by_similarity?(order_value)
diff --git a/lib/google_api/cloud_platform/client.rb b/lib/google_api/cloud_platform/client.rb
index 9668badc757..f16bd7c735b 100644
--- a/lib/google_api/cloud_platform/client.rb
+++ b/lib/google_api/cloud_platform/client.rb
@@ -66,7 +66,7 @@ module GoogleApi
cluster_options = make_cluster_options(cluster_name, cluster_size, machine_type, legacy_abac, enable_addons)
- request_body = Google::Apis::ContainerV1beta1::CreateClusterRequest.new(cluster_options)
+ request_body = Google::Apis::ContainerV1beta1::CreateClusterRequest.new(**cluster_options)
service.create_cluster(project_id, zone, request_body, options: user_agent_header)
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d2d692e5ecd..d9371be7d7f 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -8028,6 +8028,15 @@ msgstr ""
msgid "Dashboard|Unable to add %{invalidProjects}. This dashboard is available for public projects, and private projects in groups with a Silver plan."
msgstr ""
+msgid "DastProfiles|AJAX spider"
+msgstr ""
+
+msgid "DastProfiles|Active"
+msgstr ""
+
+msgid "DastProfiles|Active scan will make active attacks against the target site while Passive scan will not"
+msgstr ""
+
msgid "DastProfiles|Are you sure you want to delete this profile?"
msgstr ""
@@ -8067,6 +8076,9 @@ msgstr ""
msgid "DastProfiles|Could not update the site profile. Please try again."
msgstr ""
+msgid "DastProfiles|Debug messages"
+msgstr ""
+
msgid "DastProfiles|Do you want to discard this scanner profile?"
msgstr ""
@@ -8085,9 +8097,18 @@ msgstr ""
msgid "DastProfiles|Edit site profile"
msgstr ""
+msgid "DastProfiles|Enable it to include the debug messages in DAST console output"
+msgstr ""
+
+msgid "DastProfiles|Enable it to run the AJAX spider (in addition to the traditional spider) to crawl the target site"
+msgstr ""
+
msgid "DastProfiles|Error Details"
msgstr ""
+msgid "DastProfiles|Hide debug messages"
+msgstr ""
+
msgid "DastProfiles|Manage Profiles"
msgstr ""
@@ -8139,6 +8160,9 @@ msgstr ""
msgid "DastProfiles|Scanner Profiles"
msgstr ""
+msgid "DastProfiles|Show debug messages"
+msgstr ""
+
msgid "DastProfiles|Site Profile"
msgstr ""
@@ -8178,6 +8202,9 @@ msgstr ""
msgid "DastProfiles|The maximum number of seconds allowed for the site under test to respond to a request."
msgstr ""
+msgid "DastProfiles|Turn on AJAX spider"
+msgstr ""
+
msgid "DastProfiles|Validate"
msgstr ""
@@ -16050,7 +16077,7 @@ msgstr ""
msgid "MergeRequests|Jump to next unresolved thread"
msgstr ""
-msgid "MergeRequests|Reply"
+msgid "MergeRequests|Reply..."
msgstr ""
msgid "MergeRequests|Resolve this thread in a new issue"
@@ -17892,6 +17919,9 @@ msgstr ""
msgid "Omnibus Protected Paths throttle is active, and takes priority over these settings. From 12.4, Omnibus throttle is deprecated and will be removed in a future release. Please read the %{relative_url_link_start}Migrating Protected Paths documentation%{relative_url_link_end}."
msgstr ""
+msgid "On"
+msgstr ""
+
msgid "On track"
msgstr ""
@@ -28716,7 +28746,13 @@ msgstr ""
msgid "Vulnerability|Comments"
msgstr ""
-msgid "Vulnerability|Crash Address"
+msgid "Vulnerability|Crash address"
+msgstr ""
+
+msgid "Vulnerability|Crash state"
+msgstr ""
+
+msgid "Vulnerability|Crash type"
msgstr ""
msgid "Vulnerability|Description"
diff --git a/rubocop/migration_helpers.rb b/rubocop/migration_helpers.rb
index c26ba88f269..e9533fb65b2 100644
--- a/rubocop/migration_helpers.rb
+++ b/rubocop/migration_helpers.rb
@@ -21,7 +21,7 @@ module RuboCop
TABLE_METHODS = %i(create_table create_table_if_not_exists change_table).freeze
def high_traffic_tables
- @high_traffic_tables ||= rubocop_migrations_config.dig('Migration/UpdateLargeTable', 'DeniedTables')
+ @high_traffic_tables ||= rubocop_migrations_config.dig('Migration/UpdateLargeTable', 'HighTrafficTables')
end
# Returns true if the given node originated from the db/migrate directory.
diff --git a/rubocop/rubocop-migrations.yml b/rubocop/rubocop-migrations.yml
index a919d570ccc..454bed71833 100644
--- a/rubocop/rubocop-migrations.yml
+++ b/rubocop/rubocop-migrations.yml
@@ -1,6 +1,7 @@
+# Make sure to update the docs if this file moves. Docs URL: https://docs.gitlab.com/ce/development/migration_style_guide.html#when-to-use-the-helper-method
Migration/UpdateLargeTable:
Enabled: true
- DeniedTables: &denied_tables # size in GB (>= 10 GB on GitLab.com as of 02/2020) and/or number of records
+ HighTrafficTables: &high_traffic_tables # size in GB (>= 10 GB on GitLab.com as of 02/2020) and/or number of records
- :audit_events
- :ci_build_trace_sections
- :ci_builds
diff --git a/spec/controllers/projects/feature_flags_clients_controller_spec.rb b/spec/controllers/projects/feature_flags_clients_controller_spec.rb
new file mode 100644
index 00000000000..f527d2ba430
--- /dev/null
+++ b/spec/controllers/projects/feature_flags_clients_controller_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::FeatureFlagsClientsController do
+ include Gitlab::Routing
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ describe 'POST reset_token.json' do
+ subject(:reset_token) do
+ post :reset_token,
+ params: { namespace_id: project.namespace, project_id: project },
+ format: :json
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ context 'when user is a project maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'and feature flags client exist' do
+ it 'regenerates feature flags client token' do
+ project.create_operations_feature_flags_client!
+ expect { reset_token }.to change { project.reload.feature_flags_client_token }
+
+ expect(json_response['token']).to eq(project.feature_flags_client_token)
+ end
+ end
+
+ context 'but feature flags client does not exist' do
+ it 'returns 404' do
+ reset_token
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when user is not a project maintainer' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'returns 404' do
+ reset_token
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/feature_flags_controller_spec.rb b/spec/controllers/projects/feature_flags_controller_spec.rb
new file mode 100644
index 00000000000..96eeb6f239f
--- /dev/null
+++ b/spec/controllers/projects/feature_flags_controller_spec.rb
@@ -0,0 +1,1604 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::FeatureFlagsController do
+ include Gitlab::Routing
+ include FeatureFlagHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let(:user) { developer }
+
+ before_all do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET index' do
+ render_views
+
+ subject { get(:index, params: view_params) }
+
+ context 'when there is no feature flags' do
+ it 'responds with success' do
+ is_expected.to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'for a list of feature flags' do
+ let!(:feature_flags) { create_list(:operations_feature_flag, 50, project: project) }
+
+ it 'responds with success' do
+ is_expected.to have_gitlab_http_status(:ok)
+ end
+ end
+
+ context 'when the user is a reporter' do
+ let(:user) { reporter }
+
+ it 'responds with not found' do
+ is_expected.to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ describe 'GET #index.json' do
+ subject { get(:index, params: view_params, format: :json) }
+
+ let!(:feature_flag_active) do
+ create(:operations_feature_flag, project: project, active: true, name: 'feature_flag_a')
+ end
+
+ let!(:feature_flag_inactive) do
+ create(:operations_feature_flag, project: project, active: false, name: 'feature_flag_b')
+ end
+
+ it 'returns all feature flags as json response' do
+ subject
+
+ expect(json_response['feature_flags'].count).to eq(2)
+ expect(json_response['feature_flags'].first['name']).to eq(feature_flag_active.name)
+ expect(json_response['feature_flags'].second['name']).to eq(feature_flag_inactive.name)
+ end
+
+ it 'returns CRUD paths' do
+ subject
+
+ expected_edit_path = edit_project_feature_flag_path(project, feature_flag_active)
+ expected_update_path = project_feature_flag_path(project, feature_flag_active)
+ expected_destroy_path = project_feature_flag_path(project, feature_flag_active)
+
+ feature_flag_json = json_response['feature_flags'].first
+
+ expect(feature_flag_json['edit_path']).to eq(expected_edit_path)
+ expect(feature_flag_json['update_path']).to eq(expected_update_path)
+ expect(feature_flag_json['destroy_path']).to eq(expected_destroy_path)
+ end
+
+ it 'returns the summary of feature flags' do
+ subject
+
+ expect(json_response['count']['all']).to eq(2)
+ expect(json_response['count']['enabled']).to eq(1)
+ expect(json_response['count']['disabled']).to eq(1)
+ end
+
+ it 'matches json schema' do
+ is_expected.to match_response_schema('feature_flags')
+ end
+
+ it 'returns false for active when the feature flag is inactive even if it has an active scope' do
+ create(:operations_feature_flag_scope,
+ feature_flag: feature_flag_inactive,
+ environment_scope: 'production',
+ active: true)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ feature_flag_json = json_response['feature_flags'].second
+
+ expect(feature_flag_json['active']).to eq(false)
+ end
+
+ it 'returns the feature flag iid' do
+ subject
+
+ feature_flag_json = json_response['feature_flags'].first
+
+ expect(feature_flag_json['iid']).to eq(feature_flag_active.iid)
+ end
+
+ context 'when scope is specified' do
+ let(:view_params) do
+ { namespace_id: project.namespace, project_id: project, scope: scope }
+ end
+
+ context 'when all feature flags are requested' do
+ let(:scope) { 'all' }
+
+ it 'returns all feature flags' do
+ subject
+
+ expect(json_response['feature_flags'].count).to eq(2)
+ end
+ end
+
+ context 'when enabled feature flags are requested' do
+ let(:scope) { 'enabled' }
+
+ it 'returns enabled feature flags' do
+ subject
+
+ expect(json_response['feature_flags'].count).to eq(1)
+ expect(json_response['feature_flags'].first['active']).to be_truthy
+ end
+ end
+
+ context 'when disabled feature flags are requested' do
+ let(:scope) { 'disabled' }
+
+ it 'returns disabled feature flags' do
+ subject
+
+ expect(json_response['feature_flags'].count).to eq(1)
+ expect(json_response['feature_flags'].first['active']).to be_falsy
+ end
+ end
+ end
+
+ context 'when feature flags have additional scopes' do
+ let!(:feature_flag_active_scope) do
+ create(:operations_feature_flag_scope,
+ feature_flag: feature_flag_active,
+ environment_scope: 'production',
+ active: false)
+ end
+
+ let!(:feature_flag_inactive_scope) do
+ create(:operations_feature_flag_scope,
+ feature_flag: feature_flag_inactive,
+ environment_scope: 'staging',
+ active: false)
+ end
+
+ it 'returns a correct summary' do
+ subject
+
+ expect(json_response['count']['all']).to eq(2)
+ expect(json_response['count']['enabled']).to eq(1)
+ expect(json_response['count']['disabled']).to eq(1)
+ end
+
+ it 'recognizes feature flag 1 as active' do
+ subject
+
+ expect(json_response['feature_flags'].first['active']).to be_truthy
+ end
+
+ it 'recognizes feature flag 2 as inactive' do
+ subject
+
+ expect(json_response['feature_flags'].second['active']).to be_falsy
+ end
+
+ it 'has ordered scopes' do
+ subject
+
+ expect(json_response['feature_flags'][0]['scopes'][0]['id'])
+ .to be < json_response['feature_flags'][0]['scopes'][1]['id']
+ expect(json_response['feature_flags'][1]['scopes'][0]['id'])
+ .to be < json_response['feature_flags'][1]['scopes'][1]['id']
+ end
+
+ it 'does not have N+1 problem' do
+ recorded = ActiveRecord::QueryRecorder.new { subject }
+
+ related_count = recorded.log
+ .count { |query| query.include?('operations_feature_flag') }
+
+ expect(related_count).to be_within(5).of(2)
+ end
+ end
+
+ context 'with version 1 and 2 feature flags' do
+ let!(:new_version_feature_flag) do
+ create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature_flag_c')
+ end
+
+ it 'returns all feature flags as json response' do
+ subject
+
+ expect(json_response['feature_flags'].count).to eq(3)
+ end
+
+ it 'returns only version 1 flags when new version flags are disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ subject
+
+ expected = [feature_flag_active.name, feature_flag_inactive.name].sort
+ expect(json_response['feature_flags'].map { |f| f['name'] }.sort).to eq(expected)
+ end
+ end
+ end
+
+ describe 'GET new' do
+ render_views
+
+ subject { get(:new, params: view_params) }
+
+ it 'renders the form' do
+ is_expected.to have_gitlab_http_status(:ok)
+ end
+ end
+
+ describe 'GET #show.json' do
+ subject { get(:show, params: params, format: :json) }
+
+ let!(:feature_flag) do
+ create(:operations_feature_flag, project: project)
+ end
+
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid
+ }
+ end
+
+ it 'returns the feature flag as json response' do
+ subject
+
+ expect(json_response['name']).to eq(feature_flag.name)
+ expect(json_response['active']).to eq(feature_flag.active)
+ expect(json_response['version']).to eq('legacy_flag')
+ end
+
+ it 'matches json schema' do
+ is_expected.to match_response_schema('feature_flag')
+ end
+
+ it 'routes based on iid' do
+ other_project = create(:project)
+ other_project.add_developer(user)
+ other_feature_flag = create(:operations_feature_flag, project: other_project,
+ name: 'other_flag')
+ params = {
+ namespace_id: other_project.namespace,
+ project_id: other_project,
+ iid: other_feature_flag.iid
+ }
+
+ get(:show, params: params, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['name']).to eq(other_feature_flag.name)
+ end
+
+ it 'routes based on iid when new version flags are disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+ other_project = create(:project)
+ other_project.add_developer(user)
+ other_feature_flag = create(:operations_feature_flag, project: other_project,
+ name: 'other_flag')
+ params = {
+ namespace_id: other_project.namespace,
+ project_id: other_project,
+ iid: other_feature_flag.iid
+ }
+
+ get(:show, params: params, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['name']).to eq(other_feature_flag.name)
+ end
+
+ context 'when feature flag is not found' do
+ let!(:feature_flag) { }
+
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: 1
+ }
+ end
+
+ it 'returns 404' do
+ is_expected.to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'returns 404' do
+ is_expected.to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when feature flags have additional scopes' do
+ context 'when there is at least one active scope' do
+ let!(:feature_flag) do
+ create(:operations_feature_flag, project: project, active: false)
+ end
+
+ let!(:feature_flag_scope_production) do
+ create(:operations_feature_flag_scope,
+ feature_flag: feature_flag,
+ environment_scope: 'review/*',
+ active: true)
+ end
+
+ it 'returns false for active' do
+ subject
+
+ expect(json_response['active']).to eq(false)
+ end
+ end
+
+ context 'when all scopes are inactive' do
+ let!(:feature_flag) do
+ create(:operations_feature_flag, project: project, active: false)
+ end
+
+ let!(:feature_flag_scope_production) do
+ create(:operations_feature_flag_scope,
+ feature_flag: feature_flag,
+ environment_scope: 'production',
+ active: false)
+ end
+
+ it 'recognizes the feature flag as inactive' do
+ subject
+
+ expect(json_response['active']).to be_falsy
+ end
+ end
+ end
+
+ context 'with a version 2 feature flag' do
+ let!(:new_version_feature_flag) do
+ create(:operations_feature_flag, :new_version_flag, project: project)
+ end
+
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: new_version_feature_flag.iid
+ }
+ end
+
+ it 'returns the feature flag' do
+ subject
+
+ expect(json_response['name']).to eq(new_version_feature_flag.name)
+ expect(json_response['active']).to eq(new_version_feature_flag.active)
+ expect(json_response['version']).to eq('new_version_flag')
+ end
+
+ it 'returns a 404 when new version flags are disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns strategies ordered by id' do
+ first_strategy = create(:operations_strategy, feature_flag: new_version_feature_flag)
+ second_strategy = create(:operations_strategy, feature_flag: new_version_feature_flag)
+
+ subject
+
+ expect(json_response['strategies'].map { |s| s['id'] }).to eq([first_strategy.id, second_strategy.id])
+ end
+ end
+ end
+
+ describe 'POST create.json' do
+ subject { post(:create, params: params, format: :json) }
+
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true
+ }
+ }
+ end
+
+ it 'returns 200' do
+ is_expected.to have_gitlab_http_status(:ok)
+ end
+
+ it 'creates a new feature flag' do
+ subject
+
+ expect(json_response['name']).to eq('my_feature_flag')
+ expect(json_response['active']).to be_truthy
+ end
+
+ it 'creates a default scope' do
+ subject
+
+ expect(json_response['scopes'].count).to eq(1)
+ expect(json_response['scopes'].first['environment_scope']).to eq('*')
+ expect(json_response['scopes'].first['active']).to be_truthy
+ end
+
+ it 'matches json schema' do
+ is_expected.to match_response_schema('feature_flag')
+ end
+
+ context 'when the same named feature flag has already existed' do
+ before do
+ create(:operations_feature_flag, name: 'my_feature_flag', project: project)
+ end
+
+ it 'returns 400' do
+ is_expected.to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns an error message' do
+ subject
+
+ expect(json_response['message']).to include('Name has already been taken')
+ end
+ end
+
+ context 'without the active parameter' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag'
+ }
+ }
+ end
+
+ it 'creates a flag with active set to true' do
+ expect { subject }.to change { Operations::FeatureFlag.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('feature_flag')
+ expect(json_response['active']).to eq(true)
+ expect(Operations::FeatureFlag.last.active).to eq(true)
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'returns 404' do
+ is_expected.to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when creates additional scope' do
+ let(:params) do
+ view_params.merge({
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ scopes_attributes: [{ environment_scope: '*', active: true },
+ { environment_scope: 'production', active: false }]
+ }
+ })
+ end
+
+ it 'creates feature flag scopes successfully' do
+ expect { subject }.to change { Operations::FeatureFlagScope.count }.by(2)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'creates feature flag scopes in a correct order' do
+ subject
+
+ expect(json_response['scopes'].first['environment_scope']).to eq('*')
+ expect(json_response['scopes'].second['environment_scope']).to eq('production')
+ end
+
+ context 'when default scope is not placed first' do
+ let(:params) do
+ view_params.merge({
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ scopes_attributes: [{ environment_scope: 'production', active: false },
+ { environment_scope: '*', active: true }]
+ }
+ })
+ end
+
+ it 'returns 400' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message'])
+ .to include('Default scope has to be the first element')
+ end
+ end
+ end
+
+ context 'when creates additional scope with a percentage rollout' do
+ it 'creates a strategy for the scope' do
+ params = view_params.merge({
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ scopes_attributes: [{ environment_scope: '*', active: true },
+ { environment_scope: 'production', active: false,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '42' } }] }]
+ }
+ })
+
+ post(:create, params: params, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ production_strategies_json = json_response['scopes'].second['strategies']
+ expect(production_strategies_json).to eq([{
+ 'name' => 'gradualRolloutUserId',
+ 'parameters' => { "groupId" => "default", "percentage" => "42" }
+ }])
+ end
+ end
+
+ context 'when creates additional scope with a userWithId strategy' do
+ it 'creates a strategy for the scope' do
+ params = view_params.merge({
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ scopes_attributes: [{ environment_scope: '*', active: true },
+ { environment_scope: 'production', active: false,
+ strategies: [{ name: 'userWithId',
+ parameters: { userIds: '123,4,6722' } }] }]
+ }
+ })
+
+ post(:create, params: params, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ production_strategies_json = json_response['scopes'].second['strategies']
+ expect(production_strategies_json).to eq([{
+ 'name' => 'userWithId',
+ 'parameters' => { "userIds" => "123,4,6722" }
+ }])
+ end
+ end
+
+ context 'when creates an additional scope without a strategy' do
+ it 'creates a default strategy' do
+ params = view_params.merge({
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ scopes_attributes: [{ environment_scope: '*', active: true }]
+ }
+ })
+
+ post(:create, params: params, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ default_strategies_json = json_response['scopes'].first['strategies']
+ expect(default_strategies_json).to eq([{ "name" => "default", "parameters" => {} }])
+ end
+ end
+
+ context 'when creating a version 2 feature flag' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ version: 'new_version_flag'
+ }
+ }
+ end
+
+ it 'creates a new feature flag' do
+ subject
+
+ expect(json_response['name']).to eq('my_feature_flag')
+ expect(json_response['active']).to be_truthy
+ expect(json_response['version']).to eq('new_version_flag')
+ end
+ end
+
+ context 'when creating a version 2 feature flag with strategies and scopes' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ version: 'new_version_flag',
+ strategies_attributes: [{
+ name: 'userWithId',
+ parameters: { userIds: 'user1' },
+ scopes_attributes: [{ environment_scope: '*' }]
+ }]
+ }
+ }
+ end
+
+ it 'creates a new feature flag with the strategies and scopes' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['name']).to eq('my_feature_flag')
+ expect(json_response['active']).to eq(true)
+ expect(json_response['strategies'].count).to eq(1)
+
+ strategy_json = json_response['strategies'].first
+ expect(strategy_json).to have_key('id')
+ expect(strategy_json['name']).to eq('userWithId')
+ expect(strategy_json['parameters']).to eq({ 'userIds' => 'user1' })
+ expect(strategy_json['scopes'].count).to eq(1)
+
+ scope_json = strategy_json['scopes'].first
+ expect(scope_json).to have_key('id')
+ expect(scope_json['environment_scope']).to eq('*')
+ end
+ end
+
+ context 'when creating a version 2 feature flag with a gradualRolloutUserId strategy' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ version: 'new_version_flag',
+ strategies_attributes: [{
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '15' },
+ scopes_attributes: [{ environment_scope: 'production' }]
+ }]
+ }
+ }
+ end
+
+ it 'creates the new strategy' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ strategy_json = json_response['strategies'].first
+ expect(strategy_json['name']).to eq('gradualRolloutUserId')
+ expect(strategy_json['parameters']).to eq({ 'groupId' => 'default', 'percentage' => '15' })
+ expect(strategy_json['scopes'].count).to eq(1)
+
+ scope_json = strategy_json['scopes'].first
+ expect(scope_json['environment_scope']).to eq('production')
+ end
+ end
+
+ context 'when creating a version 2 feature flag with a flexibleRollout strategy' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ version: 'new_version_flag',
+ strategies_attributes: [{
+ name: 'flexibleRollout',
+ parameters: { groupId: 'default', rollout: '15', stickiness: 'DEFAULT' },
+ scopes_attributes: [{ environment_scope: 'production' }]
+ }]
+ }
+ }
+ end
+
+ it 'creates the new strategy' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ strategy_json = json_response['strategies'].first
+ expect(strategy_json['name']).to eq('flexibleRollout')
+ expect(strategy_json['parameters']).to eq({ 'groupId' => 'default', 'rollout' => '15', 'stickiness' => 'DEFAULT' })
+ expect(strategy_json['scopes'].count).to eq(1)
+
+ scope_json = strategy_json['scopes'].first
+ expect(scope_json['environment_scope']).to eq('production')
+ end
+ end
+
+ context 'when creating a version 2 feature flag with a gitlabUserList strategy' do
+ let!(:user_list) do
+ create(:operations_feature_flag_user_list, project: project,
+ name: 'My List', user_xids: 'user1,user2')
+ end
+
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ version: 'new_version_flag',
+ strategies_attributes: [{
+ name: 'gitlabUserList',
+ parameters: {},
+ user_list_id: user_list.id,
+ scopes_attributes: [{ environment_scope: 'production' }]
+ }]
+ }
+ }
+ end
+
+ it 'creates the new strategy' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies']).to match([a_hash_including({
+ 'name' => 'gitlabUserList',
+ 'parameters' => {},
+ 'user_list' => {
+ 'id' => user_list.id,
+ 'iid' => user_list.iid,
+ 'name' => 'My List',
+ 'user_xids' => 'user1,user2'
+ },
+ 'scopes' => [a_hash_including({
+ 'environment_scope' => 'production'
+ })]
+ })])
+ end
+ end
+
+ context 'when version parameter is invalid' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ version: 'bad_version'
+ }
+ }
+ end
+
+ it 'returns a 400' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({ 'message' => 'Version is invalid' })
+ expect(Operations::FeatureFlag.count).to eq(0)
+ end
+ end
+
+ context 'when version 2 flags are disabled' do
+ context 'and attempting to create a version 2 flag' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true,
+ version: 'new_version_flag'
+ }
+ }
+ end
+
+ it 'returns a 400' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(Operations::FeatureFlag.count).to eq(0)
+ end
+ end
+
+ context 'and attempting to create a version 1 flag' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ operations_feature_flag: {
+ name: 'my_feature_flag',
+ active: true
+ }
+ }
+ end
+
+ it 'creates the flag' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(Operations::FeatureFlag.count).to eq(1)
+ expect(json_response['version']).to eq('legacy_flag')
+ end
+ end
+ end
+ end
+
+ describe 'DELETE destroy.json' do
+ subject { delete(:destroy, params: params, format: :json) }
+
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
+
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid
+ }
+ end
+
+ it 'returns 200' do
+ is_expected.to have_gitlab_http_status(:ok)
+ end
+
+ it 'deletes one feature flag' do
+ expect { subject }.to change { Operations::FeatureFlag.count }.by(-1)
+ end
+
+ it 'destroys the default scope' do
+ expect { subject }.to change { Operations::FeatureFlagScope.count }.by(-1)
+ end
+
+ it 'matches json schema' do
+ is_expected.to match_response_schema('feature_flag')
+ end
+
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'returns 404' do
+ is_expected.to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when the feature flag does not exist' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: 0
+ }
+ end
+
+ it 'returns not found' do
+ is_expected.to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when there is an additional scope' do
+ let!(:scope) { create_scope(feature_flag, 'production', false) }
+
+ it 'destroys the default scope and production scope' do
+ expect { subject }.to change { Operations::FeatureFlagScope.count }.by(-2)
+ end
+ end
+
+ context 'with a version 2 flag' do
+ let!(:new_version_flag) { create(:operations_feature_flag, :new_version_flag, project: project) }
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: new_version_flag.iid
+ }
+ end
+
+ it 'deletes the flag' do
+ expect { subject }.to change { Operations::FeatureFlag.count }.by(-1)
+ end
+
+ context 'when new version flags are disabled' do
+ it 'returns a 404' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ expect { subject }.not_to change { Operations::FeatureFlag.count }
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ describe 'PUT update.json' do
+ def put_request(feature_flag, feature_flag_params)
+ params = {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid,
+ operations_feature_flag: feature_flag_params
+ }
+
+ put(:update, params: params, format: :json, as: :json)
+ end
+
+ before do
+ stub_feature_flags(
+ feature_flags_legacy_read_only: false,
+ feature_flags_legacy_read_only_override: false
+ )
+ end
+
+ subject { put(:update, params: params, format: :json) }
+
+ let!(:feature_flag) do
+ create(:operations_feature_flag,
+ :legacy_flag,
+ name: 'ci_live_trace',
+ active: true,
+ project: project)
+ end
+
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid,
+ operations_feature_flag: {
+ name: 'ci_new_live_trace'
+ }
+ }
+ end
+
+ it 'returns 200' do
+ is_expected.to have_gitlab_http_status(:ok)
+ end
+
+ it 'updates the name of the feature flag name' do
+ subject
+
+ expect(json_response['name']).to eq('ci_new_live_trace')
+ end
+
+ it 'matches json schema' do
+ is_expected.to match_response_schema('feature_flag')
+ end
+
+ context 'when updates active' do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid,
+ operations_feature_flag: {
+ active: false
+ }
+ }
+ end
+
+ it 'updates active from true to false' do
+ expect { subject }
+ .to change { feature_flag.reload.active }.from(true).to(false)
+ end
+
+ it "does not change default scope's active" do
+ expect { subject }
+ .not_to change { feature_flag.default_scope.reload.active }.from(true)
+ end
+
+ it 'updates active from false to true when an inactive feature flag has an active scope' do
+ feature_flag = create(:operations_feature_flag, project: project, name: 'my_flag', active: false)
+ create(:operations_feature_flag_scope, feature_flag: feature_flag, environment_scope: 'production', active: true)
+
+ put_request(feature_flag, { active: true })
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('feature_flag')
+ expect(json_response['active']).to eq(true)
+ expect(feature_flag.reload.active).to eq(true)
+ expect(feature_flag.default_scope.reload.active).to eq(false)
+ end
+ end
+
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'returns 404' do
+ is_expected.to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context "when creates an additional scope for production environment" do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid,
+ operations_feature_flag: {
+ scopes_attributes: [{ environment_scope: 'production', active: false }]
+ }
+ }
+ end
+
+ it 'creates a production scope' do
+ expect { subject }.to change { feature_flag.reload.scopes.count }.by(1)
+
+ expect(json_response['scopes'].last['environment_scope']).to eq('production')
+ expect(json_response['scopes'].last['active']).to be_falsy
+ end
+ end
+
+ context "when creates a default scope" do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid,
+ operations_feature_flag: {
+ scopes_attributes: [{ environment_scope: '*', active: false }]
+ }
+ }
+ end
+
+ it 'returns 400' do
+ is_expected.to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context "when updates a default scope's active value" do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid,
+ operations_feature_flag: {
+ scopes_attributes: [
+ {
+ id: feature_flag.default_scope.id,
+ environment_scope: '*',
+ active: false
+ }
+ ]
+ }
+ }
+ end
+
+ it "updates successfully" do
+ subject
+
+ expect(json_response['scopes'].first['environment_scope']).to eq('*')
+ expect(json_response['scopes'].first['active']).to be_falsy
+ end
+ end
+
+ context "when changes default scope's spec" do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid,
+ operations_feature_flag: {
+ scopes_attributes: [
+ {
+ id: feature_flag.default_scope.id,
+ environment_scope: 'review/*'
+ }
+ ]
+ }
+ }
+ end
+
+ it 'returns 400' do
+ is_expected.to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context "when destroys the default scope" do
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid,
+ operations_feature_flag: {
+ scopes_attributes: [
+ {
+ id: feature_flag.default_scope.id,
+ _destroy: 1
+ }
+ ]
+ }
+ }
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(ActiveRecord::ReadOnlyRecord)
+ end
+ end
+
+ context "when destroys a production scope" do
+ let!(:production_scope) { create_scope(feature_flag, 'production', true) }
+ let(:params) do
+ {
+ namespace_id: project.namespace,
+ project_id: project,
+ iid: feature_flag.iid,
+ operations_feature_flag: {
+ scopes_attributes: [
+ {
+ id: production_scope.id,
+ _destroy: 1
+ }
+ ]
+ }
+ }
+ end
+
+ it 'destroys successfully' do
+ subject
+
+ scopes = json_response['scopes']
+ expect(scopes.any? { |scope| scope['environment_scope'] == 'production' })
+ .to be_falsy
+ end
+ end
+
+ describe "updating the strategy" do
+ it 'creates a default strategy' do
+ scope = create_scope(feature_flag, 'production', true, [])
+
+ put_request(feature_flag, scopes_attributes: [{
+ id: scope.id,
+ strategies: [{ name: 'default', parameters: {} }]
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ scope_json = json_response['scopes'].find do |s|
+ s['environment_scope'] == 'production'
+ end
+ expect(scope_json['strategies']).to eq([{
+ "name" => "default",
+ "parameters" => {}
+ }])
+ end
+
+ it 'creates a gradualRolloutUserId strategy' do
+ scope = create_scope(feature_flag, 'production', true, [])
+
+ put_request(feature_flag, scopes_attributes: [{
+ id: scope.id,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: "70" } }]
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ scope_json = json_response['scopes'].find do |s|
+ s['environment_scope'] == 'production'
+ end
+ expect(scope_json['strategies']).to eq([{
+ "name" => "gradualRolloutUserId",
+ "parameters" => {
+ "groupId" => "default",
+ "percentage" => "70"
+ }
+ }])
+ end
+
+ it 'creates a userWithId strategy' do
+ scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
+
+ put_request(feature_flag, scopes_attributes: [{
+ id: scope.id,
+ strategies: [{ name: 'userWithId', parameters: { userIds: 'sam,fred' } }]
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ scope_json = json_response['scopes'].find do |s|
+ s['environment_scope'] == 'production'
+ end
+ expect(scope_json['strategies']).to eq([{
+ "name" => "userWithId",
+ "parameters" => { "userIds" => "sam,fred" }
+ }])
+ end
+
+ it 'updates an existing strategy' do
+ scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
+
+ put_request(feature_flag, scopes_attributes: [{
+ id: scope.id,
+ strategies: [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: "50" } }]
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ scope_json = json_response['scopes'].find do |s|
+ s['environment_scope'] == 'production'
+ end
+ expect(scope_json['strategies']).to eq([{
+ "name" => "gradualRolloutUserId",
+ "parameters" => {
+ "groupId" => "default",
+ "percentage" => "50"
+ }
+ }])
+ end
+
+ it 'clears an existing strategy' do
+ scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
+
+ put_request(feature_flag, scopes_attributes: [{
+ id: scope.id,
+ strategies: []
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ scope_json = json_response['scopes'].find do |s|
+ s['environment_scope'] == 'production'
+ end
+ expect(scope_json['strategies']).to eq([])
+ end
+
+ it 'accepts multiple strategies' do
+ scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
+
+ put_request(feature_flag, scopes_attributes: [{
+ id: scope.id,
+ strategies: [
+ { name: 'gradualRolloutUserId', parameters: { groupId: 'mygroup', percentage: '55' } },
+ { name: 'userWithId', parameters: { userIds: 'joe' } }
+ ]
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ scope_json = json_response['scopes'].find do |s|
+ s['environment_scope'] == 'production'
+ end
+ expect(scope_json['strategies'].length).to eq(2)
+ expect(scope_json['strategies']).to include({
+ "name" => "gradualRolloutUserId",
+ "parameters" => { "groupId" => "mygroup", "percentage" => "55" }
+ })
+ expect(scope_json['strategies']).to include({
+ "name" => "userWithId",
+ "parameters" => { "userIds" => "joe" }
+ })
+ end
+
+ it 'does not modify strategies when there is no strategies key in the params' do
+ scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
+
+ put_request(feature_flag, scopes_attributes: [{ id: scope.id }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ scope_json = json_response['scopes'].find do |s|
+ s['environment_scope'] == 'production'
+ end
+ expect(scope_json['strategies']).to eq([{
+ "name" => "default",
+ "parameters" => {}
+ }])
+ end
+
+ it 'leaves an existing strategy when there are no strategies in the params' do
+ scope = create_scope(feature_flag, 'production', true, [{ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '10' } }])
+
+ put_request(feature_flag, scopes_attributes: [{ id: scope.id }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ scope_json = json_response['scopes'].find do |s|
+ s['environment_scope'] == 'production'
+ end
+ expect(scope_json['strategies']).to eq([{
+ "name" => "gradualRolloutUserId",
+ "parameters" => { "groupId" => "default", "percentage" => "10" }
+ }])
+ end
+
+ it 'does not accept extra parameters in the strategy params' do
+ scope = create_scope(feature_flag, 'production', true, [{ name: 'default', parameters: {} }])
+
+ put_request(feature_flag, scopes_attributes: [{
+ id: scope.id,
+ strategies: [{ name: 'userWithId', parameters: { userIds: 'joe', groupId: 'default' } }]
+ }])
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq(["Scopes strategies parameters are invalid"])
+ end
+ end
+
+ context 'when legacy feature flags are set to be read only' do
+ it 'does not update the flag' do
+ stub_feature_flags(feature_flags_legacy_read_only: true)
+
+ put_request(feature_flag, name: 'ci_new_live_trace')
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to eq(["Legacy feature flags are read-only"])
+ end
+
+ it 'updates the flag if the legacy read-only override is enabled for a particular project' do
+ stub_feature_flags(
+ feature_flags_legacy_read_only: true,
+ feature_flags_legacy_read_only_override: project
+ )
+
+ put_request(feature_flag, name: 'ci_new_live_trace')
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['name']).to eq('ci_new_live_trace')
+ end
+ end
+
+ context 'with a version 2 feature flag' do
+ let!(:new_version_flag) do
+ create(:operations_feature_flag,
+ :new_version_flag,
+ name: 'new-feature',
+ active: true,
+ project: project)
+ end
+
+ it 'creates a new strategy and scope' do
+ put_request(new_version_flag, strategies_attributes: [{
+ name: 'userWithId',
+ parameters: { userIds: 'user1' },
+ scopes_attributes: [{
+ environment_scope: 'production'
+ }]
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies'].count).to eq(1)
+ strategy_json = json_response['strategies'].first
+ expect(strategy_json['name']).to eq('userWithId')
+ expect(strategy_json['parameters']).to eq({
+ 'userIds' => 'user1'
+ })
+ expect(strategy_json['scopes'].count).to eq(1)
+ scope_json = strategy_json['scopes'].first
+ expect(scope_json['environment_scope']).to eq('production')
+ end
+
+ it 'creates a gradualRolloutUserId strategy' do
+ put_request(new_version_flag, strategies_attributes: [{
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '30' }
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies'].count).to eq(1)
+ strategy_json = json_response['strategies'].first
+ expect(strategy_json['name']).to eq('gradualRolloutUserId')
+ expect(strategy_json['parameters']).to eq({
+ 'groupId' => 'default',
+ 'percentage' => '30'
+ })
+ expect(strategy_json['scopes']).to eq([])
+ end
+
+ it 'creates a flexibleRollout strategy' do
+ put_request(new_version_flag, strategies_attributes: [{
+ name: 'flexibleRollout',
+ parameters: { groupId: 'default', rollout: '30', stickiness: 'DEFAULT' }
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies'].count).to eq(1)
+ strategy_json = json_response['strategies'].first
+ expect(strategy_json['name']).to eq('flexibleRollout')
+ expect(strategy_json['parameters']).to eq({
+ 'groupId' => 'default',
+ 'rollout' => '30',
+ 'stickiness' => 'DEFAULT'
+ })
+ expect(strategy_json['scopes']).to eq([])
+ end
+
+ it 'creates a gitlabUserList strategy' do
+ user_list = create(:operations_feature_flag_user_list, project: project, name: 'My List', user_xids: 'user1,user2')
+
+ put_request(new_version_flag, strategies_attributes: [{
+ name: 'gitlabUserList',
+ parameters: {},
+ user_list_id: user_list.id
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies']).to match([a_hash_including({
+ 'id' => an_instance_of(Integer),
+ 'name' => 'gitlabUserList',
+ 'parameters' => {},
+ 'user_list' => {
+ 'id' => user_list.id,
+ 'iid' => user_list.iid,
+ 'name' => 'My List',
+ 'user_xids' => 'user1,user2'
+ },
+ 'scopes' => []
+ })])
+ end
+
+ it 'supports switching the associated user list for an existing gitlabUserList strategy' do
+ user_list = create(:operations_feature_flag_user_list, project: project, name: 'My List', user_xids: 'user1,user2')
+ strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'gitlabUserList', parameters: {}, user_list: user_list)
+ other_user_list = create(:operations_feature_flag_user_list, project: project, name: 'Other List', user_xids: 'user3')
+
+ put_request(new_version_flag, strategies_attributes: [{
+ id: strategy.id,
+ user_list_id: other_user_list.id
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies']).to eq([{
+ 'id' => strategy.id,
+ 'name' => 'gitlabUserList',
+ 'parameters' => {},
+ 'user_list' => {
+ 'id' => other_user_list.id,
+ 'iid' => other_user_list.iid,
+ 'name' => 'Other List',
+ 'user_xids' => 'user3'
+ },
+ 'scopes' => []
+ }])
+ end
+
+ it 'automatically dissociates the user list when switching the type of an existing gitlabUserList strategy' do
+ user_list = create(:operations_feature_flag_user_list, project: project, name: 'My List', user_xids: 'user1,user2')
+ strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'gitlabUserList', parameters: {}, user_list: user_list)
+
+ put_request(new_version_flag, strategies_attributes: [{
+ id: strategy.id,
+ name: 'gradualRolloutUserId',
+ parameters: {
+ groupId: 'default',
+ percentage: '25'
+ }
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies']).to eq([{
+ 'id' => strategy.id,
+ 'name' => 'gradualRolloutUserId',
+ 'parameters' => {
+ 'groupId' => 'default',
+ 'percentage' => '25'
+ },
+ 'scopes' => []
+ }])
+ end
+
+ it 'does not delete a user list when deleting a gitlabUserList strategy' do
+ user_list = create(:operations_feature_flag_user_list, project: project, name: 'My List', user_xids: 'user1,user2')
+ strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'gitlabUserList', parameters: {}, user_list: user_list)
+
+ put_request(new_version_flag, strategies_attributes: [{
+ id: strategy.id,
+ _destroy: true
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies']).to eq([])
+ expect(::Operations::FeatureFlags::Strategy.count).to eq(0)
+ expect(::Operations::FeatureFlags::StrategyUserList.count).to eq(0)
+ expect(::Operations::FeatureFlags::UserList.first).to eq(user_list)
+ end
+
+ it 'returns not found when trying to create a gitlabUserList strategy with an invalid user list id' do
+ put_request(new_version_flag, strategies_attributes: [{
+ name: 'gitlabUserList',
+ parameters: {},
+ user_list_id: 1
+ }])
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'updates an existing strategy' do
+ strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'default', parameters: {})
+
+ put_request(new_version_flag, strategies_attributes: [{
+ id: strategy.id,
+ name: 'userWithId',
+ parameters: { userIds: 'user2,user3' }
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies']).to eq([{
+ 'id' => strategy.id,
+ 'name' => 'userWithId',
+ 'parameters' => { 'userIds' => 'user2,user3' },
+ 'scopes' => []
+ }])
+ end
+
+ it 'updates an existing scope' do
+ strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'default', parameters: {})
+ scope = create(:operations_scope, strategy: strategy, environment_scope: 'staging')
+
+ put_request(new_version_flag, strategies_attributes: [{
+ id: strategy.id,
+ scopes_attributes: [{
+ id: scope.id,
+ environment_scope: 'sandbox'
+ }]
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies'].first['scopes']).to eq([{
+ 'id' => scope.id,
+ 'environment_scope' => 'sandbox'
+ }])
+ end
+
+ it 'deletes an existing strategy' do
+ strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'default', parameters: {})
+
+ put_request(new_version_flag, strategies_attributes: [{
+ id: strategy.id,
+ _destroy: true
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies']).to eq([])
+ end
+
+ it 'deletes an existing scope' do
+ strategy = create(:operations_strategy, feature_flag: new_version_flag, name: 'default', parameters: {})
+ scope = create(:operations_scope, strategy: strategy, environment_scope: 'staging')
+
+ put_request(new_version_flag, strategies_attributes: [{
+ id: strategy.id,
+ scopes_attributes: [{
+ id: scope.id,
+ _destroy: true
+ }]
+ }])
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['strategies'].first['scopes']).to eq([])
+ end
+
+ it 'does not update the flag if version 2 flags are disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ put_request(new_version_flag, { name: 'some-other-name' })
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(new_version_flag.reload.name).to eq('new-feature')
+ end
+
+ it 'updates the flag when legacy feature flags are set to be read only' do
+ stub_feature_flags(feature_flags_legacy_read_only: true)
+
+ put_request(new_version_flag, name: 'some-other-name')
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(new_version_flag.reload.name).to eq('some-other-name')
+ end
+ end
+ end
+
+ private
+
+ def view_params
+ { namespace_id: project.namespace, project_id: project }
+ end
+end
diff --git a/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb b/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb
new file mode 100644
index 00000000000..e0d1d3765b2
--- /dev/null
+++ b/spec/controllers/projects/feature_flags_user_lists_controller_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::FeatureFlagsUserListsController do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+
+ before_all do
+ project.add_reporter(reporter)
+ project.add_developer(developer)
+ end
+
+ def request_params(extra_params = {})
+ { namespace_id: project.namespace, project_id: project }.merge(extra_params)
+ end
+
+ describe 'GET #new' do
+ it 'redirects when the user is unauthenticated' do
+ get(:new, params: request_params)
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+
+ it 'returns not found if the user does not belong to the project' do
+ user = create(:user)
+ sign_in(user)
+
+ get(:new, params: request_params)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns not found for a reporter' do
+ sign_in(reporter)
+
+ get(:new, params: request_params)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'renders the new page for a developer' do
+ sign_in(developer)
+
+ get(:new, params: request_params)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ describe 'GET #edit' do
+ before do
+ sign_in(developer)
+ end
+
+ it 'renders the edit page for a developer' do
+ list = create(:operations_feature_flag_user_list, project: project)
+
+ get(:edit, params: request_params(iid: list.iid))
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns not found with an iid that does not exist' do
+ list = create(:operations_feature_flag_user_list, project: project)
+
+ get(:edit, params: request_params(iid: list.iid + 1))
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns not found for a list belonging to a another project' do
+ other_project = create(:project)
+ list = create(:operations_feature_flag_user_list, project: other_project)
+
+ get(:edit, params: request_params(iid: list.iid))
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'GET #show' do
+ before do
+ sign_in(developer)
+ end
+
+ it 'renders the page for a developer' do
+ list = create(:operations_feature_flag_user_list, project: project)
+
+ get(:show, params: request_params(iid: list.iid))
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns not found with an iid that does not exist' do
+ list = create(:operations_feature_flag_user_list, project: project)
+
+ get(:show, params: request_params(iid: list.iid + 1))
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ it 'returns not found for a list belonging to a another project' do
+ other_project = create(:project)
+ list = create(:operations_feature_flag_user_list, project: other_project)
+
+ get(:show, params: request_params(iid: list.iid))
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index 321e214df1c..ff78b9e608f 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -834,7 +834,7 @@ RSpec.describe 'GFM autocomplete', :js do
end
def start_and_cancel_discussion
- click_button('Reply')
+ click_button('Reply...')
fill_in('note_note', with: 'Whoops!')
diff --git a/spec/features/merge_request/batch_comments_spec.rb b/spec/features/merge_request/batch_comments_spec.rb
index f1f04c47bd8..c8fc23bebf9 100644
--- a/spec/features/merge_request/batch_comments_spec.rb
+++ b/spec/features/merge_request/batch_comments_spec.rb
@@ -223,7 +223,7 @@ end
def write_reply_to_discussion(button_text: 'Start a review', text: 'Line is wrong', resolve: false, unresolve: false)
page.within(first('.diff-files-holder .discussion-reply-holder')) do
- click_button('Reply')
+ click_button('Reply...')
fill_in('note_note', with: text)
diff --git a/spec/features/merge_request/user_posts_diff_notes_spec.rb b/spec/features/merge_request/user_posts_diff_notes_spec.rb
index d305359e022..9556142ecb8 100644
--- a/spec/features/merge_request/user_posts_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_posts_diff_notes_spec.rb
@@ -186,7 +186,7 @@ RSpec.describe 'Merge request > User posts diff notes', :js do
it 'adds as discussion' do
should_allow_commenting(find('[id="6eb14e00385d2fb284765eb1cd8d420d33d63fc9_22_22"]'), asset_form_reset: false)
expect(page).to have_css('.notes_holder .note.note-discussion', count: 1)
- expect(page).to have_button('Reply')
+ expect(page).to have_button('Reply...')
end
end
end
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index d546c602d96..cd06886169d 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -146,7 +146,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'allows user to comment' do
page.within '.diff-content' do
- click_button 'Reply'
+ click_button 'Reply...'
find(".js-unresolve-checkbox").set false
find('.js-note-text').set 'testing'
@@ -176,7 +176,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'allows user to comment & unresolve thread' do
page.within '.diff-content' do
- click_button 'Reply'
+ click_button 'Reply...'
find('.js-note-text').set 'testing'
@@ -205,7 +205,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'allows user to comment & resolve thread' do
page.within '.diff-content' do
- click_button 'Reply'
+ click_button 'Reply...'
find('.js-note-text').set 'testing'
@@ -438,7 +438,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
it 'allows user to comment & resolve thread' do
page.within '.diff-content' do
- click_button 'Reply'
+ click_button 'Reply...'
find('.js-note-text').set 'testing'
@@ -457,7 +457,7 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
page.within '.diff-content' do
click_button 'Resolve thread'
- click_button 'Reply'
+ click_button 'Reply...'
find('.js-note-text').set 'testing'
diff --git a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
index 8d492708f2c..d15d5b3bc73 100644
--- a/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
+++ b/spec/features/merge_request/user_sees_avatar_on_diff_notes_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do
end
it 'does not render avatars after commenting on discussion tab' do
- click_button 'Reply'
+ click_button 'Reply...'
page.within('.js-discussion-note-form') do
find('.note-textarea').native.send_keys('Test comment')
@@ -132,7 +132,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do
end
it 'adds avatar when commenting' do
- click_button 'Reply'
+ click_button 'Reply...'
page.within '.js-discussion-note-form' do
find('.js-note-text').native.send_keys('Test')
@@ -151,7 +151,7 @@ RSpec.describe 'Merge request > User sees avatars on diff notes', :js do
it 'adds multiple comments' do
3.times do
- click_button 'Reply'
+ click_button 'Reply...'
page.within '.js-discussion-note-form' do
find('.js-note-text').native.send_keys('Test')
diff --git a/spec/features/merge_request/user_sees_discussions_spec.rb b/spec/features/merge_request/user_sees_discussions_spec.rb
index 86e4b58b347..289c861739f 100644
--- a/spec/features/merge_request/user_sees_discussions_spec.rb
+++ b/spec/features/merge_request/user_sees_discussions_spec.rb
@@ -60,7 +60,7 @@ RSpec.describe 'Merge request > User sees threads', :js do
it 'can be replied to' do
within(".discussion[data-discussion-id='#{discussion_id}']") do
- click_button 'Reply'
+ click_button 'Reply...'
fill_in 'note[note]', with: 'Test!'
click_button 'Comment'
diff --git a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
index 44da911441a..20c45a1d652 100644
--- a/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
+++ b/spec/features/merge_request/user_sees_notes_from_forked_project_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe 'Merge request > User sees notes from forked project', :js do
expect(page).to have_content('A commit comment')
page.within('.discussion-notes') do
- find('.js-vue-discussion-reply').click
+ find('.btn-text-field').click
scroll_to(page.find('#note_note', visible: false))
find('#note_note').send_keys('A reply comment')
find('.js-comment-button').click
diff --git a/spec/features/projects/commit/builds_spec.rb b/spec/features/projects/commit/builds_spec.rb
index f97abc5bd8b..00ec9d49a10 100644
--- a/spec/features/projects/commit/builds_spec.rb
+++ b/spec/features/projects/commit/builds_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe 'project commit pipelines', :js do
context 'when no builds triggered yet' do
it 'shows the ID of the first pipeline' do
- page.within('.table-holder') do
+ page.within('.pipelines .ci-table') do
expect(page).to have_content project.ci_pipelines[0].id # pipeline ids
end
end
diff --git a/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb b/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb
new file mode 100644
index 00000000000..2a81c706525
--- /dev/null
+++ b/spec/features/projects/feature_flag_user_lists/user_deletes_feature_flag_user_list_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User deletes feature flag user list', :js do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user) }
+
+ before do
+ project.add_developer(developer)
+ sign_in(developer)
+ end
+
+ context 'with a list' do
+ before do
+ create(:operations_feature_flag_user_list, project: project, name: 'My List')
+ end
+
+ it 'deletes the list' do
+ visit(project_feature_flags_path(project, scope: 'userLists'))
+
+ delete_user_list_button.click
+ delete_user_list_modal_confirmation_button.click
+
+ expect(page).to have_text('Lists 0')
+ end
+ end
+
+ context 'with a list that is in use' do
+ before do
+ list = create(:operations_feature_flag_user_list, project: project, name: 'My List')
+ feature_flag = create(:operations_feature_flag, :new_version_flag, project: project)
+ create(:operations_strategy, feature_flag: feature_flag, name: 'gitlabUserList', user_list: list)
+ end
+
+ it 'does not delete the list' do
+ visit(project_feature_flags_path(project, scope: 'userLists'))
+
+ delete_user_list_button.click
+ delete_user_list_modal_confirmation_button.click
+
+ expect(page).to have_text('User list is associated with a strategy')
+ expect(page).to have_text('Lists 1')
+ expect(page).to have_text('My List')
+
+ alert_dismiss_button.click
+
+ expect(page).not_to have_text('User list is associated with a strategy')
+ end
+ end
+
+ def delete_user_list_button
+ find("button[data-testid='delete-user-list']")
+ end
+
+ def delete_user_list_modal_confirmation_button
+ find("button[data-testid='modal-confirm']")
+ end
+
+ def alert_dismiss_button
+ find("div[data-testid='serverErrors'] button")
+ end
+end
diff --git a/spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb b/spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb
new file mode 100644
index 00000000000..b37c2780827
--- /dev/null
+++ b/spec/features/projects/feature_flag_user_lists/user_edits_feature_flag_user_list_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User edits feature flag user list', :js do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user) }
+
+ before do
+ project.add_developer(developer)
+ sign_in(developer)
+ end
+
+ it 'prefills the edit form with the list name' do
+ list = create(:operations_feature_flag_user_list, project: project, name: 'My List Name')
+
+ visit(edit_project_feature_flags_user_list_path(project, list))
+
+ expect(page).to have_field 'Name', with: 'My List Name'
+ end
+end
diff --git a/spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb b/spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb
new file mode 100644
index 00000000000..dfebe6408bd
--- /dev/null
+++ b/spec/features/projects/feature_flag_user_lists/user_sees_feature_flag_user_list_details_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User sees feature flag user list details', :js do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user) }
+
+ before do
+ project.add_developer(developer)
+ sign_in(developer)
+ end
+
+ it 'displays the list name' do
+ list = create(:operations_feature_flag_user_list, project: project, name: 'My List')
+
+ visit(project_feature_flags_user_list_path(project, list))
+
+ expect(page).to have_text('My List')
+ end
+end
diff --git a/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb
new file mode 100644
index 00000000000..830dda737b0
--- /dev/null
+++ b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User creates feature flag', :js do
+ include FeatureFlagHelpers
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, namespace: user.namespace) }
+
+ before do
+ project.add_developer(user)
+ stub_feature_flags(feature_flag_permissions: false)
+ sign_in(user)
+ end
+
+ it 'user creates a flag enabled for user ids' do
+ visit(new_project_feature_flag_path(project))
+ set_feature_flag_info('test_feature', 'Test feature')
+ within_strategy_row(1) do
+ select 'User IDs', from: 'Type'
+ fill_in 'User IDs', with: 'user1, user2'
+ environment_plus_button.click
+ environment_search_input.set('production')
+ environment_search_results.first.click
+ end
+ click_button 'Create feature flag'
+
+ expect_user_to_see_feature_flags_index_page
+ expect(page).to have_text('test_feature')
+ end
+
+ it 'user creates a flag with default environment scopes' do
+ visit(new_project_feature_flag_path(project))
+ set_feature_flag_info('test_flag', 'Test flag')
+ within_strategy_row(1) do
+ select 'All users', from: 'Type'
+ end
+ click_button 'Create feature flag'
+
+ expect_user_to_see_feature_flags_index_page
+ expect(page).to have_text('test_flag')
+
+ edit_feature_flag_button.click
+
+ within_strategy_row(1) do
+ expect(page).to have_text('All users')
+ expect(page).to have_text('All environments')
+ end
+ end
+
+ it 'removes the correct strategy when a strategy is deleted' do
+ visit(new_project_feature_flag_path(project))
+ click_button 'Add strategy'
+ within_strategy_row(1) do
+ select 'All users', from: 'Type'
+ end
+ within_strategy_row(2) do
+ select 'Percent of users', from: 'Type'
+ end
+ within_strategy_row(1) do
+ delete_strategy_button.click
+ end
+
+ within_strategy_row(1) do
+ expect(page).to have_select('Type', selected: 'Percent of users')
+ end
+ end
+
+ context 'with new version flags disabled' do
+ before do
+ stub_feature_flags(feature_flags_new_version: false)
+ end
+
+ context 'when creates without changing scopes' do
+ before do
+ visit(new_project_feature_flag_path(project))
+ set_feature_flag_info('ci_live_trace', 'For live trace')
+ click_button 'Create feature flag'
+ expect(page).to have_current_path(project_feature_flags_path(project))
+ end
+
+ it 'shows the created feature flag' do
+ within_feature_flag_row(1) do
+ expect(page.find('.feature-flag-name')).to have_content('ci_live_trace')
+ expect_status_toggle_button_to_be_checked
+
+ within_feature_flag_scopes do
+ expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
+ end
+ end
+ end
+ end
+
+ context 'when creates with disabling the default scope' do
+ before do
+ visit(new_project_feature_flag_path(project))
+ set_feature_flag_info('ci_live_trace', 'For live trace')
+
+ within_scope_row(1) do
+ within_status { find('.project-feature-toggle').click }
+ end
+
+ click_button 'Create feature flag'
+ end
+
+ it 'shows the created feature flag' do
+ within_feature_flag_row(1) do
+ expect(page.find('.feature-flag-name')).to have_content('ci_live_trace')
+ expect_status_toggle_button_to_be_checked
+
+ within_feature_flag_scopes do
+ expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(1)')).to have_content('*')
+ end
+ end
+ end
+ end
+
+ context 'when creates with an additional scope' do
+ before do
+ visit(new_project_feature_flag_path(project))
+ set_feature_flag_info('mr_train', '')
+
+ within_scope_row(2) do
+ within_environment_spec do
+ find('.js-env-search > input').set("review/*")
+ find('.js-create-button').click
+ end
+ end
+
+ within_scope_row(2) do
+ within_status { find('.project-feature-toggle').click }
+ end
+
+ click_button 'Create feature flag'
+ end
+
+ it 'shows the created feature flag' do
+ within_feature_flag_row(1) do
+ expect(page.find('.feature-flag-name')).to have_content('mr_train')
+ expect_status_toggle_button_to_be_checked
+
+ within_feature_flag_scopes do
+ expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
+ expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(2)')).to have_content('review/*')
+ end
+ end
+ end
+ end
+
+ context 'when searches an environment name for scope creation' do
+ let!(:environment) { create(:environment, name: 'production', project: project) }
+
+ before do
+ visit(new_project_feature_flag_path(project))
+ set_feature_flag_info('mr_train', '')
+
+ within_scope_row(2) do
+ within_environment_spec do
+ find('.js-env-search > input').set('prod')
+ click_button 'production'
+ end
+ end
+
+ click_button 'Create feature flag'
+ end
+
+ it 'shows the created feature flag' do
+ within_feature_flag_row(1) do
+ expect(page.find('.feature-flag-name')).to have_content('mr_train')
+ expect_status_toggle_button_to_be_checked
+
+ within_feature_flag_scopes do
+ expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
+ expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(2)')).to have_content('production')
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def set_feature_flag_info(name, description)
+ fill_in 'Name', with: name
+ fill_in 'Description', with: description
+ end
+
+ def environment_plus_button
+ find('.js-new-environments-dropdown')
+ end
+
+ def environment_search_input
+ find('.js-new-environments-dropdown input')
+ end
+
+ def environment_search_results
+ all('.js-new-environments-dropdown button.dropdown-item')
+ end
+end
diff --git a/spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb
new file mode 100644
index 00000000000..581709aacee
--- /dev/null
+++ b/spec/features/projects/feature_flags/user_deletes_feature_flag_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User deletes feature flag', :js do
+ include FeatureFlagHelpers
+
+ let(:user) { create(:user) }
+ let(:project) { create(:project, namespace: user.namespace) }
+
+ let!(:feature_flag) do
+ create_flag(project, 'ci_live_trace', false,
+ description: 'For live trace feature')
+ end
+
+ before do
+ project.add_developer(user)
+ stub_feature_flags(feature_flag_permissions: false)
+ sign_in(user)
+
+ visit(project_feature_flags_path(project))
+
+ find('.js-feature-flag-delete-button').click
+ click_button('Delete feature flag')
+ expect(page).to have_current_path(project_feature_flags_path(project))
+ end
+
+ it 'user does not see feature flag' do
+ expect(page).to have_no_content('ci_live_trace')
+ end
+end
diff --git a/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb b/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb
new file mode 100644
index 00000000000..750f4dc5ef4
--- /dev/null
+++ b/spec/features/projects/feature_flags/user_sees_feature_flag_list_spec.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User sees feature flag list', :js do
+ include FeatureFlagHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, namespace: user.namespace) }
+
+ before_all do
+ project.add_developer(user)
+ end
+
+ before do
+ sign_in(user)
+ end
+
+ context 'with legacy feature flags' do
+ before do
+ create_flag(project, 'ci_live_trace', false).tap do |feature_flag|
+ create_scope(feature_flag, 'review/*', true)
+ end
+ create_flag(project, 'drop_legacy_artifacts', false)
+ create_flag(project, 'mr_train', true).tap do |feature_flag|
+ create_scope(feature_flag, 'production', false)
+ end
+ stub_feature_flags(feature_flags_legacy_read_only_override: false)
+ end
+
+ it 'user sees the first flag' do
+ visit(project_feature_flags_path(project))
+
+ within_feature_flag_row(1) do
+ expect(page.find('.js-feature-flag-id')).to have_content('^1')
+ expect(page.find('.feature-flag-name')).to have_content('ci_live_trace')
+ expect_status_toggle_button_not_to_be_checked
+
+ within_feature_flag_scopes do
+ expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(1)')).to have_content('*')
+ expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(2)')).to have_content('review/*')
+ end
+ end
+ end
+
+ it 'user sees the second flag' do
+ visit(project_feature_flags_path(project))
+
+ within_feature_flag_row(2) do
+ expect(page.find('.js-feature-flag-id')).to have_content('^2')
+ expect(page.find('.feature-flag-name')).to have_content('drop_legacy_artifacts')
+ expect_status_toggle_button_not_to_be_checked
+
+ within_feature_flag_scopes do
+ expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(1)')).to have_content('*')
+ end
+ end
+ end
+
+ it 'user sees the third flag' do
+ visit(project_feature_flags_path(project))
+
+ within_feature_flag_row(3) do
+ expect(page.find('.js-feature-flag-id')).to have_content('^3')
+ expect(page.find('.feature-flag-name')).to have_content('mr_train')
+ expect_status_toggle_button_to_be_checked
+
+ within_feature_flag_scopes do
+ expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
+ expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(2)')).to have_content('production')
+ end
+ end
+ end
+
+ it 'user sees the status toggle disabled' do
+ visit(project_feature_flags_path(project))
+
+ within_feature_flag_row(1) do
+ expect_status_toggle_button_to_be_disabled
+ end
+ end
+
+ context 'when legacy feature flags are not read-only' do
+ before do
+ stub_feature_flags(feature_flags_legacy_read_only: false)
+ end
+
+ it 'user updates the status toggle' do
+ visit(project_feature_flags_path(project))
+
+ within_feature_flag_row(1) do
+ status_toggle_button.click
+
+ expect_status_toggle_button_to_be_checked
+ end
+ end
+ end
+
+ context 'when legacy feature flags are read-only but the override is active for a project' do
+ before do
+ stub_feature_flags(
+ feature_flags_legacy_read_only: true,
+ feature_flags_legacy_read_only_override: project
+ )
+ end
+
+ it 'user updates the status toggle' do
+ visit(project_feature_flags_path(project))
+
+ within_feature_flag_row(1) do
+ status_toggle_button.click
+
+ expect_status_toggle_button_to_be_checked
+ end
+ end
+ end
+ end
+
+ context 'with new version flags' do
+ before do
+ create(:operations_feature_flag, :new_version_flag, project: project,
+ name: 'my_flag', active: false)
+ end
+
+ it 'user updates the status toggle' do
+ visit(project_feature_flags_path(project))
+
+ within_feature_flag_row(1) do
+ status_toggle_button.click
+
+ expect_status_toggle_button_to_be_checked
+ end
+ end
+ end
+
+ context 'when there are no feature flags' do
+ before do
+ visit(project_feature_flags_path(project))
+ end
+
+ it 'shows empty page' do
+ expect(page).to have_text 'Get started with feature flags'
+ expect(page).to have_selector('.btn-success', text: 'New feature flag')
+ expect(page).to have_selector('[data-qa-selector="configure_feature_flags_button"]', text: 'Configure')
+ end
+ end
+end
diff --git a/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb
new file mode 100644
index 00000000000..bc2d63e1953
--- /dev/null
+++ b/spec/features/projects/feature_flags/user_updates_feature_flag_spec.rb
@@ -0,0 +1,195 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User updates feature flag', :js do
+ include FeatureFlagHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, namespace: user.namespace) }
+
+ before_all do
+ project.add_developer(user)
+ end
+
+ before do
+ stub_feature_flags(
+ feature_flag_permissions: false,
+ feature_flags_legacy_read_only_override: false
+ )
+ sign_in(user)
+ end
+
+ context 'with a new version feature flag' do
+ let!(:feature_flag) do
+ create_flag(project, 'test_flag', false, version: Operations::FeatureFlag.versions['new_version_flag'],
+ description: 'For testing')
+ end
+
+ let!(:strategy) do
+ create(:operations_strategy, feature_flag: feature_flag,
+ name: 'default', parameters: {})
+ end
+
+ let!(:scope) do
+ create(:operations_scope, strategy: strategy, environment_scope: '*')
+ end
+
+ it 'user adds a second strategy' do
+ visit(edit_project_feature_flag_path(project, feature_flag))
+
+ wait_for_requests
+
+ click_button 'Add strategy'
+ within_strategy_row(2) do
+ select 'Percent of users', from: 'Type'
+ fill_in 'Percentage', with: '15'
+ end
+ click_button 'Save changes'
+
+ edit_feature_flag_button.click
+
+ within_strategy_row(1) do
+ expect(page).to have_text 'All users'
+ expect(page).to have_text 'All environments'
+ end
+ within_strategy_row(2) do
+ expect(page).to have_text 'Percent of users'
+ expect(page).to have_field 'Percentage', with: '15'
+ expect(page).to have_text 'All environments'
+ end
+ end
+
+ it 'user toggles the flag on' do
+ visit(edit_project_feature_flag_path(project, feature_flag))
+ status_toggle_button.click
+ click_button 'Save changes'
+
+ within_feature_flag_row(1) do
+ expect_status_toggle_button_to_be_checked
+ end
+ end
+ end
+
+ context 'with a legacy feature flag' do
+ let!(:feature_flag) do
+ create_flag(project, 'ci_live_trace', true,
+ description: 'For live trace feature')
+ end
+
+ let!(:scope) { create_scope(feature_flag, 'review/*', true) }
+
+ context 'when legacy flags are editable' do
+ before do
+ stub_feature_flags(feature_flags_legacy_read_only: false)
+
+ visit(edit_project_feature_flag_path(project, feature_flag))
+ end
+
+ it 'user sees persisted default scope' do
+ within_scope_row(1) do
+ within_environment_spec do
+ expect(page).to have_content('* (All Environments)')
+ end
+
+ within_status do
+ expect(find('.project-feature-toggle')['aria-label'])
+ .to eq('Toggle Status: ON')
+ end
+ end
+ end
+
+ context 'when user updates the status of a scope' do
+ before do
+ within_scope_row(2) do
+ within_status { find('.project-feature-toggle').click }
+ end
+
+ click_button 'Save changes'
+ expect(page).to have_current_path(project_feature_flags_path(project))
+ end
+
+ it 'shows the updated feature flag' do
+ within_feature_flag_row(1) do
+ expect(page.find('.feature-flag-name')).to have_content('ci_live_trace')
+ expect_status_toggle_button_to_be_checked
+
+ within_feature_flag_scopes do
+ expect(page.find('.badge:nth-child(1)')).to have_content('*')
+ expect(page.find('.badge:nth-child(1)')['class']).to include('badge-info')
+ expect(page.find('.badge:nth-child(2)')).to have_content('review/*')
+ expect(page.find('.badge:nth-child(2)')['class']).to include('badge-muted')
+ end
+ end
+ end
+ end
+
+ context 'when user adds a new scope' do
+ before do
+ within_scope_row(3) do
+ within_environment_spec do
+ find('.js-env-search > input').set('production')
+ find('.js-create-button').click
+ end
+ end
+
+ click_button 'Save changes'
+ expect(page).to have_current_path(project_feature_flags_path(project))
+ end
+
+ it 'shows the newly created scope' do
+ within_feature_flag_row(1) do
+ within_feature_flag_scopes do
+ expect(page.find('.badge:nth-child(3)')).to have_content('production')
+ expect(page.find('.badge:nth-child(3)')['class']).to include('badge-muted')
+ end
+ end
+ end
+ end
+
+ context 'when user deletes a scope' do
+ before do
+ within_scope_row(2) do
+ within_delete { find('.js-delete-scope').click }
+ end
+
+ click_button 'Save changes'
+ expect(page).to have_current_path(project_feature_flags_path(project))
+ end
+
+ it 'shows the updated feature flag' do
+ within_feature_flag_row(1) do
+ within_feature_flag_scopes do
+ expect(page).to have_css('.badge:nth-child(1)')
+ expect(page).not_to have_css('.badge:nth-child(2)')
+ end
+ end
+ end
+ end
+ end
+
+ context 'when legacy flags are read-only' do
+ it 'the user cannot edit the flag' do
+ visit(edit_project_feature_flag_path(project, feature_flag))
+
+ expect(page).to have_text 'This feature flag is read-only, and it will be removed in 14.0.'
+ expect(page).to have_css('button.js-ff-submit.disabled')
+ end
+ end
+
+ context 'when legacy flags are read-only, but the override is active for one project' do
+ it 'the user can edit the flag' do
+ stub_feature_flags(feature_flags_legacy_read_only_override: project)
+
+ visit(edit_project_feature_flag_path(project, feature_flag))
+ status_toggle_button.click
+ click_button 'Save changes'
+
+ expect(page).to have_current_path(project_feature_flags_path(project))
+ within_feature_flag_row(1) do
+ expect_status_toggle_button_not_to_be_checked
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/releases/user_views_edit_release_spec.rb b/spec/features/projects/releases/user_views_edit_release_spec.rb
index 2028902f10f..9115a135aeb 100644
--- a/spec/features/projects/releases/user_views_edit_release_spec.rb
+++ b/spec/features/projects/releases/user_views_edit_release_spec.rb
@@ -39,7 +39,7 @@ RSpec.describe 'User edits Release', :js do
it 'renders the edit Release form' do
expect(page).to have_content('Releases are based on Git tags. We recommend tags that use semantic versioning, for example v1.0, v2.0-pre.')
- expect(find_field('Tag name', { disabled: true }).value).to eq(release.tag)
+ expect(find_field('Tag name', disabled: true).value).to eq(release.tag)
expect(find_field('Release title').value).to eq(release.name)
expect(find_field('Release notes').value).to eq(release.description)
diff --git a/spec/fixtures/api/schemas/feature_flag.json b/spec/fixtures/api/schemas/feature_flag.json
new file mode 100644
index 00000000000..5f8cedc1132
--- /dev/null
+++ b/spec/fixtures/api/schemas/feature_flag.json
@@ -0,0 +1,23 @@
+{
+ "type": "object",
+ "required" : [
+ "id",
+ "name"
+ ],
+ "properties" : {
+ "id": { "type": "integer" },
+ "iid": { "type": ["integer", "null"] },
+ "version": { "type": "string" },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "name": { "type": "string" },
+ "active": { "type": "boolean" },
+ "description": { "type": ["string", "null"] },
+ "edit_path": { "type": ["string", "null"] },
+ "update_path": { "type": ["string", "null"] },
+ "destroy_path": { "type": ["string", "null"] },
+ "scopes": { "type": "array", "items": { "$ref": "feature_flag_scope.json" } },
+ "strategies": { "type": "array", "items": { "$ref": "feature_flag_strategy.json" } }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/feature_flag_scope.json b/spec/fixtures/api/schemas/feature_flag_scope.json
new file mode 100644
index 00000000000..07c5eed532a
--- /dev/null
+++ b/spec/fixtures/api/schemas/feature_flag_scope.json
@@ -0,0 +1,18 @@
+{
+ "type": "object",
+ "required" : [
+ "id",
+ "environment_scope",
+ "active"
+ ],
+ "properties" : {
+ "id": { "type": "integer" },
+ "environment_scope": { "type": "string" },
+ "active": { "type": "boolean" },
+ "percentage": { "type": ["integer", "null"] },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "strategies": { "type": "array", "items": { "$ref": "feature_flag_strategy.json" } }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/feature_flag_strategy.json b/spec/fixtures/api/schemas/feature_flag_strategy.json
new file mode 100644
index 00000000000..5a2777dc8ea
--- /dev/null
+++ b/spec/fixtures/api/schemas/feature_flag_strategy.json
@@ -0,0 +1,13 @@
+{
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": { "type": "string" },
+ "parameters": {
+ "type": "object"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/feature_flags.json b/spec/fixtures/api/schemas/feature_flags.json
new file mode 100644
index 00000000000..fc5e668c8b0
--- /dev/null
+++ b/spec/fixtures/api/schemas/feature_flags.json
@@ -0,0 +1,13 @@
+{
+ "required": ["feature_flags", "count"],
+ "feature_flags": { "type": "array", "items": { "$ref": "feature_flag.json" } },
+ "count": {
+ "type": "object",
+ "properties" : {
+ "all": { "type": "integer" },
+ "enabled": { "type": "integer" },
+ "disabled": { "type": "integer" }
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag.json
new file mode 100644
index 00000000000..0f304e9ee73
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag.json
@@ -0,0 +1,15 @@
+{
+ "type": "object",
+ "required": ["name"],
+ "properties": {
+ "name": { "type": "string" },
+ "description": { "type": ["string", "null"] },
+ "active": {"type": "boolean" },
+ "version": { "type": "string" },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "scopes": { "type": "array", "items": { "$ref": "feature_flag_scope.json" } },
+ "strategies": { "type": "array", "items": { "$ref": "operations/strategy.json" } }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag_detailed_scopes.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag_detailed_scopes.json
new file mode 100644
index 00000000000..a11ae5705cc
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag_detailed_scopes.json
@@ -0,0 +1,22 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required" : [
+ "name",
+ "id",
+ "environment_scope",
+ "active"
+ ],
+ "properties" : {
+ "name": { "type": "string" },
+ "id": { "type": "integer" },
+ "environment_scope": { "type": "string" },
+ "active": { "type": "boolean" },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "strategies": { "type": "array", "items": { "$ref": "feature_flag_strategy.json" } }
+ },
+ "additionalProperties": false
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag_scope.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag_scope.json
new file mode 100644
index 00000000000..18402af482e
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag_scope.json
@@ -0,0 +1,17 @@
+{
+ "type": "object",
+ "required": [
+ "id",
+ "environment_scope",
+ "active"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "environment_scope": { "type": "string" },
+ "active": { "type": "boolean" },
+ "created_at": { "type": "date" },
+ "updated_at": { "type": "date" },
+ "strategies": { "type": "array", "items": { "$ref": "feature_flag_strategy.json" } }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag_scopes.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag_scopes.json
new file mode 100644
index 00000000000..b1a7021db8b
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag_scopes.json
@@ -0,0 +1,9 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "$ref": "./feature_flag_scope.json"
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flag_strategy.json b/spec/fixtures/api/schemas/public_api/v4/feature_flag_strategy.json
new file mode 100644
index 00000000000..5a2777dc8ea
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/feature_flag_strategy.json
@@ -0,0 +1,13 @@
+{
+ "type": "object",
+ "required": [
+ "name"
+ ],
+ "properties": {
+ "name": { "type": "string" },
+ "parameters": {
+ "type": "object"
+ }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/feature_flags.json b/spec/fixtures/api/schemas/public_api/v4/feature_flags.json
new file mode 100644
index 00000000000..c19df0443d9
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/feature_flags.json
@@ -0,0 +1,9 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "$ref": "./feature_flag.json"
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/operations/scope.json b/spec/fixtures/api/schemas/public_api/v4/operations/scope.json
new file mode 100644
index 00000000000..e2b6d1ad6f1
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/operations/scope.json
@@ -0,0 +1,9 @@
+{
+ "type": "object",
+ "required": ["environment_scope"],
+ "properties": {
+ "id": { "type": "integer" },
+ "environment_scope": { "type": "string" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json b/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json
new file mode 100644
index 00000000000..f572b1a4f9b
--- /dev/null
+++ b/spec/fixtures/api/schemas/public_api/v4/operations/strategy.json
@@ -0,0 +1,14 @@
+{
+ "type": "object",
+ "required": [
+ "name",
+ "parameters"
+ ],
+ "properties": {
+ "id": { "type": "integer" },
+ "name": { "type": "string" },
+ "parameters": { "type": "object" },
+ "scopes": { "type": "array", "items": { "$ref": "scope.json" } }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/lib/backup/personal_snippet_repo.bundle b/spec/fixtures/lib/backup/personal_snippet_repo.bundle
new file mode 100644
index 00000000000..452cf6a19fe
--- /dev/null
+++ b/spec/fixtures/lib/backup/personal_snippet_repo.bundle
Binary files differ
diff --git a/spec/fixtures/lib/backup/project_snippet_repo.bundle b/spec/fixtures/lib/backup/project_snippet_repo.bundle
new file mode 100644
index 00000000000..c05f8ec9495
--- /dev/null
+++ b/spec/fixtures/lib/backup/project_snippet_repo.bundle
Binary files differ
diff --git a/spec/frontend/ide/lib/languages/hcl_spec.js b/spec/frontend/ide/lib/languages/hcl_spec.js
new file mode 100644
index 00000000000..a39673a3225
--- /dev/null
+++ b/spec/frontend/ide/lib/languages/hcl_spec.js
@@ -0,0 +1,290 @@
+import { editor } from 'monaco-editor';
+import { registerLanguages } from '~/ide/utils';
+import hcl from '~/ide/lib/languages/hcl';
+
+describe('tokenization for .tf files', () => {
+ beforeEach(() => {
+ registerLanguages(hcl);
+ });
+
+ it.each([
+ ['// Foo', [[{ language: 'hcl', offset: 0, type: 'comment.hcl' }]]],
+ ['/* Bar */', [[{ language: 'hcl', offset: 0, type: 'comment.hcl' }]]],
+ ['/*', [[{ language: 'hcl', offset: 0, type: 'comment.hcl' }]]],
+ [
+ 'foo = "bar"',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'string.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'variable "foo" {',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'type.hcl' },
+ { language: 'hcl', offset: 8, type: '' },
+ { language: 'hcl', offset: 9, type: 'string.hcl' },
+ { language: 'hcl', offset: 14, type: '' },
+ { language: 'hcl', offset: 15, type: 'delimiter.curly.hcl' },
+ ],
+ ],
+ ],
+ [
+ // eslint-disable-next-line no-template-curly-in-string
+ ' api_key = "${var.foo}"',
+ [
+ [
+ { language: 'hcl', offset: 0, type: '' },
+ { language: 'hcl', offset: 2, type: 'variable.hcl' },
+ { language: 'hcl', offset: 9, type: '' },
+ { language: 'hcl', offset: 10, type: 'operator.hcl' },
+ { language: 'hcl', offset: 11, type: '' },
+ { language: 'hcl', offset: 12, type: 'string.hcl' },
+ { language: 'hcl', offset: 13, type: 'delimiter.hcl' },
+ { language: 'hcl', offset: 15, type: 'keyword.var.hcl' },
+ { language: 'hcl', offset: 18, type: 'delimiter.hcl' },
+ { language: 'hcl', offset: 19, type: 'variable.hcl' },
+ { language: 'hcl', offset: 22, type: 'delimiter.hcl' },
+ { language: 'hcl', offset: 23, type: 'string.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'resource "aws_security_group" "firewall" {',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'type.hcl' },
+ { language: 'hcl', offset: 8, type: '' },
+ { language: 'hcl', offset: 9, type: 'string.hcl' },
+ { language: 'hcl', offset: 29, type: '' },
+ { language: 'hcl', offset: 30, type: 'string.hcl' },
+ { language: 'hcl', offset: 40, type: '' },
+ { language: 'hcl', offset: 41, type: 'delimiter.curly.hcl' },
+ ],
+ ],
+ ],
+ [
+ ' network_interface {',
+ [
+ [
+ { language: 'hcl', offset: 0, type: '' },
+ { language: 'hcl', offset: 2, type: 'identifier.hcl' },
+ { language: 'hcl', offset: 20, type: 'delimiter.curly.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'foo = [1, 2, "foo"]',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'delimiter.square.hcl' },
+ { language: 'hcl', offset: 7, type: 'number.hcl' },
+ { language: 'hcl', offset: 8, type: 'delimiter.hcl' },
+ { language: 'hcl', offset: 9, type: '' },
+ { language: 'hcl', offset: 10, type: 'number.hcl' },
+ { language: 'hcl', offset: 11, type: 'delimiter.hcl' },
+ { language: 'hcl', offset: 12, type: '' },
+ { language: 'hcl', offset: 13, type: 'string.hcl' },
+ { language: 'hcl', offset: 18, type: 'delimiter.square.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'resource "foo" "bar" {}',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'type.hcl' },
+ { language: 'hcl', offset: 8, type: '' },
+ { language: 'hcl', offset: 9, type: 'string.hcl' },
+ { language: 'hcl', offset: 14, type: '' },
+ { language: 'hcl', offset: 15, type: 'string.hcl' },
+ { language: 'hcl', offset: 20, type: '' },
+ { language: 'hcl', offset: 21, type: 'delimiter.curly.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'foo = "bar"',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'string.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'bar = 7',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'number.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'baz = [1,2,3]',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'delimiter.square.hcl' },
+ { language: 'hcl', offset: 7, type: 'number.hcl' },
+ { language: 'hcl', offset: 8, type: 'delimiter.hcl' },
+ { language: 'hcl', offset: 9, type: 'number.hcl' },
+ { language: 'hcl', offset: 10, type: 'delimiter.hcl' },
+ { language: 'hcl', offset: 11, type: 'number.hcl' },
+ { language: 'hcl', offset: 12, type: 'delimiter.square.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'foo = -12',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'operator.hcl' },
+ { language: 'hcl', offset: 7, type: 'number.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'bar = 3.14159',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'number.float.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'foo = true',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'keyword.true.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'foo = false',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'keyword.false.hcl' },
+ ],
+ ],
+ ],
+ [
+ // eslint-disable-next-line no-template-curly-in-string
+ 'bar = "${file("bing/bong.txt")}"',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'operator.hcl' },
+ { language: 'hcl', offset: 5, type: '' },
+ { language: 'hcl', offset: 6, type: 'string.hcl' },
+ { language: 'hcl', offset: 7, type: 'delimiter.hcl' },
+ { language: 'hcl', offset: 9, type: 'type.hcl' },
+ { language: 'hcl', offset: 13, type: 'delimiter.parenthesis.hcl' },
+ { language: 'hcl', offset: 14, type: 'string.hcl' },
+ { language: 'hcl', offset: 29, type: 'delimiter.parenthesis.hcl' },
+ { language: 'hcl', offset: 30, type: 'delimiter.hcl' },
+ { language: 'hcl', offset: 31, type: 'string.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'a = 1e-10',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 1, type: '' },
+ { language: 'hcl', offset: 2, type: 'operator.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'number.float.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'b = 1e+10',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 1, type: '' },
+ { language: 'hcl', offset: 2, type: 'operator.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'number.float.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'c = 1e10',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 1, type: '' },
+ { language: 'hcl', offset: 2, type: 'operator.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'number.float.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'd = 1.2e-10',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 1, type: '' },
+ { language: 'hcl', offset: 2, type: 'operator.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'number.float.hcl' },
+ ],
+ ],
+ ],
+ [
+ 'e = 1.2e+10',
+ [
+ [
+ { language: 'hcl', offset: 0, type: 'variable.hcl' },
+ { language: 'hcl', offset: 1, type: '' },
+ { language: 'hcl', offset: 2, type: 'operator.hcl' },
+ { language: 'hcl', offset: 3, type: '' },
+ { language: 'hcl', offset: 4, type: 'number.float.hcl' },
+ ],
+ ],
+ ],
+ ])('%s', (string, tokens) => {
+ expect(editor.tokenize(string, 'hcl')).toEqual(tokens);
+ });
+});
diff --git a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
index c9c33cf3af1..b7b7ec08867 100644
--- a/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
+++ b/spec/frontend/notes/components/discussion_reply_placeholder_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import { GlButton } from '@gitlab/ui';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
const buttonText = 'Test Button Text';
@@ -7,7 +6,7 @@ const buttonText = 'Test Button Text';
describe('ReplyPlaceholder', () => {
let wrapper;
- const findButton = () => wrapper.find(GlButton);
+ const findButton = () => wrapper.find({ ref: 'button' });
beforeEach(() => {
wrapper = shallowMount(ReplyPlaceholder, {
@@ -21,8 +20,8 @@ describe('ReplyPlaceholder', () => {
wrapper.destroy();
});
- it('should emit a onClick event on button click', () => {
- findButton().vm.$emit('click');
+ it('emits onClick event on button click', () => {
+ findButton().trigger('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted()).toEqual({
diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js
index cb164a426c8..378d259ad3f 100644
--- a/spec/frontend/packages/details/store/getters_spec.js
+++ b/spec/frontend/packages/details/store/getters_spec.js
@@ -31,7 +31,6 @@ import {
registryUrl,
pypiSetupCommandStr,
} from '../mock_data';
-import { generateConanRecipe } from '~/packages/details/utils';
import { NpmManager } from '~/packages/details/constants';
describe('Getters PackageDetails Store', () => {
@@ -53,8 +52,7 @@ describe('Getters PackageDetails Store', () => {
};
};
- const recipe = generateConanRecipe(conanPackage);
- const conanInstallationCommandStr = `conan install ${recipe} --remote=gitlab`;
+ const conanInstallationCommandStr = `conan install ${conanPackage.name} --remote=gitlab`;
const conanSetupCommandStr = `conan remote add gitlab ${registryUrl}`;
const mavenCommandStr = generateMavenCommand(packageWithoutBuildInfo.maven_metadatum);
diff --git a/spec/frontend/packages/details/utils_spec.js b/spec/frontend/packages/details/utils_spec.js
deleted file mode 100644
index 087888016ee..00000000000
--- a/spec/frontend/packages/details/utils_spec.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import { generateConanRecipe } from '~/packages/details/utils';
-import { conanPackage } from '../mock_data';
-
-describe('Package detail utils', () => {
- describe('generateConanRecipe', () => {
- it('correctly generates the conan recipe', () => {
- const recipe = generateConanRecipe(conanPackage);
-
- expect(recipe).toEqual(conanPackage.recipe);
- });
-
- it('returns an empty recipe when no information is supplied', () => {
- const recipe = generateConanRecipe({});
-
- expect(recipe).toEqual('/@/');
- });
-
- it('recipe returns empty strings for missing metadata', () => {
- const recipe = generateConanRecipe({ name: 'foo', version: '0.0.1' });
-
- expect(recipe).toBe('foo/0.0.1@/');
- });
- });
-});
diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js
index b95d06428ff..d7494bf85d0 100644
--- a/spec/frontend/packages/mock_data.js
+++ b/spec/frontend/packages/mock_data.js
@@ -84,15 +84,15 @@ export const conanPackage = {
package_channel: 'stable',
package_username: 'conan+conan-package',
},
+ conan_package_name: 'conan-package',
created_at: '2015-12-10',
id: 3,
- name: 'conan-package',
+ name: 'conan-package/1.0.0@conan+conan-package/stable',
project_path: 'foo/bar/baz',
projectPathName: 'foo/bar/baz',
package_files: [],
package_type: 'conan',
project_id: 1,
- recipe: 'conan-package/1.0.0@conan+conan-package/stable',
updated_at: '2015-12-10',
version: '1.0.0',
_links,
diff --git a/spec/helpers/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb
index a42be3c87fb..bcb0b5c51e7 100644
--- a/spec/helpers/user_callouts_helper_spec.rb
+++ b/spec/helpers/user_callouts_helper_spec.rb
@@ -139,4 +139,26 @@ RSpec.describe UserCalloutsHelper do
helper.render_flash_user_callout(:warning, 'foo', 'bar')
end
end
+
+ describe '.show_feature_flags_new_version?' do
+ subject { helper.show_feature_flags_new_version? }
+
+ let(:user) { create(:user) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ context 'when the feature flags new version info has not been dismissed' do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when the feature flags new version has been dismissed' do
+ before do
+ create(:user_callout, user: user, feature_name: described_class::FEATURE_FLAGS_NEW_VERSION)
+ end
+
+ it { is_expected.to be_falsy }
+ end
+ end
end
diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb
index 247fce683db..5f734f4b71b 100644
--- a/spec/lib/backup/repositories_spec.rb
+++ b/spec/lib/backup/repositories_spec.rb
@@ -159,12 +159,16 @@ RSpec.describe Backup::Repositories do
describe '#restore' do
let_it_be(:project) { create(:project) }
+ let_it_be(:personal_snippet) { create(:personal_snippet, author: project.owner) }
+ let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.owner) }
it 'restores repositories from bundles', :aggregate_failures do
next_path_to_bundle = [
Rails.root.join('spec/fixtures/lib/backup/project_repo.bundle'),
Rails.root.join('spec/fixtures/lib/backup/wiki_repo.bundle'),
- Rails.root.join('spec/fixtures/lib/backup/design_repo.bundle')
+ Rails.root.join('spec/fixtures/lib/backup/design_repo.bundle'),
+ Rails.root.join('spec/fixtures/lib/backup/personal_snippet_repo.bundle'),
+ Rails.root.join('spec/fixtures/lib/backup/project_snippet_repo.bundle')
].to_enum
allow_next_instance_of(described_class::BackupRestore) do |backup_restore|
@@ -178,6 +182,8 @@ RSpec.describe Backup::Repositories do
expect(collect_commit_shas.call(project.repository)).to eq(['393a7d860a5a4c3cc736d7eb00604e3472bb95ec'])
expect(collect_commit_shas.call(project.wiki.repository)).to eq(['c74b9948d0088d703ee1fafeddd9ed9add2901ea'])
expect(collect_commit_shas.call(project.design_repository)).to eq(['c3cd4d7bd73a51a0f22045c3a4c871c435dc959d'])
+ expect(collect_commit_shas.call(personal_snippet.repository)).to eq(['3b3c067a3bc1d1b695b51e2be30c0f8cf698a06e'])
+ expect(collect_commit_shas.call(project_snippet.repository)).to eq(['6e44ba56a4748be361a841e759c20e421a1651a1'])
end
describe 'command failure' do
@@ -228,7 +234,9 @@ RSpec.describe Backup::Repositories do
expect_next_instance_of(DesignManagement::Repository) do |repository|
expect(repository).to receive(:remove)
end
- expect(Repository).to receive(:new).twice.and_wrap_original do |method, *original_args|
+
+ # 4 times = project repo + wiki repo + project_snippet repo + personal_snippet repo
+ expect(Repository).to receive(:new).exactly(4).times.and_wrap_original do |method, *original_args|
repository = method.call(*original_args)
expect(repository).to receive(:remove)
diff --git a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
index 77a8588e2cb..eb28e6c8c0a 100644
--- a/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
+++ b/spec/lib/gitlab/graphql/pagination/keyset/order_info_spec.rb
@@ -69,11 +69,23 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::OrderInfo do
it 'assigns the right attribute name, named function, and direction' do
expect(order_list.count).to eq 1
- expect(order_list.first.attribute_name).to eq 'pending_delete'
+ expect(order_list.first.attribute_name).to eq 'case_order_value'
expect(order_list.first.named_function).to be_kind_of(Arel::Nodes::Case)
expect(order_list.first.sort_direction).to eq :asc
end
end
+
+ context 'when ordering by ARRAY_POSITION', :aggregate_failuers do
+ let(:array_position) { Arel::Nodes::NamedFunction.new('ARRAY_POSITION', [Arel.sql("ARRAY[1,0]::smallint[]"), Project.arel_table[:auto_cancel_pending_pipelines]]) }
+ let(:relation) { Project.order(array_position.asc) }
+
+ it 'assigns the right attribute name, named function, and direction' do
+ expect(order_list.count).to eq 1
+ expect(order_list.first.attribute_name).to eq 'array_position'
+ expect(order_list.first.named_function).to be_kind_of(Arel::Nodes::NamedFunction)
+ expect(order_list.first.sort_direction).to eq :asc
+ end
+ end
end
describe '#validate_ordering' do
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 45f48cd8a57..2df15c8f400 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -304,6 +304,7 @@ protected_branches:
- push_access_levels
- unprotect_access_levels
- approval_project_rules
+- required_code_owners_sections
protected_tags:
- project
- create_access_levels
diff --git a/spec/models/blob_viewer/markup_spec.rb b/spec/models/blob_viewer/markup_spec.rb
new file mode 100644
index 00000000000..13b040d62d0
--- /dev/null
+++ b/spec/models/blob_viewer/markup_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BlobViewer::Markup do
+ include FakeBlobHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:blob) { fake_blob(path: 'CHANGELOG.md') }
+
+ subject { described_class.new(blob) }
+
+ describe '#banzai_render_context' do
+ it 'returns context needed for banzai rendering' do
+ expect(subject.banzai_render_context.keys).to eq([:cache_key])
+ end
+
+ context 'when blob does respond to rendered_markup' do
+ before do
+ allow(blob).to receive(:rendered_markup).and_return("some rendered markup")
+ end
+
+ it 'does sets rendered key' do
+ expect(subject.banzai_render_context.keys).to include(:rendered)
+ end
+ end
+
+ context 'when cached_markdown_blob feature flag is disabled' do
+ before do
+ stub_feature_flags(cached_markdown_blob: false)
+ end
+
+ it 'does not set cache_key key' do
+ expect(subject.banzai_render_context.keys).not_to include(:cache_key)
+ end
+ end
+ end
+end
diff --git a/spec/presenters/packages/detail/package_presenter_spec.rb b/spec/presenters/packages/detail/package_presenter_spec.rb
index 3a13aca6c7a..8ece27e9b5f 100644
--- a/spec/presenters/packages/detail/package_presenter_spec.rb
+++ b/spec/presenters/packages/detail/package_presenter_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe ::Packages::Detail::PackagePresenter do
context 'with conan metadata' do
let(:package) { create(:conan_package, project: project) }
- let(:expected_package_details) { super().merge(conan_metadatum: package.conan_metadatum) }
+ let(:expected_package_details) { super().merge(conan_metadatum: package.conan_metadatum, conan_package_name: package.name, name: package.conan_recipe) }
it 'returns conan_metadatum' do
expect(presenter.detail_view).to eq expected_package_details
diff --git a/spec/requests/api/feature_flag_scopes_spec.rb b/spec/requests/api/feature_flag_scopes_spec.rb
new file mode 100644
index 00000000000..da5b2cbb7ae
--- /dev/null
+++ b/spec/requests/api/feature_flag_scopes_spec.rb
@@ -0,0 +1,319 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe API::FeatureFlagScopes do
+ include FeatureFlagHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:developer) { create(:user) }
+ let(:reporter) { create(:user) }
+ let(:user) { developer }
+
+ before do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ shared_examples_for 'check user permission' do
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'forbids the request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ shared_examples_for 'not found' do
+ it 'returns Not Found' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'GET /projects/:id/feature_flag_scopes' do
+ subject do
+ get api("/projects/#{project.id}/feature_flag_scopes", user),
+ params: params
+ end
+
+ let(:feature_flag_1) { create_flag(project, 'flag_1', true) }
+ let(:feature_flag_2) { create_flag(project, 'flag_2', true) }
+
+ before do
+ create_scope(feature_flag_1, 'staging', false)
+ create_scope(feature_flag_1, 'production', true)
+ create_scope(feature_flag_2, 'review/*', false)
+ end
+
+ context 'when environment is production' do
+ let(:params) { { environment: 'production' } }
+
+ it_behaves_like 'check user permission'
+
+ it 'returns all effective feature flags under the environment' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag_detailed_scopes')
+ expect(json_response.second).to include({ 'name' => 'flag_1', 'active' => true })
+ expect(json_response.first).to include({ 'name' => 'flag_2', 'active' => true })
+ end
+ end
+
+ context 'when environment is staging' do
+ let(:params) { { environment: 'staging' } }
+
+ it 'returns all effective feature flags under the environment' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.second).to include({ 'name' => 'flag_1', 'active' => false })
+ expect(json_response.first).to include({ 'name' => 'flag_2', 'active' => true })
+ end
+ end
+
+ context 'when environment is review/feature X' do
+ let(:params) { { environment: 'review/feature X' } }
+
+ it 'returns all effective feature flags under the environment' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.second).to include({ 'name' => 'flag_1', 'active' => true })
+ expect(json_response.first).to include({ 'name' => 'flag_2', 'active' => false })
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/feature_flags/:name/scopes' do
+ subject do
+ get api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes", user)
+ end
+
+ context 'when there are two scopes' do
+ let(:feature_flag) { create_flag(project, 'test') }
+ let!(:additional_scope) { create_scope(feature_flag, 'production', false) }
+
+ it_behaves_like 'check user permission'
+
+ it 'returns scopes of the feature flag' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag_scopes')
+ expect(json_response.count).to eq(2)
+ expect(json_response.first['environment_scope']).to eq(feature_flag.scopes[0].environment_scope)
+ expect(json_response.second['environment_scope']).to eq(feature_flag.scopes[1].environment_scope)
+ end
+ end
+
+ context 'when there are no feature flags' do
+ let(:feature_flag) { double(:feature_flag, name: 'test') }
+
+ it_behaves_like 'not found'
+ end
+ end
+
+ describe 'POST /projects/:id/feature_flags/:name/scopes' do
+ subject do
+ post api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes", user),
+ params: params
+ end
+
+ let(:params) do
+ {
+ environment_scope: 'staging',
+ active: true,
+ strategies: [{ name: 'userWithId', parameters: { 'userIds': 'a,b,c' } }].to_json
+ }
+ end
+
+ context 'when there is a corresponding feature flag' do
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
+
+ it_behaves_like 'check user permission'
+
+ it 'creates a new scope' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag_scope')
+ expect(json_response['environment_scope']).to eq(params[:environment_scope])
+ expect(json_response['active']).to eq(params[:active])
+ expect(json_response['strategies']).to eq(Gitlab::Json.parse(params[:strategies]))
+ end
+
+ context 'when the scope already exists' do
+ before do
+ create_scope(feature_flag, params[:environment_scope])
+ end
+
+ it 'returns error' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).to include('Scopes environment scope (staging) has already been taken')
+ end
+ end
+ end
+
+ context 'when feature flag is not found' do
+ let(:feature_flag) { double(:feature_flag, name: 'test') }
+
+ it_behaves_like 'not found'
+ end
+ end
+
+ describe 'GET /projects/:id/feature_flags/:name/scopes/:environment_scope' do
+ subject do
+ get api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes/#{environment_scope}",
+ user)
+ end
+
+ let(:environment_scope) { scope.environment_scope }
+
+ shared_examples_for 'successful response' do
+ it 'returns a scope' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag_scope')
+ expect(json_response['id']).to eq(scope.id)
+ expect(json_response['active']).to eq(scope.active)
+ expect(json_response['environment_scope']).to eq(scope.environment_scope)
+ end
+ end
+
+ context 'when there is a feature flag' do
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
+ let(:scope) { feature_flag.default_scope }
+
+ it_behaves_like 'check user permission'
+ it_behaves_like 'successful response'
+
+ context 'when environment scope includes slash' do
+ let!(:scope) { create_scope(feature_flag, 'review/*', false) }
+
+ it_behaves_like 'not found'
+
+ context 'when URL-encoding the environment scope parameter' do
+ let(:environment_scope) { CGI.escape(scope.environment_scope) }
+
+ it_behaves_like 'successful response'
+ end
+ end
+ end
+
+ context 'when there are no feature flags' do
+ let(:feature_flag) { double(:feature_flag, name: 'test') }
+ let(:scope) { double(:feature_flag_scope, environment_scope: 'prd') }
+
+ it_behaves_like 'not found'
+ end
+ end
+
+ describe 'PUT /projects/:id/feature_flags/:name/scopes/:environment_scope' do
+ subject do
+ put api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes/#{environment_scope}",
+ user), params: params
+ end
+
+ let(:environment_scope) { scope.environment_scope }
+
+ let(:params) do
+ {
+ active: true,
+ strategies: [{ name: 'userWithId', parameters: { 'userIds': 'a,b,c' } }].to_json
+ }
+ end
+
+ context 'when there is a corresponding feature flag' do
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
+ let(:scope) { create_scope(feature_flag, 'staging', false, [{ name: "default", parameters: {} }]) }
+
+ it_behaves_like 'check user permission'
+
+ it 'returns the updated scope' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag_scope')
+ expect(json_response['id']).to eq(scope.id)
+ expect(json_response['active']).to eq(params[:active])
+ expect(json_response['strategies']).to eq(Gitlab::Json.parse(params[:strategies]))
+ end
+
+ context 'when there are no corresponding feature flag scopes' do
+ let(:scope) { double(:feature_flag_scope, environment_scope: 'prd') }
+
+ it_behaves_like 'not found'
+ end
+ end
+
+ context 'when there are no corresponding feature flags' do
+ let(:feature_flag) { double(:feature_flag, name: 'test') }
+ let(:scope) { double(:feature_flag_scope, environment_scope: 'prd') }
+
+ it_behaves_like 'not found'
+ end
+ end
+
+ describe 'DELETE /projects/:id/feature_flags/:name/scopes/:environment_scope' do
+ subject do
+ delete api("/projects/#{project.id}/feature_flags/#{feature_flag.name}/scopes/#{environment_scope}",
+ user)
+ end
+
+ let(:environment_scope) { scope.environment_scope }
+
+ shared_examples_for 'successful response' do
+ it 'destroys the scope' do
+ expect { subject }
+ .to change { Operations::FeatureFlagScope.exists?(environment_scope: scope.environment_scope) }
+ .from(true).to(false)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when there is a feature flag' do
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
+
+ context 'when there is a targeted scope' do
+ let!(:scope) { create_scope(feature_flag, 'production', false) }
+
+ it_behaves_like 'check user permission'
+ it_behaves_like 'successful response'
+
+ context 'when environment scope includes slash' do
+ let!(:scope) { create_scope(feature_flag, 'review/*', false) }
+
+ it_behaves_like 'not found'
+
+ context 'when URL-encoding the environment scope parameter' do
+ let(:environment_scope) { CGI.escape(scope.environment_scope) }
+
+ it_behaves_like 'successful response'
+ end
+ end
+ end
+
+ context 'when there are no targeted scopes' do
+ let!(:scope) { double(:feature_flag_scope, environment_scope: 'production') }
+
+ it_behaves_like 'not found'
+ end
+ end
+
+ context 'when there are no feature flags' do
+ let(:feature_flag) { double(:feature_flag, name: 'test') }
+ let(:scope) { double(:feature_flag_scope, environment_scope: 'prd') }
+
+ it_behaves_like 'not found'
+ end
+ end
+end
diff --git a/spec/requests/api/feature_flags_spec.rb b/spec/requests/api/feature_flags_spec.rb
new file mode 100644
index 00000000000..90d4a7b8b21
--- /dev/null
+++ b/spec/requests/api/feature_flags_spec.rb
@@ -0,0 +1,1130 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe API::FeatureFlags do
+ include FeatureFlagHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:non_project_member) { create(:user) }
+ let(:user) { developer }
+
+ before_all do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ shared_examples_for 'check user permission' do
+ context 'when user is reporter' do
+ let(:user) { reporter }
+
+ it 'forbids the request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ shared_examples_for 'not found' do
+ it 'returns Not Found' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'GET /projects/:id/feature_flags' do
+ subject { get api("/projects/#{project.id}/feature_flags", user) }
+
+ context 'when there are two feature flags' do
+ let!(:feature_flag_1) do
+ create(:operations_feature_flag, project: project)
+ end
+
+ let!(:feature_flag_2) do
+ create(:operations_feature_flag, project: project)
+ end
+
+ it 'returns feature flags ordered by name' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flags')
+ expect(json_response.count).to eq(2)
+ expect(json_response.first['name']).to eq(feature_flag_1.name)
+ expect(json_response.second['name']).to eq(feature_flag_2.name)
+ end
+
+ it 'returns the legacy flag version' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flags')
+ expect(json_response.map { |f| f['version'] }).to eq(%w[legacy_flag legacy_flag])
+ end
+
+ it 'does not return the legacy flag version when the feature flag is disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flags')
+ expect(json_response.select { |f| f.key?('version') }).to eq([])
+ end
+
+ it 'does not return strategies if the new flag is disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flags')
+ expect(json_response.select { |f| f.key?('strategies') }).to eq([])
+ end
+
+ it 'does not have N+1 problem' do
+ control_count = ActiveRecord::QueryRecorder.new { subject }
+
+ create_list(:operations_feature_flag, 3, project: project)
+
+ expect { get api("/projects/#{project.id}/feature_flags", user) }
+ .not_to exceed_query_limit(control_count)
+ end
+
+ it_behaves_like 'check user permission'
+ end
+
+ context 'with version 2 feature flags' do
+ let!(:feature_flag) do
+ create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature1')
+ end
+
+ let!(:strategy) do
+ create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ end
+
+ let!(:scope) do
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+ end
+
+ it 'returns the feature flags' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flags')
+ expect(json_response).to eq([{
+ 'name' => 'feature1',
+ 'description' => nil,
+ 'active' => true,
+ 'version' => 'new_version_flag',
+ 'updated_at' => feature_flag.updated_at.as_json,
+ 'created_at' => feature_flag.created_at.as_json,
+ 'scopes' => [],
+ 'strategies' => [{
+ 'id' => strategy.id,
+ 'name' => 'default',
+ 'parameters' => {},
+ 'scopes' => [{
+ 'id' => scope.id,
+ 'environment_scope' => 'production'
+ }]
+ }]
+ }])
+ end
+
+ it 'does not return a version 2 flag when the feature flag is disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flags')
+ expect(json_response).to eq([])
+ end
+ end
+
+ context 'with version 1 and 2 feature flags' do
+ it 'returns both versions of flags ordered by name' do
+ create(:operations_feature_flag, project: project, name: 'legacy_flag')
+ feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'new_version_flag')
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flags')
+ expect(json_response.map { |f| f['name'] }).to eq(%w[legacy_flag new_version_flag])
+ end
+
+ it 'returns only version 1 flags when the feature flag is disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+ create(:operations_feature_flag, project: project, name: 'legacy_flag')
+ feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'new_version_flag')
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flags')
+ expect(json_response.map { |f| f['name'] }).to eq(['legacy_flag'])
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/feature_flags/:name' do
+ subject { get api("/projects/#{project.id}/feature_flags/#{feature_flag.name}", user) }
+
+ context 'when there is a feature flag' do
+ let!(:feature_flag) { create_flag(project, 'awesome-feature') }
+
+ it 'returns a feature flag entry' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response['name']).to eq(feature_flag.name)
+ expect(json_response['description']).to eq(feature_flag.description)
+ expect(json_response['version']).to eq('legacy_flag')
+ end
+
+ it_behaves_like 'check user permission'
+ end
+
+ context 'with a version 2 feature_flag' do
+ it 'returns the feature flag' do
+ feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature1')
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ scope = create(:operations_scope, strategy: strategy, environment_scope: 'production')
+
+ get api("/projects/#{project.id}/feature_flags/feature1", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response).to eq({
+ 'name' => 'feature1',
+ 'description' => nil,
+ 'active' => true,
+ 'version' => 'new_version_flag',
+ 'updated_at' => feature_flag.updated_at.as_json,
+ 'created_at' => feature_flag.created_at.as_json,
+ 'scopes' => [],
+ 'strategies' => [{
+ 'id' => strategy.id,
+ 'name' => 'default',
+ 'parameters' => {},
+ 'scopes' => [{
+ 'id' => scope.id,
+ 'environment_scope' => 'production'
+ }]
+ }]
+ })
+ end
+
+ it 'returns a 404 when the feature is disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+ feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature1')
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ create(:operations_scope, strategy: strategy, environment_scope: 'production')
+
+ get api("/projects/#{project.id}/feature_flags/feature1", user)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response).to eq({ 'message' => '404 Not found' })
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/feature_flags' do
+ def scope_default
+ {
+ environment_scope: '*',
+ active: false,
+ strategies: [{ name: 'default', parameters: {} }].to_json
+ }
+ end
+
+ subject do
+ post api("/projects/#{project.id}/feature_flags", user), params: params
+ end
+
+ let(:params) do
+ {
+ name: 'awesome-feature',
+ scopes: [scope_default]
+ }
+ end
+
+ it 'creates a new feature flag' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+
+ feature_flag = project.operations_feature_flags.last
+ expect(feature_flag.name).to eq(params[:name])
+ expect(feature_flag.description).to eq(params[:description])
+ end
+
+ it 'defaults to a version 1 (legacy) feature flag' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+
+ feature_flag = project.operations_feature_flags.last
+ expect(feature_flag.version).to eq('legacy_flag')
+ end
+
+ it_behaves_like 'check user permission'
+
+ it 'returns version' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response['version']).to eq('legacy_flag')
+ end
+
+ it 'does not return version when new version flags are disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response.key?('version')).to eq(false)
+ end
+
+ context 'with active set to false in the params for a legacy flag' do
+ let(:params) do
+ {
+ name: 'awesome-feature',
+ version: 'legacy_flag',
+ active: 'false',
+ scopes: [scope_default]
+ }
+ end
+
+ it 'creates an inactive feature flag' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response['active']).to eq(false)
+ end
+ end
+
+ context 'when no scopes passed in parameters' do
+ let(:params) { { name: 'awesome-feature' } }
+
+ it 'creates a new feature flag with active default scope' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+ feature_flag = project.operations_feature_flags.last
+ expect(feature_flag.default_scope).to be_active
+ end
+ end
+
+ context 'when there is a feature flag with the same name already' do
+ before do
+ create_flag(project, 'awesome-feature')
+ end
+
+ it 'fails to create a new feature flag' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'when create a feature flag with two scopes' do
+ let(:params) do
+ {
+ name: 'awesome-feature',
+ description: 'this is awesome',
+ scopes: [
+ scope_default,
+ scope_with_user_with_id
+ ]
+ }
+ end
+
+ let(:scope_with_user_with_id) do
+ {
+ environment_scope: 'production',
+ active: true,
+ strategies: [{
+ name: 'userWithId',
+ parameters: { userIds: 'user:1' }
+ }].to_json
+ }
+ end
+
+ it 'creates a new feature flag with two scopes' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:created)
+
+ feature_flag = project.operations_feature_flags.last
+ feature_flag.scopes.ordered.each_with_index do |scope, index|
+ expect(scope.environment_scope).to eq(params[:scopes][index][:environment_scope])
+ expect(scope.active).to eq(params[:scopes][index][:active])
+ expect(scope.strategies).to eq(Gitlab::Json.parse(params[:scopes][index][:strategies]))
+ end
+ end
+ end
+
+ context 'when creating a version 2 feature flag' do
+ it 'creates a new feature flag' do
+ params = {
+ name: 'new-feature',
+ version: 'new_version_flag'
+ }
+
+ post api("/projects/#{project.id}/feature_flags", user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response).to match(hash_including({
+ 'name' => 'new-feature',
+ 'description' => nil,
+ 'active' => true,
+ 'version' => 'new_version_flag',
+ 'scopes' => [],
+ 'strategies' => []
+ }))
+
+ feature_flag = project.operations_feature_flags.last
+ expect(feature_flag.name).to eq(params[:name])
+ expect(feature_flag.version).to eq('new_version_flag')
+ end
+
+ it 'creates a new feature flag that is inactive' do
+ params = {
+ name: 'new-feature',
+ version: 'new_version_flag',
+ active: false
+ }
+
+ post api("/projects/#{project.id}/feature_flags", user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response['active']).to eq(false)
+
+ feature_flag = project.operations_feature_flags.last
+ expect(feature_flag.active).to eq(false)
+ end
+
+ it 'creates a new feature flag with strategies' do
+ params = {
+ name: 'new-feature',
+ version: 'new_version_flag',
+ strategies: [{
+ name: 'userWithId',
+ parameters: { 'userIds': 'user1' }
+ }]
+ }
+
+ post api("/projects/#{project.id}/feature_flags", user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+
+ feature_flag = project.operations_feature_flags.last
+ expect(feature_flag.name).to eq(params[:name])
+ expect(feature_flag.version).to eq('new_version_flag')
+ expect(feature_flag.strategies.map { |s| s.slice(:name, :parameters).deep_symbolize_keys }).to eq([{
+ name: 'userWithId',
+ parameters: { userIds: 'user1' }
+ }])
+ end
+
+ it 'creates a new feature flag with gradual rollout strategy with scopes' do
+ params = {
+ name: 'new-feature',
+ version: 'new_version_flag',
+ strategies: [{
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '50' },
+ scopes: [{
+ environment_scope: 'staging'
+ }]
+ }]
+ }
+
+ post api("/projects/#{project.id}/feature_flags", user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+
+ feature_flag = project.operations_feature_flags.last
+ expect(feature_flag.name).to eq(params[:name])
+ expect(feature_flag.version).to eq('new_version_flag')
+ expect(feature_flag.strategies.map { |s| s.slice(:name, :parameters).deep_symbolize_keys }).to eq([{
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '50' }
+ }])
+ expect(feature_flag.strategies.first.scopes.map { |s| s.slice(:environment_scope).deep_symbolize_keys }).to eq([{
+ environment_scope: 'staging'
+ }])
+ end
+
+ it 'creates a new feature flag with flexible rollout strategy with scopes' do
+ params = {
+ name: 'new-feature',
+ version: 'new_version_flag',
+ strategies: [{
+ name: 'flexibleRollout',
+ parameters: { groupId: 'default', rollout: '50', stickiness: 'DEFAULT' },
+ scopes: [{
+ environment_scope: 'staging'
+ }]
+ }]
+ }
+
+ post api("/projects/#{project.id}/feature_flags", user), params: params
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+
+ feature_flag = project.operations_feature_flags.last
+ expect(feature_flag.name).to eq(params[:name])
+ expect(feature_flag.version).to eq('new_version_flag')
+ expect(feature_flag.strategies.map { |s| s.slice(:name, :parameters).deep_symbolize_keys }).to eq([{
+ name: 'flexibleRollout',
+ parameters: { groupId: 'default', rollout: '50', stickiness: 'DEFAULT' }
+ }])
+ expect(feature_flag.strategies.first.scopes.map { |s| s.slice(:environment_scope).deep_symbolize_keys }).to eq([{
+ environment_scope: 'staging'
+ }])
+ end
+
+ it 'returns a 422 when the feature flag is disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+ params = {
+ name: 'new-feature',
+ version: 'new_version_flag'
+ }
+
+ post api("/projects/#{project.id}/feature_flags", user), params: params
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response).to eq({ 'message' => 'Version 2 flags are not enabled for this project' })
+ expect(project.operations_feature_flags.count).to eq(0)
+ end
+ end
+
+ context 'when given invalid parameters' do
+ it 'responds with a 400 when given an invalid version' do
+ params = { name: 'new-feature', version: 'bad_value' }
+
+ post api("/projects/#{project.id}/feature_flags", user), params: params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({ 'message' => 'Version is invalid' })
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/feature_flags/:name/enable' do
+ subject do
+ post api("/projects/#{project.id}/feature_flags/#{params[:name]}/enable", user),
+ params: params
+ end
+
+ let(:params) do
+ {
+ name: 'awesome-feature',
+ environment_scope: 'production',
+ strategy: { name: 'userWithId', parameters: { userIds: 'Project:1' } }.to_json
+ }
+ end
+
+ context 'when feature flag does not exist yet' do
+ it 'creates a new feature flag with the specified scope and strategy' do
+ subject
+
+ feature_flag = project.operations_feature_flags.last
+ scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope])
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(feature_flag.name).to eq(params[:name])
+ expect(scope.strategies).to eq([Gitlab::Json.parse(params[:strategy])])
+ expect(feature_flag.version).to eq('legacy_flag')
+ end
+
+ it 'returns the flag version and strategies in the json response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response.slice('version', 'strategies')).to eq({
+ 'version' => 'legacy_flag',
+ 'strategies' => []
+ })
+ end
+
+ it_behaves_like 'check user permission'
+ end
+
+ context 'when feature flag exists already' do
+ let!(:feature_flag) { create_flag(project, params[:name]) }
+
+ context 'when feature flag scope does not exist yet' do
+ it 'creates a new scope with the specified strategy' do
+ subject
+
+ scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope])
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(scope.strategies).to eq([Gitlab::Json.parse(params[:strategy])])
+ end
+
+ it_behaves_like 'check user permission'
+ end
+
+ context 'when feature flag scope exists already' do
+ let(:defined_strategy) { { name: 'userWithId', parameters: { userIds: 'Project:2' } } }
+
+ before do
+ create_scope(feature_flag, params[:environment_scope], true, [defined_strategy])
+ end
+
+ it 'adds an additional strategy to the scope' do
+ subject
+
+ scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope])
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(scope.strategies).to eq([defined_strategy.deep_stringify_keys, Gitlab::Json.parse(params[:strategy])])
+ end
+
+ context 'when the specified strategy exists already' do
+ let(:defined_strategy) { Gitlab::Json.parse(params[:strategy]) }
+
+ it 'does not add a duplicate strategy' do
+ subject
+
+ scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope])
+ strategy_count = scope.strategies.count { |strategy| strategy['name'] == 'userWithId' }
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(strategy_count).to eq(1)
+ end
+ end
+ end
+ end
+
+ context 'with a version 2 flag' do
+ let!(:feature_flag) { create(:operations_feature_flag, :new_version_flag, project: project, name: params[:name]) }
+
+ it 'does not change the flag and returns an unprocessable_entity response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response).to eq({ 'message' => 'Version 2 flags not supported' })
+ feature_flag.reload
+ expect(feature_flag.scopes).to eq([])
+ expect(feature_flag.strategies).to eq([])
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/feature_flags/:name/disable' do
+ subject do
+ post api("/projects/#{project.id}/feature_flags/#{params[:name]}/disable", user),
+ params: params
+ end
+
+ let(:params) do
+ {
+ name: 'awesome-feature',
+ environment_scope: 'production',
+ strategy: { name: 'userWithId', parameters: { userIds: 'Project:1' } }.to_json
+ }
+ end
+
+ context 'when feature flag does not exist yet' do
+ it_behaves_like 'not found'
+ end
+
+ context 'when feature flag exists already' do
+ let!(:feature_flag) { create_flag(project, params[:name]) }
+
+ context 'when feature flag scope does not exist yet' do
+ it_behaves_like 'not found'
+ end
+
+ context 'when feature flag scope exists already and has the specified strategy' do
+ let(:defined_strategies) do
+ [
+ { name: 'userWithId', parameters: { userIds: 'Project:1' } },
+ { name: 'userWithId', parameters: { userIds: 'Project:2' } }
+ ]
+ end
+
+ before do
+ create_scope(feature_flag, params[:environment_scope], true, defined_strategies)
+ end
+
+ it 'removes the strategy from the scope' do
+ subject
+
+ scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope])
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(scope.strategies)
+ .to eq([{ name: 'userWithId', parameters: { userIds: 'Project:2' } }.deep_stringify_keys])
+ end
+
+ it 'returns the flag version and strategies in the json response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response.slice('version', 'strategies')).to eq({
+ 'version' => 'legacy_flag',
+ 'strategies' => []
+ })
+ end
+
+ it_behaves_like 'check user permission'
+
+ context 'when strategies become empty array after the removal' do
+ let(:defined_strategies) do
+ [{ name: 'userWithId', parameters: { userIds: 'Project:1' } }]
+ end
+
+ it 'destroys the scope' do
+ subject
+
+ scope = feature_flag.scopes.find_by_environment_scope(params[:environment_scope])
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(scope).to be_nil
+ end
+
+ it_behaves_like 'check user permission'
+ end
+ end
+
+ context 'when scope exists already but cannot find the corresponding strategy' do
+ let(:defined_strategy) { { name: 'userWithId', parameters: { userIds: 'Project:2' } } }
+
+ before do
+ create_scope(feature_flag, params[:environment_scope], true, [defined_strategy])
+ end
+
+ it_behaves_like 'not found'
+ end
+ end
+
+ context 'with a version 2 feature flag' do
+ let!(:feature_flag) { create(:operations_feature_flag, :new_version_flag, project: project, name: params[:name]) }
+
+ it 'does not change the flag and returns an unprocessable_entity response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response).to eq({ 'message' => 'Version 2 flags not supported' })
+ feature_flag.reload
+ expect(feature_flag.scopes).to eq([])
+ expect(feature_flag.strategies).to eq([])
+ end
+ end
+ end
+
+ describe 'PUT /projects/:id/feature_flags/:name' do
+ context 'with a legacy feature flag' do
+ let!(:feature_flag) do
+ create(:operations_feature_flag, :legacy_flag, project: project,
+ name: 'feature1', description: 'old description')
+ end
+
+ it 'returns a 404 if the feature is disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+ params = { description: 'new description' }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(feature_flag.reload.description).to eq('old description')
+ end
+
+ it 'returns a 422' do
+ params = { description: 'new description' }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response).to eq({ 'message' => 'PUT operations are not supported for legacy feature flags' })
+ expect(feature_flag.reload.description).to eq('old description')
+ end
+ end
+
+ context 'with a version 2 feature flag' do
+ let!(:feature_flag) do
+ create(:operations_feature_flag, :new_version_flag, project: project, active: true,
+ name: 'feature1', description: 'old description')
+ end
+
+ it 'returns a 404 if the feature is disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+ params = { description: 'new description' }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(feature_flag.reload.description).to eq('old description')
+ end
+
+ it 'returns a 404 if the feature flag does not exist' do
+ params = { description: 'new description' }
+
+ put api("/projects/#{project.id}/feature_flags/other_flag_name", user), params: params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(feature_flag.reload.description).to eq('old description')
+ end
+
+ it 'forbids a request for a reporter' do
+ params = { description: 'new description' }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", reporter), params: params
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(feature_flag.reload.description).to eq('old description')
+ end
+
+ it 'returns an error for an invalid update of gradual rollout' do
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ params = {
+ strategies: [{
+ id: strategy.id,
+ name: 'gradualRolloutUserId',
+ parameters: { bad: 'params' }
+ }]
+ }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).not_to be_nil
+ result = feature_flag.reload.strategies.map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys }
+ expect(result).to eq([{
+ id: strategy.id,
+ name: 'default',
+ parameters: {}
+ }])
+ end
+
+ it 'returns an error for an invalid update of flexible rollout' do
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ params = {
+ strategies: [{
+ id: strategy.id,
+ name: 'flexibleRollout',
+ parameters: { bad: 'params' }
+ }]
+ }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['message']).not_to be_nil
+ result = feature_flag.reload.strategies.map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys }
+ expect(result).to eq([{
+ id: strategy.id,
+ name: 'default',
+ parameters: {}
+ }])
+ end
+
+ it 'updates the feature flag' do
+ params = { description: 'new description' }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(feature_flag.reload.description).to eq('new description')
+ end
+
+ it 'updates the flag active value' do
+ params = { active: false }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response['active']).to eq(false)
+ expect(feature_flag.reload.active).to eq(false)
+ end
+
+ it 'updates the feature flag name' do
+ params = { name: 'new-name' }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(json_response['name']).to eq('new-name')
+ expect(feature_flag.reload.name).to eq('new-name')
+ end
+
+ it 'ignores a provided version parameter' do
+ params = { description: 'other description', version: 'bad_value' }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(feature_flag.reload.description).to eq('other description')
+ end
+
+ it 'returns the feature flag json' do
+ params = { description: 'new description' }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ feature_flag.reload
+ expect(json_response).to eq({
+ 'name' => 'feature1',
+ 'description' => 'new description',
+ 'active' => true,
+ 'created_at' => feature_flag.created_at.as_json,
+ 'updated_at' => feature_flag.updated_at.as_json,
+ 'scopes' => [],
+ 'strategies' => [],
+ 'version' => 'new_version_flag'
+ })
+ end
+
+ it 'updates an existing feature flag strategy to be gradual rollout strategy' do
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ params = {
+ strategies: [{
+ id: strategy.id,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '10' }
+ }]
+ }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ result = feature_flag.reload.strategies.map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys }
+ expect(result).to eq([{
+ id: strategy.id,
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '10' }
+ }])
+ end
+
+ it 'updates an existing feature flag strategy to be flexible rollout strategy' do
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ params = {
+ strategies: [{
+ id: strategy.id,
+ name: 'flexibleRollout',
+ parameters: { groupId: 'default', rollout: '10', stickiness: 'DEFAULT' }
+ }]
+ }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ result = feature_flag.reload.strategies.map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys }
+ expect(result).to eq([{
+ id: strategy.id,
+ name: 'flexibleRollout',
+ parameters: { groupId: 'default', rollout: '10', stickiness: 'DEFAULT' }
+ }])
+ end
+
+ it 'adds a new gradual rollout strategy to a feature flag' do
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ params = {
+ strategies: [{
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '10' }
+ }]
+ }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ result = feature_flag.reload.strategies
+ .map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys }
+ .sort_by { |s| s[:name] }
+ expect(result.first[:id]).to eq(strategy.id)
+ expect(result.map { |s| s.slice(:name, :parameters) }).to eq([{
+ name: 'default',
+ parameters: {}
+ }, {
+ name: 'gradualRolloutUserId',
+ parameters: { groupId: 'default', percentage: '10' }
+ }])
+ end
+
+ it 'adds a new gradual flexible strategy to a feature flag' do
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ params = {
+ strategies: [{
+ name: 'flexibleRollout',
+ parameters: { groupId: 'default', rollout: '10', stickiness: 'DEFAULT' }
+ }]
+ }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ result = feature_flag.reload.strategies
+ .map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys }
+ .sort_by { |s| s[:name] }
+ expect(result.first[:id]).to eq(strategy.id)
+ expect(result.map { |s| s.slice(:name, :parameters) }).to eq([{
+ name: 'default',
+ parameters: {}
+ }, {
+ name: 'flexibleRollout',
+ parameters: { groupId: 'default', rollout: '10', stickiness: 'DEFAULT' }
+ }])
+ end
+
+ it 'deletes a feature flag strategy' do
+ strategy_a = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ strategy_b = create(:operations_strategy, feature_flag: feature_flag,
+ name: 'userWithId', parameters: { userIds: 'userA,userB' })
+ params = {
+ strategies: [{
+ id: strategy_a.id,
+ name: 'default',
+ parameters: {},
+ _destroy: true
+ }, {
+ id: strategy_b.id,
+ name: 'userWithId',
+ parameters: { userIds: 'userB' }
+ }]
+ }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ result = feature_flag.reload.strategies
+ .map { |s| s.slice(:id, :name, :parameters).deep_symbolize_keys }
+ .sort_by { |s| s[:name] }
+ expect(result).to eq([{
+ id: strategy_b.id,
+ name: 'userWithId',
+ parameters: { userIds: 'userB' }
+ }])
+ end
+
+ it 'updates an existing feature flag scope' do
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ scope = create(:operations_scope, strategy: strategy, environment_scope: '*')
+ params = {
+ strategies: [{
+ id: strategy.id,
+ scopes: [{
+ id: scope.id,
+ environment_scope: 'production'
+ }]
+ }]
+ }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ result = feature_flag.reload.strategies.first.scopes.map { |s| s.slice(:id, :environment_scope).deep_symbolize_keys }
+ expect(result).to eq([{
+ id: scope.id,
+ environment_scope: 'production'
+ }])
+ end
+
+ it 'deletes an existing feature flag scope' do
+ strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
+ scope = create(:operations_scope, strategy: strategy, environment_scope: '*')
+ params = {
+ strategies: [{
+ id: strategy.id,
+ scopes: [{
+ id: scope.id,
+ _destroy: true
+ }]
+ }]
+ }
+
+ put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/feature_flag')
+ expect(feature_flag.reload.strategies.first.scopes.count).to eq(0)
+ end
+ end
+ end
+
+ describe 'DELETE /projects/:id/feature_flags/:name' do
+ subject do
+ delete api("/projects/#{project.id}/feature_flags/#{feature_flag.name}", user),
+ params: params
+ end
+
+ let!(:feature_flag) { create(:operations_feature_flag, project: project) }
+ let(:params) { {} }
+
+ it 'destroys the feature flag' do
+ expect { subject }.to change { Operations::FeatureFlag.count }.by(-1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns version' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['version']).to eq('legacy_flag')
+ end
+
+ it 'does not return version when new version flags are disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.key?('version')).to eq(false)
+ end
+
+ context 'with a version 2 feature flag' do
+ let!(:feature_flag) { create(:operations_feature_flag, :new_version_flag, project: project) }
+
+ it 'destroys the flag' do
+ expect { subject }.to change { Operations::FeatureFlag.count }.by(-1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ it 'returns a 404 if the feature is disabled' do
+ stub_feature_flags(feature_flags_new_version: false)
+
+ expect { subject }.not_to change { Operations::FeatureFlag.count }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/feature_flags_user_lists_spec.rb b/spec/requests/api/feature_flags_user_lists_spec.rb
new file mode 100644
index 00000000000..469210040dd
--- /dev/null
+++ b/spec/requests/api/feature_flags_user_lists_spec.rb
@@ -0,0 +1,371 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::FeatureFlagsUserLists do
+ let_it_be(:project, refind: true) { create(:project) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+
+ before_all do
+ project.add_developer(developer)
+ project.add_reporter(reporter)
+ end
+
+ def create_list(name: 'mylist', user_xids: 'user1')
+ create(:operations_feature_flag_user_list, project: project, name: name, user_xids: user_xids)
+ end
+
+ def disable_repository(project)
+ project.project_feature.update!(
+ repository_access_level: ::ProjectFeature::DISABLED,
+ merge_requests_access_level: ::ProjectFeature::DISABLED,
+ builds_access_level: ::ProjectFeature::DISABLED
+ )
+ end
+
+ describe 'GET /projects/:id/feature_flags_user_lists' do
+ it 'forbids the request for a reporter' do
+ get api("/projects/#{project.id}/feature_flags_user_lists", reporter)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'returns forbidden if the feature is unavailable' do
+ disable_repository(project)
+
+ get api("/projects/#{project.id}/feature_flags_user_lists", developer)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'returns all the user lists' do
+ create_list(name: 'list_a', user_xids: 'user1')
+ create_list(name: 'list_b', user_xids: 'user1,user2,user3')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |list| list['name'] }.sort).to eq(%w[list_a list_b])
+ end
+
+ it 'returns all the data for a user list' do
+ user_list = create_list(name: 'list_a', user_xids: 'user1')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([{
+ 'id' => user_list.id,
+ 'iid' => user_list.iid,
+ 'project_id' => project.id,
+ 'created_at' => user_list.created_at.as_json,
+ 'updated_at' => user_list.updated_at.as_json,
+ 'name' => 'list_a',
+ 'user_xids' => 'user1',
+ 'path' => project_feature_flags_user_list_path(user_list.project, user_list),
+ 'edit_path' => edit_project_feature_flags_user_list_path(user_list.project, user_list)
+ }])
+ end
+
+ it 'paginates user lists' do
+ create_list(name: 'list_a', user_xids: 'user1')
+ create_list(name: 'list_b', user_xids: 'user1,user2,user3')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists?page=2&per_page=1", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |list| list['name'] }).to eq(['list_b'])
+ end
+
+ it 'returns the user lists for only the specified project' do
+ create(:operations_feature_flag_user_list, project: project, name: 'list')
+ other_project = create(:project)
+ create(:operations_feature_flag_user_list, project: other_project, name: 'other_list')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |list| list['name'] }).to eq(['list'])
+ end
+
+ it 'returns an empty list' do
+ get api("/projects/#{project.id}/feature_flags_user_lists", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq([])
+ end
+ end
+
+ describe 'GET /projects/:id/feature_flags_user_lists/:iid' do
+ it 'forbids the request for a reporter' do
+ list = create_list
+
+ get api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", reporter)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'returns forbidden if the feature is unavailable' do
+ disable_repository(project)
+ list = create_list
+
+ get api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'returns the user list' do
+ list = create_list(name: 'testers', user_xids: 'test1,test2')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq({
+ 'name' => 'testers',
+ 'user_xids' => 'test1,test2',
+ 'id' => list.id,
+ 'iid' => list.iid,
+ 'project_id' => project.id,
+ 'created_at' => list.created_at.as_json,
+ 'updated_at' => list.updated_at.as_json,
+ 'path' => project_feature_flags_user_list_path(list.project, list),
+ 'edit_path' => edit_project_feature_flags_user_list_path(list.project, list)
+ })
+ end
+
+ it 'returns the correct user list identified by the iid' do
+ create_list(name: 'list_a', user_xids: 'test1')
+ list_b = create_list(name: 'list_b', user_xids: 'test2')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists/#{list_b.iid}", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['name']).to eq('list_b')
+ end
+
+ it 'scopes the iid search to the project' do
+ other_project = create(:project)
+ other_project.add_developer(developer)
+ create(:operations_feature_flag_user_list, project: other_project, name: 'other_list')
+ list = create(:operations_feature_flag_user_list, project: project, name: 'list')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['name']).to eq('list')
+ end
+
+ it 'returns not found when the list does not exist' do
+ get api("/projects/#{project.id}/feature_flags_user_lists/1", developer)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response).to eq({ 'message' => '404 Not found' })
+ end
+ end
+
+ describe 'POST /projects/:id/feature_flags_user_lists' do
+ it 'forbids the request for a reporter' do
+ post api("/projects/#{project.id}/feature_flags_user_lists", reporter), params: {
+ name: 'mylist', user_xids: 'user1'
+ }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(project.operations_feature_flags_user_lists.count).to eq(0)
+ end
+
+ it 'returns forbidden if the feature is unavailable' do
+ disable_repository(project)
+
+ post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: {
+ name: 'mylist', user_xids: 'user1'
+ }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'creates the flag' do
+ post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: {
+ name: 'mylist', user_xids: 'user1'
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response.slice('name', 'user_xids', 'project_id', 'iid')).to eq({
+ 'name' => 'mylist',
+ 'user_xids' => 'user1',
+ 'project_id' => project.id,
+ 'iid' => 1
+ })
+ expect(project.operations_feature_flags_user_lists.count).to eq(1)
+ expect(project.operations_feature_flags_user_lists.last.name).to eq('mylist')
+ end
+
+ it 'requires name' do
+ post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: {
+ user_xids: 'user1'
+ }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({ 'message' => 'name is missing' })
+ expect(project.operations_feature_flags_user_lists.count).to eq(0)
+ end
+
+ it 'requires user_xids' do
+ post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: {
+ name: 'empty_list'
+ }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({ 'message' => 'user_xids is missing' })
+ expect(project.operations_feature_flags_user_lists.count).to eq(0)
+ end
+
+ it 'returns an error when name is already taken' do
+ create_list(name: 'myname')
+ post api("/projects/#{project.id}/feature_flags_user_lists", developer), params: {
+ name: 'myname', user_xids: 'a'
+ }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({ 'message' => ['Name has already been taken'] })
+ expect(project.operations_feature_flags_user_lists.count).to eq(1)
+ end
+
+ it 'does not create a flag for a project of which the developer is not a member' do
+ other_project = create(:project)
+
+ post api("/projects/#{other_project.id}/feature_flags_user_lists", developer), params: {
+ name: 'mylist', user_xids: 'user1'
+ }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(other_project.operations_feature_flags_user_lists.count).to eq(0)
+ expect(project.operations_feature_flags_user_lists.count).to eq(0)
+ end
+ end
+
+ describe 'PUT /projects/:id/feature_flags_user_lists/:iid' do
+ it 'forbids the request for a reporter' do
+ list = create_list(name: 'original_name')
+
+ put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", reporter), params: {
+ name: 'mylist'
+ }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(list.reload.name).to eq('original_name')
+ end
+
+ it 'returns forbidden if the feature is unavailable' do
+ list = create_list(name: 'original_name')
+ disable_repository(project)
+
+ put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer), params: {
+ name: 'mylist', user_xids: '456,789'
+ }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'updates the list' do
+ list = create_list(name: 'original_name', user_xids: '123')
+
+ put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer), params: {
+ name: 'mylist', user_xids: '456,789'
+ }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.slice('name', 'user_xids')).to eq({
+ 'name' => 'mylist',
+ 'user_xids' => '456,789'
+ })
+ expect(list.reload.name).to eq('mylist')
+ end
+
+ it 'preserves attributes not listed in the request' do
+ list = create_list(name: 'original_name', user_xids: '123')
+
+ put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer), params: {}
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.slice('name', 'user_xids')).to eq({
+ 'name' => 'original_name',
+ 'user_xids' => '123'
+ })
+ expect(list.reload.name).to eq('original_name')
+ expect(list.reload.user_xids).to eq('123')
+ end
+
+ it 'returns an error when the update is invalid' do
+ create_list(name: 'taken', user_xids: '123')
+ list = create_list(name: 'original_name', user_xids: '123')
+
+ put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer), params: {
+ name: 'taken'
+ }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response).to eq({ 'message' => ['Name has already been taken'] })
+ end
+
+ it 'returns not found when the list does not exist' do
+ list = create_list(name: 'original_name', user_xids: '123')
+
+ put api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid + 1}", developer), params: {
+ name: 'new_name'
+ }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response).to eq({ 'message' => '404 Not found' })
+ end
+ end
+
+ describe 'DELETE /projects/:id/feature_flags_user_lists/:iid' do
+ it 'forbids the request for a reporter' do
+ list = create_list
+
+ delete api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", reporter)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(project.operations_feature_flags_user_lists.count).to eq(1)
+ end
+
+ it 'returns forbidden if the feature is unavailable' do
+ list = create_list
+ disable_repository(project)
+
+ delete api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer)
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+
+ it 'returns not found when the list does not exist' do
+ delete api("/projects/#{project.id}/feature_flags_user_lists/1", developer)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response).to eq({ 'message' => '404 Not found' })
+ end
+
+ it 'deletes the list' do
+ list = create_list
+
+ delete api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(response.body).to be_blank
+ expect(project.operations_feature_flags_user_lists.count).to eq(0)
+ end
+
+ it 'does not delete the list if it is associated with a strategy' do
+ list = create_list
+ feature_flag = create(:operations_feature_flag, :new_version_flag, project: project)
+ create(:operations_strategy, feature_flag: feature_flag, name: 'gitlabUserList', user_list: list)
+
+ delete api("/projects/#{project.id}/feature_flags_user_lists/#{list.iid}", developer)
+
+ expect(response).to have_gitlab_http_status(:conflict)
+ expect(json_response).to eq({ 'message' => ['User list is associated with a strategy'] })
+ expect(list.reload).to be_persisted
+ end
+ end
+end
diff --git a/spec/services/clusters/gcp/finalize_creation_service_spec.rb b/spec/services/clusters/gcp/finalize_creation_service_spec.rb
index a20cf90a770..d8c95a70bd0 100644
--- a/spec/services/clusters/gcp/finalize_creation_service_spec.rb
+++ b/spec/services/clusters/gcp/finalize_creation_service_spec.rb
@@ -83,10 +83,7 @@ RSpec.describe Clusters::Gcp::FinalizeCreationService, '#execute' do
shared_context 'kubernetes information successfully fetched' do
before do
stub_cloud_platform_get_zone_cluster(
- provider.gcp_project_id, provider.zone, cluster.name,
- endpoint: endpoint,
- username: username,
- password: password
+ provider.gcp_project_id, provider.zone, cluster.name, { endpoint: endpoint, username: username, password: password }
)
stub_kubeclient_discover(api_url)
diff --git a/spec/support/google_api/cloud_platform_helpers.rb b/spec/support/google_api/cloud_platform_helpers.rb
index 286f3c03357..840f948e377 100644
--- a/spec/support/google_api/cloud_platform_helpers.rb
+++ b/spec/support/google_api/cloud_platform_helpers.rb
@@ -22,9 +22,9 @@ module GoogleApi
.to_return(cloud_platform_response(cloud_platform_projects_billing_info_body(project_id, billing_enabled)))
end
- def stub_cloud_platform_get_zone_cluster(project_id, zone, cluster_id, **options)
+ def stub_cloud_platform_get_zone_cluster(project_id, zone, cluster_id, options = {})
WebMock.stub_request(:get, cloud_platform_get_zone_cluster_url(project_id, zone, cluster_id))
- .to_return(cloud_platform_response(cloud_platform_cluster_body(**options)))
+ .to_return(cloud_platform_response(cloud_platform_cluster_body(options)))
end
def stub_cloud_platform_get_zone_cluster_error(project_id, zone, cluster_id)
@@ -32,7 +32,7 @@ module GoogleApi
.to_return(status: [500, "Internal Server Error"])
end
- def stub_cloud_platform_create_cluster(project_id, zone, **options)
+ def stub_cloud_platform_create_cluster(project_id, zone, options = {})
WebMock.stub_request(:post, cloud_platform_create_cluster_url(project_id, zone))
.to_return(cloud_platform_response(cloud_platform_operation_body(options)))
end
@@ -42,7 +42,7 @@ module GoogleApi
.to_return(status: [500, "Internal Server Error"])
end
- def stub_cloud_platform_get_zone_operation(project_id, zone, operation_id, **options)
+ def stub_cloud_platform_get_zone_operation(project_id, zone, operation_id, options = {})
WebMock.stub_request(:get, cloud_platform_get_zone_operation_url(project_id, zone, operation_id))
.to_return(cloud_platform_response(cloud_platform_operation_body(options)))
end
@@ -86,7 +86,7 @@ module GoogleApi
# https://cloud.google.com/kubernetes-engine/docs/reference/rest/v1/projects.zones.clusters/create
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity
- def cloud_platform_cluster_body(**options)
+ def cloud_platform_cluster_body(options)
{
"name": options[:name] || 'string',
"description": options[:description] || 'string',
@@ -121,7 +121,7 @@ module GoogleApi
}
end
- def cloud_platform_operation_body(**options)
+ def cloud_platform_operation_body(options)
{
"name": options[:name] || 'operation-1234567891234-1234567',
"zone": options[:zone] || 'us-central1-a',
@@ -136,7 +136,7 @@ module GoogleApi
}
end
- def cloud_platform_projects_body(**options)
+ def cloud_platform_projects_body(options)
{
"projects": [
{