diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-04 21:09:55 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-04 21:09:55 +0300 |
commit | ae42530b1be0d25186881ae45c39bdf1122a84b9 (patch) | |
tree | 0592eb5b3b23d1dcd3b00bdb3b00f3b28412a291 | |
parent | e0655935eb32ba057b6ced978940076681d71177 (diff) |
Add latest changes from gitlab-org/gitlab@master
80 files changed, 1794 insertions, 171 deletions
diff --git a/.gitlab/issue_templates/actionable_insight.md b/.gitlab/issue_templates/actionable_insight.md index 68b2b153831..ff6a4f12918 100644 --- a/.gitlab/issue_templates/actionable_insight.md +++ b/.gitlab/issue_templates/actionable_insight.md @@ -31,5 +31,4 @@ Actionable insights always have a follow-up action that needs to take place as a - - /label ~"Actionable Insight" +/label ~"Actionable Insight" diff --git a/.rubocop_manual_todo.yml b/.rubocop_manual_todo.yml index 8bd1a345ffe..2103f352768 100644 --- a/.rubocop_manual_todo.yml +++ b/.rubocop_manual_todo.yml @@ -21,7 +21,6 @@ Graphql/IDType: Exclude: - 'ee/app/graphql/ee/mutations/issues/update.rb' - 'app/graphql/mutations/boards/issues/issue_move_list.rb' - - 'app/graphql/mutations/metrics/dashboard/annotations/delete.rb' - 'app/graphql/resolvers/design_management/design_at_version_resolver.rb' - 'app/graphql/resolvers/design_management/design_resolver.rb' - 'app/graphql/resolvers/design_management/designs_resolver.rb' diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index a1046f7c062..ecf555d67ff 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -f5c6f6efe69a4c23fb1f10cebb66c47f90f1a70c +2eb4db13dab06b87382582f5fcddab0c8397463e diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index f469f49ce20..f922d78a74d 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -69,6 +69,7 @@ const Api = { issuePath: '/api/:version/projects/:id/issues/:issue_iid', tagsPath: '/api/:version/projects/:id/repository/tags', freezePeriodsPath: '/api/:version/projects/:id/freeze_periods', + usageDataIncrementCounterPath: '/api/:version/usage_data/increment_counter', usageDataIncrementUniqueUsersPath: '/api/:version/usage_data/increment_unique_users', featureFlagUserLists: '/api/:version/projects/:id/feature_flags_user_lists', featureFlagUserList: '/api/:version/projects/:id/feature_flags_user_lists/:list_iid', @@ -751,6 +752,19 @@ const Api = { return axios.post(url, freezePeriod); }, + trackRedisCounterEvent(event) { + if (!gon.features?.usageDataApi) { + return null; + } + + const url = Api.buildUrl(this.usageDataIncrementCounterPath); + const headers = { + 'Content-Type': 'application/json', + }; + + return axios.post(url, { event }, { headers }); + }, + trackRedisHllUserEvent(event) { if (!gon.features?.usageDataApi) { return null; diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue index b8904de8049..2fe2fd6b3d8 100644 --- a/app/assets/javascripts/diffs/components/settings_dropdown.vue +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -14,26 +14,10 @@ export default { GlDropdown, GlFormCheckbox, }, - data() { - return { - checked: false, - }; - }, computed: { ...mapGetters('diffs', ['isInlineView', 'isParallelView']), ...mapState('diffs', ['renderTreeList', 'showWhitespace', 'viewDiffsFileByFile']), }, - watch: { - viewDiffsFileByFile() { - this.checked = this.viewDiffsFileByFile; - }, - checked() { - eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting: this.checked }); - }, - }, - created() { - this.checked = this.viewDiffsFileByFile; - }, methods: { ...mapActions('diffs', [ 'setInlineDiffViewType', @@ -110,7 +94,12 @@ export default { </label> </div> <div class="gl-mt-3 gl-px-3"> - <gl-form-checkbox v-model="checked" data-testid="file-by-file" class="gl-mb-0"> + <gl-form-checkbox + data-testid="file-by-file" + class="gl-mb-0" + :checked="viewDiffsFileByFile" + @input="toggleFileByFile" + > {{ $options.i18n.fileByFile }} </gl-form-checkbox> </div> diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index 7d4df25816b..8899870be79 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -91,7 +91,7 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { ], }; - const tokenPosition = 2; + const tokenPosition = 3; IssuableTokenKeys.tokenKeys.splice(tokenPosition, 0, ...[approvedBy.token]); IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]); IssuableTokenKeys.conditions.push(...approvedBy.condition); diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index d7645f96406..77491d1556b 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -71,6 +71,11 @@ export default class AvailableDropdownMappings { gl: DropdownUser, element: this.container.querySelector('#js-dropdown-assignee'), }, + reviewer: { + reference: null, + gl: DropdownUser, + element: this.container.querySelector('#js-dropdown-reviewer'), + }, 'approved-by': { reference: null, gl: DropdownUser, diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js index 6cd6f9c9906..08736b09407 100644 --- a/app/assets/javascripts/filtered_search/constants.js +++ b/app/assets/javascripts/filtered_search/constants.js @@ -1,4 +1,4 @@ -export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by']; +export const USER_TOKEN_TYPES = ['author', 'assignee', 'approved-by', 'reviewer']; export const DROPDOWN_TYPE = { hint: 'hint', diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index d2ac80fa190..6e742e4ca02 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -22,6 +22,15 @@ export const tokenKeys = [ tag: '@assignee', }, { + formattedKey: __('Reviewer'), + key: 'reviewer', + type: 'string', + param: 'username', + symbol: '@', + icon: 'user', + tag: '@reviewer', + }, + { formattedKey: __('Milestone'), key: 'milestone', type: 'string', @@ -86,6 +95,16 @@ export const conditions = flattenDeep( value: __('Any'), }, { + url: 'reviewer_id=None', + tokenKey: 'reviewer', + value: __('None'), + }, + { + url: 'reviewer_id=Any', + tokenKey: 'reviewer', + value: __('Any'), + }, + { url: 'author_username=support-bot', tokenKey: 'author', value: 'support-bot', diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue index 258633e0125..203d6a12edd 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -47,15 +47,14 @@ export default { class="dropdown-menu-toggle build-content gl-build-content" > <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> - <span class="gl-display-flex gl-align-items-center gl-w-90"> + <span class="gl-display-flex gl-align-items-center gl-min-w-0"> <ci-icon :status="group.status" :size="24" /> - - <span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block"> + <span class="gl-text-truncate mw-70p gl-pl-3"> {{ group.name }} </span> </span> - <span class="gl-font-weight-100 gl-font-size-lg gl-pr-2"> {{ group.size }} </span> + <span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span> </div> </button> diff --git a/app/assets/javascripts/registry/settings/components/expiration_textarea.vue b/app/assets/javascripts/registry/settings/components/expiration_input.vue index 3f817e37dca..2dbd9d26f60 100644 --- a/app/assets/javascripts/registry/settings/components/expiration_textarea.vue +++ b/app/assets/javascripts/registry/settings/components/expiration_input.vue @@ -1,11 +1,11 @@ <script> -import { GlFormGroup, GlFormTextarea, GlSprintf, GlLink } from '@gitlab/ui'; +import { GlFormGroup, GlFormInput, GlSprintf, GlLink } from '@gitlab/ui'; import { NAME_REGEX_LENGTH, TEXT_AREA_INVALID_FEEDBACK } from '../constants'; export default { components: { GlFormGroup, - GlFormTextarea, + GlFormInput, GlSprintf, GlLink, }, @@ -48,7 +48,7 @@ export default { textAreaLengthErrorMessage() { return this.isInputValid(this.value) ? '' : TEXT_AREA_INVALID_FEEDBACK; }, - textAreaValidation() { + inputValidation() { const nameRegexErrors = this.error || this.textAreaLengthErrorMessage; return { state: nameRegexErrors === null ? null : !nameRegexErrors, @@ -77,8 +77,8 @@ export default { <gl-form-group :id="`${name}-form-group`" :label-for="name" - :state="textAreaValidation.state" - :invalid-feedback="textAreaValidation.message" + :state="inputValidation.state" + :invalid-feedback="inputValidation.message" > <template #label> <span data-testid="label"> @@ -89,11 +89,11 @@ export default { </gl-sprintf> </span> </template> - <gl-form-textarea + <gl-form-input :id="name" v-model="internalValue" :placeholder="placeholder" - :state="textAreaValidation.state" + :state="inputValidation.state" :disabled="disabled" trim /> diff --git a/app/assets/javascripts/registry/settings/components/expiration_run_text.vue b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue index 186ad2f34b9..fd9ca6a54c5 100644 --- a/app/assets/javascripts/registry/settings/components/expiration_run_text.vue +++ b/app/assets/javascripts/registry/settings/components/expiration_run_text.vue @@ -13,6 +13,16 @@ export default { required: false, default: NOT_SCHEDULED_POLICY_TEXT, }, + enabled: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + parsedValue() { + return this.enabled ? this.value : NOT_SCHEDULED_POLICY_TEXT; + }, }, i18n: { NEXT_CLEANUP_LABEL, @@ -26,6 +36,11 @@ export default { :label="$options.i18n.NEXT_CLEANUP_LABEL" label-for="expiration-policy-info-text" > - <gl-form-input id="expiration-policy-info-text" class="gl-pl-0!" plaintext :value="value" /> + <gl-form-input + id="expiration-policy-info-text" + class="gl-pl-0!" + plaintext + :value="parsedValue" + /> </gl-form-group> </template> diff --git a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue index 9dabe8ac51a..7f045244926 100644 --- a/app/assets/javascripts/registry/settings/components/expiration_toggle.vue +++ b/app/assets/javascripts/registry/settings/components/expiration_toggle.vue @@ -1,6 +1,6 @@ <script> import { GlFormGroup, GlToggle, GlSprintf } from '@gitlab/ui'; -import { ENABLED_TEXT, DISABLED_TEXT, ENABLE_TOGGLE_DESCRIPTION } from '../constants'; +import { ENABLED_TOGGLE_DESCRIPTION, DISABLED_TOGGLE_DESCRIPTION } from '../constants'; export default { components: { @@ -20,9 +20,6 @@ export default { default: false, }, }, - i18n: { - ENABLE_TOGGLE_DESCRIPTION, - }, computed: { enabled: { get() { @@ -32,8 +29,8 @@ export default { this.$emit('input', value); }, }, - toggleStatusText() { - return this.enabled ? ENABLED_TEXT : DISABLED_TEXT; + toggleText() { + return this.enabled ? ENABLED_TOGGLE_DESCRIPTION : DISABLED_TOGGLE_DESCRIPTION; }, }, }; @@ -44,9 +41,9 @@ export default { <div class="gl-display-flex"> <gl-toggle id="expiration-policy-toggle" v-model="enabled" :disabled="disabled" /> <span class="gl-ml-5 gl-line-height-24" data-testid="description"> - <gl-sprintf :message="$options.i18n.ENABLE_TOGGLE_DESCRIPTION"> - <template #toggleStatus> - <strong>{{ toggleStatusText }}</strong> + <gl-sprintf :message="toggleText"> + <template #strong="{content}"> + <strong>{{ content }}</strong> </template> </gl-sprintf> </span> diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue index d0a8081e455..3eab7e6d038 100644 --- a/app/assets/javascripts/registry/settings/components/settings_form.vue +++ b/app/assets/javascripts/registry/settings/components/settings_form.vue @@ -25,7 +25,7 @@ import { formOptionsGenerator } from '~/registry/shared/utils'; import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.graphql'; import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update'; import ExpirationDropdown from './expiration_dropdown.vue'; -import ExpirationTextarea from './expiration_textarea.vue'; +import ExpirationInput from './expiration_input.vue'; import ExpirationToggle from './expiration_toggle.vue'; import ExpirationRunText from './expiration_run_text.vue'; @@ -35,7 +35,7 @@ export default { GlButton, GlSprintf, ExpirationDropdown, - ExpirationTextarea, + ExpirationInput, ExpirationToggle, ExpirationRunText, }, @@ -202,7 +202,11 @@ export default { data-testid="cadence-dropdown" @input="onModelChange($event, 'cadence')" /> - <expiration-run-text :value="prefilledForm.nextRunAt" class="gl-mb-0!" /> + <expiration-run-text + :value="prefilledForm.nextRunAt" + :enabled="prefilledForm.enabled" + class="gl-mb-0!" + /> </div> <gl-card class="gl-mt-7"> <template #header> @@ -229,14 +233,14 @@ export default { data-testid="keep-n-dropdown" @input="onModelChange($event, 'keepN')" /> - <expiration-textarea + <expiration-input v-model="prefilledForm.nameRegexKeep" :error="apiErrors.nameRegexKeep" :disabled="isFieldDisabled" :label="$options.i18n.NAME_REGEX_KEEP_LABEL" :description="$options.i18n.NAME_REGEX_KEEP_DESCRIPTION" name="keep-regex" - data-testid="keep-regex-textarea" + data-testid="keep-regex-input" @input="onModelChange($event, 'nameRegexKeep')" @validation="setLocalErrors($event, 'nameRegexKeep')" /> @@ -268,7 +272,7 @@ export default { data-testid="older-than-dropdown" @input="onModelChange($event, 'olderThan')" /> - <expiration-textarea + <expiration-input v-model="prefilledForm.nameRegex" :error="apiErrors.nameRegex" :disabled="isFieldDisabled" @@ -276,7 +280,7 @@ export default { :placeholder="$options.i18n.NAME_REGEX_PLACEHOLDER" :description="$options.i18n.NAME_REGEX_DESCRIPTION" name="remove-regex" - data-testid="remove-regex-textarea" + data-testid="remove-regex-input" @input="onModelChange($event, 'nameRegex')" @validation="setLocalErrors($event, 'nameRegex')" /> diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js index ba5820196ff..1dd533ce665 100644 --- a/app/assets/javascripts/registry/settings/constants.js +++ b/app/assets/javascripts/registry/settings/constants.js @@ -37,11 +37,11 @@ export const NAME_REGEX_DESCRIPTION = s__( 'ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}More information%{linkEnd}', ); -export const ENABLED_TEXT = __('Enabled'); -export const DISABLED_TEXT = __('Disabled'); - -export const ENABLE_TOGGLE_DESCRIPTION = s__( - 'ContainerRegistry|%{toggleStatus} - Tags that match the rules on this page are automatically scheduled for deletion.', +export const ENABLED_TOGGLE_DESCRIPTION = s__( + 'ContainerRegistry|%{strongStart}Enabled%{strongEnd} - Tags that match the rules on this page are automatically scheduled for deletion.', +); +export const DISABLED_TOGGLE_DESCRIPTION = s__( + 'ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted.', ); export const CADENCE_LABEL = s__('ContainerRegistry|Run cleanup:'); diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 7073b9ca12d..97674348436 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -250,6 +250,10 @@ export class SearchAutocomplete { url: `${mrPath}/?assignee_username=${userName}`, }, { + text: s__("SearchAutocomplete|Merge requests that I'm a reviewer"), + url: `${mrPath}/?reviewer_username=${userName}`, + }, + { text: s__("SearchAutocomplete|Merge requests I've created"), url: `${mrPath}/?author_username=${userName}`, }, diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index 502f5b08522..7b424882ffa 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -139,10 +139,6 @@ width: 186px; } -.gl-w-90 { - width: 90%; -} - .gl-build-content { @include build-content(); } diff --git a/app/graphql/mutations/environments/canary_ingress/update.rb b/app/graphql/mutations/environments/canary_ingress/update.rb new file mode 100644 index 00000000000..1798143053a --- /dev/null +++ b/app/graphql/mutations/environments/canary_ingress/update.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module Environments + module CanaryIngress + class Update < ::Mutations::BaseMutation + graphql_name 'EnvironmentsCanaryIngressUpdate' + + authorize :update_environment + + argument :id, + ::Types::GlobalIDType[::Environment], + required: true, + description: 'The global ID of the environment to update' + + argument :weight, + GraphQL::INT_TYPE, + required: true, + description: 'The weight of the Canary Ingress' + + def resolve(id:, **kwargs) + environment = authorized_find!(id: id) + + result = ::Environments::CanaryIngress::UpdateService + .new(environment.project, current_user, kwargs) + .execute_async(environment) + + { errors: Array.wrap(result[:message]) } + end + + def find_object(id:) + # TODO: remove as part of https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ::Types::GlobalIDType[::Environment].coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb index d6731dfcafd..5d6763d8711 100644 --- a/app/graphql/mutations/metrics/dashboard/annotations/delete.rb +++ b/app/graphql/mutations/metrics/dashboard/annotations/delete.rb @@ -11,7 +11,7 @@ module Mutations argument :id, ::Types::GlobalIDType[::Metrics::Dashboard::Annotation], required: true, - description: 'The global ID of the annotation to delete' + description: 'Global ID of the annotation to delete' def resolve(id:) annotation = authorized_find!(id: id) diff --git a/app/graphql/resolvers/ci/config_resolver.rb b/app/graphql/resolvers/ci/config_resolver.rb new file mode 100644 index 00000000000..d6e7c206691 --- /dev/null +++ b/app/graphql/resolvers/ci/config_resolver.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class ConfigResolver < BaseResolver + type Types::Ci::Config::ConfigType, null: true + + argument :content, GraphQL::STRING_TYPE, + required: true, + description: 'Contents of .gitlab-ci.yml' + + def resolve(content:) + result = ::Gitlab::Ci::YamlProcessor.new(content).execute + + response = if result.errors.empty? + { + status: :valid, + errors: [], + stages: make_stages(result.jobs) + } + else + { + status: :invalid, + errors: result.errors + } + end + + response.merge(merged_yaml: result.merged_yaml) + end + + private + + def make_jobs(config_jobs) + config_jobs.map do |job_name, job| + { + name: job_name, + stage: job[:stage], + group_name: CommitStatus.new(name: job_name).group_name, + needs: job.dig(:needs, :job) || [] + } + end + end + + def make_groups(job_data) + jobs = make_jobs(job_data) + + jobs_by_group = jobs.group_by { |job| job[:group_name] } + jobs_by_group.map do |name, jobs| + { jobs: jobs, name: name, stage: jobs.first[:stage], size: jobs.size } + end + end + + def make_stages(jobs) + make_groups(jobs) + .group_by { |group| group[:stage] } + .map { |name, groups| { name: name, groups: groups } } + end + end + end +end diff --git a/app/graphql/types/ci/config/config_type.rb b/app/graphql/types/ci/config/config_type.rb new file mode 100644 index 00000000000..e54b345f3d3 --- /dev/null +++ b/app/graphql/types/ci/config/config_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class ConfigType < BaseObject + graphql_name 'CiConfig' + + field :errors, [GraphQL::STRING_TYPE], null: true, + description: 'Linting errors' + field :merged_yaml, GraphQL::STRING_TYPE, null: true, + description: 'Merged CI config YAML' + field :stages, [Types::Ci::Config::StageType], null: true, + description: 'Stages of the pipeline' + field :status, Types::Ci::Config::StatusEnum, null: true, + description: 'Status of linting, can be either valid or invalid' + end + end + end +end diff --git a/app/graphql/types/ci/config/group_type.rb b/app/graphql/types/ci/config/group_type.rb new file mode 100644 index 00000000000..8b0db2934a4 --- /dev/null +++ b/app/graphql/types/ci/config/group_type.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class GroupType < BaseObject + graphql_name 'CiConfigGroup' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job group' + field :jobs, [Types::Ci::Config::JobType], null: true, + description: 'Jobs in group' + field :size, GraphQL::INT_TYPE, null: true, + description: 'Size of the job group' + end + end + end +end diff --git a/app/graphql/types/ci/config/job_type.rb b/app/graphql/types/ci/config/job_type.rb new file mode 100644 index 00000000000..59bcbd9ef49 --- /dev/null +++ b/app/graphql/types/ci/config/job_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class JobType < BaseObject + graphql_name 'CiConfigJob' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job' + field :group_name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job group' + field :stage, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job stage' + field :needs, [Types::Ci::Config::NeedType], null: true, + description: 'Builds that must complete before the jobs run' + end + end + end +end diff --git a/app/graphql/types/ci/config/need_type.rb b/app/graphql/types/ci/config/need_type.rb new file mode 100644 index 00000000000..a442450b9ae --- /dev/null +++ b/app/graphql/types/ci/config/need_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class NeedType < BaseObject + graphql_name 'CiConfigNeed' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the need' + end + end + end +end diff --git a/app/graphql/types/ci/config/stage_type.rb b/app/graphql/types/ci/config/stage_type.rb new file mode 100644 index 00000000000..20618bc41f8 --- /dev/null +++ b/app/graphql/types/ci/config/stage_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + module Config + class StageType < BaseObject + graphql_name 'CiConfigStage' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the stage' + field :groups, [Types::Ci::Config::GroupType], null: true, + description: 'Groups of jobs for the stage' + end + end + end +end diff --git a/app/graphql/types/ci/config/status_enum.rb b/app/graphql/types/ci/config/status_enum.rb new file mode 100644 index 00000000000..92b04c61679 --- /dev/null +++ b/app/graphql/types/ci/config/status_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + module Config + class StatusEnum < BaseEnum + graphql_name 'CiConfigStatus' + description 'Values for YAML processor result' + + value 'VALID', 'The configuration file is valid', value: :valid + value 'INVALID', 'The configuration file is not valid', value: :invalid + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 93c650c0b97..4c9070e4d5a 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -31,6 +31,7 @@ module Types mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji mount_mutation Mutations::Discussions::ToggleResolve + mount_mutation Mutations::Environments::CanaryIngress::Update mount_mutation Mutations::Issues::Create mount_mutation Mutations::Issues::SetAssignees mount_mutation Mutations::Issues::SetConfidential diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 75ff6f8deca..05bb371088c 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -91,6 +91,11 @@ module Types description: 'Get runner setup instructions', resolver: Resolvers::Ci::RunnerSetupResolver + field :ci_config, Types::Ci::Config::ConfigType, null: true, + description: 'Get linted and processed contents of a CI config. Should not be requested more than once per request.', + resolver: Resolvers::Ci::ConfigResolver, + complexity: 126 # AUTHENTICATED_COMPLEXITY / 2 + 1 + def design_management DesignManagementObject.new(nil) end diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index f7f7bb99b5a..fa9b9fc9e2d 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -69,6 +69,11 @@ module AlertManagement unknown: 5 } + enum domain: { + operations: 0, + threat_monitoring: 1 + } + state_machine :status, initial: :triggered do state :triggered, value: STATUSES[:triggered] diff --git a/app/services/environments/canary_ingress/update_service.rb b/app/services/environments/canary_ingress/update_service.rb new file mode 100644 index 00000000000..474c3de23d9 --- /dev/null +++ b/app/services/environments/canary_ingress/update_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Environments + module CanaryIngress + class UpdateService < ::BaseService + def execute_async(environment) + result = validate(environment) + + return result unless result[:status] == :success + + Environments::CanaryIngress::UpdateWorker.perform_async(environment.id, params) + + success + end + + # This method actually executes the PATCH request to Kubernetes, + # that is used by internal processes i.e. sidekiq worker. + # You should always use `execute_async` to properly validate user's requests. + def execute(environment) + canary_ingress = environment.ingresses&.find(&:canary?) + + unless canary_ingress.present? + return error(_('Canary Ingress does not exist in the environment.')) + end + + if environment.patch_ingress(canary_ingress, patch_data) + success + else + error(_('Failed to update the Canary Ingress.'), :bad_request) + end + end + + private + + def validate(environment) + unless Feature.enabled?(:canary_ingress_weight_control, environment.project, default_enabled: true) + return error(_("Feature flag is not enabled on the environment's project.")) + end + + unless can?(current_user, :update_environment, environment) + return error(_('You do not have permission to update the environment.')) + end + + unless params[:weight].is_a?(Integer) && (0..100).cover?(params[:weight]) + return error(_('Canary weight must be specified and valid range (0..100).')) + end + + if environment.has_running_deployments? + return error(_('There are running deployments on the environment. Please retry later.')) + end + + if ::Gitlab::ApplicationRateLimiter.throttled?(:update_environment_canary_ingress, scope: [environment]) + return error(_("This environment's canary ingress has been updated recently. Please retry later.")) + end + + success + end + + def patch_data + { + metadata: { + annotations: { + Gitlab::Kubernetes::Ingress::ANNOTATION_KEY_CANARY_WEIGHT => params[:weight].to_s + } + } + } + end + end + end +end diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 5df7ade8609..79d86500bd9 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -75,6 +75,22 @@ = render 'shared/issuable/user_dropdown_item', user: User.new(username: '{{username}}', name: '{{name}}'), avatar: { lazy: true, url: '{{avatar_url}}' } + #js-dropdown-reviewer.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + - if current_user + = render 'shared/issuable/user_dropdown_item', + user: current_user + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } = render_if_exists 'shared/issuable/approver_dropdown' = render_if_exists 'shared/issuable/approved_by_dropdown' #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 0aa27c0d208..416c10e46fe 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1537,6 +1537,14 @@ :weight: 2 :idempotent: :tags: [] +- :name: environments_canary_ingress_update + :feature_category: :continuous_delivery + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: error_tracking_issue_link :feature_category: :error_tracking :has_external_dependencies: true diff --git a/app/workers/environments/canary_ingress/update_worker.rb b/app/workers/environments/canary_ingress/update_worker.rb new file mode 100644 index 00000000000..53cc38e9eec --- /dev/null +++ b/app/workers/environments/canary_ingress/update_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Environments + module CanaryIngress + class UpdateWorker + include ApplicationWorker + + sidekiq_options retry: false + idempotent! + worker_has_external_dependencies! + feature_category :continuous_delivery + + def perform(environment_id, params) + Environment.find_by_id(environment_id).try do |environment| + Environments::CanaryIngress::UpdateService + .new(environment.project, nil, params.with_indifferent_access) + .execute(environment) + end + end + end + end +end diff --git a/changelogs/unreleased/212320-move-canary-ingress-to-core.yml b/changelogs/unreleased/212320-move-canary-ingress-to-core.yml new file mode 100644 index 00000000000..d35b927573e --- /dev/null +++ b/changelogs/unreleased/212320-move-canary-ingress-to-core.yml @@ -0,0 +1,5 @@ +--- +title: Move CanaryIngress to core +merge_request: 48836 +author: +type: changed diff --git a/changelogs/unreleased/233994_add_increment_counter_js_tracking.yml b/changelogs/unreleased/233994_add_increment_counter_js_tracking.yml new file mode 100644 index 00000000000..19ff6938535 --- /dev/null +++ b/changelogs/unreleased/233994_add_increment_counter_js_tracking.yml @@ -0,0 +1,5 @@ +--- +title: Frontend client for increment_counter API +merge_request: 47622 +author: +type: added diff --git a/changelogs/unreleased/add-domain-type-to-alerts.yml b/changelogs/unreleased/add-domain-type-to-alerts.yml new file mode 100644 index 00000000000..eb95628ecad --- /dev/null +++ b/changelogs/unreleased/add-domain-type-to-alerts.yml @@ -0,0 +1,5 @@ +--- +title: Add domain column to alerts table +merge_request: 49120 +author: +type: added diff --git a/changelogs/unreleased/lm-add-ci-config-0.yml b/changelogs/unreleased/lm-add-ci-config-0.yml new file mode 100644 index 00000000000..7bda30712a6 --- /dev/null +++ b/changelogs/unreleased/lm-add-ci-config-0.yml @@ -0,0 +1,5 @@ +--- +title: 'Expose GraphQL resolver for processing CI config' +merge_request: 46912 +author: +type: added diff --git a/db/migrate/20201203123524_add_domain_enum_to_alerts.rb b/db/migrate/20201203123524_add_domain_enum_to_alerts.rb new file mode 100644 index 00000000000..f1dec91a346 --- /dev/null +++ b/db/migrate/20201203123524_add_domain_enum_to_alerts.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddDomainEnumToAlerts < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + with_lock_retries do + add_column :alert_management_alerts, :domain, :integer, limit: 2, default: 0 + end + end + + def down + with_lock_retries do + remove_column :alert_management_alerts, :domain, :integer, limit: 2 + end + end +end diff --git a/db/migrate/20201203171631_add_index_to_domain.rb b/db/migrate/20201203171631_add_index_to_domain.rb new file mode 100644 index 00000000000..dc7b9539e95 --- /dev/null +++ b/db/migrate/20201203171631_add_index_to_domain.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddIndexToDomain < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + DOWNTIME = false + INDEX_NAME = 'index_alert_management_alerts_on_domain' + + disable_ddl_transaction! + + def up + add_concurrent_index :alert_management_alerts, :domain, name: INDEX_NAME + end + + def down + remove_concurrent_index :alert_management_alerts, :domain, name: INDEX_NAME + end +end diff --git a/db/schema_migrations/20201203123524 b/db/schema_migrations/20201203123524 new file mode 100644 index 00000000000..27f47a237c0 --- /dev/null +++ b/db/schema_migrations/20201203123524 @@ -0,0 +1 @@ +4bb54293c339e20082a739f7724b02141d8fb3b0b140e21ac2acab6cbd2d2f01
\ No newline at end of file diff --git a/db/schema_migrations/20201203171631 b/db/schema_migrations/20201203171631 new file mode 100644 index 00000000000..e93633344b9 --- /dev/null +++ b/db/schema_migrations/20201203171631 @@ -0,0 +1 @@ +3b6d3fb9c279f5e8c76921e654b188a5a5ba0fddd7ff753a03706b41f43240ed
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 385183bb176..76f6f1561f8 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -8811,6 +8811,7 @@ CREATE TABLE alert_management_alerts ( payload jsonb DEFAULT '{}'::jsonb NOT NULL, prometheus_alert_id integer, environment_id integer, + domain smallint DEFAULT 0, CONSTRAINT check_2df3e2fdc1 CHECK ((char_length(monitoring_tool) <= 100)), CONSTRAINT check_5e9e57cadb CHECK ((char_length(description) <= 1000)), CONSTRAINT check_bac14dddde CHECK ((char_length(service) <= 100)), @@ -20435,6 +20436,8 @@ CREATE INDEX index_alert_assignees_on_alert_id ON alert_management_alert_assigne CREATE UNIQUE INDEX index_alert_assignees_on_user_id_and_alert_id ON alert_management_alert_assignees USING btree (user_id, alert_id); +CREATE INDEX index_alert_management_alerts_on_domain ON alert_management_alerts USING btree (domain); + CREATE INDEX index_alert_management_alerts_on_environment_id ON alert_management_alerts USING btree (environment_id) WHERE (environment_id IS NOT NULL); CREATE INDEX index_alert_management_alerts_on_issue_id ON alert_management_alerts USING btree (issue_id); diff --git a/doc/administration/troubleshooting/kubernetes_cheat_sheet.md b/doc/administration/troubleshooting/kubernetes_cheat_sheet.md index 1cb3afcc3af..aa5e440b8b1 100644 --- a/doc/administration/troubleshooting/kubernetes_cheat_sheet.md +++ b/doc/administration/troubleshooting/kubernetes_cheat_sheet.md @@ -126,7 +126,7 @@ and they will assist you with any issues you are having. kubectl get pods | grep task-runner # enter it - kubectl exec -it <task-runner-pod-name> bash + kubectl exec -it <task-runner-pod-name> -- bash # open rails console # rails console can be also called from other GitLab pods @@ -139,10 +139,10 @@ and they will assist you with any issues you are having. /usr/local/bin/gitlab-rake gitlab:check # open console without entering pod - kubectl exec -it <task-runner-pod-name> /srv/gitlab/bin/rails console + kubectl exec -it <task-runner-pod-name> -- /srv/gitlab/bin/rails console # check the status of DB migrations - kubectl exec -it <task-runner-pod-name> /usr/local/bin/gitlab-rake db:migrate:status + kubectl exec -it <task-runner-pod-name> -- /usr/local/bin/gitlab-rake db:migrate:status ``` You can also use `gitlab-rake`, instead of `/usr/local/bin/gitlab-rake`. diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index f7a1cff7afd..be9d1d8e765 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -2237,6 +2237,101 @@ type BurnupChartDailyTotals { scopeWeight: Int! } +type CiConfig { + """ + Linting errors + """ + errors: [String!] + + """ + Merged CI config YAML + """ + mergedYaml: String + + """ + Stages of the pipeline + """ + stages: [CiConfigStage!] + + """ + Status of linting, can be either valid or invalid + """ + status: CiConfigStatus +} + +type CiConfigGroup { + """ + Jobs in group + """ + jobs: [CiConfigJob!] + + """ + Name of the job group + """ + name: String + + """ + Size of the job group + """ + size: Int +} + +type CiConfigJob { + """ + Name of the job group + """ + groupName: String + + """ + Name of the job + """ + name: String + + """ + Builds that must complete before the jobs run + """ + needs: [CiConfigNeed!] + + """ + Name of the job stage + """ + stage: String +} + +type CiConfigNeed { + """ + Name of the need + """ + name: String +} + +type CiConfigStage { + """ + Groups of jobs for the stage + """ + groups: [CiConfigGroup!] + + """ + Name of the stage + """ + name: String +} + +""" +Values for YAML processor result +""" +enum CiConfigStatus { + """ + The configuration file is not valid + """ + INVALID + + """ + The configuration file is valid + """ + VALID +} + type CiGroup { """ Detailed status of the group @@ -5415,7 +5510,7 @@ input DeleteAnnotationInput { clientMutationId: String """ - The global ID of the annotation to delete + Global ID of the annotation to delete """ id: MetricsDashboardAnnotationID! } @@ -17906,6 +18001,16 @@ type PromoteToEpicPayload { type Query { """ + Get linted and processed contents of a CI config. Should not be requested more than once per request. + """ + ciConfig( + """ + Contents of .gitlab-ci.yml + """ + content: String! + ): CiConfig + + """ Find a container repository """ containerRepository( diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 2a10f750dfb..20398bac168 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -6002,6 +6002,330 @@ }, { "kind": "OBJECT", + "name": "CiConfig", + "description": null, + "fields": [ + { + "name": "errors", + "description": "Linting errors", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mergedYaml", + "description": "Merged CI config YAML", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "stages", + "description": "Stages of the pipeline", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CiConfigStage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": "Status of linting, can be either valid or invalid", + "args": [ + + ], + "type": { + "kind": "ENUM", + "name": "CiConfigStatus", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CiConfigGroup", + "description": null, + "fields": [ + { + "name": "jobs", + "description": "Jobs in group", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CiConfigJob", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "Name of the job group", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "size", + "description": "Size of the job group", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CiConfigJob", + "description": null, + "fields": [ + { + "name": "groupName", + "description": "Name of the job group", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "Name of the job", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "needs", + "description": "Builds that must complete before the jobs run", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CiConfigNeed", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "stage", + "description": "Name of the job stage", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CiConfigNeed", + "description": null, + "fields": [ + { + "name": "name", + "description": "Name of the need", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CiConfigStage", + "description": null, + "fields": [ + { + "name": "groups", + "description": "Groups of jobs for the stage", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CiConfigGroup", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "Name of the stage", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CiConfigStatus", + "description": "Values for YAML processor result", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "VALID", + "description": "The configuration file is valid", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INVALID", + "description": "The configuration file is not valid", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", "name": "CiGroup", "description": null, "fields": [ @@ -14822,7 +15146,7 @@ "inputFields": [ { "name": "id", - "description": "The global ID of the annotation to delete", + "description": "Global ID of the annotation to delete", "type": { "kind": "NON_NULL", "name": null, @@ -52315,6 +52639,33 @@ "description": null, "fields": [ { + "name": "ciConfig", + "description": "Get linted and processed contents of a CI config. Should not be requested more than once per request.", + "args": [ + { + "name": "content", + "description": "Contents of .gitlab-ci.yml", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CiConfig", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "containerRepository", "description": "Find a container repository", "args": [ diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index eb3661854d8..fa568cb9452 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -367,6 +367,45 @@ Represents the total number of issues and their weights for a particular day. | `scopeCount` | Int! | Number of issues as of this day | | `scopeWeight` | Int! | Total weight of issues as of this day | +### CiConfig + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `errors` | String! => Array | Linting errors | +| `mergedYaml` | String | Merged CI config YAML | +| `stages` | CiConfigStage! => Array | Stages of the pipeline | +| `status` | CiConfigStatus | Status of linting, can be either valid or invalid | + +### CiConfigGroup + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `jobs` | CiConfigJob! => Array | Jobs in group | +| `name` | String | Name of the job group | +| `size` | Int | Size of the job group | + +### CiConfigJob + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `groupName` | String | Name of the job group | +| `name` | String | Name of the job | +| `needs` | CiConfigNeed! => Array | Builds that must complete before the jobs run | +| `stage` | String | Name of the job stage | + +### CiConfigNeed + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `name` | String | Name of the need | + +### CiConfigStage + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `groups` | CiConfigGroup! => Array | Groups of jobs for the stage | +| `name` | String | Name of the stage | + ### CiGroup | Field | Type | Description | @@ -3925,6 +3964,15 @@ Types of blob viewers. | `rich` | | | `simple` | | +### CiConfigStatus + +Values for YAML processor result. + +| Value | Description | +| ----- | ----------- | +| `INVALID` | The configuration file is not valid | +| `VALID` | The configuration file is valid | + ### CommitActionMode Mode of a commit action. diff --git a/doc/development/product_analytics/snowplow.md b/doc/development/product_analytics/snowplow.md index b864e48fa5b..43ffaf45098 100644 --- a/doc/development/product_analytics/snowplow.md +++ b/doc/development/product_analytics/snowplow.md @@ -370,48 +370,79 @@ Snowplow Micro is a Docker-based solution for testing frontend and backend event - Look at the [Snowplow Micro repository](https://github.com/snowplow-incubator/snowplow-micro) - Watch our [installation guide recording](https://www.youtube.com/watch?v=OX46fo_A0Ag) -1. Install [Snowplow Micro](https://github.com/snowplow-incubator/snowplow-micro): +1. Ensure Docker is installed and running. - ```shell - docker run --mount type=bind,source=$(pwd)/example,destination=/config -p 9090:9090 snowplow/snowplow-micro:latest --collector-config /config/micro.conf --iglu /config/iglu.json - ``` - -1. Install Snowplow Micro by cloning the settings in [this project](https://gitlab.com/gitlab-org/snowplow-micro-configuration): +1. Install [Snowplow Micro](https://github.com/snowplow-incubator/snowplow-micro) by cloning the settings in [this project](https://gitlab.com/gitlab-org/snowplow-micro-configuration): +1. Navigate to the directory with the cloned project, and start the appropriate Docker + container with the following script: ```shell - git clone git@gitlab.com:gitlab-org/snowplow-micro-configuration.git ./snowplow-micro.sh ``` -1. Update port in SQL to set `9090`: +1. Update your instance's settings to enable Snowplow events and point to the Snowplow Micro collector: ```shell gdk psql -d gitlabhq_development update application_settings set snowplow_collector_hostname='localhost:9090', snowplow_enabled=true, snowplow_cookie_domain='.gitlab.com'; ``` -1. Update `app/assets/javascripts/tracking.js` to [remove this line](https://gitlab.com/snippets/1918635): +1. Update `DEFAULT_SNOWPLOW_OPTIONS` in `app/assets/javascripts/tracking.js` to remove `forceSecureTracker: true`: + + ```diff + diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js + index 0a1211d0a76..3b98c8f28f2 100644 + --- a/app/assets/javascripts/tracking.js + +++ b/app/assets/javascripts/tracking.js + @@ -7,7 +7,6 @@ const DEFAULT_SNOWPLOW_OPTIONS = { + appId: '', + userFingerprint: false, + respectDoNotTrack: true, + - forceSecureTracker: true, + eventMethod: 'post', + contexts: { webPage: true, performanceTiming: true }, + formTracking: false, - ```javascript - forceSecureTracker: true ``` -1. Update `lib/gitlab/tracking.rb` to [add these lines](https://gitlab.com/snippets/1918635): - - ```ruby - protocol: 'http', - port: 9090, +1. Update `snowplow_options` in `lib/gitlab/tracking.rb` to add `protocol` and `port`: + + ```diff + diff --git a/lib/gitlab/tracking.rb b/lib/gitlab/tracking.rb + index 618e359211b..e9084623c43 100644 + --- a/lib/gitlab/tracking.rb + +++ b/lib/gitlab/tracking.rb + @@ -41,7 +41,9 @@ def snowplow_options(group) + cookie_domain: Gitlab::CurrentSettings.snowplow_cookie_domain, + app_id: Gitlab::CurrentSettings.snowplow_app_id, + form_tracking: additional_features, + - link_click_tracking: additional_features + + link_click_tracking: additional_features, + + protocol: 'http', + + port: 9090 + }.transform_keys! { |key| key.to_s.camelize(:lower).to_sym } + end ``` -1. Update `lib/gitlab/tracking.rb` to [change async emitter from https to http](https://gitlab.com/snippets/1918635): +1. Update `emitter` in `lib/gitlab/tracking/destinations/snowplow.rb` to change `protocol`: + + ```diff + diff --git a/lib/gitlab/tracking/destinations/snowplow.rb b/lib/gitlab/tracking/destinations/snowplow.rb + index 4fa844de325..5dd9d0eacfb 100644 + --- a/lib/gitlab/tracking/destinations/snowplow.rb + +++ b/lib/gitlab/tracking/destinations/snowplow.rb + @@ -40,7 +40,7 @@ def tracker + def emitter + SnowplowTracker::AsyncEmitter.new( + Gitlab::CurrentSettings.snowplow_collector_hostname, + - protocol: 'https' + + protocol: 'http' + ) + end + end - ```ruby - SnowplowTracker::AsyncEmitter.new(Gitlab::CurrentSettings.snowplow_collector_hostname, protocol: 'http'), ``` -1. Enable Snowplow in the admin area, Settings::Integrations::Snowplow to point to: - `http://localhost:3000/admin/application_settings/integrations#js-snowplow-settings`. - 1. Restart GDK: ```shell @@ -423,6 +454,8 @@ Snowplow Micro is a Docker-based solution for testing frontend and backend event ```ruby Gitlab::Tracking.self_describing_event('iglu:com.gitlab/pageview_context/jsonschema/1-0-0', data: { page_type: 'MY_TYPE' }, context: nil) ``` + +1. Navigate to `localhost:9090/micro/good` to see the event. ### Snowplow Mini diff --git a/doc/development/product_analytics/usage_ping.md b/doc/development/product_analytics/usage_ping.md index c7e8155a754..3923e046ec2 100644 --- a/doc/development/product_analytics/usage_ping.md +++ b/doc/development/product_analytics/usage_ping.md @@ -265,6 +265,45 @@ Examples of implementation: - Using Redis methods [`INCR`](https://redis.io/commands/incr), [`GET`](https://redis.io/commands/get), and [`Gitlab::UsageDataCounters::WikiPageCounter`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_data_counters/wiki_page_counter.rb) - Using Redis methods [`HINCRBY`](https://redis.io/commands/hincrby), [`HGETALL`](https://redis.io/commands/hgetall), and [`Gitlab::UsageCounters::PodLogs`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_counters/pod_logs.rb) +##### UsageData API Tracking + +<!-- There's nearly identical content in `##### Adding new events`. If you fix errors here, you may need to fix the same errors in the other location. --> + +1. Track event using `UsageData` API + + Increment event count using ordinary Redis counter, for given event name. + + Tracking events using the `UsageData` API requires the `usage_data_api` feature flag to be enabled, which is enabled by default. + + API requests are protected by checking for a valid CSRF token. + + In order to be able to increment the values the related feature `usage_data_<event_name>` should be enabled. + + ```plaintext + POST /usage_data/increment_counter + ``` + + | Attribute | Type | Required | Description | + | :-------- | :--- | :------- | :---------- | + | `event` | string | yes | The event name it should be tracked | + + Response + + - `200` if event was tracked + - `400 Bad request` if event parameter is missing + - `401 Unauthorized` if user is not authenticated + - `403 Forbidden` for invalid CSRF token provided + +1. Track events using JavaScript/Vue API helper which calls the API above + + Note that `usage_data_api` and `usage_data_#{event_name}` should be enabled in order to be able to track events + + ```javascript + import api from '~/api'; + + api.trackRedisCounterEvent('my_already_defined_event_name'), + ``` + #### Redis HLL Counters With `Gitlab::UsageDataCounters::HLLRedisCounter` we have available data structures used to count unique values. @@ -387,6 +426,8 @@ Implemented using Redis methods [PFADD](https://redis.io/commands/pfadd) and [PF track_usage_event(:incident_management_incident_created, current_user.id) ``` +<!-- There's nearly identical content in `##### UsageData API Tracking`. If you find / fix errors here, you may need to fix errors in that section too. --> + 1. Track event using `UsageData` API Increment unique users count using Redis HLL, for given event name. diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index df4e5ea091e..cc9145b9e7b 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -250,6 +250,9 @@ For example, to unlink the `MyOrg` account, the following **Disconnect** button ## Group Sync +<i class="fa fa-youtube-play youtube" aria-hidden="true"></i> +For a demo of Group Sync using Azure, see [Demo: SAML Group Sync](https://youtu.be/Iqvo2tJfXjg). + When the SAML response includes a user and their group memberships from the SAML identity provider, GitLab uses that information to automatically manage that user's GitLab group memberships. diff --git a/doc/user/project/canary_deployments.md b/doc/user/project/canary_deployments.md index c7985467b5c..f3bb12c8a63 100644 --- a/doc/user/project/canary_deployments.md +++ b/doc/user/project/canary_deployments.md @@ -68,9 +68,10 @@ can easily notice them. ![Canary deployments on Deploy Board](img/deploy_boards_canary_deployments.png) -### Advanced traffic control with Canary Ingress **(PREMIUM)** +### Advanced traffic control with Canary Ingress -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/215501) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.6. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/215501) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.6. +> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212320) to Core in GitLab 13.7. Canary deployments can be more strategic with [Canary Ingress](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#canary), which is an advanced traffic routing service that controls incoming HTTP diff --git a/lib/api/entities/project_import_status.rb b/lib/api/entities/project_import_status.rb index f92593da3fa..e79c1cdf1a2 100644 --- a/lib/api/entities/project_import_status.rb +++ b/lib/api/entities/project_import_status.rb @@ -12,9 +12,8 @@ module API project.import_state&.relation_hard_failures(limit: 100) || [] end - # TODO: Use `expose_nil` once we upgrade the grape-entity gem - expose :import_error, if: lambda { |project, _ops| project.import_state&.last_error } do |project| - project.import_state.last_error + expose :import_error do |project, _options| + project.import_state&.last_error end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 98b52d679cd..f79660377fb 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7300,13 +7300,16 @@ msgstr[1] "" msgid "ContainerRegistry|%{imageName} tags" msgstr "" -msgid "ContainerRegistry|%{title} was successfully scheduled for deletion" +msgid "ContainerRegistry|%{strongStart}Disabled%{strongEnd} - Tags will not be automatically deleted." msgstr "" -msgid "ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion" +msgid "ContainerRegistry|%{strongStart}Enabled%{strongEnd} - Tags that match the rules on this page are automatically scheduled for deletion." +msgstr "" + +msgid "ContainerRegistry|%{title} was successfully scheduled for deletion" msgstr "" -msgid "ContainerRegistry|%{toggleStatus} - Tags that match the rules on this page are automatically scheduled for deletion." +msgid "ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion" msgstr "" msgid "ContainerRegistry|Build an image" @@ -24067,6 +24070,9 @@ msgstr "" msgid "SearchAutocomplete|Merge requests assigned to me" msgstr "" +msgid "SearchAutocomplete|Merge requests that I'm a reviewer" +msgstr "" + msgid "SearchAutocomplete|in all GitLab" msgstr "" @@ -27288,9 +27294,6 @@ msgstr "" msgid "The issue was successfully promoted to an epic. Redirecting to epic..." msgstr "" -msgid "The license for Deploy Board is required to use this feature." -msgstr "" - msgid "The license key is invalid. Make sure it is exactly as you received it from GitLab Inc." msgstr "" diff --git a/spec/features/dashboard/merge_requests_spec.rb b/spec/features/dashboard/merge_requests_spec.rb index 0237bbc793b..0e76b5478a1 100644 --- a/spec/features/dashboard/merge_requests_spec.rb +++ b/spec/features/dashboard/merge_requests_spec.rb @@ -172,4 +172,25 @@ RSpec.describe 'Dashboard Merge Requests' do expect(find('.issues-filters')).to have_content('Created date') end end + + context 'merge request review', :js do + let_it_be(:author_user) { create(:user) } + let!(:review_requested_merge_request) do + create(:merge_request, + reviewers: [current_user], + source_branch: 'review', + source_project: project, + author: author_user) + end + + before do + visit merge_requests_dashboard_path(reviewer_username: current_user.username) + end + + it 'displays review requested merge requests' do + expect(page).to have_content(review_requested_merge_request.title) + + expect_tokens([reviewer_token(current_user.name)]) + end + end end diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb index 120c5b56e03..2b03ecf5af1 100644 --- a/spec/features/projects/settings/registry_settings_spec.rb +++ b/spec/features/projects/settings/registry_settings_spec.rb @@ -84,7 +84,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p within '#js-registry-policies' do case result when :available_section - expect(find('[data-testid="enable-toggle"]')).to have_content('Tags that match the rules on this page are automatically scheduled for deletion.') + expect(find('[data-testid="enable-toggle"]')).to have_content('Disabled - Tags will not be automatically deleted.') when :disabled_message expect(find('.gl-alert-title')).to have_content('Cleanup policy for tags is disabled') end diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 724d33922a1..37630c15b89 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -1254,6 +1254,46 @@ describe('Api', () => { }); }); + describe('trackRedisCounterEvent', () => { + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/usage_data/increment_counter`; + + const event = 'dummy_event'; + const postData = { event }; + const headers = { + 'Content-Type': 'application/json', + }; + + describe('when usage data increment counter is called with feature flag disabled', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: false }; + }); + + it('returns null', () => { + jest.spyOn(axios, 'post'); + mock.onPost(expectedUrl).replyOnce(httpStatus.OK, true); + + expect(axios.post).toHaveBeenCalledTimes(0); + expect(Api.trackRedisCounterEvent(event)).toEqual(null); + }); + }); + + describe('when usage data increment counter is called', () => { + beforeEach(() => { + gon.features = { ...gon.features, usageDataApi: true }; + }); + + it('resolves the Promise', () => { + jest.spyOn(axios, 'post'); + mock.onPost(expectedUrl, { event }).replyOnce(httpStatus.OK, true); + + return Api.trackRedisCounterEvent(event).then(({ data }) => { + expect(data).toEqual(true); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { headers }); + }); + }); + }); + }); + describe('trackRedisHllUserEvent', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/usage_data/increment_unique_users`; diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index a9b1c81d00e..eb9f9b4db73 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -181,7 +181,7 @@ describe('Diff settings dropdown component', () => { ${true} | ${true} ${false} | ${false} `( - 'sets { checked: $checked } if the fileByFile setting is $fileByFile', + 'sets the checkbox to { checked: $checked } if the fileByFile setting is $fileByFile', async ({ fileByFile, checked }) => { createComponent(store => { Object.assign(store.state.diffs, { @@ -191,7 +191,7 @@ describe('Diff settings dropdown component', () => { await vm.$nextTick(); - expect(vm.checked).toBe(checked); + expect(getFileByFileCheckbox(wrapper).element.checked).toBe(checked); }, ); diff --git a/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap index 86895341f2c..d7f89ce070e 100644 --- a/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap +++ b/spec/frontend/registry/settings/components/__snapshots__/settings_form_spec.js.snap @@ -30,8 +30,8 @@ exports[`Settings Form Keep N matches snapshot 1`] = ` `; exports[`Settings Form Keep Regex matches snapshot 1`] = ` -<expiration-textarea-stub - data-testid="keep-regex-textarea" +<expiration-input-stub + data-testid="keep-regex-input" description="Tags with names that match this regex pattern are kept. %{linkStart}More information%{linkEnd}" error="" label="Keep tags matching:" @@ -52,8 +52,8 @@ exports[`Settings Form OlderThan matches snapshot 1`] = ` `; exports[`Settings Form Remove regex matches snapshot 1`] = ` -<expiration-textarea-stub - data-testid="remove-regex-textarea" +<expiration-input-stub + data-testid="remove-regex-input" description="Tags with names that match this regex pattern are removed. %{linkStart}More information%{linkEnd}" error="" label="Remove tags matching:" diff --git a/spec/frontend/registry/settings/components/expiration_textarea_spec.js b/spec/frontend/registry/settings/components/expiration_input_spec.js index 80464c61117..cb5034d2864 100644 --- a/spec/frontend/registry/settings/components/expiration_textarea_spec.js +++ b/spec/frontend/registry/settings/components/expiration_input_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; -import { GlSprintf, GlFormTextarea, GlLink } from '@gitlab/ui'; +import { GlSprintf, GlFormInput, GlLink } from '@gitlab/ui'; import { GlFormGroup } from 'jest/registry/shared/stubs'; -import component from '~/registry/settings/components/expiration_textarea.vue'; +import component from '~/registry/settings/components/expiration_input.vue'; import { NAME_REGEX_LENGTH } from '~/registry/shared/constants'; -describe('ExpirationTextarea', () => { +describe('ExpirationInput', () => { let wrapper; const defaultProps = { @@ -16,7 +16,7 @@ describe('ExpirationTextarea', () => { const tagsRegexHelpPagePath = 'fooPath'; - const findTextArea = () => wrapper.find(GlFormTextarea); + const findInput = () => wrapper.find(GlFormInput); const findFormGroup = () => wrapper.find(GlFormGroup); const findLabel = () => wrapper.find('[data-testid="label"]'); const findDescription = () => wrapper.find('[data-testid="description"]'); @@ -53,7 +53,7 @@ describe('ExpirationTextarea', () => { it('has a textarea component', () => { mountComponent(); - expect(findTextArea().exists()).toBe(true); + expect(findInput().exists()).toBe(true); }); it('has a description', () => { @@ -78,7 +78,7 @@ describe('ExpirationTextarea', () => { mountComponent({ value, disabled }); - expect(findTextArea().attributes()).toMatchObject({ + expect(findInput().attributes()).toMatchObject({ id: defaultProps.name, value, placeholder: defaultProps.placeholder, @@ -92,7 +92,7 @@ describe('ExpirationTextarea', () => { mountComponent(); - findTextArea().vm.$emit('input', emittedValue); + findInput().vm.$emit('input', emittedValue); expect(wrapper.emitted('input')).toEqual([[emittedValue]]); }); }); @@ -141,12 +141,12 @@ describe('ExpirationTextarea', () => { // since the component has no state we both emit the event and set the prop mountComponent({ value: invalidString }); - findTextArea().vm.$emit('input', invalidString); + findInput().vm.$emit('input', invalidString); }); it('textAreaValidation state is false', () => { expect(findFormGroup().props('state')).toBe(false); - expect(findTextArea().attributes('state')).toBeUndefined(); + expect(findInput().attributes('state')).toBeUndefined(); }); it('emits the @validation event with false payload', () => { @@ -157,10 +157,10 @@ describe('ExpirationTextarea', () => { it(`when user input is less than ${NAME_REGEX_LENGTH} state is "true"`, () => { mountComponent(); - findTextArea().vm.$emit('input', 'foo'); + findInput().vm.$emit('input', 'foo'); expect(findFormGroup().props('state')).toBe(true); - expect(findTextArea().attributes('state')).toBe('true'); + expect(findInput().attributes('state')).toBe('true'); expect(wrapper.emitted('validation')).toEqual([[true]]); }); }); diff --git a/spec/frontend/registry/settings/components/expiration_run_text_spec.js b/spec/frontend/registry/settings/components/expiration_run_text_spec.js index d023f1fd05a..c594b1f449d 100644 --- a/spec/frontend/registry/settings/components/expiration_run_text_spec.js +++ b/spec/frontend/registry/settings/components/expiration_run_text_spec.js @@ -28,19 +28,12 @@ describe('ExpirationToggle', () => { describe('structure', () => { it('has an input component', () => { mountComponent(); + expect(findInput().exists()).toBe(true); }); }); describe('model', () => { - it('assigns the right props to the input component', () => { - mountComponent({ value, disabled: true }); - - expect(findInput().attributes()).toMatchObject({ - value, - }); - }); - it('assigns the right props to the form-group component', () => { mountComponent(); @@ -51,16 +44,19 @@ describe('ExpirationToggle', () => { }); describe('formattedValue', () => { - it('displays the values when it exists', () => { - mountComponent({ value }); - - expect(findInput().attributes('value')).toBe(value); - }); - - it('displays a placeholder when no value is present', () => { - mountComponent(); - - expect(findInput().attributes('value')).toBe(NOT_SCHEDULED_POLICY_TEXT); - }); + it.each` + valueProp | enabled | expected + ${value} | ${true} | ${value} + ${value} | ${false} | ${NOT_SCHEDULED_POLICY_TEXT} + ${undefined} | ${false} | ${NOT_SCHEDULED_POLICY_TEXT} + ${undefined} | ${true} | ${NOT_SCHEDULED_POLICY_TEXT} + `( + 'when value is $valueProp and enabled is $enabled the input value is $expected', + ({ valueProp, enabled, expected }) => { + mountComponent({ value: valueProp, enabled }); + + expect(findInput().attributes('value')).toBe(expected); + }, + ); }); }); diff --git a/spec/frontend/registry/settings/components/expiration_toggle_spec.js b/spec/frontend/registry/settings/components/expiration_toggle_spec.js index 8b670c98dc1..99ff7a7f77a 100644 --- a/spec/frontend/registry/settings/components/expiration_toggle_spec.js +++ b/spec/frontend/registry/settings/components/expiration_toggle_spec.js @@ -3,9 +3,8 @@ import { GlToggle, GlSprintf } from '@gitlab/ui'; import { GlFormGroup } from 'jest/registry/shared/stubs'; import component from '~/registry/settings/components/expiration_toggle.vue'; import { - ENABLE_TOGGLE_DESCRIPTION, - ENABLED_TEXT, - DISABLED_TEXT, + ENABLED_TOGGLE_DESCRIPTION, + DISABLED_TOGGLE_DESCRIPTION, } from '~/registry/settings/constants'; describe('ExpirationToggle', () => { @@ -39,9 +38,7 @@ describe('ExpirationToggle', () => { it('has a description', () => { mountComponent(); - expect(findDescription().text()).toContain( - ENABLE_TOGGLE_DESCRIPTION.replace('%{toggleStatus}', ''), - ); + expect(findDescription().exists()).toBe(true); }); }); @@ -68,13 +65,13 @@ describe('ExpirationToggle', () => { it('says enabled when the toggle is on', () => { mountComponent({ value: true }); - expect(findDescription().text()).toContain(ENABLED_TEXT); + expect(findDescription().text()).toMatchInterpolatedText(ENABLED_TOGGLE_DESCRIPTION); }); it('says disabled when the toggle is off', () => { mountComponent({ value: false }); - expect(findDescription().text()).toContain(DISABLED_TEXT); + expect(findDescription().text()).toMatchInterpolatedText(DISABLED_TOGGLE_DESCRIPTION); }); }); }); diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 3744faa0d80..02e57396c42 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -44,9 +44,9 @@ describe('Settings Form', () => { const findEnableToggle = () => wrapper.find('[data-testid="enable-toggle"]'); const findCadenceDropdown = () => wrapper.find('[data-testid="cadence-dropdown"]'); const findKeepNDropdown = () => wrapper.find('[data-testid="keep-n-dropdown"]'); - const findKeepRegexTextarea = () => wrapper.find('[data-testid="keep-regex-textarea"]'); + const findKeepRegexInput = () => wrapper.find('[data-testid="keep-regex-input"]'); const findOlderThanDropdown = () => wrapper.find('[data-testid="older-than-dropdown"]'); - const findRemoveRegexTextarea = () => wrapper.find('[data-testid="remove-regex-textarea"]'); + const findRemoveRegexInput = () => wrapper.find('[data-testid="remove-regex-input"]'); const mountComponent = ({ props = defaultProps, @@ -115,13 +115,13 @@ describe('Settings Form', () => { }); describe.each` - model | finder | fieldName | type | defaultValue - ${'enabled'} | ${findEnableToggle} | ${'Enable'} | ${'toggle'} | ${false} - ${'cadence'} | ${findCadenceDropdown} | ${'Cadence'} | ${'dropdown'} | ${'EVERY_DAY'} - ${'keepN'} | ${findKeepNDropdown} | ${'Keep N'} | ${'dropdown'} | ${'TEN_TAGS'} - ${'nameRegexKeep'} | ${findKeepRegexTextarea} | ${'Keep Regex'} | ${'textarea'} | ${''} - ${'olderThan'} | ${findOlderThanDropdown} | ${'OlderThan'} | ${'dropdown'} | ${'NINETY_DAYS'} - ${'nameRegex'} | ${findRemoveRegexTextarea} | ${'Remove regex'} | ${'textarea'} | ${''} + model | finder | fieldName | type | defaultValue + ${'enabled'} | ${findEnableToggle} | ${'Enable'} | ${'toggle'} | ${false} + ${'cadence'} | ${findCadenceDropdown} | ${'Cadence'} | ${'dropdown'} | ${'EVERY_DAY'} + ${'keepN'} | ${findKeepNDropdown} | ${'Keep N'} | ${'dropdown'} | ${'TEN_TAGS'} + ${'nameRegexKeep'} | ${findKeepRegexInput} | ${'Keep Regex'} | ${'textarea'} | ${''} + ${'olderThan'} | ${findOlderThanDropdown} | ${'OlderThan'} | ${'dropdown'} | ${'NINETY_DAYS'} + ${'nameRegex'} | ${findRemoveRegexInput} | ${'Remove regex'} | ${'textarea'} | ${''} `('$fieldName', ({ model, finder, type, defaultValue }) => { it('matches snapshot', () => { mountComponent(); @@ -240,8 +240,8 @@ describe('Settings Form', () => { await wrapper.vm.$nextTick(); - expect(findKeepRegexTextarea().props('error')).toBe(''); - expect(findRemoveRegexTextarea().props('error')).toBe(''); + expect(findKeepRegexInput().props('error')).toBe(''); + expect(findRemoveRegexInput().props('error')).toBe(''); expect(findSaveButton().props('disabled')).toBe(false); }); }); @@ -338,7 +338,7 @@ describe('Settings Form', () => { await waitForPromises(); await wrapper.vm.$nextTick(); - expect(findKeepRegexTextarea().props('error')).toEqual('baz'); + expect(findKeepRegexInput().props('error')).toEqual('baz'); }); }); }); diff --git a/spec/graphql/features/authorization_spec.rb b/spec/graphql/features/authorization_spec.rb index e40c44925e2..ec67ed16fe9 100644 --- a/spec/graphql/features/authorization_spec.rb +++ b/spec/graphql/features/authorization_spec.rb @@ -55,7 +55,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'with a single permission' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolve: ->(obj, args, ctx) { test_object }, authorize: permission_single + query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_single end end @@ -66,7 +66,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do permissions = permission_collection query_factory do |qt| - qt.field :item, type, null: true, resolve: ->(obj, args, ctx) { test_object } do + qt.field :item, type, null: true, resolver: simple_resolver(test_object) do authorize permissions end end @@ -79,7 +79,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'Field authorizations when field is a built in type' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolve: ->(obj, args, ctx) { test_object } + query.field :item, type, null: true, resolver: simple_resolver(test_object) end end @@ -132,7 +132,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'Type authorizations' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolve: ->(obj, args, ctx) { test_object } + query.field :item, type, null: true, resolver: simple_resolver(test_object) end end @@ -169,7 +169,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, resolve: ->(obj, args, ctx) { test_object }, authorize: permission_2 + query.field :item, type, null: true, resolver: simple_resolver(test_object), authorize: permission_2 end end @@ -188,7 +188,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do query_factory do |query| - query.field :item, type.connection_type, null: true, resolve: ->(obj, args, ctx) { [test_object, second_test_object] } + query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object]) end end @@ -208,9 +208,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do describe 'limiting connections with multiple objects' do let(:query_type) do query_factory do |query| - query.field :item, type.connection_type, null: true, resolve: ->(obj, args, ctx) do - [test_object, second_test_object] - end + query.field :item, type.connection_type, null: true, resolver: simple_resolver([test_object, second_test_object]) end end @@ -234,7 +232,7 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do let(:query_type) do query_factory do |query| - query.field :item, [type], null: true, resolve: ->(obj, args, ctx) { [test_object] } + query.field :item, [type], null: true, resolver: simple_resolver([test_object]) end end @@ -262,13 +260,13 @@ RSpec.describe 'Gitlab::Graphql::Authorization' do type_factory do |type| type.graphql_name 'FakeProjectType' type.field :test_issues, issue_type.connection_type, null: false, - resolve: -> (_, _, _) { Issue.where(project: [visible_project, other_project]).order(id: :asc) } + resolver: simple_resolver(Issue.where(project: [visible_project, other_project]).order(id: :asc)) end end let(:query_type) do query_factory do |query| - query.field :test_project, project_type, null: false, resolve: -> (_, _, _) { visible_project } + query.field :test_project, project_type, null: false, resolver: simple_resolver(visible_project) end end diff --git a/spec/graphql/features/feature_flag_spec.rb b/spec/graphql/features/feature_flag_spec.rb index 9ebc6e595a6..77810f78257 100644 --- a/spec/graphql/features/feature_flag_spec.rb +++ b/spec/graphql/features/feature_flag_spec.rb @@ -23,7 +23,7 @@ RSpec.describe 'Graphql Field feature flags' do let(:query_type) do query_factory do |query| - query.field :item, type, null: true, feature_flag: feature_flag, resolve: ->(obj, args, ctx) { test_object } + query.field :item, type, null: true, feature_flag: feature_flag, resolver: simple_resolver(test_object) end end diff --git a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb new file mode 100644 index 00000000000..c022828cf09 --- /dev/null +++ b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Environments::CanaryIngress::Update do + let_it_be(:project) { create(:project) } + let_it_be(:environment) { create(:environment, project: project) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:reporter) { create(:user) } + let(:user) { maintainer } + + subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) } + + before_all do + project.add_maintainer(maintainer) + project.add_reporter(reporter) + end + + describe '#resolve' do + subject { mutation.resolve(id: environment_id, weight: weight) } + + let(:environment_id) { environment.to_global_id.to_s } + let(:weight) { 50 } + let(:update_service) { double('update_service') } + + before do + allow(Environments::CanaryIngress::UpdateService).to receive(:new) { update_service } + end + + context 'when service execution succeeded' do + before do + allow(update_service).to receive(:execute_async) { { status: :success } } + end + + it 'returns no errors' do + expect(subject[:errors]).to be_empty + end + end + + context 'when service encounters a problem' do + before do + allow(update_service).to receive(:execute_async) { { status: :error, message: 'something went wrong' } } + end + + it 'returns an error' do + expect(subject[:errors]).to eq(['something went wrong']) + end + end + + context 'when environment is not found' do + let(:environment_id) { non_existing_record_id.to_s } + + it 'raises an error' do + expect { subject }.to raise_error(GraphQL::CoercionError) + end + end + + context 'when user is reporter who does not have permission to access the environment' do + let(:user) { reporter } + + it 'raises an error' do + expect { subject }.to raise_error("The resource that you are attempting to access does not exist or you don't have permission to perform this action") + end + end + end +end diff --git a/spec/graphql/resolvers/ci/config_resolver_spec.rb b/spec/graphql/resolvers/ci/config_resolver_spec.rb new file mode 100644 index 00000000000..6911acdb4ec --- /dev/null +++ b/spec/graphql/resolvers/ci/config_resolver_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Ci::ConfigResolver do + include GraphqlHelpers + + describe '#resolve' do + before do + yaml_processor_double = instance_double(::Gitlab::Ci::YamlProcessor) + allow(yaml_processor_double).to receive(:execute).and_return(fake_result) + + allow(::Gitlab::Ci::YamlProcessor).to receive(:new).and_return(yaml_processor_double) + end + + context 'with a valid .gitlab-ci.yml' do + let(:fake_result) do + ::Gitlab::Ci::YamlProcessor::Result.new( + ci_config: ::Gitlab::Ci::Config.new(content), + errors: [], + warnings: [] + ) + end + + let_it_be(:content) do + File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci_includes.yml')) + end + + it 'lints the ci config file' do + response = resolve(described_class, args: { content: content }, ctx: {}) + + expect(response[:status]).to eq(:valid) + expect(response[:errors]).to be_empty + end + end + + context 'with an invalid .gitlab-ci.yml' do + let(:content) { 'invalid' } + + let(:fake_result) do + Gitlab::Ci::YamlProcessor::Result.new( + ci_config: nil, + errors: ['Invalid configuration format'], + warnings: [] + ) + end + + it 'responds with errors about invalid syntax' do + response = resolve(described_class, args: { content: content }, ctx: {}) + + expect(response[:status]).to eq(:invalid) + expect(response[:errors]).to eq(['Invalid configuration format']) + end + end + end +end diff --git a/spec/graphql/types/ci/config/config_type_spec.rb b/spec/graphql/types/ci/config/config_type_spec.rb new file mode 100644 index 00000000000..edd190a4365 --- /dev/null +++ b/spec/graphql/types/ci/config/config_type_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Ci::Config::ConfigType do + specify { expect(described_class.graphql_name).to eq('CiConfig') } + + it 'exposes the expected fields' do + expected_fields = %i[ + errors + mergedYaml + stages + status + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/ci/config/group_type_spec.rb b/spec/graphql/types/ci/config/group_type_spec.rb new file mode 100644 index 00000000000..7d808e85371 --- /dev/null +++ b/spec/graphql/types/ci/config/group_type_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Ci::Config::GroupType do + specify { expect(described_class.graphql_name).to eq('CiConfigGroup') } + + it 'exposes the expected fields' do + expected_fields = %i[ + name + jobs + size + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/ci/config/job_type_spec.rb b/spec/graphql/types/ci/config/job_type_spec.rb new file mode 100644 index 00000000000..600d665a84b --- /dev/null +++ b/spec/graphql/types/ci/config/job_type_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Ci::Config::JobType do + specify { expect(described_class.graphql_name).to eq('CiConfigJob') } + + it 'exposes the expected fields' do + expected_fields = %i[ + name + group_name + stage + needs + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/ci/config/need_type_spec.rb b/spec/graphql/types/ci/config/need_type_spec.rb new file mode 100644 index 00000000000..3387049a81d --- /dev/null +++ b/spec/graphql/types/ci/config/need_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Ci::Config::NeedType do + specify { expect(described_class.graphql_name).to eq('CiConfigNeed') } + + it 'exposes the expected fields' do + expected_fields = %i[ + name + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/ci/config/stage_type_spec.rb b/spec/graphql/types/ci/config/stage_type_spec.rb new file mode 100644 index 00000000000..aba97f8c7ed --- /dev/null +++ b/spec/graphql/types/ci/config/stage_type_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Ci::Config::StageType do + specify { expect(described_class.graphql_name).to eq('CiConfigStage') } + + it 'exposes the expected fields' do + expected_fields = %i[ + name + groups + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/graphql/types/permission_types/base_permission_type_spec.rb b/spec/graphql/types/permission_types/base_permission_type_spec.rb index 2ce02f1520c..68632a509ee 100644 --- a/spec/graphql/types/permission_types/base_permission_type_spec.rb +++ b/spec/graphql/types/permission_types/base_permission_type_spec.rb @@ -11,9 +11,13 @@ RSpec.describe Types::PermissionTypes::BasePermissionType do Class.new(described_class) do graphql_name 'TestClass' - permission_field :do_stuff, resolve: -> (_, _, _) { true } + permission_field :do_stuff ability_field(:read_issue) abilities :admin_issue + + define_method :do_stuff do + true + end end end diff --git a/spec/lib/gitlab/graphql/markdown_field_spec.rb b/spec/lib/gitlab/graphql/markdown_field_spec.rb index 82090f992eb..0e36ea14ac3 100644 --- a/spec/lib/gitlab/graphql/markdown_field_spec.rb +++ b/spec/lib/gitlab/graphql/markdown_field_spec.rb @@ -22,6 +22,8 @@ RSpec.describe Gitlab::Graphql::MarkdownField do .to raise_error(expected_error) end + # TODO: remove as part of https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27536 + # so that until that time, the developer check is there it 'raises when passing a resolve block' do expect { class_with_markdown_field(:test_html, null: true, resolve: -> (_, _, _) { 'not really' } ) } .to raise_error(expected_error) diff --git a/spec/requests/api/graphql/ci/config_spec.rb b/spec/requests/api/graphql/ci/config_spec.rb new file mode 100644 index 00000000000..b682470e0a1 --- /dev/null +++ b/spec/requests/api/graphql/ci/config_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Query.ciConfig' do + include GraphqlHelpers + + subject(:post_graphql_query) { post_graphql(query, current_user: user) } + + let(:user) { create(:user) } + + let_it_be(:content) do + File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci_includes.yml')) + end + + let(:query) do + %( + query { + ciConfig(content: "#{content}") { + status + errors + stages { + name + groups { + name + size + jobs { + name + groupName + stage + needs { + name + } + } + } + } + } + } + ) + end + + before do + post_graphql_query + end + + it_behaves_like 'a working graphql query' + + it 'returns the correct structure' do + expect(graphql_data['ciConfig']).to eq( + "status" => "VALID", + "errors" => [], + "stages" => + [ + { + "name" => "build", + "groups" => + [ + { + "name" => "rspec", + "size" => 2, + "jobs" => + [ + { "name" => "rspec 0 1", "groupName" => "rspec", "stage" => "build", "needs" => [] }, + { "name" => "rspec 0 2", "groupName" => "rspec", "stage" => "build", "needs" => [] } + ] + }, + { + "name" => "spinach", "size" => 1, "jobs" => + [ + { "name" => "spinach", "groupName" => "spinach", "stage" => "build", "needs" => [] } + ] + } + ] + }, + { + "name" => "test", + "groups" => + [ + { + "name" => "docker", + "size" => 1, + "jobs" => [ + { "name" => "docker", "groupName" => "docker", "stage" => "test", "needs" => [{ "name" => "spinach" }, { "name" => "rspec 0 1" }] } + ] + } + ] + } + ] + ) + end +end diff --git a/spec/requests/api/graphql/mutations/environments/canary_ingress/update_spec.rb b/spec/requests/api/graphql/mutations/environments/canary_ingress/update_spec.rb new file mode 100644 index 00000000000..f25a49291a6 --- /dev/null +++ b/spec/requests/api/graphql/mutations/environments/canary_ingress/update_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Update Environment Canary Ingress', :clean_gitlab_redis_cache do + include GraphqlHelpers + include KubernetesHelpers + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:cluster) { create(:cluster, :project, projects: [project]) } + let_it_be(:service) { create(:cluster_platform_kubernetes, :configured, cluster: cluster) } + let_it_be(:environment) { create(:environment, project: project) } + let_it_be(:deployment) { create(:deployment, :success, environment: environment, project: project) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:developer) { create(:user) } + let(:environment_id) { environment.to_global_id.to_s } + let(:weight) { 25 } + let(:actor) { developer } + + let(:mutation) do + graphql_mutation(:environments_canary_ingress_update, id: environment_id, weight: weight) + end + + before_all do + project.add_maintainer(maintainer) + project.add_developer(developer) + end + + before do + stub_kubeclient_ingresses(environment.deployment_namespace, response: kube_ingresses_response(with_canary: true)) + end + + context 'when kubernetes accepted the patch request' do + before do + stub_kubeclient_ingresses(environment.deployment_namespace, method: :patch, resource_path: "/production-auto-deploy") + end + + it 'updates successfully' do + post_graphql_mutation(mutation, current_user: actor) + + expect(graphql_mutation_response(:environments_canary_ingress_update)['errors']) + .to be_empty + end + end +end diff --git a/spec/services/environments/canary_ingress/update_service_spec.rb b/spec/services/environments/canary_ingress/update_service_spec.rb new file mode 100644 index 00000000000..31d6f543817 --- /dev/null +++ b/spec/services/environments/canary_ingress/update_service_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Environments::CanaryIngress::UpdateService, :clean_gitlab_redis_cache do + include KubernetesHelpers + + let_it_be(:project, refind: true) { create(:project) } + let_it_be(:maintainer) { create(:user) } + let_it_be(:reporter) { create(:user) } + let(:user) { maintainer } + let(:params) { {} } + let(:service) { described_class.new(project, user, params) } + + before_all do + project.add_maintainer(maintainer) + project.add_reporter(reporter) + end + + shared_examples_for 'failed request' do + it 'returns an error' do + expect(subject[:status]).to eq(:error) + expect(subject[:message]).to eq(message) + end + end + + describe '#execute_async' do + subject { service.execute_async(environment) } + + let(:environment) { create(:environment, project: project) } + let(:params) { { weight: 50 } } + let(:canary_ingress) { ::Gitlab::Kubernetes::Ingress.new(kube_ingress(track: :canary)) } + + context 'when canary_ingress_weight_control feature flag is disabled' do + before do + stub_feature_flags(canary_ingress_weight_control: false) + end + + it_behaves_like 'failed request' do + let(:message) { "Feature flag is not enabled on the environment's project." } + end + end + + context 'when the actor does not have permission to update environment' do + let(:user) { reporter } + + it_behaves_like 'failed request' do + let(:message) { "You do not have permission to update the environment." } + end + end + + context 'when weight parameter is invalid' do + let(:params) { { weight: 'unknown' } } + + it_behaves_like 'failed request' do + let(:message) { 'Canary weight must be specified and valid range (0..100).' } + end + end + + context 'when no parameters exist' do + let(:params) { {} } + + it_behaves_like 'failed request' do + let(:message) { 'Canary weight must be specified and valid range (0..100).' } + end + end + + context 'when environment has a running deployment' do + before do + allow(environment).to receive(:has_running_deployments?) { true } + end + + it_behaves_like 'failed request' do + let(:message) { 'There are running deployments on the environment. Please retry later.' } + end + end + + context 'when canary ingress was updated recently' do + before do + allow(::Gitlab::ApplicationRateLimiter).to receive(:throttled?) { true } + end + + it_behaves_like 'failed request' do + let(:message) { "This environment's canary ingress has been updated recently. Please retry later." } + end + end + end + + describe '#execute' do + subject { service.execute(environment) } + + let(:environment) { create(:environment, project: project) } + let(:params) { { weight: 50 } } + let(:canary_ingress) { ::Gitlab::Kubernetes::Ingress.new(kube_ingress(track: :canary)) } + + context 'when canary ingress is present in the environment' do + before do + allow(environment).to receive(:ingresses) { [canary_ingress] } + end + + context 'when patch request succeeds' do + let(:patch_data) do + { + metadata: { + annotations: { + Gitlab::Kubernetes::Ingress::ANNOTATION_KEY_CANARY_WEIGHT => params[:weight].to_s + } + } + } + end + + before do + allow(environment).to receive(:patch_ingress).with(canary_ingress, patch_data) { true } + end + + it 'returns success' do + expect(subject[:status]).to eq(:success) + expect(subject[:message]).to be_nil + end + end + + context 'when patch request does not succeed' do + before do + allow(environment).to receive(:patch_ingress) { false } + end + + it_behaves_like 'failed request' do + let(:message) { 'Failed to update the Canary Ingress.' } + end + end + end + + context 'when canary ingress is not present in the environment' do + it_behaves_like 'failed request' do + let(:message) { 'Canary Ingress does not exist in the environment.' } + end + end + end +end diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb index 4674f614cf1..3d7d928d744 100644 --- a/spec/services/projects/alerting/notify_service_spec.rb +++ b/spec/services/projects/alerting/notify_service_spec.rb @@ -54,7 +54,6 @@ RSpec.describe Projects::Alerting::NotifyService do shared_examples 'assigns the alert properties' do it 'ensure that created alert has all data properly assigned' do subject - expect(last_alert_attributes).to match( project_id: project.id, title: payload_raw.fetch(:title), @@ -62,6 +61,7 @@ RSpec.describe Projects::Alerting::NotifyService do severity: payload_raw.fetch(:severity), status: AlertManagement::Alert.status_value(:triggered), events: 1, + domain: 'operations', hosts: payload_raw.fetch(:hosts), payload: payload_raw.with_indifferent_access, issue_id: nil, @@ -187,6 +187,7 @@ RSpec.describe Projects::Alerting::NotifyService do status: AlertManagement::Alert.status_value(:triggered), events: 1, hosts: [], + domain: 'operations', payload: payload_raw.with_indifferent_access, issue_id: nil, description: nil, diff --git a/spec/support/gitlab_stubs/gitlab_ci_includes.yml b/spec/support/gitlab_stubs/gitlab_ci_includes.yml new file mode 100644 index 00000000000..e74773ce23e --- /dev/null +++ b/spec/support/gitlab_stubs/gitlab_ci_includes.yml @@ -0,0 +1,19 @@ +rspec 0 1: + stage: build + script: 'rake spec' + needs: [] + +rspec 0 2: + stage: build + script: 'rake spec' + needs: [] + +spinach: + stage: build + script: 'rake spinach' + needs: [] + +docker: + stage: test + script: 'curl http://dockerhub/URL' + needs: [spinach, rspec 0 1] diff --git a/spec/support/helpers/filtered_search_helpers.rb b/spec/support/helpers/filtered_search_helpers.rb index d203ff60cc9..10068b9c508 100644 --- a/spec/support/helpers/filtered_search_helpers.rb +++ b/spec/support/helpers/filtered_search_helpers.rb @@ -113,6 +113,10 @@ module FilteredSearchHelpers create_token('Assignee', assignee_name) end + def reviewer_token(reviewer_name = nil) + create_token('Reviewer', reviewer_name) + end + def milestone_token(milestone_name = nil, has_symbol = true, operator = '=') symbol = has_symbol ? '%' : nil create_token('Milestone', milestone_name, symbol, operator) diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index d53ca5b36ee..b20801bd3c4 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -150,6 +150,14 @@ module GraphqlHelpers field.resolve_field(instance, args, context) end + def simple_resolver(resolved_value = 'Resolved value') + Class.new(Resolvers::BaseResolver) do + define_method :resolve do |**_args| + resolved_value + end + end + end + # Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys # # prepare_input_for_mutation({ 'my_key' => 1 }) diff --git a/spec/workers/environments/canary_ingress/update_worker_spec.rb b/spec/workers/environments/canary_ingress/update_worker_spec.rb new file mode 100644 index 00000000000..7bc5108719c --- /dev/null +++ b/spec/workers/environments/canary_ingress/update_worker_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Environments::CanaryIngress::UpdateWorker do + let_it_be(:environment) { create(:environment) } + let(:worker) { described_class.new } + + describe '#perform' do + subject { worker.perform(environment_id, params) } + + let(:environment_id) { environment.id } + let(:params) { { 'weight' => 50 } } + + it 'executes the update service' do + expect_next_instance_of(Environments::CanaryIngress::UpdateService, environment.project, nil, params) do |service| + expect(service).to receive(:execute).with(environment) + end + + subject + end + + context 'when an environment does not exist' do + let(:environment_id) { non_existing_record_id } + + it 'does not execute the update service' do + expect(Environments::CanaryIngress::UpdateService).not_to receive(:new) + + subject + end + end + end +end |