diff options
81 files changed, 1262 insertions, 679 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 042664f7338..296a103cfe8 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -53fd83a9c21e89bf1bfb9b7f918b9bcfa3ef776a +76dabc8174f7978025f48adcfab0a19c85416531 diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 06757e7a280..867bf0b4d55 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -39,12 +39,12 @@ export class ContentEditor { this._eventHub.dispose(); } - deserialize(serializedContent) { + deserialize(markdown) { const { _tiptapEditor: editor, _deserializer: deserializer } = this; return deserializer.deserialize({ schema: editor.schema, - content: serializedContent, + markdown, }); } diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js index dcd56e55268..fa46bd9ff81 100644 --- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js @@ -16,8 +16,8 @@ export default ({ render }) => { * document. The dom property contains the HTML generated from the Markdown Source. */ return { - deserialize: async ({ schema, content }) => { - const html = await render(content); + deserialize: async ({ schema, markdown }) => { + const html = await render(markdown); if (!html) return {}; @@ -25,7 +25,7 @@ export default ({ render }) => { const { body } = parser.parseFromString(html, 'text/html'); // append original source as a comment that nodes can access - body.append(document.createComment(content)); + body.append(document.createComment(markdown)); return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) }; }, diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js index 5d675fb4851..ad9419699c8 100644 --- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js +++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js @@ -53,7 +53,7 @@ function maybeMerge(a, b) { * Hast node documentation: https://github.com/syntax-tree/hast * * @param {HastNode} hastNode A Hast node - * @param {String} source Markdown source file + * @param {String} markdown Markdown source file * * @returns It returns an object with the following attributes: * @@ -62,13 +62,13 @@ function maybeMerge(a, b) { * - sourceMarkdown: A node’s original Markdown source extrated * from the Markdown source file. */ -function createSourceMapAttributes(hastNode, source) { +function createSourceMapAttributes(hastNode, markdown) { const { position } = hastNode; return position && position.end ? { sourceMapKey: `${position.start.offset}:${position.end.offset}`, - sourceMarkdown: source.substring(position.start.offset, position.end.offset), + sourceMarkdown: markdown.substring(position.start.offset, position.end.offset), } : {}; } @@ -84,16 +84,16 @@ function createSourceMapAttributes(hastNode, source) { * @param {*} proseMirrorNodeSpec ProseMirror node spec object * @param {HastNode} hastNode A hast node * @param {Array<HastNode>} hastParents All the ancestors of the hastNode - * @param {String} source Markdown source file’s content + * @param {String} markdown Markdown source file’s content * * @returns An object that contains a ProseMirror node’s attributes */ -function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, source) { +function getAttrs(proseMirrorNodeSpec, hastNode, hastParents, markdown) { const { getAttrs: specGetAttrs } = proseMirrorNodeSpec; return { - ...createSourceMapAttributes(hastNode, source), - ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, source) : {}), + ...createSourceMapAttributes(hastNode, markdown), + ...(isFunction(specGetAttrs) ? specGetAttrs(hastNode, hastParents, markdown) : {}), }; } @@ -319,11 +319,11 @@ class HastToProseMirrorConverterState { * @param {model.ProseMirrorSchema} schema A ProseMirror schema used to create the * ProseMirror nodes and marks. * @param {Object} proseMirrorFactorySpecs ProseMirror nodes factory specifications. - * @param {String} source Markdown source file’s content + * @param {String} markdown Markdown source file’s content * * @returns An object that contains ProseMirror node factories */ -const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) => { +const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, markdown) => { const factories = { root: { selector: 'root', @@ -356,7 +356,7 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) const nodeType = schema.nodeType(proseMirrorName); state.closeUntil(parent); - state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, source), factory); + state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); /** * If a getContent function is provided, we immediately close @@ -371,14 +371,14 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) const nodeType = schema.nodeType(proseMirrorName); factory.handle = (state, hastNode, parent) => { state.closeUntil(parent); - state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, source), factory); + state.openNode(nodeType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); // Inline nodes do not have children therefore they are immediately closed state.closeNode(); }; } else if (factory.type === 'mark') { const markType = schema.marks[proseMirrorName]; factory.handle = (state, hastNode, parent) => { - state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, source), factory); + state.openMark(markType, hastNode, getAttrs(factory, hastNode, parent, markdown), factory); }; } else if (factory.type === 'ignore') { factory.handle = noop; @@ -601,9 +601,9 @@ export const createProseMirrorDocFromMdastTree = ({ factorySpecs, wrappableTags, tree, - source, + markdown, }) => { - const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, source); + const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, markdown); const state = new HastToProseMirrorConverterState(); visitParents(tree, (hastNode, ancestors) => { diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index 8c99dc157e6..9bf130c454e 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -169,7 +169,7 @@ const factorySpecs = { export default () => { return { - deserialize: async ({ schema, content: markdown }) => { + deserialize: async ({ schema, markdown }) => { const document = await render({ markdown, renderer: (tree) => @@ -178,7 +178,7 @@ export default () => { factorySpecs, tree, wrappableTags, - source: markdown, + markdown, }), }); diff --git a/app/assets/javascripts/jobs/components/table/jobs_table.vue b/app/assets/javascripts/jobs/components/table/jobs_table.vue index f513d2090fa..d8c5c292f52 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table.vue @@ -45,6 +45,7 @@ export default { :fields="tableFields" :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }" :empty-text="$options.i18n.emptyText" + data-testid="jobs-table" show-empty stacked="lg" fixed diff --git a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue index 1ac1a2d68e2..b3db5a94ac5 100644 --- a/app/assets/javascripts/jobs/components/table/jobs_table_app.vue +++ b/app/assets/javascripts/jobs/components/table/jobs_table_app.vue @@ -2,7 +2,6 @@ import { GlAlert, GlSkeletonLoader, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import createFlash from '~/flash'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import JobsFilteredSearch from '../filtered_search/jobs_filtered_search.vue'; import eventHub from './event_hub'; import GetJobs from './graphql/queries/get_jobs.query.graphql'; @@ -28,7 +27,6 @@ export default { GlIntersectionObserver, GlLoadingIcon, }, - mixins: [glFeatureFlagMixin()], inject: { fullPath: { default: '', @@ -93,7 +91,7 @@ export default { return this.loading && !this.showLoadingSpinner; }, showFilteredSearch() { - return this.glFeatures?.jobsTableVueSearch && !this.scope; + return !this.scope; }, jobsCount() { return this.jobs.count; diff --git a/app/assets/javascripts/pages/projects/jobs/index/index.js b/app/assets/javascripts/pages/projects/jobs/index/index.js index 75194499a7f..eb3a24f38a8 100644 --- a/app/assets/javascripts/pages/projects/jobs/index/index.js +++ b/app/assets/javascripts/pages/projects/jobs/index/index.js @@ -1,23 +1,3 @@ -import Vue from 'vue'; import initJobsTable from '~/jobs/components/table'; -import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; -if (gon.features?.jobsTableVue) { - initJobsTable(); -} else { - const remainingTimeElements = document.querySelectorAll('.js-remaining-time'); - - remainingTimeElements.forEach( - (el) => - new Vue({ - el, - render(h) { - return h(GlCountdown, { - props: { - endDateString: el.dateTime, - }, - }); - }, - }), - ); -} +initJobsTable(); diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index c68437b9879..3e0ac236fdf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -221,8 +221,11 @@ export default { formattedHumanAccess() { return (this.mr.humanAccess || '').toLowerCase(); }, + hasMergeError() { + return this.mr.mergeError && this.state !== 'closed'; + }, hasAlerts() { - return this.mr.mergeError || this.showMergePipelineForkWarning; + return this.hasMergeError || this.showMergePipelineForkWarning; }, shouldShowExtension() { return ( @@ -574,7 +577,12 @@ export default { /> <div class="mr-section-container mr-widget-workflow"> <div v-if="hasAlerts" class="gl-overflow-hidden mr-widget-alert-container"> - <mr-widget-alert-message v-if="mr.mergeError" type="danger" dismissible> + <mr-widget-alert-message + v-if="hasMergeError" + type="danger" + dismissible + data-testid="merge_error" + > <span v-safe-html="mergeError"></span> </mr-widget-alert-message> <mr-widget-alert-message 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 4fb4a18c460..138101890bf 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -4,6 +4,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { i18n, WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_LABELS, WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_WEIGHT, } from '../constants'; @@ -14,6 +15,7 @@ import WorkItemState from './work_item_state.vue'; import WorkItemTitle from './work_item_title.vue'; import WorkItemDescription from './work_item_description.vue'; import WorkItemAssignees from './work_item_assignees.vue'; +import WorkItemLabels from './work_item_labels.vue'; import WorkItemWeight from './work_item_weight.vue'; export default { @@ -25,6 +27,7 @@ export default { WorkItemAssignees, WorkItemActions, WorkItemDescription, + WorkItemLabels, WorkItemTitle, WorkItemState, WorkItemWeight, @@ -99,6 +102,9 @@ export default { workItemAssignees() { return this.workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_ASSIGNEES); }, + workItemLabels() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + }, workItemWeight() { return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_WEIGHT); }, @@ -155,6 +161,12 @@ export default { :allows-multiple-assignees="workItemAssignees.allowsMultipleAssignees" @error="error = $event" /> + <work-item-labels + v-if="workItemLabels" + :work-item-id="workItem.id" + :can-update="canUpdate" + @error="error = $event" + /> <work-item-weight v-if="workItemWeight" class="gl-mb-5" diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue new file mode 100644 index 00000000000..78ed67998d7 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -0,0 +1,246 @@ +<script> +import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import Tracking from '~/tracking'; +import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { isScopedLabel, scopedLabelKey } from '~/lib/utils/common_utils'; +import workItemQuery from '../graphql/work_item.query.graphql'; +import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql'; + +import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_LABELS } from '../constants'; + +function isTokenSelectorElement(el) { + return el?.classList.contains('gl-label-close') || el?.classList.contains('dropdown-item'); +} + +function addClass(el) { + return { + ...el, + class: 'gl-bg-transparent', + }; +} + +export default { + components: { + GlTokenSelector, + GlLabel, + GlSkeletonLoader, + LabelItem, + }, + mixins: [Tracking.mixin()], + inject: ['fullPath'], + props: { + workItemId: { + type: String, + required: true, + }, + canUpdate: { + type: Boolean, + required: true, + }, + }, + data() { + return { + isEditing: false, + searchStarted: false, + localLabels: [], + searchKey: '', + searchLabels: [], + }; + }, + apollo: { + workItem: { + query: workItemQuery, + variables() { + return { + id: this.workItemId, + }; + }, + skip() { + return !this.workItemId; + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + searchLabels: { + query: labelSearchQuery, + variables() { + return { + fullPath: this.fullPath, + search: this.searchKey, + }; + }, + skip() { + return !this.searchStarted; + }, + update(data) { + return data.workspace?.labels?.nodes.map((node) => addClass({ ...node, ...node.label })); + }, + error() { + this.$emit('error', i18n.fetchError); + }, + }, + }, + computed: { + tracking() { + return { + category: TRACKING_CATEGORY_SHOW, + label: 'item_labels', + property: `type_${this.workItem.workItemType?.name}`, + }; + }, + allowScopedLabels() { + return this.labelsWidget.allowScopedLabels; + }, + listEmpty() { + return this.labels.length === 0; + }, + containerClass() { + return !this.isEditing ? 'gl-shadow-none!' : ''; + }, + isLoading() { + return this.$apollo.queries.searchLabels.loading; + }, + labelsWidget() { + return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_LABELS); + }, + labels() { + return this.labelsWidget?.nodes || []; + }, + }, + watch: { + labels(newVal) { + if (!this.isEditing) { + this.localLabels = newVal.map(addClass); + } + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + methods: { + getId(id) { + return getIdFromGraphQLId(id); + }, + removeLabel({ id }) { + this.localLabels = this.localLabels.filter((label) => label.id !== id); + }, + setLabels(event) { + this.searchKey = ''; + if (isTokenSelectorElement(event.relatedTarget) || !this.isEditing) return; + this.isEditing = false; + this.$apollo + .mutate({ + mutation: localUpdateWorkItemMutation, + variables: { + input: { + id: this.workItemId, + labels: this.localLabels, + }, + }, + }) + .catch((e) => { + this.$emit('error', e); + }); + this.track('updated_labels'); + }, + handleFocus() { + this.isEditing = true; + this.searchStarted = true; + }, + async focusTokenSelector(labels) { + if (this.allowScopedLabels) { + const newLabel = labels[labels.length - 1]; + const existingLabels = labels.slice(0, labels.length - 1); + + const newLabelKey = scopedLabelKey(newLabel); + + const removeLabelsWithSameScope = existingLabels.filter((label) => { + const sameKey = newLabelKey === scopedLabelKey(label); + return !sameKey; + }); + + this.localLabels = [...removeLabelsWithSameScope, newLabel]; + } + this.handleFocus(); + await this.$nextTick(); + this.$refs.tokenSelector.focusTextInput(); + }, + handleMouseOver() { + this.timeout = setTimeout(() => { + this.searchStarted = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + handleMouseOut() { + clearTimeout(this.timeout); + }, + setSearchKey(value) { + this.searchKey = value; + }, + scopedLabel(label) { + return this.allowScopedLabels && isScopedLabel(label); + }, + }, +}; +</script> + +<template> + <div class="form-row gl-mb-5 work-item-labels gl-relative"> + <span + class="gl-font-weight-bold gl-mt-2 col-lg-2 col-3 gl-pt-2 min-w-fit-content gl-overflow-wrap-break" + data-testid="labels-title" + >{{ __('Labels') }}</span + > + <gl-token-selector + ref="tokenSelector" + v-model="localLabels" + :container-class="containerClass" + :dropdown-items="searchLabels" + :loading="isLoading" + :view-only="!canUpdate" + class="gl-flex-grow-1 gl-border gl-border-white gl-hover-border-gray-200 gl-rounded-base col-9 gl-align-self-start gl-px-0! gl-mx-2!" + @input="focusTokenSelector" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" + @blur="setLabels" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" + > + <template #empty-placeholder> + <div + class="add-labels gl-min-w-fit-content gl-display-flex gl-align-items-center gl-text-gray-400 gl-pr-4 gl-top-2" + data-testid="empty-state" + > + <span v-if="canUpdate" class="gl-ml-2">{{ __('Select labels') }}</span> + <span v-else class="gl-ml-2">{{ __('None') }}</span> + </div> + </template> + <template #token-content="{ token }"> + <gl-label + :data-qa-label-name="token.title" + :title="token.title" + :description="token.description" + :background-color="token.color" + :scoped="scopedLabel(token)" + :show-close-button="canUpdate" + @close="removeLabel(token)" + /> + </template> + <template #dropdown-item-content="{ dropdownItem }"> + <label-item :label="dropdownItem" /> + </template> + <template #loading-content> + <gl-skeleton-loader :height="170"> + <rect width="380" height="20" x="10" y="15" rx="4" /> + <rect width="280" height="20" x="10" y="50" rx="4" /> + <rect width="380" height="20" x="10" y="95" rx="4" /> + <rect width="280" height="20" x="10" y="130" rx="4" /> + </gl-skeleton-loader> + </template> + </gl-token-selector> + </div> +</template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index c1d2851f0b7..c9ccbd48ba1 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -17,6 +17,7 @@ export const TASK_TYPE_NAME = 'Task'; export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES'; export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION'; +export const WIDGET_TYPE_LABELS = 'LABELS'; export const WIDGET_TYPE_WEIGHT = 'WEIGHT'; export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY'; diff --git a/app/assets/javascripts/work_items/graphql/provider.js b/app/assets/javascripts/work_items/graphql/provider.js index f1772cae9ac..8788ad21e7b 100644 --- a/app/assets/javascripts/work_items/graphql/provider.js +++ b/app/assets/javascripts/work_items/graphql/provider.js @@ -2,7 +2,7 @@ import produce from 'immer'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_WEIGHT } from '../constants'; +import { WIDGET_TYPE_ASSIGNEES, WIDGET_TYPE_LABELS, WIDGET_TYPE_WEIGHT } from '../constants'; import typeDefs from './typedefs.graphql'; import workItemQuery from './work_item.query.graphql'; @@ -10,7 +10,7 @@ export const temporaryConfig = { typeDefs, cacheConfig: { possibleTypes: { - LocalWorkItemWidget: ['LocalWorkItemWeight'], + LocalWorkItemWidget: ['LocalWorkItemLabels', 'LocalWorkItemWeight'], }, typePolicies: { WorkItem: { @@ -20,6 +20,12 @@ export const temporaryConfig = { return ( widgets || [ { + __typename: 'LocalWorkItemLabels', + type: WIDGET_TYPE_LABELS, + allowScopedLabels: true, + nodes: [], + }, + { __typename: 'LocalWorkItemWeight', type: 'WEIGHT', weight: null, @@ -56,6 +62,13 @@ export const resolvers = { ); weightWidget.weight = input.weight; } + + if (input.labels) { + const labelsWidget = draftData.workItem.mockWidgets.find( + (widget) => widget.type === WIDGET_TYPE_LABELS, + ); + labelsWidget.nodes = [...input.labels]; + } }); cache.writeQuery({ diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index 71ac263a02e..48228b15a53 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -1,5 +1,6 @@ enum LocalWidgetType { ASSIGNEES + LABELS WEIGHT } @@ -12,6 +13,12 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget { nodes: [UserCore] } +type LocalWorkItemLabels implements LocalWorkItemWidget { + type: LocalWidgetType! + allowScopedLabels: Boolean! + nodes: [Label!] +} + type LocalWorkItemWeight implements LocalWorkItemWidget { type: LocalWidgetType! weight: Int @@ -24,6 +31,7 @@ extend type WorkItem { input LocalUpdateWorkItemInput { id: WorkItemID! assignees: [UserCore!] + labels: [Label] weight: Int } diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 724741c9957..61cb8802187 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -1,9 +1,17 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" #import "./work_item.fragment.graphql" query workItem($id: WorkItemID!) { workItem(id: $id) { ...WorkItem mockWidgets @client { + ... on LocalWorkItemLabels { + type + allowScopedLabels + nodes { + ...Label + } + } ... on LocalWorkItemWeight { type weight diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index e5910dcd5d2..9220fa82b46 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -23,3 +23,13 @@ display: block; } } + +.work-item-labels { + .gl-token { + padding-left: $gl-spacing-scale-1; + } + + .gl-token-close { + display: none; + } +} diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 24685d26fc9..ad59f421c06 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -18,8 +18,6 @@ class Projects::JobsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize before_action :verify_proxy_request!, only: :proxy_websocket_authorize - before_action :push_jobs_table_vue, only: [:index] - before_action :push_jobs_table_vue_search, only: [:index] before_action :push_job_log_search, only: [:show] before_action :reject_if_build_artifacts_size_refreshing!, only: [:erase] @@ -251,14 +249,6 @@ class Projects::JobsController < Projects::ApplicationController ::Gitlab::Workhorse.channel_websocket(service) end - def push_jobs_table_vue - push_frontend_feature_flag(:jobs_table_vue, @project) - end - - def push_jobs_table_vue_search - push_frontend_feature_flag(:jobs_table_vue_search, @project) - end - def push_job_log_search push_frontend_feature_flag(:job_log_search, @project) end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index b1fdfed5ae0..ec97ab0ea42 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -174,6 +174,10 @@ class MergeRequest < ApplicationRecord merge_request.merge_jid = nil end + before_transition any => :closed do |merge_request| + merge_request.merge_error = nil + end + after_transition any => :opened do |merge_request| merge_request.run_after_commit do UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) diff --git a/app/services/ci/runners/reconcile_existing_runner_versions_service.rb b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb index e15676faa33..e04079bfe27 100644 --- a/app/services/ci/runners/reconcile_existing_runner_versions_service.rb +++ b/app/services/ci/runners/reconcile_existing_runner_versions_service.rb @@ -75,7 +75,8 @@ module Ci def runner_version_with_updated_status(runner_version) version = runner_version['version'] - new_status = upgrade_check.check_runner_upgrade_status(version) + suggestion = upgrade_check.check_runner_upgrade_status(version) + new_status = suggestion.each_key.first if new_status != :error && new_status != runner_version['status'].to_sym { diff --git a/app/services/gravatar_service.rb b/app/services/gravatar_service.rb index a689b088854..9d5990f2c8a 100644 --- a/app/services/gravatar_service.rb +++ b/app/services/gravatar_service.rb @@ -2,6 +2,7 @@ class GravatarService def execute(email, size = nil, scale = 2, username: nil) + return if Gitlab::FIPS.enabled? return unless Gitlab::CurrentSettings.gravatar_enabled? identifier = email.presence || username.presence diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb deleted file mode 100644 index 279d3051848..00000000000 --- a/app/services/issuable/clone/attributes_rewriter.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -module Issuable - module Clone - class AttributesRewriter < ::Issuable::Clone::BaseService - def initialize(current_user, original_entity, new_entity) - @current_user = current_user - @original_entity = original_entity - @new_entity = new_entity - end - - def execute - update_attributes = { labels: cloneable_labels } - - milestone = matching_milestone(original_entity.milestone&.title) - update_attributes[:milestone] = milestone if milestone.present? - - new_entity.update(update_attributes) - - copy_resource_label_events - copy_resource_milestone_events - copy_resource_state_events - end - - private - - def matching_milestone(title) - return if title.blank? || !new_entity.supports_milestone? - - params = { title: title, project_ids: new_entity.project&.id, group_ids: group&.id } - - milestones = MilestonesFinder.new(params).execute - milestones.first - end - - def cloneable_labels - params = { - project_id: new_entity.project&.id, - group_id: group&.id, - title: original_entity.labels.select(:title), - include_ancestor_groups: true - } - - params[:only_group_labels] = true if new_parent.is_a?(Group) - - LabelsFinder.new(current_user, params).execute - end - - def copy_resource_label_events - copy_events(ResourceLabelEvent.table_name, original_entity.resource_label_events) do |event| - event.attributes - .except('id', 'reference', 'reference_html') - .merge(entity_key => new_entity.id, 'action' => ResourceLabelEvent.actions[event.action]) - end - end - - def copy_resource_milestone_events - return unless milestone_events_supported? - - copy_events(ResourceMilestoneEvent.table_name, original_entity.resource_milestone_events) do |event| - if event.remove? - event_attributes_with_milestone(event, nil) - else - matching_destination_milestone = matching_milestone(event.milestone_title) - - event_attributes_with_milestone(event, matching_destination_milestone) if matching_destination_milestone.present? - end - end - end - - def copy_resource_state_events - return unless state_events_supported? - - copy_events(ResourceStateEvent.table_name, original_entity.resource_state_events) do |event| - event.attributes - .except(*blocked_state_event_attributes) - .merge(entity_key => new_entity.id, - 'state' => ResourceStateEvent.states[event.state]) - end - end - - # Overriden on EE::Issuable::Clone::AttributesRewriter - def blocked_state_event_attributes - ['id'] - end - - def event_attributes_with_milestone(event, milestone) - event.attributes - .except('id') - .merge(entity_key => new_entity.id, - 'milestone_id' => milestone&.id, - 'action' => ResourceMilestoneEvent.actions[event.action], - 'state' => ResourceMilestoneEvent.states[event.state]) - end - - def copy_events(table_name, events_to_copy) - events_to_copy.find_in_batches do |batch| - events = batch.map do |event| - yield(event) - end.compact - - ApplicationRecord.legacy_bulk_insert(table_name, events) # rubocop:disable Gitlab/BulkInsert - end - end - - def entity_key - new_entity.class.name.underscore.foreign_key - end - - def milestone_events_supported? - both_respond_to?(:resource_milestone_events) - end - - def state_events_supported? - both_respond_to?(:resource_state_events) - end - - def both_respond_to?(method) - original_entity.respond_to?(method) && - new_entity.respond_to?(method) - end - end - end -end - -Issuable::Clone::AttributesRewriter.prepend_mod_with('Issuable::Clone::AttributesRewriter') diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb index ce9918a4b56..6061a4b6012 100644 --- a/app/services/issuable/clone/base_service.rb +++ b/app/services/issuable/clone/base_service.rb @@ -25,19 +25,19 @@ module Issuable private - def copy_award_emoji - AwardEmojis::CopyService.new(original_entity, new_entity).execute - end - - def copy_notes - Notes::CopyService.new(current_user, original_entity, new_entity).execute + def rewritten_old_entity_attributes(include_milestone: true) + Gitlab::Issuable::Clone::AttributesRewriter.new( + current_user, + original_entity, + target_project + ).execute(include_milestone: include_milestone) end def update_new_entity update_new_entity_description - update_new_entity_attributes copy_award_emoji copy_notes + copy_resource_events end def update_new_entity_description @@ -52,8 +52,16 @@ module Issuable new_entity.update!(update_description_params) end - def update_new_entity_attributes - AttributesRewriter.new(current_user, original_entity, new_entity).execute + def copy_award_emoji + AwardEmojis::CopyService.new(original_entity, new_entity).execute + end + + def copy_notes + Notes::CopyService.new(current_user, original_entity, new_entity).execute + end + + def copy_resource_events + Gitlab::Issuable::Clone::CopyResourceEventsService.new(current_user, original_entity, new_entity).execute end def update_old_entity @@ -74,12 +82,6 @@ module Issuable new_entity.resource_parent end - def group - if new_entity.project&.group && current_user.can?(:read_group, new_entity.project.group) - new_entity.project.group - end - end - def relative_position return if original_entity.project.root_ancestor.id != target_project.root_ancestor.id diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb index c675f957cd7..896b15a14b8 100644 --- a/app/services/issues/clone_service.rb +++ b/app/services/issues/clone_service.rb @@ -41,9 +41,12 @@ module Issues def update_new_entity # we don't call `super` because we want to be able to decide whether or not to copy all comments over. update_new_entity_description - update_new_entity_attributes copy_award_emoji - copy_notes if with_notes + + if with_notes + copy_notes + copy_resource_events + end end def update_old_entity @@ -62,14 +65,18 @@ module Issues } new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params) + new_params = new_params.merge(rewritten_old_entity_attributes) + new_params.delete(:created_at) + new_params.delete(:updated_at) # spam checking is not necessary, as no new content is being created. Passing nil for # spam_params will cause SpamActionService to skip checking and return a success response. spam_params = nil - # Skip creation of system notes for existing attributes of the issue. The system notes of the old - # issue are copied over so we don't want to end up with duplicate notes. - CreateService.new(project: target_project, current_user: current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: true) + # Skip creation of system notes for existing attributes of the issue when cloning with notes. + # The system notes of the old issue are copied over so we don't want to end up with duplicate notes. + # When cloning without notes, we want to generate system notes for the attributes that were copied. + CreateService.new(project: target_project, current_user: current_user, params: new_params, spam_params: spam_params).execute(skip_system_notes: with_notes) end def queue_copy_designs diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index d210ba2a76c..edab62b1fdf 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -76,6 +76,7 @@ module Issues } new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params) + new_params = new_params.merge(rewritten_old_entity_attributes) # spam checking is not necessary, as no new content is being created. Passing nil for # spam_params will cause SpamActionService to skip checking and return a success response. spam_params = nil diff --git a/app/services/work_items/create_and_link_service.rb b/app/services/work_items/create_and_link_service.rb index 534d220a846..6a773a84225 100644 --- a/app/services/work_items/create_and_link_service.rb +++ b/app/services/work_items/create_and_link_service.rb @@ -25,7 +25,11 @@ module WorkItems work_item = create_result[:work_item] return ::ServiceResponse.success(payload: payload(work_item)) if @link_params.blank? - result = IssueLinks::CreateService.new(work_item, @current_user, @link_params).execute + result = WorkItems::ParentLinks::CreateService.new( + @link_params[:parent_work_item], + @current_user, + { target_issuable: work_item } + ).execute if result[:status] == :success ::ServiceResponse.success(payload: payload(work_item)) diff --git a/app/services/work_items/create_from_task_service.rb b/app/services/work_items/create_from_task_service.rb index 4203c96e676..d5fa5fca772 100644 --- a/app/services/work_items/create_from_task_service.rb +++ b/app/services/work_items/create_from_task_service.rb @@ -17,7 +17,7 @@ module WorkItems current_user: @current_user, params: @work_item_params.slice(:title, :work_item_type_id), spam_params: @spam_params, - link_params: { target_issuable: @work_item } + link_params: { parent_work_item: @work_item } ).execute if create_and_link_result.error? diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml index dfea4db4d07..d39d292fb53 100644 --- a/app/views/projects/jobs/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -2,12 +2,4 @@ - add_page_specific_style 'page_bundles/ci_status' - admin = local_assigns.fetch(:admin, false) -- if Feature.enabled?(:jobs_table_vue, @project) - #js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } } -- else - .top-area - - build_path_proc = ->(scope) { project_jobs_path(@project, scope: scope) } - = render "shared/builds/tabs", build_path_proc: build_path_proc, all_builds: @all_builds, scope: @scope - - .content-list.builds-content-list - = render "table", builds: @builds, project: @project +#js-jobs-table{ data: { admin: admin, full_path: @project.full_path, job_statuses: job_statuses.to_json, pipeline_editor_path: project_ci_pipeline_editor_path(@project), empty_state_svg_path: image_path('jobs-empty-state.svg') } } diff --git a/config/feature_flags/development/ci_value_change_for_processable_and_rules_entry.yml b/config/feature_flags/development/ci_value_change_for_processable_and_rules_entry.yml deleted file mode 100644 index f7c2542cb22..00000000000 --- a/config/feature_flags/development/ci_value_change_for_processable_and_rules_entry.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: ci_value_change_for_processable_and_rules_entry -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90238 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/365876 -milestone: '15.2' -type: development -group: group::pipeline authoring -default_enabled: false diff --git a/config/feature_flags/development/jobs_table_vue.yml b/config/feature_flags/development/jobs_table_vue.yml deleted file mode 100644 index ea489278b20..00000000000 --- a/config/feature_flags/development/jobs_table_vue.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: jobs_table_vue -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57155 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/327500 -milestone: '13.11' -type: development -group: group::pipeline execution -default_enabled: false diff --git a/config/feature_flags/development/jobs_table_vue_search.yml b/config/feature_flags/development/jobs_table_vue_search.yml deleted file mode 100644 index ad0c25eccce..00000000000 --- a/config/feature_flags/development/jobs_table_vue_search.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: jobs_table_vue_search -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82539 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/356007 -milestone: '14.10' -type: development -group: group::pipeline execution -default_enabled: false diff --git a/config/feature_flags/development/standard_context_type_check.yml b/config/feature_flags/development/standard_context_type_check.yml deleted file mode 100644 index 1c6094abbf4..00000000000 --- a/config/feature_flags/development/standard_context_type_check.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: standard_context_type_check -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88540 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364265 -milestone: '15.1' -type: development -group: group::product intelligence -default_enabled: false diff --git a/doc/administration/geo/replication/datatypes.md b/doc/administration/geo/replication/datatypes.md index e49d5260233..acd27d5feed 100644 --- a/doc/administration/geo/replication/datatypes.md +++ b/doc/administration/geo/replication/datatypes.md @@ -202,8 +202,8 @@ successfully, you must replicate their data using some other means. |[External merge request diffs](../../merge_request_diffs.md) | **Yes** (13.5) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication is behind the feature flag `geo_merge_request_diff_replication`, enabled by default. Verification was behind the feature flag `geo_merge_request_diff_verification`, removed in 14.7.| |[Versioned snippets](../../../user/snippets.md#versioned-snippets) | [**Yes** (13.7)](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [**Yes** (14.2)](https://gitlab.com/groups/gitlab-org/-/epics/2810) | N/A | N/A | Verification was implemented behind the feature flag `geo_snippet_repository_verification` in 13.11, and the feature flag was removed in 14.2. | |[GitLab Pages](../../pages/index.md) | [**Yes** (14.3)](https://gitlab.com/groups/gitlab-org/-/epics/589) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Behind feature flag `geo_pages_deployment_replication`, enabled by default. Verification was behind the feature flag `geo_pages_deployment_verification`, removed in 14.7. | -|[Incident Metric Images](../../../operations/incident_management/incidents.md#metrics) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/352326) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | No | No | | -|[Alert Metric Images](../../../operations/incident_management/alerts.md#metrics-tab) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/352326) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | No | No | | +|[Incident Metric Images](../../../operations/incident_management/incidents.md#metrics) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362561) | No | No | | +|[Alert Metric Images](../../../operations/incident_management/alerts.md#metrics-tab) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/362564) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362564) | No | No | | |[Server-side Git hooks](../../server_hooks.md) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/1867) | No | N/A | N/A | Not planned because of current implementation complexity, low customer interest, and availability of alternatives to hooks. | |[Elasticsearch integration](../../../integration/elasticsearch.md) | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/1186) | No | No | No | Not planned because further product discovery is required and Elasticsearch (ES) clusters can be rebuilt. Secondaries use the same ES cluster as the primary. | |[Dependency proxy images](../../../user/packages/dependency_proxy/index.md) | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/259694) | No | No | No | Blocked by [Geo: Secondary Mimicry](https://gitlab.com/groups/gitlab-org/-/epics/1528). Replication of this cache is not needed for disaster recovery purposes because it can be recreated from external sources. | diff --git a/doc/ci/jobs/index.md b/doc/ci/jobs/index.md index 373cc22e675..fd6afb1a0ad 100644 --- a/doc/ci/jobs/index.md +++ b/doc/ci/jobs/index.md @@ -45,9 +45,6 @@ Clicking an individual job shows you its job log, and allows you to: ### View all jobs in a project -> - An improved view was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/293862) in GitLab 14.10, [with a flag](../../administration/feature_flags.md) named `jobs_table_vue`. Disabled by default. -> - The job status filter was [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82539) in GitLab 14.10, [with a flag](../../administration/feature_flags.md) named `jobs_table_vue_search`. Disabled by default. - To view the full list of jobs that ran in a project: 1. On the top bar, select **Menu > Projects** and find the project. diff --git a/doc/subscriptions/self_managed/index.md b/doc/subscriptions/self_managed/index.md index edb51b8b4c5..e2d47d389ec 100644 --- a/doc/subscriptions/self_managed/index.md +++ b/doc/subscriptions/self_managed/index.md @@ -121,6 +121,12 @@ GitLab has several features which can help you manage the number of users: > Introduced in GitLab 14.1. +Prerequisites: + +- You must be running GitLab Enterprise Edition (EE). +- You must have GitLab 14.1 or later. +- Your instance must be connected to the internet, and not be in an offline environment. + To sync subscription data between your self-managed instance and GitLab, you must [activate your instance](../../user/admin_area/license.md) with an activation code. diff --git a/doc/user/project/members/index.md b/doc/user/project/members/index.md index 7bea57d180b..ff5f2ac8cb6 100644 --- a/doc/user/project/members/index.md +++ b/doc/user/project/members/index.md @@ -158,7 +158,7 @@ group itself. Prerequisites: -- You must have the Owner role. +- You must have the Maintainer or Owner role. - Optional. Unassign the member from all issues and merge requests that are assigned to them. diff --git a/lib/api/geo.rb b/lib/api/geo.rb index 85f242cd135..cb04d2a4e1e 100644 --- a/lib/api/geo.rb +++ b/lib/api/geo.rb @@ -8,7 +8,7 @@ module API helpers do # Overridden in EE def geo_proxy_response - {} + { geo_enabled: false } end end diff --git a/lib/gitlab/ci/config/entry/processable.rb b/lib/gitlab/ci/config/entry/processable.rb index d17c52a3ace..78794f524f4 100644 --- a/lib/gitlab/ci/config/entry/processable.rb +++ b/lib/gitlab/ci/config/entry/processable.rb @@ -86,16 +86,10 @@ module Gitlab @entries.delete(:except) unless except_defined? # rubocop:disable Gitlab/ModuleWithInstanceVariables end - unless ::Feature.enabled?(:ci_value_change_for_processable_and_rules_entry) - validate_against_warnings unless has_workflow_rules - end - yield if block_given? end - if ::Feature.enabled?(:ci_value_change_for_processable_and_rules_entry) - validate_against_warnings unless has_workflow_rules - end + validate_against_warnings unless has_workflow_rules end def validate_against_warnings diff --git a/lib/gitlab/ci/config/entry/rules.rb b/lib/gitlab/ci/config/entry/rules.rb index 4f2f08889a1..91be1bb3ee4 100644 --- a/lib/gitlab/ci/config/entry/rules.rb +++ b/lib/gitlab/ci/config/entry/rules.rb @@ -13,12 +13,7 @@ module Gitlab end def value - if ::Feature.enabled?(:ci_value_change_for_processable_and_rules_entry) - # `flatten` is needed to make it work with nested `!reference` - [super].flatten - else - [@config].flatten - end + [super].flatten end def composable_class diff --git a/lib/gitlab/issuable/clone/attributes_rewriter.rb b/lib/gitlab/issuable/clone/attributes_rewriter.rb new file mode 100644 index 00000000000..0fa75f745ca --- /dev/null +++ b/lib/gitlab/issuable/clone/attributes_rewriter.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Gitlab + module Issuable + module Clone + class AttributesRewriter + attr_reader :current_user, :original_entity, :target_parent + + def initialize(current_user, original_entity, target_parent) + @current_user = current_user + @original_entity = original_entity + @target_parent = target_parent + end + + def execute(include_milestone: true) + attributes = { label_ids: cloneable_labels.pluck_primary_key } + + if include_milestone + milestone = matching_milestone(original_entity.milestone&.title) + attributes[:milestone_id] = milestone.id if milestone.present? + end + + attributes + end + + private + + def cloneable_labels + params = { + project_id: project&.id, + group_id: group&.id, + title: original_entity.labels.select(:title), + include_ancestor_groups: true + } + + params[:only_group_labels] = true if target_parent.is_a?(Group) + + LabelsFinder.new(current_user, params).execute + end + + def matching_milestone(title) + return if title.blank? + + params = { title: title, project_ids: project&.id, group_ids: group&.id } + + milestones = MilestonesFinder.new(params).execute + milestones.first + end + + def project + target_parent if target_parent.is_a?(Project) + end + + def group + if target_parent.is_a?(Group) + target_parent + elsif target_parent&.group && current_user.can?(:read_group, target_parent.group) + target_parent.group + end + end + end + end + end +end diff --git a/lib/gitlab/issuable/clone/copy_resource_events_service.rb b/lib/gitlab/issuable/clone/copy_resource_events_service.rb new file mode 100644 index 00000000000..563805fcb01 --- /dev/null +++ b/lib/gitlab/issuable/clone/copy_resource_events_service.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Gitlab + module Issuable + module Clone + class CopyResourceEventsService + attr_reader :current_user, :original_entity, :new_entity + + def initialize(current_user, original_entity, new_entity) + @current_user = current_user + @original_entity = original_entity + @new_entity = new_entity + end + + def execute + copy_resource_label_events + copy_resource_milestone_events + copy_resource_state_events + end + + private + + def copy_resource_label_events + copy_events(ResourceLabelEvent.table_name, original_entity.resource_label_events) do |event| + event.attributes + .except('id', 'reference', 'reference_html') + .merge(entity_key => new_entity.id, 'action' => ResourceLabelEvent.actions[event.action]) + end + end + + def copy_resource_milestone_events + return unless milestone_events_supported? + + copy_events(ResourceMilestoneEvent.table_name, original_entity.resource_milestone_events) do |event| + if event.remove? + event_attributes_with_milestone(event, nil) + else + destination_milestone = matching_milestone(event.milestone_title) + + event_attributes_with_milestone(event, destination_milestone) if destination_milestone.present? + end + end + end + + def copy_resource_state_events + return unless state_events_supported? + + copy_events(ResourceStateEvent.table_name, original_entity.resource_state_events) do |event| + event.attributes + .except(*blocked_state_event_attributes) + .merge(entity_key => new_entity.id, + 'state' => ResourceStateEvent.states[event.state]) + end + end + + # Overriden on EE::Gitlab::Issuable::Clone::CopyResourceEventsService + def blocked_state_event_attributes + ['id'] + end + + def event_attributes_with_milestone(event, milestone) + event.attributes + .except('id') + .merge(entity_key => new_entity.id, + 'milestone_id' => milestone&.id, + 'action' => ResourceMilestoneEvent.actions[event.action], + 'state' => ResourceMilestoneEvent.states[event.state]) + end + + def copy_events(table_name, events_to_copy) + events_to_copy.find_in_batches do |batch| + events = batch.map do |event| + yield(event) + end.compact + + ApplicationRecord.legacy_bulk_insert(table_name, events) # rubocop:disable Gitlab/BulkInsert + end + end + + def entity_key + new_entity.class.name.underscore.foreign_key + end + + def milestone_events_supported? + both_respond_to?(:resource_milestone_events) + end + + def state_events_supported? + both_respond_to?(:resource_state_events) + end + + def both_respond_to?(method) + original_entity.respond_to?(method) && + new_entity.respond_to?(method) + end + + def matching_milestone(title) + return if title.blank? || !new_entity.supports_milestone? + + params = { title: title, project_ids: new_entity.project&.id, group_ids: group&.id } + + milestones = MilestonesFinder.new(params).execute + milestones.first + end + + def group + if new_entity.project&.group && current_user.can?(:read_group, new_entity.project.group) + new_entity.project.group + end + end + end + end + end +end + +Gitlab::Issuable::Clone::CopyResourceEventsService.prepend_mod diff --git a/lib/gitlab/jira_import/issue_serializer.rb b/lib/gitlab/jira_import/issue_serializer.rb index 85e0188aa47..70ec6f08fcd 100644 --- a/lib/gitlab/jira_import/issue_serializer.rb +++ b/lib/gitlab/jira_import/issue_serializer.rb @@ -48,9 +48,9 @@ module Gitlab def map_status(jira_status_category) case jira_status_category["key"].downcase when 'done' - Issuable::STATE_ID_MAP[:closed] + ::Issuable::STATE_ID_MAP[:closed] else - Issuable::STATE_ID_MAP[:opened] + ::Issuable::STATE_ID_MAP[:opened] end end diff --git a/lib/gitlab/quick_actions/issuable_actions.rb b/lib/gitlab/quick_actions/issuable_actions.rb index 259d9e38d65..3b85d6952a1 100644 --- a/lib/gitlab/quick_actions/issuable_actions.rb +++ b/lib/gitlab/quick_actions/issuable_actions.rb @@ -23,7 +23,7 @@ module Gitlab _('Closed this %{quick_action_target}.') % { quick_action_target: quick_action_target.to_ability_name.humanize(capitalize: false) } end - types Issuable + types ::Issuable condition do quick_action_target.persisted? && quick_action_target.open? && @@ -45,7 +45,7 @@ module Gitlab _('Reopened this %{quick_action_target}.') % { quick_action_target: quick_action_target.to_ability_name.humanize(capitalize: false) } end - types Issuable + types ::Issuable condition do quick_action_target.persisted? && quick_action_target.closed? && @@ -63,7 +63,7 @@ module Gitlab _('Changed the title to "%{title_param}".') % { title_param: title_param } end params '<New title>' - types Issuable + types ::Issuable condition do quick_action_target.persisted? && current_user.can?(:"update_#{quick_action_target.to_ability_name}", quick_action_target) @@ -82,7 +82,7 @@ module Gitlab end end params '~label1 ~"label 2"' - types Issuable + types ::Issuable condition do current_user.can?(:"set_#{quick_action_target.to_ability_name}_metadata", quick_action_target) && find_labels.any? @@ -102,7 +102,7 @@ module Gitlab end end params '~label1 ~"label 2"' - types Issuable + types ::Issuable condition do quick_action_target.persisted? && quick_action_target.labels.any? && @@ -134,7 +134,7 @@ module Gitlab "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any? end params '~label1 ~"label 2"' - types Issuable + types ::Issuable condition do quick_action_target.persisted? && quick_action_target.labels.any? && @@ -147,7 +147,7 @@ module Gitlab desc { _('Add a to do') } explanation { _('Adds a to do.') } execution_message { _('Added a to do.') } - types Issuable + types ::Issuable condition do quick_action_target.persisted? && !TodoService.new.todo_exist?(quick_action_target, current_user) @@ -159,7 +159,7 @@ module Gitlab desc { _('Mark to do as done') } explanation { _('Marks to do as done.') } execution_message { _('Marked to do as done.') } - types Issuable + types ::Issuable condition do quick_action_target.persisted? && TodoService.new.todo_exist?(quick_action_target, current_user) @@ -177,7 +177,7 @@ module Gitlab _('Subscribed to this %{quick_action_target}.') % { quick_action_target: quick_action_target.to_ability_name.humanize(capitalize: false) } end - types Issuable + types ::Issuable condition do quick_action_target.persisted? && !quick_action_target.subscribed?(current_user, project) @@ -195,7 +195,7 @@ module Gitlab _('Unsubscribed from this %{quick_action_target}.') % { quick_action_target: quick_action_target.to_ability_name.humanize(capitalize: false) } end - types Issuable + types ::Issuable condition do quick_action_target.persisted? && quick_action_target.subscribed?(current_user, project) @@ -212,7 +212,7 @@ module Gitlab _("Toggled :%{name}: emoji award.") % { name: name } if name end params ':emoji:' - types Issuable + types ::Issuable condition do quick_action_target.persisted? end @@ -228,14 +228,14 @@ module Gitlab desc { _("Append the comment with %{shrug}") % { shrug: SHRUG } } params '<Comment>' - types Issuable + types ::Issuable substitution :shrug do |comment| "#{comment} #{SHRUG}" end desc { _("Append the comment with %{tableflip}") % { tableflip: TABLEFLIP } } params '<Comment>' - types Issuable + types ::Issuable substitution :tableflip do |comment| "#{comment} #{TABLEFLIP}" end diff --git a/lib/gitlab/tracking/standard_context.rb b/lib/gitlab/tracking/standard_context.rb index 05ddc7e26cc..50467de44b8 100644 --- a/lib/gitlab/tracking/standard_context.rb +++ b/lib/gitlab/tracking/standard_context.rb @@ -7,11 +7,9 @@ module Gitlab GITLAB_RAILS_SOURCE = 'gitlab-rails' def initialize(namespace: nil, project: nil, user: nil, **extra) - if Feature.enabled?(:standard_context_type_check) - check_argument_type(:namespace, namespace, [Namespace]) - check_argument_type(:project, project, [Project, Integer]) - check_argument_type(:user, user, [User, DeployToken]) - end + check_argument_type(:namespace, namespace, [Namespace]) + check_argument_type(:project, project, [Project, Integer]) + check_argument_type(:user, user, [User, DeployToken]) @namespace = namespace @plan = namespace&.actual_plan_name diff --git a/spec/features/projects/jobs/permissions_spec.rb b/spec/features/projects/jobs/permissions_spec.rb index a904ba770dd..b6019944071 100644 --- a/spec/features/projects/jobs/permissions_spec.rb +++ b/spec/features/projects/jobs/permissions_spec.rb @@ -12,8 +12,6 @@ RSpec.describe 'Project Jobs Permissions' do let_it_be(:job) { create(:ci_build, :running, :coverage, :trace_artifact, pipeline: pipeline) } before do - stub_feature_flags(jobs_table_vue: false) - sign_in(user) project.enable_ci @@ -96,8 +94,8 @@ RSpec.describe 'Project Jobs Permissions' do end it_behaves_like 'project jobs page responds with status', 200 do - it 'renders job' do - page.within('.build') do + it 'renders job', :js do + page.within('[data-testid="jobs-table"]') do expect(page).to have_content("##{job.id}") .and have_content(job.sha[0..7]) .and have_content(job.ref) diff --git a/spec/features/projects/jobs/user_browses_jobs_spec.rb b/spec/features/projects/jobs/user_browses_jobs_spec.rb index 07b7a54974a..bb44b70bb3a 100644 --- a/spec/features/projects/jobs/user_browses_jobs_spec.rb +++ b/spec/features/projects/jobs/user_browses_jobs_spec.rb @@ -9,48 +9,11 @@ def visit_jobs_page end RSpec.describe 'User browses jobs' do - describe 'with jobs_table_vue feature flag turned off' do - let!(:build) { create(:ci_build, :coverage, pipeline: pipeline) } - let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } - let(:project) { create(:project, :repository, namespace: user.namespace) } - let(:user) { create(:user) } - - before do - stub_feature_flags(jobs_table_vue: false) - project.add_maintainer(user) - project.enable_ci - build.update!(coverage_regex: '/Coverage (\d+)%/') - - sign_in(user) - - visit(project_jobs_path(project)) - end - - it 'shows the coverage' do - page.within('td.coverage') do - expect(page).to have_content('99.9%') - end - end - - context 'with a failed job' do - let!(:build) { create(:ci_build, :coverage, :failed, pipeline: pipeline) } - - it 'displays a tooltip with the failure reason' do - page.within('.ci-table') do - failed_job_link = page.find('.ci-failed') - expect(failed_job_link[:title]).to eq('Failed - (unknown failure)') - end - end - end - end - - describe 'with jobs_table_vue feature flag turned on', :js do + describe 'Jobs', :js do let(:project) { create(:project, :repository) } let(:user) { create(:user) } before do - stub_feature_flags(jobs_table_vue: true) - project.add_maintainer(user) project.enable_ci @@ -135,6 +98,26 @@ RSpec.describe 'User browses jobs' do end end + context 'with a coverage job' do + let!(:job) do + create(:ci_build, :coverage, pipeline: pipeline) + end + + before do + job.update!(coverage_regex: '/Coverage (\d+)%/') + + visit_jobs_page + + wait_for_requests + end + + it 'shows the coverage' do + page.within('[data-testid="job-coverage"]') do + expect(page).to have_content('99.9%') + end + end + end + context 'with a scheduled job' do let!(:scheduled_job) { create(:ci_build, :scheduled, pipeline: pipeline, name: 'build') } diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index 779d8e41a7b..84c75752bc1 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -20,7 +20,6 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do end before do - stub_feature_flags(jobs_table_vue: false) project.add_role(user, user_access_level) sign_in(user) end @@ -29,9 +28,11 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do context 'with no jobs' do before do visit project_jobs_path(project) + + wait_for_requests end - it 'shows the empty state page' do + it 'shows the empty state page', :js do expect(page).to have_content('Use jobs to automate your tasks') expect(page).to have_link('Create CI/CD configuration file', href: project_ci_pipeline_editor_path(project)) end @@ -40,59 +41,6 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do context 'with a job' do let!(:job) { create(:ci_build, pipeline: pipeline) } - context "Pending scope" do - before do - visit project_jobs_path(project, scope: :pending) - end - - it "shows Pending tab jobs" do - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Pending') - expect(page).to have_content job.short_sha - expect(page).to have_content job.ref - expect(page).to have_content job.name - end - end - - context "Running scope" do - before do - job.run! - visit project_jobs_path(project, scope: :running) - end - - it "shows Running tab jobs" do - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Running') - expect(page).to have_content job.short_sha - expect(page).to have_content job.ref - expect(page).to have_content job.name - end - end - - context "Finished scope" do - before do - job.run! - visit project_jobs_path(project, scope: :finished) - end - - it "shows Finished tab jobs" do - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'Finished') - expect(page).to have_content('Use jobs to automate your tasks') - end - end - - context "All jobs" do - before do - project.builds.running_or_pending.each(&:success) - visit project_jobs_path(project) - end - - it "shows All tab jobs" do - expect(page).to have_selector('[data-testid="jobs-tabs"] a.active', text: 'All') - expect(page).to have_content job.short_sha - expect(page).to have_content job.ref - expect(page).to have_content job.name - end - end - context "when visiting old URL" do let(:jobs_url) do project_jobs_path(project) diff --git a/spec/fixtures/api/schemas/entities/commit.json b/spec/fixtures/api/schemas/entities/commit.json index 324702e3f94..ed08c35f89b 100644 --- a/spec/fixtures/api/schemas/entities/commit.json +++ b/spec/fixtures/api/schemas/entities/commit.json @@ -11,7 +11,7 @@ "author" ], "properties": { - "author_gravatar_url": { "type": "string" }, + "author_gravatar_url": { "type": [ "string", "null" ] }, "commit_url": { "type": "string" }, "commit_path": { "type": "string" }, "author": { diff --git a/spec/fixtures/api/schemas/entities/github/user.json b/spec/fixtures/api/schemas/entities/github/user.json index 3d772a0c648..23db912ad5c 100644 --- a/spec/fixtures/api/schemas/entities/github/user.json +++ b/spec/fixtures/api/schemas/entities/github/user.json @@ -5,7 +5,7 @@ "id": { "type": "integer" }, "login": { "type": "string" }, "url": { "type": "string" }, - "avatar_url": { "type": "string" }, + "avatar_url": { "type": [ "string", "null" ] }, "html_url": { "type": "string" } }, "additionalProperties": false diff --git a/spec/fixtures/api/schemas/entities/member.json b/spec/fixtures/api/schemas/entities/member.json index 88f7d87b269..24a4863df9b 100644 --- a/spec/fixtures/api/schemas/entities/member.json +++ b/spec/fixtures/api/schemas/entities/member.json @@ -62,7 +62,7 @@ "required": ["email", "avatar_url", "can_resend", "user_state"], "properties": { "email": { "type": "string" }, - "avatar_url": { "type": "string" }, + "avatar_url": { "type": [ "string", "null" ] }, "can_resend": { "type": "boolean" }, "user_state": { "type": "string" } }, diff --git a/spec/fixtures/api/schemas/entities/note_user_entity.json b/spec/fixtures/api/schemas/entities/note_user_entity.json index e2bbaad7201..f5d28dd7b71 100644 --- a/spec/fixtures/api/schemas/entities/note_user_entity.json +++ b/spec/fixtures/api/schemas/entities/note_user_entity.json @@ -11,7 +11,7 @@ "properties": { "id": { "type": "integer" }, "state": { "type": "string" }, - "avatar_url": { "type": "string" }, + "avatar_url": { "type": [ "string", "null" ] }, "path": { "type": "string" }, "name": { "type": "string" }, "username": { "type": "string" }, diff --git a/spec/fixtures/api/schemas/entities/user.json b/spec/fixtures/api/schemas/entities/user.json index 3252a37c82a..984b7184d36 100644 --- a/spec/fixtures/api/schemas/entities/user.json +++ b/spec/fixtures/api/schemas/entities/user.json @@ -12,7 +12,7 @@ "properties": { "id": { "type": "integer" }, "state": { "type": "string" }, - "avatar_url": { "type": "string" }, + "avatar_url": { "type": [ "string", "null" ] }, "web_url": { "type": "string" }, "path": { "type": "string" }, "name": { "type": "string" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/user/basic.json b/spec/fixtures/api/schemas/public_api/v4/user/basic.json index 2d815be32a6..d8286f0d84c 100644 --- a/spec/fixtures/api/schemas/public_api/v4/user/basic.json +++ b/spec/fixtures/api/schemas/public_api/v4/user/basic.json @@ -13,7 +13,7 @@ "name": { "type": "string" }, "username": { "type": "string" }, "state": { "type": "string" }, - "avatar_url": { "type": "string" }, + "avatar_url": { "type": [ "string", "null" ] }, "web_url": { "type": "string" } } } diff --git a/spec/fixtures/api/schemas/public_api/v4/user/public.json b/spec/fixtures/api/schemas/public_api/v4/user/public.json index 0955c70aef0..c4549e3ef63 100644 --- a/spec/fixtures/api/schemas/public_api/v4/user/public.json +++ b/spec/fixtures/api/schemas/public_api/v4/user/public.json @@ -39,7 +39,7 @@ "type": "string", "enum": ["active", "blocked"] }, - "avatar_url": { "type": "string" }, + "avatar_url": { "type": [ "string", "null" ] }, "web_url": { "type": "string" }, "created_at": { "type": "string", "format": "date-time" }, "bio": { "type": ["string", "null"] }, diff --git a/spec/frontend/content_editor/remark_markdown_processing_spec.js b/spec/frontend/content_editor/remark_markdown_processing_spec.js index cd63720a433..42cb077a51c 100644 --- a/spec/frontend/content_editor/remark_markdown_processing_spec.js +++ b/spec/frontend/content_editor/remark_markdown_processing_spec.js @@ -122,10 +122,10 @@ const { }); describe('Client side Markdown processing', () => { - const deserialize = async (content) => { + const deserialize = async (markdown) => { const { document } = await remarkMarkdownDeserializer().deserialize({ schema: tiptapEditor.schema, - content, + markdown, }); return document; diff --git a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js index 0ac6fdb9ce4..31bddceb48b 100644 --- a/spec/frontend/content_editor/render_html_and_json_for_all_examples.js +++ b/spec/frontend/content_editor/render_html_and_json_for_all_examples.js @@ -78,7 +78,7 @@ export const IMPLEMENTATION_ERROR_MSG = 'Error - check implementation'; async function renderMarkdownToHTMLAndJSON(markdown, schema, deserializer) { let prosemirrorDocument; try { - const { document } = await deserializer.deserialize({ schema, content: markdown }); + const { document } = await deserializer.deserialize({ schema, markdown }); prosemirrorDocument = document; } catch (e) { const errorMsg = `${IMPLEMENTATION_ERROR_MSG}:\n${e.message}`; diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 68806f9c26a..509cda3046c 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -1177,7 +1177,7 @@ Oranges are orange [^1] }; it.each` - mark | content | modifiedContent | editAction + mark | markdown | modifiedMarkdown | editAction ${'bold'} | ${'**bold**'} | ${'**bold modified**'} | ${defaultEditAction} ${'bold'} | ${'__bold__'} | ${'__bold modified__'} | ${defaultEditAction} ${'bold'} | ${'<strong>bold</strong>'} | ${'<strong>bold modified</strong>'} | ${defaultEditAction} @@ -1213,10 +1213,10 @@ Oranges are orange [^1] ${'taskList'} | ${'2) [x] task list item'} | ${'2) [x] task list item modified'} | ${defaultEditAction} `( 'preserves original $mark syntax when sourceMarkdown is available for $content', - async ({ content, modifiedContent, editAction }) => { + async ({ markdown, modifiedMarkdown, editAction }) => { const { document } = await remarkMarkdownDeserializer().deserialize({ schema: tiptapEditor.schema, - content, + markdown, }); editAction(document); @@ -1226,7 +1226,7 @@ Oranges are orange [^1] doc: tiptapEditor.state.doc, }); - expect(serialized).toEqual(modifiedContent); + expect(serialized).toEqual(modifiedMarkdown); }, ); }); diff --git a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js index 8a304c73163..2efc73ddef8 100644 --- a/spec/frontend/content_editor/services/markdown_sourcemap_spec.js +++ b/spec/frontend/content_editor/services/markdown_sourcemap_spec.js @@ -77,7 +77,7 @@ describe('content_editor/services/markdown_sourcemap', () => { render: () => BULLET_LIST_HTML, }).deserialize({ schema: tiptapEditor.schema, - content: BULLET_LIST_MARKDOWN, + markdown: BULLET_LIST_MARKDOWN, }); const expected = doc( diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index a79982fa647..24c6a78d881 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -29,7 +29,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do before do allow(Gitlab::Ci::RunnerUpgradeCheck.instance) .to receive(:check_runner_upgrade_status) - .and_return(:not_available) + .and_return({ not_available: nil }) end describe do diff --git a/spec/frontend/jobs/components/table/job_table_app_spec.js b/spec/frontend/jobs/components/table/job_table_app_spec.js index 963112fbd5e..4e54d2d3a1d 100644 --- a/spec/frontend/jobs/components/table/job_table_app_spec.js +++ b/spec/frontend/jobs/components/table/job_table_app_spec.js @@ -30,7 +30,6 @@ jest.mock('~/flash'); describe('Job table app', () => { let wrapper; - let jobsTableVueSearch = true; const successHandler = jest.fn().mockResolvedValue(mockJobsResponsePaginated); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); @@ -66,7 +65,6 @@ describe('Job table app', () => { }, provide: { fullPath: projectPath, - glFeatures: { jobsTableVueSearch }, }, apolloProvider: createMockApolloProvider(handler), }); @@ -230,13 +228,5 @@ describe('Job table app', () => { expect(createFlash).toHaveBeenCalledWith(expectedWarning); expect(wrapper.vm.$apollo.queries.jobs.refetch).toHaveBeenCalledTimes(0); }); - - it('should not display filtered search', () => { - jobsTableVueSearch = false; - - createComponent(); - - expect(findFilteredSearch().exists()).toBe(false); - }); }); }); diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index a41b52a597e..1b93d13f3f2 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -906,6 +906,19 @@ describe('MrWidgetOptions', () => { }); }); + describe('merge error', () => { + it.each` + state | show | showText + ${'closed'} | ${false} | ${'hides'} + ${'merged'} | ${true} | ${'shows'} + ${'open'} | ${true} | ${'shows'} + `('it $showText merge error when state is $state', ({ state, show }) => { + createComponent({ ...mockData, state, merge_error: 'Error!' }); + + expect(wrapper.find('[data-testid="merge_error"]').exists()).toBe(show); + }); + }); + describe('mock extension', () => { let pollRequest; diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js new file mode 100644 index 00000000000..1734b901d1a --- /dev/null +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -0,0 +1,171 @@ +import { GlTokenSelector, GlSkeletonLoader } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; +import { i18n } from '~/work_items/constants'; +import { temporaryConfig, resolvers } from '~/work_items/graphql/provider'; +import { projectLabelsResponse, mockLabels, workItemQueryResponse } from '../mock_data'; + +Vue.use(VueApollo); + +const workItemId = 'gid://gitlab/WorkItem/1'; + +describe('WorkItemLabels component', () => { + let wrapper; + + const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + + const findEmptyState = () => wrapper.findByTestId('empty-state'); + + const successSearchQueryHandler = jest.fn().mockResolvedValue(projectLabelsResponse); + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); + + const createComponent = ({ + labels = mockLabels, + canUpdate = true, + searchQueryHandler = successSearchQueryHandler, + } = {}) => { + const apolloProvider = createMockApollo([[labelSearchQuery, searchQueryHandler]], resolvers, { + typePolicies: temporaryConfig.cacheConfig.typePolicies, + }); + + apolloProvider.clients.defaultClient.writeQuery({ + query: workItemQuery, + variables: { + id: workItemId, + }, + data: workItemQueryResponse.data, + }); + + wrapper = mountExtended(WorkItemLabels, { + provide: { + fullPath: 'test-project-path', + }, + propsData: { + labels, + workItemId, + canUpdate, + }, + attachTo: document.body, + apolloProvider, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('focuses token selector on token selector input event', async () => { + createComponent(); + findTokenSelector().vm.$emit('input', [mockLabels[0]]); + await nextTick(); + + expect(findEmptyState().exists()).toBe(false); + expect(findTokenSelector().element.contains(document.activeElement)).toBe(true); + }); + + it('does not start search by default', () => { + createComponent(); + + expect(findTokenSelector().props('loading')).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toEqual([]); + }); + + it('starts search on hovering for more than 250ms', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('starts search on focusing token selector', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(true); + }); + + it('does not start searching if token-selector was hovered for less than 250ms', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('does not start searching if cursor was moved out from token selector before 250ms passed', async () => { + createComponent(); + findTokenSelector().trigger('mouseover'); + jest.advanceTimersByTime(100); + + findTokenSelector().trigger('mouseout'); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await nextTick(); + + expect(findTokenSelector().props('loading')).toBe(false); + }); + + it('shows skeleton loader on dropdown when loading', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('shows list in dropdown when loaded', async () => { + createComponent(); + findTokenSelector().vm.$emit('focus'); + await nextTick(); + + expect(findSkeletonLoader().exists()).toBe(true); + + await waitForPromises(); + + expect(findSkeletonLoader().exists()).toBe(false); + expect(findTokenSelector().props('dropdownItems')).toHaveLength(2); + }); + + it.each([true, false])( + 'passes canUpdate=%s prop to view-only of token-selector', + async (canUpdate) => { + createComponent({ canUpdate }); + + await waitForPromises(); + + expect(findTokenSelector().props('viewOnly')).toBe(!canUpdate); + }, + ); + + it('emits error event if search query fails', async () => { + createComponent({ searchQueryHandler: errorHandler }); + findTokenSelector().vm.$emit('focus'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.fetchError]]); + }); + + it('should search for with correct key after text input', async () => { + const searchKey = 'Hello'; + + createComponent(); + findTokenSelector().vm.$emit('focus'); + findTokenSelector().vm.$emit('text-input', searchKey); + await waitForPromises(); + + expect(successSearchQueryHandler).toHaveBeenCalledWith( + expect.objectContaining({ search: searchKey }), + ); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 676097eb553..deea50ec412 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -456,3 +456,34 @@ export const currentUserNullResponse = { currentUser: null, }, }; + +export const mockLabels = [ + { + __typename: 'Label', + id: 'gid://gitlab/Label/1', + title: 'Label 1', + description: '', + color: '#f00', + textColor: '#00f', + }, + { + __typename: 'Label', + id: 'gid://gitlab/Label/2', + title: 'Label 2', + description: '', + color: '#b00', + textColor: '#00b', + }, +]; + +export const projectLabelsResponse = { + data: { + workspace: { + id: '1', + __typename: 'Project', + labels: { + nodes: mockLabels, + }, + }, + }, +}; diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js index 6fb7bb5226e..530b81deb3a 100644 --- a/spec/frontend/work_items/pages/work_item_detail_spec.js +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -9,6 +9,7 @@ import WorkItemDescription from '~/work_items/components/work_item_description.v import WorkItemState from '~/work_items/components/work_item_state.vue'; import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; +import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemWeight from '~/work_items/components/work_item_weight.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; @@ -32,6 +33,7 @@ describe('WorkItemDetail component', () => { const findWorkItemState = () => wrapper.findComponent(WorkItemState); const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription); const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees); + const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels); const findWorkItemWeight = () => wrapper.findComponent(WorkItemWeight); const createComponent = ({ @@ -203,6 +205,19 @@ describe('WorkItemDetail component', () => { expect(findWorkItemAssignees().exists()).toBe(false); }); + describe('labels widget', () => { + it.each` + description | includeWidgets | exists + ${'renders when widget is returned from API'} | ${true} | ${true} + ${'does not render when widget is not returned from API'} | ${false} | ${false} + `('$description', async ({ includeWidgets, exists }) => { + createComponent({ includeWidgets, workItemsMvc2Enabled: true }); + await waitForPromises(); + + expect(findWorkItemLabels().exists()).toBe(exists); + }); + }); + describe('weight widget', () => { describe('when work_items_mvc_2 feature flag is enabled', () => { describe.each` diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb index 192e48f43e5..9c0f8b77d45 100644 --- a/spec/helpers/avatars_helper_spec.rb +++ b/spec/helpers/avatars_helper_spec.rb @@ -221,48 +221,56 @@ RSpec.describe AvatarsHelper do stub_application_setting(gravatar_enabled?: true) end - it 'returns a generic avatar when email is blank' do - expect(helper.gravatar_icon('')).to match_asset_path(described_class::DEFAULT_AVATAR_PATH) - end + context 'with FIPS not enabled', fips_mode: false do + it 'returns a generic avatar when email is blank' do + expect(helper.gravatar_icon('')).to match_asset_path(described_class::DEFAULT_AVATAR_PATH) + end - it 'returns a valid Gravatar URL' do - stub_config_setting(https: false) + it 'returns a valid Gravatar URL' do + stub_config_setting(https: false) - expect(helper.gravatar_icon(user_email)) - .to match('https://www.gravatar.com/avatar/b58c6f14d292556214bd64909bcdb118') - end + expect(helper.gravatar_icon(user_email)) + .to match('https://www.gravatar.com/avatar/b58c6f14d292556214bd64909bcdb118') + end - it 'uses HTTPs when configured' do - stub_config_setting(https: true) + it 'uses HTTPs when configured' do + stub_config_setting(https: true) - expect(helper.gravatar_icon(user_email)) - .to match('https://secure.gravatar.com') - end + expect(helper.gravatar_icon(user_email)) + .to match('https://secure.gravatar.com') + end - it 'returns custom gravatar path when gravatar_url is set' do - stub_gravatar_setting(plain_url: 'http://example.local/?s=%{size}&hash=%{hash}') + it 'returns custom gravatar path when gravatar_url is set' do + stub_gravatar_setting(plain_url: 'http://example.local/?s=%{size}&hash=%{hash}') - expect(gravatar_icon(user_email, 20)) - .to eq('http://example.local/?s=40&hash=b58c6f14d292556214bd64909bcdb118') - end + expect(gravatar_icon(user_email, 20)) + .to eq('http://example.local/?s=40&hash=b58c6f14d292556214bd64909bcdb118') + end - it 'accepts a custom size argument' do - expect(helper.gravatar_icon(user_email, 64)).to include '?s=128' - end + it 'accepts a custom size argument' do + expect(helper.gravatar_icon(user_email, 64)).to include '?s=128' + end - it 'defaults size to 40@2x when given an invalid size' do - expect(helper.gravatar_icon(user_email, nil)).to include '?s=80' - end + it 'defaults size to 40@2x when given an invalid size' do + expect(helper.gravatar_icon(user_email, nil)).to include '?s=80' + end - it 'accepts a scaling factor' do - expect(helper.gravatar_icon(user_email, 40, 3)).to include '?s=120' - end + it 'accepts a scaling factor' do + expect(helper.gravatar_icon(user_email, 40, 3)).to include '?s=120' + end - it 'ignores case and surrounding whitespace' do - normal = helper.gravatar_icon('foo@example.com') - upcase = helper.gravatar_icon(' FOO@EXAMPLE.COM ') + it 'ignores case and surrounding whitespace' do + normal = helper.gravatar_icon('foo@example.com') + upcase = helper.gravatar_icon(' FOO@EXAMPLE.COM ') - expect(normal).to eq upcase + expect(normal).to eq upcase + end + end + + context 'with FIPS enabled', :fips_mode do + it 'returns a generic avatar' do + expect(helper.gravatar_icon(user_email)).to match_asset_path(described_class::DEFAULT_AVATAR_PATH) + end end end end diff --git a/spec/lib/gitlab/ci/config/entry/processable_spec.rb b/spec/lib/gitlab/ci/config/entry/processable_spec.rb index cc32c57e873..5b9337ede34 100644 --- a/spec/lib/gitlab/ci/config/entry/processable_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/processable_spec.rb @@ -278,13 +278,8 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do context 'when workflow rules is not used' do let(:workflow) { double('workflow', 'has_rules?' => false) } - let(:ci_value_change_for_processable_and_rules_entry) { true } before do - stub_feature_flags( - ci_value_change_for_processable_and_rules_entry: ci_value_change_for_processable_and_rules_entry - ) - entry.compose!(deps) end @@ -308,14 +303,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Processable do it 'raises a warning' do expect(entry.warnings).to contain_exactly(/may allow multiple pipelines/) end - - context 'when the FF ci_value_change_for_processable_and_rules_entry is disabled' do - let(:ci_value_change_for_processable_and_rules_entry) { false } - - it 'raises a warning' do - expect(entry.warnings).to contain_exactly(/may allow multiple pipelines/) - end - end end context 'and its value is `never`' do diff --git a/spec/lib/gitlab/ci/config/entry/rules_spec.rb b/spec/lib/gitlab/ci/config/entry/rules_spec.rb index faae5aa121f..b0871f2345e 100644 --- a/spec/lib/gitlab/ci/config/entry/rules_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/rules_spec.rb @@ -1,13 +1,9 @@ # frozen_string_literal: true require 'fast_spec_helper' -require 'support/helpers/stubbed_feature' -require 'support/helpers/stub_feature_flags' require_dependency 'active_model' RSpec.describe Gitlab::Ci::Config::Entry::Rules do - include StubFeatureFlags - let(:factory) do Gitlab::Config::Entry::Factory.new(described_class) .metadata(metadata) @@ -106,14 +102,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Rules do end it { is_expected.to eq([]) } - - context 'when the FF ci_value_change_for_processable_and_rules_entry is disabled' do - before do - stub_feature_flags(ci_value_change_for_processable_and_rules_entry: false) - end - - it { is_expected.to eq([config]) } - end end context 'with nested rules' do diff --git a/spec/lib/gitlab/issuable/clone/attributes_rewriter_spec.rb b/spec/lib/gitlab/issuable/clone/attributes_rewriter_spec.rb new file mode 100644 index 00000000000..0668ef528fb --- /dev/null +++ b/spec/lib/gitlab/issuable/clone/attributes_rewriter_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Issuable::Clone::AttributesRewriter do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project1) { create(:project, :public, group: group) } + let_it_be(:project2) { create(:project, :public, group: group) } + let_it_be(:original_issue) { create(:issue, project: project1) } + + let(:new_attributes) { described_class.new(user, original_issue, project2).execute } + + context 'setting labels' do + it 'sets labels present in the new project and group labels' do + project1_label_1 = create(:label, title: 'label1', project: project1) + project1_label_2 = create(:label, title: 'label2', project: project1) + project2_label_1 = create(:label, title: 'label1', project: project2) + group_label = create(:group_label, title: 'group_label', group: group) + create(:label, title: 'label3', project: project2) + + original_issue.update!(labels: [project1_label_1, project1_label_2, group_label]) + + expect(new_attributes[:label_ids]).to match_array([project2_label_1.id, group_label.id]) + end + + it 'does not set any labels when not used on the original issue' do + expect(new_attributes[:label_ids]).to be_empty + end + end + + context 'setting milestones' do + it 'sets milestone to nil when old issue milestone is not in the new project' do + milestone = create(:milestone, title: 'milestone', project: project1) + + original_issue.update!(milestone: milestone) + + expect(new_attributes[:milestone_id]).to be_nil + end + + it 'copies the milestone when old issue milestone title is in the new project' do + milestone_project1 = create(:milestone, title: 'milestone', project: project1) + milestone_project2 = create(:milestone, title: 'milestone', project: project2) + + original_issue.update!(milestone: milestone_project1) + + expect(new_attributes[:milestone_id]).to eq(milestone_project2.id) + end + + it 'copies the milestone when old issue milestone is a group milestone' do + milestone = create(:milestone, title: 'milestone', group: group) + + original_issue.update!(milestone: milestone) + + expect(new_attributes[:milestone_id]).to eq(milestone.id) + end + + context 'when include_milestone is false' do + let(:new_attributes) { described_class.new(user, original_issue, project2).execute(include_milestone: false) } + + it 'does not return any milestone' do + milestone = create(:milestone, title: 'milestone', group: group) + + original_issue.update!(milestone: milestone) + + expect(new_attributes[:milestone_id]).to be_nil + end + end + end + + context 'when target parent is a group' do + let(:new_attributes) { described_class.new(user, original_issue, group).execute } + + context 'setting labels' do + let(:project_label1) { create(:label, title: 'label1', project: project1) } + let!(:project_label2) { create(:label, title: 'label2', project: project1) } + let(:group_label1) { create(:group_label, title: 'group_label', group: group) } + let!(:group_label2) { create(:group_label, title: 'label2', group: group) } + + it 'keeps group labels and merges project labels where possible' do + original_issue.update!(labels: [project_label1, project_label2, group_label1]) + + expect(new_attributes[:label_ids]).to match_array([group_label1.id, group_label2.id]) + end + end + end +end diff --git a/spec/lib/gitlab/issuable/clone/copy_resource_events_service_spec.rb b/spec/lib/gitlab/issuable/clone/copy_resource_events_service_spec.rb new file mode 100644 index 00000000000..1700939f49e --- /dev/null +++ b/spec/lib/gitlab/issuable/clone/copy_resource_events_service_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Issuable::Clone::CopyResourceEventsService do + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project1) { create(:project, :public, group: group) } + let_it_be(:project2) { create(:project, :public, group: group) } + let_it_be(:new_issue) { create(:issue, project: project2) } + let_it_be_with_reload(:original_issue) { create(:issue, project: project1) } + + subject { described_class.new(user, original_issue, new_issue) } + + it 'copies the resource label events' do + resource_label_events = create_list(:resource_label_event, 2, issue: original_issue) + + subject.execute + + expected = resource_label_events.map(&:label_id) + + expect(new_issue.resource_label_events.map(&:label_id)).to match_array(expected) + end + + context 'with existing milestone events' do + let!(:milestone1_project1) { create(:milestone, title: 'milestone1', project: project1) } + let!(:milestone2_project1) { create(:milestone, title: 'milestone2', project: project1) } + let!(:milestone3_project1) { create(:milestone, title: 'milestone3', project: project1) } + + let!(:milestone1_project2) { create(:milestone, title: 'milestone1', project: project2) } + let!(:milestone2_project2) { create(:milestone, title: 'milestone2', project: project2) } + + before do + original_issue.update!(milestone: milestone2_project1) + + create_event(milestone1_project1) + create_event(milestone2_project1) + create_event(nil, 'remove') + create_event(milestone3_project1) + end + + it 'copies existing resource milestone events' do + subject.execute + + new_issue_milestone_events = new_issue.reload.resource_milestone_events + expect(new_issue_milestone_events.count).to eq(3) + + expect_milestone_event( + new_issue_milestone_events.first, milestone: milestone1_project2, action: 'add', state: 'opened' + ) + expect_milestone_event( + new_issue_milestone_events.second, milestone: milestone2_project2, action: 'add', state: 'opened' + ) + expect_milestone_event( + new_issue_milestone_events.third, milestone: nil, action: 'remove', state: 'opened' + ) + end + + def create_event(milestone, action = 'add') + create(:resource_milestone_event, issue: original_issue, milestone: milestone, action: action) + end + + def expect_milestone_event(event, expected_attrs) + expect(event.milestone_id).to eq(expected_attrs[:milestone]&.id) + expect(event.action).to eq(expected_attrs[:action]) + expect(event.state).to eq(expected_attrs[:state]) + end + end + + context 'with existing state events' do + let!(:event1) { create(:resource_state_event, issue: original_issue, state: 'opened') } + let!(:event2) { create(:resource_state_event, issue: original_issue, state: 'closed') } + let!(:event3) { create(:resource_state_event, issue: original_issue, state: 'reopened') } + + it 'copies existing state events as expected' do + subject.execute + + state_events = new_issue.reload.resource_state_events + expect(state_events.size).to eq(3) + + expect_state_event(state_events.first, issue: new_issue, state: 'opened') + expect_state_event(state_events.second, issue: new_issue, state: 'closed') + expect_state_event(state_events.third, issue: new_issue, state: 'reopened') + end + + def expect_state_event(event, expected_attrs) + expect(event.issue_id).to eq(expected_attrs[:issue]&.id) + expect(event.state).to eq(expected_attrs[:state]) + end + end +end diff --git a/spec/lib/gitlab/tracking/standard_context_spec.rb b/spec/lib/gitlab/tracking/standard_context_spec.rb index 508b33949a8..cfb83bc0528 100644 --- a/spec/lib/gitlab/tracking/standard_context_spec.rb +++ b/spec/lib/gitlab/tracking/standard_context_spec.rb @@ -93,30 +93,11 @@ RSpec.describe Gitlab::Tracking::StandardContext do end context 'with incorrect argument type' do - context 'when standard_context_type_check FF is disabled' do - before do - stub_feature_flags(standard_context_type_check: false) - end - - subject { described_class.new(project: create(:group)) } - - it 'does not call `track_and_raise_for_dev_exception`' do - expect(Gitlab::ErrorTracking).not_to receive(:track_and_raise_for_dev_exception) - snowplow_context - end - end + subject { described_class.new(project: create(:group)) } - context 'when standard_context_type_check FF is enabled' do - before do - stub_feature_flags(standard_context_type_check: true) - end - - subject { described_class.new(project: create(:group)) } - - it 'does call `track_and_raise_for_dev_exception`' do - expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) - snowplow_context - end + it 'does call `track_and_raise_for_dev_exception`' do + expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + snowplow_context end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index c2f3deb6c06..c3e325c4e6c 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -4286,6 +4286,18 @@ RSpec.describe MergeRequest, factory_default: :keep do end end + describe 'transition to closed' do + context 'with merge error' do + subject { create(:merge_request, merge_error: 'merge error') } + + it 'clears merge error' do + subject.close! + + expect(subject.reload.merge_error).to eq(nil) + end + end + end + describe 'transition to cannot_be_merged' do let(:notification_service) { double(:notification_service) } let(:todo_service) { double(:todo_service) } diff --git a/spec/requests/api/geo_spec.rb b/spec/requests/api/geo_spec.rb index edbca5eb1c6..4e77fa9405c 100644 --- a/spec/requests/api/geo_spec.rb +++ b/spec/requests/api/geo_spec.rb @@ -10,12 +10,24 @@ RSpec.describe API::Geo do include_context 'workhorse headers' + let(:non_proxy_response_schema) do + { + 'type' => 'object', + 'additionalProperties' => false, + 'required' => %w(geo_enabled), + 'properties' => { + 'geo_enabled' => { 'type' => 'boolean' } + } + } + end + context 'with valid auth' do it 'returns empty data' do subject expect(response).to have_gitlab_http_status(:ok) - expect(json_response).to be_empty + expect(json_response).to match_schema(non_proxy_response_schema) + expect(json_response['geo_enabled']).to be_falsey end end diff --git a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb index 8d33f8e1806..b1356bbe6fd 100644 --- a/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb +++ b/spec/requests/api/graphql/mutations/work_items/create_from_task_spec.rb @@ -47,6 +47,7 @@ RSpec.describe "Create a work item from a task in a work item's description" do expect(work_item.description).to eq("- [ ] #{created_work_item.to_reference}+") expect(created_work_item.issue_type).to eq('task') expect(created_work_item.work_item_type.base_type).to eq('task') + expect(created_work_item.work_item_parent).to eq(work_item) expect(mutation_response['workItem']).to include('id' => work_item.to_global_id.to_s) expect(mutation_response['newWorkItem']).to include('id' => created_work_item.to_global_id.to_s) end diff --git a/spec/services/ci/runners/reconcile_existing_runner_versions_service_spec.rb b/spec/services/ci/runners/reconcile_existing_runner_versions_service_spec.rb index fd9a89fe8e2..dc4f76b723f 100644 --- a/spec/services/ci/runners/reconcile_existing_runner_versions_service_spec.rb +++ b/spec/services/ci/runners/reconcile_existing_runner_versions_service_spec.rb @@ -15,7 +15,7 @@ RSpec.describe ::Ci::Runners::ReconcileExistingRunnerVersionsService, '#execute' allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) .to receive(:check_runner_upgrade_status) - .and_return(:recommended) + .and_return({ recommended: ::Gitlab::VersionInfo.new(14, 0, 2) }) end context 'with runner with new version' do @@ -27,7 +27,7 @@ RSpec.describe ::Ci::Runners::ReconcileExistingRunnerVersionsService, '#execute' allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) .to receive(:check_runner_upgrade_status) .with('14.0.2') - .and_return(:not_available) + .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 2) }) .once end @@ -59,7 +59,7 @@ RSpec.describe ::Ci::Runners::ReconcileExistingRunnerVersionsService, '#execute' before do allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) .to receive(:check_runner_upgrade_status) - .and_return(:not_available) + .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 2) }) end it 'deletes orphan ci_runner_versions entry', :aggregate_failures do @@ -81,7 +81,7 @@ RSpec.describe ::Ci::Runners::ReconcileExistingRunnerVersionsService, '#execute' before do allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) .to receive(:check_runner_upgrade_status) - .and_return(:not_available) + .and_return({ not_available: ::Gitlab::VersionInfo.new(14, 0, 1) }) end it 'does not modify ci_runner_versions entries', :aggregate_failures do @@ -101,7 +101,7 @@ RSpec.describe ::Ci::Runners::ReconcileExistingRunnerVersionsService, '#execute' before do allow(::Gitlab::Ci::RunnerUpgradeCheck.instance) .to receive(:check_runner_upgrade_status) - .and_return(:error) + .and_return({ error: ::Gitlab::VersionInfo.new(14, 0, 1) }) end it 'makes no changes to ci_runner_versions', :aggregate_failures do diff --git a/spec/services/issuable/clone/attributes_rewriter_spec.rb b/spec/services/issuable/clone/attributes_rewriter_spec.rb deleted file mode 100644 index 7f434b8b246..00000000000 --- a/spec/services/issuable/clone/attributes_rewriter_spec.rb +++ /dev/null @@ -1,140 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Issuable::Clone::AttributesRewriter do - let(:user) { create(:user) } - let(:group) { create(:group) } - let(:project1) { create(:project, :public, group: group) } - let(:project2) { create(:project, :public, group: group) } - let(:original_issue) { create(:issue, project: project1) } - let(:new_issue) { create(:issue, project: project2) } - - subject { described_class.new(user, original_issue, new_issue) } - - context 'setting labels' do - it 'sets labels present in the new project and group labels' do - project1_label_1 = create(:label, title: 'label1', project: project1) - project1_label_2 = create(:label, title: 'label2', project: project1) - project2_label_1 = create(:label, title: 'label1', project: project2) - group_label = create(:group_label, title: 'group_label', group: group) - create(:label, title: 'label3', project: project2) - - original_issue.update!(labels: [project1_label_1, project1_label_2, group_label]) - - subject.execute - - expect(new_issue.reload.labels).to match_array([project2_label_1, group_label]) - end - - it 'does not set any labels when not used on the original issue' do - subject.execute - - expect(new_issue.reload.labels).to be_empty - end - - it 'copies the resource label events' do - resource_label_events = create_list(:resource_label_event, 2, issue: original_issue) - - subject.execute - - expected = resource_label_events.map(&:label_id) - - expect(new_issue.resource_label_events.map(&:label_id)).to match_array(expected) - end - end - - context 'setting milestones' do - it 'sets milestone to nil when old issue milestone is not in the new project' do - milestone = create(:milestone, title: 'milestone', project: project1) - - original_issue.update!(milestone: milestone) - - subject.execute - - expect(new_issue.reload.milestone).to be_nil - end - - it 'copies the milestone when old issue milestone title is in the new project' do - milestone_project1 = create(:milestone, title: 'milestone', project: project1) - milestone_project2 = create(:milestone, title: 'milestone', project: project2) - - original_issue.update!(milestone: milestone_project1) - - subject.execute - - expect(new_issue.reload.milestone).to eq(milestone_project2) - end - - it 'copies the milestone when old issue milestone is a group milestone' do - milestone = create(:milestone, title: 'milestone', group: group) - - original_issue.update!(milestone: milestone) - - subject.execute - - expect(new_issue.reload.milestone).to eq(milestone) - end - - context 'with existing milestone events' do - let!(:milestone1_project1) { create(:milestone, title: 'milestone1', project: project1) } - let!(:milestone2_project1) { create(:milestone, title: 'milestone2', project: project1) } - let!(:milestone3_project1) { create(:milestone, title: 'milestone3', project: project1) } - - let!(:milestone1_project2) { create(:milestone, title: 'milestone1', project: project2) } - let!(:milestone2_project2) { create(:milestone, title: 'milestone2', project: project2) } - - before do - original_issue.update!(milestone: milestone2_project1) - - create_event(milestone1_project1) - create_event(milestone2_project1) - create_event(nil, 'remove') - create_event(milestone3_project1) - end - - it 'copies existing resource milestone events' do - subject.execute - - new_issue_milestone_events = new_issue.reload.resource_milestone_events - expect(new_issue_milestone_events.count).to eq(3) - - expect_milestone_event(new_issue_milestone_events.first, milestone: milestone1_project2, action: 'add', state: 'opened') - expect_milestone_event(new_issue_milestone_events.second, milestone: milestone2_project2, action: 'add', state: 'opened') - expect_milestone_event(new_issue_milestone_events.third, milestone: nil, action: 'remove', state: 'opened') - end - - def create_event(milestone, action = 'add') - create(:resource_milestone_event, issue: original_issue, milestone: milestone, action: action) - end - - def expect_milestone_event(event, expected_attrs) - expect(event.milestone_id).to eq(expected_attrs[:milestone]&.id) - expect(event.action).to eq(expected_attrs[:action]) - expect(event.state).to eq(expected_attrs[:state]) - end - end - - context 'with existing state events' do - let!(:event1) { create(:resource_state_event, issue: original_issue, state: 'opened') } - let!(:event2) { create(:resource_state_event, issue: original_issue, state: 'closed') } - let!(:event3) { create(:resource_state_event, issue: original_issue, state: 'reopened') } - - it 'copies existing state events as expected' do - subject.execute - - state_events = new_issue.reload.resource_state_events - expect(state_events.size).to eq(3) - - expect_state_event(state_events.first, issue: new_issue, state: 'opened') - expect_state_event(state_events.second, issue: new_issue, state: 'closed') - expect_state_event(state_events.third, issue: new_issue, state: 'reopened') - end - - def expect_state_event(event, expected_attrs) - expect(event.issue_id).to eq(expected_attrs[:issue]&.id) - expect(event.state).to eq(expected_attrs[:state]) - end - end - end -end diff --git a/spec/services/issues/clone_service_spec.rb b/spec/services/issues/clone_service_spec.rb index abbcb1c1d48..858dfc4ab3a 100644 --- a/spec/services/issues/clone_service_spec.rb +++ b/spec/services/issues/clone_service_spec.rb @@ -82,12 +82,14 @@ RSpec.describe Issues::CloneService do expect(new_issue.iid).to be_present end - it 'preserves create time' do - expect(old_issue.created_at.strftime('%D')).to eq new_issue.created_at.strftime('%D') - end + it 'sets created_at of new issue to the time of clone' do + future_time = 5.days.from_now - it 'does not copy system notes' do - expect(new_issue.notes.count).to eq(1) + travel_to(future_time) do + new_issue = clone_service.execute(old_issue, new_project, with_notes: with_notes) + + expect(new_issue.created_at).to be_like_time(future_time) + end end it 'does not set moved_issue' do @@ -105,6 +107,24 @@ RSpec.describe Issues::CloneService do end end + context 'issue with system notes and resource events' do + before do + create(:note, :system, noteable: old_issue, project: old_project) + create(:resource_label_event, label: create(:label, project: old_project), issue: old_issue) + create(:resource_state_event, issue: old_issue, state: :reopened) + create(:resource_milestone_event, issue: old_issue, action: 'remove', milestone_id: nil) + end + + it 'does not copy system notes and resource events' do + new_issue = clone_service.execute(old_issue, new_project) + + # 1 here is for the "cloned from" system note + expect(new_issue.notes.count).to eq(1) + expect(new_issue.resource_state_events).to be_empty + expect(new_issue.resource_milestone_events).to be_empty + end + end + context 'issue with award emoji' do let!(:award_emoji) { create(:award_emoji, awardable: old_issue) } @@ -124,14 +144,27 @@ RSpec.describe Issues::CloneService do create(:issue, title: title, description: description, project: old_project, author: author, milestone: milestone) end - before do - create(:resource_milestone_event, issue: old_issue, milestone: milestone, action: :add) + it 'copies the milestone and creates a resource_milestone_event' do + new_issue = clone_service.execute(old_issue, new_project) + + expect(new_issue.milestone).to eq(milestone) + expect(new_issue.resource_milestone_events.count).to eq(1) + end + end + + context 'issue with label' do + let(:label) { create(:group_label, group: sub_group_1) } + let(:new_project) { create(:project, namespace: sub_group_1) } + + let(:old_issue) do + create(:issue, project: old_project, labels: [label]) end - it 'does not create extra milestone events' do + it 'copies the label and creates a resource_label_event' do new_issue = clone_service.execute(old_issue, new_project) - expect(new_issue.resource_milestone_events.count).to eq(old_issue.resource_milestone_events.count) + expect(new_issue.labels).to contain_exactly(label) + expect(new_issue.resource_label_events.count).to eq(1) end end diff --git a/spec/services/work_items/create_and_link_service_spec.rb b/spec/services/work_items/create_and_link_service_spec.rb index 93c029bdab1..36038768d1a 100644 --- a/spec/services/work_items/create_and_link_service_spec.rb +++ b/spec/services/work_items/create_and_link_service_spec.rb @@ -7,13 +7,16 @@ RSpec.describe WorkItems::CreateAndLinkService do let_it_be(:project) { create(:project, group: group) } let_it_be(:user) { create(:user) } let_it_be(:related_work_item) { create(:work_item, project: project) } + let_it_be(:invalid_parent) { create(:work_item, :task, project: project) } let(:spam_params) { double } let(:link_params) { {} } + let(:params) do { title: 'Awesome work item', - description: 'please fix' + description: 'please fix', + work_item_type_id: WorkItems::Type.default_by_type(:task).id } end @@ -40,32 +43,32 @@ RSpec.describe WorkItems::CreateAndLinkService do end context 'when link params are valid' do - let(:link_params) { { issuable_references: [related_work_item.to_reference] } } + let(:link_params) { { parent_work_item: related_work_item } } it 'creates a work item successfully with links' do expect do service_result end.to change(WorkItem, :count).by(1).and( - change(IssueLink, :count).by(1) + change(WorkItems::ParentLink, :count).by(1) ) end end - context 'when link params are invalid' do - let(:link_params) { { issuable_references: ['invalid reference'] } } + context 'when link creation fails' do + let(:link_params) { { parent_work_item: invalid_parent } } it { is_expected.to be_error } it 'does not create a link and does not rollback transaction' do expect do service_result - end.to not_change(IssueLink, :count).and( + end.to not_change(WorkItems::ParentLink, :count).and( change(WorkItem, :count).by(1) ) end it 'returns a link creation error message' do - expect(service_result.errors).to contain_exactly('No matching issue found. Make sure that you are adding a valid issue URL.') + expect(service_result.errors).to contain_exactly(/Only Issue can be parent of Task./) end end end @@ -84,7 +87,7 @@ RSpec.describe WorkItems::CreateAndLinkService do expect do service_result end.to not_change(WorkItem, :count).and( - not_change(IssueLink, :count) + not_change(WorkItems::ParentLink, :count) ) end diff --git a/spec/services/work_items/create_from_task_service_spec.rb b/spec/services/work_items/create_from_task_service_spec.rb index b4db925f053..7d2dab228b1 100644 --- a/spec/services/work_items/create_from_task_service_spec.rb +++ b/spec/services/work_items/create_from_task_service_spec.rb @@ -32,7 +32,7 @@ RSpec.describe WorkItems::CreateFromTaskService do expect do service_result end.to not_change(WorkItem, :count).and( - not_change(IssueLink, :count) + not_change(WorkItems::ParentLink, :count) ) end end @@ -47,12 +47,14 @@ RSpec.describe WorkItems::CreateFromTaskService do context 'when work item params are valid' do it { is_expected.to be_success } - it 'creates a work item and links it to the original work item successfully' do + it 'creates a work item and creates parent link to the original work item' do expect do service_result end.to change(WorkItem, :count).by(1).and( - change(IssueLink, :count) + change(WorkItems::ParentLink, :count).by(1) ) + + expect(work_item_to_update.reload.work_item_children).not_to be_empty end it 'replaces the original issue markdown description with new work item reference' do @@ -73,7 +75,7 @@ RSpec.describe WorkItems::CreateFromTaskService do expect do service_result end.to not_change(WorkItem, :count).and( - not_change(IssueLink, :count) + not_change(WorkItems::ParentLink, :count) ) end diff --git a/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb index ab04692616a..d42e925ed22 100644 --- a/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb +++ b/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb @@ -89,10 +89,13 @@ RSpec.shared_examples 'clone quick action' do let(:bug) { create(:label, project: project, title: 'bug') } let(:wontfix) { create(:label, project: project, title: 'wontfix') } - let!(:target_milestone) { create(:milestone, title: '1.0', project: target_project) } - before do target_project.add_maintainer(user) + + # create equivalent labels and milestones in the target project + create(:label, project: target_project, title: 'bug') + create(:label, project: target_project, title: 'wontfix') + create(:milestone, title: '1.0', project: target_project) end shared_examples 'applies the commands to issues in both projects, target and source' do diff --git a/workhorse/internal/api/api.go b/workhorse/internal/api/api.go index 3c309887484..aa6d7cf1bc7 100644 --- a/workhorse/internal/api/api.go +++ b/workhorse/internal/api/api.go @@ -65,11 +65,13 @@ func NewAPI(myURL *url.URL, version string, roundTripper http.RoundTripper) *API type GeoProxyEndpointResponse struct { GeoProxyURL string `json:"geo_proxy_url"` GeoProxyExtraData string `json:"geo_proxy_extra_data"` + GeoEnabled bool `json:"geo_enabled"` } type GeoProxyData struct { GeoProxyURL *url.URL GeoProxyExtraData string + GeoEnabled bool } type HandleFunc func(http.ResponseWriter, *http.Request, *Response) @@ -458,5 +460,6 @@ func (api *API) GetGeoProxyData() (*GeoProxyData, error) { return &GeoProxyData{ GeoProxyURL: geoProxyURL, GeoProxyExtraData: response.GeoProxyExtraData, + GeoEnabled: response.GeoEnabled, }, nil } diff --git a/workhorse/internal/upstream/upstream.go b/workhorse/internal/upstream/upstream.go index fb511b5d456..f836e32f06c 100644 --- a/workhorse/internal/upstream/upstream.go +++ b/workhorse/internal/upstream/upstream.go @@ -52,6 +52,7 @@ type upstream struct { geoProxyCableRoute routeEntry geoProxyRoute routeEntry geoProxyPollSleep func(time.Duration) + geoPollerDone chan struct{} accessLogger *logrus.Logger enableGeoProxyFeature bool mu sync.RWMutex @@ -81,6 +82,7 @@ func newUpstream(cfg config.Config, accessLogger *logrus.Logger, routesCallback if up.CableSocket == "" { up.CableSocket = up.Socket } + up.geoPollerDone = make(chan struct{}) up.RoundTripper = roundtripper.NewBackendRoundTripper(up.Backend, up.Socket, up.ProxyHeadersTimeout, cfg.DevelopmentMode) up.CableRoundTripper = roundtripper.NewBackendRoundTripper(up.CableBackend, up.CableSocket, up.ProxyHeadersTimeout, cfg.DevelopmentMode) up.configureURLPrefix() @@ -92,9 +94,7 @@ func newUpstream(cfg config.Config, accessLogger *logrus.Logger, routesCallback routesCallback(&up) - if up.enableGeoProxyFeature { - go up.pollGeoProxyAPI() - } + go up.pollGeoProxyAPI() var correlationOpts []correlation.InboundHandlerOption if cfg.PropagateCorrelationID { @@ -165,10 +165,8 @@ func (u *upstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (u *upstream) findRoute(cleanedPath string, r *http.Request) *routeEntry { - if u.enableGeoProxyFeature { - if route := u.findGeoProxyRoute(cleanedPath, r); route != nil { - return route - } + if route := u.findGeoProxyRoute(cleanedPath, r); route != nil { + return route } for _, ro := range u.Routes { @@ -207,7 +205,15 @@ func (u *upstream) findGeoProxyRoute(cleanedPath string, r *http.Request) *route } func (u *upstream) pollGeoProxyAPI() { + defer close(u.geoPollerDone) + for { + // Check enableGeoProxyFeature every time because `callGeoProxyApi()` can change its value. + // This is can also be disabled through the GEO_SECONDARY_PROXY env var. + if !u.enableGeoProxyFeature { + break + } + u.callGeoProxyAPI() u.geoProxyPollSleep(geoProxyApiPollingInterval) } @@ -221,6 +227,14 @@ func (u *upstream) callGeoProxyAPI() { return } + if !geoProxyData.GeoEnabled { + // When Geo is not enabled, we don't need to proxy, as it unnecessarily polls the + // API, whereas a restart is necessary to enable Geo in the first place; at which + // point we get fresh data from the API. + u.enableGeoProxyFeature = false + return + } + hasProxyDataChanged := false if u.geoProxyBackend.String() != geoProxyData.GeoProxyURL.String() { // URL changed diff --git a/workhorse/internal/upstream/upstream_test.go b/workhorse/internal/upstream/upstream_test.go index f931c1b31b3..21fa7b81fdb 100644 --- a/workhorse/internal/upstream/upstream_test.go +++ b/workhorse/internal/upstream/upstream_test.go @@ -12,9 +12,11 @@ import ( "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" + apipkg "gitlab.com/gitlab-org/gitlab/workhorse/internal/api" "gitlab.com/gitlab-org/gitlab/workhorse/internal/config" "gitlab.com/gitlab-org/gitlab/workhorse/internal/helper" "gitlab.com/gitlab-org/gitlab/workhorse/internal/testhelper" + "gitlab.com/gitlab-org/gitlab/workhorse/internal/upstream/roundtripper" ) const ( @@ -72,6 +74,54 @@ func TestRouting(t *testing.T) { runTestCases(t, ts, testCases) } +func TestPollGeoProxyApiStopsWhenExplicitlyDisabled(t *testing.T) { + up := upstream{ + enableGeoProxyFeature: false, + geoProxyPollSleep: func(time.Duration) {}, + geoPollerDone: make(chan struct{}), + } + + go up.pollGeoProxyAPI() + + select { + case <-up.geoPollerDone: + // happy + case <-time.After(10 * time.Second): + t.Fatal("timeout") + } +} + +func TestPollGeoProxyApiStopsWhenGeoNotEnabled(t *testing.T) { + remoteServer, rsDeferredClose := startRemoteServer("Geo primary") + defer rsDeferredClose() + + geoProxyEndpointResponseBody := `{"geo_enabled":false}` + railsServer, deferredClose := startRailsServer("Local Rails server", &geoProxyEndpointResponseBody) + defer deferredClose() + + cfg := newUpstreamConfig(railsServer.URL) + roundTripper := roundtripper.NewBackendRoundTripper(cfg.Backend, "", 1*time.Minute, true) + remoteServerUrl := helper.URLMustParse(remoteServer.URL) + + up := upstream{ + Config: *cfg, + RoundTripper: roundTripper, + APIClient: apipkg.NewAPI(remoteServerUrl, "", roundTripper), + enableGeoProxyFeature: true, + geoProxyPollSleep: func(time.Duration) {}, + geoPollerDone: make(chan struct{}), + } + + go up.pollGeoProxyAPI() + + select { + case <-up.geoPollerDone: + // happy + case <-time.After(10 * time.Second): + t.Fatal("timeout") + } +} + // This test can be removed when the environment variable `GEO_SECONDARY_PROXY` is removed func TestGeoProxyFeatureDisabledOnGeoSecondarySite(t *testing.T) { // We could just not set up the primary, but then we'd have to assert @@ -79,7 +129,7 @@ func TestGeoProxyFeatureDisabledOnGeoSecondarySite(t *testing.T) { remoteServer, rsDeferredClose := startRemoteServer("Geo primary") defer rsDeferredClose() - geoProxyEndpointResponseBody := fmt.Sprintf(`{"geo_proxy_url":"%v"}`, remoteServer.URL) + geoProxyEndpointResponseBody := fmt.Sprintf(`{"geo_enabled":true,"geo_proxy_url":"%v"}`, remoteServer.URL) railsServer, deferredClose := startRailsServer("Local Rails server", &geoProxyEndpointResponseBody) defer deferredClose() @@ -109,7 +159,7 @@ func TestGeoProxyFeatureEnabledOnGeoSecondarySite(t *testing.T) { // This test can be removed when the environment variable `GEO_SECONDARY_PROXY` is removed func TestGeoProxyFeatureDisabledOnNonGeoSecondarySite(t *testing.T) { - geoProxyEndpointResponseBody := "{}" + geoProxyEndpointResponseBody := `{"geo_enabled":false}` railsServer, deferredClose := startRailsServer("Local Rails server", &geoProxyEndpointResponseBody) defer deferredClose() @@ -127,7 +177,7 @@ func TestGeoProxyFeatureDisabledOnNonGeoSecondarySite(t *testing.T) { } func TestGeoProxyFeatureEnabledOnNonGeoSecondarySite(t *testing.T) { - geoProxyEndpointResponseBody := "{}" + geoProxyEndpointResponseBody := `{"geo_enabled":false}` railsServer, deferredClose := startRailsServer("Local Rails server", &geoProxyEndpointResponseBody) defer deferredClose() @@ -166,8 +216,8 @@ func TestGeoProxyFeatureEnablingAndDisabling(t *testing.T) { remoteServer, rsDeferredClose := startRemoteServer("Geo primary") defer rsDeferredClose() - geoProxyEndpointEnabledResponseBody := fmt.Sprintf(`{"geo_proxy_url":"%v"}`, remoteServer.URL) - geoProxyEndpointDisabledResponseBody := "{}" + geoProxyEndpointEnabledResponseBody := fmt.Sprintf(`{"geo_enabled":true,"geo_proxy_url":"%v"}`, remoteServer.URL) + geoProxyEndpointDisabledResponseBody := `{"geo_enabled":true}` geoProxyEndpointResponseBody := geoProxyEndpointEnabledResponseBody railsServer, deferredClose := startRailsServer("Local Rails server", &geoProxyEndpointResponseBody) @@ -218,9 +268,9 @@ func TestGeoProxyUpdatesExtraDataWhenChanged(t *testing.T) { })) defer remoteServer.Close() - geoProxyEndpointExtraData1 := fmt.Sprintf(`{"geo_proxy_url":"%v","geo_proxy_extra_data":"data1"}`, remoteServer.URL) - geoProxyEndpointExtraData2 := fmt.Sprintf(`{"geo_proxy_url":"%v","geo_proxy_extra_data":"data2"}`, remoteServer.URL) - geoProxyEndpointExtraData3 := fmt.Sprintf(`{"geo_proxy_url":"%v"}`, remoteServer.URL) + geoProxyEndpointExtraData1 := fmt.Sprintf(`{"geo_enabled":true,"geo_proxy_url":"%v","geo_proxy_extra_data":"data1"}`, remoteServer.URL) + geoProxyEndpointExtraData2 := fmt.Sprintf(`{"geo_enabled":true,"geo_proxy_url":"%v","geo_proxy_extra_data":"data2"}`, remoteServer.URL) + geoProxyEndpointExtraData3 := fmt.Sprintf(`{"geo_enabled":true,"geo_proxy_url":"%v"}`, remoteServer.URL) geoProxyEndpointResponseBody := geoProxyEndpointExtraData1 expectedGeoProxyExtraData = "data1" @@ -253,8 +303,8 @@ func TestGeoProxySetsCustomHeader(t *testing.T) { json string extraData string }{ - {"no extra data", `{"geo_proxy_url":"%v"}`, ""}, - {"with extra data", `{"geo_proxy_url":"%v","geo_proxy_extra_data":"extra-geo-data"}`, "extra-geo-data"}, + {"no extra data", `{"geo_enabled":true,"geo_proxy_url":"%v"}`, ""}, + {"with extra data", `{"geo_enabled":true,"geo_proxy_url":"%v","geo_proxy_extra_data":"extra-geo-data"}`, "extra-geo-data"}, } for _, tc := range testCases { @@ -299,7 +349,7 @@ func runTestCasesWithGeoProxyEnabled(t *testing.T, testCases []testCase) { remoteServer, rsDeferredClose := startRemoteServer("Geo primary") defer rsDeferredClose() - geoProxyEndpointResponseBody := fmt.Sprintf(`{"geo_proxy_url":"%v"}`, remoteServer.URL) + geoProxyEndpointResponseBody := fmt.Sprintf(`{"geo_enabled":true,"geo_proxy_url":"%v"}`, remoteServer.URL) railsServer, deferredClose := startRailsServer("Local Rails server", &geoProxyEndpointResponseBody) defer deferredClose() |