diff options
65 files changed, 1275 insertions, 229 deletions
diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml index ba623ef4cbe..28f386329a3 100644 --- a/.gitlab/ci/global.gitlab-ci.yml +++ b/.gitlab/ci/global.gitlab-ci.yml @@ -395,3 +395,29 @@ before_script: - export KUBE_CONTEXT="gitlab-org/gitlab:review-apps" - kubectl config use-context ${KUBE_CONTEXT} + +.fast-no-clone-job: + variables: + GIT_STRATEGY: none # We will download the required files for the job from the API + before_script: + # Logic taken from scripts/utils.sh in download_files function + - | + if [[ "${CI_PROJECT_VISIBILITY}" == "public" ]]; then + url="${CI_PROJECT_URL}/raw/${CI_COMMIT_SHA}" + else + url="https://gitlab.com/gitlab-org/gitlab/raw/master" + + echo -e "\033[1;31m ************************************ \033[0m" + echo -e "\033[1;31m ************* WARNING! ************* \033[0m" + echo -e "\033[1;31m ************************************ \033[0m" + + echo -e "\033[1;31m The following files will be downloaded from gitlab-org/gitlab's master branch: \033[0m" + echo -e "\033[1;31m \t scripts/utils.sh \033[0m" + for file in "${FILES_TO_DOWNLOAD}"; do + echo -e "\033[1;31m \t $file \033[0m" + done + fi + + curl "${url}/scripts/utils.sh" --create-dirs --output scripts/utils.sh + - source scripts/utils.sh + - download_files ${FILES_TO_DOWNLOAD} diff --git a/.gitlab/ci/review-apps/main.gitlab-ci.yml b/.gitlab/ci/review-apps/main.gitlab-ci.yml index 369330f8189..0812f236c62 100644 --- a/.gitlab/ci/review-apps/main.gitlab-ci.yml +++ b/.gitlab/ci/review-apps/main.gitlab-ci.yml @@ -30,6 +30,7 @@ review-build-cng-env: extends: - .default-retry - .review:rules:review-build-cng + - .fast-no-clone-job image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}ruby:${RUBY_VERSION}-alpine3.16 stage: prepare needs: @@ -38,8 +39,10 @@ review-build-cng-env: job: build-assets-image variables: BUILD_ENV: build.env + FILES_TO_DOWNLOAD: scripts/trigger-build.rb before_script: - - source ./scripts/utils.sh + - apk add --no-cache --update curl # Not present in ruby-alpine, so we add it manually + - !reference [".fast-no-clone-job", before_script] - install_gitlab_gem script: - 'ruby -r./scripts/trigger-build.rb -e "puts Trigger.variables_for_env_file(Trigger::CNG.new.variables)" > $BUILD_ENV' @@ -105,6 +108,7 @@ review-deploy: extends: - .review-workflow-base - .review:rules:review-deploy + - .fast-no-clone-job stage: deploy image: ${GITLAB_DEPENDENCY_PROXY_ADDRESS}dtzar/helm-kubectl:3.9.3 needs: @@ -116,7 +120,17 @@ review-deploy: - "gitlab-${GITLAB_HELM_CHART_REF}" environment: action: start + variables: + # We use > instead of | because we want the files to be space-separated. + FILES_TO_DOWNLOAD: > + scripts/review_apps/review-apps.sh + scripts/review_apps/seed-dast-test-data.sh + GITLAB_SHELL_VERSION + GITALY_SERVER_VERSION + GITLAB_WORKHORSE_VERSION before_script: + - apk add --no-cache --update curl # Not present in ruby-alpine, so we add it manually + - !reference [".fast-no-clone-job", before_script] - export GITLAB_SHELL_VERSION=$(<GITLAB_SHELL_VERSION) - export GITALY_VERSION=$(<GITALY_SERVER_VERSION) - export GITLAB_WORKHORSE_VERSION=$(<GITLAB_WORKHORSE_VERSION) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 574451662c2..b4edbf7434d 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -4ca70c40dd50e1e3fe6904c05eedca33dcd8b429 +b0919f19443fbc6a155caf6d029fbe1cdd19a4c0 diff --git a/app/assets/javascripts/admin/topics/components/topic_select.vue b/app/assets/javascripts/admin/topics/components/topic_select.vue index 8bf5be1afd1..9f42aa27097 100644 --- a/app/assets/javascripts/admin/topics/components/topic_select.vue +++ b/app/assets/javascripts/admin/topics/components/topic_select.vue @@ -1,22 +1,14 @@ <script> -import { - GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, -} from '@gitlab/ui'; -import { s__ } from '~/locale'; +import { GlAvatarLabeled, GlCollapsibleListbox } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { s__, n__ } from '~/locale'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql'; export default { components: { GlAvatarLabeled, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, + GlCollapsibleListbox, }, props: { selectedTopic: { @@ -48,15 +40,13 @@ export default { return { topics: [], search: '', + selected: null, }; }, computed: { loading() { return this.$apollo.queries.topics.loading; }, - isResultEmpty() { - return this.topics.length === 0; - }, dropdownText() { if (Object.keys(this.selectedTopic).length) { return this.selectedTopic.name; @@ -64,10 +54,35 @@ export default { return this.$options.i18n.dropdownText; }, + items() { + return this.topics.map(({ id, title, name, avatarUrl }) => ({ + value: id, + text: title, + secondaryText: name, + icon: avatarUrl, + })); + }, + searchSummary() { + return n__('TopicSelect|%d topic found', 'TopicSelect|%d topics found', this.topics.length); + }, + labelId() { + if (!this.labelText) { + return null; + } + + return uniqueId('topic-listbox-label-'); + }, }, methods: { - selectTopic(topic) { - this.$emit('click', topic); + onSelect(topicId) { + const topicObj = this.topics.find((topic) => topic.id === topicId); + + if (!topicObj) return; + + this.$emit('click', topicObj); + }, + onSearch(query) { + this.search = query; }, }, i18n: { @@ -81,26 +96,34 @@ export default { <template> <div> - <label v-if="labelText">{{ labelText }}</label> - <gl-dropdown block :text="dropdownText"> - <gl-search-box-by-type - v-model="search" - :is-loading="loading" - :placeholder="$options.i18n.searchPlaceholder" - /> - <gl-dropdown-item v-for="topic in topics" :key="topic.id" @click="selectTopic(topic)"> + <label v-if="labelText" :id="labelId">{{ labelText }}</label> + <gl-collapsible-listbox + v-model="selected" + block + searchable + is-check-centered + :items="items" + :toggle-text="dropdownText" + :searching="loading" + :search-placeholder="$options.i18n.searchPlaceholder" + :no-results-text="$options.i18n.emptySearchResult" + :toggle-aria-labelled-by="labelId" + @select="onSelect" + @search="onSearch" + > + <template #list-item="{ item: { text, secondaryText, icon } }"> <gl-avatar-labeled - :label="topic.title" - :sub-label="topic.name" - :src="topic.avatarUrl" - :entity-name="topic.name" + :label="text" + :sub-label="secondaryText" + :src="icon" + :entity-name="secondaryText" :size="32" :shape="$options.AVATAR_SHAPE_OPTION_RECT" /> - </gl-dropdown-item> - <gl-dropdown-text v-if="isResultEmpty && !loading"> - <span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span> - </gl-dropdown-text> - </gl-dropdown> + </template> + <template #search-summary-sr-only> + {{ searchSummary }} + </template> + </gl-collapsible-listbox> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 04c5007dbec..3bc24e8ce01 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; @@ -35,6 +36,16 @@ export default { default: true, }, }, + data() { + return { + formFieldProps: { + id: 'issue-description', + name: 'issue-description', + placeholder: __('Write a comment or drag your files here…'), + 'aria-label': __('Description'), + }, + }; + }, computed: { quickActionsDocsPath() { return helpPagePath('user/project/quick_actions'); @@ -60,10 +71,7 @@ export default { :value="value" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" - :form-field-aria-label="__('Description')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="issue-description" - form-field-name="issue-description" + :form-field-props="formFieldProps" :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" supports-quick-actions @@ -84,15 +92,13 @@ export default { > <template #textarea> <textarea - id="issue-description" + v-bind="formFieldProps" ref="textarea" :value="value" class="note-textarea js-gfm-input js-autosize markdown-area" data-qa-selector="description_field" dir="auto" data-supports-quick-actions="true" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" @input="$emit('input', $event.target.value)" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" diff --git a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue index 8e2f542aec0..0d2bbfbbc43 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -119,6 +119,12 @@ export default { isContentEditorActive: false, switchEditingControlDisabled: false, isFormDirty: getIsFormDirty(this.pageInfo), + formFieldProps: { + placeholder: this.$options.i18n.content.placeholder, + 'aria-label': this.$options.i18n.content.label, + id: 'wiki_content', + name: 'wiki[content]', + }, }; }, computed: { @@ -338,16 +344,13 @@ export default { <gl-form-group> <markdown-editor v-model="content" + :form-field-props="formFieldProps" :render-markdown-path="pageInfo.markdownPreviewPath" :markdown-docs-path="pageInfo.markdownHelpPath" :uploads-path="pageInfo.uploadsPath" :enable-content-editor="isMarkdownFormat" :enable-preview="isMarkdownFormat" :autofocus="pageInfo.persisted" - :form-field-placeholder="$options.i18n.content.placeholder" - :form-field-aria-label="$options.i18n.content.label" - form-field-id="wiki_content" - form-field-name="wiki[content]" @contentEditor="notifyContentEditorActive" @markdownField="notifyContentEditorInactive" @keydown.ctrl.enter="submitFormShortcut" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index c498f12d5c7..4111823e0bb 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -311,7 +311,7 @@ export default { this.resetRequestData(); } - this.updateContent(this.requestData); + this.updateContent({ ...this.requestData, page: '1' }); }, changeVisibilityPipelineID(val) { this.selectedPipelineKeyOption = PipelineKeyOptions.find((e) => val === e.value); diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index c53118b9f62..9d294369afa 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -41,14 +41,6 @@ export default { required: false, default: true, }, - formFieldId: { - type: String, - required: true, - }, - formFieldName: { - type: String, - required: true, - }, enablePreview: { type: Boolean, required: false, @@ -59,15 +51,10 @@ export default { required: false, default: true, }, - formFieldPlaceholder: { - type: String, - required: false, - default: '', - }, - formFieldAriaLabel: { - type: String, - required: false, - default: '', + formFieldProps: { + type: Object, + required: true, + validator: (prop) => prop.id && prop.name, }, autofocus: { type: Boolean, @@ -160,16 +147,13 @@ export default { > <template #textarea> <textarea - :id="formFieldId" + v-bind="formFieldProps" ref="textarea" :value="value" - :name="formFieldName" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" :data-supports-quick-actions="supportsQuickActions" data-qa-selector="markdown_editor_form_field" - :aria-label="formFieldAriaLabel" - :placeholder="formFieldPlaceholder" @input="updateMarkdownFromMarkdownField" @keydown="$emit('keydown', $event)" > @@ -189,9 +173,8 @@ export default { @enableMarkdownEditor="onEditingModeChange('markdownField')" /> <input - :id="formFieldId" + v-bind="formFieldProps" :value="value" - :name="formFieldName" data-qa-selector="markdown_editor_form_field" type="hidden" /> diff --git a/app/assets/javascripts/work_items/components/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/work_item_comment_form.vue index e2025aebd28..53e704f3da6 100644 --- a/app/assets/javascripts/work_items/components/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_comment_form.vue @@ -79,6 +79,12 @@ export default { isSubmitting: false, isSubmittingWithKeydown: false, commentText: '', + formFieldProps: { + 'aria-label': __('Add a comment'), + placeholder: __('Write a comment or drag your files here…'), + id: 'work-item-add-comment', + name: 'work-item-add-comment', + }, }; }, apollo: { @@ -230,10 +236,7 @@ export default { :value="commentText" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.constantOptions.markdownDocsPath" - :form-field-aria-label="__('Add a comment')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="work-item-add-comment" - form-field-name="work-item-add-comment" + :form-field-props="formFieldProps" data-testid="work-item-add-comment" enable-autocomplete autofocus diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index 07da0279b41..a93b1450012 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -56,6 +56,12 @@ export default { descriptionText: '', descriptionHtml: '', conflictedDescription: '', + formFieldProps: { + 'aria-label': __('Description'), + placeholder: __('Write a comment or drag your files here…'), + id: 'work-item-description', + name: 'work-item-description', + }, }; }, apollo: { @@ -241,10 +247,7 @@ export default { :value="descriptionText" :render-markdown-path="markdownPreviewPath" :markdown-docs-path="$options.markdownDocsPath" - :form-field-aria-label="__('Description')" - :form-field-placeholder="__('Write a comment or drag your files here…')" - form-field-id="work-item-description" - form-field-name="work-item-description" + :form-field-props="formFieldProps" enable-autocomplete init-on-autofocus use-bottom-toolbar @@ -263,15 +266,13 @@ export default { > <template #textarea> <textarea - id="work-item-description" + v-bind="formFieldProps" ref="textarea" v-model="descriptionText" :disabled="isSubmitting" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" data-supports-quick-actions="false" - :aria-label="__('Description')" - :placeholder="__('Write a comment or drag your files here…')" @keydown.meta.enter="updateWorkItem" @keydown.ctrl.enter="updateWorkItem" @keydown.exact.esc.stop="cancelEditing" diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 9c06a5cd724..8e9e1def0b9 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -19,7 +19,13 @@ import { } from '../constants'; function isTokenSelectorElement(el) { - return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item'); + return ( + el?.classList.contains('gl-label-close') || + el?.classList.contains('dropdown-item') || + // TODO: replace this logic when we have a class added to clear-all button in GitLab UI + (el?.classList.contains('gl-button') && + el?.closest('.form-control')?.classList.contains('gl-token-selector')) + ); } function addClass(el) { @@ -146,7 +152,17 @@ export default { watch: { labels(newVal) { if (!this.isEditing) { - this.localLabels = newVal.map(addClass); + // remove labels that aren't in list from server + this.localLabels = this.localLabels.filter((label) => + newVal.find((l) => l.id === label.id), + ); + + // add any that we don't have to the end + const labelsToAdd = newVal + .map(addClass) + .filter((label) => !this.localLabels.find((l) => l.id === label.id)); + + this.localLabels = this.localLabels.concat(labelsToAdd); } }, }, @@ -163,10 +179,11 @@ export default { this.setLabels(); }, async setLabels() { - if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return; - this.searchKey = ''; this.isEditing = false; + + if (this.addLabelIds.length === 0 && this.removeLabelIds.length === 0) return; + try { const { data: { @@ -214,18 +231,23 @@ export default { this.searchStarted = true; }, async focusTokenSelector(labels) { - const labelsToAdd = without(labels, ...this.localLabels).map((label) => label.id); - const labelsToRemove = without(this.localLabels, ...labels).map((label) => label.id); + const labelsToAdd = without(labels, ...this.localLabels); + const labelIdsToAdd = labelsToAdd.map((label) => label.id); + const labelIdsToRemove = without(this.localLabels, ...labels).map((label) => label.id); - if (labelsToAdd.length > 0) { - this.addLabelIds.push(...labelsToAdd); + if (labelIdsToAdd.length > 0) { + this.addLabelIds.push(...labelIdsToAdd); } - if (labelsToRemove.length > 0) { - this.removeLabelIds.push(...labelsToRemove); + if (labelIdsToRemove.length > 0) { + this.removeLabelIds.push(...labelIdsToRemove); } - this.localLabels = labels; + if (labels.length === 0) { + this.localLabels = []; + } else { + this.localLabels = this.localLabels.concat(labelsToAdd); + } this.handleFocus(); await this.$nextTick(); @@ -265,6 +287,7 @@ export default { :dropdown-items="searchLabels" :loading="isLoading" :view-only="!canUpdate" + :allow-clear-all="isEditing" class="gl-flex-grow-1 gl-border gl-border-white gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" data-testid="work-item-labels-input" :class="{ 'gl-hover-border-gray-200': canUpdate }" diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb index c91edb74d6b..2141b257b40 100644 --- a/app/controllers/concerns/send_file_upload.rb +++ b/app/controllers/concerns/send_file_upload.rb @@ -63,21 +63,32 @@ module SendFileUpload private def image_scaling_request?(file_upload) - avatar_safe_for_scaling?(file_upload) && - scaling_allowed_by_feature_flags?(file_upload) && - valid_image_scaling_width? + (avatar_safe_for_scaling?(file_upload) || pwa_icon_safe_for_scaling?(file_upload)) && + scaling_allowed_by_feature_flags?(file_upload) + end + + def pwa_icon_safe_for_scaling?(file_upload) + file_upload.try(:image_safe_for_scaling?) && + mounted_as_pwa_icon?(file_upload) && + valid_image_scaling_width?(Appearance::ALLOWED_PWA_ICON_SCALER_WIDTHS) end def avatar_safe_for_scaling?(file_upload) - file_upload.try(:image_safe_for_scaling?) && mounted_as_avatar?(file_upload) + file_upload.try(:image_safe_for_scaling?) && + mounted_as_avatar?(file_upload) && + valid_image_scaling_width?(Avatarable::ALLOWED_IMAGE_SCALER_WIDTHS) end def mounted_as_avatar?(file_upload) file_upload.try(:mounted_as)&.to_sym == :avatar end - def valid_image_scaling_width? - Avatarable::ALLOWED_IMAGE_SCALER_WIDTHS.include?(params[:width]&.to_i) + def mounted_as_pwa_icon?(file_upload) + file_upload.try(:mounted_as)&.to_sym == :pwa_icon + end + + def valid_image_scaling_width?(allowed_scalar_widths) + allowed_scalar_widths.include?(params[:width]&.to_i) end def scaling_allowed_by_feature_flags?(file_upload) diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb index 70f3b8dbb74..e9465e0db22 100644 --- a/app/helpers/appearances_helper.rb +++ b/app/helpers/appearances_helper.rb @@ -4,6 +4,20 @@ module AppearancesHelper include MarkupHelper include Gitlab::Utils::StrongMemoize + def appearance_pwa_icon_path_scaled(width) + return unless Appearance::ALLOWED_PWA_ICON_SCALER_WIDTHS.include?(width) + + append_root_path((current_appearance&.pwa_icon_path_scaled(width) || "/-/pwa-icons/logo-#{width}.png")) + end + + def appearance_maskable_logo + append_root_path('/-/pwa-icons/maskable-logo.png') + end + + def append_root_path(path) + Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, path) + end + def brand_title current_appearance&.title.presence || default_brand_title end diff --git a/app/models/appearance.rb b/app/models/appearance.rb index 34cc8d1a64d..833f2335774 100644 --- a/app/models/appearance.rb +++ b/app/models/appearance.rb @@ -5,6 +5,8 @@ class Appearance < ApplicationRecord include CacheMarkdownField include WithUploads + ALLOWED_PWA_ICON_SCALER_WIDTHS = [192, 512].freeze + attribute :title, default: '' attribute :description, default: '' attribute :pwa_name, default: '' @@ -61,6 +63,12 @@ class Appearance < ApplicationRecord end end + def pwa_icon_path_scaled(width) + return unless pwa_icon_path.present? + + pwa_icon_path + "?width=#{width}" + end + def logo_path logo_system_path(logo, 'logo') end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 7a606c0b417..21d17f11c50 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -355,7 +355,7 @@ module Ci scope :for_name, -> (name) do name_column = Ci::PipelineMetadata.arel_table[:name] - joins(:pipeline_metadata).where(name_column.lower.eq(name.downcase)) + joins(:pipeline_metadata).where(name_column.eq(name)) end scope :created_after, -> (time) { where(arel_table[:created_at].gt(time)) } scope :created_before_id, -> (id) { where(arel_table[:id].lt(id)) } diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb index 8fea0d6d993..1a0a65df6a3 100644 --- a/app/models/sent_notification.rb +++ b/app/models/sent_notification.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class SentNotification < ApplicationRecord + include IgnorableColumns + serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize belongs_to :project @@ -14,6 +16,8 @@ class SentNotification < ApplicationRecord validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true } validate :note_valid + ignore_column :id_convert_to_bigint, remove_with: '16.0', remove_after: '2023-05-22' + after_save :keep_around_commit, if: :for_commit? class << self diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index abb3616c58f..9568998e27d 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -36,7 +36,14 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy condition(:request_access_enabled) { @subject.request_access_enabled } condition(:create_projects_disabled, scope: :subject) do - @subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS + next true if @user.nil? + + visibility_evaluation_result = Gitlab::VisibilityLevelChecker + .new(user, Project.new(namespace_id: @subject.id)) + .level_restricted? + + @subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS || + visibility_evaluation_result.restricted? end condition(:developer_maintainer_access, scope: :subject) do diff --git a/app/uploaders/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb index 47de6fe0fbd..9b142dbe4b8 100644 --- a/app/uploaders/attachment_uploader.rb +++ b/app/uploaders/attachment_uploader.rb @@ -6,12 +6,6 @@ class AttachmentUploader < GitlabUploader prepend ObjectStorage::Extension::RecordsUploads include UploaderHelper - private - - def dynamic_segment - File.join(model.class.underscore, mounted_as.to_s, model.id.to_s) - end - def mounted_as # Geo fails to sync attachments on Note, and LegacyDiffNotes with missing mount_point. # @@ -22,4 +16,10 @@ class AttachmentUploader < GitlabUploader super end end + + private + + def dynamic_segment + File.join(model.class.underscore, mounted_as.to_s, model.id.to_s) + end end diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index 501660bba57..65501b27451 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -8,20 +8,28 @@ "orientation": "any", "background_color": "#fff", "theme_color": "<%= user_theme_primary_color %>", - "icons": [{ - "src": "<%= Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/-/pwa-icons/logo-192.png') %>", - "sizes": "192x192", - "type": "image/png" - }, + "icons": [ + <% widths = Appearance::ALLOWED_PWA_ICON_SCALER_WIDTHS %> + <% widths.each do |width| -%> { - "src": "<%= Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/-/pwa-icons/logo-512.png') %>", - "sizes": "512x512", + <% if source = appearance_pwa_icon_path_scaled(width) -%> + "src": "<%= source %>", + "sizes": "<%= width.to_s + "x" + width.to_s %>", "type": "image/png" - }, - { - "src": "<%= Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/-/pwa-icons/maskable-logo.png') %>", + <% else -%> + "error": "<%= "#{width} is not an allowed PWA scale" %>" + <% end -%> + } + <% unless width == widths.last -%> + , + <% end -%> + <% end -%> + <% unless current_appearance&.pwa_icon.present? -%> + ,{ + "src": "<%= appearance_maskable_logo %>", "sizes": "512x512", "type": "image/png", "purpose": "maskable" - }] + } + <% end -%>] } diff --git a/config/feature_flags/development/service_desk_html_to_text_email_handler.yml b/config/feature_flags/development/service_desk_html_to_text_email_handler.yml new file mode 100644 index 00000000000..733b2521c8d --- /dev/null +++ b/config/feature_flags/development/service_desk_html_to_text_email_handler.yml @@ -0,0 +1,8 @@ +--- +name: service_desk_html_to_text_email_handler +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/109811 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/372301 +milestone: '15.9' +type: development +group: group::respond +default_enabled: false diff --git a/db/migrate/20230130175512_initialize_conversion_of_sent_notifications_to_bigint.rb b/db/migrate/20230130175512_initialize_conversion_of_sent_notifications_to_bigint.rb new file mode 100644 index 00000000000..4e588ab2197 --- /dev/null +++ b/db/migrate/20230130175512_initialize_conversion_of_sent_notifications_to_bigint.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class InitializeConversionOfSentNotificationsToBigint < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + TABLE = :sent_notifications + COLUMNS = %i[id] + + def up + initialize_conversion_of_integer_to_bigint(TABLE, COLUMNS) + end + + def down + revert_initialize_conversion_of_integer_to_bigint(TABLE, COLUMNS) + end +end diff --git a/db/post_migrate/20230130202201_backfill_sent_notifications_for_bigint_conversion.rb b/db/post_migrate/20230130202201_backfill_sent_notifications_for_bigint_conversion.rb new file mode 100644 index 00000000000..2c8efed8dc2 --- /dev/null +++ b/db/post_migrate/20230130202201_backfill_sent_notifications_for_bigint_conversion.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class BackfillSentNotificationsForBigintConversion < Gitlab::Database::Migration[2.1] + restrict_gitlab_migration gitlab_schema: :gitlab_main + + TABLE = :sent_notifications + COLUMNS = %i[id] + + def up + backfill_conversion_of_integer_to_bigint(TABLE, COLUMNS) + end + + def down + revert_backfill_conversion_of_integer_to_bigint(TABLE, COLUMNS) + end +end diff --git a/db/post_migrate/20230203122609_change_pipeline_name_index.rb b/db/post_migrate/20230203122609_change_pipeline_name_index.rb new file mode 100644 index 00000000000..2f2fef82c9d --- /dev/null +++ b/db/post_migrate/20230203122609_change_pipeline_name_index.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class ChangePipelineNameIndex < Gitlab::Database::Migration[2.1] + disable_ddl_transaction! + + OLD_INDEX_NAME = 'index_pipeline_metadata_on_pipeline_id_name_lower_text_pattern' + NEW_INDEX_NAME = 'index_pipeline_metadata_on_pipeline_id_name_text_pattern' + + def up + add_concurrent_index :ci_pipeline_metadata, 'pipeline_id, name text_pattern_ops', name: NEW_INDEX_NAME + + remove_concurrent_index_by_name :ci_pipeline_metadata, OLD_INDEX_NAME + end + + def down + add_concurrent_index :ci_pipeline_metadata, 'pipeline_id, lower(name) text_pattern_ops', name: OLD_INDEX_NAME + + remove_concurrent_index_by_name :ci_pipeline_metadata, NEW_INDEX_NAME + end +end diff --git a/db/schema_migrations/20230130175512 b/db/schema_migrations/20230130175512 new file mode 100644 index 00000000000..77e9d27b8ef --- /dev/null +++ b/db/schema_migrations/20230130175512 @@ -0,0 +1 @@ +cfda498c61c30312398c325b04944109128ea5363e4096307cb2f59ee850f8a6
\ No newline at end of file diff --git a/db/schema_migrations/20230130202201 b/db/schema_migrations/20230130202201 new file mode 100644 index 00000000000..625a80908cd --- /dev/null +++ b/db/schema_migrations/20230130202201 @@ -0,0 +1 @@ +d2a0747a84d465cd7e4e4ca48539442ee37da00691f14bac580f225aa055be36
\ No newline at end of file diff --git a/db/schema_migrations/20230203122609 b/db/schema_migrations/20230203122609 new file mode 100644 index 00000000000..a1549773f34 --- /dev/null +++ b/db/schema_migrations/20230203122609 @@ -0,0 +1 @@ +7a1f0770999871ba021a2d6f0c036f4dbe19143abacd7140c76f6f576b89f002
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 50cc77078c9..3827afa0a1a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -234,6 +234,15 @@ BEGIN END; $$; +CREATE FUNCTION trigger_7f4fcd5aa322() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + NEW."id_convert_to_bigint" := NEW."id"; + RETURN NEW; +END; +$$; + CREATE FUNCTION trigger_c5a5f48f12b0() RETURNS trigger LANGUAGE plpgsql AS $$ @@ -21824,7 +21833,8 @@ CREATE TABLE sent_notifications ( line_code character varying, note_type character varying, "position" text, - in_reply_to_discussion_id character varying + in_reply_to_discussion_id character varying, + id_convert_to_bigint bigint DEFAULT 0 NOT NULL ); CREATE SEQUENCE sent_notifications_id_seq @@ -30879,7 +30889,7 @@ CREATE UNIQUE INDEX index_personal_access_tokens_on_token_digest ON personal_acc CREATE INDEX index_personal_access_tokens_on_user_id ON personal_access_tokens USING btree (user_id); -CREATE INDEX index_pipeline_metadata_on_pipeline_id_name_lower_text_pattern ON ci_pipeline_metadata USING btree (pipeline_id, lower(name) text_pattern_ops); +CREATE INDEX index_pipeline_metadata_on_pipeline_id_name_text_pattern ON ci_pipeline_metadata USING btree (pipeline_id, name text_pattern_ops); CREATE UNIQUE INDEX index_plan_limits_on_plan_id ON plan_limits USING btree (plan_id); @@ -33471,6 +33481,8 @@ CREATE TRIGGER projects_loose_fk_trigger AFTER DELETE ON projects REFERENCING OL CREATE TRIGGER trigger_1a857e8db6cd BEFORE INSERT OR UPDATE ON vulnerability_occurrences FOR EACH ROW EXECUTE FUNCTION trigger_1a857e8db6cd(); +CREATE TRIGGER trigger_7f4fcd5aa322 BEFORE INSERT OR UPDATE ON sent_notifications FOR EACH ROW EXECUTE FUNCTION trigger_7f4fcd5aa322(); + CREATE TRIGGER trigger_c5a5f48f12b0 BEFORE INSERT OR UPDATE ON epic_user_mentions FOR EACH ROW EXECUTE FUNCTION trigger_c5a5f48f12b0(); CREATE TRIGGER trigger_3207b8d0d6f3 BEFORE INSERT OR UPDATE ON ci_build_needs FOR EACH ROW EXECUTE FUNCTION trigger_3207b8d0d6f3(); diff --git a/doc/ci/yaml/workflow.md b/doc/ci/yaml/workflow.md index 3d6314c8e03..82144e55216 100644 --- a/doc/ci/yaml/workflow.md +++ b/doc/ci/yaml/workflow.md @@ -29,7 +29,7 @@ See the [common `if` clauses for `rules`](../jobs/job_control.md#common-if-claus In the following example: - Pipelines run for all `push` events (changes to branches and new tags). -- Pipelines for push events with `-draft` in the commit message don't run, because +- Pipelines for push events with commit messages that end with `-draft` don't run, because they are set to `when: never`. - Pipelines for schedules or merge requests don't run either, because no rules evaluate to true for them. diff --git a/doc/development/pipelines/internals.md b/doc/development/pipelines/internals.md index 9ff4e5a35ec..c6f86811ac8 100644 --- a/doc/development/pipelines/internals.md +++ b/doc/development/pipelines/internals.md @@ -275,3 +275,94 @@ qa:selectors-as-if-foss: extends: - .qa:rules:as-if-foss ``` + +### Extend the `.fast-no-clone-job` job + +Downloading the branch for the canonical project takes between 20 and 30 seconds. + +Some jobs only need a limited number of files, which we can download via the `raw` API, e.g. `https://gitlab.com/gitlab-org/gitlab/raw/master/VERSION`. + +You can skip a job `git clone`/`git fetch` by adding the following pattern to a job: + +```yaml + # Scenario 1: no before_script is defined in the job + # + # You can just extend the .fast-no-clone-job + extends: + - .fast-no-clone-job + variables: + FILES_TO_DOWNLOAD: > + scripts/rspec_helpers.sh + scripts/slack +``` + +```yaml + # Scenario 2: a before_script block is already defined in the job + # + # You will have to include the .fast-no-clone-job via a !reference as well + extends: + - .fast-no-clone-job + variables: + FILES_TO_DOWNLOAD: > + scripts/rspec_helpers.sh + scripts/slack + before_script: + - !reference [".fast-no-clone-job", before_script] + - [...] +``` + +- The job will set the `GIT_STRATEGY` to `none`. +- The files are downloaded from: + - The current project, on the current `CI_COMMIT_SHA` if the project is **public** + - The canonical project, on the `master` branch if the project **isn't public** + +Below is an example on how to convert a job using this pattern: + +```yaml +# Before +my-job: + image: ruby + stage: prepare + script: # This job requires two files to function + - source ./scripts/rspec_helpers.sh + - source ./scripts/slack + - echo "The files were successfully sourced!" + +# After +my-job: + extends: + - .fast-no-clone-job + image: ruby + stage: prepare + variables: + FILES_TO_DOWNLOAD: > + scripts/rspec_helpers.sh + scripts/slack + script: # This job requires two files to function + - source ./scripts/rspec_helpers.sh + - source ./scripts/slack + - echo "The files were successfully sourced!" +``` + +#### Caveats + +- This pattern does not work if a script relies on `git` to access the repository, because we don't have the repository without cloning or fetching. +- Given that we do not require setting up any API tokens to make this work, **we cannot download the files from any private repository**. + In that case, we will attempt to download the files from [https://gitlab.com/gitlab-org/gitlab](https://gitlab.com/gitlab-org/gitlab), which is a public repository. + Changes made in the private repository will not take effects for the files it's downloading for this reason, + and older builds can break if the files it's downloading are changed in a non-backwards compatible way. + Do not use this pattern for jobs which might block pipelines in private repositories like `security` or `dev`. + +#### Where is this pattern used? + +- For now, we use this pattern for the following jobs, and those do not block private repositories: + - `review-build-cng-env` for: + - `scripts/trigger-build.rb` + - `review-deploy` for: + - `scripts/review_apps/review-apps.sh` + - `scripts/review_apps/seed-dast-test-data.sh` + - `GITLAB_SHELL_VERSION` + - `GITALY_SERVER_VERSION` + - `GITLAB_WORKHORSE_VERSION` + +Additionally, `scripts/utils.sh` will always be downloaded from the API when this pattern is used (this file contains the code for `.fast-no-clone-job`). diff --git a/doc/integration/jira/connect-app.md b/doc/integration/jira/connect-app.md index bca74d16f91..a4757d5fa80 100644 --- a/doc/integration/jira/connect-app.md +++ b/doc/integration/jira/connect-app.md @@ -91,10 +91,8 @@ To create an OAuth application: 1. In **Redirect URI**: - If you're installing the app from the official marketplace listing, enter `https://gitlab.com/-/jira_connect/oauth_callbacks`. - If you're installing the app manually, enter `<instance_url>/-/jira_connect/oauth_callbacks` and replace `<instance_url>` with the URL of your instance. -1. Clear the **Confidential** checkbox. -<!-- markdownlint-disable MD044 --> -1. In **Scopes**, select the **api** checkbox only. -<!-- markdownlint-enable MD044 --> +1. Clear the **Trusted** and **Confidential** checkboxes. +1. In **Scopes**, select the `api` checkbox only. 1. Select **Save application**. 1. Copy the **Application ID** value. 1. On the left sidebar, select **Settings > General** (`/admin/application_settings/general`). diff --git a/doc/user/group/settings/group_access_tokens.md b/doc/user/group/settings/group_access_tokens.md index d7a3eee6524..cd50c209b0d 100644 --- a/doc/user/group/settings/group_access_tokens.md +++ b/doc/user/group/settings/group_access_tokens.md @@ -163,7 +163,9 @@ Even when creation is disabled, you can still use and revoke existing group acce ## Bot users for groups -Each time you create a group access token, a bot user is created and added to the group. These bot users are similar to +Bot users for groups are [GitLab-created service accounts](../../../subscriptions/self_managed/index.md#billable-users). +Each time you create a group access token, a bot user is created and added to the group. +These bot users are similar to [bot users for projects](../../project/settings/project_access_tokens.md#bot-users-for-projects), except they are added to groups instead of projects. Bot users for groups: @@ -174,5 +176,3 @@ to groups instead of projects. Bot users for groups: - Have an email set to `group{group_id}_bot@noreply.{Gitlab.config.gitlab.host}`. For example, `group123_bot@noreply.example.com`. All other properties are similar to [bot users for projects](../../project/settings/project_access_tokens.md#bot-users-for-projects). - -For more information, see [Bot users for projects](../../project/settings/project_access_tokens.md#bot-users-for-projects). diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md index 732450f5443..b707f979079 100644 --- a/doc/user/project/service_desk.md +++ b/doc/user/project/service_desk.md @@ -504,6 +504,23 @@ On GitLab.com, this feature is not available. If a comment contains any attachments and their total size is less than or equal to 10 MB, these attachments are sent as part of the email. In other cases, the email contains links to the attachments. +#### Special HTML formatting in HTML emails + +<!-- When the feature flag is removed, delete this topic and add as a line in version history under one of the topics above this one.--> + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/372301) in GitLab 15.9 [with a flag](../../administration/feature_flags.md) named `service_desk_html_to_text_email_handler`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available per project or for your entire instance, ask an administrator to [enable the feature flag](../../administration/feature_flags.md) named `service_desk_html_to_text_email_handler`. +On GitLab.com, this feature is not available. + +When this feature is enabled, HTML emails correctly show additional HTML formatting, such as: + +- Tables +- Blockquotes +- Images +- Collapsible sections + #### Privacy considerations > [Changed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/108901) the minimum required role to view the creator's and participant's email in GitLab 15.9. diff --git a/lib/api/draft_notes.rb b/lib/api/draft_notes.rb index 0cf0f43a839..aef42fb125e 100644 --- a/lib/api/draft_notes.rb +++ b/lib/api/draft_notes.rb @@ -20,6 +20,10 @@ module API def get_draft_note(params:) load_draft_notes(params: params).find(params[:draft_note_id]) end + + def delete_draft_note(draft_note) + ::DraftNotes::DestroyService.new(user_project, current_user).execute(draft_note) + end end resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do @@ -60,6 +64,32 @@ module API not_found!("Draft Note") end end + + desc "Delete a draft note" do + success Entities::DraftNote + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not found' } + ] + end + params do + requires :id, type: String, desc: "The ID of a project" + requires :merge_request_iid, type: Integer, desc: "The ID of a merge request" + requires :draft_note_id, type: Integer, desc: "The ID of a draft note" + end + delete( + ":id/merge_requests/:merge_request_iid/draft_notes/:draft_note_id", + feature_category: :code_review_workflow) do + draft_note = get_draft_note(params: params) + + if draft_note + delete_draft_note(draft_note) + status 204 + body false + else + not_found!("Draft Note") + end + end end end end diff --git a/lib/gitlab/email/html_parser.rb b/lib/gitlab/email/html_parser.rb index 65117ac0141..10dbedbb464 100644 --- a/lib/gitlab/email/html_parser.rb +++ b/lib/gitlab/email/html_parser.rb @@ -34,7 +34,11 @@ module Gitlab end def filtered_text - @filtered_text ||= Html2Text.convert(filtered_html) + @filtered_text ||= if Feature.enabled?(:service_desk_html_to_text_email_handler) + ::Gitlab::Email::HtmlToMarkdownParser.convert(filtered_html) + else + Html2Text.convert(filtered_html) + end end end end diff --git a/lib/gitlab/email/html_to_markdown_parser.rb b/lib/gitlab/email/html_to_markdown_parser.rb new file mode 100644 index 00000000000..42dd012308b --- /dev/null +++ b/lib/gitlab/email/html_to_markdown_parser.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'nokogiri' + +module Gitlab + module Email + class HtmlToMarkdownParser < Html2Text + ADDITIONAL_TAGS = %w[em strong img details].freeze + IMG_ATTRS = %w[alt src].freeze + + def self.convert(html) + html = fix_newlines(replace_entities(html)) + doc = Nokogiri::HTML(html) + + HtmlToMarkdownParser.new(doc).convert + end + + def iterate_over(node) + return super unless ADDITIONAL_TAGS.include?(node.name) + + if node.name == 'img' + node.keys.each { |key| node.remove_attribute(key) unless IMG_ATTRS.include?(key) } # rubocop:disable Style/HashEachMethods + end + + Kramdown::Document.new(node.to_html, input: 'html').to_commonmark + end + end + end +end diff --git a/lib/gitlab/import_export/project/relation_factory.rb b/lib/gitlab/import_export/project/relation_factory.rb index 568315930d8..4134c428500 100644 --- a/lib/gitlab/import_export/project/relation_factory.rb +++ b/lib/gitlab/import_export/project/relation_factory.rb @@ -165,9 +165,21 @@ module Gitlab end def setup_protected_branch_access_level + return if root_group_owner? + return if @relation_hash['access_level'] == Gitlab::Access::NO_ACCESS + return if @relation_hash['access_level'] == Gitlab::Access::MAINTAINER + @relation_hash['access_level'] = Gitlab::Access::MAINTAINER end + def root_group_owner? + root_ancestor = @importable.root_ancestor + + return false unless root_ancestor.is_a?(::Group) + + root_ancestor.max_member_access_for_user(@user) == Gitlab::Access::OWNER + end + def compute_relative_position return unless max_relative_position diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a62a3255093..570f6e10598 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -44671,6 +44671,11 @@ msgstr "" msgid "Topic was successfully updated." msgstr "" +msgid "TopicSelect|%d topic found" +msgid_plural "TopicSelect|%d topics found" +msgstr[0] "" +msgstr[1] "" + msgid "TopicSelect|No matching results" msgstr "" diff --git a/scripts/build_qa_image b/scripts/build_qa_image index 045de2835a4..9c401718336 100755 --- a/scripts/build_qa_image +++ b/scripts/build_qa_image @@ -16,12 +16,16 @@ function latest_stable_tag() { git -c versionsort.prereleaseSuffix=rc tag --sort=-v:refname | awk '!/rc/' | head -1 } -QA_IMAGE_NAME="gitlab-ee-qa" -QA_BUILD_TARGET="ee" - if [[ "${CI_PROJECT_NAME}" == "gitlabhq" || "${CI_PROJECT_NAME}" == "gitlab-foss" || "${FOSS_ONLY}" == "1" ]]; then QA_IMAGE_NAME="gitlab-ce-qa" QA_BUILD_TARGET="foss" +# Build QA Image for JH project +elif [[ "${CI_PROJECT_PATH}" =~ ^gitlab-(jh|cn)\/.*$ || "${CI_PROJECT_NAME}" =~ ^gitlab-jh ]]; then + QA_IMAGE_NAME="gitlab-jh-qa" + QA_BUILD_TARGET="jhqa" +else + QA_IMAGE_NAME="gitlab-ee-qa" + QA_BUILD_TARGET="ee" fi # Tag with commit SHA by default diff --git a/scripts/utils.sh b/scripts/utils.sh index 55005d0abff..5073e89330e 100644 --- a/scripts/utils.sh +++ b/scripts/utils.sh @@ -287,3 +287,20 @@ function setup_gcloud() { gcloud auth activate-service-account --key-file="${REVIEW_APPS_GCP_CREDENTIALS}" gcloud config set project "${REVIEW_APPS_GCP_PROJECT}" } + +function download_files() { + # If public fork, just download the files directly from there. Otherwise, get files from canonical. + if [[ "${CI_PROJECT_VISIBILITY}" == "public" ]]; then + local url="${CI_PROJECT_URL}/raw/${CI_COMMIT_SHA}" + else + local url="https://gitlab.com/gitlab-org/gitlab/raw/master" + fi + + # Loop through all files and download them one by one sequentially. + for file in "$@"; do + local file_url="${url}/${file}" + + echo "Downloading file: ${file_url}" + curl "${file_url}" --create-dirs --output "${file}" + done +} diff --git a/spec/controllers/concerns/send_file_upload_spec.rb b/spec/controllers/concerns/send_file_upload_spec.rb index 0b24387483b..6acbff6e745 100644 --- a/spec/controllers/concerns/send_file_upload_spec.rb +++ b/spec/controllers/concerns/send_file_upload_spec.rb @@ -54,17 +54,18 @@ RSpec.describe SendFileUpload do FileUtils.rm_f(temp_file) end - shared_examples 'handles image resize requests' do + shared_examples 'handles image resize requests' do |mount| let(:headers) { double } let(:image_requester) { build(:user) } let(:image_owner) { build(:user) } + let(:width) { mount == :pwa_icon ? 192 : 64 } let(:params) do { attachment: 'avatar.png' } end before do allow(uploader).to receive(:image_safe_for_scaling?).and_return(true) - allow(uploader).to receive(:mounted_as).and_return(:avatar) + allow(uploader).to receive(:mounted_as).and_return(mount) allow(controller).to receive(:headers).and_return(headers) # both of these are valid cases, depending on whether we are dealing with @@ -99,11 +100,11 @@ RSpec.describe SendFileUpload do context 'with valid width parameter' do it 'renders OK with workhorse command header' do expect(controller).not_to receive(:send_file) - expect(controller).to receive(:params).at_least(:once).and_return(width: '64') + expect(controller).to receive(:params).at_least(:once).and_return(width: width.to_s) expect(controller).to receive(:head).with(:ok) expect(Gitlab::Workhorse).to receive(:send_scaled_image) - .with(a_string_matching('^(/.+|https://.+)'), 64, 'image/png') + .with(a_string_matching('^(/.+|https://.+)'), width, 'image/png') .and_return([Gitlab::Workhorse::SEND_DATA_HEADER, "send-scaled-img:faux"]) expect(headers).to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, "send-scaled-img:faux") @@ -168,7 +169,8 @@ RSpec.describe SendFileUpload do subject end - it_behaves_like 'handles image resize requests' + it_behaves_like 'handles image resize requests', :avatar + it_behaves_like 'handles image resize requests', :pwa_icon end context 'with inline image' do @@ -273,7 +275,8 @@ RSpec.describe SendFileUpload do end end - it_behaves_like 'handles image resize requests' + it_behaves_like 'handles image resize requests', :avatar + it_behaves_like 'handles image resize requests', :pwa_icon end context 'when CDN-enabled remote file is used' do diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb index 8806d1c2219..e3ec28f9c65 100644 --- a/spec/features/groups_spec.rb +++ b/spec/features/groups_spec.rb @@ -510,6 +510,33 @@ RSpec.describe 'Group', feature_category: :subgroups do end end end + + context 'when in a private group' do + before do + group.update!( + visibility_level: Gitlab::VisibilityLevel::PRIVATE, + project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS + ) + end + + context 'when visibility levels have been restricted to private only by an administrator' do + before do + stub_application_setting( + restricted_visibility_levels: [ + Gitlab::VisibilityLevel::PRIVATE + ] + ) + end + + it 'does not display the "New project" button' do + visit group_path(group) + + page.within '[data-testid="group-buttons"]' do + expect(page).not_to have_link('New project') + end + end + end + end end def remove_with_confirm(button_text, confirm_with) diff --git a/spec/features/merge_request/user_sees_discussions_navigation_spec.rb b/spec/features/merge_request/user_sees_discussions_navigation_spec.rb index 9d3046a9a72..4beddb8c8bc 100644 --- a/spec/features/merge_request/user_sees_discussions_navigation_spec.rb +++ b/spec/features/merge_request/user_sees_discussions_navigation_spec.rb @@ -92,7 +92,8 @@ RSpec.describe 'Merge request > User sees discussions navigation', :js, feature_ page.execute_script("window.scrollTo(0,0)") end - it 'excludes resolved threads during navigation' do + it 'excludes resolved threads during navigation', + quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/383687' do goto_next_thread goto_next_thread goto_next_thread diff --git a/spec/finders/ci/pipelines_finder_spec.rb b/spec/finders/ci/pipelines_finder_spec.rb index 9ce3becf013..8773fbccdfc 100644 --- a/spec/finders/ci/pipelines_finder_spec.rb +++ b/spec/finders/ci/pipelines_finder_spec.rb @@ -246,9 +246,9 @@ RSpec.describe Ci::PipelinesFinder do let_it_be(:pipeline) { create(:ci_pipeline, project: project, name: 'Build pipeline') } let_it_be(:pipeline_other) { create(:ci_pipeline, project: project, name: 'Some other pipeline') } - let(:params) { { name: 'build Pipeline' } } + let(:params) { { name: 'Build pipeline' } } - it 'performs case insensitive compare' do + it 'performs exact compare' do is_expected.to contain_exactly(pipeline) end diff --git a/spec/fixtures/emails/html_only.eml b/spec/fixtures/emails/html_only.eml new file mode 100644 index 00000000000..22a1a431771 --- /dev/null +++ b/spec/fixtures/emails/html_only.eml @@ -0,0 +1,45 @@ +Delivered-To: reply@discourse.org +Return-Path: <walter.white@googlemail.com> +MIME-Version: 1.0 +In-Reply-To: <topic/22638/86406@meta.discourse.org> +References: <topic/22638@meta.discourse.org> + <topic/22638/86406@meta.discourse.org> +Date: Fri, 28 Nov 2014 12:36:49 -0800 +Subject: Re: [Discourse Meta] [Lounge] Testing default email replies +From: Walter White <walter.white@googlemail.com> +To: Discourse Meta <reply@discourse.org> +Content-Type: multipart/related; boundary=001a11c2e04e6544f30508f138ba + +--001a11c2e04e6544f30508f138ba +Content-Type: text/html; charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +<div dir=3D"ltr"><div>### This is a reply from standard GMail in Google Chr= +ome.</div><div><br></div><div>The quick brown fox jumps over the lazy dog. = +The quick brown fox jumps over the lazy dog. The quick brown fox jumps over= + the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown= + fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. = +The quick brown fox jumps over the lazy dog. The quick brown fox jumps over= + the lazy dog.=C2=A0</div><div><br></div><div>Here's some **bold** text= +, <strong>strong</strong> text and <em>italic</em> in Markdown.</div><div><= +br></div><div>Here's a link <a href=3D"http://example.com">http://examp= +le.com</a></div></div><div class=3D"gmail_extra"><br>Here's an img <i= +mg class="header__logoSize_110px" src="http://img.png" hspace="0" vspac= +e="0" border="0" style="display:block; width:138px; max-width:138px;" width= +="138" alt="Miro"><details><summary>One</summary> Some details</details> +<details><summary>Two</summary> Some details</details></div> + +<table style=3D"margin-bottom:25px" cellspacing=3D"0" cellpadding=3D"0" bor= +der=3D"0"> + <tbody> + <tr> + <td style=3D"padding-top:5px" colspan=3D"2"> + <p style=3D"margin-top:0;border:0">Test reply.</p> + <p style=3D"margin-top:0;border:0">First paragraph.</p> + <p style=3D"margin-top:0;border:0">Second paragraph.</p> + </td> + </tr> + </tbody> +</table> + +--001a11c2e04e6544f30508f138ba-- diff --git a/spec/fixtures/lib/gitlab/email/basic.html b/spec/fixtures/lib/gitlab/email/basic.html new file mode 100644 index 00000000000..807b23c46e3 --- /dev/null +++ b/spec/fixtures/lib/gitlab/email/basic.html @@ -0,0 +1,72 @@ +<html> + <title>Ignored Title</title> + <body> + <h1>Hello, World!</h1> + + This is some e-mail content. + Even though it has whitespace and newlines, the e-mail converter + will handle it correctly. + + <p><em>Even</em> mismatched tags.</p> + + <div>A div</div> + <div>Another div</div> + <div>A div<div><strong>within</strong> a div</div></div> + + <p>Another line<br />Yet another line</p> + + <a href="http://foo.com">A link</a> + + <p><details><summary>One</summary>Some details</details></p> + + <p><details><summary>Two</summary>Some details</details></p> + + <img class="header__logoSize_110px" src="http://img.png" hspace="0" vspace="0" border="0" + style="display:block; width:138px; max-width:138px;" width="138" alt="Miro"> + + <table> + <thead> + <tr> + <th>Col A</th> + <th>Col B</th> + </tr> + </thead> + <tbody> + <tr> + <td> + Data A1 + </td> + <td> + Data B1 + </td> + </tr> + <tr> + <td> + Data A2 + </td> + <td> + Data B2 + </td> + </tr> + <tr> + <td> + Data A3 + </td> + <td> + Data B4 + </td> + </tr> + </tbody> + <tfoot> + <tr> + <td> + Total A + </td> + <td> + Total B + </td> + </tr> + </tfoot> + </table> + </body> +</html> diff --git a/spec/frontend/admin/topics/components/topic_select_spec.js b/spec/frontend/admin/topics/components/topic_select_spec.js index f61af6203f0..738cbd88c4c 100644 --- a/spec/frontend/admin/topics/components/topic_select_spec.js +++ b/spec/frontend/admin/topics/components/topic_select_spec.js @@ -1,39 +1,66 @@ -import { GlAvatarLabeled, GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAvatarLabeled, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import TopicSelect from '~/admin/topics/components/topic_select.vue'; +import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; const mockTopics = [ - { id: 1, name: 'topic1', title: 'Topic 1', avatarUrl: 'avatar.com/topic1.png' }, - { id: 2, name: 'GitLab', title: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' }, + { + id: 'gid://gitlab/Projects::Topic/6', + name: 'topic1', + title: 'Topic 1', + avatarUrl: 'avatar.com/topic1.png', + __typename: 'Topic', + }, + { + id: 'gid://gitlab/Projects::Topic/5', + name: 'gitlab', + title: 'GitLab', + avatarUrl: 'avatar.com/GitLab.png', + __typename: 'Topic', + }, ]; +const mockTopicsQueryResponse = { + data: { + topics: { + nodes: mockTopics, + __typename: 'TopicConnection', + }, + }, +}; + describe('TopicSelect', () => { let wrapper; + const mockSearchTopicsSuccess = jest.fn().mockResolvedValue(mockTopicsQueryResponse); + + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + function createMockApolloProvider({ mockSearchTopicsQuery = mockSearchTopicsSuccess } = {}) { + Vue.use(VueApollo); + + return createMockApollo([[searchProjectTopics, mockSearchTopicsQuery]]); + } - function createComponent(props = {}) { - wrapper = shallowMount(TopicSelect, { + function createComponent({ props = {}, mockApollo } = {}) { + wrapper = mount(TopicSelect, { + apolloProvider: mockApollo || createMockApolloProvider(), propsData: props, data() { return { topics: mockTopics, - search: '', }; }, - mocks: { - $apollo: { - queries: { - topics: { loading: false }, - }, - }, - }, }); } afterEach(() => { wrapper.destroy(); + jest.clearAllMocks(); }); it('mounts', () => { @@ -57,17 +84,27 @@ describe('TopicSelect', () => { it('renders default text if no selected topic', () => { createComponent(); - expect(findDropdown().props('text')).toBe('Select a topic'); + expect(findListbox().props('toggleText')).toBe('Select a topic'); }); it('renders selected topic', () => { - createComponent({ selectedTopic: mockTopics[0] }); + const mockTopic = mockTopics[0]; - expect(findDropdown().props('text')).toBe('topic1'); + createComponent({ + props: { + selectedTopic: mockTopic, + }, + }); + + expect(findListbox().props('toggleText')).toBe(mockTopic.name); }); it('renders label', () => { - createComponent({ labelText: 'my label' }); + createComponent({ + props: { + labelText: 'my label', + }, + }); expect(wrapper.find('label').text()).toBe('my label'); }); @@ -75,17 +112,52 @@ describe('TopicSelect', () => { it('renders dropdown items', () => { createComponent(); - const dropdownItems = findAllDropdownItems(); + const listboxItems = findAllListboxItems(); + + expect(listboxItems.at(0).findComponent(GlAvatarLabeled).props('label')).toBe('Topic 1'); + expect(listboxItems.at(1).findComponent(GlAvatarLabeled).props('label')).toBe('GitLab'); + }); + + it('dropdown `toggledAriaLabelledBy` prop is not set if `labelText` prop is null', () => { + createComponent(); - expect(dropdownItems.at(0).findComponent(GlAvatarLabeled).props('label')).toBe('Topic 1'); - expect(dropdownItems.at(1).findComponent(GlAvatarLabeled).props('label')).toBe('GitLab'); + expect(findListbox().props('toggle-aria-labelled-by')).toBe(undefined); }); - it('emits `click` event when topic selected', () => { + it('emits `click` event when topic selected', async () => { createComponent(); - findAllDropdownItems().at(0).vm.$emit('click'); + await findAllListboxItems().at(0).trigger('click'); expect(wrapper.emitted('click')).toEqual([[mockTopics[0]]]); }); + + describe('when searching a topic', () => { + const searchTopic = (searchTerm) => findListbox().vm.$emit('search', searchTerm); + const mockSearchTerm = 'gitl'; + + it('toggles loading state', async () => { + createComponent(); + jest.runOnlyPendingTimers(); + + await searchTopic(mockSearchTerm); + + expect(findListbox().props('searching')).toBe(true); + + await waitForPromises(); + + expect(findListbox().props('searching')).toBe(false); + }); + + it('fetches topics matching search string', async () => { + createComponent(); + + await searchTopic(mockSearchTerm); + jest.runOnlyPendingTimers(); + + expect(mockSearchTopicsSuccess).toHaveBeenCalledWith({ + search: mockSearchTerm, + }); + }); + }); }); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js index 67d0fbdd9d1..ffcfd1d9f78 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -110,17 +110,22 @@ describe('WikiForm', () => { it('displays markdown editor', () => { createWrapper({ persisted: true }); - expect(findMarkdownEditor().props()).toEqual( + const markdownEditor = findMarkdownEditor(); + + expect(markdownEditor.props()).toEqual( expect.objectContaining({ value: pageInfoPersisted.content, renderMarkdownPath: pageInfoPersisted.markdownPreviewPath, markdownDocsPath: pageInfoPersisted.markdownHelpPath, uploadsPath: pageInfoPersisted.uploadsPath, autofocus: pageInfoPersisted.persisted, - formFieldId: 'wiki_content', - formFieldName: 'wiki[content]', }), ); + + expect(markdownEditor.props('formFieldProps')).toMatchObject({ + id: 'wiki_content', + name: 'wiki[content]', + }); }); it.each` diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 518539d97ba..2523b901506 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -449,6 +449,26 @@ describe('Pipelines', () => { `${window.location.pathname}?page=2&scope=all`, ); }); + + it('should reset page to 1 when filtering pipelines', () => { + expect(window.history.pushState).toHaveBeenCalledTimes(1); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=2&scope=all`, + ); + + findFilteredSearch().vm.$emit('submit', [ + { type: 'status', value: { data: 'success', operator: '=' } }, + ]); + + expect(window.history.pushState).toHaveBeenCalledTimes(2); + expect(window.history.pushState).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + `${window.location.pathname}?page=1&scope=all&status=success`, + ); + }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index e3df2cde1c1..12eda284aea 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -36,10 +36,12 @@ describe('vue_shared/component/markdown/markdown_editor', () => { quickActionsDocsPath, enableAutocomplete, enablePreview, - formFieldId, - formFieldName, - formFieldPlaceholder, - formFieldAriaLabel, + formFieldProps: { + id: formFieldId, + name: formFieldName, + placeholder: formFieldPlaceholder, + 'aria-label': formFieldAriaLabel, + }, ...propsData, }, stubs: { @@ -95,6 +97,12 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(findTextarea().element.value).toBe(value); }); + it('fails to render if textarea id and name is not passed', () => { + expect(() => { + buildWrapper({ propsData: { formFieldProps: {} } }); + }).toThrow('Invalid prop: custom validator check failed for prop "formFieldProps"'); + }); + it(`emits ${EDITING_MODE_CONTENT_EDITOR} event when enableContentEditor emitted from markdown editor`, async () => { buildWrapper(); diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index 083bb5bc4a4..0b6ab5c3290 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -85,7 +85,7 @@ describe('WorkItemLabels component', () => { it('focuses token selector on token selector input event', async () => { createComponent(); findTokenSelector().vm.$emit('input', [mockLabels[0]]); - await nextTick(); + await waitForPromises(); expect(findEmptyState().exists()).toBe(false); expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); @@ -189,6 +189,23 @@ describe('WorkItemLabels component', () => { ); }); + it('adds new labels to the end', async () => { + const response = workItemResponseFactory({ labels: [mockLabels[1]] }); + const workItemQueryHandler = jest.fn().mockResolvedValue(response); + createComponent({ + workItemQueryHandler, + updateWorkItemMutationHandler: successUpdateWorkItemMutationHandler, + }); + await waitForPromises(); + + findTokenSelector().vm.$emit('input', [mockLabels[0]]); + await waitForPromises(); + + const labels = findTokenSelector().props('selectedTokens'); + expect(labels[0]).toMatchObject(mockLabels[1]); + expect(labels[1]).toMatchObject(mockLabels[0]); + }); + describe('when clicking outside the token selector', () => { it('calls a mutation with correct variables', () => { createComponent(); @@ -205,9 +222,7 @@ describe('WorkItemLabels component', () => { }); it('emits an error and resets labels if mutation was rejected', async () => { - const workItemQueryHandler = jest.fn().mockResolvedValue(workItemResponseFactory()); - - createComponent({ updateWorkItemMutationHandler: errorHandler, workItemQueryHandler }); + createComponent({ updateWorkItemMutationHandler: errorHandler }); await waitForPromises(); @@ -224,6 +239,23 @@ describe('WorkItemLabels component', () => { expect(updatedLabels).toEqual(initialLabels); }); + it('does not make server request if no labels added or removed', async () => { + const updateWorkItemMutationHandler = jest + .fn() + .mockResolvedValue(updateWorkItemMutationResponse); + + createComponent({ updateWorkItemMutationHandler }); + + await waitForPromises(); + + findTokenSelector().vm.$emit('input', []); + findTokenSelector().vm.$emit('blur', new FocusEvent({ relatedTarget: null })); + + await waitForPromises(); + + expect(updateWorkItemMutationHandler).not.toHaveBeenCalled(); + }); + it('has a subscription', async () => { createComponent(); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 5b331c016a9..d6b2b5a1981 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -215,6 +215,14 @@ export const updateWorkItemMutationResponse = { nodes: [mockAssignees[0]], }, }, + { + __typename: 'WorkItemWidgetLabels', + type: 'LABELS', + allowsScopedLabels: false, + labels: { + nodes: mockLabels, + }, + }, ], }, }, @@ -279,7 +287,6 @@ export const workItemResponseFactory = ({ allowsMultipleAssignees = true, assigneesWidgetPresent = true, datesWidgetPresent = true, - labelsWidgetPresent = true, weightWidgetPresent = true, progressWidgetPresent = true, milestoneWidgetPresent = true, @@ -288,6 +295,8 @@ export const workItemResponseFactory = ({ notesWidgetPresent = true, confidential = false, canInviteMembers = false, + labelsWidgetPresent = true, + labels = mockLabels, allowsScopedLabels = false, lastEditedAt = null, lastEditedBy = null, @@ -350,7 +359,7 @@ export const workItemResponseFactory = ({ type: 'LABELS', allowsScopedLabels, labels: { - nodes: mockLabels, + nodes: labels, }, } : { type: 'MOCK TYPE' }, diff --git a/spec/helpers/appearances_helper_spec.rb b/spec/helpers/appearances_helper_spec.rb index 3c698fb2d41..2b0192d24b3 100644 --- a/spec/helpers/appearances_helper_spec.rb +++ b/spec/helpers/appearances_helper_spec.rb @@ -10,6 +10,51 @@ RSpec.describe AppearancesHelper do allow(helper).to receive(:current_user).and_return(user) end + describe 'pwa icon scaled' do + before do + stub_config_setting(relative_url_root: '/relative_root') + end + + shared_examples 'gets icon path' do |width| + let!(:width) { width } + + it 'returns path of icon' do + expect(helper.appearance_pwa_icon_path_scaled(width)).to match(result) + end + end + + context 'with custom icon' do + let!(:appearance) { create(:appearance, :with_pwa_icon) } + let!(:result) { "/relative_root/uploads/-/system/appearance/pwa_icon/#{appearance.id}/dk.png?width=#{width}" } + + it_behaves_like 'gets icon path', 192 + it_behaves_like 'gets icon path', 512 + end + + context 'with default icon' do + let!(:result) { "/relative_root/-/pwa-icons/logo-#{width}.png" } + + it_behaves_like 'gets icon path', 192 + it_behaves_like 'gets icon path', 512 + end + + it 'returns path of maskable logo' do + expect(helper.appearance_maskable_logo).to match('/relative_root/-/pwa-icons/maskable-logo.png') + end + + context 'with wrong input' do + let!(:result) { nil } + + it_behaves_like 'gets icon path', 19200 + end + + context 'when path is append to root' do + it 'appends root and path' do + expect(helper.append_root_path('/works_just_fine')).to match('/relative_root/works_just_fine') + end + end + end + describe '#appearance_pwa_name' do it 'returns the default value' do create(:appearance) diff --git a/spec/lib/gitlab/bare_repository_import/importer_spec.rb b/spec/lib/gitlab/bare_repository_import/importer_spec.rb index 3a885d70eb4..1fb442a74fb 100644 --- a/spec/lib/gitlab/bare_repository_import/importer_spec.rb +++ b/spec/lib/gitlab/bare_repository_import/importer_spec.rb @@ -73,9 +73,8 @@ RSpec.describe Gitlab::BareRepositoryImport::Importer do end it 'does not schedule an import' do - expect_next_instance_of(Project) do |instance| - expect(instance).not_to receive(:import_schedule) - end + project = Project.find_by_full_path(project_path) + expect(project).not_to receive(:import_schedule) importer.create_project_if_needed end diff --git a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb index 236e04a041b..95b1661ac99 100644 --- a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb @@ -27,8 +27,8 @@ RSpec.describe Gitlab::BitbucketImport::ProjectCreator do end it 'creates project' do - expect_next_instance_of(Project) do |project| - expect(project).to receive(:add_import_job) + allow_next_instances_of(Project, 2) do |project| + allow(project).to receive(:add_import_job) end project_creator = described_class.new(repo, 'vim', namespace, user, access_params) diff --git a/spec/lib/gitlab/email/html_to_markdown_parser_spec.rb b/spec/lib/gitlab/email/html_to_markdown_parser_spec.rb new file mode 100644 index 00000000000..fe585d47d59 --- /dev/null +++ b/spec/lib/gitlab/email/html_to_markdown_parser_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Email::HtmlToMarkdownParser, feature_category: :service_desk do + subject { described_class.convert(html) } + + describe '.convert' do + let(:html) { fixture_file("lib/gitlab/email/basic.html") } + + it 'parses html correctly' do + expect(subject) + .to eq( + <<-BODY.strip_heredoc.chomp + Hello, World! + This is some e-mail content. Even though it has whitespace and newlines, the e-mail converter will handle it correctly. + *Even* mismatched tags. + A div + Another div + A div + **within** a div + + Another line + Yet another line + [A link](http://foo.com) + <details> + <summary> + One</summary> + Some details</details> + + <details> + <summary> + Two</summary> + Some details</details> + + ![Miro](http://img.png) + Col A Col B + Data A1 Data B1 + Data A2 Data B2 + Data A3 Data B4 + Total A Total B + BODY + ) + end + end +end diff --git a/spec/lib/gitlab/email/reply_parser_spec.rb b/spec/lib/gitlab/email/reply_parser_spec.rb index 10ffb420508..e4c68dbba92 100644 --- a/spec/lib/gitlab/email/reply_parser_spec.rb +++ b/spec/lib/gitlab/email/reply_parser_spec.rb @@ -188,6 +188,70 @@ RSpec.describe Gitlab::Email::ReplyParser do ) end + context 'properly renders email reply from gmail web client' do + context 'when feature flag is enabled' do + it do + expect(test_parse_body(fixture_file("emails/html_only.eml"))) + .to eq( + <<-BODY.strip_heredoc.chomp + ### This is a reply from standard GMail in Google Chrome. + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. + + Here's some **bold** text, **strong** text and *italic* in Markdown. + + Here's a link http://example.com + + Here's an img ![Miro](http://img.png)<details> + <summary> + One</summary> + Some details</details> + + <details> + <summary> + Two</summary> + Some details</details> + + Test reply. + + First paragraph. + + Second paragraph. + BODY + ) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(service_desk_html_to_text_email_handler: false) + end + + it do + expect(test_parse_body(fixture_file("emails/html_only.eml"))) + .to eq( + <<-BODY.strip_heredoc.chomp + ### This is a reply from standard GMail in Google Chrome. + + The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. The quick brown fox jumps over the lazy dog. + + Here's some **bold** text, strong text and italic in Markdown. + + Here's a link http://example.com + + Here's an img [Miro]One Some details Two Some details + + Test reply. + + First paragraph. + + Second paragraph. + BODY + ) + end + end + end + it "properly renders email reply from iOS default mail client" do expect(test_parse_body(fixture_file("emails/ios_default.eml"))) .to eq( diff --git a/spec/lib/gitlab/gitlab_import/project_creator_spec.rb b/spec/lib/gitlab/gitlab_import/project_creator_spec.rb index 53bf1db3438..59a98987f7d 100644 --- a/spec/lib/gitlab/gitlab_import/project_creator_spec.rb +++ b/spec/lib/gitlab/gitlab_import/project_creator_spec.rb @@ -24,8 +24,8 @@ RSpec.describe Gitlab::GitlabImport::ProjectCreator do end it 'creates project' do - expect_next_instance_of(Project) do |project| - expect(project).to receive(:add_import_job) + allow_next_instance_of(Project) do |project| + allow(project).to receive(:add_import_job) end project_creator = described_class.new(repo, namespace, user, access_params) diff --git a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb index f853bccc115..103d3512e8b 100644 --- a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb +++ b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb @@ -295,9 +295,9 @@ RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer, feature_category end end - it_behaves_like 'record with exportable associations', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/390356' do + it_behaves_like 'record with exportable associations' do let(:expected_issue) do - issue_hash[many_relation].delete_at(1) + issue_hash[many_relation].delete_if { |record| record['id'] == link2.id } issue_hash.to_json(options) end end diff --git a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb index 936c63fd6cd..d133f54ade5 100644 --- a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb +++ b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::ImportExport::Project::RelationFactory, :use_clean_rails_memory_store_caching do +RSpec.describe Gitlab::ImportExport::Project::RelationFactory, :use_clean_rails_memory_store_caching, feature_category: :importers do let(:group) { create(:group).tap { |g| g.add_maintainer(importer_user) } } let(:project) { create(:project, :repository, group: group) } let(:members_mapper) { double('members_mapper').as_null_object } @@ -418,21 +418,73 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory, :use_clean_rails_ end end - context 'merge request access level object' do - let(:relation_sym) { :'ProtectedBranch::MergeAccessLevel' } - let(:relation_hash) { { 'access_level' => 30, 'created_at' => '2022-03-29T09:53:13.457Z', 'updated_at' => '2022-03-29T09:54:13.457Z' } } + describe 'protected branch access levels' do + shared_examples 'access levels' do + let(:relation_hash) { { 'access_level' => access_level, 'created_at' => '2022-03-29T09:53:13.457Z', 'updated_at' => '2022-03-29T09:54:13.457Z' } } - it 'sets access level to maintainer' do - expect(created_object.access_level).to equal(Gitlab::Access::MAINTAINER) + context 'when access level is no one' do + let(:access_level) { Gitlab::Access::NO_ACCESS } + + it 'keeps no one access level' do + expect(created_object.access_level).to equal(access_level) + end + end + + context 'when access level is below maintainer' do + let(:access_level) { Gitlab::Access::DEVELOPER } + + it 'sets access level to maintainer' do + expect(created_object.access_level).to equal(Gitlab::Access::MAINTAINER) + end + end + + context 'when access level is above maintainer' do + let(:access_level) { Gitlab::Access::OWNER } + + it 'sets access level to maintainer' do + expect(created_object.access_level).to equal(Gitlab::Access::MAINTAINER) + end + end + + describe 'root ancestor membership' do + let(:access_level) { Gitlab::Access::DEVELOPER } + + context 'when importer user is root group owner' do + let(:importer_user) { create(:user) } + + it 'keeps access level as is' do + group.add_owner(importer_user) + + expect(created_object.access_level).to equal(access_level) + end + end + + context 'when user membership in root group is missing' do + it 'sets access level to maintainer' do + group.members.delete_all + + expect(created_object.access_level).to equal(Gitlab::Access::MAINTAINER) + end + end + + context 'when root ancestor is not a group' do + it 'sets access level to maintainer' do + expect(created_object.access_level).to equal(Gitlab::Access::MAINTAINER) + end + end + end + end + + describe 'merge access level' do + let(:relation_sym) { :'ProtectedBranch::MergeAccessLevel' } + + include_examples 'access levels' end - end - context 'push access level object' do - let(:relation_sym) { :'ProtectedBranch::PushAccessLevel' } - let(:relation_hash) { { 'access_level' => 30, 'created_at' => '2022-03-29T09:53:13.457Z', 'updated_at' => '2022-03-29T09:54:13.457Z' } } + describe 'push access level' do + let(:relation_sym) { :'ProtectedBranch::PushAccessLevel' } - it 'sets access level to maintainer' do - expect(created_object.access_level).to equal(Gitlab::Access::MAINTAINER) + include_examples 'access levels' end end end diff --git a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb index 17ecd183ac9..5df44bfb83c 100644 --- a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb +++ b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Gitlab::LegacyGithubImport::ProjectCreator do before do namespace.add_owner(user) - expect_next_instance_of(Project) do |project| + allow_next_instances_of(Project, 2) do |project| allow(project).to receive(:add_import_job) end end diff --git a/spec/models/appearance_spec.rb b/spec/models/appearance_spec.rb index 0a1de1d5dc4..b5f47c950b9 100644 --- a/spec/models/appearance_spec.rb +++ b/spec/models/appearance_spec.rb @@ -26,6 +26,7 @@ RSpec.describe Appearance do it { expect(appearance.message_background_color).to eq('#E75E40') } it { expect(appearance.message_font_color).to eq('#FFFFFF') } it { expect(appearance.email_header_and_footer_enabled).to eq(false) } + it { expect(Appearance::ALLOWED_PWA_ICON_SCALER_WIDTHS).to match_array([192, 512]) } end describe '#single_appearance_row' do @@ -84,6 +85,19 @@ RSpec.describe Appearance do it_behaves_like 'logo paths', logo_type end + shared_examples 'icon paths sized' do |width| + let_it_be(:appearance) { create(:appearance, :with_pwa_icon) } + let_it_be(:filename) { 'dk.png' } + let_it_be(:expected_path) { "/uploads/-/system/appearance/pwa_icon/#{appearance.id}/#{filename}?width=#{width}" } + + it 'returns icon path with size parameter' do + expect(appearance.pwa_icon_path_scaled(width)).to eq(expected_path) + end + end + + it_behaves_like 'icon paths sized', 192 + it_behaves_like 'icon paths sized', 512 + describe 'validations' do let(:triplet) { '#000' } let(:hex) { '#AABBCC' } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 5888f9d109c..4a59f8d8efc 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -226,9 +226,9 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep, feature_category: let_it_be(:pipeline2) { create(:ci_pipeline, name: 'Chatops pipeline') } context 'when name exists' do - let(:name) { 'build Pipeline' } + let(:name) { 'Build pipeline' } - it 'performs case insensitive compare' do + it 'performs exact compare' do is_expected.to contain_exactly(pipeline1) end end diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb index 451db9eaf9c..668b3aa8236 100644 --- a/spec/policies/group_policy_spec.rb +++ b/spec/policies/group_policy_spec.rb @@ -667,6 +667,42 @@ RSpec.describe GroupPolicy, feature_category: :authentication_and_authorization it { is_expected.to be_allowed(:create_projects) } end + + context 'when there are no available visibility levels because they have been restricted by an administrator' do + before do + stub_application_setting( + restricted_visibility_levels: [ + Gitlab::VisibilityLevel::PUBLIC, + Gitlab::VisibilityLevel::INTERNAL, + Gitlab::VisibilityLevel::PRIVATE + ] + ) + end + + context 'reporter' do + let(:current_user) { reporter } + + it { is_expected.to be_disallowed(:create_projects) } + end + + context 'developer' do + let(:current_user) { developer } + + it { is_expected.to be_disallowed(:create_projects) } + end + + context 'maintainer' do + let(:current_user) { maintainer } + + it { is_expected.to be_disallowed(:create_projects) } + end + + context 'owner' do + let(:current_user) { owner } + + it { is_expected.to be_disallowed(:create_projects) } + end + end end end diff --git a/spec/requests/api/draft_notes_spec.rb b/spec/requests/api/draft_notes_spec.rb index cff8c34e4a1..b8331e072cf 100644 --- a/spec/requests/api/draft_notes_spec.rb +++ b/spec/requests/api/draft_notes_spec.rb @@ -9,8 +9,8 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: user) } let_it_be(:merge_request_note) { create(:note, noteable: merge_request, project: project, author: user) } - let_it_be(:draft_note_by_current_user) { create(:draft_note, merge_request: merge_request, author: user) } - let_it_be(:draft_note_by_random_user) { create(:draft_note, merge_request: merge_request) } + let!(:draft_note_by_current_user) { create(:draft_note, merge_request: merge_request, author: user) } + let!(:draft_note_by_random_user) { create(:draft_note, merge_request: merge_request) } before do project.add_developer(user) @@ -74,4 +74,47 @@ RSpec.describe API::DraftNotes, feature_category: :code_review_workflow do end end end + + describe "delete a draft note" do + context "when deleting an existing draft note by the user" do + let!(:deleted_draft_note_id) { draft_note_by_current_user.id } + + before do + delete api( + "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes/#{draft_note_by_current_user.id}", + user + ) + end + + it "returns 204 No Content status" do + expect(response).to have_gitlab_http_status(:no_content) + end + + it "deletes the specified draft note" do + expect(DraftNote.exists?(deleted_draft_note_id)).to eq(false) + end + end + + context "when deleting a non-existent draft note" do + it "returns a 404 Not Found" do + delete api( + "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes/#{non_existing_record_id}", + user + ) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context "when deleting a draft note by a different user" do + it "returns a 404 Not Found" do + delete api( + "/projects/#{project.id}/merge_requests/#{merge_request.iid}/draft_notes/#{draft_note_by_random_user.id}", + user + ) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end end diff --git a/spec/requests/pwa_controller_spec.rb b/spec/requests/pwa_controller_spec.rb index 393f8803375..08eeefd1dc4 100644 --- a/spec/requests/pwa_controller_spec.rb +++ b/spec/requests/pwa_controller_spec.rb @@ -4,28 +4,74 @@ require 'spec_helper' RSpec.describe PwaController, feature_category: :navigation do describe 'GET #manifest' do - it 'responds with json' do - get manifest_path(format: :json) + shared_examples 'text values' do |params, result| + let_it_be(:appearance) { create(:appearance, **params) } - expect(Gitlab::Json.parse(response.body)).to include({ 'name' => 'GitLab' }) - expect(Gitlab::Json.parse(response.body)).to include({ 'short_name' => 'GitLab' }) - expect(response.body).to include('The complete DevOps platform.') - expect(response).to have_gitlab_http_status(:success) + it 'uses custom values', :aggregate_failures do + get manifest_path(format: :json) + + expect(Gitlab::Json.parse(response.body)).to include(result) + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'with default appearance' do + it_behaves_like 'text values', {}, { + 'name' => 'GitLab', + 'short_name' => 'GitLab', + 'description' => 'The complete DevOps platform. ' \ + 'One application with endless possibilities. ' \ + 'Organizations rely on GitLab’s source code management, ' \ + 'CI/CD, security, and more to deliver software rapidly.' + } end context 'with customized appearance' do - let_it_be(:appearance) do - create(:appearance, pwa_name: 'PWA name', pwa_short_name: 'Short name', pwa_description: 'This is a test') + context 'with custom text values' do + it_behaves_like 'text values', { pwa_name: 'PWA name' }, { 'name' => 'PWA name' } + it_behaves_like 'text values', { pwa_short_name: 'Short name' }, { 'short_name' => 'Short name' } + it_behaves_like 'text values', { pwa_description: 'This is a test' }, { 'description' => 'This is a test' } end - it 'uses custom values', :aggregate_failures do - get manifest_path(format: :json) + shared_examples 'icon paths' do + it 'returns expected icon paths', :aggregate_failures do + get manifest_path(format: :json) + + expect(Gitlab::Json.parse(response.body)["icons"]).to match_array(result) + expect(response).to have_gitlab_http_status(:success) + end + end + + context 'with custom icon' do + let_it_be(:appearance) { create(:appearance, :with_pwa_icon) } + let_it_be(:result) do + [{ "src" => "/uploads/-/system/appearance/pwa_icon/#{appearance.id}/dk.png?width=192", "sizes" => "192x192", + "type" => "image/png" }, + { "src" => "/uploads/-/system/appearance/pwa_icon/#{appearance.id}/dk.png?width=512", "sizes" => "512x512", + "type" => "image/png" }] + end + + it_behaves_like 'icon paths' + end - expect(Gitlab::Json.parse(response.body)).to include({ - 'description' => 'This is a test', - 'name' => 'PWA name', - 'short_name' => 'Short name' - }) + context 'with no custom icon' do + let_it_be(:appearance) { create(:appearance) } + let_it_be(:result) do + [{ "src" => "/-/pwa-icons/logo-192.png", "sizes" => "192x192", "type" => "image/png" }, + { "src" => "/-/pwa-icons/logo-512.png", "sizes" => "512x512", "type" => "image/png" }, + { "src" => "/-/pwa-icons/maskable-logo.png", "sizes" => "512x512", "type" => "image/png", + "purpose" => "maskable" }] + end + + it_behaves_like 'icon paths' + end + end + + describe 'GET #offline' do + it 'responds with static HTML page' do + get offline_path + + expect(response.body).to include('You are currently offline') expect(response).to have_gitlab_http_status(:success) end end @@ -47,13 +93,4 @@ RSpec.describe PwaController, feature_category: :navigation do end end end - - describe 'GET #offline' do - it 'responds with static HTML page' do - get offline_path - - expect(response.body).to include('You are currently offline') - expect(response).to have_gitlab_http_status(:success) - end - end end |