diff options
88 files changed, 1493 insertions, 243 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index efde50ac13a..0d9079f6165 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -15c2f3921c4729e9c4d7ce8592300decfcfdb2e6 +12dcff902c9a2178fa6f4992d9d562ad9b422dd2 @@ -93,7 +93,7 @@ gem 'graphql', '~> 1.10.5' # TODO: remove app/views/graphiql/rails/editors/show.html.erb when https://github.com/rmosolgo/graphiql-rails/pull/71 is released: # https://gitlab.com/gitlab-org/gitlab/issues/31747 gem 'graphiql-rails', '~> 1.4.10' -gem 'apollo_upload_server', '~> 2.0.0.beta3' +gem 'apollo_upload_server', '~> 2.0.2' gem 'graphql-docs', '~> 1.6.0', group: [:development, :test] # Disable strong_params so that Mash does not respond to :permitted? diff --git a/Gemfile.lock b/Gemfile.lock index 0f5cfcdc056..ae678d2321b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -73,7 +73,7 @@ GEM public_suffix (>= 2.0.2, < 5.0) aes_key_wrap (1.0.1) akismet (3.0.0) - apollo_upload_server (2.0.0.beta.3) + apollo_upload_server (2.0.2) graphql (>= 1.8) rails (>= 4.2) asana (0.10.0) @@ -1220,7 +1220,7 @@ DEPENDENCIES acts-as-taggable-on (~> 6.0) addressable (~> 2.7) akismet (~> 3.0) - apollo_upload_server (~> 2.0.0.beta3) + apollo_upload_server (~> 2.0.2) asana (= 0.10.0) asciidoctor (~> 2.0.10) asciidoctor-include-ext (~> 0.3.1) diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue index 80b44f7bb13..78647065c8e 100644 --- a/app/assets/javascripts/diffs/components/settings_dropdown.vue +++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue @@ -1,16 +1,24 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlDeprecatedButton, GlIcon } from '@gitlab/ui'; +import { GlButtonGroup, GlButton, GlDropdown } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { components: { - GlDeprecatedButton, - GlIcon, + GlButtonGroup, + GlButton, + GlDropdown, }, computed: { ...mapGetters('diffs', ['isInlineView', 'isParallelView']), ...mapState('diffs', ['renderTreeList', 'showWhitespace']), }, + mounted() { + this.patchAriaLabel(); + }, + updated() { + this.patchAriaLabel(); + }, methods: { ...mapActions('diffs', [ 'setInlineDiffViewType', @@ -18,74 +26,69 @@ export default { 'setRenderTreeList', 'setShowWhitespace', ]), + patchAriaLabel() { + this.$el + .querySelector('.js-show-diff-settings') + .setAttribute('aria-label', __('Diff view settings')); + }, }, }; </script> <template> - <div class="dropdown"> - <button - type="button" - class="btn btn-default js-show-diff-settings" - data-toggle="dropdown" - data-display="static" - > - <gl-icon name="settings" /> <gl-icon name="chevron-down" /> - </button> - <div class="dropdown-menu dropdown-menu-right p-2 pt-3 pb-3"> - <div> - <span class="bold d-block mb-1">{{ __('File browser') }}</span> - <div class="btn-group d-flex"> - <gl-deprecated-button - :class="{ active: !renderTreeList }" - class="w-100 js-list-view" - @click="setRenderTreeList(false)" - > - {{ __('List view') }} - </gl-deprecated-button> - <gl-deprecated-button - :class="{ active: renderTreeList }" - class="w-100 js-tree-view" - @click="setRenderTreeList(true)" - > - {{ __('Tree view') }} - </gl-deprecated-button> - </div> - </div> - <div class="mt-2"> - <span class="bold d-block mb-1">{{ __('Compare changes') }}</span> - <div class="btn-group d-flex js-diff-view-buttons"> - <gl-deprecated-button - id="inline-diff-btn" - :class="{ active: isInlineView }" - class="w-100 js-inline-diff-button" - data-view-type="inline" - @click="setInlineDiffViewType" - > - {{ __('Inline') }} - </gl-deprecated-button> - <gl-deprecated-button - id="parallel-diff-btn" - :class="{ active: isParallelView }" - class="w-100 js-parallel-diff-button" - data-view-type="parallel" - @click="setParallelDiffViewType" - > - {{ __('Side-by-side') }} - </gl-deprecated-button> - </div> - </div> - <div class="mt-2"> - <label class="mb-0"> - <input - id="show-whitespace" - type="checkbox" - :checked="showWhitespace" - @change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })" - /> - {{ __('Show whitespace changes') }} - </label> - </div> + <gl-dropdown icon="settings" toggle-class="js-show-diff-settings" right> + <div class="gl-px-3"> + <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('File browser') }}</span> + <gl-button-group class="gl-display-flex"> + <gl-button + :class="{ selected: !renderTreeList }" + class="gl-w-half js-list-view" + @click="setRenderTreeList(false)" + > + {{ __('List view') }} + </gl-button> + <gl-button + :class="{ selected: renderTreeList }" + class="gl-w-half js-tree-view" + @click="setRenderTreeList(true)" + > + {{ __('Tree view') }} + </gl-button> + </gl-button-group> + </div> + <div class="gl-mt-3 gl-px-3"> + <span class="gl-font-weight-bold gl-display-block gl-mb-2">{{ __('Compare changes') }}</span> + <gl-button-group class="gl-display-flex js-diff-view-buttons"> + <gl-button + id="inline-diff-btn" + :class="{ selected: isInlineView }" + class="gl-w-half js-inline-diff-button" + data-view-type="inline" + @click="setInlineDiffViewType" + > + {{ __('Inline') }} + </gl-button> + <gl-button + id="parallel-diff-btn" + :class="{ selected: isParallelView }" + class="gl-w-half js-parallel-diff-button" + data-view-type="parallel" + @click="setParallelDiffViewType" + > + {{ __('Side-by-side') }} + </gl-button> + </gl-button-group> + </div> + <div class="gl-mt-3 gl-px-3"> + <label class="gl-mb-0"> + <input + id="show-whitespace" + type="checkbox" + :checked="showWhitespace" + @change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })" + /> + {{ __('Show whitespace changes') }} + </label> </div> - </div> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 992d87a969f..1cc04003aa6 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -20,7 +20,6 @@ export default { components: { GlIcon, GlIntersectionObserver, - descriptionComponent, titleComponent, editedComponent, formComponent, @@ -152,6 +151,18 @@ export default { required: false, default: 0, }, + descriptionComponent: { + type: Object, + required: false, + default: () => { + return descriptionComponent; + }, + }, + showTitleBorder: { + type: Boolean, + required: false, + default: true, + }, }, data() { const store = new Store({ @@ -209,6 +220,11 @@ export default { isOpenStatus() { return this.issuableStatus === IssuableStatus.Open; }, + pinnedLinkClasses() { + return this.showTitleBorder + ? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6' + : ''; + }, statusIcon() { return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close'; }, @@ -447,9 +463,11 @@ export default { <pinned-links :zoom-meeting-url="zoomMeetingUrl" :published-incident-url="publishedIncidentUrl" + :class="pinnedLinkClasses" /> - <description-component + <component + :is="descriptionComponent" v-if="state.descriptionHtml" :can-update="canUpdate" :description-html="state.descriptionHtml" diff --git a/app/assets/javascripts/issue_show/components/incident_tabs.vue b/app/assets/javascripts/issue_show/components/incident_tabs.vue new file mode 100644 index 00000000000..f6e82cfaa74 --- /dev/null +++ b/app/assets/javascripts/issue_show/components/incident_tabs.vue @@ -0,0 +1,26 @@ +<script> +import { GlTab, GlTabs } from '@gitlab/ui'; +import DescriptionComponent from './description.vue'; + +export default { + components: { + GlTab, + GlTabs, + DescriptionComponent, + }, +}; +</script> + +<template> + <div> + <gl-tabs + content-class="gl-reset-line-height gl-mt-3" + class="gl-mt-n3" + data-testid="incident-tabs" + > + <gl-tab :title="__('Summary')"> + <description-component v-bind="$attrs" /> + </gl-tab> + </gl-tabs> + </div> +</template> diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue index a877aa2ac96..36375ca743b 100644 --- a/app/assets/javascripts/issue_show/components/pinned_links.vue +++ b/app/assets/javascripts/issue_show/components/pinned_links.vue @@ -45,7 +45,7 @@ export default { </script> <template> - <div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start"> + <div class="gl-display-flex gl-justify-content-start"> <template v-for="(link, i) in pinnedLinks"> <div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }"> <gl-button diff --git a/app/assets/javascripts/issue_show/incident.js b/app/assets/javascripts/issue_show/incident.js new file mode 100644 index 00000000000..82b862a2195 --- /dev/null +++ b/app/assets/javascripts/issue_show/incident.js @@ -0,0 +1,21 @@ +import Vue from 'vue'; +import issuableApp from './components/app.vue'; +import incidentTabs from './components/incident_tabs.vue'; + +export default function initIssuableApp(issuableData = {}) { + return new Vue({ + el: document.getElementById('js-issuable-app'), + components: { + issuableApp, + }, + render(createElement) { + return createElement('issuable-app', { + props: { + ...issuableData, + descriptionComponent: incidentTabs, + showTitleBorder: false, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/issue.js index e170d338408..f9f61d5aa64 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/issue.js @@ -1,8 +1,7 @@ import Vue from 'vue'; import issuableApp from './components/app.vue'; -import { parseIssuableData } from './utils/parse_data'; -export default function initIssueableApp() { +export default function initIssuableApp(issuableData) { return new Vue({ el: document.getElementById('js-issuable-app'), components: { @@ -10,7 +9,7 @@ export default function initIssueableApp() { }, render(createElement) { return createElement('issuable-app', { - props: parseIssuableData(), + props: issuableData, }); }, }); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 5ac6c17e09d..a577d2e1ecd 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -4,14 +4,23 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; import { store } from '~/notes/stores'; -import initIssueableApp from '~/issue_show'; +import initIssueApp from '~/issue_show/issue'; +import initIncidentApp from '~/issue_show/incident'; import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initRelatedMergeRequestsApp from '~/related_merge_requests'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; +import { parseIssuableData } from '~/issue_show/utils/parse_data'; export default function() { - initIssueableApp(); + const { issueType, ...issuableData } = parseIssuableData(); + + if (issueType === 'incident') { + initIncidentApp(issuableData); + } else { + initIssueApp(issuableData); + } + initIssuableHeaderWarning(store); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 77170f7de5e..8aaeb92eb7a 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -1033,3 +1033,9 @@ $mr-widget-min-height: 69px; .diff-file-row.is-active { background-color: $gray-50; } + +.merge-request-container { + .flash-container { + @include gl-mb-4; + } +} diff --git a/app/finders/concerns/merged_at_filter.rb b/app/finders/concerns/merged_at_filter.rb index e92bee3934c..581bcca3c25 100644 --- a/app/finders/concerns/merged_at_filter.rb +++ b/app/finders/concerns/merged_at_filter.rb @@ -3,7 +3,6 @@ module MergedAtFilter private - # rubocop: disable CodeReuse/ActiveRecord def by_merged_at(items) return items unless merged_after || merged_before @@ -11,11 +10,8 @@ module MergedAtFilter mr_metrics_scope = mr_metrics_scope.merged_after(merged_after) if merged_after.present? mr_metrics_scope = mr_metrics_scope.merged_before(merged_before) if merged_before.present? - scope = items.joins(:metrics).merge(mr_metrics_scope) - scope = target_project_id_filter_on_metrics(scope) if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true) - scope + items.join_metrics.merge(mr_metrics_scope) end - # rubocop: enable CodeReuse/ActiveRecord def merged_after params[:merged_after] @@ -24,10 +20,4 @@ module MergedAtFilter def merged_before params[:merged_before] end - - # rubocop: disable CodeReuse/ActiveRecord - def target_project_id_filter_on_metrics(scope) - scope.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id])) - end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb index e428e9f115f..677f84e5795 100644 --- a/app/graphql/resolvers/merge_requests_resolver.rb +++ b/app/graphql/resolvers/merge_requests_resolver.rb @@ -37,6 +37,10 @@ module Resolvers argument :milestone_title, GraphQL::STRING_TYPE, required: false, description: 'Title of the milestone' + argument :sort, Types::MergeRequestSortEnum, + description: 'Sort merge requests by this criteria', + required: false, + default_value: 'created_desc' def self.single ::Resolvers::MergeRequestResolver diff --git a/app/graphql/types/merge_request_sort_enum.rb b/app/graphql/types/merge_request_sort_enum.rb new file mode 100644 index 00000000000..c64ae367a76 --- /dev/null +++ b/app/graphql/types/merge_request_sort_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class MergeRequestSortEnum < IssuableSortEnum + graphql_name 'MergeRequestSort' + description 'Values for sorting merge requests' + + value 'MERGED_AT_ASC', 'Merge time by ascending order', value: :merged_at_asc + value 'MERGED_AT_DESC', 'Merge time by descending order', value: :merged_at_desc + end +end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index d68ae1a56ca..7b73af4bd09 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -327,7 +327,8 @@ module ApplicationSettingsHelper :group_import_limit, :group_export_limit, :group_download_export_limit, - :wiki_page_max_content_bytes + :wiki_page_max_content_bytes, + :container_registry_delete_tags_service_timeout ] end diff --git a/app/helpers/container_registry_helper.rb b/app/helpers/container_registry_helper.rb new file mode 100644 index 00000000000..9a5d84a90dd --- /dev/null +++ b/app/helpers/container_registry_helper.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module ContainerRegistryHelper + def limit_delete_tags_service? + Feature.enabled?(:container_registry_expiration_policies_throttling) && + ContainerRegistry::Client.supports_tag_delete? + end +end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 0b859a39c4f..398e76b6697 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -292,6 +292,7 @@ module IssuablesHelper { hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0, + issueType: issuable.issue_type, zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable), sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord } @@ -301,8 +302,8 @@ module IssuablesHelper return { groupPath: parent.path } if parent.is_a?(Group) { - projectPath: ref_project.path, - projectNamespace: ref_project.namespace.full_path + projectPath: ref_project.path, + projectNamespace: ref_project.namespace.full_path } end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 6666a04e71d..83576edb866 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -282,6 +282,9 @@ class ApplicationSetting < ApplicationRecord validates :hashed_storage_enabled, inclusion: { in: [true], message: _("Hashed storage can't be disabled anymore for new projects") } + validates :container_registry_delete_tags_service_timeout, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 5a5fc02c112..82304671e4e 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -163,7 +163,8 @@ module ApplicationSettingImplementation user_default_external: false, user_default_internal_regex: nil, user_show_add_ssh_key_message: true, - wiki_page_max_content_bytes: 50.megabytes + wiki_page_max_content_bytes: 50.megabytes, + container_registry_delete_tags_service_timeout: 100 } end diff --git a/app/models/atlassian/identity.rb b/app/models/atlassian/identity.rb new file mode 100644 index 00000000000..906f2be0fbf --- /dev/null +++ b/app/models/atlassian/identity.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Atlassian + class Identity < ApplicationRecord + self.table_name = 'atlassian_identities' + + belongs_to :user + + validates :extern_uid, presence: true, uniqueness: true + validates :user, presence: true, uniqueness: true + + attr_encrypted :token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: false, + encode_iv: false + + attr_encrypted :refresh_token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: false, + encode_iv: false + end +end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index fd73b0d1e04..618fa06745e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -251,6 +251,15 @@ class MergeRequest < ApplicationRecord joins(:notes).where(notes: { commit_id: sha }) end scope :join_project, -> { joins(:target_project) } + scope :join_metrics, -> do + query = joins(:metrics) + + if Feature.enabled?(:improved_mr_merged_at_queries, default_enabled: true) + query = query.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id])) + end + + query + end scope :references_project, -> { references(:target_project) } scope :with_api_entity_associations, -> { preload_routables @@ -264,6 +273,14 @@ class MergeRequest < ApplicationRecord where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) end scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) } + scope :order_merged_at, ->(direction) do + query = join_metrics.order(Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', direction)) + + # Add `merge_request_metrics.merged_at` to the `SELECT` in order to make the keyset pagination work. + query.select(*query.arel.projections, MergeRequest::Metrics.arel_table[:merged_at].as('"merge_request_metrics.merged_at"')) + end + scope :order_merged_at_asc, -> { order_merged_at('ASC') } + scope :order_merged_at_desc, -> { order_merged_at('DESC') } scope :preload_source_project, -> { preload(:source_project) } scope :preload_target_project, -> { preload(:target_project) } scope :preload_routables, -> do @@ -320,6 +337,15 @@ class MergeRequest < ApplicationRecord .pluck(:target_branch) end + def self.sort_by_attribute(method, excluded_labels: []) + case method.to_s + when 'merged_at', 'merged_at_asc' then order_merged_at_asc.with_order_id_desc + when 'merged_at_desc' then order_merged_at_desc.with_order_id_desc + else + super + end + end + def rebase_in_progress? rebase_jid.present? && Gitlab::SidekiqStatus.running?(rebase_jid) end diff --git a/app/models/service.rb b/app/models/service.rb index 148c554119f..262806cd0f0 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -351,10 +351,10 @@ class Service < ApplicationRecord { success: result.present?, result: result } end - # Disable test for instance-level services. + # Disable test for instance-level and group-level services. # https://gitlab.com/gitlab-org/gitlab/-/issues/213138 def can_test? - !instance? + !instance? && !group_id end # Returns a hash of the properties that have been assigned a new value since last save, diff --git a/app/models/snippet.rb b/app/models/snippet.rb index eb3960ff12b..0179176ba9e 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -345,6 +345,10 @@ class Snippet < ApplicationRecord repository.ls_files(ref) end + def multiple_files? + list_files(repository.root_ref).size > 1 + end + class << self # Searches for snippets with a matching title, description or file name. # diff --git a/app/models/user.rb b/app/models/user.rb index 300e918513a..355a174ba9a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -181,6 +181,7 @@ class User < ApplicationRecord has_one :user_detail has_one :user_highest_role has_one :user_canonical_email + has_one :atlassian_identity, class_name: 'Atlassian::Identity' has_many :reviews, foreign_key: :author_id, inverse_of: :author diff --git a/app/services/ci/parse_dotenv_artifact_service.rb b/app/services/ci/parse_dotenv_artifact_service.rb index fcbdc94c097..71b306864b2 100644 --- a/app/services/ci/parse_dotenv_artifact_service.rb +++ b/app/services/ci/parse_dotenv_artifact_service.rb @@ -54,7 +54,7 @@ module Ci end def scan_line!(line) - result = line.scan(/^(.*)=(.*)$/).last + result = line.scan(/^(.*?)=(.*)$/).last raise ParserError, 'Invalid Format' if result.nil? diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb index 18049648e26..cee94b994a3 100644 --- a/app/services/projects/container_repository/gitlab/delete_tags_service.rb +++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb @@ -5,6 +5,11 @@ module Projects module Gitlab class DeleteTagsService include BaseServiceUtility + include ::Gitlab::Utils::StrongMemoize + + DISABLED_TIMEOUTS = [nil, 0].freeze + + TimeoutError = Class.new(StandardError) def initialize(container_repository, tag_names) @container_repository = container_repository @@ -17,12 +22,42 @@ module Projects def execute return success(deleted: []) if @tag_names.empty? + delete_tags + rescue TimeoutError => e + ::Gitlab::ErrorTracking.track_exception(e, tags_count: @tag_names&.size, container_repository_id: @container_repository&.id) + error('timeout while deleting tags') + end + + private + + def delete_tags + start_time = Time.zone.now + deleted_tags = @tag_names.select do |name| + raise TimeoutError if timeout?(start_time) + @container_repository.delete_tag_by_name(name) end deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags') end + + def timeout?(start_time) + return false unless throttling_enabled? + return false if service_timeout.in?(DISABLED_TIMEOUTS) + + (Time.zone.now - start_time) > service_timeout + end + + def throttling_enabled? + strong_memoize(:feature_flag) do + Feature.enabled?(:container_registry_expiration_policies_throttling) + end + end + + def service_timeout + ::Gitlab::CurrentSettings.current_application_settings.container_registry_delete_tags_service_timeout + end end end end diff --git a/app/services/projects/container_repository/third_party/delete_tags_service.rb b/app/services/projects/container_repository/third_party/delete_tags_service.rb index 6504172109e..404642acf72 100644 --- a/app/services/projects/container_repository/third_party/delete_tags_service.rb +++ b/app/services/projects/container_repository/third_party/delete_tags_service.rb @@ -15,7 +15,7 @@ module Projects # This is a hack as the registry doesn't support deleting individual # tags. This code effectively pushes a dummy image and assigns the tag to it. # This way when the tag is deleted only the dummy image is affected. - # This is used to preverse compatibility with third-party registries that + # This is used to preserve compatibility with third-party registries that # don't support fast delete. # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion def execute diff --git a/app/views/admin/application_settings/_registry.html.haml b/app/views/admin/application_settings/_registry.html.haml index fea3ff4c3ba..8a2de6f53b7 100644 --- a/app/views/admin/application_settings/_registry.html.haml +++ b/app/views/admin/application_settings/_registry.html.haml @@ -14,5 +14,11 @@ .form-text.text-muted = _("Existing projects will be able to use expiration policies. Avoid enabling this if an external Container Registry is being used, as there is a performance risk if many images exist on one project.") = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'use-with-external-container-registries') + - if limit_delete_tags_service? + .form-group + = f.label :container_registry_delete_tags_service_timeout, _('Cleanup policy maximum processing time (seconds)'), class: 'label-bold' + = f.number_field :container_registry_delete_tags_service_timeout, min: 0, class: 'form-control' + .form-text.text-muted + = _("Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0.") = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 746d613934c..735a7fa4fea 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -1,5 +1,5 @@ - @gfm_form = true -- @content_class = "limit-container-width" unless fluid_layout +- @content_class = "merge-request-container#{' limit-container-width' unless fluid_layout}" - add_to_breadcrumbs _("Merge Requests"), project_merge_requests_path(@project) - breadcrumb_title @merge_request.to_reference - page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge Requests") diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml index 4d64521f9b0..1fd2194e25b 100644 --- a/app/views/shared/wikis/_form.html.haml +++ b/app/views/shared/wikis/_form.html.haml @@ -21,7 +21,7 @@ .col-sm-12 = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: s_('Wiki|Page title') %span.d-inline-block.mw-100.gl-mt-2 - = icon('lightbulb-o') + = sprite_icon('bulb', size: 12, css_class: 'gl-mr-n1') - if @page.persisted? = s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.") = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'), diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index b85c3d862cd..9f8129c8c08 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -17,13 +17,13 @@ = sprite_icon('pencil') - elsif current_user - if @user.abuse_report - %button{ class: link_classes + 'btn btn-danger mr-1', title: s_('UserProfile|Already reported for abuse'), - data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } } - = icon('exclamation-circle') + %button{ class: link_classes + 'btn btn-danger', title: s_('UserProfile|Already reported for abuse'), + data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } }> + = sprite_icon('error') - else = link_to new_abuse_report_path(user_id: @user.id, ref_url: request.referrer), class: link_classes + 'btn', title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do - = icon('exclamation-circle') + = sprite_icon('error') - if can?(current_user, :read_user_profile, @user) = link_to user_path(@user, rss_url_options), class: link_classes + 'btn btn-svg btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do = sprite_icon('rss', css_class: 'qa-rss-icon') diff --git a/changelogs/unreleased/208193-limit-delete-tags-service-runtime.yml b/changelogs/unreleased/208193-limit-delete-tags-service-runtime.yml new file mode 100644 index 00000000000..900d9d9be1e --- /dev/null +++ b/changelogs/unreleased/208193-limit-delete-tags-service-runtime.yml @@ -0,0 +1,5 @@ +--- +title: Add timeout support in the delete tags service for the GitLab Registry +merge_request: 36319 +author: +type: changed diff --git a/changelogs/unreleased/225935-replace-fa-exclamation-circle-icons-with-gitlab-svg-error-icon.yml b/changelogs/unreleased/225935-replace-fa-exclamation-circle-icons-with-gitlab-svg-error-icon.yml new file mode 100644 index 00000000000..b8374b50c95 --- /dev/null +++ b/changelogs/unreleased/225935-replace-fa-exclamation-circle-icons-with-gitlab-svg-error-icon.yml @@ -0,0 +1,5 @@ +--- +title: Replace fa-exclamation-circle and fa-lightbulb-o with GitLab SVG icons +merge_request: 40857 +author: +type: changed diff --git a/changelogs/unreleased/228657-version-bump-apollo_upload_server-gem.yml b/changelogs/unreleased/228657-version-bump-apollo_upload_server-gem.yml new file mode 100644 index 00000000000..cd71e4bfbf6 --- /dev/null +++ b/changelogs/unreleased/228657-version-bump-apollo_upload_server-gem.yml @@ -0,0 +1,5 @@ +--- +title: Bug fix GraphQL file uploads accepting non-file input +merge_request: 39763 +author: +type: fixed diff --git a/changelogs/unreleased/229320-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml b/changelogs/unreleased/229320-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml new file mode 100644 index 00000000000..1d41e6fa09c --- /dev/null +++ b/changelogs/unreleased/229320-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml @@ -0,0 +1,5 @@ +--- +title: Migrating buttons and classes to match GitLab UI +merge_request: 40409 +author: +type: other diff --git a/changelogs/unreleased/239116-add-merge-request-sort-options-to-graphql.yml b/changelogs/unreleased/239116-add-merge-request-sort-options-to-graphql.yml new file mode 100644 index 00000000000..5f1698427a4 --- /dev/null +++ b/changelogs/unreleased/239116-add-merge-request-sort-options-to-graphql.yml @@ -0,0 +1,5 @@ +--- +title: Add MergeRequest sort options to GraphQL API +merge_request: 40138 +author: +type: added diff --git a/changelogs/unreleased/add-flash-spacing-on-merge-request.yml b/changelogs/unreleased/add-flash-spacing-on-merge-request.yml new file mode 100644 index 00000000000..345f3bee2bc --- /dev/null +++ b/changelogs/unreleased/add-flash-spacing-on-merge-request.yml @@ -0,0 +1,5 @@ +--- +title: Add Flash spacing on merge request show page +merge_request: 39903 +author: +type: changed diff --git a/changelogs/unreleased/dblessing-atlassian-integration.yml b/changelogs/unreleased/dblessing-atlassian-integration.yml new file mode 100644 index 00000000000..f65d5d6f351 --- /dev/null +++ b/changelogs/unreleased/dblessing-atlassian-integration.yml @@ -0,0 +1,5 @@ +--- +title: Add Atlassian Identity to store identity/credentials +merge_request: 40176 +author: +type: added diff --git a/changelogs/unreleased/fix-regexp-dotenv.yml b/changelogs/unreleased/fix-regexp-dotenv.yml new file mode 100644 index 00000000000..4c6247d2259 --- /dev/null +++ b/changelogs/unreleased/fix-regexp-dotenv.yml @@ -0,0 +1,5 @@ +--- +title: Fix RegExp for dotenv report artifact +merge_request: 38562 +author: +type: fixed diff --git a/changelogs/unreleased/tr-incident-tabs.yml b/changelogs/unreleased/tr-incident-tabs.yml new file mode 100644 index 00000000000..7664bf0f47d --- /dev/null +++ b/changelogs/unreleased/tr-incident-tabs.yml @@ -0,0 +1,5 @@ +--- +title: Add Summary tab for incident issues +merge_request: 39822 +author: +type: added diff --git a/config/feature_flags/development/container_registry_expiration_policies_throttling.yml b/config/feature_flags/development/container_registry_expiration_policies_throttling.yml new file mode 100644 index 00000000000..5169bcdfa28 --- /dev/null +++ b/config/feature_flags/development/container_registry_expiration_policies_throttling.yml @@ -0,0 +1,7 @@ +--- +name: container_registry_expiration_policies_throttling +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36319 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/238190 +group: group::package +type: development +default_enabled: false diff --git a/db/migrate/20200710113437_add_container_registry_delete_tags_service_timeout_to_application_settings.rb b/db/migrate/20200710113437_add_container_registry_delete_tags_service_timeout_to_application_settings.rb new file mode 100644 index 00000000000..d3865db2e18 --- /dev/null +++ b/db/migrate/20200710113437_add_container_registry_delete_tags_service_timeout_to_application_settings.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddContainerRegistryDeleteTagsServiceTimeoutToApplicationSettings < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def up + add_column( + :application_settings, + :container_registry_delete_tags_service_timeout, + :integer, + default: 250, + null: false + ) + end + + def down + remove_column(:application_settings, :container_registry_delete_tags_service_timeout) + end +end diff --git a/db/migrate/20200821194920_create_atlassian_identities.rb b/db/migrate/20200821194920_create_atlassian_identities.rb new file mode 100644 index 00000000000..1aab9ed6381 --- /dev/null +++ b/db/migrate/20200821194920_create_atlassian_identities.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class CreateAtlassianIdentities < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + unless table_exists?(:atlassian_identities) + with_lock_retries do + create_table :atlassian_identities, id: false do |t| + t.references :user, index: false, foreign_key: { on_delete: :cascade }, null: false, primary_key: true + t.timestamps_with_timezone + t.datetime_with_timezone :expires_at + t.text :extern_uid, null: false, index: { unique: true } + t.binary :encrypted_token + t.binary :encrypted_token_iv + t.binary :encrypted_refresh_token + t.binary :encrypted_refresh_token_iv + end + end + end + + add_text_limit :atlassian_identities, :extern_uid, 255 + + add_check_constraint :atlassian_identities, 'octet_length(encrypted_token) <= 2048', 'atlassian_identities_token_length_constraint' + add_check_constraint :atlassian_identities, 'octet_length(encrypted_token_iv) <= 12', 'atlassian_identities_token_iv_length_constraint' + add_check_constraint :atlassian_identities, 'octet_length(encrypted_refresh_token) <= 512', 'atlassian_identities_refresh_token_length_constraint' + add_check_constraint :atlassian_identities, 'octet_length(encrypted_refresh_token_iv) <= 12', 'atlassian_identities_refresh_token_iv_length_constraint' + end + + def down + with_lock_retries do + drop_table :atlassian_identities + end + end +end diff --git a/db/schema_migrations/20200710113437 b/db/schema_migrations/20200710113437 new file mode 100644 index 00000000000..02e9161ed7f --- /dev/null +++ b/db/schema_migrations/20200710113437 @@ -0,0 +1 @@ +3d49c22b718c5b4af0a7372584fe12ab730e1ffca501c7f582f7d01200708eb1
\ No newline at end of file diff --git a/db/schema_migrations/20200821194920 b/db/schema_migrations/20200821194920 new file mode 100644 index 00000000000..b681c3269c3 --- /dev/null +++ b/db/schema_migrations/20200821194920 @@ -0,0 +1 @@ +d92cdef33a892fdd1761d9491bc8e4c782e9db348d4a6848a1470e99e644fbfd
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index f5a8d4f9f16..19eec2aea26 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9266,6 +9266,7 @@ CREATE TABLE public.application_settings ( wiki_page_max_content_bytes bigint DEFAULT 52428800 NOT NULL, elasticsearch_indexed_file_size_limit_kb integer DEFAULT 1024 NOT NULL, enforce_namespace_storage_limit boolean DEFAULT false NOT NULL, + container_registry_delete_tags_service_timeout integer DEFAULT 250 NOT NULL, CONSTRAINT check_51700b31b5 CHECK ((char_length(default_branch_name) <= 255)), CONSTRAINT check_9c6c447a13 CHECK ((char_length(maintenance_mode_message) <= 255)), CONSTRAINT check_d03919528d CHECK ((char_length(container_registry_vendor) <= 255)), @@ -9479,6 +9480,32 @@ CREATE TABLE public.ar_internal_metadata ( updated_at timestamp(6) without time zone NOT NULL ); +CREATE TABLE public.atlassian_identities ( + user_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone, + extern_uid text NOT NULL, + encrypted_token bytea, + encrypted_token_iv bytea, + encrypted_refresh_token bytea, + encrypted_refresh_token_iv bytea, + CONSTRAINT atlassian_identities_refresh_token_iv_length_constraint CHECK ((octet_length(encrypted_refresh_token_iv) <= 12)), + CONSTRAINT atlassian_identities_refresh_token_length_constraint CHECK ((octet_length(encrypted_refresh_token) <= 512)), + CONSTRAINT atlassian_identities_token_iv_length_constraint CHECK ((octet_length(encrypted_token_iv) <= 12)), + CONSTRAINT atlassian_identities_token_length_constraint CHECK ((octet_length(encrypted_token) <= 2048)), + CONSTRAINT check_32f5779763 CHECK ((char_length(extern_uid) <= 255)) +); + +CREATE SEQUENCE public.atlassian_identities_user_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.atlassian_identities_user_id_seq OWNED BY public.atlassian_identities.user_id; + CREATE TABLE public.audit_events ( id integer NOT NULL, author_id integer NOT NULL, @@ -16880,6 +16907,8 @@ ALTER TABLE ONLY public.approver_groups ALTER COLUMN id SET DEFAULT nextval('pub ALTER TABLE ONLY public.approvers ALTER COLUMN id SET DEFAULT nextval('public.approvers_id_seq'::regclass); +ALTER TABLE ONLY public.atlassian_identities ALTER COLUMN user_id SET DEFAULT nextval('public.atlassian_identities_user_id_seq'::regclass); + ALTER TABLE ONLY public.audit_events ALTER COLUMN id SET DEFAULT nextval('public.audit_events_id_seq'::regclass); ALTER TABLE ONLY public.award_emoji ALTER COLUMN id SET DEFAULT nextval('public.award_emoji_id_seq'::regclass); @@ -17800,6 +17829,9 @@ ALTER TABLE ONLY public.approvers ALTER TABLE ONLY public.ar_internal_metadata ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key); +ALTER TABLE ONLY public.atlassian_identities + ADD CONSTRAINT atlassian_identities_pkey PRIMARY KEY (user_id); + ALTER TABLE ONLY public.audit_events_part_5fc467ac26 ADD CONSTRAINT audit_events_part_5fc467ac26_pkey PRIMARY KEY (id, created_at); @@ -19237,6 +19269,8 @@ CREATE INDEX index_approvers_on_target_id_and_target_type ON public.approvers US CREATE INDEX index_approvers_on_user_id ON public.approvers USING btree (user_id); +CREATE UNIQUE INDEX index_atlassian_identities_on_extern_uid ON public.atlassian_identities USING btree (extern_uid); + CREATE INDEX index_audit_events_on_entity_id_entity_type_id_desc_author_id ON public.audit_events USING btree (entity_id, entity_type, id DESC, author_id); CREATE INDEX index_award_emoji_on_awardable_type_and_awardable_id ON public.award_emoji USING btree (awardable_type, awardable_id); @@ -23097,6 +23131,9 @@ ALTER TABLE ONLY public.resource_weight_events ALTER TABLE ONLY public.design_management_designs ADD CONSTRAINT fk_rails_bfe283ec3c FOREIGN KEY (issue_id) REFERENCES public.issues(id) ON DELETE CASCADE; +ALTER TABLE ONLY public.atlassian_identities + ADD CONSTRAINT fk_rails_c02928bc18 FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE; + ALTER TABLE ONLY public.serverless_domain_cluster ADD CONSTRAINT fk_rails_c09009dee1 FOREIGN KEY (pages_domain_id) REFERENCES public.pages_domains(id) ON DELETE CASCADE; diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index faa9cd519fd..d5b80575acb 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -9521,6 +9521,71 @@ type MergeRequestSetWipPayload { } """ +Values for sorting merge requests +""" +enum MergeRequestSort { + """ + Label priority by ascending order + """ + LABEL_PRIORITY_ASC + + """ + Label priority by descending order + """ + LABEL_PRIORITY_DESC + + """ + Merge time by ascending order + """ + MERGED_AT_ASC + + """ + Merge time by descending order + """ + MERGED_AT_DESC + + """ + Milestone due date by ascending order + """ + MILESTONE_DUE_ASC + + """ + Milestone due date by descending order + """ + MILESTONE_DUE_DESC + + """ + Priority by ascending order + """ + PRIORITY_ASC + + """ + Priority by descending order + """ + PRIORITY_DESC + + """ + Created at ascending order + """ + created_asc + + """ + Created at descending order + """ + created_desc + + """ + Updated at ascending order + """ + updated_asc + + """ + Updated at descending order + """ + updated_desc +} + +""" State of a GitLab merge request """ enum MergeRequestState { @@ -11742,6 +11807,11 @@ type Project { milestoneTitle: String """ + Sort merge requests by this criteria + """ + sort: MergeRequestSort = created_desc + + """ Array of source branch names. All resolved merge requests will have one of these branches as their source. """ sourceBranches: [String!] @@ -16809,6 +16879,11 @@ type User { projectPath: String """ + Sort merge requests by this criteria + """ + sort: MergeRequestSort = created_desc + + """ Array of source branch names. All resolved merge requests will have one of these branches as their source. """ sourceBranches: [String!] @@ -16884,6 +16959,11 @@ type User { projectPath: String """ + Sort merge requests by this criteria + """ + sort: MergeRequestSort = created_desc + + """ Array of source branch names. All resolved merge requests will have one of these branches as their source. """ sourceBranches: [String!] diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 5921f3719c1..683a0a032e3 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -26641,6 +26641,89 @@ }, { "kind": "ENUM", + "name": "MergeRequestSort", + "description": "Values for sorting merge requests", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "updated_desc", + "description": "Updated at descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updated_asc", + "description": "Updated at ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "created_desc", + "description": "Created at descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "created_asc", + "description": "Created at ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PRIORITY_ASC", + "description": "Priority by ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PRIORITY_DESC", + "description": "Priority by descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LABEL_PRIORITY_ASC", + "description": "Label priority by ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LABEL_PRIORITY_DESC", + "description": "Label priority by descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MILESTONE_DUE_ASC", + "description": "Milestone due date by ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MILESTONE_DUE_DESC", + "description": "Milestone due date by descending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MERGED_AT_ASC", + "description": "Merge time by ascending order", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MERGED_AT_DESC", + "description": "Merge time by descending order", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", "name": "MergeRequestState", "description": "State of a GitLab merge request", "fields": null, @@ -34772,6 +34855,16 @@ "defaultValue": null }, { + "name": "sort", + "description": "Sort merge requests by this criteria", + "type": { + "kind": "ENUM", + "name": "MergeRequestSort", + "ofType": null + }, + "defaultValue": "created_desc" + }, + { "name": "assigneeUsername", "description": "Username of the assignee", "type": { @@ -49462,6 +49555,16 @@ "defaultValue": null }, { + "name": "sort", + "description": "Sort merge requests by this criteria", + "type": { + "kind": "ENUM", + "name": "MergeRequestSort", + "ofType": null + }, + "defaultValue": "created_desc" + }, + { "name": "projectPath", "description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.", "type": { @@ -49647,6 +49750,16 @@ "defaultValue": null }, { + "name": "sort", + "description": "Sort merge requests by this criteria", + "type": { + "kind": "ENUM", + "name": "MergeRequestSort", + "ofType": null + }, + "defaultValue": "created_desc" + }, + { "name": "projectPath", "description": "The full-path of the project the authored merge requests should be in. Incompatible with projectId.", "type": { diff --git a/doc/user/packages/container_registry/index.md b/doc/user/packages/container_registry/index.md index b103486928a..0f29b2be54e 100644 --- a/doc/user/packages/container_registry/index.md +++ b/doc/user/packages/container_registry/index.md @@ -533,6 +533,11 @@ The cleanup policy: 1. Excludes from the list any tags matching the `name_regex_keep` value (tags to preserve). 1. Finally, the remaining tags in the list are deleted from the Container Registry. +CAUTION: **Warning:** +On GitLab.com, the execution time for the cleanup policy is limited, and some of the tags may remain in +the Container Registry after the policy runs. The next time the policy runs, the remaining tags are included, +so it may take multiple runs for all tags to be deleted. + ### Create a cleanup policy You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the UI. diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 599d5bd0baf..c4a3385efd0 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -537,6 +537,10 @@ module API ) end + def with_api_params(&block) + yield({ api: true, request: request }) + end + protected def project_finder_params_visibility_ce diff --git a/lib/api/helpers/snippets_helpers.rb b/lib/api/helpers/snippets_helpers.rb index 79367da8d1f..560c61c7e53 100644 --- a/lib/api/helpers/snippets_helpers.rb +++ b/lib/api/helpers/snippets_helpers.rb @@ -27,6 +27,20 @@ module API exactly_one_of :files, :content end + params :update_file_params do |options| + optional :files, type: Array, desc: 'An array of files to update' do + requires :action, type: String, + values: SnippetInputAction::ACTIONS.map(&:to_s), + desc: "The type of action to perform on the file, must be one of: #{SnippetInputAction::ACTIONS.join(", ")}" + optional :content, type: String, desc: 'The content of a snippet' + optional :file_path, file_path: true, type: String, desc: 'The file path of a snippet file' + optional :previous_path, file_path: true, type: String, desc: 'The previous path of a snippet file' + end + + mutually_exclusive :files, :content + mutually_exclusive :files, :file_name + end + def content_for(snippet) if snippet.empty_repo? env['api.format'] = :txt @@ -53,10 +67,30 @@ module API end end - def process_file_args(args) - args[:snippet_actions] = args.delete(:files)&.map do |file| - file[:action] = :create - file.symbolize_keys + def process_create_params(args) + with_api_params do |api_params| + args[:snippet_actions] = args.delete(:files)&.map do |file| + file[:action] = :create + file.symbolize_keys + end + + args.merge(api_params) + end + end + + def process_update_params(args) + with_api_params do |api_params| + args[:snippet_actions] = args.delete(:files)&.map(&:symbolize_keys) + + args.merge(api_params) + end + end + + def validate_params_for_multiple_files(snippet) + return unless params[:content] || params[:file_name] + + if Feature.enabled?(:snippet_multiple_files, current_user) && snippet.multiple_files? + render_api_error!({ error: _('To update Snippets with multiple files, you must use the `files` parameter') }, 400) end end end diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb index 0d27a3eca26..bed86e9990d 100644 --- a/lib/api/project_snippets.rb +++ b/lib/api/project_snippets.rb @@ -64,12 +64,8 @@ module API end post ":id/snippets" do authorize! :create_snippet, user_project - snippet_params = declared_params(include_missing: false).tap do |create_args| - create_args[:request] = request - create_args[:api] = true - process_file_args(create_args) - end + snippet_params = process_create_params(declared_params(include_missing: false)) service_response = ::Snippets::CreateService.new(user_project, current_user, snippet_params).execute snippet = service_response.payload[:snippet] diff --git a/lib/api/snippets.rb b/lib/api/snippets.rb index 1a3283aed98..b6914d571dc 100644 --- a/lib/api/snippets.rb +++ b/lib/api/snippets.rb @@ -76,12 +76,7 @@ module API post do authorize! :create_snippet - attrs = declared_params(include_missing: false).tap do |create_args| - create_args[:request] = request - create_args[:api] = true - - process_file_args(create_args) - end + attrs = process_create_params(declared_params(include_missing: false)) service_response = ::Snippets::CreateService.new(nil, current_user, attrs).execute snippet = service_response.payload[:snippet] @@ -99,16 +94,20 @@ module API detail 'This feature was introduced in GitLab 8.15.' success Entities::PersonalSnippet end + params do requires :id, type: Integer, desc: 'The ID of a snippet' - optional :title, type: String, allow_blank: false, desc: 'The title of a snippet' - optional :file_name, type: String, desc: 'The name of a snippet file' optional :content, type: String, allow_blank: false, desc: 'The content of a snippet' optional :description, type: String, desc: 'The description of a snippet' + optional :file_name, type: String, desc: 'The name of a snippet file' + optional :title, type: String, allow_blank: false, desc: 'The title of a snippet' optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The visibility of the snippet' - at_least_one_of :title, :file_name, :content, :visibility + + use :update_file_params + + at_least_one_of :title, :file_name, :content, :files, :visibility end put ':id' do snippet = snippets_for_current_user.find_by_id(params.delete(:id)) @@ -116,8 +115,12 @@ module API authorize! :update_snippet, snippet - attrs = declared_params(include_missing: false).merge(request: request, api: true) + validate_params_for_multiple_files(snippet) + + attrs = process_update_params(declared_params(include_missing: false)) + service_response = ::Snippets::UpdateService.new(nil, current_user, attrs).execute(snippet) + snippet = service_response.payload[:snippet] if service_response.success? diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb index 118eb8e2d7c..e6ca33d749b 100644 --- a/lib/container_registry/client.rb +++ b/lib/container_registry/client.rb @@ -21,6 +21,17 @@ module ContainerRegistry # Taken from: FaradayMiddleware::FollowRedirects REDIRECT_CODES = Set.new [301, 302, 303, 307] + def self.supports_tag_delete? + registry_config = Gitlab.config.registry + return false unless registry_config.enabled && registry_config.api_url.present? + + return true if ::Gitlab.com? + + token = Auth::ContainerRegistryAuthenticationService.access_token([], []) + client = new(registry_config.api_url, token: token) + client.supports_tag_delete? + end + def initialize(base_uri, options = {}) @base_uri = base_uri @options = options diff --git a/lib/gitlab/graphql/pagination/keyset/conditions/base_condition.rb b/lib/gitlab/graphql/pagination/keyset/conditions/base_condition.rb index afea7c602be..25b68db2233 100644 --- a/lib/gitlab/graphql/pagination/keyset/conditions/base_condition.rb +++ b/lib/gitlab/graphql/pagination/keyset/conditions/base_condition.rb @@ -29,7 +29,7 @@ module Gitlab def table_condition(order_info, value, operator) if order_info.named_function target = order_info.named_function - value = value&.downcase if target&.name&.downcase == 'lower' + value = value&.downcase if target.respond_to?(:name) && target&.name&.downcase == 'lower' else target = arel_table[order_info.attribute_name] end diff --git a/lib/gitlab/graphql/pagination/keyset/order_info.rb b/lib/gitlab/graphql/pagination/keyset/order_info.rb index 12bcc4993b5..df103a73209 100644 --- a/lib/gitlab/graphql/pagination/keyset/order_info.rb +++ b/lib/gitlab/graphql/pagination/keyset/order_info.rb @@ -71,7 +71,22 @@ module Gitlab def extract_nulls_last_order(order_value) tokens = order_value.downcase.split - [tokens.first, (tokens[1] == 'asc' ? :asc : :desc), nil] + column_reference = tokens.first + sort_direction = tokens[1] == 'asc' ? :asc : :desc + + # Handles the case when the order value is coming from another table. + # Example: table_name.column_name + # Query the value using the fully qualified column name: pass table_name.column_name as the named_function + if fully_qualified_column_reference?(column_reference) + [column_reference, sort_direction, Arel.sql(column_reference)] + else + [column_reference, sort_direction, nil] + end + end + + # Example: table_name.column_name + def fully_qualified_column_reference?(attribute) + attribute.to_s.count('.') == 1 end def extract_attribute_values(order_value) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9305d66f79d..e780e4ffc8a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2834,6 +2834,9 @@ msgstr "" msgid "An error occurred while retrieving diff files" msgstr "" +msgid "An error occurred while retrieving projects." +msgstr "" + msgid "An error occurred while saving LDAP override status. Please try again." msgstr "" @@ -5006,6 +5009,9 @@ msgstr "" msgid "Cleanup policy for tags" msgstr "" +msgid "Cleanup policy maximum processing time (seconds)" +msgstr "" + msgid "Clear" msgstr "" @@ -8610,6 +8616,9 @@ msgstr "" msgid "Diff limits" msgstr "" +msgid "Diff view settings" +msgstr "" + msgid "Difference between start date and now" msgstr "" @@ -24097,6 +24106,9 @@ msgstr "" msgid "Tags" msgstr "" +msgid "Tags are deleted until the timeout is reached. Any remaining tags are included the next time the policy runs. To remove the time limit, set it to 0." +msgstr "" + msgid "Tags feed" msgstr "" @@ -25968,6 +25980,9 @@ msgstr "" msgid "To unsubscribe from this issue, please paste the following link into your browser:" msgstr "" +msgid "To update Snippets with multiple files, you must use the `files` parameter" +msgstr "" + msgid "To view all %{scannedResourcesCount} scanned URLs, please download the CSV file" msgstr "" diff --git a/qa/qa/support/json_formatter.rb b/qa/qa/support/json_formatter.rb index 0c3ab63dce0..ceafa34b1c2 100644 --- a/qa/qa/support/json_formatter.rb +++ b/qa/qa/support/json_formatter.rb @@ -12,19 +12,21 @@ module QA # implementation so that it's not included. end - def stop(notification) + def stop(example_notification) # Based on https://github.com/rspec/rspec-core/blob/main/lib/rspec/core/formatters/json_formatter.rb#L35 - # But modified to include full details of multiple exceptions - @output_hash[:examples] = notification.examples.map do |example| - format_example(example).tap do |hash| - e = example.exception + # But modified to include full details of multiple exceptions and to provide output similar to + # https://github.com/sj26/rspec_junit_formatter + @output_hash[:examples] = example_notification.notifications.map do |notification| + format_example(notification.example).tap do |hash| + e = notification.example.exception if e exceptions = e.respond_to?(:all_exceptions) ? e.all_exceptions : [e] hash[:exceptions] = exceptions.map do |exception| { class: exception.class.name, message: exception.message, - backtrace: exception.backtrace + message_lines: strip_ansi_codes(notification.message_lines), + backtrace: notification.formatted_backtrace } end end @@ -60,6 +62,12 @@ module QA metadata[:shared_group_inclusion_backtrace].last.formatted_inclusion_location.split(':') end end + + def strip_ansi_codes(strings) + # The code below is from https://github.com/piotrmurach/pastel/blob/master/lib/pastel/color.rb + modified = Array(strings).map { |string| string.dup.gsub(/\x1b\[{1,2}[0-9;:?]*m/m, '') } + modified.size == 1 ? modified[0] : modified + end end end end diff --git a/spec/factories/atlassian_identities.rb b/spec/factories/atlassian_identities.rb new file mode 100644 index 00000000000..698cf4ae7ad --- /dev/null +++ b/spec/factories/atlassian_identities.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :atlassian_identity, class: 'Atlassian::Identity' do + extern_uid { generate(:username) } + user { create(:user) } + expires_at { 2.weeks.from_now } + token { SecureRandom.alphanumeric(1254) } + refresh_token { SecureRandom.alphanumeric(45) } + end +end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index c281ba3aab9..369b258a63d 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -285,6 +285,55 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n expect(current_settings.auto_devops_domain).to eq('domain.com') expect(page).to have_content "Application settings saved successfully" end + + context 'Container Registry' do + context 'delete tags service execution timeout' do + let(:feature_flag_enabled) { true } + let(:client_support) { true } + + before do + stub_container_registry_config(enabled: true) + stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled) + allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support) + end + + RSpec.shared_examples 'not having service timeout settings' do + it 'lacks the timeout settings' do + visit ci_cd_admin_application_settings_path + + expect(page).not_to have_content "Container Registry delete tags service execution timeout" + end + end + + context 'with feature flag enabled' do + context 'with client supporting tag delete' do + it 'changes the timeout' do + visit ci_cd_admin_application_settings_path + + page.within('.as-registry') do + fill_in 'application_setting_container_registry_delete_tags_service_timeout', with: 400 + click_button 'Save changes' + end + + expect(current_settings.container_registry_delete_tags_service_timeout).to eq(400) + expect(page).to have_content "Application settings saved successfully" + end + end + + context 'with client not supporting tag delete' do + let(:client_support) { false } + + it_behaves_like 'not having service timeout settings' + end + end + + context 'with feature flag disabled' do + let(:feature_flag_enabled) { false } + + it_behaves_like 'not having service timeout settings' + end + end + end end context 'Repository page' do diff --git a/spec/features/issues/incident_issue_spec.rb b/spec/features/issues/incident_issue_spec.rb new file mode 100644 index 00000000000..57dfb370bf4 --- /dev/null +++ b/spec/features/issues/incident_issue_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Incident Detail', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:incident) { create(:issue, project: project, author: user, issue_type: 'incident', description: 'hello') } + + context 'when user displays the incident' do + before do + visit project_issue_path(project, incident) + wait_for_requests + end + + it 'shows the incident tabs' do + page.within('.issuable-details') do + incident_tabs = find('[data-testid="incident-tabs"]') + + expect(find('h2')).to have_content(incident.title) + expect(incident_tabs).to have_content('Summary') + expect(incident_tabs).to have_content(incident.description) + end + end + end +end diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index 2e95d79ea49..8bad3e37e99 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -7,7 +7,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constant const localVue = createLocalVue(); localVue.use(Vuex); -describe('Diff settiings dropdown component', () => { +describe('Diff settings dropdown component', () => { let vm; let actions; @@ -61,50 +61,50 @@ describe('Diff settiings dropdown component', () => { expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true, undefined); }); - it('sets list button as active when renderTreeList is false', () => { + it('sets list button as selected when renderTreeList is false', () => { createComponent(store => { Object.assign(store.state.diffs, { renderTreeList: false, }); }); - expect(vm.find('.js-list-view').classes('active')).toBe(true); - expect(vm.find('.js-tree-view').classes('active')).toBe(false); + expect(vm.find('.js-list-view').classes('selected')).toBe(true); + expect(vm.find('.js-tree-view').classes('selected')).toBe(false); }); - it('sets tree button as active when renderTreeList is true', () => { + it('sets tree button as selected when renderTreeList is true', () => { createComponent(store => { Object.assign(store.state.diffs, { renderTreeList: true, }); }); - expect(vm.find('.js-list-view').classes('active')).toBe(false); - expect(vm.find('.js-tree-view').classes('active')).toBe(true); + expect(vm.find('.js-list-view').classes('selected')).toBe(false); + expect(vm.find('.js-tree-view').classes('selected')).toBe(true); }); }); describe('compare changes', () => { - it('sets inline button as active', () => { + it('sets inline button as selected', () => { createComponent(store => { Object.assign(store.state.diffs, { diffViewType: INLINE_DIFF_VIEW_TYPE, }); }); - expect(vm.find('.js-inline-diff-button').classes('active')).toBe(true); - expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(false); + expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(true); + expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(false); }); - it('sets parallel button as active', () => { + it('sets parallel button as selected', () => { createComponent(store => { Object.assign(store.state.diffs, { diffViewType: PARALLEL_DIFF_VIEW_TYPE, }); }); - expect(vm.find('.js-inline-diff-button').classes('active')).toBe(false); - expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(true); + expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(false); + expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(true); }); it('calls setInlineDiffViewType when clicking inline button', () => { diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index 10806499d5e..698f7dbb1e4 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -9,6 +9,9 @@ import '~/behaviors/markdown/render_gfm'; import IssuableApp from '~/issue_show/components/app.vue'; import eventHub from '~/issue_show/event_hub'; import { initialRequest, secondRequest } from '../mock_data'; +import IncidentTabs from '~/issue_show/components/incident_tabs.vue'; +import DescriptionComponent from '~/issue_show/components/description.vue'; +import PinnedLinks from '~/issue_show/components/pinned_links.vue'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); @@ -22,6 +25,27 @@ const REALTIME_REQUEST_STACK = [initialRequest, secondRequest]; const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811'; const publishedIncidentUrl = 'https://status.com/'; +const defaultProps = { + canUpdate: true, + canDestroy: true, + endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes', + updateEndpoint: TEST_HOST, + issuableRef: '#1', + issuableStatus: 'opened', + initialTitleHtml: '', + initialTitleText: '', + initialDescriptionHtml: 'test', + initialDescriptionText: 'test', + lockVersion: 1, + markdownPreviewPath: '/', + markdownDocsPath: '/', + projectNamespace: '/', + projectPath: '/', + issuableTemplateNamesPath: '/issuable-templates-path', + zoomMeetingUrl, + publishedIncidentUrl, +}; + describe('Issuable output', () => { useMockIntersectionObserver(); @@ -31,6 +55,12 @@ describe('Issuable output', () => { const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); + const mountComponent = (props = {}) => { + wrapper = mount(IssuableApp, { + propsData: { ...defaultProps, ...props }, + }); + }; + beforeEach(() => { setFixtures(` <div> @@ -57,28 +87,7 @@ describe('Issuable output', () => { return res; }); - wrapper = mount(IssuableApp, { - propsData: { - canUpdate: true, - canDestroy: true, - endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes', - updateEndpoint: TEST_HOST, - issuableRef: '#1', - issuableStatus: 'opened', - initialTitleHtml: '', - initialTitleText: '', - initialDescriptionHtml: 'test', - initialDescriptionText: 'test', - lockVersion: 1, - markdownPreviewPath: '/', - markdownDocsPath: '/', - projectNamespace: '/', - projectPath: '/', - issuableTemplateNamesPath: '/issuable-templates-path', - zoomMeetingUrl, - publishedIncidentUrl, - }, - }); + mountComponent(); }); afterEach(() => { @@ -562,4 +571,46 @@ describe('Issuable output', () => { }); }); }); + + describe('Composable description component', () => { + const findIncidentTabs = () => wrapper.find(IncidentTabs); + const findDescriptionComponent = () => wrapper.find(DescriptionComponent); + const findPinnedLinks = () => wrapper.find(PinnedLinks); + const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; + + describe('when using description component', () => { + it('renders the description component', () => { + expect(findDescriptionComponent().exists()).toBe(true); + }); + + it('does not render incident tabs', () => { + expect(findIncidentTabs().exists()).toBe(false); + }); + + it('adds a border below the header', () => { + expect(findPinnedLinks().attributes('class')).toContain(borderClass); + }); + }); + + describe('when using incident tabs description wrapper', () => { + beforeEach(() => { + mountComponent({ + descriptionComponent: IncidentTabs, + showTitleBorder: false, + }); + }); + + it('renders the description component', () => { + expect(findDescriptionComponent().exists()).toBe(true); + }); + + it('renders incident tabs', () => { + expect(findIncidentTabs().exists()).toBe(true); + }); + + it('does not add a border below the header', () => { + expect(findPinnedLinks().attributes('class')).not.toContain(borderClass); + }); + }); + }); }); diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js index 0053475dd13..f3c078184a9 100644 --- a/spec/frontend/issue_show/components/description_spec.js +++ b/spec/frontend/issue_show/components/description_spec.js @@ -5,20 +5,13 @@ import mountComponent from 'helpers/vue_mount_component_helper'; import { TEST_HOST } from 'helpers/test_constants'; import Description from '~/issue_show/components/description.vue'; import TaskList from '~/task_list'; +import { descriptionProps as props } from '../mock_data'; jest.mock('~/task_list'); describe('Description component', () => { let vm; let DescriptionComponent; - const props = { - canUpdate: true, - descriptionHtml: 'test', - descriptionText: 'test', - updatedAt: new Date().toString(), - taskStatus: '', - updateUrl: TEST_HOST, - }; beforeEach(() => { DescriptionComponent = Vue.extend(Description); diff --git a/spec/frontend/issue_show/components/incident_tabs_spec.js b/spec/frontend/issue_show/components/incident_tabs_spec.js new file mode 100644 index 00000000000..19f75345450 --- /dev/null +++ b/spec/frontend/issue_show/components/incident_tabs_spec.js @@ -0,0 +1,44 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlTab } from '@gitlab/ui'; +import IncidentTabs from '~/issue_show/components/incident_tabs.vue'; +import { descriptionProps } from '../mock_data'; +import DescriptionComponent from '~/issue_show/components/description.vue'; + +describe('Incident Tabs component', () => { + let wrapper; + + const mountComponent = () => { + wrapper = shallowMount(IncidentTabs, { + propsData: { + ...descriptionProps, + }, + stubs: { + DescriptionComponent: true, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + const findTabs = () => wrapper.findAll(GlTab); + const findSummaryTab = () => findTabs().at(0); + const findDescriptionComponent = () => wrapper.find(DescriptionComponent); + + describe('default state', () => { + it('renders the summary tab', async () => { + expect(findTabs()).toHaveLength(1); + expect(findSummaryTab().exists()).toBe(true); + expect(findSummaryTab().attributes('title')).toBe('Summary'); + }); + + it('renders the description component', () => { + expect(findDescriptionComponent().exists()).toBe(true); + }); + + it('passes all props to the description component', () => { + expect(findDescriptionComponent().props()).toMatchObject(descriptionProps); + }); + }); +}); diff --git a/spec/frontend/issue_show/index_spec.js b/spec/frontend/issue_show/index_spec.js deleted file mode 100644 index e80d1b83c11..00000000000 --- a/spec/frontend/issue_show/index_spec.js +++ /dev/null @@ -1,19 +0,0 @@ -import initIssueableApp from '~/issue_show'; - -describe('Issue show index', () => { - describe('initIssueableApp', () => { - it('should initialize app with no potential XSS attack', () => { - const d = document.createElement('div'); - d.id = 'js-issuable-app-initial-data'; - d.innerHTML = JSON.stringify({ - initialDescriptionHtml: '<img src=x onerror=alert(1)>', - }); - document.body.appendChild(d); - - const alertSpy = jest.spyOn(window, 'alert'); - initIssueableApp(); - - expect(alertSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js new file mode 100644 index 00000000000..29b4f49f74f --- /dev/null +++ b/spec/frontend/issue_show/issue_spec.js @@ -0,0 +1,26 @@ +import initIssuableApp from '~/issue_show/issue'; +import { parseIssuableData } from '~/issue_show/utils/parse_data'; + +describe('Issue show index', () => { + describe('initIssueableApp', () => { + // Warning: this test is currently faulty. + // More details at https://gitlab.com/gitlab-org/gitlab/-/issues/241717 + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should initialize app with no potential XSS attack', () => { + const d = document.createElement('div'); + d.id = 'js-issuable-app-initial-data'; + + d.innerHTML = JSON.stringify({ + initialDescriptionHtml: '<img src=x onerror=alert(1)>', + }); + + document.body.appendChild(d); + + const alertSpy = jest.spyOn(window, 'alert'); + const issuableData = parseIssuableData(); + initIssuableApp(issuableData); + + expect(alertSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/issue_show/mock_data.js b/spec/frontend/issue_show/mock_data.js index ff01a004186..6d17eb08bf7 100644 --- a/spec/frontend/issue_show/mock_data.js +++ b/spec/frontend/issue_show/mock_data.js @@ -1,3 +1,5 @@ +import { TEST_HOST } from 'helpers/test_constants'; + export const initialRequest = { title: '<p>this is a title</p>', title_text: 'this is a title', @@ -21,3 +23,11 @@ export const secondRequest = { updated_by_path: '/other_user', lock_version: 2, }; + +export const descriptionProps = { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + taskStatus: '', + updateUrl: TEST_HOST, +}; diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb index f9acbe46820..a548589d269 100644 --- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb +++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb @@ -206,6 +206,33 @@ RSpec.describe Resolvers::MergeRequestsResolver do expect(result.compact).to contain_exactly(merge_request_4) end end + + describe 'sorting' do + context 'when sorting by created' do + it 'sorts merge requests ascending' do + expect(resolve_mr(project, sort: 'created_asc')).to eq [merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5, merge_request_6, merge_request_with_milestone] + end + + it 'sorts merge requests descending' do + expect(resolve_mr(project, sort: 'created_desc')).to eq [merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_3, merge_request_2, merge_request_1] + end + end + + context 'when sorting by merged at' do + before do + merge_request_1.metrics.update!(merged_at: 10.days.ago) + merge_request_3.metrics.update!(merged_at: 5.days.ago) + end + + it 'sorts merge requests ascending' do + expect(resolve_mr(project, sort: :merged_at_asc)).to eq [merge_request_1, merge_request_3, merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_2] + end + + it 'sorts merge requests descending' do + expect(resolve_mr(project, sort: :merged_at_desc)).to eq [merge_request_3, merge_request_1, merge_request_with_milestone, merge_request_6, merge_request_5, merge_request_4, merge_request_2] + end + end + end end def resolve_mr_single(project, iid) diff --git a/spec/graphql/types/merge_request_sort_enum_spec.rb b/spec/graphql/types/merge_request_sort_enum_spec.rb new file mode 100644 index 00000000000..472eba1a50d --- /dev/null +++ b/spec/graphql/types/merge_request_sort_enum_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['MergeRequestSort'] do + specify { expect(described_class.graphql_name).to eq('MergeRequestSort') } + + it_behaves_like 'common sort values' + + it 'exposes all the existing issue sort values' do + expect(described_class.values.keys).to include( + *%w[MERGED_AT_ASC MERGED_AT_DESC] + ) + end +end diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb index 95255d534af..44a89bfa35e 100644 --- a/spec/graphql/types/project_type_spec.rb +++ b/spec/graphql/types/project_type_spec.rb @@ -75,7 +75,8 @@ RSpec.describe GitlabSchema.types['Project'] do :merged_before, :author_username, :assignee_username, - :milestone_title + :milestone_title, + :sort ) end end diff --git a/spec/helpers/container_registry_helper_spec.rb b/spec/helpers/container_registry_helper_spec.rb new file mode 100644 index 00000000000..6e6e8137b3e --- /dev/null +++ b/spec/helpers/container_registry_helper_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ContainerRegistryHelper do + using RSpec::Parameterized::TableSyntax + + describe '#limit_delete_tags_service?' do + subject { helper.limit_delete_tags_service? } + + where(:feature_flag_enabled, :client_support, :expected_result) do + true | true | true + true | false | false + false | true | false + false | false | false + end + + with_them do + before do + stub_feature_flags(container_registry_expiration_policies_throttling: feature_flag_enabled) + allow(ContainerRegistry::Client).to receive(:supports_tag_delete?).and_return(client_support) + end + + it { is_expected.to eq(expected_result) } + end + end +end diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 9b32758c053..8e83bd0ee9d 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -197,7 +197,8 @@ RSpec.describe IssuablesHelper do initialTitleText: issue.title, initialDescriptionHtml: '<p dir="auto">issue text</p>', initialDescriptionText: 'issue text', - initialTaskStatus: '0 of 0 tasks completed' + initialTaskStatus: '0 of 0 tasks completed', + issueType: 'issue' } expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data)) end diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb index aa947329c33..4daf7375a40 100644 --- a/spec/lib/container_registry/client_spec.rb +++ b/spec/lib/container_registry/client_spec.rb @@ -289,4 +289,57 @@ RSpec.describe ContainerRegistry::Client do end end end + + describe '.supports_tag_delete?' do + let(:registry_enabled) { true } + let(:registry_api_url) { 'http://sandbox.local' } + let(:registry_tags_support_enabled) { true } + let(:is_on_dot_com) { false } + + subject { described_class.supports_tag_delete? } + + before do + allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com) + stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key') + stub_registry_tags_support(registry_tags_support_enabled) + end + + context 'with the registry enabled' do + it { is_expected.to be true } + + context 'without an api url' do + let(:registry_api_url) { '' } + + it { is_expected.to be false } + end + + context 'on .com' do + let(:is_on_dot_com) { true } + + it { is_expected.to be true } + end + + context 'when registry server does not support tag deletion' do + let(:registry_tags_support_enabled) { false } + + it { is_expected.to be false } + end + end + + context 'with the registry disabled' do + let(:registry_enabled) { false } + + it { is_expected.to be false } + end + + def stub_registry_tags_support(supported = true) + status_code = supported ? 200 : 404 + stub_request(:options, "#{registry_api_url}/v2/name/tags/reference/tag") + .to_return( + status: status_code, + body: '', + headers: { 'Allow' => 'DELETE' } + ) + end + end end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index ab25608e2f0..0c4318013e3 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -71,6 +71,8 @@ RSpec.describe ApplicationSetting do it { is_expected.not_to allow_value('three').for(:push_event_activities_limit) } it { is_expected.not_to allow_value(nil).for(:push_event_activities_limit) } + it { is_expected.to validate_numericality_of(:container_registry_delete_tags_service_timeout).only_integer.is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) } it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) } it { is_expected.to validate_presence_of(:max_artifacts_size) } diff --git a/spec/models/atlassian/identity_spec.rb b/spec/models/atlassian/identity_spec.rb new file mode 100644 index 00000000000..a1dfe5b0e51 --- /dev/null +++ b/spec/models/atlassian/identity_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Atlassian::Identity do + describe 'associations' do + it { is_expected.to belong_to(:user) } + end + + describe 'validations' do + subject { create(:atlassian_identity) } + + it { is_expected.to validate_presence_of(:extern_uid) } + it { is_expected.to validate_uniqueness_of(:extern_uid) } + it { is_expected.to validate_presence_of(:user) } + it { is_expected.to validate_uniqueness_of(:user) } + end + + describe 'encrypted tokens' do + let(:token) { SecureRandom.alphanumeric(1254) } + let(:refresh_token) { SecureRandom.alphanumeric(45) } + let(:identity) { create(:atlassian_identity, token: token, refresh_token: refresh_token) } + + it 'saves the encrypted token, refresh token and corresponding ivs' do + expect(identity.encrypted_token).not_to be_nil + expect(identity.encrypted_token_iv).not_to be_nil + expect(identity.encrypted_refresh_token).not_to be_nil + expect(identity.encrypted_refresh_token_iv).not_to be_nil + + expect(identity.token).to eq(token) + expect(identity.refresh_token).to eq(refresh_token) + end + end +end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 052afc28ef7..63c2b6fbf7b 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -61,6 +61,24 @@ RSpec.describe MergeRequest, factory_default: :keep do end end + describe '.order_merged_at_asc' do + let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) } + let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) } + + it 'returns MRs ordered by merged_at ascending' do + expect(described_class.order_merged_at_asc).to eq([older_mr, newer_mr]) + end + end + + describe '.order_merged_at_desc' do + let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) } + let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) } + + it 'returns MRs ordered by merged_at descending' do + expect(described_class.order_merged_at_desc).to eq([newer_mr, older_mr]) + end + end + describe '#squash_in_progress?' do let(:repo_path) do Gitlab::GitalyClient::StorageSettings.allow_disk_access do @@ -431,6 +449,23 @@ RSpec.describe MergeRequest, factory_default: :keep do end end + describe '.sort_by_attribute' do + context 'merged_at' do + let_it_be(:older_mr) { create(:merge_request, :with_merged_metrics) } + let_it_be(:newer_mr) { create(:merge_request, :with_merged_metrics) } + + it 'sorts asc' do + merge_requests = described_class.sort_by_attribute(:merged_at_asc) + expect(merge_requests).to eq([older_mr, newer_mr]) + end + + it 'sorts desc' do + merge_requests = described_class.sort_by_attribute(:merged_at_desc) + expect(merge_requests).to eq([newer_mr, older_mr]) + end + end + end + describe '#target_branch_sha' do let(:project) { create(:project, :repository) } diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 0c356fc5118..bdd13a77d7f 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -171,6 +171,16 @@ RSpec.describe Service do it { is_expected.to be_falsey } end end + + context 'when group-level service' do + Service.available_services_types.each do |service_type| + let(:service) do + service_type.constantize.new(group_id: group.id) + end + + it { is_expected.to be_falsey } + end + end end describe '#test' do diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index 3f9c6981de1..248957a43e0 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -787,4 +787,26 @@ RSpec.describe Snippet do end end end + + describe '#multiple_files?' do + subject { snippet.multiple_files? } + + context 'when snippet has multiple files' do + let(:snippet) { create(:snippet, :repository) } + + it { is_expected.to be_truthy } + end + + context 'when snippet does not have multiple files' do + let(:snippet) { create(:snippet, :empty_repo) } + + it { is_expected.to be_falsey } + end + + context 'when the snippet does not have a repository' do + let(:snippet) { build(:snippet) } + + it { is_expected.to be_falsey } + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 9cd666e541f..98f1c5bb2cc 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -68,6 +68,7 @@ RSpec.describe User do it { is_expected.to have_one(:namespace) } it { is_expected.to have_one(:status) } it { is_expected.to have_one(:user_detail) } + it { is_expected.to have_one(:atlassian_identity) } it { is_expected.to have_one(:user_highest_role) } it { is_expected.to have_many(:snippets).dependent(:destroy) } it { is_expected.to have_many(:members) } diff --git a/spec/requests/api/graphql/mutations/design_management/upload_spec.rb b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb index 9a9c7107b20..2189ae3c519 100644 --- a/spec/requests/api/graphql/mutations/design_management/upload_spec.rb +++ b/spec/requests/api/graphql/mutations/design_management/upload_spec.rb @@ -12,11 +12,11 @@ RSpec.describe "uploading designs" do let(:files) { [fixture_file_upload("spec/fixtures/dk.png")] } let(:variables) { {} } - let(:mutation) do + def mutation input = { project_path: project.full_path, iid: issue.iid, - files: files + files: files.dup }.merge(variables) graphql_mutation(:design_management_upload, input) end @@ -30,31 +30,15 @@ RSpec.describe "uploading designs" do end it "returns an error if the user is not allowed to upload designs" do - post_graphql_mutation(mutation, current_user: create(:user)) + post_graphql_mutation_with_uploads(mutation, current_user: create(:user)) expect(graphql_errors).to be_present end - it "succeeds (backward compatibility)" do - post_graphql_mutation(mutation, current_user: current_user) + it "succeeds, and responds with the created designs" do + post_graphql_mutation_with_uploads(mutation, current_user: current_user) expect(graphql_errors).not_to be_present - end - - it 'succeeds' do - file_path_in_params = ['designManagementUploadInput', 'files', 0] - params = mutation_to_apollo_uploads_param(mutation, files: [file_path_in_params]) - - workhorse_post_with_file(api('/', current_user, version: 'graphql'), - params: params, - file_key: '1' - ) - - expect(graphql_errors).not_to be_present - end - - it "responds with the created designs" do - post_graphql_mutation(mutation, current_user: current_user) expect(mutation_response).to include( "designs" => a_collection_containing_exactly( @@ -65,7 +49,7 @@ RSpec.describe "uploading designs" do it "can respond with skipped designs" do 2.times do - post_graphql_mutation(mutation, current_user: current_user) + post_graphql_mutation_with_uploads(mutation, current_user: current_user) files.each(&:rewind) end @@ -80,7 +64,7 @@ RSpec.describe "uploading designs" do let(:variables) { { iid: "123" } } it "returns an error" do - post_graphql_mutation(mutation, current_user: create(:user)) + post_graphql_mutation_with_uploads(mutation, current_user: create(:user)) expect(graphql_errors).not_to be_empty end @@ -92,7 +76,7 @@ RSpec.describe "uploading designs" do expect(service).to receive(:execute).and_return({ status: :error, message: "Something went wrong" }) end - post_graphql_mutation(mutation, current_user: current_user) + post_graphql_mutation_with_uploads(mutation, current_user: current_user) expect(mutation_response["errors"].first).to eq("Something went wrong") end end diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb index bb63a5994b0..9446e10c01d 100644 --- a/spec/requests/api/graphql/project/merge_requests_spec.rb +++ b/spec/requests/api/graphql/project/merge_requests_spec.rb @@ -210,4 +210,48 @@ RSpec.describe 'getting merge request listings nested in a project' do include_examples 'N+1 query check' end end + describe 'sorting and pagination' do + let(:data_path) { [:project, :mergeRequests] } + + def pagination_query(params, page_info) + graphql_query_for( + :project, + { full_path: project.full_path }, + <<~QUERY + mergeRequests(#{params}) { + #{page_info} edges { + node { + id + } + } + } + QUERY + ) + end + + def pagination_results_data(data) + data.map { |project| project.dig('node', 'id') } + end + + context 'when sorting by merged_at DESC' do + it_behaves_like 'sorted paginated query' do + let(:sort_param) { 'MERGED_AT_DESC' } + let(:first_param) { 2 } + + let(:expected_results) do + [ + merge_request_b, + merge_request_c, + merge_request_d, + merge_request_a + ].map(&:to_gid).map(&:to_s) + end + + before do + merge_request_c.metrics.update!(merged_at: 5.days.ago) + merge_request_b.metrics.update!(merged_at: 1.day.ago) + end + end + end + end end diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb index 4e2f6e108eb..81dd9022657 100644 --- a/spec/requests/api/snippets_spec.rb +++ b/spec/requests/api/snippets_spec.rb @@ -391,21 +391,98 @@ RSpec.describe API::Snippets do create(:personal_snippet, :repository, author: user, visibility_level: visibility_level) end - shared_examples 'snippet updates' do - it 'updates a snippet' do - new_content = 'New content' + let(:create_action) { { action: 'create', file_path: 'foo.txt', content: 'bar' } } + let(:update_action) { { action: 'update', file_path: 'CHANGELOG', content: 'bar' } } + let(:move_action) { { action: 'move', file_path: '.old-gitattributes', previous_path: '.gitattributes' } } + let(:delete_action) { { action: 'delete', file_path: 'CONTRIBUTING.md' } } + let(:bad_file_path) { { action: 'create', file_path: '../../etc/passwd', content: 'bar' } } + let(:bad_previous_path) { { action: 'create', previous_path: '../../etc/passwd', file_path: 'CHANGELOG', content: 'bar' } } + let(:invalid_move) { { action: 'move', file_path: 'missing_previous_path.txt' } } + + context 'with snippet file changes' do + using RSpec::Parameterized::TableSyntax + + where(:is_multi_file, :file_name, :content, :files, :status) do + true | nil | nil | [create_action] | :success + true | nil | nil | [update_action] | :success + true | nil | nil | [move_action] | :success + true | nil | nil | [delete_action] | :success + true | nil | nil | [create_action, update_action] | :success + true | 'foo.txt' | 'bar' | [create_action] | :bad_request + true | 'foo.txt' | 'bar' | nil | :bad_request + true | nil | nil | nil | :bad_request + true | 'foo.txt' | nil | [create_action] | :bad_request + true | nil | 'bar' | [create_action] | :bad_request + true | '' | nil | [create_action] | :bad_request + true | nil | '' | [create_action] | :bad_request + true | nil | nil | [bad_file_path] | :bad_request + true | nil | nil | [bad_previous_path] | :bad_request + true | nil | nil | [invalid_move] | :forbidden + + false | 'foo.txt' | 'bar' | nil | :success + false | 'foo.txt' | nil | nil | :success + false | nil | 'bar' | nil | :success + false | 'foo.txt' | 'bar' | [create_action] | :bad_request + false | nil | nil | nil | :bad_request + false | nil | '' | nil | :bad_request + false | nil | nil | [bad_file_path] | :bad_request + false | nil | nil | [bad_previous_path] | :bad_request + end + + with_them do + before do + allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(is_multi_file) + end + + it 'has the correct response' do + update_params = {}.tap do |params| + params[:files] = files if files + params[:file_name] = file_name if file_name + params[:content] = content if content + end + + update_snippet(params: update_params) + + expect(response).to have_gitlab_http_status(status) + end + end + + context 'when save fails due to a repository commit error' do + before do + allow_next_instance_of(Repository) do |instance| + allow(instance).to receive(:multi_action).and_raise(Gitlab::Git::CommitError) + end + + update_snippet(params: { files: [create_action] }) + end + + it 'returns a bad request response' do + expect(response).to have_gitlab_http_status(:bad_request) + end + end + end + + shared_examples 'snippet non-file updates' do + it 'updates a snippet non-file attributes' do new_description = 'New description' + new_title = 'New title' + new_visibility = 'internal' - update_snippet(params: { content: new_content, description: new_description, visibility: 'internal' }) + update_snippet(params: { title: new_title, description: new_description, visibility: new_visibility }) - expect(response).to have_gitlab_http_status(:ok) snippet.reload - expect(snippet.content).to eq(new_content) - expect(snippet.description).to eq(new_description) - expect(snippet.visibility).to eq('internal') + + aggregate_failures do + expect(response).to have_gitlab_http_status(:ok) + expect(snippet.description).to eq(new_description) + expect(snippet.visibility).to eq(new_visibility) + expect(snippet.title).to eq(new_title) + end end end + it_behaves_like 'snippet non-file updates' + context 'with restricted visibility settings' do before do stub_application_setting(restricted_visibility_levels: @@ -413,11 +490,9 @@ RSpec.describe API::Snippets do Gitlab::VisibilityLevel::PRIVATE]) end - it_behaves_like 'snippet updates' + it_behaves_like 'snippet non-file updates' end - it_behaves_like 'snippet updates' - it 'returns 404 for invalid snippet id' do update_snippet(snippet_id: non_existing_record_id, params: { title: 'Foo' }) @@ -438,13 +513,6 @@ RSpec.describe API::Snippets do expect(response).to have_gitlab_http_status(:bad_request) end - it 'returns 400 if content is blank' do - update_snippet(params: { content: '' }) - - expect(response).to have_gitlab_http_status(:bad_request) - expect(json_response['error']).to eq 'content is empty' - end - it 'returns 400 if title is blank' do update_snippet(params: { title: '' }) diff --git a/spec/services/ci/parse_dotenv_artifact_service_spec.rb b/spec/services/ci/parse_dotenv_artifact_service_spec.rb index a5f01187a83..91b81af9fd1 100644 --- a/spec/services/ci/parse_dotenv_artifact_service_spec.rb +++ b/spec/services/ci/parse_dotenv_artifact_service_spec.rb @@ -66,12 +66,13 @@ RSpec.describe Ci::ParseDotenvArtifactService do end context 'when multiple key/value pairs exist in one line' do - let(:blob) { 'KEY1=VAR1KEY2=VAR1' } + let(:blob) { 'KEY=VARCONTAINING=EQLS' } - it 'returns error' do - expect(subject[:status]).to eq(:error) - expect(subject[:message]).to eq("Validation failed: Key can contain only letters, digits and '_'.") - expect(subject[:http_status]).to eq(:bad_request) + it 'parses the dotenv data' do + subject + + expect(build.job_variables.as_json).to contain_exactly( + hash_including('key' => 'KEY', 'value' => 'VARCONTAINING=EQLS')) end end diff --git a/spec/services/projects/container_repository/delete_tags_service_spec.rb b/spec/services/projects/container_repository/delete_tags_service_spec.rb index 3014ccbd7ba..5116427dad2 100644 --- a/spec/services/projects/container_repository/delete_tags_service_spec.rb +++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb @@ -90,6 +90,10 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do subject { service.execute(repository) } + before do + stub_feature_flags(container_registry_expiration_policies_throttling: false) + end + context 'without permissions' do it { is_expected.to include(status: :error) } end @@ -119,6 +123,18 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do it_behaves_like 'logging a success response' end + + context 'with a timeout error' do + before do + expect_next_instance_of(::Projects::ContainerRepository::Gitlab::DeleteTagsService) do |delete_service| + expect(delete_service).to receive(:delete_tags).and_raise(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError) + end + end + + it { is_expected.to include(status: :error, message: 'timeout while deleting tags') } + + it_behaves_like 'logging an error response', message: 'timeout while deleting tags' + end end context 'and the feature is disabled' do diff --git a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb index 68c232e5d83..3bbcec8775e 100644 --- a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb +++ b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb @@ -12,13 +12,21 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do subject { service.execute } - context 'with tags to delete' do + before do + stub_feature_flags(container_registry_expiration_policies_throttling: false) + end + + RSpec.shared_examples 'deleting tags' do it 'deletes the tags by name' do stub_delete_reference_requests(tags) expect_delete_tag_by_names(tags) is_expected.to eq(status: :success, deleted: tags) end + end + + context 'with tags to delete' do + it_behaves_like 'deleting tags' it 'succeeds when tag delete returns 404' do stub_delete_reference_requests('A' => 200, 'Ba' => 404) @@ -41,6 +49,47 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do it { is_expected.to eq(status: :error, message: 'could not delete tags') } end end + + context 'with throttling enabled' do + let(:timeout) { 10 } + + before do + stub_feature_flags(container_registry_expiration_policies_throttling: true) + stub_application_setting(container_registry_delete_tags_service_timeout: timeout) + end + + it_behaves_like 'deleting tags' + + context 'with timeout' do + context 'set to a valid value' do + before do + allow(Time.zone).to receive(:now).and_return(10, 15, 25) # third call to Time.zone.now will be triggering the timeout + stub_delete_reference_requests('A' => 200) + end + + it { is_expected.to include(status: :error, message: 'timeout while deleting tags') } + + it 'tracks the exception' do + expect(::Gitlab::ErrorTracking) + .to receive(:track_exception).with(::Projects::ContainerRepository::Gitlab::DeleteTagsService::TimeoutError, tags_count: tags.size, container_repository_id: repository.id) + + subject + end + end + + context 'set to 0' do + let(:timeout) { 0 } + + it_behaves_like 'deleting tags' + end + + context 'set to nil' do + let(:timeout) { nil } + + it_behaves_like 'deleting tags' + end + end + end end context 'with empty tags' do diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb index 87525734490..5635ba3df05 100644 --- a/spec/support/helpers/graphql_helpers.rb +++ b/spec/support/helpers/graphql_helpers.rb @@ -241,6 +241,39 @@ module GraphqlHelpers post_graphql(mutation.query, current_user: current_user, variables: mutation.variables) end + def post_graphql_mutation_with_uploads(mutation, current_user: nil) + file_paths = file_paths_in_mutation(mutation) + params = mutation_to_apollo_uploads_param(mutation, files: file_paths) + + workhorse_post_with_file(api('/', current_user, version: 'graphql'), + params: params, + file_key: '1' + ) + end + + def file_paths_in_mutation(mutation) + paths = [] + find_uploads(paths, [], mutation.variables) + + paths + end + + # Depth first search for UploadedFile values + def find_uploads(paths, path, value) + case value + when Rack::Test::UploadedFile + paths << path + when Hash + value.each do |k, v| + find_uploads(paths, path + [k], v) + end + when Array + value.each_with_index do |v, i| + find_uploads(paths, path + [i], v) + end + end + end + # this implements GraphQL multipart request v2 # https://github.com/jaydenseric/graphql-multipart-request-spec/tree/v2.0.0-alpha.2 # this is simplified and do not support file deduplication diff --git a/spec/support/shared_examples/requests/snippet_shared_examples.rb b/spec/support/shared_examples/requests/snippet_shared_examples.rb index a17163328f4..84ef7723b9b 100644 --- a/spec/support/shared_examples/requests/snippet_shared_examples.rb +++ b/spec/support/shared_examples/requests/snippet_shared_examples.rb @@ -2,6 +2,10 @@ RSpec.shared_examples 'update with repository actions' do context 'when the repository exists' do + before do + allow_any_instance_of(Snippet).to receive(:multiple_files?).and_return(false) + end + it 'commits the changes to the repository' do existing_blob = snippet.blobs.first new_file_name = existing_blob.path + '_new' |