diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-11-04 15:07:23 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-11-04 15:07:23 +0000 |
commit | 4938925517ffb73a07fbf55972ea415bd90ea342 (patch) | |
tree | c0258ddd137ce50265050b19c46659d59e6b76c8 | |
parent | f2fd07aa1c0bfb732b80c3d028cd23c91547991c (diff) | |
download | gitlab-ce-4938925517ffb73a07fbf55972ea415bd90ea342.tar.gz |
Add latest changes from gitlab-org/gitlab@master
88 files changed, 1989 insertions, 1128 deletions
diff --git a/.eslintignore b/.eslintignore index 1d069e19385..5428964b1ce 100644 --- a/.eslintignore +++ b/.eslintignore @@ -8,4 +8,6 @@ /vendor/ /sitespeed-result/ /fixtures/**/*.graphql +# Storybook build artifacts +/storybook/public spec/fixtures/**/*.graphql diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index c8b1b374b24..0581c5d27e6 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -897,6 +897,7 @@ rules: - !reference [".strict-ee-only-rules", rules] - !reference [".frontend:rules:default-frontend-jobs-as-if-foss", rules] + - <<: *if-merge-request-labels-run-all-jest - <<: *if-merge-request changes: *frontend-patterns-for-as-if-foss diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml index ee095512645..3cdede96163 100644 --- a/.rubocop_todo/layout/line_length.yml +++ b/.rubocop_todo/layout/line_length.yml @@ -1817,7 +1817,6 @@ Layout/LineLength: - 'ee/spec/features/groups/scim_token_spec.rb' - 'ee/spec/features/groups/security/compliance_dashboards_spec.rb' - 'ee/spec/features/groups/sso_spec.rb' - - 'ee/spec/features/groups/usage_quotas_spec.rb' - 'ee/spec/features/integrations/jira/jira_issues_list_spec.rb' - 'ee/spec/features/invites_spec.rb' - 'ee/spec/features/issues/filtered_search/filter_issues_weight_spec.rb' diff --git a/.rubocop_todo/rspec/context_wording.yml b/.rubocop_todo/rspec/context_wording.yml index f5c9e9cc059..f54d3b38f7a 100644 --- a/.rubocop_todo/rspec/context_wording.yml +++ b/.rubocop_todo/rspec/context_wording.yml @@ -123,10 +123,8 @@ RSpec/ContextWording: - 'ee/spec/features/groups/push_rules_spec.rb' - 'ee/spec/features/groups/saml_enforcement_spec.rb' - 'ee/spec/features/groups/saml_providers_spec.rb' - - 'ee/spec/features/groups/seat_usage/seat_usage_spec.rb' - 'ee/spec/features/groups/security/compliance_dashboards_spec.rb' - 'ee/spec/features/groups/sso_spec.rb' - - 'ee/spec/features/groups/usage_quotas_spec.rb' - 'ee/spec/features/groups_spec.rb' - 'ee/spec/features/ide/user_commits_changes_spec.rb' - 'ee/spec/features/ide/user_opens_ide_spec.rb' diff --git a/.rubocop_todo/rspec/empty_line_after_hook.yml b/.rubocop_todo/rspec/empty_line_after_hook.yml index 4b1c4299b20..125055044de 100644 --- a/.rubocop_todo/rspec/empty_line_after_hook.yml +++ b/.rubocop_todo/rspec/empty_line_after_hook.yml @@ -4,7 +4,6 @@ RSpec/EmptyLineAfterHook: Exclude: - 'ee/spec/controllers/projects/integrations/zentao/issues_controller_spec.rb' - 'ee/spec/controllers/projects/push_rules_controller_spec.rb' - - 'ee/spec/features/groups/usage_quotas_spec.rb' - 'ee/spec/features/issues/user_bulk_edits_issues_spec.rb' - 'ee/spec/features/profiles/usage_quotas_spec.rb' - 'ee/spec/lib/ee/api/entities/user_with_admin_spec.rb' diff --git a/GITLAB_METRICS_EXPORTER_VERSION b/GITLAB_METRICS_EXPORTER_VERSION index 19666f20264..e470d75e4bf 100644 --- a/GITLAB_METRICS_EXPORTER_VERSION +++ b/GITLAB_METRICS_EXPORTER_VERSION @@ -1 +1 @@ -1cbf6d9ce79fe9df99b545529f7b7d754baea080 +af0cd47633f6e0a5b8ac349a2584c01164af701a diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index dbe634e7295..180dea77003 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -67,7 +67,7 @@ export default { :quick-actions-docs-path="quickActionsDocsPath" :enable-autocomplete="enableAutocomplete" supports-quick-actions - init-on-autofocus + autofocus @input="$emit('input', $event)" @keydown.meta.enter="updateIssuable" @keydown.ctrl.enter="updateIssuable" diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue index 19d35a135fd..ba8caabb40a 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue @@ -95,13 +95,8 @@ export default { <template #right-actions> <slot name="commands"></slot> </template> - <template #metadata-count> - <metadata-item - v-if="imagesCount" - data-testid="images-count" - icon="container-image" - :text="imagesCountText" - /> + <template v-if="imagesCount" #metadata-count> + <metadata-item data-testid="images-count" icon="container-image" :text="imagesCountText" /> </template> <template #metadata-exp-policies> <metadata-item 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 7b9656de362..8e2f542aec0 100644 --- a/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/pages/shared/wikis/components/wiki_form.vue @@ -343,7 +343,7 @@ export default { :uploads-path="pageInfo.uploadsPath" :enable-content-editor="isMarkdownFormat" :enable-preview="isMarkdownFormat" - :init-on-autofocus="pageInfo.persisted" + :autofocus="pageInfo.persisted" :form-field-placeholder="$options.i18n.content.placeholder" :form-field-aria-label="$options.i18n.content.label" form-field-id="wiki_content" 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 b38772d5aa5..c0712e46613 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -72,7 +72,7 @@ export default { required: false, default: '', }, - initOnAutofocus: { + autofocus: { type: Boolean, required: false, default: false, @@ -87,20 +87,20 @@ export default { return { editingMode: EDITING_MODE_MARKDOWN_FIELD, switchEditingControlEnabled: true, - autofocus: this.initOnAutofocus, + autofocused: false, }; }, computed: { isContentEditorActive() { return this.enableContentEditor && this.editingMode === EDITING_MODE_CONTENT_EDITOR; }, - contentEditorAutofocus() { + contentEditorAutofocused() { // Match textarea focus behavior - return this.autofocus ? 'end' : false; + return this.autofocus && !this.autofocused ? 'end' : false; }, }, mounted() { - this.autofocusTextarea(this.editingMode); + this.autofocusTextarea(); }, methods: { updateMarkdownFromContentEditor({ markdown }) { @@ -120,7 +120,6 @@ export default { }, onEditingModeChange(editingMode) { this.notifyEditingModeChange(editingMode); - this.enableAutofocus(editingMode); }, onEditingModeRestored(editingMode) { this.notifyEditingModeChange(editingMode); @@ -128,15 +127,15 @@ export default { notifyEditingModeChange(editingMode) { this.$emit(editingMode); }, - enableAutofocus(editingMode) { - this.autofocus = true; - this.autofocusTextarea(editingMode); - }, - autofocusTextarea(editingMode) { - if (this.autofocus && editingMode === EDITING_MODE_MARKDOWN_FIELD) { + autofocusTextarea() { + if (this.autofocus && this.editingMode === EDITING_MODE_MARKDOWN_FIELD) { this.$refs.textarea.focus(); + this.setEditorAsAutofocused(); } }, + setEditorAsAutofocused() { + this.autofocused = true; + }, }, switchEditingControlOptions: [ { text: __('Source'), value: EDITING_MODE_MARKDOWN_FIELD }, @@ -197,7 +196,8 @@ export default { :render-markdown="renderMarkdown" :uploads-path="uploadsPath" :markdown="value" - :autofocus="contentEditorAutofocus" + :autofocus="contentEditorAutofocused" + @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" @loading="disableSwitchEditingControl" @loadingSuccess="enableSwitchEditingControl" 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 57babe4569d..f317688b136 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -8,7 +8,7 @@ import { __, s__ } from '~/locale'; import EditedAt from '~/issues/show/components/edited.vue'; import Tracking from '~/tracking'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import workItemQuery from '../graphql/work_item.query.graphql'; +import { getWorkItemQuery } from '../utils'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants'; @@ -32,6 +32,15 @@ export default { type: String, required: true, }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + queryVariables: { + type: Object, + required: true, + }, }, markdownDocsPath: helpPagePath('user/markdown'), data() { @@ -45,11 +54,14 @@ export default { }, apollo: { workItem: { - query: workItemQuery, + query() { + return getWorkItemQuery(this.fetchByIid); + }, variables() { - return { - id: this.workItemId, - }; + return this.queryVariables; + }, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; }, skip() { return !this.workItemId; diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index af9b8c6101a..a2d7b3c4b80 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -1,4 +1,5 @@ <script> +import { isEmpty } from 'lodash'; import { GlAlert, GlSkeletonLoader, @@ -11,6 +12,7 @@ import { } from '@gitlab/ui'; import noAccessSvg from '@gitlab/svgs/dist/illustrations/analytics/no-access.svg'; import { s__ } from '~/locale'; +import { parseBoolean } from '~/lib/utils/common_utils'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WorkItemTypeIcon from '~/work_items/components/work_item_type_icon.vue'; @@ -27,12 +29,12 @@ import { WIDGET_TYPE_ITERATION, } from '../constants'; -import workItemQuery from '../graphql/work_item.query.graphql'; import workItemDatesSubscription from '../graphql/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql'; import workItemAssigneesSubscription from '../graphql/work_item_assignees.subscription.graphql'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql'; +import { getWorkItemQuery } from '../utils'; import WorkItemActions from './work_item_actions.vue'; import WorkItemState from './work_item_state.vue'; @@ -72,6 +74,7 @@ export default { WorkItemMilestone, }, mixins: [glFeatureFlagMixin()], + inject: ['fullPath'], props: { isModal: { type: Boolean, @@ -83,6 +86,11 @@ export default { required: false, default: null, }, + iid: { + type: String, + required: false, + default: null, + }, workItemParentId: { type: String, required: false, @@ -100,20 +108,26 @@ export default { }, apollo: { workItem: { - query: workItemQuery, + query() { + return getWorkItemQuery(this.fetchByIid); + }, variables() { - return { - id: this.workItemId, - }; + return this.queryVariables; }, skip() { return !this.workItemId; }, + update(data) { + const workItem = this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; + return workItem ?? {}; + }, error() { - this.error = this.$options.i18n.fetchError; - document.title = s__('404|Not found'); + this.setEmptyState(); }, result() { + if (isEmpty(this.workItem)) { + this.setEmptyState(); + } if (!this.isModal && this.workItem.project) { const path = this.workItem.project?.fullPath ? ` · ${this.workItem.project.fullPath}` @@ -127,30 +141,33 @@ export default { document: workItemTitleSubscription, variables() { return { - issuableId: this.workItemId, + issuableId: this.workItem.id, }; }, + skip() { + return !this.workItem?.id; + }, }, { document: workItemDatesSubscription, variables() { return { - issuableId: this.workItemId, + issuableId: this.workItem.id, }; }, skip() { - return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE); + return !this.isWidgetPresent(WIDGET_TYPE_START_AND_DUE_DATE) || !this.workItem?.id; }, }, { document: workItemAssigneesSubscription, variables() { return { - issuableId: this.workItemId, + issuableId: this.workItem.id, }; }, skip() { - return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES); + return !this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES) || !this.workItem?.id; }, }, ], @@ -214,6 +231,19 @@ export default { workItemMilestone() { return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_MILESTONE); }, + fetchByIid() { + return this.glFeatures.useIidInWorkItemsPath && parseBoolean(this.$route.query.iid_path); + }, + queryVariables() { + return this.fetchByIid + ? { + fullPath: this.fullPath, + iid: this.iid, + } + : { + id: this.workItemId, + }; + }, }, beforeDestroy() { /** make sure that if the user has not even dismissed the alert , @@ -231,7 +261,7 @@ export default { this.updateInProgress = true; let updateMutation = updateWorkItemMutation; let inputVariables = { - id: this.workItemId, + id: this.workItem.id, confidential: confidentialStatus, }; @@ -240,7 +270,7 @@ export default { inputVariables = { id: this.parentWorkItem.id, taskData: { - id: this.workItemId, + id: this.workItem.id, confidential: confidentialStatus, }, }; @@ -275,6 +305,10 @@ export default { this.updateInProgress = false; }); }, + setEmptyState() { + this.error = this.$options.i18n.fetchError; + document.title = s__('404|Not found'); + }, }, WORK_ITEM_VIEWED_STORAGE_KEY, }; @@ -352,7 +386,7 @@ export default { :can-update="canUpdate" :is-confidential="workItem.confidential" :is-parent-confidential="parentWorkItemConfidentiality" - @deleteWorkItem="$emit('deleteWorkItem', workItemType)" + @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" @toggleWorkItemConfidentiality="toggleConfidentiality" @error="updateError = $event" /> @@ -406,6 +440,8 @@ export default { :work-item-id="workItem.id" :can-update="canUpdate" :full-path="fullPath" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" @error="updateError = $event" /> <work-item-due-date @@ -435,6 +471,8 @@ export default { :weight="workItemWeight.weight" :work-item-id="workItem.id" :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" @error="updateError = $event" /> <template v-if="workItemsMvc2Enabled"> @@ -445,6 +483,8 @@ export default { :can-update="canUpdate" :work-item-id="workItem.id" :work-item-type="workItemType" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" @error="updateError = $event" /> </template> @@ -452,6 +492,8 @@ export default { v-if="hasDescriptionWidget" :work-item-id="workItem.id" :full-path="fullPath" + :fetch-by-iid="fetchByIid" + :query-variables="queryVariables" class="gl-pt-5" @error="updateError = $event" /> 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 05077862690..22af3c653e9 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -8,7 +8,7 @@ import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/labe import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; -import workItemQuery from '../graphql/work_item.query.graphql'; +import { getWorkItemQuery } from '../utils'; import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql'; import { @@ -50,6 +50,15 @@ export default { type: String, required: true, }, + fetchByIid: { + type: Boolean, + required: false, + default: false, + }, + queryVariables: { + type: Object, + required: true, + }, }, data() { return { @@ -64,11 +73,14 @@ export default { }, apollo: { workItem: { - query: workItemQuery, + query() { + return getWorkItemQuery(this.fetchByIid); + }, variables() { - return { - id: this.workItemId, - }; + return this.queryVariables; + }, + update(data) { + return this.fetchByIid ? data.workspace.workItems.nodes[0] : data.workItem; }, skip() { return !this.workItemId; diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index bb05c9b2135..6a81cc230b1 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -2,6 +2,7 @@ fragment WorkItem on WorkItem { id + iid title state description diff --git a/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql new file mode 100644 index 00000000000..83f0ce32e24 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/work_item_by_iid.query.graphql @@ -0,0 +1,23 @@ +#import "./work_item.fragment.graphql" + +query workItemByIid($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + id + workItems(iid: $iid) { + nodes { + ...WorkItem + mockWidgets @client { + ... on LocalWorkItemMilestone { + type + nodes { + id + title + expired + dueDate + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 4908b99e5b0..2245f984174 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -3,10 +3,11 @@ import { GlButton, GlAlert, GlLoadingIcon, GlFormSelect } from '@gitlab/ui'; import { getPreferredLocales, s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_CREATING } from '../constants'; -import workItemQuery from '../graphql/work_item.query.graphql'; import createWorkItemMutation from '../graphql/create_work_item.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; +import { getWorkItemQuery } from '../utils'; import ItemTitle from '../components/item_title.vue'; @@ -21,6 +22,7 @@ export default { ItemTitle, GlFormSelect, }, + mixins: [glFeatureFlagMixin()], inject: ['fullPath'], props: { initialTitle: { @@ -71,6 +73,9 @@ export default { return sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemType); }, + fetchByIid() { + return this.glFeatures.useIidInWorkItemsPath; + }, }, methods: { async createWorkItem() { @@ -89,28 +94,47 @@ export default { workItemTypeId: this.selectedWorkItemType, }, }, - update(store, { data: { workItemCreate } }) { + update: (store, { data: { workItemCreate } }) => { const { workItem } = workItemCreate; + const data = this.fetchByIid + ? { + workspace: { + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Project', + id: workItem.project.id, + workItems: { + __typename: 'WorkItemConnection', + nodes: [workItem], + }, + }, + } + : { workItem }; store.writeQuery({ - query: workItemQuery, - variables: { - id: workItem.id, - }, - data: { - workItem, - }, + query: getWorkItemQuery(this.fetchByIid), + variables: this.fetchByIid + ? { + fullPath: this.fullPath, + iid: workItem.iid, + } + : { + id: workItem.id, + }, + data, }); }, }); const { data: { workItemCreate: { - workItem: { id }, + workItem: { id, iid }, }, }, } = response; - this.$router.push({ name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }); + const routerParams = this.fetchByIid + ? { name: 'workItem', params: { id: iid }, query: { iid_path: 'true' } } + : { name: 'workItem', params: { id: `${getIdFromGraphQLId(id)}` } }; + this.$router.push(routerParams); } catch { this.error = this.createErrorText; } diff --git a/app/assets/javascripts/work_items/pages/work_item_root.vue b/app/assets/javascripts/work_items/pages/work_item_root.vue index a2cacd8bd7a..1c00bd16263 100644 --- a/app/assets/javascripts/work_items/pages/work_item_root.vue +++ b/app/assets/javascripts/work_items/pages/work_item_root.vue @@ -38,14 +38,12 @@ export default { this.ZenMode = new ZenMode(); }, methods: { - deleteWorkItem(workItemType) { + deleteWorkItem({ workItemType, workItemId: id }) { this.$apollo .mutate({ mutation: deleteWorkItemMutation, variables: { - input: { - id: this.gid, - }, + input: { id }, }, }) .then(({ data: { workItemDelete, errors } }) => { @@ -72,6 +70,6 @@ export default { <template> <div> <gl-alert v-if="error" variant="danger" @dismiss="error = ''">{{ error }}</gl-alert> - <work-item-detail :work-item-id="gid" @deleteWorkItem="deleteWorkItem($event)" /> + <work-item-detail :work-item-id="gid" :iid="id" @deleteWorkItem="deleteWorkItem($event)" /> </div> </template> diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js new file mode 100644 index 00000000000..17f9c882c2d --- /dev/null +++ b/app/assets/javascripts/work_items/utils.js @@ -0,0 +1,6 @@ +import workItemQuery from './graphql/work_item.query.graphql'; +import workItemByIidQuery from './graphql/work_item_by_iid.query.graphql'; + +export function getWorkItemQuery(isFetchedByIid) { + return isFetchedByIid ? workItemByIidQuery : workItemQuery; +} diff --git a/app/controllers/jira_connect/application_controller.rb b/app/controllers/jira_connect/application_controller.rb index e26d69314cd..a70c1ef4965 100644 --- a/app/controllers/jira_connect/application_controller.rb +++ b/app/controllers/jira_connect/application_controller.rb @@ -3,6 +3,12 @@ class JiraConnect::ApplicationController < ApplicationController include Gitlab::Utils::StrongMemoize + CORS_ALLOWED_METHODS = { + '/-/jira_connect/oauth_application_id' => %i[GET OPTIONS], + '/-/jira_connect/subscriptions' => %i[GET POST OPTIONS], + '/-/jira_connect/subscriptions/*' => %i[DELETE OPTIONS] + }.freeze + skip_before_action :authenticate_user! skip_before_action :verify_authenticity_token before_action :verify_atlassian_jwt! @@ -60,4 +66,25 @@ class JiraConnect::ApplicationController < ApplicationController def auth_token params[:jwt] || request.headers['Authorization']&.split(' ', 2)&.last end + + def cors_allowed_methods + CORS_ALLOWED_METHODS[resource] + end + + def resource + request.path.gsub(%r{/\d+$}, '/*') + end + + def set_cors_headers + return unless allow_cors_request? + + response.set_header('Access-Control-Allow-Origin', Gitlab::CurrentSettings.jira_connect_proxy_url) + response.set_header('Access-Control-Allow-Methods', cors_allowed_methods.join(', ')) + end + + def allow_cors_request? + return false if cors_allowed_methods.nil? + + !Gitlab.com? && Gitlab::CurrentSettings.jira_connect_proxy_url.present? + end end diff --git a/app/controllers/jira_connect/cors_preflight_checks_controller.rb b/app/controllers/jira_connect/cors_preflight_checks_controller.rb new file mode 100644 index 00000000000..3f30c1e04df --- /dev/null +++ b/app/controllers/jira_connect/cors_preflight_checks_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module JiraConnect + class CorsPreflightChecksController < ApplicationController + feature_category :integrations + + skip_before_action :verify_atlassian_jwt! + before_action :set_cors_headers + + def index + return render_404 unless allow_cors_request? + + render plain: '', content_type: 'text/plain' + end + end +end diff --git a/app/controllers/jira_connect/oauth_application_ids_controller.rb b/app/controllers/jira_connect/oauth_application_ids_controller.rb index a84b47f4c8b..3e788e2282e 100644 --- a/app/controllers/jira_connect/oauth_application_ids_controller.rb +++ b/app/controllers/jira_connect/oauth_application_ids_controller.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true module JiraConnect - class OauthApplicationIdsController < ::ApplicationController + class OauthApplicationIdsController < ApplicationController feature_category :integrations - skip_before_action :authenticate_user! - skip_before_action :verify_authenticity_token + skip_before_action :verify_atlassian_jwt! + before_action :set_cors_headers def show if show_application_id? diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb index 60787465bdf..9a732cadd94 100644 --- a/app/controllers/jira_connect/subscriptions_controller.rb +++ b/app/controllers/jira_connect/subscriptions_controller.rb @@ -27,6 +27,7 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController before_action :verify_qsh_claim!, only: :index before_action :allow_self_managed_content_security_policy, only: :index before_action :authenticate_user!, only: :create + before_action :set_cors_headers def index @subscriptions = current_jira_installation.subscriptions.preload_namespace_route diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb index 14013821dfb..a7e59a28fb7 100644 --- a/app/controllers/projects/work_items_controller.rb +++ b/app/controllers/projects/work_items_controller.rb @@ -4,6 +4,7 @@ class Projects::WorkItemsController < Projects::ApplicationController before_action do push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) + push_frontend_feature_flag(:use_iid_in_work_items_path, project) end feature_category :team_planning diff --git a/app/models/project.rb b/app/models/project.rb index a9144fa7c2a..0e60c466726 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -2789,7 +2789,7 @@ class Project < ApplicationRecord return unless service_desk_enabled? config = Gitlab.config.incoming_email - wildcard = Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER + wildcard = Gitlab::Email::Common::WILDCARD_PLACEHOLDER config.address&.gsub(wildcard, "#{full_path_slug}-#{default_service_desk_suffix}") end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 0b49beffcb5..33c34d050c0 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -30,6 +30,7 @@ module Ci Gitlab::Ci::Pipeline::Chain::Limit::Deployments, Gitlab::Ci::Pipeline::Chain::Validate::External, Gitlab::Ci::Pipeline::Chain::Populate, + Gitlab::Ci::Pipeline::Chain::PopulateMetadata, Gitlab::Ci::Pipeline::Chain::StopDryRun, Gitlab::Ci::Pipeline::Chain::EnsureEnvironments, Gitlab::Ci::Pipeline::Chain::EnsureResourceGroups, diff --git a/config/application.rb b/config/application.rb index 85b1a676409..02a57ad447c 100644 --- a/config/application.rb +++ b/config/application.rb @@ -422,22 +422,15 @@ module Gitlab allow do origins '*' resource oauth_path, - headers: %w(Authorization), + # These headers are added as defaults to axios. + # See: https://gitlab.com/gitlab-org/gitlab/-/blob/dd1e70d3676891025534dc4a1e89ca9383178fe7/app/assets/javascripts/lib/utils/axios_utils.js#L8) + # It's added to declare that this is a XHR request and add the CSRF token without which Rails may reject the request from the frontend. + headers: %w(Authorization X-CSRF-Token X-Requested-With), credentials: false, methods: %i(post options) end end - # Cross-origin requests must be enabled to fetch the self-managed application oauth application ID - # for the GitLab for Jira app. - allow do - origins '*' - resource '/-/jira_connect/oauth_application_id', - headers: :any, - methods: %i(get options), - credentials: false - end - # These are routes from doorkeeper-openid_connect: # https://github.com/doorkeeper-gem/doorkeeper-openid_connect#routes allow do diff --git a/config/open_api.yml b/config/open_api.yml index 811801584ac..ed454dd52dd 100644 --- a/config/open_api.yml +++ b/config/open_api.yml @@ -19,6 +19,8 @@ metadata: description: Operations related to access requests - name: cluster_agents description: Operations related to the GitLab agent for Kubernetes + - name: ci_resource_groups + description: Operations to manage job concurrency with resource groups - name: deploy_keys description: Operations related to deploy keys - name: deploy_tokens diff --git a/config/routes.rb b/config/routes.rb index e2e1b3439a4..5ebd7246e5a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -55,7 +55,10 @@ InitializerConnections.with_disabled_database_connections do match '/oauth/token' => 'oauth/tokens#create', via: :options match '/oauth/revoke' => 'oauth/tokens#revoke', via: :options - match '/-/jira_connect/oauth_application_id' => 'jira_connect/oauth_application_ids#show', via: :options + match '/-/jira_connect/oauth_application_id' => 'jira_connect/cors_preflight_checks#index', via: :options + match '/-/jira_connect/subscriptions' => 'jira_connect/cors_preflight_checks#index', via: :options + match '/-/jira_connect/subscriptions/:id' => 'jira_connect/cors_preflight_checks#index', via: :options + match '/-/jira_connect/installations' => 'jira_connect/cors_preflight_checks#index', via: :options # Sign up scope path: '/users/sign_up', module: :registrations, as: :users_sign_up do diff --git a/data/deprecations/14-7-deprecate-merged_by-api-field.yml b/data/deprecations/14-7-deprecate-merged_by-api-field.yml index 0a84b118981..561f3d5360e 100644 --- a/data/deprecations/14-7-deprecate-merged_by-api-field.yml +++ b/data/deprecations/14-7-deprecate-merged_by-api-field.yml @@ -13,11 +13,11 @@ - name: "merged_by API field" # The name of the feature to be deprecated announcement_milestone: "14.7" # The milestone when this feature was first announced as deprecated. announcement_date: "2022-01-22" # The date of the milestone release when this feature was first announced as deprecated. This should almost always be the 22nd of a month (YYYY-MM-22), unless you did an out of band blog post. - removal_milestone: "15.0" # The milestone when this feature is planned to be removed - removal_date: "2022-05-22" # the date of the milestone release when this feature is planned to be removed + removal_milestone: "16.0" # The milestone when this feature is planned to be removed + removal_date: "2023-05-22" # the date of the milestone release when this feature is planned to be removed breaking_change: true # If this deprecation is a breaking change, set this value to true body: | # Do not modify this line, instead modify the lines below. - The `merged_by` field in the [merge request API](https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests) is being deprecated and will be removed in GitLab 15.0. This field is being replaced with the `merge_user` field (already present in GraphQL) which more correctly identifies who merged a merge request when performing actions (merge when pipeline succeeds, add to merge train) other than a simple merge. + The `merged_by` field in the [merge request API](https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests) has been deprecated in favor of the `merge_user` field which more correctly identifies who merged a merge request when performing actions (merge when pipeline succeeds, add to merge train) other than a simple merge. API users are encouraged to use the new `merge_user` field instead. The `merged_by` field will be removed in v5 of the GitLab REST API. # The following items are not published on the docs page, but may be used in the future. stage: create # (optional - may be required in the future) String value of the stage that the feature was created in. e.g., Growth tiers: # (optional - may be required in the future) An array of tiers that the feature is available in currently. e.g., [Free, Silver, Gold, Core, Premium, Ultimate] diff --git a/db/post_migrate/20221103084213_remove_tmp_index_members_on_id_where_namespace_id_null.rb b/db/post_migrate/20221103084213_remove_tmp_index_members_on_id_where_namespace_id_null.rb new file mode 100644 index 00000000000..07908e697f5 --- /dev/null +++ b/db/post_migrate/20221103084213_remove_tmp_index_members_on_id_where_namespace_id_null.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class RemoveTmpIndexMembersOnIdWhereNamespaceIdNull < Gitlab::Database::Migration[2.0] + INDEX_NAME = 'tmp_index_members_on_id_where_namespace_id_null' + + disable_ddl_transaction! + + def up + remove_concurrent_index_by_name :members, INDEX_NAME + end + + def down + add_concurrent_index :members, :id, name: INDEX_NAME, where: 'member_namespace_id IS NULL' + end +end diff --git a/db/post_migrate/20221103150250_migrate_sidekiq_queued_jobs.rb b/db/post_migrate/20221103150250_migrate_sidekiq_queued_jobs.rb new file mode 100644 index 00000000000..ae2d7a47342 --- /dev/null +++ b/db/post_migrate/20221103150250_migrate_sidekiq_queued_jobs.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class MigrateSidekiqQueuedJobs < Gitlab::Database::Migration[2.0] + class SidekiqMigrateJobs + LOG_FREQUENCY_QUEUES = 10 + + attr_reader :logger, :mappings + + # mappings is a hash of WorkerClassName => target_queue_name + def initialize(mappings, logger: nil) + @mappings = mappings + @logger = logger + end + + # Migrates jobs from queues that are outside the mappings + # rubocop: disable Cop/SidekiqRedisCall + def migrate_queues + routing_rules_queues = mappings.values.uniq + logger&.info("List of queues based on routing rules: #{routing_rules_queues}") + Sidekiq.redis do |conn| + # Redis 6 supports conn.scan_each(match: "queue:*", type: 'list') + conn.scan_each(match: "queue:*") do |key| + # Redis 5 compatibility + next unless conn.type(key) == 'list' + + queue_from = key.split(':', 2).last + next if routing_rules_queues.include?(queue_from) + + logger&.info("Migrating #{queue_from} queue") + + migrated = 0 + while queue_length(queue_from) > 0 + begin + if migrated >= 0 && migrated % LOG_FREQUENCY_QUEUES == 0 + logger&.info("Migrating from #{queue_from}. Total: #{queue_length(queue_from)}. Migrated: #{migrated}.") + end + + job = conn.rpop "queue:#{queue_from}" + job_hash = Sidekiq.load_json job + next unless mappings.has_key?(job_hash['class']) + + destination_queue = mappings[job_hash['class']] + job_hash['queue'] = destination_queue + conn.lpush("queue:#{destination_queue}", Sidekiq.dump_json(job_hash)) + migrated += 1 + rescue JSON::ParserError + logger&.error("Unmarshal JSON payload from SidekiqMigrateJobs failed. Job: #{job}") + next + end + end + logger&.info("Finished migrating #{queue_from} queue") + end + end + end + + private + + def queue_length(queue_name) + Sidekiq.redis do |conn| + conn.llen("queue:#{queue_name}") + end + end + # rubocop: enable Cop/SidekiqRedisCall + end + + def up + return if Gitlab.com? + + mappings = Gitlab::SidekiqConfig.worker_queue_mappings + logger = ::Gitlab::BackgroundMigration::Logger.build + SidekiqMigrateJobs.new(mappings, logger: logger).migrate_queues + end + + def down + # no-op + end +end diff --git a/db/post_migrate/20221104100203_recreate_async_trigram_index_for_vulnerability_reads_container_images.rb b/db/post_migrate/20221104100203_recreate_async_trigram_index_for_vulnerability_reads_container_images.rb new file mode 100644 index 00000000000..ea2914f4dc4 --- /dev/null +++ b/db/post_migrate/20221104100203_recreate_async_trigram_index_for_vulnerability_reads_container_images.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RecreateAsyncTrigramIndexForVulnerabilityReadsContainerImages < Gitlab::Database::Migration[2.0] + disable_ddl_transaction! + + INDEX_NAME = 'index_vulnerability_reads_on_location_image_trigram' + REPORT_TYPES = { container_scanning: 2, cluster_image_scanning: 7 }.freeze + + def up + remove_concurrent_index_by_name :vulnerability_reads, INDEX_NAME + + prepare_async_index :vulnerability_reads, :location_image, + name: INDEX_NAME, + using: :gin, opclass: { location_image: :gin_trgm_ops }, + where: "report_type = ANY (ARRAY[#{REPORT_TYPES.values.join(', ')}]) AND location_image IS NOT NULL" + end + + def down + unprepare_async_index :vulnerability_reads, :location_image, name: INDEX_NAME + end +end diff --git a/db/schema_migrations/20221103084213 b/db/schema_migrations/20221103084213 new file mode 100644 index 00000000000..f9790952cf0 --- /dev/null +++ b/db/schema_migrations/20221103084213 @@ -0,0 +1 @@ +90794c6a9b8b9e08e8b0898e55bc581b8411fd0e85a17fefa916213d82e98099
\ No newline at end of file diff --git a/db/schema_migrations/20221103150250 b/db/schema_migrations/20221103150250 new file mode 100644 index 00000000000..cc6b55ba5ea --- /dev/null +++ b/db/schema_migrations/20221103150250 @@ -0,0 +1 @@ +662c4df2d65a9259e2eafc11e828ffc15765b92fe3a5291ff869129aaf7bb1c0
\ No newline at end of file diff --git a/db/schema_migrations/20221104100203 b/db/schema_migrations/20221104100203 new file mode 100644 index 00000000000..df7b06eef5d --- /dev/null +++ b/db/schema_migrations/20221104100203 @@ -0,0 +1 @@ +1d7912409bb5afc7de82b7507fb2aeb164253c70a58eaf88d502513577bad979
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index b328dd7aa94..416bdd9bf2f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -31204,8 +31204,6 @@ CREATE INDEX tmp_index_for_project_namespace_id_migration_on_routes ON routes US CREATE INDEX tmp_index_issues_on_issue_type_and_id ON issues USING btree (issue_type, id); -CREATE INDEX tmp_index_members_on_id_where_namespace_id_null ON members USING btree (id) WHERE (member_namespace_id IS NULL); - CREATE INDEX tmp_index_members_on_state ON members USING btree (state) WHERE (state = 2); CREATE INDEX tmp_index_migrated_container_registries ON container_repositories USING btree (project_id) WHERE ((migration_state = 'import_done'::text) OR (created_at >= '2022-01-23 00:00:00'::timestamp without time zone)); diff --git a/doc/administration/packages/container_registry.md b/doc/administration/packages/container_registry.md index c732bccef9a..2623f2afd8d 100644 --- a/doc/administration/packages/container_registry.md +++ b/doc/administration/packages/container_registry.md @@ -198,7 +198,8 @@ docker login gitlab.example.com:5050 When the Registry is configured to use its own domain, you need a TLS certificate for that specific domain (for example, `registry.example.com`). You might need a wildcard certificate if hosted under a subdomain of your existing GitLab -domain, for example, `registry.gitlab.example.com`. +domain. For example, `*.gitlab.example.com`, is a wildcard that matches `registry.gitlab.example.com`, +and is distinct from `*.example.com`. As well as manually generated SSL certificates (explained here), certificates automatically generated by Let's Encrypt are also [supported in Omnibus installs](https://docs.gitlab.com/omnibus/settings/ssl.html). diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index fa057c81580..d154adf1d78 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -260,6 +260,7 @@ control over how the Pages daemon runs and serves content in your environment. | `gitlab_id` | The OAuth application public ID. Leave blank to automatically fill when Pages authenticates with GitLab. | | `gitlab_secret` | The OAuth application secret. Leave blank to automatically fill when Pages authenticates with GitLab. | | `auth_scope` | The OAuth application scope to use for authentication. Must match GitLab Pages OAuth application settings. Leave blank to use `api` scope by default. | +| `auth_cookie_session_timeout` | Authentication cookie session timeout in seconds (default: 600s). A value of `0` means the cookie is deleted after the browser session ends. | | `gitlab_server` | Server to use for authentication when access control is enabled; defaults to GitLab `external_url`. | | `headers` | Specify any additional http headers that should be sent to the client with each response. Multiple headers can be given as an array, header and value as one string, for example `['my-header: myvalue', 'my-other-header: my-other-value']` | | `enable_disk` | Allows the GitLab Pages daemon to serve content from disk. Shall be disabled if shared disk storage isn't available. | diff --git a/doc/api/openapi/openapi.yaml b/doc/api/openapi/openapi.yaml index a3a0485428d..93326fd15e2 100644 --- a/doc/api/openapi/openapi.yaml +++ b/doc/api/openapi/openapi.yaml @@ -43,35 +43,657 @@ components: paths: # METADATA /v4/metadata: - $ref: 'v4/metadata.yaml' + $ref: '#/metadata' # VERSION /v4/version: - $ref: 'v4/version.yaml' + $ref: '#/version' # ACCESS REQUESTS (PROJECTS) /v4/projects/{id}/access_requests: - $ref: 'v4/access_requests.yaml#/accessRequestsProjects' + $ref: '#/accessRequestsProjects' /v4/projects/{id}/access_requests/{user_id}/approve: - $ref: 'v4/access_requests.yaml#/accessRequestsProjectsApprove' + $ref: '#/accessRequestsProjectsApprove' /v4/projects/{id}/access_requests/{user_id}: - $ref: 'v4/access_requests.yaml#/accessRequestsProjectsDeny' + $ref: '#/accessRequestsProjectsDeny' # ACCESS REQUESTS (GROUPS) /v4/groups/{id}/access_requests: - $ref: 'v4/access_requests.yaml#/accessRequestsGroups' + $ref: '#/accessRequestsGroups' /v4/groups/{id}/access_requests/{user_id}/approve: - $ref: 'v4/access_requests.yaml#/accessRequestsGroupsApprove' + $ref: '#/accessRequestsGroupsApprove' /v4/groups/{id}/access_requests/{user_id}: - $ref: 'v4/access_requests.yaml#/accessRequestsGroupsDeny' + $ref: '#/accessRequestsGroupsDeny' # ACCESS REQUESTS (PROJECTS) /v4/projects/{id}/access_tokens: - $ref: 'v4/access_tokens.yaml#/accessTokens' + $ref: '#/accessTokens' /v4/projects/{id}/access_tokens/{token_id}: - $ref: 'v4/access_tokens.yaml#/accessTokensRevoke' + $ref: '#/accessTokensRevoke' + +metadata: + get: + tags: + - metadata + summary: 'Retrieve metadata information for this GitLab instance.' + operationId: 'getMetadata' + responses: + '401': + description: 'unauthorized operation' + '200': + description: 'successful operation' + content: + 'application/json': + schema: + title: 'MetadataResponse' + type: 'object' + properties: + version: + type: 'string' + revision: + type: 'string' + kas: + type: 'object' + properties: + enabled: + type: 'boolean' + externalUrl: + type: 'string' + nullable: true + version: + type: 'string' + nullable: true + examples: + Example: + value: + version: '15.0-pre' + revision: 'c401a659d0c' + kas: + enabled: true + externalUrl: 'grpc://gitlab.example.com:8150' + version: '15.0.0' + +version: + get: + tags: + - version + summary: 'Retrieve version information for this GitLab instance.' + operationId: 'getVersion' + responses: + '401': + description: 'unauthorized operation' + '200': + description: 'successful operation' + content: + 'application/json': + schema: + title: 'VersionResponse' + type: 'object' + properties: + version: + type: 'string' + revision: + type: 'string' + examples: + Example: + value: + version: '13.3.0-pre' + revision: 'f2b05afebb0' + +#/v4/projects/{id}/access_requests +accessRequestsProjects: + get: + description: Lists access requests for a project + summary: List access requests for a project + operationId: accessRequestsProjects_get + tags: + - access_requests + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the project owned by the authenticated user. + required: true + schema: + oneOf: + - type: integer + - type: string + responses: + '401': + description: Unauthorized operation + '200': + description: Successful operation + content: + application/json: + schema: + title: ProjectAccessResponse + type: object + properties: + id: + type: integer + usename: + type: string + name: + type: string + state: + type: string + created_at: + type: string + requested_at: + type: string + example: + - 'id': 1 + 'username': 'raymond_smith' + 'name': 'Raymond Smith' + 'state': 'active' + 'created_at': '2012-10-22T14:13:35Z' + 'requested_at': '2012-10-22T14:13:35Z' + - 'id': 2 + 'username': 'john_doe' + 'name': 'John Doe' + 'state': 'active' + 'created_at': '2012-10-22T14:13:35Z' + 'requested_at': '2012-10-22T14:13:35Z' + post: + description: Requests access for the authenticated user to a project + summary: Requests access for the authenticated user to a project + operationId: accessRequestsProjects_post + tags: + - access_requests + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the project owned by the authenticated user. + required: true + schema: + oneOf: + - type: integer + - type: string + responses: + '401': + description: Unauthorized operation + '200': + description: Successful operation + content: + application/json: + schema: + title: ProjectAccessRequest + type: object + properties: + id: + type: integer + usename: + type: string + name: + type: string + state: + type: string + created_at: + type: string + requested_at: + type: string + example: + 'id': 1 + 'username': 'raymond_smith' + 'name': 'Raymond Smith' + 'state': 'active' + 'created_at': '2012-10-22T14:13:35Z' + 'requested_at': '2012-10-22T14:13:35Z' + +#/v4/projects/{id}/access_requests/{user_id}/approve +accessRequestsProjectsApprove: + put: + description: Approves access for the authenticated user to a project + summary: Approves access for the authenticated user to a project + operationId: accessRequestsProjectsApprove_put + tags: + - access_requests + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the project owned by the authenticated user. + required: true + schema: + oneOf: + - type: integer + - type: string + - name: user_id + in: path + description: The userID of the access requester + required: true + schema: + type: integer + - name: access_level + in: query + description: A valid project access level. 0 = no access , 10 = guest, 20 = reporter, 30 = developer, 40 = Maintainer. Default is 30.' + required: false + schema: + enum: [0, 10, 20, 30, 40] + default: 30 + type: integer + responses: + '401': + description: Unauthorized operation + '200': + description: Successful operation + content: + application/json: + schema: + title: ProjectAccessApprove + type: object + properties: + id: + type: integer + usename: + type: string + name: + type: string + state: + type: string + created_at: + type: string + access_level: + type: integer + example: + 'id': 1 + 'username': 'raymond_smith' + 'name': 'Raymond Smith' + 'state': 'active' + 'created_at': '2012-10-22T14:13:35Z' + 'access_level': 20 + +#/v4/projects/{id}/access_requests/{user_id} +accessRequestsProjectsDeny: + delete: + description: Denies a project access request for the given user + summary: Denies a project access request for the given user + operationId: accessRequestProjectsDeny_delete + tags: + - access_requests + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the project owned by the authenticated user. + required: true + schema: + oneOf: + - type: integer + - type: string + - name: user_id + in: path + description: The user ID of the access requester + required: true + schema: + type: integer + responses: # Does anything go here? Markdown doc does not list a response. + '401': + description: Unauthorized operation + '200': + description: Successful operation + +#/v4/groups/{id}/access_requests +accessRequestsGroups: + get: + description: List access requests for a group + summary: List access requests for a group + operationId: accessRequestsGroups_get + tags: + - access_requests + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the group owned by the authenticated user. + required: true + schema: + oneOf: + - type: integer + - type: string + responses: + '401': + description: Unauthorized operation + '200': + description: Successful operation + content: + application/json: + schema: + title: GroupAccessResponse + type: object + properties: + id: + type: integer + usename: + type: string + name: + type: string + state: + type: string + created_at: + type: string + requested_at: + type: string + example: + - 'id': 1 + 'username': 'raymond_smith' + 'name': 'Raymond Smith' + 'state': 'active' + 'created_at': '2012-10-22T14:13:35Z' + 'requested_at': '2012-10-22T14:13:35Z' + - 'id': 2 + 'username': 'john_doe' + 'name': 'John Doe' + 'state': 'active' + 'created_at': '2012-10-22T14:13:35Z' + 'requested_at': '2012-10-22T14:13:35Z' + post: + description: Requests access for the authenticated user to a group + summary: Requests access for the authenticated user to a group + operationId: accessRequestsGroups_post + tags: + - access_requests + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the group owned by the authenticated user. + required: true + schema: + oneOf: + - type: integer + - type: string + responses: + '401': + description: Unauthorized operation + '200': + description: Successful operation + content: + application/json: + schema: + title: GroupAccessRequest + type: object + properties: + id: + type: integer + usename: + type: string + name: + type: string + state: + type: string + created_at: + type: string + requested_at: + type: string + example: + 'id': 1 + 'username': 'raymond_smith' + 'name': 'Raymond Smith' + 'state': 'active' + 'created_at': '2012-10-22T14:13:35Z' + 'requested_at': '2012-10-22T14:13:35Z' + +#/v4/groups/{id}/access_requests/{user_id}/approve +accessRequestsGroupsApprove: + put: + description: Approves access for the authenticated user to a group + summary: Approves access for the authenticated user to a group + operationId: accessRequestsGroupsApprove_put + tags: + - access_requests + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the group owned by the authenticated user. + required: true + schema: + oneOf: + - type: integer + - type: string + - name: user_id + in: path + description: The userID of the access requester + required: true + schema: + type: integer + - name: access_level + in: query + description: A valid group access level. 0 = no access , 10 = Guest, 20 = Reporter, 30 = Developer, 40 = Maintainer, 50 = Owner. Default is 30. + required: false + schema: + enum: [0, 10, 20, 30, 40, 50] + default: 30 + type: integer + responses: + '401': + description: Unauthorized operation + '200': + description: Successful operation + content: + application/json: + schema: + title: GroupAccessApprove + type: object + properties: + id: + type: integer + usename: + type: string + name: + type: string + state: + type: string + created_at: + type: string + access_level: + type: integer + example: + 'id': 1 + 'username': 'raymond_smith' + 'name': 'Raymond Smith' + 'state': 'active' + 'created_at': '2012-10-22T14:13:35Z' + 'access_level': 20 + +#/v4/groups/{id}/access_requests/{user_id} +accessRequestsGroupsDeny: + delete: + description: Denies a group access request for the given user + summary: Denies a group access request for the given user + operationId: accessRequestsGroupsDeny_delete + tags: + - access_requests + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the group owned by the authenticated user. + required: true + schema: + oneOf: + - type: integer + - type: string + - name: user_id + in: path + description: The userID of the access requester + required: true + schema: + type: integer + responses: # Does anything go here? Markdown doc does not list a response. + '401': + description: Unauthorized operation + '200': + description: Successful operation +#/v4/projects/{id}/access_tokens +accessTokens: + get: + description: Lists access tokens for a project + summary: List access tokens for a project + operationId: accessTokens_get + tags: + - access_tokens + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the project + required: true + schema: + oneOf: + - type: integer + - type: string + responses: + '404': + description: Not Found + '401': + description: Unauthorized operation + '200': + description: Successful operation + content: + application/json: + schema: + title: AccessTokenList + type: object + properties: + user_id: + type: integer + scopes: + type: array + name: + type: string + expires_at: + type: date + id: + type: integer + active: + type: boolean + created_at: + type: date + revoked: + type: boolean + example: + 'user_id': 141 + 'scopes': ['api'] + 'name': 'token' + 'expires_at': '2022-01-31' + 'id': 42 + 'active': true + 'created_at': '2021-01-20T14:13:35Z' + 'revoked': false + post: + description: Creates an access token for a project + summary: Creates an access token for a project + operationId: accessTokens_post + tags: + - access_tokens + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the project + required: true + schema: + oneOf: + - type: integer + - type: string + - name: name + in: query + description: The name of the project access token + required: true + schema: + type: string + - name: scopes + in: query + description: Defines read and write permissions for the token + required: true + schema: + type: array + items: + type: string + enum: + [ + 'api', + 'read_api', + 'read_registry', + 'write_registry', + 'read_repository', + 'write_repository', + ] + - name: expires_at + in: query + description: Date when the token expires. Time of day is Midnight UTC of that date. + required: false + schema: + type: date + responses: + '404': + description: Not Found + '401': + description: Unauthorized operation + '200': + description: Successful operation + content: + application/json: + schema: + title: AccessTokenList + type: object + properties: + user_id: + type: integer + scopes: + type: array + name: + type: string + expires_at: + type: date + id: + type: integer + active: + type: boolean + created_at: + type: date + revoked: + type: boolean + token: + type: string + example: + 'user_id': 166 + 'scopes': ['api', 'read_repository'] + 'name': 'test' + 'expires_at': '2022-01-31' + 'id': 58 + 'active': true + 'created_at': '2021-01-20T14:13:35Z' + 'revoked': false + 'token': 'D4y...Wzr' + +#/v4/projects/{id}/access_tokens/{token_id} +accessTokensRevoke: + delete: + description: Revokes an access token + summary: Revokes an access token + operationId: accessTokens_delete + tags: + - access_tokens + parameters: + - name: id + in: path + description: The ID or URL-encoded path of the project + required: true + schema: + oneOf: + - type: integer + - type: string + - name: token_id + in: path + description: The ID of the project access token + required: true + schema: + oneOf: + - type: integer + - type: string + responses: + '400': + description: Bad Request + '404': + description: Not Found + '204': + description: No content if successfully revoked diff --git a/doc/api/openapi/v4/access_requests.yaml b/doc/api/openapi/v4/access_requests.yaml deleted file mode 100644 index 157a0973e1e..00000000000 --- a/doc/api/openapi/v4/access_requests.yaml +++ /dev/null @@ -1,381 +0,0 @@ -# Markdown documentation: https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/access_requests.md - -#/v4/projects/{id}/access_requests -accessRequestsProjects: - get: - description: Lists access requests for a project - summary: List access requests for a project - operationId: accessRequestsProjects_get - tags: - - access_requests - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the project owned by the authenticated user. - required: true - schema: - oneOf: - - type: integer - - type: string - responses: - '401': - description: Unauthorized operation - '200': - description: Successful operation - content: - application/json: - schema: - title: ProjectAccessResponse - type: object - properties: - id: - type: integer - usename: - type: string - name: - type: string - state: - type: string - created_at: - type: string - requested_at: - type: string - example: - - "id": 1 - "username": "raymond_smith" - "name": "Raymond Smith" - "state": "active" - "created_at": "2012-10-22T14:13:35Z" - "requested_at": "2012-10-22T14:13:35Z" - - "id": 2 - "username": "john_doe" - "name": "John Doe" - "state": "active" - "created_at": "2012-10-22T14:13:35Z" - "requested_at": "2012-10-22T14:13:35Z" - post: - description: Requests access for the authenticated user to a project - summary: Requests access for the authenticated user to a project - operationId: accessRequestsProjects_post - tags: - - access_requests - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the project owned by the authenticated user. - required: true - schema: - oneOf: - - type: integer - - type: string - responses: - '401': - description: Unauthorized operation - '200': - description: Successful operation - content: - application/json: - schema: - title: ProjectAccessRequest - type: object - properties: - id: - type: integer - usename: - type: string - name: - type: string - state: - type: string - created_at: - type: string - requested_at: - type: string - example: - "id": 1 - "username": "raymond_smith" - "name": "Raymond Smith" - "state": "active" - "created_at": "2012-10-22T14:13:35Z" - "requested_at": "2012-10-22T14:13:35Z" - -#/v4/projects/{id}/access_requests/{user_id}/approve -accessRequestsProjectsApprove: - put: - description: Approves access for the authenticated user to a project - summary: Approves access for the authenticated user to a project - operationId: accessRequestsProjectsApprove_put - tags: - - access_requests - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the project owned by the authenticated user. - required: true - schema: - oneOf: - - type: integer - - type: string - - name: user_id - in: path - description: The userID of the access requester - required: true - schema: - type: integer - - name: access_level - in: query - description: A valid project access level. 0 = no access , 10 = guest, 20 = reporter, 30 = developer, 40 = Maintainer. Default is 30.' - required: false - schema: - enum: [0, 10, 20, 30, 40] - default: 30 - type: integer - responses: - '401': - description: Unauthorized operation - '200': - description: Successful operation - content: - application/json: - schema: - title: ProjectAccessApprove - type: object - properties: - id: - type: integer - usename: - type: string - name: - type: string - state: - type: string - created_at: - type: string - access_level: - type: integer - example: - "id": 1 - "username": "raymond_smith" - "name": "Raymond Smith" - "state": "active" - "created_at": "2012-10-22T14:13:35Z" - "access_level": 20 - -#/v4/projects/{id}/access_requests/{user_id} -accessRequestsProjectsDeny: - delete: - description: Denies a project access request for the given user - summary: Denies a project access request for the given user - operationId: accessRequestProjectsDeny_delete - tags: - - access_requests - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the project owned by the authenticated user. - required: true - schema: - oneOf: - - type: integer - - type: string - - name: user_id - in: path - description: The user ID of the access requester - required: true - schema: - type: integer - responses: # Does anything go here? Markdown doc does not list a response. - '401': - description: Unauthorized operation - '200': - description: Successful operation - -#/v4/groups/{id}/access_requests -accessRequestsGroups: - get: - description: List access requests for a group - summary: List access requests for a group - operationId: accessRequestsGroups_get - tags: - - access_requests - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the group owned by the authenticated user. - required: true - schema: - oneOf: - - type: integer - - type: string - responses: - '401': - description: Unauthorized operation - '200': - description: Successful operation - content: - application/json: - schema: - title: GroupAccessResponse - type: object - properties: - id: - type: integer - usename: - type: string - name: - type: string - state: - type: string - created_at: - type: string - requested_at: - type: string - example: - - "id": 1 - "username": "raymond_smith" - "name": "Raymond Smith" - "state": "active" - "created_at": "2012-10-22T14:13:35Z" - "requested_at": "2012-10-22T14:13:35Z" - - "id": 2 - "username": "john_doe" - "name": "John Doe" - "state": "active" - "created_at": "2012-10-22T14:13:35Z" - "requested_at": "2012-10-22T14:13:35Z" - post: - description: Requests access for the authenticated user to a group - summary: Requests access for the authenticated user to a group - operationId: accessRequestsGroups_post - tags: - - access_requests - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the group owned by the authenticated user. - required: true - schema: - oneOf: - - type: integer - - type: string - responses: - '401': - description: Unauthorized operation - '200': - description: Successful operation - content: - application/json: - schema: - title: GroupAccessRequest - type: object - properties: - id: - type: integer - usename: - type: string - name: - type: string - state: - type: string - created_at: - type: string - requested_at: - type: string - example: - "id": 1 - "username": "raymond_smith" - "name": "Raymond Smith" - "state": "active" - "created_at": "2012-10-22T14:13:35Z" - "requested_at": "2012-10-22T14:13:35Z" - -#/v4/groups/{id}/access_requests/{user_id}/approve -accessRequestsGroupsApprove: - put: - description: Approves access for the authenticated user to a group - summary: Approves access for the authenticated user to a group - operationId: accessRequestsGroupsApprove_put - tags: - - access_requests - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the group owned by the authenticated user. - required: true - schema: - oneOf: - - type: integer - - type: string - - name: user_id - in: path - description: The userID of the access requester - required: true - schema: - type: integer - - name: access_level - in: query - description: A valid group access level. 0 = no access , 10 = Guest, 20 = Reporter, 30 = Developer, 40 = Maintainer, 50 = Owner. Default is 30. - required: false - schema: - enum: [0, 10, 20, 30, 40, 50] - default: 30 - type: integer - responses: - '401': - description: Unauthorized operation - '200': - description: Successful operation - content: - application/json: - schema: - title: GroupAccessApprove - type: object - properties: - id: - type: integer - usename: - type: string - name: - type: string - state: - type: string - created_at: - type: string - access_level: - type: integer - example: - "id": 1 - "username": "raymond_smith" - "name": "Raymond Smith" - "state": "active" - "created_at": "2012-10-22T14:13:35Z" - "access_level": 20 - -#/v4/groups/{id}/access_requests/{user_id} -accessRequestsGroupsDeny: - delete: - description: Denies a group access request for the given user - summary: Denies a group access request for the given user - operationId: accessRequestsGroupsDeny_delete - tags: - - access_requests - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the group owned by the authenticated user. - required: true - schema: - oneOf: - - type: integer - - type: string - - name: user_id - in: path - description: The userID of the access requester - required: true - schema: - type: integer - responses: # Does anything go here? Markdown doc does not list a response. - '401': - description: Unauthorized operation - '200': - description: Successful operation diff --git a/doc/api/openapi/v4/access_tokens.yaml b/doc/api/openapi/v4/access_tokens.yaml deleted file mode 100644 index 9a1a6960eea..00000000000 --- a/doc/api/openapi/v4/access_tokens.yaml +++ /dev/null @@ -1,170 +0,0 @@ -# Markdown documentation: https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/resource_access_tokens.md - -#/v4/projects/{id}/access_tokens -accessTokens: - get: - description: Lists access tokens for a project - summary: List access tokens for a project - operationId: accessTokens_get - tags: - - access_tokens - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the project - required: true - schema: - oneOf: - - type: integer - - type: string - responses: - '404': - description: Not Found - '401': - description: Unauthorized operation - '200': - description: Successful operation - content: - application/json: - schema: - title: AccessTokenList - type: object - properties: - user_id: - type: integer - scopes: - type: array - name: - type: string - expires_at: - type: date - id: - type: integer - active: - type: boolean - created_at: - type: date - revoked: - type: boolean - example: - "user_id": 141 - "scopes" : ["api"] - "name": "token" - "expires_at": "2022-01-31" - "id": 42 - "active": true - "created_at": "2021-01-20T14:13:35Z" - "revoked" : false - post: - description: Creates an access token for a project - summary: Creates an access token for a project - operationId: accessTokens_post - tags: - - access_tokens - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the project - required: true - schema: - oneOf: - - type: integer - - type: string - - name: name - in: query - description: The name of the project access token - required: true - schema: - type: string - - name: scopes - in: query - description: Defines read and write permissions for the token - required: true - schema: - type: array - items: - type: string - enum: ["api", "read_api", "read_registry", "write_registry", "read_repository", "write_repository"] - - name: expires_at - in: query - description: Date when the token expires. Time of day is Midnight UTC of that date. - required: false - schema: - type: date - responses: - '404': - description: Not Found - '401': - description: Unauthorized operation - '200': - description: Successful operation - content: - application/json: - schema: - title: AccessTokenList - type: object - properties: - user_id: - type: integer - scopes: - type: array - name: - type: string - expires_at: - type: date - id: - type: integer - active: - type: boolean - created_at: - type: date - revoked: - type: boolean - token: - type: string - example: - "user_id": 166 - "scopes" : [ - "api", - "read_repository" - ] - "name": "test" - "expires_at": "2022-01-31" - "id": 58 - "active": true - "created_at": "2021-01-20T14:13:35Z" - "revoked" : false - "token" : "D4y...Wzr" - -#/v4/projects/{id}/access_tokens/{token_id} -accessTokensRevoke: - delete: - description: Revokes an access token - summary: Revokes an access token - operationId: accessTokens_delete - tags: - - access_tokens - parameters: - - name: id - in: path - description: The ID or URL-encoded path of the project - required: true - schema: - oneOf: - - type: integer - - type: string - - name: token_id - in: path - description: The ID of the project access token - required: true - schema: - oneOf: - - type: integer - - type: string - responses: - '400': - description: Bad Request - '404': - description: Not Found - '204': - description: No content if successfully revoked diff --git a/doc/api/openapi/v4/metadata.yaml b/doc/api/openapi/v4/metadata.yaml deleted file mode 100644 index 6a5ef9f3355..00000000000 --- a/doc/api/openapi/v4/metadata.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# Markdown documentation: https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/metadata.md - -get: - tags: - - metadata - summary: "Retrieve metadata information for this GitLab instance." - operationId: "getMetadata" - responses: - "401": - description: "unauthorized operation" - "200": - description: "successful operation" - content: - "application/json": - schema: - title: "MetadataResponse" - type: "object" - properties: - version: - type: "string" - revision: - type: "string" - kas: - type: "object" - properties: - enabled: - type: "boolean" - externalUrl: - type: "string" - nullable: true - version: - type: "string" - nullable: true - examples: - Example: - value: - version: "15.0-pre" - revision: "c401a659d0c" - kas: - enabled: true - externalUrl: "grpc://gitlab.example.com:8150" - version: "15.0.0" - diff --git a/doc/api/openapi/v4/version.yaml b/doc/api/openapi/v4/version.yaml deleted file mode 100644 index 3a689840f4c..00000000000 --- a/doc/api/openapi/v4/version.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# Markdown documentation: https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/api/version.md - -get: - tags: - - version - summary: "Retrieve version information for this GitLab instance." - operationId: "getVersion" - responses: - "401": - description: "unauthorized operation" - "200": - description: "successful operation" - content: - "application/json": - schema: - title: "VersionResponse" - type: "object" - properties: - version: - type: "string" - revision: - type: "string" - examples: - Example: - value: - version: "13.3.0-pre" - revision: "f2b05afebb0" - diff --git a/doc/update/deprecations.md b/doc/update/deprecations.md index a8762ed67c1..8e4da6c6bb5 100644 --- a/doc/update/deprecations.md +++ b/doc/update/deprecations.md @@ -1707,17 +1707,17 @@ only supported report file in 15.0, but this is the first step towards GitLab su </div> -<div class="deprecation removal-150 breaking-change"> +<div class="deprecation removal-160 breaking-change"> ### merged_by API field -Planned removal: GitLab <span class="removal-milestone">15.0</span> (2022-05-22) +Planned removal: GitLab <span class="removal-milestone">16.0</span> (2023-05-22) WARNING: This is a [breaking change](https://docs.gitlab.com/ee/development/deprecation_guidelines/). Review the details carefully before upgrading. -The `merged_by` field in the [merge request API](https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests) is being deprecated and will be removed in GitLab 15.0. This field is being replaced with the `merge_user` field (already present in GraphQL) which more correctly identifies who merged a merge request when performing actions (merge when pipeline succeeds, add to merge train) other than a simple merge. +The `merged_by` field in the [merge request API](https://docs.gitlab.com/ee/api/merge_requests.html#list-merge-requests) has been deprecated in favor of the `merge_user` field which more correctly identifies who merged a merge request when performing actions (merge when pipeline succeeds, add to merge train) other than a simple merge. API users are encouraged to use the new `merge_user` field instead. The `merged_by` field will be removed in v5 of the GitLab REST API. </div> </div> diff --git a/doc/user/discussions/index.md b/doc/user/discussions/index.md index c0cf584f8c3..106e4871736 100644 --- a/doc/user/discussions/index.md +++ b/doc/user/discussions/index.md @@ -45,7 +45,7 @@ mentions for yourself (the user currently signed in) are highlighted in a different color. Avoid mentioning `@all` in issues and merge requests. It sends an email notification -to all members of that project's parent group, not only the participants of the project, +to all members of that project's parent group, not only the participants of the project, and may be interpreted as spam. Notifications and mentions can be disabled in [a group's settings](../group/manage.md#disable-email-notifications). @@ -144,7 +144,7 @@ If you edit an existing comment to add a user mention that wasn't there before, - Creates a to-do item for the mentioned user. - Does not send a notification email. -## Prevent comments by locking an issue +## Prevent comments by locking the discussion You can prevent public comments in an issue or merge request. When you do, only project members can add and edit comments. @@ -154,6 +154,8 @@ Prerequisite: - In merge requests, you must have at least the Developer role. - In issues, you must have at least the Reporter role. +To lock an issue or merge request: + 1. On the right sidebar, next to **Lock issue** or **Lock merge request**, select **Edit**. 1. On the confirmation dialog, select **Lock**. @@ -161,6 +163,9 @@ Notes are added to the page details. If an issue or merge request is locked and closed, you cannot reopen it. +<!-- Delete when the `moved_mr_sidebar` feature flag is removed --> +If you don't see this action on the right sidebar, your project or instance might have [moved sidebar actions](../project/merge_requests/index.md#move-sidebar-actions) enabled. + ## Add an internal note > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/207473) in GitLab 13.9 [with a flag](../../administration/feature_flags.md) named `confidential_notes`. Disabled by default. diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md index 057fed3c2e0..fd5c037fe3a 100644 --- a/doc/user/profile/notifications.md +++ b/doc/user/profile/notifications.md @@ -228,20 +228,20 @@ to change their user notification settings to **Watch** instead. ### Edit notification settings for issues, merge requests, and epics -To enable notifications on a specific issue, merge request, or epic, you must turn on the -**Notifications** toggle in the right sidebar. +To toggle notifications on an issue, merge request, or epic: on the right sidebar, turn on or off the **Notifications** toggle. -- To subscribe, **turn on** if you are not a participant in the discussion, but want to receive - notifications on each update. +When you **turn on** notifications, you start receiving notifications on each update, even if you +haven't participated in the discussion. +When you turn notifications on in an epic, you aren't automatically subscribed to the issues linked +to the epic. - When you turn notifications on in an epic, you aren't automatically subscribed to the issues linked - to the epic. - -- To unsubscribe, **turn off** if you are receiving notifications for updates but no longer want to - receive them. +When you **turn off** notifications, you stop receiving notifications for updates. +Turning this toggle off only unsubscribes you from updates related to this issue, merge request, or epic. +Learn how to [opt out of all emails from GitLab](#opt-out-of-all-gitlab-emails). - Turning this toggle off only unsubscribes you from updates related to this issue, merge request, or epic. - Learn how to [opt out of all emails from GitLab](#opt-out-of-all-gitlab-emails). +<!-- Delete when the `moved_mr_sidebar` feature flag is removed --> +If you don't see this action on the right sidebar, your project or instance may have +enabled a feature flag for [moved sidebar actions](../project/merge_requests/index.md#move-sidebar-actions). ### Notification events on issues, merge requests, and epics diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md index e6b0da30bad..e8ec954df8f 100644 --- a/doc/user/project/members/index.md +++ b/doc/user/project/members/index.md @@ -61,7 +61,7 @@ To add a user to a project: 1. Select **Invite members**. 1. Enter an email address and select a [role](../../permissions.md). 1. Optional. Select an **Access expiration date**. - On that date, the user can no longer access the project. + From that date onwards, the user can no longer access the project. 1. Select **Invite**. If the user has a GitLab account, they are added to the members list. @@ -97,19 +97,20 @@ Each user's access is based on: - The role they're assigned in the group. - The maximum role you choose when you invite the group. -Prerequisite: +Prerequisites: - You must have the Maintainer or Owner role. - Sharing the project with other groups must not be [prevented](../../group/access_and_permissions.md#prevent-a-project-from-being-shared-with-groups). -To add groups to a project: +To add a group to a project: 1. On the top bar, select **Main menu > Projects** and find your project. 1. On the left sidebar, select **Project information > Members**. 1. Select **Invite a group**. 1. Select a group. 1. Select the highest [role](../../permissions.md) for users in the group. -1. Optional. Select an **Access expiration date**. On that date, the group can no longer access the project. +1. Optional. Select an **Access expiration date**. + From that date onwards, the group can no longer access the project. 1. Select **Invite**. The members of the group are not displayed on the **Members** tab. diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 872f7524cb7..8309b04758a 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -250,6 +250,28 @@ This feature works only when a merge request is merged. Selecting **Remove sourc after merging does not retarget open merge requests. This improvement is [proposed as a follow-up](https://gitlab.com/gitlab-org/gitlab/-/issues/321559). +## Move sidebar actions + +<!-- When the `moved_mr_sidebar` feature flag is removed, delete this topic and update the steps for these actions +like in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87727/diffs?diff_id=522279685#5d9afba799c4af9920dab533571d7abb8b9e9163 --> + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85584) in GitLab 14.10 [with a flag](../../../administration/feature_flags.md) named `moved_mr_sidebar`. 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 `moved_mr_sidebar`. +On GitLab.com, this feature is not available. + +When this feature flag is enabled, you can find the following actions in +**Merge request actions** (**{ellipsis_v}**) on the top right: + +- The [notifications](../../profile/notifications.md#edit-notification-settings-for-issues-merge-requests-and-epics) toggle +- Mark merge request as ready or [draft](../merge_requests/drafts.md) +- Close merge request +- [Lock discussion](../../discussions/index.md#prevent-comments-by-locking-the-discussion) +- Copy reference + +When this feature flag is disabled, these actions are in the right sidebar. + ## Merge request workflows For a software developer working in a team: diff --git a/lib/api/api.rb b/lib/api/api.rb index f0194f5dabd..fa10d796e00 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -173,6 +173,7 @@ module API mount ::API::AccessRequests mount ::API::Appearance mount ::API::BulkImports + mount ::API::Ci::ResourceGroups mount ::API::Ci::Runner mount ::API::Ci::Runners mount ::API::Clusters::Agents @@ -226,7 +227,6 @@ module API mount ::API::Ci::Jobs mount ::API::Ci::PipelineSchedules mount ::API::Ci::Pipelines - mount ::API::Ci::ResourceGroups mount ::API::Ci::SecureFiles mount ::API::Ci::Triggers mount ::API::Ci::Variables diff --git a/lib/api/ci/resource_groups.rb b/lib/api/ci/resource_groups.rb index 3ec2d08158e..a2b6b7fd68b 100644 --- a/lib/api/ci/resource_groups.rb +++ b/lib/api/ci/resource_groups.rb @@ -5,17 +5,27 @@ module API class ResourceGroups < ::API::Base include PaginationParams + ci_resource_groups_tags = %w[ci_resource_groups] + before { authenticate! } feature_category :continuous_delivery urgency :low params do - requires :id, types: [String, Integer], desc: 'The ID or URL-encoded path of the project' + requires :id, + types: [String, Integer], + desc: 'The ID or URL-encoded path of the project owned by the authenticated user' end resource :projects, requirements: ::API::API::NAMESPACE_OR_PROJECT_REQUIREMENTS do - desc 'Get all resource groups for this project' do + desc 'Get all resource groups for a project' do success Entities::Ci::ResourceGroup + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not found' } + ] + is_array true + tags ci_resource_groups_tags end params do use :pagination @@ -26,8 +36,13 @@ module API present paginate(user_project.resource_groups), with: Entities::Ci::ResourceGroup end - desc 'Get a single resource group' do + desc 'Get a specific resource group' do success Entities::Ci::ResourceGroup + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not found' } + ] + tags ci_resource_groups_tags end params do requires :key, type: String, desc: 'The key of the resource group' @@ -38,8 +53,14 @@ module API present resource_group, with: Entities::Ci::ResourceGroup end - desc 'List upcoming jobs of a resource group' do + desc 'List upcoming jobs for a specific resource group' do success Entities::Ci::JobBasic + failure [ + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not found' } + ] + is_array true + tags ci_resource_groups_tags end params do requires :key, type: String, desc: 'The key of the resource group' @@ -57,13 +78,23 @@ module API present paginate(upcoming_processables), with: Entities::Ci::JobBasic end - desc 'Edit a resource group' do + desc 'Edit an existing resource group' do + detail "Updates an existing resource group's properties." success Entities::Ci::ResourceGroup + failure [ + { code: 400, message: 'Bad request' }, + { code: 401, message: 'Unauthorized' }, + { code: 404, message: 'Not found' } + ] + tags ci_resource_groups_tags end params do requires :key, type: String, desc: 'The key of the resource group' - optional :process_mode, type: String, desc: 'The process mode', - values: ::Ci::ResourceGroup.process_modes.keys + + optional :process_mode, + type: String, + desc: 'The process mode of the resource group', + values: ::Ci::ResourceGroup.process_modes.keys end put ':id/resource_groups/:key' do authorize! :update_resource_group, resource_group diff --git a/lib/api/entities/ci/job_basic.rb b/lib/api/entities/ci/job_basic.rb index 26d91d11839..e5b04b86075 100644 --- a/lib/api/entities/ci/job_basic.rb +++ b/lib/api/entities/ci/job_basic.rb @@ -4,16 +4,26 @@ module API module Entities module Ci class JobBasic < Grape::Entity - expose :id, :status, :stage, :name, :ref, :tag, :coverage, :allow_failure - expose :created_at, :started_at, :finished_at + expose :id, documentation: { type: 'integer', example: 1 } + expose :status, documentation: { type: 'string', example: 'waiting_for_resource' } + expose :stage, documentation: { type: 'string', example: 'deploy' } + expose :name, documentation: { type: 'string', example: 'deploy_to_production' } + expose :ref, documentation: { type: 'string', example: 'main' } + expose :tag, documentation: { type: 'boolean' } + expose :coverage, documentation: { type: 'number', format: 'float', example: 0.90 } + expose :allow_failure, documentation: { type: 'boolean' } + expose :created_at, documentation: { type: 'dateTime', example: '2015-12-24T15:51:21.880Z' } + expose :started_at, documentation: { type: 'dateTime', example: '2015-12-24T17:54:30.733Z' } + expose :finished_at, documentation: { type: 'dateTime', example: '2015-12-24T17:54:31.198Z' } expose :duration, - documentation: { type: 'number', format: 'float', desc: 'Time spent running' } + documentation: { type: 'number', format: 'float', desc: 'Time spent running', example: 0.465 } expose :queued_duration, - documentation: { type: 'number', format: 'float', desc: 'Time spent enqueued' } + documentation: { type: 'number', format: 'float', desc: 'Time spent enqueued', example: 0.123 } expose :user, with: ::API::Entities::User expose :commit, with: ::API::Entities::Commit expose :pipeline, with: ::API::Entities::Ci::PipelineBasic - expose :failure_reason, if: -> (job) { job.failed? } + expose :failure_reason, + documentation: { type: 'string', example: 'script_failure' }, if: -> (job) { job.failed? } expose :web_url do |job, _options| Gitlab::Routing.url_helpers.project_job_url(job.project, job) diff --git a/lib/api/entities/ci/resource_group.rb b/lib/api/entities/ci/resource_group.rb index 0afadfa9e2a..c14e32d32b1 100644 --- a/lib/api/entities/ci/resource_group.rb +++ b/lib/api/entities/ci/resource_group.rb @@ -4,7 +4,11 @@ module API module Entities module Ci class ResourceGroup < Grape::Entity - expose :id, :key, :process_mode, :created_at, :updated_at + expose :id, documentation: { type: 'integer', example: 1 } + expose :key, documentation: { type: 'string', example: 'production' } + expose :process_mode, documentation: { type: 'string', example: 'unordered' } + expose :created_at, documentation: { type: 'dateTime', example: '2021-09-01T08:04:59.650Z' } + expose :updated_at, documentation: { type: 'dateTime', example: '2021-09-01T08:04:59.650Z' } end end end diff --git a/lib/gitlab/ci/config/external/file/base.rb b/lib/gitlab/ci/config/external/file/base.rb index 89da0796906..48ba154862a 100644 --- a/lib/gitlab/ci/config/external/file/base.rb +++ b/lib/gitlab/ci/config/external/file/base.rb @@ -47,12 +47,9 @@ module Gitlab end def validate! - context.logger.instrument(:config_file_validation) do - validate_execution_time! - validate_location! - validate_content! if errors.none? - validate_hash! if errors.none? - end + validate_location! + fetch_and_validate_content! if valid? + load_and_validate_expanded_hash! if valid? end def metadata @@ -72,32 +69,36 @@ module Gitlab protected - def expanded_content_hash - return unless content_hash - - strong_memoize(:expanded_content_yaml) do - expand_includes(content_hash) + def validate_location! + if invalid_location_type? + errors.push("Included file `#{masked_location}` needs to be a string") + elsif invalid_extension? + errors.push("Included file `#{masked_location}` does not have YAML extension!") end end - def content_hash - strong_memoize(:content_yaml) do - ::Gitlab::Ci::Config::Yaml.load!(content) + def fetch_and_validate_content! + context.logger.instrument(:config_file_fetch_content) do + content # calling the method fetches then memoizes the result end - rescue Gitlab::Config::Loader::FormatError - nil - end - def validate_execution_time! - context.check_execution_time! + return if errors.any? + + context.logger.instrument(:config_file_validate_content) do + validate_content! + end end - def validate_location! - if invalid_location_type? - errors.push("Included file `#{masked_location}` needs to be a string") - elsif invalid_extension? - errors.push("Included file `#{masked_location}` does not have YAML extension!") + def load_and_validate_expanded_hash! + context.logger.instrument(:config_file_fetch_content_hash) do + content_hash # calling the method loads then memoizes the result + end + + context.logger.instrument(:config_file_expand_content_includes) do + expanded_content_hash # calling the method expands then memoizes the result end + + validate_hash! end def validate_content! @@ -106,6 +107,22 @@ module Gitlab end end + def content_hash + strong_memoize(:content_yaml) do + ::Gitlab::Ci::Config::Yaml.load!(content) + end + rescue Gitlab::Config::Loader::FormatError + nil + end + + def expanded_content_hash + return unless content_hash + + strong_memoize(:expanded_content_yaml) do + expand_includes(content_hash) + end + end + def validate_hash! if to_hash.blank? errors.push("Included file `#{masked_location}` does not have valid YAML syntax!") diff --git a/lib/gitlab/ci/config/external/mapper.rb b/lib/gitlab/ci/config/external/mapper.rb index 2a1060a6059..cd752694e74 100644 --- a/lib/gitlab/ci/config/external/mapper.rb +++ b/lib/gitlab/ci/config/external/mapper.rb @@ -127,6 +127,7 @@ module Gitlab def verify!(location_object) verify_max_includes! + verify_execution_time! location_object.validate! expandset.add(location_object) end @@ -137,6 +138,10 @@ module Gitlab end end + def verify_execution_time! + context.check_execution_time! + end + def expand_variables(data) logger.instrument(:config_mapper_variables) do expand_variables_without_instrumentation(data) diff --git a/lib/gitlab/ci/pipeline/chain/populate.rb b/lib/gitlab/ci/pipeline/chain/populate.rb index 3f8745b4ab1..654e24be8e1 100644 --- a/lib/gitlab/ci/pipeline/chain/populate.rb +++ b/lib/gitlab/ci/pipeline/chain/populate.rb @@ -25,8 +25,6 @@ module Gitlab return error('Failed to build the pipeline!') end - set_pipeline_name - raise Populate::PopulateError if pipeline.persisted? end @@ -36,21 +34,6 @@ module Gitlab private - def set_pipeline_name - return if Feature.disabled?(:pipeline_name, pipeline.project) || - @command.yaml_processor_result.workflow_name.blank? - - name = @command.yaml_processor_result.workflow_name - name = ExpandVariables.expand(name, -> { global_context.variables.sort_and_expand_all }) - - pipeline.build_pipeline_metadata(project: pipeline.project, name: name) - end - - def global_context - Gitlab::Ci::Build::Context::Global.new( - pipeline, yaml_variables: @command.pipeline_seed.root_variables) - end - def stage_names # We filter out `.pre/.post` stages, as they alone are not considered # a complete pipeline: diff --git a/lib/gitlab/ci/pipeline/chain/populate_metadata.rb b/lib/gitlab/ci/pipeline/chain/populate_metadata.rb new file mode 100644 index 00000000000..35b907b669c --- /dev/null +++ b/lib/gitlab/ci/pipeline/chain/populate_metadata.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Chain + class PopulateMetadata < Chain::Base + include Chain::Helpers + + def perform! + set_pipeline_name + return if pipeline.pipeline_metadata.nil? || pipeline.pipeline_metadata.valid? + + message = pipeline.pipeline_metadata.errors.full_messages.join(', ') + error("Failed to build pipeline metadata! #{message}") + end + + def break? + pipeline.pipeline_metadata&.errors&.any? + end + + private + + def set_pipeline_name + return if Feature.disabled?(:pipeline_name, pipeline.project) || + @command.yaml_processor_result.workflow_name.blank? + + name = @command.yaml_processor_result.workflow_name + name = ExpandVariables.expand(name, -> { global_context.variables.sort_and_expand_all }) + + pipeline.build_pipeline_metadata(project: pipeline.project, name: name) + end + + def global_context + Gitlab::Ci::Build::Context::Global.new( + pipeline, yaml_variables: @command.pipeline_seed.root_variables) + end + end + end + end + end +end diff --git a/lib/gitlab/email/common.rb b/lib/gitlab/email/common.rb new file mode 100644 index 00000000000..afee8d9cd3d --- /dev/null +++ b/lib/gitlab/email/common.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Gitlab + module Email + # Contains common methods which must be present in all email classes + module Common + UNSUBSCRIBE_SUFFIX = '-unsubscribe' + UNSUBSCRIBE_SUFFIX_LEGACY = '+unsubscribe' + WILDCARD_PLACEHOLDER = '%{key}' + + # This can be overridden for a custom config + def config + raise NotImplementedError + end + + def incoming_email_config + Gitlab.config.incoming_email + end + + def enabled? + !!config&.enabled && config.address.present? + end + + def supports_wildcard? + config_address = incoming_email_config.address + + config_address.present? && config_address.include?(WILDCARD_PLACEHOLDER) + end + + def supports_issue_creation? + enabled? && supports_wildcard? + end + + def reply_address(key) + incoming_email_config.address.sub(WILDCARD_PLACEHOLDER, key) + end + + # example: incoming+1234567890abcdef1234567890abcdef-unsubscribe@incoming.gitlab.com + def unsubscribe_address(key) + incoming_email_config.address.sub(WILDCARD_PLACEHOLDER, "#{key}#{UNSUBSCRIBE_SUFFIX}") + end + + def key_from_address(address, wildcard_address: nil) + raise NotImplementedError + end + + def key_from_fallback_message_id(mail_id) + message_id_regexp = /\Areply-(.+)@#{Gitlab.config.gitlab.host}\z/ + + mail_id[message_id_regexp, 1] + end + + def scan_fallback_references(references) + # It's looking for each <...> + references.scan(/(?!<)[^<>]+(?=>)/) + end + end + end +end diff --git a/lib/gitlab/email/handler/create_issue_handler.rb b/lib/gitlab/email/handler/create_issue_handler.rb index 434893eab82..e21a88c4e0d 100644 --- a/lib/gitlab/email/handler/create_issue_handler.rb +++ b/lib/gitlab/email/handler/create_issue_handler.rb @@ -73,7 +73,7 @@ module Gitlab end def can_handle_legacy_format? - project_path && !incoming_email_token.include?('+') && !mail_key.include?(Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY) + project_path && !incoming_email_token.include?('+') && !mail_key.include?(Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY) end end end diff --git a/lib/gitlab/email/handler/unsubscribe_handler.rb b/lib/gitlab/email/handler/unsubscribe_handler.rb index 528857aff14..a4e526d9a24 100644 --- a/lib/gitlab/email/handler/unsubscribe_handler.rb +++ b/lib/gitlab/email/handler/unsubscribe_handler.rb @@ -12,8 +12,8 @@ module Gitlab delegate :project, to: :sent_notification, allow_nil: true HANDLER_REGEX_FOR = -> (suffix) { /\A(?<reply_token>\w+)#{Regexp.escape(suffix)}\z/ }.freeze - HANDLER_REGEX = HANDLER_REGEX_FOR.call(Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX).freeze - HANDLER_REGEX_LEGACY = HANDLER_REGEX_FOR.call(Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY).freeze + HANDLER_REGEX = HANDLER_REGEX_FOR.call(Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX).freeze + HANDLER_REGEX_LEGACY = HANDLER_REGEX_FOR.call(Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY).freeze def initialize(mail, mail_key) super(mail, mail_key) diff --git a/lib/gitlab/incoming_email.rb b/lib/gitlab/incoming_email.rb index d55906083ff..d34c19bc9fc 100644 --- a/lib/gitlab/incoming_email.rb +++ b/lib/gitlab/incoming_email.rb @@ -2,30 +2,11 @@ module Gitlab module IncomingEmail - UNSUBSCRIBE_SUFFIX = '-unsubscribe' - UNSUBSCRIBE_SUFFIX_LEGACY = '+unsubscribe' - WILDCARD_PLACEHOLDER = '%{key}' - class << self - def enabled? - config.enabled && config.address.present? - end + include Gitlab::Email::Common - def supports_wildcard? - config.address.present? && config.address.include?(WILDCARD_PLACEHOLDER) - end - - def supports_issue_creation? - enabled? && supports_wildcard? - end - - def reply_address(key) - config.address.sub(WILDCARD_PLACEHOLDER, key) - end - - # example: incoming+1234567890abcdef1234567890abcdef-unsubscribe@incoming.gitlab.com - def unsubscribe_address(key) - config.address.sub(WILDCARD_PLACEHOLDER, "#{key}#{UNSUBSCRIBE_SUFFIX}") + def config + incoming_email_config end def key_from_address(address, wildcard_address: nil) @@ -39,21 +20,6 @@ module Gitlab match[1] end - def key_from_fallback_message_id(mail_id) - message_id_regexp = /\Areply\-(.+)@#{Gitlab.config.gitlab.host}\z/ - - mail_id[message_id_regexp, 1] - end - - def scan_fallback_references(references) - # It's looking for each <...> - references.scan(/(?!<)[^<>]+(?=>)/) - end - - def config - Gitlab.config.incoming_email - end - private def address_regex(wildcard_address) diff --git a/lib/gitlab/service_desk_email.rb b/lib/gitlab/service_desk_email.rb index 14f07140825..bc49efafdda 100644 --- a/lib/gitlab/service_desk_email.rb +++ b/lib/gitlab/service_desk_email.rb @@ -3,8 +3,10 @@ module Gitlab module ServiceDeskEmail class << self - def enabled? - !!config&.enabled && config&.address.present? + include Gitlab::Email::Common + + def config + Gitlab.config.service_desk_email end def key_from_address(address) @@ -14,20 +16,10 @@ module Gitlab Gitlab::IncomingEmail.key_from_address(address, wildcard_address: wildcard_address) end - def config - Gitlab.config.service_desk_email - end - def address_for_key(key) return if config.address.blank? - config.address.sub(Gitlab::IncomingEmail::WILDCARD_PLACEHOLDER, key) - end - - def key_from_fallback_message_id(mail_id) - message_id_regexp = /\Areply\-(.+)@#{Gitlab.config.gitlab.host}\z/ - - mail_id[message_id_regexp, 1] + config.address.sub(WILDCARD_PLACEHOLDER, key) end end end diff --git a/lib/gitlab/sidekiq_migrate_jobs.rb b/lib/gitlab/sidekiq_migrate_jobs.rb index 9811e1d53d2..2467dd7ca43 100644 --- a/lib/gitlab/sidekiq_migrate_jobs.rb +++ b/lib/gitlab/sidekiq_migrate_jobs.rb @@ -3,6 +3,7 @@ module Gitlab class SidekiqMigrateJobs LOG_FREQUENCY = 1_000 + LOG_FREQUENCY_QUEUES = 10 attr_reader :logger, :mappings @@ -72,7 +73,7 @@ module Gitlab migrated = 0 while queue_length(queue_from) > 0 begin - if migrated >= 0 && migrated % LOG_FREQUENCY == 0 + if migrated >= 0 && migrated % LOG_FREQUENCY_QUEUES == 0 logger&.info("Migrating from #{queue_from}. Total: #{queue_length(queue_from)}. Migrated: #{migrated}.") end diff --git a/scripts/rspec_helpers.sh b/scripts/rspec_helpers.sh index 3c96635896e..20faa9acaa9 100644 --- a/scripts/rspec_helpers.sh +++ b/scripts/rspec_helpers.sh @@ -11,7 +11,6 @@ function retrieve_tests_metadata() { if [[ ! -f "${FLAKY_RSPEC_SUITE_REPORT_PATH}" ]]; then curl --location -o "${FLAKY_RSPEC_SUITE_REPORT_PATH}" "https://gitlab-org.gitlab.io/gitlab/${FLAKY_RSPEC_SUITE_REPORT_PATH}" || - curl --location -o "${FLAKY_RSPEC_SUITE_REPORT_PATH}" "https://gitlab-org.gitlab.io/gitlab/rspec_flaky/report-suite.json" || # temporary back-compat echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}" fi else @@ -35,13 +34,7 @@ function retrieve_tests_metadata() { if [[ ! -f "${FLAKY_RSPEC_SUITE_REPORT_PATH}" ]]; then scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${FLAKY_RSPEC_SUITE_REPORT_PATH}" || - scripts/api/download_job_artifact.rb --endpoint "https://gitlab.com/api/v4" --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "rspec_flaky/report-suite.json" || # temporary back-compat echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}" - - # temporary back-compat - if [[ -f "rspec_flaky/report-suite.json" ]]; then - mv "rspec_flaky/report-suite.json" "${FLAKY_RSPEC_SUITE_REPORT_PATH}" - fi fi else echo "test_metadata_job_id couldn't be found!" diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index af1b94b925b..75df85f362f 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -101,6 +101,35 @@ RSpec.describe "Issues > User edits issue", :js do visit project_issue_path(project, issue) end + describe 'edit description' do + def click_edit_issue_description + click_on 'Edit title and description' + end + + it 'places focus on the web editor' do + toggle_editing_mode_selector = '[data-testid="toggle-editing-mode-button"] label' + content_editor_focused_selector = '[data-testid="content-editor"].is-focused' + markdown_field_focused_selector = 'textarea:focus' + click_edit_issue_description + + expect(page).to have_selector(markdown_field_focused_selector) + + find(toggle_editing_mode_selector, text: 'Rich text').click + + expect(page).not_to have_selector(content_editor_focused_selector) + + refresh + + click_edit_issue_description + + expect(page).to have_selector(content_editor_focused_selector) + + find(toggle_editing_mode_selector, text: 'Source').click + + expect(page).not_to have_selector(markdown_field_focused_selector) + end + end + describe 'update labels' do it 'will not send ajax request when no data is changed' do page.within '.labels' do diff --git a/spec/frontend/issues/show/components/fields/description_spec.js b/spec/frontend/issues/show/components/fields/description_spec.js index cd4d422583b..273ddfdd5d4 100644 --- a/spec/frontend/issues/show/components/fields/description_spec.js +++ b/spec/frontend/issues/show/components/fields/description_spec.js @@ -86,7 +86,7 @@ describe('Description field component', () => { renderMarkdownPath: '/', markdownDocsPath: '/', quickActionsDocsPath: expect.any(String), - initOnAutofocus: true, + autofocus: true, supportsQuickActions: true, enableAutocomplete: true, }), diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js index e6d81d4a28f..bcc8e41fce8 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js @@ -58,6 +58,12 @@ describe('registry_header', () => { describe('sub header parts', () => { describe('images count', () => { + it('does not exist', async () => { + await mountComponent({ imagesCount: 0 }); + + expect(findImagesCountSubHeader().exists()).toBe(false); + }); + it('exists', async () => { await mountComponent({ imagesCount: 1 }); 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 0f947e84e0f..67d0fbdd9d1 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_form_spec.js @@ -116,7 +116,7 @@ describe('WikiForm', () => { renderMarkdownPath: pageInfoPersisted.markdownPreviewPath, markdownDocsPath: pageInfoPersisted.markdownHelpPath, uploadsPath: pageInfoPersisted.uploadsPath, - initOnAutofocus: pageInfoPersisted.persisted, + autofocus: pageInfoPersisted.persisted, formFieldId: 'wiki_content', formFieldName: 'wiki[content]', }), 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 f7e93f45148..625e67c7cc1 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -27,7 +27,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { const formFieldAriaLabel = 'Edit your content'; let mock; - const buildWrapper = ({ propsData = {}, attachTo } = {}) => { + const buildWrapper = ({ propsData = {}, attachTo, stubs = {} } = {}) => { wrapper = mountExtended(MarkdownEditor, { attachTo, propsData: { @@ -45,6 +45,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { }, stubs: { BubbleMenu: stubComponent(BubbleMenu), + ...stubs, }, }); }; @@ -138,9 +139,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect(wrapper.emitted('input')).toEqual([[newValue]]); }); - describe('when initOnAutofocus is true', () => { + describe('when autofocus is true', () => { beforeEach(async () => { - buildWrapper({ attachTo: document.body, propsData: { initOnAutofocus: true } }); + buildWrapper({ attachTo: document.body, propsData: { autofocus: true } }); await nextTick(); }); @@ -171,7 +172,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => { renderMarkdown: expect.any(Function), uploadsPath: window.uploads_path, markdown: value, - autofocus: 'end', }), ); }); @@ -204,10 +204,12 @@ describe('vue_shared/component/markdown/markdown_editor', () => { findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); }); - describe('when initOnAutofocus is true', () => { + describe('when autofocus is true', () => { beforeEach(() => { - buildWrapper({ propsData: { initOnAutofocus: true } }); - findLocalStorageSync().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); + buildWrapper({ + propsData: { autofocus: true }, + stubs: { ContentEditor: stubComponent(ContentEditor) }, + }); }); it('sets the content editor autofocus property to end', () => { @@ -247,19 +249,6 @@ describe('vue_shared/component/markdown/markdown_editor', () => { it('updates localStorage value', () => { expect(findLocalStorageSync().props().value).toBe(EDITING_MODE_MARKDOWN_FIELD); }); - - it('sets the textarea as the activeElement in the document', async () => { - // The component should be rebuilt to attach it to the document body - buildWrapper({ attachTo: document.body }); - await findSegmentedControl().vm.$emit('input', EDITING_MODE_CONTENT_EDITOR); - - expect(findContentEditor().exists()).toBe(true); - - await findSegmentedControl().vm.$emit('input', EDITING_MODE_MARKDOWN_FIELD); - await findSegmentedControl().vm.$emit('change', EDITING_MODE_MARKDOWN_FIELD); - - expect(document.activeElement).toBe(findTextarea().element); - }); }); describe('when content editor emits loading event', () => { diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index 4a9978bbadf..fafd129d356 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -12,10 +12,12 @@ import WorkItemDescription from '~/work_items/components/work_item_description.v import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { updateWorkItemMutationResponse, workItemResponseFactory, workItemQueryResponse, + projectWorkItemResponse, } from '../mock_data'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); @@ -29,6 +31,8 @@ describe('WorkItemDescription', () => { Vue.use(VueApollo); const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); + let workItemResponseHandler; const findEditButton = () => wrapper.find('[data-testid="edit-description"]'); const findMarkdownField = () => wrapper.findComponent(MarkdownField); @@ -44,18 +48,24 @@ describe('WorkItemDescription', () => { canUpdate = true, workItemResponse = workItemResponseFactory({ canUpdate }), isEditing = false, + fetchByIid = false, } = {}) => { - const workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); + workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); const { id } = workItemQueryResponse.data.workItem; wrapper = shallowMount(WorkItemDescription, { apolloProvider: createMockApollo([ [workItemQuery, workItemResponseHandler], [updateWorkItemMutation, mutationHandler], + [workItemByIidQuery, workItemByIidResponseHandler], ]), propsData: { workItemId: id, fullPath: 'test-project-path', + queryVariables: { + id: workItemId, + }, + fetchByIid, }, stubs: { MarkdownField, @@ -242,4 +252,20 @@ describe('WorkItemDescription', () => { expect(updateDraft).toHaveBeenCalled(); }); }); + + it('calls the global ID work item query when `fetchByIid` prop is false', async () => { + createComponent({ fetchByIid: false }); + await waitForPromises(); + + expect(workItemResponseHandler).toHaveBeenCalled(); + expect(workItemByIidResponseHandler).not.toHaveBeenCalled(); + }); + + it('calls the IID work item query when when `fetchByIid` prop is true', async () => { + createComponent({ fetchByIid: true }); + await waitForPromises(); + + expect(workItemResponseHandler).not.toHaveBeenCalled(); + expect(workItemByIidResponseHandler).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index 6b1ef8971d3..4029e47c390 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -86,6 +86,7 @@ describe('WorkItemDetailModal component', () => { isModal: true, workItemId: defaultPropsData.workItemId, workItemParentId: defaultPropsData.issueGid, + iid: null, }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 1e22fb42a83..6dbca7086cc 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -24,6 +24,7 @@ import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; import WorkItemInformation from '~/work_items/components/work_item_information.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import workItemDatesSubscription from '~/work_items/graphql/work_item_dates.subscription.graphql'; import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql'; @@ -37,6 +38,7 @@ import { workItemResponseFactory, workItemTitleSubscriptionResponse, workItemAssigneesSubscriptionResponse, + projectWorkItemResponse, } from '../mock_data'; describe('WorkItemDetail component', () => { @@ -52,6 +54,7 @@ describe('WorkItemDetail component', () => { canDelete: true, }); const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const successByIidHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse); const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); const assigneesSubscriptionHandler = jest @@ -87,12 +90,15 @@ describe('WorkItemDetail component', () => { error = undefined, includeWidgets = false, workItemsMvc2Enabled = false, + fetchByIid = false, + iidPathQueryParam = undefined, } = {}) => { const handlers = [ [workItemQuery, handler], [workItemTitleSubscription, subscriptionHandler], [workItemDatesSubscription, datesSubscriptionHandler], [workItemAssigneesSubscription, assigneesSubscriptionHandler], + [workItemByIidQuery, successByIidHandler], confidentialityMock, ]; @@ -104,7 +110,7 @@ describe('WorkItemDetail component', () => { typePolicies: includeWidgets ? config.cacheConfig.typePolicies : {}, }, ), - propsData: { isModal, workItemId }, + propsData: { isModal, workItemId, iid: '1' }, data() { return { updateInProgress, @@ -114,15 +120,24 @@ describe('WorkItemDetail component', () => { provide: { glFeatures: { workItemsMvc2: workItemsMvc2Enabled, + useIidInWorkItemsPath: fetchByIid, }, hasIssueWeightsFeature: true, hasIterationsFeature: true, projectNamespace: 'namespace', + fullPath: 'group/project', }, stubs: { WorkItemWeight: true, WorkItemIteration: true, }, + mocks: { + $route: { + query: { + iid_path: iidPathQueryParam, + }, + }, + }, }); }; @@ -421,8 +436,9 @@ describe('WorkItemDetail component', () => { }); describe('subscriptions', () => { - it('calls the title subscription', () => { + it('calls the title subscription', async () => { createComponent(); + await waitForPromises(); expect(titleSubscriptionHandler).toHaveBeenCalledWith({ issuableId: workItemQueryResponse.data.workItem.id, @@ -571,4 +587,35 @@ describe('WorkItemDetail component', () => { expect(findWorkItemInformationAlert().exists()).toBe(false); }); }); + + it('calls the global ID work item query when `useIidInWorkItemsPath` feature flag is false', async () => { + createComponent(); + await waitForPromises(); + + expect(successHandler).toHaveBeenCalledWith({ + id: workItemQueryResponse.data.workItem.id, + }); + expect(successByIidHandler).not.toHaveBeenCalled(); + }); + + it('calls the global ID work item query when `useIidInWorkItemsPath` feature flag is true but there is no `iid_path` parameter in URL', async () => { + createComponent({ fetchByIid: true }); + await waitForPromises(); + + expect(successHandler).toHaveBeenCalledWith({ + id: workItemQueryResponse.data.workItem.id, + }); + expect(successByIidHandler).not.toHaveBeenCalled(); + }); + + it('calls the IID work item query when `useIidInWorkItemsPath` feature flag is true and `iid_path` route parameter is present', async () => { + createComponent({ fetchByIid: true, iidPathQueryParam: 'true' }); + await waitForPromises(); + + expect(successHandler).not.toHaveBeenCalled(); + expect(successByIidHandler).toHaveBeenCalledWith({ + fullPath: 'group/project', + iid: '1', + }); + }); }); 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 e6ff7e8502d..9f7659b3f8d 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -9,6 +9,7 @@ import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widg import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import { i18n, I18N_WORK_ITEM_ERROR_FETCHING_LABELS } from '~/work_items/constants'; import { @@ -18,6 +19,7 @@ import { workItemResponseFactory, updateWorkItemMutationResponse, workItemLabelsSubscriptionResponse, + projectWorkItemResponse, } from '../mock_data'; Vue.use(VueApollo); @@ -33,6 +35,7 @@ describe('WorkItemLabels component', () => { const findLabelsTitle = () => wrapper.findByTestId('labels-title'); const workItemQuerySuccess = jest.fn().mockResolvedValue(workItemQueryResponse); + const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); const successUpdateWorkItemMutationHandler = jest .fn() @@ -45,12 +48,14 @@ describe('WorkItemLabels component', () => { workItemQueryHandler = workItemQuerySuccess, searchQueryHandler = successSearchQueryHandler, updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, + fetchByIid = false, } = {}) => { const apolloProvider = createMockApollo([ [workItemQuery, workItemQueryHandler], [labelSearchQuery, searchQueryHandler], [updateWorkItemMutation, updateWorkItemMutationHandler], [workItemLabelsSubscription, subscriptionHandler], + [workItemByIidQuery, workItemByIidResponseHandler], ]); wrapper = mountExtended(WorkItemLabels, { @@ -58,6 +63,10 @@ describe('WorkItemLabels component', () => { workItemId, canUpdate, fullPath: 'test-project-path', + queryVariables: { + id: workItemId, + }, + fetchByIid, }, attachTo: document.body, apolloProvider, @@ -226,4 +235,20 @@ describe('WorkItemLabels component', () => { }); }); }); + + it('calls the global ID work item query when `fetchByIid` prop is false', async () => { + createComponent({ fetchByIid: false }); + await waitForPromises(); + + expect(workItemQuerySuccess).toHaveBeenCalled(); + expect(workItemByIidResponseHandler).not.toHaveBeenCalled(); + }); + + it('calls the IID work item query when when `fetchByIid` prop is true', async () => { + createComponent({ fetchByIid: true }); + await waitForPromises(); + + expect(workItemQuerySuccess).not.toHaveBeenCalled(); + expect(workItemByIidResponseHandler).toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index ed90b11222a..d6fcd029990 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -41,6 +41,7 @@ export const workItemQueryResponse = { workItem: { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', + iid: '1', title: 'Test', state: 'OPEN', description: 'description', @@ -113,6 +114,7 @@ export const updateWorkItemMutationResponse = { workItem: { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', + iid: '1', title: 'Updated title', state: 'OPEN', description: 'description', @@ -199,6 +201,7 @@ export const workItemResponseFactory = ({ workItem: { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', + iid: 1, title: 'Updated title', state: 'OPEN', description: 'description', @@ -331,6 +334,7 @@ export const createWorkItemMutationResponse = { workItem: { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1', + iid: '1', title: 'Updated title', state: 'OPEN', description: 'description', @@ -368,6 +372,7 @@ export const createWorkItemFromTaskMutationResponse = { __typename: 'WorkItem', description: 'New description', id: 'gid://gitlab/WorkItem/1', + iid: '1', title: 'Updated title', state: 'OPEN', confidential: false, @@ -405,6 +410,7 @@ export const createWorkItemFromTaskMutationResponse = { newWorkItem: { __typename: 'WorkItem', id: 'gid://gitlab/WorkItem/1000000', + iid: '100', title: 'Updated title', state: 'OPEN', createdAt: '2022-08-03T12:41:54Z', @@ -776,6 +782,7 @@ export const changeWorkItemParentMutationResponse = { }, description: null, id: 'gid://gitlab/WorkItem/2', + iid: '2', state: 'OPEN', title: 'Foo', confidential: false, @@ -1122,3 +1129,14 @@ export const projectMilestonesResponseWithNoMilestones = { }, }, }; + +export const projectWorkItemResponse = { + data: { + workspace: { + id: 'gid://gitlab/Project/1', + workItems: { + nodes: [workItemQueryResponse.data.workItem], + }, + }, + }, +}; diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index 15dac25b7d9..387c8a355fa 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -37,12 +37,17 @@ describe('Create work item component', () => { props = {}, queryHandler = querySuccessHandler, mutationHandler = createWorkItemSuccessHandler, + fetchByIid = false, } = {}) => { - fakeApollo = createMockApollo([ - [projectWorkItemTypesQuery, queryHandler], - [createWorkItemMutation, mutationHandler], - [createWorkItemFromTaskMutation, mutationHandler], - ]); + fakeApollo = createMockApollo( + [ + [projectWorkItemTypesQuery, queryHandler], + [createWorkItemMutation, mutationHandler], + [createWorkItemFromTaskMutation, mutationHandler], + ], + {}, + { typePolicies: { Project: { merge: true } } }, + ); wrapper = shallowMount(CreateWorkItem, { apolloProvider: fakeApollo, data() { @@ -61,6 +66,9 @@ describe('Create work item component', () => { }, provide: { fullPath: 'full-path', + glFeatures: { + useIidInWorkItemsPath: fetchByIid, + }, }, }); }; @@ -99,7 +107,12 @@ describe('Create work item component', () => { wrapper.find('form').trigger('submit'); await waitForPromises(); - expect(wrapper.vm.$router.push).toHaveBeenCalled(); + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + name: 'workItem', + params: { + id: '1', + }, + }); }); it('adds right margin for create button', () => { @@ -197,4 +210,18 @@ describe('Create work item component', () => { 'Something went wrong when creating work item. Please try again.', ); }); + + it('performs a correct redirect when `useIidInWorkItemsPath` feature flag is enabled', async () => { + createComponent({ fetchByIid: true }); + findTitleInput().vm.$emit('title-input', 'Test title'); + + wrapper.find('form').trigger('submit'); + await waitForPromises(); + + expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ + name: 'workItem', + params: { id: '1' }, + query: { iid_path: 'true' }, + }); + }); }); diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index d9372f2bcf0..880c4271024 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -55,6 +55,7 @@ describe('Work items root component', () => { isModal: false, workItemId: 'gid://gitlab/WorkItem/1', workItemParentId: null, + iid: '1', }); }); @@ -65,11 +66,15 @@ describe('Work items root component', () => { deleteWorkItemHandler, }); - findWorkItemDetail().vm.$emit('deleteWorkItem'); + findWorkItemDetail().vm.$emit('deleteWorkItem', { workItemType: 'task', workItemId: '1' }); await waitForPromises(); - expect(deleteWorkItemHandler).toHaveBeenCalled(); + expect(deleteWorkItemHandler).toHaveBeenCalledWith({ + input: { + id: '1', + }, + }); expect(mockToastShow).toHaveBeenCalled(); expect(visitUrl).toHaveBeenCalledWith(issuesListPath); }); @@ -81,7 +86,7 @@ describe('Work items root component', () => { deleteWorkItemHandler, }); - findWorkItemDetail().vm.$emit('deleteWorkItem'); + findWorkItemDetail().vm.$emit('deleteWorkItem', { workItemType: 'task', workItemId: '1' }); await waitForPromises(); diff --git a/spec/lib/gitlab/ci/pipeline/chain/populate_metadata_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/populate_metadata_spec.rb new file mode 100644 index 00000000000..ce1ee2fcda0 --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/populate_metadata_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::Pipeline::Chain::PopulateMetadata do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + let(:pipeline) do + build(:ci_pipeline, project: project, ref: 'master', user: user) + end + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new( + project: project, + current_user: user, + origin_ref: 'master') + end + + let(:dependencies) do + [ + Gitlab::Ci::Pipeline::Chain::Config::Content.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::Config::Process.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::SeedBlock.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::Seed.new(pipeline, command), + Gitlab::Ci::Pipeline::Chain::Populate.new(pipeline, command) + ] + end + + let(:step) { described_class.new(pipeline, command) } + + let(:config) do + { rspec: { script: 'rspec' } } + end + + def run_chain + dependencies.map(&:perform!) + step.perform! + end + + before do + stub_ci_pipeline_yaml_file(YAML.dump(config)) + end + + context 'with pipeline name' do + let(:config) do + { workflow: { name: ' Pipeline name ' }, rspec: { script: 'rspec' } } + end + + it 'does not break the chain' do + run_chain + + expect(step.break?).to be false + end + + context 'with feature flag disabled' do + before do + stub_feature_flags(pipeline_name: false) + end + + it 'does not build pipeline_metadata' do + run_chain + + expect(pipeline.pipeline_metadata).to be_nil + end + end + + context 'with feature flag enabled' do + before do + stub_feature_flags(pipeline_name: true) + end + + it 'builds pipeline_metadata' do + run_chain + + expect(pipeline.pipeline_metadata.name).to eq('Pipeline name') + expect(pipeline.pipeline_metadata.project).to eq(pipeline.project) + expect(pipeline.pipeline_metadata).not_to be_persisted + end + + context 'with empty name' do + let(:config) do + { workflow: { name: ' ' }, rspec: { script: 'rspec' } } + end + + it 'strips whitespace from name' do + run_chain + + expect(pipeline.pipeline_metadata).to be_nil + end + end + + context 'with variables' do + let(:config) do + { + variables: { ROOT_VAR: 'value $WORKFLOW_VAR1' }, + workflow: { + name: 'Pipeline $ROOT_VAR $WORKFLOW_VAR2 $UNKNOWN_VAR', + rules: [{ variables: { WORKFLOW_VAR1: 'value1', WORKFLOW_VAR2: 'value2' } }] + }, + rspec: { script: 'rspec' } + } + end + + it 'substitutes variables' do + run_chain + + expect(pipeline.pipeline_metadata.name).to eq('Pipeline value value1 value2 ') + end + end + + context 'with invalid name' do + let(:config) do + { + variables: { ROOT_VAR: 'a' * 256 }, + workflow: { + name: 'Pipeline $ROOT_VAR' + }, + rspec: { script: 'rspec' } + } + end + + it 'returns error and breaks chain' do + ret = run_chain + + expect(ret) + .to match_array(["Failed to build pipeline metadata! Name is too long (maximum is 255 characters)"]) + expect(pipeline.pipeline_metadata.errors.full_messages) + .to match_array(['Name is too long (maximum is 255 characters)']) + expect(step.break?).to be true + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb index 67b2498488f..62de4d2e96d 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb @@ -236,66 +236,4 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Populate do end end end - - context 'with pipeline name' do - let(:config) do - { workflow: { name: ' Pipeline name ' }, rspec: { script: 'rspec' } } - end - - context 'with feature flag disabled' do - before do - stub_feature_flags(pipeline_name: false) - end - - it 'does not build pipeline_metadata' do - run_chain - - expect(pipeline.pipeline_metadata).to be_nil - end - end - - context 'with feature flag enabled' do - before do - stub_feature_flags(pipeline_name: true) - end - - it 'builds pipeline_metadata' do - run_chain - - expect(pipeline.pipeline_metadata.name).to eq('Pipeline name') - expect(pipeline.pipeline_metadata.project).to eq(pipeline.project) - end - - context 'with empty name' do - let(:config) do - { workflow: { name: ' ' }, rspec: { script: 'rspec' } } - end - - it 'strips whitespace from name' do - run_chain - - expect(pipeline.pipeline_metadata).to be_nil - end - end - - context 'with variables' do - let(:config) do - { - variables: { ROOT_VAR: 'value $WORKFLOW_VAR1' }, - workflow: { - name: 'Pipeline $ROOT_VAR $WORKFLOW_VAR2 $UNKNOWN_VAR', - rules: [{ variables: { WORKFLOW_VAR1: 'value1', WORKFLOW_VAR2: 'value2' } }] - }, - rspec: { script: 'rspec' } - } - end - - it 'substitutes variables' do - run_chain - - expect(pipeline.pipeline_metadata.name).to eq('Pipeline value value1 value2 ') - end - end - end - end end diff --git a/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb b/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb index 2c1badbd113..2bc3cd81b48 100644 --- a/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/unsubscribe_handler_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Gitlab::Email::Handler::UnsubscribeHandler do stub_config_setting(host: 'localhost') end - let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX}") } + let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX}") } let(:project) { create(:project, :public) } let(:user) { create(:user) } let(:noteable) { create(:issue, project: project) } @@ -21,19 +21,19 @@ RSpec.describe Gitlab::Email::Handler::UnsubscribeHandler do let(:mail) { Mail::Message.new(email_raw) } it "matches the new format" do - handler = described_class.new(mail, "#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX}") + handler = described_class.new(mail, "#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX}") expect(handler.can_handle?).to be_truthy end it "matches the legacy format" do - handler = described_class.new(mail, "#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY}") + handler = described_class.new(mail, "#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY}") expect(handler.can_handle?).to be_truthy end it "doesn't match either format" do - handler = described_class.new(mail, "+#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX}") + handler = described_class.new(mail, "+#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX}") expect(handler.can_handle?).to be_falsey end @@ -64,7 +64,7 @@ RSpec.describe Gitlab::Email::Handler::UnsubscribeHandler do end context 'when using old style unsubscribe link' do - let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "#{mail_key}#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY}") } + let(:email_raw) { fixture_file('emails/valid_reply.eml').gsub(mail_key, "#{mail_key}#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY}") } it 'unsubscribes user from notable' do expect { receiver.execute }.to change { noteable.subscribed?(user) }.from(true).to(false) diff --git a/spec/lib/gitlab/email/handler_spec.rb b/spec/lib/gitlab/email/handler_spec.rb index eff6fb63a5f..d38b7d9c85c 100644 --- a/spec/lib/gitlab/email/handler_spec.rb +++ b/spec/lib/gitlab/email/handler_spec.rb @@ -60,7 +60,7 @@ RSpec.describe Gitlab::Email::Handler do describe 'regexps are set properly' do let(:addresses) do - %W(sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX} sent_notification_key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX_LEGACY}) + + %W(sent_notification_key#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX} sent_notification_key#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX_LEGACY}) + %w(sent_notification_key path-to-project-123-user_email_token-merge-request) + %w(path-to-project-123-user_email_token-issue path-to-project-123-user_email_token-issue-123) + %w(path/to/project+user_email_token path/to/project+merge-request+user_email_token some/project) diff --git a/spec/lib/gitlab/incoming_email_spec.rb b/spec/lib/gitlab/incoming_email_spec.rb index 1545de6d8fd..acd6634058f 100644 --- a/spec/lib/gitlab/incoming_email_spec.rb +++ b/spec/lib/gitlab/incoming_email_spec.rb @@ -1,87 +1,17 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' RSpec.describe Gitlab::IncomingEmail do - describe "self.enabled?" do - context "when reply by email is enabled" do - before do - stub_incoming_email_setting(enabled: true) - end - - it 'returns true' do - expect(described_class.enabled?).to be(true) - end - end - - context "when reply by email is disabled" do - before do - stub_incoming_email_setting(enabled: false) - end + let(:setting_name) { :incoming_email } - it "returns false" do - expect(described_class.enabled?).to be(false) - end - end - end + it_behaves_like 'common email methods' - describe 'self.supports_wildcard?' do - context 'address contains the wildcard placeholder' do - before do - stub_incoming_email_setting(address: 'replies+%{key}@example.com') - end - - it 'confirms that wildcard is supported' do - expect(described_class.supports_wildcard?).to be(true) - end - end - - context "address doesn't contain the wildcard placeholder" do - before do - stub_incoming_email_setting(address: 'replies@example.com') - end - - it 'returns that wildcard is not supported' do - expect(described_class.supports_wildcard?).to be(false) - end - end - - context 'address is not set' do - before do - stub_incoming_email_setting(address: nil) - end - - it 'returns that wildcard is not supported' do - expect(described_class.supports_wildcard?).to be(false) - end - end - end - - context 'self.unsubscribe_address' do + describe 'self.key_from_address' do before do stub_incoming_email_setting(address: 'replies+%{key}@example.com') end - it 'returns the address with interpolated reply key and unsubscribe suffix' do - expect(described_class.unsubscribe_address('key')).to eq("replies+key#{Gitlab::IncomingEmail::UNSUBSCRIBE_SUFFIX}@example.com") - end - end - - context "self.reply_address" do - before do - stub_incoming_email_setting(address: "replies+%{key}@example.com") - end - - it "returns the address with an interpolated reply key" do - expect(described_class.reply_address("key")).to eq("replies+key@example.com") - end - end - - context "self.key_from_address" do - before do - stub_incoming_email_setting(address: "replies+%{key}@example.com") - end - it "returns reply key" do expect(described_class.key_from_address("replies+key@example.com")).to eq("key") end @@ -101,25 +31,4 @@ RSpec.describe Gitlab::IncomingEmail do end end end - - context 'self.key_from_fallback_message_id' do - it 'returns reply key' do - expect(described_class.key_from_fallback_message_id('reply-key@localhost')).to eq('key') - end - end - - context 'self.scan_fallback_references' do - let(:references) do - '<issue_1@localhost>' \ - ' <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>' \ - ',<exchange@microsoft.com>' - end - - it 'returns reply key' do - expect(described_class.scan_fallback_references(references)) - .to eq(%w[issue_1@localhost - reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost - exchange@microsoft.com]) - end - end end diff --git a/spec/lib/gitlab/service_desk_email_spec.rb b/spec/lib/gitlab/service_desk_email_spec.rb index 6667b61c02b..69569c0f194 100644 --- a/spec/lib/gitlab/service_desk_email_spec.rb +++ b/spec/lib/gitlab/service_desk_email_spec.rb @@ -1,39 +1,11 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' RSpec.describe Gitlab::ServiceDeskEmail do - describe '.enabled?' do - context 'when service_desk_email is enabled and address is set' do - before do - stub_service_desk_email_setting(enabled: true, address: 'foo') - end + let(:setting_name) { :service_desk_email } - it 'returns true' do - expect(described_class.enabled?).to be_truthy - end - end - - context 'when service_desk_email is disabled' do - before do - stub_service_desk_email_setting(enabled: false, address: 'foo') - end - - it 'returns false' do - expect(described_class.enabled?).to be_falsey - end - end - - context 'when service desk address is not set' do - before do - stub_service_desk_email_setting(enabled: true, address: nil) - end - - it 'returns false' do - expect(described_class.enabled?).to be_falsey - end - end - end + it_behaves_like 'common email methods' describe '.key_from_address' do context 'when service desk address is set' do @@ -78,10 +50,4 @@ RSpec.describe Gitlab::ServiceDeskEmail do end end end - - context 'self.key_from_fallback_message_id' do - it 'returns reply key' do - expect(described_class.key_from_fallback_message_id('reply-key@localhost')).to eq('key') - end - end end diff --git a/spec/migrations/20221103150250_migrate_sidekiq_queued_jobs_spec.rb b/spec/migrations/20221103150250_migrate_sidekiq_queued_jobs_spec.rb new file mode 100644 index 00000000000..1c8bedadc0a --- /dev/null +++ b/spec/migrations/20221103150250_migrate_sidekiq_queued_jobs_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe MigrateSidekiqQueuedJobs, :clean_gitlab_redis_queues do + around do |example| + Sidekiq::Testing.disable!(&example) + end + + describe '#up', :aggregate_failures, :silence_stdout do + before do + EmailReceiverWorker.sidekiq_options queue: 'email_receiver' + EmailReceiverWorker.perform_async('foo') + EmailReceiverWorker.perform_async('bar') + end + + after do + EmailReceiverWorker.set_queue + end + + context 'with worker_queue_mappings mocked' do + it 'migrates the jobs to the correct destination queue' do + allow(Gitlab::SidekiqConfig).to receive(:worker_queue_mappings) + .and_return({ "EmailReceiverWorker" => "default" }) + expect(queue_length('email_receiver')).to eq(2) + expect(queue_length('default')).to eq(0) + migrate! + expect(queue_length('email_receiver')).to eq(0) + expect(queue_length('default')).to eq(2) + + jobs = list_jobs('default') + expect(jobs[0]).to include("class" => "EmailReceiverWorker", "args" => ["bar"]) + expect(jobs[1]).to include("class" => "EmailReceiverWorker", "args" => ["foo"]) + end + end + + context 'without worker_queue_mappings mocked' do + it 'migration still works' do + # Assuming Settings.sidekiq.routing_rules is [['*', 'default']] + # If routing_rules or Gitlab::SidekiqConfig.worker_queue_mappings changed, + # this spec might be failing. We'll have to adjust the migration or this spec. + expect(queue_length('email_receiver')).to eq(2) + expect(queue_length('default')).to eq(0) + migrate! + expect(queue_length('email_receiver')).to eq(0) + expect(queue_length('default')).to eq(2) + + jobs = list_jobs('default') + expect(jobs[0]).to include("class" => "EmailReceiverWorker", "args" => ["bar"]) + expect(jobs[1]).to include("class" => "EmailReceiverWorker", "args" => ["foo"]) + end + end + + context 'with illegal JSON payload' do + let(:job) { '{foo: 1}' } + + before do + Sidekiq.redis do |conn| + conn.lpush("queue:email_receiver", job) + end + end + + it 'logs an error' do + allow(::Gitlab::BackgroundMigration::Logger).to receive(:build).and_return(Logger.new($stdout)) + migrate! + expect($stdout.string).to include("Unmarshal JSON payload from SidekiqMigrateJobs failed. Job: #{job}") + end + end + + context 'when run in GitLab.com' do + it 'skips the migration' do + allow(Gitlab).to receive(:com?).and_return(true) + expect(described_class::SidekiqMigrateJobs).not_to receive(:new) + migrate! + end + end + + def queue_length(queue_name) + Sidekiq.redis do |conn| + conn.llen("queue:#{queue_name}") + end + end + + def list_jobs(queue_name) + Sidekiq.redis { |conn| conn.lrange("queue:#{queue_name}", 0, -1) } + .map { |item| Sidekiq.load_json item } + end + end +end diff --git a/spec/requests/jira_connect/cors_preflight_checks_controller_spec.rb b/spec/requests/jira_connect/cors_preflight_checks_controller_spec.rb new file mode 100644 index 00000000000..aeea77091b1 --- /dev/null +++ b/spec/requests/jira_connect/cors_preflight_checks_controller_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe JiraConnect::CorsPreflightChecksController do + shared_examples 'allows cross-origin requests on self managed' do + it 'renders not found' do + options path + + expect(response).to have_gitlab_http_status(:not_found) + expect(response.headers['Access-Control-Allow-Origin']).to be_nil + end + + context 'with jira_connect_proxy_url setting' do + before do + stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com') + + options path, headers: { 'Origin' => 'http://notgitlab.com' } + end + + it 'returns 200' do + expect(response).to have_gitlab_http_status(:ok) + end + + it 'responds with access-control-allow headers', :aggregate_failures do + expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com' + expect(response.headers['Access-Control-Allow-Methods']).to eq allowed_methods + expect(response.headers['Access-Control-Allow-Credentials']).to be_nil + end + + context 'when on GitLab.com' do + before do + allow(Gitlab).to receive(:com?).and_return(true) + end + + it 'renders not found' do + options path + + expect(response).to have_gitlab_http_status(:not_found) + expect(response.headers['Access-Control-Allow-Origin']).to be_nil + end + end + end + end + + describe 'OPTIONS /-/jira_connect/oauth_application_id' do + let(:allowed_methods) { 'GET, OPTIONS' } + let(:path) { '/-/jira_connect/oauth_application_id' } + + it_behaves_like 'allows cross-origin requests on self managed' + end + + describe 'OPTIONS /-/jira_connect/subscriptions' do + let(:allowed_methods) { 'GET, POST, OPTIONS' } + let(:path) { '/-/jira_connect/subscriptions' } + + it_behaves_like 'allows cross-origin requests on self managed' + end + + describe 'OPTIONS /-/jira_connect/subscriptions/:id' do + let(:allowed_methods) { 'DELETE, OPTIONS' } + let(:path) { '/-/jira_connect/subscriptions/123' } + + it_behaves_like 'allows cross-origin requests on self managed' + end +end diff --git a/spec/requests/jira_connect/oauth_application_ids_controller_spec.rb b/spec/requests/jira_connect/oauth_application_ids_controller_spec.rb index b0c2eaec4e2..1d772e973ff 100644 --- a/spec/requests/jira_connect/oauth_application_ids_controller_spec.rb +++ b/spec/requests/jira_connect/oauth_application_ids_controller_spec.rb @@ -3,42 +3,12 @@ require 'spec_helper' RSpec.describe JiraConnect::OauthApplicationIdsController do - describe 'OPTIONS /-/jira_connect/oauth_application_id' do - before do - stub_application_setting(jira_connect_application_key: '123456') - - options '/-/jira_connect/oauth_application_id', headers: { 'Origin' => 'http://notgitlab.com' } - end - - it 'returns 200' do - expect(response).to have_gitlab_http_status(:ok) - end - - it 'allows cross-origin requests', :aggregate_failures do - expect(response.headers['Access-Control-Allow-Origin']).to eq '*' - expect(response.headers['Access-Control-Allow-Methods']).to eq 'GET, OPTIONS' - expect(response.headers['Access-Control-Allow-Credentials']).to be_nil - end - - context 'on GitLab.com' do - before do - allow(Gitlab).to receive(:com?).and_return(true) - end - - it 'renders not found' do - options '/-/jira_connect/oauth_application_id' - - expect(response).to have_gitlab_http_status(:not_found) - expect(response.headers['Access-Control-Allow-Origin']).not_to eq '*' - end - end - end - describe 'GET /-/jira_connect/oauth_application_id' do let(:cors_request_headers) { { 'Origin' => 'http://notgitlab.com' } } before do stub_application_setting(jira_connect_application_key: '123456') + stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com') end it 'renders the jira connect application id' do @@ -51,7 +21,7 @@ RSpec.describe JiraConnect::OauthApplicationIdsController do it 'allows cross-origin requests', :aggregate_failures do get '/-/jira_connect/oauth_application_id', headers: cors_request_headers - expect(response.headers['Access-Control-Allow-Origin']).to eq '*' + expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com' expect(response.headers['Access-Control-Allow-Methods']).to eq 'GET, OPTIONS' expect(response.headers['Access-Control-Allow-Credentials']).to be_nil end diff --git a/spec/requests/jira_connect/subscriptions_controller_spec.rb b/spec/requests/jira_connect/subscriptions_controller_spec.rb index 29da7f25d30..2a0b73d4ab1 100644 --- a/spec/requests/jira_connect/subscriptions_controller_spec.rb +++ b/spec/requests/jira_connect/subscriptions_controller_spec.rb @@ -10,9 +10,16 @@ RSpec.describe JiraConnect::SubscriptionsController do end let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) } + let(:cors_request_headers) { { 'Origin' => 'http://notgitlab.com' } } + let(:path) { '/-/jira_connect/subscriptions' } + let(:params) { { jwt: jwt } } + + before do + stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com') + end subject(:content_security_policy) do - get '/-/jira_connect/subscriptions', params: { jwt: jwt } + get path, params: params response.headers['Content-Security-Policy'] end @@ -21,6 +28,14 @@ RSpec.describe JiraConnect::SubscriptionsController do it { is_expected.to include('http://self-managed-gitlab.com/api/') } it { is_expected.to include('http://self-managed-gitlab.com/oauth/') } + it 'allows cross-origin requests', :aggregate_failures do + get path, params: params, headers: cors_request_headers + + expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com' + expect(response.headers['Access-Control-Allow-Methods']).to eq 'GET, POST, OPTIONS' + expect(response.headers['Access-Control-Allow-Credentials']).to be_nil + end + context 'with no self-managed instance configured' do let_it_be(:installation) { create(:jira_connect_installation, instance_url: '') } @@ -39,4 +54,57 @@ RSpec.describe JiraConnect::SubscriptionsController do it { is_expected.not_to include('http://self-managed-gitlab.com/oauth/') } end end + + describe 'POST /-/jira_connect/subscriptions' do + let_it_be(:installation) { create(:jira_connect_installation, instance_url: 'http://self-managed-gitlab.com') } + let_it_be(:group) { create(:group) } + let_it_be(:user) { create(:user) } + + let(:qsh) do + Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test') + end + + let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) } + let(:cors_request_headers) { { 'Origin' => 'http://notgitlab.com' } } + let(:params) { { jwt: jwt, namespace_path: group.path, format: :json } } + + before do + group.add_maintainer(user) + sign_in(user) + stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com') + end + + it 'allows cross-origin requests', :aggregate_failures do + post '/-/jira_connect/subscriptions', params: params, headers: cors_request_headers + + expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com' + expect(response.headers['Access-Control-Allow-Methods']).to eq 'GET, POST, OPTIONS' + expect(response.headers['Access-Control-Allow-Credentials']).to be_nil + end + end + + describe 'DELETE /-/jira_connect/subscriptions/:id' do + let_it_be(:installation) { create(:jira_connect_installation, instance_url: 'http://self-managed-gitlab.com') } + let_it_be(:subscription) { create(:jira_connect_subscription, installation: installation) } + + let(:qsh) do + Atlassian::Jwt.create_query_string_hash('https://gitlab.test/subscriptions', 'GET', 'https://gitlab.test') + end + + let(:jwt) { Atlassian::Jwt.encode({ iss: installation.client_key, qsh: qsh }, installation.shared_secret) } + let(:cors_request_headers) { { 'Origin' => 'http://notgitlab.com' } } + let(:params) { { jwt: jwt, format: :json } } + + before do + stub_application_setting(jira_connect_proxy_url: 'https://gitlab.com') + end + + it 'allows cross-origin requests', :aggregate_failures do + delete "/-/jira_connect/subscriptions/#{subscription.id}", params: params, headers: cors_request_headers + + expect(response.headers['Access-Control-Allow-Origin']).to eq 'https://gitlab.com' + expect(response.headers['Access-Control-Allow-Methods']).to eq 'DELETE, OPTIONS' + expect(response.headers['Access-Control-Allow-Credentials']).to be_nil + end + end end diff --git a/spec/requests/oauth/tokens_controller_spec.rb b/spec/requests/oauth/tokens_controller_spec.rb index e4cb28cc42b..507489d92cf 100644 --- a/spec/requests/oauth/tokens_controller_spec.rb +++ b/spec/requests/oauth/tokens_controller_spec.rb @@ -7,6 +7,7 @@ RSpec.describe Oauth::TokensController do let(:other_headers) { {} } let(:headers) { cors_request_headers.merge(other_headers) } let(:allowed_methods) { 'POST, OPTIONS' } + let(:authorization_methods) { %w[Authorization X-CSRF-Token X-Requested-With] } shared_examples 'cross-origin POST request' do it 'allows cross-origin requests' do @@ -25,7 +26,7 @@ RSpec.describe Oauth::TokensController do it 'allows cross-origin requests' do expect(response.headers['Access-Control-Allow-Origin']).to eq '*' expect(response.headers['Access-Control-Allow-Methods']).to eq allowed_methods - expect(response.headers['Access-Control-Allow-Headers']).to eq 'Authorization' + expect(response.headers['Access-Control-Allow-Headers']).to eq authorization_methods expect(response.headers['Access-Control-Allow-Credentials']).to be_nil end end @@ -39,7 +40,7 @@ RSpec.describe Oauth::TokensController do end describe 'OPTIONS /oauth/token' do - let(:other_headers) { { 'Access-Control-Request-Headers' => 'Authorization', 'Access-Control-Request-Method' => 'POST' } } + let(:other_headers) { { 'Access-Control-Request-Headers' => authorization_methods, 'Access-Control-Request-Method' => 'POST' } } before do options '/oauth/token', headers: headers @@ -63,7 +64,7 @@ RSpec.describe Oauth::TokensController do end describe 'OPTIONS /oauth/revoke' do - let(:other_headers) { { 'Access-Control-Request-Headers' => 'Authorization', 'Access-Control-Request-Method' => 'POST' } } + let(:other_headers) { { 'Access-Control-Request-Headers' => authorization_methods, 'Access-Control-Request-Method' => 'POST' } } before do options '/oauth/revoke', headers: headers diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9d169c50013..235342b00c9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -78,6 +78,7 @@ require_relative '../tooling/quality/test_level' quality_level = Quality::TestLevel.new RSpec.configure do |config| + config.threadsafe = false config.use_transactional_fixtures = true config.use_instantiated_fixtures = false config.fixture_path = Rails.root diff --git a/spec/support/shared_examples/lib/email/email_shared_examples.rb b/spec/support/shared_examples/lib/email/email_shared_examples.rb new file mode 100644 index 00000000000..26655a71fec --- /dev/null +++ b/spec/support/shared_examples/lib/email/email_shared_examples.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +# Set the particular setting as a key-value pair +# Setting method is different depending on klass and must be defined in the calling spec +def stub_email_setting(key_value_pairs) + case setting_name + when :incoming_email + stub_incoming_email_setting(key_value_pairs) + when :service_desk_email + stub_service_desk_email_setting(key_value_pairs) + end +end + +RSpec.shared_examples_for 'enabled? method for email' do + using RSpec::Parameterized::TableSyntax + + subject { described_class.enabled? } + + where(:value, :address, :result) do + false | nil | false + false | 'replies+%{key}@example.com' | false + true | nil | false + true | 'replies+%{key}@example.com' | true + end + + with_them do + before do + stub_email_setting(enabled: value) + stub_email_setting(address: address) + end + + it { is_expected.to eq result } + end +end + +RSpec.shared_examples_for 'supports_wildcard? method for email' do + subject { described_class.supports_wildcard? } + + before do + stub_incoming_email_setting(address: value) + end + + context 'when address contains the wildcard placeholder' do + let(:value) { 'replies+%{key}@example.com' } + + it 'confirms that wildcard is supported' do + expect(subject).to be_truthy + end + end + + context "when address doesn't contain the wildcard placeholder" do + let(:value) { 'replies@example.com' } + + it 'returns that wildcard is not supported' do + expect(subject).to be_falsey + end + end + + context 'when address is nil' do + let(:value) { nil } + + it 'returns that wildcard is not supported' do + expect(subject).to be_falsey + end + end +end + +RSpec.shared_examples_for 'unsubscribe_address method for email' do + before do + stub_incoming_email_setting(address: 'replies+%{key}@example.com') + end + + it 'returns the address with interpolated reply key and unsubscribe suffix' do + expect(described_class.unsubscribe_address('key')) + .to eq("replies+key#{Gitlab::Email::Common::UNSUBSCRIBE_SUFFIX}@example.com") + end +end + +RSpec.shared_examples_for 'key_from_fallback_message_id method for email' do + it 'returns reply key' do + expect(described_class.key_from_fallback_message_id('reply-key@localhost')).to eq('key') + end +end + +RSpec.shared_examples_for 'supports_issue_creation? method for email' do + using RSpec::Parameterized::TableSyntax + + subject { described_class.supports_issue_creation? } + + where(:enabled_value, :supports_wildcard_value, :result) do + false | false | false + false | true | false + true | false | false + true | true | true + end + + with_them do + before do + allow(described_class).to receive(:enabled?).and_return(enabled_value) + allow(described_class).to receive(:supports_wildcard?).and_return(supports_wildcard_value) + end + + it { is_expected.to eq result } + end +end + +RSpec.shared_examples_for 'reply_address method for email' do + before do + stub_incoming_email_setting(address: "replies+%{key}@example.com") + end + + it "returns the address with an interpolated reply key" do + expect(described_class.reply_address("key")).to eq("replies+key@example.com") + end +end + +RSpec.shared_examples_for 'scan_fallback_references method for email' do + let(:references) do + '<issue_1@localhost>' \ + ' <reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost>' \ + ',<exchange@microsoft.com>' + end + + it 'returns reply key' do + expect(described_class.scan_fallback_references(references)) + .to eq(%w[issue_1@localhost + reply-59d8df8370b7e95c5a49fbf86aeb2c93@localhost + exchange@microsoft.com]) + end +end + +RSpec.shared_examples_for 'common email methods' do + it_behaves_like 'enabled? method for email' + it_behaves_like 'supports_wildcard? method for email' + it_behaves_like 'key_from_fallback_message_id method for email' + it_behaves_like 'supports_issue_creation? method for email' + it_behaves_like 'reply_address method for email' + it_behaves_like 'unsubscribe_address method for email' + it_behaves_like 'scan_fallback_references method for email' +end |