summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-02-08 12:10:06 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2023-02-08 12:10:06 +0000
commitd0aeb5df3d6b06165355b023a25b79c7bd74a27d (patch)
tree7b5d3ff0f0ac5c124aa8626aeb4a0682d99a17c2
parent9ccf40d15a14e9ccf613701ba7e3d5d250961345 (diff)
downloadgitlab-ce-d0aeb5df3d6b06165355b023a25b79c7bd74a27d.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.gitlab/ci/global.gitlab-ci.yml26
-rw-r--r--.gitlab/ci/review-apps/main.gitlab-ci.yml16
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--app/assets/javascripts/admin/topics/components/topic_select.vue91
-rw-r--r--app/assets/javascripts/issues/show/components/fields/description.vue20
-rw-r--r--app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue29
-rw-r--r--app/assets/javascripts/work_items/components/work_item_comment_form.vue11
-rw-r--r--app/assets/javascripts/work_items/components/work_item_description.vue15
-rw-r--r--app/assets/javascripts/work_items/components/work_item_labels.vue45
-rw-r--r--app/controllers/concerns/send_file_upload.rb23
-rw-r--r--app/helpers/appearances_helper.rb14
-rw-r--r--app/models/appearance.rb8
-rw-r--r--app/models/ci/pipeline.rb2
-rw-r--r--app/models/sent_notification.rb4
-rw-r--r--app/policies/group_policy.rb9
-rw-r--r--app/uploaders/attachment_uploader.rb12
-rw-r--r--app/views/pwa/manifest.json.erb30
-rw-r--r--config/feature_flags/development/service_desk_html_to_text_email_handler.yml8
-rw-r--r--db/migrate/20230130175512_initialize_conversion_of_sent_notifications_to_bigint.rb16
-rw-r--r--db/post_migrate/20230130202201_backfill_sent_notifications_for_bigint_conversion.rb16
-rw-r--r--db/post_migrate/20230203122609_change_pipeline_name_index.rb20
-rw-r--r--db/schema_migrations/202301301755121
-rw-r--r--db/schema_migrations/202301302022011
-rw-r--r--db/schema_migrations/202302031226091
-rw-r--r--db/structure.sql16
-rw-r--r--doc/ci/yaml/workflow.md2
-rw-r--r--doc/development/pipelines/internals.md91
-rw-r--r--doc/integration/jira/connect-app.md6
-rw-r--r--doc/user/group/settings/group_access_tokens.md6
-rw-r--r--doc/user/project/service_desk.md17
-rw-r--r--lib/api/draft_notes.rb30
-rw-r--r--lib/gitlab/email/html_parser.rb6
-rw-r--r--lib/gitlab/email/html_to_markdown_parser.rb29
-rw-r--r--lib/gitlab/import_export/project/relation_factory.rb12
-rw-r--r--locale/gitlab.pot5
-rwxr-xr-xscripts/build_qa_image10
-rw-r--r--scripts/utils.sh17
-rw-r--r--spec/controllers/concerns/send_file_upload_spec.rb15
-rw-r--r--spec/features/groups_spec.rb27
-rw-r--r--spec/features/merge_request/user_sees_discussions_navigation_spec.rb3
-rw-r--r--spec/finders/ci/pipelines_finder_spec.rb4
-rw-r--r--spec/fixtures/emails/html_only.eml45
-rw-r--r--spec/fixtures/lib/gitlab/email/basic.html72
-rw-r--r--spec/frontend/admin/topics/components/topic_select_spec.js122
-rw-r--r--spec/frontend/pages/shared/wikis/components/wiki_form_spec.js11
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js20
-rw-r--r--spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js16
-rw-r--r--spec/frontend/work_items/components/work_item_labels_spec.js40
-rw-r--r--spec/frontend/work_items/mock_data.js13
-rw-r--r--spec/helpers/appearances_helper_spec.rb45
-rw-r--r--spec/lib/gitlab/bare_repository_import/importer_spec.rb5
-rw-r--r--spec/lib/gitlab/bitbucket_import/project_creator_spec.rb4
-rw-r--r--spec/lib/gitlab/email/html_to_markdown_parser_spec.rb46
-rw-r--r--spec/lib/gitlab/email/reply_parser_spec.rb64
-rw-r--r--spec/lib/gitlab/gitlab_import/project_creator_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb4
-rw-r--r--spec/lib/gitlab/import_export/project/relation_factory_spec.rb76
-rw-r--r--spec/lib/gitlab/legacy_github_import/project_creator_spec.rb2
-rw-r--r--spec/models/appearance_spec.rb14
-rw-r--r--spec/models/ci/pipeline_spec.rb4
-rw-r--r--spec/policies/group_policy_spec.rb36
-rw-r--r--spec/requests/api/draft_notes_spec.rb47
-rw-r--r--spec/requests/pwa_controller_spec.rb85
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&#39;s some **bold** text=
+, <strong>strong</strong> text and <em>italic</em> in Markdown.</div><div><=
+br></div><div>Here&#39;s a link <a href=3D"http://example.com">http://examp=
+le.com</a></div></div><div class=3D"gmail_extra"><br>Here&#39;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